From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- browser/components/.eslintrc.js | 13 + browser/components/BrowserComponents.manifest | 10 + browser/components/BrowserContentHandler.sys.mjs | 1500 + browser/components/BrowserGlue.sys.mjs | 6595 ++++ browser/components/StartupRecorder.sys.mjs | 228 + browser/components/about/AboutRedirector.cpp | 309 + browser/components/about/AboutRedirector.h | 30 + browser/components/about/components.conf | 49 + browser/components/about/moz.build | 32 + .../components/about/test/unit/test_getURIFlags.js | 14 + browser/components/about/test/unit/xpcshell.toml | 7 + .../aboutlogins/AboutLoginsChild.sys.mjs | 313 + .../aboutlogins/AboutLoginsParent.sys.mjs | 884 + .../components/aboutlogins/LoginBreaches.sys.mjs | 171 + .../components/aboutlogins/content/aboutLogins.css | 99 + .../aboutlogins/content/aboutLogins.html | 379 + .../components/aboutlogins/content/aboutLogins.mjs | 285 + .../content/aboutLoginsImportReport.css | 125 + .../content/aboutLoginsImportReport.html | 103 + .../content/aboutLoginsImportReport.mjs | 83 + .../aboutlogins/content/aboutLoginsUtils.mjs | 72 + browser/components/aboutlogins/content/common.css | 9 + .../content/components/confirmation-dialog.css | 73 + .../content/components/confirmation-dialog.mjs | 105 + .../content/components/fxaccounts-button.css | 74 + .../content/components/fxaccounts-button.mjs | 83 + .../content/components/generic-dialog.css | 64 + .../content/components/generic-dialog.mjs | 63 + .../content/components/import-details-row.mjs | 60 + .../content/components/import-error-dialog.css | 28 + .../content/components/import-error-dialog.mjs | 59 + .../content/components/import-summary-dialog.css | 42 + .../content/components/import-summary-dialog.mjs | 72 + .../content/components/input-field/input-field.css | 60 + .../content/components/input-field/input-field.mjs | 32 + .../components/input-field/input-field.stories.mjs | 133 + .../components/input-field/login-origin-field.mjs | 43 + .../input-field/login-password-field.mjs | 84 + .../input-field/login-username-field.mjs | 30 + .../aboutlogins/content/components/login-alert.css | 81 + .../aboutlogins/content/components/login-alert.mjs | 150 + .../content/components/login-alert.stories.mjs | 79 + .../content/components/login-command-button.css | 34 + .../content/components/login-command-button.mjs | 187 + .../content/components/login-filter.css | 29 + .../content/components/login-filter.mjs | 99 + .../aboutlogins/content/components/login-intro.css | 23 + .../aboutlogins/content/components/login-intro.mjs | 65 + .../aboutlogins/content/components/login-item.css | 328 + .../aboutlogins/content/components/login-item.mjs | 1038 + .../content/components/login-list-item.mjs | 34 + .../content/components/login-list-item.stories.mjs | 62 + .../content/components/login-list-lit-item.css | 81 + .../content/components/login-list-lit-item.mjs | 169 + .../content/components/login-list-section.mjs | 34 + .../aboutlogins/content/components/login-list.css | 163 + .../aboutlogins/content/components/login-list.mjs | 923 + .../content/components/login-message-popup.css | 47 + .../content/components/login-message-popup.mjs | 59 + .../content/components/login-timeline.css | 62 + .../content/components/login-timeline.mjs | 79 + .../aboutlogins/content/components/menu-button.css | 93 + .../aboutlogins/content/components/menu-button.mjs | 183 + .../content/components/remove-logins-dialog.css | 102 + .../content/components/remove-logins-dialog.mjs | 117 + .../aboutlogins/content/icons/breached-website.svg | 6 + .../content/icons/intro-illustration.svg | 62 + .../aboutlogins/content/icons/password-hide.svg | 8 + .../aboutlogins/content/icons/password.svg | 8 + .../content/icons/vulnerable-password.svg | 6 + .../aboutlogins/content/utils/controllers.mjs | 18 + .../aboutlogins/content/utils/keypress.mjs | 42 + browser/components/aboutlogins/jar.mn | 54 + browser/components/aboutlogins/moz.build | 23 + .../tests/browser/AboutLoginsTestUtils.sys.mjs | 107 + .../aboutlogins/tests/browser/browser.toml | 88 + .../browser_aaa_eventTelemetry_run_first.js | 267 + .../browser_alertDismissedAfterChangingPassword.js | 227 + .../browser_breachAlertShowingForAddedLogin.js | 123 + .../tests/browser/browser_confirmDeleteDialog.js | 128 + .../tests/browser/browser_contextmenuFillLogins.js | 185 + .../tests/browser/browser_copyToClipboardButton.js | 118 + .../tests/browser/browser_createLogin.js | 558 + .../tests/browser/browser_deleteLogin.js | 182 + .../tests/browser/browser_fxAccounts.js | 96 + .../tests/browser/browser_loginFilter.js | 61 + .../tests/browser/browser_loginItemErrors.js | 153 + .../tests/browser/browser_loginListChanges.js | 144 + .../browser/browser_loginSortOrderRestored.js | 172 + .../tests/browser/browser_noLoginsView.js | 199 + .../tests/browser/browser_openExport.js | 149 + .../tests/browser/browser_openFiltered.js | 298 + .../tests/browser/browser_openImport.js | 60 + .../tests/browser/browser_openImportCSV.js | 411 + .../tests/browser/browser_openPreferences.js | 82 + .../browser/browser_openPreferencesExternal.js | 65 + .../aboutlogins/tests/browser/browser_openSite.js | 94 + .../tests/browser/browser_osAuthDialog.js | 165 + .../tests/browser/browser_primaryPassword.js | 272 + .../tests/browser/browser_removeAllDialog.js | 558 + .../tests/browser/browser_sessionRestore.js | 62 + .../aboutlogins/tests/browser/browser_tabKeyNav.js | 277 + .../tests/browser/browser_updateLogin.js | 431 + ...rowser_vulnerableLoginAddedInSecondaryWindow.js | 224 + .../components/aboutlogins/tests/browser/head.js | 225 + .../aboutlogins/tests/chrome/.eslintrc.js | 16 + .../aboutlogins/tests/chrome/aboutlogins_common.js | 98 + .../aboutlogins/tests/chrome/chrome.toml | 16 + .../tests/chrome/test_confirm_delete_dialog.html | 127 + .../tests/chrome/test_fxaccounts_button.html | 96 + .../tests/chrome/test_login_filter.html | 178 + .../aboutlogins/tests/chrome/test_login_item.html | 577 + .../aboutlogins/tests/chrome/test_login_list.html | 712 + .../aboutlogins/tests/chrome/test_menu_button.html | 260 + browser/components/aboutlogins/tests/unit/head.js | 22 + .../unit/test_getPotentialBreachesByLoginGUID.js | 327 + .../aboutlogins/tests/unit/xpcshell.toml | 7 + browser/components/aboutwelcome/.eslintrc.js | 185 + .../aboutwelcome/actors/AboutWelcomeChild.sys.mjs | 898 + .../aboutwelcome/actors/AboutWelcomeParent.sys.mjs | 299 + .../components/aboutwelcome/assets/confetti.svg | 55 + .../aboutwelcome/assets/device-migration.svg | 36 + .../aboutwelcome/assets/fox-doodle-tail.png | Bin 0 -> 34903 bytes .../assets/fox-doodle-waving-static.png | Bin 0 -> 10017 bytes .../aboutwelcome/assets/fox-doodle-waving.gif | Bin 0 -> 45426 bytes browser/components/aboutwelcome/assets/heart.webp | Bin 0 -> 100396 bytes .../components/aboutwelcome/assets/long-zap.svg | 4 + .../assets/mobile-download-qr-existing-user-cn.svg | 5 + .../assets/mobile-download-qr-existing-user.svg | 7 + .../assets/mobile-download-qr-new-user-cn.svg | 4 + .../assets/mobile-download-qr-new-user.svg | 7 + .../aboutwelcome/assets/mr-amo-collection.svg | 4 + .../aboutwelcome/assets/mr-gratitude.svg | 4 + .../components/aboutwelcome/assets/mr-import.svg | 4 + .../aboutwelcome/assets/mr-mobilecrosspromo.svg | 4 + .../aboutwelcome/assets/mr-pinprivate.svg | 4 + .../aboutwelcome/assets/mr-pintaskbar.svg | 4 + .../aboutwelcome/assets/mr-privacysegmentation.svg | 4 + .../assets/mr-rtamo-background-image.svg | 4 + .../aboutwelcome/assets/mr-settodefault.svg | 4 + .../components/aboutwelcome/assets/noodle-C.svg | 6 + .../aboutwelcome/assets/noodle-outline-L.svg | 13 + .../aboutwelcome/assets/noodle-solid-L.svg | 6 + .../aboutwelcome/assets/person-typing.svg | 4 + .../components/aboutwelcome/assets/short-zap.svg | 4 + .../aboutwelcome/content-src/aboutwelcome.jsx | 144 + .../aboutwelcome/content-src/aboutwelcome.scss | 1940 + .../content-src/components/AdditionalCTA.jsx | 42 + .../content-src/components/AddonsPicker.jsx | 116 + .../content-src/components/CTAParagraph.jsx | 45 + .../components/EmbeddedMigrationWizard.jsx | 40 + .../content-src/components/HelpText.jsx | 54 + .../content-src/components/HeroImage.jsx | 26 + .../content-src/components/LanguageSwitcher.jsx | 308 + .../content-src/components/LinkParagraph.jsx | 59 + .../content-src/components/MRColorways.jsx | 200 + .../content-src/components/MSLocalized.jsx | 114 + .../content-src/components/MobileDownloads.jsx | 73 + .../content-src/components/MultiSelect.jsx | 158 + .../components/MultiStageAboutWelcome.jsx | 568 + .../components/MultiStageProtonScreen.jsx | 620 + .../content-src/components/OnboardingVideo.jsx | 34 + .../content-src/components/ReturnToAMO.jsx | 105 + .../content-src/components/SubmenuButton.jsx | 149 + .../aboutwelcome/content-src/components/Themes.jsx | 52 + .../aboutwelcome/content-src/components/Zap.jsx | 60 + .../content-src/lib/aboutwelcome-utils.mjs | 122 + .../aboutwelcome/content-src/lib/addUtmParams.mjs | 32 + .../aboutwelcome/content/aboutwelcome.bundle.js | 2678 ++ .../aboutwelcome/content/aboutwelcome.css | 2536 ++ .../aboutwelcome/content/aboutwelcome.html | 50 + .../components/aboutwelcome/content/onboarding.ftl | 13 + browser/components/aboutwelcome/docs/index.rst | 97 + browser/components/aboutwelcome/jar.mn | 38 + browser/components/aboutwelcome/karma.mc.config.js | 246 + .../aboutwelcome/modules/AWScreenUtils.sys.mjs | 74 + .../modules/AboutWelcomeDefaults.sys.mjs | 902 + .../modules/AboutWelcomeTelemetry.sys.mjs | 276 + browser/components/aboutwelcome/moz.build | 25 + browser/components/aboutwelcome/package-lock.json | 11051 ++++++ browser/components/aboutwelcome/package.json | 80 + .../aboutwelcome/tests/browser/browser.toml | 57 + .../browser/browser_aboutwelcome_attribution.js | 214 + .../browser_aboutwelcome_configurable_ui.js | 724 + .../browser_aboutwelcome_fxa_signin_flow.js | 303 + .../tests/browser/browser_aboutwelcome_glean.js | 162 + .../tests/browser/browser_aboutwelcome_import.js | 94 + .../browser_aboutwelcome_mobile_downloads.js | 112 + ...browser_aboutwelcome_multistage_addonspicker.js | 178 + .../browser_aboutwelcome_multistage_default.js | 794 + ...rowser_aboutwelcome_multistage_experimentAPI.js | 641 + ...ser_aboutwelcome_multistage_languageSwitcher.js | 708 + .../browser/browser_aboutwelcome_multistage_mr.js | 770 + .../browser_aboutwelcome_multistage_video.js | 97 + .../tests/browser/browser_aboutwelcome_observer.js | 73 + .../tests/browser/browser_aboutwelcome_rtamo.js | 299 + .../browser_aboutwelcome_screen_targeting.js | 274 + .../browser_aboutwelcome_upgrade_multistage_mr.js | 320 + .../components/aboutwelcome/tests/browser/head.js | 149 + .../aboutwelcome/tests/unit/AWScreenUtils.test.jsx | 140 + .../aboutwelcome/tests/unit/CTAParagraph.test.jsx | 49 + .../aboutwelcome/tests/unit/HelpText.test.jsx | 41 + .../aboutwelcome/tests/unit/HeroImage.test.jsx | 40 + .../aboutwelcome/tests/unit/LinkParagraph.test.jsx | 102 + .../aboutwelcome/tests/unit/MRColorways.test.jsx | 328 + .../aboutwelcome/tests/unit/MSLocalized.test.jsx | 48 + .../tests/unit/MobileDownloads.test.jsx | 69 + .../aboutwelcome/tests/unit/MultiSelect.test.jsx | 221 + .../tests/unit/MultiStageAWProton.test.jsx | 571 + .../tests/unit/MultiStageAboutWelcome.test.jsx | 859 + .../tests/unit/OnboardingVideoTest.test.jsx | 45 + .../aboutwelcome/tests/unit/addUtmParams.test.js | 34 + .../aboutwelcome/tests/unit/unit-entry.js | 716 + .../aboutwelcome/webpack.aboutwelcome.config.js | 24 + browser/components/aboutwelcome/yamscripts.yml | 45 + browser/components/asrouter/.eslintrc.js | 155 + browser/components/asrouter/README.md | 23 + .../asrouter/actors/ASRouterChild.sys.mjs | 123 + .../asrouter/actors/ASRouterParent.sys.mjs | 98 + browser/components/asrouter/bin/import-rollouts.js | 369 + .../asrouter/content-src/asrouter-utils.js | 79 + .../components/ASRouterAdmin/ASRouterAdmin.jsx | 1498 + .../components/ASRouterAdmin/ASRouterAdmin.scss | 353 + .../components/ASRouterAdmin/CopyButton.jsx | 33 + .../ASRouterAdmin/ImpressionsSection.jsx | 146 + .../components/ASRouterAdmin/SimpleHashRouter.jsx | 35 + .../content-src/components/Button/Button.jsx | 32 + .../content-src/components/Button/_Button.scss | 51 + .../ConditionalWrapper/ConditionalWrapper.jsx | 9 + .../ImpressionsWrapper/ImpressionsWrapper.jsx | 76 + .../BackgroundTaskMessagingExperiment.schema.json | 305 + .../content-src/schemas/FxMSCommon.schema.json | 128 + .../schemas/MessagingExperiment.schema.json | 1366 + .../schemas/corpus/ReachExperiments.messages.json | 15 + .../content-src/schemas/extract-test-corpus.js | 65 + .../asrouter/content-src/schemas/make-schemas.py | 472 + .../asrouter/content-src/schemas/message-format.md | 111 + .../content-src/schemas/message-group.schema.json | 64 + .../schemas/provider-response.schema.json | 67 + .../content-src/styles/_feature-callout-theme.scss | 92 + .../content-src/styles/_feature-callout.scss | 775 + .../asrouter/content-src/styles/_shopping.scss | 209 + .../CFR/templates/CFRUrlbarChiclet.schema.json | 66 + .../CFR/templates/ExtensionDoorhanger.schema.json | 320 + .../templates/CFR/templates/InfoBar.schema.json | 89 + .../OnboardingMessage/Spotlight.schema.json | 66 + .../ToolbarBadgeMessage.schema.json | 45 + .../OnboardingMessage/UpdateAction.schema.json | 47 + .../OnboardingMessage/WhatsNewMessage.schema.json | 73 + .../PBNewtab/NewtabPromoMessage.schema.json | 153 + .../ToastNotification.schema.json | 113 + .../asrouter/content/asrouter-admin.bundle.js | 1936 + .../asrouter/content/asrouter-admin.html | 38 + .../components/ASRouterAdmin/ASRouterAdmin.css | 546 + browser/components/asrouter/content/render.js | 7 + browser/components/asrouter/docs/about-welcome.md | 105 + .../components/asrouter/docs/aboutwelcome-1.png | Bin 0 -> 91928 bytes .../components/asrouter/docs/aboutwelcome-2.png | Bin 0 -> 103305 bytes .../asrouter/docs/aboutwelcome-res-1.png | Bin 0 -> 86697 bytes .../asrouter/docs/aboutwelcome-res-2.png | Bin 0 -> 104842 bytes .../components/asrouter/docs/cfr-doorhanger.png | Bin 0 -> 70244 bytes .../asrouter/docs/cfr_doorhanger_screenshot.png | Bin 0 -> 257709 bytes .../docs/contextual-feature-recommendation.md | 83 + browser/components/asrouter/docs/debugging-docs.md | 32 + .../components/asrouter/docs/debugging-guide.png | Bin 0 -> 247644 bytes .../components/asrouter/docs/feature-callout.md | 614 + .../components/asrouter/docs/feature-callout.png | Bin 0 -> 188755 bytes browser/components/asrouter/docs/first-run.md | 68 + browser/components/asrouter/docs/index.rst | 108 + browser/components/asrouter/docs/infobar.png | Bin 0 -> 106424 bytes browser/components/asrouter/docs/infobars.md | 60 + .../asrouter/docs/message-routing-overview.png | Bin 0 -> 50250 bytes browser/components/asrouter/docs/moments-page.md | 64 + browser/components/asrouter/docs/moments.png | 0 .../components/asrouter/docs/private-browsing.md | 59 + .../components/asrouter/docs/private-browsing.png | Bin 0 -> 139858 bytes browser/components/asrouter/docs/remote_cfr.md | 82 + browser/components/asrouter/docs/selected-PB.png | Bin 0 -> 24388 bytes .../asrouter/docs/simple-cfr-template.rst | 37 + browser/components/asrouter/docs/spotlight.md | 90 + browser/components/asrouter/docs/spotlight.png | Bin 0 -> 52974 bytes .../asrouter/docs/targeting-attributes.md | 1033 + .../components/asrouter/docs/targeting-guide.md | 37 + browser/components/asrouter/docs/telemetry.md | 90 + browser/components/asrouter/jar.mn | 11 + browser/components/asrouter/karma.mc.config.js | 214 + .../components/asrouter/modules/ASRouter.sys.mjs | 2079 + .../asrouter/modules/ASRouterDefaultConfig.sys.mjs | 64 + .../asrouter/modules/ASRouterNewTabHook.sys.mjs | 117 + .../ASRouterParentProcessMessageHandler.sys.mjs | 171 + .../asrouter/modules/ASRouterPreferences.sys.mjs | 241 + .../asrouter/modules/ASRouterTargeting.sys.mjs | 1308 + .../modules/ASRouterTriggerListeners.sys.mjs | 1439 + .../asrouter/modules/ActorConstants.sys.mjs | 49 + .../asrouter/modules/CFRMessageProvider.sys.mjs | 820 + .../asrouter/modules/CFRPageActions.sys.mjs | 1086 + .../asrouter/modules/FeatureCallout.sys.mjs | 2100 + .../asrouter/modules/FeatureCalloutBroker.sys.mjs | 215 + .../modules/FeatureCalloutMessages.sys.mjs | 1299 + .../components/asrouter/modules/InfoBar.sys.mjs | 169 + .../modules/MessagingExperimentConstants.sys.mjs | 37 + .../asrouter/modules/MomentsPageHub.sys.mjs | 171 + .../modules/OnboardingMessageProvider.sys.mjs | 1414 + .../asrouter/modules/PageEventManager.sys.mjs | 135 + .../asrouter/modules/PanelTestProvider.sys.mjs | 771 + .../components/asrouter/modules/RemoteL10n.sys.mjs | 249 + .../components/asrouter/modules/Spotlight.sys.mjs | 78 + .../asrouter/modules/ToastNotification.sys.mjs | 138 + .../asrouter/modules/ToolbarBadgeHub.sys.mjs | 308 + .../asrouter/modules/ToolbarPanelHub.sys.mjs | 544 + browser/components/asrouter/moz.build | 67 + browser/components/asrouter/package-lock.json | 11917 ++++++ browser/components/asrouter/package.json | 82 + .../tests/InflightAssetsMessageProvider.sys.mjs | 340 + .../tests/NimbusRolloutMessageProvider.sys.mjs | 199 + .../components/asrouter/tests/browser/browser.toml | 44 + .../tests/browser/browser_asrouter_bug1761522.js | 234 + .../tests/browser/browser_asrouter_bug1800087.js | 48 + .../asrouter/tests/browser/browser_asrouter_cfr.js | 932 + .../browser_asrouter_experimentsAPILoader.js | 505 + .../browser/browser_asrouter_group_frequency.js | 188 + .../browser/browser_asrouter_group_userprefs.js | 158 + .../tests/browser/browser_asrouter_infobar.js | 223 + .../browser/browser_asrouter_momentspagehub.js | 116 + .../tests/browser/browser_asrouter_targeting.js | 1706 + .../browser/browser_asrouter_toast_notification.js | 139 + .../tests/browser/browser_asrouter_toolbarbadge.js | 149 + .../browser/browser_feature_callout_in_chrome.js | 1122 + .../tests/browser/browser_feature_callout_panel.js | 430 + .../tests/browser/browser_trigger_listeners.js | 430 + browser/components/asrouter/tests/browser/head.js | 66 + .../asrouter/tests/unit/ASRouter.test.js | 2870 ++ .../asrouter/tests/unit/ASRouterChild.test.js | 71 + .../asrouter/tests/unit/ASRouterNewTabHook.test.js | 153 + .../asrouter/tests/unit/ASRouterParent.test.js | 83 + .../ASRouterParentProcessMessageHandler.test.js | 428 + .../tests/unit/ASRouterPreferences.test.js | 480 + .../asrouter/tests/unit/ASRouterTargeting.test.js | 574 + .../tests/unit/ASRouterTriggerListeners.test.js | 833 + .../asrouter/tests/unit/CFRMessageProvider.test.js | 32 + .../asrouter/tests/unit/CFRPageActions.test.js | 1414 + .../asrouter/tests/unit/MessageLoaderUtils.test.js | 459 + .../asrouter/tests/unit/ModalOverlay.test.jsx | 69 + .../asrouter/tests/unit/MomentsPageHub.test.js | 336 + .../asrouter/tests/unit/RemoteL10n.test.js | 217 + .../asrouter/tests/unit/TargetingDocs.test.js | 88 + .../asrouter/tests/unit/ToolbarBadgeHub.test.js | 652 + .../asrouter/tests/unit/ToolbarPanelHub.test.js | 762 + .../asrouter/tests/unit/asrouter-utils.test.js | 118 + .../components/asrouter/tests/unit/constants.js | 131 + .../content-src/components/ASRouterAdmin.test.jsx | 262 + .../unit/templates/ExtensionDoorhanger.test.jsx | 112 + .../components/asrouter/tests/unit/unit-entry.js | 727 + browser/components/asrouter/tests/xpcshell/head.js | 98 + .../xpcshell/test_ASRouterTargeting_attribution.js | 94 + .../xpcshell/test_ASRouterTargeting_snapshot.js | 172 + .../test_ASRouter_getTargetingParameters.js | 73 + .../tests/xpcshell/test_CFRMessageProvider.js | 32 + .../xpcshell/test_InflightAssetsMessageProvider.js | 41 + .../xpcshell/test_NimbusRolloutMessageProvider.js | 41 + .../xpcshell/test_OnboardingMessageProvider.js | 229 + .../tests/xpcshell/test_PanelTestProvider.js | 84 + .../tests/xpcshell/test_reach_experiments.js | 97 + .../tests/xpcshell/test_remoteExperiments.js | 37 + .../asrouter/tests/xpcshell/xpcshell.toml | 24 + .../asrouter/webpack.asrouter-admin.config.js | 35 + browser/components/asrouter/yamscripts.yml | 47 + .../components/attribution/AttributionCode.sys.mjs | 388 + .../components/attribution/MacAttribution.sys.mjs | 49 + browser/components/attribution/docs/index.rst | 143 + browser/components/attribution/moz.build | 25 + .../attribution/test/browser/browser.toml | 10 + .../browser_AttributionCode_Mac_telemetry.js | 121 + .../browser/browser_AttributionCode_telemetry.js | 94 + .../components/attribution/test/browser/head.js | 25 + .../components/attribution/test/xpcshell/head.js | 136 + .../test/xpcshell/test_AttributionCode.js | 153 + .../test/xpcshell/test_MacAttribution.js | 137 + .../test/xpcshell/test_attribution_parsing.js | 44 + .../attribution/test/xpcshell/xpcshell.toml | 14 + browser/components/build/components.conf | 49 + browser/components/build/moz.build | 22 + browser/components/build/nsBrowserCompsCID.h | 41 + browser/components/components.conf | 70 + .../content/ContentAnalysis.sys.mjs | 617 + browser/components/contentanalysis/moz.build | 12 + .../contextualidentity/content/briefcase.svg | 11 + .../components/contextualidentity/content/cart.svg | 13 + .../contextualidentity/content/chill.svg | 14 + .../contextualidentity/content/circle.svg | 8 + .../contextualidentity/content/dollar.svg | 14 + .../contextualidentity/content/fence.svg | 7 + .../contextualidentity/content/fingerprint.svg | 8 + .../components/contextualidentity/content/food.svg | 10 + .../contextualidentity/content/fruit.svg | 10 + .../components/contextualidentity/content/gift.svg | 12 + .../components/contextualidentity/content/pet.svg | 12 + .../components/contextualidentity/content/tree.svg | 10 + .../contextualidentity/content/usercontext.css | 139 + .../contextualidentity/content/vacation.svg | 20 + browser/components/contextualidentity/jar.mn | 21 + browser/components/contextualidentity/moz.build | 14 + .../contextualidentity/test/browser/blank.html | 2 + .../contextualidentity/test/browser/browser.toml | 89 + .../test/browser/browser_aboutURLs.js | 70 + .../test/browser/browser_blobUrl.js | 92 + .../test/browser/browser_broadcastchannel.js | 70 + .../test/browser/browser_count_and_remove.js | 107 + .../contextualidentity/test/browser/browser_eme.js | 210 + .../test/browser/browser_favicon.js | 136 + .../browser_forgetAPI_EME_forgetThisSite.js | 246 + ...getAPI_cookie_getCookiesWithOriginAttributes.js | 82 + ...er_forgetAPI_quota_clearStoragesForPrincipal.js | 152 + .../test/browser/browser_forgetaboutsite.js | 414 + .../test/browser/browser_guessusercontext.js | 115 + .../test/browser/browser_imageCache.js | 57 + .../test/browser/browser_middleClick.js | 50 + .../test/browser/browser_newtabButton.js | 214 + .../test/browser/browser_originattrs_reopenin.js | 184 + .../test/browser/browser_relatedTab.js | 94 + .../test/browser/browser_reopenIn.js | 164 + ...owser_restore_getCookiesWithOriginAttributes.js | 109 + .../test/browser/browser_saveLink.js | 134 + .../test/browser/browser_serviceworkers.js | 121 + .../browser_switchTab_across_user_context.js | 141 + .../test/browser/browser_tab_color_update.js | 42 + .../test/browser/browser_usercontext.js | 88 + .../browser/browser_usercontextid_new_window.js | 93 + .../test/browser/browser_usercontextid_tabdrop.js | 181 + .../test/browser/browser_windowName.js | 80 + .../test/browser/browser_windowOpen.js | 41 + .../test/browser/empty_file.html | 5 + .../test/browser/favicon-normal32.png | Bin 0 -> 344 bytes .../browser/file_reflect_cookie_into_title.html | 22 + .../test/browser/file_set_storages.html | 41 + .../contextualidentity/test/browser/head.js | 68 + .../contextualidentity/test/browser/saveLink.sjs | 53 + .../test/browser/serviceworker.html | 12 + .../contextualidentity/test/browser/worker.js | 1 + .../controlcenter/content/identityPanel.inc.xhtml | 213 + .../content/permissionPanel.inc.xhtml | 55 + .../content/protectionsPanel.inc.xhtml | 425 + .../customizableui/CustomizableUI.sys.mjs | 6285 +++ .../customizableui/CustomizableWidgets.sys.mjs | 615 + .../customizableui/CustomizeMode.sys.mjs | 2971 ++ .../customizableui/DragPositionManager.sys.mjs | 313 + .../customizableui/PanelMultiView.sys.mjs | 1894 + .../customizableui/SearchWidgetTracker.sys.mjs | 134 + .../components/customizableui/content/.eslintrc.js | 13 + .../customizableui/content/customizeMode.inc.xhtml | 121 + browser/components/customizableui/content/jar.mn | 6 + .../components/customizableui/content/moz.build | 7 + .../customizableui/content/panelUI.inc.xhtml | 329 + .../components/customizableui/content/panelUI.js | 1072 + browser/components/customizableui/moz.build | 28 + .../test/CustomizableUITestUtils.sys.mjs | 156 + .../components/customizableui/test/browser.toml | 368 + .../test/browser_1003588_no_specials_in_panel.js | 133 + .../test/browser_1008559_anchor_undo_restore.js | 102 + .../browser_1042100_default_placements_update.js | 241 + .../test/browser_1058573_showToolbarsDropdown.js | 29 + .../test/browser_1087303_button_fullscreen.js | 55 + .../test/browser_1087303_button_preferences.js | 59 + ...owser_1089591_still_customizable_after_reset.js | 24 + .../browser_1096763_seen_widgets_post_reset.js | 41 + ...browser_1161838_inserted_new_default_buttons.js | 109 + ...84275_PanelMultiView_toggle_with_other_popup.js | 69 + ...browser_1701883_restore_defaults_pocket_pref.js | 28 + ...wser_1702200_PanelMultiView_header_separator.js | 76 + .../browser_1795260_searchbar_overflow_toolbar.js | 30 + ...1856572_ensure_Fluent_works_in_customizeMode.js | 62 + .../test/browser_694291_searchbar_preference.js | 48 + .../test/browser_873501_handle_specials.js | 89 + .../test/browser_876926_customize_mode_wrapping.js | 295 + ...browser_876944_customize_mode_create_destroy.js | 44 + .../test/browser_877006_missing_view.js | 46 + .../test/browser_877178_unregisterArea.js | 70 + .../test/browser_877447_skip_missing_ids.js | 35 + .../test/browser_878452_drag_to_panel.js | 90 + .../test/browser_884402_customize_from_overflow.js | 117 + ...wser_885052_customize_mode_observers_disabed.js | 73 + .../test/browser_885530_showInPrivateBrowsing.js | 141 + .../browser_886323_buildArea_removable_nodes.js | 58 + ...wser_890262_destroyWidget_after_add_to_panel.js | 74 + ...892955_isWidgetRemovable_for_removed_widgets.js | 30 + ...owser_892956_destroyWidget_defaultPlacements.js | 30 + .../test/browser_901207_searchbar_in_panel.js | 139 + .../browser_909779_overflow_toolbars_new_window.js | 49 + .../test/browser_913972_currentset_overflow.js | 92 + ...owser_914138_widget_API_overflowable_toolbar.js | 347 + .../test/browser_918049_skipintoolbarset_dnd.js | 46 + ...7_customize_mode_event_wrapping_during_reset.js | 27 + .../browser_927717_customize_drag_empty_toolbar.js | 29 + .../test/browser_934113_menubar_removable.js | 43 + .../test/browser_934951_zoom_in_toolbar.js | 94 + .../test/browser_938980_navbar_collapsed.js | 214 + .../browser_938995_indefaultstate_nonremovable.js | 45 + ...40013_registerToolbarNode_calls_registerArea.js | 64 + .../browser_940307_panel_click_closure_handling.js | 141 + ...r_940946_removable_from_navbar_customizemode.js | 30 + ...941083_invalidate_wrapper_cache_createWidget.js | 49 + ...owser_942581_unregisterArea_keeps_placements.js | 119 + ...4887_destroyWidget_should_destroy_in_palette.js | 26 + ..._945739_showInPrivateBrowsing_customize_mode.js | 43 + .../test/browser_947914_button_copy.js | 64 + .../test/browser_947914_button_cut.js | 58 + .../test/browser_947914_button_find.js | 37 + .../test/browser_947914_button_history.js | 68 + .../test/browser_947914_button_newPrivateWindow.js | 62 + .../test/browser_947914_button_newWindow.js | 62 + .../test/browser_947914_button_paste.js | 55 + .../test/browser_947914_button_print.js | 54 + .../test/browser_947914_button_zoomIn.js | 60 + .../test/browser_947914_button_zoomOut.js | 61 + .../test/browser_947914_button_zoomReset.js | 75 + .../test/browser_947987_removable_default.js | 94 + .../browser_948985_non_removable_defaultArea.js | 47 + .../test/browser_952963_areaType_getter_no_area.js | 70 + .../test/browser_956602_remove_special_widget.js | 37 + .../browser_962069_drag_to_overflow_chevron.js | 79 + ...stomizing_attribute_non_customizable_toolbar.js | 48 + .../browser_968565_insert_before_hidden_items.js | 60 + ...969427_recreate_destroyed_widget_after_reset.js | 47 + ...er_969661_character_encoding_navbar_disabled.js | 28 + .../test/browser_970511_undo_restore_default.js | 274 + .../browser_972267_customizationchange_events.js | 39 + .../test/browser_976792_insertNodeInWindow.js | 597 + .../test/browser_978084_dragEnd_after_move.js | 52 + .../test/browser_980155_add_overflow_toolbar.js | 97 + .../test/browser_981305_separator_insertion.js | 89 + ...rowser_981418-widget-onbeforecreated-handler.js | 66 + ...wser_982656_restore_defaults_builtin_widgets.js | 82 + .../browser_984455_bookmarks_items_reparenting.js | 328 + ...rowser_985815_propagate_setToolbarVisibility.js | 56 + .../test/browser_987177_destroyWidget_xul.js | 35 + .../test/browser_987177_xul_wrapper_updating.js | 142 + .../test/browser_987492_window_api.js | 83 + .../test/browser_987640_charEncoding.js | 78 + .../browser_989338_saved_placements_not_resaved.js | 70 + .../test/browser_989751_subviewbutton_class.js | 91 + ...rowser_992747_toggle_noncustomizable_toolbar.js | 25 + .../test/browser_993322_widget_notoolbar.js | 59 + ...er_995164_registerArea_during_customize_mode.js | 278 + ...ser_996364_registerArea_different_properties.js | 142 + .../test/browser_996635_remove_non_widgets.js | 54 + .../customizableui/test/browser_PanelMultiView.js | 566 + .../test/browser_PanelMultiView_focus.js | 170 + .../test/browser_PanelMultiView_keyboard.js | 583 + .../customizableui/test/browser_addons_area.js | 76 + .../test/browser_allow_dragging_removable_false.js | 42 + .../test/browser_backfwd_enabled_post_customize.js | 79 + .../test/browser_bookmarks_empty_message.js | 83 + ..._bookmarks_toolbar_collapsed_restore_default.js | 35 + .../test/browser_bookmarks_toolbar_shown_newtab.js | 34 + .../test/browser_bootstrapped_custom_toolbar.js | 81 + .../test/browser_check_tooltips_in_navbar.js | 21 + .../test/browser_create_button_widget.js | 90 + .../test/browser_ctrl_click_panel_opening.js | 56 + .../test/browser_currentset_post_reset.js | 37 + .../test/browser_customization_context_menus.js | 632 + ...er_customizemode_contextmenu_menubuttonstate.js | 71 + .../test/browser_customizemode_lwthemes.js | 25 + .../test/browser_customizemode_uidensity.js | 230 + .../test/browser_disable_commands_customize.js | 86 + .../test/browser_drag_outside_palette.js | 53 + .../test/browser_editcontrols_update.js | 307 + .../test/browser_exit_background_customize_mode.js | 44 + .../test/browser_flexible_space_area.js | 48 + .../test/browser_help_panel_cloning.js | 90 + .../test/browser_hidden_widget_overflow.js | 115 + .../test/browser_history_after_appMenu.js | 35 + .../test/browser_history_recently_closed.js | 430 + .../browser_history_recently_closed_middleclick.js | 106 + .../test/browser_history_restore_session.js | 52 + .../test/browser_insert_before_moved_node.js | 51 + .../test/browser_menubar_visibility.js | 66 + .../test/browser_newtab_button_customizemode.js | 181 + .../customizableui/test/browser_open_from_popup.js | 24 + .../test/browser_open_in_lazy_tab.js | 42 + .../test/browser_overflow_use_subviews.js | 88 + .../customizableui/test/browser_palette_labels.js | 66 + .../test/browser_panelUINotifications.js | 597 + ...rowser_panelUINotifications_bannerVisibility.js | 151 + .../browser_panelUINotifications_fullscreen.js | 92 + ...UINotifications_fullscreen_noAutoHideToolbar.js | 145 + .../test/browser_panelUINotifications_modals.js | 87 + .../browser_panelUINotifications_multiWindow.js | 214 + .../test/browser_panel_keyboard_navigation.js | 326 + .../test/browser_panel_locationSpecific.js | 78 + .../customizableui/test/browser_panel_menulist.js | 50 + .../customizableui/test/browser_panel_toggle.js | 53 + .../test/browser_proton_moreTools_panel.js | 54 + .../browser_proton_toolbar_hide_toolbarbuttons.js | 285 + .../customizableui/test/browser_registerArea.js | 28 + .../customizableui/test/browser_reload_tab.js | 103 + .../test/browser_remote_attribute.js | 73 + .../test/browser_remote_tabs_button.js | 100 + .../test/browser_remove_customized_specials.js | 35 + .../browser_reset_builtin_widget_currentArea.js | 26 + .../test/browser_reset_dom_events.js | 34 + .../test/browser_screenshot_button_disabled.js | 22 + .../test/browser_searchbar_removal.js | 36 + .../customizableui/test/browser_sidebar_toggle.js | 58 + .../test/browser_switch_to_customize_mode.js | 53 + .../test/browser_synced_tabs_menu.js | 523 + .../test/browser_tabbar_big_widgets.js | 32 + .../test/browser_toolbar_collapsed_states.js | 112 + .../test/browser_touchbar_customization.js | 21 + .../test/browser_unified_extensions_reset.js | 91 + .../test/browser_widget_animation.js | 84 + .../test/browser_widget_recreate_events.js | 99 + .../customizableui/test/dummy_history_item.html | 2 + browser/components/customizableui/test/head.js | 530 + .../support/test_967000_charEncoding_page.html | 11 + .../test/unit/test_unified_extensions_migration.js | 373 + .../customizableui/test/unit/xpcshell.toml | 6 + browser/components/distribution.sys.mjs | 652 + browser/components/doh/DoHConfig.sys.mjs | 356 + browser/components/doh/DoHController.sys.mjs | 791 + browser/components/doh/DoHHeuristics.sys.mjs | 437 + browser/components/doh/DoHTestUtils.sys.mjs | 133 + browser/components/doh/TRRPerformance.sys.mjs | 395 + browser/components/doh/moz.build | 22 + browser/components/doh/test/browser/browser.toml | 33 + .../doh/test/browser/browser_cleanFlow.js | 88 + .../doh/test/browser/browser_dirtyEnable.js | 55 + .../doh/test/browser/browser_doh_region.js | 60 + .../test/browser/browser_doorhangerUserReject.js | 90 + .../doh/test/browser/browser_platformDetection.js | 126 + .../doh/test/browser/browser_policyOverride.js | 76 + .../doh/test/browser/browser_providerSteering.js | 120 + .../browser/browser_remoteSettings_newProfile.js | 147 + .../test/browser/browser_remoteSettings_rollout.js | 70 + .../doh/test/browser/browser_rollback.js | 144 + .../test/browser/browser_throttle_heuristics.js | 97 + .../doh/test/browser/browser_trrSelect.js | 147 + .../test/browser/browser_trrSelection_disable.js | 74 + .../doh/test/browser/browser_userInterference.js | 81 + browser/components/doh/test/browser/head.js | 408 + browser/components/doh/test/unit/head.js | 99 + browser/components/doh/test/unit/test_DNSLookup.js | 62 + .../doh/test/unit/test_LookupAggregator.js | 162 + browser/components/doh/test/unit/test_TRRRacer.js | 209 + .../components/doh/test/unit/test_heuristics.js | 80 + browser/components/doh/test/unit/xpcshell.toml | 14 + .../downloads/DownloadSpamProtection.sys.mjs | 295 + .../components/downloads/DownloadsCommon.sys.mjs | 1643 + .../downloads/DownloadsMacFinderProgress.sys.mjs | 84 + .../components/downloads/DownloadsTaskbar.sys.mjs | 215 + .../components/downloads/DownloadsViewUI.sys.mjs | 1198 + .../downloads/DownloadsViewableInternally.sys.mjs | 343 + .../downloads/content/allDownloadsView.js | 946 + .../downloads/content/contentAreaDownloadsView.css | 8 + .../downloads/content/contentAreaDownloadsView.js | 49 + .../content/contentAreaDownloadsView.xhtml | 60 + browser/components/downloads/content/downloads.css | 105 + browser/components/downloads/content/downloads.js | 1689 + .../downloads/content/downloadsCommands.inc.xhtml | 29 + .../downloads/content/downloadsCommands.js | 17 + .../content/downloadsContextMenu.inc.xhtml | 50 + .../downloads/content/downloadsPanel.inc.xhtml | 195 + browser/components/downloads/content/indicator.js | 670 + browser/components/downloads/jar.mn | 13 + browser/components/downloads/moz.build | 30 + .../components/downloads/test/browser/blank.JPG | Bin 0 -> 631 bytes .../components/downloads/test/browser/browser.toml | 100 + .../test/browser/browser_about_downloads.js | 44 + .../test/browser/browser_basic_functionality.js | 59 + .../browser/browser_confirm_unblock_download.js | 110 + .../test/browser/browser_download_is_clickable.js | 78 + .../browser/browser_download_opens_on_click.js | 89 + .../test/browser/browser_download_opens_policy.js | 104 + .../test/browser/browser_download_overwrite.js | 126 + .../browser/browser_download_spam_protection.js | 217 + .../test/browser/browser_download_starts_in_tmp.js | 264 + .../test/browser/browser_downloads_autohide.js | 517 + ...loads_context_menu_always_open_similar_files.js | 236 + .../browser_downloads_context_menu_delete_file.js | 253 + .../browser_downloads_context_menu_selection.js | 139 + .../test/browser/browser_downloads_keynav.js | 255 + .../test/browser/browser_downloads_panel_block.js | 130 + .../browser_downloads_panel_context_menu.js | 421 + .../browser/browser_downloads_panel_ctrl_click.js | 35 + .../browser_downloads_panel_disable_items.js | 171 + .../browser/browser_downloads_panel_dontshow.js | 126 + .../test/browser/browser_downloads_panel_focus.js | 108 + .../test/browser/browser_downloads_panel_height.js | 35 + .../test/browser/browser_downloads_panel_opens.js | 672 + .../test/browser/browser_downloads_pauseResume.js | 49 + .../test/browser/browser_first_download_panel.js | 68 + .../test/browser/browser_go_to_download_page.js | 93 + .../browser/browser_iframe_gone_mid_download.js | 72 + .../test/browser/browser_image_mimetype_issues.js | 135 + .../test/browser/browser_indicatorDrop.js | 38 + .../downloads/test/browser/browser_libraryDrop.js | 39 + .../test/browser/browser_library_clearall.js | 122 + .../test/browser/browser_library_select_all.js | 77 + .../test/browser/browser_overflow_anchor.js | 59 + .../test/browser/browser_pdfjs_preview.js | 753 + .../downloads/test/browser/browser_tempfilename.js | 88 + browser/components/downloads/test/browser/foo.txt | 1 + .../downloads/test/browser/foo.txt^headers^ | 2 + browser/components/downloads/test/browser/head.js | 450 + .../downloads/test/browser/not-really-a-jpeg.jpeg | Bin 0 -> 42 bytes .../test/browser/not-really-a-jpeg.jpeg^headers^ | 2 + .../downloads/test/browser/test_spammy_page.html | 26 + browser/components/downloads/test/unit/head.js | 63 + .../test/unit/test_DownloadLastDir_basics.js | 177 + .../test/unit/test_DownloadsCommon_getMimeInfo.js | 168 + .../test/unit/test_DownloadsCommon_isFileOfType.js | 147 + .../test/unit/test_DownloadsViewableInternally.js | 238 + .../components/downloads/test/unit/xpcshell.toml | 12 + .../components/enterprisepolicies/Policies.sys.mjs | 2885 ++ .../enterprisepolicies/content/aboutPolicies.css | 170 + .../enterprisepolicies/content/aboutPolicies.html | 95 + .../enterprisepolicies/content/aboutPolicies.js | 430 + .../enterprisepolicies/content/policies-active.svg | 6 + .../content/policies-documentation.svg | 6 + .../enterprisepolicies/content/policies-error.svg | 6 + .../components/enterprisepolicies/docs/index.rst | 18 + .../helpers/BookmarksPolicies.sys.mjs | 299 + .../helpers/ProxyPolicies.sys.mjs | 109 + .../helpers/WebsiteFilter.sys.mjs | 186 + .../enterprisepolicies/helpers/moz.build | 14 + .../enterprisepolicies/helpers/sample.json | 14 + .../helpers/sample_bookmarks.json | 37 + .../enterprisepolicies/helpers/sample_proxy.json | 13 + .../helpers/sample_websitefilter.json | 9 + browser/components/enterprisepolicies/jar.mn | 11 + browser/components/enterprisepolicies/moz.build | 25 + .../enterprisepolicies/schemas/configuration.json | 10 + .../enterprisepolicies/schemas/moz.build | 12 + .../schemas/policies-schema.json | 1441 + .../enterprisepolicies/schemas/schema.sys.mjs | 16 + .../enterprisepolicies/tests/browser/301.sjs | 8 + .../enterprisepolicies/tests/browser/302.sjs | 8 + .../enterprisepolicies/tests/browser/404.sjs | 3 + .../enterprisepolicies/tests/browser/browser.toml | 122 + .../browser/browser_policies_getActivePolicies.js | 52 + .../browser_policies_notice_in_aboutpreferences.js | 19 + .../browser/browser_policies_setAndLockPref_API.js | 179 + .../browser_policy_allowfileselectiondialogs.js | 166 + .../browser/browser_policy_app_auto_update.js | 77 + .../tests/browser/browser_policy_app_update.js | 41 + .../browser_policy_background_app_update.js | 99 + .../tests/browser/browser_policy_block_about.js | 50 + .../browser/browser_policy_block_about_support.js | 41 + .../browser_policy_block_set_desktop_background.js | 50 + .../tests/browser/browser_policy_bookmarks.js | 316 + .../browser/browser_policy_cookie_settings.js | 339 + .../browser_policy_disable_feedback_commands.js | 63 + .../browser/browser_policy_disable_fxaccounts.js | 48 + .../browser_policy_disable_masterpassword.js | 90 + .../browser_policy_disable_password_reveal.js | 43 + .../tests/browser/browser_policy_disable_pocket.js | 29 + .../browser_policy_disable_popup_blocker.js | 149 + .../browser_policy_disable_privatebrowsing.js | 32 + .../browser_policy_disable_profile_import.js | 100 + .../browser_policy_disable_profile_reset.js | 80 + .../browser/browser_policy_disable_safemode.js | 57 + .../tests/browser/browser_policy_disable_shield.js | 68 + .../browser/browser_policy_disable_telemetry.js | 28 + .../browser/browser_policy_display_bookmarks.js | 86 + .../tests/browser/browser_policy_display_menu.js | 84 + .../tests/browser/browser_policy_downloads.js | 147 + .../tests/browser/browser_policy_extensions.js | 117 + .../browser/browser_policy_extensionsettings.js | 261 + .../browser/browser_policy_extensionsettings2.js | 71 + .../tests/browser/browser_policy_firefoxhome.js | 129 + .../tests/browser/browser_policy_firefoxsuggest.js | 61 + .../tests/browser/browser_policy_handlers.js | 183 + .../tests/browser/browser_policy_masterpassword.js | 95 + .../browser_policy_masterpassword_aboutlogins.js | 76 + .../browser_policy_masterpassword_doorhanger.js | 76 + .../browser/browser_policy_offertosavelogins.js | 23 + .../browser_policy_override_postupdatepage.js | 132 + .../browser/browser_policy_pageinfo_permissions.js | 76 + .../browser/browser_policy_passwordmanager.js | 25 + .../tests/browser/browser_policy_search_engine.js | 109 + .../tests/browser/browser_policy_searchbar.js | 36 + .../tests/browser/browser_policy_set_homepage.js | 115 + .../tests/browser/browser_policy_set_startpage.js | 68 + .../tests/browser/browser_policy_support_menu.js | 68 + .../tests/browser/browser_policy_usermessaging.js | 21 + .../tests/browser/browser_policy_websitefilter.js | 186 + .../tests/browser/disable_app_update/browser.toml | 9 + .../browser_policy_disable_app_update.js | 122 + .../config_disable_app_update.json | 5 + .../bookmarks_policies.json | 5 + .../browser/disable_default_bookmarks/browser.toml | 5 + .../browser_policy_no_default_bookmarks.js | 24 + .../browser/disable_developer_tools/browser.toml | 5 + .../browser_policy_disable_developer_tools.js | 87 + .../config_disable_developer_tools.json | 5 + .../browser/disable_forget_button/browser.toml | 5 + .../browser_policy_disable_forgetbutton.js | 9 + .../disable_forget_button/forget_button.json | 5 + .../browser/disable_fxscreenshots/browser.toml | 8 + .../browser_policy_disable_fxscreenshots.js | 35 + .../config_disable_fxscreenshots.json | 5 + .../tests/browser/extensionsettings.html | 28 + .../browser/hardware_acceleration/browser.toml | 5 + .../browser_policy_hardware_acceleration.js | 13 + .../disable_hardware_acceleration.json | 5 + .../enterprisepolicies/tests/browser/head.js | 251 + .../tests/browser/homepage_button/browser.toml | 5 + ...rowser_show_home_button_with_homepage_policy.js | 9 + .../browser/homepage_button/homepage_policies.json | 7 + .../tests/browser/managedbookmarks/browser.toml | 7 + .../browser_policy_managedbookmarks.js | 210 + .../browser/managedbookmarks/managedbookmarks.json | 44 + .../tests/browser/opensearch.html | 8 + .../tests/browser/opensearchEngine.xml | 12 + .../tests/browser/policy_websitefilter_block.html | 10 + .../browser/policy_websitefilter_exception.html | 10 + .../browser/policy_websitefilter_savelink.html | 11 + .../tests/browser/policytest_v0.1.xpi | Bin 0 -> 305 bytes .../tests/browser/policytest_v0.2.xpi | Bin 0 -> 297 bytes .../tests/browser/show_home_button/browser.toml | 4 + .../browser_policy_show_home_button.js | 38 + .../show_home_button_policies.json | 7 + .../components/enterprisepolicies/tests/moz.build | 20 + .../xpcshell/config_popups_cookies_addons.json | 17 + .../enterprisepolicies/tests/xpcshell/head.js | 150 + .../tests/xpcshell/policytest_v0.1.xpi | Bin 0 -> 305 bytes .../tests/xpcshell/test_3rdparty.js | 22 + .../tests/xpcshell/test_addon_update.js | 156 + .../tests/xpcshell/test_appupdatepin.js | 80 + .../tests/xpcshell/test_appupdateurl.js | 25 + .../tests/xpcshell/test_bug1658259.js | 44 + .../tests/xpcshell/test_cleanup.js | 84 + .../tests/xpcshell/test_clear_blocked_cookies.js | 118 + .../tests/xpcshell/test_containers.js | 37 + .../tests/xpcshell/test_defaultbrowsercheck.js | 52 + .../tests/xpcshell/test_empty_policy.js | 32 + ..._type_pairs_from_file_type_download_warnings.js | 66 + .../tests/xpcshell/test_extensions.js | 83 + .../tests/xpcshell/test_extensionsettings.js | 291 + .../tests/xpcshell/test_macosparser_unflatten.js | 110 + .../tests/xpcshell/test_permissions.js | 355 + .../tests/xpcshell/test_policy_search_engine.js | 490 + .../tests/xpcshell/test_popups_cookies_addons.js | 121 + .../tests/xpcshell/test_preferences.js | 257 + .../tests/xpcshell/test_proxy.js | 122 + .../tests/xpcshell/test_requestedlocales.js | 119 + .../tests/xpcshell/test_runOnce_helper.js | 21 + .../tests/xpcshell/test_simple_pref_policies.js | 1055 + .../tests/xpcshell/test_sorted_alphabetically.js | 48 + .../tests/xpcshell/test_telemetry.js | 102 + .../tests/xpcshell/xpcshell.toml | 55 + browser/components/extensions/.eslintrc.js | 9 + .../extensions/ExtensionBrowsingData.sys.mjs | 75 + .../extensions/ExtensionControlledPopup.sys.mjs | 421 + .../components/extensions/ExtensionPopups.sys.mjs | 725 + browser/components/extensions/child/.eslintrc.js | 9 + .../extensions/child/ext-browser-content-only.js | 13 + browser/components/extensions/child/ext-browser.js | 49 + .../child/ext-devtools-inspectedWindow.js | 29 + .../extensions/child/ext-devtools-network.js | 70 + .../extensions/child/ext-devtools-panels.js | 326 + .../components/extensions/child/ext-devtools.js | 15 + .../components/extensions/child/ext-menus-child.js | 38 + browser/components/extensions/child/ext-menus.js | 305 + browser/components/extensions/child/ext-omnibox.js | 38 + browser/components/extensions/child/ext-tabs.js | 22 + browser/components/extensions/ext-browser.json | 180 + .../extensions/extension-popup-panel.css | 7 + browser/components/extensions/extension.css | 577 + .../extensions/extensions-browser.manifest | 6 + browser/components/extensions/jar.mn | 41 + browser/components/extensions/moz.build | 37 + browser/components/extensions/parent/.eslintrc.js | 32 + .../components/extensions/parent/ext-bookmarks.js | 511 + .../components/extensions/parent/ext-browser.js | 1243 + .../extensions/parent/ext-browserAction.js | 1018 + .../parent/ext-chrome-settings-overrides.js | 572 + .../components/extensions/parent/ext-commands.js | 82 + .../parent/ext-devtools-inspectedWindow.js | 53 + .../extensions/parent/ext-devtools-network.js | 82 + .../extensions/parent/ext-devtools-panels.js | 691 + .../components/extensions/parent/ext-devtools.js | 510 + browser/components/extensions/parent/ext-find.js | 272 + .../components/extensions/parent/ext-history.js | 326 + browser/components/extensions/parent/ext-menus.js | 1471 + .../extensions/parent/ext-normandyAddonStudy.js | 84 + .../components/extensions/parent/ext-omnibox.js | 177 + .../components/extensions/parent/ext-pageAction.js | 383 + browser/components/extensions/parent/ext-pkcs11.js | 187 + browser/components/extensions/parent/ext-search.js | 113 + .../components/extensions/parent/ext-sessions.js | 305 + .../extensions/parent/ext-sidebarAction.js | 520 + browser/components/extensions/parent/ext-tabs.js | 1635 + .../components/extensions/parent/ext-topSites.js | 117 + .../extensions/parent/ext-url-overrides.js | 205 + .../components/extensions/parent/ext-windows.js | 544 + .../components/extensions/schemas/LICENSE-CHROMIUM | 27 + browser/components/extensions/schemas/README.md | 13 + .../components/extensions/schemas/bookmarks.json | 550 + .../schemas/chrome_settings_overrides.json | 207 + .../components/extensions/schemas/commands.json | 204 + .../components/extensions/schemas/devtools.json | 31 + .../schemas/devtools_inspected_window.json | 266 + .../extensions/schemas/devtools_network.json | 91 + .../extensions/schemas/devtools_panels.json | 422 + browser/components/extensions/schemas/find.json | 122 + browser/components/extensions/schemas/history.json | 345 + browser/components/extensions/schemas/jar.mn | 26 + browser/components/extensions/schemas/menus.json | 605 + .../components/extensions/schemas/menus_child.json | 29 + browser/components/extensions/schemas/moz.build | 7 + .../extensions/schemas/normandyAddonStudy.json | 130 + browser/components/extensions/schemas/omnibox.json | 220 + browser/components/extensions/schemas/pkcs11.json | 76 + browser/components/extensions/schemas/search.json | 127 + .../components/extensions/schemas/sessions.json | 320 + .../extensions/schemas/sidebar_action.json | 264 + browser/components/extensions/schemas/tabs.json | 1857 + .../components/extensions/schemas/top_sites.json | 133 + .../extensions/schemas/url_overrides.json | 35 + browser/components/extensions/schemas/windows.json | 505 + .../extensions/test/AppUiTestDelegate.sys.mjs | 227 + .../extensions/test/browser/.eslintrc.js | 11 + .../extensions/test/browser/authenticate.sjs | 85 + .../extensions/test/browser/browser-private.toml | 11 + .../extensions/test/browser/browser.toml | 702 + .../browser/browser_AMBrowserExtensionsImport.js | 286 + .../browser/browser_ExtensionControlledPopup.js | 241 + .../browser_ext_action_popup_allowed_urls.js | 283 + .../test/browser/browser_ext_activeScript.js | 480 + .../browser_ext_addon_debugging_netmonitor.js | 116 + .../test/browser/browser_ext_autocompletepopup.js | 90 + .../browser/browser_ext_autoplayInBackground.js | 52 + .../browser/browser_ext_browserAction_activeTab.js | 195 + .../test/browser/browser_ext_browserAction_area.js | 126 + .../browser_ext_browserAction_click_types.js | 269 + .../browser/browser_ext_browserAction_context.js | 1194 + .../browser_ext_browserAction_contextMenu.js | 880 + .../browser/browser_ext_browserAction_disabled.js | 101 + .../browser_ext_browserAction_experiment.js | 159 + .../browser_ext_browserAction_getUserSettings.js | 244 + .../browser/browser_ext_browserAction_incognito.js | 48 + .../browser/browser_ext_browserAction_keyclick.js | 68 + .../browser_ext_browserAction_pageAction_icon.js | 651 + ...xt_browserAction_pageAction_icon_permissions.js | 239 + .../browser/browser_ext_browserAction_popup.js | 370 + .../browser_ext_browserAction_popup_port.js | 56 + .../browser_ext_browserAction_popup_preload.js | 472 + ...er_ext_browserAction_popup_preload_smoketest.js | 194 + .../browser_ext_browserAction_popup_resize.js | 79 + ...rowser_ext_browserAction_popup_resize_bottom.js | 39 + .../browser/browser_ext_browserAction_simple.js | 105 + .../browser/browser_ext_browserAction_telemetry.js | 386 + .../browser_ext_browserAction_theme_icons.js | 370 + .../browser_ext_browsingData_cookieStoreId.js | 86 + .../browser/browser_ext_browsingData_formData.js | 175 + .../browser/browser_ext_browsingData_history.js | 123 + .../browser_ext_chrome_settings_overrides_home.js | 843 + .../browser_ext_commands_execute_browser_action.js | 194 + .../browser_ext_commands_execute_page_action.js | 204 + .../browser_ext_commands_execute_sidebar_action.js | 56 + .../test/browser/browser_ext_commands_getAll.js | 142 + .../test/browser/browser_ext_commands_onChanged.js | 59 + .../test/browser/browser_ext_commands_onCommand.js | 442 + .../test/browser/browser_ext_commands_update.js | 428 + .../browser/browser_ext_connect_and_move_tabs.js | 104 + .../browser/browser_ext_contentscript_animate.js | 135 + .../browser/browser_ext_contentscript_connect.js | 94 + ...er_ext_contentscript_cross_docGroup_adoption.js | 63 + ...xt_contentscript_cross_docGroup_adoption_xhr.js | 56 + ...browser_ext_contentscript_dataTransfer_files.js | 104 + .../browser/browser_ext_contentscript_in_parent.js | 101 + .../browser/browser_ext_contentscript_incognito.js | 42 + .../browser_ext_contentscript_nontab_connect.js | 116 + .../browser_ext_contentscript_sender_url.js | 67 + .../test/browser/browser_ext_contextMenus.js | 854 + .../browser/browser_ext_contextMenus_bookmarks.js | 115 + .../browser/browser_ext_contextMenus_checkboxes.js | 157 + .../browser/browser_ext_contextMenus_commands.js | 158 + .../test/browser/browser_ext_contextMenus_icons.js | 493 + .../browser/browser_ext_contextMenus_onclick.js | 297 + .../browser_ext_contextMenus_radioGroups.js | 140 + .../browser_ext_contextMenus_srcUrl_redirect.js | 69 + .../browser_ext_contextMenus_targetUrlPatterns.js | 317 + .../browser/browser_ext_contextMenus_uninstall.js | 114 + .../browser_ext_contextMenus_urlPatterns.js | 337 + .../test/browser/browser_ext_currentWindow.js | 183 + .../browser_ext_devtools_inspectedWindow.js | 540 + ...r_ext_devtools_inspectedWindow_eval_bindings.js | 270 + ...owser_ext_devtools_inspectedWindow_eval_file.js | 54 + .../browser_ext_devtools_inspectedWindow_reload.js | 481 + ...er_ext_devtools_inspectedWindow_targetSwitch.js | 128 + .../test/browser/browser_ext_devtools_network.js | 298 + .../browser_ext_devtools_network_targetSwitch.js | 74 + .../test/browser/browser_ext_devtools_optional.js | 169 + .../test/browser/browser_ext_devtools_page.js | 304 + .../browser/browser_ext_devtools_page_incognito.js | 92 + .../test/browser/browser_ext_devtools_panel.js | 812 + .../browser_ext_devtools_panels_elements.js | 124 + ...browser_ext_devtools_panels_elements_sidebar.js | 323 + .../extensions/test/browser/browser_ext_find.js | 468 + .../test/browser/browser_ext_getViews.js | 439 + .../test/browser/browser_ext_history_redirect.js | 72 + .../browser/browser_ext_identity_indication.js | 141 + .../test/browser/browser_ext_incognito_popup.js | 209 + .../test/browser/browser_ext_incognito_views.js | 269 + .../test/browser/browser_ext_lastError.js | 61 + .../test/browser/browser_ext_management.js | 139 + .../extensions/test/browser/browser_ext_menus.js | 458 + .../test/browser/browser_ext_menus_accesskey.js | 209 + .../test/browser/browser_ext_menus_activeTab.js | 115 + .../browser_ext_menus_capture_secondary_click.js | 140 + .../test/browser/browser_ext_menus_errors.js | 164 + .../test/browser/browser_ext_menus_event_order.js | 87 + .../test/browser/browser_ext_menus_eventpage.js | 277 + .../test/browser/browser_ext_menus_events.js | 911 + ...owser_ext_menus_events_after_context_destroy.js | 64 + .../test/browser/browser_ext_menus_incognito.js | 155 + .../test/browser/browser_ext_menus_refresh.js | 438 + .../test/browser/browser_ext_menus_replace_menu.js | 525 + .../browser_ext_menus_replace_menu_context.js | 475 + .../browser_ext_menus_replace_menu_permissions.js | 220 + .../browser/browser_ext_menus_targetElement.js | 326 + .../browser_ext_menus_targetElement_extension.js | 198 + .../browser_ext_menus_targetElement_shadow.js | 108 + .../test/browser/browser_ext_menus_viewType.js | 122 + .../test/browser/browser_ext_menus_visible.js | 95 + .../test/browser/browser_ext_mousewheel_zoom.js | 186 + .../browser/browser_ext_nontab_process_switch.js | 154 + .../extensions/test/browser/browser_ext_omnibox.js | 504 + .../test/browser/browser_ext_openPanel.js | 152 + .../browser/browser_ext_optionsPage_activity.js | 79 + .../browser_ext_optionsPage_browser_style.js | 155 + .../browser_ext_optionsPage_links_open_in_tabs.js | 68 + .../test/browser/browser_ext_optionsPage_modals.js | 100 + .../test/browser/browser_ext_optionsPage_popups.js | 249 + .../browser/browser_ext_optionsPage_privileges.js | 86 + .../test/browser/browser_ext_originControls.js | 867 + .../browser/browser_ext_pageAction_activeTab.js | 107 + .../browser/browser_ext_pageAction_click_types.js | 240 + .../test/browser/browser_ext_pageAction_context.js | 453 + .../browser/browser_ext_pageAction_contextMenu.js | 128 + .../test/browser/browser_ext_pageAction_popup.js | 305 + .../browser/browser_ext_pageAction_popup_resize.js | 192 + .../browser/browser_ext_pageAction_show_matches.js | 329 + .../test/browser/browser_ext_pageAction_simple.js | 213 + .../browser/browser_ext_pageAction_telemetry.js | 228 + .../test/browser/browser_ext_pageAction_title.js | 275 + ...ext_persistent_storage_permission_indication.js | 131 + .../browser/browser_ext_popup_api_injection.js | 113 + .../test/browser/browser_ext_popup_background.js | 160 + .../test/browser/browser_ext_popup_corners.js | 165 + .../test/browser/browser_ext_popup_focus.js | 88 + .../browser_ext_popup_links_open_in_tabs.js | 56 + .../browser/browser_ext_popup_requestPermission.js | 67 + .../test/browser/browser_ext_popup_select.js | 115 + .../browser/browser_ext_popup_select_in_oopif.js | 131 + .../test/browser/browser_ext_popup_sendMessage.js | 135 + .../test/browser/browser_ext_popup_shutdown.js | 80 + .../browser_ext_port_disconnect_on_crash.js | 113 + .../browser_ext_port_disconnect_on_window_close.js | 39 + .../browser/browser_ext_reload_manifest_cache.js | 72 + .../browser/browser_ext_request_permissions.js | 121 + .../browser_ext_runtime_onPerformanceWarning.js | 144 + .../browser/browser_ext_runtime_openOptionsPage.js | 442 + ...rowser_ext_runtime_openOptionsPage_uninstall.js | 122 + .../browser/browser_ext_runtime_setUninstallURL.js | 134 + .../extensions/test/browser/browser_ext_search.js | 351 + .../test/browser/browser_ext_search_favicon.js | 184 + .../test/browser/browser_ext_search_query.js | 174 + .../browser_ext_sessions_forgetClosedTab.js | 145 + .../browser_ext_sessions_forgetClosedWindow.js | 121 + .../browser_ext_sessions_getRecentlyClosed.js | 216 + ...owser_ext_sessions_getRecentlyClosed_private.js | 93 + .../browser_ext_sessions_getRecentlyClosed_tabs.js | 292 + .../test/browser/browser_ext_sessions_incognito.js | 113 + .../test/browser/browser_ext_sessions_restore.js | 234 + .../browser/browser_ext_sessions_restoreTab.js | 137 + .../browser_ext_sessions_restore_private.js | 236 + .../browser_ext_sessions_window_tab_value.js | 398 + ...rowser_ext_settings_overrides_default_search.js | 881 + .../test/browser/browser_ext_sidebarAction.js | 268 + .../browser_ext_sidebarAction_browser_style.js | 90 + .../browser/browser_ext_sidebarAction_click.js | 74 + .../browser/browser_ext_sidebarAction_context.js | 683 + .../browser_ext_sidebarAction_contextMenu.js | 133 + .../browser/browser_ext_sidebarAction_httpAuth.js | 72 + .../browser/browser_ext_sidebarAction_incognito.js | 139 + .../browser/browser_ext_sidebarAction_runtime.js | 76 + .../test/browser/browser_ext_sidebarAction_tabs.js | 48 + .../browser/browser_ext_sidebarAction_windows.js | 69 + .../browser_ext_sidebar_requestPermission.js | 43 + .../extensions/test/browser/browser_ext_simple.js | 60 + .../test/browser/browser_ext_slow_script.js | 72 + .../test/browser/browser_ext_tab_runtimeConnect.js | 100 + .../test/browser/browser_ext_tabs_attention.js | 64 + .../test/browser/browser_ext_tabs_audio.js | 261 + .../browser/browser_ext_tabs_autoDiscardable.js | 177 + .../browser/browser_ext_tabs_containerIsolation.js | 360 + .../test/browser/browser_ext_tabs_cookieStoreId.js | 328 + .../browser_ext_tabs_cookieStoreId_private.js | 44 + .../test/browser/browser_ext_tabs_create.js | 299 + .../browser/browser_ext_tabs_create_invalid_url.js | 79 + .../test/browser/browser_ext_tabs_create_url.js | 230 + .../test/browser/browser_ext_tabs_discard.js | 98 + .../browser/browser_ext_tabs_discard_reversed.js | 129 + .../test/browser/browser_ext_tabs_discarded.js | 386 + .../test/browser/browser_ext_tabs_duplicate.js | 316 + .../test/browser/browser_ext_tabs_events.js | 794 + .../test/browser/browser_ext_tabs_events_order.js | 208 + .../test/browser/browser_ext_tabs_executeScript.js | 453 + .../browser_ext_tabs_executeScript_about_blank.js | 33 + .../browser/browser_ext_tabs_executeScript_bad.js | 361 + .../browser/browser_ext_tabs_executeScript_file.js | 93 + .../browser/browser_ext_tabs_executeScript_good.js | 190 + .../browser_ext_tabs_executeScript_multiple.js | 61 + .../browser_ext_tabs_executeScript_no_create.js | 80 + .../browser_ext_tabs_executeScript_runAt.js | 134 + .../test/browser/browser_ext_tabs_getCurrent.js | 86 + .../browser/browser_ext_tabs_goBack_goForward.js | 113 + .../test/browser/browser_ext_tabs_hide.js | 375 + .../test/browser/browser_ext_tabs_hide_update.js | 146 + .../test/browser/browser_ext_tabs_highlight.js | 118 + .../browser_ext_tabs_incognito_not_allowed.js | 155 + .../test/browser/browser_ext_tabs_insertCSS.js | 312 + .../test/browser/browser_ext_tabs_lastAccessed.js | 52 + .../test/browser/browser_ext_tabs_lazy.js | 49 + .../test/browser/browser_ext_tabs_move_array.js | 95 + ...browser_ext_tabs_move_array_multiple_windows.js | 160 + .../browser/browser_ext_tabs_move_discarded.js | 94 + .../test/browser/browser_ext_tabs_move_window.js | 178 + .../browser_ext_tabs_move_window_multiple.js | 64 + .../browser/browser_ext_tabs_move_window_pinned.js | 44 + .../browser/browser_ext_tabs_newtab_private.js | 96 + .../test/browser/browser_ext_tabs_onCreated.js | 35 + .../test/browser/browser_ext_tabs_onHighlighted.js | 130 + .../test/browser/browser_ext_tabs_onUpdated.js | 339 + .../browser/browser_ext_tabs_onUpdated_filter.js | 354 + .../test/browser/browser_ext_tabs_opener.js | 130 + .../test/browser/browser_ext_tabs_printPreview.js | 44 + .../test/browser/browser_ext_tabs_query.js | 468 + .../test/browser/browser_ext_tabs_readerMode.js | 138 + .../test/browser/browser_ext_tabs_reload.js | 53 + .../browser_ext_tabs_reload_bypass_cache.js | 89 + .../test/browser/browser_ext_tabs_remove.js | 258 + .../test/browser/browser_ext_tabs_removeCSS.js | 151 + .../test/browser/browser_ext_tabs_saveAsPDF.js | 197 + .../test/browser/browser_ext_tabs_sendMessage.js | 385 + .../test/browser/browser_ext_tabs_sharingState.js | 110 + .../test/browser/browser_ext_tabs_successors.js | 396 + .../test/browser/browser_ext_tabs_update.js | 54 + .../browser/browser_ext_tabs_update_highlighted.js | 183 + .../test/browser/browser_ext_tabs_update_url.js | 235 + .../test/browser/browser_ext_tabs_warmup.js | 40 + .../test/browser/browser_ext_tabs_zoom.js | 346 + .../test/browser/browser_ext_themes_validation.js | 55 + .../test/browser/browser_ext_topSites.js | 413 + .../browser/browser_ext_url_overrides_newtab.js | 794 + .../test/browser/browser_ext_user_events.js | 271 + ...browser_ext_webNavigation_containerIsolation.js | 169 + .../browser/browser_ext_webNavigation_frameId0.js | 43 + .../browser/browser_ext_webNavigation_getFrames.js | 323 + ..._ext_webNavigation_onCreatedNavigationTarget.js | 194 + ...gation_onCreatedNavigationTarget_contextmenu.js | 182 + ...ation_onCreatedNavigationTarget_named_window.js | 100 + ...CreatedNavigationTarget_subframe_window_open.js | 168 + ...gation_onCreatedNavigationTarget_window_open.js | 168 + ...browser_ext_webNavigation_urlbar_transitions.js | 314 + .../test/browser/browser_ext_webRequest.js | 142 + ...ext_webRequest_error_after_stopped_or_closed.js | 110 + .../extensions/test/browser/browser_ext_webrtc.js | 131 + .../extensions/test/browser/browser_ext_windows.js | 348 + .../browser_ext_windows_allowScriptsToClose.js | 69 + .../test/browser/browser_ext_windows_create.js | 205 + .../browser_ext_windows_create_cookieStoreId.js | 345 + .../browser/browser_ext_windows_create_params.js | 249 + .../browser/browser_ext_windows_create_tabId.js | 387 + .../test/browser/browser_ext_windows_create_url.js | 253 + .../test/browser/browser_ext_windows_events.js | 222 + .../test/browser/browser_ext_windows_incognito.js | 84 + .../test/browser/browser_ext_windows_remove.js | 53 + .../test/browser/browser_ext_windows_size.js | 122 + .../test/browser/browser_ext_windows_update.js | 390 + .../test/browser/browser_legacy_recent_tabs.toml | 34 + .../browser_toolbar_prefers_color_scheme.js | 266 + .../test/browser/browser_unified_extensions.js | 1545 + .../browser_unified_extensions_accessibility.js | 302 + .../browser_unified_extensions_context_menu.js | 1006 + .../test/browser/browser_unified_extensions_cui.js | 159 + .../browser_unified_extensions_doorhangers.js | 116 + .../browser/browser_unified_extensions_messages.js | 222 + ...wser_unified_extensions_overflowable_toolbar.js | 1389 + .../extensions/test/browser/context.html | 44 + .../extensions/test/browser/context_frame.html | 8 + .../browser/context_tabs_onUpdated_iframe.html | 19 + .../test/browser/context_tabs_onUpdated_page.html | 18 + .../test/browser/context_with_redirect.html | 4 + .../extensions/test/browser/ctxmenu-image.png | Bin 0 -> 5401 bytes .../components/extensions/test/browser/empty.xpi | 0 .../extensions/test/browser/file_bypass_cache.sjs | 13 + .../test/browser/file_dataTransfer_files.html | 36 + .../extensions/test/browser/file_dummy.html | 10 + .../extensions/test/browser/file_find_frames.html | 19 + ...ile_has_non_web_controlled_blank_page_link.html | 5 + .../test/browser/file_iframe_document.html | 11 + .../test/browser/file_inspectedwindow_eval.html | 29 + .../browser/file_inspectedwindow_reload_target.sjs | 130 + .../test/browser/file_popup_api_injection_a.html | 10 + .../test/browser/file_popup_api_injection_b.html | 10 + .../test/browser/file_slowed_document.sjs | 49 + .../extensions/test/browser/file_title.html | 9 + .../test/browser/file_with_example_com_frame.html | 5 + .../test/browser/file_with_xorigin_frame.html | 5 + browser/components/extensions/test/browser/head.js | 1046 + .../extensions/test/browser/head_browserAction.js | 368 + .../extensions/test/browser/head_devtools.js | 162 + .../extensions/test/browser/head_pageAction.js | 232 + .../extensions/test/browser/head_sessions.js | 64 + .../test/browser/head_unified_extensions.js | 199 + .../extensions/test/browser/head_webNavigation.js | 49 + .../extensions/test/browser/redirect_to.sjs | 9 + .../browser/search-engines/another/manifest.json | 19 + .../browser/search-engines/basic/manifest.json | 19 + .../test/browser/search-engines/engines.json | 38 + .../browser/search-engines/simple/manifest.json | 29 + .../test/browser/searchSuggestionEngine.sjs | 10 + .../test/browser/searchSuggestionEngine.xml | 9 + .../components/extensions/test/browser/silence.ogg | Bin 0 -> 3557 bytes .../extensions/test/browser/wait-a-bit.sjs | 23 + .../test/browser/webNav_createdTarget.html | 10 + .../test/browser/webNav_createdTargetSource.html | 45 + .../webNav_createdTargetSource_subframe.html | 42 + .../extensions/test/mochitest/.eslintrc.js | 8 + .../extensions/test/mochitest/mochitest.toml | 14 + .../test/mochitest/test_ext_all_apis.html | 83 + .../extensions/test/xpcshell/.eslintrc.js | 9 + .../test/xpcshell/data/test/manifest.json | 80 + .../test/xpcshell/data/test2/manifest.json | 23 + .../components/extensions/test/xpcshell/head.js | 78 + .../extensions/test/xpcshell/test_ext_bookmarks.js | 1725 + .../xpcshell/test_ext_browsingData_downloads.js | 126 + .../xpcshell/test_ext_browsingData_passwords.js | 96 + .../xpcshell/test_ext_browsingData_settings.js | 147 + .../test_ext_chrome_settings_overrides_home.js | 231 + .../test_ext_chrome_settings_overrides_update.js | 794 + .../test/xpcshell/test_ext_distribution_popup.js | 56 + .../extensions/test/xpcshell/test_ext_history.js | 864 + .../test_ext_homepage_overrides_private.js | 134 + .../extensions/test/xpcshell/test_ext_manifest.js | 105 + .../test/xpcshell/test_ext_manifest_commands.js | 52 + .../test/xpcshell/test_ext_manifest_omnibox.js | 62 + .../test/xpcshell/test_ext_manifest_permissions.js | 85 + .../test/xpcshell/test_ext_menu_caller.js | 53 + .../test/xpcshell/test_ext_menu_startup.js | 432 + .../test/xpcshell/test_ext_normandyAddonStudy.js | 243 + .../test/xpcshell/test_ext_pageAction_shutdown.js | 81 + .../test/xpcshell/test_ext_pkcs11_management.js | 300 + .../test_ext_settings_overrides_defaults.js | 263 + .../xpcshell/test_ext_settings_overrides_search.js | 597 + .../test_ext_settings_overrides_search_mozParam.js | 239 + .../test_ext_settings_overrides_shutdown.js | 109 + .../test/xpcshell/test_ext_settings_validate.js | 193 + .../extensions/test/xpcshell/test_ext_topSites.js | 293 + .../test/xpcshell/test_ext_url_overrides_newtab.js | 340 + .../test_ext_url_overrides_newtab_update.js | 127 + .../extensions/test/xpcshell/xpcshell.toml | 69 + browser/components/firefoxview/OpenTabs.sys.mjs | 410 + browser/components/firefoxview/card-container.css | 171 + browser/components/firefoxview/card-container.mjs | 208 + .../content/callout-tab-pickup-dark.svg | 4 + .../firefoxview/content/callout-tab-pickup.svg | 4 + .../firefoxview/content/category-history.svg | 6 + .../firefoxview/content/category-opentabs.svg | 6 + .../content/category-recentbrowsing.svg | 7 + .../content/category-recentlyclosed.svg | 7 + .../firefoxview/content/category-syncedtabs.svg | 6 + .../firefoxview/content/history-empty.svg | 20 + .../firefoxview/content/recentlyclosed-empty.svg | 25 + .../firefoxview/content/synced-tabs-error.svg | 30 + .../firefox-view-notification-manager.sys.mjs | 112 + .../firefoxview/firefox-view-places-query.sys.mjs | 187 + .../firefox-view-synced-tabs-error-handler.sys.mjs | 187 + .../firefox-view-tabs-setup-manager.sys.mjs | 653 + browser/components/firefoxview/firefoxview.css | 187 + browser/components/firefoxview/firefoxview.html | 118 + browser/components/firefoxview/firefoxview.mjs | 189 + .../firefoxview/fxview-category-button.css | 125 + .../firefoxview/fxview-category-navigation.css | 60 + .../firefoxview/fxview-category-navigation.mjs | 150 + .../components/firefoxview/fxview-empty-state.css | 99 + .../components/firefoxview/fxview-empty-state.mjs | 121 + .../firefoxview/fxview-search-textbox.css | 78 + .../firefoxview/fxview-search-textbox.mjs | 143 + browser/components/firefoxview/fxview-tab-list.css | 24 + browser/components/firefoxview/fxview-tab-list.mjs | 919 + browser/components/firefoxview/fxview-tab-row.css | 204 + browser/components/firefoxview/helpers.mjs | 175 + browser/components/firefoxview/history.css | 80 + browser/components/firefoxview/history.mjs | 656 + browser/components/firefoxview/jar.mn | 40 + browser/components/firefoxview/moz.build | 22 + browser/components/firefoxview/opentabs.mjs | 834 + browser/components/firefoxview/recentbrowsing.mjs | 65 + browser/components/firefoxview/recentlyclosed.mjs | 473 + browser/components/firefoxview/syncedtabs.mjs | 725 + .../tests/browser/FirefoxViewTestUtils.sys.mjs | 177 + .../firefoxview/tests/browser/browser.toml | 74 + .../browser_dragDrop_after_opening_fxViewTab.js | 120 + .../tests/browser/browser_entrypoint_management.js | 67 + .../tests/browser/browser_feature_callout.js | 746 + .../browser/browser_feature_callout_position.js | 445 + .../browser/browser_feature_callout_resize.js | 178 + .../browser/browser_feature_callout_targeting.js | 175 + .../tests/browser/browser_feature_callout_theme.js | 80 + .../tests/browser/browser_firefoxview.js | 87 + .../browser_firefoxview_general_telemetry.js | 368 + .../browser/browser_firefoxview_navigation.js | 96 + .../tests/browser/browser_firefoxview_paused.js | 407 + .../browser_firefoxview_search_telemetry.js | 629 + .../tests/browser/browser_firefoxview_tab.js | 370 + .../browser/browser_firefoxview_virtual_list.js | 85 + .../tests/browser/browser_history_firefoxview.js | 544 + .../tests/browser/browser_notification_dot.js | 392 + .../tests/browser/browser_opentabs_cards.js | 628 + .../tests/browser/browser_opentabs_changes.js | 541 + .../tests/browser/browser_opentabs_firefoxview.js | 423 + .../tests/browser/browser_opentabs_recency.js | 408 + .../browser/browser_opentabs_tab_indicators.js | 207 + .../browser/browser_recentlyclosed_firefoxview.js | 600 + .../tests/browser/browser_reload_firefoxview.js | 36 + .../browser_syncedtabs_errors_firefoxview.js | 141 + .../browser/browser_syncedtabs_firefoxview.js | 747 + .../tests/browser/browser_tab_close_last_tab.js | 43 + .../tests/browser/browser_tab_on_close_warning.js | 60 + .../components/firefoxview/tests/browser/head.js | 708 + .../firefoxview/tests/chrome/chrome.toml | 7 + .../tests/chrome/test_card_container.html | 122 + .../chrome/test_fxview_category_navigation.html | 322 + .../tests/chrome/test_fxview_tab_list.html | 447 + browser/components/firefoxview/triage.json | 31 + browser/components/firefoxview/view-opentabs.css | 44 + browser/components/firefoxview/view-syncedtabs.css | 118 + browser/components/firefoxview/viewpage.mjs | 261 + .../installerprefs/InstallerPrefs.sys.mjs | 141 + browser/components/installerprefs/components.conf | 16 + browser/components/installerprefs/moz.build | 18 + .../components/installerprefs/test/unit/head.js | 137 + .../test/unit/test_empty_prefs_list.js | 20 + .../installerprefs/test/unit/test_invalid_name.js | 20 + .../installerprefs/test/unit/test_nonbool_pref.js | 19 + .../installerprefs/test/unit/test_pref_change.js | 26 + .../installerprefs/test/unit/test_pref_values.js | 19 + .../installerprefs/test/unit/xpcshell.toml | 25 + browser/components/ion/content/ion.css | 180 + browser/components/ion/content/ion.ftl | 83 + browser/components/ion/content/ion.html | 206 + browser/components/ion/content/ion.js | 791 + browser/components/ion/jar.mn | 8 + browser/components/ion/moz.build | 17 + .../components/ion/schemas/IonContentSchema.json | 39 + .../ion/schemas/IonStudyAddonsSchema.json | 159 + browser/components/ion/test/browser/browser.toml | 4 + .../components/ion/test/browser/browser_ion_ui.js | 1128 + .../actors/AboutMessagePreviewChild.sys.mjs | 58 + .../actors/AboutMessagePreviewParent.sys.mjs | 107 + browser/components/messagepreview/jar.mn | 9 + browser/components/messagepreview/limelight.svg | 4 + .../components/messagepreview/messagepreview.css | 33 + .../components/messagepreview/messagepreview.html | 29 + .../components/messagepreview/messagepreview.js | 39 + browser/components/messagepreview/moz.build | 17 + browser/components/metrics.yaml | 80 + browser/components/migration/.eslintrc.js | 36 + .../migration/360seMigrationUtils.sys.mjs | 190 + .../migration/ChromeMacOSLoginCrypto.sys.mjs | 185 + .../migration/ChromeMigrationUtils.sys.mjs | 499 + .../migration/ChromeProfileMigrator.sys.mjs | 1253 + .../migration/ChromeWindowsLoginCrypto.sys.mjs | 175 + browser/components/migration/ESEDBReader.sys.mjs | 799 + .../migration/EdgeProfileMigrator.sys.mjs | 582 + browser/components/migration/FileMigrators.sys.mjs | 359 + .../migration/FirefoxProfileMigrator.sys.mjs | 400 + .../components/migration/IEProfileMigrator.sys.mjs | 133 + .../InternalTestingProfileMigrator.sys.mjs | 76 + .../components/migration/MSMigrationUtils.sys.mjs | 749 + .../components/migration/MigrationUtils.sys.mjs | 1175 + .../migration/MigrationWizardChild.sys.mjs | 400 + .../migration/MigrationWizardParent.sys.mjs | 834 + browser/components/migration/MigratorBase.sys.mjs | 599 + .../components/migration/ProfileMigrator.sys.mjs | 15 + .../migration/SafariProfileMigrator.sys.mjs | 678 + browser/components/migration/components.conf | 37 + .../migration/content/aboutWelcomeBack.xhtml | 126 + .../components/migration/content/brands/360.png | Bin 0 -> 21075 bytes .../components/migration/content/brands/brave.png | Bin 0 -> 7099 bytes .../components/migration/content/brands/canary.png | Bin 0 -> 7463 bytes .../components/migration/content/brands/chrome.png | Bin 0 -> 8353 bytes .../migration/content/brands/chromium.png | Bin 0 -> 6408 bytes .../components/migration/content/brands/edge.png | Bin 0 -> 11899 bytes .../migration/content/brands/edgebeta.png | Bin 0 -> 12273 bytes browser/components/migration/content/brands/ie.png | Bin 0 -> 6871 bytes .../components/migration/content/brands/opera.png | Bin 0 -> 5403 bytes .../migration/content/brands/operagx.png | Bin 0 -> 8222 bytes .../components/migration/content/brands/safari.png | Bin 0 -> 20520 bytes .../migration/content/brands/vivaldi.png | Bin 0 -> 7535 bytes .../migration/content/migration-dialog-window.html | 37 + .../migration/content/migration-dialog-window.js | 116 + .../content/migration-wizard-constants.mjs | 124 + .../migration/content/migration-wizard.mjs | 1372 + browser/components/migration/docs/index.rst | 16 + .../components/migration/docs/migration-utils.rst | 5 + .../docs/migration-wizard-architecture-diagram.svg | 128 + .../components/migration/docs/migration-wizard.rst | 72 + browser/components/migration/docs/migrators.rst | 112 + browser/components/migration/jar.mn | 31 + browser/components/migration/metrics.yaml | 42 + browser/components/migration/moz.build | 85 + .../components/migration/nsEdgeMigrationUtils.cpp | 61 + .../components/migration/nsEdgeMigrationUtils.h | 24 + .../components/migration/nsIEHistoryEnumerator.cpp | 116 + .../components/migration/nsIEHistoryEnumerator.h | 39 + .../components/migration/nsIEdgeMigrationUtils.idl | 23 + .../migration/nsIKeychainMigrationUtils.idl | 12 + .../migration/nsKeychainMigrationUtils.h | 23 + .../migration/nsKeychainMigrationUtils.mm | 68 + .../components/migration/nsWindowsMigrationUtils.h | 33 + .../migration/tests/browser/browser.toml | 50 + .../tests/browser/browser_aboutwelcome_behavior.js | 100 + .../tests/browser/browser_dialog_cancel_close.js | 55 + .../migration/tests/browser/browser_dialog_open.js | 55 + .../tests/browser/browser_dialog_resize.js | 29 + .../tests/browser/browser_disabled_migrator.js | 131 + .../tests/browser/browser_do_migration.js | 209 + .../tests/browser/browser_entrypoint_telemetry.js | 72 + .../tests/browser/browser_extension_migration.js | 238 + .../tests/browser/browser_file_migration.js | 306 + .../browser_ie_edge_bookmarks_success_strings.js | 89 + .../tests/browser/browser_misc_telemetry.js | 178 + .../tests/browser/browser_no_browsers_state.js | 92 + .../tests/browser/browser_only_file_migrators.js | 71 + .../migration/tests/browser/browser_permissions.js | 166 + .../tests/browser/browser_safari_passwords.js | 468 + .../tests/browser/browser_safari_permissions.js | 136 + .../migration/tests/browser/dummy_file.csv | 1 + browser/components/migration/tests/browser/head.js | 534 + .../components/migration/tests/chrome/chrome.toml | 5 + .../tests/chrome/test_migration_wizard.html | 1533 + browser/components/migration/tests/head-common.js | 24 + .../migration/tests/marionette/manifest.toml | 4 + .../tests/marionette/test_refresh_firefox.py | 703 + .../Local/Google/Chrome/User Data/Default/Favicons | Bin 0 -> 49152 bytes .../Google/Chrome/User Data/Default/Login Data | Bin 0 -> 24576 bytes .../Local/Google/Chrome/User Data/Default/Web Data | Bin 0 -> 108544 bytes .../Local/Google/Chrome/User Data/Local State | 5 + .../Google/Chrome/User Data/Default/Login Data | Bin 0 -> 24576 bytes .../0f3ab103a522f4463ecacc36d34eb996/360sefav.dat | Bin 0 -> 6144 bytes .../Roaming/360se6/User Data/Default/360Bookmarks | 1 + .../DailyBackup/360default_ori_2020_08_28.favdb | 3 + .../DailyBackup/360default_ori_2021_12_02.favdb | 3 + .../DailyBackup/360sefav_new_2020_08_28.favdb | 1 + .../DailyBackup/360sefav_new_2021_12_02.favdb | 1 + .../0f3ab103a522f4463ecacc36d34eb996/360sefav.dat | 1 + .../DailyBackup/360sefav_2020_08_28.favdb | 3 + .../360se6/User Data/Default4SE9Test/Bookmarks | 3 + .../AppData/Roaming/360se6/User Data/Local State | 12 + .../Google/Chrome/Default/Cookies | Bin 0 -> 10240 bytes .../fake-app-1/1.0_0/_locales/en_US/messages.json | 9 + .../Extensions/fake-app-1/1.0_0/manifest.json | 10 + .../1.0_0/_locales/en_US/messages.json | 9 + .../fake-extension-1/1.0_0/manifest.json | 5 + .../fake-extension-2/1.0_0/manifest.json | 4 + .../Google/Chrome/Default/HistoryCorrupt | Bin 0 -> 91558 bytes .../Google/Chrome/Default/HistoryMaster | Bin 0 -> 118784 bytes .../Google/Chrome/Default/Login Data | Bin 0 -> 24576 bytes .../Application Support/Google/Chrome/Local State | 22 + .../tests/unit/Library/Safari/Bookmarks.plist | Bin 0 -> 2252 bytes .../unit/Library/Safari/Favicon Cache/favicons.db | Bin 0 -> 40960 bytes .../Library/Safari/Favicon Cache/favicons.db-lock | 0 .../Library/Safari/Favicon Cache/favicons.db-shm | Bin 0 -> 32768 bytes .../Library/Safari/Favicon Cache/favicons.db-wal | Bin 0 -> 280192 bytes .../favicons/04860FA3D07D8936B87D2B965317C6E9 | Bin 0 -> 24838 bytes .../favicons/19A777E4F7BDA0C0E350D6C681B6E271 | Bin 0 -> 15086 bytes .../favicons/2558A57A1AE576AA31F0DCD1364B3F42 | Bin 0 -> 5558 bytes .../favicons/57D1907A1EBDA1889AA85B8AB7A90804 | Bin 0 -> 5558 bytes .../favicons/6EEDD53B65A19CB364EB6FB07DEACF80 | Bin 0 -> 22382 bytes .../favicons/7F65370AD319C7B294EDF2E2BEBA880F | Bin 0 -> 2734 bytes .../favicons/999E2BD5CD612AA550F222A1088DB3D8 | Bin 0 -> 15406 bytes .../favicons/9D8A6E2153D42043A7AE0430B41D374A | Bin 0 -> 5558 bytes .../favicons/A21F634481CF5188329FD2052F07ADBC | Bin 0 -> 5558 bytes .../favicons/BC2288B5BA9B7BE352BA586257442E08 | Bin 0 -> 22382 bytes .../favicons/CFFC3831D8E7201BF8B77728FC79B52B | Bin 0 -> 15406 bytes .../favicons/F3FA61DDA95B78A8B5F2C392C0382137 | Bin 0 -> 5430 bytes .../unit/Library/Safari/HistoryStrangeEntries.db | Bin 0 -> 98304 bytes .../tests/unit/Library/Safari/HistoryTemplate.db | Bin 0 -> 98304 bytes .../Google/Chrome/Default/Login Data | Bin 0 -> 24576 bytes .../Application Support/Google/Chrome/Local State | 22 + .../migration/tests/unit/bookmarks.exported.html | 32 + .../migration/tests/unit/bookmarks.exported.json | 194 + .../migration/tests/unit/bookmarks.invalid.html | 1 + .../migration/tests/unit/head_migration.js | 260 + .../tests/unit/insertIEHistory/InsertIEHistory.cpp | 38 + .../migration/tests/unit/insertIEHistory/moz.build | 19 + .../tests/unit/test_360seMigrationUtils.js | 164 + .../migration/tests/unit/test_360se_bookmarks.js | 62 + .../tests/unit/test_BookmarksFileMigrator.js | 134 + .../tests/unit/test_ChromeMigrationUtils.js | 87 + .../tests/unit/test_ChromeMigrationUtils_path.js | 141 + ...test_ChromeMigrationUtils_path_chromium_snap.js | 55 + .../migration/tests/unit/test_Chrome_bookmarks.js | 205 + .../tests/unit/test_Chrome_corrupt_history.js | 83 + .../tests/unit/test_Chrome_credit_cards.js | 239 + .../migration/tests/unit/test_Chrome_extensions.js | 165 + .../migration/tests/unit/test_Chrome_formdata.js | 118 + .../migration/tests/unit/test_Chrome_history.js | 206 + .../migration/tests/unit/test_Chrome_passwords.js | 373 + .../unit/test_Chrome_passwords_emptySource.js | 43 + .../tests/unit/test_Chrome_permissions.js | 205 + .../migration/tests/unit/test_Edge_db_migration.js | 849 + .../tests/unit/test_Edge_registry_migration.js | 81 + .../migration/tests/unit/test_IE_bookmarks.js | 30 + .../migration/tests/unit/test_IE_history.js | 187 + .../tests/unit/test_MigrationUtils_timedRetry.js | 29 + .../tests/unit/test_PasswordFileMigrator.js | 116 + .../migration/tests/unit/test_Safari_bookmarks.js | 85 + .../migration/tests/unit/test_Safari_history.js | 101 + .../unit/test_Safari_history_strange_entries.js | 115 + .../tests/unit/test_Safari_permissions.js | 145 + .../migration/tests/unit/test_fx_telemetry.js | 436 + .../components/migration/tests/unit/xpcshell.toml | 95 + browser/components/moz.build | 117 + browser/components/newtab/.eslintrc.js | 184 + browser/components/newtab/.nvmrc | 1 + .../components/newtab/AboutNewTabService.sys.mjs | 510 + .../newtab/bin/render-activity-stream-html.js | 188 + browser/components/newtab/bin/try-runner.js | 366 + browser/components/newtab/bin/vendor.js | 38 + browser/components/newtab/common/Actions.sys.mjs | 457 + browser/components/newtab/common/Dedupe.sys.mjs | 36 + browser/components/newtab/common/Reducers.sys.mjs | 855 + browser/components/newtab/components.conf | 14 + .../newtab/components/CustomElements/paragraph.js | 72 + .../newtab/content-src/activity-stream.jsx | 57 + .../components/A11yLinkButton/A11yLinkButton.jsx | 18 + .../components/A11yLinkButton/_A11yLinkButton.scss | 13 + .../newtab/content-src/components/Base/Base.jsx | 262 + .../newtab/content-src/components/Base/_Base.scss | 126 + .../newtab/content-src/components/Card/Card.jsx | 362 + .../newtab/content-src/components/Card/_Card.scss | 333 + .../newtab/content-src/components/Card/types.js | 30 + .../CollapsibleSection/CollapsibleSection.jsx | 116 + .../CollapsibleSection/_CollapsibleSection.scss | 108 + .../ComponentPerfTimer/ComponentPerfTimer.jsx | 177 + .../components/ConfirmDialog/ConfirmDialog.jsx | 103 + .../components/ConfirmDialog/_ConfirmDialog.scss | 68 + .../components/ContextMenu/ContextMenu.jsx | 176 + .../components/ContextMenu/ContextMenuButton.jsx | 72 + .../components/ContextMenu/_ContextMenu.scss | 59 + .../BackgroundsSection/BackgroundsSection.jsx | 11 + .../ContentSection/ContentSection.jsx | 270 + .../components/CustomizeMenu/CustomizeMenu.jsx | 85 + .../components/CustomizeMenu/_CustomizeMenu.scss | 244 + .../DiscoveryStreamAdmin/DiscoveryStreamAdmin.jsx | 506 + .../DiscoveryStreamAdmin/DiscoveryStreamAdmin.scss | 337 + .../DiscoveryStreamAdmin/SimpleHashRouter.jsx | 35 + .../DiscoveryStreamBase/DiscoveryStreamBase.jsx | 386 + .../DiscoveryStreamBase/_DiscoveryStreamBase.scss | 67 + .../CardGrid/CardGrid.jsx | 542 + .../CardGrid/_CardGrid.scss | 352 + .../CollectionCardGrid/CollectionCardGrid.jsx | 139 + .../CollectionCardGrid/_CollectionCardGrid.scss | 38 + .../DiscoveryStreamComponents/DSCard/DSCard.jsx | 529 + .../DiscoveryStreamComponents/DSCard/_DSCard.scss | 303 + .../DSContextFooter/DSContextFooter.jsx | 145 + .../DSContextFooter/_DSContextFooter.scss | 81 + .../DSDismiss/DSDismiss.jsx | 56 + .../DSDismiss/_DSDismiss.scss | 48 + .../DSEmptyState/DSEmptyState.jsx | 100 + .../DSEmptyState/_DSEmptyState.scss | 83 + .../DiscoveryStreamComponents/DSImage/DSImage.jsx | 263 + .../DSImage/_DSImage.scss | 48 + .../DSLinkMenu/DSLinkMenu.jsx | 70 + .../DSLinkMenu/_DSLinkMenu.scss | 28 + .../DSMessage/DSMessage.jsx | 34 + .../DSMessage/_DSMessage.scss | 37 + .../DSPrivacyModal/DSPrivacyModal.jsx | 72 + .../DSPrivacyModal/_DSPrivacyModal.scss | 48 + .../DSSignup/DSSignup.jsx | 168 + .../DSSignup/DSSignup.scss | 52 + .../DSTextPromo/DSTextPromo.jsx | 143 + .../DSTextPromo/_DSTextPromo.scss | 92 + .../Highlights/Highlights.jsx | 26 + .../Highlights/_Highlights.scss | 47 + .../HorizontalRule/HorizontalRule.jsx | 11 + .../HorizontalRule/_HorizontalRule.scss | 7 + .../Navigation/Navigation.jsx | 112 + .../Navigation/_Navigation.scss | 182 + .../PrivacyLink/PrivacyLink.jsx | 20 + .../PrivacyLink/_PrivacyLink.scss | 10 + .../SafeAnchor/SafeAnchor.jsx | 65 + .../SectionTitle/SectionTitle.jsx | 19 + .../SectionTitle/_SectionTitle.scss | 18 + .../TopSites/_TopSites.scss | 79 + .../TopicsWidget/TopicsWidget.jsx | 125 + .../TopicsWidget/_TopicsWidget.scss | 90 + .../ImpressionStats.jsx | 251 + .../_ImpressionStats.scss | 7 + .../components/ErrorBoundary/ErrorBoundary.jsx | 68 + .../components/ErrorBoundary/_ErrorBoundary.scss | 21 + .../components/FluentOrText/FluentOrText.jsx | 36 + .../content-src/components/LinkMenu/LinkMenu.jsx | 110 + .../components/ModalOverlay/ModalOverlay.jsx | 56 + .../components/ModalOverlay/_ModalOverlay.scss | 103 + .../MoreRecommendations/MoreRecommendations.jsx | 21 + .../MoreRecommendations/_MoreRecommendations.scss | 24 + .../PocketLoggedInCta/PocketLoggedInCta.jsx | 42 + .../PocketLoggedInCta/_PocketLoggedInCta.scss | 42 + .../content-src/components/Search/Search.jsx | 189 + .../content-src/components/Search/_Search.scss | 394 + .../content-src/components/Sections/Sections.jsx | 378 + .../content-src/components/Sections/_Sections.scss | 123 + .../components/TopSites/SearchShortcutsForm.jsx | 192 + .../content-src/components/TopSites/TopSite.jsx | 889 + .../components/TopSites/TopSiteForm.jsx | 323 + .../components/TopSites/TopSiteFormInput.jsx | 111 + .../TopSites/TopSiteImpressionWrapper.jsx | 149 + .../content-src/components/TopSites/TopSites.jsx | 213 + .../components/TopSites/TopSitesConstants.js | 39 + .../content-src/components/TopSites/_TopSites.scss | 631 + .../content-src/components/Topics/Topics.jsx | 33 + .../content-src/components/Topics/_Topics.scss | 24 + .../components/newtab/content-src/lib/constants.js | 38 + .../content-src/lib/detect-user-session-start.js | 82 + .../newtab/content-src/lib/init-store.js | 140 + .../newtab/content-src/lib/link-menu-options.js | 309 + .../newtab/content-src/lib/perf-service.js | 104 + .../newtab/content-src/lib/screenshot-utils.js | 61 + .../newtab/content-src/lib/selectLayoutRender.js | 255 + .../content-src/styles/_activity-stream.scss | 172 + .../newtab/content-src/styles/_icons.scss | 211 + .../newtab/content-src/styles/_mixins.scss | 50 + .../newtab/content-src/styles/_normalize.scss | 29 + .../newtab/content-src/styles/_theme.scss | 97 + .../newtab/content-src/styles/_variables.scss | 215 + .../content-src/styles/activity-stream-linux.scss | 11 + .../content-src/styles/activity-stream-mac.scss | 16 + .../styles/activity-stream-windows.scss | 11 + .../newtab/css/activity-stream-linux.css | 4248 ++ .../components/newtab/css/activity-stream-mac.css | 4252 ++ .../newtab/css/activity-stream-windows.css | 4248 ++ .../data/content/abouthomecache/page.html.template | 46 + .../data/content/abouthomecache/script.js.template | 19 + .../newtab/data/content/activity-stream.bundle.js | 9558 +++++ .../newtab/data/content/assets/firefox.svg | 168 + .../data/content/assets/glyph-cfr-feature-16.svg | 4 + .../newtab/data/content/assets/glyph-mail-16.svg | 4 + .../data/content/assets/glyph-maximize-16.svg | 4 + .../data/content/assets/glyph-minimize-16.svg | 4 + .../data/content/assets/glyph-modal-delete-20.svg | 8 + .../data/content/assets/glyph-newWindow-16.svg | 4 + .../data/content/assets/glyph-open-file-16.svg | 4 + .../newtab/data/content/assets/glyph-pin-16.svg | 6 + .../content/assets/glyph-pocket-archive-16.svg | 4 + .../data/content/assets/glyph-pocket-delete-16.svg | 4 + .../newtab/data/content/assets/glyph-unpin-16.svg | 4 + .../data/content/assets/glyph-webextension-16.svg | 4 + .../data/content/assets/icon-removed-bookmark.svg | 4 + .../data/content/assets/pocket-onboarding.avif | Bin 0 -> 7462 bytes .../data/content/assets/pocket-onboarding@2x.avif | Bin 0 -> 18590 bytes .../newtab/data/content/assets/pocket-swoosh.svg | 11 + .../newtab/data/content/assets/remote/mountain.svg | 12 + .../newtab/data/content/assets/remote/umbrella.png | Bin 0 -> 4292 bytes .../newtab/data/content/assets/spinner.svg | 4 + .../newtab/data/content/newtab-render.js | 11 + .../data/content/tippytop/favicons/adidas.png | Bin 0 -> 3226 bytes .../content/tippytop/favicons/aliexpress-com.ico | Bin 0 -> 4286 bytes .../data/content/tippytop/favicons/allegro-pl.ico | Bin 0 -> 1150 bytes .../data/content/tippytop/favicons/amazon.ico | Bin 0 -> 1407 bytes .../data/content/tippytop/favicons/avito-ru.ico | Bin 0 -> 5430 bytes .../data/content/tippytop/favicons/baidu-com.png | Bin 0 -> 1983 bytes .../data/content/tippytop/favicons/bbc-uk.ico | Bin 0 -> 958 bytes .../data/content/tippytop/favicons/bing-com.ico | Bin 0 -> 4286 bytes .../data/content/tippytop/favicons/ctrip-com.ico | Bin 0 -> 1150 bytes .../content/tippytop/favicons/duckduckgo-com.ico | Bin 0 -> 2799 bytes .../newtab/data/content/tippytop/favicons/ebay.ico | Bin 0 -> 1455 bytes .../newtab/data/content/tippytop/favicons/etsy.ico | Bin 0 -> 4286 bytes .../content/tippytop/favicons/facebook-com.ico | Bin 0 -> 5430 bytes .../data/content/tippytop/favicons/geico.png | Bin 0 -> 1472 bytes .../data/content/tippytop/favicons/google-com.ico | Bin 0 -> 5430 bytes .../data/content/tippytop/favicons/hrblock.ico | Bin 0 -> 3950 bytes .../data/content/tippytop/favicons/ifeng-com.ico | Bin 0 -> 4038 bytes .../data/content/tippytop/favicons/iqiyi-com.ico | Bin 0 -> 5430 bytes .../content/tippytop/favicons/leboncoin-fr.png | Bin 0 -> 454 bytes .../newtab/data/content/tippytop/favicons/nike.ico | Bin 0 -> 1150 bytes .../data/content/tippytop/favicons/ok-ru.ico | Bin 0 -> 5430 bytes .../data/content/tippytop/favicons/olx-pl.ico | Bin 0 -> 5430 bytes .../data/content/tippytop/favicons/reddit-com.png | Bin 0 -> 2094 bytes .../data/content/tippytop/favicons/samsung.ico | Bin 0 -> 4286 bytes .../data/content/tippytop/favicons/turbotax.png | Bin 0 -> 3744 bytes .../data/content/tippytop/favicons/twitter-com.ico | Bin 0 -> 1650 bytes .../data/content/tippytop/favicons/vk-com.ico | Bin 0 -> 302 bytes .../data/content/tippytop/favicons/vodafone.png | Bin 0 -> 1757 bytes .../data/content/tippytop/favicons/weibo-com.ico | Bin 0 -> 10134 bytes .../content/tippytop/favicons/wikipedia-org.ico | Bin 0 -> 2734 bytes .../newtab/data/content/tippytop/favicons/wix.ico | Bin 0 -> 1061 bytes .../data/content/tippytop/favicons/wykop-pl.png | Bin 0 -> 1705 bytes .../data/content/tippytop/favicons/yandex-com.png | Bin 0 -> 1338 bytes .../data/content/tippytop/favicons/yandex-ru.png | Bin 0 -> 1368 bytes .../data/content/tippytop/favicons/youtube-com.png | Bin 0 -> 348 bytes .../data/content/tippytop/favicons/zhihu-com.ico | Bin 0 -> 6518 bytes .../data/content/tippytop/images/adidas@2x.png | Bin 0 -> 5448 bytes .../content/tippytop/images/aliexpress-com@2x.png | Bin 0 -> 12459 bytes .../data/content/tippytop/images/allegro-pl@2x.png | Bin 0 -> 5041 bytes .../data/content/tippytop/images/amazon@2x.png | Bin 0 -> 6061 bytes .../data/content/tippytop/images/avito-ru@2x.png | Bin 0 -> 1568 bytes .../data/content/tippytop/images/baidu-com@2x.png | Bin 0 -> 8198 bytes .../data/content/tippytop/images/bbc-uk@2x.png | Bin 0 -> 18207 bytes .../data/content/tippytop/images/bing-com@2x.svg | 106 + .../data/content/tippytop/images/ctrip-com@2x.png | Bin 0 -> 15862 bytes .../content/tippytop/images/duckduckgo-com@2x.svg | 12 + .../data/content/tippytop/images/ebay@2x.png | Bin 0 -> 5361 bytes .../data/content/tippytop/images/etsy@2x.jpg | Bin 0 -> 4094 bytes .../content/tippytop/images/facebook-com@2x.png | Bin 0 -> 10780 bytes .../data/content/tippytop/images/geico@2x.jpg | Bin 0 -> 11834 bytes .../data/content/tippytop/images/google-com@2x.png | Bin 0 -> 3035 bytes .../data/content/tippytop/images/hrblock@2x.png | Bin 0 -> 4642 bytes .../data/content/tippytop/images/ifeng-com@2x.png | Bin 0 -> 22282 bytes .../data/content/tippytop/images/iqiyi-com@2x.png | Bin 0 -> 14340 bytes .../content/tippytop/images/leboncoin-fr@2x.png | Bin 0 -> 7146 bytes .../data/content/tippytop/images/nike@2x.jpg | Bin 0 -> 5163 bytes .../data/content/tippytop/images/ok-ru@2x.png | Bin 0 -> 2526 bytes .../data/content/tippytop/images/olx-pl@2x.png | Bin 0 -> 5287 bytes .../data/content/tippytop/images/reddit-com@2x.png | Bin 0 -> 5180 bytes .../data/content/tippytop/images/samsung@2x.jpg | Bin 0 -> 3347 bytes .../data/content/tippytop/images/turbotax@2x.jpg | Bin 0 -> 11930 bytes .../content/tippytop/images/twitter-com@2x.png | Bin 0 -> 1260 bytes .../data/content/tippytop/images/vk-com@2x.png | Bin 0 -> 9897 bytes .../data/content/tippytop/images/vodafone@2x.jpg | Bin 0 -> 7050 bytes .../data/content/tippytop/images/weibo-com@2x.png | Bin 0 -> 15507 bytes .../content/tippytop/images/wikipedia-org@2x.png | Bin 0 -> 19001 bytes .../newtab/data/content/tippytop/images/wix@2x.jpg | Bin 0 -> 8714 bytes .../data/content/tippytop/images/wykop-pl@2x.png | Bin 0 -> 4415 bytes .../data/content/tippytop/images/yandex-com@2x.png | Bin 0 -> 6516 bytes .../data/content/tippytop/images/yandex-ru@2x.png | Bin 0 -> 6638 bytes .../content/tippytop/images/youtube-com@2x.png | Bin 0 -> 2924 bytes .../data/content/tippytop/images/zhihu-com@2x.png | Bin 0 -> 10225 bytes .../newtab/data/content/tippytop/top_sites.json | 182 + browser/components/newtab/docs/index.rst | 119 + .../v2-system-addon/about_home_startup_cache.md | 86 + .../newtab/docs/v2-system-addon/data_events.md | 19 + .../newtab/docs/v2-system-addon/geo_locale.md | 23 + .../newtab/docs/v2-system-addon/mochitests.md | 26 + .../newtab/docs/v2-system-addon/preferences.md | 270 + .../newtab/docs/v2-system-addon/sections.md | 82 + .../newtab/docs/v2-system-addon/telemetry.md | 10 + .../newtab/docs/v2-system-addon/tippytop.md | 40 + .../docs/v2-system-addon/unit_testing_guide.md | 149 + browser/components/newtab/jar.mn | 40 + browser/components/newtab/karma.mc.config.js | 287 + .../components/newtab/lib/AboutPreferences.sys.mjs | 298 + .../components/newtab/lib/ActivityStream.sys.mjs | 700 + .../lib/ActivityStreamMessageChannel.sys.mjs | 333 + .../newtab/lib/ActivityStreamPrefs.sys.mjs | 100 + .../newtab/lib/ActivityStreamStorage.sys.mjs | 119 + browser/components/newtab/lib/DefaultSites.sys.mjs | 46 + .../newtab/lib/DiscoveryStreamFeed.sys.mjs | 2265 ++ .../components/newtab/lib/DownloadsManager.sys.mjs | 188 + browser/components/newtab/lib/FaviconFeed.sys.mjs | 198 + browser/components/newtab/lib/FilterAdult.sys.mjs | 3040 ++ .../components/newtab/lib/HighlightsFeed.sys.mjs | 322 + browser/components/newtab/lib/LinksCache.sys.mjs | 133 + browser/components/newtab/lib/NewTabInit.sys.mjs | 55 + .../components/newtab/lib/PersistentCache.sys.mjs | 90 + .../PersonalityProvider/NaiveBayesTextTagger.mjs | 60 + .../lib/PersonalityProvider/NmfTextTagger.mjs | 58 + .../PersonalityProvider.sys.mjs | 277 + .../PersonalityProvider.worker.mjs | 26 + .../PersonalityProviderWorkerClass.mjs | 306 + .../lib/PersonalityProvider/RecipeExecutor.mjs | 1119 + .../newtab/lib/PersonalityProvider/Tokenize.mjs | 83 + browser/components/newtab/lib/PlacesFeed.sys.mjs | 572 + browser/components/newtab/lib/PrefsFeed.sys.mjs | 273 + .../newtab/lib/RecommendationProvider.sys.mjs | 291 + browser/components/newtab/lib/Screenshots.sys.mjs | 140 + .../components/newtab/lib/SearchShortcuts.sys.mjs | 73 + .../components/newtab/lib/SectionsManager.sys.mjs | 715 + browser/components/newtab/lib/ShortURL.sys.mjs | 88 + .../components/newtab/lib/SiteClassifier.sys.mjs | 103 + browser/components/newtab/lib/Store.sys.mjs | 188 + .../components/newtab/lib/SystemTickFeed.sys.mjs | 70 + .../components/newtab/lib/TelemetryFeed.sys.mjs | 1122 + .../components/newtab/lib/TippyTopProvider.sys.mjs | 60 + browser/components/newtab/lib/TopSitesFeed.sys.mjs | 2007 + .../components/newtab/lib/TopStoriesFeed.sys.mjs | 731 + .../components/newtab/lib/UTEventReporting.sys.mjs | 62 + browser/components/newtab/lib/cache.worker.js | 203 + browser/components/newtab/loaders/inject-loader.js | 59 + browser/components/newtab/metrics.yaml | 1589 + browser/components/newtab/moz.build | 35 + .../components/newtab/nsIAboutNewTabService.idl | 39 + browser/components/newtab/package-lock.json | 12455 ++++++ browser/components/newtab/package.json | 115 + browser/components/newtab/pings.yaml | 75 + .../newtab/prerendered/activity-stream-debug.html | 55 + .../prerendered/activity-stream-noscripts.html | 44 + .../newtab/prerendered/activity-stream.html | 55 + .../test/browser/abouthomecache/browser.toml | 52 + .../abouthomecache/browser_basic_endtoend.js | 22 + .../browser/abouthomecache/browser_bump_version.js | 35 + .../browser/abouthomecache/browser_disabled.js | 97 + .../browser_experiments_api_control.js | 63 + .../abouthomecache/browser_locale_change.js | 30 + .../browser/abouthomecache/browser_no_cache.js | 27 + .../browser_no_cache_on_SessionStartup_restore.js | 37 + .../abouthomecache/browser_no_startup_actions.js | 83 + .../abouthomecache/browser_overwrite_cache.js | 38 + .../abouthomecache/browser_process_crash.js | 82 + .../abouthomecache/browser_same_consumer.js | 52 + .../browser/abouthomecache/browser_sanitize.js | 54 + .../abouthomecache/browser_shutdown_timeout.js | 45 + .../newtab/test/browser/abouthomecache/head.js | 365 + .../newtab/test/browser/annotation_first.html | 2 + .../newtab/test/browser/annotation_second.html | 2 + .../newtab/test/browser/annotation_third.html | 2 + .../components/newtab/test/browser/blue_page.html | 6 + .../components/newtab/test/browser/browser.toml | 81 + .../test/browser/browser_as_load_location.js | 44 + .../newtab/test/browser/browser_as_render.js | 83 + .../test/browser/browser_context_menu_item.js | 18 + .../test/browser/browser_customize_menu_content.js | 219 + .../test/browser/browser_customize_menu_render.js | 28 + .../newtab/test/browser/browser_discovery_card.js | 49 + .../test/browser/browser_discovery_render.js | 31 + .../test/browser/browser_enabled_newtabpage.js | 33 + .../test/browser/browser_foxdoodle_set_default.js | 69 + .../newtab/test/browser/browser_getScreenshots.js | 88 + .../test/browser/browser_highlights_section.js | 96 + .../test/browser/browser_multistage_spotlight.js | 90 + .../browser_multistage_spotlight_telemetry.js | 141 + .../newtab/test/browser/browser_newtab_glean.js | 28 + .../newtab/test/browser/browser_newtab_header.js | 76 + .../test/browser/browser_newtab_last_LinkMenu.js | 153 + .../test/browser/browser_newtab_overrides.js | 134 + .../newtab/test/browser/browser_newtab_ping.js | 216 + .../newtab/test/browser/browser_newtab_towindow.js | 45 + .../newtab/test/browser/browser_newtab_trigger.js | 50 + .../newtab/test/browser/browser_open_tab_focus.js | 38 + .../newtab/test/browser/browser_remote_l10n.js | 56 + .../test/browser/browser_topsites_annotation.js | 980 + .../browser_topsites_contextMenu_options.js | 126 + .../test/browser/browser_topsites_section.js | 304 + .../test/browser/browser_trigger_messagesLoaded.js | 153 + .../components/newtab/test/browser/file_pdf.PDF | 12 + browser/components/newtab/test/browser/head.js | 244 + .../components/newtab/test/browser/red_page.html | 6 + .../components/newtab/test/browser/redirect_to.sjs | 9 + .../components/newtab/test/browser/topstories.json | 11 + .../test/schemas/asrouter_event_ping.schema.json | 36 + .../newtab/test/schemas/base_ping.schema.json | 29 + browser/components/newtab/test/schemas/pings.js | 181 + .../newtab/test/schemas/session_ping.schema.json | 122 + .../test/schemas/user_event_ping.schema.json | 75 + .../newtab/test/unit/common/Actions.test.js | 236 + .../newtab/test/unit/common/Dedupe.test.js | 38 + .../newtab/test/unit/common/Reducers.test.js | 1525 + .../test/unit/content-src/components/Base.test.jsx | 130 + .../test/unit/content-src/components/Card.test.jsx | 510 + .../components/CollapsibleSection.test.jsx | 67 + .../components/ComponentPerfTimer.test.jsx | 447 + .../content-src/components/ConfirmDialog.test.jsx | 182 + .../content-src/components/ContextMenu.test.jsx | 227 + .../content-src/components/CustomiseMenu.test.jsx | 72 + .../components/DiscoveryStreamAdmin.test.jsx | 267 + .../components/DiscoveryStreamBase.test.jsx | 313 + .../DiscoveryStreamComponents/CardGrid.test.jsx | 354 + .../CollectionCardGrid.test.jsx | 138 + .../DiscoveryStreamComponents/DSCard.test.jsx | 582 + .../DSContextFooter.test.jsx | 138 + .../DiscoveryStreamComponents/DSDismiss.test.jsx | 51 + .../DSEmptyState.test.jsx | 73 + .../DiscoveryStreamComponents/DSImage.test.jsx | 146 + .../DiscoveryStreamComponents/DSLinkMenu.test.jsx | 151 + .../DiscoveryStreamComponents/DSMessage.test.jsx | 57 + .../DSPrivacyModal.test.jsx | 50 + .../DiscoveryStreamComponents/DSSignup.test.jsx | 92 + .../DiscoveryStreamComponents/DSTextPromo.test.jsx | 96 + .../DiscoveryStreamComponents/Highlights.test.jsx | 41 + .../HorizontalRule.test.jsx | 16 + .../ImpressionStats.test.jsx | 276 + .../DiscoveryStreamComponents/Navigation.test.jsx | 131 + .../DiscoveryStreamComponents/PrivacyLink.test.jsx | 29 + .../DiscoveryStreamComponents/SafeAnchor.test.jsx | 56 + .../SectionTitle.test.jsx | 22 + .../TopicsWidget.test.jsx | 238 + .../content-src/components/ErrorBoundary.test.jsx | 110 + .../content-src/components/FluentOrText.test.jsx | 68 + .../unit/content-src/components/LinkMenu.test.jsx | 667 + .../components/MoreRecommendations.test.jsx | 24 + .../components/PocketLoggedInCta.test.jsx | 46 + .../unit/content-src/components/Search.test.jsx | 179 + .../unit/content-src/components/Sections.test.jsx | 600 + .../unit/content-src/components/TopSites.test.jsx | 1930 + .../TopSites/SearchShortcutsForm.test.jsx | 56 + .../TopSites/TopSiteImpressionWrapper.test.jsx | 148 + .../unit/content-src/components/Topics.test.jsx | 22 + .../lib/detect-user-session-start.test.js | 120 + .../test/unit/content-src/lib/init-store.test.js | 155 + .../test/unit/content-src/lib/perf-service.test.js | 89 + .../unit/content-src/lib/screenshot-utils.test.js | 147 + .../content-src/lib/selectLayoutRender.test.js | 576 + .../newtab/test/unit/lib/AboutPreferences.test.js | 429 + .../newtab/test/unit/lib/ActivityStream.test.js | 576 + .../unit/lib/ActivityStreamMessageChannel.test.js | 445 + .../test/unit/lib/ActivityStreamPrefs.test.js | 113 + .../test/unit/lib/ActivityStreamStorage.test.js | 161 + .../test/unit/lib/DiscoveryStreamFeed.test.js | 3523 ++ .../newtab/test/unit/lib/DownloadsManager.test.js | 373 + .../newtab/test/unit/lib/FaviconFeed.test.js | 233 + .../newtab/test/unit/lib/FilterAdult.test.js | 112 + .../newtab/test/unit/lib/LinksCache.test.js | 16 + .../newtab/test/unit/lib/NewTabInit.test.js | 81 + .../newtab/test/unit/lib/PersistentCache.test.js | 142 + .../NaiveBayesTextTagger.test.js | 95 + .../lib/PersonalityProvider/NmfTextTagger.test.js | 479 + .../PersonalityProvider.test.js | 356 + .../PersonalityProviderWorkerClass.test.js | 456 + .../lib/PersonalityProvider/RecipeExecutor.test.js | 1543 + .../unit/lib/PersonalityProvider/Tokenize.test.js | 134 + .../newtab/test/unit/lib/PrefsFeed.test.js | 357 + .../test/unit/lib/RecommendationProvider.test.js | 331 + .../newtab/test/unit/lib/Screenshots.test.js | 209 + .../newtab/test/unit/lib/SectionsManager.test.js | 897 + .../newtab/test/unit/lib/ShortUrl.test.js | 104 + .../newtab/test/unit/lib/SiteClassifier.test.js | 252 + .../newtab/test/unit/lib/SystemTickFeed.test.js | 79 + .../newtab/test/unit/lib/TippyTopProvider.test.js | 121 + .../newtab/test/unit/lib/UTEventReporting.test.js | 115 + browser/components/newtab/test/unit/unit-entry.js | 733 + browser/components/newtab/test/unit/utils.js | 406 + .../xpcshell/test_AboutHomeStartupCacheChild.js | 33 + .../xpcshell/test_AboutHomeStartupCacheWorker.js | 255 + .../newtab/test/xpcshell/test_AboutNewTab.js | 363 + .../test/xpcshell/test_AboutWelcomeAttribution.js | 69 + .../test/xpcshell/test_AboutWelcomeTelemetry.js | 90 + .../xpcshell/test_AboutWelcomeTelemetry_glean.js | 238 + .../newtab/test/xpcshell/test_HighlightsFeed.js | 1402 + .../newtab/test/xpcshell/test_PlacesFeed.js | 1812 + .../components/newtab/test/xpcshell/test_Store.js | 453 + .../newtab/test/xpcshell/test_TelemetryFeed.js | 3285 ++ .../newtab/test/xpcshell/test_TopSitesFeed.js | 3397 ++ .../test/xpcshell/test_TopSitesFeed_glean.js | 2023 + .../newtab/test/xpcshell/topstories.json | 53 + .../components/newtab/test/xpcshell/xpcshell.toml | 34 + .../components/newtab/tools/resourceUriPlugin.js | 65 + .../components/newtab/vendor/PROP_TYPES_LICENSE | 21 + .../newtab/vendor/REACT_AND_REACT_DOM_LICENSE | 21 + .../components/newtab/vendor/REACT_REDUX_LICENSE | 21 + .../newtab/vendor/REACT_TRANSITION_GROUP_LICENSE | 30 + browser/components/newtab/vendor/REDUX_LICENSE | 21 + browser/components/newtab/vendor/Redux.sys.mjs | 691 + browser/components/newtab/vendor/prop-types.js | 1 + browser/components/newtab/vendor/react-dev.js | 3318 ++ browser/components/newtab/vendor/react-dom-dev.js | 25147 ++++++++++++ .../components/newtab/vendor/react-dom-server.js | 45 + browser/components/newtab/vendor/react-dom.js | 239 + browser/components/newtab/vendor/react-redux.js | 1 + .../newtab/vendor/react-transition-group.js | 9 + browser/components/newtab/vendor/react.js | 32 + browser/components/newtab/vendor/redux.js | 948 + .../newtab/webpack.system-addon.config.js | 65 + browser/components/newtab/yamscripts.yml | 60 + browser/components/nsIBrowserHandler.idl | 23 + browser/components/originattributes/moz.build | 14 + .../test/browser/blobify.worker.js | 47 + .../originattributes/test/browser/browser.toml | 120 + .../test/browser/browser_blobURLIsolation.js | 123 + .../test/browser/browser_broadcastChannel.js | 55 + .../originattributes/test/browser/browser_cache.js | 350 + .../test/browser/browser_cacheAPI.js | 24 + .../test/browser/browser_clientAuth.js | 49 + .../test/browser/browser_cookieIsolation.js | 43 + .../test/browser/browser_favicon_firstParty.js | 511 + .../test/browser/browser_favicon_userContextId.js | 406 + .../test/browser/browser_firstPartyIsolation.js | 511 + .../browser_firstPartyIsolation_aboutPages.js | 258 + .../browser_firstPartyIsolation_about_newtab.js | 55 + .../browser/browser_firstPartyIsolation_blobURI.js | 116 + .../browser/browser_firstPartyIsolation_js_uri.js | 90 + .../browser/browser_firstPartyIsolation_saveAs.js | 326 + .../test/browser/browser_httpauth.js | 79 + .../test/browser/browser_imageCacheIsolation.js | 92 + .../test/browser/browser_localStorageIsolation.js | 33 + .../test/browser/browser_permissions.js | 91 + .../test/browser/browser_postMessage.js | 121 + .../test/browser/browser_sanitize.js | 92 + .../test/browser/browser_sharedworker.js | 30 + .../browser/browser_windowOpenerRestriction.js | 113 + .../originattributes/test/browser/dummy.html | 9 + .../test/browser/file_broadcastChannel.html | 16 + .../test/browser/file_broadcastChanneliFrame.html | 15 + .../originattributes/test/browser/file_cache.html | 33 + .../test/browser/file_favicon.html | 11 + .../originattributes/test/browser/file_favicon.png | Bin 0 -> 344 bytes .../test/browser/file_favicon.png^headers^ | 1 + .../test/browser/file_favicon_cache.html | 11 + .../test/browser/file_favicon_cache.png | Bin 0 -> 344 bytes .../test/browser/file_favicon_thirdParty.html | 11 + .../test/browser/file_firstPartyBasic.html | 8 + .../test/browser/file_postMessage.html | 27 + .../test/browser/file_postMessageSender.html | 16 + .../originattributes/test/browser/file_saveAs.sjs | 38 + .../test/browser/file_shared.worker.js | 7 + .../test/browser/file_sharedworker.html | 10 + .../test/browser/file_thirdPartyChild.audio.ogg | Bin 0 -> 2603 bytes .../test/browser/file_thirdPartyChild.embed.png | Bin 0 -> 95 bytes .../test/browser/file_thirdPartyChild.favicon.png | Bin 0 -> 95 bytes .../test/browser/file_thirdPartyChild.fetch.html | 8 + .../test/browser/file_thirdPartyChild.font.woff | Bin 0 -> 1112 bytes .../test/browser/file_thirdPartyChild.iframe.html | 18 + .../test/browser/file_thirdPartyChild.img.png | Bin 0 -> 95 bytes .../test/browser/file_thirdPartyChild.import.js | 1 + .../test/browser/file_thirdPartyChild.link.css | 1 + .../test/browser/file_thirdPartyChild.object.png | Bin 0 -> 95 bytes .../test/browser/file_thirdPartyChild.request.html | 8 + .../test/browser/file_thirdPartyChild.script.js | 1 + .../browser/file_thirdPartyChild.sharedworker.js | 1 + .../test/browser/file_thirdPartyChild.track.vtt | 13 + .../test/browser/file_thirdPartyChild.video.ogv | Bin 0 -> 16049 bytes .../browser/file_thirdPartyChild.worker.fetch.html | 8 + .../test/browser/file_thirdPartyChild.worker.js | 20 + .../file_thirdPartyChild.worker.request.html | 8 + .../browser/file_thirdPartyChild.worker.xhr.html | 8 + .../test/browser/file_thirdPartyChild.xhr.html | 8 + .../test/browser/file_windowOpenerRestriction.html | 10 + .../file_windowOpenerRestrictionTarget.html | 33 + .../originattributes/test/browser/head.js | 463 + .../originattributes/test/browser/test.html | 20 + .../originattributes/test/browser/test.js | 1 + .../originattributes/test/browser/test.js^headers^ | 1 + .../originattributes/test/browser/test2.html | 12 + .../originattributes/test/browser/test2.js | 1 + .../test/browser/test2.js^headers^ | 1 + .../test/browser/test_firstParty.html | 15 + .../test/browser/test_firstParty_cookie.html | 13 + .../browser/test_firstParty_html_redirect.html | 9 + .../browser/test_firstParty_http_redirect.html | 9 + .../test_firstParty_http_redirect.html^headers^ | 2 + ...st_firstParty_http_redirect_to_same_domain.html | 9 + ...arty_http_redirect_to_same_domain.html^headers^ | 2 + .../test_firstParty_iframe_http_redirect.html | 13 + .../test/browser/test_firstParty_postMessage.html | 28 + .../originattributes/test/browser/test_form.html | 14 + .../originattributes/test/browser/window.html | 11 + .../originattributes/test/browser/window2.html | 11 + .../originattributes/test/browser/window3.html | 11 + .../test/browser/window_redirect.html | 12 + .../test/mochitest/file_empty.html | 2 + .../originattributes/test/mochitest/mochitest.toml | 6 + .../test/mochitest/test_permissions_api.html | 176 + browser/components/pagedata/.eslintrc.js | 14 + .../components/pagedata/OpenGraphPageData.sys.mjs | 46 + browser/components/pagedata/PageDataChild.sys.mjs | 121 + browser/components/pagedata/PageDataParent.sys.mjs | 56 + browser/components/pagedata/PageDataSchema.sys.mjs | 249 + .../components/pagedata/PageDataService.sys.mjs | 677 + .../components/pagedata/SchemaOrgPageData.sys.mjs | 441 + .../components/pagedata/TwitterPageData.sys.mjs | 42 + browser/components/pagedata/docs/index.md | 50 + browser/components/pagedata/jar.mn | 6 + browser/components/pagedata/moz.build | 29 + .../pagedata/schemas/article.schema.json | 26 + .../components/pagedata/schemas/audio.schema.json | 34 + .../pagedata/schemas/document.schema.json | 18 + .../pagedata/schemas/general.schema.json | 30 + .../pagedata/schemas/product.schema.json | 46 + .../components/pagedata/schemas/video.schema.json | 38 + .../components/pagedata/tests/browser/browser.toml | 16 + .../tests/browser/browser_pagedata_background.js | 48 + .../tests/browser/browser_pagedata_basic.js | 64 + .../tests/browser/browser_pagedata_cache.js | 155 + browser/components/pagedata/tests/browser/head.js | 8 + browser/components/pagedata/tests/unit/head.js | 105 + .../pagedata/tests/unit/test_opengraph.js | 67 + .../pagedata/tests/unit/test_pagedata_basic.js | 100 + .../pagedata/tests/unit/test_pagedata_schema.js | 210 + .../components/pagedata/tests/unit/test_queue.js | 512 + .../pagedata/tests/unit/test_schemaorg.js | 213 + .../pagedata/tests/unit/test_schemaorg_parse.js | 193 + .../components/pagedata/tests/unit/test_twitter.js | 34 + .../components/pagedata/tests/unit/xpcshell.toml | 19 + browser/components/places/.eslintrc.js | 9 + browser/components/places/Interactions.sys.mjs | 762 + .../places/InteractionsBlocklist.sys.mjs | 281 + .../components/places/InteractionsChild.sys.mjs | 148 + .../components/places/InteractionsParent.sys.mjs | 33 + browser/components/places/PlacesUIUtils.sys.mjs | 2226 ++ .../places/content/bookmarkProperties.js | 519 + .../places/content/bookmarkProperties.xhtml | 58 + .../content/bookmarksHistoryTooltip.inc.xhtml | 14 + .../components/places/content/bookmarksSidebar.js | 78 + .../places/content/bookmarksSidebar.xhtml | 76 + .../places/content/browserPlacesViews.js | 2263 ++ browser/components/places/content/controller.js | 1745 + browser/components/places/content/editBookmark.js | 1255 + .../places/content/editBookmarkPanel.inc.xhtml | 122 + .../components/places/content/historySidebar.js | 171 + .../components/places/content/historySidebar.xhtml | 108 + .../components/places/content/places-menupopup.js | 693 + browser/components/places/content/places-tree.js | 865 + browser/components/places/content/places.css | 43 + browser/components/places/content/places.js | 1526 + browser/components/places/content/places.xhtml | 429 + .../places/content/placesCommands.inc.xhtml | 52 + .../places/content/placesContextMenu.inc.xhtml | 178 + browser/components/places/content/treeView.js | 1862 + browser/components/places/docs/Bookmarks.rst | 50 + browser/components/places/docs/History.rst | 43 + .../components/places/docs/PlacesTransactions.rst | 73 + .../places/docs/architecture-overview.rst | 105 + .../nontechnical-overview/bookmark-folder-menu.png | Bin 0 -> 170652 bytes .../nontechnical-overview/bookmark-undo-redo.png | Bin 0 -> 204229 bytes .../firefox-bookmarks-main-application.png | Bin 0 -> 115641 bytes .../firefox-bookmarks-menu.png | Bin 0 -> 109179 bytes .../firefox-bookmarks-toolbar.png | Bin 0 -> 34607 bytes browser/components/places/docs/index.rst | 40 + .../places/docs/nontechnical-overview.rst | 163 + browser/components/places/docs/notifyObservers.rst | 35 + browser/components/places/jar.mn | 27 + .../places/metadataViewer/interactionsViewer.css | 67 + .../places/metadataViewer/interactionsViewer.html | 52 + .../places/metadataViewer/interactionsViewer.js | 427 + browser/components/places/moz.build | 33 + .../places/tests/browser/bookmark_dummy_1.html | 9 + .../places/tests/browser/bookmark_dummy_2.html | 9 + .../browser/bookmarklet_windowOpen_dummy.html | 9 + .../components/places/tests/browser/browser.toml | 236 + .../tests/browser/browser_addBookmarkForFrame.js | 150 + .../browser/browser_autoshow_bookmarks_toolbar.js | 165 + .../browser/browser_bookmarkMenu_hiddenWindow.js | 49 + ...er_bookmarkProperties_addFolderDefaultButton.js | 68 + ...r_bookmarkProperties_addKeywordForThisSearch.js | 188 + .../browser_bookmarkProperties_bookmarkAllTabs.js | 66 + .../browser/browser_bookmarkProperties_cancel.js | 126 + .../browser_bookmarkProperties_editFolder.js | 78 + .../browser_bookmarkProperties_editTagContainer.js | 140 + .../browser_bookmarkProperties_folderSelection.js | 221 + .../browser_bookmarkProperties_newFolder.js | 110 + .../browser_bookmarkProperties_no_user_actions.js | 89 + .../browser_bookmarkProperties_readOnlyRoot.js | 67 + .../browser_bookmarkProperties_remember_folders.js | 221 + ...ser_bookmarkProperties_speculativeConnection.js | 106 + .../browser/browser_bookmarkProperties_xulStore.js | 47 + .../tests/browser/browser_bookmark_add_tags.js | 228 + .../tests/browser/browser_bookmark_all_tabs.js | 46 + .../browser_bookmark_backup_export_import.js | 205 + .../browser/browser_bookmark_change_location.js | 213 + .../browser_bookmark_context_menu_contents.js | 798 + .../browser/browser_bookmark_copy_folder_tree.js | 172 + .../browser/browser_bookmark_folder_moveability.js | 139 + .../browser/browser_bookmark_menu_ctrl_click.js | 43 + .../places/tests/browser/browser_bookmark_popup.js | 712 + .../browser/browser_bookmark_private_window.js | 58 + .../tests/browser/browser_bookmark_remove_tags.js | 256 + .../tests/browser/browser_bookmark_titles.js | 129 + .../browser/browser_bookmarklet_windowOpen.js | 79 + .../tests/browser/browser_bookmarksProperties.js | 532 + .../browser/browser_bookmarks_change_title.js | 256 + .../tests/browser/browser_bookmarks_change_url.js | 106 + .../browser/browser_bookmarks_sidebar_search.js | 213 + ..._bookmarks_toolbar_context_menu_view_options.js | 87 + .../browser/browser_bookmarks_toolbar_telemetry.js | 162 + .../browser_bug427633_no_newfolder_if_noip.js | 50 + .../browser_bug485100-change-case-loses-tag.js | 78 + .../browser_bug631374_tags_selector_scroll.js | 162 + .../browser/browser_check_correct_controllers.js | 114 + .../browser/browser_click_bookmarks_on_toolbar.js | 234 + .../tests/browser/browser_controller_onDrop.js | 125 + .../browser/browser_controller_onDrop_query.js | 132 + .../browser/browser_controller_onDrop_sidebar.js | 312 + .../browser/browser_controller_onDrop_tagFolder.js | 125 + .../browser/browser_copy_query_without_tree.js | 113 + .../tests/browser/browser_cutting_bookmarks.js | 87 + .../browser/browser_default_bookmark_location.js | 274 + .../browser/browser_drag_bookmarks_on_toolbar.js | 255 + .../tests/browser/browser_drag_folder_on_newTab.js | 100 + .../tests/browser/browser_editBookmark_keywords.js | 64 + .../browser/browser_enable_toolbar_sidebar.js | 57 + .../places/tests/browser/browser_forgetthissite.js | 262 + .../browser/browser_history_sidebar_search.js | 71 + .../places/tests/browser/browser_import_button.js | 187 + .../browser_library_bookmark_clear_visits.js | 124 + .../browser/browser_library_bookmark_pages.js | 101 + .../browser/browser_library_bulk_tag_bookmarks.js | 146 + .../tests/browser/browser_library_commands.js | 335 + .../places/tests/browser/browser_library_delete.js | 157 + .../browser_library_delete_bookmarks_in_tags.js | 111 + .../tests/browser/browser_library_delete_tags.js | 60 + .../tests/browser/browser_library_downloads.js | 65 + .../browser_library_left_pane_middleclick.js | 106 + .../browser_library_left_pane_select_hierarchy.js | 52 + .../tests/browser/browser_library_middleclick.js | 234 + .../tests/browser/browser_library_new_bookmark.js | 95 + .../browser/browser_library_openFlatContainer.js | 125 + .../tests/browser/browser_library_open_all.js | 57 + .../browser_library_open_all_with_separator.js | 61 + .../tests/browser/browser_library_open_bookmark.js | 46 + .../tests/browser/browser_library_open_leak.js | 22 + .../tests/browser/browser_library_panel_leak.js | 71 + ...wser_library_sameNodeDetailsPaneOptimization.js | 45 + .../places/tests/browser/browser_library_search.js | 206 + .../browser/browser_library_tags_visibility.js | 88 + .../tests/browser/browser_library_telemetry.js | 413 + .../tests/browser/browser_library_tree_leak.js | 24 + .../browser/browser_library_views_liveupdate.js | 217 + .../tests/browser/browser_library_warnOnOpen.js | 159 + .../browser/browser_markPageAsFollowedLink.js | 75 + .../browser/browser_panelview_bookmarks_delete.js | 58 + .../tests/browser/browser_paste_bookmarks.js | 439 + .../tests/browser/browser_paste_into_tags.js | 116 + .../browser/browser_paste_resets_cut_highlights.js | 99 + .../tests/browser/browser_remove_bookmarks.js | 155 + .../browser/browser_sidebar_bookmarks_telemetry.js | 141 + .../browser/browser_sidebar_history_telemetry.js | 285 + .../browser/browser_sidebar_on_customization.js | 100 + .../browser/browser_sidebar_open_bookmarks.js | 134 + .../tests/browser/browser_sidebarpanels_click.js | 172 + .../tests/browser/browser_sort_in_library.js | 248 + .../places/tests/browser/browser_stayopenmenu.js | 267 + .../browser/browser_toolbar_drop_bookmarklet.js | 200 + .../browser_toolbar_drop_multiple_flavors.js | 69 + ...owser_toolbar_drop_multiple_with_bookmarklet.js | 50 + .../tests/browser/browser_toolbar_drop_text.js | 136 + .../browser/browser_toolbar_library_open_recent.js | 158 + .../browser/browser_toolbar_other_bookmarks.js | 601 + .../tests/browser/browser_toolbar_overflow.js | 436 + .../browser/browser_toolbarbutton_menu_context.js | 72 + .../browser_toolbarbutton_menu_show_in_folder.js | 92 + .../tests/browser/browser_views_iconsupdate.js | 132 + .../tests/browser/browser_views_liveupdate.js | 493 + .../places/tests/browser/favicon-normal16.png | Bin 0 -> 286 bytes .../components/places/tests/browser/frameLeft.html | 8 + .../places/tests/browser/frameRight.html | 8 + .../places/tests/browser/framedPage.html | 9 + browser/components/places/tests/browser/head.js | 570 + .../places/tests/browser/interactions/browser.toml | 36 + .../interactions/browser_interactions_blocklist.js | 105 + .../browser_interactions_clearHistory.js | 106 + .../browser_interactions_disabledHistory.js | 102 + .../interactions/browser_interactions_referrer.js | 45 + .../interactions/browser_interactions_scrolling.js | 162 + .../browser_interactions_scrolling_dom_history.js | 208 + .../interactions/browser_interactions_typing.js | 410 + .../browser_interactions_typing_dom_history.js | 171 + .../interactions/browser_interactions_view_time.js | 410 + .../browser_interactions_view_time_dom_history.js | 123 + .../places/tests/browser/interactions/head.js | 201 + .../tests/browser/interactions/scrolling.html | 121 + .../browser/interactions/scrolling_subframe.html | 25 + .../places/tests/browser/keyword_form.html | 17 + .../places/tests/browser/pageopeningwindow.html | 10 + .../browser/sidebarpanels_click_test_page.html | 7 + browser/components/places/tests/chrome/chrome.toml | 14 + browser/components/places/tests/chrome/head.js | 36 + .../places/tests/chrome/test_0_bug510634.xhtml | 100 + ...t_bug1163447_selectItems_through_shortcut.xhtml | 88 + .../places/tests/chrome/test_bug549192.xhtml | 130 + .../places/tests/chrome/test_bug549491.xhtml | 78 + .../chrome/test_selectItems_on_nested_tree.xhtml | 85 + .../places/tests/chrome/test_treeview_date.xhtml | 159 + .../places/tests/marionette/manifest.toml | 4 + .../tests/marionette/test_reopen_from_library.py | 164 + .../places/tests/unit/bookmarks.glue.html | 16 + .../places/tests/unit/bookmarks.glue.json | 83 + .../components/places/tests/unit/corruptDB.sqlite | Bin 0 -> 32772 bytes .../components/places/tests/unit/distribution.ini | 30 + .../components/places/tests/unit/head_bookmarks.js | 82 + .../tests/unit/test_PUIU_batchUpdatesForNode.js | 107 + .../tests/unit/test_PUIU_setCharsetForPage.js | 141 + .../unit/test_PUIU_title_difference_spotter.js | 81 + .../tests/unit/test_browserGlue_bookmarkshtml.js | 33 + .../places/tests/unit/test_browserGlue_corrupt.js | 53 + .../unit/test_browserGlue_corrupt_nobackup.js | 47 + .../test_browserGlue_corrupt_nobackup_default.js | 50 + .../tests/unit/test_browserGlue_distribution.js | 143 + .../places/tests/unit/test_browserGlue_migrate.js | 68 + .../places/tests/unit/test_browserGlue_prefs.js | 161 + .../places/tests/unit/test_browserGlue_restore.js | 55 + .../tests/unit/test_clearHistory_shutdown.js | 183 + .../tests/unit/test_interactions_blocklist.js | 67 + .../tests/unit/test_invalid_defaultLocation.js | 24 + browser/components/places/tests/unit/xpcshell.toml | 38 + browser/components/pocket/.eslintrc.js | 18 + browser/components/pocket/.nvmrc | 1 + browser/components/pocket/README.md | 39 + browser/components/pocket/content/Pocket.sys.mjs | 56 + .../components/pocket/content/SaveToPocket.sys.mjs | 245 + .../pocket/content/panels/css/global.scss | 67 + .../components/pocket/content/panels/css/home.scss | 117 + .../pocket/content/panels/css/main.compiled.css | 2346 ++ .../components/pocket/content/panels/css/main.scss | 17 + .../pocket/content/panels/css/normalize.scss | 425 + .../pocket/content/panels/css/panel.scss | 8 + .../pocket/content/panels/css/saved.scss | 903 + .../pocket/content/panels/css/signup.scss | 324 + .../pocket/content/panels/css/styleguide.scss | 28 + .../content/panels/fonts/FiraSans-Regular.woff | Bin 0 -> 179188 bytes browser/components/pocket/content/panels/home.html | 20 + .../pocket/content/panels/img/chevron-right.svg | 3 + .../pocket/content/panels/img/list-view.svg | 8 + .../components/pocket/content/panels/img/open.svg | 3 + .../pocket/content/panels/img/pocketerror@1x.png | Bin 0 -> 923 bytes .../pocket/content/panels/img/pocketerror@2x.png | Bin 0 -> 1698 bytes .../pocket/content/panels/img/pocketlogo-dark.svg | 14 + .../pocket/content/panels/img/pocketlogo.svg | 16 + .../pocket/content/panels/img/pocketlogo@1x.png | Bin 0 -> 2120 bytes .../pocket/content/panels/img/pocketlogo@2x.png | Bin 0 -> 4060 bytes .../content/panels/img/pocketlogosolo@1x.png | Bin 0 -> 766 bytes .../content/panels/img/pocketlogosolo@2x.png | Bin 0 -> 1393 bytes .../content/panels/img/pocketsignup_button@1x.png | Bin 0 -> 2413 bytes .../content/panels/img/pocketsignup_button@2x.png | Bin 0 -> 4872 bytes .../content/panels/img/pocketsignup_devices@1x.png | Bin 0 -> 20258 bytes .../content/panels/img/pocketsignup_devices@2x.png | Bin 0 -> 68058 bytes .../content/panels/img/pocketsignup_hero@1x.png | Bin 0 -> 41497 bytes .../content/panels/img/pocketsignup_hero@2x.png | Bin 0 -> 137839 bytes .../pocket/content/panels/img/rainbow-reader.svg | 53 + .../content/panels/img/signup_firefoxlogo@1x.png | Bin 0 -> 412 bytes .../content/panels/img/signup_firefoxlogo@2x.png | Bin 0 -> 763 bytes .../pocket/content/panels/img/signup_help@1x.png | Bin 0 -> 420 bytes .../pocket/content/panels/img/signup_help@2x.png | Bin 0 -> 788 bytes .../pocket/content/panels/img/tag_close@1x.png | Bin 0 -> 176 bytes .../pocket/content/panels/img/tag_close@2x.png | Bin 0 -> 334 bytes .../content/panels/img/tag_closeactive@1x.png | Bin 0 -> 159 bytes .../content/panels/img/tag_closeactive@2x.png | Bin 0 -> 274 bytes .../js/components/ArticleList/ArticleList.jsx | 139 + .../js/components/ArticleList/ArticleList.scss | 65 + .../content/panels/js/components/Button/Button.jsx | 21 + .../panels/js/components/Button/Button.scss | 142 + .../content/panels/js/components/Header/Header.jsx | 16 + .../panels/js/components/Header/Header.scss | 22 + .../content/panels/js/components/Home/Home.jsx | 175 + .../js/components/PopularTopics/PopularTopics.jsx | 27 + .../js/components/PopularTopics/PopularTopics.scss | 56 + .../content/panels/js/components/Saved/Saved.jsx | 175 + .../content/panels/js/components/Saved/Saved.scss | 17 + .../content/panels/js/components/Signup/Signup.jsx | 79 + .../panels/js/components/Signup/Signup.scss | 19 + .../panels/js/components/TagPicker/TagPicker.jsx | 208 + .../panels/js/components/TagPicker/TagPicker.scss | 141 + .../js/components/TelemetryLink/TelemetryLink.jsx | 35 + .../pocket/content/panels/js/home/entry.js | 17 + .../pocket/content/panels/js/home/overlay.jsx | 48 + .../pocket/content/panels/js/main.bundle.js | 1238 + .../content/panels/js/main.bundle.js.LICENSE.txt | 23 + .../components/pocket/content/panels/js/main.mjs | 118 + .../pocket/content/panels/js/messages.mjs | 50 + .../pocket/content/panels/js/saved/entry.js | 17 + .../pocket/content/panels/js/saved/overlay.jsx | 46 + .../pocket/content/panels/js/signup/entry.js | 17 + .../pocket/content/panels/js/signup/overlay.jsx | 50 + .../pocket/content/panels/js/style-guide/entry.js | 45 + .../content/panels/js/style-guide/overlay.jsx | 106 + .../pocket/content/panels/js/vendor.bundle.js | 451 + .../content/panels/js/vendor.bundle.js.LICENSE.txt | 32 + .../components/pocket/content/panels/license.txt | 35 + .../components/pocket/content/panels/saved.html | 20 + .../components/pocket/content/panels/signup.html | 21 + .../pocket/content/panels/style-guide.html | 34 + browser/components/pocket/content/pktApi.sys.mjs | 901 + .../components/pocket/content/pktTelemetry.sys.mjs | 74 + browser/components/pocket/content/pktUI.js | 564 + browser/components/pocket/jar.mn | 24 + browser/components/pocket/metrics.yaml | 144 + browser/components/pocket/moz.build | 16 + browser/components/pocket/package-lock.json | 6537 ++++ browser/components/pocket/package.json | 32 + browser/components/pocket/pings.yaml | 22 + browser/components/pocket/test/browser.toml | 17 + .../test/browser_pocket_button_icon_state.js | 131 + .../test/browser_pocket_context_menu_action.js | 53 + .../pocket/test/browser_pocket_home_panel.js | 53 + .../components/pocket/test/browser_pocket_panel.js | 78 + .../pocket/test/browser_pocket_panel_closemenu.js | 54 + .../pocket/test/browser_pocket_ui_check.js | 85 + browser/components/pocket/test/head.js | 85 + browser/components/pocket/test/test.html | 12 + browser/components/pocket/test/unit/browser.toml | 8 + .../test/unit/browser_pocket_AboutPocketParent.js | 372 + .../test/unit/browser_pocket_pktTelemetry.js | 91 + .../pocket/test/unit/browser_pocket_pktUI.js | 37 + browser/components/pocket/test/unit/head.js | 12 + .../pocket/test/unit/panels/browser.toml | 4 + .../pocket/test/unit/panels/browser_pocket_main.js | 49 + browser/components/pocket/test/unit/panels/head.js | 24 + browser/components/pocket/webpack.config.js | 41 + .../components/preferences/containers.inc.xhtml | 42 + browser/components/preferences/containers.js | 151 + .../components/preferences/dialogs/addEngine.css | 24 + .../components/preferences/dialogs/addEngine.js | 69 + .../components/preferences/dialogs/addEngine.xhtml | 78 + .../preferences/dialogs/applicationManager.js | 129 + .../preferences/dialogs/applicationManager.xhtml | 71 + .../components/preferences/dialogs/blocklists.js | 175 + .../preferences/dialogs/blocklists.xhtml | 83 + .../preferences/dialogs/browserLanguages.js | 728 + .../preferences/dialogs/browserLanguages.xhtml | 87 + .../preferences/dialogs/clearSiteData.css | 20 + .../preferences/dialogs/clearSiteData.js | 96 + .../preferences/dialogs/clearSiteData.xhtml | 76 + browser/components/preferences/dialogs/colors.js | 18 + .../components/preferences/dialogs/colors.xhtml | 135 + .../components/preferences/dialogs/connection.js | 388 + .../preferences/dialogs/connection.xhtml | 247 + .../components/preferences/dialogs/containers.js | 167 + .../preferences/dialogs/containers.xhtml | 75 + .../preferences/dialogs/dohExceptions.js | 287 + .../preferences/dialogs/dohExceptions.xhtml | 110 + browser/components/preferences/dialogs/fonts.js | 173 + browser/components/preferences/dialogs/fonts.xhtml | 254 + .../components/preferences/dialogs/handlers.css | 21 + browser/components/preferences/dialogs/jar.mn | 49 + .../components/preferences/dialogs/languages.js | 386 + .../components/preferences/dialogs/languages.xhtml | 107 + browser/components/preferences/dialogs/moz.build | 13 + .../components/preferences/dialogs/permissions.js | 673 + .../preferences/dialogs/permissions.xhtml | 140 + browser/components/preferences/dialogs/sanitize.js | 38 + .../components/preferences/dialogs/sanitize.xhtml | 94 + .../preferences/dialogs/selectBookmark.js | 119 + .../preferences/dialogs/selectBookmark.xhtml | 61 + .../preferences/dialogs/siteDataRemoveSelected.js | 56 + .../dialogs/siteDataRemoveSelected.xhtml | 51 + .../preferences/dialogs/siteDataSettings.js | 331 + .../preferences/dialogs/siteDataSettings.xhtml | 92 + .../preferences/dialogs/sitePermissions.css | 58 + .../preferences/dialogs/sitePermissions.js | 679 + .../preferences/dialogs/sitePermissions.xhtml | 121 + .../preferences/dialogs/syncChooseWhatToSync.js | 60 + .../preferences/dialogs/syncChooseWhatToSync.xhtml | 88 + .../preferences/dialogs/translationExceptions.js | 256 + .../dialogs/translationExceptions.xhtml | 130 + .../components/preferences/dialogs/translations.js | 454 + .../preferences/dialogs/translations.xhtml | 161 + .../components/preferences/experimental.inc.xhtml | 38 + browser/components/preferences/experimental.js | 163 + .../components/preferences/extensionControlled.js | 309 + browser/components/preferences/findInPage.js | 776 + browser/components/preferences/fxaPairDevice.js | 144 + browser/components/preferences/fxaPairDevice.xhtml | 78 + browser/components/preferences/home.inc.xhtml | 92 + browser/components/preferences/home.js | 694 + browser/components/preferences/jar.mn | 25 + browser/components/preferences/main.inc.xhtml | 837 + browser/components/preferences/main.js | 4255 ++ browser/components/preferences/metrics.yaml | 53 + .../more-from-mozilla-qr-code-simple-cn.svg | 4 + .../more-from-mozilla-qr-code-simple.svg | 4 + .../preferences/moreFromMozilla.inc.xhtml | 44 + browser/components/preferences/moreFromMozilla.js | 285 + browser/components/preferences/moz.build | 23 + browser/components/preferences/preferences.js | 663 + browser/components/preferences/preferences.xhtml | 275 + browser/components/preferences/privacy.inc.xhtml | 1348 + browser/components/preferences/privacy.js | 3385 ++ browser/components/preferences/search.inc.xhtml | 209 + browser/components/preferences/search.js | 1319 + .../components/preferences/searchResults.inc.xhtml | 25 + browser/components/preferences/sync.inc.xhtml | 243 + browser/components/preferences/sync.js | 547 + .../preferences/tests/addons/pl-dictionary.xpi | Bin 0 -> 793 bytes .../preferences/tests/addons/set_homepage.xpi | Bin 0 -> 5156 bytes .../preferences/tests/addons/set_newtab.xpi | Bin 0 -> 5210 bytes browser/components/preferences/tests/browser.toml | 288 + .../preferences/tests/browser_advanced_update.js | 182 + .../browser_application_xml_handle_internally.js | 49 + .../tests/browser_applications_selection.js | 403 + .../tests/browser_basic_rebuild_fonts_test.js | 235 + .../tests/browser_browser_languages_subdialog.js | 1063 + .../browser_bug1018066_resetScrollPosition.js | 30 + ...er_bug1020245_openPreferences_to_paneContent.js | 163 + ...9_prevent_scrolling_when_preferences_flipped.js | 116 + ...revent_scrolling_when_preferences_flipped.xhtml | 26 + .../tests/browser_bug1547020_lockedDownloadDir.js | 24 + .../preferences/tests/browser_bug1579418.js | 55 + .../preferences/tests/browser_bug410900.js | 47 + .../preferences/tests/browser_bug731866.js | 95 + .../tests/browser_bug795764_cachedisabled.js | 62 + .../preferences/tests/browser_cert_export.js | 161 + .../tests/browser_change_app_handler.js | 155 + .../preferences/tests/browser_checkspelling.js | 34 + .../preferences/tests/browser_connection.js | 145 + .../tests/browser_connection_bug1445991.js | 31 + .../tests/browser_connection_bug1505330.js | 31 + .../tests/browser_connection_bug388287.js | 124 + .../tests/browser_connection_valid_hostname.js | 55 + .../tests/browser_containers_name_input.js | 72 + .../preferences/tests/browser_contentblocking.js | 1612 + .../tests/browser_contentblocking_categories.js | 539 + ...browser_contentblocking_standard_tcp_section.js | 148 + .../tests/browser_cookie_exceptions_addRemove.js | 299 + .../tests/browser_cookies_exceptions.js | 568 + .../tests/browser_defaultbrowser_alwayscheck.js | 185 + .../preferences/tests/browser_engines.js | 141 + .../tests/browser_ensure_prefs_bindings_initted.js | 40 + .../tests/browser_etp_exceptions_dialog.js | 96 + .../tests/browser_experimental_features.js | 74 + .../tests/browser_experimental_features_filter.js | 193 + ...experimental_features_hidden_when_not_public.js | 86 + .../browser_experimental_features_resetall.js | 112 + .../tests/browser_extension_controlled.js | 1443 + .../preferences/tests/browser_filetype_dialog.js | 189 + .../components/preferences/tests/browser_fluent.js | 40 + .../preferences/tests/browser_homepage_default.js | 31 + .../browser_homepages_filter_aboutpreferences.js | 33 + .../tests/browser_homepages_use_bookmark.js | 94 + .../tests/browser_hometab_restore_defaults.js | 220 + .../tests/browser_https_only_exceptions.js | 377 + .../tests/browser_https_only_section.js | 74 + .../tests/browser_ignore_invalid_capability.js | 40 + .../preferences/tests/browser_keyboardfocus.js | 80 + .../tests/browser_languages_subdialog.js | 172 + .../tests/browser_layersacceleration.js | 36 + .../tests/browser_localSearchShortcuts.js | 309 + .../preferences/tests/browser_moreFromMozilla.js | 395 + .../tests/browser_moreFromMozilla_locales.js | 371 + .../preferences/tests/browser_newtab_menu.js | 38 + .../tests/browser_notifications_do_not_disturb.js | 57 + .../tests/browser_open_download_preferences.js | 288 + .../tests/browser_open_migration_wizard.js | 27 + .../tests/browser_password_management.js | 43 + .../preferences/tests/browser_pdf_disabled.js | 49 + .../preferences/tests/browser_performance.js | 300 + .../browser_performance_content_process_limit.js | 52 + .../tests/browser_performance_e10srollout.js | 164 + .../tests/browser_performance_non_e10s.js | 210 + ...rowser_permissions_checkPermissionsWereAdded.js | 149 + .../tests/browser_permissions_dialog.js | 641 + .../browser_permissions_dialog_default_perm.js | 145 + .../tests/browser_permissions_urlFieldHidden.js | 38 + .../preferences/tests/browser_primaryPassword.js | 130 + .../tests/browser_privacy_cookieBannerHandling.js | 160 + .../tests/browser_privacy_dnsoverhttps.js | 844 + .../tests/browser_privacy_firefoxSuggest.js | 140 + .../preferences/tests/browser_privacy_gpc.js | 182 + ...rowser_privacy_passwordGenerationAndAutofill.js | 199 + .../tests/browser_privacy_relayIntegration.js | 251 + .../tests/browser_privacy_segmentation_pref.js | 131 + .../tests/browser_privacy_syncDataClearing.js | 287 + .../preferences/tests/browser_privacypane_2.js | 19 + .../preferences/tests/browser_privacypane_3.js | 21 + .../preferences/tests/browser_proxy_backup.js | 84 + .../tests/browser_sanitizeOnShutdown_prefLocked.js | 47 + .../tests/browser_searchChangedEngine.js | 90 + .../tests/browser_searchDefaultEngine.js | 372 + .../tests/browser_searchFindMoreLink.js | 36 + .../tests/browser_searchRestoreDefaults.js | 259 + .../preferences/tests/browser_searchScroll.js | 66 + .../tests/browser_searchShowSuggestionsFirst.js | 244 + .../tests/browser_search_firefoxSuggest.js | 628 + .../browser_search_no_results_change_category.js | 44 + .../tests/browser_search_quickactions.js | 110 + .../tests/browser_search_searchTerms.js | 199 + ...ser_search_subdialog_tooltip_saved_addresses.js | 39 + ...owser_search_subdialogs_within_preferences_1.js | 48 + ...owser_search_subdialogs_within_preferences_2.js | 36 + ...owser_search_subdialogs_within_preferences_3.js | 35 + ...owser_search_subdialogs_within_preferences_4.js | 39 + ...owser_search_subdialogs_within_preferences_5.js | 49 + ...owser_search_subdialogs_within_preferences_6.js | 35 + ...owser_search_subdialogs_within_preferences_7.js | 34 + ...owser_search_subdialogs_within_preferences_8.js | 45 + ...arch_subdialogs_within_preferences_site_data.js | 48 + .../tests/browser_search_within_preferences_1.js | 344 + .../tests/browser_search_within_preferences_2.js | 180 + .../browser_search_within_preferences_command.js | 45 + .../preferences/tests/browser_searchsuggestions.js | 231 + .../preferences/tests/browser_security-1.js | 106 + .../preferences/tests/browser_security-2.js | 177 + .../preferences/tests/browser_security-3.js | 130 + .../tests/browser_site_login_exceptions.js | 101 + .../tests/browser_site_login_exceptions_policy.js | 65 + .../preferences/tests/browser_spotlight.js | 72 + .../tests/browser_statePartitioning_PBM_strings.js | 124 + .../tests/browser_statePartitioning_strings.js | 79 + .../preferences/tests/browser_subdialogs.js | 659 + .../tests/browser_sync_chooseWhatToSync.js | 178 + .../preferences/tests/browser_sync_disabled.js | 26 + .../preferences/tests/browser_sync_pairing.js | 149 + .../tests/browser_trendingsuggestions.js | 80 + .../browser_warning_permanent_private_browsing.js | 57 + .../tests/browser_windows_launch_on_login.js | 145 + .../preferences/tests/empty_pdf_file.pdf | 0 .../preferences/tests/engine1/manifest.json | 27 + .../preferences/tests/engine2/manifest.json | 27 + browser/components/preferences/tests/head.js | 334 + .../tests/privacypane_tests_perwindow.js | 388 + .../preferences/tests/siteData/browser.toml | 24 + .../tests/siteData/browser_clearSiteData.js | 243 + .../preferences/tests/siteData/browser_siteData.js | 395 + .../tests/siteData/browser_siteData2.js | 475 + .../tests/siteData/browser_siteData3.js | 328 + .../siteData/browser_siteData_multi_select.js | 119 + .../components/preferences/tests/siteData/head.js | 283 + .../tests/siteData/offline/manifest.appcache | 3 + .../tests/siteData/offline/offline.html | 13 + .../tests/siteData/service_worker_test.html | 19 + .../tests/siteData/service_worker_test.js | 1 + .../preferences/tests/siteData/site_data_test.html | 29 + .../components/preferences/tests/subdialog.xhtml | 31 + .../components/preferences/tests/subdialog2.xhtml | 29 + .../components/preferences/translations.inc.xhtml | 60 + browser/components/preferences/translations.js | 15 + .../components/preferences/web-appearance-dark.svg | 17 + .../preferences/web-appearance-light.svg | 17 + .../privatebrowsing/ResetPBMPanel.sys.mjs | 247 + .../content/aboutPrivateBrowsing.css | 8 + .../content/aboutPrivateBrowsing.html | 132 + .../content/aboutPrivateBrowsing.js | 406 + .../content/assets/cookie-banners-begone.svg | 34 + .../privatebrowsing/content/assets/focus-logo.svg | 112 + .../privatebrowsing/content/assets/focus-promo.png | Bin 0 -> 49712 bytes .../content/assets/focus-qr-code.svg | 114 + .../content/assets/klar-qr-code.svg | 114 + .../privatebrowsing/content/assets/moz-vpn.svg | 4 + .../content/assets/private-promo-asset.svg | 144 + .../privatebrowsing/content/assets/vpn-logo.svg | 6 + browser/components/privatebrowsing/jar.mn | 9 + browser/components/privatebrowsing/metrics.yaml | 50 + browser/components/privatebrowsing/moz.build | 18 + .../privatebrowsing/test/browser/browser.toml | 123 + .../browser/browser_oa_private_browsing_window.js | 64 + ...owser_privatebrowsing_DownloadLastDirWithCPS.js | 445 + .../test/browser/browser_privatebrowsing_about.js | 266 + .../browser_privatebrowsing_aboutSessionRestore.js | 25 + ...r_privatebrowsing_about_cookie_banners_promo.js | 107 + ...wser_privatebrowsing_about_default_pin_promo.js | 110 + .../browser_privatebrowsing_about_default_promo.js | 224 + .../browser_privatebrowsing_about_focus_promo.js | 89 + .../browser_privatebrowsing_about_nimbus.js | 459 + ...browser_privatebrowsing_about_nimbus_dismiss.js | 139 + ...ser_privatebrowsing_about_nimbus_impressions.js | 126 + ...owser_privatebrowsing_about_nimbus_messaging.js | 247 + .../browser_privatebrowsing_about_search_banner.js | 317 + .../test/browser/browser_privatebrowsing_beacon.js | 46 + .../browser/browser_privatebrowsing_blobUrl.js | 69 + .../test/browser/browser_privatebrowsing_cache.js | 94 + .../browser_privatebrowsing_certexceptionsui.js | 65 + .../browser/browser_privatebrowsing_cleanup.js | 46 + .../browser/browser_privatebrowsing_concurrent.js | 101 + .../browser_privatebrowsing_concurrent_page.html | 33 + ...wser_privatebrowsing_context_and_chromeFlags.js | 69 + .../test/browser/browser_privatebrowsing_crh.js | 48 + .../browser_privatebrowsing_downloadLastDir.js | 133 + .../browser_privatebrowsing_downloadLastDir_c.js | 146 + ...owser_privatebrowsing_downloadLastDir_toggle.js | 118 + .../browser/browser_privatebrowsing_favicon.js | 322 + .../browser_privatebrowsing_geoprompt_page.html | 13 + .../browser_privatebrowsing_history_shift_click.js | 69 + ...rowsing_last_private_browsing_context_exited.js | 66 + .../browser_privatebrowsing_lastpbcontextexited.js | 63 + .../browser_privatebrowsing_localStorage.js | 28 + ...er_privatebrowsing_localStorage_before_after.js | 46 + ...atebrowsing_localStorage_before_after_page.html | 11 + ...tebrowsing_localStorage_before_after_page2.html | 10 + ...browser_privatebrowsing_localStorage_page1.html | 10 + ...browser_privatebrowsing_localStorage_page2.html | 10 + .../browser_privatebrowsing_newtab_from_popup.js | 71 + ...r_privatebrowsing_noSessionRestoreMenuOption.js | 29 + .../browser/browser_privatebrowsing_nonbrowser.js | 21 + .../browser/browser_privatebrowsing_opendir.js | 175 + ...rowser_privatebrowsing_placesTitleNoUpdate.html | 8 + .../browser_privatebrowsing_placesTitleNoUpdate.js | 59 + .../browser/browser_privatebrowsing_placestitle.js | 82 + .../browser_privatebrowsing_protocolhandler.js | 71 + ...owser_privatebrowsing_protocolhandler_page.html | 13 + .../browser_privatebrowsing_rememberprompt.js | 90 + .../browser/browser_privatebrowsing_resetPBM.js | 824 + .../browser/browser_privatebrowsing_sidebar.js | 88 + .../browser/browser_privatebrowsing_theming.js | 46 + .../test/browser/browser_privatebrowsing_ui.js | 102 + .../browser/browser_privatebrowsing_urlbarfocus.js | 44 + .../browser/browser_privatebrowsing_windowtitle.js | 140 + .../browser_privatebrowsing_windowtitle_page.html | 9 + .../browser_privatebrowsing_xrprompt_page.html | 11 + .../test/browser/browser_privatebrowsing_zoom.js | 46 + .../browser/browser_privatebrowsing_zoomrestore.js | 80 + .../privatebrowsing/test/browser/empty_file.html | 1 + .../privatebrowsing/test/browser/file_favicon.html | 11 + .../privatebrowsing/test/browser/file_favicon.png | Bin 0 -> 344 bytes .../test/browser/file_favicon.png^headers^ | 1 + .../test/browser/file_triggeringprincipal_oa.html | 10 + .../privatebrowsing/test/browser/head.js | 163 + .../privatebrowsing/test/browser/title.sjs | 23 + .../components/prompts/PromptCollection.sys.mjs | 193 + browser/components/prompts/components.conf | 12 + browser/components/prompts/moz.build | 14 + .../protections/content/lockwise-card.mjs | 142 + .../protections/content/monitor-card.mjs | 449 + .../components/protections/content/protections.css | 1127 + .../components/protections/content/protections.ftl | 26 + .../protections/content/protections.html | 597 + .../components/protections/content/protections.mjs | 490 + .../components/protections/content/proxy-card.mjs | 29 + .../components/protections/content/vpn-card.mjs | 103 + browser/components/protections/jar.mn | 12 + browser/components/protections/moz.build | 12 + .../protections/test/browser/browser.toml | 18 + .../test/browser/browser_protections_lockwise.js | 290 + .../test/browser/browser_protections_monitor.js | 161 + .../test/browser/browser_protections_proxy.js | 107 + .../test/browser/browser_protections_report_ui.js | 1129 + .../test/browser/browser_protections_telemetry.js | 1123 + .../test/browser/browser_protections_vpn.js | 282 + .../components/protections/test/browser/head.js | 96 + .../WebProtocolHandlerRegistrar.sys.mjs | 506 + browser/components/protocolhandler/components.conf | 15 + browser/components/protocolhandler/metrics.yaml | 55 + browser/components/protocolhandler/moz.build | 18 + .../protocolhandler/test/browser/browser.toml | 5 + ...owser_registerProtocolHandler_notification.html | 15 + ...browser_registerProtocolHandler_notification.js | 57 + .../protocolhandler/test/test_registerHandler.html | 88 + .../reportbrokensite/ReportBrokenSite.sys.mjs | 770 + .../content/reportBrokenSitePanel.inc.xhtml | 128 + browser/components/reportbrokensite/moz.build | 14 + .../reportbrokensite/test/browser/browser.toml | 39 + .../test/browser/browser_antitracking_data_sent.js | 113 + .../test/browser/browser_back_buttons.js | 37 + .../test/browser/browser_error_messages.js | 64 + .../test/browser/browser_keyboard_navigation.js | 113 + .../test/browser/browser_parent_menuitems.js | 107 + .../test/browser/browser_prefers_contrast.js | 62 + .../test/browser/browser_reason_dropdown.js | 161 + .../test/browser/browser_report_send.js | 79 + .../browser/browser_report_site_issue_fallback.js | 89 + .../test/browser/browser_send_more_info.js | 68 + .../browser/browser_site_not_working_fallback.js | 35 + .../test/browser/browser_tab_key_order.js | 133 + .../test/browser/browser_tab_switch_handling.js | 81 + .../test/browser/example_report_page.html | 22 + .../reportbrokensite/test/browser/head.js | 863 + .../reportbrokensite/test/browser/send.js | 290 + .../test/browser/sendMoreInfoTestEndpoint.html | 27 + .../test/browser/send_more_info.js | 215 + browser/components/resistfingerprinting/moz.build | 16 + .../resistfingerprinting/test/browser/browser.toml | 124 + .../test/browser/browser_animationapi_iframes.js | 235 + .../test/browser/browser_block_mozAddonManager.js | 43 + .../browser_bug1369357_site_specific_zoom_level.js | 77 + .../browser_cross_origin_isolated_animation_api.js | 159 + ...rowser_cross_origin_isolated_performance_api.js | 171 + ..._cross_origin_isolated_reduce_time_precision.js | 497 + .../browser/browser_dynamical_window_rounding.js | 397 + .../browser/browser_hwconcurrency_etp_iframes.js | 126 + .../test/browser/browser_hwconcurrency_iframes.js | 102 + .../browser_hwconcurrency_iframes_aboutblank.js | 102 + .../browser_hwconcurrency_iframes_aboutsrcdoc.js | 102 + .../browser/browser_hwconcurrency_iframes_blob.js | 102 + ...rowser_hwconcurrency_iframes_blobcrossorigin.js | 116 + .../browser/browser_hwconcurrency_iframes_data.js | 102 + ...rowser_hwconcurrency_iframes_sandboxediframe.js | 102 + .../test/browser/browser_hwconcurrency_popups.js | 82 + .../browser_hwconcurrency_popups_aboutblank.js | 73 + .../browser/browser_hwconcurrency_popups_blob.js | 73 + .../browser_hwconcurrency_popups_blob_noopener.js | 93 + .../browser/browser_hwconcurrency_popups_data.js | 73 + .../browser_hwconcurrency_popups_data_noopener.js | 93 + .../browser_hwconcurrency_popups_noopener.js | 101 + .../test/browser/browser_math.js | 115 + .../test/browser/browser_navigator.js | 535 + .../test/browser/browser_navigator_iframes.js | 424 + .../test/browser/browser_netInfo.js | 64 + .../test/browser/browser_performanceAPI.js | 203 + .../test/browser/browser_performanceAPIWorkers.js | 79 + .../browser/browser_reduceTimePrecision_iframes.js | 242 + .../browser/browser_roundedWindow_dialogWindow.js | 51 + .../browser/browser_roundedWindow_newWindow.js | 62 + .../browser_roundedWindow_open_max_inner.js | 26 + .../browser_roundedWindow_open_mid_inner.js | 26 + .../browser_roundedWindow_open_min_inner.js | 20 + .../browser/browser_spoofing_keyboard_event.js | 2238 ++ .../test/browser/browser_timezone.js | 194 + .../test/browser/coop_header.sjs | 79 + .../test/browser/file_animationapi_iframee.html | 85 + .../test/browser/file_animationapi_iframer.html | 31 + .../test/browser/file_dummy.html | 13 + .../file_hwconcurrency_aboutblank_iframee.html | 28 + .../file_hwconcurrency_aboutblank_iframer.html | 31 + .../file_hwconcurrency_aboutblank_popupmaker.html | 65 + .../file_hwconcurrency_aboutsrcdoc_iframee.html | 29 + .../file_hwconcurrency_aboutsrcdoc_iframer.html | 31 + .../browser/file_hwconcurrency_blob_iframee.html | 35 + .../browser/file_hwconcurrency_blob_iframer.html | 31 + .../file_hwconcurrency_blob_popupmaker.html | 67 + ...file_hwconcurrency_blobcrossorigin_iframee.html | 20 + ...file_hwconcurrency_blobcrossorigin_iframer.html | 29 + .../browser/file_hwconcurrency_data_iframee.html | 38 + .../browser/file_hwconcurrency_data_iframer.html | 31 + .../file_hwconcurrency_data_popupmaker.html | 58 + .../test/browser/file_hwconcurrency_iframee.html | 29 + .../test/browser/file_hwconcurrency_iframer.html | 55 + ...wconcurrency_sandboxediframe_double_framee.html | 19 + ...file_hwconcurrency_sandboxediframe_iframee.html | 25 + ...file_hwconcurrency_sandboxediframe_iframer.html | 30 + .../test/browser/file_keyBoardEvent.sjs | 62 + .../test/browser/file_navigator.html | 33 + .../test/browser/file_navigator.worker.js | 17 + .../test/browser/file_navigator_header.sjs | 12 + .../test/browser/file_navigator_iframe_worker.sjs | 25 + .../test/browser/file_navigator_iframee.html | 60 + .../test/browser/file_navigator_iframer.html | 37 + .../file_reduceTimePrecision_iframe_worker.sjs | 34 + .../browser/file_reduceTimePrecision_iframee.html | 52 + .../browser/file_reduceTimePrecision_iframer.html | 31 + .../test/browser/file_workerNetInfo.js | 32 + .../test/browser/file_workerPerformance.js | 124 + .../resistfingerprinting/test/browser/head.js | 1094 + .../test/browser/shared_test_funcs.js | 10 + .../test/mochitest/.eslintrc.js | 7 + .../test/mochitest/decode_error.mp4 | Bin 0 -> 344124 bytes .../test/mochitest/file_animation_api.html | 104 + .../test/mochitest/mochitest.toml | 38 + .../test/mochitest/test_animation_api.html | 78 + .../mochitest/test_bug1354633_media_error.html | 53 + .../test/mochitest/test_bug1382499_touch_api.html | 70 + .../mochitest/test_bug863246_resource_uri.html | 43 + .../test/mochitest/test_device_sensor_event.html | 50 + .../test/mochitest/test_geolocation.html | 68 + .../test/mochitest/test_hide_gamepad_info.html | 23 + .../mochitest/test_hide_gamepad_info_iframe.html | 45 + .../test/mochitest/test_iframe.html | 18 + .../test/mochitest/test_keyboard_event.html | 61 + .../test/mochitest/test_pointer_event.html | 242 + .../test/mochitest/test_speech_synthesis.html | 105 + .../test/mochitest/worker_child.js | 28 + .../test/mochitest/worker_grandchild.js | 10 + .../safebrowsing/content/test/browser.toml | 14 + .../safebrowsing/content/test/browser_bug400731.js | 65 + .../safebrowsing/content/test/browser_bug415846.js | 98 + .../test/browser_mixedcontent_aboutblocked.js | 43 + .../content/test/browser_whitelisted.js | 46 + .../safebrowsing/content/test/empty_file.html | 1 + .../components/safebrowsing/content/test/head.js | 103 + .../screenshots/ScreenshotsHelperChild.sys.mjs | 47 + .../screenshots/ScreenshotsOverlayChild.sys.mjs | 1593 + .../screenshots/ScreenshotsUtils.sys.mjs | 993 + browser/components/screenshots/content/cancel.svg | 4 + .../screenshots/content/copied-notification.svg | 4 + browser/components/screenshots/content/copy.svg | 4 + .../screenshots/content/download-white.svg | 4 + .../components/screenshots/content/download.svg | 4 + .../content/icon-welcome-face-without-eyes.svg | 4 + .../screenshots/content/menu-fullpage.svg | 4 + .../screenshots/content/menu-visible.svg | 4 + .../components/screenshots/content/screenshots.css | 68 + .../screenshots/content/screenshots.html | 68 + .../components/screenshots/content/screenshots.js | 105 + browser/components/screenshots/fileHelpers.mjs | 269 + browser/components/screenshots/jar.mn | 24 + browser/components/screenshots/moz.build | 20 + browser/components/screenshots/overlay/overlay.css | 349 + browser/components/screenshots/overlayHelpers.mjs | 497 + .../components/screenshots/screenshots-buttons.css | 35 + .../components/screenshots/screenshots-buttons.js | 68 + .../screenshots/tests/browser/browser.toml | 55 + .../tests/browser/browser_iframe_test.js | 123 + .../tests/browser/browser_overlay_keyboard_test.js | 748 + .../browser_screenshots_drag_scroll_test.js | 465 + .../tests/browser/browser_screenshots_drag_test.js | 488 + .../browser/browser_screenshots_focus_test.js | 384 + .../browser_screenshots_overlay_panel_sync.js | 74 + .../browser/browser_screenshots_page_unload.js | 42 + .../browser/browser_screenshots_short_page_test.js | 123 + .../browser/browser_screenshots_telemetry_tests.js | 466 + .../browser/browser_screenshots_test_downloads.js | 186 + .../browser/browser_screenshots_test_escape.js | 35 + .../browser/browser_screenshots_test_full_page.js | 175 + .../browser/browser_screenshots_test_page_crash.js | 54 + .../browser_screenshots_test_screenshot_too_big.js | 90 + .../browser_screenshots_test_toggle_pref.js | 289 + .../browser_screenshots_test_toolbar_button.js | 26 + .../browser/browser_screenshots_test_visible.js | 356 + .../tests/browser/browser_test_element_picker.js | 56 + .../tests/browser/browser_test_resize.js | 100 + .../screenshots/tests/browser/first-iframe.html | 23 + .../components/screenshots/tests/browser/head.js | 951 + .../tests/browser/iframe-test-page.html | 23 + .../screenshots/tests/browser/large-test-page.html | 9 + .../screenshots/tests/browser/second-iframe.html | 18 + .../screenshots/tests/browser/short-test-page.html | 8 + .../tests/browser/test-page-resize.html | 25 + .../screenshots/tests/browser/test-page.html | 27 + browser/components/search/.eslintrc.js | 13 + .../search/BrowserSearchTelemetry.sys.mjs | 328 + browser/components/search/SearchOneOffs.sys.mjs | 1126 + .../components/search/SearchSERPTelemetry.sys.mjs | 2515 ++ browser/components/search/SearchUIUtils.sys.mjs | 120 + .../search/content/autocomplete-popup.js | 289 + .../search/content/contentSearchHandoffUI.js | 152 + .../components/search/content/contentSearchUI.css | 160 + .../components/search/content/contentSearchUI.js | 1021 + browser/components/search/content/searchbar.js | 907 + browser/components/search/docs/Preferences.rst | 25 + .../search/docs/application-search-engines.rst | 41 + browser/components/search/docs/index.rst | 23 + browser/components/search/docs/telemetry.rst | 201 + .../components/search/extensions/1und1/favicon.ico | Bin 0 -> 159 bytes .../search/extensions/1und1/manifest.json | 24 + .../search/extensions/allegro-pl/favicon.ico | Bin 0 -> 1150 bytes .../search/extensions/allegro-pl/manifest.json | 24 + .../extensions/amazon/_locales/au/messages.json | 17 + .../extensions/amazon/_locales/ca/messages.json | 17 + .../extensions/amazon/_locales/de/messages.json | 17 + .../extensions/amazon/_locales/en-GB/messages.json | 17 + .../amazon/_locales/france/messages.json | 17 + .../extensions/amazon/_locales/in/messages.json | 17 + .../extensions/amazon/_locales/it/messages.json | 17 + .../extensions/amazon/_locales/jp/messages.json | 23 + .../extensions/amazon/_locales/nl/messages.json | 17 + .../extensions/amazon/_locales/spain/messages.json | 17 + .../amazon/_locales/sweden/messages.json | 17 + .../search/extensions/amazon/favicon.ico | Bin 0 -> 1407 bytes .../search/extensions/amazon/manifest.json | 26 + .../amazondotcn/_locales/default/messages.json | 8 + .../_locales/mozillaonline/messages.json | 8 + .../search/extensions/amazondotcn/favicon.ico | Bin 0 -> 1407 bytes .../search/extensions/amazondotcn/manifest.json | 26 + .../amazondotcom/_locales/en/messages.json | 17 + .../amazondotcom/_locales/us/messages.json | 17 + .../search/extensions/amazondotcom/favicon.ico | Bin 0 -> 1407 bytes .../search/extensions/amazondotcom/manifest.json | 26 + .../search/extensions/azerdict/favicon.ico | Bin 0 -> 5430 bytes .../search/extensions/azerdict/manifest.json | 24 + .../components/search/extensions/baidu/favicon.ico | Bin 0 -> 5430 bytes .../search/extensions/baidu/manifest.json | 27 + .../components/search/extensions/bing/favicon.ico | Bin 0 -> 4286 bytes .../search/extensions/bing/manifest.json | 59 + .../search/extensions/bok-NO/favicon.png | Bin 0 -> 530 bytes .../search/extensions/bok-NO/manifest.json | 24 + .../search/extensions/ceneji/favicon.png | Bin 0 -> 283 bytes .../search/extensions/ceneji/manifest.json | 24 + .../search/extensions/coccoc/favicon.ico | Bin 0 -> 5430 bytes .../search/extensions/coccoc/manifest.json | 25 + .../search/extensions/daum-kr/favicon.ico | Bin 0 -> 5430 bytes .../search/extensions/daum-kr/manifest.json | 26 + .../components/search/extensions/ddg/favicon.ico | Bin 0 -> 2799 bytes .../components/search/extensions/ddg/manifest.json | 27 + .../extensions/ebay/_locales/at/messages.json | 20 + .../extensions/ebay/_locales/au/messages.json | 20 + .../extensions/ebay/_locales/be/messages.json | 20 + .../extensions/ebay/_locales/ca/messages.json | 20 + .../extensions/ebay/_locales/ch/messages.json | 20 + .../extensions/ebay/_locales/de/messages.json | 20 + .../extensions/ebay/_locales/en/messages.json | 20 + .../extensions/ebay/_locales/es/messages.json | 20 + .../extensions/ebay/_locales/fr/messages.json | 20 + .../extensions/ebay/_locales/ie/messages.json | 20 + .../extensions/ebay/_locales/it/messages.json | 20 + .../extensions/ebay/_locales/nl/messages.json | 20 + .../extensions/ebay/_locales/uk/messages.json | 20 + .../components/search/extensions/ebay/favicon.ico | Bin 0 -> 1455 bytes .../search/extensions/ebay/manifest.json | 28 + .../search/extensions/ecosia/favicon.ico | Bin 0 -> 5430 bytes .../search/extensions/ecosia/manifest.json | 26 + .../search/extensions/eudict/favicon.ico | Bin 0 -> 1785 bytes .../search/extensions/eudict/manifest.json | 24 + .../search/extensions/faclair-beag/favicon.ico | Bin 0 -> 1091 bytes .../search/extensions/faclair-beag/manifest.json | 23 + .../extensions/gmx/_locales/de/messages.json | 17 + .../extensions/gmx/_locales/en-GB/messages.json | 17 + .../extensions/gmx/_locales/es/messages.json | 17 + .../extensions/gmx/_locales/fr/messages.json | 17 + .../extensions/gmx/_locales/shopping/messages.json | 17 + .../components/search/extensions/gmx/favicon.png | Bin 0 -> 1122 bytes .../components/search/extensions/gmx/manifest.json | 26 + .../extensions/google/_locales/en/messages.json | 23 + .../google/_locales/region-by/messages.json | 20 + .../google/_locales/region-kz/messages.json | 20 + .../google/_locales/region-ru/messages.json | 20 + .../google/_locales/region-tr/messages.json | 20 + .../search/extensions/google/favicon.ico | Bin 0 -> 5430 bytes .../search/extensions/google/manifest.json | 34 + .../search/extensions/gulesider-NO/favicon.ico | Bin 0 -> 1150 bytes .../search/extensions/gulesider-NO/manifest.json | 24 + .../search/extensions/leo_ende_de/favicon.png | Bin 0 -> 749 bytes .../search/extensions/leo_ende_de/manifest.json | 25 + .../search/extensions/longdo/favicon.ico | Bin 0 -> 252 bytes .../search/extensions/longdo/manifest.json | 26 + .../search/extensions/mailcom/favicon.ico | Bin 0 -> 1150 bytes .../search/extensions/mailcom/manifest.json | 25 + .../mailru/_locales/default/messages.json | 11 + .../mailru/_locales/mailru001/messages.json | 11 + .../mailru/_locales/okru-az/messages.json | 11 + .../mailru/_locales/okru-en-US/messages.json | 11 + .../mailru/_locales/okru-hy-AM/messages.json | 11 + .../mailru/_locales/okru-kk/messages.json | 11 + .../mailru/_locales/okru-ro/messages.json | 11 + .../mailru/_locales/okru-ru/messages.json | 11 + .../mailru/_locales/okru-tr/messages.json | 11 + .../mailru/_locales/okru-uk/messages.json | 11 + .../mailru/_locales/okru-uz/messages.json | 11 + .../search/extensions/mailru/favicon.ico | Bin 0 -> 5430 bytes .../search/extensions/mailru/manifest.json | 27 + .../search/extensions/mapy-cz/favicon.ico | Bin 0 -> 1812 bytes .../search/extensions/mapy-cz/manifest.json | 24 + .../mercadolibre/_locales/ar/messages.json | 17 + .../mercadolibre/_locales/cl/messages.json | 17 + .../mercadolibre/_locales/mx/messages.json | 17 + .../search/extensions/mercadolibre/favicon.ico | Bin 0 -> 5430 bytes .../search/extensions/mercadolibre/manifest.json | 25 + .../search/extensions/mercadolivre/favicon.ico | Bin 0 -> 5430 bytes .../search/extensions/mercadolivre/manifest.json | 24 + .../search/extensions/naver-kr/favicon.ico | Bin 0 -> 5430 bytes .../search/extensions/naver-kr/manifest.json | 26 + .../search/extensions/odpiralni/favicon.png | Bin 0 -> 2639 bytes .../search/extensions/odpiralni/manifest.json | 23 + .../search/extensions/pazaruvaj/favicon.ico | Bin 0 -> 2584 bytes .../search/extensions/pazaruvaj/manifest.json | 24 + .../search/extensions/priberam/favicon.png | Bin 0 -> 790 bytes .../search/extensions/priberam/manifest.json | 25 + .../search/extensions/prisjakt-sv-SE/favicon.ico | Bin 0 -> 1406 bytes .../search/extensions/prisjakt-sv-SE/manifest.json | 26 + .../components/search/extensions/qwant/favicon.ico | Bin 0 -> 5430 bytes .../search/extensions/qwant/manifest.json | 26 + .../search/extensions/qwantjr/favicon.ico | Bin 0 -> 5430 bytes .../search/extensions/qwantjr/manifest.json | 25 + .../search/extensions/rakuten/favicon.ico | Bin 0 -> 2053 bytes .../search/extensions/rakuten/manifest.json | 25 + .../search/extensions/readmoo/favicon.ico | Bin 0 -> 2468 bytes .../search/extensions/readmoo/manifest.json | 24 + .../search/extensions/salidzinilv/favicon.ico | Bin 0 -> 3638 bytes .../search/extensions/salidzinilv/manifest.json | 26 + .../search/extensions/seznam-cz/favicon.ico | Bin 0 -> 1743 bytes .../search/extensions/seznam-cz/manifest.json | 26 + .../search/extensions/tyda-sv-SE/favicon.ico | Bin 0 -> 379 bytes .../search/extensions/tyda-sv-SE/manifest.json | 24 + .../search/extensions/vatera/favicon.ico | Bin 0 -> 5430 bytes .../search/extensions/vatera/manifest.json | 25 + .../components/search/extensions/webde/favicon.ico | Bin 0 -> 3638 bytes .../search/extensions/webde/manifest.json | 24 + .../extensions/wikipedia/_locales/NN/messages.json | 20 + .../extensions/wikipedia/_locales/NO/messages.json | 20 + .../extensions/wikipedia/_locales/af/messages.json | 20 + .../extensions/wikipedia/_locales/an/messages.json | 20 + .../extensions/wikipedia/_locales/ar/messages.json | 20 + .../wikipedia/_locales/ast/messages.json | 20 + .../extensions/wikipedia/_locales/az/messages.json | 20 + .../wikipedia/_locales/be-tarask/messages.json | 20 + .../extensions/wikipedia/_locales/be/messages.json | 20 + .../extensions/wikipedia/_locales/bg/messages.json | 20 + .../extensions/wikipedia/_locales/bn/messages.json | 20 + .../extensions/wikipedia/_locales/br/messages.json | 20 + .../extensions/wikipedia/_locales/bs/messages.json | 20 + .../extensions/wikipedia/_locales/ca/messages.json | 20 + .../extensions/wikipedia/_locales/cy/messages.json | 20 + .../extensions/wikipedia/_locales/cz/messages.json | 20 + .../extensions/wikipedia/_locales/da/messages.json | 20 + .../extensions/wikipedia/_locales/de/messages.json | 20 + .../wikipedia/_locales/dsb/messages.json | 20 + .../extensions/wikipedia/_locales/el/messages.json | 20 + .../extensions/wikipedia/_locales/en/messages.json | 20 + .../extensions/wikipedia/_locales/eo/messages.json | 20 + .../extensions/wikipedia/_locales/es/messages.json | 20 + .../extensions/wikipedia/_locales/et/messages.json | 20 + .../extensions/wikipedia/_locales/eu/messages.json | 20 + .../extensions/wikipedia/_locales/fa/messages.json | 20 + .../extensions/wikipedia/_locales/fi/messages.json | 20 + .../extensions/wikipedia/_locales/fr/messages.json | 20 + .../wikipedia/_locales/fy-NL/messages.json | 20 + .../wikipedia/_locales/ga-IE/messages.json | 20 + .../extensions/wikipedia/_locales/gd/messages.json | 20 + .../extensions/wikipedia/_locales/gl/messages.json | 20 + .../extensions/wikipedia/_locales/gn/messages.json | 20 + .../extensions/wikipedia/_locales/gu/messages.json | 20 + .../extensions/wikipedia/_locales/he/messages.json | 20 + .../extensions/wikipedia/_locales/hi/messages.json | 20 + .../extensions/wikipedia/_locales/hr/messages.json | 20 + .../wikipedia/_locales/hsb/messages.json | 20 + .../extensions/wikipedia/_locales/hu/messages.json | 20 + .../extensions/wikipedia/_locales/hy/messages.json | 20 + .../extensions/wikipedia/_locales/ia/messages.json | 20 + .../extensions/wikipedia/_locales/id/messages.json | 20 + .../extensions/wikipedia/_locales/is/messages.json | 20 + .../extensions/wikipedia/_locales/it/messages.json | 20 + .../extensions/wikipedia/_locales/ja/messages.json | 20 + .../extensions/wikipedia/_locales/ka/messages.json | 20 + .../wikipedia/_locales/kab/messages.json | 20 + .../extensions/wikipedia/_locales/kk/messages.json | 20 + .../extensions/wikipedia/_locales/km/messages.json | 20 + .../extensions/wikipedia/_locales/kn/messages.json | 20 + .../extensions/wikipedia/_locales/kr/messages.json | 20 + .../wikipedia/_locales/lij/messages.json | 20 + .../extensions/wikipedia/_locales/lo/messages.json | 20 + .../extensions/wikipedia/_locales/lt/messages.json | 20 + .../wikipedia/_locales/ltg/messages.json | 20 + .../extensions/wikipedia/_locales/lv/messages.json | 20 + .../extensions/wikipedia/_locales/mk/messages.json | 20 + .../extensions/wikipedia/_locales/mr/messages.json | 20 + .../extensions/wikipedia/_locales/ms/messages.json | 20 + .../extensions/wikipedia/_locales/my/messages.json | 20 + .../extensions/wikipedia/_locales/ne/messages.json | 20 + .../extensions/wikipedia/_locales/nl/messages.json | 20 + .../extensions/wikipedia/_locales/oc/messages.json | 20 + .../extensions/wikipedia/_locales/pa/messages.json | 20 + .../extensions/wikipedia/_locales/pl/messages.json | 20 + .../extensions/wikipedia/_locales/pt/messages.json | 20 + .../extensions/wikipedia/_locales/rm/messages.json | 20 + .../extensions/wikipedia/_locales/ro/messages.json | 20 + .../extensions/wikipedia/_locales/ru/messages.json | 20 + .../extensions/wikipedia/_locales/si/messages.json | 20 + .../extensions/wikipedia/_locales/sk/messages.json | 20 + .../extensions/wikipedia/_locales/sl/messages.json | 20 + .../extensions/wikipedia/_locales/sq/messages.json | 20 + .../extensions/wikipedia/_locales/sr/messages.json | 20 + .../wikipedia/_locales/sv-SE/messages.json | 20 + .../extensions/wikipedia/_locales/ta/messages.json | 20 + .../extensions/wikipedia/_locales/te/messages.json | 20 + .../extensions/wikipedia/_locales/th/messages.json | 20 + .../extensions/wikipedia/_locales/tl/messages.json | 20 + .../extensions/wikipedia/_locales/tr/messages.json | 20 + .../extensions/wikipedia/_locales/uk/messages.json | 20 + .../extensions/wikipedia/_locales/ur/messages.json | 20 + .../extensions/wikipedia/_locales/uz/messages.json | 20 + .../extensions/wikipedia/_locales/vi/messages.json | 20 + .../extensions/wikipedia/_locales/wo/messages.json | 20 + .../wikipedia/_locales/zh-CN/messages.json | 20 + .../wikipedia/_locales/zh-TW/messages.json | 20 + .../search/extensions/wikipedia/favicon.ico | Bin 0 -> 884 bytes .../search/extensions/wikipedia/manifest.json | 27 + .../wiktionary/_locales/oc/messages.json | 20 + .../wiktionary/_locales/te/messages.json | 20 + .../search/extensions/wiktionary/favicon.ico | Bin 0 -> 318 bytes .../search/extensions/wiktionary/manifest.json | 26 + .../search/extensions/wolnelektury-pl/favicon.png | Bin 0 -> 304 bytes .../extensions/wolnelektury-pl/manifest.json | 24 + .../extensions/yahoo-jp-auctions/favicon.ico | Bin 0 -> 2672 bytes .../extensions/yahoo-jp-auctions/manifest.json | 25 + .../search/extensions/yahoo-jp/favicon.ico | Bin 0 -> 5430 bytes .../search/extensions/yahoo-jp/manifest.json | 24 + .../extensions/yandex/_locales/az/messages.json | 38 + .../extensions/yandex/_locales/by/messages.json | 38 + .../extensions/yandex/_locales/en/messages.json | 38 + .../extensions/yandex/_locales/kk/messages.json | 38 + .../extensions/yandex/_locales/ru/messages.json | 38 + .../extensions/yandex/_locales/tr/messages.json | 38 + .../extensions/yandex/_locales/ua/messages.json | 23 + .../search/extensions/yandex/manifest.json | 59 + .../search/extensions/yandex/yandex-en.ico | Bin 0 -> 1338 bytes .../search/extensions/yandex/yandex-ru.ico | Bin 0 -> 1368 bytes browser/components/search/jar.mn | 13 + browser/components/search/metrics.yaml | 355 + browser/components/search/moz.build | 29 + browser/components/search/pings.yaml | 22 + browser/components/search/schema/Readme.txt | 7 + .../search/schema/search-telemetry-schema.json | 417 + .../search/schema/search-telemetry-ui-schema.json | 23 + browser/components/search/test/browser/426329.xml | 11 + .../components/search/test/browser/browser.toml | 103 + .../search/test/browser/browser_426329.js | 301 + .../test/browser/browser_addKeywordSearch.js | 115 + .../test/browser/browser_contentContextMenu.js | 230 + .../test/browser/browser_contentContextMenu.xhtml | 22 + .../search/test/browser/browser_contentSearch.js | 516 + .../search/test/browser/browser_contentSearchUI.js | 1158 + .../browser/browser_contentSearchUI_default.js | 210 + .../browser/browser_contextSearchTabPosition.js | 94 + .../search/test/browser/browser_contextmenu.js | 249 + .../browser/browser_contextmenu_whereToOpenLink.js | 183 + .../test/browser/browser_defaultPrivate_nimbus.js | 155 + .../search/test/browser/browser_google_behavior.js | 215 + .../browser/browser_hiddenOneOffs_diacritics.js | 75 + .../search/test/browser/browser_ime_composition.js | 77 + .../test/browser/browser_oneOffContextMenu.js | 89 + .../browser_oneOffContextMenu_setDefault.js | 236 + .../browser/browser_private_search_perwindowpb.js | 84 + .../test/browser/browser_rich_suggestions.js | 125 + .../test/browser/browser_searchEngine_behaviors.js | 223 + .../test/browser/browser_search_annotation.js | 176 + .../test/browser/browser_search_discovery.js | 132 + .../test/browser/browser_search_nimbus_reload.js | 55 + .../test/browser/browser_searchbar_addEngine.js | 99 + .../test/browser/browser_searchbar_context.js | 246 + .../test/browser/browser_searchbar_default.js | 221 + .../search/test/browser/browser_searchbar_enter.js | 152 + .../browser_searchbar_keyboard_navigation.js | 663 + .../test/browser/browser_searchbar_openpopup.js | 812 + .../test/browser/browser_searchbar_results.js | 60 + ...ser_searchbar_smallpanel_keyboard_navigation.js | 453 + .../test/browser/browser_searchbar_widths.js | 33 + .../test/browser/browser_tooManyEnginesOffered.js | 68 + .../test/browser/browser_trending_suggestions.js | 240 + .../search/test/browser/contentSearchBadImage.xml | 6 + .../test/browser/contentSearchSuggestions.sjs | 9 + .../test/browser/contentSearchSuggestions.xml | 6 + .../search/test/browser/contentSearchUI.html | 22 + .../search/test/browser/contentSearchUI.js | 13 + .../components/search/test/browser/discovery.html | 9 + .../search/test/browser/google_codes/browser.toml | 4 + browser/components/search/test/browser/head.js | 133 + .../components/search/test/browser/mozsearch.sjs | 11 + .../components/search/test/browser/opensearch.html | 10 + .../browser/search-engines/basic/manifest.json | 20 + .../browser/search-engines/private/manifest.json | 20 + .../search/test/browser/searchSuggestionEngine.sjs | 53 + .../search/test/browser/telemetry/browser.toml | 197 + ...ry_categorization_enabled_by_nimbus_variable.js | 186 + ...p_event_telemetry_enabled_by_nimbus_variable.js | 167 + .../browser_search_telemetry_abandonment.js | 294 + .../browser_search_telemetry_aboutHome.js | 135 + ...wser_search_telemetry_adImpression_component.js | 502 + ...owser_search_telemetry_categorization_timing.js | 83 + .../telemetry/browser_search_telemetry_content.js | 204 + ...ch_telemetry_domain_categorization_ad_values.js | 190 + ...lemetry_domain_categorization_download_timer.js | 313 + ...h_telemetry_domain_categorization_extraction.js | 263 + ...earch_telemetry_domain_categorization_region.js | 120 + ...ch_telemetry_domain_categorization_reporting.js | 225 + ...emetry_domain_categorization_reporting_timer.js | 287 + ...domain_categorization_reporting_timer_wakeup.js | 202 + .../browser_search_telemetry_engagement_cached.js | 201 + ...wser_search_telemetry_engagement_cached_serp.js | 218 + .../browser_search_telemetry_engagement_content.js | 633 + ...er_search_telemetry_engagement_multiple_tabs.js | 206 + .../browser_search_telemetry_engagement_non_ad.js | 146 + ...ser_search_telemetry_engagement_query_params.js | 387 + ...browser_search_telemetry_engagement_redirect.js | 372 + .../browser_search_telemetry_engagement_target.js | 457 + .../browser_search_telemetry_new_window.js | 350 + .../telemetry/browser_search_telemetry_private.js | 135 + ...rowser_search_telemetry_remote_settings_sync.js | 329 + .../browser_search_telemetry_searchbar.js | 442 + .../telemetry/browser_search_telemetry_shopping.js | 143 + .../telemetry/browser_search_telemetry_sources.js | 349 + .../browser_search_telemetry_sources_about.js | 225 + .../browser_search_telemetry_sources_ads.js | 378 + .../browser_search_telemetry_sources_ads_clicks.js | 373 + ...search_telemetry_sources_ads_data_attributes.js | 173 + ...ser_search_telemetry_sources_ads_load_events.js | 142 + .../browser_search_telemetry_sources_in_content.js | 506 + .../browser_search_telemetry_sources_navigation.js | 684 + ...rowser_search_telemetry_sources_webextension.js | 219 + .../browser_search_telemetry_spa_in_content.js | 524 + .../browser_search_telemetry_spa_multi_provider.js | 529 + .../browser_search_telemetry_spa_multi_tab.js | 875 + .../browser_search_telemetry_spa_single_tab.js | 661 + .../search/test/browser/telemetry/cacheable.html | 12 + .../test/browser/telemetry/cacheable.html^headers^ | 1 + .../telemetry/domain_category_mappings.json | 8 + .../search/test/browser/telemetry/head-spa.js | 259 + .../search/test/browser/telemetry/head.js | 621 + .../search/test/browser/telemetry/redirect_ad.sjs | 10 + .../test/browser/telemetry/redirect_final.sjs | 9 + .../test/browser/telemetry/redirect_once.sjs | 9 + .../test/browser/telemetry/redirect_thrice.sjs | 9 + .../test/browser/telemetry/redirect_twice.sjs | 9 + .../test/browser/telemetry/searchTelemetry.html | 11 + .../test/browser/telemetry/searchTelemetryAd.html | 13 + .../searchTelemetryAd_components_carousel.html | 116 + ...metryAd_components_carousel_below_the_fold.html | 83 + ...rchTelemetryAd_components_carousel_doubled.html | 182 + ...ponents_carousel_first_element_non_visible.html | 85 + ...archTelemetryAd_components_carousel_hidden.html | 87 + ...etryAd_components_carousel_outer_container.html | 83 + ...rchTelemetryAd_components_query_parameters.html | 36 + .../searchTelemetryAd_components_text.html | 112 + .../searchTelemetryAd_components_visibility.html | 46 + .../searchTelemetryAd_dataAttributes.html | 10 + .../searchTelemetryAd_dataAttributes_href.html | 10 + .../searchTelemetryAd_dataAttributes_none.html | 10 + .../searchTelemetryAd_nonAdsLink_redirect.html | 12 + ...chTelemetryAd_nonAdsLink_redirect.html^headers^ | 1 + ...lemetryAd_nonAdsLink_redirect_nonTopLoaded.html | 17 + ..._nonAdsLink_redirect_nonTopLoaded.html^headers^ | 4 + .../telemetry/searchTelemetryAd_searchbox.html | 38 + .../searchTelemetryAd_searchbox.html^headers^ | 1 + .../searchTelemetryAd_searchbox_with_content.html | 39 + ...elemetryAd_searchbox_with_content.html^headers^ | 1 + ...elemetryAd_searchbox_with_content_redirect.html | 12 + ...d_searchbox_with_content_redirect.html^headers^ | 1 + .../telemetry/searchTelemetryAd_shopping.html | 15 + .../searchTelemetryDomainCategorization.html | 45 + ...tryDomainCategorizationCapProcessedDomains.html | 64 + ...archTelemetryDomainCategorizationReporting.html | 45 + .../telemetry/searchTelemetryDomainExtraction.html | 84 + .../telemetry/searchTelemetrySinglePageApp.html | 243 + .../search/test/browser/telemetry/serp.css | 164 + .../telemetry/slow_loading_page_with_ads.html | 14 + .../telemetry/slow_loading_page_with_ads.sjs | 21 + .../slow_loading_page_with_ads_on_load_event.html | 30 + .../telemetry/telemetrySearchSuggestions.sjs | 9 + .../telemetry/telemetrySearchSuggestions.xml | 6 + browser/components/search/test/browser/test.html | 8 + .../components/search/test/browser/testEngine.xml | 12 + .../search/test/browser/testEngine_chromeicon.xml | 12 + .../search/test/browser/testEngine_diacritics.xml | 12 + .../search/test/browser/testEngine_dupe.xml | 12 + .../search/test/browser/testEngine_mozsearch.xml | 14 + .../search/test/browser/test_search.html | 1 + .../search/test/browser/tooManyEnginesOffered.html | 13 + .../test/browser/trendingSuggestionEngine.sjs | 54 + .../search/test/marionette/manifest.toml | 4 + .../test/marionette/test_engines_on_restart.py | 40 + .../test/unit/domain_category_mappings_1a.json | 3 + .../test/unit/domain_category_mappings_1b.json | 3 + .../test/unit/domain_category_mappings_2a.json | 3 + .../test/unit/domain_category_mappings_2b.json | 3 + .../test_search_telemetry_categorization_logic.js | 346 + ...rch_telemetry_categorization_process_domains.js | 89 + .../test_search_telemetry_categorization_sync.js | 423 + .../unit/test_search_telemetry_compare_urls.js | 188 + .../test_search_telemetry_config_validation.js | 137 + .../search/test/unit/test_urlTelemetry.js | 306 + .../search/test/unit/test_urlTelemetry_generic.js | 329 + browser/components/search/test/unit/xpcshell.toml | 29 + .../components/sessionstore/ContentRestore.sys.mjs | 435 + .../sessionstore/ContentSessionStore.sys.mjs | 685 + .../components/sessionstore/GlobalState.sys.mjs | 88 + .../RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs | 372 + browser/components/sessionstore/RunState.sys.mjs | 92 + .../components/sessionstore/SessionCookies.sys.mjs | 303 + .../components/sessionstore/SessionFile.sys.mjs | 467 + .../sessionstore/SessionMigration.sys.mjs | 92 + .../components/sessionstore/SessionSaver.sys.mjs | 405 + .../components/sessionstore/SessionStartup.sys.mjs | 419 + .../components/sessionstore/SessionStore.sys.mjs | 7727 ++++ .../components/sessionstore/SessionWriter.sys.mjs | 396 + .../sessionstore/StartupPerformance.sys.mjs | 242 + .../components/sessionstore/TabAttributes.sys.mjs | 72 + browser/components/sessionstore/TabState.sys.mjs | 204 + .../components/sessionstore/TabStateCache.sys.mjs | 171 + .../sessionstore/TabStateFlusher.sys.mjs | 234 + .../sessionstore/content/aboutSessionRestore.js | 447 + .../sessionstore/content/aboutSessionRestore.xhtml | 69 + .../sessionstore/content/content-sessionStore.js | 13 + browser/components/sessionstore/jar.mn | 8 + browser/components/sessionstore/moz.build | 38 + .../test/SessionStoreTestUtils.sys.mjs | 186 + browser/components/sessionstore/test/browser.toml | 577 + .../sessionstore/test/browser_1234021.js | 22 + .../sessionstore/test/browser_1234021_page.html | 6 + .../test/browser_1284886_suspend_tab.html | 12 + .../test/browser_1284886_suspend_tab.js | 95 + .../test/browser_1284886_suspend_tab_2.html | 11 + .../test/browser_1446343-windowsize.js | 39 + .../test/browser_248970_b_perwindowpb.js | 199 + .../sessionstore/test/browser_248970_b_sample.html | 37 + .../components/sessionstore/test/browser_339445.js | 39 + .../sessionstore/test/browser_339445_sample.html | 18 + .../components/sessionstore/test/browser_345898.js | 69 + .../components/sessionstore/test/browser_350525.js | 136 + .../test/browser_354894_perwindowpb.js | 489 + .../components/sessionstore/test/browser_367052.js | 52 + .../components/sessionstore/test/browser_393716.js | 103 + .../sessionstore/test/browser_394759_basic.js | 123 + .../sessionstore/test/browser_394759_behavior.js | 91 + .../test/browser_394759_perwindowpb.js | 61 + .../sessionstore/test/browser_394759_purge.js | 247 + .../components/sessionstore/test/browser_423132.js | 52 + .../sessionstore/test/browser_423132_sample.html | 14 + .../components/sessionstore/test/browser_447951.js | 84 + .../sessionstore/test/browser_447951_sample.html | 5 + .../components/sessionstore/test/browser_454908.js | 65 + .../sessionstore/test/browser_454908_sample.html | 8 + .../components/sessionstore/test/browser_456342.js | 90 + .../sessionstore/test/browser_456342_sample.xhtml | 46 + .../components/sessionstore/test/browser_459906.js | 78 + .../sessionstore/test/browser_459906_empty.html | 3 + .../sessionstore/test/browser_459906_sample.html | 41 + .../components/sessionstore/test/browser_461634.js | 133 + .../components/sessionstore/test/browser_461743.js | 53 + .../sessionstore/test/browser_461743_sample.html | 56 + .../components/sessionstore/test/browser_463205.js | 40 + .../sessionstore/test/browser_463205_sample.html | 7 + .../components/sessionstore/test/browser_463206.js | 120 + .../sessionstore/test/browser_463206_sample.html | 11 + .../components/sessionstore/test/browser_464199.js | 176 + .../sessionstore/test/browser_464620_a.html | 54 + .../sessionstore/test/browser_464620_a.js | 64 + .../sessionstore/test/browser_464620_b.html | 57 + .../sessionstore/test/browser_464620_b.js | 64 + .../sessionstore/test/browser_464620_xd.html | 5 + .../components/sessionstore/test/browser_465215.js | 36 + .../components/sessionstore/test/browser_465223.js | 51 + .../components/sessionstore/test/browser_466937.js | 51 + .../sessionstore/test/browser_466937_sample.html | 20 + .../test/browser_467409-backslashplosion.js | 88 + .../components/sessionstore/test/browser_477657.js | 80 + .../components/sessionstore/test/browser_480893.js | 45 + .../components/sessionstore/test/browser_485482.js | 76 + .../sessionstore/test/browser_485482_sample.html | 12 + .../components/sessionstore/test/browser_485563.js | 33 + .../components/sessionstore/test/browser_490040.js | 105 + .../components/sessionstore/test/browser_491168.js | 113 + .../components/sessionstore/test/browser_491577.js | 212 + .../components/sessionstore/test/browser_495495.js | 47 + .../components/sessionstore/test/browser_500328.js | 132 + .../components/sessionstore/test/browser_506482.js | 78 + .../components/sessionstore/test/browser_514751.js | 41 + .../components/sessionstore/test/browser_522375.js | 25 + .../components/sessionstore/test/browser_522545.js | 443 + .../components/sessionstore/test/browser_524745.js | 55 + .../components/sessionstore/test/browser_526613.js | 86 + .../components/sessionstore/test/browser_528776.js | 27 + .../components/sessionstore/test/browser_579868.js | 31 + .../components/sessionstore/test/browser_579879.js | 31 + .../components/sessionstore/test/browser_580512.js | 117 + .../components/sessionstore/test/browser_581937.js | 22 + .../sessionstore/test/browser_586068-apptabs.js | 109 + .../test/browser_586068-apptabs_ondemand.js | 105 + .../browser_586068-browser_state_interrupted.js | 212 + .../sessionstore/test/browser_586068-cascade.js | 107 + .../test/browser_586068-multi_window.js | 115 + .../sessionstore/test/browser_586068-reload.js | 118 + .../sessionstore/test/browser_586068-select.js | 128 + .../test/browser_586068-window_state.js | 120 + .../test/browser_586068-window_state_override.js | 118 + .../components/sessionstore/test/browser_586147.js | 52 + .../components/sessionstore/test/browser_588426.js | 62 + .../components/sessionstore/test/browser_589246.js | 286 + .../components/sessionstore/test/browser_590268.js | 159 + .../components/sessionstore/test/browser_590563.js | 120 + .../test/browser_595601-restore_hidden.js | 165 + .../components/sessionstore/test/browser_597071.js | 36 + .../components/sessionstore/test/browser_600545.js | 123 + .../components/sessionstore/test/browser_601955.js | 54 + .../components/sessionstore/test/browser_607016.js | 155 + ...ser_615394-SSWindowState_events_duplicateTab.js | 69 + ..._615394-SSWindowState_events_setBrowserState.js | 152 + ...wser_615394-SSWindowState_events_setTabState.js | 61 + ...r_615394-SSWindowState_events_setWindowState.js | 65 + ...ser_615394-SSWindowState_events_undoCloseTab.js | 65 + ..._615394-SSWindowState_events_undoCloseWindow.js | 146 + .../components/sessionstore/test/browser_618151.js | 67 + .../components/sessionstore/test/browser_623779.js | 13 + .../components/sessionstore/test/browser_624727.js | 33 + .../components/sessionstore/test/browser_625016.js | 103 + .../components/sessionstore/test/browser_628270.js | 43 + .../components/sessionstore/test/browser_635418.js | 58 + .../components/sessionstore/test/browser_636279.js | 144 + .../components/sessionstore/test/browser_637020.js | 74 + .../sessionstore/test/browser_637020_slow.sjs | 22 + .../components/sessionstore/test/browser_645428.js | 22 + .../components/sessionstore/test/browser_659591.js | 39 + .../components/sessionstore/test/browser_662743.js | 138 + .../sessionstore/test/browser_662743_sample.html | 15 + .../components/sessionstore/test/browser_662812.js | 43 + .../test/browser_665702-state_session.js | 26 + .../components/sessionstore/test/browser_682507.js | 22 + .../components/sessionstore/test/browser_687710.js | 58 + .../sessionstore/test/browser_687710_2.js | 100 + .../components/sessionstore/test/browser_694378.js | 41 + .../components/sessionstore/test/browser_701377.js | 56 + .../components/sessionstore/test/browser_705597.js | 85 + .../components/sessionstore/test/browser_707862.js | 95 + .../components/sessionstore/test/browser_739531.js | 55 + .../sessionstore/test/browser_739531_frame.html | 1 + .../sessionstore/test/browser_739531_sample.html | 23 + .../components/sessionstore/test/browser_739805.js | 49 + .../test/browser_819510_perwindowpb.js | 152 + .../sessionstore/test/browser_906076_lazy_tabs.js | 163 + .../components/sessionstore/test/browser_911547.js | 82 + .../sessionstore/test/browser_911547_sample.html | 18 + .../test/browser_911547_sample.html^headers^ | 1 + .../test/browser_aboutPrivateBrowsing.js | 24 + .../test/browser_aboutSessionRestore.js | 67 + .../test/browser_async_duplicate_tab.js | 87 + .../sessionstore/test/browser_async_flushes.js | 131 + .../sessionstore/test/browser_async_remove_tab.js | 209 + .../test/browser_async_window_flushing.js | 208 + .../sessionstore/test/browser_attributes.js | 83 + .../test/browser_background_tab_crash.js | 262 + .../sessionstore/test/browser_backup_recovery.js | 308 + .../sessionstore/test/browser_bfcache_telemetry.js | 45 + .../sessionstore/test/browser_broadcast.js | 162 + .../sessionstore/test/browser_capabilities.js | 90 + .../sessionstore/test/browser_cleaner.js | 214 + .../sessionstore/test/browser_closedId.js | 109 + ...er_closed_objects_changed_notifications_tabs.js | 138 + ...closed_objects_changed_notifications_windows.js | 131 + .../test/browser_closed_tabs_closed_windows.js | 319 + .../test/browser_closed_tabs_windows.js | 337 + .../sessionstore/test/browser_cookies.js | 81 + .../sessionstore/test/browser_cookies_legacy.js | 76 + .../sessionstore/test/browser_cookies_privacy.js | 125 + .../sessionstore/test/browser_cookies_sameSite.js | 89 + .../sessionstore/test/browser_crashedTabs.js | 503 + .../test/browser_docshell_uuid_consistency.js | 104 + .../sessionstore/test/browser_duplicate_history.js | 30 + .../test/browser_duplicate_tab_in_new_window.js | 37 + .../sessionstore/test/browser_dying_cache.js | 84 + .../sessionstore/test/browser_dynamic_frames.js | 105 + .../test/browser_firefoxView_restore.js | 39 + .../test/browser_firefoxView_selected_restore.js | 88 + .../test/browser_focus_after_restore.js | 34 + .../test/browser_forget_async_closings.js | 163 + .../test/browser_forget_closed_tab_window_byId.js | 148 + .../sessionstore/test/browser_formdata.js | 227 + .../sessionstore/test/browser_formdata_cc.js | 107 + .../sessionstore/test/browser_formdata_face.js | 168 + .../sessionstore/test/browser_formdata_format.js | 170 + .../test/browser_formdata_format_sample.html | 7 + .../sessionstore/test/browser_formdata_max_size.js | 131 + .../sessionstore/test/browser_formdata_password.js | 69 + .../sessionstore/test/browser_formdata_sample.html | 20 + .../sessionstore/test/browser_formdata_xpath.js | 242 + .../test/browser_formdata_xpath_sample.html | 37 + .../sessionstore/test/browser_frame_history.js | 230 + .../sessionstore/test/browser_frame_history_a.html | 5 + .../sessionstore/test/browser_frame_history_b.html | 10 + .../sessionstore/test/browser_frame_history_c.html | 5 + .../test/browser_frame_history_c1.html | 5 + .../test/browser_frame_history_c2.html | 5 + .../test/browser_frame_history_index.html | 9 + .../test/browser_frame_history_index2.html | 3 + .../test/browser_frame_history_index_blank.html | 4 + .../sessionstore/test/browser_frametree.js | 134 + .../test/browser_frametree_sample.html | 8 + .../test/browser_frametree_sample_frameset.html | 11 + .../test/browser_frametree_sample_iframes.html | 9 + .../sessionstore/test/browser_global_store.js | 53 + .../sessionstore/test/browser_history_persist.js | 163 + .../test/browser_ignore_updates_crashed_tabs.js | 108 + .../sessionstore/test/browser_label_and_icon.js | 53 + .../test/browser_movePendingTabToNewWindow.js | 124 + .../test/browser_multiple_navigateAndRestore.js | 45 + .../test/browser_multiple_select_after_load.js | 54 + .../test/browser_newtab_userTypedValue.js | 92 + .../test/browser_not_collect_when_idle.js | 118 + .../sessionstore/test/browser_old_favicon.js | 57 + .../sessionstore/test/browser_page_title.js | 54 + .../test/browser_parentProcessRestoreHash.js | 115 + .../sessionstore/test/browser_pending_tabs.js | 38 + .../sessionstore/test/browser_pinned_tabs.js | 324 + .../sessionstore/test/browser_privatetabs.js | 60 + .../sessionstore/test/browser_purge_shistory.js | 65 + .../test/browser_remoteness_flip_on_restore.js | 310 + .../test/browser_reopen_all_windows.js | 146 + .../sessionstore/test/browser_replace_load.js | 56 + .../test/browser_restoreLastActionCorrectOrder.js | 101 + ...rowser_restoreLastClosedTabOrWindowOrSession.js | 284 + .../test/browser_restoreTabContainer.js | 81 + .../test/browser_restore_container_tabs_oa.js | 249 + .../browser_restore_cookies_noOriginAttributes.js | 191 + .../test/browser_restore_pageProxyState.js | 77 + .../test/browser_restore_private_tab_os.js | 59 + .../sessionstore/test/browser_restore_redirect.js | 72 + .../test/browser_restore_reversed_z_order.js | 128 + .../sessionstore/test/browser_restore_srcdoc.js | 44 + .../test/browser_restore_tabless_window.js | 56 + .../test/browser_restored_window_features.js | 167 + .../test/browser_revive_crashed_bg_tabs.js | 59 + .../sessionstore/test/browser_scrollPositions.js | 258 + .../test/browser_scrollPositionsReaderMode.js | 76 + .../browser_scrollPositions_readerModeArticle.html | 26 + .../test/browser_scrollPositions_sample.html | 8 + .../test/browser_scrollPositions_sample2.html | 8 + .../browser_scrollPositions_sample_frameset.html | 11 + .../test/browser_send_async_message_oom.js | 75 + .../sessionstore/test/browser_sessionHistory.js | 331 + .../test/browser_sessionHistory_slow.sjs | 22 + .../sessionstore/test/browser_sessionStorage.html | 28 + .../sessionstore/test/browser_sessionStorage.js | 298 + .../test/browser_sessionStorage_size.js | 34 + .../test/browser_sessionStoreContainer.js | 166 + .../test/browser_should_restore_tab.js | 139 + .../test/browser_sizemodeBeforeMinimized.js | 44 + .../test/browser_speculative_connect.html | 8 + .../test/browser_speculative_connect.js | 145 + .../sessionstore/test/browser_swapDocShells.js | 40 + .../sessionstore/test/browser_switch_remoteness.js | 57 + .../test/browser_tab_label_during_restore.js | 182 + .../test/browser_tabicon_after_bg_tab_crash.js | 52 + .../sessionstore/test/browser_tabs_in_urlbar.js | 151 + .../sessionstore/test/browser_undoCloseById.js | 185 + .../test/browser_undoCloseById_targetWindow.js | 93 + .../test/browser_unrestored_crashedTabs.js | 73 + .../sessionstore/test/browser_upgrade_backup.js | 158 + .../sessionstore/test/browser_urlbarSearchMode.js | 57 + .../browser_userTyped_restored_after_discard.js | 47 + .../test/browser_windowRestore_perwindowpb.js | 32 + .../test/browser_windowStateContainer.js | 176 + .../sessionstore/test/coopHeaderCommon.sjs | 32 + .../components/sessionstore/test/coop_coep.html | 6 + .../sessionstore/test/coop_coep.html^headers^ | 2 + browser/components/sessionstore/test/empty.html | 6 + .../test/file_async_duplicate_tab.html | 1 + .../sessionstore/test/file_async_flushes.html | 1 + .../sessionstore/test/file_formdata_password.html | 17 + .../test/file_sessionHistory_hashchange.html | 1 + browser/components/sessionstore/test/head.js | 690 + .../sessionstore/test/marionette/manifest.toml | 19 + .../test/marionette/session_store_test_case.py | 432 + .../test_persist_closed_tabs_restore_manually.py | 225 + .../test/marionette/test_restore_loading_tab.py | 69 + .../test/marionette/test_restore_manually.py | 144 + .../test_restore_manually_with_pinned_tabs.py | 108 + .../test_restore_windows_after_close_last_tabs.py | 59 + .../test_restore_windows_after_restart_and_quit.py | 88 + .../test_restore_windows_after_windows_shutdown.py | 66 + .../sessionstore/test/restore_redirect_http.html | 0 .../test/restore_redirect_http.html^headers^ | 2 + .../sessionstore/test/restore_redirect_js.html | 10 + .../sessionstore/test/restore_redirect_target.html | 8 + .../test/unit/data/sessionCheckpoints_all.json | 11 + .../test/unit/data/sessionstore_invalid.js | 3 + .../test/unit/data/sessionstore_valid.js | 3 + browser/components/sessionstore/test/unit/head.js | 36 + .../sessionstore/test/unit/test_backup_once.js | 137 + .../test/unit/test_final_write_cleanup.js | 116 + .../test/unit/test_histogram_corrupt_files.js | 117 + .../test/unit/test_migration_lz4compression.js | 151 + .../test/unit/test_startup_invalid_session.js | 27 + .../test/unit/test_startup_nosession_async.js | 19 + .../test/unit/test_startup_session_async.js | 32 + .../sessionstore/test/unit/xpcshell.toml | 28 + browser/components/sessionstore/triage.json | 68 + browser/components/shell/HeadlessShell.sys.mjs | 262 + browser/components/shell/ScreenshotChild.sys.mjs | 31 + browser/components/shell/ShellService.sys.mjs | 499 + browser/components/shell/WindowsDefaultBrowser.cpp | 205 + browser/components/shell/WindowsDefaultBrowser.h | 20 + browser/components/shell/WindowsUserChoice.cpp | 474 + browser/components/shell/WindowsUserChoice.h | 118 + .../shell/content/setDesktopBackground.js | 249 + .../shell/content/setDesktopBackground.xhtml | 91 + browser/components/shell/jar.mn | 7 + browser/components/shell/moz.build | 89 + .../components/shell/nsGNOMEShellDBusHelper.cpp | 397 + browser/components/shell/nsGNOMEShellDBusHelper.h | 35 + .../shell/nsGNOMEShellSearchProvider.cpp | 508 + .../components/shell/nsGNOMEShellSearchProvider.h | 153 + browser/components/shell/nsGNOMEShellService.cpp | 502 + browser/components/shell/nsGNOMEShellService.h | 46 + browser/components/shell/nsIGNOMEShellService.idl | 23 + browser/components/shell/nsIMacShellService.idl | 42 + browser/components/shell/nsIShellService.idl | 66 + .../components/shell/nsIWindowsShellService.idl | 288 + browser/components/shell/nsMacShellService.cpp | 346 + browser/components/shell/nsMacShellService.h | 33 + browser/components/shell/nsShellService.h | 11 + browser/components/shell/nsToolkitShellService.h | 21 + browser/components/shell/nsWindowsShellService.cpp | 1954 + browser/components/shell/nsWindowsShellService.h | 35 + .../components/shell/search-provider-files/README | 24 + .../shell/search-provider-files/firefox.desktop | 274 + .../org.mozilla.firefox.SearchProvider.service | 3 + .../org.mozilla.firefox.search-provider.ini | 5 + browser/components/shell/test/browser.toml | 95 + browser/components/shell/test/browser_1119088.js | 167 + browser/components/shell/test/browser_420786.js | 105 + browser/components/shell/test/browser_633221.js | 11 + .../shell/test/browser_doesAppNeedPin.js | 54 + .../shell/test/browser_headless_screenshot_1.js | 74 + .../shell/test/browser_headless_screenshot_2.js | 48 + .../shell/test/browser_headless_screenshot_3.js | 59 + .../shell/test/browser_headless_screenshot_4.js | 31 + .../browser_headless_screenshot_cross_origin.js | 9 + .../test/browser_headless_screenshot_redirect.js | 14 + browser/components/shell/test/browser_pinning.js | 72 + .../shell/test/browser_setDefaultBrowser.js | 142 + .../shell/test/browser_setDefaultPDFHandler.js | 269 + .../test/browser_setDesktopBackgroundPreview.js | 87 + browser/components/shell/test/head.js | 159 + browser/components/shell/test/headless.html | 6 + .../shell/test/headless_cross_origin.html | 7 + browser/components/shell/test/headless_iframe.html | 6 + .../components/shell/test/headless_redirect.html | 0 .../shell/test/headless_redirect.html^headers^ | 2 + browser/components/shell/test/mac_desktop_image.py | 168 + .../unit/test_macOS_showSecurityPreferences.js | 30 + browser/components/shell/test/unit/xpcshell.toml | 6 + .../shopping/ShoppingSidebarChild.sys.mjs | 517 + .../shopping/ShoppingSidebarParent.sys.mjs | 430 + browser/components/shopping/ShoppingUtils.sys.mjs | 308 + .../shopping/content/adjusted-rating.mjs | 53 + .../shopping/content/analysis-explainer.css | 39 + .../shopping/content/analysis-explainer.mjs | 158 + .../shopping/content/assets/competitiveness.svg | 6 + .../shopping/content/assets/optInDark.avif | Bin 0 -> 9746 bytes .../shopping/content/assets/optInLight.avif | Bin 0 -> 9651 bytes .../shopping/content/assets/packaging.svg | 6 + .../components/shopping/content/assets/price.svg | 7 + .../content/assets/priceTagButtonCallout.svg | 41 + .../components/shopping/content/assets/quality.svg | 7 + .../shopping/content/assets/ratingDark.avif | Bin 0 -> 14230 bytes .../shopping/content/assets/ratingLight.avif | Bin 0 -> 14071 bytes .../content/assets/reviewsVisualCallout.svg | 77 + .../shopping/content/assets/shipping.svg | 6 + .../shopping/content/assets/shopping.svg | 6 + .../shopping/content/assets/unanalyzedDark.avif | Bin 0 -> 11485 bytes .../shopping/content/assets/unanalyzedLight.avif | Bin 0 -> 11070 bytes .../components/shopping/content/highlight-item.css | 66 + .../components/shopping/content/highlight-item.mjs | 57 + browser/components/shopping/content/highlights.mjs | 124 + .../components/shopping/content/letter-grade.css | 132 + .../components/shopping/content/letter-grade.mjs | 78 + browser/components/shopping/content/onboarding.mjs | 69 + .../components/shopping/content/recommended-ad.css | 59 + .../components/shopping/content/recommended-ad.mjs | 150 + .../components/shopping/content/reliability.mjs | 48 + browser/components/shopping/content/settings.css | 69 + browser/components/shopping/content/settings.mjs | 210 + .../components/shopping/content/shopping-card.css | 201 + .../components/shopping/content/shopping-card.mjs | 204 + .../shopping/content/shopping-container.css | 152 + .../shopping/content/shopping-container.mjs | 471 + .../shopping/content/shopping-message-bar.css | 78 + .../shopping/content/shopping-message-bar.mjs | 278 + .../components/shopping/content/shopping-page.css | 28 + .../shopping/content/shopping-sidebar.js | 80 + browser/components/shopping/content/shopping.ftl | 7 + browser/components/shopping/content/shopping.html | 53 + browser/components/shopping/content/unanalyzed.css | 41 + browser/components/shopping/content/unanalyzed.mjs | 61 + browser/components/shopping/jar.mn | 31 + browser/components/shopping/metrics.yaml | 738 + browser/components/shopping/moz.build | 21 + .../components/shopping/tests/browser/browser.toml | 79 + .../tests/browser/browser_adjusted_rating.js | 115 + .../browser/browser_ads_exposure_telemetry.js | 213 + .../tests/browser/browser_analysis_explainer.js | 43 + .../shopping/tests/browser/browser_auto_open.js | 90 + .../tests/browser/browser_exposure_telemetry.js | 123 + .../tests/browser/browser_inprogress_analysis.js | 151 + .../browser/browser_keep_close_message_bar.js | 530 + .../tests/browser/browser_network_offline.js | 33 + .../tests/browser/browser_not_enough_reviews.js | 80 + .../tests/browser/browser_page_not_supported.js | 36 + .../shopping/tests/browser/browser_private_mode.js | 35 + .../tests/browser/browser_recommended_ad_test.js | 71 + .../tests/browser/browser_review_highlights.js | 194 + .../tests/browser/browser_settings_telemetry.js | 102 + .../tests/browser/browser_shopping_card.js | 50 + .../tests/browser/browser_shopping_container.js | 41 + .../browser/browser_shopping_message_triggers.js | 315 + .../tests/browser/browser_shopping_onboarding.js | 661 + .../tests/browser/browser_shopping_settings.js | 642 + .../tests/browser/browser_shopping_sidebar.js | 66 + .../tests/browser/browser_shopping_survey.js | 337 + .../tests/browser/browser_shopping_urlbar.js | 427 + .../tests/browser/browser_stale_product.js | 36 + .../shopping/tests/browser/browser_ui_telemetry.js | 762 + .../tests/browser/browser_unanalyzed_product.js | 86 + .../tests/browser/browser_unavailable_product.js | 102 + browser/components/shopping/tests/browser/head.js | 225 + .../addon-component-status/StatusIndicator.mjs | 114 + .../addon-component-status/constants.mjs | 7 + .../.storybook/addon-component-status/index.js | 23 + .../addon-component-status/preset/manager.mjs | 19 + .../addon-component-status/preset/preview.mjs | 12 + .../.storybook/addon-fluent/FluentPanel.mjs | 121 + .../addon-fluent/PseudoLocalizationButton.mjs | 55 + .../.storybook/addon-fluent/constants.mjs | 32 + .../.storybook/addon-fluent/fluent-panel.css | 83 + .../storybook/.storybook/addon-fluent/index.js | 23 + .../.storybook/addon-fluent/preset/manager.mjs | 34 + .../.storybook/addon-fluent/preset/preview.mjs | 27 + .../addon-fluent/withPseudoLocalization.mjs | 89 + .../storybook/.storybook/chrome-styles-loader.js | 145 + .../storybook/.storybook/chrome-uri-utils.js | 30 + .../storybook/.storybook/fluent-utils.mjs | 122 + .../storybook/.storybook/l10n-pseudo.mjs | 110 + browser/components/storybook/.storybook/main.js | 153 + .../storybook/.storybook/markdown-story-loader.js | 149 + .../storybook/.storybook/preview-head.html | 208 + .../components/storybook/.storybook/preview.mjs | 108 + .../storybook/custom-elements-manifest.config.mjs | 46 + .../storybook/docs/README.lit-guide.stories.md | 157 + .../storybook/docs/README.other-widgets.stories.md | 84 + .../docs/README.reusable-widgets.stories.md | 152 + .../storybook/docs/README.storybook.stories.md | 148 + .../storybook/docs/README.typography.stories.md | 389 + .../storybook/docs/README.xul-and-html.stories.md | 67 + browser/components/storybook/mach_commands.py | 108 + browser/components/storybook/moz.build | 8 + browser/components/storybook/package-lock.json | 38503 +++++++++++++++++++ browser/components/storybook/package.json | 28 + .../storybook/stories/button.stories.mjs | 100 + .../stories/fxview-category-navigation.stories.mjs | 113 + .../storybook/stories/fxview-tab-list.stories.md | 103 + .../storybook/stories/fxview-tab-list.stories.mjs | 213 + .../storybook/stories/letter-grade.stories.mjs | 55 + .../stories/login-command-button.stories.mjs | 90 + .../storybook/stories/login-timeline.stories.mjs | 45 + .../storybook/stories/migration-wizard.stories.mjs | 553 + .../storybook/stories/named-deck.stories.mjs | 165 + .../storybook/stories/shopping-card.stories.mjs | 91 + .../stories/shopping-container.stories.mjs | 301 + .../stories/shopping-message-bar.stories.mjs | 54 + browser/components/syncedtabs/EventEmitter.sys.mjs | 36 + .../syncedtabs/SyncedTabsDeckComponent.sys.mjs | 173 + .../syncedtabs/SyncedTabsDeckStore.sys.mjs | 54 + .../syncedtabs/SyncedTabsDeckView.sys.mjs | 90 + .../syncedtabs/SyncedTabsListStore.sys.mjs | 253 + .../components/syncedtabs/TabListComponent.sys.mjs | 147 + browser/components/syncedtabs/TabListView.sys.mjs | 657 + browser/components/syncedtabs/jar.mn | 7 + browser/components/syncedtabs/moz.build | 23 + browser/components/syncedtabs/sidebar.js | 41 + browser/components/syncedtabs/sidebar.xhtml | 157 + .../syncedtabs/test/browser/browser.toml | 4 + .../test/browser/browser_sidebar_syncedtabslist.js | 646 + browser/components/syncedtabs/test/browser/head.js | 3 + .../components/syncedtabs/test/xpcshell/head.js | 8 + .../syncedtabs/test/xpcshell/test_EventEmitter.js | 36 + .../test/xpcshell/test_SyncedTabsDeckComponent.js | 263 + .../test/xpcshell/test_SyncedTabsDeckStore.js | 69 + .../test/xpcshell/test_SyncedTabsListStore.js | 289 + .../test/xpcshell/test_TabListComponent.js | 190 + .../syncedtabs/test/xpcshell/xpcshell.toml | 14 + browser/components/syncedtabs/util.sys.mjs | 8 + browser/components/tabpreview/jar.mn | 7 + browser/components/tabpreview/moz.build | 10 + browser/components/tabpreview/tabpreview.css | 63 + browser/components/tabpreview/tabpreview.mjs | 249 + .../tabunloader/content/aboutUnloads.css | 25 + .../tabunloader/content/aboutUnloads.html | 74 + .../components/tabunloader/content/aboutUnloads.js | 126 + browser/components/tabunloader/docs/fullmode.png | Bin 0 -> 70005 bytes browser/components/tabunloader/docs/index.rst | 58 + browser/components/tabunloader/docs/lightmode.png | Bin 0 -> 52756 bytes browser/components/tabunloader/jar.mn | 8 + browser/components/tabunloader/moz.build | 12 + browser/components/tests/browser/browser.toml | 40 + .../browser_browserGlue_showModal_trigger.js | 47 + .../tests/browser/browser_browserGlue_telemetry.js | 114 + .../browser_browserGlue_upgradeDialog_trigger.js | 204 + .../components/tests/browser/browser_bug538331.js | 228 + .../browser/browser_contentpermissionprompt.js | 175 + .../browser_default_bookmark_toolbar_visibility.js | 89 + .../browser/browser_default_browser_prompt.js | 125 + .../browser/browser_initial_tab_remoteType.js | 156 + .../tests/browser/browser_quit_disabled.js | 62 + .../tests/browser/browser_quit_multiple_tabs.js | 110 + .../tests/browser/browser_quit_shortcut_warning.js | 54 + .../tests/browser/browser_startup_homepage.js | 121 + .../browser_system_notification_telemetry.js | 54 + .../tests/browser/browser_to_handle_telemetry.js | 199 + browser/components/tests/browser/head.js | 90 + .../tests/browser/whats_new_page/active-update.xml | 1 + .../tests/browser/whats_new_page/browser.toml | 21 + .../whats_new_page/browser_whats_new_page.js | 108 + .../config_localhost_update_url.json | 5 + .../browser/whats_new_page/updates/0/update.status | 1 + browser/components/tests/marionette/manifest.toml | 4 + .../marionette/test_no_errors_clean_profile.py | 186 + browser/components/tests/unit/distribution.ini | 60 + browser/components/tests/unit/head.js | 8 + ...erGlue_migration_ctrltab_recently_used_order.js | 111 + .../test_browserGlue_migration_formautofill.js | 142 + .../unit/test_browserGlue_migration_no_errors.js | 34 + .../test_browserGlue_migration_places_xulstore.js | 54 + .../unit/test_browserGlue_migration_remove_pref.js | 26 + .../test_browserGlue_migration_resetDefaults.js | 109 + browser/components/tests/unit/test_distribution.js | 202 + .../unit/test_distribution_cachedexistence.js | 131 + browser/components/tests/unit/xpcshell.toml | 22 + browser/components/textrecognition/jar.mn | 8 + browser/components/textrecognition/moz.build | 14 + .../textrecognition/tests/browser/browser.toml | 12 + .../tests/browser/browser_textrecognition.js | 140 + .../browser/browser_textrecognition_no_result.js | 99 + .../textrecognition/tests/browser/head.js | 59 + .../textrecognition/tests/browser/image.png | Bin 0 -> 7061 bytes .../components/textrecognition/textrecognition.css | 143 + .../textrecognition/textrecognition.html | 42 + .../components/textrecognition/textrecognition.mjs | 458 + browser/components/touchbar/MacTouchBar.sys.mjs | 671 + browser/components/touchbar/components.conf | 20 + browser/components/touchbar/docs/index.rst | 361 + browser/components/touchbar/moz.build | 18 + .../components/touchbar/tests/browser/browser.toml | 16 + .../browser/browser_touchbar_searchrestrictions.js | 155 + .../tests/browser/browser_touchbar_tests.js | 159 + .../touchbar/tests/browser/readerModeArticle.html | 29 + .../touchbar/tests/browser/test-video.mp4 | Bin 0 -> 36502 bytes .../touchbar/tests/browser/video_test.html | 32 + .../content/translationsPanel.inc.xhtml | 145 + .../translations/content/translationsPanel.js | 1630 + browser/components/translations/jar.mn | 6 + browser/components/translations/moz.build | 10 + .../translations/tests/browser/browser.toml | 110 + ...bout_preferences_manage_downloaded_languages.js | 225 + ...ferences_settings_always_translate_languages.js | 97 + ...eferences_settings_never_translate_languages.js | 89 + ...t_preferences_settings_never_translate_sites.js | 124 + ...r_translations_about_preferences_settings_ui.js | 87 + .../browser_translations_panel_a11y_focus.js | 32 + ...ons_panel_always_translate_language_bad_data.js | 40 + ...ations_panel_always_translate_language_basic.js | 79 + ...tions_panel_always_translate_language_manual.js | 80 + ...ions_panel_always_translate_language_restore.js | 101 + ...ions_panel_app_menu_never_translate_language.js | 40 + ...slations_panel_app_menu_never_translate_site.js | 140 + ...translations_panel_auto_translate_error_view.js | 86 + ...anslations_panel_auto_translate_revisit_view.js | 86 + .../browser/browser_translations_panel_basics.js | 69 + .../browser/browser_translations_panel_button.js | 77 + .../browser/browser_translations_panel_cancel.js | 27 + ..._translate_language_with_translations_active.js | 147 + ...ranslate_language_with_translations_inactive.js | 93 + ...tions_panel_close_panel_never_translate_site.js | 159 + .../browser_translations_panel_engine_destroy.js | 55 + ...er_translations_panel_engine_destroy_pending.js | 72 + ...rowser_translations_panel_engine_unsupported.js | 59 + ...r_translations_panel_engine_unsupported_lang.js | 28 + .../browser/browser_translations_panel_firstrun.js | 33 + .../browser_translations_panel_firstrun_revisit.js | 57 + .../browser/browser_translations_panel_fuzzing.js | 240 + .../browser/browser_translations_panel_gear.js | 32 + ..._translations_panel_never_translate_language.js | 186 + ...wser_translations_panel_never_translate_site.js | 247 + ...translations_panel_never_translate_site_auto.js | 109 + ...ranslations_panel_never_translate_site_basic.js | 62 + ...anslations_panel_never_translate_site_manual.js | 84 + .../browser/browser_translations_panel_retry.js | 54 + ...translations_panel_settings_unsupported_lang.js | 74 + .../browser_translations_panel_switch_languages.js | 70 + .../browser/browser_translations_reader_mode.js | 115 + ...lations_select_context_menu_feature_disabled.js | 114 + ...text_menu_with_full_page_translations_active.js | 162 + ...nslations_select_context_menu_with_hyperlink.js | 109 + ...ns_select_context_menu_with_no_text_selected.js | 37 + ...tions_select_context_menu_with_text_selected.js | 76 + ...anslations_telemetry_firstrun_auto_translate.js | 117 + ...owser_translations_telemetry_firstrun_basics.js | 93 + ...tions_telemetry_firstrun_translation_failure.js | 149 + ...slations_telemetry_firstrun_unsupported_lang.js | 122 + .../browser_translations_telemetry_open_panel.js | 85 + ...wser_translations_telemetry_panel_auto_offer.js | 83 + ...slations_telemetry_panel_auto_offer_settings.js | 110 + ...wser_translations_telemetry_switch_languages.js | 156 + ...r_translations_telemetry_translation_failure.js | 212 + ...r_translations_telemetry_translation_request.js | 170 + .../components/translations/tests/browser/head.js | 1423 + browser/components/uitour/UITour-lib.js | 841 + browser/components/uitour/UITour.sys.mjs | 2043 + browser/components/uitour/UITourChild.sys.mjs | 112 + browser/components/uitour/UITourParent.sys.mjs | 18 + browser/components/uitour/docs/UITour-lib.rst | 11 + browser/components/uitour/docs/index.rst | 8 + browser/components/uitour/moz.build | 14 + browser/components/uitour/test/browser.toml | 79 + browser/components/uitour/test/browser_UITour.js | 757 + browser/components/uitour/test/browser_UITour2.js | 150 + browser/components/uitour/test/browser_UITour3.js | 317 + browser/components/uitour/test/browser_UITour4.js | 235 + browser/components/uitour/test/browser_UITour5.js | 60 + .../browser_UITour_annotation_size_attributes.js | 65 + .../uitour/test/browser_UITour_availableTargets.js | 129 + .../uitour/test/browser_UITour_colorway.js | 74 + .../uitour/test/browser_UITour_defaultBrowser.js | 66 + .../uitour/test/browser_UITour_detach_tab.js | 113 + .../uitour/test/browser_UITour_forceReaderMode.js | 24 + .../uitour/test/browser_UITour_modalDialog.js | 116 + .../uitour/test/browser_UITour_observe.js | 99 + .../test/browser_UITour_panel_close_annotation.js | 227 + .../uitour/test/browser_UITour_pocket.js | 38 + .../uitour/test/browser_UITour_private_browsing.js | 38 + .../uitour/test/browser_UITour_resetProfile.js | 46 + .../uitour/test/browser_UITour_showNewTab.js | 25 + .../test/browser_UITour_showProtectionReport.js | 47 + .../components/uitour/test/browser_UITour_sync.js | 231 + .../uitour/test/browser_UITour_toggleReaderMode.js | 21 + .../uitour/test/browser_backgroundTab.js | 57 + browser/components/uitour/test/browser_closeTab.js | 23 + browser/components/uitour/test/browser_fxa.js | 61 + .../components/uitour/test/browser_fxa_config.js | 379 + .../uitour/test/browser_openPreferences.js | 73 + .../uitour/test/browser_openSearchPanel.js | 34 + browser/components/uitour/test/head.js | 539 + browser/components/uitour/test/image.png | Bin 0 -> 56060 bytes browser/components/uitour/test/uitour.html | 42 + browser/components/urlbar/.eslintrc.js | 14 + browser/components/urlbar/MerinoClient.sys.mjs | 397 + .../urlbar/QuickActionsLoaderDefault.sys.mjs | 331 + browser/components/urlbar/QuickSuggest.sys.mjs | 550 + browser/components/urlbar/UrlbarController.sys.mjs | 1373 + .../components/urlbar/UrlbarEventBufferer.sys.mjs | 374 + browser/components/urlbar/UrlbarInput.sys.mjs | 4455 +++ .../urlbar/UrlbarMuxerUnifiedComplete.sys.mjs | 1420 + browser/components/urlbar/UrlbarPrefs.sys.mjs | 1666 + .../urlbar/UrlbarProviderAboutPages.sys.mjs | 82 + .../urlbar/UrlbarProviderAliasEngines.sys.mjs | 94 + .../urlbar/UrlbarProviderAutofill.sys.mjs | 1011 + .../urlbar/UrlbarProviderBookmarkKeywords.sys.mjs | 117 + .../urlbar/UrlbarProviderCalculator.sys.mjs | 465 + .../urlbar/UrlbarProviderClipboard.sys.mjs | 182 + .../urlbar/UrlbarProviderContextualSearch.sys.mjs | 278 + .../urlbar/UrlbarProviderHeuristicFallback.sys.mjs | 328 + .../UrlbarProviderHistoryUrlHeuristic.sys.mjs | 139 + .../urlbar/UrlbarProviderInputHistory.sys.mjs | 267 + .../urlbar/UrlbarProviderInterventions.sys.mjs | 827 + .../urlbar/UrlbarProviderOmnibox.sys.mjs | 196 + .../urlbar/UrlbarProviderOpenTabs.sys.mjs | 327 + .../components/urlbar/UrlbarProviderPlaces.sys.mjs | 1585 + .../urlbar/UrlbarProviderPrivateSearch.sys.mjs | 133 + .../urlbar/UrlbarProviderQuickActions.sys.mjs | 359 + .../urlbar/UrlbarProviderQuickSuggest.sys.mjs | 954 + ...lbarProviderQuickSuggestContextualOptIn.sys.mjs | 286 + .../urlbar/UrlbarProviderRecentSearches.sys.mjs | 148 + .../urlbar/UrlbarProviderRemoteTabs.sys.mjs | 256 + .../urlbar/UrlbarProviderSearchSuggestions.sys.mjs | 664 + .../urlbar/UrlbarProviderSearchTips.sys.mjs | 600 + .../urlbar/UrlbarProviderTabToSearch.sys.mjs | 475 + .../urlbar/UrlbarProviderTokenAliasEngines.sys.mjs | 234 + .../urlbar/UrlbarProviderTopSites.sys.mjs | 354 + .../urlbar/UrlbarProviderUnitConversion.sys.mjs | 183 + .../urlbar/UrlbarProviderWeather.sys.mjs | 313 + .../urlbar/UrlbarProvidersManager.sys.mjs | 763 + browser/components/urlbar/UrlbarResult.sys.mjs | 368 + .../components/urlbar/UrlbarSearchOneOffs.sys.mjs | 566 + .../components/urlbar/UrlbarSearchUtils.sys.mjs | 449 + browser/components/urlbar/UrlbarTokenizer.sys.mjs | 445 + browser/components/urlbar/UrlbarUtils.sys.mjs | 3039 ++ .../components/urlbar/UrlbarValueFormatter.sys.mjs | 522 + browser/components/urlbar/UrlbarView.sys.mjs | 3559 ++ .../urlbar/content/enUS-searchFeatures.ftl | 378 + .../components/urlbar/content/interventions.ftl | 40 + .../urlbar/content/quicksuggestOnboarding.css | 311 + .../urlbar/content/quicksuggestOnboarding.html | 109 + .../urlbar/content/quicksuggestOnboarding.js | 338 + .../content/quicksuggestOnboarding_magglass.svg | 34 + .../quicksuggestOnboarding_magglass_animation.svg | 4 + .../components/urlbar/content/suggest-example.svg | 53 + browser/components/urlbar/docs/.rstcheck.cfg | 13 + .../components/urlbar/docs/UrlbarController.rst | 5 + browser/components/urlbar/docs/UrlbarInput.rst | 5 + browser/components/urlbar/docs/UrlbarView.rst | 5 + .../urlbar/docs/assets/lifetime/lifetime.png | Bin 0 -> 52107 bytes .../docs/assets/nontechnical-overview/autofill.png | Bin 0 -> 23301 bytes .../nontechnical-overview/bookmark-keyword.png | Bin 0 -> 24030 bytes .../docs/assets/nontechnical-overview/bookmark.png | Bin 0 -> 20434 bytes .../nontechnical-overview/empty-placeholder.png | Bin 0 -> 14776 bytes .../assets/nontechnical-overview/empty-url.png | Bin 0 -> 15940 bytes .../assets/nontechnical-overview/form-history.png | Bin 0 -> 10663 bytes .../docs/assets/nontechnical-overview/history.png | Bin 0 -> 24746 bytes .../nontechnical-overview/intervention-clear.png | Bin 0 -> 47041 bytes .../nontechnical-overview/intervention-refresh.png | Bin 0 -> 48747 bytes .../nontechnical-overview/intervention-update.png | Bin 0 -> 46528 bytes .../assets/nontechnical-overview/non-empty.png | Bin 0 -> 10247 bytes .../docs/assets/nontechnical-overview/open-tab.png | Bin 0 -> 17500 bytes .../assets/nontechnical-overview/prefs-privacy.png | Bin 0 -> 62510 bytes .../prefs-show-suggestions.png | Bin 0 -> 207957 bytes .../prefs-suggestions-first.png | Bin 0 -> 212783 bytes .../assets/nontechnical-overview/remote-tab.png | Bin 0 -> 14062 bytes .../nontechnical-overview/search-heuristic.png | Bin 0 -> 23751 bytes .../assets/nontechnical-overview/search-mode.png | Bin 0 -> 123345 bytes .../search-offers-selected.png | Bin 0 -> 71718 bytes .../assets/nontechnical-overview/search-offers.png | Bin 0 -> 73542 bytes .../nontechnical-overview/search-suggestion.png | Bin 0 -> 12248 bytes .../nontechnical-overview/search-tip-onboard.png | Bin 0 -> 43386 bytes .../nontechnical-overview/search-tip-redirect.png | Bin 0 -> 48764 bytes .../tab-to-search-onboard.png | Bin 0 -> 53696 bytes .../tab-to-search-regular.png | Bin 0 -> 40469 bytes .../nontechnical-overview/tail-suggestions.png | Bin 0 -> 36356 bytes .../assets/nontechnical-overview/top-sites.png | Bin 0 -> 86813 bytes .../docs/assets/nontechnical-overview/visit.png | Bin 0 -> 25467 bytes browser/components/urlbar/docs/contact.rst | 9 + browser/components/urlbar/docs/debugging.rst | 4 + .../urlbar/docs/dynamic-result-types.rst | 709 + .../urlbar/docs/firefox-suggest-telemetry.rst | 1384 + browser/components/urlbar/docs/index.rst | 55 + browser/components/urlbar/docs/lifetime.rst | 109 + .../urlbar/docs/nontechnical-overview.rst | 628 + browser/components/urlbar/docs/overview.rst | 405 + browser/components/urlbar/docs/preferences.rst | 254 + browser/components/urlbar/docs/ranking.rst | 229 + browser/components/urlbar/docs/telemetry.rst | 591 + browser/components/urlbar/docs/testing.rst | 216 + browser/components/urlbar/docs/utilities.rst | 25 + browser/components/urlbar/jar.mn | 11 + browser/components/urlbar/metrics.yaml | 942 + browser/components/urlbar/moz.build | 93 + browser/components/urlbar/pings.yaml | 21 + .../urlbar/private/AddonSuggestions.sys.mjs | 279 + .../components/urlbar/private/AdmWikipedia.sys.mjs | 307 + .../components/urlbar/private/BaseFeature.sys.mjs | 224 + .../urlbar/private/BlockedSuggestions.sys.mjs | 187 + .../urlbar/private/ImpressionCaps.sys.mjs | 561 + .../urlbar/private/MDNSuggestions.sys.mjs | 198 + .../urlbar/private/PocketSuggestions.sys.mjs | 314 + .../urlbar/private/SuggestBackendJs.sys.mjs | 443 + .../urlbar/private/SuggestBackendRust.sys.mjs | 407 + browser/components/urlbar/private/Weather.sys.mjs | 896 + .../urlbar/private/YelpSuggestions.sys.mjs | 264 + .../urlbar/tests/UrlbarTestUtils.sys.mjs | 1581 + .../urlbar/tests/browser-tips/README.txt | 7 + .../urlbar/tests/browser-tips/browser.toml | 31 + .../tests/browser-tips/browser_interventions.js | 273 + .../urlbar/tests/browser-tips/browser_picks.js | 200 + .../tests/browser-tips/browser_searchTips.js | 645 + .../browser-tips/browser_searchTips_interaction.js | 691 + .../urlbar/tests/browser-tips/browser_selection.js | 261 + .../urlbar/tests/browser-tips/browser_updateAsk.js | 74 + .../tests/browser-tips/browser_updateRefresh.js | 54 + .../tests/browser-tips/browser_updateRestart.js | 48 + .../urlbar/tests/browser-tips/browser_updateWeb.js | 52 + .../components/urlbar/tests/browser-tips/head.js | 759 + .../urlbar/tests/browser-tips/slow-page.html | 7 + .../browser-tips/suppress-tips/active-update.xml | 1 + .../tests/browser-tips/suppress-tips/browser.toml | 24 + .../suppress-tips/browser_suppressTips.js | 128 + .../suppress-tips/config_localhost_update_url.json | 5 + .../suppress-tips/updates/0/update.status | 1 + .../tests/browser-updateResults/browser.toml | 27 + .../browser_appendSpanCount.js | 183 + .../browser_noUpdateResultsFromOtherProviders.js | 128 + .../browser_suggestedIndex_10_search_10_url.js | 1102 + .../browser_suggestedIndex_10_search_5_url.js | 661 + .../browser_suggestedIndex_10_url_10_search.js | 1165 + .../browser_suggestedIndex_10_url_5_search.js | 707 + .../browser_suggestedIndex_5_search_10_url.js | 1015 + .../browser_suggestedIndex_5_search_5_url.js | 1131 + .../browser_suggestedIndex_5_url_10_search.js | 1057 + .../browser_suggestedIndex_5_url_5_search.js | 1178 + .../urlbar/tests/browser-updateResults/head.js | 552 + .../urlbar/tests/browser/POSTSearchEngine.xml | 6 + .../urlbar/tests/browser/add_search_engine_0.xml | 7 + .../urlbar/tests/browser/add_search_engine_1.xml | 7 + .../urlbar/tests/browser/add_search_engine_2.xml | 7 + .../urlbar/tests/browser/add_search_engine_3.xml | 7 + .../tests/browser/add_search_engine_invalid.html | 11 + .../tests/browser/add_search_engine_many.html | 24 + .../tests/browser/add_search_engine_one.html | 12 + .../browser/add_search_engine_same_names.html | 15 + .../tests/browser/add_search_engine_two.html | 16 + .../urlbar/tests/browser/authenticate.sjs | 218 + .../components/urlbar/tests/browser/browser.toml | 692 + .../browser/browser_UrlbarInput_formatValue.js | 187 + .../browser_UrlbarInput_formatValue_detachedTab.js | 92 + .../browser_UrlbarInput_formatValue_strikeout.js | 62 + .../browser/browser_UrlbarInput_hiddenFocus.js | 21 + .../tests/browser/browser_UrlbarInput_overflow.js | 159 + .../browser/browser_UrlbarInput_overflow_resize.js | 58 + .../browser/browser_UrlbarInput_privateFeature.js | 74 + .../browser/browser_UrlbarInput_searchTerms.js | 275 + ...owser_UrlbarInput_searchTerms_backgroundTabs.js | 63 + .../browser_UrlbarInput_searchTerms_modifiedUrl.js | 104 + .../browser_UrlbarInput_searchTerms_moveTab.js | 136 + .../browser_UrlbarInput_searchTerms_popup.js | 145 + .../browser_UrlbarInput_searchTerms_revert.js | 170 + .../browser_UrlbarInput_searchTerms_searchBar.js | 104 + .../browser_UrlbarInput_searchTerms_searchMode.js | 85 + .../browser_UrlbarInput_searchTerms_strings.js | 79 + ...rowser_UrlbarInput_searchTerms_stringsUnsafe.js | 133 + .../browser_UrlbarInput_searchTerms_switch_tab.js | 139 + .../browser_UrlbarInput_searchTerms_telemetry.js | 378 + .../tests/browser/browser_UrlbarInput_setURI.js | 128 + .../tests/browser/browser_UrlbarInput_tooltip.js | 83 + .../tests/browser/browser_UrlbarInput_trimURLs.js | 150 + .../tests/browser/browser_aboutHomeLoading.js | 228 + .../browser_acknowledgeFeedbackAndDismissal.js | 361 + .../tests/browser/browser_action_searchengine.js | 125 + .../browser/browser_action_searchengine_alias.js | 63 + .../tests/browser/browser_add_search_engine.js | 325 + .../tests/browser/browser_autoFill_backspaced.js | 272 + .../tests/browser/browser_autoFill_canonize.js | 65 + .../browser/browser_autoFill_caretNotAtEnd.js | 34 + ...owser_autoFill_clear_properly_on_accent_char.js | 185 + .../tests/browser/browser_autoFill_firstResult.js | 201 + .../urlbar/tests/browser/browser_autoFill_paste.js | 39 + .../tests/browser/browser_autoFill_placeholder.js | 894 + .../tests/browser/browser_autoFill_preserve.js | 257 + .../tests/browser/browser_autoFill_trimURLs.js | 181 + .../urlbar/tests/browser/browser_autoFill_typed.js | 174 + .../urlbar/tests/browser/browser_autoFill_undo.js | 51 + .../urlbar/tests/browser/browser_autoOpen.js | 93 + .../browser/browser_autocomplete_a11y_label.js | 185 + .../browser/browser_autocomplete_autoselect.js | 122 + .../tests/browser/browser_autocomplete_cursor.js | 37 + .../browser/browser_autocomplete_edit_completed.js | 76 + .../browser/browser_autocomplete_enter_race.js | 198 + .../tests/browser/browser_autocomplete_no_title.js | 34 + .../browser_autocomplete_readline_navigation.js | 71 + .../browser_autocomplete_tag_star_visibility.js | 167 + .../urlbar/tests/browser/browser_bestMatch.js | 193 + .../urlbar/tests/browser/browser_blanking.js | 58 + .../urlbar/tests/browser/browser_blobIcons.js | 133 + .../browser/browser_bufferer_onQueryResults.js | 82 + .../urlbar/tests/browser/browser_calculator.js | 33 + .../urlbar/tests/browser/browser_canonizeURL.js | 284 + .../urlbar/tests/browser/browser_caret_position.js | 362 + .../tests/browser/browser_click_row_border.js | 36 + .../urlbar/tests/browser/browser_clipboard.js | 349 + .../tests/browser/browser_closePanelOnClick.js | 51 + .../urlbar/tests/browser/browser_content_opener.js | 23 + .../tests/browser/browser_contextualsearch.js | 125 + .../browser/browser_copy_and_paste_first_result.js | 46 + .../tests/browser/browser_copy_during_load.js | 51 + .../urlbar/tests/browser/browser_copying.js | 738 + .../urlbar/tests/browser/browser_customizeMode.js | 73 + .../urlbar/tests/browser/browser_cutting.js | 16 + .../urlbar/tests/browser/browser_decode.js | 144 + .../urlbar/tests/browser/browser_delete.js | 51 + .../urlbar/tests/browser/browser_deleteAllText.js | 100 + .../browser_display_selectedAction_Extensions.js | 57 + .../browser/browser_dns_first_for_single_words.js | 52 + .../tests/browser/browser_downArrowKeySearch.js | 89 + .../urlbar/tests/browser/browser_dragdropURL.js | 106 + .../urlbar/tests/browser/browser_dynamicResults.js | 998 + .../browser/browser_editAndEnterWithSlowQuery.js | 476 + .../tests/browser/browser_edit_invalid_url.js | 91 + .../urlbar/tests/browser/browser_engagement.js | 210 + .../urlbar/tests/browser/browser_enter.js | 331 + .../tests/browser/browser_enterAfterMouseOver.js | 97 + .../urlbar/tests/browser/browser_focusedCmdK.js | 15 + .../urlbar/tests/browser/browser_groupLabels.js | 629 + .../browser/browser_handleCommand_fallback.js | 142 + .../tests/browser/browser_hashChangeProxyState.js | 151 + .../browser/browser_heuristicNotAddedFirst.js | 159 + .../urlbar/tests/browser/browser_hideHeuristic.js | 514 + .../tests/browser/browser_ime_composition.js | 328 + .../urlbar/tests/browser/browser_inputHistory.js | 676 + .../tests/browser/browser_inputHistory_autofill.js | 210 + .../browser/browser_inputHistory_emptystring.js | 97 + .../browser/browser_keepStateAcrossTabSwitches.js | 235 + .../urlbar/tests/browser/browser_keyword.js | 234 + .../tests/browser/browser_keywordBookmarklets.js | 133 + .../urlbar/tests/browser/browser_keywordSearch.js | 57 + .../browser/browser_keywordSearch_postData.js | 74 + .../tests/browser/browser_keyword_override.js | 61 + .../browser/browser_keyword_select_and_type.js | 97 + .../urlbar/tests/browser/browser_loadRace.js | 90 + .../tests/browser/browser_locationBarCommand.js | 352 + .../browser/browser_locationBarExternalLoad.js | 94 + .../browser_locationchange_urlbar_edit_dos.js | 67 + .../urlbar/tests/browser/browser_middleClick.js | 279 + .../browser/browser_move_tab_to_new_window.js | 120 + .../tests/browser/browser_new_tab_urlbar_reset.js | 39 + .../browser_observers_for_strip_on_share.js | 81 + .../urlbar/tests/browser/browser_oneOffs.js | 999 + .../tests/browser/browser_oneOffs_contextMenu.js | 80 + .../browser/browser_oneOffs_heuristicRestyle.js | 516 + .../tests/browser/browser_oneOffs_keyModifiers.js | 392 + .../browser/browser_oneOffs_searchSuggestions.js | 358 + .../tests/browser/browser_oneOffs_settings.js | 89 + .../urlbar/tests/browser/browser_pasteAndGo.js | 80 + .../tests/browser/browser_paste_multi_lines.js | 239 + .../tests/browser/browser_paste_then_focus.js | 60 + .../tests/browser/browser_paste_then_switch_tab.js | 74 + .../tests/browser/browser_percent_encoded.js | 59 + .../urlbar/tests/browser/browser_placeholder.js | 412 + .../browser/browser_populateAfterPushState.js | 32 + .../browser_primary_selection_safe_on_new_tab.js | 70 + .../browser/browser_privateBrowsingWindowChange.js | 51 + .../tests/browser/browser_queryContextCache.js | 490 + .../urlbar/tests/browser/browser_quickactions.js | 737 + .../tests/browser/browser_quickactions_devtools.js | 176 + .../browser/browser_quickactions_screenshot.js | 170 + .../browser/browser_quickactions_tab_refocus.js | 194 + .../urlbar/tests/browser/browser_raceWithTabs.js | 86 + .../urlbar/tests/browser/browser_recentsearches.js | 138 + .../urlbar/tests/browser/browser_redirect_error.js | 137 + .../tests/browser/browser_remoteness_switch.js | 56 + .../urlbar/tests/browser/browser_remotetab.js | 111 + ...browser_removeUnsafeProtocolsFromURLBarPaste.js | 95 + .../urlbar/tests/browser/browser_remove_match.js | 218 + .../tests/browser/browser_restoreEmptyInput.js | 64 + .../urlbar/tests/browser/browser_resultSpan.js | 254 + .../urlbar/tests/browser/browser_result_menu.js | 260 + .../tests/browser/browser_result_menu_general.js | 416 + .../tests/browser/browser_result_onSelection.js | 73 + .../browser/browser_results_format_displayValue.js | 76 + .../browser/browser_retainedResultsOnFocus.js | 438 + .../urlbar/tests/browser/browser_revert.js | 33 + .../urlbar/tests/browser/browser_searchFunction.js | 278 + .../tests/browser/browser_searchHistoryLimit.js | 87 + .../browser_searchMode_alias_replacement.js | 274 + .../tests/browser/browser_searchMode_autofill.js | 133 + .../tests/browser/browser_searchMode_clickLink.js | 94 + .../browser/browser_searchMode_engineRemoval.js | 109 + .../browser/browser_searchMode_excludeResults.js | 217 + .../tests/browser/browser_searchMode_heuristic.js | 219 + .../tests/browser/browser_searchMode_indicator.js | 377 + .../browser_searchMode_indicator_clickthrough.js | 106 + .../browser_searchMode_localOneOffs_actionText.js | 459 + .../tests/browser/browser_searchMode_newWindow.js | 40 + .../tests/browser/browser_searchMode_no_results.js | 290 + .../browser/browser_searchMode_oneOffButton.js | 108 + .../tests/browser/browser_searchMode_pickResult.js | 89 + .../tests/browser/browser_searchMode_preview.js | 489 + .../browser/browser_searchMode_sessionStore.js | 332 + .../tests/browser/browser_searchMode_setURI.js | 119 + .../browser/browser_searchMode_suggestions.js | 581 + .../tests/browser/browser_searchMode_switchTabs.js | 317 + .../urlbar/tests/browser/browser_searchSettings.js | 30 + .../browser_searchSingleWordNotification.js | 372 + .../tests/browser/browser_searchSuggestions.js | 341 + .../tests/browser/browser_searchTelemetry.js | 220 + ...browser_search_bookmarks_from_bookmarks_menu.js | 55 + .../tests/browser/browser_search_continuation.js | 113 + .../browser_search_history_from_history_panel.js | 97 + .../tests/browser/browser_selectStaleResults.js | 329 + .../browser/browser_selectionKeyNavigation.js | 200 + .../browser/browser_separatePrivateDefault.js | 223 + ...owser_separatePrivateDefault_differentEngine.js | 354 + .../browser/browser_shortcuts_add_search_engine.js | 243 + .../urlbar/tests/browser/browser_slow_heuristic.js | 84 + .../tests/browser/browser_speculative_connect.js | 199 + ...ser_speculative_connect_not_with_client_cert.js | 230 + .../urlbar/tests/browser/browser_stop.js | 75 + .../tests/browser/browser_stopSearchOnSelection.js | 113 + .../urlbar/tests/browser/browser_stop_pending.js | 459 + .../urlbar/tests/browser/browser_strip_on_share.js | 197 + .../browser/browser_strip_on_share_telemetry.js | 98 + .../urlbar/tests/browser/browser_suggestedIndex.js | 120 + .../tests/browser/browser_suppressFocusBorder.js | 391 + .../browser/browser_switchTab_closesUrlbarPopup.js | 42 + .../tests/browser/browser_switchTab_currentTab.js | 41 + .../tests/browser/browser_switchTab_decodeuri.js | 51 + .../browser/browser_switchTab_inputHistory.js | 91 + .../tests/browser/browser_switchTab_override.js | 100 + .../browser_switchToTabHavingURI_aOpenParams.js | 217 + .../tests/browser/browser_switchToTab_chiclet.js | 122 + .../browser/browser_switchToTab_closed_tab.js | 90 + .../browser/browser_switchToTab_closes_newtab.js | 63 + .../browser_switchToTab_fullUrl_repeatedKeydown.js | 60 + .../urlbar/tests/browser/browser_tabKeyBehavior.js | 378 + .../browser/browser_tabMatchesInAwesomebar.js | 224 + .../browser_tabMatchesInAwesomebar_perwindowpb.js | 174 + .../urlbar/tests/browser/browser_tabToSearch.js | 647 + .../urlbar/tests/browser/browser_textruns.js | 55 + .../urlbar/tests/browser/browser_tokenAlias.js | 861 + .../urlbar/tests/browser/browser_top_sites.js | 478 + .../tests/browser/browser_top_sites_private.js | 171 + .../urlbar/tests/browser/browser_typed_value.js | 69 + .../urlbar/tests/browser/browser_unitConversion.js | 88 + .../browser/browser_updateForDomainCompletion.js | 22 + .../browser_url_formatted_correctly_on_load.js | 54 + .../tests/browser/browser_urlbar_annotation.js | 333 + .../tests/browser/browser_urlbar_selection.js | 307 + .../tests/browser/browser_urlbar_telemetry.js | 1218 + .../browser/browser_urlbar_telemetry_autofill.js | 684 + .../browser/browser_urlbar_telemetry_dynamic.js | 136 + .../browser/browser_urlbar_telemetry_extension.js | 155 + .../browser/browser_urlbar_telemetry_handoff.js | 182 + .../browser/browser_urlbar_telemetry_persisted.js | 270 + .../browser/browser_urlbar_telemetry_places.js | 321 + .../browser_urlbar_telemetry_quickactions.js | 133 + .../browser/browser_urlbar_telemetry_remotetab.js | 185 + .../browser/browser_urlbar_telemetry_searchmode.js | 592 + .../browser_urlbar_telemetry_tabtosearch.js | 418 + .../tests/browser/browser_urlbar_telemetry_tip.js | 130 + .../browser/browser_urlbar_telemetry_topsite.js | 133 + .../browser/browser_urlbar_telemetry_zeroPrefix.js | 266 + .../urlbar/tests/browser/browser_userTypedValue.js | 50 + .../tests/browser/browser_valueOnTabSwitch.js | 166 + .../tests/browser/browser_view_emptyResultSet.js | 40 + .../browser/browser_view_removedSelectedElement.js | 87 + .../tests/browser/browser_view_resultDisplay.js | 354 + .../browser/browser_view_resultTypes_display.js | 317 + .../tests/browser/browser_view_selectionByMouse.js | 567 + .../browser/browser_waitForLoadStartOrTimeout.js | 33 + .../urlbar/tests/browser/browser_whereToOpen.js | 192 + .../urlbar/tests/browser/dummy_page.html | 9 + .../urlbar/tests/browser/dynamicResult0.css | 50 + .../urlbar/tests/browser/dynamicResult1.css | 50 + .../tests/browser/file_blank_but_not_blank.html | 2 + .../urlbar/tests/browser/file_copying_home.html | 1 + .../urlbar/tests/browser/file_urlbar_edit_dos.html | 18 + .../urlbar/tests/browser/file_userTypedValue.html | 1 + .../components/urlbar/tests/browser/head-common.js | 153 + browser/components/urlbar/tests/browser/head.js | 248 + .../urlbar/tests/browser/mixed_active.html | 14 + browser/components/urlbar/tests/browser/moz.png | Bin 0 -> 580 bytes .../urlbar/tests/browser/print_postdata.sjs | 25 + .../urlbar/tests/browser/redirect_error.sjs | 16 + .../urlbar/tests/browser/redirect_to.sjs | 9 + .../browser/search-engines/basic/manifest.json | 20 + .../tests/browser/searchSuggestionEngine.sjs | 57 + .../tests/browser/searchSuggestionEngine.xml | 11 + .../tests/browser/searchSuggestionEngine2.xml | 13 + .../tests/browser/searchSuggestionEngineMany.xml | 11 + .../tests/browser/searchSuggestionEngineSlow.xml | 11 + .../components/urlbar/tests/browser/slow-page.sjs | 23 + .../browser/urlbarTelemetrySearchSuggestions.sjs | 9 + .../browser/urlbarTelemetrySearchSuggestions.xml | 6 + .../tests/browser/urlbarTelemetryUrlbarDynamic.css | 45 + .../components/urlbar/tests/browser/wait-a-bit.sjs | 11 + .../tests/engagementTelemetry/browser/browser.toml | 87 + .../browser_glean_telemetry_abandonment_groups.js | 235 + ...wser_glean_telemetry_abandonment_interaction.js | 61 + ..._interaction_persisted_search_terms_disabled.js | 48 + ...t_interaction_persisted_search_terms_enabled.js | 53 + ..._glean_telemetry_abandonment_n_chars_n_words.js | 36 + .../browser_glean_telemetry_abandonment_sap.js | 39 + ...lemetry_abandonment_search_engine_default_id.js | 19 + ...wser_glean_telemetry_abandonment_search_mode.js | 54 + .../browser_glean_telemetry_abandonment_tips.js | 99 + ...rowser_glean_telemetry_engagement_edge_cases.js | 221 + .../browser_glean_telemetry_engagement_groups.js | 292 + ...owser_glean_telemetry_engagement_interaction.js | 90 + ..._interaction_persisted_search_terms_disabled.js | 61 + ...t_interaction_persisted_search_terms_enabled.js | 60 + ...r_glean_telemetry_engagement_n_chars_n_words.js | 36 + .../browser_glean_telemetry_engagement_sap.js | 33 + ...elemetry_engagement_search_engine_default_id.js | 19 + ...owser_glean_telemetry_engagement_search_mode.js | 63 + ...r_glean_telemetry_engagement_selected_result.js | 974 + .../browser_glean_telemetry_engagement_tips.js | 173 + .../browser_glean_telemetry_engagement_type.js | 118 + .../browser/browser_glean_telemetry_exposure.js | 136 + .../browser_glean_telemetry_exposure_edge_cases.js | 539 + .../browser_glean_telemetry_impression_groups.js | 258 + ...owser_glean_telemetry_impression_interaction.js | 68 + ..._interaction_persisted_search_terms_disabled.js | 57 + ...n_interaction_persisted_search_terms_enabled.js | 61 + ...r_glean_telemetry_impression_n_chars_n_words.js | 40 + ...owser_glean_telemetry_impression_preferences.js | 41 + .../browser_glean_telemetry_impression_sap.js | 38 + ...elemetry_impression_search_engine_default_id.js | 28 + ...owser_glean_telemetry_impression_search_mode.js | 72 + .../browser_glean_telemetry_impression_timing.js | 91 + .../browser_glean_telemetry_record_preferences.js | 74 + .../engagementTelemetry/browser/head-exposure.js | 47 + .../engagementTelemetry/browser/head-groups.js | 339 + .../browser/head-interaction.js | 340 + .../browser/head-n_chars_n_words.js | 56 + .../tests/engagementTelemetry/browser/head-sap.js | 66 + .../browser/head-search_engine_default_id.js | 43 + .../browser/head-search_mode.js | 93 + .../tests/engagementTelemetry/browser/head.js | 473 + .../tests/quicksuggest/MerinoTestUtils.sys.mjs | 809 + .../quicksuggest/QuickSuggestTestUtils.sys.mjs | 915 + .../quicksuggest/RemoteSettingsServer.sys.mjs | 619 + .../urlbar/tests/quicksuggest/browser/browser.toml | 68 + .../quicksuggest/browser/browser_quicksuggest.js | 166 + .../browser/browser_quicksuggest_addons.js | 443 + .../browser/browser_quicksuggest_block.js | 252 + .../browser/browser_quicksuggest_configuration.js | 2099 + .../browser/browser_quicksuggest_indexes.js | 410 + .../browser/browser_quicksuggest_mdn.js | 230 + .../browser/browser_quicksuggest_merinoSessions.js | 138 + .../browser_quicksuggest_onboardingDialog.js | 1569 + .../browser/browser_quicksuggest_pocket.js | 435 + .../browser/browser_quicksuggest_yelp.js | 429 + .../browser/browser_telemetry_dynamicWikipedia.js | 116 + .../browser/browser_telemetry_gleanEmptyStrings.js | 221 + .../browser_telemetry_impressionEdgeCases.js | 482 + .../browser_telemetry_navigationalSuggestions.js | 346 + .../browser/browser_telemetry_nonsponsored.js | 236 + .../browser/browser_telemetry_other.js | 298 + .../browser/browser_telemetry_sponsored.js | 408 + .../browser/browser_telemetry_weather.js | 158 + .../tests/quicksuggest/browser/browser_weather.js | 426 + .../urlbar/tests/quicksuggest/browser/head.js | 693 + .../browser/searchSuggestionEngine.sjs | 57 + .../browser/searchSuggestionEngine.xml | 11 + .../tests/quicksuggest/browser/subdialog.xhtml | 14 + .../urlbar/tests/quicksuggest/unit/head.js | 911 + .../tests/quicksuggest/unit/test_merinoClient.js | 647 + .../unit/test_merinoClient_sessions.js | 402 + .../tests/quicksuggest/unit/test_quicksuggest.js | 1661 + .../quicksuggest/unit/test_quicksuggest_addons.js | 558 + .../unit/test_quicksuggest_dynamicWikipedia.js | 103 + .../unit/test_quicksuggest_impressionCaps.js | 3907 ++ .../quicksuggest/unit/test_quicksuggest_mdn.js | 190 + .../quicksuggest/unit/test_quicksuggest_merino.js | 574 + .../unit/test_quicksuggest_merinoSessions.js | 173 + .../unit/test_quicksuggest_migrate_v1.js | 490 + .../unit/test_quicksuggest_migrate_v2.js | 1355 + .../unit/test_quicksuggest_nonUniqueKeywords.js | 285 + .../unit/test_quicksuggest_offlineDefault.js | 127 + .../quicksuggest/unit/test_quicksuggest_pocket.js | 531 + .../test_quicksuggest_positionInSuggestions.js | 487 + .../unit/test_quicksuggest_scoreMap.js | 670 + .../unit/test_quicksuggest_topPicks.js | 192 + .../quicksuggest/unit/test_quicksuggest_yelp.js | 842 + .../tests/quicksuggest/unit/test_rust_ingest.js | 244 + .../tests/quicksuggest/unit/test_suggestionsMap.js | 293 + .../urlbar/tests/quicksuggest/unit/test_weather.js | 1402 + .../quicksuggest/unit/test_weather_keywords.js | 1503 + .../urlbar/tests/quicksuggest/unit/xpcshell.toml | 51 + .../components/urlbar/tests/unit/data/engine.xml | 10 + browser/components/urlbar/tests/unit/head.js | 1173 + .../urlbar/tests/unit/test_000_frecency.js | 245 + .../unit/test_UrlbarController_integration.js | 106 + .../tests/unit/test_UrlbarController_telemetry.js | 253 + .../tests/unit/test_UrlbarController_unit.js | 389 + .../urlbar/tests/unit/test_UrlbarPrefs.js | 447 + .../urlbar/tests/unit/test_UrlbarQueryContext.js | 73 + .../unit/test_UrlbarQueryContext_restrictSource.js | 113 + .../urlbar/tests/unit/test_UrlbarSearchUtils.js | 462 + .../unit/test_UrlbarUtils_addToUrlbarHistory.js | 63 + .../unit/test_UrlbarUtils_copySnakeKeysToCamel.js | 226 + ...test_UrlbarUtils_getShortcutOrURIAndPostData.js | 249 + .../tests/unit/test_UrlbarUtils_getTokenMatches.js | 294 + .../tests/unit/test_UrlbarUtils_skippableTimer.js | 89 + .../unit/test_UrlbarUtils_unEscapeURIForUI.js | 36 + .../urlbar/tests/unit/test_about_urls.js | 176 + .../tests/unit/test_autofill_adaptiveHistory.js | 1443 + .../urlbar/tests/unit/test_autofill_bookmarked.js | 151 + .../urlbar/tests/unit/test_autofill_do_not_trim.js | 140 + .../urlbar/tests/unit/test_autofill_functional.js | 147 + .../urlbar/tests/unit/test_autofill_origins.js | 1041 + .../tests/unit/test_autofill_originsAndQueries.js | 2471 ++ .../unit/test_autofill_origins_alt_frecency.js | 272 + .../tests/unit/test_autofill_prefix_fallback.js | 76 + .../unit/test_autofill_search_engine_aliases.js | 85 + .../urlbar/tests/unit/test_autofill_urls.js | 916 + .../unit/test_avoid_stripping_to_empty_tokens.js | 117 + .../urlbar/tests/unit/test_calculator.js | 46 + .../components/urlbar/tests/unit/test_casing.js | 370 + .../tests/unit/test_dedupe_embedded_url_param.js | 226 + .../urlbar/tests/unit/test_dedupe_prefix.js | 277 + .../urlbar/tests/unit/test_dedupe_switchTab.js | 34 + .../urlbar/tests/unit/test_dont_autofill_cases.js | 59 + .../tests/unit/test_download_embed_bookmarks.js | 137 + .../urlbar/tests/unit/test_empty_search.js | 181 + .../urlbar/tests/unit/test_encoded_urls.js | 97 + .../tests/unit/test_escaping_badEscapedURI.js | 37 + .../urlbar/tests/unit/test_escaping_escapeSelf.js | 62 + .../components/urlbar/tests/unit/test_exposure.js | 271 + .../components/urlbar/tests/unit/test_frecency.js | 403 + .../tests/unit/test_frecency_alternative_nimbus.js | 77 + .../urlbar/tests/unit/test_heuristic_cancel.js | 238 + .../urlbar/tests/unit/test_hideSponsoredHistory.js | 104 + ...y_bookmark_results_on_search_service_failure.js | 116 + .../components/urlbar/tests/unit/test_keywords.js | 212 + .../components/urlbar/tests/unit/test_l10nCache.js | 685 + .../urlbar/tests/unit/test_local_suggest_prefs.js | 126 + .../urlbar/tests/unit/test_match_javascript.js | 153 + .../urlbar/tests/unit/test_multi_word_search.js | 126 + browser/components/urlbar/tests/unit/test_muxer.js | 731 + .../urlbar/tests/unit/test_pages_alt_frecency.js | 85 + .../urlbar/tests/unit/test_protocol_ignore.js | 42 + .../urlbar/tests/unit/test_protocol_swap.js | 302 + .../urlbar/tests/unit/test_providerAliasEngines.js | 146 + .../tests/unit/test_providerHeuristicFallback.js | 775 + .../tests/unit/test_providerHistoryUrlHeuristic.js | 197 + .../urlbar/tests/unit/test_providerKeywords.js | 407 + .../urlbar/tests/unit/test_providerOmnibox.js | 887 + .../urlbar/tests/unit/test_providerOpenTabs.js | 80 + .../urlbar/tests/unit/test_providerPlaces.js | 250 + .../unit/test_providerPlaces_duplicate_entries.js | 42 + .../tests/unit/test_providerPlaces_nonEnglish.js | 43 + .../tests/unit/test_providerRecentSearches.js | 167 + .../urlbar/tests/unit/test_providerTabToSearch.js | 536 + .../unit/test_providerTabToSearch_partialHost.js | 214 + .../urlbar/tests/unit/test_providersManager.js | 74 + .../tests/unit/test_providersManager_filtering.js | 405 + .../tests/unit/test_providersManager_maxResults.js | 37 + .../urlbar/tests/unit/test_queryScorer.js | 405 + .../components/urlbar/tests/unit/test_query_url.js | 123 + .../urlbar/tests/unit/test_quickactions.js | 127 + .../urlbar/tests/unit/test_remote_tabs.js | 695 + .../urlbar/tests/unit/test_resultGroups.js | 1576 + .../urlbar/tests/unit/test_richsuggestions.js | 66 + .../tests/unit/test_richsuggestions_order.js | 76 + .../tests/unit/test_search_engine_restyle.js | 124 + .../urlbar/tests/unit/test_search_suggestions.js | 2077 + .../tests/unit/test_search_suggestions_aliases.js | 364 + .../tests/unit/test_search_suggestions_tail.js | 379 + .../urlbar/tests/unit/test_special_search.js | 543 + .../urlbar/tests/unit/test_suggestedIndex.js | 599 + .../unit/test_suggestedIndexRelativeToGroup.js | 645 + .../urlbar/tests/unit/test_tab_matches.js | 366 + .../tests/unit/test_tags_caseInsensitivity.js | 137 + .../urlbar/tests/unit/test_tags_extendedUnicode.js | 66 + .../urlbar/tests/unit/test_tags_general.js | 207 + .../tests/unit/test_tags_matchBookmarkTitles.js | 42 + .../tests/unit/test_tags_returnedInSearches.js | 125 + .../components/urlbar/tests/unit/test_tokenizer.js | 449 + .../components/urlbar/tests/unit/test_trimming.js | 171 + .../urlbar/tests/unit/test_unitConversion.js | 503 + .../urlbar/tests/unit/test_word_boundary_search.js | 401 + browser/components/urlbar/tests/unit/xpcshell.toml | 201 + .../unitconverters/UnitConverterSimple.sys.mjs | 243 + .../UnitConverterTemperature.sys.mjs | 124 + .../unitconverters/UnitConverterTimezone.sys.mjs | 148 + browser/components/urlbar/unitconverters/moz.build | 9 + 4569 files changed, 966529 insertions(+) create mode 100644 browser/components/.eslintrc.js create mode 100644 browser/components/BrowserComponents.manifest create mode 100644 browser/components/BrowserContentHandler.sys.mjs create mode 100644 browser/components/BrowserGlue.sys.mjs create mode 100644 browser/components/StartupRecorder.sys.mjs create mode 100644 browser/components/about/AboutRedirector.cpp create mode 100644 browser/components/about/AboutRedirector.h create mode 100644 browser/components/about/components.conf create mode 100644 browser/components/about/moz.build create mode 100644 browser/components/about/test/unit/test_getURIFlags.js create mode 100644 browser/components/about/test/unit/xpcshell.toml create mode 100644 browser/components/aboutlogins/AboutLoginsChild.sys.mjs create mode 100644 browser/components/aboutlogins/AboutLoginsParent.sys.mjs create mode 100644 browser/components/aboutlogins/LoginBreaches.sys.mjs create mode 100644 browser/components/aboutlogins/content/aboutLogins.css create mode 100644 browser/components/aboutlogins/content/aboutLogins.html create mode 100644 browser/components/aboutlogins/content/aboutLogins.mjs create mode 100644 browser/components/aboutlogins/content/aboutLoginsImportReport.css create mode 100644 browser/components/aboutlogins/content/aboutLoginsImportReport.html create mode 100644 browser/components/aboutlogins/content/aboutLoginsImportReport.mjs create mode 100644 browser/components/aboutlogins/content/aboutLoginsUtils.mjs create mode 100644 browser/components/aboutlogins/content/common.css create mode 100644 browser/components/aboutlogins/content/components/confirmation-dialog.css create mode 100644 browser/components/aboutlogins/content/components/confirmation-dialog.mjs create mode 100644 browser/components/aboutlogins/content/components/fxaccounts-button.css create mode 100644 browser/components/aboutlogins/content/components/fxaccounts-button.mjs create mode 100644 browser/components/aboutlogins/content/components/generic-dialog.css create mode 100644 browser/components/aboutlogins/content/components/generic-dialog.mjs create mode 100644 browser/components/aboutlogins/content/components/import-details-row.mjs create mode 100644 browser/components/aboutlogins/content/components/import-error-dialog.css create mode 100644 browser/components/aboutlogins/content/components/import-error-dialog.mjs create mode 100644 browser/components/aboutlogins/content/components/import-summary-dialog.css create mode 100644 browser/components/aboutlogins/content/components/import-summary-dialog.mjs create mode 100644 browser/components/aboutlogins/content/components/input-field/input-field.css create mode 100644 browser/components/aboutlogins/content/components/input-field/input-field.mjs create mode 100644 browser/components/aboutlogins/content/components/input-field/input-field.stories.mjs create mode 100644 browser/components/aboutlogins/content/components/input-field/login-origin-field.mjs create mode 100644 browser/components/aboutlogins/content/components/input-field/login-password-field.mjs create mode 100644 browser/components/aboutlogins/content/components/input-field/login-username-field.mjs create mode 100644 browser/components/aboutlogins/content/components/login-alert.css create mode 100644 browser/components/aboutlogins/content/components/login-alert.mjs create mode 100644 browser/components/aboutlogins/content/components/login-alert.stories.mjs create mode 100644 browser/components/aboutlogins/content/components/login-command-button.css create mode 100644 browser/components/aboutlogins/content/components/login-command-button.mjs create mode 100644 browser/components/aboutlogins/content/components/login-filter.css create mode 100644 browser/components/aboutlogins/content/components/login-filter.mjs create mode 100644 browser/components/aboutlogins/content/components/login-intro.css create mode 100644 browser/components/aboutlogins/content/components/login-intro.mjs create mode 100644 browser/components/aboutlogins/content/components/login-item.css create mode 100644 browser/components/aboutlogins/content/components/login-item.mjs create mode 100644 browser/components/aboutlogins/content/components/login-list-item.mjs create mode 100644 browser/components/aboutlogins/content/components/login-list-item.stories.mjs create mode 100644 browser/components/aboutlogins/content/components/login-list-lit-item.css create mode 100644 browser/components/aboutlogins/content/components/login-list-lit-item.mjs create mode 100644 browser/components/aboutlogins/content/components/login-list-section.mjs create mode 100644 browser/components/aboutlogins/content/components/login-list.css create mode 100644 browser/components/aboutlogins/content/components/login-list.mjs create mode 100644 browser/components/aboutlogins/content/components/login-message-popup.css create mode 100644 browser/components/aboutlogins/content/components/login-message-popup.mjs create mode 100644 browser/components/aboutlogins/content/components/login-timeline.css create mode 100644 browser/components/aboutlogins/content/components/login-timeline.mjs create mode 100644 browser/components/aboutlogins/content/components/menu-button.css create mode 100644 browser/components/aboutlogins/content/components/menu-button.mjs create mode 100644 browser/components/aboutlogins/content/components/remove-logins-dialog.css create mode 100644 browser/components/aboutlogins/content/components/remove-logins-dialog.mjs create mode 100644 browser/components/aboutlogins/content/icons/breached-website.svg create mode 100644 browser/components/aboutlogins/content/icons/intro-illustration.svg create mode 100644 browser/components/aboutlogins/content/icons/password-hide.svg create mode 100644 browser/components/aboutlogins/content/icons/password.svg create mode 100644 browser/components/aboutlogins/content/icons/vulnerable-password.svg create mode 100644 browser/components/aboutlogins/content/utils/controllers.mjs create mode 100644 browser/components/aboutlogins/content/utils/keypress.mjs create mode 100644 browser/components/aboutlogins/jar.mn create mode 100644 browser/components/aboutlogins/moz.build create mode 100644 browser/components/aboutlogins/tests/browser/AboutLoginsTestUtils.sys.mjs create mode 100644 browser/components/aboutlogins/tests/browser/browser.toml create mode 100644 browser/components/aboutlogins/tests/browser/browser_aaa_eventTelemetry_run_first.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_alertDismissedAfterChangingPassword.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_breachAlertShowingForAddedLogin.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_confirmDeleteDialog.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_contextmenuFillLogins.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_copyToClipboardButton.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_createLogin.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_deleteLogin.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_fxAccounts.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_loginFilter.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_loginItemErrors.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_loginListChanges.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_loginSortOrderRestored.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_noLoginsView.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_openExport.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_openFiltered.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_openImport.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_openImportCSV.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_openPreferences.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_openPreferencesExternal.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_openSite.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_primaryPassword.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_removeAllDialog.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_sessionRestore.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_tabKeyNav.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_updateLogin.js create mode 100644 browser/components/aboutlogins/tests/browser/browser_vulnerableLoginAddedInSecondaryWindow.js create mode 100644 browser/components/aboutlogins/tests/browser/head.js create mode 100644 browser/components/aboutlogins/tests/chrome/.eslintrc.js create mode 100644 browser/components/aboutlogins/tests/chrome/aboutlogins_common.js create mode 100644 browser/components/aboutlogins/tests/chrome/chrome.toml create mode 100644 browser/components/aboutlogins/tests/chrome/test_confirm_delete_dialog.html create mode 100644 browser/components/aboutlogins/tests/chrome/test_fxaccounts_button.html create mode 100644 browser/components/aboutlogins/tests/chrome/test_login_filter.html create mode 100644 browser/components/aboutlogins/tests/chrome/test_login_item.html create mode 100644 browser/components/aboutlogins/tests/chrome/test_login_list.html create mode 100644 browser/components/aboutlogins/tests/chrome/test_menu_button.html create mode 100644 browser/components/aboutlogins/tests/unit/head.js create mode 100644 browser/components/aboutlogins/tests/unit/test_getPotentialBreachesByLoginGUID.js create mode 100644 browser/components/aboutlogins/tests/unit/xpcshell.toml create mode 100644 browser/components/aboutwelcome/.eslintrc.js create mode 100644 browser/components/aboutwelcome/actors/AboutWelcomeChild.sys.mjs create mode 100644 browser/components/aboutwelcome/actors/AboutWelcomeParent.sys.mjs create mode 100644 browser/components/aboutwelcome/assets/confetti.svg create mode 100644 browser/components/aboutwelcome/assets/device-migration.svg create mode 100644 browser/components/aboutwelcome/assets/fox-doodle-tail.png create mode 100644 browser/components/aboutwelcome/assets/fox-doodle-waving-static.png create mode 100644 browser/components/aboutwelcome/assets/fox-doodle-waving.gif create mode 100644 browser/components/aboutwelcome/assets/heart.webp create mode 100644 browser/components/aboutwelcome/assets/long-zap.svg create mode 100644 browser/components/aboutwelcome/assets/mobile-download-qr-existing-user-cn.svg create mode 100644 browser/components/aboutwelcome/assets/mobile-download-qr-existing-user.svg create mode 100644 browser/components/aboutwelcome/assets/mobile-download-qr-new-user-cn.svg create mode 100644 browser/components/aboutwelcome/assets/mobile-download-qr-new-user.svg create mode 100644 browser/components/aboutwelcome/assets/mr-amo-collection.svg create mode 100644 browser/components/aboutwelcome/assets/mr-gratitude.svg create mode 100644 browser/components/aboutwelcome/assets/mr-import.svg create mode 100644 browser/components/aboutwelcome/assets/mr-mobilecrosspromo.svg create mode 100644 browser/components/aboutwelcome/assets/mr-pinprivate.svg create mode 100644 browser/components/aboutwelcome/assets/mr-pintaskbar.svg create mode 100644 browser/components/aboutwelcome/assets/mr-privacysegmentation.svg create mode 100644 browser/components/aboutwelcome/assets/mr-rtamo-background-image.svg create mode 100644 browser/components/aboutwelcome/assets/mr-settodefault.svg create mode 100644 browser/components/aboutwelcome/assets/noodle-C.svg create mode 100644 browser/components/aboutwelcome/assets/noodle-outline-L.svg create mode 100644 browser/components/aboutwelcome/assets/noodle-solid-L.svg create mode 100644 browser/components/aboutwelcome/assets/person-typing.svg create mode 100644 browser/components/aboutwelcome/assets/short-zap.svg create mode 100644 browser/components/aboutwelcome/content-src/aboutwelcome.jsx create mode 100644 browser/components/aboutwelcome/content-src/aboutwelcome.scss create mode 100644 browser/components/aboutwelcome/content-src/components/AdditionalCTA.jsx create mode 100644 browser/components/aboutwelcome/content-src/components/AddonsPicker.jsx create mode 100644 browser/components/aboutwelcome/content-src/components/CTAParagraph.jsx create mode 100644 browser/components/aboutwelcome/content-src/components/EmbeddedMigrationWizard.jsx create mode 100644 browser/components/aboutwelcome/content-src/components/HelpText.jsx create mode 100644 browser/components/aboutwelcome/content-src/components/HeroImage.jsx create mode 100644 browser/components/aboutwelcome/content-src/components/LanguageSwitcher.jsx create mode 100644 browser/components/aboutwelcome/content-src/components/LinkParagraph.jsx create mode 100644 browser/components/aboutwelcome/content-src/components/MRColorways.jsx create mode 100644 browser/components/aboutwelcome/content-src/components/MSLocalized.jsx create mode 100644 browser/components/aboutwelcome/content-src/components/MobileDownloads.jsx create mode 100644 browser/components/aboutwelcome/content-src/components/MultiSelect.jsx create mode 100644 browser/components/aboutwelcome/content-src/components/MultiStageAboutWelcome.jsx create mode 100644 browser/components/aboutwelcome/content-src/components/MultiStageProtonScreen.jsx create mode 100644 browser/components/aboutwelcome/content-src/components/OnboardingVideo.jsx create mode 100644 browser/components/aboutwelcome/content-src/components/ReturnToAMO.jsx create mode 100644 browser/components/aboutwelcome/content-src/components/SubmenuButton.jsx create mode 100644 browser/components/aboutwelcome/content-src/components/Themes.jsx create mode 100644 browser/components/aboutwelcome/content-src/components/Zap.jsx create mode 100644 browser/components/aboutwelcome/content-src/lib/aboutwelcome-utils.mjs create mode 100644 browser/components/aboutwelcome/content-src/lib/addUtmParams.mjs create mode 100644 browser/components/aboutwelcome/content/aboutwelcome.bundle.js create mode 100644 browser/components/aboutwelcome/content/aboutwelcome.css create mode 100644 browser/components/aboutwelcome/content/aboutwelcome.html create mode 100644 browser/components/aboutwelcome/content/onboarding.ftl create mode 100644 browser/components/aboutwelcome/docs/index.rst create mode 100644 browser/components/aboutwelcome/jar.mn create mode 100644 browser/components/aboutwelcome/karma.mc.config.js create mode 100644 browser/components/aboutwelcome/modules/AWScreenUtils.sys.mjs create mode 100644 browser/components/aboutwelcome/modules/AboutWelcomeDefaults.sys.mjs create mode 100644 browser/components/aboutwelcome/modules/AboutWelcomeTelemetry.sys.mjs create mode 100644 browser/components/aboutwelcome/moz.build create mode 100644 browser/components/aboutwelcome/package-lock.json create mode 100644 browser/components/aboutwelcome/package.json create mode 100644 browser/components/aboutwelcome/tests/browser/browser.toml create mode 100644 browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_attribution.js create mode 100644 browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_configurable_ui.js create mode 100644 browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_fxa_signin_flow.js create mode 100644 browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_glean.js create mode 100644 browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_import.js create mode 100644 browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_mobile_downloads.js create mode 100644 browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_addonspicker.js create mode 100644 browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_default.js create mode 100644 browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_experimentAPI.js create mode 100644 browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_languageSwitcher.js create mode 100644 browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_mr.js create mode 100644 browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_multistage_video.js create mode 100644 browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_observer.js create mode 100644 browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_rtamo.js create mode 100644 browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_screen_targeting.js create mode 100644 browser/components/aboutwelcome/tests/browser/browser_aboutwelcome_upgrade_multistage_mr.js create mode 100644 browser/components/aboutwelcome/tests/browser/head.js create mode 100644 browser/components/aboutwelcome/tests/unit/AWScreenUtils.test.jsx create mode 100644 browser/components/aboutwelcome/tests/unit/CTAParagraph.test.jsx create mode 100644 browser/components/aboutwelcome/tests/unit/HelpText.test.jsx create mode 100644 browser/components/aboutwelcome/tests/unit/HeroImage.test.jsx create mode 100644 browser/components/aboutwelcome/tests/unit/LinkParagraph.test.jsx create mode 100644 browser/components/aboutwelcome/tests/unit/MRColorways.test.jsx create mode 100644 browser/components/aboutwelcome/tests/unit/MSLocalized.test.jsx create mode 100644 browser/components/aboutwelcome/tests/unit/MobileDownloads.test.jsx create mode 100644 browser/components/aboutwelcome/tests/unit/MultiSelect.test.jsx create mode 100644 browser/components/aboutwelcome/tests/unit/MultiStageAWProton.test.jsx create mode 100644 browser/components/aboutwelcome/tests/unit/MultiStageAboutWelcome.test.jsx create mode 100644 browser/components/aboutwelcome/tests/unit/OnboardingVideoTest.test.jsx create mode 100644 browser/components/aboutwelcome/tests/unit/addUtmParams.test.js create mode 100644 browser/components/aboutwelcome/tests/unit/unit-entry.js create mode 100644 browser/components/aboutwelcome/webpack.aboutwelcome.config.js create mode 100644 browser/components/aboutwelcome/yamscripts.yml create mode 100644 browser/components/asrouter/.eslintrc.js create mode 100644 browser/components/asrouter/README.md create mode 100644 browser/components/asrouter/actors/ASRouterChild.sys.mjs create mode 100644 browser/components/asrouter/actors/ASRouterParent.sys.mjs create mode 100644 browser/components/asrouter/bin/import-rollouts.js create mode 100644 browser/components/asrouter/content-src/asrouter-utils.js create mode 100644 browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx create mode 100644 browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.scss create mode 100644 browser/components/asrouter/content-src/components/ASRouterAdmin/CopyButton.jsx create mode 100644 browser/components/asrouter/content-src/components/ASRouterAdmin/ImpressionsSection.jsx create mode 100644 browser/components/asrouter/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx create mode 100644 browser/components/asrouter/content-src/components/Button/Button.jsx create mode 100644 browser/components/asrouter/content-src/components/Button/_Button.scss create mode 100644 browser/components/asrouter/content-src/components/ConditionalWrapper/ConditionalWrapper.jsx create mode 100644 browser/components/asrouter/content-src/components/ImpressionsWrapper/ImpressionsWrapper.jsx create mode 100644 browser/components/asrouter/content-src/schemas/BackgroundTaskMessagingExperiment.schema.json create mode 100644 browser/components/asrouter/content-src/schemas/FxMSCommon.schema.json create mode 100644 browser/components/asrouter/content-src/schemas/MessagingExperiment.schema.json create mode 100644 browser/components/asrouter/content-src/schemas/corpus/ReachExperiments.messages.json create mode 100644 browser/components/asrouter/content-src/schemas/extract-test-corpus.js create mode 100755 browser/components/asrouter/content-src/schemas/make-schemas.py create mode 100644 browser/components/asrouter/content-src/schemas/message-format.md create mode 100644 browser/components/asrouter/content-src/schemas/message-group.schema.json create mode 100644 browser/components/asrouter/content-src/schemas/provider-response.schema.json create mode 100644 browser/components/asrouter/content-src/styles/_feature-callout-theme.scss create mode 100644 browser/components/asrouter/content-src/styles/_feature-callout.scss create mode 100644 browser/components/asrouter/content-src/styles/_shopping.scss create mode 100644 browser/components/asrouter/content-src/templates/CFR/templates/CFRUrlbarChiclet.schema.json create mode 100644 browser/components/asrouter/content-src/templates/CFR/templates/ExtensionDoorhanger.schema.json create mode 100644 browser/components/asrouter/content-src/templates/CFR/templates/InfoBar.schema.json create mode 100644 browser/components/asrouter/content-src/templates/OnboardingMessage/Spotlight.schema.json create mode 100644 browser/components/asrouter/content-src/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json create mode 100644 browser/components/asrouter/content-src/templates/OnboardingMessage/UpdateAction.schema.json create mode 100644 browser/components/asrouter/content-src/templates/OnboardingMessage/WhatsNewMessage.schema.json create mode 100644 browser/components/asrouter/content-src/templates/PBNewtab/NewtabPromoMessage.schema.json create mode 100644 browser/components/asrouter/content-src/templates/ToastNotification/ToastNotification.schema.json create mode 100644 browser/components/asrouter/content/asrouter-admin.bundle.js create mode 100644 browser/components/asrouter/content/asrouter-admin.html create mode 100644 browser/components/asrouter/content/components/ASRouterAdmin/ASRouterAdmin.css create mode 100644 browser/components/asrouter/content/render.js create mode 100644 browser/components/asrouter/docs/about-welcome.md create mode 100644 browser/components/asrouter/docs/aboutwelcome-1.png create mode 100644 browser/components/asrouter/docs/aboutwelcome-2.png create mode 100644 browser/components/asrouter/docs/aboutwelcome-res-1.png create mode 100644 browser/components/asrouter/docs/aboutwelcome-res-2.png create mode 100644 browser/components/asrouter/docs/cfr-doorhanger.png create mode 100644 browser/components/asrouter/docs/cfr_doorhanger_screenshot.png create mode 100644 browser/components/asrouter/docs/contextual-feature-recommendation.md create mode 100644 browser/components/asrouter/docs/debugging-docs.md create mode 100644 browser/components/asrouter/docs/debugging-guide.png create mode 100644 browser/components/asrouter/docs/feature-callout.md create mode 100644 browser/components/asrouter/docs/feature-callout.png create mode 100644 browser/components/asrouter/docs/first-run.md create mode 100644 browser/components/asrouter/docs/index.rst create mode 100644 browser/components/asrouter/docs/infobar.png create mode 100644 browser/components/asrouter/docs/infobars.md create mode 100644 browser/components/asrouter/docs/message-routing-overview.png create mode 100644 browser/components/asrouter/docs/moments-page.md create mode 100644 browser/components/asrouter/docs/moments.png create mode 100644 browser/components/asrouter/docs/private-browsing.md create mode 100644 browser/components/asrouter/docs/private-browsing.png create mode 100644 browser/components/asrouter/docs/remote_cfr.md create mode 100644 browser/components/asrouter/docs/selected-PB.png create mode 100644 browser/components/asrouter/docs/simple-cfr-template.rst create mode 100644 browser/components/asrouter/docs/spotlight.md create mode 100644 browser/components/asrouter/docs/spotlight.png create mode 100644 browser/components/asrouter/docs/targeting-attributes.md create mode 100644 browser/components/asrouter/docs/targeting-guide.md create mode 100644 browser/components/asrouter/docs/telemetry.md create mode 100644 browser/components/asrouter/jar.mn create mode 100644 browser/components/asrouter/karma.mc.config.js create mode 100644 browser/components/asrouter/modules/ASRouter.sys.mjs create mode 100644 browser/components/asrouter/modules/ASRouterDefaultConfig.sys.mjs create mode 100644 browser/components/asrouter/modules/ASRouterNewTabHook.sys.mjs create mode 100644 browser/components/asrouter/modules/ASRouterParentProcessMessageHandler.sys.mjs create mode 100644 browser/components/asrouter/modules/ASRouterPreferences.sys.mjs create mode 100644 browser/components/asrouter/modules/ASRouterTargeting.sys.mjs create mode 100644 browser/components/asrouter/modules/ASRouterTriggerListeners.sys.mjs create mode 100644 browser/components/asrouter/modules/ActorConstants.sys.mjs create mode 100644 browser/components/asrouter/modules/CFRMessageProvider.sys.mjs create mode 100644 browser/components/asrouter/modules/CFRPageActions.sys.mjs create mode 100644 browser/components/asrouter/modules/FeatureCallout.sys.mjs create mode 100644 browser/components/asrouter/modules/FeatureCalloutBroker.sys.mjs create mode 100644 browser/components/asrouter/modules/FeatureCalloutMessages.sys.mjs create mode 100644 browser/components/asrouter/modules/InfoBar.sys.mjs create mode 100644 browser/components/asrouter/modules/MessagingExperimentConstants.sys.mjs create mode 100644 browser/components/asrouter/modules/MomentsPageHub.sys.mjs create mode 100644 browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs create mode 100644 browser/components/asrouter/modules/PageEventManager.sys.mjs create mode 100644 browser/components/asrouter/modules/PanelTestProvider.sys.mjs create mode 100644 browser/components/asrouter/modules/RemoteL10n.sys.mjs create mode 100644 browser/components/asrouter/modules/Spotlight.sys.mjs create mode 100644 browser/components/asrouter/modules/ToastNotification.sys.mjs create mode 100644 browser/components/asrouter/modules/ToolbarBadgeHub.sys.mjs create mode 100644 browser/components/asrouter/modules/ToolbarPanelHub.sys.mjs create mode 100644 browser/components/asrouter/moz.build create mode 100644 browser/components/asrouter/package-lock.json create mode 100644 browser/components/asrouter/package.json create mode 100644 browser/components/asrouter/tests/InflightAssetsMessageProvider.sys.mjs create mode 100644 browser/components/asrouter/tests/NimbusRolloutMessageProvider.sys.mjs create mode 100644 browser/components/asrouter/tests/browser/browser.toml create mode 100644 browser/components/asrouter/tests/browser/browser_asrouter_bug1761522.js create mode 100644 browser/components/asrouter/tests/browser/browser_asrouter_bug1800087.js create mode 100644 browser/components/asrouter/tests/browser/browser_asrouter_cfr.js create mode 100644 browser/components/asrouter/tests/browser/browser_asrouter_experimentsAPILoader.js create mode 100644 browser/components/asrouter/tests/browser/browser_asrouter_group_frequency.js create mode 100644 browser/components/asrouter/tests/browser/browser_asrouter_group_userprefs.js create mode 100644 browser/components/asrouter/tests/browser/browser_asrouter_infobar.js create mode 100644 browser/components/asrouter/tests/browser/browser_asrouter_momentspagehub.js create mode 100644 browser/components/asrouter/tests/browser/browser_asrouter_targeting.js create mode 100644 browser/components/asrouter/tests/browser/browser_asrouter_toast_notification.js create mode 100644 browser/components/asrouter/tests/browser/browser_asrouter_toolbarbadge.js create mode 100644 browser/components/asrouter/tests/browser/browser_feature_callout_in_chrome.js create mode 100644 browser/components/asrouter/tests/browser/browser_feature_callout_panel.js create mode 100644 browser/components/asrouter/tests/browser/browser_trigger_listeners.js create mode 100644 browser/components/asrouter/tests/browser/head.js create mode 100644 browser/components/asrouter/tests/unit/ASRouter.test.js create mode 100644 browser/components/asrouter/tests/unit/ASRouterChild.test.js create mode 100644 browser/components/asrouter/tests/unit/ASRouterNewTabHook.test.js create mode 100644 browser/components/asrouter/tests/unit/ASRouterParent.test.js create mode 100644 browser/components/asrouter/tests/unit/ASRouterParentProcessMessageHandler.test.js create mode 100644 browser/components/asrouter/tests/unit/ASRouterPreferences.test.js create mode 100644 browser/components/asrouter/tests/unit/ASRouterTargeting.test.js create mode 100644 browser/components/asrouter/tests/unit/ASRouterTriggerListeners.test.js create mode 100644 browser/components/asrouter/tests/unit/CFRMessageProvider.test.js create mode 100644 browser/components/asrouter/tests/unit/CFRPageActions.test.js create mode 100644 browser/components/asrouter/tests/unit/MessageLoaderUtils.test.js create mode 100644 browser/components/asrouter/tests/unit/ModalOverlay.test.jsx create mode 100644 browser/components/asrouter/tests/unit/MomentsPageHub.test.js create mode 100644 browser/components/asrouter/tests/unit/RemoteL10n.test.js create mode 100644 browser/components/asrouter/tests/unit/TargetingDocs.test.js create mode 100644 browser/components/asrouter/tests/unit/ToolbarBadgeHub.test.js create mode 100644 browser/components/asrouter/tests/unit/ToolbarPanelHub.test.js create mode 100644 browser/components/asrouter/tests/unit/asrouter-utils.test.js create mode 100644 browser/components/asrouter/tests/unit/constants.js create mode 100644 browser/components/asrouter/tests/unit/content-src/components/ASRouterAdmin.test.jsx create mode 100644 browser/components/asrouter/tests/unit/templates/ExtensionDoorhanger.test.jsx create mode 100644 browser/components/asrouter/tests/unit/unit-entry.js create mode 100644 browser/components/asrouter/tests/xpcshell/head.js create mode 100644 browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_attribution.js create mode 100644 browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_snapshot.js create mode 100644 browser/components/asrouter/tests/xpcshell/test_ASRouter_getTargetingParameters.js create mode 100644 browser/components/asrouter/tests/xpcshell/test_CFRMessageProvider.js create mode 100644 browser/components/asrouter/tests/xpcshell/test_InflightAssetsMessageProvider.js create mode 100644 browser/components/asrouter/tests/xpcshell/test_NimbusRolloutMessageProvider.js create mode 100644 browser/components/asrouter/tests/xpcshell/test_OnboardingMessageProvider.js create mode 100644 browser/components/asrouter/tests/xpcshell/test_PanelTestProvider.js create mode 100644 browser/components/asrouter/tests/xpcshell/test_reach_experiments.js create mode 100644 browser/components/asrouter/tests/xpcshell/test_remoteExperiments.js create mode 100644 browser/components/asrouter/tests/xpcshell/xpcshell.toml create mode 100644 browser/components/asrouter/webpack.asrouter-admin.config.js create mode 100644 browser/components/asrouter/yamscripts.yml create mode 100644 browser/components/attribution/AttributionCode.sys.mjs create mode 100644 browser/components/attribution/MacAttribution.sys.mjs create mode 100644 browser/components/attribution/docs/index.rst create mode 100644 browser/components/attribution/moz.build create mode 100644 browser/components/attribution/test/browser/browser.toml create mode 100644 browser/components/attribution/test/browser/browser_AttributionCode_Mac_telemetry.js create mode 100644 browser/components/attribution/test/browser/browser_AttributionCode_telemetry.js create mode 100644 browser/components/attribution/test/browser/head.js create mode 100644 browser/components/attribution/test/xpcshell/head.js create mode 100644 browser/components/attribution/test/xpcshell/test_AttributionCode.js create mode 100644 browser/components/attribution/test/xpcshell/test_MacAttribution.js create mode 100644 browser/components/attribution/test/xpcshell/test_attribution_parsing.js create mode 100644 browser/components/attribution/test/xpcshell/xpcshell.toml create mode 100644 browser/components/build/components.conf create mode 100644 browser/components/build/moz.build create mode 100644 browser/components/build/nsBrowserCompsCID.h create mode 100644 browser/components/components.conf create mode 100644 browser/components/contentanalysis/content/ContentAnalysis.sys.mjs create mode 100644 browser/components/contentanalysis/moz.build create mode 100644 browser/components/contextualidentity/content/briefcase.svg create mode 100644 browser/components/contextualidentity/content/cart.svg create mode 100644 browser/components/contextualidentity/content/chill.svg create mode 100644 browser/components/contextualidentity/content/circle.svg create mode 100644 browser/components/contextualidentity/content/dollar.svg create mode 100644 browser/components/contextualidentity/content/fence.svg create mode 100644 browser/components/contextualidentity/content/fingerprint.svg create mode 100644 browser/components/contextualidentity/content/food.svg create mode 100644 browser/components/contextualidentity/content/fruit.svg create mode 100644 browser/components/contextualidentity/content/gift.svg create mode 100644 browser/components/contextualidentity/content/pet.svg create mode 100644 browser/components/contextualidentity/content/tree.svg create mode 100644 browser/components/contextualidentity/content/usercontext.css create mode 100644 browser/components/contextualidentity/content/vacation.svg create mode 100644 browser/components/contextualidentity/jar.mn create mode 100644 browser/components/contextualidentity/moz.build create mode 100644 browser/components/contextualidentity/test/browser/blank.html create mode 100644 browser/components/contextualidentity/test/browser/browser.toml create mode 100644 browser/components/contextualidentity/test/browser/browser_aboutURLs.js create mode 100644 browser/components/contextualidentity/test/browser/browser_blobUrl.js create mode 100644 browser/components/contextualidentity/test/browser/browser_broadcastchannel.js create mode 100644 browser/components/contextualidentity/test/browser/browser_count_and_remove.js create mode 100644 browser/components/contextualidentity/test/browser/browser_eme.js create mode 100644 browser/components/contextualidentity/test/browser/browser_favicon.js create mode 100644 browser/components/contextualidentity/test/browser/browser_forgetAPI_EME_forgetThisSite.js create mode 100644 browser/components/contextualidentity/test/browser/browser_forgetAPI_cookie_getCookiesWithOriginAttributes.js create mode 100644 browser/components/contextualidentity/test/browser/browser_forgetAPI_quota_clearStoragesForPrincipal.js create mode 100644 browser/components/contextualidentity/test/browser/browser_forgetaboutsite.js create mode 100644 browser/components/contextualidentity/test/browser/browser_guessusercontext.js create mode 100644 browser/components/contextualidentity/test/browser/browser_imageCache.js create mode 100644 browser/components/contextualidentity/test/browser/browser_middleClick.js create mode 100644 browser/components/contextualidentity/test/browser/browser_newtabButton.js create mode 100644 browser/components/contextualidentity/test/browser/browser_originattrs_reopenin.js create mode 100644 browser/components/contextualidentity/test/browser/browser_relatedTab.js create mode 100644 browser/components/contextualidentity/test/browser/browser_reopenIn.js create mode 100644 browser/components/contextualidentity/test/browser/browser_restore_getCookiesWithOriginAttributes.js create mode 100644 browser/components/contextualidentity/test/browser/browser_saveLink.js create mode 100644 browser/components/contextualidentity/test/browser/browser_serviceworkers.js create mode 100644 browser/components/contextualidentity/test/browser/browser_switchTab_across_user_context.js create mode 100644 browser/components/contextualidentity/test/browser/browser_tab_color_update.js create mode 100644 browser/components/contextualidentity/test/browser/browser_usercontext.js create mode 100644 browser/components/contextualidentity/test/browser/browser_usercontextid_new_window.js create mode 100644 browser/components/contextualidentity/test/browser/browser_usercontextid_tabdrop.js create mode 100644 browser/components/contextualidentity/test/browser/browser_windowName.js create mode 100644 browser/components/contextualidentity/test/browser/browser_windowOpen.js create mode 100644 browser/components/contextualidentity/test/browser/empty_file.html create mode 100644 browser/components/contextualidentity/test/browser/favicon-normal32.png create mode 100644 browser/components/contextualidentity/test/browser/file_reflect_cookie_into_title.html create mode 100644 browser/components/contextualidentity/test/browser/file_set_storages.html create mode 100644 browser/components/contextualidentity/test/browser/head.js create mode 100644 browser/components/contextualidentity/test/browser/saveLink.sjs create mode 100644 browser/components/contextualidentity/test/browser/serviceworker.html create mode 100644 browser/components/contextualidentity/test/browser/worker.js create mode 100644 browser/components/controlcenter/content/identityPanel.inc.xhtml create mode 100644 browser/components/controlcenter/content/permissionPanel.inc.xhtml create mode 100644 browser/components/controlcenter/content/protectionsPanel.inc.xhtml create mode 100644 browser/components/customizableui/CustomizableUI.sys.mjs create mode 100644 browser/components/customizableui/CustomizableWidgets.sys.mjs create mode 100644 browser/components/customizableui/CustomizeMode.sys.mjs create mode 100644 browser/components/customizableui/DragPositionManager.sys.mjs create mode 100644 browser/components/customizableui/PanelMultiView.sys.mjs create mode 100644 browser/components/customizableui/SearchWidgetTracker.sys.mjs create mode 100644 browser/components/customizableui/content/.eslintrc.js create mode 100644 browser/components/customizableui/content/customizeMode.inc.xhtml create mode 100644 browser/components/customizableui/content/jar.mn create mode 100644 browser/components/customizableui/content/moz.build create mode 100644 browser/components/customizableui/content/panelUI.inc.xhtml create mode 100644 browser/components/customizableui/content/panelUI.js create mode 100644 browser/components/customizableui/moz.build create mode 100644 browser/components/customizableui/test/CustomizableUITestUtils.sys.mjs create mode 100644 browser/components/customizableui/test/browser.toml create mode 100644 browser/components/customizableui/test/browser_1003588_no_specials_in_panel.js create mode 100644 browser/components/customizableui/test/browser_1008559_anchor_undo_restore.js create mode 100644 browser/components/customizableui/test/browser_1042100_default_placements_update.js create mode 100644 browser/components/customizableui/test/browser_1058573_showToolbarsDropdown.js create mode 100644 browser/components/customizableui/test/browser_1087303_button_fullscreen.js create mode 100644 browser/components/customizableui/test/browser_1087303_button_preferences.js create mode 100644 browser/components/customizableui/test/browser_1089591_still_customizable_after_reset.js create mode 100644 browser/components/customizableui/test/browser_1096763_seen_widgets_post_reset.js create mode 100644 browser/components/customizableui/test/browser_1161838_inserted_new_default_buttons.js create mode 100644 browser/components/customizableui/test/browser_1484275_PanelMultiView_toggle_with_other_popup.js create mode 100644 browser/components/customizableui/test/browser_1701883_restore_defaults_pocket_pref.js create mode 100644 browser/components/customizableui/test/browser_1702200_PanelMultiView_header_separator.js create mode 100644 browser/components/customizableui/test/browser_1795260_searchbar_overflow_toolbar.js create mode 100644 browser/components/customizableui/test/browser_1856572_ensure_Fluent_works_in_customizeMode.js create mode 100644 browser/components/customizableui/test/browser_694291_searchbar_preference.js create mode 100644 browser/components/customizableui/test/browser_873501_handle_specials.js create mode 100644 browser/components/customizableui/test/browser_876926_customize_mode_wrapping.js create mode 100644 browser/components/customizableui/test/browser_876944_customize_mode_create_destroy.js create mode 100644 browser/components/customizableui/test/browser_877006_missing_view.js create mode 100644 browser/components/customizableui/test/browser_877178_unregisterArea.js create mode 100644 browser/components/customizableui/test/browser_877447_skip_missing_ids.js create mode 100644 browser/components/customizableui/test/browser_878452_drag_to_panel.js create mode 100644 browser/components/customizableui/test/browser_884402_customize_from_overflow.js create mode 100644 browser/components/customizableui/test/browser_885052_customize_mode_observers_disabed.js create mode 100644 browser/components/customizableui/test/browser_885530_showInPrivateBrowsing.js create mode 100644 browser/components/customizableui/test/browser_886323_buildArea_removable_nodes.js create mode 100644 browser/components/customizableui/test/browser_890262_destroyWidget_after_add_to_panel.js create mode 100644 browser/components/customizableui/test/browser_892955_isWidgetRemovable_for_removed_widgets.js create mode 100644 browser/components/customizableui/test/browser_892956_destroyWidget_defaultPlacements.js create mode 100644 browser/components/customizableui/test/browser_901207_searchbar_in_panel.js create mode 100644 browser/components/customizableui/test/browser_909779_overflow_toolbars_new_window.js create mode 100644 browser/components/customizableui/test/browser_913972_currentset_overflow.js create mode 100644 browser/components/customizableui/test/browser_914138_widget_API_overflowable_toolbar.js create mode 100644 browser/components/customizableui/test/browser_918049_skipintoolbarset_dnd.js create mode 100644 browser/components/customizableui/test/browser_923857_customize_mode_event_wrapping_during_reset.js create mode 100644 browser/components/customizableui/test/browser_927717_customize_drag_empty_toolbar.js create mode 100644 browser/components/customizableui/test/browser_934113_menubar_removable.js create mode 100644 browser/components/customizableui/test/browser_934951_zoom_in_toolbar.js create mode 100644 browser/components/customizableui/test/browser_938980_navbar_collapsed.js create mode 100644 browser/components/customizableui/test/browser_938995_indefaultstate_nonremovable.js create mode 100644 browser/components/customizableui/test/browser_940013_registerToolbarNode_calls_registerArea.js create mode 100644 browser/components/customizableui/test/browser_940307_panel_click_closure_handling.js create mode 100644 browser/components/customizableui/test/browser_940946_removable_from_navbar_customizemode.js create mode 100644 browser/components/customizableui/test/browser_941083_invalidate_wrapper_cache_createWidget.js create mode 100644 browser/components/customizableui/test/browser_942581_unregisterArea_keeps_placements.js create mode 100644 browser/components/customizableui/test/browser_944887_destroyWidget_should_destroy_in_palette.js create mode 100644 browser/components/customizableui/test/browser_945739_showInPrivateBrowsing_customize_mode.js create mode 100644 browser/components/customizableui/test/browser_947914_button_copy.js create mode 100644 browser/components/customizableui/test/browser_947914_button_cut.js create mode 100644 browser/components/customizableui/test/browser_947914_button_find.js create mode 100644 browser/components/customizableui/test/browser_947914_button_history.js create mode 100644 browser/components/customizableui/test/browser_947914_button_newPrivateWindow.js create mode 100644 browser/components/customizableui/test/browser_947914_button_newWindow.js create mode 100644 browser/components/customizableui/test/browser_947914_button_paste.js create mode 100644 browser/components/customizableui/test/browser_947914_button_print.js create mode 100644 browser/components/customizableui/test/browser_947914_button_zoomIn.js create mode 100644 browser/components/customizableui/test/browser_947914_button_zoomOut.js create mode 100644 browser/components/customizableui/test/browser_947914_button_zoomReset.js create mode 100644 browser/components/customizableui/test/browser_947987_removable_default.js create mode 100644 browser/components/customizableui/test/browser_948985_non_removable_defaultArea.js create mode 100644 browser/components/customizableui/test/browser_952963_areaType_getter_no_area.js create mode 100644 browser/components/customizableui/test/browser_956602_remove_special_widget.js create mode 100644 browser/components/customizableui/test/browser_962069_drag_to_overflow_chevron.js create mode 100644 browser/components/customizableui/test/browser_963639_customizing_attribute_non_customizable_toolbar.js create mode 100644 browser/components/customizableui/test/browser_968565_insert_before_hidden_items.js create mode 100644 browser/components/customizableui/test/browser_969427_recreate_destroyed_widget_after_reset.js create mode 100644 browser/components/customizableui/test/browser_969661_character_encoding_navbar_disabled.js create mode 100644 browser/components/customizableui/test/browser_970511_undo_restore_default.js create mode 100644 browser/components/customizableui/test/browser_972267_customizationchange_events.js create mode 100644 browser/components/customizableui/test/browser_976792_insertNodeInWindow.js create mode 100644 browser/components/customizableui/test/browser_978084_dragEnd_after_move.js create mode 100644 browser/components/customizableui/test/browser_980155_add_overflow_toolbar.js create mode 100644 browser/components/customizableui/test/browser_981305_separator_insertion.js create mode 100644 browser/components/customizableui/test/browser_981418-widget-onbeforecreated-handler.js create mode 100644 browser/components/customizableui/test/browser_982656_restore_defaults_builtin_widgets.js create mode 100644 browser/components/customizableui/test/browser_984455_bookmarks_items_reparenting.js create mode 100644 browser/components/customizableui/test/browser_985815_propagate_setToolbarVisibility.js create mode 100644 browser/components/customizableui/test/browser_987177_destroyWidget_xul.js create mode 100644 browser/components/customizableui/test/browser_987177_xul_wrapper_updating.js create mode 100644 browser/components/customizableui/test/browser_987492_window_api.js create mode 100644 browser/components/customizableui/test/browser_987640_charEncoding.js create mode 100644 browser/components/customizableui/test/browser_989338_saved_placements_not_resaved.js create mode 100644 browser/components/customizableui/test/browser_989751_subviewbutton_class.js create mode 100644 browser/components/customizableui/test/browser_992747_toggle_noncustomizable_toolbar.js create mode 100644 browser/components/customizableui/test/browser_993322_widget_notoolbar.js create mode 100644 browser/components/customizableui/test/browser_995164_registerArea_during_customize_mode.js create mode 100644 browser/components/customizableui/test/browser_996364_registerArea_different_properties.js create mode 100644 browser/components/customizableui/test/browser_996635_remove_non_widgets.js create mode 100644 browser/components/customizableui/test/browser_PanelMultiView.js create mode 100644 browser/components/customizableui/test/browser_PanelMultiView_focus.js create mode 100644 browser/components/customizableui/test/browser_PanelMultiView_keyboard.js create mode 100644 browser/components/customizableui/test/browser_addons_area.js create mode 100644 browser/components/customizableui/test/browser_allow_dragging_removable_false.js create mode 100644 browser/components/customizableui/test/browser_backfwd_enabled_post_customize.js create mode 100644 browser/components/customizableui/test/browser_bookmarks_empty_message.js create mode 100644 browser/components/customizableui/test/browser_bookmarks_toolbar_collapsed_restore_default.js create mode 100644 browser/components/customizableui/test/browser_bookmarks_toolbar_shown_newtab.js create mode 100644 browser/components/customizableui/test/browser_bootstrapped_custom_toolbar.js create mode 100644 browser/components/customizableui/test/browser_check_tooltips_in_navbar.js create mode 100644 browser/components/customizableui/test/browser_create_button_widget.js create mode 100644 browser/components/customizableui/test/browser_ctrl_click_panel_opening.js create mode 100644 browser/components/customizableui/test/browser_currentset_post_reset.js create mode 100644 browser/components/customizableui/test/browser_customization_context_menus.js create mode 100644 browser/components/customizableui/test/browser_customizemode_contextmenu_menubuttonstate.js create mode 100644 browser/components/customizableui/test/browser_customizemode_lwthemes.js create mode 100644 browser/components/customizableui/test/browser_customizemode_uidensity.js create mode 100644 browser/components/customizableui/test/browser_disable_commands_customize.js create mode 100644 browser/components/customizableui/test/browser_drag_outside_palette.js create mode 100644 browser/components/customizableui/test/browser_editcontrols_update.js create mode 100644 browser/components/customizableui/test/browser_exit_background_customize_mode.js create mode 100644 browser/components/customizableui/test/browser_flexible_space_area.js create mode 100644 browser/components/customizableui/test/browser_help_panel_cloning.js create mode 100644 browser/components/customizableui/test/browser_hidden_widget_overflow.js create mode 100644 browser/components/customizableui/test/browser_history_after_appMenu.js create mode 100644 browser/components/customizableui/test/browser_history_recently_closed.js create mode 100644 browser/components/customizableui/test/browser_history_recently_closed_middleclick.js create mode 100644 browser/components/customizableui/test/browser_history_restore_session.js create mode 100644 browser/components/customizableui/test/browser_insert_before_moved_node.js create mode 100644 browser/components/customizableui/test/browser_menubar_visibility.js create mode 100644 browser/components/customizableui/test/browser_newtab_button_customizemode.js create mode 100644 browser/components/customizableui/test/browser_open_from_popup.js create mode 100644 browser/components/customizableui/test/browser_open_in_lazy_tab.js create mode 100644 browser/components/customizableui/test/browser_overflow_use_subviews.js create mode 100644 browser/components/customizableui/test/browser_palette_labels.js create mode 100644 browser/components/customizableui/test/browser_panelUINotifications.js create mode 100644 browser/components/customizableui/test/browser_panelUINotifications_bannerVisibility.js create mode 100644 browser/components/customizableui/test/browser_panelUINotifications_fullscreen.js create mode 100644 browser/components/customizableui/test/browser_panelUINotifications_fullscreen_noAutoHideToolbar.js create mode 100644 browser/components/customizableui/test/browser_panelUINotifications_modals.js create mode 100644 browser/components/customizableui/test/browser_panelUINotifications_multiWindow.js create mode 100644 browser/components/customizableui/test/browser_panel_keyboard_navigation.js create mode 100644 browser/components/customizableui/test/browser_panel_locationSpecific.js create mode 100644 browser/components/customizableui/test/browser_panel_menulist.js create mode 100644 browser/components/customizableui/test/browser_panel_toggle.js create mode 100644 browser/components/customizableui/test/browser_proton_moreTools_panel.js create mode 100644 browser/components/customizableui/test/browser_proton_toolbar_hide_toolbarbuttons.js create mode 100644 browser/components/customizableui/test/browser_registerArea.js create mode 100644 browser/components/customizableui/test/browser_reload_tab.js create mode 100644 browser/components/customizableui/test/browser_remote_attribute.js create mode 100644 browser/components/customizableui/test/browser_remote_tabs_button.js create mode 100644 browser/components/customizableui/test/browser_remove_customized_specials.js create mode 100644 browser/components/customizableui/test/browser_reset_builtin_widget_currentArea.js create mode 100644 browser/components/customizableui/test/browser_reset_dom_events.js create mode 100644 browser/components/customizableui/test/browser_screenshot_button_disabled.js create mode 100644 browser/components/customizableui/test/browser_searchbar_removal.js create mode 100644 browser/components/customizableui/test/browser_sidebar_toggle.js create mode 100644 browser/components/customizableui/test/browser_switch_to_customize_mode.js create mode 100644 browser/components/customizableui/test/browser_synced_tabs_menu.js create mode 100644 browser/components/customizableui/test/browser_tabbar_big_widgets.js create mode 100644 browser/components/customizableui/test/browser_toolbar_collapsed_states.js create mode 100644 browser/components/customizableui/test/browser_touchbar_customization.js create mode 100644 browser/components/customizableui/test/browser_unified_extensions_reset.js create mode 100644 browser/components/customizableui/test/browser_widget_animation.js create mode 100644 browser/components/customizableui/test/browser_widget_recreate_events.js create mode 100644 browser/components/customizableui/test/dummy_history_item.html create mode 100644 browser/components/customizableui/test/head.js create mode 100644 browser/components/customizableui/test/support/test_967000_charEncoding_page.html create mode 100644 browser/components/customizableui/test/unit/test_unified_extensions_migration.js create mode 100644 browser/components/customizableui/test/unit/xpcshell.toml create mode 100644 browser/components/distribution.sys.mjs create mode 100644 browser/components/doh/DoHConfig.sys.mjs create mode 100644 browser/components/doh/DoHController.sys.mjs create mode 100644 browser/components/doh/DoHHeuristics.sys.mjs create mode 100644 browser/components/doh/DoHTestUtils.sys.mjs create mode 100644 browser/components/doh/TRRPerformance.sys.mjs create mode 100644 browser/components/doh/moz.build create mode 100644 browser/components/doh/test/browser/browser.toml create mode 100644 browser/components/doh/test/browser/browser_cleanFlow.js create mode 100644 browser/components/doh/test/browser/browser_dirtyEnable.js create mode 100644 browser/components/doh/test/browser/browser_doh_region.js create mode 100644 browser/components/doh/test/browser/browser_doorhangerUserReject.js create mode 100644 browser/components/doh/test/browser/browser_platformDetection.js create mode 100644 browser/components/doh/test/browser/browser_policyOverride.js create mode 100644 browser/components/doh/test/browser/browser_providerSteering.js create mode 100644 browser/components/doh/test/browser/browser_remoteSettings_newProfile.js create mode 100644 browser/components/doh/test/browser/browser_remoteSettings_rollout.js create mode 100644 browser/components/doh/test/browser/browser_rollback.js create mode 100644 browser/components/doh/test/browser/browser_throttle_heuristics.js create mode 100644 browser/components/doh/test/browser/browser_trrSelect.js create mode 100644 browser/components/doh/test/browser/browser_trrSelection_disable.js create mode 100644 browser/components/doh/test/browser/browser_userInterference.js create mode 100644 browser/components/doh/test/browser/head.js create mode 100644 browser/components/doh/test/unit/head.js create mode 100644 browser/components/doh/test/unit/test_DNSLookup.js create mode 100644 browser/components/doh/test/unit/test_LookupAggregator.js create mode 100644 browser/components/doh/test/unit/test_TRRRacer.js create mode 100644 browser/components/doh/test/unit/test_heuristics.js create mode 100644 browser/components/doh/test/unit/xpcshell.toml create mode 100644 browser/components/downloads/DownloadSpamProtection.sys.mjs create mode 100644 browser/components/downloads/DownloadsCommon.sys.mjs create mode 100644 browser/components/downloads/DownloadsMacFinderProgress.sys.mjs create mode 100644 browser/components/downloads/DownloadsTaskbar.sys.mjs create mode 100644 browser/components/downloads/DownloadsViewUI.sys.mjs create mode 100644 browser/components/downloads/DownloadsViewableInternally.sys.mjs create mode 100644 browser/components/downloads/content/allDownloadsView.js create mode 100644 browser/components/downloads/content/contentAreaDownloadsView.css create mode 100644 browser/components/downloads/content/contentAreaDownloadsView.js create mode 100644 browser/components/downloads/content/contentAreaDownloadsView.xhtml create mode 100644 browser/components/downloads/content/downloads.css create mode 100644 browser/components/downloads/content/downloads.js create mode 100644 browser/components/downloads/content/downloadsCommands.inc.xhtml create mode 100644 browser/components/downloads/content/downloadsCommands.js create mode 100644 browser/components/downloads/content/downloadsContextMenu.inc.xhtml create mode 100644 browser/components/downloads/content/downloadsPanel.inc.xhtml create mode 100644 browser/components/downloads/content/indicator.js create mode 100644 browser/components/downloads/jar.mn create mode 100644 browser/components/downloads/moz.build create mode 100644 browser/components/downloads/test/browser/blank.JPG create mode 100644 browser/components/downloads/test/browser/browser.toml create mode 100644 browser/components/downloads/test/browser/browser_about_downloads.js create mode 100644 browser/components/downloads/test/browser/browser_basic_functionality.js create mode 100644 browser/components/downloads/test/browser/browser_confirm_unblock_download.js create mode 100644 browser/components/downloads/test/browser/browser_download_is_clickable.js create mode 100644 browser/components/downloads/test/browser/browser_download_opens_on_click.js create mode 100644 browser/components/downloads/test/browser/browser_download_opens_policy.js create mode 100644 browser/components/downloads/test/browser/browser_download_overwrite.js create mode 100644 browser/components/downloads/test/browser/browser_download_spam_protection.js create mode 100644 browser/components/downloads/test/browser/browser_download_starts_in_tmp.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_autohide.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_context_menu_always_open_similar_files.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_context_menu_delete_file.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_context_menu_selection.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_keynav.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_panel_block.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_panel_context_menu.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_panel_ctrl_click.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_panel_disable_items.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_panel_dontshow.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_panel_focus.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_panel_height.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_panel_opens.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_pauseResume.js create mode 100644 browser/components/downloads/test/browser/browser_first_download_panel.js create mode 100644 browser/components/downloads/test/browser/browser_go_to_download_page.js create mode 100644 browser/components/downloads/test/browser/browser_iframe_gone_mid_download.js create mode 100644 browser/components/downloads/test/browser/browser_image_mimetype_issues.js create mode 100644 browser/components/downloads/test/browser/browser_indicatorDrop.js create mode 100644 browser/components/downloads/test/browser/browser_libraryDrop.js create mode 100644 browser/components/downloads/test/browser/browser_library_clearall.js create mode 100644 browser/components/downloads/test/browser/browser_library_select_all.js create mode 100644 browser/components/downloads/test/browser/browser_overflow_anchor.js create mode 100644 browser/components/downloads/test/browser/browser_pdfjs_preview.js create mode 100644 browser/components/downloads/test/browser/browser_tempfilename.js create mode 100644 browser/components/downloads/test/browser/foo.txt create mode 100644 browser/components/downloads/test/browser/foo.txt^headers^ create mode 100644 browser/components/downloads/test/browser/head.js create mode 100644 browser/components/downloads/test/browser/not-really-a-jpeg.jpeg create mode 100644 browser/components/downloads/test/browser/not-really-a-jpeg.jpeg^headers^ create mode 100644 browser/components/downloads/test/browser/test_spammy_page.html create mode 100644 browser/components/downloads/test/unit/head.js create mode 100644 browser/components/downloads/test/unit/test_DownloadLastDir_basics.js create mode 100644 browser/components/downloads/test/unit/test_DownloadsCommon_getMimeInfo.js create mode 100644 browser/components/downloads/test/unit/test_DownloadsCommon_isFileOfType.js create mode 100644 browser/components/downloads/test/unit/test_DownloadsViewableInternally.js create mode 100644 browser/components/downloads/test/unit/xpcshell.toml create mode 100644 browser/components/enterprisepolicies/Policies.sys.mjs create mode 100644 browser/components/enterprisepolicies/content/aboutPolicies.css create mode 100644 browser/components/enterprisepolicies/content/aboutPolicies.html create mode 100644 browser/components/enterprisepolicies/content/aboutPolicies.js create mode 100644 browser/components/enterprisepolicies/content/policies-active.svg create mode 100644 browser/components/enterprisepolicies/content/policies-documentation.svg create mode 100644 browser/components/enterprisepolicies/content/policies-error.svg create mode 100644 browser/components/enterprisepolicies/docs/index.rst create mode 100644 browser/components/enterprisepolicies/helpers/BookmarksPolicies.sys.mjs create mode 100644 browser/components/enterprisepolicies/helpers/ProxyPolicies.sys.mjs create mode 100644 browser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs create mode 100644 browser/components/enterprisepolicies/helpers/moz.build create mode 100644 browser/components/enterprisepolicies/helpers/sample.json create mode 100644 browser/components/enterprisepolicies/helpers/sample_bookmarks.json create mode 100644 browser/components/enterprisepolicies/helpers/sample_proxy.json create mode 100644 browser/components/enterprisepolicies/helpers/sample_websitefilter.json create mode 100644 browser/components/enterprisepolicies/jar.mn create mode 100644 browser/components/enterprisepolicies/moz.build create mode 100644 browser/components/enterprisepolicies/schemas/configuration.json create mode 100644 browser/components/enterprisepolicies/schemas/moz.build create mode 100644 browser/components/enterprisepolicies/schemas/policies-schema.json create mode 100644 browser/components/enterprisepolicies/schemas/schema.sys.mjs create mode 100644 browser/components/enterprisepolicies/tests/browser/301.sjs create mode 100644 browser/components/enterprisepolicies/tests/browser/302.sjs create mode 100644 browser/components/enterprisepolicies/tests/browser/404.sjs create mode 100644 browser/components/enterprisepolicies/tests/browser/browser.toml create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policies_getActivePolicies.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policies_notice_in_aboutpreferences.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policies_setAndLockPref_API.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_allowfileselectiondialogs.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_app_auto_update.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_app_update.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_background_app_update.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_block_about.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_block_about_support.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_block_set_desktop_background.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_bookmarks.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_cookie_settings.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_disable_feedback_commands.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_disable_fxaccounts.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_disable_masterpassword.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_disable_password_reveal.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_disable_pocket.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_disable_popup_blocker.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_disable_privatebrowsing.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_disable_profile_import.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_disable_profile_reset.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_disable_safemode.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_disable_shield.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_disable_telemetry.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_display_bookmarks.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_display_menu.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_downloads.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_extensions.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings2.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_firefoxhome.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_firefoxsuggest.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_handlers.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_masterpassword.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_masterpassword_aboutlogins.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_masterpassword_doorhanger.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_offertosavelogins.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_override_postupdatepage.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_pageinfo_permissions.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_passwordmanager.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_search_engine.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_searchbar.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_set_homepage.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_set_startpage.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_support_menu.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_usermessaging.js create mode 100644 browser/components/enterprisepolicies/tests/browser/browser_policy_websitefilter.js create mode 100644 browser/components/enterprisepolicies/tests/browser/disable_app_update/browser.toml create mode 100644 browser/components/enterprisepolicies/tests/browser/disable_app_update/browser_policy_disable_app_update.js create mode 100644 browser/components/enterprisepolicies/tests/browser/disable_app_update/config_disable_app_update.json create mode 100644 browser/components/enterprisepolicies/tests/browser/disable_default_bookmarks/bookmarks_policies.json create mode 100644 browser/components/enterprisepolicies/tests/browser/disable_default_bookmarks/browser.toml create mode 100644 browser/components/enterprisepolicies/tests/browser/disable_default_bookmarks/browser_policy_no_default_bookmarks.js create mode 100644 browser/components/enterprisepolicies/tests/browser/disable_developer_tools/browser.toml create mode 100644 browser/components/enterprisepolicies/tests/browser/disable_developer_tools/browser_policy_disable_developer_tools.js create mode 100644 browser/components/enterprisepolicies/tests/browser/disable_developer_tools/config_disable_developer_tools.json create mode 100644 browser/components/enterprisepolicies/tests/browser/disable_forget_button/browser.toml create mode 100644 browser/components/enterprisepolicies/tests/browser/disable_forget_button/browser_policy_disable_forgetbutton.js create mode 100644 browser/components/enterprisepolicies/tests/browser/disable_forget_button/forget_button.json create mode 100644 browser/components/enterprisepolicies/tests/browser/disable_fxscreenshots/browser.toml create mode 100644 browser/components/enterprisepolicies/tests/browser/disable_fxscreenshots/browser_policy_disable_fxscreenshots.js create mode 100644 browser/components/enterprisepolicies/tests/browser/disable_fxscreenshots/config_disable_fxscreenshots.json create mode 100644 browser/components/enterprisepolicies/tests/browser/extensionsettings.html create mode 100644 browser/components/enterprisepolicies/tests/browser/hardware_acceleration/browser.toml create mode 100644 browser/components/enterprisepolicies/tests/browser/hardware_acceleration/browser_policy_hardware_acceleration.js create mode 100644 browser/components/enterprisepolicies/tests/browser/hardware_acceleration/disable_hardware_acceleration.json create mode 100644 browser/components/enterprisepolicies/tests/browser/head.js create mode 100644 browser/components/enterprisepolicies/tests/browser/homepage_button/browser.toml create mode 100644 browser/components/enterprisepolicies/tests/browser/homepage_button/browser_show_home_button_with_homepage_policy.js create mode 100644 browser/components/enterprisepolicies/tests/browser/homepage_button/homepage_policies.json create mode 100644 browser/components/enterprisepolicies/tests/browser/managedbookmarks/browser.toml create mode 100644 browser/components/enterprisepolicies/tests/browser/managedbookmarks/browser_policy_managedbookmarks.js create mode 100644 browser/components/enterprisepolicies/tests/browser/managedbookmarks/managedbookmarks.json create mode 100644 browser/components/enterprisepolicies/tests/browser/opensearch.html create mode 100644 browser/components/enterprisepolicies/tests/browser/opensearchEngine.xml create mode 100644 browser/components/enterprisepolicies/tests/browser/policy_websitefilter_block.html create mode 100644 browser/components/enterprisepolicies/tests/browser/policy_websitefilter_exception.html create mode 100644 browser/components/enterprisepolicies/tests/browser/policy_websitefilter_savelink.html create mode 100644 browser/components/enterprisepolicies/tests/browser/policytest_v0.1.xpi create mode 100644 browser/components/enterprisepolicies/tests/browser/policytest_v0.2.xpi create mode 100644 browser/components/enterprisepolicies/tests/browser/show_home_button/browser.toml create mode 100644 browser/components/enterprisepolicies/tests/browser/show_home_button/browser_policy_show_home_button.js create mode 100644 browser/components/enterprisepolicies/tests/browser/show_home_button/show_home_button_policies.json create mode 100644 browser/components/enterprisepolicies/tests/moz.build create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/config_popups_cookies_addons.json create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/head.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/policytest_v0.1.xpi create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_3rdparty.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_addon_update.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_appupdatepin.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_appupdateurl.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_bug1658259.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_cleanup.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_clear_blocked_cookies.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_containers.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_defaultbrowsercheck.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_empty_policy.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_exempt_domain_file_type_pairs_from_file_type_download_warnings.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_extensions.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_extensionsettings.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_macosparser_unflatten.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_permissions.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_policy_search_engine.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_popups_cookies_addons.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_preferences.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_proxy.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_requestedlocales.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_runOnce_helper.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_simple_pref_policies.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_sorted_alphabetically.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/test_telemetry.js create mode 100644 browser/components/enterprisepolicies/tests/xpcshell/xpcshell.toml create mode 100644 browser/components/extensions/.eslintrc.js create mode 100644 browser/components/extensions/ExtensionBrowsingData.sys.mjs create mode 100644 browser/components/extensions/ExtensionControlledPopup.sys.mjs create mode 100644 browser/components/extensions/ExtensionPopups.sys.mjs create mode 100644 browser/components/extensions/child/.eslintrc.js create mode 100644 browser/components/extensions/child/ext-browser-content-only.js create mode 100644 browser/components/extensions/child/ext-browser.js create mode 100644 browser/components/extensions/child/ext-devtools-inspectedWindow.js create mode 100644 browser/components/extensions/child/ext-devtools-network.js create mode 100644 browser/components/extensions/child/ext-devtools-panels.js create mode 100644 browser/components/extensions/child/ext-devtools.js create mode 100644 browser/components/extensions/child/ext-menus-child.js create mode 100644 browser/components/extensions/child/ext-menus.js create mode 100644 browser/components/extensions/child/ext-omnibox.js create mode 100644 browser/components/extensions/child/ext-tabs.js create mode 100644 browser/components/extensions/ext-browser.json create mode 100644 browser/components/extensions/extension-popup-panel.css create mode 100644 browser/components/extensions/extension.css create mode 100644 browser/components/extensions/extensions-browser.manifest create mode 100644 browser/components/extensions/jar.mn create mode 100644 browser/components/extensions/moz.build create mode 100644 browser/components/extensions/parent/.eslintrc.js create mode 100644 browser/components/extensions/parent/ext-bookmarks.js create mode 100644 browser/components/extensions/parent/ext-browser.js create mode 100644 browser/components/extensions/parent/ext-browserAction.js create mode 100644 browser/components/extensions/parent/ext-chrome-settings-overrides.js create mode 100644 browser/components/extensions/parent/ext-commands.js create mode 100644 browser/components/extensions/parent/ext-devtools-inspectedWindow.js create mode 100644 browser/components/extensions/parent/ext-devtools-network.js create mode 100644 browser/components/extensions/parent/ext-devtools-panels.js create mode 100644 browser/components/extensions/parent/ext-devtools.js create mode 100644 browser/components/extensions/parent/ext-find.js create mode 100644 browser/components/extensions/parent/ext-history.js create mode 100644 browser/components/extensions/parent/ext-menus.js create mode 100644 browser/components/extensions/parent/ext-normandyAddonStudy.js create mode 100644 browser/components/extensions/parent/ext-omnibox.js create mode 100644 browser/components/extensions/parent/ext-pageAction.js create mode 100644 browser/components/extensions/parent/ext-pkcs11.js create mode 100644 browser/components/extensions/parent/ext-search.js create mode 100644 browser/components/extensions/parent/ext-sessions.js create mode 100644 browser/components/extensions/parent/ext-sidebarAction.js create mode 100644 browser/components/extensions/parent/ext-tabs.js create mode 100644 browser/components/extensions/parent/ext-topSites.js create mode 100644 browser/components/extensions/parent/ext-url-overrides.js create mode 100644 browser/components/extensions/parent/ext-windows.js create mode 100644 browser/components/extensions/schemas/LICENSE-CHROMIUM create mode 100644 browser/components/extensions/schemas/README.md create mode 100644 browser/components/extensions/schemas/bookmarks.json create mode 100644 browser/components/extensions/schemas/chrome_settings_overrides.json create mode 100644 browser/components/extensions/schemas/commands.json create mode 100644 browser/components/extensions/schemas/devtools.json create mode 100644 browser/components/extensions/schemas/devtools_inspected_window.json create mode 100644 browser/components/extensions/schemas/devtools_network.json create mode 100644 browser/components/extensions/schemas/devtools_panels.json create mode 100644 browser/components/extensions/schemas/find.json create mode 100644 browser/components/extensions/schemas/history.json create mode 100644 browser/components/extensions/schemas/jar.mn create mode 100644 browser/components/extensions/schemas/menus.json create mode 100644 browser/components/extensions/schemas/menus_child.json create mode 100644 browser/components/extensions/schemas/moz.build create mode 100644 browser/components/extensions/schemas/normandyAddonStudy.json create mode 100644 browser/components/extensions/schemas/omnibox.json create mode 100644 browser/components/extensions/schemas/pkcs11.json create mode 100644 browser/components/extensions/schemas/search.json create mode 100644 browser/components/extensions/schemas/sessions.json create mode 100644 browser/components/extensions/schemas/sidebar_action.json create mode 100644 browser/components/extensions/schemas/tabs.json create mode 100644 browser/components/extensions/schemas/top_sites.json create mode 100644 browser/components/extensions/schemas/url_overrides.json create mode 100644 browser/components/extensions/schemas/windows.json create mode 100644 browser/components/extensions/test/AppUiTestDelegate.sys.mjs create mode 100644 browser/components/extensions/test/browser/.eslintrc.js create mode 100644 browser/components/extensions/test/browser/authenticate.sjs create mode 100644 browser/components/extensions/test/browser/browser-private.toml create mode 100644 browser/components/extensions/test/browser/browser.toml create mode 100644 browser/components/extensions/test/browser/browser_AMBrowserExtensionsImport.js create mode 100644 browser/components/extensions/test/browser/browser_ExtensionControlledPopup.js create mode 100644 browser/components/extensions/test/browser/browser_ext_action_popup_allowed_urls.js create mode 100644 browser/components/extensions/test/browser/browser_ext_activeScript.js create mode 100644 browser/components/extensions/test/browser/browser_ext_addon_debugging_netmonitor.js create mode 100644 browser/components/extensions/test/browser/browser_ext_autocompletepopup.js create mode 100644 browser/components/extensions/test/browser/browser_ext_autoplayInBackground.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_activeTab.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_area.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_click_types.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_context.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_contextMenu.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_disabled.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_experiment.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_getUserSettings.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_incognito.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_keyclick.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_pageAction_icon.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_pageAction_icon_permissions.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_popup.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_popup_port.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_popup_preload.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_popup_preload_smoketest.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_popup_resize.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_popup_resize_bottom.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_simple.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_telemetry.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_theme_icons.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browsingData_cookieStoreId.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browsingData_formData.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browsingData_history.js create mode 100644 browser/components/extensions/test/browser/browser_ext_chrome_settings_overrides_home.js create mode 100644 browser/components/extensions/test/browser/browser_ext_commands_execute_browser_action.js create mode 100644 browser/components/extensions/test/browser/browser_ext_commands_execute_page_action.js create mode 100644 browser/components/extensions/test/browser/browser_ext_commands_execute_sidebar_action.js create mode 100644 browser/components/extensions/test/browser/browser_ext_commands_getAll.js create mode 100644 browser/components/extensions/test/browser/browser_ext_commands_onChanged.js create mode 100644 browser/components/extensions/test/browser/browser_ext_commands_onCommand.js create mode 100644 browser/components/extensions/test/browser/browser_ext_commands_update.js create mode 100644 browser/components/extensions/test/browser/browser_ext_connect_and_move_tabs.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contentscript_animate.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contentscript_connect.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contentscript_cross_docGroup_adoption.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contentscript_cross_docGroup_adoption_xhr.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contentscript_dataTransfer_files.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contentscript_in_parent.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contentscript_incognito.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contentscript_nontab_connect.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contentscript_sender_url.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus_bookmarks.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus_checkboxes.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus_commands.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus_icons.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus_onclick.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus_radioGroups.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus_srcUrl_redirect.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus_targetUrlPatterns.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus_uninstall.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus_urlPatterns.js create mode 100644 browser/components/extensions/test/browser/browser_ext_currentWindow.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_eval_bindings.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_eval_file.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_reload.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_targetSwitch.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_network.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_network_targetSwitch.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_optional.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_page.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_page_incognito.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_panel.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_panels_elements.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_panels_elements_sidebar.js create mode 100644 browser/components/extensions/test/browser/browser_ext_find.js create mode 100644 browser/components/extensions/test/browser/browser_ext_getViews.js create mode 100644 browser/components/extensions/test/browser/browser_ext_history_redirect.js create mode 100644 browser/components/extensions/test/browser/browser_ext_identity_indication.js create mode 100644 browser/components/extensions/test/browser/browser_ext_incognito_popup.js create mode 100644 browser/components/extensions/test/browser/browser_ext_incognito_views.js create mode 100644 browser/components/extensions/test/browser/browser_ext_lastError.js create mode 100644 browser/components/extensions/test/browser/browser_ext_management.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_accesskey.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_activeTab.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_capture_secondary_click.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_errors.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_event_order.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_eventpage.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_events.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_events_after_context_destroy.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_incognito.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_refresh.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_replace_menu.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_replace_menu_context.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_replace_menu_permissions.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_targetElement.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_targetElement_extension.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_targetElement_shadow.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_viewType.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_visible.js create mode 100644 browser/components/extensions/test/browser/browser_ext_mousewheel_zoom.js create mode 100644 browser/components/extensions/test/browser/browser_ext_nontab_process_switch.js create mode 100644 browser/components/extensions/test/browser/browser_ext_omnibox.js create mode 100644 browser/components/extensions/test/browser/browser_ext_openPanel.js create mode 100644 browser/components/extensions/test/browser/browser_ext_optionsPage_activity.js create mode 100644 browser/components/extensions/test/browser/browser_ext_optionsPage_browser_style.js create mode 100644 browser/components/extensions/test/browser/browser_ext_optionsPage_links_open_in_tabs.js create mode 100644 browser/components/extensions/test/browser/browser_ext_optionsPage_modals.js create mode 100644 browser/components/extensions/test/browser/browser_ext_optionsPage_popups.js create mode 100644 browser/components/extensions/test/browser/browser_ext_optionsPage_privileges.js create mode 100644 browser/components/extensions/test/browser/browser_ext_originControls.js create mode 100644 browser/components/extensions/test/browser/browser_ext_pageAction_activeTab.js create mode 100644 browser/components/extensions/test/browser/browser_ext_pageAction_click_types.js create mode 100644 browser/components/extensions/test/browser/browser_ext_pageAction_context.js create mode 100644 browser/components/extensions/test/browser/browser_ext_pageAction_contextMenu.js create mode 100644 browser/components/extensions/test/browser/browser_ext_pageAction_popup.js create mode 100644 browser/components/extensions/test/browser/browser_ext_pageAction_popup_resize.js create mode 100644 browser/components/extensions/test/browser/browser_ext_pageAction_show_matches.js create mode 100644 browser/components/extensions/test/browser/browser_ext_pageAction_simple.js create mode 100644 browser/components/extensions/test/browser/browser_ext_pageAction_telemetry.js create mode 100644 browser/components/extensions/test/browser/browser_ext_pageAction_title.js create mode 100644 browser/components/extensions/test/browser/browser_ext_persistent_storage_permission_indication.js create mode 100644 browser/components/extensions/test/browser/browser_ext_popup_api_injection.js create mode 100644 browser/components/extensions/test/browser/browser_ext_popup_background.js create mode 100644 browser/components/extensions/test/browser/browser_ext_popup_corners.js create mode 100644 browser/components/extensions/test/browser/browser_ext_popup_focus.js create mode 100644 browser/components/extensions/test/browser/browser_ext_popup_links_open_in_tabs.js create mode 100644 browser/components/extensions/test/browser/browser_ext_popup_requestPermission.js create mode 100644 browser/components/extensions/test/browser/browser_ext_popup_select.js create mode 100644 browser/components/extensions/test/browser/browser_ext_popup_select_in_oopif.js create mode 100644 browser/components/extensions/test/browser/browser_ext_popup_sendMessage.js create mode 100644 browser/components/extensions/test/browser/browser_ext_popup_shutdown.js create mode 100644 browser/components/extensions/test/browser/browser_ext_port_disconnect_on_crash.js create mode 100644 browser/components/extensions/test/browser/browser_ext_port_disconnect_on_window_close.js create mode 100644 browser/components/extensions/test/browser/browser_ext_reload_manifest_cache.js create mode 100644 browser/components/extensions/test/browser/browser_ext_request_permissions.js create mode 100644 browser/components/extensions/test/browser/browser_ext_runtime_onPerformanceWarning.js create mode 100644 browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage.js create mode 100644 browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage_uninstall.js create mode 100644 browser/components/extensions/test/browser/browser_ext_runtime_setUninstallURL.js create mode 100644 browser/components/extensions/test/browser/browser_ext_search.js create mode 100644 browser/components/extensions/test/browser/browser_ext_search_favicon.js create mode 100644 browser/components/extensions/test/browser/browser_ext_search_query.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_forgetClosedTab.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_forgetClosedWindow.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed_private.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed_tabs.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_incognito.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_restore.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_restoreTab.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_restore_private.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_window_tab_value.js create mode 100644 browser/components/extensions/test/browser/browser_ext_settings_overrides_default_search.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebarAction.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebarAction_browser_style.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebarAction_click.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebarAction_context.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebarAction_contextMenu.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebarAction_httpAuth.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebarAction_incognito.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebarAction_runtime.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebarAction_tabs.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebarAction_windows.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebar_requestPermission.js create mode 100644 browser/components/extensions/test/browser/browser_ext_simple.js create mode 100644 browser/components/extensions/test/browser/browser_ext_slow_script.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tab_runtimeConnect.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_attention.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_audio.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_autoDiscardable.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_containerIsolation.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_cookieStoreId.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_cookieStoreId_private.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_create.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_create_invalid_url.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_create_url.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_discard.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_discard_reversed.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_discarded.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_duplicate.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_events.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_events_order.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_executeScript.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_executeScript_about_blank.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_executeScript_bad.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_executeScript_file.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_executeScript_good.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_executeScript_multiple.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_executeScript_no_create.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_executeScript_runAt.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_getCurrent.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_goBack_goForward.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_hide.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_hide_update.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_highlight.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_incognito_not_allowed.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_insertCSS.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_lastAccessed.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_lazy.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_move_array.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_move_array_multiple_windows.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_move_discarded.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_move_window.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_move_window_multiple.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_move_window_pinned.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_newtab_private.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_onCreated.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_onHighlighted.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_onUpdated.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_onUpdated_filter.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_opener.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_printPreview.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_query.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_readerMode.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_reload.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_reload_bypass_cache.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_remove.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_removeCSS.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_saveAsPDF.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_sendMessage.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_sharingState.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_successors.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_update.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_update_highlighted.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_update_url.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_warmup.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_zoom.js create mode 100644 browser/components/extensions/test/browser/browser_ext_themes_validation.js create mode 100644 browser/components/extensions/test/browser/browser_ext_topSites.js create mode 100644 browser/components/extensions/test/browser/browser_ext_url_overrides_newtab.js create mode 100644 browser/components/extensions/test/browser/browser_ext_user_events.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webNavigation_containerIsolation.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webNavigation_frameId0.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webNavigation_getFrames.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_contextmenu.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_named_window.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_subframe_window_open.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_window_open.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webNavigation_urlbar_transitions.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webRequest.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webRequest_error_after_stopped_or_closed.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webrtc.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_allowScriptsToClose.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_create.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_create_cookieStoreId.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_create_params.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_create_tabId.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_create_url.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_events.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_incognito.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_remove.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_size.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_update.js create mode 100644 browser/components/extensions/test/browser/browser_legacy_recent_tabs.toml create mode 100644 browser/components/extensions/test/browser/browser_toolbar_prefers_color_scheme.js create mode 100644 browser/components/extensions/test/browser/browser_unified_extensions.js create mode 100644 browser/components/extensions/test/browser/browser_unified_extensions_accessibility.js create mode 100644 browser/components/extensions/test/browser/browser_unified_extensions_context_menu.js create mode 100644 browser/components/extensions/test/browser/browser_unified_extensions_cui.js create mode 100644 browser/components/extensions/test/browser/browser_unified_extensions_doorhangers.js create mode 100644 browser/components/extensions/test/browser/browser_unified_extensions_messages.js create mode 100644 browser/components/extensions/test/browser/browser_unified_extensions_overflowable_toolbar.js create mode 100644 browser/components/extensions/test/browser/context.html create mode 100644 browser/components/extensions/test/browser/context_frame.html create mode 100644 browser/components/extensions/test/browser/context_tabs_onUpdated_iframe.html create mode 100644 browser/components/extensions/test/browser/context_tabs_onUpdated_page.html create mode 100644 browser/components/extensions/test/browser/context_with_redirect.html create mode 100644 browser/components/extensions/test/browser/ctxmenu-image.png create mode 100644 browser/components/extensions/test/browser/empty.xpi create mode 100644 browser/components/extensions/test/browser/file_bypass_cache.sjs create mode 100644 browser/components/extensions/test/browser/file_dataTransfer_files.html create mode 100644 browser/components/extensions/test/browser/file_dummy.html create mode 100644 browser/components/extensions/test/browser/file_find_frames.html create mode 100644 browser/components/extensions/test/browser/file_has_non_web_controlled_blank_page_link.html create mode 100644 browser/components/extensions/test/browser/file_iframe_document.html create mode 100644 browser/components/extensions/test/browser/file_inspectedwindow_eval.html create mode 100644 browser/components/extensions/test/browser/file_inspectedwindow_reload_target.sjs create mode 100644 browser/components/extensions/test/browser/file_popup_api_injection_a.html create mode 100644 browser/components/extensions/test/browser/file_popup_api_injection_b.html create mode 100644 browser/components/extensions/test/browser/file_slowed_document.sjs create mode 100644 browser/components/extensions/test/browser/file_title.html create mode 100644 browser/components/extensions/test/browser/file_with_example_com_frame.html create mode 100644 browser/components/extensions/test/browser/file_with_xorigin_frame.html create mode 100644 browser/components/extensions/test/browser/head.js create mode 100644 browser/components/extensions/test/browser/head_browserAction.js create mode 100644 browser/components/extensions/test/browser/head_devtools.js create mode 100644 browser/components/extensions/test/browser/head_pageAction.js create mode 100644 browser/components/extensions/test/browser/head_sessions.js create mode 100644 browser/components/extensions/test/browser/head_unified_extensions.js create mode 100644 browser/components/extensions/test/browser/head_webNavigation.js create mode 100644 browser/components/extensions/test/browser/redirect_to.sjs create mode 100644 browser/components/extensions/test/browser/search-engines/another/manifest.json create mode 100644 browser/components/extensions/test/browser/search-engines/basic/manifest.json create mode 100644 browser/components/extensions/test/browser/search-engines/engines.json create mode 100644 browser/components/extensions/test/browser/search-engines/simple/manifest.json create mode 100644 browser/components/extensions/test/browser/searchSuggestionEngine.sjs create mode 100644 browser/components/extensions/test/browser/searchSuggestionEngine.xml create mode 100644 browser/components/extensions/test/browser/silence.ogg create mode 100644 browser/components/extensions/test/browser/wait-a-bit.sjs create mode 100644 browser/components/extensions/test/browser/webNav_createdTarget.html create mode 100644 browser/components/extensions/test/browser/webNav_createdTargetSource.html create mode 100644 browser/components/extensions/test/browser/webNav_createdTargetSource_subframe.html create mode 100644 browser/components/extensions/test/mochitest/.eslintrc.js create mode 100644 browser/components/extensions/test/mochitest/mochitest.toml create mode 100644 browser/components/extensions/test/mochitest/test_ext_all_apis.html create mode 100644 browser/components/extensions/test/xpcshell/.eslintrc.js create mode 100644 browser/components/extensions/test/xpcshell/data/test/manifest.json create mode 100644 browser/components/extensions/test/xpcshell/data/test2/manifest.json create mode 100644 browser/components/extensions/test/xpcshell/head.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_bookmarks.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_browsingData_downloads.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_browsingData_passwords.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_browsingData_settings.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_home.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_update.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_distribution_popup.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_history.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_homepage_overrides_private.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_manifest.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_manifest_commands.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_manifest_omnibox.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_manifest_permissions.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_menu_caller.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_menu_startup.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_normandyAddonStudy.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_pageAction_shutdown.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_pkcs11_management.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_settings_overrides_defaults.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search_mozParam.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_settings_overrides_shutdown.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_settings_validate.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_topSites.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_url_overrides_newtab.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_url_overrides_newtab_update.js create mode 100644 browser/components/extensions/test/xpcshell/xpcshell.toml create mode 100644 browser/components/firefoxview/OpenTabs.sys.mjs create mode 100644 browser/components/firefoxview/card-container.css create mode 100644 browser/components/firefoxview/card-container.mjs create mode 100644 browser/components/firefoxview/content/callout-tab-pickup-dark.svg create mode 100644 browser/components/firefoxview/content/callout-tab-pickup.svg create mode 100644 browser/components/firefoxview/content/category-history.svg create mode 100644 browser/components/firefoxview/content/category-opentabs.svg create mode 100644 browser/components/firefoxview/content/category-recentbrowsing.svg create mode 100644 browser/components/firefoxview/content/category-recentlyclosed.svg create mode 100644 browser/components/firefoxview/content/category-syncedtabs.svg create mode 100644 browser/components/firefoxview/content/history-empty.svg create mode 100644 browser/components/firefoxview/content/recentlyclosed-empty.svg create mode 100644 browser/components/firefoxview/content/synced-tabs-error.svg create mode 100644 browser/components/firefoxview/firefox-view-notification-manager.sys.mjs create mode 100644 browser/components/firefoxview/firefox-view-places-query.sys.mjs create mode 100644 browser/components/firefoxview/firefox-view-synced-tabs-error-handler.sys.mjs create mode 100644 browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs create mode 100644 browser/components/firefoxview/firefoxview.css create mode 100644 browser/components/firefoxview/firefoxview.html create mode 100644 browser/components/firefoxview/firefoxview.mjs create mode 100644 browser/components/firefoxview/fxview-category-button.css create mode 100644 browser/components/firefoxview/fxview-category-navigation.css create mode 100644 browser/components/firefoxview/fxview-category-navigation.mjs create mode 100644 browser/components/firefoxview/fxview-empty-state.css create mode 100644 browser/components/firefoxview/fxview-empty-state.mjs create mode 100644 browser/components/firefoxview/fxview-search-textbox.css create mode 100644 browser/components/firefoxview/fxview-search-textbox.mjs create mode 100644 browser/components/firefoxview/fxview-tab-list.css create mode 100644 browser/components/firefoxview/fxview-tab-list.mjs create mode 100644 browser/components/firefoxview/fxview-tab-row.css create mode 100644 browser/components/firefoxview/helpers.mjs create mode 100644 browser/components/firefoxview/history.css create mode 100644 browser/components/firefoxview/history.mjs create mode 100644 browser/components/firefoxview/jar.mn create mode 100644 browser/components/firefoxview/moz.build create mode 100644 browser/components/firefoxview/opentabs.mjs create mode 100644 browser/components/firefoxview/recentbrowsing.mjs create mode 100644 browser/components/firefoxview/recentlyclosed.mjs create mode 100644 browser/components/firefoxview/syncedtabs.mjs create mode 100644 browser/components/firefoxview/tests/browser/FirefoxViewTestUtils.sys.mjs create mode 100644 browser/components/firefoxview/tests/browser/browser.toml create mode 100644 browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js create mode 100644 browser/components/firefoxview/tests/browser/browser_entrypoint_management.js create mode 100644 browser/components/firefoxview/tests/browser/browser_feature_callout.js create mode 100644 browser/components/firefoxview/tests/browser/browser_feature_callout_position.js create mode 100644 browser/components/firefoxview/tests/browser/browser_feature_callout_resize.js create mode 100644 browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js create mode 100644 browser/components/firefoxview/tests/browser/browser_feature_callout_theme.js create mode 100644 browser/components/firefoxview/tests/browser/browser_firefoxview.js create mode 100644 browser/components/firefoxview/tests/browser/browser_firefoxview_general_telemetry.js create mode 100644 browser/components/firefoxview/tests/browser/browser_firefoxview_navigation.js create mode 100644 browser/components/firefoxview/tests/browser/browser_firefoxview_paused.js create mode 100644 browser/components/firefoxview/tests/browser/browser_firefoxview_search_telemetry.js create mode 100644 browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js create mode 100644 browser/components/firefoxview/tests/browser/browser_firefoxview_virtual_list.js create mode 100644 browser/components/firefoxview/tests/browser/browser_history_firefoxview.js create mode 100644 browser/components/firefoxview/tests/browser/browser_notification_dot.js create mode 100644 browser/components/firefoxview/tests/browser/browser_opentabs_cards.js create mode 100644 browser/components/firefoxview/tests/browser/browser_opentabs_changes.js create mode 100644 browser/components/firefoxview/tests/browser/browser_opentabs_firefoxview.js create mode 100644 browser/components/firefoxview/tests/browser/browser_opentabs_recency.js create mode 100644 browser/components/firefoxview/tests/browser/browser_opentabs_tab_indicators.js create mode 100644 browser/components/firefoxview/tests/browser/browser_recentlyclosed_firefoxview.js create mode 100644 browser/components/firefoxview/tests/browser/browser_reload_firefoxview.js create mode 100644 browser/components/firefoxview/tests/browser/browser_syncedtabs_errors_firefoxview.js create mode 100644 browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js create mode 100644 browser/components/firefoxview/tests/browser/browser_tab_close_last_tab.js create mode 100644 browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js create mode 100644 browser/components/firefoxview/tests/browser/head.js create mode 100644 browser/components/firefoxview/tests/chrome/chrome.toml create mode 100644 browser/components/firefoxview/tests/chrome/test_card_container.html create mode 100644 browser/components/firefoxview/tests/chrome/test_fxview_category_navigation.html create mode 100644 browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html create mode 100644 browser/components/firefoxview/triage.json create mode 100644 browser/components/firefoxview/view-opentabs.css create mode 100644 browser/components/firefoxview/view-syncedtabs.css create mode 100644 browser/components/firefoxview/viewpage.mjs create mode 100644 browser/components/installerprefs/InstallerPrefs.sys.mjs create mode 100644 browser/components/installerprefs/components.conf create mode 100644 browser/components/installerprefs/moz.build create mode 100644 browser/components/installerprefs/test/unit/head.js create mode 100644 browser/components/installerprefs/test/unit/test_empty_prefs_list.js create mode 100644 browser/components/installerprefs/test/unit/test_invalid_name.js create mode 100644 browser/components/installerprefs/test/unit/test_nonbool_pref.js create mode 100644 browser/components/installerprefs/test/unit/test_pref_change.js create mode 100644 browser/components/installerprefs/test/unit/test_pref_values.js create mode 100644 browser/components/installerprefs/test/unit/xpcshell.toml create mode 100644 browser/components/ion/content/ion.css create mode 100644 browser/components/ion/content/ion.ftl create mode 100644 browser/components/ion/content/ion.html create mode 100644 browser/components/ion/content/ion.js create mode 100644 browser/components/ion/jar.mn create mode 100644 browser/components/ion/moz.build create mode 100644 browser/components/ion/schemas/IonContentSchema.json create mode 100644 browser/components/ion/schemas/IonStudyAddonsSchema.json create mode 100644 browser/components/ion/test/browser/browser.toml create mode 100644 browser/components/ion/test/browser/browser_ion_ui.js create mode 100644 browser/components/messagepreview/actors/AboutMessagePreviewChild.sys.mjs create mode 100644 browser/components/messagepreview/actors/AboutMessagePreviewParent.sys.mjs create mode 100644 browser/components/messagepreview/jar.mn create mode 100644 browser/components/messagepreview/limelight.svg create mode 100644 browser/components/messagepreview/messagepreview.css create mode 100644 browser/components/messagepreview/messagepreview.html create mode 100644 browser/components/messagepreview/messagepreview.js create mode 100644 browser/components/messagepreview/moz.build create mode 100644 browser/components/metrics.yaml create mode 100644 browser/components/migration/.eslintrc.js create mode 100644 browser/components/migration/360seMigrationUtils.sys.mjs create mode 100644 browser/components/migration/ChromeMacOSLoginCrypto.sys.mjs create mode 100644 browser/components/migration/ChromeMigrationUtils.sys.mjs create mode 100644 browser/components/migration/ChromeProfileMigrator.sys.mjs create mode 100644 browser/components/migration/ChromeWindowsLoginCrypto.sys.mjs create mode 100644 browser/components/migration/ESEDBReader.sys.mjs create mode 100644 browser/components/migration/EdgeProfileMigrator.sys.mjs create mode 100644 browser/components/migration/FileMigrators.sys.mjs create mode 100644 browser/components/migration/FirefoxProfileMigrator.sys.mjs create mode 100644 browser/components/migration/IEProfileMigrator.sys.mjs create mode 100644 browser/components/migration/InternalTestingProfileMigrator.sys.mjs create mode 100644 browser/components/migration/MSMigrationUtils.sys.mjs create mode 100644 browser/components/migration/MigrationUtils.sys.mjs create mode 100644 browser/components/migration/MigrationWizardChild.sys.mjs create mode 100644 browser/components/migration/MigrationWizardParent.sys.mjs create mode 100644 browser/components/migration/MigratorBase.sys.mjs create mode 100644 browser/components/migration/ProfileMigrator.sys.mjs create mode 100644 browser/components/migration/SafariProfileMigrator.sys.mjs create mode 100644 browser/components/migration/components.conf create mode 100644 browser/components/migration/content/aboutWelcomeBack.xhtml create mode 100644 browser/components/migration/content/brands/360.png create mode 100644 browser/components/migration/content/brands/brave.png create mode 100644 browser/components/migration/content/brands/canary.png create mode 100644 browser/components/migration/content/brands/chrome.png create mode 100644 browser/components/migration/content/brands/chromium.png create mode 100644 browser/components/migration/content/brands/edge.png create mode 100644 browser/components/migration/content/brands/edgebeta.png create mode 100644 browser/components/migration/content/brands/ie.png create mode 100644 browser/components/migration/content/brands/opera.png create mode 100644 browser/components/migration/content/brands/operagx.png create mode 100644 browser/components/migration/content/brands/safari.png create mode 100644 browser/components/migration/content/brands/vivaldi.png create mode 100644 browser/components/migration/content/migration-dialog-window.html create mode 100644 browser/components/migration/content/migration-dialog-window.js create mode 100644 browser/components/migration/content/migration-wizard-constants.mjs create mode 100644 browser/components/migration/content/migration-wizard.mjs create mode 100644 browser/components/migration/docs/index.rst create mode 100644 browser/components/migration/docs/migration-utils.rst create mode 100644 browser/components/migration/docs/migration-wizard-architecture-diagram.svg create mode 100644 browser/components/migration/docs/migration-wizard.rst create mode 100644 browser/components/migration/docs/migrators.rst create mode 100644 browser/components/migration/jar.mn create mode 100644 browser/components/migration/metrics.yaml create mode 100644 browser/components/migration/moz.build create mode 100644 browser/components/migration/nsEdgeMigrationUtils.cpp create mode 100644 browser/components/migration/nsEdgeMigrationUtils.h create mode 100644 browser/components/migration/nsIEHistoryEnumerator.cpp create mode 100644 browser/components/migration/nsIEHistoryEnumerator.h create mode 100644 browser/components/migration/nsIEdgeMigrationUtils.idl create mode 100644 browser/components/migration/nsIKeychainMigrationUtils.idl create mode 100644 browser/components/migration/nsKeychainMigrationUtils.h create mode 100644 browser/components/migration/nsKeychainMigrationUtils.mm create mode 100644 browser/components/migration/nsWindowsMigrationUtils.h create mode 100644 browser/components/migration/tests/browser/browser.toml create mode 100644 browser/components/migration/tests/browser/browser_aboutwelcome_behavior.js create mode 100644 browser/components/migration/tests/browser/browser_dialog_cancel_close.js create mode 100644 browser/components/migration/tests/browser/browser_dialog_open.js create mode 100644 browser/components/migration/tests/browser/browser_dialog_resize.js create mode 100644 browser/components/migration/tests/browser/browser_disabled_migrator.js create mode 100644 browser/components/migration/tests/browser/browser_do_migration.js create mode 100644 browser/components/migration/tests/browser/browser_entrypoint_telemetry.js create mode 100644 browser/components/migration/tests/browser/browser_extension_migration.js create mode 100644 browser/components/migration/tests/browser/browser_file_migration.js create mode 100644 browser/components/migration/tests/browser/browser_ie_edge_bookmarks_success_strings.js create mode 100644 browser/components/migration/tests/browser/browser_misc_telemetry.js create mode 100644 browser/components/migration/tests/browser/browser_no_browsers_state.js create mode 100644 browser/components/migration/tests/browser/browser_only_file_migrators.js create mode 100644 browser/components/migration/tests/browser/browser_permissions.js create mode 100644 browser/components/migration/tests/browser/browser_safari_passwords.js create mode 100644 browser/components/migration/tests/browser/browser_safari_permissions.js create mode 100644 browser/components/migration/tests/browser/dummy_file.csv create mode 100644 browser/components/migration/tests/browser/head.js create mode 100644 browser/components/migration/tests/chrome/chrome.toml create mode 100644 browser/components/migration/tests/chrome/test_migration_wizard.html create mode 100644 browser/components/migration/tests/head-common.js create mode 100644 browser/components/migration/tests/marionette/manifest.toml create mode 100644 browser/components/migration/tests/marionette/test_refresh_firefox.py create mode 100644 browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Favicons create mode 100644 browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Login Data create mode 100644 browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Web Data create mode 100644 browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Local State create mode 100644 browser/components/migration/tests/unit/AppData/LocalWithNoData/Google/Chrome/User Data/Default/Login Data create mode 100644 browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat create mode 100644 browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/360Bookmarks create mode 100644 browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2020_08_28.favdb create mode 100644 browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2021_12_02.favdb create mode 100644 browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2020_08_28.favdb create mode 100644 browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2021_12_02.favdb create mode 100644 browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat create mode 100644 browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/DailyBackup/360sefav_2020_08_28.favdb create mode 100644 browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/Bookmarks create mode 100644 browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Local State create mode 100644 browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Cookies create mode 100644 browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/_locales/en_US/messages.json create mode 100644 browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/manifest.json create mode 100644 browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/_locales/en_US/messages.json create mode 100644 browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/manifest.json create mode 100644 browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-2/1.0_0/manifest.json create mode 100644 browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryCorrupt create mode 100644 browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryMaster create mode 100644 browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Login Data create mode 100644 browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Local State create mode 100644 browser/components/migration/tests/unit/Library/Safari/Bookmarks.plist create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-lock create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-shm create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-wal create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/04860FA3D07D8936B87D2B965317C6E9 create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/19A777E4F7BDA0C0E350D6C681B6E271 create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/2558A57A1AE576AA31F0DCD1364B3F42 create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/57D1907A1EBDA1889AA85B8AB7A90804 create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/6EEDD53B65A19CB364EB6FB07DEACF80 create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/7F65370AD319C7B294EDF2E2BEBA880F create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/999E2BD5CD612AA550F222A1088DB3D8 create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/9D8A6E2153D42043A7AE0430B41D374A create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/A21F634481CF5188329FD2052F07ADBC create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/BC2288B5BA9B7BE352BA586257442E08 create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/CFFC3831D8E7201BF8B77728FC79B52B create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/F3FA61DDA95B78A8B5F2C392C0382137 create mode 100644 browser/components/migration/tests/unit/Library/Safari/HistoryStrangeEntries.db create mode 100644 browser/components/migration/tests/unit/Library/Safari/HistoryTemplate.db create mode 100644 browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Default/Login Data create mode 100644 browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Local State create mode 100644 browser/components/migration/tests/unit/bookmarks.exported.html create mode 100644 browser/components/migration/tests/unit/bookmarks.exported.json create mode 100644 browser/components/migration/tests/unit/bookmarks.invalid.html create mode 100644 browser/components/migration/tests/unit/head_migration.js create mode 100644 browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp create mode 100644 browser/components/migration/tests/unit/insertIEHistory/moz.build create mode 100644 browser/components/migration/tests/unit/test_360seMigrationUtils.js create mode 100644 browser/components/migration/tests/unit/test_360se_bookmarks.js create mode 100644 browser/components/migration/tests/unit/test_BookmarksFileMigrator.js create mode 100644 browser/components/migration/tests/unit/test_ChromeMigrationUtils.js create mode 100644 browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js create mode 100644 browser/components/migration/tests/unit/test_ChromeMigrationUtils_path_chromium_snap.js create mode 100644 browser/components/migration/tests/unit/test_Chrome_bookmarks.js create mode 100644 browser/components/migration/tests/unit/test_Chrome_corrupt_history.js create mode 100644 browser/components/migration/tests/unit/test_Chrome_credit_cards.js create mode 100644 browser/components/migration/tests/unit/test_Chrome_extensions.js create mode 100644 browser/components/migration/tests/unit/test_Chrome_formdata.js create mode 100644 browser/components/migration/tests/unit/test_Chrome_history.js create mode 100644 browser/components/migration/tests/unit/test_Chrome_passwords.js create mode 100644 browser/components/migration/tests/unit/test_Chrome_passwords_emptySource.js create mode 100644 browser/components/migration/tests/unit/test_Chrome_permissions.js create mode 100644 browser/components/migration/tests/unit/test_Edge_db_migration.js create mode 100644 browser/components/migration/tests/unit/test_Edge_registry_migration.js create mode 100644 browser/components/migration/tests/unit/test_IE_bookmarks.js create mode 100644 browser/components/migration/tests/unit/test_IE_history.js create mode 100644 browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js create mode 100644 browser/components/migration/tests/unit/test_PasswordFileMigrator.js create mode 100644 browser/components/migration/tests/unit/test_Safari_bookmarks.js create mode 100644 browser/components/migration/tests/unit/test_Safari_history.js create mode 100644 browser/components/migration/tests/unit/test_Safari_history_strange_entries.js create mode 100644 browser/components/migration/tests/unit/test_Safari_permissions.js create mode 100644 browser/components/migration/tests/unit/test_fx_telemetry.js create mode 100644 browser/components/migration/tests/unit/xpcshell.toml create mode 100644 browser/components/moz.build create mode 100644 browser/components/newtab/.eslintrc.js create mode 100644 browser/components/newtab/.nvmrc create mode 100644 browser/components/newtab/AboutNewTabService.sys.mjs create mode 100644 browser/components/newtab/bin/render-activity-stream-html.js create mode 100644 browser/components/newtab/bin/try-runner.js create mode 100644 browser/components/newtab/bin/vendor.js create mode 100644 browser/components/newtab/common/Actions.sys.mjs create mode 100644 browser/components/newtab/common/Dedupe.sys.mjs create mode 100644 browser/components/newtab/common/Reducers.sys.mjs create mode 100644 browser/components/newtab/components.conf create mode 100644 browser/components/newtab/components/CustomElements/paragraph.js create mode 100644 browser/components/newtab/content-src/activity-stream.jsx create mode 100644 browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx create mode 100644 browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss create mode 100644 browser/components/newtab/content-src/components/Base/Base.jsx create mode 100644 browser/components/newtab/content-src/components/Base/_Base.scss create mode 100644 browser/components/newtab/content-src/components/Card/Card.jsx create mode 100644 browser/components/newtab/content-src/components/Card/_Card.scss create mode 100644 browser/components/newtab/content-src/components/Card/types.js create mode 100644 browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx create mode 100644 browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss create mode 100644 browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx create mode 100644 browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx create mode 100644 browser/components/newtab/content-src/components/ConfirmDialog/_ConfirmDialog.scss create mode 100644 browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx create mode 100644 browser/components/newtab/content-src/components/ContextMenu/ContextMenuButton.jsx create mode 100644 browser/components/newtab/content-src/components/ContextMenu/_ContextMenu.scss create mode 100644 browser/components/newtab/content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection.jsx create mode 100644 browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx create mode 100644 browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx create mode 100644 browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamAdmin/SimpleHashRouter.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/Highlights.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/_HorizontalRule.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/_PrivacyLink.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/_TopicsWidget.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/_ImpressionStats.scss create mode 100644 browser/components/newtab/content-src/components/ErrorBoundary/ErrorBoundary.jsx create mode 100644 browser/components/newtab/content-src/components/ErrorBoundary/_ErrorBoundary.scss create mode 100644 browser/components/newtab/content-src/components/FluentOrText/FluentOrText.jsx create mode 100644 browser/components/newtab/content-src/components/LinkMenu/LinkMenu.jsx create mode 100644 browser/components/newtab/content-src/components/ModalOverlay/ModalOverlay.jsx create mode 100644 browser/components/newtab/content-src/components/ModalOverlay/_ModalOverlay.scss create mode 100644 browser/components/newtab/content-src/components/MoreRecommendations/MoreRecommendations.jsx create mode 100644 browser/components/newtab/content-src/components/MoreRecommendations/_MoreRecommendations.scss create mode 100644 browser/components/newtab/content-src/components/PocketLoggedInCta/PocketLoggedInCta.jsx create mode 100644 browser/components/newtab/content-src/components/PocketLoggedInCta/_PocketLoggedInCta.scss create mode 100644 browser/components/newtab/content-src/components/Search/Search.jsx create mode 100644 browser/components/newtab/content-src/components/Search/_Search.scss create mode 100644 browser/components/newtab/content-src/components/Sections/Sections.jsx create mode 100644 browser/components/newtab/content-src/components/Sections/_Sections.scss create mode 100644 browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx create mode 100644 browser/components/newtab/content-src/components/TopSites/TopSite.jsx create mode 100644 browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx create mode 100644 browser/components/newtab/content-src/components/TopSites/TopSiteFormInput.jsx create mode 100644 browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx create mode 100644 browser/components/newtab/content-src/components/TopSites/TopSites.jsx create mode 100644 browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js create mode 100644 browser/components/newtab/content-src/components/TopSites/_TopSites.scss create mode 100644 browser/components/newtab/content-src/components/Topics/Topics.jsx create mode 100644 browser/components/newtab/content-src/components/Topics/_Topics.scss create mode 100644 browser/components/newtab/content-src/lib/constants.js create mode 100644 browser/components/newtab/content-src/lib/detect-user-session-start.js create mode 100644 browser/components/newtab/content-src/lib/init-store.js create mode 100644 browser/components/newtab/content-src/lib/link-menu-options.js create mode 100644 browser/components/newtab/content-src/lib/perf-service.js create mode 100644 browser/components/newtab/content-src/lib/screenshot-utils.js create mode 100644 browser/components/newtab/content-src/lib/selectLayoutRender.js create mode 100644 browser/components/newtab/content-src/styles/_activity-stream.scss create mode 100644 browser/components/newtab/content-src/styles/_icons.scss create mode 100644 browser/components/newtab/content-src/styles/_mixins.scss create mode 100644 browser/components/newtab/content-src/styles/_normalize.scss create mode 100644 browser/components/newtab/content-src/styles/_theme.scss create mode 100644 browser/components/newtab/content-src/styles/_variables.scss create mode 100644 browser/components/newtab/content-src/styles/activity-stream-linux.scss create mode 100644 browser/components/newtab/content-src/styles/activity-stream-mac.scss create mode 100644 browser/components/newtab/content-src/styles/activity-stream-windows.scss create mode 100644 browser/components/newtab/css/activity-stream-linux.css create mode 100644 browser/components/newtab/css/activity-stream-mac.css create mode 100644 browser/components/newtab/css/activity-stream-windows.css create mode 100644 browser/components/newtab/data/content/abouthomecache/page.html.template create mode 100644 browser/components/newtab/data/content/abouthomecache/script.js.template create mode 100644 browser/components/newtab/data/content/activity-stream.bundle.js create mode 100644 browser/components/newtab/data/content/assets/firefox.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-cfr-feature-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-mail-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-maximize-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-minimize-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-modal-delete-20.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-newWindow-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-open-file-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-pin-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-pocket-archive-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-pocket-delete-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-unpin-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-webextension-16.svg create mode 100644 browser/components/newtab/data/content/assets/icon-removed-bookmark.svg create mode 100644 browser/components/newtab/data/content/assets/pocket-onboarding.avif create mode 100644 browser/components/newtab/data/content/assets/pocket-onboarding@2x.avif create mode 100644 browser/components/newtab/data/content/assets/pocket-swoosh.svg create mode 100644 browser/components/newtab/data/content/assets/remote/mountain.svg create mode 100644 browser/components/newtab/data/content/assets/remote/umbrella.png create mode 100644 browser/components/newtab/data/content/assets/spinner.svg create mode 100644 browser/components/newtab/data/content/newtab-render.js create mode 100644 browser/components/newtab/data/content/tippytop/favicons/adidas.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/aliexpress-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/allegro-pl.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/amazon.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/avito-ru.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/baidu-com.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/bbc-uk.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/bing-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/ctrip-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/duckduckgo-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/ebay.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/etsy.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/facebook-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/geico.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/google-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/hrblock.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/ifeng-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/iqiyi-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/leboncoin-fr.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/nike.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/ok-ru.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/olx-pl.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/reddit-com.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/samsung.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/turbotax.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/twitter-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/vk-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/vodafone.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/weibo-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/wikipedia-org.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/wix.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/wykop-pl.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/yandex-com.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/yandex-ru.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/youtube-com.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/zhihu-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/images/adidas@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/aliexpress-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/allegro-pl@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/amazon@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/avito-ru@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/baidu-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/bbc-uk@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/bing-com@2x.svg create mode 100644 browser/components/newtab/data/content/tippytop/images/ctrip-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/duckduckgo-com@2x.svg create mode 100644 browser/components/newtab/data/content/tippytop/images/ebay@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/etsy@2x.jpg create mode 100644 browser/components/newtab/data/content/tippytop/images/facebook-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/geico@2x.jpg create mode 100644 browser/components/newtab/data/content/tippytop/images/google-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/hrblock@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/ifeng-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/iqiyi-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/leboncoin-fr@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/nike@2x.jpg create mode 100644 browser/components/newtab/data/content/tippytop/images/ok-ru@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/olx-pl@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/reddit-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/samsung@2x.jpg create mode 100644 browser/components/newtab/data/content/tippytop/images/turbotax@2x.jpg create mode 100644 browser/components/newtab/data/content/tippytop/images/twitter-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/vk-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/vodafone@2x.jpg create mode 100644 browser/components/newtab/data/content/tippytop/images/weibo-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/wikipedia-org@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/wix@2x.jpg create mode 100644 browser/components/newtab/data/content/tippytop/images/wykop-pl@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/yandex-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/yandex-ru@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/youtube-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/zhihu-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/top_sites.json create mode 100644 browser/components/newtab/docs/index.rst create mode 100644 browser/components/newtab/docs/v2-system-addon/about_home_startup_cache.md create mode 100644 browser/components/newtab/docs/v2-system-addon/data_events.md create mode 100644 browser/components/newtab/docs/v2-system-addon/geo_locale.md create mode 100644 browser/components/newtab/docs/v2-system-addon/mochitests.md create mode 100644 browser/components/newtab/docs/v2-system-addon/preferences.md create mode 100644 browser/components/newtab/docs/v2-system-addon/sections.md create mode 100644 browser/components/newtab/docs/v2-system-addon/telemetry.md create mode 100644 browser/components/newtab/docs/v2-system-addon/tippytop.md create mode 100644 browser/components/newtab/docs/v2-system-addon/unit_testing_guide.md create mode 100644 browser/components/newtab/jar.mn create mode 100644 browser/components/newtab/karma.mc.config.js create mode 100644 browser/components/newtab/lib/AboutPreferences.sys.mjs create mode 100644 browser/components/newtab/lib/ActivityStream.sys.mjs create mode 100644 browser/components/newtab/lib/ActivityStreamMessageChannel.sys.mjs create mode 100644 browser/components/newtab/lib/ActivityStreamPrefs.sys.mjs create mode 100644 browser/components/newtab/lib/ActivityStreamStorage.sys.mjs create mode 100644 browser/components/newtab/lib/DefaultSites.sys.mjs create mode 100644 browser/components/newtab/lib/DiscoveryStreamFeed.sys.mjs create mode 100644 browser/components/newtab/lib/DownloadsManager.sys.mjs create mode 100644 browser/components/newtab/lib/FaviconFeed.sys.mjs create mode 100644 browser/components/newtab/lib/FilterAdult.sys.mjs create mode 100644 browser/components/newtab/lib/HighlightsFeed.sys.mjs create mode 100644 browser/components/newtab/lib/LinksCache.sys.mjs create mode 100644 browser/components/newtab/lib/NewTabInit.sys.mjs create mode 100644 browser/components/newtab/lib/PersistentCache.sys.mjs create mode 100644 browser/components/newtab/lib/PersonalityProvider/NaiveBayesTextTagger.mjs create mode 100644 browser/components/newtab/lib/PersonalityProvider/NmfTextTagger.mjs create mode 100644 browser/components/newtab/lib/PersonalityProvider/PersonalityProvider.sys.mjs create mode 100644 browser/components/newtab/lib/PersonalityProvider/PersonalityProvider.worker.mjs create mode 100644 browser/components/newtab/lib/PersonalityProvider/PersonalityProviderWorkerClass.mjs create mode 100644 browser/components/newtab/lib/PersonalityProvider/RecipeExecutor.mjs create mode 100644 browser/components/newtab/lib/PersonalityProvider/Tokenize.mjs create mode 100644 browser/components/newtab/lib/PlacesFeed.sys.mjs create mode 100644 browser/components/newtab/lib/PrefsFeed.sys.mjs create mode 100644 browser/components/newtab/lib/RecommendationProvider.sys.mjs create mode 100644 browser/components/newtab/lib/Screenshots.sys.mjs create mode 100644 browser/components/newtab/lib/SearchShortcuts.sys.mjs create mode 100644 browser/components/newtab/lib/SectionsManager.sys.mjs create mode 100644 browser/components/newtab/lib/ShortURL.sys.mjs create mode 100644 browser/components/newtab/lib/SiteClassifier.sys.mjs create mode 100644 browser/components/newtab/lib/Store.sys.mjs create mode 100644 browser/components/newtab/lib/SystemTickFeed.sys.mjs create mode 100644 browser/components/newtab/lib/TelemetryFeed.sys.mjs create mode 100644 browser/components/newtab/lib/TippyTopProvider.sys.mjs create mode 100644 browser/components/newtab/lib/TopSitesFeed.sys.mjs create mode 100644 browser/components/newtab/lib/TopStoriesFeed.sys.mjs create mode 100644 browser/components/newtab/lib/UTEventReporting.sys.mjs create mode 100644 browser/components/newtab/lib/cache.worker.js create mode 100644 browser/components/newtab/loaders/inject-loader.js create mode 100644 browser/components/newtab/metrics.yaml create mode 100644 browser/components/newtab/moz.build create mode 100644 browser/components/newtab/nsIAboutNewTabService.idl create mode 100644 browser/components/newtab/package-lock.json create mode 100644 browser/components/newtab/package.json create mode 100644 browser/components/newtab/pings.yaml create mode 100644 browser/components/newtab/prerendered/activity-stream-debug.html create mode 100644 browser/components/newtab/prerendered/activity-stream-noscripts.html create mode 100644 browser/components/newtab/prerendered/activity-stream.html create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser.toml create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_basic_endtoend.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_bump_version.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_disabled.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_experiments_api_control.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_locale_change.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_no_cache.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_no_cache_on_SessionStartup_restore.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_no_startup_actions.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_overwrite_cache.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_process_crash.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_same_consumer.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_sanitize.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_shutdown_timeout.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/head.js create mode 100644 browser/components/newtab/test/browser/annotation_first.html create mode 100644 browser/components/newtab/test/browser/annotation_second.html create mode 100644 browser/components/newtab/test/browser/annotation_third.html create mode 100644 browser/components/newtab/test/browser/blue_page.html create mode 100644 browser/components/newtab/test/browser/browser.toml create mode 100644 browser/components/newtab/test/browser/browser_as_load_location.js create mode 100644 browser/components/newtab/test/browser/browser_as_render.js create mode 100644 browser/components/newtab/test/browser/browser_context_menu_item.js create mode 100644 browser/components/newtab/test/browser/browser_customize_menu_content.js create mode 100644 browser/components/newtab/test/browser/browser_customize_menu_render.js create mode 100644 browser/components/newtab/test/browser/browser_discovery_card.js create mode 100644 browser/components/newtab/test/browser/browser_discovery_render.js create mode 100644 browser/components/newtab/test/browser/browser_enabled_newtabpage.js create mode 100644 browser/components/newtab/test/browser/browser_foxdoodle_set_default.js create mode 100644 browser/components/newtab/test/browser/browser_getScreenshots.js create mode 100644 browser/components/newtab/test/browser/browser_highlights_section.js create mode 100644 browser/components/newtab/test/browser/browser_multistage_spotlight.js create mode 100644 browser/components/newtab/test/browser/browser_multistage_spotlight_telemetry.js create mode 100644 browser/components/newtab/test/browser/browser_newtab_glean.js create mode 100644 browser/components/newtab/test/browser/browser_newtab_header.js create mode 100644 browser/components/newtab/test/browser/browser_newtab_last_LinkMenu.js create mode 100644 browser/components/newtab/test/browser/browser_newtab_overrides.js create mode 100644 browser/components/newtab/test/browser/browser_newtab_ping.js create mode 100644 browser/components/newtab/test/browser/browser_newtab_towindow.js create mode 100644 browser/components/newtab/test/browser/browser_newtab_trigger.js create mode 100644 browser/components/newtab/test/browser/browser_open_tab_focus.js create mode 100644 browser/components/newtab/test/browser/browser_remote_l10n.js create mode 100644 browser/components/newtab/test/browser/browser_topsites_annotation.js create mode 100644 browser/components/newtab/test/browser/browser_topsites_contextMenu_options.js create mode 100644 browser/components/newtab/test/browser/browser_topsites_section.js create mode 100644 browser/components/newtab/test/browser/browser_trigger_messagesLoaded.js create mode 100644 browser/components/newtab/test/browser/file_pdf.PDF create mode 100644 browser/components/newtab/test/browser/head.js create mode 100644 browser/components/newtab/test/browser/red_page.html create mode 100644 browser/components/newtab/test/browser/redirect_to.sjs create mode 100644 browser/components/newtab/test/browser/topstories.json create mode 100644 browser/components/newtab/test/schemas/asrouter_event_ping.schema.json create mode 100644 browser/components/newtab/test/schemas/base_ping.schema.json create mode 100644 browser/components/newtab/test/schemas/pings.js create mode 100644 browser/components/newtab/test/schemas/session_ping.schema.json create mode 100644 browser/components/newtab/test/schemas/user_event_ping.schema.json create mode 100644 browser/components/newtab/test/unit/common/Actions.test.js create mode 100644 browser/components/newtab/test/unit/common/Dedupe.test.js create mode 100644 browser/components/newtab/test/unit/common/Reducers.test.js create mode 100644 browser/components/newtab/test/unit/content-src/components/Base.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/Card.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/CollapsibleSection.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/ComponentPerfTimer.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/ConfirmDialog.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/ContextMenu.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/CustomiseMenu.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamAdmin.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamBase.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CollectionCardGrid.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSCard.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSContextFooter.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSDismiss.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSEmptyState.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSImage.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSLinkMenu.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSMessage.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSPrivacyModal.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSSignup.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSTextPromo.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/Highlights.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/HorizontalRule.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/Navigation.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/PrivacyLink.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/SafeAnchor.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/SectionTitle.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/TopicsWidget.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/ErrorBoundary.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/FluentOrText.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/LinkMenu.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/MoreRecommendations.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/PocketLoggedInCta.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/Search.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/Sections.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/TopSites.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/TopSites/SearchShortcutsForm.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/TopSites/TopSiteImpressionWrapper.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/Topics.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/lib/detect-user-session-start.test.js create mode 100644 browser/components/newtab/test/unit/content-src/lib/init-store.test.js create mode 100644 browser/components/newtab/test/unit/content-src/lib/perf-service.test.js create mode 100644 browser/components/newtab/test/unit/content-src/lib/screenshot-utils.test.js create mode 100644 browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js create mode 100644 browser/components/newtab/test/unit/lib/AboutPreferences.test.js create mode 100644 browser/components/newtab/test/unit/lib/ActivityStream.test.js create mode 100644 browser/components/newtab/test/unit/lib/ActivityStreamMessageChannel.test.js create mode 100644 browser/components/newtab/test/unit/lib/ActivityStreamPrefs.test.js create mode 100644 browser/components/newtab/test/unit/lib/ActivityStreamStorage.test.js create mode 100644 browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js create mode 100644 browser/components/newtab/test/unit/lib/DownloadsManager.test.js create mode 100644 browser/components/newtab/test/unit/lib/FaviconFeed.test.js create mode 100644 browser/components/newtab/test/unit/lib/FilterAdult.test.js create mode 100644 browser/components/newtab/test/unit/lib/LinksCache.test.js create mode 100644 browser/components/newtab/test/unit/lib/NewTabInit.test.js create mode 100644 browser/components/newtab/test/unit/lib/PersistentCache.test.js create mode 100644 browser/components/newtab/test/unit/lib/PersonalityProvider/NaiveBayesTextTagger.test.js create mode 100644 browser/components/newtab/test/unit/lib/PersonalityProvider/NmfTextTagger.test.js create mode 100644 browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProvider.test.js create mode 100644 browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProviderWorkerClass.test.js create mode 100644 browser/components/newtab/test/unit/lib/PersonalityProvider/RecipeExecutor.test.js create mode 100644 browser/components/newtab/test/unit/lib/PersonalityProvider/Tokenize.test.js create mode 100644 browser/components/newtab/test/unit/lib/PrefsFeed.test.js create mode 100644 browser/components/newtab/test/unit/lib/RecommendationProvider.test.js create mode 100644 browser/components/newtab/test/unit/lib/Screenshots.test.js create mode 100644 browser/components/newtab/test/unit/lib/SectionsManager.test.js create mode 100644 browser/components/newtab/test/unit/lib/ShortUrl.test.js create mode 100644 browser/components/newtab/test/unit/lib/SiteClassifier.test.js create mode 100644 browser/components/newtab/test/unit/lib/SystemTickFeed.test.js create mode 100644 browser/components/newtab/test/unit/lib/TippyTopProvider.test.js create mode 100644 browser/components/newtab/test/unit/lib/UTEventReporting.test.js create mode 100644 browser/components/newtab/test/unit/unit-entry.js create mode 100644 browser/components/newtab/test/unit/utils.js create mode 100644 browser/components/newtab/test/xpcshell/test_AboutHomeStartupCacheChild.js create mode 100644 browser/components/newtab/test/xpcshell/test_AboutHomeStartupCacheWorker.js create mode 100644 browser/components/newtab/test/xpcshell/test_AboutNewTab.js create mode 100644 browser/components/newtab/test/xpcshell/test_AboutWelcomeAttribution.js create mode 100644 browser/components/newtab/test/xpcshell/test_AboutWelcomeTelemetry.js create mode 100644 browser/components/newtab/test/xpcshell/test_AboutWelcomeTelemetry_glean.js create mode 100644 browser/components/newtab/test/xpcshell/test_HighlightsFeed.js create mode 100644 browser/components/newtab/test/xpcshell/test_PlacesFeed.js create mode 100644 browser/components/newtab/test/xpcshell/test_Store.js create mode 100644 browser/components/newtab/test/xpcshell/test_TelemetryFeed.js create mode 100644 browser/components/newtab/test/xpcshell/test_TopSitesFeed.js create mode 100644 browser/components/newtab/test/xpcshell/test_TopSitesFeed_glean.js create mode 100644 browser/components/newtab/test/xpcshell/topstories.json create mode 100644 browser/components/newtab/test/xpcshell/xpcshell.toml create mode 100644 browser/components/newtab/tools/resourceUriPlugin.js create mode 100644 browser/components/newtab/vendor/PROP_TYPES_LICENSE create mode 100644 browser/components/newtab/vendor/REACT_AND_REACT_DOM_LICENSE create mode 100644 browser/components/newtab/vendor/REACT_REDUX_LICENSE create mode 100644 browser/components/newtab/vendor/REACT_TRANSITION_GROUP_LICENSE create mode 100644 browser/components/newtab/vendor/REDUX_LICENSE create mode 100644 browser/components/newtab/vendor/Redux.sys.mjs create mode 100644 browser/components/newtab/vendor/prop-types.js create mode 100644 browser/components/newtab/vendor/react-dev.js create mode 100644 browser/components/newtab/vendor/react-dom-dev.js create mode 100644 browser/components/newtab/vendor/react-dom-server.js create mode 100644 browser/components/newtab/vendor/react-dom.js create mode 100644 browser/components/newtab/vendor/react-redux.js create mode 100644 browser/components/newtab/vendor/react-transition-group.js create mode 100644 browser/components/newtab/vendor/react.js create mode 100644 browser/components/newtab/vendor/redux.js create mode 100644 browser/components/newtab/webpack.system-addon.config.js create mode 100644 browser/components/newtab/yamscripts.yml create mode 100644 browser/components/nsIBrowserHandler.idl create mode 100644 browser/components/originattributes/moz.build create mode 100644 browser/components/originattributes/test/browser/blobify.worker.js create mode 100644 browser/components/originattributes/test/browser/browser.toml create mode 100644 browser/components/originattributes/test/browser/browser_blobURLIsolation.js create mode 100644 browser/components/originattributes/test/browser/browser_broadcastChannel.js create mode 100644 browser/components/originattributes/test/browser/browser_cache.js create mode 100644 browser/components/originattributes/test/browser/browser_cacheAPI.js create mode 100644 browser/components/originattributes/test/browser/browser_clientAuth.js create mode 100644 browser/components/originattributes/test/browser/browser_cookieIsolation.js create mode 100644 browser/components/originattributes/test/browser/browser_favicon_firstParty.js create mode 100644 browser/components/originattributes/test/browser/browser_favicon_userContextId.js create mode 100644 browser/components/originattributes/test/browser/browser_firstPartyIsolation.js create mode 100644 browser/components/originattributes/test/browser/browser_firstPartyIsolation_aboutPages.js create mode 100644 browser/components/originattributes/test/browser/browser_firstPartyIsolation_about_newtab.js create mode 100644 browser/components/originattributes/test/browser/browser_firstPartyIsolation_blobURI.js create mode 100644 browser/components/originattributes/test/browser/browser_firstPartyIsolation_js_uri.js create mode 100644 browser/components/originattributes/test/browser/browser_firstPartyIsolation_saveAs.js create mode 100644 browser/components/originattributes/test/browser/browser_httpauth.js create mode 100644 browser/components/originattributes/test/browser/browser_imageCacheIsolation.js create mode 100644 browser/components/originattributes/test/browser/browser_localStorageIsolation.js create mode 100644 browser/components/originattributes/test/browser/browser_permissions.js create mode 100644 browser/components/originattributes/test/browser/browser_postMessage.js create mode 100644 browser/components/originattributes/test/browser/browser_sanitize.js create mode 100644 browser/components/originattributes/test/browser/browser_sharedworker.js create mode 100644 browser/components/originattributes/test/browser/browser_windowOpenerRestriction.js create mode 100644 browser/components/originattributes/test/browser/dummy.html create mode 100644 browser/components/originattributes/test/browser/file_broadcastChannel.html create mode 100644 browser/components/originattributes/test/browser/file_broadcastChanneliFrame.html create mode 100644 browser/components/originattributes/test/browser/file_cache.html create mode 100644 browser/components/originattributes/test/browser/file_favicon.html create mode 100644 browser/components/originattributes/test/browser/file_favicon.png create mode 100644 browser/components/originattributes/test/browser/file_favicon.png^headers^ create mode 100644 browser/components/originattributes/test/browser/file_favicon_cache.html create mode 100644 browser/components/originattributes/test/browser/file_favicon_cache.png create mode 100644 browser/components/originattributes/test/browser/file_favicon_thirdParty.html create mode 100644 browser/components/originattributes/test/browser/file_firstPartyBasic.html create mode 100644 browser/components/originattributes/test/browser/file_postMessage.html create mode 100644 browser/components/originattributes/test/browser/file_postMessageSender.html create mode 100644 browser/components/originattributes/test/browser/file_saveAs.sjs create mode 100644 browser/components/originattributes/test/browser/file_shared.worker.js create mode 100644 browser/components/originattributes/test/browser/file_sharedworker.html create mode 100644 browser/components/originattributes/test/browser/file_thirdPartyChild.audio.ogg create mode 100644 browser/components/originattributes/test/browser/file_thirdPartyChild.embed.png create mode 100644 browser/components/originattributes/test/browser/file_thirdPartyChild.favicon.png create mode 100644 browser/components/originattributes/test/browser/file_thirdPartyChild.fetch.html create mode 100644 browser/components/originattributes/test/browser/file_thirdPartyChild.font.woff create mode 100644 browser/components/originattributes/test/browser/file_thirdPartyChild.iframe.html create mode 100644 browser/components/originattributes/test/browser/file_thirdPartyChild.img.png create mode 100644 browser/components/originattributes/test/browser/file_thirdPartyChild.import.js create mode 100644 browser/components/originattributes/test/browser/file_thirdPartyChild.link.css create mode 100644 browser/components/originattributes/test/browser/file_thirdPartyChild.object.png create mode 100644 browser/components/originattributes/test/browser/file_thirdPartyChild.request.html create mode 100644 browser/components/originattributes/test/browser/file_thirdPartyChild.script.js create mode 100644 browser/components/originattributes/test/browser/file_thirdPartyChild.sharedworker.js create mode 100644 browser/components/originattributes/test/browser/file_thirdPartyChild.track.vtt create mode 100644 browser/components/originattributes/test/browser/file_thirdPartyChild.video.ogv create mode 100644 browser/components/originattributes/test/browser/file_thirdPartyChild.worker.fetch.html create mode 100644 browser/components/originattributes/test/browser/file_thirdPartyChild.worker.js create mode 100644 browser/components/originattributes/test/browser/file_thirdPartyChild.worker.request.html create mode 100644 browser/components/originattributes/test/browser/file_thirdPartyChild.worker.xhr.html create mode 100644 browser/components/originattributes/test/browser/file_thirdPartyChild.xhr.html create mode 100644 browser/components/originattributes/test/browser/file_windowOpenerRestriction.html create mode 100644 browser/components/originattributes/test/browser/file_windowOpenerRestrictionTarget.html create mode 100644 browser/components/originattributes/test/browser/head.js create mode 100644 browser/components/originattributes/test/browser/test.html create mode 100644 browser/components/originattributes/test/browser/test.js create mode 100644 browser/components/originattributes/test/browser/test.js^headers^ create mode 100644 browser/components/originattributes/test/browser/test2.html create mode 100644 browser/components/originattributes/test/browser/test2.js create mode 100644 browser/components/originattributes/test/browser/test2.js^headers^ create mode 100644 browser/components/originattributes/test/browser/test_firstParty.html create mode 100644 browser/components/originattributes/test/browser/test_firstParty_cookie.html create mode 100644 browser/components/originattributes/test/browser/test_firstParty_html_redirect.html create mode 100644 browser/components/originattributes/test/browser/test_firstParty_http_redirect.html create mode 100644 browser/components/originattributes/test/browser/test_firstParty_http_redirect.html^headers^ create mode 100644 browser/components/originattributes/test/browser/test_firstParty_http_redirect_to_same_domain.html create mode 100644 browser/components/originattributes/test/browser/test_firstParty_http_redirect_to_same_domain.html^headers^ create mode 100644 browser/components/originattributes/test/browser/test_firstParty_iframe_http_redirect.html create mode 100644 browser/components/originattributes/test/browser/test_firstParty_postMessage.html create mode 100644 browser/components/originattributes/test/browser/test_form.html create mode 100644 browser/components/originattributes/test/browser/window.html create mode 100644 browser/components/originattributes/test/browser/window2.html create mode 100644 browser/components/originattributes/test/browser/window3.html create mode 100644 browser/components/originattributes/test/browser/window_redirect.html create mode 100644 browser/components/originattributes/test/mochitest/file_empty.html create mode 100644 browser/components/originattributes/test/mochitest/mochitest.toml create mode 100644 browser/components/originattributes/test/mochitest/test_permissions_api.html create mode 100644 browser/components/pagedata/.eslintrc.js create mode 100644 browser/components/pagedata/OpenGraphPageData.sys.mjs create mode 100644 browser/components/pagedata/PageDataChild.sys.mjs create mode 100644 browser/components/pagedata/PageDataParent.sys.mjs create mode 100644 browser/components/pagedata/PageDataSchema.sys.mjs create mode 100644 browser/components/pagedata/PageDataService.sys.mjs create mode 100644 browser/components/pagedata/SchemaOrgPageData.sys.mjs create mode 100644 browser/components/pagedata/TwitterPageData.sys.mjs create mode 100644 browser/components/pagedata/docs/index.md create mode 100644 browser/components/pagedata/jar.mn create mode 100644 browser/components/pagedata/moz.build create mode 100644 browser/components/pagedata/schemas/article.schema.json create mode 100644 browser/components/pagedata/schemas/audio.schema.json create mode 100644 browser/components/pagedata/schemas/document.schema.json create mode 100644 browser/components/pagedata/schemas/general.schema.json create mode 100644 browser/components/pagedata/schemas/product.schema.json create mode 100644 browser/components/pagedata/schemas/video.schema.json create mode 100644 browser/components/pagedata/tests/browser/browser.toml create mode 100644 browser/components/pagedata/tests/browser/browser_pagedata_background.js create mode 100644 browser/components/pagedata/tests/browser/browser_pagedata_basic.js create mode 100644 browser/components/pagedata/tests/browser/browser_pagedata_cache.js create mode 100644 browser/components/pagedata/tests/browser/head.js create mode 100644 browser/components/pagedata/tests/unit/head.js create mode 100644 browser/components/pagedata/tests/unit/test_opengraph.js create mode 100644 browser/components/pagedata/tests/unit/test_pagedata_basic.js create mode 100644 browser/components/pagedata/tests/unit/test_pagedata_schema.js create mode 100644 browser/components/pagedata/tests/unit/test_queue.js create mode 100644 browser/components/pagedata/tests/unit/test_schemaorg.js create mode 100644 browser/components/pagedata/tests/unit/test_schemaorg_parse.js create mode 100644 browser/components/pagedata/tests/unit/test_twitter.js create mode 100644 browser/components/pagedata/tests/unit/xpcshell.toml create mode 100644 browser/components/places/.eslintrc.js create mode 100644 browser/components/places/Interactions.sys.mjs create mode 100644 browser/components/places/InteractionsBlocklist.sys.mjs create mode 100644 browser/components/places/InteractionsChild.sys.mjs create mode 100644 browser/components/places/InteractionsParent.sys.mjs create mode 100644 browser/components/places/PlacesUIUtils.sys.mjs create mode 100644 browser/components/places/content/bookmarkProperties.js create mode 100644 browser/components/places/content/bookmarkProperties.xhtml create mode 100644 browser/components/places/content/bookmarksHistoryTooltip.inc.xhtml create mode 100644 browser/components/places/content/bookmarksSidebar.js create mode 100644 browser/components/places/content/bookmarksSidebar.xhtml create mode 100644 browser/components/places/content/browserPlacesViews.js create mode 100644 browser/components/places/content/controller.js create mode 100644 browser/components/places/content/editBookmark.js create mode 100644 browser/components/places/content/editBookmarkPanel.inc.xhtml create mode 100644 browser/components/places/content/historySidebar.js create mode 100644 browser/components/places/content/historySidebar.xhtml create mode 100644 browser/components/places/content/places-menupopup.js create mode 100644 browser/components/places/content/places-tree.js create mode 100644 browser/components/places/content/places.css create mode 100644 browser/components/places/content/places.js create mode 100644 browser/components/places/content/places.xhtml create mode 100644 browser/components/places/content/placesCommands.inc.xhtml create mode 100644 browser/components/places/content/placesContextMenu.inc.xhtml create mode 100644 browser/components/places/content/treeView.js create mode 100644 browser/components/places/docs/Bookmarks.rst create mode 100644 browser/components/places/docs/History.rst create mode 100644 browser/components/places/docs/PlacesTransactions.rst create mode 100644 browser/components/places/docs/architecture-overview.rst create mode 100644 browser/components/places/docs/assets/nontechnical-overview/bookmark-folder-menu.png create mode 100644 browser/components/places/docs/assets/nontechnical-overview/bookmark-undo-redo.png create mode 100644 browser/components/places/docs/assets/nontechnical-overview/firefox-bookmarks-main-application.png create mode 100644 browser/components/places/docs/assets/nontechnical-overview/firefox-bookmarks-menu.png create mode 100644 browser/components/places/docs/assets/nontechnical-overview/firefox-bookmarks-toolbar.png create mode 100644 browser/components/places/docs/index.rst create mode 100644 browser/components/places/docs/nontechnical-overview.rst create mode 100644 browser/components/places/docs/notifyObservers.rst create mode 100644 browser/components/places/jar.mn create mode 100644 browser/components/places/metadataViewer/interactionsViewer.css create mode 100644 browser/components/places/metadataViewer/interactionsViewer.html create mode 100644 browser/components/places/metadataViewer/interactionsViewer.js create mode 100644 browser/components/places/moz.build create mode 100644 browser/components/places/tests/browser/bookmark_dummy_1.html create mode 100644 browser/components/places/tests/browser/bookmark_dummy_2.html create mode 100644 browser/components/places/tests/browser/bookmarklet_windowOpen_dummy.html create mode 100644 browser/components/places/tests/browser/browser.toml create mode 100644 browser/components/places/tests/browser/browser_addBookmarkForFrame.js create mode 100644 browser/components/places/tests/browser/browser_autoshow_bookmarks_toolbar.js create mode 100644 browser/components/places/tests/browser/browser_bookmarkMenu_hiddenWindow.js create mode 100644 browser/components/places/tests/browser/browser_bookmarkProperties_addFolderDefaultButton.js create mode 100644 browser/components/places/tests/browser/browser_bookmarkProperties_addKeywordForThisSearch.js create mode 100644 browser/components/places/tests/browser/browser_bookmarkProperties_bookmarkAllTabs.js create mode 100644 browser/components/places/tests/browser/browser_bookmarkProperties_cancel.js create mode 100644 browser/components/places/tests/browser/browser_bookmarkProperties_editFolder.js create mode 100644 browser/components/places/tests/browser/browser_bookmarkProperties_editTagContainer.js create mode 100644 browser/components/places/tests/browser/browser_bookmarkProperties_folderSelection.js create mode 100644 browser/components/places/tests/browser/browser_bookmarkProperties_newFolder.js create mode 100644 browser/components/places/tests/browser/browser_bookmarkProperties_no_user_actions.js create mode 100644 browser/components/places/tests/browser/browser_bookmarkProperties_readOnlyRoot.js create mode 100644 browser/components/places/tests/browser/browser_bookmarkProperties_remember_folders.js create mode 100644 browser/components/places/tests/browser/browser_bookmarkProperties_speculativeConnection.js create mode 100644 browser/components/places/tests/browser/browser_bookmarkProperties_xulStore.js create mode 100644 browser/components/places/tests/browser/browser_bookmark_add_tags.js create mode 100644 browser/components/places/tests/browser/browser_bookmark_all_tabs.js create mode 100644 browser/components/places/tests/browser/browser_bookmark_backup_export_import.js create mode 100644 browser/components/places/tests/browser/browser_bookmark_change_location.js create mode 100644 browser/components/places/tests/browser/browser_bookmark_context_menu_contents.js create mode 100644 browser/components/places/tests/browser/browser_bookmark_copy_folder_tree.js create mode 100644 browser/components/places/tests/browser/browser_bookmark_folder_moveability.js create mode 100644 browser/components/places/tests/browser/browser_bookmark_menu_ctrl_click.js create mode 100644 browser/components/places/tests/browser/browser_bookmark_popup.js create mode 100644 browser/components/places/tests/browser/browser_bookmark_private_window.js create mode 100644 browser/components/places/tests/browser/browser_bookmark_remove_tags.js create mode 100644 browser/components/places/tests/browser/browser_bookmark_titles.js create mode 100644 browser/components/places/tests/browser/browser_bookmarklet_windowOpen.js create mode 100644 browser/components/places/tests/browser/browser_bookmarksProperties.js create mode 100644 browser/components/places/tests/browser/browser_bookmarks_change_title.js create mode 100644 browser/components/places/tests/browser/browser_bookmarks_change_url.js create mode 100644 browser/components/places/tests/browser/browser_bookmarks_sidebar_search.js create mode 100644 browser/components/places/tests/browser/browser_bookmarks_toolbar_context_menu_view_options.js create mode 100644 browser/components/places/tests/browser/browser_bookmarks_toolbar_telemetry.js create mode 100644 browser/components/places/tests/browser/browser_bug427633_no_newfolder_if_noip.js create mode 100644 browser/components/places/tests/browser/browser_bug485100-change-case-loses-tag.js create mode 100644 browser/components/places/tests/browser/browser_bug631374_tags_selector_scroll.js create mode 100644 browser/components/places/tests/browser/browser_check_correct_controllers.js create mode 100644 browser/components/places/tests/browser/browser_click_bookmarks_on_toolbar.js create mode 100644 browser/components/places/tests/browser/browser_controller_onDrop.js create mode 100644 browser/components/places/tests/browser/browser_controller_onDrop_query.js create mode 100644 browser/components/places/tests/browser/browser_controller_onDrop_sidebar.js create mode 100644 browser/components/places/tests/browser/browser_controller_onDrop_tagFolder.js create mode 100644 browser/components/places/tests/browser/browser_copy_query_without_tree.js create mode 100644 browser/components/places/tests/browser/browser_cutting_bookmarks.js create mode 100644 browser/components/places/tests/browser/browser_default_bookmark_location.js create mode 100644 browser/components/places/tests/browser/browser_drag_bookmarks_on_toolbar.js create mode 100644 browser/components/places/tests/browser/browser_drag_folder_on_newTab.js create mode 100644 browser/components/places/tests/browser/browser_editBookmark_keywords.js create mode 100644 browser/components/places/tests/browser/browser_enable_toolbar_sidebar.js create mode 100644 browser/components/places/tests/browser/browser_forgetthissite.js create mode 100644 browser/components/places/tests/browser/browser_history_sidebar_search.js create mode 100644 browser/components/places/tests/browser/browser_import_button.js create mode 100644 browser/components/places/tests/browser/browser_library_bookmark_clear_visits.js create mode 100644 browser/components/places/tests/browser/browser_library_bookmark_pages.js create mode 100644 browser/components/places/tests/browser/browser_library_bulk_tag_bookmarks.js create mode 100644 browser/components/places/tests/browser/browser_library_commands.js create mode 100644 browser/components/places/tests/browser/browser_library_delete.js create mode 100644 browser/components/places/tests/browser/browser_library_delete_bookmarks_in_tags.js create mode 100644 browser/components/places/tests/browser/browser_library_delete_tags.js create mode 100644 browser/components/places/tests/browser/browser_library_downloads.js create mode 100644 browser/components/places/tests/browser/browser_library_left_pane_middleclick.js create mode 100644 browser/components/places/tests/browser/browser_library_left_pane_select_hierarchy.js create mode 100644 browser/components/places/tests/browser/browser_library_middleclick.js create mode 100644 browser/components/places/tests/browser/browser_library_new_bookmark.js create mode 100644 browser/components/places/tests/browser/browser_library_openFlatContainer.js create mode 100644 browser/components/places/tests/browser/browser_library_open_all.js create mode 100644 browser/components/places/tests/browser/browser_library_open_all_with_separator.js create mode 100644 browser/components/places/tests/browser/browser_library_open_bookmark.js create mode 100644 browser/components/places/tests/browser/browser_library_open_leak.js create mode 100644 browser/components/places/tests/browser/browser_library_panel_leak.js create mode 100644 browser/components/places/tests/browser/browser_library_sameNodeDetailsPaneOptimization.js create mode 100644 browser/components/places/tests/browser/browser_library_search.js create mode 100644 browser/components/places/tests/browser/browser_library_tags_visibility.js create mode 100644 browser/components/places/tests/browser/browser_library_telemetry.js create mode 100644 browser/components/places/tests/browser/browser_library_tree_leak.js create mode 100644 browser/components/places/tests/browser/browser_library_views_liveupdate.js create mode 100644 browser/components/places/tests/browser/browser_library_warnOnOpen.js create mode 100644 browser/components/places/tests/browser/browser_markPageAsFollowedLink.js create mode 100644 browser/components/places/tests/browser/browser_panelview_bookmarks_delete.js create mode 100644 browser/components/places/tests/browser/browser_paste_bookmarks.js create mode 100644 browser/components/places/tests/browser/browser_paste_into_tags.js create mode 100644 browser/components/places/tests/browser/browser_paste_resets_cut_highlights.js create mode 100644 browser/components/places/tests/browser/browser_remove_bookmarks.js create mode 100644 browser/components/places/tests/browser/browser_sidebar_bookmarks_telemetry.js create mode 100644 browser/components/places/tests/browser/browser_sidebar_history_telemetry.js create mode 100644 browser/components/places/tests/browser/browser_sidebar_on_customization.js create mode 100644 browser/components/places/tests/browser/browser_sidebar_open_bookmarks.js create mode 100644 browser/components/places/tests/browser/browser_sidebarpanels_click.js create mode 100644 browser/components/places/tests/browser/browser_sort_in_library.js create mode 100644 browser/components/places/tests/browser/browser_stayopenmenu.js create mode 100644 browser/components/places/tests/browser/browser_toolbar_drop_bookmarklet.js create mode 100644 browser/components/places/tests/browser/browser_toolbar_drop_multiple_flavors.js create mode 100644 browser/components/places/tests/browser/browser_toolbar_drop_multiple_with_bookmarklet.js create mode 100644 browser/components/places/tests/browser/browser_toolbar_drop_text.js create mode 100644 browser/components/places/tests/browser/browser_toolbar_library_open_recent.js create mode 100644 browser/components/places/tests/browser/browser_toolbar_other_bookmarks.js create mode 100644 browser/components/places/tests/browser/browser_toolbar_overflow.js create mode 100644 browser/components/places/tests/browser/browser_toolbarbutton_menu_context.js create mode 100644 browser/components/places/tests/browser/browser_toolbarbutton_menu_show_in_folder.js create mode 100644 browser/components/places/tests/browser/browser_views_iconsupdate.js create mode 100644 browser/components/places/tests/browser/browser_views_liveupdate.js create mode 100644 browser/components/places/tests/browser/favicon-normal16.png create mode 100644 browser/components/places/tests/browser/frameLeft.html create mode 100644 browser/components/places/tests/browser/frameRight.html create mode 100644 browser/components/places/tests/browser/framedPage.html create mode 100644 browser/components/places/tests/browser/head.js create mode 100644 browser/components/places/tests/browser/interactions/browser.toml create mode 100644 browser/components/places/tests/browser/interactions/browser_interactions_blocklist.js create mode 100644 browser/components/places/tests/browser/interactions/browser_interactions_clearHistory.js create mode 100644 browser/components/places/tests/browser/interactions/browser_interactions_disabledHistory.js create mode 100644 browser/components/places/tests/browser/interactions/browser_interactions_referrer.js create mode 100644 browser/components/places/tests/browser/interactions/browser_interactions_scrolling.js create mode 100644 browser/components/places/tests/browser/interactions/browser_interactions_scrolling_dom_history.js create mode 100644 browser/components/places/tests/browser/interactions/browser_interactions_typing.js create mode 100644 browser/components/places/tests/browser/interactions/browser_interactions_typing_dom_history.js create mode 100644 browser/components/places/tests/browser/interactions/browser_interactions_view_time.js create mode 100644 browser/components/places/tests/browser/interactions/browser_interactions_view_time_dom_history.js create mode 100644 browser/components/places/tests/browser/interactions/head.js create mode 100644 browser/components/places/tests/browser/interactions/scrolling.html create mode 100644 browser/components/places/tests/browser/interactions/scrolling_subframe.html create mode 100644 browser/components/places/tests/browser/keyword_form.html create mode 100644 browser/components/places/tests/browser/pageopeningwindow.html create mode 100644 browser/components/places/tests/browser/sidebarpanels_click_test_page.html create mode 100644 browser/components/places/tests/chrome/chrome.toml create mode 100644 browser/components/places/tests/chrome/head.js create mode 100644 browser/components/places/tests/chrome/test_0_bug510634.xhtml create mode 100644 browser/components/places/tests/chrome/test_bug1163447_selectItems_through_shortcut.xhtml create mode 100644 browser/components/places/tests/chrome/test_bug549192.xhtml create mode 100644 browser/components/places/tests/chrome/test_bug549491.xhtml create mode 100644 browser/components/places/tests/chrome/test_selectItems_on_nested_tree.xhtml create mode 100644 browser/components/places/tests/chrome/test_treeview_date.xhtml create mode 100644 browser/components/places/tests/marionette/manifest.toml create mode 100644 browser/components/places/tests/marionette/test_reopen_from_library.py create mode 100644 browser/components/places/tests/unit/bookmarks.glue.html create mode 100644 browser/components/places/tests/unit/bookmarks.glue.json create mode 100644 browser/components/places/tests/unit/corruptDB.sqlite create mode 100644 browser/components/places/tests/unit/distribution.ini create mode 100644 browser/components/places/tests/unit/head_bookmarks.js create mode 100644 browser/components/places/tests/unit/test_PUIU_batchUpdatesForNode.js create mode 100644 browser/components/places/tests/unit/test_PUIU_setCharsetForPage.js create mode 100644 browser/components/places/tests/unit/test_PUIU_title_difference_spotter.js create mode 100644 browser/components/places/tests/unit/test_browserGlue_bookmarkshtml.js create mode 100644 browser/components/places/tests/unit/test_browserGlue_corrupt.js create mode 100644 browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup.js create mode 100644 browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup_default.js create mode 100644 browser/components/places/tests/unit/test_browserGlue_distribution.js create mode 100644 browser/components/places/tests/unit/test_browserGlue_migrate.js create mode 100644 browser/components/places/tests/unit/test_browserGlue_prefs.js create mode 100644 browser/components/places/tests/unit/test_browserGlue_restore.js create mode 100644 browser/components/places/tests/unit/test_clearHistory_shutdown.js create mode 100644 browser/components/places/tests/unit/test_interactions_blocklist.js create mode 100644 browser/components/places/tests/unit/test_invalid_defaultLocation.js create mode 100644 browser/components/places/tests/unit/xpcshell.toml create mode 100644 browser/components/pocket/.eslintrc.js create mode 100644 browser/components/pocket/.nvmrc create mode 100644 browser/components/pocket/README.md create mode 100644 browser/components/pocket/content/Pocket.sys.mjs create mode 100644 browser/components/pocket/content/SaveToPocket.sys.mjs create mode 100644 browser/components/pocket/content/panels/css/global.scss create mode 100644 browser/components/pocket/content/panels/css/home.scss create mode 100644 browser/components/pocket/content/panels/css/main.compiled.css create mode 100644 browser/components/pocket/content/panels/css/main.scss create mode 100644 browser/components/pocket/content/panels/css/normalize.scss create mode 100644 browser/components/pocket/content/panels/css/panel.scss create mode 100644 browser/components/pocket/content/panels/css/saved.scss create mode 100644 browser/components/pocket/content/panels/css/signup.scss create mode 100644 browser/components/pocket/content/panels/css/styleguide.scss create mode 100644 browser/components/pocket/content/panels/fonts/FiraSans-Regular.woff create mode 100644 browser/components/pocket/content/panels/home.html create mode 100644 browser/components/pocket/content/panels/img/chevron-right.svg create mode 100644 browser/components/pocket/content/panels/img/list-view.svg create mode 100644 browser/components/pocket/content/panels/img/open.svg create mode 100644 browser/components/pocket/content/panels/img/pocketerror@1x.png create mode 100644 browser/components/pocket/content/panels/img/pocketerror@2x.png create mode 100644 browser/components/pocket/content/panels/img/pocketlogo-dark.svg create mode 100644 browser/components/pocket/content/panels/img/pocketlogo.svg create mode 100644 browser/components/pocket/content/panels/img/pocketlogo@1x.png create mode 100644 browser/components/pocket/content/panels/img/pocketlogo@2x.png create mode 100644 browser/components/pocket/content/panels/img/pocketlogosolo@1x.png create mode 100644 browser/components/pocket/content/panels/img/pocketlogosolo@2x.png create mode 100644 browser/components/pocket/content/panels/img/pocketsignup_button@1x.png create mode 100644 browser/components/pocket/content/panels/img/pocketsignup_button@2x.png create mode 100644 browser/components/pocket/content/panels/img/pocketsignup_devices@1x.png create mode 100644 browser/components/pocket/content/panels/img/pocketsignup_devices@2x.png create mode 100644 browser/components/pocket/content/panels/img/pocketsignup_hero@1x.png create mode 100644 browser/components/pocket/content/panels/img/pocketsignup_hero@2x.png create mode 100644 browser/components/pocket/content/panels/img/rainbow-reader.svg create mode 100644 browser/components/pocket/content/panels/img/signup_firefoxlogo@1x.png create mode 100644 browser/components/pocket/content/panels/img/signup_firefoxlogo@2x.png create mode 100644 browser/components/pocket/content/panels/img/signup_help@1x.png create mode 100644 browser/components/pocket/content/panels/img/signup_help@2x.png create mode 100644 browser/components/pocket/content/panels/img/tag_close@1x.png create mode 100644 browser/components/pocket/content/panels/img/tag_close@2x.png create mode 100644 browser/components/pocket/content/panels/img/tag_closeactive@1x.png create mode 100644 browser/components/pocket/content/panels/img/tag_closeactive@2x.png create mode 100644 browser/components/pocket/content/panels/js/components/ArticleList/ArticleList.jsx create mode 100644 browser/components/pocket/content/panels/js/components/ArticleList/ArticleList.scss create mode 100644 browser/components/pocket/content/panels/js/components/Button/Button.jsx create mode 100644 browser/components/pocket/content/panels/js/components/Button/Button.scss create mode 100644 browser/components/pocket/content/panels/js/components/Header/Header.jsx create mode 100644 browser/components/pocket/content/panels/js/components/Header/Header.scss create mode 100644 browser/components/pocket/content/panels/js/components/Home/Home.jsx create mode 100644 browser/components/pocket/content/panels/js/components/PopularTopics/PopularTopics.jsx create mode 100644 browser/components/pocket/content/panels/js/components/PopularTopics/PopularTopics.scss create mode 100644 browser/components/pocket/content/panels/js/components/Saved/Saved.jsx create mode 100644 browser/components/pocket/content/panels/js/components/Saved/Saved.scss create mode 100644 browser/components/pocket/content/panels/js/components/Signup/Signup.jsx create mode 100644 browser/components/pocket/content/panels/js/components/Signup/Signup.scss create mode 100644 browser/components/pocket/content/panels/js/components/TagPicker/TagPicker.jsx create mode 100644 browser/components/pocket/content/panels/js/components/TagPicker/TagPicker.scss create mode 100644 browser/components/pocket/content/panels/js/components/TelemetryLink/TelemetryLink.jsx create mode 100644 browser/components/pocket/content/panels/js/home/entry.js create mode 100644 browser/components/pocket/content/panels/js/home/overlay.jsx create mode 100644 browser/components/pocket/content/panels/js/main.bundle.js create mode 100644 browser/components/pocket/content/panels/js/main.bundle.js.LICENSE.txt create mode 100644 browser/components/pocket/content/panels/js/main.mjs create mode 100644 browser/components/pocket/content/panels/js/messages.mjs create mode 100644 browser/components/pocket/content/panels/js/saved/entry.js create mode 100644 browser/components/pocket/content/panels/js/saved/overlay.jsx create mode 100644 browser/components/pocket/content/panels/js/signup/entry.js create mode 100644 browser/components/pocket/content/panels/js/signup/overlay.jsx create mode 100644 browser/components/pocket/content/panels/js/style-guide/entry.js create mode 100644 browser/components/pocket/content/panels/js/style-guide/overlay.jsx create mode 100644 browser/components/pocket/content/panels/js/vendor.bundle.js create mode 100644 browser/components/pocket/content/panels/js/vendor.bundle.js.LICENSE.txt create mode 100644 browser/components/pocket/content/panels/license.txt create mode 100644 browser/components/pocket/content/panels/saved.html create mode 100644 browser/components/pocket/content/panels/signup.html create mode 100644 browser/components/pocket/content/panels/style-guide.html create mode 100644 browser/components/pocket/content/pktApi.sys.mjs create mode 100644 browser/components/pocket/content/pktTelemetry.sys.mjs create mode 100644 browser/components/pocket/content/pktUI.js create mode 100644 browser/components/pocket/jar.mn create mode 100644 browser/components/pocket/metrics.yaml create mode 100644 browser/components/pocket/moz.build create mode 100644 browser/components/pocket/package-lock.json create mode 100644 browser/components/pocket/package.json create mode 100644 browser/components/pocket/pings.yaml create mode 100644 browser/components/pocket/test/browser.toml create mode 100644 browser/components/pocket/test/browser_pocket_button_icon_state.js create mode 100644 browser/components/pocket/test/browser_pocket_context_menu_action.js create mode 100644 browser/components/pocket/test/browser_pocket_home_panel.js create mode 100644 browser/components/pocket/test/browser_pocket_panel.js create mode 100644 browser/components/pocket/test/browser_pocket_panel_closemenu.js create mode 100644 browser/components/pocket/test/browser_pocket_ui_check.js create mode 100644 browser/components/pocket/test/head.js create mode 100644 browser/components/pocket/test/test.html create mode 100644 browser/components/pocket/test/unit/browser.toml create mode 100644 browser/components/pocket/test/unit/browser_pocket_AboutPocketParent.js create mode 100644 browser/components/pocket/test/unit/browser_pocket_pktTelemetry.js create mode 100644 browser/components/pocket/test/unit/browser_pocket_pktUI.js create mode 100644 browser/components/pocket/test/unit/head.js create mode 100644 browser/components/pocket/test/unit/panels/browser.toml create mode 100644 browser/components/pocket/test/unit/panels/browser_pocket_main.js create mode 100644 browser/components/pocket/test/unit/panels/head.js create mode 100644 browser/components/pocket/webpack.config.js create mode 100644 browser/components/preferences/containers.inc.xhtml create mode 100644 browser/components/preferences/containers.js create mode 100644 browser/components/preferences/dialogs/addEngine.css create mode 100644 browser/components/preferences/dialogs/addEngine.js create mode 100644 browser/components/preferences/dialogs/addEngine.xhtml create mode 100644 browser/components/preferences/dialogs/applicationManager.js create mode 100644 browser/components/preferences/dialogs/applicationManager.xhtml create mode 100644 browser/components/preferences/dialogs/blocklists.js create mode 100644 browser/components/preferences/dialogs/blocklists.xhtml create mode 100644 browser/components/preferences/dialogs/browserLanguages.js create mode 100644 browser/components/preferences/dialogs/browserLanguages.xhtml create mode 100644 browser/components/preferences/dialogs/clearSiteData.css create mode 100644 browser/components/preferences/dialogs/clearSiteData.js create mode 100644 browser/components/preferences/dialogs/clearSiteData.xhtml create mode 100644 browser/components/preferences/dialogs/colors.js create mode 100644 browser/components/preferences/dialogs/colors.xhtml create mode 100644 browser/components/preferences/dialogs/connection.js create mode 100644 browser/components/preferences/dialogs/connection.xhtml create mode 100644 browser/components/preferences/dialogs/containers.js create mode 100644 browser/components/preferences/dialogs/containers.xhtml create mode 100644 browser/components/preferences/dialogs/dohExceptions.js create mode 100644 browser/components/preferences/dialogs/dohExceptions.xhtml create mode 100644 browser/components/preferences/dialogs/fonts.js create mode 100644 browser/components/preferences/dialogs/fonts.xhtml create mode 100644 browser/components/preferences/dialogs/handlers.css create mode 100644 browser/components/preferences/dialogs/jar.mn create mode 100644 browser/components/preferences/dialogs/languages.js create mode 100644 browser/components/preferences/dialogs/languages.xhtml create mode 100644 browser/components/preferences/dialogs/moz.build create mode 100644 browser/components/preferences/dialogs/permissions.js create mode 100644 browser/components/preferences/dialogs/permissions.xhtml create mode 100644 browser/components/preferences/dialogs/sanitize.js create mode 100644 browser/components/preferences/dialogs/sanitize.xhtml create mode 100644 browser/components/preferences/dialogs/selectBookmark.js create mode 100644 browser/components/preferences/dialogs/selectBookmark.xhtml create mode 100644 browser/components/preferences/dialogs/siteDataRemoveSelected.js create mode 100644 browser/components/preferences/dialogs/siteDataRemoveSelected.xhtml create mode 100644 browser/components/preferences/dialogs/siteDataSettings.js create mode 100644 browser/components/preferences/dialogs/siteDataSettings.xhtml create mode 100644 browser/components/preferences/dialogs/sitePermissions.css create mode 100644 browser/components/preferences/dialogs/sitePermissions.js create mode 100644 browser/components/preferences/dialogs/sitePermissions.xhtml create mode 100644 browser/components/preferences/dialogs/syncChooseWhatToSync.js create mode 100644 browser/components/preferences/dialogs/syncChooseWhatToSync.xhtml create mode 100644 browser/components/preferences/dialogs/translationExceptions.js create mode 100644 browser/components/preferences/dialogs/translationExceptions.xhtml create mode 100644 browser/components/preferences/dialogs/translations.js create mode 100644 browser/components/preferences/dialogs/translations.xhtml create mode 100644 browser/components/preferences/experimental.inc.xhtml create mode 100644 browser/components/preferences/experimental.js create mode 100644 browser/components/preferences/extensionControlled.js create mode 100644 browser/components/preferences/findInPage.js create mode 100644 browser/components/preferences/fxaPairDevice.js create mode 100644 browser/components/preferences/fxaPairDevice.xhtml create mode 100644 browser/components/preferences/home.inc.xhtml create mode 100644 browser/components/preferences/home.js create mode 100644 browser/components/preferences/jar.mn create mode 100644 browser/components/preferences/main.inc.xhtml create mode 100644 browser/components/preferences/main.js create mode 100644 browser/components/preferences/metrics.yaml create mode 100644 browser/components/preferences/more-from-mozilla-qr-code-simple-cn.svg create mode 100644 browser/components/preferences/more-from-mozilla-qr-code-simple.svg create mode 100644 browser/components/preferences/moreFromMozilla.inc.xhtml create mode 100644 browser/components/preferences/moreFromMozilla.js create mode 100644 browser/components/preferences/moz.build create mode 100644 browser/components/preferences/preferences.js create mode 100644 browser/components/preferences/preferences.xhtml create mode 100644 browser/components/preferences/privacy.inc.xhtml create mode 100644 browser/components/preferences/privacy.js create mode 100644 browser/components/preferences/search.inc.xhtml create mode 100644 browser/components/preferences/search.js create mode 100644 browser/components/preferences/searchResults.inc.xhtml create mode 100644 browser/components/preferences/sync.inc.xhtml create mode 100644 browser/components/preferences/sync.js create mode 100644 browser/components/preferences/tests/addons/pl-dictionary.xpi create mode 100644 browser/components/preferences/tests/addons/set_homepage.xpi create mode 100644 browser/components/preferences/tests/addons/set_newtab.xpi create mode 100644 browser/components/preferences/tests/browser.toml create mode 100644 browser/components/preferences/tests/browser_advanced_update.js create mode 100644 browser/components/preferences/tests/browser_application_xml_handle_internally.js create mode 100644 browser/components/preferences/tests/browser_applications_selection.js create mode 100644 browser/components/preferences/tests/browser_basic_rebuild_fonts_test.js create mode 100644 browser/components/preferences/tests/browser_browser_languages_subdialog.js create mode 100644 browser/components/preferences/tests/browser_bug1018066_resetScrollPosition.js create mode 100644 browser/components/preferences/tests/browser_bug1020245_openPreferences_to_paneContent.js create mode 100644 browser/components/preferences/tests/browser_bug1184989_prevent_scrolling_when_preferences_flipped.js create mode 100644 browser/components/preferences/tests/browser_bug1184989_prevent_scrolling_when_preferences_flipped.xhtml create mode 100644 browser/components/preferences/tests/browser_bug1547020_lockedDownloadDir.js create mode 100644 browser/components/preferences/tests/browser_bug1579418.js create mode 100644 browser/components/preferences/tests/browser_bug410900.js create mode 100644 browser/components/preferences/tests/browser_bug731866.js create mode 100644 browser/components/preferences/tests/browser_bug795764_cachedisabled.js create mode 100644 browser/components/preferences/tests/browser_cert_export.js create mode 100644 browser/components/preferences/tests/browser_change_app_handler.js create mode 100644 browser/components/preferences/tests/browser_checkspelling.js create mode 100644 browser/components/preferences/tests/browser_connection.js create mode 100644 browser/components/preferences/tests/browser_connection_bug1445991.js create mode 100644 browser/components/preferences/tests/browser_connection_bug1505330.js create mode 100644 browser/components/preferences/tests/browser_connection_bug388287.js create mode 100644 browser/components/preferences/tests/browser_connection_valid_hostname.js create mode 100644 browser/components/preferences/tests/browser_containers_name_input.js create mode 100644 browser/components/preferences/tests/browser_contentblocking.js create mode 100644 browser/components/preferences/tests/browser_contentblocking_categories.js create mode 100644 browser/components/preferences/tests/browser_contentblocking_standard_tcp_section.js create mode 100644 browser/components/preferences/tests/browser_cookie_exceptions_addRemove.js create mode 100644 browser/components/preferences/tests/browser_cookies_exceptions.js create mode 100644 browser/components/preferences/tests/browser_defaultbrowser_alwayscheck.js create mode 100644 browser/components/preferences/tests/browser_engines.js create mode 100644 browser/components/preferences/tests/browser_ensure_prefs_bindings_initted.js create mode 100644 browser/components/preferences/tests/browser_etp_exceptions_dialog.js create mode 100644 browser/components/preferences/tests/browser_experimental_features.js create mode 100644 browser/components/preferences/tests/browser_experimental_features_filter.js create mode 100644 browser/components/preferences/tests/browser_experimental_features_hidden_when_not_public.js create mode 100644 browser/components/preferences/tests/browser_experimental_features_resetall.js create mode 100644 browser/components/preferences/tests/browser_extension_controlled.js create mode 100644 browser/components/preferences/tests/browser_filetype_dialog.js create mode 100644 browser/components/preferences/tests/browser_fluent.js create mode 100644 browser/components/preferences/tests/browser_homepage_default.js create mode 100644 browser/components/preferences/tests/browser_homepages_filter_aboutpreferences.js create mode 100644 browser/components/preferences/tests/browser_homepages_use_bookmark.js create mode 100644 browser/components/preferences/tests/browser_hometab_restore_defaults.js create mode 100644 browser/components/preferences/tests/browser_https_only_exceptions.js create mode 100644 browser/components/preferences/tests/browser_https_only_section.js create mode 100644 browser/components/preferences/tests/browser_ignore_invalid_capability.js create mode 100644 browser/components/preferences/tests/browser_keyboardfocus.js create mode 100644 browser/components/preferences/tests/browser_languages_subdialog.js create mode 100644 browser/components/preferences/tests/browser_layersacceleration.js create mode 100644 browser/components/preferences/tests/browser_localSearchShortcuts.js create mode 100644 browser/components/preferences/tests/browser_moreFromMozilla.js create mode 100644 browser/components/preferences/tests/browser_moreFromMozilla_locales.js create mode 100644 browser/components/preferences/tests/browser_newtab_menu.js create mode 100644 browser/components/preferences/tests/browser_notifications_do_not_disturb.js create mode 100644 browser/components/preferences/tests/browser_open_download_preferences.js create mode 100644 browser/components/preferences/tests/browser_open_migration_wizard.js create mode 100644 browser/components/preferences/tests/browser_password_management.js create mode 100644 browser/components/preferences/tests/browser_pdf_disabled.js create mode 100644 browser/components/preferences/tests/browser_performance.js create mode 100644 browser/components/preferences/tests/browser_performance_content_process_limit.js create mode 100644 browser/components/preferences/tests/browser_performance_e10srollout.js create mode 100644 browser/components/preferences/tests/browser_performance_non_e10s.js create mode 100644 browser/components/preferences/tests/browser_permissions_checkPermissionsWereAdded.js create mode 100644 browser/components/preferences/tests/browser_permissions_dialog.js create mode 100644 browser/components/preferences/tests/browser_permissions_dialog_default_perm.js create mode 100644 browser/components/preferences/tests/browser_permissions_urlFieldHidden.js create mode 100644 browser/components/preferences/tests/browser_primaryPassword.js create mode 100644 browser/components/preferences/tests/browser_privacy_cookieBannerHandling.js create mode 100644 browser/components/preferences/tests/browser_privacy_dnsoverhttps.js create mode 100644 browser/components/preferences/tests/browser_privacy_firefoxSuggest.js create mode 100644 browser/components/preferences/tests/browser_privacy_gpc.js create mode 100644 browser/components/preferences/tests/browser_privacy_passwordGenerationAndAutofill.js create mode 100644 browser/components/preferences/tests/browser_privacy_relayIntegration.js create mode 100644 browser/components/preferences/tests/browser_privacy_segmentation_pref.js create mode 100644 browser/components/preferences/tests/browser_privacy_syncDataClearing.js create mode 100644 browser/components/preferences/tests/browser_privacypane_2.js create mode 100644 browser/components/preferences/tests/browser_privacypane_3.js create mode 100644 browser/components/preferences/tests/browser_proxy_backup.js create mode 100644 browser/components/preferences/tests/browser_sanitizeOnShutdown_prefLocked.js create mode 100644 browser/components/preferences/tests/browser_searchChangedEngine.js create mode 100644 browser/components/preferences/tests/browser_searchDefaultEngine.js create mode 100644 browser/components/preferences/tests/browser_searchFindMoreLink.js create mode 100644 browser/components/preferences/tests/browser_searchRestoreDefaults.js create mode 100644 browser/components/preferences/tests/browser_searchScroll.js create mode 100644 browser/components/preferences/tests/browser_searchShowSuggestionsFirst.js create mode 100644 browser/components/preferences/tests/browser_search_firefoxSuggest.js create mode 100644 browser/components/preferences/tests/browser_search_no_results_change_category.js create mode 100644 browser/components/preferences/tests/browser_search_quickactions.js create mode 100644 browser/components/preferences/tests/browser_search_searchTerms.js create mode 100644 browser/components/preferences/tests/browser_search_subdialog_tooltip_saved_addresses.js create mode 100644 browser/components/preferences/tests/browser_search_subdialogs_within_preferences_1.js create mode 100644 browser/components/preferences/tests/browser_search_subdialogs_within_preferences_2.js create mode 100644 browser/components/preferences/tests/browser_search_subdialogs_within_preferences_3.js create mode 100644 browser/components/preferences/tests/browser_search_subdialogs_within_preferences_4.js create mode 100644 browser/components/preferences/tests/browser_search_subdialogs_within_preferences_5.js create mode 100644 browser/components/preferences/tests/browser_search_subdialogs_within_preferences_6.js create mode 100644 browser/components/preferences/tests/browser_search_subdialogs_within_preferences_7.js create mode 100644 browser/components/preferences/tests/browser_search_subdialogs_within_preferences_8.js create mode 100644 browser/components/preferences/tests/browser_search_subdialogs_within_preferences_site_data.js create mode 100644 browser/components/preferences/tests/browser_search_within_preferences_1.js create mode 100644 browser/components/preferences/tests/browser_search_within_preferences_2.js create mode 100644 browser/components/preferences/tests/browser_search_within_preferences_command.js create mode 100644 browser/components/preferences/tests/browser_searchsuggestions.js create mode 100644 browser/components/preferences/tests/browser_security-1.js create mode 100644 browser/components/preferences/tests/browser_security-2.js create mode 100644 browser/components/preferences/tests/browser_security-3.js create mode 100644 browser/components/preferences/tests/browser_site_login_exceptions.js create mode 100644 browser/components/preferences/tests/browser_site_login_exceptions_policy.js create mode 100644 browser/components/preferences/tests/browser_spotlight.js create mode 100644 browser/components/preferences/tests/browser_statePartitioning_PBM_strings.js create mode 100644 browser/components/preferences/tests/browser_statePartitioning_strings.js create mode 100644 browser/components/preferences/tests/browser_subdialogs.js create mode 100644 browser/components/preferences/tests/browser_sync_chooseWhatToSync.js create mode 100644 browser/components/preferences/tests/browser_sync_disabled.js create mode 100644 browser/components/preferences/tests/browser_sync_pairing.js create mode 100644 browser/components/preferences/tests/browser_trendingsuggestions.js create mode 100644 browser/components/preferences/tests/browser_warning_permanent_private_browsing.js create mode 100644 browser/components/preferences/tests/browser_windows_launch_on_login.js create mode 100644 browser/components/preferences/tests/empty_pdf_file.pdf create mode 100644 browser/components/preferences/tests/engine1/manifest.json create mode 100644 browser/components/preferences/tests/engine2/manifest.json create mode 100644 browser/components/preferences/tests/head.js create mode 100644 browser/components/preferences/tests/privacypane_tests_perwindow.js create mode 100644 browser/components/preferences/tests/siteData/browser.toml create mode 100644 browser/components/preferences/tests/siteData/browser_clearSiteData.js create mode 100644 browser/components/preferences/tests/siteData/browser_siteData.js create mode 100644 browser/components/preferences/tests/siteData/browser_siteData2.js create mode 100644 browser/components/preferences/tests/siteData/browser_siteData3.js create mode 100644 browser/components/preferences/tests/siteData/browser_siteData_multi_select.js create mode 100644 browser/components/preferences/tests/siteData/head.js create mode 100644 browser/components/preferences/tests/siteData/offline/manifest.appcache create mode 100644 browser/components/preferences/tests/siteData/offline/offline.html create mode 100644 browser/components/preferences/tests/siteData/service_worker_test.html create mode 100644 browser/components/preferences/tests/siteData/service_worker_test.js create mode 100644 browser/components/preferences/tests/siteData/site_data_test.html create mode 100644 browser/components/preferences/tests/subdialog.xhtml create mode 100644 browser/components/preferences/tests/subdialog2.xhtml create mode 100644 browser/components/preferences/translations.inc.xhtml create mode 100644 browser/components/preferences/translations.js create mode 100644 browser/components/preferences/web-appearance-dark.svg create mode 100644 browser/components/preferences/web-appearance-light.svg create mode 100644 browser/components/privatebrowsing/ResetPBMPanel.sys.mjs create mode 100644 browser/components/privatebrowsing/content/aboutPrivateBrowsing.css create mode 100644 browser/components/privatebrowsing/content/aboutPrivateBrowsing.html create mode 100644 browser/components/privatebrowsing/content/aboutPrivateBrowsing.js create mode 100644 browser/components/privatebrowsing/content/assets/cookie-banners-begone.svg create mode 100644 browser/components/privatebrowsing/content/assets/focus-logo.svg create mode 100644 browser/components/privatebrowsing/content/assets/focus-promo.png create mode 100644 browser/components/privatebrowsing/content/assets/focus-qr-code.svg create mode 100644 browser/components/privatebrowsing/content/assets/klar-qr-code.svg create mode 100644 browser/components/privatebrowsing/content/assets/moz-vpn.svg create mode 100644 browser/components/privatebrowsing/content/assets/private-promo-asset.svg create mode 100644 browser/components/privatebrowsing/content/assets/vpn-logo.svg create mode 100644 browser/components/privatebrowsing/jar.mn create mode 100644 browser/components/privatebrowsing/metrics.yaml create mode 100644 browser/components/privatebrowsing/moz.build create mode 100644 browser/components/privatebrowsing/test/browser/browser.toml create mode 100644 browser/components/privatebrowsing/test/browser/browser_oa_private_browsing_window.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_DownloadLastDirWithCPS.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_aboutSessionRestore.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_cookie_banners_promo.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_default_pin_promo.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_default_promo.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_focus_promo.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus_dismiss.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus_impressions.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus_messaging.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_search_banner.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_beacon.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_blobUrl.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_cache.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_certexceptionsui.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_cleanup.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_concurrent.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_concurrent_page.html create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_context_and_chromeFlags.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_crh.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_downloadLastDir.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_downloadLastDir_c.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_downloadLastDir_toggle.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_favicon.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_geoprompt_page.html create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_history_shift_click.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_last_private_browsing_context_exited.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_lastpbcontextexited.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage_before_after.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage_before_after_page.html create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage_before_after_page2.html create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage_page1.html create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage_page2.html create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_newtab_from_popup.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_noSessionRestoreMenuOption.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_nonbrowser.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_opendir.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_placesTitleNoUpdate.html create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_placesTitleNoUpdate.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_placestitle.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_protocolhandler.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_protocolhandler_page.html create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_rememberprompt.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_resetPBM.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_sidebar.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_theming.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_ui.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_urlbarfocus.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_windowtitle.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_windowtitle_page.html create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_xrprompt_page.html create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_zoom.js create mode 100644 browser/components/privatebrowsing/test/browser/browser_privatebrowsing_zoomrestore.js create mode 100644 browser/components/privatebrowsing/test/browser/empty_file.html create mode 100644 browser/components/privatebrowsing/test/browser/file_favicon.html create mode 100644 browser/components/privatebrowsing/test/browser/file_favicon.png create mode 100644 browser/components/privatebrowsing/test/browser/file_favicon.png^headers^ create mode 100644 browser/components/privatebrowsing/test/browser/file_triggeringprincipal_oa.html create mode 100644 browser/components/privatebrowsing/test/browser/head.js create mode 100644 browser/components/privatebrowsing/test/browser/title.sjs create mode 100644 browser/components/prompts/PromptCollection.sys.mjs create mode 100644 browser/components/prompts/components.conf create mode 100644 browser/components/prompts/moz.build create mode 100644 browser/components/protections/content/lockwise-card.mjs create mode 100644 browser/components/protections/content/monitor-card.mjs create mode 100644 browser/components/protections/content/protections.css create mode 100644 browser/components/protections/content/protections.ftl create mode 100644 browser/components/protections/content/protections.html create mode 100644 browser/components/protections/content/protections.mjs create mode 100644 browser/components/protections/content/proxy-card.mjs create mode 100644 browser/components/protections/content/vpn-card.mjs create mode 100644 browser/components/protections/jar.mn create mode 100644 browser/components/protections/moz.build create mode 100644 browser/components/protections/test/browser/browser.toml create mode 100644 browser/components/protections/test/browser/browser_protections_lockwise.js create mode 100644 browser/components/protections/test/browser/browser_protections_monitor.js create mode 100644 browser/components/protections/test/browser/browser_protections_proxy.js create mode 100644 browser/components/protections/test/browser/browser_protections_report_ui.js create mode 100644 browser/components/protections/test/browser/browser_protections_telemetry.js create mode 100644 browser/components/protections/test/browser/browser_protections_vpn.js create mode 100644 browser/components/protections/test/browser/head.js create mode 100644 browser/components/protocolhandler/WebProtocolHandlerRegistrar.sys.mjs create mode 100644 browser/components/protocolhandler/components.conf create mode 100644 browser/components/protocolhandler/metrics.yaml create mode 100644 browser/components/protocolhandler/moz.build create mode 100644 browser/components/protocolhandler/test/browser/browser.toml create mode 100644 browser/components/protocolhandler/test/browser/browser_registerProtocolHandler_notification.html create mode 100644 browser/components/protocolhandler/test/browser/browser_registerProtocolHandler_notification.js create mode 100644 browser/components/protocolhandler/test/test_registerHandler.html create mode 100644 browser/components/reportbrokensite/ReportBrokenSite.sys.mjs create mode 100644 browser/components/reportbrokensite/content/reportBrokenSitePanel.inc.xhtml create mode 100644 browser/components/reportbrokensite/moz.build create mode 100644 browser/components/reportbrokensite/test/browser/browser.toml create mode 100644 browser/components/reportbrokensite/test/browser/browser_antitracking_data_sent.js create mode 100644 browser/components/reportbrokensite/test/browser/browser_back_buttons.js create mode 100644 browser/components/reportbrokensite/test/browser/browser_error_messages.js create mode 100644 browser/components/reportbrokensite/test/browser/browser_keyboard_navigation.js create mode 100644 browser/components/reportbrokensite/test/browser/browser_parent_menuitems.js create mode 100644 browser/components/reportbrokensite/test/browser/browser_prefers_contrast.js create mode 100644 browser/components/reportbrokensite/test/browser/browser_reason_dropdown.js create mode 100644 browser/components/reportbrokensite/test/browser/browser_report_send.js create mode 100644 browser/components/reportbrokensite/test/browser/browser_report_site_issue_fallback.js create mode 100644 browser/components/reportbrokensite/test/browser/browser_send_more_info.js create mode 100644 browser/components/reportbrokensite/test/browser/browser_site_not_working_fallback.js create mode 100644 browser/components/reportbrokensite/test/browser/browser_tab_key_order.js create mode 100644 browser/components/reportbrokensite/test/browser/browser_tab_switch_handling.js create mode 100644 browser/components/reportbrokensite/test/browser/example_report_page.html create mode 100644 browser/components/reportbrokensite/test/browser/head.js create mode 100644 browser/components/reportbrokensite/test/browser/send.js create mode 100644 browser/components/reportbrokensite/test/browser/sendMoreInfoTestEndpoint.html create mode 100644 browser/components/reportbrokensite/test/browser/send_more_info.js create mode 100644 browser/components/resistfingerprinting/moz.build create mode 100644 browser/components/resistfingerprinting/test/browser/browser.toml create mode 100644 browser/components/resistfingerprinting/test/browser/browser_animationapi_iframes.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_block_mozAddonManager.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_bug1369357_site_specific_zoom_level.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_cross_origin_isolated_animation_api.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_cross_origin_isolated_performance_api.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_cross_origin_isolated_reduce_time_precision.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_dynamical_window_rounding.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_etp_iframes.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_aboutblank.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_aboutsrcdoc.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_blob.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_blobcrossorigin.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_data.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_iframes_sandboxediframe.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_aboutblank.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_blob.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_blob_noopener.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_data.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_data_noopener.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_hwconcurrency_popups_noopener.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_math.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_navigator.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_navigator_iframes.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_netInfo.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_performanceAPI.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_performanceAPIWorkers.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_reduceTimePrecision_iframes.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_roundedWindow_dialogWindow.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_roundedWindow_newWindow.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_roundedWindow_open_max_inner.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_roundedWindow_open_mid_inner.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_roundedWindow_open_min_inner.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_spoofing_keyboard_event.js create mode 100644 browser/components/resistfingerprinting/test/browser/browser_timezone.js create mode 100644 browser/components/resistfingerprinting/test/browser/coop_header.sjs create mode 100644 browser/components/resistfingerprinting/test/browser/file_animationapi_iframee.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_animationapi_iframer.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_dummy.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_hwconcurrency_aboutblank_iframee.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_hwconcurrency_aboutblank_iframer.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_hwconcurrency_aboutblank_popupmaker.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_hwconcurrency_aboutsrcdoc_iframee.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_hwconcurrency_aboutsrcdoc_iframer.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_hwconcurrency_blob_iframee.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_hwconcurrency_blob_iframer.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_hwconcurrency_blob_popupmaker.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_hwconcurrency_blobcrossorigin_iframee.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_hwconcurrency_blobcrossorigin_iframer.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_hwconcurrency_data_iframee.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_hwconcurrency_data_iframer.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_hwconcurrency_data_popupmaker.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_hwconcurrency_iframee.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_hwconcurrency_iframer.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_hwconcurrency_sandboxediframe_double_framee.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_hwconcurrency_sandboxediframe_iframee.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_hwconcurrency_sandboxediframe_iframer.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_keyBoardEvent.sjs create mode 100644 browser/components/resistfingerprinting/test/browser/file_navigator.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_navigator.worker.js create mode 100644 browser/components/resistfingerprinting/test/browser/file_navigator_header.sjs create mode 100644 browser/components/resistfingerprinting/test/browser/file_navigator_iframe_worker.sjs create mode 100644 browser/components/resistfingerprinting/test/browser/file_navigator_iframee.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_navigator_iframer.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_reduceTimePrecision_iframe_worker.sjs create mode 100644 browser/components/resistfingerprinting/test/browser/file_reduceTimePrecision_iframee.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_reduceTimePrecision_iframer.html create mode 100644 browser/components/resistfingerprinting/test/browser/file_workerNetInfo.js create mode 100644 browser/components/resistfingerprinting/test/browser/file_workerPerformance.js create mode 100644 browser/components/resistfingerprinting/test/browser/head.js create mode 100644 browser/components/resistfingerprinting/test/browser/shared_test_funcs.js create mode 100644 browser/components/resistfingerprinting/test/mochitest/.eslintrc.js create mode 100644 browser/components/resistfingerprinting/test/mochitest/decode_error.mp4 create mode 100644 browser/components/resistfingerprinting/test/mochitest/file_animation_api.html create mode 100644 browser/components/resistfingerprinting/test/mochitest/mochitest.toml create mode 100644 browser/components/resistfingerprinting/test/mochitest/test_animation_api.html create mode 100644 browser/components/resistfingerprinting/test/mochitest/test_bug1354633_media_error.html create mode 100644 browser/components/resistfingerprinting/test/mochitest/test_bug1382499_touch_api.html create mode 100644 browser/components/resistfingerprinting/test/mochitest/test_bug863246_resource_uri.html create mode 100644 browser/components/resistfingerprinting/test/mochitest/test_device_sensor_event.html create mode 100644 browser/components/resistfingerprinting/test/mochitest/test_geolocation.html create mode 100644 browser/components/resistfingerprinting/test/mochitest/test_hide_gamepad_info.html create mode 100644 browser/components/resistfingerprinting/test/mochitest/test_hide_gamepad_info_iframe.html create mode 100644 browser/components/resistfingerprinting/test/mochitest/test_iframe.html create mode 100644 browser/components/resistfingerprinting/test/mochitest/test_keyboard_event.html create mode 100644 browser/components/resistfingerprinting/test/mochitest/test_pointer_event.html create mode 100644 browser/components/resistfingerprinting/test/mochitest/test_speech_synthesis.html create mode 100644 browser/components/resistfingerprinting/test/mochitest/worker_child.js create mode 100644 browser/components/resistfingerprinting/test/mochitest/worker_grandchild.js create mode 100644 browser/components/safebrowsing/content/test/browser.toml create mode 100644 browser/components/safebrowsing/content/test/browser_bug400731.js create mode 100644 browser/components/safebrowsing/content/test/browser_bug415846.js create mode 100644 browser/components/safebrowsing/content/test/browser_mixedcontent_aboutblocked.js create mode 100644 browser/components/safebrowsing/content/test/browser_whitelisted.js create mode 100644 browser/components/safebrowsing/content/test/empty_file.html create mode 100644 browser/components/safebrowsing/content/test/head.js create mode 100644 browser/components/screenshots/ScreenshotsHelperChild.sys.mjs create mode 100644 browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs create mode 100644 browser/components/screenshots/ScreenshotsUtils.sys.mjs create mode 100644 browser/components/screenshots/content/cancel.svg create mode 100644 browser/components/screenshots/content/copied-notification.svg create mode 100644 browser/components/screenshots/content/copy.svg create mode 100644 browser/components/screenshots/content/download-white.svg create mode 100644 browser/components/screenshots/content/download.svg create mode 100644 browser/components/screenshots/content/icon-welcome-face-without-eyes.svg create mode 100644 browser/components/screenshots/content/menu-fullpage.svg create mode 100644 browser/components/screenshots/content/menu-visible.svg create mode 100644 browser/components/screenshots/content/screenshots.css create mode 100644 browser/components/screenshots/content/screenshots.html create mode 100644 browser/components/screenshots/content/screenshots.js create mode 100644 browser/components/screenshots/fileHelpers.mjs create mode 100644 browser/components/screenshots/jar.mn create mode 100644 browser/components/screenshots/moz.build create mode 100644 browser/components/screenshots/overlay/overlay.css create mode 100644 browser/components/screenshots/overlayHelpers.mjs create mode 100644 browser/components/screenshots/screenshots-buttons.css create mode 100644 browser/components/screenshots/screenshots-buttons.js create mode 100644 browser/components/screenshots/tests/browser/browser.toml create mode 100644 browser/components/screenshots/tests/browser/browser_iframe_test.js create mode 100644 browser/components/screenshots/tests/browser/browser_overlay_keyboard_test.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_drag_scroll_test.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_drag_test.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_focus_test.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_overlay_panel_sync.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_page_unload.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_short_page_test.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_telemetry_tests.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_test_downloads.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_test_escape.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_test_full_page.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_test_page_crash.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_test_screenshot_too_big.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_test_toggle_pref.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_test_toolbar_button.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_test_visible.js create mode 100644 browser/components/screenshots/tests/browser/browser_test_element_picker.js create mode 100644 browser/components/screenshots/tests/browser/browser_test_resize.js create mode 100644 browser/components/screenshots/tests/browser/first-iframe.html create mode 100644 browser/components/screenshots/tests/browser/head.js create mode 100644 browser/components/screenshots/tests/browser/iframe-test-page.html create mode 100644 browser/components/screenshots/tests/browser/large-test-page.html create mode 100644 browser/components/screenshots/tests/browser/second-iframe.html create mode 100644 browser/components/screenshots/tests/browser/short-test-page.html create mode 100644 browser/components/screenshots/tests/browser/test-page-resize.html create mode 100644 browser/components/screenshots/tests/browser/test-page.html create mode 100644 browser/components/search/.eslintrc.js create mode 100644 browser/components/search/BrowserSearchTelemetry.sys.mjs create mode 100644 browser/components/search/SearchOneOffs.sys.mjs create mode 100644 browser/components/search/SearchSERPTelemetry.sys.mjs create mode 100644 browser/components/search/SearchUIUtils.sys.mjs create mode 100644 browser/components/search/content/autocomplete-popup.js create mode 100644 browser/components/search/content/contentSearchHandoffUI.js create mode 100644 browser/components/search/content/contentSearchUI.css create mode 100644 browser/components/search/content/contentSearchUI.js create mode 100644 browser/components/search/content/searchbar.js create mode 100644 browser/components/search/docs/Preferences.rst create mode 100644 browser/components/search/docs/application-search-engines.rst create mode 100644 browser/components/search/docs/index.rst create mode 100644 browser/components/search/docs/telemetry.rst create mode 100644 browser/components/search/extensions/1und1/favicon.ico create mode 100644 browser/components/search/extensions/1und1/manifest.json create mode 100644 browser/components/search/extensions/allegro-pl/favicon.ico create mode 100644 browser/components/search/extensions/allegro-pl/manifest.json create mode 100644 browser/components/search/extensions/amazon/_locales/au/messages.json create mode 100644 browser/components/search/extensions/amazon/_locales/ca/messages.json create mode 100644 browser/components/search/extensions/amazon/_locales/de/messages.json create mode 100644 browser/components/search/extensions/amazon/_locales/en-GB/messages.json create mode 100644 browser/components/search/extensions/amazon/_locales/france/messages.json create mode 100644 browser/components/search/extensions/amazon/_locales/in/messages.json create mode 100644 browser/components/search/extensions/amazon/_locales/it/messages.json create mode 100644 browser/components/search/extensions/amazon/_locales/jp/messages.json create mode 100644 browser/components/search/extensions/amazon/_locales/nl/messages.json create mode 100644 browser/components/search/extensions/amazon/_locales/spain/messages.json create mode 100644 browser/components/search/extensions/amazon/_locales/sweden/messages.json create mode 100644 browser/components/search/extensions/amazon/favicon.ico create mode 100644 browser/components/search/extensions/amazon/manifest.json create mode 100644 browser/components/search/extensions/amazondotcn/_locales/default/messages.json create mode 100644 browser/components/search/extensions/amazondotcn/_locales/mozillaonline/messages.json create mode 100644 browser/components/search/extensions/amazondotcn/favicon.ico create mode 100644 browser/components/search/extensions/amazondotcn/manifest.json create mode 100644 browser/components/search/extensions/amazondotcom/_locales/en/messages.json create mode 100644 browser/components/search/extensions/amazondotcom/_locales/us/messages.json create mode 100644 browser/components/search/extensions/amazondotcom/favicon.ico create mode 100644 browser/components/search/extensions/amazondotcom/manifest.json create mode 100644 browser/components/search/extensions/azerdict/favicon.ico create mode 100644 browser/components/search/extensions/azerdict/manifest.json create mode 100644 browser/components/search/extensions/baidu/favicon.ico create mode 100644 browser/components/search/extensions/baidu/manifest.json create mode 100644 browser/components/search/extensions/bing/favicon.ico create mode 100644 browser/components/search/extensions/bing/manifest.json create mode 100644 browser/components/search/extensions/bok-NO/favicon.png create mode 100644 browser/components/search/extensions/bok-NO/manifest.json create mode 100644 browser/components/search/extensions/ceneji/favicon.png create mode 100644 browser/components/search/extensions/ceneji/manifest.json create mode 100644 browser/components/search/extensions/coccoc/favicon.ico create mode 100644 browser/components/search/extensions/coccoc/manifest.json create mode 100644 browser/components/search/extensions/daum-kr/favicon.ico create mode 100644 browser/components/search/extensions/daum-kr/manifest.json create mode 100644 browser/components/search/extensions/ddg/favicon.ico create mode 100644 browser/components/search/extensions/ddg/manifest.json create mode 100644 browser/components/search/extensions/ebay/_locales/at/messages.json create mode 100644 browser/components/search/extensions/ebay/_locales/au/messages.json create mode 100644 browser/components/search/extensions/ebay/_locales/be/messages.json create mode 100644 browser/components/search/extensions/ebay/_locales/ca/messages.json create mode 100644 browser/components/search/extensions/ebay/_locales/ch/messages.json create mode 100644 browser/components/search/extensions/ebay/_locales/de/messages.json create mode 100644 browser/components/search/extensions/ebay/_locales/en/messages.json create mode 100644 browser/components/search/extensions/ebay/_locales/es/messages.json create mode 100644 browser/components/search/extensions/ebay/_locales/fr/messages.json create mode 100644 browser/components/search/extensions/ebay/_locales/ie/messages.json create mode 100644 browser/components/search/extensions/ebay/_locales/it/messages.json create mode 100644 browser/components/search/extensions/ebay/_locales/nl/messages.json create mode 100644 browser/components/search/extensions/ebay/_locales/uk/messages.json create mode 100644 browser/components/search/extensions/ebay/favicon.ico create mode 100644 browser/components/search/extensions/ebay/manifest.json create mode 100644 browser/components/search/extensions/ecosia/favicon.ico create mode 100644 browser/components/search/extensions/ecosia/manifest.json create mode 100644 browser/components/search/extensions/eudict/favicon.ico create mode 100644 browser/components/search/extensions/eudict/manifest.json create mode 100644 browser/components/search/extensions/faclair-beag/favicon.ico create mode 100644 browser/components/search/extensions/faclair-beag/manifest.json create mode 100644 browser/components/search/extensions/gmx/_locales/de/messages.json create mode 100644 browser/components/search/extensions/gmx/_locales/en-GB/messages.json create mode 100644 browser/components/search/extensions/gmx/_locales/es/messages.json create mode 100644 browser/components/search/extensions/gmx/_locales/fr/messages.json create mode 100644 browser/components/search/extensions/gmx/_locales/shopping/messages.json create mode 100644 browser/components/search/extensions/gmx/favicon.png create mode 100644 browser/components/search/extensions/gmx/manifest.json create mode 100644 browser/components/search/extensions/google/_locales/en/messages.json create mode 100644 browser/components/search/extensions/google/_locales/region-by/messages.json create mode 100644 browser/components/search/extensions/google/_locales/region-kz/messages.json create mode 100644 browser/components/search/extensions/google/_locales/region-ru/messages.json create mode 100644 browser/components/search/extensions/google/_locales/region-tr/messages.json create mode 100644 browser/components/search/extensions/google/favicon.ico create mode 100644 browser/components/search/extensions/google/manifest.json create mode 100644 browser/components/search/extensions/gulesider-NO/favicon.ico create mode 100644 browser/components/search/extensions/gulesider-NO/manifest.json create mode 100644 browser/components/search/extensions/leo_ende_de/favicon.png create mode 100644 browser/components/search/extensions/leo_ende_de/manifest.json create mode 100644 browser/components/search/extensions/longdo/favicon.ico create mode 100644 browser/components/search/extensions/longdo/manifest.json create mode 100644 browser/components/search/extensions/mailcom/favicon.ico create mode 100644 browser/components/search/extensions/mailcom/manifest.json create mode 100644 browser/components/search/extensions/mailru/_locales/default/messages.json create mode 100644 browser/components/search/extensions/mailru/_locales/mailru001/messages.json create mode 100644 browser/components/search/extensions/mailru/_locales/okru-az/messages.json create mode 100644 browser/components/search/extensions/mailru/_locales/okru-en-US/messages.json create mode 100644 browser/components/search/extensions/mailru/_locales/okru-hy-AM/messages.json create mode 100644 browser/components/search/extensions/mailru/_locales/okru-kk/messages.json create mode 100644 browser/components/search/extensions/mailru/_locales/okru-ro/messages.json create mode 100644 browser/components/search/extensions/mailru/_locales/okru-ru/messages.json create mode 100644 browser/components/search/extensions/mailru/_locales/okru-tr/messages.json create mode 100644 browser/components/search/extensions/mailru/_locales/okru-uk/messages.json create mode 100644 browser/components/search/extensions/mailru/_locales/okru-uz/messages.json create mode 100644 browser/components/search/extensions/mailru/favicon.ico create mode 100644 browser/components/search/extensions/mailru/manifest.json create mode 100644 browser/components/search/extensions/mapy-cz/favicon.ico create mode 100644 browser/components/search/extensions/mapy-cz/manifest.json create mode 100644 browser/components/search/extensions/mercadolibre/_locales/ar/messages.json create mode 100644 browser/components/search/extensions/mercadolibre/_locales/cl/messages.json create mode 100644 browser/components/search/extensions/mercadolibre/_locales/mx/messages.json create mode 100644 browser/components/search/extensions/mercadolibre/favicon.ico create mode 100644 browser/components/search/extensions/mercadolibre/manifest.json create mode 100644 browser/components/search/extensions/mercadolivre/favicon.ico create mode 100644 browser/components/search/extensions/mercadolivre/manifest.json create mode 100644 browser/components/search/extensions/naver-kr/favicon.ico create mode 100644 browser/components/search/extensions/naver-kr/manifest.json create mode 100644 browser/components/search/extensions/odpiralni/favicon.png create mode 100644 browser/components/search/extensions/odpiralni/manifest.json create mode 100644 browser/components/search/extensions/pazaruvaj/favicon.ico create mode 100644 browser/components/search/extensions/pazaruvaj/manifest.json create mode 100644 browser/components/search/extensions/priberam/favicon.png create mode 100644 browser/components/search/extensions/priberam/manifest.json create mode 100644 browser/components/search/extensions/prisjakt-sv-SE/favicon.ico create mode 100644 browser/components/search/extensions/prisjakt-sv-SE/manifest.json create mode 100644 browser/components/search/extensions/qwant/favicon.ico create mode 100644 browser/components/search/extensions/qwant/manifest.json create mode 100644 browser/components/search/extensions/qwantjr/favicon.ico create mode 100644 browser/components/search/extensions/qwantjr/manifest.json create mode 100644 browser/components/search/extensions/rakuten/favicon.ico create mode 100644 browser/components/search/extensions/rakuten/manifest.json create mode 100644 browser/components/search/extensions/readmoo/favicon.ico create mode 100644 browser/components/search/extensions/readmoo/manifest.json create mode 100644 browser/components/search/extensions/salidzinilv/favicon.ico create mode 100644 browser/components/search/extensions/salidzinilv/manifest.json create mode 100644 browser/components/search/extensions/seznam-cz/favicon.ico create mode 100644 browser/components/search/extensions/seznam-cz/manifest.json create mode 100644 browser/components/search/extensions/tyda-sv-SE/favicon.ico create mode 100644 browser/components/search/extensions/tyda-sv-SE/manifest.json create mode 100644 browser/components/search/extensions/vatera/favicon.ico create mode 100644 browser/components/search/extensions/vatera/manifest.json create mode 100644 browser/components/search/extensions/webde/favicon.ico create mode 100644 browser/components/search/extensions/webde/manifest.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/NN/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/NO/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/af/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/an/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/ar/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/ast/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/az/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/be-tarask/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/be/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/bg/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/bn/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/br/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/bs/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/ca/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/cy/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/cz/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/da/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/de/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/dsb/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/el/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/en/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/eo/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/es/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/et/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/eu/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/fa/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/fi/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/fr/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/fy-NL/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/ga-IE/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/gd/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/gl/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/gn/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/gu/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/he/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/hi/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/hr/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/hsb/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/hu/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/hy/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/ia/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/id/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/is/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/it/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/ja/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/ka/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/kab/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/kk/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/km/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/kn/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/kr/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/lij/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/lo/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/lt/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/ltg/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/lv/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/mk/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/mr/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/ms/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/my/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/ne/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/nl/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/oc/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/pa/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/pl/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/pt/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/rm/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/ro/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/ru/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/si/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/sk/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/sl/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/sq/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/sr/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/sv-SE/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/ta/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/te/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/th/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/tl/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/tr/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/uk/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/ur/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/uz/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/vi/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/wo/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/zh-CN/messages.json create mode 100644 browser/components/search/extensions/wikipedia/_locales/zh-TW/messages.json create mode 100644 browser/components/search/extensions/wikipedia/favicon.ico create mode 100644 browser/components/search/extensions/wikipedia/manifest.json create mode 100644 browser/components/search/extensions/wiktionary/_locales/oc/messages.json create mode 100644 browser/components/search/extensions/wiktionary/_locales/te/messages.json create mode 100644 browser/components/search/extensions/wiktionary/favicon.ico create mode 100644 browser/components/search/extensions/wiktionary/manifest.json create mode 100644 browser/components/search/extensions/wolnelektury-pl/favicon.png create mode 100644 browser/components/search/extensions/wolnelektury-pl/manifest.json create mode 100644 browser/components/search/extensions/yahoo-jp-auctions/favicon.ico create mode 100644 browser/components/search/extensions/yahoo-jp-auctions/manifest.json create mode 100644 browser/components/search/extensions/yahoo-jp/favicon.ico create mode 100644 browser/components/search/extensions/yahoo-jp/manifest.json create mode 100644 browser/components/search/extensions/yandex/_locales/az/messages.json create mode 100644 browser/components/search/extensions/yandex/_locales/by/messages.json create mode 100644 browser/components/search/extensions/yandex/_locales/en/messages.json create mode 100644 browser/components/search/extensions/yandex/_locales/kk/messages.json create mode 100644 browser/components/search/extensions/yandex/_locales/ru/messages.json create mode 100644 browser/components/search/extensions/yandex/_locales/tr/messages.json create mode 100644 browser/components/search/extensions/yandex/_locales/ua/messages.json create mode 100644 browser/components/search/extensions/yandex/manifest.json create mode 100644 browser/components/search/extensions/yandex/yandex-en.ico create mode 100644 browser/components/search/extensions/yandex/yandex-ru.ico create mode 100644 browser/components/search/jar.mn create mode 100644 browser/components/search/metrics.yaml create mode 100644 browser/components/search/moz.build create mode 100644 browser/components/search/pings.yaml create mode 100644 browser/components/search/schema/Readme.txt create mode 100644 browser/components/search/schema/search-telemetry-schema.json create mode 100644 browser/components/search/schema/search-telemetry-ui-schema.json create mode 100644 browser/components/search/test/browser/426329.xml create mode 100644 browser/components/search/test/browser/browser.toml create mode 100644 browser/components/search/test/browser/browser_426329.js create mode 100644 browser/components/search/test/browser/browser_addKeywordSearch.js create mode 100644 browser/components/search/test/browser/browser_contentContextMenu.js create mode 100644 browser/components/search/test/browser/browser_contentContextMenu.xhtml create mode 100644 browser/components/search/test/browser/browser_contentSearch.js create mode 100644 browser/components/search/test/browser/browser_contentSearchUI.js create mode 100644 browser/components/search/test/browser/browser_contentSearchUI_default.js create mode 100644 browser/components/search/test/browser/browser_contextSearchTabPosition.js create mode 100644 browser/components/search/test/browser/browser_contextmenu.js create mode 100644 browser/components/search/test/browser/browser_contextmenu_whereToOpenLink.js create mode 100644 browser/components/search/test/browser/browser_defaultPrivate_nimbus.js create mode 100644 browser/components/search/test/browser/browser_google_behavior.js create mode 100644 browser/components/search/test/browser/browser_hiddenOneOffs_diacritics.js create mode 100644 browser/components/search/test/browser/browser_ime_composition.js create mode 100644 browser/components/search/test/browser/browser_oneOffContextMenu.js create mode 100644 browser/components/search/test/browser/browser_oneOffContextMenu_setDefault.js create mode 100644 browser/components/search/test/browser/browser_private_search_perwindowpb.js create mode 100644 browser/components/search/test/browser/browser_rich_suggestions.js create mode 100644 browser/components/search/test/browser/browser_searchEngine_behaviors.js create mode 100644 browser/components/search/test/browser/browser_search_annotation.js create mode 100644 browser/components/search/test/browser/browser_search_discovery.js create mode 100644 browser/components/search/test/browser/browser_search_nimbus_reload.js create mode 100644 browser/components/search/test/browser/browser_searchbar_addEngine.js create mode 100644 browser/components/search/test/browser/browser_searchbar_context.js create mode 100644 browser/components/search/test/browser/browser_searchbar_default.js create mode 100644 browser/components/search/test/browser/browser_searchbar_enter.js create mode 100644 browser/components/search/test/browser/browser_searchbar_keyboard_navigation.js create mode 100644 browser/components/search/test/browser/browser_searchbar_openpopup.js create mode 100644 browser/components/search/test/browser/browser_searchbar_results.js create mode 100644 browser/components/search/test/browser/browser_searchbar_smallpanel_keyboard_navigation.js create mode 100644 browser/components/search/test/browser/browser_searchbar_widths.js create mode 100644 browser/components/search/test/browser/browser_tooManyEnginesOffered.js create mode 100644 browser/components/search/test/browser/browser_trending_suggestions.js create mode 100644 browser/components/search/test/browser/contentSearchBadImage.xml create mode 100644 browser/components/search/test/browser/contentSearchSuggestions.sjs create mode 100644 browser/components/search/test/browser/contentSearchSuggestions.xml create mode 100644 browser/components/search/test/browser/contentSearchUI.html create mode 100644 browser/components/search/test/browser/contentSearchUI.js create mode 100644 browser/components/search/test/browser/discovery.html create mode 100644 browser/components/search/test/browser/google_codes/browser.toml create mode 100644 browser/components/search/test/browser/head.js create mode 100644 browser/components/search/test/browser/mozsearch.sjs create mode 100644 browser/components/search/test/browser/opensearch.html create mode 100644 browser/components/search/test/browser/search-engines/basic/manifest.json create mode 100644 browser/components/search/test/browser/search-engines/private/manifest.json create mode 100644 browser/components/search/test/browser/searchSuggestionEngine.sjs create mode 100644 browser/components/search/test/browser/telemetry/browser.toml create mode 100644 browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_categorization_enabled_by_nimbus_variable.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_enabled_by_nimbus_variable.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_abandonment.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_aboutHome.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_adImpression_component.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_categorization_timing.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_content.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_ad_values.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_download_timer.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_extraction.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_region.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer_wakeup.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached_serp.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_content.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_multiple_tabs.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_non_ad.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_query_params.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_redirect.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_target.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_new_window.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_private.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_remote_settings_sync.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_searchbar.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_shopping.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_sources.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_about.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_clicks.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_data_attributes.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_load_events.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_in_content.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_navigation.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_webextension.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_in_content.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_provider.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_tab.js create mode 100644 browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_single_tab.js create mode 100644 browser/components/search/test/browser/telemetry/cacheable.html create mode 100644 browser/components/search/test/browser/telemetry/cacheable.html^headers^ create mode 100644 browser/components/search/test/browser/telemetry/domain_category_mappings.json create mode 100644 browser/components/search/test/browser/telemetry/head-spa.js create mode 100644 browser/components/search/test/browser/telemetry/head.js create mode 100644 browser/components/search/test/browser/telemetry/redirect_ad.sjs create mode 100644 browser/components/search/test/browser/telemetry/redirect_final.sjs create mode 100644 browser/components/search/test/browser/telemetry/redirect_once.sjs create mode 100644 browser/components/search/test/browser/telemetry/redirect_thrice.sjs create mode 100644 browser/components/search/test/browser/telemetry/redirect_twice.sjs create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetry.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_below_the_fold.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_doubled.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_first_element_non_visible.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_hidden.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_outer_container.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_components_query_parameters.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_components_text.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_components_visibility.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes_href.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes_none.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html^headers^ create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^ create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html^headers^ create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html^headers^ create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html^headers^ create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryAd_shopping.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorization.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationCapProcessedDomains.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationReporting.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetryDomainExtraction.html create mode 100644 browser/components/search/test/browser/telemetry/searchTelemetrySinglePageApp.html create mode 100644 browser/components/search/test/browser/telemetry/serp.css create mode 100644 browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.html create mode 100644 browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.sjs create mode 100644 browser/components/search/test/browser/telemetry/slow_loading_page_with_ads_on_load_event.html create mode 100644 browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.sjs create mode 100644 browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.xml create mode 100644 browser/components/search/test/browser/test.html create mode 100644 browser/components/search/test/browser/testEngine.xml create mode 100644 browser/components/search/test/browser/testEngine_chromeicon.xml create mode 100644 browser/components/search/test/browser/testEngine_diacritics.xml create mode 100644 browser/components/search/test/browser/testEngine_dupe.xml create mode 100644 browser/components/search/test/browser/testEngine_mozsearch.xml create mode 100644 browser/components/search/test/browser/test_search.html create mode 100644 browser/components/search/test/browser/tooManyEnginesOffered.html create mode 100644 browser/components/search/test/browser/trendingSuggestionEngine.sjs create mode 100644 browser/components/search/test/marionette/manifest.toml create mode 100644 browser/components/search/test/marionette/test_engines_on_restart.py create mode 100644 browser/components/search/test/unit/domain_category_mappings_1a.json create mode 100644 browser/components/search/test/unit/domain_category_mappings_1b.json create mode 100644 browser/components/search/test/unit/domain_category_mappings_2a.json create mode 100644 browser/components/search/test/unit/domain_category_mappings_2b.json create mode 100644 browser/components/search/test/unit/test_search_telemetry_categorization_logic.js create mode 100644 browser/components/search/test/unit/test_search_telemetry_categorization_process_domains.js create mode 100644 browser/components/search/test/unit/test_search_telemetry_categorization_sync.js create mode 100644 browser/components/search/test/unit/test_search_telemetry_compare_urls.js create mode 100644 browser/components/search/test/unit/test_search_telemetry_config_validation.js create mode 100644 browser/components/search/test/unit/test_urlTelemetry.js create mode 100644 browser/components/search/test/unit/test_urlTelemetry_generic.js create mode 100644 browser/components/search/test/unit/xpcshell.toml create mode 100644 browser/components/sessionstore/ContentRestore.sys.mjs create mode 100644 browser/components/sessionstore/ContentSessionStore.sys.mjs create mode 100644 browser/components/sessionstore/GlobalState.sys.mjs create mode 100644 browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs create mode 100644 browser/components/sessionstore/RunState.sys.mjs create mode 100644 browser/components/sessionstore/SessionCookies.sys.mjs create mode 100644 browser/components/sessionstore/SessionFile.sys.mjs create mode 100644 browser/components/sessionstore/SessionMigration.sys.mjs create mode 100644 browser/components/sessionstore/SessionSaver.sys.mjs create mode 100644 browser/components/sessionstore/SessionStartup.sys.mjs create mode 100644 browser/components/sessionstore/SessionStore.sys.mjs create mode 100644 browser/components/sessionstore/SessionWriter.sys.mjs create mode 100644 browser/components/sessionstore/StartupPerformance.sys.mjs create mode 100644 browser/components/sessionstore/TabAttributes.sys.mjs create mode 100644 browser/components/sessionstore/TabState.sys.mjs create mode 100644 browser/components/sessionstore/TabStateCache.sys.mjs create mode 100644 browser/components/sessionstore/TabStateFlusher.sys.mjs create mode 100644 browser/components/sessionstore/content/aboutSessionRestore.js create mode 100644 browser/components/sessionstore/content/aboutSessionRestore.xhtml create mode 100644 browser/components/sessionstore/content/content-sessionStore.js create mode 100644 browser/components/sessionstore/jar.mn create mode 100644 browser/components/sessionstore/moz.build create mode 100644 browser/components/sessionstore/test/SessionStoreTestUtils.sys.mjs create mode 100644 browser/components/sessionstore/test/browser.toml create mode 100644 browser/components/sessionstore/test/browser_1234021.js create mode 100644 browser/components/sessionstore/test/browser_1234021_page.html create mode 100644 browser/components/sessionstore/test/browser_1284886_suspend_tab.html create mode 100644 browser/components/sessionstore/test/browser_1284886_suspend_tab.js create mode 100644 browser/components/sessionstore/test/browser_1284886_suspend_tab_2.html create mode 100644 browser/components/sessionstore/test/browser_1446343-windowsize.js create mode 100644 browser/components/sessionstore/test/browser_248970_b_perwindowpb.js create mode 100644 browser/components/sessionstore/test/browser_248970_b_sample.html create mode 100644 browser/components/sessionstore/test/browser_339445.js create mode 100644 browser/components/sessionstore/test/browser_339445_sample.html create mode 100644 browser/components/sessionstore/test/browser_345898.js create mode 100644 browser/components/sessionstore/test/browser_350525.js create mode 100644 browser/components/sessionstore/test/browser_354894_perwindowpb.js create mode 100644 browser/components/sessionstore/test/browser_367052.js create mode 100644 browser/components/sessionstore/test/browser_393716.js create mode 100644 browser/components/sessionstore/test/browser_394759_basic.js create mode 100644 browser/components/sessionstore/test/browser_394759_behavior.js create mode 100644 browser/components/sessionstore/test/browser_394759_perwindowpb.js create mode 100644 browser/components/sessionstore/test/browser_394759_purge.js create mode 100644 browser/components/sessionstore/test/browser_423132.js create mode 100644 browser/components/sessionstore/test/browser_423132_sample.html create mode 100644 browser/components/sessionstore/test/browser_447951.js create mode 100644 browser/components/sessionstore/test/browser_447951_sample.html create mode 100644 browser/components/sessionstore/test/browser_454908.js create mode 100644 browser/components/sessionstore/test/browser_454908_sample.html create mode 100644 browser/components/sessionstore/test/browser_456342.js create mode 100644 browser/components/sessionstore/test/browser_456342_sample.xhtml create mode 100644 browser/components/sessionstore/test/browser_459906.js create mode 100644 browser/components/sessionstore/test/browser_459906_empty.html create mode 100644 browser/components/sessionstore/test/browser_459906_sample.html create mode 100644 browser/components/sessionstore/test/browser_461634.js create mode 100644 browser/components/sessionstore/test/browser_461743.js create mode 100644 browser/components/sessionstore/test/browser_461743_sample.html create mode 100644 browser/components/sessionstore/test/browser_463205.js create mode 100644 browser/components/sessionstore/test/browser_463205_sample.html create mode 100644 browser/components/sessionstore/test/browser_463206.js create mode 100644 browser/components/sessionstore/test/browser_463206_sample.html create mode 100644 browser/components/sessionstore/test/browser_464199.js create mode 100644 browser/components/sessionstore/test/browser_464620_a.html create mode 100644 browser/components/sessionstore/test/browser_464620_a.js create mode 100644 browser/components/sessionstore/test/browser_464620_b.html create mode 100644 browser/components/sessionstore/test/browser_464620_b.js create mode 100644 browser/components/sessionstore/test/browser_464620_xd.html create mode 100644 browser/components/sessionstore/test/browser_465215.js create mode 100644 browser/components/sessionstore/test/browser_465223.js create mode 100644 browser/components/sessionstore/test/browser_466937.js create mode 100644 browser/components/sessionstore/test/browser_466937_sample.html create mode 100644 browser/components/sessionstore/test/browser_467409-backslashplosion.js create mode 100644 browser/components/sessionstore/test/browser_477657.js create mode 100644 browser/components/sessionstore/test/browser_480893.js create mode 100644 browser/components/sessionstore/test/browser_485482.js create mode 100644 browser/components/sessionstore/test/browser_485482_sample.html create mode 100644 browser/components/sessionstore/test/browser_485563.js create mode 100644 browser/components/sessionstore/test/browser_490040.js create mode 100644 browser/components/sessionstore/test/browser_491168.js create mode 100644 browser/components/sessionstore/test/browser_491577.js create mode 100644 browser/components/sessionstore/test/browser_495495.js create mode 100644 browser/components/sessionstore/test/browser_500328.js create mode 100644 browser/components/sessionstore/test/browser_506482.js create mode 100644 browser/components/sessionstore/test/browser_514751.js create mode 100644 browser/components/sessionstore/test/browser_522375.js create mode 100644 browser/components/sessionstore/test/browser_522545.js create mode 100644 browser/components/sessionstore/test/browser_524745.js create mode 100644 browser/components/sessionstore/test/browser_526613.js create mode 100644 browser/components/sessionstore/test/browser_528776.js create mode 100644 browser/components/sessionstore/test/browser_579868.js create mode 100644 browser/components/sessionstore/test/browser_579879.js create mode 100644 browser/components/sessionstore/test/browser_580512.js create mode 100644 browser/components/sessionstore/test/browser_581937.js create mode 100644 browser/components/sessionstore/test/browser_586068-apptabs.js create mode 100644 browser/components/sessionstore/test/browser_586068-apptabs_ondemand.js create mode 100644 browser/components/sessionstore/test/browser_586068-browser_state_interrupted.js create mode 100644 browser/components/sessionstore/test/browser_586068-cascade.js create mode 100644 browser/components/sessionstore/test/browser_586068-multi_window.js create mode 100644 browser/components/sessionstore/test/browser_586068-reload.js create mode 100644 browser/components/sessionstore/test/browser_586068-select.js create mode 100644 browser/components/sessionstore/test/browser_586068-window_state.js create mode 100644 browser/components/sessionstore/test/browser_586068-window_state_override.js create mode 100644 browser/components/sessionstore/test/browser_586147.js create mode 100644 browser/components/sessionstore/test/browser_588426.js create mode 100644 browser/components/sessionstore/test/browser_589246.js create mode 100644 browser/components/sessionstore/test/browser_590268.js create mode 100644 browser/components/sessionstore/test/browser_590563.js create mode 100644 browser/components/sessionstore/test/browser_595601-restore_hidden.js create mode 100644 browser/components/sessionstore/test/browser_597071.js create mode 100644 browser/components/sessionstore/test/browser_600545.js create mode 100644 browser/components/sessionstore/test/browser_601955.js create mode 100644 browser/components/sessionstore/test/browser_607016.js create mode 100644 browser/components/sessionstore/test/browser_615394-SSWindowState_events_duplicateTab.js create mode 100644 browser/components/sessionstore/test/browser_615394-SSWindowState_events_setBrowserState.js create mode 100644 browser/components/sessionstore/test/browser_615394-SSWindowState_events_setTabState.js create mode 100644 browser/components/sessionstore/test/browser_615394-SSWindowState_events_setWindowState.js create mode 100644 browser/components/sessionstore/test/browser_615394-SSWindowState_events_undoCloseTab.js create mode 100644 browser/components/sessionstore/test/browser_615394-SSWindowState_events_undoCloseWindow.js create mode 100644 browser/components/sessionstore/test/browser_618151.js create mode 100644 browser/components/sessionstore/test/browser_623779.js create mode 100644 browser/components/sessionstore/test/browser_624727.js create mode 100644 browser/components/sessionstore/test/browser_625016.js create mode 100644 browser/components/sessionstore/test/browser_628270.js create mode 100644 browser/components/sessionstore/test/browser_635418.js create mode 100644 browser/components/sessionstore/test/browser_636279.js create mode 100644 browser/components/sessionstore/test/browser_637020.js create mode 100644 browser/components/sessionstore/test/browser_637020_slow.sjs create mode 100644 browser/components/sessionstore/test/browser_645428.js create mode 100644 browser/components/sessionstore/test/browser_659591.js create mode 100644 browser/components/sessionstore/test/browser_662743.js create mode 100644 browser/components/sessionstore/test/browser_662743_sample.html create mode 100644 browser/components/sessionstore/test/browser_662812.js create mode 100644 browser/components/sessionstore/test/browser_665702-state_session.js create mode 100644 browser/components/sessionstore/test/browser_682507.js create mode 100644 browser/components/sessionstore/test/browser_687710.js create mode 100644 browser/components/sessionstore/test/browser_687710_2.js create mode 100644 browser/components/sessionstore/test/browser_694378.js create mode 100644 browser/components/sessionstore/test/browser_701377.js create mode 100644 browser/components/sessionstore/test/browser_705597.js create mode 100644 browser/components/sessionstore/test/browser_707862.js create mode 100644 browser/components/sessionstore/test/browser_739531.js create mode 100644 browser/components/sessionstore/test/browser_739531_frame.html create mode 100644 browser/components/sessionstore/test/browser_739531_sample.html create mode 100644 browser/components/sessionstore/test/browser_739805.js create mode 100644 browser/components/sessionstore/test/browser_819510_perwindowpb.js create mode 100644 browser/components/sessionstore/test/browser_906076_lazy_tabs.js create mode 100644 browser/components/sessionstore/test/browser_911547.js create mode 100644 browser/components/sessionstore/test/browser_911547_sample.html create mode 100644 browser/components/sessionstore/test/browser_911547_sample.html^headers^ create mode 100644 browser/components/sessionstore/test/browser_aboutPrivateBrowsing.js create mode 100644 browser/components/sessionstore/test/browser_aboutSessionRestore.js create mode 100644 browser/components/sessionstore/test/browser_async_duplicate_tab.js create mode 100644 browser/components/sessionstore/test/browser_async_flushes.js create mode 100644 browser/components/sessionstore/test/browser_async_remove_tab.js create mode 100644 browser/components/sessionstore/test/browser_async_window_flushing.js create mode 100644 browser/components/sessionstore/test/browser_attributes.js create mode 100644 browser/components/sessionstore/test/browser_background_tab_crash.js create mode 100644 browser/components/sessionstore/test/browser_backup_recovery.js create mode 100644 browser/components/sessionstore/test/browser_bfcache_telemetry.js create mode 100644 browser/components/sessionstore/test/browser_broadcast.js create mode 100644 browser/components/sessionstore/test/browser_capabilities.js create mode 100644 browser/components/sessionstore/test/browser_cleaner.js create mode 100644 browser/components/sessionstore/test/browser_closedId.js create mode 100644 browser/components/sessionstore/test/browser_closed_objects_changed_notifications_tabs.js create mode 100644 browser/components/sessionstore/test/browser_closed_objects_changed_notifications_windows.js create mode 100644 browser/components/sessionstore/test/browser_closed_tabs_closed_windows.js create mode 100644 browser/components/sessionstore/test/browser_closed_tabs_windows.js create mode 100644 browser/components/sessionstore/test/browser_cookies.js create mode 100644 browser/components/sessionstore/test/browser_cookies_legacy.js create mode 100644 browser/components/sessionstore/test/browser_cookies_privacy.js create mode 100644 browser/components/sessionstore/test/browser_cookies_sameSite.js create mode 100644 browser/components/sessionstore/test/browser_crashedTabs.js create mode 100644 browser/components/sessionstore/test/browser_docshell_uuid_consistency.js create mode 100644 browser/components/sessionstore/test/browser_duplicate_history.js create mode 100644 browser/components/sessionstore/test/browser_duplicate_tab_in_new_window.js create mode 100644 browser/components/sessionstore/test/browser_dying_cache.js create mode 100644 browser/components/sessionstore/test/browser_dynamic_frames.js create mode 100644 browser/components/sessionstore/test/browser_firefoxView_restore.js create mode 100644 browser/components/sessionstore/test/browser_firefoxView_selected_restore.js create mode 100644 browser/components/sessionstore/test/browser_focus_after_restore.js create mode 100644 browser/components/sessionstore/test/browser_forget_async_closings.js create mode 100644 browser/components/sessionstore/test/browser_forget_closed_tab_window_byId.js create mode 100644 browser/components/sessionstore/test/browser_formdata.js create mode 100644 browser/components/sessionstore/test/browser_formdata_cc.js create mode 100644 browser/components/sessionstore/test/browser_formdata_face.js create mode 100644 browser/components/sessionstore/test/browser_formdata_format.js create mode 100644 browser/components/sessionstore/test/browser_formdata_format_sample.html create mode 100644 browser/components/sessionstore/test/browser_formdata_max_size.js create mode 100644 browser/components/sessionstore/test/browser_formdata_password.js create mode 100644 browser/components/sessionstore/test/browser_formdata_sample.html create mode 100644 browser/components/sessionstore/test/browser_formdata_xpath.js create mode 100644 browser/components/sessionstore/test/browser_formdata_xpath_sample.html create mode 100644 browser/components/sessionstore/test/browser_frame_history.js create mode 100644 browser/components/sessionstore/test/browser_frame_history_a.html create mode 100644 browser/components/sessionstore/test/browser_frame_history_b.html create mode 100644 browser/components/sessionstore/test/browser_frame_history_c.html create mode 100644 browser/components/sessionstore/test/browser_frame_history_c1.html create mode 100644 browser/components/sessionstore/test/browser_frame_history_c2.html create mode 100644 browser/components/sessionstore/test/browser_frame_history_index.html create mode 100644 browser/components/sessionstore/test/browser_frame_history_index2.html create mode 100644 browser/components/sessionstore/test/browser_frame_history_index_blank.html create mode 100644 browser/components/sessionstore/test/browser_frametree.js create mode 100644 browser/components/sessionstore/test/browser_frametree_sample.html create mode 100644 browser/components/sessionstore/test/browser_frametree_sample_frameset.html create mode 100644 browser/components/sessionstore/test/browser_frametree_sample_iframes.html create mode 100644 browser/components/sessionstore/test/browser_global_store.js create mode 100644 browser/components/sessionstore/test/browser_history_persist.js create mode 100644 browser/components/sessionstore/test/browser_ignore_updates_crashed_tabs.js create mode 100644 browser/components/sessionstore/test/browser_label_and_icon.js create mode 100644 browser/components/sessionstore/test/browser_movePendingTabToNewWindow.js create mode 100644 browser/components/sessionstore/test/browser_multiple_navigateAndRestore.js create mode 100644 browser/components/sessionstore/test/browser_multiple_select_after_load.js create mode 100644 browser/components/sessionstore/test/browser_newtab_userTypedValue.js create mode 100644 browser/components/sessionstore/test/browser_not_collect_when_idle.js create mode 100644 browser/components/sessionstore/test/browser_old_favicon.js create mode 100644 browser/components/sessionstore/test/browser_page_title.js create mode 100644 browser/components/sessionstore/test/browser_parentProcessRestoreHash.js create mode 100644 browser/components/sessionstore/test/browser_pending_tabs.js create mode 100644 browser/components/sessionstore/test/browser_pinned_tabs.js create mode 100644 browser/components/sessionstore/test/browser_privatetabs.js create mode 100644 browser/components/sessionstore/test/browser_purge_shistory.js create mode 100644 browser/components/sessionstore/test/browser_remoteness_flip_on_restore.js create mode 100644 browser/components/sessionstore/test/browser_reopen_all_windows.js create mode 100644 browser/components/sessionstore/test/browser_replace_load.js create mode 100644 browser/components/sessionstore/test/browser_restoreLastActionCorrectOrder.js create mode 100644 browser/components/sessionstore/test/browser_restoreLastClosedTabOrWindowOrSession.js create mode 100644 browser/components/sessionstore/test/browser_restoreTabContainer.js create mode 100644 browser/components/sessionstore/test/browser_restore_container_tabs_oa.js create mode 100644 browser/components/sessionstore/test/browser_restore_cookies_noOriginAttributes.js create mode 100644 browser/components/sessionstore/test/browser_restore_pageProxyState.js create mode 100644 browser/components/sessionstore/test/browser_restore_private_tab_os.js create mode 100644 browser/components/sessionstore/test/browser_restore_redirect.js create mode 100644 browser/components/sessionstore/test/browser_restore_reversed_z_order.js create mode 100644 browser/components/sessionstore/test/browser_restore_srcdoc.js create mode 100644 browser/components/sessionstore/test/browser_restore_tabless_window.js create mode 100644 browser/components/sessionstore/test/browser_restored_window_features.js create mode 100644 browser/components/sessionstore/test/browser_revive_crashed_bg_tabs.js create mode 100644 browser/components/sessionstore/test/browser_scrollPositions.js create mode 100644 browser/components/sessionstore/test/browser_scrollPositionsReaderMode.js create mode 100644 browser/components/sessionstore/test/browser_scrollPositions_readerModeArticle.html create mode 100644 browser/components/sessionstore/test/browser_scrollPositions_sample.html create mode 100644 browser/components/sessionstore/test/browser_scrollPositions_sample2.html create mode 100644 browser/components/sessionstore/test/browser_scrollPositions_sample_frameset.html create mode 100644 browser/components/sessionstore/test/browser_send_async_message_oom.js create mode 100644 browser/components/sessionstore/test/browser_sessionHistory.js create mode 100644 browser/components/sessionstore/test/browser_sessionHistory_slow.sjs create mode 100644 browser/components/sessionstore/test/browser_sessionStorage.html create mode 100644 browser/components/sessionstore/test/browser_sessionStorage.js create mode 100644 browser/components/sessionstore/test/browser_sessionStorage_size.js create mode 100644 browser/components/sessionstore/test/browser_sessionStoreContainer.js create mode 100644 browser/components/sessionstore/test/browser_should_restore_tab.js create mode 100644 browser/components/sessionstore/test/browser_sizemodeBeforeMinimized.js create mode 100644 browser/components/sessionstore/test/browser_speculative_connect.html create mode 100644 browser/components/sessionstore/test/browser_speculative_connect.js create mode 100644 browser/components/sessionstore/test/browser_swapDocShells.js create mode 100644 browser/components/sessionstore/test/browser_switch_remoteness.js create mode 100644 browser/components/sessionstore/test/browser_tab_label_during_restore.js create mode 100644 browser/components/sessionstore/test/browser_tabicon_after_bg_tab_crash.js create mode 100644 browser/components/sessionstore/test/browser_tabs_in_urlbar.js create mode 100644 browser/components/sessionstore/test/browser_undoCloseById.js create mode 100644 browser/components/sessionstore/test/browser_undoCloseById_targetWindow.js create mode 100644 browser/components/sessionstore/test/browser_unrestored_crashedTabs.js create mode 100644 browser/components/sessionstore/test/browser_upgrade_backup.js create mode 100644 browser/components/sessionstore/test/browser_urlbarSearchMode.js create mode 100644 browser/components/sessionstore/test/browser_userTyped_restored_after_discard.js create mode 100644 browser/components/sessionstore/test/browser_windowRestore_perwindowpb.js create mode 100644 browser/components/sessionstore/test/browser_windowStateContainer.js create mode 100644 browser/components/sessionstore/test/coopHeaderCommon.sjs create mode 100644 browser/components/sessionstore/test/coop_coep.html create mode 100644 browser/components/sessionstore/test/coop_coep.html^headers^ create mode 100644 browser/components/sessionstore/test/empty.html create mode 100644 browser/components/sessionstore/test/file_async_duplicate_tab.html create mode 100644 browser/components/sessionstore/test/file_async_flushes.html create mode 100644 browser/components/sessionstore/test/file_formdata_password.html create mode 100644 browser/components/sessionstore/test/file_sessionHistory_hashchange.html create mode 100644 browser/components/sessionstore/test/head.js create mode 100644 browser/components/sessionstore/test/marionette/manifest.toml create mode 100644 browser/components/sessionstore/test/marionette/session_store_test_case.py create mode 100644 browser/components/sessionstore/test/marionette/test_persist_closed_tabs_restore_manually.py create mode 100644 browser/components/sessionstore/test/marionette/test_restore_loading_tab.py create mode 100644 browser/components/sessionstore/test/marionette/test_restore_manually.py create mode 100644 browser/components/sessionstore/test/marionette/test_restore_manually_with_pinned_tabs.py create mode 100644 browser/components/sessionstore/test/marionette/test_restore_windows_after_close_last_tabs.py create mode 100644 browser/components/sessionstore/test/marionette/test_restore_windows_after_restart_and_quit.py create mode 100644 browser/components/sessionstore/test/marionette/test_restore_windows_after_windows_shutdown.py create mode 100644 browser/components/sessionstore/test/restore_redirect_http.html create mode 100644 browser/components/sessionstore/test/restore_redirect_http.html^headers^ create mode 100644 browser/components/sessionstore/test/restore_redirect_js.html create mode 100644 browser/components/sessionstore/test/restore_redirect_target.html create mode 100644 browser/components/sessionstore/test/unit/data/sessionCheckpoints_all.json create mode 100644 browser/components/sessionstore/test/unit/data/sessionstore_invalid.js create mode 100644 browser/components/sessionstore/test/unit/data/sessionstore_valid.js create mode 100644 browser/components/sessionstore/test/unit/head.js create mode 100644 browser/components/sessionstore/test/unit/test_backup_once.js create mode 100644 browser/components/sessionstore/test/unit/test_final_write_cleanup.js create mode 100644 browser/components/sessionstore/test/unit/test_histogram_corrupt_files.js create mode 100644 browser/components/sessionstore/test/unit/test_migration_lz4compression.js create mode 100644 browser/components/sessionstore/test/unit/test_startup_invalid_session.js create mode 100644 browser/components/sessionstore/test/unit/test_startup_nosession_async.js create mode 100644 browser/components/sessionstore/test/unit/test_startup_session_async.js create mode 100644 browser/components/sessionstore/test/unit/xpcshell.toml create mode 100644 browser/components/sessionstore/triage.json create mode 100644 browser/components/shell/HeadlessShell.sys.mjs create mode 100644 browser/components/shell/ScreenshotChild.sys.mjs create mode 100644 browser/components/shell/ShellService.sys.mjs create mode 100644 browser/components/shell/WindowsDefaultBrowser.cpp create mode 100644 browser/components/shell/WindowsDefaultBrowser.h create mode 100644 browser/components/shell/WindowsUserChoice.cpp create mode 100644 browser/components/shell/WindowsUserChoice.h create mode 100644 browser/components/shell/content/setDesktopBackground.js create mode 100644 browser/components/shell/content/setDesktopBackground.xhtml create mode 100644 browser/components/shell/jar.mn create mode 100644 browser/components/shell/moz.build create mode 100644 browser/components/shell/nsGNOMEShellDBusHelper.cpp create mode 100644 browser/components/shell/nsGNOMEShellDBusHelper.h create mode 100644 browser/components/shell/nsGNOMEShellSearchProvider.cpp create mode 100644 browser/components/shell/nsGNOMEShellSearchProvider.h create mode 100644 browser/components/shell/nsGNOMEShellService.cpp create mode 100644 browser/components/shell/nsGNOMEShellService.h create mode 100644 browser/components/shell/nsIGNOMEShellService.idl create mode 100644 browser/components/shell/nsIMacShellService.idl create mode 100644 browser/components/shell/nsIShellService.idl create mode 100644 browser/components/shell/nsIWindowsShellService.idl create mode 100644 browser/components/shell/nsMacShellService.cpp create mode 100644 browser/components/shell/nsMacShellService.h create mode 100644 browser/components/shell/nsShellService.h create mode 100644 browser/components/shell/nsToolkitShellService.h create mode 100644 browser/components/shell/nsWindowsShellService.cpp create mode 100644 browser/components/shell/nsWindowsShellService.h create mode 100644 browser/components/shell/search-provider-files/README create mode 100644 browser/components/shell/search-provider-files/firefox.desktop create mode 100644 browser/components/shell/search-provider-files/org.mozilla.firefox.SearchProvider.service create mode 100644 browser/components/shell/search-provider-files/org.mozilla.firefox.search-provider.ini create mode 100644 browser/components/shell/test/browser.toml create mode 100644 browser/components/shell/test/browser_1119088.js create mode 100644 browser/components/shell/test/browser_420786.js create mode 100644 browser/components/shell/test/browser_633221.js create mode 100644 browser/components/shell/test/browser_doesAppNeedPin.js create mode 100644 browser/components/shell/test/browser_headless_screenshot_1.js create mode 100644 browser/components/shell/test/browser_headless_screenshot_2.js create mode 100644 browser/components/shell/test/browser_headless_screenshot_3.js create mode 100644 browser/components/shell/test/browser_headless_screenshot_4.js create mode 100644 browser/components/shell/test/browser_headless_screenshot_cross_origin.js create mode 100644 browser/components/shell/test/browser_headless_screenshot_redirect.js create mode 100644 browser/components/shell/test/browser_pinning.js create mode 100644 browser/components/shell/test/browser_setDefaultBrowser.js create mode 100644 browser/components/shell/test/browser_setDefaultPDFHandler.js create mode 100644 browser/components/shell/test/browser_setDesktopBackgroundPreview.js create mode 100644 browser/components/shell/test/head.js create mode 100644 browser/components/shell/test/headless.html create mode 100644 browser/components/shell/test/headless_cross_origin.html create mode 100644 browser/components/shell/test/headless_iframe.html create mode 100644 browser/components/shell/test/headless_redirect.html create mode 100644 browser/components/shell/test/headless_redirect.html^headers^ create mode 100755 browser/components/shell/test/mac_desktop_image.py create mode 100644 browser/components/shell/test/unit/test_macOS_showSecurityPreferences.js create mode 100644 browser/components/shell/test/unit/xpcshell.toml create mode 100644 browser/components/shopping/ShoppingSidebarChild.sys.mjs create mode 100644 browser/components/shopping/ShoppingSidebarParent.sys.mjs create mode 100644 browser/components/shopping/ShoppingUtils.sys.mjs create mode 100644 browser/components/shopping/content/adjusted-rating.mjs create mode 100644 browser/components/shopping/content/analysis-explainer.css create mode 100644 browser/components/shopping/content/analysis-explainer.mjs create mode 100644 browser/components/shopping/content/assets/competitiveness.svg create mode 100644 browser/components/shopping/content/assets/optInDark.avif create mode 100644 browser/components/shopping/content/assets/optInLight.avif create mode 100644 browser/components/shopping/content/assets/packaging.svg create mode 100644 browser/components/shopping/content/assets/price.svg create mode 100644 browser/components/shopping/content/assets/priceTagButtonCallout.svg create mode 100644 browser/components/shopping/content/assets/quality.svg create mode 100644 browser/components/shopping/content/assets/ratingDark.avif create mode 100644 browser/components/shopping/content/assets/ratingLight.avif create mode 100644 browser/components/shopping/content/assets/reviewsVisualCallout.svg create mode 100644 browser/components/shopping/content/assets/shipping.svg create mode 100644 browser/components/shopping/content/assets/shopping.svg create mode 100644 browser/components/shopping/content/assets/unanalyzedDark.avif create mode 100644 browser/components/shopping/content/assets/unanalyzedLight.avif create mode 100644 browser/components/shopping/content/highlight-item.css create mode 100644 browser/components/shopping/content/highlight-item.mjs create mode 100644 browser/components/shopping/content/highlights.mjs create mode 100644 browser/components/shopping/content/letter-grade.css create mode 100644 browser/components/shopping/content/letter-grade.mjs create mode 100644 browser/components/shopping/content/onboarding.mjs create mode 100644 browser/components/shopping/content/recommended-ad.css create mode 100644 browser/components/shopping/content/recommended-ad.mjs create mode 100644 browser/components/shopping/content/reliability.mjs create mode 100644 browser/components/shopping/content/settings.css create mode 100644 browser/components/shopping/content/settings.mjs create mode 100644 browser/components/shopping/content/shopping-card.css create mode 100644 browser/components/shopping/content/shopping-card.mjs create mode 100644 browser/components/shopping/content/shopping-container.css create mode 100644 browser/components/shopping/content/shopping-container.mjs create mode 100644 browser/components/shopping/content/shopping-message-bar.css create mode 100644 browser/components/shopping/content/shopping-message-bar.mjs create mode 100644 browser/components/shopping/content/shopping-page.css create mode 100644 browser/components/shopping/content/shopping-sidebar.js create mode 100644 browser/components/shopping/content/shopping.ftl create mode 100644 browser/components/shopping/content/shopping.html create mode 100644 browser/components/shopping/content/unanalyzed.css create mode 100644 browser/components/shopping/content/unanalyzed.mjs create mode 100644 browser/components/shopping/jar.mn create mode 100644 browser/components/shopping/metrics.yaml create mode 100644 browser/components/shopping/moz.build create mode 100644 browser/components/shopping/tests/browser/browser.toml create mode 100644 browser/components/shopping/tests/browser/browser_adjusted_rating.js create mode 100644 browser/components/shopping/tests/browser/browser_ads_exposure_telemetry.js create mode 100644 browser/components/shopping/tests/browser/browser_analysis_explainer.js create mode 100644 browser/components/shopping/tests/browser/browser_auto_open.js create mode 100644 browser/components/shopping/tests/browser/browser_exposure_telemetry.js create mode 100644 browser/components/shopping/tests/browser/browser_inprogress_analysis.js create mode 100644 browser/components/shopping/tests/browser/browser_keep_close_message_bar.js create mode 100644 browser/components/shopping/tests/browser/browser_network_offline.js create mode 100644 browser/components/shopping/tests/browser/browser_not_enough_reviews.js create mode 100644 browser/components/shopping/tests/browser/browser_page_not_supported.js create mode 100644 browser/components/shopping/tests/browser/browser_private_mode.js create mode 100644 browser/components/shopping/tests/browser/browser_recommended_ad_test.js create mode 100644 browser/components/shopping/tests/browser/browser_review_highlights.js create mode 100644 browser/components/shopping/tests/browser/browser_settings_telemetry.js create mode 100644 browser/components/shopping/tests/browser/browser_shopping_card.js create mode 100644 browser/components/shopping/tests/browser/browser_shopping_container.js create mode 100644 browser/components/shopping/tests/browser/browser_shopping_message_triggers.js create mode 100644 browser/components/shopping/tests/browser/browser_shopping_onboarding.js create mode 100644 browser/components/shopping/tests/browser/browser_shopping_settings.js create mode 100644 browser/components/shopping/tests/browser/browser_shopping_sidebar.js create mode 100644 browser/components/shopping/tests/browser/browser_shopping_survey.js create mode 100644 browser/components/shopping/tests/browser/browser_shopping_urlbar.js create mode 100644 browser/components/shopping/tests/browser/browser_stale_product.js create mode 100644 browser/components/shopping/tests/browser/browser_ui_telemetry.js create mode 100644 browser/components/shopping/tests/browser/browser_unanalyzed_product.js create mode 100644 browser/components/shopping/tests/browser/browser_unavailable_product.js create mode 100644 browser/components/shopping/tests/browser/head.js create mode 100644 browser/components/storybook/.storybook/addon-component-status/StatusIndicator.mjs create mode 100644 browser/components/storybook/.storybook/addon-component-status/constants.mjs create mode 100644 browser/components/storybook/.storybook/addon-component-status/index.js create mode 100644 browser/components/storybook/.storybook/addon-component-status/preset/manager.mjs create mode 100644 browser/components/storybook/.storybook/addon-component-status/preset/preview.mjs create mode 100644 browser/components/storybook/.storybook/addon-fluent/FluentPanel.mjs create mode 100644 browser/components/storybook/.storybook/addon-fluent/PseudoLocalizationButton.mjs create mode 100644 browser/components/storybook/.storybook/addon-fluent/constants.mjs create mode 100644 browser/components/storybook/.storybook/addon-fluent/fluent-panel.css create mode 100644 browser/components/storybook/.storybook/addon-fluent/index.js create mode 100644 browser/components/storybook/.storybook/addon-fluent/preset/manager.mjs create mode 100644 browser/components/storybook/.storybook/addon-fluent/preset/preview.mjs create mode 100644 browser/components/storybook/.storybook/addon-fluent/withPseudoLocalization.mjs create mode 100644 browser/components/storybook/.storybook/chrome-styles-loader.js create mode 100644 browser/components/storybook/.storybook/chrome-uri-utils.js create mode 100644 browser/components/storybook/.storybook/fluent-utils.mjs create mode 100644 browser/components/storybook/.storybook/l10n-pseudo.mjs create mode 100644 browser/components/storybook/.storybook/main.js create mode 100644 browser/components/storybook/.storybook/markdown-story-loader.js create mode 100644 browser/components/storybook/.storybook/preview-head.html create mode 100644 browser/components/storybook/.storybook/preview.mjs create mode 100644 browser/components/storybook/custom-elements-manifest.config.mjs create mode 100644 browser/components/storybook/docs/README.lit-guide.stories.md create mode 100644 browser/components/storybook/docs/README.other-widgets.stories.md create mode 100644 browser/components/storybook/docs/README.reusable-widgets.stories.md create mode 100644 browser/components/storybook/docs/README.storybook.stories.md create mode 100644 browser/components/storybook/docs/README.typography.stories.md create mode 100644 browser/components/storybook/docs/README.xul-and-html.stories.md create mode 100644 browser/components/storybook/mach_commands.py create mode 100644 browser/components/storybook/moz.build create mode 100644 browser/components/storybook/package-lock.json create mode 100644 browser/components/storybook/package.json create mode 100644 browser/components/storybook/stories/button.stories.mjs create mode 100644 browser/components/storybook/stories/fxview-category-navigation.stories.mjs create mode 100644 browser/components/storybook/stories/fxview-tab-list.stories.md create mode 100644 browser/components/storybook/stories/fxview-tab-list.stories.mjs create mode 100644 browser/components/storybook/stories/letter-grade.stories.mjs create mode 100644 browser/components/storybook/stories/login-command-button.stories.mjs create mode 100644 browser/components/storybook/stories/login-timeline.stories.mjs create mode 100644 browser/components/storybook/stories/migration-wizard.stories.mjs create mode 100644 browser/components/storybook/stories/named-deck.stories.mjs create mode 100644 browser/components/storybook/stories/shopping-card.stories.mjs create mode 100644 browser/components/storybook/stories/shopping-container.stories.mjs create mode 100644 browser/components/storybook/stories/shopping-message-bar.stories.mjs create mode 100644 browser/components/syncedtabs/EventEmitter.sys.mjs create mode 100644 browser/components/syncedtabs/SyncedTabsDeckComponent.sys.mjs create mode 100644 browser/components/syncedtabs/SyncedTabsDeckStore.sys.mjs create mode 100644 browser/components/syncedtabs/SyncedTabsDeckView.sys.mjs create mode 100644 browser/components/syncedtabs/SyncedTabsListStore.sys.mjs create mode 100644 browser/components/syncedtabs/TabListComponent.sys.mjs create mode 100644 browser/components/syncedtabs/TabListView.sys.mjs create mode 100644 browser/components/syncedtabs/jar.mn create mode 100644 browser/components/syncedtabs/moz.build create mode 100644 browser/components/syncedtabs/sidebar.js create mode 100644 browser/components/syncedtabs/sidebar.xhtml create mode 100644 browser/components/syncedtabs/test/browser/browser.toml create mode 100644 browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js create mode 100644 browser/components/syncedtabs/test/browser/head.js create mode 100644 browser/components/syncedtabs/test/xpcshell/head.js create mode 100644 browser/components/syncedtabs/test/xpcshell/test_EventEmitter.js create mode 100644 browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckComponent.js create mode 100644 browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckStore.js create mode 100644 browser/components/syncedtabs/test/xpcshell/test_SyncedTabsListStore.js create mode 100644 browser/components/syncedtabs/test/xpcshell/test_TabListComponent.js create mode 100644 browser/components/syncedtabs/test/xpcshell/xpcshell.toml create mode 100644 browser/components/syncedtabs/util.sys.mjs create mode 100644 browser/components/tabpreview/jar.mn create mode 100644 browser/components/tabpreview/moz.build create mode 100644 browser/components/tabpreview/tabpreview.css create mode 100644 browser/components/tabpreview/tabpreview.mjs create mode 100644 browser/components/tabunloader/content/aboutUnloads.css create mode 100644 browser/components/tabunloader/content/aboutUnloads.html create mode 100644 browser/components/tabunloader/content/aboutUnloads.js create mode 100644 browser/components/tabunloader/docs/fullmode.png create mode 100644 browser/components/tabunloader/docs/index.rst create mode 100644 browser/components/tabunloader/docs/lightmode.png create mode 100644 browser/components/tabunloader/jar.mn create mode 100644 browser/components/tabunloader/moz.build create mode 100644 browser/components/tests/browser/browser.toml create mode 100644 browser/components/tests/browser/browser_browserGlue_showModal_trigger.js create mode 100644 browser/components/tests/browser/browser_browserGlue_telemetry.js create mode 100644 browser/components/tests/browser/browser_browserGlue_upgradeDialog_trigger.js create mode 100644 browser/components/tests/browser/browser_bug538331.js create mode 100644 browser/components/tests/browser/browser_contentpermissionprompt.js create mode 100644 browser/components/tests/browser/browser_default_bookmark_toolbar_visibility.js create mode 100644 browser/components/tests/browser/browser_default_browser_prompt.js create mode 100644 browser/components/tests/browser/browser_initial_tab_remoteType.js create mode 100644 browser/components/tests/browser/browser_quit_disabled.js create mode 100644 browser/components/tests/browser/browser_quit_multiple_tabs.js create mode 100644 browser/components/tests/browser/browser_quit_shortcut_warning.js create mode 100644 browser/components/tests/browser/browser_startup_homepage.js create mode 100644 browser/components/tests/browser/browser_system_notification_telemetry.js create mode 100644 browser/components/tests/browser/browser_to_handle_telemetry.js create mode 100644 browser/components/tests/browser/head.js create mode 100644 browser/components/tests/browser/whats_new_page/active-update.xml create mode 100644 browser/components/tests/browser/whats_new_page/browser.toml create mode 100644 browser/components/tests/browser/whats_new_page/browser_whats_new_page.js create mode 100644 browser/components/tests/browser/whats_new_page/config_localhost_update_url.json create mode 100644 browser/components/tests/browser/whats_new_page/updates/0/update.status create mode 100644 browser/components/tests/marionette/manifest.toml create mode 100644 browser/components/tests/marionette/test_no_errors_clean_profile.py create mode 100644 browser/components/tests/unit/distribution.ini create mode 100644 browser/components/tests/unit/head.js create mode 100644 browser/components/tests/unit/test_browserGlue_migration_ctrltab_recently_used_order.js create mode 100644 browser/components/tests/unit/test_browserGlue_migration_formautofill.js create mode 100644 browser/components/tests/unit/test_browserGlue_migration_no_errors.js create mode 100644 browser/components/tests/unit/test_browserGlue_migration_places_xulstore.js create mode 100644 browser/components/tests/unit/test_browserGlue_migration_remove_pref.js create mode 100644 browser/components/tests/unit/test_browserGlue_migration_resetDefaults.js create mode 100644 browser/components/tests/unit/test_distribution.js create mode 100644 browser/components/tests/unit/test_distribution_cachedexistence.js create mode 100644 browser/components/tests/unit/xpcshell.toml create mode 100644 browser/components/textrecognition/jar.mn create mode 100644 browser/components/textrecognition/moz.build create mode 100644 browser/components/textrecognition/tests/browser/browser.toml create mode 100644 browser/components/textrecognition/tests/browser/browser_textrecognition.js create mode 100644 browser/components/textrecognition/tests/browser/browser_textrecognition_no_result.js create mode 100644 browser/components/textrecognition/tests/browser/head.js create mode 100644 browser/components/textrecognition/tests/browser/image.png create mode 100644 browser/components/textrecognition/textrecognition.css create mode 100644 browser/components/textrecognition/textrecognition.html create mode 100644 browser/components/textrecognition/textrecognition.mjs create mode 100644 browser/components/touchbar/MacTouchBar.sys.mjs create mode 100644 browser/components/touchbar/components.conf create mode 100644 browser/components/touchbar/docs/index.rst create mode 100644 browser/components/touchbar/moz.build create mode 100644 browser/components/touchbar/tests/browser/browser.toml create mode 100644 browser/components/touchbar/tests/browser/browser_touchbar_searchrestrictions.js create mode 100644 browser/components/touchbar/tests/browser/browser_touchbar_tests.js create mode 100644 browser/components/touchbar/tests/browser/readerModeArticle.html create mode 100644 browser/components/touchbar/tests/browser/test-video.mp4 create mode 100644 browser/components/touchbar/tests/browser/video_test.html create mode 100644 browser/components/translations/content/translationsPanel.inc.xhtml create mode 100644 browser/components/translations/content/translationsPanel.js create mode 100644 browser/components/translations/jar.mn create mode 100644 browser/components/translations/moz.build create mode 100644 browser/components/translations/tests/browser/browser.toml create mode 100644 browser/components/translations/tests/browser/browser_translations_about_preferences_manage_downloaded_languages.js create mode 100644 browser/components/translations/tests/browser/browser_translations_about_preferences_settings_always_translate_languages.js create mode 100644 browser/components/translations/tests/browser/browser_translations_about_preferences_settings_never_translate_languages.js create mode 100644 browser/components/translations/tests/browser/browser_translations_about_preferences_settings_never_translate_sites.js create mode 100644 browser/components/translations/tests/browser/browser_translations_about_preferences_settings_ui.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_a11y_focus.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_always_translate_language_bad_data.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_always_translate_language_basic.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_always_translate_language_manual.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_always_translate_language_restore.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_app_menu_never_translate_language.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_app_menu_never_translate_site.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_auto_translate_error_view.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_auto_translate_revisit_view.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_basics.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_button.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_cancel.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_close_panel_never_translate_language_with_translations_active.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_close_panel_never_translate_language_with_translations_inactive.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_close_panel_never_translate_site.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_engine_destroy.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_engine_destroy_pending.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_engine_unsupported.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_engine_unsupported_lang.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_firstrun.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_firstrun_revisit.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_fuzzing.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_gear.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_never_translate_language.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_never_translate_site.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_never_translate_site_auto.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_never_translate_site_basic.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_never_translate_site_manual.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_retry.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_settings_unsupported_lang.js create mode 100644 browser/components/translations/tests/browser/browser_translations_panel_switch_languages.js create mode 100644 browser/components/translations/tests/browser/browser_translations_reader_mode.js create mode 100644 browser/components/translations/tests/browser/browser_translations_select_context_menu_feature_disabled.js create mode 100644 browser/components/translations/tests/browser/browser_translations_select_context_menu_with_full_page_translations_active.js create mode 100644 browser/components/translations/tests/browser/browser_translations_select_context_menu_with_hyperlink.js create mode 100644 browser/components/translations/tests/browser/browser_translations_select_context_menu_with_no_text_selected.js create mode 100644 browser/components/translations/tests/browser/browser_translations_select_context_menu_with_text_selected.js create mode 100644 browser/components/translations/tests/browser/browser_translations_telemetry_firstrun_auto_translate.js create mode 100644 browser/components/translations/tests/browser/browser_translations_telemetry_firstrun_basics.js create mode 100644 browser/components/translations/tests/browser/browser_translations_telemetry_firstrun_translation_failure.js create mode 100644 browser/components/translations/tests/browser/browser_translations_telemetry_firstrun_unsupported_lang.js create mode 100644 browser/components/translations/tests/browser/browser_translations_telemetry_open_panel.js create mode 100644 browser/components/translations/tests/browser/browser_translations_telemetry_panel_auto_offer.js create mode 100644 browser/components/translations/tests/browser/browser_translations_telemetry_panel_auto_offer_settings.js create mode 100644 browser/components/translations/tests/browser/browser_translations_telemetry_switch_languages.js create mode 100644 browser/components/translations/tests/browser/browser_translations_telemetry_translation_failure.js create mode 100644 browser/components/translations/tests/browser/browser_translations_telemetry_translation_request.js create mode 100644 browser/components/translations/tests/browser/head.js create mode 100644 browser/components/uitour/UITour-lib.js create mode 100644 browser/components/uitour/UITour.sys.mjs create mode 100644 browser/components/uitour/UITourChild.sys.mjs create mode 100644 browser/components/uitour/UITourParent.sys.mjs create mode 100644 browser/components/uitour/docs/UITour-lib.rst create mode 100644 browser/components/uitour/docs/index.rst create mode 100644 browser/components/uitour/moz.build create mode 100644 browser/components/uitour/test/browser.toml create mode 100644 browser/components/uitour/test/browser_UITour.js create mode 100644 browser/components/uitour/test/browser_UITour2.js create mode 100644 browser/components/uitour/test/browser_UITour3.js create mode 100644 browser/components/uitour/test/browser_UITour4.js create mode 100644 browser/components/uitour/test/browser_UITour5.js create mode 100644 browser/components/uitour/test/browser_UITour_annotation_size_attributes.js create mode 100644 browser/components/uitour/test/browser_UITour_availableTargets.js create mode 100644 browser/components/uitour/test/browser_UITour_colorway.js create mode 100644 browser/components/uitour/test/browser_UITour_defaultBrowser.js create mode 100644 browser/components/uitour/test/browser_UITour_detach_tab.js create mode 100644 browser/components/uitour/test/browser_UITour_forceReaderMode.js create mode 100644 browser/components/uitour/test/browser_UITour_modalDialog.js create mode 100644 browser/components/uitour/test/browser_UITour_observe.js create mode 100644 browser/components/uitour/test/browser_UITour_panel_close_annotation.js create mode 100644 browser/components/uitour/test/browser_UITour_pocket.js create mode 100644 browser/components/uitour/test/browser_UITour_private_browsing.js create mode 100644 browser/components/uitour/test/browser_UITour_resetProfile.js create mode 100644 browser/components/uitour/test/browser_UITour_showNewTab.js create mode 100644 browser/components/uitour/test/browser_UITour_showProtectionReport.js create mode 100644 browser/components/uitour/test/browser_UITour_sync.js create mode 100644 browser/components/uitour/test/browser_UITour_toggleReaderMode.js create mode 100644 browser/components/uitour/test/browser_backgroundTab.js create mode 100644 browser/components/uitour/test/browser_closeTab.js create mode 100644 browser/components/uitour/test/browser_fxa.js create mode 100644 browser/components/uitour/test/browser_fxa_config.js create mode 100644 browser/components/uitour/test/browser_openPreferences.js create mode 100644 browser/components/uitour/test/browser_openSearchPanel.js create mode 100644 browser/components/uitour/test/head.js create mode 100644 browser/components/uitour/test/image.png create mode 100644 browser/components/uitour/test/uitour.html create mode 100644 browser/components/urlbar/.eslintrc.js create mode 100644 browser/components/urlbar/MerinoClient.sys.mjs create mode 100644 browser/components/urlbar/QuickActionsLoaderDefault.sys.mjs create mode 100644 browser/components/urlbar/QuickSuggest.sys.mjs create mode 100644 browser/components/urlbar/UrlbarController.sys.mjs create mode 100644 browser/components/urlbar/UrlbarEventBufferer.sys.mjs create mode 100644 browser/components/urlbar/UrlbarInput.sys.mjs create mode 100644 browser/components/urlbar/UrlbarMuxerUnifiedComplete.sys.mjs create mode 100644 browser/components/urlbar/UrlbarPrefs.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderAboutPages.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderAliasEngines.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderAutofill.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderBookmarkKeywords.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderCalculator.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderClipboard.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderContextualSearch.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderHeuristicFallback.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderHistoryUrlHeuristic.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderInputHistory.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderInterventions.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderOmnibox.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderOpenTabs.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderPlaces.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderPrivateSearch.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderQuickActions.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderQuickSuggestContextualOptIn.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderRecentSearches.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderRemoteTabs.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderSearchSuggestions.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderSearchTips.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderTabToSearch.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderTokenAliasEngines.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderTopSites.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderUnitConversion.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProviderWeather.sys.mjs create mode 100644 browser/components/urlbar/UrlbarProvidersManager.sys.mjs create mode 100644 browser/components/urlbar/UrlbarResult.sys.mjs create mode 100644 browser/components/urlbar/UrlbarSearchOneOffs.sys.mjs create mode 100644 browser/components/urlbar/UrlbarSearchUtils.sys.mjs create mode 100644 browser/components/urlbar/UrlbarTokenizer.sys.mjs create mode 100644 browser/components/urlbar/UrlbarUtils.sys.mjs create mode 100644 browser/components/urlbar/UrlbarValueFormatter.sys.mjs create mode 100644 browser/components/urlbar/UrlbarView.sys.mjs create mode 100644 browser/components/urlbar/content/enUS-searchFeatures.ftl create mode 100644 browser/components/urlbar/content/interventions.ftl create mode 100644 browser/components/urlbar/content/quicksuggestOnboarding.css create mode 100644 browser/components/urlbar/content/quicksuggestOnboarding.html create mode 100644 browser/components/urlbar/content/quicksuggestOnboarding.js create mode 100644 browser/components/urlbar/content/quicksuggestOnboarding_magglass.svg create mode 100644 browser/components/urlbar/content/quicksuggestOnboarding_magglass_animation.svg create mode 100644 browser/components/urlbar/content/suggest-example.svg create mode 100644 browser/components/urlbar/docs/.rstcheck.cfg create mode 100644 browser/components/urlbar/docs/UrlbarController.rst create mode 100644 browser/components/urlbar/docs/UrlbarInput.rst create mode 100644 browser/components/urlbar/docs/UrlbarView.rst create mode 100644 browser/components/urlbar/docs/assets/lifetime/lifetime.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/autofill.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/bookmark-keyword.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/bookmark.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/empty-placeholder.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/empty-url.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/form-history.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/history.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/intervention-clear.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/intervention-refresh.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/intervention-update.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/non-empty.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/open-tab.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/prefs-privacy.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/prefs-show-suggestions.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/prefs-suggestions-first.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/remote-tab.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/search-heuristic.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/search-mode.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/search-offers-selected.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/search-offers.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/search-suggestion.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/search-tip-onboard.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/search-tip-redirect.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/tab-to-search-onboard.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/tab-to-search-regular.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/tail-suggestions.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/top-sites.png create mode 100644 browser/components/urlbar/docs/assets/nontechnical-overview/visit.png create mode 100644 browser/components/urlbar/docs/contact.rst create mode 100644 browser/components/urlbar/docs/debugging.rst create mode 100644 browser/components/urlbar/docs/dynamic-result-types.rst create mode 100644 browser/components/urlbar/docs/firefox-suggest-telemetry.rst create mode 100644 browser/components/urlbar/docs/index.rst create mode 100644 browser/components/urlbar/docs/lifetime.rst create mode 100644 browser/components/urlbar/docs/nontechnical-overview.rst create mode 100644 browser/components/urlbar/docs/overview.rst create mode 100644 browser/components/urlbar/docs/preferences.rst create mode 100644 browser/components/urlbar/docs/ranking.rst create mode 100644 browser/components/urlbar/docs/telemetry.rst create mode 100644 browser/components/urlbar/docs/testing.rst create mode 100644 browser/components/urlbar/docs/utilities.rst create mode 100644 browser/components/urlbar/jar.mn create mode 100644 browser/components/urlbar/metrics.yaml create mode 100644 browser/components/urlbar/moz.build create mode 100644 browser/components/urlbar/pings.yaml create mode 100644 browser/components/urlbar/private/AddonSuggestions.sys.mjs create mode 100644 browser/components/urlbar/private/AdmWikipedia.sys.mjs create mode 100644 browser/components/urlbar/private/BaseFeature.sys.mjs create mode 100644 browser/components/urlbar/private/BlockedSuggestions.sys.mjs create mode 100644 browser/components/urlbar/private/ImpressionCaps.sys.mjs create mode 100644 browser/components/urlbar/private/MDNSuggestions.sys.mjs create mode 100644 browser/components/urlbar/private/PocketSuggestions.sys.mjs create mode 100644 browser/components/urlbar/private/SuggestBackendJs.sys.mjs create mode 100644 browser/components/urlbar/private/SuggestBackendRust.sys.mjs create mode 100644 browser/components/urlbar/private/Weather.sys.mjs create mode 100644 browser/components/urlbar/private/YelpSuggestions.sys.mjs create mode 100644 browser/components/urlbar/tests/UrlbarTestUtils.sys.mjs create mode 100644 browser/components/urlbar/tests/browser-tips/README.txt create mode 100644 browser/components/urlbar/tests/browser-tips/browser.toml create mode 100644 browser/components/urlbar/tests/browser-tips/browser_interventions.js create mode 100644 browser/components/urlbar/tests/browser-tips/browser_picks.js create mode 100644 browser/components/urlbar/tests/browser-tips/browser_searchTips.js create mode 100644 browser/components/urlbar/tests/browser-tips/browser_searchTips_interaction.js create mode 100644 browser/components/urlbar/tests/browser-tips/browser_selection.js create mode 100644 browser/components/urlbar/tests/browser-tips/browser_updateAsk.js create mode 100644 browser/components/urlbar/tests/browser-tips/browser_updateRefresh.js create mode 100644 browser/components/urlbar/tests/browser-tips/browser_updateRestart.js create mode 100644 browser/components/urlbar/tests/browser-tips/browser_updateWeb.js create mode 100644 browser/components/urlbar/tests/browser-tips/head.js create mode 100644 browser/components/urlbar/tests/browser-tips/slow-page.html create mode 100644 browser/components/urlbar/tests/browser-tips/suppress-tips/active-update.xml create mode 100644 browser/components/urlbar/tests/browser-tips/suppress-tips/browser.toml create mode 100644 browser/components/urlbar/tests/browser-tips/suppress-tips/browser_suppressTips.js create mode 100644 browser/components/urlbar/tests/browser-tips/suppress-tips/config_localhost_update_url.json create mode 100644 browser/components/urlbar/tests/browser-tips/suppress-tips/updates/0/update.status create mode 100644 browser/components/urlbar/tests/browser-updateResults/browser.toml create mode 100644 browser/components/urlbar/tests/browser-updateResults/browser_appendSpanCount.js create mode 100644 browser/components/urlbar/tests/browser-updateResults/browser_noUpdateResultsFromOtherProviders.js create mode 100644 browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_10_search_10_url.js create mode 100644 browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_10_search_5_url.js create mode 100644 browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_10_url_10_search.js create mode 100644 browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_10_url_5_search.js create mode 100644 browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_5_search_10_url.js create mode 100644 browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_5_search_5_url.js create mode 100644 browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_5_url_10_search.js create mode 100644 browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_5_url_5_search.js create mode 100644 browser/components/urlbar/tests/browser-updateResults/head.js create mode 100644 browser/components/urlbar/tests/browser/POSTSearchEngine.xml create mode 100644 browser/components/urlbar/tests/browser/add_search_engine_0.xml create mode 100644 browser/components/urlbar/tests/browser/add_search_engine_1.xml create mode 100644 browser/components/urlbar/tests/browser/add_search_engine_2.xml create mode 100644 browser/components/urlbar/tests/browser/add_search_engine_3.xml create mode 100644 browser/components/urlbar/tests/browser/add_search_engine_invalid.html create mode 100644 browser/components/urlbar/tests/browser/add_search_engine_many.html create mode 100644 browser/components/urlbar/tests/browser/add_search_engine_one.html create mode 100644 browser/components/urlbar/tests/browser/add_search_engine_same_names.html create mode 100644 browser/components/urlbar/tests/browser/add_search_engine_two.html create mode 100644 browser/components/urlbar/tests/browser/authenticate.sjs create mode 100644 browser/components/urlbar/tests/browser/browser.toml create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_formatValue.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_formatValue_detachedTab.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_formatValue_strikeout.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_hiddenFocus.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_overflow.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_overflow_resize.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_privateFeature.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_backgroundTabs.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_modifiedUrl.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_moveTab.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_popup.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_revert.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_searchBar.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_searchMode.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_strings.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_stringsUnsafe.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_switch_tab.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_telemetry.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_setURI.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_tooltip.js create mode 100644 browser/components/urlbar/tests/browser/browser_UrlbarInput_trimURLs.js create mode 100644 browser/components/urlbar/tests/browser/browser_aboutHomeLoading.js create mode 100644 browser/components/urlbar/tests/browser/browser_acknowledgeFeedbackAndDismissal.js create mode 100644 browser/components/urlbar/tests/browser/browser_action_searchengine.js create mode 100644 browser/components/urlbar/tests/browser/browser_action_searchengine_alias.js create mode 100644 browser/components/urlbar/tests/browser/browser_add_search_engine.js create mode 100644 browser/components/urlbar/tests/browser/browser_autoFill_backspaced.js create mode 100644 browser/components/urlbar/tests/browser/browser_autoFill_canonize.js create mode 100644 browser/components/urlbar/tests/browser/browser_autoFill_caretNotAtEnd.js create mode 100644 browser/components/urlbar/tests/browser/browser_autoFill_clear_properly_on_accent_char.js create mode 100644 browser/components/urlbar/tests/browser/browser_autoFill_firstResult.js create mode 100644 browser/components/urlbar/tests/browser/browser_autoFill_paste.js create mode 100644 browser/components/urlbar/tests/browser/browser_autoFill_placeholder.js create mode 100644 browser/components/urlbar/tests/browser/browser_autoFill_preserve.js create mode 100644 browser/components/urlbar/tests/browser/browser_autoFill_trimURLs.js create mode 100644 browser/components/urlbar/tests/browser/browser_autoFill_typed.js create mode 100644 browser/components/urlbar/tests/browser/browser_autoFill_undo.js create mode 100644 browser/components/urlbar/tests/browser/browser_autoOpen.js create mode 100644 browser/components/urlbar/tests/browser/browser_autocomplete_a11y_label.js create mode 100644 browser/components/urlbar/tests/browser/browser_autocomplete_autoselect.js create mode 100644 browser/components/urlbar/tests/browser/browser_autocomplete_cursor.js create mode 100644 browser/components/urlbar/tests/browser/browser_autocomplete_edit_completed.js create mode 100644 browser/components/urlbar/tests/browser/browser_autocomplete_enter_race.js create mode 100644 browser/components/urlbar/tests/browser/browser_autocomplete_no_title.js create mode 100644 browser/components/urlbar/tests/browser/browser_autocomplete_readline_navigation.js create mode 100644 browser/components/urlbar/tests/browser/browser_autocomplete_tag_star_visibility.js create mode 100644 browser/components/urlbar/tests/browser/browser_bestMatch.js create mode 100644 browser/components/urlbar/tests/browser/browser_blanking.js create mode 100644 browser/components/urlbar/tests/browser/browser_blobIcons.js create mode 100644 browser/components/urlbar/tests/browser/browser_bufferer_onQueryResults.js create mode 100644 browser/components/urlbar/tests/browser/browser_calculator.js create mode 100644 browser/components/urlbar/tests/browser/browser_canonizeURL.js create mode 100644 browser/components/urlbar/tests/browser/browser_caret_position.js create mode 100644 browser/components/urlbar/tests/browser/browser_click_row_border.js create mode 100644 browser/components/urlbar/tests/browser/browser_clipboard.js create mode 100644 browser/components/urlbar/tests/browser/browser_closePanelOnClick.js create mode 100644 browser/components/urlbar/tests/browser/browser_content_opener.js create mode 100644 browser/components/urlbar/tests/browser/browser_contextualsearch.js create mode 100644 browser/components/urlbar/tests/browser/browser_copy_and_paste_first_result.js create mode 100644 browser/components/urlbar/tests/browser/browser_copy_during_load.js create mode 100644 browser/components/urlbar/tests/browser/browser_copying.js create mode 100644 browser/components/urlbar/tests/browser/browser_customizeMode.js create mode 100644 browser/components/urlbar/tests/browser/browser_cutting.js create mode 100644 browser/components/urlbar/tests/browser/browser_decode.js create mode 100644 browser/components/urlbar/tests/browser/browser_delete.js create mode 100644 browser/components/urlbar/tests/browser/browser_deleteAllText.js create mode 100644 browser/components/urlbar/tests/browser/browser_display_selectedAction_Extensions.js create mode 100644 browser/components/urlbar/tests/browser/browser_dns_first_for_single_words.js create mode 100644 browser/components/urlbar/tests/browser/browser_downArrowKeySearch.js create mode 100644 browser/components/urlbar/tests/browser/browser_dragdropURL.js create mode 100644 browser/components/urlbar/tests/browser/browser_dynamicResults.js create mode 100644 browser/components/urlbar/tests/browser/browser_editAndEnterWithSlowQuery.js create mode 100644 browser/components/urlbar/tests/browser/browser_edit_invalid_url.js create mode 100644 browser/components/urlbar/tests/browser/browser_engagement.js create mode 100644 browser/components/urlbar/tests/browser/browser_enter.js create mode 100644 browser/components/urlbar/tests/browser/browser_enterAfterMouseOver.js create mode 100644 browser/components/urlbar/tests/browser/browser_focusedCmdK.js create mode 100644 browser/components/urlbar/tests/browser/browser_groupLabels.js create mode 100644 browser/components/urlbar/tests/browser/browser_handleCommand_fallback.js create mode 100644 browser/components/urlbar/tests/browser/browser_hashChangeProxyState.js create mode 100644 browser/components/urlbar/tests/browser/browser_heuristicNotAddedFirst.js create mode 100644 browser/components/urlbar/tests/browser/browser_hideHeuristic.js create mode 100644 browser/components/urlbar/tests/browser/browser_ime_composition.js create mode 100644 browser/components/urlbar/tests/browser/browser_inputHistory.js create mode 100644 browser/components/urlbar/tests/browser/browser_inputHistory_autofill.js create mode 100644 browser/components/urlbar/tests/browser/browser_inputHistory_emptystring.js create mode 100644 browser/components/urlbar/tests/browser/browser_keepStateAcrossTabSwitches.js create mode 100644 browser/components/urlbar/tests/browser/browser_keyword.js create mode 100644 browser/components/urlbar/tests/browser/browser_keywordBookmarklets.js create mode 100644 browser/components/urlbar/tests/browser/browser_keywordSearch.js create mode 100644 browser/components/urlbar/tests/browser/browser_keywordSearch_postData.js create mode 100644 browser/components/urlbar/tests/browser/browser_keyword_override.js create mode 100644 browser/components/urlbar/tests/browser/browser_keyword_select_and_type.js create mode 100644 browser/components/urlbar/tests/browser/browser_loadRace.js create mode 100644 browser/components/urlbar/tests/browser/browser_locationBarCommand.js create mode 100644 browser/components/urlbar/tests/browser/browser_locationBarExternalLoad.js create mode 100644 browser/components/urlbar/tests/browser/browser_locationchange_urlbar_edit_dos.js create mode 100644 browser/components/urlbar/tests/browser/browser_middleClick.js create mode 100644 browser/components/urlbar/tests/browser/browser_move_tab_to_new_window.js create mode 100644 browser/components/urlbar/tests/browser/browser_new_tab_urlbar_reset.js create mode 100644 browser/components/urlbar/tests/browser/browser_observers_for_strip_on_share.js create mode 100644 browser/components/urlbar/tests/browser/browser_oneOffs.js create mode 100644 browser/components/urlbar/tests/browser/browser_oneOffs_contextMenu.js create mode 100644 browser/components/urlbar/tests/browser/browser_oneOffs_heuristicRestyle.js create mode 100644 browser/components/urlbar/tests/browser/browser_oneOffs_keyModifiers.js create mode 100644 browser/components/urlbar/tests/browser/browser_oneOffs_searchSuggestions.js create mode 100644 browser/components/urlbar/tests/browser/browser_oneOffs_settings.js create mode 100644 browser/components/urlbar/tests/browser/browser_pasteAndGo.js create mode 100644 browser/components/urlbar/tests/browser/browser_paste_multi_lines.js create mode 100644 browser/components/urlbar/tests/browser/browser_paste_then_focus.js create mode 100644 browser/components/urlbar/tests/browser/browser_paste_then_switch_tab.js create mode 100644 browser/components/urlbar/tests/browser/browser_percent_encoded.js create mode 100644 browser/components/urlbar/tests/browser/browser_placeholder.js create mode 100644 browser/components/urlbar/tests/browser/browser_populateAfterPushState.js create mode 100644 browser/components/urlbar/tests/browser/browser_primary_selection_safe_on_new_tab.js create mode 100644 browser/components/urlbar/tests/browser/browser_privateBrowsingWindowChange.js create mode 100644 browser/components/urlbar/tests/browser/browser_queryContextCache.js create mode 100644 browser/components/urlbar/tests/browser/browser_quickactions.js create mode 100644 browser/components/urlbar/tests/browser/browser_quickactions_devtools.js create mode 100644 browser/components/urlbar/tests/browser/browser_quickactions_screenshot.js create mode 100644 browser/components/urlbar/tests/browser/browser_quickactions_tab_refocus.js create mode 100644 browser/components/urlbar/tests/browser/browser_raceWithTabs.js create mode 100644 browser/components/urlbar/tests/browser/browser_recentsearches.js create mode 100644 browser/components/urlbar/tests/browser/browser_redirect_error.js create mode 100644 browser/components/urlbar/tests/browser/browser_remoteness_switch.js create mode 100644 browser/components/urlbar/tests/browser/browser_remotetab.js create mode 100644 browser/components/urlbar/tests/browser/browser_removeUnsafeProtocolsFromURLBarPaste.js create mode 100644 browser/components/urlbar/tests/browser/browser_remove_match.js create mode 100644 browser/components/urlbar/tests/browser/browser_restoreEmptyInput.js create mode 100644 browser/components/urlbar/tests/browser/browser_resultSpan.js create mode 100644 browser/components/urlbar/tests/browser/browser_result_menu.js create mode 100644 browser/components/urlbar/tests/browser/browser_result_menu_general.js create mode 100644 browser/components/urlbar/tests/browser/browser_result_onSelection.js create mode 100644 browser/components/urlbar/tests/browser/browser_results_format_displayValue.js create mode 100644 browser/components/urlbar/tests/browser/browser_retainedResultsOnFocus.js create mode 100644 browser/components/urlbar/tests/browser/browser_revert.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchFunction.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchHistoryLimit.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchMode_alias_replacement.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchMode_autofill.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchMode_clickLink.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchMode_engineRemoval.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchMode_excludeResults.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchMode_heuristic.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchMode_indicator.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchMode_indicator_clickthrough.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchMode_localOneOffs_actionText.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchMode_newWindow.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchMode_no_results.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchMode_oneOffButton.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchMode_pickResult.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchMode_preview.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchMode_sessionStore.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchMode_setURI.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchMode_suggestions.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchMode_switchTabs.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchSettings.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchSingleWordNotification.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchSuggestions.js create mode 100644 browser/components/urlbar/tests/browser/browser_searchTelemetry.js create mode 100644 browser/components/urlbar/tests/browser/browser_search_bookmarks_from_bookmarks_menu.js create mode 100644 browser/components/urlbar/tests/browser/browser_search_continuation.js create mode 100644 browser/components/urlbar/tests/browser/browser_search_history_from_history_panel.js create mode 100644 browser/components/urlbar/tests/browser/browser_selectStaleResults.js create mode 100644 browser/components/urlbar/tests/browser/browser_selectionKeyNavigation.js create mode 100644 browser/components/urlbar/tests/browser/browser_separatePrivateDefault.js create mode 100644 browser/components/urlbar/tests/browser/browser_separatePrivateDefault_differentEngine.js create mode 100644 browser/components/urlbar/tests/browser/browser_shortcuts_add_search_engine.js create mode 100644 browser/components/urlbar/tests/browser/browser_slow_heuristic.js create mode 100644 browser/components/urlbar/tests/browser/browser_speculative_connect.js create mode 100644 browser/components/urlbar/tests/browser/browser_speculative_connect_not_with_client_cert.js create mode 100644 browser/components/urlbar/tests/browser/browser_stop.js create mode 100644 browser/components/urlbar/tests/browser/browser_stopSearchOnSelection.js create mode 100644 browser/components/urlbar/tests/browser/browser_stop_pending.js create mode 100644 browser/components/urlbar/tests/browser/browser_strip_on_share.js create mode 100644 browser/components/urlbar/tests/browser/browser_strip_on_share_telemetry.js create mode 100644 browser/components/urlbar/tests/browser/browser_suggestedIndex.js create mode 100644 browser/components/urlbar/tests/browser/browser_suppressFocusBorder.js create mode 100644 browser/components/urlbar/tests/browser/browser_switchTab_closesUrlbarPopup.js create mode 100644 browser/components/urlbar/tests/browser/browser_switchTab_currentTab.js create mode 100644 browser/components/urlbar/tests/browser/browser_switchTab_decodeuri.js create mode 100644 browser/components/urlbar/tests/browser/browser_switchTab_inputHistory.js create mode 100644 browser/components/urlbar/tests/browser/browser_switchTab_override.js create mode 100644 browser/components/urlbar/tests/browser/browser_switchToTabHavingURI_aOpenParams.js create mode 100644 browser/components/urlbar/tests/browser/browser_switchToTab_chiclet.js create mode 100644 browser/components/urlbar/tests/browser/browser_switchToTab_closed_tab.js create mode 100644 browser/components/urlbar/tests/browser/browser_switchToTab_closes_newtab.js create mode 100644 browser/components/urlbar/tests/browser/browser_switchToTab_fullUrl_repeatedKeydown.js create mode 100644 browser/components/urlbar/tests/browser/browser_tabKeyBehavior.js create mode 100644 browser/components/urlbar/tests/browser/browser_tabMatchesInAwesomebar.js create mode 100644 browser/components/urlbar/tests/browser/browser_tabMatchesInAwesomebar_perwindowpb.js create mode 100644 browser/components/urlbar/tests/browser/browser_tabToSearch.js create mode 100644 browser/components/urlbar/tests/browser/browser_textruns.js create mode 100644 browser/components/urlbar/tests/browser/browser_tokenAlias.js create mode 100644 browser/components/urlbar/tests/browser/browser_top_sites.js create mode 100644 browser/components/urlbar/tests/browser/browser_top_sites_private.js create mode 100644 browser/components/urlbar/tests/browser/browser_typed_value.js create mode 100644 browser/components/urlbar/tests/browser/browser_unitConversion.js create mode 100644 browser/components/urlbar/tests/browser/browser_updateForDomainCompletion.js create mode 100644 browser/components/urlbar/tests/browser/browser_url_formatted_correctly_on_load.js create mode 100644 browser/components/urlbar/tests/browser/browser_urlbar_annotation.js create mode 100644 browser/components/urlbar/tests/browser/browser_urlbar_selection.js create mode 100644 browser/components/urlbar/tests/browser/browser_urlbar_telemetry.js create mode 100644 browser/components/urlbar/tests/browser/browser_urlbar_telemetry_autofill.js create mode 100644 browser/components/urlbar/tests/browser/browser_urlbar_telemetry_dynamic.js create mode 100644 browser/components/urlbar/tests/browser/browser_urlbar_telemetry_extension.js create mode 100644 browser/components/urlbar/tests/browser/browser_urlbar_telemetry_handoff.js create mode 100644 browser/components/urlbar/tests/browser/browser_urlbar_telemetry_persisted.js create mode 100644 browser/components/urlbar/tests/browser/browser_urlbar_telemetry_places.js create mode 100644 browser/components/urlbar/tests/browser/browser_urlbar_telemetry_quickactions.js create mode 100644 browser/components/urlbar/tests/browser/browser_urlbar_telemetry_remotetab.js create mode 100644 browser/components/urlbar/tests/browser/browser_urlbar_telemetry_searchmode.js create mode 100644 browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tabtosearch.js create mode 100644 browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tip.js create mode 100644 browser/components/urlbar/tests/browser/browser_urlbar_telemetry_topsite.js create mode 100644 browser/components/urlbar/tests/browser/browser_urlbar_telemetry_zeroPrefix.js create mode 100644 browser/components/urlbar/tests/browser/browser_userTypedValue.js create mode 100644 browser/components/urlbar/tests/browser/browser_valueOnTabSwitch.js create mode 100644 browser/components/urlbar/tests/browser/browser_view_emptyResultSet.js create mode 100644 browser/components/urlbar/tests/browser/browser_view_removedSelectedElement.js create mode 100644 browser/components/urlbar/tests/browser/browser_view_resultDisplay.js create mode 100644 browser/components/urlbar/tests/browser/browser_view_resultTypes_display.js create mode 100644 browser/components/urlbar/tests/browser/browser_view_selectionByMouse.js create mode 100644 browser/components/urlbar/tests/browser/browser_waitForLoadStartOrTimeout.js create mode 100644 browser/components/urlbar/tests/browser/browser_whereToOpen.js create mode 100644 browser/components/urlbar/tests/browser/dummy_page.html create mode 100644 browser/components/urlbar/tests/browser/dynamicResult0.css create mode 100644 browser/components/urlbar/tests/browser/dynamicResult1.css create mode 100644 browser/components/urlbar/tests/browser/file_blank_but_not_blank.html create mode 100644 browser/components/urlbar/tests/browser/file_copying_home.html create mode 100644 browser/components/urlbar/tests/browser/file_urlbar_edit_dos.html create mode 100644 browser/components/urlbar/tests/browser/file_userTypedValue.html create mode 100644 browser/components/urlbar/tests/browser/head-common.js create mode 100644 browser/components/urlbar/tests/browser/head.js create mode 100644 browser/components/urlbar/tests/browser/mixed_active.html create mode 100644 browser/components/urlbar/tests/browser/moz.png create mode 100644 browser/components/urlbar/tests/browser/print_postdata.sjs create mode 100644 browser/components/urlbar/tests/browser/redirect_error.sjs create mode 100644 browser/components/urlbar/tests/browser/redirect_to.sjs create mode 100644 browser/components/urlbar/tests/browser/search-engines/basic/manifest.json create mode 100644 browser/components/urlbar/tests/browser/searchSuggestionEngine.sjs create mode 100644 browser/components/urlbar/tests/browser/searchSuggestionEngine.xml create mode 100644 browser/components/urlbar/tests/browser/searchSuggestionEngine2.xml create mode 100644 browser/components/urlbar/tests/browser/searchSuggestionEngineMany.xml create mode 100644 browser/components/urlbar/tests/browser/searchSuggestionEngineSlow.xml create mode 100644 browser/components/urlbar/tests/browser/slow-page.sjs create mode 100644 browser/components/urlbar/tests/browser/urlbarTelemetrySearchSuggestions.sjs create mode 100644 browser/components/urlbar/tests/browser/urlbarTelemetrySearchSuggestions.xml create mode 100644 browser/components/urlbar/tests/browser/urlbarTelemetryUrlbarDynamic.css create mode 100644 browser/components/urlbar/tests/browser/wait-a-bit.sjs create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser.toml create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_groups.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction_persisted_search_terms_disabled.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction_persisted_search_terms_enabled.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_n_chars_n_words.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_sap.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_search_engine_default_id.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_search_mode.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_tips.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_edge_cases.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_groups.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction_persisted_search_terms_disabled.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction_persisted_search_terms_enabled.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_n_chars_n_words.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_sap.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_search_engine_default_id.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_search_mode.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_selected_result.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_tips.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_type.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_exposure.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_exposure_edge_cases.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_groups.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction_persisted_search_terms_disabled.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction_persisted_search_terms_enabled.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_n_chars_n_words.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_preferences.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_sap.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_search_engine_default_id.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_search_mode.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_timing.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_record_preferences.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/head-exposure.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/head-groups.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/head-interaction.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/head-n_chars_n_words.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/head-sap.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/head-search_engine_default_id.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/head-search_mode.js create mode 100644 browser/components/urlbar/tests/engagementTelemetry/browser/head.js create mode 100644 browser/components/urlbar/tests/quicksuggest/MerinoTestUtils.sys.mjs create mode 100644 browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs create mode 100644 browser/components/urlbar/tests/quicksuggest/RemoteSettingsServer.sys.mjs create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/browser.toml create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_addons.js create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_block.js create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_configuration.js create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_indexes.js create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_mdn.js create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_merinoSessions.js create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_onboardingDialog.js create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_pocket.js create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_yelp.js create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_dynamicWikipedia.js create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_gleanEmptyStrings.js create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_impressionEdgeCases.js create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_navigationalSuggestions.js create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_nonsponsored.js create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_other.js create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_sponsored.js create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_weather.js create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/browser_weather.js create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/head.js create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.sjs create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.xml create mode 100644 browser/components/urlbar/tests/quicksuggest/browser/subdialog.xhtml create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/head.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient_sessions.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_addons.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_mdn.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v1.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v2.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_nonUniqueKeywords.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_offlineDefault.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_pocket.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_positionInSuggestions.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_scoreMap.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_yelp.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_rust_ingest.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_suggestionsMap.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_weather.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_weather_keywords.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/xpcshell.toml create mode 100644 browser/components/urlbar/tests/unit/data/engine.xml create mode 100644 browser/components/urlbar/tests/unit/head.js create mode 100644 browser/components/urlbar/tests/unit/test_000_frecency.js create mode 100644 browser/components/urlbar/tests/unit/test_UrlbarController_integration.js create mode 100644 browser/components/urlbar/tests/unit/test_UrlbarController_telemetry.js create mode 100644 browser/components/urlbar/tests/unit/test_UrlbarController_unit.js create mode 100644 browser/components/urlbar/tests/unit/test_UrlbarPrefs.js create mode 100644 browser/components/urlbar/tests/unit/test_UrlbarQueryContext.js create mode 100644 browser/components/urlbar/tests/unit/test_UrlbarQueryContext_restrictSource.js create mode 100644 browser/components/urlbar/tests/unit/test_UrlbarSearchUtils.js create mode 100644 browser/components/urlbar/tests/unit/test_UrlbarUtils_addToUrlbarHistory.js create mode 100644 browser/components/urlbar/tests/unit/test_UrlbarUtils_copySnakeKeysToCamel.js create mode 100644 browser/components/urlbar/tests/unit/test_UrlbarUtils_getShortcutOrURIAndPostData.js create mode 100644 browser/components/urlbar/tests/unit/test_UrlbarUtils_getTokenMatches.js create mode 100644 browser/components/urlbar/tests/unit/test_UrlbarUtils_skippableTimer.js create mode 100644 browser/components/urlbar/tests/unit/test_UrlbarUtils_unEscapeURIForUI.js create mode 100644 browser/components/urlbar/tests/unit/test_about_urls.js create mode 100644 browser/components/urlbar/tests/unit/test_autofill_adaptiveHistory.js create mode 100644 browser/components/urlbar/tests/unit/test_autofill_bookmarked.js create mode 100644 browser/components/urlbar/tests/unit/test_autofill_do_not_trim.js create mode 100644 browser/components/urlbar/tests/unit/test_autofill_functional.js create mode 100644 browser/components/urlbar/tests/unit/test_autofill_origins.js create mode 100644 browser/components/urlbar/tests/unit/test_autofill_originsAndQueries.js create mode 100644 browser/components/urlbar/tests/unit/test_autofill_origins_alt_frecency.js create mode 100644 browser/components/urlbar/tests/unit/test_autofill_prefix_fallback.js create mode 100644 browser/components/urlbar/tests/unit/test_autofill_search_engine_aliases.js create mode 100644 browser/components/urlbar/tests/unit/test_autofill_urls.js create mode 100644 browser/components/urlbar/tests/unit/test_avoid_stripping_to_empty_tokens.js create mode 100644 browser/components/urlbar/tests/unit/test_calculator.js create mode 100644 browser/components/urlbar/tests/unit/test_casing.js create mode 100644 browser/components/urlbar/tests/unit/test_dedupe_embedded_url_param.js create mode 100644 browser/components/urlbar/tests/unit/test_dedupe_prefix.js create mode 100644 browser/components/urlbar/tests/unit/test_dedupe_switchTab.js create mode 100644 browser/components/urlbar/tests/unit/test_dont_autofill_cases.js create mode 100644 browser/components/urlbar/tests/unit/test_download_embed_bookmarks.js create mode 100644 browser/components/urlbar/tests/unit/test_empty_search.js create mode 100644 browser/components/urlbar/tests/unit/test_encoded_urls.js create mode 100644 browser/components/urlbar/tests/unit/test_escaping_badEscapedURI.js create mode 100644 browser/components/urlbar/tests/unit/test_escaping_escapeSelf.js create mode 100644 browser/components/urlbar/tests/unit/test_exposure.js create mode 100644 browser/components/urlbar/tests/unit/test_frecency.js create mode 100644 browser/components/urlbar/tests/unit/test_frecency_alternative_nimbus.js create mode 100644 browser/components/urlbar/tests/unit/test_heuristic_cancel.js create mode 100644 browser/components/urlbar/tests/unit/test_hideSponsoredHistory.js create mode 100644 browser/components/urlbar/tests/unit/test_history_bookmark_results_on_search_service_failure.js create mode 100644 browser/components/urlbar/tests/unit/test_keywords.js create mode 100644 browser/components/urlbar/tests/unit/test_l10nCache.js create mode 100644 browser/components/urlbar/tests/unit/test_local_suggest_prefs.js create mode 100644 browser/components/urlbar/tests/unit/test_match_javascript.js create mode 100644 browser/components/urlbar/tests/unit/test_multi_word_search.js create mode 100644 browser/components/urlbar/tests/unit/test_muxer.js create mode 100644 browser/components/urlbar/tests/unit/test_pages_alt_frecency.js create mode 100644 browser/components/urlbar/tests/unit/test_protocol_ignore.js create mode 100644 browser/components/urlbar/tests/unit/test_protocol_swap.js create mode 100644 browser/components/urlbar/tests/unit/test_providerAliasEngines.js create mode 100644 browser/components/urlbar/tests/unit/test_providerHeuristicFallback.js create mode 100644 browser/components/urlbar/tests/unit/test_providerHistoryUrlHeuristic.js create mode 100644 browser/components/urlbar/tests/unit/test_providerKeywords.js create mode 100644 browser/components/urlbar/tests/unit/test_providerOmnibox.js create mode 100644 browser/components/urlbar/tests/unit/test_providerOpenTabs.js create mode 100644 browser/components/urlbar/tests/unit/test_providerPlaces.js create mode 100644 browser/components/urlbar/tests/unit/test_providerPlaces_duplicate_entries.js create mode 100644 browser/components/urlbar/tests/unit/test_providerPlaces_nonEnglish.js create mode 100644 browser/components/urlbar/tests/unit/test_providerRecentSearches.js create mode 100644 browser/components/urlbar/tests/unit/test_providerTabToSearch.js create mode 100644 browser/components/urlbar/tests/unit/test_providerTabToSearch_partialHost.js create mode 100644 browser/components/urlbar/tests/unit/test_providersManager.js create mode 100644 browser/components/urlbar/tests/unit/test_providersManager_filtering.js create mode 100644 browser/components/urlbar/tests/unit/test_providersManager_maxResults.js create mode 100644 browser/components/urlbar/tests/unit/test_queryScorer.js create mode 100644 browser/components/urlbar/tests/unit/test_query_url.js create mode 100644 browser/components/urlbar/tests/unit/test_quickactions.js create mode 100644 browser/components/urlbar/tests/unit/test_remote_tabs.js create mode 100644 browser/components/urlbar/tests/unit/test_resultGroups.js create mode 100644 browser/components/urlbar/tests/unit/test_richsuggestions.js create mode 100644 browser/components/urlbar/tests/unit/test_richsuggestions_order.js create mode 100644 browser/components/urlbar/tests/unit/test_search_engine_restyle.js create mode 100644 browser/components/urlbar/tests/unit/test_search_suggestions.js create mode 100644 browser/components/urlbar/tests/unit/test_search_suggestions_aliases.js create mode 100644 browser/components/urlbar/tests/unit/test_search_suggestions_tail.js create mode 100644 browser/components/urlbar/tests/unit/test_special_search.js create mode 100644 browser/components/urlbar/tests/unit/test_suggestedIndex.js create mode 100644 browser/components/urlbar/tests/unit/test_suggestedIndexRelativeToGroup.js create mode 100644 browser/components/urlbar/tests/unit/test_tab_matches.js create mode 100644 browser/components/urlbar/tests/unit/test_tags_caseInsensitivity.js create mode 100644 browser/components/urlbar/tests/unit/test_tags_extendedUnicode.js create mode 100644 browser/components/urlbar/tests/unit/test_tags_general.js create mode 100644 browser/components/urlbar/tests/unit/test_tags_matchBookmarkTitles.js create mode 100644 browser/components/urlbar/tests/unit/test_tags_returnedInSearches.js create mode 100644 browser/components/urlbar/tests/unit/test_tokenizer.js create mode 100644 browser/components/urlbar/tests/unit/test_trimming.js create mode 100644 browser/components/urlbar/tests/unit/test_unitConversion.js create mode 100644 browser/components/urlbar/tests/unit/test_word_boundary_search.js create mode 100644 browser/components/urlbar/tests/unit/xpcshell.toml create mode 100644 browser/components/urlbar/unitconverters/UnitConverterSimple.sys.mjs create mode 100644 browser/components/urlbar/unitconverters/UnitConverterTemperature.sys.mjs create mode 100644 browser/components/urlbar/unitconverters/UnitConverterTimezone.sys.mjs create mode 100644 browser/components/urlbar/unitconverters/moz.build (limited to 'browser/components') diff --git a/browser/components/.eslintrc.js b/browser/components/.eslintrc.js new file mode 100644 index 0000000000..52bf9c5e32 --- /dev/null +++ b/browser/components/.eslintrc.js @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.exports = { + rules: { + // XXX Bug 1326071 - This should be reduced down - probably to 20 or to + // be removed & synced with the mozilla/recommended value. + complexity: ["error", 61], + }, +}; diff --git a/browser/components/BrowserComponents.manifest b/browser/components/BrowserComponents.manifest new file mode 100644 index 0000000000..cf329edf6e --- /dev/null +++ b/browser/components/BrowserComponents.manifest @@ -0,0 +1,10 @@ +# nsBrowserGlue.js + +# This component must restrict its registration for the app-startup category +# to the specific list of apps that use it so it doesn't get loaded in xpcshell. +# Thus we restrict it to these apps: +# +# browser: {ec8030f7-c20a-464f-9b0e-13a3a9e97384} +# mobile/android: {aa3c5121-dab2-40e2-81ca-7ea25febc110} + +category app-startup nsBrowserGlue @mozilla.org/browser/browserglue;1 application={ec8030f7-c20a-464f-9b0e-13a3a9e97384} application={aa3c5121-dab2-40e2-81ca-7ea25febc110} diff --git a/browser/components/BrowserContentHandler.sys.mjs b/browser/components/BrowserContentHandler.sys.mjs new file mode 100644 index 0000000000..88f4b745db --- /dev/null +++ b/browser/components/BrowserContentHandler.sys.mjs @@ -0,0 +1,1500 @@ +/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", + FirstStartup: "resource://gre/modules/FirstStartup.sys.mjs", + HeadlessShell: "resource:///modules/HeadlessShell.sys.mjs", + HomePage: "resource:///modules/HomePage.sys.mjs", + LaterRun: "resource:///modules/LaterRun.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SessionStartup: "resource:///modules/sessionstore/SessionStartup.sys.mjs", + ShellService: "resource:///modules/ShellService.sys.mjs", + SpecialMessageActions: + "resource://messaging-system/lib/SpecialMessageActions.sys.mjs", + UpdatePing: "resource://gre/modules/UpdatePing.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + UpdateManager: ["@mozilla.org/updates/update-manager;1", "nsIUpdateManager"], + WinTaskbar: ["@mozilla.org/windows-taskbar;1", "nsIWinTaskbar"], + WindowsUIUtils: ["@mozilla.org/windows-ui-utils;1", "nsIWindowsUIUtils"], +}); + +ChromeUtils.defineLazyGetter(lazy, "gSystemPrincipal", () => + Services.scriptSecurityManager.getSystemPrincipal() +); + +ChromeUtils.defineLazyGetter(lazy, "gWindowsAlertsService", () => { + // We might not have the Windows alerts service: e.g., on Windows 7 and Windows 8. + if (!("nsIWindowsAlertsService" in Ci)) { + return null; + } + return Cc["@mozilla.org/system-alerts-service;1"] + ?.getService(Ci.nsIAlertsService) + ?.QueryInterface(Ci.nsIWindowsAlertsService); +}); + +// One-time startup homepage override configurations +const ONCE_DOMAINS = ["mozilla.org", "firefox.com"]; +const ONCE_PREF = "browser.startup.homepage_override.once"; + +// Index of Private Browsing icon in firefox.exe +// Must line up with the one in nsNativeAppSupportWin.h. +const PRIVATE_BROWSING_ICON_INDEX = 5; + +function shouldLoadURI(aURI) { + if (aURI && !aURI.schemeIs("chrome")) { + return true; + } + + dump("*** Preventing external load of chrome: URI into browser window\n"); + dump(" Use --chrome instead\n"); + 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 +) { + 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 and Firefox-private protocols." + ); + } + aArgument = aArgument.substring(protocolWithColon.length); + + if ( + !aArgument.startsWith("http://") && + !aArgument.startsWith("https://") + ) { + throw new Error( + "Firefox and Firefox-private 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"); + handleFirefoxProtocol("firefox-private"); + + var uri = aCmdLine.resolveURI(aArgument); + var uriFixup = Services.uriFixup; + + if (!(uri instanceof Ci.nsIFileURL)) { + let prefURI = Services.uriFixup.getFixupURIInfo( + aArgument, + uriFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS + ).preferredURI; + return { uri: prefURI, principal }; + } + + try { + if (uri.file.exists()) { + return { uri, principal }; + } + } catch (e) { + console.error(e); + } + + // We have interpreted the argument as a relative file URI, but the file + // doesn't exist. Try URI fixup heuristics: see bug 290782. + + try { + uri = Services.uriFixup.getFixupURIInfo(aArgument).preferredURI; + } catch (e) { + console.error(e); + } + + return { uri, principal }; +} + +let gKiosk = false; +let gMajorUpgrade = false; +let gFirstRunProfile = false; +var gFirstWindow = false; + +const OVERRIDE_NONE = 0; +const OVERRIDE_NEW_PROFILE = 1; +const OVERRIDE_NEW_MSTONE = 2; +const OVERRIDE_NEW_BUILD_ID = 3; +/** + * Determines whether a home page override is needed. + * @param {boolean} [updateMilestones=true] + * True if we should update the milestone prefs after comparing those prefs + * with the current platform version and build ID. + * + * If updateMilestones is false, then this function has no side-effects. + * + * @returns {number} + * One of the following constants: + * OVERRIDE_NEW_PROFILE + * if this is the first run with a new profile. + * OVERRIDE_NEW_MSTONE + * if this is the first run with a build with a different Gecko milestone + * (i.e. right after an upgrade). + * OVERRIDE_NEW_BUILD_ID + * if this is the first run with a new build ID of the same Gecko + * milestone (i.e. after a nightly upgrade). + * OVERRIDE_NONE + * otherwise. + */ +function needHomepageOverride(updateMilestones = true) { + var savedmstone = Services.prefs.getCharPref( + "browser.startup.homepage_override.mstone", + "" + ); + + if (savedmstone == "ignore") { + return OVERRIDE_NONE; + } + + var mstone = Services.appinfo.platformVersion; + + var savedBuildID = Services.prefs.getCharPref( + "browser.startup.homepage_override.buildID", + "" + ); + + var buildID = Services.appinfo.platformBuildID; + + if (mstone != savedmstone) { + // Bug 462254. Previous releases had a default pref to suppress the EULA + // agreement if the platform's installer had already shown one. Now with + // about:rights we've removed the EULA stuff and default pref, but we need + // a way to make existing profiles retain the default that we removed. + if (savedmstone) { + Services.prefs.setBoolPref("browser.rights.3.shown", true); + + // Remember that we saw a major version change. + gMajorUpgrade = true; + } + + if (updateMilestones) { + Services.prefs.setCharPref( + "browser.startup.homepage_override.mstone", + mstone + ); + Services.prefs.setCharPref( + "browser.startup.homepage_override.buildID", + buildID + ); + } + return savedmstone ? OVERRIDE_NEW_MSTONE : OVERRIDE_NEW_PROFILE; + } + + if (buildID != savedBuildID) { + if (updateMilestones) { + Services.prefs.setCharPref( + "browser.startup.homepage_override.buildID", + buildID + ); + } + return OVERRIDE_NEW_BUILD_ID; + } + + return OVERRIDE_NONE; +} + +/** + * Gets the override page for the first run after the application has been + * updated. + * @param update + * The nsIUpdate for the update that has been applied. + * @param defaultOverridePage + * The default override page. + * @return The override page. + */ +function getPostUpdateOverridePage(update, defaultOverridePage) { + update = update.QueryInterface(Ci.nsIWritablePropertyBag); + let actions = update.getProperty("actions"); + // When the update doesn't specify actions fallback to the original behavior + // of displaying the default override page. + if (!actions) { + return defaultOverridePage; + } + + // The existence of silent or the non-existence of showURL in the actions both + // mean that an override page should not be displayed. + if (actions.includes("silent") || !actions.includes("showURL")) { + return ""; + } + + // If a policy was set to not allow the update.xml-provided + // URL to be used, use the default fallback (which will also + // be provided by the policy). + if (!Services.policies.isAllowed("postUpdateCustomPage")) { + return defaultOverridePage; + } + + return update.getProperty("openURL") || defaultOverridePage; +} + +/** + * Open a browser window. If this is the initial launch, this function will + * attempt to use the navigator:blank window opened by BrowserGlue.sys.mjs during + * early startup. + * + * @param cmdLine + * The nsICommandLine object given to nsICommandLineHandler's handle + * method. + * Used to check if we are processing the command line for the initial launch. + * @param triggeringPrincipal + * The nsIPrincipal to use as triggering principal for the page load(s). + * @param urlOrUrlList (optional) + * When omitted, the browser window will be opened with the default + * arguments, which will usually load the homepage. + * This can be a JS array of urls provided as strings, each url will be + * loaded in a tab. postData will be ignored in this case. + * This can be a single url to load in the new window, provided as a string. + * postData will be used in this case if provided. + * @param postData (optional) + * An nsIInputStream object to use as POST data when loading the provided + * url, or null. + * @param forcePrivate (optional) + * Boolean. If set to true, the new window will be a private browsing one. + * + * @returns {ChromeWindow} + * Returns the top level window opened. + */ +function openBrowserWindow( + cmdLine, + triggeringPrincipal, + urlOrUrlList, + postData = null, + forcePrivate = false +) { + const isStartup = + cmdLine && cmdLine.state == Ci.nsICommandLine.STATE_INITIAL_LAUNCH; + + let args; + if (!urlOrUrlList) { + // Just pass in the defaultArgs directly. We'll use system principal on the other end. + args = [gBrowserContentHandler.getArgs(isStartup)]; + } else if (Array.isArray(urlOrUrlList)) { + // There isn't an explicit way to pass a principal here, so we load multiple URLs + // with system principal when we get to actually loading them. + if ( + !triggeringPrincipal || + !triggeringPrincipal.equals(lazy.gSystemPrincipal) + ) { + throw new Error( + "Can't open multiple URLs with something other than system principal." + ); + } + // Passing an nsIArray for the url disables the "|"-splitting behavior. + let uriArray = Cc["@mozilla.org/array;1"].createInstance( + Ci.nsIMutableArray + ); + urlOrUrlList.forEach(function (uri) { + var sstring = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + sstring.data = uri; + uriArray.appendElement(sstring); + }); + args = [uriArray]; + } else { + let extraOptions = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag2 + ); + extraOptions.setPropertyAsBool("fromExternal", true); + + // Always pass at least 3 arguments to avoid the "|"-splitting behavior, + // ie. avoid the loadOneOrMoreURIs function. + // Also, we need to pass the triggering principal. + args = [ + urlOrUrlList, + extraOptions, + null, // refererInfo + postData, + undefined, // allowThirdPartyFixup; this would be `false` but that + // needs a conversion. Hopefully bug 1485961 will fix. + undefined, // user context id + null, // origin principal + null, // origin storage principal + triggeringPrincipal, + ]; + } + + if (isStartup) { + let win = Services.wm.getMostRecentWindow("navigator:blank"); + if (win) { + // Remove the windowtype of our blank window so that we don't close it + // later on when seeing cmdLine.preventDefault is true. + win.document.documentElement.removeAttribute("windowtype"); + + if (forcePrivate) { + win.docShell.QueryInterface( + Ci.nsILoadContext + ).usePrivateBrowsing = true; + + if ( + AppConstants.platform == "win" && + lazy.NimbusFeatures.majorRelease2022.getVariable( + "feltPrivacyWindowSeparation" + ) + ) { + lazy.WinTaskbar.setGroupIdForWindow( + win, + lazy.WinTaskbar.defaultPrivateGroupId + ); + lazy.WindowsUIUtils.setWindowIconFromExe( + win, + Services.dirsvc.get("XREExeF", Ci.nsIFile).path, + // This corresponds to the definitions in + // nsNativeAppSupportWin.h + PRIVATE_BROWSING_ICON_INDEX + ); + } + } + + let openTime = win.openTime; + win.location = AppConstants.BROWSER_CHROME_URL; + win.arguments = args; // <-- needs to be a plain JS array here. + + ChromeUtils.addProfilerMarker("earlyBlankWindowVisible", openTime); + lazy.BrowserWindowTracker.registerOpeningWindow(win, forcePrivate); + return win; + } + } + + // We can't provide arguments to openWindow as a JS array. + if (!urlOrUrlList) { + // If we have a single string guaranteed to not contain '|' we can simply + // wrap it in an nsISupportsString object. + let [url] = args; + args = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + args.data = url; + } else { + // Otherwise, pass an nsIArray. + if (args.length > 1) { + let string = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + string.data = args[0]; + args[0] = string; + } + let array = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray); + args.forEach(a => { + array.appendElement(a); + }); + args = array; + } + + return lazy.BrowserWindowTracker.openWindow({ + args, + features: gBrowserContentHandler.getFeatures(cmdLine), + private: forcePrivate, + }); +} + +function openPreferences(cmdLine, extraArgs) { + openBrowserWindow(cmdLine, lazy.gSystemPrincipal, "about:preferences"); +} + +async function doSearch(searchTerm, cmdLine) { + // XXXbsmedberg: use handURIToExistingBrowser to obey tabbed-browsing + // preferences, but need nsIBrowserDOMWindow extensions + // Open the window immediately as BrowserContentHandler needs to + // be handled synchronously. Then load the search URI when the + // SearchService has loaded. + let win = openBrowserWindow(cmdLine, lazy.gSystemPrincipal, "about:blank"); + await new Promise(resolve => { + Services.obs.addObserver(function observe(subject) { + if (subject == win) { + Services.obs.removeObserver( + observe, + "browser-delayed-startup-finished" + ); + resolve(); + } + }, "browser-delayed-startup-finished"); + }); + + win.BrowserSearch.loadSearchFromCommandLine( + searchTerm, + lazy.PrivateBrowsingUtils.isInTemporaryAutoStartMode || + lazy.PrivateBrowsingUtils.isWindowPrivate(win), + lazy.gSystemPrincipal, + win.gBrowser.selectedBrowser.csp + ).catch(console.error); +} + +export function nsBrowserContentHandler() { + if (!gBrowserContentHandler) { + gBrowserContentHandler = this; + } + return gBrowserContentHandler; +} + +nsBrowserContentHandler.prototype = { + /* nsISupports */ + QueryInterface: ChromeUtils.generateQI([ + "nsICommandLineHandler", + "nsIBrowserHandler", + "nsIContentHandler", + "nsICommandLineValidator", + ]), + + /* nsICommandLineHandler */ + handle: function bch_handle(cmdLine) { + if ( + cmdLine.handleFlag("kiosk", false) || + cmdLine.handleFlagWithParam("kiosk-monitor", false) + ) { + gKiosk = true; + } + if (cmdLine.handleFlag("disable-pinch", false)) { + let defaults = Services.prefs.getDefaultBranch(null); + defaults.setBoolPref("apz.allow_zooming", false); + Services.prefs.lockPref("apz.allow_zooming"); + defaults.setCharPref("browser.gesture.pinch.in", ""); + Services.prefs.lockPref("browser.gesture.pinch.in"); + defaults.setCharPref("browser.gesture.pinch.in.shift", ""); + Services.prefs.lockPref("browser.gesture.pinch.in.shift"); + defaults.setCharPref("browser.gesture.pinch.out", ""); + Services.prefs.lockPref("browser.gesture.pinch.out"); + defaults.setCharPref("browser.gesture.pinch.out.shift", ""); + Services.prefs.lockPref("browser.gesture.pinch.out.shift"); + } + if (cmdLine.handleFlag("browser", false)) { + openBrowserWindow(cmdLine, lazy.gSystemPrincipal); + cmdLine.preventDefault = true; + } + + var uriparam; + try { + while ((uriparam = cmdLine.handleFlagWithParam("new-window", false))) { + let { uri, principal } = resolveURIInternal(cmdLine, uriparam); + if (!shouldLoadURI(uri)) { + continue; + } + openBrowserWindow(cmdLine, principal, uri.spec); + cmdLine.preventDefault = true; + } + } catch (e) { + console.error(e); + } + + try { + while ((uriparam = cmdLine.handleFlagWithParam("new-tab", false))) { + let { uri, principal } = resolveURIInternal(cmdLine, uriparam); + handURIToExistingBrowser( + uri, + Ci.nsIBrowserDOMWindow.OPEN_NEWTAB, + cmdLine, + false, + principal + ); + cmdLine.preventDefault = true; + } + } catch (e) { + console.error(e); + } + + var chromeParam = cmdLine.handleFlagWithParam("chrome", false); + if (chromeParam) { + // Handle old preference dialog URLs. + if ( + chromeParam == "chrome://browser/content/pref/pref.xul" || + chromeParam == "chrome://browser/content/preferences/preferences.xul" + ) { + openPreferences(cmdLine); + cmdLine.preventDefault = true; + } else { + try { + let { uri: resolvedURI } = resolveURIInternal(cmdLine, chromeParam); + let isLocal = uri => { + let localSchemes = new Set(["chrome", "file", "resource"]); + if (uri instanceof Ci.nsINestedURI) { + uri = uri.QueryInterface(Ci.nsINestedURI).innerMostURI; + } + return localSchemes.has(uri.scheme); + }; + if (isLocal(resolvedURI)) { + // If the URI is local, we are sure it won't wrongly inherit chrome privs + let features = "chrome,dialog=no,all" + this.getFeatures(cmdLine); + // Provide 1 null argument, as openWindow has a different behavior + // when the arg count is 0. + let argArray = Cc["@mozilla.org/array;1"].createInstance( + Ci.nsIMutableArray + ); + argArray.appendElement(null); + Services.ww.openWindow( + null, + resolvedURI.spec, + "_blank", + features, + argArray + ); + cmdLine.preventDefault = true; + } else { + dump("*** Preventing load of web URI as chrome\n"); + dump( + " If you're trying to load a webpage, do not pass --chrome.\n" + ); + } + } catch (e) { + console.error(e); + } + } + } + if (cmdLine.handleFlag("preferences", false)) { + openPreferences(cmdLine); + cmdLine.preventDefault = true; + } + if (cmdLine.handleFlag("silent", false)) { + cmdLine.preventDefault = true; + } + + try { + var privateWindowParam = cmdLine.handleFlagWithParam( + "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:")) { + // Check if the osint flag is present on Windows + let launchedWithArg_osint = + AppConstants.platform == "win" && + cmdLine.findFlag("osint", false) == 0; + let forcePrivate = true; + let resolvedInfo; + if (!lazy.PrivateBrowsingUtils.enabled) { + // Load about:privatebrowsing in a normal tab, which will display an error indicating + // access to private browsing has been disabled. + forcePrivate = false; + resolvedInfo = { + uri: Services.io.newURI("about:privatebrowsing"), + principal: lazy.gSystemPrincipal, + }; + } else if (url?.startsWith("firefox-private:")) { + cmdLine.removeArguments(urlFlagIdx, urlFlagIdx + 1); + resolvedInfo = resolveURIInternal( + cmdLine, + url, + launchedWithArg_osint + ); + } else { + resolvedInfo = resolveURIInternal( + cmdLine, + privateWindowParam, + launchedWithArg_osint + ); + } + handURIToExistingBrowser( + resolvedInfo.uri, + Ci.nsIBrowserDOMWindow.OPEN_NEWTAB, + cmdLine, + forcePrivate, + resolvedInfo.principal + ); + cmdLine.preventDefault = true; + } + } catch (e) { + if (e.result != Cr.NS_ERROR_INVALID_ARG) { + throw e; + } + // NS_ERROR_INVALID_ARG is thrown when flag exists, but has no param. + if (cmdLine.handleFlag("private-window", false)) { + openBrowserWindow( + cmdLine, + lazy.gSystemPrincipal, + "about:privatebrowsing", + null, + lazy.PrivateBrowsingUtils.enabled + ); + cmdLine.preventDefault = true; + } + } + + var searchParam = cmdLine.handleFlagWithParam("search", false); + if (searchParam) { + doSearch(searchParam, cmdLine); + cmdLine.preventDefault = true; + } + + // The global PB Service consumes this flag, so only eat it in per-window + // PB builds. + if ( + cmdLine.handleFlag("private", false) && + lazy.PrivateBrowsingUtils.enabled + ) { + lazy.PrivateBrowsingUtils.enterTemporaryAutoStartMode(); + if (cmdLine.state == Ci.nsICommandLine.STATE_INITIAL_LAUNCH) { + let win = Services.wm.getMostRecentWindow("navigator:blank"); + if (win) { + win.docShell.QueryInterface( + Ci.nsILoadContext + ).usePrivateBrowsing = true; + } + } + } + if (cmdLine.handleFlag("setDefaultBrowser", false)) { + // Note that setDefaultBrowser is an async function, but "handle" (the method being executed) + // is an implementation of an interface method and changing it to be async would be complicated + // and ultimately nothing here needs the result of setDefaultBrowser, so we do not bother doing + // an await. + lazy.ShellService.setDefaultBrowser(true).catch(e => { + console.error("setDefaultBrowser failed:", e); + }); + } + + if (cmdLine.handleFlag("first-startup", false)) { + // We don't want subsequent calls to needHompageOverride to have different + // values because the milestones in prefs got updated, so we intentionally + // tell needHomepageOverride to leave the milestone prefs alone when doing + // this check. + let override = needHomepageOverride(false /* updateMilestones */); + if (override == OVERRIDE_NEW_PROFILE) { + lazy.FirstStartup.init(); + } + } + + var fileParam = cmdLine.handleFlagWithParam("file", false); + if (fileParam) { + var file = cmdLine.resolveFile(fileParam); + var fileURI = Services.io.newFileURI(file); + openBrowserWindow(cmdLine, lazy.gSystemPrincipal, fileURI.spec); + cmdLine.preventDefault = true; + } + + if (AppConstants.platform == "win") { + // Handle "? searchterm" for Windows Vista start menu integration + for (var i = cmdLine.length - 1; i >= 0; --i) { + var param = cmdLine.getArgument(i); + if (param.match(/^\? /)) { + cmdLine.removeArguments(i, i); + cmdLine.preventDefault = true; + + searchParam = param.substr(2); + doSearch(searchParam, cmdLine); + } + } + } + }, + + get helpInfo() { + let info = + " --browser Open a browser window.\n" + + " --new-window Open in a new window.\n" + + " --new-tab Open in a new tab.\n" + + " --private-window Open in a new private window.\n"; + if (AppConstants.platform == "win") { + info += " --preferences Open Options dialog.\n"; + } else { + info += " --preferences Open Preferences dialog.\n"; + } + info += + " --screenshot [] Save screenshot to or in working directory.\n"; + info += + " --window-size width[,height] Width and optionally height of screenshot.\n"; + info += + " --search Search with your default search engine.\n"; + info += " --setDefaultBrowser Set this app as the default browser.\n"; + info += + " --first-startup Run post-install actions before opening a new window.\n"; + info += " --kiosk Start the browser in kiosk mode.\n"; + info += + " --kiosk-monitor Place kiosk browser window on given monitor.\n"; + info += + " --disable-pinch Disable touch-screen and touch-pad pinch gestures.\n"; + return info; + }, + + /* nsIBrowserHandler */ + + get defaultArgs() { + return this.getArgs(); + }, + + getArgs(isStartup = false) { + var prefb = Services.prefs; + + if (!gFirstWindow) { + gFirstWindow = true; + if (lazy.PrivateBrowsingUtils.isInTemporaryAutoStartMode) { + return "about:privatebrowsing"; + } + } + + var override; + var overridePage = ""; + var additionalPage = ""; + var willRestoreSession = false; + try { + // Read the old value of homepage_override.mstone before + // needHomepageOverride updates it, so that we can later add it to the + // URL if we do end up showing an overridePage. This makes it possible + // to have the overridePage's content vary depending on the version we're + // upgrading from. + let old_mstone = Services.prefs.getCharPref( + "browser.startup.homepage_override.mstone", + "unknown" + ); + let old_buildId = Services.prefs.getCharPref( + "browser.startup.homepage_override.buildID", + "unknown" + ); + override = needHomepageOverride(); + if (override != OVERRIDE_NONE) { + switch (override) { + case OVERRIDE_NEW_PROFILE: + // New profile. + gFirstRunProfile = true; + if (lazy.NimbusFeatures.aboutwelcome.getVariable("showModal")) { + break; + } + overridePage = Services.urlFormatter.formatURLPref( + "startup.homepage_welcome_url" + ); + additionalPage = Services.urlFormatter.formatURLPref( + "startup.homepage_welcome_url.additional" + ); + // Turn on 'later run' pages for new profiles. + lazy.LaterRun.enable(lazy.LaterRun.ENABLE_REASON_NEW_PROFILE); + break; + case OVERRIDE_NEW_MSTONE: + // Check whether we will restore a session. If we will, we assume + // that this is an "update" session. This does not take crashes + // into account because that requires waiting for the session file + // to be read. If a crash occurs after updating, before restarting, + // we may open the startPage in addition to restoring the session. + willRestoreSession = + lazy.SessionStartup.isAutomaticRestoreEnabled(); + + overridePage = Services.urlFormatter.formatURLPref( + "startup.homepage_override_url" + ); + let update = lazy.UpdateManager.readyUpdate; + if ( + update && + Services.vc.compare(update.appVersion, old_mstone) > 0 + ) { + overridePage = getPostUpdateOverridePage(update, overridePage); + // Send the update ping to signal that the update was successful. + lazy.UpdatePing.handleUpdateSuccess(old_mstone, old_buildId); + lazy.LaterRun.enable(lazy.LaterRun.ENABLE_REASON_UPDATE_APPLIED); + } + + overridePage = overridePage.replace("%OLD_VERSION%", old_mstone); + break; + case OVERRIDE_NEW_BUILD_ID: + if (lazy.UpdateManager.readyUpdate) { + // Send the update ping to signal that the update was successful. + lazy.UpdatePing.handleUpdateSuccess(old_mstone, old_buildId); + lazy.LaterRun.enable(lazy.LaterRun.ENABLE_REASON_UPDATE_APPLIED); + } + break; + } + } + } catch (ex) {} + + // formatURLPref might return "about:blank" if getting the pref fails + if (overridePage == "about:blank") { + overridePage = ""; + } + + // Allow showing a one-time startup override if we're not showing one + if (isStartup && overridePage == "" && prefb.prefHasUserValue(ONCE_PREF)) { + try { + // Show if we haven't passed the expiration or there's no expiration + const { expire, url } = JSON.parse( + Services.urlFormatter.formatURLPref(ONCE_PREF) + ); + if (!(Date.now() > expire)) { + // Only set allowed urls as override pages + overridePage = url + .split("|") + .map(val => { + try { + return new URL(val); + } catch (ex) { + // Invalid URL, so filter out below + console.error("Invalid once url:", ex); + return null; + } + }) + .filter( + parsed => + parsed && + parsed.protocol == "https:" && + // Only accept exact hostname or subdomain; without port + ONCE_DOMAINS.includes( + Services.eTLD.getBaseDomainFromHost(parsed.host) + ) + ) + .join("|"); + + // Be noisy as properly configured urls should be unchanged + if (overridePage != url) { + console.error(`Mismatched once urls: ${url}`); + } + } + } catch (ex) { + // Invalid json pref, so ignore (and clear below) + console.error("Invalid once pref:", ex); + } finally { + prefb.clearUserPref(ONCE_PREF); + } + } + + if (!additionalPage) { + additionalPage = lazy.LaterRun.getURL() || ""; + } + + if (additionalPage && additionalPage != "about:blank") { + if (overridePage) { + overridePage += "|" + additionalPage; + } else { + overridePage = additionalPage; + } + } + + var startPage = ""; + try { + var choice = prefb.getIntPref("browser.startup.page"); + if (choice == 1 || choice == 3) { + startPage = lazy.HomePage.get(); + } + } catch (e) { + console.error(e); + } + + if (startPage == "about:blank") { + startPage = ""; + } + + let skipStartPage = + override == OVERRIDE_NEW_PROFILE && + prefb.getBoolPref("browser.startup.firstrunSkipsHomepage"); + // Only show the startPage if we're not restoring an update session and are + // not set to skip the start page on this profile + if (overridePage && startPage && !willRestoreSession && !skipStartPage) { + return overridePage + "|" + startPage; + } + + return overridePage || startPage || "about:blank"; + }, + + mFeatures: null, + + getFeatures: function bch_features(cmdLine) { + if (this.mFeatures === null) { + this.mFeatures = ""; + + if (cmdLine) { + try { + var width = cmdLine.handleFlagWithParam("width", false); + var height = cmdLine.handleFlagWithParam("height", false); + var left = cmdLine.handleFlagWithParam("left", false); + var top = cmdLine.handleFlagWithParam("top", false); + + if (width) { + this.mFeatures += ",width=" + width; + } + if (height) { + this.mFeatures += ",height=" + height; + } + if (left) { + this.mFeatures += ",left=" + left; + } + if (top) { + this.mFeatures += ",top=" + top; + } + } catch (e) {} + } + + // The global PB Service consumes this flag, so only eat it in per-window + // PB builds. + if (lazy.PrivateBrowsingUtils.isInTemporaryAutoStartMode) { + this.mFeatures += ",private"; + } + + if ( + Services.prefs.getBoolPref("browser.suppress_first_window_animation") && + !Services.wm.getMostRecentWindow("navigator:browser") + ) { + this.mFeatures += ",suppressanimation"; + } + } + + return this.mFeatures; + }, + + get kiosk() { + return gKiosk; + }, + + get majorUpgrade() { + return gMajorUpgrade; + }, + + set majorUpgrade(val) { + gMajorUpgrade = val; + }, + + get firstRunProfile() { + return gFirstRunProfile; + }, + + set firstRunProfile(val) { + gFirstRunProfile = val; + }, + + /* nsIContentHandler */ + + handleContent: function bch_handleContent(contentType, context, request) { + const NS_ERROR_WONT_HANDLE_CONTENT = 0x805d0001; + + try { + var webNavInfo = Cc["@mozilla.org/webnavigation-info;1"].getService( + Ci.nsIWebNavigationInfo + ); + if (!webNavInfo.isTypeSupported(contentType)) { + throw NS_ERROR_WONT_HANDLE_CONTENT; + } + } catch (e) { + throw NS_ERROR_WONT_HANDLE_CONTENT; + } + + request.QueryInterface(Ci.nsIChannel); + handURIToExistingBrowser( + request.URI, + Ci.nsIBrowserDOMWindow.OPEN_DEFAULTWINDOW, + null, + false, + request.loadInfo.triggeringPrincipal + ); + request.cancel(Cr.NS_BINDING_ABORTED); + }, + + /* nsICommandLineValidator */ + validate: function bch_validate(cmdLine) { + var urlFlagIdx = cmdLine.findFlag("url", false); + if ( + urlFlagIdx > -1 && + cmdLine.state == Ci.nsICommandLine.STATE_REMOTE_EXPLICIT + ) { + var urlParam = cmdLine.getArgument(urlFlagIdx + 1); + if ( + cmdLine.length != urlFlagIdx + 2 || + /firefoxurl(-[a-f0-9]+)?:/i.test(urlParam) + ) { + throw Components.Exception("", Cr.NS_ERROR_ABORT); + } + } + }, +}; +var gBrowserContentHandler = new nsBrowserContentHandler(); + +function handURIToExistingBrowser( + uri, + location, + cmdLine, + forcePrivate, + triggeringPrincipal +) { + if (!shouldLoadURI(uri)) { + return; + } + + let openInWindow = ({ browserDOMWindow }) => { + browserDOMWindow.openURI( + uri, + null, + location, + Ci.nsIBrowserDOMWindow.OPEN_EXTERNAL, + triggeringPrincipal + ); + }; + + // Unless using a private window is forced, open external links in private + // windows only if we're in perma-private mode. + let allowPrivate = + forcePrivate || lazy.PrivateBrowsingUtils.permanentPrivateBrowsing; + let navWin = lazy.BrowserWindowTracker.getTopWindow({ + private: allowPrivate, + }); + + if (navWin) { + openInWindow(navWin); + return; + } + + let pending = lazy.BrowserWindowTracker.getPendingWindow({ + private: allowPrivate, + }); + if (pending) { + // Note that we cannot make this function async as some callers rely on + // catching exceptions it can throw in some cases and some of those callers + // cannot be made async. + pending.then(openInWindow); + return; + } + + // if we couldn't load it in an existing window, open a new one + openBrowserWindow(cmdLine, triggeringPrincipal, uri.spec, null, forcePrivate); +} + +/** + * If given URI is a file type or a protocol, record telemetry that + * Firefox was invoked or launched (if `isLaunch` is truth-y). If the + * file type or protocol is not registered by default, record it as + * "." or "". + * + * @param uri + * The URI Firefox was asked to handle. + * @param isLaunch + * truth-y if Firefox was launched/started rather than running and invoked. + */ +function maybeRecordToHandleTelemetry(uri, isLaunch) { + let scalar = isLaunch + ? "os.environment.launched_to_handle" + : "os.environment.invoked_to_handle"; + + if (uri instanceof Ci.nsIFileURL) { + let extension = "." + uri.fileExtension.toLowerCase(); + // Keep synchronized with https://searchfox.org/mozilla-central/source/browser/installer/windows/nsis/shared.nsh + // and https://searchfox.org/mozilla-central/source/browser/installer/windows/msix/AppxManifest.xml.in. + let registeredExtensions = new Set([ + ".avif", + ".htm", + ".html", + ".pdf", + ".shtml", + ".xht", + ".xhtml", + ".svg", + ".webp", + ]); + if (registeredExtensions.has(extension)) { + Services.telemetry.keyedScalarAdd(scalar, extension, 1); + } else { + Services.telemetry.keyedScalarAdd(scalar, ".", 1); + } + } else if (uri) { + let scheme = uri.scheme.toLowerCase(); + let registeredSchemes = new Set(["about", "http", "https", "mailto"]); + if (registeredSchemes.has(scheme)) { + Services.telemetry.keyedScalarAdd(scalar, scheme, 1); + } else { + Services.telemetry.keyedScalarAdd(scalar, "", 1); + } + } +} + +export function nsDefaultCommandLineHandler() {} + +nsDefaultCommandLineHandler.prototype = { + /* nsISupports */ + QueryInterface: ChromeUtils.generateQI(["nsICommandLineHandler"]), + + _haveProfile: false, + + /* nsICommandLineHandler */ + handle: function dch_handle(cmdLine) { + var urilist = []; + var principalList = []; + + if (AppConstants.platform == "win") { + // Windows itself does disk I/O when the notification service is + // initialized, so make sure that is lazy. + while (true) { + let tag = cmdLine.handleFlagWithParam("notification-windowsTag", false); + if (!tag) { + break; + } + + // All notifications will invoke Firefox with an action. Prior to Bug 1805514, + // this data was extracted from the Windows toast object directly (keyed by the + // notification ID) and not passed over the command line. This is acceptable + // because the data passed is chrome-controlled, but if we implement the `actions` + // part of the DOM Web Notifications API, this will no longer be true: + // content-controlled data might transit over the command line. This could lead + // to escaping bugs and overflows. In the future, we intend to avoid any such + // issue by once again extracting all such data from the Windows toast object. + let notificationData = cmdLine.handleFlagWithParam( + "notification-windowsAction", + false + ); + if (!notificationData) { + break; + } + + let alertService = lazy.gWindowsAlertsService; + if (!alertService) { + console.error("Windows alert service not available."); + break; + } + + async function handleNotification() { + let { tagWasHandled } = await alertService.handleWindowsTag(tag); + + // If the tag was not handled via callback, then the notification was + // from a prior instance of the application and we need to handle + // fallback behavior. + if (!tagWasHandled) { + console.info( + `Completing Windows notification (tag=${JSON.stringify( + tag + )}, notificationData=${notificationData})` + ); + try { + notificationData = JSON.parse(notificationData); + } catch (e) { + console.error( + `Completing Windows notification (tag=${JSON.stringify( + tag + )}, failed to parse (notificationData=${notificationData})` + ); + } + } + + // This is awkward: the relaunch data set by the caller is _wrapped_ + // into a compound object that includes additional notification data, + // and everything is exchanged as strings. Unwrap and parse here. + let opaqueRelaunchData = null; + if (notificationData?.opaqueRelaunchData) { + try { + opaqueRelaunchData = JSON.parse( + notificationData.opaqueRelaunchData + ); + } catch (e) { + console.error( + `Completing Windows notification (tag=${JSON.stringify( + tag + )}, failed to parse (opaqueRelaunchData=${ + notificationData.opaqueRelaunchData + })` + ); + } + } + + if (notificationData?.privilegedName) { + Services.telemetry.setEventRecordingEnabled( + "browser.launched_to_handle", + true + ); + Glean.browserLaunchedToHandle.systemNotification.record({ + name: notificationData.privilegedName, + }); + } + + // If we have an action in the notification data, this will be the + // window to perform the action in. + let winForAction; + + if (notificationData?.launchUrl && !opaqueRelaunchData) { + // Unprivileged Web Notifications contain a launch URL and are handled + // slightly differently than privileged notifications with actions. + let { uri, principal } = resolveURIInternal( + cmdLine, + notificationData.launchUrl + ); + if (cmdLine.state != Ci.nsICommandLine.STATE_INITIAL_LAUNCH) { + // Try to find an existing window and load our URI into the current + // tab, new tab, or new window as prefs determine. + try { + handURIToExistingBrowser( + uri, + Ci.nsIBrowserDOMWindow.OPEN_DEFAULTWINDOW, + cmdLine, + false, + principal + ); + return; + } catch (e) {} + } + + if (shouldLoadURI(uri)) { + openBrowserWindow(cmdLine, principal, [uri.spec]); + } + } else if (cmdLine.state == Ci.nsICommandLine.STATE_INITIAL_LAUNCH) { + // No URL provided, but notification was interacted with while the + // application was closed. Fall back to opening the browser without url. + winForAction = openBrowserWindow(cmdLine, lazy.gSystemPrincipal); + await new Promise(resolve => { + Services.obs.addObserver(function observe(subject) { + if (subject == winForAction) { + Services.obs.removeObserver( + observe, + "browser-delayed-startup-finished" + ); + resolve(); + } + }, "browser-delayed-startup-finished"); + }); + } else { + // Relaunch in private windows only if we're in perma-private mode. + let allowPrivate = + lazy.PrivateBrowsingUtils.permanentPrivateBrowsing; + winForAction = lazy.BrowserWindowTracker.getTopWindow({ + private: allowPrivate, + }); + } + + if (opaqueRelaunchData && winForAction) { + // Without dispatch, `OPEN_URL` with `where: "tab"` does not work on relaunch. + Services.tm.dispatchToMainThread(() => { + lazy.SpecialMessageActions.handleAction( + opaqueRelaunchData, + winForAction.gBrowser + ); + }); + } + } + + // Notification handling occurs asynchronously to prevent blocking the + // main thread. As a result we won't have the information we need to open + // a new tab in the case of notification fallback handling before + // returning. We call `enterLastWindowClosingSurvivalArea` to prevent + // the browser from exiting in case early blank window is pref'd off. + if (cmdLine.state == Ci.nsICommandLine.STATE_INITIAL_LAUNCH) { + Services.startup.enterLastWindowClosingSurvivalArea(); + } + handleNotification() + .catch(e => { + console.error( + `Error handling Windows notification with tag '${tag}':`, + e + ); + }) + .finally(() => { + if (cmdLine.state == Ci.nsICommandLine.STATE_INITIAL_LAUNCH) { + Services.startup.exitLastWindowClosingSurvivalArea(); + } + }); + + return; + } + } + + if ( + cmdLine.state == Ci.nsICommandLine.STATE_INITIAL_LAUNCH && + Services.startup.wasSilentlyStarted + ) { + // If we are starting up in silent mode, don't open a window. We also need + // to make sure that the application doesn't immediately exit, so stay in + // a LastWindowClosingSurvivalArea until a window opens. + Services.startup.enterLastWindowClosingSurvivalArea(); + Services.obs.addObserver(function windowOpenObserver() { + Services.startup.exitLastWindowClosingSurvivalArea(); + Services.obs.removeObserver(windowOpenObserver, "domwindowopened"); + }, "domwindowopened"); + return; + } + + if (AppConstants.platform == "win" || AppConstants.platform == "macosx") { + // Handle the case where we don't have a profile selected yet (e.g. the + // Profile Manager is displayed). + // On Windows, we will crash if we open an url and then select a profile. + // On macOS, if we open an url we don't experience a crash but a broken + // window is opened. + // To prevent this handle all url command line flags and set the + // command line's preventDefault to true to prevent the display of the ui. + // The initial command line will be retained when nsAppRunner calls + // LaunchChild though urls launched after the initial launch will be lost. + if (!this._haveProfile) { + try { + // This will throw when a profile has not been selected. + Services.dirsvc.get("ProfD", Ci.nsIFile); + this._haveProfile = true; + } catch (e) { + // eslint-disable-next-line no-empty + while ((ar = cmdLine.handleFlagWithParam("url", false))) {} + cmdLine.preventDefault = true; + } + } + } + + // `-osint` and handling registered file types and protocols is Windows-only. + let launchedWithArg_osint = + AppConstants.platform == "win" && cmdLine.findFlag("osint", false) == 0; + if (launchedWithArg_osint) { + cmdLine.handleFlag("osint", false); + } + + try { + var ar; + while ((ar = cmdLine.handleFlagWithParam("url", false))) { + let { uri, principal } = resolveURIInternal( + cmdLine, + ar, + launchedWithArg_osint + ); + urilist.push(uri); + principalList.push(principal); + + if (launchedWithArg_osint) { + launchedWithArg_osint = false; + + // We use the resolved URI here, even though it can produce + // surprising results where-by `-osint -url test.pdf` resolves to + // a query with search parameter "test.pdf". But that shouldn't + // happen when Firefox is launched by Windows itself: files should + // exist and be resolved to file URLs. + const isLaunch = + cmdLine && cmdLine.state == Ci.nsICommandLine.STATE_INITIAL_LAUNCH; + + maybeRecordToHandleTelemetry(uri, isLaunch); + } + } + } catch (e) { + console.error(e); + } + + if ( + AppConstants.platform == "win" && + cmdLine.handleFlag("to-handle-default-browser-agent", false) + ) { + // The Default Browser Agent launches Firefox in response to a Windows + // native notification, but it does so in a non-standard manner. + Services.telemetry.setEventRecordingEnabled( + "browser.launched_to_handle", + true + ); + Glean.browserLaunchedToHandle.systemNotification.record({ + name: "default-browser-agent", + }); + + let thanksURI = Services.io.newURI( + Services.urlFormatter.formatURLPref( + "browser.shell.defaultBrowserAgent.thanksURL" + ) + ); + urilist.push(thanksURI); + principalList.push(lazy.gSystemPrincipal); + } + + if (cmdLine.findFlag("screenshot", true) != -1) { + // Shouldn't have to push principal here with the screenshot flag + lazy.HeadlessShell.handleCmdLineArgs( + cmdLine, + urilist.filter(shouldLoadURI).map(u => u.spec) + ); + return; + } + + for (let i = 0; i < cmdLine.length; ++i) { + var curarg = cmdLine.getArgument(i); + if (curarg.match(/^-/)) { + console.error("Warning: unrecognized command line flag", curarg); + // To emulate the pre-nsICommandLine behavior, we ignore + // the argument after an unrecognized flag. + ++i; + } else { + try { + let { uri, principal } = resolveURIInternal(cmdLine, curarg); + urilist.push(uri); + principalList.push(principal); + } catch (e) { + console.error( + `Error opening URI ${curarg} from the command line:`, + e + ); + } + } + } + + if (urilist.length) { + if ( + cmdLine.state != Ci.nsICommandLine.STATE_INITIAL_LAUNCH && + urilist.length == 1 + ) { + // Try to find an existing window and load our URI into the + // current tab, new tab, or new window as prefs determine. + try { + handURIToExistingBrowser( + urilist[0], + Ci.nsIBrowserDOMWindow.OPEN_DEFAULTWINDOW, + cmdLine, + false, + principalList[0] ?? lazy.gSystemPrincipal + ); + return; + } catch (e) {} + } + + // Can't open multiple URLs without using system principal. + // The firefox and firefox-private 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); + } + } else if (!cmdLine.preventDefault) { + if ( + AppConstants.platform == "win" && + cmdLine.state != Ci.nsICommandLine.STATE_INITIAL_LAUNCH && + lazy.WindowsUIUtils.inTabletMode + ) { + // In windows 10 tablet mode, do not create a new window, but reuse the existing one. + let win = lazy.BrowserWindowTracker.getTopWindow(); + if (win) { + win.focus(); + return; + } + } + openBrowserWindow(cmdLine, lazy.gSystemPrincipal); + } else { + // Need a better solution in the future to avoid opening the blank window + // when command line parameters say we are not going to show a browser + // window, but for now the blank window getting closed quickly (and + // causing only a slight flicker) is better than leaving it open. + let win = Services.wm.getMostRecentWindow("navigator:blank"); + if (win) { + win.close(); + } + } + }, + + helpInfo: "", +}; diff --git a/browser/components/BrowserGlue.sys.mjs b/browser/components/BrowserGlue.sys.mjs new file mode 100644 index 0000000000..4ad6c5212b --- /dev/null +++ b/browser/components/BrowserGlue.sys.mjs @@ -0,0 +1,6595 @@ +/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", + ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs", + ASRouterDefaultConfig: + "resource:///modules/asrouter/ASRouterDefaultConfig.sys.mjs", + ASRouterNewTabHook: "resource:///modules/asrouter/ASRouterNewTabHook.sys.mjs", + ActorManagerParent: "resource://gre/modules/ActorManagerParent.sys.mjs", + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs", + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + Blocklist: "resource://gre/modules/Blocklist.sys.mjs", + BookmarkHTMLUtils: "resource://gre/modules/BookmarkHTMLUtils.sys.mjs", + BookmarkJSONUtils: "resource://gre/modules/BookmarkJSONUtils.sys.mjs", + BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs", + BrowserUIUtils: "resource:///modules/BrowserUIUtils.sys.mjs", + BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.sys.mjs", + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", + BuiltInThemes: "resource:///modules/BuiltInThemes.sys.mjs", + ContextualIdentityService: + "resource://gre/modules/ContextualIdentityService.sys.mjs", + Corroborate: "resource://gre/modules/Corroborate.sys.mjs", + DAPTelemetrySender: "resource://gre/modules/DAPTelemetrySender.sys.mjs", + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + Discovery: "resource:///modules/Discovery.sys.mjs", + DoHController: "resource:///modules/DoHController.sys.mjs", + DownloadsViewableInternally: + "resource:///modules/DownloadsViewableInternally.sys.mjs", + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", + ExtensionsUI: "resource:///modules/ExtensionsUI.sys.mjs", + FeatureGate: "resource://featuregates/FeatureGate.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", + MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + Normandy: "resource://normandy/Normandy.sys.mjs", + OnboardingMessageProvider: + "resource:///modules/asrouter/OnboardingMessageProvider.sys.mjs", + OsEnvironment: "resource://gre/modules/OsEnvironment.sys.mjs", + PageActions: "resource:///modules/PageActions.sys.mjs", + PageDataService: "resource:///modules/pagedata/PageDataService.sys.mjs", + PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs", + PdfJs: "resource://pdf.js/PdfJs.sys.mjs", + PermissionUI: "resource:///modules/PermissionUI.sys.mjs", + PlacesBackups: "resource://gre/modules/PlacesBackups.sys.mjs", + PlacesDBUtils: "resource://gre/modules/PlacesDBUtils.sys.mjs", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + // PluginManager is used by listeners object below. + // eslint-disable-next-line mozilla/valid-lazy + PluginManager: "resource:///actors/PluginParent.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + ProcessHangMonitor: "resource:///modules/ProcessHangMonitor.sys.mjs", + PublicSuffixList: + "resource://gre/modules/netwerk-dns/PublicSuffixList.sys.mjs", + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + RFPHelper: "resource://gre/modules/RFPHelper.sys.mjs", + RemoteSecuritySettings: + "resource://gre/modules/psm/RemoteSecuritySettings.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + ResetPBMPanel: "resource:///modules/ResetPBMPanel.sys.mjs", + SafeBrowsing: "resource://gre/modules/SafeBrowsing.sys.mjs", + Sanitizer: "resource:///modules/Sanitizer.sys.mjs", + SaveToPocket: "chrome://pocket/content/SaveToPocket.sys.mjs", + ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs", + SearchSERPDomainToCategoriesMap: + "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs", + SessionStartup: "resource:///modules/sessionstore/SessionStartup.sys.mjs", + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + ShellService: "resource:///modules/ShellService.sys.mjs", + ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", + ShoppingUtils: "resource:///modules/ShoppingUtils.sys.mjs", + SpecialMessageActions: + "resource://messaging-system/lib/SpecialMessageActions.sys.mjs", + TRRRacer: "resource:///modules/TRRPerformance.sys.mjs", + TabCrashHandler: "resource:///modules/ContentCrashHandlers.sys.mjs", + TabUnloader: "resource:///modules/TabUnloader.sys.mjs", + TelemetryUtils: "resource://gre/modules/TelemetryUtils.sys.mjs", + UIState: "resource://services-sync/UIState.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + WebChannel: "resource://gre/modules/WebChannel.sys.mjs", + WindowsLaunchOnLogin: "resource://gre/modules/WindowsLaunchOnLogin.sys.mjs", + WindowsRegistry: "resource://gre/modules/WindowsRegistry.sys.mjs", + WindowsGPOParser: "resource://gre/modules/policies/WindowsGPOParser.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +if (AppConstants.MOZ_UPDATER) { + ChromeUtils.defineESModuleGetters(lazy, { + UpdateListener: "resource://gre/modules/UpdateListener.sys.mjs", + }); +} +if (AppConstants.MOZ_UPDATE_AGENT) { + ChromeUtils.defineESModuleGetters(lazy, { + BackgroundUpdate: "resource://gre/modules/BackgroundUpdate.sys.mjs", + }); +} + +// PluginManager is used in the listeners object below. +XPCOMUtils.defineLazyServiceGetters(lazy, { + BrowserHandler: ["@mozilla.org/browser/clh;1", "nsIBrowserHandler"], + PushService: ["@mozilla.org/push/Service;1", "nsIPushService"], +}); + +ChromeUtils.defineLazyGetter( + lazy, + "accountsL10n", + () => + new Localization( + ["browser/accounts.ftl", "toolkit/branding/accounts.ftl"], + true + ) +); + +if (AppConstants.ENABLE_WEBDRIVER) { + XPCOMUtils.defineLazyServiceGetter( + lazy, + "Marionette", + "@mozilla.org/remote/marionette;1", + "nsIMarionette" + ); + + XPCOMUtils.defineLazyServiceGetter( + lazy, + "RemoteAgent", + "@mozilla.org/remote/agent;1", + "nsIRemoteAgent" + ); +} else { + lazy.Marionette = { running: false }; + lazy.RemoteAgent = { running: false }; +} + +const PREF_PDFJS_ISDEFAULT_CACHE_STATE = "pdfjs.enabledCache.state"; + +const PRIVATE_BROWSING_BINARY = "private_browsing.exe"; +// Index of Private Browsing icon in private_browsing.exe +// Must line up with IDI_PBICON_PB_PB_EXE in nsNativeAppSupportWin.h. +const PRIVATE_BROWSING_EXE_ICON_INDEX = 1; +const PREF_PRIVATE_BROWSING_SHORTCUT_CREATED = + "browser.privacySegmentation.createdShortcut"; +// Whether this launch was initiated by the OS. A launch-on-login will contain +// the "os-autostart" flag in the initial launch command line. +let gThisInstanceIsLaunchOnLogin = false; +// Whether this launch was initiated by a taskbar tab shortcut. A launch from +// a taskbar tab shortcut will contain the "taskbar-tab" flag. +let gThisInstanceIsTaskbarTab = false; + +/** + * Fission-compatible JSProcess implementations. + * Each actor options object takes the form of a ProcessActorOptions dictionary. + * Detailed documentation of these options is in dom/docs/ipc/jsactors.rst, + * available at https://firefox-source-docs.mozilla.org/dom/ipc/jsactors.html + */ +let JSPROCESSACTORS = { + // Miscellaneous stuff that needs to be initialized per process. + BrowserProcess: { + child: { + esModuleURI: "resource:///actors/BrowserProcessChild.sys.mjs", + observers: [ + // WebRTC related notifications. They are here to avoid loading WebRTC + // components when not needed. + "getUserMedia:request", + "recording-device-stopped", + "PeerConnection:request", + "recording-device-events", + "recording-window-ended", + ], + }, + }, + + RefreshBlockerObserver: { + child: { + esModuleURI: "resource:///actors/RefreshBlockerChild.sys.mjs", + observers: [ + "webnavigation-create", + "chrome-webnavigation-create", + "webnavigation-destroy", + "chrome-webnavigation-destroy", + ], + }, + + enablePreference: "accessibility.blockautorefresh", + onPreferenceChanged: (prefName, prevValue, isEnabled) => { + lazy.BrowserWindowTracker.orderedWindows.forEach(win => { + for (let browser of win.gBrowser.browsers) { + try { + browser.sendMessageToActor( + "PreferenceChanged", + { isEnabled }, + "RefreshBlocker", + "all" + ); + } catch (ex) {} + } + }); + }, + }, +}; + +/** + * Fission-compatible JSWindowActor implementations. + * Detailed documentation of these options is in dom/docs/ipc/jsactors.rst, + * available at https://firefox-source-docs.mozilla.org/dom/ipc/jsactors.html + */ +let JSWINDOWACTORS = { + AboutLogins: { + parent: { + esModuleURI: "resource:///actors/AboutLoginsParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/AboutLoginsChild.sys.mjs", + events: { + AboutLoginsCopyLoginDetail: { wantUntrusted: true }, + AboutLoginsCreateLogin: { wantUntrusted: true }, + AboutLoginsDeleteLogin: { wantUntrusted: true }, + AboutLoginsDismissBreachAlert: { wantUntrusted: true }, + AboutLoginsImportFromBrowser: { wantUntrusted: true }, + AboutLoginsImportFromFile: { wantUntrusted: true }, + AboutLoginsImportReportInit: { wantUntrusted: true }, + AboutLoginsImportReportReady: { wantUntrusted: true }, + AboutLoginsInit: { wantUntrusted: true }, + AboutLoginsGetHelp: { wantUntrusted: true }, + AboutLoginsOpenPreferences: { wantUntrusted: true }, + AboutLoginsOpenSite: { wantUntrusted: true }, + AboutLoginsRecordTelemetryEvent: { wantUntrusted: true }, + AboutLoginsRemoveAllLogins: { wantUntrusted: true }, + AboutLoginsSortChanged: { wantUntrusted: true }, + AboutLoginsSyncEnable: { wantUntrusted: true }, + AboutLoginsSyncOptions: { wantUntrusted: true }, + AboutLoginsUpdateLogin: { wantUntrusted: true }, + AboutLoginsExportPasswords: { wantUntrusted: true }, + }, + }, + matches: ["about:logins", "about:logins?*", "about:loginsimportreport"], + allFrames: true, + remoteTypes: ["privilegedabout"], + }, + + AboutMessagePreview: { + parent: { + esModuleURI: "resource:///actors/AboutMessagePreviewParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/AboutMessagePreviewChild.sys.mjs", + events: { + DOMDocElementInserted: { capture: true }, + }, + }, + matches: ["about:messagepreview", "about:messagepreview?*"], + }, + + AboutNewTab: { + parent: { + esModuleURI: "resource:///actors/AboutNewTabParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/AboutNewTabChild.sys.mjs", + events: { + DOMDocElementInserted: {}, + DOMContentLoaded: {}, + load: { capture: true }, + unload: { capture: true }, + pageshow: {}, + visibilitychange: {}, + }, + }, + // The wildcard on about:newtab is for the # parameter + // that is used for the newtab devtools. The wildcard for about:home + // is similar, and also allows for falling back to loading the + // about:home document dynamically if an attempt is made to load + // about:home?jscache from the AboutHomeStartupCache as a top-level + // load. + matches: ["about:home*", "about:welcome", "about:newtab*"], + remoteTypes: ["privilegedabout"], + }, + + AboutPocket: { + parent: { + esModuleURI: "resource:///actors/AboutPocketParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/AboutPocketChild.sys.mjs", + + events: { + DOMDocElementInserted: { capture: true }, + }, + }, + + remoteTypes: ["privilegedabout"], + matches: [ + "about:pocket-saved*", + "about:pocket-signup*", + "about:pocket-home*", + "about:pocket-style-guide*", + ], + }, + + AboutPrivateBrowsing: { + parent: { + esModuleURI: "resource:///actors/AboutPrivateBrowsingParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/AboutPrivateBrowsingChild.sys.mjs", + + events: { + DOMDocElementInserted: { capture: true }, + }, + }, + + matches: ["about:privatebrowsing*"], + }, + + AboutProtections: { + parent: { + esModuleURI: "resource:///actors/AboutProtectionsParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/AboutProtectionsChild.sys.mjs", + + events: { + DOMDocElementInserted: { capture: true }, + }, + }, + + matches: ["about:protections", "about:protections?*"], + }, + + AboutReader: { + parent: { + esModuleURI: "resource:///actors/AboutReaderParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/AboutReaderChild.sys.mjs", + events: { + DOMContentLoaded: {}, + pageshow: { mozSystemGroup: true }, + // Don't try to create the actor if only the pagehide event fires. + // This can happen with the initial about:blank documents. + pagehide: { mozSystemGroup: true, createActor: false }, + }, + }, + messageManagerGroups: ["browsers"], + }, + + AboutTabCrashed: { + parent: { + esModuleURI: "resource:///actors/AboutTabCrashedParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/AboutTabCrashedChild.sys.mjs", + events: { + DOMDocElementInserted: { capture: true }, + }, + }, + + matches: ["about:tabcrashed*"], + }, + + AboutWelcomeShopping: { + parent: { + esModuleURI: "resource:///actors/AboutWelcomeParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/AboutWelcomeChild.sys.mjs", + events: { + Update: {}, + }, + }, + matches: ["about:shoppingsidebar"], + remoteTypes: ["privilegedabout"], + }, + + AboutWelcome: { + parent: { + esModuleURI: "resource:///actors/AboutWelcomeParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/AboutWelcomeChild.sys.mjs", + events: { + // This is added so the actor instantiates immediately and makes + // methods available to the page js on load. + DOMDocElementInserted: {}, + }, + }, + matches: ["about:welcome"], + remoteTypes: ["privilegedabout"], + + // See Bug 1618306 + // Remove this preference check when we turn on separate about:welcome for all users. + enablePreference: "browser.aboutwelcome.enabled", + }, + + BlockedSite: { + parent: { + esModuleURI: "resource:///actors/BlockedSiteParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/BlockedSiteChild.sys.mjs", + events: { + AboutBlockedLoaded: { wantUntrusted: true }, + click: {}, + }, + }, + matches: ["about:blocked?*"], + allFrames: true, + }, + + BrowserTab: { + child: { + esModuleURI: "resource:///actors/BrowserTabChild.sys.mjs", + }, + + messageManagerGroups: ["browsers"], + }, + + ClickHandler: { + parent: { + esModuleURI: "resource:///actors/ClickHandlerParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/ClickHandlerChild.sys.mjs", + events: { + chromelinkclick: { capture: true, mozSystemGroup: true }, + }, + }, + + allFrames: true, + }, + + /* Note: this uses the same JSMs as ClickHandler, but because it + * relies on "normal" click events anywhere on the page (not just + * links) and is expensive, and only does something for the + * small group of people who have the feature enabled, it is its + * own actor which is only registered if the pref is enabled. + */ + MiddleMousePasteHandler: { + parent: { + esModuleURI: "resource:///actors/ClickHandlerParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/ClickHandlerChild.sys.mjs", + events: { + auxclick: { capture: true, mozSystemGroup: true }, + }, + }, + enablePreference: "middlemouse.contentLoadURL", + + allFrames: true, + }, + + ContentSearch: { + parent: { + esModuleURI: "resource:///actors/ContentSearchParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/ContentSearchChild.sys.mjs", + events: { + ContentSearchClient: { capture: true, wantUntrusted: true }, + }, + }, + matches: [ + "about:home", + "about:welcome", + "about:newtab", + "about:privatebrowsing", + "about:test-about-content-search-ui", + ], + remoteTypes: ["privilegedabout"], + }, + + ContextMenu: { + parent: { + esModuleURI: "resource:///actors/ContextMenuParent.sys.mjs", + }, + + child: { + esModuleURI: "resource:///actors/ContextMenuChild.sys.mjs", + events: { + contextmenu: { mozSystemGroup: true }, + }, + }, + + allFrames: true, + }, + + DecoderDoctor: { + parent: { + esModuleURI: "resource:///actors/DecoderDoctorParent.sys.mjs", + }, + + child: { + esModuleURI: "resource:///actors/DecoderDoctorChild.sys.mjs", + observers: ["decoder-doctor-notification"], + }, + + messageManagerGroups: ["browsers"], + allFrames: true, + }, + + DOMFullscreen: { + parent: { + esModuleURI: "resource:///actors/DOMFullscreenParent.sys.mjs", + }, + + child: { + esModuleURI: "resource:///actors/DOMFullscreenChild.sys.mjs", + events: { + "MozDOMFullscreen:Request": {}, + "MozDOMFullscreen:Entered": {}, + "MozDOMFullscreen:NewOrigin": {}, + "MozDOMFullscreen:Exit": {}, + "MozDOMFullscreen:Exited": {}, + }, + }, + + messageManagerGroups: ["browsers"], + allFrames: true, + }, + + EncryptedMedia: { + parent: { + esModuleURI: "resource:///actors/EncryptedMediaParent.sys.mjs", + }, + + child: { + esModuleURI: "resource:///actors/EncryptedMediaChild.sys.mjs", + observers: ["mediakeys-request"], + }, + + messageManagerGroups: ["browsers"], + allFrames: true, + }, + + FormValidation: { + parent: { + esModuleURI: "resource:///actors/FormValidationParent.sys.mjs", + }, + + child: { + esModuleURI: "resource:///actors/FormValidationChild.sys.mjs", + events: { + MozInvalidForm: {}, + // Listening to ‘pageshow’ event is only relevant if an invalid form + // popup was open, so don't create the actor when fired. + pageshow: { createActor: false }, + }, + }, + + allFrames: true, + }, + + LightweightTheme: { + child: { + esModuleURI: "resource:///actors/LightweightThemeChild.sys.mjs", + events: { + pageshow: { mozSystemGroup: true }, + DOMContentLoaded: {}, + }, + }, + includeChrome: true, + allFrames: true, + matches: [ + "about:asrouter", + "about:home", + "about:newtab", + "about:welcome", + "chrome://browser/content/syncedtabs/sidebar.xhtml", + "chrome://browser/content/places/historySidebar.xhtml", + "chrome://browser/content/places/bookmarksSidebar.xhtml", + "about:firefoxview", + ], + }, + + LinkHandler: { + parent: { + esModuleURI: "resource:///actors/LinkHandlerParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/LinkHandlerChild.sys.mjs", + events: { + DOMHeadElementParsed: {}, + DOMLinkAdded: {}, + DOMLinkChanged: {}, + pageshow: {}, + // The `pagehide` event is only used to clean up state which will not be + // present if the actor hasn't been created. + pagehide: { createActor: false }, + }, + }, + + messageManagerGroups: ["browsers"], + }, + + PageInfo: { + child: { + esModuleURI: "resource:///actors/PageInfoChild.sys.mjs", + }, + + allFrames: true, + }, + + PageStyle: { + parent: { + esModuleURI: "resource:///actors/PageStyleParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/PageStyleChild.sys.mjs", + events: { + pageshow: { createActor: false }, + }, + }, + + messageManagerGroups: ["browsers"], + allFrames: true, + }, + + Pdfjs: { + parent: { + esModuleURI: "resource://pdf.js/PdfjsParent.sys.mjs", + }, + child: { + esModuleURI: "resource://pdf.js/PdfjsChild.sys.mjs", + }, + allFrames: true, + }, + + // GMP crash reporting + Plugin: { + parent: { + esModuleURI: "resource:///actors/PluginParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/PluginChild.sys.mjs", + events: { + PluginCrashed: { capture: true }, + }, + }, + + allFrames: true, + }, + + PointerLock: { + parent: { + esModuleURI: "resource:///actors/PointerLockParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/PointerLockChild.sys.mjs", + events: { + "MozDOMPointerLock:Entered": {}, + "MozDOMPointerLock:Exited": {}, + }, + }, + + messageManagerGroups: ["browsers"], + allFrames: true, + }, + + Prompt: { + parent: { + esModuleURI: "resource:///actors/PromptParent.sys.mjs", + }, + includeChrome: true, + allFrames: true, + }, + + RefreshBlocker: { + parent: { + esModuleURI: "resource:///actors/RefreshBlockerParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/RefreshBlockerChild.sys.mjs", + }, + + messageManagerGroups: ["browsers"], + enablePreference: "accessibility.blockautorefresh", + }, + + ScreenshotsComponent: { + parent: { + esModuleURI: "resource:///modules/ScreenshotsUtils.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/ScreenshotsComponentChild.sys.mjs", + events: { + "Screenshots:Close": { wantUntrusted: true }, + "Screenshots:Copy": { wantUntrusted: true }, + "Screenshots:Download": { wantUntrusted: true }, + "Screenshots:HidePanel": { wantUntrusted: true }, + "Screenshots:OverlaySelection": { wantUntrusted: true }, + "Screenshots:RecordEvent": { wantUntrusted: true }, + "Screenshots:ShowPanel": { wantUntrusted: true }, + }, + }, + enablePreference: "screenshots.browser.component.enabled", + }, + + ScreenshotsHelper: { + parent: { + esModuleURI: "resource:///modules/ScreenshotsUtils.sys.mjs", + }, + child: { + esModuleURI: "resource:///modules/ScreenshotsHelperChild.sys.mjs", + }, + allFrames: true, + enablePreference: "screenshots.browser.component.enabled", + }, + + SearchSERPTelemetry: { + parent: { + esModuleURI: "resource:///actors/SearchSERPTelemetryParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/SearchSERPTelemetryChild.sys.mjs", + events: { + DOMContentLoaded: {}, + pageshow: { mozSystemGroup: true }, + // The 'pagehide' event is only used to clean up state, and should not + // force actor creation. + pagehide: { createActor: false }, + load: { mozSystemGroup: true, capture: true }, + }, + }, + matches: ["https://*/*"], + }, + + ShieldFrame: { + parent: { + esModuleURI: "resource://normandy-content/ShieldFrameParent.sys.mjs", + }, + child: { + esModuleURI: "resource://normandy-content/ShieldFrameChild.sys.mjs", + events: { + pageshow: {}, + pagehide: {}, + ShieldPageEvent: { wantUntrusted: true }, + }, + }, + matches: ["about:studies*"], + }, + + ShoppingSidebar: { + parent: { + esModuleURI: "resource:///actors/ShoppingSidebarParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/ShoppingSidebarChild.sys.mjs", + events: { + ContentReady: { wantUntrusted: true }, + PolledRequestMade: { wantUntrusted: true }, + // This is added so the actor instantiates immediately and makes + // methods available to the page js on load. + DOMDocElementInserted: {}, + ReportProductAvailable: { wantUntrusted: true }, + AdClicked: { wantUntrusted: true }, + AdImpression: { wantUntrusted: true }, + DisableShopping: { wantUntrusted: true }, + }, + }, + matches: ["about:shoppingsidebar"], + remoteTypes: ["privilegedabout"], + }, + + SpeechDispatcher: { + parent: { + esModuleURI: "resource:///actors/SpeechDispatcherParent.sys.mjs", + }, + + child: { + esModuleURI: "resource:///actors/SpeechDispatcherChild.sys.mjs", + observers: ["chrome-synth-voices-error"], + }, + + messageManagerGroups: ["browsers"], + allFrames: true, + }, + + ASRouter: { + parent: { + esModuleURI: "resource:///actors/ASRouterParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/ASRouterChild.sys.mjs", + events: { + // This is added so the actor instantiates immediately and makes + // methods available to the page js on load. + DOMDocElementInserted: {}, + }, + }, + matches: [ + "about:asrouter*", + "about:home*", + "about:newtab*", + "about:welcome*", + "about:privatebrowsing*", + ], + remoteTypes: ["privilegedabout"], + }, + + SwitchDocumentDirection: { + child: { + esModuleURI: "resource:///actors/SwitchDocumentDirectionChild.sys.mjs", + }, + + allFrames: true, + }, + + UITour: { + parent: { + esModuleURI: "resource:///modules/UITourParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///modules/UITourChild.sys.mjs", + events: { + mozUITour: { wantUntrusted: true }, + }, + }, + + messageManagerGroups: ["browsers"], + }, + + WebRTC: { + parent: { + esModuleURI: "resource:///actors/WebRTCParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/WebRTCChild.sys.mjs", + }, + + allFrames: true, + }, +}; + +ChromeUtils.defineLazyGetter( + lazy, + "WeaveService", + () => Cc["@mozilla.org/weave/service;1"].getService().wrappedJSObject +); + +if (AppConstants.MOZ_CRASHREPORTER) { + ChromeUtils.defineESModuleGetters(lazy, { + UnsubmittedCrashHandler: "resource:///modules/ContentCrashHandlers.sys.mjs", + }); +} + +ChromeUtils.defineLazyGetter(lazy, "gBrandBundle", function () { + return Services.strings.createBundle( + "chrome://branding/locale/brand.properties" + ); +}); + +ChromeUtils.defineLazyGetter(lazy, "gBrowserBundle", function () { + return Services.strings.createBundle( + "chrome://browser/locale/browser.properties" + ); +}); + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + let consoleOptions = { + // tip: set maxLogLevel to "debug" and use lazy.log.debug() to create + // detailed messages during development. See LOG_LEVELS in Console.sys.mjs + // for details. + maxLogLevel: "error", + maxLogLevelPref: "browser.policies.loglevel", + prefix: "BrowserGlue.sys.mjs", + }; + return new ConsoleAPI(consoleOptions); +}); + +const listeners = { + observers: { + "gmp-plugin-crash": ["PluginManager"], + "plugin-crashed": ["PluginManager"], + }, + + observe(subject, topic, data) { + for (let module of this.observers[topic]) { + try { + lazy[module].observe(subject, topic, data); + } catch (e) { + console.error(e); + } + } + }, + + init() { + for (let observer of Object.keys(this.observers)) { + Services.obs.addObserver(this, observer); + } + }, +}; +if (AppConstants.MOZ_UPDATER) { + listeners.observers["update-downloading"] = ["UpdateListener"]; + listeners.observers["update-staged"] = ["UpdateListener"]; + listeners.observers["update-downloaded"] = ["UpdateListener"]; + listeners.observers["update-available"] = ["UpdateListener"]; + listeners.observers["update-error"] = ["UpdateListener"]; + listeners.observers["update-swap"] = ["UpdateListener"]; +} + +// Seconds of idle before trying to create a bookmarks backup. +const BOOKMARKS_BACKUP_IDLE_TIME_SEC = 8 * 60; +// Minimum interval between backups. We try to not create more than one backup +// per interval. +const BOOKMARKS_BACKUP_MIN_INTERVAL_DAYS = 1; +// Seconds of idle time before the late idle tasks will be scheduled. +const LATE_TASKS_IDLE_TIME_SEC = 20; +// Time after we stop tracking startup crashes. +const STARTUP_CRASHES_END_DELAY_MS = 30 * 1000; + +/* + * OS X has the concept of zero-window sessions and therefore ignores the + * browser-lastwindow-close-* topics. + */ +const OBSERVE_LASTWINDOW_CLOSE_TOPICS = AppConstants.platform != "macosx"; + +export let BrowserInitState = {}; +BrowserInitState.startupIdleTaskPromise = new Promise(resolve => { + BrowserInitState._resolveStartupIdleTask = resolve; +}); + +export function BrowserGlue() { + XPCOMUtils.defineLazyServiceGetter( + this, + "_userIdleService", + "@mozilla.org/widget/useridleservice;1", + "nsIUserIdleService" + ); + + ChromeUtils.defineLazyGetter(this, "_distributionCustomizer", function () { + const { DistributionCustomizer } = ChromeUtils.importESModule( + "resource:///modules/distribution.sys.mjs" + ); + return new DistributionCustomizer(); + }); + + XPCOMUtils.defineLazyServiceGetter( + this, + "AlertsService", + "@mozilla.org/alerts-service;1", + "nsIAlertsService" + ); + + this._init(); +} + +function WindowsRegPoliciesGetter(wrk, root, regLocation) { + wrk.open(root, regLocation, wrk.ACCESS_READ); + let policies; + if (wrk.hasChild("Mozilla\\" + Services.appinfo.name)) { + policies = lazy.WindowsGPOParser.readPolicies(wrk, policies); + } + wrk.close(); + return policies; +} + +function isPrivateBrowsingAllowedInRegistry() { + // If there is an attempt to open Private Browsing before + // EnterprisePolicies are initialized the Windows registry + // can be checked to determine if it is enabled + if (Services.policies.status > Ci.nsIEnterprisePolicies.UNINITIALIZED) { + // Yield to policies engine if initialized + let privateAllowed = Services.policies.isAllowed("privatebrowsing"); + lazy.log.debug( + `Yield to initialized policies engine: Private Browsing Allowed = ${privateAllowed}` + ); + return privateAllowed; + } + if (AppConstants.platform !== "win") { + // Not using Windows so no registry, return true + lazy.log.debug( + "AppConstants.platform is not 'win': Private Browsing allowed" + ); + return true; + } + // If all other checks fail only then do we check registry + let wrk = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + Ci.nsIWindowsRegKey + ); + let regLocation = "SOFTWARE\\Policies"; + let userPolicies, machinePolicies; + // Only check HKEY_LOCAL_MACHINE if not in testing + if (!Cu.isInAutomation) { + machinePolicies = WindowsRegPoliciesGetter( + wrk, + wrk.ROOT_KEY_LOCAL_MACHINE, + regLocation + ); + } + // Check machine policies before checking user policies + // HKEY_LOCAL_MACHINE supersedes HKEY_CURRENT_USER so only check + // HKEY_CURRENT_USER if the registry key is not present in + // HKEY_LOCAL_MACHINE at all + if (machinePolicies && "DisablePrivateBrowsing" in machinePolicies) { + lazy.log.debug( + `DisablePrivateBrowsing in HKEY_LOCAL_MACHINE is ${machinePolicies.DisablePrivateBrowsing}` + ); + return !(machinePolicies.DisablePrivateBrowsing === 1); + } + userPolicies = WindowsRegPoliciesGetter( + wrk, + wrk.ROOT_KEY_CURRENT_USER, + regLocation + ); + if (userPolicies && "DisablePrivateBrowsing" in userPolicies) { + lazy.log.debug( + `DisablePrivateBrowsing in HKEY_CURRENT_USER is ${userPolicies.DisablePrivateBrowsing}` + ); + return !(userPolicies.DisablePrivateBrowsing === 1); + } + // Private browsing allowed if no registry entry exists + lazy.log.debug( + "No DisablePrivateBrowsing registry entry: Private Browsing allowed" + ); + return true; +} + +BrowserGlue.prototype = { + _saveSession: false, + _migrationImportsDefaultBookmarks: false, + _placesBrowserInitComplete: false, + _isNewProfile: undefined, + _defaultCookieBehaviorAtStartup: null, + + _setPrefToSaveSession: function BG__setPrefToSaveSession(aForce) { + if (!this._saveSession && !aForce) { + return; + } + + if (!lazy.PrivateBrowsingUtils.permanentPrivateBrowsing) { + Services.prefs.setBoolPref( + "browser.sessionstore.resume_session_once", + true + ); + } + + // This method can be called via [NSApplication terminate:] on Mac, which + // ends up causing prefs not to be flushed to disk, so we need to do that + // explicitly here. See bug 497652. + Services.prefs.savePrefFile(null); + }, + + // nsIObserver implementation + observe: async function BG_observe(subject, topic, data) { + switch (topic) { + case "notifications-open-settings": + this._openPreferences("privacy-permissions"); + break; + case "final-ui-startup": + this._beforeUIStartup(); + break; + case "browser-delayed-startup-finished": + this._onFirstWindowLoaded(subject); + Services.obs.removeObserver(this, "browser-delayed-startup-finished"); + break; + case "sessionstore-windows-restored": + this._onWindowsRestored(); + break; + case "browser:purge-session-history": + // reset the console service's error buffer + Services.console.logStringMessage(null); // clear the console (in case it's open) + Services.console.reset(); + break; + case "restart-in-safe-mode": + this._onSafeModeRestart(subject); + break; + case "quit-application-requested": + this._onQuitRequest(subject, data); + break; + case "quit-application-granted": + this._onQuitApplicationGranted(); + break; + case "browser-lastwindow-close-requested": + if (OBSERVE_LASTWINDOW_CLOSE_TOPICS) { + // The application is not actually quitting, but the last full browser + // window is about to be closed. + this._onQuitRequest(subject, "lastwindow"); + } + break; + case "browser-lastwindow-close-granted": + if (OBSERVE_LASTWINDOW_CLOSE_TOPICS) { + this._setPrefToSaveSession(); + } + break; + case "fxaccounts:onverified": + this._onThisDeviceConnected(); + break; + case "fxaccounts:device_connected": + this._onDeviceConnected(data); + break; + case "fxaccounts:verify_login": + this._onVerifyLoginNotification(JSON.parse(data)); + break; + case "fxaccounts:device_disconnected": + data = JSON.parse(data); + if (data.isLocalDevice) { + this._onDeviceDisconnected(); + } + break; + case "fxaccounts:commands:open-uri": + this._onDisplaySyncURIs(subject); + break; + case "session-save": + this._setPrefToSaveSession(true); + subject.QueryInterface(Ci.nsISupportsPRBool); + subject.data = true; + break; + case "places-init-complete": + Services.obs.removeObserver(this, "places-init-complete"); + if (!this._migrationImportsDefaultBookmarks) { + this._initPlaces(false); + } + break; + case "idle": + this._backupBookmarks(); + break; + case "distribution-customization-complete": + Services.obs.removeObserver( + this, + "distribution-customization-complete" + ); + // Customization has finished, we don't need the customizer anymore. + delete this._distributionCustomizer; + break; + case "browser-glue-test": // used by tests + if (data == "force-ui-migration") { + this._migrateUI(); + } else if (data == "force-distribution-customization") { + this._distributionCustomizer.applyCustomizations(); + // To apply distribution bookmarks use "places-init-complete". + } else if (data == "test-force-places-init") { + this._placesInitialized = false; + this._initPlaces(false); + } else if (data == "mock-alerts-service") { + Object.defineProperty(this, "AlertsService", { + value: subject.wrappedJSObject, + }); + } else if (data == "places-browser-init-complete") { + if (this._placesBrowserInitComplete) { + Services.obs.notifyObservers(null, "places-browser-init-complete"); + } + } else if (data == "add-breaches-sync-handler") { + this._addBreachesSyncHandler(); + } + break; + case "initial-migration-will-import-default-bookmarks": + this._migrationImportsDefaultBookmarks = true; + break; + case "initial-migration-did-import-default-bookmarks": + this._initPlaces(true); + break; + 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); + // Preserve legacy behavior of non-modifier left-clicks + // opening in a new selected tab. + if (where == "current") { + where = "tab"; + } + win.openTrustedLinkIn(data.href, where); + linkHandled.data = true; + } + } + 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": + // 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; + try { + engine = Services.search.getEngineByName( + subject.QueryInterface(Ci.nsISupportsString).data + ); + } catch (ex) { + console.error(ex); + } + let win = lazy.BrowserWindowTracker.getTopWindow(); + lazy.BrowserSearchTelemetry.recordSearch( + win.gBrowser.selectedBrowser, + engine, + "urlbar" + ); + break; + 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; + case "handlersvc-store-initialized": + // Initialize PdfJs when running in-process and remote. This only + // happens once since PdfJs registers global hooks. If the PdfJs + // extension is installed the init method below will be overridden + // leaving initialization to the extension. + // parent only: configure default prefs, set up pref observers, register + // pdf content handler, and initializes parent side message manager + // shim for privileged api access. + lazy.PdfJs.init(this._isNewProfile); + + // Allow certain viewable internally types to be opened from downloads. + lazy.DownloadsViewableInternally.register(); + + break; + case "app-startup": + this._earlyBlankFirstPaint(subject); + gThisInstanceIsTaskbarTab = subject.handleFlag("taskbar-tab", false); + gThisInstanceIsLaunchOnLogin = subject.handleFlag( + "os-autostart", + false + ); + let launchOnLoginPref = "browser.startup.windowsLaunchOnLogin.enabled"; + let profileSvc = Cc[ + "@mozilla.org/toolkit/profile-service;1" + ].getService(Ci.nsIToolkitProfileService); + if ( + AppConstants.platform == "win" && + !profileSvc.startWithLastProfile + ) { + // If we don't start with last profile, the user + // likely sees the profile selector on launch. + if (Services.prefs.getBoolPref(launchOnLoginPref)) { + Services.telemetry.setEventRecordingEnabled( + "launch_on_login", + true + ); + Services.telemetry.recordEvent( + "launch_on_login", + "last_profile_disable", + "startup" + ); + } + Services.prefs.setBoolPref(launchOnLoginPref, false); + // Only remove registry key, not shortcut here as we can assume + // if a user manually created a shortcut they want this behavior. + await lazy.WindowsLaunchOnLogin.removeLaunchOnLoginRegistryKey(); + } + break; + } + }, + + // initialization (called on application startup) + _init: function BG__init() { + let os = Services.obs; + [ + "notifications-open-settings", + "final-ui-startup", + "browser-delayed-startup-finished", + "sessionstore-windows-restored", + "browser:purge-session-history", + "quit-application-requested", + "quit-application-granted", + "fxaccounts:onverified", + "fxaccounts:device_connected", + "fxaccounts:verify_login", + "fxaccounts:device_disconnected", + "fxaccounts:commands:open-uri", + "session-save", + "places-init-complete", + "distribution-customization-complete", + "handle-xul-text-link", + "profile-before-change", + "keyword-search", + "restart-in-safe-mode", + "xpi-signature-changed", + "sync-ui-state:update", + "handlersvc-store-initialized", + ].forEach(topic => os.addObserver(this, topic, true)); + if (OBSERVE_LASTWINDOW_CLOSE_TOPICS) { + os.addObserver(this, "browser-lastwindow-close-requested", true); + os.addObserver(this, "browser-lastwindow-close-granted", true); + } + + lazy.ActorManagerParent.addJSProcessActors(JSPROCESSACTORS); + lazy.ActorManagerParent.addJSWindowActors(JSWINDOWACTORS); + + this._firstWindowReady = new Promise( + resolve => (this._firstWindowLoaded = resolve) + ); + }, + + // cleanup (called on application shutdown) + _dispose: function BG__dispose() { + // AboutHomeStartupCache might write to the cache during + // quit-application-granted, so we defer uninitialization + // until here. + AboutHomeStartupCache.uninit(); + + if (this._bookmarksBackupIdleTime) { + this._userIdleService.removeIdleObserver( + this, + this._bookmarksBackupIdleTime + ); + this._bookmarksBackupIdleTime = null; + } + if (this._lateTasksIdleObserver) { + this._userIdleService.removeIdleObserver( + this._lateTasksIdleObserver, + LATE_TASKS_IDLE_TIME_SEC + ); + delete this._lateTasksIdleObserver; + } + if (this._gmpInstallManager) { + this._gmpInstallManager.uninit(); + delete this._gmpInstallManager; + } + + Services.prefs.removeObserver( + "privacy.trackingprotection", + this._matchCBCategory + ); + Services.prefs.removeObserver( + "network.cookie.cookieBehavior", + this._matchCBCategory + ); + Services.prefs.removeObserver( + "network.cookie.cookieBehavior.pbmode", + this._matchCBCategory + ); + Services.prefs.removeObserver( + "network.http.referer.disallowCrossSiteRelaxingDefault", + this._matchCBCategory + ); + Services.prefs.removeObserver( + "network.http.referer.disallowCrossSiteRelaxingDefault.top_navigation", + this._matchCBCategory + ); + Services.prefs.removeObserver( + "privacy.partition.network_state.ocsp_cache", + this._matchCBCategory + ); + Services.prefs.removeObserver( + "privacy.query_stripping.enabled", + this._matchCBCategory + ); + Services.prefs.removeObserver( + "privacy.query_stripping.enabled.pbmode", + this._matchCBCategory + ); + Services.prefs.removeObserver( + "privacy.fingerprintingProtection", + this._matchCBCategory + ); + Services.prefs.removeObserver( + "privacy.fingerprintingProtection.pbmode", + this._matchCBCategory + ); + Services.prefs.removeObserver( + ContentBlockingCategoriesPrefs.PREF_CB_CATEGORY, + this._updateCBCategory + ); + Services.prefs.removeObserver( + "privacy.trackingprotection", + this._setPrefExpectations + ); + Services.prefs.removeObserver( + "browser.contentblocking.features.strict", + this._setPrefExpectationsAndUpdate + ); + }, + + // runs on startup, before the first command line handler is invoked + // (i.e. before the first window is opened) + _beforeUIStartup: function BG__beforeUIStartup() { + lazy.SessionStartup.init(); + + // check if we're in safe mode + if (Services.appinfo.inSafeMode) { + Services.ww.openWindow( + null, + "chrome://browser/content/safeMode.xhtml", + "_blank", + "chrome,centerscreen,modal,resizable=no", + null + ); + } + + // apply distribution customizations + this._distributionCustomizer.applyCustomizations(); + + // handle any UI migration + this._migrateUI(); + + if (!Services.prefs.prefHasUserValue(PREF_PDFJS_ISDEFAULT_CACHE_STATE)) { + lazy.PdfJs.checkIsDefault(this._isNewProfile); + } + + listeners.init(); + + lazy.SessionStore.init(); + + lazy.BuiltInThemes.maybeInstallActiveBuiltInTheme(); + + if (AppConstants.MOZ_NORMANDY) { + lazy.Normandy.init(); + } + + lazy.SaveToPocket.init(); + + lazy.ResetPBMPanel.init(); + + AboutHomeStartupCache.init(); + + Services.obs.notifyObservers(null, "browser-ui-startup-complete"); + }, + + _checkForOldBuildUpdates() { + // check for update if our build is old + if ( + AppConstants.MOZ_UPDATER && + Services.prefs.getBoolPref("app.update.checkInstallTime") + ) { + let buildID = Services.appinfo.appBuildID; + let today = new Date().getTime(); + /* eslint-disable no-multi-spaces */ + let buildDate = new Date( + buildID.slice(0, 4), // year + buildID.slice(4, 6) - 1, // months are zero-based. + buildID.slice(6, 8), // day + buildID.slice(8, 10), // hour + buildID.slice(10, 12), // min + buildID.slice(12, 14) + ) // ms + .getTime(); + /* eslint-enable no-multi-spaces */ + + const millisecondsIn24Hours = 86400000; + let acceptableAge = + Services.prefs.getIntPref("app.update.checkInstallTime.days") * + millisecondsIn24Hours; + + if (buildDate + acceptableAge < today) { + Cc["@mozilla.org/updates/update-service;1"] + .getService(Ci.nsIApplicationUpdateService) + .checkForBackgroundUpdates(); + } + } + }, + + async _onSafeModeRestart(window) { + // prompt the user to confirm + let productName = lazy.gBrandBundle.GetStringFromName("brandShortName"); + let strings = lazy.gBrowserBundle; + let promptTitle = strings.formatStringFromName( + "troubleshootModeRestartPromptTitle", + [productName] + ); + let promptMessage = strings.GetStringFromName( + "troubleshootModeRestartPromptMessage" + ); + let restartText = strings.GetStringFromName( + "troubleshootModeRestartButton" + ); + let buttonFlags = + Services.prompt.BUTTON_POS_0 * Services.prompt.BUTTON_TITLE_IS_STRING + + Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL + + Services.prompt.BUTTON_POS_0_DEFAULT; + + let rv = await Services.prompt.asyncConfirmEx( + window.browsingContext, + Ci.nsIPrompt.MODAL_TYPE_INTERNAL_WINDOW, + promptTitle, + promptMessage, + buttonFlags, + restartText, + null, + null, + null, + {} + ); + if (rv.get("buttonNumClicked") != 0) { + return; + } + + let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + Services.obs.notifyObservers( + cancelQuit, + "quit-application-requested", + "restart" + ); + + if (!cancelQuit.data) { + Services.startup.restartInSafeMode(Ci.nsIAppStartup.eAttemptQuit); + } + }, + + /** + * Show a notification bar offering a reset. + * + * @param reason + * String of either "unused" or "uninstall", specifying the reason + * why a profile reset is offered. + */ + _resetProfileNotification(reason) { + let win = lazy.BrowserWindowTracker.getTopWindow(); + if (!win) { + return; + } + + const { ResetProfile } = ChromeUtils.importESModule( + "resource://gre/modules/ResetProfile.sys.mjs" + ); + if (!ResetProfile.resetSupported()) { + return; + } + + let productName = lazy.gBrandBundle.GetStringFromName("brandShortName"); + let resetBundle = Services.strings.createBundle( + "chrome://global/locale/resetProfile.properties" + ); + + let message; + if (reason == "unused") { + message = resetBundle.formatStringFromName("resetUnusedProfile.message", [ + productName, + ]); + } else if (reason == "uninstall") { + message = resetBundle.formatStringFromName("resetUninstalled.message", [ + productName, + ]); + } else { + throw new Error( + `Unknown reason (${reason}) given to _resetProfileNotification.` + ); + } + let buttons = [ + { + label: resetBundle.formatStringFromName( + "refreshProfile.resetButton.label", + [productName] + ), + accessKey: resetBundle.GetStringFromName( + "refreshProfile.resetButton.accesskey" + ), + callback() { + ResetProfile.openConfirmationDialog(win); + }, + }, + ]; + + win.gNotificationBox.appendNotification( + "reset-profile-notification", + { + label: message, + image: "chrome://global/skin/icons/question-64.png", + priority: win.gNotificationBox.PRIORITY_INFO_LOW, + }, + buttons + ); + }, + + _notifyUnsignedAddonsDisabled() { + let win = lazy.BrowserWindowTracker.getTopWindow(); + if (!win) { + return; + } + + let message = win.gNavigatorBundle.getString( + "unsignedAddonsDisabled.message" + ); + let buttons = [ + { + label: win.gNavigatorBundle.getString( + "unsignedAddonsDisabled.learnMore.label" + ), + accessKey: win.gNavigatorBundle.getString( + "unsignedAddonsDisabled.learnMore.accesskey" + ), + callback() { + win.BrowserOpenAddonsMgr("addons://list/extension?unsigned=true"); + }, + }, + ]; + + win.gNotificationBox.appendNotification( + "unsigned-addons-disabled", + { + label: message, + priority: win.gNotificationBox.PRIORITY_WARNING_MEDIUM, + }, + buttons + ); + }, + + _earlyBlankFirstPaint(cmdLine) { + let startTime = Cu.now(); + if ( + AppConstants.platform == "macosx" || + Services.startup.wasSilentlyStarted || + !Services.prefs.getBoolPref("browser.startup.blankWindow", false) + ) { + return; + } + + // Until bug 1450626 and bug 1488384 are fixed, skip the blank window when + // using a non-default theme. + if ( + !Services.startup.showedPreXULSkeletonUI && + Services.prefs.getCharPref( + "extensions.activeThemeID", + "default-theme@mozilla.org" + ) != "default-theme@mozilla.org" + ) { + return; + } + + let store = Services.xulStore; + let getValue = attr => + store.getValue(AppConstants.BROWSER_CHROME_URL, "main-window", attr); + let width = getValue("width"); + let height = getValue("height"); + + // The clean profile case isn't handled yet. Return early for now. + if (!width || !height) { + return; + } + + let browserWindowFeatures = + "chrome,all,dialog=no,extrachrome,menubar,resizable,scrollbars,status," + + "location,toolbar,personalbar"; + // This needs to be set when opening the window to ensure that the AppUserModelID + // is set correctly on Windows. Without it, initial launches with `-private-window` + // will show up under the regular Firefox taskbar icon first, and then switch + // to the Private Browsing icon shortly thereafter. + if (cmdLine.findFlag("private-window", false) != -1) { + if (isPrivateBrowsingAllowedInRegistry()) { + browserWindowFeatures += ",private"; + } + } + let win = Services.ww.openWindow( + null, + "about:blank", + null, + browserWindowFeatures, + null + ); + + // Hide the titlebar if the actual browser window will draw in it. + let hiddenTitlebar = Services.appinfo.drawInTitlebar; + if (hiddenTitlebar) { + win.windowUtils.setChromeMargin(0, 2, 2, 2); + } + + let docElt = win.document.documentElement; + docElt.setAttribute("screenX", getValue("screenX")); + docElt.setAttribute("screenY", getValue("screenY")); + + // The sizemode="maximized" attribute needs to be set before first paint. + let sizemode = getValue("sizemode"); + if (sizemode == "maximized") { + docElt.setAttribute("sizemode", sizemode); + + // Set the size to use when the user leaves the maximized mode. + // The persisted size is the outer size, but the height/width + // attributes set the inner size. + let appWin = win.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow); + height -= appWin.outerToInnerHeightDifferenceInCSSPixels; + width -= appWin.outerToInnerWidthDifferenceInCSSPixels; + docElt.setAttribute("height", height); + docElt.setAttribute("width", width); + } else { + // Setting the size of the window in the features string instead of here + // causes the window to grow by the size of the titlebar. + win.resizeTo(width, height); + } + + // Set this before showing the window so that graphics code can use it to + // decide to skip some expensive code paths (eg. starting the GPU process). + docElt.setAttribute("windowtype", "navigator:blank"); + + // The window becomes visible after OnStopRequest, so make this happen now. + win.stop(); + + ChromeUtils.addProfilerMarker("earlyBlankFirstPaint", startTime); + win.openTime = Cu.now(); + + let { TelemetryTimestamps } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryTimestamps.sys.mjs" + ); + TelemetryTimestamps.add("blankWindowShown"); + }, + + _firstWindowTelemetry(aWindow) { + let scaling = aWindow.devicePixelRatio * 100; + try { + Services.telemetry.getHistogramById("DISPLAY_SCALING").add(scaling); + } catch (ex) {} + }, + + _collectStartupConditionsTelemetry() { + let nowSeconds = Math.round(Date.now() / 1000); + // Don't include cases where we don't have the pref. This rules out the first install + // as well as the first run of a build since this was introduced. These could by some + // definitions be referred to as "cold" startups, but probably not since we likely + // just wrote many of the files we use to disk. This way we should approximate a lower + // bound to the number of cold startups rather than an upper bound. + let lastCheckSeconds = Services.prefs.getIntPref( + "browser.startup.lastColdStartupCheck", + nowSeconds + ); + Services.prefs.setIntPref( + "browser.startup.lastColdStartupCheck", + nowSeconds + ); + try { + let secondsSinceLastOSRestart = + Services.startup.secondsSinceLastOSRestart; + let isColdStartup = + nowSeconds - secondsSinceLastOSRestart > lastCheckSeconds; + Services.telemetry.scalarSet("startup.is_cold", isColdStartup); + Services.telemetry.scalarSet( + "startup.seconds_since_last_os_restart", + secondsSinceLastOSRestart + ); + } catch (ex) { + console.error(ex); + } + }, + + // the first browser window has finished initializing + _onFirstWindowLoaded: function BG__onFirstWindowLoaded(aWindow) { + lazy.AboutNewTab.init(); + + lazy.TabCrashHandler.init(); + + lazy.ProcessHangMonitor.init(); + + lazy.UrlbarPrefs.updateFirefoxSuggestScenario(); + + // A channel for "remote troubleshooting" code... + let channel = new lazy.WebChannel( + "remote-troubleshooting", + "remote-troubleshooting" + ); + channel.listen((id, data, target) => { + if (data.command == "request") { + let { Troubleshoot } = ChromeUtils.importESModule( + "resource://gre/modules/Troubleshoot.sys.mjs" + ); + Troubleshoot.snapshot().then(snapshotData => { + // for privacy we remove crash IDs and all preferences (but bug 1091944 + // exists to expose prefs once we are confident of privacy implications) + delete snapshotData.crashes; + delete snapshotData.modifiedPreferences; + delete snapshotData.printingPreferences; + channel.send(snapshotData, target); + }); + } + }); + + // Offer to reset a user's profile if it hasn't been used for 60 days. + const OFFER_PROFILE_RESET_INTERVAL_MS = 60 * 24 * 60 * 60 * 1000; + let lastUse = Services.appinfo.replacedLockTime; + let disableResetPrompt = Services.prefs.getBoolPref( + "browser.disableResetPrompt", + false + ); + + if ( + !disableResetPrompt && + lastUse && + Date.now() - lastUse >= OFFER_PROFILE_RESET_INTERVAL_MS + ) { + this._resetProfileNotification("unused"); + } else if (AppConstants.platform == "win" && !disableResetPrompt) { + // Check if we were just re-installed and offer Firefox Reset + let updateChannel; + try { + updateChannel = ChromeUtils.importESModule( + "resource://gre/modules/UpdateUtils.sys.mjs" + ).UpdateUtils.UpdateChannel; + } catch (ex) {} + if (updateChannel) { + let uninstalledValue = lazy.WindowsRegistry.readRegKey( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + "Software\\Mozilla\\Firefox", + `Uninstalled-${updateChannel}` + ); + let removalSuccessful = lazy.WindowsRegistry.removeRegKey( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + "Software\\Mozilla\\Firefox", + `Uninstalled-${updateChannel}` + ); + if (removalSuccessful && uninstalledValue == "True") { + this._resetProfileNotification("uninstall"); + } + } + } + + this._checkForOldBuildUpdates(); + + // Check if Sync is configured + if (Services.prefs.prefHasUserValue("services.sync.username")) { + lazy.WeaveService.init(); + } + + lazy.PageThumbs.init(); + + lazy.NewTabUtils.init(); + + Services.telemetry.setEventRecordingEnabled( + "security.ui.protections", + true + ); + + Services.telemetry.setEventRecordingEnabled("security.doh.neterror", true); + + lazy.PageActions.init(); + + lazy.DoHController.init(); + + this._firstWindowTelemetry(aWindow); + this._firstWindowLoaded(); + + this._collectStartupConditionsTelemetry(); + + // Set the default favicon size for UI views that use the page-icon protocol. + lazy.PlacesUtils.favicons.setDefaultIconURIPreferredSize( + 16 * aWindow.devicePixelRatio + ); + + this._setPrefExpectationsAndUpdate(); + this._matchCBCategory(); + + // This observes the entire privacy.trackingprotection.* pref tree. + Services.prefs.addObserver( + "privacy.trackingprotection", + this._matchCBCategory + ); + Services.prefs.addObserver( + "network.cookie.cookieBehavior", + this._matchCBCategory + ); + Services.prefs.addObserver( + "network.cookie.cookieBehavior.pbmode", + this._matchCBCategory + ); + Services.prefs.addObserver( + "network.http.referer.disallowCrossSiteRelaxingDefault", + this._matchCBCategory + ); + Services.prefs.addObserver( + "network.http.referer.disallowCrossSiteRelaxingDefault.top_navigation", + this._matchCBCategory + ); + Services.prefs.addObserver( + "privacy.partition.network_state.ocsp_cache", + this._matchCBCategory + ); + Services.prefs.addObserver( + "privacy.query_stripping.enabled", + this._matchCBCategory + ); + Services.prefs.addObserver( + "privacy.query_stripping.enabled.pbmode", + this._matchCBCategory + ); + Services.prefs.addObserver( + "privacy.fingerprintingProtection", + this._matchCBCategory + ); + Services.prefs.addObserver( + "privacy.fingerprintingProtection.pbmode", + this._matchCBCategory + ); + Services.prefs.addObserver( + ContentBlockingCategoriesPrefs.PREF_CB_CATEGORY, + this._updateCBCategory + ); + Services.prefs.addObserver( + "media.autoplay.default", + this._updateAutoplayPref + ); + Services.prefs.addObserver( + "privacy.trackingprotection", + this._setPrefExpectations + ); + Services.prefs.addObserver( + "browser.contentblocking.features.strict", + this._setPrefExpectationsAndUpdate + ); + }, + + _updateAutoplayPref() { + const blocked = Services.prefs.getIntPref("media.autoplay.default", 1); + const telemetry = Services.telemetry.getHistogramById( + "AUTOPLAY_DEFAULT_SETTING_CHANGE" + ); + const labels = { 0: "allow", 1: "blockAudible", 5: "blockAll" }; + if (blocked in labels) { + telemetry.add(labels[blocked]); + } + }, + + _setPrefExpectations() { + ContentBlockingCategoriesPrefs.setPrefExpectations(); + }, + + _setPrefExpectationsAndUpdate() { + ContentBlockingCategoriesPrefs.setPrefExpectations(); + ContentBlockingCategoriesPrefs.updateCBCategory(); + }, + + _matchCBCategory() { + ContentBlockingCategoriesPrefs.matchCBCategory(); + }, + + _updateCBCategory() { + ContentBlockingCategoriesPrefs.updateCBCategory(); + }, + + _recordContentBlockingTelemetry() { + Services.telemetry.setEventRecordingEnabled( + "security.ui.protectionspopup", + Services.prefs.getBoolPref( + "security.protectionspopup.recordEventTelemetry" + ) + ); + Services.telemetry.setEventRecordingEnabled( + "security.ui.app_menu", + Services.prefs.getBoolPref("security.app_menu.recordEventTelemetry") + ); + + let tpEnabled = Services.prefs.getBoolPref( + "privacy.trackingprotection.enabled" + ); + Services.telemetry + .getHistogramById("TRACKING_PROTECTION_ENABLED") + .add(tpEnabled); + + let tpPBDisabled = Services.prefs.getBoolPref( + "privacy.trackingprotection.pbmode.enabled" + ); + Services.telemetry + .getHistogramById("TRACKING_PROTECTION_PBM_DISABLED") + .add(!tpPBDisabled); + + let cookieBehavior = Services.prefs.getIntPref( + "network.cookie.cookieBehavior" + ); + Services.telemetry.getHistogramById("COOKIE_BEHAVIOR").add(cookieBehavior); + + let fpEnabled = Services.prefs.getBoolPref( + "privacy.trackingprotection.fingerprinting.enabled" + ); + let cmEnabled = Services.prefs.getBoolPref( + "privacy.trackingprotection.cryptomining.enabled" + ); + let categoryPref; + switch ( + Services.prefs.getStringPref("browser.contentblocking.category", null) + ) { + case "standard": + categoryPref = 0; + break; + case "strict": + categoryPref = 1; + break; + case "custom": + categoryPref = 2; + break; + default: + // Any other value is unsupported. + categoryPref = 3; + break; + } + + Services.telemetry.scalarSet( + "contentblocking.fingerprinting_blocking_enabled", + fpEnabled + ); + Services.telemetry.scalarSet( + "contentblocking.cryptomining_blocking_enabled", + cmEnabled + ); + Services.telemetry.scalarSet("contentblocking.category", categoryPref); + }, + + _recordDataSanitizationPrefs() { + Services.telemetry.scalarSet( + "datasanitization.privacy_sanitize_sanitizeOnShutdown", + Services.prefs.getBoolPref("privacy.sanitize.sanitizeOnShutdown") + ); + Services.telemetry.scalarSet( + "datasanitization.privacy_clearOnShutdown_cookies", + Services.prefs.getBoolPref("privacy.clearOnShutdown.cookies") + ); + Services.telemetry.scalarSet( + "datasanitization.privacy_clearOnShutdown_history", + Services.prefs.getBoolPref("privacy.clearOnShutdown.history") + ); + Services.telemetry.scalarSet( + "datasanitization.privacy_clearOnShutdown_formdata", + Services.prefs.getBoolPref("privacy.clearOnShutdown.formdata") + ); + Services.telemetry.scalarSet( + "datasanitization.privacy_clearOnShutdown_downloads", + Services.prefs.getBoolPref("privacy.clearOnShutdown.downloads") + ); + Services.telemetry.scalarSet( + "datasanitization.privacy_clearOnShutdown_cache", + Services.prefs.getBoolPref("privacy.clearOnShutdown.cache") + ); + Services.telemetry.scalarSet( + "datasanitization.privacy_clearOnShutdown_sessions", + Services.prefs.getBoolPref("privacy.clearOnShutdown.sessions") + ); + Services.telemetry.scalarSet( + "datasanitization.privacy_clearOnShutdown_offlineApps", + Services.prefs.getBoolPref("privacy.clearOnShutdown.offlineApps") + ); + Services.telemetry.scalarSet( + "datasanitization.privacy_clearOnShutdown_siteSettings", + Services.prefs.getBoolPref("privacy.clearOnShutdown.siteSettings") + ); + Services.telemetry.scalarSet( + "datasanitization.privacy_clearOnShutdown_openWindows", + Services.prefs.getBoolPref("privacy.clearOnShutdown.openWindows") + ); + + let exceptions = 0; + for (let permission of Services.perms.all) { + // We consider just permissions set for http, https and file URLs. + if ( + permission.type == "cookie" && + permission.capability == Ci.nsICookiePermission.ACCESS_SESSION && + ["http", "https", "file"].some(scheme => + permission.principal.schemeIs(scheme) + ) + ) { + exceptions++; + } + } + Services.telemetry.scalarSet( + "datasanitization.session_permission_exceptions", + exceptions + ); + }, + + /** + * Application shutdown handler. + */ + _onQuitApplicationGranted() { + let tasks = [ + // This pref must be set here because SessionStore will use its value + // on quit-application. + () => this._setPrefToSaveSession(), + + // Call trackStartupCrashEnd here in case the delayed call on startup hasn't + // yet occurred (see trackStartupCrashEnd caller in browser.js). + () => Services.startup.trackStartupCrashEnd(), + + () => { + if (this._bookmarksBackupIdleTime) { + this._userIdleService.removeIdleObserver( + this, + this._bookmarksBackupIdleTime + ); + this._bookmarksBackupIdleTime = null; + } + }, + + () => lazy.BrowserUsageTelemetry.uninit(), + () => lazy.SearchSERPTelemetry.uninit(), + () => lazy.Interactions.uninit(), + () => lazy.PageDataService.uninit(), + () => lazy.PageThumbs.uninit(), + () => lazy.NewTabUtils.uninit(), + () => lazy.Normandy.uninit(), + () => lazy.RFPHelper.uninit(), + () => lazy.ShoppingUtils.uninit(), + () => lazy.ASRouterNewTabHook.destroy(), + () => { + if (AppConstants.MOZ_UPDATER) { + lazy.UpdateListener.reset(); + } + }, + () => { + // bug 1839426 - The FOG service needs to be instantiated reliably so it + // can perform at-shutdown tasks later in shutdown. + Services.fog; + }, + ]; + + for (let task of tasks) { + try { + task(); + } catch (ex) { + console.error(`Error during quit-application-granted: ${ex}`); + if (Cu.isInAutomation) { + // This usually happens after the test harness is done collecting + // test errors, thus we can't easily add a failure to it. The only + // noticeable solution we have is crashing. + Cc["@mozilla.org/xpcom/debug;1"] + .getService(Ci.nsIDebug2) + .abort(ex.filename, ex.lineNumber); + } + } + } + }, + + // Set up a listener to enable/disable the screenshots extension + // based on its preference. + _monitorScreenshotsPref() { + const SCREENSHOTS_PREF = "extensions.screenshots.disabled"; + const COMPONENT_PREF = "screenshots.browser.component.enabled"; + const ID = "screenshots@mozilla.org"; + const _checkScreenshotsPref = async () => { + let addon = await lazy.AddonManager.getAddonByID(ID); + if (!addon) { + return; + } + let screenshotsDisabled = Services.prefs.getBoolPref( + SCREENSHOTS_PREF, + false + ); + let componentEnabled = Services.prefs.getBoolPref(COMPONENT_PREF, false); + if (screenshotsDisabled) { + if (componentEnabled) { + lazy.ScreenshotsUtils.uninitialize(); + } else { + await addon.disable({ allowSystemAddons: true }); + } + } else if (componentEnabled) { + lazy.ScreenshotsUtils.initialize(); + await addon.disable({ allowSystemAddons: true }); + } else { + await addon.enable({ allowSystemAddons: true }); + lazy.ScreenshotsUtils.uninitialize(); + } + }; + Services.prefs.addObserver(SCREENSHOTS_PREF, _checkScreenshotsPref); + Services.prefs.addObserver(COMPONENT_PREF, _checkScreenshotsPref); + _checkScreenshotsPref(); + }, + + _monitorWebcompatReporterPref() { + const PREF = "extensions.webcompat-reporter.enabled"; + const ID = "webcompat-reporter@mozilla.org"; + Services.prefs.addObserver(PREF, async () => { + let addon = await lazy.AddonManager.getAddonByID(ID); + if (!addon) { + return; + } + let enabled = Services.prefs.getBoolPref(PREF, false); + if (enabled && !addon.isActive) { + await addon.enable({ allowSystemAddons: true }); + } else if (!enabled && addon.isActive) { + await addon.disable({ allowSystemAddons: true }); + } + }); + }, + + async _setupSearchDetection() { + // There is no pref for this add-on because it shouldn't be disabled. + const ID = "addons-search-detection@mozilla.com"; + + let addon = await lazy.AddonManager.getAddonByID(ID); + + // first time install of addon and install on firefox update + addon = + (await lazy.AddonManager.maybeInstallBuiltinAddon( + ID, + "2.0.0", + "resource://builtin-addons/search-detection/" + )) || addon; + + if (!addon.isActive) { + addon.enable(); + } + }, + + _monitorHTTPSOnlyPref() { + const PREF_ENABLED = "dom.security.https_only_mode"; + const PREF_WAS_ENABLED = "dom.security.https_only_mode_ever_enabled"; + const _checkHTTPSOnlyPref = async () => { + const enabled = Services.prefs.getBoolPref(PREF_ENABLED, false); + const was_enabled = Services.prefs.getBoolPref(PREF_WAS_ENABLED, false); + let value = 0; + if (enabled) { + value = 1; + Services.prefs.setBoolPref(PREF_WAS_ENABLED, true); + } else if (was_enabled) { + value = 2; + } + Services.telemetry.scalarSet("security.https_only_mode_enabled", value); + }; + + Services.prefs.addObserver(PREF_ENABLED, _checkHTTPSOnlyPref); + _checkHTTPSOnlyPref(); + + const PREF_PBM_WAS_ENABLED = + "dom.security.https_only_mode_ever_enabled_pbm"; + const PREF_PBM_ENABLED = "dom.security.https_only_mode_pbm"; + + const _checkHTTPSOnlyPBMPref = async () => { + const enabledPBM = Services.prefs.getBoolPref(PREF_PBM_ENABLED, false); + const was_enabledPBM = Services.prefs.getBoolPref( + PREF_PBM_WAS_ENABLED, + false + ); + let valuePBM = 0; + if (enabledPBM) { + valuePBM = 1; + Services.prefs.setBoolPref(PREF_PBM_WAS_ENABLED, true); + } else if (was_enabledPBM) { + valuePBM = 2; + } + Services.telemetry.scalarSet( + "security.https_only_mode_enabled_pbm", + valuePBM + ); + }; + + Services.prefs.addObserver(PREF_PBM_ENABLED, _checkHTTPSOnlyPBMPref); + _checkHTTPSOnlyPBMPref(); + }, + + _monitorIonPref() { + const PREF_ION_ID = "toolkit.telemetry.pioneerId"; + + const _checkIonPref = async () => { + for (let win of Services.wm.getEnumerator("navigator:browser")) { + win.document.getElementById("ion-button").hidden = + !Services.prefs.getStringPref(PREF_ION_ID, null); + } + }; + + const windowListener = { + onOpenWindow(xulWindow) { + const win = xulWindow.docShell.domWindow; + win.addEventListener("load", () => { + const ionButton = win.document.getElementById("ion-button"); + if (ionButton) { + ionButton.hidden = !Services.prefs.getStringPref(PREF_ION_ID, null); + } + }); + }, + onCloseWindow() {}, + }; + + Services.prefs.addObserver(PREF_ION_ID, _checkIonPref); + Services.wm.addListener(windowListener); + _checkIonPref(); + }, + + _monitorIonStudies() { + const STUDY_ADDON_COLLECTION_KEY = "pioneer-study-addons-v1"; + const PREF_ION_NEW_STUDIES_AVAILABLE = + "toolkit.telemetry.pioneer-new-studies-available"; + + const _badgeIcon = async () => { + for (let win of Services.wm.getEnumerator("navigator:browser")) { + win.document + .getElementById("ion-button") + .querySelector(".toolbarbutton-badge") + .classList.add("feature-callout"); + } + }; + + const windowListener = { + onOpenWindow(xulWindow) { + const win = xulWindow.docShell.domWindow; + win.addEventListener("load", () => { + const ionButton = win.document.getElementById("ion-button"); + if (ionButton) { + const badge = ionButton.querySelector(".toolbarbutton-badge"); + if ( + Services.prefs.getBoolPref(PREF_ION_NEW_STUDIES_AVAILABLE, false) + ) { + badge.classList.add("feature-callout"); + } else { + badge.classList.remove("feature-callout"); + } + } + }); + }, + onCloseWindow() {}, + }; + + // Update all open windows if the pref changes. + Services.prefs.addObserver(PREF_ION_NEW_STUDIES_AVAILABLE, _badgeIcon); + + // Badge any currently-open windows. + if (Services.prefs.getBoolPref(PREF_ION_NEW_STUDIES_AVAILABLE, false)) { + _badgeIcon(); + } + + lazy.RemoteSettings(STUDY_ADDON_COLLECTION_KEY).on("sync", async event => { + Services.prefs.setBoolPref(PREF_ION_NEW_STUDIES_AVAILABLE, true); + }); + + // When a new window opens, check if we need to badge the icon. + Services.wm.addListener(windowListener); + }, + + _monitorGPCPref() { + const FEATURE_PREF_ENABLED = "privacy.globalprivacycontrol.enabled"; + const FUNCTIONALITY_PREF_ENABLED = + "privacy.globalprivacycontrol.functionality.enabled"; + const PREF_WAS_ENABLED = "privacy.globalprivacycontrol.was_ever_enabled"; + const _checkGPCPref = async () => { + const feature_enabled = Services.prefs.getBoolPref( + FEATURE_PREF_ENABLED, + false + ); + const functionality_enabled = Services.prefs.getBoolPref( + FUNCTIONALITY_PREF_ENABLED, + false + ); + const was_enabled = Services.prefs.getBoolPref(PREF_WAS_ENABLED, false); + let value = 0; + if (feature_enabled && functionality_enabled) { + value = 1; + Services.prefs.setBoolPref(PREF_WAS_ENABLED, true); + } else if (was_enabled) { + value = 2; + } + Services.telemetry.scalarSet( + "security.global_privacy_control_enabled", + value + ); + }; + + Services.prefs.addObserver(FEATURE_PREF_ENABLED, _checkGPCPref); + Services.prefs.addObserver(FUNCTIONALITY_PREF_ENABLED, _checkGPCPref); + _checkGPCPref(); + }, + + // All initial windows have opened. + _onWindowsRestored: function BG__onWindowsRestored() { + if (this._windowsWereRestored) { + return; + } + this._windowsWereRestored = true; + + lazy.BrowserUsageTelemetry.init(); + lazy.SearchSERPTelemetry.init(); + + lazy.Interactions.init(); + lazy.PageDataService.init(); + lazy.ExtensionsUI.init(); + + let signingRequired; + if (AppConstants.MOZ_REQUIRE_SIGNING) { + signingRequired = true; + } else { + signingRequired = Services.prefs.getBoolPref( + "xpinstall.signatures.required" + ); + } + + if (signingRequired) { + let disabledAddons = lazy.AddonManager.getStartupChanges( + lazy.AddonManager.STARTUP_CHANGE_DISABLED + ); + lazy.AddonManager.getAddonsByIDs(disabledAddons).then(addons => { + for (let addon of addons) { + if (addon.signedState <= lazy.AddonManager.SIGNEDSTATE_MISSING) { + this._notifyUnsignedAddonsDisabled(); + break; + } + } + }); + } + + if (AppConstants.MOZ_CRASHREPORTER) { + lazy.UnsubmittedCrashHandler.init(); + lazy.UnsubmittedCrashHandler.scheduleCheckForUnsubmittedCrashReports(); + lazy.FeatureGate.annotateCrashReporter(); + lazy.FeatureGate.observePrefChangesForCrashReportAnnotation(); + } + + if (AppConstants.ASAN_REPORTER) { + var { AsanReporter } = ChromeUtils.importESModule( + "resource://gre/modules/AsanReporter.sys.mjs" + ); + AsanReporter.init(); + } + + lazy.Sanitizer.onStartup(); + this._maybeShowRestoreSessionInfoBar(); + this._scheduleStartupIdleTasks(); + this._lateTasksIdleObserver = (idleService, topic, data) => { + if (topic == "idle") { + idleService.removeIdleObserver( + this._lateTasksIdleObserver, + LATE_TASKS_IDLE_TIME_SEC + ); + delete this._lateTasksIdleObserver; + this._scheduleBestEffortUserIdleTasks(); + } + }; + this._userIdleService.addIdleObserver( + this._lateTasksIdleObserver, + LATE_TASKS_IDLE_TIME_SEC + ); + + this._monitorScreenshotsPref(); + this._monitorWebcompatReporterPref(); + this._monitorHTTPSOnlyPref(); + this._monitorIonPref(); + this._monitorIonStudies(); + this._setupSearchDetection(); + + this._monitorGPCPref(); + + // Loading the MigrationUtils module does the work of registering the + // migration wizard JSWindowActor pair. In case nothing else has done + // this yet, load the MigrationUtils so that the wizard is ready to be + // used. + lazy.MigrationUtils; + }, + + /** + * Use this function as an entry point to schedule tasks that + * need to run only once after startup, and can be scheduled + * by using an idle callback. + * + * The functions scheduled here will fire from idle callbacks + * once every window has finished being restored by session + * restore, and it's guaranteed that they will run before + * the equivalent per-window idle tasks + * (from _schedulePerWindowIdleTasks in browser.js). + * + * If you have something that can wait even further than the + * per-window initialization, and is okay with not being run in some + * sessions, please schedule them using + * _scheduleBestEffortUserIdleTasks. + * Don't be fooled by thinking that the use of the timeout parameter + * will delay your function: it will just ensure that it potentially + * happens _earlier_ than expected (when the timeout limit has been reached), + * but it will not make it happen later (and out of order) compared + * to the other ones scheduled together. + */ + _scheduleStartupIdleTasks() { + const idleTasks = [ + // It's important that SafeBrowsing is initialized reasonably + // early, so we use a maximum timeout for it. + { + name: "SafeBrowsing.init", + task: () => { + lazy.SafeBrowsing.init(); + }, + timeout: 5000, + }, + + { + name: "ContextualIdentityService.load", + task: async () => { + await lazy.ContextualIdentityService.load(); + lazy.Discovery.update(); + }, + }, + + { + name: "PlacesUIUtils.unblockToolbars", + task: () => { + // We postponed loading bookmarks toolbar content until startup + // has finished, so we can start loading it now: + lazy.PlacesUIUtils.unblockToolbars(); + }, + }, + + { + name: "PlacesDBUtils.telemetry", + condition: lazy.TelemetryUtils.isTelemetryEnabled, + task: () => { + lazy.PlacesDBUtils.telemetry().catch(console.error); + }, + }, + + // Begin listening for incoming push messages. + { + name: "PushService.ensureReady", + task: () => { + try { + lazy.PushService.wrappedJSObject.ensureReady(); + } catch (ex) { + // NS_ERROR_NOT_AVAILABLE will get thrown for the PushService + // getter if the PushService is disabled. + if (ex.result != Cr.NS_ERROR_NOT_AVAILABLE) { + throw ex; + } + } + }, + }, + + { + name: "BrowserGlue._recordContentBlockingTelemetry", + task: () => { + this._recordContentBlockingTelemetry(); + }, + }, + + { + name: "BrowserGlue._recordDataSanitizationPrefs", + task: () => { + this._recordDataSanitizationPrefs(); + }, + }, + + { + name: "enableCertErrorUITelemetry", + task: () => { + let enableCertErrorUITelemetry = Services.prefs.getBoolPref( + "security.certerrors.recordEventTelemetry", + true + ); + Services.telemetry.setEventRecordingEnabled( + "security.ui.certerror", + enableCertErrorUITelemetry + ); + }, + }, + + // Load the Login Manager data from disk off the main thread, some time + // after startup. If the data is required before this runs, for example + // because a restored page contains a password field, it will be loaded on + // the main thread, and this initialization request will be ignored. + { + name: "Services.logins", + task: () => { + try { + Services.logins; + } catch (ex) { + console.error(ex); + } + }, + timeout: 3000, + }, + + // Add breach alerts pref observer reasonably early so the pref flip works + { + name: "_addBreachAlertsPrefObserver", + task: () => { + this._addBreachAlertsPrefObserver(); + }, + }, + + // Report pinning status and the type of shortcut used to launch + { + name: "pinningStatusTelemetry", + condition: AppConstants.platform == "win", + task: async () => { + let shellService = Cc[ + "@mozilla.org/browser/shell-service;1" + ].getService(Ci.nsIWindowsShellService); + let winTaskbar = Cc["@mozilla.org/windows-taskbar;1"].getService( + Ci.nsIWinTaskbar + ); + + try { + Services.telemetry.scalarSet( + "os.environment.is_taskbar_pinned", + await shellService.isCurrentAppPinnedToTaskbarAsync( + winTaskbar.defaultGroupId + ) + ); + Services.telemetry.scalarSet( + "os.environment.is_taskbar_pinned_private", + await shellService.isCurrentAppPinnedToTaskbarAsync( + winTaskbar.defaultPrivateGroupId + ) + ); + } catch (ex) { + console.error(ex); + } + + let classification; + let shortcut; + try { + shortcut = Services.appinfo.processStartupShortcut; + classification = shellService.classifyShortcut(shortcut); + } catch (ex) { + console.error(ex); + } + + if (!classification) { + if (gThisInstanceIsLaunchOnLogin) { + classification = "Autostart"; + } else if (shortcut) { + classification = "OtherShortcut"; + } else { + classification = "Other"; + } + } + // Because of how taskbar tabs work, it may be classifed as a taskbar + // shortcut, in which case we want to overwrite it. + if (gThisInstanceIsTaskbarTab) { + classification = "TaskbarTab"; + } + Services.telemetry.scalarSet( + "os.environment.launch_method", + classification + ); + }, + }, + + { + name: "dualBrowserProtocolHandler", + condition: + AppConstants.platform == "win" && + !Services.prefs.getBoolPref( + "browser.shell.customProtocolsRegistered" + ), + task: async () => { + Services.prefs.setBoolPref( + "browser.shell.customProtocolsRegistered", + true + ); + const FIREFOX_HANDLER_NAME = "firefox"; + const FIREFOX_PRIVATE_HANDLER_NAME = "firefox-private"; + const path = Services.dirsvc.get("XREExeF", Ci.nsIFile).path; + let wrk = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + Ci.nsIWindowsRegKey + ); + try { + wrk.open(wrk.ROOT_KEY_CLASSES_ROOT, "", wrk.ACCESS_READ); + let FxSet = wrk.hasChild(FIREFOX_HANDLER_NAME); + let FxPrivateSet = wrk.hasChild(FIREFOX_PRIVATE_HANDLER_NAME); + wrk.close(); + if (FxSet && FxPrivateSet) { + return; + } + wrk.open( + wrk.ROOT_KEY_CURRENT_USER, + "Software\\Classes", + wrk.ACCESS_ALL + ); + const maybeUpdateRegistry = ( + isSetAlready, + handler, + protocolName + ) => { + if (isSetAlready) { + return; + } + let FxKey = wrk.createChild(handler, wrk.ACCESS_ALL); + try { + // Write URL protocol key + FxKey.writeStringValue("", protocolName); + FxKey.writeStringValue("URL Protocol", ""); + FxKey.close(); + // Write defaultIcon key + FxKey.create( + FxKey.ROOT_KEY_CURRENT_USER, + "Software\\Classes\\" + handler + "\\DefaultIcon", + FxKey.ACCESS_ALL + ); + FxKey.open( + FxKey.ROOT_KEY_CURRENT_USER, + "Software\\Classes\\" + handler + "\\DefaultIcon", + FxKey.ACCESS_ALL + ); + FxKey.writeStringValue("", `\"${path}\",1`); + FxKey.close(); + // Write shell\\open\\command key + FxKey.create( + FxKey.ROOT_KEY_CURRENT_USER, + "Software\\Classes\\" + handler + "\\shell", + FxKey.ACCESS_ALL + ); + FxKey.create( + FxKey.ROOT_KEY_CURRENT_USER, + "Software\\Classes\\" + handler + "\\shell\\open", + FxKey.ACCESS_ALL + ); + FxKey.create( + FxKey.ROOT_KEY_CURRENT_USER, + "Software\\Classes\\" + handler + "\\shell\\open\\command", + FxKey.ACCESS_ALL + ); + FxKey.open( + FxKey.ROOT_KEY_CURRENT_USER, + "Software\\Classes\\" + handler + "\\shell\\open\\command", + FxKey.ACCESS_ALL + ); + if (handler == FIREFOX_PRIVATE_HANDLER_NAME) { + FxKey.writeStringValue( + "", + `\"${path}\" -osint -private-window \"%1\"` + ); + } else { + FxKey.writeStringValue("", `\"${path}\" -osint -url \"%1\"`); + } + } catch (ex) { + console.log(ex); + } finally { + FxKey.close(); + } + }; + try { + maybeUpdateRegistry( + FxSet, + FIREFOX_HANDLER_NAME, + "URL:Firefox Protocol" + ); + } catch (ex) { + console.log(ex); + } + try { + maybeUpdateRegistry( + FxPrivateSet, + FIREFOX_PRIVATE_HANDLER_NAME, + "URL:Firefox Private Browsing Protocol" + ); + } catch (ex) { + console.log(ex); + } + } catch (ex) { + console.log(ex); + } finally { + wrk.close(); + } + }, + timeout: 5000, + }, + + // Ensure a Private Browsing Shortcut exists. This is needed in case + // a user tries to use Windows functionality to pin our Private Browsing + // mode icon to the Taskbar (eg: the "Pin to Taskbar" context menu item). + // This is also created by the installer, but it's possible that a user + // has removed it, or is running out of a zip build. The consequences of not + // having a Shortcut for this are that regular Firefox will be pinned instead + // of the Private Browsing version -- so it's quite important we do our best + // to make sure one is available. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1762994 for additional + // background. + { + 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, + // if they exist at all. We shouldn't try to create our own. + !Services.sysinfo.getProperty("hasWinPackageId") && + // If we've ever done this successfully before, don't try again. The + // user may have deleted the shortcut, and we don't want to force it + // on them. + !Services.prefs.getBoolPref( + PREF_PRIVATE_BROWSING_SHORTCUT_CREATED, + false + ), + task: async () => { + let shellService = Cc[ + "@mozilla.org/browser/shell-service;1" + ].getService(Ci.nsIWindowsShellService); + let winTaskbar = Cc["@mozilla.org/windows-taskbar;1"].getService( + Ci.nsIWinTaskbar + ); + + if ( + !(await shellService.hasMatchingShortcut( + winTaskbar.defaultPrivateGroupId, + true + )) + ) { + let appdir = Services.dirsvc.get("GreD", Ci.nsIFile); + let exe = appdir.clone(); + exe.append(PRIVATE_BROWSING_BINARY); + let strings = new Localization( + ["branding/brand.ftl", "browser/browser.ftl"], + true + ); + let [desc] = await strings.formatValues([ + "private-browsing-shortcut-text-2", + ]); + await shellService.createShortcut( + exe, + [], + desc, + exe, + // The code we're calling indexes from 0 instead of 1 + PRIVATE_BROWSING_EXE_ICON_INDEX - 1, + winTaskbar.defaultPrivateGroupId, + "Programs", + desc + ".lnk", + appdir + ); + } + // We always set this as long as no exception has been thrown. This + // ensure that it is `true` both if we created one because it didn't + // exist, or if it already existed (most likely because it was created + // by the installer). This avoids the need to call `hasMatchingShortcut` + // again, which necessarily does pointless I/O. + Services.prefs.setBoolPref( + PREF_PRIVATE_BROWSING_SHORTCUT_CREATED, + true + ); + }, + }, + + // Report whether Firefox is the default handler for various files types, + // in particular, ".pdf". + { + name: "IsDefaultHandlerForPDF", + condition: AppConstants.platform == "win", + task: () => { + Services.telemetry.keyedScalarSet( + "os.environment.is_default_handler", + ".pdf", + lazy.ShellService.isDefaultHandlerFor(".pdf") + ); + }, + }, + + // Install built-in themes. We already installed the active built-in + // theme, if any, before UI startup. + { + name: "BuiltInThemes.ensureBuiltInThemes", + task: async () => { + await lazy.BuiltInThemes.ensureBuiltInThemes(); + }, + }, + + { + name: "WinTaskbarJumpList.startup", + condition: AppConstants.platform == "win", + task: () => { + // For Windows 7, initialize the jump list module. + const WINTASKBAR_CONTRACTID = "@mozilla.org/windows-taskbar;1"; + if ( + WINTASKBAR_CONTRACTID in Cc && + Cc[WINTASKBAR_CONTRACTID].getService(Ci.nsIWinTaskbar).available + ) { + const { WinTaskbarJumpList } = ChromeUtils.importESModule( + "resource:///modules/WindowsJumpLists.sys.mjs" + ); + WinTaskbarJumpList.startup(); + } + }, + }, + + // Report macOS Dock status + { + name: "MacDockSupport.isAppInDock", + condition: AppConstants.platform == "macosx", + task: () => { + try { + Services.telemetry.scalarSet( + "os.environment.is_kept_in_dock", + Cc["@mozilla.org/widget/macdocksupport;1"].getService( + Ci.nsIMacDockSupport + ).isAppInDock + ); + } catch (ex) { + console.error(ex); + } + }, + }, + + { + name: "BrowserGlue._maybeShowDefaultBrowserPrompt", + task: () => { + this._maybeShowDefaultBrowserPrompt(); + }, + }, + + { + name: "ScreenshotsUtils.initialize", + task: () => { + if ( + Services.prefs.getBoolPref("screenshots.browser.component.enabled") + ) { + lazy.ScreenshotsUtils.initialize(); + } + }, + }, + + { + name: "trackStartupCrashEndSetTimeout", + task: () => { + lazy.setTimeout(function () { + Services.tm.idleDispatchToMainThread( + Services.startup.trackStartupCrashEnd + ); + }, STARTUP_CRASHES_END_DELAY_MS); + }, + }, + + { + name: "handlerService.asyncInit", + task: () => { + let handlerService = Cc[ + "@mozilla.org/uriloader/handler-service;1" + ].getService(Ci.nsIHandlerService); + handlerService.asyncInit(); + }, + }, + + { + name: "RFPHelper.init", + task: () => { + lazy.RFPHelper.init(); + }, + }, + + { + name: "Blocklist.loadBlocklistAsync", + task: () => { + lazy.Blocklist.loadBlocklistAsync(); + }, + }, + + { + name: "TabUnloader.init", + task: () => { + lazy.TabUnloader.init(); + }, + }, + + // Run TRR performance measurements for DoH. + { + name: "doh-rollout.trrRacer.run", + task: () => { + let enabledPref = "doh-rollout.trrRace.enabled"; + let completePref = "doh-rollout.trrRace.complete"; + + if (Services.prefs.getBoolPref(enabledPref, false)) { + if (!Services.prefs.getBoolPref(completePref, false)) { + new lazy.TRRRacer().run(() => { + Services.prefs.setBoolPref(completePref, true); + }); + } + } else { + Services.prefs.addObserver(enabledPref, function observer() { + if (Services.prefs.getBoolPref(enabledPref, false)) { + Services.prefs.removeObserver(enabledPref, observer); + + if (!Services.prefs.getBoolPref(completePref, false)) { + new lazy.TRRRacer().run(() => { + Services.prefs.setBoolPref(completePref, true); + }); + } + } + }); + } + }, + }, + + // FOG doesn't need to be initialized _too_ early because it has a + // pre-init buffer. + { + name: "initializeFOG", + task: () => { + Services.fog.initializeFOG(); + + // Register Glean to listen for experiment updates releated to the + // "gleanInternalSdk" feature defined in the t/c/nimbus/FeatureManifest.yaml + // This feature is intended for internal Glean use only. For features wishing + // to set a remote metric configuration, please use the "glean" feature for + // the purpose of setting the data-control-plane features via Server Knobs. + lazy.NimbusFeatures.gleanInternalSdk.onUpdate(() => { + let cfg = lazy.NimbusFeatures.gleanInternalSdk.getVariable( + "gleanMetricConfiguration" + ); + Services.fog.setMetricsFeatureConfig(JSON.stringify(cfg)); + }); + + // Register Glean to listen for experiment updates releated to the + // "glean" feature defined in the t/c/nimbus/FeatureManifest.yaml + lazy.NimbusFeatures.glean.onUpdate(() => { + let cfg = lazy.NimbusFeatures.glean.getVariable( + "gleanMetricConfiguration" + ); + Services.fog.setMetricsFeatureConfig(JSON.stringify(cfg)); + }); + }, + }, + + // Add the import button if this is the first startup. + { + name: "PlacesUIUtils.ImportButton", + task: async () => { + // First check if we've already added the import button, in which + // case we should check for events indicating we can remove it. + if ( + Services.prefs.getBoolPref( + "browser.bookmarks.addedImportButton", + false + ) + ) { + lazy.PlacesUIUtils.removeImportButtonWhenImportSucceeds(); + return; + } + + // Otherwise, check if this is a new profile where we need to add it. + // `maybeAddImportButton` will call + // `removeImportButtonWhenImportSucceeds`itself if/when it adds the + // button. Doing things in this order avoids listening for removal + // more than once. + if ( + this._isNewProfile && + // Not in automation: the button changes CUI state, breaking tests + !Cu.isInAutomation + ) { + await lazy.PlacesUIUtils.maybeAddImportButton(); + } + }, + }, + + { + name: "ASRouterNewTabHook.createInstance", + task: () => { + lazy.ASRouterNewTabHook.createInstance(lazy.ASRouterDefaultConfig()); + }, + }, + + { + name: "BackgroundUpdate", + condition: AppConstants.MOZ_UPDATE_AGENT, + task: async () => { + // Never in automation! This is close to + // `UpdateService.disabledForTesting`, but without creating the + // service, which can perform a good deal of I/O in order to log its + // state. Since this is in the startup path, we avoid all of that. + let disabledForTesting = + (Cu.isInAutomation || + lazy.Marionette.running || + lazy.RemoteAgent.running) && + Services.prefs.getBoolPref("app.update.disabledForTesting", false); + if (!disabledForTesting) { + try { + await lazy.BackgroundUpdate.scheduleFirefoxMessagingSystemTargetingSnapshotting(); + } catch (e) { + console.error( + "There was an error scheduling Firefox Messaging System targeting snapshotting: ", + e + ); + } + await lazy.BackgroundUpdate.maybeScheduleBackgroundUpdateTask(); + } + }, + }, + + // Login detection service is used in fission to identify high value sites. + { + name: "LoginDetection.init", + task: () => { + let loginDetection = Cc[ + "@mozilla.org/login-detection-service;1" + ].createInstance(Ci.nsILoginDetectionService); + loginDetection.init(); + }, + }, + + { + name: "BrowserGlue._collectTelemetryPiPEnabled", + task: () => { + this._collectTelemetryPiPEnabled(); + }, + }, + // Schedule a sync (if enabled) after we've loaded + { + name: "WeaveService", + task: async () => { + if (lazy.WeaveService.enabled) { + await lazy.WeaveService.whenLoaded(); + lazy.WeaveService.Weave.Service.scheduler.autoConnect(); + } + }, + }, + + { + name: "unblock-untrusted-modules-thread", + condition: AppConstants.platform == "win", + task: () => { + Services.obs.notifyObservers( + null, + "unblock-untrusted-modules-thread" + ); + }, + }, + + { + name: "UpdateListener.maybeShowUnsupportedNotification", + condition: AppConstants.MOZ_UPDATER, + task: () => { + lazy.UpdateListener.maybeShowUnsupportedNotification(); + }, + }, + + { + name: "QuickSuggest.init", + task: () => { + lazy.QuickSuggest.init(); + }, + }, + + { + name: "DAPTelemetrySender.startup", + condition: + lazy.TelemetryUtils.isTelemetryEnabled && + lazy.NimbusFeatures.dapTelemetry.getVariable("enabled"), + task: () => { + lazy.DAPTelemetrySender.startup(); + }, + }, + + { + name: "ShoppingUtils.init", + task: () => { + lazy.ShoppingUtils.init(); + }, + }, + + { + // Starts the JSOracle process for ORB JavaScript validation, if it hasn't started already. + name: "start-orb-javascript-oracle", + task: () => { + ChromeUtils.ensureJSOracleStarted(); + }, + }, + + { + name: "SearchSERPDomainToCategoriesMap.init", + task: () => { + lazy.SearchSERPDomainToCategoriesMap.init().catch(console.error); + }, + }, + + { + name: "browser-startup-idle-tasks-finished", + task: () => { + // Use idleDispatch a second time to run this after the per-window + // idle tasks. + ChromeUtils.idleDispatch(() => { + Services.obs.notifyObservers( + null, + "browser-startup-idle-tasks-finished" + ); + BrowserInitState._resolveStartupIdleTask(); + }); + }, + }, + // Do NOT add anything after idle tasks finished. + ]; + + for (let task of idleTasks) { + if ("condition" in task && !task.condition) { + continue; + } + + ChromeUtils.idleDispatch( + () => { + if (!Services.startup.shuttingDown) { + let startTime = Cu.now(); + try { + task.task(); + } catch (ex) { + console.error(ex); + } finally { + ChromeUtils.addProfilerMarker( + "startupIdleTask", + startTime, + task.name + ); + } + } + }, + task.timeout ? { timeout: task.timeout } : undefined + ); + } + }, + + /** + * Use this function as an entry point to schedule tasks that we hope + * to run once per session, at any arbitrary point in time, and which we + * are okay with sometimes not running at all. + * + * This function will be called from an idle observer. Check the value of + * LATE_TASKS_IDLE_TIME_SEC to see the current value for this idle + * observer. + * + * Note: this function may never be called if the user is never idle for the + * requisite time (LATE_TASKS_IDLE_TIME_SEC). Be certain before adding + * something here that it's okay that it never be run. + */ + _scheduleBestEffortUserIdleTasks() { + const idleTasks = [ + function primaryPasswordTelemetry() { + // Telemetry for primary-password - we do this after a delay as it + // can cause IO if NSS/PSM has not already initialized. + let tokenDB = Cc["@mozilla.org/security/pk11tokendb;1"].getService( + Ci.nsIPK11TokenDB + ); + let token = tokenDB.getInternalKeyToken(); + let mpEnabled = token.hasPassword; + if (mpEnabled) { + Services.telemetry + .getHistogramById("MASTER_PASSWORD_ENABLED") + .add(mpEnabled); + } + }, + + function GMPInstallManagerSimpleCheckAndInstall() { + let { GMPInstallManager } = ChromeUtils.importESModule( + "resource://gre/modules/GMPInstallManager.sys.mjs" + ); + this._gmpInstallManager = new GMPInstallManager(); + // We don't really care about the results, if someone is interested they + // can check the log. + this._gmpInstallManager.simpleCheckAndInstall().catch(() => {}); + }.bind(this), + + function RemoteSettingsInit() { + lazy.RemoteSettings.init(); + this._addBreachesSyncHandler(); + }.bind(this), + + function PublicSuffixListInit() { + lazy.PublicSuffixList.init(); + }, + + function RemoteSecuritySettingsInit() { + lazy.RemoteSecuritySettings.init(); + }, + + function CorroborateInit() { + if (Services.prefs.getBoolPref("corroborator.enabled", false)) { + lazy.Corroborate.init().catch(console.error); + } + }, + + function BrowserUsageTelemetryReportProfileCount() { + lazy.BrowserUsageTelemetry.reportProfileCount(); + }, + + function reportAllowedAppSources() { + lazy.OsEnvironment.reportAllowedAppSources(); + }, + + function searchBackgroundChecks() { + Services.search.runBackgroundChecks(); + }, + + function reportInstallationTelemetry() { + lazy.BrowserUsageTelemetry.reportInstallationTelemetry(); + }, + + RunOSKeyStoreSelfTest, + ]; + + for (let task of idleTasks) { + ChromeUtils.idleDispatch(async () => { + if (!Services.startup.shuttingDown) { + let startTime = Cu.now(); + try { + await task(); + } catch (ex) { + console.error(ex); + } finally { + ChromeUtils.addProfilerMarker( + "startupLateIdleTask", + startTime, + task.name + ); + } + } + }); + } + }, + + _addBreachesSyncHandler() { + if ( + Services.prefs.getBoolPref( + "signon.management.page.breach-alerts.enabled", + false + ) + ) { + lazy + .RemoteSettings(lazy.LoginBreaches.REMOTE_SETTINGS_COLLECTION) + .on("sync", async event => { + await lazy.LoginBreaches.update(event.data.current); + }); + } + }, + + _addBreachAlertsPrefObserver() { + const BREACH_ALERTS_PREF = "signon.management.page.breach-alerts.enabled"; + const clearVulnerablePasswordsIfBreachAlertsDisabled = async function () { + if (!Services.prefs.getBoolPref(BREACH_ALERTS_PREF)) { + await lazy.LoginBreaches.clearAllPotentiallyVulnerablePasswords(); + } + }; + clearVulnerablePasswordsIfBreachAlertsDisabled(); + Services.prefs.addObserver( + BREACH_ALERTS_PREF, + clearVulnerablePasswordsIfBreachAlertsDisabled + ); + }, + + _quitSource: "unknown", + _registerQuitSource(source) { + this._quitSource = source; + }, + + _onQuitRequest: function BG__onQuitRequest(aCancelQuit, aQuitType) { + // If user has already dismissed quit request, then do nothing + if (aCancelQuit instanceof Ci.nsISupportsPRBool && aCancelQuit.data) { + return; + } + + // There are several cases where we won't show a dialog here: + // 1. There is only 1 tab open in 1 window + // 2. browser.warnOnQuit == false + // 3. The browser is currently in Private Browsing mode + // 4. The browser will be restarted. + // 5. The user has automatic session restore enabled and + // browser.sessionstore.warnOnQuit is not set to true. + // 6. The user doesn't have automatic session restore enabled + // and browser.tabs.warnOnClose is not set to true. + // + // Otherwise, we will show the "closing multiple tabs" dialog. + // + // aQuitType == "lastwindow" is overloaded. "lastwindow" is used to indicate + // "the last window is closing but we're not quitting (a non-browser window is open)" + // and also "we're quitting by closing the last window". + + if (aQuitType == "restart" || aQuitType == "os-restart") { + return; + } + + // browser.warnOnQuit is a hidden global boolean to override all quit prompts. + if (!Services.prefs.getBoolPref("browser.warnOnQuit")) { + return; + } + + let windowcount = 0; + let pagecount = 0; + let pinnedcount = 0; + for (let win of lazy.BrowserWindowTracker.orderedWindows) { + if (win.closed) { + continue; + } + windowcount++; + let tabbrowser = win.gBrowser; + if (tabbrowser) { + pinnedcount += tabbrowser._numPinnedTabs; + pagecount += tabbrowser.visibleTabs.length - tabbrowser._numPinnedTabs; + } + } + + // No windows open so no need for a warning. + if (!windowcount) { + return; + } + + // browser.warnOnQuitShortcut is checked when quitting using the shortcut key. + // The warning will appear even when only one window/tab is open. For other + // methods of quitting, the warning only appears when there is more than one + // window or tab open. + let shouldWarnForShortcut = + this._quitSource == "shortcut" && + Services.prefs.getBoolPref("browser.warnOnQuitShortcut"); + let shouldWarnForTabs = + pagecount >= 2 && Services.prefs.getBoolPref("browser.tabs.warnOnClose"); + if (!shouldWarnForTabs && !shouldWarnForShortcut) { + return; + } + + if (!aQuitType) { + aQuitType = "quit"; + } + + let win = lazy.BrowserWindowTracker.getTopWindow(); + + // Our prompt for quitting is most important, so replace others. + win.gDialogBox.replaceDialogIfOpen(); + + let titleId, buttonLabelId; + if (windowcount > 1) { + // More than 1 window. Compose our own message. + titleId = { + id: "tabbrowser-confirm-close-windows-title", + args: { windowCount: windowcount }, + }; + buttonLabelId = "tabbrowser-confirm-close-windows-button"; + } else if (shouldWarnForShortcut) { + titleId = "tabbrowser-confirm-close-tabs-with-key-title"; + buttonLabelId = "tabbrowser-confirm-close-tabs-with-key-button"; + } else { + titleId = { + id: "tabbrowser-confirm-close-tabs-title", + args: { tabCount: pagecount }, + }; + buttonLabelId = "tabbrowser-confirm-close-tabs-button"; + } + + // The checkbox label is different depending on whether the shortcut + // was used to quit or not. + let checkboxLabelId; + if (shouldWarnForShortcut) { + const quitKeyElement = win.document.getElementById("key_quitApplication"); + const quitKey = lazy.ShortcutUtils.prettifyShortcut(quitKeyElement); + checkboxLabelId = { + id: "tabbrowser-confirm-close-tabs-with-key-checkbox", + args: { quitKey }, + }; + } else { + checkboxLabelId = "tabbrowser-confirm-close-tabs-checkbox"; + } + + const [title, buttonLabel, checkboxLabel] = + win.gBrowser.tabLocalization.formatMessagesSync([ + titleId, + buttonLabelId, + checkboxLabelId, + ]); + + let warnOnClose = { value: true }; + let flags = + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 + + Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1; + // buttonPressed will be 0 for closing, 1 for cancel (don't close/quit) + let buttonPressed = Services.prompt.confirmEx( + win, + title.value, + null, + flags, + buttonLabel.value, + null, + null, + checkboxLabel.value, + warnOnClose + ); + Services.telemetry.setEventRecordingEnabled("close_tab_warning", true); + let warnCheckbox = warnOnClose.value ? "checked" : "unchecked"; + + let sessionWillBeRestored = + Services.prefs.getIntPref("browser.startup.page") == 3 || + Services.prefs.getBoolPref("browser.sessionstore.resume_session_once"); + Services.telemetry.recordEvent( + "close_tab_warning", + "shown", + "application", + null, + { + source: this._quitSource, + button: buttonPressed == 0 ? "close" : "cancel", + warn_checkbox: warnCheckbox, + closing_wins: "" + windowcount, + closing_tabs: "" + (pagecount + pinnedcount), + will_restore: sessionWillBeRestored ? "yes" : "no", + } + ); + + // If the user has unticked the box, and has confirmed closing, stop showing + // the warning. + if (buttonPressed == 0 && !warnOnClose.value) { + if (shouldWarnForShortcut) { + Services.prefs.setBoolPref("browser.warnOnQuitShortcut", false); + } else { + Services.prefs.setBoolPref("browser.tabs.warnOnClose", false); + } + } + + this._quitSource = "unknown"; + + aCancelQuit.data = buttonPressed != 0; + }, + + /** + * Initialize Places + * - imports the bookmarks html file if bookmarks database is empty, try to + * restore bookmarks from a JSON backup if the backend indicates that the + * database was corrupt. + * + * These prefs can be set up by the frontend: + * + * WARNING: setting these preferences to true will overwite existing bookmarks + * + * - browser.places.importBookmarksHTML + * Set to true will import the bookmarks.html file from the profile folder. + * - browser.bookmarks.restore_default_bookmarks + * Set to true by safe-mode dialog to indicate we must restore default + * bookmarks. + */ + _initPlaces: function BG__initPlaces(aInitialMigrationPerformed) { + if (this._placesInitialized) { + throw new Error("Cannot initialize Places more than once"); + } + this._placesInitialized = true; + + // We must instantiate the history service since it will tell us if we + // need to import or restore bookmarks due to first-run, corruption or + // forced migration (due to a major schema change). + // If the database is corrupt or has been newly created we should + // import bookmarks. + let dbStatus = lazy.PlacesUtils.history.databaseStatus; + + // Show a notification with a "more info" link for a locked places.sqlite. + if (dbStatus == lazy.PlacesUtils.history.DATABASE_STATUS_LOCKED) { + // Note: initPlaces should always happen when the first window is ready, + // in any case, better safe than sorry. + this._firstWindowReady.then(() => { + this._showPlacesLockedNotificationBox(); + this._placesBrowserInitComplete = true; + Services.obs.notifyObservers(null, "places-browser-init-complete"); + }); + return; + } + + let importBookmarks = + !aInitialMigrationPerformed && + (dbStatus == lazy.PlacesUtils.history.DATABASE_STATUS_CREATE || + dbStatus == lazy.PlacesUtils.history.DATABASE_STATUS_CORRUPT); + + // Check if user or an extension has required to import bookmarks.html + let importBookmarksHTML = false; + try { + importBookmarksHTML = Services.prefs.getBoolPref( + "browser.places.importBookmarksHTML" + ); + if (importBookmarksHTML) { + importBookmarks = true; + } + } catch (ex) {} + + // Support legacy bookmarks.html format for apps that depend on that format. + let autoExportHTML = Services.prefs.getBoolPref( + "browser.bookmarks.autoExportHTML", + false + ); // Do not export. + if (autoExportHTML) { + // Sqlite.sys.mjs and Places shutdown happen at profile-before-change, thus, + // to be on the safe side, this should run earlier. + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + "Places: export bookmarks.html", + () => + lazy.BookmarkHTMLUtils.exportToFile( + lazy.BookmarkHTMLUtils.defaultPath + ) + ); + } + + (async () => { + // Check if Safe Mode or the user has required to restore bookmarks from + // default profile's bookmarks.html + let restoreDefaultBookmarks = false; + try { + restoreDefaultBookmarks = Services.prefs.getBoolPref( + "browser.bookmarks.restore_default_bookmarks" + ); + if (restoreDefaultBookmarks) { + // Ensure that we already have a bookmarks backup for today. + await this._backupBookmarks(); + importBookmarks = true; + } + } catch (ex) {} + + // If the user did not require to restore default bookmarks, or import + // from bookmarks.html, we will try to restore from JSON + if (importBookmarks && !restoreDefaultBookmarks && !importBookmarksHTML) { + // get latest JSON backup + let lastBackupFile = await lazy.PlacesBackups.getMostRecentBackup(); + if (lastBackupFile) { + // restore from JSON backup + await lazy.BookmarkJSONUtils.importFromFile(lastBackupFile, { + replace: true, + source: lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, + }); + importBookmarks = false; + } else { + // We have created a new database but we don't have any backup available + importBookmarks = true; + if (await IOUtils.exists(lazy.BookmarkHTMLUtils.defaultPath)) { + // If bookmarks.html is available in current profile import it... + importBookmarksHTML = true; + } else { + // ...otherwise we will restore defaults + restoreDefaultBookmarks = true; + } + } + } + + // Import default bookmarks when necessary. + // Otherwise, if any kind of import runs, default bookmarks creation should be + // delayed till the import operations has finished. Not doing so would + // cause them to be overwritten by the newly imported bookmarks. + if (!importBookmarks) { + // Now apply distribution customized bookmarks. + // This should always run after Places initialization. + try { + await this._distributionCustomizer.applyBookmarks(); + } catch (e) { + console.error(e); + } + } else { + // An import operation is about to run. + let bookmarksUrl = null; + if (restoreDefaultBookmarks) { + // User wants to restore the default set of bookmarks shipped with the + // browser, those that new profiles start with. + bookmarksUrl = "chrome://browser/content/default-bookmarks.html"; + } else if (await IOUtils.exists(lazy.BookmarkHTMLUtils.defaultPath)) { + bookmarksUrl = PathUtils.toFileURI( + lazy.BookmarkHTMLUtils.defaultPath + ); + } + + if (bookmarksUrl) { + // Import from bookmarks.html file. + try { + if ( + Services.policies.isAllowed("defaultBookmarks") && + // Default bookmarks are imported after startup, and they may + // influence the outcome of tests, thus it's possible to use + // this test-only pref to skip the import. + !( + Cu.isInAutomation && + Services.prefs.getBoolPref( + "browser.bookmarks.testing.skipDefaultBookmarksImport", + false + ) + ) + ) { + await lazy.BookmarkHTMLUtils.importFromURL(bookmarksUrl, { + replace: true, + source: lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, + }); + } + } catch (e) { + console.error("Bookmarks.html file could be corrupt. ", e); + } + try { + // Now apply distribution customized bookmarks. + // This should always run after Places initialization. + await this._distributionCustomizer.applyBookmarks(); + } catch (e) { + console.error(e); + } + } else { + console.error(new Error("Unable to find bookmarks.html file.")); + } + + // Reset preferences, so we won't try to import again at next run + if (importBookmarksHTML) { + Services.prefs.setBoolPref( + "browser.places.importBookmarksHTML", + false + ); + } + if (restoreDefaultBookmarks) { + Services.prefs.setBoolPref( + "browser.bookmarks.restore_default_bookmarks", + false + ); + } + } + + // Initialize bookmark archiving on idle. + // If the last backup has been created before the last browser session, + // and is days old, be more aggressive with the idle timer. + let idleTime = BOOKMARKS_BACKUP_IDLE_TIME_SEC; + if (!(await lazy.PlacesBackups.hasRecentBackup())) { + idleTime /= 2; + } + this._userIdleService.addIdleObserver(this, idleTime); + this._bookmarksBackupIdleTime = idleTime; + + if (this._isNewProfile) { + // New profiles may have existing bookmarks (imported from another browser or + // copied into the profile) and we want to show the bookmark toolbar for them + // in some cases. + await lazy.PlacesUIUtils.maybeToggleBookmarkToolbarVisibility(); + } + })() + .catch(ex => { + console.error(ex); + }) + .then(() => { + // NB: deliberately after the catch so that we always do this, even if + // we threw halfway through initializing in the Task above. + this._placesBrowserInitComplete = true; + Services.obs.notifyObservers(null, "places-browser-init-complete"); + }); + }, + + /** + * If a backup for today doesn't exist, this creates one. + */ + _backupBookmarks: function BG__backupBookmarks() { + return (async function () { + let lastBackupFile = await lazy.PlacesBackups.getMostRecentBackup(); + // Should backup bookmarks if there are no backups or the maximum + // interval between backups elapsed. + if ( + !lastBackupFile || + new Date() - lazy.PlacesBackups.getDateForFile(lastBackupFile) > + BOOKMARKS_BACKUP_MIN_INTERVAL_DAYS * 86400000 + ) { + let maxBackups = Services.prefs.getIntPref( + "browser.bookmarks.max_backups" + ); + await lazy.PlacesBackups.create(maxBackups); + } + })(); + }, + + /** + * Show the notificationBox for a locked places database. + */ + _showPlacesLockedNotificationBox: + async function BG__showPlacesLockedNotificationBox() { + var win = lazy.BrowserWindowTracker.getTopWindow(); + var buttons = [{ supportPage: "places-locked" }]; + + var notifyBox = win.gBrowser.getNotificationBox(); + var notification = await notifyBox.appendNotification( + "places-locked", + { + label: { "l10n-id": "places-locked-prompt" }, + priority: win.gNotificationBox.PRIORITY_CRITICAL_MEDIUM, + }, + buttons + ); + notification.persistence = -1; // Until user closes it + }, + + _onThisDeviceConnected() { + const [title, body] = lazy.accountsL10n.formatValuesSync([ + "account-connection-title", + "account-connection-connected", + ]); + + let clickCallback = (subject, topic, data) => { + if (topic != "alertclickcallback") { + return; + } + this._openPreferences("sync"); + }; + this.AlertsService.showAlertNotification( + null, + title, + body, + true, + null, + clickCallback + ); + }, + + _migrateXULStoreForDocument(fromURL, toURL) { + Array.from(Services.xulStore.getIDsEnumerator(fromURL)).forEach(id => { + Array.from(Services.xulStore.getAttributeEnumerator(fromURL, id)).forEach( + attr => { + let value = Services.xulStore.getValue(fromURL, id, attr); + Services.xulStore.setValue(toURL, id, attr, value); + } + ); + }); + }, + + _migrateHashedKeysForXULStoreForDocument(docUrl) { + Array.from(Services.xulStore.getIDsEnumerator(docUrl)) + .filter(id => id.startsWith("place:")) + .forEach(id => { + Services.xulStore.removeValue(docUrl, id, "open"); + let hashedId = lazy.PlacesUIUtils.obfuscateUrlForXulStore(id); + Services.xulStore.setValue(docUrl, hashedId, "open", "true"); + }); + }, + + // eslint-disable-next-line complexity + _migrateUI() { + // Use an increasing number to keep track of the current migration state. + // Completely unrelated to the current Firefox release number. + const UI_VERSION = 142; + const BROWSER_DOCURL = AppConstants.BROWSER_CHROME_URL; + + if (!Services.prefs.prefHasUserValue("browser.migration.version")) { + // This is a new profile, nothing to migrate. + Services.prefs.setIntPref("browser.migration.version", UI_VERSION); + this._isNewProfile = true; + return; + } + + this._isNewProfile = false; + let currentUIVersion = Services.prefs.getIntPref( + "browser.migration.version" + ); + if (currentUIVersion >= UI_VERSION) { + return; + } + + let xulStore = Services.xulStore; + + if (currentUIVersion < 90) { + this._migrateXULStoreForDocument( + "chrome://browser/content/places/historySidebar.xul", + "chrome://browser/content/places/historySidebar.xhtml" + ); + this._migrateXULStoreForDocument( + "chrome://browser/content/places/places.xul", + "chrome://browser/content/places/places.xhtml" + ); + this._migrateXULStoreForDocument( + "chrome://browser/content/places/bookmarksSidebar.xul", + "chrome://browser/content/places/bookmarksSidebar.xhtml" + ); + } + + // Clear socks proxy values if they were shared from http, to prevent + // websocket breakage after bug 1577862 (see bug 969282). + if ( + currentUIVersion < 91 && + Services.prefs.getBoolPref("network.proxy.share_proxy_settings", false) && + Services.prefs.getIntPref("network.proxy.type", 0) == 1 + ) { + let httpProxy = Services.prefs.getCharPref("network.proxy.http", ""); + let httpPort = Services.prefs.getIntPref("network.proxy.http_port", 0); + let socksProxy = Services.prefs.getCharPref("network.proxy.socks", ""); + let socksPort = Services.prefs.getIntPref("network.proxy.socks_port", 0); + if (httpProxy && httpProxy == socksProxy && httpPort == socksPort) { + Services.prefs.setCharPref( + "network.proxy.socks", + Services.prefs.getCharPref("network.proxy.backup.socks", "") + ); + Services.prefs.setIntPref( + "network.proxy.socks_port", + Services.prefs.getIntPref("network.proxy.backup.socks_port", 0) + ); + } + } + + if (currentUIVersion < 92) { + // privacy.userContext.longPressBehavior pref was renamed and changed to a boolean + let longpress = Services.prefs.getIntPref( + "privacy.userContext.longPressBehavior", + 0 + ); + if (longpress == 1) { + Services.prefs.setBoolPref( + "privacy.userContext.newTabContainerOnLeftClick.enabled", + true + ); + } + } + + if (currentUIVersion < 93) { + // The Gecko Profiler Addon is now an internal component. Remove the old + // addon, and enable the new UI. + + function enableProfilerButton(wasAddonActive) { + // Enable the feature pref. This will add it to the customization palette, + // but not to the the navbar. + Services.prefs.setBoolPref( + "devtools.performance.popup.feature-flag", + true + ); + + if (wasAddonActive) { + const { ProfilerMenuButton } = ChromeUtils.importESModule( + "resource://devtools/client/performance-new/popup/menu-button.sys.mjs" + ); + if (!ProfilerMenuButton.isInNavbar()) { + // The profiler menu button is not enabled. Turn it on now. + const win = lazy.BrowserWindowTracker.getTopWindow(); + if (win && win.document) { + ProfilerMenuButton.addToNavbar(win.document); + } + } + } + } + + let addonPromise; + try { + addonPromise = lazy.AddonManager.getAddonByID( + "geckoprofiler@mozilla.com" + ); + } catch (error) { + console.error( + "Could not access the AddonManager to upgrade the profile. This is most " + + "likely because the upgrader is being run from an xpcshell test where " + + "the AddonManager is not initialized." + ); + } + Promise.resolve(addonPromise).then(addon => { + if (!addon) { + // Either the addon wasn't installed, or the call to getAddonByID failed. + return; + } + // Remove the old addon. + const wasAddonActive = addon.isActive; + addon + .uninstall() + .catch(console.error) + .then(() => enableProfilerButton(wasAddonActive)) + .catch(console.error); + }, console.error); + } + + // Clear unused socks proxy backup values - see bug 1625773. + if (currentUIVersion < 94) { + let backup = Services.prefs.getCharPref("network.proxy.backup.socks", ""); + let backupPort = Services.prefs.getIntPref( + "network.proxy.backup.socks_port", + 0 + ); + let socksProxy = Services.prefs.getCharPref("network.proxy.socks", ""); + let socksPort = Services.prefs.getIntPref("network.proxy.socks_port", 0); + if (backup == socksProxy) { + Services.prefs.clearUserPref("network.proxy.backup.socks"); + } + if (backupPort == socksPort) { + Services.prefs.clearUserPref("network.proxy.backup.socks_port"); + } + } + + if (currentUIVersion < 95) { + const oldPrefName = "media.autoplay.enabled.user-gestures-needed"; + const oldPrefValue = Services.prefs.getBoolPref(oldPrefName, true); + const newPrefValue = oldPrefValue ? 0 : 1; + Services.prefs.setIntPref("media.autoplay.blocking_policy", newPrefValue); + Services.prefs.clearUserPref(oldPrefName); + } + + if (currentUIVersion < 96) { + const oldPrefName = "browser.urlbar.openViewOnFocus"; + const oldPrefValue = Services.prefs.getBoolPref(oldPrefName, true); + Services.prefs.setBoolPref( + "browser.urlbar.suggest.topsites", + oldPrefValue + ); + Services.prefs.clearUserPref(oldPrefName); + } + + if (currentUIVersion < 97) { + let userCustomizedWheelMax = Services.prefs.prefHasUserValue( + "general.smoothScroll.mouseWheel.durationMaxMS" + ); + let userCustomizedWheelMin = Services.prefs.prefHasUserValue( + "general.smoothScroll.mouseWheel.durationMinMS" + ); + + if (!userCustomizedWheelMin && !userCustomizedWheelMax) { + // If the user has an existing profile but hasn't customized the wheel + // animation duration, they will now get the new default values. This + // condition used to set a migrationPercent pref to 0, so that users + // upgrading an older profile would gradually have their wheel animation + // speed migrated to the new values. However, that "gradual migration" + // was phased out by FF 86, so we don't need to set that pref anymore. + } else if (userCustomizedWheelMin && !userCustomizedWheelMax) { + // If they customized just one of the two, save the old value for the + // other one as well, because the two values go hand-in-hand and we + // don't want to move just one to a new value and leave the other one + // at a customized value. In both of these cases, we leave the "migration + // complete" percentage at 100, because they have customized this and + // don't need any further migration. + Services.prefs.setIntPref( + "general.smoothScroll.mouseWheel.durationMaxMS", + 400 + ); + } else if (!userCustomizedWheelMin && userCustomizedWheelMax) { + // Same as above case, but for the other pref. + Services.prefs.setIntPref( + "general.smoothScroll.mouseWheel.durationMinMS", + 200 + ); + } else { + // The last remaining case is if they customized both values, in which + // case also don't need to do anything; the user's customized values + // will be retained and respected. + } + } + + if (currentUIVersion < 98) { + Services.prefs.clearUserPref("browser.search.cohort"); + } + + if (currentUIVersion < 99) { + Services.prefs.clearUserPref("security.tls.version.enable-deprecated"); + } + + if (currentUIVersion < 102) { + // In Firefox 83, we moved to a dynamic button, so it needs to be removed + // from default placement. This is done early enough that it doesn't + // impact adding new managed bookmarks. + const { CustomizableUI } = ChromeUtils.importESModule( + "resource:///modules/CustomizableUI.sys.mjs" + ); + CustomizableUI.removeWidgetFromArea("managed-bookmarks"); + } + + // We have to rerun these because we had to use 102 on beta. + // They were 101 and 102 before. + if (currentUIVersion < 103) { + // Set a pref if the bookmarks toolbar was already visible, + // so we can keep it visible when navigating away from newtab + let bookmarksToolbarWasVisible = + Services.xulStore.getValue( + BROWSER_DOCURL, + "PersonalToolbar", + "collapsed" + ) == "false"; + if (bookmarksToolbarWasVisible) { + // Migrate the user to the "always visible" value. See firefox.js for + // the other possible states. + Services.prefs.setCharPref( + "browser.toolbars.bookmarks.visibility", + "always" + ); + } + Services.xulStore.removeValue( + BROWSER_DOCURL, + "PersonalToolbar", + "collapsed" + ); + + Services.prefs.clearUserPref( + "browser.livebookmarks.migrationAttemptsLeft" + ); + } + + // For existing profiles, continue putting bookmarks in the + // "other bookmarks" folder. + if (currentUIVersion < 104) { + Services.prefs.setCharPref( + "browser.bookmarks.defaultLocation", + "unfiled" + ); + } + + // Renamed and flipped the logic of a pref to make its purpose more clear. + if (currentUIVersion < 105) { + const oldPrefName = "browser.urlbar.imeCompositionClosesPanel"; + const oldPrefValue = Services.prefs.getBoolPref(oldPrefName, true); + Services.prefs.setBoolPref( + "browser.urlbar.keepPanelOpenDuringImeComposition", + !oldPrefValue + ); + Services.prefs.clearUserPref(oldPrefName); + } + + // Initialize the new browser.urlbar.showSuggestionsBeforeGeneral pref. + if (currentUIVersion < 106) { + lazy.UrlbarPrefs.initializeShowSearchSuggestionsFirstPref(); + } + + if (currentUIVersion < 107) { + // Migrate old http URIs for mailto handlers to their https equivalents. + // The handler service will do this. We need to wait with migrating + // until the handler service has started up, so just set a pref here. + const kPref = "browser.handlers.migrations"; + // We might have set up another migration further up. Create an array, + // and drop empty strings resulting from the `split`: + let migrations = Services.prefs + .getCharPref(kPref, "") + .split(",") + .filter(x => !!x); + migrations.push("secure-mail"); + Services.prefs.setCharPref(kPref, migrations.join(",")); + } + + if (currentUIVersion < 108) { + // Migrate old ctrlTab pref to new ctrlTab pref + let defaultValue = false; + let oldPrefName = "browser.ctrlTab.recentlyUsedOrder"; + let oldPrefDefault = true; + // Use old pref value if the user used Ctrl+Tab before, elsewise use new default value + if (Services.prefs.getBoolPref("browser.engagement.ctrlTab.has-used")) { + let newPrefValue = Services.prefs.getBoolPref( + oldPrefName, + oldPrefDefault + ); + Services.prefs.setBoolPref( + "browser.ctrlTab.sortByRecentlyUsed", + newPrefValue + ); + } else { + Services.prefs.setBoolPref( + "browser.ctrlTab.sortByRecentlyUsed", + defaultValue + ); + } + } + + if (currentUIVersion < 109) { + // Migrate old pref to new pref + if ( + Services.prefs.prefHasUserValue("signon.recipes.remoteRecipesEnabled") + ) { + // Fetch the previous value of signon.recipes.remoteRecipesEnabled and assign it to signon.recipes.remoteRecipes.enabled. + Services.prefs.setBoolPref( + "signon.recipes.remoteRecipes.enabled", + Services.prefs.getBoolPref( + "signon.recipes.remoteRecipesEnabled", + true + ) + ); + //Then clear user pref + Services.prefs.clearUserPref("signon.recipes.remoteRecipesEnabled"); + } + } + + if (currentUIVersion < 120) { + // Migrate old titlebar bool pref to new int-based one. + const oldPref = "browser.tabs.drawInTitlebar"; + const newPref = "browser.tabs.inTitlebar"; + if (Services.prefs.prefHasUserValue(oldPref)) { + // We may have int prefs for builds between bug 1736518 and bug 1739539. + const oldPrefType = Services.prefs.getPrefType(oldPref); + if (oldPrefType == Services.prefs.PREF_BOOL) { + Services.prefs.setIntPref( + newPref, + Services.prefs.getBoolPref(oldPref) ? 1 : 0 + ); + } else { + Services.prefs.setIntPref( + newPref, + Services.prefs.getIntPref(oldPref) + ); + } + Services.prefs.clearUserPref(oldPref); + } + } + + if (currentUIVersion < 121) { + // Migrate stored uris and convert them to use hashed keys + this._migrateHashedKeysForXULStoreForDocument(BROWSER_DOCURL); + this._migrateHashedKeysForXULStoreForDocument( + "chrome://browser/content/places/bookmarksSidebar.xhtml" + ); + this._migrateHashedKeysForXULStoreForDocument( + "chrome://browser/content/places/historySidebar.xhtml" + ); + } + + if (currentUIVersion < 122) { + // Migrate xdg-desktop-portal pref from old to new prefs. + try { + const oldPref = "widget.use-xdg-desktop-portal"; + if (Services.prefs.getBoolPref(oldPref)) { + Services.prefs.setIntPref( + "widget.use-xdg-desktop-portal.file-picker", + 1 + ); + Services.prefs.setIntPref( + "widget.use-xdg-desktop-portal.mime-handler", + 1 + ); + } + Services.prefs.clearUserPref(oldPref); + } catch (ex) {} + } + + // Bug 1745248: Due to multiple backouts, do not use UI Version 123 + // as this version is most likely set for the Nightly channel + + if (currentUIVersion < 124) { + // Migrate "extensions.formautofill.available" and + // "extensions.formautofill.creditCards.available" from old to new prefs + const oldFormAutofillModule = "extensions.formautofill.available"; + const oldCreditCardsAvailable = + "extensions.formautofill.creditCards.available"; + const newCreditCardsAvailable = + "extensions.formautofill.creditCards.supported"; + const newAddressesAvailable = + "extensions.formautofill.addresses.supported"; + if (Services.prefs.prefHasUserValue(oldFormAutofillModule)) { + let moduleAvailability = Services.prefs.getCharPref( + oldFormAutofillModule + ); + if (moduleAvailability == "on") { + Services.prefs.setCharPref(newAddressesAvailable, moduleAvailability); + Services.prefs.setCharPref( + newCreditCardsAvailable, + Services.prefs.getBoolPref(oldCreditCardsAvailable) ? "on" : "off" + ); + } + + if (moduleAvailability == "off") { + Services.prefs.setCharPref( + newCreditCardsAvailable, + moduleAvailability + ); + Services.prefs.setCharPref(newAddressesAvailable, moduleAvailability); + } + } + + // after migrating, clear old prefs so we can remove them later. + Services.prefs.clearUserPref(oldFormAutofillModule); + Services.prefs.clearUserPref(oldCreditCardsAvailable); + } + + if (currentUIVersion < 125) { + // Bug 1756243 - Clear PiP cached coordinates since we changed their + // coordinate space. + const PIP_PLAYER_URI = + "chrome://global/content/pictureinpicture/player.xhtml"; + try { + for (let value of ["left", "top", "width", "height"]) { + Services.xulStore.removeValue( + PIP_PLAYER_URI, + "picture-in-picture", + value + ); + } + } catch (ex) { + console.error("Failed to clear XULStore PiP values: ", ex); + } + } + + function migrateXULAttributeToStyle(url, id, attr) { + try { + let value = Services.xulStore.getValue(url, id, attr); + if (value) { + Services.xulStore.setValue(url, id, "style", `${attr}: ${value}px;`); + } + } catch (ex) { + console.error(`Error migrating ${id}'s ${attr} value: `, ex); + } + } + + // Bug 1792748 used version 129 with a buggy variant of the sidebar width + // migration. This version is already in use in the nightly channel, so it + // shouldn't be used. + + // Bug 1793366: migrate sidebar persisted attribute from width to style. + if (currentUIVersion < 130) { + migrateXULAttributeToStyle(BROWSER_DOCURL, "sidebar-box", "width"); + } + + // Migration 131 was moved to 133 to allow for an uplift. + + if (currentUIVersion < 132) { + // These attributes are no longer persisted, thus remove them from xulstore. + for (let url of [ + "chrome://browser/content/places/bookmarkProperties.xhtml", + "chrome://browser/content/places/bookmarkProperties2.xhtml", + ]) { + for (let attr of ["width", "screenX", "screenY"]) { + xulStore.removeValue(url, "bookmarkproperties", attr); + } + } + } + + if (currentUIVersion < 133) { + xulStore.removeValue(BROWSER_DOCURL, "urlbar-container", "width"); + } + + // Migration 134 was removed because it was no longer necessary. + + if (currentUIVersion < 135 && AppConstants.platform == "linux") { + // Avoid changing titlebar setting for users that used to had it off. + try { + if (!Services.prefs.prefHasUserValue("browser.tabs.inTitlebar")) { + let de = Services.appinfo.desktopEnvironment; + let oldDefault = de.includes("gnome") || de.includes("pantheon"); + if (!oldDefault) { + Services.prefs.setIntPref("browser.tabs.inTitlebar", 0); + } + } + } catch (e) { + console.error("Error migrating tabsInTitlebar setting", e); + } + } + + if (currentUIVersion < 136) { + migrateXULAttributeToStyle( + "chrome://browser/content/places/places.xhtml", + "placesList", + "width" + ); + } + + if (currentUIVersion < 137) { + // The default value for enabling smooth scrolls is now false if the + // user prefers reduced motion. If the value was previously set, do + // not reset it, but if it was not explicitly set preserve the old + // default value. + if ( + !Services.prefs.prefHasUserValue("general.smoothScroll") && + Services.appinfo.prefersReducedMotion + ) { + Services.prefs.setBoolPref("general.smoothScroll", true); + } + } + + if (currentUIVersion < 138) { + // Bug 1757297: Change scheme of all existing 'https-only-load-insecure' + // permissions with https scheme to http scheme. + try { + Services.perms + .getAllByTypes(["https-only-load-insecure"]) + .filter(permission => permission.principal.schemeIs("https")) + .forEach(permission => { + const capability = permission.capability; + const uri = permission.principal.URI.mutate() + .setScheme("http") + .finalize(); + const principal = + Services.scriptSecurityManager.createContentPrincipal(uri, {}); + Services.perms.removePermission(permission); + Services.perms.addFromPrincipal( + principal, + "https-only-load-insecure", + capability + ); + }); + } catch (e) { + console.error("Error migrating https-only-load-insecure permission", e); + } + } + + if (currentUIVersion < 139) { + // Reset the default permissions to ALLOW_ACTION to rollback issues for + // affected users, see Bug 1579517 + // originInfo in the format [origin, type] + [ + ["https://www.mozilla.org", "uitour"], + ["https://support.mozilla.org", "uitour"], + ["about:home", "uitour"], + ["about:newtab", "uitour"], + ["https://addons.mozilla.org", "install"], + ["https://support.mozilla.org", "remote-troubleshooting"], + ["about:welcome", "autoplay-media"], + ].forEach(originInfo => { + // Reset permission on the condition that it is set to + // UNKNOWN_ACTION, we want to prevent resetting user + // manipulated permissions + if ( + Services.perms.UNKNOWN_ACTION == + Services.perms.testPermissionFromPrincipal( + Services.scriptSecurityManager.createContentPrincipalFromOrigin( + originInfo[0] + ), + originInfo[1] + ) + ) { + // Adding permissions which have default values does not create + // new permissions, but rather remove the UNKNOWN_ACTION permission + // overrides. User's not affected by Bug 1579517 will not be affected by this addition. + Services.perms.addFromPrincipal( + Services.scriptSecurityManager.createContentPrincipalFromOrigin( + originInfo[0] + ), + originInfo[1], + Services.perms.ALLOW_ACTION + ); + } + }); + } + + if (currentUIVersion < 140) { + // Remove browser.fixup.alternate.enabled pref in Bug 1850902. + Services.prefs.clearUserPref("browser.fixup.alternate.enabled"); + } + + if (currentUIVersion < 141) { + for (const filename of ["signons.sqlite", "signons.sqlite.corrupt"]) { + const filePath = PathUtils.join(PathUtils.profileDir, filename); + IOUtils.remove(filePath, { ignoreAbsent: true }).catch(console.error); + } + } + + if (currentUIVersion < 142) { + // Bug 1860392 - Remove incorrectly persisted theming values from sidebar style. + try { + let value = xulStore.getValue(BROWSER_DOCURL, "sidebar-box", "style"); + if (value) { + // Remove custom properties. + value = value + .split(";") + .filter(v => !v.trim().startsWith("--")) + .join(";"); + xulStore.setValue(BROWSER_DOCURL, "sidebar-box", "style", value); + } + } catch (ex) { + console.error(ex); + } + } + + // Update the migration version. + Services.prefs.setIntPref("browser.migration.version", UI_VERSION); + }, + + async _showUpgradeDialog() { + const data = await lazy.OnboardingMessageProvider.getUpgradeMessage(); + const { gBrowser } = lazy.BrowserWindowTracker.getTopWindow(); + + // We'll be adding a new tab open the tab-modal dialog in. + let tab; + + const upgradeTabsProgressListener = { + onLocationChange(aBrowser) { + if (aBrowser === tab.linkedBrowser) { + lazy.setTimeout(() => { + // We're now far enough along in the load that we no longer have to + // worry about a call to onLocationChange triggering SubDialog.abort, + // so display the dialog + const config = { + type: "SHOW_SPOTLIGHT", + data, + }; + lazy.SpecialMessageActions.handleAction(config, tab.linkedBrowser); + + gBrowser.removeTabsProgressListener(upgradeTabsProgressListener); + }, 0); + } + }, + }; + + // Make sure we're ready to show the dialog once onLocationChange gets + // called. + gBrowser.addTabsProgressListener(upgradeTabsProgressListener); + + tab = gBrowser.addTrustedTab("about:home", { + relatedToCurrent: true, + }); + + gBrowser.selectedTab = tab; + }, + + async _showAboutWelcomeModal() { + const { gBrowser } = lazy.BrowserWindowTracker.getTopWindow(); + const data = await lazy.NimbusFeatures.aboutwelcome.getAllVariables(); + + const config = { + type: "SHOW_SPOTLIGHT", + data: { + content: { + template: "multistage", + id: data?.id || "ABOUT_WELCOME_MODAL", + backdrop: data?.backdrop, + screens: data?.screens, + UTMTerm: data?.UTMTerm, + }, + }, + }; + + lazy.SpecialMessageActions.handleAction(config, gBrowser); + }, + + async _maybeShowDefaultBrowserPrompt() { + // Highest priority is about:welcome window modal experiment + // Second highest priority is the upgrade dialog, which can include a "primary + // browser" request and is limited in various ways, e.g., major upgrades. + if ( + lazy.BrowserHandler.firstRunProfile && + lazy.NimbusFeatures.aboutwelcome.getVariable("showModal") + ) { + this._showAboutWelcomeModal(); + return; + } + const dialogVersion = 106; + const dialogVersionPref = "browser.startup.upgradeDialog.version"; + const dialogReason = await (async () => { + if (!lazy.BrowserHandler.majorUpgrade) { + return "not-major"; + } + const lastVersion = Services.prefs.getIntPref(dialogVersionPref, 0); + if (lastVersion > dialogVersion) { + return "newer-shown"; + } + if (lastVersion === dialogVersion) { + return "already-shown"; + } + + // Check the default branch as enterprise policies can set prefs there. + const defaultPrefs = Services.prefs.getDefaultBranch(""); + if ( + !defaultPrefs.getBoolPref( + "browser.messaging-system.whatsNewPanel.enabled", + true + ) + ) { + return "no-whatsNew"; + } + if (!defaultPrefs.getBoolPref("browser.aboutwelcome.enabled", true)) { + return "no-welcome"; + } + if (!Services.policies.isAllowed("postUpdateCustomPage")) { + return "disallow-postUpdate"; + } + + const useMROnboarding = + lazy.NimbusFeatures.majorRelease2022.getVariable("onboarding"); + const showUpgradeDialog = + useMROnboarding ?? + lazy.NimbusFeatures.upgradeDialog.getVariable("enabled"); + + return showUpgradeDialog ? "" : "disabled"; + })(); + + // Record why the dialog is showing or not. + Services.telemetry.setEventRecordingEnabled("upgrade_dialog", true); + Services.telemetry.recordEvent( + "upgrade_dialog", + "trigger", + "reason", + dialogReason || "satisfied" + ); + + // Show the upgrade dialog if allowed and remember the version. + if (!dialogReason) { + Services.prefs.setIntPref(dialogVersionPref, dialogVersion); + this._showUpgradeDialog(); + return; + } + + const willPrompt = await DefaultBrowserCheck.willCheckDefaultBrowser( + /* isStartupCheck */ true + ); + if (willPrompt) { + let win = lazy.BrowserWindowTracker.getTopWindow(); + DefaultBrowserCheck.prompt(win); + } else if (await lazy.QuickSuggest.maybeShowOnboardingDialog()) { + return; + } + + await lazy.ASRouter.waitForInitialized; + lazy.ASRouter.sendTriggerMessage({ + browser: + lazy.BrowserWindowTracker.getTopWindow()?.gBrowser.selectedBrowser, + // triggerId and triggerContext + id: "defaultBrowserCheck", + context: { willShowDefaultPrompt: willPrompt, source: "startup" }, + }); + }, + + /** + * Only show the infobar when canRestoreLastSession and the pref value == 1 + */ + async _maybeShowRestoreSessionInfoBar() { + let count = Services.prefs.getIntPref( + "browser.startup.couldRestoreSession.count", + 0 + ); + if (count < 0 || count >= 2) { + return; + } + if (count == 0) { + // We don't show the infobar right after the update which establishes this pref + // Increment the counter so we can consider it next time + Services.prefs.setIntPref( + "browser.startup.couldRestoreSession.count", + ++count + ); + return; + } + + const win = lazy.BrowserWindowTracker.getTopWindow(); + // We've restarted at least once; we will show the notification if possible. + // We can't do that if there's no session to restore, or this is a private window. + if ( + !lazy.SessionStore.canRestoreLastSession || + lazy.PrivateBrowsingUtils.isWindowPrivate(win) + ) { + return; + } + + Services.prefs.setIntPref( + "browser.startup.couldRestoreSession.count", + ++count + ); + + const messageFragment = win.document.createDocumentFragment(); + const message = win.document.createElement("span"); + const icon = win.document.createElement("img"); + icon.src = "chrome://browser/skin/menu.svg"; + icon.setAttribute("data-l10n-name", "icon"); + icon.className = "inline-icon"; + message.appendChild(icon); + messageFragment.appendChild(message); + win.document.l10n.setAttributes( + message, + "restore-session-startup-suggestion-message" + ); + + const buttons = [ + { + "l10n-id": "restore-session-startup-suggestion-button", + primary: true, + callback: () => { + win.PanelUI.selectAndMarkItem([ + "appMenu-history-button", + "appMenu-restoreSession", + ]); + }, + }, + ]; + + const notifyBox = win.gBrowser.getNotificationBox(); + const notification = await notifyBox.appendNotification( + "startup-restore-session-suggestion", + { + label: messageFragment, + priority: notifyBox.PRIORITY_INFO_MEDIUM, + }, + buttons + ); + // Don't allow it to be immediately hidden: + notification.timeout = Date.now() + 3000; + }, + + /** + * Open preferences even if there are no open windows. + */ + _openPreferences(...args) { + let chromeWindow = lazy.BrowserWindowTracker.getTopWindow(); + if (chromeWindow) { + chromeWindow.openPreferences(...args); + return; + } + + if (Services.appShell.hiddenDOMWindow.openPreferences) { + Services.appShell.hiddenDOMWindow.openPreferences(...args); + } + }, + + _openURLInNewWindow(url) { + let urlString = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + urlString.data = url; + return new Promise(resolve => { + let win = Services.ww.openWindow( + null, + AppConstants.BROWSER_CHROME_URL, + "_blank", + "chrome,all,dialog=no", + urlString + ); + win.addEventListener( + "load", + () => { + resolve(win); + }, + { once: true } + ); + }); + }, + + /** + * Called as an observer when Sync's "display URIs" notification is fired. + * + * We open the received URIs in background tabs. + */ + async _onDisplaySyncURIs(data) { + try { + // The payload is wrapped weirdly because of how Sync does notifications. + const URIs = data.wrappedJSObject.object; + + // win can be null, but it's ok, we'll assign it later in openTab() + let win = lazy.BrowserWindowTracker.getTopWindow({ private: false }); + + const openTab = async URI => { + let tab; + if (!win) { + win = await this._openURLInNewWindow(URI.uri); + let tabs = win.gBrowser.tabs; + tab = tabs[tabs.length - 1]; + } else { + tab = win.gBrowser.addWebTab(URI.uri); + } + tab.attention = true; + return tab; + }; + + const firstTab = await openTab(URIs[0]); + await Promise.all(URIs.slice(1).map(URI => openTab(URI))); + + const deviceName = URIs[0].sender && URIs[0].sender.name; + let titleL10nId, body; + if (URIs.length == 1) { + // Due to bug 1305895, tabs from iOS may not have device information, so + // we have separate strings to handle those cases. (See Also + // unnamedTabsArrivingNotificationNoDevice.body below) + titleL10nId = deviceName + ? { + id: "account-single-tab-arriving-from-device-title", + args: { deviceName }, + } + : { id: "account-single-tab-arriving-title" }; + // Use the page URL as the body. We strip the fragment and query (after + // the `?` and `#` respectively) to reduce size, and also format it the + // same way that the url bar would. + let url = URIs[0].uri.replace(/([?#]).*$/, "$1"); + const wasTruncated = url.length < URIs[0].uri.length; + url = lazy.BrowserUIUtils.trimURL(url); + if (wasTruncated) { + body = await lazy.accountsL10n.formatValue( + "account-single-tab-arriving-truncated-url", + { url } + ); + } else { + body = url; + } + } else { + titleL10nId = { id: "account-multiple-tabs-arriving-title" }; + const allKnownSender = URIs.every(URI => URI.sender != null); + const allSameDevice = + allKnownSender && + URIs.every(URI => URI.sender.id == URIs[0].sender.id); + let bodyL10nId; + if (allSameDevice) { + bodyL10nId = deviceName + ? "account-multiple-tabs-arriving-from-single-device" + : "account-multiple-tabs-arriving-from-unknown-device"; + } else { + bodyL10nId = "account-multiple-tabs-arriving-from-multiple-devices"; + } + + body = await lazy.accountsL10n.formatValue(bodyL10nId, { + deviceName, + tabCount: URIs.length, + }); + } + const title = await lazy.accountsL10n.formatValue(titleL10nId); + + const clickCallback = (obsSubject, obsTopic, obsData) => { + if (obsTopic == "alertclickcallback") { + win.gBrowser.selectedTab = firstTab; + } + }; + + // Specify an icon because on Windows no icon is shown at the moment + let imageURL; + if (AppConstants.platform == "win") { + imageURL = "chrome://branding/content/icon64.png"; + } + this.AlertsService.showAlertNotification( + imageURL, + title, + body, + true, + null, + clickCallback + ); + } catch (ex) { + console.error("Error displaying tab(s) received by Sync: ", ex); + } + }, + + async _onVerifyLoginNotification({ body, title, url }) { + let tab; + let imageURL; + if (AppConstants.platform == "win") { + imageURL = "chrome://branding/content/icon64.png"; + } + let win = lazy.BrowserWindowTracker.getTopWindow({ private: false }); + if (!win) { + win = await this._openURLInNewWindow(url); + let tabs = win.gBrowser.tabs; + tab = tabs[tabs.length - 1]; + } else { + tab = win.gBrowser.addWebTab(url); + } + tab.attention = true; + let clickCallback = (subject, topic, data) => { + if (topic != "alertclickcallback") { + return; + } + win.gBrowser.selectedTab = tab; + }; + + try { + this.AlertsService.showAlertNotification( + imageURL, + title, + body, + true, + null, + clickCallback + ); + } catch (ex) { + console.error("Error notifying of a verify login event: ", ex); + } + }, + + _onDeviceConnected(deviceName) { + const [title, body] = lazy.accountsL10n.formatValuesSync([ + { id: "account-connection-title" }, + deviceName + ? { id: "account-connection-connected-with", args: { deviceName } } + : { id: "account-connection-connected-with-noname" }, + ]); + + let clickCallback = async (subject, topic, data) => { + if (topic != "alertclickcallback") { + return; + } + let url = await lazy.FxAccounts.config.promiseManageDevicesURI( + "device-connected-notification" + ); + let win = lazy.BrowserWindowTracker.getTopWindow({ private: false }); + if (!win) { + this._openURLInNewWindow(url); + } else { + win.gBrowser.addWebTab(url); + } + }; + + try { + this.AlertsService.showAlertNotification( + null, + title, + body, + true, + null, + clickCallback + ); + } catch (ex) { + console.error("Error notifying of a new Sync device: ", ex); + } + }, + + _onDeviceDisconnected() { + const [title, body] = lazy.accountsL10n.formatValuesSync([ + "account-connection-title", + "account-connection-disconnected", + ]); + + let clickCallback = (subject, topic, data) => { + if (topic != "alertclickcallback") { + return; + } + this._openPreferences("sync"); + }; + this.AlertsService.showAlertNotification( + null, + title, + body, + true, + null, + clickCallback + ); + }, + + _updateFxaBadges(win) { + let fxaButton = win.document.getElementById("fxa-toolbar-menu-button"); + let badge = fxaButton?.querySelector(".toolbarbutton-badge"); + + let state = lazy.UIState.get(); + if ( + state.status == lazy.UIState.STATUS_LOGIN_FAILED || + state.status == lazy.UIState.STATUS_NOT_VERIFIED + ) { + // If the fxa toolbar button is in the toolbox, we display the notification + // on the fxa button instead of the app menu. + let navToolbox = win.document.getElementById("navigator-toolbox"); + let isFxAButtonShown = navToolbox.contains(fxaButton); + if (isFxAButtonShown) { + state.status == lazy.UIState.STATUS_LOGIN_FAILED + ? fxaButton?.setAttribute("badge-status", state.status) + : badge?.classList.add("feature-callout"); + } else { + lazy.AppMenuNotifications.showBadgeOnlyNotification( + "fxa-needs-authentication" + ); + } + } else { + fxaButton?.removeAttribute("badge-status"); + badge?.classList.remove("feature-callout"); + lazy.AppMenuNotifications.removeNotification("fxa-needs-authentication"); + } + }, + + _collectTelemetryPiPEnabled() { + Services.telemetry.setEventRecordingEnabled( + "pictureinpicture.settings", + true + ); + Services.telemetry.setEventRecordingEnabled("pictureinpicture", true); + + const TOGGLE_ENABLED_PREF = + "media.videocontrols.picture-in-picture.video-toggle.enabled"; + + const observe = (subject, topic, data) => { + const enabled = Services.prefs.getBoolPref(TOGGLE_ENABLED_PREF, false); + Services.telemetry.scalarSet("pictureinpicture.toggle_enabled", enabled); + + // Record events when preferences change + if (topic === "nsPref:changed") { + if (enabled) { + Services.telemetry.recordEvent( + "pictureinpicture.settings", + "enable", + "settings" + ); + } + } + }; + + Services.prefs.addObserver(TOGGLE_ENABLED_PREF, observe); + observe(); + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), +}; + +var ContentBlockingCategoriesPrefs = { + PREF_CB_CATEGORY: "browser.contentblocking.category", + PREF_STRICT_DEF: "browser.contentblocking.features.strict", + switchingCategory: false, + + setPrefExpectations() { + // The prefs inside CATEGORY_PREFS are initial values. + // If the pref remains null, then it will expect the default value. + // The "standard" category is defined as expecting all 5 default values. + this.CATEGORY_PREFS = { + strict: { + "network.cookie.cookieBehavior": null, + "network.cookie.cookieBehavior.pbmode": null, + "privacy.trackingprotection.pbmode.enabled": null, + "privacy.trackingprotection.enabled": null, + "privacy.trackingprotection.socialtracking.enabled": null, + "privacy.trackingprotection.fingerprinting.enabled": null, + "privacy.trackingprotection.cryptomining.enabled": null, + "privacy.trackingprotection.emailtracking.enabled": null, + "privacy.trackingprotection.emailtracking.pbmode.enabled": null, + "privacy.annotate_channels.strict_list.enabled": null, + "network.http.referer.disallowCrossSiteRelaxingDefault": null, + "network.http.referer.disallowCrossSiteRelaxingDefault.top_navigation": + null, + "privacy.partition.network_state.ocsp_cache": null, + "privacy.query_stripping.enabled": null, + "privacy.query_stripping.enabled.pbmode": null, + "privacy.fingerprintingProtection": null, + "privacy.fingerprintingProtection.pbmode": null, + }, + standard: { + "network.cookie.cookieBehavior": null, + "network.cookie.cookieBehavior.pbmode": null, + "privacy.trackingprotection.pbmode.enabled": null, + "privacy.trackingprotection.enabled": null, + "privacy.trackingprotection.socialtracking.enabled": null, + "privacy.trackingprotection.fingerprinting.enabled": null, + "privacy.trackingprotection.cryptomining.enabled": null, + "privacy.trackingprotection.emailtracking.enabled": null, + "privacy.trackingprotection.emailtracking.pbmode.enabled": null, + "privacy.annotate_channels.strict_list.enabled": null, + "network.http.referer.disallowCrossSiteRelaxingDefault": null, + "network.http.referer.disallowCrossSiteRelaxingDefault.top_navigation": + null, + "privacy.partition.network_state.ocsp_cache": null, + "privacy.query_stripping.enabled": null, + "privacy.query_stripping.enabled.pbmode": null, + "privacy.fingerprintingProtection": null, + "privacy.fingerprintingProtection.pbmode": null, + }, + }; + let type = "strict"; + let rulesArray = Services.prefs + .getStringPref(this.PREF_STRICT_DEF) + .split(","); + for (let item of rulesArray) { + switch (item) { + case "tp": + this.CATEGORY_PREFS[type][ + "privacy.trackingprotection.enabled" + ] = true; + break; + case "-tp": + this.CATEGORY_PREFS[type][ + "privacy.trackingprotection.enabled" + ] = false; + break; + case "tpPrivate": + this.CATEGORY_PREFS[type][ + "privacy.trackingprotection.pbmode.enabled" + ] = true; + break; + case "-tpPrivate": + this.CATEGORY_PREFS[type][ + "privacy.trackingprotection.pbmode.enabled" + ] = false; + break; + case "fp": + this.CATEGORY_PREFS[type][ + "privacy.trackingprotection.fingerprinting.enabled" + ] = true; + break; + case "-fp": + this.CATEGORY_PREFS[type][ + "privacy.trackingprotection.fingerprinting.enabled" + ] = false; + break; + case "cm": + this.CATEGORY_PREFS[type][ + "privacy.trackingprotection.cryptomining.enabled" + ] = true; + break; + case "-cm": + this.CATEGORY_PREFS[type][ + "privacy.trackingprotection.cryptomining.enabled" + ] = false; + break; + case "stp": + this.CATEGORY_PREFS[type][ + "privacy.trackingprotection.socialtracking.enabled" + ] = true; + break; + case "-stp": + this.CATEGORY_PREFS[type][ + "privacy.trackingprotection.socialtracking.enabled" + ] = false; + break; + case "emailTP": + this.CATEGORY_PREFS[type][ + "privacy.trackingprotection.emailtracking.enabled" + ] = true; + break; + case "-emailTP": + this.CATEGORY_PREFS[type][ + "privacy.trackingprotection.emailtracking.enabled" + ] = false; + break; + case "emailTPPrivate": + this.CATEGORY_PREFS[type][ + "privacy.trackingprotection.emailtracking.pbmode.enabled" + ] = true; + break; + case "-emailTPPrivate": + this.CATEGORY_PREFS[type][ + "privacy.trackingprotection.emailtracking.pbmode.enabled" + ] = false; + break; + case "lvl2": + this.CATEGORY_PREFS[type][ + "privacy.annotate_channels.strict_list.enabled" + ] = true; + break; + case "-lvl2": + this.CATEGORY_PREFS[type][ + "privacy.annotate_channels.strict_list.enabled" + ] = false; + break; + case "rp": + this.CATEGORY_PREFS[type][ + "network.http.referer.disallowCrossSiteRelaxingDefault" + ] = true; + break; + case "-rp": + this.CATEGORY_PREFS[type][ + "network.http.referer.disallowCrossSiteRelaxingDefault" + ] = false; + break; + case "rpTop": + this.CATEGORY_PREFS[type][ + "network.http.referer.disallowCrossSiteRelaxingDefault.top_navigation" + ] = true; + break; + case "-rpTop": + this.CATEGORY_PREFS[type][ + "network.http.referer.disallowCrossSiteRelaxingDefault.top_navigation" + ] = false; + break; + case "ocsp": + this.CATEGORY_PREFS[type][ + "privacy.partition.network_state.ocsp_cache" + ] = true; + break; + case "-ocsp": + this.CATEGORY_PREFS[type][ + "privacy.partition.network_state.ocsp_cache" + ] = false; + break; + case "qps": + this.CATEGORY_PREFS[type]["privacy.query_stripping.enabled"] = true; + break; + case "-qps": + this.CATEGORY_PREFS[type]["privacy.query_stripping.enabled"] = false; + break; + case "qpsPBM": + this.CATEGORY_PREFS[type][ + "privacy.query_stripping.enabled.pbmode" + ] = true; + break; + case "-qpsPBM": + this.CATEGORY_PREFS[type][ + "privacy.query_stripping.enabled.pbmode" + ] = false; + break; + case "fpp": + this.CATEGORY_PREFS[type]["privacy.fingerprintingProtection"] = true; + break; + case "-fpp": + this.CATEGORY_PREFS[type]["privacy.fingerprintingProtection"] = false; + break; + case "fppPrivate": + this.CATEGORY_PREFS[type][ + "privacy.fingerprintingProtection.pbmode" + ] = true; + break; + case "-fppPrivate": + this.CATEGORY_PREFS[type][ + "privacy.fingerprintingProtection.pbmode" + ] = false; + break; + case "cookieBehavior0": + this.CATEGORY_PREFS[type]["network.cookie.cookieBehavior"] = + Ci.nsICookieService.BEHAVIOR_ACCEPT; + break; + case "cookieBehavior1": + this.CATEGORY_PREFS[type]["network.cookie.cookieBehavior"] = + Ci.nsICookieService.BEHAVIOR_REJECT_FOREIGN; + break; + case "cookieBehavior2": + this.CATEGORY_PREFS[type]["network.cookie.cookieBehavior"] = + Ci.nsICookieService.BEHAVIOR_REJECT; + break; + case "cookieBehavior3": + this.CATEGORY_PREFS[type]["network.cookie.cookieBehavior"] = + Ci.nsICookieService.BEHAVIOR_LIMIT_FOREIGN; + break; + case "cookieBehavior4": + this.CATEGORY_PREFS[type]["network.cookie.cookieBehavior"] = + Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER; + break; + case "cookieBehavior5": + this.CATEGORY_PREFS[type]["network.cookie.cookieBehavior"] = + Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN; + break; + case "cookieBehaviorPBM0": + this.CATEGORY_PREFS[type]["network.cookie.cookieBehavior.pbmode"] = + Ci.nsICookieService.BEHAVIOR_ACCEPT; + break; + case "cookieBehaviorPBM1": + this.CATEGORY_PREFS[type]["network.cookie.cookieBehavior.pbmode"] = + Ci.nsICookieService.BEHAVIOR_REJECT_FOREIGN; + break; + case "cookieBehaviorPBM2": + this.CATEGORY_PREFS[type]["network.cookie.cookieBehavior.pbmode"] = + Ci.nsICookieService.BEHAVIOR_REJECT; + break; + case "cookieBehaviorPBM3": + this.CATEGORY_PREFS[type]["network.cookie.cookieBehavior.pbmode"] = + Ci.nsICookieService.BEHAVIOR_LIMIT_FOREIGN; + break; + case "cookieBehaviorPBM4": + this.CATEGORY_PREFS[type]["network.cookie.cookieBehavior.pbmode"] = + Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER; + break; + case "cookieBehaviorPBM5": + this.CATEGORY_PREFS[type]["network.cookie.cookieBehavior.pbmode"] = + Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN; + break; + default: + console.error(`Error: Unknown rule observed ${item}`); + } + } + }, + + /** + * Checks if CB prefs match perfectly with one of our pre-defined categories. + */ + prefsMatch(category) { + // The category pref must be either unset, or match. + if ( + Services.prefs.prefHasUserValue(this.PREF_CB_CATEGORY) && + Services.prefs.getStringPref(this.PREF_CB_CATEGORY) != category + ) { + return false; + } + for (let pref in this.CATEGORY_PREFS[category]) { + let value = this.CATEGORY_PREFS[category][pref]; + if (value == null) { + if (Services.prefs.prefHasUserValue(pref)) { + return false; + } + } else { + let prefType = Services.prefs.getPrefType(pref); + if ( + (prefType == Services.prefs.PREF_BOOL && + Services.prefs.getBoolPref(pref) != value) || + (prefType == Services.prefs.PREF_INT && + Services.prefs.getIntPref(pref) != value) || + (prefType == Services.prefs.PREF_STRING && + Services.prefs.getStringPref(pref) != value) + ) { + return false; + } + } + } + return true; + }, + + matchCBCategory() { + if (this.switchingCategory) { + return; + } + // If PREF_CB_CATEGORY is not set match users to a Content Blocking category. Check if prefs fit + // perfectly into strict or standard, otherwise match with custom. If PREF_CB_CATEGORY has previously been set, + // a change of one of these prefs necessarily puts us in "custom". + if (this.prefsMatch("standard")) { + Services.prefs.setStringPref(this.PREF_CB_CATEGORY, "standard"); + } else if (this.prefsMatch("strict")) { + Services.prefs.setStringPref(this.PREF_CB_CATEGORY, "strict"); + } else { + Services.prefs.setStringPref(this.PREF_CB_CATEGORY, "custom"); + } + + // If there is a custom policy which changes a related pref, then put the user in custom so + // they still have access to other content blocking prefs, and to keep our default definitions + // from changing. + let policy = Services.policies.getActivePolicies(); + if (policy && (policy.EnableTrackingProtection || policy.Cookies)) { + Services.prefs.setStringPref(this.PREF_CB_CATEGORY, "custom"); + } + }, + + updateCBCategory() { + if ( + this.switchingCategory || + !Services.prefs.prefHasUserValue(this.PREF_CB_CATEGORY) + ) { + return; + } + // Turn on switchingCategory flag, to ensure that when the individual prefs that change as a result + // of the category change do not trigger yet another category change. + this.switchingCategory = true; + let value = Services.prefs.getStringPref(this.PREF_CB_CATEGORY); + this.setPrefsToCategory(value); + this.switchingCategory = false; + }, + + /** + * Sets all user-exposed content blocking preferences to values that match the selected category. + */ + setPrefsToCategory(category) { + // Leave prefs as they were if we are switching to "custom" category. + if (category == "custom") { + return; + } + + for (let pref in this.CATEGORY_PREFS[category]) { + let value = this.CATEGORY_PREFS[category][pref]; + if (!Services.prefs.prefIsLocked(pref)) { + if (value == null) { + Services.prefs.clearUserPref(pref); + } else { + switch (Services.prefs.getPrefType(pref)) { + case Services.prefs.PREF_BOOL: + Services.prefs.setBoolPref(pref, value); + break; + case Services.prefs.PREF_INT: + Services.prefs.setIntPref(pref, value); + break; + case Services.prefs.PREF_STRING: + Services.prefs.setStringPref(pref, value); + break; + } + } + } + } + }, +}; + +/** + * ContentPermissionIntegration is responsible for showing the user + * simple permission prompts when content requests additional + * capabilities. + * + * While there are some built-in permission prompts, createPermissionPrompt + * can also be overridden by system add-ons or tests to provide new ones. + * + * This override ability is provided by Integration.sys.mjs. See + * PermissionUI.sys.mjs for an example of how to provide a new prompt + * from an add-on. + */ +const ContentPermissionIntegration = { + /** + * Creates a PermissionPrompt for a given permission type and + * nsIContentPermissionRequest. + * + * @param {string} type + * The type of the permission request from content. This normally + * matches the "type" field of an nsIContentPermissionType, but it + * can be something else if the permission does not use the + * nsIContentPermissionRequest model. Note that this type might also + * be different from the permission key used in the permissions + * database. + * Example: "geolocation" + * @param {nsIContentPermissionRequest} request + * The request for a permission from content. + * @return {PermissionPrompt} (see PermissionUI.sys.mjs), + * or undefined if the type cannot be handled. + */ + createPermissionPrompt(type, request) { + switch (type) { + case "geolocation": { + return new lazy.PermissionUI.GeolocationPermissionPrompt(request); + } + case "xr": { + return new lazy.PermissionUI.XRPermissionPrompt(request); + } + case "desktop-notification": { + return new lazy.PermissionUI.DesktopNotificationPermissionPrompt( + request + ); + } + case "persistent-storage": { + return new lazy.PermissionUI.PersistentStoragePermissionPrompt(request); + } + case "midi": { + return new lazy.PermissionUI.MIDIPermissionPrompt(request); + } + case "storage-access": { + return new lazy.PermissionUI.StorageAccessPermissionPrompt(request); + } + } + return undefined; + }, +}; + +export function ContentPermissionPrompt() {} + +ContentPermissionPrompt.prototype = { + classID: Components.ID("{d8903bf6-68d5-4e97-bcd1-e4d3012f721a}"), + + QueryInterface: ChromeUtils.generateQI(["nsIContentPermissionPrompt"]), + + /** + * This implementation of nsIContentPermissionPrompt.prompt ensures + * that there's only one nsIContentPermissionType in the request, + * and that it's of type nsIContentPermissionType. Failing to + * satisfy either of these conditions will result in this method + * throwing NS_ERRORs. If the combined ContentPermissionIntegration + * cannot construct a prompt for this particular request, an + * NS_ERROR_FAILURE will be thrown. + * + * Any time an error is thrown, the nsIContentPermissionRequest is + * cancelled automatically. + * + * @param {nsIContentPermissionRequest} request + * The request that we're to show a prompt for. + */ + prompt(request) { + if (request.element && request.element.fxrPermissionPrompt) { + // For Firefox Reality on Desktop, switch to a different mechanism to + // prompt the user since fewer permissions are available and since many + // UI dependencies are not availabe. + request.element.fxrPermissionPrompt(request); + return; + } + + let type; + try { + // Only allow exactly one permission request here. + let types = request.types.QueryInterface(Ci.nsIArray); + if (types.length != 1) { + throw Components.Exception( + "Expected an nsIContentPermissionRequest with only 1 type.", + Cr.NS_ERROR_UNEXPECTED + ); + } + + type = types.queryElementAt(0, Ci.nsIContentPermissionType).type; + let combinedIntegration = lazy.Integration.contentPermission.getCombined( + ContentPermissionIntegration + ); + + let permissionPrompt = combinedIntegration.createPermissionPrompt( + type, + request + ); + if (!permissionPrompt) { + throw Components.Exception( + `Failed to handle permission of type ${type}`, + Cr.NS_ERROR_FAILURE + ); + } + + permissionPrompt.prompt(); + } catch (ex) { + console.error(ex); + request.cancel(); + throw ex; + } + + let schemeHistogram = Services.telemetry.getKeyedHistogramById( + "PERMISSION_REQUEST_ORIGIN_SCHEME" + ); + let scheme = 0; + try { + if (request.principal.schemeIs("http")) { + scheme = 1; + } else if (request.principal.schemeIs("https")) { + scheme = 2; + } + } catch (ex) { + // If the request principal is not available at this point, + // the request has likely been cancelled before being shown to the + // user. We shouldn't record this request. + if (ex.result != Cr.NS_ERROR_FAILURE) { + console.error(ex); + } + return; + } + schemeHistogram.add(type, scheme); + + let userInputHistogram = Services.telemetry.getKeyedHistogramById( + "PERMISSION_REQUEST_HANDLING_USER_INPUT" + ); + userInputHistogram.add( + type, + request.hasValidTransientUserGestureActivation + ); + }, +}; + +export var DefaultBrowserCheck = { + async prompt(win) { + const shellService = win.getShellService(); + const needPin = await shellService.doesAppNeedPin(); + + win.MozXULElement.insertFTLIfNeeded("branding/brand.ftl"); + win.MozXULElement.insertFTLIfNeeded( + "browser/defaultBrowserNotification.ftl" + ); + // Resolve the translations for the prompt elements and return only the + // string values + const pinMessage = + AppConstants.platform == "macosx" + ? "default-browser-prompt-message-pin-mac" + : "default-browser-prompt-message-pin"; + let [promptTitle, promptMessage, askLabel, yesButton, notNowButton] = ( + await win.document.l10n.formatMessages([ + { + id: needPin + ? "default-browser-prompt-title-pin" + : "default-browser-prompt-title-alt", + }, + { + id: needPin ? pinMessage : "default-browser-prompt-message-alt", + }, + { id: "default-browser-prompt-checkbox-not-again-label" }, + { + id: needPin + ? "default-browser-prompt-button-primary-pin" + : "default-browser-prompt-button-primary-alt", + }, + { id: "default-browser-prompt-button-secondary" }, + ]) + ).map(({ value }) => value); + + let ps = Services.prompt; + let buttonFlags = + ps.BUTTON_TITLE_IS_STRING * ps.BUTTON_POS_0 + + ps.BUTTON_TITLE_IS_STRING * ps.BUTTON_POS_1 + + ps.BUTTON_POS_0_DEFAULT; + let rv = await ps.asyncConfirmEx( + win.browsingContext, + ps.MODAL_TYPE_INTERNAL_WINDOW, + promptTitle, + promptMessage, + buttonFlags, + yesButton, + notNowButton, + null, + askLabel, + false, // checkbox state + { headerIconURL: "chrome://branding/content/icon32.png" } + ); + let buttonNumClicked = rv.get("buttonNumClicked"); + let checkboxState = rv.get("checked"); + if (buttonNumClicked == 0) { + try { + await shellService.setAsDefault(); + } catch (e) { + this.log.error("Failed to set the default browser", e); + } + + shellService.pinToTaskbar(); + } + if (checkboxState) { + shellService.shouldCheckDefaultBrowser = false; + } + + try { + let resultEnum = buttonNumClicked * 2 + !checkboxState; + Services.telemetry + .getHistogramById("BROWSER_SET_DEFAULT_RESULT") + .add(resultEnum); + } catch (ex) { + /* Don't break if Telemetry is acting up. */ + } + }, + + /** + * Checks if the default browser check prompt will be shown. + * @param {boolean} isStartupCheck + * If true, prefs will be set and telemetry will be recorded. + * @returns {boolean} True if the default browser check prompt will be shown. + */ + async willCheckDefaultBrowser(isStartupCheck) { + let win = lazy.BrowserWindowTracker.getTopWindow(); + let shellService = win.getShellService(); + + // Perform default browser checking. + if (!shellService) { + return false; + } + + let shouldCheck = + !AppConstants.DEBUG && shellService.shouldCheckDefaultBrowser; + + // Even if we shouldn't check the default browser, we still continue when + // isStartupCheck = true to set prefs and telemetry. + if (!shouldCheck && !isStartupCheck) { + return false; + } + + // Skip the "Set Default Browser" check during first-run or after the + // browser has been run a few times. + const skipDefaultBrowserCheck = + Services.prefs.getBoolPref( + "browser.shell.skipDefaultBrowserCheckOnFirstRun" + ) && + !Services.prefs.getBoolPref( + "browser.shell.didSkipDefaultBrowserCheckOnFirstRun" + ); + + let promptCount = Services.prefs.getIntPref( + "browser.shell.defaultBrowserCheckCount", + 0 + ); + + // If SessionStartup's state is not initialized, checking sessionType will set + // its internal state to "do not restore". + await lazy.SessionStartup.onceInitialized; + let willRecoverSession = + lazy.SessionStartup.sessionType == lazy.SessionStartup.RECOVER_SESSION; + + // Don't show the prompt if we're already the default browser. + let isDefault = false; + let isDefaultError = false; + try { + isDefault = shellService.isDefaultBrowser(isStartupCheck, false); + } catch (ex) { + isDefaultError = true; + } + + if (isDefault && isStartupCheck) { + let now = Math.floor(Date.now() / 1000).toString(); + Services.prefs.setCharPref( + "browser.shell.mostRecentDateSetAsDefault", + now + ); + } + + let willPrompt = shouldCheck && !isDefault && !willRecoverSession; + + if (willPrompt) { + if (skipDefaultBrowserCheck) { + if (isStartupCheck) { + Services.prefs.setBoolPref( + "browser.shell.didSkipDefaultBrowserCheckOnFirstRun", + true + ); + } + willPrompt = false; + } else { + promptCount++; + if (isStartupCheck) { + Services.prefs.setIntPref( + "browser.shell.defaultBrowserCheckCount", + promptCount + ); + } + if (!AppConstants.RELEASE_OR_BETA && promptCount > 3) { + willPrompt = false; + } + } + } + + if (isStartupCheck) { + try { + // Report default browser status on startup to telemetry + // so we can track whether we are the default. + Services.telemetry + .getHistogramById("BROWSER_IS_USER_DEFAULT") + .add(isDefault); + Services.telemetry + .getHistogramById("BROWSER_IS_USER_DEFAULT_ERROR") + .add(isDefaultError); + Services.telemetry + .getHistogramById("BROWSER_SET_DEFAULT_ALWAYS_CHECK") + .add(shouldCheck); + Services.telemetry + .getHistogramById("BROWSER_SET_DEFAULT_DIALOG_PROMPT_RAWCOUNT") + .add(promptCount); + } catch (ex) { + /* Don't break the default prompt if telemetry is broken. */ + } + } + + return willPrompt; + }, +}; + +/** + * AboutHomeStartupCache is responsible for reading and writing the + * initial about:home document from the HTTP cache as a startup + * performance optimization. It only works when the "privileged about + * content process" is enabled and when ENABLED_PREF is set to true. + * + * See https://firefox-source-docs.mozilla.org/browser/components/newtab/docs/v2-system-addon/about_home_startup_cache.html + * for further details. + */ +export var AboutHomeStartupCache = { + ABOUT_HOME_URI_STRING: "about:home", + SCRIPT_EXTENSION: "script", + ENABLED_PREF: "browser.startup.homepage.abouthome_cache.enabled", + PRELOADED_NEWTAB_PREF: "browser.newtab.preload", + LOG_LEVEL_PREF: "browser.startup.homepage.abouthome_cache.loglevel", + + // It's possible that the layout of about:home will change such that + // we want to invalidate any pre-existing caches. We do this by setting + // this meta key in the nsICacheEntry for the page. + // + // The version is currently set to the build ID, meaning that the cache + // is invalidated after every upgrade (like the main startup cache). + CACHE_VERSION_META_KEY: "version", + + LOG_NAME: "AboutHomeStartupCache", + + // These messages are used to request the "privileged about content process" + // to create the cached document, and then to receive that document. + CACHE_REQUEST_MESSAGE: "AboutHomeStartupCache:CacheRequest", + CACHE_RESPONSE_MESSAGE: "AboutHomeStartupCache:CacheResponse", + CACHE_USAGE_RESULT_MESSAGE: "AboutHomeStartupCache:UsageResult", + + // When a "privileged about content process" is launched, this message is + // sent to give it some nsIInputStream's for the about:home document they + // should load. + SEND_STREAMS_MESSAGE: "AboutHomeStartupCache:InputStreams", + + // This time in ms is used to debounce messages that are broadcast to + // all about:newtab's, or the preloaded about:newtab. We use those + // messages as a signal that it's likely time to refresh the cache. + CACHE_DEBOUNCE_RATE_MS: 5000, + + // This is how long we'll block the AsyncShutdown while waiting for + // the cache to write. If we fail to write within that time, we will + // allow the shutdown to proceed. + SHUTDOWN_CACHE_WRITE_TIMEOUT_MS: 1000, + + // The following values are as possible values for the + // browser.startup.abouthome_cache_result scalar. Keep these in sync with the + // scalar definition in Scalars.yaml. See setDeferredResult for more + // information. + CACHE_RESULT_SCALARS: { + UNSET: 0, + DOES_NOT_EXIST: 1, + CORRUPT_PAGE: 2, + CORRUPT_SCRIPT: 3, + INVALIDATED: 4, + LATE: 5, + VALID_AND_USED: 6, + DISABLED: 7, + NOT_LOADING_ABOUTHOME: 8, + PRELOADING_DISABLED: 9, + }, + + // This will be set to one of the values of CACHE_RESULT_SCALARS + // once it is determined which result best suits what occurred. + _cacheDeferredResultScalar: -1, + + // A reference to the nsICacheEntry to read from and write to. + _cacheEntry: null, + + // These nsIPipe's are sent down to the "privileged about content process" + // immediately after the process launches. This allows us to race the loading + // of the cache entry in the parent process with the load of the about:home + // page in the content process, since we'll connect the InputStream's to + // the pipes as soon as the nsICacheEntry is available. + // + // The page pipe is for the HTML markup for the page. + _pagePipe: null, + // The script pipe is for the JavaScript that the HTML markup loads + // to set its internal state. + _scriptPipe: null, + _cacheDeferred: null, + + _enabled: false, + _initted: false, + _hasWrittenThisSession: false, + _finalized: false, + _firstPrivilegedProcessCreated: false, + + init() { + if (this._initted) { + throw new Error("AboutHomeStartupCache already initted."); + } + + this.setDeferredResult(this.CACHE_RESULT_SCALARS.UNSET); + + this._enabled = !!lazy.NimbusFeatures.abouthomecache.getVariable("enabled"); + + if (!this._enabled) { + this.recordResult(this.CACHE_RESULT_SCALARS.DISABLED); + return; + } + + this.log = console.createInstance({ + prefix: this.LOG_NAME, + maxLogLevelPref: this.LOG_LEVEL_PREF, + }); + + this.log.trace("Initting."); + + // If the user is not configured to load about:home at startup, then + // let's not bother with the cache - loading it needlessly is more likely + // to hinder what we're actually trying to load. + let willLoadAboutHome = + !lazy.HomePage.overridden && + Services.prefs.getIntPref("browser.startup.page") === 1; + + if (!willLoadAboutHome) { + this.log.trace("Not configured to load about:home by default."); + this.recordResult(this.CACHE_RESULT_SCALARS.NOT_LOADING_ABOUTHOME); + return; + } + + if (!Services.prefs.getBoolPref(this.PRELOADED_NEWTAB_PREF, false)) { + this.log.trace("Preloaded about:newtab disabled."); + this.recordResult(this.CACHE_RESULT_SCALARS.PRELOADING_DISABLED); + return; + } + + Services.obs.addObserver(this, "ipc:content-created"); + Services.obs.addObserver(this, "process-type-set"); + Services.obs.addObserver(this, "ipc:content-shutdown"); + Services.obs.addObserver(this, "intl:app-locales-changed"); + + this.log.trace("Constructing pipes."); + this._pagePipe = this.makePipe(); + this._scriptPipe = this.makePipe(); + + this._cacheEntryPromise = new Promise(resolve => { + this._cacheEntryResolver = resolve; + }); + + let lci = Services.loadContextInfo.default; + let storage = Services.cache2.diskCacheStorage(lci); + try { + storage.asyncOpenURI( + this.aboutHomeURI, + "", + Ci.nsICacheStorage.OPEN_PRIORITY, + this + ); + } catch (e) { + this.log.error("Failed to open about:home cache entry", e); + } + + this._cacheTask = new lazy.DeferredTask(async () => { + await this.cacheNow(); + }, this.CACHE_DEBOUNCE_RATE_MS); + + lazy.AsyncShutdown.quitApplicationGranted.addBlocker( + "AboutHomeStartupCache: Writing cache", + async () => { + await this.onShutdown(); + }, + () => this._cacheProgress + ); + + this._cacheDeferred = null; + this._initted = true; + this.log.trace("Initialized."); + }, + + get initted() { + return this._initted; + }, + + uninit() { + if (!this._enabled) { + return; + } + + try { + Services.obs.removeObserver(this, "ipc:content-created"); + Services.obs.removeObserver(this, "process-type-set"); + Services.obs.removeObserver(this, "ipc:content-shutdown"); + Services.obs.removeObserver(this, "intl:app-locales-changed"); + } catch (e) { + // If we failed to initialize and register for these observer + // notifications, then attempting to remove them will throw. + // It's fine to ignore that case on shutdown. + } + + if (this._cacheTask) { + this._cacheTask.disarm(); + this._cacheTask = null; + } + + this._pagePipe = null; + this._scriptPipe = null; + this._initted = false; + this._cacheEntry = null; + this._hasWrittenThisSession = false; + this._cacheEntryPromise = null; + this._cacheEntryResolver = null; + this._cacheDeferredResultScalar = -1; + + if (this.log) { + this.log.trace("Uninitialized."); + this.log = null; + } + + this._procManager = null; + this._procManagerID = null; + this._appender = null; + this._cacheDeferred = null; + this._finalized = false; + this._firstPrivilegedProcessCreated = false; + }, + + _aboutHomeURI: null, + + get aboutHomeURI() { + if (this._aboutHomeURI) { + return this._aboutHomeURI; + } + + this._aboutHomeURI = Services.io.newURI(this.ABOUT_HOME_URI_STRING); + return this._aboutHomeURI; + }, + + // For the AsyncShutdown blocker, this is used to populate the progress + // value. + _cacheProgress: "Not yet begun", + + /** + * Called by the AsyncShutdown blocker on quit-application-granted + * to potentially flush the most recent cache to disk. If one was + * never written during the session, one is generated and written + * before the async function resolves. + * + * @param withTimeout (boolean) + * Whether or not the timeout mechanism should be used. Defaults + * to true. + * @returns Promise + * @resolves boolean + * If a cache has never been written, or a cache write is in + * progress, resolves true when the cache has been written. Also + * resolves to true if a cache didn't need to be written. + * + * Resolves to false if a cache write unexpectedly timed out. + */ + async onShutdown(withTimeout = true) { + // If we never wrote this session, arm the task so that the next + // step can finalize. + if (!this._hasWrittenThisSession) { + this.log.trace("Never wrote a cache this session. Arming cache task."); + this._cacheTask.arm(); + } + + Services.telemetry.scalarSet( + "browser.startup.abouthome_cache_shutdownwrite", + this._cacheTask.isArmed + ); + + if (this._cacheTask.isArmed) { + this.log.trace("Finalizing cache task on shutdown"); + this._finalized = true; + + // To avoid hanging shutdowns, we'll ensure that we wait a maximum of + // SHUTDOWN_CACHE_WRITE_TIMEOUT_MS millseconds before giving up. + const TIMED_OUT = Symbol(); + let timeoutID = 0; + + let timeoutPromise = new Promise(resolve => { + timeoutID = lazy.setTimeout( + () => resolve(TIMED_OUT), + this.SHUTDOWN_CACHE_WRITE_TIMEOUT_MS + ); + }); + + let promises = [this._cacheTask.finalize()]; + if (withTimeout) { + this.log.trace("Using timeout mechanism."); + promises.push(timeoutPromise); + } else { + this.log.trace("Skipping timeout mechanism."); + } + + let result = await Promise.race(promises); + this.log.trace("Done blocking shutdown."); + lazy.clearTimeout(timeoutID); + if (result === TIMED_OUT) { + this.log.error("Timed out getting cache streams. Skipping cache task."); + return false; + } + } + this.log.trace("onShutdown is exiting"); + return true; + }, + + /** + * Called by the _cacheTask DeferredTask to actually do the work of + * caching the about:home document. + * + * @returns Promise + * @resolves undefined + * Resolves when a fresh version of the cache has been written. + */ + async cacheNow() { + this.log.trace("Caching now."); + this._cacheProgress = "Getting cache streams"; + + let { pageInputStream, scriptInputStream } = await this.requestCache(); + + if (!pageInputStream || !scriptInputStream) { + this.log.trace("Failed to get cache streams."); + this._cacheProgress = "Failed to get streams"; + return; + } + + this.log.trace("Got cache streams."); + + this._cacheProgress = "Writing to cache"; + + try { + this.log.trace("Populating cache."); + await this.populateCache(pageInputStream, scriptInputStream); + } catch (e) { + this._cacheProgress = "Failed to populate cache"; + this.log.error("Populating the cache failed: ", e); + return; + } + + this._cacheProgress = "Done"; + this.log.trace("Done writing to cache."); + this._hasWrittenThisSession = true; + }, + + /** + * Requests the cached document streams from the "privileged about content + * process". + * + * @returns Promise + * @resolves Object + * Resolves with an Object with the following properties: + * + * pageInputStream (nsIInputStream) + * The page content to write to the cache, or null if request the streams + * failed. + * + * scriptInputStream (nsIInputStream) + * The script content to write to the cache, or null if request the streams + * failed. + */ + requestCache() { + this.log.trace("Parent is requesting Activity Stream state object."); + if (!this._procManager) { + this.log.error("requestCache called with no _procManager!"); + return { pageInputStream: null, scriptInputStream: null }; + } + + if ( + this._procManager.remoteType != lazy.E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE + ) { + this.log.error("Somehow got the wrong process type."); + return { pageInputStream: null, scriptInputStream: null }; + } + + let state = lazy.AboutNewTab.activityStream.store.getState(); + return new Promise(resolve => { + this._cacheDeferred = resolve; + this.log.trace("Parent is requesting cache streams."); + this._procManager.sendAsyncMessage(this.CACHE_REQUEST_MESSAGE, { state }); + }); + }, + + /** + * Helper function that returns a newly constructed nsIPipe instance. + * + * @return nsIPipe + */ + makePipe() { + let pipe = Cc["@mozilla.org/pipe;1"].createInstance(Ci.nsIPipe); + pipe.init( + true /* non-blocking input */, + true /* non-blocking output */, + 0 /* segment size */, + 0 /* max segments */ + ); + return pipe; + }, + + get pagePipe() { + return this._pagePipe; + }, + + get scriptPipe() { + return this._scriptPipe; + }, + + /** + * Called when the nsICacheEntry has been accessed. If the nsICacheEntry + * has content that we want to send down to the "privileged about content + * process", then we connect that content to the nsIPipe's that may or + * may not have already been sent down to the process. + * + * In the event that the nsICacheEntry doesn't contain anything usable, + * the nsInputStreams on the nsIPipe's are closed. + */ + connectToPipes() { + this.log.trace(`Connecting nsICacheEntry to pipes.`); + + // If the cache doesn't yet exist, we'll know because the version metadata + // won't exist yet. + let version; + try { + this.log.trace(""); + version = this._cacheEntry.getMetaDataElement( + this.CACHE_VERSION_META_KEY + ); + } catch (e) { + if (e.result == Cr.NS_ERROR_NOT_AVAILABLE) { + this.log.debug("Cache meta data does not exist. Closing streams."); + this.pagePipe.outputStream.close(); + this.scriptPipe.outputStream.close(); + this.setDeferredResult(this.CACHE_RESULT_SCALARS.DOES_NOT_EXIST); + return; + } + + throw e; + } + + this.log.info("Version retrieved is", version); + + if (version != Services.appinfo.appBuildID) { + this.log.info("Version does not match! Dooming and closing streams.\n"); + // This cache is no good - doom it, and prepare for a new one. + this.clearCache(); + this.pagePipe.outputStream.close(); + this.scriptPipe.outputStream.close(); + this.setDeferredResult(this.CACHE_RESULT_SCALARS.INVALIDATED); + return; + } + + let cachePageInputStream; + + try { + cachePageInputStream = this._cacheEntry.openInputStream(0); + } catch (e) { + this.log.error("Failed to open main input stream for cache entry", e); + this.pagePipe.outputStream.close(); + this.scriptPipe.outputStream.close(); + this.setDeferredResult(this.CACHE_RESULT_SCALARS.CORRUPT_PAGE); + return; + } + + this.log.trace("Connecting page stream to pipe."); + lazy.NetUtil.asyncCopy( + cachePageInputStream, + this.pagePipe.outputStream, + () => { + this.log.info("Page stream connected to pipe."); + } + ); + + let cacheScriptInputStream; + try { + this.log.trace("Connecting script stream to pipe."); + cacheScriptInputStream = + this._cacheEntry.openAlternativeInputStream("script"); + lazy.NetUtil.asyncCopy( + cacheScriptInputStream, + this.scriptPipe.outputStream, + () => { + this.log.info("Script stream connected to pipe."); + } + ); + } catch (e) { + if (e.result == Cr.NS_ERROR_NOT_AVAILABLE) { + // For some reason, the script was not available. We'll close the pipe + // without sending anything into it. The privileged about content process + // will notice that there's nothing available in the pipe, and fall back + // to dynamically generating the page. + this.log.error("Script stream not available! Closing pipe."); + this.scriptPipe.outputStream.close(); + this.setDeferredResult(this.CACHE_RESULT_SCALARS.CORRUPT_SCRIPT); + } else { + throw e; + } + } + + this.setDeferredResult(this.CACHE_RESULT_SCALARS.VALID_AND_USED); + this.log.trace("Streams connected to pipes."); + }, + + /** + * Called when we have received a the cache values from the "privileged + * about content process". The page and script streams are written to + * the nsICacheEntry. + * + * This writing is asynchronous, and if a write happens to already be + * underway when this function is called, that latter call will be + * ignored. + * + * @param pageInputStream (nsIInputStream) + * A stream containing the HTML markup to be saved to the cache. + * @param scriptInputStream (nsIInputStream) + * A stream containing the JS hydration script to be saved to the cache. + * @returns Promise + * @resolves undefined + * When the cache has been successfully written to. + * @rejects Error + * Rejects with a JS Error if writing any part of the cache happens to + * fail. + */ + async populateCache(pageInputStream, scriptInputStream) { + await this.ensureCacheEntry(); + + await new Promise((resolve, reject) => { + // Doom the old cache entry, so we can start writing to a new one. + this.log.trace("Populating the cache. Dooming old entry."); + this.clearCache(); + + this.log.trace("Opening the page output stream."); + let pageOutputStream; + try { + pageOutputStream = this._cacheEntry.openOutputStream(0, -1); + } catch (e) { + reject(e); + return; + } + + this.log.info("Writing the page cache."); + lazy.NetUtil.asyncCopy(pageInputStream, pageOutputStream, pageResult => { + if (!Components.isSuccessCode(pageResult)) { + this.log.error("Failed to write page. Result: " + pageResult); + reject(new Error(pageResult)); + return; + } + + this.log.trace( + "Writing the page data is complete. Now opening the " + + "script output stream." + ); + + let scriptOutputStream; + try { + scriptOutputStream = this._cacheEntry.openAlternativeOutputStream( + "script", + -1 + ); + } catch (e) { + reject(e); + return; + } + + this.log.info("Writing the script cache."); + lazy.NetUtil.asyncCopy( + scriptInputStream, + scriptOutputStream, + scriptResult => { + if (!Components.isSuccessCode(scriptResult)) { + this.log.error("Failed to write script. Result: " + scriptResult); + reject(new Error(scriptResult)); + return; + } + + this.log.trace( + "Writing the script cache is done. Setting version." + ); + try { + this._cacheEntry.setMetaDataElement( + "version", + Services.appinfo.appBuildID + ); + } catch (e) { + this.log.error("Failed to write version."); + reject(e); + return; + } + this.log.trace(`Version is set to ${Services.appinfo.appBuildID}.`); + this.log.info("Caching of page and script is done."); + resolve(); + } + ); + }); + }); + + this.log.trace("populateCache has finished."); + }, + + /** + * Returns a Promise that resolves once the nsICacheEntry for the cache + * is available to write to and read from. + * + * @returns Promise + * @resolves nsICacheEntry + * Once the cache entry has become available. + * @rejects String + * Rejects with an error message if getting the cache entry is attempted + * before the AboutHomeStartupCache component has been initialized. + */ + ensureCacheEntry() { + if (!this._initted) { + return Promise.reject( + "Cannot ensureCacheEntry - AboutHomeStartupCache is not initted" + ); + } + + return this._cacheEntryPromise; + }, + + /** + * Clears the contents of the cache. + */ + clearCache() { + this.log.trace("Clearing the cache."); + this._cacheEntry = this._cacheEntry.recreate(); + this._cacheEntryPromise = new Promise(resolve => { + resolve(this._cacheEntry); + }); + this._hasWrittenThisSession = false; + }, + + /** + * Called when a content process is created. If this is the "privileged + * about content process", then the cache streams will be sent to it. + * + * @param childID (Number) + * The unique ID for the content process that was created, as passed by + * ipc:content-created. + * @param procManager (ProcessMessageManager) + * The ProcessMessageManager for the created content process. + * @param processParent + * The nsIDOMProcessParent for the tab. + */ + onContentProcessCreated(childID, procManager, processParent) { + if (procManager.remoteType == lazy.E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE) { + if (this._finalized) { + this.log.trace( + "Ignoring privileged about content process launch after finalization." + ); + return; + } + + if (this._firstPrivilegedProcessCreated) { + this.log.trace( + "Ignoring non-first privileged about content processes." + ); + return; + } + + this.log.trace( + `A privileged about content process is launching with ID ${childID}.` + ); + + this.log.info("Sending input streams down to content process."); + let actor = processParent.getActor("BrowserProcess"); + actor.sendAsyncMessage(this.SEND_STREAMS_MESSAGE, { + pageInputStream: this.pagePipe.inputStream, + scriptInputStream: this.scriptPipe.inputStream, + }); + + procManager.addMessageListener(this.CACHE_RESPONSE_MESSAGE, this); + procManager.addMessageListener(this.CACHE_USAGE_RESULT_MESSAGE, this); + this._procManager = procManager; + this._procManagerID = childID; + this._firstPrivilegedProcessCreated = true; + } + }, + + /** + * Called when a content process is destroyed. Either it shut down normally, + * or it crashed. If this is the "privileged about content process", then some + * internal state is cleared. + * + * @param childID (Number) + * The unique ID for the content process that was created, as passed by + * ipc:content-shutdown. + */ + onContentProcessShutdown(childID) { + this.log.info(`Content process shutdown: ${childID}`); + if (this._procManagerID == childID) { + this.log.info("It was the current privileged about process."); + if (this._cacheDeferred) { + this.log.error( + "A privileged about content process shut down while cache streams " + + "were still en route." + ); + // The crash occurred while we were waiting on cache input streams to + // be returned to us. Resolve with null streams instead. + this._cacheDeferred({ pageInputStream: null, scriptInputStream: null }); + this._cacheDeferred = null; + } + + this._procManager.removeMessageListener( + this.CACHE_RESPONSE_MESSAGE, + this + ); + this._procManager.removeMessageListener( + this.CACHE_USAGE_RESULT_MESSAGE, + this + ); + this._procManager = null; + this._procManagerID = null; + } + }, + + /** + * Called externally by ActivityStreamMessageChannel anytime + * a message is broadcast to all about:newtabs, or sent to the + * preloaded about:newtab. This is used to determine if we need + * to refresh the cache. + */ + onPreloadedNewTabMessage() { + if (!this._initted || !this._enabled) { + return; + } + + if (this._finalized) { + this.log.trace("Ignoring preloaded newtab update after finalization."); + return; + } + + this.log.trace("Preloaded about:newtab was updated."); + + this._cacheTask.disarm(); + this._cacheTask.arm(); + }, + + /** + * Stores the CACHE_RESULT_SCALARS value that most accurately represents + * the current notion of how the cache has operated so far. It is stored + * temporarily like this because we need to hear from the privileged + * about content process to hear whether or not retrieving the cache + * actually worked on that end. The success state reported back from + * the privileged about content process will be compared against the + * deferred result scalar to compute what will be recorded to + * Telemetry. + * + * Note that this value will only be recorded if its value is GREATER + * than the currently recorded value. This is because it's possible for + * certain functions that record results to re-enter - but we want to record + * the _first_ condition that caused the cache to not be read from. + * + * @param result (Number) + * One of the CACHE_RESULT_SCALARS values. If this value is less than + * the currently recorded value, it is ignored. + */ + setDeferredResult(result) { + if (this._cacheDeferredResultScalar < result) { + this._cacheDeferredResultScalar = result; + } + }, + + /** + * Records the final result of how the cache operated for the user + * during this session to Telemetry. + */ + recordResult(result) { + // Note: this can be called very early on in the lifetime of + // AboutHomeStartupCache, so things like this.log might not exist yet. + Services.telemetry.scalarSet( + "browser.startup.abouthome_cache_result", + result + ); + }, + + /** + * Called when the parent process receives a message from the privileged + * about content process saying whether or not reading from the cache + * was successful. + * + * @param success (boolean) + * True if reading from the cache succeeded. + */ + onUsageResult(success) { + this.log.trace(`Received usage result. Success = ${success}`); + if (success) { + if ( + this._cacheDeferredResultScalar != + this.CACHE_RESULT_SCALARS.VALID_AND_USED + ) { + this.log.error( + "Somehow got a success result despite having never " + + "successfully sent down the cache streams" + ); + this.recordResult(this._cacheDeferredResultScalar); + } else { + this.recordResult(this.CACHE_RESULT_SCALARS.VALID_AND_USED); + } + + return; + } + + if ( + this._cacheDeferredResultScalar == + this.CACHE_RESULT_SCALARS.VALID_AND_USED + ) { + // We failed to read from the cache despite having successfully + // sent it down to the content process. We presume then that the + // streams just didn't provide any bytes in time. + this.recordResult(this.CACHE_RESULT_SCALARS.LATE); + } else { + // We failed to read the cache, but already knew why. We can + // now record that value. + this.recordResult(this._cacheDeferredResultScalar); + } + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsICacheEntryOpenallback", + "nsIObserver", + ]), + + /** MessageListener **/ + + receiveMessage(message) { + // Only the privileged about content process can write to the cache. + if ( + message.target.remoteType != lazy.E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE + ) { + this.log.error( + "Received a message from a non-privileged content process!" + ); + return; + } + + switch (message.name) { + case this.CACHE_RESPONSE_MESSAGE: { + this.log.trace("Parent received cache streams."); + if (!this._cacheDeferred) { + this.log.error("Parent doesn't have _cacheDeferred set up!"); + return; + } + + this._cacheDeferred(message.data); + this._cacheDeferred = null; + break; + } + case this.CACHE_USAGE_RESULT_MESSAGE: { + this.onUsageResult(message.data.success); + break; + } + } + }, + + /** nsIObserver **/ + + observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "intl:app-locales-changed": { + this.clearCache(); + break; + } + case "process-type-set": + // Intentional fall-through + case "ipc:content-created": { + let childID = aData; + let procManager = aSubject + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIMessageSender); + let pp = aSubject.QueryInterface(Ci.nsIDOMProcessParent); + this.onContentProcessCreated(childID, procManager, pp); + break; + } + + case "ipc:content-shutdown": { + let childID = aData; + this.onContentProcessShutdown(childID); + break; + } + } + }, + + /** nsICacheEntryOpenCallback **/ + + onCacheEntryCheck(aEntry) { + return Ci.nsICacheEntryOpenCallback.ENTRY_WANTED; + }, + + onCacheEntryAvailable(aEntry, aNew, aResult) { + this.log.trace("Cache entry is available."); + + this._cacheEntry = aEntry; + this.connectToPipes(); + this._cacheEntryResolver(this._cacheEntry); + }, +}; + +async function RunOSKeyStoreSelfTest() { + // The linux implementation always causes an OS dialog, in contrast to + // Windows and macOS (the latter of which causes an OS dialog to appear on + // local developer builds), so only run on Windows and macOS and only if this + // has been built and signed by Mozilla's infrastructure. Similarly, don't + // run this code in automation. + if ( + (AppConstants.platform != "win" && AppConstants.platform != "macosx") || + !AppConstants.MOZILLA_OFFICIAL || + Services.env.get("MOZ_AUTOMATION") + ) { + return; + } + let osKeyStore = Cc["@mozilla.org/security/oskeystore;1"].getService( + Ci.nsIOSKeyStore + ); + let label = Services.prefs.getCharPref("security.oskeystore.test.label", ""); + if (!label) { + label = Services.uuid.generateUUID().toString().slice(1, -1); + Services.prefs.setCharPref("security.oskeystore.test.label", label); + try { + await osKeyStore.asyncGenerateSecret(label); + Glean.oskeystore.selfTest.generate.set(true); + } catch (_) { + Glean.oskeystore.selfTest.generate.set(false); + return; + } + } + let secretAvailable = await osKeyStore.asyncSecretAvailable(label); + Glean.oskeystore.selfTest.available.set(secretAvailable); + if (!secretAvailable) { + return; + } + let encrypted = Services.prefs.getCharPref( + "security.oskeystore.test.encrypted", + "" + ); + if (!encrypted) { + try { + encrypted = await osKeyStore.asyncEncryptBytes(label, [1, 1, 3, 8]); + Services.prefs.setCharPref( + "security.oskeystore.test.encrypted", + encrypted + ); + Glean.oskeystore.selfTest.encrypt.set(true); + } catch (_) { + Glean.oskeystore.selfTest.encrypt.set(false); + return; + } + } + try { + let decrypted = await osKeyStore.asyncDecryptBytes(label, encrypted); + if ( + decrypted.length != 4 || + decrypted[0] != 1 || + decrypted[1] != 1 || + decrypted[2] != 3 || + decrypted[3] != 8 + ) { + throw new Error("decrypted value not as expected?"); + } + Glean.oskeystore.selfTest.decrypt.set(true); + } catch (_) { + Glean.oskeystore.selfTest.decrypt.set(false); + } +} diff --git a/browser/components/StartupRecorder.sys.mjs b/browser/components/StartupRecorder.sys.mjs new file mode 100644 index 0000000000..7b69b52690 --- /dev/null +++ b/browser/components/StartupRecorder.sys.mjs @@ -0,0 +1,228 @@ +/* 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 Cm = Components.manager; +Cm.QueryInterface(Ci.nsIServiceManager); + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +let firstPaintNotification = "widget-first-paint"; +// widget-first-paint fires much later than expected on Linux. +if ( + AppConstants.platform == "linux" || + Services.prefs.getBoolPref("browser.startup.preXulSkeletonUI", false) +) { + firstPaintNotification = "xul-window-visible"; +} + +let win, canvas; +let paints = []; +let afterPaintListener = () => { + let width, height; + canvas.width = width = win.innerWidth; + canvas.height = height = win.innerHeight; + if (width < 1 || height < 1) { + return; + } + let ctx = canvas.getContext("2d", { alpha: false, willReadFrequently: true }); + + ctx.drawWindow( + win, + 0, + 0, + width, + height, + "white", + ctx.DRAWWINDOW_DO_NOT_FLUSH | + ctx.DRAWWINDOW_DRAW_VIEW | + ctx.DRAWWINDOW_ASYNC_DECODE_IMAGES | + ctx.DRAWWINDOW_USE_WIDGET_LAYERS + ); + paints.push({ + data: ctx.getImageData(0, 0, width, height).data, + width, + height, + }); +}; + +/** + * The StartupRecorder component observes notifications at various stages of + * startup and records the set of JS modules that were already loaded at + * each of these points. + * The records are meant to be used by startup tests in + * browser/base/content/test/performance + * This component only exists in nightly and debug builds, it doesn't ship in + * our release builds. + */ +export function StartupRecorder() { + this.wrappedJSObject = this; + this.data = { + images: { + "image-drawing": new Set(), + "image-loading": new Set(), + }, + code: {}, + extras: {}, + prefStats: {}, + }; + this.done = new Promise(resolve => { + this._resolve = resolve; + }); +} + +StartupRecorder.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + + record(name) { + ChromeUtils.addProfilerMarker("startupRecorder:" + name); + this.data.code[name] = { + modules: Cu.loadedJSModules.concat(Cu.loadedESModules), + services: Object.keys(Cc).filter(c => { + try { + return Cm.isServiceInstantiatedByContractID(c, Ci.nsISupports); + } catch (e) { + return false; + } + }), + }; + this.data.extras[name] = { + hiddenWindowLoaded: Services.appShell.hasHiddenWindow, + }; + }, + + observe(subject, topic, data) { + if (topic == "app-startup" || topic == "content-process-ready-for-script") { + // Don't do anything in xpcshell. + if (Services.appinfo.ID != "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}") { + return; + } + + if ( + !Services.prefs.getBoolPref("browser.startup.record", false) && + !Services.prefs.getBoolPref("browser.startup.recordImages", false) + ) { + this._resolve(); + this._resolve = null; + return; + } + + // We can't ensure our observer will be called first or last, so the list of + // topics we observe here should avoid the topics used to trigger things + // during startup (eg. the topics observed by BrowserGlue.sys.mjs). + let topics = [ + "profile-do-change", // This catches stuff loaded during app-startup + "toplevel-window-ready", // Catches stuff from final-ui-startup + firstPaintNotification, + "sessionstore-windows-restored", + "browser-startup-idle-tasks-finished", + ]; + + if (Services.prefs.getBoolPref("browser.startup.recordImages", false)) { + // For code simplicify, recording images excludes the other startup + // recorder behaviors, so we can observe only the image topics. + topics = [ + "image-loading", + "image-drawing", + "browser-startup-idle-tasks-finished", + ]; + } + for (let t of topics) { + Services.obs.addObserver(this, t); + } + return; + } + + // We only care about the first paint notification for browser windows, and + // not other types (for example, the gfx sanity test window) + if (topic == firstPaintNotification) { + // In the case we're handling xul-window-visible, we'll have been handed + // an nsIAppWindow instead of an nsIDOMWindow. + if (subject instanceof Ci.nsIAppWindow) { + subject = subject + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + } + + if ( + subject.document.documentElement.getAttribute("windowtype") != + "navigator:browser" + ) { + return; + } + } + + if (topic == "image-drawing" || topic == "image-loading") { + this.data.images[topic].add(data); + return; + } + + Services.obs.removeObserver(this, topic); + + if (topic == firstPaintNotification) { + // Because of the check for navigator:browser we made earlier, we know + // that if we got here, then the subject must be the first browser window. + win = subject; + canvas = win.document.createElementNS( + "http://www.w3.org/1999/xhtml", + "canvas" + ); + canvas.mozOpaque = true; + afterPaintListener(); + win.addEventListener("MozAfterPaint", afterPaintListener); + } + + if (topic == "sessionstore-windows-restored") { + // We use idleDispatchToMainThread here to record the set of + // loaded scripts after we are fully done with startup and ready + // to react to user events. + Services.tm.dispatchToMainThread( + this.record.bind(this, "before handling user events") + ); + } else if (topic == "browser-startup-idle-tasks-finished") { + if (Services.prefs.getBoolPref("browser.startup.recordImages", false)) { + Services.obs.removeObserver(this, "image-drawing"); + Services.obs.removeObserver(this, "image-loading"); + this._resolve(); + this._resolve = null; + return; + } + + this.record("before becoming idle"); + win.removeEventListener("MozAfterPaint", afterPaintListener); + win = null; + this.data.frames = paints; + this.data.prefStats = {}; + if (AppConstants.DEBUG) { + Services.prefs.readStats( + (key, value) => (this.data.prefStats[key] = value) + ); + } + paints = null; + + if (!Services.env.exists("MOZ_PROFILER_STARTUP_PERFORMANCE_TEST")) { + this._resolve(); + this._resolve = null; + return; + } + + Services.profiler.getProfileDataAsync().then(profileData => { + this.data.profile = profileData; + // There's no equivalent StartProfiler call in this file because the + // profiler is started using the MOZ_PROFILER_STARTUP environment + // variable in browser/base/content/test/performance/browser.ini + Services.profiler.StopProfiler(); + + this._resolve(); + this._resolve = null; + }); + } else { + const topicsToNames = { + "profile-do-change": "before profile selection", + "toplevel-window-ready": "before opening first browser window", + }; + topicsToNames[firstPaintNotification] = "before first paint"; + this.record(topicsToNames[topic]); + } + }, +}; diff --git a/browser/components/about/AboutRedirector.cpp b/browser/components/about/AboutRedirector.cpp new file mode 100644 index 0000000000..6f01344014 --- /dev/null +++ b/browser/components/about/AboutRedirector.cpp @@ -0,0 +1,309 @@ +/* -*- 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/. */ + +// See also: docshell/base/nsAboutRedirector.cpp + +#include "AboutRedirector.h" +#include "nsNetUtil.h" +#include "nsIAboutNewTabService.h" +#include "nsIChannel.h" +#include "nsIURI.h" +#include "nsIProtocolHandler.h" +#include "nsServiceManagerUtils.h" +#include "mozilla/StaticPrefs_browser.h" +#include "mozilla/dom/ContentChild.h" + +namespace mozilla { +namespace browser { + +NS_IMPL_ISUPPORTS(AboutRedirector, nsIAboutModule) + +static const uint32_t ACTIVITY_STREAM_FLAGS = + nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::ENABLE_INDEXED_DB | + nsIAboutModule::URI_MUST_LOAD_IN_CHILD | + nsIAboutModule::URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS | + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT; + +struct RedirEntry { + const char* id; + const char* url; + uint32_t flags; +}; + +/* + Entries which do not have URI_SAFE_FOR_UNTRUSTED_CONTENT will run with chrome + privileges. This is potentially dangerous. Please use + URI_SAFE_FOR_UNTRUSTED_CONTENT in the third argument to each map item below + unless your about: page really needs chrome privileges. Security review is + required before adding new map entries without + URI_SAFE_FOR_UNTRUSTED_CONTENT. + + NOTE: changes to this redir map need to be accompanied with changes to + browser/components/about/components.conf +*/ +static const RedirEntry kRedirMap[] = { + {"asrouter", "chrome://browser/content/asrouter/asrouter-admin.html", + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS | + nsIAboutModule::URI_MUST_LOAD_IN_CHILD | nsIAboutModule::ALLOW_SCRIPT | + nsIAboutModule::HIDE_FROM_ABOUTABOUT}, + {"blocked", "chrome://browser/content/blockedSite.xhtml", + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::URI_CAN_LOAD_IN_CHILD | nsIAboutModule::ALLOW_SCRIPT | + nsIAboutModule::HIDE_FROM_ABOUTABOUT}, + {"certerror", "chrome://global/content/aboutNetError.html", + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::URI_CAN_LOAD_IN_CHILD | nsIAboutModule::ALLOW_SCRIPT | + nsIAboutModule::HIDE_FROM_ABOUTABOUT}, + {"unloads", "chrome://browser/content/tabunloader/aboutUnloads.html", + nsIAboutModule::ALLOW_SCRIPT}, + {"framecrashed", "chrome://browser/content/aboutFrameCrashed.html", + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::URI_CAN_LOAD_IN_CHILD | + nsIAboutModule::HIDE_FROM_ABOUTABOUT}, + {"logins", "chrome://browser/content/aboutlogins/aboutLogins.html", + nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::URI_MUST_LOAD_IN_CHILD | + nsIAboutModule::URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS | + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::IS_SECURE_CHROME_UI}, + {"loginsimportreport", + "chrome://browser/content/aboutlogins/aboutLoginsImportReport.html", + nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::URI_MUST_LOAD_IN_CHILD | + nsIAboutModule::URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS | + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::IS_SECURE_CHROME_UI}, + {"firefoxview", "chrome://browser/content/firefoxview/firefoxview.html", + nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::IS_SECURE_CHROME_UI | + nsIAboutModule::HIDE_FROM_ABOUTABOUT}, + {"policies", "chrome://browser/content/policies/aboutPolicies.html", + nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::IS_SECURE_CHROME_UI}, + {"privatebrowsing", "chrome://browser/content/aboutPrivateBrowsing.html", + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::URI_MUST_LOAD_IN_CHILD | nsIAboutModule::ALLOW_SCRIPT | + nsIAboutModule::URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS}, + {"profiling", + "chrome://devtools/content/performance-new/aboutprofiling/index.xhtml", + nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::IS_SECURE_CHROME_UI}, + {"rights", "chrome://global/content/aboutRights.xhtml", + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::IS_SECURE_CHROME_UI}, + {"robots", "chrome://browser/content/aboutRobots.xhtml", + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::ALLOW_SCRIPT}, + {"sessionrestore", "chrome://browser/content/aboutSessionRestore.xhtml", + nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::HIDE_FROM_ABOUTABOUT | + nsIAboutModule::IS_SECURE_CHROME_UI}, + {"shoppingsidebar", "chrome://browser/content/shopping/shopping.html", + nsIAboutModule::URI_MUST_LOAD_IN_CHILD | + nsIAboutModule::URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS | + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::HIDE_FROM_ABOUTABOUT | + nsIAboutModule::IS_SECURE_CHROME_UI}, + {"tabcrashed", "chrome://browser/content/aboutTabCrashed.xhtml", + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::HIDE_FROM_ABOUTABOUT}, + {"welcomeback", "chrome://browser/content/aboutWelcomeBack.xhtml", + nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::HIDE_FROM_ABOUTABOUT | + nsIAboutModule::IS_SECURE_CHROME_UI}, + // Actual activity stream URL for home and newtab are set in channel + // creation + {"home", "about:blank", ACTIVITY_STREAM_FLAGS}, + {"newtab", "chrome://browser/content/blanktab.html", ACTIVITY_STREAM_FLAGS}, + {"welcome", "about:blank", + nsIAboutModule::URI_MUST_LOAD_IN_CHILD | + nsIAboutModule::URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS | + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::ALLOW_SCRIPT}, + {"messagepreview", + "chrome://browser/content/messagepreview/messagepreview.html", + nsIAboutModule::URI_MUST_LOAD_IN_CHILD | + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::HIDE_FROM_ABOUTABOUT}, + {"pocket-saved", "chrome://pocket/content/panels/saved.html", + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::URI_MUST_LOAD_IN_CHILD | nsIAboutModule::ALLOW_SCRIPT | + nsIAboutModule::URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS | + nsIAboutModule::HIDE_FROM_ABOUTABOUT}, + {"pocket-signup", "chrome://pocket/content/panels/signup.html", + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::URI_MUST_LOAD_IN_CHILD | nsIAboutModule::ALLOW_SCRIPT | + nsIAboutModule::URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS | + nsIAboutModule::HIDE_FROM_ABOUTABOUT}, + {"pocket-home", "chrome://pocket/content/panels/home.html", + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::URI_MUST_LOAD_IN_CHILD | nsIAboutModule::ALLOW_SCRIPT | + nsIAboutModule::URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS | + nsIAboutModule::HIDE_FROM_ABOUTABOUT}, + {"pocket-style-guide", "chrome://pocket/content/panels/style-guide.html", + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::URI_MUST_LOAD_IN_CHILD | nsIAboutModule::ALLOW_SCRIPT | + nsIAboutModule::URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS | + nsIAboutModule::HIDE_FROM_ABOUTABOUT}, + {"preferences", "chrome://browser/content/preferences/preferences.xhtml", + nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::IS_SECURE_CHROME_UI}, + {"downloads", + "chrome://browser/content/downloads/contentAreaDownloadsView.xhtml", + nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::IS_SECURE_CHROME_UI}, + {"reader", "chrome://global/content/reader/aboutReader.html", + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::URI_MUST_LOAD_IN_CHILD | + nsIAboutModule::HIDE_FROM_ABOUTABOUT}, + {"restartrequired", "chrome://browser/content/aboutRestartRequired.xhtml", + nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::HIDE_FROM_ABOUTABOUT}, + {"protections", "chrome://browser/content/protections.html", + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::URI_MUST_LOAD_IN_CHILD | nsIAboutModule::ALLOW_SCRIPT | + nsIAboutModule::URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS | + nsIAboutModule::IS_SECURE_CHROME_UI}, + {"ion", "chrome://browser/content/ion.html", + nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::HIDE_FROM_ABOUTABOUT | + nsIAboutModule::IS_SECURE_CHROME_UI}, +}; + +static nsAutoCString GetAboutModuleName(nsIURI* aURI) { + nsAutoCString path; + aURI->GetPathQueryRef(path); + + int32_t f = path.FindChar('#'); + if (f >= 0) path.SetLength(f); + + f = path.FindChar('?'); + if (f >= 0) path.SetLength(f); + + ToLowerCase(path); + return path; +} + +NS_IMETHODIMP +AboutRedirector::NewChannel(nsIURI* aURI, nsILoadInfo* aLoadInfo, + nsIChannel** result) { + NS_ENSURE_ARG_POINTER(aURI); + NS_ENSURE_ARG_POINTER(aLoadInfo); + + NS_ASSERTION(result, "must not be null"); + + nsAutoCString path = GetAboutModuleName(aURI); + + nsresult rv; + nsCOMPtr ioService = do_GetIOService(&rv); + NS_ENSURE_SUCCESS(rv, rv); + + // If we're accessing about:home in the "privileged about content + // process", then we give the nsIAboutNewTabService the responsibility + // to return the nsIChannel, since it might be from the about:home + // startup cache. + if (XRE_IsContentProcess() && path.EqualsLiteral("home")) { + auto& remoteType = dom::ContentChild::GetSingleton()->GetRemoteType(); + if (remoteType == PRIVILEGEDABOUT_REMOTE_TYPE) { + nsCOMPtr aboutNewTabService = + do_GetService("@mozilla.org/browser/aboutnewtab-service;1", &rv); + NS_ENSURE_SUCCESS(rv, rv); + + return aboutNewTabService->AboutHomeChannel(aURI, aLoadInfo, result); + } + } + + for (auto& redir : kRedirMap) { + if (!strcmp(path.get(), redir.id)) { + nsAutoCString url; + + // Let the aboutNewTabService decide where to redirect for about:home and + // enabled about:newtab. Disabled about:newtab page uses fallback. + if (path.EqualsLiteral("home") || + (StaticPrefs::browser_newtabpage_enabled() && + path.EqualsLiteral("newtab"))) { + nsCOMPtr aboutNewTabService = + do_GetService("@mozilla.org/browser/aboutnewtab-service;1", &rv); + NS_ENSURE_SUCCESS(rv, rv); + rv = aboutNewTabService->GetDefaultURL(url); + NS_ENSURE_SUCCESS(rv, rv); + } + + if (path.EqualsLiteral("welcome")) { + nsCOMPtr aboutNewTabService = + do_GetService("@mozilla.org/browser/aboutnewtab-service;1", &rv); + NS_ENSURE_SUCCESS(rv, rv); + rv = aboutNewTabService->GetWelcomeURL(url); + NS_ENSURE_SUCCESS(rv, rv); + } + + // fall back to the specified url in the map + if (url.IsEmpty()) { + url.AssignASCII(redir.url); + } + + nsCOMPtr tempChannel; + nsCOMPtr tempURI; + rv = NS_NewURI(getter_AddRefs(tempURI), url); + NS_ENSURE_SUCCESS(rv, rv); + + // If tempURI links to an external URI (i.e. something other than + // chrome:// or resource://) then set the result principal URI on the + // load info which forces the channel prncipal to reflect the displayed + // URL rather then being the systemPrincipal. + bool isUIResource = false; + rv = NS_URIChainHasFlags(tempURI, nsIProtocolHandler::URI_IS_UI_RESOURCE, + &isUIResource); + NS_ENSURE_SUCCESS(rv, rv); + + rv = NS_NewChannelInternal(getter_AddRefs(tempChannel), tempURI, + aLoadInfo); + NS_ENSURE_SUCCESS(rv, rv); + + if (!isUIResource) { + aLoadInfo->SetResultPrincipalURI(tempURI); + } + tempChannel->SetOriginalURI(aURI); + + NS_ADDREF(*result = tempChannel); + return rv; + } + } + + return NS_ERROR_ILLEGAL_VALUE; +} + +NS_IMETHODIMP +AboutRedirector::GetURIFlags(nsIURI* aURI, uint32_t* result) { + NS_ENSURE_ARG_POINTER(aURI); + + nsAutoCString name = GetAboutModuleName(aURI); + + for (auto& redir : kRedirMap) { + if (name.Equals(redir.id)) { + *result = redir.flags; + return NS_OK; + } + } + + return NS_ERROR_ILLEGAL_VALUE; +} + +NS_IMETHODIMP +AboutRedirector::GetChromeURI(nsIURI* aURI, nsIURI** chromeURI) { + NS_ENSURE_ARG_POINTER(aURI); + + nsAutoCString name = GetAboutModuleName(aURI); + + for (const auto& redir : kRedirMap) { + if (name.Equals(redir.id)) { + return NS_NewURI(chromeURI, redir.url); + } + } + + return NS_ERROR_ILLEGAL_VALUE; +} + +nsresult AboutRedirector::Create(REFNSIID aIID, void** result) { + AboutRedirector* about = new AboutRedirector(); + if (about == nullptr) return NS_ERROR_OUT_OF_MEMORY; + NS_ADDREF(about); + nsresult rv = about->QueryInterface(aIID, result); + NS_RELEASE(about); + return rv; +} + +} // namespace browser +} // namespace mozilla diff --git a/browser/components/about/AboutRedirector.h b/browser/components/about/AboutRedirector.h new file mode 100644 index 0000000000..87addd2671 --- /dev/null +++ b/browser/components/about/AboutRedirector.h @@ -0,0 +1,30 @@ +/* -*- 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 AboutRedirector_h__ +#define AboutRedirector_h__ + +#include "nsIAboutModule.h" + +namespace mozilla { +namespace browser { + +class AboutRedirector : public nsIAboutModule { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSIABOUTMODULE + + AboutRedirector() {} + + static nsresult Create(REFNSIID aIID, void** aResult); + + protected: + virtual ~AboutRedirector() {} +}; + +} // namespace browser +} // namespace mozilla + +#endif // AboutRedirector_h__ diff --git a/browser/components/about/components.conf b/browser/components/about/components.conf new file mode 100644 index 0000000000..c03994b11c --- /dev/null +++ b/browser/components/about/components.conf @@ -0,0 +1,49 @@ +# -*- 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/. + +pages = [ + 'asrouter', + 'blocked', + 'certerror', + 'downloads', + 'framecrashed', + 'home', + 'logins', + 'loginsimportreport', + 'firefoxview', + 'messagepreview', + 'newtab', + 'ion', + 'pocket-home', + 'pocket-saved', + 'pocket-signup', + 'pocket-style-guide', + 'policies', + 'preferences', + 'privatebrowsing', + 'protections', + 'profiling', + 'reader', + 'restartrequired', + 'rights', + 'robots', + 'sessionrestore', + 'shoppingsidebar', + 'tabcrashed', + 'unloads', + 'welcome', + 'welcomeback', +] + +Classes = [ + { + 'cid': '{7e4bb6ad-2fc4-4dc6-89ef-23e8e5ccf980}', + 'contract_ids': ['@mozilla.org/network/protocol/about;1?what=%s' % page + for page in pages], + 'legacy_constructor': 'mozilla::browser::AboutRedirector::Create', + 'headers': ['/browser/components/about/AboutRedirector.h'], + }, +] diff --git a/browser/components/about/moz.build b/browser/components/about/moz.build new file mode 100644 index 0000000000..994f084510 --- /dev/null +++ b/browser/components/about/moz.build @@ -0,0 +1,32 @@ +# -*- 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/. + +with Files("**"): + BUG_COMPONENT = ("Firefox", "General") + +EXPORTS.mozilla.browser += [ + "AboutRedirector.h", +] + +XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.toml"] + +SOURCES += [ + "AboutRedirector.cpp", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +FINAL_LIBRARY = "browsercomps" + +LOCAL_INCLUDES += [ + "../build", + "/dom/base", + "/ipc/chromium/src", +] + +include("/ipc/chromium/chromium-config.mozbuild") diff --git a/browser/components/about/test/unit/test_getURIFlags.js b/browser/components/about/test/unit/test_getURIFlags.js new file mode 100644 index 0000000000..09846b8122 --- /dev/null +++ b/browser/components/about/test/unit/test_getURIFlags.js @@ -0,0 +1,14 @@ +const contract = "@mozilla.org/network/protocol/about;1?what=newtab"; +const am = Cc[contract].getService(Ci.nsIAboutModule); +const uri = Services.io.newURI("about:newtab"); + +function run_test() { + test_AS_enabled_flags(); +} + +// Activity Stream, however, is e10s-capable, and should advertise it. +function test_AS_enabled_flags() { + let flags = am.getURIFlags(uri); + + ok(flags & Ci.nsIAboutModule.URI_MUST_LOAD_IN_CHILD); +} diff --git a/browser/components/about/test/unit/xpcshell.toml b/browser/components/about/test/unit/xpcshell.toml new file mode 100644 index 0000000000..c71e0f2449 --- /dev/null +++ b/browser/components/about/test/unit/xpcshell.toml @@ -0,0 +1,7 @@ +[DEFAULT] +skip-if = ["os == 'android'"] # bug 1730213 +head = '' +# make the firefox services (eg newtab-service) available to xpcshell +firefox-appdir = "browser" + +["test_getURIFlags.js"] diff --git a/browser/components/aboutlogins/AboutLoginsChild.sys.mjs b/browser/components/aboutlogins/AboutLoginsChild.sys.mjs new file mode 100644 index 0000000000..3fcdf77923 --- /dev/null +++ b/browser/components/aboutlogins/AboutLoginsChild.sys.mjs @@ -0,0 +1,313 @@ +/* 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 { LoginHelper } from "resource://gre/modules/LoginHelper.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "ClipboardHelper", + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper" +); + +const TELEMETRY_EVENT_CATEGORY = "pwmgr"; +const TELEMETRY_MIN_MS_BETWEEN_OPEN_MANAGEMENT = 5000; + +let gLastOpenManagementBrowserId = null; +let gLastOpenManagementEventTime = Number.NEGATIVE_INFINITY; +let gPrimaryPasswordPromise; + +function recordTelemetryEvent(event) { + try { + let { method, object, extra = {}, value = null } = event; + Services.telemetry.recordEvent( + TELEMETRY_EVENT_CATEGORY, + method, + object, + value, + extra + ); + } catch (ex) { + console.error("AboutLoginsChild: error recording telemetry event:", ex); + } +} + +export class AboutLoginsChild extends JSWindowActorChild { + handleEvent(event) { + switch (event.type) { + case "AboutLoginsInit": { + this.#aboutLoginsInit(); + break; + } + case "AboutLoginsImportReportInit": { + this.#aboutLoginsImportReportInit(); + break; + } + case "AboutLoginsCopyLoginDetail": { + this.#aboutLoginsCopyLoginDetail(event.detail); + break; + } + case "AboutLoginsCreateLogin": { + this.#aboutLoginsCreateLogin(event.detail); + break; + } + case "AboutLoginsDeleteLogin": { + this.#aboutLoginsDeleteLogin(event.detail); + break; + } + case "AboutLoginsExportPasswords": { + this.#aboutLoginsExportPasswords(); + break; + } + case "AboutLoginsGetHelp": { + this.#aboutLoginsGetHelp(); + break; + } + case "AboutLoginsImportFromBrowser": { + this.#aboutLoginsImportFromBrowser(); + break; + } + case "AboutLoginsImportFromFile": { + this.#aboutLoginsImportFromFile(); + break; + } + case "AboutLoginsOpenPreferences": { + this.#aboutLoginsOpenPreferences(); + break; + } + case "AboutLoginsRecordTelemetryEvent": { + this.#aboutLoginsRecordTelemetryEvent(event); + break; + } + case "AboutLoginsRemoveAllLogins": { + this.#aboutLoginsRemoveAllLogins(); + break; + } + case "AboutLoginsSortChanged": { + this.#aboutLoginsSortChanged(event.detail); + break; + } + case "AboutLoginsSyncEnable": { + this.#aboutLoginsSyncEnable(); + break; + } + case "AboutLoginsSyncOptions": { + this.#aboutLoginsSyncOptions(); + break; + } + case "AboutLoginsUpdateLogin": { + this.#aboutLoginsUpdateLogin(event.detail); + break; + } + } + } + + #aboutLoginsInit() { + this.sendAsyncMessage("AboutLogins:Subscribe"); + + let win = this.browsingContext.window; + let waivedContent = Cu.waiveXrays(win); + let that = this; + let AboutLoginsUtils = { + doLoginsMatch(loginA, loginB) { + return LoginHelper.doLoginsMatch(loginA, loginB, {}); + }, + getLoginOrigin(uriString) { + return LoginHelper.getLoginOrigin(uriString); + }, + setFocus(element) { + Services.focus.setFocus(element, Services.focus.FLAG_BYKEY); + }, + /** + * Shows the Primary Password prompt if enabled, or the + * OS auth dialog otherwise. + * @param resolve Callback that is called with result of authentication. + * @param messageId The string ID that corresponds to a string stored in aboutLogins.ftl. + * This string will be displayed only when the OS auth dialog is used. + */ + async promptForPrimaryPassword(resolve, messageId) { + gPrimaryPasswordPromise = { + resolve, + }; + + that.sendAsyncMessage("AboutLogins:PrimaryPasswordRequest", messageId); + + return gPrimaryPasswordPromise; + }, + fileImportEnabled: Services.prefs.getBoolPref( + "signon.management.page.fileImport.enabled" + ), + // Default to enabled just in case a search is attempted before we get a response. + primaryPasswordEnabled: true, + passwordRevealVisible: true, + }; + waivedContent.AboutLoginsUtils = Cu.cloneInto( + AboutLoginsUtils, + waivedContent, + { + cloneFunctions: true, + } + ); + } + + #aboutLoginsImportReportInit() { + this.sendAsyncMessage("AboutLogins:ImportReportInit"); + } + + #aboutLoginsCopyLoginDetail(detail) { + lazy.ClipboardHelper.copyString(detail, lazy.ClipboardHelper.Sensitive); + } + + #aboutLoginsCreateLogin(login) { + this.sendAsyncMessage("AboutLogins:CreateLogin", { + login, + }); + } + + #aboutLoginsDeleteLogin(login) { + this.sendAsyncMessage("AboutLogins:DeleteLogin", { + login, + }); + } + + #aboutLoginsExportPasswords() { + this.sendAsyncMessage("AboutLogins:ExportPasswords"); + } + + #aboutLoginsGetHelp() { + this.sendAsyncMessage("AboutLogins:GetHelp"); + } + + #aboutLoginsImportFromBrowser() { + this.sendAsyncMessage("AboutLogins:ImportFromBrowser"); + recordTelemetryEvent({ + object: "import_from_browser", + method: "mgmt_menu_item_used", + }); + } + + #aboutLoginsImportFromFile() { + this.sendAsyncMessage("AboutLogins:ImportFromFile"); + recordTelemetryEvent({ + object: "import_from_csv", + method: "mgmt_menu_item_used", + }); + } + + #aboutLoginsOpenPreferences() { + this.sendAsyncMessage("AboutLogins:OpenPreferences"); + recordTelemetryEvent({ + object: "preferences", + method: "mgmt_menu_item_used", + }); + } + + #aboutLoginsRecordTelemetryEvent(event) { + let { method } = event.detail; + + if (method == "open_management") { + let { docShell } = this.browsingContext; + // Compare to the last time open_management was recorded for the same + // outerWindowID to not double-count them due to a redirect to remove + // the entryPoint query param (since replaceState isn't allowed for + // about:). Don't use performance.now for the tab since you can't + // compare that number between different tabs and this JSM is shared. + let now = docShell.now(); + if ( + this.browsingContext.browserId == gLastOpenManagementBrowserId && + now - gLastOpenManagementEventTime < + TELEMETRY_MIN_MS_BETWEEN_OPEN_MANAGEMENT + ) { + return; + } + gLastOpenManagementEventTime = now; + gLastOpenManagementBrowserId = this.browsingContext.browserId; + } + recordTelemetryEvent(event.detail); + } + + #aboutLoginsRemoveAllLogins() { + this.sendAsyncMessage("AboutLogins:RemoveAllLogins"); + } + + #aboutLoginsSortChanged(detail) { + this.sendAsyncMessage("AboutLogins:SortChanged", detail); + } + + #aboutLoginsSyncEnable() { + this.sendAsyncMessage("AboutLogins:SyncEnable"); + } + + #aboutLoginsSyncOptions() { + this.sendAsyncMessage("AboutLogins:SyncOptions"); + } + + #aboutLoginsUpdateLogin(login) { + this.sendAsyncMessage("AboutLogins:UpdateLogin", { + login, + }); + } + + receiveMessage(message) { + switch (message.name) { + case "AboutLogins:ImportReportData": + this.#importReportData(message.data); + break; + case "AboutLogins:PrimaryPasswordResponse": + this.#primaryPasswordResponse(message.data); + break; + case "AboutLogins:RemaskPassword": + this.#remaskPassword(message.data); + break; + case "AboutLogins:Setup": + this.#setup(message.data); + break; + default: + this.#passMessageDataToContent(message); + } + } + + #importReportData(data) { + this.sendToContent("ImportReportData", data); + } + + #primaryPasswordResponse(data) { + if (gPrimaryPasswordPromise) { + gPrimaryPasswordPromise.resolve(data.result); + recordTelemetryEvent(data.telemetryEvent); + } + } + + #remaskPassword(data) { + this.sendToContent("RemaskPassword", data); + } + + #setup(data) { + let utils = Cu.waiveXrays(this.browsingContext.window).AboutLoginsUtils; + utils.primaryPasswordEnabled = data.primaryPasswordEnabled; + utils.passwordRevealVisible = data.passwordRevealVisible; + utils.importVisible = data.importVisible; + utils.supportBaseURL = Services.urlFormatter.formatURLPref( + "app.support.baseURL" + ); + this.sendToContent("Setup", data); + } + + #passMessageDataToContent(message) { + this.sendToContent(message.name.replace("AboutLogins:", ""), message.data); + } + + sendToContent(messageType, detail) { + let win = this.document.defaultView; + let message = Object.assign({ messageType }, { value: detail }); + let event = new win.CustomEvent("AboutLoginsChromeToContent", { + detail: Cu.cloneInto(message, win), + }); + win.dispatchEvent(event); + } +} diff --git a/browser/components/aboutlogins/AboutLoginsParent.sys.mjs b/browser/components/aboutlogins/AboutLoginsParent.sys.mjs new file mode 100644 index 0000000000..75b695cdd2 --- /dev/null +++ b/browser/components/aboutlogins/AboutLoginsParent.sys.mjs @@ -0,0 +1,884 @@ +/* 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/. */ + +// _AboutLogins is only exported for testing +import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { E10SUtils } from "resource://gre/modules/E10SUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LoginBreaches: "resource:///modules/LoginBreaches.sys.mjs", + LoginCSVImport: "resource://gre/modules/LoginCSVImport.sys.mjs", + 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", +}); + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + return lazy.LoginHelper.createLogger("AboutLoginsParent"); +}); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "BREACH_ALERTS_ENABLED", + "signon.management.page.breach-alerts.enabled", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "FXA_ENABLED", + "identity.fxaccounts.enabled", + false +); +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 +); +ChromeUtils.defineLazyGetter(lazy, "AboutLoginsL10n", () => { + return new Localization(["branding/brand.ftl", "browser/aboutLogins.ftl"]); +}); + +const ABOUT_LOGINS_ORIGIN = "about:logins"; +const AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes +const PRIMARY_PASSWORD_NOTIFICATION_ID = "primary-password-login-required"; + +// about:logins will always use the privileged content process, +// even if it is disabled for other consumers such as about:newtab. +const EXPECTED_ABOUTLOGINS_REMOTE_TYPE = E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE; +let _gPasswordRemaskTimeout = null; +const convertSubjectToLogin = subject => { + subject.QueryInterface(Ci.nsILoginMetaInfo).QueryInterface(Ci.nsILoginInfo); + const login = lazy.LoginHelper.loginToVanillaObject(subject); + if (!lazy.LoginHelper.isUserFacingLogin(login)) { + return null; + } + return augmentVanillaLoginObject(login); +}; + +const SUBDOMAIN_REGEX = new RegExp(/^www\d*\./); +const augmentVanillaLoginObject = login => { + // Note that `displayOrigin` can also include a httpRealm. + let title = login.displayOrigin.replace(SUBDOMAIN_REGEX, ""); + return Object.assign({}, login, { + title, + }); +}; + +const EXPORT_PASSWORD_OS_AUTH_DIALOG_MESSAGE_IDS = { + win: "about-logins-export-password-os-auth-dialog-message2-win", + macosx: "about-logins-export-password-os-auth-dialog-message2-macosx", +}; + +export class AboutLoginsParent extends JSWindowActorParent { + async receiveMessage(message) { + if (!this.browsingContext.embedderElement) { + return; + } + + // Only respond to messages sent from a privlegedabout process. Ideally + // we would also check the contentPrincipal.originNoSuffix but this + // check has been removed due to bug 1576722. + if ( + this.browsingContext.embedderElement.remoteType != + EXPECTED_ABOUTLOGINS_REMOTE_TYPE + ) { + throw new Error( + `AboutLoginsParent: Received ${message.name} message the remote type didn't match expectations: ${this.browsingContext.embedderElement.remoteType} == ${EXPECTED_ABOUTLOGINS_REMOTE_TYPE}` + ); + } + + AboutLogins.subscribers.add(this.browsingContext); + + switch (message.name) { + case "AboutLogins:CreateLogin": { + await this.#createLogin(message.data.login); + break; + } + case "AboutLogins:DeleteLogin": { + this.#deleteLogin(message.data.login); + break; + } + case "AboutLogins:SortChanged": { + this.#sortChanged(message.data); + break; + } + case "AboutLogins:SyncEnable": { + this.#syncEnable(); + break; + } + case "AboutLogins:SyncOptions": { + this.#syncOptions(); + break; + } + case "AboutLogins:ImportFromBrowser": { + this.#importFromBrowser(); + break; + } + case "AboutLogins:ImportReportInit": { + this.#importReportInit(); + break; + } + case "AboutLogins:GetHelp": { + this.#getHelp(); + break; + } + case "AboutLogins:OpenPreferences": { + this.#openPreferences(); + break; + } + case "AboutLogins:PrimaryPasswordRequest": { + await this.#primaryPasswordRequest(message.data); + break; + } + case "AboutLogins:Subscribe": { + await this.#subscribe(); + break; + } + case "AboutLogins:UpdateLogin": { + this.#updateLogin(message.data.login); + break; + } + case "AboutLogins:ExportPasswords": { + await this.#exportPasswords(); + break; + } + case "AboutLogins:ImportFromFile": { + await this.#importFromFile(); + break; + } + case "AboutLogins:RemoveAllLogins": { + this.#removeAllLogins(); + break; + } + } + } + + get #ownerGlobal() { + return this.browsingContext.embedderElement?.ownerGlobal; + } + + async #createLogin(newLogin) { + if (!Services.policies.isAllowed("removeMasterPassword")) { + if (!lazy.LoginHelper.isPrimaryPasswordSet()) { + this.#ownerGlobal.openDialog( + "chrome://mozapps/content/preferences/changemp.xhtml", + "", + "centerscreen,chrome,modal,titlebar" + ); + if (!lazy.LoginHelper.isPrimaryPasswordSet()) { + return; + } + } + } + // Remove the path from the origin, if it was provided. + let origin = lazy.LoginHelper.getLoginOrigin(newLogin.origin); + if (!origin) { + console.error( + "AboutLogins:CreateLogin: Unable to get an origin from the login details." + ); + return; + } + newLogin.origin = origin; + Object.assign(newLogin, { + formActionOrigin: "", + usernameField: "", + passwordField: "", + }); + newLogin = lazy.LoginHelper.vanillaObjectToLogin(newLogin); + try { + await Services.logins.addLoginAsync(newLogin); + } catch (error) { + this.#handleLoginStorageErrors(newLogin, error); + } + } + + get preselectedLogin() { + const preselectedLogin = + this.#ownerGlobal?.gBrowser.selectedTab.getAttribute("preselect-login") || + this.browsingContext.currentURI?.ref; + this.#ownerGlobal?.gBrowser.selectedTab.removeAttribute("preselect-login"); + return preselectedLogin || null; + } + + #deleteLogin(loginObject) { + let login = lazy.LoginHelper.vanillaObjectToLogin(loginObject); + Services.logins.removeLogin(login); + } + + #sortChanged(sort) { + Services.prefs.setCharPref("signon.management.page.sort", sort); + } + + #syncEnable() { + this.#ownerGlobal.gSync.openFxAEmailFirstPage("password-manager"); + } + + #syncOptions() { + this.#ownerGlobal.gSync.openFxAManagePage("password-manager"); + } + + #importFromBrowser() { + try { + lazy.MigrationUtils.showMigrationWizard(this.#ownerGlobal, { + entrypoint: lazy.MigrationUtils.MIGRATION_ENTRYPOINTS.PASSWORDS, + }); + } catch (ex) { + console.error(ex); + } + } + + #importReportInit() { + let reportData = lazy.LoginCSVImport.lastImportReport; + this.sendAsyncMessage("AboutLogins:ImportReportData", reportData); + } + + #getHelp() { + const SUPPORT_URL = + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "password-manager-remember-delete-edit-logins"; + this.#ownerGlobal.openWebLinkIn(SUPPORT_URL, "tab", { + relatedToCurrent: true, + }); + } + + #openPreferences() { + this.#ownerGlobal.openPreferences("privacy-logins"); + } + + async #primaryPasswordRequest(messageId) { + if (!messageId) { + throw new Error("AboutLogins:PrimaryPasswordRequest: no messageId."); + } + let messageText = { value: "NOT SUPPORTED" }; + let captionText = { value: "" }; + + // 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()) { + messageId += "-" + AppConstants.platform; + [messageText, captionText] = await lazy.AboutLoginsL10n.formatMessages([ + { + id: messageId, + }, + { + id: "about-logins-os-auth-dialog-caption", + }, + ]); + } + + let { isAuthorized, telemetryEvent } = await lazy.LoginHelper.requestReauth( + this.browsingContext.embedderElement, + lazy.OS_AUTH_ENABLED, + AboutLogins._authExpirationTime, + messageText.value, + captionText.value + ); + this.sendAsyncMessage("AboutLogins:PrimaryPasswordResponse", { + result: isAuthorized, + telemetryEvent, + }); + if (isAuthorized) { + AboutLogins._authExpirationTime = Date.now() + AUTH_TIMEOUT_MS; + const remaskPasswords = () => { + this.sendAsyncMessage("AboutLogins:RemaskPassword"); + }; + clearTimeout(_gPasswordRemaskTimeout); + _gPasswordRemaskTimeout = setTimeout(remaskPasswords, AUTH_TIMEOUT_MS); + } + } + + async #subscribe() { + AboutLogins._authExpirationTime = Number.NEGATIVE_INFINITY; + AboutLogins.addObservers(); + + const logins = await AboutLogins.getAllLogins(); + try { + let syncState = AboutLogins.getSyncState(); + + let selectedSort = Services.prefs.getCharPref( + "signon.management.page.sort", + "name" + ); + if (selectedSort == "breached") { + // The "breached" value was used since Firefox 70 and + // replaced with "alerts" in Firefox 76. + selectedSort = "alerts"; + } + this.sendAsyncMessage("AboutLogins:Setup", { + logins, + selectedSort, + syncState, + primaryPasswordEnabled: lazy.LoginHelper.isPrimaryPasswordSet(), + passwordRevealVisible: Services.policies.isAllowed("passwordReveal"), + importVisible: + Services.policies.isAllowed("profileImport") && + AppConstants.platform != "linux", + preselectedLogin: this.preselectedLogin, + }); + + await AboutLogins.sendAllLoginRelatedObjects( + logins, + this.browsingContext + ); + } catch (ex) { + if (ex.result != Cr.NS_ERROR_NOT_INITIALIZED) { + throw ex; + } + + // The message manager may be destroyed before the replies can be sent. + lazy.log.debug( + "AboutLogins:Subscribe: exception when replying with logins", + ex + ); + } + } + + #updateLogin(loginUpdates) { + let logins = lazy.LoginHelper.searchLoginsWithObject({ + guid: loginUpdates.guid, + }); + if (logins.length != 1) { + lazy.log.warn( + `AboutLogins:UpdateLogin: expected to find a login for guid: ${loginUpdates.guid} but found ${logins.length}` + ); + return; + } + + let modifiedLogin = logins[0].clone(); + if (loginUpdates.hasOwnProperty("username")) { + modifiedLogin.username = loginUpdates.username; + } + if (loginUpdates.hasOwnProperty("password")) { + modifiedLogin.password = loginUpdates.password; + } + try { + Services.logins.modifyLogin(logins[0], modifiedLogin); + } catch (error) { + this.#handleLoginStorageErrors(modifiedLogin, error); + } + } + + async #exportPasswords() { + let messageText = { value: "NOT SUPPORTED" }; + let captionText = { value: "" }; + + // 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()) { + const messageId = + EXPORT_PASSWORD_OS_AUTH_DIALOG_MESSAGE_IDS[AppConstants.platform]; + if (!messageId) { + throw new Error( + `AboutLoginsParent: Cannot find l10n id for platform ${AppConstants.platform} for export passwords os auth dialog message` + ); + } + [messageText, captionText] = await lazy.AboutLoginsL10n.formatMessages([ + { + id: messageId, + }, + { + id: "about-logins-os-auth-dialog-caption", + }, + ]); + } + + let { isAuthorized, telemetryEvent } = await lazy.LoginHelper.requestReauth( + this.browsingContext.embedderElement, + true, + null, // Prompt regardless of a recent prompt + messageText.value, + captionText.value + ); + + let { method, object, extra = {}, value = null } = telemetryEvent; + Services.telemetry.recordEvent("pwmgr", method, object, value, extra); + + if (!isAuthorized) { + return; + } + + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + function fpCallback(aResult) { + if (aResult != Ci.nsIFilePicker.returnCancel) { + lazy.LoginExport.exportAsCSV(fp.file.path); + Services.telemetry.recordEvent( + "pwmgr", + "mgmt_menu_item_used", + "export_complete" + ); + } + } + let [title, defaultFilename, okButtonLabel, csvFilterTitle] = + await lazy.AboutLoginsL10n.formatValues([ + { + id: "about-logins-export-file-picker-title2", + }, + { + id: "about-logins-export-file-picker-default-filename2", + }, + { + id: "about-logins-export-file-picker-export-button", + }, + { + id: "about-logins-export-file-picker-csv-filter-title", + }, + ]); + + fp.init(this.#ownerGlobal, title, Ci.nsIFilePicker.modeSave); + fp.appendFilter(csvFilterTitle, "*.csv"); + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.defaultString = defaultFilename; + fp.defaultExtension = "csv"; + fp.okButtonLabel = okButtonLabel; + fp.open(fpCallback); + } + + async #importFromFile() { + let [title, okButtonLabel, csvFilterTitle, tsvFilterTitle] = + await lazy.AboutLoginsL10n.formatValues([ + { + id: "about-logins-import-file-picker-title2", + }, + { + id: "about-logins-import-file-picker-import-button", + }, + { + id: "about-logins-import-file-picker-csv-filter-title", + }, + { + id: "about-logins-import-file-picker-tsv-filter-title", + }, + ]); + let { result, path } = await this.openFilePickerDialog( + title, + okButtonLabel, + [ + { + title: csvFilterTitle, + extensionPattern: "*.csv", + }, + { + title: tsvFilterTitle, + extensionPattern: "*.tsv", + }, + ], + this.#ownerGlobal + ); + + if (result != Ci.nsIFilePicker.returnCancel) { + let summary; + try { + summary = await lazy.LoginCSVImport.importFromCSV(path); + } catch (e) { + console.error(e); + this.sendAsyncMessage( + "AboutLogins:ImportPasswordsErrorDialog", + e.errorType + ); + } + if (summary) { + this.sendAsyncMessage("AboutLogins:ImportPasswordsDialog", summary); + Services.telemetry.recordEvent( + "pwmgr", + "mgmt_menu_item_used", + "import_csv_complete" + ); + } + } + } + + #removeAllLogins() { + Services.logins.removeAllUserFacingLogins(); + } + + #handleLoginStorageErrors(login, error) { + let messageObject = { + login: augmentVanillaLoginObject( + lazy.LoginHelper.loginToVanillaObject(login) + ), + errorMessage: error.message, + }; + + if (error.message.includes("This login already exists")) { + // See comment in LoginHelper.createLoginAlreadyExistsError as to + // why we need to call .toString() on the nsISupportsString. + messageObject.existingLoginGuid = error.data.toString(); + } + + this.sendAsyncMessage("AboutLogins:ShowLoginItemError", messageObject); + } + + async openFilePickerDialog(title, okButtonLabel, appendFilters, ownerGlobal) { + return new Promise(resolve => { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init(ownerGlobal, title, Ci.nsIFilePicker.modeOpen); + for (const appendFilter of appendFilters) { + fp.appendFilter(appendFilter.title, appendFilter.extensionPattern); + } + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.okButtonLabel = okButtonLabel; + fp.open(async result => { + resolve({ result, path: fp.file.path }); + }); + }); + } +} + +class AboutLoginsInternal { + subscribers = new WeakSet(); + #observersAdded = false; + authExpirationTime = Number.NEGATIVE_INFINITY; + + async observe(subject, topic, type) { + if (!ChromeUtils.nondeterministicGetWeakSetKeys(this.subscribers).length) { + this.#removeObservers(); + return; + } + + switch (topic) { + case "passwordmgr-reload-all": { + await this.#reloadAllLogins(); + break; + } + case "passwordmgr-crypto-login": { + this.#removeNotifications(PRIMARY_PASSWORD_NOTIFICATION_ID); + await this.#reloadAllLogins(); + break; + } + case "passwordmgr-crypto-loginCanceled": { + this.#showPrimaryPasswordLoginNotifications(); + break; + } + case lazy.UIState.ON_UPDATE: { + this.#messageSubscribers("AboutLogins:SyncState", this.getSyncState()); + break; + } + case "passwordmgr-storage-changed": { + switch (type) { + case "addLogin": { + await this.#addLogin(subject); + break; + } + case "modifyLogin": { + this.#modifyLogin(subject); + break; + } + case "removeLogin": { + this.#removeLogin(subject); + break; + } + case "removeAllLogins": { + this.#removeAllLogins(); + break; + } + } + } + } + } + + async #addLogin(subject) { + const login = convertSubjectToLogin(subject); + if (!login) { + return; + } + + if (lazy.BREACH_ALERTS_ENABLED) { + this.#messageSubscribers( + "AboutLogins:UpdateBreaches", + await lazy.LoginBreaches.getPotentialBreachesByLoginGUID([login]) + ); + if (lazy.VULNERABLE_PASSWORDS_ENABLED) { + this.#messageSubscribers( + "AboutLogins:UpdateVulnerableLogins", + await lazy.LoginBreaches.getPotentiallyVulnerablePasswordsByLoginGUID( + [login] + ) + ); + } + } + + this.#messageSubscribers("AboutLogins:LoginAdded", login); + } + + async #modifyLogin(subject) { + subject.QueryInterface(Ci.nsIArrayExtensions); + const login = convertSubjectToLogin(subject.GetElementAt(1)); + if (!login) { + return; + } + + if (lazy.BREACH_ALERTS_ENABLED) { + let breachesForThisLogin = + await lazy.LoginBreaches.getPotentialBreachesByLoginGUID([login]); + let breachData = breachesForThisLogin.size + ? breachesForThisLogin.get(login.guid) + : false; + this.#messageSubscribers( + "AboutLogins:UpdateBreaches", + new Map([[login.guid, breachData]]) + ); + if (lazy.VULNERABLE_PASSWORDS_ENABLED) { + let vulnerablePasswordsForThisLogin = + await lazy.LoginBreaches.getPotentiallyVulnerablePasswordsByLoginGUID( + [login] + ); + let isLoginVulnerable = !!vulnerablePasswordsForThisLogin.size; + this.#messageSubscribers( + "AboutLogins:UpdateVulnerableLogins", + new Map([[login.guid, isLoginVulnerable]]) + ); + } + } + + this.#messageSubscribers("AboutLogins:LoginModified", login); + } + + #removeLogin(subject) { + const login = convertSubjectToLogin(subject); + if (!login) { + return; + } + this.#messageSubscribers("AboutLogins:LoginRemoved", login); + } + + #removeAllLogins() { + this.#messageSubscribers("AboutLogins:RemoveAllLogins", []); + } + + async #reloadAllLogins() { + let logins = await this.getAllLogins(); + this.#messageSubscribers("AboutLogins:AllLogins", logins); + await this.sendAllLoginRelatedObjects(logins); + } + + #showPrimaryPasswordLoginNotifications() { + this.#showNotifications({ + id: PRIMARY_PASSWORD_NOTIFICATION_ID, + priority: "PRIORITY_WARNING_MEDIUM", + iconURL: "chrome://browser/skin/login.svg", + messageId: "about-logins-primary-password-notification-message", + buttonIds: ["master-password-reload-button"], + onClicks: [ + function onReloadClick(browser) { + browser.reload(); + }, + ], + }); + this.#messageSubscribers("AboutLogins:PrimaryPasswordAuthRequired"); + } + + #showNotifications({ + id, + priority, + iconURL, + messageId, + buttonIds, + onClicks, + extraFtl = [], + } = {}) { + for (let subscriber of this.#subscriberIterator()) { + let browser = subscriber.embedderElement; + let MozXULElement = browser.ownerGlobal.MozXULElement; + MozXULElement.insertFTLIfNeeded("browser/aboutLogins.ftl"); + for (let ftl of extraFtl) { + MozXULElement.insertFTLIfNeeded(ftl); + } + + // If there's already an existing notification bar, don't do anything. + let { gBrowser } = browser.ownerGlobal; + let notificationBox = gBrowser.getNotificationBox(browser); + let notification = notificationBox.getNotificationWithValue(id); + if (notification) { + continue; + } + + let buttons = []; + for (let i = 0; i < buttonIds.length; i++) { + buttons[i] = { + "l10n-id": buttonIds[i], + popup: null, + callback: () => { + onClicks[i](browser); + }, + }; + } + + notification = notificationBox.appendNotification( + id, + { + label: { "l10n-id": messageId }, + image: iconURL, + priority: notificationBox[priority], + }, + buttons + ); + } + } + + #removeNotifications(notificationId) { + for (let subscriber of this.#subscriberIterator()) { + let browser = subscriber.embedderElement; + let { gBrowser } = browser.ownerGlobal; + let notificationBox = gBrowser.getNotificationBox(browser); + let notification = + notificationBox.getNotificationWithValue(notificationId); + if (!notification) { + continue; + } + notificationBox.removeNotification(notification); + } + } + + *#subscriberIterator() { + let subscribers = ChromeUtils.nondeterministicGetWeakSetKeys( + this.subscribers + ); + for (let subscriber of subscribers) { + let browser = subscriber.embedderElement; + if ( + browser?.remoteType != EXPECTED_ABOUTLOGINS_REMOTE_TYPE || + browser?.contentPrincipal?.originNoSuffix != ABOUT_LOGINS_ORIGIN + ) { + this.subscribers.delete(subscriber); + continue; + } + yield subscriber; + } + } + + #messageSubscribers(name, details) { + for (let subscriber of this.#subscriberIterator()) { + try { + if (subscriber.currentWindowGlobal) { + let actor = subscriber.currentWindowGlobal.getActor("AboutLogins"); + actor.sendAsyncMessage(name, details); + } + } catch (ex) { + if (ex.result == Cr.NS_ERROR_NOT_INITIALIZED) { + // The actor may be destroyed before the message is sent. + lazy.log.debug( + "messageSubscribers: exception when calling sendAsyncMessage", + ex + ); + } else { + throw ex; + } + } + } + } + + async getAllLogins() { + try { + let logins = await lazy.LoginHelper.getAllUserFacingLogins(); + return logins + .map(lazy.LoginHelper.loginToVanillaObject) + .map(augmentVanillaLoginObject); + } catch (e) { + if (e.result == Cr.NS_ERROR_ABORT) { + // If the user cancels the MP prompt then return no logins. + return []; + } + throw e; + } + } + + async sendAllLoginRelatedObjects(logins, browsingContext) { + let sendMessageFn = (name, details) => { + if (browsingContext?.currentWindowGlobal) { + let actor = browsingContext.currentWindowGlobal.getActor("AboutLogins"); + actor.sendAsyncMessage(name, details); + } else { + this.#messageSubscribers(name, details); + } + }; + + if (lazy.BREACH_ALERTS_ENABLED) { + sendMessageFn( + "AboutLogins:SetBreaches", + await lazy.LoginBreaches.getPotentialBreachesByLoginGUID(logins) + ); + if (lazy.VULNERABLE_PASSWORDS_ENABLED) { + sendMessageFn( + "AboutLogins:SetVulnerableLogins", + await lazy.LoginBreaches.getPotentiallyVulnerablePasswordsByLoginGUID( + logins + ) + ); + } + } + } + + getSyncState() { + const state = lazy.UIState.get(); + // As long as Sync is configured, about:logins will treat it as + // authenticated. More diagnostics and error states can be handled + // by other more Sync-specific pages. + const loggedIn = state.status != lazy.UIState.STATUS_NOT_CONFIGURED; + const passwordSyncEnabled = state.syncEnabled && lazy.PASSWORD_SYNC_ENABLED; + + return { + loggedIn, + email: state.email, + avatarURL: state.avatarURL, + fxAccountsEnabled: lazy.FXA_ENABLED, + passwordSyncEnabled, + }; + } + + onPasswordSyncEnabledPreferenceChange(data, previous, latest) { + this.#messageSubscribers("AboutLogins:SyncState", this.getSyncState()); + } + + #observedTopics = [ + "passwordmgr-crypto-login", + "passwordmgr-crypto-loginCanceled", + "passwordmgr-storage-changed", + "passwordmgr-reload-all", + lazy.UIState.ON_UPDATE, + ]; + + addObservers() { + if (!this.#observersAdded) { + for (const topic of this.#observedTopics) { + Services.obs.addObserver(this, topic); + } + this.#observersAdded = true; + } + } + + #removeObservers() { + for (const topic of this.#observedTopics) { + Services.obs.removeObserver(this, topic); + } + this.#observersAdded = false; + } +} + +let AboutLogins = new AboutLoginsInternal(); +export var _AboutLogins = AboutLogins; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "PASSWORD_SYNC_ENABLED", + "services.sync.engine.passwords", + false, + AboutLogins.onPasswordSyncEnabledPreferenceChange +); diff --git a/browser/components/aboutlogins/LoginBreaches.sys.mjs b/browser/components/aboutlogins/LoginBreaches.sys.mjs new file mode 100644 index 0000000000..b2a0af5e39 --- /dev/null +++ b/browser/components/aboutlogins/LoginBreaches.sys.mjs @@ -0,0 +1,171 @@ +/* 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/. */ + +/** + * Manages breach alerts for saved logins using data from Firefox Monitor via + * RemoteSettings. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + RemoteSettingsClient: + "resource://services-settings/RemoteSettingsClient.sys.mjs", +}); + +export const LoginBreaches = { + REMOTE_SETTINGS_COLLECTION: "fxmonitor-breaches", + + async update(breaches = null) { + const logins = await lazy.LoginHelper.getAllUserFacingLogins(); + await this.getPotentialBreachesByLoginGUID(logins, breaches); + }, + + /** + * Return a Map of login GUIDs to a potential breach affecting that login + * by considering only breaches affecting passwords. + * + * This only uses the breach `Domain` and `timePasswordChanged` to determine + * if a login may be breached which means it may contain false-positives if + * login timestamps are incorrect, the user didn't save their password change + * in Firefox, or the breach didn't contain all accounts, etc. As a result, + * consumers should avoid making stronger claims than the data supports. + * + * @param {nsILoginInfo[]} logins Saved logins to check for potential breaches. + * @param {object[]} [breaches = null] Only ones involving passwords will be used. + * @returns {Map} with a key for each login GUID potentially in a breach. + */ + async getPotentialBreachesByLoginGUID(logins, breaches = null) { + const breachesByLoginGUID = new Map(); + if (!breaches) { + try { + breaches = await lazy + .RemoteSettings(this.REMOTE_SETTINGS_COLLECTION) + .get(); + } catch (ex) { + if (ex instanceof lazy.RemoteSettingsClient.UnknownCollectionError) { + lazy.log.warn( + "Could not get Remote Settings collection.", + this.REMOTE_SETTINGS_COLLECTION, + ex + ); + return breachesByLoginGUID; + } + throw ex; + } + } + const BREACH_ALERT_URL = Services.prefs.getStringPref( + "signon.management.page.breachAlertUrl" + ); + const baseBreachAlertURL = new URL(BREACH_ALERT_URL); + + await Services.logins.initializationPromise; + const storageJSON = Services.logins.wrappedJSObject._storage; + const dismissedBreachAlertsByLoginGUID = + storageJSON.getBreachAlertDismissalsByLoginGUID(); + + // Determine potentially breached logins by checking their origin and the last time + // they were changed. It's important to note here that we are NOT considering the + // username and password of that login. + for (const login of logins) { + let loginHost; + try { + // nsIURI.host can throw if the URI scheme doesn't have a host. + loginHost = Services.io.newURI(login.origin).host; + } catch { + continue; + } + for (const breach of breaches) { + if ( + !breach.Domain || + !Services.eTLD.hasRootDomain(loginHost, breach.Domain) || + !this._breachInvolvedPasswords(breach) || + !this._breachWasAfterPasswordLastChanged(breach, login) + ) { + continue; + } + + if (!storageJSON.isPotentiallyVulnerablePassword(login)) { + storageJSON.addPotentiallyVulnerablePassword(login); + } + + if ( + this._breachAlertIsDismissed( + login, + breach, + dismissedBreachAlertsByLoginGUID + ) + ) { + continue; + } + + let breachAlertURL = new URL(breach.Name, baseBreachAlertURL); + breachAlertURL.searchParams.set("utm_source", "firefox-desktop"); + breachAlertURL.searchParams.set("utm_medium", "referral"); + breachAlertURL.searchParams.set("utm_campaign", "about-logins"); + breachAlertURL.searchParams.set("utm_content", "about-logins"); + breach.breachAlertURL = breachAlertURL.href; + breachesByLoginGUID.set(login.guid, breach); + } + } + Services.telemetry.scalarSet( + "pwmgr.potentially_breached_passwords", + breachesByLoginGUID.size + ); + return breachesByLoginGUID; + }, + + /** + * Return information about logins using passwords that were potentially in a + * breach. + * @see the caveats in the documentation for `getPotentialBreachesByLoginGUID`. + * + * @param {nsILoginInfo[]} logins to check the passwords of. + * @returns {Map} from login GUID to `true` for logins that have a password + * that may be vulnerable. + */ + getPotentiallyVulnerablePasswordsByLoginGUID(logins) { + const vulnerablePasswordsByLoginGUID = new Map(); + const storageJSON = Services.logins.wrappedJSObject._storage; + for (const login of logins) { + if (storageJSON.isPotentiallyVulnerablePassword(login)) { + vulnerablePasswordsByLoginGUID.set(login.guid, true); + } + } + return vulnerablePasswordsByLoginGUID; + }, + + async clearAllPotentiallyVulnerablePasswords() { + await Services.logins.initializationPromise; + const storageJSON = Services.logins.wrappedJSObject._storage; + storageJSON.clearAllPotentiallyVulnerablePasswords(); + }, + + _breachAlertIsDismissed(login, breach, dismissedBreachAlerts) { + const breachAddedDate = new Date(breach.AddedDate).getTime(); + const breachAlertIsDismissed = + dismissedBreachAlerts[login.guid] && + dismissedBreachAlerts[login.guid].timeBreachAlertDismissed > + breachAddedDate; + return breachAlertIsDismissed; + }, + + _breachInvolvedPasswords(breach) { + return ( + breach.hasOwnProperty("DataClasses") && + breach.DataClasses.includes("Passwords") + ); + }, + + _breachWasAfterPasswordLastChanged(breach, login) { + const breachDate = new Date(breach.BreachDate).getTime(); + return login.timePasswordChanged < breachDate; + }, +}; + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + return lazy.LoginHelper.createLogger("LoginBreaches"); +}); diff --git a/browser/components/aboutlogins/content/aboutLogins.css b/browser/components/aboutlogins/content/aboutLogins.css new file mode 100644 index 0000000000..6b4a16451c --- /dev/null +++ b/browser/components/aboutlogins/content/aboutLogins.css @@ -0,0 +1,99 @@ +/* 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 { + position: fixed; +} +html, +body { + height: 100%; + width: 100%; +} + +body { + --sidebar-width: 320px; + display: grid; + grid-template-columns: var(--sidebar-width) 1fr; + grid-template-rows: auto 1fr; +} + +@media (max-width: 830px) { + body { + --sidebar-width: 270px; + } +} + +header { + display: flex; + align-items: center; + justify-content: flex-end; + background-color: var(--in-content-page-background); + padding-block: 9px; + padding-inline-start: 16px; + padding-inline-end: 23px; +} + +login-filter { + min-width: 200px; + max-width: 400px; + margin-inline: 40px auto; + flex-grow: 0.5; + align-self: center; +} + +fxaccounts-button, +menu-button { + margin-inline-start: 18px; +} + +login-list { + grid-row: 1/4; +} + +:root:not(.initialized) login-intro, +:root:not(.initialized) login-item, +:root.empty-search login-intro, +:root:not(.no-logins, .empty-search, .login-selected) login-intro, +login-item[data-editing="true"] + login-intro, +.login-selected login-intro, +:root:not(.login-selected) login-item:not([data-editing="true"]), +.no-logins login-item:not([data-editing="true"]) { + display: none; +} + +.heading-wrapper { + display: flex; + justify-content: center; + width: var(--sidebar-width); + font-weight: 600; +} + +:root:not(.primary-password-auth-required) #primary-password-required-overlay { + display: none; +} + +.primary-password-auth-required > body > header, +.primary-password-auth-required > body > login-list, +.primary-password-auth-required > body > section { + filter: blur(2px) +} + +#primary-password-required-overlay { + z-index: 1; + position: fixed; + width: 100vw; + height: 100vh; + background-color: rgba(0,0,0,0.2); +} + +body > section { + display: grid; + grid-template-rows: auto 1fr; + overflow-y: hidden; + overflow-x: auto; +} + +login-intro { + overflow-y: scroll; +} diff --git a/browser/components/aboutlogins/content/aboutLogins.html b/browser/components/aboutlogins/content/aboutLogins.html new file mode 100644 index 0000000000..67712c8f29 --- /dev/null +++ b/browser/components/aboutlogins/content/aboutLogins.html @@ -0,0 +1,379 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/components/aboutlogins/content/aboutLogins.mjs b/browser/components/aboutlogins/content/aboutLogins.mjs new file mode 100644 index 0000000000..2764f36f4e --- /dev/null +++ b/browser/components/aboutlogins/content/aboutLogins.mjs @@ -0,0 +1,285 @@ +/* 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 { + recordTelemetryEvent, + setKeyboardAccessForNonDialogElements, +} from "./aboutLoginsUtils.mjs"; + +// The init code isn't wrapped in a DOMContentLoaded/load event listener so the +// page works properly when restored from session restore. +const gElements = { + fxAccountsButton: document.querySelector("fxaccounts-button"), + loginList: document.querySelector("login-list"), + loginIntro: document.querySelector("login-intro"), + loginItem: document.querySelector("login-item"), + loginFilter: document + .querySelector("login-list") + .shadowRoot.querySelector("login-filter"), + menuButton: document.querySelector("menu-button"), + // removeAllLogins button is nested inside of menuButton + get removeAllButton() { + return this.menuButton.shadowRoot.querySelector( + ".menuitem-remove-all-logins" + ); + }, +}; + +let numberOfLogins = 0; + +function updateNoLogins() { + document.documentElement.classList.toggle("no-logins", numberOfLogins == 0); + gElements.loginList.classList.toggle("no-logins", numberOfLogins == 0); + gElements.loginItem.classList.toggle("no-logins", numberOfLogins == 0); + gElements.removeAllButton.disabled = numberOfLogins == 0; +} + +function handleAllLogins(logins) { + gElements.loginList.setLogins(logins); + numberOfLogins = logins.length; + updateNoLogins(); +} + +let fxaLoggedIn = null; +let passwordSyncEnabled = null; + +function handleSyncState(syncState) { + gElements.fxAccountsButton.updateState(syncState); + gElements.loginIntro.updateState(syncState); + fxaLoggedIn = syncState.loggedIn; + passwordSyncEnabled = syncState.passwordSyncEnabled; +} + +window.addEventListener("AboutLoginsChromeToContent", event => { + switch (event.detail.messageType) { + case "AllLogins": { + document.documentElement.classList.remove( + "primary-password-auth-required" + ); + setKeyboardAccessForNonDialogElements(true); + handleAllLogins(event.detail.value); + break; + } + case "ImportPasswordsDialog": { + let dialog = document.querySelector("import-summary-dialog"); + let options = { + logins: event.detail.value, + }; + dialog.show(options); + break; + } + case "ImportPasswordsErrorDialog": { + let dialog = document.querySelector("import-error-dialog"); + dialog.show(event.detail.value); + break; + } + case "LoginAdded": { + gElements.loginList.loginAdded(event.detail.value); + gElements.loginItem.loginAdded(event.detail.value); + numberOfLogins++; + updateNoLogins(); + break; + } + case "LoginModified": { + gElements.loginList.loginModified(event.detail.value); + gElements.loginItem.loginModified(event.detail.value); + break; + } + case "LoginRemoved": { + // The loginRemoved function of loginItem needs to be called before + // the one in loginList since it will remove the editing. So that the + // discard dialog won't show up if we delete a login after edit it. + gElements.loginItem.loginRemoved(event.detail.value); + gElements.loginList.loginRemoved(event.detail.value); + numberOfLogins--; + updateNoLogins(); + break; + } + case "PrimaryPasswordAuthRequired": { + document.documentElement.classList.add("primary-password-auth-required"); + setKeyboardAccessForNonDialogElements(false); + break; + } + case "RemaskPassword": { + window.dispatchEvent(new CustomEvent("AboutLoginsRemaskPassword")); + break; + } + case "RemoveAllLogins": { + handleAllLogins(event.detail.value); + document.documentElement.classList.remove("login-selected"); + break; + } + case "SetBreaches": { + gElements.loginList.setBreaches(event.detail.value); + gElements.loginItem.setBreaches(event.detail.value); + break; + } + case "SetVulnerableLogins": { + gElements.loginList.setVulnerableLogins(event.detail.value); + gElements.loginItem.setVulnerableLogins(event.detail.value); + break; + } + case "Setup": { + gElements.loginList.selectLoginByDomainOrGuid( + event.detail.value.preselectedLogin + ); + handleAllLogins(event.detail.value.logins); + handleSyncState(event.detail.value.syncState); + gElements.loginList.setSortDirection(event.detail.value.selectedSort); + document.documentElement.classList.add("initialized"); + gElements.loginList.classList.add("initialized"); + break; + } + case "ShowLoginItemError": { + gElements.loginItem.showLoginItemError(event.detail.value); + break; + } + case "SyncState": { + handleSyncState(event.detail.value); + break; + } + case "UpdateBreaches": { + gElements.loginList.updateBreaches(event.detail.value); + gElements.loginItem.updateBreaches(event.detail.value); + break; + } + case "UpdateVulnerableLogins": { + gElements.loginList.updateVulnerableLogins(event.detail.value); + gElements.loginItem.updateVulnerableLogins(event.detail.value); + break; + } + } +}); + +window.addEventListener("AboutLoginsRemoveAllLoginsDialog", () => { + let loginItem = document.querySelector("login-item"); + let options = {}; + if (fxaLoggedIn && passwordSyncEnabled) { + options.title = "about-logins-confirm-remove-all-sync-dialog-title2"; + options.message = "about-logins-confirm-remove-all-sync-dialog-message3"; + } else { + options.title = "about-logins-confirm-remove-all-dialog-title2"; + options.message = "about-logins-confirm-remove-all-dialog-message2"; + } + options.confirmCheckboxLabel = + "about-logins-confirm-remove-all-dialog-checkbox-label2"; + options.confirmButtonLabel = + "about-logins-confirm-remove-all-dialog-confirm-button-label"; + options.count = numberOfLogins; + + let dialog = document.querySelector("remove-logins-dialog"); + let dialogPromise = dialog.show(options); + try { + dialogPromise.then( + () => { + if (loginItem.dataset.isNewLogin) { + // Bug 1681042 - Resetting the form prevents a double confirmation dialog since there + // may be pending changes in the new login. + loginItem.resetForm(); + window.dispatchEvent(new CustomEvent("AboutLoginsClearSelection")); + } else if (loginItem.dataset.editing) { + loginItem._toggleEditing(); + } + window.document.documentElement.classList.remove("login-selected"); + let removeAllEvt = new CustomEvent("AboutLoginsRemoveAllLogins", { + bubbles: true, + }); + window.dispatchEvent(removeAllEvt); + }, + () => {} + ); + } catch (e) { + if (e != undefined) { + throw e; + } + } +}); + +window.addEventListener("AboutLoginsExportPasswordsDialog", async () => { + recordTelemetryEvent({ + object: "export", + method: "mgmt_menu_item_used", + }); + let dialog = document.querySelector("confirmation-dialog"); + let options = { + title: "about-logins-confirm-export-dialog-title2", + message: "about-logins-confirm-export-dialog-message2", + confirmButtonLabel: "about-logins-confirm-export-dialog-confirm-button2", + }; + try { + await dialog.show(options); + document.dispatchEvent( + new CustomEvent("AboutLoginsExportPasswords", { bubbles: true }) + ); + } catch (ex) { + // The user cancelled the dialog. + } +}); + +async function interceptFocusKey() { + // Intercept Ctrl+F on the page to focus login filter box + const [findKey] = await document.l10n.formatMessages([ + { id: "about-logins-login-filter2" }, + ]); + const focusKey = findKey.attributes + .find(a => a.name == "key") + .value.toLowerCase(); + document.addEventListener("keydown", event => { + if (event.key == focusKey && event.getModifierState("Accel")) { + event.preventDefault(); + document + .querySelector("login-list") + .shadowRoot.querySelector("login-filter") + .shadowRoot.querySelector("input") + .focus(); + } + }); +} + +await interceptFocusKey(); + +// Begin code that executes on page load. + +let searchParamsChanged = false; +let { protocol, pathname, searchParams } = new URL(document.location); + +recordTelemetryEvent({ + method: "open_management", + object: searchParams.get("entryPoint") || "direct", +}); + +if (searchParams.has("entryPoint")) { + // Remove this parameter from the URL (after recording above) to make it + // cleaner for bookmarking and switch-to-tab and so that bookmarked values + // don't skew telemetry. + searchParams.delete("entryPoint"); + searchParamsChanged = true; +} + +if (searchParams.has("filter")) { + let filter = searchParams.get("filter"); + if (!filter) { + // Remove empty `filter` params to give a cleaner URL for bookmarking and + // switch-to-tab + searchParams.delete("filter"); + searchParamsChanged = true; + } +} + +if (searchParamsChanged) { + const paramsPart = searchParams.toString() ? `?${searchParams}` : ""; + const newURL = protocol + pathname + paramsPart + document.location.hash; + // This redirect doesn't stop this script from running so ensure you guard + // later code if it shouldn't run before and after the redirect. + window.location.replace(newURL); +} else if (searchParams.has("filter")) { + // This must be after the `location.replace` so it doesn't cause telemetry to + // record a filter event before the navigation to clean the URL. + gElements.loginFilter.value = searchParams.get("filter"); +} + +if (!searchParamsChanged) { + gElements.loginFilter.focus(); + document.dispatchEvent(new CustomEvent("AboutLoginsInit", { bubbles: true })); +} diff --git a/browser/components/aboutlogins/content/aboutLoginsImportReport.css b/browser/components/aboutlogins/content/aboutLoginsImportReport.css new file mode 100644 index 0000000000..8e126ecb62 --- /dev/null +++ b/browser/components/aboutlogins/content/aboutLoginsImportReport.css @@ -0,0 +1,125 @@ +/* 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/. */ + +.importreport { + display: block; +} + +#report-body { + display: grid; + grid-template-columns: repeat(6, auto); + grid-template-rows: 110px 145px auto; + grid-column: logins/login; + height: 100%; +} + +.import-report-heading { + font-weight: 600; + margin-block: auto; + margin-inline-start: 48px; +} + +.summary { + grid-column: 2 / 5; + grid-row-start: 1; + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.summary h2 { + font-size: 24px; + margin-block: 32px 8px; +} + + +.summary > a { + margin-top: 12px; +} + +.new-logins, +.exiting-logins, +.duplicate-logins, +.errors-logins { + display: flex; + flex-direction: column; + width: 120px; + height: 100px; + align-items: center; + margin: auto; +} + +.count-details { + margin-top: 8px; + text-align: center; +} + +.result-count { + font-size: 40px; + font-weight: bold; +} + +.new-logins { + grid-column: 2; + grid-row-start: 2; +} + +.exiting-logins { + grid-column: 3; + grid-row-start: 2; +} + +.duplicate-logins { + grid-column: 4; + grid-row-start: 2; +} + +.errors-logins { + grid-column: 5; + grid-row-start: 2; +} + +.logins-list { + grid-column: 2 / 6; + grid-row-start: 3; + display: grid; + grid-template-columns: auto 1fr; + border-top: 1px solid var(--in-content-border-color); + grid-auto-rows: 28px; + overflow-y: auto; +} + +.not-imported { + font-style: italic; + font-weight: bold; +} + +.error { + color: var(--dialog-warning-text-color); +} + +.not-imported-hidden { + visibility: hidden; +} + +import-details-row:nth-child(odd) { + background-color: var(--in-content-box-background-odd); +} + +import-details-row { + height: 20px; + margin-block: 1px; + display: grid; + grid-column: 1 / 3; + grid-template-columns: subgrid; + grid-gap: 16px; +} + +import-details-row .row-count { + padding-inline: 8px 12px; +} + +import-details-row .row-details { + padding-inline-start: 5px; +} diff --git a/browser/components/aboutlogins/content/aboutLoginsImportReport.html b/browser/components/aboutlogins/content/aboutLoginsImportReport.html new file mode 100644 index 0000000000..9ab2641ca2 --- /dev/null +++ b/browser/components/aboutlogins/content/aboutLoginsImportReport.html @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + +
+
+

+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + diff --git a/browser/components/aboutlogins/content/aboutLoginsImportReport.mjs b/browser/components/aboutlogins/content/aboutLoginsImportReport.mjs new file mode 100644 index 0000000000..6f5943ee46 --- /dev/null +++ b/browser/components/aboutlogins/content/aboutLoginsImportReport.mjs @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import ImportDetailsRow from "./components/import-details-row.mjs"; + +const detailsLoginsList = document.querySelector(".logins-list"); +const detailedNewCount = document.querySelector(".new-logins"); +const detailedExitingCount = document.querySelector(".exiting-logins"); +const detailedDuplicateCount = document.querySelector(".duplicate-logins"); +const detailedErrorsCount = document.querySelector(".errors-logins"); + +document.dispatchEvent( + new CustomEvent("AboutLoginsImportReportInit", { bubbles: true }) +); + +function importReportDataHandler(event) { + switch (event.detail.messageType) { + case "ImportReportData": + const logins = event.detail.value; + const report = { + added: 0, + modified: 0, + no_change: 0, + error: 0, + }; + for (let loginRow of logins) { + if (loginRow.result.includes("error")) { + report.error++; + } else { + report[loginRow.result]++; + } + } + document.l10n.setAttributes( + detailedNewCount, + "about-logins-import-report-added2", + { count: report.added } + ); + document.l10n.setAttributes( + detailedExitingCount, + "about-logins-import-report-modified2", + { count: report.modified } + ); + document.l10n.setAttributes( + detailedDuplicateCount, + "about-logins-import-report-no-change2", + { count: report.no_change } + ); + document.l10n.setAttributes( + detailedErrorsCount, + "about-logins-import-report-error", + { count: report.error } + ); + if (report.no_change > 0) { + detailedDuplicateCount + .querySelector(".not-imported") + .classList.toggle("not-imported-hidden"); + } + if (report.error > 0) { + detailedErrorsCount + .querySelector(".not-imported") + .classList.toggle("not-imported-hidden"); + } + + detailsLoginsList.innerHTML = ""; + let fragment = document.createDocumentFragment(); + for (let index = 0; index < logins.length; index++) { + const row = new ImportDetailsRow(index + 1, logins[index]); + fragment.appendChild(row); + } + detailsLoginsList.appendChild(fragment); + window.removeEventListener( + "AboutLoginsChromeToContent", + importReportDataHandler + ); + document.dispatchEvent( + new CustomEvent("AboutLoginsImportReportReady", { bubbles: true }) + ); + break; + } +} + +window.addEventListener("AboutLoginsChromeToContent", importReportDataHandler); diff --git a/browser/components/aboutlogins/content/aboutLoginsUtils.mjs b/browser/components/aboutlogins/content/aboutLoginsUtils.mjs new file mode 100644 index 0000000000..4e55487cec --- /dev/null +++ b/browser/components/aboutlogins/content/aboutLoginsUtils.mjs @@ -0,0 +1,72 @@ +/* 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/. */ + +export const CONCEALED_PASSWORD_TEXT = " ".repeat(8); + +/** + * Dispatches a custom event to the AboutLoginsChild.sys.mjs script which + * will record the event. + * @param {object} event.method The telemety event method + * @param {object} event.object The telemety event object + * @param {object} event.value [optional] The telemety event value + */ +export function recordTelemetryEvent(event) { + document.dispatchEvent( + new CustomEvent("AboutLoginsRecordTelemetryEvent", { + bubbles: true, + detail: event, + }) + ); +} + +export function setKeyboardAccessForNonDialogElements(enableKeyboardAccess) { + const pageElements = document.querySelectorAll( + "login-item, login-list, menu-button, login-filter, fxaccounts-button, [tabindex]" + ); + + let { activeElement: docActiveElement } = document; + if ( + !enableKeyboardAccess && + docActiveElement && + !docActiveElement.closest("confirmation-dialog") + ) { + let elementToBlur = + docActiveElement?.shadowRoot?.activeElement ?? docActiveElement; + elementToBlur.blur(); + } + + pageElements.forEach(el => { + if (!enableKeyboardAccess) { + if (el.tabIndex > -1) { + el.dataset.oldTabIndex = el.tabIndex; + } + el.tabIndex = "-1"; + } else if (el.dataset.oldTabIndex) { + el.tabIndex = el.dataset.oldTabIndex; + delete el.dataset.oldTabIndex; + } else { + el.removeAttribute("tabindex"); + } + }); +} + +export function promptForPrimaryPassword(messageId) { + return new Promise(resolve => { + window.AboutLoginsUtils.promptForPrimaryPassword(resolve, messageId); + }); +} + +/** + * Initializes a dialog based on a template using shadow dom. + * @param {HTMLElement} element The element to attach the shadow dom to. + * @param {string} templateSelector The selector of the template to be used. + * @returns {object} The shadow dom that is attached. + */ +export function initDialog(element, templateSelector) { + let template = document.querySelector(templateSelector); + let shadowRoot = element.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + shadowRoot.appendChild(template.content.cloneNode(true)); + return shadowRoot; +} diff --git a/browser/components/aboutlogins/content/common.css b/browser/components/aboutlogins/content/common.css new file mode 100644 index 0000000000..2771a6b03e --- /dev/null +++ b/browser/components/aboutlogins/content/common.css @@ -0,0 +1,9 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* [hidden] isn't applying to elements in Shadow DOM. */ +:host([hidden]), +[hidden] { + display: none !important; +} diff --git a/browser/components/aboutlogins/content/components/confirmation-dialog.css b/browser/components/aboutlogins/content/components/confirmation-dialog.css new file mode 100644 index 0000000000..49c3188f6f --- /dev/null +++ b/browser/components/aboutlogins/content/components/confirmation-dialog.css @@ -0,0 +1,73 @@ +/* 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/. */ + + .overlay { + position: fixed; + z-index: 1; + inset: 0; + /* TODO: this color is used in the about:preferences overlay, but + why isn't it declared as a variable? */ + background-color: rgba(0,0,0,0.5); + display: flex; +} + +.container { + z-index: 2; + position: relative; + display: flex; + flex-direction: column; + min-width: 250px; + max-width: 500px; + min-height: 200px; + margin: auto; + background: var(--in-content-page-background); + color: var(--in-content-page-color); + box-shadow: var(--shadow-30); + /* show a border in high contrast mode */ + outline: 1px solid transparent; +} + +.title { + font-size: 1.5em; + font-weight: normal; + user-select: none; + margin: 0; +} + +.message { + color: var(--text-color-deemphasized); + margin-bottom: 0; +} + +.dismiss-button { + position: absolute; + top: 0; + inset-inline-end: 0; + min-width: 20px; + min-height: 20px; + margin: 16px; + padding: 0; + line-height: 0; +} + +.dismiss-icon { + -moz-context-properties: fill; + fill: currentColor; + user-select: none; +} + +.warning-icon { + -moz-context-properties: fill; + fill: currentColor; + user-select: none; + width: 40px; + height: 40px; + margin: 16px; +} + +.content, +.buttons { + text-align: center; + padding: 16px 32px; +} diff --git a/browser/components/aboutlogins/content/components/confirmation-dialog.mjs b/browser/components/aboutlogins/content/components/confirmation-dialog.mjs new file mode 100644 index 0000000000..91a9c3a9d7 --- /dev/null +++ b/browser/components/aboutlogins/content/components/confirmation-dialog.mjs @@ -0,0 +1,105 @@ +/* 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 { setKeyboardAccessForNonDialogElements } from "../aboutLoginsUtils.mjs"; + +export default class ConfirmationDialog extends HTMLElement { + constructor() { + super(); + this._promise = null; + } + + connectedCallback() { + if (this.shadowRoot) { + return; + } + let template = document.querySelector("#confirmation-dialog-template"); + let shadowRoot = this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + shadowRoot.appendChild(template.content.cloneNode(true)); + + this._buttons = this.shadowRoot.querySelector(".buttons"); + this._cancelButton = this.shadowRoot.querySelector(".cancel-button"); + this._confirmButton = this.shadowRoot.querySelector(".confirm-button"); + this._dismissButton = this.shadowRoot.querySelector(".dismiss-button"); + this._message = this.shadowRoot.querySelector(".message"); + this._overlay = this.shadowRoot.querySelector(".overlay"); + this._title = this.shadowRoot.querySelector(".title"); + } + + handleEvent(event) { + switch (event.type) { + case "keydown": + if (event.repeat) { + // Prevent repeat keypresses from accidentally confirming the + // dialog since the confirmation button is focused by default. + event.preventDefault(); + return; + } + if (event.key === "Escape" && !event.defaultPrevented) { + this.onCancel(); + } + break; + case "click": + if ( + event.target.classList.contains("cancel-button") || + event.currentTarget.classList.contains("dismiss-button") || + event.target.classList.contains("overlay") + ) { + this.onCancel(); + } else if (event.target.classList.contains("confirm-button")) { + this.onConfirm(); + } + } + } + + hide() { + setKeyboardAccessForNonDialogElements(true); + this._cancelButton.removeEventListener("click", this); + this._confirmButton.removeEventListener("click", this); + this._dismissButton.removeEventListener("click", this); + this._overlay.removeEventListener("click", this); + window.removeEventListener("keydown", this); + + this.hidden = true; + } + + show({ title, message, confirmButtonLabel }) { + setKeyboardAccessForNonDialogElements(false); + this.hidden = false; + + document.l10n.setAttributes(this._title, title); + document.l10n.setAttributes(this._message, message); + document.l10n.setAttributes(this._confirmButton, confirmButtonLabel); + + this._cancelButton.addEventListener("click", this); + this._confirmButton.addEventListener("click", this); + this._dismissButton.addEventListener("click", this); + this._overlay.addEventListener("click", this); + window.addEventListener("keydown", this); + + // For speed-of-use, focus the confirm button when the + // dialog loads. Showing the dialog itself provides enough + // of a buffer for accidental deletions. + this._confirmButton.focus(); + + this._promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + + return this._promise; + } + + onCancel() { + this._reject(); + this.hide(); + } + + onConfirm() { + this._resolve(); + this.hide(); + } +} +customElements.define("confirmation-dialog", ConfirmationDialog); diff --git a/browser/components/aboutlogins/content/components/fxaccounts-button.css b/browser/components/aboutlogins/content/components/fxaccounts-button.css new file mode 100644 index 0000000000..a6d136ff70 --- /dev/null +++ b/browser/components/aboutlogins/content/components/fxaccounts-button.css @@ -0,0 +1,74 @@ +/* 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/. */ + +.logged-out-view, +.logged-in-view { + display: flex; + align-items: center; +} + +.fxaccounts-extra-text { + /* Only show at most 3 lines of text to limit the + text from overflowing the header. */ + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + text-align: end; +} + +@media (max-width: 830px) { + .fxaccounts-extra-text, + .fxaccount-email { + display: none; + } +} + +.fxaccount-avatar, +.fxaccounts-enable-button { + font-size: var(--font-size-small); + margin-inline-start: 9px; +} + +.fxaccounts-enable-button { + min-width: 120px; + padding-inline: 16px; + /* See bug 1626764: The width of button could go lesser than 120px in small window size which could wrap the texts into two lines in systems with different default fonts */ + flex-shrink: 0; +} + +.fxaccounts-avatar-button { + cursor: pointer; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.fxaccount-email { + font-size: var(--font-size-small); + vertical-align: middle; +} + +.fxaccount-avatar { + display: inline-block; + vertical-align: middle; + background-image: var(--avatar-url, + url(chrome://browser/skin/fxa/avatar-color.svg)); + background-position: center; + background-repeat: no-repeat; + background-size: cover; + border-radius: 1000px; + width: 32px; + height: 32px; +} + +@media not (prefers-contrast) { + .fxaccounts-avatar-button:hover { + background-color: transparent !important; + } + + .fxaccounts-avatar-button:hover > .fxaccount-email { + text-decoration: underline; + } +} diff --git a/browser/components/aboutlogins/content/components/fxaccounts-button.mjs b/browser/components/aboutlogins/content/components/fxaccounts-button.mjs new file mode 100644 index 0000000000..d39969d726 --- /dev/null +++ b/browser/components/aboutlogins/content/components/fxaccounts-button.mjs @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export default class FxAccountsButton extends HTMLElement { + connectedCallback() { + if (this.shadowRoot) { + return; + } + let template = document.querySelector("#fxaccounts-button-template"); + let shadowRoot = this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + shadowRoot.appendChild(template.content.cloneNode(true)); + + this._avatarButton = shadowRoot.querySelector(".fxaccounts-avatar-button"); + this._extraText = shadowRoot.querySelector(".fxaccounts-extra-text"); + this._enableButton = shadowRoot.querySelector(".fxaccounts-enable-button"); + this._loggedOutView = shadowRoot.querySelector(".logged-out-view"); + this._loggedInView = shadowRoot.querySelector(".logged-in-view"); + this._emailText = shadowRoot.querySelector(".fxaccount-email"); + + this._avatarButton.addEventListener("click", this); + this._enableButton.addEventListener("click", this); + + this.render(); + } + + handleEvent(event) { + if (event.currentTarget == this._avatarButton) { + document.dispatchEvent( + new CustomEvent("AboutLoginsSyncOptions", { + bubbles: true, + }) + ); + return; + } + if (event.target == this._enableButton) { + document.dispatchEvent( + new CustomEvent("AboutLoginsSyncEnable", { + bubbles: true, + }) + ); + } + } + + render() { + this._loggedOutView.hidden = !!this._loggedIn; + this._loggedInView.hidden = !this._loggedIn; + this._emailText.textContent = this._email; + if (this._avatarURL) { + this._avatarButton.style.setProperty( + "--avatar-url", + `url(${this._avatarURL})` + ); + } else { + let defaultAvatar = "chrome://browser/skin/fxa/avatar-color.svg"; + this._avatarButton.style.setProperty( + "--avatar-url", + `url(${defaultAvatar})` + ); + } + } + + /** + * + * @param {object} state + * loggedIn: {Boolean} FxAccount authentication + * status. + * email: {String} Email address used with FxAccount. Must + * be empty if `loggedIn` is false. + * avatarURL: {String} URL of account avatar. Must + * be empty if `loggedIn` is false. + */ + updateState(state) { + this.hidden = !state.fxAccountsEnabled; + this._loggedIn = state.loggedIn; + this._email = state.email; + this._avatarURL = state.avatarURL; + + this.render(); + } +} +customElements.define("fxaccounts-button", FxAccountsButton); diff --git a/browser/components/aboutlogins/content/components/generic-dialog.css b/browser/components/aboutlogins/content/components/generic-dialog.css new file mode 100644 index 0000000000..897ef5da20 --- /dev/null +++ b/browser/components/aboutlogins/content/components/generic-dialog.css @@ -0,0 +1,64 @@ +/* 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/. */ + +.overlay { + position: fixed; + z-index: 1; + inset: 0; + /* TODO: this color is used in the about:preferences overlay, but + why isn't it declared as a variable? */ + background-color: rgba(0,0,0,0.5); + display: flex; +} + +.container { + z-index: 2; + position: relative; + display: grid; + grid-template-columns: 37px auto; + grid-template-rows: 32px auto 50px; + grid-gap: 5px; + align-items: center; + width: 580px; + height: 290px; + padding: 50px 50px 20px; + margin: auto; + background-color: var(--in-content-page-background); + color: var(--in-content-page-color); + box-shadow: var(--shadow-30); + /* show a border in high contrast mode */ + outline: 1px solid transparent; +} + +::slotted([slot="dialog-icon"]) { + width: 32px; + height: 32px; + -moz-context-properties: fill; + fill: currentColor; +} + +::slotted([slot="dialog-title"]) { + font-size: 2.2em; + user-select: none; + margin: 0; +} + +::slotted([slot="content"]) { + grid-column-start: 2; + align-self: baseline; + margin-top: 16px; + line-height: 1.4em; +} + +::slotted([slot="buttons"]) { + grid-column: 1 / 4; + grid-row-start: 3; + border-top: 1px solid var(--in-content-border-color); + padding-top: 12px; +} + +.dialog-body { + padding-block: 40px 16px; + padding-inline: 45px 32px; +} diff --git a/browser/components/aboutlogins/content/components/generic-dialog.mjs b/browser/components/aboutlogins/content/components/generic-dialog.mjs new file mode 100644 index 0000000000..8d9ddc9d36 --- /dev/null +++ b/browser/components/aboutlogins/content/components/generic-dialog.mjs @@ -0,0 +1,63 @@ +/* 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 { + setKeyboardAccessForNonDialogElements, + initDialog, +} from "../aboutLoginsUtils.mjs"; + +export default class GenericDialog extends HTMLElement { + constructor() { + super(); + this._promise = null; + } + + connectedCallback() { + if (this.shadowRoot) { + return; + } + const shadowRoot = initDialog(this, "#generic-dialog-template"); + this._dismissButton = this.querySelector(".dismiss-button"); + this._overlay = shadowRoot.querySelector(".overlay"); + } + + handleEvent(event) { + switch (event.type) { + case "keydown": + if (event.key === "Escape" && !event.defaultPrevented) { + this.hide(); + } + break; + case "click": + if ( + event.currentTarget.classList.contains("dismiss-button") || + event.target.classList.contains("overlay") + ) { + this.hide(); + } + } + } + + show() { + setKeyboardAccessForNonDialogElements(false); + this.hidden = false; + this.parentNode.host.hidden = false; + + this._dismissButton.addEventListener("click", this); + this._overlay.addEventListener("click", this); + window.addEventListener("keydown", this); + } + + hide() { + setKeyboardAccessForNonDialogElements(true); + this._dismissButton.removeEventListener("click", this); + this._overlay.removeEventListener("click", this); + window.removeEventListener("keydown", this); + + this.hidden = true; + this.parentNode.host.hidden = true; + } +} + +customElements.define("generic-dialog", GenericDialog); diff --git a/browser/components/aboutlogins/content/components/import-details-row.mjs b/browser/components/aboutlogins/content/components/import-details-row.mjs new file mode 100644 index 0000000000..8b6fe269a3 --- /dev/null +++ b/browser/components/aboutlogins/content/components/import-details-row.mjs @@ -0,0 +1,60 @@ +/* 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 resultToUiData = { + no_change: { + message: "about-logins-import-report-row-description-no-change2", + }, + modified: { + message: "about-logins-import-report-row-description-modified2", + }, + added: { + message: "about-logins-import-report-row-description-added2", + }, + error: { + message: "about-logins-import-report-row-description-error", + isError: true, + }, + error_multiple_values: { + message: "about-logins-import-report-row-description-error-multiple-values", + isError: true, + }, + error_missing_field: { + message: "about-logins-import-report-row-description-error-missing-field", + isError: true, + }, +}; + +export default class ImportDetailsRow extends HTMLElement { + constructor(number, reportRow) { + super(); + this._login = reportRow; + + let rowElement = document + .querySelector("#import-details-row-template") + .content.cloneNode(true); + + const uiData = resultToUiData[reportRow.result]; + if (uiData.isError) { + this.classList.add("error"); + } + const rowCount = rowElement.querySelector(".row-count"); + const rowDetails = rowElement.querySelector(".row-details"); + while (rowElement.childNodes.length) { + this.appendChild(rowElement.childNodes[0]); + } + document.l10n.connectRoot(this); + document.l10n.setAttributes( + rowCount, + "about-logins-import-report-row-index", + { + number, + } + ); + document.l10n.setAttributes(rowDetails, uiData.message, { + field: reportRow.field_name, + }); + } +} +customElements.define("import-details-row", ImportDetailsRow); diff --git a/browser/components/aboutlogins/content/components/import-error-dialog.css b/browser/components/aboutlogins/content/components/import-error-dialog.css new file mode 100644 index 0000000000..6fc2e945e4 --- /dev/null +++ b/browser/components/aboutlogins/content/components/import-error-dialog.css @@ -0,0 +1,28 @@ +/* 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/. */ + +.content { + display: flex; + flex-direction: column; + grid-area: 2 / 2 / 3 / 4; + align-items: flex-start; +} + +.error-title { + font-weight: 600; + margin-top: 20px; +} + +.no-logins { + margin-top: 25px; +} + +.error-learn-more-link { + font-weight: 600; +} + +.warning-icon { + -moz-context-properties: fill; + fill: #FFBF00; +} diff --git a/browser/components/aboutlogins/content/components/import-error-dialog.mjs b/browser/components/aboutlogins/content/components/import-error-dialog.mjs new file mode 100644 index 0000000000..31ad29512f --- /dev/null +++ b/browser/components/aboutlogins/content/components/import-error-dialog.mjs @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { initDialog } from "../aboutLoginsUtils.mjs"; + +export default class ImportErrorDialog extends HTMLElement { + constructor() { + super(); + this._promise = null; + this._errorMessages = {}; + this._errorMessages.CONFLICTING_VALUES_ERROR = { + title: "about-logins-import-dialog-error-conflicting-values-title", + description: + "about-logins-import-dialog-error-conflicting-values-description", + }; + this._errorMessages.FILE_FORMAT_ERROR = { + title: "about-logins-import-dialog-error-file-format-title", + description: "about-logins-import-dialog-error-file-format-description", + }; + this._errorMessages.FILE_PERMISSIONS_ERROR = { + title: "about-logins-import-dialog-error-file-permission-title", + description: + "about-logins-import-dialog-error-file-permission-description", + }; + this._errorMessages.UNABLE_TO_READ_ERROR = { + title: "about-logins-import-dialog-error-unable-to-read-title", + description: + "about-logins-import-dialog-error-unable-to-read-description", + }; + } + + connectedCallback() { + if (this.shadowRoot) { + return; + } + const shadowRoot = initDialog(this, "#import-error-dialog-template"); + this._titleElement = shadowRoot.querySelector(".error-title"); + this._descriptionElement = shadowRoot.querySelector(".error-description"); + this._genericDialog = this.shadowRoot.querySelector("generic-dialog"); + this._focusedElement = this.shadowRoot.querySelector("a"); + const tryImportAgain = this.shadowRoot.querySelector(".try-import-again"); + tryImportAgain.addEventListener("click", () => { + this._genericDialog.hide(); + document.dispatchEvent( + new CustomEvent("AboutLoginsImportFromFile", { bubbles: true }) + ); + }); + } + + show(errorType) { + const { title, description } = this._errorMessages[errorType]; + document.l10n.setAttributes(this._titleElement, title); + document.l10n.setAttributes(this._descriptionElement, description); + this._genericDialog.show(); + window.AboutLoginsUtils.setFocus(this._focusedElement); + } +} +customElements.define("import-error-dialog", ImportErrorDialog); diff --git a/browser/components/aboutlogins/content/components/import-summary-dialog.css b/browser/components/aboutlogins/content/components/import-summary-dialog.css new file mode 100644 index 0000000000..20dd987958 --- /dev/null +++ b/browser/components/aboutlogins/content/components/import-summary-dialog.css @@ -0,0 +1,42 @@ +/* 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/. */ + +.content { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.import-summary { + display: grid; + grid-template-columns: max-content max-content max-content; +} + +.import-summary > * > span { + margin-block: 0 2px; + margin-inline: 0 10px; +} + +.import-items-row { + grid-column: 1 / 4; + display: grid; + grid-template-columns: subgrid; +} + +.result-count { + text-align: end; + font-weight: bold; +} + +.result-meta { + font-style: italic; +} +.import-items-errors .result-meta { + color: var(--dialog-warning-text-color); +} + +.open-detailed-report { + margin-block-start: 30px; + font-weight: 600; +} diff --git a/browser/components/aboutlogins/content/components/import-summary-dialog.mjs b/browser/components/aboutlogins/content/components/import-summary-dialog.mjs new file mode 100644 index 0000000000..3b84338527 --- /dev/null +++ b/browser/components/aboutlogins/content/components/import-summary-dialog.mjs @@ -0,0 +1,72 @@ +/* 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 { initDialog } from "../aboutLoginsUtils.mjs"; + +export default class ImportSummaryDialog extends HTMLElement { + constructor() { + super(); + this._promise = null; + } + + connectedCallback() { + if (this.shadowRoot) { + return; + } + initDialog(this, "#import-summary-dialog-template"); + this._added = this.shadowRoot.querySelector(".import-items-added"); + this._modified = this.shadowRoot.querySelector(".import-items-modified"); + this._noChange = this.shadowRoot.querySelector(".import-items-no-change"); + this._error = this.shadowRoot.querySelector(".import-items-errors"); + this._genericDialog = this.shadowRoot.querySelector("generic-dialog"); + } + + show({ logins }) { + const report = { + added: 0, + modified: 0, + no_change: 0, + error: 0, + }; + for (let loginRow of logins) { + if (loginRow.result.includes("error")) { + report.error++; + } else { + report[loginRow.result]++; + } + } + this._updateCount( + report.added, + this._added, + "about-logins-import-dialog-items-added2" + ); + this._updateCount( + report.modified, + this._modified, + "about-logins-import-dialog-items-modified2" + ); + this._updateCount( + report.no_change, + this._noChange, + "about-logins-import-dialog-items-no-change2" + ); + this._updateCount( + report.error, + this._error, + "about-logins-import-dialog-items-error" + ); + this._noChange.querySelector(".result-meta").hidden = + report.no_change === 0; + this._error.querySelector(".result-meta").hidden = report.error === 0; + this._genericDialog.show(); + window.AboutLoginsUtils.setFocus(this._genericDialog._dismissButton); + } + + _updateCount(count, component, message) { + if (count != document.l10n.getAttributes(component).args.count) { + document.l10n.setAttributes(component, message, { count }); + } + } +} +customElements.define("import-summary-dialog", ImportSummaryDialog); diff --git a/browser/components/aboutlogins/content/components/input-field/input-field.css b/browser/components/aboutlogins/content/components/input-field/input-field.css new file mode 100644 index 0000000000..0e65b7f1fc --- /dev/null +++ b/browser/components/aboutlogins/content/components/input-field/input-field.css @@ -0,0 +1,60 @@ +/* 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 { + display: grid; + grid-template-areas: "label label" "input actions"; + align-items: center; + font-family: monospace; +} + +label { + grid-area: label; + display: block; + color: var(--in-content-page-color); + margin-bottom: 8px; +} + +/** input.input-field needed to override margin in themes/osx/global/in-content/common.css */ +input.input-field { + grid-area: input; + margin: 0; +} + + +.input-field:read-only { + all: unset; + display: inline-block; + text-overflow: ellipsis; + overflow: hidden; +} + +.reveal-password-button { + grid-area: actions; + width: 32px; + height: 32px; + min-width: 0; + background: url("chrome://browser/content/aboutlogins/icons/password.svg") center no-repeat; + cursor: pointer; + -moz-context-properties: fill; + fill: currentColor; + color: inherit; + opacity: 0.8; +} + +.reveal-password-button.revealed { + background-image: url("chrome://browser/content/aboutlogins/icons/password-hide.svg"); +} + +/** button.reveal-password-button needed to override --in-content-button-background-hover in common-shared.css **/ +button.reveal-password-button:hover { + opacity: 0.6; + background-color: transparent; +} + +/** button.reveal-password-button needed to override --in-content-button-background-active in common-shared.css **/ +button.reveal-password-button:hover:active { + opacity: 1; + background-color: transparent; +} diff --git a/browser/components/aboutlogins/content/components/input-field/input-field.mjs b/browser/components/aboutlogins/content/components/input-field/input-field.mjs new file mode 100644 index 0000000000..dd65f167fe --- /dev/null +++ b/browser/components/aboutlogins/content/components/input-field/input-field.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/. */ + +import { html, ifDefined } from "chrome://global/content/vendor/lit.all.mjs"; + +export const stylesTemplate = () => + html` + + + `; + +export const editableFieldTemplate = ({ + type, + value, + inputId, + disabled, + onFocus, + onBlur, +}) => + html``; diff --git a/browser/components/aboutlogins/content/components/input-field/input-field.stories.mjs b/browser/components/aboutlogins/content/components/input-field/input-field.stories.mjs new file mode 100644 index 0000000000..99dfa83738 --- /dev/null +++ b/browser/components/aboutlogins/content/components/input-field/input-field.stories.mjs @@ -0,0 +1,133 @@ +/* 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 "./login-password-field.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "./login-username-field.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "./login-origin-field.mjs"; + +export default { + title: "Domain-specific UI Widgets/Credential Management/Input Fields", +}; + +window.MozXULElement.insertFTLIfNeeded("browser/aboutLogins.ftl"); + +export const LoginUsernameField = ({ value, readonly }) => { + return html` +
+ + +
+ `; +}; + +LoginUsernameField.argTypes = { + value: { + control: "text", + defaultValue: "username", + }, + readonly: { + control: "boolean", + defaultValue: false, + }, +}; + +export const LoginOriginField = ({ value, readonly }) => { + return html` +
+ + +
+ `; +}; + +LoginOriginField.argTypes = { + value: { + control: "text", + defaultValue: "https://example.com", + }, + readonly: { + control: "boolean", + defaultValue: false, + }, +}; + +export const LoginPasswordField = ({ + readonly, + visible, + value = "longpassword".repeat(2), +}) => { + return html` +
+ alert("auth...")} + > + +
+ `; +}; + +LoginPasswordField.argTypes = { + readonly: { + control: "boolean", + defaultValue: true, + }, + visible: { + control: "boolean", + defaultValue: false, + }, +}; + +export const LoginPasswordFieldDisplayMode = ({ + visible, + value = "longpassword".repeat(2), +}) => { + return html` +
+ + +
+ `; +}; + +LoginPasswordFieldDisplayMode.argTypes = { + visible: { + control: "boolean", + defaultValue: false, + }, +}; + +export const LoginPasswordFieldEditMode = ({ + visible, + value = "longpassword".repeat(2), +}) => { + return html` +
+ + +
+ `; +}; + +LoginPasswordFieldEditMode.argTypes = { + visible: { + control: "boolean", + defaultValue: false, + }, +}; diff --git a/browser/components/aboutlogins/content/components/input-field/login-origin-field.mjs b/browser/components/aboutlogins/content/components/input-field/login-origin-field.mjs new file mode 100644 index 0000000000..a6170eae5f --- /dev/null +++ b/browser/components/aboutlogins/content/components/input-field/login-origin-field.mjs @@ -0,0 +1,43 @@ +/* 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"; +import { editableFieldTemplate, stylesTemplate } from "./input-field.mjs"; + +class LoginOriginField extends MozLitElement { + static properties = { + value: { type: String, reflect: true }, + readonly: { type: Boolean, reflect: true }, + }; + + get readonlyTemplate() { + return html` + + ${this.value} + + `; + } + + render() { + return html` + ${stylesTemplate()} + + ${this.readonly + ? this.readonlyTemplate + : editableFieldTemplate({ + type: "url", + value: this.value, + })} + `; + } +} + +customElements.define("login-origin-field", LoginOriginField); diff --git a/browser/components/aboutlogins/content/components/input-field/login-password-field.mjs b/browser/components/aboutlogins/content/components/input-field/login-password-field.mjs new file mode 100644 index 0000000000..6d903beb0a --- /dev/null +++ b/browser/components/aboutlogins/content/components/input-field/login-password-field.mjs @@ -0,0 +1,84 @@ +/* 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, classMap } from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; +import { editableFieldTemplate, stylesTemplate } from "./input-field.mjs"; + +class LoginPasswordField extends MozLitElement { + static CONCEALED_PASSWORD_TEXT = " ".repeat(8); + + static properties = { + _value: { type: String, state: true }, + readonly: { type: Boolean, reflect: true }, + visible: { type: Boolean, reflect: true }, + }; + + static queries = { + input: "input", + button: "button", + }; + + set value(newValue) { + this._value = newValue; + } + + get #type() { + return this.visible ? "text" : "password"; + } + + get #password() { + return this.readonly && !this.visible + ? LoginPasswordField.CONCEALED_PASSWORD_TEXT + : this._value; + } + + render() { + return html` + ${stylesTemplate()} + + ${editableFieldTemplate({ + type: this.#type, + value: this.#password, + labelId: "login-item-password-label", + disabled: this.readonly, + onFocus: this.handleFocus, + onBlur: this.handleBlur, + })} + + `; + } + + handleFocus(ev) { + if (ev.relatedTarget !== this.button) { + this.visible = true; + } + } + + handleBlur(ev) { + if (ev.relatedTarget !== this.button) { + this.visible = false; + } + } + + toggleVisibility() { + this.visible = !this.visible; + if (this.visible) { + this.onPasswordVisible?.(); + } + this.input.focus(); + } +} + +customElements.define("login-password-field", LoginPasswordField); diff --git a/browser/components/aboutlogins/content/components/input-field/login-username-field.mjs b/browser/components/aboutlogins/content/components/input-field/login-username-field.mjs new file mode 100644 index 0000000000..87743f3689 --- /dev/null +++ b/browser/components/aboutlogins/content/components/input-field/login-username-field.mjs @@ -0,0 +1,30 @@ +/* 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"; +import { editableFieldTemplate, stylesTemplate } from "./input-field.mjs"; + +class LoginUsernameField extends MozLitElement { + static properties = { + value: { type: String, reflect: true }, + readonly: { type: Boolean, reflect: true }, + }; + + render() { + return html` + ${stylesTemplate()} + + ${editableFieldTemplate({ + type: "text", + value: this.value, + disabled: this.readonly, + })} + `; + } +} + +customElements.define("login-username-field", LoginUsernameField); diff --git a/browser/components/aboutlogins/content/components/login-alert.css b/browser/components/aboutlogins/content/components/login-alert.css new file mode 100644 index 0000000000..b2d17b433e --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-alert.css @@ -0,0 +1,81 @@ +/* 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(login-alert) { + display: grid; + column-gap: 16px; + grid-template-areas: "icon title action" "icon content content"; + grid-template-columns: min-content 1fr auto; + padding: 16px 32px; + color: var(--in-content-text-color); + background-color: var(--in-content-box-background); + border-radius: 4px; + border: 1px solid var(--in-content-border-color); + box-shadow: 0 2px 8px 0 rgba(0, 0, 0, .1); + font-size: .9em; +} + +:host([variant="info"]) { + background-color: var(--in-content-box-background); +} + +:host([variant="error"]) { + background-color: #a4000f; + color: white; +} + +:host([variant="warning"]) { + background: #d7b600; + color: black; +} + +:host(login-alert) img { + grid-area: icon; + width: 24px; + -moz-context-properties: fill; + fill: currentColor; +} + +:host(login-alert) h3 { + grid-area: title; + font-size: 1.5em; + font-weight: normal; + margin: 0; + padding: 0; +} + +:host(login-alert) slot[name="action"] { + grid-area: action; +} + +:host(login-alert) slot[name="content"] { + grid-area: content; +} + +:host(login-breach-alert) div[slot="content"], +:host(login-vulnerable-password-alert) div[slot="content"] { + margin-block-start: 8px; +} + +:host(login-vulnerable-password-alert) a { + font-weight: 600; +} + +:host(login-vulnerable-password-alert) div[slot="content"] > a { + color: var(--link-color); +} + +:host(login-vulnerable-password-alert) a[slot="action"] { + color: var(--text-color-deemphasized); +} + +:host(login-breach-alert) h4 { + margin: 0; + padding: 0; +} + +:host(login-breach-alert) a { + font-weight: 600; + color: inherit; +} diff --git a/browser/components/aboutlogins/content/components/login-alert.mjs b/browser/components/aboutlogins/content/components/login-alert.mjs new file mode 100644 index 0000000000..f435b9daef --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-alert.mjs @@ -0,0 +1,150 @@ +/* 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, + guard, +} from "chrome://global/content/vendor/lit.all.mjs"; + +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +export class LoginAlert extends MozLitElement { + static get properties() { + return { + variant: { type: String, reflect: true }, + icon: { type: String }, + titleId: { type: String }, + }; + } + + render() { + return html` + + +

+
+ +
+ + `; + } +} + +export class VulnerablePasswordAlert extends MozLitElement { + static get properties() { + return { + hostname: { type: String, reflect: true }, + }; + } + + constructor() { + super(); + this.hostname = ""; + } + render() { + return html` + + +
+ + +
+ +
+ `; + } +} + +export class LoginBreachAlert extends MozLitElement { + static get properties() { + return { + date: { type: Number, reflect: true }, + hostname: { type: String, reflect: true }, + }; + } + + constructor() { + super(); + this.date = 0; + this.hostname = ""; + } + + get displayHostname() { + try { + return new URL(this.hostname).hostname; + } catch (err) { + return this.hostname; + } + } + + render() { + return html` + + + + + `; + } +} + +customElements.define( + "login-vulnerable-password-alert", + VulnerablePasswordAlert +); +customElements.define("login-breach-alert", LoginBreachAlert); +customElements.define("login-alert", LoginAlert); diff --git a/browser/components/aboutlogins/content/components/login-alert.stories.mjs b/browser/components/aboutlogins/content/components/login-alert.stories.mjs new file mode 100644 index 0000000000..7eaa2dadf4 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-alert.stories.mjs @@ -0,0 +1,79 @@ +/* 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 "./login-alert.mjs"; + +export default { + title: "Domain-specific UI Widgets/Credential Management/Login Alert", + component: "login-alert", +}; + +window.MozXULElement.insertFTLIfNeeded("browser/aboutLogins.ftl"); + +export const BasicLoginAlert = ({ variant, icon }) => { + return html` + + Some action +
+ Alert extra content, could be a description for more context. +
+
+ `; +}; + +BasicLoginAlert.argTypes = { + variant: { + options: ["info", "error", "warning"], + control: { type: "radio" }, + defaultValue: "info", + }, + icon: { + options: { + info: "chrome://global/skin/icons/info-filled.svg", + "breached-website": + "chrome://browser/content/aboutlogins/icons/breached-website.svg", + "vulnerable-password": + "chrome://browser/content/aboutlogins/icons/vulnerable-password.svg", + }, + control: { type: "select" }, + defaultValue: "chrome://global/skin/icons/info-filled.svg", + }, +}; + +export const VulnerablePasswordAlert = ({ hostname }) => + html` + + `; + +VulnerablePasswordAlert.args = { + hostname: "https://www.example.com", +}; + +export const LoginBreachAlert = ({ date, hostname }) => + html` + + `; + +LoginBreachAlert.argTypes = { + date: { + control: { type: "date" }, + defaultValue: 1684849435571, + }, + hostname: { + control: { type: "text" }, + defaultValue: "https://www.example.com", + }, +}; diff --git a/browser/components/aboutlogins/content/components/login-command-button.css b/browser/components/aboutlogins/content/components/login-command-button.css new file mode 100644 index 0000000000..40b3f9a455 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-command-button.css @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +:host button { + margin: 0; + display: inline-flex; + flex-direction: row; + align-items: center; + min-width: auto; +} + +:host img { + padding-inline-end: 8px; + -moz-context-properties: fill; + fill: currentColor; +} + +:host(create-login-button) img, +:host(.copy-button) img { + padding: 0; +} + +:host([data-copied]) button { + color: var(--in-content-success-icon-color) !important; + background-color: transparent; + opacity: 1; + /* override common.css fading out disabled buttons */ +} + +:host([data-copied]) { + -moz-context-properties: fill; + fill: currentColor; +} diff --git a/browser/components/aboutlogins/content/components/login-command-button.mjs b/browser/components/aboutlogins/content/components/login-command-button.mjs new file mode 100644 index 0000000000..d8d195bfcc --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-command-button.mjs @@ -0,0 +1,187 @@ +/* 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/. */ + +/* + * Note: For now, to display the tooltip for a you need to + * use data-l10n-id attribute instead of the l10nId attribute in the tag. + * Bug 1844869 will make an attempt to fix this. + */ + +import { + html, + ifDefined, + when, +} from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +export const stylesTemplate = () => html` + `; + +export const LoginCommandButton = ({ + onClick, + l10nId, + icon, + variant, + disabled, + buttonText, +}) => html``; + +export class CreateLoginButton extends MozLitElement { + static get properties() { + return { + disabled: { type: Boolean, reflect: true }, + }; + } + + constructor() { + super(); + this.disabled = false; + } + render() { + return html` + ${stylesTemplate()} + ${LoginCommandButton({ + l10nId: "create-login-button", + variant: "icon-button", + icon: "chrome://global/skin/icons/plus.svg", + disabled: this.disabled, + })} + `; + } +} + +export class EditButton extends MozLitElement { + static get properties() { + return { + disabled: { type: Boolean, reflect: true }, + }; + } + + constructor() { + super(); + this.disabled = false; + } + render() { + return html` + ${stylesTemplate()} + ${LoginCommandButton({ + buttonText: "login-item-edit-button", + variant: "ghost-button", + icon: "chrome://global/skin/icons/edit.svg", + disabled: this.disabled, + })} + `; + } +} + +export class DeleteButton extends MozLitElement { + static get properties() { + return { + disabled: { type: Boolean, reflect: true }, + }; + } + + constructor() { + super(); + this.disabled = false; + } + render() { + return html` ${stylesTemplate()} + ${LoginCommandButton({ + buttonText: "about-logins-login-item-remove-button", + variant: "ghost-button", + icon: "chrome://global/skin/icons/delete.svg", + disabled: this.disabled, + })}`; + } +} + +export class CopyUsernameButton extends MozLitElement { + static get properties() { + return { + copiedText: { type: Boolean, reflect: true }, + disabled: { type: Boolean, reflect: true }, + }; + } + + constructor() { + super(); + this.copiedText = false; + this.disabled = false; + } + render() { + this.className = this.copiedText ? "copied-button" : "copy-button"; + return html` ${stylesTemplate()} + ${when( + this.copiedText, + () => + html`${LoginCommandButton({ + buttonText: "login-item-copied-username-button-text", + icon: "chrome://global/skin/icons/check.svg", + disabled: this.disabled, + })}`, + () => + html`${LoginCommandButton({ + variant: "text-button", + buttonText: "login-item-copy-username-button-text", + disabled: this.disabled, + })}` + )}`; + } +} + +export class CopyPasswordButton extends MozLitElement { + static get properties() { + return { + copiedText: { type: Boolean, reflect: true }, + disabled: { type: Boolean, reflect: true }, + }; + } + + constructor() { + super(); + this.copiedText = false; + this.disabled = false; + } + render() { + this.className = this.copiedText ? "copied-button" : "copy-button"; + return html` ${stylesTemplate()} + ${when( + this.copiedText, + () => + html`${LoginCommandButton({ + buttonText: "login-item-copied-password-button-text", + icon: "chrome://global/skin/icons/check.svg", + disabled: this.disabled, + })}`, + () => + html`${LoginCommandButton({ + variant: "text-button", + buttonText: "login-item-copy-password-button-text", + disabled: this.disabled, + })}` + )}`; + } +} + +customElements.define("copy-password-button", CopyPasswordButton); +customElements.define("copy-username-button", CopyUsernameButton); +customElements.define("delete-button", DeleteButton); +customElements.define("edit-button", EditButton); +customElements.define("create-login-button", CreateLoginButton); diff --git a/browser/components/aboutlogins/content/components/login-filter.css b/browser/components/aboutlogins/content/components/login-filter.css new file mode 100644 index 0000000000..f7db0e6770 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-filter.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 http://mozilla.org/MPL/2.0/. */ + +.filter[type="text"] { + -moz-context-properties: fill, fill-opacity; + fill: currentColor; + fill-opacity: 0.4; + background-image: url("chrome://global/skin/icons/search-glass.svg"); + background-position: 8px center; + background-repeat: no-repeat; + background-size: 16px; + text-align: match-parent; + width: 100%; + margin: 0; + box-sizing: border-box; + padding-block: 6px; +} + +:host(:dir(ltr)) .filter { + /* We use separate RTL rules over logical properties since we want the visual direction + to be independent from the user input direction */ + padding-left: 32px; +} + +:host(:dir(rtl)) .filter { + background-position-x: right 8px; + padding-right: 32px; +} diff --git a/browser/components/aboutlogins/content/components/login-filter.mjs b/browser/components/aboutlogins/content/components/login-filter.mjs new file mode 100644 index 0000000000..e5b89327d6 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-filter.mjs @@ -0,0 +1,99 @@ +/* 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 { recordTelemetryEvent } from "../aboutLoginsUtils.mjs"; + +export default class LoginFilter extends HTMLElement { + get #loginList() { + return document.querySelector("login-list"); + } + + connectedCallback() { + if (this.shadowRoot) { + return; + } + + let loginFilterTemplate = document.querySelector("#login-filter-template"); + let shadowRoot = this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + shadowRoot.appendChild(loginFilterTemplate.content.cloneNode(true)); + + this._input = this.shadowRoot.querySelector("input"); + + this.addEventListener("input", this); + this._input.addEventListener("keydown", this); + window.addEventListener("AboutLoginsFilterLogins", this); + } + + focus() { + this._input.focus(); + } + + handleEvent(event) { + switch (event.type) { + case "AboutLoginsFilterLogins": + this.#filterLogins(event.detail); + break; + case "input": + this.#input(event.originalTarget.value); + break; + case "keydown": + this.#keyDown(event); + break; + } + } + + #filterLogins(filterText) { + if (this.value != filterText) { + this.value = filterText; + } + } + + #input(value) { + this._dispatchFilterEvent(value); + } + + #keyDown(e) { + switch (e.code) { + case "ArrowUp": + e.preventDefault(); + this.#loginList.selectPrevious(); + break; + case "ArrowDown": + e.preventDefault(); + this.#loginList.selectNext(); + break; + case "Escape": + e.preventDefault(); + this.value = ""; + break; + case "Enter": + e.preventDefault(); + this.#loginList.clickSelected(); + break; + } + } + + get value() { + return this._input.value; + } + + set value(val) { + this._input.value = val; + this._dispatchFilterEvent(val); + } + + _dispatchFilterEvent(value) { + this.dispatchEvent( + new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + composed: true, + detail: value, + }) + ); + + recordTelemetryEvent({ object: "list", method: "filter" }); + } +} +customElements.define("login-filter", LoginFilter); diff --git a/browser/components/aboutlogins/content/components/login-intro.css b/browser/components/aboutlogins/content/components/login-intro.css new file mode 100644 index 0000000000..e27f0bddd0 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-intro.css @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +:host { + padding: 60px; + display: flex; + flex-direction: column; + align-items: center; +} + +section { + line-height: 2; +} + +.description { + font-weight: var(--font-weight-bold); + margin-bottom: 0; +} + +.illustration.logged-in { + opacity: .5; +} diff --git a/browser/components/aboutlogins/content/components/login-intro.mjs b/browser/components/aboutlogins/content/components/login-intro.mjs new file mode 100644 index 0000000000..f988910eb6 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-intro.mjs @@ -0,0 +1,65 @@ +/* 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/. */ + +export default class LoginIntro extends HTMLElement { + connectedCallback() { + if (this.shadowRoot) { + return; + } + + let loginIntroTemplate = document.querySelector("#login-intro-template"); + let shadowRoot = this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + shadowRoot.appendChild(loginIntroTemplate.content.cloneNode(true)); + } + + focus() { + let helpLink = this.shadowRoot.querySelector(".intro-help-link"); + helpLink.focus(); + } + + handleEvent(event) { + if ( + event.currentTarget.classList.contains("intro-import-text") && + event.target.localName == "a" + ) { + let eventName = + event.target.dataset.l10nName == "import-file-link" + ? "AboutLoginsImportFromFile" + : "AboutLoginsImportFromBrowser"; + document.dispatchEvent( + new CustomEvent(eventName, { + bubbles: true, + }) + ); + } + event.preventDefault(); + } + + updateState(syncState) { + let l10nId = "about-logins-login-intro-heading-message"; + document.l10n.setAttributes( + this.shadowRoot.querySelector(".heading"), + l10nId + ); + + this.shadowRoot + .querySelector(".illustration") + .classList.toggle("logged-in", syncState.loggedIn); + let supportURL = + window.AboutLoginsUtils.supportBaseURL + + "password-manager-remember-delete-edit-logins"; + this.shadowRoot + .querySelector(".intro-help-link") + .setAttribute("href", supportURL); + + let importClass = window.AboutLoginsUtils.fileImportEnabled + ? ".intro-import-text.file-import" + : ".intro-import-text.no-file-import"; + let importText = this.shadowRoot.querySelector(importClass); + importText.addEventListener("click", this); + importText.hidden = !window.AboutLoginsUtils.importVisible; + } +} +customElements.define("login-intro", LoginIntro); diff --git a/browser/components/aboutlogins/content/components/login-item.css b/browser/components/aboutlogins/content/components/login-item.css new file mode 100644 index 0000000000..07471c35ef --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-item.css @@ -0,0 +1,328 @@ +/* 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 { + overflow: hidden; + + --reveal-checkbox-opacity: .8; + --reveal-checkbox-opacity-hover: .6; + --reveal-checkbox-opacity-active: 1; +} + +/* Only overwrite the deemphasized text color in non-dark mode. */ +@media not (prefers-color-scheme: dark) { + :host { + --text-color-deemphasized: #737373; + } +} + +@media (prefers-color-scheme: dark) { + :host { + --reveal-checkbox-opacity: .8; + --reveal-checkbox-opacity-hover: 1; + --reveal-checkbox-opacity-active: .6; + } +} + +.container { + overflow: auto; + padding: 0 40px; + box-sizing: border-box; + height: 100%; +} + +@media (max-width: 830px) { + .container { + padding-inline: 20px; + } +} + +.column { + min-height: 100%; + max-width: 700px; + display: flex; + flex-direction: column; +} + +button { + min-width: 100px; +} + +form { + flex-grow: 1; +} + +:host([data-editing]) edit-button, +:host([data-editing]) :is(.copy-button, .copied-button), +:host([data-is-new-login]) delete-button, +:host([data-is-new-login]) .origin-saved-value, +:host([data-is-new-login]) login-timeline, +:host([data-is-new-login]) .login-item-title, +:host(:not([data-is-new-login])) .new-login-title, +:host(:not([data-editing])) .form-actions-row { + 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, +input[type="url"]:read-only { + all: unset; + font-size: 1.1em; + display: inline-block; + background-color: transparent !important; /* override common.inc.css */ + text-overflow: ellipsis; + overflow: hidden; + width: 100%; +} + +/* 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. */ +.detail-cell:dir(ltr) input:not([type="checkbox"]) { + margin-left: 0; +} + +.detail-cell:dir(rtl) input:not([type="checkbox"]) { + margin-right: 0; +} + +.save-changes-button { + margin-inline-start: 0; /* Align the button on the start side */ +} + +.header { + display: flex; + align-items: center; + margin-bottom: 40px; + margin-top: 5px; +} + +.title { + margin-block: 0; + flex-grow: 1; +} + +origin-warning, password-warning { + display: none; +} + +input[type = "url"]:focus:not(:user-invalid):invalid ~ origin-warning, +input[type = "url"]:focus:user-invalid:not(:placeholder-shown) ~ origin-warning { + display: block; +} + +input[name = "password"]:focus ~ password-warning { + display: block; +} + +.reveal-password-wrapper { + display: flex; + align-items: center; + justify-content: space-between; +} + +.detail-grid { + display: grid; + grid-template-columns: minmax(240px, max-content) auto; + grid-template-rows: auto; + column-gap: 20px; + row-gap: 40px; + justify-content: start; +} + +:host([data-editing]) .detail-grid { + grid-template-columns: auto; +} + +:host([data-editing]) .detail-grid > .detail-row { + display: flex; +} + +.detail-grid > .detail-row:not([hidden]) { + display: contents; +} + +.detail-grid > .detail-row > .detail-cell { + grid-column: 1; +} + +.detail-grid > .detail-row > :is(.copy-button, .copied-button) { + grid-column: 2; + margin-inline-start: 0; /* Reset button's margin so it doesn't affect the overall grid's width */ + justify-self: start; + align-self: end; +} + +.detail-row { + display: flex; + position: relative; /* Allows for the hint message to be positioned correctly */ +} + +.detail-grid, +.detail-row { + margin-bottom: 40px; +} + +.detail-cell { + flex-grow: 1; + min-width: 0; /* Allow long passwords to collapse down to flex item width */ +} + +.field-label { + display: block; + margin-bottom: 8px; +} + +moz-button-group, +:host([data-editing]) .detail-cell input:read-write:not([type="checkbox"]), +:host([data-editing]) input[type="password"]:read-only { + width: 298px; + box-sizing: border-box; +} + +.copy-button, +.copied-button { + margin-bottom: 0; /* Align button at the bottom of the row */ +} + +.copied-button[data-copied]:focus-visible { + outline-width: 0; + 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"); + margin-inline: 10px 0; + cursor: pointer; + -moz-context-properties: fill; + fill: currentColor; + color: inherit; + opacity: var(--reveal-checkbox-opacity); + + &:hover { + opacity: var(--reveal-checkbox-opacity-hover); + + &:active { + opacity: var(--reveal-checkbox-opacity-active); + } + } + + &:checked { + background-image: url("resource://gre-resources/password-hide.svg"); + } +} + +.login-item-favicon { + margin-inline-end: 12px; + height: 24px; + width: 24px; + flex-shrink: 0; + -moz-context-properties: fill, fill-opacity; + fill: currentColor; + fill-opacity: 0.75; +} + +login-breach-alert, +login-vulnerable-password-alert { + margin-block-end: 40px; +} + +login-command-button { + margin-block-start: 4px; /* Focus did not display entirely on edit and remove with margin:0 */ +} + +.alert-title { + font-size: var(--font-size-xlarge); + font-weight: var(--font-weight-default); + line-height: 1em; + margin-block: 0 12px; +} + +.alert-date { + display: block; + font-weight: var(--font-weight-bold); +} + +.alert-link:visited, +.alert-link { + font-weight: var(--font-weight-bold); + overflow-wrap: anywhere; +} + +.breach-alert > .alert-link:visited, +.breach-alert > .alert-link { + color: inherit; + text-decoration: underline; +} + +.alert-icon { + position: absolute; + inset-block-start: 16px; + inset-inline-start: 32px; + -moz-context-properties: fill; + fill: currentColor; + width: 24px; +} + +.alert-learn-more-link:hover, +.alert-learn-more-link:visited, +.alert-learn-more-link { + position: absolute; + inset-block-start: 16px; + inset-inline-end: 32px; + color: inherit; + font-size: var(--font-size-small); +} + +.vulnerable-alert > .alert-learn-more-link { + color: var(--text-color-deemphasized); +} + +.error-message { + color: #fff; + background-color: var(--red-60); + border: 1px solid transparent; + padding-block: 6px; + display: inline-block; + padding-inline: 32px 16px; + background-image: url("chrome://global/skin/icons/warning.svg"); + background-repeat: no-repeat; + background-position: left 10px center; + -moz-context-properties: fill; + fill: currentColor; + margin-bottom: 38px; +} + +.error-message:dir(rtl) { + background-position-x: right 10px; +} + +.error-message-link > a, +.error-message-link > a:hover, +.error-message-link > a:hover:active { + color: currentColor; + text-decoration: underline; + font-weight: var(--font-weight-bold); +} + +.action-buttons { + display: flex; + flex-direction: row; +} + +.action-buttons .form-actions-row { + margin-inline: 0 5px; +} diff --git a/browser/components/aboutlogins/content/components/login-item.mjs b/browser/components/aboutlogins/content/components/login-item.mjs new file mode 100644 index 0000000000..fc7dffff8b --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-item.mjs @@ -0,0 +1,1038 @@ +/* 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 { + CONCEALED_PASSWORD_TEXT, + recordTelemetryEvent, + promptForPrimaryPassword, +} from "../aboutLoginsUtils.mjs"; + +export default class LoginItem extends HTMLElement { + /** + * The number of milliseconds to display the "Copied" success message + * before reverting to the normal "Copy" button. + */ + static get COPY_BUTTON_RESET_TIMEOUT() { + return 5000; + } + + constructor() { + super(); + this._login = {}; + this._error = null; + this._copyUsernameTimeoutId = 0; + this._copyPasswordTimeoutId = 0; + } + + connectedCallback() { + if (this.shadowRoot) { + this.render(); + return; + } + + let loginItemTemplate = document.querySelector("#login-item-template"); + let shadowRoot = this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + shadowRoot.appendChild(loginItemTemplate.content.cloneNode(true)); + + this._cancelButton = this.shadowRoot.querySelector(".cancel-button"); + this._confirmDeleteDialog = document.querySelector("confirm-delete-dialog"); + this._copyPasswordButton = this.shadowRoot.querySelector( + "copy-password-button" + ); + this._copyUsernameButton = this.shadowRoot.querySelector( + "copy-username-button" + ); + this._deleteButton = this.shadowRoot.querySelector("delete-button"); + this._editButton = this.shadowRoot.querySelector("edit-button"); + this._errorMessage = this.shadowRoot.querySelector(".error-message"); + this._errorMessageLink = this._errorMessage.querySelector( + ".error-message-link" + ); + this._errorMessageText = this._errorMessage.querySelector( + ".error-message-text" + ); + this._form = this.shadowRoot.querySelector("form"); + this._originInput = this.shadowRoot.querySelector("input[name='origin']"); + this._originDisplayInput = + this.shadowRoot.querySelector("a[name='origin']"); + this._usernameInput = this.shadowRoot.querySelector( + "input[name='username']" + ); + // type=password field for display which only ever contains spaces the correct + // length of the password. + this._passwordDisplayInput = this.shadowRoot.querySelector( + "input.password-display" + ); + // type=text field for editing the password with the actual password value. + this._passwordInput = this.shadowRoot.querySelector( + "input[name='password']" + ); + this._revealCheckbox = this.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + this._saveChangesButton = this.shadowRoot.querySelector( + ".save-changes-button" + ); + this._favicon = this.shadowRoot.querySelector(".login-item-favicon"); + this._title = this.shadowRoot.querySelector(".login-item-title"); + this._breachAlert = this.shadowRoot.querySelector("login-breach-alert"); + this._vulnerableAlert = this.shadowRoot.querySelector( + "login-vulnerable-password-alert" + ); + this._passwordWarning = this.shadowRoot.querySelector("password-warning"); + this._originWarning = this.shadowRoot.querySelector("origin-warning"); + + this.render(); + + this._cancelButton.addEventListener("click", e => + this.handleCancelEvent(e) + ); + + window.addEventListener("keydown", e => this.handleKeydown(e)); + + // TODO: Using the addEventListener to listen for clicks and pass the event handler due to a CSP error. + // This will be fixed as login-item itself is converted into a lit component. We will then be able to use the onclick + // prop of login-command-button as seen in the example below (functionality works and passes tests). + // this._editButton.onClick = e => this.handleEditEvent(e); + + this._editButton.addEventListener("click", e => this.handleEditEvent(e)); + + this._copyPasswordButton.addEventListener("click", e => + this.handleCopyPasswordClick(e) + ); + + this._copyUsernameButton.addEventListener("click", e => + this.handleCopyUsernameClick(e) + ); + + this._deleteButton.addEventListener("click", e => + this.handleDeleteEvent(e) + ); + + this._errorMessageLink.addEventListener("click", e => + this.handleDuplicateErrorGuid(e) + ); + + this._form.addEventListener("submit", e => this.handleInputSubmit(e)); + this._originInput.addEventListener("blur", e => this.addHTTPSPrefix(e)); + + this._originInput.addEventListener("click", e => + this.handleOriginInputClick(e) + ); + + this._originInput.addEventListener( + "mousedown", + e => this.handleInputMousedown(e), + true + ); + + this._originInput.addEventListener("auxclick", e => + this.handleInputAuxclick(e) + ); + + this._originDisplayInput.addEventListener("click", e => + this.handleOriginInputClick(e) + ); + + this._revealCheckbox.addEventListener("click", e => + this.handleRevealPasswordClick(e) + ); + + this._passwordInput.addEventListener("focus", e => + this.handlePasswordDisplayFocus(e) + ); + + this._passwordInput.addEventListener("blur", e => + this.dataset.editing + ? this.handleEditPasswordInputBlur(e) + : this.addHTTPSPrefix(e) + ); + + this._passwordDisplayInput.addEventListener("focus", e => + this.handlePasswordDisplayFocus(e) + ); + this._passwordDisplayInput.addEventListener("blur", e => + this.handlePasswordDisplayBlur(e) + ); + + window.addEventListener("AboutLoginsInitialLoginSelected", e => + this.handleAboutLoginsInitial(e) + ); + window.addEventListener("AboutLoginsLoginSelected", e => + this.handleAboutLoginsLoginSelected(e) + ); + window.addEventListener("AboutLoginsShowBlankLogin", e => + this.handleAboutLoginsShowBlankLogin(e) + ); + window.addEventListener("AboutLoginsRemaskPassword", e => + this.handleAboutLoginsRemaskPassword(e) + ); + } + + focus() { + if (!this._editButton.disabled) { + this._editButton.focus(); + } else if (!this._deleteButton.disabled) { + this._deleteButton.focus(); + } else { + this._originInput.focus(); + } + } + + async render( + { onlyUpdateErrorsAndAlerts } = { onlyUpdateErrorsAndAlerts: false } + ) { + if (this._error) { + if (this._error.errorMessage.includes("This login already exists")) { + document.l10n.setAttributes( + this._errorMessageLink, + "about-logins-error-message-duplicate-login-with-link", + { + loginTitle: this._error.login.title, + } + ); + this._errorMessageLink.dataset.errorGuid = + this._error.existingLoginGuid; + this._errorMessageText.hidden = true; + this._errorMessageLink.hidden = false; + } else { + this._errorMessageText.hidden = false; + this._errorMessageLink.hidden = true; + } + } + this._errorMessage.hidden = !this._error; + + this._breachAlert.hidden = + !this._breachesMap || !this._breachesMap.has(this._login.guid); + if (!this._breachAlert.hidden) { + const breachDetails = this._breachesMap.get(this._login.guid); + const breachTimestamp = new Date(breachDetails.BreachDate ?? 0).getTime(); + this.#updateBreachAlert(this._login.origin, breachTimestamp); + } + this._vulnerableAlert.hidden = + !this._vulnerableLoginsMap || + !this._vulnerableLoginsMap.has(this._login.guid) || + !this._breachAlert.hidden; + if (!this._vulnerableAlert.hidden) { + this.#updateVulnerablePasswordAlert(this._login.origin); + } + if (onlyUpdateErrorsAndAlerts) { + return; + } + + this._favicon.src = `page-icon:${this._login.origin}`; + this._title.textContent = this._login.title; + this._title.title = this._login.title; + this._originInput.defaultValue = this._login.origin || ""; + if (this._login.origin) { + // Creates anchor element with origin URL + this._originDisplayInput.href = this._login.origin || ""; + this._originDisplayInput.innerText = this._login.origin || ""; + } + this._usernameInput.defaultValue = this._login.username || ""; + if (this._login.password) { + // We use .value instead of .defaultValue since the latter updates the + // content attribute making the password easily viewable with Inspect + // Element even when Primary Password is enabled. This is only run when + // the password is non-empty since setting the field to an empty value + // would mark the field as 'dirty' for form validation and thus trigger + // the error styling since the password field is 'required'. + // This element is only in the document while unmasked or editing. + this._passwordInput.value = this._login.password; + + // In masked non-edit mode we use a different "display" element to render + // the masked password so that one cannot simply remove/change + // @type=password to reveal the real password. + this._passwordDisplayInput.value = CONCEALED_PASSWORD_TEXT; + } + + if (this.dataset.editing) { + this._usernameInput.removeAttribute("data-l10n-id"); + this._usernameInput.placeholder = ""; + } else { + document.l10n.setAttributes( + this._usernameInput, + "about-logins-login-item-username" + ); + } + this._copyUsernameButton.disabled = !this._login.username; + document.l10n.setAttributes( + this._saveChangesButton, + this.dataset.isNewLogin + ? "login-item-save-new-button" + : "about-logins-login-item-save-changes-button" + ); + this._updatePasswordRevealState(); + this._updateOriginDisplayState(); + this.#updateTimeline(); + this.#updatePasswordMessage(); + } + + #updateTimeline() { + let timeline = this.shadowRoot.querySelector("login-timeline"); + timeline.hidden = !this._login.guid; + const createdTime = { + actionId: "login-item-timeline-action-created", + time: this._login.timeCreated, + }; + const lastUpdatedTime = { + actionId: "login-item-timeline-action-updated", + time: this._login.timePasswordChanged, + }; + const lastUsedTime = { + actionId: "login-item-timeline-action-used", + time: this._login.timeLastUsed, + }; + timeline.history = + this._login.timeCreated == this._login.timePasswordChanged + ? [createdTime, lastUsedTime] + : [createdTime, lastUpdatedTime, lastUsedTime]; + } + + setBreaches(breachesByLoginGUID) { + this._internalSetMonitorData("_breachesMap", breachesByLoginGUID); + } + + updateBreaches(breachesByLoginGUID) { + this._internalUpdateMonitorData("_breachesMap", breachesByLoginGUID); + } + + setVulnerableLogins(vulnerableLoginsByLoginGUID) { + this._internalSetMonitorData( + "_vulnerableLoginsMap", + vulnerableLoginsByLoginGUID + ); + } + + updateVulnerableLogins(vulnerableLoginsByLoginGUID) { + this._internalUpdateMonitorData( + "_vulnerableLoginsMap", + vulnerableLoginsByLoginGUID + ); + } + + _internalSetMonitorData(internalMemberName, mapByLoginGUID) { + this[internalMemberName] = mapByLoginGUID; + this.render({ onlyUpdateErrorsAndAlerts: true }); + } + + _internalUpdateMonitorData(internalMemberName, mapByLoginGUID) { + if (!this[internalMemberName]) { + this[internalMemberName] = new Map(); + } + for (const [guid, data] of [...mapByLoginGUID]) { + if (data) { + this[internalMemberName].set(guid, data); + } else { + this[internalMemberName].delete(guid); + } + } + this._internalSetMonitorData(internalMemberName, this[internalMemberName]); + } + + showLoginItemError(error) { + this._error = error; + this.render(); + } + + async handleKeydown(e) { + // The below handleKeydown will be cleaned up when Bug 1848785 lands. + if (e.key === "Escape" && this.dataset.editing) { + this.handleCancelEvent(); + } else if (e.altKey && e.key === "Enter" && !this.dataset.editing) { + this.handleEditEvent(); + } else if (e.altKey && (e.key === "Backspace" || e.key === "Delete")) { + this.handleDeleteEvent(); + } + } + + async handlePasswordDisplayFocus(e) { + // TODO(Bug 1838494): Remove this if block + // This is a temporary fix until Bug 1750072 lands + const focusFromCheckbox = e && e.relatedTarget === this._revealCheckbox; + const isEditingMode = this.dataset.editing || this.dataset.isNewLogin; + if (focusFromCheckbox && isEditingMode) { + this._passwordInput.type = this._revealCheckbox.checked + ? "text" + : "password"; + return; + } + + this._revealCheckbox.checked = !!this.dataset.editing; + this._updatePasswordRevealState(); + } + + async addHTTPSPrefix(e) { + // TODO(Bug 1838494): Remove this if block + // This is a temporary fix until Bug 1750072 lands + const focusCheckboxNext = e && e.relatedTarget === this._revealCheckbox; + if (focusCheckboxNext) { + return; + } + + // Add https:// prefix if one was not provided. + let originValue = this._originInput.value.trim(); + if (!originValue) { + return; + } + + if (!originValue.match(/:\/\//)) { + this._originInput.value = "https://" + originValue; + } + } + + async handlePasswordDisplayBlur(e) { + // TODO(Bug 1838494): Remove this if block + // This is a temporary fix until Bug 1750072 lands + const focusCheckboxNext = e && e.relatedTarget === this._revealCheckbox; + if (focusCheckboxNext) { + return; + } + + this._revealCheckbox.checked = !!this.dataset.editing; + this._updatePasswordRevealState(); + + this.addHTTPSPrefix(); + } + + async handleEditPasswordInputBlur(e) { + // TODO(Bug 1838494): Remove this if block + // This is a temporary fix until Bug 1750072 lands + const focusCheckboxNext = e && e.relatedTarget === this._revealCheckbox; + if (focusCheckboxNext) { + return; + } + + this._revealCheckbox.checked = false; + this._updatePasswordRevealState(); + + this.addHTTPSPrefix(); + } + + async handleRevealPasswordClick() { + // TODO(Bug 1838494): Remove this if block + // This is a temporary fix until Bug 1750072 lands + if (this.dataset.editing || this.dataset.isNewLogin) { + this._passwordDisplayInput.replaceWith(this._passwordInput); + this._passwordInput.type = "text"; + this._passwordInput.focus(); + return; + } + + // We prompt for the primary password when entering edit mode already. + if (this._revealCheckbox.checked && !this.dataset.editing) { + let primaryPasswordAuth = await promptForPrimaryPassword( + "about-logins-reveal-password-os-auth-dialog-message" + ); + if (!primaryPasswordAuth) { + this._revealCheckbox.checked = false; + return; + } + } + this._updatePasswordRevealState(); + + let method = this._revealCheckbox.checked ? "show" : "hide"; + this._recordTelemetryEvent({ object: "password", method }); + } + + async handleCancelEvent(e) { + let wasExistingLogin = !!this._login.guid; + if (wasExistingLogin) { + if (this.hasPendingChanges()) { + this.showConfirmationDialog("discard-changes", () => { + this.setLogin(this._login); + }); + } else { + this.setLogin(this._login); + } + } else if (!this.hasPendingChanges()) { + window.dispatchEvent(new CustomEvent("AboutLoginsClearSelection")); + this._recordTelemetryEvent({ + object: "new_login", + method: "cancel", + }); + + this.setLogin(this._login, { skipFocusChange: true }); + this._toggleEditing(false); + this.render(); + } else { + this.showConfirmationDialog("discard-changes", () => { + window.dispatchEvent(new CustomEvent("AboutLoginsClearSelection")); + + this.setLogin({}, { skipFocusChange: true }); + this._toggleEditing(false); + this.render(); + }); + } + } + + async handleCopyPasswordClick({ currentTarget }) { + let primaryPasswordAuth = await promptForPrimaryPassword( + "about-logins-copy-password-os-auth-dialog-message" + ); + if (!primaryPasswordAuth) { + return; + } + currentTarget.dataset.copied = true; + currentTarget.copiedText = true; + currentTarget.disabled = true; + let propertyToCopy = this._login.password; + document.dispatchEvent( + new CustomEvent("AboutLoginsCopyLoginDetail", { + bubbles: true, + detail: propertyToCopy, + }) + ); + // If there is no username, this must be triggered by the password button, + // don't enable otherCopyButton (username copy button) in this case. + if (this._login.username) { + this._copyUsernameButton.copiedText = false; + this._copyUsernameButton.disabled = false; + delete this._copyUsernameButton.dataset.copied; + } + clearTimeout(this._copyUsernameTimeoutId); + clearTimeout(this._copyPasswordTimeoutId); + let timeoutId = setTimeout(() => { + currentTarget.disabled = false; + currentTarget.copiedText = false; + delete currentTarget.dataset.copied; + }, LoginItem.COPY_BUTTON_RESET_TIMEOUT); + this._copyPasswordTimeoutId = timeoutId; + this._recordTelemetryEvent({ + object: "password", + method: "copy", + }); + } + + async handleCopyUsernameClick({ currentTarget }) { + currentTarget.dataset.copied = true; + currentTarget.copiedText = true; + currentTarget.disabled = true; + let propertyToCopy = this._login.username; + document.dispatchEvent( + new CustomEvent("AboutLoginsCopyLoginDetail", { + bubbles: true, + detail: propertyToCopy, + }) + ); + // If there is no username, this must be triggered by the password button, + // don't enable otherCopyButton (username copy button) in this case. + if (this._login.username) { + this._copyPasswordButton.copiedText = false; + this._copyPasswordButton.disabled = false; + delete this._copyPasswordButton.dataset.copied; + } + clearTimeout(this._copyUsernameTimeoutId); + clearTimeout(this._copyPasswordTimeoutId); + let timeoutId = setTimeout(() => { + currentTarget.disabled = false; + currentTarget.copiedText = false; + delete currentTarget.dataset.copied; + }, LoginItem.COPY_BUTTON_RESET_TIMEOUT); + this._copyUsernameTimeoutId = timeoutId; + this._recordTelemetryEvent({ + object: "username", + method: "copy", + }); + } + + async handleDeleteEvent() { + this.showConfirmationDialog("delete", () => { + document.dispatchEvent( + new CustomEvent("AboutLoginsDeleteLogin", { + bubbles: true, + detail: this._login, + }) + ); + }); + } + + async handleEditEvent() { + let primaryPasswordAuth = await promptForPrimaryPassword( + "about-logins-edit-login-os-auth-dialog-message2" + ); + if (!primaryPasswordAuth) { + return; + } + + this._toggleEditing(); + this.render(); + + this._recordTelemetryEvent({ + object: "existing_login", + method: "edit", + }); + } + + async handleAlertLearnMoreClick({ currentTarget }) { + if (currentTarget.closest(".vulnerable-alert")) { + this._recordTelemetryEvent({ + object: "existing_login", + method: "learn_more_vuln", + }); + } + } + + async handleOriginInputClick() { + this._handleOriginClick(); + } + + async handleDuplicateErrorGuid({ currentTarget }) { + let existingDuplicateLogin = { + guid: currentTarget.dataset.errorGuid, + }; + window.dispatchEvent( + new CustomEvent("AboutLoginsLoginSelected", { + detail: existingDuplicateLogin, + cancelable: true, + }) + ); + } + + async handleInputSubmit(event) { + // Prevent page navigation form submit behavior. + event.preventDefault(); + if (!this._isFormValid({ reportErrors: true })) { + return; + } + if (!this.hasPendingChanges()) { + this._toggleEditing(false); + this.render(); + return; + } + let loginUpdates = this._loginFromForm(); + if (this._login.guid) { + loginUpdates.guid = this._login.guid; + document.dispatchEvent( + new CustomEvent("AboutLoginsUpdateLogin", { + bubbles: true, + detail: loginUpdates, + }) + ); + + this._recordTelemetryEvent({ + object: "existing_login", + method: "save", + }); + this._toggleEditing(false); + this.render(); + } else { + document.dispatchEvent( + new CustomEvent("AboutLoginsCreateLogin", { + bubbles: true, + detail: loginUpdates, + }) + ); + + this._recordTelemetryEvent({ object: "new_login", method: "save" }); + } + } + + async handleInputAuxclick({ button }) { + if (button == 1) { + this._handleOriginClick(); + } + } + + async handleInputMousedown(event) { + // No AutoScroll when middle clicking on origin input. + if (event.currentTarget == this._originInput && event.button == 1) { + event.preventDefault(); + } + } + + async handleAboutLoginsInitial({ detail }) { + this.setLogin(detail, { skipFocusChange: true }); + } + + async handleAboutLoginsLoginSelected(event) { + this.#confirmPendingChangesOnEvent(event, event.detail); + } + + async handleAboutLoginsShowBlankLogin(event) { + this.#confirmPendingChangesOnEvent(event, {}); + } + + async handleAboutLoginsRemaskPassword() { + if (this._revealCheckbox.checked && !this.dataset.editing) { + this._revealCheckbox.checked = false; + } + this._updatePasswordRevealState(); + let method = this._revealCheckbox.checked ? "show" : "hide"; + this._recordTelemetryEvent({ object: "password", method }); + } + + /** + * Helper to show the "Discard changes" confirmation dialog and delay the + * received event after confirmation. + * @param {object} event The event to be delayed. + * @param {object} login The login to be shown on confirmation. + */ + #confirmPendingChangesOnEvent(event, login) { + if (this.hasPendingChanges()) { + event.preventDefault(); + this.showConfirmationDialog("discard-changes", () => { + // Clear any pending changes + this.setLogin(login); + + window.dispatchEvent( + new CustomEvent(event.type, { + detail: login, + cancelable: false, + }) + ); + }); + } else { + this.setLogin(login, { skipFocusChange: true }); + } + } + + /** + * Shows a confirmation dialog. + * @param {string} type The type of confirmation dialog to display. + * @param {boolean} onConfirm Optional, the function to execute when the confirm button is clicked. + */ + showConfirmationDialog(type, onConfirm = () => {}) { + const dialog = document.querySelector("confirmation-dialog"); + let options; + switch (type) { + case "delete": { + options = { + title: "about-logins-confirm-delete-dialog-title", + message: "about-logins-confirm-delete-dialog-message", + confirmButtonLabel: + "about-logins-confirm-remove-dialog-confirm-button", + }; + break; + } + case "discard-changes": { + options = { + title: "confirm-discard-changes-dialog-title", + message: "confirm-discard-changes-dialog-message", + confirmButtonLabel: "confirm-discard-changes-dialog-confirm-button", + }; + break; + } + } + let wasExistingLogin = !!this._login.guid; + let method = type == "delete" ? "delete" : "cancel"; + let dialogPromise = dialog.show(options); + dialogPromise.then( + () => { + try { + onConfirm(); + } catch (ex) {} + this._recordTelemetryEvent({ + object: wasExistingLogin ? "existing_login" : "new_login", + method, + }); + }, + () => {} + ); + return dialogPromise; + } + + hasPendingChanges() { + let valuesChanged = !window.AboutLoginsUtils.doLoginsMatch( + Object.assign({ username: "", password: "", origin: "" }, this._login), + this._loginFromForm() + ); + + return this.dataset.editing && valuesChanged; + } + + resetForm() { + // If the password input (which uses HTML form validation) wasn't connected, + // append it to the form so it gets included in the reset, specifically for + // .value and the dirty state for validation. + let wasConnected = this._passwordInput.isConnected; + if (!wasConnected) { + this._revealCheckbox.insertAdjacentElement( + "beforebegin", + this._passwordInput + ); + } + + this._form.reset(); + if (!wasConnected) { + this._passwordInput.remove(); + } + } + + /** + * @param {login} login The login that should be displayed. The login object is + * a plain JS object representation of nsILoginInfo/nsILoginMetaInfo. + * @param {boolean} skipFocusChange Optional, if present and set to true, the Edit button of the + * login will not get focus automatically. This is used to prevent + * stealing focus from the search filter upon page load. + */ + setLogin(login, { skipFocusChange } = {}) { + this._login = login; + this._error = null; + + this.resetForm(); + + if (login.guid) { + delete this.dataset.isNewLogin; + } else { + this.dataset.isNewLogin = true; + } + document.documentElement.classList.toggle("login-selected", login.guid); + this._toggleEditing(!login.guid); + + this._revealCheckbox.checked = false; + + clearTimeout(this._copyUsernameTimeoutId); + clearTimeout(this._copyPasswordTimeoutId); + for (let currentTarget of [ + this._copyUsernameButton, + this._copyPasswordButton, + ]) { + currentTarget.disabled = false; + this._copyPasswordButton.copiedText = false; + this._copyUsernameButton.copiedText = false; + delete currentTarget.dataset.copied; + } + + if (!skipFocusChange) { + this._editButton.focus(); + } + this.render(); + } + + /** + * Updates the view if the login argument matches the login currently + * displayed. + * + * @param {login} login The login that was added to storage. The login object is + * a plain JS object representation of nsILoginInfo/nsILoginMetaInfo. + */ + loginAdded(login) { + if ( + this._login.guid || + !window.AboutLoginsUtils.doLoginsMatch(login, this._loginFromForm()) + ) { + return; + } + + this.setLogin(login); + this.dispatchEvent( + new CustomEvent("AboutLoginsLoginSelected", { + bubbles: true, + composed: true, + detail: login, + }) + ); + } + + /** + * Updates the view if the login argument matches the login currently + * displayed. + * + * @param {login} login The login that was modified in storage. The login object is + * a plain JS object representation of nsILoginInfo/nsILoginMetaInfo. + */ + loginModified(login) { + if (this._login.guid != login.guid) { + return; + } + + let valuesChanged = + this.dataset.editing && + !window.AboutLoginsUtils.doLoginsMatch(login, this._loginFromForm()); + if (valuesChanged) { + this.showConfirmationDialog("discard-changes", () => { + this.setLogin(login); + }); + } else { + this.setLogin(login); + } + } + + /** + * Clears the displayed login if the argument matches the currently + * displayed login. + * + * @param {login} login The login that was removed from storage. The login object is + * a plain JS object representation of nsILoginInfo/nsILoginMetaInfo. + */ + loginRemoved(login) { + if (login.guid != this._login.guid) { + return; + } + + this.setLogin({}, { skipFocusChange: true }); + this._toggleEditing(false); + } + + _handleOriginClick() { + this._recordTelemetryEvent({ + object: "existing_login", + method: "open_site", + }); + } + + /** + * Checks that the edit/new-login form has valid values present for their + * respective required fields. + * + * @param {boolean} reportErrors If true, validation errors will be reported + * to the user. + */ + _isFormValid({ reportErrors } = {}) { + let fields = [this._passwordInput]; + if (this.dataset.isNewLogin) { + fields.push(this._originInput); + } + let valid = true; + // Check validity on all required fields so each field will get :invalid styling + // if applicable. + for (let field of fields) { + if (reportErrors) { + valid &= field.reportValidity(); + } else { + valid &= field.checkValidity(); + } + } + return valid; + } + + _loginFromForm() { + return Object.assign({}, this._login, { + username: this._usernameInput.value.trim(), + password: this._passwordInput.value, + origin: + window.AboutLoginsUtils.getLoginOrigin(this._originInput.value) || "", + }); + } + + _recordTelemetryEvent(eventObject) { + // Breach alerts have higher priority than vulnerable logins, the + // following conditionals must reflect this priority. + const extra = eventObject.hasOwnProperty("extra") ? eventObject.extra : {}; + if (this._breachesMap && this._breachesMap.has(this._login.guid)) { + Object.assign(extra, { breached: "true" }); + eventObject.extra = extra; + } else if ( + this._vulnerableLoginsMap && + this._vulnerableLoginsMap.has(this._login.guid) + ) { + Object.assign(extra, { vulnerable: "true" }); + eventObject.extra = extra; + } + recordTelemetryEvent(eventObject); + } + + /** + * Toggles the login-item view from editing to non-editing mode. + * + * @param {boolean} force When true puts the form in 'edit' mode, otherwise + * puts the form in read-only mode. + */ + _toggleEditing(force) { + let shouldEdit = force !== undefined ? force : !this.dataset.editing; + + if (!shouldEdit) { + delete this.dataset.isNewLogin; + } + + // Reset cursor to the start of the input for long text names. + this._usernameInput.scrollLeft = 0; + + if (shouldEdit) { + this._passwordInput.style.removeProperty("width"); + } else { + // Need to set a shorter width than -moz-available so the reveal checkbox + // will still appear next to the password. + this._passwordInput.style.width = + (this._login.password || "").length + "ch"; + } + + this._deleteButton.disabled = this.dataset.isNewLogin; + this._editButton.disabled = shouldEdit; + let inputTabIndex = shouldEdit ? 0 : -1; + this._originInput.readOnly = !this.dataset.isNewLogin; + this._originInput.tabIndex = inputTabIndex; + this._usernameInput.readOnly = !shouldEdit; + this._usernameInput.tabIndex = inputTabIndex; + this._passwordInput.readOnly = !shouldEdit; + this._passwordInput.tabIndex = inputTabIndex; + if (shouldEdit) { + this.dataset.editing = true; + this._usernameInput.focus(); + this._usernameInput.select(); + } else { + delete this.dataset.editing; + // Only reset the reveal checkbox when exiting 'edit' mode + this._revealCheckbox.checked = false; + } + } + + _updatePasswordRevealState() { + if ( + window.AboutLoginsUtils && + window.AboutLoginsUtils.passwordRevealVisible === false + ) { + this._revealCheckbox.hidden = true; + } + + let { checked } = this._revealCheckbox; + let inputType = checked ? "text" : "password"; + this._passwordInput.type = inputType; + + if (this.dataset.editing) { + this._passwordDisplayInput.removeAttribute("tabindex"); + this._revealCheckbox.hidden = true; + } else { + this._passwordDisplayInput.setAttribute("tabindex", -1); + this._revealCheckbox.hidden = false; + } + + // Swap which is in the document depending on whether we need the + // real .value (which means that the primary password was already entered, + // if applicable) + if (checked || this.dataset.isNewLogin) { + this._passwordDisplayInput.replaceWith(this._passwordInput); + + // Focus the input if it hasn't been already. + if (this.dataset.editing && inputType === "text") { + this._passwordInput.focus(); + } + } else { + this._passwordInput.replaceWith(this._passwordDisplayInput); + } + } + + _updateOriginDisplayState() { + // Switches between the origin input and anchor tag depending + // if a new login is being created. + if (this.dataset.isNewLogin) { + this._originDisplayInput.replaceWith(this._originInput); + this._originInput.focus(); + } else { + this._originInput.replaceWith(this._originDisplayInput); + } + } + + // TODO(Bug 1838182): This is glue code to make lit component work + // Once login-item itself is a lit component, this method is going to be deleted + // in favour of updating the props themselves. + // NOTE: Adding this method here instead of login-alert because this file will be + // refactored soon. + #updateBreachAlert(hostname, date) { + this._breachAlert.hostname = hostname; + this._breachAlert.date = date; + } + + #updateVulnerablePasswordAlert(hostname) { + this._vulnerableAlert.hostname = hostname; + } + + #updatePasswordMessage() { + this._passwordWarning.isNewLogin = this.dataset.isNewLogin; + this._passwordWarning.webTitle = this._login.title; + } +} +customElements.define("login-item", LoginItem); diff --git a/browser/components/aboutlogins/content/components/login-list-item.mjs b/browser/components/aboutlogins/content/components/login-list-item.mjs new file mode 100644 index 0000000000..32c8dec98f --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-list-item.mjs @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { LoginListItem, NewListItem } from "./login-list-lit-item.mjs"; +/** + * This file will be removed once login-list is made into a lit-component since + * whatever the update function does, lit does internally automatically. + */ +export default class LoginListItemFactory { + static create(login) { + if (!login.guid) { + const newListItem = new NewListItem(); + newListItem.classList.add("list-item"); + return newListItem; + } + const loginListItem = new LoginListItem(); + loginListItem.classList.add("list-item"); + LoginListItemFactory.update(loginListItem, login); + return loginListItem; + } + + static update(listItem, login) { + listItem.title = login.title; + listItem.username = login.username; + listItem.favicon = `page-icon:${login.origin}`; + + // Prepend the ID with a string since IDs must not begin with a number. + if (!listItem.id) { + listItem.id = "lli-" + login.guid; + listItem.dataset.guid = login.guid; + } + } +} diff --git a/browser/components/aboutlogins/content/components/login-list-item.stories.mjs b/browser/components/aboutlogins/content/components/login-list-item.stories.mjs new file mode 100644 index 0000000000..994f32922c --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-list-item.stories.mjs @@ -0,0 +1,62 @@ +/* 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 "./login-list-lit-item.mjs"; + +export default { + title: "Domain-specific UI Widgets/Credential Management/Login List Item", + component: "login-list-item", +}; + +window.MozXULElement.insertFTLIfNeeded("browser/aboutLogins.ftl"); + +export const NewLoginListItem = ({ selected }) => { + return html` `; +}; + +NewLoginListItem.argTypes = { + selected: { + options: [true, false], + control: { type: "radio" }, + defaultValue: false, + }, +}; + +export const LoginListItem = ({ + title, + username, + notificationIcon, + selected, +}) => { + return html` + + + `; +}; + +LoginListItem.argTypes = { + notificationIcon: { + options: ["default", "breached", "vulnerable"], + control: { type: "radio" }, + defaultValue: "default", + }, + selected: { + options: [true, false], + control: { type: "radio" }, + defaultValue: false, + }, +}; + +LoginListItem.args = { + title: "https://www.example.com", + username: "test-username", +}; diff --git a/browser/components/aboutlogins/content/components/login-list-lit-item.css b/browser/components/aboutlogins/content/components/login-list-lit-item.css new file mode 100644 index 0000000000..69b6d72b0c --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-list-lit-item.css @@ -0,0 +1,81 @@ +/* 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/. */ + +.list-item { + display: flex; + align-items: center; + padding-block: 10px; + padding-inline: 12px 18px; + border-inline-start: 4px solid transparent; + user-select: none; +} + +.list-item:not(.selected):hover { + background-color: var(--in-content-button-background-hover); + color: var(--in-content-button-text-color-hover); +} + +.list-item:not(.selected):hover:active { + background-color: var(--in-content-button-background-active); + color: var(--in-content-button-text-color-active); +} + +.list-item.keyboard-selected { + border-inline-start-color: var(--in-content-border-color); + background-color: var(--in-content-button-background-hover); +} + +.list-item.selected { + border-inline-start-color: var(--in-content-accent-color); + background-color: var(--in-content-page-background); +} + +.list-item.selected .title { + font-weight: 600; +} + +.labels { + flex-grow: 1; + overflow: hidden; + min-height: 40px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.title, +.subtitle { + display: block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.icon { + height: 16px; + width: 16px; + margin-inline-end: 12px; + -moz-context-properties: fill, fill-opacity; + fill: currentColor; + fill-opacity: 0.8; +} + +.subtitle { + font-size: 0.85em; + color: var(--text-color-deemphasized); +} + +.alert-icon { + min-width: 16px; + width: 16px; + margin-inline-start: 12px; + -moz-context-properties: fill, fill-opacity; + fill: currentColor; + fill-opacity: 0.75; + + :host([notificationIcon="breached"]) & { + fill: var(--red-60); + fill-opacity: 1; + } +} diff --git a/browser/components/aboutlogins/content/components/login-list-lit-item.mjs b/browser/components/aboutlogins/content/components/login-list-lit-item.mjs new file mode 100644 index 0000000000..9bd9632971 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-list-lit-item.mjs @@ -0,0 +1,169 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + html, + classMap, + choose, + when, +} from "chrome://global/content/vendor/lit.all.mjs"; + +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +export class ListItem extends MozLitElement { + static get properties() { + return { + icon: { type: String }, + selected: { type: Boolean }, + }; + } + + constructor() { + super(); + this.icon = ""; + this.selected = false; + } + + render() { + const classes = { selected: this.selected, "list-item": true }; + return html` +
  • + + + +
  • `; + } +} + +export class NewListItem extends MozLitElement { + static properties = { + icon: { type: String }, + selected: { type: Boolean }, + }; + + constructor() { + super(); + this.id = "new-login-list-item"; + this.selected = false; + this.icon = "page-icon:undefined"; + } + + render() { + return html` + + +
    + +
    +
    + `; + } +} + +export class LoginListItem extends MozLitElement { + static get properties() { + return { + favicon: { type: String }, + title: { type: String, reflect: true }, + username: { type: String, reflect: true }, + notificationIcon: { type: String, reflect: true }, + selected: { type: Boolean }, + }; + } + + constructor() { + super(); + this.favicon = ""; + this.title = ""; + this.username = ""; + this.notificationIcon = ""; + this.selected = false; + } + render() { + switch (this.notificationIcon) { + case "breached": + this.classList.add("breached"); + break; + case "vulnerable": + this.classList.add("vulnerable"); + break; + default: + this.classList.remove("breached"); + this.classList.remove("vulnerable"); + break; + } + + return html` + + +
    + ${this.title} + ${when( + this.username, + () => html` + ${this.username} + `, + () => html`` + )} +
    +
    + ${choose( + this.notificationIcon, + [ + [ + "breached", + () => + html``, + ], + [ + "vulnerable", + () => + html``, + ], + ], + () => html`` + )} +
    +
    + `; + } +} + +customElements.define("list-item", ListItem); +customElements.define("new-list-item", NewListItem); +customElements.define("login-list-item", LoginListItem); diff --git a/browser/components/aboutlogins/content/components/login-list-section.mjs b/browser/components/aboutlogins/content/components/login-list-section.mjs new file mode 100644 index 0000000000..5495f55e28 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-list-section.mjs @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export default class LoginListHeaderFactory { + static ID_PREFIX = "id-"; + + static create(header) { + let template = document.querySelector("#login-list-section-template"); + let fragment = template.content.cloneNode(true); + let sectionItem = fragment.firstElementChild; + + this.update(sectionItem, header); + + return sectionItem; + } + + static update(headerItem, header) { + let headerElement = headerItem.querySelector(".login-list-header"); + if (header) { + if (header.startsWith(this.ID_PREFIX)) { + document.l10n.setAttributes( + headerElement, + header.substring(this.ID_PREFIX.length) + ); + } else { + headerElement.textContent = header; + } + headerElement.hidden = false; + } else { + headerElement.hidden = true; + } + } +} diff --git a/browser/components/aboutlogins/content/components/login-list.css b/browser/components/aboutlogins/content/components/login-list.css new file mode 100644 index 0000000000..bc8e72a3cd --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-list.css @@ -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/. */ + +:host { + border-inline-end: 1px solid var(--in-content-border-color); + background-color: var(--in-content-box-background); + display: flex; + flex-direction: column; + overflow: auto; +} + +.meta { + display: flex; + align-items: center; + padding: 0 16px 16px; + border-bottom: 1px solid var(--in-content-border-color); + background-color: var(--in-content-box-background); +} + +.meta > label > span { + margin-inline-end: 2px; +} + +#login-sort { + --logical-padding: 0px; + margin: 0; + background-color: transparent; + color: var(--in-content-text-color) !important; +} + +#login-sort:hover:not([disabled]) { + background-color: var(--in-content-button-background); +} + +#login-sort > option { + font-weight: var(--font-weight-default); +} + +.count { + flex-grow: 1; + text-align: end; + margin-inline-start: 18px; +} + +.container { + display: contents; +} + +.listHeader { + display: flex; + justify-content: center; + align-content: center; + gap: 16px; + padding: 16px; +} + +:host(.no-logins) .empty-search-message, +:host(:not(.empty-search)) .empty-search-message, +:host(.empty-search:not(.create-login-selected)) ol, +:host(.no-logins:not(.create-login-selected)) ol, +:host(:not(.no-logins)) .intro, +:host(.create-login-selected) .intro, +:host(.create-login-selected) .empty-search-message { + display: none; +} + +:host(:not(.initialized)) .count, +:host(:not(.initialized)) .empty-search-message { + visibility: hidden; +} + +.empty-search-message, +.intro { + text-align: center; + padding: 1em; + max-width: 50ch; /* This should be kept in sync with login-list-item username and title max-width */ + flex-grow: 1; + border-bottom: 1px solid var(--in-content-border-color); +} + +.empty-search-message span, +.intro span { + font-size: 0.85em; +} + +ol { + outline-offset: var(--in-content-focus-outline-inset); + margin-block: 0; + padding-inline-start: 0; + overflow: hidden auto; + flex-grow: 1; + scroll-padding-top: 24px; /* there is the section header that is sticky to the top */ +} + +.login-list-item { + display: flex; + align-items: center; + padding-block: 10px; + padding-inline: 12px 18px; + border-inline-start: 4px solid transparent; + user-select: none; +} + +.login-list-header { + display: block; + position: sticky; + top: 0; + font-size: .85em; + font-weight: var(--font-weight-bold); + padding: 4px 16px; + border-bottom: 1px solid var(--in-content-border-color); + background-color: var(--in-content-box-background); + margin-block-start: 2px; + margin-inline: 2px; +} + +.login-list-item:not(.selected):hover { + background-color: var(--in-content-button-background-hover); + color: var(--in-content-button-text-color-hover); +} + +.login-list-item:not(.selected):hover:active { + background-color: var(--in-content-button-background-active); + color: var(--in-content-button-text-color-active); +} + +.login-list-item.keyboard-selected { + border-inline-start-color: var(--in-content-border-color); + background-color: var(--in-content-button-background-hover); +} + +.login-list-item.selected { + border-inline-start-color: var(--in-content-accent-color); + background-color: var(--in-content-page-background); +} + +.login-list-item.selected .title { + font-weight: var(--font-weight-bold); +} + +.labels { + flex-grow: 1; + overflow: hidden; + min-height: 40px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.favicon { + height: 16px; + width: 16px; + margin-inline-end: 12px; + -moz-context-properties: fill, fill-opacity; + fill: currentColor; + fill-opacity: 0.8; +} + +.username { + font-size: 0.85em; + color: var(--text-color-deemphasized); +} diff --git a/browser/components/aboutlogins/content/components/login-list.mjs b/browser/components/aboutlogins/content/components/login-list.mjs new file mode 100644 index 0000000000..80afdbb58d --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-list.mjs @@ -0,0 +1,923 @@ +/* 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 LoginListItemFactory from "./login-list-item.mjs"; +import LoginListSectionFactory from "./login-list-section.mjs"; +import { recordTelemetryEvent } from "../aboutLoginsUtils.mjs"; + +const collator = new Intl.Collator(); +const monthFormatter = new Intl.DateTimeFormat(undefined, { month: "long" }); +const yearMonthFormatter = new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "long", +}); +const dayDuration = 24 * 60 * 60_000; +const sortFnOptions = { + name: (a, b) => collator.compare(a.title, b.title), + "name-reverse": (a, b) => collator.compare(b.title, a.title), + username: (a, b) => collator.compare(a.username, b.username), + "username-reverse": (a, b) => collator.compare(b.username, a.username), + "last-used": (a, b) => a.timeLastUsed < b.timeLastUsed, + "last-changed": (a, b) => a.timePasswordChanged < b.timePasswordChanged, + alerts: (a, b, breachesByLoginGUID, vulnerableLoginsByLoginGUID) => { + const aIsBreached = breachesByLoginGUID && breachesByLoginGUID.has(a.guid); + const bIsBreached = breachesByLoginGUID && breachesByLoginGUID.has(b.guid); + const aIsVulnerable = + vulnerableLoginsByLoginGUID && vulnerableLoginsByLoginGUID.has(a.guid); + const bIsVulnerable = + vulnerableLoginsByLoginGUID && vulnerableLoginsByLoginGUID.has(b.guid); + + if ((aIsBreached && !bIsBreached) || (aIsVulnerable && !bIsVulnerable)) { + return -1; + } + + if ((!aIsBreached && bIsBreached) || (!aIsVulnerable && bIsVulnerable)) { + return 1; + } + return sortFnOptions.name(a, b); + }, +}; + +const headersFnOptions = { + // TODO: name should use the ICU API, see Bug 1592834 + // name: l => + // l.title.length && letterRegExp.test(l.title[0]) + // ? l.title[0].toUpperCase() + // : "#", + // "name-reverse": l => headersFnOptions.name(l), + name: () => "", + "name-reverse": () => "", + username: () => "", + "username-reverse": () => "", + "last-used": l => headerFromDate(l.timeLastUsed), + "last-changed": l => headerFromDate(l.timePasswordChanged), + alerts: (l, breachesByLoginGUID, vulnerableLoginsByLoginGUID) => { + const isBreached = breachesByLoginGUID && breachesByLoginGUID.has(l.guid); + const isVulnerable = + vulnerableLoginsByLoginGUID && vulnerableLoginsByLoginGUID.has(l.guid); + if (isBreached) { + return ( + LoginListSectionFactory.ID_PREFIX + "about-logins-list-section-breach" + ); + } else if (isVulnerable) { + return ( + LoginListSectionFactory.ID_PREFIX + + "about-logins-list-section-vulnerable" + ); + } + return ( + LoginListSectionFactory.ID_PREFIX + "about-logins-list-section-nothing" + ); + }, +}; + +function headerFromDate(timestamp) { + let now = new Date(); + now.setHours(0, 0, 0, 0); // reset to start of day + let date = new Date(timestamp); + + if (now < date) { + return ( + LoginListSectionFactory.ID_PREFIX + "about-logins-list-section-today" + ); + } else if (now - dayDuration < date) { + return ( + LoginListSectionFactory.ID_PREFIX + "about-logins-list-section-yesterday" + ); + } else if (now - 7 * dayDuration < date) { + return LoginListSectionFactory.ID_PREFIX + "about-logins-list-section-week"; + } else if (now.getFullYear() == date.getFullYear()) { + return monthFormatter.format(date); + } else if (now.getFullYear() - 1 == date.getFullYear()) { + return yearMonthFormatter.format(date); + } + return String(date.getFullYear()); +} + +export default class LoginList extends HTMLElement { + // An array of login GUIDs, stored in sorted order. + _loginGuidsSortedOrder = []; + // A map of login GUID -> {login, listItem}. + _logins = {}; + // A map of section header -> sectionItem + _sections = {}; + _filter = ""; + _selectedGuid = null; + _blankLoginListItem = LoginListItemFactory.create({}); + + constructor() { + super(); + this._blankLoginListItem.hidden = true; + } + + connectedCallback() { + if (this.shadowRoot) { + return; + } + let loginListTemplate = document.querySelector("#login-list-template"); + let shadowRoot = this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + shadowRoot.appendChild(loginListTemplate.content.cloneNode(true)); + + this._count = shadowRoot.querySelector(".count"); + this._createLoginButton = shadowRoot.querySelector("create-login-button"); + this._list = shadowRoot.querySelector("ol"); + this._list.appendChild(this._blankLoginListItem); + this._sortSelect = shadowRoot.querySelector("#login-sort"); + + this.render(); + + this.shadowRoot + .getElementById("login-sort") + .addEventListener("change", this); + window.addEventListener("AboutLoginsClearSelection", this); + window.addEventListener("AboutLoginsFilterLogins", this); + window.addEventListener("AboutLoginsInitialLoginSelected", this); + window.addEventListener("AboutLoginsLoginSelected", this); + window.addEventListener("AboutLoginsShowBlankLogin", this); + this._list.addEventListener("click", this); + this.addEventListener("keydown", this); + this.addEventListener("keyup", this); + + // TODO: Using the addEventListener to listen for clicks and pass the event handler due to a CSP error. + // This will be fixed as login-list itself is converted into a lit component. We will then be able to use the onclick + // prop of login-command-button as seen in the example below (functionality works and passes tests). + // this._createLoginButton.onClick = e => this.handleCreateNewLogin(e); + + this._createLoginButton.addEventListener("click", e => + this.handleCreateNewLogin(e) + ); + } + + get #activeDescendant() { + const activeDescendantId = this._list.getAttribute("aria-activedescendant"); + let activeDescendant = + activeDescendantId && this.shadowRoot.getElementById(activeDescendantId); + return activeDescendant; + } + + selectLoginByDomainOrGuid(searchParam) { + this._preselectLogin = searchParam; + } + + render() { + let visibleLoginGuids = this._applyFilter(); + this.#updateVisibleLoginCount( + visibleLoginGuids.size, + this._loginGuidsSortedOrder.length + ); + this.classList.toggle("empty-search", !visibleLoginGuids.size); + document.documentElement.classList.toggle( + "empty-search", + this._filter && !visibleLoginGuids.size + ); + this._sortSelect.disabled = !visibleLoginGuids.size; + + // Add all of the logins that are not in the DOM yet. + let fragment = document.createDocumentFragment(); + for (let guid of this._loginGuidsSortedOrder) { + if (this._logins[guid].listItem) { + continue; + } + let login = this._logins[guid].login; + let listItem = LoginListItemFactory.create(login); + this._logins[login.guid] = Object.assign(this._logins[login.guid], { + listItem, + }); + fragment.appendChild(listItem); + } + this._list.appendChild(fragment); + + // Show, hide, and update state of the list items per the applied search filter. + for (let guid of this._loginGuidsSortedOrder) { + let { listItem } = this._logins[guid]; + + if (guid == this._selectedGuid) { + this._setListItemAsSelected(listItem); + } + + if ( + !!this._breachesByLoginGUID && + this._breachesByLoginGUID.has(listItem.dataset.guid) + ) { + listItem.notificationIcon = "breached"; + } else if ( + !!this._vulnerableLoginsByLoginGUID && + this._vulnerableLoginsByLoginGUID.has(listItem.dataset.guid) && + listItem.notificationIcon !== "breached" + ) { + listItem.notificationIcon = "vulnerable"; + } else { + listItem.notificationIcon = ""; + } + listItem.hidden = !visibleLoginGuids.has(listItem.dataset.guid); + } + + let sectionsKey = Object.keys(this._sections); + for (let sectionKey of sectionsKey) { + this._sections[sectionKey]._inUse = false; + } + + if (this._loginGuidsSortedOrder.length) { + let section = null; + let currentHeader = null; + // Re-arrange the login-list-items according to their sort and + // create / re-arrange sections + for (let i = this._loginGuidsSortedOrder.length - 1; i >= 0; i--) { + let guid = this._loginGuidsSortedOrder[i]; + let { listItem, _header } = this._logins[guid]; + + if (!listItem.hidden) { + if (currentHeader != _header) { + section = this.renderSectionHeader((currentHeader = _header)); + } + + section.insertBefore( + listItem, + section.firstElementChild.nextElementSibling + ); + } + } + } + + for (let sectionKey of sectionsKey) { + let section = this._sections[sectionKey]; + if (section._inUse) { + continue; + } + + section.hidden = true; + } + + let activeDescendant = this.#activeDescendant; + if (!activeDescendant || activeDescendant.hidden) { + let visibleListItem = this._list.querySelector( + "login-list-item:not([hidden])" + ); + if (visibleListItem) { + this._list.setAttribute("aria-activedescendant", visibleListItem.id); + } + } + + if ( + this._sortSelect.namedItem("alerts").hidden && + ((this._breachesByLoginGUID && + this._loginGuidsSortedOrder.some(loginGuid => + this._breachesByLoginGUID.has(loginGuid) + )) || + (this._vulnerableLoginsByLoginGUID && + this._loginGuidsSortedOrder.some(loginGuid => + this._vulnerableLoginsByLoginGUID.has(loginGuid) + ))) + ) { + // Make available the "alerts" option but don't change the + // selected sort so the user's current task isn't interrupted. + this._sortSelect.namedItem("alerts").hidden = false; + } + } + + renderSectionHeader(header) { + let section = this._sections[header]; + if (!section) { + section = this._sections[header] = LoginListSectionFactory.create(header); + } + + this._list.insertBefore( + section, + this._blankLoginListItem.nextElementSibling + ); + + section._inUse = true; + section.hidden = false; + return section; + } + + handleCreateNewLogin() { + window.dispatchEvent( + new CustomEvent("AboutLoginsShowBlankLogin", { + cancelable: true, + }) + ); + recordTelemetryEvent({ object: "new_login", method: "new" }); + } + + handleEvent(event) { + switch (event.type) { + case "click": { + let listItem; + if (event.originalTarget.tagName === "LOGIN-LIST-ITEM") { + listItem = event.originalTarget; + } else { + listItem = event.originalTarget + ? event.originalTarget.getRootNode().host + : null; + } + if (!listItem || !listItem.dataset.guid) { + return; + } + + let { login } = this._logins[listItem.dataset.guid]; + this.dispatchEvent( + new CustomEvent("AboutLoginsLoginSelected", { + bubbles: true, + composed: true, + cancelable: true, // allow calling preventDefault() on event + detail: login, + }) + ); + + let extra = {}; + if (listItem.notificationIcon === "breached") { + extra = { breached: "true" }; + } else if (listItem.notificationIcon === "vulnerable") { + extra = { vulnerable: "true" }; + } + recordTelemetryEvent({ + object: "existing_login", + method: "select", + extra, + }); + break; + } + case "change": { + this._applyHeaders(); + this._applySortAndScrollToTop(); + const extra = { sort_key: this._sortSelect.value }; + recordTelemetryEvent({ object: "list", method: "sort", extra }); + document.dispatchEvent( + new CustomEvent("AboutLoginsSortChanged", { + bubbles: true, + detail: this._sortSelect.value, + }) + ); + break; + } + case "AboutLoginsClearSelection": { + if (!this._loginGuidsSortedOrder.length) { + this._createLoginButton.disabled = false; + this.classList.remove("create-login-selected"); + return; + } + + let firstVisibleListItem = this._list.querySelector( + "login-list-item[data-guid]:not([hidden])" + ); + let newlySelectedLogin; + if (firstVisibleListItem) { + newlySelectedLogin = + this._logins[firstVisibleListItem.dataset.guid].login; + } else { + // Clear the filter if all items have been filtered out. + this.classList.remove("create-login-selected"); + this._createLoginButton.disabled = false; + window.dispatchEvent( + new CustomEvent("AboutLoginsFilterLogins", { + detail: "", + }) + ); + newlySelectedLogin = + this._logins[this._loginGuidsSortedOrder[0]].login; + } + + // Select the first visible login after any possible filter is applied. + window.dispatchEvent( + new CustomEvent("AboutLoginsLoginSelected", { + detail: newlySelectedLogin, + cancelable: true, + }) + ); + break; + } + case "AboutLoginsFilterLogins": { + this._filter = event.detail.toLocaleLowerCase(); + this.render(); + break; + } + case "AboutLoginsInitialLoginSelected": + case "AboutLoginsLoginSelected": { + if (event.defaultPrevented || this._selectedGuid == event.detail.guid) { + return; + } + + // XXX If an AboutLoginsLoginSelected event is received that doesn't contain + // the full login object, re-dispatch the event with the full login object since + // only the login-list knows the full details of each login object. + if ( + Object.keys(event.detail).length == 1 && + event.detail.hasOwnProperty("guid") + ) { + window.dispatchEvent( + new CustomEvent("AboutLoginsLoginSelected", { + detail: this._logins[event.detail.guid].login, + cancelable: true, + }) + ); + return; + } + + let listItem = this._list.querySelector( + `login-list-item[data-guid="${event.detail.guid}"]` + ); + if (listItem) { + this._setListItemAsSelected(listItem); + } else { + this.render(); + } + break; + } + case "AboutLoginsShowBlankLogin": { + if (!event.defaultPrevented) { + this._selectedGuid = null; + this._setListItemAsSelected(this._blankLoginListItem); + } + break; + } + case "keyup": + case "keydown": { + if (event.type == "keydown") { + if ( + this.shadowRoot.activeElement && + this.shadowRoot.activeElement.closest("ol") && + (event.key == " " || + event.key == "ArrowUp" || + event.key == "ArrowDown") + ) { + // Since Space, ArrowUp and ArrowDown will perform actions, prevent + // them from also scrolling the list. + event.preventDefault(); + } + } + + this._handleKeyboardNavWithinList(event); + break; + } + } + } + + /** + * @param {login[]} logins An array of logins used for displaying in the list. + */ + setLogins(logins) { + this._loginGuidsSortedOrder = []; + this._logins = logins.reduce((map, login) => { + this._loginGuidsSortedOrder.push(login.guid); + map[login.guid] = { login }; + return map; + }, {}); + this._sections = {}; + this._applyHeaders(); + this._applySort(); + this._list.textContent = ""; + this._list.appendChild(this._blankLoginListItem); + this.render(); + + if (!this._selectedGuid || !this._logins[this._selectedGuid]) { + this._selectFirstVisibleLogin(); + } + } + + /** + * @param {Map} breachesByLoginGUID A Map of breaches by login GUIDs used + * for displaying breached login indicators. + */ + setBreaches(breachesByLoginGUID) { + this._internalSetMonitorData("_breachesByLoginGUID", breachesByLoginGUID); + } + + /** + * @param {Map} breachesByLoginGUID A Map of breaches by login GUIDs that + * should be added to the local cache of + * breaches. + */ + updateBreaches(breachesByLoginGUID) { + this._internalUpdateMonitorData( + "_breachesByLoginGUID", + breachesByLoginGUID + ); + } + + setVulnerableLogins(vulnerableLoginsByLoginGUID) { + this._internalSetMonitorData( + "_vulnerableLoginsByLoginGUID", + vulnerableLoginsByLoginGUID + ); + } + + updateVulnerableLogins(vulnerableLoginsByLoginGUID) { + this._internalUpdateMonitorData( + "_vulnerableLoginsByLoginGUID", + vulnerableLoginsByLoginGUID + ); + } + + _internalSetMonitorData( + internalMemberName, + mapByLoginGUID, + updateSortAndSelectedLogin = true + ) { + this[internalMemberName] = mapByLoginGUID; + if (this[internalMemberName].size) { + for (let [loginGuid] of mapByLoginGUID) { + if (this._logins[loginGuid]) { + let { login, listItem } = this._logins[loginGuid]; + LoginListItemFactory.update(listItem, login); + } + } + if (updateSortAndSelectedLogin) { + const alertsSortOptionElement = this._sortSelect.namedItem("alerts"); + alertsSortOptionElement.hidden = false; + this._sortSelect.selectedIndex = alertsSortOptionElement.index; + this._applyHeaders(); + this._applySortAndScrollToTop(); + this._selectFirstVisibleLogin(); + } + } + this.render(); + } + + _internalUpdateMonitorData(internalMemberName, mapByLoginGUID) { + if (!this[internalMemberName]) { + this[internalMemberName] = new Map(); + } + for (const [guid, data] of [...mapByLoginGUID]) { + if (data) { + this[internalMemberName].set(guid, data); + } else { + this[internalMemberName].delete(guid); + } + } + this._internalSetMonitorData( + internalMemberName, + this[internalMemberName], + false + ); + } + + setSortDirection(sortDirection) { + // The 'alerts' sort becomes visible when there are known alerts. + // Don't restore to the 'alerts' sort if there are no alerts to show. + if ( + sortDirection == "alerts" && + this._sortSelect.namedItem("alerts").hidden + ) { + return; + } + this._sortSelect.value = sortDirection; + this._applyHeaders(); + this._applySortAndScrollToTop(); + this._selectFirstVisibleLogin(); + } + + /** + * @param {login} login A login that was added to storage. + */ + loginAdded(login) { + this._logins[login.guid] = { login }; + this._loginGuidsSortedOrder.push(login.guid); + this._applyHeaders(false); + this._applySort(); + + // Add the list item and update any other related state that may pertain + // to the list item such as breach alerts. + this.render(); + + if ( + this.classList.contains("no-logins") && + !this.classList.contains("create-login-selected") + ) { + this._selectFirstVisibleLogin(); + } + } + + /** + * @param {login} login A login that was modified in storage. The related + * login-list-item will get updated. + */ + loginModified(login) { + this._logins[login.guid] = Object.assign(this._logins[login.guid], { + login, + _header: null, // reset header + }); + this._applyHeaders(false); + this._applySort(); + let loginObject = this._logins[login.guid]; + LoginListItemFactory.update(loginObject.listItem, login); + + // Update any other related state that may pertain to the list item + // such as breach alerts that may or may not now apply. + this.render(); + } + + /** + * @param {login} login A login that was removed from storage. The related + * login-list-item will get removed. The login object + * is a plain JS object representation of + * nsILoginInfo/nsILoginMetaInfo. + */ + loginRemoved(login) { + // Update the selected list item to the previous item in the list + // if one exists, otherwise the next item. If no logins remain + // the login-intro or empty-search text will be shown instead of the login-list. + if (this._selectedGuid == login.guid) { + let visibleListItems = this._list.querySelectorAll( + "login-list-item[data-guid]:not([hidden])" + ); + if (visibleListItems.length > 1) { + let index = [...visibleListItems].findIndex(listItem => { + return listItem.dataset.guid == login.guid; + }); + let newlySelectedIndex = index > 0 ? index - 1 : index + 1; + let newlySelectedLogin = + this._logins[visibleListItems[newlySelectedIndex].dataset.guid].login; + window.dispatchEvent( + new CustomEvent("AboutLoginsLoginSelected", { + detail: newlySelectedLogin, + cancelable: true, + }) + ); + } + } + + this._logins[login.guid].listItem.remove(); + delete this._logins[login.guid]; + this._loginGuidsSortedOrder = this._loginGuidsSortedOrder.filter(guid => { + return guid != login.guid; + }); + + // Render the login-list to update the search result count and show the + // empty-search message if needed. + this.render(); + } + + /** + * @returns {Set} Set of login guids that match the filter. + */ + _applyFilter() { + let matchingLoginGuids; + if (this._filter) { + matchingLoginGuids = new Set( + this._loginGuidsSortedOrder.filter(guid => { + let { login } = this._logins[guid]; + return ( + login.origin.toLocaleLowerCase().includes(this._filter) || + (!!login.httpRealm && + login.httpRealm.toLocaleLowerCase().includes(this._filter)) || + login.username.toLocaleLowerCase().includes(this._filter) || + login.password.toLocaleLowerCase().includes(this._filter) + ); + }) + ); + } else { + matchingLoginGuids = new Set([...this._loginGuidsSortedOrder]); + } + + return matchingLoginGuids; + } + + _applySort() { + const sort = this._sortSelect.value; + this._loginGuidsSortedOrder = this._loginGuidsSortedOrder.sort((a, b) => { + let loginA = this._logins[a].login; + let loginB = this._logins[b].login; + return sortFnOptions[sort]( + loginA, + loginB, + this._breachesByLoginGUID, + this._vulnerableLoginsByLoginGUID + ); + }); + } + + _applyHeaders(updateAll = true) { + let headerFn = headersFnOptions[this._sortSelect.value]; + for (let guid of this._loginGuidsSortedOrder) { + let login = this._logins[guid]; + if (updateAll || !login._header) { + login._header = headerFn( + login.login, + this._breachesByLoginGUID, + this._vulnerableLoginsByLoginGUID + ); + } + } + } + + _applySortAndScrollToTop() { + this._applySort(); + this.render(); + this._list.scrollTop = 0; + } + + #updateVisibleLoginCount(count, total) { + const args = document.l10n.getAttributes(this._count).args; + if (count != args.count || total != args.total) { + document.l10n.setAttributes( + this._count, + count == total ? "login-list-count2" : "login-list-filtered-count2", + { count, total } + ); + } + } + + #findPreviousItem(item) { + let previousItem = item; + do { + previousItem = + (previousItem.tagName == "SECTION" + ? previousItem.lastElementChild + : previousItem.previousElementSibling) || + (previousItem.parentElement.tagName == "SECTION" && + previousItem.parentElement.previousElementSibling); + } while ( + previousItem && + (previousItem.hidden || previousItem.tagName !== "LOGIN-LIST-ITEM") + ); + + return previousItem; + } + + #findNextItem(item) { + let nextItem = item; + do { + nextItem = + (nextItem.tagName == "SECTION" + ? nextItem.firstElementChild.nextElementSibling + : nextItem.nextElementSibling) || + (nextItem.parentElement.tagName == "SECTION" && + nextItem.parentElement.nextElementSibling); + } while ( + nextItem && + (nextItem.hidden || nextItem.tagName !== "LOGIN-LIST-ITEM") + ); + return nextItem; + } + + #pickByDirection(ltr, rtl) { + return document.dir == "ltr" ? ltr : rtl; + } + + //TODO May be we can use this fn in render(), but logic is different a bit + get #activeDescendantForSelection() { + let activeDescendant = this.#activeDescendant; + if ( + !activeDescendant || + activeDescendant.hidden || + activeDescendant.tagName !== "LOGIN-LIST-ITEM" + ) { + activeDescendant = + this._list.querySelector("login-list-item[data-guid]:not([hidden])") || + this._list.firstElementChild; + } + return activeDescendant; + } + + _handleKeyboardNavWithinList(event) { + if (this._list != this.shadowRoot.activeElement) { + return; + } + + let command = null; + + switch (event.type) { + case "keyup": + switch (event.key) { + case " ": + case "Enter": + command = "click"; + break; + } + break; + case "keydown": + switch (event.key) { + case "ArrowDown": + command = "next"; + break; + case "ArrowLeft": + command = this.#pickByDirection("previous", "next"); + break; + case "ArrowRight": + command = this.#pickByDirection("next", "previous"); + break; + case "ArrowUp": + command = "previous"; + break; + } + break; + } + + if (command) { + event.preventDefault(); + + switch (command) { + case "click": + this.clickSelected(); + break; + case "next": + this.selectNext(); + break; + case "previous": + this.selectPrevious(); + break; + } + } + } + + clickSelected() { + this.#activeDescendantForSelection?.click(); + } + + selectNext() { + const activeDescendant = this.#activeDescendantForSelection; + if (activeDescendant) { + this.#moveSelection( + activeDescendant, + this.#findNextItem(activeDescendant) + ); + } + } + + selectPrevious() { + const activeDescendant = this.#activeDescendantForSelection; + if (activeDescendant) { + this.#moveSelection( + activeDescendant, + this.#findPreviousItem(activeDescendant) + ); + } + } + + #moveSelection(from, to) { + if (to) { + this._list.setAttribute("aria-activedescendant", to.id); + from?.classList.remove("keyboard-selected"); + to.classList.add("keyboard-selected"); + to.scrollIntoView({ block: "nearest" }); + this.clickSelected(); + } + } + + /** + * Selects the first visible login as part of the initial load of the page, + * which will bypass any focus changes that occur during manual login + * selection. + */ + _selectFirstVisibleLogin() { + const visibleLoginsGuids = this._applyFilter(); + let selectedLoginGuid = + this._loginGuidsSortedOrder.find(guid => guid === this._preselectLogin) ?? + this.findLoginGuidFromDomain(this._preselectLogin) ?? + this._loginGuidsSortedOrder[0]; + + selectedLoginGuid = [ + selectedLoginGuid, + ...this._loginGuidsSortedOrder, + ].find(guid => visibleLoginsGuids.has(guid)); + + const selectedLogin = this._logins[selectedLoginGuid]?.login; + + if (selectedLogin) { + window.dispatchEvent( + new CustomEvent("AboutLoginsInitialLoginSelected", { + detail: selectedLogin, + }) + ); + this.updateSelectedLocationHash(selectedLoginGuid); + } + } + + _setListItemAsSelected(listItem) { + let oldSelectedItem = this._list.querySelector(".selected"); + if (oldSelectedItem) { + oldSelectedItem.classList.remove("selected"); + oldSelectedItem.selected = false; + oldSelectedItem.removeAttribute("aria-selected"); + } + this.classList.toggle("create-login-selected", !listItem.dataset.guid); + this._blankLoginListItem.hidden = !!listItem.dataset.guid; + this._createLoginButton.disabled = !listItem.dataset.guid; + listItem.classList.add("selected"); + listItem.selected = true; + listItem.setAttribute("aria-selected", "true"); + this._list.setAttribute("aria-activedescendant", listItem.id); + this._selectedGuid = listItem.dataset.guid; + this.updateSelectedLocationHash(this._selectedGuid); + // Scroll item into view if it isn't visible + listItem.scrollIntoView({ block: "nearest" }); + } + + updateSelectedLocationHash(guid) { + window.location.hash = guid ? `#${encodeURIComponent(guid)}` : ""; + } + + findLoginGuidFromDomain(domain) { + for (let guid of this._loginGuidsSortedOrder) { + let login = this._logins[guid].login; + if (login.hostname === domain) { + return guid; + } + } + return null; + } +} +customElements.define("login-list", LoginList); diff --git a/browser/components/aboutlogins/content/components/login-message-popup.css b/browser/components/aboutlogins/content/components/login-message-popup.css new file mode 100644 index 0000000000..802c8c0fa4 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-message-popup.css @@ -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 https://mozilla.org/MPL/2.0/. */ + +:host .tooltip-container { + position: absolute; + inset-inline-start: 315px; + width: 232px; + box-shadow: 2px 2px 10px 1px rgba(0,0,0,0.18); + top: 0; +} + +:host .tooltip-message { + margin: 0; + font-size: 14px; +} + +:host .arrow-box { + position: relative; + padding: 12px; + background-color: var(--in-content-box-background); + border: 1px solid var(--in-content-border-color); + border-radius: 4px; +} + +:host .arrow-box::before, +:host .arrow-box::after { + inset-inline-end: 100%; + top: 40px; /* This allows the arrow to stay in the correct position, even if the text length is changed */ + border: solid transparent; + content: ""; + height: 0; + width: 0; + position: absolute; + pointer-events: none; +} + +:host .arrow-box::after { + border-inline-end-color: var(--in-content-box-background); + border-width: 10px; + margin-top: -10px; +} +:host .arrow-box::before { + border-inline-end-color: var(--in-content-border-color); + border-width: 11px; + margin-top: -11px; +} diff --git a/browser/components/aboutlogins/content/components/login-message-popup.mjs b/browser/components/aboutlogins/content/components/login-message-popup.mjs new file mode 100644 index 0000000000..5949917142 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-message-popup.mjs @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { html, ifDefined } from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +const stylesTemplate = () => html` `; + +export const MessagePopup = ({ l10nid, webTitle = "" }) => { + return html`
    +
    +

    +
    +
    `; +}; + +export class PasswordWarning extends MozLitElement { + static get properties() { + return { + isNewLogin: { type: Boolean, reflect: true }, + webTitle: { type: String, reflect: true }, + }; + } + + constructor() { + super(); + this.isNewLogin = false; + } + render() { + return this.isNewLogin + ? html`${stylesTemplate()} + ${MessagePopup({ + l10nid: "about-logins-add-password-tooltip", + })}` + : html`${stylesTemplate()} + ${MessagePopup({ + l10nid: "about-logins-edit-password-tooltip", + webTitle: this.webTitle, + })}`; + } +} + +export class OriginWarning extends MozLitElement { + render() { + return html`${stylesTemplate()} + ${MessagePopup({ l10nid: "about-logins-origin-tooltip2" })}`; + } +} + +customElements.define("password-warning", PasswordWarning); +customElements.define("origin-warning", OriginWarning); diff --git a/browser/components/aboutlogins/content/components/login-timeline.css b/browser/components/aboutlogins/content/components/login-timeline.css new file mode 100644 index 0000000000..1e6eaa2b30 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-timeline.css @@ -0,0 +1,62 @@ +/* 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/. */ + +.timeline { + display: grid; + grid-template-rows: 24px auto auto; + font-size: smaller; + color: var(--text-color-deemphasized); + padding-inline-start: 0; + text-align: center; +} + +.timeline.empty { + display: none; +} + +.timeline > svg { + grid-row: 1 / 1; + fill: var(--in-content-box-background); +} + +.timeline > .line { + height: 2px; + justify-self: stretch; + align-self: center; + background-color: var(--in-content-border-color); + grid-row: 1; +} + +.timeline > .line:nth-child(1) { + grid-column: 1; + width: 50%; + justify-self: flex-end; +} + +.timeline > .line:nth-child(2) { + grid-column: 2/-2; +} + +.timeline > .line:nth-child(3) { + grid-column: -2; + width: 50%; + justify-self: flex-start; +} + +.timeline > .point { + width: 24px; + height: 24px; + stroke: var(--in-content-border-color); + stroke-width: 30px; + justify-self: center; +} + +.timeline > .date { + grid-row: 2; + padding: 4px 8px; +} + +.timeline > .action { + grid-row: 3; +} diff --git a/browser/components/aboutlogins/content/components/login-timeline.mjs b/browser/components/aboutlogins/content/components/login-timeline.mjs new file mode 100644 index 0000000000..7925602fc9 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-timeline.mjs @@ -0,0 +1,79 @@ +/* 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 { + styleMap, + classMap, + html, +} from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +export default class Timeline extends MozLitElement { + static get properties() { + return { + history: { type: Array }, + }; + } + + constructor() { + super(); + this.history = []; + } + + render() { + this.history = this.history.filter(historyPoint => historyPoint.time); + this.history.sort((a, b) => a.time - b.time); + let columns = "auto"; + + // Add each history event to the timeline + let points = this.history.map((entry, index) => { + if (index > 0) { + // add a gap between previous point and current one + columns += ` ${entry.time - this.history[index - 1].time}fr auto`; + } + + let columnNumber = 2 * index + 1; + let styles = styleMap({ gridColumn: columnNumber }); + return html` + + + +
    +
    + `; + }); + + return html` + +
    +
    +
    +
    + ${points} +
    + `; + } +} + +customElements.define("login-timeline", Timeline); diff --git a/browser/components/aboutlogins/content/components/menu-button.css b/browser/components/aboutlogins/content/components/menu-button.css new file mode 100644 index 0000000000..57e26676b3 --- /dev/null +++ b/browser/components/aboutlogins/content/components/menu-button.css @@ -0,0 +1,93 @@ +/* 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 { + position: relative; +} + +.menu-button { + background-image: url("chrome://global/skin/icons/more.svg"); + background-repeat: no-repeat; + background-position: center; + -moz-context-properties: fill; + fill: currentColor; + width: 30px; + min-width: 30px; + margin: 0; +} + +.menu { + position: absolute; + inset-inline-end: 0; + margin: 0; + padding: 5px 0; + background-color: var(--in-content-box-background); + border: 1px solid var(--in-content-box-border-color); + border-radius: 4px; + box-shadow: var(--shadow-30); + min-width: max-content; + list-style-type: none; + display: flex; + flex-direction: column; + /* Show on top of .breach-alert which is also positioned */ + z-index: 1; + font: menu; +} + +.menuitem-button { + padding: 4px 8px; + /* 32px = 8px (padding) + 16px (icon) + 8px (padding) */ + padding-inline-start: 32px; + background-repeat: no-repeat; + background-position: left 8px center; + background-size: 16px; + -moz-context-properties: fill; + fill: currentColor; + + /* Override common.inc.css properties */ + margin: 0; + border: 0; + border-radius: 0; + text-align: start; + min-height: initial; + font: inherit; +} + +.menuitem-button:dir(rtl) { + background-position-x: right 8px; +} + +.menuitem-button:focus-visible { + outline-offset: var(--in-content-focus-outline-inset); +} + +.menuitem-separator { + border-top-width: 1px; + margin-block: 5px; + width: 100%; +} + +.menuitem-help { + background-image: url("chrome://global/skin/icons/help.svg"); +} + +.menuitem-import-browser { + background-image: url("chrome://browser/skin/import.svg"); +} + +.menuitem-import-file { + background-image: url("chrome://browser/skin/import.svg"); +} + +.menuitem-export { + background-image: url("chrome://browser/skin/save.svg"); +} + +.menuitem-remove-all-logins { + background-image: url("chrome://global/skin/icons/delete.svg"); +} + +.menuitem-preferences { + background-image: url("chrome://global/skin/icons/settings.svg"); +} diff --git a/browser/components/aboutlogins/content/components/menu-button.mjs b/browser/components/aboutlogins/content/components/menu-button.mjs new file mode 100644 index 0000000000..bb69b711c9 --- /dev/null +++ b/browser/components/aboutlogins/content/components/menu-button.mjs @@ -0,0 +1,183 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export default class MenuButton extends HTMLElement { + connectedCallback() { + if (this.shadowRoot) { + return; + } + + let MenuButtonTemplate = document.querySelector("#menu-button-template"); + let shadowRoot = this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + shadowRoot.appendChild(MenuButtonTemplate.content.cloneNode(true)); + + for (let menuitem of this.shadowRoot.querySelectorAll( + ".menuitem-button[data-supported-platforms]" + )) { + let supportedPlatforms = menuitem.dataset.supportedPlatforms + .split(",") + .map(platform => platform.trim()); + if (supportedPlatforms.includes(navigator.platform)) { + menuitem.hidden = false; + } + } + + this._menu = this.shadowRoot.querySelector(".menu"); + this._menuButton = this.shadowRoot.querySelector(".menu-button"); + + this._menuButton.addEventListener("click", this); + document.addEventListener("keydown", this, true); + } + + handleEvent(event) { + switch (event.type) { + case "blur": { + if (event.explicitOriginalTarget) { + let node = event.explicitOriginalTarget; + if (node.nodeType == Node.TEXT_NODE) { + node = node.parentElement; + } + if (node.closest(".menu") == this._menu) { + // Only hide the menu if focus has left the menu-button. + return; + } + } + this._hideMenu(); + break; + } + case "click": { + // Skip the catch-all event listener if it was the menu-button + // that was clicked on. + if ( + event.currentTarget == document.documentElement && + event.target == this && + event.originalTarget == this._menuButton + ) { + return; + } + + if (event.originalTarget == this._menuButton) { + this._toggleMenu(); + if (!this._menu.hidden) { + this._menuButton.focus(); + } + return; + } + + let classList = event.originalTarget.classList; + if (classList.contains("menuitem-button")) { + let eventName = event.originalTarget.dataset.eventName; + const linkTrackingSource = "Elipsis_Menu"; + document.dispatchEvent( + new CustomEvent(eventName, { + bubbles: true, + detail: linkTrackingSource, + }) + ); + + // Bug 1645365: Only hide the menu when the buttons are clicked + // So that the menu isn't closed when non-buttons (e.g. separators, paddings) are clicked + this._hideMenu(); + } + + // Explicitly close menu at the catch-all click event (i.e. a click outside of the menu) + if ( + !this._menu.contains(event.originalTarget) && + !this._menuButton.contains(event.originalTarget) + ) { + this._hideMenu(); + } + + break; + } + case "keydown": { + this._handleKeyDown(event); + } + } + } + + _handleKeyDown(event) { + if (event.key == "Enter" && event.originalTarget == this._menuButton) { + event.preventDefault(); + this._toggleMenu(); + this._focusSuccessor(true); + } else if (event.key == "Escape" && !this._menu.hidden) { + this._hideMenu(); + this._menuButton.focus(); + } else if ( + (event.key == "ArrowDown" || event.key == "ArrowUp") && + !this._menu.hidden + ) { + event.preventDefault(); + this._focusSuccessor(event.key == "ArrowDown"); + } + } + + _focusSuccessor(next = true) { + let items = this._menu.querySelectorAll(".menuitem-button:not([hidden])"); + let firstItem = items[0]; + let lastItem = items[items.length - 1]; + + let activeItem = this.shadowRoot.activeElement; + let activeItemIndex = [...items].indexOf(activeItem); + + let successor = null; + + if (next) { + if (!activeItem || activeItem === lastItem) { + successor = firstItem; + } else { + successor = items[activeItemIndex + 1]; + } + } else if (activeItem === this._menuButton || activeItem === firstItem) { + successor = lastItem; + } else { + successor = items[activeItemIndex - 1]; + } + + if (this._menu.hidden) { + this._showMenu(); + } + if (successor.disabled) { + if (next) { + successor = items[activeItemIndex + 2]; + } else { + successor = items[activeItemIndex - 2]; + } + } + window.AboutLoginsUtils.setFocus(successor); + } + + _hideMenu() { + this._menu.hidden = true; + + this.removeEventListener("blur", this); + document.documentElement.removeEventListener("click", this, true); + } + + _showMenu() { + this._menu.querySelector(".menuitem-import-file").hidden = + !window.AboutLoginsUtils.fileImportEnabled; + + this._menu.hidden = false; + + // Event listeners to close the menu + this.addEventListener("blur", this); + document.documentElement.addEventListener("click", this, true); + } + + /** + * Toggles the visibility of the menu. + */ + _toggleMenu() { + let wasHidden = this._menu.hidden; + if (wasHidden) { + this._showMenu(); + } else { + this._hideMenu(); + } + } +} +customElements.define("menu-button", MenuButton); diff --git a/browser/components/aboutlogins/content/components/remove-logins-dialog.css b/browser/components/aboutlogins/content/components/remove-logins-dialog.css new file mode 100644 index 0000000000..160ca47d03 --- /dev/null +++ b/browser/components/aboutlogins/content/components/remove-logins-dialog.css @@ -0,0 +1,102 @@ +/* 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/. */ + + .overlay { + position: fixed; + z-index: 1; + inset: 0; + /* TODO: this color is used in the about:preferences overlay, but + why isn't it declared as a variable? */ + background-color: rgba(0,0,0,0.5); + display: flex; +} + +.container { + z-index: 2; + position: relative; + display: flex; + flex-direction: column; + min-width: 300px; + max-width: 660px; + min-height: 200px; + margin: auto; + background-color: var(--in-content-page-background); + color: var(--in-content-text-color); + box-shadow: var(--shadow-30); + /* show a border in high contrast mode */ + outline: 1px solid transparent; +} + +.title { + grid-area: 1 / 2 / 2 / 8; +} + +.message { + font-weight: 600; + grid-area: 2 / 2 / 3 / 8; + font-size: 1.25em; +} + +.checkbox-text { + font-size: 1.25em; +} + +.dismiss-button { + position: absolute; + top: 0; + inset-inline-end: 0; + min-width: 20px; + min-height: 20px; + margin: 16px; + padding: 0; + line-height: 0; +} + +.dismiss-icon { + -moz-context-properties: fill; + fill: currentColor; +} + +.warning-icon { + -moz-context-properties: fill; + fill: currentColor; + width: 32px; + height: 32px; + margin: 8px; +} + +.content, +.buttons { + padding: 36px 48px; + padding-bottom: 24px; +} + +.content { + display: grid; + grid-template-columns: 0.5fr 1fr 1fr 1fr 1fr 1fr 1fr; + grid-template-rows: 0.5fr 0.5fr 0.5fr; +} + +.checkbox-wrapper { + grid-area: 3 / 2 / 4 / 8; + align-self: first baseline; + justify-self: start; +} + +.warning-icon { + grid-area: 1 / 1 / 2 / 2; +} + +.checkbox { + grid-area: 3 / 2 / 4 / 8; + font-size: 1.1em; + align-self: center; +} + +.buttons { + padding-block: 16px 32px; + padding-inline: 48px 0; + border-top: 1px solid var(--in-content-border-color); + margin-inline: 48px; +} diff --git a/browser/components/aboutlogins/content/components/remove-logins-dialog.mjs b/browser/components/aboutlogins/content/components/remove-logins-dialog.mjs new file mode 100644 index 0000000000..94cd6ef13e --- /dev/null +++ b/browser/components/aboutlogins/content/components/remove-logins-dialog.mjs @@ -0,0 +1,117 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { setKeyboardAccessForNonDialogElements } from "../aboutLoginsUtils.mjs"; + +export default class RemoveLoginsDialog extends HTMLElement { + constructor() { + super(); + this._promise = null; + } + + connectedCallback() { + if (this.shadowRoot) { + return; + } + let template = document.querySelector("#remove-logins-dialog-template"); + let shadowRoot = this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + shadowRoot.appendChild(template.content.cloneNode(true)); + + this._buttons = this.shadowRoot.querySelector(".buttons"); + this._cancelButton = this.shadowRoot.querySelector(".cancel-button"); + this._confirmButton = this.shadowRoot.querySelector(".confirm-button"); + this._dismissButton = this.shadowRoot.querySelector(".dismiss-button"); + this._message = this.shadowRoot.querySelector(".message"); + this._overlay = this.shadowRoot.querySelector(".overlay"); + this._title = this.shadowRoot.querySelector(".title"); + this._checkbox = this.shadowRoot.querySelector(".checkbox"); + this._checkboxLabel = this.shadowRoot.querySelector(".checkbox-text"); + } + + handleEvent(event) { + switch (event.type) { + case "keydown": + if (event.key === "Escape" && !event.defaultPrevented) { + this.onCancel(); + } + break; + case "click": + if ( + event.target.classList.contains("cancel-button") || + event.currentTarget.classList.contains("dismiss-button") || + event.target.classList.contains("overlay") + ) { + this.onCancel(); + } else if (event.target.classList.contains("confirm-button")) { + this.onConfirm(); + } else if (event.target.classList.contains("checkbox")) { + this._confirmButton.disabled = !this._checkbox.checked; + } + } + } + + hide() { + setKeyboardAccessForNonDialogElements(true); + this._cancelButton.removeEventListener("click", this); + this._confirmButton.removeEventListener("click", this); + this._dismissButton.removeEventListener("click", this); + this._overlay.removeEventListener("click", this); + this._checkbox.removeEventListener("click", this); + window.removeEventListener("keydown", this); + + this._checkbox.checked = false; + + this.hidden = true; + } + + show({ title, message, confirmButtonLabel, confirmCheckboxLabel, count }) { + setKeyboardAccessForNonDialogElements(false); + this.hidden = false; + + document.l10n.setAttributes(this._title, title, { + count, + }); + document.l10n.setAttributes(this._message, message, { + count, + }); + document.l10n.setAttributes(this._confirmButton, confirmButtonLabel, { + count, + }); + document.l10n.setAttributes(this._checkboxLabel, confirmCheckboxLabel, { + count, + }); + + this._checkbox.addEventListener("click", this); + this._cancelButton.addEventListener("click", this); + this._confirmButton.addEventListener("click", this); + this._dismissButton.addEventListener("click", this); + this._overlay.addEventListener("click", this); + window.addEventListener("keydown", this); + + this._confirmButton.disabled = true; + // For speed-of-use, focus the confirmation checkbox when the dialog loads. + // Introducing this checkbox provides enough of a buffer for accidental deletions. + this._checkbox.focus(); + + this._promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + + return this._promise; + } + + onCancel() { + this._reject(); + this.hide(); + } + + onConfirm() { + this._resolve(); + this.hide(); + } +} + +customElements.define("remove-logins-dialog", RemoveLoginsDialog); diff --git a/browser/components/aboutlogins/content/icons/breached-website.svg b/browser/components/aboutlogins/content/icons/breached-website.svg new file mode 100644 index 0000000000..7ab9d5a173 --- /dev/null +++ b/browser/components/aboutlogins/content/icons/breached-website.svg @@ -0,0 +1,6 @@ + + + + diff --git a/browser/components/aboutlogins/content/icons/intro-illustration.svg b/browser/components/aboutlogins/content/icons/intro-illustration.svg new file mode 100644 index 0000000000..accbd3b979 --- /dev/null +++ b/browser/components/aboutlogins/content/icons/intro-illustration.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/components/aboutlogins/content/icons/password-hide.svg b/browser/components/aboutlogins/content/icons/password-hide.svg new file mode 100644 index 0000000000..058023f661 --- /dev/null +++ b/browser/components/aboutlogins/content/icons/password-hide.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/browser/components/aboutlogins/content/icons/password.svg b/browser/components/aboutlogins/content/icons/password.svg new file mode 100644 index 0000000000..fdd32417ae --- /dev/null +++ b/browser/components/aboutlogins/content/icons/password.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/browser/components/aboutlogins/content/icons/vulnerable-password.svg b/browser/components/aboutlogins/content/icons/vulnerable-password.svg new file mode 100644 index 0000000000..9ffac637c9 --- /dev/null +++ b/browser/components/aboutlogins/content/icons/vulnerable-password.svg @@ -0,0 +1,6 @@ + + + + diff --git a/browser/components/aboutlogins/content/utils/controllers.mjs b/browser/components/aboutlogins/content/utils/controllers.mjs new file mode 100644 index 0000000000..13c46dfcaf --- /dev/null +++ b/browser/components/aboutlogins/content/utils/controllers.mjs @@ -0,0 +1,18 @@ +/* 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/. */ + +// Simple controller wrapper that binds a controller and runs cleanup function +// https://lit.dev/docs/composition/controllers/ +export const withSimpleController = (host, functionToBind, ...args) => + class { + constructor() { + host.addController(this); + } + hostConnected() { + this.cleanup = functionToBind?.(host, ...args); + } + hostDisconnected() { + this.cleanup?.(); + } + }; diff --git a/browser/components/aboutlogins/content/utils/keypress.mjs b/browser/components/aboutlogins/content/utils/keypress.mjs new file mode 100644 index 0000000000..13a97964c0 --- /dev/null +++ b/browser/components/aboutlogins/content/utils/keypress.mjs @@ -0,0 +1,42 @@ +/* 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 { withSimpleController } from "./controllers.mjs"; + +const useKeyEvent = + keyEventName => (target, keyCombination, callback, options) => { + const parts = keyCombination.split("+"); + const keys = parts.map(part => part.toLowerCase()); + const modifiers = ["ctrl", "alt", "shift", "meta"]; + + const handleKeyEvent = event => { + const isModifierCorrect = modifiers.every( + modifier => keys.includes(modifier) === event[`${modifier}Key`] + ); + + const actualKey = keys.find(key => !modifiers.includes(key)); + + // We check the code value rather than key since key value + // can be missleading without prevent default e.g. option + N = ñ + const isKeyCorrect = + event.code.toLowerCase() === `key${actualKey}`.toLowerCase() || + event.code.toLowerCase() === actualKey.toLowerCase(); + + if (isModifierCorrect && isKeyCorrect) { + if (options?.preventDefault) { + event.preventDefault(); + } + callback?.(event); + } + }; + + target.addEventListener(keyEventName, handleKeyEvent); + + return () => { + target.removeEventListener(keyEventName, handleKeyEvent); + }; + }; + +export const handleKeyPress = (host, ...args) => + new (withSimpleController(host, useKeyEvent("keydown"), ...args))(); diff --git a/browser/components/aboutlogins/jar.mn b/browser/components/aboutlogins/jar.mn new file mode 100644 index 0000000000..375ca5862b --- /dev/null +++ b/browser/components/aboutlogins/jar.mn @@ -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/. + +browser.jar: + content/browser/aboutlogins/components/input-field/input-field.css (content/components/input-field/input-field.css) + content/browser/aboutlogins/components/login-list-lit-item.mjs (content/components/login-list-lit-item.mjs) + content/browser/aboutlogins/components/login-list-lit-item.css (content/components/login-list-lit-item.css) + content/browser/aboutlogins/components/login-alert.css (content/components/login-alert.css) + content/browser/aboutlogins/components/login-alert.mjs (content/components/login-alert.mjs) + content/browser/aboutlogins/components/confirmation-dialog.css (content/components/confirmation-dialog.css) + content/browser/aboutlogins/components/confirmation-dialog.mjs (content/components/confirmation-dialog.mjs) + content/browser/aboutlogins/components/remove-logins-dialog.css (content/components/remove-logins-dialog.css) + content/browser/aboutlogins/components/remove-logins-dialog.mjs (content/components/remove-logins-dialog.mjs) + content/browser/aboutlogins/components/import-summary-dialog.css (content/components/import-summary-dialog.css) + content/browser/aboutlogins/components/import-summary-dialog.mjs (content/components/import-summary-dialog.mjs) + content/browser/aboutlogins/components/import-error-dialog.css (content/components/import-error-dialog.css) + content/browser/aboutlogins/components/import-error-dialog.mjs (content/components/import-error-dialog.mjs) + content/browser/aboutlogins/components/import-details-row.mjs (content/components/import-details-row.mjs) + content/browser/aboutlogins/components/generic-dialog.css (content/components/generic-dialog.css) + content/browser/aboutlogins/components/generic-dialog.mjs (content/components/generic-dialog.mjs) + content/browser/aboutlogins/components/fxaccounts-button.css (content/components/fxaccounts-button.css) + content/browser/aboutlogins/components/fxaccounts-button.mjs (content/components/fxaccounts-button.mjs) + content/browser/aboutlogins/components/login-filter.css (content/components/login-filter.css) + content/browser/aboutlogins/components/login-filter.mjs (content/components/login-filter.mjs) + content/browser/aboutlogins/components/login-intro.css (content/components/login-intro.css) + content/browser/aboutlogins/components/login-intro.mjs (content/components/login-intro.mjs) + content/browser/aboutlogins/components/login-item.css (content/components/login-item.css) + content/browser/aboutlogins/components/login-item.mjs (content/components/login-item.mjs) + content/browser/aboutlogins/components/login-list.css (content/components/login-list.css) + content/browser/aboutlogins/components/login-list.mjs (content/components/login-list.mjs) + content/browser/aboutlogins/components/login-list-item.mjs (content/components/login-list-item.mjs) + content/browser/aboutlogins/components/login-list-section.mjs (content/components/login-list-section.mjs) + content/browser/aboutlogins/components/login-command-button.mjs (content/components/login-command-button.mjs) + content/browser/aboutlogins/components/login-command-button.css (content/components/login-command-button.css) + content/browser/aboutlogins/components/login-message-popup.mjs (content/components/login-message-popup.mjs) + content/browser/aboutlogins/components/login-message-popup.css (content/components/login-message-popup.css) + content/browser/aboutlogins/components/menu-button.css (content/components/menu-button.css) + content/browser/aboutlogins/components/menu-button.mjs (content/components/menu-button.mjs) + content/browser/aboutlogins/components/login-timeline.css (content/components/login-timeline.css) + content/browser/aboutlogins/components/login-timeline.mjs (content/components/login-timeline.mjs) + content/browser/aboutlogins/icons/breached-website.svg (content/icons/breached-website.svg) + content/browser/aboutlogins/icons/vulnerable-password.svg (content/icons/vulnerable-password.svg) + content/browser/aboutlogins/icons/password.svg (content/icons/password.svg) + content/browser/aboutlogins/icons/password-hide.svg (content/icons/password-hide.svg) + content/browser/aboutlogins/icons/intro-illustration.svg (content/icons/intro-illustration.svg) + content/browser/aboutlogins/aboutLogins.css (content/aboutLogins.css) + content/browser/aboutlogins/aboutLogins.mjs (content/aboutLogins.mjs) + content/browser/aboutlogins/aboutLogins.html (content/aboutLogins.html) + content/browser/aboutlogins/aboutLoginsImportReport.css (content/aboutLoginsImportReport.css) + content/browser/aboutlogins/aboutLoginsImportReport.mjs (content/aboutLoginsImportReport.mjs) + content/browser/aboutlogins/aboutLoginsImportReport.html (content/aboutLoginsImportReport.html) + content/browser/aboutlogins/aboutLoginsUtils.mjs (content/aboutLoginsUtils.mjs) + content/browser/aboutlogins/common.css (content/common.css) diff --git a/browser/components/aboutlogins/moz.build b/browser/components/aboutlogins/moz.build new file mode 100644 index 0000000000..b0ac44a41c --- /dev/null +++ b/browser/components/aboutlogins/moz.build @@ -0,0 +1,23 @@ +# -*- 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/. + +JAR_MANIFESTS += ["jar.mn"] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "about:logins") + +EXTRA_JS_MODULES += [ + "LoginBreaches.sys.mjs", +] + +FINAL_TARGET_FILES.actors += [ + "AboutLoginsChild.sys.mjs", + "AboutLoginsParent.sys.mjs", +] + +BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.toml"] +MOCHITEST_CHROME_MANIFESTS += ["tests/chrome/chrome.toml"] +XPCSHELL_TESTS_MANIFESTS += ["tests/unit/xpcshell.toml"] diff --git a/browser/components/aboutlogins/tests/browser/AboutLoginsTestUtils.sys.mjs b/browser/components/aboutlogins/tests/browser/AboutLoginsTestUtils.sys.mjs new file mode 100644 index 0000000000..44a51b80ad --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/AboutLoginsTestUtils.sys.mjs @@ -0,0 +1,107 @@ +/* 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/. */ + +/** + * An utility class to help out with the about:logins and about:loginsimportreport DOM interaction for the tests. + * + */ +export class AboutLoginsTestUtils { + /** + * An utility method to fetch the data from the CSV import success dialog. + * + * @param {content} content + * The content object. + * @param {ContentTaskUtils} ContentTaskUtils + * The ContentTaskUtils object. + * @returns {Promise} A promise that contains added, modified, noChange and errors count. + */ + static async getCsvImportSuccessDialogData(content, ContentTaskUtils) { + let dialog = Cu.waiveXrays( + content.document.querySelector("import-summary-dialog") + ); + await ContentTaskUtils.waitForCondition( + () => !dialog.hidden, + "Waiting for the dialog to be visible" + ); + + let added = dialog.shadowRoot.querySelector( + ".import-items-added .result-count" + ).textContent; + let modified = dialog.shadowRoot.querySelector( + ".import-items-modified .result-count" + ).textContent; + let noChange = dialog.shadowRoot.querySelector( + ".import-items-no-change .result-count" + ).textContent; + let errors = dialog.shadowRoot.querySelector( + ".import-items-errors .result-count" + ).textContent; + return { + added, + modified, + noChange, + errors, + l10nFocused: dialog.shadowRoot.activeElement.getAttribute("data-l10n-id"), + }; + } + + /** + * An utility method to fetch the data from the CSV import error dialog. + * + * @param {content} content + * The content object. + * @returns {Promise} A promise that contains the hidden state and l10n id for title, description and focused element. + */ + static async getCsvImportErrorDialogData(content) { + const dialog = Cu.waiveXrays( + content.document.querySelector("import-error-dialog") + ); + const l10nTitle = dialog._genericDialog + .querySelector(".error-title") + .getAttribute("data-l10n-id"); + const l10nDescription = dialog._genericDialog + .querySelector(".error-description") + .getAttribute("data-l10n-id"); + return { + hidden: dialog.hidden, + l10nFocused: dialog.shadowRoot.activeElement.getAttribute("data-l10n-id"), + l10nTitle, + l10nDescription, + }; + } + + /** + * An utility method to fetch data from the about:loginsimportreport page. + * It also cleans up the tab so you don't have to. + * + * @param {content} content + * The content object. + * @returns {Promise} A promise that contains the detailed report data like added, modified, noChange, errors and rows. + */ + static async getCsvImportReportData(content) { + const rows = []; + for (let element of content.document.querySelectorAll(".row-details")) { + rows.push(element.getAttribute("data-l10n-id")); + } + const added = content.document.querySelector( + ".new-logins .result-count" + ).textContent; + const modified = content.document.querySelector( + ".exiting-logins .result-count" + ).textContent; + const noChange = content.document.querySelector( + ".duplicate-logins .result-count" + ).textContent; + const errors = content.document.querySelector( + ".errors-logins .result-count" + ).textContent; + return { + rows, + added, + modified, + noChange, + errors, + }; + } +} diff --git a/browser/components/aboutlogins/tests/browser/browser.toml b/browser/components/aboutlogins/tests/browser/browser.toml new file mode 100644 index 0000000000..0b38e0dda1 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser.toml @@ -0,0 +1,88 @@ +[DEFAULT] +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. +# Skip ASAN and debug since waiting for content events is already slow. + +["browser_aaa_eventTelemetry_run_first.js"] +skip-if = [ + "asan", + "tsan", + "ccov", + "debug", + "os == 'win' && !debug", # bug 1605494 is more prevalent on linux, Bug 1627419 + "os == 'linux' && bits == 64 && !debug", # Bug 1648862 +] + +["browser_alertDismissedAfterChangingPassword.js"] +skip-if = ["apple_catalina && !debug"] # Bug 1684513 + +["browser_breachAlertShowingForAddedLogin.js"] + +["browser_confirmDeleteDialog.js"] + +["browser_contextmenuFillLogins.js"] + +["browser_copyToClipboardButton.js"] + +["browser_createLogin.js"] + +["browser_deleteLogin.js"] + +["browser_fxAccounts.js"] + +["browser_loginFilter.js"] + +["browser_loginItemErrors.js"] +skip-if = ["debug"] # Bug 1577710 + +["browser_loginListChanges.js"] + +["browser_loginSortOrderRestored.js"] +skip-if = ["os == 'linux' && bits == 64 && os_version == '18.04'"] # Bug 1587625; Bug 1587626 for linux1804 + +["browser_noLoginsView.js"] + +["browser_openExport.js"] + +["browser_openFiltered.js"] + +["browser_openImport.js"] +skip-if = [ + "os == 'linux'", # import is only available on Windows and macOS + "os == 'android'", # import is only available on Windows and macOS + "os == 'mac' && !debug", # bug 1775753 +] + +["browser_openImportCSV.js"] + +["browser_openPreferences.js"] + +["browser_openPreferencesExternal.js"] + +["browser_openSite.js"] +skip-if = ["os == 'linux' && bits == 64"] # Bug 1581889 + +["browser_osAuthDialog.js"] +skip-if = ["os == 'linux'"] # bug 1527745 + +["browser_primaryPassword.js"] +skip-if = ["os == 'linux'"] # bug 1569789 + +["browser_removeAllDialog.js"] + +["browser_sessionRestore.js"] +skip-if = [ + "tsan", + "debug", # Bug 1576876 +] + +["browser_tabKeyNav.js"] + +["browser_updateLogin.js"] + +["browser_vulnerableLoginAddedInSecondaryWindow.js"] 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 new file mode 100644 index 0000000000..52b2eb02a2 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_aaa_eventTelemetry_run_first.js @@ -0,0 +1,267 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +requestLongerTimeout(2); + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +EXPECTED_BREACH = { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.example.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", +}; + +let VULNERABLE_TEST_LOGIN2 = new nsLoginInfo( + "https://2.example.com", + "https://2.example.com", + null, + "user2", + "pass3", + "username", + "password" +); + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + VULNERABLE_TEST_LOGIN2 = await addLogin(VULNERABLE_TEST_LOGIN2); + TEST_LOGIN3 = await addLogin(TEST_LOGIN3); + + await TestUtils.waitForCondition(() => { + Services.telemetry.clearEvents(); + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + return !events || !events.length; + }, "Waiting for telemetry events to get cleared"); + + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_telemetry_events() { + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginList = content.document.querySelector("login-list"); + let loginListItem = loginList.shadowRoot.querySelector( + "login-list-item.breached" + ); + loginListItem.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(2); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let copyButton = loginItem.shadowRoot.querySelector("copy-username-button"); + copyButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(3); + + if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let copyButton = loginItem.shadowRoot.querySelector( + "copy-password-button" + ); + copyButton.click(); + }); + await reauthObserved; + // When reauth is observed an extra telemetry event will be recorded + // for the reauth, hence the event count increasing by 2 here, and later + // in the test as well. + await LoginTestUtils.telemetry.waitForEventCount(5); + } + let nextTelemetryEventCount = OSKeyStoreTestUtils.canTestOSKeyStoreLogin() + ? 6 + : 4; + + let promiseNewTab = BrowserTestUtils.waitForNewTab( + gBrowser, + TEST_LOGIN3.origin + "/" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let originInput = loginItem.shadowRoot.querySelector(".origin-input"); + originInput.click(); + }); + let newTab = await promiseNewTab; + Assert.ok(true, "New tab opened to " + TEST_LOGIN3.origin); + BrowserTestUtils.removeTab(newTab); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + // Show the password + if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + let 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"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + }); + await reauthObserved; + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + // Hide the password + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + // Don't force the auth timeout here to check that `auth_skipped: true` is set as + // in `extra`. + nextTelemetryEventCount++; // An extra event is observed for the reauth event. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let editButton = loginItem.shadowRoot.querySelector("edit-button"); + editButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let usernameField = loginItem.shadowRoot.querySelector( + 'input[name="username"]' + ); + usernameField.value = "user1-modified"; + + let saveButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + saveButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + } + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let deleteButton = loginItem.shadowRoot.querySelector("delete-button"); + deleteButton.click(); + let confirmDeleteDialog = content.document.querySelector( + "confirmation-dialog" + ); + let confirmDeleteButton = + confirmDeleteDialog.shadowRoot.querySelector(".confirm-button"); + confirmDeleteButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let newLoginButton = content.document + .querySelector("login-list") + .shadowRoot.querySelector("create-login-button"); + newLoginButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button"); + cancelButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginList = content.document.querySelector("login-list"); + let loginListItem = loginList.shadowRoot.querySelector( + "login-list-item.vulnerable" + ); + loginListItem.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let copyButton = loginItem.shadowRoot.querySelector("copy-username-button"); + copyButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let deleteButton = loginItem.shadowRoot.querySelector("delete-button"); + deleteButton.click(); + let confirmDeleteDialog = content.document.querySelector( + "confirmation-dialog" + ); + let confirmDeleteButton = + confirmDeleteDialog.shadowRoot.querySelector(".confirm-button"); + confirmDeleteButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginSort = content.document + .querySelector("login-list") + .shadowRoot.querySelector("#login-sort"); + loginSort.value = "last-used"; + loginSort.dispatchEvent(new content.Event("change", { bubbles: true })); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.management.page.sort"); + }); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + const loginList = content.document.querySelector("login-list"); + const loginFilter = loginList.shadowRoot.querySelector("login-filter"); + const input = loginFilter.shadowRoot.querySelector("input"); + input.setUserInput("test"); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + const testOSAuth = OSKeyStoreTestUtils.canTestOSKeyStoreLogin(); + let expectedEvents = [ + [true, "pwmgr", "open_management", "direct"], + [true, "pwmgr", "select", "existing_login", null, { breached: "true" }], + [true, "pwmgr", "copy", "username", null, { breached: "true" }], + [testOSAuth, "pwmgr", "reauthenticate", "os_auth", "success"], + [testOSAuth, "pwmgr", "copy", "password", null, { breached: "true" }], + [true, "pwmgr", "open_site", "existing_login", null, { breached: "true" }], + [testOSAuth, "pwmgr", "reauthenticate", "os_auth", "success"], + [testOSAuth, "pwmgr", "show", "password", null, { breached: "true" }], + [testOSAuth, "pwmgr", "hide", "password", null, { breached: "true" }], + [testOSAuth, "pwmgr", "reauthenticate", "os_auth", "success_no_prompt"], + [testOSAuth, "pwmgr", "edit", "existing_login", null, { breached: "true" }], + [testOSAuth, "pwmgr", "save", "existing_login", null, { breached: "true" }], + [true, "pwmgr", "delete", "existing_login", null, { breached: "true" }], + [true, "pwmgr", "new", "new_login"], + [true, "pwmgr", "cancel", "new_login"], + [true, "pwmgr", "select", "existing_login", null, { vulnerable: "true" }], + [true, "pwmgr", "copy", "username", null, { vulnerable: "true" }], + [true, "pwmgr", "delete", "existing_login", null, { vulnerable: "true" }], + [true, "pwmgr", "sort", "list"], + [true, "pwmgr", "filter", "list"], + ]; + expectedEvents = expectedEvents + .filter(event => event[0]) + .map(event => event.slice(1)); + + TelemetryTestUtils.assertEvents( + expectedEvents, + { category: "pwmgr" }, + { clear: true, process: "content" } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_alertDismissedAfterChangingPassword.js b/browser/components/aboutlogins/tests/browser/browser_alertDismissedAfterChangingPassword.js new file mode 100644 index 0000000000..944f852c18 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_alertDismissedAfterChangingPassword.js @@ -0,0 +1,227 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +EXPECTED_BREACH = { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.example.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", +}; + +let VULNERABLE_TEST_LOGIN2 = new nsLoginInfo( + "https://2.example.com", + "https://2.example.com", + null, + "user2", + "pass3", + "username", + "password" +); + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + VULNERABLE_TEST_LOGIN2 = await addLogin(VULNERABLE_TEST_LOGIN2); + TEST_LOGIN3 = await addLogin(TEST_LOGIN3); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_added_login_shows_breach_warning() { + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn( + browser, + [[TEST_LOGIN1.guid, VULNERABLE_TEST_LOGIN2.guid, TEST_LOGIN3.guid]], + async ([regularLoginGuid, vulnerableLoginGuid, breachedLoginGuid]) => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition( + () => loginList.shadowRoot.querySelectorAll("login-list-item").length, + "Waiting for login-list to get populated" + ); + let { listItem: regularListItem } = loginList._logins[regularLoginGuid]; + let { listItem: vulnerableListItem } = + loginList._logins[vulnerableLoginGuid]; + let { listItem: breachedListItem } = loginList._logins[breachedLoginGuid]; + await ContentTaskUtils.waitForCondition(() => { + return ( + !regularListItem.matches(".breached.vulnerable") && + vulnerableListItem.matches(".vulnerable") && + breachedListItem.matches(".breached") + ); + }, `waiting for the list items to get their classes updated: ${regularListItem.className} / ${vulnerableListItem.className} / ${breachedListItem.className}`); + Assert.ok( + !regularListItem.classList.contains("breached") && + !regularListItem.classList.contains("vulnerable"), + "regular login should not be marked breached or vulnerable: " + + regularLoginGuid.className + ); + Assert.ok( + !vulnerableListItem.classList.contains("breached") && + vulnerableListItem.classList.contains("vulnerable"), + "vulnerable login should be marked vulnerable: " + + vulnerableListItem.className + ); + Assert.ok( + breachedListItem.classList.contains("breached") && + !breachedListItem.classList.contains("vulnerable"), + "breached login should be marked breached: " + + breachedListItem.className + ); + + breachedListItem.click(); + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition(() => { + return loginItem._login && loginItem._login.guid == breachedLoginGuid; + }, "waiting for breached login to get selected"); + Assert.ok( + !ContentTaskUtils.isHidden( + loginItem.shadowRoot.querySelector("login-breach-alert") + ), + "the breach alert should be visible" + ); + } + ); + + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + info( + "leaving test early since the remaining part of the test requires 'edit' mode which requires 'oskeystore' login" + ); + return; + } + + let 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. + await SpecialPowers.spawn(browser, [], () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + loginItem.shadowRoot.querySelector("edit-button").click(); + }); + await reauthObserved; + await SpecialPowers.spawn( + browser, + [[TEST_LOGIN1.guid, VULNERABLE_TEST_LOGIN2.guid, TEST_LOGIN3.guid]], + async ([regularLoginGuid, vulnerableLoginGuid, breachedLoginGuid]) => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing == "true", + "waiting for login-item to enter edit mode" + ); + + // The password display field is in the DOM when password input is unfocused. + // To get the password input field, ensure it receives focus. + let passwordInput = loginItem.shadowRoot.querySelector( + "input[type='password']" + ); + passwordInput.focus(); + passwordInput = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + + const CHANGED_PASSWORD_VALUE = "changedPassword"; + passwordInput.value = CHANGED_PASSWORD_VALUE; + let saveChangesButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + saveChangesButton.click(); + + await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._logins[breachedLoginGuid].login.password == + CHANGED_PASSWORD_VALUE + ); + }, "waiting for stored login to get updated"); + + Assert.ok( + ContentTaskUtils.isHidden( + loginItem.shadowRoot.querySelector("login-breach-alert") + ), + "the breach alert should be hidden now" + ); + + let { listItem: breachedListItem } = loginList._logins[breachedLoginGuid]; + let { listItem: vulnerableListItem } = + loginList._logins[vulnerableLoginGuid]; + Assert.ok( + !breachedListItem.classList.contains("breached") && + !breachedListItem.classList.contains("vulnerable"), + "the originally breached login should no longer be marked as breached" + ); + Assert.ok( + !vulnerableListItem.classList.contains("breached") && + vulnerableListItem.classList.contains("vulnerable"), + "vulnerable login should still be marked vulnerable: " + + vulnerableListItem.className + ); + + // Change the password on the vulnerable login and check that the + // login is no longer marked as vulnerable. + vulnerableListItem.click(); + await ContentTaskUtils.waitForCondition(() => { + return loginItem._login && loginItem._login.guid == vulnerableLoginGuid; + }, "waiting for vulnerable login to get selected"); + Assert.ok( + !ContentTaskUtils.isHidden( + loginItem.shadowRoot.querySelector("login-vulnerable-password-alert") + ), + "the vulnerable alert should be visible" + ); + loginItem.shadowRoot.querySelector("edit-button").click(); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing == "true", + "waiting for login-item to enter edit mode" + ); + + passwordInput.value = CHANGED_PASSWORD_VALUE; + saveChangesButton.click(); + + await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._logins[vulnerableLoginGuid].login.password == + CHANGED_PASSWORD_VALUE + ); + }, "waiting for stored login to get updated"); + + Assert.ok( + ContentTaskUtils.isHidden( + loginItem.shadowRoot.querySelector("login-vulnerable-password-alert") + ), + "the vulnerable alert should be hidden now" + ); + Assert.equal( + vulnerableListItem.notificationIcon, + "", + ".alert-icon for the vulnerable list item should have its source removed" + ); + vulnerableListItem = loginList._logins[vulnerableLoginGuid].listItem; + Assert.ok( + !vulnerableListItem.classList.contains("breached") && + !vulnerableListItem.classList.contains("vulnerable"), + "vulnerable login should no longer be marked vulnerable: " + + vulnerableListItem.className + ); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_breachAlertShowingForAddedLogin.js b/browser/components/aboutlogins/tests/browser/browser_breachAlertShowingForAddedLogin.js new file mode 100644 index 0000000000..184cb58278 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_breachAlertShowingForAddedLogin.js @@ -0,0 +1,123 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +EXPECTED_BREACH = { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.example.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", +}; + +add_setup(async function () { + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_added_login_shows_breach_warning() { + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 0, + "the login list should be empty" + ); + }); + + TEST_LOGIN3 = await addLogin(TEST_LOGIN3); + await SpecialPowers.spawn( + browser, + [TEST_LOGIN3.guid], + async aTestLogin3Guid => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition( + () => loginList._loginGuidsSortedOrder.length == 1, + "waiting for login list count to equal one. count=" + + loginList._loginGuidsSortedOrder.length + ); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 1, + "one login should be in the list" + ); + let breachedLoginListItems; + await ContentTaskUtils.waitForCondition(() => { + breachedLoginListItems = loginList._list.querySelectorAll( + "login-list-item[data-guid].breached" + ); + return breachedLoginListItems.length == 1; + }, "waiting for the login to get marked as breached"); + Assert.equal( + breachedLoginListItems[0].dataset.guid, + aTestLogin3Guid, + "the breached login should be login3" + ); + } + ); + + info("adding a login that uses the same password as the breached login"); + let vulnerableLogin = new nsLoginInfo( + "https://2.example.com", + "https://2.example.com", + null, + "user2", + "pass3", + "username", + "password" + ); + vulnerableLogin = await addLogin(vulnerableLogin); + await SpecialPowers.spawn( + browser, + [[TEST_LOGIN3.guid, vulnerableLogin.guid]], + async ([aTestLogin3Guid, aVulnerableLoginGuid]) => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition( + () => loginList._loginGuidsSortedOrder.length == 2, + "waiting for login list count to equal two. count=" + + loginList._loginGuidsSortedOrder.length + ); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 2, + "two logins should be in the list" + ); + let breachedAndVulnerableLoginListItems; + await ContentTaskUtils.waitForCondition(() => { + breachedAndVulnerableLoginListItems = [ + ...loginList._list.querySelectorAll(".breached, .vulnerable"), + ]; + return breachedAndVulnerableLoginListItems.length == 2; + }, "waiting for the logins to get marked as breached and vulnerable"); + Assert.ok( + !!breachedAndVulnerableLoginListItems.find( + listItem => listItem.dataset.guid == aTestLogin3Guid + ), + "the list should include the breached login: " + + breachedAndVulnerableLoginListItems.map(li => li.dataset.guid) + ); + Assert.ok( + !!breachedAndVulnerableLoginListItems.find( + listItem => listItem.dataset.guid == aVulnerableLoginGuid + ), + "the list should include the vulnerable login: " + + breachedAndVulnerableLoginListItems.map(li => li.dataset.guid) + ); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_confirmDeleteDialog.js b/browser/components/aboutlogins/tests/browser/browser_confirmDeleteDialog.js new file mode 100644 index 0000000000..b40b9c2f1a --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_confirmDeleteDialog.js @@ -0,0 +1,128 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test() { + let browser = gBrowser.selectedBrowser; + + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + + let showPromise = loginItem.showConfirmationDialog("delete"); + + let dialog = Cu.waiveXrays( + content.document.querySelector("confirmation-dialog") + ); + let cancelButton = dialog.shadowRoot.querySelector(".cancel-button"); + let confirmDeleteButton = + dialog.shadowRoot.querySelector(".confirm-button"); + let dismissButton = dialog.shadowRoot.querySelector(".dismiss-button"); + let message = dialog.shadowRoot.querySelector(".message"); + let title = dialog.shadowRoot.querySelector(".title"); + + await content.document.l10n.translateElements([ + title, + message, + cancelButton, + confirmDeleteButton, + ]); + + Assert.equal( + title.dataset.l10nId, + "about-logins-confirm-delete-dialog-title", + "Title contents should match l10n attribute set on outer element" + ); + Assert.equal( + message.dataset.l10nId, + "about-logins-confirm-delete-dialog-message", + "Message contents should match l10n attribute set on outer element" + ); + Assert.equal( + cancelButton.dataset.l10nId, + "confirmation-dialog-cancel-button", + "Cancel button contents should match l10n attribute set on outer element" + ); + Assert.equal( + confirmDeleteButton.dataset.l10nId, + "about-logins-confirm-remove-dialog-confirm-button", + "Remove button contents should match l10n attribute set on outer element" + ); + + cancelButton.click(); + try { + await showPromise; + Assert.ok( + false, + "Promise returned by show() should not resolve after clicking cancel button" + ); + } catch (ex) { + Assert.ok( + true, + "Promise returned by show() should reject after clicking cancel button" + ); + } + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden" + ); + Assert.ok( + dialog.hidden, + "Dialog should be hidden after clicking cancel button" + ); + + showPromise = loginItem.showConfirmationDialog("delete"); + dismissButton.click(); + try { + await showPromise; + Assert.ok( + false, + "Promise returned by show() should not resolve after clicking dismiss button" + ); + } catch (ex) { + Assert.ok( + true, + "Promise returned by show() should reject after clicking dismiss button" + ); + } + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden" + ); + Assert.ok( + dialog.hidden, + "Dialog should be hidden after clicking dismiss button" + ); + + showPromise = loginItem.showConfirmationDialog("delete"); + confirmDeleteButton.click(); + try { + await showPromise; + Assert.ok( + true, + "Promise returned by show() should resolve after clicking confirm button" + ); + } catch (ex) { + Assert.ok( + false, + "Promise returned by show() should not reject after clicking confirm button" + ); + } + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden" + ); + Assert.ok( + dialog.hidden, + "Dialog should be hidden after clicking confirm button" + ); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_contextmenuFillLogins.js b/browser/components/aboutlogins/tests/browser/browser_contextmenuFillLogins.js new file mode 100644 index 0000000000..ee1527d792 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_contextmenuFillLogins.js @@ -0,0 +1,185 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +const gTests = [ + { + name: "test contextmenu on password field in create login view", + async setup(browser) { + // load up the create login view + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let createButton = loginList._createLoginButton; + createButton.click(); + }); + }, + }, +]; + +if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + gTests[gTests.length] = { + name: "test contextmenu on password field in edit login view", + async setup(browser) { + let osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + + // load up the edit login view + await SpecialPowers.spawn( + browser, + [LoginHelper.loginToVanillaObject(TEST_LOGIN1)], + async login => { + let loginList = content.document.querySelector("login-list"); + let loginListItem = loginList.shadowRoot.querySelector( + "login-list-item[data-guid]:not([hidden])" + ); + info("Clicking on the first login"); + + loginListItem.click(); + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition(() => { + return ( + loginItem._login.guid == loginListItem.dataset.guid && + loginItem._login.guid == login.guid + ); + }, "Waiting for login item to get populated"); + let editButton = loginItem.shadowRoot.querySelector("edit-button"); + editButton.click(); + } + ); + await osAuthDialogShown; + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "Waiting for login-item to be in editing state" + ); + }); + }, + }; +} + +/** + * Synthesize mouse clicks to open the password manager context menu popup + * for a target input element. + * + */ +async function openContextMenuForPasswordInput(browser) { + const doc = browser.ownerDocument; + const CONTEXT_MENU = doc.getElementById("contentAreaContextMenu"); + + let contextMenuShownPromise = BrowserTestUtils.waitForEvent( + CONTEXT_MENU, + "popupshown" + ); + + let passwordInputCoords = await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + + // The password display field is in the DOM when password input is unfocused. + // To get the password input field, ensure it receives focus. + let passwordInput = loginItem.shadowRoot.querySelector( + "input[type='password']" + ); + passwordInput.focus(); + passwordInput = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + + passwordInput.focus(); + let passwordRect = passwordInput.getBoundingClientRect(); + + // listen for the contextmenu event so we can assert on receiving it + // and examine the target + content.contextmenuPromise = new Promise(resolve => { + content.document.body.addEventListener( + "contextmenu", + event => { + info( + `Received event on target: ${event.target.nodeName}, type: ${event.target.type}` + ); + content.console.log("got contextmenu event: ", event); + resolve(event); + }, + { once: true } + ); + }); + + let coords = { + x: passwordRect.x + passwordRect.width / 2, + y: passwordRect.y + passwordRect.height / 2, + }; + return coords; + }); + + // add the offsets of the in the chrome window + let browserOffsets = browser.getBoundingClientRect(); + let offsetX = browserOffsets.x + passwordInputCoords.x; + let offsetY = browserOffsets.y + passwordInputCoords.y; + + // Synthesize a right mouse click over the password input element, we have to trigger + // both events because formfill code relies on this event happening before the contextmenu + // (which it does for real user input) in order to not show the password autocomplete. + let eventDetails = { type: "mousedown", button: 2 }; + await EventUtils.synthesizeMouseAtPoint(offsetX, offsetY, eventDetails); + + // Synthesize a contextmenu event to actually open the context menu. + eventDetails = { type: "contextmenu", button: 2 }; + await EventUtils.synthesizeMouseAtPoint(offsetX, offsetY, eventDetails); + + await SpecialPowers.spawn(browser, [], async () => { + let event = await content.contextmenuPromise; + // XXX the event target here is the login-item element, + // not the input[type='password'] in its shadowRoot + info("contextmenu event target: " + event.target.nodeName); + }); + + info("waiting for contextMenuShownPromise"); + await contextMenuShownPromise; + return CONTEXT_MENU; +} + +async function testContextMenuOnInputField(testData) { + let browser = gBrowser.selectedBrowser; + + await SimpleTest.promiseFocus(browser.ownerGlobal); + await testData.setup(browser); + + info("test setup completed"); + let contextMenu = await openContextMenuForPasswordInput(browser); + let fillItem = contextMenu.querySelector("#fill-login"); + Assert.ok(fillItem, "fill menu item exists"); + Assert.ok( + fillItem && EventUtils.isHidden(fillItem), + "fill menu item is hidden" + ); + + let promiseHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + info("Calling hidePopup on contextMenu"); + contextMenu.hidePopup(); + info("waiting for promiseHidden"); + await promiseHidden; +} + +for (let testData of gTests) { + let tmp = { + async [testData.name]() { + await testContextMenuOnInputField(testData); + }, + }; + add_task(tmp[testData.name]); +} diff --git a/browser/components/aboutlogins/tests/browser/browser_copyToClipboardButton.js b/browser/components/aboutlogins/tests/browser/browser_copyToClipboardButton.js new file mode 100644 index 0000000000..8b0d3b7bf6 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_copyToClipboardButton.js @@ -0,0 +1,118 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.events.testing.asyncClipboard", true]], + }); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:logins" }, + async function (browser) { + let TEST_LOGIN = { + guid: "70a", + username: "jared", + password: "deraj", + origin: "https://www.example.com", + }; + + await SpecialPowers.spawn(browser, [TEST_LOGIN], async function (login) { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + + // The login object needs to be cloned into the content global. + loginItem.setLogin(Cu.cloneInto(login, content)); + + // Lower the timeout for the test. + Object.defineProperty( + loginItem.constructor, + "COPY_BUTTON_RESET_TIMEOUT", + { + configurable: true, + writable: true, + value: 1000, + } + ); + }); + + let testCases = [[TEST_LOGIN.username, "copy-username-button"]]; + if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + testCases[1] = [TEST_LOGIN.password, "copy-password-button"]; + } + + for (let testCase of testCases) { + let testObj = { + expectedValue: testCase[0], + copyButtonSelector: testCase[1], + }; + info( + "waiting for " + testObj.expectedValue + " to be placed on clipboard" + ); + let reauthObserved = true; + if (testObj.copyButtonSelector.includes("password")) { + reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } + + await SimpleTest.promiseClipboardChange( + testObj.expectedValue, + async () => { + await SpecialPowers.spawn( + browser, + [testObj], + async function (aTestObj) { + let loginItem = content.document.querySelector("login-item"); + let copyButton = loginItem.shadowRoot.querySelector( + aTestObj.copyButtonSelector + ); + info("Clicking 'copy' button"); + copyButton.click(); + } + ); + } + ); + await reauthObserved; + Assert.ok(true, testObj.expectedValue + " is on clipboard now"); + + await SpecialPowers.spawn( + browser, + [testObj], + async function (aTestObj) { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let copyButton = loginItem.shadowRoot.querySelector( + aTestObj.copyButtonSelector + ); + let otherCopyButton = + copyButton == loginItem._copyUsernameButton + ? loginItem._copyPasswordButton + : loginItem._copyUsernameButton; + Assert.ok( + !otherCopyButton.dataset.copied, + "The other copy button should have the 'copied' state removed" + ); + Assert.ok( + copyButton.dataset.copied, + "Success message should be shown" + ); + } + ); + } + + // Wait for the 'copied' attribute to get removed from the copyPassword + // button, which is the last button that is clicked in the above testcase. + // Since another Copy button isn't clicked, the state won't get cleared + // instantly. This test covers the built-in timeout of the visual display. + await SpecialPowers.spawn(browser, [], async () => { + let copyButton = Cu.waiveXrays( + content.document.querySelector("login-item") + )._copyPasswordButton; + await ContentTaskUtils.waitForCondition( + () => !copyButton.dataset.copied, + "'copied' attribute should be removed after a timeout" + ); + }); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_createLogin.js b/browser/components/aboutlogins/tests/browser/browser_createLogin.js new file mode 100644 index 0000000000..2de24de952 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_createLogin.js @@ -0,0 +1,558 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + let aboutLoginsTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(aboutLoginsTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_create_login() { + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + Assert.ok( + !loginList._selectedGuid, + "should not be a selected guid by default" + ); + Assert.ok( + content.document.documentElement.classList.contains("no-logins"), + "Should initially be in no logins view" + ); + Assert.ok( + loginList.classList.contains("no-logins"), + "login-list should initially be in no logins view" + ); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 0, + "login list should be empty" + ); + }); + + let testCases = [ + ["ftp://ftp.example.com/", "ftp://ftp.example.com"], + ["https://example.com/foo", "https://example.com"], + ["http://example.com/", "http://example.com"], + [ + "https://testuser1:testpass1@bugzilla.mozilla.org/show_bug.cgi?id=1556934", + "https://bugzilla.mozilla.org", + ], + ["https://www.example.com/bar", "https://www.example.com"], + ]; + + for (let i = 0; i < testCases.length; i++) { + let originTuple = testCases[i]; + info("Testcase " + i); + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + + await SpecialPowers.spawn( + browser, + [[originTuple, i]], + async ([aOriginTuple, index]) => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let createButton = loginList._createLoginButton; + Assert.ok( + ContentTaskUtils.isHidden(loginList._blankLoginListItem), + "the blank login list item should be hidden initially" + ); + Assert.ok( + !createButton.disabled, + "Create button should not be disabled initially" + ); + + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + usernameInput.placeholder = "dummy placeholder"; + + createButton.click(); + + Assert.ok( + ContentTaskUtils.isVisible(loginList._blankLoginListItem), + "the blank login list item should be visible after clicking on the create button" + ); + Assert.ok( + createButton.disabled, + "Create button should be disabled after being clicked" + ); + + let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button"); + Assert.ok( + ContentTaskUtils.isVisible(cancelButton), + "cancel button should be visible in create mode with no logins saved" + ); + + let originInput = loginItem.shadowRoot.querySelector( + "input[name='origin']" + ); + let passwordInput = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + + // Upon clicking create-login-button, the origin input field is automatically focused. + Assert.ok( + ContentTaskUtils.isVisible(loginItem._originWarning), + "The origin warning should be visible" + ); + + // At this moment, the password input field is not focused so no warning should be displayed. + Assert.ok( + ContentTaskUtils.isHidden(loginItem._passwordWarning), + "The password warning should not be visible if the password input field is not focused" + ); + + Assert.equal( + content.document.l10n.getAttributes(usernameInput).id, + null, + "there should be no placeholder id on the username input in edit mode" + ); + Assert.equal( + usernameInput.placeholder, + "", + "there should be no placeholder on the username input in edit mode" + ); + + originInput.value = aOriginTuple[0]; + usernameInput.value = "testuser1"; + + passwordInput.focus(); + Assert.ok( + ContentTaskUtils.isVisible(loginItem._passwordWarning), + "The password warning should not visible" + ); + passwordInput.value = "testpass1"; + + // Since the password field is focused, the origin warning should not be displayed. + Assert.ok( + ContentTaskUtils.isHidden(loginItem._originWarning), + "The origin warning should not be visible if the origin input field is not focused" + ); + + let saveChangesButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + saveChangesButton.click(); + } + ); + + info("waiting for login to get added to storage"); + await storageChangedPromised; + info("login added to storage"); + + let canTestOSKeyStoreLogin = OSKeyStoreTestUtils.canTestOSKeyStoreLogin(); + if (canTestOSKeyStoreLogin) { + storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + } + await SpecialPowers.spawn(browser, [originTuple], async aOriginTuple => { + await ContentTaskUtils.waitForCondition(() => { + return !content.document.documentElement.classList.contains( + "no-logins" + ); + }, "waiting for no-logins view to exit"); + Assert.ok( + !content.document.documentElement.classList.contains("no-logins"), + "Should no longer be in no logins view" + ); + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + Assert.ok( + !loginList.classList.contains("no-logins"), + "login-list should no longer be in no logins view" + ); + Assert.ok( + ContentTaskUtils.isHidden(loginList._blankLoginListItem), + "the blank login list item should be hidden after adding new login" + ); + Assert.ok( + !loginList._createLoginButton.disabled, + "Create button shouldn't be disabled after exiting create login view" + ); + + let loginGuid = await ContentTaskUtils.waitForCondition(() => { + return loginList._loginGuidsSortedOrder.find( + guid => loginList._logins[guid].login.origin == aOriginTuple[1] + ); + }, "Waiting for login to be displayed"); + Assert.ok(loginGuid, "Expected login found in login-list"); + + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + Assert.equal(loginItem._login.guid, loginGuid, "login-item should match"); + + let { login, listItem } = loginList._logins[loginGuid]; + Assert.ok( + listItem.classList.contains("selected"), + "list item should be selected" + ); + Assert.equal( + login.origin, + aOriginTuple[1], + "Stored login should only include the origin of the URL provided during creation" + ); + Assert.equal( + login.username, + "testuser1", + "Stored login should have username provided during creation" + ); + Assert.equal( + login.password, + "testpass1", + "Stored login should have password provided during creation" + ); + + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + await ContentTaskUtils.waitForCondition( + () => usernameInput.placeholder, + "waiting for placeholder to get set" + ); + Assert.ok( + usernameInput.placeholder, + "there should be a placeholder on the username input when not in edit mode" + ); + }); + + if (!canTestOSKeyStoreLogin) { + continue; + } + + let reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + await SpecialPowers.spawn(browser, [originTuple], async aOriginTuple => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let editButton = loginItem.shadowRoot.querySelector("edit-button"); + info("clicking on edit button"); + editButton.click(); + }); + info("waiting for oskeystore auth"); + await reauthObserved; + + await SpecialPowers.spawn(browser, [originTuple], async aOriginTuple => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "waiting for 'edit' mode" + ); + info("in edit mode"); + + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + let passwordInput = loginItem.shadowRoot.querySelector( + "input[type='password']" + ); + passwordInput.focus(); + passwordInput = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + usernameInput.value = "testuser2"; + passwordInput.value = "testpass2"; + + let saveChangesButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + info("clicking save changes button"); + saveChangesButton.click(); + }); + + info("waiting for login to get modified in storage"); + await storageChangedPromised; + info("login modified in storage"); + + await SpecialPowers.spawn(browser, [originTuple], async aOriginTuple => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let login; + await ContentTaskUtils.waitForCondition(() => { + login = Object.values(loginList._logins).find( + obj => obj.login.origin == aOriginTuple[1] + ).login; + info(`${login.origin} / ${login.username} / ${login.password}`); + return ( + login.origin == aOriginTuple[1] && + login.username == "testuser2" && + login.password == "testpass2" + ); + }, "waiting for the login to get updated"); + Assert.equal( + login.origin, + aOriginTuple[1], + "Stored login should only include the origin of the URL provided during creation" + ); + Assert.equal( + login.username, + "testuser2", + "Stored login should have modified username" + ); + Assert.equal( + login.password, + "testpass2", + "Stored login should have modified password" + ); + }); + } + + await SpecialPowers.spawn( + browser, + [testCases.length], + async testCasesLength => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 5, + "login list should have a login per testcase" + ); + } + ); +}); + +add_task(async function test_cancel_create_login() { + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + Assert.ok( + loginList._selectedGuid, + "there should be a selected guid before create mode" + ); + Assert.ok( + ContentTaskUtils.isHidden(loginList._blankLoginListItem), + "the blank login list item should be hidden before create mode" + ); + + let createButton = loginList._createLoginButton; + createButton.click(); + + Assert.ok( + !loginList._selectedGuid, + "there should be no selected guid when in create mode" + ); + Assert.ok( + ContentTaskUtils.isVisible(loginList._blankLoginListItem), + "the blank login list item should be visible in create mode" + ); + + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button"); + cancelButton.click(); + + Assert.ok( + loginList._selectedGuid, + "there should be a selected guid after canceling create mode" + ); + Assert.ok( + ContentTaskUtils.isHidden(loginList._blankLoginListItem), + "the blank login list item should be hidden after canceling create mode" + ); + }); +}); + +add_task( + async function test_cancel_create_login_with_filter_showing_one_login() { + const browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + const loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + + const loginFilter = Cu.waiveXrays( + loginList.shadowRoot.querySelector("login-filter") + ); + loginFilter.value = "bugzilla.mozilla.org"; + Assert.equal( + loginList._list.querySelectorAll( + "login-list-item[data-guid]:not([hidden])" + ).length, + 1, + "filter should have one login showing" + ); + let visibleLoginGuid = loginList.shadowRoot.querySelectorAll( + "login-list-item[data-guid]:not([hidden])" + )[0].dataset.guid; + + let createButton = loginList._createLoginButton; + createButton.click(); + + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button"); + Assert.ok( + ContentTaskUtils.isVisible(cancelButton), + "cancel button should be visible in create mode with one login showing" + ); + cancelButton.click(); + + Assert.equal( + loginFilter.value, + "bugzilla.mozilla.org", + "login-filter should not be cleared if there was a login in the list" + ); + Assert.equal( + loginList.shadowRoot.querySelectorAll( + "login-list-item[data-guid]:not([hidden])" + )[0].dataset.guid, + visibleLoginGuid, + "the same login should still be visible" + ); + }); + } +); + +add_task(async function test_cancel_create_login_with_logins_filtered_out() { + const browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + const loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + const loginFilter = Cu.waiveXrays( + loginList.shadowRoot.querySelector("login-filter") + ); + loginFilter.value = "XXX-no-logins-should-match-this-XXX"; + await Promise.resolve(); + Assert.equal( + loginList._list.querySelectorAll( + "login-list-item[data-guid]:not([hidden])" + ).length, + 0, + "filter should have no logins showing" + ); + + let createButton = loginList._createLoginButton; + createButton.click(); + + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button"); + Assert.ok( + ContentTaskUtils.isVisible(cancelButton), + "cancel button should be visible in create mode with no logins showing" + ); + cancelButton.click(); + await Promise.resolve(); + + Assert.equal( + loginFilter.value, + "", + "login-filter should be cleared if there were no logins in the list" + ); + let visibleLoginItems = loginList.shadowRoot.querySelectorAll( + "login-list-item[data-guid]:not([hidden])" + ); + Assert.equal( + visibleLoginItems.length, + 5, + "all logins should be visible with blank filter" + ); + Assert.equal( + loginList._selectedGuid, + visibleLoginItems[0].dataset.guid, + "the first item in the list should be selected" + ); + }); +}); + +add_task(async function test_create_duplicate_login() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + return; + } + + let browser = gBrowser.selectedBrowser; + EXPECTED_ERROR_MESSAGE = "This login already exists."; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let createButton = loginList._createLoginButton; + createButton.click(); + + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let originInput = loginItem.shadowRoot.querySelector( + "input[name='origin']" + ); + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + let passwordInput = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + const EXISTING_USERNAME = "testuser2"; + const EXISTING_ORIGIN = "https://example.com"; + originInput.value = EXISTING_ORIGIN; + usernameInput.value = EXISTING_USERNAME; + passwordInput.value = "different password value"; + + let saveChangesButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + saveChangesButton.click(); + + await ContentTaskUtils.waitForCondition( + () => !loginItem._errorMessage.hidden, + "waiting until the error message is visible" + ); + let duplicatedGuid = Object.values(loginList._logins).find( + v => + v.login.origin == EXISTING_ORIGIN && + v.login.username == EXISTING_USERNAME + ).login.guid; + Assert.equal( + loginItem._errorMessageLink.dataset.errorGuid, + duplicatedGuid, + "Error message has GUID of existing duplicated login set on it" + ); + + let confirmationDialog = Cu.waiveXrays( + content.document.querySelector("confirmation-dialog") + ); + Assert.ok( + confirmationDialog.hidden, + "the discard-changes dialog should be hidden before clicking the error-message-text" + ); + loginItem._errorMessageLink.querySelector("a").click(); + Assert.ok( + !confirmationDialog.hidden, + "the discard-changes dialog should be visible" + ); + let discardChangesButton = + confirmationDialog.shadowRoot.querySelector(".confirm-button"); + discardChangesButton.click(); + + await ContentTaskUtils.waitForCondition( + () => + Object.keys(loginItem._login).length > 1 && + loginItem._login.guid == duplicatedGuid, + "waiting until the existing duplicated login is selected" + ); + Assert.equal( + loginList._selectedGuid, + duplicatedGuid, + "the duplicated login should be selected in the list" + ); + }); + EXPECTED_ERROR_MESSAGE = null; +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_deleteLogin.js b/browser/components/aboutlogins/tests/browser/browser_deleteLogin.js new file mode 100644 index 0000000000..90195d0e0a --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_deleteLogin.js @@ -0,0 +1,182 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test_show_logins() { + let browser = gBrowser.selectedBrowser; + + await SpecialPowers.spawn( + browser, + [[TEST_LOGIN1.guid, TEST_LOGIN2.guid]], + async loginGuids => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._loginGuidsSortedOrder.length == 2 && + loginList._loginGuidsSortedOrder.includes(loginGuids[0]) && + loginList._loginGuidsSortedOrder.includes(loginGuids[1]) + ); + }, "Waiting for logins to be displayed"); + Assert.ok( + !content.document.documentElement.classList.contains("no-logins"), + "Should no longer be in no logins view" + ); + Assert.ok( + !loginList.classList.contains("no-logins"), + "login-list should no longer be in no logins view" + ); + Assert.ok(loginFound, "Newly added logins should be added to the page"); + } + ); +}); + +add_task(async function test_login_item() { + let browser = gBrowser.selectedBrowser; + + function waitForDelete() { + let numLogins = Services.logins.countLogins("", "", ""); + return TestUtils.waitForCondition( + () => Services.logins.countLogins("", "", "") < numLogins, + "Error waiting for login deletion" + ); + } + + async function deleteFirstLoginAfterEdit() { + await SpecialPowers.spawn(browser, [], async () => { + let loginList = content.document.querySelector("login-list"); + let loginListItem = loginList.shadowRoot.querySelector( + "login-list-item[data-guid]:not([hidden])" + ); + info("Clicking on the first login"); + loginListItem.click(); + + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let loginItemPopulated = await ContentTaskUtils.waitForCondition(() => { + return loginItem._login.guid == loginListItem.dataset.guid; + }, "Waiting for login item to get populated"); + Assert.ok(loginItemPopulated, "The login item should get populated"); + }); + let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let editButton = loginItem.shadowRoot.querySelector("edit-button"); + editButton.click(); + }); + await reauthObserved; + return SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + let passwordInput = loginItem._passwordInput; + usernameInput.value += "-undone"; + passwordInput.value += "-undone"; + + let deleteButton = loginItem.shadowRoot.querySelector("delete-button"); + deleteButton.click(); + + let confirmDeleteDialog = Cu.waiveXrays( + content.document.querySelector("confirmation-dialog") + ); + let confirmButton = + confirmDeleteDialog.shadowRoot.querySelector(".confirm-button"); + confirmButton.click(); + }); + } + + function deleteFirstLogin() { + return SpecialPowers.spawn(browser, [], async () => { + let loginList = content.document.querySelector("login-list"); + let loginListItem = loginList.shadowRoot.querySelector( + "login-list-item[data-guid]:not([hidden])" + ); + info("Clicking on the first login"); + loginListItem.click(); + + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let loginItemPopulated = await ContentTaskUtils.waitForCondition(() => { + return loginItem._login.guid == loginListItem.dataset.guid; + }, "Waiting for login item to get populated"); + Assert.ok(loginItemPopulated, "The login item should get populated"); + + let deleteButton = loginItem.shadowRoot.querySelector("delete-button"); + deleteButton.click(); + + let confirmDeleteDialog = Cu.waiveXrays( + content.document.querySelector("confirmation-dialog") + ); + let confirmButton = + confirmDeleteDialog.shadowRoot.querySelector(".confirm-button"); + confirmButton.click(); + }); + } + + let onDeletePromise; + if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + // Can only test Edit mode in official builds + onDeletePromise = waitForDelete(); + await deleteFirstLoginAfterEdit(); + await onDeletePromise; + + await SpecialPowers.spawn(browser, [], async () => { + let loginList = content.document.querySelector("login-list"); + Assert.ok( + !content.document.documentElement.classList.contains("no-logins"), + "Should not be in no logins view as there is still one login" + ); + Assert.ok( + !loginList.classList.contains("no-logins"), + "Should not be in no logins view as there is still one login" + ); + + let confirmDiscardDialog = Cu.waiveXrays( + content.document.querySelector("confirmation-dialog") + ); + Assert.ok( + confirmDiscardDialog.hidden, + "Discard confirm dialog should not show up after delete an edited login" + ); + }); + } else { + onDeletePromise = waitForDelete(); + await deleteFirstLogin(); + await onDeletePromise; + } + + onDeletePromise = waitForDelete(); + await deleteFirstLogin(); + await onDeletePromise; + + await SpecialPowers.spawn(browser, [], async () => { + let loginList = content.document.querySelector("login-list"); + Assert.ok( + content.document.documentElement.classList.contains("no-logins"), + "Should be in no logins view as all logins got deleted" + ); + Assert.ok( + loginList.classList.contains("no-logins"), + "login-list should be in no logins view as all logins got deleted" + ); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_fxAccounts.js b/browser/components/aboutlogins/tests/browser/browser_fxAccounts.js new file mode 100644 index 0000000000..b66f204c92 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_fxAccounts.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { UIState } = ChromeUtils.importESModule( + "resource://services-sync/UIState.sys.mjs" +); + +function mockState(state) { + UIState.get = () => ({ + status: state.status, + lastSync: new Date(), + email: state.email, + avatarURL: state.avatarURL, + }); +} + +add_setup(async function () { + let aboutLoginsTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + let getState = UIState.get; + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(aboutLoginsTab); + UIState.get = getState; + }); +}); + +add_task(async function test_logged_out() { + mockState({ status: UIState.STATUS_NOT_CONFIGURED }); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + let fxAccountsButton = content.document.querySelector("fxaccounts-button"); + Assert.ok(fxAccountsButton, "fxAccountsButton should exist"); + fxAccountsButton = Cu.waiveXrays(fxAccountsButton); + await ContentTaskUtils.waitForCondition( + () => fxAccountsButton._loggedIn === false, + "waiting for _loggedIn to strictly equal false" + ); + Assert.equal( + fxAccountsButton._loggedIn, + false, + "state should reflect not logged in" + ); + }); +}); + +add_task(async function test_login_syncing_enabled() { + const TEST_EMAIL = "test@example.com"; + const TEST_AVATAR_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; + mockState({ + status: UIState.STATUS_SIGNED_IN, + email: TEST_EMAIL, + avatarURL: TEST_AVATAR_URL, + }); + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.engine.passwords", true]], + }); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn( + browser, + [[TEST_EMAIL, TEST_AVATAR_URL]], + async ([expectedEmail, expectedAvatarURL]) => { + let fxAccountsButton = + content.document.querySelector("fxaccounts-button"); + Assert.ok(fxAccountsButton, "fxAccountsButton should exist"); + fxAccountsButton = Cu.waiveXrays(fxAccountsButton); + await ContentTaskUtils.waitForCondition( + () => fxAccountsButton._email === expectedEmail, + "waiting for _email to strictly equal expectedEmail" + ); + Assert.equal( + fxAccountsButton._loggedIn, + true, + "state should reflect logged in" + ); + Assert.equal( + fxAccountsButton._email, + expectedEmail, + "state should have email set" + ); + Assert.equal( + fxAccountsButton._avatarURL, + expectedAvatarURL, + "state should have avatarURL set" + ); + } + ); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_loginFilter.js b/browser/components/aboutlogins/tests/browser/browser_loginFilter.js new file mode 100644 index 0000000000..beedf6d2f2 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_loginFilter.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + const aboutLoginsTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(aboutLoginsTab); + }); +}); + +add_task(async function focus_filter_by_ctrl_f() { + const browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + function getActiveElement() { + let element = content.document.activeElement; + + while (element?.shadowRoot) { + element = element.shadowRoot.activeElement; + } + + return element; + } + + //// State after load + + const loginFilter = content.document + .querySelector("login-list") + .shadowRoot.querySelector("login-filter") + .shadowRoot.querySelector("input"); + Assert.equal( + getActiveElement(), + loginFilter, + "login filter must be focused after opening about:logins" + ); + + //// Focus something else (Create Login button) + + content.document + .querySelector("login-list") + .shadowRoot.querySelector("create-login-button") + .shadowRoot.querySelector("button") + .focus(); + Assert.notEqual( + getActiveElement(), + loginFilter, + "login filter is not focused" + ); + + //// Ctrl+F key + + EventUtils.synthesizeKey("f", { accelKey: true }, content); + Assert.equal( + getActiveElement(), + loginFilter, + "Ctrl+F/Cmd+F focused login filter" + ); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_loginItemErrors.js b/browser/components/aboutlogins/tests/browser/browser_loginItemErrors.js new file mode 100644 index 0000000000..a78f49d3c9 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_loginItemErrors.js @@ -0,0 +1,153 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_showLoginItemErrors() { + const browser = gBrowser.selectedBrowser; + let LOGIN_TO_UPDATE = new nsLoginInfo( + "https://example.com", + "https://example.com", + null, + "user2", + "pass2" + ); + LOGIN_TO_UPDATE = await Services.logins.addLoginAsync(LOGIN_TO_UPDATE); + EXPECTED_ERROR_MESSAGE = "This login already exists."; + const LOGIN_UPDATES = { + origin: "https://example.com", + password: "my1GoodPassword", + username: "user1", + }; + + await SpecialPowers.spawn( + browser, + [[LoginHelper.loginToVanillaObject(LOGIN_TO_UPDATE), LOGIN_UPDATES]], + async ([loginToUpdate, loginUpdates]) => { + const loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + const loginItemErrorMessage = Cu.waiveXrays( + loginItem.shadowRoot.querySelector(".error-message") + ); + const loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + + const createButton = loginList._createLoginButton; + createButton.click(); + + const event = Cu.cloneInto( + { + bubbles: true, + detail: loginUpdates, + }, + content + ); + + content.dispatchEvent( + // adds first login + new content.CustomEvent("AboutLoginsCreateLogin", event) + ); + + await ContentTaskUtils.waitForCondition(() => { + return loginList.shadowRoot.querySelectorAll(".list-item").length === 3; + }, "Waiting for login item to be created."); + + Assert.ok( + loginItemErrorMessage.hidden, + "An error message should not be displayed after adding a new login." + ); + + content.dispatchEvent( + // adds a duplicate of the first login + new content.CustomEvent("AboutLoginsCreateLogin", event) + ); + + const loginItemErrorMessageVisible = + await ContentTaskUtils.waitForCondition(() => { + return !loginItemErrorMessage.hidden; + }, "Waiting for error message to be shown after attempting to create a duplicate login."); + Assert.ok( + loginItemErrorMessageVisible, + "An error message should be shown after user attempts to add a login that already exists." + ); + + const loginItemErrorMessageText = + loginItemErrorMessage.querySelector("span:not([hidden])"); + Assert.equal( + loginItemErrorMessageText.dataset.l10nId, + "about-logins-error-message-duplicate-login-with-link", + "The correct error message is displayed." + ); + + let loginListItem = Cu.waiveXrays( + loginList.shadowRoot.querySelector( + `login-list-item[data-guid='${loginToUpdate.guid}']` + ) + ); + loginListItem.click(); + + Assert.ok( + loginItemErrorMessage.hidden, + "The error message should no longer be visible." + ); + } + ); + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + // The rest of the test uses Edit mode which causes an OS prompt in official builds. + return; + } + let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await SpecialPowers.spawn( + browser, + [[LoginHelper.loginToVanillaObject(LOGIN_TO_UPDATE), LOGIN_UPDATES]], + async ([loginToUpdate, loginUpdates]) => { + const loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + const editButton = loginItem.shadowRoot + .querySelector("edit-button") + .shadowRoot.querySelector("button"); + editButton.click(); + + const updateEvent = Cu.cloneInto( + { + bubbles: true, + detail: Object.assign({ guid: loginToUpdate.guid }, loginUpdates), + }, + content + ); + + content.dispatchEvent( + // attempt to update LOGIN_TO_UPDATE to a username/origin combination that already exists. + new content.CustomEvent("AboutLoginsUpdateLogin", updateEvent) + ); + + const loginItemErrorMessage = Cu.waiveXrays( + loginItem.shadowRoot.querySelector(".error-message") + ); + const loginAlreadyExistsErrorShownAfterUpdate = + await ContentTaskUtils.waitForCondition(() => { + return !loginItemErrorMessage.hidden; + }, "Waiting for error message to show after updating login to existing login."); + Assert.ok( + loginAlreadyExistsErrorShownAfterUpdate, + "An error message should be shown after updating a login to a username/origin combination that already exists." + ); + } + ); + info("making sure os auth dialog is shown"); + await reauthObserved; + info("saw os auth dialog"); + EXPECTED_ERROR_MESSAGE = null; +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_loginListChanges.js b/browser/components/aboutlogins/tests/browser/browser_loginListChanges.js new file mode 100644 index 0000000000..13df6c1ef6 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_loginListChanges.js @@ -0,0 +1,144 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test_login_added() { + let login = { + guid: "70", + username: "jared", + password: "deraj", + origin: "https://www.example.com", + }; + let browser = gBrowser.selectedBrowser; + browser.browsingContext.currentWindowGlobal + .getActor("AboutLogins") + .sendAsyncMessage("AboutLogins:LoginAdded", login); + + await SpecialPowers.spawn(browser, [login], async addedLogin => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._loginGuidsSortedOrder.length == 1 && + loginList._loginGuidsSortedOrder[0] == addedLogin.guid + ); + }, "Waiting for login to be added"); + Assert.ok(loginFound, "Newly added logins should be added to the page"); + }); +}); + +add_task(async function test_login_modified() { + let login = { + guid: "70", + username: "jared@example.com", + password: "deraj", + origin: "https://www.example.com", + }; + let browser = gBrowser.selectedBrowser; + browser.browsingContext.currentWindowGlobal + .getActor("AboutLogins") + .sendAsyncMessage("AboutLogins:LoginModified", login); + + await SpecialPowers.spawn(browser, [login], async modifiedLogin => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._loginGuidsSortedOrder.length == 1 && + loginList._loginGuidsSortedOrder[0] == modifiedLogin.guid && + loginList._logins[loginList._loginGuidsSortedOrder[0]].login.username == + modifiedLogin.username + ); + }, "Waiting for username to get updated"); + Assert.ok(loginFound, "The login should get updated on the page"); + }); +}); + +add_task(async function test_login_removed() { + let login = { + guid: "70", + username: "jared@example.com", + password: "deraj", + origin: "https://www.example.com", + }; + let browser = gBrowser.selectedBrowser; + browser.browsingContext.currentWindowGlobal + .getActor("AboutLogins") + .sendAsyncMessage("AboutLogins:LoginRemoved", login); + + await SpecialPowers.spawn(browser, [login], async removedLogin => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginRemoved = await ContentTaskUtils.waitForCondition(() => { + return !loginList._loginGuidsSortedOrder.length; + }, "Waiting for login to get removed"); + Assert.ok(loginRemoved, "The login should be removed from the page"); + }); +}); + +add_task(async function test_all_logins_removed() { + // Setup the test with 2 logins. + let logins = [ + { + guid: "70", + username: "jared", + password: "deraj", + origin: "https://www.example.com", + }, + { + guid: "71", + username: "ntim", + password: "verysecurepassword", + origin: "https://www.example.com", + }, + ]; + + let browser = gBrowser.selectedBrowser; + browser.browsingContext.currentWindowGlobal + .getActor("AboutLogins") + .sendAsyncMessage("AboutLogins:AllLogins", logins); + + await SpecialPowers.spawn(browser, [logins], async addedLogins => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._loginGuidsSortedOrder.length == 2 && + loginList._loginGuidsSortedOrder[0] == addedLogins[0].guid && + loginList._loginGuidsSortedOrder[1] == addedLogins[1].guid + ); + }, "Waiting for login to be added"); + Assert.ok(loginFound, "Newly added logins should be added to the page"); + Assert.ok( + !content.document.documentElement.classList.contains("no-logins"), + "Should not be in no logins view after adding logins" + ); + Assert.ok( + !loginList.classList.contains("no-logins"), + "login-list should not be in no logins view after adding logins" + ); + }); + + Services.logins.removeAllUserFacingLogins(); + + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return !loginList._loginGuidsSortedOrder.length; + }, "Waiting for logins to be cleared"); + Assert.ok(loginFound, "Logins should be cleared"); + Assert.ok( + content.document.documentElement.classList.contains("no-logins"), + "Should be in no logins view after clearing" + ); + Assert.ok( + loginList.classList.contains("no-logins"), + "login-list should be in no logins view after clearing" + ); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_loginSortOrderRestored.js b/browser/components/aboutlogins/tests/browser/browser_loginSortOrderRestored.js new file mode 100644 index 0000000000..e8386474e2 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_loginSortOrderRestored.js @@ -0,0 +1,172 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +EXPECTED_BREACH = { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.example.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", +}; + +const SORT_PREF_NAME = "signon.management.page.sort"; + +add_setup(async function () { + TEST_LOGIN3.QueryInterface(Ci.nsILoginMetaInfo).timePasswordChanged = 1; + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + info(`TEST_LOGIN1 added with guid=${TEST_LOGIN1.guid}`); + TEST_LOGIN3 = await addLogin(TEST_LOGIN3); + info(`TEST_LOGIN3 added with guid=${TEST_LOGIN3.guid}`); + registerCleanupFunction(() => { + Services.logins.removeAllUserFacingLogins(); + Services.prefs.clearUserPref(SORT_PREF_NAME); + }); +}); + +add_task(async function test_sort_order_persisted() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:logins", + }, + async function (browser) { + await ContentTask.spawn( + browser, + [TEST_LOGIN1.guid, TEST_LOGIN3.guid], + async function ([testLogin1Guid, testLogin3Guid]) { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition( + () => loginList._sortSelect.value == "alerts", + "Waiting for login-list sort to get changed to 'alerts'. Current value is: " + + loginList._sortSelect.value + ); + Assert.equal( + loginList._sortSelect.value, + "alerts", + "selected sort should be 'alerts' since there is a breached login" + ); + Assert.equal( + loginList._list.querySelector( + "login-list-item[data-guid]:not([hidden])" + ).dataset.guid, + testLogin3Guid, + "the first login should be TEST_LOGIN3 since they are sorted by alerts" + ); + + loginList._sortSelect.value = "last-changed"; + loginList._sortSelect.dispatchEvent( + new content.Event("change", { bubbles: true }) + ); + Assert.equal( + loginList._list.querySelector( + "login-list-item[data-guid]:not([hidden])" + ).dataset.guid, + testLogin1Guid, + "the first login should be TEST_LOGIN1 since it has the most recent timePasswordChanged value" + ); + } + ); + } + ); + + Assert.equal( + Services.prefs.getCharPref(SORT_PREF_NAME), + "last-changed", + "'last-changed' should be stored in the pref" + ); + + // Set the pref to the value used in Fx70-76 to confirm our + // backwards-compat support that "breached" is changed to "alerts" + Services.prefs.setCharPref(SORT_PREF_NAME, "breached"); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:logins", + }, + async function (browser) { + await ContentTask.spawn( + browser, + TEST_LOGIN3.guid, + async function (testLogin3Guid) { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition( + () => loginList._sortSelect.value == "alerts", + "Waiting for login-list sort to get changed to 'alerts'. Current value is: " + + loginList._sortSelect.value + ); + Assert.equal( + loginList._sortSelect.value, + "alerts", + "selected sort should be restored to 'alerts' since 'breached' was in prefs" + ); + Assert.equal( + loginList._list.querySelector( + "login-list-item[data-guid]:not([hidden])" + ).dataset.guid, + testLogin3Guid, + "the first login should be TEST_LOGIN3 since they are sorted by alerts" + ); + } + ); + } + ); + + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "removeLogin" + ); + Services.logins.removeLogin(TEST_LOGIN3); + await storageChangedPromised; + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + + Assert.equal( + Services.prefs.getCharPref(SORT_PREF_NAME), + "breached", + "confirm that the stored sort is still 'breached' and as such shouldn't apply when the page loads" + ); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:logins", + }, + async function (browser) { + await ContentTask.spawn( + browser, + TEST_LOGIN2.guid, + async function (testLogin2Guid) { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition( + () => + loginList._list.querySelector( + "login-list-item[data-guid]:not([hidden])" + ), + "wait for a visible loging to get populated" + ); + Assert.equal( + loginList._sortSelect.value, + "name", + "selected sort should be name since 'alerts' no longer applies with no breached or vulnerable logins" + ); + Assert.equal( + loginList._list.querySelector( + "login-list-item[data-guid]:not([hidden])" + ).dataset.guid, + testLogin2Guid, + "the first login should be TEST_LOGIN2 since it is sorted first by 'name'" + ); + } + ); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_noLoginsView.js b/browser/components/aboutlogins/tests/browser/browser_noLoginsView.js new file mode 100644 index 0000000000..792cf386de --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_noLoginsView.js @@ -0,0 +1,199 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test_no_logins_class() { + let { platform } = AppConstants; + let wizardPromise; + + // The import link is hidden on Linux, so we don't wait for the migration + // wizard to open on that platform. + if (AppConstants.platform != "linux") { + wizardPromise = BrowserTestUtils.waitForMigrationWizard(window); + } + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [platform], + async aPlatform => { + let loginList = content.document.querySelector("login-list"); + + Assert.ok( + content.document.documentElement.classList.contains("no-logins"), + "root should be in no logins view" + ); + Assert.ok( + loginList.classList.contains("no-logins"), + "login-list should be in no logins view" + ); + + let loginIntro = Cu.waiveXrays( + content.document.querySelector("login-intro") + ); + let loginItem = content.document.querySelector("login-item"); + let loginListIntro = loginList.shadowRoot.querySelector(".intro"); + let loginListList = loginList.shadowRoot.querySelector("ol"); + + Assert.ok( + !ContentTaskUtils.isHidden(loginIntro), + "login-intro should be shown in no logins view" + ); + Assert.ok( + !ContentTaskUtils.isHidden(loginListIntro), + "login-list intro should be shown in no logins view" + ); + + Assert.ok( + ContentTaskUtils.isHidden(loginItem), + "login-item should be hidden in no logins view" + ); + Assert.ok( + ContentTaskUtils.isHidden(loginListList), + "login-list logins list should be hidden in no logins view" + ); + Assert.equal( + content.document.l10n.getAttributes( + loginIntro.shadowRoot.querySelector(".heading") + ).id, + "about-logins-login-intro-heading-message", + "The default message should be the non-logged-in message" + ); + Assert.ok( + loginIntro.shadowRoot + .querySelector("a.intro-help-link") + .href.includes("password-manager-remember-delete-edit-logins"), + "Check support href populated" + ); + + loginIntro.updateState(Cu.cloneInto({ loggedIn: true }, content)); + + Assert.equal( + content.document.l10n.getAttributes( + loginIntro.shadowRoot.querySelector(".heading") + ).id, + "about-logins-login-intro-heading-message", + "When logged in the message should update" + ); + + let importClass = Services.prefs.getBoolPref( + "signon.management.page.fileImport.enabled" + ) + ? ".intro-import-text.file-import" + : ".intro-import-text.no-file-import"; + Assert.equal( + ContentTaskUtils.isHidden( + loginIntro.shadowRoot.querySelector(importClass) + ), + aPlatform == "linux", + "the import link should be hidden on Linux builds" + ); + if (aPlatform == "linux") { + // End the test now for Linux since the link is hidden. + return; + } + loginIntro.shadowRoot.querySelector(importClass + " > a").click(); + info("waiting for MigrationWizard to open"); + } + ); + if (AppConstants.platform == "linux") { + // End the test now for Linux since the link is hidden. + return; + } + let wizardTab = await wizardPromise; + Assert.ok(wizardTab, "Migrator wizard tab opened"); + await BrowserTestUtils.removeTab(wizardTab); +}); + +add_task( + async function login_selected_when_login_added_and_in_no_logins_view() { + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + let loginList = content.document.querySelector("login-list"); + let loginItem = content.document.querySelector("login-item"); + let loginIntro = content.document.querySelector("login-intro"); + Assert.ok( + loginList.classList.contains("empty-search"), + "login-list should be showing no logins view from a search with no results" + ); + Assert.ok( + loginList.classList.contains("no-logins"), + "login-list should be showing no logins view since there are no saved logins" + ); + Assert.ok( + !loginList.classList.contains("create-login-selected"), + "login-list should not be in create-login-selected mode" + ); + Assert.ok( + loginItem.classList.contains("no-logins"), + "login-item should be marked as having no-logins" + ); + Assert.ok( + ContentTaskUtils.isHidden(loginItem), + "login-item should be hidden" + ); + Assert.ok( + !ContentTaskUtils.isHidden(loginIntro), + "login-intro should be visible" + ); + }); + + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [TEST_LOGIN1.guid], + async testLogin1Guid => { + let loginList = content.document.querySelector("login-list"); + let loginItem = content.document.querySelector("login-item"); + let loginIntro = content.document.querySelector("login-intro"); + await ContentTaskUtils.waitForCondition(() => { + return !loginList.classList.contains("no-logins"); + }, "waiting for login-list to leave the no-logins view"); + Assert.ok( + !loginList.classList.contains("empty-search"), + "login-list should not be showing no logins view since one login exists" + ); + Assert.ok( + !loginList.classList.contains("no-logins"), + "login-list should not be showing no logins view since one login exists" + ); + Assert.ok( + !loginList.classList.contains("create-login-selected"), + "login-list should not be in create-login-selected mode" + ); + Assert.equal( + loginList.shadowRoot.querySelector( + "login-list-item.selected[data-guid]" + ).dataset.guid, + testLogin1Guid, + "the login that was just added should be selected" + ); + Assert.ok( + !loginItem.classList.contains("no-logins"), + "login-item should not be marked as having no-logins" + ); + Assert.equal( + Cu.waiveXrays(loginItem)._login.guid, + testLogin1Guid, + "the login-item should have the newly added login selected" + ); + Assert.ok( + !ContentTaskUtils.isHidden(loginItem), + "login-item should be visible" + ); + Assert.ok( + ContentTaskUtils.isHidden(loginIntro), + "login-intro should be hidden" + ); + } + ); + } +); diff --git a/browser/components/aboutlogins/tests/browser/browser_openExport.js b/browser/components/aboutlogins/tests/browser/browser_openExport.js new file mode 100644 index 0000000000..c5df84c447 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openExport.js @@ -0,0 +1,149 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * 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" +); + +let { MockFilePicker } = SpecialPowers; + +add_setup(async function () { + await TestUtils.waitForCondition(() => { + Services.telemetry.clearEvents(); + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + return !events || !events.length; + }, "Waiting for content telemetry events to get cleared"); + + MockFilePicker.init(window); + MockFilePicker.useAnyFile(); + MockFilePicker.returnValue = MockFilePicker.returnOK; + + registerCleanupFunction(() => { + MockFilePicker.cleanup(); + }); +}); + +function waitForFilePicker() { + return new Promise(resolve => { + MockFilePicker.showCallback = () => { + MockFilePicker.showCallback = null; + Assert.ok(true, "Saw the file picker"); + resolve(); + }; + }); +} + +add_task(async function test_open_export() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:logins" }, + async function (browser) { + await BrowserTestUtils.synthesizeMouseAtCenter( + "menu-button", + {}, + browser + ); + + await SpecialPowers.spawn(browser, [], async () => { + let menuButton = content.document.querySelector("menu-button"); + return ContentTaskUtils.waitForCondition(function waitForMenu() { + return !menuButton.shadowRoot.querySelector(".menu").hidden; + }, "waiting for menu to open"); + }); + + function getExportMenuItem() { + let menuButton = window.document.querySelector("menu-button"); + let exportButton = + menuButton.shadowRoot.querySelector(".menuitem-export"); + return exportButton; + } + + await BrowserTestUtils.synthesizeMouseAtCenter( + getExportMenuItem, + {}, + browser + ); + + // First event is for opening about:logins + await LoginTestUtils.telemetry.waitForEventCount(2); + TelemetryTestUtils.assertEvents( + [["pwmgr", "mgmt_menu_item_used", "export"]], + { category: "pwmgr", method: "mgmt_menu_item_used" }, + { process: "content" } + ); + + info("Clicking confirm button"); + let osReAuthPromise = null; + + if ( + OSKeyStore.canReauth() && + !OSKeyStoreTestUtils.canTestOSKeyStoreLogin() + ) { + todo( + OSKeyStoreTestUtils.canTestOSKeyStoreLogin(), + "Cannot test OS key store login in this build." + ); + return; + } + + if (OSKeyStore.canReauth()) { + osReAuthPromise = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } + let filePicker = waitForFilePicker(); + await BrowserTestUtils.synthesizeMouseAtCenter( + () => { + let confirmExportDialog = window.document.querySelector( + "confirmation-dialog" + ); + return confirmExportDialog.shadowRoot.querySelector( + ".confirm-button" + ); + }, + {}, + browser + ); + + if (osReAuthPromise) { + Assert.ok(osReAuthPromise, "Waiting for OS re-auth promise"); + await osReAuthPromise; + } + + info("waiting for Export file picker to get opened"); + await filePicker; + Assert.ok(true, "Export file picker opened"); + + info("Waiting for the export to complete"); + let expectedEvents = [ + [ + "pwmgr", + "reauthenticate", + "os_auth", + osReAuthPromise ? "success" : "success_unsupported_platform", + ], + ["pwmgr", "mgmt_menu_item_used", "export_complete"], + ]; + await LoginTestUtils.telemetry.waitForEventCount( + expectedEvents.length, + "parent" + ); + + TelemetryTestUtils.assertEvents( + expectedEvents, + { category: "pwmgr", method: /(reauthenticate|mgmt_menu_item_used)/ }, + { process: "parent" } + ); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_openFiltered.js b/browser/components/aboutlogins/tests/browser/browser_openFiltered.js new file mode 100644 index 0000000000..2188ee7b99 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openFiltered.js @@ -0,0 +1,298 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + TEST_LOGIN1 = await Services.logins.addLoginAsync(TEST_LOGIN1); + await storageChangedPromised; + storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + TEST_LOGIN2 = await Services.logins.addLoginAsync(TEST_LOGIN2); + await storageChangedPromised; + let tabOpenedPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + url => + url.includes( + `about:logins?filter=${encodeURIComponent(TEST_LOGIN1.origin)}` + ), + true + ); + LoginHelper.openPasswordManager(window, { + filterString: TEST_LOGIN1.origin, + entryPoint: "preferences", + }); + await tabOpenedPromise; + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_query_parameter_filter() { + let browser = gBrowser.selectedBrowser; + let vanillaLogins = [ + LoginHelper.loginToVanillaObject(TEST_LOGIN1), + LoginHelper.loginToVanillaObject(TEST_LOGIN2), + ]; + await SpecialPowers.spawn(browser, [vanillaLogins], async logins => { + const loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition(() => { + return loginList._loginGuidsSortedOrder.length == 2; + }, "Waiting for logins to be cached"); + + await ContentTaskUtils.waitForCondition(() => { + const selectedLoginItem = Cu.waiveXrays( + loginList.shadowRoot.querySelector( + "login-list-item[aria-selected='true']" + ) + ); + return selectedLoginItem.dataset.guid === logins[0].guid; + }, "Waiting for TEST_LOGIN1 to be selected for the login-item view"); + + const loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + + Assert.ok( + ContentTaskUtils.isVisible(loginItem), + "login-item should be visible when a login is selected" + ); + const loginIntro = content.document.querySelector("login-intro"); + Assert.ok( + ContentTaskUtils.isHidden(loginIntro), + "login-intro should be hidden when a login is selected" + ); + + const loginFilter = loginList.shadowRoot.querySelector("login-filter"); + + const xRayLoginFilter = Cu.waiveXrays(loginFilter); + Assert.equal( + xRayLoginFilter.value, + logins[0].origin, + "The filter should be prepopulated" + ); + Assert.equal( + loginList.shadowRoot.activeElement, + loginFilter, + "login-filter should be focused" + ); + Assert.equal( + loginFilter.shadowRoot.activeElement, + loginFilter.shadowRoot.querySelector(".filter"), + "the actual input inside of login-filter should be focused" + ); + + let hiddenLoginListItems = + loginList.shadowRoot.querySelectorAll(".list-item[hidden]"); + + let visibleLoginListItems = loginList.shadowRoot.querySelectorAll( + ".list-item:not([hidden])" + ); + + Assert.equal( + visibleLoginListItems.length, + 1, + "The one login should be visible" + ); + Assert.equal( + visibleLoginListItems[0].dataset.guid, + logins[0].guid, + "TEST_LOGIN1 should be visible" + ); + Assert.equal( + hiddenLoginListItems.length, + 2, + "One saved login and one blank login should be hidden" + ); + Assert.equal( + hiddenLoginListItems[0].tagName, + "NEW-LIST-ITEM", + "new-list-item should be hidden" + ); + Assert.equal( + hiddenLoginListItems[1].dataset.guid, + logins[1].guid, + "TEST_LOGIN2 should be hidden" + ); + }); +}); + +add_task(async function test_query_parameter_filter_no_logins_for_site() { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + const HOSTNAME_WITH_NO_LOGINS = "xxx-no-logins-for-site-xxx"; + let tabOpenedPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + url => + url.includes( + `about:logins?filter=${encodeURIComponent(HOSTNAME_WITH_NO_LOGINS)}` + ), + true + ); + LoginHelper.openPasswordManager(window, { + filterString: HOSTNAME_WITH_NO_LOGINS, + entryPoint: "preferences", + }); + await tabOpenedPromise; + + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + await ContentTaskUtils.waitForCondition(() => { + return loginList._loginGuidsSortedOrder.length == 2; + }, "Waiting for logins to be cached"); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 2, + "login list should have two logins stored" + ); + + Assert.ok( + ContentTaskUtils.isHidden(loginList._list), + "the login list should be hidden when there is a search with no results" + ); + let intro = loginList.shadowRoot.querySelector(".intro"); + Assert.ok( + ContentTaskUtils.isHidden(intro), + "the intro should be hidden when there is a search with no results" + ); + let emptySearchMessage = loginList.shadowRoot.querySelector( + ".empty-search-message" + ); + Assert.ok( + ContentTaskUtils.isVisible(emptySearchMessage), + "the empty search message should be visible when there is a search with no results" + ); + + let visibleLoginListItems = loginList.shadowRoot.querySelectorAll( + "login-list-item:not([hidden])" + ); + Assert.equal(visibleLoginListItems.length, 0, "No login should be visible"); + + Assert.ok( + !loginList._createLoginButton.disabled, + "create button should be enabled" + ); + + let loginItem = content.document.querySelector("login-item"); + Assert.ok(!loginItem.dataset.isNewLogin, "should not be in create mode"); + Assert.ok(!loginItem.dataset.editing, "should not be in edit mode"); + Assert.ok( + ContentTaskUtils.isHidden(loginItem), + "login-item should be hidden when a login is not selected and we're not in create mode" + ); + let loginIntro = content.document.querySelector("login-intro"); + Assert.ok( + ContentTaskUtils.isHidden(loginIntro), + "login-intro should be hidden when a login is not selected and we're not in create mode" + ); + + loginList._createLoginButton.click(); + + Assert.ok(loginItem.dataset.isNewLogin, "should be in create mode"); + Assert.ok(loginItem.dataset.editing, "should be in edit mode"); + Assert.ok( + ContentTaskUtils.isVisible(loginItem), + "login-item should be visible in create mode" + ); + Assert.ok( + ContentTaskUtils.isHidden(loginIntro), + "login-intro should be hidden in create mode" + ); + }); +}); + +add_task(async function test_query_parameter_filter_no_login_until_backspace() { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + let tabOpenedPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:logins?filter=" + encodeURIComponent(TEST_LOGIN1.origin) + "x", + true + ); + LoginHelper.openPasswordManager(window, { + filterString: TEST_LOGIN1.origin + "x", + entryPoint: "preferences", + }); + await tabOpenedPromise; + + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + await ContentTaskUtils.waitForCondition(() => { + return loginList._loginGuidsSortedOrder.length == 2; + }, "Waiting for logins to be cached"); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 2, + "login list should have two logins stored" + ); + + Assert.ok( + ContentTaskUtils.isHidden(loginList._list), + "the login list should be hidden when there is a search with no results" + ); + + // Backspace the trailing 'x' to get matching logins + const EventUtils = ContentTaskUtils.getEventUtils(content); + EventUtils.sendChar("KEY_Backspace", content); + + let intro = loginList.shadowRoot.querySelector(".intro"); + Assert.ok( + ContentTaskUtils.isHidden(intro), + "the intro should be hidden when there is no selection" + ); + let emptySearchMessage = loginList.shadowRoot.querySelector( + ".empty-search-message" + ); + Assert.ok( + ContentTaskUtils.isHidden(emptySearchMessage), + "the empty search message should be hidden when there is matching logins" + ); + + let visibleLoginListItems = loginList.shadowRoot.querySelectorAll( + "login-list-item:not([hidden])" + ); + Assert.equal( + visibleLoginListItems.length, + 1, + "One login should be visible after backspacing" + ); + + Assert.ok( + !loginList._createLoginButton.disabled, + "create button should be enabled" + ); + + let loginItem = content.document.querySelector("login-item"); + Assert.ok(!loginItem.dataset.isNewLogin, "should not be in create mode"); + Assert.ok(!loginItem.dataset.editing, "should not be in edit mode"); + Assert.ok( + ContentTaskUtils.isHidden(loginItem), + "login-item should be hidden when a login is not selected and we're not in create mode" + ); + let loginIntro = content.document.querySelector("login-intro"); + Assert.ok( + ContentTaskUtils.isHidden(loginIntro), + "login-intro should be hidden when a login is not selected and we're not in create mode" + ); + + loginList._createLoginButton.click(); + + Assert.ok(loginItem.dataset.isNewLogin, "should be in create mode"); + Assert.ok(loginItem.dataset.editing, "should be in edit mode"); + Assert.ok( + ContentTaskUtils.isVisible(loginItem), + "login-item should be visible in create mode" + ); + Assert.ok( + ContentTaskUtils.isHidden(loginIntro), + "login-intro should be hidden in create mode" + ); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_openImport.js b/browser/components/aboutlogins/tests/browser/browser_openImport.js new file mode 100644 index 0000000000..f7b692bf1b --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openImport.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +let { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +add_setup(async function () { + await TestUtils.waitForCondition(() => { + Services.telemetry.clearEvents(); + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + return !events || !events.length; + }, "Waiting for content telemetry events to get cleared"); + + let aboutLoginsTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(aboutLoginsTab); + }); +}); + +add_task(async function test_open_import() { + let promiseWizardTab = BrowserTestUtils.waitForMigrationWizard(window); + + let browser = gBrowser.selectedBrowser; + await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser); + await SpecialPowers.spawn(browser, [], async () => { + return ContentTaskUtils.waitForCondition(() => { + let menuButton = Cu.waiveXrays( + content.document.querySelector("menu-button") + ); + return !menuButton.shadowRoot.querySelector(".menu").hidden; + }, "waiting for menu to open"); + }); + + function getImportItem() { + let menuButton = window.document.querySelector("menu-button"); + return menuButton.shadowRoot.querySelector(".menuitem-import-browser"); + } + await BrowserTestUtils.synthesizeMouseAtCenter(getImportItem, {}, browser); + + info("waiting for migration wizard to open"); + let wizardTab = await promiseWizardTab; + Assert.ok(wizardTab, "Migration wizard opened"); + + // First event is for opening about:logins + await LoginTestUtils.telemetry.waitForEventCount(2); + TelemetryTestUtils.assertEvents( + [["pwmgr", "mgmt_menu_item_used", "import_from_browser"]], + { category: "pwmgr", method: "mgmt_menu_item_used" }, + { process: "content" } + ); + + await BrowserTestUtils.removeTab(wizardTab); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_openImportCSV.js b/browser/components/aboutlogins/tests/browser/browser_openImportCSV.js new file mode 100644 index 0000000000..4800b8a309 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openImportCSV.js @@ -0,0 +1,411 @@ +/* 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 { FileTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/FileTestUtils.sys.mjs" +); + +let { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +let { MockFilePicker } = SpecialPowers; + +/** + * A helper class to deal with Login CSV import UI. + */ +class CsvImportHelper { + /** + * Waits until the mock file picker is opened and sets the destFilePath as it's selected file. + * + * @param {nsIFile} destFile + * The file being passed to the picker. + * @returns {string} A promise that is resolved when the picker selects the file. + */ + static waitForOpenFilePicker(destFile) { + return new Promise(resolve => { + MockFilePicker.showCallback = fp => { + info("showCallback"); + info("fileName: " + destFile.path); + MockFilePicker.setFiles([destFile]); + MockFilePicker.filterIndex = 1; + info("done showCallback"); + resolve(); + }; + }); + } + + /** + * Clicks the 3 dot menu and then "Import from a file..." and then it serves a CSV file. + * It also does the needed assertions and telemetry validations. + * If you await for it to return, it will have processed the CSV file already. + * + * @param {browser} browser + * The browser object. + * @param {string[]} linesInFile + * An array of strings to be used to generate the CSV file. Each string is a line. + * @returns {Promise} A promise that is resolved when the picker selects the file. + */ + static async clickImportFromCsvMenu(browser, linesInFile) { + MockFilePicker.init(window); + MockFilePicker.returnValue = MockFilePicker.returnOK; + let csvFile = await LoginTestUtils.file.setupCsvFileWithLines(linesInFile); + + await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser); + + await SpecialPowers.spawn(browser, [], async () => { + let menuButton = content.document.querySelector("menu-button"); + return ContentTaskUtils.waitForCondition(function waitForMenu() { + return !menuButton.shadowRoot.querySelector(".menu").hidden; + }, "waiting for menu to open"); + }); + + Services.telemetry.clearEvents(); + + function getImportMenuItem() { + let menuButton = window.document.querySelector("menu-button"); + let importButton = menuButton.shadowRoot.querySelector( + ".menuitem-import-file" + ); + // Force the menu item to be visible for the test. + importButton.hidden = false; + return importButton; + } + + BrowserTestUtils.synthesizeMouseAtCenter(getImportMenuItem, {}, browser); + + async function waitForFilePicker() { + let filePickerPromise = CsvImportHelper.waitForOpenFilePicker(csvFile); + // First event is for opening about:logins + await LoginTestUtils.telemetry.waitForEventCount( + 1, + "content", + "pwmgr", + "mgmt_menu_item_used" + ); + TelemetryTestUtils.assertEvents( + [["pwmgr", "mgmt_menu_item_used", "import_from_csv"]], + { category: "pwmgr", method: "mgmt_menu_item_used" }, + { process: "content", clear: false } + ); + + info("waiting for Import file picker to get opened"); + await filePickerPromise; + Assert.ok(true, "Import file picker opened"); + } + + await waitForFilePicker(); + } + + /** + * An utility method to fetch the data from the CSV import success dialog. + * + * @param {browser} browser + * The browser object. + * @returns {Promise} A promise that contains added, modified, noChange and errors count. + */ + static async getCsvImportSuccessDialogData(browser) { + return SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("import-summary-dialog") + ); + await ContentTaskUtils.waitForCondition( + () => !dialog.hidden, + "Waiting for the dialog to be visible" + ); + + let added = dialog.shadowRoot.querySelector( + ".import-items-added .result-count" + ).textContent; + let modified = dialog.shadowRoot.querySelector( + ".import-items-modified .result-count" + ).textContent; + let noChange = dialog.shadowRoot.querySelector( + ".import-items-no-change .result-count" + ).textContent; + let errors = dialog.shadowRoot.querySelector( + ".import-items-errors .result-count" + ).textContent; + const dialogData = { + added, + modified, + noChange, + errors, + }; + if (dialog.shadowRoot.activeElement) { + dialogData.l10nFocused = + dialog.shadowRoot.activeElement.getAttribute("data-l10n-id"); + } + return dialogData; + }); + } + + /** + * An utility method to fetch the data from the CSV import error dialog. + * + * @param {browser} browser + * The browser object. + * @returns {Promise} A promise that contains the hidden state and l10n id for title, description and focused element. + */ + static async getCsvImportErrorDialogData(browser) { + return SpecialPowers.spawn(browser, [], async () => { + const dialog = Cu.waiveXrays( + content.document.querySelector("import-error-dialog") + ); + const l10nTitle = dialog._genericDialog + .querySelector(".error-title") + .getAttribute("data-l10n-id"); + const l10nDescription = dialog._genericDialog + .querySelector(".error-description") + .getAttribute("data-l10n-id"); + return { + hidden: dialog.hidden, + l10nFocused: + dialog.shadowRoot.activeElement.getAttribute("data-l10n-id"), + l10nTitle, + l10nDescription, + }; + }); + } + + /** + * An utility method to wait until CSV import is complete. + * + * @returns {Promise} A promise that gets resolved when the import is complete. + */ + static async waitForImportToComplete() { + info("Waiting for the import to complete"); + await LoginTestUtils.telemetry.waitForEventCount(1, "parent"); + TelemetryTestUtils.assertEvents( + [["pwmgr", "mgmt_menu_item_used", "import_csv_complete"]], + { category: "pwmgr", method: "mgmt_menu_item_used" }, + { process: "parent" } + ); + } + + /** + * An utility method open the about:loginsimportreport page. + * + * @param {browser} browser + * The browser object. + * @returns {Promise} A promise that contains the about:loginsimportreport tab. + */ + static async clickDetailedReport(browser) { + let loadedReportTab = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:loginsimportreport", + true + ); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("import-summary-dialog") + ); + await ContentTaskUtils.waitForCondition( + () => !dialog.hidden, + "Waiting for the dialog to be visible" + ); + let detailedReportLink = dialog.shadowRoot.querySelector( + ".open-detailed-report" + ); + + detailedReportLink.click(); + }); + return loadedReportTab; + } + + /** + * An utility method to fetch data from the about:loginsimportreport page. + * + * @param {browser} browser + * The browser object. + * @returns {Promise} A promise that contains the detailed report data like added, modified, noChange, errors and rows. + */ + static async getDetailedReportData(browser) { + const data = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async () => { + function getCount(selector) { + const attribute = content.document + .querySelector(selector) + .getAttribute("data-l10n-args"); + return JSON.parse(attribute).count; + } + const rows = []; + for (let element of content.document.querySelectorAll(".row-details")) { + rows.push(element.getAttribute("data-l10n-id")); + } + const added = getCount(".new-logins"); + const modified = getCount(".exiting-logins"); + const noChange = getCount(".duplicate-logins"); + const errors = getCount(".errors-logins"); + return { + rows, + added, + modified, + noChange, + errors, + }; + } + ); + return data; + } +} + +const random = Math.round(Math.random() * 100000001); + +add_setup(async function () { + registerCleanupFunction(() => { + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_open_import_one_item_from_csv() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:logins" }, + async browser => { + await CsvImportHelper.clickImportFromCsvMenu(browser, [ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + `https://example.com,joe${random}@example.com,qwerty,My realm,,{${random}-e194-4279-ae1b-d7d281bb46f0},1589617814635,1589710449871,1589617846802`, + ]); + await CsvImportHelper.waitForImportToComplete(); + + let summary = await CsvImportHelper.getCsvImportSuccessDialogData( + browser + ); + Assert.equal(summary.added, "1", "It should have one item as added"); + Assert.equal( + summary.l10nFocused, + "about-logins-import-dialog-done", + "dismiss button should be focused" + ); + } + ); +}); + +add_task(async function test_open_import_all_four_categories() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:logins" }, + async browser => { + const initialCsvData = [ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + `https://example1.com,existing${random},existing,,,{${random}-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924000`, + `https://example1.com,duplicate,duplicate,,,{dddd0080-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363`, + ]; + const updatedCsvData = [ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + `https://example1.com,added${random},added,,,,,,`, + `https://example1.com,existing${random},modified,,,{${random}-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363`, + `https://example1.com,duplicate,duplicate,,,{dddd0080-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363`, + `https://example1.com,error,,,,,,,`, + ]; + + await CsvImportHelper.clickImportFromCsvMenu(browser, initialCsvData); + await CsvImportHelper.waitForImportToComplete(); + await BrowserTestUtils.synthesizeMouseAtCenter( + "dismiss-button", + {}, + browser + ); + await CsvImportHelper.clickImportFromCsvMenu(browser, updatedCsvData); + await CsvImportHelper.waitForImportToComplete(); + + let summary = await CsvImportHelper.getCsvImportSuccessDialogData( + browser + ); + Assert.equal(summary.added, "1", "It should have one item as added"); + Assert.equal( + summary.modified, + "1", + "It should have one item as modified" + ); + Assert.equal( + summary.noChange, + "1", + "It should have one item as unchanged" + ); + Assert.equal(summary.errors, "1", "It should have one item as error"); + } + ); +}); + +add_task(async function test_open_import_all_four_detailed_report() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:logins" }, + async browser => { + const initialCsvData = [ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + `https://example2.com,existing${random},existing,,,{${random}-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924000`, + "https://example2.com,duplicate,duplicate,,,{dddd0080-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363", + ]; + const updatedCsvData = [ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + `https://example2.com,added${random},added,,,,,,`, + `https://example2.com,existing${random},modified,,,{${random}-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363`, + "https://example2.com,duplicate,duplicate,,,{dddd0080-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363", + "https://example2.com,error,,,,,,,", + ]; + + await CsvImportHelper.clickImportFromCsvMenu(browser, initialCsvData); + await CsvImportHelper.waitForImportToComplete(); + await BrowserTestUtils.synthesizeMouseAtCenter( + "dismiss-button", + {}, + browser + ); + await CsvImportHelper.clickImportFromCsvMenu(browser, updatedCsvData); + await CsvImportHelper.waitForImportToComplete(); + const reportTab = await CsvImportHelper.clickDetailedReport(browser); + const report = await CsvImportHelper.getDetailedReportData(browser); + BrowserTestUtils.removeTab(reportTab); + const { added, modified, noChange, errors, rows } = report; + Assert.equal(added, 1, "It should have one item as added"); + Assert.equal(modified, 1, "It should have one item as modified"); + Assert.equal(noChange, 1, "It should have one item as unchanged"); + Assert.equal(errors, 1, "It should have one item as error"); + Assert.deepEqual( + [ + "about-logins-import-report-row-description-added2", + "about-logins-import-report-row-description-modified2", + "about-logins-import-report-row-description-no-change2", + "about-logins-import-report-row-description-error-missing-field", + ], + rows, + "It should have expected rows in order" + ); + } + ); +}); + +add_task(async function test_open_import_from_csv_with_invalid_file() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:logins" }, + async browser => { + await CsvImportHelper.clickImportFromCsvMenu(browser, [ + "invalid csv file", + ]); + + info("Waiting for the import error dialog"); + const errorDialog = await CsvImportHelper.getCsvImportErrorDialogData( + browser + ); + Assert.equal(errorDialog.hidden, false, "Dialog should not be hidden"); + Assert.equal( + errorDialog.l10nTitle, + "about-logins-import-dialog-error-file-format-title", + "Dialog error title should be correct" + ); + Assert.equal( + errorDialog.l10nDescription, + "about-logins-import-dialog-error-file-format-description", + "Dialog error description should be correct" + ); + Assert.equal( + errorDialog.l10nFocused, + "about-logins-import-dialog-error-learn-more", + "Learn more link should be focused." + ); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_openPreferences.js b/browser/components/aboutlogins/tests/browser/browser_openPreferences.js new file mode 100644 index 0000000000..57ca74ba87 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openPreferences.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +let { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +add_setup(async function () { + await TestUtils.waitForCondition(() => { + Services.telemetry.clearEvents(); + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + return !events || !events.length; + }, "Waiting for content telemetry events to get cleared"); + + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test_open_preferences() { + // We want to make sure we visit about:preferences#privacy-logins , as that is + // what causes us to scroll to and highlight the "logins" section. However, + // about:preferences will redirect the URL, so the eventual load event will happen + // on about:preferences#privacy . The `wantLoad` parameter we pass to + // `waitForNewTab` needs to take this into account: + let seenFirstURL = false; + let promiseNewTab = BrowserTestUtils.waitForNewTab( + gBrowser, + url => { + if (url == "about:preferences#privacy-logins") { + seenFirstURL = true; + return true; + } else if (url == "about:preferences#privacy") { + Assert.ok( + seenFirstURL, + "Must have seen an onLocationChange notification for the privacy-logins hash" + ); + return true; + } + return false; + }, + true + ); + + let browser = gBrowser.selectedBrowser; + await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser); + await SpecialPowers.spawn(browser, [], async () => { + return ContentTaskUtils.waitForCondition(() => { + let menuButton = Cu.waiveXrays( + content.document.querySelector("menu-button") + ); + return !menuButton.shadowRoot.querySelector(".menu").hidden; + }, "waiting for menu to open"); + }); + + function getPrefsItem() { + let menuButton = window.document.querySelector("menu-button"); + return menuButton.shadowRoot.querySelector(".menuitem-preferences"); + } + await BrowserTestUtils.synthesizeMouseAtCenter(getPrefsItem, {}, browser); + + info("waiting for new tab to get opened"); + let newTab = await promiseNewTab; + Assert.ok(true, "New tab opened to about:preferences"); + + BrowserTestUtils.removeTab(newTab); + + // First event is for opening about:logins + await LoginTestUtils.telemetry.waitForEventCount(2); + TelemetryTestUtils.assertEvents( + [["pwmgr", "mgmt_menu_item_used", "preferences"]], + { category: "pwmgr", method: "mgmt_menu_item_used" }, + { process: "content" } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_openPreferencesExternal.js b/browser/components/aboutlogins/tests/browser/browser_openPreferencesExternal.js new file mode 100644 index 0000000000..e4290371fb --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openPreferencesExternal.js @@ -0,0 +1,65 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test_open_feedback() { + const menuArray = [ + { + urlFinal: + "https://example.com/password-manager-remember-delete-edit-logins", + urlBase: "https://example.com/", + pref: "app.support.baseURL", + selector: ".menuitem-help", + }, + ]; + + for (const { urlFinal, urlBase, pref, selector } of menuArray) { + info("Test on " + urlFinal); + + await SpecialPowers.pushPrefEnv({ + set: [[pref, urlBase]], + }); + + let promiseNewTab = BrowserTestUtils.waitForNewTab(gBrowser, urlFinal); + + let browser = gBrowser.selectedBrowser; + await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser); + await SpecialPowers.spawn(browser, [], async () => { + return ContentTaskUtils.waitForCondition(() => { + let menuButton = content.document.querySelector("menu-button"); + return !menuButton.shadowRoot.querySelector(".menu").hidden; + }, "waiting for menu to open"); + }); + + // Not using synthesizeMouseAtCenter here because the element we want clicked on + // is in the shadow DOM and therefore requires using a function 1st argument + // to BrowserTestUtils.synthesizeMouseAtCenter but we need to pass an + // arbitrary selector. See bug 1557489 for more info. As a workaround, this + // manually calculates the position to click. + let { x, y } = await SpecialPowers.spawn( + browser, + [selector], + async menuItemSelector => { + let menuButton = content.document.querySelector("menu-button"); + let prefsItem = menuButton.shadowRoot.querySelector(menuItemSelector); + return prefsItem.getBoundingClientRect(); + } + ); + await BrowserTestUtils.synthesizeMouseAtPoint(x + 5, y + 5, {}, browser); + + info("waiting for new tab to get opened"); + let newTab = await promiseNewTab; + Assert.ok(true, "New tab opened to" + urlFinal); + + BrowserTestUtils.removeTab(newTab); + } +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_openSite.js b/browser/components/aboutlogins/tests/browser/browser_openSite.js new file mode 100644 index 0000000000..f33d57a8e4 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openSite.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_launch_login_item() { + let promiseNewTab = BrowserTestUtils.waitForNewTab( + gBrowser, + TEST_LOGIN1.origin + "/" + ); + + let browser = gBrowser.selectedBrowser; + + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let originInput = loginItem.shadowRoot.querySelector("a[name='origin']"); + let EventUtils = ContentTaskUtils.getEventUtils(content); + // Use synthesizeMouseAtCenter to generate an event that more closely resembles the + // properties of the event object that will be seen when the user clicks the element + // (.click() sets originalTarget while synthesizeMouse has originalTarget as a Restricted object). + await EventUtils.synthesizeMouseAtCenter(originInput, {}, content); + }); + + info("waiting for new tab to get opened"); + let newTab = await promiseNewTab; + Assert.ok(true, "New tab opened to " + TEST_LOGIN1.origin); + BrowserTestUtils.removeTab(newTab); + + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + return; + } + + promiseNewTab = BrowserTestUtils.waitForNewTab( + gBrowser, + TEST_LOGIN1.origin + "/" + ); + let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + loginItem._editButton.click(); + }); + await reauthObserved; + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + loginItem._usernameInput.value += "-changed"; + + Assert.ok( + content.document.querySelector("confirmation-dialog").hidden, + "discard-changes confirmation-dialog should be hidden before opening the site" + ); + + let originInput = loginItem.shadowRoot.querySelector("a[name='origin']"); + let EventUtils = ContentTaskUtils.getEventUtils(content); + // Use synthesizeMouseAtCenter to generate an event that more closely resembles the + // properties of the event object that will be seen when the user clicks the element + // (.click() sets originalTarget while synthesizeMouse has originalTarget as a Restricted object). + await EventUtils.synthesizeMouseAtCenter(originInput, {}, content); + }); + + info("waiting for new tab to get opened"); + newTab = await promiseNewTab; + Assert.ok(true, "New tab opened to " + TEST_LOGIN1.origin); + + let modifiedLogin = TEST_LOGIN1.clone(); + modifiedLogin.timeLastUsed = 9000; + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + Services.logins.modifyLogin(TEST_LOGIN1, modifiedLogin); + await storageChangedPromised; + + BrowserTestUtils.removeTab(newTab); + + await SpecialPowers.spawn(browser, [], async () => { + await ContentTaskUtils.waitForCondition(() => { + return !content.document.querySelector("confirmation-dialog").hidden; + }, "waiting for confirmation-dialog to appear"); + Assert.ok( + !content.document.querySelector("confirmation-dialog").hidden, + "discard-changes confirmation-dialog should be visible after logging in to a site with a modified login present in the form" + ); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js b/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js new file mode 100644 index 0000000000..9c2688cc77 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js @@ -0,0 +1,165 @@ +/* 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 + }` + ); + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + Assert.ok( + true, + `skipping test since oskeystore cannot be automated in this environment` + ); + 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); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + 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" + ); + }); + 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" + ); + revealCheckbox.click(); + }); + await osAuthDialogShown; + info("OS auth dialog shown and authenticated"); + 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 checked if OS auth dialog authenticated" + ); + }); + + info("'Edit' shouldn't show the prompt since the user has authenticated now"); + 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 editButton = loginItem.shadowRoot.querySelector("edit-button"); + editButton.click(); + + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "waiting for 'edit' mode" + ); + 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); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + + // 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( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + }); + await osAuthDialogShown; + info("OS auth dialog shown and canceled"); + + // Show OS auth dialog since the previous attempt was canceled + 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" + ); + revealCheckbox.click(); + info("clicking on reveal checkbox to hide the password"); + revealCheckbox.click(); + }); + await osAuthDialogShown; + info("OS auth dialog shown and passed"); + + // Show OS auth dialog since the timeout will have expired + osAuthDialogShown = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: 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"); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + + info("'Edit' shouldn't show the prompt since the feature has been disabled"); + 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 editButton = loginItem.shadowRoot.querySelector("edit-button"); + editButton.click(); + + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "waiting for 'edit' mode" + ); + Assert.ok(loginItem.dataset.editing, "In edit mode"); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_primaryPassword.js b/browser/components/aboutlogins/tests/browser/browser_primaryPassword.js new file mode 100644 index 0000000000..896a6f7f3e --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_primaryPassword.js @@ -0,0 +1,272 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function waitForLoginCountToReach(browser, loginCount) { + return SpecialPowers.spawn( + browser, + [loginCount], + async expectedLoginCount => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition(() => { + return loginList._loginGuidsSortedOrder.length == expectedLoginCount; + }); + return loginList._loginGuidsSortedOrder.length; + } + ); +} + +add_setup(async function () { + await addLogin(TEST_LOGIN1); + registerCleanupFunction(() => { + Services.logins.removeAllUserFacingLogins(); + LoginTestUtils.primaryPassword.disable(); + }); +}); + +add_task(async function test() { + // Confirm that the mocking of the OS auth dialog isn't enabled so the + // test will timeout if a real OS auth dialog is shown. We don't show + // the OS auth dialog when Primary Password is enabled. + Assert.equal( + Services.prefs.getStringPref( + "toolkit.osKeyStore.unofficialBuildOnlyLogin", + "" + ), + "", + "Pref should be set to default value of empty string to start the test" + ); + LoginTestUtils.primaryPassword.enable(); + + let mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("cancel"); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + await mpDialogShown; + + let browser = gBrowser.selectedBrowser; + let logins = await waitForLoginCountToReach(browser, 0); + Assert.equal( + logins, + 0, + "No logins should be displayed when MP is set and unauthenticated" + ); + + let notification; + await TestUtils.waitForCondition( + () => + (notification = gBrowser + .getNotificationBox() + .getNotificationWithValue("primary-password-login-required")), + "waiting for primary-password-login-required notification" + ); + + Assert.ok( + notification, + "primary-password-login-required notification should be visible" + ); + + let buttons = notification.buttonContainer.querySelectorAll( + ".notification-button" + ); + Assert.equal(buttons.length, 1, "Should have one button."); + + let refreshPromise = BrowserTestUtils.browserLoaded(browser); + // Sign in with the Primary Password this time the dialog is shown + mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("authenticate"); + // Click the button to reload the page. + buttons[0].click(); + await refreshPromise; + info("Page reloaded"); + + await mpDialogShown; + info("Primary Password dialog shown and authenticated"); + + logins = await waitForLoginCountToReach(browser, 1); + Assert.equal( + logins, + 1, + "Logins should be displayed when MP is set and authenticated" + ); + + // Show MP dialog when Copy Password button clicked + mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("cancel"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let copyButton = loginItem.shadowRoot.querySelector("copy-password-button"); + copyButton.click(); + }); + await mpDialogShown; + info("Primary Password dialog shown and canceled"); + mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("authenticate"); + info("Clicking copy password button again"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let copyButton = loginItem.shadowRoot.querySelector("copy-password-button"); + copyButton.click(); + }); + await mpDialogShown; + info("Primary Password dialog shown and authenticated"); + await SpecialPowers.spawn(browser, [], async function () { + info("Password was copied to clipboard"); + }); + + // Show MP dialog when Reveal Password checkbox is checked if not on a new login + mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("cancel"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + }); + await mpDialogShown; + info("Primary Password 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 MP dialog canceled" + ); + }); + mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("authenticate"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + }); + await mpDialogShown; + info("Primary Password dialog shown and authenticated"); + 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 checked if MP dialog authenticated" + ); + }); + + info("Test toggling the password visibility on a new login"); + await SpecialPowers.spawn(browser, [], async function createNewToggle() { + let createButton = content.document + .querySelector("login-list") + .shadowRoot.querySelector("create-login-button"); + createButton.click(); + + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let passwordField = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + Assert.ok( + !ContentTaskUtils.isVisible(revealCheckbox), + "Toggle should not be visible" + ); + Assert.equal(passwordField.type, "password", "type is password"); + passwordField.focus(); + + await ContentTaskUtils.waitForCondition(() => { + return passwordField.type == "text"; + }, "Waiting for type='text'"); + + let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button"); + cancelButton.click(); + }); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + const loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + + const loginFilter = Cu.waiveXrays( + loginList.shadowRoot.querySelector("login-filter") + ); + loginFilter.value = "pass1"; + Assert.equal( + loginList._list.querySelectorAll( + "login-list-item[data-guid]:not([hidden])" + ).length, + 1, + "login-list should show corresponding result when primary password is enabled" + ); + loginFilter.value = ""; + Assert.equal( + loginList._list.querySelectorAll( + "login-list-item[data-guid]:not([hidden])" + ).length, + 1, + "login-list should show all results since the filter is empty" + ); + }); + LoginTestUtils.primaryPassword.disable(); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + Cu.waiveXrays(content).AboutLoginsUtils.primaryPasswordEnabled = false; + const loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + const loginFilter = Cu.waiveXrays( + loginList.shadowRoot.querySelector("login-filter") + ); + loginFilter.value = "pass1"; + Assert.equal( + loginList._list.querySelectorAll( + "login-list-item[data-guid]:not([hidden])" + ).length, + 1, + "login-list should show login with matching password since MP is disabled" + ); + }); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +add_task(async function test_login_item_after_successful_auth() { + // Confirm that the mocking of the OS auth dialog isn't enabled so the + // test will timeout if a real OS auth dialog is shown. We don't show + // the OS auth dialog when Primary Password is enabled. + Assert.equal( + Services.prefs.getStringPref( + "toolkit.osKeyStore.unofficialBuildOnlyLogin", + "" + ), + "", + "Pref should be set to default value of empty string to start the test" + ); + LoginTestUtils.primaryPassword.enable(); + + let mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("authenticate"); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + await mpDialogShown; + + let browser = gBrowser.selectedBrowser; + let logins = await waitForLoginCountToReach(browser, 1); + Assert.equal( + logins, + 1, + "Logins should be displayed when MP is set and authenticated" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + !loginItem.classList.contains("no-logins"), + "Login item should have content after MP is authenticated" + ); + }); + + LoginTestUtils.primaryPassword.disable(); + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_removeAllDialog.js b/browser/components/aboutlogins/tests/browser/browser_removeAllDialog.js new file mode 100644 index 0000000000..c5879ceeaf --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_removeAllDialog.js @@ -0,0 +1,558 @@ +/* Any copyright is dedicated to the Public Domain. + * 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); + await SpecialPowers.spawn(browser, [], async () => { + let menuButton = content.document.querySelector("menu-button"); + let menu = menuButton.shadowRoot.querySelector("ul.menu"); + await ContentTaskUtils.waitForCondition(() => !menu.hidden); + }); + function getRemoveAllMenuButton() { + let menuButton = window.document.querySelector("menu-button"); + return menuButton.shadowRoot.querySelector(".menuitem-remove-all-logins"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getRemoveAllMenuButton, + {}, + browser + ); + info("remove all dialog should be opened"); +} + +async function activateLoginItemEdit(browser) { + await SimpleTest.promiseFocus(browser); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok(loginItem, "Login item should exist"); + }); + function getLoginItemEditButton() { + let loginItem = window.document.querySelector("login-item"); + return loginItem.shadowRoot.querySelector("edit-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getLoginItemEditButton, + {}, + browser + ); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + let editButton = loginItem.shadowRoot + .querySelector("edit-button") + .shadowRoot.querySelector("button"); + editButton.click(); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "Waiting for login-item to enter edit mode" + ); + }); + info("login-item should be in edit mode"); +} + +async function activateCreateNewLogin(browser) { + await SimpleTest.promiseFocus(browser); + function getCreateNewLoginButton() { + let loginList = window.document.querySelector("login-list"); + return loginList.shadowRoot.querySelector("create-login-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getCreateNewLoginButton, + {}, + browser + ); +} + +async function waitForRemoveAllLogins() { + return new Promise(resolve => { + Services.obs.addObserver(function observer(subject, topic, changeType) { + if (changeType != "removeAllLogins") { + return; + } + + Services.obs.removeObserver(observer, "passwordmgr-storage-changed"); + resolve(); + }, "passwordmgr-storage-changed"); + }); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [[OS_REAUTH_PREF, false]], + }); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + await SpecialPowers.popPrefEnv(); + }); + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); +}); + +add_task(async function test_remove_all_dialog_l10n() { + Assert.ok(TEST_LOGIN1, "test_login1"); + let browser = gBrowser.selectedBrowser; + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + const EventUtils = ContentTaskUtils.getEventUtils(content); + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + Assert.ok(!dialog.hidden); + let title = dialog.shadowRoot.querySelector(".title"); + let message = dialog.shadowRoot.querySelector(".message"); + let label = dialog.shadowRoot.querySelector(".checkbox-text"); + let cancelButton = dialog.shadowRoot.querySelector(".cancel-button"); + let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button"); + await content.document.l10n.translateElements([ + title, + message, + label, + cancelButton, + removeAllButton, + ]); + Assert.equal( + title.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-title2", + "Title contents should match l10n-id attribute set on element" + ); + Assert.equal( + message.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-message2", + "Message contents should match l10n-id attribute set on element" + ); + Assert.equal( + label.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-checkbox-label2", + "Label contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + cancelButton.dataset.l10nId, + "confirmation-dialog-cancel-button", + "Cancel button contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + removeAllButton.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-confirm-button-label", + "Remove all button contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + JSON.parse(title.dataset.l10nArgs).count, + 1, + "Title contents should match l10n-args attribute set on element" + ); + Assert.equal( + JSON.parse(message.dataset.l10nArgs).count, + 1, + "Message contents should match l10n-args attribute set on element" + ); + Assert.equal( + JSON.parse(label.dataset.l10nArgs).count, + 1, + "Label contents should match l10n-id attribute set on outer element" + ); + EventUtils.synthesizeMouseAtCenter( + dialog.shadowRoot.querySelector(".cancel-button"), + {}, + content + ); + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden after clicking cancel button" + ); + }); +}); + +add_task(async function test_remove_all_dialog_keyboard_navigation() { + let browser = gBrowser.selectedBrowser; + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + const EventUtils = ContentTaskUtils.getEventUtils(content); + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + let cancelButton = dialog.shadowRoot.querySelector(".cancel-button"); + let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button"); + Assert.equal( + removeAllButton.disabled, + true, + "Remove all should be disabled on dialog open" + ); + await EventUtils.synthesizeKey(" ", {}, content); + Assert.equal( + removeAllButton.disabled, + false, + "Remove all should be enabled when activating the checkbox" + ); + await EventUtils.synthesizeKey(" ", {}, content); + Assert.equal( + removeAllButton.disabled, + true, + "Remove all should be disabled after deactivating the checkbox" + ); + await EventUtils.synthesizeKey("KEY_Tab", {}, content); + Assert.equal( + dialog.shadowRoot.activeElement, + cancelButton, + "Cancel button should be the next element in tab order" + ); + await EventUtils.synthesizeKey(" ", {}, content); + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden after activating cancel button via Space key" + ); + }); + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + await EventUtils.synthesizeKey("KEY_Escape", {}, content); + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden after activating Escape key" + ); + }); + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + let dismissButton = dialog.shadowRoot.querySelector(".dismiss-button"); + await EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }, content); + Assert.equal( + dialog.shadowRoot.activeElement, + dismissButton, + "dismiss button should be focused" + ); + await EventUtils.synthesizeKey(" ", {}, content); + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden after activating X button" + ); + }); +}); + +add_task(async function test_remove_all_dialog_remove_logins() { + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + let browser = gBrowser.selectedBrowser; + let removeAllPromise = waitForRemoveAllLogins(); + + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + let title = dialog.shadowRoot.querySelector(".title"); + let message = dialog.shadowRoot.querySelector(".message"); + let label = dialog.shadowRoot.querySelector(".checkbox-text"); + let cancelButton = dialog.shadowRoot.querySelector(".cancel-button"); + let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button"); + + let checkbox = dialog.shadowRoot.querySelector(".checkbox"); + + await content.document.l10n.translateElements([ + title, + message, + cancelButton, + removeAllButton, + label, + checkbox, + ]); + Assert.equal( + dialog.shadowRoot.activeElement, + checkbox, + "Checkbox should be the focused element on dialog open" + ); + Assert.equal( + title.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-title2", + "Title contents should match l10n-id attribute set on element" + ); + Assert.equal( + JSON.parse(title.dataset.l10nArgs).count, + 2, + "Title contents should match l10n-args attribute set on element" + ); + Assert.equal( + message.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-message2", + "Message contents should match l10n-id attribute set on element" + ); + Assert.equal( + JSON.parse(message.dataset.l10nArgs).count, + 2, + "Message contents should match l10n-args attribute set on element" + ); + Assert.equal( + label.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-checkbox-label2", + "Label contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + JSON.parse(label.dataset.l10nArgs).count, + 2, + "Label contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + cancelButton.dataset.l10nId, + "confirmation-dialog-cancel-button", + "Cancel button contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + removeAllButton.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-confirm-button-label", + "Remove all button contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + removeAllButton.disabled, + true, + "Remove all button should be disabled on dialog open" + ); + }); + function activateConfirmCheckbox() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".checkbox"); + } + + await BrowserTestUtils.synthesizeMouseAtCenter( + activateConfirmCheckbox, + {}, + browser + ); + + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button"); + Assert.equal( + removeAllButton.disabled, + false, + "Remove all should be enabled after clicking the checkbox" + ); + }); + function getDialogRemoveAllButton() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".confirm-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getDialogRemoveAllButton, + {}, + browser + ); + await removeAllPromise; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + await ContentTaskUtils.waitForCondition( + () => content.document.documentElement.classList.contains("no-logins"), + "Waiting for no logins view since all logins should be deleted" + ); + await ContentTaskUtils.waitForCondition( + () => + !content.document.documentElement.classList.contains("login-selected"), + "Waiting for the FxA Sync illustration to reappear" + ); + await ContentTaskUtils.waitForCondition( + () => loginList.classList.contains("no-logins"), + "Waiting for login-list to be in no logins view as all logins should be deleted" + ); + }); + await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser); + await SpecialPowers.spawn(browser, [], async () => { + let menuButton = content.document.querySelector("menu-button"); + let removeAllMenuButton = menuButton.shadowRoot.querySelector( + ".menuitem-remove-all-logins" + ); + Assert.ok( + removeAllMenuButton.disabled, + "Remove all logins menu button is disabled if there are no logins" + ); + }); + await SpecialPowers.spawn(browser, [], async () => { + let menuButton = Cu.waiveXrays( + content.document.querySelector("menu-button") + ); + let menu = menuButton.shadowRoot.querySelector("ul.menu"); + await EventUtils.synthesizeKey("KEY_Escape", {}, content); + await ContentTaskUtils.waitForCondition( + () => menu.hidden, + "Waiting for menu to close" + ); + }); +}); + +add_task(async function test_edit_mode_resets_on_remove_all_with_login() { + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + let removeAllPromise = waitForRemoveAllLogins(); + let browser = gBrowser.selectedBrowser; + await activateLoginItemEdit(browser); + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + loginItem.dataset.editing, + "Login item is still in edit mode when the remove all dialog opens" + ); + }); + function getDialogCancelButton() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".cancel-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getDialogCancelButton, + {}, + browser + ); + await TestUtils.waitForTick(); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + loginItem.dataset.editing, + "Login item should be in editing mode after activating the cancel button in the remove all dialog" + ); + }); + + await openRemoveAllDialog(browser); + function activateConfirmCheckbox() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".checkbox"); + } + + await BrowserTestUtils.synthesizeMouseAtCenter( + activateConfirmCheckbox, + {}, + browser + ); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button"); + Assert.equal( + removeAllButton.disabled, + false, + "Remove all should be enabled after clicking the checkbox" + ); + }); + function getDialogRemoveAllButton() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".confirm-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getDialogRemoveAllButton, + {}, + browser + ); + await TestUtils.waitForTick(); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + !loginItem.dataset.editing, + "Login item should not be in editing mode after activating the confirm button in the remove all dialog" + ); + }); + await removeAllPromise; +}); + +add_task(async function test_remove_all_when_creating_new_login() { + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + let removeAllPromise = waitForRemoveAllLogins(); + let browser = gBrowser.selectedBrowser; + await activateCreateNewLogin(browser); + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + loginItem.dataset.editing, + "Login item should be in edit mode when the remove all dialog opens" + ); + Assert.ok( + loginItem.dataset.isNewLogin, + "Login item should be in the 'new login' state when the remove all dialog opens" + ); + }); + function getDialogCancelButton() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".cancel-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getDialogCancelButton, + {}, + browser + ); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + loginItem.dataset.editing, + "Login item is still in edit mode after cancelling out of the remove all dialog" + ); + Assert.ok( + loginItem.dataset.isNewLogin, + "Login item should be in the 'newLogin' state after cancelling out of the remove all dialog" + ); + }); + + await openRemoveAllDialog(browser); + function activateConfirmCheckbox() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".checkbox"); + } + + await BrowserTestUtils.synthesizeMouseAtCenter( + activateConfirmCheckbox, + {}, + browser + ); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button"); + Assert.equal( + removeAllButton.disabled, + false, + "Remove all should be enabled after clicking the checkbox" + ); + }); + function getDialogRemoveAllButton() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".confirm-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getDialogRemoveAllButton, + {}, + browser + ); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + !loginItem.dataset.editing, + "Login item should not be in editing mode after activating the confirm button in the remove all dialog" + ); + Assert.ok( + !loginItem.dataset.isNewLogin, + "Login item should not be in 'new login' mode after activating the confirm button in the remove all dialog" + ); + }); + await removeAllPromise; +}); + +add_task(async function test_ensure_icons_are_not_draggable() { + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + let browser = gBrowser.selectedBrowser; + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = content.document.querySelector("remove-logins-dialog"); + let warningIcon = dialog.shadowRoot.querySelector(".warning-icon"); + Assert.ok(!warningIcon.draggable, "Warning icon should not be draggable"); + let dismissIcon = dialog.shadowRoot.querySelector(".dismiss-icon"); + Assert.ok(!dismissIcon.draggable, "Dismiss icon should not be draggable"); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_sessionRestore.js b/browser/components/aboutlogins/tests/browser/browser_sessionRestore.js new file mode 100644 index 0000000000..5ab03f9867 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_sessionRestore.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +async function checkLoginDisplayed(browser, testGuid) { + await SpecialPowers.spawn(browser, [testGuid], async function (guid) { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._loginGuidsSortedOrder.length == 1 && + loginList._loginGuidsSortedOrder[0] == guid + ); + }, "Waiting for login to be displayed in page"); + Assert.ok(loginFound, "Confirming that login is displayed in page"); + }); +} + +add_task(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + registerCleanupFunction(() => { + Services.logins.removeAllUserFacingLogins(); + }); + + const testGuid = TEST_LOGIN1.guid; + const tab = BrowserTestUtils.addTab(gBrowser, "about:logins"); + const browser = gBrowser.getBrowserForTab(tab); + await BrowserTestUtils.browserLoaded(browser); + + await checkLoginDisplayed(browser, testGuid); + + BrowserTestUtils.removeTab(tab); + info("Adding a lazy about:logins tab..."); + let lazyTab = BrowserTestUtils.addTab(gBrowser, "about:logins", { + createLazyBrowser: true, + }); + + Assert.equal(lazyTab.linkedPanel, "", "Tab is lazy"); + let tabLoaded = new Promise(resolve => { + gBrowser.addTabsProgressListener({ + async onLocationChange(aBrowser) { + if (lazyTab.linkedBrowser == aBrowser) { + gBrowser.removeTabsProgressListener(this); + await Promise.resolve(); + resolve(); + } + }, + }); + }); + + info("Switching tab to cause it to get restored"); + const browserLoaded = BrowserTestUtils.browserLoaded(lazyTab.linkedBrowser); + await BrowserTestUtils.switchTab(gBrowser, lazyTab); + + await tabLoaded; + await browserLoaded; + + let lazyBrowser = lazyTab.linkedBrowser; + await checkLoginDisplayed(lazyBrowser, testGuid); + + BrowserTestUtils.removeTab(lazyTab); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_tabKeyNav.js b/browser/components/aboutlogins/tests/browser/browser_tabKeyNav.js new file mode 100644 index 0000000000..890d39a316 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_tabKeyNav.js @@ -0,0 +1,277 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +EXPECTED_BREACH = { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.example.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", +}; + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_tab_key_nav() { + const browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + // Helper function for getting the resulting DOM element given a list of selectors possibly inside shadow DOM + const selectWithShadowRootIfNeeded = (document, selectorsArray) => + selectorsArray.reduce( + (selectionSoFar, currentSelector) => + selectionSoFar.shadowRoot + ? selectionSoFar.shadowRoot.querySelector(currentSelector) + : selectionSoFar.querySelector(currentSelector), + document + ); + + const EventUtils = ContentTaskUtils.getEventUtils(content); + // list [selector, shadow root selector] for each element + // in the order we expect them to be navigated. + const expectedElementsInOrder = [ + ["login-list", "login-filter", "input"], + ["login-list", "create-login-button", "button"], + ["login-list", "select#login-sort"], + ["login-list", "ol"], + ["login-item", "edit-button", "button"], + ["login-item", "delete-button", "button"], + ["login-item", "a.origin-input"], + ["login-item", "copy-username-button", "button"], + ["login-item", "input.reveal-password-checkbox"], + ["login-item", "copy-password-button", "button"], + ]; + + const firstElement = selectWithShadowRootIfNeeded( + content.document, + expectedElementsInOrder.at(0) + ); + + const lastElement = selectWithShadowRootIfNeeded( + content.document, + expectedElementsInOrder.at(-1) + ); + + async function tab() { + EventUtils.synthesizeKey("KEY_Tab", {}, content); + await new Promise(resolve => content.requestAnimationFrame(resolve)); + // The following line can help with focus trap debugging: + // await new Promise(resolve => content.window.setTimeout(resolve, 500)); + } + async function shiftTab() { + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }, content); + await new Promise(resolve => content.requestAnimationFrame(resolve)); + // await new Promise(resolve => content.window.setTimeout(resolve, 500)); + } + + // Getting focused shadow DOM element itself instead of shadowRoot, + // using recursion for any component-nesting level, as in: + // document.activeElement.shadowRoot.activeElement.shadowRoot.activeElement + function getFocusedElement() { + let element = content.document.activeElement; + const getShadowRootFocus = e => { + if (e.shadowRoot) { + return getShadowRootFocus(e.shadowRoot.activeElement); + } + return e; + }; + return getShadowRootFocus(element); + } + + // Ensure the test starts in a valid state + firstElement.focus(); + // Assert that we tab navigate correctly + for (let expectedSelector of expectedElementsInOrder) { + const expectedElement = selectWithShadowRootIfNeeded( + 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( + actualElement, + expectedElement, + "Actual focused element should equal the expected focused element" + ); + await tab(); + } + + lastElement.focus(); + + // Assert that we shift + tab navigate correctly starting from the last ordered element + for (let expectedSelector of expectedElementsInOrder.reverse()) { + const expectedElement = selectWithShadowRootIfNeeded( + 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( + actualElement, + expectedElement, + "Actual focused element should equal the expected focused element" + ); + await shiftTab(); + } + await tab(); // tab back to the first element + }); +}); + +add_task(async function test_tab_to_create_button() { + const browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + const EventUtils = ContentTaskUtils.getEventUtils(content); + + function waitForAnimationFrame() { + return new Promise(resolve => content.requestAnimationFrame(resolve)); + } + + async function tab() { + EventUtils.synthesizeKey("KEY_Tab", {}, content); + await waitForAnimationFrame(); + } + + const loginList = content.document.querySelector("login-list"); + const loginFilter = loginList.shadowRoot.querySelector("login-filter"); + const loginSort = loginList.shadowRoot.getElementById("login-sort"); + const loginListbox = loginList.shadowRoot.querySelector("ol"); + const createButton = loginList.shadowRoot.querySelector( + "create-login-button" + ); + + const getFocusedElement = () => loginList.shadowRoot.activeElement; + Assert.equal(getFocusedElement(), loginFilter, "login-filter is focused"); + + await tab(); + Assert.equal(getFocusedElement(), createButton, "create button is focused"); + + await tab(); + Assert.equal(getFocusedElement(), loginSort, "login sort is focused"); + + await tab(); + Assert.equal(getFocusedElement(), loginListbox, "listbox is focused next"); + + await tab(); + Assert.equal(getFocusedElement(), null, "login-list isn't focused again"); + }); +}); + +add_task(async function test_tab_to_edit_button() { + TEST_LOGIN3 = await addLogin(TEST_LOGIN3); + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn( + browser, + [[TEST_LOGIN1.guid, TEST_LOGIN3.guid]], + async ([testLoginNormalGuid, testLoginBreachedGuid]) => { + const EventUtils = ContentTaskUtils.getEventUtils(content); + + function waitForAnimationFrame() { + return new Promise(resolve => content.requestAnimationFrame(resolve)); + } + + async function tab() { + EventUtils.synthesizeKey("KEY_Tab", {}, content); + await waitForAnimationFrame(); + } + + const loginList = content.document.querySelector("login-list"); + const loginItem = content.document.querySelector("login-item"); + const loginFilter = loginList.shadowRoot.querySelector("login-filter"); + const createButton = loginList.shadowRoot.querySelector( + "create-login-button" + ); + const loginSort = loginList.shadowRoot.getElementById("login-sort"); + const loginListbox = loginList.shadowRoot.querySelector("ol"); + const editButton = loginItem.shadowRoot.querySelector("edit-button"); + const breachAlert = + loginItem.shadowRoot.querySelector("login-breach-alert"); + const getFocusedElement = () => { + if (content.document.activeElement == loginList) { + return loginList.shadowRoot.activeElement; + } + if (content.document.activeElement == loginItem) { + return loginItem.shadowRoot.activeElement; + } + if (content.document.activeElement == loginFilter) { + return loginFilter.shadowRoot.activeElement; + } + Assert.ok( + false, + "not expecting a different element to get focused in this test: " + + content.document.activeElement.outerHTML + ); + return undefined; + }; + + for (let guidToSelect of [testLoginNormalGuid, testLoginBreachedGuid]) { + let loginListItem = loginList.shadowRoot.querySelector( + `login-list-item[data-guid="${guidToSelect}"]` + ); + loginListItem.click(); + await ContentTaskUtils.waitForCondition(() => { + let waivedLoginItem = Cu.waiveXrays(loginItem); + return ( + waivedLoginItem._login && + waivedLoginItem._login.guid == guidToSelect + ); + }, "waiting for login-item to show the selected login"); + + Assert.equal( + breachAlert.hidden, + guidToSelect == testLoginNormalGuid, + ".breach-alert should be hidden if the login is not breached. current login breached? " + + (guidToSelect == testLoginBreachedGuid) + ); + + createButton.shadowRoot.querySelector("button").focus(); + Assert.equal( + getFocusedElement(), + createButton, + "create button is focused" + ); + + await tab(); + Assert.equal(getFocusedElement(), loginSort, "login sort is focused"); + + await tab(); + Assert.equal( + getFocusedElement(), + loginListbox, + "listbox is focused next" + ); + + await tab(); + Assert.equal(getFocusedElement(), editButton, "edit button is focused"); + } + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_updateLogin.js b/browser/components/aboutlogins/tests/browser/browser_updateLogin.js new file mode 100644 index 0000000000..c7ed2816ff --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_updateLogin.js @@ -0,0 +1,431 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { CONCEALED_PASSWORD_TEXT } = ChromeUtils.importESModule( + "chrome://browser/content/aboutlogins/aboutLoginsUtils.mjs" +); + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_show_logins() { + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [TEST_LOGIN1.guid], async loginGuid => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._loginGuidsSortedOrder.length == 1 && + loginList._loginGuidsSortedOrder[0] == loginGuid + ); + }, "Waiting for login to be displayed"); + Assert.ok( + loginFound, + "Stored logins should be displayed upon loading the page" + ); + }); +}); + +add_task(async function test_login_item() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + return; + } + + async function test_discard_dialog( + login, + exitPointSelector, + concealedPasswordText + ) { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "Entering edit mode" + ); + await Promise.resolve(); + + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + let passwordInput = loginItem._passwordInput; + usernameInput.value += "-undome"; + passwordInput.value += "-undome"; + + let dialog = content.document.querySelector("confirmation-dialog"); + Assert.ok(dialog.hidden, "Confirm dialog should initially be hidden"); + + let exitPoint = + loginItem.shadowRoot.querySelector(exitPointSelector) || + loginList.shadowRoot.querySelector(exitPointSelector); + exitPoint.click(); + + Assert.ok(!dialog.hidden, "Confirm dialog should be visible"); + + let confirmDiscardButton = + dialog.shadowRoot.querySelector(".confirm-button"); + await content.document.l10n.translateElements([ + dialog.shadowRoot.querySelector(".title"), + dialog.shadowRoot.querySelector(".message"), + confirmDiscardButton, + ]); + + confirmDiscardButton.click(); + + Assert.ok( + dialog.hidden, + "Confirm dialog should be hidden after confirming" + ); + + await Promise.resolve(); + + let loginListItem = Cu.waiveXrays( + loginList.shadowRoot.querySelector("login-list-item[data-guid]") + ); + + loginListItem.click(); + + await ContentTaskUtils.waitForCondition( + () => usernameInput.value == login.username + ); + + Assert.equal( + usernameInput.value, + login.username, + "Username change should be reverted" + ); + Assert.equal( + passwordInput.value, + login.password, + "Password change should be reverted" + ); + let passwordDisplayInput = loginItem._passwordDisplayInput; + Assert.equal( + passwordDisplayInput.value, + concealedPasswordText, + "Password change should be reverted for display" + ); + Assert.ok( + !passwordInput.hasAttribute("value"), + "Password shouldn't be exposed in @value" + ); + Assert.equal( + passwordInput.style.width, + login.password.length + "ch", + "Password field width shouldn't have changed" + ); + } + + let browser = gBrowser.selectedBrowser; + let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await SpecialPowers.spawn( + browser, + [LoginHelper.loginToVanillaObject(TEST_LOGIN1)], + async login => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let loginListItem = Cu.waiveXrays( + loginList.shadowRoot.querySelector("login-list-item[data-guid]") + ); + loginListItem.click(); + + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let loginItemPopulated = await ContentTaskUtils.waitForCondition(() => { + return ( + loginItem._login.guid == loginListItem.dataset.guid && + loginItem._login.guid == login.guid + ); + }, "Waiting for login item to get populated"); + Assert.ok(loginItemPopulated, "The login item should get populated"); + + let editButton = loginItem.shadowRoot.querySelector("edit-button"); + editButton.click(); + } + ); + info("waiting for oskeystore auth #1"); + await reauthObserved; + await SpecialPowers.spawn( + browser, + [ + LoginHelper.loginToVanillaObject(TEST_LOGIN1), + "create-login-button", + CONCEALED_PASSWORD_TEXT, + ], + test_discard_dialog + ); + reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let editButton = loginItem.shadowRoot + .querySelector("edit-button") + .shadowRoot.querySelector("button"); + editButton.click(); + }); + info("waiting for oskeystore auth #2"); + await reauthObserved; + await SpecialPowers.spawn( + browser, + [ + LoginHelper.loginToVanillaObject(TEST_LOGIN1), + ".cancel-button", + CONCEALED_PASSWORD_TEXT, + ], + test_discard_dialog + ); + reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let editButton = loginItem.shadowRoot + .querySelector("edit-button") + .shadowRoot.querySelector("button"); + editButton.click(); + }); + info("waiting for oskeystore auth #3"); + await reauthObserved; + await SpecialPowers.spawn( + browser, + [LoginHelper.loginToVanillaObject(TEST_LOGIN1), CONCEALED_PASSWORD_TEXT], + async (login, concealedPasswordText) => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "Entering edit mode" + ); + await Promise.resolve(); + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + let passwordInput = loginItem._passwordInput; + let passwordDisplayInput = loginItem._passwordDisplayInput; + + Assert.ok( + loginItem.dataset.editing, + "LoginItem should be in 'edit' mode" + ); + Assert.equal( + passwordInput.type, + "password", + "Password should still be hidden before revealed in edit mode" + ); + + passwordDisplayInput.focus(); + + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + Assert.ok( + revealCheckbox.checked, + "reveal-checkbox should be checked when password input is focused" + ); + + Assert.equal( + passwordInput.type, + "text", + "Password should be shown as text when focused" + ); + let saveChangesButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + + saveChangesButton.click(); + + await ContentTaskUtils.waitForCondition(() => { + let editButton = loginItem.shadowRoot.querySelector("edit-button"); + return !editButton.disabled; + }, "Waiting to exit edit mode"); + + Assert.ok( + !revealCheckbox.checked, + "reveal-checkbox should be unchecked after saving changes" + ); + Assert.ok( + !loginItem.dataset.editing, + "LoginItem should not be in 'edit' mode after saving" + ); + Assert.equal( + passwordInput.type, + "password", + "Password should be hidden after exiting edit mode" + ); + Assert.equal( + usernameInput.value, + login.username, + "Username change should be reverted" + ); + Assert.equal( + passwordInput.value, + login.password, + "Password change should be reverted" + ); + Assert.equal( + passwordDisplayInput.value, + concealedPasswordText, + "Password change should be reverted for display" + ); + Assert.ok( + !passwordInput.hasAttribute("value"), + "Password shouldn't be exposed in @value" + ); + Assert.equal( + passwordInput.style.width, + login.password.length + "ch", + "Password field width shouldn't have changed" + ); + } + ); + reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let editButton = loginItem.shadowRoot + .querySelector("edit-button") + .shadowRoot.querySelector("button"); + editButton.click(); + }); + info("waiting for oskeystore auth #4"); + await reauthObserved; + await SpecialPowers.spawn( + browser, + [LoginHelper.loginToVanillaObject(TEST_LOGIN1)], + async login => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "Entering edit mode" + ); + await Promise.resolve(); + + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + Assert.ok( + revealCheckbox.checked, + "reveal-checkbox should be checked after clicking" + ); + + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + let passwordInput = loginItem._passwordInput; + + usernameInput.value += "-saveme"; + passwordInput.value += "-saveme"; + + Assert.ok( + loginItem.dataset.editing, + "LoginItem should be in 'edit' mode" + ); + + let saveChangesButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + saveChangesButton.click(); + + await ContentTaskUtils.waitForCondition(() => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let guid = loginList._loginGuidsSortedOrder[0]; + let updatedLogin = loginList._logins[guid].login; + return ( + updatedLogin && + updatedLogin.username == usernameInput.value && + updatedLogin.password == passwordInput.value + ); + }, "Waiting for corresponding login in login list to update"); + + Assert.ok( + !revealCheckbox.checked, + "reveal-checkbox should be unchecked after saving changes" + ); + Assert.ok( + !loginItem.dataset.editing, + "LoginItem should not be in 'edit' mode after saving" + ); + Assert.equal( + passwordInput.style.width, + passwordInput.value.length + "ch", + "Password field width should be correctly updated" + ); + } + ); + reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let editButton = loginItem.shadowRoot + .querySelector("edit-button") + .shadowRoot.querySelector("button"); + editButton.click(); + }); + info("waiting for oskeystore auth #5"); + await reauthObserved; + await SpecialPowers.spawn( + browser, + [LoginHelper.loginToVanillaObject(TEST_LOGIN1)], + async login => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "Entering edit mode" + ); + await Promise.resolve(); + + Assert.ok( + loginItem.dataset.editing, + "LoginItem should be in 'edit' mode" + ); + let deleteButton = loginItem.shadowRoot + .querySelector("delete-button") + .shadowRoot.querySelector("button"); + deleteButton.click(); + let confirmDeleteDialog = Cu.waiveXrays( + content.document.querySelector("confirmation-dialog") + ); + let confirmDeleteButton = + confirmDeleteDialog.shadowRoot.querySelector(".confirm-button"); + confirmDeleteButton.click(); + + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let loginListItem = Cu.waiveXrays( + loginList.shadowRoot.querySelector("login-list-item[data-guid]") + ); + await ContentTaskUtils.waitForCondition(() => { + loginListItem = loginList.shadowRoot.querySelector( + "login-list-item[data-guid]" + ); + return !loginListItem; + }, "Waiting for login to be removed from list"); + + Assert.ok( + !loginItem.dataset.editing, + "LoginItem should not be in 'edit' mode after deleting" + ); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_vulnerableLoginAddedInSecondaryWindow.js b/browser/components/aboutlogins/tests/browser/browser_vulnerableLoginAddedInSecondaryWindow.js new file mode 100644 index 0000000000..6e7dbbea14 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_vulnerableLoginAddedInSecondaryWindow.js @@ -0,0 +1,224 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +EXPECTED_BREACH = { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.example.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", +}; + +let tabInSecondWindow; + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + TEST_LOGIN3 = await addLogin(TEST_LOGIN3); + + let breaches = await LoginBreaches.getPotentialBreachesByLoginGUID([ + TEST_LOGIN3, + ]); + Assert.ok(breaches.size, "TEST_LOGIN3 should be marked as breached"); + + // Remove the breached login so the 'alerts' option + // is hidden when opening about:logins. + Services.logins.removeLogin(TEST_LOGIN3); + + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + tabInSecondWindow = await BrowserTestUtils.openNewForegroundTab({ + gBrowser: newWin.gBrowser, + url: "about:logins", + }); + + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + await BrowserTestUtils.closeWindow(newWin); + }); +}); + +add_task(async function test_new_login_marked_vulnerable_in_both_windows() { + const ORIGIN_FOR_NEW_VULNERABLE_LOGIN = "https://vulnerable"; + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + Assert.ok( + loginList.shadowRoot.querySelector("#login-sort").namedItem("alerts") + .hidden, + "The 'alerts' option should be hidden before adding a vulnerable login to the list" + ); + }); + + await SpecialPowers.spawn( + tabInSecondWindow.linkedBrowser, + [[TEST_LOGIN3.password, ORIGIN_FOR_NEW_VULNERABLE_LOGIN]], + async ([passwordOfBreachedAccount, originForNewVulnerableLogin]) => { + let loginList = content.document.querySelector("login-list"); + + await ContentTaskUtils.waitForCondition( + () => + loginList.shadowRoot.querySelectorAll("login-list-item[data-guid]") + .length == 2, + "waiting for all two initials logins to get added to login-list" + ); + + let loginSort = loginList.shadowRoot.querySelector("#login-sort"); + Assert.ok( + loginSort.namedItem("alerts").hidden, + "The 'alerts' option should be hidden when there are no breached or vulnerable logins in the list" + ); + + let createButton = loginList.shadowRoot.querySelector( + "create-login-button" + ); + createButton.click(); + + let loginItem = content.document.querySelector("login-item"); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.isNewLogin, + "waiting for create login form to be visible" + ); + + let originInput = loginItem.shadowRoot.querySelector( + "input[name='origin']" + ); + originInput.value = originForNewVulnerableLogin; + let passwordInput = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + passwordInput.value = passwordOfBreachedAccount; + + let saveButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + saveButton.click(); + + await ContentTaskUtils.waitForCondition( + () => + loginList.shadowRoot.querySelectorAll("login-list-item[data-guid]") + .length == 3, + "waiting for new login to get added to login-list" + ); + + let vulnerableLoginGuid = Cu.waiveXrays(loginItem)._login.guid; + let vulnerableListItem = loginList.shadowRoot.querySelector( + `login-list-item[data-guid="${vulnerableLoginGuid}"]` + ); + + Assert.ok( + vulnerableListItem.classList.contains("vulnerable"), + "vulnerable login list item should be marked as such" + ); + Assert.ok( + !loginItem.shadowRoot.querySelector("login-vulnerable-password-alert") + .hidden, + "vulnerable alert on login-item should be visible" + ); + + Assert.ok( + !loginSort.namedItem("alerts").hidden, + "The 'alerts' option should be visible after adding a vulnerable login to the list" + ); + } + ); + console.log("xxxxxxx ---- 0"); + + tabInSecondWindow.linkedBrowser.reload(); + await BrowserTestUtils.browserLoaded( + tabInSecondWindow.linkedBrowser, + false, + url => url.includes("about:logins") + ); + + console.log("xxxxxxx ---- 1"); + + await SpecialPowers.spawn(tabInSecondWindow.linkedBrowser, [], async () => { + let loginList = content.document.querySelector("login-list"); + let loginSort = loginList.shadowRoot.querySelector("#login-sort"); + + await ContentTaskUtils.waitForCondition( + () => loginSort.value == "alerts", + "waiting for sort to get updated to 'alerts'" + ); + + Assert.equal( + loginSort.value, + "alerts", + "The login list should be sorted by Alerts" + ); + let loginListItems = loginList.shadowRoot.querySelectorAll( + "login-list-item[data-guid]" + ); + for (let i = 1; i < loginListItems.length; i++) { + if (loginListItems[i].matches(".vulnerable, .breached")) { + Assert.ok( + loginListItems[i - 1].matches(".vulnerable, .breached"), + `The previous login-list-item must be vulnerable or breached if the current one is (second window, i=${i})` + ); + } + } + }); + console.log("xxxxxxx ---- 2"); + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [ORIGIN_FOR_NEW_VULNERABLE_LOGIN], + async originForNewVulnerableLogin => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let vulnerableListItem; + await ContentTaskUtils.waitForCondition(() => { + let entry = Object.entries(loginList._logins).find( + ([guid, { login, listItem }]) => + login.origin == originForNewVulnerableLogin + ); + vulnerableListItem = entry[1].listItem; + return !!entry; + }, "waiting for vulnerable list item to get added to login-list"); + Assert.ok( + vulnerableListItem.classList.contains("vulnerable"), + "vulnerable login list item should be marked as such" + ); + + Assert.ok( + !loginList.shadowRoot.querySelector("#login-sort").namedItem("alerts") + .hidden, + "The 'alerts' option should be visible after adding a vulnerable login to the list" + ); + } + ); + gBrowser.selectedBrowser.reload(); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, false, url => + url.includes("about:logins") + ); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + await ContentTaskUtils.waitForCondition( + () => loginList.shadowRoot.querySelector("#login-sort").value == "alerts", + "waiting for sort to get updated to 'alerts'" + ); + let loginListItems = loginList.shadowRoot.querySelectorAll( + "login-list-item[data-guid]" + ); + for (let i = 1; i < loginListItems.length; i++) { + if (loginListItems[i].matches(".vulnerable, .breached")) { + Assert.ok( + loginListItems[i - 1].matches(".vulnerable, .breached"), + `The previous login-list-item must be vulnerable or breached if the current one is (first window, i=${i})` + ); + } + } + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/head.js b/browser/components/aboutlogins/tests/browser/head.js new file mode 100644 index 0000000000..2aec0e632a --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/head.js @@ -0,0 +1,225 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +let { LoginBreaches } = ChromeUtils.importESModule( + "resource:///modules/LoginBreaches.sys.mjs" +); +let { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); +let { _AboutLogins } = ChromeUtils.importESModule( + "resource:///actors/AboutLoginsParent.sys.mjs" +); +let { OSKeyStoreTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/OSKeyStoreTestUtils.sys.mjs" +); +var { LoginTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" +); + +let nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); + +let TEST_LOGIN1 = new nsLoginInfo( + "https://example.com", + "https://example.com", + null, + "user1", + "pass1", + "username", + "password" +); +let TEST_LOGIN2 = new nsLoginInfo( + "https://2.example.com", + "https://2.example.com", + null, + "user2", + "pass2", + "username", + "password" +); + +let TEST_LOGIN3 = new nsLoginInfo( + "https://breached.example.com", + "https://breached.example.com", + null, + "breachedLogin1", + "pass3", + "breachedLogin", + "password" +); +TEST_LOGIN3.QueryInterface(Ci.nsILoginMetaInfo).timePasswordChanged = 123456; + +async function addLogin(login) { + const result = await Services.logins.addLoginAsync(login); + registerCleanupFunction(() => { + let matchData = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag2 + ); + matchData.setPropertyAsAUTF8String("guid", result.guid); + + let logins = Services.logins.searchLogins(matchData); + if (!logins.length) { + return; + } + // Use the login that was returned from searchLogins + // in case the initial login object was changed by the test code, + // since removeLogin makes sure that the login argument exactly + // matches the login that it will be removing. + Services.logins.removeLogin(logins[0]); + }); + return result; +} + +let EXPECTED_BREACH = null; +let EXPECTED_ERROR_MESSAGE = null; +add_setup(async function setup_head() { + const db = RemoteSettings(LoginBreaches.REMOTE_SETTINGS_COLLECTION).db; + if (EXPECTED_BREACH) { + await db.create(EXPECTED_BREACH, { + useRecordId: true, + }); + } + await db.importChanges({}, Date.now()); + if (EXPECTED_BREACH) { + await RemoteSettings(LoginBreaches.REMOTE_SETTINGS_COLLECTION).emit( + "sync", + { data: { current: [EXPECTED_BREACH] } } + ); + } + + SpecialPowers.registerConsoleListener(function onConsoleMessage(msg) { + if (msg.isWarning || !msg.errorMessage) { + // Ignore warnings and non-errors. + return; + } + + if (msg.errorMessage.includes('Unknown event: ["jsonfile", "load"')) { + // Ignore telemetry errors from JSONFile.sys.mjs. + return; + } + + if ( + msg.errorMessage == "Refreshing device list failed." || + msg.errorMessage == "Skipping device list refresh; not signed in" + ) { + // Ignore errors from browser-sync.js. + return; + } + if ( + msg.errorMessage.includes( + "ReferenceError: MigrationWizard is not defined" + ) + ) { + // todo(Bug 1587237): Ignore error when loading the Migration Wizard in automation. + return; + } + if ( + msg.errorMessage.includes("Error detecting Chrome profiles") || + msg.errorMessage.includes( + "Library/Application Support/Chromium/Local State (No such file or directory)" + ) || + msg.errorMessage.includes( + "Library/Application Support/Google/Chrome/Local State (No such file or directory)" + ) + ) { + // Ignore errors that can occur when the migrator is looking for a + // Chrome/Chromium profile + return; + } + if (msg.errorMessage.includes("Can't find profile directory.")) { + // Ignore error messages for no profile found in old XULStore.jsm + return; + } + if (msg.errorMessage.includes("Error reading typed URL history")) { + // The Migrator when opened can log this exception if there is no Edge + // history on the machine. + return; + } + if (msg.errorMessage.includes(EXPECTED_ERROR_MESSAGE)) { + return; + } + if (msg.errorMessage == "FILE_FORMAT_ERROR") { + // Ignore errors handled by the error message dialog. + return; + } + if ( + msg.errorMessage == + "NotFoundError: No such JSWindowActor 'MarionetteEvents'" + ) { + // Ignore MarionetteEvents error (Bug 1730837, Bug 1710079). + return; + } + Assert.ok(false, msg.message || msg.errorMessage); + }); + + registerCleanupFunction(async () => { + EXPECTED_ERROR_MESSAGE = null; + await db.clear(); + Services.telemetry.clearEvents(); + SpecialPowers.postConsoleSentinel(); + }); +}); + +/** + * Waits for the primary password prompt and performs an action. + * @param {string} action Set to "authenticate" to log in or "cancel" to + * close the dialog without logging in. + */ +function waitForMPDialog(action, aWindow = window) { + const BRAND_BUNDLE = Services.strings.createBundle( + "chrome://branding/locale/brand.properties" + ); + const BRAND_FULL_NAME = BRAND_BUNDLE.GetStringFromName("brandFullName"); + let dialogShown = TestUtils.topicObserved("common-dialog-loaded"); + return dialogShown.then(function ([subject]) { + let dialog = subject.Dialog; + let expected = "Password Required - " + BRAND_FULL_NAME; + Assert.equal( + dialog.args.title, + expected, + "Dialog is the Primary Password dialog" + ); + if (action == "authenticate") { + SpecialPowers.wrap(dialog.ui.password1Textbox).setUserInput( + LoginTestUtils.primaryPassword.primaryPassword + ); + dialog.ui.button0.click(); + } else if (action == "cancel") { + dialog.ui.button1.click(); + } + return BrowserTestUtils.waitForEvent(aWindow, "DOMModalDialogClosed"); + }); +} + +/** + * Allows for tests to reset the MP auth expiration and + * return a promise that will resolve after the MP dialog has + * been presented. + * + * @param {string} action Set to "authenticate" to log in or "cancel" to + * close the dialog without logging in. + * @returns {Promise} Resolves after the MP dialog has been presented and actioned upon + */ +function forceAuthTimeoutAndWaitForMPDialog(action, aWindow = window) { + const AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes (duplicated from AboutLoginsParent.sys.mjs) + _AboutLogins._authExpirationTime -= AUTH_TIMEOUT_MS + 1; + return waitForMPDialog(action, aWindow); +} + +/** + * Allows for tests to reset the OS auth expiration and + * return a promise that will resolve after the OS auth dialog has + * been presented. + * + * @param {bool} loginResult True if the auth prompt should pass, otherwise false will fail + * @returns {Promise} Resolves after the OS auth dialog has been presented + */ +function forceAuthTimeoutAndWaitForOSKeyStoreLogin({ loginResult }) { + const AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes (duplicated from AboutLoginsParent.sys.mjs) + _AboutLogins._authExpirationTime -= AUTH_TIMEOUT_MS + 1; + return OSKeyStoreTestUtils.waitForOSKeyStoreLogin(loginResult); +} diff --git a/browser/components/aboutlogins/tests/chrome/.eslintrc.js b/browser/components/aboutlogins/tests/chrome/.eslintrc.js new file mode 100644 index 0000000000..9b6510bdd2 --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/.eslintrc.js @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.exports = { + overrides: [ + { + files: ["test_login_item.html"], + parserOptions: { + sourceType: "module", + }, + }, + ], +}; diff --git a/browser/components/aboutlogins/tests/chrome/aboutlogins_common.js b/browser/components/aboutlogins/tests/chrome/aboutlogins_common.js new file mode 100644 index 0000000000..e881b6ca22 --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/aboutlogins_common.js @@ -0,0 +1,98 @@ +"use strict"; + +/* exported asyncElementRendered, importDependencies */ + +/** + * A helper to await on while waiting for an asynchronous rendering of a Custom + * Element. + * @returns {Promise} + */ +function asyncElementRendered() { + return Promise.resolve(); +} + +/** + * Import the templates from the real page to avoid duplication in the tests. + * @param {HTMLIFrameElement} templateFrame - Frame to copy the resources from + * @param {HTMLElement} destinationEl - Where to append the copied resources + */ +function importDependencies(templateFrame, destinationEl) { + let promises = []; + for (let template of templateFrame.contentDocument.querySelectorAll( + "template" + )) { + let imported = document.importNode(template, true); + destinationEl.appendChild(imported); + // Preload the styles in the actual page, to ensure they're loaded on time. + for (let element of imported.content.querySelectorAll( + "link[rel='stylesheet']" + )) { + let clone = element.cloneNode(true); + promises.push( + new Promise(resolve => { + clone.onload = function () { + resolve(); + clone.remove(); + }; + }) + ); + destinationEl.appendChild(clone); + } + } + return Promise.all(promises); +} + +Object.defineProperty(document, "l10n", { + configurable: true, + writable: true, + value: { + connectRoot() {}, + disconnectRoot() {}, + translateElements() { + return Promise.resolve(); + }, + getAttributes(element) { + return { + id: element.getAttribute("data-l10n-id"), + args: element.getAttribute("data-l10n-args") + ? JSON.parse(element.getAttribute("data-l10n-args")) + : {}, + }; + }, + setAttributes(element, id, args) { + element.setAttribute("data-l10n-id", id); + if (args) { + element.setAttribute("data-l10n-args", JSON.stringify(args)); + } else { + element.removeAttribute("data-l10n-args"); + } + }, + }, +}); + +Object.defineProperty(window, "AboutLoginsUtils", { + configurable: true, + writable: true, + value: { + getLoginOrigin(uriString) { + return uriString; + }, + setFocus(element) { + return element.focus(); + }, + async promptForPrimaryPassword(resolve, messageId) { + resolve(true); + }, + doLoginsMatch(login1, login2) { + return ( + login1.origin == login2.origin && + login1.username == login2.username && + login1.password == login2.password + ); + }, + fileImportEnabled: SpecialPowers.getBoolPref( + "signon.management.page.fileImport.enabled" + ), + primaryPasswordEnabled: false, + }, +}); diff --git a/browser/components/aboutlogins/tests/chrome/chrome.toml b/browser/components/aboutlogins/tests/chrome/chrome.toml new file mode 100644 index 0000000000..2653b6e821 --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/chrome.toml @@ -0,0 +1,16 @@ +[DEFAULT] +scheme = "https" +prefs = ["identity.fxaccounts.enabled=true"] +support-files = ["aboutlogins_common.js"] + +["test_confirm_delete_dialog.html"] + +["test_fxaccounts_button.html"] + +["test_login_filter.html"] + +["test_login_item.html"] + +["test_login_list.html"] + +["test_menu_button.html"] diff --git a/browser/components/aboutlogins/tests/chrome/test_confirm_delete_dialog.html b/browser/components/aboutlogins/tests/chrome/test_confirm_delete_dialog.html new file mode 100644 index 0000000000..68a58aee4f --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/test_confirm_delete_dialog.html @@ -0,0 +1,127 @@ + + + + + + Test the confirmation-dialog component + + + + + + + + +

    +

    + +
    +
    + + + diff --git a/browser/components/aboutlogins/tests/chrome/test_fxaccounts_button.html b/browser/components/aboutlogins/tests/chrome/test_fxaccounts_button.html new file mode 100644 index 0000000000..ce6046bf2a --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/test_fxaccounts_button.html @@ -0,0 +1,96 @@ + + + + + + Test the fxaccounts-button component + + + + + + + + +

    +

    + +
    +
    + + + + diff --git a/browser/components/aboutlogins/tests/chrome/test_login_filter.html b/browser/components/aboutlogins/tests/chrome/test_login_filter.html new file mode 100644 index 0000000000..284fb69c65 --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/test_login_filter.html @@ -0,0 +1,178 @@ + + + + + + Test the login-filter component + + + + + + + + + +

    +

    + +
    +
    + + + + diff --git a/browser/components/aboutlogins/tests/chrome/test_login_item.html b/browser/components/aboutlogins/tests/chrome/test_login_item.html new file mode 100644 index 0000000000..5403487599 --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/test_login_item.html @@ -0,0 +1,577 @@ + + + + + + Test the login-item component + + + + + + + + + + + + +

    +

    + +
    +
    + + + + diff --git a/browser/components/aboutlogins/tests/chrome/test_login_list.html b/browser/components/aboutlogins/tests/chrome/test_login_list.html new file mode 100644 index 0000000000..4c811bf2eb --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/test_login_list.html @@ -0,0 +1,712 @@ + + + + + + Test the login-list component + + + + + + + + + +

    +

    + +
    +
    + + + + diff --git a/browser/components/aboutlogins/tests/chrome/test_menu_button.html b/browser/components/aboutlogins/tests/chrome/test_menu_button.html new file mode 100644 index 0000000000..2beede09f1 --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/test_menu_button.html @@ -0,0 +1,260 @@ + + + + + + Test the menu-button component + + + + + + + + +

    +

    + +
    +
    + + + + diff --git a/browser/components/aboutlogins/tests/unit/head.js b/browser/components/aboutlogins/tests/unit/head.js new file mode 100644 index 0000000000..938e06e3c0 --- /dev/null +++ b/browser/components/aboutlogins/tests/unit/head.js @@ -0,0 +1,22 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { LoginTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" +); +const { LoginHelper } = ChromeUtils.importESModule( + "resource://gre/modules/LoginHelper.sys.mjs" +); + +const TestData = LoginTestUtils.testData; +const newPropertyBag = LoginHelper.newPropertyBag; + +/** + * All the tests are implemented with add_task, this starts them automatically. + */ +function run_test() { + do_get_profile(); + run_next_test(); +} diff --git a/browser/components/aboutlogins/tests/unit/test_getPotentialBreachesByLoginGUID.js b/browser/components/aboutlogins/tests/unit/test_getPotentialBreachesByLoginGUID.js new file mode 100644 index 0000000000..b0034d93c8 --- /dev/null +++ b/browser/components/aboutlogins/tests/unit/test_getPotentialBreachesByLoginGUID.js @@ -0,0 +1,327 @@ +/** + * Test LoginBreaches.getPotentialBreachesByLoginGUID + */ + +"use strict"; + +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); + +// Initializing BrowserGlue requires a profile on Windows. +do_get_profile(); + +const gBrowserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService( + Ci.nsIObserver +); + +ChromeUtils.defineESModuleGetters(this, { + LoginBreaches: "resource:///modules/LoginBreaches.sys.mjs", +}); + +const TEST_BREACHES = [ + { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", + }, + { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached-subdomain.host.com", + Name: "Only a Sub-Domain was Breached", + PwnCount: 2754200, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0044", + last_modified: "1541615610052", + schema: "1541615609018", + }, + { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached-site-without-passwords.com", + Name: "Breached Site without passwords", + PwnCount: 987654, + DataClasses: ["Email addresses", "Usernames", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0045", + last_modified: "1541615610052", + schema: "1541615609018", + }, +]; + +const CRASHING_URI_LOGIN = LoginTestUtils.testData.formLogin({ + origin: "chrome://grwatcher", + formActionOrigin: "https://www.example.com", + username: "username", + password: "password", + timePasswordChanged: new Date("2018-12-15").getTime(), +}); +const NOT_BREACHED_LOGIN = LoginTestUtils.testData.formLogin({ + origin: "https://www.example.com", + formActionOrigin: "https://www.example.com", + username: "username", + password: "password", + timePasswordChanged: new Date("2018-12-15").getTime(), +}); +const BREACHED_LOGIN = LoginTestUtils.testData.formLogin({ + origin: "https://www.breached.com", + formActionOrigin: "https://www.breached.com", + username: "username", + password: "password", + timePasswordChanged: new Date("2018-12-15").getTime(), +}); +const NOT_BREACHED_SUBDOMAIN_LOGIN = LoginTestUtils.testData.formLogin({ + origin: "https://not-breached-subdomain.host.com", + formActionOrigin: "https://not-breached-subdomain.host.com", + username: "username", + password: "password", +}); +const BREACHED_SUBDOMAIN_LOGIN = LoginTestUtils.testData.formLogin({ + origin: "https://breached-subdomain.host.com", + formActionOrigin: "https://breached-subdomain.host.com", + username: "username", + password: "password", + timePasswordChanged: new Date("2018-12-15").getTime(), +}); +const LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS = + LoginTestUtils.testData.formLogin({ + origin: "https://breached-site-without-passwords.com", + formActionOrigin: "https://breached-site-without-passwords.com", + username: "username", + password: "password", + timePasswordChanged: new Date("2018-12-15").getTime(), + }); +const LOGIN_WITH_NON_STANDARD_URI = LoginTestUtils.testData.formLogin({ + origin: "someApp://random/path/to/login", + formActionOrigin: "someApp://random/path/to/login", + username: "username", + password: "password", + timePasswordChanged: new Date("2018-12-15").getTime(), +}); + +add_task(async function test_notBreachedLogin() { + await Services.logins.addLoginAsync(NOT_BREACHED_LOGIN); + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [NOT_BREACHED_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 0, + "Should be 0 breached logins." + ); +}); + +add_task(async function test_breachedLogin() { + await Services.logins.addLoginAsync(BREACHED_LOGIN); + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [NOT_BREACHED_LOGIN, BREACHED_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 1, + "Should be 1 breached login: " + BREACHED_LOGIN.origin + ); + Assert.strictEqual( + breachesByLoginGUID.get(BREACHED_LOGIN.guid).breachAlertURL, + "https://monitor.firefox.com/breach-details/Breached?utm_source=firefox-desktop&utm_medium=referral&utm_campaign=about-logins&utm_content=about-logins", + "Breach alert link should be equal to the breachAlertURL" + ); +}); + +add_task(async function test_breachedLoginAfterCrashingUriLogin() { + await Services.logins.addLoginAsync(CRASHING_URI_LOGIN); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [CRASHING_URI_LOGIN, BREACHED_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 1, + "Should be 1 breached login: " + BREACHED_LOGIN.origin + ); + Assert.strictEqual( + breachesByLoginGUID.get(BREACHED_LOGIN.guid).breachAlertURL, + "https://monitor.firefox.com/breach-details/Breached?utm_source=firefox-desktop&utm_medium=referral&utm_campaign=about-logins&utm_content=about-logins", + "Breach alert link should be equal to the breachAlertURL" + ); +}); + +add_task(async function test_notBreachedSubdomain() { + await Services.logins.addLoginAsync(NOT_BREACHED_SUBDOMAIN_LOGIN); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [NOT_BREACHED_LOGIN, NOT_BREACHED_SUBDOMAIN_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 0, + "Should be 0 breached logins." + ); +}); + +add_task(async function test_breachedSubdomain() { + await Services.logins.addLoginAsync(BREACHED_SUBDOMAIN_LOGIN); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [NOT_BREACHED_SUBDOMAIN_LOGIN, BREACHED_SUBDOMAIN_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 1, + "Should be 1 breached login: " + BREACHED_SUBDOMAIN_LOGIN.origin + ); +}); + +add_task(async function test_breachedSiteWithoutPasswords() { + await Services.logins.addLoginAsync( + LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS + ); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 0, + "Should be 0 breached login: " + + LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS.origin + ); +}); + +add_task(async function test_breachAlertHiddenAfterDismissal() { + BREACHED_LOGIN.guid = "{d2de5ac1-4de6-e544-a7af-1f75abcba92b}"; + + await Services.logins.initializationPromise; + const storageJSON = Services.logins.wrappedJSObject._storage; + + storageJSON.recordBreachAlertDismissal(BREACHED_LOGIN.guid); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [BREACHED_LOGIN, NOT_BREACHED_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 0, + "Should be 0 breached logins after dismissal: " + BREACHED_LOGIN.origin + ); + + info("Clear login storage"); + Services.logins.removeAllUserFacingLogins(); + + const breachesByLoginGUID2 = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [BREACHED_LOGIN, NOT_BREACHED_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID2.size, + 1, + "Breached login should re-appear after clearing storage: " + + BREACHED_LOGIN.origin + ); +}); + +add_task(async function test_newBreachAfterDismissal() { + TEST_BREACHES[0].AddedDate = new Date().toISOString(); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [BREACHED_LOGIN, NOT_BREACHED_LOGIN], + TEST_BREACHES + ); + + Assert.strictEqual( + breachesByLoginGUID.size, + 1, + "Should be 1 breached login after new breach following the dismissal of a previous breach: " + + BREACHED_LOGIN.origin + ); +}); + +add_task(async function test_ExceptionsThrownByNonStandardURIsAreCaught() { + await Services.logins.addLoginAsync(LOGIN_WITH_NON_STANDARD_URI); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [LOGIN_WITH_NON_STANDARD_URI, BREACHED_LOGIN], + TEST_BREACHES + ); + + Assert.strictEqual( + breachesByLoginGUID.size, + 1, + "Exceptions thrown by logins with non-standard URIs should be caught." + ); +}); + +add_task(async function test_setBreachesFromRemoteSettingsSync() { + const login = NOT_BREACHED_SUBDOMAIN_LOGIN; + const nowExampleIsInBreachedRecords = [ + { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "not-breached-subdomain.host.com", + Name: "not-breached-subdomain.host.com is now breached!", + PwnCount: 1643100, + DataClasses: [ + "Email addresses", + "Usernames", + "Passwords", + "IP addresses", + ], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0044", + last_modified: "1541615610052", + schema: "1541615609018", + }, + ]; + async function emitSync() { + await RemoteSettings(LoginBreaches.REMOTE_SETTINGS_COLLECTION).emit( + "sync", + { data: { current: nowExampleIsInBreachedRecords } } + ); + } + + const beforeSyncBreachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID([login]); + Assert.strictEqual( + beforeSyncBreachesByLoginGUID.size, + 0, + "Should be 0 breached login before not-breached-subdomain.host.com is added to fxmonitor-breaches collection and synced: " + ); + gBrowserGlue.observe(null, "browser-glue-test", "add-breaches-sync-handler"); + const db = RemoteSettings(LoginBreaches.REMOTE_SETTINGS_COLLECTION).db; + await db.importChanges({}, Date.now(), [nowExampleIsInBreachedRecords[0]]); + await emitSync(); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID([login]); + Assert.strictEqual( + breachesByLoginGUID.size, + 1, + "Should be 1 breached login after not-breached-subdomain.host.com is added to fxmonitor-breaches collection and synced: " + ); +}); diff --git a/browser/components/aboutlogins/tests/unit/xpcshell.toml b/browser/components/aboutlogins/tests/unit/xpcshell.toml new file mode 100644 index 0000000000..b23e325c00 --- /dev/null +++ b/browser/components/aboutlogins/tests/unit/xpcshell.toml @@ -0,0 +1,7 @@ +[DEFAULT] +skip-if = ["os == 'android'"] # bug 1730213 +head = "head.js" +firefox-appdir = "browser" + +["test_getPotentialBreachesByLoginGUID.js"] +tags = "remote-settings" diff --git a/browser/components/aboutwelcome/.eslintrc.js b/browser/components/aboutwelcome/.eslintrc.js new file mode 100644 index 0000000000..d5f33683e5 --- /dev/null +++ b/browser/components/aboutwelcome/.eslintrc.js @@ -0,0 +1,185 @@ +/* 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/. */ + +module.exports = { + // When adding items to this file please check for effects on sub-directories. + plugins: ["import", "react", "jsx-a11y"], + settings: { + react: { + version: "16.2.0", + }, + }, + extends: ["plugin:jsx-a11y/recommended"], + overrides: [ + { + // TODO: Bug 1773467 - Move these to .mjs or figure out a generic way + // to identify these as modules. + files: ["tests/unit/**/*.js"], + parserOptions: { + sourceType: "module", + }, + }, + { + // These files use fluent-dom to insert content + files: [ + "content-src/components/Zap.jsx", + "content-src/components/MultiStageAboutWelcome.jsx", + "content-src/components/MultiStageScreen.jsx", + "content-src/components/MultiStageProtonScreen.jsx", + "content-src/components/MultiSelect.jsx", + "content-src/components/ReturnToAMO.jsx", + ], + rules: { + "jsx-a11y/anchor-has-content": "off", + "jsx-a11y/heading-has-content": "off", + "jsx-a11y/label-has-associated-control": "off", + "jsx-a11y/no-onchange": "off", + }, + }, + { + files: ["content-src/**", "tests/unit/**"], + env: { + node: true, + }, + }, + { + // Use a configuration that's appropriate for modules, workers and + // non-production files. + files: ["modules/*.jsm", "tests/**"], + rules: { + "no-implicit-globals": "off", + }, + }, + { + files: ["content-src/**", "tests/unit/**"], + rules: { + // Disallow commonjs in these directories. + "import/no-commonjs": 2, + }, + }, + { + // These tests simulate the browser environment. + files: "tests/unit/**", + env: { + browser: true, + mocha: true, + }, + globals: { + assert: true, + chai: true, + sinon: true, + }, + }, + { + files: "tests/**", + rules: { + "func-name-matching": 0, + "lines-between-class-members": 0, + }, + }, + ], + rules: { + "fetch-options/no-fetch-credentials": "error", + + "react/jsx-boolean-value": ["error", "always"], + "react/jsx-key": "error", + "react/jsx-no-bind": [ + "error", + { allowArrowFunctions: true, allowFunctions: true }, + ], + "react/jsx-no-comment-textnodes": "error", + "react/jsx-no-duplicate-props": "error", + "react/jsx-no-target-blank": "error", + "react/jsx-no-undef": "error", + "react/jsx-pascal-case": "error", + "react/jsx-uses-react": "error", + "react/jsx-uses-vars": "error", + "react/no-access-state-in-setstate": "error", + "react/no-danger": "error", + "react/no-deprecated": "error", + "react/no-did-mount-set-state": "error", + "react/no-did-update-set-state": "error", + "react/no-direct-mutation-state": "error", + "react/no-is-mounted": "error", + "react/no-unknown-property": [ + "error", + { + // Custom HTML properties used in aboutwelcome React components. + ignore: [ + "flow", + "alignment", + "button-size", + "layout", + "pos", + "hide-secondary-section", + ], + }, + ], + "react/require-render-return": "error", + + "accessor-pairs": ["error", { setWithoutGet: true, getWithoutSet: false }], + "array-callback-return": "error", + "block-scoped-var": "error", + "consistent-this": ["error", "use-bind"], + eqeqeq: "error", + "func-name-matching": "error", + "getter-return": "error", + "guard-for-in": "error", + "max-depth": ["error", 4], + "max-nested-callbacks": ["error", 4], + "max-params": ["error", 6], + "max-statements": ["error", 50], + "new-cap": ["error", { newIsCap: true, capIsNew: false }], + "no-alert": "error", + "no-console": ["error", { allow: ["error"] }], + "no-div-regex": "error", + "no-duplicate-imports": "error", + "no-eq-null": "error", + "no-extend-native": "error", + "no-extra-label": "error", + "no-implicit-coercion": ["error", { allow: ["!!"] }], + "no-implicit-globals": "error", + "no-loop-func": "error", + "no-multi-assign": "error", + "no-multi-str": "error", + "no-new": "error", + "no-new-func": "error", + "no-octal-escape": "error", + "no-param-reassign": "error", + "no-proto": "error", + "no-prototype-builtins": "error", + "no-return-assign": ["error", "except-parens"], + "no-script-url": "error", + "no-shadow": "error", + "no-template-curly-in-string": "error", + "no-undef-init": "error", + "no-unmodified-loop-condition": "error", + "no-unused-expressions": "error", + "no-use-before-define": "error", + "no-useless-computed-key": "error", + "no-useless-constructor": "error", + "no-useless-rename": "error", + "no-var": "error", + "no-void": ["error", { allowAsStatement: true }], + "one-var": ["error", "never"], + "operator-assignment": ["error", "always"], + "prefer-destructuring": [ + "error", + { + AssignmentExpression: { array: true }, + VariableDeclarator: { array: true, object: true }, + }, + ], + "prefer-numeric-literals": "error", + "prefer-promise-reject-errors": "error", + "prefer-rest-params": "error", + "prefer-spread": "error", + "prefer-template": "error", + radix: ["error", "always"], + "sort-vars": "error", + "symbol-description": "error", + "vars-on-top": "error", + yoda: ["error", "never"], + }, +}; diff --git a/browser/components/aboutwelcome/actors/AboutWelcomeChild.sys.mjs b/browser/components/aboutwelcome/actors/AboutWelcomeChild.sys.mjs new file mode 100644 index 0000000000..24bf73c80a --- /dev/null +++ b/browser/components/aboutwelcome/actors/AboutWelcomeChild.sys.mjs @@ -0,0 +1,898 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutWelcomeDefaults: + "resource:///modules/aboutwelcome/AboutWelcomeDefaults.sys.mjs", + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + const { Logger } = ChromeUtils.importESModule( + "resource://messaging-system/lib/Logger.sys.mjs" + ); + return new Logger("AboutWelcomeChild"); +}); + +export class AboutWelcomeChild extends JSWindowActorChild { + // Can be used to avoid accesses to the document/contentWindow after it's + // destroyed, which may throw unhandled exceptions. + _destroyed = false; + + didDestroy() { + this._destroyed = true; + } + + actorCreated() { + this.exportFunctions(); + } + + /** + * Send event that can be handled by the page + * + * @param {{type: string, data?: any}} action + */ + sendToPage(action) { + lazy.log.debug(`Sending to page: ${action.type}`); + const win = this.document.defaultView; + const event = new win.CustomEvent("AboutWelcomeChromeToContent", { + detail: Cu.cloneInto(action, win), + }); + win.dispatchEvent(event); + } + + /** + * Export functions that can be called by page js + */ + exportFunctions() { + let window = this.contentWindow; + + Cu.exportFunction(this.AWAddScreenImpression.bind(this), window, { + defineAs: "AWAddScreenImpression", + }); + + Cu.exportFunction(this.AWGetFeatureConfig.bind(this), window, { + defineAs: "AWGetFeatureConfig", + }); + + Cu.exportFunction(this.AWGetFxAMetricsFlowURI.bind(this), window, { + defineAs: "AWGetFxAMetricsFlowURI", + }); + + Cu.exportFunction(this.AWGetSelectedTheme.bind(this), window, { + defineAs: "AWGetSelectedTheme", + }); + + Cu.exportFunction(this.AWSelectTheme.bind(this), window, { + defineAs: "AWSelectTheme", + }); + + Cu.exportFunction(this.AWEvaluateScreenTargeting.bind(this), window, { + defineAs: "AWEvaluateScreenTargeting", + }); + + Cu.exportFunction(this.AWSendEventTelemetry.bind(this), window, { + defineAs: "AWSendEventTelemetry", + }); + + Cu.exportFunction(this.AWSendToParent.bind(this), window, { + defineAs: "AWSendToParent", + }); + + Cu.exportFunction(this.AWWaitForMigrationClose.bind(this), window, { + defineAs: "AWWaitForMigrationClose", + }); + + Cu.exportFunction(this.AWFinish.bind(this), window, { + defineAs: "AWFinish", + }); + + Cu.exportFunction(this.AWEnsureAddonInstalled.bind(this), window, { + defineAs: "AWEnsureAddonInstalled", + }); + + Cu.exportFunction(this.AWEnsureLangPackInstalled.bind(this), window, { + defineAs: "AWEnsureLangPackInstalled", + }); + + Cu.exportFunction( + this.AWNegotiateLangPackForLanguageMismatch.bind(this), + window, + { + defineAs: "AWNegotiateLangPackForLanguageMismatch", + } + ); + + Cu.exportFunction(this.AWSetRequestedLocales.bind(this), window, { + defineAs: "AWSetRequestedLocales", + }); + + Cu.exportFunction(this.AWSendToDeviceEmailsSupported.bind(this), window, { + defineAs: "AWSendToDeviceEmailsSupported", + }); + + Cu.exportFunction(this.AWNewScreen.bind(this), window, { + defineAs: "AWNewScreen", + }); + } + + /** + * Wrap a promise so content can use Promise methods. + */ + wrapPromise(promise) { + return new this.contentWindow.Promise((resolve, reject) => + promise.then(resolve, reject) + ); + } + + /** + * Clones the result of the query into the content window. + */ + sendQueryAndCloneForContent(...sendQueryArgs) { + return this.wrapPromise( + (async () => { + return Cu.cloneInto( + await this.sendQuery(...sendQueryArgs), + this.contentWindow + ); + })() + ); + } + + AWSelectTheme(data) { + return this.wrapPromise( + this.sendQuery("AWPage:SELECT_THEME", data.toUpperCase()) + ); + } + + AWEvaluateScreenTargeting(data) { + return this.sendQueryAndCloneForContent( + "AWPage:EVALUATE_SCREEN_TARGETING", + data + ); + } + + AWAddScreenImpression(screen) { + return this.wrapPromise( + this.sendQuery("AWPage:ADD_SCREEN_IMPRESSION", screen) + ); + } + + /** + * Send initial data to page including experiment information + */ + async getAWContent() { + let attributionData = await this.sendQuery("AWPage:GET_ATTRIBUTION_DATA"); + + // Return to AMO gets returned early. + if (attributionData?.template) { + lazy.log.debug("Loading about:welcome with RTAMO attribution data"); + return Cu.cloneInto(attributionData, this.contentWindow); + } else if (attributionData?.ua) { + lazy.log.debug("Loading about:welcome with UA attribution"); + } + + let experimentMetadata = + lazy.ExperimentAPI.getExperimentMetaData({ + featureId: "aboutwelcome", + }) || {}; + + lazy.log.debug( + `Loading about:welcome with ${ + experimentMetadata?.slug ?? "no" + } experiment` + ); + + let featureConfig = lazy.NimbusFeatures.aboutwelcome.getAllVariables(); + featureConfig.needDefault = await this.sendQuery("AWPage:NEED_DEFAULT"); + featureConfig.needPin = await this.sendQuery("AWPage:DOES_APP_NEED_PIN"); + if (featureConfig.languageMismatchEnabled) { + featureConfig.appAndSystemLocaleInfo = await this.sendQuery( + "AWPage:GET_APP_AND_SYSTEM_LOCALE_INFO" + ); + } + + // FeatureConfig (from experiments) has higher precendence + // to defaults. But the `screens` property isn't defined we shouldn't + // override the default with `null` + let defaults = lazy.AboutWelcomeDefaults.getDefaults(); + + const content = await lazy.AboutWelcomeDefaults.prepareContentForReact({ + ...attributionData, + ...experimentMetadata, + ...defaults, + ...featureConfig, + screens: featureConfig.screens ?? defaults.screens, + backdrop: featureConfig.backdrop ?? defaults.backdrop, + }); + + return Cu.cloneInto(content, this.contentWindow); + } + + AWGetFeatureConfig() { + return this.wrapPromise(this.getAWContent()); + } + + AWGetFxAMetricsFlowURI() { + return this.wrapPromise(this.sendQuery("AWPage:FXA_METRICS_FLOW_URI")); + } + + AWGetSelectedTheme() { + return this.wrapPromise(this.sendQuery("AWPage:GET_SELECTED_THEME")); + } + + /** + * Send Event Telemetry + * + * @param {object} eventData + */ + AWSendEventTelemetry(eventData) { + this.AWSendToParent("TELEMETRY_EVENT", { + ...eventData, + event_context: { + ...eventData.event_context, + }, + }); + } + + /** + * Send message that can be handled by AboutWelcomeParent.jsm + * + * @param {string} type + * @param {any=} data + * @returns {Promise} + */ + AWSendToParent(type, data) { + return this.sendQueryAndCloneForContent(`AWPage:${type}`, data); + } + + AWWaitForMigrationClose() { + return this.wrapPromise(this.sendQuery("AWPage:WAIT_FOR_MIGRATION_CLOSE")); + } + + AWFinish() { + const shouldFocusNewtabUrlBar = + lazy.NimbusFeatures.aboutwelcome.getVariable("newtabUrlBarFocus"); + + this.contentWindow.location.href = "about:home"; + if (shouldFocusNewtabUrlBar) { + this.AWSendToParent("SPECIAL_ACTION", { + type: "FOCUS_URLBAR", + }); + } + } + + AWEnsureAddonInstalled(addonId) { + return this.wrapPromise( + this.sendQuery("AWPage:ENSURE_ADDON_INSTALLED", addonId) + ); + } + + AWEnsureLangPackInstalled(negotiated, screenContent) { + const content = Cu.cloneInto(screenContent, {}); + return this.wrapPromise( + this.sendQuery( + "AWPage:ENSURE_LANG_PACK_INSTALLED", + negotiated.langPack + ).then(() => { + const formatting = []; + const l10n = new Localization( + ["branding/brand.ftl", "browser/newtab/onboarding.ftl"], + false, + undefined, + // Use the system-ish then app then default locale. + [...negotiated.requestSystemLocales, "en-US"] + ); + + // Add the negotiated language name as args. + function addMessageArgsAndUseLangPack(obj) { + for (const value of Object.values(obj)) { + if (value?.string_id) { + value.args = { + ...value.args, + negotiatedLanguage: negotiated.langPackDisplayName, + }; + + // Expose fluent strings wanting lang pack as raw. + if (value.useLangPack) { + formatting.push( + l10n.formatValue(value.string_id, value.args).then(raw => { + delete value.string_id; + value.raw = raw; + }) + ); + } + } + } + } + addMessageArgsAndUseLangPack(content.languageSwitcher); + addMessageArgsAndUseLangPack(content); + return Promise.all(formatting).then(() => + Cu.cloneInto(content, this.contentWindow) + ); + }) + ); + } + + AWSetRequestedLocales(requestSystemLocales) { + return this.sendQueryAndCloneForContent( + "AWPage:SET_REQUESTED_LOCALES", + requestSystemLocales + ); + } + + AWNegotiateLangPackForLanguageMismatch(appAndSystemLocaleInfo) { + return this.sendQueryAndCloneForContent( + "AWPage:NEGOTIATE_LANGPACK", + appAndSystemLocaleInfo + ); + } + + AWSendToDeviceEmailsSupported() { + return this.wrapPromise( + this.sendQuery("AWPage:SEND_TO_DEVICE_EMAILS_SUPPORTED") + ); + } + + AWNewScreen(screenId) { + return this.wrapPromise(this.sendQuery("AWPage:NEW_SCREEN", screenId)); + } + + /** + * @param {{type: string, detail?: any}} event + * @override + */ + handleEvent(event) { + lazy.log.debug(`Received page event ${event.type}`); + } +} + +const OPTIN_DEFAULT = { + id: "FAKESPOT_OPTIN_DEFAULT", + template: "multistage", + backdrop: "transparent", + aria_role: "alert", + UTMTerm: "opt-in", + screens: [ + { + id: "FS_OPT_IN", + content: { + position: "split", + title: { string_id: "shopping-onboarding-headline" }, + // We set the dynamic subtitle ID below at the same time as the args; + // to prevent intermittents caused by the args loading too late. + subtitle: { string_id: "" }, + above_button_content: [ + { + type: "text", + text: { + string_id: "shopping-onboarding-body", + }, + link_keys: ["learn_more"], + }, + { + type: "image", + url: "chrome://browser/content/shopping/assets/optInLight.avif", + darkModeImageURL: + "chrome://browser/content/shopping/assets/optInDark.avif", + marginInline: "24px", + }, + { + type: "text", + text: { + string_id: + "shopping-onboarding-opt-in-privacy-policy-and-terms-of-use3", + }, + link_keys: ["privacy_policy", "terms_of_use"], + font_styles: "legal", + }, + ], + learn_more: { + action: { + type: "OPEN_URL", + data: { + args: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/review-checker-review-quality?utm_source=review-checker&utm_campaign=learn-more&utm_medium=in-product", + where: "tab", + }, + }, + }, + privacy_policy: { + action: { + type: "OPEN_URL", + data: { + args: "https://www.mozilla.org/privacy/firefox?utm_source=review-checker&utm_campaign=privacy-policy&utm_medium=in-product&utm_term=opt-in-screen", + where: "tab", + }, + }, + }, + terms_of_use: { + action: { + type: "OPEN_URL", + data: { + args: "https://www.fakespot.com/terms?utm_source=review-checker&utm_campaign=terms-of-use&utm_medium=in-product", + where: "tab", + }, + }, + }, + primary_button: { + should_focus_button: true, + label: { string_id: "shopping-onboarding-opt-in-button" }, + action: { + type: "SET_PREF", + data: { + pref: { + name: "browser.shopping.experience2023.optedIn", + value: 1, + }, + }, + }, + }, + additional_button: { + label: { + string_id: "shopping-onboarding-not-now-button", + }, + style: "link", + flow: "column", + action: { + type: "SET_PREF", + data: { + pref: { + name: "browser.shopping.experience2023.active", + value: false, + }, + }, + }, + }, + }, + }, + ], +}; + +const SHOPPING_MICROSURVEY = { + id: "SHOPPING_MICROSURVEY", + template: "multistage", + backdrop: "transparent", + transitions: true, + UTMTerm: "survey", + screens: [ + { + id: "SHOPPING_MICROSURVEY_SCREEN_1", + above_button_steps_indicator: true, + content: { + position: "split", + layout: "survey", + steps_indicator: { + string_id: "shopping-onboarding-welcome-steps-indicator-label", + }, + title: { + string_id: "shopping-survey-headline", + }, + primary_button: { + label: { + string_id: "shopping-survey-next-button-label", + paddingBlock: "5px", + marginBlock: "0 12px", + }, + action: { + type: "MULTI_ACTION", + collectSelect: true, + data: { + actions: [], + }, + navigate: true, + }, + disabled: "hasActiveMultiSelect", + }, + additional_button: { + label: { + string_id: "shopping-survey-terms-link", + }, + style: "link", + flow: "column", + action: { + type: "OPEN_URL", + data: { + args: "https://www.mozilla.org/about/legal/terms/mozilla/?utm_source=review-checker&utm_campaign=terms-of-use-screen-1&utm_medium=in-product", + where: "tab", + }, + }, + }, + dismiss_button: { + action: { + dismiss: true, + }, + label: { + string_id: "shopping-onboarding-dialog-close-button", + }, + }, + tiles: { + type: "multiselect", + style: { + flexDirection: "column", + alignItems: "flex-start", + }, + label: { + string_id: "shopping-survey-question-one", + }, + data: [ + { + id: "radio-1", + type: "radio", + group: "radios", + defaultValue: false, + label: { string_id: "shopping-survey-q1-radio-1-label" }, + }, + { + id: "radio-2", + type: "radio", + group: "radios", + defaultValue: false, + label: { string_id: "shopping-survey-q1-radio-2-label" }, + }, + { + id: "radio-3", + type: "radio", + group: "radios", + defaultValue: false, + label: { string_id: "shopping-survey-q1-radio-3-label" }, + }, + { + id: "radio-4", + type: "radio", + group: "radios", + defaultValue: false, + label: { string_id: "shopping-survey-q1-radio-4-label" }, + }, + { + id: "radio-5", + type: "radio", + group: "radios", + defaultValue: false, + label: { string_id: "shopping-survey-q1-radio-5-label" }, + }, + ], + }, + }, + }, + { + id: "SHOPPING_MICROSURVEY_SCREEN_2", + above_button_steps_indicator: true, + content: { + position: "split", + layout: "survey", + steps_indicator: { + string_id: "shopping-onboarding-welcome-steps-indicator-label", + }, + title: { + string_id: "shopping-survey-headline", + }, + primary_button: { + label: { + string_id: "shopping-survey-submit-button-label", + paddingBlock: "5px", + marginBlock: "0 12px", + }, + action: { + type: "MULTI_ACTION", + collectSelect: true, + data: { + actions: [], + }, + navigate: true, + }, + disabled: "hasActiveMultiSelect", + }, + additional_button: { + label: { + string_id: "shopping-survey-terms-link", + }, + style: "link", + flow: "column", + action: { + type: "OPEN_URL", + data: { + args: "https://www.mozilla.org/about/legal/terms/mozilla/?utm_source=review-checker&utm_campaign=terms-of-use-screen-2&utm_medium=in-product", + where: "tab", + }, + }, + }, + dismiss_button: { + action: { + dismiss: true, + }, + label: { + string_id: "shopping-onboarding-dialog-close-button", + }, + }, + tiles: { + type: "multiselect", + style: { + flexDirection: "column", + alignItems: "flex-start", + }, + label: { + string_id: "shopping-survey-question-two", + }, + data: [ + { + id: "radio-1", + type: "radio", + group: "radios", + defaultValue: false, + label: { string_id: "shopping-survey-q2-radio-1-label" }, + }, + { + id: "radio-2", + type: "radio", + group: "radios", + defaultValue: false, + label: { string_id: "shopping-survey-q2-radio-2-label" }, + }, + { + id: "radio-3", + type: "radio", + group: "radios", + defaultValue: false, + label: { string_id: "shopping-survey-q2-radio-3-label" }, + }, + ], + }, + }, + }, + ], +}; + +const OPTED_IN_TIME_PREF = "browser.shopping.experience2023.survey.optedInTime"; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "isSurveySeen", + "browser.shopping.experience2023.survey.hasSeen", + false +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "pdpVisits", + "browser.shopping.experience2023.survey.pdpVisits", + 0 +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "optedInTime", + OPTED_IN_TIME_PREF, + 0 +); + +let optInDynamicContent; +// Limit pref increase to 5 as we don't need to count any higher than that +const MIN_VISITS_TO_SHOW_SURVEY = 5; +// Wait 24 hours after opt in to show survey +const MIN_TIME_AFTER_OPT_IN = 24 * 60 * 60; + +export class AboutWelcomeShoppingChild extends AboutWelcomeChild { + // Static state used to track session in which user opted-in + static optedInSession = false; + + // Static used to track PDP visits per session for showing survey + static eligiblePDPvisits = []; + + constructor() { + super(); + this.surveyEnabled = + lazy.NimbusFeatures.shopping2023.getVariable("surveyEnabled"); + + // Used by tests + this.resetChildStates = () => { + AboutWelcomeShoppingChild.eligiblePDPvisits.length = 0; + AboutWelcomeShoppingChild.optedInSession = false; + }; + } + + computeEligiblePDPCount(data) { + // Increment our pref if this isn't a page we've already seen this session + if (lazy.pdpVisits < MIN_VISITS_TO_SHOW_SURVEY) { + this.AWSendToParent("SPECIAL_ACTION", { + type: "SET_PREF", + data: { + pref: { + name: "browser.shopping.experience2023.survey.pdpVisits", + value: lazy.pdpVisits + 1, + }, + }, + }); + } + + // Add this product to our list of unique eligible PDPs visited + // to prevent errors caused by multiple events being fired simultaneously + AboutWelcomeShoppingChild.eligiblePDPvisits.push(data?.product_id); + } + + evaluateAndShowSurvey() { + // Re-evaluate if we should show the survey + // Render survey if user is opted-in and has met survey seen conditions + const now = Date.now() / 1000; + const hasBeen24HrsSinceOptin = + lazy.optedInTime && now - lazy.optedInTime >= MIN_TIME_AFTER_OPT_IN; + + this.showMicroSurvey = + this.surveyEnabled && + !lazy.isSurveySeen && + !AboutWelcomeShoppingChild.optedInSession && + lazy.pdpVisits >= MIN_VISITS_TO_SHOW_SURVEY && + hasBeen24HrsSinceOptin; + + if (this.showMicroSurvey) { + this.renderMessage(); + } + } + + setOptInTime() { + const now = Date.now() / 1000; + this.AWSendToParent("SPECIAL_ACTION", { + type: "SET_PREF", + data: { + pref: { + name: OPTED_IN_TIME_PREF, + value: now, + }, + }, + }); + } + + handleEvent(event) { + // Decide when to show/hide onboarding and survey message + const { productUrl, showOnboarding, data } = event.detail; + + // Display onboarding if a user hasn't opted-in + const optInReady = showOnboarding && productUrl; + if (optInReady) { + // Render opt-in message + AboutWelcomeShoppingChild.optedInSession = true; + this.AWSetProductURL(new URL(productUrl).hostname); + this.renderMessage(); + return; + } + + //Store timestamp if user opts in + if ( + Object.hasOwn(event.detail, "showOnboarding") && + !event.detail.showOnboarding && + !lazy.optedInTime + ) { + this.setOptInTime(); + } + // Hide the container until the user is eligible to see the survey + // or user has just completed opt-in + if (!lazy.isSurveySeen || AboutWelcomeShoppingChild.optedInSession) { + this.document.getElementById("multi-stage-message-root").hidden = true; + } + + // Early exit if user has seen survey, if we have no data, encountered + // an error, or if pdp is ineligible or not unique + if ( + lazy.isSurveySeen || + !data || + data.error || + !productUrl || + (data.needs_analysis && + (!data.product_id || !data.grade || !data.adjusted_rating)) || + AboutWelcomeShoppingChild.eligiblePDPvisits.includes(data.product_id) + ) { + return; + } + + this.computeEligiblePDPCount(data, productUrl); + this.evaluateAndShowSurvey(); + } + + renderMessage() { + this.document.getElementById("multi-stage-message-root").hidden = false; + this.document.dispatchEvent( + new this.contentWindow.CustomEvent("RenderWelcome", { + bubbles: true, + }) + ); + } + + // TODO - Move messages into an ASRouter message provider. See bug 1848251. + AWGetFeatureConfig() { + let messageContent = optInDynamicContent; + if (this.showMicroSurvey) { + messageContent = SHOPPING_MICROSURVEY; + this.setShoppingSurveySeen(); + } + return Cu.cloneInto(messageContent, this.contentWindow); + } + + setShoppingSurveySeen() { + this.AWSendToParent("SPECIAL_ACTION", { + type: "SET_PREF", + data: { + pref: { + name: "browser.shopping.experience2023.survey.hasSeen", + value: true, + }, + }, + }); + } + + // TODO - Add dismiss: true to the primary CTA so it cleans up the React + // content, which will stop being rendered on opt-in. See bug 1848429. + AWFinish() { + if (this._destroyed) { + return; + } + const root = this.document.getElementById("multi-stage-message-root"); + if (root) { + root.innerHTML = ""; + root + .appendChild(this.document.createElement("shopping-message-bar")) + .setAttribute("type", "thank-you-for-feedback"); + this.contentWindow.setTimeout(() => { + root.hidden = true; + }, 5000); + } + } + + AWSetProductURL(productUrl) { + let content = JSON.parse(JSON.stringify(OPTIN_DEFAULT)); + const [optInScreen] = content.screens; + + if (productUrl) { + optInScreen.content.subtitle.string_id = + "shopping-onboarding-dynamic-subtitle-1"; + + switch ( + productUrl // Insert the productUrl into content + ) { + case "www.amazon.fr": + case "www.amazon.de": + optInScreen.content.subtitle.string_id = + "shopping-onboarding-single-subtitle"; + optInScreen.content.subtitle.args = { + currentSite: "Amazon", + }; + break; + case "www.amazon.com": + optInScreen.content.subtitle.args = { + currentSite: "Amazon", + secondSite: "Walmart", + thirdSite: "Best Buy", + }; + break; + case "www.walmart.com": + optInScreen.content.subtitle.args = { + currentSite: "Walmart", + secondSite: "Amazon", + thirdSite: "Best Buy", + }; + break; + case "www.bestbuy.com": + optInScreen.content.subtitle.args = { + currentSite: "Best Buy", + secondSite: "Amazon", + thirdSite: "Walmart", + }; + break; + default: + optInScreen.content.subtitle.args = { + currentSite: "Amazon", + secondSite: "Walmart", + thirdSite: "Best Buy", + }; + } + } + + optInDynamicContent = content; + } + + AWEnsureLangPackInstalled() {} +} diff --git a/browser/components/aboutwelcome/actors/AboutWelcomeParent.sys.mjs b/browser/components/aboutwelcome/actors/AboutWelcomeParent.sys.mjs new file mode 100644 index 0000000000..1eb77da0d8 --- /dev/null +++ b/browser/components/aboutwelcome/actors/AboutWelcomeParent.sys.mjs @@ -0,0 +1,299 @@ +/* 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, { + AboutWelcomeDefaults: + "resource:///modules/aboutwelcome/AboutWelcomeDefaults.sys.mjs", + AboutWelcomeTelemetry: + "resource:///modules/aboutwelcome/AboutWelcomeTelemetry.sys.mjs", + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AWScreenUtils: "resource:///modules/aboutwelcome/AWScreenUtils.sys.mjs", + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", + BuiltInThemes: "resource:///modules/BuiltInThemes.sys.mjs", + FxAccounts: "resource://gre/modules/FxAccounts.sys.mjs", + LangPackMatcher: "resource://gre/modules/LangPackMatcher.sys.mjs", + ShellService: "resource:///modules/ShellService.sys.mjs", + SpecialMessageActions: + "resource://messaging-system/lib/SpecialMessageActions.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + const { Logger } = ChromeUtils.importESModule( + "resource://messaging-system/lib/Logger.sys.mjs" + ); + return new Logger("AboutWelcomeParent"); +}); + +ChromeUtils.defineLazyGetter( + lazy, + "Telemetry", + () => new lazy.AboutWelcomeTelemetry() +); + +const DID_SEE_ABOUT_WELCOME_PREF = "trailhead.firstrun.didSeeAboutWelcome"; +const AWTerminate = { + WINDOW_CLOSED: "welcome-window-closed", + TAB_CLOSED: "welcome-tab-closed", + APP_SHUT_DOWN: "app-shut-down", + ADDRESS_BAR_NAVIGATED: "address-bar-navigated", +}; +const LIGHT_WEIGHT_THEMES = { + AUTOMATIC: "default-theme@mozilla.org", + DARK: "firefox-compact-dark@mozilla.org", + LIGHT: "firefox-compact-light@mozilla.org", + ALPENGLOW: "firefox-alpenglow@mozilla.org", +}; + +class AboutWelcomeObserver { + constructor() { + Services.obs.addObserver(this, "quit-application"); + + this.win = Services.focus.activeWindow; + if (!this.win) { + return; + } + + this.terminateReason = AWTerminate.ADDRESS_BAR_NAVIGATED; + + this.onWindowClose = () => { + this.terminateReason = AWTerminate.WINDOW_CLOSED; + }; + + this.onTabClose = () => { + this.terminateReason = AWTerminate.TAB_CLOSED; + }; + + this.win.addEventListener("TabClose", this.onTabClose, { once: true }); + this.win.addEventListener("unload", this.onWindowClose, { once: true }); + } + + observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "quit-application": + this.terminateReason = AWTerminate.APP_SHUT_DOWN; + break; + } + } + + // Added for testing + get AWTerminate() { + return AWTerminate; + } + + stop() { + lazy.log.debug(`Terminate reason is ${this.terminateReason}`); + Services.obs.removeObserver(this, "quit-application"); + if (!this.win) { + return; + } + this.win.removeEventListener("TabClose", this.onTabClose); + this.win.removeEventListener("unload", this.onWindowClose); + this.win = null; + } +} + +export class AboutWelcomeParent extends JSWindowActorParent { + constructor() { + super(); + this.startAboutWelcomeObserver(); + } + + startAboutWelcomeObserver() { + this.AboutWelcomeObserver = new AboutWelcomeObserver(); + } + + // Static methods that calls into ShellService to check + // if Firefox is pinned or already default + static doesAppNeedPin() { + return lazy.ShellService.doesAppNeedPin(); + } + + static isDefaultBrowser() { + return lazy.ShellService.isDefaultBrowser(); + } + + didDestroy() { + if (this.AboutWelcomeObserver) { + this.AboutWelcomeObserver.stop(); + } + this.RegionHomeObserver?.stop(); + + lazy.Telemetry.sendTelemetry({ + event: "SESSION_END", + event_context: { + reason: this.AboutWelcomeObserver.terminateReason, + page: "about:welcome", + }, + message_id: this.AWMessageId, + }); + } + + /** + * Handle messages from AboutWelcomeChild.jsm + * + * @param {string} type + * @param {any=} data + * @param {Browser} the xul:browser rendering the page + */ + async onContentMessage(type, data, browser) { + lazy.log.debug(`Received content event: ${type}`); + switch (type) { + case "AWPage:SET_WELCOME_MESSAGE_SEEN": + this.AWMessageId = data; + try { + Services.prefs.setBoolPref(DID_SEE_ABOUT_WELCOME_PREF, true); + } catch (e) { + lazy.log.debug(`Fails to set ${DID_SEE_ABOUT_WELCOME_PREF}.`); + } + break; + case "AWPage:SPECIAL_ACTION": + return lazy.SpecialMessageActions.handleAction(data, browser); + case "AWPage:FXA_METRICS_FLOW_URI": + return lazy.FxAccounts.config.promiseMetricsFlowURI("aboutwelcome"); + case "AWPage:TELEMETRY_EVENT": + lazy.Telemetry.sendTelemetry(data); + break; + case "AWPage:GET_ATTRIBUTION_DATA": + let attributionData = + await lazy.AboutWelcomeDefaults.getAttributionContent(); + return attributionData; + case "AWPage:ENSURE_ADDON_INSTALLED": + return new Promise(resolve => { + let listener = { + onInstallEnded(install, addon) { + if (addon.id === data) { + lazy.AddonManager.removeInstallListener(listener); + resolve("complete"); + } + }, + onInstallCancelled() { + lazy.AddonManager.removeInstallListener(listener); + resolve("install cancelled"); + }, + onInstallFailed() { + lazy.AddonManager.removeInstallListener(listener); + resolve("install failed"); + }, + }; + lazy.AddonManager.addInstallListener(listener); + }); + case "AWPage:GET_ADDON_DETAILS": + let addonDetails = + await lazy.AboutWelcomeDefaults.getAddonFromRepository(data); + + return { + label: addonDetails.name, + icon: addonDetails.iconURL, + type: addonDetails.type, + screenshots: addonDetails.screenshots, + url: addonDetails.url, + }; + case "AWPage:SELECT_THEME": + await lazy.BuiltInThemes.ensureBuiltInThemes(); + return lazy.AddonManager.getAddonByID(LIGHT_WEIGHT_THEMES[data]).then( + addon => addon.enable() + ); + case "AWPage:GET_SELECTED_THEME": + let themes = await lazy.AddonManager.getAddonsByTypes(["theme"]); + let activeTheme = themes.find(addon => addon.isActive); + // Store the current theme ID so user can restore their previous theme. + if (activeTheme?.id) { + LIGHT_WEIGHT_THEMES.AUTOMATIC = activeTheme.id; + } + // convert this to the short form name that the front end code + // expects + let themeShortName = Object.keys(LIGHT_WEIGHT_THEMES).find( + key => LIGHT_WEIGHT_THEMES[key] === activeTheme?.id + ); + return themeShortName?.toLowerCase(); + case "AWPage:DOES_APP_NEED_PIN": + return AboutWelcomeParent.doesAppNeedPin(); + case "AWPage:NEED_DEFAULT": + // Only need to set default if we're supposed to check and not default. + return ( + Services.prefs.getBoolPref("browser.shell.checkDefaultBrowser") && + !AboutWelcomeParent.isDefaultBrowser() + ); + case "AWPage:WAIT_FOR_MIGRATION_CLOSE": + // Support multiples types of migration: 1) content modal 2) old + // migration modal 3) standalone content modal + return new Promise(resolve => { + const topics = [ + "MigrationWizard:Closed", + "MigrationWizard:Destroyed", + ]; + const observer = () => { + topics.forEach(t => Services.obs.removeObserver(observer, t)); + resolve(); + }; + topics.forEach(t => Services.obs.addObserver(observer, t)); + }); + case "AWPage:GET_APP_AND_SYSTEM_LOCALE_INFO": + return lazy.LangPackMatcher.getAppAndSystemLocaleInfo(); + case "AWPage:EVALUATE_SCREEN_TARGETING": + return lazy.AWScreenUtils.evaluateTargetingAndRemoveScreens(data); + case "AWPage:ADD_SCREEN_IMPRESSION": + return lazy.AWScreenUtils.addScreenImpression(data); + case "AWPage:NEGOTIATE_LANGPACK": + return lazy.LangPackMatcher.negotiateLangPackForLanguageMismatch(data); + case "AWPage:ENSURE_LANG_PACK_INSTALLED": + return lazy.LangPackMatcher.ensureLangPackInstalled(data); + case "AWPage:SET_REQUESTED_LOCALES": + return lazy.LangPackMatcher.setRequestedAppLocales(data); + case "AWPage:SEND_TO_DEVICE_EMAILS_SUPPORTED": { + return lazy.BrowserUtils.sendToDeviceEmailsSupported(); + } + default: + lazy.log.debug(`Unexpected event ${type} was not handled.`); + } + + return undefined; + } + + /** + * @param {{name: string, data?: any}} message + * @override + */ + receiveMessage(message) { + const { name, data } = message; + let browser; + + if (this.manager.rootFrameLoader) { + browser = this.manager.rootFrameLoader.ownerElement; + return this.onContentMessage(name, data, browser); + } + + lazy.log.warn(`Not handling ${name} because the browser doesn't exist.`); + return null; + } +} + +export class AboutWelcomeShoppingParent extends AboutWelcomeParent { + /** + * Handle messages from AboutWelcomeChild.jsm + * + * @param {string} type + * @param {any=} data + * @param {Browser} the xul:browser rendering the page + */ + onContentMessage(type, data, browser) { + // Only handle the messages that are relevant to the shopping page. + switch (type) { + case "AWPage:SPECIAL_ACTION": + case "AWPage:TELEMETRY_EVENT": + case "AWPage:EVALUATE_SCREEN_TARGETING": + case "AWPage:ADD_SCREEN_IMPRESSION": + return super.onContentMessage(type, data, browser); + } + + return undefined; + } + + // Override unnecessary methods + startAboutWelcomeObserver() {} + + didDestroy() {} +} diff --git a/browser/components/aboutwelcome/assets/confetti.svg b/browser/components/aboutwelcome/assets/confetti.svg new file mode 100644 index 0000000000..e00cd95120 --- /dev/null +++ b/browser/components/aboutwelcome/assets/confetti.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/components/aboutwelcome/assets/device-migration.svg b/browser/components/aboutwelcome/assets/device-migration.svg new file mode 100644 index 0000000000..d4beba87d9 --- /dev/null +++ b/browser/components/aboutwelcome/assets/device-migration.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/components/aboutwelcome/assets/fox-doodle-tail.png b/browser/components/aboutwelcome/assets/fox-doodle-tail.png new file mode 100644 index 0000000000..7f8fa37fa7 Binary files /dev/null and b/browser/components/aboutwelcome/assets/fox-doodle-tail.png differ diff --git a/browser/components/aboutwelcome/assets/fox-doodle-waving-static.png b/browser/components/aboutwelcome/assets/fox-doodle-waving-static.png new file mode 100644 index 0000000000..6ce8c828c7 Binary files /dev/null and b/browser/components/aboutwelcome/assets/fox-doodle-waving-static.png differ diff --git a/browser/components/aboutwelcome/assets/fox-doodle-waving.gif b/browser/components/aboutwelcome/assets/fox-doodle-waving.gif new file mode 100644 index 0000000000..1db8fc9ab6 Binary files /dev/null and b/browser/components/aboutwelcome/assets/fox-doodle-waving.gif differ diff --git a/browser/components/aboutwelcome/assets/heart.webp b/browser/components/aboutwelcome/assets/heart.webp new file mode 100644 index 0000000000..fb9f7fdca5 Binary files /dev/null and b/browser/components/aboutwelcome/assets/heart.webp differ diff --git a/browser/components/aboutwelcome/assets/long-zap.svg b/browser/components/aboutwelcome/assets/long-zap.svg new file mode 100644 index 0000000000..757a5483f9 --- /dev/null +++ b/browser/components/aboutwelcome/assets/long-zap.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/aboutwelcome/assets/mobile-download-qr-existing-user-cn.svg b/browser/components/aboutwelcome/assets/mobile-download-qr-existing-user-cn.svg new file mode 100644 index 0000000000..99d174a259 --- /dev/null +++ b/browser/components/aboutwelcome/assets/mobile-download-qr-existing-user-cn.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/browser/components/aboutwelcome/assets/mobile-download-qr-existing-user.svg b/browser/components/aboutwelcome/assets/mobile-download-qr-existing-user.svg new file mode 100644 index 0000000000..8c1662bf48 --- /dev/null +++ b/browser/components/aboutwelcome/assets/mobile-download-qr-existing-user.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/browser/components/aboutwelcome/assets/mobile-download-qr-new-user-cn.svg b/browser/components/aboutwelcome/assets/mobile-download-qr-new-user-cn.svg new file mode 100644 index 0000000000..cf25a0e18d --- /dev/null +++ b/browser/components/aboutwelcome/assets/mobile-download-qr-new-user-cn.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/aboutwelcome/assets/mobile-download-qr-new-user.svg b/browser/components/aboutwelcome/assets/mobile-download-qr-new-user.svg new file mode 100644 index 0000000000..9a8f61a95c --- /dev/null +++ b/browser/components/aboutwelcome/assets/mobile-download-qr-new-user.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/browser/components/aboutwelcome/assets/mr-amo-collection.svg b/browser/components/aboutwelcome/assets/mr-amo-collection.svg new file mode 100644 index 0000000000..3e2fd6bd7b --- /dev/null +++ b/browser/components/aboutwelcome/assets/mr-amo-collection.svg @@ -0,0 +1,4 @@ + + diff --git a/browser/components/aboutwelcome/assets/mr-gratitude.svg b/browser/components/aboutwelcome/assets/mr-gratitude.svg new file mode 100644 index 0000000000..f490d454c9 --- /dev/null +++ b/browser/components/aboutwelcome/assets/mr-gratitude.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/aboutwelcome/assets/mr-import.svg b/browser/components/aboutwelcome/assets/mr-import.svg new file mode 100644 index 0000000000..93034912ec --- /dev/null +++ b/browser/components/aboutwelcome/assets/mr-import.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/aboutwelcome/assets/mr-mobilecrosspromo.svg b/browser/components/aboutwelcome/assets/mr-mobilecrosspromo.svg new file mode 100644 index 0000000000..d613d4bdca --- /dev/null +++ b/browser/components/aboutwelcome/assets/mr-mobilecrosspromo.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/aboutwelcome/assets/mr-pinprivate.svg b/browser/components/aboutwelcome/assets/mr-pinprivate.svg new file mode 100644 index 0000000000..be3e0c0ba3 --- /dev/null +++ b/browser/components/aboutwelcome/assets/mr-pinprivate.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/aboutwelcome/assets/mr-pintaskbar.svg b/browser/components/aboutwelcome/assets/mr-pintaskbar.svg new file mode 100644 index 0000000000..386af26d89 --- /dev/null +++ b/browser/components/aboutwelcome/assets/mr-pintaskbar.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/aboutwelcome/assets/mr-privacysegmentation.svg b/browser/components/aboutwelcome/assets/mr-privacysegmentation.svg new file mode 100644 index 0000000000..feda0087d7 --- /dev/null +++ b/browser/components/aboutwelcome/assets/mr-privacysegmentation.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/aboutwelcome/assets/mr-rtamo-background-image.svg b/browser/components/aboutwelcome/assets/mr-rtamo-background-image.svg new file mode 100644 index 0000000000..25a66c2a96 --- /dev/null +++ b/browser/components/aboutwelcome/assets/mr-rtamo-background-image.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/aboutwelcome/assets/mr-settodefault.svg b/browser/components/aboutwelcome/assets/mr-settodefault.svg new file mode 100644 index 0000000000..a12306e87c --- /dev/null +++ b/browser/components/aboutwelcome/assets/mr-settodefault.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/aboutwelcome/assets/noodle-C.svg b/browser/components/aboutwelcome/assets/noodle-C.svg new file mode 100644 index 0000000000..f91f5f1083 --- /dev/null +++ b/browser/components/aboutwelcome/assets/noodle-C.svg @@ -0,0 +1,6 @@ + + + + diff --git a/browser/components/aboutwelcome/assets/noodle-outline-L.svg b/browser/components/aboutwelcome/assets/noodle-outline-L.svg new file mode 100644 index 0000000000..d2c1f29ff6 --- /dev/null +++ b/browser/components/aboutwelcome/assets/noodle-outline-L.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/browser/components/aboutwelcome/assets/noodle-solid-L.svg b/browser/components/aboutwelcome/assets/noodle-solid-L.svg new file mode 100644 index 0000000000..7e6a52d12f --- /dev/null +++ b/browser/components/aboutwelcome/assets/noodle-solid-L.svg @@ -0,0 +1,6 @@ + + + + diff --git a/browser/components/aboutwelcome/assets/person-typing.svg b/browser/components/aboutwelcome/assets/person-typing.svg new file mode 100644 index 0000000000..d05cda6a94 --- /dev/null +++ b/browser/components/aboutwelcome/assets/person-typing.svg @@ -0,0 +1,4 @@ + + diff --git a/browser/components/aboutwelcome/assets/short-zap.svg b/browser/components/aboutwelcome/assets/short-zap.svg new file mode 100644 index 0000000000..cc004ed607 --- /dev/null +++ b/browser/components/aboutwelcome/assets/short-zap.svg @@ -0,0 +1,4 @@ + + diff --git a/browser/components/aboutwelcome/content-src/aboutwelcome.jsx b/browser/components/aboutwelcome/content-src/aboutwelcome.jsx new file mode 100644 index 0000000000..28bef55998 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/aboutwelcome.jsx @@ -0,0 +1,144 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import ReactDOM from "react-dom"; +import { AboutWelcomeUtils } from "./lib/aboutwelcome-utils.mjs"; +import { MultiStageAboutWelcome } from "./components/MultiStageAboutWelcome"; +import { ReturnToAMO } from "./components/ReturnToAMO"; + +class AboutWelcome extends React.PureComponent { + constructor(props) { + super(props); + this.state = { metricsFlowUri: null }; + this.fetchFxAFlowUri = this.fetchFxAFlowUri.bind(this); + } + + async fetchFxAFlowUri() { + this.setState({ metricsFlowUri: await window.AWGetFxAMetricsFlowURI?.() }); + } + + componentDidMount() { + if (!this.props.skipFxA) { + this.fetchFxAFlowUri(); + } + + if (document.location.href === "about:welcome") { + // Record impression with performance data after allowing the page to load + const recordImpression = domState => { + const { domComplete, domInteractive } = performance + .getEntriesByType("navigation") + .pop(); + AboutWelcomeUtils.sendImpressionTelemetry(this.props.messageId, { + domComplete, + domInteractive, + mountStart: performance.getEntriesByName("mount").pop().startTime, + domState, + source: this.props.UTMTerm, + }); + }; + if (document.readyState === "complete") { + // Page might have already triggered a load event because it waited for async data, + // e.g., attribution, so the dom load timing could be of a empty content + // with domState in telemetry captured as 'complete' + recordImpression(document.readyState); + } else { + window.addEventListener("load", () => recordImpression("load"), { + once: true, + }); + } + + // Captures user has seen about:welcome by setting + // firstrun.didSeeAboutWelcome pref to true and capturing welcome UI unique messageId + window.AWSendToParent("SET_WELCOME_MESSAGE_SEEN", this.props.messageId); + } + } + + render() { + const { props } = this; + if (props.template === "return_to_amo") { + return ( + + ); + } + return ( + + ); + } +} + +// Computes messageId and UTMTerm info used in telemetry +function ComputeTelemetryInfo(welcomeContent, experimentId, branchId) { + let messageId = + welcomeContent.template === "return_to_amo" + ? `RTAMO_DEFAULT_WELCOME_${welcomeContent.type.toUpperCase()}` + : "DEFAULT_ID"; + let UTMTerm = "aboutwelcome-default"; + + if (welcomeContent.id) { + messageId = welcomeContent.id.toUpperCase(); + } + + if (experimentId && branchId) { + UTMTerm = `aboutwelcome-${experimentId}-${branchId}`.toLowerCase(); + } + return { + messageId, + UTMTerm, + }; +} + +async function retrieveRenderContent() { + // Feature config includes RTAMO attribution data if exists + // else below data in order specified + // user prefs + // experiment data + // defaults + let featureConfig = await window.AWGetFeatureConfig(); + + let { messageId, UTMTerm } = ComputeTelemetryInfo( + featureConfig, + featureConfig.slug, + featureConfig.branch && featureConfig.branch.slug + ); + return { featureConfig, messageId, UTMTerm }; +} + +async function mount() { + let { + featureConfig: aboutWelcomeProps, + messageId, + UTMTerm, + } = await retrieveRenderContent(); + ReactDOM.render( + , + document.getElementById("multi-stage-message-root") + ); +} + +performance.mark("mount"); +mount(); diff --git a/browser/components/aboutwelcome/content-src/aboutwelcome.scss b/browser/components/aboutwelcome/content-src/aboutwelcome.scss new file mode 100644 index 0000000000..aa49a04799 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/aboutwelcome.scss @@ -0,0 +1,1940 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +@use 'sass:math'; + +// Don't import any styles that are not scoped to .onboardingContainer. This +// stylesheet is loaded by FeatureCallout.sys.mjs into the browser chrome. To +// add other stylesheets to about:welcome or spotlight, add them to +// aboutwelcome.html or spotlight.html. Ideally, there should be no `@import` +// statements in the built aboutwelcome.css file. +@import '../../asrouter/content-src/styles/feature-callout'; +@import '../../asrouter/content-src/styles/shopping'; + +/* stylelint-disable max-nesting-depth */ + +$break-point-small: 570px; +$break-point-medium: 610px; +$break-point-large: 866px; +$container-min-width: 700px; +$logo-size: 80px; +$main-section-width: 504px; +$split-section-width: 400px; +$split-screen-height: 550px; +$small-main-section-height: 450px; +$small-secondary-section-height: 100px; +$noodle-buffer: 106px; +$double-section-width: 800px; + +html { + height: 100%; +} + +// Below variables are used via config JSON in AboutWelcomeDefaults +// and referenced below inside dummy class to pass test browser_parsable_css +.dummy { + background: var(--mr-welcome-background-color) var(--mr-welcome-background-gradient) var(--mr-secondary-position) var(--mr-screen-background-color); +} + +// Styling for content rendered in a Spotlight messaging surface. +:root { + &[dialogroot] { + background-color: transparent; + + body { + padding: 0; + } + + .onboardingContainer { + // Without this, the container will be 100vh in height. When the dialog + // overflows horizontally, the horizontal scrollbar will appear. If the + // scrollbars aren't overlay scrollbars (this is controlled by + // Theme::ScrollbarStyle), they will take up vertical space in the + // viewport, causing the dialog to overflow vertically. This causes the + // vertical scrollbar to appear, which takes up horizontal space, causing + // the horizontal scrollbar to appear, and so on. + height: 100%; + background-color: transparent; + + &:dir(rtl) { + transform: unset; + } + + .logo-container { + pointer-events: none; + } + + .screen { + &:dir(rtl) { + transform: unset; + } + } + } + } +} + +// Styling for about:welcome background container +.welcome-container { + .onboardingContainer { + min-height: $break-point-medium; + min-width: fit-content; + } +} + +.onboardingContainer { + --grey-subtitle-1: #696977; + --mr-welcome-background-color: #F8F6F4; + --mr-screen-heading-color: var(--in-content-text-color); + --mr-welcome-background-gradient: linear-gradient(0deg, rgba(144, 89, 255, 20%) 0%, rgba(2, 144, 238, 20%) 100%); + --mr-screen-background-color: #F8F6F4; + + @media (prefers-color-scheme: dark) { + --grey-subtitle-1: #FFF; + --mr-welcome-background-color: #333336; + --mr-welcome-background-gradient: linear-gradient(0deg, rgba(144, 89, 255, 30%) 0%, rgba(2, 144, 238, 30%) 100%); + --mr-screen-background-color: #62697A; + } + + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Ubuntu, + 'Helvetica Neue', sans-serif; + font-size: 16px; + position: relative; + text-align: center; + height: 100vh; + + @media (prefers-contrast) { + --mr-screen-background-color: buttontext; + --mr-screen-heading-color: buttonface; + + background-color: var(--in-content-page-background); + } + + // Transition all of these and reduced motion effectively only does opacity. + --transition: 0.6s opacity, 0.6s scale, 0.6s rotate, 0.6s translate; + + // Define some variables that are used for in/out states. + @media (prefers-reduced-motion: no-preference) { + --translate: 30px; + --rotate: 20deg; + --scale: 0.4; + --progress-bar-transition: 0.6s translate; + + // Scale is used for noodles that can be flipped. + &:dir(rtl) { + --scale: -0.4 0.4; + } + } + + // Use default values that match "unmoved" state to avoid motion. + @media (prefers-reduced-motion: reduce) { + --translate: 0; + --rotate: 0deg; + --scale: 1; + // To reduce motion, progress bar fades in instead of wiping in. + --progress-bar-transition: none; + + &:dir(rtl) { + --scale: -1 1; + } + } + + &:dir(rtl) { + transform: rotateY(180deg); + } + + .section-main { + display: flex; + flex-direction: column; + justify-content: center; + width: $main-section-width; + flex-shrink: 0; + } + + .section-main:not(.embedded-migration) { + position: relative; + } + + .main-content { + background-color: var(--in-content-page-background); + border-radius: 20px; + box-shadow: 0 2px 14px 0 rgba(0, 0, 0, 20%); + display: flex; + flex-direction: column; + height: 100%; + padding: 0; + transition: var(--transition); + z-index: 1; + box-sizing: border-box; + + &.no-steps { + padding-bottom: 48px; + } + + .main-content-inner { + display: flex; + flex-direction: column; + flex-grow: 1; + justify-content: space-around; + } + } + + // Handle conditional display of steps indicator + // Don't show when there's only one screen + .main-content .no-steps { + .main-content { + padding-bottom: 48px; + } + + .steps { + display: none; + } + } + + @mixin arrow-icon-styles { + .arrow-icon { + -moz-context-properties: fill; + fill: currentColor; + text-decoration: none; + + &::after { + content: ''; + padding-inline-end: 12px; + margin-inline-start: 4px; + background: url('chrome://browser/skin/forward.svg') no-repeat center / 12px; + } + + &:dir(rtl)::after { + background-image: url('chrome://browser/skin/back.svg'); + } + } + } + + @mixin secondary-cta-styles { + background-color: var(--in-content-button-background); + border: 1px solid var(--in-content-button-border-color); + line-height: 12px; + font-size: 0.72em; + font-weight: 600; + padding: 8px 16px; + text-decoration: none; + cursor: default; + + &:hover, + &[open] { + background-color: var(--in-content-button-background-hover); + color: var(--in-content-button-text-color-hover); + + &:active { + background-color: var(--in-content-button-background-active); + color: var(--in-content-button-text-color-active); + } + } + } + + @mixin text-link-styles { + background: none; + text-decoration: underline; + cursor: pointer; + color: var(--link-color); + + &:hover { + color: var(--link-color-hover); + } + + &:active { + color: var(--link-color-active); + + @media (prefers-contrast) { + text-decoration: none; + } + } + } + + .screen { + display: flex; + position: relative; + flex-flow: row nowrap; + height: 100%; + min-height: 500px; + overflow: hidden; + + &.light-text { + --in-content-page-color: rgb(251, 251, 254); + --in-content-primary-button-text-color: rgb(43, 42, 51); + --in-content-primary-button-text-color-hover: rgb(43, 42, 51); + --in-content-primary-button-background: rgb(0, 221, 255); + --in-content-primary-button-background-hover: rgb(128, 235, 255); + --in-content-primary-button-background-active: rgb(170, 242, 255); + --in-content-button-text-color: var(--in-content-page-color); + } + + &.dark-text { + --in-content-page-color: rgb(21, 20, 26); + --in-content-primary-button-text-color: rgb(251, 251, 254); + --in-content-primary-button-text-color-hover: rgb(251, 251, 254); + --in-content-primary-button-background: #0061E0; + --in-content-primary-button-background-hover: #0250BB; + --in-content-primary-button-background-active: #053E94; + --in-content-primary-button-border-color: transparent; + --in-content-primary-button-border-hover: transparent; + --in-content-button-text-color: var(--in-content-page-color); + } + + &:dir(rtl) { + transform: rotateY(180deg); + } + + &[pos='center'] { + background-color: rgba(21, 20, 26, 50%); + min-width: $main-section-width; + + &.with-noodles { + // Adjust for noodles partially extending out from the square modal + min-width: $main-section-width + $noodle-buffer; + min-height: $main-section-width + $noodle-buffer; + + .section-main { + height: $main-section-width; + } + } + + &.with-video { + justify-content: center; + background: none; + align-items: center; + + .section-main { + width: $double-section-width; + height: $split-screen-height; + } + + .main-content { + background-color: var(--mr-welcome-background-color); + border-radius: 8px; + box-shadow: 0 2px 14px rgba(58, 57, 68, 20%); + padding: 44px 85px 20px; + + .welcome-text { + margin: 0; + } + + .main-content-inner { + justify-content: space-between; + } + + h1, + h2 { + align-self: start; + } + + h1 { + font-size: 24px; + line-height: 28.8px; + } + + h2 { + font-size: 15px; + line-height: 22px; + } + + .secondary-cta { + @include arrow-icon-styles; + + justify-content: end; + + .secondary { + @include secondary-cta-styles; + + color: var(--in-content-button-text-color); + } + } + } + } + + &.addons-picker { + justify-content: center; + align-items: center; + background: none; + + .section-main { + width: $double-section-width; + height: $split-screen-height; + } + + .main-content { + background-color: var(--in-content-page-background); + border-radius: 8px; + box-shadow: 0 2px 14px rgba(58, 57, 68, 20%); + overflow: hidden; + + .welcome-text { + display: flex; + margin: 0; + } + + .main-content-inner { + padding: 25px 56px 0; + justify-content: space-between; + } + + h1, + h2 { + align-self: start; + } + + h2 { + font-size: 15px; + text-align: start; + } + + .brand-logo { + display: block; + margin: 40px 56px 0; + transition: var(--transition); + height: 30px; + } + + .additional-cta { + display: block; + margin: 8px 0; + + &.cta-link { + background: none; + padding: 0; + font-weight: normal; + + @include text-link-styles; + } + + &.secondary { + &:hover { + background-color: var(--in-content-button-background-hover); + } + } + } + + .secondary-cta { + @include arrow-icon-styles; + + justify-content: end; + + .secondary { + @include secondary-cta-styles; + + color: var(--in-content-button-text-color); + } + } + } + + .addon-container { + display: flex; + border: 1px solid var(--in-content-border-color); + box-shadow: 0 1px 2px 0 var(--in-content-border-color); + border-radius: 4px; + margin: 5px auto; + text-align: start; + + .rtamo-icon { + img { + margin: 10px; + } + } + + .addon-details { + display: grid; + width: 70%; + } + + .addon-title { + margin: 10px 0 3px; + font-size: 16px; + font-weight: 600; + } + + .addon-description { + margin: 2px 0 10px; + font-size: 13px; + font-weight: 400; + } + + .install-button-wrapper { + display: flex; + } + + button { + align-self: center; + width: 124px; + } + } + + .loader { + width: 1em; + height: 1em; + border: 3px solid var(--in-content-primary-button-text-color); + border-bottom-color: transparent; + border-radius: 50%; + box-sizing: border-box; + animation: rotation 1s linear infinite; + justify-self: center; + } + + @keyframes rotation { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } + } + + .loaderContainer { + display: flex; + padding: 1.5px 37.5px; + margin: auto; + } + } + } + + &:not([pos='split']) { + .secondary-cta { + .secondary { + @include text-link-styles; + + font-size: 14px; + font-weight: normal; + line-height: 20px; + } + + &.top { + button { + color: #FFF; + + &:hover { + color: #E0E0E6; + } + } + } + } + + migration-wizard { + padding: 5px 60px; + + &::part(header){ + text-align: center; + } + + &::part(buttons){ + margin: 32px auto 0; + } + } + + .welcome-text { + &:empty { + margin: 0; + } + } + } + + &[pos='split'] { + margin: auto; + min-height: $split-screen-height; + + &::before { + content: ''; + position: absolute; + box-shadow: 0 2px 14px 0 rgba(0, 0, 0, 20%); + width: $split-section-width + $split-section-width; + height: $split-screen-height; + border-radius: 8px; + inset: 0; + margin: auto; + pointer-events: none; + } + + .section-secondary, + .section-main { + width: $split-section-width; + height: $split-screen-height; + } + + .secondary-cta.top { + position: fixed; + padding-inline-end: 0; + + button { + padding: 7px 15px; + } + } + + .section-main { + flex-direction: row; + display: block; + margin: auto auto auto 0; + + &:dir(rtl) { + margin: auto 0 auto auto; + } + + &.embedded-migration { + .main-content { + padding-block: 100px 0; + } + } + + .main-content { + border-radius: 0 8px 8px 0; + overflow: hidden; + padding-inline: 35px 20px; + padding-block: 120px 0; + box-shadow: none; + + &.no-steps { + padding-bottom: 48px; + } + + &:dir(rtl) { + border-radius: 8px 0 0 8px; + } + + .main-content-inner { + min-height: 330px; + + .mobile-download-buttons { + padding: 0; + margin-inline-start: -5px; + display: flex; + + button { + cursor: pointer; + } + } + + .qr-code-image { + margin: 5px 0 10px; + display: flex; + } + + .language-switcher-container { + .primary { + margin-bottom: 5px; + } + } + } + + .action-buttons { + position: relative; + text-align: initial; + height: 100%; + + .checkbox-container { + font-size: 13px; + margin-block: 1em; + + &:not(.multi-select-item) { + transition: var(--transition); + } + + input, + label { + vertical-align: middle; + } + } + + .additional-cta-box { + margin: 8px 0; + + .additional-cta { + margin: 0; + + &.cta-link { + @include text-link-styles; + + padding: 0; + font-weight: normal; + } + + &.secondary { + &:hover, + &[open] { + background-color: var(--in-content-button-background-hover); + } + } + } + } + + &.additional-cta-container { + flex-wrap: nowrap; + align-items: start; + } + + .secondary-cta { + position: absolute; + bottom: -30px; + inset-inline-end: 0; + + .secondary { + @include secondary-cta-styles; + } + + @include arrow-icon-styles; + } + } + + .logo-container { + text-align: start; + } + + .brand-logo { + height: 25px; + margin-block: 0; + } + + .logo-alt { + width: inherit; + height: inherit; + } + + .welcome-text { + margin-inline: 0 10px; + margin-block: 10px 35px; + text-align: initial; + align-items: initial; + + &:empty { + margin: 0; + } + + h1 { + font-size: 24px; + line-height: 1.2; + width: 300px; + } + + h2 { + margin: 10px 0 0; + min-height: 1em; + font-size: 15px; + line-height: 1.5; + + @media (prefers-contrast: no-preference) { + color: #5B5B66; + } + } + } + + .welcome-text h1, + .primary { + margin: 0; + } + + .steps { + z-index: 1; + + &.progress-bar { + width: $split-section-width; + margin-inline: -35px; + } + } + + @media (prefers-contrast) { + border: 1px solid var(--in-content-page-color); + + .steps.progress-bar { + border-top: 1px solid var(--in-content-page-color); + background-color: var(--in-content-page-background); + + .indicator { + background-color: var(--in-content-accent-color); + } + } + } + } + } + + .section-secondary { + --mr-secondary-position: center center / auto 350px; + + border-radius: 8px 0 0 8px; + margin: auto 0 auto auto; + display: flex; + align-items: center; + -moz-context-properties: fill, stroke, fill-opacity, stroke-opacity; + stroke: currentColor; + + &:dir(rtl) { + border-radius: 0 8px 8px 0; + margin: auto auto auto 0; + } + + h1 { + color: var(--mr-screen-heading-color); + font-weight: 700; + font-size: 47px; + line-height: 110%; + max-width: 340px; + text-align: initial; + white-space: pre-wrap; + text-shadow: none; + margin-inline: 40px 0; + } + + .image-alt { + width: inherit; + height: inherit; + } + + .hero-image { + flex: 1; + display: flex; + justify-content: center; + max-height: 100%; + + img { + width: 100%; + max-width: 180px; + margin: 25px 0; + padding-bottom: 30px; + + @media only screen and (width <= 800px) { + padding-bottom: unset; + } + } + } + } + + .multi-select-container { + margin-inline: 0 10px; + + @media only screen and (width <= 800px) { + flex-direction: column; + align-self: center; + align-items: start; + justify-content: center; + width: 240px; + padding: 0 30px; + margin-inline: 0; + box-sizing: content-box; + } + } + + .tiles-theme-container { + margin-block: -20px auto; + align-items: initial; + + .theme { + min-width: 38px; + } + } + + @media (prefers-contrast: no-preference) and (prefers-color-scheme: dark) { + .section-main .main-content { + .welcome-text h2 { + color: #CFCFD8; + } + + .action-buttons .secondary { + background-color: #2B2A33; + } + } + } + + @media only screen and (width >= 800px) { + .tiles-theme-section { + margin-inline-start: -10px; + } + } + + @media only screen and (width <= 800px) { + flex-direction: column; + min-height: $small-main-section-height + $small-secondary-section-height; + + &::before { + width: $split-section-width; + } + + .section-secondary, + .section-main { + width: $split-section-width; + } + + .section-secondary { + --mr-secondary-background-position-y: top; + --mr-secondary-position: center var(--mr-secondary-background-position-y) / 75%; + + border-radius: 8px 8px 0 0; + margin: auto auto 0; + height: $small-secondary-section-height; + + .hero-image img { + margin: 6px 0; + } + + .message-text { + margin-inline: auto; + } + + h1 { + font-size: 35px; + text-align: center; + white-space: normal; + margin-inline: auto; + margin-block: 14px 6px; + } + + &:dir(rtl) { + margin: auto auto 0; + border-radius: 8px 8px 0 0; + } + + &.with-secondary-section-hidden { + display: none; + } + } + + migration-wizard { + &::part(deck){ + min-width: 330px; + margin-inline: 36px; + } + } + + .section-main { + margin: 0 auto auto; + height: $small-main-section-height; + + migration-wizard::part(buttons) { + flex-direction: column; + margin-inline: 46px; + } + + &[hide-secondary-section='responsive'] { + height: $split-screen-height; + margin: auto; + + .main-content { + padding: 50px 0 0; + border-radius: 8px; + } + } + + .main-content { + border-radius: 0 0 8px 8px; + padding: 30px 0 0; + + .main-content-inner { + align-items: center; + } + + .logo-container { + text-align: center; + + .brand-logo { + min-height: 25px; + + &, + &:dir(rtl) { + background-position: center; + } + } + + .logo-alt { + width: inherit; + height: inherit; + } + } + + .welcome-text { + align-items: center; + text-align: center; + margin-inline: 0; + padding-inline: 30px; + + .spacer-bottom, + .spacer-top { + display: none; + } + } + + .action-buttons { + text-align: center; + + .checkbox-container { + display: none; + } + + .secondary-cta { + position: relative; + margin-block: 10px 0; + bottom: 0; + } + } + + .primary, + .secondary { + min-width: 240px; + margin-inline: 0; + } + + .steps { + padding-block: 0; + margin: 0; + + &.progress-bar { + margin-inline: 0; + } + } + } + + .additional-cta { + &.cta-link { + align-self: center; + } + } + + .dismiss-button { + top: -$small-secondary-section-height; + } + + &:dir(rtl) { + margin: 0 auto auto; + + .main-content { + border-radius: 0 0 8px 8px; + } + } + } + + } + + @media only screen and (height <= 650px) { + // Hide the "Sign in" button on the welcome screen when it would + // otherwise overlap the screen. We'd reposition it, but then it would + // overlap the dismiss button. We may change the alignment so they don't + // overlap in a future revision. + @media (800px <= width <= 990px) { + .section-main .secondary-cta.top { + display: none; + } + } + + // Reposition the "Sign in" button on the welcome screen to move inside + // the screen when it would otherwise overlap the screen. + @media (width <= 620px) { + .section-main .secondary-cta.top { + position: absolute; + padding: 0; + top: 0; + inset-inline-end: 0; + } + } + } + } + } + + .brand-logo { + margin-block: 60px 10px; + transition: var(--transition); + height: 80px; + + &.cta-top { + margin-top: 25px; + } + + &.hide { + visibility: hidden; + padding: unset; + margin-top: 50px; + } + } + + .logo-alt { + width: inherit; + height: inherit; + } + + .rtamo-theme-icon { + max-height: 30px; + border-radius: 2px; + margin-bottom: 10px; + margin-top: 24px; + } + + .rtamo-icon { + text-align: start; + + @media only screen and (width <= 800px) { + text-align: center; + } + } + + .text-link { + @include text-link-styles; + } + + .welcome-text { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin: 0.5em 1em; + transition: var(--transition); + + h1, + h2 { + color: var(--in-content-page-color); + line-height: 1.5; + } + + h1 { + font-size: 24px; + font-weight: 600; + margin: 0 6px; + letter-spacing: -0.02em; + outline: none; + } + + h2 { + font-size: 16px; + font-weight: normal; + margin: 10px 6px 0; + max-width: 750px; + letter-spacing: -0.01em; + } + + &.fancy { + h1 { + background-image: linear-gradient(90deg, #9059FF, #FF4AA2, #FF8C00, #FF4AA2, #9059FF); + background-clip: text; + background-size: 200%; + + @media (prefers-contrast: no-preference) { + color: transparent; + } + + @media (prefers-color-scheme: dark) { + background-image: linear-gradient(90deg, #C688FF, #FF84C0, #FFBD4F, #FF84C0, #C688FF); + + &::selection { + color: #FFF; + background-color: #696977; + } + } + } + } + + &.shine { + h1 { + animation: shine 50s linear infinite; + background-size: 400%; + } + + @keyframes shine { + to { + background-position: 400%; + } + } + } + + .cta-paragraph { + a { + margin: 0; + text-decoration: underline; + cursor: pointer; + } + } + } + + // Override light and dark mode fancy title colors for use over light and dark backgrounds + .screen.light-text .welcome-text.fancy h1 { + background-image: linear-gradient(90deg, #C688FF, #FF84C0, #FFBD4F, #FF84C0, #C688FF); + } + + .screen.dark-text .welcome-text.fancy h1 { + background-image: linear-gradient(90deg, #9059FF, #FF4AA2, #FF8C00, #FF4AA2, #9059FF); + } + + .welcomeZap { + span { + position: relative; + z-index: 1; + white-space: nowrap; + } + + .zap { + &::after { + display: block; + background-repeat: no-repeat; + background-size: 100% 100%; + content: ''; + position: absolute; + top: calc(100% - 0.15em); + width: 100%; + height: 0.3em; + left: 0; + z-index: -1; + transform: scaleY(3); + } + + &.short::after { + background-image: url('chrome://activity-stream/content/data/content/assets/short-zap.svg'); + } + + &.long::after { + background-image: url('chrome://activity-stream/content/data/content/assets/long-zap.svg'); + } + } + } + + .language-loader { + filter: invert(1); + margin-inline-end: 10px; + position: relative; + top: 3px; + width: 16px; + height: 16px; + margin-top: -6px; + } + + @media (prefers-color-scheme: dark) { + .language-loader { + filter: invert(0); + } + } + + .tiles-theme-container { + display: flex; + flex-direction: column; + align-items: center; + margin: 10px auto; + } + + .sr-only { + opacity: 0; + overflow: hidden; + position: absolute; + + &.input { + height: 1px; + width: 1px; + } + } + + .tiles-theme-section { + border: 0; + display: flex; + flex-wrap: wrap; + gap: 5px; + justify-content: space-evenly; + margin-inline: 10px; + padding: 10px; + transition: var(--transition); + + &:hover, + &:active, + &:focus-within { + border-radius: 8px; + outline: 2px solid var(--in-content-primary-button-background); + } + + .theme { + align-items: center; + display: flex; + flex-direction: column; + flex: 1; + padding: 0; + min-width: 50px; + width: 180px; + color: #000; + box-shadow: none; + border-radius: 4px; + cursor: pointer; + z-index: 0; + + &:focus, + &:active { + outline: initial; + outline-offset: initial; + } + + .icon { + background-size: cover; + width: 40px; + height: 40px; + border-radius: 40px; + outline: 1px solid var(--in-content-border-color); + outline-offset: -0.5px; + z-index: -1; + + &:dir(rtl) { + transform: scaleX(-1); + } + + &:focus, + &:active, + &.selected { + outline: 2px solid var(--in-content-primary-button-background); + outline-offset: 2px; + } + + &.light { + background-image: url('resource://builtin-themes/light/icon.svg'); + } + + &.dark { + background-image: url('resource://builtin-themes/dark/icon.svg'); + } + + &.alpenglow { + background-image: url('resource://builtin-themes/alpenglow/icon.svg'); + } + + &.default, + &.automatic { + background-image: url('resource://default-theme/icon.svg'); + } + } + + .text { + display: flex; + color: var(--in-content-page-color); + font-size: 14px; + font-weight: normal; + line-height: 20px; + margin-inline-start: 0; + margin-top: 9px; + } + } + + legend { + cursor: default; + } + } + + .tiles-container { + margin: 10px auto; + + &.info { + padding: 6px 12px 12px; + + &:hover, + &:focus { + background-color: rgba(217, 217, 227, 30%); + border-radius: 4px; + } + } + } + + .tiles-delayed { + animation: fadein 0.4s; + } + + .multi-select-container { + display: flex; + flex-direction: column; + flex-wrap: wrap; + flex-shrink: 0; + align-items: flex-start; + gap: 16px; + margin-block: -1em 2em; + margin-inline: 1em; + color: #5B5B66; + font-weight: 400; + font-size: 14px; + text-align: initial; + transition: var(--transition); + z-index: 1; + + #multi-stage-multi-select-label { + // These styles are based on .welcome-text>h2 (subtitle). + color: var(--in-content-page-color); + line-height: 1.5; + font-size: 16px; + font-weight: normal; + letter-spacing: -0.01em; + // Try to get the label positioned the same way it would be if it was a + // subtitle. -0.5em for the welcome-text margin, 1em for the + // multi-select-container margin, and 10px for the desired margin between + // the label and the title. + margin: calc(-0.5em + 1em + 10px) 6px 0; + max-width: 750px; + } + + @at-root .onboardingContainer .screen[pos='split'] .multi-select-container #multi-stage-multi-select-label { + margin: calc(-35px + 1em + 10px) 0 0; + min-height: 1em; + font-size: 15px; + line-height: 1.5; + + @media (prefers-contrast: no-preference) { + color: #5B5B66; + } + + @media (prefers-contrast: no-preference) and (prefers-color-scheme: dark) { + color: #CFCFD8; + } + } + + .checkbox-container { + display: flex; + } + + @media (prefers-contrast: no-preference) and (prefers-color-scheme: dark) { + color: #CFCFD8; + } + } + + .mobile-downloads { + .qr-code-image { + margin: 24px 0 10px; + width: 113px; + height: 113px; + } + + .email-link { + @include text-link-styles; + + font-size: 16px; + font-weight: 400; + + &:hover { + background: none; + } + } + + .ios button { + background-image: url('chrome://app-marketplace-icons/locale/ios.svg'); + } + + .android button { + background-image: url('chrome://app-marketplace-icons/locale/android.png'); + } + } + + .mobile-download-buttons { + list-style: none; + padding: 10px 0; + margin: 0; + + li { + display: inline-block; + + button { + display: inline-block; + height: 45px; + width: 152px; + background-repeat: no-repeat; + background-size: contain; + background-position: center; + box-shadow: none; + border: 0; + } + + &:not(:first-child) { + margin-inline: 5px 0; + } + } + } + + .dismiss-button { + position: absolute; + z-index: 2; + top: 0; + left: auto; + right: 0; + box-sizing: border-box; + padding: 0; + margin: 16px; + display: block; + float: inline-end; + background: url('chrome://global/skin/icons/close.svg') no-repeat center / 16px; + height: 32px; + width: 32px; + align-self: end; + // override default min-height and min-width for buttons + min-height: 32px; + min-width: 32px; + -moz-context-properties: fill; + fill: currentColor; + transition: var(--transition); + + &:dir(rtl) { + left: 0; + right: auto; + } + } + + @keyframes fadein { + from { opacity: 0; } + } + + .secondary-cta { + display: flex; + align-items: end; + flex-direction: row; + justify-content: center; + font-size: 14px; + transition: var(--transition); + + &.top { + justify-content: end; + padding-inline-end: min(150px, 500px - 70vh); + padding-top: 4px; + position: absolute; + top: 10px; + inset-inline-end: 20px; + z-index: 2; + } + + span { + color: var(--grey-subtitle-1); + margin: 0 4px; + } + } + + .message-text { + transition: var(--transition); + } + + .helptext { + padding: 1em; + text-align: center; + color: var(--grey-subtitle-1); + font-size: 12px; + line-height: 18px; + + &.default { + align-self: center; + max-width: 40%; + } + + span { + padding-inline-end: 4px; + } + } + + .helptext-img { + height: 1.5em; + width: 1.5em; + margin-inline-end: 4px; + vertical-align: middle; + + &.end { + margin: 4px; + } + + &.footer { + vertical-align: bottom; + } + } + + .steps { + display: flex; + flex-direction: row; + justify-content: center; + margin-top: 0; + padding-block: 16px 0; + transition: var(--transition); + z-index: -1; + height: 48px; + box-sizing: border-box; + + &.has-helptext { + padding-bottom: 0; + } + + .indicator { + width: 0; + height: 0; + margin-inline-end: 4px; + margin-inline-start: 4px; + background: var(--grey-subtitle-1); + border-radius: 5px; + // using border will show up in Windows High Contrast Mode to improve accessibility. + border: 3px solid var(--in-content-button-text-color); + opacity: 0.35; + box-sizing: inherit; + + &.current { + opacity: 1; + border-color: var(--in-content-primary-button-background); + + // This is the only step shown, so visually hide it to maintain spacing. + &:last-of-type:first-of-type { + opacity: 0; + } + } + } + + &.progress-bar { + height: 6px; + padding-block: 0; + margin-block: 42px 0; + background-color: color-mix(in srgb, var(--in-content-button-text-color) 25%, transparent); + justify-content: start; + opacity: 1; + transition: none; + + .indicator { + width: 100%; + height: 100%; + margin-inline: -1px; + background-color: var(--in-content-primary-button-background); + border: 0; + border-radius: 0; + opacity: 1; + transition: var(--progress-bar-transition); + translate: calc(var(--progress-bar-progress, 0%) - 100%); + + &:dir(rtl) { + translate: calc(var(--progress-bar-progress, 0%) * -1 + 100%); + } + } + } + } + + .additional-cta-container { + &[flow] { + display: flex; + flex-flow: column wrap; + align-items: center; + + &[flow='row'] { + flex-direction: row; + justify-content: center; + + .secondary-cta { + flex-basis: 100%; + } + } + } + } + + .primary, + .secondary, + .additional-cta, + .submenu-button { + font-size: 13px; + line-height: 16px; + padding: 11px 15px; + transition: var(--transition); + + &.rtamo { + margin-top: 24px; + } + } + + .secondary { + background-color: var(--in-content-button-background); + color: var(--in-content-button-text-color); + } + + .split-button-container, + .screen .action-buttons .split-button-container { + align-items: stretch; + + &:not([hidden]) { + display: flex; + } + + .primary, + .secondary, + .additional-cta { + &:not(.submenu-button) { + border-start-end-radius: 0; + border-end-end-radius: 0; + margin-inline-end: 0; + } + + &:focus-visible { + z-index: 2; + } + } + + .submenu-button { + border-start-start-radius: 0; + border-end-start-radius: 0; + margin-inline-start: 1px; + padding: 8px; + min-width: 30px; + box-sizing: border-box; + background-image: url('chrome://global/skin/icons/arrow-down.svg'); + background-repeat: no-repeat; + background-size: 16px; + background-position: center; + -moz-context-properties: fill; + fill: currentColor; + } + } + + // Styles specific to background noodles, with screen-by-screen positions + .noodle { + display: block; + background-repeat: no-repeat; + background-size: 100% 100%; + position: absolute; + transition: var(--transition); + + // Flip noodles in a way that combines individual transforms. + &:dir(rtl) { + scale: -1 1; + } + } + + .outline-L { + background-image: url('chrome://activity-stream/content/data/content/assets/noodle-outline-L.svg'); + } + + .solid-L { + background-image: url('chrome://activity-stream/content/data/content/assets/noodle-solid-L.svg'); + -moz-context-properties: fill; + fill: var(--in-content-page-background); + display: none; + } + + .purple-C { + background-image: url('chrome://activity-stream/content/data/content/assets/noodle-C.svg'); + -moz-context-properties: fill; + fill: #E7258C; + } + + .orange-L { + background-image: url('chrome://activity-stream/content/data/content/assets/noodle-solid-L.svg'); + -moz-context-properties: fill; + fill: #FFA437; + } + + .screen-1 { + .section-main { + z-index: 1; + margin: auto; + } + + // Position of noodles on second screen + .outline-L { + width: 87px; + height: 80px; + transform: rotate(10deg) translate(-30%, 200%); + transition-delay: 0.4s; + z-index: 2; + } + + .orange-L { + width: 550px; + height: 660px; + transform: rotate(-155deg) translate(11%, -18%); + transition-delay: 0.2s; + } + + .purple-C { + width: 310px; + height: 260px; + transform: translate(-18%, -67%); + } + + .yellow-circle { + width: 165px; + height: 165px; + border-radius: 50%; + transform: translate(230%, -5%); + background: #952BB9; + transition-delay: -0.2s; + } + } + + // Defining the timing of the transition-in for items within the dialog, + // These longer delays are to allow for the dialog to slide down on first screen + .dialog-initial { + .brand-logo { + transition-delay: 0.6s; + } + + .welcome-text { + transition-delay: 0.8s; + } + + .tiles-theme-section, + .multi-select-container, + migration-wizard { + transition-delay: 0.9s; + } + + .primary, + .secondary, + .secondary-cta, + .steps, + .cta-link { + transition-delay: 1s; + } + } + + // Delays for transitioning-in of intermediate screens + .screen:not(.dialog-initial) { + .tiles-theme-section, + .multi-select-container + { + transition-delay: 0.2s; + } + + .primary, + .secondary, + .secondary-cta, + .cta-link { + transition-delay: 0.4s; + } + } + + .screen-2 { + .section-main { + z-index: 1; + margin: auto; + } + + // Position of noodles on third screen + .outline-L { + width: 87px; + height: 80px; + transform: rotate(250deg) translate(-420%, 425%); + transition-delay: 0.2s; + z-index: 2; + } + + .orange-L { + height: 800px; + width: 660px; + transform: rotate(35deg) translate(-10%, -7%); + transition-delay: -0.4s; + } + + .purple-C { + width: 392px; + height: 394px; + transform: rotate(260deg) translate(-34%, -35%); + transition-delay: -0.2s; + fill: #952BB9; + } + + .yellow-circle { + width: 165px; + height: 165px; + border-radius: 50%; + transform: translate(160%, 130%); + background: #E7258C; + } + } + + &.transition-in { + .noodle { + opacity: 0; + rotate: var(--rotate); + scale: var(--scale); + } + + .dialog-initial { + .main-content, + .dismiss-button { + translate: 0 calc(-2 * var(--translate)); + } + + .brand-logo, + .steps { + opacity: 0; + translate: 0 calc(-1 * var(--translate)); + } + } + + .screen { + .welcome-text, + .multi-select-container, + .tiles-theme-section, + .primary, + .checkbox-container:not(.multi-select-item), + .secondary, + .secondary-cta:not(.top), + .cta-link, + migration-wizard { + opacity: 0; + translate: 0 calc(-1 * var(--translate)); + } + + &:not(.dialog-initial) { + .steps:not(.progress-bar) { + opacity: 0.2; + } + } + } + } + + &.transition-out { + .noodle { + opacity: 0; + rotate: var(--rotate); + scale: var(--scale); + transition-delay: 0.2s; + } + + .screen:not(.dialog-last) { + .main-content { + overflow: hidden; + } + + .welcome-text, + .multi-select-container { + opacity: 0; + translate: 0 var(--translate); + transition-delay: 0.1s; + } + + // content that is nested between inner main content and navigation CTAs + // requires an additional 0.1s transition to avoid overlap + .tiles-theme-section, + migration-wizard { + opacity: 0; + translate: 0 var(--translate); + transition-delay: 0.2s; + } + + .primary, + .checkbox-container:not(.multi-select-item), + .secondary, + .secondary-cta:not(.top), + .cta-link { + opacity: 0; + translate: 0 var(--translate); + transition-delay: 0.3s; + } + + .steps:not(.progress-bar) { + opacity: 0.2; + transition-delay: 0.5s; + } + } + + .dialog-last { + .noodle { + transition-delay: 0s; + } + + .main-content, + .dismiss-button { + opacity: 0; + translate: 0 calc(2 * var(--translate)); + transition-delay: 0.4s; + } + } + } + + migration-wizard { + width: unset; + transition: var(--transition); + + &::part(buttons) { + margin-top: 24px; + justify-content: flex-start; + } + + &::part(deck) { + font-size: 0.83em; + } + } +} diff --git a/browser/components/aboutwelcome/content-src/components/AdditionalCTA.jsx b/browser/components/aboutwelcome/content-src/components/AdditionalCTA.jsx new file mode 100644 index 0000000000..7685195666 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/AdditionalCTA.jsx @@ -0,0 +1,42 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { Localized } from "./MSLocalized"; +import { SubmenuButton } from "./SubmenuButton"; + +export const AdditionalCTA = ({ content, handleAction }) => { + let buttonStyle = ""; + const isSplitButton = + content.submenu_button?.attached_to === "additional_button"; + let className = "additional-cta-box"; + if (isSplitButton) { + className += " split-button-container"; + } + + if (!content.additional_button?.style) { + buttonStyle = "primary"; + } else { + buttonStyle = + content.additional_button?.style === "link" + ? "cta-link" + : content.additional_button?.style; + } + + return ( +
    + +
    + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/AddonsPicker.jsx b/browser/components/aboutwelcome/content-src/components/AddonsPicker.jsx new file mode 100644 index 0000000000..10c88008de --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/AddonsPicker.jsx @@ -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 React, { useState } from "react"; +import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; +import { Localized } from "./MSLocalized"; + +export const Loader = () => { + return ( + + ); +}; + +export const InstallButton = props => { + const [installing, setInstalling] = useState(false); + const [installComplete, setInstallComplete] = useState(false); + + let buttonLabel = installComplete ? "Installed" : "Add to Firefox"; + + function onClick(event) { + props.handleAction(event); + // Replace the label with the spinner + setInstalling(true); + + window.AWEnsureAddonInstalled(props.addonId).then(value => { + if (value === "complete") { + // Set the label to "Installed" + setInstallComplete(true); + } + // Whether the addon installs or not, we want to remove the spinner + setInstalling(false); + }); + } + + return ( +
    + {installing ? ( + + ) : ( + +
    + ); +}; + +export const AddonsPicker = props => { + const { content } = props; + + if (!content) { + return null; + } + + function handleAction(event) { + const { message_id } = props; + let { action, source_id } = content.tiles.data[event.currentTarget.value]; + let { type, data } = action; + + if (type === "INSTALL_ADDON_FROM_URL") { + if (!data) { + return; + } + } + + AboutWelcomeUtils.handleUserAction({ type, data }); + AboutWelcomeUtils.sendActionTelemetry(message_id, source_id); + } + + return ( +
    + {content.tiles.data.map(({ id, name, type, description, icon }, index) => + name ? ( +
    +
    + +
    +
    + +
    + + +
    + +
    + +
    + ) : null + )} +
    + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/CTAParagraph.jsx b/browser/components/aboutwelcome/content-src/components/CTAParagraph.jsx new file mode 100644 index 0000000000..41726626a4 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/CTAParagraph.jsx @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { Localized } from "./MSLocalized"; + +export const CTAParagraph = props => { + const { content, handleAction } = props; + + if (!content?.text) { + return null; + } + + return ( +

    + + {content.text.string_name && typeof handleAction === "function" ? ( + + ["Enter", " "].includes(event.key) ? handleAction(event) : null + } + value="cta_paragraph" + role="button" + tabIndex="0" + > + {" "} + {/* is valid here because of click and keyup handling. */} + {/*

    + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/EmbeddedMigrationWizard.jsx b/browser/components/aboutwelcome/content-src/components/EmbeddedMigrationWizard.jsx new file mode 100644 index 0000000000..2fff85abd9 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/EmbeddedMigrationWizard.jsx @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useEffect, useRef } from "react"; + +export const EmbeddedMigrationWizard = ({ handleAction }) => { + const ref = useRef(); + useEffect(() => { + const handleBeginMigration = () => { + handleAction({ + currentTarget: { value: "migrate_start" }, + source: "primary_button", + }); + }; + const handleClose = () => { + handleAction({ currentTarget: { value: "migrate_close" } }); + }; + const { current } = ref; + current?.addEventListener( + "MigrationWizard:BeginMigration", + handleBeginMigration + ); + current?.addEventListener("MigrationWizard:Close", handleClose); + return () => { + current?.removeEventListener( + "MigrationWizard:BeginMigration", + handleBeginMigration + ); + current?.removeEventListener("MigrationWizard:Close", handleClose); + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + return ( + + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/HelpText.jsx b/browser/components/aboutwelcome/content-src/components/HelpText.jsx new file mode 100644 index 0000000000..f7cb91df24 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/HelpText.jsx @@ -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/. */ + +import React from "react"; +import { Localized } from "./MSLocalized"; +import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; +const MS_STRING_PROP = "string_id"; + +export const HelpText = props => { + if (!props.text) { + return null; + } + + if (props.hasImg) { + if (typeof props.text === "object" && props.text[MS_STRING_PROP]) { + return ( + +

    + +

    +
    + ); + } else if (typeof props.text === "string") { + // Add the img at the end of the props.text + return ( +

    + {props.text} + +

    + ); + } + } else { + return ( + +

    + + ); + } + return null; +}; diff --git a/browser/components/aboutwelcome/content-src/components/HeroImage.jsx b/browser/components/aboutwelcome/content-src/components/HeroImage.jsx new file mode 100644 index 0000000000..9ca89179fa --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/HeroImage.jsx @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; + +export const HeroImage = props => { + const { height, url, alt } = props; + + if (!url) { + return null; + } + + return ( +

    + {alt +
    + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/LanguageSwitcher.jsx b/browser/components/aboutwelcome/content-src/components/LanguageSwitcher.jsx new file mode 100644 index 0000000000..b5ebc69909 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/LanguageSwitcher.jsx @@ -0,0 +1,308 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useState, useEffect } from "react"; +import { Localized } from "./MSLocalized"; +import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; + +/** + * The language switcher implements a hook that should be placed at a higher level + * than the actual language switcher component, as it needs to preemptively fetch + * and install langpacks for the user if there is a language mismatch screen. + */ +export function useLanguageSwitcher( + appAndSystemLocaleInfo, + screens, + screenIndex, + setScreenIndex +) { + const languageMismatchScreenIndex = screens.findIndex( + ({ id }) => id === "AW_LANGUAGE_MISMATCH" + ); + const screen = screens[languageMismatchScreenIndex]; + + // Ensure fluent messages have the negotiatedLanguage args set, as they are rendered + // before the negotiatedLanguage is known. If the arg isn't present then Firefox will + // crash in development mode. + useEffect(() => { + if (screen?.content?.languageSwitcher) { + for (const text of Object.values(screen.content.languageSwitcher)) { + if (text?.args && text.args.negotiatedLanguage === undefined) { + text.args.negotiatedLanguage = ""; + } + } + } + }, [screen]); + + // If there is a mismatch, then Firefox can negotiate a better langpack to offer + // the user. + const [negotiatedLanguage, setNegotiatedLanguage] = useState(null); + useEffect( + function getNegotiatedLanguage() { + if (!appAndSystemLocaleInfo) { + return; + } + if (appAndSystemLocaleInfo.matchType !== "language-mismatch") { + // There is no language mismatch, so there is no need to negotiate a langpack. + return; + } + + (async () => { + const { langPack, langPackDisplayName } = + await window.AWNegotiateLangPackForLanguageMismatch( + appAndSystemLocaleInfo + ); + if (langPack) { + setNegotiatedLanguage({ + langPackDisplayName, + appDisplayName: appAndSystemLocaleInfo.displayNames.appLanguage, + langPack, + requestSystemLocales: [ + langPack.target_locale, + appAndSystemLocaleInfo.appLocaleRaw, + ], + originalAppLocales: [appAndSystemLocaleInfo.appLocaleRaw], + }); + } else { + setNegotiatedLanguage({ + langPackDisplayName: null, + appDisplayName: null, + langPack: null, + requestSystemLocales: null, + }); + } + })(); + }, + [appAndSystemLocaleInfo] + ); + + /** + * @type { + * "before-installation" + * | "installing" + * | "installed" + * | "installation-error" + * | "none-available" + * } + */ + const [langPackInstallPhase, setLangPackInstallPhase] = useState( + "before-installation" + ); + useEffect( + function ensureLangPackInstalled() { + if (!negotiatedLanguage) { + // There are no negotiated languages to download yet. + return; + } + setLangPackInstallPhase("installing"); + window + .AWEnsureLangPackInstalled(negotiatedLanguage, screen?.content) + .then( + content => { + // Update screen content with strings that might have changed. + screen.content = content; + setLangPackInstallPhase("installed"); + }, + error => { + console.error(error); + setLangPackInstallPhase("installation-error"); + } + ); + }, + [negotiatedLanguage, screen] + ); + + const [languageFilteredScreens, setLanguageFilteredScreens] = + useState(screens); + useEffect( + function filterScreen() { + // Remove the language screen if it exists (already removed for no live + // reload) and we either don't-need-to or can't switch. + if ( + screen && + (appAndSystemLocaleInfo?.matchType !== "language-mismatch" || + negotiatedLanguage?.langPack === null) + ) { + if (screenIndex > languageMismatchScreenIndex) { + setScreenIndex(screenIndex - 1); + } + setLanguageFilteredScreens( + screens.filter(s => s.id !== "AW_LANGUAGE_MISMATCH") + ); + } else { + setLanguageFilteredScreens(screens); + } + }, + // Removing screenIndex as a dependency as it's causing infinite re-renders (1873019) + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + appAndSystemLocaleInfo?.matchType, + languageMismatchScreenIndex, + negotiatedLanguage, + screen, + screens, + setScreenIndex, + ] + ); + + return { + negotiatedLanguage, + langPackInstallPhase, + languageFilteredScreens, + }; +} + +/** + * The language switcher is a separate component as it needs to perform some asynchronous + * network actions such as retrieving the list of langpacks available, and downloading + * a new langpack. On a fast connection, this won't be noticeable, but on slow or unreliable + * internet this may fail for a user. + */ +export function LanguageSwitcher(props) { + const { + content, + handleAction, + negotiatedLanguage, + langPackInstallPhase, + messageId, + } = props; + + const [isAwaitingLangpack, setIsAwaitingLangpack] = useState(false); + + // Determine the status of the langpack installation. + useEffect(() => { + if (isAwaitingLangpack && langPackInstallPhase !== "installing") { + window.AWSetRequestedLocales(negotiatedLanguage.requestSystemLocales); + requestAnimationFrame(() => { + handleAction( + // Simulate the click event. + { currentTarget: { value: "download_complete" } } + ); + }); + } + }, [ + handleAction, + isAwaitingLangpack, + langPackInstallPhase, + negotiatedLanguage?.requestSystemLocales, + ]); + + let showWaitingScreen = false; + let showPreloadingScreen = false; + let showReadyScreen = false; + + if (isAwaitingLangpack && langPackInstallPhase !== "installed") { + showWaitingScreen = true; + } else if (langPackInstallPhase === "before-installation") { + showPreloadingScreen = true; + } else { + showReadyScreen = true; + } + + // Use {display: "none"} rather than if statements to prevent layout thrashing with + // the localized text elements rendering as blank, then filling in the text. + return ( +
    + {/* Pre-loading screen */} +
    + +
    + +
    +
    + {/* Waiting to download the language screen. */} +
    + +
    + +
    +
    + {/* The typical ready screen. */} +
    +
    + +
    +
    + +
    +
    +
    + ); +} diff --git a/browser/components/aboutwelcome/content-src/components/LinkParagraph.jsx b/browser/components/aboutwelcome/content-src/components/LinkParagraph.jsx new file mode 100644 index 0000000000..14de368b2a --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/LinkParagraph.jsx @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useCallback } from "react"; +import { Localized } from "./MSLocalized"; + +export const LinkParagraph = props => { + const { text_content, handleAction } = props; + + const handleParagraphAction = useCallback( + event => { + if (event.target.closest("a")) { + handleAction({ ...event, currentTarget: event.target }); + } + }, + [handleAction] + ); + + const onKeyPress = useCallback( + event => { + if (event.key === "Enter" && !event.repeat) { + handleParagraphAction(event); + } + }, + [handleParagraphAction] + ); + + return ( + + {/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */} +

    + {/* eslint-disable jsx-a11y/anchor-is-valid */} + {text_content.link_keys?.map(link => ( + + {" "} + + ))} +

    +
    + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/MRColorways.jsx b/browser/components/aboutwelcome/content-src/components/MRColorways.jsx new file mode 100644 index 0000000000..758e6ddc4a --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/MRColorways.jsx @@ -0,0 +1,200 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useState, useEffect } from "react"; +import { Localized } from "./MSLocalized"; + +export const ColorwayDescription = props => { + const { colorway } = props; + if (!colorway) { + return null; + } + const { label, description } = colorway; + return ( + +
    + + ); +}; + +// Return colorway as "default" for default theme variations Automatic, Light, Dark, +// Alpenglow theme and legacy colorways which is not supported in Colorway picker. +// For themes other then default, theme names exist in +// format colorway-variationId inside LIGHT_WEIGHT_THEMES in AboutWelcomeParent +export function computeColorWay(themeName, systemVariations) { + return !themeName || + themeName === "alpenglow" || + systemVariations.includes(themeName) + ? "default" + : themeName.split("-")[0]; +} + +// Set variationIndex based off activetheme value e.g. 'light', 'expressionist-soft' +export function computeVariationIndex( + themeName, + systemVariations, + variations, + defaultVariationIndex +) { + // Check if themeName is in systemVariations, if yes choose variationIndex by themeName + let index = systemVariations.findIndex(theme => theme === themeName); + if (index >= 0) { + return index; + } + + // If themeName is one of the colorways, select variation index from colorways + let variation = themeName?.split("-")[1]; + index = variations.findIndex(element => element === variation); + if (index >= 0) { + return index; + } + return defaultVariationIndex; +} + +export function Colorways(props) { + let { + colorways, + darkVariation, + defaultVariationIndex, + systemVariations, + variations, + } = props.content.tiles; + let hasReverted = false; + + // Active theme id from JSON e.g. "expressionist" + const activeId = computeColorWay(props.activeTheme, systemVariations); + const [colorwayId, setState] = useState(activeId); + const [variationIndex, setVariationIndex] = useState(defaultVariationIndex); + + function revertToDefaultTheme() { + if (hasReverted) { + return; + } + + // Spoofing an event with current target value of "navigate_away" + // helps the handleAction method to read the colorways theme as "revert" + // which causes the initial theme to be activated. + // The "navigate_away" action is set in content in the colorways screen JSON config. + // Any value in the JSON for theme will work, provided it is not ``. + const event = { + currentTarget: { + value: "navigate_away", + }, + }; + props.handleAction(event); + hasReverted = true; + } + + // Revert to default theme if the user navigates away from the page or spotlight modal + // before clicking on the primary button to officially set theme. + useEffect(() => { + addEventListener("beforeunload", revertToDefaultTheme); + addEventListener("pagehide", revertToDefaultTheme); + + return () => { + removeEventListener("beforeunload", revertToDefaultTheme); + removeEventListener("pagehide", revertToDefaultTheme); + }; + }); + // Update state any time activeTheme changes. + useEffect(() => { + setState(computeColorWay(props.activeTheme, systemVariations)); + setVariationIndex( + computeVariationIndex( + props.activeTheme, + systemVariations, + variations, + defaultVariationIndex + ) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.activeTheme]); + + //select a random colorway + useEffect(() => { + //We don't want the default theme to be selected + const randomIndex = Math.floor(Math.random() * (colorways.length - 1)) + 1; + const randomColorwayId = colorways[randomIndex].id; + + // Change the variation to be the dark variation if configured and dark. + // Additional colorway changes will remain dark while system is unchanged. + if ( + darkVariation !== undefined && + window.matchMedia("(prefers-color-scheme: dark)").matches + ) { + variations[variationIndex] = variations[darkVariation]; + } + const value = `${randomColorwayId}-${variations[variationIndex]}`; + props.handleAction({ currentTarget: { value } }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
    +
    +
    + + + + {colorways.map(({ id, label, tooltip }) => ( + +
    +
    + colorway.id === activeId)} + /> +
    + ); +} diff --git a/browser/components/aboutwelcome/content-src/components/MSLocalized.jsx b/browser/components/aboutwelcome/content-src/components/MSLocalized.jsx new file mode 100644 index 0000000000..42fb071475 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/MSLocalized.jsx @@ -0,0 +1,114 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useEffect } from "react"; +export const CONFIGURABLE_STYLES = [ + "color", + "fontSize", + "fontWeight", + "letterSpacing", + "lineHeight", + "marginBlock", + "marginInline", + "paddingBlock", + "paddingInline", + "whiteSpace", +]; +const ZAP_SIZE_THRESHOLD = 160; + +/** + * Based on the .text prop, localizes an inner element if a string_id + * is provided, OR renders plain text, OR hides it if nothing is provided. + * Allows configuring of some styles including zap underline and color. + * + * Examples: + * + * Localized text + * ftl: + * title = Welcome + * jsx: + *

    + * output: + *

    Welcome

    + * + * Unlocalized text + * jsx: + *

    + *

    + * output: + *

    Welcome

    + */ + +export const Localized = ({ text, children }) => { + // Dynamically determine the size of the zap style. + const zapRef = React.createRef(); + useEffect(() => { + const { current } = zapRef; + if (current) { + requestAnimationFrame(() => + current?.classList.replace( + "short", + current.getBoundingClientRect().width > ZAP_SIZE_THRESHOLD + ? "long" + : "short" + ) + ); + } + }); + + // Skip rendering of children with no text. + if (!text) { + return null; + } + + // Allow augmenting existing child container properties. + const props = { children: [], className: "", style: {}, ...children?.props }; + // Support nested Localized by starting with their children. + const textNodes = Array.isArray(props.children) + ? props.children + : [props.children]; + + // Pick desired fluent or raw/plain text to render. + if (text.string_id) { + // Set the key so React knows not to reuse when switching to plain text. + props.key = text.string_id; + props["data-l10n-id"] = text.string_id; + if (text.args) { + props["data-l10n-args"] = JSON.stringify(text.args); + } + } else if (text.raw) { + textNodes.push(text.raw); + } else if (typeof text === "string") { + textNodes.push(text); + } + + // Add zap style and content in a way that allows fluent to insert too. + if (text.zap) { + props.className += " welcomeZap"; + textNodes.push( + + {text.zap} + + ); + } + + if (text.aria_label) { + props["aria-label"] = text.aria_label; + } + + // Apply certain configurable styles. + CONFIGURABLE_STYLES.forEach(style => { + if (text[style] !== undefined) { + props.style[style] = text[style]; + } + }); + + return React.cloneElement( + // Provide a default container for the text if necessary. + children ?? , + props, + // Conditionally pass in as void elements can't accept empty array. + textNodes.length ? textNodes : null + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/MobileDownloads.jsx b/browser/components/aboutwelcome/content-src/components/MobileDownloads.jsx new file mode 100644 index 0000000000..fbd0940805 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/MobileDownloads.jsx @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { Localized } from "./MSLocalized"; +import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; + +export const MarketplaceButtons = props => { + return ( +
      + {props.buttons.includes("ios") ? ( +
    • + +
    • + ) : null} + {props.buttons.includes("android") ? ( +
    • + +
    • + ) : null} +
    + ); +}; + +export const MobileDownloads = props => { + const { QR_code: QRCode } = props.data; + const showEmailLink = + props.data.email && window.AWSendToDeviceEmailsSupported(); + + return ( +
    + {/* Avoid use of Localized element to set alt text here as a plain string value + results in a React error due to "dangerouslySetInnerHTML" */} + {QRCode ? ( + {typeof + ) : null} + {showEmailLink ? ( +
    + +
    + ) : null} + {props.data.marketplace_buttons ? ( + + ) : null} +
    + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/MultiSelect.jsx b/browser/components/aboutwelcome/content-src/components/MultiSelect.jsx new file mode 100644 index 0000000000..f65665a7b2 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/MultiSelect.jsx @@ -0,0 +1,158 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useEffect, useCallback, useMemo, useRef } from "react"; +import { Localized, CONFIGURABLE_STYLES } from "./MSLocalized"; + +const MULTI_SELECT_STYLES = [ + ...CONFIGURABLE_STYLES, + "flexDirection", + "flexWrap", + "flexFlow", + "flexGrow", + "flexShrink", + "justifyContent", + "alignItems", + "gap", +]; + +const MULTI_SELECT_ICON_STYLES = [ + ...CONFIGURABLE_STYLES, + "width", + "height", + "background", + "backgroundColor", + "backgroundImage", + "backgroundSize", + "backgroundPosition", + "backgroundRepeat", + "backgroundOrigin", + "backgroundClip", + "border", + "borderRadius", + "appearance", + "fill", + "stroke", + "outline", + "outlineOffset", + "boxShadow", +]; + +function getValidStyle(style, validStyles, allowVars) { + if (!style) { + return null; + } + return Object.keys(style) + .filter( + key => validStyles.includes(key) || (allowVars && key.startsWith("--")) + ) + .reduce((obj, key) => { + obj[key] = style[key]; + return obj; + }, {}); +} + +export const MultiSelect = ({ + content, + screenMultiSelects, + setScreenMultiSelects, + activeMultiSelect, + setActiveMultiSelect, +}) => { + const { data } = content.tiles; + + const refs = useRef({}); + + const handleChange = useCallback(() => { + const newActiveMultiSelect = []; + Object.keys(refs.current).forEach(key => { + if (refs.current[key]?.checked) { + newActiveMultiSelect.push(key); + } + }); + setActiveMultiSelect(newActiveMultiSelect); + }, [setActiveMultiSelect]); + + const items = useMemo( + () => { + function getOrderedIds() { + if (screenMultiSelects) { + return screenMultiSelects; + } + let orderedIds = data + .map(item => ({ + id: item.id, + rank: item.randomize ? Math.random() : NaN, + })) + .sort((a, b) => b.rank - a.rank) + .map(({ id }) => id); + setScreenMultiSelects(orderedIds); + return orderedIds; + } + return getOrderedIds().map(id => data.find(item => item.id === id)); + }, + [] // eslint-disable-line react-hooks/exhaustive-deps + ); + + const containerStyle = useMemo( + () => getValidStyle(content.tiles.style, MULTI_SELECT_STYLES, true), + [content.tiles.style] + ); + + // When screen renders for first time, update state + // with checkbox ids that has defaultvalue true + useEffect(() => { + if (!activeMultiSelect) { + let newActiveMultiSelect = []; + items.forEach(({ id, defaultValue }) => { + if (defaultValue && id) { + newActiveMultiSelect.push(id); + } + }); + setActiveMultiSelect(newActiveMultiSelect); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + return ( +
    type === "radio" && group) + ? "radiogroup" + : "group" + } + aria-labelledby="multi-stage-multi-select-label" + > + {content.tiles.label ? ( + +

    + + ) : null} + {items.map(({ id, label, icon, type = "checkbox", group, style }) => ( +
    + (refs.current[id] = el)} + /> + {label ? ( + + + + ) : null} +
    + ))} +

    + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/MultiStageAboutWelcome.jsx b/browser/components/aboutwelcome/content-src/components/MultiStageAboutWelcome.jsx new file mode 100644 index 0000000000..034055bf3d --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/MultiStageAboutWelcome.jsx @@ -0,0 +1,568 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useState, useEffect, useRef } from "react"; +import { Localized } from "./MSLocalized"; +import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; +import { MultiStageProtonScreen } from "./MultiStageProtonScreen"; +import { useLanguageSwitcher } from "./LanguageSwitcher"; +import { SubmenuButton } from "./SubmenuButton"; +import { BASE_PARAMS, addUtmParams } from "../lib/addUtmParams.mjs"; + +// Amount of milliseconds for all transitions to complete (including delays). +const TRANSITION_OUT_TIME = 1000; +const LANGUAGE_MISMATCH_SCREEN_ID = "AW_LANGUAGE_MISMATCH"; + +export const MultiStageAboutWelcome = props => { + let { defaultScreens } = props; + const didFilter = useRef(false); + const [didMount, setDidMount] = useState(false); + const [screens, setScreens] = useState(defaultScreens); + + const [index, setScreenIndex] = useState(props.startScreen); + const [previousOrder, setPreviousOrder] = useState(props.startScreen - 1); + + useEffect(() => { + (async () => { + // If we want to load index from history state, we don't want to send impression yet + if (!didMount) { + return; + } + // On about:welcome first load, screensVisited should be empty + let screensVisited = didFilter.current ? screens.slice(0, index) : []; + let upcomingScreens = defaultScreens + .filter(s => !screensVisited.find(v => v.id === s.id)) + // Filter out Language Mismatch screen from upcoming + // screens if screens set from useLanguageSwitcher hook + // has filtered language screen + .filter( + upcomingScreen => + !( + !screens.find(s => s.id === LANGUAGE_MISMATCH_SCREEN_ID) && + upcomingScreen.id === LANGUAGE_MISMATCH_SCREEN_ID + ) + ); + + let filteredScreens = screensVisited.concat( + (await window.AWEvaluateScreenTargeting(upcomingScreens)) ?? + upcomingScreens + ); + + // Use existing screen for the filtered screen to carry over any modification + // e.g. if AW_LANGUAGE_MISMATCH exists, use it from existing screens + setScreens( + filteredScreens.map( + filtered => screens.find(s => s.id === filtered.id) ?? filtered + ) + ); + + didFilter.current = true; + + const screenInitials = filteredScreens + .map(({ id }) => id?.split("_")[1]?.[0]) + .join(""); + // Send impression ping when respective screen first renders + filteredScreens.forEach((screen, order) => { + if (index === order) { + const messageId = `${props.message_id}_${order}_${screen.id}_${screenInitials}`; + + AboutWelcomeUtils.sendImpressionTelemetry(messageId, { + screen_family: props.message_id, + screen_index: order, + screen_id: screen.id, + screen_initials: screenInitials, + }); + + window.AWAddScreenImpression?.(screen); + } + }); + + // Remember that a new screen has loaded for browser navigation + if (props.updateHistory && index > window.history.state) { + window.history.pushState(index, ""); + } + + // Remember the previous screen index so we can animate the transition + setPreviousOrder(index); + })(); + }, [index, didMount]); // eslint-disable-line react-hooks/exhaustive-deps + + const [flowParams, setFlowParams] = useState(null); + const { metricsFlowUri } = props; + useEffect(() => { + (async () => { + if (metricsFlowUri) { + setFlowParams(await AboutWelcomeUtils.fetchFlowParams(metricsFlowUri)); + } + })(); + }, [metricsFlowUri]); + + // Allow "in" style to render to actually transition towards regular state, + // which also makes using browser back/forward navigation skip transitions. + const [transition, setTransition] = useState(props.transitions ? "in" : ""); + useEffect(() => { + if (transition === "in") { + requestAnimationFrame(() => + requestAnimationFrame(() => setTransition("")) + ); + } + }, [transition]); + + // Transition to next screen, opening about:home on last screen button CTA + const handleTransition = () => { + // Only handle transitioning out from a screen once. + if (transition === "out") { + return; + } + + // Start transitioning things "out" immediately when moving forwards. + setTransition(props.transitions ? "out" : ""); + + // Actually move forwards after all transitions finish. + setTimeout( + () => { + if (index < screens.length - 1) { + setTransition(props.transitions ? "in" : ""); + setScreenIndex(prevState => prevState + 1); + } else { + window.AWFinish(); + } + }, + props.transitions ? TRANSITION_OUT_TIME : 0 + ); + }; + + useEffect(() => { + // When about:welcome loads (on refresh or pressing back button + // from about:home), ensure history state usEffect runs before + // useEffect hook that send impression telemetry + setDidMount(true); + + if (props.updateHistory) { + // Switch to the screen tracked in state (null for initial state) + // or last screen index if a user navigates by pressing back + // button from about:home + const handler = ({ state }) => { + if (transition === "out") { + return; + } + setTransition(props.transitions ? "out" : ""); + setTimeout( + () => { + setTransition(props.transitions ? "in" : ""); + setScreenIndex(Math.min(state, screens.length - 1)); + }, + props.transitions ? TRANSITION_OUT_TIME : 0 + ); + }; + + // Handle page load, e.g., going back to about:welcome from about:home + const { state } = window.history; + if (state) { + setScreenIndex(Math.min(state, screens.length - 1)); + setPreviousOrder(Math.min(state, screens.length - 1)); + } + + // Watch for browser back/forward button navigation events + window.addEventListener("popstate", handler); + return () => window.removeEventListener("popstate", handler); + } + return false; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const [multiSelects, setMultiSelects] = useState({}); + + // Save the active multi select state for each screen as an object keyed by + // screen id. Each screen id has an array containing checkbox ids used in + // handleAction to update MULTI_ACTION data. This allows us to remember the + // state of each screen's multi select checkboxes when navigating back and + // forth between screens, while also allowing a message to have more than one + // multi select screen. + const [activeMultiSelects, setActiveMultiSelects] = useState({}); + + // Get the active theme so the rendering code can make it selected + // by default. + const [activeTheme, setActiveTheme] = useState(null); + const [initialTheme, setInitialTheme] = useState(null); + useEffect(() => { + (async () => { + let theme = await window.AWGetSelectedTheme(); + setInitialTheme(theme); + setActiveTheme(theme); + })(); + }, []); + + const { negotiatedLanguage, langPackInstallPhase, languageFilteredScreens } = + useLanguageSwitcher( + props.appAndSystemLocaleInfo, + screens, + index, + setScreenIndex + ); + + useEffect(() => { + setScreens(languageFilteredScreens); + }, [languageFilteredScreens]); + + return ( + +
    + {screens.map((screen, order) => { + const isFirstScreen = screen === screens[0]; + const isLastScreen = screen === screens[screens.length - 1]; + const totalNumberOfScreens = screens.length; + const isSingleScreen = totalNumberOfScreens === 1; + + const setActiveMultiSelect = valueOrFn => + setActiveMultiSelects(prevState => ({ + ...prevState, + [screen.id]: + typeof valueOrFn === "function" + ? valueOrFn(prevState[screen.id]) + : valueOrFn, + })); + const setScreenMultiSelects = valueOrFn => + setMultiSelects(prevState => ({ + ...prevState, + [screen.id]: + typeof valueOrFn === "function" + ? valueOrFn(prevState[screen.id]) + : valueOrFn, + })); + + return index === order ? ( + + ) : null; + })} +
    +
    + ); +}; + +export const SecondaryCTA = props => { + const targetElement = props.position + ? `secondary_button_${props.position}` + : `secondary_button`; + let buttonStyling = props.content.secondary_button?.has_arrow_icon + ? `secondary arrow-icon` + : `secondary`; + const isPrimary = props.content.secondary_button?.style === "primary"; + const isTextLink = + !["split", "callout"].includes(props.content.position) && + props.content.tiles?.type !== "addons-picker" && + !isPrimary; + const isSplitButton = + props.content.submenu_button?.attached_to === targetElement; + let className = "secondary-cta"; + if (props.position) { + className += ` ${props.position}`; + } + if (isSplitButton) { + className += " split-button-container"; + } + const isDisabled = React.useCallback( + disabledValue => + disabledValue === "hasActiveMultiSelect" + ? !(props.activeMultiSelect?.length > 0) + : disabledValue, + [props.activeMultiSelect?.length] + ); + + if (isTextLink) { + buttonStyling += " text-link"; + } + + if (isPrimary) { + buttonStyling = props.content.secondary_button?.has_arrow_icon + ? `primary arrow-icon` + : `primary`; + } + + return ( +
    + + + + +
    + ); +}; + +export const StepsIndicator = props => { + let steps = []; + for (let i = 0; i < props.totalNumberOfScreens; i++) { + let className = `${i === props.order ? "current" : ""} ${ + i < props.order ? "complete" : "" + }`; + steps.push( +
    + ); + } + return steps; +}; + +export const ProgressBar = ({ step, previousStep, totalNumberOfScreens }) => { + const [progress, setProgress] = React.useState( + previousStep / totalNumberOfScreens + ); + useEffect(() => { + // We don't need to hook any dependencies because any time the step changes, + // the screen's entire DOM tree will be re-rendered. + setProgress(step / totalNumberOfScreens); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + return ( +
    + ); +}; + +export class WelcomeScreen extends React.PureComponent { + constructor(props) { + super(props); + this.handleAction = this.handleAction.bind(this); + } + + handleOpenURL(action, flowParams, UTMTerm) { + let { type, data } = action; + if (type === "SHOW_FIREFOX_ACCOUNTS") { + let params = { + ...BASE_PARAMS, + utm_term: `${UTMTerm}-screen`, + }; + if (action.addFlowParams && flowParams) { + params = { + ...params, + ...flowParams, + }; + } + data = { ...data, extraParams: params }; + } else if (type === "OPEN_URL") { + let url = new URL(data.args); + addUtmParams(url, `${UTMTerm}-screen`); + if (action.addFlowParams && flowParams) { + url.searchParams.append("device_id", flowParams.deviceId); + url.searchParams.append("flow_id", flowParams.flowId); + url.searchParams.append("flow_begin_time", flowParams.flowBeginTime); + } + data = { ...data, args: url.toString() }; + } + return AboutWelcomeUtils.handleUserAction({ type, data }); + } + + async handleAction(event) { + let { props } = this; + const value = + event.currentTarget.value ?? event.currentTarget.getAttribute("value"); + const source = event.source || value; + let targetContent = + props.content[value] || + props.content.tiles || + props.content.languageSwitcher; + + if (value === "submenu_button" && event.action) { + targetContent = { action: event.action }; + } + + if (!(targetContent && targetContent.action)) { + return; + } + // Send telemetry before waiting on actions + AboutWelcomeUtils.sendActionTelemetry(props.messageId, source, event.name); + + // Send additional telemetry if a messaging surface like feature callout is + // dismissed via the dismiss button. Other causes of dismissal will be + // handled separately by the messaging surface's own code. + if (value === "dismiss_button" && !event.name) { + AboutWelcomeUtils.sendDismissTelemetry(props.messageId, source); + } + + let { action } = targetContent; + action = JSON.parse(JSON.stringify(action)); + + if (action.collectSelect) { + this.setMultiSelectActions(action); + } + + let actionResult; + if (["OPEN_URL", "SHOW_FIREFOX_ACCOUNTS"].includes(action.type)) { + actionResult = await this.handleOpenURL( + action, + props.flowParams, + props.UTMTerm + ); + } else if (action.type) { + actionResult = await AboutWelcomeUtils.handleUserAction(action); + if (action.type === "FXA_SIGNIN_FLOW") { + AboutWelcomeUtils.sendActionTelemetry( + props.messageId, + actionResult ? "sign_in" : "sign_in_cancel", + "FXA_SIGNIN_FLOW" + ); + } + // Wait until migration closes to complete the action + const hasMigrate = a => + a.type === "SHOW_MIGRATION_WIZARD" || + (a.type === "MULTI_ACTION" && a.data?.actions?.some(hasMigrate)); + if (hasMigrate(action)) { + await window.AWWaitForMigrationClose(); + AboutWelcomeUtils.sendActionTelemetry(props.messageId, "migrate_close"); + } + } + + // A special tiles.action.theme value indicates we should use the event's value vs provided value. + if (action.theme) { + let themeToUse = + action.theme === "" + ? event.currentTarget.value + : this.props.initialTheme || action.theme; + + this.props.setActiveTheme(themeToUse); + window.AWSelectTheme(themeToUse); + } + + // If the action has persistActiveTheme: true, we set the initial theme to the currently active theme + // so that it can be reverted to in the event that the user navigates away from the screen + if (action.persistActiveTheme) { + this.props.setInitialTheme(this.props.activeTheme); + } + + // `navigate` and `dismiss` can be true/false/undefined, or they can be a + // string "actionResult" in which case we should use the actionResult + // (boolean resolved by handleUserAction) + const shouldDoBehavior = behavior => + behavior === "actionResult" ? actionResult : behavior; + + if (shouldDoBehavior(action.navigate)) { + props.navigate(); + } + + if (shouldDoBehavior(action.dismiss)) { + window.AWFinish(); + } + } + + setMultiSelectActions(action) { + let { props } = this; + // Populate MULTI_ACTION data actions property with selected checkbox + // actions from tiles data + if (action.type !== "MULTI_ACTION") { + console.error( + "collectSelect is only supported for MULTI_ACTION type actions" + ); + action.type = "MULTI_ACTION"; + } + if (!Array.isArray(action.data?.actions)) { + console.error( + "collectSelect is only supported for MULTI_ACTION type actions with an array of actions" + ); + action.data = { actions: [] }; + } + + // Prepend the multi-select actions to the CTA's actions array, but keep + // the actions in the same order they appear in. This way the CTA action + // can go last, after the multi-select actions are processed. For example, + // 1. checkbox action 1 + // 2. checkbox action 2 + // 3. radio action + // 4. CTA action (which perhaps depends on the radio action) + let multiSelectActions = []; + for (const checkbox of props.content?.tiles?.data ?? []) { + let checkboxAction; + if (props.activeMultiSelect?.includes(checkbox.id)) { + checkboxAction = checkbox.checkedAction ?? checkbox.action; + } else { + checkboxAction = checkbox.uncheckedAction; + } + + if (checkboxAction) { + multiSelectActions.push(checkboxAction); + } + } + action.data.actions.unshift(...multiSelectActions); + + // Send telemetry with selected checkbox ids + AboutWelcomeUtils.sendActionTelemetry( + props.messageId, + props.activeMultiSelect, + "SELECT_CHECKBOX" + ); + } + + render() { + return ( + + ); + } +} diff --git a/browser/components/aboutwelcome/content-src/components/MultiStageProtonScreen.jsx b/browser/components/aboutwelcome/content-src/components/MultiStageProtonScreen.jsx new file mode 100644 index 0000000000..ffe64f05f1 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/MultiStageProtonScreen.jsx @@ -0,0 +1,620 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useEffect, useState } from "react"; +import { Localized } from "./MSLocalized"; +import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; +import { MobileDownloads } from "./MobileDownloads"; +import { MultiSelect } from "./MultiSelect"; +import { Themes } from "./Themes"; +import { + SecondaryCTA, + StepsIndicator, + ProgressBar, +} from "./MultiStageAboutWelcome"; +import { LanguageSwitcher } from "./LanguageSwitcher"; +import { CTAParagraph } from "./CTAParagraph"; +import { HeroImage } from "./HeroImage"; +import { OnboardingVideo } from "./OnboardingVideo"; +import { AdditionalCTA } from "./AdditionalCTA"; +import { EmbeddedMigrationWizard } from "./EmbeddedMigrationWizard"; +import { AddonsPicker } from "./AddonsPicker"; +import { LinkParagraph } from "./LinkParagraph"; + +export const MultiStageProtonScreen = props => { + const { autoAdvance, handleAction, order } = props; + useEffect(() => { + if (autoAdvance) { + const timer = setTimeout(() => { + handleAction({ + currentTarget: { + value: autoAdvance, + }, + name: "AUTO_ADVANCE", + }); + }, 20000); + return () => clearTimeout(timer); + } + return () => {}; + }, [autoAdvance, handleAction, order]); + + return ( + + ); +}; + +export const ProtonScreenActionButtons = props => { + const { content, addonName, activeMultiSelect } = props; + const defaultValue = content.checkbox?.defaultValue; + + const [isChecked, setIsChecked] = useState(defaultValue || false); + const buttonRef = React.useRef(null); + + const shouldFocusButton = content?.primary_button?.should_focus_button; + + useEffect(() => { + if (shouldFocusButton) { + buttonRef.current?.focus(); + } + }, [shouldFocusButton]); + + if ( + !content.primary_button && + !content.secondary_button && + !content.additional_button + ) { + return null; + } + + // If we have a multi-select screen, we want to disable the primary button + // until the user has selected at least one item. + const isPrimaryDisabled = primaryDisabledValue => + primaryDisabledValue === "hasActiveMultiSelect" + ? !(activeMultiSelect?.length > 0) + : primaryDisabledValue; + + return ( +
    + +
    + ); +}; + +export class ProtonScreen extends React.PureComponent { + componentDidMount() { + this.mainContentHeader.focus(); + } + + getScreenClassName( + isFirstScreen, + isLastScreen, + includeNoodles, + isVideoOnboarding, + isAddonsPicker + ) { + const screenClass = `screen-${this.props.order % 2 !== 0 ? 1 : 2}`; + + if (isVideoOnboarding) { + return "with-video"; + } + + if (isAddonsPicker) { + return "addons-picker"; + } + + return `${isFirstScreen ? `dialog-initial` : ``} ${ + isLastScreen ? `dialog-last` : `` + } ${includeNoodles ? `with-noodles` : ``} ${screenClass}`; + } + + renderTitle({ title, title_logo }) { + if (title_logo) { + const { alignment, ...rest } = title_logo; + return ( +
    + {this.renderPicture({ ...rest })} + +

    + +

    + ); + } + return ( + +

    + + ); + } + + renderPicture({ + imageURL = "chrome://branding/content/about-logo.svg", + darkModeImageURL, + reducedMotionImageURL, + darkModeReducedMotionImageURL, + alt = "", + width, + height, + marginBlock, + marginInline, + className = "logo-container", + }) { + function getLoadingStrategy() { + for (let url of [ + imageURL, + darkModeImageURL, + reducedMotionImageURL, + darkModeReducedMotionImageURL, + ]) { + if (AboutWelcomeUtils.getLoadingStrategyFor(url) === "lazy") { + return "lazy"; + } + } + return "eager"; + } + + return ( + + {darkModeReducedMotionImageURL ? ( + + ) : null} + {darkModeImageURL ? ( + + ) : null} + {reducedMotionImageURL ? ( + + ) : null} + +
    + + + + ); + } + + renderContentTiles() { + const { content } = this.props; + return ( + + {content.tiles && + content.tiles.type === "addons-picker" && + content.tiles.data ? ( + + ) : null} + {content.tiles && + content.tiles.type === "theme" && + content.tiles.data ? ( + + ) : null} + {content.tiles && + content.tiles.type === "mobile_downloads" && + content.tiles.data ? ( + + ) : null} + {content.tiles && + content.tiles.type === "multiselect" && + content.tiles.data ? ( + + ) : null} + {content.tiles && content.tiles.type === "migration-wizard" ? ( + + ) : null} + + ); + } + + renderNoodles() { + return ( + +
    +
    +
    +
    +
    + + ); + } + + renderLanguageSwitcher() { + return this.props.content.languageSwitcher ? ( + + ) : null; + } + + renderDismissButton() { + const { size, marginBlock, marginInline, label } = + this.props.content.dismiss_button; + return ( + + ); + } + + renderStepsIndicator() { + const currentStep = (this.props.order ?? 0) + 1; + const previousStep = (this.props.previousOrder ?? -1) + 1; + const { content, totalNumberOfScreens: total } = this.props; + return ( +
    + {content.progress_bar ? ( + + ) : ( + + )} +
    + ); + } + + renderSecondarySection(content) { + return ( +
    + +
    + + {content.hero_image ? ( + + ) : ( + +
    +
    + +

    + +
    +
    + + )} +

    + ); + } + + renderOrderedContent(content) { + const elements = []; + for (const item of content) { + switch (item.type) { + case "text": + elements.push( + + ); + break; + case "image": + elements.push( + this.renderPicture({ + imageURL: item.url, + darkModeImageURL: item.darkModeImageURL, + height: item.height, + width: item.width, + alt: item.alt_text, + marginInline: item.marginInline, + className: "inline-image", + }) + ); + } + } + return <>{elements}; + } + + render() { + const { + autoAdvance, + content, + isRtamo, + isTheme, + isFirstScreen, + isLastScreen, + isSingleScreen, + forceHideStepsIndicator, + ariaRole, + aboveButtonStepsIndicator, + } = this.props; + const includeNoodles = content.has_noodles; + // The default screen position is "center" + const isCenterPosition = content.position === "center" || !content.position; + const hideStepsIndicator = + autoAdvance || + content?.video_container || + isSingleScreen || + forceHideStepsIndicator; + const textColorClass = content.text_color + ? `${content.text_color}-text` + : ""; + // Assign proton screen style 'screen-1' or 'screen-2' to centered screens + // by checking if screen order is even or odd. + const screenClassName = isCenterPosition + ? this.getScreenClassName( + isFirstScreen, + isLastScreen, + includeNoodles, + content?.video_container, + content.tiles?.type === "addons-picker" + ) + : ""; + const isEmbeddedMigration = content.tiles?.type === "migration-wizard"; + + return ( +
    { + this.mainContentHeader = input; + }} + > + {isCenterPosition ? null : this.renderSecondarySection(content)} +
    + {content.secondary_button_top ? ( + + ) : null} + {includeNoodles ? this.renderNoodles() : null} + {content.dismiss_button ? this.renderDismissButton() : null} +
    + {content.logo ? this.renderPicture(content.logo) : null} + + {isRtamo ? ( +
    + +
    + ) : null} + +
    +
    + {content.title ? this.renderTitle(content) : null} + + {content.subtitle ? ( + +

    + + ) : null} + {content.cta_paragraph ? ( + + ) : null} +

    + {content.video_container ? ( + + ) : null} + {content.above_button_content + ? this.renderOrderedContent(content.above_button_content) + : null} + {this.renderContentTiles()} + {this.renderLanguageSwitcher()} + {!hideStepsIndicator && aboveButtonStepsIndicator + ? this.renderStepsIndicator() + : null} + +
    + {!hideStepsIndicator && !aboveButtonStepsIndicator + ? this.renderStepsIndicator() + : null} +
    +
    + + + +
    + ); + } +} diff --git a/browser/components/aboutwelcome/content-src/components/OnboardingVideo.jsx b/browser/components/aboutwelcome/content-src/components/OnboardingVideo.jsx new file mode 100644 index 0000000000..629a409a59 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/OnboardingVideo.jsx @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; + +export const OnboardingVideo = props => { + const vidUrl = props.content.video_url; + const autoplay = props.content.autoPlay; + + const handleVideoAction = event => { + props.handleAction({ + currentTarget: { + value: event, + }, + }); + }; + + return ( +
    + +
    + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/ReturnToAMO.jsx b/browser/components/aboutwelcome/content-src/components/ReturnToAMO.jsx new file mode 100644 index 0000000000..e262e3d92a --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/ReturnToAMO.jsx @@ -0,0 +1,105 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { + AboutWelcomeUtils, + DEFAULT_RTAMO_CONTENT, +} from "../lib/aboutwelcome-utils.mjs"; +import { MultiStageProtonScreen } from "./MultiStageProtonScreen"; +import { BASE_PARAMS } from "../lib/addUtmParams.mjs"; + +export class ReturnToAMO extends React.PureComponent { + constructor(props) { + super(props); + this.fetchFlowParams = this.fetchFlowParams.bind(this); + this.handleAction = this.handleAction.bind(this); + } + + async fetchFlowParams() { + if (this.props.metricsFlowUri) { + this.setState({ + flowParams: await AboutWelcomeUtils.fetchFlowParams( + this.props.metricsFlowUri + ), + }); + } + } + + componentDidUpdate() { + this.fetchFlowParams(); + } + + handleAction(event) { + const { content, message_id, url, utm_term } = this.props; + let { action, source_id } = content[event.currentTarget.value]; + let { type, data } = action; + + if (type === "INSTALL_ADDON_FROM_URL") { + if (!data) { + return; + } + // Set add-on url in action.data.url property from JSON + data = { ...data, url }; + } else if (type === "SHOW_FIREFOX_ACCOUNTS") { + let params = { + ...BASE_PARAMS, + utm_term: `aboutwelcome-${utm_term}-screen`, + }; + if (action.addFlowParams && this.state.flowParams) { + params = { + ...params, + ...this.state.flowParams, + }; + } + data = { ...data, extraParams: params }; + } + + AboutWelcomeUtils.handleUserAction({ type, data }); + AboutWelcomeUtils.sendActionTelemetry(message_id, source_id); + } + + render() { + const { content, type } = this.props; + + if (!content) { + return null; + } + + if (content?.primary_button.label) { + content.primary_button.label.string_id = type.includes("theme") + ? "return-to-amo-add-theme-label" + : "mr1-return-to-amo-add-extension-label"; + } + + // For experiments, when needed below rendered UI allows settings hard coded strings + // directly inside JSON except for ReturnToAMOText which picks add-on name and icon from fluent string + return ( +
    + +
    + ); + } +} + +ReturnToAMO.defaultProps = DEFAULT_RTAMO_CONTENT; diff --git a/browser/components/aboutwelcome/content-src/components/SubmenuButton.jsx b/browser/components/aboutwelcome/content-src/components/SubmenuButton.jsx new file mode 100644 index 0000000000..e0c8144e73 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/SubmenuButton.jsx @@ -0,0 +1,149 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useEffect, useRef, useCallback } from "react"; +import { Localized } from "./MSLocalized"; + +export const SubmenuButton = props => { + return document.createXULElement ? : null; +}; + +function translateMenuitem(item, element) { + let { label } = item; + if (!label) { + return; + } + if (label.raw) { + element.setAttribute("label", label.raw); + } + if (label.access_key) { + element.setAttribute("accesskey", label.access_key); + } + if (label.aria_label) { + element.setAttribute("aria-label", label.aria_label); + } + if (label.tooltip_text) { + element.setAttribute("tooltiptext", label.tooltip_text); + } + if (label.string_id) { + element.setAttribute("data-l10n-id", label.string_id); + if (label.args) { + element.setAttribute("data-l10n-args", JSON.stringify(label.args)); + } + } +} + +function addMenuitems(items, popup) { + for (let item of items) { + switch (item.type) { + case "separator": + popup.appendChild(document.createXULElement("menuseparator")); + break; + case "menu": + let menu = document.createXULElement("menu"); + menu.className = "fxms-multi-stage-menu"; + translateMenuitem(item, menu); + if (item.id) { + menu.value = item.id; + } + if (item.icon) { + menu.classList.add("menu-iconic"); + menu.setAttribute("image", item.icon); + } + popup.appendChild(menu); + let submenuPopup = document.createXULElement("menupopup"); + menu.appendChild(submenuPopup); + addMenuitems(item.submenu, submenuPopup); + break; + case "action": + let menuitem = document.createXULElement("menuitem"); + translateMenuitem(item, menuitem); + menuitem.config = item; + if (item.id) { + menuitem.value = item.id; + } + if (item.icon) { + menuitem.classList.add("menuitem-iconic"); + menuitem.setAttribute("image", item.icon); + } + popup.appendChild(menuitem); + break; + } + } +} + +const SubmenuButtonInner = ({ content, handleAction }) => { + const ref = useRef(null); + const isPrimary = content.submenu_button?.style === "primary"; + const onCommand = useCallback( + event => { + let { config } = event.target; + let mockEvent = { + currentTarget: ref.current, + source: config.id, + name: "command", + action: config.action, + }; + handleAction(mockEvent); + }, + [handleAction] + ); + const onClick = useCallback(() => { + let button = ref.current; + let submenu = button?.querySelector(".fxms-multi-stage-submenu"); + if (submenu && !button.hasAttribute("open")) { + submenu.openPopup(button, { position: "after_end" }); + } + }, []); + useEffect(() => { + let button = ref.current; + if (!button || button.querySelector(".fxms-multi-stage-submenu")) { + return null; + } + let menupopup = document.createXULElement("menupopup"); + menupopup.className = "fxms-multi-stage-submenu"; + addMenuitems(content.submenu_button.submenu, menupopup); + button.appendChild(menupopup); + let stylesheet; + if ( + !document.head.querySelector( + `link[href="chrome://global/content/widgets.css"], link[href="chrome://global/skin/global.css"]` + ) + ) { + stylesheet = document.createElement("link"); + stylesheet.rel = "stylesheet"; + stylesheet.href = "chrome://global/content/widgets.css"; + document.head.appendChild(stylesheet); + } + if (!menupopup.listenersRegistered) { + menupopup.addEventListener("command", onCommand); + menupopup.addEventListener("popupshowing", event => { + if (event.target === menupopup && event.target.anchorNode) { + event.target.anchorNode.toggleAttribute("open", true); + } + }); + menupopup.addEventListener("popuphiding", event => { + if (event.target === menupopup && event.target.anchorNode) { + event.target.anchorNode.toggleAttribute("open", false); + } + }); + menupopup.listenersRegistered = true; + } + return () => { + menupopup?.remove(); + stylesheet?.remove(); + }; + }, [onCommand]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + ; + } +} + +export class ToggleMessageJSON extends React.PureComponent { + constructor(props) { + super(props); + this.handleClick = this.handleClick.bind(this); + } + + handleClick() { + this.props.toggleJSON(this.props.msgId); + } + + render() { + let iconName = this.props.isCollapsed + ? "icon icon-arrowhead-forward-small" + : "icon icon-arrowhead-down-small"; + return ( + + ); + } +} + +export class TogglePrefCheckbox extends React.PureComponent { + constructor(props) { + super(props); + this.onChange = this.onChange.bind(this); + } + + onChange(event) { + this.props.onChange(this.props.pref, event.target.checked); + } + + render() { + return ( + <> + {" "} + {this.props.pref}{" "} + + ); + } +} + +export class ASRouterAdminInner extends React.PureComponent { + constructor(props) { + super(props); + this.handleEnabledToggle = this.handleEnabledToggle.bind(this); + this.handleUserPrefToggle = this.handleUserPrefToggle.bind(this); + this.onChangeMessageFilter = this.onChangeMessageFilter.bind(this); + this.onChangeMessageGroupsFilter = + this.onChangeMessageGroupsFilter.bind(this); + this.unblockAll = this.unblockAll.bind(this); + this.handleClearAllImpressionsByProvider = + this.handleClearAllImpressionsByProvider.bind(this); + this.handleExpressionEval = this.handleExpressionEval.bind(this); + this.onChangeTargetingParameters = + this.onChangeTargetingParameters.bind(this); + this.onChangeAttributionParameters = + this.onChangeAttributionParameters.bind(this); + this.setAttribution = this.setAttribution.bind(this); + this.onCopyTargetingParams = this.onCopyTargetingParams.bind(this); + this.onNewTargetingParams = this.onNewTargetingParams.bind(this); + this.handleOpenPB = this.handleOpenPB.bind(this); + this.selectPBMessage = this.selectPBMessage.bind(this); + this.resetPBJSON = this.resetPBJSON.bind(this); + this.resetPBMessageState = this.resetPBMessageState.bind(this); + this.toggleJSON = this.toggleJSON.bind(this); + this.toggleAllMessages = this.toggleAllMessages.bind(this); + this.resetGroups = this.resetGroups.bind(this); + this.onMessageFromParent = this.onMessageFromParent.bind(this); + this.setStateFromParent = this.setStateFromParent.bind(this); + this.setState = this.setState.bind(this); + this.state = { + messageFilter: "all", + messageGroupsFilter: "all", + collapsedMessages: [], + modifiedMessages: [], + selectedPBMessage: "", + evaluationStatus: {}, + stringTargetingParameters: null, + newStringTargetingParameters: null, + copiedToClipboard: false, + attributionParameters: { + source: "addons.mozilla.org", + medium: "referral", + campaign: "non-fx-button", + content: `rta:${btoa("uBlock0@raymondhill.net")}`, + experiment: "ua-onboarding", + variation: "chrome", + ua: "Google Chrome 123", + dltoken: "00000000-0000-0000-0000-000000000000", + }, + }; + } + + onMessageFromParent({ type, data }) { + // These only exists due to onPrefChange events in ASRouter + switch (type) { + case "UpdateAdminState": { + this.setStateFromParent(data); + break; + } + } + } + + setStateFromParent(data) { + this.setState(data); + if (!this.state.stringTargetingParameters) { + const stringTargetingParameters = {}; + for (const param of Object.keys(data.targetingParameters)) { + stringTargetingParameters[param] = JSON.stringify( + data.targetingParameters[param], + null, + 2 + ); + } + this.setState({ stringTargetingParameters }); + } + } + + componentWillMount() { + ASRouterUtils.addListener(this.onMessageFromParent); + const endpoint = ASRouterUtils.getPreviewEndpoint(); + ASRouterUtils.sendMessage({ + type: "ADMIN_CONNECT_STATE", + data: { endpoint }, + }).then(this.setStateFromParent); + } + + componentWillUnmount() { + ASRouterUtils.removeListener(this.onMessageFromParent); + } + + handleBlock(msg) { + return () => ASRouterUtils.blockById(msg.id); + } + + handleUnblock(msg) { + return () => ASRouterUtils.unblockById(msg.id); + } + + resetJSON(msg) { + // reset the displayed JSON for the given message + document.getElementById(`${msg.id}-textarea`).value = JSON.stringify( + msg, + null, + 2 + ); + // remove the message from the list of modified IDs + let index = this.state.modifiedMessages.indexOf(msg.id); + this.setState(prevState => ({ + modifiedMessages: [ + ...prevState.modifiedMessages.slice(0, index), + ...prevState.modifiedMessages.slice(index + 1), + ], + })); + } + + handleOverride(id) { + return () => + ASRouterUtils.overrideMessage(id).then(state => { + this.setStateFromParent(state); + }); + } + + resetPBMessageState() { + // Iterate over Private Browsing messages and block/unblock each one to clear impressions + const PBMessages = this.state.messages.filter( + message => message.template === "pb_newtab" + ); // messages from state go here + + PBMessages.forEach(message => { + if (message?.id) { + ASRouterUtils.blockById(message.id); + ASRouterUtils.unblockById(message.id); + } + }); + // Clear the selected messages & radio buttons + document.getElementById("clear radio").checked = true; + this.selectPBMessage("clear"); + } + + resetPBJSON(msg) { + // reset the displayed JSON for the given message + document.getElementById(`${msg.id}-textarea`).value = JSON.stringify( + msg, + null, + 2 + ); + } + + handleOpenPB() { + ASRouterUtils.sendMessage({ + type: "FORCE_PRIVATE_BROWSING_WINDOW", + data: { message: { content: this.state.selectedPBMessage } }, + }); + } + + expireCache() { + ASRouterUtils.sendMessage({ type: "EXPIRE_QUERY_CACHE" }); + } + + resetPref() { + ASRouterUtils.sendMessage({ type: "RESET_PROVIDER_PREF" }); + } + + resetGroups(id, value) { + ASRouterUtils.sendMessage({ + type: "RESET_GROUPS_STATE", + }).then(this.setStateFromParent); + } + + handleExpressionEval() { + const context = {}; + for (const param of Object.keys(this.state.stringTargetingParameters)) { + const value = this.state.stringTargetingParameters[param]; + context[param] = value ? JSON.parse(value) : null; + } + ASRouterUtils.sendMessage({ + type: "EVALUATE_JEXL_EXPRESSION", + data: { + expression: this.refs.expressionInput.value, + context, + }, + }).then(this.setStateFromParent); + } + + onChangeTargetingParameters(event) { + const { name } = event.target; + const { value } = event.target; + + this.setState(({ stringTargetingParameters }) => { + let targetingParametersError = null; + const updatedParameters = { ...stringTargetingParameters }; + updatedParameters[name] = value; + try { + JSON.parse(value); + } catch (e) { + console.error(`Error parsing value of parameter ${name}`); + targetingParametersError = { id: name }; + } + + return { + copiedToClipboard: false, + evaluationStatus: {}, + stringTargetingParameters: updatedParameters, + targetingParametersError, + }; + }); + } + + unblockAll() { + return ASRouterUtils.sendMessage({ + type: "UNBLOCK_ALL", + }).then(this.setStateFromParent); + } + + handleClearAllImpressionsByProvider() { + const providerId = this.state.messageFilter; + if (!providerId) { + return; + } + const userPrefInfo = this.state.userPrefs; + + const isUserEnabled = + providerId in userPrefInfo ? userPrefInfo[providerId] : true; + + ASRouterUtils.sendMessage({ + type: "DISABLE_PROVIDER", + data: providerId, + }); + if (!isUserEnabled) { + ASRouterUtils.sendMessage({ + type: "SET_PROVIDER_USER_PREF", + data: { id: providerId, value: true }, + }); + } + ASRouterUtils.sendMessage({ + type: "ENABLE_PROVIDER", + data: providerId, + }); + } + + handleEnabledToggle(event) { + const provider = this.state.providerPrefs.find( + p => p.id === event.target.dataset.provider + ); + const userPrefInfo = this.state.userPrefs; + + const isUserEnabled = + provider.id in userPrefInfo ? userPrefInfo[provider.id] : true; + const isSystemEnabled = provider.enabled; + const isEnabling = event.target.checked; + + if (isEnabling) { + if (!isUserEnabled) { + ASRouterUtils.sendMessage({ + type: "SET_PROVIDER_USER_PREF", + data: { id: provider.id, value: true }, + }); + } + if (!isSystemEnabled) { + ASRouterUtils.sendMessage({ + type: "ENABLE_PROVIDER", + data: provider.id, + }); + } + } else { + ASRouterUtils.sendMessage({ + type: "DISABLE_PROVIDER", + data: provider.id, + }); + } + + this.setState({ messageFilter: "all" }); + } + + handleUserPrefToggle(event) { + const action = { + type: "SET_PROVIDER_USER_PREF", + data: { id: event.target.dataset.provider, value: event.target.checked }, + }; + ASRouterUtils.sendMessage(action); + this.setState({ messageFilter: "all" }); + } + + onChangeMessageFilter(event) { + this.setState({ messageFilter: event.target.value }); + } + + onChangeMessageGroupsFilter(event) { + this.setState({ messageGroupsFilter: event.target.value }); + } + + // Simulate a copy event that sets to clipboard all targeting paramters and values + onCopyTargetingParams(event) { + const stringTargetingParameters = { + ...this.state.stringTargetingParameters, + }; + for (const key of Object.keys(stringTargetingParameters)) { + // If the value is not set the parameter will be lost when we stringify + if (stringTargetingParameters[key] === undefined) { + stringTargetingParameters[key] = null; + } + } + const setClipboardData = e => { + e.preventDefault(); + e.clipboardData.setData( + "text", + JSON.stringify(stringTargetingParameters, null, 2) + ); + document.removeEventListener("copy", setClipboardData); + this.setState({ copiedToClipboard: true }); + }; + + document.addEventListener("copy", setClipboardData); + + document.execCommand("copy"); + } + + onNewTargetingParams(event) { + this.setState({ newStringTargetingParameters: event.target.value }); + event.target.classList.remove("errorState"); + this.refs.targetingParamsEval.innerText = ""; + + try { + const stringTargetingParameters = JSON.parse(event.target.value); + this.setState({ stringTargetingParameters }); + } catch (e) { + event.target.classList.add("errorState"); + this.refs.targetingParamsEval.innerText = e.message; + } + } + + toggleJSON(msgId) { + if (this.state.collapsedMessages.includes(msgId)) { + let index = this.state.collapsedMessages.indexOf(msgId); + this.setState(prevState => ({ + collapsedMessages: [ + ...prevState.collapsedMessages.slice(0, index), + ...prevState.collapsedMessages.slice(index + 1), + ], + })); + } else { + this.setState(prevState => ({ + collapsedMessages: prevState.collapsedMessages.concat(msgId), + })); + } + } + + handleChange(msgId) { + if (!this.state.modifiedMessages.includes(msgId)) { + this.setState(prevState => ({ + modifiedMessages: prevState.modifiedMessages.concat(msgId), + })); + } + } + + renderMessageItem(msg) { + const isBlockedByGroup = this.state.groups + .filter(group => msg.groups.includes(group.id)) + .some(group => !group.enabled); + const msgProvider = + this.state.providers.find(provider => provider.id === msg.provider) || {}; + const isProviderExcluded = + msgProvider.exclude && msgProvider.exclude.includes(msg.id); + const isMessageBlocked = + this.state.messageBlockList.includes(msg.id) || + this.state.messageBlockList.includes(msg.campaign); + const isBlocked = + isMessageBlocked || isBlockedByGroup || isProviderExcluded; + const impressions = this.state.messageImpressions[msg.id] + ? this.state.messageImpressions[msg.id].length + : 0; + const isCollapsed = this.state.collapsedMessages.includes(msg.id); + const isModified = this.state.modifiedMessages.includes(msg.id); + const aboutMessagePreviewSupported = [ + "infobar", + "spotlight", + "cfr_doorhanger", + ].includes(msg.template); + + let itemClassName = "message-item"; + if (isBlocked) { + itemClassName += " blocked"; + } + + return ( + + + + {msg.id}
    +
    + + + + + + + { + // eslint-disable-next-line no-nested-ternary + isBlocked ? null : isModified ? ( + + ) : ( + + ) + } + {isBlocked ? null : ( + + )} + {aboutMessagePreviewSupported ? ( + + `about:messagepreview?json=${encodeURIComponent(btoa(text))}` + } + label="Share" + copiedLabel="Copied!" + inputSelector={`#${msg.id}-textarea`} + className={"button share"} + /> + ) : null} +
    ({impressions} impressions) + + + {isBlocked && ( + + Block reason: + {isBlockedByGroup && " Blocked by group"} + {isProviderExcluded && " Excluded by provider"} + {isMessageBlocked && " Message blocked"} + + )} + +
    +              
    +            
    + + + + ); + } + + selectPBMessage(msgId) { + if (msgId === "clear") { + this.setState({ + selectedPBMessage: "", + }); + } else { + let selected = document.getElementById(`${msgId} radio`); + let msg = JSON.parse(document.getElementById(`${msgId}-textarea`).value); + + if (selected.checked) { + this.setState({ + selectedPBMessage: msg?.content, + }); + } else { + this.setState({ + selectedPBMessage: "", + }); + } + } + } + + modifyJson(content) { + const message = JSON.parse( + document.getElementById(`${content.id}-textarea`).value + ); + return ASRouterUtils.modifyMessageJson(message).then(state => { + this.setStateFromParent(state); + }); + } + + renderPBMessageItem(msg) { + const isBlocked = + this.state.messageBlockList.includes(msg.id) || + this.state.messageBlockList.includes(msg.campaign); + const impressions = this.state.messageImpressions[msg.id] + ? this.state.messageImpressions[msg.id].length + : 0; + + const isCollapsed = this.state.collapsedMessages.includes(msg.id); + + let itemClassName = "message-item"; + if (isBlocked) { + itemClassName += " blocked"; + } + + return ( + + + + {msg.id}
    +
    ({impressions} impressions) +
    + + + + + + this.selectPBMessage(msg.id)} + disabled={isBlocked} + /> + + + + +
    +            
    +          
    + + + ); + } + + toggleAllMessages(messagesToShow) { + if (this.state.collapsedMessages.length) { + this.setState({ + collapsedMessages: [], + }); + } else { + Array.prototype.forEach.call(messagesToShow, msg => { + this.setState(prevState => ({ + collapsedMessages: prevState.collapsedMessages.concat(msg.id), + })); + }); + } + } + + renderMessages() { + if (!this.state.messages) { + return null; + } + const messagesToShow = + this.state.messageFilter === "all" + ? this.state.messages + : this.state.messages.filter( + message => + message.provider === this.state.messageFilter && + message.template !== "pb_newtab" + ); + + return ( +
    + +

    + {" "} + + To modify a message, change the JSON and click 'Modify' to see your + changes. Click 'Reset' to restore the JSON to the original. Click + 'Share' to copy a link to the clipboard that can be used to preview + the message by opening the link in Nightly/local builds. + +

    + + + {messagesToShow.map(msg => this.renderMessageItem(msg))} + +
    +
    + ); + } + + renderMessagesByGroup() { + if (!this.state.messages) { + return null; + } + const messagesToShow = + this.state.messageGroupsFilter === "all" + ? this.state.messages.filter(m => m.groups.length) + : this.state.messages.filter(message => + message.groups.includes(this.state.messageGroupsFilter) + ); + + return ( + + {messagesToShow.map(msg => this.renderMessageItem(msg))} +
    + ); + } + + renderPBMessages() { + if (!this.state.messages) { + return null; + } + const messagesToShow = this.state.messages.filter( + message => message.template === "pb_newtab" + ); + return ( + + + {messagesToShow.map(msg => this.renderPBMessageItem(msg))} + +
    + ); + } + + renderMessageFilter() { + if (!this.state.providers) { + return null; + } + + return ( +

    + + Show messages from{" "} + + {this.state.messageFilter !== "all" && + !this.state.messageFilter.includes("_local_testing") ? ( + + ) : null} +

    + ); + } + + renderMessageGroupsFilter() { + if (!this.state.groups) { + return null; + } + + return ( +

    + Show messages from {/* eslint-disable-next-line jsx-a11y/no-onchange */} + +

    + ); + } + + renderTableHead() { + return ( + + + + Provider ID + Source + Cohort + Last Updated + + + ); + } + + renderProviders() { + const providersConfig = this.state.providerPrefs; + const providerInfo = this.state.providers; + const userPrefInfo = this.state.userPrefs; + + return ( + + {this.renderTableHead()} + + {providersConfig.map((provider, i) => { + const isTestProvider = provider.id.includes("_local_testing"); + const info = providerInfo.find(p => p.id === provider.id) || {}; + const isUserEnabled = + provider.id in userPrefInfo ? userPrefInfo[provider.id] : true; + const isSystemEnabled = isTestProvider || provider.enabled; + + let label = "local"; + if (provider.type === "remote") { + label = ( + + endpoint ( + + {info.url} + + ) + + ); + } else if (provider.type === "remote-settings") { + label = `remote settings (${provider.collection})`; + } else if (provider.type === "remote-experiments") { + label = ( + + remote settings ( + + nimbus-desktop-experiments + + ) + + ); + } + + let reasonsDisabled = []; + if (!isSystemEnabled) { + reasonsDisabled.push("system pref"); + } + if (!isUserEnabled) { + reasonsDisabled.push("user pref"); + } + if (reasonsDisabled.length) { + label = `disabled via ${reasonsDisabled.join(", ")}`; + } + + return ( + + + + + + + + ); + })} + +
    + {isTestProvider ? ( + + ) : ( + + )} + {provider.id} + + {label} + + {provider.cohort} + {info.lastUpdated + ? new Date(info.lastUpdated).toLocaleString() + : ""} +
    + ); + } + + renderTargetingParameters() { + // There was no error and the result is truthy + const success = + this.state.evaluationStatus.success && + !!this.state.evaluationStatus.result; + const result = + JSON.stringify(this.state.evaluationStatus.result, null, 2) || + "(Empty result)"; + + return ( + + + + + + + + {props.children} + +); + +function relativeTime(timestamp) { + if (!timestamp) { + return ""; + } + const seconds = Math.floor((Date.now() - timestamp) / 1000); + const minutes = Math.floor((Date.now() - timestamp) / 60000); + if (seconds < 2) { + return "just now"; + } else if (seconds < 60) { + return `${seconds} seconds ago`; + } else if (minutes === 1) { + return "1 minute ago"; + } else if (minutes < 600) { + return `${minutes} minutes ago`; + } + return new Date(timestamp).toLocaleString(); +} + +export class ToggleStoryButton extends React.PureComponent { + constructor(props) { + super(props); + this.handleClick = this.handleClick.bind(this); + } + + handleClick() { + this.props.onClick(this.props.story); + } + + render() { + return ; + } +} + +export class TogglePrefCheckbox extends React.PureComponent { + constructor(props) { + super(props); + this.onChange = this.onChange.bind(this); + } + + onChange(event) { + this.props.onChange(this.props.pref, event.target.checked); + } + + render() { + return ( + <> + {" "} + {this.props.pref}{" "} + + ); + } +} + +export class Personalization extends React.PureComponent { + constructor(props) { + super(props); + this.togglePersonalization = this.togglePersonalization.bind(this); + } + + togglePersonalization() { + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_TOGGLE, + }) + ); + } + + render() { + const { lastUpdated, initialized } = this.props.state.Personalization; + return ( + +
    +

    Evaluate JEXL expression

    +
    +

    + '; + +let gAnchor; +let gPanel; +let gPanelMultiView; +let gMainView; +let gMainContext; +let gMainButton1; +let gMainMenulist; +let gMainRadiogroup; +let gMainTextbox; +let gMainButton2; +let gMainButton3; +let gCheckbox; +let gNamespacedLink; +let gLink; +let gMainTabOrder; +let gMainArrowOrder; +let gSubView; +let gSubButton; +let gSubTextarea; +let gBrowserView; +let gBrowserBrowser; +let gIframeView; +let gIframeIframe; +let gToggle; + +async function openPopup() { + let shown = BrowserTestUtils.waitForEvent(gMainView, "ViewShown"); + PanelMultiView.openPopup(gPanel, gAnchor, "bottomright topright"); + await shown; +} + +async function hidePopup() { + let hidden = BrowserTestUtils.waitForEvent(gPanel, "popuphidden"); + PanelMultiView.hidePopup(gPanel); + await hidden; +} + +async function showSubView(view = gSubView) { + let shown = BrowserTestUtils.waitForEvent(view, "ViewShown"); + // We must show with an anchor so the Back button is generated. + gPanelMultiView.showSubView(view, gMainButton1); + await shown; +} + +async function expectFocusAfterKey(aKey, aFocus) { + let res = aKey.match(/^(Shift\+)?(.+)$/); + let shift = Boolean(res[1]); + let key; + if (res[2].length == 1) { + key = res[2]; // Character. + } else { + key = "KEY_" + res[2]; // Tab, ArrowRight, etc. + } + info("Waiting for focus on " + aFocus.id); + let focused = BrowserTestUtils.waitForEvent(aFocus, "focus"); + EventUtils.synthesizeKey(key, { shiftKey: shift }); + await focused; + ok(true, aFocus.id + " focused after " + aKey + " pressed"); +} + +add_setup(async function () { + // This shouldn't be necessary - but it is, because we use same-process frames. + // https://bugzilla.mozilla.org/show_bug.cgi?id=1565276 covers improving this. + await SpecialPowers.pushPrefEnv({ + set: [["security.allow_unsafe_parent_loads", true]], + }); + let navBar = document.getElementById("nav-bar"); + gAnchor = document.createXULElement("toolbarbutton"); + navBar.appendChild(gAnchor); + gPanel = document.createXULElement("panel"); + navBar.appendChild(gPanel); + gPanelMultiView = document.createXULElement("panelmultiview"); + gPanelMultiView.setAttribute("mainViewId", "testMainView"); + gPanel.appendChild(gPanelMultiView); + + gMainView = document.createXULElement("panelview"); + gMainView.id = "testMainView"; + gPanelMultiView.appendChild(gMainView); + gMainContext = document.createXULElement("menupopup"); + gMainContext.id = "gMainContext"; + gMainView.appendChild(gMainContext); + gMainContext.appendChild(document.createXULElement("menuitem")); + gMainButton1 = document.createXULElement("button"); + gMainButton1.id = "gMainButton1"; + gMainView.appendChild(gMainButton1); + // We use this for anchoring subviews, so it must have a label. + gMainButton1.setAttribute("label", "gMainButton1"); + gMainButton1.setAttribute("context", "gMainContext"); + gMainMenulist = document.createXULElement("menulist"); + gMainMenulist.id = "gMainMenulist"; + gMainView.appendChild(gMainMenulist); + let menuPopup = document.createXULElement("menupopup"); + gMainMenulist.appendChild(menuPopup); + let item = document.createXULElement("menuitem"); + item.setAttribute("value", "1"); + item.setAttribute("selected", "true"); + menuPopup.appendChild(item); + item = document.createXULElement("menuitem"); + item.setAttribute("value", "2"); + menuPopup.appendChild(item); + gMainRadiogroup = document.createXULElement("radiogroup"); + gMainRadiogroup.id = "gMainRadiogroup"; + gMainView.appendChild(gMainRadiogroup); + let radio = document.createXULElement("radio"); + radio.setAttribute("value", "1"); + radio.setAttribute("selected", "true"); + gMainRadiogroup.appendChild(radio); + radio = document.createXULElement("radio"); + radio.setAttribute("value", "2"); + gMainRadiogroup.appendChild(radio); + gMainTextbox = document.createElement("input"); + gMainTextbox.id = "gMainTextbox"; + gMainView.appendChild(gMainTextbox); + gMainTextbox.setAttribute("value", "value"); + gMainButton2 = document.createXULElement("button"); + gMainButton2.id = "gMainButton2"; + gMainView.appendChild(gMainButton2); + gMainButton3 = document.createXULElement("button"); + gMainButton3.id = "gMainButton3"; + gMainView.appendChild(gMainButton3); + gCheckbox = document.createXULElement("checkbox"); + gCheckbox.id = "gCheckbox"; + gMainView.appendChild(gCheckbox); + + // moz-support-links in XUL documents are created with the + // tag and so we need to test this separately from + // tags. + gNamespacedLink = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "html:a" + ); + gNamespacedLink.href = "www.mozilla.org"; + gNamespacedLink.innerText = "gNamespacedLink"; + gNamespacedLink.id = "gNamespacedLink"; + gMainView.appendChild(gNamespacedLink); + gLink = document.createElement("a"); + gLink.href = "www.mozilla.org"; + 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); + + gMainTabOrder = [ + gMainButton1, + gMainMenulist, + gMainRadiogroup, + gMainTextbox, + gMainButton2, + gMainButton3, + gCheckbox, + gNamespacedLink, + gLink, + gToggle, + ]; + gMainArrowOrder = [ + gMainButton1, + gMainButton2, + gMainButton3, + gCheckbox, + gNamespacedLink, + gLink, + gToggle, + ]; + + gSubView = document.createXULElement("panelview"); + gSubView.id = "testSubView"; + gPanelMultiView.appendChild(gSubView); + gSubButton = document.createXULElement("button"); + gSubView.appendChild(gSubButton); + gSubTextarea = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "textarea" + ); + gSubTextarea.id = "gSubTextarea"; + gSubView.appendChild(gSubTextarea); + gSubTextarea.value = "value"; + + gBrowserView = document.createXULElement("panelview"); + gBrowserView.id = "testBrowserView"; + gPanelMultiView.appendChild(gBrowserView); + gBrowserBrowser = document.createXULElement("browser"); + gBrowserBrowser.id = "GBrowserBrowser"; + gBrowserBrowser.setAttribute("type", "content"); + gBrowserBrowser.setAttribute("src", kEmbeddedDocUrl); + gBrowserBrowser.style.minWidth = gBrowserBrowser.style.minHeight = "100px"; + gBrowserView.appendChild(gBrowserBrowser); + + gIframeView = document.createXULElement("panelview"); + gIframeView.id = "testIframeView"; + gPanelMultiView.appendChild(gIframeView); + gIframeIframe = document.createXULElement("iframe"); + gIframeIframe.id = "gIframeIframe"; + gIframeIframe.setAttribute("src", kEmbeddedDocUrl); + gIframeView.appendChild(gIframeIframe); + + registerCleanupFunction(() => { + gAnchor.remove(); + gPanel.remove(); + }); +}); + +// Test that the tab key focuses all expected controls. +add_task(async function testTab() { + await openPopup(); + for (let elem of gMainTabOrder) { + await expectFocusAfterKey("Tab", elem); + } + // Wrap around. + await expectFocusAfterKey("Tab", gMainTabOrder[0]); + await hidePopup(); +}); + +// Test that the shift+tab key focuses all expected controls. +add_task(async function testShiftTab() { + await openPopup(); + for (let i = gMainTabOrder.length - 1; i >= 0; --i) { + await expectFocusAfterKey("Shift+Tab", gMainTabOrder[i]); + } + // Wrap around. + await expectFocusAfterKey( + "Shift+Tab", + gMainTabOrder[gMainTabOrder.length - 1] + ); + await hidePopup(); +}); + +// Test that the down arrow key skips menulists and textboxes. +add_task(async function testDownArrow() { + await openPopup(); + for (let elem of gMainArrowOrder) { + await expectFocusAfterKey("ArrowDown", elem); + } + // Wrap around. + await expectFocusAfterKey("ArrowDown", gMainArrowOrder[0]); + await hidePopup(); +}); + +// Test that the up arrow key skips menulists and textboxes. +add_task(async function testUpArrow() { + await openPopup(); + for (let i = gMainArrowOrder.length - 1; i >= 0; --i) { + await expectFocusAfterKey("ArrowUp", gMainArrowOrder[i]); + } + // Wrap around. + await expectFocusAfterKey( + "ArrowUp", + gMainArrowOrder[gMainArrowOrder.length - 1] + ); + await hidePopup(); +}); + +// Test that the home/end keys move to the first/last controls. +add_task(async function testHomeEnd() { + await openPopup(); + await expectFocusAfterKey("Home", gMainArrowOrder[0]); + await expectFocusAfterKey("End", gMainArrowOrder[gMainArrowOrder.length - 1]); + await hidePopup(); +}); + +// Test that the up/down arrow keys work as expected in menulists. +add_task(async function testArrowsMenulist() { + await openPopup(); + gMainMenulist.focus(); + is(document.activeElement, gMainMenulist, "menulist focused"); + is(gMainMenulist.value, "1", "menulist initial value 1"); + if (AppConstants.platform == "macosx") { + // On Mac, down/up arrows just open the menulist. + let popup = gMainMenulist.menupopup; + for (let key of ["ArrowDown", "ArrowUp"]) { + let shown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + EventUtils.synthesizeKey("KEY_" + key); + await shown; + ok(gMainMenulist.open, "menulist open after " + key); + let hidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + EventUtils.synthesizeKey("KEY_Escape"); + await hidden; + ok(!gMainMenulist.open, "menulist closed after Escape"); + } + } else { + // On other platforms, down/up arrows change the value without opening the + // menulist. + EventUtils.synthesizeKey("KEY_ArrowDown"); + is( + document.activeElement, + gMainMenulist, + "menulist still focused after ArrowDown" + ); + is(gMainMenulist.value, "2", "menulist value 2 after ArrowDown"); + EventUtils.synthesizeKey("KEY_ArrowUp"); + is( + document.activeElement, + gMainMenulist, + "menulist still focused after ArrowUp" + ); + is(gMainMenulist.value, "1", "menulist value 1 after ArrowUp"); + } + await hidePopup(); +}); + +// Test that the tab key closes an open menu list. +add_task(async function testTabOpenMenulist() { + await openPopup(); + gMainMenulist.focus(); + is(document.activeElement, gMainMenulist, "menulist focused"); + let popup = gMainMenulist.menupopup; + let shown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + gMainMenulist.open = true; + await shown; + ok(gMainMenulist.open, "menulist open"); + let menuHidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + EventUtils.synthesizeKey("KEY_Tab"); + await menuHidden; + ok(!gMainMenulist.open, "menulist closed after Tab"); + is(gPanel.state, "open", "Panel should be open"); + await hidePopup(); +}); + +if (AppConstants.platform == "macosx") { + // Test that using the mouse to open a menulist still allows keyboard navigation + // inside it. + add_task(async function testNavigateMouseOpenedMenulist() { + await openPopup(); + let popup = gMainMenulist.menupopup; + let shown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + gMainMenulist.open = true; + await shown; + ok(gMainMenulist.open, "menulist open"); + let oldFocus = document.activeElement; + let oldSelectedItem = gMainMenulist.selectedItem; + ok( + oldSelectedItem.hasAttribute("_moz-menuactive"), + "Selected item should show up as active" + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + await TestUtils.waitForCondition( + () => !oldSelectedItem.hasAttribute("_moz-menuactive") + ); + is(oldFocus, document.activeElement, "Focus should not move on mac"); + ok( + !oldSelectedItem.hasAttribute("_moz-menuactive"), + "Selected item should change" + ); + + let menuHidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + EventUtils.synthesizeKey("KEY_Tab"); + await menuHidden; + ok(!gMainMenulist.open, "menulist closed after Tab"); + is(gPanel.state, "open", "Panel should be open"); + await hidePopup(); + }); +} + +// Test that the up/down arrow keys work as expected in radiogroups. +add_task(async function testArrowsRadiogroup() { + await openPopup(); + gMainRadiogroup.focus(); + is(document.activeElement, gMainRadiogroup, "radiogroup focused"); + is(gMainRadiogroup.value, "1", "radiogroup initial value 1"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + is( + document.activeElement, + gMainRadiogroup, + "radiogroup still focused after ArrowDown" + ); + is(gMainRadiogroup.value, "2", "radiogroup value 2 after ArrowDown"); + EventUtils.synthesizeKey("KEY_ArrowUp"); + is( + document.activeElement, + gMainRadiogroup, + "radiogroup still focused after ArrowUp" + ); + is(gMainRadiogroup.value, "1", "radiogroup value 1 after ArrowUp"); + await hidePopup(); +}); + +// Test that pressing space in a textbox inserts a space (instead of trying to +// activate the control). +add_task(async function testSpaceTextbox() { + await openPopup(); + gMainTextbox.focus(); + gMainTextbox.selectionStart = gMainTextbox.selectionEnd = 0; + EventUtils.synthesizeKey(" "); + is(gMainTextbox.value, " value", "Space typed into textbox"); + gMainTextbox.value = "value"; + await hidePopup(); +}); + +// Tests that the left arrow key normally moves back to the previous view. +add_task(async function testLeftArrow() { + await openPopup(); + await showSubView(); + let shown = BrowserTestUtils.waitForEvent(gMainView, "ViewShown"); + EventUtils.synthesizeKey("KEY_ArrowLeft"); + await shown; + ok("Moved to previous view after ArrowLeft"); + await hidePopup(); +}); + +// Tests that the left arrow key moves the caret in a textarea in a subview +// (instead of going back to the previous view). +add_task(async function testLeftArrowTextarea() { + await openPopup(); + await showSubView(); + gSubTextarea.focus(); + is(document.activeElement, gSubTextarea, "textarea focused"); + EventUtils.synthesizeKey("KEY_End"); + is(gSubTextarea.selectionStart, 5, "selectionStart 5 after End"); + EventUtils.synthesizeKey("KEY_ArrowLeft"); + is(gSubTextarea.selectionStart, 4, "selectionStart 4 after ArrowLeft"); + is(document.activeElement, gSubTextarea, "textarea still focused"); + await hidePopup(); +}); + +// Test navigation to a button which is initially disabled and later enabled. +add_task(async function testDynamicButton() { + gMainButton2.disabled = true; + await openPopup(); + await expectFocusAfterKey("ArrowDown", gMainButton1); + await expectFocusAfterKey("ArrowDown", gMainButton3); + gMainButton2.disabled = false; + await expectFocusAfterKey("ArrowUp", gMainButton2); + await hidePopup(); +}); + +add_task(async function testActivation() { + function checkActivated(elem, activationFn, reason) { + let activated = false; + elem.onclick = function () { + activated = true; + }; + activationFn(); + ok(activated, "Should have activated button after " + reason); + elem.onclick = null; + } + await openPopup(); + await expectFocusAfterKey("ArrowDown", gMainButton1); + checkActivated( + gMainButton1, + () => EventUtils.synthesizeKey("KEY_Enter"), + "pressing enter" + ); + checkActivated( + gMainButton1, + () => EventUtils.synthesizeKey(" "), + "pressing space" + ); + checkActivated( + gMainButton1, + () => EventUtils.synthesizeKey("KEY_Enter", { code: "NumpadEnter" }), + "pressing numpad enter" + ); + await hidePopup(); +}); + +// Test that keyboard activation works for buttons responding to mousedown +// events (instead of command or click). The Library button does this, for +// example. +add_task(async function testActivationMousedown() { + await openPopup(); + await expectFocusAfterKey("ArrowDown", gMainButton1); + let activated = false; + gMainButton1.onmousedown = function () { + activated = true; + }; + EventUtils.synthesizeKey(" "); + ok(activated, "mousedown activated after space"); + gMainButton1.onmousedown = null; + await hidePopup(); +}); + +// Test that tab and the arrow keys aren't overridden in embedded documents. +async function testTabArrowsEmbeddedDoc(aView, aEmbedder) { + await openPopup(); + await showSubView(aView); + let doc = aEmbedder.contentDocument; + if (doc.readyState != "complete" || doc.location.href != kEmbeddedDocUrl) { + info(`Embedded doc readyState ${doc.readyState}, location ${doc.location}`); + info("Waiting for load on embedder"); + // Browsers don't fire load events, and iframes don't fire load events in + // typeChrome windows. We can handle both by using a capturing event + // listener to capture the load event from the child document. + await BrowserTestUtils.waitForEvent(aEmbedder, "load", true); + // The original doc might have been a temporary about:blank, so fetch it + // again. + doc = aEmbedder.contentDocument; + } + is(doc.location.href, kEmbeddedDocUrl, "Embedded doc has correct URl"); + let backButton = aView.querySelector(".subviewbutton-back"); + backButton.id = "docBack"; + await expectFocusAfterKey("Tab", backButton); + // Documents don't have an id property, but expectFocusAfterKey wants one. + doc.id = "doc"; + await expectFocusAfterKey("Tab", doc); + // Make sure tab/arrows aren't overridden within the embedded document. + let textarea = doc.getElementById("docTextarea"); + // Tab should really focus the textarea, but default tab handling seems to + // skip everything inside the embedder element when run in this test. This + // behaves as expected in real panels, though. Force focus to the textarea + // and then test from there. + textarea.focus(); + is(doc.activeElement, textarea, "textarea focused"); + is(textarea.selectionStart, 0, "selectionStart initially 0"); + EventUtils.synthesizeKey("KEY_ArrowRight"); + is(textarea.selectionStart, 1, "selectionStart 1 after ArrowRight"); + EventUtils.synthesizeKey("KEY_ArrowLeft"); + is(textarea.selectionStart, 0, "selectionStart 0 after ArrowLeft"); + is(doc.activeElement, textarea, "textarea still focused"); + let docButton = doc.getElementById("docButton"); + await expectFocusAfterKey("Tab", docButton); + await hidePopup(); +} + +// Test that tab and the arrow keys aren't overridden in embedded browsers. +add_task(async function testTabArrowsBrowser() { + await testTabArrowsEmbeddedDoc(gBrowserView, gBrowserBrowser); +}); + +// Test that tab and the arrow keys aren't overridden in embedded iframes. +add_task(async function testTabArrowsIframe() { + await testTabArrowsEmbeddedDoc(gIframeView, gIframeIframe); +}); + +// Test that the arrow keys aren't overridden in context menus. +add_task(async function testArowsContext() { + await openPopup(); + await expectFocusAfterKey("ArrowDown", gMainButton1); + let shown = BrowserTestUtils.waitForEvent(gMainContext, "popupshown"); + // There's no cross-platform way to open a context menu from the keyboard. + gMainContext.openPopup(gMainButton1); + await shown; + let item = gMainContext.children[0]; + ok( + !item.getAttribute("_moz-menuactive"), + "First context menu item initially inactive" + ); + let active = BrowserTestUtils.waitForEvent(item, "DOMMenuItemActive"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + await active; + ok( + item.getAttribute("_moz-menuactive"), + "First context menu item active after ArrowDown" + ); + is( + document.activeElement, + gMainButton1, + "gMainButton1 still focused after ArrowDown" + ); + let hidden = BrowserTestUtils.waitForEvent(gMainContext, "popuphidden"); + gMainContext.hidePopup(); + await hidden; + await hidePopup(); +}); + +add_task(async function testMozToggle() { + await openPopup(); + is(gToggle.pressed, false, "The toggle is not pressed initially."); + // Focus the toggle via keyboard navigation. + while (document.activeElement !== gToggle) { + EventUtils.synthesizeKey("KEY_Tab"); + } + EventUtils.synthesizeKey(" "); + await gToggle.updateComplete; + is(gToggle.pressed, true, "Toggle pressed state changes via spacebar."); + EventUtils.synthesizeKey("KEY_Enter"); + await gToggle.updateComplete; + is(gToggle.pressed, false, "Toggle pressed state changes via enter."); + await hidePopup(); +}); diff --git a/browser/components/customizableui/test/browser_addons_area.js b/browser/components/customizableui/test/browser_addons_area.js new file mode 100644 index 0000000000..533d48b238 --- /dev/null +++ b/browser/components/customizableui/test/browser_addons_area.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test that widgets provided by extensions can be added to the + * ADDONS area, but all other widgets cannot. + */ +add_task(async function test_only_extension_widgets_in_addons_area() { + registerCleanupFunction(async () => { + await CustomizableUI.reset(); + }); + + Assert.ok( + !CustomizableUI.canWidgetMoveToArea( + "home-button", + CustomizableUI.AREA_ADDONS + ), + "Cannot move a built-in button to the ADDONS area." + ); + + // Now double-check that we cannot accidentally default a non-extension + // widget into the ADDONS area. + const kTestDynamicWidget = "a-test-widget"; + CustomizableUI.createWidget({ + id: kTestDynamicWidget, + label: "Test widget", + defaultArea: CustomizableUI.AREA_ADDONS, + }); + Assert.equal( + CustomizableUI.getPlacementOfWidget(kTestDynamicWidget), + null, + "An attempt to put a non-extension widget into the ADDONS area by default should fail." + ); + CustomizableUI.destroyWidget(kTestDynamicWidget); + + const kWebExtensionButtonID1 = "a-test-extension-button"; + + CustomizableUI.createWidget({ + id: kWebExtensionButtonID1, + label: "Test extension widget", + defaultArea: CustomizableUI.AREA_NAVBAR, + webExtension: true, + }); + + Assert.ok( + CustomizableUI.canWidgetMoveToArea( + kWebExtensionButtonID1, + CustomizableUI.AREA_ADDONS + ), + "Can move extension button to the addons area." + ); + + CustomizableUI.destroyWidget(kWebExtensionButtonID1); + + // Now check that extension buttons can default to the ADDONS area, if need + // be. + + const kWebExtensionButtonID2 = "a-test-extension-button-2"; + + CustomizableUI.createWidget({ + id: kWebExtensionButtonID2, + label: "Test extension widget 2", + defaultArea: CustomizableUI.AREA_ADDONS, + webExtension: true, + }); + + Assert.equal( + CustomizableUI.getPlacementOfWidget(kWebExtensionButtonID2)?.area, + CustomizableUI.AREA_ADDONS, + "An attempt to put an extension widget into the ADDONS area by default should work." + ); + + CustomizableUI.destroyWidget(kWebExtensionButtonID2); +}); diff --git a/browser/components/customizableui/test/browser_allow_dragging_removable_false.js b/browser/components/customizableui/test/browser_allow_dragging_removable_false.js new file mode 100644 index 0000000000..76269f44ae --- /dev/null +++ b/browser/components/customizableui/test/browser_allow_dragging_removable_false.js @@ -0,0 +1,42 @@ +"use strict"; + +/** + * Test dragging a removable=false widget within its own area as well as to the palette. + */ +add_task(async function () { + await startCustomizing(); + let forwardButton = document.getElementById("forward-button"); + is( + forwardButton.getAttribute("removable"), + "false", + "forward-button should not be removable" + ); + ok(CustomizableUI.inDefaultState, "Should start in default state."); + + let urlbarContainer = document.getElementById("urlbar-container"); + let placementsAfterDrag = getAreaWidgetIds(CustomizableUI.AREA_NAVBAR); + placementsAfterDrag.splice(placementsAfterDrag.indexOf("forward-button"), 1); + placementsAfterDrag.splice( + placementsAfterDrag.indexOf("urlbar-container"), + 0, + "forward-button" + ); + + // Force layout flush to ensure the drag completes as expected + urlbarContainer.clientWidth; + + simulateItemDrag(forwardButton, urlbarContainer, "start"); + assertAreaPlacements(CustomizableUI.AREA_NAVBAR, placementsAfterDrag); + ok(!CustomizableUI.inDefaultState, "Should no longer be in default state."); + let palette = document.getElementById("customization-palette"); + simulateItemDrag(forwardButton, palette); + is( + CustomizableUI.getPlacementOfWidget("forward-button").area, + CustomizableUI.AREA_NAVBAR, + "forward-button was not able to move to palette" + ); + + await endCustomizing(); + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "Should be in default state again."); +}); diff --git a/browser/components/customizableui/test/browser_backfwd_enabled_post_customize.js b/browser/components/customizableui/test/browser_backfwd_enabled_post_customize.js new file mode 100644 index 0000000000..93d88c7c55 --- /dev/null +++ b/browser/components/customizableui/test/browser_backfwd_enabled_post_customize.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://example.com" +); + +/** + * Back/fwd buttons should be re-enabled after customizing. + */ +add_task(async function test_back_forward_buttons() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.navigation.requireUserInteraction", false]], + }); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PATH); + let loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + "data:text/html,A separate page" + ); + await loaded; + loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + "data:text/html,Another separate page" + ); + await loaded; + gBrowser.goBack(); + await BrowserTestUtils.waitForCondition(() => gBrowser.canGoForward); + + let backButton = document.getElementById("back-button"); + let forwardButton = document.getElementById("forward-button"); + + await BrowserTestUtils.waitForCondition( + () => + !backButton.hasAttribute("disabled") && + !forwardButton.hasAttribute("disabled") + ); + + ok(!backButton.hasAttribute("disabled"), "Back button shouldn't be disabled"); + ok( + !forwardButton.hasAttribute("disabled"), + "Forward button shouldn't be disabled" + ); + await startCustomizing(); + + is( + backButton.getAttribute("disabled"), + "true", + "Back button should be disabled in customize mode" + ); + is( + forwardButton.getAttribute("disabled"), + "true", + "Forward button should be disabled in customize mode" + ); + + await endCustomizing(); + + await BrowserTestUtils.waitForCondition( + () => + !backButton.hasAttribute("disabled") && + !forwardButton.hasAttribute("disabled") + ); + + ok( + !backButton.hasAttribute("disabled"), + "Back button shouldn't be disabled after customize mode" + ); + ok( + !forwardButton.hasAttribute("disabled"), + "Forward button shouldn't be disabled after customize mode" + ); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/customizableui/test/browser_bookmarks_empty_message.js b/browser/components/customizableui/test/browser_bookmarks_empty_message.js new file mode 100644 index 0000000000..f4497cefdb --- /dev/null +++ b/browser/components/customizableui/test/browser_bookmarks_empty_message.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +async function emptyToolbarMessageVisible(visible, win = window) { + info("Empty toolbar message should be " + (visible ? "visible" : "hidden")); + let emptyMessage = win.document.getElementById("personal-toolbar-empty"); + await BrowserTestUtils.waitForMutationCondition( + emptyMessage, + { attributes: true, attributeFilter: ["hidden"] }, + () => emptyMessage.hidden != visible + ); +} + +add_task(async function empty_message_on_non_empty_bookmarks_toolbar() { + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "Default state to begin"); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.toolbars.bookmarks.visibility", "always"]], + }); + + CustomizableUI.removeWidgetFromArea("import-button"); + CustomizableUI.removeWidgetFromArea("personal-bookmarks"); + CustomizableUI.addWidgetToArea( + "bookmarks-menu-button", + CustomizableUI.AREA_BOOKMARKS, + 0 + ); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + let doc = newWin.document; + ok( + BrowserTestUtils.isVisible(doc.getElementById("PersonalToolbar")), + "Personal toolbar should be visible" + ); + ok( + doc.getElementById("personal-toolbar-empty").hidden, + "Empty message should be hidden" + ); + + await BrowserTestUtils.closeWindow(newWin); + await resetCustomization(); +}); + +add_task(async function empty_message_after_customization() { + // ensure There's something on the toolbar. + let bm = await PlacesUtils.bookmarks.insert({ + url: "https://mozilla.org/", + title: "test", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + registerCleanupFunction(() => PlacesUtils.bookmarks.remove(bm)); + ok(CustomizableUI.inDefaultState, "Default state to begin"); + + // Open window with a visible toolbar. + await SpecialPowers.pushPrefEnv({ + set: [["browser.toolbars.bookmarks.visibility", "always"]], + }); + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + let doc = newWin.document; + let toolbar = doc.getElementById("PersonalToolbar"); + ok(BrowserTestUtils.isVisible(toolbar), "Personal toolbar should be visible"); + await emptyToolbarMessageVisible(false, newWin); + + // Force a Places view uninit through customization. + CustomizableUI.removeWidgetFromArea("personal-bookmarks"); + await resetCustomization(); + // Show the toolbar again. + setToolbarVisibility(toolbar, true, false, false); + ok(BrowserTestUtils.isVisible(toolbar), "Personal toolbar should be visible"); + // Wait for bookmarks to be visible. + let placesItems = doc.getElementById("PlacesToolbarItems"); + await BrowserTestUtils.waitForMutationCondition( + placesItems, + { childList: true }, + () => placesItems.childNodes.length + ); + await emptyToolbarMessageVisible(false, newWin); + + await BrowserTestUtils.closeWindow(newWin); +}); diff --git a/browser/components/customizableui/test/browser_bookmarks_toolbar_collapsed_restore_default.js b/browser/components/customizableui/test/browser_bookmarks_toolbar_collapsed_restore_default.js new file mode 100644 index 0000000000..84ddc37d29 --- /dev/null +++ b/browser/components/customizableui/test/browser_bookmarks_toolbar_collapsed_restore_default.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +// Restoring default should set Bookmarks Toolbar back to "newtab" +add_task(async function () { + let prefName = "browser.toolbars.bookmarks.visibility"; + let toolbar = document.querySelector("#PersonalToolbar"); + for (let state of ["always", "never"]) { + info(`Testing setting toolbar state to '${state}'`); + + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "Default state to begin"); + + setToolbarVisibility(toolbar, state, true, false); + + is( + Services.prefs.getCharPref(prefName), + state, + "Pref updated to: " + state + ); + ok(!CustomizableUI.inDefaultState, "Not in default state"); + + await resetCustomization(); + + ok(CustomizableUI.inDefaultState, "Back in default state after reset"); + is( + Services.prefs.getCharPref(prefName), + "newtab", + "Pref should get reset to 'newtab'" + ); + } +}); diff --git a/browser/components/customizableui/test/browser_bookmarks_toolbar_shown_newtab.js b/browser/components/customizableui/test/browser_bookmarks_toolbar_shown_newtab.js new file mode 100644 index 0000000000..38f385e38c --- /dev/null +++ b/browser/components/customizableui/test/browser_bookmarks_toolbar_shown_newtab.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +// Entering customize mode should show the toolbar as long as it's not set to "never" +add_task(async function () { + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "Default state to begin"); + + let toolbar = document.querySelector("#PersonalToolbar"); + for (let state of ["always", "never", "newtab"]) { + info(`Testing setting toolbar state to '${state}'`); + + setToolbarVisibility(toolbar, state, true, false); + + await startCustomizing(); + + let expected = state != "never"; + await TestUtils.waitForCondition( + () => !toolbar.collapsed == expected, + `Waiting for toolbar visibility, state=${state}, visible=${!toolbar.collapsed}, expected=${expected}` + ); + is( + !toolbar.collapsed, + expected, + "The toolbar should be visible when state isn't 'never'" + ); + + await endCustomizing(); + } + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_bootstrapped_custom_toolbar.js b/browser/components/customizableui/test/browser_bootstrapped_custom_toolbar.js new file mode 100644 index 0000000000..67325b7b36 --- /dev/null +++ b/browser/components/customizableui/test/browser_bootstrapped_custom_toolbar.js @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +requestLongerTimeout(2); + +const kTestBarID = "testBar"; +const kWidgetID = "characterencoding-button"; + +function createTestBar() { + let testBar = document.createXULElement("toolbar"); + testBar.id = kTestBarID; + testBar.setAttribute("customizable", "true"); + CustomizableUI.registerArea(kTestBarID, { + type: CustomizableUI.TYPE_TOOLBAR, + }); + gNavToolbox.appendChild(testBar); + CustomizableUI.registerToolbarNode(testBar); + return testBar; +} + +/** + * Helper function that does the following: + * + * 1) Creates a custom toolbar and registers it + * with CustomizableUI. + * 2) Adds the widget with ID aWidgetID to that new + * toolbar. + * 3) Enters customize mode and makes sure that the + * widget is still in the right toolbar. + * 4) Exits customize mode, then removes and deregisters + * the custom toolbar. + * 5) Checks that the widget has no placement. + * 6) Re-adds and re-registers a custom toolbar with the same + * ID and options as the first one. + * 7) Enters customize mode and checks that the widget is + * properly back in the toolbar. + * 8) Exits customize mode, removes and de-registers the + * toolbar, and resets the toolbars to default. + */ +function checkRestoredPresence(aWidgetID) { + return (async function () { + let testBar = createTestBar(); + CustomizableUI.addWidgetToArea(aWidgetID, kTestBarID); + let placement = CustomizableUI.getPlacementOfWidget(aWidgetID); + is( + placement.area, + kTestBarID, + "Expected " + aWidgetID + " to be in the test toolbar" + ); + + CustomizableUI.unregisterArea(testBar.id); + testBar.remove(); + + placement = CustomizableUI.getPlacementOfWidget(aWidgetID); + is(placement, null, "Expected " + aWidgetID + " to be in the palette"); + + testBar = createTestBar(); + + await startCustomizing(); + placement = CustomizableUI.getPlacementOfWidget(aWidgetID); + is( + placement.area, + kTestBarID, + "Expected " + aWidgetID + " to be in the test toolbar" + ); + await endCustomizing(); + + CustomizableUI.unregisterArea(testBar.id); + testBar.remove(); + + await resetCustomization(); + })(); +} + +add_task(async function () { + await checkRestoredPresence("downloads-button"); + await checkRestoredPresence("characterencoding-button"); +}); diff --git a/browser/components/customizableui/test/browser_check_tooltips_in_navbar.js b/browser/components/customizableui/test/browser_check_tooltips_in_navbar.js new file mode 100644 index 0000000000..f36b55032d --- /dev/null +++ b/browser/components/customizableui/test/browser_check_tooltips_in_navbar.js @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function check_tooltips_in_navbar() { + await startCustomizing(); + let homeButtonWrapper = document.getElementById("wrapper-home-button"); + let homeButton = document.getElementById("home-button"); + is( + homeButtonWrapper.getAttribute("tooltiptext"), + homeButton.getAttribute("label"), + "the wrapper's tooltip should match the button's label" + ); + ok( + homeButtonWrapper.getAttribute("tooltiptext"), + "the button should have tooltip text" + ); + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_create_button_widget.js b/browser/components/customizableui/test/browser_create_button_widget.js new file mode 100644 index 0000000000..3e90011453 --- /dev/null +++ b/browser/components/customizableui/test/browser_create_button_widget.js @@ -0,0 +1,90 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kButton = "test_dynamically_created_button"; +var initialLocation = gBrowser.currentURI.spec; + +add_task(async function () { + info("Check dynamically created button functionality"); + + // Let's create a simple button that will open about:addons. + let widgetSpec = { + id: kButton, + type: "button", + tooltiptext: "I am an accessible name", + onClick() { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:addons"); + }, + }; + CustomizableUI.createWidget(widgetSpec); + CustomizableUI.addWidgetToArea(kButton, CustomizableUI.AREA_NAVBAR); + ok( + !CustomizableUI.isWebExtensionWidget(kButton), + "This button should not be considered an extension widget." + ); + + // check the button's functionality in navigation bar + let button = document.getElementById(kButton); + let navBar = document.getElementById("nav-bar"); + ok(button, "Dynamically created button exists"); + ok(navBar.contains(button), "Dynamically created button is in the navbar"); + await checkButtonFunctionality(button); + + resetTabs(); + + // move the add-on button in the Panel Menu + CustomizableUI.addWidgetToArea( + kButton, + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + ok( + !navBar.contains(button), + "Dynamically created button was removed from the browser bar" + ); + + await waitForOverflowButtonShown(); + + // check the button's functionality in the Overflow Panel. + await document.getElementById("nav-bar").overflowable.show(); + var panelMenu = document.getElementById("widget-overflow-mainView"); + let buttonInPanel = panelMenu.getElementsByAttribute("id", kButton); + ok( + panelMenu.contains(button), + "Dynamically created button was added to the Panel Menu" + ); + await checkButtonFunctionality(buttonInPanel[0]); +}); + +add_task(async function asyncCleanup() { + resetTabs(); + + // reset the UI to the default state + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "The UI is in default state again."); + + // destroy the widget + CustomizableUI.destroyWidget(kButton); +}); + +function resetTabs() { + // close all opened tabs + while (gBrowser.tabs.length > 1) { + gBrowser.removeTab(gBrowser.selectedTab); + } + + // restore the initial tab + BrowserTestUtils.addTab(gBrowser, initialLocation); + gBrowser.removeTab(gBrowser.selectedTab); +} + +async function checkButtonFunctionality(aButton) { + aButton.click(); + await BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + "about:addons" + ); +} diff --git a/browser/components/customizableui/test/browser_ctrl_click_panel_opening.js b/browser/components/customizableui/test/browser_ctrl_click_panel_opening.js new file mode 100644 index 0000000000..9377c28950 --- /dev/null +++ b/browser/components/customizableui/test/browser_ctrl_click_panel_opening.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_appMenu_mainView() { + // On macOS, ctrl-click shouldn't open the panel because this normally opens + // the context menu. This happens via the `contextmenu` event which is created + // by widget code, so our simulated clicks do not do so, so we can't test + // anything on macOS: + if (AppConstants.platform == "macosx") { + ok(true, "The test is ignored on Mac"); + return; + } + + let mainViewID = "appMenu-protonMainView"; + const mainView = document.getElementById(mainViewID); + + let shownPromise = BrowserTestUtils.waitForEvent(mainView, "ViewShown"); + // Should still open the panel when Ctrl key is pressed. + EventUtils.synthesizeMouseAtCenter(PanelUI.menuButton, { ctrlKey: true }); + await shownPromise; + ok(true, "Main menu shown after button pressed"); + + // Close the main panel. + let hiddenPromise = BrowserTestUtils.waitForEvent(document, "popuphidden"); + mainView.closest("panel").hidePopup(); + await hiddenPromise; +}); + +add_task(async function test_appMenu_libraryView() { + // On macOS, ctrl-click shouldn't open the panel because this normally opens + // the context menu. This happens via the `contextmenu` event which is created + // by widget code, so our simulated clicks do not do so, so we can't test + // anything on macOS: + if (AppConstants.platform == "macosx") { + ok(true, "The test is ignored on Mac"); + return; + } + + CustomizableUI.addWidgetToArea("library-button", "nav-bar"); + const button = document.getElementById("library-button"); + await waitForElementShown(button); + + // Should still open the panel when Ctrl key is pressed. + EventUtils.synthesizeMouseAtCenter(button, { ctrlKey: true }); + const libraryView = document.getElementById("appMenu-libraryView"); + let shownPromise = BrowserTestUtils.waitForEvent(libraryView, "ViewShown"); + await shownPromise; + ok(true, "Library menu shown after button pressed"); + + // Close the Library panel. + let hiddenPromise = BrowserTestUtils.waitForEvent(document, "popuphidden"); + libraryView.closest("panel").hidePopup(); + await hiddenPromise; +}); diff --git a/browser/components/customizableui/test/browser_currentset_post_reset.js b/browser/components/customizableui/test/browser_currentset_post_reset.js new file mode 100644 index 0000000000..31bcd150b1 --- /dev/null +++ b/browser/components/customizableui/test/browser_currentset_post_reset.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function checkSpacers() { + let navbarWidgets = CustomizableUI.getWidgetIdsInArea("nav-bar"); + let currentSetWidgets = CustomizableUI.getTestOnlyInternalProp( + "CustomizableUIInternal" + )._getCurrentWidgetsInContainer(document.getElementById("nav-bar")); + navbarWidgets = navbarWidgets.filter(w => CustomizableUI.isSpecialWidget(w)); + currentSetWidgets = currentSetWidgets.filter(w => + CustomizableUI.isSpecialWidget(w) + ); + Assert.deepEqual( + navbarWidgets, + currentSetWidgets, + "Should have the same 'special' widgets in currentset and placements" + ); +} + +/** + * Check that after a reset, CUI's internal bookkeeping correctly deals with flexible spacers. + */ +add_task(async function () { + await startCustomizing(); + checkSpacers(); + + CustomizableUI.addWidgetToArea( + "spring", + "nav-bar", + 4 /* Insert before the last extant spacer */ + ); + await gCustomizeMode.reset(); + checkSpacers(); + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_customization_context_menus.js b/browser/components/customizableui/test/browser_customization_context_menus.js new file mode 100644 index 0000000000..526b3abd1b --- /dev/null +++ b/browser/components/customizableui/test/browser_customization_context_menus.js @@ -0,0 +1,632 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +requestLongerTimeout(2); + +const isOSX = Services.appinfo.OS === "Darwin"; + +const overflowButton = document.getElementById("nav-bar-overflow-button"); +const overflowPanel = document.getElementById("widget-overflow"); + +// Right-click on the stop/reload button should +// show a context menu with options to move it. +add_task(async function home_button_context() { + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + let stopReloadButton = document.getElementById("stop-reload-button"); + EventUtils.synthesizeMouse(stopReloadButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToPanel", true], + [".customize-context-removeFromToolbar", true], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", true] + ); + checkContextMenu(contextMenu, expectedEntries); + + let hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; +}); + +// Right-click on an empty bit of tabstrip should +// show a context menu without options to move it, +// but with tab-specific options instead. +add_task(async function tabstrip_context() { + // ensure there are tabs to reload/bookmark: + let extraTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + let tabstrip = document.getElementById("tabbrowser-tabs"); + let rect = tabstrip.getBoundingClientRect(); + EventUtils.synthesizeMouse(tabstrip, rect.width - 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let closedTabsAvailable = SessionStore.getClosedTabCount() == 0; + info("Closed tabs: " + closedTabsAvailable); + let expectedEntries = [ + ["#toolbar-context-openANewTab", true], + ["---"], + ["#toolbar-context-reloadSelectedTab", true], + ["#toolbar-context-bookmarkSelectedTab", true], + ["#toolbar-context-selectAllTabs", true], + ["#toolbar-context-undoCloseTab", !closedTabsAvailable], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", true] + ); + checkContextMenu(contextMenu, expectedEntries); + + let hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; + BrowserTestUtils.removeTab(extraTab); +}); + +// Right-click on the title bar spacer before the tabstrip should show a +// context menu without options to move it and no tab-specific options. +add_task(async function titlebar_spacer_context() { + if (!TabsInTitlebar.enabled) { + info("Skipping test that requires tabs in the title bar."); + return; + } + + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + let spacer = document.querySelector( + "#TabsToolbar .titlebar-spacer[type='pre-tabs']" + ); + EventUtils.synthesizeMouseAtCenter(spacer, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToPanel", false], + [".customize-context-removeFromToolbar", false], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", true] + ); + checkContextMenu(contextMenu, expectedEntries); + + let hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; +}); + +// Right-click on an empty bit of extra toolbar should +// show a context menu with moving options disabled, +// and a toggle option for the extra toolbar +add_task(async function empty_toolbar_context() { + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + let toolbar = createToolbarWithPlacements("880164_empty_toolbar", []); + toolbar.setAttribute("context", "toolbar-context-menu"); + toolbar.setAttribute("toolbarname", "Fancy Toolbar for Context Menu"); + EventUtils.synthesizeMouseAtCenter(toolbar, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToPanel", false], + [".customize-context-removeFromToolbar", false], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["#toggle_880164_empty_toolbar", true], + ["---"], + [".viewCustomizeToolbar", true] + ); + checkContextMenu(contextMenu, expectedEntries); + + let hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; + removeCustomToolbars(); +}); + +// Right-click on the urlbar-container should +// show a context menu with disabled options to move it. +add_task(async function urlbar_context() { + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + let urlBarContainer = document.getElementById("urlbar-container"); + // Need to make sure not to click within an edit field. + EventUtils.synthesizeMouse(urlBarContainer, 100, 1, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToPanel", false], + [".customize-context-removeFromToolbar", false], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", true] + ); + checkContextMenu(contextMenu, expectedEntries); + + let hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; +}); + +// Right-click on the searchbar and moving it to the menu +// and back should move the search-container instead. +add_task(async function searchbar_context_move_to_panel_and_back() { + // This is specifically testing the addToPanel function for the search bar, so + // we have to move it to its correct position in the navigation toolbar first. + // The preference will be restored when the customizations are reset later. + Services.prefs.setBoolPref("browser.search.widget.inNavBar", true); + + let searchbar = document.getElementById("searchbar"); + // This fails if the screen resolution is small and the search bar overflows + // from the nav bar. + await gCustomizeMode.addToPanel(searchbar); + let placement = CustomizableUI.getPlacementOfWidget("search-container"); + is( + placement.area, + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, + "Should be in panel" + ); + + await waitForOverflowButtonShown(); + + let shownPanelPromise = popupShown(overflowPanel); + overflowButton.click(); + await shownPanelPromise; + let hiddenPanelPromise = popupHidden(overflowPanel); + overflowPanel.hidePopup(); + await hiddenPanelPromise; + + gCustomizeMode.addToToolbar(searchbar); + placement = CustomizableUI.getPlacementOfWidget("search-container"); + is(placement.area, CustomizableUI.AREA_NAVBAR, "Should be in navbar"); + await gCustomizeMode.removeFromArea(searchbar); + placement = CustomizableUI.getPlacementOfWidget("search-container"); + is(placement, null, "Should be in palette"); + CustomizableUI.reset(); + placement = CustomizableUI.getPlacementOfWidget("search-container"); + is(placement, null, "Should be in palette"); +}); + +// Right-click on an item within the panel should +// show a context menu with options to move it. +add_task(async function context_within_panel() { + CustomizableUI.addWidgetToArea( + "new-window-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + await waitForOverflowButtonShown(); + + let shownPanelPromise = popupShown(overflowPanel); + overflowButton.click(); + await shownPanelPromise; + + let contextMenu = document.getElementById( + "customizationPanelItemContextMenu" + ); + let shownContextPromise = popupShown(contextMenu); + let newWindowButton = document.getElementById("new-window-button"); + ok(newWindowButton, "new-window-button was found"); + EventUtils.synthesizeMouse(newWindowButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownContextPromise; + + is(overflowPanel.state, "open", "The overflow panel should still be open."); + + let expectedEntries = [ + [".customize-context-moveToToolbar", true], + [".customize-context-removeFromPanel", true], + ["---"], + [".viewCustomizeToolbar", true], + ]; + checkContextMenu(contextMenu, expectedEntries); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; + + let hiddenPromise = popupHidden(overflowPanel); + overflowPanel.hidePopup(); + await hiddenPromise; + + CustomizableUI.removeWidgetFromArea("new-window-button"); +}); + +// Right-click on the stop/reload button while in customization mode +// should show a context menu with options to move it. +add_task(async function context_home_button_in_customize_mode() { + await startCustomizing(); + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + let stopReloadButton = document.getElementById("wrapper-stop-reload-button"); + EventUtils.synthesizeMouse(stopReloadButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToPanel", true], + [".customize-context-removeFromToolbar", true], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", false] + ); + checkContextMenu(contextMenu, expectedEntries); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; +}); + +// Right-click on an item in the palette should +// show a context menu with options to move it. +add_task(async function context_click_in_palette() { + let contextMenu = document.getElementById( + "customizationPaletteItemContextMenu" + ); + let shownPromise = popupShown(contextMenu); + let openFileButton = document.getElementById("wrapper-open-file-button"); + EventUtils.synthesizeMouse(openFileButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-addToToolbar", true], + [".customize-context-addToPanel", true], + ]; + checkContextMenu(contextMenu, expectedEntries); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; +}); + +// Right-click on an item in the panel while in customization mode +// should show a context menu with options to move it. +add_task(async function context_click_in_customize_mode() { + CustomizableUI.addWidgetToArea( + "new-window-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + let contextMenu = document.getElementById( + "customizationPanelItemContextMenu" + ); + let shownPromise = popupShown(contextMenu); + let newWindowButton = document.getElementById("wrapper-new-window-button"); + EventUtils.synthesizeMouse(newWindowButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToToolbar", true], + [".customize-context-removeFromPanel", true], + ["---"], + [".viewCustomizeToolbar", false], + ]; + checkContextMenu(contextMenu, expectedEntries); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; + CustomizableUI.removeWidgetFromArea("new-window-button"); + await endCustomizing(); +}); + +// Test the toolbarbutton panel context menu in customization mode +// without opening the panel before customization mode +add_task(async function context_click_customize_mode_panel_not_opened() { + CustomizableUI.addWidgetToArea( + "new-window-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + this.otherWin = await openAndLoadWindow(null, true); + + await new Promise(resolve => waitForFocus(resolve, this.otherWin)); + + await startCustomizing(this.otherWin); + + let contextMenu = this.otherWin.document.getElementById( + "customizationPanelItemContextMenu" + ); + let shownPromise = popupShown(contextMenu); + let newWindowButton = this.otherWin.document.getElementById( + "wrapper-new-window-button" + ); + EventUtils.synthesizeMouse( + newWindowButton, + 2, + 2, + { type: "contextmenu", button: 2 }, + this.otherWin + ); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToToolbar", true], + [".customize-context-removeFromPanel", true], + ["---"], + [".viewCustomizeToolbar", false], + ]; + checkContextMenu(contextMenu, expectedEntries, this.otherWin); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; + await endCustomizing(this.otherWin); + CustomizableUI.removeWidgetFromArea("new-window-button"); + await promiseWindowClosed(this.otherWin); + this.otherWin = null; + + await new Promise(resolve => waitForFocus(resolve, window)); +}); + +// Bug 945191 - Combined buttons show wrong context menu options +// when they are in the toolbar. +add_task(async function context_combined_buttons_toolbar() { + CustomizableUI.addWidgetToArea( + "zoom-controls", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + await startCustomizing(); + let contextMenu = document.getElementById( + "customizationPanelItemContextMenu" + ); + let shownPromise = popupShown(contextMenu); + let zoomControls = document.getElementById("wrapper-zoom-controls"); + EventUtils.synthesizeMouse(zoomControls, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + // Execute the command to move the item from the panel to the toolbar. + let moveToToolbar = contextMenu.querySelector( + ".customize-context-moveToToolbar" + ); + moveToToolbar.doCommand(); + let hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; + await endCustomizing(); + + zoomControls = document.getElementById("zoom-controls"); + is( + zoomControls.parentNode.id, + "nav-bar-customization-target", + "Zoom-controls should be on the nav-bar" + ); + + contextMenu = document.getElementById("toolbar-context-menu"); + shownPromise = popupShown(contextMenu); + EventUtils.synthesizeMouse(zoomControls, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToPanel", true], + [".customize-context-removeFromToolbar", true], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", true] + ); + checkContextMenu(contextMenu, expectedEntries); + + hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; + await resetCustomization(); +}); + +// Bug 947586 - After customization, panel items show wrong context menu options +add_task(async function context_after_customization_panel() { + info("Check panel context menu is correct after customization"); + await startCustomizing(); + await endCustomizing(); + + CustomizableUI.addWidgetToArea( + "new-window-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + let shownPanelPromise = popupShown(overflowPanel); + overflowButton.click(); + await shownPanelPromise; + + let contextMenu = document.getElementById( + "customizationPanelItemContextMenu" + ); + let shownContextPromise = popupShown(contextMenu); + let newWindowButton = document.getElementById("new-window-button"); + ok(newWindowButton, "new-window-button was found"); + EventUtils.synthesizeMouse(newWindowButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownContextPromise; + + is(overflowPanel.state, "open", "The panel should still be open."); + + let expectedEntries = [ + [".customize-context-moveToToolbar", true], + [".customize-context-removeFromPanel", true], + ["---"], + [".viewCustomizeToolbar", true], + ]; + checkContextMenu(contextMenu, expectedEntries); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; + + let hiddenPromise = popupHidden(overflowPanel); + overflowPanel.hidePopup(); + await hiddenPromise; + CustomizableUI.removeWidgetFromArea("new-window-button"); +}); + +// Bug 982027 - moving icon around removes custom context menu. +add_task(async function custom_context_menus() { + let widgetId = "custom-context-menu-toolbarbutton"; + let expectedContext = "myfancycontext"; + let widget = createDummyXULButton(widgetId, "Test ctxt menu"); + widget.setAttribute("context", expectedContext); + CustomizableUI.addWidgetToArea(widgetId, CustomizableUI.AREA_NAVBAR); + is( + widget.getAttribute("context"), + expectedContext, + "Should have context menu when added to the toolbar." + ); + + await startCustomizing(); + is( + widget.getAttribute("context"), + "", + "Should not have own context menu in the toolbar now that we're customizing." + ); + is( + widget.getAttribute("wrapped-context"), + expectedContext, + "Should keep own context menu wrapped when in toolbar." + ); + + let panel = document.getElementById("widget-overflow-fixed-list"); + simulateItemDrag(widget, panel); + is( + widget.getAttribute("context"), + "", + "Should not have own context menu when in the panel." + ); + is( + widget.getAttribute("wrapped-context"), + expectedContext, + "Should keep own context menu wrapped now that we're in the panel." + ); + + simulateItemDrag( + widget, + CustomizableUI.getCustomizationTarget(document.getElementById("nav-bar")) + ); + is( + widget.getAttribute("context"), + "", + "Should not have own context menu when back in toolbar because we're still customizing." + ); + is( + widget.getAttribute("wrapped-context"), + expectedContext, + "Should keep own context menu wrapped now that we're back in the toolbar." + ); + + await endCustomizing(); + is( + widget.getAttribute("context"), + expectedContext, + "Should have context menu again now that we're out of customize mode." + ); + CustomizableUI.removeWidgetFromArea(widgetId); + widget.remove(); + ok( + CustomizableUI.inDefaultState, + "Should be in default state after removing button." + ); +}); + +// Bug 1690575 - 'pin to overflow menu' and 'remove from toolbar' should be hidden +// for flexible spaces +add_task(async function flexible_space_context_menu() { + CustomizableUI.addWidgetToArea("spring", "nav-bar"); + let springs = document.querySelectorAll("#nav-bar toolbarspring"); + let lastSpring = springs[springs.length - 1]; + ok(lastSpring, "we added a spring"); + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + EventUtils.synthesizeMouse(lastSpring, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", true], + ]; + + if (!isOSX) { + expectedEntries.unshift(["#toggle_toolbar-menubar", true]); + } + + checkContextMenu(contextMenu, expectedEntries); + contextMenu.hidePopup(); + gCustomizeMode.removeFromArea(lastSpring); + ok(!lastSpring.parentNode, "Spring should have been removed successfully."); +}); diff --git a/browser/components/customizableui/test/browser_customizemode_contextmenu_menubuttonstate.js b/browser/components/customizableui/test/browser_customizemode_contextmenu_menubuttonstate.js new file mode 100644 index 0000000000..78b621054c --- /dev/null +++ b/browser/components/customizableui/test/browser_customizemode_contextmenu_menubuttonstate.js @@ -0,0 +1,71 @@ +"use strict"; + +add_task(async function () { + ok( + !PanelUI.menuButton.hasAttribute("open"), + "Menu button should not be 'pressed' outside customize mode" + ); + ok( + !PanelUI.menuButton.hasAttribute("disabled"), + "Menu button should not be disabled outside of customize mode" + ); + await startCustomizing(); + + ok( + !PanelUI.menuButton.hasAttribute("open"), + "Menu button should still not be 'pressed' when in customize mode" + ); + is( + PanelUI.menuButton.getAttribute("disabled"), + "true", + "Menu button should be disabled in customize mode" + ); + + let contextMenu = document.getElementById( + "customizationPaletteItemContextMenu" + ); + let shownPromise = popupShown(contextMenu); + let newWindowButton = document.getElementById("wrapper-new-window-button"); + EventUtils.synthesizeMouse(newWindowButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + ok( + !PanelUI.menuButton.hasAttribute("open"), + "Menu button should still not be 'pressed' when in customize mode after opening a context menu" + ); + is( + PanelUI.menuButton.getAttribute("disabled"), + "true", + "Menu button should still be disabled in customize mode" + ); + is( + PanelUI.menuButton.getAttribute("disabled"), + "true", + "Menu button should still be disabled in customize mode after opening context menu" + ); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; + ok( + !PanelUI.menuButton.hasAttribute("open"), + "Menu button should still not be 'pressed' when in customize mode after hiding a context menu" + ); + is( + PanelUI.menuButton.getAttribute("disabled"), + "true", + "Menu button should still be disabled in customize mode after hiding context menu" + ); + await endCustomizing(); + + ok( + !PanelUI.menuButton.hasAttribute("open"), + "Menu button should not be 'pressed' after ending customize mode" + ); + ok( + !PanelUI.menuButton.hasAttribute("disabled"), + "Menu button should not be disabled after ending customize mode" + ); +}); diff --git a/browser/components/customizableui/test/browser_customizemode_lwthemes.js b/browser/components/customizableui/test/browser_customizemode_lwthemes.js new file mode 100644 index 0000000000..3b19566ac0 --- /dev/null +++ b/browser/components/customizableui/test/browser_customizemode_lwthemes.js @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function () { + await startCustomizing(); + // Find the footer buttons to test. + let manageLink = document.querySelector("#customization-lwtheme-link"); + + let waitForNewTab = BrowserTestUtils.waitForNewTab(gBrowser, "about:addons"); + manageLink.click(); + let addonsTab = await waitForNewTab; + + is(gBrowser.currentURI.spec, "about:addons", "Manage opened about:addons"); + BrowserTestUtils.removeTab(addonsTab); + + // Wait for customize mode to be re-entered now that the customize tab is + // active. This is needed for endCustomizing() to work properly. + await TestUtils.waitForCondition( + () => document.documentElement.getAttribute("customizing") == "true" + ); + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_customizemode_uidensity.js b/browser/components/customizableui/test/browser_customizemode_uidensity.js new file mode 100644 index 0000000000..9f22341f56 --- /dev/null +++ b/browser/components/customizableui/test/browser_customizemode_uidensity.js @@ -0,0 +1,230 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const PREF_UI_DENSITY = "browser.uidensity"; +const PREF_AUTO_TOUCH_MODE = "browser.touchmode.auto"; + +async function testModeMenuitem(mode, modePref) { + await startCustomizing(); + + let win = document.getElementById("main-window"); + let popupButton = document.getElementById("customization-uidensity-button"); + let popup = document.getElementById("customization-uidensity-menu"); + + // Show the popup. + let popupShownPromise = popupShown(popup); + EventUtils.synthesizeMouseAtCenter(popupButton, {}); + await popupShownPromise; + + let item = document.getElementById( + "customization-uidensity-menuitem-" + mode + ); + let normalItem = document.getElementById( + "customization-uidensity-menuitem-normal" + ); + + is( + normalItem.getAttribute("active"), + "true", + "Normal mode menuitem should be active by default" + ); + + // Hover over the mode menuitem and wait for the event that updates the UI + // density. + let mouseoverPromise = BrowserTestUtils.waitForEvent(item, "mouseover"); + EventUtils.synthesizeMouseAtCenter(item, { type: "mouseover" }); + await mouseoverPromise; + + is( + win.getAttribute("uidensity"), + mode, + `UI Density should be set to ${mode} on ${mode} menuitem hover` + ); + + is( + Services.prefs.getIntPref(PREF_UI_DENSITY), + window.gUIDensity.MODE_NORMAL, + `UI Density pref should still be set to normal on ${mode} menuitem hover` + ); + + // Hover the normal menuitem again and check that the UI density reset to normal. + EventUtils.synthesizeMouseAtCenter(normalItem, { type: "mouseover" }); + await BrowserTestUtils.waitForCondition(() => !win.hasAttribute("uidensity")); + + ok( + !win.hasAttribute("uidensity"), + `UI Density should be reset when no longer hovering the ${mode} menuitem` + ); + + // Select the custom UI density and wait for the popup to be hidden. + let popupHiddenPromise = popupHidden(popup); + EventUtils.synthesizeMouseAtCenter(item, {}); + await popupHiddenPromise; + + // Check that the click permanently changed the UI density. + is( + win.getAttribute("uidensity"), + mode, + `UI Density should be set to ${mode} on ${mode} menuitem click` + ); + is( + Services.prefs.getIntPref(PREF_UI_DENSITY), + modePref, + `UI Density pref should be set to ${mode} when clicking the ${mode} menuitem` + ); + + // Open the popup again. + popupShownPromise = popupShown(popup); + EventUtils.synthesizeMouseAtCenter(popupButton, {}); + await popupShownPromise; + + // Check that the menuitem is still active after opening and closing the popup. + is( + item.getAttribute("active"), + "true", + `${mode} mode menuitem should be active` + ); + + // Hide the popup again. + popupHiddenPromise = popupHidden(popup); + EventUtils.synthesizeMouseAtCenter(popupButton, {}); + await popupHiddenPromise; + + // Check that the menuitem is still active after re-opening customize mode. + await endCustomizing(); + await startCustomizing(); + + popupShownPromise = popupShown(popup); + EventUtils.synthesizeMouseAtCenter(popupButton, {}); + await popupShownPromise; + + is( + item.getAttribute("active"), + "true", + `${mode} mode menuitem should be active after entering and exiting customize mode` + ); + + // Click the normal menuitem and check that the density is reset. + popupHiddenPromise = popupHidden(popup); + EventUtils.synthesizeMouseAtCenter(normalItem, {}); + await popupHiddenPromise; + + ok( + !win.hasAttribute("uidensity"), + "UI Density should be reset when clicking the normal menuitem" + ); + + is( + Services.prefs.getIntPref(PREF_UI_DENSITY), + window.gUIDensity.MODE_NORMAL, + "UI Density pref should be set to normal." + ); + + // Show the popup and click on the mode menuitem again to test the + // reset default feature. + popupShownPromise = popupShown(popup); + EventUtils.synthesizeMouseAtCenter(popupButton, {}); + await popupShownPromise; + + popupHiddenPromise = popupHidden(popup); + EventUtils.synthesizeMouseAtCenter(item, {}); + await popupHiddenPromise; + + is( + win.getAttribute("uidensity"), + mode, + `UI Density should be set to ${mode} on ${mode} menuitem click` + ); + + is( + Services.prefs.getIntPref(PREF_UI_DENSITY), + modePref, + `UI Density pref should be set to ${mode} when clicking the ${mode} menuitem` + ); + + await gCustomizeMode.reset(); + + ok( + !win.hasAttribute("uidensity"), + "UI Density should be reset when clicking the normal menuitem" + ); + + is( + Services.prefs.getIntPref(PREF_UI_DENSITY), + window.gUIDensity.MODE_NORMAL, + "UI Density pref should be set to normal." + ); + + await endCustomizing(); +} + +add_task(async function test_touch_mode_menuitem() { + // OSX doesn't get touch mode for now. + if (AppConstants.platform == "macosx") { + is( + document.getElementById("customization-uidensity-menuitem-touch"), + null, + "There's no touch option on Mac OSX" + ); + return; + } + + await testModeMenuitem("touch", window.gUIDensity.MODE_TOUCH); + + // Test the checkbox for automatic Touch Mode transition + // in Windows Tablet Mode. + if (AppConstants.platform == "win") { + await startCustomizing(); + + let popupButton = document.getElementById("customization-uidensity-button"); + let popup = document.getElementById("customization-uidensity-menu"); + let popupShownPromise = popupShown(popup); + EventUtils.synthesizeMouseAtCenter(popupButton, {}); + await popupShownPromise; + + let checkbox = document.getElementById( + "customization-uidensity-autotouchmode-checkbox" + ); + ok(checkbox.checked, "Checkbox should be checked by default"); + + // Test toggling the checkbox. + EventUtils.synthesizeMouseAtCenter(checkbox, {}); + is( + Services.prefs.getBoolPref(PREF_AUTO_TOUCH_MODE), + false, + "Automatic Touch Mode is off when the checkbox is unchecked." + ); + + EventUtils.synthesizeMouseAtCenter(checkbox, {}); + is( + Services.prefs.getBoolPref(PREF_AUTO_TOUCH_MODE), + true, + "Automatic Touch Mode is on when the checkbox is checked." + ); + + // Test reset to defaults. + EventUtils.synthesizeMouseAtCenter(checkbox, {}); + is( + Services.prefs.getBoolPref(PREF_AUTO_TOUCH_MODE), + false, + "Automatic Touch Mode is off when the checkbox is unchecked." + ); + + await gCustomizeMode.reset(); + is( + Services.prefs.getBoolPref(PREF_AUTO_TOUCH_MODE), + true, + "Automatic Touch Mode is on when the checkbox is checked." + ); + } +}); + +add_task(async function cleanup() { + await endCustomizing(); + + Services.prefs.clearUserPref(PREF_UI_DENSITY); + Services.prefs.clearUserPref(PREF_AUTO_TOUCH_MODE); +}); diff --git a/browser/components/customizableui/test/browser_disable_commands_customize.js b/browser/components/customizableui/test/browser_disable_commands_customize.js new file mode 100644 index 0000000000..f3eb06efbe --- /dev/null +++ b/browser/components/customizableui/test/browser_disable_commands_customize.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Most commands don't make sense in customize mode. Check that they're + * disabled, so shortcuts can't activate them either. Also check that + * some basic commands (close tab/window, quit, new tab, new window) + * remain functional. + */ +add_task(async function test_disable_commands() { + let disabledCommands = ["cmd_print", "Browser:SavePage", "Browser:SendLink"]; + let enabledCommands = [ + "cmd_newNavigatorTab", + "cmd_newNavigator", + "cmd_quitApplication", + "cmd_close", + "cmd_closeWindow", + ]; + + function checkDisabled() { + for (let cmd of disabledCommands) { + is( + document.getElementById(cmd).getAttribute("disabled"), + "true", + `Command ${cmd} should be disabled` + ); + } + for (let cmd of enabledCommands) { + ok( + !document.getElementById(cmd).hasAttribute("disabled"), + `Command ${cmd} should NOT be disabled` + ); + } + } + await startCustomizing(); + + checkDisabled(); + + // Do a reset just for fun, making sure we don't accidentally + // break things: + await gCustomizeMode.reset(); + + checkDisabled(); + + await endCustomizing(); + for (let cmd of disabledCommands.concat(enabledCommands)) { + ok( + !document.getElementById(cmd).hasAttribute("disabled"), + `Command ${cmd} should NOT be disabled after customize mode` + ); + } +}); + +/** + * When buttons are connected to a command, they should not get + * disabled just because we move them. + */ +add_task(async function test_dont_disable_when_moving() { + let button = gNavToolbox.palette.querySelector("#print-button"); + ok(button.hasAttribute("command"), "Button should have a command attribute."); + await startCustomizing(); + CustomizableUI.addWidgetToArea("print-button", "nav-bar"); + await endCustomizing(); + ok( + !button.hasAttribute("disabled"), + "Should not have disabled attribute after adding the button." + ); + ok( + button.hasAttribute("command"), + "Button should still have a command attribute." + ); + + await startCustomizing(); + await gCustomizeMode.reset(); + await endCustomizing(); + ok( + !button.hasAttribute("disabled"), + "Should not have disabled attribute when resetting in customize mode" + ); + ok( + button.hasAttribute("command"), + "Button should still have a command attribute." + ); +}); diff --git a/browser/components/customizableui/test/browser_drag_outside_palette.js b/browser/components/customizableui/test/browser_drag_outside_palette.js new file mode 100644 index 0000000000..2785a08896 --- /dev/null +++ b/browser/components/customizableui/test/browser_drag_outside_palette.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that moving items from the toolbar or panel to the palette by + * dropping on the panel container (not inside the visible panel) works. + */ +add_task(async function () { + await startCustomizing(); + let panelContainer = document.getElementById("customization-panel-container"); + // Try dragging an item from the navbar: + let stopReloadButton = document.getElementById("stop-reload-button"); + let oldNavbarPlacements = CustomizableUI.getWidgetIdsInArea("nav-bar"); + simulateItemDrag(stopReloadButton, panelContainer); + assertAreaPlacements( + CustomizableUI.AREA_NAVBAR, + oldNavbarPlacements.filter(w => w != "stop-reload-button") + ); + ok( + stopReloadButton.closest("#customization-palette"), + "Button should be in the palette" + ); + + // Put it in the panel and try again from there: + let panelHolder = document.getElementById("customization-panelHolder"); + simulateItemDrag(stopReloadButton, panelHolder); + assertAreaPlacements(CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, [ + "stop-reload-button", + ]); + + simulateItemDrag(stopReloadButton, panelContainer); + assertAreaPlacements(CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, []); + + ok( + stopReloadButton.closest("#customization-palette"), + "Button should be in the palette" + ); + + // Check we can't move non-removable items like this: + let urlbar = document.getElementById("urlbar-container"); + simulateItemDrag(urlbar, panelContainer); + assertAreaPlacements( + CustomizableUI.AREA_NAVBAR, + oldNavbarPlacements.filter(w => w != "stop-reload-button") + ); +}); + +registerCleanupFunction(async function () { + await gCustomizeMode.reset(); + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_editcontrols_update.js b/browser/components/customizableui/test/browser_editcontrols_update.js new file mode 100644 index 0000000000..9f064e521a --- /dev/null +++ b/browser/components/customizableui/test/browser_editcontrols_update.js @@ -0,0 +1,307 @@ +// This test checks that the edit command enabled state (cut/paste) is updated +// properly when the edit controls are on the toolbar, popup and not present. +// It also verifies that the performance optimiation implemented by +// updateEditUIVisibility in browser.js is applied. + +let isMac = navigator.platform.indexOf("Mac") == 0; + +function checkState(allowCut, desc, testWindow = window) { + is( + testWindow.document.getElementById("cmd_cut").getAttribute("disabled") == + "true", + !allowCut, + desc + " - cut" + ); + is( + testWindow.document.getElementById("cmd_paste").getAttribute("disabled") == + "true", + false, + desc + " - paste" + ); +} + +// Add a special controller to the urlbar and browser to listen in on when +// commands are being updated. Return a promise that resolves when 'count' +// updates have occurred. +function expectCommandUpdate(count, testWindow = window) { + return new Promise((resolve, reject) => { + let overrideController = { + supportsCommand(cmd) { + return cmd == "cmd_delete"; + }, + isCommandEnabled(cmd) { + if (!count) { + ok(false, "unexpected update"); + reject(); + } + + if (!--count) { + testWindow.gURLBar.inputField.controllers.removeControllerAt( + 0, + overrideController + ); + testWindow.gBrowser.selectedBrowser.controllers.removeControllerAt( + 0, + overrideController + ); + resolve(true); + } + }, + }; + + if (!count) { + SimpleTest.executeSoon(() => { + testWindow.gURLBar.inputField.controllers.removeControllerAt( + 0, + overrideController + ); + testWindow.gBrowser.selectedBrowser.controllers.removeControllerAt( + 0, + overrideController + ); + resolve(false); + }); + } + + testWindow.gURLBar.inputField.controllers.insertControllerAt( + 0, + overrideController + ); + testWindow.gBrowser.selectedBrowser.controllers.insertControllerAt( + 0, + overrideController + ); + }); +} + +// Call this between `.select()` to make sure the selection actually changes +// and thus TextInputListener::UpdateTextInputCommands() is called. +function deselectURLBarAndSpin() { + gURLBar.inputField.setSelectionRange(0, 0); + return new Promise(setTimeout); +} + +add_task(async function test_init() { + // Put something on the clipboard to verify that the paste button is properly enabled during the test. + let clipboardHelper = Cc["@mozilla.org/widget/clipboardhelper;1"].getService( + Ci.nsIClipboardHelper + ); + await new Promise(resolve => { + SimpleTest.waitForClipboard( + "Sample", + function () { + clipboardHelper.copyString("Sample"); + }, + resolve + ); + }); + + // Open and close the panel first so that it is fully initialized. + await gCUITestUtils.openMainMenu(); + await gCUITestUtils.hideMainMenu(); +}); + +// Test updating when the panel is open with the edit-controls on the panel. +// Updates should occur. +add_task(async function test_panelui_opened() { + document.commandDispatcher.unlock(); + gURLBar.focus(); + gURLBar.value = "test"; + + await gCUITestUtils.openMainMenu(); + + checkState(false, "Update when edit-controls is on panel and visible"); + + let overridePromise = expectCommandUpdate(1); + gURLBar.select(); + await overridePromise; + + checkState( + true, + "Update when edit-controls is on panel and selection changed" + ); + + overridePromise = expectCommandUpdate(0); + await gCUITestUtils.hideMainMenu(); + await overridePromise; + + // Check that updates do not occur after the panel has been closed. + checkState(true, "Update when edit-controls is on panel and hidden"); + + // Mac will update the enabled state even when the panel is closed so that + // main menubar shortcuts will work properly. + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(isMac ? 1 : 0); + gURLBar.select(); + await overridePromise; + checkState( + true, + "Update when edit-controls is on panel, hidden and selection changed" + ); +}); + +// Test updating when the edit-controls are moved to the toolbar. +add_task(async function test_panelui_customize_to_toolbar() { + await startCustomizing(); + let navbar = document.getElementById("nav-bar"); + simulateItemDrag( + document.getElementById("edit-controls"), + CustomizableUI.getCustomizationTarget(navbar), + "end" + ); + await endCustomizing(); + + // updateEditUIVisibility should be called when customization ends but isn't. See bug 1359790. + updateEditUIVisibility(); + + // The URL bar may have been focused to begin with, which means + // that subsequent calls to focus it won't result in command + // updates, so we'll make sure to blur it. + gURLBar.blur(); + + let overridePromise = expectCommandUpdate(1); + gURLBar.select(); + gURLBar.focus(); + gURLBar.value = "other"; + await overridePromise; + checkState(false, "Update when edit-controls on toolbar and focused"); + + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(1); + gURLBar.select(); + await overridePromise; + checkState( + true, + "Update when edit-controls on toolbar and selection changed" + ); + + const kOverflowPanel = document.getElementById("widget-overflow"); + + let originalWidth = window.outerWidth; + registerCleanupFunction(async function () { + kOverflowPanel.removeAttribute("animate"); + window.resizeTo(originalWidth, window.outerHeight); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); + CustomizableUI.reset(); + }); + + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + await TestUtils.waitForCondition( + () => + navbar.hasAttribute("overflowing") && + !navbar.querySelector("edit-controls") + ); + + // Mac will update the enabled state even when the buttons are overflowing, + // so main menubar shortcuts will work properly. + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(isMac ? 1 : 0); + gURLBar.select(); + await overridePromise; + checkState( + true, + "Update when edit-controls is on overflow panel, hidden and selection changed" + ); + + // Check that we get an update if we select content while the panel is open. + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(1); + await navbar.overflowable.show(); + gURLBar.select(); + await overridePromise; + + // And that we don't (except on mac) when the panel is hidden. + kOverflowPanel.hidePopup(); + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(isMac ? 1 : 0); + gURLBar.select(); + await overridePromise; + + window.resizeTo(originalWidth, window.outerHeight); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); + + CustomizableUI.addWidgetToArea( + "edit-controls", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + // updateEditUIVisibility should be called when customization happens but isn't. See bug 1359790. + updateEditUIVisibility(); + + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(isMac ? 1 : 0); + gURLBar.select(); + await overridePromise; + + // Check that we get an update if we select content while the panel is open. + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(1); + await navbar.overflowable.show(); + gURLBar.select(); + await overridePromise; + + // And that we don't (except on mac) when the panel is hidden. + kOverflowPanel.hidePopup(); + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(isMac ? 1 : 0); + gURLBar.select(); + await overridePromise; +}); + +// Test updating when the edit-controls are moved to the palette. +add_task(async function test_panelui_customize_to_palette() { + await startCustomizing(); + let palette = document.getElementById("customization-palette"); + simulateItemDrag(document.getElementById("edit-controls"), palette); + await endCustomizing(); + + // updateEditUIVisibility should be called when customization ends but isn't. See bug 1359790. + updateEditUIVisibility(); + + let overridePromise = expectCommandUpdate(isMac ? 1 : 0); + gURLBar.focus(); + gURLBar.value = "other"; + gURLBar.select(); + await overridePromise; + + // If the UI isn't found, the command is set to be enabled. + checkState( + true, + "Update when edit-controls is on palette, hidden and selection changed" + ); +}); + +add_task(async function finish() { + await resetCustomization(); +}); + +// Test updating in the initial state when the edit-controls are on the panel but +// have not yet been created. This needs to be done in a new window to ensure that +// other tests haven't opened the panel. +add_task(async function test_initial_state() { + let testWindow = await BrowserTestUtils.openNewBrowserWindow(); + await SimpleTest.promiseFocus(testWindow); + + // For focusing the URL bar to have an effect, we need to ensure the URL bar isn't + // initially focused: + testWindow.gBrowser.selectedTab.focus(); + await TestUtils.waitForCondition(() => !testWindow.gURLBar.focused); + + let overridePromise = expectCommandUpdate(isMac, testWindow); + + testWindow.gURLBar.focus(); + testWindow.gURLBar.value = "test"; + + await overridePromise; + + // Commands won't update when no edit UI is present. They default to being + // enabled so that keyboard shortcuts will work. The real enabled state will + // be checked when shortcut is pressed. + checkState( + !isMac, + "No update when edit-controls is on panel and not visible", + testWindow + ); + + await BrowserTestUtils.closeWindow(testWindow); + await SimpleTest.promiseFocus(window); +}); diff --git a/browser/components/customizableui/test/browser_exit_background_customize_mode.js b/browser/components/customizableui/test/browser_exit_background_customize_mode.js new file mode 100644 index 0000000000..2b53405256 --- /dev/null +++ b/browser/components/customizableui/test/browser_exit_background_customize_mode.js @@ -0,0 +1,44 @@ +"use strict"; + +/** + * Tests that if customize mode is currently attached to a background + * tab, and that tab browses to a new location, that customize mode + * is detached from that tab. + */ +add_task(async function test_exit_background_customize_mode() { + let nonCustomizingTab = gBrowser.selectedTab; + + Assert.equal( + gBrowser.tabContainer.querySelector("tab[customizemode=true]"), + null, + "Should not have a tab marked as being the customize tab now." + ); + + await startCustomizing(); + is(gBrowser.tabs.length, 2, "Should have 2 tabs"); + + let custTab = gBrowser.selectedTab; + + let finishedCustomizing = BrowserTestUtils.waitForEvent( + gNavToolbox, + "aftercustomization" + ); + await BrowserTestUtils.switchTab(gBrowser, nonCustomizingTab); + await finishedCustomizing; + + let newURL = "http://example.com/"; + BrowserTestUtils.startLoadingURIString(custTab.linkedBrowser, newURL); + await BrowserTestUtils.browserLoaded(custTab.linkedBrowser, false, newURL); + + Assert.equal( + gBrowser.tabContainer.querySelector("tab[customizemode=true]"), + null, + "Should not have a tab marked as being the customize tab now." + ); + + await startCustomizing(); + is(gBrowser.tabs.length, 3, "Should have 3 tabs now"); + + await endCustomizing(); + BrowserTestUtils.removeTab(custTab); +}); diff --git a/browser/components/customizableui/test/browser_flexible_space_area.js b/browser/components/customizableui/test/browser_flexible_space_area.js new file mode 100644 index 0000000000..f3189096de --- /dev/null +++ b/browser/components/customizableui/test/browser_flexible_space_area.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function getSpringCount(area) { + return CustomizableUI.getWidgetIdsInArea(area).filter(id => + id.includes("spring") + ).length; +} + +/** + * Check that no matter where we add a flexible space, we + * never end up without a flexible space in the palette. + */ +add_task(async function test_flexible_space_addition() { + await startCustomizing(); + let palette = document.getElementById("customization-palette"); + // Make the bookmarks toolbar visible: + CustomizableUI.setToolbarVisibility(CustomizableUI.AREA_BOOKMARKS, true); + let areas = [CustomizableUI.AREA_NAVBAR, CustomizableUI.AREA_BOOKMARKS]; + if (AppConstants.platform != "macosx") { + areas.push(CustomizableUI.AREA_MENUBAR); + } + + for (let area of areas) { + let spacer = palette.querySelector("toolbarspring"); + let toolbar = document.getElementById(area); + toolbar = CustomizableUI.getCustomizationTarget(toolbar); + + let springCount = getSpringCount(area); + simulateItemDrag(spacer, toolbar); + // Check we added the spring: + is( + springCount + 1, + getSpringCount(area), + "Should now have an extra spring" + ); + + // Check there's still one in the palette: + let newSpacer = palette.querySelector("toolbarspring"); + ok(newSpacer, "Should have created a new spring"); + } +}); +registerCleanupFunction(async function asyncCleanup() { + await endCustomizing(); + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_help_panel_cloning.js b/browser/components/customizableui/test/browser_help_panel_cloning.js new file mode 100644 index 0000000000..4234a52cd8 --- /dev/null +++ b/browser/components/customizableui/test/browser_help_panel_cloning.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* global PanelUI */ + +let gAppMenuStrings = new Localization( + ["branding/brand.ftl", "browser/appmenu.ftl"], + true +); + +const CLONED_ATTRS = ["command", "oncommand", "onclick", "key", "disabled"]; + +/** + * Tests that the Help panel inside of the AppMenu properly clones + * the items from the Help menupopup. Also ensures that the AppMenu + * string variants for those menuitems exist inside of appmenu.ftl. + */ +add_task(async function test_help_panel_cloning() { + await gCUITestUtils.openMainMenu(); + registerCleanupFunction(async () => { + await gCUITestUtils.hideMainMenu(); + }); + + // Showing the Help panel should be enough to get the menupopup to + // populate itself. + let anchor = document.getElementById("PanelUI-menu-button"); + PanelUI.showHelpView(anchor); + + let appMenuHelpSubview = document.getElementById("PanelUI-helpView"); + await BrowserTestUtils.waitForEvent(appMenuHelpSubview, "ViewShowing"); + + let helpMenuPopup = document.getElementById("menu_HelpPopup"); + let helpMenuPopupItems = helpMenuPopup.querySelectorAll("menuitem"); + + for (let helpMenuPopupItem of helpMenuPopupItems) { + if (helpMenuPopupItem.hidden) { + continue; + } + + let appMenuHelpId = "appMenu_" + helpMenuPopupItem.id; + info(`Checking ${appMenuHelpId}`); + + let appMenuHelpItem = appMenuHelpSubview.querySelector(`#${appMenuHelpId}`); + Assert.ok(appMenuHelpItem, "Should have found a cloned AppMenu help item"); + + let appMenuHelpItemL10nId = appMenuHelpItem.dataset.l10nId; + // There is a convention that the Help menu item should have an + // appmenu-data-l10n-id attribute set as the AppMenu-specific localization + // id. + Assert.equal( + helpMenuPopupItem.getAttribute("appmenu-data-l10n-id"), + appMenuHelpItemL10nId, + "Help menuitem supplied a data-l10n-id for the AppMenu Help item" + ); + + let [strings] = gAppMenuStrings.formatMessagesSync([ + { id: appMenuHelpItemL10nId }, + ]); + Assert.ok(strings, "Should have found strings for the AppMenu help item"); + + // Make sure the CLONED_ATTRs are actually cloned. + for (let attr of CLONED_ATTRS) { + if (attr == "oncommand" && helpMenuPopupItem.hasAttribute("command")) { + // If the original element had a "command" attribute set, then the + // cloned element will have its "oncommand" attribute set to equal + // the "oncommand" attribute of the pointed to via the + // original's "command" attribute once it is inserted into the DOM. + // + // This is by virtue of the broadcasting ability of XUL + // elements. + let commandNode = document.getElementById( + helpMenuPopupItem.getAttribute("command") + ); + Assert.equal( + commandNode.getAttribute("oncommand"), + appMenuHelpItem.getAttribute("oncommand"), + "oncommand was properly cloned." + ); + } else { + Assert.equal( + helpMenuPopupItem.getAttribute(attr), + appMenuHelpItem.getAttribute(attr), + `${attr} attribute was cloned.` + ); + } + } + } +}); diff --git a/browser/components/customizableui/test/browser_hidden_widget_overflow.js b/browser/components/customizableui/test/browser_hidden_widget_overflow.js new file mode 100644 index 0000000000..eff0bff4b0 --- /dev/null +++ b/browser/components/customizableui/test/browser_hidden_widget_overflow.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if only hidden widgets are overflowed that the + * OverflowableToolbar won't show the overflow panel anchor. + */ + +const kHiddenButtonID = "fake-hidden-button"; +const kDisplayNoneButtonID = "display-none-button"; +const kWebExtensionButtonID1 = "fake-webextension-button-1"; +const kWebExtensionButtonID2 = "fake-webextension-button-2"; +let gWin = null; + +add_setup(async function () { + gWin = await BrowserTestUtils.openNewBrowserWindow(); + + // To make it easier to write a test where we can control overflowing + // for a test that can run in a bunch of environments with slightly + // different rules on when things will overflow, we'll go ahead and + // just remove everything removable from the nav-bar by default. Then + // we'll add our hidden item, and a single WebExtension item, and + // force toolbar overflow. + let widgetIDs = CustomizableUI.getWidgetIdsInArea(CustomizableUI.AREA_NAVBAR); + for (let widgetID of widgetIDs) { + if (CustomizableUI.isWidgetRemovable(widgetID)) { + CustomizableUI.removeWidgetFromArea(widgetID); + } + } + + CustomizableUI.createWidget({ + id: kWebExtensionButtonID1, + label: "Test WebExtension widget 1", + defaultArea: CustomizableUI.AREA_NAVBAR, + webExtension: true, + }); + + CustomizableUI.createWidget({ + id: kWebExtensionButtonID2, + label: "Test WebExtension widget 2", + defaultArea: CustomizableUI.AREA_NAVBAR, + webExtension: true, + }); + + // Let's force the WebExtension widgets to be significantly wider. This + // just makes it easier to ensure that both of these (which are to the left + // of the hidden widget) get overflowed. + for (let webExtID of [kWebExtensionButtonID1, kWebExtensionButtonID2]) { + let webExtNode = CustomizableUI.getWidget(webExtID).forWindow(gWin).node; + webExtNode.style.minWidth = "100px"; + } + + CustomizableUI.createWidget({ + id: kHiddenButtonID, + label: "Test hidden=true widget", + defaultArea: CustomizableUI.AREA_NAVBAR, + }); + + // Now hide the button with hidden=true so that it has no dimensions. + let hiddenButtonNode = + CustomizableUI.getWidget(kHiddenButtonID).forWindow(gWin).node; + hiddenButtonNode.hidden = true; + + CustomizableUI.createWidget({ + id: kDisplayNoneButtonID, + label: "Test display:none widget", + defaultArea: CustomizableUI.AREA_NAVBAR, + }); + + // Now hide the button with display: none so that it has no dimensions. + let displayNoneButtonNode = + CustomizableUI.getWidget(kDisplayNoneButtonID).forWindow(gWin).node; + displayNoneButtonNode.style.display = "none"; + + registerCleanupFunction(async () => { + CustomizableUI.destroyWidget(kWebExtensionButtonID1); + CustomizableUI.destroyWidget(kWebExtensionButtonID2); + CustomizableUI.destroyWidget(kHiddenButtonID); + CustomizableUI.destroyWidget(kDisplayNoneButtonID); + await BrowserTestUtils.closeWindow(gWin); + await CustomizableUI.reset(); + }); +}); + +add_task(async function test_hidden_widget_overflow() { + gWin.resizeTo(kForceOverflowWidthPx, window.outerHeight); + + // Wait until the left-most fake WebExtension button is overflowing. + let webExtNode = CustomizableUI.getWidget(kWebExtensionButtonID1).forWindow( + gWin + ).node; + await BrowserTestUtils.waitForMutationCondition( + webExtNode, + { attributes: true }, + () => { + return webExtNode.hasAttribute("overflowedItem"); + } + ); + + let hiddenButtonNode = + CustomizableUI.getWidget(kHiddenButtonID).forWindow(gWin).node; + Assert.ok( + hiddenButtonNode.hasAttribute("overflowedItem"), + "Hidden button should be overflowed." + ); + + let overflowButton = gWin.document.getElementById("nav-bar-overflow-button"); + + Assert.ok( + !BrowserTestUtils.isVisible(overflowButton), + "Overflow panel button should be hidden." + ); +}); diff --git a/browser/components/customizableui/test/browser_history_after_appMenu.js b/browser/components/customizableui/test/browser_history_after_appMenu.js new file mode 100644 index 0000000000..89c4b467a2 --- /dev/null +++ b/browser/components/customizableui/test/browser_history_after_appMenu.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Checks that opening the History view using the default toolbar button works + * also while the view is displayed in the main menu. + */ +add_task(async function test_history_after_appMenu() { + // First add the button to the toolbar and wait for it to show up: + CustomizableUI.addWidgetToArea("history-panelmenu", "nav-bar"); + registerCleanupFunction(() => + CustomizableUI.removeWidgetFromArea("history-panelmenu") + ); + await waitForElementShown(document.getElementById("history-panelmenu")); + + let historyView = PanelMultiView.getViewNode(document, "PanelUI-history"); + // Open the main menu. + await gCUITestUtils.openMainMenu(); + + // Show the History view as a subview of the main menu. + document.getElementById("appMenu-history-button").click(); + await BrowserTestUtils.waitForEvent(historyView, "ViewShown"); + + // Show the History view as the main view of the History panel. + document.getElementById("history-panelmenu").click(); + await BrowserTestUtils.waitForEvent(historyView, "ViewShown"); + + // Close the history panel. + let historyPanel = historyView.closest("panel"); + let promise = BrowserTestUtils.waitForEvent(historyPanel, "popuphidden"); + historyPanel.hidePopup(); + await promise; +}); diff --git a/browser/components/customizableui/test/browser_history_recently_closed.js b/browser/components/customizableui/test/browser_history_recently_closed.js new file mode 100644 index 0000000000..32c75ec8e2 --- /dev/null +++ b/browser/components/customizableui/test/browser_history_recently_closed.js @@ -0,0 +1,430 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { SessionStoreTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/SessionStoreTestUtils.sys.mjs" +); +const triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL; + +SessionStoreTestUtils.init(this, window); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://example.com" +); + +let panelMenuWidgetAdded = false; +function prepareHistoryPanel() { + if (panelMenuWidgetAdded) { + return; + } + CustomizableUI.addWidgetToArea( + "history-panelmenu", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + registerCleanupFunction(() => CustomizableUI.reset()); +} + +async function openRecentlyClosedTabsMenu() { + prepareHistoryPanel(); + await openHistoryPanel(); + + let recentlyClosedTabs = document.getElementById("appMenuRecentlyClosedTabs"); + Assert.ok( + !recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs button enabled" + ); + let closeTabsPanel = document.getElementById( + "appMenu-library-recentlyClosedTabs" + ); + let panelView = closeTabsPanel && PanelView.forNode(closeTabsPanel); + if (!panelView?.active) { + recentlyClosedTabs.click(); + closeTabsPanel = document.getElementById( + "appMenu-library-recentlyClosedTabs" + ); + await BrowserTestUtils.waitForEvent(closeTabsPanel, "ViewShown"); + ok( + PanelView.forNode(closeTabsPanel)?.active, + "Opened 'Recently closed tabs' panel" + ); + } + + return closeTabsPanel; +} + +function resetClosedTabsAndWindows() { + // Clear the lists of closed windows and tabs. + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is(SessionStore.getClosedWindowCount(), 0, "Expect 0 closed windows"); + for (const win of BrowserWindowTracker.orderedWindows) { + is( + SessionStore.getClosedTabCountForWindow(win), + 0, + "Expect 0 closed tabs for this window" + ); + } +} + +registerCleanupFunction(async () => { + await resetClosedTabsAndWindows(); +}); + +add_task(async function testRecentlyClosedDisabled() { + info("Check history recently closed tabs/windows section"); + + prepareHistoryPanel(); + // We need to make sure the history is cleared before starting the test + await Sanitizer.sanitize(["history"]); + + await openHistoryPanel(); + + let recentlyClosedTabs = document.getElementById("appMenuRecentlyClosedTabs"); + let recentlyClosedWindows = document.getElementById( + "appMenuRecentlyClosedWindows" + ); + + // Wait for the disabled attribute to change, as we receive + // the "viewshown" event before this changes + await BrowserTestUtils.waitForCondition( + () => recentlyClosedTabs.getAttribute("disabled"), + "Waiting for button to become disabled" + ); + Assert.ok( + recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs button disabled" + ); + Assert.ok( + recentlyClosedWindows.getAttribute("disabled"), + "Recently closed windows button disabled" + ); + + await hideHistoryPanel(); + + gBrowser.selectedTab.focus(); + await SessionStoreTestUtils.openAndCloseTab( + window, + TEST_PATH + "dummy_history_item.html" + ); + + await openHistoryPanel(); + + await BrowserTestUtils.waitForCondition( + () => !recentlyClosedTabs.getAttribute("disabled"), + "Waiting for button to be enabled" + ); + Assert.ok( + !recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs is available" + ); + Assert.ok( + recentlyClosedWindows.getAttribute("disabled"), + "Recently closed windows button disabled" + ); + + await hideHistoryPanel(); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + let loadedPromise = BrowserTestUtils.browserLoaded( + newWin.gBrowser.selectedBrowser + ); + BrowserTestUtils.startLoadingURIString( + newWin.gBrowser.selectedBrowser, + "about:mozilla" + ); + await loadedPromise; + await BrowserTestUtils.closeWindow(newWin); + + await openHistoryPanel(); + + await BrowserTestUtils.waitForCondition( + () => !recentlyClosedWindows.getAttribute("disabled"), + "Waiting for button to be enabled" + ); + Assert.ok( + !recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs is available" + ); + Assert.ok( + !recentlyClosedWindows.getAttribute("disabled"), + "Recently closed windows is available" + ); + + await hideHistoryPanel(); +}); + +add_task(async function testRecentlyClosedTabsDisabledPersists() { + info("Check history recently closed tabs/windows section"); + + prepareHistoryPanel(); + + // We need to make sure the history is cleared before starting the test + await Sanitizer.sanitize(["history"]); + + await openHistoryPanel(); + + let recentlyClosedTabs = document.getElementById("appMenuRecentlyClosedTabs"); + Assert.ok( + recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs button disabled" + ); + + await hideHistoryPanel(); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + await openHistoryPanel(newWin.document); + recentlyClosedTabs = newWin.document.getElementById( + "appMenuRecentlyClosedTabs" + ); + Assert.ok( + recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs is disabled" + ); + + // We close the window without hiding the panel first, which used to interfere + // with populating the view subsequently. + await BrowserTestUtils.closeWindow(newWin); + + newWin = await BrowserTestUtils.openNewBrowserWindow(); + await openHistoryPanel(newWin.document); + recentlyClosedTabs = newWin.document.getElementById( + "appMenuRecentlyClosedTabs" + ); + Assert.ok( + recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs is disabled" + ); + await hideHistoryPanel(newWin.document); + await BrowserTestUtils.closeWindow(newWin); +}); + +add_task(async function testRecentlyClosedRestoreAllTabs() { + // We need to make sure the history is cleared before starting the test + await Sanitizer.sanitize(["history"]); + await resetClosedTabsAndWindows(); + const initialTabCount = gBrowser.visibleTabs.length; + + const closedTabUrls = [ + "about:robots", + "https://example.com/", + "https://example.org/", + ]; + const windowState = { + tabs: [ + { + entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }], + }, + ], + _closedTabs: closedTabUrls.map(url => { + return { + title: url, + state: { + entries: [ + { + url, + triggeringPrincipal_base64, + }, + ], + }, + }; + }), + }; + await SessionStoreTestUtils.promiseBrowserState({ + windows: [windowState], + }); + + is(gBrowser.visibleTabs.length, 1, "We start with one tab open"); + // Open the "Recently closed tabs" panel. + let closeTabsPanel = await openRecentlyClosedTabsMenu(); + + // Click the first toolbar button in the panel. + let toolbarButton = closeTabsPanel.querySelector( + ".panel-subview-body toolbarbutton" + ); + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true); + EventUtils.sendMouseEvent({ type: "click" }, toolbarButton, window); + + info( + "We should reopen the first of closedTabUrls: " + + JSON.stringify(closedTabUrls) + ); + let reopenedTab = await newTabPromise; + is( + reopenedTab.linkedBrowser.currentURI.spec, + closedTabUrls[0], + "Opened the first URL" + ); + info(`restored tab, total open tabs: ${gBrowser.tabs.length}`); + + info("waiting for closeTab"); + await SessionStoreTestUtils.closeTab(reopenedTab); + + await openRecentlyClosedTabsMenu(); + let restoreAllItem = closeTabsPanel.querySelector(".restoreallitem"); + ok( + restoreAllItem && !restoreAllItem.hidden, + "Restore all menu item is not hidden" + ); + + // Click the restore-all toolbar button in the panel. + EventUtils.sendMouseEvent({ type: "click" }, restoreAllItem, window); + + info("waiting for restored tabs"); + await BrowserTestUtils.waitForCondition( + () => SessionStore.getClosedTabCount() === 0, + "Waiting for all the closed tabs to be opened" + ); + + is( + gBrowser.tabs.length, + initialTabCount + closedTabUrls.length, + "The expected number of closed tabs were restored" + ); + + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } +}); + +add_task(async function testRecentlyClosedWindows() { + // We need to make sure the history is cleared before starting the test + await Sanitizer.sanitize(["history"]); + await resetClosedTabsAndWindows(); + + // Open and close a new window. + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + let loadedPromise = BrowserTestUtils.browserLoaded( + newWin.gBrowser.selectedBrowser + ); + BrowserTestUtils.startLoadingURIString( + newWin.gBrowser.selectedBrowser, + "https://example.com" + ); + await loadedPromise; + let closedObjectsChangePromise = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + await BrowserTestUtils.closeWindow(newWin); + await closedObjectsChangePromise; + + prepareHistoryPanel(); + await openHistoryPanel(); + + // Open the "Recently closed windows" panel. + document.getElementById("appMenuRecentlyClosedWindows").click(); + + let winPanel = document.getElementById( + "appMenu-library-recentlyClosedWindows" + ); + await BrowserTestUtils.waitForEvent(winPanel, "ViewShown"); + ok(true, "Opened 'Recently closed windows' panel"); + + // Click the first toolbar button in the panel. + let panelBody = winPanel.querySelector(".panel-subview-body"); + let toolbarButton = panelBody.querySelector("toolbarbutton"); + let newWindowPromise = BrowserTestUtils.waitForNewWindow({ + url: "https://example.com/", + }); + closedObjectsChangePromise = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + EventUtils.sendMouseEvent({ type: "click" }, toolbarButton, window); + + newWin = await newWindowPromise; + await closedObjectsChangePromise; + is(gBrowser.tabs.length, 1, "Did not open new tabs"); + + await BrowserTestUtils.closeWindow(newWin); +}); + +add_task(async function testRecentlyClosedTabsFromClosedWindows() { + await resetClosedTabsAndWindows(); + const closedTabUrls = [ + "about:robots", + "https://example.com/", + "https://example.org/", + ]; + const closedWindowState = { + tabs: [ + { + entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }], + }, + ], + _closedTabs: closedTabUrls.map(url => { + return { + title: url, + state: { + entries: [ + { + url, + triggeringPrincipal_base64, + }, + ], + }, + }; + }), + }; + await SessionStoreTestUtils.promiseBrowserState({ + windows: [ + { + tabs: [ + { + entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }], + }, + ], + }, + ], + _closedWindows: [closedWindowState], + }); + Assert.equal( + SessionStore.getClosedTabCountFromClosedWindows(), + closedTabUrls.length, + "Sanity check number of closed tabs from closed windows" + ); + + prepareHistoryPanel(); + let closeTabsPanel = await openRecentlyClosedTabsMenu(); + // make sure we can actually restore one of these closed tabs + const closedTabItems = closeTabsPanel.querySelectorAll( + "toolbarbutton[targetURI]" + ); + Assert.equal( + closedTabItems.length, + closedTabUrls.length, + "We have expected number of closed tab items" + ); + + const newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true); + const closedObjectsChangePromise = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + EventUtils.sendMouseEvent({ type: "click" }, closedTabItems[0], window); + await newTabPromise; + await closedObjectsChangePromise; + + // flip the pref so none of the closed tabs from closed window are included + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.closedTabsFromClosedWindows", false]], + }); + await openHistoryPanel(); + + // verify the recently-closed-tabs menu item is disabled + let recentlyClosedTabsItem = document.getElementById( + "appMenuRecentlyClosedTabs" + ); + Assert.ok( + recentlyClosedTabsItem.hasAttribute("disabled"), + "Recently closed tabs button is now disabled" + ); + SpecialPowers.popPrefEnv(); + while (gBrowser.tabs.length > 1) { + await SessionStoreTestUtils.closeTab( + gBrowser.tabs[gBrowser.tabs.length - 1] + ); + } +}); diff --git a/browser/components/customizableui/test/browser_history_recently_closed_middleclick.js b/browser/components/customizableui/test/browser_history_recently_closed_middleclick.js new file mode 100644 index 0000000000..ee2656ebca --- /dev/null +++ b/browser/components/customizableui/test/browser_history_recently_closed_middleclick.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Verifies that middle-clicking "Recently Closed Tabs" in both history +// menus works as expected. + +const URLS = [ + "http://example.com/", + "http://example.org/", + "http://example.net/", +]; + +async function setupTest() { + // Navigate the initial tab to ensure that it won't be reused for the tab + // that will be reopened. + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "https://example.com" + ); + await loadPromise; + + // Populate the recently closed tabs list. + for (let url of URLS) { + await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + } + for (let i = 0; i < URLS.length; i++) { + gBrowser.removeTab(gBrowser.selectedTab); + } + + return gBrowser.tabs.length; +} + +add_task(async function testMenubar() { + if (AppConstants.platform === "macosx") { + ok(true, "Can't open menu items on macOS"); + return; + } + + let nOpenTabs = await setupTest(); + + // Open the "History" menu. + let menu = document.getElementById("history-menu"); + let popupPromise = BrowserTestUtils.waitForEvent(menu, "popupshown"); + menu.open = true; + await popupPromise; + ok(true, "Opened 'History' menu"); + + // Open the "Recently Closed Tabs" submenu. + let undoMenu = document.getElementById("historyUndoMenu"); + popupPromise = BrowserTestUtils.waitForEvent(undoMenu, "popupshown"); + undoMenu.open = true; + let popupEvent = await popupPromise; + ok(true, "Opened 'Recently Closed Tabs' menu"); + + // And now middle-click the first item in that menu, and ensure that we're + // only opening a single new tab. + let menuitems = popupEvent.target.querySelectorAll("menuitem"); + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true); + popupEvent.target.activateItem(menuitems[0], { button: 1 }); + + let newTab = await newTabPromise; + is(newTab.linkedBrowser.currentURI.spec, URLS[0], "Opened correct URL"); + is(gBrowser.tabs.length, nOpenTabs + 1, "Only opened 1 new tab"); + + gBrowser.removeTab(newTab); +}); + +add_task(async function testHistoryPanel() { + let nOpenTabs = await setupTest(); + + // Setup history panel. + CustomizableUI.addWidgetToArea( + "history-panelmenu", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + registerCleanupFunction(() => CustomizableUI.reset()); + await openHistoryPanel(); + + // Open the "Recently closed tabs" panel. + let recentlyClosedTabs = document.getElementById("appMenuRecentlyClosedTabs"); + recentlyClosedTabs.click(); + + let recentlyClosedTabsPanel = document.getElementById( + "appMenu-library-recentlyClosedTabs" + ); + await BrowserTestUtils.waitForEvent(recentlyClosedTabsPanel, "ViewShown"); + ok(true, "Opened 'Recently closed tabs' panel"); + + let panelBody = recentlyClosedTabsPanel.querySelector(".panel-subview-body"); + let toolbarButtons = panelBody.querySelectorAll("toolbarbutton"); + + // Middle-click the first toolbar button in the panel. + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true); + EventUtils.sendMouseEvent( + { type: "click", button: 1 }, + toolbarButtons[0], + window + ); + + let newTab = await newTabPromise; + is(newTab.linkedBrowser.currentURI.spec, URLS[0], "Opened correct URL"); + is(gBrowser.tabs.length, nOpenTabs + 1, "Only opened 1 new tab"); + + gBrowser.removeTab(newTab); +}); diff --git a/browser/components/customizableui/test/browser_history_restore_session.js b/browser/components/customizableui/test/browser_history_restore_session.js new file mode 100644 index 0000000000..a8b9529209 --- /dev/null +++ b/browser/components/customizableui/test/browser_history_restore_session.js @@ -0,0 +1,52 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function testRestoreSession() { + info("Check history panel's restore previous session button"); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + CustomizableUI.addWidgetToArea( + "history-panelmenu", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + registerCleanupFunction(() => CustomizableUI.reset()); + + // We need to make sure the history is cleared before starting the test + await Sanitizer.sanitize(["history"]); + + await openHistoryPanel(win.document); + + let restorePrevSessionBtn = win.document.getElementById( + "appMenu-restoreSession" + ); + + Assert.ok( + restorePrevSessionBtn.hidden, + "Restore previous session button is not visible" + ); + await hideHistoryPanel(win.document); + + BrowserTestUtils.addTab(win.gBrowser, "about:mozilla"); + await BrowserTestUtils.closeWindow(win); + + win = await BrowserTestUtils.openNewBrowserWindow(); + + let lastSession = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionStore.sys.mjs" + )._LastSession; + lastSession.setState(true); + + await openHistoryPanel(win.document); + + restorePrevSessionBtn = win.document.getElementById("appMenu-restoreSession"); + Assert.ok( + !restorePrevSessionBtn.hidden, + "Restore previous session button is visible" + ); + + await hideHistoryPanel(win.document); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/customizableui/test/browser_insert_before_moved_node.js b/browser/components/customizableui/test/browser_insert_before_moved_node.js new file mode 100644 index 0000000000..611f4e3ce0 --- /dev/null +++ b/browser/components/customizableui/test/browser_insert_before_moved_node.js @@ -0,0 +1,51 @@ +"use strict"; + +/** + * Check inserting before a node that has moved from the toolbar into a + * non-customizable bit of the browser works. + */ +add_task(async function () { + for (let toolbar of ["nav-bar", "TabsToolbar"]) { + CustomizableUI.createWidget({ + id: "real-button", + label: "test real button", + }); + CustomizableUI.addWidgetToArea("real-button", toolbar); + CustomizableUI.addWidgetToArea("moved-button-not-here", toolbar); + let placements = CustomizableUI.getWidgetIdsInArea(toolbar); + Assert.deepEqual( + placements.slice(-2), + ["real-button", "moved-button-not-here"], + "Should have correct placements" + ); + let otherButton = document.createXULElement("toolbarbutton"); + otherButton.id = "moved-button-not-here"; + if (toolbar == "nav-bar") { + gURLBar.textbox.parentNode.appendChild(otherButton); + } else { + gBrowser.tabContainer.appendChild(otherButton); + } + CustomizableUI.destroyWidget("real-button"); + CustomizableUI.createWidget({ + id: "real-button", + label: "test real button", + }); + + let button = document.getElementById("real-button"); + ok(button, "Button should exist"); + if (button) { + let expectedContainer = CustomizableUI.getCustomizationTarget( + document.getElementById(toolbar) + ); + is( + button.parentNode, + expectedContainer, + "Button should be in the toolbar" + ); + } + + CustomizableUI.destroyWidget("real-button"); + otherButton.remove(); + CustomizableUI.reset(); + } +}); diff --git a/browser/components/customizableui/test/browser_menubar_visibility.js b/browser/components/customizableui/test/browser_menubar_visibility.js new file mode 100644 index 0000000000..82f2959905 --- /dev/null +++ b/browser/components/customizableui/test/browser_menubar_visibility.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that menubar visibility is propagated correctly to new windows. + */ +add_task(async function test_menubar_visbility() { + let menubar = document.getElementById("toolbar-menubar"); + is(menubar.getAttribute("autohide"), "true", "Menubar should be autohiding"); + registerCleanupFunction(() => { + Services.xulStore.removeValue( + AppConstants.BROWSER_CHROME_URL, + menubar.id, + "autohide" + ); + menubar.setAttribute("autohide", "true"); + }); + + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + EventUtils.synthesizeMouse( + document.getElementById("stop-reload-button"), + 2, + 2, + { + type: "contextmenu", + button: 2, + } + ); + await shownPromise; + let attrChanged = BrowserTestUtils.waitForAttribute( + "autohide", + menubar, + "false" + ); + EventUtils.synthesizeMouseAtCenter( + document.getElementById("toggle_toolbar-menubar"), + {} + ); + await attrChanged; + contextMenu.hidePopup(); // to be safe. + + is( + menubar.getAttribute("autohide"), + "false", + "Menubar should now be permanently visible." + ); + let persistedValue = Services.xulStore.getValue( + AppConstants.BROWSER_CHROME_URL, + menubar.id, + "autohide" + ); + is(persistedValue, "false", "New value should be persisted"); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + + is( + win.document.getElementById("toolbar-menubar").getAttribute("autohide"), + "false", + "Menubar should also be permanently visible in the new window." + ); + + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/customizableui/test/browser_newtab_button_customizemode.js b/browser/components/customizableui/test/browser_newtab_button_customizemode.js new file mode 100644 index 0000000000..c30616f3a3 --- /dev/null +++ b/browser/components/customizableui/test/browser_newtab_button_customizemode.js @@ -0,0 +1,181 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests in this file check that user customizations to the tabstrip show + * the correct type of new tab button while the tabstrip isn't overflowing. + */ + +const kGlobalNewTabButton = document.getElementById("new-tab-button"); +const kInnerNewTabButton = gBrowser.tabContainer.newTabButton; + +function assertNewTabButton(which) { + if (which == "global") { + isnot( + kGlobalNewTabButton.getBoundingClientRect().width, + 0, + "main new tab button should be visible" + ); + is( + kInnerNewTabButton.getBoundingClientRect().width, + 0, + "inner new tab button should be hidden" + ); + } else if (which == "inner") { + is( + kGlobalNewTabButton.getBoundingClientRect().width, + 0, + "main new tab button should be hidden" + ); + isnot( + kInnerNewTabButton.getBoundingClientRect().width, + 0, + "inner new tab button should be visible" + ); + } else { + ok(false, "Unexpected button: " + which); + } +} + +/** + * Add and remove items *after* the new tab button in customize mode. + */ +add_task(async function addremove_after_newtab_customizemode() { + await startCustomizing(); + await waitForElementShown(kGlobalNewTabButton); + simulateItemDrag( + document.getElementById("stop-reload-button"), + kGlobalNewTabButton, + "end" + ); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should have the adjacent newtab attribute" + ); + await endCustomizing(); + assertNewTabButton("inner"); + + await startCustomizing(); + let dropTarget = document.getElementById("forward-button"); + await waitForElementShown(dropTarget); + simulateItemDrag( + document.getElementById("stop-reload-button"), + dropTarget, + "end" + ); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should still have the adjacent newtab attribute" + ); + await endCustomizing(); + assertNewTabButton("inner"); + ok(CustomizableUI.inDefaultState, "Should be in default state"); +}); + +/** + * Add and remove items *before* the new tab button in customize mode. + */ +add_task(async function addremove_before_newtab_customizemode() { + await startCustomizing(); + await waitForElementShown(kGlobalNewTabButton); + simulateItemDrag( + document.getElementById("stop-reload-button"), + kGlobalNewTabButton, + "start" + ); + ok( + !gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should no longer have the adjacent newtab attribute" + ); + await endCustomizing(); + assertNewTabButton("global"); + await startCustomizing(); + let dropTarget = document.getElementById("forward-button"); + await waitForElementShown(dropTarget); + simulateItemDrag( + document.getElementById("stop-reload-button"), + dropTarget, + "end" + ); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should have the adjacent newtab attribute again" + ); + await endCustomizing(); + assertNewTabButton("inner"); + ok(CustomizableUI.inDefaultState, "Should be in default state"); +}); + +/** + * Add and remove items *after* the new tab button outside of customize mode. + */ +add_task(async function addremove_after_newtab_api() { + CustomizableUI.addWidgetToArea("stop-reload-button", "TabsToolbar"); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should have the adjacent newtab attribute" + ); + assertNewTabButton("inner"); + + CustomizableUI.reset(); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should still have the adjacent newtab attribute" + ); + assertNewTabButton("inner"); + ok(CustomizableUI.inDefaultState, "Should be in default state"); +}); + +/** + * Add and remove items *before* the new tab button outside of customize mode. + */ +add_task(async function addremove_before_newtab_api() { + let index = + CustomizableUI.getWidgetIdsInArea("TabsToolbar").indexOf("new-tab-button"); + CustomizableUI.addWidgetToArea("stop-reload-button", "TabsToolbar", index); + ok( + !gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should no longer have the adjacent newtab attribute" + ); + assertNewTabButton("global"); + + CustomizableUI.removeWidgetFromArea("stop-reload-button"); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should have the adjacent newtab attribute again" + ); + assertNewTabButton("inner"); + + CustomizableUI.reset(); + ok(CustomizableUI.inDefaultState, "Should be in default state"); +}); + +/** + * Reset to defaults in customize mode to see if that doesn't break things. + */ +add_task(async function reset_before_newtab_customizemode() { + await startCustomizing(); + await waitForElementShown(kGlobalNewTabButton); + simulateItemDrag( + document.getElementById("stop-reload-button"), + kGlobalNewTabButton, + "start" + ); + ok( + !gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should no longer have the adjacent newtab attribute" + ); + await endCustomizing(); + assertNewTabButton("global"); + await startCustomizing(); + await gCustomizeMode.reset(); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should have the adjacent newtab attribute again" + ); + await endCustomizing(); + assertNewTabButton("inner"); + ok(CustomizableUI.inDefaultState, "Should be in default state"); +}); diff --git a/browser/components/customizableui/test/browser_open_from_popup.js b/browser/components/customizableui/test/browser_open_from_popup.js new file mode 100644 index 0000000000..bf140fde79 --- /dev/null +++ b/browser/components/customizableui/test/browser_open_from_popup.js @@ -0,0 +1,24 @@ +"use strict"; + +/** + * Check that opening customize mode in a popup opens it in the main window. + */ +add_task(async function open_customize_mode_from_popup() { + let promiseWindow = BrowserTestUtils.waitForNewWindow(); + SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + content.window.open("about:blank", "_blank", "height=300,toolbar=no"); + }); + let win = await promiseWindow; + let customizePromise = BrowserTestUtils.waitForEvent( + gNavToolbox, + "customizationready" + ); + win.gCustomizeMode.enter(); + await customizePromise; + ok( + document.documentElement.hasAttribute("customizing"), + "Should have opened customize mode in the parent window" + ); + await endCustomizing(); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/customizableui/test/browser_open_in_lazy_tab.js b/browser/components/customizableui/test/browser_open_in_lazy_tab.js new file mode 100644 index 0000000000..c18de67698 --- /dev/null +++ b/browser/components/customizableui/test/browser_open_in_lazy_tab.js @@ -0,0 +1,42 @@ +"use strict"; + +/** + * Check that customize mode can be loaded in a lazy tab. + */ +add_task(async function open_customize_mode_in_lazy_tab() { + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank", { + createLazyBrowser: true, + }); + gCustomizeMode.setTab(tab); + + is(tab.linkedPanel, "", "Tab should be lazy"); + + let title = gNavigatorBundle.getFormattedString("customizeMode.tabTitle", [ + document.getElementById("bundle_brand").getString("brandShortName"), + ]); + is(tab.label, title, "Tab should have correct title"); + + let customizePromise = BrowserTestUtils.waitForEvent( + gNavToolbox, + "customizationready" + ); + gCustomizeMode.enter(); + await customizePromise; + + is( + tab.getAttribute("customizemode"), + "true", + "Tab should be in customize mode" + ); + + let customizationContainer = document.getElementById( + "customization-container" + ); + is( + customizationContainer.hidden, + false, + "Customization container should be visible" + ); + + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_overflow_use_subviews.js b/browser/components/customizableui/test/browser_overflow_use_subviews.js new file mode 100644 index 0000000000..1e6e227364 --- /dev/null +++ b/browser/components/customizableui/test/browser_overflow_use_subviews.js @@ -0,0 +1,88 @@ +"use strict"; + +const kOverflowPanel = document.getElementById("widget-overflow"); + +var gOriginalWidth; +async function stopOverflowing() { + kOverflowPanel.removeAttribute("animate"); + window.resizeTo(gOriginalWidth, window.outerHeight); + await TestUtils.waitForCondition( + () => !document.getElementById("nav-bar").hasAttribute("overflowing") + ); + CustomizableUI.reset(); +} + +registerCleanupFunction(stopOverflowing); + +/** + * This checks that subview-compatible items show up as subviews rather than + * re-anchored panels. If we ever remove the library widget, please + * replace this test with another subview - don't remove it. + */ +add_task(async function check_library_subview_in_overflow() { + kOverflowPanel.setAttribute("animate", "false"); + gOriginalWidth = window.outerWidth; + + CustomizableUI.addWidgetToArea("library-button", CustomizableUI.AREA_NAVBAR); + + let navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); + ok( + !navbar.hasAttribute("overflowing"), + "Should start with a non-overflowing toolbar." + ); + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + + await TestUtils.waitForCondition(() => navbar.hasAttribute("overflowing")); + + let chevron = document.getElementById("nav-bar-overflow-button"); + let shownPanelPromise = BrowserTestUtils.waitForEvent( + kOverflowPanel, + "ViewShown" + ); + chevron.click(); + await shownPanelPromise; + + let button = document.getElementById("library-button"); + button.click(); + + let libraryView = document.getElementById("appMenu-libraryView"); + await BrowserTestUtils.waitForEvent(libraryView, "ViewShown"); + let hasSubviews = !!kOverflowPanel.querySelector("panelmultiview"); + let expectedPanel = hasSubviews + ? kOverflowPanel + : document.getElementById("customizationui-widget-panel"); + is(libraryView.closest("panel"), expectedPanel, "Should be inside the panel"); + expectedPanel.hidePopup(); + await Promise.resolve(); // wait for popup to hide fully. + await stopOverflowing(); +}); + +/** + * This checks that non-subview-compatible items still work correctly. + * Ideally we should make the downloads panel and bookmarks/library item + * proper subview items, then this test can go away, and potentially we can + * simplify some of the subview anchoring code. + */ +add_task(async function check_downloads_panel_in_overflow() { + let button = document.getElementById("downloads-button"); + await gCustomizeMode.addToPanel(button); + await waitForOverflowButtonShown(); + + let chevron = document.getElementById("nav-bar-overflow-button"); + let shownPanelPromise = promisePanelElementShown(window, kOverflowPanel); + chevron.click(); + await shownPanelPromise; + + button.click(); + await TestUtils.waitForCondition(() => { + let panel = document.getElementById("downloadsPanel"); + return panel && panel.state != "closed"; + }); + let downloadsPanel = document.getElementById("downloadsPanel"); + isnot( + downloadsPanel.state, + "closed", + "Should be attempting to show the downloads panel." + ); + downloadsPanel.hidePopup(); +}); diff --git a/browser/components/customizableui/test/browser_palette_labels.js b/browser/components/customizableui/test/browser_palette_labels.js new file mode 100644 index 0000000000..42767a8ee2 --- /dev/null +++ b/browser/components/customizableui/test/browser_palette_labels.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that all customizable buttons have labels and icons. + * + * This is primarily designed to ensure we don't end up with items without + * labels in customize mode. In the past, this has happened due to race + * conditions, where labels would be correct if and only if the item had + * already been moved into a toolbar or panel in the main UI before + * (forcing it to be constructed and any fluent identifiers to be localized + * and applied). + * We use a new window to ensure that earlier tests using some of the widgets + * in the palette do not influence our checks to see that such items get + * labels, "even" if the first time they're rendered is in customize mode's + * palette. + */ +add_task(async function test_all_buttons_have_labels() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + registerCleanupFunction(async () => { + await endCustomizing(win); + return BrowserTestUtils.closeWindow(win); + }); + await startCustomizing(win); + let { palette } = win.gNavToolbox; + // Wait for things to paint. + await TestUtils.waitForCondition(() => { + return !!Array.from(palette.querySelectorAll(".toolbarbutton-icon")).filter( + n => { + let rect = n.getBoundingClientRect(); + return rect.height > 0 && rect.width > 0; + } + ).length; + }, "Must start rendering icons."); + + for (let wrapper of palette.children) { + if (wrapper.hasAttribute("title")) { + ok(true, wrapper.firstElementChild.id + " has a label."); + } else { + info( + `${wrapper.firstElementChild.id} doesn't seem to have a label, waiting.` + ); + await BrowserTestUtils.waitForAttribute("title", wrapper); + ok( + wrapper.hasAttribute("title"), + wrapper.firstElementChild.id + " has a label." + ); + } + let icons = Array.from(wrapper.querySelectorAll(".toolbarbutton-icon")); + // If there are icons, at least one must be visible + // (not everything necessarily has one, e.g. the search bar has no icon) + if (icons.length) { + let visibleIcons = icons.filter(n => { + let rect = n.getBoundingClientRect(); + return rect.height > 0 && rect.width > 0; + }); + Assert.greater( + visibleIcons.length, + 0, + `${wrapper.firstElementChild.id} should have at least one visible icon.` + ); + } + } +}); diff --git a/browser/components/customizableui/test/browser_panelUINotifications.js b/browser/components/customizableui/test/browser_panelUINotifications.js new file mode 100644 index 0000000000..818fcbad39 --- /dev/null +++ b/browser/components/customizableui/test/browser_panelUINotifications.js @@ -0,0 +1,597 @@ +"use strict"; + +const { AppMenuNotifications } = ChromeUtils.importESModule( + "resource://gre/modules/AppMenuNotifications.sys.mjs" +); + +/** + * Tests that when we click on the main call-to-action of the doorhanger, the provided + * action is called, and the doorhanger removed. + */ +add_task(async function testMainActionCalled() { + let options = { + gBrowser: window.gBrowser, + url: "about:blank", + }; + + await BrowserTestUtils.withNewTab(options, function (browser) { + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let button = doorhanger.button; + button.click(); + + ok(mainActionCalled, "Main action callback was called"); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + }); +}); + +/** + * This tests that when we click the secondary action for a notification, + * it will display the badge for that notification on the PanelUI menu button. + * Once we click on this button, we should see an item in the menu which will + * call our main action. + */ +add_task(async function testSecondaryActionWorkflow() { + let options = { + gBrowser: window.gBrowser, + url: "about:blank", + }; + + await BrowserTestUtils.withNewTab(options, async function (browser) { + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let secondaryActionButton = doorhanger.secondaryButton; + secondaryActionButton.click(); + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is displaying on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is displaying on PanelUI button." + ); + let menuItem = PanelUI.mainView.querySelector(".panel-banner-item"); + is( + menuItem.getAttribute("data-l10n-id"), + "appmenuitem-banner-update-manual", + "Showing correct label" + ); + is(menuItem.hidden, false, "update-manual menu item is showing."); + + await gCUITestUtils.hideMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is shown on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + menuItem.click(); + ok(mainActionCalled, "Main action callback was called"); + + AppMenuNotifications.removeNotification(/.*/); + }); +}); + +/** + * This tests that the PanelUI update downloading badge and banner + * notification are correctly displayed and that clicking the banner + * item calls the main action. + */ +add_task(async function testDownloadingBadge() { + let options = { + gBrowser: window.gBrowser, + url: "about:blank", + }; + + await BrowserTestUtils.withNewTab(options, async function (browser) { + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + // The downloading notification is always displayed in a dismissed state. + AppMenuNotifications.showNotification( + "update-downloading", + mainAction, + undefined, + { dismissed: true } + ); + is(PanelUI.notificationPanel.state, "closed", "doorhanger is closed."); + + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-downloading", + "Downloading badge is displaying on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-downloading", + "Downloading badge is displaying on PanelUI button." + ); + let menuItem = PanelUI.mainView.querySelector(".panel-banner-item"); + is( + menuItem.getAttribute("data-l10n-id"), + "appmenuitem-banner-update-downloading", + "Showing correct label (downloading)" + ); + is(menuItem.hidden, false, "update-downloading menu item is showing."); + + await gCUITestUtils.hideMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-downloading", + "Downloading badge is shown on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + menuItem.click(); + ok(mainActionCalled, "Main action callback was called"); + + AppMenuNotifications.removeNotification(/.*/); + }); +}); + +/** + * We want to ensure a few things with this: + * - Adding a doorhanger will make a badge disappear + * - once the notification for the doorhanger is resolved (removed, not just dismissed), + * then we display any other badges that are remaining. + */ +add_task(async function testInteractionWithBadges() { + await BrowserTestUtils.withNewTab("about:blank", async function (browser) { + // Remove the fxa toolbar button from the navbar to ensure the notification + // is displayed on the app menu button. + let { CustomizableUI } = ChromeUtils.importESModule( + "resource:///modules/CustomizableUI.sys.mjs" + ); + CustomizableUI.removeWidgetFromArea("fxa-toolbar-menu-button"); + + AppMenuNotifications.showBadgeOnlyNotification("fxa-needs-authentication"); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Fxa badge is shown on PanelUI button." + ); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + + isnot( + PanelUI.menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Fxa badge is hidden on PanelUI button." + ); + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let secondaryActionButton = doorhanger.secondaryButton; + secondaryActionButton.click(); + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is displaying on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is displaying on PanelUI button." + ); + let menuItem = PanelUI.mainView.querySelector(".panel-banner-item"); + is( + menuItem.getAttribute("data-l10n-id"), + "appmenuitem-banner-update-manual", + "Showing correct label" + ); + is(menuItem.hidden, false, "update-manual menu item is showing."); + + menuItem.click(); + ok(mainActionCalled, "Main action callback was called"); + + is( + PanelUI.menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Fxa badge is shown on PanelUI button." + ); + AppMenuNotifications.removeNotification(/.*/); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + }); +}); + +/** + * This tests that adding a badge will not dismiss any existing doorhangers. + */ +add_task(async function testAddingBadgeWhileDoorhangerIsShowing() { + await BrowserTestUtils.withNewTab("about:blank", function (browser) { + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + AppMenuNotifications.showBadgeOnlyNotification("fxa-needs-authentication"); + + isnot( + PanelUI.menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Fxa badge is hidden on PanelUI button." + ); + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let mainActionButton = doorhanger.button; + mainActionButton.click(); + + ok(mainActionCalled, "Main action callback was called"); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Fxa badge is shown on PanelUI button." + ); + AppMenuNotifications.removeNotification(/.*/); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + }); +}); + +/** + * Tests that badges operate like a stack. + */ +add_task(async function testMultipleBadges() { + await BrowserTestUtils.withNewTab("about:blank", async function (browser) { + let doc = browser.ownerDocument; + let menuButton = doc.getElementById("PanelUI-menu-button"); + + is( + menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + is( + menuButton.hasAttribute("badge"), + false, + "Should not have the badge attribute set" + ); + + AppMenuNotifications.showBadgeOnlyNotification("fxa-needs-authentication"); + is( + menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Should have fxa-needs-authentication badge status" + ); + + AppMenuNotifications.showBadgeOnlyNotification("update-succeeded"); + is( + menuButton.getAttribute("badge-status"), + "update-succeeded", + "Should have update-succeeded badge status (update > fxa)" + ); + + AppMenuNotifications.showBadgeOnlyNotification("update-failed"); + is( + menuButton.getAttribute("badge-status"), + "update-failed", + "Should have update-failed badge status" + ); + + AppMenuNotifications.removeNotification(/^update-/); + is( + menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Should have fxa-needs-authentication badge status" + ); + + AppMenuNotifications.removeNotification(/^fxa-/); + is( + menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + + await gCUITestUtils.openMainMenu(); + is( + menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status (Hamburger menu opened)" + ); + await gCUITestUtils.hideMainMenu(); + + AppMenuNotifications.showBadgeOnlyNotification("fxa-needs-authentication"); + AppMenuNotifications.showBadgeOnlyNotification("update-succeeded"); + AppMenuNotifications.removeNotification(/.*/); + is( + menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + }); +}); + +/** + * Tests that non-badges also operate like a stack. + */ +add_task(async function testMultipleNonBadges() { + await BrowserTestUtils.withNewTab("about:blank", async function (browser) { + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + let updateManualAction = { + called: false, + callback: () => { + updateManualAction.called = true; + }, + }; + let updateRestartAction = { + called: false, + callback: () => { + updateRestartAction.called = true; + }, + }; + + AppMenuNotifications.showNotification("update-manual", updateManualAction); + + let notifications; + let doorhanger; + + isnot(PanelUI.notificationPanel.state, "closed", "Doorhanger is showing."); + notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + AppMenuNotifications.showNotification( + "update-restart", + updateRestartAction + ); + + isnot(PanelUI.notificationPanel.state, "closed", "Doorhanger is showing."); + notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-restart-notification", + "PanelUI is displaying the update-restart notification." + ); + + let secondaryActionButton = doorhanger.secondaryButton; + secondaryActionButton.click(); + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-restart", + "update-restart badge is displaying on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-restart", + "update-restart badge is displaying on PanelUI button." + ); + let menuItem = PanelUI.mainView.querySelector(".panel-banner-item"); + is( + menuItem.getAttribute("data-l10n-id"), + "appmenuitem-banner-update-restart", + "Showing correct label" + ); + is(menuItem.hidden, false, "update-restart menu item is showing."); + + menuItem.click(); + ok( + updateRestartAction.called, + "update-restart main action callback was called" + ); + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "update-manual badge is displaying on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "update-manual badge is displaying on PanelUI button." + ); + is( + menuItem.getAttribute("data-l10n-id"), + "appmenuitem-banner-update-manual", + "Showing correct label" + ); + is(menuItem.hidden, false, "update-manual menu item is showing."); + + menuItem.click(); + ok( + updateManualAction.called, + "update-manual main action callback was called" + ); + }); +}); diff --git a/browser/components/customizableui/test/browser_panelUINotifications_bannerVisibility.js b/browser/components/customizableui/test/browser_panelUINotifications_bannerVisibility.js new file mode 100644 index 0000000000..df856dd4cf --- /dev/null +++ b/browser/components/customizableui/test/browser_panelUINotifications_bannerVisibility.js @@ -0,0 +1,151 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AppMenuNotifications } = ChromeUtils.importESModule( + "resource://gre/modules/AppMenuNotifications.sys.mjs" +); + +/** + * The update banner should become visible when the badge-only notification is + * shown before opening the menu. + */ +add_task(async function testBannerVisibilityBeforeOpen() { + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + AppMenuNotifications.showBadgeOnlyNotification("update-restart"); + + let menuButton = newWin.document.getElementById("PanelUI-menu-button"); + let shown = BrowserTestUtils.waitForEvent( + newWin.PanelUI.mainView, + "ViewShown" + ); + menuButton.click(); + await shown; + + let banner = newWin.document.getElementById("appMenu-proton-update-banner"); + + let labelPromise = BrowserTestUtils.waitForMutationCondition( + banner, + { attributes: true, attributeFilter: ["label"] }, + () => banner.hasAttribute("label") + ); + + ok(!banner.hidden, "Update banner should be shown"); + + await labelPromise; + + Assert.notEqual( + banner.getAttribute("label"), + "", + "Update banner should contain text" + ); + + AppMenuNotifications.removeNotification(/.*/); + + await BrowserTestUtils.closeWindow(newWin); +}); + +/** + * The update banner should become visible when the badge-only notification is + * shown during the menu is opened. + */ +add_task(async function testBannerVisibilityDuringOpen() { + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + let menuButton = newWin.document.getElementById("PanelUI-menu-button"); + let shown = BrowserTestUtils.waitForEvent( + newWin.PanelUI.mainView, + "ViewShown" + ); + menuButton.click(); + await shown; + + let banner = newWin.document.getElementById("appMenu-proton-update-banner"); + ok( + !banner.hasAttribute("label"), + "Update banner shouldn't contain text before notification" + ); + + let labelPromise = BrowserTestUtils.waitForMutationCondition( + banner, + { attributes: true, attributeFilter: ["label"] }, + () => banner.hasAttribute("label") + ); + + AppMenuNotifications.showNotification("update-restart"); + + ok(!banner.hidden, "Update banner should be shown"); + + await labelPromise; + + Assert.notEqual( + banner.getAttribute("label"), + "", + "Update banner should contain text" + ); + + AppMenuNotifications.removeNotification(/.*/); + + await BrowserTestUtils.closeWindow(newWin); +}); + +/** + * The update banner should become visible when the badge-only notification is + * shown after opening/closing the menu, so that the DOM tree is there but + * the menu is closed. + */ +add_task(async function testBannerVisibilityAfterClose() { + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + let menuButton = newWin.document.getElementById("PanelUI-menu-button"); + let shown = BrowserTestUtils.waitForEvent( + newWin.PanelUI.mainView, + "ViewShown" + ); + menuButton.click(); + await shown; + + ok(newWin.PanelUI.mainView.hasAttribute("visible")); + + let banner = newWin.document.getElementById("appMenu-proton-update-banner"); + + ok(banner.hidden, "Update banner should be hidden before notification"); + ok( + !banner.hasAttribute("label"), + "Update banner shouldn't contain text before notification" + ); + + let labelPromise = BrowserTestUtils.waitForMutationCondition( + banner, + { attributes: true, attributeFilter: ["label"] }, + () => banner.hasAttribute("label") + ); + + let hidden = BrowserTestUtils.waitForCondition(() => { + return !newWin.PanelUI.mainView.hasAttribute("visible"); + }); + menuButton.click(); + await hidden; + + AppMenuNotifications.showBadgeOnlyNotification("update-restart"); + + shown = BrowserTestUtils.waitForEvent(newWin.PanelUI.mainView, "ViewShown"); + menuButton.click(); + await shown; + + ok(!banner.hidden, "Update banner should be shown"); + + await labelPromise; + + Assert.notEqual( + banner.getAttribute("label"), + "", + "Update banner should contain text" + ); + + AppMenuNotifications.removeNotification(/.*/); + + await BrowserTestUtils.closeWindow(newWin); +}); diff --git a/browser/components/customizableui/test/browser_panelUINotifications_fullscreen.js b/browser/components/customizableui/test/browser_panelUINotifications_fullscreen.js new file mode 100644 index 0000000000..4b3340696b --- /dev/null +++ b/browser/components/customizableui/test/browser_panelUINotifications_fullscreen.js @@ -0,0 +1,92 @@ +"use strict"; + +const { AppMenuNotifications } = ChromeUtils.importESModule( + "resource://gre/modules/AppMenuNotifications.sys.mjs" +); + +add_task(async function testFullscreen() { + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let popuphiddenPromise = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popuphidden" + ); + document.documentElement.focus(); + EventUtils.synthesizeKey("KEY_F11"); + await popuphiddenPromise; + await new Promise(executeSoon); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + FullScreen.showNavToolbox(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is displaying on PanelUI button." + ); + + let popupshownPromise = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popupshown" + ); + EventUtils.synthesizeKey("KEY_F11"); + await popupshownPromise; + await new Promise(executeSoon); + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + isnot( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is not displaying on PanelUI button." + ); + + doorhanger.button.click(); + ok(mainActionCalled, "Main action callback was called"); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); +}); diff --git a/browser/components/customizableui/test/browser_panelUINotifications_fullscreen_noAutoHideToolbar.js b/browser/components/customizableui/test/browser_panelUINotifications_fullscreen_noAutoHideToolbar.js new file mode 100644 index 0000000000..853c39e89f --- /dev/null +++ b/browser/components/customizableui/test/browser_panelUINotifications_fullscreen_noAutoHideToolbar.js @@ -0,0 +1,145 @@ +"use strict"; + +// This test tends to trigger a race in the fullscreen time telemetry, +// where the fullscreen enter and fullscreen exit events (which use the +// same histogram ID) overlap. That causes TelemetryStopwatch to log an +// error. +SimpleTest.ignoreAllUncaughtExceptions(true); + +const { AppMenuNotifications } = ChromeUtils.importESModule( + "resource://gre/modules/AppMenuNotifications.sys.mjs" +); + +function waitForDocshellActivated() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + // Setting docshell activated/deactivated will trigger visibility state + // changes to relevant state ("visible" or "hidden"). AFAIK, there is no + // such event notifying docshell is being activated, so I use + // "visibilitychange" event rather than polling the isActive flag. + await ContentTaskUtils.waitForEvent( + content.document, + "visibilitychange", + true /* capture */, + aEvent => { + return content.browsingContext.isActive; + } + ); + }); +} + +function waitForFullscreen() { + return Promise.all([ + BrowserTestUtils.waitForEvent(window, "fullscreen"), + // In the platforms that support reporting occlusion state (e.g. Mac), + // enter/exit fullscreen mode will trigger docshell being set to non-activate + // and then set to activate back again. For those platforms, we should wait + // until the docshell has been activated again before starting next test, + // otherwise, the fullscreen request might be denied. + Services.appinfo.OS === "Darwin" + ? waitForDocshellActivated() + : Promise.resolve(), + ]); +} + +add_task(async function testFullscreen() { + if (Services.appinfo.OS !== "Darwin") { + await SpecialPowers.pushPrefEnv({ + set: [["browser.fullscreen.autohide", false]], + }); + } + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + await BrowserTestUtils.waitForEvent(PanelUI.notificationPanel, "popupshown"); + + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let fullscreenPromise = waitForFullscreen(); + EventUtils.synthesizeKey("KEY_F11"); + await fullscreenPromise; + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is still showing after entering fullscreen." + ); + + let popuphiddenPromise = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popuphidden" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + content.document.documentElement.requestFullscreen(); + }); + await popuphiddenPromise; + await new Promise(executeSoon); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is hidden after entering DOM fullscreen." + ); + + let popupshownPromise = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popupshown" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + content.document.exitFullscreen(); + }); + await popupshownPromise; + await new Promise(executeSoon); + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is shown after exiting DOM fullscreen." + ); + isnot( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is not displaying on PanelUI button." + ); + + doorhanger.button.click(); + ok(mainActionCalled, "Main action callback was called"); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + + fullscreenPromise = BrowserTestUtils.waitForEvent(window, "fullscreen"); + EventUtils.synthesizeKey("KEY_F11"); + await fullscreenPromise; +}); diff --git a/browser/components/customizableui/test/browser_panelUINotifications_modals.js b/browser/components/customizableui/test/browser_panelUINotifications_modals.js new file mode 100644 index 0000000000..87be14fcee --- /dev/null +++ b/browser/components/customizableui/test/browser_panelUINotifications_modals.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AppMenuNotifications } = ChromeUtils.importESModule( + "resource://gre/modules/AppMenuNotifications.sys.mjs" +); + +add_task(async function testModals() { + await SpecialPowers.pushPrefEnv({ + set: [["prompts.windowPromptSubDialog", true]], + }); + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let popuphiddenPromise = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popuphidden" + ); + + let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen("accept"); + Services.prompt.asyncAlert( + window.browsingContext, + Services.prompt.MODAL_TYPE_INTERNAL_WINDOW, + "Test alert", + "Test alert description" + ); + await popuphiddenPromise; + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + let popupshownPromise = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popupshown" + ); + + await dialogPromise; + await popupshownPromise; + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + + doorhanger.button.click(); + ok(mainActionCalled, "Main action callback was called"); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); +}); diff --git a/browser/components/customizableui/test/browser_panelUINotifications_multiWindow.js b/browser/components/customizableui/test/browser_panelUINotifications_multiWindow.js new file mode 100644 index 0000000000..fd75763857 --- /dev/null +++ b/browser/components/customizableui/test/browser_panelUINotifications_multiWindow.js @@ -0,0 +1,214 @@ +"use strict"; + +const { AppMenuNotifications } = ChromeUtils.importESModule( + "resource://gre/modules/AppMenuNotifications.sys.mjs" +); + +/** + * Tests that when we try to show a notification in a background window, it + * does not display until the window comes back into the foreground. However, + * it should display a badge. + */ +add_task(async function testDoesNotShowDoorhangerForBackgroundWindow() { + let options = { + gBrowser: window.gBrowser, + url: "about:blank", + }; + + await BrowserTestUtils.withNewTab(options, async function (browser) { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await SimpleTest.promiseFocus(win); + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + is( + PanelUI.notificationPanel.state, + "closed", + "The background window's doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + true, + "The background window has a badge." + ); + + let popupShown = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popupshown" + ); + + await BrowserTestUtils.closeWindow(win); + await SimpleTest.promiseFocus(window); + await popupShown; + + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let button = doorhanger.button; + + Assert.equal( + PanelUI.notificationPanel.state, + "open", + "Expect panel state to be open when clicking panel buttons" + ); + button.click(); + + ok(mainActionCalled, "Main action callback was called"); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + }); +}); + +/** + * Tests that when we try to show a notification in a background window and in + * a foreground window, if the foreground window's main action is called, the + * background window's doorhanger will be removed. + */ +add_task( + async function testBackgroundWindowNotificationsAreRemovedByForeground() { + let options = { + gBrowser: window.gBrowser, + url: "about:blank", + }; + + await BrowserTestUtils.withNewTab(options, async function (browser) { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await SimpleTest.promiseFocus(win); + AppMenuNotifications.showNotification("update-manual", { callback() {} }); + let notifications = [...win.PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + doorhanger.button.click(); + + await BrowserTestUtils.closeWindow(win); + await SimpleTest.promiseFocus(window); + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + }); + } +); + +/** + * Tests that when we try to show a notification in a background window and in + * a foreground window, if the foreground window's doorhanger is dismissed, + * the background window's doorhanger will also be dismissed once the window + * regains focus. + */ +add_task( + async function testBackgroundWindowNotificationsAreDismissedByForeground() { + let options = { + gBrowser: window.gBrowser, + url: "about:blank", + }; + + await BrowserTestUtils.withNewTab(options, async function (browser) { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await SimpleTest.promiseFocus(win); + AppMenuNotifications.showNotification("update-manual", { callback() {} }); + let notifications = [...win.PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + let button = doorhanger.secondaryButton; + button.click(); + + await BrowserTestUtils.closeWindow(win); + await SimpleTest.promiseFocus(window); + + is( + PanelUI.notificationPanel.state, + "closed", + "The background window's doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + true, + "The dismissed notification should still have a badge status" + ); + + AppMenuNotifications.removeNotification(/.*/); + }); + } +); + +/** + * Tests that when we open a new window while a notification is showing, the + * notification also shows on the new window. + */ +add_task(async function testOpenWindowAfterShowingNotification() { + AppMenuNotifications.showNotification("update-manual", { callback() {} }); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + await SimpleTest.promiseFocus(win); + let notifications = [...win.PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + let button = doorhanger.secondaryButton; + button.click(); + + await BrowserTestUtils.closeWindow(win); + await SimpleTest.promiseFocus(window); + + is( + PanelUI.notificationPanel.state, + "closed", + "The background window's doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + true, + "The dismissed notification should still have a badge status" + ); + + AppMenuNotifications.removeNotification(/.*/); +}); diff --git a/browser/components/customizableui/test/browser_panel_keyboard_navigation.js b/browser/components/customizableui/test/browser_panel_keyboard_navigation.js new file mode 100644 index 0000000000..9b5ad48cce --- /dev/null +++ b/browser/components/customizableui/test/browser_panel_keyboard_navigation.js @@ -0,0 +1,326 @@ +"use strict"; + +/** + * Test keyboard navigation in the app menu panel. + */ + +const kHelpButtonId = "appMenu-help-button2"; + +function getEnabledNavigableElementsForView(panelView) { + return Array.from( + panelView.querySelectorAll("button,toolbarbutton,menulist,.text-link") + ).filter(element => { + let bounds = element.getBoundingClientRect(); + return !element.disabled && bounds.width > 0 && bounds.height > 0; + }); +} + +add_task(async function testUpDownKeys() { + await gCUITestUtils.openMainMenu(); + + let buttons = getEnabledNavigableElementsForView(PanelUI.mainView); + + for (let button of buttons) { + if (button.disabled) { + continue; + } + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + document.commandDispatcher.focusedElement, + button, + "The correct button should be focused after navigating downward" + ); + } + + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + document.commandDispatcher.focusedElement, + buttons[0], + "Pressing upwards should cycle around and select the first button again" + ); + + for (let i = buttons.length - 1; i >= 0; --i) { + let button = buttons[i]; + if (button.disabled) { + continue; + } + EventUtils.synthesizeKey("KEY_ArrowUp"); + Assert.equal( + document.commandDispatcher.focusedElement, + button, + "The first button should be focused after navigating upward" + ); + } + + await gCUITestUtils.hideMainMenu(); +}); + +add_task(async function testHomeEndKeys() { + await gCUITestUtils.openMainMenu(); + + let buttons = getEnabledNavigableElementsForView(PanelUI.mainView); + let enabledButtons = buttons.filter(btn => !btn.disabled); + let firstButton = enabledButtons[0]; + let lastButton = enabledButtons.pop(); + + Assert.ok(firstButton != lastButton, "There is more than one button"); + + EventUtils.synthesizeKey("KEY_End"); + Assert.equal( + document.commandDispatcher.focusedElement, + lastButton, + "The last button should be focused after pressing End" + ); + + EventUtils.synthesizeKey("KEY_Home"); + Assert.equal( + document.commandDispatcher.focusedElement, + firstButton, + "The first button should be focused after pressing Home" + ); + + await gCUITestUtils.hideMainMenu(); +}); + +add_task(async function testEnterKeyBehaviors() { + await gCUITestUtils.openMainMenu(); + + let buttons = getEnabledNavigableElementsForView(PanelUI.mainView); + + // Navigate to the 'Help' button, which points to a subview. + EventUtils.synthesizeKey("KEY_ArrowUp"); + let focusedElement = document.commandDispatcher.focusedElement; + Assert.equal( + focusedElement, + buttons[buttons.length - 1], + "The last button should be focused after navigating upward" + ); + + // Make sure the Help button is in focus. + while ( + !focusedElement || + !focusedElement.id || + focusedElement.id != kHelpButtonId + ) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + focusedElement = document.commandDispatcher.focusedElement; + } + EventUtils.synthesizeKey("KEY_Enter"); + + let helpView = document.getElementById("PanelUI-helpView"); + await BrowserTestUtils.waitForEvent(helpView, "ViewShown"); + + let helpButtons = getEnabledNavigableElementsForView(helpView); + Assert.ok( + helpButtons[0].classList.contains("subviewbutton-back"), + "First button in help view should be a back button" + ); + + // For posterity, check navigating the subview using up/ down arrow keys as well. + // When opening a subview, the first control *after* the Back button gets + // focus. + EventUtils.synthesizeKey("KEY_ArrowUp"); + focusedElement = document.commandDispatcher.focusedElement; + Assert.equal( + focusedElement, + helpButtons[0], + "The Back button should be focused after navigating upward" + ); + for (let i = helpButtons.length - 1; i >= 0; --i) { + let button = helpButtons[i]; + if (button.disabled) { + continue; + } + EventUtils.synthesizeKey("KEY_ArrowUp"); + focusedElement = document.commandDispatcher.focusedElement; + Assert.equal( + focusedElement, + button, + "The previous button should be focused after navigating upward" + ); + } + + // Make sure the back button is in focus again. + while (focusedElement != helpButtons[0]) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + focusedElement = document.commandDispatcher.focusedElement; + } + + // The first button is the back button. Hittin Enter should navigate us back. + let promise = BrowserTestUtils.waitForEvent(PanelUI.mainView, "ViewShown"); + EventUtils.synthesizeKey("KEY_Enter"); + await promise; + + // Let's test a 'normal' command button. + focusedElement = document.commandDispatcher.focusedElement; + const kFindButtonId = "appMenu-find-button2"; + while ( + !focusedElement || + !focusedElement.id || + focusedElement.id != kFindButtonId + ) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + focusedElement = document.commandDispatcher.focusedElement; + } + let findBarPromise = gBrowser.isFindBarInitialized() + ? null + : BrowserTestUtils.waitForEvent(gBrowser.selectedTab, "TabFindInitialized"); + Assert.equal( + focusedElement.id, + kFindButtonId, + "Find button should be selected" + ); + + await gCUITestUtils.hidePanelMultiView(PanelUI.panel, () => + EventUtils.synthesizeKey("KEY_Enter") + ); + + await findBarPromise; + Assert.ok(!gFindBar.hidden, "Findbar should have opened"); + gFindBar.close(); +}); + +add_task(async function testLeftRightKeys() { + await gCUITestUtils.openMainMenu(); + + // Navigate to the 'Help' button, which points to a subview. + let focusedElement = document.commandDispatcher.focusedElement; + while ( + !focusedElement || + !focusedElement.id || + focusedElement.id != kHelpButtonId + ) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + focusedElement = document.commandDispatcher.focusedElement; + } + Assert.equal( + focusedElement.id, + kHelpButtonId, + "The last button should be focused after navigating upward" + ); + + // Hitting ArrowRight on a button that points to a subview should navigate us + // there. + EventUtils.synthesizeKey("KEY_ArrowRight"); + let helpView = document.getElementById("PanelUI-helpView"); + await BrowserTestUtils.waitForEvent(helpView, "ViewShown"); + + // Hitting ArrowLeft should navigate us back. + let promise = BrowserTestUtils.waitForEvent(PanelUI.mainView, "ViewShown"); + EventUtils.synthesizeKey("KEY_ArrowLeft"); + await promise; + + focusedElement = document.commandDispatcher.focusedElement; + Assert.equal( + focusedElement.id, + kHelpButtonId, + "Help button should be focused again now that we're back in the main view" + ); + + await gCUITestUtils.hideMainMenu(); +}); + +add_task(async function testTabKey() { + await gCUITestUtils.openMainMenu(); + + let buttons = getEnabledNavigableElementsForView(PanelUI.mainView); + + for (let button of buttons) { + if (button.disabled) { + continue; + } + EventUtils.synthesizeKey("KEY_Tab"); + Assert.equal( + document.commandDispatcher.focusedElement, + button, + "The correct button should be focused after tabbing" + ); + } + + EventUtils.synthesizeKey("KEY_Tab"); + Assert.equal( + document.commandDispatcher.focusedElement, + buttons[0], + "Pressing tab should cycle around and select the first button again" + ); + + for (let i = buttons.length - 1; i >= 0; --i) { + let button = buttons[i]; + if (button.disabled) { + continue; + } + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + Assert.equal( + document.commandDispatcher.focusedElement, + button, + "The correct button should be focused after shift + tabbing" + ); + } + + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + Assert.equal( + document.commandDispatcher.focusedElement, + buttons[buttons.length - 1], + "Pressing shift + tab should cycle around and select the last button again" + ); + + await gCUITestUtils.hideMainMenu(); +}); + +add_task(async function testInterleavedTabAndArrowKeys() { + await gCUITestUtils.openMainMenu(); + + let buttons = getEnabledNavigableElementsForView(PanelUI.mainView); + let tab = false; + + for (let button of buttons) { + if (button.disabled) { + continue; + } + if (tab) { + EventUtils.synthesizeKey("KEY_Tab"); + } else { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + tab = !tab; + } + + Assert.equal( + document.commandDispatcher.focusedElement, + buttons[buttons.length - 1], + "The last button should be focused after a mix of Tab and ArrowDown" + ); + + await gCUITestUtils.hideMainMenu(); +}); + +add_task(async function testSpaceDownAfterTabNavigation() { + await gCUITestUtils.openMainMenu(); + + let buttons = getEnabledNavigableElementsForView(PanelUI.mainView); + let button; + + for (button of buttons) { + if (button.disabled) { + continue; + } + EventUtils.synthesizeKey("KEY_Tab"); + if (button.id == kHelpButtonId) { + break; + } + } + + Assert.equal( + document.commandDispatcher.focusedElement, + button, + "Help button should be focused after tabbing to it." + ); + + // Pressing down space on a button that points to a subview should navigate us + // there, before keyup. + EventUtils.synthesizeKey(" ", { type: "keydown" }); + let helpView = document.getElementById("PanelUI-helpView"); + await BrowserTestUtils.waitForEvent(helpView, "ViewShown"); + + await gCUITestUtils.hideMainMenu(); +}); diff --git a/browser/components/customizableui/test/browser_panel_locationSpecific.js b/browser/components/customizableui/test/browser_panel_locationSpecific.js new file mode 100644 index 0000000000..9965a141b2 --- /dev/null +++ b/browser/components/customizableui/test/browser_panel_locationSpecific.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* + * This test creates multiple panels, one that has been tagged as location specific + * and one that isn't. When the location changes, the specific panel should close. + * The non-specific panel should remain open. + * + */ + +add_task(async function () { + let specificPanel = document.createXULElement("panel"); + specificPanel.setAttribute("locationspecific", "true"); + specificPanel.setAttribute("noautohide", "true"); + specificPanel.style.height = "100px"; + specificPanel.style.width = "100px"; + + let generalPanel = document.createXULElement("panel"); + generalPanel.setAttribute("noautohide", "true"); + generalPanel.style.height = "100px"; + generalPanel.style.width = "100px"; + + let anchor = document.getElementById(CustomizableUI.AREA_NAVBAR); + + anchor.appendChild(specificPanel); + anchor.appendChild(generalPanel); + is(specificPanel.state, "closed", "specificPanel starts as closed"); + is(generalPanel.state, "closed", "generalPanel starts as closed"); + + let specificPanelPromise = BrowserTestUtils.waitForEvent( + specificPanel, + "popupshown" + ); + + specificPanel.openPopupAtScreen(0, 0); + + await specificPanelPromise; + is(specificPanel.state, "open", "specificPanel has been opened"); + + let generalPanelPromise = BrowserTestUtils.waitForEvent( + generalPanel, + "popupshown" + ); + + generalPanel.openPopupAtScreen(100, 0); + + await generalPanelPromise; + is(generalPanel.state, "open", "generalPanel has been opened"); + + let specificPanelHiddenPromise = BrowserTestUtils.waitForEvent( + specificPanel, + "popuphidden" + ); + + // Simulate a location change, and check which panel closes. + let browser = gBrowser.selectedBrowser; + let loaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.startLoadingURIString(browser, "http://mochi.test:8888/#0"); + await loaded; + + await specificPanelHiddenPromise; + + is( + specificPanel.state, + "closed", + "specificPanel panel is closed after location change" + ); + is( + generalPanel.state, + "open", + "generalPanel is still open after location change" + ); + + specificPanel.remove(); + generalPanel.remove(); +}); diff --git a/browser/components/customizableui/test/browser_panel_menulist.js b/browser/components/customizableui/test/browser_panel_menulist.js new file mode 100644 index 0000000000..c863a872ee --- /dev/null +++ b/browser/components/customizableui/test/browser_panel_menulist.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const kViewID = "panelview-with-menulist"; + +/** + * When there's a menulist inside a panelview, closing it shouldn't close the panel. + */ +add_task(async function test_closing_menulist_should_not_close_panel() { + let viewCache = document.getElementById("appMenu-viewCache"); + let panelview = document.createXULElement("panelview"); + panelview.id = kViewID; + let menulist = document.createXULElement("menulist"); + let popup = document.createXULElement("menupopup"); + for (let item of ["one", "two"]) { + let menuitem = document.createXULElement("menuitem"); + menuitem.id = `menuitem-${item}`; + menuitem.setAttribute("label", item); + popup.append(menuitem); + } + menulist.append(popup); + panelview.append(menulist); + viewCache.append(panelview); + await PanelUI.showSubView(kViewID, PanelUI.menuButton); + let panel = panelview.closest("panel"); + + // Ensure that not only has the subview started showing, the panel is + // all the way open: + await BrowserTestUtils.waitForPopupEvent(panel, "shown"); + + registerCleanupFunction(async () => { + if (panel && panel.state != "closed") { + let panelGone = BrowserTestUtils.waitForPopupEvent(panel, "hidden"); + panel.hidePopup(); + await panelGone; + } + panelview.remove(); + }); + + let shown = BrowserTestUtils.waitForPopupEvent(popup, "shown"); + menulist.openMenu(true); + await shown; + let hidden = BrowserTestUtils.waitForPopupEvent(popup, "hidden"); + popup.activateItem(popup.firstElementChild); + await hidden; + + Assert.equal(panel?.state, "open", "Panel should still be open."); +}); diff --git a/browser/components/customizableui/test/browser_panel_toggle.js b/browser/components/customizableui/test/browser_panel_toggle.js new file mode 100644 index 0000000000..cba441e8e5 --- /dev/null +++ b/browser/components/customizableui/test/browser_panel_toggle.js @@ -0,0 +1,53 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Test opening and closing the menu panel UI. + */ + +// Show and hide the menu panel programmatically without an event (like UITour.sys.mjs would) +add_task(async function () { + await gCUITestUtils.openMainMenu(); + + is( + PanelUI.panel.getAttribute("panelopen"), + "true", + "Check that panel has panelopen attribute" + ); + is(PanelUI.panel.state, "open", "Check that panel state is 'open'"); + + await gCUITestUtils.hideMainMenu(); + + ok( + !PanelUI.panel.hasAttribute("panelopen"), + "Check that panel doesn't have the panelopen attribute" + ); + is(PanelUI.panel.state, "closed", "Check that panel state is 'closed'"); +}); + +// Toggle the menu panel open and closed +add_task(async function () { + await gCUITestUtils.openPanelMultiView(PanelUI.panel, PanelUI.mainView, () => + PanelUI.toggle({ type: "command" }) + ); + + is( + PanelUI.panel.getAttribute("panelopen"), + "true", + "Check that panel has panelopen attribute" + ); + is(PanelUI.panel.state, "open", "Check that panel state is 'open'"); + + await gCUITestUtils.hidePanelMultiView(PanelUI.panel, () => + PanelUI.toggle({ type: "command" }) + ); + + ok( + !PanelUI.panel.hasAttribute("panelopen"), + "Check that panel doesn't have the panelopen attribute" + ); + is(PanelUI.panel.state, "closed", "Check that panel state is 'closed'"); +}); diff --git a/browser/components/customizableui/test/browser_proton_moreTools_panel.js b/browser/components/customizableui/test/browser_proton_moreTools_panel.js new file mode 100644 index 0000000000..8104b8920e --- /dev/null +++ b/browser/components/customizableui/test/browser_proton_moreTools_panel.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineLazyGetter(this, "DevToolsStartup", () => { + return Cc["@mozilla.org/devtools/startup-clh;1"].getService( + Ci.nsICommandLineHandler + ).wrappedJSObject; +}); + +// Test activating the developer button shows the More Tools panel. +add_task(async function testDevToolsPanelInToolbar() { + // We need to force DevToolsStartup to rebuild the developer tool toggle so that + // proton prefs are applied to the new browser window for this test. + DevToolsStartup.developerToggleCreated = false; + CustomizableUI.destroyWidget("developer-button"); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + + CustomizableUI.addWidgetToArea( + "developer-button", + CustomizableUI.AREA_NAVBAR + ); + + // Test the developer tools panel is showing. + let button = document.getElementById("developer-button"); + let devToolsView = PanelMultiView.getViewNode( + document, + "PanelUI-developer-tools" + ); + let devToolsShownPromise = BrowserTestUtils.waitForEvent( + devToolsView, + "ViewShown" + ); + + EventUtils.synthesizeMouseAtCenter(button, {}); + await devToolsShownPromise; + ok(true, "Dev Tools view is showing"); + is( + devToolsView.children.length, + 1, + "Dev tools subview is the only child of panel" + ); + is( + devToolsView.children[0].id, + "PanelUI-developer-tools-view", + "Dev tools child has correct id" + ); + + // Cleanup + await BrowserTestUtils.closeWindow(win); + CustomizableUI.reset(); +}); diff --git a/browser/components/customizableui/test/browser_proton_toolbar_hide_toolbarbuttons.js b/browser/components/customizableui/test/browser_proton_toolbar_hide_toolbarbuttons.js new file mode 100644 index 0000000000..ac46fd12ae --- /dev/null +++ b/browser/components/customizableui/test/browser_proton_toolbar_hide_toolbarbuttons.js @@ -0,0 +1,285 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + HomePage: "resource:///modules/HomePage.sys.mjs", +}); + +const kPrefProtonToolbarVersion = "browser.proton.toolbar.version"; +const kPrefHomeButtonUsed = "browser.engagement.home-button.has-used"; +const kPrefLibraryButtonUsed = "browser.engagement.library-button.has-used"; +const kPrefSidebarButtonUsed = "browser.engagement.sidebar-button.has-used"; + +async function testToolbarButtons(aActions) { + let { + shouldRemoveHomeButton, + shouldRemoveLibraryButton, + shouldRemoveSidebarButton, + shouldUpdateVersion, + } = aActions; + const defaultPlacements = [ + "back-button", + "forward-button", + "stop-reload-button", + "home-button", + "customizableui-special-spring1", + "urlbar-container", + "customizableui-special-spring2", + "downloads-button", + "library-button", + "sidebar-button", + "fxa-toolbar-menu-button", + ]; + let oldState = CustomizableUI.getTestOnlyInternalProp("gSavedState"); + + Assert.equal( + Services.prefs.getIntPref(kPrefProtonToolbarVersion), + 0, + "Toolbar proton version is 0" + ); + + let CustomizableUIInternal = CustomizableUI.getTestOnlyInternalProp( + "CustomizableUIInternal" + ); + + CustomizableUI.setTestOnlyInternalProp("gSavedState", { + placements: { + "nav-bar": defaultPlacements, + }, + }); + CustomizableUIInternal._updateForNewProtonVersion(); + + let navbarPlacements = + CustomizableUI.getTestOnlyInternalProp("gSavedState").placements["nav-bar"]; + let includesHomeButton = navbarPlacements.includes("home-button"); + let includesLibraryButton = navbarPlacements.includes("library-button"); + let includesSidebarButton = navbarPlacements.includes("sidebar-button"); + + Assert.equal( + !includesHomeButton, + shouldRemoveHomeButton, + "Correctly handles home button" + ); + Assert.equal( + !includesLibraryButton, + shouldRemoveLibraryButton, + "Correctly handles library button" + ); + Assert.equal( + !includesSidebarButton, + shouldRemoveSidebarButton, + "Correctly handles sidebar button" + ); + + let toolbarVersion = Services.prefs.getIntPref(kPrefProtonToolbarVersion); + if (shouldUpdateVersion) { + Assert.ok(toolbarVersion >= 1, "Toolbar proton version updated"); + } else { + Assert.ok(toolbarVersion == 0, "Toolbar proton version not updated"); + } + + // Cleanup + CustomizableUI.setTestOnlyInternalProp("gSavedState", oldState); +} + +/** + * Checks that the home button is removed from the nav-bar under + * these conditions: proton must be enabled, the toolbar engagement + * pref is false, and the homepage is about:home or about:blank. + * Otherwise, the home button should remain if it was previously + * in the navbar. + * Also checks that the library button is removed from the nav-bar + * if proton is enabled and the toolbar engagement pref is false. + */ +add_task(async function testButtonRemoval() { + // Ensure the engagement prefs are set to their default values + await SpecialPowers.pushPrefEnv({ + set: [ + [kPrefHomeButtonUsed, false], + [kPrefLibraryButtonUsed, false], + [kPrefSidebarButtonUsed, false], + ], + }); + + let tests = [ + // Proton enabled without home and library engagement + { + prefs: [], + actions: { + shouldRemoveHomeButton: true, + shouldRemoveLibraryButton: true, + shouldRemoveSidebarButton: true, + shouldUpdateVersion: true, + }, + }, + // Proton enabled with home engagement + { + prefs: [[kPrefHomeButtonUsed, true]], + actions: { + shouldRemoveHomeButton: false, + shouldRemoveLibraryButton: true, + shouldRemoveSidebarButton: true, + shouldUpdateVersion: true, + }, + }, + // Proton enabled with custom homepage + { + prefs: [], + actions: { + shouldRemoveHomeButton: false, + shouldRemoveLibraryButton: true, + shouldRemoveSidebarButton: true, + shouldUpdateVersion: true, + }, + async fn() { + HomePage.safeSet("https://example.com"); + }, + }, + // Proton enabled with library engagement + { + prefs: [[kPrefLibraryButtonUsed, true]], + actions: { + shouldRemoveHomeButton: true, + shouldRemoveLibraryButton: false, + shouldRemoveSidebarButton: true, + shouldUpdateVersion: true, + }, + }, + // Proton enabled with sidebar engagement + { + prefs: [[kPrefSidebarButtonUsed, true]], + actions: { + shouldRemoveHomeButton: true, + shouldRemoveLibraryButton: true, + shouldRemoveSidebarButton: false, + shouldUpdateVersion: true, + }, + }, + ]; + + for (let test of tests) { + await SpecialPowers.pushPrefEnv({ + set: [[kPrefProtonToolbarVersion, 0], ...test.prefs], + }); + if (test.fn) { + await test.fn(); + } + testToolbarButtons(test.actions); + HomePage.reset(); + await SpecialPowers.popPrefEnv(); + } +}); + +/** + * Checks that a null saved state (new profile) does not prevent migration. + */ +add_task(async function testNullSavedState() { + await SpecialPowers.pushPrefEnv({ + set: [[kPrefProtonToolbarVersion, 0]], + }); + let oldState = CustomizableUI.getTestOnlyInternalProp("gSavedState"); + + Assert.equal( + Services.prefs.getIntPref(kPrefProtonToolbarVersion), + 0, + "Toolbar proton version is 0" + ); + + let CustomizableUIInternal = CustomizableUI.getTestOnlyInternalProp( + "CustomizableUIInternal" + ); + CustomizableUIInternal.initialize(); + + Assert.ok( + Services.prefs.getIntPref(kPrefProtonToolbarVersion) >= 1, + "Toolbar proton version updated" + ); + let navbarPlacements = CustomizableUI.getTestOnlyInternalProp("gAreas") + .get("nav-bar") + .get("defaultPlacements"); + Assert.ok( + !navbarPlacements.includes("home-button"), + "Home button isn't included by default" + ); + Assert.ok( + !navbarPlacements.includes("library-button"), + "Library button isn't included by default" + ); + Assert.ok( + !navbarPlacements.includes("sidebar-button"), + "Sidebar button isn't included by default" + ); + + // Cleanup + CustomizableUI.setTestOnlyInternalProp("gSavedState", oldState); + await SpecialPowers.popPrefEnv(); + // Re-initialize to prevent future test failures + CustomizableUIInternal.initialize(); +}); + +/** + * Checks that a saved state that is missing nav-bar placements does not prevent migration. + */ +add_task(async function testNoNavbarPlacements() { + await SpecialPowers.pushPrefEnv({ + set: [[kPrefProtonToolbarVersion, 0]], + }); + + let oldState = CustomizableUI.getTestOnlyInternalProp("gSavedState"); + + Assert.equal( + Services.prefs.getIntPref(kPrefProtonToolbarVersion), + 0, + "Toolbar proton version is 0" + ); + + let CustomizableUIInternal = CustomizableUI.getTestOnlyInternalProp( + "CustomizableUIInternal" + ); + + CustomizableUI.setTestOnlyInternalProp("gSavedState", { + placements: { "widget-overflow-fixed-list": [] }, + }); + CustomizableUIInternal._updateForNewProtonVersion(); + + Assert.ok(true, "_updateForNewProtonVersion didn't throw"); + + // Cleanup + CustomizableUI.setTestOnlyInternalProp("gSavedState", oldState); + + await SpecialPowers.popPrefEnv(); +}); + +/** + * Checks that a saved state that is missing the placements value does not prevent migration. + */ +add_task(async function testNullPlacements() { + await SpecialPowers.pushPrefEnv({ + set: [[kPrefProtonToolbarVersion, 0]], + }); + + let oldState = CustomizableUI.getTestOnlyInternalProp("gSavedState"); + + Assert.equal( + Services.prefs.getIntPref(kPrefProtonToolbarVersion), + 0, + "Toolbar proton version is 0" + ); + + let CustomizableUIInternal = CustomizableUI.getTestOnlyInternalProp( + "CustomizableUIInternal" + ); + + CustomizableUI.setTestOnlyInternalProp("gSavedState", {}); + CustomizableUIInternal._updateForNewProtonVersion(); + + Assert.ok(true, "_updateForNewProtonVersion didn't throw"); + + // Cleanup + CustomizableUI.setTestOnlyInternalProp("gSavedState", oldState); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/customizableui/test/browser_registerArea.js b/browser/components/customizableui/test/browser_registerArea.js new file mode 100644 index 0000000000..2900c9eb8b --- /dev/null +++ b/browser/components/customizableui/test/browser_registerArea.js @@ -0,0 +1,28 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that a toolbar area can be registered with overflowable: false + * as one of its properties, and this results in a non-overflowable + * toolbar. + */ +add_task(async function test_overflowable_false() { + registerCleanupFunction(removeCustomToolbars); + + const kToolbarId = "no-overflow-toolbar"; + createToolbarWithPlacements(kToolbarId, ["spring"], { + overflowable: false, + }); + + let node = CustomizableUI.getWidget(kToolbarId).forWindow(window).node; + Assert.ok( + !node.hasAttribute("overflowable"), + "Toolbar should not be overflowable" + ); + Assert.ok( + !node.overflowable, + "OverflowableToolbar instance should not have been created." + ); +}); diff --git a/browser/components/customizableui/test/browser_reload_tab.js b/browser/components/customizableui/test/browser_reload_tab.js new file mode 100644 index 0000000000..ba44ba1e34 --- /dev/null +++ b/browser/components/customizableui/test/browser_reload_tab.js @@ -0,0 +1,103 @@ +"use strict"; + +/** + * Check that customize mode doesn't break when its tab is reloaded. + */ +add_task(async function reload_tab() { + let initialTab = gBrowser.selectedTab; + let customizeTab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + gCustomizeMode.setTab(customizeTab); + let customizationContainer = document.getElementById( + "customization-container" + ); + + is( + customizationContainer.clientWidth, + 0, + "Customization container shouldn't be visible (X)" + ); + is( + customizationContainer.clientHeight, + 0, + "Customization container shouldn't be visible (Y)" + ); + + let customizePromise = BrowserTestUtils.waitForEvent( + gNavToolbox, + "customizationready" + ); + gCustomizeMode.enter(); + await customizePromise; + + let tabReloaded = new Promise(resolve => { + gBrowser.addTabsProgressListener({ + async onLocationChange(aBrowser) { + if (customizeTab.linkedBrowser == aBrowser) { + gBrowser.removeTabsProgressListener(this); + await Promise.resolve(); + resolve(); + } + }, + }); + }); + gBrowser.reloadTab(customizeTab); + await tabReloaded; + + is( + gBrowser.getIcon(customizeTab), + "chrome://browser/skin/customize.svg", + "Tab should have customize icon" + ); + is( + customizeTab.getAttribute("customizemode"), + "true", + "Tab should be in customize mode" + ); + Assert.greater( + customizationContainer.clientWidth, + 0, + "Customization container should be visible (X)" + ); + Assert.greater( + customizationContainer.clientHeight, + 0, + "Customization container should be visible (Y)" + ); + + customizePromise = BrowserTestUtils.waitForEvent( + gNavToolbox, + "aftercustomization" + ); + await BrowserTestUtils.switchTab(gBrowser, initialTab); + await customizePromise; + + customizePromise = BrowserTestUtils.waitForEvent( + gNavToolbox, + "customizationready" + ); + await BrowserTestUtils.switchTab(gBrowser, customizeTab); + await customizePromise; + + is( + gBrowser.getIcon(customizeTab), + "chrome://browser/skin/customize.svg", + "Tab should still have customize icon" + ); + is( + customizeTab.getAttribute("customizemode"), + "true", + "Tab should still be in customize mode" + ); + Assert.greater( + customizationContainer.clientWidth, + 0, + "Customization container should still be visible (X)" + ); + Assert.greater( + customizationContainer.clientHeight, + 0, + "Customization container should still be visible (Y)" + ); + + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_remote_attribute.js b/browser/components/customizableui/test/browser_remote_attribute.js new file mode 100644 index 0000000000..543e62e2bc --- /dev/null +++ b/browser/components/customizableui/test/browser_remote_attribute.js @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * These tests check that the remote attribute is true for remote panels. + * This attribute is needed for Mac to properly render the panel. + */ +add_task(async function check_remote_attribute() { + // The panel is created on the fly, so we can't simply wait for focus + // inside it. + //let pocketPanelShown = BrowserTestUtils.waitForEvent( + // document, + // "popupshown", + // true + //); + let pocketPanelShown = popupShown(document); + // Using Pocket panel as it's an available remote panel. + let pocketButton = document.getElementById("save-to-pocket-button"); + pocketButton.click(); + await pocketPanelShown; + + let pocketPanel = document.getElementById("customizationui-widget-panel"); + is( + pocketPanel.getAttribute("remote"), + "true", + "Pocket panel has remote attribute" + ); + + // Close panel and cleanup. + let pocketPanelHidden = popupHidden(pocketPanel); + pocketPanel.hidePopup(); + await pocketPanelHidden; +}); + +add_task(async function check_remote_attribute_overflow() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + let overflowPanel = win.document.getElementById("widget-overflow"); + overflowPanel.setAttribute("animate", "false"); + + // Force a narrow window to get an overflow toolbar. + win.resizeTo(kForceOverflowWidthPx, win.outerHeight); + let navbar = win.document.getElementById(CustomizableUI.AREA_NAVBAR); + await TestUtils.waitForCondition(() => navbar.hasAttribute("overflowing")); + + // Open the overflow panel view. + let overflowPanelShown = popupShown(overflowPanel); + let overflowPanelButton = win.document.getElementById( + "nav-bar-overflow-button" + ); + overflowPanelButton.click(); + await overflowPanelShown; + + // Using Pocket panel as it's an available remote panel. + let pocketButton = win.document.getElementById("save-to-pocket-button"); + pocketButton.click(); + await BrowserTestUtils.waitForEvent(win.document, "ViewShown"); + + is( + overflowPanel.getAttribute("remote"), + "true", + "Pocket overflow panel has remote attribute" + ); + + // Close panel and cleanup. + let overflowPanelHidden = popupHidden(overflowPanel); + overflowPanel.hidePopup(); + await overflowPanelHidden; + overflowPanel.removeAttribute("animate"); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/customizableui/test/browser_remote_tabs_button.js b/browser/components/customizableui/test/browser_remote_tabs_button.js new file mode 100644 index 0000000000..094335d4b1 --- /dev/null +++ b/browser/components/customizableui/test/browser_remote_tabs_button.js @@ -0,0 +1,100 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +"use strict"; + +let { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { UIState } = ChromeUtils.importESModule( + "resource://services-sync/UIState.sys.mjs" +); + +let getState; +let originalSync; +let syncWasCalled = false; + +// TODO: This test should probably be re-written, we don't really test much here. +add_task(async function testSyncRemoteTabsButtonFunctionality() { + info("Test the Sync Remote Tabs button in the panel"); + storeInitialValues(); + mockFunctions(); + + // Force UI update. + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + // add the sync remote tabs button to the panel + CustomizableUI.addWidgetToArea( + "sync-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + await waitForOverflowButtonShown(); + + // check the button's functionality + await document.getElementById("nav-bar").overflowable.show(); + info("The panel menu was opened"); + + let syncRemoteTabsBtn = document.getElementById("sync-button"); + ok( + syncRemoteTabsBtn, + "The sync remote tabs button was added to the Panel Menu" + ); + // click the button - the panel should open. + syncRemoteTabsBtn.click(); + let remoteTabsPanel = document.getElementById("PanelUI-remotetabs"); + let viewShown = BrowserTestUtils.waitForEvent(remoteTabsPanel, "ViewShown"); + await viewShown; + ok(remoteTabsPanel.getAttribute("visible"), "Sync Panel is in view"); + + // Find and click the "setup" button. + let syncNowButton = document.getElementById("PanelUI-remotetabs-syncnow"); + syncNowButton.click(); + info("The sync now button was clicked"); + + await TestUtils.waitForCondition(() => syncWasCalled); + + // We need to stop the Syncing animation manually otherwise the button + // will be disabled at the beginning of a next test. + gSync._onActivityStop(); +}); + +add_task(async function asyncCleanup() { + // reset the panel UI to the default state + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "The panel UI is in default state again."); + + if (isOverflowOpen()) { + let panelHidePromise = promiseOverflowHidden(window); + PanelUI.overflowPanel.hidePopup(); + await panelHidePromise; + } + + restoreValues(); +}); + +function mockFunctions() { + // mock UIState.get() + UIState.get = () => ({ + status: UIState.STATUS_SIGNED_IN, + lastSync: new Date(), + email: "user@mozilla.com", + }); + + Service.sync = mocked_sync; +} + +function mocked_sync() { + syncWasCalled = true; +} + +function restoreValues() { + UIState.get = getState; + Service.sync = originalSync; +} + +function storeInitialValues() { + getState = UIState.get; + originalSync = Service.sync; +} diff --git a/browser/components/customizableui/test/browser_remove_customized_specials.js b/browser/components/customizableui/test/browser_remove_customized_specials.js new file mode 100644 index 0000000000..1f123d10cb --- /dev/null +++ b/browser/components/customizableui/test/browser_remove_customized_specials.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that after a reset, we can still drag special nodes in customize mode + */ +add_task(async function () { + await startCustomizing(); + CustomizableUI.addWidgetToArea("spring", "nav-bar", 5); + await gCustomizeMode.reset(); + let springs = document.querySelectorAll("#nav-bar toolbarspring"); + let lastSpring = springs[springs.length - 1]; + let expectedPlacements = CustomizableUI.getWidgetIdsInArea("nav-bar"); + info("Placements before drag: " + expectedPlacements.join(",")); + let lastItem = document.getElementById( + expectedPlacements[expectedPlacements.length - 1] + ); + await waitForElementShown(lastItem); + simulateItemDrag(lastSpring, lastItem, "end"); + expectedPlacements.splice(expectedPlacements.indexOf(lastSpring.id), 1); + expectedPlacements.push(lastSpring.id); + let actualPlacements = CustomizableUI.getWidgetIdsInArea("nav-bar"); + // Log these separately because Assert.deepEqual truncates the stringified versions... + info("Actual placements: " + actualPlacements.join(",")); + info("Expected placements: " + expectedPlacements.join(",")); + Assert.deepEqual( + expectedPlacements, + actualPlacements, + "Should be able to move spring" + ); + await gCustomizeMode.reset(); + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_reset_builtin_widget_currentArea.js b/browser/components/customizableui/test/browser_reset_builtin_widget_currentArea.js new file mode 100644 index 0000000000..fa9e497734 --- /dev/null +++ b/browser/components/customizableui/test/browser_reset_builtin_widget_currentArea.js @@ -0,0 +1,26 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that if we move a non-default, but builtin, widget to another area, +// and then reset things, the currentArea is updated correctly. +add_task(async function reset_should_not_keep_currentArea() { + CustomizableUI.addWidgetToArea( + "save-page-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + // We can't check currentArea directly; check areaType which is based on it: + is( + CustomizableUI.getWidget("save-page-button").areaType, + CustomizableUI.TYPE_PANEL, + "Button should know it's in the overflow panel" + ); + CustomizableUI.reset(); + ok( + !CustomizableUI.getWidget("save-page-button").areaType, + "Button should know it's not in the overflow panel anymore" + ); +}); + +registerCleanupFunction(() => CustomizableUI.reset()); diff --git a/browser/components/customizableui/test/browser_reset_dom_events.js b/browser/components/customizableui/test/browser_reset_dom_events.js new file mode 100644 index 0000000000..2922fe481d --- /dev/null +++ b/browser/components/customizableui/test/browser_reset_dom_events.js @@ -0,0 +1,34 @@ +"use strict"; + +const widgetId = "import-button"; +const listener = { + _beforeCount: 0, + _afterCount: 0, + onWidgetBeforeDOMChange(node) { + if (node.id == widgetId) { + this._beforeCount++; + } + }, + onWidgetAfterDOMChange(node) { + if (node.id == widgetId) { + this._afterCount++; + } + }, +}; + +add_task(async function test_reset_dom_events() { + await startCustomizing(); + + CustomizableUI.addWidgetToArea(widgetId, CustomizableUI.AREA_BOOKMARKS); + CustomizableUI.addListener(listener); + + info("Resetting"); + await gCustomizeMode.reset(); + + is(listener._beforeCount, 1, "Should've been notified of the mutation"); + is(listener._afterCount, 1, "Should've been notified of the mutation"); + + CustomizableUI.removeListener(listener); + + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_screenshot_button_disabled.js b/browser/components/customizableui/test/browser_screenshot_button_disabled.js new file mode 100644 index 0000000000..b8eca2b3d3 --- /dev/null +++ b/browser/components/customizableui/test/browser_screenshot_button_disabled.js @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +"use strict"; + +add_task(async function testScreenshotButtonPrefDisabled() { + info("Test the Screenshots widget not available"); + + Assert.ok( + Services.prefs.getBoolPref("extensions.screenshots.disabled"), + "Sceenshots feature is disabled" + ); + + CustomizableUI.addWidgetToArea( + "screenshot-button", + CustomizableUI.AREA_NAVBAR + ); + + let screenshotBtn = document.getElementById("screenshot-button"); + Assert.ok(!screenshotBtn, "Screenshot button is unavailable"); +}); diff --git a/browser/components/customizableui/test/browser_searchbar_removal.js b/browser/components/customizableui/test/browser_searchbar_removal.js new file mode 100644 index 0000000000..ac847c8a4c --- /dev/null +++ b/browser/components/customizableui/test/browser_searchbar_removal.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SearchWidgetTracker } = ChromeUtils.importESModule( + "resource:///modules/SearchWidgetTracker.sys.mjs" +); + +const SEARCH_BAR_PREF_NAME = "browser.search.widget.inNavBar"; +const SEARCH_BAR_LAST_USED_PREF_NAME = "browser.search.widget.lastUsed"; + +add_task(async function checkSearchBarPresent() { + Services.prefs.setBoolPref(SEARCH_BAR_PREF_NAME, true); + Services.prefs.setStringPref( + SEARCH_BAR_LAST_USED_PREF_NAME, + new Date("2022").toISOString() + ); + + Assert.ok( + BrowserSearch.searchBar, + "Search bar should be present in the Nav bar" + ); + SearchWidgetTracker._updateSearchBarVisibilityBasedOnUsage(); + Assert.ok( + !BrowserSearch.searchBar, + "Search bar should not be present in the Nav bar" + ); + Assert.equal( + Services.prefs.getBoolPref(SEARCH_BAR_PREF_NAME), + false, + "Should remove the search bar" + ); + Services.prefs.clearUserPref(SEARCH_BAR_LAST_USED_PREF_NAME); + Services.prefs.clearUserPref(SEARCH_BAR_PREF_NAME); +}); diff --git a/browser/components/customizableui/test/browser_sidebar_toggle.js b/browser/components/customizableui/test/browser_sidebar_toggle.js new file mode 100644 index 0000000000..5742f368ee --- /dev/null +++ b/browser/components/customizableui/test/browser_sidebar_toggle.js @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +registerCleanupFunction(async function () { + await resetCustomization(); + + // Ensure sidebar is hidden after each test: + if (!document.getElementById("sidebar-box").hidden) { + SidebarUI.hide(); + } +}); + +var showSidebar = async function (win = window) { + let button = win.document.getElementById("sidebar-button"); + let sidebarFocusedPromise = BrowserTestUtils.waitForEvent( + win.document, + "SidebarFocused" + ); + EventUtils.synthesizeMouseAtCenter(button, {}, win); + await sidebarFocusedPromise; + ok(win.SidebarUI.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(!button.hasAttribute("checked"), "Toolbar button isn't checked"); +}; + +// Check the sidebar widget shows the default items +add_task(async function () { + CustomizableUI.addWidgetToArea("sidebar-button", "nav-bar"); + + await showSidebar(); + is(SidebarUI.currentID, "viewBookmarksSidebar", "Default sidebar selected"); + await SidebarUI.show("viewHistorySidebar"); + + await hideSidebar(); + await showSidebar(); + is(SidebarUI.currentID, "viewHistorySidebar", "Selected sidebar remembered"); + + await hideSidebar(); + let otherWin = await BrowserTestUtils.openNewBrowserWindow(); + await showSidebar(otherWin); + is( + otherWin.SidebarUI.currentID, + "viewHistorySidebar", + "Selected sidebar remembered across windows" + ); + await hideSidebar(otherWin); + + await BrowserTestUtils.closeWindow(otherWin); +}); diff --git a/browser/components/customizableui/test/browser_switch_to_customize_mode.js b/browser/components/customizableui/test/browser_switch_to_customize_mode.js new file mode 100644 index 0000000000..55e80d3517 --- /dev/null +++ b/browser/components/customizableui/test/browser_switch_to_customize_mode.js @@ -0,0 +1,53 @@ +"use strict"; + +add_task(async function () { + await startCustomizing(); + is(gBrowser.tabs.length, 2, "Should have 2 tabs"); + + let paletteKidCount = document.getElementById( + "customization-palette" + ).childElementCount; + let nonCustomizingTab = gBrowser.tabContainer.querySelector( + "tab:not([customizemode=true])" + ); + let finishedCustomizing = BrowserTestUtils.waitForEvent( + gNavToolbox, + "aftercustomization" + ); + await BrowserTestUtils.switchTab(gBrowser, nonCustomizingTab); + await finishedCustomizing; + + let startedCount = 0; + let handler = e => startedCount++; + gNavToolbox.addEventListener("customizationstarting", handler); + await startCustomizing(); + CustomizableUI.removeWidgetFromArea("stop-reload-button"); + await gCustomizeMode.reset().catch(e => { + ok( + false, + "Threw an exception trying to reset after making modifications in customize mode: " + + e + ); + }); + + let newKidCount = document.getElementById( + "customization-palette" + ).childElementCount; + is( + newKidCount, + paletteKidCount, + "Should have just as many items in the palette as before." + ); + await endCustomizing(); + is(startedCount, 1, "Should have only started once"); + gNavToolbox.removeEventListener("customizationstarting", handler); + let customizableToolbars = document.querySelectorAll( + "toolbar[customizable=true]:not([autohide=true])" + ); + for (let toolbar of customizableToolbars) { + ok( + !toolbar.hasAttribute("customizing"), + "Toolbar " + toolbar.id + " is no longer customizing" + ); + } +}); diff --git a/browser/components/customizableui/test/browser_synced_tabs_menu.js b/browser/components/customizableui/test/browser_synced_tabs_menu.js new file mode 100644 index 0000000000..ff60167fea --- /dev/null +++ b/browser/components/customizableui/test/browser_synced_tabs_menu.js @@ -0,0 +1,523 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +requestLongerTimeout(2); + +const { FxAccounts } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); +let { SyncedTabs } = ChromeUtils.importESModule( + "resource://services-sync/SyncedTabs.sys.mjs" +); +let { UIState } = ChromeUtils.importESModule( + "resource://services-sync/UIState.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + UITour: "resource:///modules/UITour.sys.mjs", +}); + +const DECKINDEX_TABS = 0; +const DECKINDEX_FETCHING = 1; +const DECKINDEX_TABSDISABLED = 2; +const DECKINDEX_NOCLIENTS = 3; + +const SAMPLE_TAB_URL = "https://example.com/"; + +var initialLocation = gBrowser.currentURI.spec; +var newTab = null; + +// A helper to notify there are new tabs. Returns a promise that is resolved +// once the UI has been updated. +function updateTabsPanel() { + let promiseTabsUpdated = promiseObserverNotified( + "synced-tabs-menu:test:tabs-updated" + ); + Services.obs.notifyObservers(null, SyncedTabs.TOPIC_TABS_CHANGED); + return promiseTabsUpdated; +} + +// This is the mock we use for SyncedTabs.jsm - tests may override various +// functions. +let mockedInternal = { + get isConfiguredToSyncTabs() { + return true; + }, + getTabClients() { + return Promise.resolve([]); + }, + syncTabs() { + return Promise.resolve(); + }, + hasSyncedThisSession: false, +}; + +add_setup(async function () { + const getSignedInUser = FxAccounts.config.getSignedInUser; + FxAccounts.config.getSignedInUser = async () => + Promise.resolve({ uid: "uid", email: "foo@bar.com" }); + Services.prefs.setCharPref( + "identity.fxaccounts.remote.root", + "https://example.com/" + ); + + let oldInternal = SyncedTabs._internal; + SyncedTabs._internal = mockedInternal; + + let origNotifyStateUpdated = UIState._internal.notifyStateUpdated; + // Sync start-up will interfere with our tests, don't let UIState send UI updates. + UIState._internal.notifyStateUpdated = () => {}; + + // Force gSync initialization + gSync.init(); + + registerCleanupFunction(() => { + FxAccounts.config.getSignedInUser = getSignedInUser; + Services.prefs.clearUserPref("identity.fxaccounts.remote.root"); + UIState._internal.notifyStateUpdated = origNotifyStateUpdated; + SyncedTabs._internal = oldInternal; + }); +}); + +// The test expects the about:preferences#sync page to open in the current tab +async function openPrefsFromMenuPanel(expectedPanelId, entryPoint) { + info("Check Sync button functionality"); + CustomizableUI.addWidgetToArea( + "sync-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + await waitForOverflowButtonShown(); + + // check the button's functionality + await document.getElementById("nav-bar").overflowable.show(); + + if (entryPoint == "uitour") { + UITour.tourBrowsersByWindow.set(window, new Set()); + UITour.tourBrowsersByWindow.get(window).add(gBrowser.selectedBrowser); + } + + let syncButton = document.getElementById("sync-button"); + ok(syncButton, "The Sync button was added to the Panel Menu"); + + let tabsUpdatedPromise = promiseObserverNotified( + "synced-tabs-menu:test:tabs-updated" + ); + syncButton.click(); + let syncPanel = document.getElementById("PanelUI-remotetabs"); + let viewShownPromise = BrowserTestUtils.waitForEvent(syncPanel, "ViewShown"); + await Promise.all([tabsUpdatedPromise, viewShownPromise]); + ok(syncPanel.getAttribute("visible"), "Sync Panel is in view"); + + // Sync is not configured - verify that state is reflected. + let subpanel = document.getElementById(expectedPanelId); + ok(!subpanel.hidden, "sync setup element is visible"); + + // Find and click the "setup" button. + let setupButton = subpanel.querySelector(".PanelUI-remotetabs-button"); + setupButton.click(); + + await new Promise(resolve => { + let handler = async e => { + if ( + e.originalTarget != gBrowser.selectedBrowser.contentDocument || + e.target.location.href == "about:blank" + ) { + info("Skipping spurious 'load' event for " + e.target.location.href); + return; + } + gBrowser.selectedBrowser.removeEventListener("load", handler, true); + resolve(); + }; + gBrowser.selectedBrowser.addEventListener("load", handler, true); + }); + newTab = gBrowser.selectedTab; + + is( + gBrowser.currentURI.spec, + "about:preferences?entrypoint=" + entryPoint + "#sync", + "Firefox Sync preference page opened with `menupanel` entrypoint" + ); + ok(!isOverflowOpen(), "The panel closed"); + + if (isOverflowOpen()) { + await hideOverflow(); + } +} + +function hideOverflow() { + let panelHidePromise = promiseOverflowHidden(window); + PanelUI.overflowPanel.hidePopup(); + return panelHidePromise; +} + +async function asyncCleanup() { + // reset the panel UI to the default state + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "The panel UI is in default state again."); + + // restore the tabs + BrowserTestUtils.addTab(gBrowser, initialLocation); + gBrowser.removeTab(newTab); + UITour.tourBrowsersByWindow.delete(window); +} + +// When Sync is not setup. +add_task(async function () { + gSync.updateAllUI({ status: UIState.STATUS_NOT_CONFIGURED }); + await openPrefsFromMenuPanel("PanelUI-remotetabs-setupsync", "synced-tabs"); +}); +add_task(asyncCleanup); + +// When an account is connected by Sync is not enabled. +add_task(async function () { + gSync.updateAllUI({ status: UIState.STATUS_SIGNED_IN, syncEnabled: false }); + await openPrefsFromMenuPanel( + "PanelUI-remotetabs-syncdisabled", + "synced-tabs" + ); +}); +add_task(asyncCleanup); + +// When Sync is configured in an unverified state. +add_task(async function () { + gSync.updateAllUI({ + status: UIState.STATUS_NOT_VERIFIED, + email: "foo@bar.com", + }); + await openPrefsFromMenuPanel("PanelUI-remotetabs-unverified", "synced-tabs"); +}); +add_task(asyncCleanup); + +// When Sync is configured in a "needs reauthentication" state. +add_task(async function () { + gSync.updateAllUI({ + status: UIState.STATUS_LOGIN_FAILED, + email: "foo@bar.com", + }); + await openPrefsFromMenuPanel("PanelUI-remotetabs-reauthsync", "synced-tabs"); +}); + +// Test the Connect Another Device button +add_task(async function () { + gSync.updateAllUI({ + status: UIState.STATUS_SIGNED_IN, + syncEnabled: true, + email: "foo@bar.com", + lastSync: new Date(), + }); + + let button = document.getElementById( + "PanelUI-remotetabs-connect-device-button" + ); + ok(button, "found the button"); + + await document.getElementById("nav-bar").overflowable.show(); + let expectedUrl = + "https://example.com/connect_another_device?context=" + + "fx_desktop_v3&entrypoint=synced-tabs&service=sync&uid=uid&email=foo%40bar.com"; + let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, expectedUrl); + button.click(); + // the panel should have been closed. + ok(!isOverflowOpen(), "click closed the panel"); + await promiseTabOpened; + + gBrowser.removeTab(gBrowser.selectedTab); +}); + +// Test the "Sync Now" button +add_task(async function () { + gSync.updateAllUI({ + status: UIState.STATUS_SIGNED_IN, + syncEnabled: true, + email: "foo@bar.com", + lastSync: new Date(), + }); + + await document.getElementById("nav-bar").overflowable.show(); + let tabsUpdatedPromise = promiseObserverNotified( + "synced-tabs-menu:test:tabs-updated" + ); + let syncPanel = document.getElementById("PanelUI-remotetabs"); + let viewShownPromise = BrowserTestUtils.waitForEvent(syncPanel, "ViewShown"); + let syncButton = document.getElementById("sync-button"); + syncButton.click(); + await Promise.all([tabsUpdatedPromise, viewShownPromise]); + ok(syncPanel.getAttribute("visible"), "Sync Panel is in view"); + + let subpanel = document.getElementById("PanelUI-remotetabs-main"); + ok(!subpanel.hidden, "main pane is visible"); + let deck = document.getElementById("PanelUI-remotetabs-deck"); + + // The widget is still fetching tabs, as we've neutered everything that + // provides them + is(deck.selectedIndex, DECKINDEX_FETCHING, "first deck entry is visible"); + + // Tell the widget there are tabs available, but with zero clients. + mockedInternal.getTabClients = () => { + return Promise.resolve([]); + }; + mockedInternal.hasSyncedThisSession = true; + await updateTabsPanel(); + // The UI should be showing the "no clients" pane. + is( + deck.selectedIndex, + DECKINDEX_NOCLIENTS, + "no-clients deck entry is visible" + ); + + // Tell the widget there are tabs available - we have 3 clients, one with no + // tabs. + mockedInternal.getTabClients = () => { + return Promise.resolve([ + { + id: "guid_mobile", + type: "client", + name: "My Phone", + lastModified: 1492201200, + tabs: [], + }, + { + id: "guid_desktop", + type: "client", + name: "My Desktop", + lastModified: 1492201200, + tabs: [ + { + title: "http://example.com/10", + lastUsed: 10, // the most recent + }, + { + title: "http://example.com/1", + lastUsed: 1, // the least recent. + }, + { + title: "http://example.com/5", + lastUsed: 5, + }, + ], + }, + { + id: "guid_second_desktop", + name: "My Other Desktop", + lastModified: 1492201200, + tabs: [ + { + title: "http://example.com/6", + lastUsed: 6, + }, + ], + }, + ]); + }; + await updateTabsPanel(); + + // The UI should be showing tabs! + is(deck.selectedIndex, DECKINDEX_TABS, "no-clients deck entry is visible"); + let tabList = document.getElementById("PanelUI-remotetabs-tabslist"); + let node = tabList.firstElementChild; + // First entry should be the client with the most-recent tab. + is(node.nodeName, "vbox"); + let currentClient = node; + 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 + node = node.nextElementSibling; + is(node.getAttribute("itemtype"), "tab", "node is a tab"); + is(node.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"); + + // 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"); + node = node.nextElementSibling; + is(node, null, "no more siblings"); + + // Next is a toolbarseparator between the clients. + node = currentClient.nextElementSibling; + is(node.nodeName, "toolbarseparator"); + + // Next is the container for client 2. + node = node.nextElementSibling; + is(node.nodeName, "vbox"); + currentClient = node; + + // Next is the client with 1 tab. + node = node.firstElementChild; + is(node.getAttribute("itemtype"), "client", "node is a client entry"); + 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"); + node = node.nextElementSibling; + is(node, null, "no more siblings"); + + // Next is a toolbarseparator between the clients. + node = currentClient.nextElementSibling; + is(node.nodeName, "toolbarseparator"); + + // Next is the container for client 3. + node = node.nextElementSibling; + is(node.nodeName, "vbox"); + currentClient = node; + + // Next is the client with no tab. + node = node.firstElementChild; + is(node.getAttribute("itemtype"), "client", "node is a client entry"); + is(node.textContent, "My Phone", "correct client"); + // There is a single node saying there's no tabs for the client. + node = node.nextElementSibling; + is(node.nodeName, "label", "node is a label"); + is(node.getAttribute("itemtype"), "", "node is neither a tab nor a client"); + + node = node.nextElementSibling; + is(node, null, "no more siblings"); + is(currentClient.nextElementSibling, null, "no more clients"); + + // Check accessibility. There should be containers for each client, with an + // aria attribute that identifies the client name. + let clientContainers = [ + ...tabList.querySelectorAll("[aria-labelledby]").values(), + ]; + let labelIds = clientContainers.map(container => + container.getAttribute("aria-labelledby") + ); + let labels = labelIds.map(id => document.getElementById(id).textContent); + Assert.deepEqual(labels.sort(), [ + "My Desktop", + "My Other Desktop", + "My Phone", + ]); + + let didSync = false; + let oldDoSync = gSync.doSync; + gSync.doSync = function () { + didSync = true; + gSync.doSync = oldDoSync; + }; + + let syncNowButton = document.getElementById("PanelUI-remotetabs-syncnow"); + is(syncNowButton.disabled, false); + syncNowButton.click(); + ok(didSync, "clicking the button called the correct function"); + + await hideOverflow(); +}); + +// Test the pagination capabilities (Show More/All tabs) +add_task(async function () { + mockedInternal.getTabClients = () => { + return Promise.resolve([ + { + id: "guid_desktop", + type: "client", + name: "My Desktop", + lastModified: 1492201200, + tabs: (function () { + let allTabsDesktop = []; + // We choose 77 tabs, because TABS_PER_PAGE is 25, which means + // on the second to last page we should have 22 items shown + // (because we have to show at least NEXT_PAGE_MIN_TABS=5 tabs on the last page) + for (let i = 1; i <= 77; i++) { + allTabsDesktop.push({ title: "Tab #" + i, url: SAMPLE_TAB_URL }); + } + return allTabsDesktop; + })(), + }, + ]); + }; + + gSync.updateAllUI({ + status: UIState.STATUS_SIGNED_IN, + syncEnabled: true, + lastSync: new Date(), + email: "foo@bar.com", + }); + + await document.getElementById("nav-bar").overflowable.show(); + let tabsUpdatedPromise = promiseObserverNotified( + "synced-tabs-menu:test:tabs-updated" + ); + let syncPanel = document.getElementById("PanelUI-remotetabs"); + let viewShownPromise = BrowserTestUtils.waitForEvent(syncPanel, "ViewShown"); + let syncButton = document.getElementById("sync-button"); + syncButton.click(); + await Promise.all([tabsUpdatedPromise, viewShownPromise]); + + // Check pre-conditions + ok(syncPanel.getAttribute("visible"), "Sync Panel is in view"); + let subpanel = document.getElementById("PanelUI-remotetabs-main"); + ok(!subpanel.hidden, "main pane is visible"); + let deck = document.getElementById("PanelUI-remotetabs-deck"); + is(deck.selectedIndex, DECKINDEX_TABS, "we should be showing tabs"); + + function checkTabsPage(tabsShownCount, showMoreLabel) { + let tabList = document.getElementById("PanelUI-remotetabs-tabslist"); + let node = tabList.firstElementChild.firstElementChild; + is(node.getAttribute("itemtype"), "client", "node is a client entry"); + 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.getAttribute("label"), + "Tab #" + (i + 1), + "the tab is the correct one" + ); + is( + node.getAttribute("targetURI"), + SAMPLE_TAB_URL, + "url is the correct one" + ); + } + let showMoreButton; + if (showMoreLabel) { + node = showMoreButton = node.nextElementSibling; + is( + node.getAttribute("itemtype"), + "showmorebutton", + "node is a show more button" + ); + is(node.getAttribute("label"), showMoreLabel); + } + node = node.nextElementSibling; + is(node, null, "no more entries"); + + return showMoreButton; + } + + async function checkCanOpenURL() { + let tabList = document.getElementById("PanelUI-remotetabs-tabslist"); + let node = tabList.firstElementChild.firstElementChild.nextElementSibling; + let promiseTabOpened = BrowserTestUtils.waitForLocationChange( + gBrowser, + SAMPLE_TAB_URL + ); + node.click(); + await promiseTabOpened; + } + + let showMoreButton; + function clickShowMoreButton() { + let promise = promiseObserverNotified("synced-tabs-menu:test:tabs-updated"); + showMoreButton.click(); + return promise; + } + + showMoreButton = checkTabsPage(25, "Show More Tabs"); + await clickShowMoreButton(); + + checkTabsPage(77, null); + /* calling this will close the overflow menu */ + await checkCanOpenURL(); +}); diff --git a/browser/components/customizableui/test/browser_tabbar_big_widgets.js b/browser/components/customizableui/test/browser_tabbar_big_widgets.js new file mode 100644 index 0000000000..2eae968656 --- /dev/null +++ b/browser/components/customizableui/test/browser_tabbar_big_widgets.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +const kButtonId = "test-tabbar-size-with-large-buttons"; + +function test() { + registerCleanupFunction(cleanup); + let titlebar = document.getElementById("titlebar"); + let originalHeight = titlebar.getBoundingClientRect().height; + let button = document.createXULElement("toolbarbutton"); + button.id = kButtonId; + button.setAttribute("style", "min-height: 100px"); + gNavToolbox.palette.appendChild(button); + CustomizableUI.addWidgetToArea(kButtonId, CustomizableUI.AREA_TABSTRIP); + let currentHeight = titlebar.getBoundingClientRect().height; + Assert.greater(currentHeight, originalHeight, "Titlebar should have grown"); + CustomizableUI.removeWidgetFromArea(kButtonId); + currentHeight = titlebar.getBoundingClientRect().height; + is( + currentHeight, + originalHeight, + "Titlebar should have gone back to its original size." + ); +} + +function cleanup() { + let btn = document.getElementById(kButtonId); + if (btn) { + btn.remove(); + } +} diff --git a/browser/components/customizableui/test/browser_toolbar_collapsed_states.js b/browser/components/customizableui/test/browser_toolbar_collapsed_states.js new file mode 100644 index 0000000000..23da60a7d5 --- /dev/null +++ b/browser/components/customizableui/test/browser_toolbar_collapsed_states.js @@ -0,0 +1,112 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Checks that CustomizableUI reports the expected collapsed toolbar IDs. + * + * Note: on macOS, expectations for CustomizableUI.AREA_MENUBAR are + * automatically skipped since that area isn't available on that platform. + * + * @param {string[]} The IDs of the expected collapsed toolbars. + */ +function assertCollapsedToolbarIds(expected) { + if (AppConstants.platform == "macosx") { + let menubarIndex = expected.indexOf(CustomizableUI.AREA_MENUBAR); + if (menubarIndex != -1) { + expected.splice(menubarIndex, 1); + } + } + + let collapsedIds = CustomizableUI.getCollapsedToolbarIds(window); + Assert.equal(collapsedIds.size, expected.length); + for (let expectedId of expected) { + Assert.ok( + collapsedIds.has(expectedId), + `${expectedId} should be collapsed` + ); + } +} + +registerCleanupFunction(async () => { + await CustomizableUI.reset(); +}); + +/** + * Tests that CustomizableUI.getCollapsedToolbarIds will return the IDs of + * toolbars that are collapsed, or menubars that are autohidden. + */ +add_task(async function test_toolbar_collapsed_states() { + // By default, we expect the menubar and the bookmarks toolbar to be + // collapsed. + assertCollapsedToolbarIds([ + CustomizableUI.AREA_BOOKMARKS, + CustomizableUI.AREA_MENUBAR, + ]); + + let bookmarksToolbar = document.getElementById(CustomizableUI.AREA_BOOKMARKS); + // Make sure we're configured to show the bookmarks toolbar on about:newtab. + setToolbarVisibility(bookmarksToolbar, "newtab"); + + let newTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: "about:newtab", + waitForLoad: false, + }); + // Now that we've opened about:newtab, the bookmarks toolbar should now + // be visible. + assertCollapsedToolbarIds([CustomizableUI.AREA_MENUBAR]); + await BrowserTestUtils.removeTab(newTab); + + // And with about:newtab closed again, the bookmarks toolbar should be + // reported as collapsed. + assertCollapsedToolbarIds([ + CustomizableUI.AREA_BOOKMARKS, + CustomizableUI.AREA_MENUBAR, + ]); + + // Make sure we're configured to show the bookmarks toolbar on about:newtab. + setToolbarVisibility(bookmarksToolbar, "always"); + assertCollapsedToolbarIds([CustomizableUI.AREA_MENUBAR]); + + setToolbarVisibility(bookmarksToolbar, "never"); + assertCollapsedToolbarIds([ + CustomizableUI.AREA_BOOKMARKS, + CustomizableUI.AREA_MENUBAR, + ]); + + if (AppConstants.platform != "macosx") { + // We'll still consider the menubar collapsed by default, even if it's being temporarily + // shown via the alt key. + let menubarActive = BrowserTestUtils.waitForEvent( + window, + "DOMMenuBarActive" + ); + EventUtils.synthesizeKey("VK_ALT", {}); + await menubarActive; + assertCollapsedToolbarIds([ + CustomizableUI.AREA_BOOKMARKS, + CustomizableUI.AREA_MENUBAR, + ]); + let menubarInactive = BrowserTestUtils.waitForEvent( + window, + "DOMMenuBarInactive" + ); + EventUtils.synthesizeKey("VK_ESCAPE", {}); + await menubarInactive; + assertCollapsedToolbarIds([ + CustomizableUI.AREA_BOOKMARKS, + CustomizableUI.AREA_MENUBAR, + ]); + + let menubar = document.getElementById(CustomizableUI.AREA_MENUBAR); + setToolbarVisibility(menubar, true); + assertCollapsedToolbarIds([CustomizableUI.AREA_BOOKMARKS]); + setToolbarVisibility(menubar, false); + assertCollapsedToolbarIds([ + CustomizableUI.AREA_BOOKMARKS, + CustomizableUI.AREA_MENUBAR, + ]); + } +}); diff --git a/browser/components/customizableui/test/browser_touchbar_customization.js b/browser/components/customizableui/test/browser_touchbar_customization.js new file mode 100644 index 0000000000..106c202ad9 --- /dev/null +++ b/browser/components/customizableui/test/browser_touchbar_customization.js @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Checks if the Customize Touch Bar button appears when a Touch Bar is +// initialized. +add_task(async function customizeTouchBarButtonAppears() { + let updater = Cc["@mozilla.org/widget/touchbarupdater;1"].getService( + Ci.nsITouchBarUpdater + ); + // This value will be reset to its default the next time a window is opened. + updater.setTouchBarInitialized(true); + await startCustomizing(); + let touchbarButton = document.querySelector("#customization-touchbar-button"); + ok(!touchbarButton.hidden, "Customize Touch Bar button is not hidden."); + let touchbarSpacer = document.querySelector("#customization-touchbar-spacer"); + ok(!touchbarSpacer.hidden, "Customize Touch Bar spacer is not hidden."); + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_unified_extensions_reset.js b/browser/components/customizableui/test/browser_unified_extensions_reset.js new file mode 100644 index 0000000000..fdee8cf76a --- /dev/null +++ b/browser/components/customizableui/test/browser_unified_extensions_reset.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if Unified Extensions UI is enabled that resetting the toolbars + * puts all browser action buttons into the AREA_ADDONS area. + */ +add_task(async function test_reset_with_unified_extensions_ui() { + const kWebExtensionWidgetIDs = [ + "ext0-browser-action", + "ext1-browser-action", + "ext2-browser-action", + "ext3-browser-action", + "ext4-browser-action", + "ext5-browser-action", + "ext6-browser-action", + "ext7-browser-action", + "ext8-browser-action", + "ext9-browser-action", + "ext10-browser-action", + ]; + + for (let widgetID of kWebExtensionWidgetIDs) { + CustomizableUI.createWidget({ + id: widgetID, + label: "Test extension widget", + defaultArea: CustomizableUI.AREA_NAVBAR, + webExtension: true, + }); + } + + // Now let's put these browser actions in a bunch of different places. + // Regardless of where they go, we're going to expect them in AREA_ADDONS + // after we reset. + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[0], + CustomizableUI.AREA_TABSTRIP + ); + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[1], + CustomizableUI.AREA_TABSTRIP + ); + + // macOS doesn't have AREA_MENUBAR registered, so we'll leave these widgets + // behind in the AREA_NAVBAR there, and put them into the menubar on the + // other platforms. + if (AppConstants.platform != "macosx") { + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[2], + CustomizableUI.AREA_MENUBAR + ); + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[3], + CustomizableUI.AREA_MENUBAR + ); + } + + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[4], + CustomizableUI.AREA_BOOKMARKS + ); + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[5], + CustomizableUI.AREA_BOOKMARKS + ); + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[6], + CustomizableUI.AREA_ADDONS + ); + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[7], + CustomizableUI.AREA_ADDONS + ); + + CustomizableUI.reset(); + + // Let's force the Unified Extensions panel to register itself now if it + // wasn't already done. Using the getter should be sufficient. + Assert.ok(gUnifiedExtensions.panel, "Should have found the panel."); + + for (let widgetID of kWebExtensionWidgetIDs) { + let { area } = CustomizableUI.getPlacementOfWidget(widgetID); + Assert.equal(area, CustomizableUI.AREA_ADDONS); + // Let's double-check that they're actually in there in the DOM too. + let widget = CustomizableUI.getWidget(widgetID).forWindow(window); + Assert.equal(widget.node.parentElement.id, CustomizableUI.AREA_ADDONS); + CustomizableUI.destroyWidget(widgetID); + } +}); diff --git a/browser/components/customizableui/test/browser_widget_animation.js b/browser/components/customizableui/test/browser_widget_animation.js new file mode 100644 index 0000000000..514e3f763b --- /dev/null +++ b/browser/components/customizableui/test/browser_widget_animation.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +gReduceMotionOverride = false; + +function promiseWidgetAnimationOut(aNode) { + let animationNode = aNode; + if ( + animationNode.tagName != "toolbaritem" && + animationNode.tagName != "toolbarbutton" + ) { + animationNode = animationNode.closest("toolbaritem"); + } + if (animationNode.parentNode.id.startsWith("wrapper-")) { + animationNode = animationNode.parentNode; + } + return new Promise(resolve => { + animationNode.addEventListener( + "animationend", + function cleanupWidgetAnimationOut(e) { + if ( + e.animationName == "widget-animate-out" && + e.target.id == animationNode.id + ) { + animationNode.removeEventListener( + "animationend", + cleanupWidgetAnimationOut + ); + ok(true, "The widget`s animationend should have happened"); + resolve(); + } + } + ); + }); +} + +function promiseOverflowAnimationEnd() { + return new Promise(resolve => { + let overflowButton = document.getElementById("nav-bar-overflow-button"); + overflowButton.addEventListener( + "animationend", + function cleanupOverflowAnimationOut(event) { + if (event.animationName == "overflow-animation") { + overflowButton.removeEventListener( + "animationend", + cleanupOverflowAnimationOut + ); + ok( + true, + "The overflow button`s animationend event should have happened" + ); + resolve(); + } + } + ); + }); +} + +// Right-click on the stop/reload button, use the context menu to move it to the overflow menu. +// The button should animate out, and the overflow menu should animate upon adding. +add_task(async function () { + let stopReloadButton = document.getElementById("stop-reload-button"); + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + EventUtils.synthesizeMouseAtCenter(stopReloadButton, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + contextMenu.activateItem( + contextMenu.querySelector(".customize-context-moveToPanel") + ); + + await Promise.all([ + promiseWidgetAnimationOut(stopReloadButton), + promiseOverflowAnimationEnd(), + ]); + ok(true, "The widget and overflow animations should have both happened."); +}); + +registerCleanupFunction(CustomizableUI.reset); diff --git a/browser/components/customizableui/test/browser_widget_recreate_events.js b/browser/components/customizableui/test/browser_widget_recreate_events.js new file mode 100644 index 0000000000..3eca9231a8 --- /dev/null +++ b/browser/components/customizableui/test/browser_widget_recreate_events.js @@ -0,0 +1,99 @@ +"use strict"; + +const widgetData = { + id: "test-widget", + type: "view", + viewId: "PanelUI-testbutton", + label: "test widget label", + onViewShowing() {}, + onViewHiding() {}, +}; + +async function simulateWidgetOpen() { + let testWidgetButton = document.getElementById("test-widget"); + let testWidgetShowing = BrowserTestUtils.waitForEvent( + document, + "popupshowing", + true + ); + testWidgetButton.click(); + await testWidgetShowing; +} + +async function simulateWidgetClose() { + let panel = document.getElementById("customizationui-widget-panel"); + let panelHidden = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + + panel.hidePopup(); + await panelHidden; +} + +function createPanelView() { + let panelView = document.createXULElement("panelview"); + panelView.id = "PanelUI-testbutton"; + let vbox = document.createXULElement("vbox"); + panelView.appendChild(vbox); + return panelView; +} + +/** + * Check that panel view/hide events are added back, + * if widget is destroyed and created again in one session. + */ +add_task(async function () { + let viewCache = document.getElementById("appMenu-viewCache"); + let panelView = createPanelView(); + viewCache.appendChild(panelView); + + CustomizableUI.createWidget(widgetData); + CustomizableUI.addWidgetToArea("test-widget", "nav-bar"); + + // Simulate clicking and wait for the open + // so we ensure the lazy event creation is done. + await simulateWidgetOpen(); + + let listeners = Services.els.getListenerInfoFor(panelView); + ok( + listeners.some(info => info.type == "ViewShowing"), + "ViewShowing event added" + ); + ok( + listeners.some(info => info.type == "ViewHiding"), + "ViewHiding event added" + ); + + await simulateWidgetClose(); + CustomizableUI.destroyWidget("test-widget"); + + listeners = Services.els.getListenerInfoFor(panelView); + // Ensure the events got removed after destorying the widget. + ok( + !listeners.some(info => info.type == "ViewShowing"), + "ViewShowing event removed" + ); + ok( + !listeners.some(info => info.type == "ViewHiding"), + "ViewHiding event removed" + ); + + CustomizableUI.createWidget(widgetData); + // Simulate clicking and wait for the open + // so we ensure the lazy event creation is done. + // We need to do this again because we destroyed the widget. + await simulateWidgetOpen(); + + listeners = Services.els.getListenerInfoFor(panelView); + ok( + listeners.some(info => info.type == "ViewShowing"), + "ViewShowing event added again" + ); + ok( + listeners.some(info => info.type == "ViewHiding"), + "ViewHiding event added again" + ); + + await simulateWidgetClose(); + CustomizableUI.destroyWidget("test-widget"); + panelView.remove(); + CustomizableUI.reset(); +}); diff --git a/browser/components/customizableui/test/dummy_history_item.html b/browser/components/customizableui/test/dummy_history_item.html new file mode 100644 index 0000000000..23a6992923 --- /dev/null +++ b/browser/components/customizableui/test/dummy_history_item.html @@ -0,0 +1,2 @@ +Happy History Hero +

    I am a page for the history books.

    diff --git a/browser/components/customizableui/test/head.js b/browser/components/customizableui/test/head.js new file mode 100644 index 0000000000..f8c0d02a12 --- /dev/null +++ b/browser/components/customizableui/test/head.js @@ -0,0 +1,530 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", + CustomizableUITestUtils: + "resource://testing-common/CustomizableUITestUtils.sys.mjs", +}); + +/** + * Instance of CustomizableUITestUtils for the current browser window. + */ +var gCUITestUtils = new CustomizableUITestUtils(window); + +Services.prefs.setBoolPref("browser.uiCustomization.skipSourceNodeCheck", true); +registerCleanupFunction(() => + Services.prefs.clearUserPref("browser.uiCustomization.skipSourceNodeCheck") +); + +var { synthesizeDrop, synthesizeMouseAtCenter } = EventUtils; + +const kForceOverflowWidthPx = 450; + +function createDummyXULButton(id, label, win = window) { + let btn = win.document.createXULElement("toolbarbutton"); + btn.id = id; + btn.setAttribute("label", label || id); + btn.className = "toolbarbutton-1 chromeclass-toolbar-additional"; + win.gNavToolbox.palette.appendChild(btn); + return btn; +} + +var gAddedToolbars = new Set(); + +function createToolbarWithPlacements(id, placements = [], properties = {}) { + gAddedToolbars.add(id); + let tb = document.createXULElement("toolbar"); + tb.id = id; + tb.setAttribute("customizable", "true"); + + properties.type = CustomizableUI.TYPE_TOOLBAR; + properties.defaultPlacements = placements; + CustomizableUI.registerArea(id, properties); + gNavToolbox.appendChild(tb); + CustomizableUI.registerToolbarNode(tb); + return tb; +} + +function createOverflowableToolbarWithPlacements(id, placements) { + gAddedToolbars.add(id); + + let tb = document.createXULElement("toolbar"); + tb.id = id; + tb.setAttribute("customizationtarget", id + "-target"); + + let customizationtarget = document.createXULElement("hbox"); + customizationtarget.id = id + "-target"; + customizationtarget.setAttribute("flex", "1"); + tb.appendChild(customizationtarget); + + let overflowPanel = document.createXULElement("panel"); + overflowPanel.id = id + "-overflow"; + document.getElementById("mainPopupSet").appendChild(overflowPanel); + + let overflowList = document.createXULElement("vbox"); + overflowList.id = id + "-overflow-list"; + overflowPanel.appendChild(overflowList); + + let chevron = document.createXULElement("toolbarbutton"); + chevron.id = id + "-chevron"; + tb.appendChild(chevron); + + CustomizableUI.registerArea(id, { + type: CustomizableUI.TYPE_TOOLBAR, + defaultPlacements: placements, + overflowable: true, + }); + + tb.setAttribute("customizable", "true"); + tb.setAttribute("overflowable", "true"); + tb.setAttribute("default-overflowpanel", overflowPanel.id); + tb.setAttribute("default-overflowtarget", overflowList.id); + tb.setAttribute("default-overflowbutton", chevron.id); + tb.setAttribute("addon-webext-overflowbutton", "unified-extensions-button"); + tb.setAttribute("addon-webext-overflowtarget", "overflowed-extensions-list"); + + gNavToolbox.appendChild(tb); + CustomizableUI.registerToolbarNode(tb); + return tb; +} + +function removeCustomToolbars() { + CustomizableUI.reset(); + for (let toolbarId of gAddedToolbars) { + CustomizableUI.unregisterArea(toolbarId, true); + let tb = document.getElementById(toolbarId); + if (tb.hasAttribute("overflowpanel")) { + let panel = document.getElementById(tb.getAttribute("overflowpanel")); + if (panel) { + panel.remove(); + } + } + tb.remove(); + } + gAddedToolbars.clear(); +} + +function resetCustomization() { + return CustomizableUI.reset(); +} + +function isInDevEdition() { + return AppConstants.MOZ_DEV_EDITION; +} + +function removeNonReleaseButtons(areaPanelPlacements) { + if (isInDevEdition() && areaPanelPlacements.includes("developer-button")) { + areaPanelPlacements.splice( + areaPanelPlacements.indexOf("developer-button"), + 1 + ); + } +} + +function removeNonOriginalButtons() { + CustomizableUI.removeWidgetFromArea("sync-button"); +} + +function assertAreaPlacements(areaId, expectedPlacements) { + let actualPlacements = getAreaWidgetIds(areaId); + placementArraysEqual(areaId, actualPlacements, expectedPlacements); +} + +function placementArraysEqual(areaId, actualPlacements, expectedPlacements) { + info("Actual placements: " + actualPlacements.join(", ")); + info("Expected placements: " + expectedPlacements.join(", ")); + is( + actualPlacements.length, + expectedPlacements.length, + "Area " + areaId + " should have " + expectedPlacements.length + " items." + ); + let minItems = Math.min(expectedPlacements.length, actualPlacements.length); + for (let i = 0; i < minItems; i++) { + if (typeof expectedPlacements[i] == "string") { + is( + actualPlacements[i], + expectedPlacements[i], + "Item " + i + " in " + areaId + " should match expectations." + ); + } else if (expectedPlacements[i] instanceof RegExp) { + ok( + expectedPlacements[i].test(actualPlacements[i]), + "Item " + + i + + " (" + + actualPlacements[i] + + ") in " + + areaId + + " should match " + + expectedPlacements[i] + ); + } else { + ok( + false, + "Unknown type of expected placement passed to " + + " assertAreaPlacements. Is your test broken?" + ); + } + } +} + +function todoAssertAreaPlacements(areaId, expectedPlacements) { + let actualPlacements = getAreaWidgetIds(areaId); + let isPassing = actualPlacements.length == expectedPlacements.length; + let minItems = Math.min(expectedPlacements.length, actualPlacements.length); + for (let i = 0; i < minItems; i++) { + if (typeof expectedPlacements[i] == "string") { + isPassing = isPassing && actualPlacements[i] == expectedPlacements[i]; + } else if (expectedPlacements[i] instanceof RegExp) { + isPassing = isPassing && expectedPlacements[i].test(actualPlacements[i]); + } else { + ok( + false, + "Unknown type of expected placement passed to " + + " assertAreaPlacements. Is your test broken?" + ); + } + } + todo( + isPassing, + "The area placements for " + + areaId + + " should equal the expected placements." + ); +} + +function getAreaWidgetIds(areaId) { + return CustomizableUI.getWidgetIdsInArea(areaId); +} + +function simulateItemDrag(aToDrag, aTarget, aEvent = {}, aOffset = 2) { + let ev = aEvent; + if (ev == "end" || ev == "start") { + let win = aTarget.ownerGlobal; + const dwu = win.windowUtils; + let bounds = dwu.getBoundsWithoutFlushing(aTarget); + if (ev == "end") { + ev = { + clientX: bounds.right - aOffset, + clientY: bounds.bottom - aOffset, + }; + } else { + ev = { clientX: bounds.left + aOffset, clientY: bounds.top + aOffset }; + } + } + ev._domDispatchOnly = true; + synthesizeDrop( + aToDrag.parentNode, + aTarget, + null, + null, + aToDrag.ownerGlobal, + aTarget.ownerGlobal, + ev + ); + // Ensure dnd suppression is cleared. + synthesizeMouseAtCenter(aTarget, { type: "mouseup" }, aTarget.ownerGlobal); +} + +function endCustomizing(aWindow = window) { + if (aWindow.document.documentElement.getAttribute("customizing") != "true") { + return true; + } + let afterCustomizationPromise = BrowserTestUtils.waitForEvent( + aWindow.gNavToolbox, + "aftercustomization" + ); + aWindow.gCustomizeMode.exit(); + return afterCustomizationPromise; +} + +function startCustomizing(aWindow = window) { + if (aWindow.document.documentElement.getAttribute("customizing") == "true") { + return null; + } + let customizationReadyPromise = BrowserTestUtils.waitForEvent( + aWindow.gNavToolbox, + "customizationready" + ); + aWindow.gCustomizeMode.enter(); + return customizationReadyPromise; +} + +function promiseObserverNotified(aTopic) { + return new Promise(resolve => { + Services.obs.addObserver(function onNotification(subject, topic, data) { + Services.obs.removeObserver(onNotification, topic); + resolve({ subject, data }); + }, aTopic); + }); +} + +function openAndLoadWindow(aOptions, aWaitForDelayedStartup = false) { + return new Promise(resolve => { + let win = OpenBrowserWindow(aOptions); + if (aWaitForDelayedStartup) { + Services.obs.addObserver(function onDS(aSubject, aTopic, aData) { + if (aSubject != win) { + return; + } + Services.obs.removeObserver(onDS, "browser-delayed-startup-finished"); + resolve(win); + }, "browser-delayed-startup-finished"); + } else { + win.addEventListener( + "load", + function () { + resolve(win); + }, + { once: true } + ); + } + }); +} + +function promiseWindowClosed(win) { + return new Promise(resolve => { + win.addEventListener( + "unload", + function () { + resolve(); + }, + { once: true } + ); + win.close(); + }); +} + +function promiseOverflowShown(win) { + let panelEl = win.document.getElementById("widget-overflow"); + return promisePanelElementShown(win, panelEl); +} + +function promisePanelElementShown(win, aPanel) { + return new Promise((resolve, reject) => { + let timeoutId = win.setTimeout(() => { + reject("Panel did not show within 20 seconds."); + }, 20000); + function onPanelOpen(e) { + aPanel.removeEventListener("popupshown", onPanelOpen); + win.clearTimeout(timeoutId); + resolve(); + } + aPanel.addEventListener("popupshown", onPanelOpen); + }); +} + +function promiseOverflowHidden(win) { + let panelEl = win.PanelUI.overflowPanel; + return promisePanelElementHidden(win, panelEl); +} + +function promisePanelElementHidden(win, aPanel) { + return new Promise((resolve, reject) => { + let timeoutId = win.setTimeout(() => { + reject("Panel did not hide within 20 seconds."); + }, 20000); + function onPanelClose(e) { + aPanel.removeEventListener("popuphidden", onPanelClose); + win.clearTimeout(timeoutId); + executeSoon(resolve); + } + aPanel.addEventListener("popuphidden", onPanelClose); + }); +} + +function isPanelUIOpen() { + return PanelUI.panel.state == "open" || PanelUI.panel.state == "showing"; +} + +function isOverflowOpen() { + let panel = document.getElementById("widget-overflow"); + return panel.state == "open" || panel.state == "showing"; +} + +function subviewShown(aSubview) { + return new Promise((resolve, reject) => { + let win = aSubview.ownerGlobal; + let timeoutId = win.setTimeout(() => { + reject("Subview (" + aSubview.id + ") did not show within 20 seconds."); + }, 20000); + function onViewShown(e) { + aSubview.removeEventListener("ViewShown", onViewShown); + win.clearTimeout(timeoutId); + resolve(); + } + aSubview.addEventListener("ViewShown", onViewShown); + }); +} + +function subviewHidden(aSubview) { + return new Promise((resolve, reject) => { + let win = aSubview.ownerGlobal; + let timeoutId = win.setTimeout(() => { + reject("Subview (" + aSubview.id + ") did not hide within 20 seconds."); + }, 20000); + function onViewHiding(e) { + aSubview.removeEventListener("ViewHiding", onViewHiding); + win.clearTimeout(timeoutId); + resolve(); + } + aSubview.addEventListener("ViewHiding", onViewHiding); + }); +} + +function waitFor(aTimeout = 100) { + return new Promise(resolve => { + setTimeout(() => resolve(), aTimeout); + }); +} + +/** + * Starts a load in an existing tab and waits for it to finish (via some event). + * + * @param aTab The tab to load into. + * @param aUrl The url to load. + * @param aEventType The load event type to wait for. Defaults to "load". + * @return {Promise} resolved when the event is handled. + */ +function promiseTabLoadEvent(aTab, aURL) { + let browser = aTab.linkedBrowser; + + BrowserTestUtils.startLoadingURIString(browser, aURL); + return BrowserTestUtils.browserLoaded(browser); +} + +/** + * Wait for an attribute on a node to change + * + * @param aNode Node on which the mutation is expected + * @param aAttribute The attribute we're interested in + * @param aFilterFn A function to check if the new value is what we want. + * @return {Promise} resolved when the requisite mutation shows up. + */ +function promiseAttributeMutation(aNode, aAttribute, aFilterFn) { + return new Promise((resolve, reject) => { + info("waiting for mutation of attribute '" + aAttribute + "'."); + let obs = new MutationObserver(mutations => { + for (let mut of mutations) { + let attr = mut.attributeName; + let newValue = mut.target.getAttribute(attr); + if (aFilterFn(newValue)) { + ok( + true, + "mutation occurred: attribute '" + + attr + + "' changed to '" + + newValue + + "' from '" + + mut.oldValue + + "'." + ); + obs.disconnect(); + resolve(); + } else { + info( + "Ignoring mutation that produced value " + + newValue + + " because of filter." + ); + } + } + }); + obs.observe(aNode, { attributeFilter: [aAttribute] }); + }); +} + +function popupShown(aPopup) { + return BrowserTestUtils.waitForPopupEvent(aPopup, "shown"); +} + +function popupHidden(aPopup) { + return BrowserTestUtils.waitForPopupEvent(aPopup, "hidden"); +} + +// This is a simpler version of the context menu check that +// exists in contextmenu_common.js. +function checkContextMenu(aContextMenu, aExpectedEntries, aWindow = window) { + let children = [...aContextMenu.children]; + // Ignore hidden nodes: + children = children.filter(n => !n.hidden); + + for (let i = 0; i < children.length; i++) { + let menuitem = children[i]; + try { + if (aExpectedEntries[i][0] == "---") { + is(menuitem.localName, "menuseparator", "menuseparator expected"); + continue; + } + + let selector = aExpectedEntries[i][0]; + ok( + menuitem.matches(selector), + "menuitem should match " + selector + " selector" + ); + let commandValue = menuitem.getAttribute("command"); + let relatedCommand = commandValue + ? aWindow.document.getElementById(commandValue) + : null; + let menuItemDisabled = relatedCommand + ? relatedCommand.getAttribute("disabled") == "true" + : menuitem.getAttribute("disabled") == "true"; + is( + menuItemDisabled, + !aExpectedEntries[i][1], + "disabled state for " + selector + ); + } catch (e) { + ok(false, "Exception when checking context menu: " + e); + } + } +} + +function waitForOverflowButtonShown(win = window) { + info("Waiting for overflow button to show"); + let ov = win.document.getElementById("nav-bar-overflow-button"); + return waitForElementShown(ov.icon); +} +function waitForElementShown(element) { + return BrowserTestUtils.waitForCondition(() => { + info("Checking if element has non-0 size"); + // We intentionally flush layout to ensure the element is actually shown. + let rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); +} + +/** + * Opens the history panel through the history toolbarbutton in the + * navbar and returns a promise that resolves as soon as the panel is open + * is showing. + */ +async function openHistoryPanel(doc = document) { + await waitForOverflowButtonShown(); + await doc.getElementById("nav-bar").overflowable.show(); + info("Menu panel was opened"); + + let historyButton = doc.getElementById("history-panelmenu"); + Assert.ok(historyButton, "History button appears in Panel Menu"); + + historyButton.click(); + + let historyPanel = doc.getElementById("PanelUI-history"); + return BrowserTestUtils.waitForEvent(historyPanel, "ViewShown"); +} + +/** + * Closes the history panel and returns a promise that resolves as sooon + * as the panel is closed. + */ +async function hideHistoryPanel(doc = document) { + let historyView = doc.getElementById("PanelUI-history"); + let historyPanel = historyView.closest("panel"); + let promise = BrowserTestUtils.waitForEvent(historyPanel, "popuphidden"); + historyPanel.hidePopup(); + return promise; +} diff --git a/browser/components/customizableui/test/support/test_967000_charEncoding_page.html b/browser/components/customizableui/test/support/test_967000_charEncoding_page.html new file mode 100644 index 0000000000..7932b16f12 --- /dev/null +++ b/browser/components/customizableui/test/support/test_967000_charEncoding_page.html @@ -0,0 +1,11 @@ + + + + + Test page + + + + This is a test page + + diff --git a/browser/components/customizableui/test/unit/test_unified_extensions_migration.js b/browser/components/customizableui/test/unit/test_unified_extensions_migration.js new file mode 100644 index 0000000000..022b783d70 --- /dev/null +++ b/browser/components/customizableui/test/unit/test_unified_extensions_migration.js @@ -0,0 +1,373 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// We're in an xpcshell test but have an eslint browser test env applied; +// We definitely do need to manually import CustomizableUI. +// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix +const { CustomizableUI } = ChromeUtils.importESModule( + "resource:///modules/CustomizableUI.sys.mjs" +); + +do_get_profile(); + +// Make Cu.isInAutomation true. This is necessary so that we can use +// CustomizableUIInternal. +Services.prefs.setBoolPref( + "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer", + true +); + +const CustomizableUIInternal = CustomizableUI.getTestOnlyInternalProp( + "CustomizableUIInternal" +); + +// Migration 19 was the Unified Extensions migration version introduced +// in 109, so we'll run tests by artificially setting the migration version +// to one value earlier. +const PRIOR_MIGRATION_VERSION = 18; + +/** + * Writes customization state into CustomizableUI and then performs the forward migration + * for Unified Extensions. + * + * @param {object|null} stateObj An object that will be structure-cloned and + * written into CustomizableUI's internal `gSavedState` state variable. Should + * not include the currentVersion property, as this will be set automatically by + * function if stateObj is not null. + * @returns {object} + * the saved state object (minus the currentVersion property). + */ +function migrateForward(stateObj) { + // We make sure to use structuredClone here so that we don't end up comparing + // SAVED_STATE against itself. + let stateToSave = structuredClone(stateObj); + if (stateToSave) { + stateToSave.currentVersion = PRIOR_MIGRATION_VERSION; + } + + CustomizableUI.setTestOnlyInternalProp("gSavedState", stateToSave); + CustomizableUIInternal._updateForNewVersion(); + + let migratedState = CustomizableUI.getTestOnlyInternalProp("gSavedState"); + if (migratedState) { + delete migratedState.currentVersion; + } + return migratedState; +} + +/** + * Test that attempting a migration on a new profile with no saved + * state exits safely. + */ +add_task(async function test_no_saved_state() { + let migratedState = migrateForward(null); + + Assert.deepEqual( + migratedState, + null, + "gSavedState should not have been modified" + ); +}); + +/** + * Test that attempting a migration on a new profile with no saved + * state exits safely. + */ +add_task(async function test_no_saved_placements() { + let migratedState = migrateForward({}); + + Assert.deepEqual( + migratedState, + {}, + "gSavedState should not have been modified" + ); +}); + +/** + * Test that placements that don't involve any extension buttons are + * not changed during the migration. + */ +add_task(async function test_no_extensions() { + const SAVED_STATE = { + placements: { + "nav-bar": [ + "back-button", + "forward-button", + "spring", + "urlbar-container", + "save-to-pocket-button", + "reset-pbm-toolbar-button", + ], + "toolbar-menubar": [ + "home-button", + "menubar-items", + "spring", + "downloads-button", + ], + TabsToolbar: [ + "firefox-view-button", + "tabbrowser-tabs", + "new-tab-button", + "alltabs-button", + "developer-button", + ], + PersonalToolbar: ["personal-bookmarks", "fxa-toolbar-menu-button"], + "widget-overflow-fixed-list": ["privatebrowsing-button", "panic-button"], + }, + }; + + // ADDONS_AREA should end up with an empty array as its set of placements. + const EXPECTED_STATE = structuredClone(SAVED_STATE); + EXPECTED_STATE.placements[CustomizableUI.AREA_ADDONS] = []; + + let migratedState = migrateForward(SAVED_STATE); + + Assert.deepEqual( + migratedState, + EXPECTED_STATE, + "Got the expected state after the migration." + ); +}); + +/** + * Test that if there's an existing set of items in CustomizableUI.AREA_ADDONS, + * and no extension buttons to migrate from the overflow menu, then we don't + * change the state at all. + */ +add_task(async function test_existing_browser_actions_no_movement() { + const SAVED_STATE = { + placements: { + "nav-bar": [ + "back-button", + "forward-button", + "spring", + "urlbar-container", + "save-to-pocket-button", + "reset-pbm-toolbar-button", + ], + "toolbar-menubar": [ + "home-button", + "menubar-items", + "spring", + "downloads-button", + ], + TabsToolbar: [ + "firefox-view-button", + "tabbrowser-tabs", + "new-tab-button", + "alltabs-button", + "developer-button", + ], + PersonalToolbar: ["personal-bookmarks", "fxa-toolbar-menu-button"], + "widget-overflow-fixed-list": ["privatebrowsing-button", "panic-button"], + "unified-extensions-area": ["ext0-browser-action", "ext1-browser-action"], + }, + }; + + let migratedState = migrateForward(SAVED_STATE); + + Assert.deepEqual( + migratedState, + SAVED_STATE, + "The saved state should not have changed after migration." + ); +}); + +/** + * Test that we can migrate extension buttons out from the overflow panel + * into the addons panel. + */ +add_task(async function test_migrate_extension_buttons() { + const SAVED_STATE = { + placements: { + "nav-bar": [ + "back-button", + "forward-button", + "spring", + "urlbar-container", + "save-to-pocket-button", + "reset-pbm-toolbar-button", + ], + "toolbar-menubar": [ + "home-button", + "menubar-items", + "spring", + "downloads-button", + ], + TabsToolbar: [ + "firefox-view-button", + "tabbrowser-tabs", + "new-tab-button", + "alltabs-button", + "developer-button", + ], + PersonalToolbar: ["personal-bookmarks", "fxa-toolbar-menu-button"], + "widget-overflow-fixed-list": [ + "ext0-browser-action", + "privatebrowsing-button", + "ext1-browser-action", + "panic-button", + "ext2-browser-action", + ], + }, + }; + const EXPECTED_STATE = structuredClone(SAVED_STATE); + EXPECTED_STATE.placements[CustomizableUI.AREA_FIXED_OVERFLOW_PANEL] = [ + "privatebrowsing-button", + "panic-button", + ]; + EXPECTED_STATE.placements[CustomizableUI.AREA_ADDONS] = [ + "ext0-browser-action", + "ext1-browser-action", + "ext2-browser-action", + ]; + + let migratedState = migrateForward(SAVED_STATE); + + Assert.deepEqual( + migratedState, + EXPECTED_STATE, + "The saved state should not have changed after migration." + ); +}); + +/** + * Test that we won't overwrite existing placements within the addons panel + * if we migrate things over from the overflow panel. We'll prepend the + * migrated items to the addons panel instead. + */ +add_task(async function test_migrate_extension_buttons_no_overwrite() { + const SAVED_STATE = { + placements: { + "nav-bar": [ + "back-button", + "forward-button", + "spring", + "urlbar-container", + "save-to-pocket-button", + "reset-pbm-toolbar-button", + ], + "toolbar-menubar": [ + "home-button", + "menubar-items", + "spring", + "downloads-button", + ], + TabsToolbar: [ + "firefox-view-button", + "tabbrowser-tabs", + "new-tab-button", + "alltabs-button", + "developer-button", + ], + PersonalToolbar: ["personal-bookmarks", "fxa-toolbar-menu-button"], + "widget-overflow-fixed-list": [ + "ext0-browser-action", + "privatebrowsing-button", + "ext1-browser-action", + "panic-button", + "ext2-browser-action", + ], + "unified-extensions-area": ["ext3-browser-action", "ext4-browser-action"], + }, + }; + const EXPECTED_STATE = structuredClone(SAVED_STATE); + EXPECTED_STATE.placements[CustomizableUI.AREA_FIXED_OVERFLOW_PANEL] = [ + "privatebrowsing-button", + "panic-button", + ]; + EXPECTED_STATE.placements[CustomizableUI.AREA_ADDONS] = [ + "ext0-browser-action", + "ext1-browser-action", + "ext2-browser-action", + "ext3-browser-action", + "ext4-browser-action", + ]; + + let migratedState = migrateForward(SAVED_STATE); + + Assert.deepEqual( + migratedState, + EXPECTED_STATE, + "The saved state should not have changed after migration." + ); +}); + +/** + * Test that extension buttons from areas other than the overflow panel + * won't be moved. + */ +add_task(async function test_migrate_extension_buttons_elsewhere() { + const SAVED_STATE = { + placements: { + "nav-bar": [ + "back-button", + "ext0-browser-action", + "forward-button", + "ext1-browser-action", + "spring", + "ext2-browser-action", + "urlbar-container", + "ext3-browser-action", + "save-to-pocket-button", + "reset-pbm-toolbar-button", + "ext4-browser-action", + ], + "toolbar-menubar": [ + "home-button", + "ext5-browser-action", + "menubar-items", + "ext6-browser-action", + "spring", + "ext7-browser-action", + "downloads-button", + "ext8-browser-action", + ], + TabsToolbar: [ + "firefox-view-button", + "ext9-browser-action", + "tabbrowser-tabs", + "ext10-browser-action", + "new-tab-button", + "ext11-browser-action", + "alltabs-button", + "ext12-browser-action", + "developer-button", + "ext13-browser-action", + ], + PersonalToolbar: [ + "personal-bookmarks", + "ext14-browser-action", + "fxa-toolbar-menu-button", + "ext15-browser-action", + ], + "widget-overflow-fixed-list": [ + "ext16-browser-action", + "privatebrowsing-button", + "ext17-browser-action", + "panic-button", + "ext18-browser-action", + ], + }, + }; + const EXPECTED_STATE = structuredClone(SAVED_STATE); + EXPECTED_STATE.placements[CustomizableUI.AREA_FIXED_OVERFLOW_PANEL] = [ + "privatebrowsing-button", + "panic-button", + ]; + EXPECTED_STATE.placements[CustomizableUI.AREA_ADDONS] = [ + "ext16-browser-action", + "ext17-browser-action", + "ext18-browser-action", + ]; + + let migratedState = migrateForward(SAVED_STATE); + + Assert.deepEqual( + migratedState, + EXPECTED_STATE, + "The saved state should not have changed after migration." + ); +}); diff --git a/browser/components/customizableui/test/unit/xpcshell.toml b/browser/components/customizableui/test/unit/xpcshell.toml new file mode 100644 index 0000000000..029a8a962d --- /dev/null +++ b/browser/components/customizableui/test/unit/xpcshell.toml @@ -0,0 +1,6 @@ +[DEFAULT] +head = '' +skip-if = ["os == 'android'"] # bug 1730213 +firefox-appdir = "browser" + +["test_unified_extensions_migration.js"] diff --git a/browser/components/distribution.sys.mjs b/browser/components/distribution.sys.mjs new file mode 100644 index 0000000000..369de15ab2 --- /dev/null +++ b/browser/components/distribution.sys.mjs @@ -0,0 +1,652 @@ +/* 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 DISTRIBUTION_CUSTOMIZATION_COMPLETE_TOPIC = + "distribution-customization-complete"; + +const PREF_CACHED_FILE_EXISTENCE = "distribution.iniFile.exists.value"; +const PREF_CACHED_FILE_APPVERSION = "distribution.iniFile.exists.appversion"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +export function DistributionCustomizer() {} + +DistributionCustomizer.prototype = { + // These prefixes must only contain characters + // allowed by PlacesUtils.isValidGuid + BOOKMARK_GUID_PREFIX: "DstB-", + FOLDER_GUID_PREFIX: "DstF-", + + get _iniFile() { + // For parallel xpcshell testing purposes allow loading the distribution.ini + // file from the profile folder through an hidden pref. + let loadFromProfile = Services.prefs.getBoolPref( + "distribution.testing.loadFromProfile", + false + ); + + let iniFile; + try { + iniFile = loadFromProfile + ? Services.dirsvc.get("ProfD", Ci.nsIFile) + : Services.dirsvc.get("XREAppDist", Ci.nsIFile); + if (loadFromProfile) { + iniFile.leafName = "distribution"; + } + iniFile.append("distribution.ini"); + } catch (ex) {} + + this.__defineGetter__("_iniFile", () => iniFile); + return iniFile; + }, + + get _hasDistributionIni() { + if (Services.prefs.prefHasUserValue(PREF_CACHED_FILE_EXISTENCE)) { + let knownForVersion = Services.prefs.getStringPref( + PREF_CACHED_FILE_APPVERSION, + "unknown" + ); + // StartupCacheInfo isn't available in xpcshell tests. + if ( + knownForVersion == AppConstants.MOZ_APP_VERSION && + (Cu.isInAutomation || + Cc["@mozilla.org/startupcacheinfo;1"].getService( + Ci.nsIStartupCacheInfo + ).FoundDiskCacheOnInit) + ) { + return Services.prefs.getBoolPref(PREF_CACHED_FILE_EXISTENCE); + } + } + + let fileExists = this._iniFile.exists(); + Services.prefs.setBoolPref(PREF_CACHED_FILE_EXISTENCE, fileExists); + Services.prefs.setStringPref( + PREF_CACHED_FILE_APPVERSION, + AppConstants.MOZ_APP_VERSION + ); + + this.__defineGetter__("_hasDistributionIni", () => fileExists); + return fileExists; + }, + + get _ini() { + let ini = null; + try { + if (this._hasDistributionIni) { + ini = Cc["@mozilla.org/xpcom/ini-parser-factory;1"] + .getService(Ci.nsIINIParserFactory) + .createINIParser(this._iniFile); + } + } catch (e) { + if (e.result == Cr.NS_ERROR_FILE_NOT_FOUND) { + // We probably had cached the file existence as true, + // but it no longer exists. We could set the new cache + // value here, but let's just invalidate the cache and + // let it be cached by a single code path on the next check. + Services.prefs.clearUserPref(PREF_CACHED_FILE_EXISTENCE); + } else { + // Unable to parse INI. + console.error("Unable to parse distribution.ini"); + } + } + this.__defineGetter__("_ini", () => ini); + return this._ini; + }, + + get _locale() { + const locale = Services.locale.requestedLocale || "en-US"; + this.__defineGetter__("_locale", () => locale); + return this._locale; + }, + + get _language() { + let language = this._locale.split("-")[0]; + this.__defineGetter__("_language", () => language); + return this._language; + }, + + async _removeDistributionBookmarks() { + await lazy.PlacesUtils.bookmarks.fetch( + { guidPrefix: this.BOOKMARK_GUID_PREFIX }, + bookmark => lazy.PlacesUtils.bookmarks.remove(bookmark).catch() + ); + await lazy.PlacesUtils.bookmarks.fetch( + { guidPrefix: this.FOLDER_GUID_PREFIX }, + folder => { + lazy.PlacesUtils.bookmarks.remove(folder).catch(); + } + ); + }, + + async _parseBookmarksSection(parentGuid, section) { + let keys = Array.from(this._ini.getKeys(section)).sort(); + let re = /^item\.(\d+)\.(\w+)\.?(\w*)/; + let items = {}; + let defaultIndex = -1; + let maxIndex = -1; + + for (let key of keys) { + let m = re.exec(key); + if (m) { + let [, itemIndex, iprop, ilocale] = m; + itemIndex = parseInt(itemIndex); + + if (ilocale) { + continue; + } + + if (keys.includes(key + "." + this._locale)) { + key += "." + this._locale; + } else if (keys.includes(key + "." + this._language)) { + key += "." + this._language; + } + + if (!items[itemIndex]) { + items[itemIndex] = {}; + } + items[itemIndex][iprop] = this._ini.getString(section, key); + + if (iprop == "type" && items[itemIndex].type == "default") { + defaultIndex = itemIndex; + } + + if (maxIndex < itemIndex) { + maxIndex = itemIndex; + } + } else { + dump(`Key did not match: ${key}\n`); + } + } + + let prependIndex = 0; + for (let itemIndex = 0; itemIndex <= maxIndex; itemIndex++) { + if (!items[itemIndex]) { + continue; + } + + let index = lazy.PlacesUtils.bookmarks.DEFAULT_INDEX; + let item = items[itemIndex]; + + switch (item.type) { + case "default": + break; + + case "folder": + if (itemIndex < defaultIndex) { + index = prependIndex++; + } + + let folder = await lazy.PlacesUtils.bookmarks.insert({ + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + guid: lazy.PlacesUtils.generateGuidWithPrefix( + this.FOLDER_GUID_PREFIX + ), + parentGuid, + index, + title: item.title, + }); + + await this._parseBookmarksSection( + folder.guid, + "BookmarksFolder-" + item.folderId + ); + break; + + case "separator": + if (itemIndex < defaultIndex) { + index = prependIndex++; + } + + await lazy.PlacesUtils.bookmarks.insert({ + type: lazy.PlacesUtils.bookmarks.TYPE_SEPARATOR, + parentGuid, + index, + }); + break; + + case "livemark": + // Livemarks are no more supported, instead of a livemark we'll insert + // a bookmark pointing to the site uri, if available. + if (!item.siteLink) { + break; + } + if (itemIndex < defaultIndex) { + index = prependIndex++; + } + + await lazy.PlacesUtils.bookmarks.insert({ + parentGuid, + index, + title: item.title, + url: item.siteLink, + }); + break; + + case "bookmark": + default: + if (itemIndex < defaultIndex) { + index = prependIndex++; + } + + await lazy.PlacesUtils.bookmarks.insert({ + guid: lazy.PlacesUtils.generateGuidWithPrefix( + this.BOOKMARK_GUID_PREFIX + ), + parentGuid, + index, + title: item.title, + url: item.link, + }); + + 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( + Services.io.newURI(item.link), + faviconURI, + false, + lazy.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + } catch (e) { + console.error(e); + } + } + + break; + } + } + }, + + _newProfile: false, + _customizationsApplied: false, + applyCustomizations: function DIST_applyCustomizations() { + this._customizationsApplied = true; + + if (!Services.prefs.prefHasUserValue("browser.migration.version")) { + this._newProfile = true; + } + + if (!this._ini) { + return this._checkCustomizationComplete(); + } + + if (!this._prefDefaultsApplied) { + this.applyPrefDefaults(); + } + }, + + _bookmarksApplied: false, + async applyBookmarks() { + let prefs = Services.prefs + .getChildList("distribution.yandex") + .concat(Services.prefs.getChildList("distribution.mailru")) + .concat(Services.prefs.getChildList("distribution.okru")); + if (prefs.length) { + let extensionIDs = [ + "sovetnik-yandex@yandex.ru", + "vb@yandex.ru", + "ntp-mail@corp.mail.ru", + "ntp-okru@corp.mail.ru", + ]; + for (let extensionID of extensionIDs) { + let addon = await lazy.AddonManager.getAddonByID(extensionID); + if (addon) { + await addon.disable(); + } + } + for (let pref of prefs) { + Services.prefs.clearUserPref(pref); + } + await this._removeDistributionBookmarks(); + } else { + await this._doApplyBookmarks(); + } + this._bookmarksApplied = true; + this._checkCustomizationComplete(); + }, + + async _doApplyBookmarks() { + if (!this._ini) { + return; + } + + let sections = enumToObject(this._ini.getSections()); + + // The global section, and several of its fields, is required + // (we also check here to be consistent with applyPrefDefaults below) + if (!sections.Global) { + return; + } + + let globalPrefs = enumToObject(this._ini.getKeys("Global")); + if (!(globalPrefs.id && globalPrefs.version && globalPrefs.about)) { + return; + } + + let bmProcessedPref; + try { + bmProcessedPref = this._ini.getString( + "Global", + "bookmarks.initialized.pref" + ); + } catch (e) { + bmProcessedPref = + "distribution." + + this._ini.getString("Global", "id") + + ".bookmarksProcessed"; + } + + if (Services.prefs.getBoolPref(bmProcessedPref, false)) { + return; + } + + let { ProfileAge } = ChromeUtils.importESModule( + "resource://gre/modules/ProfileAge.sys.mjs" + ); + let profileAge = await ProfileAge(); + let resetDate = await profileAge.reset; + + // If the profile has been reset, don't recreate bookmarks. + if (!resetDate) { + if (sections.BookmarksMenu) { + await this._parseBookmarksSection( + lazy.PlacesUtils.bookmarks.menuGuid, + "BookmarksMenu" + ); + } + if (sections.BookmarksToolbar) { + await this._parseBookmarksSection( + lazy.PlacesUtils.bookmarks.toolbarGuid, + "BookmarksToolbar" + ); + } + } + Services.prefs.setBoolPref(bmProcessedPref, true); + }, + + _prefDefaultsApplied: false, + applyPrefDefaults: function DIST_applyPrefDefaults() { + this._prefDefaultsApplied = true; + if (!this._ini) { + return this._checkCustomizationComplete(); + } + + let sections = enumToObject(this._ini.getSections()); + + // The global section, and several of its fields, is required + if (!sections.Global) { + return this._checkCustomizationComplete(); + } + let globalPrefs = enumToObject(this._ini.getKeys("Global")); + if (!(globalPrefs.id && globalPrefs.version)) { + return this._checkCustomizationComplete(); + } + let distroID = this._ini.getString("Global", "id"); + if (!globalPrefs.about && !distroID.startsWith("mozilla-")) { + // About is required unless it is a mozilla distro. + return this._checkCustomizationComplete(); + } + + let defaults = Services.prefs.getDefaultBranch(null); + + // Global really contains info we set as prefs. They're only + // separate because they are "special" (read: required) + + defaults.setStringPref("distribution.id", distroID); + + if ( + distroID.startsWith("yandex") || + distroID.startsWith("mailru") || + distroID.startsWith("okru") + ) { + this.__defineGetter__("_ini", () => null); + return this._checkCustomizationComplete(); + } + + defaults.setStringPref( + "distribution.version", + this._ini.getString("Global", "version") + ); + + let partnerAbout; + try { + if (globalPrefs["about." + this._locale]) { + partnerAbout = this._ini.getString("Global", "about." + this._locale); + } else if (globalPrefs["about." + this._language]) { + partnerAbout = this._ini.getString("Global", "about." + this._language); + } else { + partnerAbout = this._ini.getString("Global", "about"); + } + defaults.setStringPref("distribution.about", partnerAbout); + } catch (e) { + /* ignore bad prefs due to bug 895473 and move on */ + } + + /* order of precedence is locale->language->default */ + + let preferences = new Map(); + + if (sections.Preferences) { + for (let key of this._ini.getKeys("Preferences")) { + let value = this._ini.getString("Preferences", key); + if (value) { + preferences.set(key, value); + } + } + } + + if (sections["Preferences-" + this._language]) { + for (let key of this._ini.getKeys("Preferences-" + this._language)) { + let value = this._ini.getString("Preferences-" + this._language, key); + if (value) { + preferences.set(key, value); + } else { + // If something was set by Preferences, but it's empty in language, + // it should be removed. + preferences.delete(key); + } + } + } + + if (sections["Preferences-" + this._locale]) { + for (let key of this._ini.getKeys("Preferences-" + this._locale)) { + let value = this._ini.getString("Preferences-" + this._locale, key); + if (value) { + preferences.set(key, value); + } else { + // If something was set by Preferences, but it's empty in locale, + // it should be removed. + preferences.delete(key); + } + } + } + + for (let [prefName, prefValue] of preferences) { + prefValue = prefValue.replace(/%LOCALE%/g, this._locale); + prefValue = prefValue.replace(/%LANGUAGE%/g, this._language); + prefValue = parseValue(prefValue); + try { + if (prefName == "general.useragent.locale") { + defaults.setStringPref("intl.locale.requested", prefValue); + } else { + switch (typeof prefValue) { + case "boolean": + defaults.setBoolPref(prefName, prefValue); + break; + case "number": + defaults.setIntPref(prefName, prefValue); + break; + case "string": + defaults.setStringPref(prefName, prefValue); + break; + } + } + } catch (e) { + /* ignore bad prefs and move on */ + } + } + + if (this._ini.getString("Global", "id") == "yandex") { + // All yandex distributions have the same distribution ID, + // so we're using an internal preference to name them correctly. + // This is needed for search to work properly. + try { + defaults.setStringPref( + "distribution.id", + defaults + .get("extensions.yasearch@yandex.ru.clids.vendor") + .replace("firefox", "yandex") + ); + } catch (e) { + // Just use the default distribution ID. + } + } + + let localizedStr = Cc["@mozilla.org/pref-localizedstring;1"].createInstance( + Ci.nsIPrefLocalizedString + ); + + let localizablePreferences = new Map(); + + if (sections.LocalizablePreferences) { + for (let key of this._ini.getKeys("LocalizablePreferences")) { + let value = this._ini.getString("LocalizablePreferences", key); + if (value) { + localizablePreferences.set(key, value); + } + } + } + + if (sections["LocalizablePreferences-" + this._language]) { + for (let key of this._ini.getKeys( + "LocalizablePreferences-" + this._language + )) { + let value = this._ini.getString( + "LocalizablePreferences-" + this._language, + key + ); + if (value) { + localizablePreferences.set(key, value); + } else { + // If something was set by Preferences, but it's empty in language, + // it should be removed. + localizablePreferences.delete(key); + } + } + } + + if (sections["LocalizablePreferences-" + this._locale]) { + for (let key of this._ini.getKeys( + "LocalizablePreferences-" + this._locale + )) { + let value = this._ini.getString( + "LocalizablePreferences-" + this._locale, + key + ); + if (value) { + localizablePreferences.set(key, value); + } else { + // If something was set by Preferences, but it's empty in locale, + // it should be removed. + localizablePreferences.delete(key); + } + } + } + + for (let [prefName, prefValue] of localizablePreferences) { + prefValue = parseValue(prefValue); + prefValue = prefValue.replace(/%LOCALE%/g, this._locale); + prefValue = prefValue.replace(/%LANGUAGE%/g, this._language); + localizedStr.data = "data:text/plain," + prefName + "=" + prefValue; + try { + defaults.setComplexValue( + prefName, + Ci.nsIPrefLocalizedString, + localizedStr + ); + } catch (e) { + /* ignore bad prefs and move on */ + } + } + + return this._checkCustomizationComplete(); + }, + + _checkCustomizationComplete: function DIST__checkCustomizationComplete() { + const BROWSER_DOCURL = AppConstants.BROWSER_CHROME_URL; + + if (this._newProfile) { + try { + var showPersonalToolbar = Services.prefs.getBoolPref( + "browser.showPersonalToolbar" + ); + if (showPersonalToolbar) { + Services.prefs.setCharPref( + "browser.toolbars.bookmarks.visibility", + "always" + ); + } + } catch (e) {} + try { + var showMenubar = Services.prefs.getBoolPref("browser.showMenubar"); + if (showMenubar) { + Services.xulStore.setValue( + BROWSER_DOCURL, + "toolbar-menubar", + "autohide", + "false" + ); + } + } catch (e) {} + } + + let prefDefaultsApplied = this._prefDefaultsApplied || !this._ini; + if ( + this._customizationsApplied && + this._bookmarksApplied && + prefDefaultsApplied + ) { + Services.obs.notifyObservers( + null, + DISTRIBUTION_CUSTOMIZATION_COMPLETE_TOPIC + ); + } + }, +}; + +function parseValue(value) { + try { + value = JSON.parse(value); + } catch (e) { + // JSON.parse catches numbers and booleans. + // Anything else, we assume is a string. + // Remove the quotes that aren't needed anymore. + value = value.replace(/^"/, ""); + value = value.replace(/"$/, ""); + } + return value; +} + +function enumToObject(UTF8Enumerator) { + let ret = {}; + for (let i of UTF8Enumerator) { + ret[i] = 1; + } + return ret; +} diff --git a/browser/components/doh/DoHConfig.sys.mjs b/browser/components/doh/DoHConfig.sys.mjs new file mode 100644 index 0000000000..5d35940d55 --- /dev/null +++ b/browser/components/doh/DoHConfig.sys.mjs @@ -0,0 +1,356 @@ +/* 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 provides an interface to access DoH configuration - e.g. whether + * DoH is enabled, whether capabilities are enabled, etc. The configuration is + * sourced from either Remote Settings or pref values, with Remote Settings + * being preferred. + */ + +import { RemoteSettings } from "resource://services-settings/remote-settings.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", + Region: "resource://gre/modules/Region.sys.mjs", +}); + +const kGlobalPrefBranch = "doh-rollout"; +var kRegionPrefBranch; + +const kConfigPrefs = { + kEnabledPref: "enabled", + kProvidersPref: "provider-list", + kTRRSelectionEnabledPref: "trr-selection.enabled", + kTRRSelectionProvidersPref: "trr-selection.provider-list", + kTRRSelectionCommitResultPref: "trr-selection.commit-result", + kProviderSteeringEnabledPref: "provider-steering.enabled", + kProviderSteeringListPref: "provider-steering.provider-list", +}; + +const kPrefChangedTopic = "nsPref:changed"; + +const gProvidersCollection = RemoteSettings("doh-providers"); +const gConfigCollection = RemoteSettings("doh-config"); + +function getPrefValueRegionFirst(prefName) { + let regionalPrefName = `${kRegionPrefBranch}.${prefName}`; + let regionalPrefValue = lazy.Preferences.get(regionalPrefName); + if (regionalPrefValue !== undefined) { + return regionalPrefValue; + } + return lazy.Preferences.get(`${kGlobalPrefBranch}.${prefName}`); +} + +function getProviderListFromPref(prefName) { + let prefVal = getPrefValueRegionFirst(prefName); + if (prefVal) { + try { + return JSON.parse(prefVal); + } catch (e) { + console.error(`DoH provider list not a valid JSON array: ${prefName}`); + } + } + return undefined; +} + +// Generate a base config object with getters that return pref values. When +// Remote Settings values become available, a new config object will be +// generated from this and specific fields will be replaced by the RS value. +// If we use a class to store base config and instantiate new config objects +// from it, we lose the ability to override getters because they are defined +// as non-configureable properties on class instances. So just use a function. +function makeBaseConfigObject() { + function makeConfigProperty({ + obj, + propName, + defaultVal, + prefName, + isProviderList, + }) { + let prefFn = isProviderList + ? getProviderListFromPref + : getPrefValueRegionFirst; + + let overridePropName = "_" + propName; + + Object.defineProperty(obj, propName, { + get() { + // If a pref value exists, it gets top priority. Otherwise, if it has an + // explicitly set value (from Remote Settings), we return that. + let prefVal = prefFn(prefName); + if (prefVal !== undefined) { + return prefVal; + } + if (this[overridePropName] !== undefined) { + return this[overridePropName]; + } + return defaultVal; + }, + set(val) { + this[overridePropName] = val; + }, + }); + } + let newConfig = { + get fallbackProviderURI() { + return this.providerList[0]?.uri; + }, + trrSelection: {}, + providerSteering: {}, + }; + makeConfigProperty({ + obj: newConfig, + propName: "enabled", + defaultVal: false, + prefName: kConfigPrefs.kEnabledPref, + isProviderList: false, + }); + makeConfigProperty({ + obj: newConfig, + propName: "providerList", + defaultVal: [], + prefName: kConfigPrefs.kProvidersPref, + isProviderList: true, + }); + makeConfigProperty({ + obj: newConfig.trrSelection, + propName: "enabled", + defaultVal: false, + prefName: kConfigPrefs.kTRRSelectionEnabledPref, + isProviderList: false, + }); + makeConfigProperty({ + obj: newConfig.trrSelection, + propName: "commitResult", + defaultVal: false, + prefName: kConfigPrefs.kTRRSelectionCommitResultPref, + isProviderList: false, + }); + makeConfigProperty({ + obj: newConfig.trrSelection, + propName: "providerList", + defaultVal: [], + prefName: kConfigPrefs.kTRRSelectionProvidersPref, + isProviderList: true, + }); + makeConfigProperty({ + obj: newConfig.providerSteering, + propName: "enabled", + defaultVal: false, + prefName: kConfigPrefs.kProviderSteeringEnabledPref, + isProviderList: false, + }); + makeConfigProperty({ + obj: newConfig.providerSteering, + propName: "providerList", + defaultVal: [], + prefName: kConfigPrefs.kProviderSteeringListPref, + isProviderList: true, + }); + return newConfig; +} + +export const DoHConfigController = { + initComplete: null, + _resolveInitComplete: null, + + // This field always contains the current config state, for + // consumer use. + currentConfig: makeBaseConfigObject(), + + // Loads the client's region via Region.sys.mjs. This might mean waiting + // until the region is available. + async loadRegion() { + await new Promise(resolve => { + // If the region has changed since it was last set, update the pref. + let homeRegionChanged = lazy.Preferences.get( + `${kGlobalPrefBranch}.home-region-changed` + ); + if (homeRegionChanged) { + lazy.Preferences.reset(`${kGlobalPrefBranch}.home-region-changed`); + lazy.Preferences.reset(`${kGlobalPrefBranch}.home-region`); + } + + let homeRegion = lazy.Preferences.get(`${kGlobalPrefBranch}.home-region`); + if (homeRegion) { + kRegionPrefBranch = `${kGlobalPrefBranch}.${homeRegion.toLowerCase()}`; + resolve(); + return; + } + + let updateRegionAndResolve = () => { + kRegionPrefBranch = `${kGlobalPrefBranch}.${lazy.Region.home.toLowerCase()}`; + lazy.Preferences.set( + `${kGlobalPrefBranch}.home-region`, + lazy.Region.home + ); + resolve(); + }; + + if (lazy.Region.home) { + updateRegionAndResolve(); + return; + } + + Services.obs.addObserver(function obs(sub, top, data) { + Services.obs.removeObserver(obs, lazy.Region.REGION_TOPIC); + updateRegionAndResolve(); + }, lazy.Region.REGION_TOPIC); + }); + + // Finally, reload config. + await this.updateFromRemoteSettings(); + }, + + async init() { + await this.loadRegion(); + + Services.prefs.addObserver(`${kGlobalPrefBranch}.`, this, true); + Services.obs.addObserver(this, lazy.Region.REGION_TOPIC); + + gProvidersCollection.on("sync", this.updateFromRemoteSettings); + gConfigCollection.on("sync", this.updateFromRemoteSettings); + + this._resolveInitComplete(); + }, + + // Useful for tests to set prior state before init() + async _uninit() { + await this.initComplete; + + Services.prefs.removeObserver(`${kGlobalPrefBranch}`, this); + Services.obs.removeObserver(this, lazy.Region.REGION_TOPIC); + + gProvidersCollection.off("sync", this.updateFromRemoteSettings); + gConfigCollection.off("sync", this.updateFromRemoteSettings); + + this.initComplete = new Promise(resolve => { + this._resolveInitComplete = resolve; + }); + }, + + observe(subject, topic, data) { + switch (topic) { + case kPrefChangedTopic: + let allowedPrefs = Object.getOwnPropertyNames(kConfigPrefs).map( + k => kConfigPrefs[k] + ); + if ( + !allowedPrefs.some(pref => + [ + `${kRegionPrefBranch}.${pref}`, + `${kGlobalPrefBranch}.${pref}`, + ].includes(data) + ) + ) { + break; + } + this.notifyNewConfig(); + break; + case lazy.Region.REGION_TOPIC: + lazy.Preferences.set(`${kGlobalPrefBranch}.home-region-changed`, true); + break; + } + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + + // Creates new config object from currently available + // Remote Settings values. + async updateFromRemoteSettings() { + let providers = await gProvidersCollection.get(); + let config = await gConfigCollection.get(); + + let providersById = new Map(); + providers.forEach(p => providersById.set(p.id, p)); + + let configByRegion = new Map(); + config.forEach(c => { + c.id = c.id.toLowerCase(); + configByRegion.set(c.id, c); + }); + + let homeRegion = lazy.Preferences.get(`${kGlobalPrefBranch}.home-region`); + let localConfig = + configByRegion.get(homeRegion?.toLowerCase()) || + configByRegion.get("global"); + + // Make a new config object first, mutate it as needed, then synchronously + // replace the currentConfig object at the end to ensure atomicity. + let newConfig = makeBaseConfigObject(); + + if (!localConfig) { + DoHConfigController.currentConfig = newConfig; + DoHConfigController.notifyNewConfig(); + return; + } + + if (localConfig.rolloutEnabled) { + newConfig.enabled = true; + } + + let parseProviderList = (list, checkFn) => { + let parsedList = []; + list?.split(",")?.forEach(p => { + p = p.trim(); + if (!p.length) { + return; + } + p = providersById.get(p); + if (!p || (checkFn && !checkFn(p))) { + return; + } + parsedList.push(p); + }); + return parsedList; + }; + + let regionalProviders = parseProviderList(localConfig.providers); + if (regionalProviders?.length) { + newConfig.providerList = regionalProviders; + } + + if (localConfig.steeringEnabled) { + let steeringProviders = parseProviderList( + localConfig.steeringProviders, + p => p.canonicalName?.length + ); + if (steeringProviders?.length) { + newConfig.providerSteering.providerList = steeringProviders; + newConfig.providerSteering.enabled = true; + } + } + + if (localConfig.autoDefaultEnabled) { + let defaultProviders = parseProviderList( + localConfig.autoDefaultProviders + ); + if (defaultProviders?.length) { + newConfig.trrSelection.providerList = defaultProviders; + newConfig.trrSelection.enabled = true; + } + } + + // Finally, update the currentConfig object synchronously. + DoHConfigController.currentConfig = newConfig; + + DoHConfigController.notifyNewConfig(); + }, + + kConfigUpdateTopic: "doh-config-updated", + notifyNewConfig() { + Services.obs.notifyObservers(null, this.kConfigUpdateTopic); + }, +}; + +DoHConfigController.initComplete = new Promise(resolve => { + DoHConfigController._resolveInitComplete = resolve; +}); +DoHConfigController.init(); diff --git a/browser/components/doh/DoHController.sys.mjs b/browser/components/doh/DoHController.sys.mjs new file mode 100644 index 0000000000..2ed87c6b68 --- /dev/null +++ b/browser/components/doh/DoHController.sys.mjs @@ -0,0 +1,791 @@ +/* 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 runs the automated heuristics to enable/disable DoH on different + * networks. Heuristics are run at startup and upon network changes. + * Heuristics are disabled if the user sets their DoH provider or mode manually. + */ +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + ClientID: "resource://gre/modules/ClientID.sys.mjs", + DoHConfigController: "resource:///modules/DoHConfig.sys.mjs", + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + Heuristics: "resource:///modules/DoHHeuristics.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + Preferences: "resource://gre/modules/Preferences.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +// When this is set we suppress automatic TRR selection beyond dry-run as well +// as sending observer notifications during heuristics throttling. +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "kIsInAutomation", + "doh-rollout._testing", + false +); + +// We wait until the network has been stably up for this many milliseconds +// before triggering a heuristics run. +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "kNetworkDebounceTimeout", + "doh-rollout.network-debounce-timeout", + 1000 +); + +// If consecutive heuristics runs are attempted within this period after a first, +// we suppress them for this duration, at the end of which point we decide whether +// to do one coalesced run or to extend the timer if the rate limit was exceeded. +// Note that the very first run is allowed, after which we start the timer. +// This throttling is necessary due to evidence of clients that experience +// network volatility leading to thousands of runs per hour. See bug 1626083. +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "kHeuristicsThrottleTimeout", + "doh-rollout.heuristics-throttle-timeout", + 15000 +); + +// After the throttle timeout described above, if there are more than this many +// heuristics attempts during the timeout, we restart the timer without running +// heuristics. Thus, heuristics are suppressed completely as long as the rate +// exceeds this limit. +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "kHeuristicsRateLimit", + "doh-rollout.heuristics-throttle-rate-limit", + 2 +); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gCaptivePortalService", + "@mozilla.org/network/captive-portal-service;1", + "nsICaptivePortalService" +); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gNetworkLinkService", + "@mozilla.org/network/network-link-service;1", + "nsINetworkLinkService" +); + +// Stores whether we've done first-run. +const FIRST_RUN_PREF = "doh-rollout.doneFirstRun"; + +// Set when we detect that the user set their DoH provider or mode manually. +// If set, we don't run heuristics. +const DISABLED_PREF = "doh-rollout.disable-heuristics"; + +// Set when we detect either a non-DoH enterprise policy, or a DoH policy that +// tells us to disable it. This pref's effect is to suppress the opt-out CFR. +const SKIP_HEURISTICS_PREF = "doh-rollout.skipHeuristicsCheck"; + +// Whether to clear doh-rollout.mode on shutdown. When false, the mode value +// that exists at shutdown will be used at startup until heuristics re-run. +const CLEAR_ON_SHUTDOWN_PREF = "doh-rollout.clearModeOnShutdown"; + +const BREADCRUMB_PREF = "doh-rollout.self-enabled"; + +// Necko TRR prefs to watch for user-set values. +const NETWORK_TRR_MODE_PREF = "network.trr.mode"; +const NETWORK_TRR_URI_PREF = "network.trr.uri"; + +const ROLLOUT_MODE_PREF = "doh-rollout.mode"; +const ROLLOUT_URI_PREF = "doh-rollout.uri"; + +const TRR_SELECT_DRY_RUN_RESULT_PREF = + "doh-rollout.trr-selection.dry-run-result"; + +const NATIVE_FALLBACK_WARNING_PREF = "network.trr.display_fallback_warning"; +const NATIVE_FALLBACK_WARNING_HEURISTIC_LIST_PREF = + "network.trr.fallback_warning_heuristic_list"; + +const HEURISTICS_TELEMETRY_CATEGORY = "doh"; +const TRRSELECT_TELEMETRY_CATEGORY = "security.doh.trrPerformance"; + +const kLinkStatusChangedTopic = "network:link-status-changed"; +const kConnectivityTopic = "network:captive-portal-connectivity-changed"; +const kPrefChangedTopic = "nsPref:changed"; + +// Helper function to hash the network ID concatenated with telemetry client ID. +// This prevents us from being able to tell if 2 clients are on the same network. +function getHashedNetworkID() { + let currentNetworkID = lazy.gNetworkLinkService.networkID; + if (!currentNetworkID) { + return ""; + } + + let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + + hasher.init(Ci.nsICryptoHash.SHA256); + // Concat the client ID with the network ID before hashing. + let clientNetworkID = lazy.ClientID.getClientID() + currentNetworkID; + hasher.update( + clientNetworkID.split("").map(c => c.charCodeAt(0)), + clientNetworkID.length + ); + return hasher.finish(true); +} + +export const DoHController = { + _heuristicsAreEnabled: false, + + async init() { + Services.telemetry.setEventRecordingEnabled( + HEURISTICS_TELEMETRY_CATEGORY, + true + ); + Services.telemetry.setEventRecordingEnabled( + TRRSELECT_TELEMETRY_CATEGORY, + true + ); + + await lazy.DoHConfigController.initComplete; + + Services.obs.addObserver(this, lazy.DoHConfigController.kConfigUpdateTopic); + lazy.Preferences.observe(NETWORK_TRR_MODE_PREF, this); + lazy.Preferences.observe(NETWORK_TRR_URI_PREF, this); + lazy.Preferences.observe(NATIVE_FALLBACK_WARNING_PREF, this); + lazy.Preferences.observe(NATIVE_FALLBACK_WARNING_HEURISTIC_LIST_PREF, this); + + if (lazy.DoHConfigController.currentConfig.enabled) { + // At init time set these heuristics to false if we may run heuristics + for (let key of lazy.Heuristics.Telemetry.heuristicNames()) { + Services.telemetry.keyedScalarSet( + "networking.doh_heuristic_ever_tripped", + key, + false + ); + } + + await this.maybeEnableHeuristics(); + } else if (lazy.Preferences.get(FIRST_RUN_PREF, false)) { + await this.rollback(); + } + + this._asyncShutdownBlocker = async () => { + await this.disableHeuristics("shutdown"); + }; + + lazy.AsyncShutdown.profileBeforeChange.addBlocker( + "DoHController: clear state and remove observers", + this._asyncShutdownBlocker + ); + + lazy.Preferences.set(FIRST_RUN_PREF, true); + }, + + // Also used by tests to reset DoHController state (prefs are not cleared + // here - tests do that when needed between _uninit and init). + async _uninit() { + Services.obs.removeObserver( + this, + lazy.DoHConfigController.kConfigUpdateTopic + ); + lazy.Preferences.ignore(NETWORK_TRR_MODE_PREF, this); + lazy.Preferences.ignore(NETWORK_TRR_URI_PREF, this); + lazy.AsyncShutdown.profileBeforeChange.removeBlocker( + this._asyncShutdownBlocker + ); + await this.disableHeuristics("shutdown"); + }, + + // Called to reset state when a new config is available. + resetPromise: Promise.resolve(), + async reset() { + this.resetPromise = this.resetPromise.then(async () => { + await this._uninit(); + await this.init(); + Services.obs.notifyObservers(null, "doh:controller-reloaded"); + }); + + return this.resetPromise; + }, + + // The "maybe" is because there are two cases when we don't enable heuristics: + // 1. If we detect that TRR mode or URI have user values, or we previously + // detected this (i.e. DISABLED_PREF is true) + // 2. If there are any non-DoH enterprise policies active + async maybeEnableHeuristics() { + if (lazy.Preferences.get(DISABLED_PREF)) { + return; + } + + let policyResult = await lazy.Heuristics.checkEnterprisePolicy(); + + if (policyResult != "no_policy_set") { + switch (policyResult) { + case "policy_without_doh": + Services.telemetry.scalarSet( + "networking.doh_heuristics_result", + lazy.Heuristics.Telemetry.enterprisePresent + ); + await this.setState("policyDisabled"); + break; + case "disable_doh": + Services.telemetry.scalarSet( + "networking.doh_heuristics_result", + lazy.Heuristics.Telemetry.enterpriseDisabled + ); + await this.setState("policyDisabled"); + break; + case "enable_doh": + // The TRR mode has already been set, so theoretically we should not get here. + // XXX: should we skip heuristics or continue? + // TODO: Make sure we use the correct URL if the policy defines one. + Services.telemetry.scalarSet( + "networking.doh_heuristics_result", + lazy.Heuristics.Telemetry.enterpriseEnabled + ); + break; + } + lazy.Preferences.set(SKIP_HEURISTICS_PREF, true); + return; + } + + lazy.Preferences.reset(SKIP_HEURISTICS_PREF); + + if ( + lazy.Preferences.isSet(NETWORK_TRR_MODE_PREF) || + lazy.Preferences.isSet(NETWORK_TRR_URI_PREF) + ) { + await this.setState("manuallyDisabled"); + lazy.Preferences.set(DISABLED_PREF, true); + return; + } + + await this.runTRRSelection(); + // If we enter this branch it means that no automatic selection was possible. + // In this case, we try to set a fallback (as defined by DoHConfigController). + if (!lazy.Preferences.isSet(ROLLOUT_URI_PREF)) { + let uri = lazy.DoHConfigController.currentConfig.fallbackProviderURI; + + // If part of the treatment branch use the URL from the experiment. + try { + let ohttpURI = lazy.NimbusFeatures.dooh.getVariable("ohttpUri"); + if (ohttpURI) { + uri = ohttpURI; + } + } catch (e) { + console.error(`Error getting dooh.ohttpURI: ${e.message}`); + } + + lazy.Preferences.set(ROLLOUT_URI_PREF, uri || ""); + } + this.runHeuristicsThrottled("startup"); + Services.obs.addObserver(this, kLinkStatusChangedTopic); + Services.obs.addObserver(this, kConnectivityTopic); + + this._heuristicsAreEnabled = true; + }, + + _runsWhileThrottling: 0, + _wasThrottleExtended: false, + _throttleHeuristics() { + if (lazy.kHeuristicsThrottleTimeout < 0) { + // Skip throttling in tests that set timeout to a negative value. + return false; + } + + if (this._throttleTimer) { + // Already throttling - nothing to do. + this._runsWhileThrottling++; + return true; + } + + this._runsWhileThrottling = 0; + + this._throttleTimer = lazy.setTimeout( + this._handleThrottleTimeout.bind(this), + lazy.kHeuristicsThrottleTimeout + ); + + return false; + }, + + _handleThrottleTimeout() { + delete this._throttleTimer; + if (this._runsWhileThrottling > lazy.kHeuristicsRateLimit) { + // During the throttle period, we saw that the rate limit was exceeded. + // We extend the throttle period, and don't bother running heuristics yet. + this._wasThrottleExtended = true; + // Restart the throttle timer. + this._throttleHeuristics(); + if (lazy.kIsInAutomation) { + Services.obs.notifyObservers(null, "doh:heuristics-throttle-extend"); + } + return; + } + + // If this was an extended throttle and there were no runs during the + // extended period, we still want to run heuristics, since the extended + // throttle implies we had a non-zero number of attempts before extension. + if (this._runsWhileThrottling > 0 || this._wasThrottleExtended) { + this.runHeuristicsThrottled("throttled"); + } + + this._wasThrottleExtended = false; + + if (lazy.kIsInAutomation) { + Services.obs.notifyObservers(null, "doh:heuristics-throttle-done"); + } + }, + + runHeuristicsThrottled(evaluateReason) { + // _throttleHeuristics returns true if we've already witnessed a run and the + // timeout period hasn't lapsed yet. If it does so, we suppress this run. + if (this._throttleHeuristics()) { + return; + } + + // _throttleHeuristics returned false - we're good to run heuristics. + // At this point the timer has been started and subsequent calls will be + // suppressed if it hasn't fired yet. + this.runHeuristics(evaluateReason); + }, + + async runHeuristics(evaluateReason) { + let start = Date.now(); + + Services.telemetry.scalarAdd("networking.doh_heuristics_attempts", 1); + Services.telemetry.scalarSet( + "networking.doh_heuristics_result", + lazy.Heuristics.Telemetry.incomplete + ); + let results = await lazy.Heuristics.run(); + + if ( + !lazy.gNetworkLinkService.isLinkUp || + this._lastDebounceTimestamp > start || + lazy.gCaptivePortalService.state == + lazy.gCaptivePortalService.LOCKED_PORTAL + ) { + // If the network is currently down or there was a debounce triggered + // while we were running heuristics, it means the network fluctuated + // during this heuristics run. We simply discard the results in this case. + // Same thing if there was another heuristics run triggered or if we have + // detected a locked captive portal while this one was ongoing. + Services.telemetry.scalarSet( + "networking.doh_heuristics_result", + lazy.Heuristics.Telemetry.ignored + ); + return; + } + + let decision = Object.values(results).includes(lazy.Heuristics.DISABLE_DOH) + ? lazy.Heuristics.DISABLE_DOH + : lazy.Heuristics.ENABLE_DOH; + + let getCaptiveStateString = () => { + switch (lazy.gCaptivePortalService.state) { + case lazy.gCaptivePortalService.NOT_CAPTIVE: + return "not_captive"; + case lazy.gCaptivePortalService.UNLOCKED_PORTAL: + return "unlocked"; + case lazy.gCaptivePortalService.LOCKED_PORTAL: + return "locked"; + default: + return "unknown"; + } + }; + + let resultsForTelemetry = { + evaluateReason, + steeredProvider: "", + captiveState: getCaptiveStateString(), + // NOTE: This might not yet be available after a network change. We mainly + // care about the startup case though - we want to look at whether the + // heuristics result is consistent for networkIDs often seen at startup. + // TODO: Use this data to implement cached results to use early at startup. + networkID: getHashedNetworkID(), + }; + + const oHTTPexperiment = lazy.ExperimentAPI.getExperimentMetaData({ + featureId: "dooh", + }); + + // When the OHTTP experiment is active we don't want to enable steering. + if (results.steeredProvider && !oHTTPexperiment) { + Services.dns.setDetectedTrrURI(results.steeredProvider.uri); + resultsForTelemetry.steeredProvider = results.steeredProvider.id; + } + + this.setHeuristicResult(Ci.nsITRRSkipReason.TRR_UNSET); + if (decision === lazy.Heuristics.DISABLE_DOH) { + Services.telemetry.scalarSet( + "networking.doh_heuristics_result", + lazy.Heuristics.Telemetry.fromResults(results) + ); + + let fallbackHeuristicTripped = undefined; + if (lazy.Preferences.get(NATIVE_FALLBACK_WARNING_PREF, false)) { + let heuristics = lazy.Preferences.get( + NATIVE_FALLBACK_WARNING_HEURISTIC_LIST_PREF, + "" + ).split(","); + for (let [heuristicName, result] of Object.entries(results)) { + if (result !== lazy.Heuristics.DISABLE_DOH) { + continue; + } + if (heuristics.includes(heuristicName)) { + fallbackHeuristicTripped = heuristicName; + break; + } + } + } + + // If none of the fallback heuristics failed, the detection result will be TRR_OK + // Otherwise it will be the skip reason for the failed heuristic. + let heuristicSkipReason = Ci.nsITRRSkipReason.TRR_OK; + if (fallbackHeuristicTripped != undefined) { + heuristicSkipReason = lazy.Heuristics.heuristicNameToSkipReason( + fallbackHeuristicTripped + ); + } + this.setHeuristicResult(heuristicSkipReason); + + await this.setState("disabled"); + } else { + Services.telemetry.scalarSet( + "networking.doh_heuristics_result", + lazy.Heuristics.Telemetry.pass + ); + Services.telemetry.scalarAdd("networking.doh_heuristics_pass_count", 1); + await this.setState("enabled"); + } + + // For telemetry, we group the heuristics results into three categories. + // Only heuristics with a DISABLE_DOH result are included. + // Each category is finally included in the event as a comma-separated list. + let canaries = []; + let filtering = []; + let enterprise = []; + let platform = []; + + for (let [heuristicName, result] of Object.entries(results)) { + if (result !== lazy.Heuristics.DISABLE_DOH) { + continue; + } + + if (["canary", "zscalerCanary"].includes(heuristicName)) { + canaries.push(heuristicName); + } else if ( + ["browserParent", "google", "youtube"].includes(heuristicName) + ) { + filtering.push(heuristicName); + } else if ( + ["policy", "modifiedRoots", "thirdPartyRoots"].includes(heuristicName) + ) { + enterprise.push(heuristicName); + } else if (["vpn", "proxy", "nrpt"].includes(heuristicName)) { + platform.push(heuristicName); + } + + if (lazy.Heuristics.Telemetry.heuristicNames().includes(heuristicName)) { + Services.telemetry.keyedScalarSet( + "networking.doh_heuristic_ever_tripped", + heuristicName, + true + ); + } + } + + resultsForTelemetry.canaries = canaries.join(","); + resultsForTelemetry.filtering = filtering.join(","); + resultsForTelemetry.enterprise = enterprise.join(","); + resultsForTelemetry.platform = platform.join(","); + + Services.telemetry.recordEvent( + HEURISTICS_TELEMETRY_CATEGORY, + "evaluate_v2", + "heuristics", + decision, + resultsForTelemetry + ); + }, + + async setState(state) { + switch (state) { + case "disabled": + lazy.Preferences.set(ROLLOUT_MODE_PREF, 0); + break; + case "enabled": + lazy.Preferences.set(ROLLOUT_MODE_PREF, 2); + lazy.Preferences.set(BREADCRUMB_PREF, true); + break; + case "policyDisabled": + case "manuallyDisabled": + case "UIDisabled": + lazy.Preferences.reset(BREADCRUMB_PREF); + // Fall through. + case "rollback": + this.setHeuristicResult(Ci.nsITRRSkipReason.TRR_UNSET); + lazy.Preferences.reset(ROLLOUT_MODE_PREF); + break; + case "shutdown": + this.setHeuristicResult(Ci.nsITRRSkipReason.TRR_UNSET); + if (lazy.Preferences.get(CLEAR_ON_SHUTDOWN_PREF, true)) { + lazy.Preferences.reset(ROLLOUT_MODE_PREF); + } + break; + } + + Services.telemetry.recordEvent( + HEURISTICS_TELEMETRY_CATEGORY, + "state", + state, + "null" + ); + + let modePref = lazy.Preferences.get(NETWORK_TRR_MODE_PREF); + if (state == "manuallyDisabled") { + if ( + modePref == Ci.nsIDNSService.MODE_TRRFIRST || + modePref == Ci.nsIDNSService.MODE_TRRONLY + ) { + Services.telemetry.scalarSet( + "networking.doh_heuristics_result", + lazy.Heuristics.Telemetry.manuallyEnabled + ); + } else if ( + lazy.Preferences.get("doh-rollout.doorhanger-decision", "") == + "UIDisabled" + ) { + Services.telemetry.scalarSet( + "networking.doh_heuristics_result", + lazy.Heuristics.Telemetry.optOut + ); + } else { + Services.telemetry.scalarSet( + "networking.doh_heuristics_result", + lazy.Heuristics.Telemetry.manuallyDisabled + ); + } + } + }, + + async disableHeuristics(state) { + await this.setState(state); + + if (!this._heuristicsAreEnabled) { + return; + } + + Services.obs.removeObserver(this, kLinkStatusChangedTopic); + Services.obs.removeObserver(this, kConnectivityTopic); + if (this._debounceTimer) { + lazy.clearTimeout(this._debounceTimer); + delete this._debounceTimer; + } + if (this._throttleTimer) { + lazy.clearTimeout(this._throttleTimer); + delete this._throttleTimer; + } + this._heuristicsAreEnabled = false; + }, + + async rollback() { + await this.disableHeuristics("rollback"); + }, + + async runTRRSelection() { + // If persisting the selection is disabled, clear the existing + // selection. + if (!lazy.DoHConfigController.currentConfig.trrSelection.commitResult) { + lazy.Preferences.reset(ROLLOUT_URI_PREF); + } + + if (!lazy.DoHConfigController.currentConfig.trrSelection.enabled) { + return; + } + + if ( + lazy.Preferences.isSet(ROLLOUT_URI_PREF) && + lazy.Preferences.get(ROLLOUT_URI_PREF) == + lazy.Preferences.get(TRR_SELECT_DRY_RUN_RESULT_PREF) + ) { + return; + } + + await this.runTRRSelectionDryRun(); + + // If persisting the selection is disabled, don't commit the value. + if (!lazy.DoHConfigController.currentConfig.trrSelection.commitResult) { + return; + } + + lazy.Preferences.set( + ROLLOUT_URI_PREF, + lazy.Preferences.get(TRR_SELECT_DRY_RUN_RESULT_PREF) + ); + }, + + async runTRRSelectionDryRun() { + if (lazy.Preferences.isSet(TRR_SELECT_DRY_RUN_RESULT_PREF)) { + // Check whether the existing dry-run-result is in the default + // list of TRRs. If it is, all good. Else, run the dry run again. + let dryRunResult = lazy.Preferences.get(TRR_SELECT_DRY_RUN_RESULT_PREF); + let dryRunResultIsValid = + lazy.DoHConfigController.currentConfig.providerList.some( + trr => trr.uri == dryRunResult + ); + if (dryRunResultIsValid) { + return; + } + } + + let setDryRunResultAndRecordTelemetry = trrUri => { + lazy.Preferences.set(TRR_SELECT_DRY_RUN_RESULT_PREF, trrUri); + Services.telemetry.recordEvent( + TRRSELECT_TELEMETRY_CATEGORY, + "trrselect", + "dryrunresult", + trrUri.substring(0, 40) // Telemetry payload max length + ); + }; + + if (lazy.kIsInAutomation) { + // For mochitests, just record telemetry with a dummy result. + // TRRPerformance.sys.mjs is tested in xpcshell. + setDryRunResultAndRecordTelemetry("https://example.com/dns-query"); + return; + } + + // Importing the module here saves us from having to do it at startup, and + // ensures tests have time to set prefs before the module initializes. + let { TRRRacer } = ChromeUtils.importESModule( + "resource:///modules/TRRPerformance.sys.mjs" + ); + await new Promise(resolve => { + let trrList = + lazy.DoHConfigController.currentConfig.trrSelection.providerList.map( + trr => trr.uri + ); + let racer = new TRRRacer(() => { + setDryRunResultAndRecordTelemetry(racer.getFastestTRR(true)); + resolve(); + }, trrList); + racer.run(); + }); + }, + + observe(subject, topic, data) { + switch (topic) { + case kLinkStatusChangedTopic: + this.onConnectionChanged(); + break; + case kConnectivityTopic: + this.onConnectivityAvailable(); + break; + case kPrefChangedTopic: + this.onPrefChanged(data); + break; + case lazy.DoHConfigController.kConfigUpdateTopic: + this.reset(); + break; + } + }, + + setHeuristicResult(skipReason) { + try { + Services.dns.setHeuristicDetectionResult(skipReason); + } catch (e) {} + }, + + async onPrefChanged(pref) { + switch (pref) { + case NETWORK_TRR_URI_PREF: + case NETWORK_TRR_MODE_PREF: + lazy.Preferences.set(DISABLED_PREF, true); + await this.disableHeuristics("manuallyDisabled"); + break; + case NATIVE_FALLBACK_WARNING_PREF: + case NATIVE_FALLBACK_WARNING_HEURISTIC_LIST_PREF: + if (this._heuristicsAreEnabled) { + await this.runHeuristics("native-fallback-warning-pref-changed"); + } else { + this.setHeuristicResult(Ci.nsITRRSkipReason.TRR_UNSET); + } + break; + } + }, + + // Connection change events are debounced to allow the network to settle. + // We wait for the network to be up for a period of kDebounceTimeout before + // handling the change. The timer is canceled when the network goes down and + // restarted the first time we learn that it went back up. + _debounceTimer: null, + _cancelDebounce() { + if (!this._debounceTimer) { + return; + } + + lazy.clearTimeout(this._debounceTimer); + this._debounceTimer = null; + }, + + _lastDebounceTimestamp: 0, + onConnectionChanged() { + if (!lazy.gNetworkLinkService.isLinkUp) { + // Network is down - reset debounce timer. + this._cancelDebounce(); + return; + } + + if (this._debounceTimer) { + // Already debouncing - nothing to do. + return; + } + + if (lazy.kNetworkDebounceTimeout < 0) { + // Skip debouncing in tests that set timeout to a negative value. + this.onConnectionChangedDebounced(); + return; + } + + this._lastDebounceTimestamp = Date.now(); + this._debounceTimer = lazy.setTimeout(() => { + this._cancelDebounce(); + this.onConnectionChangedDebounced(); + }, lazy.kNetworkDebounceTimeout); + }, + + onConnectionChangedDebounced() { + if (!lazy.gNetworkLinkService.isLinkUp) { + return; + } + + if ( + lazy.gCaptivePortalService.state == + lazy.gCaptivePortalService.LOCKED_PORTAL + ) { + return; + } + + // The network is up and we don't know that we're in a locked portal. + // Run heuristics. If we detect a portal later, we'll run heuristics again + // when it's unlocked. In that case, this run will likely have failed. + this.runHeuristicsThrottled("netchange"); + }, + + onConnectivityAvailable() { + if (this._debounceTimer) { + // Already debouncing - nothing to do. + return; + } + + this.runHeuristicsThrottled("connectivity"); + }, +}; diff --git a/browser/components/doh/DoHHeuristics.sys.mjs b/browser/components/doh/DoHHeuristics.sys.mjs new file mode 100644 index 0000000000..efe0bcab5a --- /dev/null +++ b/browser/components/doh/DoHHeuristics.sys.mjs @@ -0,0 +1,437 @@ +/* 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 implements the heuristics used to determine whether to enable + * or disable DoH on different networks. DoHController is responsible for running + * these at startup and upon network changes. + */ +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gNetworkLinkService", + "@mozilla.org/network/network-link-service;1", + "nsINetworkLinkService" +); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gParentalControlsService", + "@mozilla.org/parental-controls-service;1", + "nsIParentalControlsService" +); + +ChromeUtils.defineESModuleGetters(lazy, { + DoHConfigController: "resource:///modules/DoHConfig.sys.mjs", +}); + +const GLOBAL_CANARY = "use-application-dns.net."; + +const NXDOMAIN_ERR = "NS_ERROR_UNKNOWN_HOST"; + +export const Heuristics = { + // String constants used to indicate outcome of heuristics. + ENABLE_DOH: "enable_doh", + DISABLE_DOH: "disable_doh", + + async run() { + // Run all the heuristics at the same time. + let [safeSearchChecks, zscaler, canary] = await Promise.all([ + safeSearch(), + zscalerCanary(), + globalCanary(), + ]); + + let platformChecks = await platform(); + let results = { + google: safeSearchChecks.google, + youtube: safeSearchChecks.youtube, + zscalerCanary: zscaler, + canary, + browserParent: await parentalControls(), + thirdPartyRoots: await thirdPartyRoots(), + policy: await enterprisePolicy(), + vpn: platformChecks.vpn, + proxy: platformChecks.proxy, + nrpt: platformChecks.nrpt, + steeredProvider: "", + }; + + // If any of those were triggered, return the results immediately. + if (Object.values(results).includes("disable_doh")) { + return results; + } + + // Check for provider steering only after the other heuristics have passed. + results.steeredProvider = (await providerSteering()) || ""; + return results; + }, + + async checkEnterprisePolicy() { + return enterprisePolicy(); + }, + + // Test only + async _setMockLinkService(mockLinkService) { + this.mockLinkService = mockLinkService; + }, + + heuristicNameToSkipReason(heuristicName) { + const namesToSkipReason = { + google: Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_GOOGLE_SAFESEARCH, + youtube: Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_YOUTUBE_SAFESEARCH, + zscalerCanary: Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_ZSCALER_CANARY, + canary: Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_CANARY, + modifiedRoots: Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_MODIFIED_ROOTS, + browserParent: + Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_PARENTAL_CONTROLS, + thirdPartyRoots: + Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_THIRD_PARTY_ROOTS, + policy: Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_ENTERPRISE_POLICY, + vpn: Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_VPN, + proxy: Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_PROXY, + nrpt: Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_NRPT, + }; + + let value = namesToSkipReason[heuristicName]; + if (value != undefined) { + return value; + } + return Ci.nsITRRSkipReason.TRR_FAILED; + }, + + // Keep this in sync with the description of networking.doh_heuristics_result + // defined in Scalars.yaml + Telemetry: { + incomplete: 0, + pass: 1, + optOut: 2, + manuallyDisabled: 3, + manuallyEnabled: 4, + enterpriseDisabled: 5, + enterprisePresent: 6, + enterpriseEnabled: 7, + vpn: 8, + proxy: 9, + nrpt: 10, + browserParent: 11, + modifiedRoots: 12, + thirdPartyRoots: 13, + google: 14, + youtube: 15, + zscalerCanary: 16, + canary: 17, + ignored: 18, + + heuristicNames() { + return [ + "google", + "youtube", + "zscalerCanary", + "canary", + "browserParent", + "thirdPartyRoots", + "policy", + "vpn", + "proxy", + "nrpt", + ]; + }, + + fromResults(results) { + for (let label of Heuristics.Telemetry.heuristicNames()) { + if (results[label] == Heuristics.DISABLE_DOH) { + return Heuristics.Telemetry[label]; + } + } + return Heuristics.Telemetry.pass; + }, + }, +}; + +async function dnsLookup(hostname, resolveCanonicalName = false) { + let lookupPromise = new Promise((resolve, reject) => { + let request; + let response = { + addresses: [], + }; + let listener = { + onLookupComplete(inRequest, inRecord, inStatus) { + if (inRequest === request) { + if (!Components.isSuccessCode(inStatus)) { + reject({ message: new Components.Exception("", inStatus).name }); + return; + } + inRecord.QueryInterface(Ci.nsIDNSAddrRecord); + if (resolveCanonicalName) { + try { + response.canonicalName = inRecord.canonicalName; + } catch (e) { + // no canonicalName + } + } + while (inRecord.hasMore()) { + let addr = inRecord.getNextAddrAsString(); + // Sometimes there are duplicate records with the same ip. + if (!response.addresses.includes(addr)) { + response.addresses.push(addr); + } + } + resolve(response); + } + }, + }; + let dnsFlags = + Ci.nsIDNSService.RESOLVE_TRR_DISABLED_MODE | + Ci.nsIDNSService.RESOLVE_DISABLE_IPV6 | + Ci.nsIDNSService.RESOLVE_BYPASS_CACHE | + Ci.nsIDNSService.RESOLVE_CANONICAL_NAME; + try { + request = Services.dns.asyncResolve( + hostname, + Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT, + dnsFlags, + null, + listener, + null, + {} /* defaultOriginAttributes */ + ); + } catch (e) { + // handle exceptions such as offline mode. + reject({ message: e.name }); + } + }); + + let addresses, canonicalName, err; + + try { + let response = await lookupPromise; + addresses = response.addresses; + canonicalName = response.canonicalName; + } catch (e) { + addresses = [null]; + err = e.message; + } + + return { addresses, canonicalName, err }; +} + +async function dnsListLookup(domainList) { + let results = []; + + let resolutions = await Promise.all( + domainList.map(domain => dnsLookup(domain)) + ); + for (let { addresses } of resolutions) { + results = results.concat(addresses); + } + + return results; +} + +// TODO: Confirm the expected behavior when filtering is on +async function globalCanary() { + let { addresses, err } = await dnsLookup(GLOBAL_CANARY); + + if ( + err === NXDOMAIN_ERR || + !addresses.length || + addresses.every(addr => + Services.io.hostnameIsLocalIPAddress(Services.io.newURI(`http://${addr}`)) + ) + ) { + return "disable_doh"; + } + + return "enable_doh"; +} + +export async function parentalControls() { + if (lazy.gParentalControlsService.parentalControlsEnabled) { + return "disable_doh"; + } + + return "enable_doh"; +} + +async function thirdPartyRoots() { + if (Cu.isInAutomation) { + return "enable_doh"; + } + + let certdb = Cc["@mozilla.org/security/x509certdb;1"].getService( + Ci.nsIX509CertDB + ); + + let hasThirdPartyRoots = await new Promise(resolve => { + certdb.asyncHasThirdPartyRoots(resolve); + }); + + if (hasThirdPartyRoots) { + return "disable_doh"; + } + + return "enable_doh"; +} + +async function enterprisePolicy() { + if (Services.policies.status === Services.policies.ACTIVE) { + let policies = Services.policies.getActivePolicies(); + + if (!policies.hasOwnProperty("DNSOverHTTPS")) { + // If DoH isn't in the policy, return that there is a policy (but no DoH specifics) + return "policy_without_doh"; + } + + if (policies.DNSOverHTTPS.Enabled === true) { + // If DoH is enabled in the policy, enable it + return "enable_doh"; + } + + // If DoH is disabled in the policy, disable it + return "disable_doh"; + } + + // Default return, meaning no policy related to DNSOverHTTPS + return "no_policy_set"; +} + +async function safeSearch() { + const providerList = [ + { + name: "google", + unfiltered: ["www.google.com.", "google.com."], + safeSearch: ["forcesafesearch.google.com."], + }, + { + name: "youtube", + unfiltered: [ + "www.youtube.com.", + "m.youtube.com.", + "youtubei.googleapis.com.", + "youtube.googleapis.com.", + "www.youtube-nocookie.com.", + ], + safeSearch: ["restrict.youtube.com.", "restrictmoderate.youtube.com."], + }, + ]; + + async function checkProvider(provider) { + let [unfilteredAnswers, safeSearchAnswers] = await Promise.all([ + dnsListLookup(provider.unfiltered), + dnsListLookup(provider.safeSearch), + ]); + + // Given a provider, check if the answer for any safe search domain + // matches the answer for any default domain + for (let answer of safeSearchAnswers) { + if (answer && unfilteredAnswers.includes(answer)) { + return { name: provider.name, result: "disable_doh" }; + } + } + + return { name: provider.name, result: "enable_doh" }; + } + + // Compare strict domain lookups to non-strict domain lookups. + // Resolutions has a type of [{ name, result }] + let resolutions = await Promise.all( + providerList.map(provider => checkProvider(provider)) + ); + + // Reduce that array entries into a single map + return resolutions.reduce( + (accumulator, check) => { + accumulator[check.name] = check.result; + return accumulator; + }, + {} // accumulator + ); +} + +async function zscalerCanary() { + const ZSCALER_CANARY = "sitereview.zscaler.com."; + + let { addresses } = await dnsLookup(ZSCALER_CANARY); + for (let address of addresses) { + if ( + ["213.152.228.242", "199.168.151.251", "8.25.203.30"].includes(address) + ) { + // if sitereview.zscaler.com resolves to either one of the 3 IPs above, + // Zscaler Shift service is in use, don't enable DoH + return "disable_doh"; + } + } + + return "enable_doh"; +} + +async function platform() { + let platformChecks = {}; + + let indications = Ci.nsINetworkLinkService.NONE_DETECTED; + try { + let linkService = lazy.gNetworkLinkService; + if (Heuristics.mockLinkService) { + linkService = Heuristics.mockLinkService; + } + indications = linkService.platformDNSIndications; + } catch (e) { + if (e.result != Cr.NS_ERROR_NOT_IMPLEMENTED) { + console.error(e); + } + } + + platformChecks.vpn = + indications & Ci.nsINetworkLinkService.VPN_DETECTED + ? "disable_doh" + : "enable_doh"; + platformChecks.proxy = + indications & Ci.nsINetworkLinkService.PROXY_DETECTED + ? "disable_doh" + : "enable_doh"; + platformChecks.nrpt = + indications & Ci.nsINetworkLinkService.NRPT_DETECTED + ? "disable_doh" + : "enable_doh"; + + return platformChecks; +} + +// Check if the network provides a DoH endpoint to use. Returns the name of the +// provider if the check is successful, else null. Currently we only support +// this for Comcast networks. +async function providerSteering() { + if (!lazy.DoHConfigController.currentConfig.providerSteering.enabled) { + return null; + } + const TEST_DOMAIN = "doh.test."; + + // Array of { name, canonicalName, uri } where name is an identifier for + // telemetry, canonicalName is the expected CNAME when looking up doh.test, + // and uri is the provider's DoH endpoint. + let steeredProviders = + lazy.DoHConfigController.currentConfig.providerSteering.providerList; + + if (!steeredProviders || !steeredProviders.length) { + return null; + } + + let { canonicalName, err } = await dnsLookup(TEST_DOMAIN, true); + if (err || !canonicalName) { + return null; + } + + let provider = steeredProviders.find(p => { + return p.canonicalName == canonicalName; + }); + if (!provider || !provider.uri || !provider.id) { + return null; + } + + return provider; +} diff --git a/browser/components/doh/DoHTestUtils.sys.mjs b/browser/components/doh/DoHTestUtils.sys.mjs new file mode 100644 index 0000000000..b89c1fe966 --- /dev/null +++ b/browser/components/doh/DoHTestUtils.sys.mjs @@ -0,0 +1,133 @@ +/* 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, { + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +const kConfigCollectionKey = "doh-config"; +const kProviderCollectionKey = "doh-providers"; + +const kConfigUpdateTopic = "doh-config-updated"; +const kControllerReloadedTopic = "doh:controller-reloaded"; + +/* + * Some helpers for loading and modifying DoH config in + * Remote Settings. Call resetRemoteSettingsConfig to set up + * basic default config that omits external URLs. Use + * waitForConfigFlush to wait for DoH actors to pick up changes. + * + * Some tests need to load/reset config while DoH actors are + * uninitialized. Pass waitForConfigFlushes = false in these cases. + */ +export const DoHTestUtils = { + providers: [ + { + uri: "https://example.com/1", + UIName: "Example 1", + autoDefault: false, + canonicalName: "", + id: "example-1", + }, + { + uri: "https://example.com/2", + UIName: "Example 2", + autoDefault: false, + canonicalName: "", + id: "example-2", + }, + ], + + async loadRemoteSettingsProviders(providers, waitForConfigFlushes = true) { + let configFlushedPromise = this.waitForConfigFlush(waitForConfigFlushes); + + let providerRS = lazy.RemoteSettings(kProviderCollectionKey); + let db = await providerRS.db; + await db.importChanges({}, Date.now(), providers, { clear: true }); + + // Trigger a sync. + await this.triggerSync(providerRS); + + await configFlushedPromise; + }, + + async loadRemoteSettingsConfig(config, waitForConfigFlushes = true) { + let configFlushedPromise = this.waitForConfigFlush(waitForConfigFlushes); + + let configRS = lazy.RemoteSettings(kConfigCollectionKey); + let db = await configRS.db; + await db.importChanges({}, Date.now(), [config]); + + // Trigger a sync. + await this.triggerSync(configRS); + + await configFlushedPromise; + }, + + // Loads default config for testing without clearing existing entries. + async loadDefaultRemoteSettingsConfig(waitForConfigFlushes = true) { + await this.loadRemoteSettingsProviders( + this.providers, + waitForConfigFlushes + ); + + await this.loadRemoteSettingsConfig( + { + providers: "example-1, example-2", + rolloutEnabled: false, + steeringEnabled: false, + steeringProviders: "", + autoDefaultEnabled: false, + autoDefaultProviders: "", + id: "global", + }, + waitForConfigFlushes + ); + }, + + // Clears existing config AND loads defaults. + async resetRemoteSettingsConfig(waitForConfigFlushes = true) { + let providerRS = lazy.RemoteSettings(kProviderCollectionKey); + let configRS = lazy.RemoteSettings(kConfigCollectionKey); + for (let rs of [providerRS, configRS]) { + let configFlushedPromise = this.waitForConfigFlush(waitForConfigFlushes); + await rs.db.importChanges({}, Date.now(), [], { clear: true }); + // Trigger a sync to clear. + await this.triggerSync(rs); + await configFlushedPromise; + } + + await this.loadDefaultRemoteSettingsConfig(waitForConfigFlushes); + }, + + triggerSync(rs) { + return rs.emit("sync", { + data: { + current: [], + }, + }); + }, + + waitForConfigUpdate() { + return lazy.TestUtils.topicObserved(kConfigUpdateTopic); + }, + + waitForControllerReload() { + return lazy.TestUtils.topicObserved(kControllerReloadedTopic); + }, + + waitForConfigFlush(shouldWait = true) { + if (!shouldWait) { + return Promise.resolve(); + } + + return Promise.all([ + this.waitForConfigUpdate(), + this.waitForControllerReload(), + ]); + }, +}; diff --git a/browser/components/doh/TRRPerformance.sys.mjs b/browser/components/doh/TRRPerformance.sys.mjs new file mode 100644 index 0000000000..e46f280f40 --- /dev/null +++ b/browser/components/doh/TRRPerformance.sys.mjs @@ -0,0 +1,395 @@ +/* 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 tests TRR performance by issuing DNS requests to TRRs and + * recording telemetry for the network time for each request. + * + * We test each TRR with 5 random subdomains of a canonical domain and also + * a "popular" domain (which the TRR likely have cached). + * + * To ensure data integrity, we run the requests in an aggregator wrapper + * and collect all the results before sending telemetry. If we detect network + * loss, the results are discarded. A new run is triggered upon detection of + * usable network until a full set of results has been captured. We stop retrying + * after 5 attempts. + */ +Services.telemetry.setEventRecordingEnabled( + "security.doh.trrPerformance", + true +); + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gNetworkLinkService", + "@mozilla.org/network/network-link-service;1", + "nsINetworkLinkService" +); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gCaptivePortalService", + "@mozilla.org/network/captive-portal-service;1", + "nsICaptivePortalService" +); + +// The canonical domain whose subdomains we will be resolving. +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "kCanonicalDomain", + "doh-rollout.trrRace.canonicalDomain", + "firefox-dns-perf-test.net." +); + +// The number of random subdomains to resolve per TRR. +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "kRepeats", + "doh-rollout.trrRace.randomSubdomainCount", + 5 +); + +// The "popular" domain that we expect the TRRs to have cached. +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "kPopularDomains", + "doh-rollout.trrRace.popularDomains", + null, + null, + val => + val + ? val.split(",").map(t => t.trim()) + : [ + "google.com.", + "youtube.com.", + "amazon.com.", + "facebook.com.", + "yahoo.com.", + ] +); + +function getRandomSubdomain() { + let uuid = Services.uuid.generateUUID().toString().slice(1, -1); // Discard surrounding braces + return `${uuid}.${lazy.kCanonicalDomain}`; +} + +// A wrapper around async DNS lookups. The results are passed on to the supplied +// callback. The wrapper attempts the lookup 3 times before passing on a failure. +// If a false-y `domain` is supplied, a random subdomain will be used. Each retry +// will use a different random subdomain to ensure we bypass chached responses. +export class DNSLookup { + constructor(domain, trrServer, callback) { + this._domain = domain; + this.trrServer = trrServer; + this.callback = callback; + this.retryCount = 0; + } + + doLookup() { + this.retryCount++; + try { + this.usedDomain = this._domain || getRandomSubdomain(); + Services.dns.asyncResolve( + this.usedDomain, + Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT, + Ci.nsIDNSService.RESOLVE_BYPASS_CACHE, + Services.dns.newAdditionalInfo(this.trrServer, -1), + this, + Services.tm.currentThread, + {} + ); + } catch (e) { + console.error(e); + } + } + + onLookupComplete(request, record, status) { + // Try again if we failed... + if (!Components.isSuccessCode(status) && this.retryCount < 3) { + this.doLookup(); + return; + } + + // But after the third try, just pass the status on. + this.callback(request, record, status, this.usedDomain, this.retryCount); + } +} + +DNSLookup.prototype.QueryInterface = ChromeUtils.generateQI(["nsIDNSListener"]); + +// A wrapper around a single set of measurements. The required lookups are +// triggered and the results aggregated before telemetry is sent. If aborted, +// any aggregated results are discarded. +export class LookupAggregator { + constructor(onCompleteCallback, trrList) { + this.onCompleteCallback = onCompleteCallback; + this.trrList = trrList; + this.aborted = false; + this.networkUnstable = false; + this.captivePortal = false; + + this.domains = []; + for (let i = 0; i < lazy.kRepeats; ++i) { + // false-y domain will cause DNSLookup to generate a random one. + this.domains.push(null); + } + this.domains.push(...lazy.kPopularDomains); + this.totalLookups = this.trrList.length * this.domains.length; + this.completedLookups = 0; + this.results = []; + } + + run() { + if (this._ran || this._aborted) { + console.error("Trying to re-run a LookupAggregator."); + return; + } + + this._ran = true; + for (let trr of this.trrList) { + for (let domain of this.domains) { + new DNSLookup( + domain, + trr, + (request, record, status, usedDomain, retryCount) => { + this.results.push({ + domain: usedDomain, + trr, + status, + time: record + ? record.QueryInterface(Ci.nsIDNSAddrRecord) + .trrFetchDurationNetworkOnly + : -1, + retryCount, + }); + + this.completedLookups++; + if (this.completedLookups == this.totalLookups) { + this.recordResults(); + } + } + ).doLookup(); + } + } + } + + abort() { + this.aborted = true; + } + + markUnstableNetwork() { + this.networkUnstable = true; + } + + markCaptivePortal() { + this.captivePortal = true; + } + + recordResults() { + if (this.aborted) { + return; + } + + for (let { domain, trr, status, time, retryCount } of this.results) { + if ( + !( + lazy.kPopularDomains.includes(domain) || + domain.includes(lazy.kCanonicalDomain) + ) + ) { + console.error("Expected known domain for reporting, got ", domain); + return; + } + + Services.telemetry.recordEvent( + "security.doh.trrPerformance", + "resolved", + "record", + "success", + { + domain, + trr, + status: status.toString(), + time: time.toString(), + retryCount: retryCount.toString(), + networkUnstable: this.networkUnstable.toString(), + captivePortal: this.captivePortal.toString(), + } + ); + } + + this.onCompleteCallback(); + } +} + +// This class monitors the network and spawns a new LookupAggregator when ready. +// When the network goes down, an ongoing aggregator is aborted and a new one +// spawned next time we get a link, up to 5 times. On the fifth time, we just +// let the aggegator complete and mark it as tainted. +export class TRRRacer { + constructor(onCompleteCallback, trrList) { + this._aggregator = null; + this._retryCount = 0; + this._complete = false; + this._onCompleteCallback = onCompleteCallback; + this._trrList = trrList; + } + + run() { + if ( + lazy.gNetworkLinkService.isLinkUp && + lazy.gCaptivePortalService.state != + lazy.gCaptivePortalService.LOCKED_PORTAL + ) { + this._runNewAggregator(); + if ( + lazy.gCaptivePortalService.state == + lazy.gCaptivePortalService.UNLOCKED_PORTAL + ) { + this._aggregator.markCaptivePortal(); + } + } + + Services.obs.addObserver(this, "ipc:network:captive-portal-set-state"); + Services.obs.addObserver(this, "network:link-status-changed"); + } + + onComplete() { + Services.obs.removeObserver(this, "ipc:network:captive-portal-set-state"); + Services.obs.removeObserver(this, "network:link-status-changed"); + + this._complete = true; + + if (this._onCompleteCallback) { + this._onCompleteCallback(); + } + } + + getFastestTRR(returnRandomDefault = false) { + if (!this._complete) { + throw new Error("getFastestTRR: Measurement still running."); + } + + return this._getFastestTRRFromResults( + this._aggregator.results, + returnRandomDefault + ); + } + + /* + * Given an array of { trr, time }, returns the trr with smallest mean time. + * Separate from _getFastestTRR for easy unit-testing. + * + * @returns The TRR with the fastest average time. + * If returnRandomDefault is false-y, returns undefined if no valid + * times were present in the results. Otherwise, returns one of the + * present TRRs at random. + */ + _getFastestTRRFromResults(results, returnRandomDefault = false) { + // First, organize the results into a map of TRR -> array of times + let TRRTimingMap = new Map(); + let TRRErrorCount = new Map(); + for (let { trr, time } of results) { + if (!TRRTimingMap.has(trr)) { + TRRTimingMap.set(trr, []); + } + if (time != -1) { + TRRTimingMap.get(trr).push(time); + } else { + TRRErrorCount.set(trr, 1 + (TRRErrorCount.get(trr) || 0)); + } + } + + // Loop through each TRR's array of times, compute the geometric means, + // and remember the fastest TRR. Geometric mean is a bit more forgiving + // in the presence of noise (anomalously high values). + // We don't need the full geometric mean, we simply calculate the arithmetic + // means in log-space and then compare those values. + let fastestTRR; + let fastestAverageTime = -1; + let trrs = [...TRRTimingMap.keys()]; + for (let trr of trrs) { + let times = TRRTimingMap.get(trr); + if (!times.length) { + continue; + } + + // Skip TRRs that had an error rate of more than 30%. + let errorCount = TRRErrorCount.get(trr) || 0; + let totalResults = times.length + errorCount; + if (errorCount / totalResults > 0.3) { + continue; + } + + // Arithmetic mean in log space. Take log of (a + 1) to ensure we never + // take log(0) which would be -Infinity. + let averageTime = + times.map(a => Math.log(a + 1)).reduce((a, b) => a + b) / times.length; + if (fastestAverageTime == -1 || averageTime < fastestAverageTime) { + fastestAverageTime = averageTime; + fastestTRR = trr; + } + } + + if (returnRandomDefault && !fastestTRR) { + fastestTRR = trrs[Math.floor(Math.random() * trrs.length)]; + } + + return fastestTRR; + } + + _runNewAggregator() { + this._aggregator = new LookupAggregator( + () => this.onComplete(), + this._trrList + ); + this._aggregator.run(); + this._retryCount++; + } + + // When the link goes *down*, or when we detect a locked captive portal, we + // abort any ongoing LookupAggregator run. When the link goes *up*, or we + // detect a newly unlocked portal, we start a run if one isn't ongoing. + observe(subject, topic, data) { + switch (topic) { + case "network:link-status-changed": + if (this._aggregator && data == "down") { + if (this._retryCount < 5) { + this._aggregator.abort(); + } else { + this._aggregator.markUnstableNetwork(); + } + } else if ( + data == "up" && + (!this._aggregator || this._aggregator.aborted) + ) { + this._runNewAggregator(); + } + break; + case "ipc:network:captive-portal-set-state": + if ( + this._aggregator && + lazy.gCaptivePortalService.state == + lazy.gCaptivePortalService.LOCKED_PORTAL + ) { + if (this._retryCount < 5) { + this._aggregator.abort(); + } else { + this._aggregator.markCaptivePortal(); + } + } else if ( + lazy.gCaptivePortalService.state == + lazy.gCaptivePortalService.UNLOCKED_PORTAL && + (!this._aggregator || this._aggregator.aborted) + ) { + this._runNewAggregator(); + } + break; + } + } +} diff --git a/browser/components/doh/moz.build b/browser/components/doh/moz.build new file mode 100644 index 0000000000..05030b09ab --- /dev/null +++ b/browser/components/doh/moz.build @@ -0,0 +1,22 @@ +# -*- 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/. + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Security") + +EXTRA_JS_MODULES += [ + "DoHConfig.sys.mjs", + "DoHController.sys.mjs", + "DoHHeuristics.sys.mjs", + "TRRPerformance.sys.mjs", +] + +TESTING_JS_MODULES += [ + "DoHTestUtils.sys.mjs", +] + +XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.toml"] +BROWSER_CHROME_MANIFESTS += ["test/browser/browser.toml"] diff --git a/browser/components/doh/test/browser/browser.toml b/browser/components/doh/test/browser/browser.toml new file mode 100644 index 0000000000..0cf2d4937b --- /dev/null +++ b/browser/components/doh/test/browser/browser.toml @@ -0,0 +1,33 @@ +[DEFAULT] +head = "head.js" + +["browser_cleanFlow.js"] +skip-if = ["socketprocess_networking"] + +["browser_dirtyEnable.js"] + +["browser_doh_region.js"] + +["browser_doorhangerUserReject.js"] + +["browser_platformDetection.js"] + +["browser_policyOverride.js"] + +["browser_providerSteering.js"] + +["browser_remoteSettings_newProfile.js"] +skip-if = ["win11_2009 && bits == 32"] # Bug 1713464 + +["browser_remoteSettings_rollout.js"] +skip-if = ["win11_2009 && bits == 32"] # Bug 1713464 + +["browser_rollback.js"] + +["browser_throttle_heuristics.js"] + +["browser_trrSelect.js"] + +["browser_trrSelection_disable.js"] + +["browser_userInterference.js"] diff --git a/browser/components/doh/test/browser/browser_cleanFlow.js b/browser/components/doh/test/browser/browser_cleanFlow.js new file mode 100644 index 0000000000..9beb1a5a26 --- /dev/null +++ b/browser/components/doh/test/browser/browser_cleanFlow.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); + +add_task(async function testCleanFlow() { + // Set up a passing environment and enable DoH. + setPassingHeuristics(); + let promise = waitForDoorhanger(); + let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF); + Preferences.set(prefs.ENABLED_PREF, true); + + await prefPromise; + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved."); + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/dns-query", + "TRR selection complete." + ); + await checkTRRSelectionTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, EXAMPLE_URL); + let panel = await promise; + + prefPromise = TestUtils.waitForPrefChange( + prefs.DOORHANGER_USER_DECISION_PREF + ); + + // Click the doorhanger's "accept" button. + let button = panel.querySelector(".popup-notification-primary-button"); + promise = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + EventUtils.synthesizeMouseAtCenter(button, {}); + await promise; + + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + await prefPromise; + is( + Preferences.get(prefs.DOORHANGER_USER_DECISION_PREF), + "UIOk", + "Doorhanger decision saved." + ); + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb not cleared."); + + BrowserTestUtils.removeTab(tab); + + // Change the environment to failing and simulate a network change. + setFailingHeuristics(); + simulateNetworkChange(); + await ensureTRRMode(0); + await checkHeuristicsTelemetry("disable_doh", "netchange"); + + // Trigger another network change. + simulateNetworkChange(); + await ensureNoTRRModeChange(0); + await checkHeuristicsTelemetry("disable_doh", "netchange"); + + // Restart the controller for good measure. + await restartDoHController(); + ensureNoTRRSelectionTelemetry(); + // The mode technically changes from undefined/empty to 0 here. + await ensureTRRMode(0); + await checkHeuristicsTelemetry("disable_doh", "startup"); + + // Set a passing environment and simulate a network change. + setPassingHeuristics(); + simulateNetworkChange(); + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "netchange"); + + // Again, repeat and check nothing changed. + simulateNetworkChange(); + await ensureNoTRRModeChange(2); + await checkHeuristicsTelemetry("enable_doh", "netchange"); + + // Test the clearModeOnShutdown pref. `restartDoHController` does the actual + // test for us between shutdown and startup. + Preferences.set(prefs.CLEAR_ON_SHUTDOWN_PREF, false); + await restartDoHController(); + ensureNoTRRSelectionTelemetry(); + await ensureNoTRRModeChange(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); + Preferences.set(prefs.CLEAR_ON_SHUTDOWN_PREF, true); +}); diff --git a/browser/components/doh/test/browser/browser_dirtyEnable.js b/browser/components/doh/test/browser/browser_dirtyEnable.js new file mode 100644 index 0000000000..c704ca06e6 --- /dev/null +++ b/browser/components/doh/test/browser/browser_dirtyEnable.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); + +add_task(async function testDirtyEnable() { + // Set up a failing environment, pre-set DoH to enabled, and verify that + // when the add-on is enabled, it doesn't do anything - DoH remains turned on. + setFailingHeuristics(); + let prefPromise = TestUtils.waitForPrefChange(prefs.DISABLED_PREF); + Preferences.set(prefs.NETWORK_TRR_MODE_PREF, 2); + Preferences.set(prefs.ENABLED_PREF, true); + await prefPromise; + is( + Preferences.get(prefs.DISABLED_PREF, false), + true, + "Disabled state recorded." + ); + is( + Preferences.get(prefs.BREADCRUMB_PREF), + undefined, + "Breadcrumb not saved." + ); + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + undefined, + "TRR selection not performed." + ); + is(Preferences.get(prefs.NETWORK_TRR_MODE_PREF), 2, "TRR mode preserved."); + ensureNoTRRSelectionTelemetry(); + await ensureNoTRRModeChange(undefined); + ensureNoHeuristicsTelemetry(); + + // Simulate a network change. + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + ensureNoHeuristicsTelemetry(); + is(Preferences.get(prefs.NETWORK_TRR_MODE_PREF), 2, "TRR mode preserved."); + + // Restart the controller for good measure. + await restartDoHController(); + await ensureNoTRRModeChange(undefined); + ensureNoTRRSelectionTelemetry(); + ensureNoHeuristicsTelemetry(); + is(Preferences.get(prefs.NETWORK_TRR_MODE_PREF), 2, "TRR mode preserved."); + + // Simulate a network change. + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + is(Preferences.get(prefs.NETWORK_TRR_MODE_PREF), 2, "TRR mode preserved."); + ensureNoHeuristicsTelemetry(); +}); diff --git a/browser/components/doh/test/browser/browser_doh_region.js b/browser/components/doh/test/browser/browser_doh_region.js new file mode 100644 index 0000000000..56d1ad7142 --- /dev/null +++ b/browser/components/doh/test/browser/browser_doh_region.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; +add_task(async function testPrefFirstRollout() { + await setup(); + await setupRegion(); + let defaults = Services.prefs.getDefaultBranch(""); + + is( + DoHConfigController.currentConfig.enabled, + false, + "Rollout should not be enabled" + ); + setPassingHeuristics(); + + let configFlushedPromise = DoHTestUtils.waitForConfigFlush(); + defaults.setBoolPref(`${kRegionalPrefNamespace}.enabled`, true); + await configFlushedPromise; + + is( + DoHConfigController.currentConfig.enabled, + true, + "Rollout should be enabled" + ); + await ensureTRRMode(2); + + is( + Preferences.get("doh-rollout.home-region"), + "DE", + "Initial region should be DE" + ); + Region._setHomeRegion("UK"); + await ensureTRRMode(2); // Mode shouldn't change. + + is(Preferences.get("doh-rollout.home-region-changed"), true); + + await DoHController._uninit(); + await DoHConfigController._uninit(); + + // Check after controller gets reinitialized (or restart) + // that the region gets set to UK + await DoHConfigController.init(); + await DoHController.init(); + is(Preferences.get("doh-rollout.home-region"), "UK"); + + is( + DoHConfigController.currentConfig.enabled, + false, + "Rollout should not be enabled for new region" + ); + await ensureTRRMode(undefined); // restart of the controller should change the region. + + // Reset state to initial values. + await setupRegion(); + defaults.deleteBranch(`doh-rollout.de`); + Preferences.reset("doh-rollout.home-region-changed"); + Preferences.reset("doh-rollout.home-region"); +}); diff --git a/browser/components/doh/test/browser/browser_doorhangerUserReject.js b/browser/components/doh/test/browser/browser_doorhangerUserReject.js new file mode 100644 index 0000000000..f9468d46cd --- /dev/null +++ b/browser/components/doh/test/browser/browser_doorhangerUserReject.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); + +add_task(async function testDoorhangerUserReject() { + // Set up a passing environment and enable DoH. + setPassingHeuristics(); + let promise = waitForDoorhanger(); + let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF); + Preferences.set(prefs.ENABLED_PREF, true); + + await prefPromise; + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved."); + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/dns-query", + "TRR selection complete." + ); + await checkTRRSelectionTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, EXAMPLE_URL); + let panel = await promise; + + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + checkScalars([ + ["networking.doh_heuristics_attempts", { value: 1 }], + ["networking.doh_heuristics_pass_count", { value: 1 }], + ["networking.doh_heuristics_result", { value: Heuristics.Telemetry.pass }], + // All of the heuristics must be false. + falseExpectations([]), + ]); + + prefPromise = TestUtils.waitForPrefChange( + prefs.DOORHANGER_USER_DECISION_PREF + ); + + // Click the doorhanger's "reject" button. + let button = panel.querySelector(".popup-notification-secondary-button"); + promise = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + EventUtils.synthesizeMouseAtCenter(button, {}); + await promise; + + await prefPromise; + + is( + Preferences.get(prefs.DOORHANGER_USER_DECISION_PREF), + "UIDisabled", + "Doorhanger decision saved." + ); + + BrowserTestUtils.removeTab(tab); + + await ensureTRRMode(undefined); + ensureNoHeuristicsTelemetry(); + is(Preferences.get(prefs.BREADCRUMB_PREF), undefined, "Breadcrumb cleared."); + + checkScalars([ + ["networking.doh_heuristics_attempts", { value: 1 }], + ["networking.doh_heuristics_pass_count", { value: 1 }], + [ + "networking.doh_heuristics_result", + { value: Heuristics.Telemetry.optOut }, + ], + // All of the heuristics must be false. + falseExpectations([]), + ]); + + // Simulate a network change. + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + ensureNoHeuristicsTelemetry(); + + // Restart the controller for good measure. + await restartDoHController(); + ensureNoTRRSelectionTelemetry(); + await ensureNoTRRModeChange(undefined); + ensureNoHeuristicsTelemetry(); + + // Set failing environment and trigger another network change. + setFailingHeuristics(); + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + ensureNoHeuristicsTelemetry(); +}); diff --git a/browser/components/doh/test/browser/browser_platformDetection.js b/browser/components/doh/test/browser/browser_platformDetection.js new file mode 100644 index 0000000000..025546ae9e --- /dev/null +++ b/browser/components/doh/test/browser/browser_platformDetection.js @@ -0,0 +1,126 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Heuristics: "resource:///modules/DoHHeuristics.sys.mjs", +}); + +add_task(setup); + +add_task(async function testPlatformIndications() { + // Check if the platform heuristics actually cause a "disable_doh" event + let { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" + ); + + let mockedLinkService = { + isLinkUp: true, + linkStatusKnown: true, + linkType: Ci.nsINetworkLinkService.LINK_TYPE_WIFI, + networkID: "abcd", + dnsSuffixList: [], + platformDNSIndications: Ci.nsINetworkLinkService.NONE_DETECTED, + QueryInterface: ChromeUtils.generateQI(["nsINetworkLinkService"]), + }; + + let networkLinkServiceCID = MockRegistrar.register( + "@mozilla.org/network/network-link-service;1", + mockedLinkService + ); + + Heuristics._setMockLinkService(mockedLinkService); + registerCleanupFunction(async () => { + MockRegistrar.unregister(networkLinkServiceCID); + Heuristics._setMockLinkService(undefined); + }); + + setPassingHeuristics(); + let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF); + Preferences.set(prefs.ENABLED_PREF, true); + await prefPromise; + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved."); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + checkScalars([ + ["networking.doh_heuristics_attempts", { value: 1 }], + ["networking.doh_heuristics_pass_count", { value: 1 }], + ["networking.doh_heuristics_result", { value: Heuristics.Telemetry.pass }], + // All of the heuristics must be false. + falseExpectations([]), + ]); + + await ensureTRRMode(2); + + mockedLinkService.platformDNSIndications = + Ci.nsINetworkLinkService.VPN_DETECTED; + simulateNetworkChange(); + await ensureTRRMode(0); + await checkHeuristicsTelemetry("disable_doh", "netchange"); + checkScalars( + [ + ["networking.doh_heuristics_attempts", { value: 2 }], + ["networking.doh_heuristics_pass_count", { value: 1 }], + ["networking.doh_heuristics_result", { value: Heuristics.Telemetry.vpn }], + ["networking.doh_heuristic_ever_tripped", { value: true, key: "vpn" }], + ].concat(falseExpectations(["vpn"])) + ); + + mockedLinkService.platformDNSIndications = + Ci.nsINetworkLinkService.PROXY_DETECTED; + simulateNetworkChange(); + await ensureNoTRRModeChange(0); + await checkHeuristicsTelemetry("disable_doh", "netchange"); + checkScalars( + [ + ["networking.doh_heuristics_attempts", { value: 3 }], + ["networking.doh_heuristics_pass_count", { value: 1 }], + [ + "networking.doh_heuristics_result", + { value: Heuristics.Telemetry.proxy }, + ], + ["networking.doh_heuristic_ever_tripped", { value: true, key: "vpn" }], // Was tripped earlier this session + ["networking.doh_heuristic_ever_tripped", { value: true, key: "proxy" }], + ].concat(falseExpectations(["vpn", "proxy"])) + ); + + mockedLinkService.platformDNSIndications = + Ci.nsINetworkLinkService.NRPT_DETECTED; + simulateNetworkChange(); + await ensureNoTRRModeChange(0); + await checkHeuristicsTelemetry("disable_doh", "netchange"); + checkScalars( + [ + ["networking.doh_heuristics_attempts", { value: 4 }], + ["networking.doh_heuristics_pass_count", { value: 1 }], + [ + "networking.doh_heuristics_result", + { value: Heuristics.Telemetry.nrpt }, + ], + ["networking.doh_heuristic_ever_tripped", { value: true, key: "vpn" }], // Was tripped earlier this session + ["networking.doh_heuristic_ever_tripped", { value: true, key: "proxy" }], // Was tripped earlier this session + ["networking.doh_heuristic_ever_tripped", { value: true, key: "nrpt" }], + ].concat(falseExpectations(["vpn", "proxy", "nrpt"])) + ); + + mockedLinkService.platformDNSIndications = + Ci.nsINetworkLinkService.NONE_DETECTED; + simulateNetworkChange(); + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "netchange"); + checkScalars( + [ + ["networking.doh_heuristics_attempts", { value: 5 }], + ["networking.doh_heuristics_pass_count", { value: 2 }], + [ + "networking.doh_heuristics_result", + { value: Heuristics.Telemetry.pass }, + ], + ["networking.doh_heuristic_ever_tripped", { value: true, key: "vpn" }], // Was tripped earlier this session + ["networking.doh_heuristic_ever_tripped", { value: true, key: "proxy" }], // Was tripped earlier this session + ["networking.doh_heuristic_ever_tripped", { value: true, key: "nrpt" }], // Was tripped earlier this session + ].concat(falseExpectations(["vpn", "proxy", "nrpt"])) + ); +}); diff --git a/browser/components/doh/test/browser/browser_policyOverride.js b/browser/components/doh/test/browser/browser_policyOverride.js new file mode 100644 index 0000000000..02d851d947 --- /dev/null +++ b/browser/components/doh/test/browser/browser_policyOverride.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); + +const { EnterprisePolicyTesting } = ChromeUtils.importESModule( + "resource://testing-common/EnterprisePolicyTesting.sys.mjs" +); + +add_task(async function testPolicyOverride() { + // Set up an arbitrary enterprise policy. Its existence should be sufficient + // to disable heuristics. + await EnterprisePolicyTesting.setupPolicyEngineWithJson({ + policies: { + EnableTrackingProtection: { + Value: true, + }, + }, + }); + is( + Services.policies.status, + Ci.nsIEnterprisePolicies.ACTIVE, + "Policy engine is active." + ); + + Preferences.set(prefs.ENABLED_PREF, true); + await waitForStateTelemetry(["shutdown", "policyDisabled"]); + is( + Preferences.get(prefs.BREADCRUMB_PREF), + undefined, + "Breadcrumb not saved." + ); + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + undefined, + "TRR selection not performed." + ); + is( + Preferences.get(prefs.SKIP_HEURISTICS_PREF), + true, + "Pref set to suppress CFR." + ); + ensureNoTRRSelectionTelemetry(); + await ensureNoTRRModeChange(undefined); + ensureNoHeuristicsTelemetry(); + + checkScalars( + [ + [ + "networking.doh_heuristics_result", + { value: Heuristics.Telemetry.enterprisePresent }, + ], + // All of the heuristics must be false. + ].concat(falseExpectations([])) + ); + + // Simulate a network change. + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + ensureNoHeuristicsTelemetry(); + + // Clean up. + await EnterprisePolicyTesting.setupPolicyEngineWithJson({ + policies: {}, + }); + EnterprisePolicyTesting.resetRunOnceState(); + + is( + Services.policies.status, + Ci.nsIEnterprisePolicies.INACTIVE, + "Policy engine is inactive at the end of the test" + ); +}); diff --git a/browser/components/doh/test/browser/browser_providerSteering.js b/browser/components/doh/test/browser/browser_providerSteering.js new file mode 100644 index 0000000000..dc8b2cdf26 --- /dev/null +++ b/browser/components/doh/test/browser/browser_providerSteering.js @@ -0,0 +1,120 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const TEST_DOMAIN = "doh.test."; +const AUTO_TRR_URI = "https://example.com/dns-query"; + +add_task(setup); + +add_task(async function testProviderSteering() { + setPassingHeuristics(); + let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF); + Preferences.set(prefs.ENABLED_PREF, true); + await prefPromise; + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved."); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + let providerTestcases = [ + { + id: "provider1", + canonicalName: "foo.provider1.com", + uri: "https://foo.provider1.com/query", + }, + { + id: "provider2", + canonicalName: "bar.provider2.com", + uri: "https://bar.provider2.com/query", + }, + ]; + let configFlushPromise = DoHTestUtils.waitForConfigFlush(); + Preferences.set( + prefs.PROVIDER_STEERING_LIST_PREF, + JSON.stringify(providerTestcases) + ); + await configFlushPromise; + await checkHeuristicsTelemetry("enable_doh", "startup"); + + let testNetChangeResult = async ( + expectedURI, + heuristicsDecision, + providerName + ) => { + let trrURIChanged = TestUtils.topicObserved( + "network:trr-uri-changed", + () => { + // We need this check because this topic is observed once immediately + // after the network change when the URI is reset, and then when the + // provider steering heuristic runs and sets it to our uri. + return Services.dns.currentTrrURI == expectedURI; + } + ); + simulateNetworkChange(); + await trrURIChanged; + is( + Services.dns.currentTrrURI, + expectedURI, + `TRR URI set to ${expectedURI}` + ); + await checkHeuristicsTelemetry( + heuristicsDecision, + "netchange", + providerName + ); + }; + + for (let { id, canonicalName, uri } of providerTestcases) { + gDNSOverride.addIPOverride(TEST_DOMAIN, "9.9.9.9"); + gDNSOverride.setCnameOverride(TEST_DOMAIN, canonicalName); + await testNetChangeResult(uri, "enable_doh", id); + gDNSOverride.clearHostOverride(TEST_DOMAIN); + } + + await testNetChangeResult(AUTO_TRR_URI, "enable_doh"); + + // Just use the first provider for the remaining checks. + let provider = providerTestcases[0]; + gDNSOverride.addIPOverride(TEST_DOMAIN, "9.9.9.9"); + gDNSOverride.setCnameOverride(TEST_DOMAIN, provider.canonicalName); + await testNetChangeResult(provider.uri, "enable_doh", provider.id); + + // Trigger safesearch heuristics and ensure provider steering is disabled. + let googleDomain = "google.com."; + let googleIP = "1.1.1.1"; + let googleSafeSearchIP = "1.1.1.2"; + gDNSOverride.clearHostOverride(googleDomain); + gDNSOverride.addIPOverride(googleDomain, googleSafeSearchIP); + await testNetChangeResult(AUTO_TRR_URI, "disable_doh"); + gDNSOverride.clearHostOverride(googleDomain); + gDNSOverride.addIPOverride(googleDomain, googleIP); + checkScalars( + [ + [ + "networking.doh_heuristics_result", + { value: Heuristics.Telemetry.google }, + ], + ["networking.doh_heuristic_ever_tripped", { value: true, key: "google" }], + // All of the other heuristics must be false. + ].concat(falseExpectations(["google"])) + ); + + // Check that provider steering is enabled again after we reset above. + await testNetChangeResult(provider.uri, "enable_doh", provider.id); + + // Finally, provider steering should be disabled once we clear the override. + gDNSOverride.clearHostOverride(TEST_DOMAIN); + await testNetChangeResult(AUTO_TRR_URI, "enable_doh"); + + checkScalars( + [ + [ + "networking.doh_heuristics_result", + { value: Heuristics.Telemetry.pass }, + ], + ["networking.doh_heuristic_ever_tripped", { value: true, key: "google" }], + // All of the other heuristics must be false. + ].concat(falseExpectations(["google"])) + ); +}); diff --git a/browser/components/doh/test/browser/browser_remoteSettings_newProfile.js b/browser/components/doh/test/browser/browser_remoteSettings_newProfile.js new file mode 100644 index 0000000000..cd4356ed3f --- /dev/null +++ b/browser/components/doh/test/browser/browser_remoteSettings_newProfile.js @@ -0,0 +1,147 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); +add_task(setupRegion); + +async function setPrefAndWaitForConfigFlush(pref, value) { + let configFlushedPromise = DoHTestUtils.waitForConfigFlush(); + Preferences.set(pref, value); + await configFlushedPromise; +} + +async function clearPrefAndWaitForConfigFlush(pref, value) { + let configFlushedPromise = DoHTestUtils.waitForConfigFlush(); + Preferences.reset(pref); + await configFlushedPromise; +} + +add_task(async function testNewProfile() { + is( + DoHConfigController.currentConfig.enabled, + false, + "Rollout should not be enabled" + ); + + let provider1 = { + id: "provider1", + uri: "https://example.org/1", + autoDefault: true, + }; + let provider2 = { + id: "provider2", + uri: "https://example.org/2", + canonicalName: "https://example.org/cname", + }; + let provider3 = { + id: "provider3", + uri: "https://example.org/3", + autoDefault: true, + }; + + await DoHTestUtils.loadRemoteSettingsProviders([ + provider1, + provider2, + provider3, + ]); + + await DoHTestUtils.loadRemoteSettingsConfig({ + id: kTestRegion.toLowerCase(), + rolloutEnabled: true, + providers: "provider1, provider3", + steeringEnabled: true, + steeringProviders: "provider2", + autoDefaultEnabled: true, + autoDefaultProviders: "provider1, provider3", + }); + + is( + DoHConfigController.currentConfig.enabled, + true, + "Rollout should be enabled" + ); + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); + Assert.deepEqual( + DoHConfigController.currentConfig.providerList, + [provider1, provider3], + "Provider list should be loaded" + ); + is( + DoHConfigController.currentConfig.providerSteering.enabled, + true, + "Steering should be enabled" + ); + Assert.deepEqual( + DoHConfigController.currentConfig.providerSteering.providerList, + [provider2], + "Steering provider list should be loaded" + ); + is( + DoHConfigController.currentConfig.trrSelection.enabled, + true, + "TRR Selection should be enabled" + ); + Assert.deepEqual( + DoHConfigController.currentConfig.trrSelection.providerList, + [provider1, provider3], + "TRR Selection provider list should be loaded" + ); + is( + DoHConfigController.currentConfig.fallbackProviderURI, + provider1.uri, + "Fallback provider URI should be that of the first one" + ); + + // Test that overriding with prefs works. + await setPrefAndWaitForConfigFlush(prefs.PROVIDER_STEERING_PREF, false); + is( + DoHConfigController.currentConfig.providerSteering.enabled, + false, + "Provider steering should be disabled" + ); + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + await setPrefAndWaitForConfigFlush(prefs.TRR_SELECT_ENABLED_PREF, false); + is( + DoHConfigController.currentConfig.trrSelection.enabled, + false, + "TRR selection should be disabled" + ); + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + // Try a regional pref this time + await setPrefAndWaitForConfigFlush( + `${kRegionalPrefNamespace}.enabled`, + false + ); + is( + DoHConfigController.currentConfig.enabled, + false, + "Rollout should be disabled" + ); + await ensureTRRMode(undefined); + await ensureNoHeuristicsTelemetry(); + + await clearPrefAndWaitForConfigFlush(`${kRegionalPrefNamespace}.enabled`); + + is( + DoHConfigController.currentConfig.enabled, + true, + "Rollout should be enabled" + ); + + await DoHTestUtils.resetRemoteSettingsConfig(); + + is( + DoHConfigController.currentConfig.enabled, + false, + "Rollout should be disabled" + ); + await ensureTRRMode(undefined); +}); diff --git a/browser/components/doh/test/browser/browser_remoteSettings_rollout.js b/browser/components/doh/test/browser/browser_remoteSettings_rollout.js new file mode 100644 index 0000000000..e0e31dd238 --- /dev/null +++ b/browser/components/doh/test/browser/browser_remoteSettings_rollout.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); +add_task(setupRegion); + +add_task(async function testPrefFirstRollout() { + let defaults = Services.prefs.getDefaultBranch(""); + + setPassingHeuristics(); + + is( + DoHConfigController.currentConfig.enabled, + false, + "Rollout should not be enabled" + ); + + let configFlushedPromise = DoHTestUtils.waitForConfigFlush(); + defaults.setBoolPref(`${kRegionalPrefNamespace}.enabled`, true); + await configFlushedPromise; + + is( + DoHConfigController.currentConfig.enabled, + true, + "Rollout should be enabled" + ); + await ensureTRRMode(2); + + await DoHTestUtils.loadRemoteSettingsProviders([ + { + id: "provider1", + uri: "https://example.org/1", + autoDefault: true, + }, + ]); + + await DoHTestUtils.loadRemoteSettingsConfig({ + id: kTestRegion.toLowerCase(), + rolloutEnabled: true, + providers: "provider1", + }); + + is( + DoHConfigController.currentConfig.enabled, + true, + "Rollout should still be enabled" + ); + + defaults.deleteBranch(`${kRegionalPrefNamespace}.enabled`); + await restartDoHController(); + + is( + DoHConfigController.currentConfig.enabled, + true, + "Rollout should still be enabled" + ); + await ensureTRRMode(2); + + await DoHTestUtils.resetRemoteSettingsConfig(); + + is( + DoHConfigController.currentConfig.enabled, + false, + "Rollout should not be enabled" + ); + await ensureTRRMode(undefined); +}); diff --git a/browser/components/doh/test/browser/browser_rollback.js b/browser/components/doh/test/browser/browser_rollback.js new file mode 100644 index 0000000000..b414c35ae5 --- /dev/null +++ b/browser/components/doh/test/browser/browser_rollback.js @@ -0,0 +1,144 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +requestLongerTimeout(2); + +add_task(setup); + +add_task(async function testRollback() { + // Set up a passing environment and enable DoH. + setPassingHeuristics(); + let promise = waitForDoorhanger(); + let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF); + Preferences.set(prefs.ENABLED_PREF, true); + + await prefPromise; + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved."); + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/dns-query", + "TRR selection complete." + ); + await checkTRRSelectionTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, EXAMPLE_URL); + let panel = await promise; + + prefPromise = TestUtils.waitForPrefChange( + prefs.DOORHANGER_USER_DECISION_PREF + ); + + // Click the doorhanger's "accept" button. + let button = panel.querySelector(".popup-notification-primary-button"); + promise = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + EventUtils.synthesizeMouseAtCenter(button, {}); + await promise; + + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + await prefPromise; + is( + Preferences.get(prefs.DOORHANGER_USER_DECISION_PREF), + "UIOk", + "Doorhanger decision saved." + ); + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb not cleared."); + + BrowserTestUtils.removeTab(tab); + + // Change the environment to failing and simulate a network change. + setFailingHeuristics(); + simulateNetworkChange(); + await ensureTRRMode(0); + await checkHeuristicsTelemetry("disable_doh", "netchange"); + + // Trigger another network change. + simulateNetworkChange(); + await ensureNoTRRModeChange(0); + await checkHeuristicsTelemetry("disable_doh", "netchange"); + + // Rollback! + setPassingHeuristics(); + Preferences.reset(prefs.ENABLED_PREF); + await waitForStateTelemetry(["shutdown", "rollback"]); + await ensureTRRMode(undefined); + ensureNoTRRSelectionTelemetry(); + await ensureNoHeuristicsTelemetry(); + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + await ensureNoHeuristicsTelemetry(); + + // Re-enable. + Preferences.set(prefs.ENABLED_PREF, true); + + await ensureTRRMode(2); + ensureNoTRRSelectionTelemetry(); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + // Change the environment to failing and simulate a network change. + setFailingHeuristics(); + simulateNetworkChange(); + await ensureTRRMode(0); + await checkHeuristicsTelemetry("disable_doh", "netchange"); + + // Rollback again for good measure! This time with failing heuristics. + Preferences.reset(prefs.ENABLED_PREF); + await waitForStateTelemetry(["shutdown", "rollback"]); + await ensureTRRMode(undefined); + ensureNoTRRSelectionTelemetry(); + await ensureNoHeuristicsTelemetry(); + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + await ensureNoHeuristicsTelemetry(); + + // Re-enable. + Preferences.set(prefs.ENABLED_PREF, true); + + await ensureTRRMode(0); + ensureNoTRRSelectionTelemetry(); + await checkHeuristicsTelemetry("disable_doh", "startup"); + + // Change the environment to passing and simulate a network change. + setPassingHeuristics(); + simulateNetworkChange(); + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "netchange"); + + // Rollback again, this time with TRR mode set to 2 prior to doing so. + Preferences.reset(prefs.ENABLED_PREF); + await waitForStateTelemetry(["shutdown", "rollback"]); + await ensureTRRMode(undefined); + ensureNoTRRSelectionTelemetry(); + await ensureNoHeuristicsTelemetry(); + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + await ensureNoHeuristicsTelemetry(); + + // Re-enable. + Preferences.set(prefs.ENABLED_PREF, true); + + await ensureTRRMode(2); + ensureNoTRRSelectionTelemetry(); + await checkHeuristicsTelemetry("enable_doh", "startup"); + simulateNetworkChange(); + await ensureNoTRRModeChange(2); + await checkHeuristicsTelemetry("enable_doh", "netchange"); + + // Rollback again. This time, uninit DoHController first to ensure it reacts + // correctly at startup. + await DoHController._uninit(); + await waitForStateTelemetry(["shutdown"]); + Preferences.reset(prefs.ENABLED_PREF); + await DoHController.init(); + await ensureTRRMode(undefined); + ensureNoTRRSelectionTelemetry(); + await ensureNoHeuristicsTelemetry(); + await waitForStateTelemetry(["rollback"]); + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + await ensureNoHeuristicsTelemetry(); +}); diff --git a/browser/components/doh/test/browser/browser_throttle_heuristics.js b/browser/components/doh/test/browser/browser_throttle_heuristics.js new file mode 100644 index 0000000000..7a0b22ed11 --- /dev/null +++ b/browser/components/doh/test/browser/browser_throttle_heuristics.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); + +add_task(async function testHeuristicsThrottling() { + // Use a zero throttle timeout for the test. This both ensures the test has a + // short runtime as well as preventing intermittents because we thought + // something was deterministic when it wasn't. + let throttleTimeout = 0; + let rateLimit = 1; + let throttleDoneTopic = "doh:heuristics-throttle-done"; + let throttleExtendTopic = "doh:heuristics-throttle-extend"; + + Preferences.set(prefs.HEURISTICS_THROTTLE_TIMEOUT_PREF, throttleTimeout); + Preferences.set(prefs.HEURISTICS_THROTTLE_RATE_LIMIT_PREF, rateLimit); + + // Set up a passing environment and enable DoH. + let throttledPromise = TestUtils.topicObserved(throttleDoneTopic); + setPassingHeuristics(); + let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF); + Preferences.set(prefs.ENABLED_PREF, true); + + await prefPromise; + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved."); + await ensureTRRMode(2); + info("waiting for throttle done"); + await throttledPromise; + await checkHeuristicsTelemetry("enable_doh", "startup"); + + // Change the environment to failing and simulate a network change. + throttledPromise = TestUtils.topicObserved(throttleDoneTopic); + simulateNetworkChange(); + info("waiting for throttle done"); + await throttledPromise; + await checkHeuristicsTelemetry("enable_doh", "netchange"); + + /* Simulate two consecutive network changes and check that we throttled the + * second heuristics run. */ + + // We wait for the throttle timer to fire twice - the first time, a fresh + // heuristics run will be performed since it was queued while throttling. + // This triggers another throttle timeout which is the second one we wait for. + throttledPromise = TestUtils.topicObserved(throttleDoneTopic).then(() => + TestUtils.topicObserved(throttleDoneTopic) + ); + simulateNetworkChange(); + simulateNetworkChange(); + info("waiting for throttle done"); + await throttledPromise; + await checkHeuristicsTelemetryMultiple(["netchange", "throttled"]); + + /* Simulate several consecutive network changes at a rate that exceeds the + * rate limit and check that we only record two heuristics runs in total - + * one for the initial netchange and one throttled run at the end. */ + + // We wait for the throttle timer to be extended twice. + let throttleExtendPromise = TestUtils.topicObserved(throttleExtendTopic); + let throttleExtendPromise2 = throttleExtendPromise.then(() => + TestUtils.topicObserved(throttleExtendTopic) + ); + + // Again, we wait for the timer to fire twice - once for the volatile period + // which results in a throttled heuristics run, and once after it with no run. + throttledPromise = throttleExtendPromise2 + .then(() => TestUtils.topicObserved(throttleDoneTopic)) + .then(() => TestUtils.topicObserved(throttleDoneTopic)); + + // Simulate three network changes: + // - The first one starts the throttle timer + // - The second one is within the limit of 1. + // - The third one exceeds the limit and extends the throttle period. + simulateNetworkChange(); + simulateNetworkChange(); + simulateNetworkChange(); + + // First throttle extension should happen now. + info("waiting for throttle extend"); + await throttleExtendPromise; + + // Two more network changes to once again extend the throttle period. + simulateNetworkChange(); + simulateNetworkChange(); + + // Now the second extension should be detected. + info("waiting for throttle done"); + await throttleExtendPromise2; + + // Finally, we wait for the throttle period to finish. + info("waiting for throttle done"); + await throttledPromise; + + await checkHeuristicsTelemetryMultiple(["netchange", "throttled"]); +}); diff --git a/browser/components/doh/test/browser/browser_trrSelect.js b/browser/components/doh/test/browser/browser_trrSelect.js new file mode 100644 index 0000000000..68861be8b8 --- /dev/null +++ b/browser/components/doh/test/browser/browser_trrSelect.js @@ -0,0 +1,147 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +async function waitForStartup() { + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); +} + +async function setPrefAndWaitForConfigFlush(pref, value) { + let configFlushed = DoHTestUtils.waitForConfigFlush(); + if (value) { + Preferences.set(pref, value); + } else { + Preferences.reset(pref); + } + await configFlushed; + await waitForStartup(); +} + +add_task(setup); + +add_task(async function testTRRSelect() { + // Clean start: doh-rollout.uri should be set after init. + setPassingHeuristics(); + let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF); + Preferences.set(prefs.ENABLED_PREF, true); + await prefPromise; + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved."); + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/dns-query", + "TRR selection complete." + ); + + // Wait for heuristics to complete. + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + // Reset and restart the controller for good measure. + Preferences.reset(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF); + Preferences.reset(prefs.TRR_SELECT_URI_PREF); + await restartDoHController(); + await waitForStartup(); + + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/dns-query", + "TRR selection complete." + ); + + // Disable committing. The committed URI should be reset to the + // default provider and the dry-run-result should persist. + prefPromise = TestUtils.waitForPrefChange( + prefs.TRR_SELECT_URI_PREF, + newVal => newVal == "https://example.com/1" + ); + await setPrefAndWaitForConfigFlush(prefs.TRR_SELECT_COMMIT_PREF, false); + await prefPromise; + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/1", + "Default TRR selected." + ); + try { + await BrowserTestUtils.waitForCondition(() => { + return !Preferences.isSet(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF); + }); + ok(false, "Dry run result was cleared, fail!"); + } catch (e) { + ok(true, "Dry run result was not cleared."); + } + is( + Preferences.get(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF), + "https://example.com/dns-query", + "dry-run result has the correct value." + ); + + // Reset again, dry-run-result should be recorded but not + // be committed. Committing is still disabled from above. + Preferences.reset(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF); + Preferences.reset(prefs.TRR_SELECT_URI_PREF); + await restartDoHController(); + await waitForStartup(); + + try { + await BrowserTestUtils.waitForCondition(() => { + return ( + Preferences.get(prefs.TRR_SELECT_URI_PREF) == + "https://example.com/dns-query" + ); + }); + ok(false, "Dry run result got committed, fail!"); + } catch (e) { + ok(true, "Dry run result did not get committed"); + } + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/1", + "Default TRR selected." + ); + is( + Preferences.get(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF), + "https://example.com/dns-query", + "TRR selection complete, dry-run result recorded." + ); + + // Reset doh-rollout.uri, and change the dry-run-result to another one on the + // default list. After init, the existing dry-run-result should be committed. + Preferences.reset(prefs.TRR_SELECT_URI_PREF); + Preferences.set( + prefs.TRR_SELECT_DRY_RUN_RESULT_PREF, + "https://example.com/2" + ); + prefPromise = TestUtils.waitForPrefChange( + prefs.TRR_SELECT_URI_PREF, + newVal => newVal == "https://example.com/2" + ); + await setPrefAndWaitForConfigFlush(prefs.TRR_SELECT_COMMIT_PREF, true); + await prefPromise; + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/2", + "TRR selection complete, existing dry-run-result committed." + ); + + // Reset doh-rollout.uri, and change the dry-run-result to another one NOT on + // default list. After init, a new TRR should be selected and committed. + prefPromise = TestUtils.waitForPrefChange( + prefs.TRR_SELECT_URI_PREF, + newVal => newVal == "https://example.com/dns-query" + ); + Preferences.reset(prefs.TRR_SELECT_URI_PREF); + Preferences.set( + prefs.TRR_SELECT_DRY_RUN_RESULT_PREF, + "https://example.com/4" + ); + await restartDoHController(); + await prefPromise; + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/dns-query", + "TRR selection complete, existing dry-run-result discarded and refreshed." + ); +}); diff --git a/browser/components/doh/test/browser/browser_trrSelection_disable.js b/browser/components/doh/test/browser/browser_trrSelection_disable.js new file mode 100644 index 0000000000..dc7bd68262 --- /dev/null +++ b/browser/components/doh/test/browser/browser_trrSelection_disable.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); + +add_task(async function testTrrSelectionDisable() { + // Turn off TRR Selection. + let configFlushed = DoHTestUtils.waitForConfigFlush(); + Preferences.set(prefs.TRR_SELECT_ENABLED_PREF, false); + await configFlushed; + + // Set up a passing environment and enable DoH. + setPassingHeuristics(); + let promise = waitForDoorhanger(); + Preferences.set(prefs.ENABLED_PREF, true); + await BrowserTestUtils.waitForCondition(() => { + return Preferences.get(prefs.BREADCRUMB_PREF); + }); + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved."); + is( + Preferences.get(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF), + undefined, + "TRR selection dry run not performed." + ); + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/1", + "doh-rollout.uri set to first provider in the list." + ); + ensureNoTRRSelectionTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, EXAMPLE_URL); + let panel = await promise; + + // Click the doorhanger's "accept" button. + let button = panel.querySelector(".popup-notification-primary-button"); + promise = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + EventUtils.synthesizeMouseAtCenter(button, {}); + await promise; + + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + await BrowserTestUtils.waitForCondition(() => { + return Preferences.get(prefs.DOORHANGER_USER_DECISION_PREF); + }); + is( + Preferences.get(prefs.DOORHANGER_USER_DECISION_PREF), + "UIOk", + "Doorhanger decision saved." + ); + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb not cleared."); + + BrowserTestUtils.removeTab(tab); + + // Restart the controller for good measure. + await restartDoHController(); + ensureNoTRRSelectionTelemetry(); + is( + Preferences.get(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF), + undefined, + "TRR selection dry run not performed." + ); + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/1", + "doh-rollout.uri set to first provider in the list." + ); + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); +}); diff --git a/browser/components/doh/test/browser/browser_userInterference.js b/browser/components/doh/test/browser/browser_userInterference.js new file mode 100644 index 0000000000..96d1e3c94c --- /dev/null +++ b/browser/components/doh/test/browser/browser_userInterference.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); + +add_task(async function testUserInterference() { + // Set up a passing environment and enable DoH. + setPassingHeuristics(); + let promise = waitForDoorhanger(); + let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF); + Preferences.set(prefs.ENABLED_PREF, true); + + await prefPromise; + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved."); + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/dns-query", + "TRR selection complete." + ); + await checkTRRSelectionTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, EXAMPLE_URL); + let panel = await promise; + + prefPromise = TestUtils.waitForPrefChange( + prefs.DOORHANGER_USER_DECISION_PREF + ); + + // Click the doorhanger's "accept" button. + let button = panel.querySelector(".popup-notification-primary-button"); + promise = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + EventUtils.synthesizeMouseAtCenter(button, {}); + await promise; + await prefPromise; + + is( + Preferences.get(prefs.DOORHANGER_USER_DECISION_PREF), + "UIOk", + "Doorhanger decision saved." + ); + + BrowserTestUtils.removeTab(tab); + + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + // Set the TRR mode pref manually and ensure we respect this. + Preferences.set(prefs.NETWORK_TRR_MODE_PREF, 3); + await ensureTRRMode(undefined); + + // Simulate a network change. + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + ensureNoHeuristicsTelemetry(); + + is( + Preferences.get(prefs.DISABLED_PREF, false), + true, + "Manual disable recorded." + ); + is(Preferences.get(prefs.BREADCRUMB_PREF), undefined, "Breadcrumb cleared."); + + // Simulate another network change. + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + ensureNoHeuristicsTelemetry(); + + // Restart the controller for good measure. + await restartDoHController(); + await ensureNoTRRModeChange(undefined); + ensureNoTRRSelectionTelemetry(); + ensureNoHeuristicsTelemetry(); + + // Simulate another network change. + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + ensureNoHeuristicsTelemetry(); +}); diff --git a/browser/components/doh/test/browser/head.js b/browser/components/doh/test/browser/head.js new file mode 100644 index 0000000000..c5c4c1c16b --- /dev/null +++ b/browser/components/doh/test/browser/head.js @@ -0,0 +1,408 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs", + DoHConfigController: "resource:///modules/DoHConfig.sys.mjs", + DoHController: "resource:///modules/DoHController.sys.mjs", + DoHTestUtils: "resource://testing-common/DoHTestUtils.sys.mjs", + Heuristics: "resource:///modules/DoHHeuristics.sys.mjs", + Preferences: "resource://gre/modules/Preferences.sys.mjs", + Region: "resource://gre/modules/Region.sys.mjs", + RegionTestUtils: "resource://testing-common/RegionTestUtils.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "gDNSOverride", + "@mozilla.org/network/native-dns-override;1", + "nsINativeDNSResolverOverride" +); + +const { CommonUtils } = ChromeUtils.importESModule( + "resource://services-common/utils.sys.mjs" +); + +const EXAMPLE_URL = "https://example.com/"; + +const prefs = { + TESTING_PREF: "doh-rollout._testing", + ENABLED_PREF: "doh-rollout.enabled", + ROLLOUT_TRR_MODE_PREF: "doh-rollout.mode", + NETWORK_TRR_MODE_PREF: "network.trr.mode", + CONFIRMATION_NS_PREF: "network.trr.confirmationNS", + BREADCRUMB_PREF: "doh-rollout.self-enabled", + DOORHANGER_USER_DECISION_PREF: "doh-rollout.doorhanger-decision", + DISABLED_PREF: "doh-rollout.disable-heuristics", + SKIP_HEURISTICS_PREF: "doh-rollout.skipHeuristicsCheck", + CLEAR_ON_SHUTDOWN_PREF: "doh-rollout.clearModeOnShutdown", + FIRST_RUN_PREF: "doh-rollout.doneFirstRun", + PROVIDER_LIST_PREF: "doh-rollout.provider-list", + TRR_SELECT_ENABLED_PREF: "doh-rollout.trr-selection.enabled", + TRR_SELECT_URI_PREF: "doh-rollout.uri", + TRR_SELECT_COMMIT_PREF: "doh-rollout.trr-selection.commit-result", + TRR_SELECT_DRY_RUN_RESULT_PREF: "doh-rollout.trr-selection.dry-run-result", + PROVIDER_STEERING_PREF: "doh-rollout.provider-steering.enabled", + PROVIDER_STEERING_LIST_PREF: "doh-rollout.provider-steering.provider-list", + NETWORK_DEBOUNCE_TIMEOUT_PREF: "doh-rollout.network-debounce-timeout", + HEURISTICS_THROTTLE_TIMEOUT_PREF: "doh-rollout.heuristics-throttle-timeout", + HEURISTICS_THROTTLE_RATE_LIMIT_PREF: + "doh-rollout.heuristics-throttle-rate-limit", +}; + +const CFR_PREF = "browser.newtabpage.activity-stream.asrouter.providers.cfr"; +const CFR_JSON = { + id: "cfr", + enabled: true, + type: "local", + localProvider: "CFRMessageProvider", + categories: ["cfrAddons", "cfrFeatures"], +}; + +async function setup() { + await DoHController._uninit(); + await DoHConfigController._uninit(); + SpecialPowers.pushPrefEnv({ + set: [["security.notification_enable_delay", 0]], + }); + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + Services.telemetry.clearEvents(); + Services.telemetry.clearScalars(); + + // Enable the CFR. + Preferences.set(CFR_PREF, JSON.stringify(CFR_JSON)); + + // Tell DoHController that this isn't real life. + Preferences.set(prefs.TESTING_PREF, true); + + // Avoid non-local connections to the TRR endpoint. + Preferences.set(prefs.CONFIRMATION_NS_PREF, "skip"); + + // Enable trr selection and provider steeringfor tests. This is off + // by default so it can be controlled via Normandy. + Preferences.set(prefs.TRR_SELECT_ENABLED_PREF, true); + Preferences.set(prefs.PROVIDER_STEERING_PREF, true); + + // Enable committing the TRR selection. This pref ships false by default so + // it can be controlled e.g. via Normandy, but for testing let's set enable. + Preferences.set(prefs.TRR_SELECT_COMMIT_PREF, true); + + // Clear mode on shutdown by default. + Preferences.set(prefs.CLEAR_ON_SHUTDOWN_PREF, true); + + // Generally don't bother with debouncing or throttling. + // The throttling test will set this explicitly. + Preferences.set(prefs.NETWORK_DEBOUNCE_TIMEOUT_PREF, -1); + Preferences.set(prefs.HEURISTICS_THROTTLE_TIMEOUT_PREF, -1); + + // Set up heuristics, all passing by default. + + // Google safesearch overrides + gDNSOverride.addIPOverride("www.google.com.", "1.1.1.1"); + gDNSOverride.addIPOverride("google.com.", "1.1.1.1"); + gDNSOverride.addIPOverride("forcesafesearch.google.com.", "1.1.1.2"); + + // YouTube safesearch overrides + gDNSOverride.addIPOverride("www.youtube.com.", "2.1.1.1"); + gDNSOverride.addIPOverride("m.youtube.com.", "2.1.1.1"); + gDNSOverride.addIPOverride("youtubei.googleapis.com.", "2.1.1.1"); + gDNSOverride.addIPOverride("youtube.googleapis.com.", "2.1.1.1"); + gDNSOverride.addIPOverride("www.youtube-nocookie.com.", "2.1.1.1"); + gDNSOverride.addIPOverride("restrict.youtube.com.", "2.1.1.2"); + gDNSOverride.addIPOverride("restrictmoderate.youtube.com.", "2.1.1.2"); + + // Zscaler override + gDNSOverride.addIPOverride("sitereview.zscaler.com.", "3.1.1.1"); + + // Global canary + gDNSOverride.addIPOverride("use-application-dns.net.", "4.1.1.1"); + + await DoHTestUtils.resetRemoteSettingsConfig(false); + + await DoHConfigController.init(); + await DoHController.init(); + + await waitForStateTelemetry(["rollback"]); + + registerCleanupFunction(async () => { + Services.telemetry.canRecordExtended = oldCanRecord; + Services.telemetry.clearEvents(); + gDNSOverride.clearOverrides(); + if (ASRouter.state.messageBlockList.includes("DOH_ROLLOUT_CONFIRMATION")) { + await ASRouter.unblockMessageById("DOH_ROLLOUT_CONFIRMATION"); + } + // The CFR pref is set to an empty array in user.js for testing profiles, + // so "reset" it back to that value. + Preferences.set(CFR_PREF, "[]"); + await DoHController._uninit(); + Services.telemetry.clearEvents(); + Preferences.reset(Object.values(prefs)); + await DoHTestUtils.resetRemoteSettingsConfig(false); + await DoHController.init(); + }); +} + +const kTestRegion = "DE"; +const kRegionalPrefNamespace = `doh-rollout.${kTestRegion.toLowerCase()}`; + +async function setupRegion() { + Region._home = null; + RegionTestUtils.setNetworkRegion(kTestRegion); + await Region._fetchRegion(); + is(Region.home, kTestRegion, "Should have correct region"); + Preferences.reset("doh-rollout.home-region"); + await DoHConfigController.loadRegion(); +} + +async function checkTRRSelectionTelemetry() { + let events; + await TestUtils.waitForCondition(() => { + events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS + ).parent; + return events && events.length; + }); + events = events.filter( + e => + e[1] == "security.doh.trrPerformance" && + e[2] == "trrselect" && + e[3] == "dryrunresult" + ); + is(events.length, 1, "Found the expected trrselect event."); + is( + events[0][4], + "https://example.com/dns-query", + "The event records the expected decision" + ); +} + +function ensureNoTRRSelectionTelemetry() { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS + ).parent; + if (!events) { + ok(true, "Found no trrselect events."); + return; + } + events = events.filter( + e => + e[1] == "security.doh.trrPerformance" && + e[2] == "trrselect" && + e[3] == "dryrunresult" + ); + is(events.length, 0, "Found no trrselect events."); +} + +async function checkHeuristicsTelemetry( + decision, + evaluateReason, + steeredProvider = "" +) { + let events; + await TestUtils.waitForCondition(() => { + events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS + ).parent; + events = events?.filter( + e => e[1] == "doh" && e[2] == "evaluate_v2" && e[3] == "heuristics" + ); + return events?.length; + }); + is(events.length, 1, "Found the expected heuristics event."); + is(events[0][4], decision, "The event records the expected decision"); + if (evaluateReason) { + is(events[0][5].evaluateReason, evaluateReason, "Got the expected reason."); + } + is(events[0][5].steeredProvider, steeredProvider, "Got expected provider."); + + // After checking the event, clear all telemetry. Since we check for a single + // event above, this ensures all heuristics events are intentional and tested. + // TODO: Test events other than heuristics. Those tests would also work the + // same way, so as to test one event at a time, and this clearEvents() call + // will continue to exist as-is. + Services.telemetry.clearEvents(); +} + +// Generates an array of expectations for the ever_tripped scalar +// containing false and key, except for the keyes contained in +// the `except` parameter. +function falseExpectations(except) { + return Heuristics.Telemetry.heuristicNames() + .map(e => [ + "networking.doh_heuristic_ever_tripped", + { value: false, key: e }, + ]) + .filter(e => except && !except.includes(e[1].key)); +} + +function checkScalars(expectations) { + // expectations: [[scalarname: expectationObject]] + // expectationObject: {value, key} + let snapshot = TelemetryTestUtils.getProcessScalars("parent", false, false); + let keyedSnapshot = TelemetryTestUtils.getProcessScalars( + "parent", + true, + false + ); + for (let ex of expectations) { + let scalarName = ex[0]; + let exObject = ex[1]; + if (exObject.key) { + TelemetryTestUtils.assertKeyedScalar( + keyedSnapshot, + scalarName, + exObject.key, + exObject.value, + `${scalarName} expected to have ${exObject.value}, key: ${exObject.key}` + ); + } else { + TelemetryTestUtils.assertScalar( + snapshot, + scalarName, + exObject.value, + `${scalarName} expected to have ${exObject.value}` + ); + } + } +} + +async function checkHeuristicsTelemetryMultiple(expectedEvaluateReasons) { + let events; + await TestUtils.waitForCondition(() => { + events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS + ).parent; + if (events && events.length) { + events = events.filter( + e => e[1] == "doh" && e[2] == "evaluate_v2" && e[3] == "heuristics" + ); + if (events.length == expectedEvaluateReasons.length) { + return true; + } + } + return false; + }); + is( + events.length, + expectedEvaluateReasons.length, + "Found the expected heuristics events." + ); + for (let reason of expectedEvaluateReasons) { + let event = events.find(e => e[5].evaluateReason == reason); + is(event[5].evaluateReason, reason, `${reason} event found`); + } + Services.telemetry.clearEvents(); +} + +function ensureNoHeuristicsTelemetry() { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS + ).parent; + if (!events) { + ok(true, "Found no heuristics events."); + return; + } + events = events.filter( + e => e[1] == "doh" && e[2] == "evaluate_v2" && e[3] == "heuristics" + ); + is(events.length, 0, "Found no heuristics events."); +} + +async function waitForStateTelemetry(expectedStates) { + let events; + await TestUtils.waitForCondition(() => { + events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS + ).parent; + return events; + }); + events = events.filter(e => e[1] == "doh" && e[2] == "state"); + info(events); + is(events.length, expectedStates.length, "Found the expected state events."); + for (let state of expectedStates) { + let event = events.find(e => e[3] == state); + is(event[3], state, `${state} state found`); + } + Services.telemetry.clearEvents(); +} + +async function restartDoHController() { + let oldMode = Preferences.get(prefs.ROLLOUT_TRR_MODE_PREF); + await DoHController._uninit(); + let newMode = Preferences.get(prefs.ROLLOUT_TRR_MODE_PREF); + let expectClear = Preferences.get(prefs.CLEAR_ON_SHUTDOWN_PREF); + is( + newMode, + expectClear ? undefined : oldMode, + `Mode was ${expectClear ? "cleared" : "persisted"} on shutdown.` + ); + await DoHController.init(); +} + +// setPassing/FailingHeuristics are used generically to test that DoH is enabled +// or disabled correctly. We use the zscaler canary arbitrarily here, individual +// heuristics are tested separately. +function setPassingHeuristics() { + gDNSOverride.clearHostOverride("sitereview.zscaler.com."); + gDNSOverride.addIPOverride("sitereview.zscaler.com.", "3.1.1.1"); +} + +function setFailingHeuristics() { + gDNSOverride.clearHostOverride("sitereview.zscaler.com."); + gDNSOverride.addIPOverride("sitereview.zscaler.com.", "213.152.228.242"); +} + +async function waitForDoorhanger() { + const popupID = "contextual-feature-recommendation"; + const bucketID = "DOH_ROLLOUT_CONFIRMATION"; + let panel; + await BrowserTestUtils.waitForEvent(document, "popupshown", true, event => { + panel = event.originalTarget; + let popupNotification = event.originalTarget.firstChild; + return ( + popupNotification && + popupNotification.notification && + popupNotification.notification.id == popupID && + popupNotification.getAttribute("data-notification-bucket") == bucketID + ); + }); + return panel; +} + +function simulateNetworkChange() { + // The networkStatus API does not actually propagate the link status we supply + // here, but rather sends the link status from the NetworkLinkService. + // This means there's no point sending a down and then an up - the extension + // will just receive "up" twice. + // TODO: Implement a mock NetworkLinkService and use it to also simulate + // network down events. + Services.obs.notifyObservers(null, "network:link-status-changed", "up"); +} + +async function ensureTRRMode(mode) { + await TestUtils.waitForCondition(() => { + return Preferences.get(prefs.ROLLOUT_TRR_MODE_PREF) === mode; + }); + is(Preferences.get(prefs.ROLLOUT_TRR_MODE_PREF), mode, `TRR mode is ${mode}`); +} + +async function ensureNoTRRModeChange(mode) { + try { + // Try and wait for the TRR pref to change... waitForCondition should throw + // after trying for a while. + await TestUtils.waitForCondition(() => { + return Preferences.get(prefs.ROLLOUT_TRR_MODE_PREF) !== mode; + }); + // If we reach this, the waitForCondition didn't throw. Fail! + ok(false, "TRR mode changed when it shouldn't have!"); + } catch (e) { + // Assert for clarity. + is( + Preferences.get(prefs.ROLLOUT_TRR_MODE_PREF), + mode, + "No change in TRR mode" + ); + } +} diff --git a/browser/components/doh/test/unit/head.js b/browser/components/doh/test/unit/head.js new file mode 100644 index 0000000000..3c8010cba3 --- /dev/null +++ b/browser/components/doh/test/unit/head.js @@ -0,0 +1,99 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { NetUtil } = ChromeUtils.importESModule( + "resource://gre/modules/NetUtil.sys.mjs" +); +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +let h2Port, trrServer1, trrServer2, trrList; +let DNSLookup, LookupAggregator, TRRRacer; + +function readFile(file) { + let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + fstream.init(file, -1, 0, 0); + let data = NetUtil.readInputStreamToString(fstream, fstream.available()); + fstream.close(); + return data; +} + +function addCertFromFile(certdb, filename, trustString) { + let certFile = do_get_file(filename, false); + let pem = readFile(certFile) + .replace(/-----BEGIN CERTIFICATE-----/, "") + .replace(/-----END CERTIFICATE-----/, "") + .replace(/[\r\n]/g, ""); + certdb.addCertFromBase64(pem, trustString); +} + +function ensureNoTelemetry() { + let events = + Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).parent || []; + events = events.filter(e => e[1] == "security.doh.trrPerformance"); + Assert.ok(!events.length); +} + +function setup() { + h2Port = Services.env.get("MOZHTTP2_PORT"); + Assert.notEqual(h2Port, null); + Assert.notEqual(h2Port, ""); + + // Set to allow the cert presented by our H2 server + do_get_profile(); + + Services.prefs.setBoolPref("network.http.http2.enabled", true); + + // use the h2 server as DOH provider + trrServer1 = `https://foo.example.com:${h2Port}/doh?responseIP=1.1.1.1`; + trrServer2 = `https://foo.example.com:${h2Port}/doh?responseIP=2.2.2.2`; + trrList = [trrServer1, trrServer2]; + // make all native resolve calls "secretly" resolve localhost instead + Services.prefs.setBoolPref("network.dns.native-is-localhost", true); + + // The moz-http2 cert is for foo.example.com and is signed by http2-ca.pem + // so add that cert to the trust list as a signing cert. // the foo.example.com domain name. + let certdb = Cc["@mozilla.org/security/x509certdb;1"].getService( + Ci.nsIX509CertDB + ); + addCertFromFile(certdb, "http2-ca.pem", "CTu,u,u"); + + Services.prefs.setIntPref("doh-rollout.trrRace.randomSubdomainCount", 2); + + Services.prefs.setCharPref( + "doh-rollout.trrRace.popularDomains", + "foo.example.com., bar.example.com." + ); + + Services.prefs.setCharPref( + "doh-rollout.trrRace.canonicalDomain", + "firefox-dns-perf-test.net." + ); + + let TRRPerformance = ChromeUtils.importESModule( + "resource:///modules/TRRPerformance.sys.mjs" + ); + + DNSLookup = TRRPerformance.DNSLookup; + LookupAggregator = TRRPerformance.LookupAggregator; + TRRRacer = TRRPerformance.TRRRacer; + + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("network.http.http2.enabled"); + Services.prefs.clearUserPref("network.dns.native-is-localhost"); + + Services.telemetry.canRecordExtended = oldCanRecord; + }); +} diff --git a/browser/components/doh/test/unit/test_DNSLookup.js b/browser/components/doh/test/unit/test_DNSLookup.js new file mode 100644 index 0000000000..62ac3f3cd3 --- /dev/null +++ b/browser/components/doh/test/unit/test_DNSLookup.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); + +add_task(async function test_SuccessfulRandomDNSLookup() { + let deferred = Promise.withResolvers(); + let lookup = new DNSLookup( + null, + trrServer1, + (request, record, status, usedDomain, retryCount) => { + deferred.resolve({ request, record, status, usedDomain, retryCount }); + } + ); + lookup.doLookup(); + let result = await deferred.promise; + Assert.ok(result.usedDomain.endsWith(".firefox-dns-perf-test.net.")); + Assert.equal(result.status, Cr.NS_OK); + Assert.ok(result.record.QueryInterface(Ci.nsIDNSAddrRecord)); + Assert.ok(result.record.IsTRR()); + Assert.greater(result.record.trrFetchDuration, 0); + Assert.equal(result.retryCount, 1); +}); + +add_task(async function test_SuccessfulSpecifiedDNSLookup() { + let deferred = Promise.withResolvers(); + let lookup = new DNSLookup( + "foo.example.com", + trrServer1, + (request, record, status, usedDomain, retryCount) => { + deferred.resolve({ request, record, status, usedDomain, retryCount }); + } + ); + lookup.doLookup(); + let result = await deferred.promise; + Assert.equal(result.usedDomain, "foo.example.com"); + Assert.equal(result.status, Cr.NS_OK); + Assert.ok(result.record.QueryInterface(Ci.nsIDNSAddrRecord)); + Assert.ok(result.record.IsTRR()); + Assert.greater(result.record.trrFetchDuration, 0); + Assert.equal(result.retryCount, 1); +}); + +add_task(async function test_FailedDNSLookup() { + let deferred = Promise.withResolvers(); + let lookup = new DNSLookup( + null, + `https://foo.example.com:${h2Port}/doh?responseIP=none`, + (request, record, status, usedDomain, retryCount) => { + deferred.resolve({ request, record, status, usedDomain, retryCount }); + } + ); + lookup.doLookup(); + let result = await deferred.promise; + Assert.ok(result.usedDomain.endsWith(".firefox-dns-perf-test.net.")); + Assert.notEqual(result.status, Cr.NS_OK); + Assert.equal(result.record, null); + Assert.equal(result.retryCount, 3); +}); diff --git a/browser/components/doh/test/unit/test_LookupAggregator.js b/browser/components/doh/test/unit/test_LookupAggregator.js new file mode 100644 index 0000000000..e63920ee35 --- /dev/null +++ b/browser/components/doh/test/unit/test_LookupAggregator.js @@ -0,0 +1,162 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +add_task(setup); + +async function helper_SuccessfulLookupAggregator( + networkUnstable = false, + captivePortal = false +) { + let deferred = Promise.withResolvers(); + let aggregator = new LookupAggregator(() => deferred.resolve(), trrList); + // The aggregator's domain list should correctly reflect our set + // prefs for number of random subdomains (2) and the list of + // popular domains. + Assert.equal(aggregator.domains[0], null); + Assert.equal(aggregator.domains[1], null); + Assert.equal(aggregator.domains[2], "foo.example.com."); + Assert.equal(aggregator.domains[3], "bar.example.com."); + Assert.equal(aggregator.totalLookups, 8); // 2 TRRs * 4 domains. + + if (networkUnstable) { + aggregator.markUnstableNetwork(); + } + if (captivePortal) { + aggregator.markCaptivePortal(); + } + aggregator.run(); + await deferred.promise; + Assert.ok(!aggregator.aborted); + Assert.equal(aggregator.networkUnstable, networkUnstable); + Assert.equal(aggregator.captivePortal, captivePortal); + Assert.equal(aggregator.results.length, aggregator.totalLookups); + + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).parent; + Assert.ok(events); + events = events.filter(e => e[1] == "security.doh.trrPerformance"); + Assert.equal(events.length, aggregator.totalLookups); + + for (let event of events) { + info(JSON.stringify(event)); + Assert.equal(event[1], "security.doh.trrPerformance"); + Assert.equal(event[2], "resolved"); + Assert.equal(event[3], "record"); + Assert.equal(event[4], "success"); + } + + // We only need to check the payload of each event from here on. + events = events.map(e => e[5]); + + for (let trr of [trrServer1, trrServer2]) { + // There should be two results for random subdomains. + let results = aggregator.results.filter(r => { + return r.trr == trr && r.domain.endsWith(".firefox-dns-perf-test.net."); + }); + Assert.equal(results.length, 2); + + for (let result of results) { + Assert.ok(result.domain.endsWith(".firefox-dns-perf-test.net.")); + Assert.equal(result.trr, trr); + Assert.ok(Components.isSuccessCode(result.status)); + Assert.greater(result.time, 0); + Assert.equal(result.retryCount, 1); + + let matchingEvents = events.filter( + e => e.domain == result.domain && e.trr == result.trr + ); + Assert.equal(matchingEvents.length, 1); + let e = matchingEvents.pop(); + for (let key of Object.keys(result)) { + Assert.equal(e[key], result[key].toString()); + } + Assert.equal(e.networkUnstable, networkUnstable.toString()); + Assert.equal(e.captivePortal, captivePortal.toString()); + } + + // There should be two results for the popular domains. + results = aggregator.results.filter(r => { + return r.trr == trr && !r.domain.endsWith(".firefox-dns-perf-test.net."); + }); + Assert.equal(results.length, 2); + + Assert.ok( + [results[0].domain, results[1].domain].includes("foo.example.com.") + ); + Assert.ok( + [results[0].domain, results[1].domain].includes("bar.example.com.") + ); + for (let result of results) { + Assert.equal(result.trr, trr); + Assert.equal(result.status, Cr.NS_OK); + Assert.greater(result.time, 0); + Assert.equal(result.retryCount, 1); + + let matchingEvents = events.filter( + e => e.domain == result.domain && e.trr == result.trr + ); + Assert.equal(matchingEvents.length, 1); + let e = matchingEvents.pop(); + for (let key of Object.keys(result)) { + Assert.equal(e[key], result[key].toString()); + } + Assert.equal(e.networkUnstable, networkUnstable.toString()); + Assert.equal(e.captivePortal, captivePortal.toString()); + } + } + + Services.telemetry.clearEvents(); +} + +add_task(async function test_SuccessfulLookupAggregator() { + await helper_SuccessfulLookupAggregator(false, false); + await helper_SuccessfulLookupAggregator(false, true); + await helper_SuccessfulLookupAggregator(true, false); + await helper_SuccessfulLookupAggregator(true, true); +}); + +add_task(async function test_AbortedLookupAggregator() { + let deferred = Promise.withResolvers(); + let aggregator = new LookupAggregator(() => deferred.resolve(), trrList); + // The aggregator's domain list should correctly reflect our set + // prefs for number of random subdomains (2) and the list of + // popular domains. + Assert.equal(aggregator.domains[0], null); + Assert.equal(aggregator.domains[1], null); + Assert.equal(aggregator.domains[2], "foo.example.com."); + Assert.equal(aggregator.domains[3], "bar.example.com."); + Assert.equal(aggregator.totalLookups, 8); // 2 TRRs * 4 domains. + + // The aggregator should never call the onComplete callback. To test + // this, race the deferred promise with a 3 second timeout. The timeout + // should win, since the deferred promise should never resolve. + let timeoutPromise = new Promise(resolve => { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(() => resolve("timeout"), 3000); + }); + aggregator.run(); + aggregator.abort(); + let winner = await Promise.race([deferred.promise, timeoutPromise]); + Assert.equal(winner, "timeout"); + Assert.ok(aggregator.aborted); + Assert.ok(!aggregator.networkUnstable); + Assert.ok(!aggregator.captivePortal); + + // Ensure we send no telemetry for an aborted run! + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).parent; + Assert.ok( + !events || !events.filter(e => e[1] == "security.doh.trrPerformance").length + ); +}); diff --git a/browser/components/doh/test/unit/test_TRRRacer.js b/browser/components/doh/test/unit/test_TRRRacer.js new file mode 100644 index 0000000000..9ad38f3981 --- /dev/null +++ b/browser/components/doh/test/unit/test_TRRRacer.js @@ -0,0 +1,209 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); + +add_task(async function test_TRRRacer_cleanRun() { + let deferred = Promise.withResolvers(); + let racer = new TRRRacer(() => { + deferred.resolve(); + deferred.resolved = true; + }, trrList); + racer.run(); + + await deferred.promise; + Assert.equal(racer._retryCount, 1); + + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).parent; + Assert.ok(events); + events = events.filter(e => e[1] == "security.doh.trrPerformance"); + Assert.equal(events.length, racer._aggregator.totalLookups); + + Services.telemetry.clearEvents(); + + // Simulate network changes and ensure no re-runs since it's already complete. + async function testNetworkChange(captivePortal = false) { + if (captivePortal) { + Services.obs.notifyObservers(null, "captive-portal-login"); + } else { + Services.obs.notifyObservers(null, "network:link-status-changed", "down"); + } + + Assert.ok(!racer._aggregator.aborted); + + if (captivePortal) { + Services.obs.notifyObservers(null, "captive-portal-login-success"); + } else { + Services.obs.notifyObservers(null, "network:link-status-changed", "up"); + } + + Assert.equal(racer._retryCount, 1); + ensureNoTelemetry(); + + if (captivePortal) { + Services.obs.notifyObservers(null, "captive-portal-login-abort"); + } + } + + testNetworkChange(false); + testNetworkChange(true); +}); + +async function test_TRRRacer_networkFlux_helper(captivePortal = false) { + let deferred = Promise.withResolvers(); + let racer = new TRRRacer(() => { + deferred.resolve(); + deferred.resolved = true; + }, trrList); + racer.run(); + + if (captivePortal) { + Services.obs.notifyObservers(null, "captive-portal-login"); + } else { + Services.obs.notifyObservers(null, "network:link-status-changed", "down"); + } + + Assert.ok(racer._aggregator.aborted); + ensureNoTelemetry(); + Assert.equal(racer._retryCount, 1); + Assert.ok(!deferred.resolved); + + if (captivePortal) { + Services.obs.notifyObservers(null, "captive-portal-login-success"); + } else { + Services.obs.notifyObservers(null, "network:link-status-changed", "up"); + } + + Assert.ok(!racer._aggregator.aborted); + await deferred.promise; + + Assert.equal(racer._retryCount, 2); + + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).parent; + Assert.ok(events); + events = events.filter(e => e[1] == "security.doh.trrPerformance"); + Assert.equal(events.length, racer._aggregator.totalLookups); + + Services.telemetry.clearEvents(); + if (captivePortal) { + Services.obs.notifyObservers(null, "captive-portal-login-abort"); + } +} + +add_task(async function test_TRRRacer_networkFlux() { + await test_TRRRacer_networkFlux_helper(false); + await test_TRRRacer_networkFlux_helper(true); +}); + +async function test_TRRRacer_maxRetries_helper(captivePortal = false) { + let deferred = Promise.withResolvers(); + let racer = new TRRRacer(() => { + deferred.resolve(); + deferred.resolved = true; + }, trrList); + racer.run(); + info("ran new racer"); + // Start at i = 1 since we're already at retry #1. + for (let i = 1; i < 5; ++i) { + if (captivePortal) { + Services.obs.notifyObservers(null, "captive-portal-login"); + } else { + Services.obs.notifyObservers(null, "network:link-status-changed", "down"); + } + + info("notified observers"); + + Assert.ok(racer._aggregator.aborted); + ensureNoTelemetry(); + Assert.equal(racer._retryCount, i); + Assert.ok(!deferred.resolved); + + if (captivePortal) { + Services.obs.notifyObservers(null, "captive-portal-login-success"); + } else { + Services.obs.notifyObservers(null, "network:link-status-changed", "up"); + } + } + + // Simulate a "down" network event and ensure we still send telemetry + // since we've maxed out our retry count. + if (captivePortal) { + Services.obs.notifyObservers(null, "captive-portal-login"); + } else { + Services.obs.notifyObservers(null, "network:link-status-changed", "down"); + } + Assert.ok(!racer._aggregator.aborted); + await deferred.promise; + Assert.equal(racer._retryCount, 5); + + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).parent; + Assert.ok(events); + events = events.filter(e => e[1] == "security.doh.trrPerformance"); + Assert.equal(events.length, racer._aggregator.totalLookups); + + Services.telemetry.clearEvents(); + if (captivePortal) { + Services.obs.notifyObservers(null, "captive-portal-login-abort"); + } +} + +add_task(async function test_TRRRacer_maxRetries() { + await test_TRRRacer_maxRetries_helper(false); + await test_TRRRacer_maxRetries_helper(true); +}); + +add_task(async function test_TRRRacer_getFastestTRRFromResults() { + let results = [ + { trr: "trr1", time: 10 }, + { trr: "trr2", time: 100 }, + { trr: "trr1", time: 1000 }, + { trr: "trr2", time: 110 }, + { trr: "trr3", time: -1 }, + { trr: "trr4", time: -1 }, + { trr: "trr4", time: -1 }, + { trr: "trr4", time: 1 }, + { trr: "trr4", time: 1 }, + { trr: "trr5", time: 10 }, + { trr: "trr5", time: 20 }, + { trr: "trr5", time: 1000 }, + ]; + let racer = new TRRRacer(undefined, trrList); + let fastest = racer._getFastestTRRFromResults(results); + // trr1's geometric mean is 100 + // trr2's geometric mean is 110 + // trr3 has no valid times, excluded + // trr4 has 50% invalid times, excluded + // trr5's geometric mean is ~58.5, it's the winner. + Assert.equal(fastest, "trr5"); + + // When no valid entries are available, undefined is the default output. + results = [ + { trr: "trr1", time: -1 }, + { trr: "trr2", time: -1 }, + ]; + + fastest = racer._getFastestTRRFromResults(results); + Assert.equal(fastest, undefined); + + // When passing `returnRandomDefault = true`, verify that both TRRs are + // possible outputs. The probability that the randomization is working + // correctly and we consistently get the same output after 50 iterations is + // 0.5^50 ~= 8.9*10^-16. + let firstResult = racer._getFastestTRRFromResults(results, true); + while (racer._getFastestTRRFromResults(results, true) == firstResult) { + continue; + } + Assert.ok(true, "Both TRRs were possible outputs when all results invalid."); +}); diff --git a/browser/components/doh/test/unit/test_heuristics.js b/browser/components/doh/test/unit/test_heuristics.js new file mode 100644 index 0000000000..a6a6c9b6c9 --- /dev/null +++ b/browser/components/doh/test/unit/test_heuristics.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +let cid; + +async function SetMockParentalControlEnabled(aEnabled) { + if (cid) { + MockRegistrar.unregister(cid); + } + + let parentalControlsService = { + parentalControlsEnabled: aEnabled, + QueryInterface: ChromeUtils.generateQI(["nsIParentalControlsService"]), + }; + cid = MockRegistrar.register( + "@mozilla.org/parental-controls-service;1", + parentalControlsService + ); +} + +registerCleanupFunction(() => { + if (cid) { + MockRegistrar.unregister(cid); + } +}); + +add_task(setup); + +add_task(async function test_parentalControls() { + let DoHHeuristics = ChromeUtils.importESModule( + "resource:///modules/DoHHeuristics.sys.mjs" + ); + + let parentalControls = DoHHeuristics.parentalControls; + + Assert.equal( + await parentalControls(), + "enable_doh", + "By default, parental controls should be disabled and doh should be enabled" + ); + + SetMockParentalControlEnabled(false); + + Assert.equal( + await parentalControls(), + "enable_doh", + "Mocked parental controls service is disabled; doh is enabled" + ); + + SetMockParentalControlEnabled(true); + + Assert.equal( + await parentalControls(), + "enable_doh", + "Default value of mocked parental controls service is disabled; doh is enabled" + ); + + SetMockParentalControlEnabled(false); + + Assert.equal( + await parentalControls(), + "enable_doh", + "Mocked parental controls service is disabled; doh is enabled" + ); + + MockRegistrar.unregister(cid); + + Assert.equal( + await parentalControls(), + "enable_doh", + "By default, parental controls should be disabled and doh should be enabled" + ); +}); diff --git a/browser/components/doh/test/unit/xpcshell.toml b/browser/components/doh/test/unit/xpcshell.toml new file mode 100644 index 0000000000..9d40b74621 --- /dev/null +++ b/browser/components/doh/test/unit/xpcshell.toml @@ -0,0 +1,14 @@ +[DEFAULT] +skip-if = ["os == 'android'"] # bug 1730213 +head = "head.js" +firefox-appdir = "browser" +support-files = ["../../../../../netwerk/test/unit/http2-ca.pem"] + +["test_DNSLookup.js"] +skip-if = ["debug"] # Bug 1617845 + +["test_LookupAggregator.js"] + +["test_TRRRacer.js"] + +["test_heuristics.js"] diff --git a/browser/components/downloads/DownloadSpamProtection.sys.mjs b/browser/components/downloads/DownloadSpamProtection.sys.mjs new file mode 100644 index 0000000000..a05c508e62 --- /dev/null +++ b/browser/components/downloads/DownloadSpamProtection.sys.mjs @@ -0,0 +1,295 @@ +/* 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/. */ + +/** + * Provides functions to prevent multiple automatic downloads. + */ + +import { + Download, + DownloadError, +} from "resource://gre/modules/DownloadCore.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", + DownloadList: "resource://gre/modules/DownloadList.sys.mjs", + Downloads: "resource://gre/modules/Downloads.sys.mjs", + DownloadsCommon: "resource:///modules/DownloadsCommon.sys.mjs", +}); + +/** + * Each window tracks download spam independently, so one of these objects is + * constructed for each window. This is responsible for tracking the spam and + * updating the window's downloads UI accordingly. + */ +class WindowSpamProtection { + constructor(window) { + this._window = window; + } + + /** + * This map stores blocked spam downloads for the window, keyed by the + * download's source URL. This is done so we can track the number of times a + * given download has been blocked. + * @type {Map} + */ + _downloadSpamForUrl = new Map(); + + /** + * This set stores views that are waiting to have download notification + * listeners attached. They will be attached when the spamList is created + * (i.e. when the first spam download is blocked). + * @type {Set} + */ + _pendingViews = new Set(); + + /** + * Set to true when we first start _blocking downloads in the window. This is + * used to lazily load the spamList. Spam downloads are rare enough that many + * sessions will have no blocked downloads. So we don't want to create a + * DownloadList unless we actually need it. + * @type {Boolean} + */ + _blocking = false; + + /** + * A per-window DownloadList for blocked spam downloads. Registered views will + * be sent notifications about downloads in this list, so that blocked spam + * downloads can be represented in the UI. If spam downloads haven't been + * blocked in the window, this will be undefined. See DownloadList.sys.mjs. + * @type {DownloadList | undefined} + */ + get spamList() { + if (!this._blocking) { + return undefined; + } + if (!this._spamList) { + this._spamList = new lazy.DownloadList(); + } + return this._spamList; + } + + /** + * A per-window downloads indicator whose state depends on notifications from + * DownloadLists registered in the window (for example, the visual state of + * the downloads toolbar button). See DownloadsCommon.sys.mjs for more details. + * @type {DownloadsIndicatorData} + */ + get indicator() { + if (!this._indicator) { + this._indicator = lazy.DownloadsCommon.getIndicatorData(this._window); + } + return this._indicator; + } + + /** + * Add a blocked download to the spamList or increment the count of an + * existing blocked download, then notify listeners about this. + * @param {String} url + */ + addDownloadSpam(url) { + this._blocking = true; + // Start listening on registered downloads views, if any exist. + this._maybeAddViews(); + // If this URL is already paired with a DownloadSpam object, increment its + // blocked downloads count by 1 and don't open the downloads panel. + if (this._downloadSpamForUrl.has(url)) { + let downloadSpam = this._downloadSpamForUrl.get(url); + downloadSpam.blockedDownloadsCount += 1; + this.indicator.onDownloadStateChanged(downloadSpam); + return; + } + // Otherwise, create a new DownloadSpam object for the URL, add it to the + // spamList, and open the downloads panel. + let downloadSpam = new DownloadSpam(url); + this.spamList.add(downloadSpam); + this._downloadSpamForUrl.set(url, downloadSpam); + this._notifyDownloadSpamAdded(downloadSpam); + } + + /** + * Notify the downloads panel that a new download has been added to the + * spamList. This is invoked when a new DownloadSpam object is created. + * @param {DownloadSpam} downloadSpam + */ + _notifyDownloadSpamAdded(downloadSpam) { + let hasActiveDownloads = lazy.DownloadsCommon.summarizeDownloads( + this.indicator._activeDownloads() + ).numDownloading; + if ( + !hasActiveDownloads && + this._window === lazy.BrowserWindowTracker.getTopWindow() + ) { + // If there are no active downloads, open the downloads panel. + this._window.DownloadsPanel.showPanel(); + } else { + // Otherwise, flash a taskbar/dock icon notification if available. + this._window.getAttention(); + } + this.indicator.onDownloadAdded(downloadSpam); + } + + /** + * Remove the download spam data for a given source URL. + * @param {String} url + */ + removeDownloadSpamForUrl(url) { + if (this._downloadSpamForUrl.has(url)) { + let downloadSpam = this._downloadSpamForUrl.get(url); + this.spamList.remove(downloadSpam); + this.indicator.onDownloadRemoved(downloadSpam); + this._downloadSpamForUrl.delete(url); + } + } + + /** + * Set up a downloads view (e.g. the downloads panel) to receive notifications + * about downloads in the spamList. + * @param {Object} view An object that implements handlers for download + * related notifications, like onDownloadAdded. + */ + registerView(view) { + if (!view || this.spamList?._views.has(view)) { + return; + } + this._pendingViews.add(view); + this._maybeAddViews(); + } + + /** + * If any downloads have been blocked in the window, add download notification + * listeners for each downloads view that has been registered. + */ + _maybeAddViews() { + if (this.spamList) { + for (let view of this._pendingViews) { + if (!this.spamList._views.has(view)) { + this.spamList.addView(view); + } + } + this._pendingViews.clear(); + } + } + + /** + * Remove download notification listeners for all views. This is invoked when + * the window is closed. + */ + removeAllViews() { + if (this.spamList) { + for (let view of this.spamList._views) { + this.spamList.removeView(view); + } + } + this._pendingViews.clear(); + } +} + +/** + * Responsible for detecting events related to downloads spam and notifying the + * relevant window's WindowSpamProtection object. This is a singleton object, + * constructed by DownloadIntegration.sys.mjs when the first download is blocked. + */ +export class DownloadSpamProtection { + /** + * Stores spam protection data per-window. + * @type {WeakMap} + */ + _forWindowMap = new WeakMap(); + + /** + * Add download spam data for a given source URL in the window where the + * download was blocked. This is invoked when a download is blocked by + * nsExternalAppHandler::IsDownloadSpam + * @param {String} url + * @param {Window} window + */ + update(url, window) { + if (window == null) { + lazy.DownloadsCommon.log( + "Download spam blocked in a non-chrome window. URL: ", + url + ); + return; + } + // Get the spam protection object for a given window or create one if it + // does not already exist. Also attach notification listeners to any pending + // downloads views. + let wsp = + this._forWindowMap.get(window) ?? new WindowSpamProtection(window); + this._forWindowMap.set(window, wsp); + wsp.addDownloadSpam(url); + } + + /** + * Get the spam list for a given window (provided it exists). + * @param {Window} window + * @returns {DownloadList} + */ + getSpamListForWindow(window) { + return this._forWindowMap.get(window)?.spamList; + } + + /** + * Remove the download spam data for a given source URL in the passed window, + * if any exists. + * @param {String} url + * @param {Window} window + */ + removeDownloadSpamForWindow(url, window) { + let wsp = this._forWindowMap.get(window); + wsp?.removeDownloadSpamForUrl(url); + } + + /** + * Create the spam protection object for a given window (if not already + * created) and prepare to start listening for notifications on the passed + * downloads view. The bulk of resources won't be expended until a download is + * blocked. To add multiple views, call this method multiple times. + * @param {Object} view An object that implements handlers for download + * related notifications, like onDownloadAdded. + * @param {Window} window + */ + register(view, window) { + let wsp = + this._forWindowMap.get(window) ?? new WindowSpamProtection(window); + // Try setting up the view now; it will be deferred if there's no spam. + wsp.registerView(view); + this._forWindowMap.set(window, wsp); + } + + /** + * Remove the spam protection object for a window when it is closed. + * @param {Window} window + */ + unregister(window) { + let wsp = this._forWindowMap.get(window); + if (wsp) { + // Stop listening on the view if it was previously set up. + wsp.removeAllViews(); + this._forWindowMap.delete(window); + } + } +} + +/** + * Represents a special Download object for download spam. + * @extends Download + */ +class DownloadSpam extends Download { + constructor(url) { + super(); + this.hasBlockedData = true; + this.stopped = true; + this.error = new DownloadError({ + becauseBlockedByReputationCheck: true, + reputationCheckVerdict: lazy.Downloads.Error.BLOCK_VERDICT_DOWNLOAD_SPAM, + }); + this.target = { path: "" }; + this.source = { url }; + this.blockedDownloadsCount = 1; + } +} diff --git a/browser/components/downloads/DownloadsCommon.sys.mjs b/browser/components/downloads/DownloadsCommon.sys.mjs new file mode 100644 index 0000000000..a0cbd2f8d8 --- /dev/null +++ b/browser/components/downloads/DownloadsCommon.sys.mjs @@ -0,0 +1,1643 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */ +/* 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/. */ + +/** + * Handles the Downloads panel shared methods and data access. + * + * This file includes the following constructors and global objects: + * + * DownloadsCommon + * This object is exposed directly to the consumers of this JavaScript module, + * and provides shared methods for all the instances of the user interface. + * + * DownloadsData + * Retrieves the list of past and completed downloads from the underlying + * Downloads API data, and provides asynchronous notifications allowing + * to build a consistent view of the available data. + * + * DownloadsIndicatorData + * This object registers itself with DownloadsData as a view, and transforms the + * notifications it receives into overall status data, that is then broadcast to + * the registered download status indicators. + */ + +// Globals + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", + DownloadHistory: "resource://gre/modules/DownloadHistory.sys.mjs", + DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs", + Downloads: "resource://gre/modules/Downloads.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + gClipboardHelper: [ + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper", + ], + gMIMEService: ["@mozilla.org/mime;1", "nsIMIMEService"], +}); + +ChromeUtils.defineLazyGetter(lazy, "DownloadsLogger", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + let consoleOptions = { + maxLogLevelPref: "browser.download.loglevel", + prefix: "Downloads", + }; + return new ConsoleAPI(consoleOptions); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "gAlwaysOpenPanel", + "browser.download.alwaysOpenPanel", + true +); + +const kDownloadsStringBundleUrl = + "chrome://browser/locale/downloads/downloads.properties"; + +const kDownloadsFluentStrings = new Localization( + ["browser/downloads.ftl"], + true +); + +const kDownloadsStringsRequiringFormatting = { + sizeWithUnits: true, + statusSeparator: true, + statusSeparatorBeforeNumber: true, +}; + +const kMaxHistoryResultsForLimitedView = 42; + +const kPrefBranch = Services.prefs.getBranch("browser.download."); + +const kGenericContentTypes = [ + "application/octet-stream", + "binary/octet-stream", + "application/unknown", +]; + +var PrefObserver = { + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + getPref(name) { + try { + switch (typeof this.prefs[name]) { + case "boolean": + return kPrefBranch.getBoolPref(name); + } + } catch (ex) {} + return this.prefs[name]; + }, + observe(aSubject, aTopic, aData) { + if (this.prefs.hasOwnProperty(aData)) { + delete this[aData]; + this[aData] = this.getPref(aData); + } + }, + register(prefs) { + this.prefs = prefs; + kPrefBranch.addObserver("", this, true); + for (let key in prefs) { + let name = key; + ChromeUtils.defineLazyGetter(this, name, function () { + return PrefObserver.getPref(name); + }); + } + }, +}; + +PrefObserver.register({ + // prefName: defaultValue + openInSystemViewerContextMenuItem: true, + alwaysOpenInSystemViewerContextMenuItem: true, +}); + +// DownloadsCommon + +/** + * This object is exposed directly to the consumers of this JavaScript module, + * and provides shared methods for all the instances of the user interface. + */ +export var DownloadsCommon = { + // The following legacy constants are still returned by stateOfDownload, but + // individual properties of the Download object should normally be used. + DOWNLOAD_NOTSTARTED: -1, + DOWNLOAD_DOWNLOADING: 0, + DOWNLOAD_FINISHED: 1, + DOWNLOAD_FAILED: 2, + DOWNLOAD_CANCELED: 3, + DOWNLOAD_PAUSED: 4, + DOWNLOAD_BLOCKED_PARENTAL: 6, + DOWNLOAD_DIRTY: 8, + DOWNLOAD_BLOCKED_POLICY: 9, + + // The following are the possible values of the "attention" property. + ATTENTION_NONE: "", + ATTENTION_SUCCESS: "success", + ATTENTION_INFO: "info", + ATTENTION_WARNING: "warning", + ATTENTION_SEVERE: "severe", + + // Bit flags for the attentionSuppressed property. + SUPPRESS_NONE: 0, + SUPPRESS_PANEL_OPEN: 1, + SUPPRESS_ALL_DOWNLOADS_OPEN: 2, + SUPPRESS_CONTENT_AREA_DOWNLOADS_OPEN: 4, + + /** + * Returns an object whose keys are the string names from the downloads string + * bundle, and whose values are either the translated strings or functions + * returning formatted strings. + */ + get strings() { + let strings = {}; + let sb = Services.strings.createBundle(kDownloadsStringBundleUrl); + for (let string of sb.getSimpleEnumeration()) { + let stringName = string.key; + if (stringName in kDownloadsStringsRequiringFormatting) { + strings[stringName] = function () { + // Convert "arguments" to a real array before calling into XPCOM. + return sb.formatStringFromName(stringName, Array.from(arguments)); + }; + } else { + strings[stringName] = string.value; + } + } + delete this.strings; + return (this.strings = strings); + }, + + /** + * Indicates whether or not to show the 'Open in system viewer' context menu item when appropriate + */ + get openInSystemViewerItemEnabled() { + return PrefObserver.openInSystemViewerContextMenuItem; + }, + + /** + * Indicates whether or not to show the 'Always open...' context menu item when appropriate + */ + get alwaysOpenInSystemViewerItemEnabled() { + return PrefObserver.alwaysOpenInSystemViewerContextMenuItem; + }, + + /** + * Get access to one of the DownloadsData, PrivateDownloadsData, or + * HistoryDownloadsData objects, depending on the privacy status of the + * specified window and on whether history downloads should be included. + * + * @param [optional] window + * The browser window which owns the download button. + * If not given, the privacy status will be assumed as non-private. + * @param [optional] history + * True to include history downloads when the window is public. + * @param [optional] privateAll + * Whether to force the public downloads data to be returned together + * with the private downloads data for a private window. + * @param [optional] limited + * True to limit the amount of downloads returned to + * `kMaxHistoryResultsForLimitedView`. + */ + getData(window, history = false, privateAll = false, limited = false) { + let isPrivate = + window && lazy.PrivateBrowsingUtils.isContentWindowPrivate(window); + if (isPrivate && !privateAll) { + return lazy.PrivateDownloadsData; + } + if (history) { + if (isPrivate && privateAll) { + return lazy.LimitedPrivateHistoryDownloadData; + } + return limited + ? lazy.LimitedHistoryDownloadsData + : lazy.HistoryDownloadsData; + } + return lazy.DownloadsData; + }, + + /** + * Initializes the Downloads back-end and starts receiving events for both the + * private and non-private downloads data objects. + */ + initializeAllDataLinks() { + lazy.DownloadsData.initializeDataLink(); + lazy.PrivateDownloadsData.initializeDataLink(); + }, + + /** + * Get access to one of the DownloadsIndicatorData or + * PrivateDownloadsIndicatorData objects, depending on the privacy status of + * the window in question. + */ + getIndicatorData(aWindow) { + if (lazy.PrivateBrowsingUtils.isContentWindowPrivate(aWindow)) { + return lazy.PrivateDownloadsIndicatorData; + } + return lazy.DownloadsIndicatorData; + }, + + /** + * Returns a reference to the DownloadsSummaryData singleton - creating one + * in the process if one hasn't been instantiated yet. + * + * @param aWindow + * The browser window which owns the download button. + * @param aNumToExclude + * The number of items on the top of the downloads list to exclude + * from the summary. + */ + getSummary(aWindow, aNumToExclude) { + if (lazy.PrivateBrowsingUtils.isContentWindowPrivate(aWindow)) { + if (this._privateSummary) { + return this._privateSummary; + } + return (this._privateSummary = new DownloadsSummaryData( + true, + aNumToExclude + )); + } + if (this._summary) { + return this._summary; + } + return (this._summary = new DownloadsSummaryData(false, aNumToExclude)); + }, + _summary: null, + _privateSummary: null, + + /** + * Returns the legacy state integer value for the provided Download object. + */ + stateOfDownload(download) { + // Collapse state using the correct priority. + if (!download.stopped) { + return DownloadsCommon.DOWNLOAD_DOWNLOADING; + } + if (download.succeeded) { + return DownloadsCommon.DOWNLOAD_FINISHED; + } + if (download.error) { + if (download.error.becauseBlockedByParentalControls) { + return DownloadsCommon.DOWNLOAD_BLOCKED_PARENTAL; + } + if (download.error.becauseBlockedByReputationCheck) { + return DownloadsCommon.DOWNLOAD_DIRTY; + } + return DownloadsCommon.DOWNLOAD_FAILED; + } + if (download.canceled) { + if (download.hasPartialData) { + return DownloadsCommon.DOWNLOAD_PAUSED; + } + return DownloadsCommon.DOWNLOAD_CANCELED; + } + return DownloadsCommon.DOWNLOAD_NOTSTARTED; + }, + + /** + * Removes a Download object from both session and history downloads. + */ + async deleteDownload(download) { + // Check hasBlockedData to avoid double counting if you click the X button + // in the Libarary view and then delete the download from the history. + if ( + download.error?.becauseBlockedByReputationCheck && + download.hasBlockedData + ) { + Services.telemetry + .getKeyedHistogramById("DOWNLOADS_USER_ACTION_ON_BLOCKED_DOWNLOAD") + .add(download.error.reputationCheckVerdict, 1); // confirm block + } + + // Remove the associated history element first, if any, so that the views + // that combine history and session downloads won't resurrect the history + // download into the view just before it is deleted permanently. + try { + await lazy.PlacesUtils.history.remove(download.source.url); + } catch (ex) { + console.error(ex); + } + let list = await lazy.Downloads.getList(lazy.Downloads.ALL); + await list.remove(download); + await download.finalize(true); + }, + + /** + * Deletes all files associated with a download, with or without removing it + * from the session downloads list and/or download history. + * + * @param download + * The download to delete and/or forget. + * @param clearHistory + * Optional. Removes history from session downloads list or history. + * 0 - Don't remove the download from session list or history. + * 1 - Remove the download from session list, but not history. + * 2 - Remove the download from both session list and history. + */ + async deleteDownloadFiles(download, clearHistory = 0) { + if (clearHistory > 1) { + try { + await lazy.PlacesUtils.history.remove(download.source.url); + } catch (ex) { + console.error(ex); + } + } + if (clearHistory > 0) { + let list = await lazy.Downloads.getList(lazy.Downloads.ALL); + await list.remove(download); + } + await download.manuallyRemoveData(); + if (clearHistory < 2) { + lazy.DownloadHistory.updateMetaData(download).catch(console.error); + } + }, + + /** + * Get a nsIMIMEInfo object for a download + */ + getMimeInfo(download) { + if (!download.succeeded) { + return null; + } + let contentType = download.contentType; + let url = Cc["@mozilla.org/network/standard-url-mutator;1"] + .createInstance(Ci.nsIURIMutator) + .setSpec("http://example.com") // construct the URL + .setFilePath(download.target.path) + .finalize() + .QueryInterface(Ci.nsIURL); + let fileExtension = url.fileExtension; + + // look at file extension if there's no contentType or it is generic + if (!contentType || kGenericContentTypes.includes(contentType)) { + try { + contentType = lazy.gMIMEService.getTypeFromExtension(fileExtension); + } catch (ex) { + DownloadsCommon.log( + "Cant get mimeType from file extension: ", + fileExtension + ); + } + } + if (!(contentType || fileExtension)) { + return null; + } + let mimeInfo = null; + try { + mimeInfo = lazy.gMIMEService.getFromTypeAndExtension( + contentType || "", + fileExtension || "" + ); + } catch (ex) { + DownloadsCommon.log( + "Can't get nsIMIMEInfo for contentType: ", + contentType, + "and fileExtension:", + fileExtension + ); + } + return mimeInfo; + }, + + /** + * Confirm if the download exists on the filesystem and is a given mime-type + */ + isFileOfType(download, mimeType) { + if (!(download.succeeded && download.target?.exists)) { + DownloadsCommon.log( + `isFileOfType returning false for mimeType: ${mimeType}, succeeded: ${download.succeeded}, exists: ${download.target?.exists}` + ); + return false; + } + let mimeInfo = DownloadsCommon.getMimeInfo(download); + return mimeInfo?.type === mimeType.toLowerCase(); + }, + + /** + * Copies the source URI of the given Download object to the clipboard. + */ + copyDownloadLink(download) { + lazy.gClipboardHelper.copyString( + download.source.originalUrl || download.source.url + ); + }, + + /** + * Given an iterable collection of Download objects, generates and returns + * statistics about that collection. + * + * @param downloads An iterable collection of Download objects. + * + * @return Object whose properties are the generated statistics. Currently, + * we return the following properties: + * + * numActive : The total number of downloads. + * numPaused : The total number of paused downloads. + * numDownloading : The total number of downloads being downloaded. + * totalSize : The total size of all downloads once completed. + * totalTransferred: The total amount of transferred data for these + * downloads. + * slowestSpeed : The slowest download rate. + * rawTimeLeft : The estimated time left for the downloads to + * complete. + * percentComplete : The percentage of bytes successfully downloaded. + */ + summarizeDownloads(downloads) { + let summary = { + numActive: 0, + numPaused: 0, + numDownloading: 0, + totalSize: 0, + totalTransferred: 0, + // slowestSpeed is Infinity so that we can use Math.min to + // find the slowest speed. We'll set this to 0 afterwards if + // it's still at Infinity by the time we're done iterating all + // download. + slowestSpeed: Infinity, + rawTimeLeft: -1, + percentComplete: -1, + }; + + for (let download of downloads) { + summary.numActive++; + + if (!download.stopped) { + summary.numDownloading++; + if (download.hasProgress && download.speed > 0) { + let sizeLeft = download.totalBytes - download.currentBytes; + summary.rawTimeLeft = Math.max( + summary.rawTimeLeft, + sizeLeft / download.speed + ); + summary.slowestSpeed = Math.min(summary.slowestSpeed, download.speed); + } + } else if (download.canceled && download.hasPartialData) { + summary.numPaused++; + } + + // Only add to total values if we actually know the download size. + if (download.succeeded) { + summary.totalSize += download.target.size; + summary.totalTransferred += download.target.size; + } else if (download.hasProgress) { + summary.totalSize += download.totalBytes; + summary.totalTransferred += download.currentBytes; + } + } + + if (summary.totalSize != 0) { + summary.percentComplete = Math.floor( + (summary.totalTransferred / summary.totalSize) * 100 + ); + } + + if (summary.slowestSpeed == Infinity) { + summary.slowestSpeed = 0; + } + + return summary; + }, + + /** + * If necessary, smooths the estimated number of seconds remaining for one + * or more downloads to complete. + * + * @param aSeconds + * Current raw estimate on number of seconds left for one or more + * downloads. This is a floating point value to help get sub-second + * accuracy for current and future estimates. + */ + smoothSeconds(aSeconds, aLastSeconds) { + // We apply an algorithm similar to the DownloadUtils.getTimeLeft function, + // though tailored to a single time estimation for all downloads. We never + // apply something if the new value is less than half the previous value. + let shouldApplySmoothing = aLastSeconds >= 0 && aSeconds > aLastSeconds / 2; + if (shouldApplySmoothing) { + // Apply hysteresis to favor downward over upward swings. Trust only 30% + // of the new value if lower, and 10% if higher (exponential smoothing). + let diff = aSeconds - aLastSeconds; + aSeconds = aLastSeconds + (diff < 0 ? 0.3 : 0.1) * diff; + + // If the new time is similar, reuse something close to the last time + // left, but subtract a little to provide forward progress. + diff = aSeconds - aLastSeconds; + let diffPercent = (diff / aLastSeconds) * 100; + if (Math.abs(diff) < 5 || Math.abs(diffPercent) < 5) { + aSeconds = aLastSeconds - (diff < 0 ? 0.4 : 0.2); + } + } + + // In the last few seconds of downloading, we are always subtracting and + // never adding to the time left. Ensure that we never fall below one + // second left until all downloads are actually finished. + return (aLastSeconds = Math.max(aSeconds, 1)); + }, + + /** + * Opens a downloaded file. + * + * @param downloadProperties + * A Download object or the initial properties of a serialized download + * @param options.openWhere + * Optional string indicating how to handle opening a download target file URI. + * One of "window", "tab", "tabshifted". + * @param options.useSystemDefault + * Optional value indicating how to handle launching this download, + * this call only. Will override the associated mimeInfo.preferredAction + * @return {Promise} + * @resolves When the instruction to launch the file has been + * successfully given to the operating system or handled internally + * @rejects JavaScript exception if there was an error trying to launch + * the file. + */ + async openDownload(download, options) { + // some download objects got serialized and need reconstituting + if (typeof download.launch !== "function") { + download = await lazy.Downloads.createDownload(download); + } + return download.launch(options).catch(ex => console.error(ex)); + }, + + /** + * Show a downloaded file in the system file manager. + * + * @param aFile + * a downloaded file. + */ + showDownloadedFile(aFile) { + if (!(aFile instanceof Ci.nsIFile)) { + throw new Error("aFile must be a nsIFile object"); + } + try { + // Show the directory containing the file and select the file. + aFile.reveal(); + } catch (ex) { + // If reveal fails for some reason (e.g., it's not implemented on unix + // or the file doesn't exist), try using the parent if we have it. + let parent = aFile.parent; + if (parent) { + this.showDirectory(parent); + } + } + }, + + /** + * Show the specified folder in the system file manager. + * + * @param aDirectory + * a directory to be opened with system file manager. + */ + showDirectory(aDirectory) { + if (!(aDirectory instanceof Ci.nsIFile)) { + throw new Error("aDirectory must be a nsIFile object"); + } + try { + aDirectory.launch(); + } catch (ex) { + // If launch fails (probably because it's not implemented), let + // the OS handler try to open the directory. + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI( + lazy.NetUtil.newURI(aDirectory), + Services.scriptSecurityManager.getSystemPrincipal() + ); + } + }, + + /** + * Displays an alert message box which asks the user if they want to + * unblock the downloaded file or not. + * + * @param options + * An object with the following properties: + * { + * verdict: + * The detailed reason why the download was blocked, according to + * the "Downloads.Error.BLOCK_VERDICT_" constants. If an unknown + * reason is specified, "Downloads.Error.BLOCK_VERDICT_MALWARE" is + * assumed. + * window: + * The window with which this action is associated. + * dialogType: + * String that determines which actions are available: + * - "unblock" to offer just "unblock". + * - "chooseUnblock" to offer "unblock" and "confirmBlock". + * - "chooseOpen" to offer "open" and "confirmBlock". + * } + * + * @return {Promise} + * @resolves String representing the action that should be executed: + * - "open" to allow the download and open the file. + * - "unblock" to allow the download without opening the file. + * - "confirmBlock" to delete the blocked data permanently. + * - "cancel" to do nothing and cancel the operation. + */ + async confirmUnblockDownload({ verdict, window, dialogType }) { + let s = DownloadsCommon.strings; + + // All the dialogs have an action button and a cancel button, while only + // some of them have an additonal button to remove the file. The cancel + // button must always be the one at BUTTON_POS_1 because this is the value + // returned by confirmEx when using ESC or closing the dialog (bug 345067). + let title = s.unblockHeaderUnblock; + let firstButtonText = s.unblockButtonUnblock; + let firstButtonAction = "unblock"; + let buttonFlags = + Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_0 + + Ci.nsIPrompt.BUTTON_TITLE_CANCEL * Ci.nsIPrompt.BUTTON_POS_1; + + switch (dialogType) { + case "unblock": + // Use only the unblock action. The default is to cancel. + buttonFlags += Ci.nsIPrompt.BUTTON_POS_1_DEFAULT; + break; + case "chooseUnblock": + // Use the unblock and remove file actions. The default is remove file. + buttonFlags += + Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_2 + + Ci.nsIPrompt.BUTTON_POS_2_DEFAULT; + break; + case "chooseOpen": + // Use the unblock and open file actions. The default is open file. + title = s.unblockHeaderOpen; + firstButtonText = s.unblockButtonOpen; + firstButtonAction = "open"; + buttonFlags += + Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_2 + + Ci.nsIPrompt.BUTTON_POS_0_DEFAULT; + break; + default: + console.error("Unexpected dialog type: " + dialogType); + return "cancel"; + } + + let message; + switch (verdict) { + case lazy.Downloads.Error.BLOCK_VERDICT_UNCOMMON: + message = s.unblockTypeUncommon2; + break; + case lazy.Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED: + message = s.unblockTypePotentiallyUnwanted2; + break; + case lazy.Downloads.Error.BLOCK_VERDICT_INSECURE: + message = s.unblockInsecure2; + break; + default: + // Assume Downloads.Error.BLOCK_VERDICT_MALWARE + message = s.unblockTypeMalware; + break; + } + message += "\n\n" + s.unblockTip2; + + Services.ww.registerNotification(function onOpen(subj, topic) { + if (topic == "domwindowopened" && subj instanceof Ci.nsIDOMWindow) { + // Make sure to listen for "DOMContentLoaded" because it is fired + // before the "load" event. + subj.addEventListener( + "DOMContentLoaded", + function () { + if ( + subj.document.documentURI == + "chrome://global/content/commonDialog.xhtml" + ) { + Services.ww.unregisterNotification(onOpen); + let dialog = subj.document.getElementById("commonDialog"); + if (dialog) { + // Change the dialog to use a warning icon. + dialog.classList.add("alert-dialog"); + } + } + }, + { once: true } + ); + } + }); + + let rv = Services.prompt.confirmEx( + window, + title, + message, + buttonFlags, + firstButtonText, + null, + s.unblockButtonConfirmBlock, + null, + {} + ); + return [firstButtonAction, "cancel", "confirmBlock"][rv]; + }, +}; + +ChromeUtils.defineLazyGetter(DownloadsCommon, "log", () => { + return lazy.DownloadsLogger.log.bind(lazy.DownloadsLogger); +}); +ChromeUtils.defineLazyGetter(DownloadsCommon, "error", () => { + return lazy.DownloadsLogger.error.bind(lazy.DownloadsLogger); +}); + +// DownloadsData + +/** + * Retrieves the list of past and completed downloads from the underlying + * Downloads API data, and provides asynchronous notifications allowing to + * build a consistent view of the available data. + * + * Note that using this object does not automatically initialize the list of + * downloads. This is useful to display a neutral progress indicator in + * the main browser window until the autostart timeout elapses. + * + * This powers the DownloadsData, PrivateDownloadsData, and HistoryDownloadsData + * singleton objects. + */ +function DownloadsDataCtor({ isPrivate, isHistory, maxHistoryResults } = {}) { + this._isPrivate = !!isPrivate; + + // Contains all the available Download objects and their integer state. + this._oldDownloadStates = new WeakMap(); + + // For the history downloads list we don't need to register this as a view, + // but we have to ensure that the DownloadsData object is initialized before + // we register more views. This ensures that the view methods of DownloadsData + // are invoked before those of views registered on HistoryDownloadsData, + // allowing the endTime property to be set correctly. + if (isHistory) { + if (isPrivate) { + lazy.PrivateDownloadsData.initializeDataLink(); + } + lazy.DownloadsData.initializeDataLink(); + this._promiseList = lazy.DownloadsData._promiseList.then(() => { + // For history downloads in Private Browsing mode, we'll fetch the combined + // list of public and private downloads. + return lazy.DownloadHistory.getList({ + type: isPrivate ? lazy.Downloads.ALL : lazy.Downloads.PUBLIC, + maxHistoryResults, + }); + }); + return; + } + + // This defines "initializeDataLink" and "_promiseList" synchronously, then + // continues execution only when "initializeDataLink" is called, allowing the + // underlying data to be loaded only when actually needed. + this._promiseList = (async () => { + await new Promise(resolve => (this.initializeDataLink = resolve)); + let list = await lazy.Downloads.getList( + isPrivate ? lazy.Downloads.PRIVATE : lazy.Downloads.PUBLIC + ); + await list.addView(this); + return list; + })(); +} + +DownloadsDataCtor.prototype = { + /** + * Starts receiving events for current downloads. + */ + initializeDataLink() {}, + + /** + * Promise resolved with the underlying DownloadList object once we started + * receiving events for current downloads. + */ + _promiseList: null, + + /** + * Iterator for all the available Download objects. This is empty until the + * data has been loaded using the JavaScript API for downloads. + */ + get _downloads() { + return ChromeUtils.nondeterministicGetWeakMapKeys(this._oldDownloadStates); + }, + + /** + * True if there are finished downloads that can be removed from the list. + */ + get canRemoveFinished() { + for (let download of this._downloads) { + // Stopped, paused, and failed downloads with partial data are removed. + if (download.stopped && !(download.canceled && download.hasPartialData)) { + return true; + } + } + return false; + }, + + /** + * Asks the back-end to remove finished downloads from the list. This method + * is only called after the data link has been initialized. + */ + removeFinished() { + lazy.Downloads.getList( + this._isPrivate ? lazy.Downloads.PRIVATE : lazy.Downloads.PUBLIC + ) + .then(list => list.removeFinished()) + .catch(console.error); + }, + + // Integration with the asynchronous Downloads back-end + + onDownloadAdded(download) { + // Download objects do not store the end time of downloads, as the Downloads + // API does not need to persist this information for all platforms. Once a + // download terminates on a Desktop browser, it becomes a history download, + // for which the end time is stored differently, as a Places annotation. + download.endTime = Date.now(); + + this._oldDownloadStates.set( + download, + DownloadsCommon.stateOfDownload(download) + ); + if (download.error?.becauseBlockedByReputationCheck) { + this._notifyDownloadEvent("error"); + } + }, + + onDownloadChanged(download) { + let oldState = this._oldDownloadStates.get(download); + let newState = DownloadsCommon.stateOfDownload(download); + this._oldDownloadStates.set(download, newState); + + if (oldState != newState) { + if ( + download.succeeded || + (download.canceled && !download.hasPartialData) || + download.error + ) { + // Store the end time that may be displayed by the views. + download.endTime = Date.now(); + + // This state transition code should actually be located in a Downloads + // API module (bug 941009). + lazy.DownloadHistory.updateMetaData(download).catch(console.error); + } + + if ( + download.succeeded || + (download.error && download.error.becauseBlocked) + ) { + this._notifyDownloadEvent("finish"); + } + } + + if (!download.newDownloadNotified) { + download.newDownloadNotified = true; + this._notifyDownloadEvent("start", { + openDownloadsListOnStart: download.openDownloadsListOnStart, + }); + } + }, + + onDownloadRemoved(download) { + this._oldDownloadStates.delete(download); + }, + + // Registration of views + + /** + * Adds an object to be notified when the available download data changes. + * The specified object is initialized with the currently available downloads. + * + * @param aView + * DownloadsView object to be added. This reference must be passed to + * removeView before termination. + */ + addView(aView) { + this._promiseList.then(list => list.addView(aView)).catch(console.error); + }, + + /** + * Removes an object previously added using addView. + * + * @param aView + * DownloadsView object to be removed. + */ + removeView(aView) { + this._promiseList.then(list => list.removeView(aView)).catch(console.error); + }, + + // Notifications sent to the most recent browser window only + + /** + * Set to true after the first download causes the downloads panel to be + * displayed. + */ + get panelHasShownBefore() { + try { + return Services.prefs.getBoolPref("browser.download.panel.shown"); + } catch (ex) {} + return false; + }, + + set panelHasShownBefore(aValue) { + Services.prefs.setBoolPref("browser.download.panel.shown", aValue); + }, + + /** + * Displays a new or finished download notification in the most recent browser + * window, if one is currently available with the required privacy type. + * @param {string} aType + * Set to "start" for new downloads, "finish" for completed downloads, + * "error" for downloads that failed and need attention + * @param {boolean} [openDownloadsListOnStart] + * (Only relevant when aType = "start") + * true (default) - open the downloads panel. + * false - only show an indicator notification. + */ + _notifyDownloadEvent(aType, { openDownloadsListOnStart = true } = {}) { + DownloadsCommon.log( + "Attempting to notify that a new download has started or finished." + ); + + // Show the panel in the most recent browser window, if present. + let browserWin = lazy.BrowserWindowTracker.getTopWindow({ + private: this._isPrivate, + }); + if (!browserWin) { + return; + } + + let shouldOpenDownloadsPanel = + aType == "start" && + DownloadsCommon.summarizeDownloads(this._downloads).numDownloading <= 1 && + lazy.gAlwaysOpenPanel; + + // For new downloads after the first one, don't show the panel + // automatically, but provide a visible notification in the topmost browser + // window, if the status indicator is already visible. Also ensure that if + // openDownloadsListOnStart = false is passed, we always skip opening the + // panel. That's because this will only be passed if the download is started + // without user interaction or if a dialog was previously opened in the + // process of the download (e.g. unknown content type dialog). + if ( + aType != "error" && + ((this.panelHasShownBefore && !shouldOpenDownloadsPanel) || + !openDownloadsListOnStart || + browserWin != Services.focus.activeWindow) + ) { + DownloadsCommon.log("Showing new download notification."); + browserWin.DownloadsIndicatorView.showEventNotification(aType); + return; + } + this.panelHasShownBefore = true; + browserWin.DownloadsPanel.showPanel(); + }, +}; + +ChromeUtils.defineLazyGetter(lazy, "HistoryDownloadsData", function () { + return new DownloadsDataCtor({ isHistory: true }); +}); + +ChromeUtils.defineLazyGetter(lazy, "LimitedHistoryDownloadsData", function () { + return new DownloadsDataCtor({ + isHistory: true, + maxHistoryResults: kMaxHistoryResultsForLimitedView, + }); +}); + +ChromeUtils.defineLazyGetter( + lazy, + "LimitedPrivateHistoryDownloadData", + function () { + return new DownloadsDataCtor({ + isPrivate: true, + isHistory: true, + maxHistoryResults: kMaxHistoryResultsForLimitedView, + }); + } +); + +ChromeUtils.defineLazyGetter(lazy, "PrivateDownloadsData", function () { + return new DownloadsDataCtor({ isPrivate: true }); +}); + +ChromeUtils.defineLazyGetter(lazy, "DownloadsData", function () { + return new DownloadsDataCtor(); +}); + +// DownloadsViewPrototype + +/** + * A prototype for an object that registers itself with DownloadsData as soon + * as a view is registered with it. + */ +const DownloadsViewPrototype = { + /** + * Contains all the available Download objects and their current state value. + * + * SUBCLASSES MUST OVERRIDE THIS PROPERTY. + */ + _oldDownloadStates: null, + + // Registration of views + + /** + * Array of view objects that should be notified when the available status + * data changes. + * + * SUBCLASSES MUST OVERRIDE THIS PROPERTY. + */ + _views: null, + + /** + * Determines whether this view object is over the private or non-private + * downloads. + * + * SUBCLASSES MUST OVERRIDE THIS PROPERTY. + */ + _isPrivate: false, + + /** + * Adds an object to be notified when the available status data changes. + * The specified object is initialized with the currently available status. + * + * @param aView + * View object to be added. This reference must be + * passed to removeView before termination. + */ + addView(aView) { + // Start receiving events when the first of our views is registered. + if (!this._views.length) { + if (this._isPrivate) { + lazy.PrivateDownloadsData.addView(this); + } else { + lazy.DownloadsData.addView(this); + } + } + + this._views.push(aView); + this.refreshView(aView); + }, + + /** + * Updates the properties of an object previously added using addView. + * + * @param aView + * View object to be updated. + */ + refreshView(aView) { + // Update immediately even if we are still loading data asynchronously. + // Subclasses must provide these two functions! + this._refreshProperties(); + this._updateView(aView); + }, + + /** + * Removes an object previously added using addView. + * + * @param aView + * View object to be removed. + */ + removeView(aView) { + let index = this._views.indexOf(aView); + if (index != -1) { + this._views.splice(index, 1); + } + + // Stop receiving events when the last of our views is unregistered. + if (!this._views.length) { + if (this._isPrivate) { + lazy.PrivateDownloadsData.removeView(this); + } else { + lazy.DownloadsData.removeView(this); + } + } + }, + + // Callback functions from DownloadList + + /** + * Indicates whether we are still loading downloads data asynchronously. + */ + _loading: false, + + /** + * Called before multiple downloads are about to be loaded. + */ + onDownloadBatchStarting() { + this._loading = true; + }, + + /** + * Called after data loading finished. + */ + onDownloadBatchEnded() { + this._loading = false; + this._updateViews(); + }, + + /** + * Called when a new download data item is available, either during the + * asynchronous data load or when a new download is started. + * + * @param download + * Download object that was just added. + * + * @note Subclasses should override this and still call the base method. + */ + onDownloadAdded(download) { + this._oldDownloadStates.set( + download, + DownloadsCommon.stateOfDownload(download) + ); + }, + + /** + * Called when the overall state of a Download has changed. In particular, + * this is called only once when the download succeeds or is blocked + * permanently, and is never called if only the current progress changed. + * + * The onDownloadChanged notification will always be sent afterwards. + * + * @note Subclasses should override this. + */ + onDownloadStateChanged(download) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + + /** + * Called every time any state property of a Download may have changed, + * including progress properties. + * + * Note that progress notification changes are throttled at the Downloads.sys.mjs + * API level, and there is no throttling mechanism in the front-end. + * + * @note Subclasses should override this and still call the base method. + */ + onDownloadChanged(download) { + let oldState = this._oldDownloadStates.get(download); + let newState = DownloadsCommon.stateOfDownload(download); + this._oldDownloadStates.set(download, newState); + + if (oldState != newState) { + this.onDownloadStateChanged(download); + } + }, + + /** + * Called when a data item is removed, ensures that the widget associated with + * the view item is removed from the user interface. + * + * @param download + * Download object that is being removed. + * + * @note Subclasses should override this. + */ + onDownloadRemoved(download) { + this._oldDownloadStates.delete(download); + }, + + /** + * Private function used to refresh the internal properties being sent to + * each registered view. + * + * @note Subclasses should override this. + */ + _refreshProperties() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + + /** + * Private function used to refresh an individual view. + * + * @note Subclasses should override this. + */ + _updateView() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + + /** + * Computes aggregate values and propagates the changes to our views. + */ + _updateViews() { + // Do not update the status indicators during batch loads of download items. + if (this._loading) { + return; + } + + this._refreshProperties(); + this._views.forEach(this._updateView, this); + }, +}; + +// DownloadsIndicatorData + +/** + * This object registers itself with DownloadsData as a view, and transforms the + * notifications it receives into overall status data, that is then broadcast to + * the registered download status indicators. + * + * Note that using this object does not automatically start the Download Manager + * service. Consumers will see an empty list of downloads until the service is + * actually started. This is useful to display a neutral progress indicator in + * the main browser window until the autostart timeout elapses. + */ +function DownloadsIndicatorDataCtor(aPrivate) { + this._oldDownloadStates = new WeakMap(); + this._isPrivate = aPrivate; + this._views = []; +} +DownloadsIndicatorDataCtor.prototype = { + /** + * Map of the relative severities of different attention states. + * Used in sorting the map of active downloads' attention states + * to determine the attention state to be displayed. + */ + _attentionPriority: new Map([ + [DownloadsCommon.ATTENTION_NONE, 0], + [DownloadsCommon.ATTENTION_SUCCESS, 1], + [DownloadsCommon.ATTENTION_INFO, 2], + [DownloadsCommon.ATTENTION_WARNING, 3], + [DownloadsCommon.ATTENTION_SEVERE, 4], + ]), + + /** + * Iterator for all the available Download objects. This is empty until the + * data has been loaded using the JavaScript API for downloads. + */ + get _downloads() { + return ChromeUtils.nondeterministicGetWeakMapKeys(this._oldDownloadStates); + }, + + /** + * Removes an object previously added using addView. + * + * @param aView + * DownloadsIndicatorView object to be removed. + */ + removeView(aView) { + DownloadsViewPrototype.removeView.call(this, aView); + + if (!this._views.length) { + this._itemCount = 0; + } + }, + + onDownloadAdded(download) { + DownloadsViewPrototype.onDownloadAdded.call(this, download); + this._itemCount++; + this._updateViews(); + }, + + onDownloadStateChanged(download) { + if (this._attentionSuppressed !== DownloadsCommon.SUPPRESS_NONE) { + return; + } + let attention; + if ( + !download.succeeded && + download.error && + download.error.reputationCheckVerdict + ) { + switch (download.error.reputationCheckVerdict) { + case lazy.Downloads.Error.BLOCK_VERDICT_UNCOMMON: + attention = DownloadsCommon.ATTENTION_INFO; + break; + case lazy.Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED: // fall-through + case lazy.Downloads.Error.BLOCK_VERDICT_INSECURE: + case lazy.Downloads.Error.BLOCK_VERDICT_DOWNLOAD_SPAM: + attention = DownloadsCommon.ATTENTION_WARNING; + break; + case lazy.Downloads.Error.BLOCK_VERDICT_MALWARE: + attention = DownloadsCommon.ATTENTION_SEVERE; + break; + default: + attention = DownloadsCommon.ATTENTION_SEVERE; + console.error( + "Unknown reputation verdict: " + + download.error.reputationCheckVerdict + ); + } + } else if (download.succeeded) { + attention = DownloadsCommon.ATTENTION_SUCCESS; + } else if (download.error) { + attention = DownloadsCommon.ATTENTION_WARNING; + } + download.attention = attention; + this.updateAttention(); + }, + + onDownloadChanged(download) { + DownloadsViewPrototype.onDownloadChanged.call(this, download); + this._updateViews(); + }, + + onDownloadRemoved(download) { + DownloadsViewPrototype.onDownloadRemoved.call(this, download); + this._itemCount--; + this.updateAttention(); + this._updateViews(); + }, + + // Propagation of properties to our views + + // The following properties are updated by _refreshProperties and are then + // propagated to the views. See _refreshProperties for details. + _hasDownloads: false, + _percentComplete: -1, + + /** + * Indicates whether the download indicators should be highlighted. + */ + set attention(aValue) { + this._attention = aValue; + this._updateViews(); + }, + _attention: DownloadsCommon.ATTENTION_NONE, + + /** + * Indicates whether the user is interacting with downloads, thus the + * attention indication should not be shown even if requested. + */ + set attentionSuppressed(aFlags) { + this._attentionSuppressed = aFlags; + if (aFlags !== DownloadsCommon.SUPPRESS_NONE) { + for (let download of this._downloads) { + download.attention = DownloadsCommon.ATTENTION_NONE; + } + this.attention = DownloadsCommon.ATTENTION_NONE; + } + }, + get attentionSuppressed() { + return this._attentionSuppressed; + }, + _attentionSuppressed: DownloadsCommon.SUPPRESS_NONE, + + /** + * Set the indicator's attention to the most severe attention state among the + * unseen displayed downloads, or DownloadsCommon.ATTENTION_NONE if empty. + */ + updateAttention() { + let currentAttention = DownloadsCommon.ATTENTION_NONE; + let currentPriority = 0; + for (let download of this._downloads) { + let { attention } = download; + let priority = this._attentionPriority.get(attention); + if (priority > currentPriority) { + currentPriority = priority; + currentAttention = attention; + } + } + this.attention = currentAttention; + }, + + /** + * Updates the specified view with the current aggregate values. + * + * @param aView + * DownloadsIndicatorView object to be updated. + */ + _updateView(aView) { + aView.hasDownloads = this._hasDownloads; + aView.percentComplete = this._percentComplete; + aView.attention = + this.attentionSuppressed !== DownloadsCommon.SUPPRESS_NONE + ? DownloadsCommon.ATTENTION_NONE + : this._attention; + }, + + // Property updating based on current download status + + /** + * Number of download items that are available to be displayed. + */ + _itemCount: 0, + + /** + * A generator function for the Download objects this summary is currently + * interested in. This generator is passed off to summarizeDownloads in order + * to generate statistics about the downloads we care about - in this case, + * it's all active downloads. + */ + *_activeDownloads() { + let downloads = this._isPrivate + ? lazy.PrivateDownloadsData._downloads + : lazy.DownloadsData._downloads; + for (let download of downloads) { + if (!download.stopped || (download.canceled && download.hasPartialData)) { + yield download; + } + } + }, + + /** + * Computes aggregate values based on the current state of downloads. + */ + _refreshProperties() { + let summary = DownloadsCommon.summarizeDownloads(this._activeDownloads()); + + // Determine if the indicator should be shown or get attention. + this._hasDownloads = this._itemCount > 0; + + // Always show a progress bar if there are downloads in progress. + if (summary.percentComplete >= 0) { + this._percentComplete = summary.percentComplete; + } else if (summary.numDownloading > 0) { + this._percentComplete = 0; + } else { + this._percentComplete = -1; + } + }, +}; +Object.setPrototypeOf( + DownloadsIndicatorDataCtor.prototype, + DownloadsViewPrototype +); + +ChromeUtils.defineLazyGetter( + lazy, + "PrivateDownloadsIndicatorData", + function () { + return new DownloadsIndicatorDataCtor(true); + } +); + +ChromeUtils.defineLazyGetter(lazy, "DownloadsIndicatorData", function () { + return new DownloadsIndicatorDataCtor(false); +}); + +// DownloadsSummaryData + +/** + * DownloadsSummaryData is a view for DownloadsData that produces a summary + * of all downloads after a certain exclusion point aNumToExclude. For example, + * if there were 5 downloads in progress, and a DownloadsSummaryData was + * constructed with aNumToExclude equal to 3, then that DownloadsSummaryData + * would produce a summary of the last 2 downloads. + * + * @param aIsPrivate + * True if the browser window which owns the download button is a private + * window. + * @param aNumToExclude + * The number of items to exclude from the summary, starting from the + * top of the list. + */ +function DownloadsSummaryData(aIsPrivate, aNumToExclude) { + this._numToExclude = aNumToExclude; + // Since we can have multiple instances of DownloadsSummaryData, we + // override these values from the prototype so that each instance can be + // completely separated from one another. + this._loading = false; + + this._downloads = []; + + // Floating point value indicating the last number of seconds estimated until + // the longest download will finish. We need to store this value so that we + // don't continuously apply smoothing if the actual download state has not + // changed. This is set to -1 if the previous value is unknown. + this._lastRawTimeLeft = -1; + + // Last number of seconds estimated until all in-progress downloads with a + // known size and speed will finish. This value is stored to allow smoothing + // in case of small variations. This is set to -1 if the previous value is + // unknown. + this._lastTimeLeft = -1; + + // The following properties are updated by _refreshProperties and are then + // propagated to the views. + this._showingProgress = false; + this._details = ""; + this._description = ""; + this._numActive = 0; + this._percentComplete = -1; + + this._oldDownloadStates = new WeakMap(); + this._isPrivate = aIsPrivate; + this._views = []; +} + +DownloadsSummaryData.prototype = { + /** + * Removes an object previously added using addView. + * + * @param aView + * DownloadsSummary view to be removed. + */ + removeView(aView) { + DownloadsViewPrototype.removeView.call(this, aView); + + if (!this._views.length) { + // Clear out our collection of Download objects. If we ever have + // another view registered with us, this will get re-populated. + this._downloads = []; + } + }, + + onDownloadAdded(download) { + DownloadsViewPrototype.onDownloadAdded.call(this, download); + this._downloads.unshift(download); + this._updateViews(); + }, + + onDownloadStateChanged() { + // Since the state of a download changed, reset the estimated time left. + this._lastRawTimeLeft = -1; + this._lastTimeLeft = -1; + }, + + onDownloadChanged(download) { + DownloadsViewPrototype.onDownloadChanged.call(this, download); + this._updateViews(); + }, + + onDownloadRemoved(download) { + DownloadsViewPrototype.onDownloadRemoved.call(this, download); + let itemIndex = this._downloads.indexOf(download); + this._downloads.splice(itemIndex, 1); + this._updateViews(); + }, + + // Propagation of properties to our views + + /** + * Updates the specified view with the current aggregate values. + * + * @param aView + * DownloadsIndicatorView object to be updated. + */ + _updateView(aView) { + aView.showingProgress = this._showingProgress; + aView.percentComplete = this._percentComplete; + aView.description = this._description; + aView.details = this._details; + }, + + // Property updating based on current download status + + /** + * A generator function for the Download objects this summary is currently + * interested in. This generator is passed off to summarizeDownloads in order + * to generate statistics about the downloads we care about - in this case, + * it's the downloads in this._downloads after the first few to exclude, + * which was set when constructing this DownloadsSummaryData instance. + */ + *_downloadsForSummary() { + if (this._downloads.length) { + for (let i = this._numToExclude; i < this._downloads.length; ++i) { + yield this._downloads[i]; + } + } + }, + + /** + * Computes aggregate values based on the current state of downloads. + */ + _refreshProperties() { + // Pre-load summary with default values. + let summary = DownloadsCommon.summarizeDownloads( + this._downloadsForSummary() + ); + + // Run sync to update view right away and get correct description. + // See refreshView for more details. + this._description = kDownloadsFluentStrings.formatValueSync( + "downloads-more-downloading", + { + count: summary.numDownloading, + } + ); + this._percentComplete = summary.percentComplete; + + // Only show the downloading items. + this._showingProgress = summary.numDownloading > 0; + + // Display the estimated time left, if present. + if (summary.rawTimeLeft == -1) { + // There are no downloads with a known time left. + this._lastRawTimeLeft = -1; + this._lastTimeLeft = -1; + this._details = ""; + } else { + // Compute the new time left only if state actually changed. + if (this._lastRawTimeLeft != summary.rawTimeLeft) { + this._lastRawTimeLeft = summary.rawTimeLeft; + this._lastTimeLeft = DownloadsCommon.smoothSeconds( + summary.rawTimeLeft, + this._lastTimeLeft + ); + } + [this._details] = lazy.DownloadUtils.getDownloadStatusNoRate( + summary.totalTransferred, + summary.totalSize, + summary.slowestSpeed, + this._lastTimeLeft + ); + } + }, +}; +Object.setPrototypeOf(DownloadsSummaryData.prototype, DownloadsViewPrototype); diff --git a/browser/components/downloads/DownloadsMacFinderProgress.sys.mjs b/browser/components/downloads/DownloadsMacFinderProgress.sys.mjs new file mode 100644 index 0000000000..64e1dc4b8d --- /dev/null +++ b/browser/components/downloads/DownloadsMacFinderProgress.sys.mjs @@ -0,0 +1,84 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */ +/* 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/. */ + +/** + * Handles the download progress indicator of the macOS Finder. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Downloads: "resource://gre/modules/Downloads.sys.mjs", +}); + +export var DownloadsMacFinderProgress = { + /** + * Maps the path of the download, to the according progress indicator instance. + */ + _finderProgresses: null, + + /** + * This method is called after a new browser window on macOS is opened, it + * registers for receiving download events for the progressbar of the Finder. + */ + register() { + // Ensure to register only once per process and not for every window. + if (!this._finderProgresses) { + this._finderProgresses = new Map(); + lazy.Downloads.getList(lazy.Downloads.ALL).then(list => + list.addView(this) + ); + } + }, + + onDownloadAdded(download) { + if (download.stopped) { + return; + } + + let finderProgress = Cc[ + "@mozilla.org/widget/macfinderprogress;1" + ].createInstance(Ci.nsIMacFinderProgress); + + let path = download.target.path; + + finderProgress.init(path, () => { + download.cancel().catch(console.error); + download.removePartialData().catch(console.error); + }); + + if (download.hasProgress) { + finderProgress.updateProgress(download.currentBytes, download.totalBytes); + } else { + finderProgress.updateProgress(0, 0); + } + this._finderProgresses.set(path, finderProgress); + }, + + onDownloadChanged(download) { + let path = download.target.path; + let finderProgress = this._finderProgresses.get(path); + if (!finderProgress) { + // The download is not tracked, it may have been restarted, + // thus forward the call to onDownloadAdded to check if it should be tracked. + this.onDownloadAdded(download); + } else if (download.stopped) { + finderProgress.end(); + this._finderProgresses.delete(path); + } else { + finderProgress.updateProgress(download.currentBytes, download.totalBytes); + } + }, + + onDownloadRemoved(download) { + let path = download.target.path; + let finderProgress = this._finderProgresses.get(path); + if (finderProgress) { + finderProgress.end(); + this._finderProgresses.delete(path); + } + }, +}; diff --git a/browser/components/downloads/DownloadsTaskbar.sys.mjs b/browser/components/downloads/DownloadsTaskbar.sys.mjs new file mode 100644 index 0000000000..c3fa349531 --- /dev/null +++ b/browser/components/downloads/DownloadsTaskbar.sys.mjs @@ -0,0 +1,215 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */ +/* 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/. */ + +/** + * Handles the download progress indicator in the taskbar. + */ + +// Globals + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", + Downloads: "resource://gre/modules/Downloads.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "gWinTaskbar", function () { + if (!("@mozilla.org/windows-taskbar;1" in Cc)) { + return null; + } + let winTaskbar = Cc["@mozilla.org/windows-taskbar;1"].getService( + Ci.nsIWinTaskbar + ); + return winTaskbar.available && winTaskbar; +}); + +ChromeUtils.defineLazyGetter(lazy, "gMacTaskbarProgress", function () { + return ( + "@mozilla.org/widget/macdocksupport;1" in Cc && + Cc["@mozilla.org/widget/macdocksupport;1"].getService(Ci.nsITaskbarProgress) + ); +}); + +ChromeUtils.defineLazyGetter(lazy, "gGtkTaskbarProgress", function () { + return ( + "@mozilla.org/widget/taskbarprogress/gtk;1" in Cc && + Cc["@mozilla.org/widget/taskbarprogress/gtk;1"].getService( + Ci.nsIGtkTaskbarProgress + ) + ); +}); + +// DownloadsTaskbar + +/** + * Handles the download progress indicator in the taskbar. + */ +export var DownloadsTaskbar = { + /** + * Underlying DownloadSummary providing the aggregate download information, or + * null if the indicator has never been initialized. + */ + _summary: null, + + /** + * nsITaskbarProgress object to which download information is dispatched. + * This can be null if the indicator has never been initialized or if the + * indicator is currently hidden on Windows. + */ + _taskbarProgress: null, + + /** + * This method is called after a new browser window is opened, and ensures + * that the download progress indicator is displayed in the taskbar. + * + * On Windows, the indicator is attached to the first browser window that + * calls this method. When the window is closed, the indicator is moved to + * another browser window, if available, in no particular order. When there + * are no browser windows visible, the indicator is hidden. + * + * On Mac OS X, the indicator is initialized globally when this method is + * called for the first time. Subsequent calls have no effect. + * + * @param aBrowserWindow + * nsIDOMWindow object of the newly opened browser window to which the + * indicator may be attached. + */ + registerIndicator(aBrowserWindow) { + if (!this._taskbarProgress) { + if (lazy.gMacTaskbarProgress) { + // On Mac OS X, we have to register the global indicator only once. + this._taskbarProgress = lazy.gMacTaskbarProgress; + // Free the XPCOM reference on shutdown, to prevent detecting a leak. + Services.obs.addObserver(() => { + this._taskbarProgress = null; + lazy.gMacTaskbarProgress = null; + }, "quit-application-granted"); + } else if (lazy.gWinTaskbar) { + // On Windows, the indicator is currently hidden because we have no + // previous browser window, thus we should attach the indicator now. + this._attachIndicator(aBrowserWindow); + } else if (lazy.gGtkTaskbarProgress) { + this._taskbarProgress = lazy.gGtkTaskbarProgress; + + this._attachGtkTaskbarProgress(aBrowserWindow); + } else { + // The taskbar indicator is not available on this platform. + return; + } + } + + // Ensure that the DownloadSummary object will be created asynchronously. + if (!this._summary) { + lazy.Downloads.getSummary(lazy.Downloads.ALL) + .then(summary => { + // In case the method is re-entered, we simply ignore redundant + // invocations of the callback, instead of keeping separate state. + if (this._summary) { + return undefined; + } + this._summary = summary; + return this._summary.addView(this); + }) + .catch(console.error); + } + }, + + /** + * On Windows, attaches the taskbar indicator to the specified browser window. + */ + _attachIndicator(aWindow) { + // Activate the indicator on the specified window. + let { docShell } = aWindow.browsingContext.topChromeWindow; + this._taskbarProgress = lazy.gWinTaskbar.getTaskbarProgress(docShell); + + // If the DownloadSummary object has already been created, we should update + // the state of the new indicator, otherwise it will be updated as soon as + // the DownloadSummary view is registered. + if (this._summary) { + this.onSummaryChanged(); + } + + aWindow.addEventListener("unload", () => { + // Locate another browser window, excluding the one being closed. + let browserWindow = lazy.BrowserWindowTracker.getTopWindow(); + if (browserWindow) { + // Move the progress indicator to the other browser window. + this._attachIndicator(browserWindow); + } else { + // The last browser window has been closed. We remove the reference to + // the taskbar progress object so that the indicator will be registered + // again on the next browser window that is opened. + this._taskbarProgress = null; + } + }); + }, + + /** + * In gtk3, the window itself implements the progress interface. + */ + _attachGtkTaskbarProgress(aWindow) { + // Set the current window. + this._taskbarProgress.setPrimaryWindow(aWindow); + + // If the DownloadSummary object has already been created, we should update + // the state of the new indicator, otherwise it will be updated as soon as + // the DownloadSummary view is registered. + if (this._summary) { + this.onSummaryChanged(); + } + + aWindow.addEventListener("unload", () => { + // Locate another browser window, excluding the one being closed. + let browserWindow = lazy.BrowserWindowTracker.getTopWindow(); + if (browserWindow) { + // Move the progress indicator to the other browser window. + this._attachGtkTaskbarProgress(browserWindow); + } else { + // The last browser window has been closed. We remove the reference to + // the taskbar progress object so that the indicator will be registered + // again on the next browser window that is opened. + this._taskbarProgress = null; + } + }); + }, + + // DownloadSummary view + + onSummaryChanged() { + // If the last browser window has been closed, we have no indicator any more. + if (!this._taskbarProgress) { + return; + } + + if (this._summary.allHaveStopped || this._summary.progressTotalBytes == 0) { + this._taskbarProgress.setProgressState( + Ci.nsITaskbarProgress.STATE_NO_PROGRESS, + 0, + 0 + ); + } else if (this._summary.allUnknownSize) { + this._taskbarProgress.setProgressState( + Ci.nsITaskbarProgress.STATE_INDETERMINATE, + 0, + 0 + ); + } else { + // For a brief moment before completion, some download components may + // report more transferred bytes than the total number of bytes. Thus, + // ensure that we never break the expectations of the progress indicator. + let progressCurrentBytes = Math.min( + this._summary.progressTotalBytes, + this._summary.progressCurrentBytes + ); + this._taskbarProgress.setProgressState( + Ci.nsITaskbarProgress.STATE_NORMAL, + progressCurrentBytes, + this._summary.progressTotalBytes + ); + } + }, +}; diff --git a/browser/components/downloads/DownloadsViewUI.sys.mjs b/browser/components/downloads/DownloadsViewUI.sys.mjs new file mode 100644 index 0000000000..9c6bd17d63 --- /dev/null +++ b/browser/components/downloads/DownloadsViewUI.sys.mjs @@ -0,0 +1,1198 @@ +/* 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 is imported by code that uses the "download.xml" binding, and + * provides prototypes for objects that handle input and display information. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", + DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs", + Downloads: "resource://gre/modules/Downloads.sys.mjs", + DownloadsCommon: "resource:///modules/DownloadsCommon.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "handlerSvc", + "@mozilla.org/uriloader/handler-service;1", + "nsIHandlerService" +); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gReputationService", + "@mozilla.org/reputationservice/application-reputation-service;1", + Ci.nsIApplicationReputationService +); + +import { Integration } from "resource://gre/modules/Integration.sys.mjs"; + +Integration.downloads.defineESModuleGetter( + lazy, + "DownloadIntegration", + "resource://gre/modules/DownloadIntegration.sys.mjs" +); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; + +var gDownloadElementButtons = { + cancel: { + commandName: "downloadsCmd_cancel", + l10nId: "downloads-cmd-cancel", + descriptionL10nId: "downloads-cancel-download", + panelL10nId: "downloads-cmd-cancel-panel", + iconClass: "downloadIconCancel", + }, + retry: { + commandName: "downloadsCmd_retry", + l10nId: "downloads-cmd-retry", + descriptionL10nId: "downloads-retry-download", + panelL10nId: "downloads-cmd-retry-panel", + iconClass: "downloadIconRetry", + }, + show: { + commandName: "downloadsCmd_show", + l10nId: "downloads-cmd-show-button-2", + descriptionL10nId: "downloads-cmd-show-description-2", + panelL10nId: "downloads-cmd-show-panel-2", + iconClass: "downloadIconShow", + }, + subviewOpenOrRemoveFile: { + commandName: "downloadsCmd_showBlockedInfo", + l10nId: "downloads-cmd-choose-open", + descriptionL10nId: "downloads-show-more-information", + panelL10nId: "downloads-cmd-choose-open-panel", + iconClass: "downloadIconSubviewArrow", + }, + askOpenOrRemoveFile: { + commandName: "downloadsCmd_chooseOpen", + l10nId: "downloads-cmd-choose-open", + panelL10nId: "downloads-cmd-choose-open-panel", + iconClass: "downloadIconShow", + }, + askRemoveFileOrAllow: { + commandName: "downloadsCmd_chooseUnblock", + l10nId: "downloads-cmd-choose-unblock", + panelL10nId: "downloads-cmd-choose-unblock-panel", + iconClass: "downloadIconShow", + }, + removeFile: { + commandName: "downloadsCmd_confirmBlock", + l10nId: "downloads-cmd-remove-file", + panelL10nId: "downloads-cmd-remove-file-panel", + iconClass: "downloadIconCancel", + }, +}; + +/** + * Associates each document with a pre-built DOM fragment representing the + * download list item. This is then cloned to create each individual list item. + * This is stored on the document to prevent leaks that would occur if a single + * instance created by one document's DOMParser was stored globally. + */ +var gDownloadListItemFragments = new WeakMap(); + +export var DownloadsViewUI = { + /** + * Returns true if the given string is the name of a command that can be + * handled by the Downloads user interface, including standard commands. + */ + isCommandName(name) { + return name.startsWith("cmd_") || name.startsWith("downloadsCmd_"); + }, + + /** + * Get source url of the download without'http' or'https' prefix. + */ + getStrippedUrl(download) { + return lazy.UrlbarUtils.stripPrefixAndTrim(download?.source?.url, { + stripHttp: true, + stripHttps: true, + })[0]; + }, + + /** + * Returns the user-facing label for the given Download object. This is + * normally the leaf name of the download target file. In case this is a very + * old history download for which the target file is unknown, the download + * source URI is displayed. + */ + getDisplayName(download) { + if ( + download.error?.reputationCheckVerdict == + lazy.Downloads.Error.BLOCK_VERDICT_DOWNLOAD_SPAM + ) { + let l10n = { + id: "downloads-blocked-from-url", + args: { url: DownloadsViewUI.getStrippedUrl(download) }, + }; + return { l10n }; + } + return download.target.path + ? PathUtils.filename(download.target.path) + : download.source.url; + }, + + /** + * Given a Download object, returns a string representing its file size with + * an appropriate measurement unit, for example "1.5 MB", or an empty string + * if the size is unknown. + */ + getSizeWithUnits(download) { + if (download.target.size === undefined) { + return ""; + } + + let [size, unit] = lazy.DownloadUtils.convertByteUnits( + download.target.size + ); + return lazy.DownloadsCommon.strings.sizeWithUnits(size, unit); + }, + + /** + * Given a context menu and a download element on which it is invoked, + * update items in the context menu to reflect available options for + * that download element. + */ + updateContextMenuForElement(contextMenu, element) { + // Get the state and ensure only the appropriate items are displayed. + let state = parseInt(element.getAttribute("state"), 10); + + const document = contextMenu.ownerDocument; + + const { + DOWNLOAD_NOTSTARTED, + DOWNLOAD_DOWNLOADING, + DOWNLOAD_FINISHED, + DOWNLOAD_FAILED, + DOWNLOAD_CANCELED, + DOWNLOAD_PAUSED, + DOWNLOAD_BLOCKED_PARENTAL, + DOWNLOAD_DIRTY, + DOWNLOAD_BLOCKED_POLICY, + } = lazy.DownloadsCommon; + + contextMenu.querySelector(".downloadPauseMenuItem").hidden = + state != DOWNLOAD_DOWNLOADING; + + contextMenu.querySelector(".downloadResumeMenuItem").hidden = + state != DOWNLOAD_PAUSED; + + // Only show "unblock" for blocked (dirty) items that have not been + // confirmed and have temporary data: + contextMenu.querySelector(".downloadUnblockMenuItem").hidden = + state != DOWNLOAD_DIRTY || !element.classList.contains("temporary-block"); + + // Can only remove finished/failed/canceled/blocked downloads. + contextMenu.querySelector(".downloadRemoveFromHistoryMenuItem").hidden = ![ + DOWNLOAD_FINISHED, + DOWNLOAD_FAILED, + DOWNLOAD_CANCELED, + DOWNLOAD_BLOCKED_PARENTAL, + DOWNLOAD_DIRTY, + DOWNLOAD_BLOCKED_POLICY, + ].includes(state); + + // Can reveal downloads with data on the file system using the relevant OS + // tool (Explorer, Finder, appropriate Linux file system viewer): + contextMenu.querySelector(".downloadShowMenuItem").hidden = + ![ + DOWNLOAD_NOTSTARTED, + DOWNLOAD_DOWNLOADING, + DOWNLOAD_FINISHED, + DOWNLOAD_PAUSED, + ].includes(state) || + (state == DOWNLOAD_FINISHED && !element.hasAttribute("exists")); + + // Show the separator if we're showing either unblock or reveal menu items. + contextMenu.querySelector(".downloadCommandsSeparator").hidden = + contextMenu.querySelector(".downloadUnblockMenuItem").hidden && + contextMenu.querySelector(".downloadShowMenuItem").hidden; + + let download = element._shell.download; + let mimeInfo = lazy.DownloadsCommon.getMimeInfo(download); + let { preferredAction, useSystemDefault, defaultDescription } = mimeInfo + ? mimeInfo + : {}; + + // Hide the "Delete" item if there's no file data to delete. + contextMenu.querySelector(".downloadDeleteFileMenuItem").hidden = + download.deleted || + !(download.target?.exists || download.target?.partFileExists); + + // Hide the "Go To Download Page" item if there's no referrer. Ideally the + // Downloads API will require a referrer (see bug 1723712) to create a + // download, but this fallback will ensure any failures aren't user facing. + contextMenu.querySelector(".downloadOpenReferrerMenuItem").hidden = + !download.source.referrerInfo?.originalReferrer; + + // Hide the "use system viewer" and "always use system viewer" items + // if the feature is disabled or this download doesn't support it: + let useSystemViewerItem = contextMenu.querySelector( + ".downloadUseSystemDefaultMenuItem" + ); + let alwaysUseSystemViewerItem = contextMenu.querySelector( + ".downloadAlwaysUseSystemDefaultMenuItem" + ); + let canViewInternally = element.hasAttribute("viewable-internally"); + useSystemViewerItem.hidden = + !lazy.DownloadsCommon.openInSystemViewerItemEnabled || + !canViewInternally || + !download.target?.exists; + + alwaysUseSystemViewerItem.hidden = + !lazy.DownloadsCommon.alwaysOpenInSystemViewerItemEnabled || + !canViewInternally; + + // Set menuitem labels to display the system viewer's name. Stop the l10n + // mutation observer temporarily since we're going to synchronously + // translate the elements to avoid translation delay. See bug 1737951 & bug + // 1746748. This can be simplified when they're resolved. + try { + document.l10n.pauseObserving(); + // Handler descriptions longer than 40 characters will be skipped to avoid + // unreasonably stretching the context menu. + if (defaultDescription && defaultDescription.length < 40) { + document.l10n.setAttributes( + useSystemViewerItem, + "downloads-cmd-use-system-default-named", + { handler: defaultDescription } + ); + document.l10n.setAttributes( + alwaysUseSystemViewerItem, + "downloads-cmd-always-use-system-default-named", + { handler: defaultDescription } + ); + } else { + // In the unlikely event that defaultDescription is somehow missing/invalid, + // fall back to the static "Open In System Viewer" label. + document.l10n.setAttributes( + useSystemViewerItem, + "downloads-cmd-use-system-default" + ); + document.l10n.setAttributes( + alwaysUseSystemViewerItem, + "downloads-cmd-always-use-system-default" + ); + } + } finally { + document.l10n.resumeObserving(); + } + document.l10n.translateElements([ + useSystemViewerItem, + alwaysUseSystemViewerItem, + ]); + + // If non default mime-type or cannot be opened internally, display + // "always open similar files" item instead so that users can add a new + // mimetype to about:preferences table and set to open with system default. + let alwaysOpenSimilarFilesItem = contextMenu.querySelector( + ".downloadAlwaysOpenSimilarFilesMenuItem" + ); + + /** + * In HelperAppDlg.sys.mjs, we determine whether or not an "always open..." checkbox + * should appear in the unknownContentType window. Here, we use similar checks to + * determine if we should show the "always open similar files" context menu item. + * + * Note that we also read the content type using mimeInfo to detect better and available + * mime types, given a file extension. Some sites default to "application/octet-stream", + * further limiting what file types can be added to about:preferences, even for file types + * that are in fact capable of being handled with a default application. + * + * There are also cases where download.contentType is undefined (ex. when opening + * the context menu on a previously downloaded item via download history). + * Using mimeInfo ensures that content type exists and prevents intermittence. + */ + // + let filename = PathUtils.filename(download.target.path); + + let isExemptExecutableExtension = + Services.policies.isExemptExecutableExtension( + download.source.originalUrl || download.source.url, + filename?.split(".").at(-1) + ); + + let shouldNotRememberChoice = + !mimeInfo?.type || + mimeInfo.type === "application/octet-stream" || + mimeInfo.type === "application/x-msdownload" || + mimeInfo.type === "application/x-msdos-program" || + (lazy.gReputationService.isExecutable(filename) && + !isExemptExecutableExtension) || + (mimeInfo.type === "text/plain" && + lazy.gReputationService.isBinary(download.target.path)); + + alwaysOpenSimilarFilesItem.hidden = + canViewInternally || + state !== DOWNLOAD_FINISHED || + shouldNotRememberChoice; + + // Update checkbox for "always open..." options. + if (preferredAction === useSystemDefault) { + alwaysUseSystemViewerItem.setAttribute("checked", "true"); + alwaysOpenSimilarFilesItem.setAttribute("checked", "true"); + } else { + alwaysUseSystemViewerItem.removeAttribute("checked"); + alwaysOpenSimilarFilesItem.removeAttribute("checked"); + } + }, +}; + +XPCOMUtils.defineLazyPreferenceGetter( + DownloadsViewUI, + "clearHistoryOnDelete", + "browser.download.clearHistoryOnDelete", + 0 +); + +DownloadsViewUI.BaseView = class { + canClearDownloads(nodeContainer) { + // Downloads can be cleared if there's at least one removable download in + // the list (either a history download or a completed session download). + // Because history downloads are always removable and are listed after the + // session downloads, check from bottom to top. + for (let elt = nodeContainer.lastChild; elt; elt = elt.previousSibling) { + // Stopped, paused, and failed downloads with partial data are removed. + let download = elt._shell.download; + if (download.stopped && !(download.canceled && download.hasPartialData)) { + return true; + } + } + return false; + } +}; + +/** + * A download element shell is responsible for handling the commands and the + * displayed data for a single element that uses the "download.xml" binding. + * + * The information to display is obtained through the associated Download object + * from the JavaScript API for downloads, and commands are executed using a + * combination of Download methods and DownloadsCommon.sys.mjs helper functions. + * + * Specialized versions of this shell must be defined, and they are required to + * implement the "download" property or getter. Currently these objects are the + * HistoryDownloadElementShell and the DownloadsViewItem for the panel. The + * history view may use a HistoryDownload object in place of a Download object. + */ +DownloadsViewUI.DownloadElementShell = function () {}; + +DownloadsViewUI.DownloadElementShell.prototype = { + /** + * The richlistitem for the download, initialized by the derived object. + */ + element: null, + + /** + * Manages the "active" state of the shell. By default all the shells are + * inactive, thus their UI is not updated. They must be activated when + * entering the visible area. + */ + ensureActive() { + if (!this._active) { + this._active = true; + this.connect(); + this.onChanged(); + } + }, + get active() { + return !!this._active; + }, + + connect() { + let document = this.element.ownerDocument; + let downloadListItemFragment = gDownloadListItemFragments.get(document); + // When changing the markup within the fragment, please ensure that + // the functions within DownloadsView still operate correctly. + if (!downloadListItemFragment) { + let MozXULElement = document.defaultView.MozXULElement; + downloadListItemFragment = MozXULElement.parseXULToFragment(` + + + + + + + + + + + `, + }, + background, + }); + + let extensionTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + null, + true + ); + await extension.startup(); + // Must wait for the tab to have loaded completely before calling openContextMenu. + await extensionTabPromise; + await openContextMenu("button"); + await extension.awaitMessage("elementChecked"); + await closeContextMenu(); + + // Unloading the extension will automatically close the extension's tab.html + await extension.unload(); +}); + +add_task(async function getTargetElement_in_extension_tab_on_click() { + // Similar to getTargetElement_in_extension_tab, except we check whether + // calling getTargetElement in onClicked results in the expected behavior. + async function background() { + browser.menus.onClicked.addListener(info => { + let [tabGlobal] = browser.extension.getViews({ type: "tab" }); + let elem = tabGlobal.browser.menus.getTargetElement(info.targetElementId); + browser.test.assertEq( + "BUTTON", + elem.tagName, + "should get element in tab content on click" + ); + browser.test.sendMessage("elementClicked"); + }); + + browser.menus.create({ title: "click here" }, () => { + browser.tabs.create({ url: "tab.html" }); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + files: { + "tab.html": ``, + }, + background, + }); + + let extensionTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + null, + true + ); + await extension.startup(); + await extensionTabPromise; + let menu = await openContextMenu("button"); + let menuItem = menu.getElementsByAttribute("label", "click here")[0]; + await closeExtensionContextMenu(menuItem); + await extension.awaitMessage("elementClicked"); + + await extension.unload(); +}); + +add_task(async function getTargetElement_in_browserAction_popup() { + async function background() { + browser.menus.onShown.addListener(info => { + let elem = browser.menus.getTargetElement(info.targetElementId); + browser.test.assertEq( + null, + elem, + "should not get element of popup content in background" + ); + + let [popupGlobal] = browser.extension.getViews({ type: "popup" }); + elem = popupGlobal.browser.menus.getTargetElement(info.targetElementId); + browser.test.assertEq( + "BUTTON", + elem.tagName, + "should get element in popup content" + ); + browser.test.sendMessage("popupChecked"); + }); + + // Ensure that onShown is registered (workaround for bug 1300234): + await browser.menus.removeAll(); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + }, + }, + files: { + "popup.html": ``, + }, + background, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + await clickBrowserAction(extension); + await openContextMenuInPopup(extension, "button"); + await extension.awaitMessage("popupChecked"); + await closeContextMenu(); + await closeBrowserAction(extension); + + await extension.unload(); +}); + +add_task(async function getTargetElement_in_sidebar_panel() { + async function sidebarJs() { + browser.menus.onShown.addListener(info => { + let expected = document.querySelector("button"); + let elem = browser.menus.getTargetElement(info.targetElementId); + browser.test.assertEq( + expected, + elem, + "should get element in sidebar content" + ); + browser.test.sendMessage("done"); + }); + + // Ensure that onShown is registered (workaround for bug 1300234): + await browser.menus.removeAll(); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", // To automatically show sidebar on load. + manifest: { + permissions: ["menus"], + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + files: { + "sidebar.html": ` + + + + `, + "sidebar.js": sidebarJs, + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + let sidebarMenu = await openContextMenuInSidebar("button"); + await extension.awaitMessage("done"); + await closeContextMenu(sidebarMenu); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_targetElement_shadow.js b/browser/components/extensions/test/browser/browser_ext_menus_targetElement_shadow.js new file mode 100644 index 0000000000..115f4fe96a --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_targetElement_shadow.js @@ -0,0 +1,108 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const PAGE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html"; + +add_task(async function menuInShadowDOM() { + Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("security.allow_eval_with_system_principal"); + }); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + gBrowser.selectedTab = tab; + + async function background() { + browser.menus.onShown.addListener(async (info, tab) => { + browser.test.assertTrue( + Number.isInteger(info.targetElementId), + `${info.targetElementId} should be an integer` + ); + browser.test.assertEq( + "all,link", + info.contexts.sort().join(","), + "Expected context" + ); + browser.test.assertEq( + "http://example.com/?shadowlink", + info.linkUrl, + "Menu target should be a link in the shadow DOM" + ); + + let code = `{ + try { + let elem = browser.menus.getTargetElement(${info.targetElementId}); + browser.test.assertTrue(elem, "Shadow element must be found"); + browser.test.assertEq("http://example.com/?shadowlink", elem.href, "Element is a link in shadow DOM " - elem.outerHTML); + } catch (e) { + browser.test.fail("Unexpected error in getTargetElement: " + e); + } + }`; + await browser.tabs.executeScript(tab.id, { code }); + browser.test.sendMessage( + "onShownMenuAndCheckedInfo", + info.targetElementId + ); + }); + + // Ensure that onShown is registered (workaround for bug 1300234): + await browser.menus.removeAll(); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus", "http://mochi.test/*"], + }, + background, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + async function testShadowMenu(setupMenuTarget) { + await openContextMenu(setupMenuTarget); + await extension.awaitMessage("onShownMenuAndCheckedInfo"); + await closeContextMenu(); + } + + info("Clicking in open shadow root"); + await testShadowMenu(() => { + let doc = this.document; + doc.body.innerHTML = `
    `; + let host = doc.body.firstElementChild.attachShadow({ mode: "open" }); + host.innerHTML = `Test open`; + this.document.testTarget = host.firstElementChild; + return this.document.testTarget; + }); + + info("Clicking in closed shadow root"); + await testShadowMenu(() => { + let doc = this.document; + doc.body.innerHTML = `
    `; + let host = doc.body.firstElementChild.attachShadow({ mode: "closed" }); + host.innerHTML = `Test closed`; + this.document.testTarget = host.firstElementChild; + return this.document.testTarget; + }); + + info("Clicking in nested shadow DOM"); + await testShadowMenu(() => { + let doc = this.document; + let host; + for (let container = doc.body, i = 0; i < 10; ++i) { + container.innerHTML = `
    `; + host = container.firstElementChild.attachShadow({ mode: "open" }); + container = host; + } + host.innerHTML = `Test nested`; + this.document.testTarget = host.firstElementChild; + return this.document.testTarget; + }); + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_viewType.js b/browser/components/extensions/test/browser/browser_ext_menus_viewType.js new file mode 100644 index 0000000000..bb59e0fffd --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_viewType.js @@ -0,0 +1,122 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +// browser_ext_menus_events.js provides some coverage for viewTypes in normal +// tabs and extension popups. +// This test provides coverage for extension tabs and sidebars, as well as +// using the viewTypes property in menus.create and menus.update. + +add_task(async function extension_tab_viewType() { + async function background() { + browser.menus.onShown.addListener(info => { + browser.test.assertEq( + "tabonly", + info.menuIds.join(","), + "Expected menu items" + ); + browser.test.sendMessage("shown"); + }); + browser.menus.onClicked.addListener(info => { + browser.test.assertEq("tab", info.viewType, "Expected viewType"); + browser.test.sendMessage("clicked"); + }); + + browser.menus.create({ + id: "sidebaronly", + title: "sidebar-only", + viewTypes: ["sidebar"], + }); + browser.menus.create( + { id: "tabonly", title: "click here", viewTypes: ["tab"] }, + () => { + browser.tabs.create({ url: "tab.html" }); + } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + files: { + "tab.html": ``, + "tab.js": `browser.test.sendMessage("ready");`, + }, + background, + }); + + let extensionTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + null, + true + ); + await extension.startup(); + await extension.awaitMessage("ready"); + await extensionTabPromise; + let menu = await openContextMenu(); + await extension.awaitMessage("shown"); + + let menuItem = menu.getElementsByAttribute("label", "click here")[0]; + await closeExtensionContextMenu(menuItem); + await extension.awaitMessage("clicked"); + + // Unloading the extension will automatically close the extension's tab.html + await extension.unload(); +}); + +add_task(async function sidebar_panel_viewType() { + async function sidebarJs() { + browser.menus.onShown.addListener(info => { + browser.test.assertEq( + "sidebaronly", + info.menuIds.join(","), + "Expected menu items" + ); + browser.test.assertEq("sidebar", info.viewType, "Expected viewType"); + browser.test.sendMessage("shown"); + }); + + // Create menus and change their viewTypes using menus.update. + browser.menus.create({ + id: "sidebaronly", + title: "sidebaronly", + viewTypes: ["tab"], + }); + browser.menus.create({ + id: "tabonly", + title: "tabonly", + viewTypes: ["sidebar"], + }); + await browser.menus.update("sidebaronly", { viewTypes: ["sidebar"] }); + await browser.menus.update("tabonly", { viewTypes: ["tab"] }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", // To automatically show sidebar on load. + manifest: { + permissions: ["menus"], + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + files: { + "sidebar.html": ` + + + `, + "sidebar.js": sidebarJs, + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + let sidebarMenu = await openContextMenuInSidebar(); + await extension.awaitMessage("shown"); + await closeContextMenu(sidebarMenu); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_visible.js b/browser/components/extensions/test/browser/browser_ext_menus_visible.js new file mode 100644 index 0000000000..cf9718fddc --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_visible.js @@ -0,0 +1,95 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const PAGE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html"; + +add_task(async function visible_false() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + async function background() { + browser.menus.onShown.addListener(info => { + browser.test.assertEq( + "[]", + JSON.stringify(info.menuIds), + "Expected no menu items" + ); + browser.test.sendMessage("done"); + }); + browser.menus.create({ + id: "create-visible-false", + title: "invisible menu item", + visible: false, + }); + browser.menus.create({ + id: "update-without-params", + title: "invisible menu item", + visible: false, + }); + await browser.menus.update("update-without-params", {}); + browser.menus.create({ + id: "update-visible-to-false", + title: "initially visible menu item", + }); + await browser.menus.update("update-visible-to-false", { visible: false }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + background, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + await openContextMenu(); + await extension.awaitMessage("done"); + await closeContextMenu(); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function visible_true() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + async function background() { + browser.menus.onShown.addListener(info => { + browser.test.assertEq( + `["update-to-true"]`, + JSON.stringify(info.menuIds), + "Expected no menu items" + ); + browser.test.sendMessage("done"); + }); + browser.menus.create({ + id: "update-to-true", + title: "invisible menu item", + visible: false, + }); + await browser.menus.update("update-to-true", { visible: true }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + background, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + await openContextMenu(); + await extension.awaitMessage("done"); + await closeContextMenu(); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_mousewheel_zoom.js b/browser/components/extensions/test/browser/browser_ext_mousewheel_zoom.js new file mode 100644 index 0000000000..d558400a7e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_mousewheel_zoom.js @@ -0,0 +1,186 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Extensions can be loaded in 3 ways: as a sidebar, as a browser action, +// or as a page action. We use these constants to alter the extension +// manifest, content script, background script, and setup conventions. +const TESTS = { + SIDEBAR: "sidebar", + BROWSER_ACTION: "browserAction", + PAGE_ACTION: "pageAction", +}; + +function promiseBrowserReflow(browser) { + return SpecialPowers.spawn(browser, [], async function () { + return new Promise(resolve => { + content.window.requestAnimationFrame(() => { + content.window.requestAnimationFrame(resolve); + }); + }); + }); +} + +async function promiseBrowserZoom(browser, extension) { + await promiseBrowserReflow(browser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "html", + { type: "mousedown", button: 0 }, + browser + ); + return extension.awaitMessage("zoom"); +} + +async function test_mousewheel_zoom(test) { + info(`Starting test of ${test} extension.`); + let browser; + + // Scroll on Ctrl + mousewheel + SpecialPowers.pushPrefEnv({ set: [["mousewheel.with_control.action", 3]] }); + + function contentScript() { + // eslint-disable-next-line mozilla/balanced-listeners + document.addEventListener("mousedown", e => { + // Send the zoom level back as a "zoom" message. + const zoom = SpecialPowers.getFullZoom(window).toFixed(2); + browser.test.sendMessage("zoom", zoom); + }); + } + + function sidebarContentScript() { + // eslint-disable-next-line mozilla/balanced-listeners + document.addEventListener("mousedown", e => { + // Send the zoom level back as a "zoom" message. + const zoom = SpecialPowers.getFullZoom(window).toFixed(2); + browser.test.sendMessage("zoom", zoom); + }); + browser.test.sendMessage("content-loaded"); + } + + function pageActionBackgroundScript() { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + const tabId = tabs[0].id; + + browser.pageAction.show(tabId).then(() => { + browser.test.sendMessage("content-loaded"); + }); + }); + } + + let manifest; + if (test == TESTS.SIDEBAR) { + manifest = { + sidebar_action: { + default_panel: "panel.html", + }, + }; + } else if (test == TESTS.BROWSER_ACTION) { + manifest = { + browser_action: { + default_popup: "panel.html", + default_area: "navbar", + }, + }; + } else if (test == TESTS.PAGE_ACTION) { + manifest = { + page_action: { + default_popup: "panel.html", + }, + }; + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest, + useAddonManager: "temporary", + files: { + "panel.html": ` + + + + + + + +

    Please Zoom Me

    + + + `, + "panel.js": test == TESTS.SIDEBAR ? sidebarContentScript : contentScript, + }, + background: + test == TESTS.PAGE_ACTION ? pageActionBackgroundScript : undefined, + }); + + await extension.startup(); + info("Awaiting notification that extension has loaded."); + + if (test == TESTS.SIDEBAR) { + await extension.awaitMessage("content-loaded"); + + const sidebar = document.getElementById("sidebar-box"); + ok(!sidebar.hidden, "Sidebar box is visible"); + + browser = SidebarUI.browser.contentWindow.gBrowser.selectedBrowser; + } else if (test == TESTS.BROWSER_ACTION) { + browser = await openBrowserActionPanel(extension, undefined, true); + } else if (test == TESTS.PAGE_ACTION) { + await extension.awaitMessage("content-loaded"); + + clickPageAction(extension, window); + + browser = await awaitExtensionPanel(extension); + } + + info(`Requesting initial zoom from ${test} extension.`); + let initialZoom = await promiseBrowserZoom(browser, extension); + info(`Extension (${test}) initial zoom is ${initialZoom}.`); + + // Attempt to change the zoom of the extension with a mousewheel event. + await BrowserTestUtils.synthesizeMouseAtCenter( + "html", + { + wheel: true, + ctrlKey: true, + deltaY: -1, + deltaMode: WheelEvent.DOM_DELTA_LINE, + }, + browser + ); + + info(`Requesting changed zoom from ${test} extension.`); + let changedZoom = await promiseBrowserZoom(browser, extension); + info(`Extension (${test}) changed zoom is ${changedZoom}.`); + isnot( + changedZoom, + initialZoom, + `Extension (${test}) zoom was changed as expected.` + ); + + // Attempt to restore the zoom of the extension with a mousewheel event. + await BrowserTestUtils.synthesizeMouseAtCenter( + "html", + { + wheel: true, + ctrlKey: true, + deltaY: 1, + deltaMode: WheelEvent.DOM_DELTA_LINE, + }, + browser + ); + + info(`Requesting changed zoom from ${test} extension.`); + let finalZoom = await promiseBrowserZoom(browser, extension); + is( + finalZoom, + initialZoom, + `Extension (${test}) zoom was restored as expected.` + ); + + await extension.unload(); +} + +// Actually trigger the tests. Bind test_mousewheel_zoom each time so we +// capture the test type. +for (const t in TESTS) { + add_task(test_mousewheel_zoom.bind(this, TESTS[t])); +} diff --git a/browser/components/extensions/test/browser/browser_ext_nontab_process_switch.js b/browser/components/extensions/test/browser/browser_ext_nontab_process_switch.js new file mode 100644 index 0000000000..56511d1a7d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_nontab_process_switch.js @@ -0,0 +1,154 @@ +"use strict"; + +add_task(async function process_switch_in_sidebars_popups() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.content_web_accessible.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", // To automatically show sidebar on load. + manifest: { + content_scripts: [ + { + matches: ["http://example.com/*"], + js: ["cs.js"], + }, + ], + + sidebar_action: { + default_panel: "page.html?sidebar", + }, + browser_action: { + default_popup: "page.html?popup", + default_area: "navbar", + }, + web_accessible_resources: ["page.html"], + }, + files: { + "page.html": ``, + async "page.js"() { + browser.test.sendMessage("extension_page", { + place: location.search, + pid: await SpecialPowers.spawnChrome([], () => { + return windowGlobalParent.osPid; + }), + }); + if (!location.search.endsWith("_back")) { + window.location.href = "http://example.com/" + location.search; + } + }, + + async "cs.js"() { + browser.test.sendMessage("content_script", { + url: location.href, + pid: await this.wrappedJSObject.SpecialPowers.spawnChrome([], () => { + return windowGlobalParent.osPid; + }), + }); + if (location.search === "?popup") { + window.location.href = + browser.runtime.getURL("page.html") + "?popup_back"; + } + }, + }, + }); + + await extension.startup(); + + let sidebar = await extension.awaitMessage("extension_page"); + is(sidebar.place, "?sidebar", "Message from the extension sidebar"); + + let cs1 = await extension.awaitMessage("content_script"); + is(cs1.url, "http://example.com/?sidebar", "CS on example.com in sidebar"); + isnot(sidebar.pid, cs1.pid, "Navigating to example.com changed process"); + + await clickBrowserAction(extension); + let popup = await extension.awaitMessage("extension_page"); + is(popup.place, "?popup", "Message from the extension popup"); + + let cs2 = await extension.awaitMessage("content_script"); + is(cs2.url, "http://example.com/?popup", "CS on example.com in popup"); + isnot(popup.pid, cs2.pid, "Navigating to example.com changed process"); + + let popup2 = await extension.awaitMessage("extension_page"); + is(popup2.place, "?popup_back", "Back at extension page in popup"); + is(popup.pid, popup2.pid, "Same process as original popup page"); + + is(sidebar.pid, popup.pid, "Sidebar and popup pages from the same process"); + + // There's no guarantee that two (independent) pages from the same domain will + // end up in the same process. + + await closeBrowserAction(extension); + await extension.unload(); +}); + +// Test that navigating the browserAction popup between extension pages doesn't keep the +// parser blocked (See Bug 1747813). +add_task( + async function test_navigate_browserActionPopups_shouldnot_block_parser() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup-1.html", + default_area: "navbar", + }, + }, + files: { + "popup-1.html": `

    Popup 1

    `, + "popup-2.html": `

    Popup 2

    `, + + "popup-1.js": function () { + browser.test.onMessage.addListener(msg => { + if (msg !== "navigate-popup") { + browser.test.fail(`Unexpected test message "${msg}"`); + return; + } + location.href = "/popup-2.html"; + }); + window.onload = () => browser.test.sendMessage("popup-page-1"); + }, + + "popup-2.js": function () { + window.onload = () => browser.test.sendMessage("popup-page-2"); + }, + }, + }); + + // Make sure the mouse isn't hovering over the browserAction widget. + EventUtils.synthesizeMouseAtCenter( + gURLBar.textbox, + { type: "mouseover" }, + window + ); + + await extension.startup(); + + // Triggers popup preload (otherwise we wouldn't be blocking the parser for the browserAction popup + // and the issue wouldn't be triggered, a real user on the contrary has a pretty high chance to trigger a + // preload while hovering the browserAction popup before opening the popup with a click). + let widget = getBrowserActionWidget(extension).forWindow(window); + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mouseover" }, + window + ); + await clickBrowserAction(extension); + + await extension.awaitMessage("popup-page-1"); + + extension.sendMessage("navigate-popup"); + + await extension.awaitMessage("popup-page-2"); + // If the bug is triggered (e.g. it did regress), the test will get stuck waiting for + // the test message "popup-page-2" (which will never be sent because the extension page + // script isn't executed while the parser is blocked). + ok( + true, + "Extension browserAction popup successfully navigated to popup-page-2.html" + ); + + await closeBrowserAction(extension); + await extension.unload(); + } +); diff --git a/browser/components/extensions/test/browser/browser_ext_omnibox.js b/browser/components/extensions/test/browser/browser_ext_omnibox.js new file mode 100644 index 0000000000..f7c27af14d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_omnibox.js @@ -0,0 +1,504 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { UrlbarTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/UrlbarTestUtils.sys.mjs" +); + +const keyword = "VeryUniqueKeywordThatDoesNeverMatchAnyTestUrl"; + +// This test does a lot. To ease debugging, we'll sometimes print the lines. +function getCallerLines() { + const lines = Array.from( + new Error().stack.split("\n").slice(1), + line => /browser_ext_omnibox.js:(\d+):\d+$/.exec(line)?.[1] + ); + return "Caller lines: " + lines.filter(lineno => lineno != null).join(", "); +} + +add_setup(async () => { + // Override default timeout of 3000 ms, to make sure that the test progresses + // reasonably quickly. See comment in "function waitForResult" below. + // In this whole test, we respond ASAP to omnibox.onInputChanged events, so + // it should be safe to choose a relatively low timeout. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.extension.omnibox.timeout", 500]], + }); +}); + +add_task(async function () { + // This keyword needs to be unique to prevent history entries from unrelated + // tests from appearing in the suggestions list. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + omnibox: { + keyword: keyword, + }, + }, + + background: function () { + browser.omnibox.onInputStarted.addListener(() => { + browser.test.sendMessage("on-input-started-fired"); + }); + + let synchronous = true; + let suggestions = null; + let suggestCallback = null; + + browser.omnibox.onInputChanged.addListener((text, suggest) => { + if (synchronous && suggestions) { + suggest(suggestions); + } else { + suggestCallback = suggest; + } + browser.test.sendMessage("on-input-changed-fired", { text }); + }); + + browser.omnibox.onInputCancelled.addListener(() => { + browser.test.sendMessage("on-input-cancelled-fired"); + }); + + browser.omnibox.onInputEntered.addListener((text, disposition) => { + browser.test.sendMessage("on-input-entered-fired", { + text, + disposition, + }); + }); + + browser.omnibox.onDeleteSuggestion.addListener(text => { + browser.test.sendMessage("on-delete-suggestion-fired", { text }); + }); + + browser.test.onMessage.addListener((msg, data) => { + switch (msg) { + case "set-suggestions": + suggestions = data.suggestions; + browser.test.sendMessage("suggestions-set"); + break; + case "set-default-suggestion": + browser.omnibox.setDefaultSuggestion(data.suggestion); + browser.test.sendMessage("default-suggestion-set"); + break; + case "set-synchronous": + synchronous = data.synchronous; + browser.test.sendMessage("set-synchronous-set"); + break; + case "test-multiple-suggest-calls": + suggestions.forEach(suggestion => suggestCallback([suggestion])); + browser.test.sendMessage("test-ready"); + break; + case "test-suggestions-after-delay": + Promise.resolve().then(() => { + suggestCallback(suggestions); + browser.test.sendMessage("test-ready"); + }); + break; + } + }); + }, + }); + + async function expectEvent(event, expected) { + info(`Waiting for event: ${event} (${getCallerLines()})`); + let actual = await extension.awaitMessage(event); + if (!expected) { + ok(true, `Expected "${event} to have fired."`); + return; + } + if (expected.text != undefined) { + is( + actual.text, + expected.text, + `Expected "${event}" to have fired with text: "${expected.text}".` + ); + } + if (expected.disposition) { + is( + actual.disposition, + expected.disposition, + `Expected "${event}" to have fired with disposition: "${expected.disposition}".` + ); + } + } + + async function waitForResult(index) { + info(`waitForResult (${getCallerLines()})`); + // When omnibox.onInputChanged is triggered, the "startQuery" method in + // UrlbarProviderOmnibox.sys.mjs's startQuery will wait for a fixed amount + // of time before releasing the promise, which we observe by the call to + // UrlbarTestUtils here. + // + // To reduce the time that the test takes, we lower this in add_setup, by + // overriding the browser.urlbar.extension.omnibox.timeout preference. + // + // While this is not specific to the "waitForResult" test helper here, the + // issue is only observed in waitForResult because it is usually the first + // method called after observing "on-input-changed-fired". + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + + // Ensure the addition is complete, for proper mouse events on the entries. + await new Promise(resolve => + window.requestIdleCallback(resolve, { timeout: 1000 }) + ); + return result; + } + + async function promiseClickOnItem(index, details) { + // The Address Bar panel is animated and updated on a timer, thus it may not + // yet be listening to events when we try to click on it. This uses a + // polling strategy to repeat the click, if it doesn't go through. + let clicked = false; + let element = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + index + ); + element.addEventListener( + "mousedown", + () => { + clicked = true; + }, + { once: true } + ); + while (!clicked) { + EventUtils.synthesizeMouseAtCenter(element, details); + await new Promise(r => window.requestIdleCallback(r, { timeout: 1000 })); + } + } + + let inputSessionSerial = 0; + async function startInputSession() { + gURLBar.focus(); + gURLBar.value = keyword; + EventUtils.sendString(" "); + await expectEvent("on-input-started-fired"); + // Always use a different input at every invokation, so that + // waitForResult can distinguish different cases. + let char = (inputSessionSerial++ % 10).toString(); + EventUtils.sendString(char); + + await expectEvent("on-input-changed-fired", { text: char }); + return char; + } + + async function testInputEvents() { + gURLBar.focus(); + + // Start an input session by typing in . + EventUtils.sendString(keyword + " "); + await expectEvent("on-input-started-fired"); + + // Test canceling the input before any changed events fire. + EventUtils.synthesizeKey("KEY_Backspace"); + await expectEvent("on-input-cancelled-fired"); + + EventUtils.sendString(" "); + await expectEvent("on-input-started-fired"); + + // Test submitting the input before any changed events fire. + EventUtils.synthesizeKey("KEY_Enter"); + await expectEvent("on-input-entered-fired"); + + gURLBar.focus(); + + // Start an input session by typing in . + EventUtils.sendString(keyword + " "); + await expectEvent("on-input-started-fired"); + + // We should expect input changed events now that the keyword is active. + EventUtils.sendString("b"); + await expectEvent("on-input-changed-fired", { text: "b" }); + + EventUtils.sendString("c"); + await expectEvent("on-input-changed-fired", { text: "bc" }); + + EventUtils.synthesizeKey("KEY_Backspace"); + await expectEvent("on-input-changed-fired", { text: "b" }); + + // Even though the input is We should not expect an + // input started event to fire since the keyword is active. + EventUtils.synthesizeKey("KEY_Backspace"); + await expectEvent("on-input-changed-fired", { text: "" }); + + // Make the keyword inactive by hitting backspace. + EventUtils.synthesizeKey("KEY_Backspace"); + await expectEvent("on-input-cancelled-fired"); + + // Activate the keyword by typing a space. + // Expect onInputStarted to fire. + EventUtils.sendString(" "); + await expectEvent("on-input-started-fired"); + + // onInputChanged should fire even if a space is entered. + EventUtils.sendString(" "); + await expectEvent("on-input-changed-fired", { text: " " }); + + // The active session should cancel if the input blurs. + gURLBar.blur(); + await expectEvent("on-input-cancelled-fired"); + } + + async function testSuggestionDeletion() { + extension.sendMessage("set-suggestions", { + suggestions: [{ content: "a", description: "select a", deletable: true }], + }); + await extension.awaitMessage("suggestions-set"); + + gURLBar.focus(); + + EventUtils.sendString(keyword); + EventUtils.sendString(" select a"); + + await expectEvent("on-input-changed-fired"); + + // Select the suggestion + await EventUtils.synthesizeKey("KEY_ArrowDown"); + + // Delete the suggestion + await EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true }); + + await expectEvent("on-delete-suggestion-fired", { text: "select a" }); + } + + async function testHeuristicResult(expectedText, setDefaultSuggestion) { + if (setDefaultSuggestion) { + extension.sendMessage("set-default-suggestion", { + suggestion: { + description: expectedText, + }, + }); + await extension.awaitMessage("default-suggestion-set"); + } + + let text = await startInputSession(); + let result = await waitForResult(0); + + Assert.equal( + result.displayed.title, + expectedText, + `Expected heuristic result to have title: "${expectedText}".` + ); + + Assert.equal( + result.displayed.action, + `${keyword} ${text}`, + `Expected heuristic result to have displayurl: "${keyword} ${text}".` + ); + + let promiseEvent = expectEvent("on-input-entered-fired", { + text, + disposition: "currentTab", + }); + await promiseClickOnItem(0, {}); + await promiseEvent; + } + + async function testDisposition( + suggestionIndex, + expectedDisposition, + expectedText + ) { + await startInputSession(); + await waitForResult(suggestionIndex); + + // Select the suggestion. + EventUtils.synthesizeKey("KEY_ArrowDown", { repeat: suggestionIndex }); + + let promiseEvent = expectEvent("on-input-entered-fired", { + text: expectedText, + disposition: expectedDisposition, + }); + + if (expectedDisposition == "currentTab") { + await promiseClickOnItem(suggestionIndex, {}); + } else if (expectedDisposition == "newForegroundTab") { + await promiseClickOnItem(suggestionIndex, { accelKey: true }); + } else if (expectedDisposition == "newBackgroundTab") { + await promiseClickOnItem(suggestionIndex, { + shiftKey: true, + accelKey: true, + }); + } + await promiseEvent; + } + + async function testSuggestions(info) { + extension.sendMessage("set-synchronous", { synchronous: false }); + await extension.awaitMessage("set-synchronous-set"); + + let text = await startInputSession(); + + extension.sendMessage(info.test); + await extension.awaitMessage("test-ready"); + + await waitForResult(info.suggestions.length - 1); + // Skip the heuristic result. + let index = 1; + for (let { content, description } of info.suggestions) { + let item = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + Assert.equal( + item.displayed.title, + description, + `Expected suggestion to have title: "${description}".` + ); + Assert.equal( + item.displayed.action, + `${keyword} ${content}`, + `Expected suggestion to have displayurl: "${keyword} ${content}".` + ); + index++; + } + + let promiseEvent = expectEvent("on-input-entered-fired", { + text, + disposition: "currentTab", + }); + await promiseClickOnItem(0, {}); + await promiseEvent; + } + + await extension.startup(); + + await SimpleTest.promiseFocus(window); + + await testInputEvents(); + + await testSuggestionDeletion(); + + // Test the heuristic result with default suggestions. + await testHeuristicResult( + "Generated extension", + false /* setDefaultSuggestion */ + ); + await testHeuristicResult("hello world", true /* setDefaultSuggestion */); + await testHeuristicResult("foo bar", true /* setDefaultSuggestion */); + + let suggestions = [ + { content: "a", description: "select a" }, + { content: "b", description: "select b" }, + { content: "c", description: "select c" }, + ]; + + extension.sendMessage("set-suggestions", { suggestions }); + await extension.awaitMessage("suggestions-set"); + + // Test each suggestion and search disposition. + await testDisposition(1, "currentTab", suggestions[0].content); + await testDisposition(2, "newForegroundTab", suggestions[1].content); + await testDisposition(3, "newBackgroundTab", suggestions[2].content); + + extension.sendMessage("set-suggestions", { suggestions }); + await extension.awaitMessage("suggestions-set"); + + // Test adding suggestions asynchronously. + await testSuggestions({ + test: "test-multiple-suggest-calls", + suggestions, + }); + await testSuggestions({ + test: "test-suggestions-after-delay", + suggestions, + }); + + // When we're the first task to be added, `waitForExplicitFinish()` may not have + // been called yet. Let's just do that, otherwise the `monitorConsole` will make + // the test fail with a failing assertion. + SimpleTest.waitForExplicitFinish(); + // Start monitoring the console. + let waitForConsole = new Promise(resolve => { + SimpleTest.monitorConsole(resolve, [ + { + message: new RegExp( + `The keyword provided is already registered: "${keyword}"` + ), + }, + ]); + }); + + // Try registering another extension with the same keyword + let extension2 = ExtensionTestUtils.loadExtension({ + manifest: { + omnibox: { + keyword: keyword, + }, + }, + }); + + await extension2.startup(); + + // Stop monitoring the console and confirm the correct errors are logged. + SimpleTest.endMonitorConsole(); + await waitForConsole; + + await extension2.unload(); + await extension.unload(); +}); + +add_task(async function test_omnibox_event_page() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.eventPages.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "eventpage@omnibox" } }, + omnibox: { + keyword: keyword, + }, + background: { persistent: false }, + }, + background() { + browser.omnibox.onInputStarted.addListener(() => { + browser.test.sendMessage("onInputStarted"); + }); + browser.omnibox.onInputEntered.addListener(() => {}); + browser.omnibox.onInputChanged.addListener(() => {}); + browser.omnibox.onInputCancelled.addListener(() => {}); + browser.omnibox.onDeleteSuggestion.addListener(() => {}); + browser.test.sendMessage("ready"); + }, + }); + + const EVENTS = [ + "onInputStarted", + "onInputEntered", + "onInputChanged", + "onInputCancelled", + "onDeleteSuggestion", + ]; + + await extension.startup(); + await extension.awaitMessage("ready"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "omnibox", event, { + primed: false, + }); + } + + // test events waken background + await extension.terminateBackground(); + for (let event of EVENTS) { + assertPersistentListeners(extension, "omnibox", event, { + primed: true, + }); + } + + // Activate the keyword by typing a space. + // Expect onInputStarted to fire. + gURLBar.focus(); + gURLBar.value = keyword; + EventUtils.sendString(" "); + + await extension.awaitMessage("ready"); + await extension.awaitMessage("onInputStarted"); + ok(true, "persistent event woke background"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "omnibox", event, { + primed: false, + }); + } + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_openPanel.js b/browser/components/extensions/test/browser/browser_ext_openPanel.js new file mode 100644 index 0000000000..105cdc834b --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_openPanel.js @@ -0,0 +1,152 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_openPopup_requires_user_interaction() { + async function backgroundScript() { + browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tabInfo) => { + if (changeInfo.status != "complete") { + return; + } + await browser.pageAction.show(tabId); + + await browser.test.assertRejects( + browser.pageAction.openPopup(), + "pageAction.openPopup may only be called from a user input handler", + "The error is informative." + ); + await browser.test.assertRejects( + browser.sidebarAction.open(), + "sidebarAction.open may only be called from a user input handler", + "The error is informative." + ); + await browser.test.assertRejects( + browser.sidebarAction.close(), + "sidebarAction.close may only be called from a user input handler", + "The error is informative." + ); + await browser.test.assertRejects( + browser.sidebarAction.toggle(), + "sidebarAction.toggle may only be called from a user input handler", + "The error is informative." + ); + + browser.runtime.onMessage.addListener(async msg => { + browser.test.assertEq(msg, "from-panel", "correct message received"); + browser.test.sendMessage("panel-opened"); + }); + + browser.test.sendMessage("ready"); + }); + browser.tabs.create({ url: "tab.html" }); + } + + let extensionData = { + background: backgroundScript, + manifest: { + browser_action: { + default_popup: "panel.html", + }, + page_action: { + default_popup: "panel.html", + }, + sidebar_action: { + default_panel: "panel.html", + }, + }, + // We don't want the panel open automatically, so need a non-default reason. + startupReason: "APP_STARTUP", + + files: { + "tab.html": ` + + + + + + + + + `, + "panel.html": ` + + + + + `, + "tab.js": function () { + document.getElementById("openPageAction").addEventListener( + "click", + () => { + browser.pageAction.openPopup(); + }, + { once: true } + ); + document.getElementById("openSidebarAction").addEventListener( + "click", + () => { + browser.sidebarAction.open(); + }, + { once: true } + ); + document.getElementById("closeSidebarAction").addEventListener( + "click", + () => { + browser.sidebarAction.close(); + }, + { once: true } + ); + /* eslint-disable mozilla/balanced-listeners */ + document + .getElementById("toggleSidebarAction") + .addEventListener("click", () => { + browser.sidebarAction.toggle(); + }); + /* eslint-enable mozilla/balanced-listeners */ + }, + "panel.js": function () { + browser.runtime.sendMessage("from-panel"); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + async function click(id) { + let open = extension.awaitMessage("panel-opened"); + await BrowserTestUtils.synthesizeMouseAtCenter( + id, + {}, + gBrowser.selectedBrowser + ); + return open; + } + + await extension.startup(); + await extension.awaitMessage("ready"); + + await click("#openPageAction"); + closePageAction(extension); + await new Promise(resolve => setTimeout(resolve, 0)); + + await click("#openSidebarAction"); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#closeSidebarAction", + {}, + gBrowser.selectedBrowser + ); + await TestUtils.waitForCondition(() => !SidebarUI.isOpen); + + await click("#toggleSidebarAction"); + await TestUtils.waitForCondition(() => SidebarUI.isOpen); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#toggleSidebarAction", + {}, + gBrowser.selectedBrowser + ); + await TestUtils.waitForCondition(() => !SidebarUI.isOpen); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_optionsPage_activity.js b/browser/components/extensions/test/browser/browser_ext_optionsPage_activity.js new file mode 100644 index 0000000000..44ca2509e1 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_optionsPage_activity.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_options_activity() { + async function backgroundScript() { + browser.runtime.openOptionsPage(); + } + + function optionsScript() { + browser.test.sendMessage("options-page:loaded", document.documentURI); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: { + options_ui: { + page: "options.html", + }, + }, + files: { + "options.html": ` + + + + + + +

    Extensions Options

    + options page link + + `, + "options.js": optionsScript, + }, + background: backgroundScript, + }); + + const aboutAddonsTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:addons" + ); + + await extension.startup(); + + await extension.awaitMessage("options-page:loaded"); + + const optionsBrowser = getInlineOptionsBrowser(gBrowser.selectedBrowser); + + ok( + !optionsBrowser.ownerDocument.hidden, + "Parent should be active since it's in the foreground" + ); + ok( + optionsBrowser.docShellIsActive, + "Should be active since we're in the foreground" + ); + + let parentVisibilityChange = BrowserTestUtils.waitForEvent( + optionsBrowser.ownerDocument, + "visibilitychange" + ); + const aboutBlankTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + await parentVisibilityChange; + ok( + !optionsBrowser.docShellIsActive, + "Should become inactive since parent was backgrounded" + ); + + BrowserTestUtils.removeTab(aboutBlankTab); + BrowserTestUtils.removeTab(aboutAddonsTab); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_optionsPage_browser_style.js b/browser/components/extensions/test/browser/browser_ext_optionsPage_browser_style.js new file mode 100644 index 0000000000..9fbd4f7fd3 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_optionsPage_browser_style.js @@ -0,0 +1,155 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +requestLongerTimeout(2); + +async function testOptionsBrowserStyle(optionsUI, assertMessage) { + function optionsScript() { + browser.test.onMessage.addListener((msgName, optionsUI, assertMessage) => { + if (msgName !== "check-style") { + browser.test.notifyFail("options-ui-browser_style"); + } + + let browserStyle = + !("browser_style" in optionsUI) || optionsUI.browser_style; + + function verifyButton(buttonElement, expected) { + let buttonStyle = window.getComputedStyle(buttonElement); + let buttonBackgroundColor = buttonStyle.backgroundColor; + if (browserStyle && expected.hasBrowserStyleClass) { + browser.test.assertEq( + "rgb(9, 150, 248)", + buttonBackgroundColor, + assertMessage + ); + } else { + browser.test.assertTrue( + buttonBackgroundColor !== "rgb(9, 150, 248)", + assertMessage + ); + } + } + + function verifyCheckboxOrRadio(element, expected) { + let style = window.getComputedStyle(element); + let styledBackground = element.checked + ? "rgb(9, 150, 248)" + : "rgb(255, 255, 255)"; + if (browserStyle && expected.hasBrowserStyleClass) { + browser.test.assertEq( + styledBackground, + style.backgroundColor, + assertMessage + ); + } else { + browser.test.assertTrue( + style.backgroundColor != styledBackground, + assertMessage + ); + } + } + + let normalButton = document.getElementById("normalButton"); + let browserStyleButton = document.getElementById("browserStyleButton"); + verifyButton(normalButton, { hasBrowserStyleClass: false }); + verifyButton(browserStyleButton, { hasBrowserStyleClass: true }); + + let normalCheckbox1 = document.getElementById("normalCheckbox1"); + let normalCheckbox2 = document.getElementById("normalCheckbox2"); + let browserStyleCheckbox = document.getElementById( + "browserStyleCheckbox" + ); + verifyCheckboxOrRadio(normalCheckbox1, { hasBrowserStyleClass: false }); + verifyCheckboxOrRadio(normalCheckbox2, { hasBrowserStyleClass: false }); + verifyCheckboxOrRadio(browserStyleCheckbox, { + hasBrowserStyleClass: true, + }); + + let normalRadio1 = document.getElementById("normalRadio1"); + let normalRadio2 = document.getElementById("normalRadio2"); + let browserStyleRadio = document.getElementById("browserStyleRadio"); + verifyCheckboxOrRadio(normalRadio1, { hasBrowserStyleClass: false }); + verifyCheckboxOrRadio(normalRadio2, { hasBrowserStyleClass: false }); + verifyCheckboxOrRadio(browserStyleRadio, { hasBrowserStyleClass: true }); + + browser.test.notifyPass("options-ui-browser_style"); + }); + browser.test.sendMessage("options-ui-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: { + permissions: ["tabs"], + options_ui: optionsUI, + }, + files: { + "options.html": ` + + + + + + + +
    + +
    + + + +
    + +
    + + + `, + "options.js": optionsScript, + }, + background() { + browser.runtime.openOptionsPage(); + }, + }); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await extension.startup(); + await extension.awaitMessage("options-ui-ready"); + + extension.sendMessage("check-style", optionsUI, assertMessage); + await extension.awaitFinish("options-ui-browser_style"); + BrowserTestUtils.removeTab(tab); + + await extension.unload(); +} + +add_task(async function test_options_without_setting_browser_style() { + await testOptionsBrowserStyle( + { + page: "options.html", + }, + "Expected correct style when browser_style is excluded" + ); +}); + +add_task(async function test_options_with_browser_style_set_to_true() { + await testOptionsBrowserStyle( + { + page: "options.html", + browser_style: true, + }, + "Expected correct style when browser_style is set to `true`" + ); +}); + +add_task(async function test_options_with_browser_style_set_to_false() { + await testOptionsBrowserStyle( + { + page: "options.html", + browser_style: false, + }, + "Expected no style when browser_style is set to `false`" + ); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_optionsPage_links_open_in_tabs.js b/browser/components/extensions/test/browser/browser_ext_optionsPage_links_open_in_tabs.js new file mode 100644 index 0000000000..147754b344 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_optionsPage_links_open_in_tabs.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_options_links() { + async function backgroundScript() { + browser.runtime.openOptionsPage(); + } + + function optionsScript() { + browser.test.sendMessage("options-page:loaded", document.documentURI); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: { + options_ui: { + page: "options.html", + }, + }, + files: { + "options.html": ` + + + + + + +

    Extensions Options

    + options page link + + `, + "options.js": optionsScript, + }, + background: backgroundScript, + }); + + const aboutAddonsTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:addons" + ); + + await extension.startup(); + + await extension.awaitMessage("options-page:loaded"); + + const optionsBrowser = getInlineOptionsBrowser(gBrowser.selectedBrowser); + + const promiseNewTabOpened = BrowserTestUtils.waitForNewTab( + gBrowser, + "https://example.com/options-page-link" + ); + await SpecialPowers.spawn(optionsBrowser, [], () => + content.document.querySelector("a").click() + ); + info( + "Expect a new tab to be opened when a link is clicked in the options_page embedded inside about:addons" + ); + const newTab = await promiseNewTabOpened; + ok(newTab, "Got a new tab created on the expected url"); + BrowserTestUtils.removeTab(newTab); + + BrowserTestUtils.removeTab(aboutAddonsTab); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_optionsPage_modals.js b/browser/components/extensions/test/browser/browser_ext_optionsPage_modals.js new file mode 100644 index 0000000000..809a5605c0 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_optionsPage_modals.js @@ -0,0 +1,100 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_tab_options_modals() { + function backgroundScript() { + browser.runtime.openOptionsPage(); + } + + function optionsScript() { + try { + alert("WebExtensions OptionsUI Page Modal"); + + browser.test.notifyPass("options-ui-modals"); + } catch (error) { + browser.test.log(`Error: ${error} :: ${error.stack}`); + browser.test.notifyFail("options-ui-modals"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: { + permissions: ["tabs"], + options_ui: { + page: "options.html", + }, + }, + files: { + "options.html": ` + + + + + + `, + "options.js": optionsScript, + }, + background: backgroundScript, + }); + + await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:addons"); + + await extension.startup(); + + const onceModalOpened = new Promise(resolve => { + const aboutAddonsBrowser = gBrowser.selectedBrowser; + + aboutAddonsBrowser.addEventListener( + "DOMWillOpenModalDialog", + function onModalDialog(event) { + // Wait for the next event tick to make sure the remaining part of the + // testcase runs after the dialog gets opened. + SimpleTest.executeSoon(resolve); + }, + { once: true, capture: true } + ); + }); + + info("Wait the options_ui modal to be opened"); + await onceModalOpened; + + const optionsBrowser = getInlineOptionsBrowser(gBrowser.selectedBrowser); + + // The stack that contains the tabmodalprompt elements is the parent of + // the extensions options_ui browser element. + let stack = optionsBrowser.parentNode; + + let dialogs = stack.querySelectorAll("tabmodalprompt"); + Assert.equal( + dialogs.length, + 1, + "Expect a tab modal opened for the about addons tab" + ); + + // Verify that the expected stylesheets have been applied on the + // tabmodalprompt element (See Bug 1550529). + const tabmodalStyle = dialogs[0].ownerGlobal.getComputedStyle(dialogs[0]); + is( + tabmodalStyle["background-color"], + "rgba(26, 26, 26, 0.5)", + "Got the expected styles applied to the tabmodalprompt" + ); + + info("Close the tab modal prompt"); + dialogs[0].querySelector(".tabmodalprompt-button0").click(); + + await extension.awaitFinish("options-ui-modals"); + + Assert.equal( + stack.querySelectorAll("tabmodalprompt").length, + 0, + "Expect the tab modal to be closed" + ); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_optionsPage_popups.js b/browser/components/extensions/test/browser/browser_ext_optionsPage_popups.js new file mode 100644 index 0000000000..168a9c11b5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_optionsPage_popups.js @@ -0,0 +1,249 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function openContextMenuInOptionsPage(optionsBrowser) { + let contentAreaContextMenu = document.getElementById( + "contentAreaContextMenu" + ); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contentAreaContextMenu, + "popupshown" + ); + + info("Trigger context menu in the extension options page"); + + // Instead of BrowserTestUtils.synthesizeMouseAtCenter, we are dispatching a contextmenu + // event directly on the target element to prevent intermittent failures on debug builds + // (especially linux32-debug), see Bug 1519808 for a rationale. + SpecialPowers.spawn(optionsBrowser, [], () => { + let el = content.document.querySelector("a"); + el.dispatchEvent( + new content.MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + view: el.ownerGlobal, + }) + ); + }); + + info("Wait the context menu to be shown"); + await popupShownPromise; + + return contentAreaContextMenu; +} + +async function contextMenuClosed(contextMenu) { + info("Wait context menu popup to be closed"); + await closeContextMenu(contextMenu); + is(contextMenu.state, "closed", "The context menu popup has been closed"); +} + +add_task(async function test_tab_options_popups() { + async function backgroundScript() { + browser.menus.onShown.addListener(info => { + browser.test.sendMessage("extension-menus-onShown", info); + }); + + await browser.menus.create({ + id: "sidebaronly", + title: "sidebaronly", + viewTypes: ["sidebar"], + }); + await browser.menus.create({ + id: "tabonly", + title: "tabonly", + viewTypes: ["tab"], + }); + await browser.menus.create({ id: "anypage", title: "anypage" }); + + browser.runtime.openOptionsPage(); + } + + function optionsScript() { + browser.test.sendMessage("options-page:loaded", document.documentURI); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: { + permissions: ["tabs", "menus"], + options_ui: { + page: "options.html", + }, + }, + files: { + "options.html": ` + + + + + + +

    Extensions Options

    + options page link + + `, + "options.js": optionsScript, + }, + background: backgroundScript, + }); + + const aboutAddonsTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:addons" + ); + + await extension.startup(); + + const pageUrl = await extension.awaitMessage("options-page:loaded"); + + const optionsBrowser = getInlineOptionsBrowser(gBrowser.selectedBrowser); + + const contentAreaContextMenu = await openContextMenuInOptionsPage( + optionsBrowser + ); + + let contextMenuItemIds = [ + "context-openlinkintab", + "context-openlinkprivate", + "context-copylink", + ]; + + // Test that the "open link in container" menu is available if the containers are enabled + // (which is the default on Nightly, but not on Beta). + if (Services.prefs.getBoolPref("privacy.userContext.enabled")) { + contextMenuItemIds.push("context-openlinkinusercontext-menu"); + } + + for (const itemID of contextMenuItemIds) { + const item = contentAreaContextMenu.querySelector(`#${itemID}`); + + ok(!item.hidden, `${itemID} should not be hidden`); + ok(!item.disabled, `${itemID} should not be disabled`); + } + + const menuDetails = await extension.awaitMessage("extension-menus-onShown"); + + isnot( + menuDetails.targetElementId, + undefined, + "Got a targetElementId in the menu details" + ); + delete menuDetails.targetElementId; + + Assert.deepEqual( + menuDetails, + { + menuIds: ["anypage"], + contexts: ["link", "all"], + viewType: undefined, + frameId: 0, + editable: false, + linkText: "options page link", + linkUrl: "http://mochi.test:8888/", + pageUrl, + }, + "Got the expected menu details from menus.onShown" + ); + + await contextMenuClosed(contentAreaContextMenu); + + BrowserTestUtils.removeTab(aboutAddonsTab); + + await extension.unload(); +}); + +add_task(async function overrideContext_in_options_page() { + function optionsScript() { + document.addEventListener( + "contextmenu", + () => { + browser.menus.overrideContext({}); + browser.test.sendMessage("contextmenu-overridden"); + }, + { once: true } + ); + browser.test.sendMessage("options-page:loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["tabs", "menus", "menus.overrideContext"], + options_ui: { + page: "options.html", + }, + }, + files: { + "options.html": ` + + + + + + +

    Extensions Options

    + options page link + + `, + "options.js": optionsScript, + }, + async background() { + // Expected to match and be shown. + await new Promise(resolve => { + browser.menus.create({ id: "bg_1_1", title: "bg_1_1" }); + browser.menus.create({ id: "bg_1_2", title: "bg_1_2" }); + // Expected to not match and be hidden. + browser.menus.create( + { + id: "bg_1_3", + title: "bg_1_3", + targetUrlPatterns: ["*://nomatch/*"], + }, + // menus.create returns a number and gets a callback, the order + // is deterministic and so we just need to wait for the last one. + resolve + ); + }); + browser.runtime.openOptionsPage(); + }, + }); + + const aboutAddonsTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:addons" + ); + + await extension.startup(); + await extension.awaitMessage("options-page:loaded"); + + const optionsBrowser = getInlineOptionsBrowser(gBrowser.selectedBrowser); + const contentAreaContextMenu = await openContextMenuInOptionsPage( + optionsBrowser + ); + + await extension.awaitMessage("contextmenu-overridden"); + + const allVisibleMenuItems = Array.from(contentAreaContextMenu.children) + .filter(elem => { + return !elem.hidden; + }) + .map(elem => elem.id); + + Assert.deepEqual( + allVisibleMenuItems, + [ + `${makeWidgetId(extension.id)}-menuitem-_bg_1_1`, + `${makeWidgetId(extension.id)}-menuitem-_bg_1_2`, + ], + "Expected only extension menu items" + ); + + await contextMenuClosed(contentAreaContextMenu); + + BrowserTestUtils.removeTab(aboutAddonsTab); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_optionsPage_privileges.js b/browser/components/extensions/test/browser/browser_ext_optionsPage_privileges.js new file mode 100644 index 0000000000..33ddc6db34 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_optionsPage_privileges.js @@ -0,0 +1,86 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_tab_options_privileges() { + function backgroundScript() { + browser.runtime.onMessage.addListener(async ({ msgName, tab }) => { + if (msgName == "removeTab") { + try { + const [activeTab] = await browser.tabs.query({ active: true }); + browser.test.assertEq( + tab.id, + activeTab.id, + "tabs.getCurrent has got the expected tabId" + ); + browser.test.assertEq( + tab.windowId, + activeTab.windowId, + "tabs.getCurrent has got the expected windowId" + ); + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("options-ui-privileges"); + } catch (error) { + browser.test.log(`Error: ${error} :: ${error.stack}`); + browser.test.notifyFail("options-ui-privileges"); + } + } + }); + browser.runtime.openOptionsPage(); + } + + async function optionsScript() { + try { + let [tab] = await browser.tabs.query({ url: "http://example.com/" }); + browser.test.assertEq( + "http://example.com/", + tab.url, + "Got the expect tab" + ); + + tab = await browser.tabs.getCurrent(); + browser.runtime.sendMessage({ msgName: "removeTab", tab }); + } catch (error) { + browser.test.log(`Error: ${error} :: ${error.stack}`); + browser.test.notifyFail("options-ui-privileges"); + } + } + + const ID = "options_privileges@tests.mozilla.org"; + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["tabs"], + options_ui: { + page: "options.html", + }, + }, + files: { + "options.html": ` + + + + + + `, + "options.js": optionsScript, + }, + background: backgroundScript, + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + + await extension.startup(); + + await extension.awaitFinish("options-ui-privileges"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_originControls.js b/browser/components/extensions/test/browser/browser_ext_originControls.js new file mode 100644 index 0000000000..176eef08bc --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_originControls.js @@ -0,0 +1,867 @@ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const { ExtensionPermissions, QuarantinedDomains } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +AddonTestUtils.initMochitest(this); + +loadTestSubscript("head_unified_extensions.js"); + +const RED = + "iVBORw0KGgoAAAANSUhEUgAAANwAAADcCAYAAAAbWs+BAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gIUARQAHY8+4wAAApBJREFUeNrt3cFqAjEUhlEjvv8rXzciiiBGk/He5JxdN2U649dY+KmnEwAAAAAv2uMXEeGOwERntwAEB4IDBAeCAwQHggPBAYIDwQGCA8GB4ADBgeAAwYHgAMGB4EBwgOCgpkuKq2it/r8Li2hbvGKqP6s/PycnHHv9YvSWEgQHCA4EBwgOBAeCAwQHggMEByXM+QRUE6D3suwuPafDn5MTDg50KXnVPSdxa54y/oYDwQGCA8EBggPBAYIDwYHggBE+X5rY3Y3Tey97Nn2eU+rnlGfaZa6Ft5SA4EBwgOBAcCA4QHAgOEBwIDjgZu60y1xrDPtIJxwgOBAcIDgQHAgOEBwIDhAcCA4EBwgOBAcIDgQHCA4EB4IDBAeCAwQHggPBAYIDwQGCA8GB4ADBgeAAwYHgAMGB4GADcz9y2McIgxMOBAeCAwQHggMEB4IDwQGCA8EBggPBATdP6+KIGPRdW7i1LCFi6ALfCQfeUoLgAMGB4ADBgeBAcIDgQHCA4CCdOVvK7quwveQgg7eRTjjwlhIQHAgOBAcIDgQHCA4EB4IDBAfl5dhSdl+17SX3F22rdLlOOBAcCA4QHAgOEBwIDgQHCA4EBwgO0qm5pez6Ce0uSym2jXTCgeAAwYHgQHCA4EBwgOBAcCA4QHBQ3vpbyu47Yns51OLbSCccCA4QHAgOBAcIDgQHCA4EB4ID5jDt+vkObjgFM9dywoHgAMGB4EBwgOBAcIDgQHAgOEBwsA5bysPveMLtpW2kEw4EBwgOBAcIDgQHggMEB4IDBAeCg33ZUqZ/Ql9sL20jnXCA4EBwIDhAcCA4QHAgOBAcIDgQHNOZai3DlhKccCA4QHAgOEBwIDgQHCA4AAAAAGA1VyxaWIohrgXFAAAAAElFTkSuQmCC"; + +const l10n = new Localization( + ["branding/brand.ftl", "browser/extensionsUI.ftl"], + true +); + +async function makeExtension({ + useAddonManager = "temporary", + manifest_version = 3, + id, + permissions, + host_permissions, + content_scripts, + granted, + default_area, +}) { + info( + `Loading extension ` + + JSON.stringify({ id, permissions, host_permissions, granted }) + ); + + let manifest = { + manifest_version, + browser_specific_settings: { gecko: { id } }, + permissions, + host_permissions, + content_scripts, + action: { + default_popup: "popup.html", + default_area: default_area || "navbar", + }, + icons: { + 16: "red.png", + }, + }; + if (manifest_version < 3) { + manifest.browser_action = manifest.action; + delete manifest.action; + } + + let ext = ExtensionTestUtils.loadExtension({ + manifest, + + useAddonManager, + + async background() { + browser.permissions.onAdded.addListener(({ origins }) => { + browser.test.sendMessage("granted", origins.join()); + }); + browser.permissions.onRemoved.addListener(({ origins }) => { + browser.test.sendMessage("revoked", origins.join()); + }); + + browser.runtime.onInstalled.addListener(async () => { + if (browser.menus) { + let submenu = browser.menus.create({ + id: "parent", + title: "submenu", + contexts: ["action"], + }); + browser.menus.create({ + id: "child1", + title: "child1", + parentId: submenu, + }); + await new Promise(resolve => { + browser.menus.create( + { + id: "child2", + title: "child2", + parentId: submenu, + }, + resolve + ); + }); + } + + browser.test.sendMessage("ready"); + }); + }, + + files: { + "red.png": imageBufferFromDataURI(RED), + "popup.html": `Test Popup`, + }, + }); + + if (granted) { + info("Granting initial permissions."); + await ExtensionPermissions.add(id, { permissions: [], origins: granted }); + } + + await ext.startup(); + await ext.awaitMessage("ready"); + return ext; +} + +async function testQuarantinePopup(popup) { + let [title, line1, line2] = await l10n.formatMessages([ + { + id: "webext-quarantine-confirmation-title", + args: { addonName: "Generated extension" }, + }, + "webext-quarantine-confirmation-line-1", + "webext-quarantine-confirmation-line-2", + ]); + let [titleEl, , helpEl] = popup.querySelectorAll("description"); + + ok(popup.getAttribute("icon").endsWith("/red.png"), "Correct icon."); + + is(title.value, titleEl.textContent, "Correct title."); + is(line1.value + "\n\n" + line2.value, helpEl.textContent, "Correct lines."); +} + +async function testOriginControls( + extension, + { contextMenuId }, + { + items, + selected, + click, + granted, + revoked, + attention, + quarantined, + allowQuarantine, + } +) { + info( + `Testing ${extension.id} on ${gBrowser.currentURI.spec} with contextMenuId=${contextMenuId}.` + ); + + let buttonOrWidget; + let menu; + let nextMenuItemClassName; + + switch (contextMenuId) { + case "toolbar-context-menu": + let target = `#${CSS.escape(makeWidgetId(extension.id))}-BAP`; + buttonOrWidget = document.querySelector(target).parentElement; + menu = await openChromeContextMenu(contextMenuId, target); + nextMenuItemClassName = "customize-context-manageExtension"; + break; + + case "unified-extensions-context-menu": + await openExtensionsPanel(); + buttonOrWidget = getUnifiedExtensionsItem(extension.id); + menu = await openUnifiedExtensionsContextMenu(extension.id); + nextMenuItemClassName = "unified-extensions-context-menu-pin-to-toolbar"; + break; + + default: + throw new Error(`unexpected context menu "${contextMenuId}"`); + } + + let doc = menu.ownerDocument; + let visibleOriginItems = menu.querySelectorAll( + ":is(menuitem, menuseparator):not([hidden])" + ); + + info("Check expected menu items."); + for (let i = 0; i < items.length; i++) { + let l10n = doc.l10n.getAttributes(visibleOriginItems[i]); + Assert.deepEqual( + l10n, + items[i], + `Visible menu item ${i} has correct l10n attrs.` + ); + + let checked = visibleOriginItems[i].getAttribute("checked") === "true"; + is(i === selected, checked, `Expected checked value for item ${i}.`); + } + + if (items.length) { + is( + visibleOriginItems[items.length].nodeName, + "menuseparator", + "Found separator." + ); + is( + visibleOriginItems[items.length + 1].className, + nextMenuItemClassName, + "All items accounted for." + ); + } + + is( + buttonOrWidget.hasAttribute("attention"), + !!attention, + "Expected attention badge before clicking." + ); + + Assert.deepEqual( + document.l10n.getAttributes( + buttonOrWidget.querySelector(".unified-extensions-item-action-button") + ), + { + // eslint-disable-next-line no-nested-ternary + id: attention + ? quarantined + ? "origin-controls-toolbar-button-quarantined" + : "origin-controls-toolbar-button-permission-needed" + : "origin-controls-toolbar-button", + args: { + extensionTitle: "Generated extension", + }, + }, + "Correct l10n message." + ); + + let itemToClick; + if (click) { + itemToClick = visibleOriginItems[click]; + } + + let quarantinePopup; + if (itemToClick && quarantined) { + quarantinePopup = promisePopupNotificationShown("addon-webext-permissions"); + } + + // Clicking a menu item of the unified extensions context menu should close + // the unified extensions panel automatically. + let panelHidden = + itemToClick && contextMenuId === "unified-extensions-context-menu" + ? BrowserTestUtils.waitForEvent(document, "popuphidden", true) + : Promise.resolve(); + + await closeChromeContextMenu(contextMenuId, itemToClick); + await panelHidden; + + // When there is no menu item to close, we should manually close the unified + // extensions panel because simply closing the context menu will not close + // it. + if (!itemToClick && contextMenuId === "unified-extensions-context-menu") { + await closeExtensionsPanel(); + } + + if (granted) { + info("Waiting for the permissions.onAdded event."); + let host = await extension.awaitMessage("granted"); + is(host, granted.join(), "Expected host permission granted."); + } + if (revoked) { + info("Waiting for the permissions.onRemoved event."); + let host = await extension.awaitMessage("revoked"); + is(host, revoked.join(), "Expected host permission revoked."); + } + + if (quarantinePopup) { + let popup = await quarantinePopup; + await testQuarantinePopup(popup); + + if (allowQuarantine) { + popup.button.click(); + } else { + popup.secondaryButton.click(); + } + } +} + +// Move the widget to the toolbar or the addons panel (if Unified Extensions +// is enabled) or the overflow panel otherwise. +function moveWidget(ext, pinToToolbar = false) { + let area = pinToToolbar + ? CustomizableUI.AREA_NAVBAR + : CustomizableUI.AREA_ADDONS; + let widgetId = `${makeWidgetId(ext.id)}-browser-action`; + CustomizableUI.addWidgetToArea(widgetId, area); +} + +const originControlsInContextMenu = async options => { + // Has no permissions. + let ext1 = await makeExtension({ id: "ext1@test" }); + + // Has activeTab and (ungranted) example.com permissions. + let ext2 = await makeExtension({ + id: "ext2@test", + permissions: ["activeTab"], + host_permissions: ["*://example.com/*"], + useAddonManager: "permanent", + }); + + // Has ungranted , and granted example.com. + let ext3 = await makeExtension({ + id: "ext3@test", + host_permissions: [""], + granted: ["*://example.com/*"], + useAddonManager: "permanent", + }); + + // Has granted . + let ext4 = await makeExtension({ + id: "ext4@test", + host_permissions: [""], + granted: [""], + useAddonManager: "permanent", + }); + + // MV2 extension with an content script and activeTab. + let ext5 = await makeExtension({ + manifest_version: 2, + id: "ext5@test", + permissions: ["activeTab"], + content_scripts: [ + { + matches: [""], + css: [], + }, + ], + useAddonManager: "permanent", + }); + + // Add an extension always visible in the extensions panel. + let ext6 = await makeExtension({ + id: "ext6@test", + default_area: "menupanel", + }); + + let extensions = [ext1, ext2, ext3, ext4, ext5, ext6]; + + let unifiedButton; + if (options.contextMenuId === "unified-extensions-context-menu") { + moveWidget(ext1, false); + moveWidget(ext2, false); + moveWidget(ext3, false); + moveWidget(ext4, false); + moveWidget(ext5, false); + unifiedButton = document.querySelector("#unified-extensions-button"); + } else { + // TestVerify runs this again in the same Firefox instance, so move the + // widgets back to the toolbar for testing outside the unified extensions + // panel. + moveWidget(ext1, true); + moveWidget(ext2, true); + moveWidget(ext3, true); + moveWidget(ext4, true); + moveWidget(ext5, true); + } + + const NO_ACCESS = { id: "origin-controls-no-access", args: null }; + const QUARANTINED = { + id: "origin-controls-quarantined-status", + args: null, + }; + const ALLOW_QUARANTINED = { + id: "origin-controls-quarantined-allow", + args: null, + }; + const ACCESS_OPTIONS = { id: "origin-controls-options", args: null }; + const ALL_SITES = { id: "origin-controls-option-all-domains", args: null }; + const WHEN_CLICKED = { + id: "origin-controls-option-when-clicked", + args: null, + }; + + const UNIFIED_NO_ATTENTION = { id: "unified-extensions-button", args: null }; + const UNIFIED_ATTENTION = { + id: "unified-extensions-button-permissions-needed", + args: null, + }; + const UNIFIED_QUARANTINED = { + id: "unified-extensions-button-quarantined", + args: null, + }; + + await BrowserTestUtils.withNewTab("about:blank", async () => { + await testOriginControls(ext1, options, { items: [NO_ACCESS] }); + await testOriginControls(ext2, options, { items: [NO_ACCESS] }); + await testOriginControls(ext3, options, { items: [NO_ACCESS] }); + await testOriginControls(ext4, options, { items: [NO_ACCESS] }); + await testOriginControls(ext5, options, { items: [] }); + + if (unifiedButton) { + ok( + !unifiedButton.hasAttribute("attention"), + "No extension will have attention indicator on about:blank." + ); + Assert.deepEqual( + document.l10n.getAttributes(unifiedButton), + UNIFIED_NO_ATTENTION, + "Unified button has no permissions needed tooltip." + ); + } + }); + + await BrowserTestUtils.withNewTab("http://mochi.test:8888/", async () => { + const ALWAYS_ON = { + id: "origin-controls-option-always-on", + args: { domain: "mochi.test" }, + }; + + await testOriginControls(ext1, options, { items: [NO_ACCESS] }); + + // Has activeTab. + await testOriginControls(ext2, options, { + items: [ACCESS_OPTIONS, WHEN_CLICKED], + selected: 1, + attention: true, + }); + + // Could access mochi.test when clicked. + await testOriginControls(ext3, options, { + items: [ACCESS_OPTIONS, WHEN_CLICKED, ALWAYS_ON], + selected: 1, + attention: true, + }); + + // Has granted. + await testOriginControls(ext4, options, { + items: [ACCESS_OPTIONS, ALL_SITES], + selected: 1, + attention: false, + }); + + // MV2 extension, has no origin controls, and never flags for attention. + await testOriginControls(ext5, options, { items: [], attention: false }); + if (unifiedButton) { + ok( + unifiedButton.hasAttribute("attention"), + "Both ext2 and ext3 are WHEN_CLICKED for example.com, so show attention indicator." + ); + Assert.deepEqual( + document.l10n.getAttributes(unifiedButton), + UNIFIED_ATTENTION, + "UEB has permissions needed tooltip." + ); + } + }); + + info("Testing again with mochi.test now quarantined."); + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.quarantinedDomains.enabled", true], + ["extensions.quarantinedDomains.list", "mochi.test"], + ], + }); + + // Reset quarantined state between test runs. + QuarantinedDomains.setUserAllowedAddonIdPref(ext2.id, false); + QuarantinedDomains.setUserAllowedAddonIdPref(ext4.id, false); + QuarantinedDomains.setUserAllowedAddonIdPref(ext5.id, false); + + await BrowserTestUtils.withNewTab("http://mochi.test:8888/", async () => { + await testOriginControls(ext1, options, { + items: [NO_ACCESS], + attention: false, + }); + + await testOriginControls(ext2, options, { + items: [QUARANTINED, ALLOW_QUARANTINED], + attention: true, + quarantined: true, + click: 1, + allowQuarantine: false, + }); + // Still quarantined. + await testOriginControls(ext2, options, { + items: [QUARANTINED, ALLOW_QUARANTINED], + attention: true, + quarantined: true, + click: 1, + allowQuarantine: true, + }); + // Not quarantined anymore. + await testOriginControls(ext2, options, { + items: [ACCESS_OPTIONS, WHEN_CLICKED], + selected: 1, + attention: true, + quarantined: false, + }); + + await testOriginControls(ext3, options, { + items: [QUARANTINED, ALLOW_QUARANTINED], + attention: true, + quarantined: true, + }); + + await testOriginControls(ext4, options, { + items: [QUARANTINED, ALLOW_QUARANTINED], + attention: true, + quarantined: true, + click: 1, + allowQuarantine: true, + }); + await testOriginControls(ext4, options, { + items: [ACCESS_OPTIONS, ALL_SITES], + selected: 1, + attention: false, + quarantined: false, + }); + + // MV2 normally don't have controls, but we always show for quarantined. + await testOriginControls(ext5, options, { + items: [QUARANTINED, ALLOW_QUARANTINED], + attention: true, + quarantined: true, + click: 1, + allowQuarantine: true, + }); + await testOriginControls(ext5, options, { + items: [], + attention: false, + quarantined: false, + }); + + if (unifiedButton) { + ok(unifiedButton.hasAttribute("attention"), "Expected attention UI"); + Assert.deepEqual( + document.l10n.getAttributes(unifiedButton), + UNIFIED_QUARANTINED, + "Expected attention tooltip text for quarantined domains" + ); + } + }); + + if (unifiedButton) { + extensions.forEach(extension => + moveWidget(extension, /* pinToToolbar */ true) + ); + + await BrowserTestUtils.withNewTab("http://mochi.test:8888/", async () => { + ok(unifiedButton.hasAttribute("attention"), "Expected attention UI"); + Assert.deepEqual( + document.l10n.getAttributes(unifiedButton), + UNIFIED_QUARANTINED, + "Expected attention tooltip text for quarantined domains" + ); + + await openExtensionsPanel(); + + const messages = getMessageBars(); + Assert.equal(messages.length, 1, "expected a message"); + const supportLink = messages[0].querySelector("a"); + Assert.equal( + supportLink.getAttribute("support-page"), + "quarantined-domains", + "Expected the correct support page ID" + ); + + await closeExtensionsPanel(); + }); + + extensions.forEach(extension => + moveWidget(extension, /* pinToToolbar */ false) + ); + } + + await SpecialPowers.popPrefEnv(); + + await BrowserTestUtils.withNewTab("http://example.com/", async () => { + const ALWAYS_ON = { + id: "origin-controls-option-always-on", + args: { domain: "example.com" }, + }; + + await testOriginControls(ext1, options, { items: [NO_ACCESS] }); + + // Click alraedy selected options, expect no permission changes. + await testOriginControls(ext2, options, { + items: [ACCESS_OPTIONS, WHEN_CLICKED, ALWAYS_ON], + selected: 1, + click: 1, + attention: true, + }); + await testOriginControls(ext3, options, { + items: [ACCESS_OPTIONS, WHEN_CLICKED, ALWAYS_ON], + selected: 2, + click: 2, + attention: false, + }); + await testOriginControls(ext4, options, { + items: [ACCESS_OPTIONS, ALL_SITES], + selected: 1, + click: 1, + attention: false, + }); + + await testOriginControls(ext5, options, { items: [], attention: false }); + + if (unifiedButton) { + ok( + unifiedButton.hasAttribute("attention"), + "ext2 is WHEN_CLICKED for example.com, show attention indicator." + ); + Assert.deepEqual( + document.l10n.getAttributes(unifiedButton), + UNIFIED_ATTENTION, + "UEB attention for only one extension." + ); + } + + // Click the other option, expect example.com permission granted/revoked. + await testOriginControls(ext2, options, { + items: [ACCESS_OPTIONS, WHEN_CLICKED, ALWAYS_ON], + selected: 1, + click: 2, + granted: ["*://example.com/*"], + attention: true, + }); + if (unifiedButton) { + ok( + !unifiedButton.hasAttribute("attention"), + "Bot ext2 and ext3 are ALWAYS_ON for example.com, so no attention indicator." + ); + Assert.deepEqual( + document.l10n.getAttributes(unifiedButton), + UNIFIED_NO_ATTENTION, + "Unified button has no permissions needed tooltip." + ); + } + + await testOriginControls(ext3, options, { + items: [ACCESS_OPTIONS, WHEN_CLICKED, ALWAYS_ON], + selected: 2, + click: 1, + revoked: ["*://example.com/*"], + attention: false, + }); + if (unifiedButton) { + ok( + unifiedButton.hasAttribute("attention"), + "ext3 is now WHEN_CLICKED for example.com, show attention indicator." + ); + Assert.deepEqual( + document.l10n.getAttributes(unifiedButton), + UNIFIED_ATTENTION, + "UEB attention for only one extension." + ); + } + + // Other option is now selected. + await testOriginControls(ext2, options, { + items: [ACCESS_OPTIONS, WHEN_CLICKED, ALWAYS_ON], + selected: 2, + attention: false, + }); + await testOriginControls(ext3, options, { + items: [ACCESS_OPTIONS, WHEN_CLICKED, ALWAYS_ON], + selected: 1, + attention: true, + }); + + if (unifiedButton) { + ok( + unifiedButton.hasAttribute("attention"), + "Still showing the attention indicator." + ); + Assert.deepEqual( + document.l10n.getAttributes(unifiedButton), + UNIFIED_ATTENTION, + "UEB attention for only one extension." + ); + } + }); + + // Regression test for Bug 1861002. + const addonListener = { + registered: false, + onPropertyChanged(addon, changedProps) { + ok( + addon, + `onPropertyChanged should not be called without an AddonWrapper for changed properties: ${changedProps}` + ); + }, + }; + AddonManager.addAddonListener(addonListener); + addonListener.registered = true; + const unregisterAddonListener = () => { + if (!addonListener.registered) { + return; + } + AddonManager.removeAddonListener(addonListener); + addonListener.registered = false; + }; + registerCleanupFunction(unregisterAddonListener); + + let { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + await Promise.all(extensions.map(e => e.unload())); + }); + + unregisterAddonListener(); + + AddonTestUtils.checkMessages( + messages, + { + forbidden: [ + { + message: + /AddonListener threw exception when calling onPropertyChanged/, + }, + ], + }, + "Expect no exception raised from AddonListener onPropertyChanged callbacks" + ); +}; + +add_task(async function originControls_in_browserAction_contextMenu() { + await originControlsInContextMenu({ contextMenuId: "toolbar-context-menu" }); +}); + +add_task(async function originControls_in_unifiedExtensions_contextMenu() { + await originControlsInContextMenu({ + contextMenuId: "unified-extensions-context-menu", + }); +}); + +add_task(async function test_attention_dot_when_pinning_extension() { + const extension = await makeExtension({ permissions: ["activeTab"] }); + await extension.startup(); + + const unifiedButton = document.querySelector("#unified-extensions-button"); + const extensionWidgetID = AppUiTestInternals.getBrowserActionWidgetId( + extension.id + ); + const extensionWidget = + CustomizableUI.getWidget(extensionWidgetID).forWindow(window).node; + + await BrowserTestUtils.withNewTab("http://mochi.test:8888/", async () => { + // The extensions should be placed in the navbar by default so we do not + // expect an attention dot on the Unifed Extensions Button (UEB), only on + // the extension (widget) itself. + ok( + !unifiedButton.hasAttribute("attention"), + "expected no attention attribute on the UEB" + ); + ok( + extensionWidget.hasAttribute("attention"), + "expected attention attribute on the extension widget" + ); + + // Open the context menu of the extension and unpin the extension. + let contextMenu = await openChromeContextMenu( + "toolbar-context-menu", + `#${CSS.escape(extensionWidgetID)}` + ); + let pinToToolbar = contextMenu.querySelector( + ".customize-context-pinToToolbar" + ); + ok(pinToToolbar, "expected a 'Pin to Toolbar' menu item"); + // Passing the `pinToToolbar` item to `closeChromeContextMenu()` will + // activate it before closing the context menu. + await closeChromeContextMenu(contextMenu.id, pinToToolbar); + + ok( + unifiedButton.hasAttribute("attention"), + "expected attention attribute on the UEB" + ); + // We still expect the attention dot on the extension. + ok( + extensionWidget.hasAttribute("attention"), + "expected attention attribute on the extension widget" + ); + + // Now let's open the unified extensions panel, and pin the same extension + // to the toolbar, which should hide the attention dot on the UEB again. + await openExtensionsPanel(); + contextMenu = await openUnifiedExtensionsContextMenu(extension.id); + pinToToolbar = contextMenu.querySelector( + ".unified-extensions-context-menu-pin-to-toolbar" + ); + ok(pinToToolbar, "expected a 'Pin to Toolbar' menu item"); + const hidden = BrowserTestUtils.waitForEvent( + gUnifiedExtensions.panel, + "popuphidden", + true + ); + contextMenu.activateItem(pinToToolbar); + await hidden; + + ok( + !unifiedButton.hasAttribute("attention"), + "expected no attention attribute on the UEB" + ); + // We still expect the attention dot on the extension. + ok( + extensionWidget.hasAttribute("attention"), + "expected attention attribute on the extension widget" + ); + }); + + await extension.unload(); +}); + +async function testWithSubmenu(menu, nextItemClassName) { + function expectMenuItems() { + info("Checking expected menu items."); + let [submenu, sep1, ocMessage, sep2, next] = menu.children; + + is(submenu.tagName, "menu", "First item is a submenu."); + is(submenu.label, "submenu", "Submenu has the expected label."); + is(sep1.tagName, "menuseparator", "Second item is a separator."); + + let l10n = menu.ownerDocument.l10n.getAttributes(ocMessage); + is(ocMessage.tagName, "menuitem", "Third is origin controls message."); + is(l10n.id, "origin-controls-no-access", "Expected l10n id."); + + is(sep2.tagName, "menuseparator", "Fourth item is a separator."); + is(next.className, nextItemClassName, "All items accounted for."); + } + + const [submenu] = menu.children; + const popup = submenu.querySelector("menupopup"); + + // Open and close the submenu repeatedly a few times. + for (let i = 0; i < 3; i++) { + expectMenuItems(); + + const popupShown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + submenu.openMenu(true); + await popupShown; + + expectMenuItems(); + + const popupHidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + submenu.openMenu(false); + await popupHidden; + } + + menu.hidePopup(); +} + +add_task(async function test_originControls_with_submenus() { + let extension = await makeExtension({ + id: "submenus@test", + permissions: ["menus"], + }); + + await BrowserTestUtils.withNewTab("about:blank", async () => { + info(`Testing with submenus.`); + moveWidget(extension, true); + let target = `#${CSS.escape(makeWidgetId(extension.id))}-BAP`; + + await testWithSubmenu( + await openChromeContextMenu("toolbar-context-menu", target), + "customize-context-manageExtension" + ); + + info(`Testing with submenus inside extensions panel.`); + moveWidget(extension, false); + await openExtensionsPanel(); + + await testWithSubmenu( + await openUnifiedExtensionsContextMenu(extension.id), + "unified-extensions-context-menu-pin-to-toolbar" + ); + + await closeExtensionsPanel(); + }); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_pageAction_activeTab.js b/browser/components/extensions/test/browser/browser_ext_pageAction_activeTab.js new file mode 100644 index 0000000000..4ce6b247bf --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_activeTab.js @@ -0,0 +1,107 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_middle_click_with_activeTab() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: {}, + permissions: ["activeTab"], + }, + + async background() { + browser.pageAction.onClicked.addListener(async (tab, info) => { + browser.test.assertEq(1, info.button, "Expected button value"); + browser.test.assertEq( + "https://example.com/", + tab.url, + "tab.url has the expected url" + ); + await browser.tabs.insertCSS(tab.id, { + code: "body { border: 20px solid red; }", + }); + browser.test.sendMessage("onClick"); + }); + + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tab.id); + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + let ext = WebExtensionPolicy.getByID(extension.id).extension; + is( + ext.tabManager.hasActiveTabPermission(tab), + false, + "Active tab was not granted permission" + ); + + await clickPageAction(extension, window, { button: 1 }); + await extension.awaitMessage("onClick"); + + is( + ext.tabManager.hasActiveTabPermission(tab), + true, + "Active tab was granted permission" + ); + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_middle_click_without_activeTab() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: {}, + }, + + async background() { + browser.pageAction.onClicked.addListener(async (tab, info) => { + browser.test.assertEq(1, info.button, "Expected button value"); + browser.test.assertEq(tab.url, undefined, "tab.url is undefined"); + await browser.test.assertRejects( + browser.tabs.insertCSS(tab.id, { + code: "body { border: 20px solid red; }", + }), + "Missing host permission for the tab", + "expected failure of tabs.insertCSS without permission" + ); + browser.test.sendMessage("onClick"); + }); + + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tab.id); + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + await clickPageAction(extension, window, { button: 1 }); + await extension.awaitMessage("onClick"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_pageAction_click_types.js b/browser/components/extensions/test/browser/browser_ext_pageAction_click_types.js new file mode 100644 index 0000000000..0168ea0ab2 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_click_types.js @@ -0,0 +1,240 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_setup(async function () { + // The page action button is hidden by default. + // This tests the use of pageAction when the button is visible. + // + // TODO(Bug 1704171): this should technically be removed in a follow up + // and the tests in this file adapted to keep into account that: + // - The pageAction is pinned on the urlbar by default + // when shown, and hidden when is not available (same for the + // overflow menu when enabled) + BrowserPageActions.mainButtonNode.style.visibility = "visible"; + registerCleanupFunction(() => { + BrowserPageActions.mainButtonNode.style.removeProperty("visibility"); + }); +}); + +async function test_clickData(testAsNonPersistent = false) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: {}, + background: { + persistent: !testAsNonPersistent, + scripts: ["background.js"], + }, + }, + + files: { + "background.js": async function background() { + function onClicked(_tab, info) { + let button = info.button; + let modifiers = info.modifiers; + browser.test.sendMessage("onClick", { button, modifiers }); + } + + browser.pageAction.onClicked.addListener(onClicked); + + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tab.id); + browser.test.sendMessage("ready"); + }, + }, + }); + + const map = { + shiftKey: "Shift", + altKey: "Alt", + metaKey: "Command", + ctrlKey: "Ctrl", + }; + + function assertSingleModifier(info, modifier) { + if (modifier === "ctrlKey" && AppConstants.platform === "macosx") { + is( + info.modifiers.length, + 2, + `MacCtrl modifier with control click on Mac` + ); + is( + info.modifiers[1], + "MacCtrl", + `MacCtrl modifier with control click on Mac` + ); + } else { + is( + info.modifiers.length, + 1, + `No unnecessary modifiers for exactly one key on event` + ); + } + + is(info.modifiers[0], map[modifier], `Correct modifier on click event`); + } + + async function testClickPageAction(doClick, doEnterKey) { + for (let modifier of Object.keys(map)) { + for (let i = 0; i < 2; i++) { + let clickEventData = { button: i }; + clickEventData[modifier] = true; + await doClick(extension, window, clickEventData); + let info = await extension.awaitMessage("onClick"); + + is(info.button, i, `Correct button on click event`); + assertSingleModifier(info, modifier); + } + + let keypressEventData = {}; + keypressEventData[modifier] = true; + await doEnterKey(extension, keypressEventData); + let info = await extension.awaitMessage("onClick"); + + is(info.button, 0, `Key command emulates left click`); + assertSingleModifier(info, modifier); + } + } + + await extension.startup(); + await extension.awaitMessage("ready"); + + if (testAsNonPersistent) { + assertPersistentListeners(extension, "pageAction", "onClicked", { + primed: false, + }); + info("Terminating the background event page"); + await extension.terminateBackground(); + assertPersistentListeners(extension, "pageAction", "onClicked", { + primed: true, + }); + } + + info("Clicking the pageAction"); + await testClickPageAction(clickPageAction, triggerPageActionWithKeyboard); + + if (testAsNonPersistent) { + await extension.awaitMessage("ready"); + assertPersistentListeners(extension, "pageAction", "onClicked", { + primed: false, + }); + } + + await testClickPageAction( + clickPageActionInPanel, + triggerPageActionWithKeyboardInPanel + ); + + await extension.unload(); +} + +async function test_clickData_reset(testAsNonPersistent = false) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_area: "navbar", + }, + page_action: {}, + background: { + persistent: !testAsNonPersistent, + scripts: ["background.js"], + }, + }, + + files: { + "background.js": async function background() { + function onBrowserActionClicked(tab, info) { + // openPopup requires user interaction, such as a browser action click. + browser.pageAction.openPopup(); + } + + function onPageActionClicked(tab, info) { + browser.test.sendMessage("onClick", info); + } + + browser.browserAction.onClicked.addListener(onBrowserActionClicked); + browser.pageAction.onClicked.addListener(onPageActionClicked); + + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tab.id); + browser.test.sendMessage("ready"); + }, + }, + }); + + async function clickPageActionWithModifiers() { + await clickPageAction(extension, window, { button: 1, shiftKey: true }); + let info = await extension.awaitMessage("onClick"); + is(info.button, 1); + is(info.modifiers[0], "Shift"); + } + + function assertInfoReset(info) { + is(info.button, 0, `ClickData button reset properly`); + is(info.modifiers.length, 0, `ClickData modifiers reset properly`); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + + if (testAsNonPersistent) { + assertPersistentListeners(extension, "pageAction", "onClicked", { + primed: false, + }); + info("Terminating the background event page"); + await extension.terminateBackground(); + assertPersistentListeners(extension, "pageAction", "onClicked", { + primed: true, + }); + } + + info("Clicking the pageAction"); + await clickPageActionWithModifiers(); + + if (testAsNonPersistent) { + await extension.awaitMessage("ready"); + assertPersistentListeners(extension, "pageAction", "onClicked", { + primed: false, + }); + } + + await clickBrowserAction(extension); + assertInfoReset(await extension.awaitMessage("onClick")); + + await clickPageActionWithModifiers(); + + await triggerPageActionWithKeyboard(extension); + assertInfoReset(await extension.awaitMessage("onClick")); + + await extension.unload(); +} + +add_task(function test_clickData_MV2() { + return test_clickData(/* testAsNonPersistent */ false); +}); + +add_task(async function test_clickData_MV2_eventPage() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.eventPages.enabled", true]], + }); + await test_clickData(/* testAsNonPersistent */ true); + await SpecialPowers.popPrefEnv(); +}); + +add_task(function test_clickData_reset_MV2() { + return test_clickData_reset(/* testAsNonPersistent */ false); +}); + +add_task(async function test_clickData_reset_MV2_eventPage() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.eventPages.enabled", true]], + }); + await test_clickData_reset(/* testAsNonPersistent */ true); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_pageAction_context.js b/browser/components/extensions/test/browser/browser_ext_pageAction_context.js new file mode 100644 index 0000000000..fde45cf2f5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_context.js @@ -0,0 +1,453 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_pageAction.js"); + +add_task(async function testTabSwitchContext() { + await runTests({ + manifest: { + name: "Foo Extension", + + page_action: { + default_icon: "default.png", + default_popup: "__MSG_popup__", + default_title: "Default __MSG_title__ \u263a", + }, + + default_locale: "en", + + permissions: ["tabs"], + }, + + files: { + "_locales/en/messages.json": { + popup: { + message: "default.html", + description: "Popup", + }, + + title: { + message: "Title", + description: "Title", + }, + }, + + "_locales/es_ES/messages.json": { + popup: { + message: "default.html", + description: "Popup", + }, + + title: { + message: "T\u00edtulo", + description: "Title", + }, + }, + + "default.png": imageBuffer, + "1.png": imageBuffer, + "2.png": imageBuffer, + }, + + getTests: function (tabs) { + let defaultIcon = "chrome://mozapps/skin/extensions/extensionGeneric.svg"; + let details = [ + { + icon: browser.runtime.getURL("default.png"), + popup: browser.runtime.getURL("default.html"), + title: "Default T\u00edtulo \u263a", + }, + { + icon: browser.runtime.getURL("1.png"), + popup: browser.runtime.getURL("default.html"), + title: "Default T\u00edtulo \u263a", + }, + { + icon: browser.runtime.getURL("2.png"), + popup: browser.runtime.getURL("2.html"), + title: "Title 2", + }, + { icon: defaultIcon, popup: "", title: "" }, + ]; + + let promiseTabLoad = details => { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener(tabId, changed) { + if (tabId == details.id && changed.url == details.url) { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + }; + + return [ + expect => { + browser.test.log("Initial state. No icon visible."); + expect(null); + }, + async expect => { + browser.test.log( + "Show the icon on the first tab, expect default properties." + ); + await browser.pageAction.show(tabs[0]); + expect(details[0]); + }, + expect => { + browser.test.log( + "Change the icon. Expect default properties excluding the icon." + ); + browser.pageAction.setIcon({ tabId: tabs[0], path: "1.png" }); + expect(details[1]); + }, + async expect => { + browser.test.log("Create a new tab. No icon visible."); + let tab = await browser.tabs.create({ + active: true, + url: "about:blank?0", + }); + tabs.push(tab.id); + expect(null); + }, + async expect => { + browser.test.log("Await tab load. No icon visible."); + let promise = promiseTabLoad({ id: tabs[1], url: "about:blank?0" }); + let { url } = await browser.tabs.get(tabs[1]); + if (url === "about:blank") { + await promise; + } + expect(null); + }, + async expect => { + browser.test.log("Change properties. Expect new properties."); + let tabId = tabs[1]; + await browser.pageAction.show(tabId); + + browser.pageAction.setIcon({ tabId, path: "2.png" }); + browser.pageAction.setPopup({ tabId, popup: "2.html" }); + browser.pageAction.setTitle({ tabId, title: "Title 2" }); + + expect(details[2]); + }, + async expect => { + browser.test.log("Change the hash. Expect same properties."); + + let promise = promiseTabLoad({ + id: tabs[1], + url: "about:blank?0#ref", + }); + browser.tabs.update(tabs[1], { url: "about:blank?0#ref" }); + await promise; + + expect(details[2]); + }, + expect => { + browser.test.log( + "Set empty string values. Expect empty strings but default icon." + ); + browser.pageAction.setIcon({ tabId: tabs[1], path: "" }); + browser.pageAction.setPopup({ tabId: tabs[1], popup: "" }); + browser.pageAction.setTitle({ tabId: tabs[1], title: "" }); + + expect(details[3]); + }, + expect => { + browser.test.log("Clear the values. Expect default ones."); + browser.pageAction.setIcon({ tabId: tabs[1], path: null }); + browser.pageAction.setPopup({ tabId: tabs[1], popup: null }); + browser.pageAction.setTitle({ tabId: tabs[1], title: null }); + + expect(details[0]); + }, + async expect => { + browser.test.log("Navigate to a new page. Expect icon hidden."); + + // TODO: This listener should not be necessary, but the |tabs.update| + // callback currently fires too early in e10s windows. + let promise = promiseTabLoad({ id: tabs[1], url: "about:blank?1" }); + + browser.tabs.update(tabs[1], { url: "about:blank?1" }); + + await promise; + expect(null); + }, + async expect => { + browser.test.log("Show the icon. Expect default properties again."); + + await browser.pageAction.show(tabs[1]); + expect(details[0]); + }, + async expect => { + browser.test.log( + "Switch back to the first tab. Expect previously set properties." + ); + await browser.tabs.update(tabs[0], { active: true }); + expect(details[1]); + }, + async expect => { + browser.test.log( + "Hide the icon on tab 2. Switch back, expect hidden." + ); + await browser.pageAction.hide(tabs[1]); + + await browser.tabs.update(tabs[1], { active: true }); + expect(null); + }, + async expect => { + browser.test.log( + "Switch back to tab 1. Expect previous results again." + ); + await browser.tabs.remove(tabs[1]); + expect(details[1]); + }, + async expect => { + browser.test.log("Hide the icon. Expect hidden."); + + await browser.pageAction.hide(tabs[0]); + expect(null); + }, + async expect => { + browser.test.assertRejects( + browser.pageAction.setPopup({ + tabId: tabs[0], + popup: "about:addons", + }), + /Access denied for URL about:addons/, + "unable to set popup to about:addons" + ); + + expect(null); + }, + ]; + }, + }); +}); + +add_task(async function testMultipleWindows() { + // Disable newtab preloading, so that the tabs.create call below will always + // trigger a new load that can be detected by webNavigation.onCompleted. + await SpecialPowers.pushPrefEnv({ + set: [["browser.newtab.preload", false]], + }); + await runTests({ + manifest: { + page_action: { + default_icon: "default.png", + default_popup: "default.html", + default_title: "Default Title", + }, + permissions: ["webNavigation"], + }, + + files: { + "default.png": imageBuffer, + "tab.png": imageBuffer, + }, + + getTests: function (tabs, windows) { + let details = [ + { + icon: browser.runtime.getURL("default.png"), + popup: browser.runtime.getURL("default.html"), + title: "Default Title", + }, + { + icon: browser.runtime.getURL("tab.png"), + popup: browser.runtime.getURL("tab.html"), + title: "tab", + }, + ]; + + function promiseWebNavigationCompleted(url) { + return new Promise(resolve => { + // The pageAction visibility state is reset when the location changes. + // The webNavigation.onCompleted event is triggered when that happens. + browser.webNavigation.onCompleted.addListener( + function listener() { + browser.webNavigation.onCompleted.removeListener(listener); + resolve(); + }, + { + url: [{ urlEquals: url }], + } + ); + }); + } + + return [ + async expect => { + browser.test.log("Create a new tab, expect hidden pageAction."); + let promise = promiseWebNavigationCompleted("about:newtab"); + let tab = await browser.tabs.create({ active: true }); + await promise; + tabs.push(tab.id); + expect(null); + }, + async expect => { + browser.test.log("Show the pageAction, expect default values."); + await browser.pageAction.show(tabs[1]); + expect(details[0]); + }, + async expect => { + browser.test.log("Set tab-specific values, expect them."); + await browser.pageAction.setIcon({ tabId: tabs[1], path: "tab.png" }); + await browser.pageAction.setPopup({ + tabId: tabs[1], + popup: "tab.html", + }); + await browser.pageAction.setTitle({ tabId: tabs[1], title: "tab" }); + expect(details[1]); + }, + async expect => { + browser.test.log("Open a new window, expect hidden pageAction."); + let { id } = await browser.windows.create(); + windows.push(id); + expect(null); + }, + async expect => { + browser.test.log( + "Move tab from old window to the new one, expect old values." + ); + await browser.tabs.move(tabs[1], { windowId: windows[1], index: -1 }); + await browser.tabs.update(tabs[1], { active: true }); + expect(details[1]); + }, + async expect => { + browser.test.log("Close the initial tab of the new window."); + let [{ id }] = await browser.tabs.query({ + windowId: windows[1], + index: 0, + }); + await browser.tabs.remove(id); + expect(details[1]); + }, + async expect => { + browser.test.log( + "Move the previous tab to a 3rd window, the 2nd one will close." + ); + await browser.windows.create({ tabId: tabs[1] }); + expect(details[1]); + }, + async expect => { + browser.test.log("Close the tab, go back to the 1st window."); + await browser.tabs.remove(tabs[1]); + expect(null); + }, + ]; + }, + }); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function testNavigationClearsData() { + let url = "http://example.com/"; + let default_title = "Default title"; + let tab_title = "Tab title"; + + const { + Management: { + global: { tabTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + let extension, + tabs = []; + async function addTab(...args) { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, ...args); + tabs.push(tab); + return tab; + } + async function sendMessage(method, param, expect, msg) { + extension.sendMessage({ method, param, expect, msg }); + await extension.awaitMessage("done"); + } + async function expectTabSpecificData(tab, msg) { + let tabId = tabTracker.getId(tab); + await sendMessage("isShown", { tabId }, true, msg); + await sendMessage("getTitle", { tabId }, tab_title, msg); + } + async function expectDefaultData(tab, msg) { + let tabId = tabTracker.getId(tab); + await sendMessage("isShown", { tabId }, false, msg); + await sendMessage("getTitle", { tabId }, default_title, msg); + } + async function setTabSpecificData(tab) { + let tabId = tabTracker.getId(tab); + await expectDefaultData( + tab, + "Expect default data before setting tab-specific data." + ); + await sendMessage("show", tabId); + await sendMessage("setTitle", { tabId, title: tab_title }); + await expectTabSpecificData( + tab, + "Expect tab-specific data after setting it." + ); + } + + info("Load a tab before installing the extension"); + let tab1 = await addTab(url, true, true); + + extension = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: { default_title }, + }, + background: function () { + browser.test.onMessage.addListener( + async ({ method, param, expect, msg }) => { + let result = await browser.pageAction[method](param); + if (expect !== undefined) { + browser.test.assertEq(expect, result, msg); + } + browser.test.sendMessage("done"); + } + ); + }, + }); + await extension.startup(); + + info("Set tab-specific data to the existing tab."); + await setTabSpecificData(tab1); + + info("Add a hash. Does not cause navigation."); + await navigateTab(tab1, url + "#hash"); + await expectTabSpecificData( + tab1, + "Adding a hash does not clear tab-specific data" + ); + + info("Remove the hash. Causes navigation."); + await navigateTab(tab1, url); + await expectDefaultData(tab1, "Removing hash clears tab-specific data"); + + info("Open a new tab, set tab-specific data to it."); + let tab2 = await addTab("about:newtab", false, false); + await setTabSpecificData(tab2); + + info("Load a page in that tab."); + await navigateTab(tab2, url); + await expectDefaultData(tab2, "Loading a page clears tab-specific data."); + + info("Set tab-specific data."); + await setTabSpecificData(tab2); + + info("Push history state. Does not cause navigation."); + await historyPushState(tab2, url + "/path"); + await expectTabSpecificData( + tab2, + "history.pushState() does not clear tab-specific data" + ); + + info("Navigate when the tab is not selected"); + gBrowser.selectedTab = tab1; + await navigateTab(tab2, url); + await expectDefaultData( + tab2, + "Navigating clears tab-specific data, even when not selected." + ); + + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_pageAction_contextMenu.js b/browser/components/extensions/test/browser/browser_ext_pageAction_contextMenu.js new file mode 100644 index 0000000000..57ecc889ae --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_contextMenu.js @@ -0,0 +1,128 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +let extData = { + manifest: { + permissions: ["contextMenus"], + page_action: { + default_popup: "popup.html", + }, + }, + useAddonManager: "temporary", + + files: { + "popup.html": ` + + + + + + A Test Popup + + + `, + }, + + background: function () { + browser.contextMenus.create({ + id: "clickme-page", + title: "Click me!", + contexts: ["all"], + }); + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + const tabId = tabs[0].id; + + browser.pageAction.show(tabId).then(() => { + browser.test.sendMessage("action-shown"); + }); + }); + }, +}; + +let contextMenuItems = { + "context-sep-navigation": "hidden", + "context-viewsource": "", + "inspect-separator": "hidden", + "context-inspect": "hidden", + "context-inspect-a11y": "hidden", + "context-bookmarkpage": "hidden", +}; +if (AppConstants.platform == "macosx") { + contextMenuItems["context-back"] = "hidden"; + contextMenuItems["context-forward"] = "hidden"; + contextMenuItems["context-reload"] = "hidden"; + contextMenuItems["context-stop"] = "hidden"; +} else { + contextMenuItems["context-navigation"] = "hidden"; +} + +add_task(async function pageaction_popup_contextmenu() { + let extension = ExtensionTestUtils.loadExtension(extData); + await extension.startup(); + await extension.awaitMessage("action-shown"); + + await clickPageAction(extension, window); + + let contentAreaContextMenu = await openContextMenuInPopup(extension); + let item = contentAreaContextMenu.getElementsByAttribute( + "label", + "Click me!" + ); + is(item.length, 1, "contextMenu item for page was found"); + await closeContextMenu(contentAreaContextMenu); + + await extension.unload(); +}); + +add_task(async function pageaction_popup_contextmenu_hidden_items() { + let extension = ExtensionTestUtils.loadExtension(extData); + await extension.startup(); + await extension.awaitMessage("action-shown"); + + await clickPageAction(extension, window); + + let contentAreaContextMenu = await openContextMenuInPopup(extension, "#text"); + + let item, state; + for (const itemID in contextMenuItems) { + item = contentAreaContextMenu.querySelector(`#${itemID}`); + state = contextMenuItems[itemID]; + + if (state !== "") { + ok(item[state], `${itemID} is ${state}`); + + if (state !== "hidden") { + ok(!item.hidden, `Disabled ${itemID} is not hidden`); + } + } else { + ok(!item.hidden, `${itemID} is not hidden`); + ok(!item.disabled, `${itemID} is not disabled`); + } + } + + await closeContextMenu(contentAreaContextMenu); + + await extension.unload(); +}); + +add_task(async function pageaction_popup_image_contextmenu() { + let extension = ExtensionTestUtils.loadExtension(extData); + await extension.startup(); + await extension.awaitMessage("action-shown"); + + await clickPageAction(extension, window); + + let contentAreaContextMenu = await openContextMenuInPopup( + extension, + "#testimg" + ); + + let item = contentAreaContextMenu.querySelector("#context-copyimage"); + ok(!item.hidden); + ok(!item.disabled); + + await closeContextMenu(contentAreaContextMenu); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_pageAction_popup.js b/browser/components/extensions/test/browser/browser_ext_pageAction_popup.js new file mode 100644 index 0000000000..366827dd1b --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_popup.js @@ -0,0 +1,305 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +PromiseTestUtils.allowMatchingRejectionsGlobally(/packaging errors/); + +function assertViewCount(extension, count) { + let ext = WebExtensionPolicy.getByID(extension.id).extension; + is( + ext.views.size, + count, + "Should have the expected number of extension views" + ); +} + +add_task(async function testPageActionPopup() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + + let scriptPage = url => + ``; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { + page: "data/background.html", + }, + page_action: { + default_popup: "popup-a.html", + }, + }, + + files: { + "popup-a.html": scriptPage("popup-a.js"), + "popup-a.js": function () { + window.onload = () => { + let background = window.getComputedStyle( + document.body + ).backgroundColor; + browser.test.assertEq("rgba(0, 0, 0, 0)", background); + browser.runtime.sendMessage("from-popup-a"); + }; + browser.runtime.onMessage.addListener(msg => { + if (msg == "close-popup") { + window.close(); + } + }); + }, + + "data/popup-b.html": scriptPage("popup-b.js"), + "data/popup-b.js": function () { + browser.runtime.sendMessage("from-popup-b"); + }, + + "data/background.html": scriptPage("background.js"), + + "data/background.js": async function () { + let tabId; + + let sendClick; + let tests = [ + () => { + sendClick({ expectEvent: false, expectPopup: "a" }); + }, + () => { + sendClick({ expectEvent: false, expectPopup: "a" }); + }, + () => { + browser.pageAction.setPopup({ tabId, popup: "popup-b.html" }); + sendClick({ expectEvent: false, expectPopup: "b" }); + }, + () => { + sendClick({ expectEvent: false, expectPopup: "b" }); + }, + () => { + sendClick({ + expectEvent: true, + expectPopup: "b", + middleClick: true, + }); + }, + () => { + browser.pageAction.setPopup({ tabId, popup: "" }); + sendClick({ expectEvent: true, expectPopup: null }); + }, + () => { + sendClick({ expectEvent: true, expectPopup: null }); + }, + () => { + browser.pageAction.setPopup({ tabId, popup: "/popup-a.html" }); + sendClick({ + expectEvent: false, + expectPopup: "a", + runNextTest: true, + }); + }, + () => { + browser.test.sendMessage("next-test", { expectClosed: true }); + }, + () => { + sendClick({ + expectEvent: false, + expectPopup: "a", + runNextTest: true, + }); + }, + () => { + browser.test.sendMessage("next-test", { closeOnTabSwitch: true }); + }, + ]; + + let expect = {}; + sendClick = ({ + expectEvent, + expectPopup, + runNextTest, + middleClick, + }) => { + expect = { event: expectEvent, popup: expectPopup, runNextTest }; + + browser.test.sendMessage("send-click", middleClick ? 1 : 0); + }; + + browser.runtime.onMessage.addListener(msg => { + if (msg == "close-popup") { + return; + } else if (expect.popup) { + browser.test.assertEq( + msg, + `from-popup-${expect.popup}`, + "expected popup opened" + ); + } else { + browser.test.fail(`unexpected popup: ${msg}`); + } + + expect.popup = null; + if (expect.runNextTest) { + expect.runNextTest = false; + tests.shift()(); + } else { + browser.test.sendMessage("next-test"); + } + }); + + browser.pageAction.onClicked.addListener((tab, info) => { + if (expect.event) { + browser.test.succeed("expected click event received"); + } else { + browser.test.fail("unexpected click event"); + } + expect.event = false; + + if (info.button == 1) { + browser.pageAction.openPopup(); + return; + } + + browser.test.sendMessage("next-test"); + }); + + browser.test.onMessage.addListener(msg => { + if (msg == "close-popup") { + browser.runtime.sendMessage("close-popup"); + return; + } + + if (msg != "next-test") { + browser.test.fail("Expecting 'next-test' message"); + } + + if (expect.event) { + browser.test.fail( + "Expecting click event before next test but none occurred" + ); + } + + if (expect.popup) { + browser.test.fail( + "Expecting popup before next test but none were shown" + ); + } + + if (tests.length) { + let test = tests.shift(); + test(); + } else { + browser.test.notifyPass("pageaction-tests-done"); + } + }); + + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + tabId = tab.id; + + await browser.pageAction.show(tabId); + browser.test.sendMessage("next-test"); + }, + }, + }); + + extension.onMessage("send-click", button => { + clickPageAction(extension, window, { button }); + }); + + let pageActionId, panelId; + extension.onMessage("next-test", async function (expecting = {}) { + pageActionId = `${makeWidgetId(extension.id)}-page-action`; + panelId = `${makeWidgetId(extension.id)}-panel`; + let panel = document.getElementById(panelId); + if (expecting.expectClosed) { + ok(panel, "Expect panel to exist"); + await promisePopupShown(panel); + + extension.sendMessage("close-popup"); + + await promisePopupHidden(panel); + ok(true, `Panel is closed`); + } else if (expecting.closeOnTabSwitch) { + ok(panel, "Expect panel to exist"); + await promisePopupShown(panel); + + let oldTab = gBrowser.selectedTab; + Assert.notEqual( + oldTab, + gBrowser.tabs[0], + "Should have an inactive tab to switch to" + ); + + let hiddenPromise = promisePopupHidden(panel); + + gBrowser.selectedTab = gBrowser.tabs[0]; + await hiddenPromise; + info("Panel closed"); + + gBrowser.selectedTab = oldTab; + } else if (panel) { + await promisePopupShown(panel); + panel.hidePopup(); + } + + assertViewCount(extension, 1); + + if (panel) { + panel = document.getElementById(panelId); + is(panel, null, "panel successfully removed from document after hiding"); + } + + extension.sendMessage("next-test"); + }); + + await extension.startup(); + await extension.awaitFinish("pageaction-tests-done"); + + await extension.unload(); + + let node = document.getElementById(pageActionId); + is(node, null, "pageAction image removed from document"); + + let panel = document.getElementById(panelId); + is(panel, null, "pageAction panel removed from document"); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function testPageActionSecurity() { + const URL = "chrome://browser/content/browser.xhtml"; + + let apis = ["browser_action", "page_action"]; + + for (let api of apis) { + info(`TEST ${api} icon url: ${URL}`); + + let messages = [/Access to restricted URI denied/]; + + let waitForConsole = new Promise(resolve => { + // Not necessary in browser-chrome tests, but monitorConsole gripes + // if we don't call it. + SimpleTest.waitForExplicitFinish(); + + SimpleTest.monitorConsole(resolve, messages); + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + [api]: { default_popup: URL }, + }, + }); + + await Assert.rejects( + extension.startup(), + /startup failed/, + "Manifest rejected" + ); + + SimpleTest.endMonitorConsole(); + await waitForConsole; + } +}); + +add_task(forceGC); diff --git a/browser/components/extensions/test/browser/browser_ext_pageAction_popup_resize.js b/browser/components/extensions/test/browser/browser_ext_pageAction_popup_resize.js new file mode 100644 index 0000000000..a0b5b8377d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_popup_resize.js @@ -0,0 +1,192 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testPageActionPopupResize() { + let browser; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: { + default_popup: "popup.html", + browser_style: true, + }, + }, + background: function () { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + const tabId = tabs[0].id; + + browser.pageAction.show(tabId).then(() => { + browser.test.sendMessage("action-shown"); + }); + }); + }, + + files: { + "popup.html": `
    `, + }, + }); + + await extension.startup(); + await extension.awaitMessage("action-shown"); + + clickPageAction(extension, window); + + browser = await awaitExtensionPanel(extension); + + async function checkSize(height, width) { + let dims = await promiseContentDimensions(browser); + let { body, root } = dims; + + is( + dims.window.innerHeight, + height, + `Panel window should be ${height}px tall` + ); + is( + body.clientHeight, + body.scrollHeight, + "Panel body should be tall enough to fit its contents" + ); + is( + root.clientHeight, + root.scrollHeight, + "Panel root should be tall enough to fit its contents" + ); + + if (width) { + is( + body.clientWidth, + body.scrollWidth, + "Panel body should be wide enough to fit its contents" + ); + + // Tolerate if it is 1px too wide, as that may happen with the current + // resizing method. + Assert.lessOrEqual( + Math.abs(dims.window.innerWidth - width), + 1, + `Panel window should be ${width}px wide` + ); + } + } + + function setSize(size) { + let elem = content.document.body.firstElementChild; + elem.style.height = `${size}px`; + elem.style.width = `${size}px`; + } + + function setHeight(height) { + content.document.body.style.overflow = "hidden"; + let elem = content.document.body.firstElementChild; + elem.style.height = `${height}px`; + } + + let sizes = [200, 400, 300]; + + for (let size of sizes) { + await alterContent(browser, setSize, size); + await checkSize(size, size); + } + + let dims = await alterContent(browser, setSize, 1400); + let { body, root } = dims; + + is(dims.window.innerWidth, 800, "Panel window width"); + Assert.lessOrEqual( + body.clientWidth, + 800, + `Panel body width ${body.clientWidth} is less than 800` + ); + is(body.scrollWidth, 1400, "Panel body scroll width"); + + is(dims.window.innerHeight, 600, "Panel window height"); + Assert.lessOrEqual( + root.clientHeight, + 600, + `Panel root height (${root.clientHeight}px) is less than 600px` + ); + is(root.scrollHeight, 1400, "Panel root scroll height"); + + for (let size of sizes) { + await alterContent(browser, setHeight, size); + await checkSize(size, null); + } + + await extension.unload(); +}); + +add_task(async function testPageActionPopupReflow() { + let browser; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: { + default_popup: "popup.html", + browser_style: true, + }, + }, + background: function () { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + const tabId = tabs[0].id; + + browser.pageAction.show(tabId).then(() => { + browser.test.sendMessage("action-shown"); + }); + }); + }, + + files: { + "popup.html": ` + + The quick mauve fox jumps over the opalescent toad, with its glowing + eyes, and its vantablack mouth, and its bottomless chasm where you + would hope to find a heart, that looks straight into the deepest + pits of hell. The fox shivers, and cowers, and tries to run, but + the toad is utterly without pity. It turns, ever so slightly... + + `, + }, + }); + + await extension.startup(); + await extension.awaitMessage("action-shown"); + + clickPageAction(extension, window); + + browser = await awaitExtensionPanel(extension); + + function setSize(size) { + content.document.body.style.fontSize = `${size}px`; + } + + let dims = await alterContent(browser, setSize, 18); + + is(dims.window.innerWidth, 800, "Panel window should be 800px wide"); + is(dims.body.clientWidth, 800, "Panel body should be 800px wide"); + is( + dims.body.clientWidth, + dims.body.scrollWidth, + "Panel body should be wide enough to fit its contents" + ); + + Assert.greater( + dims.window.innerHeight, + 36, + `Panel window height (${dims.window.innerHeight}px) should be taller than two lines of text.` + ); + + is( + dims.body.clientHeight, + dims.body.scrollHeight, + "Panel body should be tall enough to fit its contents" + ); + is( + dims.root.clientHeight, + dims.root.scrollHeight, + "Panel root should be tall enough to fit its contents" + ); + + await extension.unload(); +}); 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 new file mode 100644 index 0000000000..fd589acdbd --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_show_matches.js @@ -0,0 +1,329 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +PromiseTestUtils.allowMatchingRejectionsGlobally(/packaging errors/); + +function getExtension(page_action) { + return ExtensionTestUtils.loadExtension({ + manifest: { + page_action, + }, + background: function () { + browser.test.onMessage.addListener( + async ({ method, param, expect, msg }) => { + let result = await browser.pageAction[method](param); + if (expect !== undefined) { + browser.test.assertEq(expect, result, msg); + } + browser.test.sendMessage("done"); + } + ); + }, + }); +} + +async function sendMessage(ext, method, param, expect, msg) { + ext.sendMessage({ method, param, expect, msg }); + await ext.awaitMessage("done"); +} + +let tests = [ + { + name: "Test shown for all_urls", + page_action: { + show_matches: [""], + }, + shown: [true, true, false], + }, + { + name: "Test hide_matches overrides all_urls.", + page_action: { + show_matches: [""], + hide_matches: ["*://mochi.test/*"], + }, + shown: [true, false, false], + }, + { + name: "Test shown only for show_matches.", + page_action: { + show_matches: ["*://mochi.test/*"], + }, + shown: [false, true, false], + }, +]; + +// For some reason about:rights and about:about used to behave differently (maybe +// because only the latter is privileged?) so both should be tested. about:about +// is used in the test as the base tab. +let urls = ["http://example.com/", "http://mochi.test:8888/", "about:rights"]; + +function getId(tab) { + const { + Management: { + global: { tabTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + getId = tabTracker.getId.bind(tabTracker); // eslint-disable-line no-func-assign + return getId(tab); +} + +async function check(extension, tab, expected, msg) { + await promiseAnimationFrame(); + let widgetId = makeWidgetId(extension.id); + let pageActionId = BrowserPageActions.urlbarButtonNodeIDForActionID(widgetId); + is( + gBrowser.selectedTab, + tab, + `tab ${tab.linkedBrowser.currentURI.spec} is selected` + ); + let button = document.getElementById(pageActionId); + // Sometimes we're hidden, sometimes a parent is hidden via css (e.g. about pages) + let hidden = + button === null || + button.hidden || + window.getComputedStyle(button).display == "none"; + is(!hidden, expected, msg + " (computed)"); + await sendMessage( + extension, + "isShown", + { tabId: getId(tab) }, + expected, + msg + " (isShown)" + ); +} + +add_task(async function test_pageAction_default_show_tabs() { + info( + "Check show_matches and hide_matches are respected when opening a new tab or switching to an existing tab." + ); + let switchTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:about", + true, + true + ); + for (let [i, test] of tests.entries()) { + info(`test ${i}: ${test.name}`); + let extension = getExtension(test.page_action); + await extension.startup(); + for (let [j, url] of urls.entries()) { + let expected = test.shown[j]; + let msg = `test ${i} url ${j}: page action is ${ + expected ? "shown" : "hidden" + } for ${url}`; + + info("Check new tab."); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + url, + true, + true + ); + await check(extension, tab, expected, msg + " (new)"); + + info("Check switched tab."); + await BrowserTestUtils.switchTab(gBrowser, switchTab); + await check(extension, switchTab, false, msg + " (about:about)"); + await BrowserTestUtils.switchTab(gBrowser, tab); + await check(extension, tab, expected, msg + " (switched)"); + + BrowserTestUtils.removeTab(tab); + } + await extension.unload(); + } + BrowserTestUtils.removeTab(switchTab); +}); + +add_task(async function test_pageAction_default_show_install() { + info( + "Check show_matches and hide_matches are respected when installing the extension" + ); + for (let [i, test] of tests.entries()) { + info(`test ${i}: ${test.name}`); + for (let expected of [true, false]) { + let j = test.shown.indexOf(expected); + if (j === -1) { + continue; + } + let initialTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + urls[j], + true, + true + ); + let installTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + urls[j], + true, + true + ); + let extension = getExtension(test.page_action); + await extension.startup(); + let msg = `test ${i} url ${j}: page action is ${ + expected ? "shown" : "hidden" + } for ${urls[j]}`; + await check(extension, installTab, expected, msg + " (active)"); + + // initialTab has not been activated after installation, so we have not evaluated whether the page + // action should be shown in it. Check that pageAction.isShown works anyways. + await sendMessage( + extension, + "isShown", + { tabId: getId(initialTab) }, + expected, + msg + " (inactive)" + ); + + BrowserTestUtils.removeTab(initialTab); + BrowserTestUtils.removeTab(installTab); + await extension.unload(); + } + } +}); + +add_task(async function test_pageAction_history() { + info( + "Check match patterns are reevaluated when using history.pushState or navigating" + ); + let url1 = "http://example.com/"; + let url2 = url1 + "path/"; + let extension = getExtension({ + show_matches: [url1], + hide_matches: [url2], + }); + await extension.startup(); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + url1, + true, + true + ); + await check(extension, tab, true, "page action is shown for " + url1); + + info("Use history.pushState to change the URL without navigating"); + await historyPushState(tab, url2); + await check(extension, tab, false, "page action is hidden for " + url2); + + info("Use hide()"); + await sendMessage(extension, "hide", getId(tab)); + await check(extension, tab, false, "page action is still hidden"); + + info("Use history.pushState to revert to first url"); + await historyPushState(tab, url1); + await check( + extension, + tab, + false, + "hide() has more precedence than pattern matching" + ); + + info("Select another tab"); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + url1, + true, + true + ); + + info("Perform navigation in the old tab"); + await navigateTab(tab, url1); + await sendMessage( + extension, + "isShown", + { tabId: getId(tab) }, + true, + "Navigating undoes hide(), even when the tab is not selected." + ); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); + await extension.unload(); +}); + +add_task(async function test_pageAction_all_urls() { + info("Check is not allowed in hide_matches"); + let extension = getExtension({ + show_matches: ["*://mochi.test/*"], + hide_matches: [""], + }); + let rejects = await extension.startup().then( + () => false, + () => true + ); + is(rejects, true, "startup failed"); +}); + +add_task(async function test_pageAction_restrictScheme_false() { + info( + "Check restricted origins are allowed in show_matches for privileged extensions" + ); + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + permissions: ["mozillaAddons", "tabs"], + page_action: { + show_matches: ["about:reader*"], + hide_matches: ["*://*/*"], + }, + }, + background: function () { + browser.tabs.onUpdated.addListener(async (tabId, changeInfo) => { + if (changeInfo.url && changeInfo.url.startsWith("about:reader")) { + browser.test.sendMessage("readerModeEntered"); + } + }); + + browser.test.onMessage.addListener(async msg => { + if (msg !== "enterReaderMode") { + browser.test.fail(`Received unexpected test message: ${msg}`); + return; + } + + browser.tabs.toggleReaderMode(); + }); + }, + }); + + async function expectPageAction(extension, tab, isShown) { + await promiseAnimationFrame(); + let widgetId = makeWidgetId(extension.id); + let pageActionId = + BrowserPageActions.urlbarButtonNodeIDForActionID(widgetId); + let iconEl = document.getElementById(pageActionId); + + if (isShown) { + ok(iconEl && !iconEl.hasAttribute("disabled"), "pageAction is shown"); + } else { + ok( + iconEl == null || iconEl.getAttribute("disabled") == "true", + "pageAction is hidden" + ); + } + } + + const baseUrl = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://example.com" + ); + const url = `${baseUrl}/readerModeArticle.html`; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + url, + true, + true + ); + + await extension.startup(); + + await expectPageAction(extension, tab, false); + + extension.sendMessage("enterReaderMode"); + await extension.awaitMessage("readerModeEntered"); + + await expectPageAction(extension, tab, true); + + BrowserTestUtils.removeTab(tab); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_pageAction_simple.js b/browser/components/extensions/test/browser/browser_ext_pageAction_simple.js new file mode 100644 index 0000000000..dc323afd94 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_simple.js @@ -0,0 +1,213 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const BASE = + "http://example.com/browser/browser/components/extensions/test/browser/"; + +add_task(async function test_pageAction_basic() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: { + default_popup: "popup.html", + unrecognized_property: "with-a-random-value", + }, + }, + + files: { + "popup.html": ` + + + + + `, + + "popup.js": function () { + browser.runtime.sendMessage("from-popup"); + }, + }, + + background: function () { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq(msg, "from-popup", "correct message received"); + browser.test.sendMessage("popup"); + }); + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + let tabId = tabs[0].id; + + browser.pageAction.show(tabId).then(() => { + browser.test.sendMessage("page-action-shown"); + }); + }); + }, + }); + + SimpleTest.waitForExplicitFinish(); + let waitForConsole = new Promise(resolve => { + SimpleTest.monitorConsole(resolve, [ + { + message: + /Reading manifest: Warning processing page_action.unrecognized_property: An unexpected property was found/, + }, + ]); + }); + + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + await extension.awaitMessage("page-action-shown"); + + let elem = await getPageActionButton(extension); + let parent = window.document.getElementById("page-action-buttons"); + is( + elem && elem.parentNode, + parent, + `pageAction pinned to urlbar ${elem.parentNode.getAttribute("id")}` + ); + + clickPageAction(extension); + + await extension.awaitMessage("popup"); + + await extension.unload(); + + SimpleTest.endMonitorConsole(); + await waitForConsole; +}); + +add_task(async function test_pageAction_pinned() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: { + default_popup: "popup.html", + pinned: false, + }, + }, + + files: { + "popup.html": ` + + + + `, + }, + + background: function () { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + let tabId = tabs[0].id; + + browser.pageAction.show(tabId).then(() => { + browser.test.sendMessage("page-action-shown"); + }); + }); + }, + }); + + await extension.startup(); + await extension.awaitMessage("page-action-shown"); + + // There are plenty of tests for the main action button, we just verify + // that we've properly set the pinned value. + // This test used to check that the button was not pinned, but that is no + // longer supported. + // TODO bug 1703537: consider removal of the pinned property. + let action = PageActions.actionForID(makeWidgetId(extension.id)); + ok(action && action.pinnedToUrlbar, "Check pageAction pinning"); + + await extension.unload(); +}); + +add_task(async function test_pageAction_icon_on_subframe_navigation() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: { + default_popup: "popup.html", + }, + }, + + files: { + "popup.html": ` + + + + `, + }, + + background: function () { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + let tabId = tabs[0].id; + + browser.pageAction.show(tabId).then(() => { + browser.test.sendMessage("page-action-shown"); + }); + }); + }, + }); + + await navigateTab( + gBrowser.selectedTab, + "data:text/html,

    Top Level Frame

    " + ); + + await extension.startup(); + await extension.awaitMessage("page-action-shown"); + + const pageActionId = BrowserPageActions.urlbarButtonNodeIDForActionID( + makeWidgetId(extension.id) + ); + + await BrowserTestUtils.waitForCondition(() => { + return document.getElementById(pageActionId); + }, "pageAction is initially visible"); + + info("Create a sub-frame"); + + let subframeURL = `${BASE}#subframe-url-1`; + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [subframeURL], + async url => { + const iframe = this.content.document.createElement("iframe"); + iframe.setAttribute("id", "test-subframe"); + iframe.setAttribute("src", url); + iframe.setAttribute("style", "height: 200px; width: 200px"); + + // Await the initial url to be loaded in the subframe. + await new Promise(resolve => { + iframe.onload = resolve; + this.content.document.body.appendChild(iframe); + }); + } + ); + + await BrowserTestUtils.waitForCondition(() => { + return document.getElementById(pageActionId); + }, "pageAction should be visible when a subframe is created"); + + info("Navigating the sub-frame"); + + subframeURL = `${BASE}/file_dummy.html#subframe-url-2`; + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [subframeURL], + async url => { + const iframe = this.content.document.querySelector( + "iframe#test-subframe" + ); + + // Await the subframe navigation. + await new Promise(resolve => { + iframe.onload = resolve; + iframe.setAttribute("src", url); + }); + } + ); + + info("Subframe location changed"); + + await BrowserTestUtils.waitForCondition(() => { + return document.getElementById(pageActionId); + }, "pageAction should be visible after a subframe navigation"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_pageAction_telemetry.js b/browser/components/extensions/test/browser/browser_ext_pageAction_telemetry.js new file mode 100644 index 0000000000..fe14a5e6ac --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_telemetry.js @@ -0,0 +1,228 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const HISTOGRAM = "WEBEXT_PAGEACTION_POPUP_OPEN_MS"; +const HISTOGRAM_KEYED = "WEBEXT_PAGEACTION_POPUP_OPEN_MS_BY_ADDONID"; + +const EXTENSION_ID1 = "@test-extension1"; +const EXTENSION_ID2 = "@test-extension2"; + +function snapshotCountsSum(snapshot) { + return Object.values(snapshot.values).reduce((a, b) => a + b, 0); +} + +function histogramCountsSum(histogram) { + return snapshotCountsSum(histogram.snapshot()); +} + +function gleanMetricSamplesCount(gleanMetric) { + return snapshotCountsSum(gleanMetric.testGetValue() ?? { values: {} }); +} + +add_task(async function testPageActionTelemetry() { + let extensionOptions = { + manifest: { + page_action: { + default_popup: "popup.html", + browser_style: true, + }, + }, + background: function () { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + const tabId = tabs[0].id; + + browser.pageAction.show(tabId).then(() => { + browser.test.sendMessage("action-shown"); + }); + }); + }, + + files: { + "popup.html": `
    `, + }, + }; + let extension1 = ExtensionTestUtils.loadExtension({ + ...extensionOptions, + manifest: { + ...extensionOptions.manifest, + browser_specific_settings: { + gecko: { id: EXTENSION_ID1 }, + }, + }, + }); + let extension2 = ExtensionTestUtils.loadExtension({ + ...extensionOptions, + manifest: { + ...extensionOptions.manifest, + browser_specific_settings: { + gecko: { id: EXTENSION_ID2 }, + }, + }, + }); + + let histogram = Services.telemetry.getHistogramById(HISTOGRAM); + let histogramKeyed = + Services.telemetry.getKeyedHistogramById(HISTOGRAM_KEYED); + + histogram.clear(); + histogramKeyed.clear(); + Services.fog.testResetFOG(); + + is( + histogramCountsSum(histogram), + 0, + `No data recorded for histogram: ${HISTOGRAM}.` + ); + is( + Object.keys(histogramKeyed).length, + 0, + `No data recorded for histogram: ${HISTOGRAM_KEYED}.` + ); + Assert.deepEqual( + Glean.extensionsTiming.pageActionPopupOpen.testGetValue(), + undefined, + "No data recorded for glean metric extensionsTiming.pageActionPopupOpen" + ); + + await extension1.startup(); + await extension1.awaitMessage("action-shown"); + await extension2.startup(); + await extension2.awaitMessage("action-shown"); + + is( + histogramCountsSum(histogram), + 0, + `No data recorded for histogram after PageAction shown: ${HISTOGRAM}.` + ); + is( + Object.keys(histogramKeyed).length, + 0, + `No data recorded for histogram after PageAction shown: ${HISTOGRAM_KEYED}.` + ); + is( + gleanMetricSamplesCount(Glean.extensionsTiming.pageActionPopupOpen), + 0, + "No data recorded for glean metric extensionsTiming.pageActionPopupOpen" + ); + + clickPageAction(extension1, window); + await awaitExtensionPanel(extension1); + + is( + histogramCountsSum(histogram), + 1, + `Data recorded for first extension for histogram: ${HISTOGRAM}.` + ); + is( + gleanMetricSamplesCount(Glean.extensionsTiming.pageActionPopupOpen), + 1, + `Data recorded for first extension on Glean metric extensionsTiming.pageActionPopupOpen` + ); + + let keyedSnapshot = histogramKeyed.snapshot(); + Assert.deepEqual( + Object.keys(keyedSnapshot), + [EXTENSION_ID1], + `Data recorded for first extension histogram: ${HISTOGRAM_KEYED}.` + ); + is( + snapshotCountsSum(keyedSnapshot[EXTENSION_ID1]), + 1, + `Data recorded for first extension for histogram: ${HISTOGRAM_KEYED}.` + ); + + await closePageAction(extension1, window); + + clickPageAction(extension2, window); + await awaitExtensionPanel(extension2); + + is( + histogramCountsSum(histogram), + 2, + `Data recorded for second extension for histogram: ${HISTOGRAM}.` + ); + is( + gleanMetricSamplesCount(Glean.extensionsTiming.pageActionPopupOpen), + 2, + `Data recorded for second extension on Glean metric extensionsTiming.pageActionPopupOpen` + ); + + keyedSnapshot = histogramKeyed.snapshot(); + Assert.deepEqual( + Object.keys(keyedSnapshot).sort(), + [EXTENSION_ID1, EXTENSION_ID2], + `Data recorded for second extension histogram: ${HISTOGRAM_KEYED}.` + ); + is( + snapshotCountsSum(keyedSnapshot[EXTENSION_ID2]), + 1, + `Data recorded for second extension for histogram: ${HISTOGRAM_KEYED}.` + ); + is( + snapshotCountsSum(keyedSnapshot[EXTENSION_ID1]), + 1, + `Data recorded for first extension should not change for histogram: ${HISTOGRAM_KEYED}.` + ); + + await closePageAction(extension2, window); + + clickPageAction(extension2, window); + await awaitExtensionPanel(extension2); + + is( + histogramCountsSum(histogram), + 3, + `Data recorded for second opening of popup for histogram: ${HISTOGRAM}.` + ); + is( + gleanMetricSamplesCount(Glean.extensionsTiming.pageActionPopupOpen), + 3, + `Data recorded for second opening popup on Glean metric extensionsTiming.pageActionPopupOpen` + ); + + keyedSnapshot = histogramKeyed.snapshot(); + is( + snapshotCountsSum(keyedSnapshot[EXTENSION_ID2]), + 2, + `Data recorded for second opening of popup for histogram: ${HISTOGRAM_KEYED}.` + ); + is( + snapshotCountsSum(keyedSnapshot[EXTENSION_ID1]), + 1, + `Data recorded for first extension should not change for histogram: ${HISTOGRAM_KEYED}.` + ); + + await closePageAction(extension2, window); + + clickPageAction(extension1, window); + await awaitExtensionPanel(extension1); + + is( + histogramCountsSum(histogram), + 4, + `Data recorded for third opening of popup for histogram: ${HISTOGRAM}.` + ); + is( + gleanMetricSamplesCount(Glean.extensionsTiming.pageActionPopupOpen), + 4, + `Data recorded for third opening popup on Glean metric extensionsTiming.pageActionPopupOpen` + ); + + keyedSnapshot = histogramKeyed.snapshot(); + is( + snapshotCountsSum(keyedSnapshot[EXTENSION_ID1]), + 2, + `Data recorded for second opening of popup for histogram: ${HISTOGRAM_KEYED}.` + ); + is( + snapshotCountsSum(keyedSnapshot[EXTENSION_ID1]), + 2, + `Data recorded for second extension should not change for histogram: ${HISTOGRAM_KEYED}.` + ); + + await closePageAction(extension1, window); + + await extension1.unload(); + await extension2.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_pageAction_title.js b/browser/components/extensions/test/browser/browser_ext_pageAction_title.js new file mode 100644 index 0000000000..feeb0a1419 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_title.js @@ -0,0 +1,275 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_pageAction.js"); + +add_task(async function testTabSwitchContext() { + await runTests({ + manifest: { + name: "Foo Extension", + + page_action: { + default_icon: "default.png", + default_popup: "__MSG_popup__", + default_title: "Default __MSG_title__ \u263a", + }, + + default_locale: "en", + + permissions: ["tabs"], + }, + + files: { + "_locales/en/messages.json": { + popup: { + message: "default.html", + description: "Popup", + }, + + title: { + message: "Title", + description: "Title", + }, + }, + + "_locales/es_ES/messages.json": { + popup: { + message: "default.html", + description: "Popup", + }, + + title: { + message: "T\u00edtulo", + description: "Title", + }, + }, + + "default.png": imageBuffer, + "1.png": imageBuffer, + "2.png": imageBuffer, + }, + + getTests: function (tabs) { + let details = [ + { + icon: browser.runtime.getURL("default.png"), + popup: browser.runtime.getURL("default.html"), + title: "Default T\u00edtulo \u263a", + }, + { + icon: browser.runtime.getURL("1.png"), + popup: browser.runtime.getURL("default.html"), + title: "Default T\u00edtulo \u263a", + }, + { + icon: browser.runtime.getURL("2.png"), + popup: browser.runtime.getURL("2.html"), + title: "Title 2", + }, + { + icon: browser.runtime.getURL("2.png"), + popup: browser.runtime.getURL("2.html"), + title: "", + }, + { + icon: browser.runtime.getURL("2.png"), + popup: browser.runtime.getURL("2.html"), + title: "Default T\u00edtulo \u263a", + }, + ]; + + let promiseTabLoad = details => { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener(tabId, changed) { + if (tabId == details.id && changed.url == details.url) { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + }; + return [ + expect => { + browser.test.log("Initial state. No icon visible."); + expect(null); + }, + async expect => { + browser.test.log( + "Show the icon on the first tab, expect default properties." + ); + await browser.pageAction.show(tabs[0]); + expect(details[0]); + }, + expect => { + browser.test.log( + "Change the icon. Expect default properties excluding the icon." + ); + browser.pageAction.setIcon({ tabId: tabs[0], path: "1.png" }); + expect(details[1]); + }, + async expect => { + browser.test.log("Create a new tab. No icon visible."); + let tab = await browser.tabs.create({ + active: true, + url: "about:blank?0", + }); + tabs.push(tab.id); + expect(null); + }, + async expect => { + browser.test.log("Await tab load. No icon visible."); + let promise = promiseTabLoad({ id: tabs[1], url: "about:blank?0" }); + let { url } = await browser.tabs.get(tabs[1]); + if (url === "about:blank") { + await promise; + } + expect(null); + }, + async expect => { + browser.test.log("Change properties. Expect new properties."); + let tabId = tabs[1]; + + await browser.pageAction.show(tabId); + browser.pageAction.setIcon({ tabId, path: "2.png" }); + browser.pageAction.setPopup({ tabId, popup: "2.html" }); + browser.pageAction.setTitle({ tabId, title: "Title 2" }); + + expect(details[2]); + }, + async expect => { + browser.test.log("Change the hash. Expect same properties."); + + let promise = promiseTabLoad({ + id: tabs[1], + url: "about:blank?0#ref", + }); + + browser.tabs.update(tabs[1], { url: "about:blank?0#ref" }); + + await promise; + expect(details[2]); + }, + expect => { + browser.test.log("Set empty title. Expect empty title."); + browser.pageAction.setTitle({ tabId: tabs[1], title: "" }); + + expect(details[3]); + }, + expect => { + browser.test.log("Clear the title. Expect default title."); + browser.pageAction.setTitle({ tabId: tabs[1], title: null }); + + expect(details[4]); + }, + async expect => { + browser.test.log("Navigate to a new page. Expect icon hidden."); + + // TODO: This listener should not be necessary, but the |tabs.update| + // callback currently fires too early in e10s windows. + let promise = promiseTabLoad({ id: tabs[1], url: "about:blank?1" }); + + browser.tabs.update(tabs[1], { url: "about:blank?1" }); + + await promise; + expect(null); + }, + async expect => { + browser.test.log("Show the icon. Expect default properties again."); + await browser.pageAction.show(tabs[1]); + expect(details[0]); + }, + async expect => { + browser.test.log( + "Switch back to the first tab. Expect previously set properties." + ); + await browser.tabs.update(tabs[0], { active: true }); + expect(details[1]); + }, + async expect => { + browser.test.log( + "Hide the icon on tab 2. Switch back, expect hidden." + ); + await browser.pageAction.hide(tabs[1]); + await browser.tabs.update(tabs[1], { active: true }); + expect(null); + }, + async expect => { + browser.test.log( + "Switch back to tab 1. Expect previous results again." + ); + await browser.tabs.remove(tabs[1]); + expect(details[1]); + }, + async expect => { + browser.test.log("Hide the icon. Expect hidden."); + await browser.pageAction.hide(tabs[0]); + expect(null); + }, + ]; + }, + }); +}); + +add_task(async function testDefaultTitle() { + await runTests({ + manifest: { + name: "Foo Extension", + + page_action: { + default_icon: "icon.png", + }, + + permissions: ["tabs"], + }, + + files: { + "icon.png": imageBuffer, + }, + + getTests: function (tabs) { + let details = [ + { + title: "Foo Extension", + popup: "", + icon: browser.runtime.getURL("icon.png"), + }, + { + title: "Foo Title", + popup: "", + icon: browser.runtime.getURL("icon.png"), + }, + { title: "", popup: "", icon: browser.runtime.getURL("icon.png") }, + ]; + + return [ + expect => { + browser.test.log("Initial state. No icon visible."); + expect(null); + }, + async expect => { + browser.test.log( + "Show the icon on the first tab, expect extension title as default title." + ); + await browser.pageAction.show(tabs[0]); + expect(details[0]); + }, + expect => { + browser.test.log("Change the title. Expect new title."); + browser.pageAction.setTitle({ tabId: tabs[0], title: "Foo Title" }); + expect(details[1]); + }, + expect => { + browser.test.log("Set empty title. Expect empty title."); + browser.pageAction.setTitle({ tabId: tabs[0], title: "" }); + expect(details[2]); + }, + expect => { + browser.test.log("Clear the title. Expect extension title."); + browser.pageAction.setTitle({ tabId: tabs[0], title: null }); + expect(details[0]); + }, + ]; + }, + }); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_persistent_storage_permission_indication.js b/browser/components/extensions/test/browser/browser_ext_persistent_storage_permission_indication.js new file mode 100644 index 0000000000..e50a6af135 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_persistent_storage_permission_indication.js @@ -0,0 +1,131 @@ +/* -- Mode: indent-tabs-mode: nil; js-indent-level: 2 -- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { PermissionTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PermissionTestUtils.sys.mjs" +); + +function openPermissionPopup() { + let promise = BrowserTestUtils.waitForEvent( + window, + "popupshown", + true, + event => event.target == gPermissionPanel._permissionPopup + ); + gPermissionPanel._identityPermissionBox.click(); + info("Wait permission popup to be shown"); + return promise; +} + +function closePermissionPopup() { + let promise = BrowserTestUtils.waitForEvent( + gPermissionPanel._permissionPopup, + "popuphidden" + ); + gPermissionPanel._permissionPopup.hidePopup(); + info("Wait permission popup to be hidden"); + return promise; +} + +async function testPermissionPopup({ expectPermissionHidden }) { + await openPermissionPopup(); + + if (expectPermissionHidden) { + let permissionsList = document.getElementById( + "permission-popup-permission-list" + ); + is( + permissionsList.querySelectorAll( + ".permission-popup-permission-label-persistent-storage" + ).length, + 0, + "Persistent storage Permission should be hidden" + ); + } + + await closePermissionPopup(); + + // We need to test this after the popup has been closed. + // The permission icon will be shown as long as the popup is open, event if + // no permissions are set. + let permissionsGrantedIcon = document.getElementById( + "permissions-granted-icon" + ); + + if (expectPermissionHidden) { + ok( + BrowserTestUtils.isHidden(permissionsGrantedIcon), + "Permission Granted Icon is hidden" + ); + } else { + ok( + BrowserTestUtils.isVisible(permissionsGrantedIcon), + "Permission Granted Icon is visible" + ); + } +} + +add_task(async function testPersistentStoragePermissionHidden() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("url", browser.runtime.getURL("testpage.html")); + }, + manifest: { + name: "Test Extension", + permissions: ["unlimitedStorage"], + }, + files: { + "testpage.html": "

    Extension Test Page

    ", + }, + }); + + await extension.startup(); + + let url = await extension.awaitMessage("url"); + await BrowserTestUtils.withNewTab("about:blank", async browser => { + // Wait the tab to be fully loade, then run the test on the permission prompt. + let loaded = BrowserTestUtils.browserLoaded(browser, false, url); + BrowserTestUtils.startLoadingURIString(browser, url); + await loaded; + await testPermissionPopup({ expectPermissionHidden: true }); + }); + + await extension.unload(); +}); + +add_task(async function testPersistentStoragePermissionVisible() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("url", browser.runtime.getURL("testpage.html")); + }, + manifest: { + name: "Test Extension", + }, + files: { + "testpage.html": "

    Extension Test Page

    ", + }, + }); + + await extension.startup(); + + let url = await extension.awaitMessage("url"); + + let policy = WebExtensionPolicy.getByID(extension.id); + let principal = policy.extension.principal; + PermissionTestUtils.add( + principal, + "persistent-storage", + Services.perms.ALLOW_ACTION + ); + + await BrowserTestUtils.withNewTab("about:blank", async browser => { + // Wait the tab to be fully loade, then run the test on the permission prompt. + let loaded = BrowserTestUtils.browserLoaded(browser, false, url); + BrowserTestUtils.startLoadingURIString(browser, url); + await loaded; + await testPermissionPopup({ expectPermissionHidden: false }); + }); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_popup_api_injection.js b/browser/components/extensions/test/browser/browser_ext_popup_api_injection.js new file mode 100644 index 0000000000..63948ed232 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_popup_api_injection.js @@ -0,0 +1,113 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testPageActionPopup() { + const BASE = + "http://example.com/browser/browser/components/extensions/test/browser"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: `${BASE}/file_popup_api_injection_a.html`, + default_area: "navbar", + }, + page_action: { + default_popup: `${BASE}/file_popup_api_injection_b.html`, + }, + }, + + files: { + "popup-a.html": ` + `, + "popup-a.js": 'browser.test.sendMessage("from-popup-a");', + + "popup-b.html": ` + `, + "popup-b.js": 'browser.test.sendMessage("from-popup-b");', + }, + + background: function () { + let tabId; + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + tabId = tabs[0].id; + browser.pageAction.show(tabId).then(() => { + browser.test.sendMessage("ready"); + }); + }); + + browser.test.onMessage.addListener(() => { + browser.browserAction.setPopup({ popup: "/popup-a.html" }); + browser.pageAction.setPopup({ tabId, popup: "popup-b.html" }); + + browser.test.sendMessage("ok"); + }); + }, + }); + + let promiseConsoleMessage = pattern => + new Promise(resolve => { + Services.console.registerListener(function listener(msg) { + if (pattern.test(msg.message)) { + resolve(msg.message); + Services.console.unregisterListener(listener); + } + }); + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + // Check that unprivileged documents don't get the API. + // BrowserAction: + let awaitMessage = promiseConsoleMessage( + /WebExt Privilege Escalation: BrowserAction/ + ); + SimpleTest.expectUncaughtException(); + await clickBrowserAction(extension); + await awaitExtensionPanel(extension); + + let message = await awaitMessage; + ok( + message.includes( + "WebExt Privilege Escalation: BrowserAction: typeof(browser) = undefined" + ), + `No BrowserAction API injection` + ); + + await closeBrowserAction(extension); + + // PageAction + awaitMessage = promiseConsoleMessage( + /WebExt Privilege Escalation: PageAction/ + ); + SimpleTest.expectUncaughtException(); + await clickPageAction(extension); + + message = await awaitMessage; + ok( + message.includes( + "WebExt Privilege Escalation: PageAction: typeof(browser) = undefined" + ), + `No PageAction API injection: ${message}` + ); + + await closePageAction(extension); + + SimpleTest.expectUncaughtException(false); + + // Check that privileged documents *do* get the API. + extension.sendMessage("next"); + await extension.awaitMessage("ok"); + + await clickBrowserAction(extension); + await awaitExtensionPanel(extension); + await extension.awaitMessage("from-popup-a"); + await closeBrowserAction(extension); + + await clickPageAction(extension); + await extension.awaitMessage("from-popup-b"); + await closePageAction(extension); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_popup_background.js b/browser/components/extensions/test/browser/browser_ext_popup_background.js new file mode 100644 index 0000000000..bf0f78b732 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_popup_background.js @@ -0,0 +1,160 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +async function testPanel(browser, standAlone, background_check) { + let panel = getPanelForNode(browser); + + let checkBackground = (background = null) => { + if (!standAlone) { + return; + } + + is( + getComputedStyle(panel.panelContent).backgroundColor, + background, + "Content should have correct background" + ); + }; + + function getBackground(browser) { + return SpecialPowers.spawn(browser, [], async function () { + return content.windowUtils.canvasBackgroundColor; + }); + } + + let setBackground = color => { + content.document.body.style.backgroundColor = color; + }; + + await new Promise(resolve => setTimeout(resolve, 100)); + + info("Test that initial background color is applied"); + let initialBackground = await getBackground(browser); + checkBackground(initialBackground); + background_check(initialBackground); + + info("Test that dynamically-changed background color is applied"); + await alterContent(browser, setBackground, "black"); + checkBackground(await getBackground(browser)); + + info("Test that non-opaque background color results in default styling"); + await alterContent(browser, setBackground, "rgba(1, 2, 3, .9)"); +} + +add_task(async function testPopupBackground() { + let testCases = [ + { + browser_style: false, + popup: ` + + + + `, + background_check: function (bg) { + is(bg, "rgb(0, 128, 0)", "Initial background should be green"); + }, + }, + { + browser_style: false, + popup: ` + + + + `, + background_check: function (bg) { + is(bg, "rgb(255, 255, 255)", "Initial background should be white"); + }, + }, + { + browser_style: false, + popup: ` + + + + + `, + background_check: function (bg) { + is(bg, "rgb(255, 255, 255)", "Initial background should be white"); + }, + }, + { + browser_style: false, + popup: ` + + + + + `, + background_check: function (bg) { + isnot( + bg, + "rgb(255, 255, 255)", + "Initial background should not be white" + ); + }, + }, + ]; + for (let { browser_style, popup, background_check } of testCases) { + info(`Testing browser_style: ${browser_style} popup: ${popup}`); + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + browser.pageAction.show(tabs[0].id); + }); + }, + + manifest: { + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + browser_style, + }, + + page_action: { + default_popup: "popup.html", + browser_style, + }, + }, + + files: { + "popup.html": popup, + }, + }); + + await extension.startup(); + + { + info("Test stand-alone browserAction popup"); + + clickBrowserAction(extension); + let browser = await awaitExtensionPanel(extension); + await testPanel(browser, true, background_check); + await closeBrowserAction(extension); + } + + { + info("Test menu panel browserAction popup"); + + let widget = getBrowserActionWidget(extension); + CustomizableUI.addWidgetToArea(widget.id, getCustomizableUIPanelID()); + + clickBrowserAction(extension); + let browser = await awaitExtensionPanel(extension); + await testPanel(browser, false, background_check); + await closeBrowserAction(extension); + } + + { + info("Test pageAction popup"); + + clickPageAction(extension); + let browser = await awaitExtensionPanel(extension); + await testPanel(browser, true, background_check); + await closePageAction(extension); + } + + await extension.unload(); + } +}); diff --git a/browser/components/extensions/test/browser/browser_ext_popup_corners.js b/browser/components/extensions/test/browser/browser_ext_popup_corners.js new file mode 100644 index 0000000000..67a53f0e7e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_popup_corners.js @@ -0,0 +1,165 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testPopupBorderRadius() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + browser.pageAction.show(tabs[0].id); + }); + }, + + manifest: { + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + browser_style: false, + }, + + page_action: { + default_popup: "popup.html", + browser_style: false, + }, + }, + + files: { + "popup.html": ` + + + + `, + }, + }); + + await extension.startup(); + + let widget = getBrowserActionWidget(extension); + // If the panel doesn't allows embedding in subview then + // radius will be 0, otherwise 8. In practice we always + // disallow subview. + let expectedRadius = widget.disallowSubView ? "8px" : "0px"; + + async function testPanel(browser, standAlone = true) { + let panel = getPanelForNode(browser); + let arrowContent = panel.panelContent; + + let panelStyle = getComputedStyle(arrowContent); + is(panelStyle.overflow, "clip", "overflow is clipped"); + + let stack = browser.parentNode; + let viewNode = stack.parentNode === panel ? browser : stack.parentNode; + let viewStyle = getComputedStyle(viewNode); + + let props = [ + "borderTopLeftRadius", + "borderTopRightRadius", + "borderBottomRightRadius", + "borderBottomLeftRadius", + ]; + + let bodyStyle = await SpecialPowers.spawn( + browser, + [props], + async function (props) { + let bodyStyle = content.getComputedStyle(content.document.body); + + return new Map(props.map(prop => [prop, bodyStyle[prop]])); + } + ); + + for (let prop of props) { + if (standAlone) { + is( + viewStyle[prop], + panelStyle[prop], + `Panel and view ${prop} should be the same` + ); + is( + bodyStyle.get(prop), + panelStyle[prop], + `Panel and body ${prop} should be the same` + ); + } else { + is(viewStyle[prop], expectedRadius, `View node ${prop} should be 0px`); + is( + bodyStyle.get(prop), + expectedRadius, + `Body node ${prop} should be 0px` + ); + } + } + } + + { + info("Test stand-alone browserAction popup"); + + clickBrowserAction(extension); + let browser = await awaitExtensionPanel(extension); + await testPanel(browser); + await closeBrowserAction(extension); + } + + { + info("Test overflowed browserAction popup"); + const kForceOverflowWidthPx = 450; + let overflowPanel = document.getElementById("widget-overflow"); + + let originalWindowWidth = window.outerWidth; + let navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); + ok( + !navbar.hasAttribute("overflowing"), + "Should start with a non-overflowing toolbar." + ); + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + + await TestUtils.waitForCondition(() => navbar.hasAttribute("overflowing")); + ok( + navbar.hasAttribute("overflowing"), + "Should have an overflowing toolbar." + ); + + await window.gUnifiedExtensions.togglePanel(); + + clickBrowserAction(extension); + let browser = await awaitExtensionPanel(extension); + + is( + overflowPanel.state, + "closed", + "The widget overflow panel should not be open." + ); + + await testPanel(browser, false); + await closeBrowserAction(extension); + + window.resizeTo(originalWindowWidth, window.outerHeight); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); + ok( + !navbar.hasAttribute("overflowing"), + "Should not have an overflowing toolbar." + ); + } + + { + info("Test menu panel browserAction popup"); + + CustomizableUI.addWidgetToArea(widget.id, getCustomizableUIPanelID()); + + clickBrowserAction(extension); + let browser = await awaitExtensionPanel(extension); + await testPanel(browser, false); + await closeBrowserAction(extension); + } + + { + info("Test pageAction popup"); + + clickPageAction(extension); + let browser = await awaitExtensionPanel(extension); + await testPanel(browser); + await closePageAction(extension); + } + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_popup_focus.js b/browser/components/extensions/test/browser/browser_ext_popup_focus.js new file mode 100644 index 0000000000..4cf46f2be5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_popup_focus.js @@ -0,0 +1,88 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const DUMMY_PAGE = + "http://example.com/browser/browser/components/extensions/test/browser/file_dummy.html"; + +add_task(async function testPageActionFocus() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: { + default_popup: "popup.html", + show_matches: [""], + }, + }, + files: { + "popup.html": ` + + + + `, + "popup.js": function () { + window.addEventListener( + "focus", + event => { + browser.test.log("extension popup received focus event"); + browser.test.assertEq( + true, + document.hasFocus(), + "document should be focused" + ); + browser.test.notifyPass("focused"); + }, + { once: true } + ); + browser.test.log(`extension popup loaded`); + }, + }, + }); + + await extension.startup(); + + await BrowserTestUtils.withNewTab(DUMMY_PAGE, async () => { + await clickPageAction(extension); + await extension.awaitFinish("focused"); + await closePageAction(extension); + }); + + await extension.unload(); +}); + +add_task(async function testBrowserActionFocus() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { default_popup: "popup.html" }, + }, + files: { + "popup.html": ` + + + + `, + "popup.js": function () { + window.addEventListener( + "focus", + event => { + browser.test.log("extension popup received focus event"); + browser.test.assertEq( + true, + document.hasFocus(), + "document should be focused" + ); + browser.test.notifyPass("focused"); + }, + { once: true } + ); + browser.test.log(`extension popup loaded`); + }, + }, + }); + await extension.startup(); + + await clickBrowserAction(extension); + await extension.awaitFinish("focused"); + await closeBrowserAction(extension); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_popup_links_open_in_tabs.js b/browser/components/extensions/test/browser/browser_ext_popup_links_open_in_tabs.js new file mode 100644 index 0000000000..1e935e1d0d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_popup_links_open_in_tabs.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_popup_links_open_tabs() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + }, + }, + + files: { + "popup.html": ` + + + + + + +

    Extension Popup

    + popup page link + + `, + "popup.js": function () { + window.onload = () => { + browser.test.sendMessage("from-popup", "popup-a"); + }; + }, + }, + }); + + await extension.startup(); + + let widget = getBrowserActionWidget(extension); + CustomizableUI.addWidgetToArea(widget.id, CustomizableUI.AREA_NAVBAR, 0); + + let promiseActionPopupBrowser = awaitExtensionPanel(extension); + clickBrowserAction(extension); + await extension.awaitMessage("from-popup"); + let popupBrowser = await promiseActionPopupBrowser; + const promiseNewTabOpened = BrowserTestUtils.waitForNewTab( + gBrowser, + "https://example.com/popup-page-link" + ); + await SpecialPowers.spawn(popupBrowser, [], () => + content.document.querySelector("a").click() + ); + const newTab = await promiseNewTabOpened; + ok(newTab, "Got a new tab created on the expected url"); + BrowserTestUtils.removeTab(newTab); + + await closeBrowserAction(extension); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_popup_requestPermission.js b/browser/components/extensions/test/browser/browser_ext_popup_requestPermission.js new file mode 100644 index 0000000000..657d525634 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_popup_requestPermission.js @@ -0,0 +1,67 @@ +"use strict"; + +const verifyRequestPermission = async (manifestProps, expectedIcon) => { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + }, + optional_permissions: [""], + ...manifestProps, + }, + + files: { + "popup.html": ``, + "popup.js": async () => { + const success = await new Promise(resolve => { + browser.test.withHandlingUserInput(() => { + resolve( + browser.permissions.request({ + origins: [""], + }) + ); + }); + }); + browser.test.assertTrue( + success, + "browser.permissions.request promise resolves" + ); + browser.test.sendMessage("done"); + }, + }, + }); + + const requestPrompt = promisePopupNotificationShown( + "addon-webext-permissions" + ).then(panel => { + ok( + panel.getAttribute("icon").endsWith(`/${expectedIcon}`), + "expected the correct icon on the notification" + ); + + panel.button.click(); + }); + await extension.startup(); + await clickBrowserAction(extension); + await requestPrompt; + await extension.awaitMessage("done"); + await extension.unload(); +}; + +add_task(async function test_popup_requestPermission_resolve() { + await verifyRequestPermission({}, "extensionGeneric.svg"); +}); + +add_task(async function test_popup_requestPermission_resolve_custom_icon() { + let expectedIcon = "icon-32.png"; + + await verifyRequestPermission( + { + icons: { + 16: "icon-16.png", + 32: expectedIcon, + }, + }, + expectedIcon + ); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_popup_select.js b/browser/components/extensions/test/browser/browser_ext_popup_select.js new file mode 100644 index 0000000000..87bd945a53 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_popup_select.js @@ -0,0 +1,115 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testPopupSelectPopup() { + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "https://example.com", + }); + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + browser.pageAction.show(tabs[0].id); + }); + }, + + manifest: { + browser_action: { + default_popup: "popup.html", + browser_style: false, + }, + + page_action: { + default_popup: "popup.html", + browser_style: false, + }, + }, + + files: { + "popup.html": ` + + + +
    + +
    + + `, + }, + }); + + await extension.startup(); + + async function testPanel(browser) { + const popupPromise = BrowserTestUtils.waitForSelectPopupShown(window); + + // Wait the select element in the popup window to be ready before sending a + // mouse event to open the select popup. + await SpecialPowers.spawn(browser, [], async () => { + await ContentTaskUtils.waitForCondition(() => { + return content.document && content.document.querySelector("#select"); + }); + }); + BrowserTestUtils.synthesizeMouseAtCenter("#select", {}, browser); + + const selectPopup = await popupPromise; + + let elemRect = await SpecialPowers.spawn(browser, [], async function () { + let elem = content.document.getElementById("select"); + let r = elem.getBoundingClientRect(); + + return { left: r.left, bottom: r.bottom }; + }); + + let popupRect = selectPopup.getOuterScreenRect(); + let marginTop = parseFloat(getComputedStyle(selectPopup).marginTop); + let marginLeft = parseFloat(getComputedStyle(selectPopup).marginLeft); + + is( + Math.floor(browser.screenX + elemRect.left + marginLeft), + popupRect.left, + "Select popup has the correct x origin" + ); + + is( + Math.floor(browser.screenY + elemRect.bottom + marginTop), + popupRect.top, + "Select popup has the correct y origin" + ); + + // Close the select popup before proceeding to the next test. + const onPopupHidden = BrowserTestUtils.waitForEvent( + selectPopup, + "popuphidden" + ); + selectPopup.hidePopup(); + await onPopupHidden; + } + + { + info("Test browserAction popup"); + + clickBrowserAction(extension); + let browser = await awaitExtensionPanel(extension); + await testPanel(browser); + await closeBrowserAction(extension); + } + + { + info("Test pageAction popup"); + + clickPageAction(extension); + let browser = await awaitExtensionPanel(extension); + await testPanel(browser); + await closePageAction(extension); + } + + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); 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 new file mode 100644 index 0000000000..fa2c414047 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_popup_select_in_oopif.js @@ -0,0 +1,131 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// This test is based on browser_ext_popup_select.js. + +const iframeSrc = encodeURIComponent(` + + + +`); + +add_task(async function testPopupSelectPopup() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + browser_style: false, + }, + }, + + files: { + "popup.html": ` + + + + + + + `, + }, + }); + + await extension.startup(); + + const browserForPopup = await openBrowserActionPanel( + extension, + undefined, + true + ); + + const iframe = await SpecialPowers.spawn(browserForPopup, [], async () => { + await ContentTaskUtils.waitForCondition(() => { + return content.document && content.document.querySelector("iframe"); + }); + const iframeElement = content.document.querySelector("iframe"); + + await ContentTaskUtils.waitForCondition(() => { + return iframeElement.browsingContext; + }); + return iframeElement.browsingContext; + }); + + const selectRect = await SpecialPowers.spawn(iframe, [], async () => { + await ContentTaskUtils.waitForCondition(() => { + return content.document.querySelector("select"); + }); + const select = content.document.querySelector("select"); + const focusPromise = new Promise(resolve => { + select.addEventListener("focus", resolve, { once: true }); + }); + select.focus(); + await focusPromise; + + const r = select.getBoundingClientRect(); + + return { left: r.left, bottom: r.bottom }; + }); + + const popupPromise = BrowserTestUtils.waitForSelectPopupShown(window); + + BrowserTestUtils.synthesizeMouseAtCenter("select", {}, iframe); + + const selectPopup = await popupPromise; + + let popupRect = selectPopup.getOuterScreenRect(); + let popupMarginLeft = parseFloat(getComputedStyle(selectPopup).marginLeft); + let popupMarginTop = parseFloat(getComputedStyle(selectPopup).marginTop); + + const offsetToSelectedItem = + selectPopup.querySelector("menuitem[selected]").getBoundingClientRect() + .top - selectPopup.getBoundingClientRect().top; + info( + `Browser is at ${browserForPopup.screenY}, popup is at ${popupRect.top} with ${offsetToSelectedItem} to the selected item` + ); + + is( + Math.floor(browserForPopup.screenX + selectRect.left), + popupRect.left - popupMarginLeft, + "Select popup has the correct x origin" + ); + + // On Mac select popup window appears aligned to the selected option. + let expectedY = navigator.platform.includes("Mac") + ? Math.floor(browserForPopup.screenY - offsetToSelectedItem) + : Math.floor(browserForPopup.screenY + selectRect.bottom); + is( + expectedY, + popupRect.top - popupMarginTop, + "Select popup has the correct y origin" + ); + + const onPopupHidden = BrowserTestUtils.waitForEvent( + selectPopup, + "popuphidden" + ); + selectPopup.hidePopup(); + await onPopupHidden; + + await closeBrowserAction(extension); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_popup_sendMessage.js b/browser/components/extensions/test/browser/browser_ext_popup_sendMessage.js new file mode 100644 index 0000000000..632b929121 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_popup_sendMessage.js @@ -0,0 +1,135 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function test_popup_sendMessage_reply() { + let scriptPage = url => + `${url}`; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + browser_style: true, + }, + + page_action: { + default_popup: "popup.html", + browser_style: true, + }, + }, + + files: { + "popup.html": scriptPage("popup.js"), + "popup.js": async function () { + browser.runtime.onMessage.addListener(async msg => { + if (msg == "popup-ping") { + return "popup-pong"; + } + }); + + let response = await browser.runtime.sendMessage("background-ping"); + browser.test.sendMessage("background-ping-response", response); + }, + }, + + async background() { + browser.runtime.onMessage.addListener(async msg => { + if (msg == "background-ping") { + let response = await browser.runtime.sendMessage("popup-ping"); + + browser.test.sendMessage("popup-ping-response", response); + + await new Promise(resolve => { + // Wait long enough that we're relatively sure the docShells have + // been swapped. Note that this value is fairly arbitrary. The load + // event that triggers the swap should happen almost immediately + // after the message is sent. The extra quarter of a second gives us + // enough leeway that we can expect to respond after the swap in the + // vast majority of cases. + setTimeout(resolve, 250); + }); + + return "background-pong"; + } + }); + + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + + await browser.pageAction.show(tab.id); + + browser.test.sendMessage("page-action-ready"); + }, + }); + + await extension.startup(); + + { + clickBrowserAction(extension); + + let pong = await extension.awaitMessage("background-ping-response"); + is(pong, "background-pong", "Got pong"); + + pong = await extension.awaitMessage("popup-ping-response"); + is(pong, "popup-pong", "Got pong"); + + await closeBrowserAction(extension); + } + + await extension.awaitMessage("page-action-ready"); + + { + clickPageAction(extension); + + let pong = await extension.awaitMessage("background-ping-response"); + is(pong, "background-pong", "Got pong"); + + pong = await extension.awaitMessage("popup-ping-response"); + is(pong, "popup-pong", "Got pong"); + + await closePageAction(extension); + } + + await extension.unload(); +}); + +add_task(async function test_popup_close_then_sendMessage() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + }, + }, + + files: { + "popup.html": `ghost`, + "popup.js"() { + browser.tabs.query({ active: true }).then(() => { + // NOTE: the message will be sent _after_ the popup is closed below. + browser.runtime.sendMessage("sent-after-closed"); + }); + window.close(); + }, + }, + + async background() { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq(msg, "sent-after-closed", "Message from popup."); + browser.test.sendMessage("done"); + }); + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + clickBrowserAction(extension); + await extension.awaitMessage("done"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_popup_shutdown.js b/browser/components/extensions/test/browser/browser_ext_popup_shutdown.js new file mode 100644 index 0000000000..246a83520e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_popup_shutdown.js @@ -0,0 +1,80 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +let getExtension = () => { + return ExtensionTestUtils.loadExtension({ + background: async function () { + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tab.id); + browser.test.sendMessage("pageAction ready"); + }, + + manifest: { + browser_action: { + default_popup: "popup.html", + browser_style: false, + }, + + page_action: { + default_popup: "popup.html", + browser_style: false, + }, + }, + + files: { + "popup.html": ` + `, + }, + }); +}; + +add_task(async function testStandaloneBrowserAction() { + info("Test stand-alone browserAction popup"); + + let extension = getExtension(); + await extension.startup(); + await extension.awaitMessage("pageAction ready"); + + clickBrowserAction(extension); + let browser = await awaitExtensionPanel(extension); + let panel = getPanelForNode(browser); + + await extension.unload(); + + is(panel.parentNode, null, "Panel should be removed from the document"); +}); + +add_task(async function testMenuPanelBrowserAction() { + let extension = getExtension(); + await extension.startup(); + await extension.awaitMessage("pageAction ready"); + + let widget = getBrowserActionWidget(extension); + CustomizableUI.addWidgetToArea(widget.id, getCustomizableUIPanelID()); + + clickBrowserAction(extension); + let browser = await awaitExtensionPanel(extension); + let panel = getPanelForNode(browser); + + await extension.unload(); + + is(panel.state, "closed", "Panel should be closed"); +}); + +add_task(async function testPageAction() { + let extension = getExtension(); + await extension.startup(); + await extension.awaitMessage("pageAction ready"); + + clickPageAction(extension); + let browser = await awaitExtensionPanel(extension); + let panel = getPanelForNode(browser); + + await extension.unload(); + + is(panel.parentNode, null, "Panel should be removed from the document"); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_port_disconnect_on_crash.js b/browser/components/extensions/test/browser/browser_ext_port_disconnect_on_crash.js new file mode 100644 index 0000000000..82ece1da3f --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_port_disconnect_on_crash.js @@ -0,0 +1,113 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function connect_from_tab_to_bg_and_crash_tab() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + js: ["contentscript.js"], + matches: ["http://example.com/?crashme"], + }, + ], + }, + + background() { + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq("tab_to_bg", port.name, "expected port"); + browser.test.assertEq(port.sender.frameId, 0, "correct frameId"); + + port.onDisconnect.addListener(() => { + browser.test.assertEq( + null, + port.error, + "port should be disconnected without errors" + ); + browser.test.sendMessage("port_disconnected"); + }); + browser.test.sendMessage("bg_runtime_onConnect"); + }); + }, + + files: { + "contentscript.js": function () { + let port = browser.runtime.connect({ name: "tab_to_bg" }); + port.onDisconnect.addListener(() => { + browser.test.fail("Unexpected onDisconnect event in content script"); + }); + }, + }, + }); + + await extension.startup(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/?crashme" + ); + await extension.awaitMessage("bg_runtime_onConnect"); + // Force the message manager to disconnect without giving the content a + // chance to send an "Extension:Port:Disconnect" message. + await BrowserTestUtils.crashFrame(tab.linkedBrowser); + await extension.awaitMessage("port_disconnected"); + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); + +add_task(async function connect_from_bg_to_tab_and_crash_tab() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + js: ["contentscript.js"], + matches: ["http://example.com/?crashme"], + }, + ], + }, + + background() { + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.assertEq("contentscript_ready", msg, "expected message"); + let port = browser.tabs.connect(sender.tab.id, { name: "bg_to_tab" }); + port.onDisconnect.addListener(() => { + browser.test.assertEq( + null, + port.error, + "port should be disconnected without errors" + ); + browser.test.sendMessage("port_disconnected"); + }); + }); + }, + + files: { + "contentscript.js": function () { + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq("bg_to_tab", port.name, "expected port"); + port.onDisconnect.addListener(() => { + browser.test.fail( + "Unexpected onDisconnect event in content script" + ); + }); + browser.test.sendMessage("tab_runtime_onConnect"); + }); + browser.runtime.sendMessage("contentscript_ready"); + }, + }, + }); + + await extension.startup(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/?crashme" + ); + await extension.awaitMessage("tab_runtime_onConnect"); + // Force the message manager to disconnect without giving the content a + // chance to send an "Extension:Port:Disconnect" message. + await BrowserTestUtils.crashFrame(tab.linkedBrowser); + await extension.awaitMessage("port_disconnected"); + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_port_disconnect_on_window_close.js b/browser/components/extensions/test/browser/browser_ext_port_disconnect_on_window_close.js new file mode 100644 index 0000000000..84bc4a3ff0 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_port_disconnect_on_window_close.js @@ -0,0 +1,39 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Regression test for https://bugzil.la/1392067 . +add_task(async function connect_from_window_and_close() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq("page_to_bg", port.name, "expected port"); + port.onDisconnect.addListener(() => { + browser.test.assertEq( + null, + port.error, + "port should be disconnected without errors" + ); + browser.test.sendMessage("port_disconnected"); + }); + browser.windows.remove(port.sender.tab.windowId); + }); + + browser.windows.create({ url: "page.html" }); + }, + + files: { + "page.html": ``, + "page.js": function () { + let port = browser.runtime.connect({ name: "page_to_bg" }); + port.onDisconnect.addListener(() => { + browser.test.fail("Unexpected onDisconnect event in page"); + }); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("port_disconnected"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_reload_manifest_cache.js b/browser/components/extensions/test/browser/browser_ext_reload_manifest_cache.js new file mode 100644 index 0000000000..aefa8f42f5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_reload_manifest_cache.js @@ -0,0 +1,72 @@ +"use strict"; + +const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +add_task(async function test_reload_manifest_startupcache() { + const id = "id@tests.mozilla.org"; + + let ext = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id }, + }, + options_ui: { + open_in_tab: true, + page: "options.html", + }, + optional_permissions: [""], + }, + useAddonManager: "temporary", + files: { + "options.html": `lol`, + }, + background() { + browser.runtime.openOptionsPage(); + browser.permissions.onAdded.addListener(() => { + browser.runtime.openOptionsPage(); + }); + }, + }); + + async function waitOptionsTab() { + let tab = await BrowserTestUtils.waitForNewTab(gBrowser, url => + url.endsWith("options.html") + ); + BrowserTestUtils.removeTab(tab); + } + + // Open a non-blank tab to force options to open a new tab. + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/" + ); + let optionsTabPromise = waitOptionsTab(); + + await ext.startup(); + await optionsTabPromise; + + let disabledPromise = awaitEvent("shutdown", id); + let enabledPromise = awaitEvent("ready", id); + optionsTabPromise = waitOptionsTab(); + + let addon = await AddonManager.getAddonByID(id); + await addon.reload(); + + await Promise.all([disabledPromise, enabledPromise, optionsTabPromise]); + + optionsTabPromise = waitOptionsTab(); + ExtensionPermissions.add(id, { + permissions: [], + origins: [""], + }); + await optionsTabPromise; + + let policy = WebExtensionPolicy.getByID(id); + let optionsUrl = policy.extension.manifest.options_ui.page; + ok(optionsUrl.includes(policy.mozExtensionHostname), "Normalized manifest."); + + await BrowserTestUtils.removeTab(tab); + await ext.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_request_permissions.js b/browser/components/extensions/test/browser/browser_ext_request_permissions.js new file mode 100644 index 0000000000..3ba58bccd5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_request_permissions.js @@ -0,0 +1,121 @@ +"use strict"; + +// This test case verifies that `permissions.request()` resolves in the +// expected order. +add_task(async function test_permissions_prompt() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + optional_permissions: ["history", "bookmarks"], + }, + background: async () => { + let hiddenTab = await browser.tabs.create({ + url: browser.runtime.getURL("hidden.html"), + active: false, + }); + + await browser.tabs.create({ + url: browser.runtime.getURL("active.html"), + active: true, + }); + + browser.test.onMessage.addListener(async msg => { + if (msg === "activate-hiddenTab") { + await browser.tabs.update(hiddenTab.id, { active: true }); + + browser.test.sendMessage("activate-hiddenTab-ok"); + } + }); + }, + files: { + "active.html": ``, + "active.js": async () => { + browser.test.onMessage.addListener(async msg => { + if (msg === "request-perms-activeTab") { + let granted = await new Promise(resolve => { + browser.test.withHandlingUserInput(() => { + resolve( + browser.permissions.request({ permissions: ["history"] }) + ); + }); + }); + browser.test.assertTrue(granted, "permission request succeeded"); + + browser.test.sendMessage("request-perms-activeTab-ok"); + } + }); + + browser.test.sendMessage("activeTab-ready"); + }, + "hidden.html": ``, + "hidden.js": async () => { + let resolved = false; + + browser.test.onMessage.addListener(async msg => { + if (msg === "request-perms-hiddenTab") { + let granted = await new Promise(resolve => { + browser.test.withHandlingUserInput(() => { + resolve( + browser.permissions.request({ permissions: ["bookmarks"] }) + ); + }); + }); + browser.test.assertTrue(granted, "permission request succeeded"); + + resolved = true; + + browser.test.sendMessage("request-perms-hiddenTab-ok"); + } else if (msg === "hiddenTab-read-state") { + browser.test.sendMessage("hiddenTab-state-value", resolved); + } + }); + + browser.test.sendMessage("hiddenTab-ready"); + }, + }, + }); + await extension.startup(); + + await extension.awaitMessage("activeTab-ready"); + await extension.awaitMessage("hiddenTab-ready"); + + // Call request() on a hidden window. + extension.sendMessage("request-perms-hiddenTab"); + + let requestPromptForActiveTab = promisePopupNotificationShown( + "addon-webext-permissions" + ).then(panel => { + panel.button.click(); + }); + + // Call request() in the current window. + extension.sendMessage("request-perms-activeTab"); + await requestPromptForActiveTab; + await extension.awaitMessage("request-perms-activeTab-ok"); + + // Check that initial request() is still pending. + extension.sendMessage("hiddenTab-read-state"); + ok( + !(await extension.awaitMessage("hiddenTab-state-value")), + "initial request is pending" + ); + + let requestPromptForHiddenTab = promisePopupNotificationShown( + "addon-webext-permissions" + ).then(panel => { + panel.button.click(); + }); + + extension.sendMessage("activate-hiddenTab"); + await extension.awaitMessage("activate-hiddenTab-ok"); + await requestPromptForHiddenTab; + await extension.awaitMessage("request-perms-hiddenTab-ok"); + + extension.sendMessage("hiddenTab-read-state"); + ok( + await extension.awaitMessage("hiddenTab-state-value"), + "initial request is resolved" + ); + + // The extension tabs are automatically closed upon unload. + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_runtime_onPerformanceWarning.js b/browser/components/extensions/test/browser/browser_ext_runtime_onPerformanceWarning.js new file mode 100644 index 0000000000..b0f62e677a --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_runtime_onPerformanceWarning.js @@ -0,0 +1,144 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { + Management: { + global: { tabTracker }, + }, +} = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + +const { + ExtensionUtils: { promiseObserved }, +} = ChromeUtils.importESModule("resource://gre/modules/ExtensionUtils.sys.mjs"); + +class TestHangReport { + constructor(addonId, scriptBrowser) { + this.addonId = addonId; + this.scriptBrowser = scriptBrowser; + this.QueryInterface = ChromeUtils.generateQI(["nsIHangReport"]); + } + + userCanceled() {} + terminateScript() {} + + isReportForBrowserOrChildren(frameLoader) { + return ( + !this.scriptBrowser || this.scriptBrowser.frameLoader === frameLoader + ); + } +} + +function dispatchHangReport(extensionId, scriptBrowser) { + const hangObserved = promiseObserved("process-hang-report"); + + Services.obs.notifyObservers( + new TestHangReport(extensionId, scriptBrowser), + "process-hang-report" + ); + + return hangObserved; +} + +function background() { + let onPerformanceWarningDetails = null; + + browser.runtime.onPerformanceWarning.addListener(details => { + onPerformanceWarningDetails = details; + }); + + browser.test.onMessage.addListener(message => { + if (message === "get-on-performance-warning-details") { + browser.test.sendMessage( + "on-performance-warning-details", + onPerformanceWarningDetails + ); + onPerformanceWarningDetails = null; + } + }); +} + +async function expectOnPerformanceWarningDetails( + extension, + expectedOnPerformanceWarningDetails +) { + extension.sendMessage("get-on-performance-warning-details"); + + let actualOnPerformanceWarningDetails = await extension.awaitMessage( + "on-performance-warning-details" + ); + Assert.deepEqual( + actualOnPerformanceWarningDetails, + expectedOnPerformanceWarningDetails, + expectedOnPerformanceWarningDetails + ? "runtime.onPerformanceWarning fired with correct details" + : "runtime.onPerformanceWarning didn't fire" + ); +} + +add_task(async function test_should_fire_on_process_hang_report() { + const description = + "Slow extension content script caused a page hang, user was warned."; + + const extension = ExtensionTestUtils.loadExtension({ background }); + await extension.startup(); + + const notificationPromise = BrowserTestUtils.waitForGlobalNotificationBar( + window, + "process-hang" + ); + + const tabs = await Promise.all([ + BrowserTestUtils.openNewForegroundTab(gBrowser), + BrowserTestUtils.openNewForegroundTab(gBrowser), + ]); + + // Warning event shouldn't have fired initially. + await expectOnPerformanceWarningDetails(extension, null); + + // Hang report fired for the extension and first tab. Warning event with first + // tab ID expected. + await dispatchHangReport(extension.id, tabs[0].linkedBrowser); + await expectOnPerformanceWarningDetails(extension, { + category: "content_script", + severity: "high", + description, + tabId: tabTracker.getId(tabs[0]), + }); + + // Hang report fired for different extension, no warning event expected. + await dispatchHangReport("wrong-addon-id", tabs[0].linkedBrowser); + await expectOnPerformanceWarningDetails(extension, null); + + // Non-extension hang report fired, no warning event expected. + await dispatchHangReport(null, tabs[0].linkedBrowser); + await expectOnPerformanceWarningDetails(extension, null); + + // Hang report fired for the extension and second tab. Warning event with + // second tab ID expected. + await dispatchHangReport(extension.id, tabs[1].linkedBrowser); + await expectOnPerformanceWarningDetails(extension, { + category: "content_script", + severity: "high", + description, + tabId: tabTracker.getId(tabs[1]), + }); + + // Hang report fired for the extension with no associated tab. Warning event + // with no tab ID expected. + await dispatchHangReport(extension.id, null); + await expectOnPerformanceWarningDetails(extension, { + category: "content_script", + severity: "high", + description, + }); + + await Promise.all(tabs.map(BrowserTestUtils.removeTab)); + await extension.unload(); + + // Wait for the process-hang warning bar to be displayed, then ensure it's + // cleared to avoid clobbering other tests. + const notification = await notificationPromise; + Assert.ok(notification.isConnected, "Notification still present"); + notification.buttonContainer.querySelector("[label='Stop']").click(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage.js b/browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage.js new file mode 100644 index 0000000000..a4b01bc182 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage.js @@ -0,0 +1,442 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function loadExtension(options) { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: Object.assign( + { + permissions: ["tabs"], + }, + options.manifest + ), + + files: { + "options.html": ` + + + + + + `, + + "options.js": function () { + window.iAmOption = true; + browser.runtime.sendMessage("options.html"); + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "ping") { + respond("pong"); + } else if (msg == "connect") { + let port = browser.runtime.connect(); + port.postMessage("ping-from-options-html"); + port.onMessage.addListener(msg => { + if (msg == "ping-from-bg") { + browser.test.log("Got outbound options.html pong"); + browser.test.sendMessage("options-html-outbound-pong"); + } + }); + } + }); + + browser.runtime.onConnect.addListener(port => { + browser.test.log("Got inbound options.html port"); + + port.postMessage("ping-from-options-html"); + port.onMessage.addListener(msg => { + if (msg == "ping-from-bg") { + browser.test.log("Got inbound options.html pong"); + browser.test.sendMessage("options-html-inbound-pong"); + } + }); + }); + }, + }, + + background: options.background, + }); + + await extension.startup(); + + return extension; +} + +add_task(async function run_test_inline_options() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + + let extension = await loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: "inline_options@tests.mozilla.org" }, + }, + options_ui: { + page: "options.html", + }, + }, + + background: async function () { + let _optionsPromise; + let awaitOptions = () => { + browser.test.assertFalse( + _optionsPromise, + "Should not be awaiting options already" + ); + + return new Promise(resolve => { + _optionsPromise = { resolve }; + }); + }; + + browser.runtime.onMessage.addListener((msg, sender) => { + if (msg == "options.html") { + if (_optionsPromise) { + _optionsPromise.resolve(sender.tab); + _optionsPromise = null; + } else { + browser.test.fail("Saw unexpected options page load"); + } + } + }); + + try { + let [firstTab] = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + + browser.test.log("Open options page. Expect fresh load."); + + let [, optionsTab] = await Promise.all([ + browser.runtime.openOptionsPage(), + awaitOptions(), + ]); + + browser.test.assertEq( + "about:addons", + optionsTab.url, + "Tab contains AddonManager" + ); + browser.test.assertTrue(optionsTab.active, "Tab is active"); + browser.test.assertTrue( + optionsTab.id != firstTab.id, + "Tab is a new tab" + ); + + browser.test.assertEq( + 0, + browser.extension.getViews({ type: "popup" }).length, + "viewType is not popup" + ); + browser.test.assertEq( + 1, + browser.extension.getViews({ type: "tab" }).length, + "viewType is tab" + ); + browser.test.assertEq( + 1, + browser.extension.getViews({ windowId: optionsTab.windowId }).length, + "windowId matches" + ); + + let views = browser.extension.getViews(); + browser.test.assertEq( + 2, + views.length, + "Expected the options page and the background page" + ); + browser.test.assertTrue( + views.includes(window), + "One of the views is the background page" + ); + browser.test.assertTrue( + views.some(w => w.iAmOption), + "One of the views is the options page" + ); + + browser.test.log("Switch tabs."); + await browser.tabs.update(firstTab.id, { active: true }); + + browser.test.log( + "Open options page again. Expect tab re-selected, no new load." + ); + + await browser.runtime.openOptionsPage(); + let [tab] = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + + browser.test.assertEq( + optionsTab.id, + tab.id, + "Tab is the same as the previous options tab" + ); + browser.test.assertEq( + "about:addons", + tab.url, + "Tab contains AddonManager" + ); + + browser.test.log("Ping options page."); + let pong = await browser.runtime.sendMessage("ping"); + browser.test.assertEq("pong", pong, "Got pong."); + + let done = new Promise(resolve => { + browser.test.onMessage.addListener(msg => { + if (msg == "ports-done") { + resolve(); + } + }); + }); + + browser.runtime.onConnect.addListener(port => { + browser.test.log("Got inbound background port"); + + port.postMessage("ping-from-bg"); + port.onMessage.addListener(msg => { + if (msg == "ping-from-options-html") { + browser.test.log("Got inbound background pong"); + browser.test.sendMessage("bg-inbound-pong"); + } + }); + }); + + browser.runtime.sendMessage("connect"); + + let port = browser.runtime.connect(); + port.postMessage("ping-from-bg"); + port.onMessage.addListener(msg => { + if (msg == "ping-from-options-html") { + browser.test.log("Got outbound background pong"); + browser.test.sendMessage("bg-outbound-pong"); + } + }); + + await done; + + browser.test.log("Remove options tab."); + await browser.tabs.remove(optionsTab.id); + + browser.test.log("Open options page again. Expect fresh load."); + [, tab] = await Promise.all([ + browser.runtime.openOptionsPage(), + awaitOptions(), + ]); + browser.test.assertEq( + "about:addons", + tab.url, + "Tab contains AddonManager" + ); + browser.test.assertTrue(tab.active, "Tab is active"); + browser.test.assertTrue(tab.id != optionsTab.id, "Tab is a new tab"); + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("options-ui"); + } catch (error) { + browser.test.fail(`Error: ${error} :: ${error.stack}`); + browser.test.notifyFail("options-ui"); + } + }, + }); + + await Promise.all([ + extension.awaitMessage("options-html-inbound-pong"), + extension.awaitMessage("options-html-outbound-pong"), + extension.awaitMessage("bg-inbound-pong"), + extension.awaitMessage("bg-outbound-pong"), + ]); + + extension.sendMessage("ports-done"); + + await extension.awaitFinish("options-ui"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_tab_options() { + info(`Test options opened in a tab`); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + + let extension = await loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: "tab_options@tests.mozilla.org" }, + }, + options_ui: { + page: "options.html", + open_in_tab: true, + }, + }, + + background: async function () { + let _optionsPromise; + let awaitOptions = () => { + browser.test.assertFalse( + _optionsPromise, + "Should not be awaiting options already" + ); + + return new Promise(resolve => { + _optionsPromise = { resolve }; + }); + }; + + browser.runtime.onMessage.addListener((msg, sender) => { + if (msg == "options.html") { + if (_optionsPromise) { + _optionsPromise.resolve(sender.tab); + _optionsPromise = null; + } else { + browser.test.fail("Saw unexpected options page load"); + } + } + }); + + let optionsURL = browser.runtime.getURL("options.html"); + + try { + let [firstTab] = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + + browser.test.log("Open options page. Expect fresh load."); + let [, optionsTab] = await Promise.all([ + browser.runtime.openOptionsPage(), + awaitOptions(), + ]); + browser.test.assertEq( + optionsURL, + optionsTab.url, + "Tab contains options.html" + ); + browser.test.assertTrue(optionsTab.active, "Tab is active"); + browser.test.assertTrue( + optionsTab.id != firstTab.id, + "Tab is a new tab" + ); + + browser.test.assertEq( + 0, + browser.extension.getViews({ type: "popup" }).length, + "viewType is not popup" + ); + browser.test.assertEq( + 1, + browser.extension.getViews({ type: "tab" }).length, + "viewType is tab" + ); + browser.test.assertEq( + 1, + browser.extension.getViews({ windowId: optionsTab.windowId }).length, + "windowId matches" + ); + + let views = browser.extension.getViews(); + browser.test.assertEq( + 2, + views.length, + "Expected the options page and the background page" + ); + browser.test.assertTrue( + views.includes(window), + "One of the views is the background page" + ); + browser.test.assertTrue( + views.some(w => w.iAmOption), + "One of the views is the options page" + ); + + browser.test.log("Switch tabs."); + await browser.tabs.update(firstTab.id, { active: true }); + + browser.test.log( + "Open options page again. Expect tab re-selected, no new load." + ); + + await browser.runtime.openOptionsPage(); + let [tab] = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + + browser.test.assertEq( + optionsTab.id, + tab.id, + "Tab is the same as the previous options tab" + ); + browser.test.assertEq(optionsURL, tab.url, "Tab contains options.html"); + + // Unfortunately, we can't currently do this, since onMessage doesn't + // currently support responses when there are multiple listeners. + // + // browser.test.log("Ping options page."); + // return new Promise(resolve => browser.runtime.sendMessage("ping", resolve)); + + browser.test.log("Remove options tab."); + await browser.tabs.remove(optionsTab.id); + + browser.test.log("Open options page again. Expect fresh load."); + [, tab] = await Promise.all([ + browser.runtime.openOptionsPage(), + awaitOptions(), + ]); + browser.test.assertEq(optionsURL, tab.url, "Tab contains options.html"); + browser.test.assertTrue(tab.active, "Tab is active"); + browser.test.assertTrue(tab.id != optionsTab.id, "Tab is a new tab"); + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("options-ui-tab"); + } catch (error) { + browser.test.fail(`Error: ${error} :: ${error.stack}`); + browser.test.notifyFail("options-ui-tab"); + } + }, + }); + + await extension.awaitFinish("options-ui-tab"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_options_no_manifest() { + info(`Test with no manifest key`); + + let extension = await loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: "no_options@tests.mozilla.org" }, + }, + }, + + async background() { + browser.test.log( + "Try to open options page when not specified in the manifest." + ); + + await browser.test.assertRejects( + browser.runtime.openOptionsPage(), + /No `options_ui` declared/, + "Expected error from openOptionsPage()" + ); + + browser.test.notifyPass("options-no-manifest"); + }, + }); + + await extension.awaitFinish("options-no-manifest"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage_uninstall.js b/browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage_uninstall.js new file mode 100644 index 0000000000..ac9bbf1ed2 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage_uninstall.js @@ -0,0 +1,122 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function loadExtension(options) { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: Object.assign( + { + permissions: ["tabs"], + }, + options.manifest + ), + + files: { + "options.html": ` + + + + + + `, + + "options.js": function () { + browser.runtime.sendMessage("options.html"); + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "ping") { + respond("pong"); + } + }); + }, + }, + + background: options.background, + }); + + await extension.startup(); + + return extension; +} + +add_task(async function test_inline_options_uninstall() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + + let extension = await loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: "inline_options_uninstall@tests.mozilla.org" }, + }, + options_ui: { + page: "options.html", + }, + }, + + background: async function () { + let _optionsPromise; + let awaitOptions = () => { + browser.test.assertFalse( + _optionsPromise, + "Should not be awaiting options already" + ); + + return new Promise(resolve => { + _optionsPromise = { resolve }; + }); + }; + + browser.runtime.onMessage.addListener((msg, sender) => { + if (msg == "options.html") { + if (_optionsPromise) { + _optionsPromise.resolve(sender.tab); + _optionsPromise = null; + } else { + browser.test.fail("Saw unexpected options page load"); + } + } + }); + + try { + let [firstTab] = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + + browser.test.log("Open options page. Expect fresh load."); + let [, tab] = await Promise.all([ + browser.runtime.openOptionsPage(), + awaitOptions(), + ]); + + browser.test.assertEq( + "about:addons", + tab.url, + "Tab contains AddonManager" + ); + browser.test.assertTrue(tab.active, "Tab is active"); + browser.test.assertTrue(tab.id != firstTab.id, "Tab is a new tab"); + + browser.test.sendMessage("options-ui-open"); + } catch (error) { + browser.test.fail(`Error: ${error} :: ${error.stack}`); + } + }, + }); + + await extension.awaitMessage("options-ui-open"); + await extension.unload(); + + is( + gBrowser.selectedBrowser.currentURI.spec, + "about:addons", + "Add-on manager tab should still be open" + ); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_runtime_setUninstallURL.js b/browser/components/extensions/test/browser/browser_ext_runtime_setUninstallURL.js new file mode 100644 index 0000000000..2530c28a6d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_runtime_setUninstallURL.js @@ -0,0 +1,134 @@ +"use strict"; + +// testing/mochitest/tests/SimpleTest/ExtensionTestUtils.js loads +// ExtensionTestCommon, and is slated as part of the SimpleTest +// environment in tools/lint/eslint/eslint-plugin-mozilla/lib/environments/simpletest.js +// However, nothing but the ExtensionTestUtils global gets put +// into the scope, and so although eslint thinks this global is +// available, it really isn't. +// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix +let { ExtensionTestCommon } = ChromeUtils.importESModule( + "resource://testing-common/ExtensionTestCommon.sys.mjs" +); + +async function makeAndInstallXPI(id, backgroundScript, loadedURL) { + let xpi = ExtensionTestCommon.generateXPI({ + manifest: { browser_specific_settings: { gecko: { id } } }, + background: backgroundScript, + }); + SimpleTest.registerCleanupFunction(function cleanupXPI() { + Services.obs.notifyObservers(xpi, "flush-cache-entry"); + xpi.remove(false); + }); + + let loadPromise = BrowserTestUtils.waitForNewTab(gBrowser, loadedURL); + + info(`installing ${xpi.path}`); + let addon = await AddonManager.installTemporaryAddon(xpi); + info("installed"); + + // A WebExtension is started asynchronously, we have our test extension + // open a new tab to signal that the background script has executed. + let loadTab = await loadPromise; + BrowserTestUtils.removeTab(loadTab); + + return addon; +} + +add_task(async function test_setuninstallurl_badargs() { + async function background() { + await browser.test.assertRejects( + browser.runtime.setUninstallURL("this is not a url"), + /Invalid URL/, + "setUninstallURL with an invalid URL should fail" + ); + + await browser.test.assertRejects( + browser.runtime.setUninstallURL("file:///etc/passwd"), + /must have the scheme http or https/, + "setUninstallURL with an illegal URL should fail" + ); + + browser.test.notifyPass("setUninstallURL bad params"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +// Test the documented behavior of setUninstallURL() that passing an +// empty string is equivalent to not setting an uninstall URL +// (i.e., no new tab is opened upon uninstall) +add_task(async function test_setuninstall_empty_url() { + async function backgroundScript() { + await browser.runtime.setUninstallURL(""); + browser.tabs.create({ url: "http://example.com/addon_loaded" }); + } + + let addon = await makeAndInstallXPI( + "test_uinstallurl2@tests.mozilla.org", + backgroundScript, + "http://example.com/addon_loaded" + ); + + addon.uninstall(true); + info("uninstalled"); + + // no need to explicitly check for the absence of a new tab, + // BrowserTestUtils will eventually complain if one is opened. +}); + +// Test the documented behavior of setUninstallURL() that passing an +// empty string is equivalent to not setting an uninstall URL +// (i.e., no new tab is opened upon uninstall) +// here we pass a null value to string and test +add_task(async function test_setuninstall_null_url() { + async function backgroundScript() { + await browser.runtime.setUninstallURL(null); + browser.tabs.create({ url: "http://example.com/addon_loaded" }); + } + + let addon = await makeAndInstallXPI( + "test_uinstallurl2@tests.mozilla.org", + backgroundScript, + "http://example.com/addon_loaded" + ); + + addon.uninstall(true); + info("uninstalled"); + + // no need to explicitly check for the absence of a new tab, + // BrowserTestUtils will eventually complain if one is opened. +}); + +add_task(async function test_setuninstallurl() { + async function backgroundScript() { + await browser.runtime.setUninstallURL( + "http://example.com/addon_uninstalled" + ); + browser.tabs.create({ url: "http://example.com/addon_loaded" }); + } + + let addon = await makeAndInstallXPI( + "test_uinstallurl@tests.mozilla.org", + backgroundScript, + "http://example.com/addon_loaded" + ); + + // look for a new tab with the uninstall url. + let uninstallPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "http://example.com/addon_uninstalled" + ); + + addon.uninstall(true); + info("uninstalled"); + + let uninstalledTab = await uninstallPromise; + isnot(uninstalledTab, null, "opened tab with uninstall url"); + BrowserTestUtils.removeTab(uninstalledTab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_search.js b/browser/components/extensions/test/browser/browser_ext_search.js new file mode 100644 index 0000000000..c7dab1c9dc --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_search.js @@ -0,0 +1,351 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const SEARCH_TERM = "test"; +const SEARCH_URL = "https://example.org/?q={searchTerms}"; + +AddonTestUtils.initMochitest(this); + +add_task(async function test_search() { + async function background(SEARCH_TERM) { + browser.test.onMessage.addListener(async (msg, tabIds) => { + if (msg !== "removeTabs") { + return; + } + + await browser.tabs.remove(tabIds); + browser.test.sendMessage("onTabsRemoved"); + }); + + function awaitSearchResult() { + return new Promise(resolve => { + async function listener(tabId, info, changedTab) { + if (changedTab.url == "about:blank") { + // Ignore events related to the initial tab open. + return; + } + + if (info.status === "complete") { + browser.tabs.onUpdated.removeListener(listener); + resolve({ tabId, url: changedTab.url }); + } + } + + browser.tabs.onUpdated.addListener(listener); + }); + } + + let engines = await browser.search.get(); + browser.test.sendMessage("engines", engines); + + // Search with no tabId + browser.search.search({ query: SEARCH_TERM + "1", engine: "Search Test" }); + let result = await awaitSearchResult(); + browser.test.sendMessage("searchLoaded", result); + + // Search with tabId + let tab = await browser.tabs.create({}); + browser.search.search({ + query: SEARCH_TERM + "2", + engine: "Search Test", + tabId: tab.id, + }); + result = await awaitSearchResult(); + browser.test.assertEq(result.tabId, tab.id, "Page loaded in right tab"); + browser.test.sendMessage("searchLoaded", result); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["search", "tabs"], + chrome_settings_overrides: { + search_provider: { + name: "Search Test", + search_url: SEARCH_URL, + }, + }, + }, + background: `(${background})("${SEARCH_TERM}")`, + useAddonManager: "temporary", + }); + await extension.startup(); + await AddonTestUtils.waitForSearchProviderStartup(extension); + + let addonEngines = await extension.awaitMessage("engines"); + let engines = (await Services.search.getEngines()).filter( + engine => !engine.hidden + ); + is(addonEngines.length, engines.length, "Engine lengths are the same."); + let defaultEngine = addonEngines.filter(engine => engine.isDefault === true); + is(defaultEngine.length, 1, "One default engine"); + is( + defaultEngine[0].name, + (await Services.search.getDefault()).name, + "Default engine is correct" + ); + + const result1 = await extension.awaitMessage("searchLoaded"); + is( + result1.url, + SEARCH_URL.replace("{searchTerms}", SEARCH_TERM + "1"), + "Loaded page matches search" + ); + await TestUtils.waitForCondition( + () => !gURLBar.focused, + "Wait for unfocusing the urlbar" + ); + info("The urlbar has no focus when searching without tabId"); + + const result2 = await extension.awaitMessage("searchLoaded"); + is( + result2.url, + SEARCH_URL.replace("{searchTerms}", SEARCH_TERM + "2"), + "Loaded page matches search" + ); + await TestUtils.waitForCondition( + () => !gURLBar.focused, + "Wait for unfocusing the urlbar" + ); + info("The urlbar has no focus when searching with tabId"); + + extension.sendMessage("removeTabs", [result1.tabId, result2.tabId]); + await extension.awaitMessage("onTabsRemoved"); + + await extension.unload(); +}); + +add_task(async function test_search_default_engine() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["search"], + }, + background() { + browser.test.onMessage.addListener((msg, tabId) => { + browser.test.assertEq(msg, "search"); + browser.search.search({ query: "searchTermForDefaultEngine", tabId }); + }); + browser.test.sendMessage("extension-origin", browser.runtime.getURL("/")); + }, + useAddonManager: "temporary", + }); + + // Use another extension to intercept and block the search request, + // so that there is no outbound network activity that would kill the test. + // This method also allows us to verify that: + // 1) the search appears as a normal request in the webRequest API. + // 2) the request is associated with the triggering extension. + let extensionWithObserver = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["webRequest", "webRequestBlocking", "*://*/*"] }, + async background() { + let tab = await browser.tabs.create({ url: "about:blank" }); + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.log(`Intercepted request ${JSON.stringify(details)}`); + browser.tabs.remove(tab.id).then(() => { + browser.test.sendMessage("detectedSearch", details); + }); + return { cancel: true }; + }, + { + tabId: tab.id, + types: ["main_frame"], + urls: ["*://*/*"], + }, + ["blocking"] + ); + browser.test.sendMessage("ready", tab.id); + }, + }); + await extension.startup(); + const EXPECTED_ORIGIN = await extension.awaitMessage("extension-origin"); + + await extensionWithObserver.startup(); + let tabId = await extensionWithObserver.awaitMessage("ready"); + + extension.sendMessage("search", tabId); + let requestDetails = await extensionWithObserver.awaitMessage( + "detectedSearch" + ); + await extension.unload(); + await extensionWithObserver.unload(); + + ok( + requestDetails.url.includes("searchTermForDefaultEngine"), + `Expected search term in ${requestDetails.url}` + ); + is( + requestDetails.originUrl, + EXPECTED_ORIGIN, + "Search request's should be associated with the originating extension." + ); +}); + +add_task(async function test_search_disposition() { + async function background() { + let resolvers = {}; + + function tabListener(tabId, changeInfo, tab) { + if (tab.url == "about:blank") { + // Ignore events related to the initial tab open. + return; + } + + if (changeInfo.status === "complete") { + let query = new URL(tab.url).searchParams.get("q"); + let resolver = resolvers[query]; + browser.test.assertTrue(resolver, `Found resolver for ${tab.url}`); + browser.test.assertTrue( + resolver.resolve, + `${query} was not resolved yet` + ); + resolver.resolve({ + tabId, + windowId: tab.windowId, + }); + resolver.resolve = null; // resolve can be used only once. + } + } + browser.tabs.onUpdated.addListener(tabListener); + + async function awaitSearchResult(args) { + resolvers[args.query] = {}; + resolvers[args.query].promise = new Promise( + _resolve => (resolvers[args.query].resolve = _resolve) + ); + await browser.search.search({ ...args, engine: "Search Test" }); + let searchResult = await resolvers[args.query].promise; + return searchResult; + } + + const firstTab = await browser.tabs.create({ + active: true, + url: "about:blank", + }); + + // Search in new tab (testing default disposition) + let result = await awaitSearchResult({ + query: "DefaultDisposition", + }); + browser.test.assertFalse( + result.tabId === firstTab.id, + "Query ran in new tab" + ); + browser.test.assertEq( + result.windowId, + firstTab.windowId, + "Query ran in current window" + ); + await browser.tabs.remove(result.tabId); // Cleanup + + // Search in new tab + result = await awaitSearchResult({ + query: "NewTab", + disposition: "NEW_TAB", + }); + browser.test.assertFalse( + result.tabId === firstTab.id, + "Query ran in new tab" + ); + browser.test.assertEq( + result.windowId, + firstTab.windowId, + "Query ran in current window" + ); + await browser.tabs.remove(result.tabId); // Cleanup + + // Search in current tab + result = await awaitSearchResult({ + query: "CurrentTab", + disposition: "CURRENT_TAB", + }); + browser.test.assertDeepEq( + { + tabId: firstTab.id, + windowId: firstTab.windowId, + }, + result, + "Query ran in current tab in current window" + ); + + // Search in a specific tab + let newTab = await browser.tabs.create({ + active: false, + url: "about:blank", + }); + result = await awaitSearchResult({ + query: "SpecificTab", + tabId: newTab.id, + }); + browser.test.assertDeepEq( + { + tabId: newTab.id, + windowId: firstTab.windowId, + }, + result, + "Query ran in specific tab in current window" + ); + await browser.tabs.remove(newTab.id); // Cleanup + + // Search in a new window + result = await awaitSearchResult({ + query: "NewWindow", + disposition: "NEW_WINDOW", + }); + browser.test.assertFalse( + result.windowId === firstTab.windowId, + "Query ran in new window" + ); + await browser.windows.remove(result.windowId); // Cleanup + await browser.tabs.remove(firstTab.id); // Cleanup + + // Make sure tabId and disposition can't be used together + await browser.test.assertRejects( + browser.search.search({ + query: " ", + tabId: 1, + disposition: "NEW_WINDOW", + }), + "Cannot set both 'disposition' and 'tabId'", + "Should not be able to set both tabId and disposition" + ); + + // Make sure we reject if an invalid tabId is used + await browser.test.assertRejects( + browser.search.search({ + query: " ", + tabId: Number.MAX_SAFE_INTEGER, + }), + /Invalid tab ID/, + "Should not be able to set an invalid tabId" + ); + + browser.test.notifyPass("disposition"); + } + let searchExtension = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "Search Test", + search_url: "https://example.org/?q={searchTerms}", + }, + }, + }, + useAddonManager: "temporary", + }); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["search", "tabs"], + }, + background, + }); + await searchExtension.startup(); + await extension.startup(); + await extension.awaitFinish("disposition"); + await extension.unload(); + await searchExtension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_search_favicon.js b/browser/components/extensions/test/browser/browser_ext_search_favicon.js new file mode 100644 index 0000000000..4e48dd55fa --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_search_favicon.js @@ -0,0 +1,184 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); +const { XPCShellContentUtils } = ChromeUtils.importESModule( + "resource://testing-common/XPCShellContentUtils.sys.mjs" +); + +AddonTestUtils.initMochitest(this); +XPCShellContentUtils.initMochitest(this); + +// Base64-encoded "Fake icon data". +const FAKE_ICON_DATA = "RmFrZSBpY29uIGRhdGE="; + +// Base64-encoded "HTTP icon data". +const HTTP_ICON_DATA = "SFRUUCBpY29uIGRhdGE="; +const HTTP_ICON_URL = "http://example.org/ico.png"; +const server = XPCShellContentUtils.createHttpServer({ + hosts: ["example.org"], +}); +server.registerPathHandler("/ico.png", (request, response) => { + response.write(atob(HTTP_ICON_DATA)); +}); + +function promiseEngineIconLoaded(engineName) { + return TestUtils.topicObserved( + "browser-search-engine-modified", + (engine, verb) => { + engine.QueryInterface(Ci.nsISearchEngine); + return ( + verb == "engine-changed" && + engine.name == engineName && + engine.getIconURL() + ); + } + ); +} + +add_task(async function test_search_favicon() { + let searchExt = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "Engine Only", + search_url: "https://example.com/", + favicon_url: "someFavicon.png", + }, + }, + }, + files: { + "someFavicon.png": atob(FAKE_ICON_DATA), + }, + useAddonManager: "temporary", + }); + + let searchExtWithBadIcon = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "Bad Icon", + search_url: "https://example.net/", + favicon_url: "iDoNotExist.png", + }, + }, + }, + useAddonManager: "temporary", + }); + + let searchExtWithHttpIcon = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "HTTP Icon", + search_url: "https://example.org/", + favicon_url: HTTP_ICON_URL, + }, + }, + }, + useAddonManager: "temporary", + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["search"], + chrome_settings_overrides: { + search_provider: { + name: "My Engine", + search_url: "https://example.org/", + favicon_url: "myFavicon.png", + }, + }, + }, + files: { + "myFavicon.png": imageBuffer, + }, + useAddonManager: "temporary", + async background() { + let engines = await browser.search.get(); + browser.test.sendMessage("engines", { + badEngine: engines.find(engine => engine.name === "Bad Icon"), + httpEngine: engines.find(engine => engine.name === "HTTP Icon"), + myEngine: engines.find(engine => engine.name === "My Engine"), + otherEngine: engines.find(engine => engine.name === "Engine Only"), + }); + }, + }); + + await searchExt.startup(); + await AddonTestUtils.waitForSearchProviderStartup(searchExt); + + await searchExtWithBadIcon.startup(); + await AddonTestUtils.waitForSearchProviderStartup(searchExtWithBadIcon); + + // TODO bug 1571718: browser.search.get should behave correctly (i.e return + // the icon) even if the icon did not finish loading when the API was called. + // Currently calling it too early returns undefined, so just wait until the + // icon has loaded before calling browser.search.get. + let httpIconLoaded = promiseEngineIconLoaded("HTTP Icon"); + await searchExtWithHttpIcon.startup(); + await AddonTestUtils.waitForSearchProviderStartup(searchExtWithHttpIcon); + await httpIconLoaded; + + await extension.startup(); + await AddonTestUtils.waitForSearchProviderStartup(extension); + + let engines = await extension.awaitMessage("engines"); + + // An extension's own icon can surely be accessed by the extension, so its + // favIconUrl can be the moz-extension:-URL itself. + Assert.deepEqual( + engines.myEngine, + { + name: "My Engine", + isDefault: false, + alias: undefined, + favIconUrl: `moz-extension://${extension.uuid}/myFavicon.png`, + }, + "browser.search.get result for own extension" + ); + + // favIconUrl of other engines need to be in base64-encoded form. + Assert.deepEqual( + engines.otherEngine, + { + name: "Engine Only", + isDefault: false, + alias: undefined, + favIconUrl: `data:image/png;base64,${FAKE_ICON_DATA}`, + }, + "browser.search.get result for other extension" + ); + + // HTTP URLs should be provided as-is. + Assert.deepEqual( + engines.httpEngine, + { + name: "HTTP Icon", + isDefault: false, + alias: undefined, + favIconUrl: `data:image/png;base64,${HTTP_ICON_DATA}`, + }, + "browser.search.get result for extension with HTTP icon URL" + ); + + // When the favicon does not exists, the favIconUrl must be unset. + Assert.deepEqual( + engines.badEngine, + { + name: "Bad Icon", + isDefault: false, + alias: undefined, + favIconUrl: undefined, + }, + "browser.search.get result for other extension with non-existing icon" + ); + + await extension.unload(); + await searchExt.unload(); + await searchExtWithBadIcon.unload(); + await searchExtWithHttpIcon.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_search_query.js b/browser/components/extensions/test/browser/browser_ext_search_query.js new file mode 100644 index 0000000000..5258b12605 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_search_query.js @@ -0,0 +1,174 @@ +"use strict"; + +add_task(async function test_query() { + async function background() { + let resolvers = {}; + + function tabListener(tabId, changeInfo, tab) { + if (tab.url == "about:blank") { + // Ignore events related to the initial tab open. + return; + } + + if (changeInfo.status === "complete") { + let query = new URL(tab.url).searchParams.get("q"); + let resolver = resolvers[query]; + browser.test.assertTrue(resolver, `Found resolver for ${tab.url}`); + browser.test.assertTrue( + resolver.resolve, + `${query} was not resolved yet` + ); + resolver.resolve({ + tabId, + windowId: tab.windowId, + }); + resolver.resolve = null; // resolve can be used only once. + } + } + browser.tabs.onUpdated.addListener(tabListener); + + async function awaitSearchResult(args) { + resolvers[args.text] = {}; + resolvers[args.text].promise = new Promise( + _resolve => (resolvers[args.text].resolve = _resolve) + ); + await browser.search.query(args); + let searchResult = await resolvers[args.text].promise; + return searchResult; + } + + const firstTab = await browser.tabs.create({ + active: true, + url: "about:blank", + }); + + browser.test.log("Search in current tab (testing default disposition)"); + let result = await awaitSearchResult({ + text: "DefaultDisposition", + }); + browser.test.assertDeepEq( + { + tabId: firstTab.id, + windowId: firstTab.windowId, + }, + result, + "Defaults to current tab in current window" + ); + + browser.test.log( + "Search in current tab (testing explicit disposition CURRENT_TAB)" + ); + result = await awaitSearchResult({ + text: "CurrentTab", + disposition: "CURRENT_TAB", + }); + browser.test.assertDeepEq( + { + tabId: firstTab.id, + windowId: firstTab.windowId, + }, + result, + "Query ran in current tab in current window" + ); + + browser.test.log("Search in new tab (testing disposition NEW_TAB)"); + result = await awaitSearchResult({ + text: "NewTab", + disposition: "NEW_TAB", + }); + browser.test.assertFalse( + result.tabId === firstTab.id, + "Query ran in new tab" + ); + browser.test.assertEq( + result.windowId, + firstTab.windowId, + "Query ran in current window" + ); + await browser.tabs.remove(result.tabId); // Cleanup + + browser.test.log("Search in a specific tab (testing property tabId)"); + let newTab = await browser.tabs.create({ + active: false, + url: "about:blank", + }); + result = await awaitSearchResult({ + text: "SpecificTab", + tabId: newTab.id, + }); + browser.test.assertDeepEq( + { + tabId: newTab.id, + windowId: firstTab.windowId, + }, + result, + "Query ran in specific tab in current window" + ); + await browser.tabs.remove(newTab.id); // Cleanup + + browser.test.log("Search in a new window (testing disposition NEW_WINDOW)"); + result = await awaitSearchResult({ + text: "NewWindow", + disposition: "NEW_WINDOW", + }); + browser.test.assertFalse( + result.windowId === firstTab.windowId, + "Query ran in new window" + ); + await browser.windows.remove(result.windowId); // Cleanup + await browser.tabs.remove(firstTab.id); // Cleanup + + browser.test.log("Make sure tabId and disposition can't be used together"); + await browser.test.assertRejects( + browser.search.query({ + text: " ", + tabId: 1, + disposition: "NEW_WINDOW", + }), + "Cannot set both 'disposition' and 'tabId'", + "Should not be able to set both tabId and disposition" + ); + + browser.test.log("Make sure we reject if an invalid tabId is used"); + await browser.test.assertRejects( + browser.search.query({ + text: " ", + tabId: Number.MAX_SAFE_INTEGER, + }), + /Invalid tab ID/, + "Should not be able to set an invalid tabId" + ); + + browser.test.notifyPass("disposition"); + } + const SEARCH_NAME = "Search Test"; + let searchExtension = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: SEARCH_NAME, + search_url: "https://example.org/?q={searchTerms}", + }, + }, + }, + useAddonManager: "temporary", + }); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["search", "tabs"], + }, + background, + }); + // We need to use a fake search engine because + // these tests aren't allowed to load actual + // webpages, like google.com for example. + await searchExtension.startup(); + await Services.search.setDefault( + Services.search.getEngineByName(SEARCH_NAME), + Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL + ); + await extension.startup(); + await extension.awaitFinish("disposition"); + await extension.unload(); + await searchExtension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_forgetClosedTab.js b/browser/components/extensions/test/browser/browser_ext_sessions_forgetClosedTab.js new file mode 100644 index 0000000000..c257cbd741 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_forgetClosedTab.js @@ -0,0 +1,145 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { TabStateFlusher } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/TabStateFlusher.sys.mjs" +); + +function getExtension(incognitoOverride) { + function background() { + browser.test.onMessage.addListener((msg, windowId, sessionId) => { + if (msg === "check-sessions") { + browser.sessions.getRecentlyClosed().then(recentlyClosed => { + browser.test.sendMessage("recentlyClosed", recentlyClosed); + }); + } else if (msg === "forget-tab") { + browser.sessions.forgetClosedTab(windowId, sessionId).then( + () => { + browser.test.sendMessage("forgot-tab"); + }, + error => { + browser.test.sendMessage("forget-reject", error.message); + } + ); + } + }); + } + + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background, + incognitoOverride, + }); +} + +async function openAndCloseTab(window, url) { + const tab = await BrowserTestUtils.openNewForegroundTab(window.gBrowser, url); + await TabStateFlusher.flush(tab.linkedBrowser); + const sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + BrowserTestUtils.removeTab(tab); + await sessionUpdatePromise; +} + +add_setup(async function prepare() { + // Clean up any session state left by previous tests that might impact this one + while (SessionStore.getClosedTabCountForWindow(window) > 0) { + SessionStore.forgetClosedTab(window, 0); + } + await TestUtils.waitForTick(); +}); + +add_task(async function test_sessions_forget_closed_tab() { + let extension = getExtension(); + await extension.startup(); + + let tabUrl = "http://example.com"; + await openAndCloseTab(window, tabUrl); + await openAndCloseTab(window, tabUrl); + + extension.sendMessage("check-sessions"); + let recentlyClosed = await extension.awaitMessage("recentlyClosed"); + let recentlyClosedLength = recentlyClosed.length; + let recentlyClosedTab = recentlyClosed[0].tab; + + // Check that forgetting a tab works properly + extension.sendMessage( + "forget-tab", + recentlyClosedTab.windowId, + recentlyClosedTab.sessionId + ); + await extension.awaitMessage("forgot-tab"); + extension.sendMessage("check-sessions"); + let remainingClosed = await extension.awaitMessage("recentlyClosed"); + is( + remainingClosed.length, + recentlyClosedLength - 1, + "One tab was forgotten." + ); + is( + remainingClosed[0].tab.sessionId, + recentlyClosed[1].tab.sessionId, + "The correct tab was forgotten." + ); + + // Check that re-forgetting the same tab fails properly + extension.sendMessage( + "forget-tab", + recentlyClosedTab.windowId, + recentlyClosedTab.sessionId + ); + let errormsg = await extension.awaitMessage("forget-reject"); + is( + errormsg, + `Could not find closed tab using sessionId ${recentlyClosedTab.sessionId}.` + ); + + extension.sendMessage("check-sessions"); + remainingClosed = await extension.awaitMessage("recentlyClosed"); + is( + remainingClosed.length, + recentlyClosedLength - 1, + "No extra tab was forgotten." + ); + is( + remainingClosed[0].tab.sessionId, + recentlyClosed[1].tab.sessionId, + "The correct tab remains." + ); + + await extension.unload(); +}); + +add_task(async function test_sessions_forget_closed_tab_private() { + let pb_extension = getExtension("spanning"); + await pb_extension.startup(); + let extension = getExtension(); + await extension.startup(); + + // Open a private browsing window. + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + let tabUrl = "http://example.com"; + await openAndCloseTab(privateWin, tabUrl); + + pb_extension.sendMessage("check-sessions"); + let recentlyClosed = await pb_extension.awaitMessage("recentlyClosed"); + let recentlyClosedTab = recentlyClosed[0].tab; + + // Check that forgetting a tab works properly + extension.sendMessage( + "forget-tab", + recentlyClosedTab.windowId, + recentlyClosedTab.sessionId + ); + let errormsg = await extension.awaitMessage("forget-reject"); + ok(/Invalid window ID/.test(errormsg), "could not access window"); + + await BrowserTestUtils.closeWindow(privateWin); + await extension.unload(); + await pb_extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_forgetClosedWindow.js b/browser/components/extensions/test/browser/browser_ext_sessions_forgetClosedWindow.js new file mode 100644 index 0000000000..471d2f4440 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_forgetClosedWindow.js @@ -0,0 +1,121 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function getExtension(incognitoOverride) { + function background() { + browser.test.onMessage.addListener((msg, sessionId) => { + if (msg === "check-sessions") { + browser.sessions.getRecentlyClosed().then(recentlyClosed => { + browser.test.sendMessage("recentlyClosed", recentlyClosed); + }); + } else if (msg === "forget-window") { + browser.sessions.forgetClosedWindow(sessionId).then( + () => { + browser.test.sendMessage("forgot-window"); + }, + error => { + browser.test.sendMessage("forget-reject", error.message); + } + ); + } + }); + } + + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background, + incognitoOverride, + }); +} + +async function openAndCloseWindow(url = "http://example.com", privateWin) { + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: privateWin, + }); + let tab = await BrowserTestUtils.openNewForegroundTab(win.gBrowser, url); + let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + await BrowserTestUtils.closeWindow(win); + await sessionUpdatePromise; +} + +add_task(async function test_sessions_forget_closed_window() { + let extension = getExtension(); + await extension.startup(); + + await openAndCloseWindow("about:config"); + await openAndCloseWindow("about:robots"); + + extension.sendMessage("check-sessions"); + let recentlyClosed = await extension.awaitMessage("recentlyClosed"); + let recentlyClosedWindow = recentlyClosed[0].window; + + // Check that forgetting a window works properly + extension.sendMessage("forget-window", recentlyClosedWindow.sessionId); + await extension.awaitMessage("forgot-window"); + extension.sendMessage("check-sessions"); + let remainingClosed = await extension.awaitMessage("recentlyClosed"); + is( + remainingClosed.length, + recentlyClosed.length - 1, + "One window was forgotten." + ); + is( + remainingClosed[0].window.sessionId, + recentlyClosed[1].window.sessionId, + "The correct window was forgotten." + ); + + // Check that re-forgetting the same window fails properly + extension.sendMessage("forget-window", recentlyClosedWindow.sessionId); + let errMsg = await extension.awaitMessage("forget-reject"); + is( + errMsg, + `Could not find closed window using sessionId ${recentlyClosedWindow.sessionId}.` + ); + + extension.sendMessage("check-sessions"); + remainingClosed = await extension.awaitMessage("recentlyClosed"); + is( + remainingClosed.length, + recentlyClosed.length - 1, + "No extra window was forgotten." + ); + is( + remainingClosed[0].window.sessionId, + recentlyClosed[1].window.sessionId, + "The correct window remains." + ); + + await extension.unload(); +}); + +add_task(async function test_sessions_forget_closed_window_private() { + let pb_extension = getExtension("spanning"); + await pb_extension.startup(); + let extension = getExtension("not_allowed"); + await extension.startup(); + + await openAndCloseWindow("about:config", true); + await openAndCloseWindow("about:robots", true); + + pb_extension.sendMessage("check-sessions"); + let recentlyClosed = await pb_extension.awaitMessage("recentlyClosed"); + let recentlyClosedWindow = recentlyClosed[0].window; + + extension.sendMessage("forget-window", recentlyClosedWindow.sessionId); + await extension.awaitMessage("forgot-window"); + extension.sendMessage("check-sessions"); + let remainingClosed = await extension.awaitMessage("recentlyClosed"); + is( + remainingClosed.length, + recentlyClosed.length - 1, + "One window was forgotten." + ); + ok(!recentlyClosedWindow.incognito, "not an incognito window"); + + await extension.unload(); + await pb_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 new file mode 100644 index 0000000000..7dd41ecfe9 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed.js @@ -0,0 +1,216 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +requestLongerTimeout(2); + +loadTestSubscript("head_sessions.js"); + +add_task(async function test_sessions_get_recently_closed() { + async function openAndCloseWindow(url = "http://example.com", tabUrls) { + let win = await BrowserTestUtils.openNewBrowserWindow(); + BrowserTestUtils.startLoadingURIString(win.gBrowser.selectedBrowser, url); + await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + if (tabUrls) { + for (let url of tabUrls) { + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, url); + } + } + await BrowserTestUtils.closeWindow(win); + } + + function background() { + Promise.all([ + browser.sessions.getRecentlyClosed(), + browser.tabs.query({ active: true, currentWindow: true }), + ]).then(([recentlyClosed, tabs]) => { + browser.test.sendMessage("initialData", { + recentlyClosed, + currentWindowId: tabs[0].windowId, + }); + }); + + browser.test.onMessage.addListener((msg, filter) => { + if (msg == "check-sessions") { + browser.sessions.getRecentlyClosed(filter).then(recentlyClosed => { + browser.test.sendMessage("recentlyClosed", recentlyClosed); + }); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background, + }); + + // Open and close a window that will be ignored, to prove that we are removing previous entries + await openAndCloseWindow(); + + await extension.startup(); + + let { recentlyClosed, currentWindowId } = await extension.awaitMessage( + "initialData" + ); + recordInitialTimestamps(recentlyClosed.map(item => item.lastModified)); + + await openAndCloseWindow(); + extension.sendMessage("check-sessions"); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + checkRecentlyClosed( + recentlyClosed.filter(onlyNewItemsFilter), + 1, + currentWindowId + ); + + await openAndCloseWindow("about:config", ["about:robots", "about:mozilla"]); + extension.sendMessage("check-sessions"); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + // Check for multiple tabs in most recently closed window + is( + recentlyClosed[0].window.tabs.length, + 3, + "most recently closed window has the expected number of tabs" + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com" + ); + BrowserTestUtils.removeTab(tab); + + tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com" + ); + BrowserTestUtils.removeTab(tab); + + await openAndCloseWindow(); + extension.sendMessage("check-sessions"); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + let finalResult = recentlyClosed.filter(onlyNewItemsFilter); + checkRecentlyClosed(finalResult, 5, currentWindowId); + + isnot(finalResult[0].window, undefined, "first item is a window"); + is(finalResult[0].tab, undefined, "first item is not a tab"); + isnot(finalResult[1].tab, undefined, "second item is a tab"); + is(finalResult[1].window, undefined, "second item is not a window"); + isnot(finalResult[2].tab, undefined, "third item is a tab"); + is(finalResult[2].window, undefined, "third item is not a window"); + isnot(finalResult[3].window, undefined, "fourth item is a window"); + is(finalResult[3].tab, undefined, "fourth item is not a tab"); + isnot(finalResult[4].window, undefined, "fifth item is a window"); + is(finalResult[4].tab, undefined, "fifth item is not a tab"); + + // test with filter + extension.sendMessage("check-sessions", { maxResults: 2 }); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + checkRecentlyClosed( + recentlyClosed.filter(onlyNewItemsFilter), + 2, + currentWindowId + ); + + await extension.unload(); +}); + +add_task(async function test_sessions_get_recently_closed_navigated() { + function background() { + browser.sessions + .getRecentlyClosed({ maxResults: 1 }) + .then(recentlyClosed => { + let tab = recentlyClosed[0].window.tabs[0]; + browser.test.assertEq( + "http://example.com/", + tab.url, + "Tab in closed window has the expected url." + ); + browser.test.assertTrue( + tab.title.includes("mochitest index"), + "Tab in closed window has the expected title." + ); + browser.test.notifyPass("getRecentlyClosed with navigation"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background, + }); + + // Test with a window with navigation history. + let win = await BrowserTestUtils.openNewBrowserWindow(); + for (let url of ["about:robots", "about:mozilla", "http://example.com/"]) { + BrowserTestUtils.startLoadingURIString(win.gBrowser.selectedBrowser, url); + await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + } + + await BrowserTestUtils.closeWindow(win); + + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task( + async function test_sessions_get_recently_closed_empty_history_in_closed_window() { + function background() { + browser.sessions + .getRecentlyClosed({ maxResults: 1 }) + .then(recentlyClosed => { + let win = recentlyClosed[0].window; + browser.test.assertEq( + 3, + win.tabs.length, + "The closed window has 3 tabs." + ); + browser.test.assertEq( + "about:blank", + win.tabs[0].url, + "The first tab is about:blank." + ); + browser.test.assertFalse( + "url" in win.tabs[1], + "The second tab with empty.xpi has no url field due to empty history." + ); + browser.test.assertEq( + "http://example.com/", + win.tabs[2].url, + "The third tab is example.com." + ); + browser.test.notifyPass("getRecentlyClosed with empty history"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background, + }); + + // Test with a window with empty history. + let xpi = + "http://example.com/browser/browser/components/extensions/test/browser/empty.xpi"; + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser: newWin.gBrowser, + url: xpi, + // A tab with broken xpi file doesn't finish loading. + waitForLoad: false, + }); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser: newWin.gBrowser, + url: "http://example.com/", + }); + await BrowserTestUtils.closeWindow(newWin); + + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); + } +); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed_private.js b/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed_private.js new file mode 100644 index 0000000000..45b1b34be1 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed_private.js @@ -0,0 +1,93 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +SimpleTest.requestCompleteLog(); + +loadTestSubscript("head_sessions.js"); + +async function run_test_extension(incognitoOverride) { + function background() { + browser.test.onMessage.addListener((msg, filter) => { + if (msg == "check-sessions") { + browser.sessions.getRecentlyClosed(filter).then(recentlyClosed => { + browser.test.sendMessage("recentlyClosed", recentlyClosed); + }); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background, + incognitoOverride, + }); + + // Open a private browsing window. + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + await extension.startup(); + + const { + Management: { + global: { windowTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + let privateWinId = windowTracker.getId(privateWin); + + extension.sendMessage("check-sessions"); + let recentlyClosed = await extension.awaitMessage("recentlyClosed"); + recordInitialTimestamps(recentlyClosed.map(item => item.lastModified)); + + // Open and close two tabs in the private window + let tab = await BrowserTestUtils.openNewForegroundTab( + privateWin.gBrowser, + "http://example.com" + ); + BrowserTestUtils.removeTab(tab); + + tab = await BrowserTestUtils.openNewForegroundTab( + privateWin.gBrowser, + "http://example.com" + ); + let sessionPromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + BrowserTestUtils.removeTab(tab); + await sessionPromise; + + extension.sendMessage("check-sessions"); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + let expectedCount = + !incognitoOverride || incognitoOverride == "not_allowed" ? 0 : 2; + checkRecentlyClosed( + recentlyClosed.filter(onlyNewItemsFilter), + expectedCount, + privateWinId, + true + ); + + // Close the private window. + await BrowserTestUtils.closeWindow(privateWin); + + extension.sendMessage("check-sessions"); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + is( + recentlyClosed.filter(onlyNewItemsFilter).length, + 0, + "the closed private window info was not found in recently closed data" + ); + + await extension.unload(); +} + +add_task(async function test_sessions_get_recently_closed_default() { + await run_test_extension(); +}); + +add_task(async function test_sessions_get_recently_closed_private_incognito() { + await run_test_extension("spanning"); + await run_test_extension("not_allowed"); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed_tabs.js b/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed_tabs.js new file mode 100644 index 0000000000..4a513f5131 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed_tabs.js @@ -0,0 +1,292 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function expectedTabInfo(tab, window) { + let browser = tab.linkedBrowser; + return { + url: browser.currentURI.spec, + title: browser.contentTitle, + favIconUrl: window.gBrowser.getIcon(tab) || undefined, + // 'selected' is marked as unsupported in schema, so we've removed it. + // For more details, see bug 1337509 + selected: undefined, + }; +} + +function checkTabInfo(expected, actual) { + for (let prop in expected) { + is( + actual[prop], + expected[prop], + `Expected value found for ${prop} of tab object.` + ); + } +} + +add_task(async function test_sessions_get_recently_closed_tabs() { + // Below, the test makes assumptions about the last accessed time of tabs that are + // not true is we execute fast and reduce the timer precision enough + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.reduceTimerPrecision", false], + ["browser.navigation.requireUserInteraction", false], + ], + }); + + async function background() { + browser.test.onMessage.addListener(async msg => { + if (msg == "check-sessions") { + let recentlyClosed = await browser.sessions.getRecentlyClosed(); + browser.test.sendMessage("recentlyClosed", recentlyClosed); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background, + }); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + let tabBrowser = win.gBrowser.selectedBrowser; + for (let url of ["about:robots", "about:mozilla", "about:config"]) { + BrowserTestUtils.startLoadingURIString(tabBrowser, url); + await BrowserTestUtils.browserLoaded(tabBrowser, false, url); + } + + // Ensure that getRecentlyClosed returns correct results after the back + // button has been used. + let goBackPromise = BrowserTestUtils.waitForLocationChange( + win.gBrowser, + "about:mozilla" + ); + tabBrowser.goBack(); + await goBackPromise; + + let expectedTabs = []; + let tab = win.gBrowser.selectedTab; + // Because there is debounce logic in FaviconLoader.sys.mjs to reduce the + // favicon loads, we have to wait some time before checking that icon was + // stored properly. If that page doesn't have favicon links, let it timeout. + try { + await BrowserTestUtils.waitForCondition( + () => { + return gBrowser.getIcon(tab) != null; + }, + "wait for favicon load to finish", + 100, + 5 + ); + } catch (e) { + // This page doesn't have any favicon link, just continue. + } + expectedTabs.push(expectedTabInfo(tab, win)); + let lastAccessedTimes = new Map(); + lastAccessedTimes.set("about:mozilla", tab.lastAccessed); + + for (let url of ["about:robots", "about:buildconfig"]) { + tab = await BrowserTestUtils.openNewForegroundTab(win.gBrowser, url); + try { + await BrowserTestUtils.waitForCondition( + () => { + return gBrowser.getIcon(tab) != null; + }, + "wait for favicon load to finish", + 100, + 5 + ); + } catch (e) { + // This page doesn't have any favicon link, just continue. + } + expectedTabs.push(expectedTabInfo(tab, win)); + lastAccessedTimes.set(url, tab.lastAccessed); + } + + await extension.startup(); + + let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + // Test with a closed tab. + BrowserTestUtils.removeTab(tab); + await sessionUpdatePromise; + + extension.sendMessage("check-sessions"); + let recentlyClosed = await extension.awaitMessage("recentlyClosed"); + let tabInfo = recentlyClosed[0].tab; + let expectedTab = expectedTabs.pop(); + checkTabInfo(expectedTab, tabInfo); + Assert.greater( + tabInfo.lastAccessed, + lastAccessedTimes.get(tabInfo.url), + "lastAccessed has been updated" + ); + + // Test with a closed window containing tabs. + await BrowserTestUtils.closeWindow(win); + + extension.sendMessage("check-sessions"); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + let tabInfos = recentlyClosed[0].window.tabs; + is(tabInfos.length, 2, "Expected number of tabs in closed window."); + for (let x = 0; x < tabInfos.length; x++) { + checkTabInfo(expectedTabs[x], tabInfos[x]); + Assert.greater( + tabInfos[x].lastAccessed, + lastAccessedTimes.get(tabInfos[x].url), + "lastAccessed has been updated" + ); + } + + await extension.unload(); + + // Test without tabs and host permissions. + extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions"], + }, + background, + }); + + await extension.startup(); + + extension.sendMessage("check-sessions"); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + tabInfos = recentlyClosed[0].window.tabs; + is(tabInfos.length, 2, "Expected number of tabs in closed window."); + for (let tabInfo of tabInfos) { + for (let prop in expectedTabs[0]) { + is( + undefined, + tabInfo[prop], + `${prop} of tab object is undefined without tabs permission.` + ); + } + } + + await extension.unload(); + + // Test with host permission. + win = await BrowserTestUtils.openNewBrowserWindow(); + tabBrowser = win.gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString( + tabBrowser, + "http://example.com/testpage" + ); + await BrowserTestUtils.browserLoaded( + tabBrowser, + false, + "http://example.com/testpage" + ); + tab = win.gBrowser.getTabForBrowser(tabBrowser); + try { + await BrowserTestUtils.waitForCondition( + () => { + return gBrowser.getIcon(tab) != null; + }, + "wait for favicon load to finish", + 100, + 5 + ); + } catch (e) { + // This page doesn't have any favicon link, just continue. + } + expectedTab = expectedTabInfo(tab, win); + await BrowserTestUtils.closeWindow(win); + + extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "http://example.com/*"], + }, + background, + }); + await extension.startup(); + + extension.sendMessage("check-sessions"); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + tabInfo = recentlyClosed[0].window.tabs[0]; + checkTabInfo(expectedTab, tabInfo); + + await extension.unload(); +}); + +add_task( + async function test_sessions_get_recently_closed_for_loading_non_web_controlled_blank_page() { + info("Prepare extension that calls browser.sessions.getRecentlyClosed()"); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background: async () => { + browser.test.onMessage.addListener(async msg => { + if (msg == "check-sessions") { + let recentlyClosed = await browser.sessions.getRecentlyClosed(); + browser.test.sendMessage("recentlyClosed", recentlyClosed); + } + }); + }, + }); + + info( + "Open a page having a link for non web controlled page in _blank target" + ); + const testRoot = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ); + let url = `${testRoot}file_has_non_web_controlled_blank_page_link.html`; + let win = await BrowserTestUtils.openNewBrowserWindow(); + BrowserTestUtils.startLoadingURIString(win.gBrowser.selectedBrowser, url); + await BrowserTestUtils.browserLoaded( + win.gBrowser.selectedBrowser, + false, + url + ); + + info("Open the non web controlled page in _blank target"); + let onNewTabOpened = new Promise(resolve => + win.gBrowser.addTabsProgressListener({ + onStateChange(browser, webProgress, request, stateFlags, status) { + if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { + win.gBrowser.removeTabsProgressListener(this); + resolve(win.gBrowser.getTabForBrowser(browser)); + } + }, + }) + ); + let targetUrl = await SpecialPowers.spawn( + win.gBrowser.selectedBrowser, + [], + () => { + const target = content.document.querySelector("a"); + EventUtils.synthesizeMouseAtCenter(target, {}, content); + return target.href; + } + ); + let tab = await onNewTabOpened; + + info("Remove tab while loading to get getRecentlyClosed()"); + await extension.startup(); + let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + BrowserTestUtils.removeTab(tab); + await sessionUpdatePromise; + + info("Check the result of getRecentlyClosed()"); + extension.sendMessage("check-sessions"); + let recentlyClosed = await extension.awaitMessage("recentlyClosed"); + checkTabInfo( + { + index: 1, + url: targetUrl, + title: targetUrl, + favIconUrl: undefined, + selected: undefined, + }, + recentlyClosed[0].tab + ); + + await extension.unload(); + await BrowserTestUtils.closeWindow(win); + } +); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_incognito.js b/browser/components/extensions/test/browser/browser_ext_sessions_incognito.js new file mode 100644 index 0000000000..aecad9e8ec --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_incognito.js @@ -0,0 +1,113 @@ +"use strict"; + +add_task(async function test_sessions_tab_value_private() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is( + SessionStore.getClosedWindowCount(), + 0, + "No closed window sessions at start of test" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "exampleextension@mozilla.org", + }, + }, + permissions: ["sessions"], + }, + background() { + browser.test.onMessage.addListener(async (msg, pbw) => { + if (msg == "value") { + await browser.test.assertRejects( + browser.sessions.setWindowValue(pbw.windowId, "foo", "bar"), + /Invalid window ID/, + "should not be able to set incognito window session data" + ); + await browser.test.assertRejects( + browser.sessions.getWindowValue(pbw.windowId, "foo"), + /Invalid window ID/, + "should not be able to get incognito window session data" + ); + await browser.test.assertRejects( + browser.sessions.removeWindowValue(pbw.windowId, "foo"), + /Invalid window ID/, + "should not be able to remove incognito window session data" + ); + await browser.test.assertRejects( + browser.sessions.setTabValue(pbw.tabId, "foo", "bar"), + /Invalid tab ID/, + "should not be able to set incognito tab session data" + ); + await browser.test.assertRejects( + browser.sessions.getTabValue(pbw.tabId, "foo"), + /Invalid tab ID/, + "should not be able to get incognito tab session data" + ); + await browser.test.assertRejects( + browser.sessions.removeTabValue(pbw.tabId, "foo"), + /Invalid tab ID/, + "should not be able to remove incognito tab session data" + ); + } + if (msg == "restore") { + await browser.test.assertRejects( + browser.sessions.restore(), + /Could not restore object/, + "should not be able to restore incognito last window session data" + ); + if (pbw) { + await browser.test.assertRejects( + browser.sessions.restore(pbw.sessionId), + /Could not restore object/, + `should not be able to restore incognito session ID ${pbw.sessionId} session data` + ); + } + } + browser.test.sendMessage("done"); + }); + }, + }); + + let winData = await getIncognitoWindow("http://mochi.test:8888/"); + await extension.startup(); + + // Test value set/get APIs on a private window and tab. + extension.sendMessage("value", winData.details); + await extension.awaitMessage("done"); + + // Test restoring a private tab. + let tab = await BrowserTestUtils.openNewForegroundTab( + winData.win.gBrowser, + "http://example.com" + ); + let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + BrowserTestUtils.removeTab(tab); + await sessionUpdatePromise; + let closedTabData = SessionStore.getClosedTabDataForWindow(winData.win); + + extension.sendMessage("restore", { + sessionId: String(closedTabData[0].closedId), + }); + await extension.awaitMessage("done"); + + // Test restoring a private window. + sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate( + winData.win.gBrowser.selectedTab + ); + await BrowserTestUtils.closeWindow(winData.win); + await sessionUpdatePromise; + + is( + SessionStore.getClosedWindowCount(), + 0, + "The closed window was added to Recently Closed Windows" + ); + + // If the window gets restored, test will fail with an unclosed window. + extension.sendMessage("restore"); + await extension.awaitMessage("done"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_restore.js b/browser/components/extensions/test/browser/browser_ext_sessions_restore.js new file mode 100644 index 0000000000..39057519ed --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_restore.js @@ -0,0 +1,234 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +SimpleTest.requestCompleteLog(); + +ChromeUtils.defineESModuleGetters(this, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs", +}); + +add_task(async function test_sessions_restore() { + function background() { + let notificationCount = 0; + browser.sessions.onChanged.addListener(() => { + notificationCount++; + browser.test.sendMessage("notificationCount", notificationCount); + }); + browser.test.onMessage.addListener((msg, data) => { + if (msg == "check-sessions") { + browser.sessions.getRecentlyClosed().then(recentlyClosed => { + browser.test.sendMessage("recentlyClosed", recentlyClosed); + }); + } else if (msg == "restore") { + browser.sessions.restore(data).then(sessions => { + browser.test.sendMessage("restored", sessions); + }); + } else if (msg == "restore-reject") { + browser.sessions.restore("not-a-valid-session-id").then( + sessions => { + browser.test.fail("restore rejected with an invalid sessionId"); + }, + error => { + browser.test.assertTrue( + error.message.includes( + "Invalid sessionId: not-a-valid-session-id." + ) + ); + browser.test.sendMessage("restore-rejected"); + } + ); + } + }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background, + }); + + async function assertNotificationCount(expected) { + let notificationCount = await extension.awaitMessage("notificationCount"); + is( + notificationCount, + expected, + "the expected number of notifications was fired" + ); + } + + await extension.startup(); + + const { + Management: { + global: { windowTracker, tabTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + + function checkLocalTab(tab, expectedUrl) { + let realTab = tabTracker.getTab(tab.id); + let tabState = JSON.parse(SessionStore.getTabState(realTab)); + is( + tabState.entries[0].url, + expectedUrl, + "restored tab has the expected url" + ); + } + + await extension.awaitMessage("ready"); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + BrowserTestUtils.startLoadingURIString( + win.gBrowser.selectedBrowser, + "about:config" + ); + await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + for (let url of ["about:robots", "about:mozilla"]) { + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, url); + } + await BrowserTestUtils.closeWindow(win); + await assertNotificationCount(1); + + extension.sendMessage("check-sessions"); + let recentlyClosed = await extension.awaitMessage("recentlyClosed"); + + // Check that our expected window is the most recently closed. + is( + recentlyClosed[0].window.tabs.length, + 3, + "most recently closed window has the expected number of tabs" + ); + + // Restore the window. + extension.sendMessage("restore"); + await assertNotificationCount(2); + let restored = await extension.awaitMessage("restored"); + + is( + restored.window.tabs.length, + 3, + "restore returned a window with the expected number of tabs" + ); + checkLocalTab(restored.window.tabs[0], "about:config"); + checkLocalTab(restored.window.tabs[1], "about:robots"); + checkLocalTab(restored.window.tabs[2], "about:mozilla"); + + // Close the window again. + let window = windowTracker.getWindow(restored.window.id); + await BrowserTestUtils.closeWindow(window); + await assertNotificationCount(3); + + // Restore the window using the sessionId. + extension.sendMessage("check-sessions"); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + extension.sendMessage("restore", recentlyClosed[0].window.sessionId); + await assertNotificationCount(4); + restored = await extension.awaitMessage("restored"); + + is( + restored.window.tabs.length, + 3, + "restore returned a window with the expected number of tabs" + ); + checkLocalTab(restored.window.tabs[0], "about:config"); + checkLocalTab(restored.window.tabs[1], "about:robots"); + checkLocalTab(restored.window.tabs[2], "about:mozilla"); + + // Close the window again. + window = windowTracker.getWindow(restored.window.id); + await BrowserTestUtils.closeWindow(window); + // notificationCount = yield extension.awaitMessage("notificationCount"); + await assertNotificationCount(5); + + // Open and close a tab. + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:robots" + ); + await TabStateFlusher.flush(tab.linkedBrowser); + BrowserTestUtils.removeTab(tab); + await assertNotificationCount(6); + + // Restore the most recently closed item. + extension.sendMessage("restore"); + await assertNotificationCount(7); + restored = await extension.awaitMessage("restored"); + + tab = restored.tab; + ok(tab, "restore returned a tab"); + checkLocalTab(tab, "about:robots"); + + // Close the tab again. + let realTab = tabTracker.getTab(tab.id); + BrowserTestUtils.removeTab(realTab); + await assertNotificationCount(8); + + // Restore the tab using the sessionId. + extension.sendMessage("check-sessions"); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + extension.sendMessage("restore", recentlyClosed[0].tab.sessionId); + await assertNotificationCount(9); + restored = await extension.awaitMessage("restored"); + + tab = restored.tab; + ok(tab, "restore returned a tab"); + checkLocalTab(tab, "about:robots"); + + // Close the tab again. + realTab = tabTracker.getTab(tab.id); + BrowserTestUtils.removeTab(realTab); + await assertNotificationCount(10); + + // Try to restore something with an invalid sessionId. + extension.sendMessage("restore-reject"); + restored = await extension.awaitMessage("restore-rejected"); + + await extension.unload(); +}); + +add_task(async function test_sessions_event_page() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.eventPages.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "eventpage@sessions" } }, + permissions: ["sessions", "tabs"], + background: { persistent: false }, + }, + background() { + browser.sessions.onChanged.addListener(() => { + browser.test.sendMessage("changed"); + }); + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + // test events waken background + await extension.terminateBackground(); + let win = await BrowserTestUtils.openNewBrowserWindow(); + BrowserTestUtils.startLoadingURIString( + win.gBrowser.selectedBrowser, + "about:config" + ); + await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + for (let url of ["about:robots", "about:mozilla"]) { + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, url); + } + await BrowserTestUtils.closeWindow(win); + + await extension.awaitMessage("ready"); + await extension.awaitMessage("changed"); + ok(true, "persistent event woke background"); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_restoreTab.js b/browser/components/extensions/test/browser/browser_ext_sessions_restoreTab.js new file mode 100644 index 0000000000..679e1fbd6c --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_restoreTab.js @@ -0,0 +1,137 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +/** + This test checks that after closing an extension made tab it restores correctly. + The tab is given an expanded triggering principal and we didn't use to serialize + these correctly into session history. + */ + +// 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.test.onMessage.addListener((msg, filter) => { + if (msg == "change-tab") { + browser.tabs.executeScript({ code: 'location.href += "?changedTab";' }); + } + }); + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", ""], + }, + browser_action: { + default_title: "Navigate current tab via content script", + }, + background, + }); + + const contentScriptTabURL = "http://example.com/?changedTab"; + + let win = await BrowserTestUtils.openNewBrowserWindow({}); + + // Open and close a tabs. + let tabPromise = BrowserTestUtils.waitForNewTab( + win.gBrowser, + "http://example.com/", + true + ); + await extension.startup(); + let firstTab = await tabPromise; + let locationChange = BrowserTestUtils.waitForLocationChange( + win.gBrowser, + contentScriptTabURL + ); + extension.sendMessage("change-tab"); + await locationChange; + is( + firstTab.linkedBrowser.currentURI.spec, + contentScriptTabURL, + "Got expected URL" + ); + + let sessionPromise = BrowserTestUtils.waitForSessionStoreUpdate(firstTab); + BrowserTestUtils.removeTab(firstTab); + await sessionPromise; + + tabPromise = BrowserTestUtils.waitForNewTab( + win.gBrowser, + contentScriptTabURL, + true + ); + SessionStore.undoCloseTab(win, 0); + let restoredTab = await tabPromise; + ok(restoredTab, "We returned a tab here"); + is( + restoredTab.linkedBrowser.currentURI.spec, + contentScriptTabURL, + "Got expected URL" + ); + + await extension.unload(); + BrowserTestUtils.removeTab(restoredTab); + + // Close the window. + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_restoringClosedTabWithTooLargeIndex() { + function background() { + browser.test.onMessage.addListener(async (msg, filter) => { + if (msg != "restoreTab") { + return; + } + const recentlyClosed = await browser.sessions.getRecentlyClosed({ + maxResults: 2, + }); + let tabWithTooLargeIndex; + for (const info of recentlyClosed) { + if (info.tab && info.tab.index > 1) { + tabWithTooLargeIndex = info.tab; + break; + } + } + const onRestored = tab => { + browser.tabs.onCreated.removeListener(onRestored); + browser.test.sendMessage("restoredTab", tab); + }; + browser.tabs.onCreated.addListener(onRestored); + browser.sessions.restore(tabWithTooLargeIndex.sessionId); + }); + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "sessions"], + }, + background, + }); + + const win = await BrowserTestUtils.openNewBrowserWindow({}); + const tabs = await Promise.all([ + BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank?0"), + BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank?1"), + ]); + const promsiedSessionStored = Promise.all([ + BrowserTestUtils.waitForSessionStoreUpdate(tabs[0]), + BrowserTestUtils.waitForSessionStoreUpdate(tabs[1]), + ]); + // Close the rightmost tab at first + BrowserTestUtils.removeTab(tabs[1]); + BrowserTestUtils.removeTab(tabs[0]); + await promsiedSessionStored; + + await extension.startup(); + const promisedRestoredTab = extension.awaitMessage("restoredTab"); + extension.sendMessage("restoreTab"); + const restoredTab = await promisedRestoredTab; + is(restoredTab.index, 1, "Got valid index"); + + await extension.unload(); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_restore_private.js b/browser/components/extensions/test/browser/browser_ext_sessions_restore_private.js new file mode 100644 index 0000000000..2eda77be45 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_restore_private.js @@ -0,0 +1,236 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +SimpleTest.requestCompleteLog(); +loadTestSubscript("head_sessions.js"); +const { TabStateFlusher } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/TabStateFlusher.sys.mjs" +); + +async function openAndCloseTab(window, url) { + let tab = BrowserTestUtils.addTab(window.gBrowser, url); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser, true, url); + await TabStateFlusher.flush(tab.linkedBrowser); + let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + BrowserTestUtils.removeTab(tab); + await sessionUpdatePromise; +} + +async function run_test_extension(incognitoOverride, testData) { + const initialURL = gBrowser.selectedBrowser.currentURI.spec; + // We'll be closing tabs from a private window and a non-private window and attempting + // to call session.restore() at each step. The goal is to compare the actual and + // expected outcome when the extension is/isn't configured for incognito window use. + + function background() { + browser.test.onMessage.addListener(async (msg, sessionId) => { + let result; + try { + result = await browser.sessions.restore(sessionId); + } catch (e) { + result = { error: e.message }; + } + browser.test.sendMessage("result", result); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background, + incognitoOverride, + }); + await extension.startup(); + + // Open a private browsing window and with a non-empty tab + // (so we dont end up closing the window when the close the other tab) + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await BrowserTestUtils.openNewForegroundTab( + privateWin.gBrowser, + testData.private.initialTabURL + ); + + // open and close a tab in the non-private window + await openAndCloseTab(window, testData.notPrivate.tabToClose); + + let { closedId: nonPrivateClosedTabId, pos: nonPrivateIndex } = + SessionStore.getClosedTabDataForWindow(window)[0]; + if (!testData.notPrivate.expected.error) { + testData.notPrivate.expected.index = nonPrivateIndex; + } + + // open and close a tab in the private window + info( + "open & close a tab in the private window with URL: " + + testData.private.tabToClose + ); + await openAndCloseTab(privateWin, testData.private.tabToClose); + let { pos: privateIndex } = + SessionStore.getClosedTabDataForWindow(privateWin)[0]; + if (!testData.private.expected.error) { + testData.private.expected.index = privateIndex; + } + + // focus the non-private window to ensure the outcome isn't just a side-effect of the + // private window being the top window + await SimpleTest.promiseFocus(window); + + // Try to restore the last-closed tab - which was private. + // If incognito access is allowed, we should successfully restore it to the private window. + // We pass no closedId so it should just try to restore the last closed tab + info("Sending 'restore' to attempt restore the closed private tab"); + extension.sendMessage("restore"); + let sessionStoreChanged = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + let extResult = await extension.awaitMessage("result"); + let result = {}; + if (extResult.tab) { + await sessionStoreChanged; + // session.restore() was returning "about:blank" as the tab.url, + // we'll wait to ensure the correct URL eventually loads in the restored tab + await BrowserTestUtils.browserLoaded( + privateWin.gBrowser.selectedTab.linkedBrowser, + true, + testData.private.tabToClose + ); + // only keep the properties we want to compare + for (let pname of Object.keys(testData.private.expected)) { + result[pname] = extResult.tab[pname]; + } + result.url = privateWin.gBrowser.selectedTab.linkedBrowser.currentURI.spec; + } else { + // Trim off the sessionId value so we can easily equality-match on the result + result.error = extResult.error.replace(/sessionId\s+\d+/, "sessionId"); + } + Assert.deepEqual( + result, + testData.private.expected, + "Restoring the private tab didn't match expected result" + ); + + await SimpleTest.promiseFocus(privateWin); + + // Try to restore the last-closed tab in the non-private window + info("Sending 'restore' to restore the non-private tab"); + extension.sendMessage("restore", String(nonPrivateClosedTabId)); + sessionStoreChanged = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + extResult = await extension.awaitMessage("result"); + result = {}; + + if (extResult.tab) { + await sessionStoreChanged; + await BrowserTestUtils.browserLoaded( + window.gBrowser.selectedTab.linkedBrowser, + true, + testData.notPrivate.tabToClose + ); + // only keep the properties we want to compare + for (let pname of Object.keys(testData.notPrivate.expected)) { + result[pname] = extResult.tab[pname]; + } + result.url = window.gBrowser.selectedTab.linkedBrowser.currentURI.spec; + } else { + // Trim off the sessionId value so we can easily equality-match on the result + result.error = extResult.error.replace(/sessionId\s+\d+/, "sessionId"); + } + Assert.deepEqual( + result, + testData.notPrivate.expected, + "Restoring the non-private tab didn't match expected result" + ); + + // Close the private window and cleanup + await BrowserTestUtils.closeWindow(privateWin); + for (let tab of gBrowser.tabs.filter( + tab => !tab.hidden && tab.linkedBrowser.currentURI.spec !== initialURL + )) { + await BrowserTestUtils.removeTab(tab); + } + await extension.unload(); +} + +const spanningTestData = { + private: { + initialTabURL: "https://example.com/", + tabToClose: "https://example.org/?private", + // restore should succeed when incognito is allowed + expected: { + url: "https://example.org/?private", + incognito: true, + }, + }, + notPrivate: { + initialTabURL: "https://example.com/", + tabToClose: "https://example.org/?notprivate", + expected: { + url: "https://example.org/?notprivate", + incognito: false, + }, + }, +}; + +add_task( + async function test_sessions_get_recently_closed_private_incognito_spanning() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.closedTabsFromAllWindows", true]], + }); + await run_test_extension("spanning", spanningTestData); + SpecialPowers.popPrefEnv(); + } +); +add_task( + async function test_sessions_get_recently_closed_private_incognito_spanning_pref_off() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.closedTabsFromAllWindows", false]], + }); + await run_test_extension("spanning", spanningTestData); + SpecialPowers.popPrefEnv(); + } +); + +const notAllowedTestData = { + private: { + initialTabURL: "https://example.com/", + tabToClose: "https://example.org/?private", + // this is expected to fail when incognito is not_allowed + expected: { + error: "Could not restore object using sessionId.", + }, + }, + notPrivate: { + // we'll open tabs for each URL + initialTabURL: "https://example.com/", + tabToClose: "https://example.org/?notprivate", + expected: { + url: "https://example.org/?notprivate", + incognito: false, + }, + }, +}; + +add_task( + async function test_sessions_get_recently_closed_private_incognito_not_allowed() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.closedTabsFromAllWindows", true]], + }); + await run_test_extension("not_allowed", notAllowedTestData); + SpecialPowers.popPrefEnv(); + } +); + +add_task( + async function test_sessions_get_recently_closed_private_incognito_not_allowed_pref_off() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.closedTabsFromAllWindows", false]], + }); + await run_test_extension("not_allowed", notAllowedTestData); + SpecialPowers.popPrefEnv(); + } +); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_window_tab_value.js b/browser/components/extensions/test/browser/browser_ext_sessions_window_tab_value.js new file mode 100644 index 0000000000..b21b59fe8c --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_window_tab_value.js @@ -0,0 +1,398 @@ +"use strict"; + +add_task(async function test_sessions_tab_value() { + info("Testing set/get/deleteTabValue."); + + async function background() { + let tests = [ + { key: "tabkey1", value: "Tab Value" }, + { key: "tabkey2", value: 25 }, + { key: "tabkey3", value: { val: "Tab Value" } }, + { + key: "tabkey4", + value: function () { + return null; + }, + }, + ]; + + async function test(params) { + let { key, value } = params; + let tabs = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + let currentTabId = tabs[0].id; + + browser.sessions.setTabValue(currentTabId, key, value); + + let testValue1 = await browser.sessions.getTabValue(currentTabId, key); + let valueType = typeof value; + + browser.test.log( + `Test that setting, getting and deleting tab value behaves properly when value is type "${valueType}"` + ); + + if (valueType == "string") { + browser.test.assertEq( + value, + testValue1, + `Value for key '${key}' should be '${value}'.` + ); + browser.test.assertEq( + "string", + typeof testValue1, + "typeof value should be '${valueType}'." + ); + } else if (valueType == "number") { + browser.test.assertEq( + value, + testValue1, + `Value for key '${key}' should be '${value}'.` + ); + browser.test.assertEq( + "number", + typeof testValue1, + "typeof value should be '${valueType}'." + ); + } else if (valueType == "object") { + let innerVal1 = value.val; + let innerVal2 = testValue1.val; + browser.test.assertEq( + innerVal1, + innerVal2, + `Value for key '${key}' should be '${innerVal1}'.` + ); + } else if (valueType == "function") { + browser.test.assertEq( + null, + testValue1, + `Value for key '${key}' is non-JSON-able and should be 'null'.` + ); + } + + // Remove the tab key/value. + browser.sessions.removeTabValue(currentTabId, key); + + // This should now return undefined. + testValue1 = await browser.sessions.getTabValue(currentTabId, key); + browser.test.assertEq( + undefined, + testValue1, + `Key has been deleted and value for key "${key}" should be 'undefined'.` + ); + } + + for (let params of tests) { + await test(params); + } + + // Attempt to remove a non-existent key, should not throw error. + let tabs = await browser.tabs.query({ currentWindow: true, active: true }); + await browser.sessions.removeTabValue(tabs[0].id, "non-existent-key"); + browser.test.succeed( + "Attempting to remove a non-existent key should not fail." + ); + + browser.test.sendMessage("testComplete"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "exampleextension@mozilla.org", + }, + }, + permissions: ["sessions", "tabs"], + }, + background, + }); + + await extension.startup(); + + await extension.awaitMessage("testComplete"); + ok(true, "Testing completed for set/get/deleteTabValue."); + + await extension.unload(); +}); + +add_task(async function test_sessions_tab_value_persistence() { + info("Testing for persistence of set tab values."); + + async function background() { + let key = "tabkey1"; + let value1 = "Tab Value 1a"; + let value2 = "Tab Value 1b"; + + browser.test.log( + "Test that two different tabs hold different values for a given key." + ); + + await browser.tabs.create({ url: "http://example.com" }); + + // Wait until the newly created tab has completed loading or it will still have + // about:blank url when it gets removed and will not appear in the removed tabs history. + browser.webNavigation.onCompleted.addListener( + async function newTabListener(details) { + browser.webNavigation.onCompleted.removeListener(newTabListener); + + let tabs = await browser.tabs.query({ currentWindow: true }); + + let tabId_1 = tabs[0].id; + let tabId_2 = tabs[1].id; + + browser.sessions.setTabValue(tabId_1, key, value1); + browser.sessions.setTabValue(tabId_2, key, value2); + + let testValue1 = await browser.sessions.getTabValue(tabId_1, key); + let testValue2 = await browser.sessions.getTabValue(tabId_2, key); + + browser.test.assertEq( + value1, + testValue1, + `Value for key '${key}' should be '${value1}'.` + ); + browser.test.assertEq( + value2, + testValue2, + `Value for key '${key}' should be '${value2}'.` + ); + + browser.test.log( + "Test that value is copied to duplicated tab for a given key." + ); + + let duptab = await browser.tabs.duplicate(tabId_2); + let tabId_3 = duptab.id; + + let testValue3 = await browser.sessions.getTabValue(tabId_3, key); + + browser.test.assertEq( + value2, + testValue3, + `Value for key '${key}' should be '${value2}'.` + ); + + browser.test.log( + "Test that restored tab still holds the value for a given key." + ); + + await browser.tabs.remove([tabId_3]); + + let sessions = await browser.sessions.getRecentlyClosed({ + maxResults: 1, + }); + + let sessionData = await browser.sessions.restore( + sessions[0].tab.sessionId + ); + let restoredId = sessionData.tab.id; + + let testValue = await browser.sessions.getTabValue(restoredId, key); + + browser.test.assertEq( + value2, + testValue, + `Value for key '${key}' should be '${value2}'.` + ); + + await browser.tabs.remove(tabId_2); + await browser.tabs.remove(restoredId); + + browser.test.sendMessage("testComplete"); + }, + { url: [{ hostContains: "example.com" }] } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "exampleextension@mozilla.org", + }, + }, + permissions: ["sessions", "tabs", "webNavigation"], + }, + background, + }); + + await extension.startup(); + + await extension.awaitMessage("testComplete"); + ok(true, "Testing completed for persistance of set tab values."); + + await extension.unload(); +}); + +add_task(async function test_sessions_window_value() { + info("Testing set/get/deleteWindowValue."); + + async function background() { + let tests = [ + { key: "winkey1", value: "Window Value" }, + { key: "winkey2", value: 25 }, + { key: "winkey3", value: { val: "Window Value" } }, + { + key: "winkey4", + value: function () { + return null; + }, + }, + ]; + + async function test(params) { + let { key, value } = params; + let win = await browser.windows.getCurrent(); + let currentWinId = win.id; + + browser.sessions.setWindowValue(currentWinId, key, value); + + let testValue1 = await browser.sessions.getWindowValue(currentWinId, key); + let valueType = typeof value; + + browser.test.log( + `Test that setting, getting and deleting window value behaves properly when value is type "${valueType}"` + ); + + if (valueType == "string") { + browser.test.assertEq( + value, + testValue1, + `Value for key '${key}' should be '${value}'.` + ); + browser.test.assertEq( + "string", + typeof testValue1, + "typeof value should be '${valueType}'." + ); + } else if (valueType == "number") { + browser.test.assertEq( + value, + testValue1, + `Value for key '${key}' should be '${value}'.` + ); + browser.test.assertEq( + "number", + typeof testValue1, + "typeof value should be '${valueType}'." + ); + } else if (valueType == "object") { + let innerVal1 = value.val; + let innerVal2 = testValue1.val; + browser.test.assertEq( + innerVal1, + innerVal2, + `Value for key '${key}' should be '${innerVal1}'.` + ); + } else if (valueType == "function") { + browser.test.assertEq( + null, + testValue1, + `Value for key '${key}' is non-JSON-able and should be 'null'.` + ); + } + + // Remove the window key/value. + browser.sessions.removeWindowValue(currentWinId, key); + + // This should return undefined as the key no longer exists. + testValue1 = await browser.sessions.getWindowValue(currentWinId, key); + browser.test.assertEq( + undefined, + testValue1, + `Key has been deleted and value for key '${key}' should be 'undefined'.` + ); + } + + for (let params of tests) { + await test(params); + } + + // Attempt to remove a non-existent key, should not throw error. + let win = await browser.windows.getCurrent(); + await browser.sessions.removeWindowValue(win.id, "non-existent-key"); + browser.test.succeed( + "Attempting to remove a non-existent key should not fail." + ); + + browser.test.sendMessage("testComplete"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "exampleextension@mozilla.org", + }, + }, + permissions: ["sessions"], + }, + background, + }); + + await extension.startup(); + + await extension.awaitMessage("testComplete"); + ok(true, "Testing completed for set/get/deleteWindowValue."); + + await extension.unload(); +}); + +add_task(async function test_sessions_window_value_persistence() { + info( + "Testing that different values for the same key in different windows are persisted properly." + ); + + async function background() { + let key = "winkey1"; + let value1 = "Window Value 1a"; + let value2 = "Window Value 1b"; + + let window1 = await browser.windows.getCurrent(); + let window2 = await browser.windows.create({}); + + let window1Id = window1.id; + let window2Id = window2.id; + + browser.sessions.setWindowValue(window1Id, key, value1); + browser.sessions.setWindowValue(window2Id, key, value2); + + let testValue1 = await browser.sessions.getWindowValue(window1Id, key); + let testValue2 = await browser.sessions.getWindowValue(window2Id, key); + + browser.test.assertEq( + value1, + testValue1, + `Value for key '${key}' should be '${value1}'.` + ); + browser.test.assertEq( + value2, + testValue2, + `Value for key '${key}' should be '${value2}'.` + ); + + await browser.windows.remove(window2Id); + browser.test.sendMessage("testComplete"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "exampleextension@mozilla.org", + }, + }, + permissions: ["sessions"], + }, + background, + }); + + await extension.startup(); + + await extension.awaitMessage("testComplete"); + ok(true, "Testing completed for persistance of set window values."); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_settings_overrides_default_search.js b/browser/components/extensions/test/browser/browser_ext_settings_overrides_default_search.js new file mode 100644 index 0000000000..74eaa6e634 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_settings_overrides_default_search.js @@ -0,0 +1,881 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs", + SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", +}); + +const EXTENSION1_ID = "extension1@mozilla.com"; +const EXTENSION2_ID = "extension2@mozilla.com"; +const DEFAULT_SEARCH_STORE_TYPE = "default_search"; +const DEFAULT_SEARCH_SETTING_NAME = "defaultSearch"; + +AddonTestUtils.initMochitest(this); +SearchTestUtils.init(this); + +const DEFAULT_ENGINE = { + id: "basic", + name: "basic", + loadPath: "[addon]basic@search.mozilla.org", + submissionUrl: + "https://mochi.test:8888/browser/browser/components/search/test/browser/?search=&foo=1", +}; +const ALTERNATE_ENGINE = { + id: "simple", + name: "Simple Engine", + loadPath: "[addon]simple@search.mozilla.org", + submissionUrl: "https://example.com/?sourceId=Mozilla-search&search=", +}; +const ALTERNATE2_ENGINE = { + id: "simple", + name: "another", + loadPath: "", + submissionUrl: "", +}; + +async function restoreDefaultEngine() { + let engine = Services.search.getEngineByName(DEFAULT_ENGINE.name); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); +} + +function clearTelemetry() { + Services.telemetry.clearEvents(); + Services.fog.testResetFOG(); +} + +async function checkTelemetry(source, prevEngine, newEngine) { + TelemetryTestUtils.assertEvents( + [ + { + object: "change_default", + value: source, + extra: { + prev_id: prevEngine.id, + new_id: newEngine.id, + new_name: newEngine.name, + new_load_path: newEngine.loadPath, + // Telemetry has a limit of 80 characters. + new_sub_url: newEngine.submissionUrl.slice(0, 80), + }, + }, + ], + { category: "search", method: "engine" } + ); + + let snapshot = await Glean.searchEngineDefault.changed.testGetValue(); + delete snapshot[0].timestamp; + Assert.deepEqual( + snapshot[0], + { + category: "search.engine.default", + name: "changed", + extra: { + change_source: source, + previous_engine_id: prevEngine.id, + new_engine_id: newEngine.id, + new_display_name: newEngine.name, + new_load_path: newEngine.loadPath, + new_submission_url: newEngine.submissionUrl, + }, + }, + "Should have received the correct event details" + ); +} + +add_setup(async function () { + let searchExtensions = getChromeDir(getResolvedURI(gTestPath)); + searchExtensions.append("search-engines"); + + await SearchTestUtils.useMochitestEngines(searchExtensions); + + SearchTestUtils.useMockIdleService(); + let response = await fetch(`resource://search-extensions/engines.json`); + let json = await response.json(); + await SearchTestUtils.updateRemoteSettingsConfig(json.data); + + registerCleanupFunction(async () => { + let settingsWritten = SearchTestUtils.promiseSearchNotification( + "write-settings-to-disk-complete" + ); + await SearchTestUtils.updateRemoteSettingsConfig(); + await settingsWritten; + }); +}); + +/* This tests setting a default engine. */ +add_task(async function test_extension_setting_default_engine() { + clearTelemetry(); + + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + + await checkTelemetry("addon-install", DEFAULT_ENGINE, ALTERNATE_ENGINE); + + clearTelemetry(); + + await ext1.unload(); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + `Default engine is ${DEFAULT_ENGINE.name}` + ); + + await checkTelemetry("addon-uninstall", ALTERNATE_ENGINE, DEFAULT_ENGINE); +}); + +/* This tests what happens when the engine you're setting it to is hidden. */ +add_task(async function test_extension_setting_default_engine_hidden() { + let engine = Services.search.getEngineByName(ALTERNATE_ENGINE.name); + engine.hidden = true; + + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + "Default engine should have remained as the default" + ); + is( + ExtensionSettingsStore.getSetting("default_search", "defaultSearch"), + null, + "The extension should not have been recorded as having set the default search" + ); + + await ext1.unload(); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + `Default engine is ${DEFAULT_ENGINE.name}` + ); + engine.hidden = false; +}); + +// Test the popup displayed when trying to add a non-built-in default +// search engine. +add_task(async function test_extension_setting_default_engine_external() { + const NAME = "Example Engine"; + + // Load an extension that tries to set the default engine, + // and wait for the ensuing prompt. + async function startExtension(win = window) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + icons: { + 48: "icon.png", + 96: "icon@2x.png", + }, + browser_specific_settings: { + gecko: { + id: EXTENSION1_ID, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: NAME, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + files: { + "icon.png": "", + "icon@2x.png": "", + }, + useAddonManager: "temporary", + }); + + let [panel] = await Promise.all([ + promisePopupNotificationShown("addon-webext-defaultsearch", win), + extension.startup(), + ]); + + isnot( + panel, + null, + "Doorhanger was displayed for non-built-in default engine" + ); + + return { panel, extension }; + } + + // First time around, don't accept the default engine. + let { panel, extension } = await startExtension(); + ok( + panel.getAttribute("icon").endsWith("/icon.png"), + "expected custom icon set on the notification" + ); + + panel.secondaryButton.click(); + + await TestUtils.topicObserved("webextension-defaultsearch-prompt-response"); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + "Default engine was not changed after rejecting prompt" + ); + + await extension.unload(); + + clearTelemetry(); + + // Do it again, this time accept the prompt. + ({ panel, extension } = await startExtension()); + + panel.button.click(); + await TestUtils.topicObserved("webextension-defaultsearch-prompt-response"); + + is( + (await Services.search.getDefault()).name, + NAME, + "Default engine was changed after accepting prompt" + ); + + await checkTelemetry("addon-install", DEFAULT_ENGINE, { + id: "other-Example Engine", + name: "Example Engine", + loadPath: "[addon]extension1@mozilla.com", + submissionUrl: "https://example.com/?q=", + }); + clearTelemetry(); + + // Do this twice to make sure we're definitely handling disable/enable + // correctly. Disabling and enabling the addon here like this also + // replicates the behavior when an addon is added then removed in the + // blocklist. + let disabledPromise = awaitEvent("shutdown", EXTENSION1_ID); + let addon = await AddonManager.getAddonByID(EXTENSION1_ID); + await addon.disable(); + await disabledPromise; + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + `Default engine is ${DEFAULT_ENGINE.name} after disabling` + ); + + await checkTelemetry( + "addon-uninstall", + { + id: "other-Example Engine", + name: "Example Engine", + loadPath: "[addon]extension1@mozilla.com", + submissionUrl: "https://example.com/?q=", + }, + DEFAULT_ENGINE + ); + clearTelemetry(); + + let opened = promisePopupNotificationShown( + "addon-webext-defaultsearch", + window + ); + await addon.enable(); + panel = await opened; + panel.button.click(); + await TestUtils.topicObserved("webextension-defaultsearch-prompt-response"); + + is( + (await Services.search.getDefault()).name, + NAME, + `Default engine is ${NAME} after enabling` + ); + + await checkTelemetry("addon-install", DEFAULT_ENGINE, { + id: "other-Example Engine", + name: "Example Engine", + loadPath: "[addon]extension1@mozilla.com", + submissionUrl: "https://example.com/?q=", + }); + + await extension.unload(); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + "Default engine is reverted after uninstalling extension." + ); + + // One more time, this time close the window where the prompt + // appears instead of explicitly accepting or denying it. + let win = await BrowserTestUtils.openNewBrowserWindow(); + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank"); + + ({ extension } = await startExtension(win)); + + await BrowserTestUtils.closeWindow(win); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + "Default engine is unchanged when prompt is dismissed" + ); + + await extension.unload(); +}); + +/* This tests that uninstalling add-ons maintains the proper + * search default. */ +add_task(async function test_extension_setting_multiple_default_engine() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + let ext2 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE2_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + + await ext2.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext2); + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + + await ext2.unload(); + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + + await ext1.unload(); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + `Default engine is ${DEFAULT_ENGINE.name}` + ); +}); + +/* This tests that uninstalling add-ons in reverse order maintains the proper + * search default. */ +add_task( + async function test_extension_setting_multiple_default_engine_reversed() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + let ext2 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE2_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + + await ext2.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext2); + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + + await ext1.unload(); + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + + await ext2.unload(); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + `Default engine is ${DEFAULT_ENGINE.name}` + ); + } +); + +/* This tests that when the user changes the search engine and the add-on + * is unistalled, search stays with the user's choice. */ +add_task(async function test_user_changing_default_engine() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + + let engine = Services.search.getEngineByName(ALTERNATE2_ENGINE.name); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + // This simulates the preferences UI when the setting is changed. + ExtensionSettingsStore.select( + ExtensionSettingsStore.SETTING_USER_SET, + DEFAULT_SEARCH_STORE_TYPE, + DEFAULT_SEARCH_SETTING_NAME + ); + + await ext1.unload(); + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + restoreDefaultEngine(); +}); + +/* This tests that when the user changes the search engine while it is + * disabled, user choice is maintained when the add-on is reenabled. */ +add_task(async function test_user_change_with_disabling() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: EXTENSION1_ID, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + + let engine = Services.search.getEngineByName(ALTERNATE2_ENGINE.name); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + // This simulates the preferences UI when the setting is changed. + ExtensionSettingsStore.select( + ExtensionSettingsStore.SETTING_USER_SET, + DEFAULT_SEARCH_STORE_TYPE, + DEFAULT_SEARCH_SETTING_NAME + ); + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + + let disabledPromise = awaitEvent("shutdown", EXTENSION1_ID); + let addon = await AddonManager.getAddonByID(EXTENSION1_ID); + await addon.disable(); + await disabledPromise; + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + + let processedPromise = awaitEvent("searchEngineProcessed", EXTENSION1_ID); + await addon.enable(); + await processedPromise; + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + await ext1.unload(); + await restoreDefaultEngine(); +}); + +/* This tests that when two add-ons are installed that change default + * search and the first one is disabled, before the second one is installed, + * when the first one is reenabled, the second add-on keeps the search. */ +add_task(async function test_two_addons_with_first_disabled_before_second() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: EXTENSION1_ID, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + let ext2 = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: EXTENSION2_ID, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE2_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + + let disabledPromise = awaitEvent("shutdown", EXTENSION1_ID); + let addon1 = await AddonManager.getAddonByID(EXTENSION1_ID); + await addon1.disable(); + await disabledPromise; + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + `Default engine is ${DEFAULT_ENGINE.name}` + ); + + await ext2.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext2); + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + + let enabledPromise = awaitEvent("ready", EXTENSION1_ID); + await addon1.enable(); + await enabledPromise; + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + await ext2.unload(); + await ext1.unload(); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + `Default engine is ${DEFAULT_ENGINE.name}` + ); +}); + +/* This tests that when two add-ons are installed that change default + * search and the first one is disabled, the second one maintains + * the search. */ +add_task(async function test_two_addons_with_first_disabled() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: EXTENSION1_ID, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + let ext2 = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: EXTENSION2_ID, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE2_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + + await ext2.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext2); + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + + let disabledPromise = awaitEvent("shutdown", EXTENSION1_ID); + let addon1 = await AddonManager.getAddonByID(EXTENSION1_ID); + await addon1.disable(); + await disabledPromise; + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + + let enabledPromise = awaitEvent("ready", EXTENSION1_ID); + await addon1.enable(); + await enabledPromise; + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + await ext2.unload(); + await ext1.unload(); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + `Default engine is ${DEFAULT_ENGINE.name}` + ); +}); + +/* This tests that when two add-ons are installed that change default + * search and the second one is disabled, the first one properly + * gets the search. */ +add_task(async function test_two_addons_with_second_disabled() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: EXTENSION1_ID, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + let ext2 = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: EXTENSION2_ID, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE2_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + + await ext2.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext2); + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + + let disabledPromise = awaitEvent("shutdown", EXTENSION2_ID); + let addon2 = await AddonManager.getAddonByID(EXTENSION2_ID); + await addon2.disable(); + await disabledPromise; + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + + let defaultPromise = SearchTestUtils.promiseSearchNotification( + "engine-default", + "browser-search-engine-modified" + ); + // No prompt, because this is switching to an app-provided engine. + await addon2.enable(); + await defaultPromise; + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + await ext2.unload(); + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + await ext1.unload(); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + `Default engine is ${DEFAULT_ENGINE.name}` + ); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction.js new file mode 100644 index 0000000000..afc1e4f9b9 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction.js @@ -0,0 +1,268 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +requestLongerTimeout(2); + +let extData = { + manifest: { + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + useAddonManager: "temporary", + + files: { + "sidebar.html": ` + + + + + + + A Test Sidebar + + `, + + "sidebar.js": function () { + window.onload = () => { + browser.test.sendMessage("sidebar"); + }; + }, + }, + + background: function () { + browser.test.onMessage.addListener(async ({ msg, data }) => { + if (msg === "set-panel") { + await browser.sidebarAction.setPanel({ panel: null }); + browser.test.assertEq( + await browser.sidebarAction.getPanel({}), + browser.runtime.getURL("sidebar.html"), + "Global panel can be reverted to the default." + ); + } else if (msg === "isOpen") { + let { arg = {}, result } = data; + let isOpen = await browser.sidebarAction.isOpen(arg); + browser.test.assertEq(result, isOpen, "expected value from isOpen"); + } + browser.test.sendMessage("done"); + }); + }, +}; + +function getExtData(manifestUpdates = {}) { + return { + ...extData, + manifest: { + ...extData.manifest, + ...manifestUpdates, + }, + }; +} + +async function sendMessage(ext, msg, data = undefined) { + ext.sendMessage({ msg, data }); + await ext.awaitMessage("done"); +} + +add_task(async function sidebar_initial_install() { + ok( + document.getElementById("sidebar-box").hidden, + "sidebar box is not visible" + ); + let extension = ExtensionTestUtils.loadExtension(getExtData()); + await extension.startup(); + await extension.awaitMessage("sidebar"); + + // Test sidebar is opened on install + ok(!document.getElementById("sidebar-box").hidden, "sidebar box is visible"); + + await extension.unload(); + // Test that the sidebar was closed on unload. + ok( + document.getElementById("sidebar-box").hidden, + "sidebar box is not visible" + ); +}); + +add_task(async function sidebar__install_closed() { + ok( + document.getElementById("sidebar-box").hidden, + "sidebar box is not visible" + ); + let tempExtData = getExtData(); + tempExtData.manifest.sidebar_action.open_at_install = false; + let extension = ExtensionTestUtils.loadExtension(tempExtData); + await extension.startup(); + + // Test sidebar is closed on install + ok(document.getElementById("sidebar-box").hidden, "sidebar box is hidden"); + + await extension.unload(); + // This is the default value + tempExtData.manifest.sidebar_action.open_at_install = true; +}); + +add_task(async function sidebar_two_sidebar_addons() { + let extension2 = ExtensionTestUtils.loadExtension(getExtData()); + await extension2.startup(); + // Test sidebar is opened on install + await extension2.awaitMessage("sidebar"); + ok(!document.getElementById("sidebar-box").hidden, "sidebar box is visible"); + + // Test second sidebar install opens new sidebar + let extension3 = ExtensionTestUtils.loadExtension(getExtData()); + await extension3.startup(); + // Test sidebar is opened on install + await extension3.awaitMessage("sidebar"); + ok(!document.getElementById("sidebar-box").hidden, "sidebar box is visible"); + await extension3.unload(); + + // We just close the sidebar on uninstall of the current sidebar. + ok( + document.getElementById("sidebar-box").hidden, + "sidebar box is not visible" + ); + + await extension2.unload(); +}); + +add_task(async function sidebar_empty_panel() { + let extension = ExtensionTestUtils.loadExtension(getExtData()); + await extension.startup(); + // Test sidebar is opened on install + await extension.awaitMessage("sidebar"); + ok( + !document.getElementById("sidebar-box").hidden, + "sidebar box is visible in first window" + ); + await sendMessage(extension, "set-panel"); + await extension.unload(); +}); + +add_task(async function sidebar_isOpen() { + info("Load extension1"); + let extension1 = ExtensionTestUtils.loadExtension(getExtData()); + await extension1.startup(); + + info("Test extension1's sidebar is opened on install"); + await extension1.awaitMessage("sidebar"); + await sendMessage(extension1, "isOpen", { result: true }); + let sidebar1ID = SidebarUI.currentID; + + info("Load extension2"); + let extension2 = ExtensionTestUtils.loadExtension(getExtData()); + await extension2.startup(); + + info("Test extension2's sidebar is opened on install"); + await extension2.awaitMessage("sidebar"); + await sendMessage(extension1, "isOpen", { result: false }); + await sendMessage(extension2, "isOpen", { result: true }); + + info("Switch back to extension1's sidebar"); + SidebarUI.show(sidebar1ID); + await extension1.awaitMessage("sidebar"); + await sendMessage(extension1, "isOpen", { result: true }); + await sendMessage(extension2, "isOpen", { result: false }); + + info("Test passing a windowId parameter"); + let windowId = window.docShell.outerWindowID; + let WINDOW_ID_CURRENT = -2; + await sendMessage(extension1, "isOpen", { arg: { windowId }, result: true }); + await sendMessage(extension2, "isOpen", { arg: { windowId }, result: false }); + await sendMessage(extension1, "isOpen", { + arg: { windowId: WINDOW_ID_CURRENT }, + result: true, + }); + await sendMessage(extension2, "isOpen", { + arg: { windowId: WINDOW_ID_CURRENT }, + result: false, + }); + + info("Open a new window"); + open("", "", "noopener"); + let newWin = Services.wm.getMostRecentWindow("navigator:browser"); + + info("The new window has no sidebar"); + await sendMessage(extension1, "isOpen", { result: false }); + await sendMessage(extension2, "isOpen", { result: false }); + + info("But the original window still does"); + await sendMessage(extension1, "isOpen", { arg: { windowId }, result: true }); + await sendMessage(extension2, "isOpen", { arg: { windowId }, result: false }); + + info("Close the new window"); + newWin.close(); + + info("Close the sidebar in the original window"); + SidebarUI.hide(); + await sendMessage(extension1, "isOpen", { result: false }); + await sendMessage(extension2, "isOpen", { result: false }); + + await extension1.unload(); + await extension2.unload(); +}); + +add_task(async function testShortcuts() { + function verifyShortcut(id, commandKey) { + // We're just testing the command key since the modifiers have different + // icons on different platforms. + let menuitem = document.getElementById( + `sidebarswitcher_menu_${makeWidgetId(id)}-sidebar-action` + ); + ok(menuitem.hasAttribute("key"), "The menu item has a key specified"); + let key = document.getElementById(menuitem.getAttribute("key")); + ok(key, "The key attribute finds the related key element"); + ok( + menuitem.getAttribute("acceltext").endsWith(commandKey), + "The shortcut has the right key" + ); + } + + let extension1 = ExtensionTestUtils.loadExtension( + getExtData({ + commands: { + _execute_sidebar_action: { + suggested_key: { + default: "Ctrl+Shift+I", + }, + }, + }, + }) + ); + let extension2 = ExtensionTestUtils.loadExtension( + getExtData({ + commands: { + _execute_sidebar_action: { + suggested_key: { + default: "Ctrl+Shift+E", + }, + }, + }, + }) + ); + + await extension1.startup(); + await extension1.awaitMessage("sidebar"); + + // Open and close the switcher panel to trigger shortcut content rendering. + let switcherPanelShown = promisePopupShown(SidebarUI._switcherPanel); + SidebarUI.showSwitcherPanel(); + await switcherPanelShown; + let switcherPanelHidden = promisePopupHidden(SidebarUI._switcherPanel); + SidebarUI.hideSwitcherPanel(); + await switcherPanelHidden; + + // Test that the key is set for the extension after the shortcuts are rendered. + verifyShortcut(extension1.id, "I"); + + await extension2.startup(); + await extension2.awaitMessage("sidebar"); + + // Once the switcher panel has been opened new shortcuts should be added + // automatically. + verifyShortcut(extension2.id, "E"); + + await extension1.unload(); + await extension2.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_browser_style.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_browser_style.js new file mode 100644 index 0000000000..866d7a3b3d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_browser_style.js @@ -0,0 +1,90 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function testSidebarBrowserStyle(sidebarAction, assertMessage) { + function sidebarScript() { + browser.test.onMessage.addListener((msgName, info, assertMessage) => { + if (msgName !== "check-style") { + browser.test.notifyFail("sidebar-browser-style"); + } + + let style = window.getComputedStyle(document.getElementById("button")); + let buttonBackgroundColor = style.backgroundColor; + let browserStyleBackgroundColor = "rgb(9, 150, 248)"; + if (!("browser_style" in info) || info.browser_style) { + browser.test.assertEq( + browserStyleBackgroundColor, + buttonBackgroundColor, + assertMessage + ); + } else { + browser.test.assertTrue( + browserStyleBackgroundColor !== buttonBackgroundColor, + assertMessage + ); + } + + browser.test.notifyPass("sidebar-browser-style"); + }); + browser.test.sendMessage("sidebar-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + sidebar_action: sidebarAction, + }, + useAddonManager: "temporary", + + files: { + "panel.html": ` + + + + + `, + "panel.js": sidebarScript, + }, + }); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await extension.startup(); + await extension.awaitMessage("sidebar-ready"); + + extension.sendMessage("check-style", sidebarAction, assertMessage); + await extension.awaitFinish("sidebar-browser-style"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +} + +add_task(async function test_sidebar_without_setting_browser_style() { + await testSidebarBrowserStyle( + { + default_panel: "panel.html", + }, + "Expected correct style when browser_style is excluded" + ); +}); + +add_task(async function test_sidebar_with_browser_style_set_to_true() { + await testSidebarBrowserStyle( + { + default_panel: "panel.html", + browser_style: true, + }, + "Expected correct style when browser_style is set to `true`" + ); +}); + +add_task(async function test_sidebar_with_browser_style_set_to_false() { + await testSidebarBrowserStyle( + { + default_panel: "panel.html", + browser_style: false, + }, + "Expected no style when browser_style is set to `false`" + ); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_click.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_click.js new file mode 100644 index 0000000000..621d2d1180 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_click.js @@ -0,0 +1,74 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_sidebar_click_isAppTab_behavior() { + function sidebarScript() { + browser.tabs.onUpdated.addListener(function onUpdated( + tabId, + changeInfo, + tab + ) { + if ( + changeInfo.status == "complete" && + tab.url == "http://mochi.test:8888/" + ) { + browser.tabs.remove(tab.id); + browser.test.notifyPass("sidebar-click"); + } + }); + window.addEventListener( + "load", + () => { + browser.test.sendMessage("sidebar-ready"); + }, + { once: true } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + sidebar_action: { + default_panel: "panel.html", + browser_style: false, + }, + permissions: ["tabs"], + }, + useAddonManager: "temporary", + + files: { + "panel.html": ` + + + + + + + Bugzilla + `, + "panel.js": sidebarScript, + }, + }); + + await extension.startup(); + await extension.awaitMessage("sidebar-ready"); + + // This test fails if docShell.isAppTab has not been set to true. + let content = SidebarUI.browser.contentWindow; + + // Wait for the layout to be flushed, otherwise this test may + // fail intermittently if synthesizeMouseAtCenter is being called + // while the sidebar is still opening and the browser window layout + // being recomputed. + await content.promiseDocumentFlushed(() => {}); + + info("Clicking link in extension sidebar"); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#testlink", + {}, + content.gBrowser.selectedBrowser + ); + await extension.awaitFinish("sidebar-click"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_context.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_context.js new file mode 100644 index 0000000000..7057037a5e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_context.js @@ -0,0 +1,683 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function runTests(options) { + async function background(getTests) { + async function checkDetails(expecting, details) { + let title = await browser.sidebarAction.getTitle(details); + browser.test.assertEq( + expecting.title, + title, + "expected value from getTitle in " + JSON.stringify(details) + ); + + let panel = await browser.sidebarAction.getPanel(details); + browser.test.assertEq( + expecting.panel, + panel, + "expected value from getPanel in " + JSON.stringify(details) + ); + } + + let tabs = []; + let windows = []; + let tests = getTests(tabs, windows); + + { + let tabId = 0xdeadbeef; + let calls = [ + () => browser.sidebarAction.setTitle({ tabId, title: "foo" }), + () => browser.sidebarAction.setIcon({ tabId, path: "foo.png" }), + () => browser.sidebarAction.setPanel({ tabId, panel: "foo.html" }), + ]; + + for (let call of calls) { + await browser.test.assertRejects( + new Promise(resolve => resolve(call())), + RegExp(`Invalid tab ID: ${tabId}`), + "Expected invalid tab ID error" + ); + } + } + + // Runs the next test in the `tests` array, checks the results, + // and passes control back to the outer test scope. + function nextTest() { + let test = tests.shift(); + + test(async (expectTab, expectWindow, expectGlobal, expectDefault) => { + expectGlobal = { ...expectDefault, ...expectGlobal }; + expectWindow = { ...expectGlobal, ...expectWindow }; + expectTab = { ...expectWindow, ...expectTab }; + + // Check that the API returns the expected values, and then + // run the next test. + let [{ windowId, id: tabId }] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await checkDetails(expectTab, { tabId }); + await checkDetails(expectWindow, { windowId }); + await checkDetails(expectGlobal, {}); + + // Check that the actual icon has the expected values, then + // run the next test. + browser.test.sendMessage("nextTest", expectTab, windowId, tests.length); + }); + } + + browser.test.onMessage.addListener(msg => { + if (msg != "runNextTest") { + browser.test.fail("Expecting 'runNextTest' message"); + } + + nextTest(); + }); + + let [{ id, windowId }] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + tabs.push(id); + windows.push(windowId); + + browser.test.sendMessage("background-page-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: options.manifest, + useAddonManager: "temporary", + + files: options.files || {}, + + background: `(${background})(${options.getTests})`, + }); + + let sidebarActionId; + function checkDetails(details, windowId) { + let { document } = Services.wm.getOuterWindowWithId(windowId); + if (!sidebarActionId) { + sidebarActionId = `${makeWidgetId(extension.id)}-sidebar-action`; + } + + let menuId = `menubar_menu_${sidebarActionId}`; + let menu = document.getElementById(menuId); + ok(menu, "menu exists"); + + let title = details.title || options.manifest.name; + + is(getListStyleImage(menu), details.icon, "icon URL is correct"); + is(menu.getAttribute("label"), title, "image label is correct"); + } + + let awaitFinish = new Promise(resolve => { + extension.onMessage("nextTest", (expecting, windowId, testsRemaining) => { + checkDetails(expecting, windowId); + + if (testsRemaining) { + extension.sendMessage("runNextTest"); + } else { + resolve(); + } + }); + }); + + // Wait for initial sidebar load. + SidebarUI.browser.addEventListener( + "load", + async () => { + // Wait for the background page listeners to be ready and + // then start the tests. + await extension.awaitMessage("background-page-ready"); + extension.sendMessage("runNextTest"); + }, + { capture: true, once: true } + ); + + await extension.startup(); + + await awaitFinish; + await extension.unload(); +} + +let sidebar = ` + + + + + A Test Sidebar + +`; + +add_task(async function testTabSwitchContext() { + await runTests({ + manifest: { + sidebar_action: { + default_icon: "default.png", + default_panel: "__MSG_panel__", + default_title: "Default __MSG_title__", + }, + + default_locale: "en", + + permissions: ["tabs"], + }, + + files: { + "default.html": sidebar, + "global.html": sidebar, + "2.html": sidebar, + + "_locales/en/messages.json": { + panel: { + message: "default.html", + description: "Panel", + }, + + title: { + message: "Title", + description: "Title", + }, + }, + + "default.png": imageBuffer, + "global.png": imageBuffer, + "1.png": imageBuffer, + "2.png": imageBuffer, + }, + + getTests: function (tabs) { + let details = [ + { + icon: browser.runtime.getURL("default.png"), + panel: browser.runtime.getURL("default.html"), + title: "Default Title", + }, + { icon: browser.runtime.getURL("1.png") }, + { + icon: browser.runtime.getURL("2.png"), + panel: browser.runtime.getURL("2.html"), + title: "Title 2", + }, + { + icon: browser.runtime.getURL("global.png"), + panel: browser.runtime.getURL("global.html"), + title: "Global Title", + }, + { + icon: browser.runtime.getURL("1.png"), + panel: browser.runtime.getURL("2.html"), + }, + ]; + + return [ + async expect => { + browser.test.log("Initial state, expect default properties."); + + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.log( + "Change the icon in the current tab. Expect default properties excluding the icon." + ); + await browser.sidebarAction.setIcon({ + tabId: tabs[0], + path: "1.png", + }); + + expect(details[1], null, null, details[0]); + }, + async expect => { + browser.test.log("Create a new tab. Expect default properties."); + let tab = await browser.tabs.create({ + active: true, + url: "about:blank?0", + }); + tabs.push(tab.id); + + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.log("Change properties. Expect new properties."); + let tabId = tabs[1]; + await Promise.all([ + browser.sidebarAction.setIcon({ tabId, path: "2.png" }), + browser.sidebarAction.setPanel({ tabId, panel: "2.html" }), + browser.sidebarAction.setTitle({ tabId, title: "Title 2" }), + ]); + expect(details[2], null, null, details[0]); + }, + expect => { + browser.test.log("Navigate to a new page. Expect no changes."); + + // TODO: This listener should not be necessary, but the |tabs.update| + // callback currently fires too early in e10s windows. + browser.tabs.onUpdated.addListener(function listener(tabId, changed) { + if (tabId == tabs[1] && changed.url) { + browser.tabs.onUpdated.removeListener(listener); + expect(details[2], null, null, details[0]); + } + }); + + browser.tabs.update(tabs[1], { url: "about:blank?1" }); + }, + async expect => { + browser.test.log( + "Switch back to the first tab. Expect previously set properties." + ); + await browser.tabs.update(tabs[0], { active: true }); + expect(details[1], null, null, details[0]); + }, + async expect => { + browser.test.log( + "Change global values, expect those changes reflected." + ); + await Promise.all([ + browser.sidebarAction.setIcon({ path: "global.png" }), + browser.sidebarAction.setPanel({ panel: "global.html" }), + browser.sidebarAction.setTitle({ title: "Global Title" }), + ]); + + expect(details[1], null, details[3], details[0]); + }, + async expect => { + browser.test.log( + "Switch back to tab 2. Expect former tab values, and new global values from previous step." + ); + await browser.tabs.update(tabs[1], { active: true }); + + expect(details[2], null, details[3], details[0]); + }, + async expect => { + browser.test.log( + "Delete tab, switch back to tab 1. Expect previous results again." + ); + await browser.tabs.remove(tabs[1]); + expect(details[1], null, details[3], details[0]); + }, + async expect => { + browser.test.log("Create a new tab. Expect new global properties."); + let tab = await browser.tabs.create({ + active: true, + url: "about:blank?2", + }); + tabs.push(tab.id); + expect(null, null, details[3], details[0]); + }, + async expect => { + browser.test.log("Delete tab."); + await browser.tabs.remove(tabs[2]); + expect(details[1], null, details[3], details[0]); + }, + async expect => { + browser.test.log("Change tab panel."); + let tabId = tabs[0]; + await browser.sidebarAction.setPanel({ tabId, panel: "2.html" }); + expect(details[4], null, details[3], details[0]); + }, + async expect => { + browser.test.log("Revert tab panel."); + let tabId = tabs[0]; + await browser.sidebarAction.setPanel({ tabId, panel: null }); + expect(details[1], null, details[3], details[0]); + }, + ]; + }, + }); +}); + +add_task(async function testDefaultTitle() { + await runTests({ + manifest: { + name: "Foo Extension", + + sidebar_action: { + default_icon: "icon.png", + default_panel: "sidebar.html", + }, + + permissions: ["tabs"], + }, + + files: { + "sidebar.html": sidebar, + "icon.png": imageBuffer, + }, + + getTests: function (tabs) { + let details = [ + { + title: "Foo Extension", + panel: browser.runtime.getURL("sidebar.html"), + icon: browser.runtime.getURL("icon.png"), + }, + { title: "Foo Title" }, + { title: "Bar Title" }, + ]; + + return [ + async expect => { + browser.test.log("Initial state. Expect default extension title."); + + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.log("Change the tab title. Expect new title."); + browser.sidebarAction.setTitle({ + tabId: tabs[0], + title: "Foo Title", + }); + + expect(details[1], null, null, details[0]); + }, + async expect => { + browser.test.log("Change the global title. Expect same properties."); + browser.sidebarAction.setTitle({ title: "Bar Title" }); + + expect(details[1], null, details[2], details[0]); + }, + async expect => { + browser.test.log("Clear the tab title. Expect new global title."); + browser.sidebarAction.setTitle({ tabId: tabs[0], title: null }); + + expect(null, null, details[2], details[0]); + }, + async expect => { + browser.test.log("Clear the global title. Expect default title."); + browser.sidebarAction.setTitle({ title: null }); + + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.assertRejects( + browser.sidebarAction.setPanel({ panel: "about:addons" }), + /Access denied for URL about:addons/, + "unable to set panel to about:addons" + ); + + expect(null, null, null, details[0]); + }, + ]; + }, + }); +}); + +add_task(async function testPropertyRemoval() { + await runTests({ + manifest: { + name: "Foo Extension", + + sidebar_action: { + default_icon: "default.png", + default_panel: "default.html", + default_title: "Default Title", + }, + + permissions: ["tabs"], + }, + + files: { + "default.html": sidebar, + "global.html": sidebar, + "global2.html": sidebar, + "window.html": sidebar, + "tab.html": sidebar, + "default.png": imageBuffer, + "global.png": imageBuffer, + "global2.png": imageBuffer, + "window.png": imageBuffer, + "tab.png": imageBuffer, + }, + + getTests: function (tabs, windows) { + let defaultIcon = "chrome://mozapps/skin/extensions/extensionGeneric.svg"; + let details = [ + { + icon: browser.runtime.getURL("default.png"), + panel: browser.runtime.getURL("default.html"), + title: "Default Title", + }, + { + icon: browser.runtime.getURL("global.png"), + panel: browser.runtime.getURL("global.html"), + title: "global", + }, + { + icon: browser.runtime.getURL("window.png"), + panel: browser.runtime.getURL("window.html"), + title: "window", + }, + { + icon: browser.runtime.getURL("tab.png"), + panel: browser.runtime.getURL("tab.html"), + title: "tab", + }, + { icon: defaultIcon, title: "" }, + { + icon: browser.runtime.getURL("global2.png"), + panel: browser.runtime.getURL("global2.html"), + title: "global2", + }, + ]; + + return [ + async expect => { + browser.test.log("Initial state, expect default properties."); + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.log("Set global values, expect the new values."); + browser.sidebarAction.setIcon({ path: "global.png" }); + browser.sidebarAction.setPanel({ panel: "global.html" }); + browser.sidebarAction.setTitle({ title: "global" }); + expect(null, null, details[1], details[0]); + }, + async expect => { + browser.test.log("Set window values, expect the new values."); + let windowId = windows[0]; + browser.sidebarAction.setIcon({ windowId, path: "window.png" }); + browser.sidebarAction.setPanel({ windowId, panel: "window.html" }); + browser.sidebarAction.setTitle({ windowId, title: "window" }); + expect(null, details[2], details[1], details[0]); + }, + async expect => { + browser.test.log("Set tab values, expect the new values."); + let tabId = tabs[0]; + browser.sidebarAction.setIcon({ tabId, path: "tab.png" }); + browser.sidebarAction.setPanel({ tabId, panel: "tab.html" }); + browser.sidebarAction.setTitle({ tabId, title: "tab" }); + expect(details[3], details[2], details[1], details[0]); + }, + async expect => { + browser.test.log("Set empty tab values."); + let tabId = tabs[0]; + browser.sidebarAction.setIcon({ tabId, path: "" }); + browser.sidebarAction.setPanel({ tabId, panel: "" }); + browser.sidebarAction.setTitle({ tabId, title: "" }); + expect(details[4], details[2], details[1], details[0]); + }, + async expect => { + browser.test.log("Remove tab values, expect window values."); + let tabId = tabs[0]; + browser.sidebarAction.setIcon({ tabId, path: null }); + browser.sidebarAction.setPanel({ tabId, panel: null }); + browser.sidebarAction.setTitle({ tabId, title: null }); + expect(null, details[2], details[1], details[0]); + }, + async expect => { + browser.test.log("Remove window values, expect global values."); + let windowId = windows[0]; + browser.sidebarAction.setIcon({ windowId, path: null }); + browser.sidebarAction.setPanel({ windowId, panel: null }); + browser.sidebarAction.setTitle({ windowId, title: null }); + expect(null, null, details[1], details[0]); + }, + async expect => { + browser.test.log("Change global values, expect the new values."); + browser.sidebarAction.setIcon({ path: "global2.png" }); + browser.sidebarAction.setPanel({ panel: "global2.html" }); + browser.sidebarAction.setTitle({ title: "global2" }); + expect(null, null, details[5], details[0]); + }, + async expect => { + browser.test.log("Remove global values, expect defaults."); + browser.sidebarAction.setIcon({ path: null }); + browser.sidebarAction.setPanel({ panel: null }); + browser.sidebarAction.setTitle({ title: null }); + expect(null, null, null, details[0]); + }, + ]; + }, + }); +}); + +add_task(async function testMultipleWindows() { + await runTests({ + manifest: { + name: "Foo Extension", + + sidebar_action: { + default_icon: "default.png", + default_panel: "default.html", + default_title: "Default Title", + }, + + permissions: ["tabs"], + }, + + files: { + "default.html": sidebar, + "window1.html": sidebar, + "window2.html": sidebar, + "default.png": imageBuffer, + "window1.png": imageBuffer, + "window2.png": imageBuffer, + }, + + getTests: function (tabs, windows) { + let details = [ + { + icon: browser.runtime.getURL("default.png"), + panel: browser.runtime.getURL("default.html"), + title: "Default Title", + }, + { + icon: browser.runtime.getURL("window1.png"), + panel: browser.runtime.getURL("window1.html"), + title: "window1", + }, + { + icon: browser.runtime.getURL("window2.png"), + panel: browser.runtime.getURL("window2.html"), + title: "window2", + }, + { title: "tab" }, + ]; + + return [ + async expect => { + browser.test.log("Initial state, expect default properties."); + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.log("Set window values, expect the new values."); + let windowId = windows[0]; + browser.sidebarAction.setIcon({ windowId, path: "window1.png" }); + browser.sidebarAction.setPanel({ windowId, panel: "window1.html" }); + browser.sidebarAction.setTitle({ windowId, title: "window1" }); + expect(null, details[1], null, details[0]); + }, + async expect => { + browser.test.log("Create a new tab, expect window values."); + let tab = await browser.tabs.create({ active: true }); + tabs.push(tab.id); + expect(null, details[1], null, details[0]); + }, + async expect => { + browser.test.log("Set a tab title, expect it."); + await browser.sidebarAction.setTitle({ + tabId: tabs[1], + title: "tab", + }); + expect(details[3], details[1], null, details[0]); + }, + async expect => { + browser.test.log("Open a new window, expect default values."); + let { id } = await browser.windows.create(); + windows.push(id); + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.log("Set window values, expect the new values."); + let windowId = windows[1]; + browser.sidebarAction.setIcon({ windowId, path: "window2.png" }); + browser.sidebarAction.setPanel({ windowId, panel: "window2.html" }); + browser.sidebarAction.setTitle({ windowId, title: "window2" }); + expect(null, details[2], null, details[0]); + }, + async expect => { + browser.test.log( + "Move tab from old window to the new one. Tab-specific data" + + " is preserved but inheritance is from the new window" + ); + await browser.tabs.move(tabs[1], { windowId: windows[1], index: -1 }); + await browser.tabs.update(tabs[1], { active: true }); + expect(details[3], details[2], null, details[0]); + }, + async expect => { + browser.test.log("Close the initial tab of the new window."); + let [{ id }] = await browser.tabs.query({ + windowId: windows[1], + index: 0, + }); + await browser.tabs.remove(id); + expect(details[3], details[2], null, details[0]); + }, + async expect => { + browser.test.log( + "Move the previous tab to a 3rd window, the 2nd one will close." + ); + await browser.windows.create({ tabId: tabs[1] }); + expect(details[3], null, null, details[0]); + }, + async expect => { + browser.test.log("Close the tab, go back to the 1st window."); + await browser.tabs.remove(tabs[1]); + expect(null, details[1], null, details[0]); + }, + async expect => { + browser.test.log( + "Assert failures for bad parameters. Expect no change" + ); + + let calls = { + setIcon: { path: "default.png" }, + setPanel: { panel: "default.html" }, + setTitle: { title: "Default Title" }, + getPanel: {}, + getTitle: {}, + }; + for (let [method, arg] of Object.entries(calls)) { + browser.test.assertThrows( + () => browser.sidebarAction[method]({ ...arg, windowId: -3 }), + /-3 is too small \(must be at least -2\)/, + method + " with invalid windowId" + ); + await browser.test.assertRejects( + browser.sidebarAction[method]({ + ...arg, + tabId: tabs[0], + windowId: windows[0], + }), + /Only one of tabId and windowId can be specified/, + method + " with both tabId and windowId" + ); + } + + expect(null, details[1], null, details[0]); + }, + ]; + }, + }); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_contextMenu.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_contextMenu.js new file mode 100644 index 0000000000..3317e6b7e0 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_contextMenu.js @@ -0,0 +1,133 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +let extData = { + manifest: { + permissions: ["contextMenus"], + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + useAddonManager: "temporary", + + files: { + "sidebar.html": ` + + + + + + + A Test Sidebar + + + `, + + "sidebar.js": function () { + window.onload = () => { + browser.test.sendMessage("sidebar"); + }; + }, + }, + + background: function () { + browser.contextMenus.create({ + id: "clickme-page", + title: "Click me!", + contexts: ["all"], + onclick(info, tab) { + browser.test.sendMessage("menu-click", tab); + }, + }); + }, +}; + +let contextMenuItems = { + "context-sep-navigation": "hidden", + "context-viewsource": "", + "inspect-separator": "hidden", + "context-inspect": "hidden", + "context-inspect-a11y": "hidden", + "context-bookmarkpage": "hidden", +}; +if (AppConstants.platform == "macosx") { + contextMenuItems["context-back"] = "hidden"; + contextMenuItems["context-forward"] = "hidden"; + contextMenuItems["context-reload"] = "hidden"; + contextMenuItems["context-stop"] = "hidden"; +} else { + contextMenuItems["context-navigation"] = "hidden"; +} + +add_task(async function sidebar_contextmenu() { + let extension = ExtensionTestUtils.loadExtension(extData); + await extension.startup(); + // Test sidebar is opened on install + await extension.awaitMessage("sidebar"); + + let contentAreaContextMenu = await openContextMenuInSidebar(); + let item = contentAreaContextMenu.getElementsByAttribute( + "label", + "Click me!" + ); + is(item.length, 1, "contextMenu item for page was found"); + + item[0].click(); + await closeContextMenu(contentAreaContextMenu); + let tab = await extension.awaitMessage("menu-click"); + is( + tab, + null, + "tab argument is optional, and missing in clicks from sidebars" + ); + + await extension.unload(); +}); + +add_task(async function sidebar_contextmenu_hidden_items() { + let extension = ExtensionTestUtils.loadExtension(extData); + await extension.startup(); + // Test sidebar is opened on install + await extension.awaitMessage("sidebar"); + + let contentAreaContextMenu = await openContextMenuInSidebar("#text"); + + let item, state; + for (const itemID in contextMenuItems) { + item = contentAreaContextMenu.querySelector(`#${itemID}`); + state = contextMenuItems[itemID]; + + if (state !== "") { + ok(item[state], `${itemID} is ${state}`); + + if (state !== "hidden") { + ok(!item.hidden, `Disabled ${itemID} is not hidden`); + } + } else { + ok(!item.hidden, `${itemID} is not hidden`); + ok(!item.disabled, `${itemID} is not disabled`); + } + } + + await closeContextMenu(contentAreaContextMenu); + + await extension.unload(); +}); + +add_task(async function sidebar_image_contextmenu() { + let extension = ExtensionTestUtils.loadExtension(extData); + await extension.startup(); + // Test sidebar is opened on install + await extension.awaitMessage("sidebar"); + + let contentAreaContextMenu = await openContextMenuInSidebar("#testimg"); + + let item = contentAreaContextMenu.querySelector("#context-copyimage"); + ok(!item.hidden); + ok(!item.disabled); + + await closeContextMenu(contentAreaContextMenu); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_httpAuth.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_httpAuth.js new file mode 100644 index 0000000000..d50d96b822 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_httpAuth.js @@ -0,0 +1,72 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PromptTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromptTestUtils.sys.mjs" +); + +add_task(async function sidebar_httpAuthPrompt() { + let data = { + manifest: { + permissions: ["https://example.com/*"], + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + useAddonManager: "temporary", + files: { + "sidebar.html": ` + + + + + + + A Test Sidebar + + `, + "sidebar.js": function () { + fetch( + "https://example.com/browser/browser/components/extensions/test/browser/authenticate.sjs?user=user&pass=pass", + { credentials: "include" } + ).then(response => { + browser.test.sendMessage("fetchResult", response.ok); + }); + }, + }, + }; + + // Wait for the http auth prompt and close it with accept button. + let promptPromise = PromptTestUtils.handleNextPrompt( + SidebarUI.browser.contentWindow, + { + modalType: Services.prompt.MODAL_TYPE_WINDOW, + promptType: "promptUserAndPass", + }, + { buttonNumClick: 0, loginInput: "user", passwordInput: "pass" } + ); + + let extension = ExtensionTestUtils.loadExtension(data); + await extension.startup(); + let fetchResultPromise = extension.awaitMessage("fetchResult"); + + await promptPromise; + ok(true, "Extension fetch should trigger auth prompt."); + + let responseOk = await fetchResultPromise; + ok(responseOk, "Login should succeed."); + + await extension.unload(); + + // Cleanup + await new Promise(resolve => + Services.clearData.deleteData( + Ci.nsIClearDataService.CLEAR_AUTH_CACHE, + resolve + ) + ); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_incognito.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_incognito.js new file mode 100644 index 0000000000..221447cf2e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_incognito.js @@ -0,0 +1,139 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_sidebarAction_not_allowed() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + background() { + browser.test.onMessage.addListener(async pbw => { + await browser.test.assertRejects( + browser.sidebarAction.setTitle({ + windowId: pbw.windowId, + title: "test", + }), + /Invalid window ID/, + "should not be able to set title with windowId" + ); + await browser.test.assertRejects( + browser.sidebarAction.setTitle({ + tabId: pbw.tabId, + title: "test", + }), + /Invalid tab ID/, + "should not be able to set title" + ); + await browser.test.assertRejects( + browser.sidebarAction.getTitle({ + windowId: pbw.windowId, + }), + /Invalid window ID/, + "should not be able to get title with windowId" + ); + await browser.test.assertRejects( + browser.sidebarAction.getTitle({ + tabId: pbw.tabId, + }), + /Invalid tab ID/, + "should not be able to get title with tabId" + ); + + await browser.test.assertRejects( + browser.sidebarAction.setIcon({ + windowId: pbw.windowId, + path: "test", + }), + /Invalid window ID/, + "should not be able to set icon with windowId" + ); + await browser.test.assertRejects( + browser.sidebarAction.setIcon({ + tabId: pbw.tabId, + path: "test", + }), + /Invalid tab ID/, + "should not be able to set icon with tabId" + ); + + await browser.test.assertRejects( + browser.sidebarAction.setPanel({ + windowId: pbw.windowId, + panel: "test", + }), + /Invalid window ID/, + "should not be able to set panel with windowId" + ); + await browser.test.assertRejects( + browser.sidebarAction.setPanel({ + tabId: pbw.tabId, + panel: "test", + }), + /Invalid tab ID/, + "should not be able to set panel with tabId" + ); + await browser.test.assertRejects( + browser.sidebarAction.getPanel({ + windowId: pbw.windowId, + }), + /Invalid window ID/, + "should not be able to get panel with windowId" + ); + await browser.test.assertRejects( + browser.sidebarAction.getPanel({ + tabId: pbw.tabId, + }), + /Invalid tab ID/, + "should not be able to get panel with tabId" + ); + + await browser.test.assertRejects( + browser.sidebarAction.isOpen({ + windowId: pbw.windowId, + }), + /Invalid window ID/, + "should not be able to determine openness with windowId" + ); + + browser.test.notifyPass("pass"); + }); + }, + files: { + "sidebar.html": ` + + + + + + + A Test Sidebar + + + `, + + "sidebar.js": function () { + window.onload = () => { + browser.test.sendMessage("sidebar"); + }; + }, + }, + }); + + await extension.startup(); + let sidebarID = `${makeWidgetId(extension.id)}-sidebar-action`; + ok(SidebarUI.sidebars.has(sidebarID), "sidebar exists in non-private window"); + + let winData = await getIncognitoWindow(); + + let hasSidebar = winData.win.SidebarUI.sidebars.has(sidebarID); + ok(!hasSidebar, "sidebar does not exist in private window"); + // Test API access to private window data. + extension.sendMessage(winData.details); + await extension.awaitFinish("pass"); + + await BrowserTestUtils.closeWindow(winData.win); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_runtime.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_runtime.js new file mode 100644 index 0000000000..55c83ee0b1 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_runtime.js @@ -0,0 +1,76 @@ +"use strict"; + +function background() { + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq(port.name, "ernie", "port name correct"); + port.onDisconnect.addListener(() => { + browser.test.assertEq( + null, + port.error, + "The port is implicitly closed without errors when the other context unloads" + ); + port.disconnect(); + browser.test.sendMessage("disconnected"); + }); + browser.test.sendMessage("connected"); + }); +} + +let extensionData = { + background, + manifest: { + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + useAddonManager: "temporary", + + files: { + "sidebar.html": ` + + + + + + + A Test Sidebar + + `, + + "sidebar.js": function () { + window.onload = () => { + browser.runtime.connect({ name: "ernie" }); + }; + }, + }, +}; + +add_task(async function test_sidebar_disconnect() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + let connected = extension.awaitMessage("connected"); + await extension.startup(); + await connected; + + // Bug 1445080 fixes currentURI, test to avoid future breakage. + let currentURI = window.SidebarUI.browser.contentDocument.getElementById( + "webext-panels-browser" + ).currentURI; + is(currentURI.scheme, "moz-extension", "currentURI is set correctly"); + + // switching sidebar to another extension + let extension2 = ExtensionTestUtils.loadExtension(extensionData); + let switched = Promise.all([ + extension.awaitMessage("disconnected"), + extension2.awaitMessage("connected"), + ]); + await extension2.startup(); + await switched; + + // switching sidebar to built-in sidebar + let disconnected = extension2.awaitMessage("disconnected"); + window.SidebarUI.show("viewBookmarksSidebar"); + await disconnected; + + await extension.unload(); + await extension2.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_tabs.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_tabs.js new file mode 100644 index 0000000000..7af75cdc19 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_tabs.js @@ -0,0 +1,48 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function sidebar_tab_query_bug_1340739() { + let data = { + manifest: { + permissions: ["tabs"], + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + useAddonManager: "temporary", + files: { + "sidebar.html": ` + + + + + + + A Test Sidebar + + `, + "sidebar.js": function () { + Promise.all([ + browser.tabs.query({}).then(tabs => { + browser.test.assertEq( + 1, + tabs.length, + "got tab without currentWindow" + ); + }), + browser.tabs.query({ currentWindow: true }).then(tabs => { + browser.test.assertEq(1, tabs.length, "got tab with currentWindow"); + }), + ]).then(() => { + browser.test.sendMessage("sidebar"); + }); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(data); + await extension.startup(); + await extension.awaitMessage("sidebar"); + 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 new file mode 100644 index 0000000000..58f2b07797 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_windows.js @@ -0,0 +1,69 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +let extData = { + manifest: { + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + useAddonManager: "temporary", + + files: { + "sidebar.html": ` + + + + + + + A Test Sidebar + + `, + + "sidebar.js": function () { + window.onload = () => { + browser.test.sendMessage("sidebar"); + }; + }, + }, +}; + +add_task(async function sidebar_windows() { + let extension = ExtensionTestUtils.loadExtension(extData); + await extension.startup(); + // Test sidebar is opened on install + await extension.awaitMessage("sidebar"); + ok( + !document.getElementById("sidebar-box").hidden, + "sidebar box is visible in first window" + ); + // Check that the menuitem has our image styling. + let elements = document.getElementsByClassName("webextension-menuitem"); + // ui is in flux, at time of writing we potentially have 3 menuitems, later + // it may be two or one, just make sure one is there. + ok(!!elements.length, "have a menuitem"); + let style = elements[0].getAttribute("style"); + ok(style.includes("webextension-menuitem-image"), "this menu has style"); + + let secondSidebar = extension.awaitMessage("sidebar"); + + // SidebarUI 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(); + + await secondSidebar; + ok( + !win.document.getElementById("sidebar-box").hidden, + "sidebar box is visible in second window" + ); + // Check that the menuitem has our image styling. + elements = win.document.getElementsByClassName("webextension-menuitem"); + ok(!!elements.length, "have a menuitem"); + style = elements[0].getAttribute("style"); + ok(style.includes("webextension-menuitem-image"), "this menu has style"); + + await extension.unload(); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebar_requestPermission.js b/browser/components/extensions/test/browser/browser_ext_sidebar_requestPermission.js new file mode 100644 index 0000000000..393efcf99e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebar_requestPermission.js @@ -0,0 +1,43 @@ +"use strict"; + +add_task(async function test_sidebar_requestPermission_resolve() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + sidebar_action: { + default_panel: "panel.html", + browser_style: false, + }, + optional_permissions: ["tabs"], + }, + useAddonManager: "temporary", + files: { + "panel.html": ``, + "panel.js": async () => { + const success = await new Promise(resolve => { + browser.test.withHandlingUserInput(() => { + resolve( + browser.permissions.request({ + permissions: ["tabs"], + }) + ); + }); + }); + browser.test.assertTrue( + success, + "browser.permissions.request promise resolves" + ); + browser.test.sendMessage("done"); + }, + }, + }); + + const requestPrompt = promisePopupNotificationShown( + "addon-webext-permissions" + ).then(panel => { + panel.button.click(); + }); + await extension.startup(); + await requestPrompt; + await extension.awaitMessage("done"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_simple.js b/browser/components/extensions/test/browser/browser_ext_simple.js new file mode 100644 index 0000000000..4d9d7c73fa --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_simple.js @@ -0,0 +1,60 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_simple() { + let extensionData = { + manifest: { + name: "Simple extension test", + version: "1.0", + manifest_version: 2, + description: "", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + info("load complete"); + await extension.startup(); + info("startup complete"); + await extension.unload(); + info("extension unloaded successfully"); +}); + +add_task(async function test_background() { + function backgroundScript() { + browser.test.log("running background script"); + + browser.test.onMessage.addListener((x, y) => { + browser.test.assertEq(x, 10, "x is 10"); + browser.test.assertEq(y, 20, "y is 20"); + + browser.test.notifyPass("background test passed"); + }); + + browser.test.sendMessage("running", 1); + } + + let extensionData = { + background: "(" + backgroundScript.toString() + ")()", + manifest: { + name: "Simple extension test", + version: "1.0", + manifest_version: 2, + description: "", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + info("load complete"); + let [, x] = await Promise.all([ + extension.startup(), + extension.awaitMessage("running"), + ]); + is(x, 1, "got correct value from extension"); + info("startup complete"); + extension.sendMessage(10, 20); + await extension.awaitFinish(); + info("test complete"); + await extension.unload(); + info("extension unloaded successfully"); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_slow_script.js b/browser/components/extensions/test/browser/browser_ext_slow_script.js new file mode 100644 index 0000000000..bd9369a904 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_slow_script.js @@ -0,0 +1,72 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const DEFAULT_PROCESS_COUNT = Services.prefs + .getDefaultBranch(null) + .getIntPref("dom.ipc.processCount"); + +add_task(async function test_slow_content_script() { + // Make sure we get a new process for our tab, or our reportProcessHangs + // preferences value won't apply to it. + await SpecialPowers.pushPrefEnv({ + set: [ + ["dom.ipc.processCount", 1], + ["dom.ipc.keepProcessesAlive.web", 0], + ], + }); + await SpecialPowers.popPrefEnv(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["dom.ipc.processCount", DEFAULT_PROCESS_COUNT * 2], + ["dom.ipc.processPrelaunch.enabled", false], + ["dom.ipc.reportProcessHangs", true], + ["dom.max_script_run_time.require_critical_input", false], + ], + }); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: { + name: "Slow Script Extension", + + content_scripts: [ + { + matches: ["http://example.com/"], + js: ["content.js"], + }, + ], + }, + + files: { + "content.js": function () { + while (true) { + // Busy wait. + } + }, + }, + }); + + await extension.startup(); + + let alert = BrowserTestUtils.waitForGlobalNotificationBar( + window, + "process-hang" + ); + + BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com/"); + + let notification = await alert; + let text = notification.messageText.textContent; + + ok(text.includes("\u201cSlow Script Extension\u201d"), "Label is correct"); + + let stopButton = notification.buttonContainer.querySelector("[label='Stop']"); + stopButton.click(); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tab_runtimeConnect.js b/browser/components/extensions/test/browser/browser_ext_tab_runtimeConnect.js new file mode 100644 index 0000000000..622916edda --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tab_runtimeConnect.js @@ -0,0 +1,100 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: function () { + let messages_received = []; + + let tabId; + + browser.runtime.onConnect.addListener(port => { + browser.test.assertTrue(!!port, "tab to background port received"); + browser.test.assertEq( + "tab-connection-name", + port.name, + "port name should be defined and equal to connectInfo.name" + ); + browser.test.assertTrue( + !!port.sender.tab, + "port.sender.tab should be defined" + ); + browser.test.assertEq( + tabId, + port.sender.tab.id, + "port.sender.tab.id should be equal to the expected tabId" + ); + + port.onMessage.addListener(msg => { + messages_received.push(msg); + + if (messages_received.length == 1) { + browser.test.assertEq( + "tab to background port message", + msg, + "'tab to background' port message received" + ); + port.postMessage("background to tab port message"); + } + + if (messages_received.length == 2) { + browser.test.assertTrue( + !!msg.tabReceived, + "'background to tab' reply port message received" + ); + browser.test.assertEq( + "background to tab port message", + msg.tabReceived, + "reply port content contains the message received" + ); + + browser.test.notifyPass("tabRuntimeConnect.pass"); + } + }); + }); + + browser.tabs.create({ url: "tab.html" }, tab => { + tabId = tab.id; + }); + }, + + files: { + "tab.js": function () { + let port = browser.runtime.connect({ name: "tab-connection-name" }); + port.postMessage("tab to background port message"); + port.onMessage.addListener(msg => { + port.postMessage({ tabReceived: msg }); + }); + }, + "tab.html": ` + + + + test tab extension page + + + + +

    test tab extension page

    + + + `, + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabRuntimeConnect.pass"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_attention.js b/browser/components/extensions/test/browser/browser_ext_tabs_attention.js new file mode 100644 index 0000000000..0f267460f3 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_attention.js @@ -0,0 +1,64 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function tabsAttention() { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/?2", + true + ); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/?1", + true + ); + gBrowser.selectedTab = tab2; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "http://example.com/*"], + }, + + background: async function () { + function onActive(tabId, changeInfo, tab) { + browser.test.assertFalse( + changeInfo.attention, + "changeInfo.attention should be false" + ); + browser.test.assertFalse( + tab.attention, + "tab.attention should be false" + ); + browser.test.assertTrue(tab.active, "tab.active should be true"); + browser.test.notifyPass("tabsAttention"); + } + + function onUpdated(tabId, changeInfo, tab) { + browser.test.assertTrue( + changeInfo.attention, + "changeInfo.attention should be true" + ); + browser.test.assertTrue(tab.attention, "tab.attention should be true"); + browser.tabs.onUpdated.removeListener(onUpdated); + browser.tabs.onUpdated.addListener(onActive); + browser.tabs.update(tabId, { active: true }); + } + + browser.tabs.onUpdated.addListener(onUpdated, { + properties: ["attention"], + }); + const tabs = await browser.tabs.query({ index: 1 }); + browser.tabs.executeScript(tabs[0].id, { + code: `alert("tab attention")`, + }); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabsAttention"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_audio.js b/browser/components/extensions/test/browser/browser_ext_tabs_audio.js new file mode 100644 index 0000000000..978c3697c8 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_audio.js @@ -0,0 +1,261 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank?1" + ); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank?2" + ); + + gBrowser.selectedTab = tab1; + + async function background() { + function promiseUpdated(tabId, attr) { + return new Promise(resolve => { + let onUpdated = (tabId_, changeInfo, tab) => { + if (tabId == tabId_ && attr in changeInfo) { + browser.tabs.onUpdated.removeListener(onUpdated); + + resolve({ changeInfo, tab }); + } + }; + browser.tabs.onUpdated.addListener(onUpdated); + }); + } + + let deferred = {}; + browser.test.onMessage.addListener((message, tabId, result) => { + if (message == "change-tab-done" && deferred[tabId]) { + deferred[tabId].resolve(result); + } + }); + + function changeTab(tabId, attr, on) { + return new Promise((resolve, reject) => { + deferred[tabId] = { resolve, reject }; + browser.test.sendMessage("change-tab", tabId, attr, on); + }); + } + + try { + let tabs = await browser.tabs.query({ lastFocusedWindow: true }); + browser.test.assertEq(tabs.length, 3, "We have three tabs"); + + for (let tab of tabs) { + // Note: We want to check that these are actual boolean values, not + // just that they evaluate as false. + browser.test.assertEq(false, tab.mutedInfo.muted, "Tab is not muted"); + browser.test.assertEq( + undefined, + tab.mutedInfo.reason, + "Tab has no muted info reason" + ); + browser.test.assertEq(false, tab.audible, "Tab is not audible"); + } + + let windowId = tabs[0].windowId; + let tabIds = [tabs[1].id, tabs[2].id]; + + browser.test.log( + "Test initial queries for muted and audible return no tabs" + ); + let silent = await browser.tabs.query({ windowId, audible: false }); + let audible = await browser.tabs.query({ windowId, audible: true }); + let muted = await browser.tabs.query({ windowId, muted: true }); + let nonMuted = await browser.tabs.query({ windowId, muted: false }); + + browser.test.assertEq(3, silent.length, "Three silent tabs"); + browser.test.assertEq(0, audible.length, "No audible tabs"); + + browser.test.assertEq(0, muted.length, "No muted tabs"); + browser.test.assertEq(3, nonMuted.length, "Three non-muted tabs"); + + browser.test.log( + "Toggle muted and audible externally on one tab each, and check results" + ); + [muted, audible] = await Promise.all([ + promiseUpdated(tabIds[0], "mutedInfo"), + promiseUpdated(tabIds[1], "audible"), + changeTab(tabIds[0], "muted", true), + changeTab(tabIds[1], "audible", true), + ]); + + for (let obj of [muted.changeInfo, muted.tab]) { + browser.test.assertEq(true, obj.mutedInfo.muted, "Tab is muted"); + browser.test.assertEq( + "user", + obj.mutedInfo.reason, + "Tab was muted by the user" + ); + } + + browser.test.assertEq( + true, + audible.changeInfo.audible, + "Tab audible state changed" + ); + browser.test.assertEq(true, audible.tab.audible, "Tab is audible"); + + browser.test.log( + "Re-check queries. Expect one audible and one muted tab" + ); + silent = await browser.tabs.query({ windowId, audible: false }); + audible = await browser.tabs.query({ windowId, audible: true }); + muted = await browser.tabs.query({ windowId, muted: true }); + nonMuted = await browser.tabs.query({ windowId, muted: false }); + + browser.test.assertEq(2, silent.length, "Two silent tabs"); + browser.test.assertEq(1, audible.length, "One audible tab"); + + browser.test.assertEq(1, muted.length, "One muted tab"); + browser.test.assertEq(2, nonMuted.length, "Two non-muted tabs"); + + browser.test.assertEq(true, muted[0].mutedInfo.muted, "Tab is muted"); + browser.test.assertEq( + "user", + muted[0].mutedInfo.reason, + "Tab was muted by the user" + ); + + browser.test.assertEq(true, audible[0].audible, "Tab is audible"); + + browser.test.log( + "Toggle muted internally on two tabs, and check results" + ); + [nonMuted, muted] = await Promise.all([ + promiseUpdated(tabIds[0], "mutedInfo"), + promiseUpdated(tabIds[1], "mutedInfo"), + browser.tabs.update(tabIds[0], { muted: false }), + browser.tabs.update(tabIds[1], { muted: true }), + ]); + + for (let obj of [nonMuted.changeInfo, nonMuted.tab]) { + browser.test.assertEq(false, obj.mutedInfo.muted, "Tab is not muted"); + } + for (let obj of [muted.changeInfo, muted.tab]) { + browser.test.assertEq(true, obj.mutedInfo.muted, "Tab is muted"); + } + + for (let obj of [ + nonMuted.changeInfo, + nonMuted.tab, + muted.changeInfo, + muted.tab, + ]) { + browser.test.assertEq( + "extension", + obj.mutedInfo.reason, + "Mute state changed by extension" + ); + + browser.test.assertEq( + browser.runtime.id, + obj.mutedInfo.extensionId, + "Mute state changed by extension" + ); + } + + browser.test.log("Test that mutedInfo is preserved by sessionstore"); + let tab = await changeTab(tabIds[1], "duplicate").then(browser.tabs.get); + + browser.test.assertEq(true, tab.mutedInfo.muted, "Tab is muted"); + + browser.test.assertEq( + "extension", + tab.mutedInfo.reason, + "Mute state changed by extension" + ); + + browser.test.assertEq( + browser.runtime.id, + tab.mutedInfo.extensionId, + "Mute state changed by extension" + ); + + browser.test.log("Unmute externally, and check results"); + [nonMuted] = await Promise.all([ + promiseUpdated(tabIds[1], "mutedInfo"), + changeTab(tabIds[1], "muted", false), + browser.tabs.remove(tab.id), + ]); + + for (let obj of [nonMuted.changeInfo, nonMuted.tab]) { + browser.test.assertEq(false, obj.mutedInfo.muted, "Tab is not muted"); + browser.test.assertEq( + "user", + obj.mutedInfo.reason, + "Mute state changed by user" + ); + } + + browser.test.notifyPass("tab-audio"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tab-audio"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background, + }); + + extension.onMessage("change-tab", (tabId, attr, on) => { + const { + Management: { + global: { tabTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + + let tab = tabTracker.getTab(tabId); + + if (attr == "muted") { + // Ideally we'd simulate a click on the tab audio icon for this, but the + // handler relies on CSS :hover states, which are complicated and fragile + // to simulate. + if (tab.muted != on) { + tab.toggleMuteAudio(); + } + } else if (attr == "audible") { + let browser = tab.linkedBrowser; + if (on) { + browser.audioPlaybackStarted(); + } else { + browser.audioPlaybackStopped(); + } + } else if (attr == "duplicate") { + // This is a bit of a hack. It won't be necessary once we have + // `tabs.duplicate`. + let newTab = gBrowser.duplicateTab(tab); + BrowserTestUtils.waitForEvent(newTab, "SSTabRestored", () => true).then( + () => { + extension.sendMessage( + "change-tab-done", + tabId, + tabTracker.getId(newTab) + ); + } + ); + return; + } + + extension.sendMessage("change-tab-done", tabId); + }); + + await extension.startup(); + + await extension.awaitFinish("tab-audio"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_autoDiscardable.js b/browser/components/extensions/test/browser/browser_ext_tabs_autoDiscardable.js new file mode 100644 index 0000000000..74043e2a3a --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_autoDiscardable.js @@ -0,0 +1,177 @@ +"use strict"; + +add_task(async function test_autoDiscardable() { + let files = { + "schema.json": JSON.stringify([ + { + namespace: "experiments", + functions: [ + { + name: "unload", + type: "function", + async: true, + description: + "Unload the least recently used tab using Firefox's built-in tab unloader mechanism", + parameters: [], + }, + ], + }, + ]), + "parent.js": () => { + const { TabUnloader } = ChromeUtils.importESModule( + "resource:///modules/TabUnloader.sys.mjs" + ); + const { ExtensionError } = ExtensionUtils; + this.experiments = class extends ExtensionAPI { + getAPI(context) { + return { + experiments: { + async unload() { + try { + await TabUnloader.unloadLeastRecentlyUsedTab(null); + } catch (error) { + // We need to do this, otherwise failures won't bubble up to the test properly. + throw ExtensionError(error); + } + }, + }, + }; + } + }; + }, + }; + + async function background() { + let firstTab = await browser.tabs.create({ + active: false, + url: "https://example.org/", + }); + + // Make sure setting and getting works properly + browser.test.assertTrue( + firstTab.autoDiscardable, + "autoDiscardable should always be true by default" + ); + let result = await browser.tabs.update(firstTab.id, { + autoDiscardable: false, + }); + browser.test.assertFalse( + result.autoDiscardable, + "autoDiscardable should be false after setting it as such" + ); + result = await browser.tabs.update(firstTab.id, { + autoDiscardable: true, + }); + browser.test.assertTrue( + result.autoDiscardable, + "autoDiscardable should be true after setting it as such" + ); + result = await browser.tabs.update(firstTab.id, { + autoDiscardable: false, + }); + browser.test.assertFalse( + result.autoDiscardable, + "autoDiscardable should be false after setting it as such" + ); + + // Make sure the tab can't be unloaded when autoDiscardable is false + await browser.experiments.unload(); + result = await browser.tabs.get(firstTab.id); + browser.test.assertFalse( + result.discarded, + "Tab should not unload when autoDiscardable is false" + ); + + // Make sure the tab CAN be unloaded when autoDiscardable is true + await browser.tabs.update(firstTab.id, { + autoDiscardable: true, + }); + await browser.experiments.unload(); + result = await browser.tabs.get(firstTab.id); + browser.test.assertTrue( + result.discarded, + "Tab should unload when autoDiscardable is true" + ); + + // Make sure filtering for discardable tabs works properly + result = await browser.tabs.query({ autoDiscardable: true }); + browser.test.assertEq( + 2, + result.length, + "tabs.query should return 2 when autoDiscardable is true " + ); + await browser.tabs.update(firstTab.id, { + autoDiscardable: false, + }); + result = await browser.tabs.query({ autoDiscardable: true }); + browser.test.assertEq( + 1, + result.length, + "tabs.query should return 1 when autoDiscardable is false" + ); + + let onUpdatedPromise = {}; + onUpdatedPromise.promise = new Promise( + resolve => (onUpdatedPromise.resolve = resolve) + ); + + // Make sure onUpdated works + async function testOnUpdatedEvent(autoDiscardable) { + browser.test.log(`Testing autoDiscardable = ${autoDiscardable}`); + let onUpdated; + let promise = new Promise(resolve => { + onUpdated = (tabId, changeInfo, tabInfo) => { + browser.test.assertEq( + firstTab.id, + tabId, + "The updated tab's ID should match the correct tab" + ); + browser.test.assertDeepEq( + { autoDiscardable }, + changeInfo, + "The updated tab's changeInfo should be correct" + ); + browser.test.assertEq( + tabInfo.autoDiscardable, + autoDiscardable, + "The updated tab's tabInfo should be correct" + ); + resolve(); + }; + }); + browser.tabs.onUpdated.addListener(onUpdated, { + properties: ["autoDiscardable"], + }); + await browser.tabs.update(firstTab.id, { autoDiscardable }); + await promise; + browser.tabs.onUpdated.removeListener(onUpdated); + } + + await testOnUpdatedEvent(true); + await testOnUpdatedEvent(false); + + await browser.tabs.remove(firstTab.id); // Cleanup + browser.test.notifyPass("autoDiscardable"); + } + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + permissions: ["tabs"], + experiment_apis: { + experiments: { + schema: "schema.json", + parent: { + scopes: ["addon_parent"], + script: "parent.js", + paths: [["experiments"]], + }, + }, + }, + }, + background, + files, + }); + await extension.startup(); + await extension.awaitFinish("autoDiscardable"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_containerIsolation.js b/browser/components/extensions/test/browser/browser_ext_tabs_containerIsolation.js new file mode 100644 index 0000000000..1960366bb5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_containerIsolation.js @@ -0,0 +1,360 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; +const { XPCShellContentUtils } = ChromeUtils.importESModule( + "resource://testing-common/XPCShellContentUtils.sys.mjs" +); +XPCShellContentUtils.initMochitest(this); +const server = XPCShellContentUtils.createHttpServer({ + hosts: ["www.example.com"], +}); +server.registerPathHandler("/", (request, response) => { + response.setHeader("Content-Type", "text/html; charset=UTF-8", false); + response.write(` + + + + + + This is example.com page content + + + `); +}); + +add_task(async function containerIsolation_restricted() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.userContextIsolation.enabled", true], + ["privacy.userContext.enabled", true], + ], + }); + + let helperExtension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "cookies"], + }, + + async background() { + browser.test.onMessage.addListener(async message => { + let tab; + if (message?.subject !== "createTab") { + browser.test.fail( + `Unexpected test message received: ${JSON.stringify(message)}` + ); + return; + } + + tab = await browser.tabs.create({ + url: message.data.url, + cookieStoreId: message.data.cookieStoreId, + }); + browser.test.sendMessage("tabCreated", tab.id); + browser.test.assertEq( + message.data.cookieStoreId, + tab.cookieStoreId, + "Created tab is associated with the expected cookieStoreId" + ); + }); + }, + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "cookies", "", "tabHide"], + }, + async background() { + const [restrictedTab, unrestrictedTab] = await new Promise(resolve => { + browser.test.onMessage.addListener(message => resolve(message)); + }); + + // Check that print preview method fails + await browser.test.assertRejects( + browser.tabs.printPreview(), + /Cannot access activeTab/, + "should refuse to print a preview of the tab for the container which doesn't have permission" + ); + + // Check that save As PDF method fails + await browser.test.assertRejects( + browser.tabs.saveAsPDF({}), + /Cannot access activeTab/, + "should refuse to save as PDF of the tab for the container which doesn't have permission" + ); + + // Check that create method fails + await browser.test.assertRejects( + browser.tabs.create({ cookieStoreId: "firefox-container-1" }), + /Cannot access firefox-container-1/, + "should refuse to create container tab for the container which doesn't have permission" + ); + + // Check that detect language method fails + await browser.test.assertRejects( + browser.tabs.detectLanguage(restrictedTab), + /Invalid tab ID/, + "should refuse to detect language of a tab for the container which doesn't have permission" + ); + + // Check that move tabs method fails + await browser.test.assertRejects( + browser.tabs.move(restrictedTab, { index: 1 }), + /Invalid tab ID/, + "should refuse to move tab for the container which doesn't have permission" + ); + + // Check that duplicate method fails. + await browser.test.assertRejects( + browser.tabs.duplicate(restrictedTab), + /Invalid tab ID:/, + "should refuse to duplicate tab for the container which doesn't have permission" + ); + + // Check that captureTab method fails. + await browser.test.assertRejects( + browser.tabs.captureTab(restrictedTab), + /Invalid tab ID/, + "should refuse to capture the tab for the container which doesn't have permission" + ); + + // Check that discard method fails. + await browser.test.assertRejects( + browser.tabs.discard([restrictedTab]), + /Invalid tab ID/, + "should refuse to discard the tab for the container which doesn't have permission " + ); + + // Check that get method fails. + await browser.test.assertRejects( + browser.tabs.get(restrictedTab), + /Invalid tab ID/, + "should refuse to get the tab for the container which doesn't have permissiond" + ); + + // Check that highlight method fails. + await browser.test.assertRejects( + browser.tabs.highlight({ populate: true, tabs: [restrictedTab] }), + `No tab at index: ${restrictedTab}`, + "should refuse to highlight the tab for the container which doesn't have permission" + ); + + // Test for moveInSuccession method of tab + + await browser.test.assertRejects( + browser.tabs.moveInSuccession([restrictedTab]), + /Invalid tab ID/, + "should refuse to moveInSuccession for the container which doesn't have permission" + ); + + // Check that executeScript method fails. + await browser.test.assertRejects( + browser.tabs.executeScript(restrictedTab, { frameId: 0 }), + /Invalid tab ID/, + "should refuse to execute a script of the tab for the container which doesn't have permission" + ); + + // Check that getZoom method fails. + + await browser.test.assertRejects( + browser.tabs.getZoom(restrictedTab), + /Invalid tab ID/, + "should refuse to zoom the tab for the container which doesn't have permission" + ); + + // Check that getZoomSetting method fails. + await browser.test.assertRejects( + browser.tabs.getZoomSettings(restrictedTab), + /Invalid tab ID/, + "should refuse the setting of zoom of the tab for the container which doesn't have permission" + ); + + //Test for hide method of tab + await browser.test.assertRejects( + browser.tabs.hide(restrictedTab), + /Invalid tab ID/, + "should refuse to hide a tab for the container which doesn't have permission" + ); + + // Check that insertCSS method fails. + await browser.test.assertRejects( + browser.tabs.insertCSS(restrictedTab, { frameId: 0 }), + /Invalid tab ID/, + "should refuse to insert a stylesheet to the tab for the container which doesn't have permission" + ); + + // Check that removeCSS method fails. + await browser.test.assertRejects( + browser.tabs.removeCSS(restrictedTab, { frameId: 0 }), + /Invalid tab ID/, + "should refuse to remove a stylesheet to the tab for the container which doesn't have permission" + ); + + //Test for show method of tab + await browser.test.assertRejects( + browser.tabs.show([restrictedTab]), + /Invalid tab ID/, + "should refuse to show the tab for the container which doesn't have permission" + ); + + // Check that toggleReaderMode method fails. + + await browser.test.assertRejects( + browser.tabs.toggleReaderMode(restrictedTab), + /Invalid tab ID/, + "should refuse to toggle reader mode in the tab for the container which doesn't have permission" + ); + + // Check that setZoom method fails. + await browser.test.assertRejects( + browser.tabs.setZoom(restrictedTab, 0), + /Invalid tab ID/, + "should refuse to set zoom of the tab for the container which doesn't have permission" + ); + + // Check that setZoomSettings method fails. + await browser.test.assertRejects( + browser.tabs.setZoomSettings(restrictedTab, { defaultZoomFactor: 1 }), + /Invalid tab ID/, + "should refuse to set zoom setting of the tab for the container which doesn't have permission" + ); + + // Check that goBack method fails. + + await browser.test.assertRejects( + browser.tabs.goBack(restrictedTab), + /Invalid tab ID/, + "should refuse to go back to the tab for the container which doesn't have permission" + ); + + // Check that goForward method fails. + + await browser.test.assertRejects( + browser.tabs.goForward(restrictedTab), + /Invalid tab ID/, + "should refuse to go forward to the tab for the container which doesn't have permission" + ); + + // Check that update method fails. + await browser.test.assertRejects( + browser.tabs.update(restrictedTab, { highlighted: true }), + /Invalid tab ID/, + "should refuse to update the tab for the container which doesn't have permission" + ); + + // Check that reload method fails. + await browser.test.assertRejects( + browser.tabs.reload(restrictedTab), + /Invalid tab ID/, + "should refuse to reload tab for the container which doesn't have permission" + ); + + //Test for warmup method of tab + await browser.test.assertRejects( + browser.tabs.warmup(restrictedTab), + /Invalid tab ID/, + "should refuse to warmup a tab for the container which doesn't have permission" + ); + + let gettab = await browser.tabs.get(unrestrictedTab); + browser.test.assertEq( + gettab.cookieStoreId, + "firefox-container-2", + "get tab should open" + ); + + let lang = await browser.tabs.detectLanguage(unrestrictedTab); + await browser.test.assertEq( + "en", + lang, + "English document should be detected" + ); + + let duptab = await browser.tabs.duplicate(unrestrictedTab); + + browser.test.assertEq( + duptab.cookieStoreId, + "firefox-container-2", + "duplicated tab should open" + ); + await browser.tabs.remove(duptab.id); + + let moved = await browser.tabs.move(unrestrictedTab, { + index: 0, + }); + browser.test.assertEq(moved.length, 1, "move() returned no moved tab"); + + //Test for query method of tab + let tabs = await browser.tabs.query({ + cookieStoreId: "firefox-container-1", + }); + await browser.test.assertEq( + 0, + tabs.length, + "should not return restricted container's tab" + ); + + tabs = await browser.tabs.query({}); + await browser.test.assertEq( + tabs + .map(tab => tab.cookieStoreId) + .sort() + .join(","), + "firefox-container-2,firefox-default", + "should return two tabs - firefox-default and firefox-container-2" + ); + + // Check that remove method fails. + + await browser.test.assertRejects( + browser.tabs.remove([restrictedTab]), + /Invalid tab ID/, + "should refuse to remove tab for the container which doesn't have permission" + ); + + let removedPromise = new Promise(resolve => { + browser.tabs.onRemoved.addListener(tabId => { + browser.test.assertEq(unrestrictedTab, tabId, "expected remove tab"); + resolve(); + }); + }); + await browser.tabs.remove(unrestrictedTab); + await removedPromise; + + browser.test.sendMessage("done"); + }, + }); + + await helperExtension.startup(); + + helperExtension.sendMessage({ + subject: "createTab", + data: { + url: "http://www.example.com/", + cookieStoreId: "firefox-container-2", + }, + }); + const unrestrictedTab = await helperExtension.awaitMessage("tabCreated"); + + helperExtension.sendMessage({ + subject: "createTab", + data: { + url: "http://www.example.com/", + cookieStoreId: "firefox-container-1", + }, + }); + const restrictedTab = await helperExtension.awaitMessage("tabCreated"); + + await SpecialPowers.pushPrefEnv({ + set: [["extensions.userContextIsolation.defaults.restricted", "[1]"]], + }); + + await extension.startup(); + extension.sendMessage([restrictedTab, unrestrictedTab]); + + await extension.awaitMessage("done"); + await extension.unload(); + await helperExtension.unload(); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_cookieStoreId.js b/browser/components/extensions/test/browser/browser_ext_tabs_cookieStoreId.js new file mode 100644 index 0000000000..27ea5d92bf --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_cookieStoreId.js @@ -0,0 +1,328 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_setup(async function () { + // make sure userContext is enabled. + return SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); +}); + +add_task(async function () { + info("Start testing tabs.create with cookieStoreId"); + + let testCases = [ + // No private window + { + privateTab: false, + cookieStoreId: null, + success: true, + expectedCookieStoreId: "firefox-default", + }, + { + privateTab: false, + cookieStoreId: "firefox-default", + success: true, + expectedCookieStoreId: "firefox-default", + }, + { + privateTab: false, + cookieStoreId: "firefox-container-1", + success: true, + expectedCookieStoreId: "firefox-container-1", + }, + { + privateTab: false, + cookieStoreId: "firefox-container-2", + success: true, + expectedCookieStoreId: "firefox-container-2", + }, + { + privateTab: false, + cookieStoreId: "firefox-container-42", + failure: "exist", + }, + { + privateTab: false, + cookieStoreId: "firefox-private", + failure: "defaultToPrivate", + }, + { privateTab: false, cookieStoreId: "wow", failure: "illegal" }, + + // Private window + { + privateTab: true, + cookieStoreId: null, + success: true, + expectedCookieStoreId: "firefox-private", + }, + { + privateTab: true, + cookieStoreId: "firefox-private", + success: true, + expectedCookieStoreId: "firefox-private", + }, + { + privateTab: true, + cookieStoreId: "firefox-default", + failure: "privateToDefault", + }, + { + privateTab: true, + cookieStoreId: "firefox-container-1", + failure: "privateToDefault", + }, + { privateTab: true, cookieStoreId: "wow", failure: "illegal" }, + ]; + + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["tabs", "cookies"], + }, + + background: function () { + function testTab(data, tab) { + browser.test.assertTrue(data.success, "we want a success"); + browser.test.assertTrue(!!tab, "we have a tab"); + browser.test.assertEq( + data.expectedCookieStoreId, + tab.cookieStoreId, + "tab should have the correct cookieStoreId" + ); + } + + async function runTest(data) { + try { + // Tab Creation + let tab; + try { + tab = await browser.tabs.create({ + windowId: data.privateTab + ? this.privateWindowId + : this.defaultWindowId, + cookieStoreId: data.cookieStoreId, + }); + + browser.test.assertTrue(!data.failure, "we want a success"); + } catch (error) { + browser.test.assertTrue(!!data.failure, "we want a failure"); + + if (data.failure == "illegal") { + browser.test.assertEq( + `Illegal cookieStoreId: ${data.cookieStoreId}`, + error.message, + "runtime.lastError should report the expected error message" + ); + } else if (data.failure == "defaultToPrivate") { + browser.test.assertEq( + "Illegal to set private cookieStoreId in a non-private window", + error.message, + "runtime.lastError should report the expected error message" + ); + } else if (data.failure == "privateToDefault") { + browser.test.assertEq( + "Illegal to set non-private cookieStoreId in a private window", + error.message, + "runtime.lastError should report the expected error message" + ); + } else if (data.failure == "exist") { + browser.test.assertEq( + `No cookie store exists with ID ${data.cookieStoreId}`, + error.message, + "runtime.lastError should report the expected error message" + ); + } else { + browser.test.fail("The test is broken"); + } + + browser.test.sendMessage("test-done"); + return; + } + + // Tests for tab creation + testTab(data, tab); + + { + // Tests for tab querying + let [tab] = await browser.tabs.query({ + windowId: data.privateTab + ? this.privateWindowId + : this.defaultWindowId, + cookieStoreId: data.cookieStoreId, + }); + + browser.test.assertTrue(tab != undefined, "Tab found!"); + testTab(data, tab); + } + + let stores = await browser.cookies.getAllCookieStores(); + + let store = stores.find(store => store.id === tab.cookieStoreId); + browser.test.assertTrue(!!store, "We have a store for this tab."); + browser.test.assertTrue( + store.tabIds.includes(tab.id), + "tabIds includes this tab." + ); + + await browser.tabs.remove(tab.id); + + browser.test.sendMessage("test-done"); + } catch (e) { + browser.test.fail("An exception has been thrown"); + } + } + + async function initialize() { + let win = await browser.windows.create({ incognito: true }); + this.privateWindowId = win.id; + + win = await browser.windows.create({ incognito: false }); + this.defaultWindowId = win.id; + + browser.test.sendMessage("ready"); + } + + async function shutdown() { + await browser.windows.remove(this.privateWindowId); + await browser.windows.remove(this.defaultWindowId); + browser.test.sendMessage("gone"); + } + + // Waiting for messages + browser.test.onMessage.addListener((msg, data) => { + if (msg == "be-ready") { + initialize(); + } else if (msg == "test") { + runTest(data); + } else { + browser.test.assertTrue("finish", msg, "Shutting down"); + shutdown(); + } + }); + }, + }); + + await extension.startup(); + + info("Tests must be ready..."); + extension.sendMessage("be-ready"); + await extension.awaitMessage("ready"); + info("Tests are ready to run!"); + + for (let test of testCases) { + info(`test tab.create with cookieStoreId: "${test.cookieStoreId}"`); + extension.sendMessage("test", test); + await extension.awaitMessage("test-done"); + } + + info("Waiting for shutting down..."); + extension.sendMessage("finish"); + await extension.awaitMessage("gone"); + + await extension.unload(); +}); + +add_task(async function userContext_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", false]], + }); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "cookies"], + }, + async background() { + await browser.test.assertRejects( + browser.tabs.create({ cookieStoreId: "firefox-container-1" }), + /Contextual identities are currently disabled/, + "should refuse to open container tab when contextual identities are disabled" + ); + browser.test.sendMessage("done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function tabs_query_cookiestoreid_nocookiepermission() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + let tab = await browser.tabs.create({}); + browser.test.assertEq( + "firefox-default", + tab.cookieStoreId, + "Expecting cookieStoreId for new tab" + ); + let query = await browser.tabs.query({ + index: tab.index, + cookieStoreId: tab.cookieStoreId, + }); + browser.test.assertEq( + "firefox-default", + query[0].cookieStoreId, + "Expecting cookieStoreId for new tab through browser.tabs.query" + ); + await browser.tabs.remove(tab.id); + browser.test.sendMessage("done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function tabs_query_multiple_cookiestoreId() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["cookies"], + }, + + async background() { + let tab1 = await browser.tabs.create({ + cookieStoreId: "firefox-container-1", + }); + browser.test.log(`Tab created for cookieStoreId:${tab1.cookieStoreId}`); + + let tab2 = await browser.tabs.create({ + cookieStoreId: "firefox-container-2", + }); + browser.test.log(`Tab created for cookieStoreId:${tab2.cookieStoreId}`); + + let tab3 = await browser.tabs.create({ + cookieStoreId: "firefox-container-3", + }); + browser.test.log(`Tab created for cookieStoreId:${tab3.cookieStoreId}`); + + let tabs = await browser.tabs.query({ + cookieStoreId: ["firefox-container-1", "firefox-container-2"], + }); + + browser.test.assertEq( + 2, + tabs.length, + "Expecting tabs for firefox-container-1 and firefox-container-2" + ); + + browser.test.assertEq( + "firefox-container-1", + tabs[0].cookieStoreId, + "Expecting tab for firefox-container-1 cookieStoreId" + ); + + browser.test.assertEq( + "firefox-container-2", + tabs[1].cookieStoreId, + "Expecting tab forfirefox-container-2 cookieStoreId" + ); + + await browser.tabs.remove([tab1.id, tab2.id, tab3.id]); + browser.test.sendMessage("test-done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("test-done"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_cookieStoreId_private.js b/browser/components/extensions/test/browser/browser_ext_tabs_cookieStoreId_private.js new file mode 100644 index 0000000000..556aa78288 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_cookieStoreId_private.js @@ -0,0 +1,44 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function perma_private_browsing_mode() { + // make sure userContext is enabled. + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + + Assert.equal( + Services.prefs.getBoolPref("browser.privatebrowsing.autostart"), + true, + "Permanent private browsing is enabled" + ); + + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["tabs", "cookies"], + }, + async background() { + let win = await browser.windows.create({}); + browser.test.assertTrue( + win.incognito, + "New window should be private when perma-PBM is enabled." + ); + await browser.test.assertRejects( + browser.tabs.create({ + cookieStoreId: "firefox-container-1", + windowId: win.id, + }), + /Illegal to set non-private cookieStoreId in a private window/, + "should refuse to open container tab in private browsing window" + ); + await browser.windows.remove(win.id); + + browser.test.sendMessage("done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_create.js b/browser/components/extensions/test/browser/browser_ext_tabs_create.js new file mode 100644 index 0000000000..a3b6e78331 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_create.js @@ -0,0 +1,299 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_create_options() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:robots" + ); + gBrowser.selectedTab = tab; + + // TODO: Multiple windows. + + await SpecialPowers.pushPrefEnv({ + set: [ + // Using pre-loaded new tab pages interferes with onUpdated events. + // It probably shouldn't. + ["browser.newtab.preload", false], + // Some test cases below load http and check the behavior of https-first. + ["dom.security.https_first", true], + ], + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + + background: { page: "bg/background.html" }, + }, + + files: { + "bg/blank.html": ``, + + "bg/background.html": ` + + + `, + + "bg/background.js": function () { + let activeTab; + let activeWindow; + + function runTests() { + const DEFAULTS = { + index: 2, + windowId: activeWindow, + active: true, + pinned: false, + url: "about:newtab", + // 'selected' is marked as unsupported in schema, so we've removed it. + // For more details, see bug 1337509 + selected: undefined, + mutedInfo: { + muted: false, + extensionId: undefined, + reason: undefined, + }, + }; + + let tests = [ + { + create: { url: "https://example.com/" }, + result: { url: "https://example.com/" }, + }, + { + create: { url: "view-source:https://example.com/" }, + result: { url: "view-source:https://example.com/" }, + }, + { + create: { url: "blank.html" }, + result: { url: browser.runtime.getURL("bg/blank.html") }, + }, + { + create: { url: "https://example.com/", openInReaderMode: true }, + result: { + url: `about:reader?url=${encodeURIComponent( + "https://example.com/" + )}`, + }, + }, + { + create: {}, + result: { url: "about:newtab" }, + }, + { + create: { active: false }, + result: { active: false }, + }, + { + create: { active: true }, + result: { active: true }, + }, + { + create: { pinned: true }, + result: { pinned: true, index: 0 }, + }, + { + create: { pinned: true, active: true }, + result: { pinned: true, active: true, index: 0 }, + }, + { + create: { pinned: true, active: false }, + result: { pinned: true, active: false, index: 0 }, + }, + { + create: { index: 1 }, + result: { index: 1 }, + }, + { + create: { index: 1, active: false }, + result: { index: 1, active: false }, + }, + { + create: { windowId: activeWindow }, + result: { windowId: activeWindow }, + }, + { + create: { index: 9999 }, + result: { index: 2 }, + }, + { + // https-first redirects http to https. + create: { url: "http://example.com/" }, + result: { url: "https://example.com/" }, + }, + { + // https-first redirects http to https. + create: { url: "view-source:http://example.com/" }, + result: { url: "view-source:https://example.com/" }, + }, + { + // Despite https-first, the about:reader URL does not change. + create: { url: "http://example.com/", openInReaderMode: true }, + result: { + url: `about:reader?url=${encodeURIComponent( + "http://example.com/" + )}`, + }, + }, + { + create: { muted: true }, + result: { + mutedInfo: { + muted: true, + extensionId: browser.runtime.id, + reason: "extension", + }, + }, + }, + { + create: { muted: false }, + result: { + mutedInfo: { + muted: false, + extensionId: undefined, + reason: undefined, + }, + }, + }, + ]; + + async function nextTest() { + if (!tests.length) { + browser.test.notifyPass("tabs.create"); + return; + } + + let test = tests.shift(); + let expected = Object.assign({}, DEFAULTS, test.result); + + browser.test.log( + `Testing tabs.create(${JSON.stringify( + test.create + )}), expecting ${JSON.stringify(test.result)}` + ); + + let updatedPromise = new Promise(resolve => { + let onUpdated = (changedTabId, changed) => { + if (changed.url) { + browser.tabs.onUpdated.removeListener(onUpdated); + resolve({ tabId: changedTabId, url: changed.url }); + } + }; + browser.tabs.onUpdated.addListener(onUpdated); + }); + + let createdPromise = new Promise(resolve => { + let onCreated = tab => { + browser.test.assertTrue( + "id" in tab, + `Expected tabs.onCreated callback to receive tab object` + ); + resolve(); + }; + browser.tabs.onCreated.addListener(onCreated); + }); + + let [tab] = await Promise.all([ + browser.tabs.create(test.create), + createdPromise, + ]); + let tabId = tab.id; + + for (let key of Object.keys(expected)) { + if (key === "url") { + // FIXME: This doesn't get updated until later in the load cycle. + continue; + } + + if (key === "mutedInfo") { + for (let key of Object.keys(expected.mutedInfo)) { + browser.test.assertEq( + expected.mutedInfo[key], + tab.mutedInfo[key], + `Expected value for tab.mutedInfo.${key}` + ); + } + } else { + browser.test.assertEq( + expected[key], + tab[key], + `Expected value for tab.${key}` + ); + } + } + + let updated = await updatedPromise; + browser.test.assertEq( + tabId, + updated.tabId, + `Expected value for tab.id` + ); + browser.test.assertEq( + expected.url, + updated.url, + `Expected value for tab.url` + ); + + await browser.tabs.remove(tabId); + await browser.tabs.update(activeTab, { active: true }); + + nextTest(); + } + + nextTest(); + } + + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + activeTab = tabs[0].id; + activeWindow = tabs[0].windowId; + + runTests(); + }); + }, + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.create"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_create_with_popup() { + const extension = ExtensionTestUtils.loadExtension({ + async background() { + let normalWin = await browser.windows.create(); + let lastFocusedNormalWin = await browser.windows.getLastFocused({}); + browser.test.assertEq( + lastFocusedNormalWin.id, + normalWin.id, + "The normal window is the last focused window." + ); + let popupWin = await browser.windows.create({ type: "popup" }); + let lastFocusedPopupWin = await browser.windows.getLastFocused({}); + browser.test.assertEq( + lastFocusedPopupWin.id, + popupWin.id, + "The popup window is the last focused window." + ); + let newtab = await browser.tabs.create({}); + browser.test.assertEq( + normalWin.id, + newtab.windowId, + "New tab was created in last focused normal window." + ); + await Promise.all([ + browser.windows.remove(normalWin.id), + browser.windows.remove(popupWin.id), + ]); + browser.test.sendMessage("complete"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("complete"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_create_invalid_url.js b/browser/components/extensions/test/browser/browser_ext_tabs_create_invalid_url.js new file mode 100644 index 0000000000..55bb33f26e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_create_invalid_url.js @@ -0,0 +1,79 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const FILE_URL = Services.io.newFileURI( + new FileUtils.File(getTestFilePath("file_dummy.html")) +).spec; + +async function testTabsCreateInvalidURL(tabsCreateURL) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: function () { + browser.test.sendMessage("ready"); + browser.test.onMessage.addListener((msg, tabsCreateURL) => { + browser.tabs.create({ url: tabsCreateURL }, tab => { + browser.test.assertEq( + undefined, + tab, + "on error tab should be undefined" + ); + browser.test.assertTrue( + /Illegal URL/.test(browser.runtime.lastError.message), + "runtime.lastError should report the expected error message" + ); + + // Remove the opened tab is any. + if (tab) { + browser.tabs.remove(tab.id); + } + browser.test.sendMessage("done"); + }); + }); + }, + }); + + await extension.startup(); + + await extension.awaitMessage("ready"); + + info(`test tab.create on invalid URL "${tabsCreateURL}"`); + + extension.sendMessage("start", tabsCreateURL); + await extension.awaitMessage("done"); + + await extension.unload(); +} + +add_task(async function () { + info("Start testing tabs.create on invalid URLs"); + + let dataURLPage = `data:text/html, + + + + + + +

    data url page

    + + `; + + let testCases = [ + { tabsCreateURL: "about:addons" }, + { + tabsCreateURL: "javascript:console.log('tabs.update execute javascript')", + }, + { tabsCreateURL: dataURLPage }, + { tabsCreateURL: FILE_URL }, + ]; + + for (let { tabsCreateURL } of testCases) { + await testTabsCreateInvalidURL(tabsCreateURL); + } + + info("done"); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_create_url.js b/browser/components/extensions/test/browser/browser_ext_tabs_create_url.js new file mode 100644 index 0000000000..91cafa6e7e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_create_url.js @@ -0,0 +1,230 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function runWithDisabledPrivateBrowsing(callback) { + const { EnterprisePolicyTesting, PoliciesPrefTracker } = + ChromeUtils.importESModule( + "resource://testing-common/EnterprisePolicyTesting.sys.mjs" + ); + + PoliciesPrefTracker.start(); + await EnterprisePolicyTesting.setupPolicyEngineWithJson({ + policies: { DisablePrivateBrowsing: true }, + }); + + try { + await callback(); + } finally { + await EnterprisePolicyTesting.setupPolicyEngineWithJson(""); + EnterprisePolicyTesting.resetRunOnceState(); + PoliciesPrefTracker.stop(); + } +} + +add_task(async function test_urlbar_focus() { + // Disable preloaded new tab because the urlbar is automatically focused when + // a preloaded new tab is opened, while this test is supposed to test that the + // implementation of tabs.create automatically focuses the urlbar of new tabs. + await SpecialPowers.pushPrefEnv({ + set: [["browser.newtab.preload", false]], + }); + + const extension = ExtensionTestUtils.loadExtension({ + background() { + browser.tabs.onUpdated.addListener(function onUpdated(_, info, tab) { + if (info.status === "complete" && tab.url !== "about:blank") { + browser.test.sendMessage("complete"); + browser.tabs.onUpdated.removeListener(onUpdated); + } + }); + browser.test.onMessage.addListener(async (cmd, ...args) => { + const result = await browser.tabs[cmd](...args); + browser.test.sendMessage("result", result); + }); + }, + }); + + await extension.startup(); + + // Test content is focused after opening a regular url + extension.sendMessage("create", { url: "https://example.com" }); + const [tab1] = await Promise.all([ + extension.awaitMessage("result"), + extension.awaitMessage("complete"), + ]); + + is( + document.activeElement.tagName, + "browser", + "Content focused after opening a web page" + ); + + extension.sendMessage("remove", tab1.id); + await extension.awaitMessage("result"); + + // Test urlbar is focused after opening an empty tab + extension.sendMessage("create", {}); + const tab2 = await extension.awaitMessage("result"); + + const active = document.activeElement; + info( + `Active element: ${active.tagName}, id: ${active.id}, class: ${active.className}` + ); + + const parent = active.parentNode; + info( + `Parent element: ${parent.tagName}, id: ${parent.id}, class: ${parent.className}` + ); + + info(`After opening an empty tab, gURLBar.focused: ${gURLBar.focused}`); + + is(active.tagName, "html:input", "Input element focused"); + is(active.id, "urlbar-input", "Urlbar focused"); + + extension.sendMessage("remove", tab2.id); + await extension.awaitMessage("result"); + + await extension.unload(); +}); + +add_task(async function default_url() { + const extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["tabs"], + }, + background() { + function promiseNonBlankTab() { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener( + tabId, + changeInfo, + tab + ) { + if (changeInfo.status === "complete" && tab.url !== "about:blank") { + browser.tabs.onUpdated.removeListener(listener); + resolve(tab); + } + }); + }); + } + + browser.test.onMessage.addListener( + async (msg, { incognito, expectedNewWindowUrl, expectedNewTabUrl }) => { + browser.test.assertEq( + "start", + msg, + `Start test, incognito=${incognito}` + ); + + let tabPromise = promiseNonBlankTab(); + let win; + try { + win = await browser.windows.create({ incognito }); + browser.test.assertEq( + 1, + win.tabs.length, + "Expected one tab in the new window." + ); + } catch (e) { + browser.test.assertEq( + expectedNewWindowUrl, + e.message, + "Expected error" + ); + browser.test.sendMessage("done"); + return; + } + let tab = await tabPromise; + browser.test.assertEq( + expectedNewWindowUrl, + tab.url, + "Expected default URL of new window" + ); + + tabPromise = promiseNonBlankTab(); + await browser.tabs.create({ windowId: win.id }); + tab = await tabPromise; + browser.test.assertEq( + expectedNewTabUrl, + tab.url, + "Expected default URL of new tab" + ); + + await browser.windows.remove(win.id); + browser.test.sendMessage("done"); + } + ); + }, + }); + + await extension.startup(); + + extension.sendMessage("start", { + incognito: false, + expectedNewWindowUrl: "about:home", + expectedNewTabUrl: "about:newtab", + }); + await extension.awaitMessage("done"); + extension.sendMessage("start", { + incognito: true, + expectedNewWindowUrl: "about:privatebrowsing", + expectedNewTabUrl: "about:privatebrowsing", + }); + await extension.awaitMessage("done"); + + info("Testing with multiple homepages."); + await SpecialPowers.pushPrefEnv({ + set: [["browser.startup.homepage", "about:robots|about:blank|about:home"]], + }); + extension.sendMessage("start", { + incognito: false, + expectedNewWindowUrl: "about:robots", + expectedNewTabUrl: "about:newtab", + }); + await extension.awaitMessage("done"); + extension.sendMessage("start", { + incognito: true, + expectedNewWindowUrl: "about:privatebrowsing", + expectedNewTabUrl: "about:privatebrowsing", + }); + await extension.awaitMessage("done"); + await SpecialPowers.popPrefEnv(); + + info("Testing with perma-private browsing mode."); + await SpecialPowers.pushPrefEnv({ + set: [["browser.privatebrowsing.autostart", true]], + }); + extension.sendMessage("start", { + incognito: false, + expectedNewWindowUrl: "about:home", + expectedNewTabUrl: "about:newtab", + }); + await extension.awaitMessage("done"); + extension.sendMessage("start", { + incognito: true, + expectedNewWindowUrl: "about:home", + expectedNewTabUrl: "about:newtab", + }); + await extension.awaitMessage("done"); + await SpecialPowers.popPrefEnv(); + + info("Testing with disabled private browsing mode."); + await runWithDisabledPrivateBrowsing(async () => { + extension.sendMessage("start", { + incognito: false, + expectedNewWindowUrl: "about:home", + expectedNewTabUrl: "about:newtab", + }); + await extension.awaitMessage("done"); + extension.sendMessage("start", { + incognito: true, + expectedNewWindowUrl: + "`incognito` cannot be used if incognito mode is disabled", + }); + await extension.awaitMessage("done"); + }); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_discard.js b/browser/components/extensions/test/browser/browser_ext_tabs_discard.js new file mode 100644 index 0000000000..074a0f4ce1 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_discard.js @@ -0,0 +1,98 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* global gBrowser */ +"use strict"; + +add_task(async function test_discarded() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let tabs = await browser.tabs.query({ currentWindow: true }); + tabs.sort((tab1, tab2) => tab1.index - tab2.index); + + async function finishTest() { + try { + await browser.tabs.discard(tabs[0].id); + await browser.tabs.discard(tabs[2].id); + browser.test.succeed( + "attempting to discard an already discarded tab or the active tab should not throw error" + ); + } catch (e) { + browser.test.fail( + "attempting to discard an already discarded tab or the active tab should not throw error" + ); + } + let discardedTab = await browser.tabs.get(tabs[2].id); + browser.test.assertEq( + false, + discardedTab.discarded, + "attempting to discard the active tab should not have succeeded" + ); + + await browser.test.assertRejects( + browser.tabs.discard(999999999), + /Invalid tab ID/, + "attempt to discard invalid tabId should throw" + ); + await browser.test.assertRejects( + browser.tabs.discard([999999999, tabs[1].id]), + /Invalid tab ID/, + "attempt to discard a valid and invalid tabId should throw" + ); + discardedTab = await browser.tabs.get(tabs[1].id); + browser.test.assertEq( + false, + discardedTab.discarded, + "tab is still not discarded" + ); + + browser.test.notifyPass("test-finished"); + } + + browser.tabs.onUpdated.addListener(async function (tabId, updatedInfo) { + if ("discarded" in updatedInfo) { + browser.test.assertEq( + tabId, + tabs[0].id, + "discarding tab triggered onUpdated" + ); + let discardedTab = await browser.tabs.get(tabs[0].id); + browser.test.assertEq( + true, + discardedTab.discarded, + "discarded tab discard property" + ); + + await finishTest(); + } + }); + + browser.tabs.discard(tabs[0].id); + }, + }); + + BrowserTestUtils.startLoadingURIString( + gBrowser.browsers[0], + "http://example.com" + ); + await BrowserTestUtils.browserLoaded(gBrowser.browsers[0]); + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com" + ); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com" + ); + + await extension.startup(); + + await extension.awaitFinish("test-finished"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_discard_reversed.js b/browser/components/extensions/test/browser/browser_ext_tabs_discard_reversed.js new file mode 100644 index 0000000000..5fad30a6fb --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_discard_reversed.js @@ -0,0 +1,129 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function tabs_discarded_load_and_discard() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "webNavigation"], + }, + async background() { + browser.test.sendMessage("ready"); + const SHIP = await new Promise(resolve => + browser.test.onMessage.addListener((msg, data) => { + resolve(data); + }) + ); + + const PAGE_URL_BEFORE = "http://example.com/initiallyDiscarded"; + const PAGE_URL = + "http://example.com/browser/browser/components/extensions/test/browser/file_dummy.html"; + // Tabs without titles default to URLs without scheme, according to the + // logic of tabbrowser.js's setTabTitle/_setTabLabel. + // TODO bug 1695512: discarded tabs should also follow this logic instead + // of using the unmodified original URL. + const PAGE_TITLE_BEFORE = PAGE_URL_BEFORE; + const PAGE_TITLE_INITIAL = PAGE_URL.replace("http://", ""); + const PAGE_TITLE = "Dummy test page"; + + function assertDeepEqual(expected, actual, message) { + browser.test.assertDeepEq(expected, actual, message); + } + + let tab = await browser.tabs.create({ + url: PAGE_URL_BEFORE, + discarded: true, + }); + const TAB_ID = tab.id; + browser.test.assertTrue(tab.discarded, "Tab initially discarded"); + browser.test.assertEq(PAGE_URL_BEFORE, tab.url, "Initial URL"); + browser.test.assertEq(PAGE_TITLE_BEFORE, tab.title, "Initial title"); + + const observedChanges = { + discarded: [], + title: [], + url: [], + }; + function tabsOnUpdatedAfterLoad(tabId, changeInfo, tab) { + browser.test.assertEq(TAB_ID, tabId, "tabId for tabs.onUpdated"); + for (let [prop, value] of Object.entries(changeInfo)) { + observedChanges[prop].push(value); + } + } + browser.tabs.onUpdated.addListener(tabsOnUpdatedAfterLoad, { + properties: ["discarded", "url", "title"], + }); + + // Load new URL to transition from discarded:true to discarded:false. + await new Promise(resolve => { + browser.webNavigation.onCompleted.addListener(details => { + browser.test.assertEq(TAB_ID, details.tabId, "onCompleted for tab"); + browser.test.assertEq(PAGE_URL, details.url, "URL ater load"); + resolve(); + }); + browser.tabs.update(TAB_ID, { url: PAGE_URL }); + }); + assertDeepEqual( + [false], + observedChanges.discarded, + "changes to tab.discarded after update" + ); + // TODO bug 1669356: the tabs.onUpdated events should only see the + // requested URL and its title. However, the current implementation + // reports several events (including url/title "changes") as part of + // "restoring" the lazy browser prior to loading the requested URL. + + let expectedUrlChanges = [PAGE_URL_BEFORE, PAGE_URL]; + if (SHIP && observedChanges.url.length === 1) { + // Except when SHIP is enabled, which turns this into a race, + // so sometimes only the final URL is seen (see bug 1696815#c22). + expectedUrlChanges = [PAGE_URL]; + } + + assertDeepEqual( + expectedUrlChanges, + observedChanges.url, + "changes to tab.url after update" + ); + assertDeepEqual( + [PAGE_TITLE_INITIAL, PAGE_TITLE], + observedChanges.title, + "changes to tab.title after update" + ); + + tab = await browser.tabs.get(TAB_ID); + browser.test.assertFalse(tab.discarded, "tab.discarded after load"); + browser.test.assertEq(PAGE_URL, tab.url, "tab.url after load"); + browser.test.assertEq(PAGE_TITLE, tab.title, "tab.title after load"); + + // Reset counters. + observedChanges.discarded.length = 0; + observedChanges.title.length = 0; + observedChanges.url.length = 0; + + // Transition from discarded:false to discarded:true + await browser.tabs.discard(TAB_ID); + assertDeepEqual( + [true], + observedChanges.discarded, + "changes to tab.discarded after discard" + ); + assertDeepEqual([], observedChanges.url, "tab.url not changed"); + assertDeepEqual([], observedChanges.title, "tab.title not changed"); + + tab = await browser.tabs.get(TAB_ID); + browser.test.assertTrue(tab.discarded, "tab.discarded after discard"); + browser.test.assertEq(PAGE_URL, tab.url, "tab.url after discard"); + browser.test.assertEq(PAGE_TITLE, tab.title, "tab.title after discard"); + + await browser.tabs.remove(TAB_ID); + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + extension.sendMessage("SHIP", Services.appinfo.sessionHistoryInParent); + await extension.awaitMessage("done"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_discarded.js b/browser/components/extensions/test/browser/browser_ext_tabs_discarded.js new file mode 100644 index 0000000000..48c57b5a05 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_discarded.js @@ -0,0 +1,386 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* global gBrowser SessionStore */ +"use strict"; + +const triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL; + +let lazyTabState = { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "Example Domain", + }, + ], +}; + +add_task(async function test_discarded() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "webNavigation"], + }, + + background() { + browser.webNavigation.onCompleted.addListener( + async details => { + browser.test.log(`webNav onCompleted received for ${details.tabId}`); + let updatedTab = await browser.tabs.get(details.tabId); + browser.test.assertEq( + false, + updatedTab.discarded, + "lazy to non-lazy update discard property" + ); + browser.test.notifyPass("test-finished"); + }, + { url: [{ hostContains: "example.com" }] } + ); + + browser.tabs.onCreated.addListener(function (tab) { + browser.test.assertEq( + true, + tab.discarded, + "non-lazy tab onCreated discard property" + ); + browser.tabs.update(tab.id, { active: true }); + }); + }, + }); + + await extension.startup(); + + let testTab = BrowserTestUtils.addTab(gBrowser, "about:blank", { + createLazyBrowser: true, + }); + SessionStore.setTabState(testTab, lazyTabState); + + await extension.awaitFinish("test-finished"); + await extension.unload(); + + BrowserTestUtils.removeTab(testTab); +}); + +// Regression test for Bug 1819794. +add_task(async function test_create_discarded_with_cookieStoreId() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["contextualIdentities", "cookies"], + }, + async background() { + const [{ cookieStoreId }] = await browser.contextualIdentities.query({}); + browser.test.assertEq( + "firefox-container-1", + cookieStoreId, + "Got expected cookieStoreId" + ); + await browser.tabs.create({ + url: `http://example.com/#${cookieStoreId}`, + cookieStoreId, + discarded: true, + }); + await browser.tabs.create({ + url: `http://example.com/#no-container`, + discarded: true, + }); + }, + // Needed by ExtensionSettingsStore (as a side-effect of contextualIdentities permission). + useAddonManager: "temporary", + }); + + const tabContainerPromise = BrowserTestUtils.waitForEvent( + window, + "TabOpen", + false, + evt => { + return evt.target.getAttribute("usercontextid", "1"); + } + ).then(evt => evt.target); + const tabDefaultPromise = BrowserTestUtils.waitForEvent( + window, + "TabOpen", + false, + evt => { + return !evt.target.hasAttribute("usercontextid"); + } + ).then(evt => evt.target); + + await extension.startup(); + + const tabContainer = await tabContainerPromise; + ok( + tabContainer.hasAttribute("pending"), + "new container tab should be discarded" + ); + const tabContainerState = SessionStore.getTabState(tabContainer); + is( + JSON.parse(tabContainerState).userContextId, + 1, + `Expect a userContextId associated to the new discarded container tab: ${tabContainerState}` + ); + + const tabDefault = await tabDefaultPromise; + ok( + tabDefault.hasAttribute("pending"), + "new non-container tab should be discarded" + ); + const tabDefaultState = SessionStore.getTabState(tabDefault); + is( + JSON.parse(tabDefaultState).userContextId, + 0, + `Expect userContextId 0 associated to the new discarded non-container tab: ${tabDefaultState}` + ); + + BrowserTestUtils.removeTab(tabContainer); + BrowserTestUtils.removeTab(tabDefault); + await extension.unload(); +}); + +// If discard is called immediately after creating a new tab, the new tab may not have loaded, +// and the sessionstore for that tab is not ready for discarding. The result was a corrupted +// sessionstore for the tab, which when the tab was activated, resulted in a tab with partial +// state. +add_task(async function test_create_then_discard() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "webNavigation"], + }, + + background: async function () { + let createdTab; + + browser.tabs.onUpdated.addListener((tabId, updatedInfo) => { + if (!updatedInfo.discarded) { + return; + } + + browser.webNavigation.onCompleted.addListener( + async details => { + browser.test.assertEq( + createdTab.id, + details.tabId, + "created tab navigation is completed" + ); + let activeTab = await browser.tabs.get(details.tabId); + browser.test.assertEq( + "http://example.com/", + details.url, + "created tab url is correct" + ); + browser.test.assertEq( + "http://example.com/", + activeTab.url, + "created tab url is correct" + ); + browser.tabs.remove(details.tabId); + browser.test.notifyPass("test-finished"); + }, + { url: [{ hostContains: "example.com" }] } + ); + + browser.tabs.update(tabId, { active: true }); + }); + + createdTab = await browser.tabs.create({ + url: "http://example.com/", + active: false, + }); + browser.tabs.discard(createdTab.id); + }, + }); + await extension.startup(); + await extension.awaitFinish("test-finished"); + await extension.unload(); +}); + +add_task(async function test_create_discarded() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "webNavigation"], + }, + + background() { + let tabOpts = { + url: "http://example.com/", + active: false, + discarded: true, + title: "discarded tab", + }; + + browser.webNavigation.onCompleted.addListener( + async details => { + let activeTab = await browser.tabs.get(details.tabId); + browser.test.assertEq( + tabOpts.url, + activeTab.url, + "restored tab url matches active tab url" + ); + browser.test.assertEq( + "mochitest index /", + activeTab.title, + "restored tab title is correct" + ); + browser.tabs.remove(details.tabId); + browser.test.notifyPass("test-finished"); + }, + { url: [{ hostContains: "example.com" }] } + ); + + browser.tabs.onCreated.addListener(tab => { + browser.test.assertEq( + tabOpts.active, + tab.active, + "lazy tab is not active" + ); + browser.test.assertEq( + tabOpts.discarded, + tab.discarded, + "lazy tab is discarded" + ); + browser.test.assertEq(tabOpts.url, tab.url, "lazy tab url is correct"); + browser.test.assertEq( + tabOpts.title, + tab.title, + "lazy tab title is correct" + ); + browser.tabs.update(tab.id, { active: true }); + }); + + browser.tabs.create(tabOpts); + }, + }); + await extension.startup(); + await extension.awaitFinish("test-finished"); + await extension.unload(); +}); + +add_task(async function test_discarded_private_tab_restored() { + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + + background() { + let isDiscarding = false; + browser.tabs.onUpdated.addListener( + async function listener(tabId, changeInfo, tab) { + const { active, discarded, incognito } = tab; + if (!incognito || active || discarded || isDiscarding) { + return; + } + // Remove the onUpdated listener to prevent intermittent failure + // to be hit if the listener gets called again for unrelated + // tabs.onUpdated events that may get fired after the test case got + // the tab-discarded test message that was expecting. + isDiscarding = true; + browser.tabs.onUpdated.removeListener(listener); + browser.test.log( + `Test extension discarding ${tabId}: ${JSON.stringify(changeInfo)}` + ); + await browser.tabs.discard(tabId); + browser.test.sendMessage("tab-discarded"); + }, + { properties: ["status"] } + ); + }, + }); + + // Open a private browsing window. + const privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + await extension.startup(); + + const newTab = await BrowserTestUtils.addTab( + privateWin.gBrowser, + "https://example.com/" + ); + await extension.awaitMessage("tab-discarded"); + is(newTab.getAttribute("pending"), "true", "private tab should be discarded"); + + const promiseTabLoaded = BrowserTestUtils.browserLoaded(newTab.linkedBrowser); + + info("Switching to the discarded background tab"); + await BrowserTestUtils.switchTab(privateWin.gBrowser, newTab); + + info("Wait for the restored tab to complete loading"); + await promiseTabLoaded; + is( + newTab.hasAttribute("pending"), + false, + "discarded private tab should have been restored" + ); + + is( + newTab.linkedBrowser.currentURI.spec, + "https://example.com/", + "Got the expected url on the restored tab" + ); + + await extension.unload(); + await BrowserTestUtils.closeWindow(privateWin); +}); + +add_task(async function test_update_discarded() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", ""], + }, + + background() { + browser.test.onMessage.addListener(async msg => { + let [tab] = await browser.tabs.query({ url: "http://example.com/" }); + if (msg == "update") { + await browser.tabs.update(tab.id, { url: "https://example.com/" }); + } else { + browser.test.fail(`Unexpected message received: ${msg}`); + } + }); + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + let lazyTab = BrowserTestUtils.addTab(gBrowser, "http://example.com/", { + createLazyBrowser: true, + lazyTabTitle: "Example Domain", + }); + + let tabBrowserInsertedPromise = BrowserTestUtils.waitForEvent( + lazyTab, + "TabBrowserInserted" + ); + + SimpleTest.waitForExplicitFinish(); + let waitForConsole = new Promise(resolve => { + SimpleTest.monitorConsole(resolve, [ + { + message: + /Lazy browser prematurely inserted via 'loadURI' property access:/, + forbid: true, + }, + ]); + }); + + extension.sendMessage("update"); + await tabBrowserInsertedPromise; + + await BrowserTestUtils.waitForBrowserStateChange( + lazyTab.linkedBrowser, + "https://example.com/", + stateFlags => { + return ( + stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW && + stateFlags & Ci.nsIWebProgressListener.STATE_STOP + ); + } + ); + + await TestUtils.waitForTick(); + BrowserTestUtils.removeTab(lazyTab); + + await extension.unload(); + + SimpleTest.endMonitorConsole(); + await waitForConsole; +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_duplicate.js b/browser/components/extensions/test/browser/browser_ext_tabs_duplicate.js new file mode 100644 index 0000000000..50c56ea796 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_duplicate.js @@ -0,0 +1,316 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testDuplicateTab() { + await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.net/"); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let [source] = await browser.tabs.query({ + lastFocusedWindow: true, + active: true, + }); + + let tab = await browser.tabs.duplicate(source.id); + + browser.test.assertEq( + "http://example.net/", + tab.url, + "duplicated tab should have the same URL as the source tab" + ); + browser.test.assertEq( + source.index + 1, + tab.index, + "duplicated tab should open next to the source tab" + ); + browser.test.assertTrue( + tab.active, + "duplicated tab should be active by default" + ); + + await browser.tabs.remove([source.id, tab.id]); + browser.test.notifyPass("tabs.duplicate"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.duplicate"); + await extension.unload(); +}); + +add_task(async function testDuplicateTabLazily() { + async function background() { + let tabLoadComplete = new Promise(resolve => { + browser.test.onMessage.addListener((message, tabId, result) => { + if (message == "duplicate-tab-done") { + resolve(tabId); + } + }); + }); + + function awaitLoad(tabId) { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener( + tabId_, + changed, + tab + ) { + if (tabId == tabId_ && changed.status == "complete") { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + } + + try { + let url = + "http://example.com/browser/browser/components/extensions/test/browser/file_dummy.html"; + let tab = await browser.tabs.create({ url }); + let startTabId = tab.id; + + await awaitLoad(startTabId); + browser.test.sendMessage("duplicate-tab", startTabId); + + let unloadedTabId = await tabLoadComplete; + let loadedtab = await browser.tabs.get(startTabId); + browser.test.assertEq( + "Dummy test page", + loadedtab.title, + "Title should be returned for loaded pages" + ); + browser.test.assertEq( + "complete", + loadedtab.status, + "Tab status should be complete for loaded pages" + ); + + let unloadedtab = await browser.tabs.get(unloadedTabId); + browser.test.assertEq( + "Dummy test page", + unloadedtab.title, + "Title should be returned after page has been unloaded" + ); + + await browser.tabs.remove([tab.id, unloadedTabId]); + browser.test.notifyPass("tabs.hasCorrectTabTitle"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tabs.hasCorrectTabTitle"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background, + }); + + extension.onMessage("duplicate-tab", tabId => { + const { + Management: { + global: { tabTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + + let tab = tabTracker.getTab(tabId); + // This is a bit of a hack to load a tab in the background. + let newTab = gBrowser.duplicateTab(tab, true, { skipLoad: true }); + + BrowserTestUtils.waitForEvent(newTab, "SSTabRestored", () => true).then( + () => { + extension.sendMessage("duplicate-tab-done", tabTracker.getId(newTab)); + } + ); + }); + + await extension.startup(); + await extension.awaitFinish("tabs.hasCorrectTabTitle"); + await extension.unload(); +}); + +add_task(async function testDuplicatePinnedTab() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.net/" + ); + gBrowser.pinTab(tab); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let [source] = await browser.tabs.query({ + lastFocusedWindow: true, + active: true, + }); + let tab = await browser.tabs.duplicate(source.id); + + browser.test.assertEq( + source.index + 1, + tab.index, + "duplicated tab should open next to the source tab" + ); + browser.test.assertFalse( + tab.pinned, + "duplicated tab should not be pinned by default, even if source tab is" + ); + + await browser.tabs.remove([source.id, tab.id]); + browser.test.notifyPass("tabs.duplicate.pinned"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.duplicate.pinned"); + await extension.unload(); +}); + +add_task(async function testDuplicateTabInBackground() { + await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.net/"); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let tabs = await browser.tabs.query({ + lastFocusedWindow: true, + active: true, + }); + let tab = await browser.tabs.duplicate(tabs[0].id, { active: false }); + // Should not be the active tab + browser.test.assertFalse(tab.active); + + await browser.tabs.remove([tabs[0].id, tab.id]); + browser.test.notifyPass("tabs.duplicate.background"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.duplicate.background"); + await extension.unload(); +}); + +add_task(async function testDuplicateTabAtIndex() { + await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.net/"); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let tabs = await browser.tabs.query({ + lastFocusedWindow: true, + active: true, + }); + let tab = await browser.tabs.duplicate(tabs[0].id, { index: 0 }); + browser.test.assertEq(0, tab.index); + + await browser.tabs.remove([tabs[0].id, tab.id]); + browser.test.notifyPass("tabs.duplicate.index"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.duplicate.index"); + await extension.unload(); +}); + +add_task(async function testDuplicatePinnedTabAtIncorrectIndex() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.net/" + ); + gBrowser.pinTab(tab); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let tabs = await browser.tabs.query({ + lastFocusedWindow: true, + active: true, + }); + let tab = await browser.tabs.duplicate(tabs[0].id, { index: 0 }); + browser.test.assertEq(1, tab.index); + browser.test.assertFalse( + tab.pinned, + "Duplicated tab should not be pinned" + ); + + await browser.tabs.remove([tabs[0].id, tab.id]); + browser.test.notifyPass("tabs.duplicate.incorrect_index"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.duplicate.incorrect_index"); + await extension.unload(); +}); + +add_task(async function testDuplicateResolvePromiseRightAway() { + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/file_slowed_document.sjs" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + // The host permission matches the above URL. No :8888 due to bug 1468162. + permissions: ["tabs", "http://mochi.test/"], + }, + + background: async function () { + let [source] = await browser.tabs.query({ + lastFocusedWindow: true, + active: true, + }); + + let resolvedRightAway = true; + browser.tabs.onUpdated.addListener( + (tabId, changeInfo, tab) => { + resolvedRightAway = false; + }, + { urls: [source.url] } + ); + + let tab = await browser.tabs.duplicate(source.id); + // if the promise is resolved before any onUpdated event has been fired, + // then the promise has been resolved before waiting for the tab to load + browser.test.assertTrue( + resolvedRightAway, + "tabs.duplicate() should resolve as soon as possible" + ); + + // Regression test for bug 1559216 + let code = "document.URL"; + let [result] = await browser.tabs.executeScript(tab.id, { code }); + browser.test.assertEq( + source.url, + result, + "APIs such as tabs.executeScript should be queued until tabs.duplicate has restored the tab" + ); + + await browser.tabs.remove([source.id, tab.id]); + browser.test.notifyPass("tabs.duplicate.resolvePromiseRightAway"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.duplicate.resolvePromiseRightAway"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_events.js b/browser/components/extensions/test/browser/browser_ext_tabs_events.js new file mode 100644 index 0000000000..fe9317b4a6 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_events.js @@ -0,0 +1,794 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// A single monitor for the tests. If it receives any +// incognito data in event listeners it will fail. +let monitor; +add_task(async function startup() { + monitor = await startIncognitoMonitorExtension(); +}); +registerCleanupFunction(async function finish() { + await monitor.unload(); +}); + +// Test tab events from private windows, the monitor above will fail +// if it receives any. +add_task(async function test_tab_events_incognito_monitored() { + async function background() { + let incognito = true; + let events = []; + let eventPromise; + let checkEvents = () => { + if (eventPromise && events.length >= eventPromise.names.length) { + eventPromise.resolve(); + } + }; + + browser.tabs.onCreated.addListener(tab => { + events.push({ type: "onCreated", tab }); + checkEvents(); + }); + + browser.tabs.onAttached.addListener((tabId, info) => { + events.push(Object.assign({ type: "onAttached", tabId }, info)); + checkEvents(); + }); + + browser.tabs.onDetached.addListener((tabId, info) => { + events.push(Object.assign({ type: "onDetached", tabId }, info)); + checkEvents(); + }); + + browser.tabs.onRemoved.addListener((tabId, info) => { + events.push(Object.assign({ type: "onRemoved", tabId }, info)); + checkEvents(); + }); + + browser.tabs.onMoved.addListener((tabId, info) => { + events.push(Object.assign({ type: "onMoved", tabId }, info)); + checkEvents(); + }); + + async function expectEvents(names) { + browser.test.log(`Expecting events: ${names.join(", ")}`); + + await new Promise(resolve => { + eventPromise = { names, resolve }; + checkEvents(); + }); + + browser.test.assertEq( + names.length, + events.length, + "Got expected number of events" + ); + for (let [i, name] of names.entries()) { + browser.test.assertEq( + name, + i in events && events[i].type, + `Got expected ${name} event` + ); + } + return events.splice(0); + } + + try { + let firstWindow = await browser.windows.create({ + url: "about:blank", + incognito, + }); + let otherWindow = await browser.windows.create({ + url: "about:blank", + incognito, + }); + + let windowId = firstWindow.id; + let otherWindowId = otherWindow.id; + + // Wait for a tab in each window + await expectEvents(["onCreated", "onCreated"]); + let initialTab = ( + await browser.tabs.query({ + active: true, + windowId: otherWindowId, + }) + )[0]; + + browser.test.log("Create tab in window 1"); + let tab = await browser.tabs.create({ + windowId, + index: 0, + url: "about:blank", + }); + let oldIndex = tab.index; + browser.test.assertEq(0, oldIndex, "Tab has the expected index"); + browser.test.assertEq(tab.incognito, incognito, "Tab is incognito"); + + let [created] = await expectEvents(["onCreated"]); + browser.test.assertEq(tab.id, created.tab.id, "Got expected tab ID"); + browser.test.assertEq( + oldIndex, + created.tab.index, + "Got expected tab index" + ); + + browser.test.log("Move tab to window 2"); + await browser.tabs.move([tab.id], { windowId: otherWindowId, index: 0 }); + + let [detached, attached] = await expectEvents([ + "onDetached", + "onAttached", + ]); + browser.test.assertEq( + tab.id, + detached.tabId, + "Expected onDetached tab ID" + ); + browser.test.assertEq( + oldIndex, + detached.oldPosition, + "Expected old index" + ); + browser.test.assertEq( + windowId, + detached.oldWindowId, + "Expected old window ID" + ); + + browser.test.assertEq( + tab.id, + attached.tabId, + "Expected onAttached tab ID" + ); + browser.test.assertEq(0, attached.newPosition, "Expected new index"); + browser.test.assertEq( + otherWindowId, + attached.newWindowId, + "Expected new window ID" + ); + + browser.test.log("Move tab within the same window"); + let [moved] = await browser.tabs.move([tab.id], { index: 1 }); + browser.test.assertEq(1, moved.index, "Expected new index"); + + [moved] = await expectEvents(["onMoved"]); + browser.test.assertEq(tab.id, moved.tabId, "Expected tab ID"); + browser.test.assertEq(0, moved.fromIndex, "Expected old index"); + browser.test.assertEq(1, moved.toIndex, "Expected new index"); + browser.test.assertEq( + otherWindowId, + moved.windowId, + "Expected window ID" + ); + + browser.test.log("Remove tab"); + await browser.tabs.remove(tab.id); + let [removed] = await expectEvents(["onRemoved"]); + + browser.test.assertEq( + tab.id, + removed.tabId, + "Expected removed tab ID for tabs.remove" + ); + browser.test.assertEq( + otherWindowId, + removed.windowId, + "Expected removed tab window ID" + ); + // Note: We want to test for the actual boolean value false here. + browser.test.assertEq( + false, + removed.isWindowClosing, + "Expected isWindowClosing value" + ); + + browser.test.log("Close second window"); + await browser.windows.remove(otherWindowId); + [removed] = await expectEvents(["onRemoved"]); + browser.test.assertEq( + initialTab.id, + removed.tabId, + "Expected removed tab ID for windows.remove" + ); + browser.test.assertEq( + otherWindowId, + removed.windowId, + "Expected removed tab window ID" + ); + browser.test.assertEq( + true, + removed.isWindowClosing, + "Expected isWindowClosing value" + ); + + browser.test.log("Create additional tab in window 1"); + tab = await browser.tabs.create({ windowId, url: "about:blank" }); + await expectEvents(["onCreated"]); + browser.test.assertEq(tab.incognito, incognito, "Tab is incognito"); + + browser.test.log("Create a new window, adopting the new tab"); + // We have to explicitly wait for the event here, since its timing is + // not predictable. + let promiseAttached = new Promise(resolve => { + browser.tabs.onAttached.addListener(function listener(tabId) { + browser.tabs.onAttached.removeListener(listener); + resolve(); + }); + }); + + let [window] = await Promise.all([ + browser.windows.create({ tabId: tab.id, incognito }), + promiseAttached, + ]); + + [detached, attached] = await expectEvents(["onDetached", "onAttached"]); + + browser.test.assertEq( + tab.id, + detached.tabId, + "Expected onDetached tab ID" + ); + browser.test.assertEq( + 1, + detached.oldPosition, + "Expected onDetached old index" + ); + browser.test.assertEq( + windowId, + detached.oldWindowId, + "Expected onDetached old window ID" + ); + + browser.test.assertEq( + tab.id, + attached.tabId, + "Expected onAttached tab ID" + ); + browser.test.assertEq( + 0, + attached.newPosition, + "Expected onAttached new index" + ); + browser.test.assertEq( + window.id, + attached.newWindowId, + "Expected onAttached new window id" + ); + + browser.test.log( + "Close the new window by moving the tab into former window" + ); + await browser.tabs.move(tab.id, { index: 1, windowId }); + [detached, attached] = await expectEvents(["onDetached", "onAttached"]); + + browser.test.assertEq( + tab.id, + detached.tabId, + "Expected onDetached tab ID" + ); + browser.test.assertEq( + 0, + detached.oldPosition, + "Expected onDetached old index" + ); + browser.test.assertEq( + window.id, + detached.oldWindowId, + "Expected onDetached old window ID" + ); + + browser.test.assertEq( + tab.id, + attached.tabId, + "Expected onAttached tab ID" + ); + browser.test.assertEq( + 1, + attached.newPosition, + "Expected onAttached new index" + ); + browser.test.assertEq( + windowId, + attached.newWindowId, + "Expected onAttached new window id" + ); + browser.test.assertEq(tab.incognito, incognito, "Tab is incognito"); + + browser.test.log("Remove the tab"); + await browser.tabs.remove(tab.id); + browser.windows.remove(windowId); + + browser.test.notifyPass("tabs-events"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tabs-events"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background, + incognitoOverride: "spanning", + }); + + await extension.startup(); + await extension.awaitFinish("tabs-events"); + await extension.unload(); +}); + +add_task(async function testTabEventsSize() { + function background() { + function sendSizeMessages(tab, type) { + browser.test.sendMessage(`${type}-dims`, { + width: tab.width, + height: tab.height, + }); + } + + browser.tabs.onCreated.addListener(tab => { + sendSizeMessages(tab, "on-created"); + }); + + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status == "complete") { + sendSizeMessages(tab, "on-updated"); + } + }); + + browser.test.onMessage.addListener(async (msg, arg) => { + if (msg === "create-tab") { + let tab = await browser.tabs.create({ url: "https://example.com/" }); + sendSizeMessages(tab, "create"); + browser.test.sendMessage("created-tab-id", tab.id); + } else if (msg === "update-tab") { + let tab = await browser.tabs.update(arg, { + url: "https://example.org/", + }); + sendSizeMessages(tab, "update"); + } else if (msg === "remove-tab") { + browser.tabs.remove(arg); + browser.test.sendMessage("tab-removed"); + } + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background, + }); + + const RESOLUTION_PREF = "layout.css.devPixelsPerPx"; + registerCleanupFunction(() => { + SpecialPowers.clearUserPref(RESOLUTION_PREF); + }); + + function checkDimensions(dims, type) { + is( + dims.width, + gBrowser.selectedBrowser.clientWidth, + `tab from ${type} reports expected width` + ); + is( + dims.height, + gBrowser.selectedBrowser.clientHeight, + `tab from ${type} reports expected height` + ); + } + + await Promise.all([extension.startup(), extension.awaitMessage("ready")]); + + for (let resolution of [2, 1]) { + SpecialPowers.setCharPref(RESOLUTION_PREF, String(resolution)); + is( + window.devicePixelRatio, + resolution, + "window has the required resolution" + ); + + extension.sendMessage("create-tab"); + let tabId = await extension.awaitMessage("created-tab-id"); + + checkDimensions(await extension.awaitMessage("create-dims"), "create"); + checkDimensions( + await extension.awaitMessage("on-created-dims"), + "onCreated" + ); + checkDimensions( + await extension.awaitMessage("on-updated-dims"), + "onUpdated" + ); + + extension.sendMessage("update-tab", tabId); + + checkDimensions(await extension.awaitMessage("update-dims"), "update"); + checkDimensions( + await extension.awaitMessage("on-updated-dims"), + "onUpdated" + ); + + extension.sendMessage("remove-tab", tabId); + await extension.awaitMessage("tab-removed"); + } + + await extension.unload(); + SpecialPowers.clearUserPref(RESOLUTION_PREF); +}).skip(); // Bug 1614075 perma-fail comparing devicePixelRatio + +add_task(async function testTabRemovalEvent() { + async function background() { + let events = []; + + function awaitLoad(tabId) { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener( + tabId_, + changed, + tab + ) { + if (tabId == tabId_ && changed.status == "complete") { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + } + + chrome.tabs.onRemoved.addListener((tabId, info) => { + browser.test.assertEq( + 0, + events.length, + "No events recorded before onRemoved." + ); + events.push("onRemoved"); + browser.test.log( + "Make sure the removed tab is not available in the tabs.query callback." + ); + chrome.tabs.query({}, tabs => { + for (let tab of tabs) { + browser.test.assertTrue( + tab.id != tabId, + "Tab query should not include removed tabId" + ); + } + }); + }); + + try { + let url = + "https://example.com/browser/browser/components/extensions/test/browser/context.html"; + let tab = await browser.tabs.create({ url: url }); + await awaitLoad(tab.id); + + chrome.tabs.onActivated.addListener(info => { + browser.test.assertEq( + 1, + events.length, + "One event recorded before onActivated." + ); + events.push("onActivated"); + browser.test.assertEq( + "onRemoved", + events[0], + "onRemoved fired before onActivated." + ); + browser.test.notifyPass("tabs-events"); + }); + + await browser.tabs.remove(tab.id); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tabs-events"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background, + }); + + await extension.startup(); + await extension.awaitFinish("tabs-events"); + await extension.unload(); +}); + +add_task(async function testTabCreateRelated() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.tabs.opentabfor.middleclick", true], + ["browser.tabs.insertRelatedAfterCurrent", true], + ], + }); + + async function background() { + let created; + browser.tabs.onCreated.addListener(tab => { + browser.test.log(`tabs.onCreated, index=${tab.index}`); + browser.test.assertEq(1, tab.index, "expecting tab index of 1"); + created = tab.id; + }); + browser.tabs.onMoved.addListener((id, info) => { + browser.test.log( + `tabs.onMoved, from ${info.fromIndex} to ${info.toIndex}` + ); + browser.test.fail("tabMoved was received"); + }); + browser.tabs.onRemoved.addListener((tabId, info) => { + browser.test.assertEq(created, tabId, "removed id same as created"); + browser.test.sendMessage("tabRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background, + }); + + // Create a *opener* tab page which has a link to "example.com". + let pageURL = + "https://example.com/browser/browser/components/extensions/test/browser/file_dummy.html"; + let openerTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + pageURL + ); + gBrowser.moveTabTo(openerTab, 0); + + await extension.startup(); + + let newTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "https://example.com/#linkclick", + true + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#link_to_example_com", + { button: 1 }, + gBrowser.selectedBrowser + ); + let openTab = await newTabPromise; + is( + openTab.linkedBrowser.currentURI.spec, + "https://example.com/#linkclick", + "Middle click should open site to correct url." + ); + BrowserTestUtils.removeTab(openTab); + + await extension.awaitMessage("tabRemoved"); + await extension.unload(); + + BrowserTestUtils.removeTab(openerTab); +}); + +add_task(async function testLastTabRemoval() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.closeWindowWithLastTab", false]], + }); + + async function background() { + let windowId; + browser.tabs.onCreated.addListener(tab => { + browser.test.assertEq( + windowId, + tab.windowId, + "expecting onCreated after onRemoved on the same window" + ); + browser.test.sendMessage("tabCreated", `${tab.width}x${tab.height}`); + }); + browser.tabs.onRemoved.addListener((tabId, info) => { + windowId = info.windowId; + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background, + }); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + await extension.startup(); + + const oldBrowser = newWin.gBrowser.selectedBrowser; + const expectedDims = `${oldBrowser.clientWidth}x${oldBrowser.clientHeight}`; + BrowserTestUtils.removeTab(newWin.gBrowser.selectedTab); + + const actualDims = await extension.awaitMessage("tabCreated"); + is( + actualDims, + expectedDims, + "created tab reports a size same to the removed last tab" + ); + + await extension.unload(); + await BrowserTestUtils.closeWindow(newWin); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function testTabActivationEvent() { + async function background() { + function makeExpectable() { + let expectation = null, + resolver = null; + const expectable = param => { + if (expectation === null) { + browser.test.fail("unexpected call to expectable"); + } else { + try { + resolver(expectation(param)); + } catch (e) { + resolver(Promise.reject(e)); + } finally { + expectation = null; + } + } + }; + expectable.expect = e => { + expectation = e; + return new Promise(r => { + resolver = r; + }); + }; + return expectable; + } + try { + const listener = makeExpectable(); + browser.tabs.onActivated.addListener(listener); + + const [ + , + { + tabs: [tab1], + }, + ] = await Promise.all([ + listener.expect(info => { + browser.test.assertEq( + undefined, + info.previousTabId, + "previousTabId should not be defined when window is first opened" + ); + }), + browser.windows.create({ url: "about:blank" }), + ]); + const [, tab2] = await Promise.all([ + listener.expect(info => { + browser.test.assertEq( + tab1.id, + info.previousTabId, + "Got expected previousTabId" + ); + }), + browser.tabs.create({ url: "about:blank" }), + ]); + + await Promise.all([ + listener.expect(info => { + browser.test.assertEq(tab1.id, info.tabId, "Got expected tabId"); + browser.test.assertEq( + tab2.id, + info.previousTabId, + "Got expected previousTabId" + ); + }), + browser.tabs.update(tab1.id, { active: true }), + ]); + + await Promise.all([ + listener.expect(info => { + browser.test.assertEq(tab2.id, info.tabId, "Got expected tabId"); + browser.test.assertEq( + undefined, + info.previousTabId, + "previousTabId should not be defined when previous tab was closed" + ); + }), + browser.tabs.remove(tab1.id), + ]); + + await browser.tabs.remove(tab2.id); + + browser.test.notifyPass("tabs-events"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tabs-events"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background, + }); + + await extension.startup(); + await extension.awaitFinish("tabs-events"); + await extension.unload(); +}); + +add_task(async function test_tabs_event_page() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.eventPages.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "eventpage@tabs" } }, + permissions: ["tabs"], + background: { persistent: false }, + }, + background() { + const EVENTS = [ + "onActivated", + "onAttached", + "onDetached", + "onRemoved", + "onMoved", + "onHighlighted", + "onUpdated", + ]; + browser.tabs.onCreated.addListener(() => { + browser.test.sendMessage("onCreated"); + }); + for (let event of EVENTS) { + browser.tabs[event].addListener(() => {}); + } + browser.test.sendMessage("ready"); + }, + }); + + const EVENTS = [ + "onActivated", + "onAttached", + "onCreated", + "onDetached", + "onRemoved", + "onMoved", + "onHighlighted", + "onUpdated", + ]; + + await extension.startup(); + await extension.awaitMessage("ready"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "tabs", event, { + primed: false, + }); + } + + // test events waken background + await extension.terminateBackground(); + for (let event of EVENTS) { + assertPersistentListeners(extension, "tabs", event, { + primed: true, + }); + } + + let win = await BrowserTestUtils.openNewBrowserWindow(); + + await extension.awaitMessage("ready"); + await extension.awaitMessage("onCreated"); + ok(true, "persistent event woke background"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "tabs", event, { + primed: false, + }); + } + await BrowserTestUtils.closeWindow(win); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_events_order.js b/browser/components/extensions/test/browser/browser_ext_tabs_events_order.js new file mode 100644 index 0000000000..8e86d72c90 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_events_order.js @@ -0,0 +1,208 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function testTabEvents() { + async function background() { + /** The list of active tab ID's */ + let tabIds = []; + + /** + * Stores the events that fire for each tab. + * + * events { + * tabId1: [event1, event2, ...], + * tabId2: [event1, event2, ...], + * } + */ + let events = {}; + + browser.tabs.onActivated.addListener(info => { + if (info.tabId in events) { + events[info.tabId].push("onActivated"); + } else { + events[info.tabId] = ["onActivated"]; + } + }); + + browser.tabs.onCreated.addListener(info => { + if (info.id in events) { + events[info.id].push("onCreated"); + } else { + events[info.id] = ["onCreated"]; + } + }); + + browser.tabs.onHighlighted.addListener(info => { + if (info.tabIds[0] in events) { + events[info.tabIds[0]].push("onHighlighted"); + } else { + events[info.tabIds[0]] = ["onHighlighted"]; + } + }); + + /** + * Asserts that the expected events are fired for the tab with id = tabId. + * The events associated to the specified tab are removed after this check is made. + * + * @param {number} tabId + * @param {Array} expectedEvents + */ + async function expectEvents(tabId, expectedEvents) { + browser.test.log(`Expecting events: ${expectedEvents.join(", ")}`); + + // Wait up to 5000 ms for the expected number of events. + for ( + let i = 0; + i < 50 && + (!events[tabId] || events[tabId].length < expectedEvents.length); + i++ + ) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + + browser.test.assertEq( + expectedEvents.length, + events[tabId].length, + `Got expected number of events for ${tabId}` + ); + + for (let name of expectedEvents) { + browser.test.assertTrue( + events[tabId].includes(name), + `Got expected ${name} event` + ); + } + + if (expectedEvents.includes("onCreated")) { + browser.test.assertEq( + events[tabId].indexOf("onCreated"), + 0, + "onCreated happened first" + ); + } + + delete events[tabId]; + } + + /** + * Opens a new tab and asserts that the correct events are fired. + * + * @param {number} windowId + */ + async function openTab(windowId) { + browser.test.assertEq( + 0, + Object.keys(events).length, + "No events remaining before testing openTab." + ); + + let tab = await browser.tabs.create({ windowId }); + + tabIds.push(tab.id); + browser.test.log(`Opened tab ${tab.id}`); + + await expectEvents(tab.id, ["onCreated", "onActivated", "onHighlighted"]); + } + + /** + * Opens a new window and asserts that the correct events are fired. + * + * @param {Array} urls A list of urls for which to open tabs in the new window. + */ + async function openWindow(urls) { + browser.test.assertEq( + 0, + Object.keys(events).length, + "No events remaining before testing openWindow." + ); + + let window = await browser.windows.create({ url: urls }); + browser.test.log(`Opened new window ${window.id}`); + + for (let [i] of urls.entries()) { + let tab = window.tabs[i]; + tabIds.push(tab.id); + + let expectedEvents = ["onCreated", "onActivated", "onHighlighted"]; + if (i !== 0) { + expectedEvents.splice(1); + } + await expectEvents(window.tabs[i].id, expectedEvents); + } + } + + /** + * Highlights an existing tab and asserts that the correct events are fired. + * + * @param {number} tabId + */ + async function highlightTab(tabId) { + browser.test.assertEq( + 0, + Object.keys(events).length, + "No events remaining before testing highlightTab." + ); + + browser.test.log(`Highlighting tab ${tabId}`); + let tab = await browser.tabs.update(tabId, { active: true }); + + browser.test.assertEq(tab.id, tabId, `Tab ${tab.id} highlighted`); + + await expectEvents(tab.id, ["onActivated", "onHighlighted"]); + } + + /** + * The main entry point to the tests. + */ + let tabs = await browser.tabs.query({ active: true, currentWindow: true }); + + let activeWindow = tabs[0].windowId; + await Promise.all([ + openTab(activeWindow), + openTab(activeWindow), + openTab(activeWindow), + ]); + + await Promise.all([ + highlightTab(tabIds[0]), + highlightTab(tabIds[1]), + highlightTab(tabIds[2]), + ]); + + // If we open these windows in parallel, there is a risk + // that a window will be occluded by the next one before + // it can finish first paint, which will prevent + // the firing of browser-delayed-startup-finished + await openWindow(["http://example.com"]); + await openWindow(["http://example.com", "http://example.org"]); + await openWindow([ + "http://example.com", + "http://example.org", + "http://example.net", + ]); + + browser.test.assertEq( + 0, + Object.keys(events).length, + "No events remaining after tests." + ); + + await Promise.all(tabIds.map(id => browser.tabs.remove(id))); + + browser.test.notifyPass("tabs.highlight"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.highlight"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_executeScript.js b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript.js new file mode 100644 index 0000000000..c02aef3da9 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript.js @@ -0,0 +1,453 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testExecuteScript() { + let { MessageChannel } = ChromeUtils.importESModule( + "resource://testing-common/MessageChannel.sys.mjs" + ); + + function countMM(messageManagerMap) { + let count = 0; + // List of permanent message managers in the main process. We should not + // count them in the test because MessageChannel unsubscribes when the + // message manager closes, which never happens to these, of course. + let globalMMs = [Services.mm, Services.ppmm, Services.ppmm.getChildAt(0)]; + for (let mm of messageManagerMap.keys()) { + if (!globalMMs.includes(mm)) { + ++count; + } + } + return count; + } + + let messageManagersSize = countMM(MessageChannel.messageManagers); + + const BASE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/"; + const URL = BASE + "file_iframe_document.html"; + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URL, true); + + async function background() { + try { + // This promise is meant to be resolved when browser.tabs.executeScript({file: "script.js"}) + // is called and the content script does message back, registering the runtime.onMessage + // listener here is meant to prevent intermittent failures due to a race on executing the + // array of promises passed to the `await Promise.all(...)` below. + const promiseRuntimeOnMessage = new Promise(resolve => { + browser.runtime.onMessage.addListener(message => { + browser.test.assertEq( + "script ran", + message, + "Expected runtime message" + ); + resolve(); + }); + }); + + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + let frames = await browser.webNavigation.getAllFrames({ tabId: tab.id }); + browser.test.assertEq(3, frames.length, "Expect exactly three frames"); + browser.test.assertEq(0, frames[0].frameId, "Main frame has frameId:0"); + browser.test.assertTrue(frames[1].frameId > 0, "Subframe has a valid id"); + + browser.test.log( + `FRAMES: ${frames[1].frameId} ${JSON.stringify(frames)}\n` + ); + await Promise.all([ + browser.tabs + .executeScript({ + code: "42", + }) + .then(result => { + browser.test.assertEq( + 1, + result.length, + "Expected one callback result" + ); + browser.test.assertEq(42, result[0], "Expected callback result"); + }), + + browser.tabs + .executeScript({ + file: "script.js", + code: "42", + }) + .then( + result => { + browser.test.fail( + "Expected not to be able to execute a script with both file and code" + ); + }, + error => { + browser.test.assertTrue( + /a 'code' or a 'file' property, but not both/.test( + error.message + ), + "Got expected error" + ); + } + ), + + browser.tabs + .executeScript({ + file: "script.js", + }) + .then(result => { + browser.test.assertEq( + 1, + result.length, + "Expected one callback result" + ); + browser.test.assertEq( + undefined, + result[0], + "Expected callback result" + ); + }), + + browser.tabs + .executeScript({ + file: "script2.js", + }) + .then(result => { + browser.test.assertEq( + 1, + result.length, + "Expected one callback result" + ); + browser.test.assertEq(27, result[0], "Expected callback result"); + }), + + browser.tabs + .executeScript({ + code: "location.href;", + allFrames: true, + }) + .then(result => { + browser.test.assertTrue( + Array.isArray(result), + "Result is an array" + ); + + browser.test.assertEq( + 2, + result.length, + "Result has correct length" + ); + + browser.test.assertTrue( + /\/file_iframe_document\.html$/.test(result[0]), + "First result is correct" + ); + browser.test.assertEq( + "http://mochi.test:8888/", + result[1], + "Second result is correct" + ); + }), + + browser.tabs + .executeScript({ + code: "location.href;", + allFrames: true, + matchAboutBlank: true, + }) + .then(result => { + browser.test.assertTrue( + Array.isArray(result), + "Result is an array" + ); + + browser.test.assertEq( + 3, + result.length, + "Result has correct length" + ); + + browser.test.assertTrue( + /\/file_iframe_document\.html$/.test(result[0]), + "First result is correct" + ); + browser.test.assertEq( + "http://mochi.test:8888/", + result[1], + "Second result is correct" + ); + browser.test.assertEq( + "about:blank", + result[2], + "Thirds result is correct" + ); + }), + + browser.tabs + .executeScript({ + code: "location.href;", + runAt: "document_end", + }) + .then(result => { + browser.test.assertEq(1, result.length, "Expected callback result"); + browser.test.assertEq( + "string", + typeof result[0], + "Result is a string" + ); + + browser.test.assertTrue( + /\/file_iframe_document\.html$/.test(result[0]), + "Result is correct" + ); + }), + + browser.tabs + .executeScript({ + code: "window", + }) + .then( + result => { + browser.test.fail( + "Expected error when returning non-structured-clonable object" + ); + }, + error => { + browser.test.assertEq( + "", + error.fileName, + "Got expected fileName" + ); + browser.test.assertEq( + "Script '' result is non-structured-clonable data", + error.message, + "Got expected error" + ); + } + ), + + browser.tabs + .executeScript({ + code: "Promise.resolve(window)", + }) + .then( + result => { + browser.test.fail( + "Expected error when returning non-structured-clonable object" + ); + }, + error => { + browser.test.assertEq( + "", + error.fileName, + "Got expected fileName" + ); + browser.test.assertEq( + "Script '' result is non-structured-clonable data", + error.message, + "Got expected error" + ); + } + ), + + browser.tabs + .executeScript({ + file: "script3.js", + }) + .then( + result => { + browser.test.fail( + "Expected error when returning non-structured-clonable object" + ); + }, + error => { + const expected = + /Script '.*script3.js' result is non-structured-clonable data/; + browser.test.assertTrue( + expected.test(error.message), + "Got expected error" + ); + browser.test.assertTrue( + error.fileName.endsWith("script3.js"), + "Got expected fileName" + ); + } + ), + + browser.tabs + .executeScript({ + frameId: Number.MAX_SAFE_INTEGER, + code: "42", + }) + .then( + result => { + browser.test.fail( + "Expected error when specifying invalid frame ID" + ); + }, + error => { + browser.test.assertEq( + `Invalid frame IDs: [${Number.MAX_SAFE_INTEGER}].`, + error.message, + "Got expected error" + ); + } + ), + + browser.tabs + .create({ url: "http://example.net/", active: false }) + .then(async tab => { + await browser.tabs + .executeScript(tab.id, { + code: "42", + }) + .then( + result => { + browser.test.fail( + "Expected error when trying to execute on invalid domain" + ); + }, + error => { + browser.test.assertEq( + "Missing host permission for the tab", + error.message, + "Got expected error" + ); + } + ); + + await browser.tabs.remove(tab.id); + }), + + browser.tabs + .executeScript({ + code: "Promise.resolve(42)", + }) + .then(result => { + browser.test.assertEq( + 42, + result[0], + "Got expected promise resolution value as result" + ); + }), + + browser.tabs + .executeScript({ + code: "location.href;", + runAt: "document_end", + allFrames: true, + }) + .then(result => { + browser.test.assertTrue( + Array.isArray(result), + "Result is an array" + ); + + browser.test.assertEq( + 2, + result.length, + "Result has correct length" + ); + + browser.test.assertTrue( + /\/file_iframe_document\.html$/.test(result[0]), + "First result is correct" + ); + browser.test.assertEq( + "http://mochi.test:8888/", + result[1], + "Second result is correct" + ); + }), + + browser.tabs + .executeScript({ + code: "location.href;", + frameId: frames[0].frameId, + }) + .then(result => { + browser.test.assertEq(1, result.length, "Expected one result"); + browser.test.assertTrue( + /\/file_iframe_document\.html$/.test(result[0]), + `Result for main frame (frameId:0) is correct: ${result[0]}` + ); + }), + + browser.tabs + .executeScript({ + code: "location.href;", + frameId: frames[1].frameId, + }) + .then(result => { + browser.test.assertEq(1, result.length, "Expected one result"); + browser.test.assertEq( + "http://mochi.test:8888/", + result[0], + "Result for frameId[1] is correct" + ); + }), + + browser.tabs.create({ url: "http://example.com/" }).then(async tab => { + let result = await browser.tabs.executeScript(tab.id, { + code: "location.href", + }); + + browser.test.assertEq( + "http://example.com/", + result[0], + "Script executed correctly in new tab" + ); + + await browser.tabs.remove(tab.id); + }), + + promiseRuntimeOnMessage, + ]); + + browser.test.notifyPass("executeScript"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("executeScript"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "http://mochi.test/", + "http://example.com/", + "webNavigation", + ], + }, + + background, + + files: { + "script.js": function () { + browser.runtime.sendMessage("script ran"); + }, + + "script2.js": "27", + + "script3.js": "window", + }, + }); + + await extension.startup(); + + await extension.awaitFinish("executeScript"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); + + // Make sure that we're not holding on to references to closed message + // managers. + is( + countMM(MessageChannel.messageManagers), + messageManagersSize, + "Message manager count" + ); + is(MessageChannel.pendingResponses.size, 0, "Pending response count"); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_about_blank.js b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_about_blank.js new file mode 100644 index 0000000000..34af25ff9b --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_about_blank.js @@ -0,0 +1,33 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testExecuteScript_at_about_blank() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + host_permissions: ["*://*/*"], // allows script in top-level about:blank. + }, + async background() { + try { + const tab = await browser.tabs.create({ url: "about:blank" }); + const result = await browser.tabs.executeScript(tab.id, { + code: "location.href", + matchAboutBlank: true, + }); + browser.test.assertEq( + "about:blank", + result[0], + "Script executed correctly in new tab" + ); + await browser.tabs.remove(tab.id); + browser.test.notifyPass("executeScript"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("executeScript"); + } + }, + }); + await extension.startup(); + await extension.awaitFinish("executeScript"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_bad.js b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_bad.js new file mode 100644 index 0000000000..6b460243b0 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_bad.js @@ -0,0 +1,361 @@ +"use strict"; + +async function testHasNoPermission(params) { + let contentSetup = params.contentSetup || (() => Promise.resolve()); + + async function background(contentSetup) { + browser.test.onMessage.addListener(async msg => { + browser.test.assertEq(msg, "execute-script"); + + await browser.test.assertRejects( + browser.tabs.executeScript({ + file: "script.js", + }), + /Missing host permission for the tab/ + ); + + browser.test.notifyPass("executeScript"); + }); + + await contentSetup(); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: params.manifest, + + background: `(${background})(${contentSetup})`, + + files: { + "script.js": function () { + browser.runtime.sendMessage("first script ran"); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + if (params.setup) { + await params.setup(extension); + } + + extension.sendMessage("execute-script"); + + await extension.awaitFinish("executeScript"); + await extension.unload(); +} + +add_task(async function testBadPermissions() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + + info("Test no special permissions"); + await testHasNoPermission({ + manifest: { permissions: [] }, + }); + + info("Test tabs permissions"); + await testHasNoPermission({ + manifest: { permissions: ["tabs"] }, + }); + + info("Test no special permissions, commands, key press"); + await testHasNoPermission({ + manifest: { + permissions: [], + commands: { + "test-tabs-executeScript": { + suggested_key: { + default: "Alt+Shift+K", + }, + }, + }, + }, + contentSetup: function () { + browser.commands.onCommand.addListener(function (command) { + if (command == "test-tabs-executeScript") { + browser.test.sendMessage("tabs-command-key-pressed"); + } + }); + return Promise.resolve(); + }, + setup: async function (extension) { + await EventUtils.synthesizeKey("k", { altKey: true, shiftKey: true }); + await extension.awaitMessage("tabs-command-key-pressed"); + }, + }); + + info("Test no special permissions, _execute_browser_action command"); + await testHasNoPermission({ + manifest: { + permissions: [], + browser_action: {}, + commands: { + _execute_browser_action: { + suggested_key: { + default: "Alt+Shift+K", + }, + }, + }, + }, + contentSetup: function () { + browser.browserAction.onClicked.addListener(() => { + browser.test.sendMessage("tabs-command-key-pressed"); + }); + return Promise.resolve(); + }, + setup: async function (extension) { + await EventUtils.synthesizeKey("k", { altKey: true, shiftKey: true }); + await extension.awaitMessage("tabs-command-key-pressed"); + }, + }); + + info("Test no special permissions, _execute_page_action command"); + await testHasNoPermission({ + manifest: { + permissions: [], + page_action: {}, + commands: { + _execute_page_action: { + suggested_key: { + default: "Alt+Shift+K", + }, + }, + }, + }, + contentSetup: async function () { + browser.pageAction.onClicked.addListener(() => { + browser.test.sendMessage("tabs-command-key-pressed"); + }); + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tab.id); + }, + setup: async function (extension) { + await EventUtils.synthesizeKey("k", { altKey: true, shiftKey: true }); + await extension.awaitMessage("tabs-command-key-pressed"); + }, + }); + + info("Test active tab, commands, no key press"); + await testHasNoPermission({ + manifest: { + permissions: ["activeTab"], + commands: { + "test-tabs-executeScript": { + suggested_key: { + default: "Alt+Shift+K", + }, + }, + }, + }, + }); + + info("Test active tab, browser action, no click"); + await testHasNoPermission({ + manifest: { + permissions: ["activeTab"], + browser_action: {}, + }, + }); + + info("Test active tab, page action, no click"); + await testHasNoPermission({ + manifest: { + permissions: ["activeTab"], + page_action: {}, + }, + contentSetup: async function () { + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tab.id); + }, + }); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function testMatchDataURI() { + // allow top level data: URI navigations, otherwise + // window.location.href = data: would be blocked + await SpecialPowers.pushPrefEnv({ + set: [["security.data_uri.block_toplevel_data_uri_navigations", false]], + }); + + const target = ExtensionTestUtils.loadExtension({ + files: { + "page.html": ` + + + + `, + "page.js": function () { + browser.test.onMessage.addListener((msg, url) => { + if (msg !== "navigate") { + return; + } + window.location.href = url; + }); + }, + }, + background() { + browser.tabs.create({ + active: true, + url: browser.runtime.getURL("page.html"), + }); + }, + }); + + const scripts = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["", "webNavigation"], + }, + background() { + browser.webNavigation.onCompleted.addListener(({ url, frameId }) => { + browser.test.log(`Document loading complete: ${url}`); + if (frameId === 0) { + browser.test.sendMessage("tab-ready", url); + } + }); + + browser.test.onMessage.addListener(async msg => { + if (msg !== "execute") { + return; + } + await browser.test.assertRejects( + browser.tabs.executeScript({ + code: "location.href;", + allFrames: true, + }), + /Missing host permission/, + "Should not execute in `data:` frame" + ); + + browser.test.sendMessage("done"); + }); + }, + }); + + await scripts.startup(); + await target.startup(); + + // Test extension page with a data: iframe. + const page = await scripts.awaitMessage("tab-ready"); + ok(page.endsWith("page.html"), "Extension page loaded into a tab"); + + scripts.sendMessage("execute"); + await scripts.awaitMessage("done"); + + // Test extension tab navigated to a data: URI. + const data = "data:text/html;charset=utf-8,also-inherits"; + target.sendMessage("navigate", data); + + const url = await scripts.awaitMessage("tab-ready"); + is(url, data, "Extension tab navigated to a data: URI"); + + scripts.sendMessage("execute"); + await scripts.awaitMessage("done"); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); + await scripts.unload(); + await target.unload(); +}); + +add_task(async function testBadURL() { + async function background() { + let promises = [ + new Promise(resolve => { + browser.tabs.executeScript( + { + file: "http://example.com/script.js", + }, + result => { + browser.test.assertEq(undefined, result, "Result value"); + + browser.test.assertTrue( + browser.runtime.lastError instanceof Error, + "runtime.lastError is Error" + ); + + browser.test.assertTrue( + browser.runtime.lastError instanceof Error, + "runtime.lastError is Error" + ); + + browser.test.assertEq( + "Files to be injected must be within the extension", + browser.runtime.lastError && browser.runtime.lastError.message, + "runtime.lastError value" + ); + + browser.test.assertEq( + "Files to be injected must be within the extension", + browser.runtime.lastError && browser.runtime.lastError.message, + "runtime.lastError value" + ); + + resolve(); + } + ); + }), + + browser.tabs + .executeScript({ + file: "http://example.com/script.js", + }) + .catch(error => { + browser.test.assertTrue(error instanceof Error, "Error is Error"); + + browser.test.assertEq( + null, + browser.runtime.lastError, + "runtime.lastError value" + ); + + browser.test.assertEq( + null, + browser.runtime.lastError, + "runtime.lastError value" + ); + + browser.test.assertEq( + "Files to be injected must be within the extension", + error && error.message, + "error value" + ); + }), + ]; + + await Promise.all(promises); + + browser.test.notifyPass("executeScript-lastError"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [""], + }, + + background, + }); + + await extension.startup(); + + await extension.awaitFinish("executeScript-lastError"); + + await extension.unload(); +}); + +// TODO bug 1435100: Test that |executeScript| fails if the tab has navigated +// to a new page, and no longer matches our expected state. This involves +// intentionally trying to trigger a race condition. + +add_task(forceGC); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_file.js b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_file.js new file mode 100644 index 0000000000..a8ba389602 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_file.js @@ -0,0 +1,93 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const FILE_URL = Services.io.newFileURI( + new FileUtils.File(getTestFilePath("file_dummy.html")) +).spec; + +add_task(async function testExecuteScript_at_file_url() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "file:///*"], + }, + background() { + browser.test.onMessage.addListener(async () => { + try { + const [tab] = await browser.tabs.query({ url: "file://*/*/*dummy*" }); + const result = await browser.tabs.executeScript(tab.id, { + code: "location.protocol", + }); + browser.test.assertEq( + "file:", + result[0], + "Script executed correctly in new tab" + ); + browser.test.notifyPass("executeScript"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("executeScript"); + } + }); + }, + }); + await extension.startup(); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, FILE_URL); + + extension.sendMessage(); + await extension.awaitFinish("executeScript"); + + BrowserTestUtils.removeTab(tab); + + await extension.unload(); +}); + +add_task(async function testExecuteScript_at_file_url_with_activeTab() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["activeTab"], + browser_action: {}, + }, + background() { + browser.browserAction.onClicked.addListener(async tab => { + try { + const result = await browser.tabs.executeScript(tab.id, { + code: "location.protocol", + }); + browser.test.assertEq( + "file:", + result[0], + "Script executed correctly in active tab" + ); + browser.test.notifyPass("executeScript"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("executeScript"); + } + }); + + browser.test.onMessage.addListener(async () => { + await browser.test.assertRejects( + browser.tabs.executeScript({ code: "location.protocol" }), + /Missing host permission for the tab/, + "activeTab not active yet, executeScript should be rejected" + ); + browser.test.sendMessage("next-step"); + }); + }, + }); + await extension.startup(); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, FILE_URL); + + extension.sendMessage(); + await extension.awaitMessage("next-step"); + + await clickBrowserAction(extension); + await extension.awaitFinish("executeScript"); + + BrowserTestUtils.removeTab(tab); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_good.js b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_good.js new file mode 100644 index 0000000000..e9d008bf92 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_good.js @@ -0,0 +1,190 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +requestLongerTimeout(2); + +async function testHasPermission(params) { + let contentSetup = params.contentSetup || (() => Promise.resolve()); + + async function background(contentSetup) { + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.assertEq(msg, "script ran", "script ran"); + browser.test.notifyPass("executeScript"); + }); + + browser.test.onMessage.addListener(msg => { + browser.test.assertEq(msg, "execute-script"); + + browser.tabs.executeScript({ + file: "script.js", + }); + }); + + await contentSetup(); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: params.manifest, + + background: `(${background})(${contentSetup})`, + + files: { + "panel.html": ` + + + + + `, + "script.js": function () { + browser.runtime.sendMessage("script ran"); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + if (params.setup) { + await params.setup(extension); + } + + extension.sendMessage("execute-script"); + + await extension.awaitFinish("executeScript"); + + if (params.tearDown) { + await params.tearDown(extension); + } + + await extension.unload(); +} + +add_task(async function testGoodPermissions() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/", + true + ); + + info("Test activeTab permission with a command key press"); + await testHasPermission({ + manifest: { + permissions: ["activeTab"], + commands: { + "test-tabs-executeScript": { + suggested_key: { + default: "Alt+Shift+K", + }, + }, + }, + }, + contentSetup: function () { + browser.commands.onCommand.addListener(function (command) { + if (command == "test-tabs-executeScript") { + browser.test.sendMessage("tabs-command-key-pressed"); + } + }); + return Promise.resolve(); + }, + setup: async function (extension) { + await EventUtils.synthesizeKey("k", { altKey: true, shiftKey: true }); + await extension.awaitMessage("tabs-command-key-pressed"); + }, + }); + + info("Test activeTab permission with _execute_browser_action command"); + await testHasPermission({ + manifest: { + permissions: ["activeTab"], + browser_action: {}, + commands: { + _execute_browser_action: { + suggested_key: { + default: "Alt+Shift+K", + }, + }, + }, + }, + contentSetup: function () { + browser.browserAction.onClicked.addListener(() => { + browser.test.sendMessage("tabs-command-key-pressed"); + }); + return Promise.resolve(); + }, + setup: async function (extension) { + await EventUtils.synthesizeKey("k", { altKey: true, shiftKey: true }); + await extension.awaitMessage("tabs-command-key-pressed"); + }, + }); + + info("Test activeTab permission with _execute_page_action command"); + await testHasPermission({ + manifest: { + permissions: ["activeTab"], + page_action: {}, + commands: { + _execute_page_action: { + suggested_key: { + default: "Alt+Shift+K", + }, + }, + }, + }, + contentSetup: async function () { + browser.pageAction.onClicked.addListener(() => { + browser.test.sendMessage("tabs-command-key-pressed"); + }); + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tab.id); + }, + setup: async function (extension) { + await EventUtils.synthesizeKey("k", { altKey: true, shiftKey: true }); + await extension.awaitMessage("tabs-command-key-pressed"); + }, + }); + + info("Test activeTab permission with a context menu click"); + await testHasPermission({ + manifest: { + permissions: ["activeTab", "contextMenus"], + }, + contentSetup: function () { + browser.contextMenus.create({ title: "activeTab", contexts: ["all"] }); + return Promise.resolve(); + }, + setup: async function (extension) { + let contextMenu = document.getElementById("contentAreaContextMenu"); + let awaitPopupShown = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + let awaitPopupHidden = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "a[href]", + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + await awaitPopupShown; + + let item = contextMenu.querySelector("[label=activeTab]"); + + contextMenu.activateItem(item); + + await awaitPopupHidden; + }, + }); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(forceGC); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_multiple.js b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_multiple.js new file mode 100644 index 0000000000..4e9cc907da --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_multiple.js @@ -0,0 +1,61 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testExecuteScript() { + const BASE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/"; + const URL = BASE + "file_dummy.html"; + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URL, true); + + async function background() { + try { + await browser.tabs.executeScript({ code: "this.foo = 'bar'" }); + await browser.tabs.executeScript({ file: "script.js" }); + + let [result1] = await browser.tabs.executeScript({ + code: "[this.foo, this.bar]", + }); + let [result2] = await browser.tabs.executeScript({ file: "script2.js" }); + + browser.test.assertEq( + "bar,baz", + String(result1), + "executeScript({code}) result" + ); + browser.test.assertEq( + "bar,baz", + String(result2), + "executeScript({file}) result" + ); + + browser.test.notifyPass("executeScript-multiple"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("executeScript-multiple"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://mochi.test/"], + }, + + background, + + files: { + "script.js": function () { + this.bar = "baz"; + }, + + "script2.js": "[this.foo, this.bar]", + }, + }); + + await extension.startup(); + + await extension.awaitFinish("executeScript-multiple"); + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_no_create.js b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_no_create.js new file mode 100644 index 0000000000..e8e1f1255f --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_no_create.js @@ -0,0 +1,80 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testExecuteScriptAtOnUpdated() { + const BASE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/"; + const URL = BASE + "file_iframe_document.html"; + // This is a regression test for bug 1325830. + // The bug (executeScript not completing any more) occurred when executeScript + // was called early at the onUpdated event, unless the tabs.create method is + // called. So this test does not use tabs.create to open new tabs. + // Note that if this test is run together with other tests that do call + // tabs.create, then this test case does not properly test the conditions of + // the regression any more. To verify that the regression has been resolved, + // this test must be run in isolation. + + function background() { + // Using variables to prevent listeners from running more than once, instead + // of removing the listener. This is to minimize any IPC, since the bug that + // is being tested is sensitive to timing. + let ignore = false; + let url; + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (ignore) { + return; + } + if (url && changeInfo.status === "loading" && tab.url === url) { + ignore = true; + browser.tabs + .executeScript(tabId, { + code: "document.URL", + }) + .then( + results => { + browser.test.assertEq( + url, + results[0], + "Content script should run" + ); + browser.test.notifyPass("executeScript-at-onUpdated"); + }, + error => { + browser.test.fail(`Unexpected error: ${error} :: ${error.stack}`); + browser.test.notifyFail("executeScript-at-onUpdated"); + } + ); + // (running this log call after executeScript to minimize IPC between + // onUpdated and executeScript.) + browser.test.log(`Found expected navigation to ${url}`); + } else { + // The bug occurs when executeScript is called before a tab is + // initialized. + browser.tabs.executeScript(tabId, { code: "" }); + } + }); + browser.test.onMessage.addListener(testUrl => { + url = testUrl; + browser.test.sendMessage("open-test-tab"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://mochi.test/", "tabs"], + }, + background, + }); + + await extension.startup(); + extension.sendMessage(URL); + await extension.awaitMessage("open-test-tab"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URL, true); + + await extension.awaitFinish("executeScript-at-onUpdated"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_runAt.js b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_runAt.js new file mode 100644 index 0000000000..bab0182a3f --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_runAt.js @@ -0,0 +1,134 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/** + * These tests ensure that the runAt argument to tabs.executeScript delays + * script execution until the document has reached the correct state. + * + * Since tests of this nature are especially race-prone, it relies on a + * server-JS script to delay the completion of our test page's load cycle long + * enough for us to attempt to load our scripts in the earlies phase we support. + * + * And since we can't actually rely on that timing, it retries any attempts that + * fail to load as early as expected, but don't load at any illegal time. + */ + +add_task(async function testExecuteScript() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank", + true + ); + + async function background() { + let tab; + + const BASE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/"; + const URL = BASE + "file_slowed_document.sjs"; + + const MAX_TRIES = 10; + + let onUpdatedPromise = (tabId, url, status) => { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener(_, changed, tab) { + if (tabId == tab.id && changed.status == status && tab.url == url) { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + }; + + try { + [tab] = await browser.tabs.query({ active: true, currentWindow: true }); + + let success = false; + for (let tries = 0; !success && tries < MAX_TRIES; tries++) { + let url = `${URL}?with-iframe&r=${Math.random()}`; + + let loadingPromise = onUpdatedPromise(tab.id, url, "loading"); + let completePromise = onUpdatedPromise(tab.id, url, "complete"); + + // TODO: Test allFrames and frameId. + + await browser.tabs.update({ url }); + await loadingPromise; + + let states = await Promise.all( + [ + // Send the executeScript requests in the reverse order that we expect + // them to execute in, to avoid them passing only because of timing + // races. + browser.tabs.executeScript({ + code: "document.readyState", + // Testing default `runAt`. + }), + browser.tabs.executeScript({ + code: "document.readyState", + runAt: "document_idle", + }), + browser.tabs.executeScript({ + code: "document.readyState", + runAt: "document_end", + }), + browser.tabs.executeScript({ + code: "document.readyState", + runAt: "document_start", + }), + ].reverse() + ); + + browser.test.log(`Got states: ${states}`); + + // Make sure that none of our scripts executed earlier than expected, + // regardless of retries. + browser.test.assertTrue( + states[1] == "interactive" || states[1] == "complete", + `document_end state is valid: ${states[1]}` + ); + browser.test.assertTrue( + states[2] == "interactive" || states[2] == "complete", + `document_idle state is valid: ${states[2]}` + ); + + // If we have the earliest valid states for each script, we're done. + // Otherwise, try again. + success = + states[0] == "loading" && + states[1] == "interactive" && + states[2] == "interactive" && + states[3] == "interactive"; + + await completePromise; + } + + browser.test.assertTrue( + success, + "Got the earliest expected states at least once" + ); + + browser.test.notifyPass("executeScript-runAt"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("executeScript-runAt"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://mochi.test/", "tabs"], + }, + + background, + }); + + await extension.startup(); + + await extension.awaitFinish("executeScript-runAt"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_getCurrent.js b/browser/components/extensions/test/browser/browser_ext_tabs_getCurrent.js new file mode 100644 index 0000000000..9304d3a5b4 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_getCurrent.js @@ -0,0 +1,86 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + + browser_action: { default_popup: "popup.html" }, + }, + + files: { + "tab.js": function () { + let url = document.location.href; + + browser.tabs.getCurrent(currentTab => { + browser.test.assertEq( + currentTab.url, + url, + "getCurrent in non-active background tab" + ); + + // Activate the tab. + browser.tabs.onActivated.addListener(function listener({ tabId }) { + if (tabId == currentTab.id) { + browser.tabs.onActivated.removeListener(listener); + + browser.tabs.getCurrent(currentTab => { + browser.test.assertEq( + currentTab.id, + tabId, + "in active background tab" + ); + browser.test.assertEq( + currentTab.url, + url, + "getCurrent in non-active background tab" + ); + + browser.test.sendMessage("tab-finished"); + }); + } + }); + browser.tabs.update(currentTab.id, { active: true }); + }); + }, + + "popup.js": function () { + browser.tabs.getCurrent(tab => { + browser.test.assertEq(tab, undefined, "getCurrent in popup script"); + browser.test.sendMessage("popup-finished"); + }); + }, + + "tab.html": ``, + "popup.html": ``, + }, + + background: function () { + browser.tabs.getCurrent(tab => { + browser.test.assertEq( + tab, + undefined, + "getCurrent in background script" + ); + browser.test.sendMessage("background-finished"); + }); + + browser.tabs.create({ url: "tab.html", active: false }); + }, + }); + + await extension.startup(); + + await extension.awaitMessage("background-finished"); + await extension.awaitMessage("tab-finished"); + + clickBrowserAction(extension); + await awaitExtensionPanel(extension); + await extension.awaitMessage("popup-finished"); + await closeBrowserAction(extension); + + // The extension tab is automatically closed when the extension unloads. + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_goBack_goForward.js b/browser/components/extensions/test/browser/browser_ext_tabs_goBack_goForward.js new file mode 100644 index 0000000000..2ab960699d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_goBack_goForward.js @@ -0,0 +1,113 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_tabs_goBack_goForward() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.navigation.requireUserInteraction", false]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + files: { + "tab1.html": ` + + tab1 + `, + "tab2.html": ` + + tab2 + `, + }, + + async background() { + let tabUpdatedCount = 0; + let tab = {}; + + browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tabInfo) => { + if (changeInfo.status !== "complete" || tabId !== tab.id) { + return; + } + + tabUpdatedCount++; + switch (tabUpdatedCount) { + case 1: + browser.test.assertEq( + "tab1", + tabInfo.title, + "tab1 is found as expected" + ); + browser.tabs.update(tabId, { url: "tab2.html" }); + break; + + case 2: + browser.test.assertEq( + "tab2", + tabInfo.title, + "tab2 is found as expected" + ); + browser.tabs.update(tabId, { url: "tab1.html" }); + break; + + case 3: + browser.test.assertEq( + "tab1", + tabInfo.title, + "tab1 is found as expected" + ); + browser.tabs.goBack(); + break; + + case 4: + browser.test.assertEq( + "tab2", + tabInfo.title, + "tab2 is found after navigating backward with empty parameter" + ); + browser.tabs.goBack(tabId); + break; + + case 5: + browser.test.assertEq( + "tab1", + tabInfo.title, + "tab1 is found after navigating backward with tabId as parameter" + ); + browser.tabs.goForward(); + break; + + case 6: + browser.test.assertEq( + "tab2", + tabInfo.title, + "tab2 is found after navigating forward with empty parameter" + ); + browser.tabs.goForward(tabId); + break; + + case 7: + browser.test.assertEq( + "tab1", + tabInfo.title, + "tab1 is found after navigating forward with tabId as parameter" + ); + await browser.tabs.remove(tabId); + browser.test.notifyPass("tabs.goBack.goForward"); + break; + + default: + break; + } + }); + + tab = await browser.tabs.create({ url: "tab1.html", active: true }); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.goBack.goForward"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_hide.js b/browser/components/extensions/test/browser/browser_ext_tabs_hide.js new file mode 100644 index 0000000000..89c50db692 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_hide.js @@ -0,0 +1,375 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionControlledPopup: + "resource:///modules/ExtensionControlledPopup.sys.mjs", + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs", +}); + +const triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL; + +async function doorhangerTest(testFn) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "tabHide"], + icons: { + 48: "addon-icon.png", + }, + }, + background() { + browser.test.onMessage.addListener(async (msg, data) => { + let tabs = await browser.tabs.query(data); + await browser.tabs[msg](tabs.map(t => t.id)); + browser.test.sendMessage("done"); + }); + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + + // Open some tabs so we can hide them. + let firstTab = gBrowser.selectedTab; + let tabs = [ + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/?one", + true, + true + ), + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/?two", + true, + true + ), + ]; + gBrowser.selectedTab = firstTab; + + await testFn(extension); + + BrowserTestUtils.removeTab(tabs[0]); + BrowserTestUtils.removeTab(tabs[1]); + + await extension.unload(); +} + +add_task(function test_doorhanger_keep() { + return doorhangerTest(async function (extension) { + is(gBrowser.visibleTabs.length, 3, "There are 3 visible tabs"); + + // Hide the first tab, expect the doorhanger. + let panel = ExtensionControlledPopup._getAndMaybeCreatePanel(document); + let popupShown = promisePopupShown(panel); + extension.sendMessage("hide", { url: "*://*/?one" }); + await extension.awaitMessage("done"); + await popupShown; + + is(gBrowser.visibleTabs.length, 2, "There are 2 visible tabs now"); + is( + panel.anchorNode.closest("toolbarbutton").id, + "alltabs-button", + "The doorhanger is anchored to the all tabs button" + ); + + // Click the Keep Tabs Hidden button. + let popupnotification = document.getElementById( + "extension-tab-hide-notification" + ); + let popupHidden = promisePopupHidden(panel); + popupnotification.button.click(); + await popupHidden; + + // Hide another tab and ensure the popup didn't open. + extension.sendMessage("hide", { url: "*://*/?two" }); + await extension.awaitMessage("done"); + is(panel.state, "closed", "The popup is still closed"); + is(gBrowser.visibleTabs.length, 1, "There's one visible tab now"); + + extension.sendMessage("show", {}); + await extension.awaitMessage("done"); + }); +}); + +add_task(function test_doorhanger_disable() { + return doorhangerTest(async function (extension) { + is(gBrowser.visibleTabs.length, 3, "There are 3 visible tabs"); + + // Hide the first tab, expect the doorhanger. + let panel = ExtensionControlledPopup._getAndMaybeCreatePanel(document); + let popupShown = promisePopupShown(panel); + extension.sendMessage("hide", { url: "*://*/?one" }); + await extension.awaitMessage("done"); + await popupShown; + + is(gBrowser.visibleTabs.length, 2, "There are 2 visible tabs now"); + is( + panel.anchorNode.closest("toolbarbutton").id, + "alltabs-button", + "The doorhanger is anchored to the all tabs button" + ); + + // verify the contents of the description. + let popupnotification = document.getElementById( + "extension-tab-hide-notification" + ); + let description = popupnotification.querySelector( + "#extension-tab-hide-notification-description" + ); + let addon = await AddonManager.getAddonByID(extension.id); + ok( + description.textContent.includes(addon.name), + "The extension name is in the description" + ); + let images = Array.from(description.querySelectorAll("image")); + is(images.length, 2, "There are two images"); + ok( + images.some(img => img.src.includes("addon-icon.png")), + "There's an icon for the extension" + ); + ok( + images.some(img => + getComputedStyle(img).backgroundImage.includes("arrow-down.svg") + ), + "There's an icon for the all tabs menu" + ); + + // Click the Disable Extension button. + let popupHidden = promisePopupHidden(panel); + popupnotification.secondaryButton.click(); + await popupHidden; + await new Promise(executeSoon); + + is(gBrowser.visibleTabs.length, 3, "There are 3 visible tabs again"); + is(addon.userDisabled, true, "The extension is now disabled"); + }); +}); + +add_task(async function test_tabs_showhide() { + async function background() { + browser.test.onMessage.addListener(async (msg, data) => { + switch (msg) { + case "hideall": { + let tabs = await browser.tabs.query({ hidden: false }); + browser.test.assertEq(tabs.length, 5, "got 5 tabs"); + let ids = tabs.map(tab => tab.id); + browser.test.log(`working with ids ${JSON.stringify(ids)}`); + + let hidden = await browser.tabs.hide(ids); + browser.test.assertEq(hidden.length, 3, "hid 3 tabs"); + tabs = await browser.tabs.query({ hidden: true }); + ids = tabs.map(tab => tab.id); + browser.test.assertEq( + JSON.stringify(hidden.sort()), + JSON.stringify(ids.sort()), + "hidden tabIds match" + ); + + browser.test.sendMessage("hidden", { hidden }); + break; + } + case "showall": { + let tabs = await browser.tabs.query({ hidden: true }); + for (let tab of tabs) { + browser.test.assertTrue(tab.hidden, "tab is hidden"); + } + let ids = tabs.map(tab => tab.id); + browser.tabs.show(ids); + browser.test.sendMessage("shown"); + break; + } + } + }); + } + + let extdata = { + manifest: { permissions: ["tabs", "tabHide"] }, + background, + useAddonManager: "temporary", // So the doorhanger can find the addon. + }; + let extension = ExtensionTestUtils.loadExtension(extdata); + await extension.startup(); + + let sessData = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + { + entries: [ + { url: "https://example.com/", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "https://mochi.test:8888/", triggeringPrincipal_base64 }, + ], + }, + ], + }, + { + tabs: [ + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + { + entries: [ + { url: "http://test1.example.com/", triggeringPrincipal_base64 }, + ], + }, + ], + }, + ], + }; + + // Set up a test session with 2 windows and 5 tabs. + let oldState = SessionStore.getBrowserState(); + let restored = TestUtils.topicObserved("sessionstore-browser-state-restored"); + SessionStore.setBrowserState(JSON.stringify(sessData)); + await restored; + + if (!Services.prefs.getBoolPref("browser.tabs.tabmanager.enabled")) { + for (let win of BrowserWindowIterator()) { + let allTabsButton = win.document.getElementById("alltabs-button"); + is( + getComputedStyle(allTabsButton).display, + "none", + "The all tabs button is hidden" + ); + } + } + + // Attempt to hide all the tabs, however the active tab in each window cannot + // be hidden, so the result will be 3 hidden tabs. + extension.sendMessage("hideall"); + await extension.awaitMessage("hidden"); + + // We have 2 windows in this session. Otherwin is the non-current window. + // In each window, the first tab will be the selected tab and should not be + // hidden. The rest of the tabs should be hidden at this point. Hidden + // status was already validated inside the extension, this double checks + // from chrome code. + let otherwin; + for (let win of BrowserWindowIterator()) { + if (win != window) { + otherwin = win; + } + let tabs = Array.from(win.gBrowser.tabs); + ok(!tabs[0].hidden, "first tab not hidden"); + for (let i = 1; i < tabs.length; i++) { + ok(tabs[i].hidden, "tab hidden value is correct"); + let id = SessionStore.getCustomTabValue(tabs[i], "hiddenBy"); + is(id, extension.id, "tab hiddenBy value is correct"); + await TabStateFlusher.flush(tabs[i].linkedBrowser); + } + + let allTabsButton = win.document.getElementById("alltabs-button"); + isnot( + getComputedStyle(allTabsButton).display, + "none", + "The all tabs button is visible" + ); + } + + // Close the other window then restore it to test that the tabs are + // restored with proper hidden state, and the correct extension id. + await BrowserTestUtils.closeWindow(otherwin); + + otherwin = SessionStore.undoCloseWindow(0); + await BrowserTestUtils.waitForEvent(otherwin, "load"); + let tabs = Array.from(otherwin.gBrowser.tabs); + ok(!tabs[0].hidden, "first tab not hidden"); + for (let i = 1; i < tabs.length; i++) { + ok(tabs[i].hidden, "tab hidden value is correct"); + let id = SessionStore.getCustomTabValue(tabs[i], "hiddenBy"); + is(id, extension.id, "tab hiddenBy value is correct"); + } + + // Test closing the last visible tab, the next tab which is hidden should become + // the selectedTab and will be visible. + ok(!otherwin.gBrowser.selectedTab.hidden, "selected tab is not hidden"); + BrowserTestUtils.removeTab(otherwin.gBrowser.selectedTab); + ok(!otherwin.gBrowser.selectedTab.hidden, "tab was unhidden"); + + // Showall will unhide any remaining hidden tabs. + extension.sendMessage("showall"); + await extension.awaitMessage("shown"); + + // Check from chrome code that all tabs are visible again. + for (let win of BrowserWindowIterator()) { + let tabs = Array.from(win.gBrowser.tabs); + for (let i = 0; i < tabs.length; i++) { + ok(!tabs[i].hidden, "tab hidden value is correct"); + } + } + + // Close second window. + await BrowserTestUtils.closeWindow(otherwin); + + await extension.unload(); + + // Restore pre-test state. + restored = TestUtils.topicObserved("sessionstore-browser-state-restored"); + SessionStore.setBrowserState(oldState); + await restored; +}); + +// Test our shutdown handling. Currently this means any hidden tabs will be +// shown when a tabHide extension is shutdown. We additionally test the +// tabs.onUpdated listener gets called with hidden state changes. +add_task(async function test_tabs_shutdown() { + let tabs = [ + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/", + true, + true + ), + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/", + true, + true + ), + ]; + + async function background() { + let tabs = await browser.tabs.query({ url: "http://example.com/" }); + let testTab = tabs[0]; + + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if ("hidden" in changeInfo) { + browser.test.assertEq(tabId, testTab.id, "correct tab was hidden"); + browser.test.assertTrue(changeInfo.hidden, "tab is hidden"); + browser.test.assertEq(tab.url, testTab.url, "tab has correct URL"); + browser.test.sendMessage("changeInfo"); + } + }); + + let hidden = await browser.tabs.hide(testTab.id); + browser.test.assertEq(hidden[0], testTab.id, "tab was hidden"); + tabs = await browser.tabs.query({ hidden: true }); + browser.test.assertEq(tabs[0].id, testTab.id, "tab was hidden"); + browser.test.sendMessage("ready"); + } + + let extdata = { + manifest: { permissions: ["tabs", "tabHide"] }, + useAddonManager: "temporary", // For testing onShutdown. + background, + }; + let extension = ExtensionTestUtils.loadExtension(extdata); + await extension.startup(); + + // test onUpdated + await Promise.all([ + extension.awaitMessage("ready"), + extension.awaitMessage("changeInfo"), + ]); + Assert.ok(tabs[0].hidden, "Tab is hidden by extension"); + + await extension.unload(); + + Assert.ok(!tabs[0].hidden, "Tab is not hidden after unloading extension"); + BrowserTestUtils.removeTab(tabs[0]); + BrowserTestUtils.removeTab(tabs[1]); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_hide_update.js b/browser/components/extensions/test/browser/browser_ext_tabs_hide_update.js new file mode 100644 index 0000000000..7fbf185704 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_hide_update.js @@ -0,0 +1,146 @@ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +AddonTestUtils.initMochitest(this); + +const ID = "@test-tabs-addon"; + +async function updateExtension(ID, options) { + let xpi = AddonTestUtils.createTempWebExtensionFile(options); + await Promise.all([ + AddonTestUtils.promiseWebExtensionStartup(ID), + AddonManager.installTemporaryAddon(xpi), + ]); +} + +async function disableExtension(ID) { + let disabledPromise = awaitEvent("shutdown", ID); + let addon = await AddonManager.getAddonByID(ID); + await addon.disable(); + await disabledPromise; +} + +function getExtension() { + async function background() { + let tabs = await browser.tabs.query({ url: "http://example.com/" }); + let testTab = tabs[0]; + + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if ("hidden" in changeInfo) { + browser.test.assertEq(tabId, testTab.id, "correct tab was hidden"); + browser.test.assertTrue(changeInfo.hidden, "tab is hidden"); + browser.test.sendMessage("changeInfo"); + } + }); + + let hidden = await browser.tabs.hide(testTab.id); + browser.test.assertEq(hidden[0], testTab.id, "tabs.hide hide the tab"); + tabs = await browser.tabs.query({ hidden: true }); + browser.test.assertEq( + tabs[0].id, + testTab.id, + "tabs.query result was hidden" + ); + browser.test.sendMessage("ready"); + } + + let extdata = { + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: ID, + }, + }, + permissions: ["tabs", "tabHide"], + }, + background, + useAddonManager: "temporary", + }; + return ExtensionTestUtils.loadExtension(extdata); +} + +// Test our update handling. Currently this means any hidden tabs will be +// shown when a tabHide extension is shutdown. We additionally test the +// tabs.onUpdated listener gets called with hidden state changes. +add_task(async function test_tabs_update() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[0]); + + const extension = getExtension(); + await extension.startup(); + + // test onUpdated + await Promise.all([ + extension.awaitMessage("ready"), + extension.awaitMessage("changeInfo"), + ]); + Assert.ok(tab.hidden, "Tab is hidden by extension"); + + // Test that update doesn't hide tabs when tabHide permission is present. + let extdata = { + manifest: { + version: "2.0", + browser_specific_settings: { + gecko: { + id: ID, + }, + }, + permissions: ["tabs", "tabHide"], + }, + }; + await updateExtension(ID, extdata); + Assert.ok(tab.hidden, "Tab is hidden hidden after update"); + + // Test that update does hide tabs when tabHide permission is removed. + extdata.manifest = { + version: "3.0", + browser_specific_settings: { + gecko: { + id: ID, + }, + }, + permissions: ["tabs"], + }; + await updateExtension(ID, extdata); + Assert.ok(!tab.hidden, "Tab is not hidden hidden after update"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +// Test our update handling. Currently this means any hidden tabs will be +// shown when a tabHide extension is shutdown. We additionally test the +// tabs.onUpdated listener gets called with hidden state changes. +add_task(async function test_tabs_disable() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[0]); + + const extension = getExtension(); + await extension.startup(); + + // test onUpdated + await Promise.all([ + extension.awaitMessage("ready"), + extension.awaitMessage("changeInfo"), + ]); + Assert.ok(tab.hidden, "Tab is hidden by extension"); + + // Test that disable does hide tabs. + await disableExtension(ID); + Assert.ok(!tab.hidden, "Tab is not hidden hidden after disable"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_highlight.js b/browser/components/extensions/test/browser/browser_ext_tabs_highlight.js new file mode 100644 index 0000000000..d622b79e7a --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_highlight.js @@ -0,0 +1,118 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* global gBrowser */ +"use strict"; + +add_task(async function test_highlighted() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + async function testHighlighted(activeIndex, highlightedIndices) { + let tabs = await browser.tabs.query({ currentWindow: true }); + for (let { index, active, highlighted } of tabs) { + browser.test.assertEq( + index == activeIndex, + active, + "Check Tab.active: " + index + ); + let expected = + highlightedIndices.includes(index) || index == activeIndex; + browser.test.assertEq( + expected, + highlighted, + "Check Tab.highlighted: " + index + ); + } + let highlightedTabs = await browser.tabs.query({ + currentWindow: true, + highlighted: true, + }); + browser.test.assertEq( + highlightedIndices + .concat(activeIndex) + .sort((a, b) => a - b) + .join(), + highlightedTabs.map(tab => tab.index).join(), + "Check tabs.query with highlighted:true provides the expected tabs" + ); + } + + browser.test.log( + "Check that last tab is active, and no other is highlighted" + ); + await testHighlighted(2, []); + + browser.test.log("Highlight first and second tabs"); + await browser.tabs.highlight({ tabs: [0, 1] }); + await testHighlighted(0, [1]); + + browser.test.log("Highlight second and first tabs"); + await browser.tabs.highlight({ tabs: [1, 0] }); + await testHighlighted(1, [0]); + + browser.test.log("Test that highlight fails for invalid data"); + await browser.test.assertRejects( + browser.tabs.highlight({ tabs: [] }), + /No highlighted tab/, + "Attempt to highlight no tab should throw" + ); + await browser.test.assertRejects( + browser.tabs.highlight({ windowId: 999999999, tabs: 0 }), + /Invalid window ID: 999999999/, + "Attempt to highlight tabs in invalid window should throw" + ); + await browser.test.assertRejects( + browser.tabs.highlight({ tabs: 999999999 }), + /No tab at index: 999999999/, + "Attempt to highlight invalid tab index should throw" + ); + await browser.test.assertRejects( + browser.tabs.highlight({ tabs: [2, 999999999] }), + /No tab at index: 999999999/, + "Attempt to highlight invalid tab index should throw" + ); + + browser.test.log( + "Highlighted tabs shouldn't be affected by failures above" + ); + await testHighlighted(1, [0]); + + browser.test.log("Highlight last tab"); + let window = await browser.tabs.highlight({ tabs: 2 }); + await testHighlighted(2, []); + + browser.test.assertEq( + 3, + window.tabs.length, + "Returned window should be populated" + ); + + window = await browser.tabs.highlight({ tabs: 2, populate: false }); + browser.test.assertFalse( + "tabs" in window, + "Returned window shouldn't be populated" + ); + + browser.test.notifyPass("test-finished"); + }, + }); + + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + + await extension.startup(); + await extension.awaitFinish("test-finished"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_incognito_not_allowed.js b/browser/components/extensions/test/browser/browser_ext_tabs_incognito_not_allowed.js new file mode 100644 index 0000000000..e998f64afc --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_incognito_not_allowed.js @@ -0,0 +1,155 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testExecuteScriptIncognitoNotAllowed() { + const url = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/file_iframe_document.html"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + // captureTab requires all_urls permission + permissions: ["", "tabs", "tabHide"], + }, + background() { + browser.test.onMessage.addListener(async pbw => { + // expect one tab from the non-pb window + let tabs = await browser.tabs.query({ windowId: pbw.windowId }); + browser.test.assertEq( + 0, + tabs.length, + "unable to query tabs in private window" + ); + tabs = await browser.tabs.query({ active: true }); + browser.test.assertEq( + 1, + tabs.length, + "unable to query active tab in private window" + ); + browser.test.assertTrue( + tabs[0].windowId != pbw.windowId, + "unable to query active tab in private window" + ); + + // apis that take a tabId + let tabIdAPIs = [ + "captureTab", + "detectLanguage", + "duplicate", + "get", + "hide", + "reload", + "getZoomSettings", + "getZoom", + "toggleReaderMode", + ]; + for (let name of tabIdAPIs) { + await browser.test.assertRejects( + browser.tabs[name](pbw.tabId), + /Invalid tab ID/, + `should not be able to ${name}` + ); + } + await browser.test.assertRejects( + browser.tabs.captureVisibleTab(pbw.windowId), + /Invalid window ID/, + "should not be able to duplicate" + ); + await browser.test.assertRejects( + browser.tabs.create({ + windowId: pbw.windowId, + url: "http://mochi.test/", + }), + /Invalid window ID/, + "unable to create tab in private window" + ); + await browser.test.assertRejects( + browser.tabs.executeScript(pbw.tabId, { code: "document.URL" }), + /Invalid tab ID/, + "should not be able to executeScript" + ); + let currentTab = await browser.tabs.getCurrent(); + browser.test.assertTrue( + !currentTab, + "unable to get current tab in private window" + ); + await browser.test.assertRejects( + browser.tabs.highlight({ windowId: pbw.windowId, tabs: [pbw.tabId] }), + /Invalid window ID/, + "should not be able to highlight" + ); + await browser.test.assertRejects( + browser.tabs.insertCSS(pbw.tabId, { + code: "* { background: rgb(42, 42, 42) }", + }), + /Invalid tab ID/, + "should not be able to insertCSS" + ); + await browser.test.assertRejects( + browser.tabs.move(pbw.tabId, { + index: 0, + windowId: tabs[0].windowId, + }), + /Invalid tab ID/, + "unable to move tab to private window" + ); + await browser.test.assertRejects( + browser.tabs.move(tabs[0].id, { index: 0, windowId: pbw.windowId }), + /Invalid window ID/, + "unable to move tab to private window" + ); + await browser.test.assertRejects( + browser.tabs.printPreview(), + /Cannot access activeTab/, + "unable to printpreview tab" + ); + await browser.test.assertRejects( + browser.tabs.removeCSS(pbw.tabId, {}), + /Invalid tab ID/, + "unable to remove tab css" + ); + await browser.test.assertRejects( + browser.tabs.sendMessage(pbw.tabId, "test"), + /Could not establish connection/, + "unable to sendmessage" + ); + await browser.test.assertRejects( + browser.tabs.setZoomSettings(pbw.tabId, {}), + /Invalid tab ID/, + "should not be able to set zoom settings" + ); + await browser.test.assertRejects( + browser.tabs.setZoom(pbw.tabId, 3), + /Invalid tab ID/, + "should not be able to set zoom" + ); + await browser.test.assertRejects( + browser.tabs.update(pbw.tabId, {}), + /Invalid tab ID/, + "should not be able to update tab" + ); + await browser.test.assertRejects( + browser.tabs.moveInSuccession([pbw.tabId], tabs[0].id), + /Invalid tab ID/, + "should not be able to moveInSuccession" + ); + await browser.test.assertRejects( + browser.tabs.moveInSuccession([tabs[0].id], pbw.tabId), + /Invalid tab ID/, + "should not be able to moveInSuccession" + ); + + browser.test.notifyPass("pass"); + }); + }, + }); + + let winData = await getIncognitoWindow(url); + await extension.startup(); + + extension.sendMessage(winData.details); + + await extension.awaitFinish("pass"); + await BrowserTestUtils.closeWindow(winData.win); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_insertCSS.js b/browser/components/extensions/test/browser/browser_ext_tabs_insertCSS.js new file mode 100644 index 0000000000..1a4bbd0c74 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_insertCSS.js @@ -0,0 +1,312 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +AddonTestUtils.initMochitest(this); + +add_task(async function testExecuteScript() { + let { MessageChannel } = ChromeUtils.importESModule( + "resource://testing-common/MessageChannel.sys.mjs" + ); + + // When the first extension is started, ProxyMessenger.init adds MessageChannel + // listeners for Services.mm and Services.ppmm, and they are never unsubscribed. + // We have to exclude them after the extension has been unloaded to get an accurate + // test. + function getMessageManagersSize(messageManagers) { + return Array.from(messageManagers).filter(([mm]) => { + return ![Services.mm, Services.ppmm].includes(mm); + }).length; + } + + let messageManagersSize = getMessageManagersSize( + MessageChannel.messageManagers + ); + let responseManagersSize = MessageChannel.responseManagers.size; + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/", + true + ); + + async function background() { + let tasks = [ + { + background: "rgba(0, 0, 0, 0)", + foreground: "rgb(0, 113, 4)", + promise: () => { + return browser.tabs.insertCSS({ + file: "file2.css", + }); + }, + }, + { + background: "rgb(42, 42, 42)", + foreground: "rgb(0, 113, 4)", + promise: () => { + return browser.tabs.insertCSS({ + code: "* { background: rgb(42, 42, 42) }", + }); + }, + }, + { + background: "rgb(43, 43, 43)", + foreground: "rgb(0, 113, 4)", + promise: () => { + return browser.tabs + .insertCSS({ + code: "* { background: rgb(100, 100, 100) !important }", + cssOrigin: "author", + }) + .then(r => + browser.tabs.insertCSS({ + code: "* { background: rgb(43, 43, 43) !important }", + cssOrigin: "author", + }) + ); + }, + }, + { + background: "rgb(100, 100, 100)", + foreground: "rgb(0, 113, 4)", + promise: () => { + // User has higher importance + return browser.tabs + .insertCSS({ + code: "* { background: rgb(100, 100, 100) !important }", + cssOrigin: "user", + }) + .then(r => + browser.tabs.insertCSS({ + code: "* { background: rgb(44, 44, 44) !important }", + cssOrigin: "author", + }) + ); + }, + }, + ]; + + function checkCSS() { + let computedStyle = window.getComputedStyle(document.body); + return [computedStyle.backgroundColor, computedStyle.color]; + } + + try { + for (let { promise, background, foreground } of tasks) { + let result = await promise(); + + browser.test.assertEq(undefined, result, "Expected callback result"); + + [result] = await browser.tabs.executeScript({ + code: `(${checkCSS})()`, + }); + + browser.test.assertEq( + background, + result[0], + "Expected background color" + ); + browser.test.assertEq( + foreground, + result[1], + "Expected foreground color" + ); + } + + browser.test.notifyPass("insertCSS"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("insertCSS"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://mochi.test/"], + }, + + background, + + files: { + "file2.css": "* { color: rgb(0, 113, 4) }", + }, + }); + + await extension.startup(); + + await extension.awaitFinish("insertCSS"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); + + // Make sure that we're not holding on to references to closed message + // managers. + is( + getMessageManagersSize(MessageChannel.messageManagers), + messageManagersSize, + "Message manager count" + ); + is( + MessageChannel.responseManagers.size, + responseManagersSize, + "Response manager count" + ); + is(MessageChannel.pendingResponses.size, 0, "Pending response count"); +}); + +add_task(async function testInsertCSS_cleanup() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/", + true + ); + + async function background() { + await browser.tabs.insertCSS({ code: "* { background: rgb(42, 42, 42) }" }); + await browser.tabs.insertCSS({ file: "customize_fg_color.css" }); + + browser.test.notifyPass("insertCSS"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://mochi.test/"], + }, + background, + files: { + "customize_fg_color.css": `* { color: rgb(255, 0, 0) }`, + }, + }); + + await extension.startup(); + + await extension.awaitFinish("insertCSS"); + + const getTabContentComputedStyle = async () => { + let computedStyle = content.getComputedStyle(content.document.body); + return [computedStyle.backgroundColor, computedStyle.color]; + }; + + const appliedStyles = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + getTabContentComputedStyle + ); + + is( + appliedStyles[0], + "rgb(42, 42, 42)", + "The injected CSS code has been applied as expected" + ); + is( + appliedStyles[1], + "rgb(255, 0, 0)", + "The injected CSS file has been applied as expected" + ); + + await extension.unload(); + + const unloadedStyles = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + getTabContentComputedStyle + ); + + is( + unloadedStyles[0], + "rgba(0, 0, 0, 0)", + "The injected CSS code has been removed as expected" + ); + is( + unloadedStyles[1], + "rgb(0, 0, 0)", + "The injected CSS file has been removed as expected" + ); + + BrowserTestUtils.removeTab(tab); +}); + +// Verify that no removeSheet/removeSheetUsingURIString errors are logged while +// cleaning up css injected using a manifest content script or tabs.insertCSS. +add_task(async function test_csscode_cleanup_on_closed_windows() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/*"], + content_scripts: [ + { + matches: ["http://example.com/*"], + css: ["content.css"], + run_at: "document_start", + }, + ], + }, + + files: { + "content.css": "body { min-width: 15px; }", + }, + + async background() { + browser.runtime.onConnect.addListener(port => { + port.onDisconnect.addListener(() => { + browser.test.sendMessage("port-disconnected"); + }); + browser.test.sendMessage("port-connected"); + }); + + await browser.tabs.create({ + url: "http://example.com/", + active: true, + }); + + await browser.tabs.insertCSS({ + code: "body { max-width: 50px; }", + }); + + // Create a port, as a way to detect when the content script has been + // destroyed and any removeSheet error already collected (if it has been + // raised during the content scripts cleanup). + await browser.tabs.executeScript({ + code: `(${function () { + const { maxWidth, minWidth } = window.getComputedStyle(document.body); + browser.test.sendMessage("body-styles", { maxWidth, minWidth }); + browser.runtime.connect(); + }})();`, + }); + }, + }); + + await extension.startup(); + + let { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + info("Waiting for content scripts to be injected"); + + const { maxWidth, minWidth } = await extension.awaitMessage("body-styles"); + is(maxWidth, "50px", "tabs.insertCSS applied"); + is(minWidth, "15px", "manifest.content_scripts CSS applied"); + + await extension.awaitMessage("port-connected"); + const tab = gBrowser.selectedTab; + + info("Close tab and wait for content script port to be disconnected"); + BrowserTestUtils.removeTab(tab); + await extension.awaitMessage("port-disconnected"); + }); + + // Look for nsIDOMWindowUtils.removeSheet and + // nsIDOMWindowUtils.removeSheetUsingURIString errors. + AddonTestUtils.checkMessages( + messages, + { + forbidden: [{ errorMessage: /nsIDOMWindowUtils.removeSheet/ }], + }, + "Expect no remoteSheet errors" + ); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_lastAccessed.js b/browser/components/extensions/test/browser/browser_ext_tabs_lastAccessed.js new file mode 100644 index 0000000000..c4738d7f2e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_lastAccessed.js @@ -0,0 +1,52 @@ +"use strict"; + +add_task(async function testLastAccessed() { + let past = Date.now(); + + for (let url of ["https://example.com/?1", "https://example.com/?2"]) { + let tab = BrowserTestUtils.addTab(gBrowser, url, { skipAnimation: true }); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, url); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + async background() { + browser.test.onMessage.addListener(async function (msg, past) { + let [tab1] = await browser.tabs.query({ + url: "https://example.com/?1", + }); + let [tab2] = await browser.tabs.query({ + url: "https://example.com/?2", + }); + + browser.test.assertTrue(tab1 && tab2, "Expected tabs were found"); + + let now = Date.now(); + + browser.test.assertTrue( + past <= tab1.lastAccessed, + "lastAccessed of tab 1 is later than the test start time." + ); + browser.test.assertTrue( + tab1.lastAccessed < tab2.lastAccessed, + "lastAccessed of tab 2 is later than lastAccessed of tab 1." + ); + browser.test.assertTrue( + tab2.lastAccessed <= now, + "lastAccessed of tab 2 is earlier than now." + ); + + await browser.tabs.remove([tab1.id, tab2.id]); + + browser.test.notifyPass("tabs.lastAccessed"); + }); + }, + }); + + await extension.startup(); + await extension.sendMessage("past", past); + await extension.awaitFinish("tabs.lastAccessed"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_lazy.js b/browser/components/extensions/test/browser/browser_ext_tabs_lazy.js new file mode 100644 index 0000000000..68205089d5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_lazy.js @@ -0,0 +1,49 @@ +"use strict"; + +const triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL; + +const SESSION = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + { + entries: [ + { url: "https://example.com/", triggeringPrincipal_base64 }, + ], + }, + ], + }, + ], +}; + +add_task(async function () { + SessionStore.setBrowserState(JSON.stringify(SESSION)); + await promiseWindowRestored(window); + const tab = gBrowser.tabs[1]; + + is(tab.getAttribute("pending"), "true", "The tab is pending restore"); + is(tab.linkedBrowser.isConnected, false, "The tab is lazy"); + + async function background() { + const [tab] = await browser.tabs.query({ url: "https://example.com/" }); + browser.test.assertRejects( + browser.tabs.sendMessage(tab.id, "void"), + /Could not establish connection. Receiving end does not exist/, + "No recievers in a tab pending restore." + ); + browser.test.notifyPass("lazy"); + } + + const manifest = { permissions: ["tabs"] }; + const extension = ExtensionTestUtils.loadExtension({ manifest, background }); + + await extension.startup(); + await extension.awaitFinish("lazy"); + await extension.unload(); + + is(tab.getAttribute("pending"), "true", "The tab is still pending restore"); + is(tab.linkedBrowser.isConnected, false, "The tab is still lazy"); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_move_array.js b/browser/components/extensions/test/browser/browser_ext_tabs_move_array.js new file mode 100644 index 0000000000..72b4adbc31 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_move_array.js @@ -0,0 +1,95 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function moveMultiple() { + let tabs = []; + for (let k of [1, 2, 3, 4]) { + let tab = await BrowserTestUtils.openNewForegroundTab( + window.gBrowser, + `https://example.com/?${k}` + ); + tabs.push(tab); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["tabs"] }, + background: async function () { + function num(url) { + return parseInt(url.slice(-1), 10); + } + + async function check(expected) { + let tabs = await browser.tabs.query({ url: "https://example.com/*" }); + let endings = tabs.map(tab => num(tab.url)); + browser.test.assertTrue( + expected.every((v, i) => v === endings[i]), + `Tab order should be ${expected}, got ${endings}.` + ); + } + + async function reset() { + let tabs = await browser.tabs.query({ url: "https://example.com/*" }); + await browser.tabs.move( + tabs.sort((a, b) => num(a.url) - num(b.url)).map(tab => tab.id), + { index: 0 } + ); + } + + async function move(moveIndexes, moveTo) { + let tabs = await browser.tabs.query({ url: "https://example.com/*" }); + await browser.tabs.move( + moveIndexes.map(e => tabs[e - 1].id), + { + index: moveTo, + } + ); + } + + let tests = [ + { move: [2], index: 0, result: [2, 1, 3, 4] }, + { move: [2], index: -1, result: [1, 3, 4, 2] }, + // Start -> After first tab -> After second tab + { move: [4, 3], index: 0, result: [4, 3, 1, 2] }, + // [1, 2, 3, 4] -> [1, 4, 2, 3] -> [1, 4, 3, 2] + { move: [4, 3], index: 1, result: [1, 4, 3, 2] }, + // [1, 2, 3, 4] -> [2, 3, 1, 4] -> [3, 1, 2, 4] + { move: [1, 2], index: 2, result: [3, 1, 2, 4] }, + // [1, 2, 3, 4] -> [1, 2, 4, 3] -> [2, 4, 1, 3] + { move: [4, 1], index: 2, result: [2, 4, 1, 3] }, + // [1, 2, 3, 4] -> [2, 3, 1, 4] -> [2, 3, 1, 4] + { move: [1, 4], index: 2, result: [2, 3, 1, 4] }, + ]; + + for (let test of tests) { + await reset(); + await move(test.move, test.index); + await check(test.result); + } + + let firstId = ( + await browser.tabs.query({ + url: "https://example.com/*", + }) + )[0].id; + // Assuming that tab.id of 12345 does not exist. + await browser.test.assertRejects( + browser.tabs.move([firstId, 12345], { index: -1 }), + /Invalid tab/, + "Should receive invalid tab error" + ); + // The first argument got moved, the second on failed. + await check([2, 3, 1, 4]); + + browser.test.notifyPass("tabs.move"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.move"); + await extension.unload(); + + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_move_array_multiple_windows.js b/browser/components/extensions/test/browser/browser_ext_tabs_move_array_multiple_windows.js new file mode 100644 index 0000000000..484197cbc5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_move_array_multiple_windows.js @@ -0,0 +1,160 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function moveMultipleWindows() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["tabs"] }, + background: async function () { + let numToId = new Map(); + let idToNum = new Map(); + let windowToInitialTabs = new Map(); + + async function createWindow(nums) { + let window = await browser.windows.create({ + url: nums.map(k => `https://example.com/?${k}`), + }); + let tabIds = window.tabs.map(tab => tab.id); + windowToInitialTabs.set(window.id, tabIds); + for (let i = 0; i < nums.length; ++i) { + numToId.set(nums[i], tabIds[i]); + idToNum.set(tabIds[i], nums[i]); + } + return window.id; + } + + let win1 = await createWindow([0, 1, 2, 3, 4]); + let win2 = await createWindow([5, 6, 7, 8, 9]); + + async function getNums(windowId) { + let tabs = await browser.tabs.query({ windowId }); + return tabs.map(tab => idToNum.get(tab.id)); + } + + async function check(msg, expected) { + let nums1 = getNums(win1); + let nums2 = getNums(win2); + browser.test.assertEq( + JSON.stringify(expected), + JSON.stringify({ win1: await nums1, win2: await nums2 }), + `Check ${msg}` + ); + } + + async function reset() { + for (let [windowId, tabIds] of windowToInitialTabs) { + await browser.tabs.move(tabIds, { index: 0, windowId }); + } + } + + async function move(nums, params) { + await browser.tabs.move( + nums.map(k => numToId.get(k)), + params + ); + } + + let tests = [ + { + move: [1, 6], + params: { index: 0 }, + result: { win1: [1, 0, 2, 3, 4], win2: [6, 5, 7, 8, 9] }, + }, + { + move: [6, 1], + params: { index: 0 }, + result: { win1: [1, 0, 2, 3, 4], win2: [6, 5, 7, 8, 9] }, + }, + { + move: [1, 6], + params: { index: 0, windowId: win2 }, + result: { win1: [0, 2, 3, 4], win2: [1, 6, 5, 7, 8, 9] }, + }, + { + move: [6, 1], + params: { index: 0, windowId: win2 }, + result: { win1: [0, 2, 3, 4], win2: [6, 1, 5, 7, 8, 9] }, + }, + { + move: [1, 6], + params: { index: -1 }, + result: { win1: [0, 2, 3, 4, 1], win2: [5, 7, 8, 9, 6] }, + }, + { + move: [6, 1], + params: { index: -1 }, + result: { win1: [0, 2, 3, 4, 1], win2: [5, 7, 8, 9, 6] }, + }, + { + move: [1, 6], + params: { index: -1, windowId: win2 }, + result: { win1: [0, 2, 3, 4], win2: [5, 7, 8, 9, 1, 6] }, + }, + { + move: [6, 1], + params: { index: -1, windowId: win2 }, + result: { win1: [0, 2, 3, 4], win2: [5, 7, 8, 9, 6, 1] }, + }, + { + move: [2, 1, 7, 6], + params: { index: 3 }, + result: { win1: [0, 3, 2, 1, 4], win2: [5, 8, 7, 6, 9] }, + }, + { + move: [1, 2, 3, 4], + params: { index: 0, windowId: win2 }, + result: { win1: [0], win2: [1, 2, 3, 4, 5, 6, 7, 8, 9] }, + }, + { + move: [0, 1, 2, 3], + params: { index: 5, windowId: win2 }, + result: { win1: [4], win2: [5, 6, 7, 8, 9, 0, 1, 2, 3] }, + }, + { + move: [1, 2, 3, 4, 5, 6, 7, 8, 9], + params: { index: 0, windowId: win2 }, + result: { win1: [0], win2: [1, 2, 3, 4, 5, 6, 7, 8, 9] }, + }, + { + move: [5, 6, 7, 8, 9, 0, 1, 2, 3], + params: { index: 0, windowId: win2 }, + result: { win1: [4], win2: [5, 6, 7, 8, 9, 0, 1, 2, 3] }, + }, + { + move: [5, 1, 6, 2, 7, 3, 8, 4, 9], + params: { index: 0, windowId: win2 }, + result: { win1: [0], win2: [5, 1, 6, 2, 7, 3, 8, 4, 9] }, + }, + { + move: [5, 1, 6, 2, 7, 3, 8, 4, 9], + params: { index: 1, windowId: win2 }, + result: { win1: [0], win2: [5, 1, 6, 2, 7, 3, 8, 4, 9] }, + }, + { + move: [5, 1, 6, 2, 7, 3, 8, 4, 9], + params: { index: 999, windowId: win2 }, + result: { win1: [0], win2: [5, 1, 6, 2, 7, 3, 8, 4, 9] }, + }, + ]; + + const initial = { win1: [0, 1, 2, 3, 4], win2: [5, 6, 7, 8, 9] }; + await check("initial", initial); + for (let test of tests) { + browser.test.log(JSON.stringify(test)); + await move(test.move, test.params); + await check("move", test.result); + await reset(); + await check("reset", initial); + } + + await browser.windows.remove(win1); + await browser.windows.remove(win2); + browser.test.notifyPass("tabs.move"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.move"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_move_discarded.js b/browser/components/extensions/test/browser/browser_ext_tabs_move_discarded.js new file mode 100644 index 0000000000..ebee4fbc90 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_move_discarded.js @@ -0,0 +1,94 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function move_discarded_to_window() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["tabs"] }, + background: async function () { + // Create a discarded tab + let url = "https://example.com/"; + let tab = await browser.tabs.create({ url, discarded: true }); + browser.test.assertEq(true, tab.discarded, "Tab should be discarded"); + browser.test.assertEq(url, tab.url, "Tab URL should be correct"); + + // Create a new window + let { id: windowId } = await browser.windows.create(); + + // Move the tab into that window + [tab] = await browser.tabs.move(tab.id, { windowId, index: -1 }); + browser.test.assertTrue(tab.discarded, "Tab should still be discarded"); + browser.test.assertEq(url, tab.url, "Tab URL should still be correct"); + + await browser.windows.remove(windowId); + browser.test.notifyPass("tabs.move"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.move"); + await extension.unload(); +}); + +add_task(async function move_hidden_discarded_to_window() { + let extensionWithoutTabsPermission = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["https://example.com/"], + }, + background() { + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.hidden) { + browser.test.assertEq( + tab.url, + "https://example.com/?hideme", + "tab.url is correctly observed without tabs permission" + ); + browser.test.sendMessage("onUpdated_checked"); + } + }); + // Listener with "urls" filter, regression test for + // https://bugzilla.mozilla.org/show_bug.cgi?id=1695346 + browser.tabs.onUpdated.addListener( + (tabId, changeInfo, tab) => { + browser.test.assertTrue(changeInfo.hidden, "tab was hidden"); + browser.test.sendMessage("onUpdated_urls_filter"); + }, + { + properties: ["hidden"], + urls: ["https://example.com/?hideme"], + } + ); + }, + }); + await extensionWithoutTabsPermission.startup(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["tabs", "tabHide"] }, + // ExtensionControlledPopup's populateDescription method requires an addon: + useAddonManager: "temporary", + async background() { + let url = "https://example.com/?hideme"; + let tab = await browser.tabs.create({ url, discarded: true }); + await browser.tabs.hide(tab.id); + + let { id: windowId } = await browser.windows.create(); + + // Move the tab into that window + [tab] = await browser.tabs.move(tab.id, { windowId, index: -1 }); + browser.test.assertTrue(tab.discarded, "Tab should still be discarded"); + browser.test.assertTrue(tab.hidden, "Tab should still be hidden"); + browser.test.assertEq(url, tab.url, "Tab URL should still be correct"); + + await browser.windows.remove(windowId); + browser.test.notifyPass("move_hidden_discarded_to_window"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("move_hidden_discarded_to_window"); + await extension.unload(); + + await extensionWithoutTabsPermission.awaitMessage("onUpdated_checked"); + await extensionWithoutTabsPermission.awaitMessage("onUpdated_urls_filter"); + await extensionWithoutTabsPermission.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_move_window.js b/browser/components/extensions/test/browser/browser_ext_tabs_move_window.js new file mode 100644 index 0000000000..56d82b3d3a --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_move_window.js @@ -0,0 +1,178 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + incognitoOverride: "spanning", + async background() { + const URL = "https://example.com/"; + let mainWindow = await browser.windows.getCurrent(); + let newWindow = await browser.windows.create({ + url: [URL, URL], + }); + let privateWindow = await browser.windows.create({ + incognito: true, + url: [URL, URL], + }); + + browser.tabs.onUpdated.addListener(() => { + // Bug 1398272: Adding onUpdated listener broke tab IDs across windows. + }); + + let tab = newWindow.tabs[0].id; + let privateTab = privateWindow.tabs[0].id; + + // Assuming that this windowId does not exist. + await browser.test.assertRejects( + browser.tabs.move(tab, { windowId: 123144576, index: 0 }), + /Invalid window/, + "Should receive invalid window error" + ); + + // Test that a tab cannot be moved to a private window. + let moved = await browser.tabs.move(tab, { + windowId: privateWindow.id, + index: 0, + }); + browser.test.assertEq( + moved.length, + 0, + "tab was not moved to private window" + ); + // Test that a private tab cannot be moved to a non-private window. + moved = await browser.tabs.move(privateTab, { + windowId: newWindow.id, + index: 0, + }); + browser.test.assertEq( + moved.length, + 0, + "tab was not moved from private window" + ); + + // Verify tabs did not move between windows via another query. + let windows = await browser.windows.getAll({ populate: true }); + let newWin2 = windows.find(w => w.id === newWindow.id); + browser.test.assertTrue(newWin2, "Found window"); + browser.test.assertEq( + newWin2.tabs.length, + 2, + "Window still has two tabs" + ); + for (let origTab of newWindow.tabs) { + browser.test.assertTrue( + newWin2.tabs.find(t => t.id === origTab.id), + `Window still has tab ${origTab.id}` + ); + } + + let privateWin2 = windows.find(w => w.id === privateWindow.id); + browser.test.assertTrue(privateWin2 !== null, "Found private window"); + browser.test.assertEq( + privateWin2.incognito, + true, + "Private window is still private" + ); + browser.test.assertEq( + privateWin2.tabs.length, + 2, + "Private window still has two tabs" + ); + for (let origTab of privateWindow.tabs) { + browser.test.assertTrue( + privateWin2.tabs.find(t => t.id === origTab.id), + `Private window still has tab ${origTab.id}` + ); + } + + // Move a tab from one non-private window to another + await browser.tabs.move(tab, { windowId: mainWindow.id, index: 0 }); + + mainWindow = await browser.windows.get(mainWindow.id, { populate: true }); + browser.test.assertTrue( + mainWindow.tabs.find(t => t.id === tab), + "Moved tab is in main window" + ); + + newWindow = await browser.windows.get(newWindow.id, { populate: true }); + browser.test.assertEq( + newWindow.tabs.length, + 1, + "New window has 1 tab left" + ); + browser.test.assertTrue( + newWindow.tabs[0].id != tab, + "Moved tab is no longer in original window" + ); + + await browser.windows.remove(newWindow.id); + await browser.windows.remove(privateWindow.id); + await browser.tabs.remove(tab); + + browser.test.notifyPass("tabs.move.window"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.move.window"); + await extension.unload(); +}); + +add_task(async function test_currentWindowAfterTabMoved() { + const files = { + "current.html": "", + "current.js": function () { + browser.test.onMessage.addListener(msg => { + if (msg === "current") { + browser.windows.getCurrent(win => { + browser.test.sendMessage("id", win.id); + }); + } + }); + browser.test.sendMessage("ready"); + }, + }; + + async function background() { + let tabId; + + const url = browser.runtime.getURL("current.html"); + + browser.test.onMessage.addListener(async msg => { + if (msg === "move") { + await browser.windows.create({ tabId }); + browser.test.sendMessage("moved"); + } else if (msg === "close") { + await browser.tabs.remove(tabId); + browser.test.sendMessage("done"); + } + }); + + let tab = await browser.tabs.create({ url }); + tabId = tab.id; + } + + const extension = ExtensionTestUtils.loadExtension({ files, background }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage("current"); + const first = await extension.awaitMessage("id"); + + extension.sendMessage("move"); + await extension.awaitMessage("moved"); + + extension.sendMessage("current"); + const second = await extension.awaitMessage("id"); + + isnot(first, second, "current window id is different after moving the tab"); + + extension.sendMessage("close"); + await extension.awaitMessage("done"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_move_window_multiple.js b/browser/components/extensions/test/browser/browser_ext_tabs_move_window_multiple.js new file mode 100644 index 0000000000..559cd7dd46 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_move_window_multiple.js @@ -0,0 +1,64 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + async background() { + const URL = "https://example.com/"; + let mainWin = await browser.windows.getCurrent(); + let tab1 = await browser.tabs.create({ url: URL }); + let tab2 = await browser.tabs.create({ url: URL }); + + let newWin = await browser.windows.create({ url: [URL, URL] }); + browser.test.assertEq(newWin.tabs.length, 2, "New window has 2 tabs"); + let [tab3, tab4] = newWin.tabs; + + // move tabs in both windows to index 0 in a single call + await browser.tabs.move([tab2.id, tab4.id], { index: 0 }); + + tab1 = await browser.tabs.get(tab1.id); + browser.test.assertEq( + tab1.windowId, + mainWin.id, + "tab 1 is still in main window" + ); + + tab2 = await browser.tabs.get(tab2.id); + browser.test.assertEq( + tab2.windowId, + mainWin.id, + "tab 2 is still in main window" + ); + browser.test.assertEq(tab2.index, 0, "tab 2 moved to index 0"); + + tab3 = await browser.tabs.get(tab3.id); + browser.test.assertEq( + tab3.windowId, + newWin.id, + "tab 3 is still in new window" + ); + + tab4 = await browser.tabs.get(tab4.id); + browser.test.assertEq( + tab4.windowId, + newWin.id, + "tab 4 is still in new window" + ); + browser.test.assertEq(tab4.index, 0, "tab 4 moved to index 0"); + + await browser.tabs.remove([tab1.id, tab2.id]); + await browser.windows.remove(newWin.id); + + browser.test.notifyPass("tabs.move.multiple"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.move.multiple"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_move_window_pinned.js b/browser/components/extensions/test/browser/browser_ext_tabs_move_window_pinned.js new file mode 100644 index 0000000000..3898540fa0 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_move_window_pinned.js @@ -0,0 +1,44 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + async background() { + const URL = "https://example.com/"; + + let mainWin = await browser.windows.getCurrent(); + let tab = await browser.tabs.create({ url: URL }); + + let newWin = await browser.windows.create({ url: URL }); + let tab2 = newWin.tabs[0]; + await browser.tabs.update(tab2.id, { pinned: true }); + + // Try to move a tab before the pinned tab. The move should be ignored. + let moved = await browser.tabs.move(tab.id, { + windowId: newWin.id, + index: 0, + }); + browser.test.assertEq(moved.length, 0, "move() returned no moved tab"); + + tab = await browser.tabs.get(tab.id); + browser.test.assertEq( + tab.windowId, + mainWin.id, + "Tab stayed in its original window" + ); + + await browser.tabs.remove(tab.id); + await browser.windows.remove(newWin.id); + browser.test.notifyPass("tabs.move.pin"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.move.pin"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_newtab_private.js b/browser/components/extensions/test/browser/browser_ext_tabs_newtab_private.js new file mode 100644 index 0000000000..19146fbe42 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_newtab_private.js @@ -0,0 +1,96 @@ +"use strict"; + +const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +const NEWTAB_PRIVATE_ALLOWED = "browser.newtab.privateAllowed"; +const NEWTAB_EXTENSION_CONTROLLED = "browser.newtab.extensionControlled"; +const NEWTAB_URI = "webext-newtab-1.html"; + +function promisePrefChange(pref) { + return new Promise((resolve, reject) => { + Services.prefs.addObserver(pref, function observer() { + Services.prefs.removeObserver(pref, observer); + resolve(arguments); + }); + }); +} + +function verifyPrefSettings(controlled, allowed) { + is( + Services.prefs.getBoolPref(NEWTAB_EXTENSION_CONTROLLED, false), + controlled, + "newtab extension controlled" + ); + is( + Services.prefs.getBoolPref(NEWTAB_PRIVATE_ALLOWED, false), + allowed, + "newtab private permission after permission change" + ); + + if (controlled) { + ok( + AboutNewTab.newTabURL.endsWith(NEWTAB_URI), + "Newtab url is overridden by the extension." + ); + } + if (controlled && allowed) { + ok( + BROWSER_NEW_TAB_URL.endsWith(NEWTAB_URI), + "active newtab url is overridden by the extension." + ); + } else { + let expectednewTab = controlled ? "about:privatebrowsing" : "about:newtab"; + is(BROWSER_NEW_TAB_URL, expectednewTab, "active newtab url is default."); + } +} + +async function promiseUpdatePrivatePermission(allowed, extension) { + info(`update private allowed permission`); + let ext = WebExtensionPolicy.getByID(extension.id).extension; + await Promise.all([ + promisePrefChange(NEWTAB_PRIVATE_ALLOWED), + ExtensionPermissions[allowed ? "add" : "remove"]( + extension.id, + { permissions: ["internal:privateBrowsingAllowed"], origins: [] }, + ext + ), + ]); + + verifyPrefSettings(true, allowed); +} + +add_task(async function test_new_tab_private() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "@private-newtab", + }, + }, + chrome_url_overrides: { + newtab: NEWTAB_URI, + }, + }, + files: { + NEWTAB_URI: ` + + + + + + + + `, + }, + useAddonManager: "permanent", + }); + await extension.startup(); + + verifyPrefSettings(true, false); + + await promiseUpdatePrivatePermission(true, extension); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_onCreated.js b/browser/components/extensions/test/browser/browser_ext_tabs_onCreated.js new file mode 100644 index 0000000000..b48047abde --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_onCreated.js @@ -0,0 +1,35 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function test_onCreated_active() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + async background() { + browser.tabs.onCreated.addListener(tab => { + browser.tabs.remove(tab.id); + browser.test.sendMessage("onCreated", tab); + }); + browser.tabs.onUpdated.addListener((tabId, changes, tab) => { + browser.test.assertEq( + '["status"]', + JSON.stringify(Object.keys(changes)), + "Should get no update other than 'status' during tab creation." + ); + }); + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + BrowserOpenTab(); + + let tab = await extension.awaitMessage("onCreated"); + is(true, tab.active, "Tab should be active"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_onHighlighted.js b/browser/components/extensions/test/browser/browser_ext_tabs_onHighlighted.js new file mode 100644 index 0000000000..a614dc6144 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_onHighlighted.js @@ -0,0 +1,130 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function test_onHighlighted() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + async function expectHighlighted(fn, action) { + let resolve; + let promise = new Promise(r => { + resolve = r; + }); + let expected; + let events = []; + let listener = highlightInfo => { + events.push(highlightInfo); + if (expected && expected.length >= events.length) { + resolve(); + } + }; + browser.tabs.onHighlighted.addListener(listener); + expected = (await fn()) || []; + if (events.length < expected.length) { + await promise; + } + let unexpected = events.splice(expected.length); + browser.test.assertEq( + JSON.stringify(expected), + JSON.stringify(events), + `Should get ${expected.length} expected onHighlighted events when ${action}` + ); + if (unexpected.length) { + browser.test.fail( + `${unexpected.length} unexpected onHighlighted events when ${action}: ` + + JSON.stringify(unexpected) + ); + } + browser.tabs.onHighlighted.removeListener(listener); + } + + let [{ id, windowId }] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + let windows = [windowId]; + let tabs = [id]; + + await expectHighlighted(async () => { + let tab = await browser.tabs.create({ + active: true, + url: "about:blank?1", + }); + tabs.push(tab.id); + return [{ tabIds: [tabs[1]], windowId: windows[0] }]; + }, "creating a new active tab"); + + await expectHighlighted(async () => { + await browser.tabs.update(tabs[0], { active: true }); + return [{ tabIds: [tabs[0]], windowId: windows[0] }]; + }, "selecting former tab"); + + await expectHighlighted(async () => { + await browser.tabs.highlight({ tabs: [0, 1] }); + return [{ tabIds: [tabs[0], tabs[1]], windowId: windows[0] }]; + }, "highlighting both tabs"); + + await expectHighlighted(async () => { + await browser.tabs.highlight({ tabs: [1, 0] }); + return [{ tabIds: [tabs[0], tabs[1]], windowId: windows[0] }]; + }, "highlighting same tabs but changing selected one"); + + await expectHighlighted(async () => { + let tab = await browser.tabs.create({ + active: false, + url: "about:blank?2", + }); + tabs.push(tab.id); + }, "create a new inactive tab"); + + await expectHighlighted(async () => { + await browser.tabs.highlight({ tabs: [2, 0, 1] }); + return [{ tabIds: [tabs[0], tabs[1], tabs[2]], windowId: windows[0] }]; + }, "highlighting all tabs"); + + await expectHighlighted(async () => { + await browser.tabs.move(tabs[1], { index: 0 }); + }, "reordering tabs"); + + await expectHighlighted(async () => { + await browser.tabs.highlight({ tabs: [0] }); + return [{ tabIds: [tabs[1]], windowId: windows[0] }]; + }, "highlighting moved tab"); + + await expectHighlighted(async () => { + await browser.tabs.highlight({ tabs: [0] }); + }, "highlighting again"); + + await expectHighlighted(async () => { + await browser.tabs.highlight({ tabs: [2, 1, 0] }); + return [{ tabIds: [tabs[1], tabs[0], tabs[2]], windowId: windows[0] }]; + }, "highlighting all tabs"); + + await expectHighlighted(async () => { + await browser.tabs.highlight({ tabs: [2, 0, 1] }); + }, "highlighting same tabs with different order"); + + await expectHighlighted(async () => { + let window = await browser.windows.create({ tabId: tabs[2] }); + windows.push(window.id); + // Bug 1481185: on Chrome it's [tabs[1], tabs[0]] instead of [tabs[0]] + return [ + { tabIds: [tabs[0]], windowId: windows[0] }, + { tabIds: [tabs[2]], windowId: windows[1] }, + ]; + }, "moving selected tab into a new window"); + + await browser.tabs.remove(tabs.slice(1)); + browser.test.notifyPass("test-finished"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("test-finished"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_onUpdated.js b/browser/components/extensions/test/browser/browser_ext_tabs_onUpdated.js new file mode 100644 index 0000000000..a59fa21f8a --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_onUpdated.js @@ -0,0 +1,339 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +requestLongerTimeout(2); + +add_task(async function () { + let win1 = await BrowserTestUtils.openNewBrowserWindow(); + + await focusWindow(win1); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + content_scripts: [ + { + matches: ["http://mochi.test/*/context_tabs_onUpdated_page.html"], + js: ["content-script.js"], + run_at: "document_start", + }, + ], + }, + + background: function () { + let pageURL = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context_tabs_onUpdated_page.html"; + + let expectedSequence = [ + { status: "loading" }, + { status: "loading", url: pageURL }, + { status: "complete" }, + ]; + let collectedSequence = []; + + browser.tabs.onUpdated.addListener(function (tabId, updatedInfo) { + // onUpdated also fires with updatedInfo.faviconUrl, so explicitly + // check for updatedInfo.status before recording the event. + if ("status" in updatedInfo) { + collectedSequence.push(updatedInfo); + } + }); + + browser.runtime.onMessage.addListener(function () { + if (collectedSequence.length !== expectedSequence.length) { + browser.test.assertEq( + JSON.stringify(expectedSequence), + JSON.stringify(collectedSequence), + "got unexpected number of updateInfo data" + ); + } else { + for (let i = 0; i < expectedSequence.length; i++) { + browser.test.assertEq( + expectedSequence[i].status, + collectedSequence[i].status, + "check updatedInfo status" + ); + if (expectedSequence[i].url || collectedSequence[i].url) { + browser.test.assertEq( + expectedSequence[i].url, + collectedSequence[i].url, + "check updatedInfo url" + ); + } + } + } + + browser.test.notifyPass("tabs.onUpdated"); + }); + + browser.tabs.create({ url: pageURL }); + }, + files: { + "content-script.js": ` + window.addEventListener("message", function(evt) { + if (evt.data == "frame-updated") { + browser.runtime.sendMessage("load-completed"); + } + }, true); + `, + }, + }); + + await Promise.all([ + extension.startup(), + extension.awaitFinish("tabs.onUpdated"), + ]); + + await extension.unload(); + + await BrowserTestUtils.closeWindow(win1); +}); + +async function do_test_update(background, withPermissions = true) { + let win1 = await BrowserTestUtils.openNewBrowserWindow(); + + await focusWindow(win1); + + let manifest = {}; + if (withPermissions) { + manifest.permissions = ["tabs", "http://mochi.test/"]; + } + let extension = ExtensionTestUtils.loadExtension({ manifest, background }); + + await extension.startup(); + await extension.awaitFinish("finish"); + + await extension.unload(); + + await BrowserTestUtils.closeWindow(win1); +} + +add_task(async function test_pinned() { + await do_test_update(function background() { + // Create a new tab for testing update. + browser.tabs.create({}, function (tab) { + browser.tabs.onUpdated.addListener(function onUpdated(tabId, changeInfo) { + // Check callback + browser.test.assertEq(tabId, tab.id, "Check tab id"); + browser.test.log("onUpdate: " + JSON.stringify(changeInfo)); + if ("pinned" in changeInfo) { + browser.test.assertTrue(changeInfo.pinned, "Check changeInfo.pinned"); + browser.tabs.onUpdated.removeListener(onUpdated); + // Remove created tab. + browser.tabs.remove(tabId); + browser.test.notifyPass("finish"); + } + }); + browser.tabs.update(tab.id, { pinned: true }); + }); + }); +}); + +add_task(async function test_unpinned() { + await do_test_update(function background() { + // Create a new tab for testing update. + browser.tabs.create({ pinned: true }, function (tab) { + browser.tabs.onUpdated.addListener(function onUpdated(tabId, changeInfo) { + // Check callback + browser.test.assertEq(tabId, tab.id, "Check tab id"); + browser.test.log("onUpdate: " + JSON.stringify(changeInfo)); + if ("pinned" in changeInfo) { + browser.test.assertFalse( + changeInfo.pinned, + "Check changeInfo.pinned" + ); + browser.tabs.onUpdated.removeListener(onUpdated); + // Remove created tab. + browser.tabs.remove(tabId); + browser.test.notifyPass("finish"); + } + }); + browser.tabs.update(tab.id, { pinned: false }); + }); + }); +}); + +add_task(async function test_url() { + await do_test_update(function background() { + // Create a new tab for testing update. + browser.tabs.create({ url: "about:blank?initial_url=1" }, function (tab) { + const expectedUpdatedURL = "about:blank?updated_url=1"; + + browser.tabs.onUpdated.addListener(function onUpdated(tabId, changeInfo) { + // Wait for the tabs.onUpdated events related to the updated url (because + // there is a good chance that we may still be receiving events related to + // the browser.tabs.create API call above before we are able to start + // loading the new url from the browser.tabs.update API call below). + if ("url" in changeInfo && changeInfo.url === expectedUpdatedURL) { + browser.test.assertEq( + expectedUpdatedURL, + changeInfo.url, + "Got tabs.onUpdated event for the expected url" + ); + browser.tabs.onUpdated.removeListener(onUpdated); + // Remove created tab. + browser.tabs.remove(tabId); + browser.test.notifyPass("finish"); + } + // Check callback + browser.test.assertEq(tabId, tab.id, "Check tab id"); + browser.test.log("onUpdate: " + JSON.stringify(changeInfo)); + }); + + browser.tabs.update(tab.id, { url: expectedUpdatedURL }); + }); + }); +}); + +add_task(async function test_title() { + await do_test_update(async function background() { + const url = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context_tabs_onUpdated_page.html"; + const tab = await browser.tabs.create({ url }); + + browser.tabs.onUpdated.addListener(function onUpdated(tabId, changeInfo) { + browser.test.assertEq(tabId, tab.id, "Check tab id"); + browser.test.log(`onUpdated: ${JSON.stringify(changeInfo)}`); + if ("title" in changeInfo && changeInfo.title === "New Message (1)") { + browser.test.log("changeInfo.title is correct"); + browser.tabs.onUpdated.removeListener(onUpdated); + browser.tabs.remove(tabId); + browser.test.notifyPass("finish"); + } + }); + + browser.tabs.executeScript(tab.id, { + code: "document.title = 'New Message (1)'", + }); + }); +}); + +add_task(async function test_without_tabs_permission() { + await do_test_update(async function background() { + const url = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context_tabs_onUpdated_page.html"; + let tab = null; + let count = 0; + + browser.tabs.onUpdated.addListener(function onUpdated(tabId, changeInfo) { + // An attention change can happen during tabs.create, so + // we can't compare against tab yet. + if (!("attention" in changeInfo)) { + browser.test.assertEq(tabId, tab.id, "Check tab id"); + } + browser.test.log(`onUpdated: ${JSON.stringify(changeInfo)}`); + + browser.test.assertFalse( + "url" in changeInfo, + "url should not be included without tabs permission" + ); + browser.test.assertFalse( + "favIconUrl" in changeInfo, + "favIconUrl should not be included without tabs permission" + ); + browser.test.assertFalse( + "title" in changeInfo, + "title should not be included without tabs permission" + ); + + if (changeInfo.status == "complete") { + count++; + if (count === 1) { + browser.tabs.reload(tabId); + } else { + browser.test.log("Reload complete"); + browser.tabs.onUpdated.removeListener(onUpdated); + browser.tabs.remove(tabId); + browser.test.notifyPass("finish"); + } + } + }); + + tab = await browser.tabs.create({ url }); + }, false /* withPermissions */); +}); + +add_task(async function test_onUpdated_after_onRemoved() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + async background() { + const url = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context_tabs_onUpdated_page.html"; + let removed = false; + let tab; + + // If remove happens fast and we never receive onUpdated, that is ok, but + // we never want to receive onUpdated after onRemoved. + browser.tabs.onUpdated.addListener(function onUpdated(tabId, changeInfo) { + if (!tab || tab.id !== tabId) { + return; + } + browser.test.assertFalse( + removed, + "tab has not been removed before onUpdated" + ); + }); + + browser.tabs.onRemoved.addListener((tabId, removedInfo) => { + if (!tab || tab.id !== tabId) { + return; + } + removed = true; + browser.test.notifyPass("onRemoved"); + }); + + tab = await browser.tabs.create({ url }); + browser.tabs.remove(tab.id); + }, + }); + await extension.startup(); + await extension.awaitFinish("onRemoved"); + await extension.unload(); +}); + +// Regression test for Bug 1852391. +add_task(async function test_pin_discarded_tab() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + async background() { + const url = "http://mochi.test:8888"; + const newTab = await browser.tabs.create({ + url, + active: false, + discarded: true, + }); + browser.tabs.onUpdated.addListener( + async (tabId, changeInfo) => { + browser.test.assertEq( + tabId, + newTab.id, + "Expect onUpdated to be fired for the expected tab" + ); + browser.test.assertEq( + changeInfo.pinned, + true, + "Expect pinned to be set to true" + ); + await browser.tabs.remove(newTab.id); + browser.test.notifyPass("onPinned"); + }, + { properties: ["pinned"] } + ); + await browser.tabs.update(newTab.id, { pinned: true }).catch(err => { + browser.test.fail(`Got unexpected rejection from tabs.update: ${err}`); + browser.test.notifyFail("onPinned"); + }); + }, + }); + + await extension.startup(); + await extension.awaitFinish("onPinned"); + await extension.unload(); +}); + +add_task(forceGC); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_onUpdated_filter.js b/browser/components/extensions/test/browser/browser_ext_tabs_onUpdated_filter.js new file mode 100644 index 0000000000..83d305e491 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_onUpdated_filter.js @@ -0,0 +1,354 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_filter_url() { + let ext_fail = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background() { + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + browser.test.fail( + `received unexpected onUpdated event ${JSON.stringify(changeInfo)}` + ); + }, + { urls: ["*://*.mozilla.org/*"] } + ); + }, + }); + await ext_fail.startup(); + + let ext_perm = ExtensionTestUtils.loadExtension({ + background() { + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + browser.test.fail( + `received unexpected onUpdated event without tabs permission` + ); + }, + { urls: ["*://mochi.test/*"] } + ); + }, + }); + await ext_perm.startup(); + + let ext_ok = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background() { + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + browser.test.log(`got onUpdated ${JSON.stringify(changeInfo)}`); + if (changeInfo.status === "complete") { + browser.test.notifyPass("onUpdated"); + } + }, + { urls: ["*://mochi.test/*"] } + ); + }, + }); + await ext_ok.startup(); + let ok1 = ext_ok.awaitFinish("onUpdated"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + await ok1; + + await ext_ok.unload(); + await ext_fail.unload(); + await ext_perm.unload(); + + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_filter_url_activeTab() { + let ext = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["activeTab"], + }, + background() { + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + browser.test.fail( + "should only have notification for activeTab, selectedTab is not activeTab" + ); + }, + { urls: ["*://mochi.test/*"] } + ); + }, + }); + await ext.startup(); + + let ext2 = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background() { + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + if (changeInfo.status === "complete") { + browser.test.notifyPass("onUpdated"); + } + }, + { urls: ["*://mochi.test/*"] } + ); + }, + }); + await ext2.startup(); + let ok = ext2.awaitFinish("onUpdated"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/#foreground" + ); + await Promise.all([ok]); + + await ext.unload(); + await ext2.unload(); + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_filter_tabId() { + let ext_fail = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background() { + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + browser.test.fail( + `received unexpected onUpdated event ${JSON.stringify(changeInfo)}` + ); + }, + { tabId: 12345 } + ); + }, + }); + await ext_fail.startup(); + + let ext_ok = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background() { + browser.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (changeInfo.status === "complete") { + browser.test.notifyPass("onUpdated"); + } + }); + }, + }); + await ext_ok.startup(); + let ok = ext_ok.awaitFinish("onUpdated"); + + let ext_ok2 = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background() { + browser.tabs.onCreated.addListener(tab => { + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + if (changeInfo.status === "complete") { + browser.test.notifyPass("onUpdated"); + } + }, + { tabId: tab.id } + ); + browser.test.log(`Tab specific tab listener on tab ${tab.id}`); + }); + }, + }); + await ext_ok2.startup(); + let ok2 = ext_ok2.awaitFinish("onUpdated"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + await Promise.all([ok, ok2]); + + await ext_ok.unload(); + await ext_ok2.unload(); + await ext_fail.unload(); + + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_filter_windowId() { + let ext_fail = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background() { + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + browser.test.fail( + `received unexpected onUpdated event ${JSON.stringify(changeInfo)}` + ); + }, + { windowId: 12345 } + ); + }, + }); + await ext_fail.startup(); + + let ext_ok = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background() { + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + if (changeInfo.status === "complete") { + browser.test.notifyPass("onUpdated"); + } + }, + { windowId: browser.windows.WINDOW_ID_CURRENT } + ); + }, + }); + await ext_ok.startup(); + let ok = ext_ok.awaitFinish("onUpdated"); + + let ext_ok2 = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + async background() { + let window = await browser.windows.getCurrent(); + browser.test.log(`Window specific tab listener on window ${window.id}`); + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + if (changeInfo.status === "complete") { + browser.test.notifyPass("onUpdated"); + } + }, + { windowId: window.id } + ); + browser.test.sendMessage("ready"); + }, + }); + await ext_ok2.startup(); + await ext_ok2.awaitMessage("ready"); + let ok2 = ext_ok2.awaitFinish("onUpdated"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + await Promise.all([ok, ok2]); + + await ext_ok.unload(); + await ext_ok2.unload(); + await ext_fail.unload(); + + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_filter_isArticle() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background() { + // We expect only status updates, anything else is a failure. + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + browser.test.log(`got onUpdated ${JSON.stringify(changeInfo)}`); + if ("isArticle" in changeInfo) { + browser.test.notifyPass("isArticle"); + } + }, + { properties: ["isArticle"] } + ); + }, + }); + await extension.startup(); + let ok = extension.awaitFinish("isArticle"); + + const baseUrl = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://mochi.test:8888/" + ); + const url = `${baseUrl}/readerModeArticle.html`; + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await ok; + + await extension.unload(); + + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_filter_property() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + async background() { + // We expect only status updates, anything else is a failure. + let properties = new Set([ + "audible", + "discarded", + "favIconUrl", + "hidden", + "isArticle", + "mutedInfo", + "pinned", + "sharingState", + "title", + "url", + ]); + + // Test that updated only happens after created. + let created = false; + let tabIds = (await browser.tabs.query({})).map(t => t.id); + browser.tabs.onCreated.addListener(tab => { + created = tab.id; + }); + + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + // ignore tabs created prior to extension startup + if (tabIds.includes(tabId)) { + return; + } + browser.test.assertEq(created, tabId, "tab created before updated"); + + browser.test.log(`got onUpdated ${JSON.stringify(changeInfo)}`); + browser.test.assertTrue(!!changeInfo.status, "changeInfo has status"); + if (Object.keys(changeInfo).some(p => properties.has(p))) { + browser.test.fail( + `received unexpected onUpdated event ${JSON.stringify( + changeInfo + )}` + ); + } + if (changeInfo.status === "complete") { + browser.test.notifyPass("onUpdated"); + } + }, + { properties: ["status"] } + ); + browser.test.sendMessage("ready"); + }, + }); + await extension.startup(); + await extension.awaitMessage("ready"); + let ok = extension.awaitFinish("onUpdated"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + await ok; + + await extension.unload(); + + await BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_opener.js b/browser/components/extensions/test/browser/browser_ext_tabs_opener.js new file mode 100644 index 0000000000..f5ea6c7a27 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_opener.js @@ -0,0 +1,130 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank?1" + ); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank?2" + ); + + gBrowser.selectedTab = tab1; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background() { + let activeTab; + let tabId; + let tabIds; + browser.tabs + .query({ lastFocusedWindow: true }) + .then(tabs => { + browser.test.assertEq(3, tabs.length, "We have three tabs"); + + browser.test.assertTrue(tabs[1].active, "Tab 1 is active"); + activeTab = tabs[1]; + + tabIds = tabs.map(tab => tab.id); + + return browser.tabs.create({ + openerTabId: activeTab.id, + active: false, + }); + }) + .then(tab => { + browser.test.assertEq( + activeTab.id, + tab.openerTabId, + "Tab opener ID is correct" + ); + browser.test.assertEq( + activeTab.index + 1, + tab.index, + "Tab was inserted after the related current tab" + ); + + tabId = tab.id; + return browser.tabs.get(tabId); + }) + .then(tab => { + browser.test.assertEq( + activeTab.id, + tab.openerTabId, + "Tab opener ID is still correct" + ); + + return browser.tabs.update(tabId, { openerTabId: tabIds[0] }); + }) + .then(tab => { + browser.test.assertEq( + tabIds[0], + tab.openerTabId, + "Updated tab opener ID is correct" + ); + + return browser.tabs.get(tabId); + }) + .then(tab => { + browser.test.assertEq( + tabIds[0], + tab.openerTabId, + "Updated tab opener ID is still correct" + ); + + return browser.tabs.create({ openerTabId: tabId, active: false }); + }) + .then(tab => { + browser.test.assertEq( + tabId, + tab.openerTabId, + "New tab opener ID is correct" + ); + browser.test.assertEq( + tabIds.length, + tab.index, + "New tab was not inserted after the unrelated current tab" + ); + + let promise = browser.tabs.remove(tabId); + + tabId = tab.id; + return promise; + }) + .then(() => { + return browser.tabs.get(tabId); + }) + .then(tab => { + browser.test.assertEq( + undefined, + tab.openerTabId, + "Tab opener ID was cleared after opener tab closed" + ); + + return browser.tabs.remove(tabId); + }) + .then(() => { + browser.test.notifyPass("tab-opener"); + }) + .catch(e => { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tab-opener"); + }); + }, + }); + + await extension.startup(); + + await extension.awaitFinish("tab-opener"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_printPreview.js b/browser/components/extensions/test/browser/browser_ext_tabs_printPreview.js new file mode 100644 index 0000000000..81dee72f93 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_printPreview.js @@ -0,0 +1,44 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testPrintPreview() { + await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.net/"); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + await browser.tabs.printPreview(); + browser.test.assertTrue(true, "print preview entered"); + browser.test.notifyPass("tabs.printPreview"); + }, + }); + + is( + document.querySelector(".printPreviewBrowser"), + null, + "There shouldn't be any print preview browser" + ); + + await extension.startup(); + + // Ensure we're showing the preview... + await BrowserTestUtils.waitForCondition(() => { + let preview = document.querySelector(".printPreviewBrowser"); + return preview && BrowserTestUtils.isVisible(preview); + }); + + gBrowser.getTabDialogBox(gBrowser.selectedBrowser).abortAllDialogs(); + // Wait for the preview to go away + await BrowserTestUtils.waitForCondition( + () => !document.querySelector(".printPreviewBrowser") + ); + + await extension.awaitFinish("tabs.printPreview"); + + await extension.unload(); + BrowserTestUtils.removeTab(gBrowser.tabs[1]); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_query.js b/browser/components/extensions/test/browser/browser_ext_tabs_query.js new file mode 100644 index 0000000000..099588c701 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_query.js @@ -0,0 +1,468 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +requestLongerTimeout(2); + +add_task(async function () { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:robots" + ); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:config" + ); + + gBrowser.selectedTab = tab1; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + async background() { + let tabs = await browser.tabs.query({ lastFocusedWindow: true }); + browser.test.assertEq(tabs.length, 3, "should have three tabs"); + + tabs.sort((tab1, tab2) => tab1.index - tab2.index); + + browser.test.assertEq(tabs[0].url, "about:blank", "first tab blank"); + tabs.shift(); + + browser.test.assertTrue(tabs[0].active, "tab 0 active"); + browser.test.assertFalse(tabs[1].active, "tab 1 inactive"); + + browser.test.assertFalse(tabs[0].pinned, "tab 0 unpinned"); + browser.test.assertFalse(tabs[1].pinned, "tab 1 unpinned"); + + browser.test.assertEq(tabs[0].url, "about:robots", "tab 0 url correct"); + browser.test.assertEq(tabs[1].url, "about:config", "tab 1 url correct"); + + browser.test.assertEq(tabs[0].status, "complete", "tab 0 status correct"); + browser.test.assertEq(tabs[1].status, "complete", "tab 1 status correct"); + + browser.test.assertEq( + tabs[0].title, + "Gort! Klaatu barada nikto!", + "tab 0 title correct" + ); + + tabs = await browser.tabs.query({ url: "about:blank" }); + browser.test.assertEq(tabs.length, 1, "about:blank query finds one tab"); + browser.test.assertEq(tabs[0].url, "about:blank", "with the correct url"); + + browser.test.notifyPass("tabs.query"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.query"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + + tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.net/" + ); + let tab3 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://test1.example.org/MochiKit/" + ); + + // test simple queries + extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: function () { + browser.tabs.query( + { + url: "", + }, + function (tabs) { + browser.test.assertEq(tabs.length, 3, "should have three tabs"); + + tabs.sort((tab1, tab2) => tab1.index - tab2.index); + + browser.test.assertEq( + tabs[0].url, + "http://example.com/", + "tab 0 url correct" + ); + browser.test.assertEq( + tabs[1].url, + "http://example.net/", + "tab 1 url correct" + ); + browser.test.assertEq( + tabs[2].url, + "http://test1.example.org/MochiKit/", + "tab 2 url correct" + ); + + browser.test.notifyPass("tabs.query"); + } + ); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.query"); + await extension.unload(); + + // match pattern + extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: function () { + browser.tabs.query( + { + url: "http://*/MochiKit*", + }, + function (tabs) { + browser.test.assertEq(tabs.length, 1, "should have one tab"); + + browser.test.assertEq( + tabs[0].url, + "http://test1.example.org/MochiKit/", + "tab 0 url correct" + ); + + browser.test.notifyPass("tabs.query"); + } + ); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.query"); + await extension.unload(); + + // match array of patterns + extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: function () { + browser.tabs.query( + { + url: ["http://*/MochiKit*", "http://*.com/*"], + }, + function (tabs) { + browser.test.assertEq(tabs.length, 2, "should have two tabs"); + + tabs.sort((tab1, tab2) => tab1.index - tab2.index); + + browser.test.assertEq( + tabs[0].url, + "http://example.com/", + "tab 0 url correct" + ); + browser.test.assertEq( + tabs[1].url, + "http://test1.example.org/MochiKit/", + "tab 1 url correct" + ); + + browser.test.notifyPass("tabs.query"); + } + ); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.query"); + await extension.unload(); + + // match title pattern + extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + async background() { + let tabs = await browser.tabs.query({ + title: "mochitest index /", + }); + + browser.test.assertEq(tabs.length, 2, "should have two tabs"); + + tabs.sort((tab1, tab2) => tab1.index - tab2.index); + + browser.test.assertEq( + tabs[0].title, + "mochitest index /", + "tab 0 title correct" + ); + browser.test.assertEq( + tabs[1].title, + "mochitest index /", + "tab 1 title correct" + ); + + tabs = await browser.tabs.query({ + title: "?ochitest index /*", + }); + + browser.test.assertEq(tabs.length, 3, "should have three tabs"); + + tabs.sort((tab1, tab2) => tab1.index - tab2.index); + + browser.test.assertEq( + tabs[0].title, + "mochitest index /", + "tab 0 title correct" + ); + browser.test.assertEq( + tabs[1].title, + "mochitest index /", + "tab 1 title correct" + ); + browser.test.assertEq( + tabs[2].title, + "mochitest index /MochiKit/", + "tab 2 title correct" + ); + + browser.test.notifyPass("tabs.query"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.query"); + await extension.unload(); + + // match highlighted + extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let tabs1 = await browser.tabs.query({ highlighted: false }); + browser.test.assertEq( + 3, + tabs1.length, + "should have three non-highlighted tabs" + ); + + let tabs2 = await browser.tabs.query({ highlighted: true }); + browser.test.assertEq(1, tabs2.length, "should have one highlighted tab"); + + for (let tab of [...tabs1, ...tabs2]) { + browser.test.assertEq( + tab.active, + tab.highlighted, + "highlighted and active are equal in tab " + tab.index + ); + } + + browser.test.notifyPass("tabs.query"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.query"); + await extension.unload(); + + // test width and height + extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: function () { + browser.test.onMessage.addListener(async msg => { + let tabs = await browser.tabs.query({ active: true }); + + browser.test.assertEq(tabs.length, 1, "should have one tab"); + browser.test.sendMessage("dims", { + width: tabs[0].width, + height: tabs[0].height, + }); + }); + browser.test.sendMessage("ready"); + }, + }); + + const RESOLUTION_PREF = "layout.css.devPixelsPerPx"; + registerCleanupFunction(() => { + Services.prefs.clearUserPref(RESOLUTION_PREF); + }); + + await Promise.all([extension.startup(), extension.awaitMessage("ready")]); + + for (let resolution of [2, 1]) { + Services.prefs.setCharPref(RESOLUTION_PREF, String(resolution)); + is( + window.devicePixelRatio, + resolution, + "window has the required resolution" + ); + + let { clientHeight, clientWidth } = gBrowser.selectedBrowser; + + extension.sendMessage("check-size"); + let dims = await extension.awaitMessage("dims"); + is(dims.width, clientWidth, "tab reports expected width"); + is(dims.height, clientHeight, "tab reports expected height"); + } + + await extension.unload(); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + BrowserTestUtils.removeTab(tab3); + Services.prefs.clearUserPref(RESOLUTION_PREF); +}); + +add_task(async function testQueryPermissions() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [], + }, + + async background() { + try { + let tabs = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + browser.test.assertEq(tabs.length, 1, "Expect query to return tabs"); + browser.test.notifyPass("queryPermissions"); + } catch (e) { + browser.test.notifyFail("queryPermissions"); + } + }, + }); + + await extension.startup(); + + await extension.awaitFinish("queryPermissions"); + + await extension.unload(); +}); + +add_task(async function testInvalidUrl() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + async background() { + await browser.test.assertRejects( + browser.tabs.query({ url: "http://test1.net" }), + "Invalid url pattern: http://test1.net", + "Expected url to match pattern" + ); + await browser.test.assertRejects( + browser.tabs.query({ url: ["test2"] }), + "Invalid url pattern: test2", + "Expected an array with an invalid match pattern" + ); + await browser.test.assertRejects( + browser.tabs.query({ url: ["http://www.bbc.com/", "test3"] }), + "Invalid url pattern: test3", + "Expected an array with an invalid match pattern" + ); + browser.test.notifyPass("testInvalidUrl"); + }, + }); + await extension.startup(); + await extension.awaitFinish("testInvalidUrl"); + await extension.unload(); +}); + +add_task(async function test_query_index() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: function () { + browser.tabs.onCreated.addListener(async function ({ + index, + windowId, + id, + }) { + browser.test.assertThrows( + () => browser.tabs.query({ index: -1 }), + /-1 is too small \(must be at least 0\)/, + "tab indices must be non-negative" + ); + + let tabs = await browser.tabs.query({ index, windowId }); + browser.test.assertEq(tabs.length, 1, `Got one tab at index ${index}`); + browser.test.assertEq(tabs[0].id, id, "The tab is the right one"); + + tabs = await browser.tabs.query({ index: 1e5, windowId }); + browser.test.assertEq(tabs.length, 0, "There is no tab at this index"); + + browser.test.notifyPass("tabs.query"); + }); + }, + }); + + await extension.startup(); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + await extension.awaitFinish("tabs.query"); + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); + +add_task(async function test_query_window() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let badWindowId = 0; + for (let { id } of await browser.windows.getAll()) { + badWindowId = Math.max(badWindowId, id + 1); + } + + let tabs = await browser.tabs.query({ windowId: badWindowId }); + browser.test.assertEq( + tabs.length, + 0, + "No tabs because there is no such window ID" + ); + + let { id: currentWindowId } = await browser.windows.getCurrent(); + tabs = await browser.tabs.query({ currentWindow: true }); + browser.test.assertEq( + tabs[0].windowId, + currentWindowId, + "Got tabs from the current window" + ); + + let { id: lastFocusedWindowId } = await browser.windows.getLastFocused(); + tabs = await browser.tabs.query({ lastFocusedWindow: true }); + browser.test.assertEq( + tabs[0].windowId, + lastFocusedWindowId, + "Got tabs from the last focused window" + ); + + browser.test.notifyPass("tabs.query"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.query"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_readerMode.js b/browser/components/extensions/test/browser/browser_ext_tabs_readerMode.js new file mode 100644 index 0000000000..1b86094611 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_readerMode.js @@ -0,0 +1,138 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_reader_mode() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + async background() { + let tab; + let tabId; + let expected = { isInReaderMode: false }; + let testState = {}; + browser.test.onMessage.addListener(async (msg, ...args) => { + switch (msg) { + case "updateUrl": + expected.isArticle = args[0]; + expected.url = args[1]; + tab = await browser.tabs.update({ url: expected.url }); + tabId = tab.id; + break; + case "enterReaderMode": + expected.isArticle = !args[0]; + expected.isInReaderMode = true; + tab = await browser.tabs.get(tabId); + browser.test.assertEq( + false, + tab.isInReaderMode, + "The tab is not in reader mode." + ); + if (args[0]) { + browser.tabs.toggleReaderMode(tabId); + } else { + await browser.test.assertRejects( + browser.tabs.toggleReaderMode(tabId), + /The specified tab cannot be placed into reader mode/, + "Toggle fails with an unreaderable document." + ); + browser.test.assertEq( + false, + tab.isInReaderMode, + "The tab is still not in reader mode." + ); + browser.test.sendMessage("enterFailed"); + } + break; + case "leaveReaderMode": + expected.isInReaderMode = false; + tab = await browser.tabs.get(tabId); + browser.test.assertTrue( + tab.isInReaderMode, + "The tab is in reader mode." + ); + browser.tabs.toggleReaderMode(tabId); + break; + } + }); + + browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + if (changeInfo.status === "complete") { + testState.url = tab.url; + let urlOk = expected.isInReaderMode + ? testState.url.startsWith("about:reader") + : expected.url == testState.url; + if (urlOk && expected.isArticle == testState.isArticle) { + browser.test.sendMessage("tabUpdated", tab); + } + return; + } + if ( + changeInfo.isArticle == expected.isArticle && + changeInfo.isArticle != testState.isArticle + ) { + testState.isArticle = changeInfo.isArticle; + let urlOk = expected.isInReaderMode + ? testState.url.startsWith("about:reader") + : expected.url == testState.url; + if (urlOk && expected.isArticle == testState.isArticle) { + browser.test.sendMessage("isArticle", tab); + } + } + }); + }, + }); + + const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://example.com" + ); + const READER_MODE_PREFIX = "about:reader"; + + await extension.startup(); + extension.sendMessage( + "updateUrl", + true, + `${TEST_PATH}readerModeArticle.html` + ); + let tab = await extension.awaitMessage("isArticle"); + + ok( + !tab.url.startsWith(READER_MODE_PREFIX), + "Tab url does not indicate reader mode." + ); + ok(tab.isArticle, "Tab is readerable."); + + extension.sendMessage("enterReaderMode", true); + tab = await extension.awaitMessage("tabUpdated"); + ok(tab.url.startsWith(READER_MODE_PREFIX), "Tab url indicates reader mode."); + ok(tab.isInReaderMode, "tab.isInReaderMode indicates reader mode."); + + extension.sendMessage("leaveReaderMode"); + tab = await extension.awaitMessage("tabUpdated"); + ok( + !tab.url.startsWith(READER_MODE_PREFIX), + "Tab url does not indicate reader mode." + ); + ok(!tab.isInReaderMode, "tab.isInReaderMode does not indicate reader mode."); + + extension.sendMessage( + "updateUrl", + false, + `${TEST_PATH}readerModeNonArticle.html` + ); + tab = await extension.awaitMessage("tabUpdated"); + ok( + !tab.url.startsWith(READER_MODE_PREFIX), + "Tab url does not indicate reader mode." + ); + ok(!tab.isArticle, "Tab is not readerable."); + ok(!tab.isInReaderMode, "tab.isInReaderMode does not indicate reader mode."); + + extension.sendMessage("enterReaderMode", false); + await extension.awaitMessage("enterFailed"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_reload.js b/browser/components/extensions/test/browser/browser_ext_tabs_reload.js new file mode 100644 index 0000000000..aed4a3822c --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_reload.js @@ -0,0 +1,53 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + files: { + "tab.js": function () { + browser.runtime.sendMessage("tab-loaded"); + }, + "tab.html": ` + + + `, + }, + + async background() { + let tabLoadedCount = 0; + + let tab = await browser.tabs.create({ url: "tab.html", active: true }); + + browser.runtime.onMessage.addListener(msg => { + if (msg == "tab-loaded") { + tabLoadedCount++; + + if (tabLoadedCount == 1) { + // Reload the tab once passing no arguments. + return browser.tabs.reload(); + } + + if (tabLoadedCount == 2) { + // Reload the tab again with explicit arguments. + return browser.tabs.reload(tab.id, { + bypassCache: false, + }); + } + + if (tabLoadedCount == 3) { + browser.test.notifyPass("tabs.reload"); + } + } + }); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.reload"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_reload_bypass_cache.js b/browser/components/extensions/test/browser/browser_ext_tabs_reload_bypass_cache.js new file mode 100644 index 0000000000..ed3d8c7a14 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_reload_bypass_cache.js @@ -0,0 +1,89 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", ""], + }, + + async background() { + const BASE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/"; + const URL = BASE + "file_bypass_cache.sjs"; + + let tabId = null; + let loadPromise, resolveLoad; + function resetLoad() { + loadPromise = new Promise(resolve => { + resolveLoad = resolve; + }); + } + function awaitLoad() { + return loadPromise.then(() => { + resetLoad(); + }); + } + resetLoad(); + + browser.tabs.onUpdated.addListener(function listener( + tabId_, + changed, + tab + ) { + if (tabId == tabId_ && changed.status == "complete" && tab.url == URL) { + resolveLoad(); + } + }); + + try { + let tab = await browser.tabs.create({ url: URL }); + tabId = tab.id; + await awaitLoad(); + + await browser.tabs.reload(tab.id, { bypassCache: false }); + await awaitLoad(); + + let [textContent] = await browser.tabs.executeScript(tab.id, { + code: "document.body.textContent", + }); + browser.test.assertEq( + "", + textContent, + "`textContent` should be empty when bypassCache=false" + ); + + await browser.tabs.reload(tab.id, { bypassCache: true }); + await awaitLoad(); + + [textContent] = await browser.tabs.executeScript(tab.id, { + code: "document.body.textContent", + }); + + let [pragma, cacheControl] = textContent.split(":"); + browser.test.assertEq( + "no-cache", + pragma, + "`pragma` should be set to `no-cache` when bypassCache is true" + ); + browser.test.assertEq( + "no-cache", + cacheControl, + "`cacheControl` should be set to `no-cache` when bypassCache is true" + ); + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("tabs.reload_bypass_cache"); + } catch (error) { + browser.test.fail(`${error} :: ${error.stack}`); + browser.test.notifyFail("tabs.reload_bypass_cache"); + } + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.reload_bypass_cache"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_remove.js b/browser/components/extensions/test/browser/browser_ext_tabs_remove.js new file mode 100644 index 0000000000..8e51494ed1 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_remove.js @@ -0,0 +1,258 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function undoCloseAfterExtRemovesOneTab() { + let initialTab = gBrowser.selectedTab; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let tabs = await browser.tabs.query({}); + + browser.test.assertEq(3, tabs.length, "Should have 3 tabs"); + + let tabIdsToRemove = ( + await browser.tabs.query({ + url: "https://example.com/closeme/*", + }) + ).map(tab => tab.id); + + await browser.tabs.remove(tabIdsToRemove); + browser.test.sendMessage("removedtabs"); + }, + }); + + await Promise.all([ + BrowserTestUtils.openNewForegroundTab(gBrowser, "https://example.com/1"), + BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/closeme/2" + ), + ]); + + await extension.startup(); + await extension.awaitMessage("removedtabs"); + + is( + gBrowser.tabs.length, + 2, + "Once extension has closed a tab, there should be 2 tabs open" + ); + + // The tabs.remove API makes no promises about SessionStore's updates + // having been completed by the time it returns. So we need to wait separately + // for the closed tab count to be updated the correct value. This is OK because + // we can observe above that the tabs length has changed to reflect that + // some were closed. + await TestUtils.waitForCondition( + () => SessionStore.getLastClosedTabCount(window) == 1, + "SessionStore should know that one tab was closed" + ); + + undoCloseTab(); + + is( + gBrowser.tabs.length, + 3, + "All tabs should be restored for a total of 3 tabs" + ); + + await BrowserTestUtils.waitForEvent(gBrowser.tabs[2], "SSTabRestored"); + + is( + gBrowser.tabs[2].linkedBrowser.currentURI.spec, + "https://example.com/closeme/2", + "Restored tab at index 2 should have expected URL" + ); + + await extension.unload(); + gBrowser.removeAllTabsBut(initialTab); +}); + +add_task(async function undoCloseAfterExtRemovesMultipleTabs() { + let initialTab = gBrowser.selectedTab; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let tabIds = (await browser.tabs.query({})).map(tab => tab.id); + + browser.test.assertEq( + 8, + tabIds.length, + "Should have 8 total tabs (4 in each window: the initial blank tab and the 3 opened by this test)" + ); + + let tabIdsToRemove = ( + await browser.tabs.query({ + url: "https://example.com/closeme/*", + }) + ).map(tab => tab.id); + + await browser.tabs.remove(tabIdsToRemove); + + browser.test.sendMessage("removedtabs"); + }, + }); + + await Promise.all([ + BrowserTestUtils.openNewForegroundTab(gBrowser, "https://example.com/1"), + BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/closeme/2" + ), + BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/closeme/3" + ), + ]); + + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + + await Promise.all([ + BrowserTestUtils.openNewForegroundTab( + window2.gBrowser, + "https://example.com/4" + ), + BrowserTestUtils.openNewForegroundTab( + window2.gBrowser, + "https://example.com/closeme/5" + ), + BrowserTestUtils.openNewForegroundTab( + window2.gBrowser, + "https://example.com/closeme/6" + ), + ]); + + await extension.startup(); + await extension.awaitMessage("removedtabs"); + + is( + gBrowser.tabs.length, + 2, + "Original window should have 2 tabs still open, after closing tabs" + ); + + is( + window2.gBrowser.tabs.length, + 2, + "Second window should have 2 tabs still open, after closing tabs" + ); + + // The tabs.remove API makes no promises about SessionStore's updates + // having been completed by the time it returns. So we need to wait separately + // for the closed tab count to be updated the correct value. This is OK because + // we can observe above that the tabs length has changed to reflect that + // some were closed. + await TestUtils.waitForCondition( + () => SessionStore.getLastClosedTabCount(window) == 2, + "Last closed tab count is 2" + ); + + await TestUtils.waitForCondition( + () => SessionStore.getLastClosedTabCount(window2) == 2, + "Last closed tab count is 2" + ); + + undoCloseTab(); + window2.undoCloseTab(); + + is( + gBrowser.tabs.length, + 4, + "All tabs in original window should be restored for a total of 4 tabs" + ); + + is( + window2.gBrowser.tabs.length, + 4, + "All tabs in second window should be restored for a total of 4 tabs" + ); + + await Promise.all([ + BrowserTestUtils.waitForEvent(gBrowser.tabs[2], "SSTabRestored"), + BrowserTestUtils.waitForEvent(gBrowser.tabs[3], "SSTabRestored"), + BrowserTestUtils.waitForEvent(window2.gBrowser.tabs[2], "SSTabRestored"), + BrowserTestUtils.waitForEvent(window2.gBrowser.tabs[3], "SSTabRestored"), + ]); + + is( + gBrowser.tabs[2].linkedBrowser.currentURI.spec, + "https://example.com/closeme/2", + "Original window restored tab at index 2 should have expected URL" + ); + + is( + gBrowser.tabs[3].linkedBrowser.currentURI.spec, + "https://example.com/closeme/3", + "Original window restored tab at index 3 should have expected URL" + ); + + is( + window2.gBrowser.tabs[2].linkedBrowser.currentURI.spec, + "https://example.com/closeme/5", + "Second window restored tab at index 2 should have expected URL" + ); + + is( + window2.gBrowser.tabs[3].linkedBrowser.currentURI.spec, + "https://example.com/closeme/6", + "Second window restored tab at index 3 should have expected URL" + ); + + await extension.unload(); + await BrowserTestUtils.closeWindow(window2); + gBrowser.removeAllTabsBut(initialTab); +}); + +add_task(async function closeWindowIfExtClosesAllTabs() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.tabs.closeWindowWithLastTab", true], + ["browser.tabs.warnOnClose", true], + ], + }); + + let extension = ExtensionTestUtils.loadExtension({ + background: async function () { + let tabsToRemove = await browser.tabs.query({ currentWindow: true }); + + let currentWindowId = tabsToRemove[0].windowId; + + browser.test.assertEq( + 2, + tabsToRemove.length, + "Current window should have 2 tabs to remove" + ); + + await browser.tabs.remove(tabsToRemove.map(tab => tab.id)); + + await browser.test.assertRejects( + browser.windows.get(currentWindowId), + RegExp(`Invalid window ID: ${currentWindowId}`), + "After closing tabs, 2nd window should be closed and querying for it should be rejected" + ); + + browser.test.notifyPass("done"); + }, + }); + + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + + await BrowserTestUtils.openNewForegroundTab( + window2.gBrowser, + "https://example.com/" + ); + + await extension.startup(); + await extension.awaitFinish("done"); + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_removeCSS.js b/browser/components/extensions/test/browser/browser_ext_tabs_removeCSS.js new file mode 100644 index 0000000000..edaf2f61b4 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_removeCSS.js @@ -0,0 +1,151 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testExecuteScript() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/", + true + ); + + async function background() { + let tasks = [ + // Insert CSS file. + { + background: "rgba(0, 0, 0, 0)", + foreground: "rgb(0, 113, 4)", + promise: () => { + return browser.tabs.insertCSS({ + file: "file2.css", + }); + }, + }, + // Insert CSS code. + { + background: "rgb(42, 42, 42)", + foreground: "rgb(0, 113, 4)", + promise: () => { + return browser.tabs.insertCSS({ + code: "* { background: rgb(42, 42, 42) }", + }); + }, + }, + // Remove CSS code again. + { + background: "rgba(0, 0, 0, 0)", + foreground: "rgb(0, 113, 4)", + promise: () => { + return browser.tabs.removeCSS({ + code: "* { background: rgb(42, 42, 42) }", + }); + }, + }, + // Remove CSS file again. + { + background: "rgba(0, 0, 0, 0)", + foreground: "rgb(0, 0, 0)", + promise: () => { + return browser.tabs.removeCSS({ + file: "file2.css", + }); + }, + }, + // Insert CSS code. + { + background: "rgb(42, 42, 42)", + foreground: "rgb(0, 0, 0)", + promise: () => { + return browser.tabs.insertCSS({ + code: "* { background: rgb(42, 42, 42) }", + cssOrigin: "user", + }); + }, + }, + // Remove CSS code again. + { + background: "rgba(0, 0, 0, 0)", + foreground: "rgb(0, 0, 0)", + promise: () => { + return browser.tabs.removeCSS({ + code: "* { background: rgb(42, 42, 42) }", + cssOrigin: "user", + }); + }, + }, + ]; + + function checkCSS() { + let computedStyle = window.getComputedStyle(document.body); + return [computedStyle.backgroundColor, computedStyle.color]; + } + + try { + for (let { promise, background, foreground } of tasks) { + let result = await promise(); + browser.test.assertEq(undefined, result, "Expected callback result"); + + [result] = await browser.tabs.executeScript({ + code: `(${checkCSS})()`, + }); + browser.test.assertEq( + background, + result[0], + "Expected background color" + ); + browser.test.assertEq( + foreground, + result[1], + "Expected foreground color" + ); + } + + browser.test.notifyPass("removeCSS"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("removeCSS"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://mochi.test/"], + }, + + background, + + files: { + "file2.css": "* { color: rgb(0, 113, 4) }", + }, + }); + + await extension.startup(); + + await extension.awaitFinish("removeCSS"); + + // Verify that scripts created by tabs.removeCSS are not added to the content scripts + // that requires cleanup (Bug 1464711). + await SpecialPowers.spawn(tab.linkedBrowser, [extension.id], async extId => { + const { ExtensionContent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionContent.sys.mjs" + ); + + let contentScriptContext = ExtensionContent.getContextByExtensionId( + extId, + content.window + ); + + for (let script of contentScriptContext.scripts) { + if (script.matcher.removeCSS && script.requiresCleanup) { + throw new Error("tabs.removeCSS scripts should not require cleanup"); + } + } + }).catch(err => { + // Log the error so that it is easy to see where the failure is coming from. + ok(false, err); + }); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_saveAsPDF.js b/browser/components/extensions/test/browser/browser_ext_tabs_saveAsPDF.js new file mode 100644 index 0000000000..fdff1dddbf --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_saveAsPDF.js @@ -0,0 +1,197 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function testReturnStatus(expectedStatus) { + // Test that tabs.saveAsPDF() returns the correct status + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.net/" + ); + + let saveDir = FileUtils.getDir("TmpD", [`testSaveDir-${Math.random()}`]); + saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + let saveFile = saveDir.clone(); + saveFile.append("testSaveFile.pdf"); + if (saveFile.exists()) { + saveFile.remove(false); + } + + if (expectedStatus == "replaced") { + // Create file that can be replaced + saveFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o666); + } else if (expectedStatus == "not_saved") { + // Create directory with same name as file - so that file cannot be saved + saveFile.create(Ci.nsIFile.DIRECTORY_TYPE, 0o666); + } else if (expectedStatus == "not_replaced") { + // Create file that cannot be replaced + saveFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o444); + } + + let MockFilePicker = SpecialPowers.MockFilePicker; + MockFilePicker.init(window); + + if (expectedStatus == "replaced" || expectedStatus == "not_replaced") { + MockFilePicker.returnValue = MockFilePicker.returnReplace; + } else if (expectedStatus == "canceled") { + MockFilePicker.returnValue = MockFilePicker.returnCancel; + } else { + MockFilePicker.returnValue = MockFilePicker.returnOK; + } + + MockFilePicker.displayDirectory = saveDir; + + MockFilePicker.showCallback = fp => { + MockFilePicker.setFiles([saveFile]); + MockFilePicker.filterIndex = 0; // *.* - all file extensions + }; + + let manifest = { + description: expectedStatus, + }; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: manifest, + + background: async function () { + let pageSettings = {}; + + let expected = chrome.runtime.getManifest().description; + + let status = await browser.tabs.saveAsPDF(pageSettings); + + browser.test.assertEq(expected, status, "Got expected status"); + + browser.test.notifyPass("tabs.saveAsPDF"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.saveAsPDF"); + await extension.unload(); + + if (expectedStatus == "saved" || expectedStatus == "replaced") { + // Check that first four bytes of saved PDF file are "%PDF" + let text = await IOUtils.read(saveFile.path, { maxBytes: 4 }); + text = new TextDecoder().decode(text); + is(text, "%PDF", "Got correct magic number - %PDF"); + } + + MockFilePicker.cleanup(); + + if (expectedStatus == "not_saved" || expectedStatus == "not_replaced") { + saveFile.permissions = 0o666; + } + + saveDir.remove(true); + + BrowserTestUtils.removeTab(tab); +} + +add_task(async function testSaveAsPDF_saved() { + await testReturnStatus("saved"); +}); + +add_task(async function testSaveAsPDF_replaced() { + await testReturnStatus("replaced"); +}); + +add_task(async function testSaveAsPDF_canceled() { + await testReturnStatus("canceled"); +}); + +add_task(async function testSaveAsPDF_not_saved() { + await testReturnStatus("not_saved"); +}); + +add_task(async function testSaveAsPDF_not_replaced() { + await testReturnStatus("not_replaced"); +}); + +async function testFileName(expectedFileName) { + // Test that tabs.saveAsPDF() saves with the correct filename + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.net/" + ); + + let saveDir = FileUtils.getDir("TmpD", [`testSaveDir-${Math.random()}`]); + saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + let saveFile = saveDir.clone(); + saveFile.append(expectedFileName); + if (saveFile.exists()) { + saveFile.remove(false); + } + + let MockFilePicker = SpecialPowers.MockFilePicker; + MockFilePicker.init(window); + + MockFilePicker.returnValue = MockFilePicker.returnOK; + + MockFilePicker.displayDirectory = saveDir; + + MockFilePicker.showCallback = fp => { + is( + fp.defaultString, + expectedFileName, + "Got expected FilePicker defaultString" + ); + + is(fp.defaultExtension, "pdf", "Got expected FilePicker defaultExtension"); + + let file = saveDir.clone(); + file.append(fp.defaultString); + MockFilePicker.setFiles([file]); + }; + + let manifest = { + description: expectedFileName, + }; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: manifest, + + background: async function () { + let pageSettings = {}; + + let expected = chrome.runtime.getManifest().description; + + if (expected == "definedFileName") { + pageSettings.toFileName = expected; + } + + let status = await browser.tabs.saveAsPDF(pageSettings); + + browser.test.assertEq("saved", status, "Got expected status"); + + browser.test.notifyPass("tabs.saveAsPDF"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.saveAsPDF"); + await extension.unload(); + + // Check that first four bytes of saved PDF file are "%PDF" + let text = await IOUtils.read(saveFile.path, { maxBytes: 4 }); + text = new TextDecoder().decode(text); + is(text, "%PDF", "Got correct magic number - %PDF"); + + MockFilePicker.cleanup(); + + saveDir.remove(true); + + BrowserTestUtils.removeTab(tab); +} + +add_task(async function testSaveAsPDF_defined_filename() { + await testFileName("definedFileName"); +}); + +add_task(async function testSaveAsPDF_undefined_filename() { + // If pageSettings.toFileName is undefined, the expected filename will be + // the test page title "mochitest index /" with the "/" replaced by "_". + await testFileName("mochitest index _"); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_sendMessage.js b/browser/components/extensions/test/browser/browser_ext_tabs_sendMessage.js new file mode 100644 index 0000000000..8c420c2821 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_sendMessage.js @@ -0,0 +1,385 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function tabsSendMessageReply() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + + content_scripts: [ + { + matches: ["http://example.com/"], + js: ["content-script.js"], + run_at: "document_start", + }, + ], + }, + + background: async function () { + let firstTab; + let promiseResponse = new Promise(resolve => { + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "content-script-ready") { + let tabId = sender.tab.id; + + Promise.all([ + promiseResponse, + + browser.tabs.sendMessage(tabId, "respond-now"), + browser.tabs.sendMessage(tabId, "respond-now-2"), + new Promise(resolve => + browser.tabs.sendMessage(tabId, "respond-soon", resolve) + ), + browser.tabs.sendMessage(tabId, "respond-promise"), + browser.tabs.sendMessage(tabId, "respond-promise-false"), + browser.tabs.sendMessage(tabId, "respond-false"), + browser.tabs.sendMessage(tabId, "respond-never"), + new Promise(resolve => { + browser.runtime.sendMessage("respond-never", response => { + resolve(response); + }); + }), + + browser.tabs + .sendMessage(tabId, "respond-error") + .catch(error => Promise.resolve({ error })), + browser.tabs + .sendMessage(tabId, "throw-error") + .catch(error => Promise.resolve({ error })), + + browser.tabs + .sendMessage(tabId, "respond-uncloneable") + .catch(error => Promise.resolve({ error })), + browser.tabs + .sendMessage(tabId, "reject-uncloneable") + .catch(error => Promise.resolve({ error })), + browser.tabs + .sendMessage(tabId, "reject-undefined") + .catch(error => Promise.resolve({ error })), + browser.tabs + .sendMessage(tabId, "throw-undefined") + .catch(error => Promise.resolve({ error })), + + browser.tabs + .sendMessage(firstTab, "no-listener") + .catch(error => Promise.resolve({ error })), + ]) + .then( + ([ + response, + respondNow, + respondNow2, + respondSoon, + respondPromise, + respondPromiseFalse, + respondFalse, + respondNever, + respondNever2, + respondError, + throwError, + respondUncloneable, + rejectUncloneable, + rejectUndefined, + throwUndefined, + noListener, + ]) => { + browser.test.assertEq( + "expected-response", + response, + "Content script got the expected response" + ); + + browser.test.assertEq( + "respond-now", + respondNow, + "Got the expected immediate response" + ); + browser.test.assertEq( + "respond-now-2", + respondNow2, + "Got the expected immediate response from the second listener" + ); + browser.test.assertEq( + "respond-soon", + respondSoon, + "Got the expected delayed response" + ); + browser.test.assertEq( + "respond-promise", + respondPromise, + "Got the expected promise response" + ); + browser.test.assertEq( + false, + respondPromiseFalse, + "Got the expected false value as a promise result" + ); + browser.test.assertEq( + undefined, + respondFalse, + "Got the expected no-response when onMessage returns false" + ); + browser.test.assertEq( + undefined, + respondNever, + "Got the expected no-response resolution" + ); + browser.test.assertEq( + undefined, + respondNever2, + "Got the expected no-response resolution" + ); + + browser.test.assertEq( + "respond-error", + respondError.error.message, + "Got the expected error response" + ); + browser.test.assertEq( + "throw-error", + throwError.error.message, + "Got the expected thrown error response" + ); + + browser.test.assertEq( + "Could not establish connection. Receiving end does not exist.", + respondUncloneable.error.message, + "An uncloneable response should be ignored" + ); + browser.test.assertEq( + "An unexpected error occurred", + rejectUncloneable.error.message, + "Got the expected error for a rejection with an uncloneable value" + ); + browser.test.assertEq( + "An unexpected error occurred", + rejectUndefined.error.message, + "Got the expected error for a void rejection" + ); + browser.test.assertEq( + "An unexpected error occurred", + throwUndefined.error.message, + "Got the expected error for a void throw" + ); + + browser.test.assertEq( + "Could not establish connection. Receiving end does not exist.", + noListener.error.message, + "Got the expected no listener response" + ); + + return browser.tabs.remove(tabId); + } + ) + .then(() => { + browser.test.notifyPass("sendMessage"); + }); + + return Promise.resolve("expected-response"); + } else if (msg[0] == "got-response") { + resolve(msg[1]); + } + }); + }); + + let tabs = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + firstTab = tabs[0].id; + browser.tabs.create({ url: "http://example.com/" }); + }, + + files: { + "content-script.js": async function () { + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + respond(msg); + } else if (msg == "respond-soon") { + setTimeout(() => { + respond(msg); + }, 0); + return true; + } else if (msg == "respond-promise") { + return Promise.resolve(msg); + } else if (msg == "respond-promise-false") { + return Promise.resolve(false); + } else if (msg == "respond-false") { + // return false means that respond() is not expected to be called. + setTimeout(() => respond("should be ignored")); + return false; + } else if (msg == "respond-never") { + return undefined; + } else if (msg == "respond-error") { + return Promise.reject(new Error(msg)); + } else if (msg === "respond-uncloneable") { + return Promise.resolve(window); + } else if (msg === "reject-uncloneable") { + return Promise.reject(window); + } else if (msg == "reject-undefined") { + return Promise.reject(); + } else if (msg == "throw-undefined") { + throw undefined; // eslint-disable-line no-throw-literal + } else if (msg == "throw-error") { + throw new Error(msg); + } + }); + + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + respond("hello"); + } else if (msg == "respond-now-2") { + respond(msg); + } + }); + + let response = await browser.runtime.sendMessage( + "content-script-ready" + ); + browser.runtime.sendMessage(["got-response", response]); + }, + }, + }); + + await extension.startup(); + + await extension.awaitFinish("sendMessage"); + + await extension.unload(); +}); + +add_task(async function tabsSendHidden() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + + content_scripts: [ + { + matches: ["http://example.com/content*"], + js: ["content-script.js"], + run_at: "document_start", + }, + ], + }, + + background: async function () { + let resolveContent; + browser.runtime.onMessage.addListener((msg, sender) => { + if (msg[0] == "content-ready") { + resolveContent(msg[1]); + } + }); + + let awaitContent = url => { + return new Promise(resolve => { + resolveContent = resolve; + }).then(result => { + browser.test.assertEq(url, result, "Expected content script URL"); + }); + }; + + try { + const URL1 = "http://example.com/content1.html"; + const URL2 = "http://example.com/content2.html"; + + let tab = await browser.tabs.create({ url: URL1 }); + await awaitContent(URL1); + + let url = await browser.tabs.sendMessage(tab.id, URL1); + browser.test.assertEq( + URL1, + url, + "Should get response from expected content window" + ); + + await browser.tabs.update(tab.id, { url: URL2 }); + await awaitContent(URL2); + + url = await browser.tabs.sendMessage(tab.id, URL2); + browser.test.assertEq( + URL2, + url, + "Should get response from expected content window" + ); + + // Repeat once just to be sure the first message was processed by all + // listeners before we exit the test. + url = await browser.tabs.sendMessage(tab.id, URL2); + browser.test.assertEq( + URL2, + url, + "Should get response from expected content window" + ); + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("contentscript-bfcache-window"); + } catch (error) { + browser.test.fail(`Error: ${error} :: ${error.stack}`); + browser.test.notifyFail("contentscript-bfcache-window"); + } + }, + + files: { + "content-script.js": function () { + // Store this in a local variable to make sure we don't touch any + // properties of the possibly-hidden content window. + let href = window.location.href; + + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.assertEq( + href, + msg, + "Should be in the expected content window" + ); + + return Promise.resolve(href); + }); + + browser.runtime.sendMessage(["content-ready", href]); + }, + }, + }); + + await extension.startup(); + + await extension.awaitFinish("contentscript-bfcache-window"); + + await extension.unload(); +}); + +add_task(async function tabsSendMessageNoExceptionOnNonExistentTab() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + async background() { + let url = + "http://example.com/mochitest/browser/browser/components/extensions/test/browser/file_dummy.html"; + let tab = await browser.tabs.create({ url }); + + await browser.test.assertRejects( + browser.tabs.sendMessage(tab.id, "message"), + /Could not establish connection. Receiving end does not exist./, + "exception should be raised on tabs.sendMessage to nonexistent tab" + ); + + await browser.test.assertRejects( + browser.tabs.sendMessage(tab.id + 100, "message"), + /Could not establish connection. Receiving end does not exist./, + "exception should be raised on tabs.sendMessage to nonexistent tab" + ); + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("tabs.sendMessage"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.sendMessage"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_sharingState.js b/browser/components/extensions/test/browser/browser_ext_tabs_sharingState.js new file mode 100644 index 0000000000..47f2006307 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_sharingState.js @@ -0,0 +1,110 @@ +"use strict"; + +add_task(async function test_tabs_mediaIndicators() { + let initialTab = gBrowser.selectedTab; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/#tab-sharing" + ); + + // Ensure that the tab to hide is not selected (otherwise + // it will not be hidden because it is selected). + gBrowser.selectedTab = initialTab; + + // updateBrowserSharing is called when a request for media icons occurs. We're + // just testing that extension tabs get the info and are updated when it is + // called. + gBrowser.updateBrowserSharing(tab.linkedBrowser, { + webRTC: { + sharing: "screen", + screen: "Window", + microphone: Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED, + camera: Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED, + }, + }); + + async function background() { + let tabs = await browser.tabs.query({ url: "http://example.com/*" }); + let testTab = tabs[0]; + + browser.test.assertEq( + testTab.url, + "http://example.com/#tab-sharing", + "Got the expected tab url" + ); + + browser.test.assertFalse(testTab.active, "test tab should not be selected"); + + let state = testTab.sharingState; + browser.test.assertTrue(state.camera, "sharing camera was turned on"); + browser.test.assertTrue(state.microphone, "sharing mic was turned on"); + browser.test.assertEq(state.screen, "Window", "sharing screen is window"); + + tabs = await browser.tabs.query({ screen: true }); + browser.test.assertEq(tabs.length, 1, "screen sharing tab was found"); + + tabs = await browser.tabs.query({ screen: "Window" }); + browser.test.assertEq( + tabs.length, + 1, + "screen sharing (window) tab was found" + ); + + tabs = await browser.tabs.query({ screen: "Screen" }); + browser.test.assertEq(tabs.length, 0, "screen sharing tab was not found"); + + // Verify we cannot hide a sharing tab. + let hidden = await browser.tabs.hide(testTab.id); + browser.test.assertEq(hidden.length, 0, "unable to hide sharing tab"); + tabs = await browser.tabs.query({ hidden: true }); + browser.test.assertEq(tabs.length, 0, "unable to hide sharing tab"); + + browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + if (testTab.id !== tabId) { + return; + } + let state = changeInfo.sharingState; + + // Ignore tab update events unrelated to the sharing state. + if (!state) { + return; + } + + browser.test.assertFalse(state.camera, "sharing camera was turned off"); + browser.test.assertFalse(state.microphone, "sharing mic was turned off"); + browser.test.assertFalse(state.screen, "sharing screen was turned off"); + + // Verify we can hide the tab once it is not shared over webRTC anymore. + let hidden = await browser.tabs.hide(testTab.id); + browser.test.assertEq(hidden.length, 1, "tab hidden successfully"); + tabs = await browser.tabs.query({ hidden: true }); + browser.test.assertEq(tabs.length, 1, "hidden tab found"); + + browser.test.notifyPass("done"); + }); + browser.test.sendMessage("ready"); + } + + let extdata = { + manifest: { permissions: ["tabs", "tabHide"] }, + useAddonManager: "temporary", + background, + }; + let extension = ExtensionTestUtils.loadExtension(extdata); + await extension.startup(); + + // Test that onUpdated is called after the sharing state is changed from + // chrome code. + await extension.awaitMessage("ready"); + + info("Updating browser sharing on the test tab"); + + // Clear only the webRTC part of the browser sharing state + // (used to test Bug 1577480 regression fix). + gBrowser.updateBrowserSharing(tab.linkedBrowser, { webRTC: null }); + + await extension.awaitFinish("done"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_successors.js b/browser/components/extensions/test/browser/browser_ext_tabs_successors.js new file mode 100644 index 0000000000..77549c44d5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_successors.js @@ -0,0 +1,396 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function background(tabCount, testFn) { + try { + const { TAB_ID_NONE } = browser.tabs; + const tabIds = await Promise.all( + Array.from({ length: tabCount }, () => + browser.tabs.create({ url: "about:blank" }).then(t => t.id) + ) + ); + + const toTabIds = i => tabIds[i]; + + const setSuccessors = mapping => + Promise.all( + mapping.map((succ, i) => + browser.tabs.update(tabIds[i], { successorTabId: tabIds[succ] }) + ) + ); + + const verifySuccessors = async function (mapping, name) { + const promises = [], + expected = []; + for (let i = 0; i < mapping.length; i++) { + if (mapping[i] !== undefined) { + promises.push( + browser.tabs.get(tabIds[i]).then(t => t.successorTabId) + ); + expected.push( + mapping[i] === TAB_ID_NONE ? TAB_ID_NONE : tabIds[mapping[i]] + ); + } + } + const results = await Promise.all(promises); + for (let i = 0; i < results.length; i++) { + browser.test.assertEq( + expected[i], + results[i], + `${name}: successorTabId of tab ${i} in mapping should be ${expected[i]}` + ); + } + }; + + await testFn({ + TAB_ID_NONE, + tabIds, + toTabIds, + setSuccessors, + verifySuccessors, + }); + + browser.test.notifyPass("background-script"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("background-script"); + } +} + +async function runTabTest(tabCount, testFn) { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background: `(${background})(${tabCount}, ${testFn});`, + }); + + await extension.startup(); + await extension.awaitFinish("background-script"); + await extension.unload(); +} + +add_task(function testTabSuccessors() { + return runTabTest(3, async function ({ TAB_ID_NONE, tabIds }) { + const anotherWindow = await browser.windows.create({ url: "about:blank" }); + + browser.test.assertEq( + TAB_ID_NONE, + (await browser.tabs.get(tabIds[0])).successorTabId, + "Tabs default to an undefined successor" + ); + + // Basic getting and setting + + await browser.tabs.update(tabIds[0], { successorTabId: tabIds[1] }); + browser.test.assertEq( + tabIds[1], + (await browser.tabs.get(tabIds[0])).successorTabId, + "tabs.update assigned the correct successor" + ); + + await browser.tabs.update(tabIds[0], { + successorTabId: browser.tabs.TAB_ID_NONE, + }); + browser.test.assertEq( + TAB_ID_NONE, + (await browser.tabs.get(tabIds[0])).successorTabId, + "tabs.update cleared successor" + ); + + await browser.tabs.update(tabIds[0], { successorTabId: tabIds[1] }); + await browser.tabs.update(tabIds[0], { successorTabId: tabIds[0] }); + browser.test.assertEq( + TAB_ID_NONE, + (await browser.tabs.get(tabIds[0])).successorTabId, + "Setting a tab as its own successor clears the successor instead" + ); + + // Validation tests + + await browser.test.assertRejects( + browser.tabs.update(tabIds[0], { successorTabId: 1e8 }), + /Invalid successorTabId/, + "tabs.update should throw with an invalid successor tab ID" + ); + + await browser.test.assertRejects( + browser.tabs.update(tabIds[0], { + successorTabId: anotherWindow.tabs[0].id, + }), + /Successor tab must be in the same window as the tab being updated/, + "tabs.update should throw with a successor tab ID from another window" + ); + + // Make sure the successor is truly being assigned + + await browser.tabs.update(tabIds[0], { + successorTabId: tabIds[2], + active: true, + }); + await browser.tabs.remove(tabIds[0]); + browser.test.assertEq( + tabIds[2], + (await browser.tabs.query({ active: true }))[0].id + ); + + return browser.tabs.remove([ + tabIds[1], + tabIds[2], + anotherWindow.tabs[0].id, + ]); + }); +}); + +add_task(function testMoveInSuccession_appendFalse() { + return runTabTest( + 8, + async function ({ + TAB_ID_NONE, + tabIds, + toTabIds, + setSuccessors, + verifySuccessors, + }) { + await browser.tabs.moveInSuccession([1, 0].map(toTabIds), tabIds[0]); + await verifySuccessors([TAB_ID_NONE, 0], "scenario 1"); + + await browser.tabs.moveInSuccession( + [0, 1, 2, 3].map(toTabIds), + tabIds[0] + ); + await verifySuccessors([1, 2, 3, 0], "scenario 2"); + + await browser.tabs.moveInSuccession([1, 0].map(toTabIds), tabIds[0]); + await verifySuccessors( + [TAB_ID_NONE, 0], + "scenario 1 after tab 0 has a successor" + ); + + await browser.tabs.update(tabIds[7], { successorTabId: tabIds[0] }); + await browser.tabs.moveInSuccession([4, 5, 6, 7].map(toTabIds)); + await verifySuccessors( + new Array(4).concat([5, 6, 7, TAB_ID_NONE]), + "scenario 4" + ); + + await setSuccessors([7, 2, 3, 4, 3, 6, 7, 5]); + await browser.tabs.moveInSuccession( + [4, 6, 3, 2].map(toTabIds), + tabIds[7] + ); + await verifySuccessors([7, TAB_ID_NONE, 7, 2, 6, 7, 3, 5], "scenario 5"); + + await setSuccessors([7, 2, 3, 4, 3, 6, 7, 5]); + await browser.tabs.moveInSuccession( + [4, 6, 3, 2].map(toTabIds), + tabIds[7], + { + insert: true, + } + ); + await verifySuccessors( + [4, TAB_ID_NONE, 7, 2, 6, 4, 3, 5], + "insert = true" + ); + + await setSuccessors([1, 2, 3, 4, 0]); + await browser.tabs.moveInSuccession([3, 1, 2].map(toTabIds), tabIds[0], { + insert: true, + }); + await verifySuccessors([4, 2, 0, 1, 3], "insert = true, part 2"); + + await browser.tabs.moveInSuccession([ + tabIds[0], + tabIds[1], + 1e8, + tabIds[2], + ]); + await verifySuccessors([1, 2, TAB_ID_NONE], "unknown tab ID"); + + browser.test.assertTrue( + await browser.tabs.moveInSuccession([1e8]).then( + () => true, + () => false + ), + "When all tab IDs are unknown, tabs.moveInSuccession should not throw" + ); + + // Validation tests + + await browser.test.assertRejects( + browser.tabs.moveInSuccession([tabIds[0], tabIds[1], tabIds[0]]), + /IDs must not occur more than once in tabIds/, + "tabs.moveInSuccession should throw when a tab is referenced more than once in tabIds" + ); + + await browser.test.assertRejects( + browser.tabs.moveInSuccession([tabIds[0], tabIds[1]], tabIds[0], { + insert: true, + }), + /Value of tabId must not occur in tabIds if append or insert is true/, + "tabs.moveInSuccession should throw when tabId occurs in tabIds and insert is true" + ); + + return browser.tabs.remove(tabIds); + } + ); +}); + +add_task(function testMoveInSuccession_appendTrue() { + return runTabTest( + 8, + async function ({ + TAB_ID_NONE, + tabIds, + toTabIds, + setSuccessors, + verifySuccessors, + }) { + await browser.tabs.moveInSuccession([1].map(toTabIds), tabIds[0], { + append: true, + }); + await verifySuccessors([1, TAB_ID_NONE], "scenario 1"); + + await browser.tabs.update(tabIds[3], { successorTabId: tabIds[4] }); + await browser.tabs.moveInSuccession([1, 2, 3].map(toTabIds), tabIds[0], { + append: true, + }); + await verifySuccessors([1, 2, 3, TAB_ID_NONE], "scenario 2"); + + await browser.tabs.update(tabIds[0], { successorTabId: tabIds[1] }); + await browser.tabs.moveInSuccession([1e8], tabIds[0], { append: true }); + browser.test.assertEq( + TAB_ID_NONE, + (await browser.tabs.get(tabIds[0])).successorTabId, + "If no tabs get appended after the reference tab, it should lose its successor" + ); + + await setSuccessors([7, 2, 3, 4, 3, 6, 7, 5]); + await browser.tabs.moveInSuccession( + [4, 6, 3, 2].map(toTabIds), + tabIds[7], + { + append: true, + } + ); + await verifySuccessors( + [7, TAB_ID_NONE, TAB_ID_NONE, 2, 6, 7, 3, 4], + "scenario 3" + ); + + await setSuccessors([7, 2, 3, 4, 3, 6, 7, 5]); + await browser.tabs.moveInSuccession( + [4, 6, 3, 2].map(toTabIds), + tabIds[7], + { + append: true, + insert: true, + } + ); + await verifySuccessors( + [7, TAB_ID_NONE, 5, 2, 6, 7, 3, 4], + "insert = true" + ); + + await browser.tabs.moveInSuccession([0, 4].map(toTabIds), tabIds[7], { + append: true, + insert: true, + }); + await verifySuccessors( + [4, undefined, undefined, undefined, 6, undefined, undefined, 0], + "insert = true, part 2" + ); + + await setSuccessors([1, 2, 3, 4, 0]); + await browser.tabs.moveInSuccession([3, 1, 2].map(toTabIds), tabIds[0], { + append: true, + insert: true, + }); + await verifySuccessors([3, 2, 4, 1, 0], "insert = true, part 3"); + + await browser.tabs.update(tabIds[0], { successorTabId: tabIds[1] }); + await browser.tabs.moveInSuccession([1e8], tabIds[0], { + append: true, + insert: true, + }); + browser.test.assertEq( + tabIds[1], + (await browser.tabs.get(tabIds[0])).successorTabId, + "If no tabs get inserted after the reference tab, it should keep its successor" + ); + + // Validation tests + + await browser.test.assertRejects( + browser.tabs.moveInSuccession([tabIds[0], tabIds[1]], tabIds[0], { + append: true, + }), + /Value of tabId must not occur in tabIds if append or insert is true/, + "tabs.moveInSuccession should throw when tabId occurs in tabIds and insert is true" + ); + + return browser.tabs.remove(tabIds); + } + ); +}); + +add_task(function testMoveInSuccession_ignoreTabsInOtherWindows() { + return runTabTest( + 2, + async function ({ + TAB_ID_NONE, + tabIds, + toTabIds, + setSuccessors, + verifySuccessors, + }) { + const anotherWindow = await browser.windows.create({ + url: Array.from({ length: 3 }, () => "about:blank"), + }); + tabIds.push(...anotherWindow.tabs.map(t => t.id)); + + await setSuccessors([1, 0, 3, 4, 2]); + await browser.tabs.moveInSuccession([1, 3, 2].map(toTabIds), tabIds[4]); + await verifySuccessors( + [1, 0, 4, 2, TAB_ID_NONE], + "first tab in another window" + ); + + await setSuccessors([1, 0, 3, 4, 2]); + await browser.tabs.moveInSuccession([3, 1, 2].map(toTabIds), tabIds[4]); + await verifySuccessors( + [1, 0, 4, 2, TAB_ID_NONE], + "middle tab in another window" + ); + + await setSuccessors([1, 0, 3, 4, 2]); + await browser.tabs.moveInSuccession([3, 1, 2].map(toTabIds)); + await verifySuccessors( + [1, 0, TAB_ID_NONE, 2, TAB_ID_NONE], + "using the first tab to determine the window" + ); + + await setSuccessors([1, 0, 3, 4, 2]); + await browser.tabs.moveInSuccession([1, 3, 2].map(toTabIds), tabIds[4], { + append: true, + }); + await verifySuccessors( + [1, 0, TAB_ID_NONE, 2, 3], + "first tab in another window, appending" + ); + + await setSuccessors([1, 0, 3, 4, 2]); + await browser.tabs.moveInSuccession([3, 1, 2].map(toTabIds), tabIds[4], { + append: true, + }); + await verifySuccessors( + [1, 0, TAB_ID_NONE, 2, 3], + "middle tab in another window, appending" + ); + + return browser.tabs.remove(tabIds); + } + ); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_update.js b/browser/components/extensions/test/browser/browser_ext_tabs_update.js new file mode 100644 index 0000000000..7b16b24225 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_update.js @@ -0,0 +1,54 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:robots" + ); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:config" + ); + + gBrowser.selectedTab = tab1; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: function () { + browser.tabs.query( + { + lastFocusedWindow: true, + }, + function (tabs) { + browser.test.assertEq(tabs.length, 3, "should have three tabs"); + + tabs.sort((tab1, tab2) => tab1.index - tab2.index); + + browser.test.assertEq(tabs[0].url, "about:blank", "first tab blank"); + tabs.shift(); + + browser.test.assertTrue(tabs[0].active, "tab 0 active"); + browser.test.assertFalse(tabs[1].active, "tab 1 inactive"); + + browser.tabs.update(tabs[1].id, { active: true }, function () { + browser.test.sendMessage("check"); + }); + } + ); + }, + }); + + await Promise.all([extension.startup(), extension.awaitMessage("check")]); + + Assert.equal(gBrowser.selectedTab, tab2, "correct tab selected"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_update_highlighted.js b/browser/components/extensions/test/browser/browser_ext_tabs_update_highlighted.js new file mode 100644 index 0000000000..0adb05e827 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_update_highlighted.js @@ -0,0 +1,183 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_update_highlighted() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + const trackedEvents = ["onActivated", "onHighlighted"]; + async function expectResults(fn, action) { + let resolve; + let reject; + let promise = new Promise((...args) => { + [resolve, reject] = args; + }); + let expectedEvents; + let events = []; + let listeners = {}; + for (let trackedEvent of trackedEvents) { + listeners[trackedEvent] = data => { + events.push([trackedEvent, data]); + if (expectedEvents && expectedEvents.length >= events.length) { + resolve(); + } + }; + browser.tabs[trackedEvent].addListener(listeners[trackedEvent]); + } + let expectedData = await fn(); + let expectedHighlighted = expectedData.highlighted; + let expectedActive = expectedData.active; + expectedEvents = expectedData.events; + if (events.length < expectedEvents.length) { + // Wait up to 1000 ms for the expected number of events. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(reject, 1000); + await promise.catch(() => { + let numMissing = expectedEvents.length - events.length; + browser.test.fail(`${numMissing} missing events when ${action}`); + }); + } + let [{ id: active }] = await browser.tabs.query({ active: true }); + browser.test.assertEq( + expectedActive, + active, + `The expected tab is active when ${action}` + ); + let highlighted = (await browser.tabs.query({ highlighted: true })).map( + ({ id }) => id + ); + browser.test.assertEq( + JSON.stringify(expectedHighlighted), + JSON.stringify(highlighted), + `The expected tabs are highlighted when ${action}` + ); + let unexpectedEvents = events.splice(expectedEvents.length); + browser.test.assertEq( + JSON.stringify(expectedEvents), + JSON.stringify(events), + `Should get expected events when ${action}` + ); + if (unexpectedEvents.length) { + browser.test.fail( + `${unexpectedEvents.length} unexpected events when ${action}: ` + + JSON.stringify(unexpectedEvents) + ); + } + for (let trackedEvent of trackedEvents) { + browser.tabs[trackedEvent].removeListener(listeners[trackedEvent]); + } + } + + let { id: windowId } = await browser.windows.getCurrent(); + let { id: tab1 } = await browser.tabs.create({ url: "about:blank?1" }); + let { id: tab2 } = await browser.tabs.create({ + url: "about:blank?2", + active: true, + }); + + await expectResults(async () => { + await browser.tabs.update(tab2, { highlighted: true }); + return { active: tab2, highlighted: [tab2], events: [] }; + }, "highlighting active tab"); + + await expectResults(async () => { + await browser.tabs.update(tab2, { highlighted: false }); + return { active: tab2, highlighted: [tab2], events: [] }; + }, "unhighlighting active tab with no multiselection"); + + await expectResults(async () => { + await browser.tabs.update(tab1, { highlighted: true }); + return { + active: tab1, + highlighted: [tab1, tab2], + events: [ + ["onActivated", { tabId: tab1, previousTabId: tab2, windowId }], + ["onHighlighted", { tabIds: [tab1, tab2], windowId }], + ], + }; + }, "highlighting non-highlighted tab"); + + await expectResults(async () => { + await browser.tabs.update(tab2, { highlighted: true }); + return { active: tab1, highlighted: [tab1, tab2], events: [] }; + }, "highlighting inactive highlighted tab"); + + await expectResults(async () => { + await browser.tabs.update(tab1, { highlighted: false }); + return { + active: tab2, + highlighted: [tab2], + events: [ + ["onActivated", { tabId: tab2, previousTabId: tab1, windowId }], + ["onHighlighted", { tabIds: [tab2], windowId }], + ], + }; + }, "unhighlighting active tab with multiselection"); + + await expectResults(async () => { + await browser.tabs.update(tab1, { highlighted: true }); + return { + active: tab1, + highlighted: [tab1, tab2], + events: [ + ["onActivated", { tabId: tab1, previousTabId: tab2, windowId }], + ["onHighlighted", { tabIds: [tab1, tab2], windowId }], + ], + }; + }, "highlighting non-highlighted tab"); + + await expectResults(async () => { + await browser.tabs.update(tab2, { highlighted: false }); + return { + active: tab1, + highlighted: [tab1], + events: [["onHighlighted", { tabIds: [tab1], windowId }]], + }; + }, "unhighlighting inactive highlighted tab"); + + await expectResults(async () => { + await browser.tabs.update(tab2, { highlighted: true, active: false }); + return { + active: tab1, + highlighted: [tab1, tab2], + events: [["onHighlighted", { tabIds: [tab1, tab2], windowId }]], + }; + }, "highlighting without activating non-highlighted tab"); + + await expectResults(async () => { + await browser.tabs.update(tab2, { highlighted: true, active: true }); + return { + active: tab2, + highlighted: [tab2], + events: [ + ["onActivated", { tabId: tab2, previousTabId: tab1, windowId }], + ["onHighlighted", { tabIds: [tab2], windowId }], + ], + }; + }, "highlighting and activating inactive highlighted tab"); + + await expectResults(async () => { + await browser.tabs.update(tab1, { active: true, highlighted: true }); + return { + active: tab1, + highlighted: [tab1], + events: [ + ["onActivated", { tabId: tab1, previousTabId: tab2, windowId }], + ["onHighlighted", { tabIds: [tab1], windowId }], + ], + }; + }, "highlighting and activating non-highlighted tab"); + + await browser.tabs.remove([tab1, tab2]); + browser.test.notifyPass("test-finished"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("test-finished"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_update_url.js b/browser/components/extensions/test/browser/browser_ext_tabs_update_url.js new file mode 100644 index 0000000000..f34a97c047 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_update_url.js @@ -0,0 +1,235 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs", +}); + +async function testTabsUpdateURL( + existentTabURL, + tabsUpdateURL, + isErrorExpected +) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + files: { + "tab.html": ` + + + + + + +

    tab page

    + + + `.trim(), + }, + background: function () { + browser.test.sendMessage("ready", browser.runtime.getURL("tab.html")); + + browser.test.onMessage.addListener( + async (msg, tabsUpdateURL, isErrorExpected) => { + let tabs = await browser.tabs.query({ lastFocusedWindow: true }); + + try { + let tab = await browser.tabs.update(tabs[1].id, { + url: tabsUpdateURL, + }); + + browser.test.assertFalse( + isErrorExpected, + `tabs.update with URL ${tabsUpdateURL} should be rejected` + ); + browser.test.assertTrue( + tab, + "on success the tab should be defined" + ); + } catch (error) { + browser.test.assertTrue( + isErrorExpected, + `tabs.update with URL ${tabsUpdateURL} should not be rejected` + ); + browser.test.assertTrue( + /^Illegal URL/.test(error.message), + "tabs.update should be rejected with the expected error message" + ); + } + + browser.test.sendMessage("done"); + } + ); + }, + }); + + await extension.startup(); + + let mozExtTabURL = await extension.awaitMessage("ready"); + + if (tabsUpdateURL == "self") { + tabsUpdateURL = mozExtTabURL; + } + + info(`tab.update URL "${tabsUpdateURL}" on tab with URL "${existentTabURL}"`); + + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + existentTabURL + ); + + extension.sendMessage("start", tabsUpdateURL, isErrorExpected); + await extension.awaitMessage("done"); + + BrowserTestUtils.removeTab(tab1); + await extension.unload(); +} + +add_task(async function () { + info("Start testing tabs.update on javascript URLs"); + + let dataURLPage = `data:text/html, + + + + + + +

    data url page

    + + `; + + let checkList = [ + { + tabsUpdateURL: "http://example.net", + isErrorExpected: false, + }, + { + tabsUpdateURL: "self", + isErrorExpected: false, + }, + { + tabsUpdateURL: "about:addons", + isErrorExpected: true, + }, + { + tabsUpdateURL: "javascript:console.log('tabs.update execute javascript')", + isErrorExpected: true, + }, + { + tabsUpdateURL: dataURLPage, + isErrorExpected: true, + }, + ]; + + let testCases = checkList.map(check => + Object.assign({}, check, { existentTabURL: "about:blank" }) + ); + + for (let { existentTabURL, tabsUpdateURL, isErrorExpected } of testCases) { + await testTabsUpdateURL(existentTabURL, tabsUpdateURL, isErrorExpected); + } + + info("done"); +}); + +add_task(async function test_update_reload() { + const URL = "https://example.com/"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background() { + browser.test.onMessage.addListener(async (cmd, ...args) => { + const result = await browser.tabs[cmd](...args); + browser.test.sendMessage("result", result); + }); + + const filter = { + properties: ["status"], + }; + + browser.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (changeInfo.status === "complete") { + browser.test.sendMessage("historyAdded"); + } + }, filter); + }, + }); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + let tabBrowser = win.gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(tabBrowser, URL); + await BrowserTestUtils.browserLoaded(tabBrowser, false, URL); + let tab = win.gBrowser.selectedTab; + + async function getTabHistory() { + await TabStateFlusher.flush(tabBrowser); + return JSON.parse(SessionStore.getTabState(tab)); + } + + await extension.startup(); + extension.sendMessage("query", { url: URL }); + let tabs = await extension.awaitMessage("result"); + let tabId = tabs[0].id; + + let history = await getTabHistory(); + is( + history.entries.length, + 1, + "Tab history contains the expected number of entries." + ); + is( + history.entries[0].url, + URL, + `Tab history contains the expected entry: URL.` + ); + + extension.sendMessage("update", tabId, { url: `${URL}1/` }); + await Promise.all([ + extension.awaitMessage("result"), + extension.awaitMessage("historyAdded"), + ]); + + history = await getTabHistory(); + is( + history.entries.length, + 2, + "Tab history contains the expected number of entries." + ); + is( + history.entries[1].url, + `${URL}1/`, + `Tab history contains the expected entry: ${URL}1/.` + ); + + extension.sendMessage("update", tabId, { + url: `${URL}2/`, + loadReplace: true, + }); + await Promise.all([ + extension.awaitMessage("result"), + extension.awaitMessage("historyAdded"), + ]); + + history = await getTabHistory(); + is( + history.entries.length, + 2, + "Tab history contains the expected number of entries." + ); + is( + history.entries[1].url, + `${URL}2/`, + `Tab history contains the expected entry: ${URL}2/.` + ); + + await extension.unload(); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_warmup.js b/browser/components/extensions/test/browser/browser_ext_tabs_warmup.js new file mode 100644 index 0000000000..e9a5382de8 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_warmup.js @@ -0,0 +1,40 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testWarmupTab() { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.net/" + ); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + Assert.ok(!tab1.linkedBrowser.renderLayers, "tab is not warm yet"); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let backgroundTab = ( + await browser.tabs.query({ + lastFocusedWindow: true, + url: "http://example.net/", + active: false, + }) + )[0]; + await browser.tabs.warmup(backgroundTab.id); + browser.test.notifyPass("tabs.warmup"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.warmup"); + Assert.ok(tab1.linkedBrowser.renderLayers, "tab has been warmed up"); + gBrowser.removeTab(tab1); + gBrowser.removeTab(tab2); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_zoom.js b/browser/components/extensions/test/browser/browser_ext_tabs_zoom.js new file mode 100644 index 0000000000..5884a9163a --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_zoom.js @@ -0,0 +1,346 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const SITE_SPECIFIC_PREF = "browser.zoom.siteSpecific"; +const FULL_ZOOM_PREF = "browser.content.full-zoom"; + +let gContentPrefs = Cc["@mozilla.org/content-pref/service;1"].getService( + Ci.nsIContentPrefService2 +); + +// A single monitor for the tests. If it receives any +// incognito data in event listeners it will fail. +let monitor; +add_task(async function startup() { + monitor = await startIncognitoMonitorExtension(); +}); +registerCleanupFunction(async function finish() { + await monitor.unload(); +}); + +add_task(async function test_zoom_api() { + async function background() { + function promiseUpdated(tabId, attr) { + return new Promise(resolve => { + let onUpdated = (tabId_, changeInfo, tab) => { + if (tabId == tabId_ && attr in changeInfo) { + browser.tabs.onUpdated.removeListener(onUpdated); + + resolve({ changeInfo, tab }); + } + }; + browser.tabs.onUpdated.addListener(onUpdated); + }); + } + + let deferred = {}; + browser.test.onMessage.addListener((message, msg, result) => { + if (message == "msg-done" && deferred[msg]) { + deferred[msg].resolve(result); + } + }); + + let _id = 0; + function msg(...args) { + return new Promise((resolve, reject) => { + let id = ++_id; + deferred[id] = { resolve, reject }; + browser.test.sendMessage("msg", id, ...args); + }); + } + + let lastZoomEvent = {}; + let promiseZoomEvents = {}; + browser.tabs.onZoomChange.addListener(info => { + lastZoomEvent[info.tabId] = info; + if (promiseZoomEvents[info.tabId]) { + promiseZoomEvents[info.tabId](); + promiseZoomEvents[info.tabId] = null; + } + }); + + let awaitZoom = async (tabId, newValue) => { + let listener; + + // eslint-disable-next-line no-async-promise-executor + await new Promise(async resolve => { + listener = info => { + if (info.tabId == tabId && info.newZoomFactor == newValue) { + resolve(); + } + }; + browser.tabs.onZoomChange.addListener(listener); + + let zoomFactor = await browser.tabs.getZoom(tabId); + if (zoomFactor == newValue) { + resolve(); + } + }); + + browser.tabs.onZoomChange.removeListener(listener); + }; + + let checkZoom = async (tabId, newValue, oldValue = null) => { + let awaitEvent; + if (oldValue != null && !lastZoomEvent[tabId]) { + awaitEvent = new Promise(resolve => { + promiseZoomEvents[tabId] = resolve; + }); + } + + let [apiZoom, realZoom] = await Promise.all([ + browser.tabs.getZoom(tabId), + msg("get-zoom", tabId), + awaitEvent, + ]); + + browser.test.assertEq( + newValue, + apiZoom, + `Got expected zoom value from API` + ); + browser.test.assertEq( + newValue, + realZoom, + `Got expected zoom value from parent` + ); + + if (oldValue != null) { + let event = lastZoomEvent[tabId]; + lastZoomEvent[tabId] = null; + browser.test.assertEq( + tabId, + event.tabId, + `Got expected zoom event tab ID` + ); + browser.test.assertEq( + newValue, + event.newZoomFactor, + `Got expected zoom event zoom factor` + ); + browser.test.assertEq( + oldValue, + event.oldZoomFactor, + `Got expected zoom event old zoom factor` + ); + + browser.test.assertEq( + 3, + Object.keys(event.zoomSettings).length, + `Zoom settings should have 3 keys` + ); + browser.test.assertEq( + "automatic", + event.zoomSettings.mode, + `Mode should be "automatic"` + ); + browser.test.assertEq( + "per-origin", + event.zoomSettings.scope, + `Scope should be "per-origin"` + ); + browser.test.assertEq( + 1, + event.zoomSettings.defaultZoomFactor, + `Default zoom should be 1` + ); + } + }; + + try { + let tabs = await browser.tabs.query({}); + browser.test.assertEq(tabs.length, 4, "We have 4 tabs"); + + let tabIds = tabs.splice(1).map(tab => tab.id); + await checkZoom(tabIds[0], 1); + + await browser.tabs.setZoom(tabIds[0], 2); + await checkZoom(tabIds[0], 2, 1); + + let zoomSettings = await browser.tabs.getZoomSettings(tabIds[0]); + browser.test.assertEq( + 3, + Object.keys(zoomSettings).length, + `Zoom settings should have 3 keys` + ); + browser.test.assertEq( + "automatic", + zoomSettings.mode, + `Mode should be "automatic"` + ); + browser.test.assertEq( + "per-origin", + zoomSettings.scope, + `Scope should be "per-origin"` + ); + browser.test.assertEq( + 1, + zoomSettings.defaultZoomFactor, + `Default zoom should be 1` + ); + + browser.test.log(`Switch to tab 2`); + await browser.tabs.update(tabIds[1], { active: true }); + await checkZoom(tabIds[1], 1); + + browser.test.log(`Navigate tab 2 to origin of tab 1`); + browser.tabs.update(tabIds[1], { url: "http://example.com" }); + await promiseUpdated(tabIds[1], "url"); + await checkZoom(tabIds[1], 2, 1); + + browser.test.log(`Update zoom in tab 2, expect changes in both tabs`); + await browser.tabs.setZoom(tabIds[1], 1.5); + await checkZoom(tabIds[1], 1.5, 2); + + browser.test.log(`Switch to tab 3, expect zoom to affect private window`); + await browser.tabs.setZoom(tabIds[2], 3); + await checkZoom(tabIds[2], 3, 1); + + browser.test.log( + `Switch to tab 1, expect asynchronous zoom change just after the switch` + ); + await Promise.all([ + awaitZoom(tabIds[0], 1.5), + browser.tabs.update(tabIds[0], { active: true }), + ]); + await checkZoom(tabIds[0], 1.5, 2); + + browser.test.log("Set zoom to 0, expect it set to 1"); + await browser.tabs.setZoom(tabIds[0], 0); + await checkZoom(tabIds[0], 1, 1.5); + + browser.test.log("Change zoom externally, expect changes reflected"); + await msg("enlarge"); + await checkZoom(tabIds[0], 1.1, 1); + + await Promise.all([ + browser.tabs.setZoom(tabIds[0], 0), + browser.tabs.setZoom(tabIds[1], 0), + browser.tabs.setZoom(tabIds[2], 0), + ]); + await Promise.all([ + checkZoom(tabIds[0], 1, 1.1), + checkZoom(tabIds[1], 1, 1.5), + checkZoom(tabIds[2], 1, 3), + ]); + + browser.test.log("Check that invalid zoom values throw an error"); + await browser.test.assertRejects( + browser.tabs.setZoom(tabIds[0], 42), + /Zoom value 42 out of range/, + "Expected an out of range error" + ); + + browser.test.log("Disable site-specific zoom, expect correct scope"); + await msg("site-specific", false); + zoomSettings = await browser.tabs.getZoomSettings(tabIds[0]); + + browser.test.assertEq( + "per-tab", + zoomSettings.scope, + `Scope should be "per-tab"` + ); + + await msg("site-specific", null); + + browser.test.onMessage.addListener(async msg => { + if (msg === "set-global-zoom-done") { + zoomSettings = await browser.tabs.getZoomSettings(tabIds[0]); + + browser.test.assertEq( + 5, + zoomSettings.defaultZoomFactor, + `Default zoom should be 5 after being changed` + ); + + browser.test.notifyPass("tab-zoom"); + } + }); + await msg("set-global-zoom"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("tab-zoom"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + incognitoOverride: "spanning", + background, + }); + + extension.onMessage("msg", (id, msg, ...args) => { + const { + Management: { + global: { tabTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + + let resp; + if (msg == "get-zoom") { + let tab = tabTracker.getTab(args[0]); + resp = ZoomManager.getZoomForBrowser(tab.linkedBrowser); + } else if (msg == "set-zoom") { + let tab = tabTracker.getTab(args[0]); + ZoomManager.setZoomForBrowser(tab.linkedBrowser); + } else if (msg == "set-global-zoom") { + resp = gContentPrefs.setGlobal( + FULL_ZOOM_PREF, + 5, + Cu.createLoadContext(), + { + handleCompletion() { + extension.sendMessage("set-global-zoom-done", id, resp); + }, + } + ); + } else if (msg == "enlarge") { + FullZoom.enlarge(); + } else if (msg == "site-specific") { + if (args[0] == null) { + SpecialPowers.clearUserPref(SITE_SPECIFIC_PREF); + } else { + SpecialPowers.setBoolPref(SITE_SPECIFIC_PREF, args[0]); + } + } + + extension.sendMessage("msg-done", id, resp); + }); + + let url = "https://example.com/"; + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.org/" + ); + + let privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + let selectedBrowser = privateWindow.gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(selectedBrowser, url); + await BrowserTestUtils.browserLoaded(selectedBrowser, false, url); + + gBrowser.selectedTab = tab1; + + await extension.startup(); + + await extension.awaitFinish("tab-zoom"); + + await extension.unload(); + + await new Promise(resolve => { + gContentPrefs.setGlobal(FULL_ZOOM_PREF, null, Cu.createLoadContext(), { + handleCompletion() { + resolve(); + }, + }); + }); + + privateWindow.close(); + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_themes_validation.js b/browser/components/extensions/test/browser/browser_ext_themes_validation.js new file mode 100644 index 0000000000..c004363a6b --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_themes_validation.js @@ -0,0 +1,55 @@ +"use strict"; + +PromiseTestUtils.allowMatchingRejectionsGlobally(/packaging errors/); + +/** + * Helper function for testing a theme with invalid properties. + * + * @param {object} invalidProps The invalid properties to load the theme with. + */ +async function testThemeWithInvalidProperties(invalidProps) { + let manifest = { + theme: {}, + }; + + invalidProps.forEach(prop => { + // Some properties require additional information: + switch (prop) { + case "background": + manifest[prop] = { scripts: ["background.js"] }; + break; + case "permissions": + manifest[prop] = ["tabs"]; + break; + case "omnibox": + manifest[prop] = { keyword: "test" }; + break; + default: + manifest[prop] = {}; + } + }); + + let extension = ExtensionTestUtils.loadExtension({ manifest }); + await Assert.rejects( + extension.startup(), + /startup failed/, + "Theme should fail to load if it contains invalid properties" + ); +} + +add_task( + async function test_that_theme_with_invalid_properties_fails_to_load() { + let invalidProps = [ + "page_action", + "browser_action", + "background", + "permissions", + "omnibox", + "commands", + ]; + for (let prop in invalidProps) { + await testThemeWithInvalidProperties([prop]); + } + await testThemeWithInvalidProperties(invalidProps); + } +); diff --git a/browser/components/extensions/test/browser/browser_ext_topSites.js b/browser/components/extensions/test/browser/browser_ext_topSites.js new file mode 100644 index 0000000000..fe0edf2b8d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_topSites.js @@ -0,0 +1,413 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { PlacesTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PlacesTestUtils.sys.mjs" +); + +const { + ExtensionUtils: { makeDataURI }, +} = ChromeUtils.importESModule("resource://gre/modules/ExtensionUtils.sys.mjs"); + +// A small 1x1 test png +const IMAGE_1x1 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="; + +async function updateTopSites(condition) { + // Toggle the pref to clear the feed cache and force an update. + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.newtabpage.activity-stream.feeds.system.topsites", false], + ["browser.newtabpage.activity-stream.feeds.system.topsites", true], + ], + }); + + // Wait for the feed to be updated. + await TestUtils.waitForCondition(() => { + let sites = AboutNewTab.getTopSites(); + return condition(sites); + }, "Waiting for top sites to be updated"); +} + +async function loadExtension() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["topSites"], + }, + background() { + browser.test.onMessage.addListener(async options => { + let sites = await browser.topSites.get(options); + browser.test.sendMessage("sites", sites); + }); + }, + }); + await extension.startup(); + return extension; +} + +async function getSites(extension, options) { + extension.sendMessage(options); + return extension.awaitMessage("sites"); +} + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + await SpecialPowers.pushPrefEnv({ + set: [ + // The pref for TopSites is empty by default. + [ + "browser.newtabpage.activity-stream.default.sites", + "https://www.youtube.com/,https://www.facebook.com/,https://www.amazon.com/,https://www.reddit.com/,https://www.wikipedia.org/,https://twitter.com/", + ], + // Toggle the feed off and on as a workaround to read the new prefs. + ["browser.newtabpage.activity-stream.feeds.system.topsites", false], + ["browser.newtabpage.activity-stream.feeds.system.topsites", true], + [ + "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts", + true, + ], + ], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:newtab", + false + ); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(tab); + }); +}); + +// Tests newtab links with an empty history. +add_task(async function test_topSites_newtab_emptyHistory() { + let extension = await loadExtension(); + + let expectedResults = [ + { + type: "search", + url: "https://amazon.com", + title: "@amazon", + favicon: null, + }, + { + type: "url", + url: "https://www.youtube.com/", + title: "youtube", + favicon: null, + }, + { + type: "url", + url: "https://www.facebook.com/", + title: "facebook", + favicon: null, + }, + { + type: "url", + url: "https://www.reddit.com/", + title: "reddit", + favicon: null, + }, + { + type: "url", + url: "https://www.wikipedia.org/", + title: "wikipedia", + favicon: null, + }, + { + type: "url", + url: "https://twitter.com/", + title: "twitter", + favicon: null, + }, + ]; + + Assert.deepEqual( + expectedResults, + await getSites(extension, { newtab: true }), + "got topSites newtab links" + ); + + await extension.unload(); +}); + +// Tests newtab links with some visits. +add_task(async function test_topSites_newtab_visits() { + let extension = await loadExtension(); + + // Add some visits to a couple of URLs. We need to add at least two visits + // per URL for it to show up. Add some extra to be safe, and add one more to + // the first so that its frecency is larger. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([ + "http://example-1.com/", + "http://example-2.com/", + ]); + } + await PlacesTestUtils.addVisits("http://example-1.com/"); + + // Wait for example-1.com to be listed second, after the Amazon search link. + await updateTopSites(sites => { + return sites && sites[1] && sites[1].url == "http://example-1.com/"; + }); + + let expectedResults = [ + { + type: "search", + url: "https://amazon.com", + title: "@amazon", + favicon: null, + }, + { + type: "url", + url: "http://example-1.com/", + title: "test visit for http://example-1.com/", + favicon: null, + }, + { + type: "url", + url: "http://example-2.com/", + title: "test visit for http://example-2.com/", + favicon: null, + }, + { + type: "url", + url: "https://www.youtube.com/", + title: "youtube", + favicon: null, + }, + { + type: "url", + url: "https://www.facebook.com/", + title: "facebook", + favicon: null, + }, + { + type: "url", + url: "https://www.reddit.com/", + title: "reddit", + favicon: null, + }, + { + type: "url", + url: "https://www.wikipedia.org/", + title: "wikipedia", + favicon: null, + }, + { + type: "url", + url: "https://twitter.com/", + title: "twitter", + favicon: null, + }, + ]; + + Assert.deepEqual( + expectedResults, + await getSites(extension, { newtab: true }), + "got topSites newtab links" + ); + + await extension.unload(); + await PlacesUtils.history.clear(); +}); + +// Tests that the newtab parameter is ignored if newtab Top Sites are disabled. +add_task(async function test_topSites_newtab_ignored() { + let extension = await loadExtension(); + // Add some visits to a couple of URLs. We need to add at least two visits + // per URL for it to show up. Add some extra to be safe, and add one more to + // the first so that its frecency is larger. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([ + "http://example-1.com/", + "http://example-2.com/", + ]); + } + await PlacesTestUtils.addVisits("http://example-1.com/"); + + // Wait for example-1.com to be listed second, after the Amazon search link. + await updateTopSites(sites => { + return sites && sites[1] && sites[1].url == "http://example-1.com/"; + }); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.newtabpage.activity-stream.feeds.system.topsites", false]], + }); + + let expectedResults = [ + { + type: "url", + url: "http://example-1.com/", + title: "test visit for http://example-1.com/", + favicon: null, + }, + { + type: "url", + url: "http://example-2.com/", + title: "test visit for http://example-2.com/", + favicon: null, + }, + ]; + + Assert.deepEqual( + expectedResults, + await getSites(extension, { newtab: true }), + "Got top-frecency links from Places" + ); + + await SpecialPowers.popPrefEnv(); + await extension.unload(); + await PlacesUtils.history.clear(); +}); + +// Tests newtab links with some visits and favicons. +add_task(async function test_topSites_newtab_visits_favicons() { + let extension = await loadExtension(); + + // Add some visits to a couple of URLs. We need to add at least two visits + // per URL for it to show up. Add some extra to be safe, and add one more to + // the first so that its frecency is larger. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([ + "http://example-1.com/", + "http://example-2.com/", + ]); + } + await PlacesTestUtils.addVisits("http://example-1.com/"); + + // Give the first URL a favicon but not the second so that we can test links + // both with and without favicons. + let faviconData = new Map(); + faviconData.set("http://example-1.com", IMAGE_1x1); + await PlacesTestUtils.addFavicons(faviconData); + + // Wait for example-1.com to be listed second, after the Amazon search link. + await updateTopSites(sites => { + return sites && sites[1] && sites[1].url == "http://example-1.com/"; + }); + + let base = "chrome://activity-stream/content/data/content/tippytop/images/"; + + let expectedResults = [ + { + type: "search", + url: "https://amazon.com", + title: "@amazon", + favicon: await makeDataURI(`${base}amazon@2x.png`), + }, + { + type: "url", + url: "http://example-1.com/", + title: "test visit for http://example-1.com/", + favicon: IMAGE_1x1, + }, + { + type: "url", + url: "http://example-2.com/", + title: "test visit for http://example-2.com/", + favicon: null, + }, + { + type: "url", + url: "https://www.youtube.com/", + title: "youtube", + favicon: await makeDataURI(`${base}youtube-com@2x.png`), + }, + { + type: "url", + url: "https://www.facebook.com/", + title: "facebook", + favicon: await makeDataURI(`${base}facebook-com@2x.png`), + }, + { + type: "url", + url: "https://www.reddit.com/", + title: "reddit", + favicon: await makeDataURI(`${base}reddit-com@2x.png`), + }, + { + type: "url", + url: "https://www.wikipedia.org/", + title: "wikipedia", + favicon: await makeDataURI(`${base}wikipedia-org@2x.png`), + }, + { + type: "url", + url: "https://twitter.com/", + title: "twitter", + favicon: await makeDataURI(`${base}twitter-com@2x.png`), + }, + ]; + + Assert.deepEqual( + expectedResults, + await getSites(extension, { newtab: true, includeFavicon: true }), + "got topSites newtab links" + ); + + await extension.unload(); + await PlacesUtils.history.clear(); +}); + +// Tests newtab links with some visits, favicons, and the `limit` option. +add_task(async function test_topSites_newtab_visits_favicons_limit() { + let extension = await loadExtension(); + + // Add some visits to a couple of URLs. We need to add at least two visits + // per URL for it to show up. Add some extra to be safe, and add one more to + // the first so that its frecency is larger. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([ + "http://example-1.com/", + "http://example-2.com/", + ]); + } + await PlacesTestUtils.addVisits("http://example-1.com/"); + + // Give the first URL a favicon but not the second so that we can test links + // both with and without favicons. + let faviconData = new Map(); + faviconData.set("http://example-1.com", IMAGE_1x1); + await PlacesTestUtils.addFavicons(faviconData); + + // Wait for example-1.com to be listed second, after the Amazon search link. + await updateTopSites(sites => { + return sites && sites[1] && sites[1].url == "http://example-1.com/"; + }); + + let expectedResults = [ + { + type: "search", + url: "https://amazon.com", + title: "@amazon", + favicon: await makeDataURI( + "chrome://activity-stream/content/data/content/tippytop/images/amazon@2x.png" + ), + }, + { + type: "url", + url: "http://example-1.com/", + title: "test visit for http://example-1.com/", + favicon: IMAGE_1x1, + }, + { + type: "url", + url: "http://example-2.com/", + title: "test visit for http://example-2.com/", + favicon: null, + }, + ]; + + Assert.deepEqual( + expectedResults, + await getSites(extension, { newtab: true, includeFavicon: true, limit: 3 }), + "got topSites newtab links" + ); + + await extension.unload(); + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_url_overrides_newtab.js b/browser/components/extensions/test/browser/browser_ext_url_overrides_newtab.js new file mode 100644 index 0000000000..988f44bd5d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_url_overrides_newtab.js @@ -0,0 +1,794 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +requestLongerTimeout(4); + +ChromeUtils.defineESModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", + ExtensionControlledPopup: + "resource:///modules/ExtensionControlledPopup.sys.mjs", + ExtensionSettingsStore: + "resource://gre/modules/ExtensionSettingsStore.sys.mjs", +}); + +function getNotificationSetting(extensionId) { + return ExtensionSettingsStore.getSetting("newTabNotification", extensionId); +} + +function getNewTabDoorhanger() { + ExtensionControlledPopup._getAndMaybeCreatePanel(document); + return document.getElementById("extension-new-tab-notification"); +} + +function clickKeepChanges(notification) { + notification.button.click(); +} + +function clickManage(notification) { + notification.secondaryButton.click(); +} + +async function promiseNewTab(expectUrl = AboutNewTab.newTabURL, win = window) { + let eventName = "browser-open-newtab-start"; + let newTabStartPromise = new Promise(resolve => { + async function observer(subject) { + Services.obs.removeObserver(observer, eventName); + resolve(subject.wrappedJSObject); + } + Services.obs.addObserver(observer, eventName); + }); + + let newtabShown = TestUtils.waitForCondition( + () => win.gBrowser.currentURI.spec == expectUrl, + `Should open correct new tab url ${expectUrl}.` + ); + + win.BrowserOpenTab(); + const newTabCreatedPromise = newTabStartPromise; + const browser = await newTabCreatedPromise; + await newtabShown; + const tab = win.gBrowser.selectedTab; + + Assert.deepEqual( + browser, + tab.linkedBrowser, + "browser-open-newtab-start notified with the created browser" + ); + return tab; +} + +function waitForAddonDisabled(addon) { + return new Promise(resolve => { + let listener = { + onDisabled(disabledAddon) { + if (disabledAddon.id == addon.id) { + resolve(); + AddonManager.removeAddonListener(listener); + } + }, + }; + AddonManager.addAddonListener(listener); + }); +} + +function waitForAddonEnabled(addon) { + return new Promise(resolve => { + let listener = { + onEnabled(enabledAddon) { + if (enabledAddon.id == addon.id) { + AddonManager.removeAddonListener(listener); + resolve(); + } + }, + }; + AddonManager.addAddonListener(listener); + }); +} + +// Default test extension data for newtab. +const extensionData = { + manifest: { + browser_specific_settings: { + gecko: { + id: "newtaburl@mochi.test", + }, + }, + chrome_url_overrides: { + newtab: "newtab.html", + }, + }, + files: { + "newtab.html": "

    New tab!

    ", + }, + useAddonManager: "temporary", +}; + +add_task(async function test_new_tab_opens() { + let panel = getNewTabDoorhanger().closest("panel"); + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + let extensionNewTabUrl = `moz-extension://${extension.uuid}/newtab.html`; + + // Simulate opening the newtab open as a user would. + let popupShown = promisePopupShown(panel); + let tab = await promiseNewTab(extensionNewTabUrl); + await popupShown; + + // This will show a confirmation doorhanger, make sure we don't leave it open. + let popupHidden = promisePopupHidden(panel); + panel.hidePopup(); + await popupHidden; + + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); + +add_task(async function test_new_tab_ignore_settings() { + await ExtensionSettingsStore.initialize(); + let notification = getNewTabDoorhanger(); + let panel = notification.closest("panel"); + let extensionId = "newtabignore@mochi.test"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: extensionId } }, + browser_action: { + default_popup: "ignore.html", + default_area: "navbar", + }, + chrome_url_overrides: { newtab: "ignore.html" }, + }, + files: { "ignore.html": '

    New Tab!

    ' }, + useAddonManager: "temporary", + }); + + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is initially closed" + ); + + await extension.startup(); + + // Simulate opening the New Tab as a user would. + let popupShown = promisePopupShown(panel); + let tab = await promiseNewTab(); + await popupShown; + + // Ensure the doorhanger is shown and the setting isn't set yet. + is( + panel.getAttribute("panelopen"), + "true", + "The notification panel is open after opening New Tab" + ); + is(gURLBar.focused, false, "The URL bar is not focused with a doorhanger"); + is( + getNotificationSetting(extensionId), + null, + "The New Tab notification is not set for this extension" + ); + is( + panel.anchorNode.closest("toolbarbutton").id, + "newtabignore_mochi_test-BAP", + "The doorhanger is anchored to the browser action" + ); + + // Manually close the panel, as if the user ignored it. + let popupHidden = promisePopupHidden(panel); + panel.hidePopup(); + await popupHidden; + + // Ensure panel is closed and the setting still isn't set. + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is closed" + ); + is( + getNotificationSetting(extensionId), + null, + "The New Tab notification is not set after ignoring the doorhanger" + ); + + // Close the first tab and open another new tab. + BrowserTestUtils.removeTab(tab); + tab = await promiseNewTab(); + + // Verify the doorhanger is not shown a second time. + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel doesn't open after ignoring the doorhanger" + ); + is(gURLBar.focused, true, "The URL bar is focused with no doorhanger"); + + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); + +add_task(async function test_new_tab_keep_settings() { + await ExtensionSettingsStore.initialize(); + let notification = getNewTabDoorhanger(); + let panel = notification.closest("panel"); + let extensionId = "newtabkeep@mochi.test"; + let manifest = { + version: "1.0", + name: "New Tab Add-on", + browser_specific_settings: { gecko: { id: extensionId } }, + chrome_url_overrides: { newtab: "newtab.html" }, + }; + let extension = ExtensionTestUtils.loadExtension({ + ...extensionData, + manifest, + useAddonManager: "permanent", + }); + + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is initially closed" + ); + + await extension.startup(); + let extensionNewTabUrl = `moz-extension://${extension.uuid}/newtab.html`; + + // Simulate opening the New Tab as a user would. + let popupShown = promisePopupShown(panel); + let tab = await promiseNewTab(extensionNewTabUrl); + await popupShown; + + // Ensure the panel is open and the setting isn't saved yet. + is( + panel.getAttribute("panelopen"), + "true", + "The notification panel is open after opening New Tab" + ); + is( + getNotificationSetting(extensionId), + null, + "The New Tab notification is not set for this extension" + ); + is( + panel.anchorNode.closest("toolbarbutton").id, + "PanelUI-menu-button", + "The doorhanger is anchored to the menu icon" + ); + is( + panel.querySelector("#extension-new-tab-notification-description") + .textContent, + "An extension, New Tab Add-on, changed the page you see when you open a new tab.Learn more", + "The description includes the add-on name" + ); + + // Click the Keep Changes button. + let confirmationSaved = TestUtils.waitForCondition(() => { + return ExtensionSettingsStore.getSetting( + "newTabNotification", + extensionId, + extensionId + ).value; + }); + clickKeepChanges(notification); + await confirmationSaved; + + // Ensure panel is closed and setting is updated. + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is closed after click" + ); + is( + getNotificationSetting(extensionId).value, + true, + "The New Tab notification is set after keeping the changes" + ); + + // Close the first tab and open another new tab. + BrowserTestUtils.removeTab(tab); + tab = await promiseNewTab(extensionNewTabUrl); + + // Verify the doorhanger is not shown a second time. + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is not opened after keeping the changes" + ); + + BrowserTestUtils.removeTab(tab); + + let upgradedExtension = ExtensionTestUtils.loadExtension({ + ...extensionData, + manifest: Object.assign({}, manifest, { version: "2.0" }), + useAddonManager: "permanent", + }); + + await upgradedExtension.startup(); + extensionNewTabUrl = `moz-extension://${upgradedExtension.uuid}/newtab.html`; + + tab = await promiseNewTab(extensionNewTabUrl); + + // Ensure panel is closed and setting is still set. + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is closed after click" + ); + is( + getNotificationSetting(extensionId).value, + true, + "The New Tab notification is set after keeping the changes" + ); + + BrowserTestUtils.removeTab(tab); + await upgradedExtension.unload(); + await extension.unload(); + + let confirmation = ExtensionSettingsStore.getSetting( + "newTabNotification", + extensionId, + extensionId + ); + is(confirmation, null, "The confirmation has been cleaned up"); +}); + +add_task(async function test_new_tab_restore_settings() { + await ExtensionSettingsStore.initialize(); + let notification = getNewTabDoorhanger(); + let panel = notification.closest("panel"); + let extensionId = "newtabrestore@mochi.test"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: extensionId } }, + chrome_url_overrides: { newtab: "restore.html" }, + }, + files: { "restore.html": '

    New Tab!

    ' }, + useAddonManager: "temporary", + }); + + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is initially closed" + ); + is( + getNotificationSetting(extensionId), + null, + "The New Tab notification is not initially set for this extension" + ); + + await extension.startup(); + + // Simulate opening the newtab open as a user would. + let popupShown = promisePopupShown(panel); + let tab = await promiseNewTab(); + await popupShown; + + // Verify that the panel is open and add-on is enabled. + let addon = await AddonManager.getAddonByID(extensionId); + is(addon.userDisabled, false, "The add-on is enabled at first"); + is( + panel.getAttribute("panelopen"), + "true", + "The notification panel is open after opening New Tab" + ); + is( + getNotificationSetting(extensionId), + null, + "The New Tab notification is not set for this extension" + ); + + // Click the Manage button. + let preferencesShown = TestUtils.waitForCondition( + () => gBrowser.currentURI.spec == "about:preferences#home", + "Should open about:preferences." + ); + + let popupHidden = promisePopupHidden(panel); + clickManage(notification); + await popupHidden; + await preferencesShown; + + // Ensure panel is closed, settings haven't changed and add-on is disabled. + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is closed after click" + ); + + is( + getNotificationSetting(extensionId), + null, + "The New Tab notification is not set after clicking manage" + ); + + // Reopen a browser tab and verify that there's no doorhanger. + BrowserTestUtils.removeTab(tab); + tab = await promiseNewTab(); + + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is not opened after keeping the changes" + ); + + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); + +add_task(async function test_new_tab_restore_settings_multiple() { + await ExtensionSettingsStore.initialize(); + let notification = getNewTabDoorhanger(); + let panel = notification.closest("panel"); + let extensionOneId = "newtabrestoreone@mochi.test"; + let extensionOne = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: extensionOneId } }, + chrome_url_overrides: { newtab: "restore-one.html" }, + }, + files: { + "restore-one.html": ` +

    New Tab!

    + `, + }, + useAddonManager: "temporary", + }); + let extensionTwoId = "newtabrestoretwo@mochi.test"; + let extensionTwo = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: extensionTwoId } }, + chrome_url_overrides: { newtab: "restore-two.html" }, + }, + files: { "restore-two.html": '

    New Tab!

    ' }, + useAddonManager: "temporary", + }); + + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is initially closed" + ); + is( + getNotificationSetting(extensionOneId), + null, + "The New Tab notification is not initially set for this extension" + ); + is( + getNotificationSetting(extensionTwoId), + null, + "The New Tab notification is not initially set for this extension" + ); + + await extensionOne.startup(); + await extensionTwo.startup(); + + // Simulate opening the newtab open as a user would. + let popupShown = promisePopupShown(panel); + let tab1 = await promiseNewTab(); + await popupShown; + + // Verify that the panel is open and add-on is enabled. + let addonTwo = await AddonManager.getAddonByID(extensionTwoId); + is(addonTwo.userDisabled, false, "The add-on is enabled at first"); + is( + panel.getAttribute("panelopen"), + "true", + "The notification panel is open after opening New Tab" + ); + is( + getNotificationSetting(extensionTwoId), + null, + "The New Tab notification is not set for this extension" + ); + + // Click the Manage button. + let popupHidden = promisePopupHidden(panel); + let preferencesShown = TestUtils.waitForCondition( + () => gBrowser.currentURI.spec == "about:preferences#home", + "Should open about:preferences." + ); + clickManage(notification); + await popupHidden; + await preferencesShown; + + // Disable the second addon then refresh the new tab expect to see a new addon dropdown. + let addonDisabled = waitForAddonDisabled(addonTwo); + addonTwo.disable(); + await addonDisabled; + + // Ensure the panel opens again for the next add-on. + popupShown = promisePopupShown(panel); + let newtabShown = TestUtils.waitForCondition( + () => gBrowser.currentURI.spec == AboutNewTab.newTabURL, + "Should open correct new tab url." + ); + let tab2 = await promiseNewTab(); + await newtabShown; + await popupShown; + + is( + getNotificationSetting(extensionTwoId), + null, + "The New Tab notification is not set after restoring the settings" + ); + let addonOne = await AddonManager.getAddonByID(extensionOneId); + is( + addonOne.userDisabled, + false, + "The extension is enabled before making a choice" + ); + is( + getNotificationSetting(extensionOneId), + null, + "The New Tab notification is not set before making a choice" + ); + is( + panel.getAttribute("panelopen"), + "true", + "The notification panel is open after opening New Tab" + ); + is( + gBrowser.currentURI.spec, + AboutNewTab.newTabURL, + "The user is now on the next extension's New Tab page" + ); + + preferencesShown = TestUtils.waitForCondition( + () => gBrowser.currentURI.spec == "about:preferences#home", + "Should open about:preferences." + ); + popupHidden = promisePopupHidden(panel); + clickManage(notification); + await popupHidden; + await preferencesShown; + // remove the extra preferences tab. + BrowserTestUtils.removeTab(tab2); + + addonDisabled = waitForAddonDisabled(addonOne); + addonOne.disable(); + await addonDisabled; + tab2 = await promiseNewTab(); + + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is closed after restoring the second time" + ); + is( + getNotificationSetting(extensionOneId), + null, + "The New Tab notification is not set after restoring the settings" + ); + is( + gBrowser.currentURI.spec, + "about:newtab", + "The user is now on the original New Tab URL since all extensions are disabled" + ); + + // Reopen a browser tab and verify that there's no doorhanger. + BrowserTestUtils.removeTab(tab2); + tab2 = await promiseNewTab(); + + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is not opened after keeping the changes" + ); + + // FIXME: We need to enable the add-on so it gets cleared from the + // ExtensionSettingsStore for now. See bug 1408226. + let addonsEnabled = Promise.all([ + waitForAddonEnabled(addonOne), + waitForAddonEnabled(addonTwo), + ]); + await addonOne.enable(); + await addonTwo.enable(); + await addonsEnabled; + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + + await extensionOne.unload(); + await extensionTwo.unload(); +}); + +/** + * Ensure we don't show the extension URL in the URL bar temporarily in new tabs + * while we're switching remoteness (when the URL we're loading and the + * default content principal are different). + */ +add_task(async function dontTemporarilyShowAboutExtensionPath() { + await ExtensionSettingsStore.initialize(); + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + let extensionNewTabUrl = `moz-extension://${extension.uuid}/newtab.html`; + + let wpl = { + onLocationChange() { + is(gURLBar.value, "", "URL bar value should stay empty."); + }, + }; + gBrowser.addProgressListener(wpl); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: extensionNewTabUrl, + }); + + gBrowser.removeProgressListener(wpl); + is(gURLBar.value, "", "URL bar value should be empty."); + await SpecialPowers.spawn(tab.linkedBrowser, [], function () { + is( + content.document.body.textContent, + "New tab!", + "New tab page is loaded." + ); + }); + + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); + +add_task(async function test_overriding_newtab_incognito_not_allowed() { + let panel = getNewTabDoorhanger().closest("panel"); + + let extension = ExtensionTestUtils.loadExtension({ + ...extensionData, + useAddonManager: "permanent", + }); + + await extension.startup(); + let extensionNewTabUrl = `moz-extension://${extension.uuid}/newtab.html`; + + let popupShown = promisePopupShown(panel); + let tab = await promiseNewTab(extensionNewTabUrl); + await popupShown; + + // This will show a confirmation doorhanger, make sure we don't leave it open. + let popupHidden = promisePopupHidden(panel); + panel.hidePopup(); + await popupHidden; + + BrowserTestUtils.removeTab(tab); + + // Verify a private window does not open the extension page. We would + // get an extra notification that we don't listen for if it gets loaded. + let windowOpenedPromise = BrowserTestUtils.waitForNewWindow(); + let win = OpenBrowserWindow({ private: true }); + await windowOpenedPromise; + + await promiseNewTab("about:privatebrowsing", win); + + is(win.gURLBar.value, "", "newtab not used in private window"); + + // Verify setting the pref directly doesn't bypass permissions. + let origUrl = AboutNewTab.newTabURL; + AboutNewTab.newTabURL = extensionNewTabUrl; + await promiseNewTab("about:privatebrowsing", win); + + is(win.gURLBar.value, "", "directly set newtab not used in private window"); + + AboutNewTab.newTabURL = origUrl; + + await extension.unload(); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_overriding_newtab_incognito_spanning() { + let extension = ExtensionTestUtils.loadExtension({ + ...extensionData, + useAddonManager: "permanent", + incognitoOverride: "spanning", + }); + + await extension.startup(); + let extensionNewTabUrl = `moz-extension://${extension.uuid}/newtab.html`; + + let windowOpenedPromise = BrowserTestUtils.waitForNewWindow(); + let win = OpenBrowserWindow({ private: true }); + await windowOpenedPromise; + let panel = ExtensionControlledPopup._getAndMaybeCreatePanel(win.document); + let popupShown = promisePopupShown(panel); + await promiseNewTab(extensionNewTabUrl, win); + await popupShown; + + // This will show a confirmation doorhanger, make sure we don't leave it open. + let popupHidden = promisePopupHidden(panel); + panel.hidePopup(); + await popupHidden; + + await extension.unload(); + await BrowserTestUtils.closeWindow(win); +}); + +// Test that prefs set by the newtab override code are +// properly unset when all newtab extensions are gone. +add_task(async function testNewTabPrefsReset() { + function isUndefinedPref(pref) { + try { + Services.prefs.getBoolPref(pref); + return false; + } catch (e) { + return true; + } + } + + ok( + isUndefinedPref("browser.newtab.extensionControlled"), + "extensionControlled pref is not set" + ); + ok( + isUndefinedPref("browser.newtab.privateAllowed"), + "privateAllowed pref is not set" + ); +}); + +// This test ensures that an extension provided newtab +// can be opened by another extension (e.g. tab manager) +// regardless of whether the newtab url is made available +// in web_accessible_resources. +add_task(async function test_newtab_from_extension() { + let panel = getNewTabDoorhanger().closest("panel"); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "newtaburl@mochi.test", + }, + }, + chrome_url_overrides: { + newtab: "newtab.html", + }, + }, + files: { + "newtab.html": `

    New tab!

    `, + "newtab.js": () => { + browser.test.sendMessage("newtab-loaded"); + }, + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + let extensionNewTabUrl = `moz-extension://${extension.uuid}/newtab.html`; + + let popupShown = promisePopupShown(panel); + let tab = await promiseNewTab(extensionNewTabUrl); + await popupShown; + + // This will show a confirmation doorhanger, make sure we don't leave it open. + let popupHidden = promisePopupHidden(panel); + panel.hidePopup(); + await popupHidden; + + BrowserTestUtils.removeTab(tab); + + // extension to open the newtab + let opener = ExtensionTestUtils.loadExtension({ + async background() { + let newtab = await browser.tabs.create({}); + browser.test.assertTrue( + newtab.id !== browser.tabs.TAB_ID_NONE, + "New tab was created." + ); + await browser.tabs.remove(newtab.id); + browser.test.sendMessage("complete"); + }, + }); + + function listener(msg) { + Assert.ok(!/may not load or link to moz-extension/.test(msg.message)); + } + Services.console.registerListener(listener); + registerCleanupFunction(() => { + Services.console.unregisterListener(listener); + }); + + await opener.startup(); + await opener.awaitMessage("complete"); + await extension.awaitMessage("newtab-loaded"); + await opener.unload(); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_user_events.js b/browser/components/extensions/test/browser/browser_ext_user_events.js new file mode 100644 index 0000000000..4852ffd124 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_user_events.js @@ -0,0 +1,271 @@ +"use strict"; + +// Test that different types of events are all considered +// "handling user input". +add_task(async function testSources() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + async function request(perm) { + try { + let result = await browser.permissions.request({ + permissions: [perm], + }); + browser.test.sendMessage("request", { success: true, result, perm }); + } catch (err) { + browser.test.sendMessage("request", { + success: false, + errmsg: err.message, + perm, + }); + } + } + + let tabs = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tabs[0].id); + + browser.pageAction.onClicked.addListener(() => request("bookmarks")); + browser.browserAction.onClicked.addListener(() => request("tabs")); + browser.commands.onCommand.addListener(() => request("downloads")); + + browser.test.onMessage.addListener(msg => { + if (msg === "contextMenus.update") { + browser.contextMenus.onClicked.addListener(() => + request("webNavigation") + ); + browser.contextMenus.update( + "menu", + { + title: "test user events in onClicked", + onclick: null, + }, + () => browser.test.sendMessage("contextMenus.update-done") + ); + } + if (msg === "openOptionsPage") { + browser.runtime.openOptionsPage(); + } + }); + + browser.contextMenus.create( + { + id: "menu", + title: "test user events in onclick", + contexts: ["page"], + onclick() { + request("cookies"); + }, + }, + () => { + browser.test.sendMessage("actions-ready"); + } + ); + }, + + files: { + "options.html": ` + + + + + + + + Link + + `, + + "options.js"() { + addEventListener("load", async () => { + let link = document.getElementById("link"); + link.onclick = async event => { + link.onclick = null; + event.preventDefault(); + + browser.test.log("Calling permission.request from options page."); + + let perm = "history"; + try { + let result = await browser.permissions.request({ + permissions: [perm], + }); + browser.test.sendMessage("request", { + success: true, + result, + perm, + }); + } catch (err) { + browser.test.sendMessage("request", { + success: false, + errmsg: err.message, + perm, + }); + } + }; + + // Make a few trips through the event loop to make sure the + // options browser is fully visible. This is a bit dodgy, but + // we don't really have a reliable way to detect this from the + // options page side, and synthetic click events won't work + // until it is. + do { + browser.test.log( + "Waiting for the options browser to be visible..." + ); + await new Promise(resolve => setTimeout(resolve, 0)); + synthesizeMouseAtCenter(link, {}); + } while (link.onclick !== null); + }); + }, + }, + + manifest: { + browser_action: { + default_title: "test", + default_area: "navbar", + }, + page_action: { default_title: "test" }, + permissions: ["contextMenus"], + optional_permissions: [ + "bookmarks", + "tabs", + "webNavigation", + "history", + "cookies", + "downloads", + ], + options_ui: { page: "options.html" }, + content_security_policy: + "script-src 'self' https://example.com; object-src 'none';", + commands: { + command: { + suggested_key: { + default: "Alt+Shift+J", + }, + }, + }, + }, + + useAddonManager: "temporary", + }); + + async function testPermissionRequest( + { requestPermission, expectPrompt, perm }, + what + ) { + info(`check request permission from '${what}'`); + + let promptPromise = null; + if (expectPrompt) { + promptPromise = promisePopupNotificationShown( + "addon-webext-permissions" + ).then(panel => { + panel.button.click(); + }); + } + + await requestPermission(); + await promptPromise; + + let result = await extension.awaitMessage("request"); + ok(result.success, `request() did not throw when called from ${what}`); + is(result.result, true, `request() succeeded when called from ${what}`); + is(result.perm, perm, `requested permission ${what}`); + await promptPromise; + } + + // Remove Sidebar button to prevent pushing extension button to overflow menu + CustomizableUI.removeWidgetFromArea("sidebar-button"); + + await extension.startup(); + await extension.awaitMessage("actions-ready"); + + await testPermissionRequest( + { + requestPermission: () => clickPageAction(extension), + expectPrompt: true, + perm: "bookmarks", + }, + "page action click" + ); + + await testPermissionRequest( + { + requestPermission: () => clickBrowserAction(extension), + expectPrompt: true, + perm: "tabs", + }, + "browser action click" + ); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + gBrowser.selectedTab = tab; + + await testPermissionRequest( + { + requestPermission: async () => { + let menu = await openContextMenu("body"); + let items = menu.getElementsByAttribute( + "label", + "test user events in onclick" + ); + is(items.length, 1, "Found context menu item"); + menu.activateItem(items[0]); + }, + expectPrompt: false, // cookies permission has no prompt. + perm: "cookies", + }, + "context menu in onclick" + ); + + extension.sendMessage("contextMenus.update"); + await extension.awaitMessage("contextMenus.update-done"); + + await testPermissionRequest( + { + requestPermission: async () => { + let menu = await openContextMenu("body"); + let items = menu.getElementsByAttribute( + "label", + "test user events in onClicked" + ); + is(items.length, 1, "Found context menu item again"); + menu.activateItem(items[0]); + }, + expectPrompt: true, + perm: "webNavigation", + }, + "context menu in onClicked" + ); + + await testPermissionRequest( + { + requestPermission: () => { + EventUtils.synthesizeKey("j", { altKey: true, shiftKey: true }); + }, + expectPrompt: true, + perm: "downloads", + }, + "commands shortcut" + ); + + await testPermissionRequest( + { + requestPermission: () => { + extension.sendMessage("openOptionsPage"); + }, + expectPrompt: true, + perm: "history", + }, + "options page link click" + ); + + await BrowserTestUtils.removeTab(gBrowser.selectedTab); + await BrowserTestUtils.removeTab(tab); + + await extension.unload(); + + registerCleanupFunction(() => CustomizableUI.reset()); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_webNavigation_containerIsolation.js b/browser/components/extensions/test/browser/browser_ext_webNavigation_containerIsolation.js new file mode 100644 index 0000000000..3bf849b518 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webNavigation_containerIsolation.js @@ -0,0 +1,169 @@ +"use strict"; + +add_task(async function containerIsolation_restricted() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.userContextIsolation.enabled", true], + ["privacy.userContext.enabled", true], + ], + }); + + let helperExtension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "cookies", "webNavigation"], + }, + + async background() { + browser.webNavigation.onCompleted.addListener(details => { + browser.test.sendMessage("tabCreated", details.tabId); + }); + browser.test.onMessage.addListener(async message => { + switch (message.subject) { + case "createTab": { + await browser.tabs.create({ + url: message.data.url, + cookieStoreId: message.data.cookieStoreId, + }); + break; + } + } + }); + }, + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webNavigation"], + }, + + async background() { + let eventNames = [ + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", + ]; + + const initialEmptyTabs = await browser.tabs.query({ active: true }); + browser.test.assertEq( + 1, + initialEmptyTabs.length, + `Got one initial empty tab as expected: ${JSON.stringify( + initialEmptyTabs + )}` + ); + + for (let eventName of eventNames) { + browser.webNavigation[eventName].addListener(details => { + if (details.tabId === initialEmptyTabs[0].id) { + // Ignore webNavigation related to the initial about:blank tab, it may be technically + // still being loading when we start this test extension to run the test scenario. + return; + } + browser.test.assertEq( + "http://www.example.com/?allowed", + details.url, + `expected ${eventName} event` + ); + browser.test.sendMessage(eventName, details.tabId); + }); + } + + const [restrictedTab, unrestrictedTab, noContainerTab] = + await new Promise(resolve => { + browser.test.onMessage.addListener(message => resolve(message)); + }); + + await browser.test.assertRejects( + browser.webNavigation.getFrame({ + tabId: restrictedTab, + frameId: 0, + }), + `Invalid tab ID: ${restrictedTab}`, + "getFrame rejected Promise should pass the expected error" + ); + + await browser.test.assertRejects( + browser.webNavigation.getAllFrames({ tabId: restrictedTab }), + `Invalid tab ID: ${restrictedTab}`, + "getAllFrames rejected Promise should pass the expected error" + ); + + await browser.tabs.remove(unrestrictedTab); + await browser.tabs.remove(noContainerTab); + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + + await SpecialPowers.pushPrefEnv({ + set: [ + [`extensions.userContextIsolation.${extension.id}.restricted`, "[1]"], + ], + }); + + await helperExtension.startup(); + + helperExtension.sendMessage({ + subject: "createTab", + data: { + url: "http://www.example.com/?restricted", + cookieStoreId: "firefox-container-1", + }, + }); + + const restrictedTab = await helperExtension.awaitMessage("tabCreated"); + + helperExtension.sendMessage({ + subject: "createTab", + data: { + url: "http://www.example.com/?allowed", + cookieStoreId: "firefox-container-2", + }, + }); + + const unrestrictedTab = await helperExtension.awaitMessage("tabCreated"); + + helperExtension.sendMessage({ + subject: "createTab", + data: { + url: "http://www.example.com/?allowed", + }, + }); + + const noContainerTab = await helperExtension.awaitMessage("tabCreated"); + + let eventNames = [ + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", + ]; + for (let eventName of eventNames) { + let recTabId1 = await extension.awaitMessage(eventName); + let recTabId2 = await extension.awaitMessage(eventName); + + Assert.equal( + recTabId1, + unrestrictedTab, + `Expected unrestricted tab with tabId: ${unrestrictedTab} from ${eventName} event` + ); + + Assert.equal( + recTabId2, + noContainerTab, + `Expected noContainer tab with tabId: ${noContainerTab} from ${eventName} event` + ); + } + + extension.sendMessage([restrictedTab, unrestrictedTab, noContainerTab]); + + await extension.awaitMessage("done"); + + await extension.unload(); + await helperExtension.unload(); + await SpecialPowers.popPrefEnv(); + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_webNavigation_frameId0.js b/browser/components/extensions/test/browser/browser_ext_webNavigation_frameId0.js new file mode 100644 index 0000000000..c5b2c72778 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webNavigation_frameId0.js @@ -0,0 +1,43 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function webNavigation_getFrameId_of_existing_main_frame() { + const BASE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/"; + const DUMMY_URL = BASE + "file_dummy.html"; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + DUMMY_URL, + true + ); + + async function background(DUMMY_URL) { + let tabs = await browser.tabs.query({ active: true, currentWindow: true }); + let frames = await browser.webNavigation.getAllFrames({ + tabId: tabs[0].id, + }); + browser.test.assertEq(1, frames.length, "The dummy page has one frame"); + browser.test.assertEq(0, frames[0].frameId, "Main frame's ID must be 0"); + browser.test.assertEq( + DUMMY_URL, + frames[0].url, + "Main frame URL must match" + ); + browser.test.notifyPass("frameId checked"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webNavigation"], + }, + + background: `(${background})(${JSON.stringify(DUMMY_URL)});`, + }); + + await extension.startup(); + await extension.awaitFinish("frameId checked"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_webNavigation_getFrames.js b/browser/components/extensions/test/browser/browser_ext_webNavigation_getFrames.js new file mode 100644 index 0000000000..b60940bae7 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webNavigation_getFrames.js @@ -0,0 +1,323 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testWebNavigationGetNonExistentTab() { + let extension = ExtensionTestUtils.loadExtension({ + background: async function () { + // There is no "tabId = 0" because the id assigned by tabTracker (defined in ext-browser.js) + // starts from 1. + await browser.test.assertRejects( + browser.webNavigation.getAllFrames({ tabId: 0 }), + "Invalid tab ID: 0", + "getAllFrames rejected Promise should pass the expected error" + ); + + // There is no "tabId = 0" because the id assigned by tabTracker (defined in ext-browser.js) + // starts from 1, processId is currently marked as optional and it is ignored. + await browser.test.assertRejects( + browser.webNavigation.getFrame({ + tabId: 0, + frameId: 15, + processId: 20, + }), + "Invalid tab ID: 0", + "getFrame rejected Promise should pass the expected error" + ); + + browser.test.sendMessage("getNonExistentTab.done"); + }, + manifest: { + permissions: ["webNavigation"], + }, + }); + + await extension.startup(); + + await extension.awaitMessage("getNonExistentTab.done"); + + await extension.unload(); +}); + +add_task(async function testWebNavigationFrames() { + let extension = ExtensionTestUtils.loadExtension({ + background: async function () { + let tabId; + let collectedDetails = []; + + browser.webNavigation.onCompleted.addListener(async details => { + collectedDetails.push(details); + + if (details.frameId !== 0) { + // wait for the top level iframe to be complete + return; + } + + let getAllFramesDetails = await browser.webNavigation.getAllFrames({ + tabId, + }); + + let getFramePromises = getAllFramesDetails.map(({ frameId }) => { + // processId is currently marked as optional and it is ignored. + return browser.webNavigation.getFrame({ + tabId, + frameId, + processId: 0, + }); + }); + + let getFrameResults = await Promise.all(getFramePromises); + browser.test.sendMessage("webNavigationFrames.done", { + collectedDetails, + getAllFramesDetails, + getFrameResults, + }); + + // Pick a random frameId. + let nonExistentFrameId = Math.floor(Math.random() * 10000); + + // Increment the picked random nonExistentFrameId until it doesn't exists. + while ( + getAllFramesDetails.filter( + details => details.frameId == nonExistentFrameId + ).length + ) { + nonExistentFrameId += 1; + } + + // Check that getFrame Promise is rejected with the expected error message on nonexistent frameId. + await browser.test.assertRejects( + browser.webNavigation.getFrame({ + tabId, + frameId: nonExistentFrameId, + processId: 20, + }), + `No frame found with frameId: ${nonExistentFrameId}`, + "getFrame promise should be rejected with the expected error message on unexistent frameId" + ); + + await browser.tabs.remove(tabId); + browser.test.sendMessage("webNavigationFrames.done"); + }); + + let tab = await browser.tabs.create({ url: "tab.html" }); + tabId = tab.id; + }, + manifest: { + permissions: ["webNavigation", "tabs"], + }, + files: { + "tab.html": ` + + + + + + + + + + + `, + "subframe.html": ` + + + + + + + `, + }, + }); + + await extension.startup(); + + let { collectedDetails, getAllFramesDetails, getFrameResults } = + await extension.awaitMessage("webNavigationFrames.done"); + + is(getAllFramesDetails.length, 3, "expected number of frames found"); + is( + getAllFramesDetails.length, + collectedDetails.length, + "number of frames found should equal the number onCompleted events collected" + ); + + is( + getAllFramesDetails[0].frameId, + 0, + "the root frame has the expected frameId" + ); + is( + getAllFramesDetails[0].parentFrameId, + -1, + "the root frame has the expected parentFrameId" + ); + + // ordered by frameId + let sortByFrameId = (el1, el2) => { + let val1 = el1 ? el1.frameId : -1; + let val2 = el2 ? el2.frameId : -1; + return val1 - val2; + }; + + collectedDetails = collectedDetails.sort(sortByFrameId); + getAllFramesDetails = getAllFramesDetails.sort(sortByFrameId); + getFrameResults = getFrameResults.sort(sortByFrameId); + + info("check frame details content"); + + is( + getFrameResults.length, + getAllFramesDetails.length, + "getFrame and getAllFrames should return the same number of results" + ); + + Assert.deepEqual( + getFrameResults, + getAllFramesDetails, + "getFrame and getAllFrames should return the same results" + ); + + info(`check frame details collected and retrieved with getAllFrames`); + + for (let [i, collected] of collectedDetails.entries()) { + let getAllFramesDetail = getAllFramesDetails[i]; + + is(getAllFramesDetail.frameId, collected.frameId, "frameId"); + is( + getAllFramesDetail.parentFrameId, + collected.parentFrameId, + "parentFrameId" + ); + is(getAllFramesDetail.tabId, collected.tabId, "tabId"); + + // This can be uncommented once Bug 1246125 has been fixed + // is(getAllFramesDetail.url, collected.url, "url"); + } + + info("frame details content checked"); + + await extension.awaitMessage("webNavigationFrames.done"); + + await extension.unload(); +}); + +add_task(async function testWebNavigationGetFrameOnDiscardedTab() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "webNavigation"], + }, + async background() { + let tabs = await browser.tabs.query({ currentWindow: true }); + browser.test.assertEq(2, tabs.length, "Expect 2 tabs open"); + + const tabId = tabs[1].id; + + await browser.tabs.discard(tabId); + let tab = await browser.tabs.get(tabId); + browser.test.assertEq(true, tab.discarded, "Expect a discarded tab"); + + const allFrames = await browser.webNavigation.getAllFrames({ tabId }); + browser.test.assertEq( + null, + allFrames, + "Expect null from calling getAllFrames on discarded tab" + ); + + tab = await browser.tabs.get(tabId); + browser.test.assertEq( + true, + tab.discarded, + "Expect tab to stay discarded" + ); + + const topFrame = await browser.webNavigation.getFrame({ + tabId, + frameId: 0, + }); + browser.test.assertEq( + null, + topFrame, + "Expect null from calling getFrame on discarded tab" + ); + + tab = await browser.tabs.get(tabId); + browser.test.assertEq( + true, + tab.discarded, + "Expect tab to stay discarded" + ); + + browser.test.sendMessage("get-frames-done"); + }, + }); + + const initialTab = gBrowser.selectedTab; + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/?toBeDiscarded=true" + ); + // Switch back to the initial tab to allow the new tab + // to be discarded. + await BrowserTestUtils.switchTab(gBrowser, initialTab); + + ok(!!tab.linkedPanel, "Tab not initially discarded"); + + await extension.startup(); + await extension.awaitMessage("get-frames-done"); + + ok(!tab.linkedPanel, "Tab should be discarded"); + + BrowserTestUtils.removeTab(tab); + + await extension.unload(); +}); + +add_task(async function testWebNavigationCrossOriginFrames() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webNavigation"], + }, + async background() { + let url = + "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html"; + let tab = await browser.tabs.create({ url }); + + await new Promise(resolve => { + browser.webNavigation.onCompleted.addListener(details => { + if (details.tabId === tab.id && details.frameId === 0) { + resolve(); + } + }); + }); + + let frames = await browser.webNavigation.getAllFrames({ tabId: tab.id }); + browser.test.assertEq(frames[0].url, url, "Top is from mochi.test"); + + await browser.tabs.remove(tab.id); + browser.test.sendMessage("webNavigation.CrossOriginFrames", frames); + }, + }); + + await extension.startup(); + + let frames = await extension.awaitMessage("webNavigation.CrossOriginFrames"); + is(frames.length, 2, "getAllFrames() returns both frames."); + + is(frames[0].frameId, 0, "Top frame has correct frameId."); + is(frames[0].parentFrameId, -1, "Top parentFrameId is correct."); + + Assert.greater( + frames[1].frameId, + 0, + "Cross-origin iframe has non-zero frameId." + ); + is(frames[1].parentFrameId, 0, "Iframe parentFrameId is correct."); + is( + frames[1].url, + "http://example.org/tests/toolkit/components/extensions/test/mochitest/file_contains_img.html", + "Irame is from example.org" + ); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget.js b/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget.js new file mode 100644 index 0000000000..efe847c2b4 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget.js @@ -0,0 +1,194 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_webNavigation.js"); + +SpecialPowers.pushPrefEnv({ + set: [["security.allow_eval_with_system_principal", true]], +}); + +async function background() { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const sourceTabId = tabs[0].id; + + const sourceTabFrames = await browser.webNavigation.getAllFrames({ + tabId: sourceTabId, + }); + + browser.webNavigation.onCreatedNavigationTarget.addListener(msg => { + browser.test.sendMessage("webNavOnCreated", msg); + }); + + browser.webNavigation.onCompleted.addListener(async msg => { + // NOTE: checking the url is currently necessary because of Bug 1252129 + // ( Filter out webNavigation events related to new window initialization phase). + if (msg.tabId !== sourceTabId && msg.url !== "about:blank") { + await browser.tabs.remove(msg.tabId); + browser.test.sendMessage("webNavOnCompleted", msg); + } + }); + + browser.tabs.onCreated.addListener(tab => { + browser.test.sendMessage("tabsOnCreated", tab.id); + }); + + browser.test.sendMessage("expectedSourceTab", { + sourceTabId, + sourceTabFrames, + }); +} + +add_task(async function test_on_created_navigation_target_from_mouse_click() { + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SOURCE_PAGE + ); + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["webNavigation"], + }, + }); + + await extension.startup(); + + const expectedSourceTab = await extension.awaitMessage("expectedSourceTab"); + + info("Open link in a new tab using Ctrl-click"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + BrowserTestUtils.synthesizeMouseAtCenter( + "#test-create-new-tab-from-mouse-click", + { ctrlKey: true, metaKey: true }, + tab.linkedBrowser + ); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#new-tab-from-mouse-click`, + }, + }); + + info("Open link in a new window using Shift-click"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + BrowserTestUtils.synthesizeMouseAtCenter( + "#test-create-new-window-from-mouse-click", + { shiftKey: true }, + tab.linkedBrowser + ); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#new-window-from-mouse-click`, + }, + }); + + info('Open link with target="_blank" in a new tab using click'); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + BrowserTestUtils.synthesizeMouseAtCenter( + "#test-create-new-tab-from-targetblank-click", + {}, + tab.linkedBrowser + ); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#new-tab-from-targetblank-click`, + }, + }); + + BrowserTestUtils.removeTab(tab); + + await extension.unload(); +}); + +add_task( + async function test_on_created_navigation_target_from_mouse_click_subframe() { + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SOURCE_PAGE + ); + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["webNavigation"], + }, + }); + + await extension.startup(); + + const expectedSourceTab = await extension.awaitMessage("expectedSourceTab"); + + info("Open a subframe link in a new tab using Ctrl-click"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + BrowserTestUtils.synthesizeMouseAtCenter( + "#test-create-new-tab-from-mouse-click-subframe", + { ctrlKey: true, metaKey: true }, + tab.linkedBrowser.browsingContext.children[0] + ); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: expectedSourceTab.sourceTabFrames[1].frameId, + url: `${OPENED_PAGE}#new-tab-from-mouse-click-subframe`, + }, + }); + + info("Open a subframe link in a new window using Shift-click"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + BrowserTestUtils.synthesizeMouseAtCenter( + "#test-create-new-window-from-mouse-click-subframe", + { shiftKey: true }, + tab.linkedBrowser.browsingContext.children[0] + ); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: expectedSourceTab.sourceTabFrames[1].frameId, + url: `${OPENED_PAGE}#new-window-from-mouse-click-subframe`, + }, + }); + + info('Open a subframe link with target="_blank" in a new tab using click'); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + BrowserTestUtils.synthesizeMouseAtCenter( + "#test-create-new-tab-from-targetblank-click-subframe", + {}, + tab.linkedBrowser.browsingContext.children[0] + ); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: expectedSourceTab.sourceTabFrames[1].frameId, + url: `${OPENED_PAGE}#new-tab-from-targetblank-click-subframe`, + }, + }); + + BrowserTestUtils.removeTab(tab); + + await extension.unload(); + } +); diff --git a/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_contextmenu.js b/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_contextmenu.js new file mode 100644 index 0000000000..8fd94af4f1 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_contextmenu.js @@ -0,0 +1,182 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_webNavigation.js"); + +SpecialPowers.pushPrefEnv({ + set: [["security.allow_eval_with_system_principal", true]], +}); + +async function clickContextMenuItem({ + pageElementSelector, + contextMenuItemLabel, + frameIndex, +}) { + let contentAreaContextMenu; + if (frameIndex == null) { + contentAreaContextMenu = await openContextMenu(pageElementSelector); + } else { + contentAreaContextMenu = await openContextMenuInFrame( + pageElementSelector, + frameIndex + ); + } + const item = contentAreaContextMenu.getElementsByAttribute( + "label", + contextMenuItemLabel + ); + is(item.length, 1, `found contextMenu item for "${contextMenuItemLabel}"`); + const closed = promiseContextMenuClosed(contentAreaContextMenu); + contentAreaContextMenu.activateItem(item[0]); + await closed; +} + +async function background() { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const sourceTabId = tabs[0].id; + + const sourceTabFrames = await browser.webNavigation.getAllFrames({ + tabId: sourceTabId, + }); + + browser.webNavigation.onCreatedNavigationTarget.addListener(msg => { + browser.test.sendMessage("webNavOnCreated", msg); + }); + + browser.webNavigation.onCompleted.addListener(async msg => { + // NOTE: checking the url is currently necessary because of Bug 1252129 + // ( Filter out webNavigation events related to new window initialization phase). + if (msg.tabId !== sourceTabId && msg.url !== "about:blank") { + await browser.tabs.remove(msg.tabId); + browser.test.sendMessage("webNavOnCompleted", msg); + } + }); + + browser.tabs.onCreated.addListener(tab => { + browser.test.sendMessage("tabsOnCreated", tab.id); + }); + + browser.test.sendMessage("expectedSourceTab", { + sourceTabId, + sourceTabFrames, + }); +} + +add_task(async function test_on_created_navigation_target_from_context_menu() { + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SOURCE_PAGE + ); + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["webNavigation"], + }, + }); + + await extension.startup(); + + const expectedSourceTab = await extension.awaitMessage("expectedSourceTab"); + + info("Open link in a new tab from the context menu"); + + await runCreatedNavigationTargetTest({ + extension, + async openNavTarget() { + await clickContextMenuItem({ + pageElementSelector: "#test-create-new-tab-from-context-menu", + contextMenuItemLabel: "Open Link in New Tab", + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#new-tab-from-context-menu`, + }, + }); + + info("Open link in a new window from the context menu"); + + await runCreatedNavigationTargetTest({ + extension, + async openNavTarget() { + await clickContextMenuItem({ + pageElementSelector: "#test-create-new-window-from-context-menu", + contextMenuItemLabel: "Open Link in New Window", + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#new-window-from-context-menu`, + }, + }); + + BrowserTestUtils.removeTab(tab); + + await extension.unload(); +}); + +add_task( + async function test_on_created_navigation_target_from_context_menu_subframe() { + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SOURCE_PAGE + ); + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["webNavigation"], + }, + }); + + await extension.startup(); + + const expectedSourceTab = await extension.awaitMessage("expectedSourceTab"); + + info("Open a subframe link in a new tab from the context menu"); + + await runCreatedNavigationTargetTest({ + extension, + async openNavTarget() { + await clickContextMenuItem({ + pageElementSelector: + "#test-create-new-tab-from-context-menu-subframe", + contextMenuItemLabel: "Open Link in New Tab", + frameIndex: 0, + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: expectedSourceTab.sourceTabFrames[1].frameId, + url: `${OPENED_PAGE}#new-tab-from-context-menu-subframe`, + }, + }); + + info("Open a subframe link in a new window from the context menu"); + + await runCreatedNavigationTargetTest({ + extension, + async openNavTarget() { + await clickContextMenuItem({ + pageElementSelector: + "#test-create-new-window-from-context-menu-subframe", + contextMenuItemLabel: "Open Link in New Window", + frameIndex: 0, + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: expectedSourceTab.sourceTabFrames[1].frameId, + url: `${OPENED_PAGE}#new-window-from-context-menu-subframe`, + }, + }); + + BrowserTestUtils.removeTab(tab); + + await extension.unload(); + } +); diff --git a/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_named_window.js b/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_named_window.js new file mode 100644 index 0000000000..3a0a950319 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_named_window.js @@ -0,0 +1,100 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_webNavigation.js"); + +async function background() { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const sourceTabId = tabs[0].id; + + const sourceTabFrames = await browser.webNavigation.getAllFrames({ + tabId: sourceTabId, + }); + + browser.webNavigation.onCreatedNavigationTarget.addListener(msg => { + browser.test.sendMessage("webNavOnCreated", msg); + }); + + browser.webNavigation.onCompleted.addListener(async msg => { + // NOTE: checking the url is currently necessary because of Bug 1252129 + // ( Filter out webNavigation events related to new window initialization phase). + if (msg.tabId !== sourceTabId && msg.url !== "about:blank") { + await browser.tabs.remove(msg.tabId); + browser.test.sendMessage("webNavOnCompleted", msg); + } + }); + + browser.tabs.onCreated.addListener(tab => { + browser.test.sendMessage("tabsOnCreated", tab.id); + }); + + browser.test.onMessage.addListener(({ type, code }) => { + if (type === "execute-contentscript") { + browser.tabs.executeScript(sourceTabId, { code: code }); + } + }); + + browser.test.sendMessage("expectedSourceTab", { + sourceTabId, + sourceTabFrames, + }); +} + +add_task(async function test_window_open_in_named_win() { + const tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SOURCE_PAGE + ); + + gBrowser.selectedTab = tab1; + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["webNavigation", "tabs", ""], + }, + }); + + await extension.startup(); + + const expectedSourceTab = await extension.awaitMessage("expectedSourceTab"); + + info("open a url in a new named window from a window.open call"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + extension.sendMessage({ + type: "execute-contentscript", + code: `window.open("${OPENED_PAGE}#new-named-window-open", "TestWinName"); true;`, + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#new-named-window-open`, + }, + }); + + info("open a url in an existent named window from a window.open call"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + extension.sendMessage({ + type: "execute-contentscript", + code: `window.open("${OPENED_PAGE}#existent-named-window-open", "TestWinName"); true;`, + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#existent-named-window-open`, + }, + }); + + BrowserTestUtils.removeTab(tab1); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_subframe_window_open.js b/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_subframe_window_open.js new file mode 100644 index 0000000000..15439460a5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_subframe_window_open.js @@ -0,0 +1,168 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_webNavigation.js"); + +async function background() { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const sourceTabId = tabs[0].id; + + const sourceTabFrames = await browser.webNavigation.getAllFrames({ + tabId: sourceTabId, + }); + + browser.webNavigation.onCreatedNavigationTarget.addListener(msg => { + browser.test.sendMessage("webNavOnCreated", msg); + }); + + browser.webNavigation.onCompleted.addListener(async msg => { + // NOTE: checking the url is currently necessary because of Bug 1252129 + // ( Filter out webNavigation events related to new window initialization phase). + if (msg.tabId !== sourceTabId && msg.url !== "about:blank") { + await browser.tabs.remove(msg.tabId); + browser.test.sendMessage("webNavOnCompleted", msg); + } + }); + + browser.tabs.onCreated.addListener(tab => { + browser.test.sendMessage("tabsOnCreated", tab.id); + }); + + browser.test.onMessage.addListener(({ type, code }) => { + if (type === "execute-contentscript") { + browser.tabs.executeScript(sourceTabId, { code: code }); + } + }); + + browser.test.sendMessage("expectedSourceTab", { + sourceTabId, + sourceTabFrames, + }); +} + +add_task(async function test_window_open_from_subframe() { + const tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SOURCE_PAGE + ); + + gBrowser.selectedTab = tab1; + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["webNavigation", "tabs", ""], + }, + }); + + await extension.startup(); + + const expectedSourceTab = await extension.awaitMessage("expectedSourceTab"); + + info("open a url in a new tab from subframe window.open call"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + extension.sendMessage({ + type: "execute-contentscript", + code: `document.querySelector('iframe').contentWindow.open("${OPENED_PAGE}#new-tab-from-window-open-subframe"); true;`, + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: expectedSourceTab.sourceTabFrames[1].frameId, + url: `${OPENED_PAGE}#new-tab-from-window-open-subframe`, + }, + }); + + info("open a url in a new window from subframe window.open call"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + extension.sendMessage({ + type: "execute-contentscript", + code: `document.querySelector('iframe').contentWindow.open("${OPENED_PAGE}#new-win-from-window-open-subframe", "_blank", "toolbar=0"); true;`, + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: expectedSourceTab.sourceTabFrames[1].frameId, + url: `${OPENED_PAGE}#new-win-from-window-open-subframe`, + }, + }); + + BrowserTestUtils.removeTab(tab1); + + await extension.unload(); +}); + +add_task(async function test_window_open_close_from_browserAction_popup() { + const tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SOURCE_PAGE + ); + + gBrowser.selectedTab = tab1; + + function popup() { + window.open("", "_self").close(); + + browser.test.sendMessage("browserAction_popup_executed"); + } + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + }, + permissions: ["webNavigation", "tabs", ""], + }, + files: { + "popup.html": ` + + + + + + + + + `, + "popup.js": popup, + }, + }); + + await extension.startup(); + + const expectedSourceTab = await extension.awaitMessage("expectedSourceTab"); + + clickBrowserAction(extension); + + await extension.awaitMessage("browserAction_popup_executed"); + + info("open a url in a new tab from a window.open call"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + extension.sendMessage({ + type: "execute-contentscript", + code: `window.open("${OPENED_PAGE}#new-tab-from-window-open"); true;`, + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#new-tab-from-window-open`, + }, + }); + + BrowserTestUtils.removeTab(tab1); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_window_open.js b/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_window_open.js new file mode 100644 index 0000000000..8a1c5ee82d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_window_open.js @@ -0,0 +1,168 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_webNavigation.js"); + +async function background() { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const sourceTabId = tabs[0].id; + + const sourceTabFrames = await browser.webNavigation.getAllFrames({ + tabId: sourceTabId, + }); + + browser.webNavigation.onCreatedNavigationTarget.addListener(msg => { + browser.test.sendMessage("webNavOnCreated", msg); + }); + + browser.webNavigation.onCompleted.addListener(async msg => { + // NOTE: checking the url is currently necessary because of Bug 1252129 + // ( Filter out webNavigation events related to new window initialization phase). + if (msg.tabId !== sourceTabId && msg.url !== "about:blank") { + await browser.tabs.remove(msg.tabId); + browser.test.sendMessage("webNavOnCompleted", msg); + } + }); + + browser.tabs.onCreated.addListener(tab => { + browser.test.sendMessage("tabsOnCreated", tab.id); + }); + + browser.test.onMessage.addListener(({ type, code }) => { + if (type === "execute-contentscript") { + browser.tabs.executeScript(sourceTabId, { code: code }); + } + }); + + browser.test.sendMessage("expectedSourceTab", { + sourceTabId, + sourceTabFrames, + }); +} + +add_task(async function test_window_open() { + const tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SOURCE_PAGE + ); + + gBrowser.selectedTab = tab1; + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["webNavigation", "tabs", ""], + }, + }); + + await extension.startup(); + + const expectedSourceTab = await extension.awaitMessage("expectedSourceTab"); + + info("open a url in a new tab from a window.open call"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + extension.sendMessage({ + type: "execute-contentscript", + code: `window.open("${OPENED_PAGE}#new-tab-from-window-open"); true;`, + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#new-tab-from-window-open`, + }, + }); + + info("open a url in a new window from a window.open call"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + extension.sendMessage({ + type: "execute-contentscript", + code: `window.open("${OPENED_PAGE}#new-win-from-window-open", "_blank", "toolbar=0"); true;`, + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#new-win-from-window-open`, + }, + }); + + BrowserTestUtils.removeTab(tab1); + + await extension.unload(); +}); + +add_task(async function test_window_open_close_from_browserAction_popup() { + const tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SOURCE_PAGE + ); + + gBrowser.selectedTab = tab1; + + function popup() { + window.open("", "_self").close(); + + browser.test.sendMessage("browserAction_popup_executed"); + } + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + }, + permissions: ["webNavigation", "tabs", ""], + }, + files: { + "popup.html": ` + + + + + + + + + `, + "popup.js": popup, + }, + }); + + await extension.startup(); + + const expectedSourceTab = await extension.awaitMessage("expectedSourceTab"); + + clickBrowserAction(extension); + + await extension.awaitMessage("browserAction_popup_executed"); + + info("open a url in a new tab from a window.open call"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + extension.sendMessage({ + type: "execute-contentscript", + code: `window.open("${OPENED_PAGE}#new-tab-from-window-open"); true;`, + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#new-tab-from-window-open`, + }, + }); + + BrowserTestUtils.removeTab(tab1); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_webNavigation_urlbar_transitions.js b/browser/components/extensions/test/browser/browser_ext_webNavigation_urlbar_transitions.js new file mode 100644 index 0000000000..07c6e70a93 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webNavigation_urlbar_transitions.js @@ -0,0 +1,314 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +SearchTestUtils.init(this); + +const SUGGEST_URLBAR_PREF = "browser.urlbar.suggest.searches"; +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; + +function promiseAutocompleteResultPopup(value) { + return UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value, + }); +} + +async function addBookmark(bookmark) { + if (bookmark.keyword) { + await PlacesUtils.keywords.insert({ + keyword: bookmark.keyword, + url: bookmark.url, + }); + } + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: bookmark.url, + title: bookmark.title, + }); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + }); +} + +async function prepareSearchEngine() { + let suggestionsEnabled = Services.prefs.getBoolPref(SUGGEST_URLBAR_PREF); + Services.prefs.setBoolPref(SUGGEST_URLBAR_PREF, true); + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + setAsDefault: true, + }); + + registerCleanupFunction(async function () { + Services.prefs.setBoolPref(SUGGEST_URLBAR_PREF, suggestionsEnabled); + + // Make sure the popup is closed for the next test. + await UrlbarTestUtils.promisePopupClose(window); + + // Clicking suggestions causes visits to search results pages, so clear that + // history now. + await PlacesUtils.history.clear(); + }); +} + +add_task(async function test_webnavigation_urlbar_typed_transitions() { + function backgroundScript() { + browser.webNavigation.onCommitted.addListener(msg => { + browser.test.assertEq( + "http://example.com/?q=typed", + msg.url, + "Got the expected url" + ); + // assert from_address_bar transition qualifier + browser.test.assertTrue( + msg.transitionQualifiers && + msg.transitionQualifiers.includes("from_address_bar"), + "Got the expected from_address_bar transitionQualifier" + ); + browser.test.assertEq( + "typed", + msg.transitionType, + "Got the expected transitionType" + ); + browser.test.notifyPass("webNavigation.from_address_bar.typed"); + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["webNavigation"], + }, + }); + + await extension.startup(); + await SimpleTest.promiseFocus(window); + + await extension.awaitMessage("ready"); + + gURLBar.focus(); + gURLBar.value = ""; + const inputValue = "http://example.com/?q=typed"; + await EventUtils.sendString(inputValue); + await EventUtils.synthesizeKey("VK_RETURN", { altKey: true }); + + await extension.awaitFinish("webNavigation.from_address_bar.typed"); + + await extension.unload(); +}); + +add_task( + async function test_webnavigation_urlbar_typed_closed_popup_transitions() { + function backgroundScript() { + browser.webNavigation.onCommitted.addListener(msg => { + browser.test.assertEq( + "http://example.com/?q=typedClosed", + msg.url, + "Got the expected url" + ); + // assert from_address_bar transition qualifier + browser.test.assertTrue( + msg.transitionQualifiers && + msg.transitionQualifiers.includes("from_address_bar"), + "Got the expected from_address_bar transitionQualifier" + ); + browser.test.assertEq( + "typed", + msg.transitionType, + "Got the expected transitionType" + ); + browser.test.notifyPass("webNavigation.from_address_bar.typed"); + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["webNavigation"], + }, + }); + + await extension.startup(); + await SimpleTest.promiseFocus(window); + + await extension.awaitMessage("ready"); + await promiseAutocompleteResultPopup("http://example.com/?q=typedClosed"); + await UrlbarTestUtils.promiseSearchComplete(window); + // Closing the popup forces a different code route that handles no results + // being displayed. + await UrlbarTestUtils.promisePopupClose(window); + EventUtils.synthesizeKey("VK_RETURN", {}); + + await extension.awaitFinish("webNavigation.from_address_bar.typed"); + + await extension.unload(); + } +); + +add_task(async function test_webnavigation_urlbar_bookmark_transitions() { + function backgroundScript() { + browser.webNavigation.onCommitted.addListener(msg => { + browser.test.assertEq( + "http://example.com/?q=bookmark", + msg.url, + "Got the expected url" + ); + + // assert from_address_bar transition qualifier + browser.test.assertTrue( + msg.transitionQualifiers && + msg.transitionQualifiers.includes("from_address_bar"), + "Got the expected from_address_bar transitionQualifier" + ); + browser.test.assertEq( + "auto_bookmark", + msg.transitionType, + "Got the expected transitionType" + ); + browser.test.notifyPass("webNavigation.from_address_bar.auto_bookmark"); + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["webNavigation"], + }, + }); + + await addBookmark({ + title: "Bookmark To Click", + url: "http://example.com/?q=bookmark", + }); + + await extension.startup(); + await SimpleTest.promiseFocus(window); + + await extension.awaitMessage("ready"); + + await promiseAutocompleteResultPopup("Bookmark To Click"); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + EventUtils.synthesizeMouseAtCenter(result.element.row, {}); + await extension.awaitFinish("webNavigation.from_address_bar.auto_bookmark"); + + await extension.unload(); +}); + +add_task(async function test_webnavigation_urlbar_keyword_transition() { + function backgroundScript() { + browser.webNavigation.onCommitted.addListener(msg => { + browser.test.assertEq( + `http://example.com/?q=search`, + msg.url, + "Got the expected url" + ); + + // assert from_address_bar transition qualifier + browser.test.assertTrue( + msg.transitionQualifiers && + msg.transitionQualifiers.includes("from_address_bar"), + "Got the expected from_address_bar transitionQualifier" + ); + browser.test.assertEq( + "keyword", + msg.transitionType, + "Got the expected transitionType" + ); + browser.test.notifyPass("webNavigation.from_address_bar.keyword"); + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["webNavigation"], + }, + }); + + await addBookmark({ + title: "Test Keyword", + url: "http://example.com/?q=%s", + keyword: "testkw", + }); + + await extension.startup(); + await SimpleTest.promiseFocus(window); + + await extension.awaitMessage("ready"); + + await promiseAutocompleteResultPopup("testkw search"); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + EventUtils.synthesizeMouseAtCenter(result.element.row, {}); + + await extension.awaitFinish("webNavigation.from_address_bar.keyword"); + + await extension.unload(); +}); + +add_task(async function test_webnavigation_urlbar_search_transitions() { + function backgroundScript() { + browser.webNavigation.onCommitted.addListener(msg => { + browser.test.assertEq( + "http://mochi.test:8888/", + msg.url, + "Got the expected url" + ); + + // assert from_address_bar transition qualifier + browser.test.assertTrue( + msg.transitionQualifiers && + msg.transitionQualifiers.includes("from_address_bar"), + "Got the expected from_address_bar transitionQualifier" + ); + browser.test.assertEq( + "generated", + msg.transitionType, + "Got the expected 'generated' transitionType" + ); + browser.test.notifyPass("webNavigation.from_address_bar.generated"); + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["webNavigation"], + }, + }); + + await extension.startup(); + await SimpleTest.promiseFocus(window); + + await extension.awaitMessage("ready"); + + await prepareSearchEngine(); + await promiseAutocompleteResultPopup("foo"); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + EventUtils.synthesizeMouseAtCenter(result.element.row, {}); + + await extension.awaitFinish("webNavigation.from_address_bar.generated"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_webRequest.js b/browser/components/extensions/test/browser/browser_ext_webRequest.js new file mode 100644 index 0000000000..c2ff1d6c64 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webRequest.js @@ -0,0 +1,142 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* import-globals-from ../../../../../toolkit/components/extensions/test/mochitest/head_webrequest.js */ +loadTestSubscript("head_webrequest.js"); + +const { HiddenFrame } = ChromeUtils.importESModule( + "resource://gre/modules/HiddenFrame.sys.mjs" +); +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +SimpleTest.requestCompleteLog(); + +function createHiddenBrowser(url) { + let frame = new HiddenFrame(); + return new Promise(resolve => + frame.get().then(subframe => { + let doc = subframe.document; + let browser = doc.createElementNS(XUL_NS, "browser"); + browser.setAttribute("type", "content"); + browser.setAttribute("remote", "true"); + browser.setAttribute("disableglobalhistory", "true"); + browser.setAttribute("src", url); + + doc.documentElement.appendChild(browser); + resolve({ frame: frame, browser: browser }); + }) + ); +} + +let extension; +let dummy = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/file_dummy.html"; +let headers = { + request: { + add: { + "X-WebRequest-request": "text", + "X-WebRequest-request-binary": "binary", + }, + modify: { + "user-agent": "WebRequest", + }, + remove: ["accept-encoding"], + }, + response: { + add: { + "X-WebRequest-response": "text", + "X-WebRequest-response-binary": "binary", + }, + modify: { + server: "WebRequest", + "content-type": "text/html; charset=utf-8", + }, + remove: ["connection"], + }, +}; + +let urls = ["http://mochi.test/browser/*"]; +let events = { + onBeforeRequest: [{ urls }, ["blocking"]], + onBeforeSendHeaders: [{ urls }, ["blocking", "requestHeaders"]], + onSendHeaders: [{ urls }, ["requestHeaders"]], + onHeadersReceived: [{ urls }, ["blocking", "responseHeaders"]], + onCompleted: [{ urls }, ["responseHeaders"]], +}; + +add_setup(async function () { + extension = makeExtension(events); + await extension.startup(); +}); + +add_task(async function test_newWindow() { + let expect = { + "file_dummy.html": { + type: "main_frame", + headers, + }, + }; + // NOTE: When running solo, favicon will be loaded at some point during + // the tests in this file, so all tests ignore it. When running with + // other tests in this directory, favicon gets loaded at some point before + // we run, and we never see the request, thus it cannot be handled as part + // of expect above. + extension.sendMessage("set-expected", { expect, ignore: ["favicon.ico"] }); + await extension.awaitMessage("continue"); + + let openedWindow = await BrowserTestUtils.openNewBrowserWindow(); + await BrowserTestUtils.openNewForegroundTab( + openedWindow.gBrowser, + `${dummy}?newWindow=${Math.random()}` + ); + + await extension.awaitMessage("done"); + await BrowserTestUtils.closeWindow(openedWindow); +}); + +add_task(async function test_newTab() { + // again, in this window + let expect = { + "file_dummy.html": { + type: "main_frame", + headers, + }, + }; + extension.sendMessage("set-expected", { expect, ignore: ["favicon.ico"] }); + await extension.awaitMessage("continue"); + let tab = await BrowserTestUtils.openNewForegroundTab( + window.gBrowser, + `${dummy}?newTab=${Math.random()}` + ); + + await extension.awaitMessage("done"); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_subframe() { + let expect = { + "file_dummy.html": { + type: "main_frame", + headers, + }, + }; + // test a content subframe attached to hidden window + extension.sendMessage("set-expected", { expect, ignore: ["favicon.ico"] }); + info("*** waiting to continue"); + await extension.awaitMessage("continue"); + info("*** creating hidden browser"); + let frameInfo = await createHiddenBrowser( + `${dummy}?subframe=${Math.random()}` + ); + info("*** waiting for finish"); + await extension.awaitMessage("done"); + info("*** destroying hidden browser"); + // cleanup + frameInfo.browser.remove(); + frameInfo.frame.destroy(); +}); + +add_task(async function teardown() { + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_webRequest_error_after_stopped_or_closed.js b/browser/components/extensions/test/browser/browser_ext_webRequest_error_after_stopped_or_closed.js new file mode 100644 index 0000000000..75d85dd3bf --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webRequest_error_after_stopped_or_closed.js @@ -0,0 +1,110 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const SLOW_PAGE = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://www.example.com" + ) + "file_slowed_document.sjs"; + +async function runTest(stopLoadFunc) { + async function background() { + let urls = ["https://www.example.com/*"]; + browser.webRequest.onCompleted.addListener( + details => { + browser.test.sendMessage("done", { + msg: "onCompleted", + requestId: details.requestId, + }); + }, + { urls } + ); + + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("onBeforeRequest", { + requestId: details.requestId, + }); + }, + { urls }, + ["blocking"] + ); + + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.sendMessage("done", { + msg: "onErrorOccurred", + requestId: details.requestId, + }); + }, + { urls } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "https://www.example.com/*", + ], + }, + background, + }); + await extension.startup(); + + // Open a SLOW_PAGE and don't wait for it to load + let slowTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SLOW_PAGE, + false + ); + + stopLoadFunc(slowTab); + + // Retrieve the requestId from onBeforeRequest + let requestIdOnBeforeRequest = await extension.awaitMessage( + "onBeforeRequest" + ); + + // Now verify that we got the correct event and request id + let doneMessage = await extension.awaitMessage("done"); + + // We shouldn't get the onCompleted message here + is(doneMessage.msg, "onErrorOccurred", "received onErrorOccurred message"); + is( + requestIdOnBeforeRequest.requestId, + doneMessage.requestId, + "request Ids match" + ); + + BrowserTestUtils.removeTab(slowTab); + await extension.unload(); +} + +/** + * Check that after we cancel a slow page load, we get an error associated with + * our request. + */ +add_task(async function test_click_stop_button() { + await runTest(async slowTab => { + // Stop the load + let stopButton = document.getElementById("stop-button"); + await TestUtils.waitForCondition(() => { + return !stopButton.disabled; + }); + stopButton.click(); + }); +}); + +/** + * Check that after we close the tab corresponding to a slow page load, + * that we get an error associated with our request. + */ +add_task(async function test_remove_tab() { + await runTest(slowTab => { + // Remove the tab + BrowserTestUtils.removeTab(slowTab); + }); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_webrtc.js b/browser/components/extensions/test/browser/browser_ext_webrtc.js new file mode 100644 index 0000000000..520cb9cd69 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webrtc.js @@ -0,0 +1,131 @@ +"use strict"; + +const { PermissionTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PermissionTestUtils.sys.mjs" +); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["media.navigator.permission.fake", true]], + }); +}); + +add_task(async function test_background_request() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: {}, + async background() { + browser.test.onMessage.addListener(async msg => { + if (msg.type != "testGUM") { + browser.test.fail("unknown message"); + } + + await browser.test.assertRejects( + navigator.mediaDevices.getUserMedia({ audio: true }), + /The request is not allowed/, + "Calling gUM in background pages throws an error" + ); + browser.test.notifyPass("done"); + }); + }, + }); + + await extension.startup(); + + let policy = WebExtensionPolicy.getByID(extension.id); + let principal = policy.extension.principal; + // Add a permission for the extension to make sure that we throw even + // if permission was given. + PermissionTestUtils.add(principal, "microphone", Services.perms.ALLOW_ACTION); + + let finished = extension.awaitFinish("done"); + extension.sendMessage({ type: "testGUM" }); + await finished; + + PermissionTestUtils.remove(principal, "microphone"); + await extension.unload(); +}); + +let scriptPage = url => + `${url}`; + +add_task(async function test_popup_request() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + browser_style: true, + }, + }, + + files: { + "popup.html": scriptPage("popup.js"), + "popup.js": function () { + browser.test + .assertRejects( + navigator.mediaDevices.getUserMedia({ audio: true }), + /The request is not allowed/, + "Calling gUM in popup pages without permission throws an error" + ) + .then(function () { + browser.test.notifyPass("done"); + }); + }, + }, + }); + + await extension.startup(); + clickBrowserAction(extension); + await extension.awaitFinish("done"); + await extension.unload(); + + extension = ExtensionTestUtils.loadExtension({ + manifest: { + // Use the same url for background page and browserAction popup, + // to double-check that the page url is not being used to decide + // if webRTC requests should be allowed or not. + background: { page: "page.html" }, + browser_action: { + default_popup: "page.html", + browser_style: true, + }, + }, + + files: { + "page.html": scriptPage("page.js"), + "page.js": async function () { + const isBackgroundPage = + window == (await browser.runtime.getBackgroundPage()); + + if (isBackgroundPage) { + await browser.test.assertRejects( + navigator.mediaDevices.getUserMedia({ audio: true }), + /The request is not allowed/, + "Calling gUM in background pages throws an error" + ); + } else { + try { + await navigator.mediaDevices.getUserMedia({ audio: true }); + browser.test.notifyPass("done"); + } catch (err) { + browser.test.fail(`Failed with error ${err.message}`); + browser.test.notifyFail("done"); + } + } + }, + }, + }); + + // Add a permission for the extension to make sure that we throw even + // if permission was given. + await extension.startup(); + + let policy = WebExtensionPolicy.getByID(extension.id); + let principal = policy.extension.principal; + + PermissionTestUtils.add(principal, "microphone", Services.perms.ALLOW_ACTION); + clickBrowserAction(extension); + + await extension.awaitFinish("done"); + PermissionTestUtils.remove(principal, "microphone"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows.js b/browser/components/extensions/test/browser/browser_ext_windows.js new file mode 100644 index 0000000000..8d6cff25b4 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows.js @@ -0,0 +1,348 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Since we apply title localization asynchronously, +// we'll use this helper to wait for the title to match +// the condition and then test against it. +async function verifyTitle(win, test, desc) { + await TestUtils.waitForCondition(test); + ok(true, desc); +} + +add_task(async function testWindowGetAll() { + let raisedWin = Services.ww.openWindow( + null, + AppConstants.BROWSER_CHROME_URL, + "_blank", + "chrome,dialog=no,all,alwaysRaised", + null + ); + + await TestUtils.topicObserved( + "browser-delayed-startup-finished", + subject => subject == raisedWin + ); + + let extension = ExtensionTestUtils.loadExtension({ + background: async function () { + let wins = await browser.windows.getAll(); + browser.test.assertEq(2, wins.length, "Expect two windows"); + + browser.test.assertEq( + false, + wins[0].alwaysOnTop, + "Expect first window not to be always on top" + ); + browser.test.assertEq( + true, + wins[1].alwaysOnTop, + "Expect first window to be always on top" + ); + + let win = await browser.windows.create({ + url: "http://example.com", + type: "popup", + }); + + wins = await browser.windows.getAll(); + browser.test.assertEq(3, wins.length, "Expect three windows"); + + wins = await browser.windows.getAll({ windowTypes: ["popup"] }); + browser.test.assertEq(1, wins.length, "Expect one window"); + browser.test.assertEq("popup", wins[0].type, "Expect type to be popup"); + + wins = await browser.windows.getAll({ windowTypes: ["normal"] }); + browser.test.assertEq(2, wins.length, "Expect two windows"); + browser.test.assertEq("normal", wins[0].type, "Expect type to be normal"); + browser.test.assertEq("normal", wins[1].type, "Expect type to be normal"); + + wins = await browser.windows.getAll({ windowTypes: ["popup", "normal"] }); + browser.test.assertEq(3, wins.length, "Expect three windows"); + + await browser.windows.remove(win.id); + + browser.test.notifyPass("getAll"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("getAll"); + await extension.unload(); + + await BrowserTestUtils.closeWindow(raisedWin); +}); + +add_task(async function testWindowTitle() { + const PREFACE1 = "My prefix1 - "; + const PREFACE2 = "My prefix2 - "; + const START_URL = + "http://example.com/browser/browser/components/extensions/test/browser/file_dummy.html"; + const START_TITLE = "Dummy test page"; + const NEW_URL = + "http://example.com/browser/browser/components/extensions/test/browser/file_title.html"; + const NEW_TITLE = "Different title test page"; + + async function background() { + browser.test.onMessage.addListener( + async (msg, options, windowId, expected) => { + if (msg === "create") { + let win = await browser.windows.create(options); + browser.test.sendMessage("created", win); + } + if (msg === "update") { + let win = await browser.windows.get(windowId); + browser.test.assertTrue( + win.title.startsWith(expected.before.preface), + "Window has the expected title preface before update." + ); + browser.test.assertTrue( + win.title.includes(expected.before.text), + "Window has the expected title text before update." + ); + win = await browser.windows.update(windowId, options); + browser.test.assertTrue( + win.title.startsWith(expected.after.preface), + "Window has the expected title preface after update." + ); + browser.test.assertTrue( + win.title.includes(expected.after.text), + "Window has the expected title text after update." + ); + browser.test.sendMessage("updated", win); + } + } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["tabs"], + }, + }); + + await extension.startup(); + const { + Management: { + global: { windowTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + + async function createApiWin(options) { + let promiseLoaded = BrowserTestUtils.waitForNewWindow({ url: START_URL }); + extension.sendMessage("create", options); + let apiWin = await extension.awaitMessage("created"); + let realWin = windowTracker.getWindow(apiWin.id); + await promiseLoaded; + let expectedPreface = options.titlePreface ? options.titlePreface : ""; + await verifyTitle( + realWin, + () => { + return ( + realWin.document.title.startsWith(expectedPreface || START_TITLE) && + realWin.document.title.includes(START_TITLE) + ); + }, + "Created window starts with the expected preface and includes the right title text." + ); + return apiWin; + } + + async function updateWindow(options, apiWin, expected) { + extension.sendMessage("update", options, apiWin.id, expected); + await extension.awaitMessage("updated"); + let realWin = windowTracker.getWindow(apiWin.id); + await verifyTitle( + realWin, + () => { + return ( + realWin.document.title.startsWith( + expected.after.preface || expected.after.text + ) && realWin.document.title.includes(expected.after.text) + ); + }, + "Updated window starts with the expected preface and includes the right title text." + ); + await BrowserTestUtils.closeWindow(realWin); + } + + // Create a window without a preface. + let apiWin = await createApiWin({ url: START_URL }); + + // Add a titlePreface to the window. + let expected = { + before: { + preface: "", + text: START_TITLE, + }, + after: { + preface: PREFACE1, + text: START_TITLE, + }, + }; + await updateWindow({ titlePreface: PREFACE1 }, apiWin, expected); + + // Create a window with a preface. + apiWin = await createApiWin({ url: START_URL, titlePreface: PREFACE1 }); + + // Navigate to a different url and check that title is reflected. + let realWin = windowTracker.getWindow(apiWin.id); + let promiseLoaded = BrowserTestUtils.browserLoaded( + realWin.gBrowser.selectedBrowser + ); + BrowserTestUtils.startLoadingURIString( + realWin.gBrowser.selectedBrowser, + NEW_URL + ); + await promiseLoaded; + await verifyTitle( + realWin, + () => { + return ( + realWin.document.title.startsWith(PREFACE1) && + realWin.document.title.includes(NEW_TITLE) + ); + }, + "Updated window starts with the expected preface and includes the expected title." + ); + + // Update the titlePreface of the window. + expected = { + before: { + preface: PREFACE1, + text: NEW_TITLE, + }, + after: { + preface: PREFACE2, + text: NEW_TITLE, + }, + }; + await updateWindow({ titlePreface: PREFACE2 }, apiWin, expected); + + // Create a window with a preface. + apiWin = await createApiWin({ url: START_URL, titlePreface: PREFACE1 }); + realWin = windowTracker.getWindow(apiWin.id); + + // Update the titlePreface of the window with an empty string. + expected = { + before: { + preface: PREFACE1, + text: START_TITLE, + }, + after: { + preface: "", + text: START_TITLE, + }, + }; + await verifyTitle( + realWin, + () => realWin.document.title.startsWith(expected.before.preface), + "Updated window has the expected title preface." + ); + await updateWindow({ titlePreface: "" }, apiWin, expected); + await verifyTitle( + realWin, + () => !realWin.document.title.startsWith(expected.before.preface), + "Updated window doesn't not contain the preface after update." + ); + + // Create a window with a preface. + apiWin = await createApiWin({ url: START_URL, titlePreface: PREFACE1 }); + realWin = windowTracker.getWindow(apiWin.id); + + // Update the window without a titlePreface. + expected = { + before: { + preface: PREFACE1, + text: START_TITLE, + }, + after: { + preface: PREFACE1, + text: START_TITLE, + }, + }; + await updateWindow({}, apiWin, expected); + + await extension.unload(); +}); + +// Test that the window title is only available with the correct tab +// permissions. +add_task(async function testWindowTitlePermissions() { + let tab = await BrowserTestUtils.openNewForegroundTab( + window.gBrowser, + "http://example.com/" + ); + + let extension = ExtensionTestUtils.loadExtension({ + async background() { + function awaitMessage(name) { + return new Promise(resolve => { + browser.test.onMessage.addListener(function listener(...msg) { + if (msg[0] === name) { + browser.test.onMessage.removeListener(listener); + resolve(msg[1]); + } + }); + }); + } + + let window = await browser.windows.getCurrent(); + + browser.test.assertEq( + undefined, + window.title, + "Window title should be null without tab permission" + ); + + browser.test.sendMessage("grant-activeTab"); + let expectedTitle = await awaitMessage("title"); + + window = await browser.windows.getCurrent(); + browser.test.assertEq( + expectedTitle, + window.title, + "Window should have the expected title with tab permission granted" + ); + + await browser.test.notifyPass("window-title-permissions"); + }, + manifest: { + permissions: ["activeTab"], + browser_action: { + default_area: "navbar", + }, + }, + }); + + await extension.startup(); + + await extension.awaitMessage("grant-activeTab"); + await clickBrowserAction(extension); + extension.sendMessage("title", document.title); + + await extension.awaitFinish("window-title-permissions"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function testInvalidWindowId() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.test.assertRejects( + // Assuming that this windowId does not exist. + browser.windows.get(123456789), + /Invalid window/, + "Should receive invalid window" + ); + browser.test.notifyPass("windows.get.invalid"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("windows.get.invalid"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_allowScriptsToClose.js b/browser/components/extensions/test/browser/browser_ext_windows_allowScriptsToClose.js new file mode 100644 index 0000000000..c89bcfce77 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_allowScriptsToClose.js @@ -0,0 +1,69 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Tests allowScriptsToClose option +add_task(async function test_allowScriptsToClose() { + const files = { + "dummy.html": "", + "close.js": function () { + window.close(); + if (!window.closed) { + browser.test.sendMessage("close-failed"); + } + }, + }; + + function background() { + browser.test.onMessage.addListener((msg, options) => { + function listener(_, { status }, { url }) { + if (status == "complete" && url == options.url) { + browser.tabs.onUpdated.removeListener(listener); + browser.tabs.executeScript({ file: "close.js" }); + } + } + options.url = browser.runtime.getURL(options.url); + browser.windows.create(options); + if (msg === "create+execute") { + browser.tabs.onUpdated.addListener(listener); + } + }); + browser.test.notifyPass(); + } + + const example = "http://example.com/"; + const manifest = { permissions: ["tabs", example] }; + + const extension = ExtensionTestUtils.loadExtension({ + files, + background, + manifest, + }); + await SpecialPowers.pushPrefEnv({ + set: [["dom.allow_scripts_to_close_windows", false]], + }); + + await extension.startup(); + await extension.awaitFinish(); + + extension.sendMessage("create", { url: "dummy.html" }); + let win = await BrowserTestUtils.waitForNewWindow(); + await BrowserTestUtils.windowClosed(win); + info("script allowed to close the window"); + + extension.sendMessage("create+execute", { url: example }); + win = await BrowserTestUtils.waitForNewWindow(); + await BrowserTestUtils.windowClosed(win); + info("script allowed to close the window"); + + extension.sendMessage("create+execute", { + url: example, + allowScriptsToClose: true, + }); + win = await BrowserTestUtils.waitForNewWindow(); + await BrowserTestUtils.windowClosed(win); + info("script allowed to close the window"); + + await SpecialPowers.popPrefEnv(); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_create.js b/browser/components/extensions/test/browser/browser_ext_windows_create.js new file mode 100644 index 0000000000..6c41abcd3e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_create.js @@ -0,0 +1,205 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testWindowCreate() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + let _checkWindowPromise; + browser.test.onMessage.addListener(msg => { + if (msg == "checked-window") { + _checkWindowPromise.resolve(); + _checkWindowPromise = null; + } + }); + + let os; + + function checkWindow(expected) { + return new Promise(resolve => { + _checkWindowPromise = { resolve }; + browser.test.sendMessage("check-window", expected); + }); + } + + async function createWindow(params, expected, keep = false) { + let window = await browser.windows.create(...params); + // params is null when testing create without createData + params = params[0] || {}; + + // Prevent frequent intermittent failures on macos where the newly created window + // may have not always got into the fullscreen state before browser.window.create + // resolves the windows details. + if ( + os === "mac" && + params.state === "fullscreen" && + window.state !== params.state + ) { + browser.test.log( + "Wait for window.state for the newly create window to be set to fullscreen" + ); + while (window.state !== params.state) { + window = await browser.windows.get(window.id, { populate: true }); + } + browser.test.log( + "Newly created browser window got into fullscreen state" + ); + } + + for (let key of Object.keys(params)) { + if (key == "state" && os == "mac" && params.state == "normal") { + // OS-X doesn't have a hard distinction between "normal" and + // "maximized" states. + browser.test.assertTrue( + window.state == "normal" || window.state == "maximized", + `Expected window.state (currently ${window.state}) to be "normal" but will accept "maximized"` + ); + } else { + browser.test.assertEq( + params[key], + window[key], + `Got expected value for window.${key}` + ); + } + } + + browser.test.assertEq( + 1, + window.tabs.length, + "tabs property got populated" + ); + + await checkWindow(expected); + if (keep) { + return window; + } + + if (params.state == "fullscreen" && os == "win") { + // FIXME: Closing a fullscreen window causes a window leak in + // Windows tests. + await browser.windows.update(window.id, { state: "normal" }); + } + await browser.windows.remove(window.id); + } + + try { + ({ os } = await browser.runtime.getPlatformInfo()); + + // Set the current window to state: "normal" because the test is failing on Windows + // where the current window is maximized. + let currentWindow = await browser.windows.getCurrent(); + await browser.windows.update(currentWindow.id, { state: "normal" }); + + await createWindow([], { state: "STATE_NORMAL" }); + await createWindow([{ state: "maximized" }], { + state: "STATE_MAXIMIZED", + }); + await createWindow([{ state: "minimized" }], { + state: "STATE_MINIMIZED", + }); + await createWindow([{ state: "normal" }], { + state: "STATE_NORMAL", + hiddenChrome: [], + }); + await createWindow([{ state: "fullscreen" }], { + state: "STATE_FULLSCREEN", + }); + + let window = await createWindow( + [{ type: "popup" }], + { + hiddenChrome: [ + "menubar", + "toolbar", + "location", + "directories", + "status", + "extrachrome", + ], + chromeFlags: ["CHROME_OPENAS_DIALOG"], + }, + true + ); + + let tabs = await browser.tabs.query({ + windowType: "popup", + active: true, + }); + + browser.test.assertEq(1, tabs.length, "Expected only one popup"); + browser.test.assertEq( + window.id, + tabs[0].windowId, + "Expected new window to be returned in query" + ); + + await browser.windows.remove(window.id); + + browser.test.notifyPass("window-create"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("window-create"); + } + }, + }); + + let latestWindow; + let windowListener = (window, topic) => { + if (topic == "domwindowopened") { + latestWindow = window; + } + }; + Services.ww.registerNotification(windowListener); + + extension.onMessage("check-window", expected => { + if (expected.state != null) { + let { windowState } = latestWindow; + if (latestWindow.fullScreen) { + windowState = latestWindow.STATE_FULLSCREEN; + } + + if (expected.state == "STATE_NORMAL") { + ok( + windowState == window.STATE_NORMAL || + windowState == window.STATE_MAXIMIZED, + `Expected windowState (currently ${windowState}) to be STATE_NORMAL but will accept STATE_MAXIMIZED` + ); + } else { + is( + windowState, + window[expected.state], + `Expected window state to be ${expected.state}` + ); + } + } + if (expected.hiddenChrome) { + let chromeHidden = + latestWindow.document.documentElement.getAttribute("chromehidden"); + is( + chromeHidden.trim().split(/\s+/).sort().join(" "), + expected.hiddenChrome.sort().join(" "), + "Got expected hidden chrome" + ); + } + if (expected.chromeFlags) { + let { chromeFlags } = latestWindow.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow); + for (let flag of expected.chromeFlags) { + ok( + chromeFlags & Ci.nsIWebBrowserChrome[flag], + `Expected window to have the ${flag} flag` + ); + } + } + + extension.sendMessage("checked-window"); + }); + + await extension.startup(); + await extension.awaitFinish("window-create"); + await extension.unload(); + + Services.ww.unregisterNotification(windowListener); + latestWindow = null; +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_create_cookieStoreId.js b/browser/components/extensions/test/browser/browser_ext_windows_create_cookieStoreId.js new file mode 100644 index 0000000000..5eda338fe5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_create_cookieStoreId.js @@ -0,0 +1,345 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function no_cookies_permission() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.test.assertRejects( + browser.windows.create({ cookieStoreId: "firefox-container-1" }), + /No permission for cookieStoreId/, + "cookieStoreId requires cookies permission" + ); + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function invalid_cookieStoreId() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["cookies"], + }, + async background() { + await browser.test.assertRejects( + browser.windows.create({ cookieStoreId: "not-firefox-container-1" }), + /Illegal cookieStoreId/, + "cookieStoreId must be valid" + ); + + await browser.test.assertRejects( + browser.windows.create({ cookieStoreId: "firefox-private" }), + /Illegal to set private cookieStoreId in a non-private window/, + "cookieStoreId cannot be private in a non-private window" + ); + await browser.test.assertRejects( + browser.windows.create({ + cookieStoreId: "firefox-default", + incognito: true, + }), + /Illegal to set non-private cookieStoreId in a private window/, + "cookieStoreId cannot be non-private in an private window" + ); + + await browser.test.assertRejects( + browser.windows.create({ + cookieStoreId: "firefox-container-1", + incognito: true, + }), + /Illegal to set non-private cookieStoreId in a private window/, + "cookieStoreId cannot be a container tab ID in a private window" + ); + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function perma_private_browsing_mode() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.privatebrowsing.autostart", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["tabs", "cookies"], + }, + async background() { + await browser.test.assertRejects( + browser.windows.create({ cookieStoreId: "firefox-container-1" }), + /Contextual identities are unavailable in permanent private browsing mode/, + "cookieStoreId cannot be a container tab ID in perma-private browsing mode" + ); + + browser.test.sendMessage("done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function userContext_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", false]], + }); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "cookies"], + }, + async background() { + await browser.test.assertRejects( + browser.windows.create({ cookieStoreId: "firefox-container-1" }), + /Contextual identities are currently disabled/, + "cookieStoreId cannot be a container tab ID when contextual identities are disabled" + ); + browser.test.sendMessage("done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function valid_cookieStoreId() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + + const testCases = [ + { + description: "no explicit URL", + createParams: { + cookieStoreId: "firefox-container-1", + }, + expectedCookieStoreIds: ["firefox-container-1"], + expectedExecuteScriptResult: [ + // Default URL is about:home, and extensions cannot run scripts in it. + "Missing host permission for the tab", + ], + }, + { + description: "one URL", + createParams: { + url: "about:blank", + cookieStoreId: "firefox-container-1", + }, + expectedCookieStoreIds: ["firefox-container-1"], + expectedExecuteScriptResult: ["about:blank - null"], + }, + { + description: "one URL in an array", + createParams: { + url: ["about:blank"], + cookieStoreId: "firefox-container-1", + }, + expectedCookieStoreIds: ["firefox-container-1"], + expectedExecuteScriptResult: ["about:blank - null"], + }, + { + description: "two URLs in an array", + createParams: { + url: ["about:blank", "about:blank"], + cookieStoreId: "firefox-container-1", + }, + expectedCookieStoreIds: ["firefox-container-1", "firefox-container-1"], + expectedExecuteScriptResult: ["about:blank - null", "about:blank - null"], + }, + ]; + + async function background(testCases) { + let readyTabs = new Map(); + let tabReadyCheckers = new Set(); + browser.webNavigation.onCompleted.addListener(({ url, tabId, frameId }) => { + if (frameId === 0) { + readyTabs.set(tabId, url); + browser.test.log(`Detected navigation in tab ${tabId} to ${url}.`); + + for (let check of tabReadyCheckers) { + check(tabId, url); + } + } + }); + async function awaitTabReady(tabId, expectedUrl) { + if (readyTabs.get(tabId) === expectedUrl) { + browser.test.log(`Tab ${tabId} was ready with URL ${expectedUrl}.`); + return; + } + await new Promise(resolve => { + browser.test.log( + `Waiting for tab ${tabId} to load URL ${expectedUrl}...` + ); + tabReadyCheckers.add(function check(completedTabId, completedUrl) { + if (completedTabId === tabId && completedUrl === expectedUrl) { + tabReadyCheckers.delete(check); + resolve(); + } + }); + }); + browser.test.log(`Tab ${tabId} is ready with URL ${expectedUrl}.`); + } + + async function executeScriptAndGetResult(tabId) { + try { + return ( + await browser.tabs.executeScript(tabId, { + matchAboutBlank: true, + code: "`${document.URL} - ${origin}`", + }) + )[0]; + } catch (e) { + return e.message; + } + } + for (let { + description, + createParams, + expectedCookieStoreIds, + expectedExecuteScriptResult, + } of testCases) { + let win = await browser.windows.create(createParams); + + browser.test.assertEq( + expectedCookieStoreIds.length, + win.tabs.length, + "Expected number of tabs" + ); + + for (let [i, expectedCookieStoreId] of Object.entries( + expectedCookieStoreIds + )) { + browser.test.assertEq( + expectedCookieStoreId, + win.tabs[i].cookieStoreId, + `expected cookieStoreId for tab ${i} (${description})` + ); + } + + for (let [i, expectedResult] of Object.entries( + expectedExecuteScriptResult + )) { + // Wait until the the tab can process the tabs.executeScript calls. + // TODO: Remove this when bug 1418655 and bug 1397667 are fixed. + let expectedUrl = Array.isArray(createParams.url) + ? createParams.url[i] + : createParams.url || "about:home"; + await awaitTabReady(win.tabs[i].id, expectedUrl); + + let result = await executeScriptAndGetResult(win.tabs[i].id); + browser.test.assertEq( + expectedResult, + result, + `expected executeScript result for tab ${i} (${description})` + ); + } + + await browser.windows.remove(win.id); + } + browser.test.sendMessage("done"); + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + host_permissions: ["*://*/*"], // allows script in top-level about:blank. + permissions: ["cookies", "webNavigation"], + }, + background: `(${background})(${JSON.stringify(testCases)})`, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function cookieStoreId_and_tabId() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["cookies"], + }, + async background() { + for (let cookieStoreId of ["firefox-default", "firefox-container-1"]) { + let { id: normalTabId } = await browser.tabs.create({ cookieStoreId }); + + await browser.test.assertRejects( + browser.windows.create({ + cookieStoreId: "firefox-private", + tabId: normalTabId, + }), + /`cookieStoreId` must match the tab's cookieStoreId/, + "Cannot use cookieStoreId for pre-existing tabs with a different cookieStoreId" + ); + + let win = await browser.windows.create({ + cookieStoreId, + tabId: normalTabId, + }); + browser.test.assertEq( + cookieStoreId, + win.tabs[0].cookieStoreId, + "Adopted tab" + ); + await browser.windows.remove(win.id); + } + + { + let privateWindow = await browser.windows.create({ incognito: true }); + let privateTabId = privateWindow.tabs[0].id; + + await browser.test.assertRejects( + browser.windows.create({ + cookieStoreId: "firefox-default", + tabId: privateTabId, + }), + /`cookieStoreId` must match the tab's cookieStoreId/, + "Cannot use cookieStoreId for pre-existing tab in a private window" + ); + let win = await browser.windows.create({ + cookieStoreId: "firefox-private", + tabId: privateTabId, + }); + browser.test.assertEq( + "firefox-private", + win.tabs[0].cookieStoreId, + "Adopted private tab" + ); + await browser.windows.remove(win.id); + + await browser.test.assertRejects( + browser.windows.remove(privateWindow.id), + /Invalid window ID:/, + "The original private window should have been closed when its only tab was adopted." + ); + } + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_create_params.js b/browser/components/extensions/test/browser/browser_ext_windows_create_params.js new file mode 100644 index 0000000000..6d80085433 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_create_params.js @@ -0,0 +1,249 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +AddonTestUtils.initMochitest(this); + +// Tests that incompatible parameters can't be used together. +add_task(async function testWindowCreateParams() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + try { + for (let state of ["minimized", "maximized", "fullscreen"]) { + for (let param of ["left", "top", "width", "height"]) { + let expected = `"state": "${state}" may not be combined with "left", "top", "width", or "height"`; + + await browser.test.assertRejects( + browser.windows.create({ state, [param]: 100 }), + RegExp(expected), + `Got expected error from create(${param}=100)` + ); + } + } + + browser.test.notifyPass("window-create-params"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("window-create-params"); + } + }, + }); + + await extension.startup(); + await extension.awaitFinish("window-create-params"); + await extension.unload(); +}); + +// We do not support the focused param, however we do not want +// to fail despite an error when it is passed. This provides +// better code level compatibility with chrome. +add_task(async function testWindowCreateFocused() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + async function doWaitForWindow(createOpts, resolve) { + let created; + browser.windows.onFocusChanged.addListener(async function listener( + wid + ) { + if (wid == browser.windows.WINDOW_ID_NONE) { + return; + } + let win = await created; + if (win.id !== wid) { + return; + } + browser.windows.onFocusChanged.removeListener(listener); + // update the window object + let window = await browser.windows.get(wid); + resolve(window); + }); + created = browser.windows.create(createOpts); + } + async function awaitNewFocusedWindow(createOpts) { + return new Promise(resolve => { + // eslint doesn't like an async promise function, so + // we need to wrap it like this. + doWaitForWindow(createOpts, resolve); + }); + } + try { + let window = await awaitNewFocusedWindow({}); + browser.test.assertEq( + window.focused, + true, + "window is focused without focused param" + ); + browser.test.log("removeWindow"); + await browser.windows.remove(window.id); + window = await awaitNewFocusedWindow({ focused: true }); + browser.test.assertEq( + window.focused, + true, + "window is focused with focused: true" + ); + browser.test.log("removeWindow"); + await browser.windows.remove(window.id); + window = await awaitNewFocusedWindow({ focused: false }); + browser.test.assertEq( + window.focused, + true, + "window is focused with focused: false" + ); + browser.test.log("removeWindow"); + await browser.windows.remove(window.id); + browser.test.notifyPass("window-create-params"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("window-create-params"); + } + }, + }); + + ExtensionTestUtils.failOnSchemaWarnings(false); + let { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + await extension.startup(); + await extension.awaitFinish("window-create-params"); + await extension.unload(); + }); + + AddonTestUtils.checkMessages( + messages, + { + expected: [ + { + message: + /Warning processing focused: Opening inactive windows is not supported/, + }, + ], + }, + "Expected warning processing focused" + ); + + ExtensionTestUtils.failOnSchemaWarnings(true); +}); + +add_task(async function testPopupTypeWithDimension() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.windows.create({ + type: "popup", + left: 123, + top: 123, + width: 151, + height: 152, + }); + await browser.windows.create({ + type: "popup", + left: 123, + width: 152, + height: 153, + }); + await browser.windows.create({ + type: "popup", + top: 123, + width: 153, + height: 154, + }); + await browser.windows.create({ + type: "popup", + left: screen.availWidth * 100, + top: screen.availHeight * 100, + width: 154, + height: 155, + }); + await browser.windows.create({ + type: "popup", + left: -screen.availWidth * 100, + top: -screen.availHeight * 100, + width: 155, + height: 156, + }); + browser.test.sendMessage("windows-created"); + }, + }); + + const baseWindow = await BrowserTestUtils.openNewBrowserWindow(); + baseWindow.resizeTo(150, 150); + baseWindow.moveTo(50, 50); + + let windows = []; + let windowListener = (window, topic) => { + if (topic == "domwindowopened") { + windows.push(window); + } + }; + Services.ww.registerNotification(windowListener); + + await extension.startup(); + await extension.awaitMessage("windows-created"); + await extension.unload(); + + const regularScreen = getScreenAt(0, 0, 150, 150); + const roundedX = roundCssPixcel(123, regularScreen); + const roundedY = roundCssPixcel(123, regularScreen); + + const availRectLarge = getCssAvailRect( + getScreenAt(screen.width * 100, screen.height * 100, 150, 150) + ); + const maxRight = availRectLarge.right; + const maxBottom = availRectLarge.bottom; + + const availRectSmall = getCssAvailRect( + getScreenAt(-screen.width * 100, -screen.height * 100, 150, 150150) + ); + const minLeft = availRectSmall.left; + const minTop = availRectSmall.top; + + const actualCoordinates = windows + .slice(0, 3) + .map(window => `${window.screenX},${window.screenY}`); + const offsetFromBase = 10; + const expectedCoordinates = [ + `${roundedX},${roundedY}`, + // Missing top should be +10 from the last browser window. + `${roundedX},${baseWindow.screenY + offsetFromBase}`, + // Missing left should be +10 from the last browser window. + `${baseWindow.screenX + offsetFromBase},${roundedY}`, + ]; + is( + actualCoordinates.join(" / "), + expectedCoordinates.join(" / "), + "expected popup type windows are opened at given coordinates" + ); + + const actualSizes = windows + .slice(0, 3) + .map(window => `${window.outerWidth}x${window.outerHeight}`); + const expectedSizes = [`151x152`, `152x153`, `153x154`]; + is( + actualSizes.join(" / "), + expectedSizes.join(" / "), + "expected popup type windows are opened with given size" + ); + + const actualRect = { + top: windows[4].screenY, + bottom: windows[3].screenY + windows[3].outerHeight, + left: windows[4].screenX, + right: windows[3].screenX + windows[3].outerWidth, + }; + const maxRect = { + top: minTop, + bottom: maxBottom, + left: minLeft, + right: maxRight, + }; + isRectContained(actualRect, maxRect); + + for (const window of windows) { + window.close(); + } + + Services.ww.unregisterNotification(windowListener); + windows = null; + await BrowserTestUtils.closeWindow(baseWindow); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_create_tabId.js b/browser/components/extensions/test/browser/browser_ext_windows_create_tabId.js new file mode 100644 index 0000000000..83fda199b7 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_create_tabId.js @@ -0,0 +1,387 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function assertNoLeaksInTabTracker() { + // Check that no tabs have been leaked by the internal tabTracker helper class. + const { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" + ); + const { tabTracker } = ExtensionParent.apiManager.global; + + for (const [tabId, nativeTab] of tabTracker._tabIds) { + if (!nativeTab.ownerGlobal) { + ok( + false, + `A tab with tabId ${tabId} has been leaked in the tabTracker ("${nativeTab.title}")` + ); + } + } +} + +add_task(async function testWindowCreate() { + async function background() { + let promiseTabAttached = () => { + return new Promise(resolve => { + browser.tabs.onAttached.addListener(function listener() { + browser.tabs.onAttached.removeListener(listener); + resolve(); + }); + }); + }; + + let promiseTabUpdated = expected => { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener( + tabId, + changeInfo, + tab + ) { + if (changeInfo.url === expected) { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + }; + + try { + let window = await browser.windows.getCurrent(); + let windowId = window.id; + + browser.test.log("Create additional tab in window 1"); + let tab = await browser.tabs.create({ windowId, url: "about:blank" }); + let tabId = tab.id; + + browser.test.log("Create a new window, adopting the new tab"); + + // Note that we want to check against actual boolean values for + // all of the `incognito` property tests. + browser.test.assertEq(false, tab.incognito, "Tab is not private"); + + { + let [, window] = await Promise.all([ + promiseTabAttached(), + browser.windows.create({ tabId: tabId }), + ]); + browser.test.assertEq( + false, + window.incognito, + "New window is not private" + ); + browser.test.assertEq( + tabId, + window.tabs[0].id, + "tabs property populated correctly" + ); + + browser.test.log("Close the new window"); + await browser.windows.remove(window.id); + } + + { + browser.test.log("Create a new private window"); + let privateWindow = await browser.windows.create({ incognito: true }); + browser.test.assertEq( + true, + privateWindow.incognito, + "Private window is private" + ); + + browser.test.log("Create additional tab in private window"); + let privateTab = await browser.tabs.create({ + windowId: privateWindow.id, + }); + browser.test.assertEq( + true, + privateTab.incognito, + "Private tab is private" + ); + + browser.test.log("Create a new window, adopting the new private tab"); + let [, newWindow] = await Promise.all([ + promiseTabAttached(), + browser.windows.create({ tabId: privateTab.id }), + ]); + browser.test.assertEq( + true, + newWindow.incognito, + "New private window is private" + ); + + browser.test.log("Close the new private window"); + await browser.windows.remove(newWindow.id); + + browser.test.log("Close the private window"); + await browser.windows.remove(privateWindow.id); + } + + browser.test.log("Try to create a window with both a tab and a URL"); + [tab] = await browser.tabs.query({ windowId, active: true }); + await browser.test.assertRejects( + browser.windows.create({ tabId: tab.id, url: "http://example.com/" }), + /`tabId` may not be used in conjunction with `url`/, + "Create call failed as expected" + ); + + browser.test.log( + "Try to create a window with both a tab and an invalid incognito setting" + ); + await browser.test.assertRejects( + browser.windows.create({ tabId: tab.id, incognito: true }), + /`incognito` property must match the incognito state of tab/, + "Create call failed as expected" + ); + + browser.test.log("Try to create a window with an invalid tabId"); + await browser.test.assertRejects( + browser.windows.create({ tabId: 0 }), + /Invalid tab ID: 0/, + "Create call failed as expected" + ); + + browser.test.log("Try to create a window with two URLs"); + let readyPromise = Promise.all([ + // tabs.onUpdated can be invoked between the call of windows.create and + // the invocation of its callback/promise, so set up the listeners + // before creating the window. + promiseTabUpdated("http://example.com/"), + promiseTabUpdated("http://example.org/"), + ]); + + window = await browser.windows.create({ + url: ["http://example.com/", "http://example.org/"], + }); + await readyPromise; + + browser.test.assertEq( + 2, + window.tabs.length, + "2 tabs were opened in new window" + ); + browser.test.assertEq( + "about:blank", + window.tabs[0].url, + "about:blank, page not loaded yet" + ); + browser.test.assertEq( + "about:blank", + window.tabs[1].url, + "about:blank, page not loaded yet" + ); + + window = await browser.windows.get(window.id, { populate: true }); + + browser.test.assertEq( + 2, + window.tabs.length, + "2 tabs were opened in new window" + ); + browser.test.assertEq( + "http://example.com/", + window.tabs[0].url, + "Correct URL was loaded in tab 1" + ); + browser.test.assertEq( + "http://example.org/", + window.tabs[1].url, + "Correct URL was loaded in tab 2" + ); + + await browser.windows.remove(window.id); + + browser.test.notifyPass("window-create"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("window-create"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["tabs"], + }, + + background, + }); + + await extension.startup(); + await extension.awaitFinish("window-create"); + await extension.unload(); + + assertNoLeaksInTabTracker(); +}); + +add_task(async function testWebNavigationOnWindowCreateTabId() { + async function background() { + const webNavEvents = []; + const onceTabsAttached = []; + + let promiseTabAttached = tab => { + return new Promise(resolve => { + browser.tabs.onAttached.addListener(function listener(tabId) { + if (tabId !== tab.id) { + return; + } + browser.tabs.onAttached.removeListener(listener); + resolve(); + }); + }); + }; + + // Listen to webNavigation.onCompleted events to ensure that + // it is not going to be fired when we move the existent tabs + // to new windows. + browser.webNavigation.onCompleted.addListener(data => { + webNavEvents.push(data); + }); + + // Wait for the list of urls needed to select the test tabs, + // and then move these tabs to a new window and assert that + // no webNavigation.onCompleted events should be received + // while the tabs are being adopted into the new windows. + browser.test.onMessage.addListener(async (msg, testTabURLs) => { + if (msg !== "testTabURLs") { + return; + } + + // Retrieve the tabs list and filter out the tabs that should + // not be moved into a new window. + let allTabs = await browser.tabs.query({}); + let testTabs = allTabs.filter(tab => { + return testTabURLs.includes(tab.url); + }); + + browser.test.assertEq( + 2, + testTabs.length, + "Got the expected number of test tabs" + ); + + for (let tab of testTabs) { + onceTabsAttached.push(promiseTabAttached(tab)); + await browser.windows.create({ tabId: tab.id }); + } + + // Wait the tabs to have been attached to the new window and then assert that no + // webNavigation.onCompleted event has been received. + browser.test.log("Waiting tabs move to new window to be attached"); + await Promise.all(onceTabsAttached); + + browser.test.assertEq( + "[]", + JSON.stringify(webNavEvents), + "No webNavigation.onCompleted event should have been received" + ); + + // Remove all the test tabs before exiting the test successfully. + for (let tab of testTabs) { + await browser.tabs.remove(tab.id); + } + + browser.test.notifyPass("webNavigation-on-window-create-tabId"); + }); + } + + const testURLs = ["http://example.com/", "http://example.org/"]; + + for (let url of testURLs) { + await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "webNavigation"], + }, + background, + }); + + await extension.startup(); + + await extension.sendMessage("testTabURLs", testURLs); + + await extension.awaitFinish("webNavigation-on-window-create-tabId"); + await extension.unload(); + + assertNoLeaksInTabTracker(); +}); + +add_task(async function testGetLastFocusedDoesNotLeakDuringTabAdoption() { + async function background() { + const allTabs = await browser.tabs.query({}); + + browser.test.onMessage.addListener(async (msg, testTabURL) => { + if (msg !== "testTabURL") { + return; + } + + let tab = allTabs.filter(tab => tab.url === testTabURL).pop(); + + // Keep calling getLastFocused while browser.windows.create is creating + // a new window to adopt the test tab, so that the test recreates + // conditions similar to the extension that has been triggered this leak + // (See Bug 1458918 for a rationale). + // The while loop is explicited exited right before the notifyPass + // (but unloading the extension will stop it in any case). + let stopGetLastFocusedLoop = false; + Promise.resolve().then(async () => { + while (!stopGetLastFocusedLoop) { + browser.windows.getLastFocused({ populate: true }); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 50)); + } + }); + + // Create a new window which adopt an existent tab and wait the tab to + // be fully attached to the new window. + await Promise.all([ + new Promise(resolve => { + const listener = () => { + browser.tabs.onAttached.removeListener(listener); + resolve(); + }; + browser.tabs.onAttached.addListener(listener); + }), + browser.windows.create({ tabId: tab.id }), + ]); + + // Check that getLastFocused populate the tabs property once the tab adoption + // has been completed. + const lastFocusedPopulate = await browser.windows.getLastFocused({ + populate: true, + }); + browser.test.assertEq( + 1, + lastFocusedPopulate.tabs.length, + "Got the expected number of tabs from windows.getLastFocused" + ); + + // Remove the test tab. + await browser.tabs.remove(tab.id); + + stopGetLastFocusedLoop = true; + + browser.test.notifyPass("tab-adopted"); + }); + } + + await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com/"); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "webNavigation"], + }, + background, + }); + + await extension.startup(); + + extension.sendMessage("testTabURL", "http://example.com/"); + + await extension.awaitFinish("tab-adopted"); + + await extension.unload(); + + assertNoLeaksInTabTracker(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_create_url.js b/browser/components/extensions/test/browser/browser_ext_windows_create_url.js new file mode 100644 index 0000000000..7c847382c5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_create_url.js @@ -0,0 +1,253 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testWindowCreate() { + let pageExt = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "page@mochitest" } }, + protocol_handlers: [ + { + protocol: "ext+foo", + name: "a foo protocol handler", + uriTemplate: "page.html?val=%s", + }, + ], + }, + files: { + "page.html": ` + + `, + }, + }); + await pageExt.startup(); + + async function background(OTHER_PAGE) { + browser.test.log(`== using ${OTHER_PAGE}`); + const EXTENSION_URL = browser.runtime.getURL("test.html"); + const EXT_PROTO = "ext+bar:foo"; + const OTHER_PROTO = "ext+foo:bar"; + + let windows = new (class extends Map { + // eslint-disable-line new-parens + get(id) { + if (!this.has(id)) { + let window = { + tabs: new Map(), + }; + window.promise = new Promise(resolve => { + window.resolvePromise = resolve; + }); + + this.set(id, window); + } + + return super.get(id); + } + })(); + + browser.tabs.onUpdated.addListener((tabId, changed, tab) => { + if (changed.status == "complete" && tab.url !== "about:blank") { + let window = windows.get(tab.windowId); + window.tabs.set(tab.index, tab); + + if (window.tabs.size === window.expectedTabs) { + browser.test.log("resolving a window load"); + window.resolvePromise(window); + } + } + }); + + async function create(options) { + browser.test.log(`creating window for ${options.url}`); + // Note: may reject + let window = await browser.windows.create(options); + let win = windows.get(window.id); + win.id = window.id; + + win.expectedTabs = Array.isArray(options.url) ? options.url.length : 1; + + return win.promise; + } + + let TEST_SETS = [ + { + name: "Single protocol URL in this extension", + url: EXT_PROTO, + expect: [`${EXTENSION_URL}?val=ext%2Bbar%3Afoo`], + }, + { + name: "Single, relative URL", + url: "test.html", + expect: [EXTENSION_URL], + }, + { + name: "Single, absolute, extension URL", + url: EXTENSION_URL, + expect: [EXTENSION_URL], + }, + { + // This is primarily for backwards-compatibility, to allow extensions + // to open other home pages. This test case opens the home page + // explicitly; the implicit case (windows.create({}) without URL) is at + // browser_ext_chrome_settings_overrides_home.js. + name: "Single, absolute, other extension URL", + url: OTHER_PAGE, + expect: [OTHER_PAGE], + }, + { + // This is oddly inconsistent with the non-array case, but here we are + // intentionally stricter because of lesser backwards-compatibility + // concerns. + name: "Array, absolute, other extension URL", + url: [OTHER_PAGE], + expectError: `Illegal URL: ${OTHER_PAGE}`, + }, + { + name: "Single protocol URL in other extension", + url: OTHER_PROTO, + expect: [`${OTHER_PAGE}?val=ext%2Bfoo%3Abar`], + }, + { + name: "Single, about:blank", + // Added "?" after "about:blank" because the test's tab load detection + // ignores about:blank. + url: "about:blank?", + expect: ["about:blank?"], + }, + { + name: "multiple urls", + url: [EXT_PROTO, "test.html", EXTENSION_URL, OTHER_PROTO], + expect: [ + `${EXTENSION_URL}?val=ext%2Bbar%3Afoo`, + EXTENSION_URL, + EXTENSION_URL, + `${OTHER_PAGE}?val=ext%2Bfoo%3Abar`, + ], + }, + { + name: "Reject array of own allowed URLs and other moz-extension:-URL", + url: [EXTENSION_URL, EXT_PROTO, "about:blank?#", OTHER_PAGE], + expectError: `Illegal URL: ${OTHER_PAGE}`, + }, + { + name: "Single, about:robots", + url: "about:robots", + expectError: "Illegal URL: about:robots", + }, + { + name: "Array containing about:robots", + url: ["about:robots"], + expectError: "Illegal URL: about:robots", + }, + ]; + async function checkCreateResult({ status, value, reason }, testCase) { + const window = status === "fulfilled" ? value : null; + try { + if (testCase.expectError) { + let error = reason?.message; + browser.test.assertEq(testCase.expectError, error, testCase.name); + } else { + let tabUrls = []; + for (let [tabIndex, tab] of window.tabs) { + tabUrls[tabIndex] = tab.url; + } + browser.test.assertDeepEq(testCase.expect, tabUrls, testCase.name); + } + } catch (e) { + browser.test.fail(`Unexpected failure in ${testCase.name} :: ${e}`); + } finally { + // Close opened windows, whether they were expected or not. + if (window) { + await browser.windows.remove(window.id); + } + } + } + try { + let promises = []; + for (let testSet of TEST_SETS) { + try { + let testPromise = create({ url: testSet.url }); + promises.push(testPromise); + // Bug 1869385 - we need to await each window opening sequentially. + // The events we check for depend on paint finishing, + // which won't happen if the window is occluded before it finishes + // loading. + await testPromise; + } catch (e) { + // Some of these calls are expected to fail, + // which we verify when calling checkCreateResult below. + } + } + const results = await Promise.allSettled(promises); + await Promise.all( + TEST_SETS.map((t, i) => checkCreateResult(results[i], t)) + ); + browser.test.notifyPass("window-create-url"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("window-create-url"); + } + } + + // Watch for any permission prompts to show up and accept them. + let dialogCount = 0; + let windowObserver = window => { + // This listener will go away when the window is closed so there is no need + // to explicitely remove it. + // eslint-disable-next-line mozilla/balanced-listeners + window.addEventListener("dialogopen", event => { + dialogCount++; + let { dialog } = event.detail; + Assert.equal( + dialog?._openedURL, + "chrome://mozapps/content/handling/permissionDialog.xhtml", + "Should only ever see the permission dialog" + ); + let dialogEl = dialog._frame.contentDocument.querySelector("dialog"); + Assert.ok(dialogEl, "Dialog element should exist"); + dialogEl.setAttribute("buttondisabledaccept", false); + dialogEl.acceptDialog(); + }); + }; + Services.obs.addObserver(windowObserver, "browser-delayed-startup-finished"); + registerCleanupFunction(() => { + Services.obs.removeObserver( + windowObserver, + "browser-delayed-startup-finished" + ); + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + protocol_handlers: [ + { + protocol: "ext+bar", + name: "a bar protocol handler", + uriTemplate: "test.html?val=%s", + }, + ], + }, + + background: `(${background})("moz-extension://${pageExt.uuid}/page.html")`, + + files: { + "test.html": ``, + }, + }); + + await extension.startup(); + await extension.awaitFinish("window-create-url"); + await extension.unload(); + await pageExt.unload(); + + Assert.equal( + dialogCount, + 2, + "Expected to see the right number of permission prompts." + ); + + // Make sure windows have been released before finishing. + Cu.forceGC(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_events.js b/browser/components/extensions/test/browser/browser_ext_windows_events.js new file mode 100644 index 0000000000..aa8a2655ce --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_events.js @@ -0,0 +1,222 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +SimpleTest.requestCompleteLog(); + +add_task(async function test_windows_events_not_allowed() { + let monitor = await startIncognitoMonitorExtension(); + + function background() { + browser.windows.onCreated.addListener(window => { + browser.test.log(`onCreated: windowId=${window.id}`); + + browser.test.assertTrue( + Number.isInteger(window.id), + "Window object's id is an integer" + ); + browser.test.assertEq( + "normal", + window.type, + "Window object returned with the correct type" + ); + browser.test.sendMessage("window-created", window.id); + }); + + let lastWindowId; + browser.windows.onFocusChanged.addListener(async eventWindowId => { + browser.test.log( + `onFocusChange: windowId=${eventWindowId} lastWindowId=${lastWindowId}` + ); + + browser.test.assertTrue( + lastWindowId !== eventWindowId, + "onFocusChanged fired once for the given window" + ); + lastWindowId = eventWindowId; + + browser.test.assertTrue( + Number.isInteger(eventWindowId), + "windowId is an integer" + ); + let window = await browser.windows.getLastFocused(); + browser.test.sendMessage("window-focus-changed", { + winId: eventWindowId, + lastFocusedWindowId: window.id, + }); + }); + + browser.windows.onRemoved.addListener(windowId => { + browser.test.log(`onRemoved: windowId=${windowId}`); + + browser.test.assertTrue( + Number.isInteger(windowId), + "windowId is an integer" + ); + browser.test.sendMessage("window-removed", windowId); + browser.test.notifyPass("windows.events"); + }); + + browser.test.sendMessage("ready", browser.windows.WINDOW_ID_NONE); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: {}, + background, + incognitoOverride: "spanning", + }); + + await extension.startup(); + const WINDOW_ID_NONE = await extension.awaitMessage("ready"); + + async function awaitFocusChanged() { + let windowInfo = await extension.awaitMessage("window-focus-changed"); + if (windowInfo.winId === WINDOW_ID_NONE) { + info("Ignoring a superfluous WINDOW_ID_NONE (blur) event."); + windowInfo = await extension.awaitMessage("window-focus-changed"); + } + is( + windowInfo.winId, + windowInfo.lastFocusedWindowId, + "Last focused window has the correct id" + ); + return windowInfo.winId; + } + + const { + Management: { + global: { windowTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + + let currentWindow = window; + let currentWindowId = windowTracker.getId(currentWindow); + info(`Current window ID: ${currentWindowId}`); + + info("Create browser window 1"); + let win1 = await BrowserTestUtils.openNewBrowserWindow(); + let win1Id = await extension.awaitMessage("window-created"); + info(`Window 1 ID: ${win1Id}`); + + // This shouldn't be necessary, but tests intermittently fail, so let's give + // it a try. + win1.focus(); + + let winId = await awaitFocusChanged(); + is(winId, win1Id, "Got focus change event for the correct window ID."); + + info("Create browser window 2"); + let win2 = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + let win2Id = await extension.awaitMessage("window-created"); + info(`Window 2 ID: ${win2Id}`); + + win2.focus(); + + winId = await awaitFocusChanged(); + is(winId, win2Id, "Got focus change event for the correct window ID."); + + info("Focus browser window 1"); + await focusWindow(win1); + + winId = await awaitFocusChanged(); + is(winId, win1Id, "Got focus change event for the correct window ID."); + + info("Close browser window 2"); + await BrowserTestUtils.closeWindow(win2); + + winId = await extension.awaitMessage("window-removed"); + is(winId, win2Id, "Got removed event for the correct window ID."); + + info("Close browser window 1"); + await BrowserTestUtils.closeWindow(win1); + + currentWindow.focus(); + + winId = await extension.awaitMessage("window-removed"); + is(winId, win1Id, "Got removed event for the correct window ID."); + + winId = await awaitFocusChanged(); + is( + winId, + currentWindowId, + "Got focus change event for the correct window ID." + ); + + await extension.awaitFinish("windows.events"); + await extension.unload(); + await monitor.unload(); +}); + +add_task(async function test_windows_event_page() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.eventPages.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "eventpage@windows" } }, + background: { persistent: false }, + }, + background() { + let removed; + browser.windows.onCreated.addListener(window => { + browser.test.sendMessage("onCreated", window.id); + }); + browser.windows.onRemoved.addListener(wid => { + removed = wid; + browser.test.sendMessage("onRemoved", wid); + }); + browser.windows.onFocusChanged.addListener(wid => { + if (wid != browser.windows.WINDOW_ID_NONE && wid != removed) { + browser.test.sendMessage("onFocusChanged", wid); + } + }); + browser.test.sendMessage("ready"); + }, + }); + + const EVENTS = ["onCreated", "onRemoved", "onFocusChanged"]; + + await extension.startup(); + await extension.awaitMessage("ready"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "windows", event, { + primed: false, + }); + } + + // test events waken background + await extension.terminateBackground(); + for (let event of EVENTS) { + assertPersistentListeners(extension, "windows", event, { + primed: true, + }); + } + + let win = await BrowserTestUtils.openNewBrowserWindow(); + + await extension.awaitMessage("ready"); + let windowId = await extension.awaitMessage("onCreated"); + ok(true, "persistent event woke background"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "windows", event, { + primed: false, + }); + } + // focus returns the new window + let focusedId = await extension.awaitMessage("onFocusChanged"); + Assert.equal(windowId, focusedId, "new window was focused"); + await extension.terminateBackground(); + + await BrowserTestUtils.closeWindow(win); + await extension.awaitMessage("ready"); + let removedId = await extension.awaitMessage("onRemoved"); + Assert.equal(windowId, removedId, "window was removed"); + // focus returns the window focus was passed to + focusedId = await extension.awaitMessage("onFocusChanged"); + Assert.notEqual(windowId, focusedId, "old window was focused"); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_incognito.js b/browser/components/extensions/test/browser/browser_ext_windows_incognito.js new file mode 100644 index 0000000000..ef6d8a8eae --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_incognito.js @@ -0,0 +1,84 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_window_incognito() { + const url = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/file_iframe_document.html"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://mochi.test/"], + }, + background() { + let lastFocusedWindowId = null; + // Catch focus change events to power the test below. + browser.windows.onFocusChanged.addListener(function listener( + eventWindowId + ) { + lastFocusedWindowId = eventWindowId; + browser.windows.onFocusChanged.removeListener(listener); + }); + + browser.test.onMessage.addListener(async pbw => { + browser.test.assertEq( + browser.windows.WINDOW_ID_NONE, + lastFocusedWindowId, + "Focus on private window sends the event, but doesn't reveal windowId (without permissions)" + ); + + await browser.test.assertRejects( + browser.windows.get(pbw.windowId), + /Invalid window ID/, + "should not be able to get incognito window" + ); + await browser.test.assertRejects( + browser.windows.remove(pbw.windowId), + /Invalid window ID/, + "should not be able to remove incognito window" + ); + await browser.test.assertRejects( + browser.windows.getCurrent(), + /Invalid window/, + "should not be able to get incognito top window" + ); + await browser.test.assertRejects( + browser.windows.getLastFocused(), + /Invalid window/, + "should not be able to get incognito focused window" + ); + await browser.test.assertRejects( + browser.windows.create({ incognito: true }), + /Extension does not have permission for incognito mode/, + "should not be able to create incognito window" + ); + await browser.test.assertRejects( + browser.windows.update(pbw.windowId, { focused: true }), + /Invalid window ID/, + "should not be able to update incognito window" + ); + + let windows = await browser.windows.getAll(); + browser.test.assertEq( + 1, + windows.length, + "unable to get incognito window" + ); + + browser.test.notifyPass("pass"); + }); + }, + }); + + await extension.startup(); + + // The tests expect the incognito window to be + // created after the extension is started, so think + // carefully when moving this line. + let winData = await getIncognitoWindow(url); + + extension.sendMessage(winData.details); + await extension.awaitFinish("pass"); + await BrowserTestUtils.closeWindow(winData.win); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_remove.js b/browser/components/extensions/test/browser/browser_ext_windows_remove.js new file mode 100644 index 0000000000..455987a908 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_remove.js @@ -0,0 +1,53 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testWindowRemove() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + async function closeWindow(id) { + let window = await browser.windows.get(id); + return new Promise(function (resolve) { + browser.windows.onRemoved.addListener(async function listener( + windowId + ) { + browser.windows.onRemoved.removeListener(listener); + await browser.test.assertEq( + windowId, + window.id, + "The right window was closed" + ); + await browser.test.assertRejects( + browser.windows.get(windowId), + new RegExp(`Invalid window ID: ${windowId}`), + "The window was really closed." + ); + resolve(); + }); + browser.windows.remove(id); + }); + } + + browser.test.log("Create a new window and close it by its ID"); + let newWindow = await browser.windows.create(); + await closeWindow(newWindow.id); + + browser.test.log("Create a new window and close it by WINDOW_ID_CURRENT"); + await browser.windows.create(); + await closeWindow(browser.windows.WINDOW_ID_CURRENT); + + browser.test.log("Assert failure for bad parameter."); + await browser.test.assertThrows( + () => browser.windows.remove(-3), + /-3 is too small \(must be at least -2\)/, + "Invalid windowId throws" + ); + + browser.test.notifyPass("window-remove"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("window-remove"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_size.js b/browser/components/extensions/test/browser/browser_ext_windows_size.js new file mode 100644 index 0000000000..4a4f0d8a0c --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_size.js @@ -0,0 +1,122 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function testWindowCreate() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + let _checkWindowPromise; + browser.test.onMessage.addListener((msg, arg) => { + if (msg == "checked-window") { + _checkWindowPromise.resolve(arg); + _checkWindowPromise = null; + } + }); + + let getWindowSize = () => { + return new Promise(resolve => { + _checkWindowPromise = { resolve }; + browser.test.sendMessage("check-window"); + }); + }; + + const KEYS = ["left", "top", "width", "height"]; + function checkGeom(expected, actual) { + for (let key of KEYS) { + browser.test.assertEq( + expected[key], + actual[key], + `Expected '${key}' value` + ); + } + } + + let windowId; + async function checkWindow(expected, retries = 5) { + let geom = await getWindowSize(); + + if (retries && KEYS.some(key => expected[key] != geom[key])) { + browser.test.log( + `Got mismatched size (${JSON.stringify( + expected + )} != ${JSON.stringify(geom)}). Retrying after a short delay.` + ); + + await new Promise(resolve => setTimeout(resolve, 200)); + + return checkWindow(expected, retries - 1); + } + + browser.test.log(`Check actual window size`); + checkGeom(expected, geom); + + browser.test.log("Check API-reported window size"); + + geom = await browser.windows.get(windowId); + + checkGeom(expected, geom); + } + + try { + let geom = { left: 100, top: 100, width: 500, height: 300 }; + + let window = await browser.windows.create(geom); + windowId = window.id; + + await checkWindow(geom); + + let update = { left: 150, width: 600 }; + Object.assign(geom, update); + await browser.windows.update(windowId, update); + await checkWindow(geom); + + update = { top: 150, height: 400 }; + Object.assign(geom, update); + await browser.windows.update(windowId, update); + await checkWindow(geom); + + geom = { left: 200, top: 200, width: 800, height: 600 }; + await browser.windows.update(windowId, geom); + await checkWindow(geom); + + let platformInfo = await browser.runtime.getPlatformInfo(); + if (platformInfo.os != "linux") { + geom = { left: -50, top: -50, width: 800, height: 600 }; + await browser.windows.update(windowId, geom); + await checkWindow({ ...geom, left: 0, top: 0 }); + } + + await browser.windows.remove(windowId); + browser.test.notifyPass("window-size"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("window-size"); + } + }, + }); + + let latestWindow; + let windowListener = (window, topic) => { + if (topic == "domwindowopened") { + latestWindow = window; + } + }; + Services.ww.registerNotification(windowListener); + + extension.onMessage("check-window", () => { + extension.sendMessage("checked-window", { + top: latestWindow.screenY, + left: latestWindow.screenX, + width: latestWindow.outerWidth, + height: latestWindow.outerHeight, + }); + }); + + await extension.startup(); + await extension.awaitFinish("window-size"); + await extension.unload(); + + Services.ww.unregisterNotification(windowListener); + latestWindow = null; +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_update.js b/browser/components/extensions/test/browser/browser_ext_windows_update.js new file mode 100644 index 0000000000..7331b2c0cc --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_update.js @@ -0,0 +1,390 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + function promiseWaitForFocus(window) { + return new Promise(resolve => { + waitForFocus(function () { + Assert.strictEqual( + Services.focus.activeWindow, + window, + "correct window focused" + ); + resolve(); + }, window); + }); + } + + let window1 = window; + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + + window2.focus(); + await promiseWaitForFocus(window2); + + let extension = ExtensionTestUtils.loadExtension({ + background: function () { + browser.windows.getAll(undefined, function (wins) { + browser.test.assertEq(wins.length, 2, "should have two windows"); + + // Sort the unfocused window to the lower index. + wins.sort(function (win1, win2) { + if (win1.focused === win2.focused) { + return 0; + } + + return win1.focused ? 1 : -1; + }); + + browser.windows.update(wins[0].id, { focused: true }, function () { + browser.test.sendMessage("check"); + }); + }); + }, + }); + + await Promise.all([extension.startup(), extension.awaitMessage("check")]); + + await promiseWaitForFocus(window1); + + await extension.unload(); + + await BrowserTestUtils.closeWindow(window2); +}); + +add_task(async function testWindowUpdate() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + let _checkWindowPromise; + browser.test.onMessage.addListener(msg => { + if (msg == "checked-window") { + _checkWindowPromise.resolve(); + _checkWindowPromise = null; + } + }); + + let os; + function checkWindow(expected) { + return new Promise(resolve => { + _checkWindowPromise = { resolve }; + browser.test.sendMessage("check-window", expected); + }); + } + + let currentWindowId; + async function updateWindow(windowId, params, expected, otherChecks) { + let window = await browser.windows.update(windowId, params); + + browser.test.assertEq( + currentWindowId, + window.id, + "Expected WINDOW_ID_CURRENT to refer to the same window" + ); + for (let key of Object.keys(params)) { + if (key == "state" && os == "mac" && params.state == "normal") { + // OS-X doesn't have a hard distinction between "normal" and + // "maximized" states. + browser.test.assertTrue( + window.state == "normal" || window.state == "maximized", + `Expected window.state (currently ${window.state}) to be "normal" but will accept "maximized"` + ); + } else { + browser.test.assertEq( + params[key], + window[key], + `Got expected value for window.${key}` + ); + } + } + if (otherChecks) { + for (let key of Object.keys(otherChecks)) { + browser.test.assertEq( + otherChecks[key], + window[key], + `Got expected value for window.${key}` + ); + } + } + + return checkWindow(expected); + } + + try { + let windowId = browser.windows.WINDOW_ID_CURRENT; + + ({ os } = await browser.runtime.getPlatformInfo()); + + let window = await browser.windows.getCurrent(); + currentWindowId = window.id; + + // Store current, "normal" width and height to compare against + // window width and height after updating to "normal" state. + let normalWidth = window.width; + let normalHeight = window.height; + + await updateWindow( + windowId, + { state: "maximized" }, + { state: "STATE_MAXIMIZED" } + ); + await updateWindow( + windowId, + { state: "normal" }, + { state: "STATE_NORMAL" }, + { width: normalWidth, height: normalHeight } + ); + await updateWindow( + windowId, + { state: "minimized" }, + { state: "STATE_MINIMIZED" } + ); + await updateWindow( + windowId, + { state: "normal" }, + { state: "STATE_NORMAL" }, + { width: normalWidth, height: normalHeight } + ); + await updateWindow( + windowId, + { state: "fullscreen" }, + { state: "STATE_FULLSCREEN" } + ); + await updateWindow( + windowId, + { state: "normal" }, + { state: "STATE_NORMAL" }, + { width: normalWidth, height: normalHeight } + ); + + browser.test.notifyPass("window-update"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("window-update"); + } + }, + }); + + extension.onMessage("check-window", expected => { + if (expected.state != null) { + let { windowState } = window; + if (window.fullScreen) { + windowState = window.STATE_FULLSCREEN; + } + + // Temporarily accepting STATE_MAXIMIZED on Linux because of bug 1307759. + if ( + expected.state == "STATE_NORMAL" && + (AppConstants.platform == "macosx" || AppConstants.platform == "linux") + ) { + ok( + windowState == window.STATE_NORMAL || + windowState == window.STATE_MAXIMIZED, + `Expected windowState (currently ${windowState}) to be STATE_NORMAL but will accept STATE_MAXIMIZED` + ); + } else { + is( + windowState, + window[expected.state], + `Expected window state to be ${expected.state}` + ); + } + } + + extension.sendMessage("checked-window"); + }); + + await extension.startup(); + await extension.awaitFinish("window-update"); + await extension.unload(); +}); + +add_task(async function () { + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + + let extension = ExtensionTestUtils.loadExtension({ + background: function () { + browser.windows.getAll(undefined, function (wins) { + browser.test.assertEq(wins.length, 2, "should have two windows"); + + let unfocused = wins.find(win => !win.focused); + browser.windows.update( + unfocused.id, + { drawAttention: true }, + function () { + browser.test.sendMessage("check"); + } + ); + }); + }, + }); + + await Promise.all([extension.startup(), extension.awaitMessage("check")]); + + await extension.unload(); + + await BrowserTestUtils.closeWindow(window2); +}); + +// Tests that incompatible parameters can't be used together. +add_task(async function testWindowUpdateParams() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + try { + for (let state of ["minimized", "maximized", "fullscreen"]) { + for (let param of ["left", "top", "width", "height"]) { + let expected = `"state": "${state}" may not be combined with "left", "top", "width", or "height"`; + + let windowId = browser.windows.WINDOW_ID_CURRENT; + await browser.test.assertRejects( + browser.windows.update(windowId, { state, [param]: 100 }), + RegExp(expected), + `Got expected error for create(${param}=100` + ); + } + } + + browser.test.notifyPass("window-update-params"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("window-update-params"); + } + }, + }); + + await extension.startup(); + await extension.awaitFinish("window-update-params"); + await extension.unload(); +}); + +add_task(async function testPositionBoundaryCheck() { + const extension = ExtensionTestUtils.loadExtension({ + async background() { + function waitMessage() { + return new Promise((resolve, reject) => { + const onMessage = message => { + if (message == "continue") { + browser.test.onMessage.removeListener(onMessage); + resolve(); + } + }; + browser.test.onMessage.addListener(onMessage); + }); + } + const win = await browser.windows.create({ + type: "popup", + left: 50, + top: 50, + width: 150, + height: 150, + }); + await browser.test.sendMessage("ready"); + await waitMessage(); + await browser.windows.update(win.id, { + left: 123, + top: 123, + }); + await browser.test.sendMessage("regular"); + await waitMessage(); + await browser.windows.update(win.id, { + left: 123, + }); + await browser.test.sendMessage("only-left"); + await waitMessage(); + await browser.windows.update(win.id, { + top: 123, + }); + await browser.test.sendMessage("only-top"); + await waitMessage(); + await browser.windows.update(win.id, { + left: screen.availWidth * 100, + top: screen.availHeight * 100, + }); + await browser.test.sendMessage("too-large"); + await waitMessage(); + await browser.windows.update(win.id, { + left: -screen.availWidth * 100, + top: -screen.availHeight * 100, + }); + await browser.test.sendMessage("too-small"); + }, + }); + + const promisedWin = new Promise((resolve, reject) => { + const windowListener = (window, topic) => { + if (topic == "domwindowopened") { + Services.ww.unregisterNotification(windowListener); + resolve(window); + } + }; + Services.ww.registerNotification(windowListener); + }); + + await extension.startup(); + + const win = await promisedWin; + + const regularScreen = getScreenAt(0, 0, 150, 150); + const roundedX = roundCssPixcel(123, regularScreen); + const roundedY = roundCssPixcel(123, regularScreen); + + const availRectLarge = getCssAvailRect( + getScreenAt(screen.width * 100, screen.height * 100, 150, 150) + ); + const maxRight = availRectLarge.right; + const maxBottom = availRectLarge.bottom; + + const availRectSmall = getCssAvailRect( + getScreenAt(-screen.width * 100, -screen.height * 100, 150, 150) + ); + const minLeft = availRectSmall.left; + const minTop = availRectSmall.top; + + const expectedCoordinates = [ + `${roundedX},${roundedY}`, + `${roundedX},${win.screenY}`, + `${win.screenX},${roundedY}`, + ]; + + await extension.awaitMessage("ready"); + + const actualCoordinates = []; + extension.sendMessage("continue"); + await extension.awaitMessage("regular"); + actualCoordinates.push(`${win.screenX},${win.screenY}`); + win.moveTo(50, 50); + extension.sendMessage("continue"); + await extension.awaitMessage("only-left"); + actualCoordinates.push(`${win.screenX},${win.screenY}`); + win.moveTo(50, 50); + extension.sendMessage("continue"); + await extension.awaitMessage("only-top"); + actualCoordinates.push(`${win.screenX},${win.screenY}`); + is( + actualCoordinates.join(" / "), + expectedCoordinates.join(" / "), + "expected window is placed at given coordinates" + ); + + const actualRect = {}; + const maxRect = { + top: minTop, + bottom: maxBottom, + left: minLeft, + right: maxRight, + }; + + extension.sendMessage("continue"); + await extension.awaitMessage("too-large"); + actualRect.right = win.screenX + win.outerWidth; + actualRect.bottom = win.screenY + win.outerHeight; + + extension.sendMessage("continue"); + await extension.awaitMessage("too-small"); + actualRect.top = win.screenY; + actualRect.left = win.screenX; + + isRectContained(actualRect, maxRect); + + await extension.unload(); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/extensions/test/browser/browser_legacy_recent_tabs.toml b/browser/components/extensions/test/browser/browser_legacy_recent_tabs.toml new file mode 100644 index 0000000000..ae23831a0d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_legacy_recent_tabs.toml @@ -0,0 +1,34 @@ +[DEFAULT] +# +# This manifest lists tests that use cover the session API and recently-closed tabs behavior with the legacy pref values +# +tags = "webextensions" +dupe-manifest = true +prefs = [ + "browser.sessionstore.closedTabsFromAllWindows=false", + "browser.sessionstore.closedTabsFromClosedWindows=false", +] +support-files = [ + "head.js", + "empty.xpi", +] + +["browser_ext_sessions_forgetClosedTab.js"] + +["browser_ext_sessions_forgetClosedWindow.js"] + +["browser_ext_sessions_getRecentlyClosed.js"] + +["browser_ext_sessions_getRecentlyClosed_private.js"] + +["browser_ext_sessions_getRecentlyClosed_tabs.js"] + +["browser_ext_sessions_incognito.js"] + +["browser_ext_sessions_restore.js"] + +["browser_ext_sessions_restoreTab.js"] + +["browser_ext_sessions_restore_private.js"] + +["browser_ext_sessions_window_tab_value.js"] diff --git a/browser/components/extensions/test/browser/browser_toolbar_prefers_color_scheme.js b/browser/components/extensions/test/browser/browser_toolbar_prefers_color_scheme.js new file mode 100644 index 0000000000..ae7f488f0a --- /dev/null +++ b/browser/components/extensions/test/browser/browser_toolbar_prefers_color_scheme.js @@ -0,0 +1,266 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const kDark = 0; +const kLight = 1; +const kSystem = 2; + +// The above tests should be enough to make sure that the prefs behave as +// expected, the following ones test various edge cases in a simpler way. +async function testTheme(description, toolbar, content, themeManifestData) { + info(description); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "dummy@mochi.test", + }, + }, + ...themeManifestData, + }, + }); + + await Promise.all([ + TestUtils.topicObserved("lightweight-theme-styling-update"), + extension.startup(), + ]); + + is( + SpecialPowers.getIntPref("browser.theme.toolbar-theme"), + toolbar, + "Toolbar theme expected" + ); + is( + SpecialPowers.getIntPref("browser.theme.content-theme"), + content, + "Content theme expected" + ); + + await Promise.all([ + TestUtils.topicObserved("lightweight-theme-styling-update"), + extension.unload(), + ]); +} + +add_task(async function test_dark_toolbar_dark_text() { + // Bug 1743010 + await testTheme( + "Dark toolbar color, dark toolbar background", + kDark, + kSystem, + { + theme: { + colors: { + toolbar: "rgb(20, 17, 26)", + toolbar_text: "rgb(251, 29, 78)", + }, + }, + } + ); + + // Dark frame text is ignored as it might be overlaid with an image, + // see bug 1741931. + await testTheme("Dark frame is ignored", kLight, kSystem, { + theme: { + colors: { + frame: "#000000", + tab_background_text: "#000000", + }, + }, + }); + + await testTheme( + "Semi-transparent toolbar backgrounds are ignored.", + kLight, + kSystem, + { + theme: { + colors: { + toolbar: "rgba(0, 0, 0, .2)", + toolbar_text: "#000", + }, + }, + } + ); +}); + +add_task(async function dark_theme_presence_overrides_heuristics() { + const systemScheme = window.matchMedia("(-moz-system-dark-theme)").matches + ? kDark + : kLight; + await testTheme( + "darkTheme presence overrides heuristics", + systemScheme, + kSystem, + { + theme: { + colors: { + toolbar: "#000", + toolbar_text: "#fff", + }, + }, + dark_theme: { + colors: { + toolbar: "#000", + toolbar_text: "#fff", + }, + }, + } + ); +}); + +add_task(async function color_scheme_override() { + await testTheme( + "color_scheme overrides toolbar / toolbar_text pair (dark)", + kDark, + kDark, + { + theme: { + colors: { + toolbar: "#fff", + toolbar_text: "#000", + }, + properties: { + color_scheme: "dark", + }, + }, + } + ); + + await testTheme( + "color_scheme overrides toolbar / toolbar_text pair (light)", + kLight, + kLight, + { + theme: { + colors: { + toolbar: "#000", + toolbar_text: "#fff", + }, + properties: { + color_scheme: "light", + }, + }, + } + ); + + await testTheme( + "content_color_scheme overrides ntp_text / ntp_background (dark)", + kLight, + kDark, + { + theme: { + colors: { + toolbar: "#fff", + toolbar_text: "#000", + ntp_background: "#fff", + ntp_text: "#000", + }, + properties: { + content_color_scheme: "dark", + }, + }, + } + ); + + await testTheme( + "content_color_scheme overrides ntp_text / ntp_background (light)", + kLight, + kLight, + { + theme: { + colors: { + toolbar: "#fff", + toolbar_text: "#000", + ntp_background: "#000", + ntp_text: "#fff", + }, + properties: { + content_color_scheme: "light", + }, + }, + } + ); + + await testTheme( + "content_color_scheme overrides color_scheme only for content", + kLight, + kDark, + { + theme: { + colors: { + toolbar: "#fff", + toolbar_text: "#000", + ntp_background: "#fff", + ntp_text: "#000", + }, + properties: { + content_color_scheme: "dark", + }, + }, + } + ); + + await testTheme( + "content_color_scheme sytem overrides color_scheme only for content", + kLight, + kSystem, + { + theme: { + colors: { + toolbar: "#fff", + toolbar_text: "#000", + ntp_background: "#fff", + ntp_text: "#000", + }, + properties: { + content_color_scheme: "system", + }, + }, + } + ); + + await testTheme("color_scheme: sytem override", kSystem, kSystem, { + theme: { + colors: { + toolbar: "#fff", + toolbar_text: "#000", + ntp_background: "#fff", + ntp_text: "#000", + }, + properties: { + color_scheme: "system", + content_color_scheme: "system", + }, + }, + }); +}); + +add_task(async function unified_theme() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.theme.unified-color-scheme", true]], + }); + + await testTheme("Dark toolbar color", kDark, kDark, { + theme: { + colors: { + toolbar: "rgb(20, 17, 26)", + toolbar_text: "rgb(251, 29, 78)", + }, + }, + }); + + await testTheme("Light toolbar color", kLight, kLight, { + theme: { + colors: { + toolbar: "white", + toolbar_text: "black", + }, + }, + }); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_unified_extensions.js b/browser/components/extensions/test/browser/browser_unified_extensions.js new file mode 100644 index 0000000000..7ab7753c0e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_unified_extensions.js @@ -0,0 +1,1545 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +/* import-globals-from ../../../../../toolkit/mozapps/extensions/test/browser/head.js */ + +const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +loadTestSubscript("head_unified_extensions.js"); + +const openCustomizationUI = async () => { + const customizationReady = BrowserTestUtils.waitForEvent( + gNavToolbox, + "customizationready" + ); + gCustomizeMode.enter(); + await customizationReady; + ok( + CustomizationHandler.isCustomizing(), + "expected customizing mode to be enabled" + ); +}; + +const closeCustomizationUI = async () => { + const afterCustomization = BrowserTestUtils.waitForEvent( + gNavToolbox, + "aftercustomization" + ); + gCustomizeMode.exit(); + await afterCustomization; + ok( + !CustomizationHandler.isCustomizing(), + "expected customizing mode to be disabled" + ); +}; + +add_setup(async function () { + // Make sure extension buttons added to the navbar will not overflow in the + // panel, which could happen when a previous test file resizes the current + // window. + await ensureMaximizedWindow(window); +}); + +add_task(async function test_button_enabled_by_pref() { + const { button } = gUnifiedExtensions; + is(button.hidden, false, "expected button to be visible"); + is( + document + .getElementById("nav-bar") + .getAttribute("unifiedextensionsbuttonshown"), + "true", + "expected attribute on nav-bar" + ); +}); + +add_task(async function test_open_panel_on_button_click() { + const extensions = createExtensions([ + { name: "Extension #1" }, + { name: "Another extension", icons: { 16: "test-icon-16.png" } }, + { + name: "Yet another extension with an icon", + icons: { + 32: "test-icon-32.png", + }, + }, + ]); + await Promise.all(extensions.map(extension => extension.startup())); + + await openExtensionsPanel(); + + let item = getUnifiedExtensionsItem(extensions[0].id); + is( + item.querySelector(".unified-extensions-item-name").textContent, + "Extension #1", + "expected name of the first extension" + ); + is( + item.querySelector(".unified-extensions-item-icon").src, + "chrome://mozapps/skin/extensions/extensionGeneric.svg", + "expected generic icon for the first extension" + ); + Assert.deepEqual( + document.l10n.getAttributes( + item.querySelector(".unified-extensions-item-menu-button") + ), + { + id: "unified-extensions-item-open-menu", + args: { extensionName: "Extension #1" }, + }, + "expected l10n attributes for the first extension" + ); + + item = getUnifiedExtensionsItem(extensions[1].id); + is( + item.querySelector(".unified-extensions-item-name").textContent, + "Another extension", + "expected name of the second extension" + ); + ok( + item + .querySelector(".unified-extensions-item-icon") + .src.endsWith("/test-icon-16.png"), + "expected custom icon for the second extension" + ); + Assert.deepEqual( + document.l10n.getAttributes( + item.querySelector(".unified-extensions-item-menu-button") + ), + { + id: "unified-extensions-item-open-menu", + args: { extensionName: "Another extension" }, + }, + "expected l10n attributes for the second extension" + ); + + item = getUnifiedExtensionsItem(extensions[2].id); + is( + item.querySelector(".unified-extensions-item-name").textContent, + "Yet another extension with an icon", + "expected name of the third extension" + ); + ok( + item + .querySelector(".unified-extensions-item-icon") + .src.endsWith("/test-icon-32.png"), + "expected custom icon for the third extension" + ); + Assert.deepEqual( + document.l10n.getAttributes( + item.querySelector(".unified-extensions-item-menu-button") + ), + { + id: "unified-extensions-item-open-menu", + args: { extensionName: "Yet another extension with an icon" }, + }, + "expected l10n attributes for the third extension" + ); + + await closeExtensionsPanel(); + + await Promise.all(extensions.map(extension => extension.unload())); +}); + +// Verify that the context click doesn't open the panel in addition to the +// context menu. +add_task(async function test_clicks_on_unified_extension_button() { + const extensions = createExtensions([{ name: "Extension #1" }]); + await Promise.all(extensions.map(extension => extension.startup())); + + const { button, panel } = gUnifiedExtensions; + ok(button, "expected button"); + ok(panel, "expected panel"); + + info("open panel with primary click"); + await openExtensionsPanel(); + Assert.strictEqual( + panel.getAttribute("panelopen"), + "true", + "expected panel to be visible" + ); + await closeExtensionsPanel(); + ok(!panel.hasAttribute("panelopen"), "expected panel to be hidden"); + + info("open context menu with non-primary click"); + const contextMenu = document.getElementById("toolbar-context-menu"); + const popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(button, { + type: "contextmenu", + button: 2, + }); + await popupShownPromise; + ok(!panel.hasAttribute("panelopen"), "expected panel to remain hidden"); + await closeChromeContextMenu(contextMenu.id, null); + + // On MacOS, ctrl-click shouldn't open the panel because this normally opens + // the context menu. We can't test anything on MacOS... + if (AppConstants.platform !== "macosx") { + info("open panel with ctrl-click"); + const listView = getListView(); + const viewShown = BrowserTestUtils.waitForEvent(listView, "ViewShown"); + EventUtils.synthesizeMouseAtCenter(button, { ctrlKey: true }); + await viewShown; + Assert.strictEqual( + panel.getAttribute("panelopen"), + "true", + "expected panel to be visible" + ); + await closeExtensionsPanel(); + ok(!panel.hasAttribute("panelopen"), "expected panel to be hidden"); + } + + await Promise.all(extensions.map(extension => extension.unload())); +}); + +add_task(async function test_item_shows_the_best_addon_icon() { + const extensions = createExtensions([ + { + name: "Extension with different icons", + icons: { + 16: "test-icon-16.png", + 32: "test-icon-32.png", + 64: "test-icon-64.png", + 96: "test-icon-96.png", + 128: "test-icon-128.png", + }, + }, + ]); + await Promise.all(extensions.map(extension => extension.startup())); + + for (const { resolution, expectedIcon } of [ + { resolution: 2, expectedIcon: "test-icon-64.png" }, + { resolution: 1, expectedIcon: "test-icon-32.png" }, + ]) { + await SpecialPowers.pushPrefEnv({ + set: [["layout.css.devPixelsPerPx", String(resolution)]], + }); + is( + window.devicePixelRatio, + resolution, + "window has the required resolution" + ); + + await openExtensionsPanel(); + + const item = getUnifiedExtensionsItem(extensions[0].id); + const iconSrc = item.querySelector(".unified-extensions-item-icon").src; + ok( + iconSrc.endsWith(expectedIcon), + `expected ${expectedIcon}, got: ${iconSrc}` + ); + + await closeExtensionsPanel(); + await SpecialPowers.popPrefEnv(); + } + + await Promise.all(extensions.map(extension => extension.unload())); +}); + +add_task(async function test_panel_has_a_manage_extensions_button() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:robots" }, + async () => { + await openExtensionsPanel(); + + const manageExtensionsButton = getListView().querySelector( + "#unified-extensions-manage-extensions" + ); + ok(manageExtensionsButton, "expected a 'manage extensions' button"); + + const tabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:addons", + true + ); + const popupHiddenPromise = BrowserTestUtils.waitForEvent( + document, + "popuphidden", + true + ); + + manageExtensionsButton.click(); + + const [tab] = await Promise.all([tabPromise, popupHiddenPromise]); + is( + gBrowser.currentURI.spec, + "about:addons", + "Manage opened about:addons" + ); + is( + gBrowser.selectedBrowser.contentWindow.gViewController.currentViewId, + "addons://list/extension", + "expected about:addons to show the list of extensions" + ); + BrowserTestUtils.removeTab(tab); + } + ); +}); + +add_task(async function test_list_active_extensions_only() { + const arrayOfManifestData = [ + { + name: "hidden addon", + browser_specific_settings: { gecko: { id: "ext1@test" } }, + hidden: true, + }, + { + name: "regular addon", + browser_specific_settings: { gecko: { id: "ext2@test" } }, + hidden: false, + }, + { + name: "disabled addon", + browser_specific_settings: { gecko: { id: "ext3@test" } }, + hidden: false, + }, + { + name: "regular addon with browser action", + browser_specific_settings: { gecko: { id: "ext4@test" } }, + hidden: false, + browser_action: { + default_area: "navbar", + }, + }, + { + manifest_version: 3, + name: "regular mv3 addon with browser action", + browser_specific_settings: { gecko: { id: "ext5@test" } }, + hidden: false, + action: { + default_area: "navbar", + }, + }, + { + name: "regular addon with page action", + browser_specific_settings: { gecko: { id: "ext6@test" } }, + hidden: false, + page_action: {}, + }, + ]; + const extensions = createExtensions(arrayOfManifestData, { + useAddonManager: "temporary", + // Allow all extensions in PB mode by default. + incognitoOverride: "spanning", + }); + // This extension is loaded with a different `incognitoOverride` value to + // make sure it won't show up in a private window. + extensions.push( + ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "ext7@test" } }, + name: "regular addon with private browsing disabled", + }, + useAddonManager: "temporary", + incognitoOverride: "not_allowed", + }) + ); + + await Promise.all(extensions.map(extension => extension.startup())); + + // Disable the "disabled addon". + let addon2 = await AddonManager.getAddonByID(extensions[2].id); + await addon2.disable(); + + for (const isPrivate of [false, true]) { + info( + `verifying extensions listed in the panel with private browsing ${ + isPrivate ? "enabled" : "disabled" + }` + ); + const aWin = await BrowserTestUtils.openNewBrowserWindow({ + private: isPrivate, + }); + // Make sure extension buttons added to the navbar will not overflow in the + // panel, which could happen when a previous test file resizes the current + // window. + await ensureMaximizedWindow(aWin); + + await openExtensionsPanel(aWin); + + ok( + aWin.gUnifiedExtensions._button.open, + "Expected unified extension panel to be open" + ); + + const hiddenAddonItem = getUnifiedExtensionsItem(extensions[0].id, aWin); + is(hiddenAddonItem, null, "didn't expect an item for a hidden add-on"); + + const regularAddonItem = getUnifiedExtensionsItem(extensions[1].id, aWin); + is( + regularAddonItem.querySelector(".unified-extensions-item-name") + .textContent, + "regular addon", + "expected an item for a regular add-on" + ); + + const disabledAddonItem = getUnifiedExtensionsItem(extensions[2].id, aWin); + is(disabledAddonItem, null, "didn't expect an item for a disabled add-on"); + + const browserActionItem = getUnifiedExtensionsItem(extensions[3].id, aWin); + is( + browserActionItem, + null, + "didn't expect an item for an add-on with browser action placed in the navbar" + ); + + const mv3BrowserActionItem = getUnifiedExtensionsItem( + extensions[4].id, + aWin + ); + is( + mv3BrowserActionItem, + null, + "didn't expect an item for a MV3 add-on with browser action placed in the navbar" + ); + + const pageActionItem = getUnifiedExtensionsItem(extensions[5].id, aWin); + is( + pageActionItem.querySelector(".unified-extensions-item-name").textContent, + "regular addon with page action", + "expected an item for a regular add-on with page action" + ); + + const privateBrowsingDisabledItem = getUnifiedExtensionsItem( + extensions[6].id, + aWin + ); + if (isPrivate) { + is( + privateBrowsingDisabledItem, + null, + "didn't expect an item for a regular add-on with private browsing enabled" + ); + } else { + is( + privateBrowsingDisabledItem.querySelector( + ".unified-extensions-item-name" + ).textContent, + "regular addon with private browsing disabled", + "expected an item for a regular add-on with private browsing disabled" + ); + } + + await closeExtensionsPanel(aWin); + + await BrowserTestUtils.closeWindow(aWin); + } + + await Promise.all(extensions.map(extension => extension.unload())); +}); + +add_task(async function test_button_opens_discopane_when_no_extension() { + // The test harness registers regular extensions so we need to mock the + // `getActivePolicies` extension to simulate zero extensions installed. + const origGetActivePolicies = gUnifiedExtensions.getActivePolicies; + gUnifiedExtensions.getActivePolicies = () => []; + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:robots" }, + async () => { + const { button } = gUnifiedExtensions; + ok(button, "expected button"); + + // Primary click should open about:addons. + const tabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:addons", + true + ); + + button.click(); + + const tab = await tabPromise; + is( + gBrowser.currentURI.spec, + "about:addons", + "expected about:addons to be open" + ); + is( + gBrowser.selectedBrowser.contentWindow.gViewController.currentViewId, + "addons://discover/", + "expected about:addons to show the recommendations" + ); + BrowserTestUtils.removeTab(tab); + + // "Right-click" should open the context menu only. + const contextMenu = document.getElementById("toolbar-context-menu"); + const popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(button, { + type: "contextmenu", + button: 2, + }); + await popupShownPromise; + await closeChromeContextMenu(contextMenu.id, null); + } + ); + + gUnifiedExtensions.getActivePolicies = origGetActivePolicies; +}); + +add_task( + async function test_button_opens_extlist_when_no_extension_and_pane_disabled() { + // If extensions.getAddons.showPane is set to false, there is no "Recommended" tab, + // so we need to make sure we don't navigate to it. + + // The test harness registers regular extensions so we need to mock the + // `getActivePolicies` extension to simulate zero extensions installed. + const origGetActivePolicies = gUnifiedExtensions.getActivePolicies; + gUnifiedExtensions.getActivePolicies = () => []; + + await SpecialPowers.pushPrefEnv({ + set: [ + // Set this to another value to make sure not to "accidentally" land on the right page + ["extensions.ui.lastCategory", "addons://list/theme"], + ["extensions.getAddons.showPane", false], + ], + }); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:robots" }, + async () => { + const { button } = gUnifiedExtensions; + ok(button, "expected button"); + + // Primary click should open about:addons. + const tabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:addons", + true + ); + + button.click(); + + const tab = await tabPromise; + is( + gBrowser.currentURI.spec, + "about:addons", + "expected about:addons to be open" + ); + is( + gBrowser.selectedBrowser.contentWindow.gViewController.currentViewId, + "addons://list/extension", + "expected about:addons to show the extension list" + ); + BrowserTestUtils.removeTab(tab); + } + ); + + await SpecialPowers.popPrefEnv(); + + gUnifiedExtensions.getActivePolicies = origGetActivePolicies; + } +); + +add_task( + async function test_unified_extensions_panel_not_open_in_customization_mode() { + const listView = getListView(); + ok(listView, "expected list view"); + const throwIfExecuted = () => { + throw new Error("panel should not have been shown"); + }; + listView.addEventListener("ViewShown", throwIfExecuted); + + await openCustomizationUI(); + + const unifiedExtensionsButtonToggled = BrowserTestUtils.waitForEvent( + window, + "UnifiedExtensionsTogglePanel" + ); + const button = document.getElementById("unified-extensions-button"); + + button.click(); + await unifiedExtensionsButtonToggled; + + await closeCustomizationUI(); + + listView.removeEventListener("ViewShown", throwIfExecuted); + } +); + +const NO_ACCESS = { id: "origin-controls-state-no-access", args: null }; +const QUARANTINED = { id: "origin-controls-state-quarantined", args: null }; + +const ALWAYS_ON = { id: "origin-controls-state-always-on", args: null }; +const WHEN_CLICKED = { id: "origin-controls-state-when-clicked", args: null }; +const TEMP_ACCESS = { + id: "origin-controls-state-temporary-access", + args: null, +}; + +const HOVER_RUN_VISIT_ONLY = { + id: "origin-controls-state-hover-run-visit-only", + args: null, +}; +const HOVER_RUNNABLE_RUN_EXT = { + id: "origin-controls-state-runnable-hover-run", + args: null, +}; +const HOVER_RUNNABLE_OPEN_EXT = { + id: "origin-controls-state-runnable-hover-open", + args: null, +}; + +add_task(async function test_messages_origin_controls() { + const TEST_CASES = [ + { + title: "MV2 - no access", + manifest: { + manifest_version: 2, + }, + expectedDefaultMessage: NO_ACCESS, + expectedHoverMessage: NO_ACCESS, + expectedActionButtonDisabled: true, + }, + { + title: "MV2 - always on", + manifest: { + manifest_version: 2, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: ALWAYS_ON, + expectedActionButtonDisabled: true, + }, + { + title: "MV2 - content script", + manifest: { + manifest_version: 2, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: ALWAYS_ON, + expectedActionButtonDisabled: true, + }, + { + title: "MV2 - non-matching content script", + manifest: { + manifest_version: 2, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://foobar.net/*"], + }, + ], + }, + expectedDefaultMessage: NO_ACCESS, + expectedHoverMessage: NO_ACCESS, + expectedActionButtonDisabled: true, + }, + { + title: "MV2 - all_urls content script", + manifest: { + manifest_version: 2, + content_scripts: [ + { + js: ["script.js"], + matches: [""], + }, + ], + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: ALWAYS_ON, + expectedActionButtonDisabled: true, + }, + { + title: "MV2 - activeTab without browser action", + manifest: { + manifest_version: 2, + permissions: ["activeTab"], + }, + expectedDefaultMessage: NO_ACCESS, + expectedHoverMessage: NO_ACCESS, + expectedActionButtonDisabled: true, + }, + { + title: "MV2 - when clicked: activeTab with browser action", + manifest: { + manifest_version: 2, + permissions: ["activeTab"], + browser_action: {}, + }, + expectedDefaultMessage: WHEN_CLICKED, + expectedHoverMessage: HOVER_RUN_VISIT_ONLY, + expectedActionButtonDisabled: false, + }, + { + title: "MV3 - when clicked: activeTab with action", + manifest: { + manifest_version: 3, + permissions: ["activeTab"], + action: {}, + }, + expectedDefaultMessage: WHEN_CLICKED, + expectedHoverMessage: HOVER_RUN_VISIT_ONLY, + expectedActionButtonDisabled: false, + }, + { + title: "MV2 - browser action - click event - always on", + manifest: { + manifest_version: 2, + browser_action: {}, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: HOVER_RUNNABLE_RUN_EXT, + expectedActionButtonDisabled: false, + }, + { + title: "MV2 - browser action - popup - always on", + manifest: { + manifest_version: 2, + browser_action: { + default_popup: "popup.html", + }, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: HOVER_RUNNABLE_OPEN_EXT, + expectedActionButtonDisabled: false, + }, + { + title: "MV2 - browser action - click event - content script", + manifest: { + manifest_version: 2, + browser_action: {}, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: HOVER_RUNNABLE_RUN_EXT, + expectedActionButtonDisabled: false, + }, + { + title: "MV2 - browser action - popup - content script", + manifest: { + manifest_version: 2, + browser_action: { + default_popup: "popup.html", + }, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: HOVER_RUNNABLE_OPEN_EXT, + expectedActionButtonDisabled: false, + }, + { + title: "no access", + manifest: { + manifest_version: 3, + }, + expectedDefaultMessage: NO_ACCESS, + expectedHoverMessage: NO_ACCESS, + expectedActionButtonDisabled: true, + }, + { + title: "when clicked with host permissions", + manifest: { + manifest_version: 3, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: WHEN_CLICKED, + expectedHoverMessage: HOVER_RUN_VISIT_ONLY, + expectedActionButtonDisabled: false, + }, + { + title: "when clicked with host permissions already granted", + manifest: { + manifest_version: 3, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: ALWAYS_ON, + expectedActionButtonDisabled: true, + grantHostPermissions: true, + }, + { + title: "when clicked", + manifest: { + manifest_version: 3, + permissions: ["activeTab"], + }, + expectedDefaultMessage: WHEN_CLICKED, + expectedHoverMessage: HOVER_RUN_VISIT_ONLY, + expectedActionButtonDisabled: false, + }, + { + title: "page action - no access", + manifest: { + manifest_version: 3, + page_action: {}, + }, + expectedDefaultMessage: NO_ACCESS, + expectedHoverMessage: NO_ACCESS, + expectedActionButtonDisabled: true, + }, + { + title: "page action - when clicked with host permissions", + manifest: { + manifest_version: 3, + host_permissions: ["*://example.com/*"], + page_action: {}, + }, + expectedDefaultMessage: WHEN_CLICKED, + expectedHoverMessage: HOVER_RUN_VISIT_ONLY, + expectedActionButtonDisabled: false, + }, + { + title: "page action - when clicked with host permissions already granted", + manifest: { + manifest_version: 3, + host_permissions: ["*://example.com/*"], + page_action: {}, + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: ALWAYS_ON, + expectedActionButtonDisabled: true, + grantHostPermissions: true, + }, + { + title: "page action - when clicked", + manifest: { + manifest_version: 3, + permissions: ["activeTab"], + page_action: {}, + }, + expectedDefaultMessage: WHEN_CLICKED, + expectedHoverMessage: HOVER_RUN_VISIT_ONLY, + expectedActionButtonDisabled: false, + }, + { + title: "browser action - click event - no access", + manifest: { + manifest_version: 3, + action: {}, + }, + expectedDefaultMessage: NO_ACCESS, + expectedHoverMessage: HOVER_RUNNABLE_RUN_EXT, + expectedActionButtonDisabled: false, + }, + { + title: "browser action - popup - no access", + manifest: { + manifest_version: 3, + action: { + default_popup: "popup.html", + }, + }, + expectedDefaultMessage: NO_ACCESS, + expectedHoverMessage: HOVER_RUNNABLE_OPEN_EXT, + expectedActionButtonDisabled: false, + }, + { + title: "browser action - click event - when clicked", + manifest: { + manifest_version: 3, + action: {}, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: WHEN_CLICKED, + expectedHoverMessage: HOVER_RUN_VISIT_ONLY, + expectedActionButtonDisabled: false, + }, + { + title: "browser action - popup - when clicked", + manifest: { + manifest_version: 3, + action: { + default_popup: "popup.html", + }, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: WHEN_CLICKED, + expectedHoverMessage: HOVER_RUN_VISIT_ONLY, + expectedActionButtonDisabled: false, + }, + { + title: + "browser action - click event - when clicked with host permissions already granted", + manifest: { + manifest_version: 3, + action: {}, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: HOVER_RUNNABLE_RUN_EXT, + expectedActionButtonDisabled: false, + grantHostPermissions: true, + }, + { + title: + "browser action - popup - when clicked with host permissions already granted", + manifest: { + manifest_version: 3, + action: { + default_popup: "popup.html", + }, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: HOVER_RUNNABLE_OPEN_EXT, + expectedActionButtonDisabled: false, + grantHostPermissions: true, + }, + ]; + + async function runTestCases(testCases) { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "https://example.com/" }, + async () => { + let count = 0; + + for (const { + title, + manifest, + expectedDefaultMessage, + expectedHoverMessage, + expectedActionButtonDisabled, + grantHostPermissions, + } of testCases) { + info(`case: ${title}`); + + const id = `test-origin-controls-${count++}@ext`; + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + name: title, + browser_specific_settings: { gecko: { id } }, + ...manifest, + }, + files: { + "script.js": "", + "popup.html": "", + }, + useAddonManager: "permanent", + }); + + if (grantHostPermissions) { + info("Granting initial permissions."); + await ExtensionPermissions.add(id, { + permissions: [], + origins: manifest.host_permissions, + }); + } + + await extension.startup(); + + // Open the extension panel. + await openExtensionsPanel(); + + const item = getUnifiedExtensionsItem(extension.id); + ok(item, `expected item for ${extension.id}`); + + const messageDeck = item.querySelector( + ".unified-extensions-item-message-deck" + ); + ok(messageDeck, "expected a message deck element"); + + // 1. Verify the default message displayed below the extension's name. + const defaultMessage = item.querySelector( + ".unified-extensions-item-message-default" + ); + ok(defaultMessage, "expected a default message element"); + + Assert.deepEqual( + document.l10n.getAttributes(defaultMessage), + expectedDefaultMessage, + "expected l10n attributes for the default message" + ); + + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_DEFAULT, + "expected selected message in the deck to be the default message" + ); + + // 2. Verify the action button state. + const actionButton = item.querySelector( + ".unified-extensions-item-action-button" + ); + ok(actionButton, "expected an action button"); + is( + actionButton.disabled, + expectedActionButtonDisabled, + `expected action button to be ${ + expectedActionButtonDisabled ? "disabled" : "enabled" + }` + ); + + // 3. Verify the message displayed on hover but only when the action + // button isn't disabled to avoid some test failures. + if (!expectedActionButtonDisabled) { + const hovered = BrowserTestUtils.waitForEvent( + actionButton, + "mouseover" + ); + EventUtils.synthesizeMouseAtCenter(actionButton, { + type: "mouseover", + }); + await hovered; + + const hoverMessage = item.querySelector( + ".unified-extensions-item-message-hover" + ); + ok(hoverMessage, "expected a hover message element"); + + Assert.deepEqual( + document.l10n.getAttributes(hoverMessage), + expectedHoverMessage, + "expected l10n attributes for the message on hover" + ); + + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to be the hover message" + ); + } + + await closeExtensionsPanel(); + + // Move cursor elsewhere to avoid issues with previous "hovering". + EventUtils.synthesizeMouseAtCenter(gURLBar.textbox, {}); + + await extension.unload(); + } + } + ); + } + + await runTestCases(TEST_CASES); + + info("Testing again with example.com quarantined."); + await SpecialPowers.pushPrefEnv({ + set: [["extensions.quarantinedDomains.list", "example.com"]], + }); + + await runTestCases([ + { + title: "MV2 - no access", + manifest: { + manifest_version: 2, + }, + expectedDefaultMessage: NO_ACCESS, + expectedHoverMessage: NO_ACCESS, + expectedActionButtonDisabled: true, + }, + { + title: "MV2 - host permission but quarantined", + manifest: { + manifest_version: 2, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: QUARANTINED, + expectedHoverMessage: QUARANTINED, + expectedActionButtonDisabled: true, + }, + { + title: "MV2 - content script but quarantined", + manifest: { + manifest_version: 2, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + }, + expectedDefaultMessage: QUARANTINED, + expectedHoverMessage: QUARANTINED, + expectedActionButtonDisabled: true, + }, + { + title: "MV2 - non-matching content script", + manifest: { + manifest_version: 2, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://foobar.net/*"], + }, + ], + }, + expectedDefaultMessage: NO_ACCESS, + expectedHoverMessage: NO_ACCESS, + expectedActionButtonDisabled: true, + }, + { + title: "MV3 - content script but quarantined", + manifest: { + manifest_version: 2, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: QUARANTINED, + expectedHoverMessage: QUARANTINED, + expectedActionButtonDisabled: true, + grantHostPermissions: true, + }, + { + title: "MV3 host permissions already granted but quarantined", + manifest: { + manifest_version: 3, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: QUARANTINED, + expectedHoverMessage: QUARANTINED, + expectedActionButtonDisabled: true, + grantHostPermissions: true, + }, + { + title: "browser action, host permissions already granted, quarantined", + manifest: { + manifest_version: 3, + action: {}, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: QUARANTINED, + expectedHoverMessage: HOVER_RUNNABLE_RUN_EXT, + expectedActionButtonDisabled: false, + grantHostPermissions: true, + }, + ]); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_hover_message_when_button_updates_itself() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + name: "an extension that refreshes its title", + action: {}, + }, + background() { + browser.test.onMessage.addListener(async msg => { + browser.test.assertEq( + "update-button", + msg, + "expected 'update-button' message" + ); + + browser.action.setTitle({ title: "a title" }); + + browser.test.sendMessage(`${msg}-done`); + }); + + browser.test.sendMessage("background-ready"); + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + await openExtensionsPanel(); + + const item = getUnifiedExtensionsItem(extension.id); + ok(item, "expected item in the panel"); + + const actionButton = item.querySelector( + ".unified-extensions-item-action-button" + ); + ok(actionButton, "expected an action button"); + + const menuButton = item.querySelector(".unified-extensions-item-menu-button"); + ok(menuButton, "expected a menu button"); + + const hovered = BrowserTestUtils.waitForEvent(actionButton, "mouseover"); + EventUtils.synthesizeMouseAtCenter(actionButton, { type: "mouseover" }); + await hovered; + + const messageDeck = item.querySelector( + ".unified-extensions-item-message-deck" + ); + ok(messageDeck, "expected a message deck element"); + + const hoverMessage = item.querySelector( + ".unified-extensions-item-message-hover" + ); + ok(hoverMessage, "expected a hover message element"); + + const expectedL10nAttributes = { + id: "origin-controls-state-runnable-hover-run", + args: null, + }; + Assert.deepEqual( + document.l10n.getAttributes(hoverMessage), + expectedL10nAttributes, + "expected l10n attributes for the hover message" + ); + + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to be the hover message" + ); + + extension.sendMessage("update-button"); + await extension.awaitMessage("update-button-done"); + + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to remain the same" + ); + + const menuButtonHovered = BrowserTestUtils.waitForEvent( + menuButton, + "mouseover" + ); + EventUtils.synthesizeMouseAtCenter(menuButton, { type: "mouseover" }); + await menuButtonHovered; + + await closeExtensionsPanel(); + + // Move cursor to the center of the entire browser UI to avoid issues with + // other focus/hover checks. We do this to avoid intermittent test failures. + EventUtils.synthesizeMouseAtCenter(document.documentElement, {}); + + await extension.unload(); +}); + +// Test the temporary access state messages and attention indicator. +add_task(async function test_temporary_access() { + const TEST_CASES = [ + { + title: "mv3 with active scripts and browser action", + manifest: { + manifest_version: 3, + action: {}, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + }, + before: { + attention: true, + state: WHEN_CLICKED, + disabled: false, + }, + messages: ["action-onClicked", "cs-injected"], + after: { + attention: false, + state: TEMP_ACCESS, + disabled: false, + }, + }, + { + title: "mv3 with active scripts and no browser action", + manifest: { + manifest_version: 3, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + }, + before: { + attention: true, + state: WHEN_CLICKED, + disabled: false, + }, + messages: ["cs-injected"], + after: { + attention: false, + state: TEMP_ACCESS, + // TODO: This will need updating for bug 1807835. + disabled: false, + }, + }, + { + title: "mv3 with browser action and host_permission", + manifest: { + manifest_version: 3, + action: {}, + host_permissions: ["*://example.com/*"], + }, + before: { + attention: true, + state: WHEN_CLICKED, + disabled: false, + }, + messages: ["action-onClicked"], + after: { + attention: false, + state: TEMP_ACCESS, + disabled: false, + }, + }, + { + title: "mv3 with browser action no host_permissions", + manifest: { + manifest_version: 3, + action: {}, + }, + before: { + attention: false, + state: NO_ACCESS, + disabled: false, + }, + messages: ["action-onClicked"], + after: { + attention: false, + state: NO_ACCESS, + disabled: false, + }, + }, + // MV2 tests. + { + title: "mv2 with content scripts and browser action", + manifest: { + manifest_version: 2, + browser_action: {}, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + }, + before: { + attention: false, + state: ALWAYS_ON, + disabled: false, + }, + messages: ["action-onClicked", "cs-injected"], + after: { + attention: false, + state: ALWAYS_ON, + disabled: false, + }, + }, + { + title: "mv2 with content scripts and no browser action", + manifest: { + manifest_version: 2, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + }, + before: { + attention: false, + state: ALWAYS_ON, + disabled: true, + }, + messages: ["cs-injected"], + after: { + attention: false, + state: ALWAYS_ON, + disabled: true, + }, + }, + { + title: "mv2 with browser action and host_permission", + manifest: { + manifest_version: 2, + browser_action: {}, + host_permissions: ["*://example.com/*"], + }, + before: { + attention: false, + state: ALWAYS_ON, + disabled: false, + }, + messages: ["action-onClicked"], + after: { + attention: false, + state: ALWAYS_ON, + disabled: false, + }, + }, + { + title: "mv2 with browser action no host_permissions", + manifest: { + manifest_version: 2, + browser_action: {}, + }, + before: { + attention: false, + state: NO_ACCESS, + disabled: false, + }, + messages: ["action-onClicked"], + after: { + attention: false, + state: NO_ACCESS, + disabled: false, + }, + }, + ]; + + let count = 1; + await Promise.all( + TEST_CASES.map(test => { + let id = `test-temp-access-${count++}@ext`; + test.extension = ExtensionTestUtils.loadExtension({ + manifest: { + name: test.title, + browser_specific_settings: { gecko: { id } }, + ...test.manifest, + }, + files: { + "popup.html": "", + "script.js"() { + browser.test.sendMessage("cs-injected"); + }, + }, + background() { + let action = browser.action ?? browser.browserAction; + action?.onClicked.addListener(() => { + browser.test.sendMessage("action-onClicked"); + }); + }, + useAddonManager: "temporary", + }); + + return test.extension.startup(); + }) + ); + + async function checkButton(extension, expect, click = false) { + await openExtensionsPanel(); + + let item = getUnifiedExtensionsItem(extension.id); + ok(item, `Expected item for ${extension.id}.`); + + let state = item.querySelector(".unified-extensions-item-message-default"); + ok(state, "Expected a default state message element."); + + is( + item.hasAttribute("attention"), + !!expect.attention, + "Expected attention badge." + ); + Assert.deepEqual( + document.l10n.getAttributes(state), + expect.state, + "Expected l10n attributes for the message." + ); + + let button = item.querySelector(".unified-extensions-item-action-button"); + is(button.disabled, !!expect.disabled, "Expect disabled item."); + + // If we should click, and button is not disabled. + if (click && !expect.disabled) { + let onClick = BrowserTestUtils.waitForEvent(button, "click"); + button.click(); + await onClick; + } else { + // Otherwise, just close the panel. + await closeExtensionsPanel(); + } + } + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "https://example.com/" }, + async () => { + for (let { title, extension, before, messages, after } of TEST_CASES) { + info(`Test case: ${title}`); + await checkButton(extension, before, true); + + await Promise.all( + messages.map(msg => { + info(`Waiting for ${msg} from clicking the button.`); + return extension.awaitMessage(msg); + }) + ); + + await checkButton(extension, after); + await extension.unload(); + } + } + ); +}); + +add_task( + async function test_action_and_menu_buttons_css_class_with_new_window() { + const [extension] = createExtensions([ + { + name: "an extension placed in the extensions panel", + browser_action: { + default_area: "menupanel", + }, + }, + ]); + await extension.startup(); + + let aSecondWindow = await BrowserTestUtils.openNewBrowserWindow(); + await ensureMaximizedWindow(aSecondWindow); + + // Open and close the extensions panel in the newly created window to build + // the extensions panel and add the extension widget(s) to it. + await openExtensionsPanel(aSecondWindow); + await closeExtensionsPanel(aSecondWindow); + + for (const { title, win } of [ + { title: "current window", win: window }, + { title: "second window", win: aSecondWindow }, + ]) { + const node = CustomizableUI.getWidget( + AppUiTestInternals.getBrowserActionWidgetId(extension.id) + ).forWindow(win).node; + + let actionButton = node.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButton.classList.contains("subviewbutton"), + `${title} - expected .subviewbutton CSS class on the action button` + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + `${title} - expected no .toolbarbutton-1 CSS class on the action button` + ); + let menuButton = node.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButton.classList.contains("subviewbutton"), + `${title} - expected .subviewbutton CSS class on the menu button` + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + `${title} - expected no .toolbarbutton-1 CSS class on the menu button` + ); + } + + await BrowserTestUtils.closeWindow(aSecondWindow); + + await extension.unload(); + } +); diff --git a/browser/components/extensions/test/browser/browser_unified_extensions_accessibility.js b/browser/components/extensions/test/browser/browser_unified_extensions_accessibility.js new file mode 100644 index 0000000000..44d861d97c --- /dev/null +++ b/browser/components/extensions/test/browser/browser_unified_extensions_accessibility.js @@ -0,0 +1,302 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +loadTestSubscript("head_unified_extensions.js"); + +add_task(async function test_keyboard_navigation_activeScript() { + const extension1 = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + name: "1", + content_scripts: [ + { + matches: ["*://*/*"], + js: ["script.js"], + }, + ], + }, + files: { + "script.js": () => { + browser.test.fail("this script should NOT have been executed"); + }, + }, + useAddonManager: "temporary", + }); + const extension2 = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + name: "2", + content_scripts: [ + { + matches: ["*://*/*"], + js: ["script.js"], + }, + ], + }, + files: { + "script.js": () => { + browser.test.sendMessage("script executed"); + }, + }, + useAddonManager: "temporary", + }); + + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "https://example.org/" + ); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + await Promise.all([extension1.startup(), extension2.startup()]); + + // Open the extension panel. + await openExtensionsPanel(); + + let item = getUnifiedExtensionsItem(extension1.id); + ok(item, `expected item for ${extension1.id}`); + + info("moving focus to first item in the unified extensions panel"); + let actionButton = item.querySelector( + ".unified-extensions-item-action-button" + ); + let focused = BrowserTestUtils.waitForEvent(actionButton, "focus"); + EventUtils.synthesizeKey("VK_TAB", {}); + await focused; + is( + actionButton, + document.activeElement, + "expected action button of first extension item to be focused" + ); + + item = getUnifiedExtensionsItem(extension2.id); + ok(item, `expected item for ${extension2.id}`); + + info("moving focus to second item in the unified extensions panel"); + actionButton = item.querySelector(".unified-extensions-item-action-button"); + focused = BrowserTestUtils.waitForEvent(actionButton, "focus"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + await focused; + is( + actionButton, + document.activeElement, + "expected action button of second extension item to be focused" + ); + + info("granting permission"); + const popupHidden = BrowserTestUtils.waitForEvent( + document, + "popuphidden", + true + ); + EventUtils.synthesizeKey(" ", {}); + await Promise.all([popupHidden, extension2.awaitMessage("script executed")]); + + await Promise.all([extension1.unload(), extension2.unload()]); +}); + +add_task(async function test_keyboard_navigation_opens_menu() { + const extension1 = ExtensionTestUtils.loadExtension({ + manifest: { + name: "1", + // activeTab and browser_action needed to enable the action button in mv2. + permissions: ["activeTab"], + browser_action: {}, + }, + useAddonManager: "temporary", + }); + const extension2 = ExtensionTestUtils.loadExtension({ + manifest: { + name: "2", + }, + useAddonManager: "temporary", + }); + const extension3 = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + name: "3", + // activeTab enables the action button without a browser action in mv3. + permissions: ["activeTab"], + }, + useAddonManager: "temporary", + }); + + await extension1.startup(); + await extension2.startup(); + await extension3.startup(); + + // Open the extension panel. + await openExtensionsPanel(); + + let item = getUnifiedExtensionsItem(extension1.id); + ok(item, `expected item for ${extension1.id}`); + + let messageDeck = item.querySelector(".unified-extensions-item-message-deck"); + ok(messageDeck, "expected a message deck element"); + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_DEFAULT, + "expected selected message in the deck to be the default message" + ); + + info("moving focus to first item in the unified extensions panel"); + let actionButton = item.querySelector( + ".unified-extensions-item-action-button" + ); + let focused = BrowserTestUtils.waitForEvent(actionButton, "focus"); + EventUtils.synthesizeKey("VK_TAB", {}); + await focused; + is( + actionButton, + document.activeElement, + "expected action button of the first extension item to be focused" + ); + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to be the hover message" + ); + + info( + "moving focus to menu button of the first item in the unified extensions panel" + ); + let menuButton = item.querySelector(".unified-extensions-item-menu-button"); + focused = BrowserTestUtils.waitForEvent(menuButton, "focus"); + ok(menuButton, "expected menu button"); + EventUtils.synthesizeKey("VK_TAB", {}); + await focused; + is( + menuButton, + document.activeElement, + "expected menu button in first extension item to be focused" + ); + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_MENU_HOVER, + "expected selected message in the deck to be the message when hovering the menu button" + ); + + info("opening menu of the first item"); + const contextMenu = document.getElementById( + "unified-extensions-context-menu" + ); + ok(contextMenu, "expected menu"); + const shown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + EventUtils.synthesizeKey(" ", {}); + await shown; + + await closeChromeContextMenu(contextMenu.id, null); + + info("moving focus back to the action button of the first item"); + focused = BrowserTestUtils.waitForEvent(actionButton, "focus"); + EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }); + await focused; + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to be the hover message" + ); + + // Moving to the third extension directly because the second extension cannot + // do anything on the current page and its action button is disabled. Note + // that this third extension does not have a browser action but it has + // "activeTab", which makes the extension "clickable". This allows us to + // verify the focus/blur behavior of custom elments. + info("moving focus to third item in the panel"); + item = getUnifiedExtensionsItem(extension3.id); + ok(item, `expected item for ${extension3.id}`); + actionButton = item.querySelector(".unified-extensions-item-action-button"); + ok(actionButton, `expected action button for ${extension3.id}`); + messageDeck = item.querySelector(".unified-extensions-item-message-deck"); + ok(messageDeck, `expected message deck for ${extension3.id}`); + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_DEFAULT, + "expected selected message in the deck to be the default message" + ); + // Now that we checked everything on this third extension, let's actually + // focus it with the arrow down key. + focused = BrowserTestUtils.waitForEvent(actionButton, "focus"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + await focused; + is( + actionButton, + document.activeElement, + "expected action button of the third extension item to be focused" + ); + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to be the hover message" + ); + + info( + "moving focus to menu button of the third item in the unified extensions panel" + ); + menuButton = item.querySelector(".unified-extensions-item-menu-button"); + focused = BrowserTestUtils.waitForEvent(menuButton, "focus"); + ok(menuButton, "expected menu button"); + EventUtils.synthesizeKey("VK_TAB", {}); + await focused; + is( + menuButton, + document.activeElement, + "expected menu button in third extension item to be focused" + ); + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_MENU_HOVER, + "expected selected message in the deck to be the message when hovering the menu button" + ); + + info("moving focus back to the action button of the third item"); + focused = BrowserTestUtils.waitForEvent(actionButton, "focus"); + EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }); + await focused; + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to be the hover message" + ); + + await closeExtensionsPanel(); + + await extension1.unload(); + await extension2.unload(); + await extension3.unload(); +}); + +add_task(async function test_open_panel_with_keyboard_navigation() { + const { button, panel } = gUnifiedExtensions; + ok(button, "expected button"); + ok(panel, "expected panel"); + + const listView = getListView(); + ok(listView, "expected list view"); + + // Force focus on the unified extensions button. + const forceFocusUnifiedExtensionsButton = () => { + button.setAttribute("tabindex", "-1"); + button.focus(); + button.removeAttribute("tabindex"); + }; + forceFocusUnifiedExtensionsButton(); + + // Use the "space" key to open the panel. + let viewShown = BrowserTestUtils.waitForEvent(listView, "ViewShown"); + EventUtils.synthesizeKey(" ", {}); + await viewShown; + + await closeExtensionsPanel(); + + // Force focus on the unified extensions button again. + forceFocusUnifiedExtensionsButton(); + + // Use the "return" key to open the panel. + viewShown = BrowserTestUtils.waitForEvent(listView, "ViewShown"); + EventUtils.synthesizeKey("KEY_Enter", {}); + await viewShown; + + await closeExtensionsPanel(); +}); 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 new file mode 100644 index 0000000000..d04d85e535 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_unified_extensions_context_menu.js @@ -0,0 +1,1006 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +ChromeUtils.defineESModuleGetters(this, { + AbuseReporter: "resource://gre/modules/AbuseReporter.sys.mjs", +}); + +const { EnterprisePolicyTesting } = ChromeUtils.importESModule( + "resource://testing-common/EnterprisePolicyTesting.sys.mjs" +); +loadTestSubscript("head_unified_extensions.js"); + +// We expect this rejection when the abuse report dialog window is +// being forcefully closed as part of the related test task. +PromiseTestUtils.allowMatchingRejectionsGlobally(/report dialog closed/); + +const promiseExtensionUninstalled = extensionId => { + return new Promise(resolve => { + let listener = {}; + listener.onUninstalled = addon => { + if (addon.id == extensionId) { + AddonManager.removeAddonListener(listener); + resolve(); + } + }; + AddonManager.addAddonListener(listener); + }); +}; + +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])" + ); + is(visibleItems.length, expected, `expected ${expected} visible menu items`); +} + +function assertOrderOfWidgetsInPanel(extensions, win = window) { + const widgetIds = CustomizableUI.getWidgetIdsInArea( + CustomizableUI.AREA_ADDONS + ).filter( + widgetId => !!CustomizableUI.getWidget(widgetId).forWindow(win).node + ); + const widgetIdsFromExtensions = extensions.map(ext => + AppUiTestInternals.getBrowserActionWidgetId(ext.id) + ); + + Assert.deepEqual( + widgetIds, + widgetIdsFromExtensions, + "expected extensions to be ordered" + ); +} + +async function moveWidgetUp(extension, win = window) { + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id, win); + const hidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + contextMenu.activateItem( + contextMenu.querySelector(".unified-extensions-context-menu-move-widget-up") + ); + await hidden; +} + +async function moveWidgetDown(extension, win = window) { + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id, win); + const hidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + contextMenu.activateItem( + contextMenu.querySelector( + ".unified-extensions-context-menu-move-widget-down" + ) + ); + await hidden; +} + +async function pinToToolbar(extension, win = window) { + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id, win); + const pinToToolbarItem = contextMenu.querySelector( + ".unified-extensions-context-menu-pin-to-toolbar" + ); + ok(pinToToolbarItem, "expected 'pin to toolbar' menu item"); + + const hidden = BrowserTestUtils.waitForEvent( + win.gUnifiedExtensions.panel, + "popuphidden", + true + ); + contextMenu.activateItem(pinToToolbarItem); + await hidden; +} + +async function assertMoveContextMenuItems( + ext, + { expectMoveUpHidden, expectMoveDownHidden, expectOrder }, + win = window +) { + const extName = WebExtensionPolicy.getByID(ext.id).name; + info(`Assert Move context menu items visibility for ${extName}`); + const contextMenu = await openUnifiedExtensionsContextMenu(ext.id, win); + const moveUp = contextMenu.querySelector( + ".unified-extensions-context-menu-move-widget-up" + ); + const moveDown = contextMenu.querySelector( + ".unified-extensions-context-menu-move-widget-down" + ); + ok(moveUp, "expected 'move up' item in the context menu"); + ok(moveDown, "expected 'move down' item in the context menu"); + + is( + BrowserTestUtils.isHidden(moveUp), + expectMoveUpHidden, + `expected 'move up' item to be ${expectMoveUpHidden ? "hidden" : "visible"}` + ); + is( + BrowserTestUtils.isHidden(moveDown), + expectMoveDownHidden, + `expected 'move down' item to be ${ + expectMoveDownHidden ? "hidden" : "visible" + }` + ); + const expectedVisibleItems = + 5 + (+(expectMoveUpHidden ? 0 : 1) + (expectMoveDownHidden ? 0 : 1)); + assertVisibleContextMenuItems(contextMenu, expectedVisibleItems); + if (expectOrder) { + assertOrderOfWidgetsInPanel(expectOrder, win); + } + await closeChromeContextMenu(contextMenu.id, null, win); +} + +add_setup(async () => { + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.abuseReport.enabled", true], + [ + "extensions.abuseReport.amoFormURL", + "https://example.org/%LOCALE%/%APP%/feedback/addon/%addonID%/", + ], + ], + }); +}); + +add_task(async function test_context_menu() { + const [extension] = createExtensions([{ name: "an extension" }]); + await extension.startup(); + + // Open the extension panel. + await openExtensionsPanel(); + + // Get the menu button of the extension and verify the mouseover/mouseout + // behavior. We expect a help message (in the message deck) to be selected + // (and therefore displayed) when the menu button is hovered/focused. + const item = getUnifiedExtensionsItem(extension.id); + ok(item, "expected an item for the extension"); + + const messageDeck = item.querySelector( + ".unified-extensions-item-message-deck" + ); + ok(messageDeck, "expected message deck"); + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_DEFAULT, + "expected selected message in the deck to be the default message" + ); + + const hoverMenuButtonMessage = item.querySelector( + ".unified-extensions-item-message-hover-menu-button" + ); + Assert.deepEqual( + document.l10n.getAttributes(hoverMenuButtonMessage), + { id: "unified-extensions-item-message-manage", args: null }, + "expected correct l10n attributes for the hover message" + ); + + const menuButton = item.querySelector(".unified-extensions-item-menu-button"); + ok(menuButton, "expected menu button"); + + let hovered = BrowserTestUtils.waitForEvent(menuButton, "mouseover"); + EventUtils.synthesizeMouseAtCenter(menuButton, { type: "mouseover" }); + await hovered; + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_MENU_HOVER, + "expected selected message in the deck to be the message when hovering the menu button" + ); + + let notHovered = BrowserTestUtils.waitForEvent(menuButton, "mouseout"); + // Move mouse somewhere else... + EventUtils.synthesizeMouseAtCenter(item, { type: "mouseover" }); + await notHovered; + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to be the hover message" + ); + + // Open the context menu for the extension. + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id); + const doc = contextMenu.ownerDocument; + + const manageButton = contextMenu.querySelector( + ".unified-extensions-context-menu-manage-extension" + ); + ok(manageButton, "expected manage button"); + is(manageButton.hidden, false, "expected manage button to be visible"); + is(manageButton.disabled, false, "expected manage button to be enabled"); + Assert.deepEqual( + doc.l10n.getAttributes(manageButton), + { id: "unified-extensions-context-menu-manage-extension", args: null }, + "expected correct l10n attributes for manage button" + ); + + const removeButton = contextMenu.querySelector( + ".unified-extensions-context-menu-remove-extension" + ); + ok(removeButton, "expected remove button"); + is(removeButton.hidden, false, "expected remove button to be visible"); + is(removeButton.disabled, false, "expected remove button to be enabled"); + Assert.deepEqual( + doc.l10n.getAttributes(removeButton), + { id: "unified-extensions-context-menu-remove-extension", args: null }, + "expected correct l10n attributes for remove button" + ); + + const reportButton = contextMenu.querySelector( + ".unified-extensions-context-menu-report-extension" + ); + ok(reportButton, "expected report button"); + is(reportButton.hidden, false, "expected report button to be visible"); + is(reportButton.disabled, false, "expected report button to be enabled"); + Assert.deepEqual( + doc.l10n.getAttributes(reportButton), + { id: "unified-extensions-context-menu-report-extension", args: null }, + "expected correct l10n attributes for report button" + ); + + await closeChromeContextMenu(contextMenu.id, null); + await closeExtensionsPanel(); + + await extension.unload(); +}); + +add_task( + async function test_context_menu_report_button_hidden_when_abuse_report_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.abuseReport.enabled", false]], + }); + + const [extension] = createExtensions([{ name: "an extension" }]); + await extension.startup(); + + // Open the extension panel, then open the contextMenu for the extension. + await openExtensionsPanel(); + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id); + + const reportButton = contextMenu.querySelector( + ".unified-extensions-context-menu-report-extension" + ); + ok(reportButton, "expected report button"); + is(reportButton.hidden, true, "expected report button to be hidden"); + + await closeChromeContextMenu(contextMenu.id, null); + await closeExtensionsPanel(); + + await extension.unload(); + + await SpecialPowers.popPrefEnv(); + } +); + +add_task( + async function test_context_menu_remove_button_disabled_when_extension_cannot_be_uninstalled() { + const [extension] = createExtensions([{ name: "an extension" }]); + await extension.startup(); + + await EnterprisePolicyTesting.setupPolicyEngineWithJson({ + policies: { + Extensions: { + Locked: [extension.id], + }, + }, + }); + + // Open the extension panel, then open the context menu for the extension. + await openExtensionsPanel(); + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id); + + const removeButton = contextMenu.querySelector( + ".unified-extensions-context-menu-remove-extension" + ); + ok(removeButton, "expected remove button"); + is(removeButton.disabled, true, "expected remove button to be disabled"); + + await closeChromeContextMenu(contextMenu.id, null); + await closeExtensionsPanel(); + + await extension.unload(); + await EnterprisePolicyTesting.setupPolicyEngineWithJson(""); + } +); + +add_task(async function test_manage_extension() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:robots" }, + async () => { + const [extension] = createExtensions([{ name: "an extension" }]); + await extension.startup(); + + // Open the extension panel, then open the context menu for the extension. + await openExtensionsPanel(); + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id); + + const manageButton = contextMenu.querySelector( + ".unified-extensions-context-menu-manage-extension" + ); + ok(manageButton, "expected manage button"); + + // Click the "manage extension" context menu item, and wait until the menu is + // closed and about:addons is open. + const hidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + const aboutAddons = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:addons", + true + ); + contextMenu.activateItem(manageButton); + const [aboutAddonsTab] = await Promise.all([aboutAddons, hidden]); + + // Close the tab containing about:addons because we don't need it anymore. + BrowserTestUtils.removeTab(aboutAddonsTab); + + await extension.unload(); + } + ); +}); + +add_task(async function test_report_extension() { + function runReportTest(extension) { + return BrowserTestUtils.withNewTab({ gBrowser }, async () => { + // Open the extension panel, then open the context menu for the extension. + await openExtensionsPanel(); + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id); + + const reportButton = contextMenu.querySelector( + ".unified-extensions-context-menu-report-extension" + ); + ok(reportButton, "expected report button"); + + // Click the "report extension" context menu item, and wait until the menu is + // 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 abuseReportOpen = BrowserTestUtils.waitForCondition( + () => AbuseReporter.getOpenDialog(), + "wait for the abuse report dialog to have been opened" + ); + 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" + ); + is( + reportDialogParams.report.reportEntryPoint, + "unified_context_menu", + "abuse report dialog has the expected reportEntryPoint" + ); + + 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(); +}); + +add_task(async function test_remove_extension() { + const [extension] = createExtensions([{ name: "an extension" }]); + await extension.startup(); + + // Open the extension panel, then open the context menu for the extension. + await openExtensionsPanel(); + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id); + + const removeButton = contextMenu.querySelector( + ".unified-extensions-context-menu-remove-extension" + ); + ok(removeButton, "expected remove button"); + + // Set up a mock prompt service that returns 0 to indicate that the user + // pressed the OK button. + const { prompt } = Services; + const promptService = { + QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]), + confirmEx() { + return 0; + }, + }; + Services.prompt = promptService; + registerCleanupFunction(() => { + Services.prompt = prompt; + }); + + // Click the "remove extension" context menu item, and wait until the menu is + // closed and the extension is uninstalled. + const uninstalled = promiseExtensionUninstalled(extension.id); + const hidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + contextMenu.activateItem(removeButton); + await Promise.all([uninstalled, hidden]); + + await extension.unload(); + // Restore prompt service. + Services.prompt = prompt; +}); + +add_task(async function test_remove_extension_cancelled() { + const [extension] = createExtensions([{ name: "an extension" }]); + await extension.startup(); + + // Open the extension panel, then open the context menu for the extension. + await openExtensionsPanel(); + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id); + + const removeButton = contextMenu.querySelector( + ".unified-extensions-context-menu-remove-extension" + ); + ok(removeButton, "expected remove button"); + + // Set up a mock prompt service that returns 1 to indicate that the user + // refused to uninstall the extension. + const { prompt } = Services; + const promptService = { + QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]), + confirmEx() { + return 1; + }, + }; + Services.prompt = promptService; + registerCleanupFunction(() => { + Services.prompt = prompt; + }); + + // Click the "remove extension" context menu item, and wait until the menu is + // closed. + const hidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + contextMenu.activateItem(removeButton); + await hidden; + + // Re-open the panel to make sure the extension is still there. + await openExtensionsPanel(); + const item = getUnifiedExtensionsItem(extension.id); + is( + item.querySelector(".unified-extensions-item-name").textContent, + "an extension", + "expected extension to still be listed" + ); + await closeExtensionsPanel(); + + await extension.unload(); + // Restore prompt service. + Services.prompt = prompt; +}); + +add_task(async function test_open_context_menu_on_click() { + const [extension] = createExtensions([{ name: "an extension" }]); + await extension.startup(); + + // Open the extension panel. + await openExtensionsPanel(); + + const button = getUnifiedExtensionsItem(extension.id).querySelector( + ".unified-extensions-item-menu-button" + ); + ok(button, "expected menu button"); + + const contextMenu = document.getElementById( + "unified-extensions-context-menu" + ); + ok(contextMenu, "expected menu"); + + // Open the context menu with a "right-click". + const shown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(button, { type: "contextmenu" }); + await shown; + + await closeChromeContextMenu(contextMenu.id, null); + await closeExtensionsPanel(); + + await extension.unload(); +}); + +add_task(async function test_open_context_menu_with_keyboard() { + const [extension] = createExtensions([{ name: "an extension" }]); + await extension.startup(); + + // Open the extension panel. + await openExtensionsPanel(); + + const button = getUnifiedExtensionsItem(extension.id).querySelector( + ".unified-extensions-item-menu-button" + ); + ok(button, "expected menu button"); + // Make this button focusable because those (toolbar) buttons are only made + // focusable when a user is navigating with the keyboard, which isn't exactly + // what we are doing in this test. + button.setAttribute("tabindex", "-1"); + + const contextMenu = document.getElementById( + "unified-extensions-context-menu" + ); + ok(contextMenu, "expected menu"); + + // Open the context menu by focusing the button and pressing the SPACE key. + let shown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + button.focus(); + is(button, document.activeElement, "expected button to be focused"); + EventUtils.synthesizeKey(" ", {}); + await shown; + + await closeChromeContextMenu(contextMenu.id, null); + + if (AppConstants.platform != "macosx") { + // Open the context menu by focusing the button and pressing the ENTER key. + // TODO(emilio): Maybe we should harmonize this behavior across platforms, + // we're inconsistent right now. + shown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + button.focus(); + is(button, document.activeElement, "expected button to be focused"); + EventUtils.synthesizeKey("KEY_Enter", {}); + await shown; + await closeChromeContextMenu(contextMenu.id, null); + } + + await closeExtensionsPanel(); + + await extension.unload(); +}); + +add_task(async function test_context_menu_without_browserActionFor_global() { + const { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" + ); + const { browserActionFor } = ExtensionParent.apiManager.global; + const cleanup = () => { + ExtensionParent.apiManager.global.browserActionFor = browserActionFor; + }; + registerCleanupFunction(cleanup); + // This is needed to simulate the case where the browserAction API hasn't + // been loaded yet (since it is lazy-loaded). That could happen when only + // extensions without browser actions are installed. In which case, the + // `global.browserActionFor()` function would not be defined yet. + delete ExtensionParent.apiManager.global.browserActionFor; + + const [extension] = createExtensions([{ name: "an extension" }]); + await extension.startup(); + + // Open the extension panel and then the context menu for the extension that + // has been loaded above. We expect the context menu to be displayed and no + // error caused by the lack of `global.browserActionFor()`. + await openExtensionsPanel(); + // This promise rejects with an error if the implementation does not handle + // the case where `global.browserActionFor()` is undefined. + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id); + assertVisibleContextMenuItems(contextMenu, 3); + + await closeChromeContextMenu(contextMenu.id, null); + await closeExtensionsPanel(); + + await extension.unload(); + + cleanup(); +}); + +add_task(async function test_page_action_context_menu() { + const extWithMenuPageAction = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: {}, + permissions: ["contextMenus"], + }, + background() { + browser.contextMenus.create( + { + id: "some-menu-id", + title: "Click me!", + contexts: ["all"], + }, + () => browser.test.sendMessage("menu-created") + ); + }, + useAddonManager: "temporary", + }); + const extWithoutMenu1 = ExtensionTestUtils.loadExtension({ + manifest: { + name: "extension without any menu", + }, + useAddonManager: "temporary", + }); + + const extensions = [extWithMenuPageAction, extWithoutMenu1]; + + await Promise.all(extensions.map(extension => extension.startup())); + + await extWithMenuPageAction.awaitMessage("menu-created"); + + await openExtensionsPanel(); + + info("extension with page action and a menu"); + // This extension declares a page action so its menu shouldn't be added to + // the unified extensions context menu. + let contextMenu = await openUnifiedExtensionsContextMenu( + extWithMenuPageAction.id + ); + assertVisibleContextMenuItems(contextMenu, 3); + await closeChromeContextMenu(contextMenu.id, null); + + info("extension with no browser action and no menu"); + // There is no context menu created by this extension, so there should only + // be 3 menu items corresponding to the default manage/remove/report items. + contextMenu = await openUnifiedExtensionsContextMenu(extWithoutMenu1.id); + assertVisibleContextMenuItems(contextMenu, 3); + await closeChromeContextMenu(contextMenu.id, null); + + await closeExtensionsPanel(); + + await Promise.all(extensions.map(extension => extension.unload())); +}); + +add_task(async function test_pin_to_toolbar() { + const [extension] = createExtensions([ + { name: "an extension", browser_action: {} }, + ]); + await extension.startup(); + + // Open the extension panel, then open the context menu for the extension and + // pin the extension to the toolbar. + await openExtensionsPanel(); + await pinToToolbar(extension); + + // Undo the 'pin to toolbar' action. + await CustomizableUI.reset(); + await extension.unload(); +}); + +add_task(async function test_contextmenu_command_closes_panel() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + name: "an extension", + browser_action: {}, + permissions: ["contextMenus"], + }, + background() { + browser.contextMenus.create( + { + id: "some-menu-id", + title: "Click me!", + contexts: ["all"], + }, + () => browser.test.sendMessage("menu-created") + ); + }, + useAddonManager: "temporary", + }); + await extension.startup(); + await extension.awaitMessage("menu-created"); + + await openExtensionsPanel(); + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id); + + const firstMenuItem = contextMenu.querySelector("menuitem"); + is( + firstMenuItem?.getAttribute("label"), + "Click me!", + "expected custom menu item as first child" + ); + + const hidden = BrowserTestUtils.waitForEvent( + gUnifiedExtensions.panel, + "popuphidden", + true + ); + contextMenu.activateItem(firstMenuItem); + await hidden; + + await extension.unload(); +}); + +add_task(async function test_contextmenu_reorder_extensions() { + const [ext1, ext2, ext3] = createExtensions([ + { name: "ext1", browser_action: {} }, + { name: "ext2", browser_action: {} }, + { name: "ext3", browser_action: {} }, + ]); + // Start the test extensions in sequence to reduce chance of + // intermittent failures when asserting the order of the + // entries in the panel in the rest of this test task. + await ext1.startup(); + await ext2.startup(); + await ext3.startup(); + + await openExtensionsPanel(); + + // First extension in the list should only have "Move Down". + await assertMoveContextMenuItems(ext1, { + expectMoveUpHidden: true, + expectMoveDownHidden: false, + }); + + // Second extension in the list should have "Move Up" and "Move Down". + await assertMoveContextMenuItems(ext2, { + expectMoveUpHidden: false, + expectMoveDownHidden: false, + }); + + // Third extension in the list should only have "Move Up". + await assertMoveContextMenuItems(ext3, { + expectMoveUpHidden: false, + expectMoveDownHidden: true, + expectOrder: [ext1, ext2, ext3], + }); + + // Let's move some extensions now. We'll start by moving ext1 down until it + // is positioned at the end of the list. + info("Move down ext1 action to the bottom of the list"); + await moveWidgetDown(ext1); + assertOrderOfWidgetsInPanel([ext2, ext1, ext3]); + await moveWidgetDown(ext1); + + // Verify that the extension 1 has the right context menu items now that it + // is located at the end of the list. + await assertMoveContextMenuItems(ext1, { + expectMoveUpHidden: false, + expectMoveDownHidden: true, + expectOrder: [ext2, ext3, ext1], + }); + + info("Move up ext1 action to the top of the list"); + await moveWidgetUp(ext1); + assertOrderOfWidgetsInPanel([ext2, ext1, ext3]); + + await moveWidgetUp(ext1); + assertOrderOfWidgetsInPanel([ext1, ext2, ext3]); + + // Move the last extension up. + info("Move up ext3 action"); + await moveWidgetUp(ext3); + assertOrderOfWidgetsInPanel([ext1, ext3, ext2]); + + // Move the last extension up (again). + info("Move up ext2 action to the top of the list"); + await moveWidgetUp(ext2); + assertOrderOfWidgetsInPanel([ext1, ext2, ext3]); + + // Move the second extension up. + await moveWidgetUp(ext2); + assertOrderOfWidgetsInPanel([ext2, ext1, ext3]); + + // Pin an extension to the toolbar, which should remove it from the panel. + info("Pin ext1 action to the toolbar"); + await pinToToolbar(ext1); + await openExtensionsPanel(); + assertOrderOfWidgetsInPanel([ext2, ext3]); + await closeExtensionsPanel(); + + await Promise.all([ext1.unload(), ext2.unload(), ext3.unload()]); + await CustomizableUI.reset(); +}); + +add_task(async function test_contextmenu_only_one_widget() { + const [extension] = createExtensions([{ name: "ext1", browser_action: {} }]); + await extension.startup(); + + await openExtensionsPanel(); + await assertMoveContextMenuItems(extension, { + expectMoveUpHidden: true, + expectMoveDownHidden: true, + }); + await closeExtensionsPanel(); + + await extension.unload(); + await CustomizableUI.reset(); +}); + +add_task( + async function test_contextmenu_reorder_extensions_with_private_window() { + // We want a panel in private mode that looks like this one (ext2 is not + // allowed in PB mode): + // + // - ext1 + // - ext3 + // + // But if we ask CUI to list the widgets in the panel, it would list: + // + // - ext1 + // - ext2 + // - ext3 + // + const ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + name: "ext1", + browser_specific_settings: { gecko: { id: "ext1@reorder-private" } }, + browser_action: {}, + }, + useAddonManager: "temporary", + incognitoOverride: "spanning", + }); + await ext1.startup(); + + const ext2 = ExtensionTestUtils.loadExtension({ + manifest: { + name: "ext2", + browser_specific_settings: { gecko: { id: "ext2@reorder-private" } }, + browser_action: {}, + }, + useAddonManager: "temporary", + incognitoOverride: "not_allowed", + }); + await ext2.startup(); + + const ext3 = ExtensionTestUtils.loadExtension({ + manifest: { + name: "ext3", + browser_specific_settings: { gecko: { id: "ext3@reorder-private" } }, + browser_action: {}, + }, + useAddonManager: "temporary", + incognitoOverride: "spanning", + }); + await ext3.startup(); + + // Make sure all extension widgets are in the correct order. + assertOrderOfWidgetsInPanel([ext1, ext2, ext3]); + + const privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + await openExtensionsPanel(privateWin); + + // First extension in the list should only have "Move Down". + await assertMoveContextMenuItems( + ext1, + { + expectMoveUpHidden: true, + expectMoveDownHidden: false, + expectOrder: [ext1, ext3], + }, + privateWin + ); + + // Second extension in the list (which is ext3) should only have "Move Up". + await assertMoveContextMenuItems( + ext3, + { + expectMoveUpHidden: false, + expectMoveDownHidden: true, + expectOrder: [ext1, ext3], + }, + privateWin + ); + + // In private mode, we should only have two CUI widget nodes in the panel. + assertOrderOfWidgetsInPanel([ext1, ext3], privateWin); + + info("Move ext1 down"); + await moveWidgetDown(ext1, privateWin); + // The new order in a regular window should be: + assertOrderOfWidgetsInPanel([ext2, ext3, ext1]); + // ... while the order in the private window should be: + assertOrderOfWidgetsInPanel([ext3, ext1], privateWin); + + // Verify that the extension 1 has the right context menu items now that it + // is located at the end of the list in PB mode. + await assertMoveContextMenuItems( + ext1, + { + expectMoveUpHidden: false, + expectMoveDownHidden: true, + expectOrder: [ext3, ext1], + }, + privateWin + ); + + // Verify that the extension 3 has the right context menu items now that it + // is located at the top of the list in PB mode. + await assertMoveContextMenuItems( + ext3, + { + expectMoveUpHidden: true, + expectMoveDownHidden: false, + expectOrder: [ext3, ext1], + }, + privateWin + ); + + info("Move ext3 extension down"); + await moveWidgetDown(ext3, privateWin); + // The new order in a regular window should be: + assertOrderOfWidgetsInPanel([ext2, ext1, ext3]); + // ... while the order in the private window should be: + assertOrderOfWidgetsInPanel([ext1, ext3], privateWin); + + // Pin an extension to the toolbar, which should remove it from the panel. + info("Pin ext1 to the toolbar"); + await pinToToolbar(ext1, privateWin); + await openExtensionsPanel(privateWin); + + // The new order in a regular window should be: + assertOrderOfWidgetsInPanel([ext2, ext3]); + await assertMoveContextMenuItems( + ext3, + { + expectMoveUpHidden: true, + expectMoveDownHidden: true, + // ... while the order in the private window should be: + expectOrder: [ext3], + }, + privateWin + ); + + await closeExtensionsPanel(privateWin); + + await Promise.all([ext1.unload(), ext2.unload(), ext3.unload()]); + await CustomizableUI.reset(); + + await BrowserTestUtils.closeWindow(privateWin); + } +); diff --git a/browser/components/extensions/test/browser/browser_unified_extensions_cui.js b/browser/components/extensions/test/browser/browser_unified_extensions_cui.js new file mode 100644 index 0000000000..dc02623452 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_unified_extensions_cui.js @@ -0,0 +1,159 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +loadTestSubscript("head_unified_extensions.js"); + +/** + * Tests that if the addons panel is somehow open when customization mode is + * invoked, that the panel is hidden. + */ +add_task(async function test_hide_panel_when_customizing() { + await openExtensionsPanel(); + + let panel = gUnifiedExtensions.panel; + Assert.equal(panel.state, "open"); + + let panelHidden = BrowserTestUtils.waitForPopupEvent(panel, "hidden"); + CustomizableUI.dispatchToolboxEvent("customizationstarting", {}); + await panelHidden; + Assert.equal(panel.state, "closed"); + CustomizableUI.dispatchToolboxEvent("aftercustomization", {}); +}); + +/** + * Tests that if a browser action is in a collapsed toolbar area, like the + * bookmarks toolbar, that its DOM node is overflowed in the extensions panel. + */ +add_task(async function test_extension_in_collapsed_area() { + const extensions = createExtensions( + [ + { + name: "extension1", + browser_action: { default_area: "navbar", default_popup: "popup.html" }, + browser_specific_settings: { + gecko: { id: "unified-extensions-cui@ext-1" }, + }, + }, + { + name: "extension2", + browser_action: { default_area: "navbar" }, + browser_specific_settings: { + gecko: { id: "unified-extensions-cui@ext-2" }, + }, + }, + ], + { + files: { + "popup.html": ` + + +

    test popup

    + + + + `, + "popup.js": function () { + browser.test.sendMessage("test-popup-opened"); + }, + }, + } + ); + await Promise.all(extensions.map(extension => extension.startup())); + + await openExtensionsPanel(); + for (const extension of extensions) { + let item = getUnifiedExtensionsItem(extension.id); + Assert.ok( + !item, + `extension with ID=${extension.id} should not appear in the panel` + ); + } + await closeExtensionsPanel(); + + // Move an extension to the bookmarks toolbar. + const bookmarksToolbar = document.getElementById( + CustomizableUI.AREA_BOOKMARKS + ); + const firstExtensionWidgetID = AppUiTestInternals.getBrowserActionWidgetId( + extensions[0].id + ); + CustomizableUI.addWidgetToArea( + firstExtensionWidgetID, + CustomizableUI.AREA_BOOKMARKS + ); + + // Ensure that the toolbar is currently collapsed. + await promiseSetToolbarVisibility(bookmarksToolbar, false); + + await openExtensionsPanel(); + let item = getUnifiedExtensionsItem(extensions[0].id); + Assert.ok( + item, + "extension placed in the collapsed toolbar should appear in the panel" + ); + + // NOTE: ideally we would simply call `AppUiTestDelegate.clickBrowserAction()` + // but, unfortunately, that does internally call `showBrowserAction()`, which + // explicitly assert the group areaType that would hit a failure in this test + // because we are moving it to AREA_BOOKMARKS. + let widget = getBrowserActionWidget(extensions[0]).forWindow(window); + ok(widget, "Got a widget for the extension button overflowed into the panel"); + widget.node.firstElementChild.click(); + + const promisePanelBrowser = AppUiTestDelegate.awaitExtensionPanel( + window, + extensions[0].id, + true + ); + await extensions[0].awaitMessage("test-popup-opened"); + const extPanelBrowser = await promisePanelBrowser; + ok(extPanelBrowser, "Got a action panel browser"); + closeBrowserAction(extensions[0]); + + // Now, make the toolbar visible. + await promiseSetToolbarVisibility(bookmarksToolbar, true); + + await openExtensionsPanel(); + for (const extension of extensions) { + let item = getUnifiedExtensionsItem(extension.id); + Assert.ok( + !item, + `extension with ID=${extension.id} should not appear in the panel` + ); + } + await closeExtensionsPanel(); + + // Hide the bookmarks toolbar again. + await promiseSetToolbarVisibility(bookmarksToolbar, false); + + await openExtensionsPanel(); + item = getUnifiedExtensionsItem(extensions[0].id); + Assert.ok(item, "extension should reappear in the panel"); + await closeExtensionsPanel(); + + // We now empty the bookmarks toolbar but we keep the extension widget. + for (const widgetId of CustomizableUI.getWidgetIdsInArea( + CustomizableUI.AREA_BOOKMARKS + ).filter(widgetId => widgetId !== firstExtensionWidgetID)) { + CustomizableUI.removeWidgetFromArea(widgetId); + } + + // We make the bookmarks toolbar visible again. At this point, the extension + // widget should be re-inserted in this toolbar. + await promiseSetToolbarVisibility(bookmarksToolbar, true); + + await openExtensionsPanel(); + for (const extension of extensions) { + let item = getUnifiedExtensionsItem(extension.id); + Assert.ok( + !item, + `extension with ID=${extension.id} should not appear in the panel` + ); + } + await closeExtensionsPanel(); + + await Promise.all(extensions.map(extension => extension.unload())); + await CustomizableUI.reset(); +}); diff --git a/browser/components/extensions/test/browser/browser_unified_extensions_doorhangers.js b/browser/components/extensions/test/browser/browser_unified_extensions_doorhangers.js new file mode 100644 index 0000000000..8603928894 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_unified_extensions_doorhangers.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +loadTestSubscript("head_unified_extensions.js"); + +const verifyPermissionsPrompt = async expectedAnchorID => { + const ext = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "some search name", + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + optional_permissions: ["history"], + }, + + background: () => { + browser.test.onMessage.addListener(async msg => { + if (msg !== "create-tab") { + return; + } + + await browser.tabs.create({ + url: browser.runtime.getURL("content.html"), + active: true, + }); + }); + }, + + files: { + "content.html": ``, + "content.js": async () => { + browser.test.onMessage.addListener(async msg => { + browser.test.assertEq( + msg, + "grant-permission", + "expected message to grant permission" + ); + + const granted = await new Promise(resolve => { + browser.test.withHandlingUserInput(() => { + resolve( + browser.permissions.request({ permissions: ["history"] }) + ); + }); + }); + browser.test.assertTrue(granted, "permission request succeeded"); + + browser.test.sendMessage("ok"); + }); + + browser.test.sendMessage("ready"); + }, + }, + }); + + await BrowserTestUtils.withNewTab({ gBrowser }, async () => { + const defaultSearchPopupPromise = promisePopupNotificationShown( + "addon-webext-defaultsearch" + ); + let [panel] = await Promise.all([defaultSearchPopupPromise, ext.startup()]); + ok(panel, "expected panel"); + let notification = PopupNotifications.getNotification( + "addon-webext-defaultsearch" + ); + ok(notification, "expected notification"); + // We always want the defaultsearch popup to be anchored on the urlbar (the + // ID below) because the post-install popup would be displayed on top of + // this one otherwise, see Bug 1789407. + is( + notification?.anchorElement?.id, + "addons-notification-icon", + "expected the right anchor ID for the defaultsearch popup" + ); + // Accept to override the search. + panel.button.click(); + await TestUtils.topicObserved("webextension-defaultsearch-prompt-response"); + + ext.sendMessage("create-tab"); + await ext.awaitMessage("ready"); + + const popupPromise = promisePopupNotificationShown( + "addon-webext-permissions" + ); + ext.sendMessage("grant-permission"); + panel = await popupPromise; + ok(panel, "expected panel"); + notification = PopupNotifications.getNotification( + "addon-webext-permissions" + ); + ok(notification, "expected notification"); + is( + // We access the parent element because the anchor is on the icon (inside + // the button), not on the unified extensions button itself. + notification.anchorElement.id || + notification.anchorElement.parentElement.id, + expectedAnchorID, + "expected the right anchor ID" + ); + + panel.button.click(); + await ext.awaitMessage("ok"); + + await ext.unload(); + }); +}; + +add_task(async function test_permissions_prompt() { + await verifyPermissionsPrompt("unified-extensions-button"); +}); diff --git a/browser/components/extensions/test/browser/browser_unified_extensions_messages.js b/browser/components/extensions/test/browser/browser_unified_extensions_messages.js new file mode 100644 index 0000000000..10ff3c9c73 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_unified_extensions_messages.js @@ -0,0 +1,222 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +loadTestSubscript("head_unified_extensions.js"); + +const verifyMessageBar = message => { + Assert.equal( + message.getAttribute("type"), + "warning", + "expected warning message" + ); + Assert.ok( + !message.hasAttribute("dismissable"), + "expected message to not be dismissable" + ); + + const supportLink = message.querySelector("a"); + Assert.equal( + supportLink.getAttribute("support-page"), + "quarantined-domains", + "expected the correct support page ID" + ); + Assert.equal( + supportLink.getAttribute("aria-label"), + "Learn more: Some extensions are not allowed", + "expected the correct aria-labelledby value" + ); +}; + +add_task(async function test_quarantined_domain_message_disabled() { + const quarantinedDomain = "example.org"; + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.quarantinedDomains.enabled", false], + ["extensions.quarantinedDomains.list", quarantinedDomain], + ], + }); + + // Load an extension that will have access to all domains, including the + // quarantined domain. + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["activeTab"], + browser_action: {}, + }, + }); + await extension.startup(); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: `https://${quarantinedDomain}/` }, + async () => { + await openExtensionsPanel(); + Assert.equal(getMessageBars().length, 0, "expected no message"); + await closeExtensionsPanel(); + } + ); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_quarantined_domain_message() { + const quarantinedDomain = "example.org"; + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.quarantinedDomains.enabled", true], + ["extensions.quarantinedDomains.list", quarantinedDomain], + ], + }); + + // Load an extension that will have access to all domains, including the + // quarantined domain. + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["activeTab"], + browser_action: {}, + }, + }); + await extension.startup(); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: `https://${quarantinedDomain}/` }, + async () => { + await openExtensionsPanel(); + + const messages = getMessageBars(); + Assert.equal(messages.length, 1, "expected a message"); + + const [message] = messages; + verifyMessageBar(message); + + await closeExtensionsPanel(); + } + ); + + // Navigating to a different tab/domain shouldn't show any message. + await BrowserTestUtils.withNewTab( + { gBrowser, url: `http://mochi.test:8888/` }, + async () => { + await openExtensionsPanel(); + Assert.equal(getMessageBars().length, 0, "expected no message"); + await closeExtensionsPanel(); + } + ); + + // Back to a quarantined domain, if we update the list, we expect the message + // to be gone when we re-open the panel (and not before because we don't + // listen to the pref currently). + await BrowserTestUtils.withNewTab( + { gBrowser, url: `https://${quarantinedDomain}/` }, + async () => { + await openExtensionsPanel(); + + const messages = getMessageBars(); + Assert.equal(messages.length, 1, "expected a message"); + + const [message] = messages; + verifyMessageBar(message); + + await closeExtensionsPanel(); + + // Clear the list of quarantined domains. + Services.prefs.setStringPref("extensions.quarantinedDomains.list", ""); + + await openExtensionsPanel(); + Assert.equal(getMessageBars().length, 0, "expected no message"); + await closeExtensionsPanel(); + } + ); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_quarantined_domain_message_learn_more_link() { + const quarantinedDomain = "example.org"; + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.quarantinedDomains.enabled", true], + ["extensions.quarantinedDomains.list", quarantinedDomain], + ], + }); + + // Load an extension that will have access to all domains, including the + // quarantined domain. + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["activeTab"], + browser_action: {}, + }, + }); + await extension.startup(); + + const expectedSupportURL = + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "quarantined-domains"; + + // We expect the SUMO page to be open in a new tab and the panel to be closed + // when the user clicks on the "learn more" link. + await BrowserTestUtils.withNewTab( + { gBrowser, url: `https://${quarantinedDomain}/` }, + async () => { + await openExtensionsPanel(); + const messages = getMessageBars(); + Assert.equal(messages.length, 1, "expected a message"); + + const [message] = messages; + verifyMessageBar(message); + + const tabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + expectedSupportURL + ); + const hidden = BrowserTestUtils.waitForEvent( + gUnifiedExtensions.panel, + "popuphidden", + true + ); + message.querySelector("a").click(); + const [tab] = await Promise.all([tabPromise, hidden]); + BrowserTestUtils.removeTab(tab); + } + ); + + // Same as above but with keyboard navigation. + await BrowserTestUtils.withNewTab( + { gBrowser, url: `https://${quarantinedDomain}/` }, + async () => { + await openExtensionsPanel(); + const messages = getMessageBars(); + Assert.equal(messages.length, 1, "expected a message"); + + const [message] = messages; + verifyMessageBar(message); + + const supportLink = message.querySelector("a"); + + // Focus the "learn more" (support) link. + const focused = BrowserTestUtils.waitForEvent(supportLink, "focus"); + EventUtils.synthesizeKey("VK_TAB", {}); + await focused; + + const tabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + expectedSupportURL + ); + const hidden = BrowserTestUtils.waitForEvent( + gUnifiedExtensions.panel, + "popuphidden", + true + ); + EventUtils.synthesizeKey("KEY_Enter", {}); + const [tab] = await Promise.all([tabPromise, hidden]); + BrowserTestUtils.removeTab(tab); + } + ); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_unified_extensions_overflowable_toolbar.js b/browser/components/extensions/test/browser/browser_unified_extensions_overflowable_toolbar.js new file mode 100644 index 0000000000..9758a96636 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_unified_extensions_overflowable_toolbar.js @@ -0,0 +1,1389 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests the behaviour of the overflowable nav-bar with Unified + * Extensions enabled and disabled. + */ + +"use strict"; + +loadTestSubscript("head_unified_extensions.js"); + +requestLongerTimeout(2); + +const NUM_EXTENSIONS = 5; +const OVERFLOW_WINDOW_WIDTH_PX = 450; +const DEFAULT_WIDGET_IDS = [ + "home-button", + "library-button", + "zoom-controls", + "search-container", + "sidebar-button", +]; +const OVERFLOWED_EXTENSIONS_LIST_ID = "overflowed-extensions-list"; + +add_setup(async function () { + // To make it easier to control things that will overflow, we'll start by + // removing that's removable out of the nav-bar and adding just a fixed + // set of items (DEFAULT_WIDGET_IDS) at the end of the nav-bar. + let existingWidgetIDs = CustomizableUI.getWidgetIdsInArea( + CustomizableUI.AREA_NAVBAR + ); + for (let widgetID of existingWidgetIDs) { + if (CustomizableUI.isWidgetRemovable(widgetID)) { + CustomizableUI.removeWidgetFromArea(widgetID); + } + } + for (const widgetID of DEFAULT_WIDGET_IDS) { + CustomizableUI.addWidgetToArea(widgetID, CustomizableUI.AREA_NAVBAR); + } + + registerCleanupFunction(async () => { + await CustomizableUI.reset(); + }); +}); + +/** + * Returns the IDs of the children of parent. + * + * @param {Element} parent + * @returns {string[]} the IDs of the children + */ +function getChildrenIDs(parent) { + return Array.from(parent.children).map(child => child.id); +} + +/** + * Returns a NodeList of all non-hidden menu, menuitem and menuseparators + * that are direct descendants of popup. + * + * @param {Element} popup + * @returns {NodeList} the visible items. + */ +function getVisibleMenuItems(popup) { + return popup.querySelectorAll( + ":scope > :is(menu, menuitem, menuseparator):not([hidden])" + ); +} + +/** + * This helper function does most of the heavy lifting for these tests. + * It does the following in order: + * + * 1. Registers and enables NUM_EXTENSIONS test WebExtensions that add + * browser_action buttons to the nav-bar. + * 2. Resizes the window to force things after the URL bar to overflow. + * 3. Calls an async test function to analyze the overflow lists. + * 4. Restores the window's original width, ensuring that the IDs of the + * nav-bar match the original set. + * 5. Unloads all of the test WebExtensions + * + * @param {DOMWindow} win The browser window to perform the test on. + * @param {object} options Additional options when running this test. + * @param {Function} options.beforeOverflowed This optional async function will + * be run after the extensions are created and added to the toolbar, but + * before the toolbar overflows. The function is called with the following + * arguments: + * + * {string[]} extensionIDs: The IDs of the test WebExtensions. + * + * The return value of the function is ignored. + * @param {Function} options.whenOverflowed This optional async function will + * run once the window is in the overflow state. The function is called + * with the following arguments: + * + * {Element} defaultList: The DOM element that holds overflowed default + * items. + * {Element} unifiedExtensionList: The DOM element that holds overflowed + * WebExtension browser_actions when Unified Extensions is enabled. + * {string[]} extensionIDs: The IDs of the test WebExtensions. + * + * The return value of the function is ignored. + * @param {Function} options.afterUnderflowed This optional async function will + * be run after the window is expanded and the toolbar has underflowed, but + * before the extensions are removed. This function is not passed any + * arguments. The return value of the function is ignored. + * + */ +async function withWindowOverflowed( + win, + { + beforeOverflowed = async () => {}, + whenOverflowed = async () => {}, + afterUnderflowed = async () => {}, + } = {} +) { + const doc = win.document; + doc.documentElement.removeAttribute("persist"); + const navbar = doc.getElementById(CustomizableUI.AREA_NAVBAR); + + await ensureMaximizedWindow(win); + + // The OverflowableToolbar operates asynchronously at times, so we will + // poll a widget's overflowedItem attribute to detect whether or not the + // widgets have finished being moved. We'll use the first widget that + // we added to the nav-bar, as this should be the left-most item in the + // set that we added. + const signpostWidgetID = "home-button"; + // We'll also force the signpost widget to be extra-wide to ensure that it + // overflows after we shrink the window. + CustomizableUI.getWidget(signpostWidgetID).forWindow(win).node.style = + "width: 150px"; + + const extWithMenuBrowserAction = ExtensionTestUtils.loadExtension({ + manifest: { + name: "Extension #0", + browser_specific_settings: { + gecko: { id: "unified-extensions-overflowable-toolbar@ext-0" }, + }, + browser_action: { + default_area: "navbar", + }, + // We pass `activeTab` to have a different permission message when + // hovering the primary/action button. + permissions: ["activeTab", "contextMenus"], + }, + background() { + browser.contextMenus.create( + { + id: "some-menu-id", + title: "Click me!", + contexts: ["all"], + }, + () => browser.test.sendMessage("menu-created") + ); + }, + useAddonManager: "temporary", + }); + + const extWithSubMenuBrowserAction = ExtensionTestUtils.loadExtension({ + manifest: { + name: "Extension #1", + browser_specific_settings: { + gecko: { id: "unified-extensions-overflowable-toolbar@ext-1" }, + }, + browser_action: { + default_area: "navbar", + }, + permissions: ["contextMenus"], + }, + background() { + browser.contextMenus.create({ + id: "some-menu-id", + title: "Open sub-menu", + contexts: ["all"], + }); + browser.contextMenus.create( + { + id: "some-sub-menu-id", + parentId: "some-menu-id", + title: "Click me!", + contexts: ["all"], + }, + () => browser.test.sendMessage("menu-created") + ); + }, + useAddonManager: "temporary", + }); + + const manifests = []; + for (let i = 2; i < NUM_EXTENSIONS; ++i) { + manifests.push({ + name: `Extension #${i}`, + browser_action: { + default_area: "navbar", + }, + browser_specific_settings: { + gecko: { id: `unified-extensions-overflowable-toolbar@ext-${i}` }, + }, + }); + } + + const extensions = [ + extWithMenuBrowserAction, + extWithSubMenuBrowserAction, + ...createExtensions(manifests), + ]; + + // Adding browser actions is asynchronous, so this CustomizableUI listener + // is used to make sure that the browser action widgets have finished getting + // added. + let listener = { + _remainingBrowserActions: NUM_EXTENSIONS, + _deferred: Promise.withResolvers(), + + get promise() { + return this._deferred.promise; + }, + + onWidgetAdded(widgetID, area) { + if (widgetID.endsWith("-browser-action")) { + this._remainingBrowserActions--; + } + if (!this._remainingBrowserActions) { + this._deferred.resolve(); + } + }, + }; + CustomizableUI.addListener(listener); + // Start all the extensions sequentially. + for (const extension of extensions) { + await extension.startup(); + } + await Promise.all([ + extWithMenuBrowserAction.awaitMessage("menu-created"), + extWithSubMenuBrowserAction.awaitMessage("menu-created"), + ]); + await listener.promise; + CustomizableUI.removeListener(listener); + + const extensionIDs = extensions.map(extension => extension.id); + + try { + info("Running beforeOverflowed task"); + await beforeOverflowed(extensionIDs); + } finally { + // The beforeOverflowed task may have moved some items out from the navbar, + // so only listen for overflows for items still in there. + const browserActionIDs = extensionIDs.map(id => + AppUiTestInternals.getBrowserActionWidgetId(id) + ); + const browserActionsInNavBar = browserActionIDs.filter(widgetID => { + let placement = CustomizableUI.getPlacementOfWidget(widgetID); + return placement.area == CustomizableUI.AREA_NAVBAR; + }); + + let widgetOverflowListener = { + _remainingOverflowables: + browserActionsInNavBar.length + DEFAULT_WIDGET_IDS.length, + _deferred: Promise.withResolvers(), + + get promise() { + return this._deferred.promise; + }, + + onWidgetOverflow(widgetNode, areaNode) { + this._remainingOverflowables--; + if (!this._remainingOverflowables) { + this._deferred.resolve(); + } + }, + }; + CustomizableUI.addListener(widgetOverflowListener); + + win.resizeTo(OVERFLOW_WINDOW_WIDTH_PX, win.outerHeight); + await widgetOverflowListener.promise; + CustomizableUI.removeListener(widgetOverflowListener); + + Assert.ok( + navbar.hasAttribute("overflowing"), + "Should have an overflowing toolbar." + ); + + const defaultList = doc.getElementById( + navbar.getAttribute("default-overflowtarget") + ); + + const unifiedExtensionList = doc.getElementById( + navbar.getAttribute("addon-webext-overflowtarget") + ); + + try { + info("Running whenOverflowed task"); + await whenOverflowed(defaultList, unifiedExtensionList, extensionIDs); + } finally { + await ensureMaximizedWindow(win); + + // Notably, we don't wait for the nav-bar to not have the "overflowing" + // attribute. This is because we might be running in an environment + // where the nav-bar was overflowing to begin with. Let's just hope that + // our sign-post widget has stopped overflowing. + await TestUtils.waitForCondition(() => { + return !doc + .getElementById(signpostWidgetID) + .hasAttribute("overflowedItem"); + }); + + try { + info("Running afterUnderflowed task"); + await afterUnderflowed(); + } finally { + await Promise.all(extensions.map(extension => extension.unload())); + } + } + } +} + +async function verifyExtensionWidget(widget, win = window) { + Assert.ok(widget, "expected widget"); + + let actionButton = widget.querySelector( + ".unified-extensions-item-action-button" + ); + Assert.ok( + actionButton.classList.contains("unified-extensions-item-action-button"), + "expected action class on the button" + ); + ok( + actionButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button in the panel" + ); + + let menuButton = widget.lastElementChild; + Assert.ok( + menuButton.classList.contains("unified-extensions-item-menu-button"), + "expected class on the button" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button in the panel" + ); + + let contents = actionButton.querySelector( + ".unified-extensions-item-contents" + ); + + Assert.ok(contents, "expected contents element"); + // This is needed to correctly position the contents (vbox) element in the + // toolbarbutton. + Assert.equal( + contents.getAttribute("move-after-stack"), + "true", + "expected move-after-stack attribute to be set" + ); + // Make sure the contents element is inserted after the stack one (which is + // automagically created by the toolbarbutton element). + Assert.deepEqual( + Array.from(actionButton.childNodes.values()).map( + child => child.classList[0] + ), + [ + // The stack (which contains the extension icon) should be the first + // child. + "toolbarbutton-badge-stack", + // This is the widget label, which is hidden with CSS. + "toolbarbutton-text", + // This is the contents element, which displays the extension name and + // messages. + "unified-extensions-item-contents", + ], + "expected the correct order for the children of the action button" + ); + + let name = contents.querySelector(".unified-extensions-item-name"); + Assert.ok(name, "expected name element"); + Assert.ok( + name.textContent.startsWith("Extension "), + "expected name to not be empty" + ); + Assert.ok( + contents.querySelector(".unified-extensions-item-message-default"), + "expected message default element" + ); + Assert.ok( + contents.querySelector(".unified-extensions-item-message-hover"), + "expected message hover element" + ); + + Assert.equal( + win.document.l10n.getAttributes(menuButton).id, + "unified-extensions-item-open-menu", + "expected l10n id attribute for the extension" + ); + Assert.deepEqual( + Object.keys(win.document.l10n.getAttributes(menuButton).args), + ["extensionName"], + "expected l10n args attribute for the extension" + ); + Assert.ok( + win.document.l10n + .getAttributes(menuButton) + .args.extensionName.startsWith("Extension "), + "expected l10n args attribute to start with the correct name" + ); + Assert.ok( + menuButton.getAttribute("aria-label") !== "", + "expected menu button to have non-empty localized content" + ); +} + +/** + * Tests that overflowed browser actions go to the Unified Extensions + * panel, and default toolbar items go into the default overflow + * panel. + */ +add_task(async function test_overflowable_toolbar() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + let movedNode; + + await withWindowOverflowed(win, { + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + // Ensure that there are 5 items in the Unified Extensions overflow + // list, and the default widgets should all be in the default overflow + // list (though there might be more items from the nav-bar in there that + // already existed in the nav-bar before we put the default widgets in + // there as well). + let defaultListIDs = getChildrenIDs(defaultList); + for (const widgetID of DEFAULT_WIDGET_IDS) { + Assert.ok( + defaultListIDs.includes(widgetID), + `Default overflow list should have ${widgetID}` + ); + } + + Assert.ok( + unifiedExtensionList.children.length, + "Should have items in the Unified Extension list." + ); + + for (const child of Array.from(unifiedExtensionList.children)) { + Assert.ok( + extensionIDs.includes(child.dataset.extensionid), + `Unified Extensions overflow list should have ${child.dataset.extensionid}` + ); + await verifyExtensionWidget(child, win); + } + + let extensionWidgetID = AppUiTestInternals.getBrowserActionWidgetId( + extensionIDs.at(-1) + ); + movedNode = + CustomizableUI.getWidget(extensionWidgetID).forWindow(win).node; + Assert.equal(movedNode.getAttribute("cui-areatype"), "toolbar"); + + CustomizableUI.addWidgetToArea( + extensionWidgetID, + CustomizableUI.AREA_ADDONS + ); + + Assert.equal( + movedNode.getAttribute("cui-areatype"), + "panel", + "The moved browser action button should have the right cui-areatype set." + ); + }, + afterUnderflowed: async () => { + // Ensure that the moved node's parent is still the add-ons panel. + Assert.equal( + movedNode.parentElement.id, + CustomizableUI.AREA_ADDONS, + "The browser action should still be in the addons panel" + ); + CustomizableUI.addWidgetToArea(movedNode.id, CustomizableUI.AREA_NAVBAR); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_context_menu() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + + await withWindowOverflowed(win, { + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + Assert.ok( + unifiedExtensionList.children.length, + "Should have items in the Unified Extension list." + ); + + // Open the extension panel. + await openExtensionsPanel(win); + + // Let's verify the context menus for the following extensions: + // + // - the first one defines a menu in the background script + // - the second one defines a menu with submenu + // - the third extension has no menu + + info("extension with browser action and a menu"); + const firstExtensionWidget = unifiedExtensionList.children[0]; + Assert.ok(firstExtensionWidget, "expected extension widget"); + let contextMenu = await openUnifiedExtensionsContextMenu( + firstExtensionWidget.dataset.extensionid, + win + ); + Assert.ok(contextMenu, "expected a context menu"); + let visibleItems = getVisibleMenuItems(contextMenu); + + // The context menu for the extension that declares a browser action menu + // should have the menu item created by the extension, a menu separator, the control + // for pinning the browser action to the toolbar, a menu separator and the 3 default menu items. + is( + visibleItems.length, + 7, + "expected a custom context menu item, a menu separator, the pin to " + + "toolbar menu item, a menu separator, and the 3 default menu items" + ); + + const [item, separator] = visibleItems; + is( + item.getAttribute("label"), + "Click me!", + "expected menu item as first child" + ); + is( + separator.tagName, + "menuseparator", + "expected separator after last menu item created by the extension" + ); + + await closeChromeContextMenu(contextMenu.id, null, win); + + info("extension with browser action and a menu with submenu"); + const secondExtensionWidget = unifiedExtensionList.children[1]; + Assert.ok(secondExtensionWidget, "expected extension widget"); + contextMenu = await openUnifiedExtensionsContextMenu( + secondExtensionWidget.dataset.extensionid, + win + ); + visibleItems = getVisibleMenuItems(contextMenu); + is(visibleItems.length, 7, "expected 7 menu items"); + const popup = await openSubmenu(visibleItems[0]); + is(popup.children.length, 1, "expected 1 submenu item"); + is( + popup.children[0].getAttribute("label"), + "Click me!", + "expected menu item" + ); + // The number of items in the (main) context menu should remain the same. + visibleItems = getVisibleMenuItems(contextMenu); + is(visibleItems.length, 7, "expected 7 menu items"); + await closeChromeContextMenu(contextMenu.id, null, win); + + info("extension with no browser action and no menu"); + // There is no context menu created by this extension, so there should + // only be 3 menu items corresponding to the default manage/remove/report + // items. + const thirdExtensionWidget = unifiedExtensionList.children[2]; + Assert.ok(thirdExtensionWidget, "expected extension widget"); + contextMenu = await openUnifiedExtensionsContextMenu( + thirdExtensionWidget.dataset.extensionid, + win + ); + Assert.ok(contextMenu, "expected a context menu"); + visibleItems = getVisibleMenuItems(contextMenu); + is(visibleItems.length, 5, "expected 5 menu items"); + + await closeChromeContextMenu(contextMenu.id, null, win); + + // We can close the unified extensions panel now. + await closeExtensionsPanel(win); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_message_deck() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + + await withWindowOverflowed(win, { + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + Assert.ok( + unifiedExtensionList.children.length, + "Should have items in the Unified Extension list." + ); + + const firstExtensionWidget = unifiedExtensionList.children[0]; + Assert.ok(firstExtensionWidget, "expected extension widget"); + Assert.ok( + firstExtensionWidget.dataset.extensionid, + "expected data attribute for extension ID" + ); + + // Navigate to a page where `activeTab` is useful. + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url: "https://example.com/" }, + async () => { + // Open the extension panel. + await openExtensionsPanel(win); + + info("verify message when focusing the action button"); + const item = getUnifiedExtensionsItem( + firstExtensionWidget.dataset.extensionid, + win + ); + Assert.ok(item, "expected an item for the extension"); + + const actionButton = item.querySelector( + ".unified-extensions-item-action-button" + ); + Assert.ok(actionButton, "expected action button"); + + const menuButton = item.querySelector( + ".unified-extensions-item-menu-button" + ); + Assert.ok(menuButton, "expected menu button"); + + const messageDeck = item.querySelector( + ".unified-extensions-item-message-deck" + ); + Assert.ok(messageDeck, "expected message deck"); + is( + messageDeck.selectedIndex, + win.gUnifiedExtensions.MESSAGE_DECK_INDEX_DEFAULT, + "expected selected message in the deck to be the default message" + ); + + const defaultMessage = item.querySelector( + ".unified-extensions-item-message-default" + ); + Assert.deepEqual( + win.document.l10n.getAttributes(defaultMessage), + { id: "origin-controls-state-when-clicked", args: null }, + "expected correct l10n attributes for the default message" + ); + Assert.ok( + defaultMessage.textContent !== "", + "expected default message to not be empty" + ); + + const hoverMessage = item.querySelector( + ".unified-extensions-item-message-hover" + ); + Assert.deepEqual( + win.document.l10n.getAttributes(hoverMessage), + { id: "origin-controls-state-hover-run-visit-only", args: null }, + "expected correct l10n attributes for the hover message" + ); + Assert.ok( + hoverMessage.textContent !== "", + "expected hover message to not be empty" + ); + + const hoverMenuButtonMessage = item.querySelector( + ".unified-extensions-item-message-hover-menu-button" + ); + Assert.deepEqual( + win.document.l10n.getAttributes(hoverMenuButtonMessage), + { id: "unified-extensions-item-message-manage", args: null }, + "expected correct l10n attributes for the message when hovering the menu button" + ); + Assert.ok( + hoverMenuButtonMessage.textContent !== "", + "expected message for when the menu button is hovered to not be empty" + ); + + // 1. Focus the action button of the first extension in the panel. + let focused = BrowserTestUtils.waitForEvent(actionButton, "focus"); + EventUtils.synthesizeKey("VK_TAB", {}, win); + await focused; + is( + actionButton, + win.document.activeElement, + "expected action button of the first extension item to be focused" + ); + is( + messageDeck.selectedIndex, + win.gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to be the hover message" + ); + + // 2. Focus the menu button, causing the action button to lose focus. + focused = BrowserTestUtils.waitForEvent(menuButton, "focus"); + EventUtils.synthesizeKey("VK_TAB", {}, win); + await focused; + is( + menuButton, + win.document.activeElement, + "expected menu button of the first extension item to be focused" + ); + is( + messageDeck.selectedIndex, + win.gUnifiedExtensions.MESSAGE_DECK_INDEX_MENU_HOVER, + "expected selected message in the deck to be the message when focusing the menu button" + ); + + await closeExtensionsPanel(win); + + info("verify message when hovering the action button"); + await openExtensionsPanel(win); + + is( + messageDeck.selectedIndex, + win.gUnifiedExtensions.MESSAGE_DECK_INDEX_DEFAULT, + "expected selected message in the deck to be the default message" + ); + + // 1. Hover the action button of the first extension in the panel. + let hovered = BrowserTestUtils.waitForEvent( + actionButton, + "mouseover" + ); + EventUtils.synthesizeMouseAtCenter( + actionButton, + { type: "mouseover" }, + win + ); + await hovered; + is( + messageDeck.selectedIndex, + win.gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to be the hover message" + ); + + // 2. Hover the menu button, causing the action button to no longer + // be hovered. + hovered = BrowserTestUtils.waitForEvent(menuButton, "mouseover"); + EventUtils.synthesizeMouseAtCenter( + menuButton, + { type: "mouseover" }, + win + ); + await hovered; + is( + messageDeck.selectedIndex, + win.gUnifiedExtensions.MESSAGE_DECK_INDEX_MENU_HOVER, + "expected selected message in the deck to be the message when hovering the menu button" + ); + + await closeExtensionsPanel(win); + } + ); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests that if we pin a browser action button listed in the addons panel + * to the toolbar when that button would immediately overflow, that the + * button is put into the addons panel overflow list. + */ +add_task(async function test_pinning_to_toolbar_when_overflowed() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + + let movedNode; + let extensionWidgetID; + let actionButton; + let menuButton; + + await withWindowOverflowed(win, { + beforeOverflowed: async extensionIDs => { + // Before we overflow the toolbar, let's move the last item to the addons + // panel. + extensionWidgetID = AppUiTestInternals.getBrowserActionWidgetId( + extensionIDs.at(-1) + ); + + movedNode = + CustomizableUI.getWidget(extensionWidgetID).forWindow(win).node; + + actionButton = movedNode.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButton.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the action button in the navbar" + ); + ok( + !actionButton.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the action button in the navbar" + ); + + menuButton = movedNode.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButton.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the menu button in the navbar" + ); + ok( + !menuButton.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the menu button in the navbar" + ); + + CustomizableUI.addWidgetToArea( + extensionWidgetID, + CustomizableUI.AREA_ADDONS + ); + + ok( + actionButton.classList.contains("subviewbutton"), + "expected .subviewbutton CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button in the panel" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected .subviewbutton CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button in the panel" + ); + }, + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + ok( + actionButton.classList.contains("subviewbutton"), + "expected .subviewbutton CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button in the panel" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected .subviewbutton CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button in the panel" + ); + + // Now that the window is overflowed, let's move the widget in the addons + // panel back to the navbar. This should cause the widget to overflow back + // into the addons panel. + CustomizableUI.addWidgetToArea( + extensionWidgetID, + CustomizableUI.AREA_NAVBAR + ); + await TestUtils.waitForCondition(() => { + return movedNode.hasAttribute("overflowedItem"); + }); + Assert.equal( + movedNode.parentElement, + unifiedExtensionList, + "Should have overflowed the extension button to the right list." + ); + + ok( + actionButton.classList.contains("subviewbutton"), + "expected .subviewbutton CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button in the panel" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected .subviewbutton CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button in the panel" + ); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); + +/** + * This test verifies that, when an extension placed in the toolbar is + * overflowed into the addons panel and context-clicked, it shows the "Pin to + * Toolbar" item as checked, and that unchecking this menu item inserts the + * extension into the dedicated addons area of the panel, and that the item + * then does not underflow. + */ +add_task(async function test_unpin_overflowed_widget() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + let extensionID; + + await withWindowOverflowed(win, { + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + const firstExtensionWidget = unifiedExtensionList.children[0]; + Assert.ok(firstExtensionWidget, "expected an extension widget"); + extensionID = firstExtensionWidget.dataset.extensionid; + + let movedNode = CustomizableUI.getWidget( + firstExtensionWidget.id + ).forWindow(win).node; + Assert.equal( + movedNode.getAttribute("cui-areatype"), + "toolbar", + "expected extension widget to be in the toolbar" + ); + Assert.ok( + movedNode.hasAttribute("overflowedItem"), + "expected extension widget to be overflowed" + ); + let actionButton = movedNode.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button in the panel" + ); + let menuButton = movedNode.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button in the panel" + ); + + // Open the panel, then the context menu of the extension widget, verify + // the 'Pin to Toolbar' menu item, then click on this menu item to + // uncheck it (i.e. unpin the extension). + await openExtensionsPanel(win); + const contextMenu = await openUnifiedExtensionsContextMenu( + extensionID, + win + ); + Assert.ok(contextMenu, "expected a context menu"); + + const pinToToolbar = contextMenu.querySelector( + ".unified-extensions-context-menu-pin-to-toolbar" + ); + Assert.ok(pinToToolbar, "expected a 'Pin to Toolbar' menu item"); + Assert.ok( + !pinToToolbar.hidden, + "expected 'Pin to Toolbar' to be visible" + ); + Assert.equal( + pinToToolbar.getAttribute("checked"), + "true", + "expected 'Pin to Toolbar' to be checked" + ); + + // Uncheck "Pin to Toolbar" menu item. Clicking a menu item in the + // context menu closes the unified extensions panel automatically. + const hidden = BrowserTestUtils.waitForEvent( + win.gUnifiedExtensions.panel, + "popuphidden", + true + ); + contextMenu.activateItem(pinToToolbar); + await hidden; + + // We expect the widget to no longer be overflowed. + await TestUtils.waitForCondition(() => { + return !movedNode.hasAttribute("overflowedItem"); + }); + + Assert.equal( + movedNode.parentElement.id, + CustomizableUI.AREA_ADDONS, + "expected extension widget to have been unpinned and placed in the addons area" + ); + Assert.equal( + movedNode.getAttribute("cui-areatype"), + "panel", + "expected extension widget to be in the unified extensions panel" + ); + }, + afterUnderflowed: async () => { + await openExtensionsPanel(win); + + const item = getUnifiedExtensionsItem(extensionID, win); + Assert.ok( + item, + "expected extension widget to be listed in the unified extensions panel" + ); + let actionButton = item.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button in the panel" + ); + let menuButton = item.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button in the panel" + ); + + await closeExtensionsPanel(win); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_overflow_with_a_second_window() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + // Open a second window that will stay maximized. We want to be sure that + // overflowing a widget in one window isn't going to affect the other window + // since we have an instance (of a CUI widget) per window. + let secondWin = await BrowserTestUtils.openNewBrowserWindow(); + await ensureMaximizedWindow(secondWin); + await BrowserTestUtils.openNewForegroundTab( + secondWin.gBrowser, + "https://example.com/" + ); + + // Make sure the first window is the active window. + let windowActivePromise = new Promise(resolve => { + if (Services.focus.activeWindow == win) { + resolve(); + } else { + win.addEventListener( + "activate", + () => { + resolve(); + }, + { once: true } + ); + } + }); + win.focus(); + await windowActivePromise; + + let extensionWidgetID; + let aNode; + let aNodeInSecondWindow; + + await withWindowOverflowed(win, { + beforeOverflowed: async extensionIDs => { + extensionWidgetID = AppUiTestInternals.getBrowserActionWidgetId( + extensionIDs.at(-1) + ); + + // This is the DOM node for the current window that is overflowed. + aNode = CustomizableUI.getWidget(extensionWidgetID).forWindow(win).node; + Assert.ok( + !aNode.hasAttribute("overflowedItem"), + "expected extension widget to NOT be overflowed" + ); + + let actionButton = aNode.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButton.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the action button" + ); + ok( + !actionButton.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the action button" + ); + + let menuButton = aNode.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButton.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the menu button" + ); + ok( + !menuButton.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the menu button" + ); + + // This is the DOM node of the same CUI widget but in the maximized + // window opened before. + aNodeInSecondWindow = + CustomizableUI.getWidget(extensionWidgetID).forWindow(secondWin).node; + + let actionButtonInSecondWindow = aNodeInSecondWindow.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButtonInSecondWindow.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the action button in the second window" + ); + ok( + !actionButtonInSecondWindow.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the action button in the second window" + ); + + let menuButtonInSecondWindow = aNodeInSecondWindow.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButtonInSecondWindow.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the menu button in the second window" + ); + ok( + !menuButtonInSecondWindow.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the menu button in the second window" + ); + }, + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + // The DOM node should have been overflowed. + Assert.ok( + aNode.hasAttribute("overflowedItem"), + "expected extension widget to be overflowed" + ); + Assert.equal( + aNode.getAttribute("widget-id"), + extensionWidgetID, + "expected the CUI widget ID to be set on the DOM node" + ); + + // When the node is overflowed, we swap the CSS class on the action + // and menu buttons since the node is now placed in the extensions panel. + let actionButton = aNode.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the action button" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button" + ); + let menuButton = aNode.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the menu button" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button" + ); + + // The DOM node in the other window should not have been overflowed. + Assert.ok( + !aNodeInSecondWindow.hasAttribute("overflowedItem"), + "expected extension widget to NOT be overflowed in the other window" + ); + Assert.equal( + aNodeInSecondWindow.getAttribute("widget-id"), + extensionWidgetID, + "expected the CUI widget ID to be set on the DOM node" + ); + + // We expect no CSS class changes for the node in the other window. + let actionButtonInSecondWindow = aNodeInSecondWindow.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButtonInSecondWindow.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the action button in the second window" + ); + ok( + !actionButtonInSecondWindow.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the action button in the second window" + ); + let menuButtonInSecondWindow = aNodeInSecondWindow.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButtonInSecondWindow.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the menu button in the second window" + ); + ok( + !menuButtonInSecondWindow.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the menu button in the second window" + ); + }, + afterUnderflowed: async () => { + // After underflow, we expect the CSS class on the action and menu + // buttons of the DOM node of the current window to be updated. + let actionButton = aNode.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButton.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the action button in the panel" + ); + let menuButton = aNode.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButton.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the menu button in the panel" + ); + + // The DOM node of the other window should not be changed. + let actionButtonInSecondWindow = aNodeInSecondWindow.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButtonInSecondWindow.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the action button in the second window" + ); + ok( + !actionButtonInSecondWindow.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the action button in the second window" + ); + let menuButtonInSecondWindow = aNodeInSecondWindow.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButtonInSecondWindow.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the menu button in the second window" + ); + ok( + !menuButtonInSecondWindow.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the menu button in the second window" + ); + }, + }); + + await BrowserTestUtils.closeWindow(win); + await BrowserTestUtils.closeWindow(secondWin); +}); + +add_task(async function test_overflow_with_extension_in_collapsed_area() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + const bookmarksToolbar = win.document.getElementById( + CustomizableUI.AREA_BOOKMARKS + ); + + let movedNode; + let extensionWidgetID; + let extensionWidgetPosition; + + await withWindowOverflowed(win, { + beforeOverflowed: async extensionIDs => { + // Before we overflow the toolbar, let's move the last item to the + // (visible) bookmarks toolbar. + extensionWidgetID = AppUiTestInternals.getBrowserActionWidgetId( + extensionIDs.at(-1) + ); + + movedNode = + CustomizableUI.getWidget(extensionWidgetID).forWindow(win).node; + + // Ensure that the toolbar is currently visible. + await promiseSetToolbarVisibility(bookmarksToolbar, true); + + // Move an extension to the bookmarks toolbar. + CustomizableUI.addWidgetToArea( + extensionWidgetID, + CustomizableUI.AREA_BOOKMARKS + ); + + Assert.equal( + movedNode.parentElement.id, + CustomizableUI.AREA_BOOKMARKS, + "expected extension widget to be in the bookmarks toolbar" + ); + Assert.ok( + !movedNode.hasAttribute("artificallyOverflowed"), + "expected node to not have any artificallyOverflowed prop" + ); + + extensionWidgetPosition = + CustomizableUI.getPlacementOfWidget(extensionWidgetID).position; + + // At this point we have an extension in the bookmarks toolbar, and this + // toolbar is visible. We are going to resize the window (width) AND + // collapse the toolbar to verify that the extension placed in the + // bookmarks toolbar is overflowed in the panel without any side effects. + }, + whenOverflowed: async () => { + // Ensure that the toolbar is currently collapsed. + await promiseSetToolbarVisibility(bookmarksToolbar, false); + + Assert.equal( + movedNode.parentElement.id, + OVERFLOWED_EXTENSIONS_LIST_ID, + "expected extension widget to be in the extensions panel" + ); + Assert.ok( + movedNode.getAttribute("artificallyOverflowed"), + "expected node to be artifically overflowed" + ); + + // At this point the extension is in the panel because it was overflowed + // after the bookmarks toolbar has been collapsed. The window is also + // narrow, but we are going to restore the initial window size. Since the + // visibility of the bookmarks toolbar hasn't changed, the extension + // should still be in the panel. + }, + afterUnderflowed: async () => { + Assert.equal( + movedNode.parentElement.id, + OVERFLOWED_EXTENSIONS_LIST_ID, + "expected extension widget to still be in the extensions panel" + ); + Assert.ok( + movedNode.getAttribute("artificallyOverflowed"), + "expected node to still be artifically overflowed" + ); + + // Ensure that the toolbar is visible again, which should move the + // extension back to where it was initially. + await promiseSetToolbarVisibility(bookmarksToolbar, true); + + Assert.equal( + movedNode.parentElement.id, + CustomizableUI.AREA_BOOKMARKS, + "expected extension widget to be in the bookmarks toolbar" + ); + Assert.ok( + !movedNode.hasAttribute("artificallyOverflowed"), + "expected node to not have any artificallyOverflowed prop" + ); + Assert.equal( + CustomizableUI.getPlacementOfWidget(extensionWidgetID).position, + extensionWidgetPosition, + "expected the extension to be back at the same position in the bookmarks toolbar" + ); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_overflowed_extension_cannot_be_moved() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + let extensionID; + + await withWindowOverflowed(win, { + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + const secondExtensionWidget = unifiedExtensionList.children[1]; + Assert.ok(secondExtensionWidget, "expected an extension widget"); + extensionID = secondExtensionWidget.dataset.extensionid; + + await openExtensionsPanel(win); + const contextMenu = await openUnifiedExtensionsContextMenu( + extensionID, + win + ); + Assert.ok(contextMenu, "expected a context menu"); + + const moveUp = contextMenu.querySelector( + ".unified-extensions-context-menu-move-widget-up" + ); + Assert.ok(moveUp, "expected 'move up' item in the context menu"); + Assert.ok(moveUp.hidden, "expected 'move up' item to be hidden"); + + const moveDown = contextMenu.querySelector( + ".unified-extensions-context-menu-move-widget-down" + ); + Assert.ok(moveDown, "expected 'move down' item in the context menu"); + Assert.ok(moveDown.hidden, "expected 'move down' item to be hidden"); + + await closeChromeContextMenu(contextMenu.id, null, win); + await closeExtensionsPanel(win); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/extensions/test/browser/context.html b/browser/components/extensions/test/browser/context.html new file mode 100644 index 0000000000..cd1a3db904 --- /dev/null +++ b/browser/components/extensions/test/browser/context.html @@ -0,0 +1,44 @@ + + + + + + + just some text 12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + + +

    + Some link +

    + +

    + + + +

    + +

    +
    + + +
    + + +

    + +

    Sed ut perspiciatis unde omnis iste natus error sit + voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque + ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta + sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut + odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem + sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit + amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora + incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad + minima veniam, quis nostrum exercitationem ullam corporis suscipit + laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum + iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae + consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?

    + + + + diff --git a/browser/components/extensions/test/browser/context_frame.html b/browser/components/extensions/test/browser/context_frame.html new file mode 100644 index 0000000000..39ed37674f --- /dev/null +++ b/browser/components/extensions/test/browser/context_frame.html @@ -0,0 +1,8 @@ + + + + + + Just some text + + diff --git a/browser/components/extensions/test/browser/context_tabs_onUpdated_iframe.html b/browser/components/extensions/test/browser/context_tabs_onUpdated_iframe.html new file mode 100644 index 0000000000..0e9b54b523 --- /dev/null +++ b/browser/components/extensions/test/browser/context_tabs_onUpdated_iframe.html @@ -0,0 +1,19 @@ + + +

    test iframe

    + + + diff --git a/browser/components/extensions/test/browser/context_tabs_onUpdated_page.html b/browser/components/extensions/test/browser/context_tabs_onUpdated_page.html new file mode 100644 index 0000000000..0f2ce1e8fe --- /dev/null +++ b/browser/components/extensions/test/browser/context_tabs_onUpdated_page.html @@ -0,0 +1,18 @@ + + +

    test page

    + + + + diff --git a/browser/components/extensions/test/browser/context_with_redirect.html b/browser/components/extensions/test/browser/context_with_redirect.html new file mode 100644 index 0000000000..cbf676729b --- /dev/null +++ b/browser/components/extensions/test/browser/context_with_redirect.html @@ -0,0 +1,4 @@ + + + + diff --git a/browser/components/extensions/test/browser/ctxmenu-image.png b/browser/components/extensions/test/browser/ctxmenu-image.png new file mode 100644 index 0000000000..4c3be50847 Binary files /dev/null and b/browser/components/extensions/test/browser/ctxmenu-image.png differ diff --git a/browser/components/extensions/test/browser/empty.xpi b/browser/components/extensions/test/browser/empty.xpi new file mode 100644 index 0000000000..e69de29bb2 diff --git a/browser/components/extensions/test/browser/file_bypass_cache.sjs b/browser/components/extensions/test/browser/file_bypass_cache.sjs new file mode 100644 index 0000000000..eed8a6ef49 --- /dev/null +++ b/browser/components/extensions/test/browser/file_bypass_cache.sjs @@ -0,0 +1,13 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80 ft=javascript: */ +"use strict"; + +function handleRequest(request, response) { + response.setHeader("Content-Type", "text/plain; charset=UTF-8", false); + + if (request.hasHeader("pragma") && request.hasHeader("cache-control")) { + response.write( + `${request.getHeader("pragma")}:${request.getHeader("cache-control")}` + ); + } +} diff --git a/browser/components/extensions/test/browser/file_dataTransfer_files.html b/browser/components/extensions/test/browser/file_dataTransfer_files.html new file mode 100644 index 0000000000..553196a942 --- /dev/null +++ b/browser/components/extensions/test/browser/file_dataTransfer_files.html @@ -0,0 +1,36 @@ + + + + + +
    +
    +
    + + + diff --git a/browser/components/extensions/test/browser/file_dummy.html b/browser/components/extensions/test/browser/file_dummy.html new file mode 100644 index 0000000000..966e0fd5d0 --- /dev/null +++ b/browser/components/extensions/test/browser/file_dummy.html @@ -0,0 +1,10 @@ + + +Dummy test page + + + +

    Dummy test page

    +link + + diff --git a/browser/components/extensions/test/browser/file_find_frames.html b/browser/components/extensions/test/browser/file_find_frames.html new file mode 100644 index 0000000000..cb93ae484e --- /dev/null +++ b/browser/components/extensions/test/browser/file_find_frames.html @@ -0,0 +1,19 @@ + + + + +

    Bánana 0

    + +

    bAnana 1

    +

    fruitcake

    +

    ang

    elf
    ood

    +

    This is an example of an example in the same node.

    +

    This is an example of an example of ranges in separate nodes.

    + + + diff --git a/browser/components/extensions/test/browser/file_has_non_web_controlled_blank_page_link.html b/browser/components/extensions/test/browser/file_has_non_web_controlled_blank_page_link.html new file mode 100644 index 0000000000..6c118fdb85 --- /dev/null +++ b/browser/components/extensions/test/browser/file_has_non_web_controlled_blank_page_link.html @@ -0,0 +1,5 @@ + + +wait-a-bit - _blank target diff --git a/browser/components/extensions/test/browser/file_iframe_document.html b/browser/components/extensions/test/browser/file_iframe_document.html new file mode 100644 index 0000000000..7b65ce17cc --- /dev/null +++ b/browser/components/extensions/test/browser/file_iframe_document.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/browser/components/extensions/test/browser/file_inspectedwindow_eval.html b/browser/components/extensions/test/browser/file_inspectedwindow_eval.html new file mode 100644 index 0000000000..04128d9ef3 --- /dev/null +++ b/browser/components/extensions/test/browser/file_inspectedwindow_eval.html @@ -0,0 +1,29 @@ + + + + + + + +
    + + link to inspect + +
    + + diff --git a/browser/components/extensions/test/browser/file_inspectedwindow_reload_target.sjs b/browser/components/extensions/test/browser/file_inspectedwindow_reload_target.sjs new file mode 100644 index 0000000000..2a7a401360 --- /dev/null +++ b/browser/components/extensions/test/browser/file_inspectedwindow_reload_target.sjs @@ -0,0 +1,130 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80 ft=javascript: */ +"use strict"; + +Cu.importGlobalProperties(["URLSearchParams"]); + +function handleRequest(request, response) { + let params = new URLSearchParams(request.queryString); + + switch (params.get("test")) { + case "cache": + /* eslint-disable-next-line no-use-before-define */ + handleCacheTestRequest(request, response); + break; + + case "user-agent": + /* eslint-disable-next-line no-use-before-define */ + handleUserAgentTestRequest(request, response); + break; + + case "injected-script": + /* eslint-disable-next-line no-use-before-define */ + handleInjectedScriptTestRequest(request, response, params); + break; + } +} + +function handleCacheTestRequest(request, response) { + response.setHeader("Content-Type", "text/plain; charset=UTF-8", false); + + if (request.hasHeader("pragma") && request.hasHeader("cache-control")) { + response.write( + `${request.getHeader("pragma")}:${request.getHeader("cache-control")}` + ); + } else { + response.write("empty cache headers"); + } +} + +function handleUserAgentTestRequest(request, response) { + response.setHeader("Content-Type", "text/html", false); + + const userAgentHeader = request.hasHeader("user-agent") + ? request.getHeader("user-agent") + : null; + + const query = new URLSearchParams(request.queryString); + if (query.get("crossOriginIsolated") === "true") { + response.setHeader("Cross-Origin-Opener-Policy", "same-origin", false); + } + + const IFRAME_HTML = ` + + + + + + + +

    Iframe

    + + `; + // We always want the iframe to have a different host from the top-level document. + const iframeHost = + request.host === "example.com" ? "example.org" : "example.com"; + const iframeOrigin = `${request.scheme}://${iframeHost}`; + const iframeUrl = `${iframeOrigin}/document-builder.sjs?html=${encodeURI( + IFRAME_HTML + )}`; + + const HTML = ` + + + + + test + + + +

    Top-level

    +

    ${userAgentHeader ?? "no user-agent header"}

    + + + `; + + response.write(HTML); +} + +function handleInjectedScriptTestRequest(request, response, params) { + response.setHeader("Content-Type", "text/html; charset=UTF-8", false); + + let content = ""; + const frames = parseInt(params.get("frames"), 10); + if (frames > 0) { + // Output an iframe in seamless mode, so that there is an higher chance that in case + // of test failures we get a screenshot where the nested iframes are all visible. + content = ``; + } + + response.write(` + + + + + + +

    IFRAME ${frames}

    +
    injected script NOT executed
    + + ${content} + + + `); +} diff --git a/browser/components/extensions/test/browser/file_popup_api_injection_a.html b/browser/components/extensions/test/browser/file_popup_api_injection_a.html new file mode 100644 index 0000000000..750ff1db37 --- /dev/null +++ b/browser/components/extensions/test/browser/file_popup_api_injection_a.html @@ -0,0 +1,10 @@ + + + + + + + diff --git a/browser/components/extensions/test/browser/file_popup_api_injection_b.html b/browser/components/extensions/test/browser/file_popup_api_injection_b.html new file mode 100644 index 0000000000..b8c287e55c --- /dev/null +++ b/browser/components/extensions/test/browser/file_popup_api_injection_b.html @@ -0,0 +1,10 @@ + + + + + + + diff --git a/browser/components/extensions/test/browser/file_slowed_document.sjs b/browser/components/extensions/test/browser/file_slowed_document.sjs new file mode 100644 index 0000000000..8c42fcc966 --- /dev/null +++ b/browser/components/extensions/test/browser/file_slowed_document.sjs @@ -0,0 +1,49 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80 ft=javascript: */ +"use strict"; + +// This script slows the load of an HTML document so that we can reliably test +// all phases of the load cycle supported by the extension API. + +/* eslint-disable no-unused-vars */ + +const URL = "file_slowed_document.sjs"; + +const DELAY = 2 * 1000; // Delay two seconds before completing the request. + +let nsTimer = Components.Constructor( + "@mozilla.org/timer;1", + "nsITimer", + "initWithCallback" +); + +let timer; + +function handleRequest(request, response) { + response.processAsync(); + + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Cache-Control", "no-cache", false); + response.write(` + + + + + + + `); + + // Note: We need to store a reference to the timer to prevent it from being + // canceled when it's GCed. + timer = new nsTimer( + () => { + if (request.queryString.includes("with-iframe")) { + response.write(``); + } + response.write(``); + response.finish(); + }, + DELAY, + Ci.nsITimer.TYPE_ONE_SHOT + ); +} diff --git a/browser/components/extensions/test/browser/file_title.html b/browser/components/extensions/test/browser/file_title.html new file mode 100644 index 0000000000..2a5d0bca30 --- /dev/null +++ b/browser/components/extensions/test/browser/file_title.html @@ -0,0 +1,9 @@ + + +Different title test page + + + +

    A page with a different title

    + + diff --git a/browser/components/extensions/test/browser/file_with_example_com_frame.html b/browser/components/extensions/test/browser/file_with_example_com_frame.html new file mode 100644 index 0000000000..a4263b3315 --- /dev/null +++ b/browser/components/extensions/test/browser/file_with_example_com_frame.html @@ -0,0 +1,5 @@ + + + +Load an iframe from example.com

    + diff --git a/browser/components/extensions/test/browser/file_with_xorigin_frame.html b/browser/components/extensions/test/browser/file_with_xorigin_frame.html new file mode 100644 index 0000000000..cee430a387 --- /dev/null +++ b/browser/components/extensions/test/browser/file_with_xorigin_frame.html @@ -0,0 +1,5 @@ + + + +Load a cross-origin iframe from example.net

    + diff --git a/browser/components/extensions/test/browser/head.js b/browser/components/extensions/test/browser/head.js new file mode 100644 index 0000000000..02f905d05c --- /dev/null +++ b/browser/components/extensions/test/browser/head.js @@ -0,0 +1,1046 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported CustomizableUI makeWidgetId focusWindow forceGC + * getBrowserActionWidget assertPersistentListeners + * clickBrowserAction clickPageAction clickPageActionInPanel + * triggerPageActionWithKeyboard triggerPageActionWithKeyboardInPanel + * triggerBrowserActionWithKeyboard + * getBrowserActionPopup getPageActionPopup getPageActionButton + * openBrowserActionPanel + * closeBrowserAction closePageAction + * promisePopupShown promisePopupHidden promisePopupNotificationShown + * toggleBookmarksToolbar + * openContextMenu closeContextMenu promiseContextMenuClosed + * openContextMenuInSidebar openContextMenuInPopup + * openExtensionContextMenu closeExtensionContextMenu + * openActionContextMenu openSubmenu closeActionContextMenu + * openTabContextMenu closeTabContextMenu + * openToolsMenu closeToolsMenu + * imageBuffer imageBufferFromDataURI + * getInlineOptionsBrowser + * getListStyleImage getRawListStyleImage getPanelForNode + * awaitExtensionPanel awaitPopupResize + * promiseContentDimensions alterContent + * promisePrefChangeObserved openContextMenuInFrame + * promiseAnimationFrame getCustomizableUIPanelID + * awaitEvent BrowserWindowIterator + * navigateTab historyPushState promiseWindowRestored + * getIncognitoWindow startIncognitoMonitorExtension + * loadTestSubscript awaitBrowserLoaded + * getScreenAt roundCssPixcel getCssAvailRect isRectContained + * getToolboxBackgroundColor + */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// This bug should be fixed, but for the moment all tests in this directory +// allow various classes of promise rejections. +// +// NOTE: Allowing rejections on an entire directory should be avoided. +// Normally you should use "expectUncaughtRejection" to flag individual +// failures. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Message manager disconnected/ +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/No matching message handler/); +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Receiving end does not exist/ +); + +const { AppUiTestDelegate, AppUiTestInternals } = ChromeUtils.importESModule( + "resource://testing-common/AppUiTestDelegate.sys.mjs" +); + +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + Management: "resource://gre/modules/Extension.sys.mjs", +}); + +var { makeWidgetId, promisePopupShown, getPanelForNode, awaitBrowserLoaded } = + AppUiTestInternals; + +// The extension tests can run a lot slower under ASAN. +if (AppConstants.ASAN) { + requestLongerTimeout(5); +} + +function loadTestSubscript(filePath) { + Services.scriptloader.loadSubScript(new URL(filePath, gTestPath).href, this); +} + +// Ensure when we turn off topsites in the next few lines, +// we don't hit any remote endpoints. +Services.prefs + .getDefaultBranch("browser.newtabpage.activity-stream.") + .setStringPref("discoverystream.endpointSpocsClear", ""); +// Leaving Top Sites enabled during these tests would create site screenshots +// and update pinned Top Sites unnecessarily. +Services.prefs + .getDefaultBranch("browser.newtabpage.activity-stream.") + .setBoolPref("feeds.topsites", false); +Services.prefs + .getDefaultBranch("browser.newtabpage.activity-stream.") + .setBoolPref("feeds.system.topsites", false); + +{ + // Touch the recipeParentPromise lazy getter so we don't get + // `this._recipeManager is undefined` errors during tests. + const { LoginManagerParent } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerParent.sys.mjs" + ); + void LoginManagerParent.recipeParentPromise; +} + +// Persistent Listener test functionality +const { assertPersistentListeners } = ExtensionTestUtils.testAssertions; + +// Bug 1239884: Our tests occasionally hit a long GC pause at unpredictable +// times in debug builds, which results in intermittent timeouts. Until we have +// a better solution, we force a GC after certain strategic tests, which tend to +// accumulate a high number of unreaped windows. +function forceGC() { + if (AppConstants.DEBUG) { + Cu.forceGC(); + } +} + +var focusWindow = async function focusWindow(win) { + if (Services.focus.activeWindow == win) { + return; + } + + let promise = new Promise(resolve => { + win.addEventListener( + "focus", + function () { + resolve(); + }, + { capture: true, once: true } + ); + }); + + win.focus(); + await promise; +}; + +function imageBufferFromDataURI(encodedImageData) { + let decodedImageData = atob(encodedImageData); + return Uint8Array.from(decodedImageData, byte => byte.charCodeAt(0)).buffer; +} + +let img = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQImWNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg=="; +var imageBuffer = imageBufferFromDataURI(img); + +function getInlineOptionsBrowser(aboutAddonsBrowser) { + let { contentDocument } = aboutAddonsBrowser; + return contentDocument.getElementById("addon-inline-options"); +} + +function getRawListStyleImage(button) { + // Ensure popups are initialized so that the elements are rendered and + // getComputedStyle works. + for ( + let popup = button.closest("panel,menupopup"); + popup; + popup = popup.parentElement?.closest("panel,menupopup") + ) { + popup.ensureInitialized(); + } + + return button.ownerGlobal.getComputedStyle(button).listStyleImage; +} + +function getListStyleImage(button) { + let match = /url\("([^"]*)"\)/.exec(getRawListStyleImage(button)); + return match && match[1]; +} + +function promiseAnimationFrame(win = window) { + return AppUiTestInternals.promiseAnimationFrame(win); +} + +function promisePopupHidden(popup) { + return new Promise(resolve => { + let onPopupHidden = event => { + popup.removeEventListener("popuphidden", onPopupHidden); + resolve(); + }; + popup.addEventListener("popuphidden", onPopupHidden); + }); +} + +/** + * Wait for the given PopupNotification to display + * + * @param {string} name + * The name of the notification to wait for. + * @param {Window} [win] + * The chrome window in which to wait for the notification. + * + * @returns {Promise} + * Resolves with the notification window. + */ +function promisePopupNotificationShown(name, win = window) { + return new Promise(resolve => { + function popupshown() { + let notification = win.PopupNotifications.getNotification(name); + if (!notification) { + return; + } + + ok(notification, `${name} notification shown`); + ok(win.PopupNotifications.isPanelOpen, "notification panel open"); + + win.PopupNotifications.panel.removeEventListener( + "popupshown", + popupshown + ); + resolve(win.PopupNotifications.panel.firstElementChild); + } + + win.PopupNotifications.panel.addEventListener("popupshown", popupshown); + }); +} + +function promisePossiblyInaccurateContentDimensions(browser) { + return SpecialPowers.spawn(browser, [], async function () { + function copyProps(obj, props) { + let res = {}; + for (let prop of props) { + res[prop] = obj[prop]; + } + return res; + } + + return { + window: copyProps(content, [ + "innerWidth", + "innerHeight", + "outerWidth", + "outerHeight", + "scrollX", + "scrollY", + "scrollMaxX", + "scrollMaxY", + ]), + body: copyProps(content.document.body, [ + "clientWidth", + "clientHeight", + "scrollWidth", + "scrollHeight", + ]), + root: copyProps(content.document.documentElement, [ + "clientWidth", + "clientHeight", + "scrollWidth", + "scrollHeight", + ]), + isStandards: content.document.compatMode !== "BackCompat", + }; + }); +} + +function delay(ms = 0) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Retrieve the content dimensions (and wait until the content gets to the. + * size of the browser element they are loaded into, optionally tollerating + * size differences to prevent intermittent failures). + * + * @param {BrowserElement} browser + * The browser element where the content has been loaded. + * @param {number} [tolleratedWidthSizeDiff] + * width size difference to tollerate in pixels (defaults to 1). + * + * @returns {Promise} + * An object with the dims retrieved from the content. + */ +async function promiseContentDimensions(browser, tolleratedWidthSizeDiff = 1) { + // For remote browsers, each resize operation requires an asynchronous + // round-trip to resize the content window. Since there's a certain amount of + // unpredictability in the timing, mainly due to the unpredictability of + // reflows, we need to wait until the content window dimensions match the + // dimensions before returning data. + + let dims = await promisePossiblyInaccurateContentDimensions(browser); + while ( + Math.abs(browser.clientWidth - dims.window.innerWidth) > + tolleratedWidthSizeDiff || + browser.clientHeight !== Math.round(dims.window.innerHeight) + ) { + const diffWidth = Math.abs(browser.clientWidth - dims.window.innerWidth); + const diffHeight = Math.abs(browser.clientHeight - dims.window.innerHeight); + info( + `Content dimension did not reached the expected size yet (diff: ${diffWidth}x${diffHeight}). Wait further.` + ); + await delay(50); + dims = await promisePossiblyInaccurateContentDimensions(browser); + } + + return dims; +} + +async function awaitPopupResize(browser) { + await BrowserTestUtils.waitForEvent( + browser, + "WebExtPopupResized", + event => event.detail === "delayed" + ); + + return promiseContentDimensions(browser); +} + +function alterContent(browser, task, arg = null) { + return Promise.all([ + SpecialPowers.spawn(browser, [arg], task), + awaitPopupResize(browser), + ]).then(([, dims]) => dims); +} + +async function focusButtonAndPressKey(key, elem, modifiers) { + let focused = BrowserTestUtils.waitForEvent(elem, "focus", true); + + elem.setAttribute("tabindex", "-1"); + elem.focus(); + elem.removeAttribute("tabindex"); + await focused; + + EventUtils.synthesizeKey(key, modifiers); + elem.blur(); +} + +var awaitExtensionPanel = function (extension, win = window, awaitLoad = true) { + return AppUiTestDelegate.awaitExtensionPanel(win, extension.id, awaitLoad); +}; + +function getCustomizableUIPanelID(win = window) { + return CustomizableUI.AREA_ADDONS; +} + +function getBrowserActionWidget(extension) { + return AppUiTestInternals.getBrowserActionWidget(extension.id); +} + +function getBrowserActionPopup(extension, win = window) { + let group = getBrowserActionWidget(extension); + + if (group.areaType == CustomizableUI.TYPE_TOOLBAR) { + return win.document.getElementById("customizationui-widget-panel"); + } + + return win.gUnifiedExtensions.panel; +} + +var showBrowserAction = function (extension, win = window) { + return AppUiTestInternals.showBrowserAction(win, extension.id); +}; + +function clickBrowserAction(extension, win = window, modifiers) { + return AppUiTestDelegate.clickBrowserAction(win, extension.id, modifiers); +} + +async function triggerBrowserActionWithKeyboard( + extension, + key = "KEY_Enter", + modifiers = {}, + win = window +) { + await promiseAnimationFrame(win); + await showBrowserAction(extension, win); + + let group = getBrowserActionWidget(extension); + let node = group.forWindow(win).node.firstElementChild; + + if (group.areaType == CustomizableUI.TYPE_TOOLBAR) { + await focusButtonAndPressKey(key, node, modifiers); + } else if (group.areaType == CustomizableUI.TYPE_PANEL) { + // Use key navigation so that the PanelMultiView doesn't ignore key events. + let panel = win.gUnifiedExtensions.panel; + while (win.document.activeElement != node) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + ok( + panel.contains(win.document.activeElement), + "Focus is inside the panel" + ); + } + EventUtils.synthesizeKey(key, modifiers); + } +} + +function closeBrowserAction(extension, win = window) { + return AppUiTestDelegate.closeBrowserAction(win, extension.id); +} + +function openBrowserActionPanel(extension, win = window, awaitLoad = false) { + clickBrowserAction(extension, win); + + return awaitExtensionPanel(extension, win, awaitLoad); +} + +async function toggleBookmarksToolbar(visible = true) { + let bookmarksToolbar = document.getElementById("PersonalToolbar"); + // Third parameter is 'persist' and true is the default. + // Fourth parameter is 'animated' and we want no animation. + setToolbarVisibility(bookmarksToolbar, visible, true, false); + if (!visible) { + return BrowserTestUtils.waitForMutationCondition( + bookmarksToolbar, + { attributes: true }, + () => bookmarksToolbar.collapsed + ); + } + + return BrowserTestUtils.waitForEvent( + bookmarksToolbar, + "BookmarksToolbarVisibilityUpdated" + ); +} + +async function openContextMenuInPopup( + extension, + selector = "body", + win = window +) { + let doc = win.document; + let contentAreaContextMenu = doc.getElementById("contentAreaContextMenu"); + let browser = await awaitExtensionPanel(extension, win); + + // Ensure that the document layout has been flushed before triggering the mouse event + // (See Bug 1519808 for a rationale). + await browser.ownerGlobal.promiseDocumentFlushed(() => {}); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contentAreaContextMenu, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + selector, + { type: "mousedown", button: 2 }, + browser + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + selector, + { type: "contextmenu" }, + browser + ); + await popupShownPromise; + return contentAreaContextMenu; +} + +async function openContextMenuInSidebar(selector = "body") { + let contentAreaContextMenu = SidebarUI.browser.contentDocument.getElementById( + "contentAreaContextMenu" + ); + let browser = SidebarUI.browser.contentDocument.getElementById( + "webext-panels-browser" + ); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contentAreaContextMenu, + "popupshown" + ); + + // Wait for the layout to be flushed, otherwise this test may + // 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(() => {}); + + info("Opening context menu in sidebarAction panel"); + await BrowserTestUtils.synthesizeMouseAtCenter( + selector, + { type: "mousedown", button: 2 }, + browser + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + selector, + { type: "contextmenu" }, + browser + ); + await popupShownPromise; + return contentAreaContextMenu; +} + +// `selector` should refer to the content in the frame. If invalid the test can +// fail intermittently because the click could inadvertently be registered on +// the upper-left corner of the frame (instead of inside the frame). +async function openContextMenuInFrame(selector = "body", frameIndex = 0) { + let contentAreaContextMenu = document.getElementById( + "contentAreaContextMenu" + ); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contentAreaContextMenu, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + selector, + { type: "contextmenu" }, + gBrowser.selectedBrowser.browsingContext.children[frameIndex] + ); + await popupShownPromise; + return contentAreaContextMenu; +} + +async function openContextMenu(selector = "#img1", win = window) { + let contentAreaContextMenu = win.document.getElementById( + "contentAreaContextMenu" + ); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contentAreaContextMenu, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + selector, + { type: "mousedown", button: 2 }, + win.gBrowser.selectedBrowser + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + selector, + { type: "contextmenu" }, + win.gBrowser.selectedBrowser + ); + await popupShownPromise; + return contentAreaContextMenu; +} + +async function promiseContextMenuClosed(contextMenu) { + let contentAreaContextMenu = + contextMenu || document.getElementById("contentAreaContextMenu"); + return BrowserTestUtils.waitForEvent(contentAreaContextMenu, "popuphidden"); +} + +async function closeContextMenu(contextMenu, win = window) { + let contentAreaContextMenu = + contextMenu || win.document.getElementById("contentAreaContextMenu"); + let closed = promiseContextMenuClosed(contentAreaContextMenu); + contentAreaContextMenu.hidePopup(); + await closed; +} + +async function openExtensionContextMenu(selector = "#img1") { + let contextMenu = await openContextMenu(selector); + let topLevelMenu = contextMenu.getElementsByAttribute( + "ext-type", + "top-level-menu" + ); + + // Return null if the extension only has one item and therefore no extension menu. + if (!topLevelMenu.length) { + return null; + } + + let extensionMenu = topLevelMenu[0]; + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + extensionMenu.openMenu(true); + await popupShownPromise; + return extensionMenu; +} + +async function closeExtensionContextMenu(itemToSelect, modifiers = {}) { + let contentAreaContextMenu = document.getElementById( + "contentAreaContextMenu" + ); + let popupHiddenPromise = promiseContextMenuClosed(contentAreaContextMenu); + if (itemToSelect) { + itemToSelect.closest("menupopup").activateItem(itemToSelect, modifiers); + } else { + contentAreaContextMenu.hidePopup(); + } + await popupHiddenPromise; + + // Bug 1351638: parent menu fails to close intermittently, make sure it does. + contentAreaContextMenu.hidePopup(); +} + +async function openToolsMenu(win = window) { + const node = win.document.getElementById("tools-menu"); + const menu = win.document.getElementById("menu_ToolsPopup"); + const shown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + if (AppConstants.platform === "macosx") { + // We can't open menubar items on OSX, so mocking instead. + menu.dispatchEvent(new MouseEvent("popupshowing")); + menu.dispatchEvent(new MouseEvent("popupshown")); + } else { + node.open = true; + } + await shown; + return menu; +} + +function closeToolsMenu(itemToSelect, win = window) { + const menu = win.document.getElementById("menu_ToolsPopup"); + const hidden = BrowserTestUtils.waitForEvent(menu, "popuphidden"); + if (AppConstants.platform === "macosx") { + // Mocking on OSX, see above. + if (itemToSelect) { + itemToSelect.doCommand(); + } + menu.dispatchEvent(new MouseEvent("popuphiding")); + menu.dispatchEvent(new MouseEvent("popuphidden")); + } else if (itemToSelect) { + EventUtils.synthesizeMouseAtCenter(itemToSelect, {}, win); + } else { + menu.hidePopup(); + } + return hidden; +} + +async function openChromeContextMenu(menuId, target, win = window) { + const node = win.document.querySelector(target); + const menu = win.document.getElementById(menuId); + const shown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(node, { type: "contextmenu" }, win); + await shown; + return menu; +} + +async function openSubmenu(submenuItem, win = window) { + const submenu = submenuItem.menupopup; + const shown = BrowserTestUtils.waitForEvent(submenu, "popupshown"); + submenuItem.openMenu(true); + await shown; + return submenu; +} + +function closeChromeContextMenu(menuId, itemToSelect, win = window) { + const menu = win.document.getElementById(menuId); + const hidden = BrowserTestUtils.waitForEvent(menu, "popuphidden"); + if (itemToSelect) { + itemToSelect.closest("menupopup").activateItem(itemToSelect); + } else { + menu.hidePopup(); + } + return hidden; +} + +async function openActionContextMenu(extension, kind, win = window) { + // See comment from getPageActionButton below. + win.gURLBar.setPageProxyState("valid"); + await promiseAnimationFrame(win); + let buttonID; + let menuID; + if (kind == "page") { + buttonID = + "#" + + BrowserPageActions.urlbarButtonNodeIDForActionID( + makeWidgetId(extension.id) + ); + menuID = "pageActionContextMenu"; + } else { + buttonID = `#${makeWidgetId(extension.id)}-${kind}-action`; + menuID = "toolbar-context-menu"; + } + return openChromeContextMenu(menuID, buttonID, win); +} + +function closeActionContextMenu(itemToSelect, kind, win = window) { + let menuID = + kind == "page" ? "pageActionContextMenu" : "toolbar-context-menu"; + return closeChromeContextMenu(menuID, itemToSelect, win); +} + +function openTabContextMenu(tab = gBrowser.selectedTab) { + // The TabContextMenu initializes its strings only on a focus or mouseover event. + // Calls focus event on the TabContextMenu before opening. + tab.focus(); + let indexOfTab = Array.prototype.indexOf.call(tab.parentNode.children, tab); + return openChromeContextMenu( + "tabContextMenu", + `.tabbrowser-tab:nth-child(${indexOfTab + 1})`, + tab.ownerGlobal + ); +} + +function closeTabContextMenu(itemToSelect, win = window) { + return closeChromeContextMenu("tabContextMenu", itemToSelect, win); +} + +function getPageActionPopup(extension, win = window) { + return AppUiTestInternals.getPageActionPopup(win, extension.id); +} + +function getPageActionButton(extension, win = window) { + return AppUiTestInternals.getPageActionButton(win, extension.id); +} + +function clickPageAction(extension, win = window, modifiers = {}) { + return AppUiTestDelegate.clickPageAction(win, extension.id, modifiers); +} + +// Shows the popup for the page action which for lists +// all available page actions +async function showPageActionsPanel(win = window) { + // See the comment at getPageActionButton + win.gURLBar.setPageProxyState("valid"); + await promiseAnimationFrame(win); + + let pageActionsPopup = win.document.getElementById("pageActionPanel"); + + let popupShownPromise = promisePopupShown(pageActionsPopup); + EventUtils.synthesizeMouseAtCenter( + win.document.getElementById("pageActionButton"), + {}, + win + ); + await popupShownPromise; + + return pageActionsPopup; +} + +async function clickPageActionInPanel(extension, win = window, modifiers = {}) { + let pageActionsPopup = await showPageActionsPanel(win); + + let pageActionId = BrowserPageActions.panelButtonNodeIDForActionID( + makeWidgetId(extension.id) + ); + + let popupHiddenPromise = promisePopupHidden(pageActionsPopup); + let widgetButton = win.document.getElementById(pageActionId); + EventUtils.synthesizeMouseAtCenter(widgetButton, modifiers, win); + if (widgetButton.disabled) { + pageActionsPopup.hidePopup(); + } + await popupHiddenPromise; + + return new Promise(SimpleTest.executeSoon); +} + +async function triggerPageActionWithKeyboard( + extension, + modifiers = {}, + win = window +) { + let elem = await getPageActionButton(extension, win); + await focusButtonAndPressKey("KEY_Enter", elem, modifiers); + return new Promise(SimpleTest.executeSoon); +} + +async function triggerPageActionWithKeyboardInPanel( + extension, + modifiers = {}, + win = window +) { + let pageActionsPopup = await showPageActionsPanel(win); + + let pageActionId = BrowserPageActions.panelButtonNodeIDForActionID( + makeWidgetId(extension.id) + ); + + let popupHiddenPromise = promisePopupHidden(pageActionsPopup); + let widgetButton = win.document.getElementById(pageActionId); + if (widgetButton.disabled) { + pageActionsPopup.hidePopup(); + return new Promise(SimpleTest.executeSoon); + } + + // Use key navigation so that the PanelMultiView doesn't ignore key events + while (win.document.activeElement != widgetButton) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + ok( + pageActionsPopup.contains(win.document.activeElement), + "Focus is inside of the panel" + ); + } + EventUtils.synthesizeKey("KEY_Enter", modifiers); + await popupHiddenPromise; + + return new Promise(SimpleTest.executeSoon); +} + +function closePageAction(extension, win = window) { + return AppUiTestDelegate.closePageAction(win, extension.id); +} + +function promisePrefChangeObserved(pref) { + return new Promise((resolve, reject) => + Preferences.observe(pref, function prefObserver() { + Preferences.ignore(pref, prefObserver); + resolve(); + }) + ); +} + +function promiseWindowRestored(window) { + return new Promise(resolve => + window.addEventListener("SSWindowRestored", resolve, { once: true }) + ); +} + +function awaitEvent(eventName, id) { + return new Promise(resolve => { + let listener = (_eventName, ...args) => { + let extension = args[0]; + if (_eventName === eventName && extension.id == id) { + Management.off(eventName, listener); + resolve(); + } + }; + + Management.on(eventName, listener); + }); +} + +function* BrowserWindowIterator() { + for (let currentWindow of Services.wm.getEnumerator("navigator:browser")) { + if (!currentWindow.closed) { + yield currentWindow; + } + } +} + +async function locationChange(tab, url, task) { + let locationChanged = BrowserTestUtils.waitForLocationChange(gBrowser, url); + await SpecialPowers.spawn(tab.linkedBrowser, [url], task); + return locationChanged; +} + +function navigateTab(tab, url) { + return locationChange(tab, url, url => { + content.location.href = url; + }); +} + +function historyPushState(tab, url) { + return locationChange(tab, url, url => { + content.history.pushState(null, null, url); + }); +} + +// This monitor extension runs with incognito: not_allowed, if it receives any +// events with incognito data it fails. +async function startIncognitoMonitorExtension() { + function background() { + // Bug 1513220 - We're unable to get the tab during onRemoved, so we track + // valid tabs in "seen" so we can at least validate tabs that we have "seen" + // during onRemoved. This means that the monitor extension must be started + // prior to creating any tabs that will be removed. + + // Map tab> + let seenTabs = new Map(); + function getTabById(tabId) { + return seenTabs.has(tabId) + ? seenTabs.get(tabId) + : browser.tabs.get(tabId); + } + + async function testTab(tabOrId, eventName) { + let tab = tabOrId; + if (typeof tabOrId == "number") { + let tabId = tabOrId; + try { + tab = await getTabById(tabId); + } catch (e) { + browser.test.fail( + `tabs.${eventName} for id ${tabOrId} unexpected failure ${e}\n` + ); + return; + } + } + browser.test.assertFalse( + tab.incognito, + `tabs.${eventName} ${tab.id}: monitor extension got expected incognito value` + ); + seenTabs.set(tab.id, tab); + } + async function testTabInfo(tabInfo, eventName) { + if (typeof tabInfo == "number") { + await testTab(tabInfo, eventName); + } else if (typeof tabInfo == "object") { + if (tabInfo.id !== undefined) { + await testTab(tabInfo, eventName); + } else if (tabInfo.tab !== undefined) { + await testTab(tabInfo.tab, eventName); + } else if (tabInfo.tabIds !== undefined) { + await Promise.all( + tabInfo.tabIds.map(tabId => testTab(tabId, eventName)) + ); + } else if (tabInfo.tabId !== undefined) { + await testTab(tabInfo.tabId, eventName); + } + } + } + let tabEvents = [ + "onUpdated", + "onCreated", + "onAttached", + "onDetached", + "onRemoved", + "onMoved", + "onZoomChange", + "onHighlighted", + ]; + for (let eventName of tabEvents) { + browser.tabs[eventName].addListener(async details => { + await testTabInfo(details, eventName); + }); + } + browser.tabs.onReplaced.addListener(async (addedTabId, removedTabId) => { + await testTabInfo(addedTabId, "onReplaced (addedTabId)"); + await testTabInfo(removedTabId, "onReplaced (removedTabId)"); + }); + + // Map window> + let seenWindows = new Map(); + function getWindowById(windowId) { + return seenWindows.has(windowId) + ? seenWindows.get(windowId) + : browser.windows.get(windowId); + } + + browser.windows.onCreated.addListener(window => { + browser.test.assertFalse( + window.incognito, + `windows.onCreated monitor extension got expected incognito value` + ); + seenWindows.set(window.id, window); + }); + browser.windows.onRemoved.addListener(async windowId => { + let window; + try { + window = await getWindowById(windowId); + } catch (e) { + browser.test.fail( + `windows.onCreated for id ${windowId} unexpected failure ${e}\n` + ); + return; + } + browser.test.assertFalse( + window.incognito, + `windows.onRemoved ${window.id}: monitor extension got expected incognito value` + ); + }); + browser.windows.onFocusChanged.addListener(async windowId => { + if (windowId == browser.windows.WINDOW_ID_NONE) { + return; + } + // onFocusChanged will also fire for blur so check actual window.incognito value. + let window; + try { + window = await getWindowById(windowId); + } catch (e) { + browser.test.fail( + `windows.onFocusChanged for id ${windowId} unexpected failure ${e}\n` + ); + return; + } + browser.test.assertFalse( + window.incognito, + `windows.onFocusChanged ${window.id}: monitor extesion got expected incognito value` + ); + seenWindows.set(window.id, window); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + incognitoOverride: "not_allowed", + background, + }); + await extension.startup(); + return extension; +} + +async function getIncognitoWindow(url = "about:privatebrowsing") { + // Since events will be limited based on incognito, we need a + // spanning extension to get the tab id so we can test access failure. + + function background(expectUrl) { + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status === "complete" && tab.url === expectUrl) { + browser.test.sendMessage("data", { tabId, windowId: tab.windowId }); + } + }); + } + + let windowWatcher = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background: `(${background})(${JSON.stringify(url)})`, + incognitoOverride: "spanning", + }); + + await windowWatcher.startup(); + let data = windowWatcher.awaitMessage("data"); + + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + BrowserTestUtils.startLoadingURIString(win.gBrowser.selectedBrowser, url); + + let details = await data; + await windowWatcher.unload(); + return { win, details }; +} + +function getScreenAt(left, top, width, height) { + const screenManager = Cc["@mozilla.org/gfx/screenmanager;1"].getService( + Ci.nsIScreenManager + ); + return screenManager.screenForRect(left, top, width, height); +} + +function roundCssPixcel(pixel, screen) { + return Math.floor( + Math.floor(pixel * screen.defaultCSSScaleFactor) / + screen.defaultCSSScaleFactor + ); +} + +function getCssAvailRect(screen) { + const availDeviceLeft = {}; + const availDeviceTop = {}; + const availDeviceWidth = {}; + const availDeviceHeight = {}; + screen.GetAvailRect( + availDeviceLeft, + availDeviceTop, + availDeviceWidth, + availDeviceHeight + ); + const factor = screen.defaultCSSScaleFactor; + const left = Math.floor(availDeviceLeft.value / factor); + const top = Math.floor(availDeviceTop.value / factor); + const width = Math.floor(availDeviceWidth.value / factor); + const height = Math.floor(availDeviceHeight.value / factor); + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height, + }; +} + +function isRectContained(actualRect, maxRect) { + is( + `top=${actualRect.top >= maxRect.top},bottom=${ + actualRect.bottom <= maxRect.bottom + },left=${actualRect.left >= maxRect.left},right=${ + actualRect.right <= maxRect.right + }`, + "top=true,bottom=true,left=true,right=true", + `Dimension must be inside, top:${actualRect.top}>=${maxRect.top}, bottom:${actualRect.bottom}<=${maxRect.bottom}, left:${actualRect.left}>=${maxRect.left}, right:${actualRect.right}<=${maxRect.right}` + ); +} + +function getToolboxBackgroundColor() { + let toolbox = document.getElementById("navigator-toolbox"); + // Ignore any potentially ongoing transition. + toolbox.style.transitionProperty = "none"; + let color = window.getComputedStyle(toolbox).backgroundColor; + toolbox.style.transitionProperty = ""; + return color; +} diff --git a/browser/components/extensions/test/browser/head_browserAction.js b/browser/components/extensions/test/browser/head_browserAction.js new file mode 100644 index 0000000000..41fda8fc06 --- /dev/null +++ b/browser/components/extensions/test/browser/head_browserAction.js @@ -0,0 +1,368 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported testPopupSize */ + +// This file is imported into the same scope as head.js. + +/* import-globals-from head.js */ + +// A test helper that retrives an old and new value after a given delay +// and then check that calls an `isCompleted` callback to check that +// the value has reached the expected value. +function waitUntilValue({ + getValue, + isCompleted, + message, + delay: delayTime, + times = 1, +} = {}) { + let i = 0; + return BrowserTestUtils.waitForCondition(async () => { + const oldVal = await getValue(); + await delay(delayTime); + const newVal = await getValue(); + + const done = isCompleted(oldVal, newVal); + + // Reset the counter if the value wasn't the expected one. + if (!done) { + i = 0; + } + + return done && times === ++i; + }, message); +} + +async function testPopupSize( + standardsMode, + browserWin = window, + arrowSide = "top" +) { + let docType = standardsMode ? "" : ""; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + browser_style: false, + }, + }, + + files: { + "popup.html": `${docType} + + + + + + + + + + + + `, + }, + }); + + await extension.startup(); + + if (arrowSide == "top") { + // Test the standalone panel for a toolbar button. + let browser = await openBrowserActionPanel(extension, browserWin, true); + + let dims = await promiseContentDimensions(browser); + + is( + dims.isStandards, + standardsMode, + "Document has the expected compat mode" + ); + + let { innerWidth, innerHeight } = dims.window; + + dims = await alterContent(browser, () => { + content.document.body.classList.add("bigger"); + }); + + let win = dims.window; + Assert.lessOrEqual( + Math.abs(win.innerHeight - innerHeight), + 1, + `Window height should not change (${win.innerHeight} ~= ${innerHeight})` + ); + Assert.greater( + win.innerWidth, + innerWidth, + `Window width should increase (${win.innerWidth} > ${innerWidth})` + ); + + dims = await alterContent(browser, () => { + content.document.body.classList.remove("bigger"); + }); + + win = dims.window; + + // The getContentSize calculation is not always reliable to single-pixel + // precision. + Assert.lessOrEqual( + Math.abs(win.innerHeight - innerHeight), + 1, + `Window height should return to approximately its original value (${win.innerHeight} ~= ${innerHeight})` + ); + Assert.lessOrEqual( + Math.abs(win.innerWidth - innerWidth), + 1, + `Window width should return to approximately its original value (${win.innerWidth} ~= ${innerWidth})` + ); + + await closeBrowserAction(extension, browserWin); + } + + // Test the PanelUI panel for a menu panel button. + let widget = getBrowserActionWidget(extension); + CustomizableUI.addWidgetToArea(widget.id, getCustomizableUIPanelID()); + + let panel = browserWin.gUnifiedExtensions.panel; + panel.setAttribute("animate", "false"); + + let shownPromise = Promise.resolve(); + + let browser = await openBrowserActionPanel(extension, browserWin); + + // Small changes if this is a fixed width window + let isFixedWidth = !widget.disallowSubView; + + // Wait long enough to make sure the initial popup positioning has been completed ( + // by waiting until the value stays the same for 20 times in a row). + await waitUntilValue({ + getValue: () => panel.getBoundingClientRect().top, + isCompleted: (oldVal, newVal) => { + return oldVal === newVal; + }, + times: 20, + message: "Wait the popup opening to be completed", + delay: 500, + }); + + let origPanelRect = panel.getBoundingClientRect(); + + // Check that the panel is still positioned as expected. + let checkPanelPosition = () => { + is( + panel.getAttribute("side"), + arrowSide, + "Panel arrow is positioned as expected" + ); + + let panelRect = panel.getBoundingClientRect(); + if (arrowSide == "top") { + is(panelRect.top, origPanelRect.top, "Panel has not moved downwards"); + Assert.greaterOrEqual( + panelRect.bottom, + origPanelRect.bottom, + `Panel has not shrunk from original size (${panelRect.bottom} >= ${origPanelRect.bottom})` + ); + + let screenBottom = + browserWin.screen.availTop + browserWin.screen.availHeight; + let panelBottom = browserWin.mozInnerScreenY + panelRect.bottom; + Assert.lessOrEqual( + Math.round(panelBottom), + screenBottom, + `Bottom of popup should be on-screen. (${panelBottom} <= ${screenBottom})` + ); + } else { + is(panelRect.bottom, origPanelRect.bottom, "Panel has not moved upwards"); + Assert.lessOrEqual( + panelRect.top, + origPanelRect.top, + `Panel has not shrunk from original size (${panelRect.top} <= ${origPanelRect.top})` + ); + + let panelTop = browserWin.mozInnerScreenY + panelRect.top; + Assert.greaterOrEqual( + panelTop, + browserWin.screen.availTop, + `Top of popup should be on-screen. (${panelTop} >= ${browserWin.screen.availTop})` + ); + } + }; + + await awaitBrowserLoaded(browser); + await shownPromise; + + // Wait long enough to make sure the initial resize debouncing timer has + // expired. + await waitUntilValue({ + getValue: () => promiseContentDimensions(browser), + isCompleted: (oldDims, newDims) => { + return ( + oldDims.window.innerWidth === newDims.window.innerWidth && + oldDims.window.innerHeight === newDims.window.innerHeight + ); + }, + message: "Wait the popup resize to be completed", + delay: 500, + }); + + let dims = await promiseContentDimensions(browser); + + is(dims.isStandards, standardsMode, "Document has the expected compat mode"); + + // If the browser's preferred height is smaller than the initial height of the + // panel, then it will still take up the full available vertical space. Even + // so, we need to check that we've gotten the preferred height calculation + // correct, so check that explicitly. + let getHeight = () => parseFloat(browser.style.height); + + let { innerWidth, innerHeight } = dims.window; + let height = getHeight(); + + let setClass = className => { + content.document.body.className = className; + }; + + info( + "Increase body children's width. " + + "Expect them to wrap, and the frame to grow vertically rather than widen." + ); + + dims = await alterContent(browser, setClass, "big"); + let win = dims.window; + + Assert.greater( + getHeight(), + height, + `Browser height should increase (${getHeight()} > ${height})` + ); + + if (isFixedWidth) { + is(win.innerWidth, innerWidth, "Window width should not change"); + } else { + Assert.greaterOrEqual( + win.innerWidth, + innerWidth, + `Window width should increase (${win.innerWidth} >= ${innerWidth})` + ); + } + Assert.greaterOrEqual( + win.innerHeight, + innerHeight, + `Window height should increase (${win.innerHeight} >= ${innerHeight})` + ); + Assert.lessOrEqual( + win.scrollMaxY, + 1, + "Document should not be vertically scrollable" + ); + + checkPanelPosition(); + + if (isFixedWidth) { + // Test a fixed width window grows in height when elements wrap + info( + "Increase body children's width and height. " + + "Expect them to wrap, and the frame to grow vertically rather than widen." + ); + + dims = await alterContent(browser, setClass, "bigger"); + win = dims.window; + + Assert.greater( + getHeight(), + height, + `Browser height should increase (${getHeight()} > ${height})` + ); + + is(win.innerWidth, innerWidth, "Window width should not change"); + Assert.greaterOrEqual( + win.innerHeight, + innerHeight, + `Window height should increase (${win.innerHeight} >= ${innerHeight})` + ); + Assert.lessOrEqual( + win.scrollMaxY, + 1, + "Document should not be vertically scrollable" + ); + + checkPanelPosition(); + } + + info( + "Increase body height beyond the height of the screen. " + + "Expect the panel to grow to accommodate, but not larger than the height of the screen." + ); + + dims = await alterContent(browser, setClass, "huge"); + win = dims.window; + + Assert.greater( + getHeight(), + height, + `Browser height should increase (${getHeight()} > ${height})` + ); + + is(win.innerWidth, innerWidth, "Window width should not change"); + Assert.greater( + win.innerHeight, + innerHeight, + `Window height should increase (${win.innerHeight} > ${innerHeight})` + ); + // Commented out check for the window height here which mysteriously breaks + // on infra but not locally. bug 1396843 covers re-enabling this. + // ok(win.innerHeight < screen.height, `Window height be less than the screen height (${win.innerHeight} < ${screen.height})`); + Assert.greater( + win.scrollMaxY, + 0, + `Document should be vertically scrollable (${win.scrollMaxY} > 0)` + ); + + checkPanelPosition(); + + info("Restore original styling. Expect original dimensions."); + dims = await alterContent(browser, setClass, ""); + win = dims.window; + + is(getHeight(), height, "Browser height should return to its original value"); + + is(win.innerWidth, innerWidth, "Window width should not change"); + is( + win.innerHeight, + innerHeight, + "Window height should return to its original value" + ); + Assert.lessOrEqual( + win.scrollMaxY, + 1, + "Document should not be vertically scrollable" + ); + + checkPanelPosition(); + + await closeBrowserAction(extension, browserWin); + + await extension.unload(); +} diff --git a/browser/components/extensions/test/browser/head_devtools.js b/browser/components/extensions/test/browser/head_devtools.js new file mode 100644 index 0000000000..934d4c8a80 --- /dev/null +++ b/browser/components/extensions/test/browser/head_devtools.js @@ -0,0 +1,162 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported + assertDevToolsExtensionEnabled, + closeToolboxForTab, + navigateToWithDevToolsOpen + openToolboxForTab, + registerBlankToolboxPanel, + TOOLBOX_BLANK_PANEL_ID, +*/ + +ChromeUtils.defineESModuleGetters(this, { + loader: "resource://devtools/shared/loader/Loader.sys.mjs", + DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs", +}); +ChromeUtils.defineLazyGetter(this, "gDevTools", () => { + const { gDevTools } = loader.require("devtools/client/framework/devtools"); + return gDevTools; +}); + +const TOOLBOX_BLANK_PANEL_ID = "testBlankPanel"; + +// Register a blank custom tool so that we don't need to wait the webconsole +// to be fully loaded/unloaded to prevent intermittent failures (related +// to a webconsole that is still loading when the test has been completed). +async function registerBlankToolboxPanel() { + const testBlankPanel = { + id: TOOLBOX_BLANK_PANEL_ID, + url: "about:blank", + label: "Blank Tool", + isToolSupported() { + return true; + }, + build(iframeWindow, toolbox) { + return Promise.resolve({ + target: toolbox.target, + toolbox: toolbox, + isReady: true, + panelDoc: iframeWindow.document, + destroy() {}, + }); + }, + }; + + registerCleanupFunction(() => { + gDevTools.unregisterTool(testBlankPanel.id); + }); + + gDevTools.registerTool(testBlankPanel); +} + +async function openToolboxForTab(tab, panelId = TOOLBOX_BLANK_PANEL_ID) { + if ( + panelId == TOOLBOX_BLANK_PANEL_ID && + !gDevTools.getToolDefinition(panelId) + ) { + info(`Registering ${TOOLBOX_BLANK_PANEL_ID} tool to the developer tools`); + registerBlankToolboxPanel(); + } + + const toolbox = await gDevTools.showToolboxForTab(tab, { toolId: panelId }); + const { url, outerWindowID } = toolbox.target.form; + info( + `Developer toolbox opened on panel "${panelId}" for target ${JSON.stringify( + { url, outerWindowID } + )}` + ); + return toolbox; +} + +async function closeToolboxForTab(tab) { + await gDevTools.closeToolboxForTab(tab); + const tabUrl = tab.linkedBrowser.currentURI.spec; + info(`Developer toolbox closed for tab "${tabUrl}"`); +} + +function assertDevToolsExtensionEnabled(uuid, enabled) { + for (let toolbox of DevToolsShim.getToolboxes()) { + is( + enabled, + !!toolbox.isWebExtensionEnabled(uuid), + `extension is ${enabled ? "enabled" : "disabled"} on toolbox` + ); + } +} + +/** + * Navigate the currently selected tab to a new URL and wait for it to load. + * Also wait for the toolbox to attach to the new target, if we navigated + * to a new process. + * + * @param {object} tab The tab to redirect. + * @param {string} uri The url to be loaded in the current tab. + * @param {boolean} isErrorPage You may pass `true` is the URL is an error + * page. Otherwise BrowserTestUtils.browserLoaded will wait + * for 'load' event, which never fires for error pages. + * + * @returns {Promise} A promise that resolves when the page has fully loaded. + */ +async function navigateToWithDevToolsOpen(tab, uri, isErrorPage = false) { + const toolbox = gDevTools.getToolboxForTab(tab); + const target = toolbox.target; + + // If we're switching origins, we need to wait for the 'switched-target' + // event to make sure everything is ready. + // Navigating from/to pages loaded in the parent process, like about:robots, + // also spawn new targets. + // (If target switching is disabled, the toolbox will reboot) + const onTargetSwitched = + toolbox.commands.targetCommand.once("switched-target"); + // Otherwise, if we don't switch target, it is safe to wait for navigate event. + const onNavigate = target.once("navigate"); + + // If the current top-level target follows the window global lifecycle, a + // target switch will occur regardless of process changes. + const targetFollowsWindowLifecycle = + target.targetForm.followWindowGlobalLifeCycle; + + info(`Load document "${uri}"`); + const browser = gBrowser.selectedBrowser; + const currentPID = browser.browsingContext.currentWindowGlobal.osPid; + const currentBrowsingContextID = browser.browsingContext.id; + const onBrowserLoaded = BrowserTestUtils.browserLoaded( + browser, + false, + null, + isErrorPage + ); + BrowserTestUtils.startLoadingURIString(browser, uri); + + info(`Waiting for page to be loaded…`); + await onBrowserLoaded; + info(`→ page loaded`); + + // Compare the PIDs (and not the toolbox's targets) as PIDs are updated also immediately, + // while target may be updated slightly later. + const switchedToAnotherProcess = + currentPID !== browser.browsingContext.currentWindowGlobal.osPid; + const switchedToAnotherBrowsingContext = + currentBrowsingContextID !== browser.browsingContext.id; + + // If: + // - the tab navigated to another process, or, + // - the tab navigated to another browsing context, or, + // - if the old target follows the window lifecycle + // then, expect a target switching. + if ( + switchedToAnotherProcess || + targetFollowsWindowLifecycle || + switchedToAnotherBrowsingContext + ) { + info(`Waiting for target switch…`); + await onTargetSwitched; + info(`→ switched-target emitted`); + } else { + info(`Waiting for target 'navigate' event…`); + await onNavigate; + info(`→ 'navigate' emitted`); + } +} diff --git a/browser/components/extensions/test/browser/head_pageAction.js b/browser/components/extensions/test/browser/head_pageAction.js new file mode 100644 index 0000000000..f80a6d3c98 --- /dev/null +++ b/browser/components/extensions/test/browser/head_pageAction.js @@ -0,0 +1,232 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported runTests */ +// This file is imported into the same scope as head.js. +/* import-globals-from head.js */ + +{ + // At the moment extension language negotiation is tied to Firefox language + // negotiation result. That means that to test an extension in `es-ES`, we need + // to mock `es-ES` being available in Firefox and then request it. + // + // In the future, we should provide some way for tests to decouple their + // language selection from that of Firefox. + const avLocales = Services.locale.availableLocales; + + Services.locale.availableLocales = ["en-US", "es-ES"]; + registerCleanupFunction(() => { + Services.locale.availableLocales = avLocales; + }); +} + +async function runTests(options) { + function background(getTests) { + let tests; + + // Gets the current details of the page action, and returns a + // promise that resolves to an object containing them. + async function getDetails(tabId) { + return { + title: await browser.pageAction.getTitle({ tabId }), + popup: await browser.pageAction.getPopup({ tabId }), + isShown: await browser.pageAction.isShown({ tabId }), + }; + } + + // Runs the next test in the `tests` array, checks the results, + // and passes control back to the outer test scope. + function nextTest() { + let test = tests.shift(); + + test(async expecting => { + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + let { id: tabId, windowId, url } = tab; + + browser.test.log(`Get details: tab={id: ${tabId}, url: ${url}}`); + + // Check that the API returns the expected values, and then + // run the next test. + let details = await getDetails(tabId); + if (expecting) { + browser.test.assertEq( + expecting.title, + details.title, + "expected value from getTitle" + ); + + browser.test.assertEq( + expecting.popup, + details.popup, + "expected value from getPopup" + ); + } + + browser.test.assertEq( + !!expecting, + details.isShown, + "expected value from isShown" + ); + + // Check that the actual icon has the expected values, then + // run the next test. + browser.test.sendMessage("nextTest", expecting, windowId, tests.length); + }); + } + + async function runTests() { + let tabs = []; + let windows = []; + tests = getTests(tabs, windows); + + let resultTabs = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + + tabs[0] = resultTabs[0].id; + windows[0] = resultTabs[0].windowId; + + nextTest(); + } + + browser.test.onMessage.addListener(msg => { + if (msg == "runTests") { + runTests(); + } else if (msg == "runNextTest") { + nextTest(); + } else { + browser.test.fail(`Unexpected message: ${msg}`); + } + }); + + runTests(); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: options.manifest, + + files: options.files || {}, + + background: `(${background})(${options.getTests})`, + }); + + let pageActionId; + let currentWindow = window; + let windows = []; + + async function waitForDetails(details, windowId) { + function check() { + let { document } = Services.wm.getOuterWindowWithId(windowId); + let image = document.getElementById(pageActionId); + if (details == null) { + return image == null || image.getAttribute("disabled") == "true"; + } + let title = details.title || options.manifest.name; + return ( + !!image && + getListStyleImage(image) == details.icon && + image.getAttribute("tooltiptext") == title && + image.getAttribute("aria-label") == title + ); + // TODO: Popup URL. If this is updated, modify also checkDetails. + } + + // eslint-disable-next-line no-async-promise-executor + return new Promise(async resolve => { + let maxCounter = 10; + while (!check() && --maxCounter > 0) { + info("checks left: " + maxCounter); + await promiseAnimationFrame(currentWindow); + } + resolve(); + }); + } + + function checkDetails(details, windowId) { + let { document } = Services.wm.getOuterWindowWithId(windowId); + let image = document.getElementById(pageActionId); + if (details == null) { + ok( + image == null || image.getAttribute("disabled") == "true", + "image is disabled" + ); + } else { + ok(image, "image exists"); + + is(getListStyleImage(image), details.icon, "icon URL is correct"); + + let title = details.title || options.manifest.name; + is(image.getAttribute("tooltiptext"), title, "image title is correct"); + is( + image.getAttribute("aria-label"), + title, + "image aria-label is correct" + ); + // TODO: Popup URL. If this is updated, modify also waitForDetails. + } + } + + let testNewWindows = 1; + + let awaitFinish = new Promise(resolve => { + extension.onMessage( + "nextTest", + async (expecting, windowId, testsRemaining) => { + if (!pageActionId) { + pageActionId = BrowserPageActions.urlbarButtonNodeIDForActionID( + makeWidgetId(extension.id) + ); + } + + await waitForDetails(expecting, windowId); + + checkDetails(expecting, windowId); + + if (testsRemaining) { + extension.sendMessage("runNextTest"); + } else if (testNewWindows) { + testNewWindows--; + + BrowserTestUtils.openNewBrowserWindow() + .then(window => { + windows.push(window); + currentWindow = window; + return focusWindow(window); + }) + .then(() => { + extension.sendMessage("runTests"); + }); + } else { + resolve(); + } + } + ); + }); + + let reqLoc = Services.locale.requestedLocales; + Services.locale.requestedLocales = ["es-ES"]; + + await extension.startup(); + + await awaitFinish; + + await extension.unload(); + + Services.locale.requestedLocales = reqLoc; + + let node = document.getElementById(pageActionId); + is(node, null, "pageAction image removed from document"); + + currentWindow = null; + for (let win of windows.splice(0)) { + node = win.document.getElementById(pageActionId); + is(node, null, "pageAction image removed from second document"); + + await BrowserTestUtils.closeWindow(win); + } +} diff --git a/browser/components/extensions/test/browser/head_sessions.js b/browser/components/extensions/test/browser/head_sessions.js new file mode 100644 index 0000000000..db58c128c6 --- /dev/null +++ b/browser/components/extensions/test/browser/head_sessions.js @@ -0,0 +1,64 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported recordInitialTimestamps onlyNewItemsFilter checkRecentlyClosed */ + +let initialTimestamps = []; + +function recordInitialTimestamps(timestamps) { + initialTimestamps = timestamps; +} + +function onlyNewItemsFilter(item) { + return !initialTimestamps.includes(item.lastModified); +} + +function checkWindow(window) { + for (let prop of ["focused", "incognito", "alwaysOnTop"]) { + is(window[prop], false, `closed window has the expected value for ${prop}`); + } + for (let prop of ["state", "type"]) { + is( + window[prop], + "normal", + `closed window has the expected value for ${prop}` + ); + } +} + +function checkTab(tab, windowId, incognito) { + for (let prop of ["highlighted", "active", "pinned"]) { + is(tab[prop], false, `closed tab has the expected value for ${prop}`); + } + is(tab.windowId, windowId, "closed tab has the expected value for windowId"); + is( + tab.incognito, + incognito, + "closed tab has the expected value for incognito" + ); +} + +function checkRecentlyClosed( + recentlyClosed, + expectedCount, + windowId, + incognito = false +) { + let sessionIds = new Set(); + is( + recentlyClosed.length, + expectedCount, + "the expected number of closed tabs/windows was found" + ); + for (let item of recentlyClosed) { + if (item.window) { + sessionIds.add(item.window.sessionId); + checkWindow(item.window); + } else if (item.tab) { + sessionIds.add(item.tab.sessionId); + checkTab(item.tab, windowId, incognito); + } + } + is(sessionIds.size, expectedCount, "each item has a unique sessionId"); +} diff --git a/browser/components/extensions/test/browser/head_unified_extensions.js b/browser/components/extensions/test/browser/head_unified_extensions.js new file mode 100644 index 0000000000..941a00a5fb --- /dev/null +++ b/browser/components/extensions/test/browser/head_unified_extensions.js @@ -0,0 +1,199 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* exported clickUnifiedExtensionsItem, + closeExtensionsPanel, + createExtensions, + ensureMaximizedWindow, + getMessageBars, + getUnifiedExtensionsItem, + openExtensionsPanel, + openUnifiedExtensionsContextMenu, + promiseSetToolbarVisibility +*/ + +const getListView = (win = window) => { + const { panel } = win.gUnifiedExtensions; + ok(panel, "expected panel to be created"); + return panel.querySelector("#unified-extensions-view"); +}; + +const openExtensionsPanel = async (win = window) => { + const { button } = win.gUnifiedExtensions; + ok(button, "expected button"); + + const listView = getListView(win); + ok(listView, "expected list view"); + + const viewShown = BrowserTestUtils.waitForEvent(listView, "ViewShown"); + button.click(); + await viewShown; +}; + +const closeExtensionsPanel = async (win = window) => { + const { button } = win.gUnifiedExtensions; + ok(button, "expected button"); + + const hidden = BrowserTestUtils.waitForEvent( + win.gUnifiedExtensions.panel, + "popuphidden", + true + ); + button.click(); + await hidden; +}; + +const getUnifiedExtensionsItem = (extensionId, win = window) => { + const view = getListView(win); + + // First try to find a CUI widget, otherwise a custom element when the + // extension does not have a browser action. + return ( + view.querySelector(`toolbaritem[data-extensionid="${extensionId}"]`) || + view.querySelector(`unified-extensions-item[extension-id="${extensionId}"]`) + ); +}; + +const openUnifiedExtensionsContextMenu = async (extensionId, win = window) => { + const item = getUnifiedExtensionsItem(extensionId, win); + ok(item, `expected item for extensionId=${extensionId}`); + const button = item.querySelector(".unified-extensions-item-menu-button"); + ok(button, "expected menu button"); + // Make sure the button is visible before clicking on it (below) since the + // list of extensions can have a scrollbar (when there are many extensions + // and/or the window is small-ish). + button.scrollIntoView({ block: "center" }); + + const menu = win.document.getElementById("unified-extensions-context-menu"); + ok(menu, "expected menu"); + + const shown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + // Use primary button click to open the context menu. + EventUtils.synthesizeMouseAtCenter(button, {}, win); + await shown; + + return menu; +}; + +const clickUnifiedExtensionsItem = async ( + win, + extensionId, + forceEnableButton = false +) => { + // The panel should be closed automatically when we click an extension item. + await openExtensionsPanel(win); + + const item = getUnifiedExtensionsItem(extensionId, win); + ok(item, `expected item for ${extensionId}`); + + // The action button should be disabled when users aren't supposed to click + // on it but it might still be useful to re-enable it for testing purposes. + if (forceEnableButton) { + let actionButton = item.querySelector( + ".unified-extensions-item-action-button" + ); + actionButton.disabled = false; + ok(!actionButton.disabled, "action button was force-enabled"); + } + + // Similar to `openUnifiedExtensionsContextMenu()`, we make sure the item is + // visible before clicking on it to prevent intermittents. + item.scrollIntoView({ block: "center" }); + + const popupHidden = BrowserTestUtils.waitForEvent( + win.document, + "popuphidden", + true + ); + EventUtils.synthesizeMouseAtCenter(item, {}, win); + await popupHidden; +}; + +const createExtensions = ( + arrayOfManifestData, + { useAddonManager = true, incognitoOverride, files } = {} +) => { + return arrayOfManifestData.map(manifestData => + ExtensionTestUtils.loadExtension({ + manifest: { + name: "default-extension-name", + ...manifestData, + }, + useAddonManager: useAddonManager ? "temporary" : undefined, + incognitoOverride, + files, + }) + ); +}; + +/** + * Given a window, this test helper resizes it so that the window takes most of + * the available screen size (unless the window is already maximized). + */ +const ensureMaximizedWindow = async win => { + info("ensuring maximized window..."); + + // Make sure we wait for window position to have settled + // to avoid unexpected failures. + let samePositionTimes = 0; + let lastScreenTop = win.screen.top; + let lastScreenLeft = win.screen.left; + win.moveTo(0, 0); + await TestUtils.waitForCondition(() => { + let isSamePosition = + lastScreenTop === win.screen.top && lastScreenLeft === win.screen.left; + if (!isSamePosition) { + lastScreenTop = win.screen.top; + lastScreenLeft = win.screen.left; + } + samePositionTimes = isSamePosition ? samePositionTimes + 1 : 0; + return samePositionTimes === 10; + }, "Wait for the chrome window position to settle"); + + const widthDiff = Math.max(win.screen.availWidth - win.outerWidth, 0); + const heightDiff = Math.max(win.screen.availHeight - win.outerHeight, 0); + + if (widthDiff || heightDiff) { + info( + `resizing window... widthDiff=${widthDiff} - heightDiff=${heightDiff}` + ); + win.windowUtils.ensureDirtyRootFrame(); + win.resizeBy(widthDiff, heightDiff); + } else { + info(`not resizing window!`); + } + + // Make sure we wait for window size to have settled. + let lastOuterWidth = win.outerWidth; + let lastOuterHeight = win.outerHeight; + let sameSizeTimes = 0; + await TestUtils.waitForCondition(() => { + const isSameSize = + win.outerWidth === lastOuterWidth && win.outerHeight === lastOuterHeight; + if (!isSameSize) { + lastOuterWidth = win.outerWidth; + lastOuterHeight = win.outerHeight; + } + sameSizeTimes = isSameSize ? sameSizeTimes + 1 : 0; + return sameSizeTimes === 10; + }, "Wait for the chrome window size to settle"); +}; + +const promiseSetToolbarVisibility = (toolbar, visible) => { + const visibilityChanged = BrowserTestUtils.waitForMutationCondition( + toolbar, + { attributeFilter: ["collapsed"] }, + () => toolbar.collapsed != visible + ); + setToolbarVisibility(toolbar, visible, undefined, false); + return visibilityChanged; +}; + +const getMessageBars = (win = window) => { + const { panel } = win.gUnifiedExtensions; + return panel.querySelectorAll( + "#unified-extensions-messages-container > moz-message-bar" + ); +}; diff --git a/browser/components/extensions/test/browser/head_webNavigation.js b/browser/components/extensions/test/browser/head_webNavigation.js new file mode 100644 index 0000000000..314ddc9326 --- /dev/null +++ b/browser/components/extensions/test/browser/head_webNavigation.js @@ -0,0 +1,49 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported BASE_URL, SOURCE_PAGE, OPENED_PAGE, + runCreatedNavigationTargetTest */ + +const BASE_URL = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser"; +const SOURCE_PAGE = `${BASE_URL}/webNav_createdTargetSource.html`; +const OPENED_PAGE = `${BASE_URL}/webNav_createdTarget.html`; + +async function runCreatedNavigationTargetTest({ + extension, + openNavTarget, + expectedWebNavProps, +}) { + await openNavTarget(); + + const webNavMsg = await extension.awaitMessage("webNavOnCreated"); + const createdTabId = await extension.awaitMessage("tabsOnCreated"); + const completedNavMsg = await extension.awaitMessage("webNavOnCompleted"); + + let { sourceTabId, sourceFrameId, url } = expectedWebNavProps; + + is(webNavMsg.tabId, createdTabId, "Got the expected tabId property"); + is( + webNavMsg.sourceTabId, + sourceTabId, + "Got the expected sourceTabId property" + ); + is( + webNavMsg.sourceFrameId, + sourceFrameId, + "Got the expected sourceFrameId property" + ); + is(webNavMsg.url, url, "Got the expected url property"); + + is( + completedNavMsg.tabId, + createdTabId, + "Got the expected webNavigation.onCompleted tabId property" + ); + is( + completedNavMsg.url, + url, + "Got the expected webNavigation.onCompleted url property" + ); +} diff --git a/browser/components/extensions/test/browser/redirect_to.sjs b/browser/components/extensions/test/browser/redirect_to.sjs new file mode 100644 index 0000000000..a07747efbe --- /dev/null +++ b/browser/components/extensions/test/browser/redirect_to.sjs @@ -0,0 +1,9 @@ +"use strict"; + +function handleRequest(request, response) { + // redirect_to.sjs?ctxmenu-image.png + // redirects to : ctxmenu-image.png + let redirectUrl = request.queryString; + response.setStatusLine(request.httpVersion, "302", "Found"); + response.setHeader("Location", redirectUrl, false); +} diff --git a/browser/components/extensions/test/browser/search-engines/another/manifest.json b/browser/components/extensions/test/browser/search-engines/another/manifest.json new file mode 100644 index 0000000000..0f78854853 --- /dev/null +++ b/browser/components/extensions/test/browser/search-engines/another/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "another", + "manifest_version": 2, + "version": "1.0", + "description": "another", + "browser_specific_settings": { + "gecko": { + "id": "another@search.mozilla.org" + } + }, + "hidden": true, + "chrome_settings_overrides": { + "search_provider": { + "name": "another", + "search_url": "https://mochi.test:8888/browser/browser/components/search/test/browser/?search={searchTerms}&bar=1", + "suggest_url": "https://mochi.test:8888/browser/browser/modules/test/browser/usageTelemetrySearchSuggestions.sjs?{searchTerms}" + } + } +} diff --git a/browser/components/extensions/test/browser/search-engines/basic/manifest.json b/browser/components/extensions/test/browser/search-engines/basic/manifest.json new file mode 100644 index 0000000000..96b29935cf --- /dev/null +++ b/browser/components/extensions/test/browser/search-engines/basic/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "basic", + "manifest_version": 2, + "version": "1.0", + "description": "basic", + "browser_specific_settings": { + "gecko": { + "id": "basic@search.mozilla.org" + } + }, + "hidden": true, + "chrome_settings_overrides": { + "search_provider": { + "name": "basic", + "search_url": "https://mochi.test:8888/browser/browser/components/search/test/browser/?search={searchTerms}&foo=1", + "suggest_url": "https://mochi.test:8888/browser/browser/modules/test/browser/usageTelemetrySearchSuggestions.sjs?{searchTerms}" + } + } +} diff --git a/browser/components/extensions/test/browser/search-engines/engines.json b/browser/components/extensions/test/browser/search-engines/engines.json new file mode 100644 index 0000000000..907aaa148f --- /dev/null +++ b/browser/components/extensions/test/browser/search-engines/engines.json @@ -0,0 +1,38 @@ +{ + "data": [ + { + "webExtension": { + "id": "basic@search.mozilla.org", + "name": "basic", + "search_url": "https://mochi.test:8888/browser/browser/components/search/test/browser/?search={searchTerms}&foo=1", + "suggest_url": "https://mochi.test:8888/browser/browser/modules/test/browser/usageTelemetrySearchSuggestions.sjs?{searchTerms}" + }, + "appliesTo": [ + { + "included": { "everywhere": true }, + "default": "yes" + } + ] + }, + { + "webExtension": { + "id": "simple@search.mozilla.org" + }, + "appliesTo": [ + { + "included": { "everywhere": true } + } + ] + }, + { + "webExtension": { + "id": "another@search.mozilla.org" + }, + "appliesTo": [ + { + "included": { "everywhere": true } + } + ] + } + ] +} diff --git a/browser/components/extensions/test/browser/search-engines/simple/manifest.json b/browser/components/extensions/test/browser/search-engines/simple/manifest.json new file mode 100644 index 0000000000..67d2974753 --- /dev/null +++ b/browser/components/extensions/test/browser/search-engines/simple/manifest.json @@ -0,0 +1,29 @@ +{ + "name": "Simple Engine", + "manifest_version": 2, + "version": "1.0", + "description": "Simple engine with a different name from the WebExtension id prefix", + "browser_specific_settings": { + "gecko": { + "id": "simple@search.mozilla.org" + } + }, + "hidden": true, + "chrome_settings_overrides": { + "search_provider": { + "name": "Simple Engine", + "search_url": "https://example.com", + "params": [ + { + "name": "sourceId", + "value": "Mozilla-search" + }, + { + "name": "search", + "value": "{searchTerms}" + } + ], + "suggest_url": "https://example.com?search={searchTerms}" + } + } +} diff --git a/browser/components/extensions/test/browser/searchSuggestionEngine.sjs b/browser/components/extensions/test/browser/searchSuggestionEngine.sjs new file mode 100644 index 0000000000..a356cbb1db --- /dev/null +++ b/browser/components/extensions/test/browser/searchSuggestionEngine.sjs @@ -0,0 +1,10 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +function handleRequest(req, resp) { + let suffixes = ["foo", "bar"]; + let data = [req.queryString, suffixes.map(s => req.queryString + s)]; + resp.setHeader("Content-Type", "application/json", false); + resp.write(JSON.stringify(data)); +} diff --git a/browser/components/extensions/test/browser/searchSuggestionEngine.xml b/browser/components/extensions/test/browser/searchSuggestionEngine.xml new file mode 100644 index 0000000000..703d459256 --- /dev/null +++ b/browser/components/extensions/test/browser/searchSuggestionEngine.xml @@ -0,0 +1,9 @@ + + + + +browser_searchSuggestionEngine searchSuggestionEngine.xml + + + diff --git a/browser/components/extensions/test/browser/silence.ogg b/browser/components/extensions/test/browser/silence.ogg new file mode 100644 index 0000000000..7bdd68ab27 Binary files /dev/null and b/browser/components/extensions/test/browser/silence.ogg differ diff --git a/browser/components/extensions/test/browser/wait-a-bit.sjs b/browser/components/extensions/test/browser/wait-a-bit.sjs new file mode 100644 index 0000000000..e90133d752 --- /dev/null +++ b/browser/components/extensions/test/browser/wait-a-bit.sjs @@ -0,0 +1,23 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +async function handleRequest(request, response) { + response.seizePower(); + + await new Promise(r => setTimeout(r, 2000)); + + response.write("HTTP/1.1 200 OK\r\n"); + const body = "wait a bitok"; + response.write("Content-Type: text/html\r\n"); + response.write(`Content-Length: ${body.length}\r\n`); + response.write("\r\n"); + response.write(body); + response.finish(); +} diff --git a/browser/components/extensions/test/browser/webNav_createdTarget.html b/browser/components/extensions/test/browser/webNav_createdTarget.html new file mode 100644 index 0000000000..e8a985ef28 --- /dev/null +++ b/browser/components/extensions/test/browser/webNav_createdTarget.html @@ -0,0 +1,10 @@ + + + + WebNavigatio onCreatedNavigationTarget target + + + + Go back to the source page + + diff --git a/browser/components/extensions/test/browser/webNav_createdTargetSource.html b/browser/components/extensions/test/browser/webNav_createdTargetSource.html new file mode 100644 index 0000000000..72d4aa56f5 --- /dev/null +++ b/browser/components/extensions/test/browser/webNav_createdTargetSource.html @@ -0,0 +1,45 @@ + + + + WebNavigatio onCreatedNavigationTarget source + + + + + + + + diff --git a/browser/components/extensions/test/browser/webNav_createdTargetSource_subframe.html b/browser/components/extensions/test/browser/webNav_createdTargetSource_subframe.html new file mode 100644 index 0000000000..7a9e9ebc4a --- /dev/null +++ b/browser/components/extensions/test/browser/webNav_createdTargetSource_subframe.html @@ -0,0 +1,42 @@ + + + + WebNavigatio onCreatedNavigationTarget source subframe + + + + + + diff --git a/browser/components/extensions/test/mochitest/.eslintrc.js b/browser/components/extensions/test/mochitest/.eslintrc.js new file mode 100644 index 0000000000..7802d13962 --- /dev/null +++ b/browser/components/extensions/test/mochitest/.eslintrc.js @@ -0,0 +1,8 @@ +"use strict"; + +module.exports = { + env: { + browser: true, + webextensions: true, + }, +}; diff --git a/browser/components/extensions/test/mochitest/mochitest.toml b/browser/components/extensions/test/mochitest/mochitest.toml new file mode 100644 index 0000000000..bc8bd0d40a --- /dev/null +++ b/browser/components/extensions/test/mochitest/mochitest.toml @@ -0,0 +1,14 @@ +[DEFAULT] +skip-if = ["os == 'android'"] # bug 1730213 +support-files = [ + "../../../../../toolkit/components/extensions/test/mochitest/test_ext_all_apis.js", + "../../../../../toolkit/components/extensions/test/mochitest/file_sample.html", +] +tags = "webextensions" +prefs = ["javascript.options.asyncstack_capture_debuggee_only=false"] + +["test_ext_all_apis.html"] +skip-if = [ + "http3", + "http2", +] diff --git a/browser/components/extensions/test/mochitest/test_ext_all_apis.html b/browser/components/extensions/test/mochitest/test_ext_all_apis.html new file mode 100644 index 0000000000..0433dc5b7e --- /dev/null +++ b/browser/components/extensions/test/mochitest/test_ext_all_apis.html @@ -0,0 +1,83 @@ + + + + WebExtension test + + + + + + + + + + + diff --git a/browser/components/extensions/test/xpcshell/.eslintrc.js b/browser/components/extensions/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..3622fff4f6 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/.eslintrc.js @@ -0,0 +1,9 @@ +"use strict"; + +module.exports = { + env: { + // The tests in this folder are testing based on WebExtensions, so lets + // just define the webextensions environment here. + webextensions: true, + }, +}; diff --git a/browser/components/extensions/test/xpcshell/data/test/manifest.json b/browser/components/extensions/test/xpcshell/data/test/manifest.json new file mode 100644 index 0000000000..b14c90e9c4 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/data/test/manifest.json @@ -0,0 +1,80 @@ +{ + "name": "MozParamsTest", + "manifest_version": 2, + "version": "1.0", + "browser_specific_settings": { + "gecko": { + "id": "test@search.mozilla.org" + } + }, + "description": "A test search engine (based on Google search)", + "chrome_settings_overrides": { + "search_provider": { + "name": "MozParamsTest", + "search_url": "https://example.com/?q={searchTerms}", + "params": [ + { + "name": "test-0", + "condition": "purpose", + "purpose": "contextmenu", + "value": "0" + }, + { + "name": "test-1", + "condition": "purpose", + "purpose": "searchbar", + "value": "1" + }, + { + "name": "test-2", + "condition": "purpose", + "purpose": "homepage", + "value": "2" + }, + { + "name": "test-3", + "condition": "purpose", + "purpose": "keyword", + "value": "3" + }, + { + "name": "test-4", + "condition": "purpose", + "purpose": "newtab", + "value": "4" + }, + { + "name": "simple", + "value": "5" + }, + { + "name": "term", + "value": "{searchTerms}" + }, + { + "name": "lang", + "value": "{language}" + }, + { + "name": "locale", + "value": "{moz:locale}" + }, + { + "name": "prefval", + "condition": "pref", + "pref": "code" + }, + { + "name": "experimenter-1", + "condition": "pref", + "pref": "nimbus-key-1" + }, + { + "name": "experimenter-2", + "condition": "pref", + "pref": "nimbus-key-2" + } + ] + } + } +} diff --git a/browser/components/extensions/test/xpcshell/data/test2/manifest.json b/browser/components/extensions/test/xpcshell/data/test2/manifest.json new file mode 100644 index 0000000000..197a993189 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/data/test2/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "MozParamsTest2", + "manifest_version": 2, + "version": "1.0", + "browser_specific_settings": { + "gecko": { + "id": "test2@search.mozilla.org" + } + }, + "description": "A second test search engine", + "chrome_settings_overrides": { + "search_provider": { + "name": "MozParamsTest2", + "search_url": "https://example.com/2/?q={searchTerms}", + "params": [ + { + "name": "simple2", + "value": "5" + } + ] + } + } +} diff --git a/browser/components/extensions/test/xpcshell/head.js b/browser/components/extensions/test/xpcshell/head.js new file mode 100644 index 0000000000..9ac33637ed --- /dev/null +++ b/browser/components/extensions/test/xpcshell/head.js @@ -0,0 +1,78 @@ +"use strict"; + +/* exported createHttpServer, promiseConsoleOutput, assertPersistentListeners */ + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +// eslint-disable-next-line no-unused-vars +ChromeUtils.defineESModuleGetters(this, { + Extension: "resource://gre/modules/Extension.sys.mjs", + ExtensionData: "resource://gre/modules/Extension.sys.mjs", + ExtensionTestUtils: + "resource://testing-common/ExtensionXPCShellUtils.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + HttpServer: "resource://testing-common/httpd.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +ExtensionTestUtils.init(this); + +// Persistent Listener test functionality +const { assertPersistentListeners } = ExtensionTestUtils.testAssertions; + +/** + * Creates a new HttpServer for testing, and begins listening on the + * specified port. Automatically shuts down the server when the test + * unit ends. + * + * @param {integer} [port] + * The port to listen on. If omitted, listen on a random + * port. The latter is the preferred behavior. + * + * @returns {HttpServer} + */ +function createHttpServer(port = -1) { + let server = new HttpServer(); + server.start(port); + + registerCleanupFunction(() => { + return new Promise(resolve => { + server.stop(resolve); + }); + }); + + return server; +} + +var promiseConsoleOutput = async function (task) { + const DONE = `=== console listener ${Math.random()} done ===`; + + let listener; + let messages = []; + let awaitListener = new Promise(resolve => { + listener = msg => { + if (msg == DONE) { + resolve(); + } else { + void (msg instanceof Ci.nsIConsoleMessage); + messages.push(msg); + } + }; + }); + + Services.console.registerListener(listener); + try { + let result = await task(); + + Services.console.logStringMessage(DONE); + await awaitListener; + + return { messages, result }; + } finally { + Services.console.unregisterListener(listener); + } +}; diff --git a/browser/components/extensions/test/xpcshell/test_ext_bookmarks.js b/browser/components/extensions/test/xpcshell/test_ext_bookmarks.js new file mode 100644 index 0000000000..15d09d1163 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_bookmarks.js @@ -0,0 +1,1725 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +add_task(async function test_bookmarks() { + async function background() { + let unsortedId, ourId; + let initialBookmarkCount = 0; + let createdBookmarks = new Set(); + let createdFolderId; + let createdSeparatorId; + let collectedEvents = []; + const nonExistentId = "000000000000"; + const bookmarkGuids = { + menuGuid: "menu________", + toolbarGuid: "toolbar_____", + unfiledGuid: "unfiled_____", + rootGuid: "root________", + }; + + function checkOurBookmark(bookmark) { + browser.test.assertEq(ourId, bookmark.id, "Bookmark has the expected Id"); + browser.test.assertTrue( + "parentId" in bookmark, + "Bookmark has a parentId" + ); + browser.test.assertEq( + 0, + bookmark.index, + "Bookmark has the expected index" + ); // We assume there are no other bookmarks. + browser.test.assertEq( + "http://example.org/", + bookmark.url, + "Bookmark has the expected url" + ); + browser.test.assertEq( + "test bookmark", + bookmark.title, + "Bookmark has the expected title" + ); + browser.test.assertTrue( + "dateAdded" in bookmark, + "Bookmark has a dateAdded" + ); + browser.test.assertFalse( + "dateGroupModified" in bookmark, + "Bookmark does not have a dateGroupModified" + ); + browser.test.assertFalse( + "unmodifiable" in bookmark, + "Bookmark is not unmodifiable" + ); + browser.test.assertEq( + "bookmark", + bookmark.type, + "Bookmark is of type bookmark" + ); + } + + function checkBookmark(expected, bookmark) { + browser.test.assertEq( + expected.url, + bookmark.url, + "Bookmark has the expected url" + ); + browser.test.assertEq( + expected.title, + bookmark.title, + "Bookmark has the expected title" + ); + browser.test.assertEq( + expected.index, + bookmark.index, + "Bookmark has expected index" + ); + browser.test.assertEq( + "bookmark", + bookmark.type, + "Bookmark is of type bookmark" + ); + if ("parentId" in expected) { + browser.test.assertEq( + expected.parentId, + bookmark.parentId, + "Bookmark has the expected parentId" + ); + } + } + + function checkOnCreated( + id, + parentId, + index, + title, + url, + dateAdded, + type = "bookmark" + ) { + let createdData = collectedEvents.pop(); + browser.test.assertEq( + "onCreated", + createdData.event, + "onCreated was the last event received" + ); + browser.test.assertEq( + id, + createdData.id, + "onCreated event received the expected id" + ); + let bookmark = createdData.bookmark; + browser.test.assertEq( + id, + bookmark.id, + "onCreated event received the expected bookmark id" + ); + browser.test.assertEq( + parentId, + bookmark.parentId, + "onCreated event received the expected bookmark parentId" + ); + browser.test.assertEq( + index, + bookmark.index, + "onCreated event received the expected bookmark index" + ); + browser.test.assertEq( + title, + bookmark.title, + "onCreated event received the expected bookmark title" + ); + browser.test.assertEq( + url, + bookmark.url, + "onCreated event received the expected bookmark url" + ); + browser.test.assertEq( + dateAdded, + bookmark.dateAdded, + "onCreated event received the expected bookmark dateAdded" + ); + browser.test.assertEq( + type, + bookmark.type, + "onCreated event received the expected bookmark type" + ); + } + + function checkOnChanged(id, url, title) { + // If both url and title are changed, then url is fired last. + let changedData = collectedEvents.pop(); + browser.test.assertEq( + "onChanged", + changedData.event, + "onChanged was the last event received" + ); + browser.test.assertEq( + id, + changedData.id, + "onChanged event received the expected id" + ); + browser.test.assertEq( + url, + changedData.info.url, + "onChanged event received the expected url" + ); + // title is fired first. + changedData = collectedEvents.pop(); + browser.test.assertEq( + "onChanged", + changedData.event, + "onChanged was the last event received" + ); + browser.test.assertEq( + id, + changedData.id, + "onChanged event received the expected id" + ); + browser.test.assertEq( + title, + changedData.info.title, + "onChanged event received the expected title" + ); + } + + function checkOnMoved(id, parentId, oldParentId, index, oldIndex) { + let movedData = collectedEvents.pop(); + browser.test.assertEq( + "onMoved", + movedData.event, + "onMoved was the last event received" + ); + browser.test.assertEq( + id, + movedData.id, + "onMoved event received the expected id" + ); + let info = movedData.info; + browser.test.assertEq( + parentId, + info.parentId, + "onMoved event received the expected parentId" + ); + browser.test.assertEq( + oldParentId, + info.oldParentId, + "onMoved event received the expected oldParentId" + ); + browser.test.assertEq( + index, + info.index, + "onMoved event received the expected index" + ); + browser.test.assertEq( + oldIndex, + info.oldIndex, + "onMoved event received the expected oldIndex" + ); + } + + function checkOnRemoved(id, parentId, index, title, url, type = "folder") { + let removedData = collectedEvents.pop(); + browser.test.assertEq( + "onRemoved", + removedData.event, + "onRemoved was the last event received" + ); + browser.test.assertEq( + id, + removedData.id, + "onRemoved event received the expected id" + ); + let info = removedData.info; + browser.test.assertEq( + parentId, + removedData.info.parentId, + "onRemoved event received the expected parentId" + ); + browser.test.assertEq( + index, + removedData.info.index, + "onRemoved event received the expected index" + ); + let node = info.node; + browser.test.assertEq( + id, + node.id, + "onRemoved event received the expected node id" + ); + browser.test.assertEq( + parentId, + node.parentId, + "onRemoved event received the expected node parentId" + ); + browser.test.assertEq( + index, + node.index, + "onRemoved event received the expected node index" + ); + browser.test.assertEq( + url, + node.url, + "onRemoved event received the expected node url" + ); + browser.test.assertEq( + title, + node.title, + "onRemoved event received the expected node title" + ); + browser.test.assertEq( + type, + node.type, + "onRemoved event received the expected node type" + ); + } + + browser.bookmarks.onChanged.addListener((id, info) => { + collectedEvents.push({ event: "onChanged", id, info }); + }); + + browser.bookmarks.onCreated.addListener((id, bookmark) => { + collectedEvents.push({ event: "onCreated", id, bookmark }); + }); + + browser.bookmarks.onMoved.addListener((id, info) => { + collectedEvents.push({ event: "onMoved", id, info }); + }); + + browser.bookmarks.onRemoved.addListener((id, info) => { + collectedEvents.push({ event: "onRemoved", id, info }); + }); + + await browser.test.assertRejects( + browser.bookmarks.get(["not-a-bookmark-guid"]), + /Invalid value for property 'guid': "not-a-bookmark-guid"/, + "Expected error thrown when trying to get a bookmark using an invalid guid" + ); + + await browser.test + .assertRejects( + browser.bookmarks.get([nonExistentId]), + /Bookmark not found/, + "Expected error thrown when trying to get a bookmark using a non-existent Id" + ) + .then(() => { + return browser.bookmarks.search({}); + }) + .then(results => { + initialBookmarkCount = results.length; + return browser.bookmarks.create({ + title: "test bookmark", + url: "http://example.org", + type: "bookmark", + }); + }) + .then(result => { + ourId = result.id; + checkOurBookmark(result); + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected event received" + ); + checkOnCreated( + ourId, + bookmarkGuids.unfiledGuid, + 0, + "test bookmark", + "http://example.org/", + result.dateAdded + ); + + return browser.bookmarks.get(ourId); + }) + .then(results => { + browser.test.assertEq(results.length, 1); + checkOurBookmark(results[0]); + + unsortedId = results[0].parentId; + return browser.bookmarks.get(unsortedId); + }) + .then(results => { + let folder = results[0]; + browser.test.assertEq(1, results.length, "1 bookmark was returned"); + + browser.test.assertEq( + unsortedId, + folder.id, + "Folder has the expected id" + ); + browser.test.assertTrue("parentId" in folder, "Folder has a parentId"); + browser.test.assertTrue("index" in folder, "Folder has an index"); + browser.test.assertEq( + undefined, + folder.url, + "Folder does not have a url" + ); + browser.test.assertEq( + "Other Bookmarks", + folder.title, + "Folder has the expected title" + ); + browser.test.assertTrue( + "dateAdded" in folder, + "Folder has a dateAdded" + ); + browser.test.assertTrue( + "dateGroupModified" in folder, + "Folder has a dateGroupModified" + ); + browser.test.assertFalse( + "unmodifiable" in folder, + "Folder is not unmodifiable" + ); // TODO: Do we want to enable this? + browser.test.assertEq( + "folder", + folder.type, + "Folder has a type of folder" + ); + + return browser.bookmarks.getChildren(unsortedId); + }) + .then(async results => { + browser.test.assertEq(1, results.length, "The folder has one child"); + checkOurBookmark(results[0]); + + await browser.test.assertRejects( + browser.bookmarks.update(nonExistentId, { title: "new test title" }), + /No bookmarks found for the provided GUID/, + "Expected error thrown when trying to update a non-existent bookmark" + ); + return browser.bookmarks.update(ourId, { + title: "new test title", + url: "http://example.com/", + }); + }) + .then(async result => { + browser.test.assertEq( + "new test title", + result.title, + "Updated bookmark has the expected title" + ); + browser.test.assertEq( + "http://example.com/", + result.url, + "Updated bookmark has the expected URL" + ); + browser.test.assertEq( + ourId, + result.id, + "Updated bookmark has the expected id" + ); + browser.test.assertEq( + "bookmark", + result.type, + "Updated bookmark has a type of bookmark" + ); + + browser.test.assertEq( + 2, + collectedEvents.length, + "2 expected events received" + ); + checkOnChanged(ourId, "http://example.com/", "new test title"); + + await browser.test.assertRejects( + browser.bookmarks.update(ourId, { url: "this is not a valid url" }), + /Invalid bookmark:/, + "Expected error thrown when trying update with an invalid url" + ); + return browser.bookmarks.getTree(); + }) + .then(results => { + browser.test.assertEq(1, results.length, "getTree returns one result"); + let bookmark = results[0].children.find( + bookmarkItem => bookmarkItem.id == unsortedId + ); + browser.test.assertEq( + "Other Bookmarks", + bookmark.title, + "Folder returned from getTree has the expected title" + ); + browser.test.assertEq( + "folder", + bookmark.type, + "Folder returned from getTree has the expected type" + ); + + return browser.test.assertRejects( + browser.bookmarks.create({ parentId: "invalid" }), + error => + error.message.includes("Invalid bookmark") && + error.message.includes(`"parentGuid":"invalid"`), + "Expected error thrown when trying to create a bookmark with an invalid parentId" + ); + }) + .then(() => { + return browser.bookmarks.remove(ourId); + }) + .then(result => { + browser.test.assertEq( + undefined, + result, + "Removing a bookmark returns undefined" + ); + + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnRemoved( + ourId, + bookmarkGuids.unfiledGuid, + 0, + "new test title", + "http://example.com/", + "bookmark" + ); + + return browser.test.assertRejects( + browser.bookmarks.get(ourId), + /Bookmark not found/, + "Expected error thrown when trying to get a removed bookmark" + ); + }) + .then(() => { + return browser.test.assertRejects( + browser.bookmarks.remove(nonExistentId), + /No bookmarks found for the provided GUID/, + "Expected error thrown when trying removed a non-existent bookmark" + ); + }) + .then(() => { + // test bookmarks.search + return Promise.all([ + browser.bookmarks.create({ + title: "Μοζιλλας", + url: "http://møzîllä.örg/", + }), + browser.bookmarks.create({ + title: "Example", + url: "http://example.org/", + }), + browser.bookmarks.create({ title: "Mozilla Folder", type: "folder" }), + browser.bookmarks.create({ title: "EFF", url: "http://eff.org/" }), + browser.bookmarks.create({ + title: "Menu Item", + url: "http://menu.org/", + parentId: bookmarkGuids.menuGuid, + }), + browser.bookmarks.create({ + title: "Toolbar Item", + url: "http://toolbar.org/", + parentId: bookmarkGuids.toolbarGuid, + }), + ]); + }) + .then(results => { + browser.test.assertEq( + 6, + collectedEvents.length, + "6 expected events received" + ); + checkOnCreated( + results[5].id, + bookmarkGuids.toolbarGuid, + 0, + "Toolbar Item", + "http://toolbar.org/", + results[5].dateAdded + ); + checkOnCreated( + results[4].id, + bookmarkGuids.menuGuid, + 0, + "Menu Item", + "http://menu.org/", + results[4].dateAdded + ); + checkOnCreated( + results[3].id, + bookmarkGuids.unfiledGuid, + 0, + "EFF", + "http://eff.org/", + results[3].dateAdded + ); + checkOnCreated( + results[2].id, + bookmarkGuids.unfiledGuid, + 0, + "Mozilla Folder", + undefined, + results[2].dateAdded, + "folder" + ); + checkOnCreated( + results[1].id, + bookmarkGuids.unfiledGuid, + 0, + "Example", + "http://example.org/", + results[1].dateAdded + ); + checkOnCreated( + results[0].id, + bookmarkGuids.unfiledGuid, + 0, + "Μοζιλλας", + "http://xn--mzll-ooa1dud.xn--rg-eka/", + results[0].dateAdded + ); + + for (let result of results) { + if (result.title !== "Mozilla Folder") { + createdBookmarks.add(result.id); + } + } + let folderResult = results[2]; + createdFolderId = folderResult.id; + return Promise.all([ + browser.bookmarks.create({ + title: "Mozilla", + url: "http://allizom.org/", + parentId: createdFolderId, + }), + browser.bookmarks.create({ + parentId: createdFolderId, + type: "separator", + }), + browser.bookmarks.create({ + title: "Mozilla Corporation", + url: "http://allizom.com/", + parentId: createdFolderId, + }), + browser.bookmarks.create({ + title: "Firefox", + url: "http://allizom.org/firefox/", + parentId: createdFolderId, + }), + ]) + .then(newBookmarks => { + browser.test.assertEq( + 4, + collectedEvents.length, + "4 expected events received" + ); + checkOnCreated( + newBookmarks[3].id, + createdFolderId, + 0, + "Firefox", + "http://allizom.org/firefox/", + newBookmarks[3].dateAdded + ); + checkOnCreated( + newBookmarks[2].id, + createdFolderId, + 0, + "Mozilla Corporation", + "http://allizom.com/", + newBookmarks[2].dateAdded + ); + checkOnCreated( + newBookmarks[1].id, + createdFolderId, + 0, + "", + "data:", + newBookmarks[1].dateAdded, + "separator" + ); + checkOnCreated( + newBookmarks[0].id, + createdFolderId, + 0, + "Mozilla", + "http://allizom.org/", + newBookmarks[0].dateAdded + ); + + return browser.bookmarks.create({ + title: "About Mozilla", + url: "http://allizom.org/about/", + parentId: createdFolderId, + index: 1, + }); + }) + .then(result => { + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnCreated( + result.id, + createdFolderId, + 1, + "About Mozilla", + "http://allizom.org/about/", + result.dateAdded + ); + + // returns all items on empty object + return browser.bookmarks.search({}); + }) + .then(async bookmarksSearchResults => { + browser.test.assertTrue( + bookmarksSearchResults.length >= 10, + "At least as many bookmarks as added were returned by search({})" + ); + + await browser.test.assertRejects( + browser.bookmarks.remove(createdFolderId), + /Cannot remove a non-empty folder/, + "Expected error thrown when trying to remove a non-empty folder" + ); + return browser.bookmarks.getSubTree(createdFolderId); + }); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of nodes returned by getSubTree" + ); + browser.test.assertEq( + "Mozilla Folder", + results[0].title, + "Folder has the expected title" + ); + browser.test.assertEq( + bookmarkGuids.unfiledGuid, + results[0].parentId, + "Folder has the expected parentId" + ); + browser.test.assertEq( + "folder", + results[0].type, + "Folder has the expected type" + ); + let children = results[0].children; + browser.test.assertEq( + 5, + children.length, + "Expected number of bookmarks returned by getSubTree" + ); + browser.test.assertEq( + "Firefox", + children[0].title, + "Bookmark has the expected title" + ); + browser.test.assertEq( + "bookmark", + children[0].type, + "Bookmark has the expected type" + ); + browser.test.assertEq( + "About Mozilla", + children[1].title, + "Bookmark has the expected title" + ); + browser.test.assertEq( + "bookmark", + children[1].type, + "Bookmark has the expected type" + ); + browser.test.assertEq( + 1, + children[1].index, + "Bookmark has the expected index" + ); + browser.test.assertEq( + "Mozilla Corporation", + children[2].title, + "Bookmark has the expected title" + ); + browser.test.assertEq( + "", + children[3].title, + "Separator has the expected title" + ); + browser.test.assertEq( + "data:", + children[3].url, + "Separator has the expected url" + ); + browser.test.assertEq( + "separator", + children[3].type, + "Separator has the expected type" + ); + browser.test.assertEq( + "Mozilla", + children[4].title, + "Bookmark has the expected title" + ); + + // throws an error for invalid query objects + browser.test.assertThrows( + () => browser.bookmarks.search(), + /Incorrect argument types for bookmarks.search/, + "Expected error thrown when trying to search with no arguments" + ); + + browser.test.assertThrows( + () => browser.bookmarks.search(null), + /Incorrect argument types for bookmarks.search/, + "Expected error thrown when trying to search with null as an argument" + ); + + browser.test.assertThrows( + () => browser.bookmarks.search(() => {}), + /Incorrect argument types for bookmarks.search/, + "Expected error thrown when trying to search with a function as an argument" + ); + + browser.test.assertThrows( + () => browser.bookmarks.search({ banana: "banana" }), + /an unexpected "banana" property/, + "Expected error thrown when trying to search with a banana as an argument" + ); + + browser.test.assertThrows( + () => browser.bookmarks.search({ url: "spider-man vs. batman" }), + /must match the format "url"/, + "Expected error thrown when trying to search with a illegally formatted URL" + ); + // queries the full url + return browser.bookmarks.search("http://example.org/"); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of results returned for url search" + ); + checkBookmark( + { title: "Example", url: "http://example.org/", index: 2 }, + results[0] + ); + + // queries a partial url + return browser.bookmarks.search("example.org"); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of results returned for url search" + ); + checkBookmark( + { title: "Example", url: "http://example.org/", index: 2 }, + results[0] + ); + + // queries the title + return browser.bookmarks.search("EFF"); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of results returned for title search" + ); + checkBookmark( + { + title: "EFF", + url: "http://eff.org/", + index: 0, + parentId: bookmarkGuids.unfiledGuid, + }, + results[0] + ); + + // finds menu items + return browser.bookmarks.search("Menu Item"); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of results returned for menu item search" + ); + checkBookmark( + { + title: "Menu Item", + url: "http://menu.org/", + index: 0, + parentId: bookmarkGuids.menuGuid, + }, + results[0] + ); + + // finds toolbar items + return browser.bookmarks.search("Toolbar Item"); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of results returned for toolbar item search" + ); + checkBookmark( + { + title: "Toolbar Item", + url: "http://toolbar.org/", + index: 0, + parentId: bookmarkGuids.toolbarGuid, + }, + results[0] + ); + + // finds folders + return browser.bookmarks.search("Mozilla Folder"); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of folders returned" + ); + browser.test.assertEq( + "Mozilla Folder", + results[0].title, + "Folder has the expected title" + ); + browser.test.assertEq( + "folder", + results[0].type, + "Folder has the expected type" + ); + + // is case-insensitive + return browser.bookmarks.search("corporation"); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of results returnedfor case-insensitive search" + ); + browser.test.assertEq( + "Mozilla Corporation", + results[0].title, + "Bookmark has the expected title" + ); + + // is case-insensitive for non-ascii + return browser.bookmarks.search("ΜοΖΙΛΛΑς"); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of results returned for non-ascii search" + ); + browser.test.assertEq( + "Μοζιλλας", + results[0].title, + "Bookmark has the expected title" + ); + + // returns multiple results + return browser.bookmarks.search("allizom"); + }) + .then(results => { + browser.test.assertEq( + 4, + results.length, + "Expected number of multiple results returned" + ); + browser.test.assertEq( + "Mozilla", + results[0].title, + "Bookmark has the expected title" + ); + browser.test.assertEq( + "Mozilla Corporation", + results[1].title, + "Bookmark has the expected title" + ); + browser.test.assertEq( + "Firefox", + results[2].title, + "Bookmark has the expected title" + ); + browser.test.assertEq( + "About Mozilla", + results[3].title, + "Bookmark has the expected title" + ); + + // accepts a url field + return browser.bookmarks.search({ url: "http://allizom.com/" }); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of results returned for url field" + ); + checkBookmark( + { + title: "Mozilla Corporation", + url: "http://allizom.com/", + index: 2, + }, + results[0] + ); + + // normalizes urls + return browser.bookmarks.search({ url: "http://allizom.com" }); + }) + .then(results => { + browser.test.assertEq( + results.length, + 1, + "Expected number of results returned for normalized url field" + ); + checkBookmark( + { + title: "Mozilla Corporation", + url: "http://allizom.com/", + index: 2, + }, + results[0] + ); + + // normalizes urls even more + return browser.bookmarks.search({ url: "http:allizom.com" }); + }) + .then(results => { + browser.test.assertEq( + results.length, + 1, + "Expected number of results returned for normalized url field" + ); + checkBookmark( + { + title: "Mozilla Corporation", + url: "http://allizom.com/", + index: 2, + }, + results[0] + ); + + // accepts a title field + return browser.bookmarks.search({ title: "Mozilla" }); + }) + .then(results => { + browser.test.assertEq( + results.length, + 1, + "Expected number of results returned for title field" + ); + checkBookmark( + { title: "Mozilla", url: "http://allizom.org/", index: 4 }, + results[0] + ); + + // can combine title and query + return browser.bookmarks.search({ title: "Mozilla", query: "allizom" }); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of results returned for title and query fields" + ); + checkBookmark( + { title: "Mozilla", url: "http://allizom.org/", index: 4 }, + results[0] + ); + + // uses AND conditions + return browser.bookmarks.search({ title: "EFF", query: "allizom" }); + }) + .then(results => { + browser.test.assertEq( + 0, + results.length, + "Expected number of results returned for non-matching title and query fields" + ); + + // returns an empty array on item not found + return browser.bookmarks.search("microsoft"); + }) + .then(results => { + browser.test.assertEq( + 0, + results.length, + "Expected number of results returned for non-matching search" + ); + + browser.test.assertThrows( + () => browser.bookmarks.getRecent(""), + /Incorrect argument types for bookmarks.getRecent/, + "Expected error thrown when calling getRecent with an empty string" + ); + }) + .then(() => { + browser.test.assertThrows( + () => browser.bookmarks.getRecent(1.234), + /Incorrect argument types for bookmarks.getRecent/, + "Expected error thrown when calling getRecent with a decimal number" + ); + }) + .then(() => { + return Promise.all([ + browser.bookmarks.search("corporation"), + browser.bookmarks.getChildren(bookmarkGuids.menuGuid), + ]); + }) + .then(results => { + let corporationBookmark = results[0][0]; + let childCount = results[1].length; + + browser.test.assertEq( + 2, + corporationBookmark.index, + "Bookmark has the expected index" + ); + + return browser.bookmarks + .move(corporationBookmark.id, { index: 0 }) + .then(result => { + browser.test.assertEq( + 0, + result.index, + "Bookmark has the expected index" + ); + + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnMoved( + corporationBookmark.id, + createdFolderId, + createdFolderId, + 0, + 2 + ); + + return browser.bookmarks.move(corporationBookmark.id, { + parentId: bookmarkGuids.menuGuid, + }); + }) + .then(result => { + browser.test.assertEq( + bookmarkGuids.menuGuid, + result.parentId, + "Bookmark has the expected parent" + ); + browser.test.assertEq( + childCount, + result.index, + "Bookmark has the expected index" + ); + + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnMoved( + corporationBookmark.id, + bookmarkGuids.menuGuid, + createdFolderId, + 1, + 0 + ); + + return browser.bookmarks.move(corporationBookmark.id, { index: 0 }); + }) + .then(result => { + browser.test.assertEq( + bookmarkGuids.menuGuid, + result.parentId, + "Bookmark has the expected parent" + ); + browser.test.assertEq( + 0, + result.index, + "Bookmark has the expected index" + ); + + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnMoved( + corporationBookmark.id, + bookmarkGuids.menuGuid, + bookmarkGuids.menuGuid, + 0, + 1 + ); + + return browser.bookmarks.move(corporationBookmark.id, { + parentId: bookmarkGuids.toolbarGuid, + index: 1, + }); + }) + .then(result => { + browser.test.assertEq( + bookmarkGuids.toolbarGuid, + result.parentId, + "Bookmark has the expected parent" + ); + browser.test.assertEq( + 1, + result.index, + "Bookmark has the expected index" + ); + + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnMoved( + corporationBookmark.id, + bookmarkGuids.toolbarGuid, + bookmarkGuids.menuGuid, + 1, + 0 + ); + + createdBookmarks.add(corporationBookmark.id); + }); + }) + .then(() => { + return browser.bookmarks.getRecent(4); + }) + .then(results => { + browser.test.assertEq( + 4, + results.length, + "Expected number of results returned by getRecent" + ); + let prevDate = results[0].dateAdded; + for (let bookmark of results) { + browser.test.assertTrue( + bookmark.dateAdded <= prevDate, + "The recent bookmarks are sorted by dateAdded" + ); + prevDate = bookmark.dateAdded; + } + let bookmarksByTitle = results.sort((a, b) => { + return a.title.localeCompare(b.title); + }); + browser.test.assertEq( + "About Mozilla", + bookmarksByTitle[0].title, + "Bookmark has the expected title" + ); + browser.test.assertEq( + "Firefox", + bookmarksByTitle[1].title, + "Bookmark has the expected title" + ); + browser.test.assertEq( + "Mozilla", + bookmarksByTitle[2].title, + "Bookmark has the expected title" + ); + browser.test.assertEq( + "Mozilla Corporation", + bookmarksByTitle[3].title, + "Bookmark has the expected title" + ); + + return browser.bookmarks.search({}); + }) + .then(results => { + let startBookmarkCount = results.length; + + return browser.bookmarks + .search({ title: "Mozilla Folder" }) + .then(result => { + return browser.bookmarks.removeTree(result[0].id); + }) + .then(() => { + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnRemoved( + createdFolderId, + bookmarkGuids.unfiledGuid, + 1, + "Mozilla Folder" + ); + + return browser.bookmarks.search({}).then(searchResults => { + browser.test.assertEq( + startBookmarkCount - 5, + searchResults.length, + "Expected number of results returned after removeTree" + ); + }); + }); + }) + .then(() => { + return browser.bookmarks.create({ title: "Empty Folder" }); + }) + .then(result => { + createdFolderId = result.id; + + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnCreated( + createdFolderId, + bookmarkGuids.unfiledGuid, + 3, + "Empty Folder", + undefined, + result.dateAdded, + "folder" + ); + + browser.test.assertEq( + "Empty Folder", + result.title, + "Folder has the expected title" + ); + browser.test.assertEq( + "folder", + result.type, + "Folder has the expected type" + ); + + return browser.bookmarks.create({ + parentId: createdFolderId, + type: "separator", + }); + }) + .then(result => { + createdSeparatorId = result.id; + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnCreated( + createdSeparatorId, + createdFolderId, + 0, + "", + "data:", + result.dateAdded, + "separator" + ); + return browser.bookmarks.remove(createdSeparatorId); + }) + .then(() => { + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnRemoved( + createdSeparatorId, + createdFolderId, + 0, + "", + "data:", + "separator" + ); + + return browser.bookmarks.remove(createdFolderId); + }) + .then(() => { + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnRemoved( + createdFolderId, + bookmarkGuids.unfiledGuid, + 3, + "Empty Folder" + ); + + return browser.test.assertRejects( + browser.bookmarks.get(createdFolderId), + /Bookmark not found/, + "Expected error thrown when trying to get a removed folder" + ); + }) + .then(() => { + return browser.test.assertRejects( + browser.bookmarks.getChildren(nonExistentId), + /root is null/, + "Expected error thrown when trying to getChildren for a non-existent folder" + ); + }) + .then(() => { + return browser.test.assertRejects( + browser.bookmarks.move(nonExistentId, {}), + /No bookmarks found for the provided GUID/, + "Expected error thrown when calling move with a non-existent bookmark" + ); + }) + .then(() => { + return browser.test.assertRejects( + browser.bookmarks.create({ + title: "test root folder", + parentId: bookmarkGuids.rootGuid, + }), + "The bookmark root cannot be modified", + "Expected error thrown when creating bookmark folder at the root" + ); + }) + .then(() => { + return browser.test.assertRejects( + browser.bookmarks.update(bookmarkGuids.rootGuid, { + title: "test update title", + }), + "The bookmark root cannot be modified", + "Expected error thrown when updating root" + ); + }) + .then(() => { + return browser.test.assertRejects( + browser.bookmarks.remove(bookmarkGuids.rootGuid), + "The bookmark root cannot be modified", + "Expected error thrown when removing root" + ); + }) + .then(() => { + return browser.test.assertRejects( + browser.bookmarks.removeTree(bookmarkGuids.rootGuid), + "The bookmark root cannot be modified", + "Expected error thrown when removing root tree" + ); + }) + .then(() => { + return browser.bookmarks.create({ title: "Empty Folder" }); + }) + .then(async result => { + createdFolderId = result.id; + + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnCreated( + createdFolderId, + bookmarkGuids.unfiledGuid, + 3, + "Empty Folder", + undefined, + result.dateAdded, + "folder" + ); + + await browser.test.assertRejects( + browser.bookmarks.move(createdFolderId, { + parentId: bookmarkGuids.rootGuid, + }), + "The bookmark root cannot be modified", + "Expected error thrown when moving bookmark folder to the root" + ); + + return browser.bookmarks.remove(createdFolderId); + }) + .then(() => { + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnRemoved( + createdFolderId, + bookmarkGuids.unfiledGuid, + 3, + "Empty Folder", + undefined, + "folder" + ); + + return browser.test.assertRejects( + browser.bookmarks.get(createdFolderId), + "Bookmark not found", + "Expected error thrown when trying to get a removed folder" + ); + }) + .then(() => { + return browser.test.assertRejects( + browser.bookmarks.move(bookmarkGuids.rootGuid, { + parentId: bookmarkGuids.unfiledGuid, + }), + "The bookmark root cannot be modified", + "Expected error thrown when moving root" + ); + }) + .then(() => { + // remove all created bookmarks + let promises = Array.from(createdBookmarks, guid => + browser.bookmarks.remove(guid) + ); + return Promise.all(promises); + }) + .then(() => { + browser.test.assertEq( + createdBookmarks.size, + collectedEvents.length, + "expected number of events received" + ); + + return browser.bookmarks.search({}); + }) + .then(results => { + browser.test.assertEq( + initialBookmarkCount, + results.length, + "All created bookmarks have been removed" + ); + + return browser.test.notifyPass("bookmarks"); + }) + .catch(error => { + browser.test.fail(`Error: ${String(error)} :: ${error.stack}`); + browser.test.notifyFail("bookmarks"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["bookmarks"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("bookmarks"); + await extension.unload(); +}); + +add_task(async function test_get_recent_with_tag_and_query() { + function background() { + browser.bookmarks.getRecent(100).then(bookmarks => { + browser.test.sendMessage("bookmarks", bookmarks); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["bookmarks"], + }, + }); + + // Start with an empty bookmarks database. + await PlacesUtils.bookmarks.eraseEverything(); + + let createdBookmarks = []; + for (let i = 0; i < 3; i++) { + let bookmark = { + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: `http://example.com/${i}`, + title: `My bookmark ${i}`, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }; + createdBookmarks.unshift(bookmark); + await PlacesUtils.bookmarks.insert(bookmark); + } + + // Add a tag to the most recent url to prove it doesn't get returned. + PlacesUtils.tagging.tagURI(NetUtil.newURI("http://example.com/${i}"), [ + "Test Tag", + ]); + + // Add a query bookmark. + let queryURL = `place:parent=${PlacesUtils.bookmarks.menuGuid}&queryType=1`; + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: queryURL, + title: "a test query", + }); + + await extension.startup(); + let receivedBookmarks = await extension.awaitMessage("bookmarks"); + + equal( + receivedBookmarks.length, + 3, + "The expected number of bookmarks was returned." + ); + for (let i = 0; i < 3; i++) { + let actual = receivedBookmarks[i]; + let expected = createdBookmarks[i]; + equal(actual.url, expected.url, "Bookmark has the expected url."); + equal(actual.title, expected.title, "Bookmark has the expected title."); + equal( + actual.parentId, + expected.parentGuid, + "Bookmark has the expected parentId." + ); + } + + await extension.unload(); +}); + +add_task(async function test_tree_with_empty_folder() { + async function background() { + await browser.bookmarks.create({ title: "Empty Folder" }); + let nonEmptyFolder = await browser.bookmarks.create({ + title: "Non-Empty Folder", + }); + await browser.bookmarks.create({ + title: "A bookmark", + url: "http://example.com", + parentId: nonEmptyFolder.id, + }); + + let tree = await browser.bookmarks.getSubTree(nonEmptyFolder.parentId); + browser.test.assertEq( + 0, + tree[0].children[0].children.length, + "The empty folder returns an empty array for children." + ); + browser.test.assertEq( + 1, + tree[0].children[1].children.length, + "The non-empty folder returns a single item array for children." + ); + + let children = await browser.bookmarks.getChildren(nonEmptyFolder.parentId); + // getChildren should only return immediate children. This is not tested in the + // monster test above. + for (let child of children) { + browser.test.assertEq( + undefined, + child.children, + "Child from getChildren does not contain any children." + ); + } + + browser.test.sendMessage("done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["bookmarks"], + }, + }); + + // Start with an empty bookmarks database. + await PlacesUtils.bookmarks.eraseEverything(); + + await extension.startup(); + await extension.awaitMessage("done"); + + await extension.unload(); +}); + +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + }, + async function test_bookmarks_event_page() { + await AddonTestUtils.promiseStartupManager(); + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "eventpage@bookmarks" } }, + permissions: ["bookmarks"], + background: { persistent: false }, + }, + background() { + browser.bookmarks.onCreated.addListener(() => { + browser.test.sendMessage("onCreated"); + }); + browser.bookmarks.onRemoved.addListener(() => { + browser.test.sendMessage("onRemoved"); + }); + browser.bookmarks.onChanged.addListener(() => {}); + browser.bookmarks.onMoved.addListener(() => {}); + browser.test.sendMessage("ready"); + }, + }); + + const EVENTS = ["onCreated", "onRemoved", "onChanged", "onMoved"]; + await PlacesUtils.bookmarks.eraseEverything(); + + await extension.startup(); + await extension.awaitMessage("ready"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "bookmarks", event, { + primed: false, + }); + } + + // test events waken background + await extension.terminateBackground(); + for (let event of EVENTS) { + assertPersistentListeners(extension, "bookmarks", event, { + primed: true, + }); + } + + let bookmark = { + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: `http://example.com/12345`, + title: `My bookmark 12345`, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }; + await PlacesUtils.bookmarks.insert(bookmark); + + await extension.awaitMessage("ready"); + await extension.awaitMessage("onCreated"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "bookmarks", event, { + primed: false, + }); + } + + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + for (let event of EVENTS) { + assertPersistentListeners(extension, "bookmarks", event, { + primed: true, + }); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await extension.awaitMessage("ready"); + await extension.awaitMessage("onRemoved"); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + } +); diff --git a/browser/components/extensions/test/xpcshell/test_ext_browsingData_downloads.js b/browser/components/extensions/test/xpcshell/test_ext_browsingData_downloads.js new file mode 100644 index 0000000000..1257f23600 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_browsingData_downloads.js @@ -0,0 +1,126 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Downloads: "resource://gre/modules/Downloads.sys.mjs", +}); + +const OLD_NAMES = { + [Downloads.PUBLIC]: "old-public", + [Downloads.PRIVATE]: "old-private", +}; +const RECENT_NAMES = { + [Downloads.PUBLIC]: "recent-public", + [Downloads.PRIVATE]: "recent-private", +}; +const REFERENCE_DATE = new Date(); +const OLD_DATE = new Date(Number(REFERENCE_DATE) - 10000); + +async function downloadExists(list, path) { + let listArray = await list.getAll(); + return listArray.some(i => i.target.path == path); +} + +async function checkDownloads( + expectOldExists = true, + expectRecentExists = true +) { + for (let listType of [Downloads.PUBLIC, Downloads.PRIVATE]) { + let downloadsList = await Downloads.getList(listType); + equal( + await downloadExists(downloadsList, OLD_NAMES[listType]), + expectOldExists, + `Fake old download ${expectOldExists ? "was found" : "was removed"}.` + ); + equal( + await downloadExists(downloadsList, RECENT_NAMES[listType]), + expectRecentExists, + `Fake recent download ${ + expectRecentExists ? "was found" : "was removed" + }.` + ); + } +} + +async function setupDownloads() { + let downloadsList = await Downloads.getList(Downloads.ALL); + await downloadsList.removeFinished(); + + for (let listType of [Downloads.PUBLIC, Downloads.PRIVATE]) { + downloadsList = await Downloads.getList(listType); + let download = await Downloads.createDownload({ + source: { + url: "https://bugzilla.mozilla.org/show_bug.cgi?id=1321303", + isPrivate: listType == Downloads.PRIVATE, + }, + target: OLD_NAMES[listType], + }); + download.startTime = OLD_DATE; + download.canceled = true; + await downloadsList.add(download); + + download = await Downloads.createDownload({ + source: { + url: "https://bugzilla.mozilla.org/show_bug.cgi?id=1321303", + isPrivate: listType == Downloads.PRIVATE, + }, + target: RECENT_NAMES[listType], + }); + download.startTime = REFERENCE_DATE; + download.canceled = true; + await downloadsList.add(download); + } + + // Confirm everything worked. + downloadsList = await Downloads.getList(Downloads.ALL); + equal((await downloadsList.getAll()).length, 4, "4 fake downloads added."); + checkDownloads(); +} + +add_task(async function testDownloads() { + function background() { + browser.test.onMessage.addListener(async (msg, options) => { + if (msg == "removeDownloads") { + await browser.browsingData.removeDownloads(options); + } else { + await browser.browsingData.remove(options, { downloads: true }); + } + browser.test.sendMessage("downloadsRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + async function testRemovalMethod(method) { + // Clear downloads with no since value. + await setupDownloads(); + extension.sendMessage(method, {}); + await extension.awaitMessage("downloadsRemoved"); + await checkDownloads(false, false); + + // Clear downloads with recent since value. + await setupDownloads(); + extension.sendMessage(method, { since: REFERENCE_DATE }); + await extension.awaitMessage("downloadsRemoved"); + await checkDownloads(true, false); + + // Clear downloads with old since value. + await setupDownloads(); + extension.sendMessage(method, { since: REFERENCE_DATE - 100000 }); + await extension.awaitMessage("downloadsRemoved"); + await checkDownloads(false, false); + } + + await extension.startup(); + + await testRemovalMethod("removeDownloads"); + await testRemovalMethod("remove"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_browsingData_passwords.js b/browser/components/extensions/test/xpcshell/test_ext_browsingData_passwords.js new file mode 100644 index 0000000000..68a2c2cdc5 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_browsingData_passwords.js @@ -0,0 +1,96 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const REFERENCE_DATE = Date.now(); +const LOGIN_USERNAME = "username"; +const LOGIN_PASSWORD = "password"; +const OLD_HOST = "http://mozilla.org"; +const NEW_HOST = "http://mozilla.com"; +const FXA_HOST = "chrome://FirefoxAccounts"; + +async function checkLoginExists(origin, shouldExist) { + const logins = await Services.logins.searchLoginsAsync({ origin }); + equal( + logins.length, + shouldExist ? 1 : 0, + `Login for origin ${origin} should ${shouldExist ? "" : "not"} be found.` + ); +} + +async function addLogin(host, timestamp) { + await checkLoginExists(host, false); + let login = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance( + Ci.nsILoginInfo + ); + login.init(host, "", null, LOGIN_USERNAME, LOGIN_PASSWORD); + login.QueryInterface(Ci.nsILoginMetaInfo); + login.timePasswordChanged = timestamp; + await Services.logins.addLoginAsync(login); + await checkLoginExists(host, true); +} + +async function setupPasswords() { + // Remove all logins if any (included FxAccounts one in case one got captured in + // a conditioned profile, see Bug 1853617). + Services.logins.removeAllLogins(); + await addLogin(FXA_HOST, REFERENCE_DATE); + await addLogin(NEW_HOST, REFERENCE_DATE); + await addLogin(OLD_HOST, REFERENCE_DATE - 10000); +} + +add_task(async function testPasswords() { + function background() { + browser.test.onMessage.addListener(async (msg, options) => { + if (msg == "removeHistory") { + await browser.browsingData.removePasswords(options); + } else { + await browser.browsingData.remove(options, { passwords: true }); + } + browser.test.sendMessage("passwordsRemoved"); + }); + } + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + async function testRemovalMethod(method) { + // Clear passwords with no since value. + await setupPasswords(); + extension.sendMessage(method, {}); + await extension.awaitMessage("passwordsRemoved"); + + await checkLoginExists(OLD_HOST, false); + await checkLoginExists(NEW_HOST, false); + await checkLoginExists(FXA_HOST, true); + + // Clear passwords with recent since value. + await setupPasswords(); + extension.sendMessage(method, { since: REFERENCE_DATE - 1000 }); + await extension.awaitMessage("passwordsRemoved"); + + await checkLoginExists(OLD_HOST, true); + await checkLoginExists(NEW_HOST, false); + await checkLoginExists(FXA_HOST, true); + + // Clear passwords with old since value. + await setupPasswords(); + extension.sendMessage(method, { since: REFERENCE_DATE - 20000 }); + await extension.awaitMessage("passwordsRemoved"); + + await checkLoginExists(OLD_HOST, false); + await checkLoginExists(NEW_HOST, false); + await checkLoginExists(FXA_HOST, true); + } + + await extension.startup(); + + await testRemovalMethod("removePasswords"); + await testRemovalMethod("remove"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_browsingData_settings.js b/browser/components/extensions/test/xpcshell/test_ext_browsingData_settings.js new file mode 100644 index 0000000000..9d2241895c --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_browsingData_settings.js @@ -0,0 +1,147 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", + Sanitizer: "resource:///modules/Sanitizer.sys.mjs", +}); + +const PREF_DOMAIN = "privacy.cpd."; +const SETTINGS_LIST = [ + "cache", + "cookies", + "history", + "formData", + "downloads", +].sort(); + +add_task(async function testSettingsProperties() { + function background() { + browser.test.onMessage.addListener(msg => { + browser.browsingData.settings().then(settings => { + browser.test.sendMessage("settings", settings); + }); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + await extension.startup(); + + extension.sendMessage("settings"); + let settings = await extension.awaitMessage("settings"); + + // Verify that we get the keys back we expect. + deepEqual( + Object.keys(settings.dataToRemove).sort(), + SETTINGS_LIST, + "dataToRemove contains expected properties." + ); + deepEqual( + Object.keys(settings.dataRemovalPermitted).sort(), + SETTINGS_LIST, + "dataToRemove contains expected properties." + ); + + let dataTypeSet = settings.dataToRemove; + for (let key of Object.keys(dataTypeSet)) { + equal( + Preferences.get(`${PREF_DOMAIN}${key.toLowerCase()}`), + dataTypeSet[key], + `${key} property of dataToRemove matches the expected pref.` + ); + } + + dataTypeSet = settings.dataRemovalPermitted; + for (let key of Object.keys(dataTypeSet)) { + equal( + true, + dataTypeSet[key], + `${key} property of dataRemovalPermitted is true.` + ); + } + + // Explicitly set a pref to both true and false and then check. + const SINGLE_OPTION = "cache"; + const SINGLE_PREF = "privacy.cpd.cache"; + + registerCleanupFunction(() => { + Preferences.reset(SINGLE_PREF); + }); + + Preferences.set(SINGLE_PREF, true); + + extension.sendMessage("settings"); + settings = await extension.awaitMessage("settings"); + equal( + settings.dataToRemove[SINGLE_OPTION], + true, + "Preference that was set to true returns true." + ); + + Preferences.set(SINGLE_PREF, false); + + extension.sendMessage("settings"); + settings = await extension.awaitMessage("settings"); + equal( + settings.dataToRemove[SINGLE_OPTION], + false, + "Preference that was set to false returns false." + ); + + await extension.unload(); +}); + +add_task(async function testSettingsSince() { + const TIMESPAN_PREF = "privacy.sanitize.timeSpan"; + const TEST_DATA = { + TIMESPAN_5MIN: Date.now() - 5 * 60 * 1000, + TIMESPAN_HOUR: Date.now() - 60 * 60 * 1000, + TIMESPAN_2HOURS: Date.now() - 2 * 60 * 60 * 1000, + TIMESPAN_EVERYTHING: 0, + }; + + function background() { + browser.test.onMessage.addListener(msg => { + browser.browsingData.settings().then(settings => { + browser.test.sendMessage("settings", settings); + }); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + await extension.startup(); + + registerCleanupFunction(() => { + Preferences.reset(TIMESPAN_PREF); + }); + + for (let timespan in TEST_DATA) { + Preferences.set(TIMESPAN_PREF, Sanitizer[timespan]); + + extension.sendMessage("settings"); + let settings = await extension.awaitMessage("settings"); + + // Because it is based on the current timestamp, we cannot know the exact + // value to expect for since, so allow a 10s variance. + Assert.less( + Math.abs(settings.options.since - TEST_DATA[timespan]), + 10000, + "settings.options contains the expected since value." + ); + } + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_home.js b/browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_home.js new file mode 100644 index 0000000000..d61e5b6b5e --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_home.js @@ -0,0 +1,231 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + HomePage: "resource:///modules/HomePage.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +function promisePrefChanged(expectedValue) { + return TestUtils.waitForPrefChange("browser.startup.homepage", value => + value.endsWith(expectedValue) + ); +} + +const HOMEPAGE_EXTENSION_CONTROLLED = + "browser.startup.homepage_override.extensionControlled"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +async function setupRemoteSettings() { + const settings = await RemoteSettings("hijack-blocklists"); + sinon.stub(settings, "get").returns([ + { + id: "homepage-urls", + matches: ["ignore=me"], + _status: "synced", + }, + ]); +} + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + await setupRemoteSettings(); +}); + +add_task(async function test_overriding_with_ignored_url() { + // Manually poke into the ignore list a value to be ignored. + HomePage._ignoreList.push("ignore=me"); + Services.prefs.setBoolPref(HOMEPAGE_EXTENSION_CONTROLLED, false); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "ignore_homepage@example.com", + }, + }, + chrome_settings_overrides: { homepage: "https://example.com/?ignore=me" }, + name: "extension", + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + + Assert.ok(HomePage.isDefault, "Should still have the default homepage"); + Assert.equal( + Services.prefs.getBoolPref( + "browser.startup.homepage_override.extensionControlled" + ), + false, + "Should not be extension controlled." + ); + TelemetryTestUtils.assertEvents( + [ + { + object: "ignore", + value: "set_blocked_extension", + extra: { webExtensionId: "ignore_homepage@example.com" }, + }, + ], + { + category: "homepage", + method: "preference", + } + ); + + await extension.unload(); + HomePage._ignoreList.pop(); +}); + +add_task(async function test_overriding_cancelled_after_ignore_update() { + const oldHomePageIgnoreList = HomePage._ignoreList; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "ignore_homepage1@example.com", + }, + }, + chrome_settings_overrides: { + homepage: "https://example.com/?ignore1=me", + }, + name: "extension", + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + + Assert.ok(!HomePage.isDefault, "Should have overriden the new homepage"); + Assert.equal( + Services.prefs.getBoolPref( + "browser.startup.homepage_override.extensionControlled" + ), + true, + "Should be extension controlled." + ); + + let prefChanged = TestUtils.waitForPrefChange( + "browser.startup.homepage_override.extensionControlled" + ); + + await HomePage._handleIgnoreListUpdated({ + data: { + current: [{ id: "homepage-urls", matches: ["ignore1=me"] }], + }, + }); + + await prefChanged; + + await TestUtils.waitForCondition( + () => + !Services.prefs.getBoolPref( + "browser.startup.homepage_override.extensionControlled", + false + ), + "Should not longer be extension controlled" + ); + + Assert.ok(HomePage.isDefault, "Should have reset the homepage"); + + TelemetryTestUtils.assertEvents( + [ + { + object: "ignore", + value: "saved_reset", + }, + ], + { + category: "homepage", + method: "preference", + } + ); + + await extension.unload(); + HomePage._ignoreList = oldHomePageIgnoreList; +}); + +add_task(async function test_overriding_homepage_locale() { + Services.locale.availableLocales = ["en-US", "es-ES"]; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "homepage@example.com", + }, + }, + chrome_settings_overrides: { + homepage: "/__MSG_homepage__", + }, + name: "extension", + default_locale: "en", + }, + useAddonManager: "permanent", + + files: { + "_locales/en/messages.json": { + homepage: { + message: "homepage.html", + description: "homepage", + }, + }, + + "_locales/es_ES/messages.json": { + homepage: { + message: "default.html", + description: "homepage", + }, + }, + }, + }); + + let prefPromise = promisePrefChanged("homepage.html"); + await extension.startup(); + await prefPromise; + + Assert.equal( + HomePage.get(), + `moz-extension://${extension.uuid}/homepage.html`, + "Should have overridden the new homepage" + ); + + // Set the new locale now, and disable the L10nRegistry reset + // when shutting down the addon mananger. This allows us to + // restart under a new locale without a lot of fuss. + let reqLoc = Services.locale.requestedLocales; + Services.locale.requestedLocales = ["es-ES"]; + + prefPromise = promisePrefChanged("default.html"); + await AddonTestUtils.promiseShutdownManager({ clearL10nRegistry: false }); + await AddonTestUtils.promiseStartupManager(); + await extension.awaitStartup(); + await prefPromise; + + Assert.equal( + HomePage.get(), + `moz-extension://${extension.uuid}/default.html`, + "Should have overridden the new homepage" + ); + + await extension.unload(); + + Services.locale.requestedLocales = reqLoc; +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_update.js b/browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_update.js new file mode 100644 index 0000000000..aac00a8023 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_update.js @@ -0,0 +1,794 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + HomePage: "resource:///modules/HomePage.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +// Similar to TestUtils.topicObserved, but returns a deferred promise that +// can be resolved +function topicObservable(topic, checkFn) { + let deferred = Promise.withResolvers(); + function observer(subject, topic, data) { + try { + if (checkFn && !checkFn(subject, data)) { + return; + } + deferred.resolve([subject, data]); + } catch (ex) { + deferred.reject(ex); + } + } + deferred.promise.finally(() => { + Services.obs.removeObserver(observer, topic); + checkFn = null; + }); + Services.obs.addObserver(observer, topic); + + return deferred; +} + +async function setupRemoteSettings() { + const settings = await RemoteSettings("hijack-blocklists"); + sinon.stub(settings, "get").returns([ + { + id: "homepage-urls", + matches: ["ignore=me"], + _status: "synced", + }, + ]); +} + +function promisePrefChanged(expectedValue) { + return TestUtils.waitForPrefChange("browser.startup.homepage", value => + value.endsWith(expectedValue) + ); +} + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + await setupRemoteSettings(); +}); + +add_task(async function test_overrides_update_removal() { + /* This tests the scenario where the manifest key for homepage and/or + * search_provider are removed between updates and therefore the + * settings are expected to revert. It also tests that an extension + * can make a builtin extension the default search without user + * interaction. */ + + const EXTENSION_ID = "test_overrides_update@tests.mozilla.org"; + const HOMEPAGE_URI = "webext-homepage-1.html"; + + let extensionInfo = { + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + chrome_settings_overrides: { + homepage: HOMEPAGE_URI, + search_provider: { + name: "DuckDuckGo", + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionInfo); + + let defaultHomepageURL = HomePage.get(); + let defaultEngineName = (await Services.search.getDefault()).name; + Assert.notStrictEqual( + defaultEngineName, + "DuckDuckGo", + "Default engine is not DuckDuckGo." + ); + + let prefPromise = promisePrefChanged(HOMEPAGE_URI); + + // When an addon is installed that overrides an app-provided engine (builtin) + // that is the default, we do not prompt for default. + let deferredPrompt = topicObservable( + "webextension-defaultsearch-prompt", + (subject, message) => { + if (subject.wrappedJSObject.id == extension.id) { + ok(false, "default override should not prompt"); + } + } + ); + + await Promise.race([extension.startup(), deferredPrompt.promise]); + deferredPrompt.resolve(); + await AddonTestUtils.waitForSearchProviderStartup(extension); + await prefPromise; + + equal( + extension.version, + "1.0", + "The installed addon has the expected version." + ); + ok( + HomePage.get().endsWith(HOMEPAGE_URI), + "Home page url is overridden by the extension." + ); + equal( + (await Services.search.getDefault()).name, + "DuckDuckGo", + "Builtin default engine was set default by extension" + ); + + extensionInfo.manifest = { + version: "2.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + }; + + prefPromise = promisePrefChanged(defaultHomepageURL); + await extension.upgrade(extensionInfo); + await prefPromise; + + equal( + extension.version, + "2.0", + "The updated addon has the expected version." + ); + equal( + HomePage.get(), + defaultHomepageURL, + "Home page url reverted to the default after update." + ); + equal( + (await Services.search.getDefault()).name, + defaultEngineName, + "Default engine reverted to the default after update." + ); + + await extension.unload(); +}); + +add_task(async function test_overrides_update_adding() { + /* This tests the scenario where an addon adds support for + * a homepage or search service when upgrading. Neither + * should override existing entries for those when added + * in an upgrade. Also, a search_provider being added + * with is_default should not prompt the user or override + * the current default engine. */ + + const EXTENSION_ID = "test_overrides_update@tests.mozilla.org"; + const HOMEPAGE_URI = "webext-homepage-1.html"; + + let extensionInfo = { + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionInfo); + + let defaultHomepageURL = HomePage.get(); + let defaultEngineName = (await Services.search.getDefault()).name; + Assert.notStrictEqual( + defaultEngineName, + "DuckDuckGo", + "Home page url is not DuckDuckGo." + ); + + await extension.startup(); + + equal( + extension.version, + "1.0", + "The installed addon has the expected version." + ); + equal( + HomePage.get(), + defaultHomepageURL, + "Home page url is the default after startup." + ); + equal( + (await Services.search.getDefault()).name, + defaultEngineName, + "Default engine is the default after startup." + ); + + extensionInfo.manifest = { + version: "2.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + chrome_settings_overrides: { + homepage: HOMEPAGE_URI, + search_provider: { + name: "DuckDuckGo", + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }; + + let prefPromise = promisePrefChanged(HOMEPAGE_URI); + + let deferredUpgradePrompt = topicObservable( + "webextension-defaultsearch-prompt", + (subject, message) => { + if (subject.wrappedJSObject.id == extension.id) { + ok(false, "should not prompt on update"); + } + } + ); + + await Promise.race([ + extension.upgrade(extensionInfo), + deferredUpgradePrompt.promise, + ]); + deferredUpgradePrompt.resolve(); + await AddonTestUtils.waitForSearchProviderStartup(extension); + await prefPromise; + + equal( + extension.version, + "2.0", + "The updated addon has the expected version." + ); + ok( + HomePage.get().endsWith(HOMEPAGE_URI), + "Home page url is overridden by the extension during upgrade." + ); + // An upgraded extension adding a search engine cannot override + // the default engine. + equal( + (await Services.search.getDefault()).name, + defaultEngineName, + "Default engine is still the default after startup." + ); + + await extension.unload(); +}); + +add_task(async function test_overrides_update_homepage_change() { + /* This tests the scenario where an addon changes + * a homepage url when upgrading. */ + + const EXTENSION_ID = "test_overrides_update@tests.mozilla.org"; + const HOMEPAGE_URI = "webext-homepage-1.html"; + const HOMEPAGE_URI_2 = "webext-homepage-2.html"; + + let extensionInfo = { + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + chrome_settings_overrides: { + homepage: HOMEPAGE_URI, + }, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionInfo); + + let prefPromise = promisePrefChanged(HOMEPAGE_URI); + await extension.startup(); + await prefPromise; + + equal( + extension.version, + "1.0", + "The installed addon has the expected version." + ); + ok( + HomePage.get().endsWith(HOMEPAGE_URI), + "Home page url is the extension url after startup." + ); + + extensionInfo.manifest = { + version: "2.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + chrome_settings_overrides: { + homepage: HOMEPAGE_URI_2, + }, + }; + + prefPromise = promisePrefChanged(HOMEPAGE_URI_2); + await extension.upgrade(extensionInfo); + await prefPromise; + + equal( + extension.version, + "2.0", + "The updated addon has the expected version." + ); + ok( + HomePage.get().endsWith(HOMEPAGE_URI_2), + "Home page url is by the extension after upgrade." + ); + + await extension.unload(); +}); + +async function withHandlingDefaultSearchPrompt({ extensionId, respond }, cb) { + const promptResponseHandled = TestUtils.topicObserved( + "webextension-defaultsearch-prompt-response" + ); + const prompted = TestUtils.topicObserved( + "webextension-defaultsearch-prompt", + (subject, message) => { + if (subject.wrappedJSObject.id == extensionId) { + return subject.wrappedJSObject.respond(respond); + } + } + ); + + await Promise.all([cb(), prompted, promptResponseHandled]); +} + +async function assertUpdateDoNotPrompt(extension, updateExtensionInfo) { + let deferredUpgradePrompt = topicObservable( + "webextension-defaultsearch-prompt", + (subject, message) => { + if (subject.wrappedJSObject.id == extension.id) { + ok(false, "should not prompt on update"); + } + } + ); + + await Promise.race([ + extension.upgrade(updateExtensionInfo), + deferredUpgradePrompt.promise, + ]); + deferredUpgradePrompt.resolve(); + + await AddonTestUtils.waitForSearchProviderStartup(extension); + + equal( + extension.version, + updateExtensionInfo.manifest.version, + "The updated addon has the expected version." + ); +} + +add_task(async function test_default_search_prompts() { + /* This tests the scenario where an addon did not gain + * default search during install, and later upgrades. + * The addon should not gain default in updates. + * If the addon is disabled, it should prompt again when + * enabled. + */ + + const EXTENSION_ID = "test_default_update@tests.mozilla.org"; + + let extensionInfo = { + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: "Example", + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionInfo); + + let defaultEngineName = (await Services.search.getDefault()).name; + Assert.notStrictEqual(defaultEngineName, "Example", "Search is not Example."); + + // Mock a response from the default search prompt where we + // say no to setting this as the default when installing. + await withHandlingDefaultSearchPrompt( + { extensionId: EXTENSION_ID, respond: false }, + () => extension.startup() + ); + + equal( + extension.version, + "1.0", + "The installed addon has the expected version." + ); + equal( + (await Services.search.getDefault()).name, + defaultEngineName, + "Default engine is the default after startup." + ); + + info( + "Verify that updating the extension does not prompt and does not take over the default engine" + ); + + extensionInfo.manifest.version = "2.0"; + await assertUpdateDoNotPrompt(extension, extensionInfo); + equal( + (await Services.search.getDefault()).name, + defaultEngineName, + "Default engine is still the default after update." + ); + + info("Verify that disable/enable the extension does prompt the user"); + + let addon = await AddonManager.getAddonByID(EXTENSION_ID); + + await withHandlingDefaultSearchPrompt( + { extensionId: EXTENSION_ID, respond: false }, + async () => { + await addon.disable(); + await addon.enable(); + } + ); + + // we still said no. + equal( + (await Services.search.getDefault()).name, + defaultEngineName, + "Default engine is the default after being disabling/enabling." + ); + + await extension.unload(); +}); + +async function test_default_search_on_updating_addons_installed_before_bug1757760({ + builtinAsInitialDefault, +}) { + /* This tests covers a scenario similar to the previous test but with an extension-settings.json file + content like the one that would be available in the profile if the add-on was installed on firefox + versions that didn't include the changes from Bug 1757760 (See Bug 1767550). + */ + + const EXTENSION_ID = `test_old_addon@tests.mozilla.org`; + const EXTENSION_ID2 = `test_old_addon2@tests.mozilla.org`; + + const extensionInfo = { + useAddonManager: "permanent", + manifest: { + version: "1.1", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: "Test SearchEngine", + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + }; + + const extensionInfo2 = { + useAddonManager: "permanent", + manifest: { + version: "1.2", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID2, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: "Test SearchEngine2", + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + }; + + const { ExtensionSettingsStore } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionSettingsStore.sys.mjs" + ); + + async function assertExtensionSettingsStore( + extensionInfo, + expectedLevelOfControl + ) { + const { id } = extensionInfo.manifest.browser_specific_settings.gecko; + info(`Asserting ExtensionSettingsStore for ${id}`); + const item = ExtensionSettingsStore.getSetting( + "default_search", + "defaultSearch", + id + ); + equal( + item.value, + extensionInfo.manifest.chrome_settings_overrides.search_provider.name, + "Got the expected item returned by ExtensionSettingsStore.getSetting" + ); + const control = await ExtensionSettingsStore.getLevelOfControl( + id, + "default_search", + "defaultSearch" + ); + equal( + control, + expectedLevelOfControl, + `Got expected levelOfControl for ${id}` + ); + } + + info("Install test extensions without opt-in to the related search engines"); + + let extension = ExtensionTestUtils.loadExtension(extensionInfo); + let extension2 = ExtensionTestUtils.loadExtension(extensionInfo2); + + // Mock a response from the default search prompt where we + // say no to setting this as the default when installing. + await withHandlingDefaultSearchPrompt( + { extensionId: EXTENSION_ID, respond: false }, + () => extension.startup() + ); + + equal( + extension.version, + "1.1", + "first installed addon has the expected version." + ); + + // Mock a response from the default search prompt where we + // say no to setting this as the default when installing. + await withHandlingDefaultSearchPrompt( + { extensionId: EXTENSION_ID2, respond: false }, + () => extension2.startup() + ); + + equal( + extension2.version, + "1.2", + "second installed addon has the expected version." + ); + + info("Setup preconditions (set the initial default search engine)"); + + // Sanity check to be sure the initial engine expected as precondition + // for the scenario covered by the current test case. + let initialEngine; + if (builtinAsInitialDefault) { + initialEngine = Services.search.appDefaultEngine; + } else { + initialEngine = Services.search.getEngineByName( + extensionInfo.manifest.chrome_settings_overrides.search_provider.name + ); + } + await Services.search.setDefault( + initialEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + let defaultEngineName = (await Services.search.getDefault()).name; + Assert.equal( + defaultEngineName, + initialEngine.name, + `initial default search engine expected to be ${ + builtinAsInitialDefault ? "app-provided" : EXTENSION_ID + }` + ); + Assert.notEqual( + defaultEngineName, + extensionInfo2.manifest.chrome_settings_overrides.search_provider.name, + "initial default search engine name should not be the same as the second extension search_provider" + ); + + equal( + (await Services.search.getDefault()).name, + initialEngine.name, + `Default engine should still be set to the ${ + builtinAsInitialDefault ? "app-provided" : EXTENSION_ID + }.` + ); + + // Mock an update from settings stored as in an older Firefox version where Bug 1757760 was not landed yet. + info( + "Setup preconditions (inject mock extension-settings.json data and assert on the expected setting and levelOfControl)" + ); + + let addon = await AddonManager.getAddonByID(EXTENSION_ID); + let addon2 = await AddonManager.getAddonByID(EXTENSION_ID2); + + const extensionSettingsData = { + version: 2, + url_overrides: {}, + prefs: {}, + homepageNotification: {}, + tabHideNotification: {}, + default_search: { + defaultSearch: { + initialValue: Services.search.appDefaultEngine.name, + precedenceList: [ + { + id: EXTENSION_ID2, + // The install dates are used in ExtensionSettingsStore.getLevelOfControl + // and to recreate the expected preconditions the last extension installed + // should have a installDate timestamp > then the first one. + installDate: addon2.installDate.getTime() + 1000, + value: + extensionInfo2.manifest.chrome_settings_overrides.search_provider + .name, + // When an addon with a default search engine override is installed in Firefox versions + // without the changes landed from Bug 1757760, `enabled` will be set to true in all cases + // (Prompt never answered, or when No or Yes is selected by the user). + enabled: true, + }, + { + id: EXTENSION_ID, + installDate: addon.installDate.getTime(), + value: + extensionInfo.manifest.chrome_settings_overrides.search_provider + .name, + enabled: true, + }, + ], + }, + }, + newTabNotification: {}, + commands: {}, + }; + + const file = Services.dirsvc.get("ProfD", Ci.nsIFile); + file.append("extension-settings.json"); + + info(`writing mock settings data into ${file.path}`); + await IOUtils.writeJSON(file.path, extensionSettingsData); + await ExtensionSettingsStore._reloadFile(false); + + equal( + (await Services.search.getDefault()).name, + initialEngine.name, + "Default engine is still set to the initial one." + ); + + // The following assertions verify that the migration applied from ExtensionSettingsStore + // fixed the inconsistent state and kept the search engine unchanged. + // + // - With the fixed settings we expect both to be resolved to "controllable_by_this_extension". + // - Without the fix applied during the migration the levelOfControl resolved would be: + // - for the last installed: "controlled_by_this_extension" + // - for the first installed: "controlled_by_other_extensions" + await assertExtensionSettingsStore( + extensionInfo2, + "controlled_by_this_extension" + ); + await assertExtensionSettingsStore( + extensionInfo, + "controlled_by_other_extensions" + ); + + info( + "Verify that updating the extension does not prompt and does not take over the default engine" + ); + + extensionInfo2.manifest.version = "2.2"; + await assertUpdateDoNotPrompt(extension2, extensionInfo2); + + extensionInfo.manifest.version = "2.1"; + await assertUpdateDoNotPrompt(extension, extensionInfo); + + equal( + (await Services.search.getDefault()).name, + initialEngine.name, + "Default engine is still the same after updating both the test extensions." + ); + + // After both the extensions have been updated and their inconsistent state + // updated internally, both extensions should have levelOfControl "controllable_*". + await assertExtensionSettingsStore( + extensionInfo2, + "controllable_by_this_extension" + ); + await assertExtensionSettingsStore( + extensionInfo, + // We expect levelOfControl to be controlled_by_this_extension if the test case + // is expecting the third party extension to stay set as default. + builtinAsInitialDefault + ? "controllable_by_this_extension" + : "controlled_by_this_extension" + ); + + info("Verify that disable/enable the extension does prompt the user"); + + await withHandlingDefaultSearchPrompt( + { extensionId: EXTENSION_ID2, respond: false }, + async () => { + await addon2.disable(); + await addon2.enable(); + } + ); + + // we said no. + equal( + (await Services.search.getDefault()).name, + initialEngine.name, + `Default engine should still be the same after disabling/enabling ${EXTENSION_ID2}.` + ); + + await withHandlingDefaultSearchPrompt( + { extensionId: EXTENSION_ID, respond: false }, + async () => { + await addon.disable(); + await addon.enable(); + } + ); + + // we said no. + equal( + (await Services.search.getDefault()).name, + Services.search.appDefaultEngine.name, + `Default engine should be set to the app default after disabling/enabling ${EXTENSION_ID}.` + ); + + await withHandlingDefaultSearchPrompt( + { extensionId: EXTENSION_ID, respond: true }, + async () => { + await addon.disable(); + await addon.enable(); + } + ); + + // we responded yes. + equal( + (await Services.search.getDefault()).name, + extensionInfo.manifest.chrome_settings_overrides.search_provider.name, + "Default engine should be set to the one opted-in from the last prompt." + ); + + await extension.unload(); + await extension2.unload(); +} + +add_task(function test_builtin_default_search_after_updating_old_addons() { + return test_default_search_on_updating_addons_installed_before_bug1757760({ + builtinAsInitialDefault: true, + }); +}); + +add_task(function test_third_party_default_search_after_updating_old_addons() { + return test_default_search_on_updating_addons_installed_before_bug1757760({ + builtinAsInitialDefault: false, + }); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_distribution_popup.js b/browser/components/extensions/test/xpcshell/test_ext_distribution_popup.js new file mode 100644 index 0000000000..030e0b27be --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_distribution_popup.js @@ -0,0 +1,56 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionControlledPopup: + "resource:///modules/ExtensionControlledPopup.sys.mjs", + ExtensionSettingsStore: + "resource://gre/modules/ExtensionSettingsStore.sys.mjs", +}); + +/* + * This function is a unit test for distributions disabling the ExtensionControlledPopup. + */ +add_task(async function testDistributionPopup() { + let distExtId = "ext-distribution@mochi.test"; + Services.prefs.setCharPref( + `extensions.installedDistroAddon.${distExtId}`, + true + ); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: distExtId } }, + name: "Ext Distribution", + }, + }); + + let userExtId = "ext-user@mochi.test"; + let userExtension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: userExtId } }, + name: "Ext User Installed", + }, + }); + + await extension.startup(); + await userExtension.startup(); + await ExtensionSettingsStore.initialize(); + + let confirmedType = "extension-controlled-confirmed"; + equal( + new ExtensionControlledPopup({ confirmedType }).userHasConfirmed(distExtId), + true, + "The popup has been disabled." + ); + + equal( + new ExtensionControlledPopup({ confirmedType }).userHasConfirmed(userExtId), + false, + "The popup has not been disabled." + ); + + await extension.unload(); + await userExtension.unload(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_history.js b/browser/components/extensions/test/xpcshell/test_ext_history.js new file mode 100644 index 0000000000..c0f6c39be7 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_history.js @@ -0,0 +1,864 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +add_task(async function test_delete() { + function background() { + let historyClearedCount = 0; + let removedUrls = []; + + browser.history.onVisitRemoved.addListener(data => { + if (data.allHistory) { + historyClearedCount++; + browser.test.assertEq( + 0, + data.urls.length, + "onVisitRemoved received an empty urls array" + ); + } else { + removedUrls.push(...data.urls); + } + }); + + browser.test.onMessage.addListener((msg, arg) => { + if (msg === "delete-url") { + browser.history.deleteUrl({ url: arg }).then(result => { + browser.test.assertEq( + undefined, + result, + "browser.history.deleteUrl returns nothing" + ); + browser.test.sendMessage("url-deleted"); + }); + } else if (msg === "delete-range") { + browser.history.deleteRange(arg).then(result => { + browser.test.assertEq( + undefined, + result, + "browser.history.deleteRange returns nothing" + ); + browser.test.sendMessage("range-deleted", removedUrls); + }); + } else if (msg === "delete-all") { + browser.history.deleteAll().then(result => { + browser.test.assertEq( + undefined, + result, + "browser.history.deleteAll returns nothing" + ); + browser.test.sendMessage("history-cleared", [ + historyClearedCount, + removedUrls, + ]); + }); + } + }); + + browser.test.sendMessage("ready"); + } + + const BASE_URL = "http://mozilla.com/test_history/"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["history"], + }, + background: `(${background})()`, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + await PlacesUtils.history.clear(); + + let historyClearedCount; + let visits = []; + let visitDate = new Date(1999, 9, 9, 9, 9).getTime(); + + function pushVisit(subvisits) { + visitDate += 1000; + subvisits.push({ date: new Date(visitDate) }); + } + + // Add 5 visits for one uri and 3 visits for 3 others + for (let i = 0; i < 4; ++i) { + let visit = { + url: `${BASE_URL}${i}`, + title: "visit " + i, + visits: [], + }; + if (i === 0) { + for (let j = 0; j < 5; ++j) { + pushVisit(visit.visits); + } + } else { + pushVisit(visit.visits); + } + visits.push(visit); + } + + await PlacesUtils.history.insertMany(visits); + equal( + await PlacesTestUtils.visitsInDB(visits[0].url), + 5, + "5 visits for uri found in history database" + ); + + let testUrl = visits[2].url; + ok( + await PlacesTestUtils.isPageInDB(testUrl), + "expected url found in history database" + ); + + extension.sendMessage("delete-url", testUrl); + await extension.awaitMessage("url-deleted"); + equal( + await PlacesTestUtils.isPageInDB(testUrl), + false, + "expected url not found in history database" + ); + + // delete 3 of the 5 visits for url 1 + let filter = { + startTime: visits[0].visits[0].date, + endTime: visits[0].visits[2].date, + }; + + extension.sendMessage("delete-range", filter); + let removedUrls = await extension.awaitMessage("range-deleted"); + ok( + !removedUrls.includes(visits[0].url), + `${visits[0].url} not received by onVisitRemoved` + ); + ok( + await PlacesTestUtils.isPageInDB(visits[0].url), + "expected uri found in history database" + ); + equal( + await PlacesTestUtils.visitsInDB(visits[0].url), + 2, + "2 visits for uri found in history database" + ); + ok( + await PlacesTestUtils.isPageInDB(visits[1].url), + "expected uri found in history database" + ); + equal( + await PlacesTestUtils.visitsInDB(visits[1].url), + 1, + "1 visit for uri found in history database" + ); + + // delete the rest of the visits for url 1, and the visit for url 2 + filter.startTime = visits[0].visits[0].date; + filter.endTime = visits[1].visits[0].date; + + extension.sendMessage("delete-range", filter); + await extension.awaitMessage("range-deleted"); + + equal( + await PlacesTestUtils.isPageInDB(visits[0].url), + false, + "expected uri not found in history database" + ); + equal( + await PlacesTestUtils.visitsInDB(visits[0].url), + 0, + "0 visits for uri found in history database" + ); + equal( + await PlacesTestUtils.isPageInDB(visits[1].url), + false, + "expected uri not found in history database" + ); + equal( + await PlacesTestUtils.visitsInDB(visits[1].url), + 0, + "0 visits for uri found in history database" + ); + + ok( + await PlacesTestUtils.isPageInDB(visits[3].url), + "expected uri found in history database" + ); + + extension.sendMessage("delete-all"); + [historyClearedCount, removedUrls] = await extension.awaitMessage( + "history-cleared" + ); + equal( + historyClearedCount, + 2, + "onVisitRemoved called for each clearing of history" + ); + equal( + removedUrls.length, + 3, + "onVisitRemoved called the expected number of times" + ); + for (let i = 1; i < 3; ++i) { + let url = visits[i].url; + ok(removedUrls.includes(url), `${url} received by onVisitRemoved`); + } + await extension.unload(); +}); + +const SINGLE_VISIT_URL = "http://example.com/"; +const DOUBLE_VISIT_URL = "http://example.com/2/"; +const MOZILLA_VISIT_URL = "http://mozilla.com/"; +const REFERENCE_DATE = new Date(); +// pages/visits to add via History.insert +const PAGE_INFOS = [ + { + url: SINGLE_VISIT_URL, + title: `test visit for ${SINGLE_VISIT_URL}`, + visits: [{ date: new Date(Number(REFERENCE_DATE) - 1000) }], + }, + { + url: DOUBLE_VISIT_URL, + title: `test visit for ${DOUBLE_VISIT_URL}`, + visits: [ + { date: REFERENCE_DATE }, + { date: new Date(Number(REFERENCE_DATE) - 2000) }, + ], + }, + { + url: MOZILLA_VISIT_URL, + title: `test visit for ${MOZILLA_VISIT_URL}`, + visits: [{ date: new Date(Number(REFERENCE_DATE) - 3000) }], + }, +]; + +add_task(async function test_search() { + function background(BGSCRIPT_REFERENCE_DATE) { + const futureTime = Date.now() + 24 * 60 * 60 * 1000; + + browser.test.onMessage.addListener(msg => { + browser.history + .search({ text: "" }) + .then(results => { + browser.test.sendMessage("empty-search", results); + return browser.history.search({ text: "mozilla.com" }); + }) + .then(results => { + browser.test.sendMessage("text-search", results); + return browser.history.search({ text: "example.com", maxResults: 1 }); + }) + .then(results => { + browser.test.sendMessage("max-results-search", results); + return browser.history.search({ + text: "", + startTime: BGSCRIPT_REFERENCE_DATE - 2000, + endTime: BGSCRIPT_REFERENCE_DATE - 1000, + }); + }) + .then(results => { + browser.test.sendMessage("date-range-search", results); + return browser.history.search({ text: "", startTime: futureTime }); + }) + .then(results => { + browser.test.assertEq( + 0, + results.length, + "no results returned for late start time" + ); + return browser.history.search({ text: "", endTime: 0 }); + }) + .then(results => { + browser.test.assertEq( + 0, + results.length, + "no results returned for early end time" + ); + return browser.history.search({ + text: "", + startTime: Date.now(), + endTime: 0, + }); + }) + .then( + results => { + browser.test.fail( + "history.search rejects with startTime that is after the endTime" + ); + }, + error => { + browser.test.assertEq( + "The startTime cannot be after the endTime", + error.message, + "history.search rejects with startTime that is after the endTime" + ); + } + ) + .then(() => { + browser.test.notifyPass("search"); + }); + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["history"], + }, + background: `(${background})(${Number(REFERENCE_DATE)})`, + }); + + function findResult(url, results) { + return results.find(r => r.url === url); + } + + function checkResult(results, url, expectedCount) { + let result = findResult(url, results); + notEqual(result, null, `history.search result was found for ${url}`); + equal( + result.visitCount, + expectedCount, + `history.search reports ${expectedCount} visit(s)` + ); + equal( + result.title, + `test visit for ${url}`, + "title for search result is correct" + ); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + await PlacesUtils.history.clear(); + + await PlacesUtils.history.insertMany(PAGE_INFOS); + + extension.sendMessage("check-history"); + + let results = await extension.awaitMessage("empty-search"); + equal(results.length, 3, "history.search with empty text returned 3 results"); + checkResult(results, SINGLE_VISIT_URL, 1); + checkResult(results, DOUBLE_VISIT_URL, 2); + checkResult(results, MOZILLA_VISIT_URL, 1); + + results = await extension.awaitMessage("text-search"); + equal( + results.length, + 1, + "history.search with specific text returned 1 result" + ); + checkResult(results, MOZILLA_VISIT_URL, 1); + + results = await extension.awaitMessage("max-results-search"); + equal(results.length, 1, "history.search with maxResults returned 1 result"); + checkResult(results, DOUBLE_VISIT_URL, 2); + + results = await extension.awaitMessage("date-range-search"); + equal( + results.length, + 2, + "history.search with a date range returned 2 result" + ); + checkResult(results, DOUBLE_VISIT_URL, 2); + checkResult(results, SINGLE_VISIT_URL, 1); + + await extension.awaitFinish("search"); + await extension.unload(); + await PlacesUtils.history.clear(); +}); + +add_task(async function test_add_url() { + function background() { + const TEST_DOMAIN = "http://example.com/"; + + browser.test.onMessage.addListener((msg, testData) => { + let [details, type] = testData; + details.url = details.url || `${TEST_DOMAIN}${type}`; + if (msg === "add-url") { + details.title = `Title for ${type}`; + browser.history + .addUrl(details) + .then(() => { + return browser.history.search({ text: details.url }); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "1 result found when searching for added URL" + ); + browser.test.sendMessage("url-added", { + details, + result: results[0], + }); + }); + } else if (msg === "expect-failure") { + let expectedMsg = testData[2]; + browser.history.addUrl(details).then( + () => { + browser.test.fail(`Expected error thrown for ${type}`); + }, + error => { + browser.test.assertTrue( + error.message.includes(expectedMsg), + `"Expected error thrown when trying to add a URL with ${type}` + ); + browser.test.sendMessage("add-failed"); + } + ); + } + }); + + browser.test.sendMessage("ready"); + } + + let addTestData = [ + [{}, "default"], + [{ visitTime: new Date() }, "with_date"], + [{ visitTime: Date.now() }, "with_ms_number"], + [{ visitTime: new Date().toISOString() }, "with_iso_string"], + [{ transition: "typed" }, "valid_transition"], + ]; + + let failTestData = [ + [ + { transition: "generated" }, + "an invalid transition", + "|generated| is not a supported transition for history", + ], + [{ visitTime: Date.now() + 1000000 }, "a future date", "Invalid value"], + [{ url: "about.config" }, "an invalid url", "Invalid value"], + ]; + + async function checkUrl(results) { + ok( + await PlacesTestUtils.isPageInDB(results.details.url), + `${results.details.url} found in history database` + ); + ok( + PlacesUtils.isValidGuid(results.result.id), + "URL was added with a valid id" + ); + equal( + results.result.title, + results.details.title, + "URL was added with the correct title" + ); + if (results.details.visitTime) { + equal( + results.result.lastVisitTime, + Number(ExtensionCommon.normalizeTime(results.details.visitTime)), + "URL was added with the correct date" + ); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["history"], + }, + background: `(${background})()`, + }); + + await PlacesUtils.history.clear(); + await extension.startup(); + await extension.awaitMessage("ready"); + + for (let data of addTestData) { + extension.sendMessage("add-url", data); + let results = await extension.awaitMessage("url-added"); + await checkUrl(results); + } + + for (let data of failTestData) { + extension.sendMessage("expect-failure", data); + await extension.awaitMessage("add-failed"); + } + + await extension.unload(); +}); + +add_task(async function test_get_visits() { + async function background() { + const TEST_DOMAIN = "http://example.com/"; + const FIRST_DATE = Date.now(); + const INITIAL_DETAILS = { + url: TEST_DOMAIN, + visitTime: FIRST_DATE, + transition: "link", + }; + + let visitIds = new Set(); + + async function checkVisit(visit, expected) { + visitIds.add(visit.visitId); + browser.test.assertEq( + expected.visitTime, + visit.visitTime, + "visit has the correct visitTime" + ); + browser.test.assertEq( + expected.transition, + visit.transition, + "visit has the correct transition" + ); + let results = await browser.history.search({ text: expected.url }); + // all results will have the same id, so we only need to use the first one + browser.test.assertEq( + results[0].id, + visit.id, + "visit has the correct id" + ); + } + + let details = Object.assign({}, INITIAL_DETAILS); + + await browser.history.addUrl(details); + let results = await browser.history.getVisits({ url: details.url }); + + browser.test.assertEq( + 1, + results.length, + "the expected number of visits were returned" + ); + await checkVisit(results[0], details); + + details.url = `${TEST_DOMAIN}/1/`; + await browser.history.addUrl(details); + + results = await browser.history.getVisits({ url: details.url }); + browser.test.assertEq( + 1, + results.length, + "the expected number of visits were returned" + ); + await checkVisit(results[0], details); + + details.visitTime = FIRST_DATE - 1000; + details.transition = "typed"; + await browser.history.addUrl(details); + results = await browser.history.getVisits({ url: details.url }); + + browser.test.assertEq( + 2, + results.length, + "the expected number of visits were returned" + ); + await checkVisit(results[0], INITIAL_DETAILS); + await checkVisit(results[1], details); + browser.test.assertEq(3, visitIds.size, "each visit has a unique visitId"); + await browser.test.notifyPass("get-visits"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["history"], + }, + background: `(${background})()`, + }); + + await PlacesUtils.history.clear(); + await extension.startup(); + + await extension.awaitFinish("get-visits"); + await extension.unload(); +}); + +add_task(async function test_transition_types() { + const VISIT_URL_PREFIX = "http://example.com/"; + const TRANSITIONS = [ + ["link", Ci.nsINavHistoryService.TRANSITION_LINK], + ["typed", Ci.nsINavHistoryService.TRANSITION_TYPED], + ["auto_bookmark", Ci.nsINavHistoryService.TRANSITION_BOOKMARK], + // Only session history contains TRANSITION_EMBED visits, + // So global history query cannot find them. + // ["auto_subframe", Ci.nsINavHistoryService.TRANSITION_EMBED], + // Redirects are not correctly tested here because History + // will not make redirect entries hidden. + ["link", Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT], + ["link", Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY], + ["link", Ci.nsINavHistoryService.TRANSITION_DOWNLOAD], + ["manual_subframe", Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK], + ["reload", Ci.nsINavHistoryService.TRANSITION_RELOAD], + ]; + + // pages/visits to add via History.insertMany + let pageInfos = []; + let visitDate = new Date(1999, 9, 9, 9, 9).getTime(); + for (let [, transitionType] of TRANSITIONS) { + pageInfos.push({ + url: VISIT_URL_PREFIX + transitionType + "/", + visits: [ + { transition: transitionType, date: new Date((visitDate -= 1000)) }, + ], + }); + } + + function background() { + browser.test.onMessage.addListener(async (msg, url) => { + switch (msg) { + case "search": { + let results = await browser.history.search({ + text: "", + startTime: new Date(0), + }); + browser.test.sendMessage("search-result", results); + break; + } + case "get-visits": { + let results = await browser.history.getVisits({ url }); + browser.test.sendMessage("get-visits-result", results); + break; + } + } + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["history"], + }, + background, + }); + + await PlacesUtils.history.clear(); + await extension.startup(); + await extension.awaitMessage("ready"); + + await PlacesUtils.history.insertMany(pageInfos); + + extension.sendMessage("search"); + let results = await extension.awaitMessage("search-result"); + equal( + results.length, + pageInfos.length, + "search returned expected length of results" + ); + for (let i = 0; i < pageInfos.length; ++i) { + equal(results[i].url, pageInfos[i].url, "search returned the expected url"); + + extension.sendMessage("get-visits", pageInfos[i].url); + let visits = await extension.awaitMessage("get-visits-result"); + equal(visits.length, 1, "getVisits returned expected length of visits"); + equal( + visits[0].transition, + TRANSITIONS[i][0], + "getVisits returned the expected transition" + ); + } + + await extension.unload(); +}); + +add_task(async function test_on_visited() { + const SINGLE_VISIT_URL = "http://example.com/1/"; + const DOUBLE_VISIT_URL = "http://example.com/2/"; + let visitDate = new Date(1999, 9, 9, 9, 9).getTime(); + + // pages/visits to add via History.insertMany + const PAGE_INFOS = [ + { + url: SINGLE_VISIT_URL, + title: `visit to ${SINGLE_VISIT_URL}`, + visits: [{ date: new Date(visitDate) }], + }, + { + url: DOUBLE_VISIT_URL, + title: `visit to ${DOUBLE_VISIT_URL}`, + visits: [ + { date: new Date((visitDate += 1000)) }, + { date: new Date((visitDate += 1000)) }, + ], + }, + { + url: SINGLE_VISIT_URL, + title: "Title Changed", + visits: [{ date: new Date(visitDate) }], + }, + ]; + + function background() { + let onVisitedData = []; + + browser.history.onVisited.addListener(data => { + if (data.url.includes("moz-extension")) { + return; + } + onVisitedData.push(data); + if (onVisitedData.length == 4) { + browser.test.sendMessage("on-visited-data", onVisitedData); + } + }); + + // Verifying onTitleChange Event along with onVisited event + browser.history.onTitleChanged.addListener(data => { + browser.test.sendMessage("on-title-changed-data", data); + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["history"], + }, + background: `(${background})()`, + }); + + await PlacesUtils.history.clear(); + await extension.startup(); + await extension.awaitMessage("ready"); + + await PlacesUtils.history.insertMany(PAGE_INFOS); + + let onVisitedData = await extension.awaitMessage("on-visited-data"); + + function checkOnVisitedData(index, expected) { + let onVisited = onVisitedData[index]; + ok(PlacesUtils.isValidGuid(onVisited.id), "onVisited received a valid id"); + equal(onVisited.url, expected.url, "onVisited received the expected url"); + equal( + onVisited.title, + expected.title, + "onVisited received the expected title" + ); + equal( + onVisited.lastVisitTime, + expected.time, + "onVisited received the expected time" + ); + equal( + onVisited.visitCount, + expected.visitCount, + "onVisited received the expected visitCount" + ); + } + + let expected = { + url: PAGE_INFOS[0].url, + title: PAGE_INFOS[0].title, + time: PAGE_INFOS[0].visits[0].date.getTime(), + visitCount: 1, + }; + checkOnVisitedData(0, expected); + + expected.url = PAGE_INFOS[1].url; + expected.title = PAGE_INFOS[1].title; + expected.time = PAGE_INFOS[1].visits[0].date.getTime(); + checkOnVisitedData(1, expected); + + expected.time = PAGE_INFOS[1].visits[1].date.getTime(); + expected.visitCount = 2; + checkOnVisitedData(2, expected); + + expected.url = PAGE_INFOS[2].url; + expected.title = PAGE_INFOS[2].title; + expected.time = PAGE_INFOS[2].visits[0].date.getTime(); + expected.visitCount = 2; + checkOnVisitedData(3, expected); + + let onTitleChangedData = await extension.awaitMessage( + "on-title-changed-data" + ); + Assert.deepEqual( + { + id: onVisitedData[3].id, + url: SINGLE_VISIT_URL, + title: "Title Changed", + }, + onTitleChangedData, + "expected event data for onTitleChanged" + ); + + await extension.unload(); +}); + +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + }, + async function test_history_event_page() { + await AddonTestUtils.promiseStartupManager(); + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "eventpage@history" } }, + permissions: ["history"], + background: { persistent: false }, + }, + background() { + browser.history.onVisited.addListener(() => { + browser.test.sendMessage("onVisited"); + }); + browser.history.onVisitRemoved.addListener(() => { + browser.test.sendMessage("onVisitRemoved"); + }); + browser.history.onTitleChanged.addListener(() => {}); + browser.test.sendMessage("ready"); + }, + }); + + const EVENTS = ["onVisited", "onVisitRemoved", "onTitleChanged"]; + await PlacesUtils.history.clear(); + + await extension.startup(); + await extension.awaitMessage("ready"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "history", event, { + primed: false, + }); + } + + // test events waken background + await extension.terminateBackground(); + for (let event of EVENTS) { + assertPersistentListeners(extension, "history", event, { + primed: true, + }); + } + + await PlacesUtils.history.insertMany(PAGE_INFOS); + + await extension.awaitMessage("ready"); + await extension.awaitMessage("onVisited"); + ok(true, "persistent event woke background"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "history", event, { + primed: false, + }); + } + + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + for (let event of EVENTS) { + assertPersistentListeners(extension, "history", event, { + primed: true, + }); + } + + await PlacesUtils.history.clear(); + await extension.awaitMessage("ready"); + await extension.awaitMessage("onVisitRemoved"); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + } +); diff --git a/browser/components/extensions/test/xpcshell/test_ext_homepage_overrides_private.js b/browser/components/extensions/test/xpcshell/test_ext_homepage_overrides_private.js new file mode 100644 index 0000000000..2d2bccc1e2 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_homepage_overrides_private.js @@ -0,0 +1,134 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); +const { HomePage } = ChromeUtils.importESModule( + "resource:///modules/HomePage.sys.mjs" +); +const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +const { createAppInfo, promiseShutdownManager, promiseStartupManager } = + AddonTestUtils; + +const EXTENSION_ID = "test_overrides@tests.mozilla.org"; +const HOMEPAGE_EXTENSION_CONTROLLED = + "browser.startup.homepage_override.extensionControlled"; +const HOMEPAGE_PRIVATE_ALLOWED = + "browser.startup.homepage_override.privateAllowed"; +const HOMEPAGE_URL_PREF = "browser.startup.homepage"; +const HOMEPAGE_URI = "webext-homepage-1.html"; + +Services.prefs.setBoolPref("browser.privatebrowsing.autostart", true); + +AddonTestUtils.init(this); +AddonTestUtils.usePrivilegedSignatures = false; +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +function promisePrefChange(pref) { + return new Promise((resolve, reject) => { + Services.prefs.addObserver(pref, function observer() { + Services.prefs.removeObserver(pref, observer); + resolve(arguments); + }); + }); +} + +let defaultHomepageURL; + +function verifyPrefSettings(controlled, allowed) { + equal( + Services.prefs.getBoolPref(HOMEPAGE_EXTENSION_CONTROLLED, false), + controlled, + "homepage extension controlled" + ); + equal( + Services.prefs.getBoolPref(HOMEPAGE_PRIVATE_ALLOWED, false), + allowed, + "homepage private permission after permission change" + ); + + if (controlled && allowed) { + ok( + HomePage.get().endsWith(HOMEPAGE_URI), + "Home page url is overridden by the extension" + ); + } else { + equal(HomePage.get(), defaultHomepageURL, "Home page url is default."); + } +} + +async function promiseUpdatePrivatePermission(allowed, extension) { + info(`update private allowed permission`); + await Promise.all([ + promisePrefChange(HOMEPAGE_PRIVATE_ALLOWED), + ExtensionPermissions[allowed ? "add" : "remove"]( + extension.id, + { permissions: ["internal:privateBrowsingAllowed"], origins: [] }, + extension + ), + ]); + + verifyPrefSettings(true, allowed); +} + +add_task(async function test_overrides_private() { + await promiseStartupManager(); + + let extensionInfo = { + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + chrome_settings_overrides: { + homepage: HOMEPAGE_URI, + }, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionInfo); + + defaultHomepageURL = HomePage.get(); + + await extension.startup(); + + verifyPrefSettings(true, false); + + equal(HomePage.get(), defaultHomepageURL, "Home page url is default."); + + info("add permission to extension"); + await promiseUpdatePrivatePermission(true, extension.extension); + info("remove permission from extension"); + await promiseUpdatePrivatePermission(false, extension.extension); + // set back to true to test upgrade removing extension control + info("add permission back to prepare for upgrade test"); + await promiseUpdatePrivatePermission(true, extension.extension); + + extensionInfo.manifest = { + version: "2.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + }; + + await Promise.all([ + promisePrefChange(HOMEPAGE_URL_PREF), + extension.upgrade(extensionInfo), + ]); + + verifyPrefSettings(false, false); + + await extension.unload(); + await promiseShutdownManager(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_manifest.js b/browser/components/extensions/test/xpcshell/test_ext_manifest.js new file mode 100644 index 0000000000..b978172ca2 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_manifest.js @@ -0,0 +1,105 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +async function testManifest(manifest, expectedError) { + ExtensionTestUtils.failOnSchemaWarnings(false); + let normalized = await ExtensionTestUtils.normalizeManifest(manifest); + ExtensionTestUtils.failOnSchemaWarnings(true); + + if (expectedError) { + ok( + expectedError.test(normalized.error), + `Should have an error for ${JSON.stringify(manifest)}, got ${ + normalized.error + }` + ); + } else { + ok( + !normalized.error, + `Should not have an error ${JSON.stringify(manifest)}, ${ + normalized.error + }` + ); + } + return normalized.errors; +} + +const all_actions = [ + "action", + "browser_action", + "page_action", + "sidebar_action", +]; + +add_task(async function test_manifest() { + let badpaths = ["", " ", "\t", "http://foo.com/icon.png"]; + for (let path of badpaths) { + for (let action of all_actions) { + let manifest_version = action == "action" ? 3 : 2; + let manifest = { manifest_version }; + manifest[action] = { default_icon: path }; + let error = new RegExp(`Error processing ${action}.default_icon`); + await testManifest(manifest, error); + + manifest[action] = { default_icon: { 16: path } }; + await testManifest(manifest, error); + } + } + + let paths = [ + "icon.png", + "/icon.png", + "./icon.png", + "path to an icon.png", + " icon.png", + ]; + for (let path of paths) { + for (let action of all_actions) { + let manifest_version = action == "action" ? 3 : 2; + let manifest = { manifest_version }; + manifest[action] = { default_icon: path }; + if (action == "sidebar_action") { + // Sidebar requires panel. + manifest[action].default_panel = "foo.html"; + } + await testManifest(manifest); + + manifest[action] = { default_icon: { 16: path } }; + if (action == "sidebar_action") { + manifest[action].default_panel = "foo.html"; + } + await testManifest(manifest); + } + } +}); + +add_task(async function test_action_version() { + let warnings = await testManifest({ + manifest_version: 3, + browser_action: { + default_panel: "foo.html", + }, + }); + Assert.deepEqual( + warnings, + [`Property "browser_action" is unsupported in Manifest Version 3`], + `Manifest v3 with "browser_action" key logs an error.` + ); + + warnings = await testManifest({ + manifest_version: 2, + action: { + default_icon: "", + default_panel: "foo.html", + }, + }); + + Assert.deepEqual( + warnings, + [`Property "action" is unsupported in Manifest Version 2`], + `Manifest v2 with "action" key first warning is clear.` + ); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_manifest_commands.js b/browser/components/extensions/test/xpcshell/test_ext_manifest_commands.js new file mode 100644 index 0000000000..8196ab0e24 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_manifest_commands.js @@ -0,0 +1,52 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_manifest_commands() { + const validShortcuts = [ + "Ctrl+Y", + "MacCtrl+Y", + "Command+Y", + "Alt+Shift+Y", + "Ctrl+Alt+Y", + "F1", + "MediaNextTrack", + ]; + const invalidShortcuts = ["Shift+Y", "Y", "Ctrl+Ctrl+Y", "Ctrl+Command+Y"]; + + async function validateShortcut(shortcut, isValid) { + let normalized = await ExtensionTestUtils.normalizeManifest({ + commands: { + "toggle-feature": { + suggested_key: { default: shortcut }, + description: "Send a 'toggle-feature' event to the extension", + }, + }, + }); + if (isValid) { + ok(!normalized.error, "There should be no manifest errors."); + } else { + let expectedError = + String.raw`Error processing commands.toggle-feature.suggested_key.default: Error: ` + + String.raw`Value "${shortcut}" must consist of ` + + String.raw`either a combination of one or two modifiers, including ` + + String.raw`a mandatory primary modifier and a key, separated by '+', ` + + String.raw`or a media key. For details see: ` + + String.raw`https://developer.mozilla.org/en-US/Add-ons/WebExtensions/manifest.json/commands#Key_combinations`; + + ok( + normalized.error.includes(expectedError), + `The manifest error ${JSON.stringify( + normalized.error + )} must contain ${JSON.stringify(expectedError)}` + ); + } + } + + for (let shortcut of validShortcuts) { + validateShortcut(shortcut, true); + } + for (let shortcut of invalidShortcuts) { + validateShortcut(shortcut, false); + } +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_manifest_omnibox.js b/browser/components/extensions/test/xpcshell/test_ext_manifest_omnibox.js new file mode 100644 index 0000000000..f81e7d3cb5 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_manifest_omnibox.js @@ -0,0 +1,62 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function testKeyword(params) { + let normalized = await ExtensionTestUtils.normalizeManifest({ + omnibox: { + keyword: params.keyword, + }, + }); + + if (params.expectError) { + let expectedError = + String.raw`omnibox.keyword: String "${params.keyword}" ` + + String.raw`must match /^[^?\s:][^\s:]*$/`; + ok( + normalized.error.includes(expectedError), + `The manifest error ${JSON.stringify(normalized.error)} ` + + `must contain ${JSON.stringify(expectedError)}` + ); + } else { + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 0, "Should not have warnings"); + } +} + +add_task(async function test_manifest_commands() { + // accepted single character keywords + await testKeyword({ keyword: "a", expectError: false }); + await testKeyword({ keyword: "-", expectError: false }); + await testKeyword({ keyword: "嗨", expectError: false }); + await testKeyword({ keyword: "*", expectError: false }); + await testKeyword({ keyword: "/", expectError: false }); + + // rejected single character keywords + await testKeyword({ keyword: "?", expectError: true }); + await testKeyword({ keyword: " ", expectError: true }); + await testKeyword({ keyword: ":", expectError: true }); + + // accepted multi-character keywords + await testKeyword({ keyword: "aa", expectError: false }); + await testKeyword({ keyword: "http", expectError: false }); + await testKeyword({ keyword: "f?a", expectError: false }); + await testKeyword({ keyword: "fa?", expectError: false }); + await testKeyword({ keyword: "f/x", expectError: false }); + await testKeyword({ keyword: "/fx", expectError: false }); + await testKeyword({ keyword: "fx/", expectError: false }); + + // rejected multi-character keywords + await testKeyword({ keyword: " a", expectError: true }); + await testKeyword({ keyword: "a ", expectError: true }); + await testKeyword({ keyword: " ", expectError: true }); + await testKeyword({ keyword: " a ", expectError: true }); + await testKeyword({ keyword: "?fx", expectError: true }); + await testKeyword({ keyword: "f:x", expectError: true }); + await testKeyword({ keyword: "fx:", expectError: true }); + await testKeyword({ keyword: "f x", expectError: true }); + + // miscellaneous tests + await testKeyword({ keyword: "こんにちは", expectError: false }); + await testKeyword({ keyword: "http://", expectError: true }); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_manifest_permissions.js b/browser/components/extensions/test/xpcshell/test_ext_manifest_permissions.js new file mode 100644 index 0000000000..fed7af5d5b --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_manifest_permissions.js @@ -0,0 +1,85 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* globals chrome */ + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +async function testPermission(options) { + function background(bgOptions) { + browser.test.sendMessage("typeof-namespace", { + browser: typeof browser[bgOptions.namespace], + chrome: typeof chrome[bgOptions.namespace], + }); + } + + let extensionDetails = { + background: `(${background})(${JSON.stringify(options)})`, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionDetails); + + await extension.startup(); + + let types = await extension.awaitMessage("typeof-namespace"); + equal( + types.browser, + "undefined", + `Type of browser.${options.namespace} without manifest entry` + ); + equal( + types.chrome, + "undefined", + `Type of chrome.${options.namespace} without manifest entry` + ); + + await extension.unload(); + + extensionDetails.manifest = options.manifest; + extension = ExtensionTestUtils.loadExtension(extensionDetails); + + await extension.startup(); + + types = await extension.awaitMessage("typeof-namespace"); + equal( + types.browser, + "object", + `Type of browser.${options.namespace} with manifest entry` + ); + equal( + types.chrome, + "object", + `Type of chrome.${options.namespace} with manifest entry` + ); + + await extension.unload(); +} + +add_task(async function test_action() { + await testPermission({ + namespace: "action", + manifest: { + manifest_version: 3, + action: {}, + }, + }); +}); + +add_task(async function test_browserAction() { + await testPermission({ + namespace: "browserAction", + manifest: { + browser_action: {}, + }, + }); +}); + +add_task(async function test_pageAction() { + await testPermission({ + namespace: "pageAction", + manifest: { + page_action: {}, + }, + }); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_menu_caller.js b/browser/components/extensions/test/xpcshell/test_ext_menu_caller.js new file mode 100644 index 0000000000..5aa04bbc78 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_menu_caller.js @@ -0,0 +1,53 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_create_menu_ext_error() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + async background() { + let { fileName } = new Error(); + browser.menus.create({ + id: "muted-tab", + title: "open link with Menu 1", + contexts: ["link"], + }); + await new Promise(resolve => { + browser.menus.create( + { + id: "muted-tab", + title: "open link with Menu 2", + contexts: ["link"], + }, + resolve + ); + }); + browser.test.sendMessage("fileName", fileName); + }, + }); + + let fileName; + const { messages } = await promiseConsoleOutput(async () => { + await extension.startup(); + fileName = await extension.awaitMessage("fileName"); + await extension.unload(); + }); + let [msg] = messages + .filter(m => m.message.includes("Unchecked lastError")) + .map(m => m.QueryInterface(Ci.nsIScriptError)); + equal(msg.sourceName, fileName, "Message source"); + + equal( + msg.errorMessage, + "Unchecked lastError value: Error: ID already exists: muted-tab", + "Message content" + ); + equal(msg.lineNumber, 9, "Message line"); + + let frame = msg.stack; + equal(frame.source, fileName, "Frame source"); + equal(frame.line, 9, "Frame line"); + equal(frame.column, 23, "Frame column"); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_menu_startup.js b/browser/components/extensions/test/xpcshell/test_ext_menu_startup.js new file mode 100644 index 0000000000..aa019c6584 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_menu_startup.js @@ -0,0 +1,432 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + Management: "resource://gre/modules/Extension.sys.mjs", +}); + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +Services.prefs.setBoolPref("extensions.eventPages.enabled", true); + +function getExtension(id, background, useAddonManager) { + return ExtensionTestUtils.loadExtension({ + useAddonManager, + manifest: { + browser_specific_settings: { gecko: { id } }, + permissions: ["menus"], + background: { persistent: false }, + }, + background, + }); +} + +async function expectCached(extension, expect) { + let { StartupCache } = ExtensionParent; + let cached = await StartupCache.menus.get(extension.id); + let createProperties = Array.from(cached.values()); + equal(cached.size, expect.length, "menus saved in cache"); + // The menus startupCache is a map and the order is significant + // for recreating menus on startup. Ensure that they are in + // the expected order. We only verify specific keys here rather + // than all menu properties. + for (let i in createProperties) { + Assert.deepEqual( + createProperties[i], + expect[i], + "expected cached properties exist" + ); + } +} + +function promiseExtensionEvent(wrapper, event) { + return new Promise(resolve => { + wrapper.extension.once(event, (kind, data) => { + resolve(data); + }); + }); +} + +add_setup(async () => { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_menu_onInstalled() { + async function background() { + browser.runtime.onInstalled.addListener(async () => { + const parentId = browser.menus.create({ + contexts: ["all"], + title: "parent", + id: "test-parent", + }); + browser.menus.create({ + parentId, + title: "click A", + id: "test-click-a", + }); + browser.menus.create( + { + parentId, + title: "click B", + id: "test-click-b", + }, + () => { + browser.test.sendMessage("onInstalled"); + } + ); + }); + browser.menus.create( + { + contexts: ["tab"], + title: "top-level", + id: "test-top-level", + }, + () => { + browser.test.sendMessage("create", browser.runtime.lastError?.message); + } + ); + + browser.test.onMessage.addListener(async msg => { + browser.test.log(`onMessage ${msg}`); + if (msg == "updatemenu") { + await browser.menus.update("test-click-a", { title: "click updated" }); + } else if (msg == "removemenu") { + await browser.menus.remove("test-click-b"); + } else if (msg == "removeall") { + await browser.menus.removeAll(); + } + browser.test.sendMessage("updated"); + }); + } + + const extension = getExtension( + "test-persist@mochitest", + background, + "permanent" + ); + + await extension.startup(); + let lastError = await extension.awaitMessage("create"); + Assert.equal(lastError, undefined, "no error creating menu"); + await extension.awaitMessage("onInstalled"); + await extension.terminateBackground(); + + await expectCached(extension, [ + { + contexts: ["tab"], + id: "test-top-level", + title: "top-level", + }, + { contexts: ["all"], id: "test-parent", title: "parent" }, + { + id: "test-click-a", + parentId: "test-parent", + title: "click A", + }, + { + id: "test-click-b", + parentId: "test-parent", + title: "click B", + }, + ]); + + await extension.wakeupBackground(); + lastError = await extension.awaitMessage("create"); + Assert.equal( + lastError, + "The menu id test-top-level already exists in menus.create.", + "correct error creating menu" + ); + + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + // verify the startupcache + await expectCached(extension, [ + { + contexts: ["tab"], + id: "test-top-level", + title: "top-level", + }, + { contexts: ["all"], id: "test-parent", title: "parent" }, + { + id: "test-click-a", + parentId: "test-parent", + title: "click A", + }, + { + id: "test-click-b", + parentId: "test-parent", + title: "click B", + }, + ]); + + equal( + extension.extension.backgroundState, + "stopped", + "background is not running" + ); + await extension.wakeupBackground(); + lastError = await extension.awaitMessage("create"); + Assert.equal( + lastError, + "The menu id test-top-level already exists in menus.create.", + "correct error creating menu" + ); + + extension.sendMessage("updatemenu"); + await extension.awaitMessage("updated"); + await extension.terminateBackground(); + + // Title change is cached + await expectCached(extension, [ + { + contexts: ["tab"], + id: "test-top-level", + title: "top-level", + }, + { contexts: ["all"], id: "test-parent", title: "parent" }, + { + id: "test-click-a", + parentId: "test-parent", + title: "click updated", + }, + { + id: "test-click-b", + parentId: "test-parent", + title: "click B", + }, + ]); + + await extension.wakeupBackground(); + lastError = await extension.awaitMessage("create"); + Assert.equal( + lastError, + "The menu id test-top-level already exists in menus.create.", + "correct error creating menu" + ); + + extension.sendMessage("removemenu"); + await extension.awaitMessage("updated"); + await extension.terminateBackground(); + + // menu removed + await expectCached(extension, [ + { + contexts: ["tab"], + id: "test-top-level", + title: "top-level", + }, + { contexts: ["all"], id: "test-parent", title: "parent" }, + { + id: "test-click-a", + parentId: "test-parent", + title: "click updated", + }, + ]); + + await extension.wakeupBackground(); + lastError = await extension.awaitMessage("create"); + Assert.equal( + lastError, + "The menu id test-top-level already exists in menus.create.", + "correct error creating menu" + ); + + extension.sendMessage("removeall"); + await extension.awaitMessage("updated"); + await extension.terminateBackground(); + + // menus removed + await expectCached(extension, []); + + await extension.unload(); +}); + +add_task(async function test_menu_nested() { + async function background() { + browser.test.onMessage.addListener(async (action, properties) => { + browser.test.log(`onMessage ${action}`); + switch (action) { + case "create": + await new Promise(resolve => { + browser.menus.create(properties, resolve); + }); + break; + case "update": + { + let { id, ...update } = properties; + await browser.menus.update(id, update); + } + break; + case "remove": + { + let { id } = properties; + await browser.menus.remove(id); + } + break; + case "removeAll": + await browser.menus.removeAll(); + break; + } + browser.test.sendMessage("updated"); + }); + } + + const extension = getExtension( + "test-nesting@mochitest", + background, + "permanent" + ); + await extension.startup(); + + extension.sendMessage("create", { + id: "first", + contexts: ["all"], + title: "first", + }); + await extension.awaitMessage("updated"); + await expectCached(extension, [ + { contexts: ["all"], id: "first", title: "first" }, + ]); + + extension.sendMessage("create", { + id: "second", + contexts: ["all"], + title: "second", + }); + await extension.awaitMessage("updated"); + await expectCached(extension, [ + { contexts: ["all"], id: "first", title: "first" }, + { contexts: ["all"], id: "second", title: "second" }, + ]); + + extension.sendMessage("create", { + id: "third", + contexts: ["all"], + title: "third", + parentId: "first", + }); + await extension.awaitMessage("updated"); + await expectCached(extension, [ + { contexts: ["all"], id: "first", title: "first" }, + { contexts: ["all"], id: "second", title: "second" }, + { + contexts: ["all"], + id: "third", + parentId: "first", + title: "third", + }, + ]); + + extension.sendMessage("create", { + id: "fourth", + contexts: ["all"], + title: "fourth", + }); + await extension.awaitMessage("updated"); + await expectCached(extension, [ + { contexts: ["all"], id: "first", title: "first" }, + { contexts: ["all"], id: "second", title: "second" }, + { + contexts: ["all"], + id: "third", + parentId: "first", + title: "third", + }, + { contexts: ["all"], id: "fourth", title: "fourth" }, + ]); + + extension.sendMessage("update", { + id: "first", + parentId: "second", + }); + await extension.awaitMessage("updated"); + await expectCached(extension, [ + { contexts: ["all"], id: "second", title: "second" }, + { contexts: ["all"], id: "fourth", title: "fourth" }, + { + contexts: ["all"], + id: "first", + title: "first", + parentId: "second", + }, + { + contexts: ["all"], + id: "third", + parentId: "first", + title: "third", + }, + ]); + + await AddonTestUtils.promiseShutdownManager(); + // We need to attach an event listener before the + // startup event is emitted. Fortunately, we + // emit via Management before emitting on extension. + let promiseMenus; + Management.once("startup", (kind, ext) => { + info(`management ${kind} ${ext.id}`); + promiseMenus = promiseExtensionEvent( + { extension: ext }, + "webext-menus-created" + ); + }); + await AddonTestUtils.promiseStartupManager(); + await extension.awaitStartup(); + await extension.wakeupBackground(); + + await expectCached(extension, [ + { contexts: ["all"], id: "second", title: "second" }, + { contexts: ["all"], id: "fourth", title: "fourth" }, + { + contexts: ["all"], + id: "first", + title: "first", + parentId: "second", + }, + { + contexts: ["all"], + id: "third", + parentId: "first", + title: "third", + }, + ]); + // validate nesting + let menus = await promiseMenus; + equal(menus.get("first").parentId, "second", "menuitem parent is correct"); + equal( + menus.get("second").children.length, + 1, + "menuitem parent has correct number of children" + ); + equal( + menus.get("second").root.children.length, + 2, // second and forth + "menuitem root has correct number of children" + ); + + extension.sendMessage("remove", { + id: "second", + }); + await extension.awaitMessage("updated"); + await expectCached(extension, [ + { contexts: ["all"], id: "fourth", title: "fourth" }, + ]); + + extension.sendMessage("removeAll"); + await extension.awaitMessage("updated"); + await expectCached(extension, []); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_normandyAddonStudy.js b/browser/components/extensions/test/xpcshell/test_ext_normandyAddonStudy.js new file mode 100644 index 0000000000..0a2a9dcd49 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_normandyAddonStudy.js @@ -0,0 +1,243 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", +}); + +const { AddonStudies } = ChromeUtils.importESModule( + "resource://normandy/lib/AddonStudies.sys.mjs" +); +const { NormandyTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/NormandyTestUtils.sys.mjs" +); +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); +var { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const { addonStudyFactory } = NormandyTestUtils.factories; + +AddonTestUtils.init(this); + +// All tests run privileged unless otherwise specified not to. +function createExtension(backgroundScript, permissions, isPrivileged = true) { + let extensionData = { + background: backgroundScript, + manifest: { + browser_specific_settings: { + gecko: { + id: "test@shield.mozilla.com", + }, + }, + permissions, + }, + isPrivileged, + }; + return ExtensionTestUtils.loadExtension(extensionData); +} + +async function run(test) { + let extension = createExtension( + test.backgroundScript, + test.permissions || ["normandyAddonStudy"], + test.isPrivileged + ); + const promiseValidation = test.validationScript + ? test.validationScript(extension) + : Promise.resolve(); + + await extension.startup(); + + await promiseValidation; + + if (test.doneSignal) { + await extension.awaitFinish(test.doneSignal); + } + + await extension.unload(); +} + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task( + async function test_normandyAddonStudy_without_normandyAddonStudy_permission_privileged() { + await run({ + backgroundScript: () => { + browser.test.assertTrue( + !browser.normandyAddonStudy, + "'normandyAddonStudy' permission is required" + ); + browser.test.notifyPass("normandyAddonStudy_permission"); + }, + permissions: [], + doneSignal: "normandyAddonStudy_permission", + }); + } +); + +add_task(async function test_normandyAddonStudy_without_privilege() { + await run({ + backgroundScript: () => { + browser.test.assertTrue( + !browser.normandyAddonStudy, + "Extension must be privileged" + ); + browser.test.notifyPass("normandyAddonStudy_permission"); + }, + isPrivileged: false, + doneSignal: "normandyAddonStudy_permission", + }); +}); + +add_task(async function test_normandyAddonStudy_temporary_without_privilege() { + let extension = ExtensionTestUtils.loadExtension({ + temporarilyInstalled: true, + isPrivileged: false, + manifest: { + permissions: ["normandyAddonStudy"], + }, + }); + ExtensionTestUtils.failOnSchemaWarnings(false); + let { messages } = await promiseConsoleOutput(async () => { + await Assert.rejects( + extension.startup(), + /Using the privileged permission/, + "Startup failed with privileged permission" + ); + }); + ExtensionTestUtils.failOnSchemaWarnings(true); + AddonTestUtils.checkMessages( + messages, + { + expected: [ + { + message: + /Using the privileged permission 'normandyAddonStudy' requires a privileged add-on/, + }, + ], + }, + true + ); +}); + +add_task(async function test_getStudy_works() { + const study = addonStudyFactory({ + addonId: "test@shield.mozilla.com", + }); + + const testWrapper = AddonStudies.withStudies([study]); + const test = testWrapper(async () => { + await run({ + backgroundScript: async () => { + const result = await browser.normandyAddonStudy.getStudy(); + browser.test.sendMessage("study", result); + }, + validationScript: async extension => { + let studyResult = await extension.awaitMessage("study"); + deepEqual( + studyResult, + study, + "normandyAddonStudy.getStudy returns the correct study" + ); + }, + }); + }); + + await test(); +}); + +add_task(async function test_endStudy_works() { + const study = addonStudyFactory({ + addonId: "test@shield.mozilla.com", + }); + + const testWrapper = AddonStudies.withStudies([study]); + const test = testWrapper(async () => { + await run({ + backgroundScript: async () => { + await browser.normandyAddonStudy.endStudy("test"); + }, + validationScript: async () => { + // Check that `AddonStudies.markAsEnded` was called + await TestUtils.topicObserved( + "shield-study-ended", + (subject, message) => { + return message === `${study.recipeId}`; + } + ); + + const addon = await AddonManager.getAddonByID(study.addonId); + equal(addon, undefined, "Addon should be uninstalled."); + }, + }); + }); + + await test(); +}); + +add_task(async function test_getClientMetadata_works() { + const study = addonStudyFactory({ + addonId: "test@shield.mozilla.com", + slug: "test-slug", + branch: "test-branch", + }); + + const testWrapper = AddonStudies.withStudies([study]); + const test = testWrapper(async () => { + await run({ + backgroundScript: async () => { + const metadata = await browser.normandyAddonStudy.getClientMetadata(); + browser.test.sendMessage("clientMetadata", metadata); + }, + validationScript: async extension => { + let clientMetadata = await extension.awaitMessage("clientMetadata"); + + Assert.strictEqual( + clientMetadata.updateChannel, + Services.appinfo.defaultUpdateChannel, + "clientMetadata contains correct updateChannel" + ); + + Assert.strictEqual( + clientMetadata.fxVersion, + Services.appinfo.version, + "clientMetadata contains correct fxVersion" + ); + + ok("clientID" in clientMetadata, "clientMetadata contains a clientID"); + }, + }); + }); + + await test(); +}); + +add_task(async function test_onUnenroll_works() { + const study = addonStudyFactory({ + addonId: "test@shield.mozilla.com", + }); + + const testWrapper = AddonStudies.withStudies([study]); + const test = testWrapper(async () => { + await run({ + backgroundScript: () => { + browser.normandyAddonStudy.onUnenroll.addListener(reason => { + browser.test.sendMessage("unenrollReason", reason); + }); + browser.test.sendMessage("bgpageReady"); + }, + validationScript: async extension => { + await extension.awaitMessage("bgpageReady"); + await AddonStudies.markAsEnded(study, "test"); + const unenrollReason = await extension.awaitMessage("unenrollReason"); + equal(unenrollReason, "test", "Unenroll listener should be called."); + }, + }); + }); + + await test(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_pageAction_shutdown.js b/browser/components/extensions/test/xpcshell/test_ext_pageAction_shutdown.js new file mode 100644 index 0000000000..bd462ec9b6 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_pageAction_shutdown.js @@ -0,0 +1,81 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Load lazy so we create the app info first. +ChromeUtils.defineESModuleGetters(this, { + PageActions: "resource:///modules/PageActions.sys.mjs", +}); + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const { createAppInfo, promiseShutdownManager, promiseStartupManager } = + AddonTestUtils; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "58"); + +// This is copied and pasted from ext-browser.js and used in ext-pageAction.js. +// It's used as the PageActions action ID. +function makeWidgetId(id) { + id = id.toLowerCase(); + // FIXME: This allows for collisions. + return id.replace(/[^a-z0-9_-]/g, "_"); +} + +// Tests that the pinnedToUrlbar property of the PageActions.Action object +// backing the extension's page action persists across app restarts. +add_task(async function testAppShutdown() { + let extensionData = { + useAddonManager: "permanent", + manifest: { + page_action: { + default_title: "test_ext_pageAction_shutdown.js", + browser_style: false, + }, + }, + }; + + // Simulate starting up the app. + PageActions.init(); + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + // Get the PageAction.Action object. Its pinnedToUrlbar should have been + // initialized to true in ext-pageAction.js, when it's created. + let actionID = makeWidgetId(extension.id); + let action = PageActions.actionForID(actionID); + Assert.equal(action.pinnedToUrlbar, true); + + // Simulate restarting the app without first unloading the extension. + await promiseShutdownManager(); + PageActions._reset(); + await promiseStartupManager(); + await extension.awaitStartup(); + + // Get the action. Its pinnedToUrlbar should remain true. + action = PageActions.actionForID(actionID); + Assert.equal(action.pinnedToUrlbar, true); + + // Now set its pinnedToUrlbar to false. + action.pinnedToUrlbar = false; + + // Simulate restarting the app again without first unloading the extension. + await promiseShutdownManager(); + PageActions._reset(); + await promiseStartupManager(); + await extension.awaitStartup(); + + action = PageActions.actionForID(actionID); + Assert.equal(action.pinnedToUrlbar, true); + + // Now unload the extension and quit the app. + await extension.unload(); + await promiseShutdownManager(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_pkcs11_management.js b/browser/components/extensions/test/xpcshell/test_ext_pkcs11_management.js new file mode 100644 index 0000000000..8c713191cc --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_pkcs11_management.js @@ -0,0 +1,300 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + MockRegistry: "resource://testing-common/MockRegistry.sys.mjs", + ctypes: "resource://gre/modules/ctypes.sys.mjs", +}); + +do_get_profile(); + +let tmpDir; +let baseDir; +let slug = + AppConstants.platform === "linux" ? "pkcs11-modules" : "PKCS11Modules"; + +add_task(async function setupTest() { + tmpDir = await IOUtils.createUniqueDirectory( + Services.dirsvc.get("TmpD", Ci.nsIFile).path, + "PKCS11" + ); + + baseDir = PathUtils.join(tmpDir, slug); + await IOUtils.makeDirectory(baseDir); +}); + +registerCleanupFunction(async () => { + await IOUtils.remove(tmpDir, { recursive: true }); +}); + +const testmodule = PathUtils.join( + PathUtils.parent(Services.dirsvc.get("CurWorkD", Ci.nsIFile).path, 5), + "security", + "manager", + "ssl", + "tests", + "unit", + "pkcs11testmodule", + ctypes.libraryName("pkcs11testmodule") +); + +// This function was inspired by the native messaging test under +// toolkit/components/extensions + +async function setupManifests(modules) { + async function writeManifest(module) { + let manifest = { + name: module.name, + description: module.description, + path: module.path, + type: "pkcs11", + allowed_extensions: [module.id], + }; + + let manifestPath = PathUtils.join(baseDir, `${module.name}.json`); + await IOUtils.writeJSON(manifestPath, manifest); + + return manifestPath; + } + + switch (AppConstants.platform) { + case "macosx": + case "linux": + let dirProvider = { + getFile(property) { + if ( + property == "XREUserNativeManifests" || + property == "XRESysNativeManifests" + ) { + return new FileUtils.File(tmpDir); + } + return null; + }, + }; + + Services.dirsvc.registerProvider(dirProvider); + registerCleanupFunction(() => { + Services.dirsvc.unregisterProvider(dirProvider); + }); + + for (let module of modules) { + await writeManifest(module); + } + break; + + case "win": + const REGKEY = String.raw`Software\Mozilla\PKCS11Modules`; + + let registry = new MockRegistry(); + registerCleanupFunction(() => { + registry.shutdown(); + }); + + for (let module of modules) { + let manifestPath = await writeManifest(module); + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGKEY}\\${module.name}`, + "", + manifestPath + ); + } + break; + + default: + ok( + false, + `Loading of PKCS#11 modules is not supported on ${AppConstants.platform}` + ); + } +} + +add_task(async function test_pkcs11() { + async function background() { + try { + const { os } = await browser.runtime.getPlatformInfo(); + if (os !== "win") { + // Expect this call to not throw (explicitly cover regression fixed in Bug 1759162). + let isInstalledNonAbsolute = await browser.pkcs11.isModuleInstalled( + "testmoduleNonAbsolutePath" + ); + browser.test.assertFalse( + isInstalledNonAbsolute, + "PKCS#11 module with non absolute path expected to not be installed" + ); + } + let isInstalled = await browser.pkcs11.isModuleInstalled("testmodule"); + browser.test.assertFalse( + isInstalled, + "PKCS#11 module is not installed before we install it" + ); + await browser.pkcs11.installModule("testmodule", 0); + isInstalled = await browser.pkcs11.isModuleInstalled("testmodule"); + browser.test.assertTrue( + isInstalled, + "PKCS#11 module is installed after we install it" + ); + let slots = await browser.pkcs11.getModuleSlots("testmodule"); + browser.test.assertEq( + "Test PKCS11 Slot", + slots[0].name, + "The first slot name matches the expected name" + ); + browser.test.assertEq( + "Test PKCS11 Slot 二", + slots[1].name, + "The second slot name matches the expected name" + ); + browser.test.assertTrue(slots[1].token, "The second slot has a token"); + browser.test.assertFalse(slots[2].token, "The third slot has no token"); + browser.test.assertEq( + "Test PKCS11 Tokeñ 2 Label", + slots[1].token.name, + "The token name matches the expected name" + ); + browser.test.assertEq( + "Test PKCS11 Manufacturer ID", + slots[1].token.manufacturer, + "The token manufacturer matches the expected manufacturer" + ); + browser.test.assertEq( + "0.0", + slots[1].token.HWVersion, + "The token hardware version matches the expected version" + ); + browser.test.assertEq( + "0.0", + slots[1].token.FWVersion, + "The token firmware version matches the expected version" + ); + browser.test.assertEq( + "", + slots[1].token.serial, + "The token has no serial number" + ); + browser.test.assertFalse( + slots[1].token.isLoggedIn, + "The token is not logged in" + ); + await browser.pkcs11.uninstallModule("testmodule"); + isInstalled = await browser.pkcs11.isModuleInstalled("testmodule"); + browser.test.assertFalse( + isInstalled, + "PKCS#11 module is no longer installed after we uninstall it" + ); + await browser.pkcs11.installModule("testmodule"); + isInstalled = await browser.pkcs11.isModuleInstalled("testmodule"); + browser.test.assertTrue( + isInstalled, + "Installing the PKCS#11 module without flags parameter succeeds" + ); + await browser.pkcs11.uninstallModule("testmodule"); + await browser.test.assertRejects( + browser.pkcs11.isModuleInstalled("nonexistingmodule"), + /No such PKCS#11 module nonexistingmodule/, + "We cannot access modules if no JSON file exists" + ); + await browser.test.assertRejects( + browser.pkcs11.isModuleInstalled("othermodule"), + /No such PKCS#11 module othermodule/, + "We cannot access modules if we're not listed in the module's manifest file's allowed_extensions key" + ); + await browser.test.assertRejects( + browser.pkcs11.uninstallModule("internalmodule"), + /No such PKCS#11 module internalmodule/, + "We cannot uninstall the NSS Builtin Roots Module" + ); + await browser.test.assertRejects( + browser.pkcs11.installModule("osclientcerts", 0), + /No such PKCS#11 module osclientcerts/, + "installModule should not work on the built-in osclientcerts module" + ); + await browser.test.assertRejects( + browser.pkcs11.uninstallModule("osclientcerts"), + /No such PKCS#11 module osclientcerts/, + "uninstallModule should not work on the built-in osclientcerts module" + ); + await browser.test.assertRejects( + browser.pkcs11.isModuleInstalled("osclientcerts"), + /No such PKCS#11 module osclientcerts/, + "isModuleLoaded should not work on the built-in osclientcerts module" + ); + await browser.test.assertRejects( + browser.pkcs11.getModuleSlots("osclientcerts"), + /No such PKCS#11 module osclientcerts/, + "getModuleSlots should not work on the built-in osclientcerts module" + ); + await browser.test.assertRejects( + browser.pkcs11.installModule("ipcclientcerts", 0), + /No such PKCS#11 module ipcclientcerts/, + "installModule should not work on the built-in ipcclientcerts module" + ); + await browser.test.assertRejects( + browser.pkcs11.uninstallModule("ipcclientcerts"), + /No such PKCS#11 module ipcclientcerts/, + "uninstallModule should not work on the built-in ipcclientcerts module" + ); + await browser.test.assertRejects( + browser.pkcs11.isModuleInstalled("ipcclientcerts"), + /No such PKCS#11 module ipcclientcerts/, + "isModuleLoaded should not work on the built-in ipcclientcerts module" + ); + await browser.test.assertRejects( + browser.pkcs11.getModuleSlots("ipcclientcerts"), + /No such PKCS#11 module ipcclientcerts/, + "getModuleSlots should not work on the built-in ipcclientcerts module" + ); + browser.test.notifyPass("pkcs11"); + } catch (e) { + browser.test.fail(`Error: ${String(e)} :: ${e.stack}`); + browser.test.notifyFail("pkcs11 failed"); + } + } + + let libDir = FileUtils.getDir("GreBinD", []); + await setupManifests([ + { + name: "testmodule", + description: "PKCS#11 Test Module", + path: testmodule, + id: "pkcs11@tests.mozilla.org", + }, + { + name: "testmoduleNonAbsolutePath", + description: "PKCS#11 Test Module", + path: ctypes.libraryName("pkcs11testmodule"), + id: "pkcs11@tests.mozilla.org", + }, + { + name: "othermodule", + description: "PKCS#11 Test Module", + path: testmodule, + id: "other@tests.mozilla.org", + }, + { + name: "internalmodule", + description: "Builtin Roots Module", + path: PathUtils.join( + Services.dirsvc.get("CurWorkD", Ci.nsIFile).path, + ctypes.libraryName("nssckbi") + ), + id: "pkcs11@tests.mozilla.org", + }, + { + name: "osclientcerts", + description: "OS Client Cert Module", + path: PathUtils.join(libDir.path, ctypes.libraryName("osclientcerts")), + id: "pkcs11@tests.mozilla.org", + }, + ]); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["pkcs11"], + browser_specific_settings: { gecko: { id: "pkcs11@tests.mozilla.org" } }, + }, + background: background, + }); + await extension.startup(); + await extension.awaitFinish("pkcs11"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_defaults.js b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_defaults.js new file mode 100644 index 0000000000..dd24be3aff --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_defaults.js @@ -0,0 +1,263 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const { SearchTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/SearchTestUtils.sys.mjs" +); + +const { SearchUtils } = ChromeUtils.importESModule( + "resource://gre/modules/SearchUtils.sys.mjs" +); + +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +const URLTYPE_SUGGEST_JSON = "application/x-suggestions+json"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +const kSearchEngineURL = "https://example.com/?q={searchTerms}&foo=myparams"; +const kSuggestURL = "https://example.com/fake/suggest/"; +const kSuggestURLParams = "q={searchTerms}&type=list2"; + +Services.prefs.setBoolPref("browser.search.log", true); + +add_task(async function setup() { + AddonTestUtils.usePrivilegedSignatures = false; + AddonTestUtils.overrideCertDB(); + await AddonTestUtils.promiseStartupManager(); + await SearchTestUtils.useTestEngines("data", null, [ + { + webExtension: { + id: "test@search.mozilla.org", + }, + appliesTo: [ + { + included: { everywhere: true }, + default: "yes", + }, + ], + }, + { + webExtension: { + id: "test2@search.mozilla.org", + }, + appliesTo: [ + { + included: { everywhere: true }, + }, + ], + }, + ]); + await Services.search.init(); + registerCleanupFunction(async () => { + await AddonTestUtils.promiseShutdownManager(); + }); +}); + +function assertEngineParameters({ + name, + searchURL, + suggestionURL, + messageSnippet, +}) { + let engine = Services.search.getEngineByName(name); + Assert.ok(engine, `Should have found ${name}`); + + Assert.equal( + engine.getSubmission("{searchTerms}").uri.spec, + encodeURI(searchURL), + `Should have ${messageSnippet} the suggestion url.` + ); + Assert.equal( + engine.getSubmission("{searchTerms}", URLTYPE_SUGGEST_JSON)?.uri.spec, + suggestionURL ? encodeURI(suggestionURL) : suggestionURL, + `Should ${messageSnippet} the submission URL.` + ); +} + +add_task(async function test_extension_changing_to_app_provided_default() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + icons: { + 16: "foo.ico", + }, + chrome_settings_overrides: { + search_provider: { + is_default: true, + name: "MozParamsTest2", + keyword: "MozSearch", + search_url: kSearchEngineURL, + suggest_url: kSuggestURL, + suggest_url_get_params: kSuggestURLParams, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + Assert.equal( + Services.search.defaultEngine.name, + "MozParamsTest2", + "Should have switched the default engine." + ); + + assertEngineParameters({ + name: "MozParamsTest2", + searchURL: "https://example.com/2/?q={searchTerms}&simple2=5", + messageSnippet: "left unchanged", + }); + + let promiseDefaultBrowserChange = SearchTestUtils.promiseSearchNotification( + "engine-default", + "browser-search-engine-modified" + ); + await ext1.unload(); + await promiseDefaultBrowserChange; + + Assert.equal( + Services.search.defaultEngine.name, + "MozParamsTest", + "Should have reverted to the original default engine." + ); +}); + +add_task(async function test_extension_overriding_app_provided_default() { + const settings = await RemoteSettings(SearchUtils.SETTINGS_ALLOWLIST_KEY); + sinon.stub(settings, "get").returns([ + { + thirdPartyId: "test@thirdparty.example.com", + overridesId: "test2@search.mozilla.org", + urls: [ + { + search_url: "https://example.com/?q={searchTerms}&foo=myparams", + }, + ], + }, + ]); + + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "test@thirdparty.example.com", + }, + }, + icons: { + 16: "foo.ico", + }, + chrome_settings_overrides: { + search_provider: { + is_default: true, + name: "MozParamsTest2", + keyword: "MozSearch", + search_url: kSearchEngineURL, + suggest_url: kSuggestURL, + suggest_url_get_params: kSuggestURLParams, + }, + }, + }, + useAddonManager: "permanent", + }); + + info("startup"); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + Assert.equal( + Services.search.defaultEngine.name, + "MozParamsTest2", + "Should have switched the default engine." + ); + assertEngineParameters({ + name: "MozParamsTest2", + searchURL: kSearchEngineURL, + suggestionURL: `${kSuggestURL}?${kSuggestURLParams}`, + messageSnippet: "changed", + }); + + info("disable"); + + let promiseDefaultBrowserChange = SearchTestUtils.promiseSearchNotification( + "engine-default", + "browser-search-engine-modified" + ); + await ext1.addon.disable(); + await promiseDefaultBrowserChange; + + Assert.equal( + Services.search.defaultEngine.name, + "MozParamsTest", + "Should have reverted to the original default engine." + ); + assertEngineParameters({ + name: "MozParamsTest2", + searchURL: "https://example.com/2/?q={searchTerms}&simple2=5", + messageSnippet: "reverted", + }); + + info("enable"); + + promiseDefaultBrowserChange = SearchTestUtils.promiseSearchNotification( + "engine-default", + "browser-search-engine-modified" + ); + await ext1.addon.enable(); + await promiseDefaultBrowserChange; + + Assert.equal( + Services.search.defaultEngine.name, + "MozParamsTest2", + "Should have switched the default engine." + ); + + assertEngineParameters({ + name: "MozParamsTest2", + searchURL: kSearchEngineURL, + suggestionURL: `${kSuggestURL}?${kSuggestURLParams}`, + messageSnippet: "changed", + }); + + info("unload"); + + promiseDefaultBrowserChange = SearchTestUtils.promiseSearchNotification( + "engine-default", + "browser-search-engine-modified" + ); + await ext1.unload(); + await promiseDefaultBrowserChange; + + Assert.equal( + Services.search.defaultEngine.name, + "MozParamsTest", + "Should have reverted to the original default engine." + ); + + assertEngineParameters({ + name: "MozParamsTest2", + searchURL: "https://example.com/2/?q={searchTerms}&simple2=5", + messageSnippet: "reverted", + }); + sinon.restore(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search.js b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search.js new file mode 100644 index 0000000000..10fed4d36b --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search.js @@ -0,0 +1,597 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +let delay = () => new Promise(resolve => setTimeout(resolve, 0)); + +const kSearchFormURL = "https://example.com/searchform"; +const kSearchEngineURL = "https://example.com/?search={searchTerms}"; +const kSearchSuggestURL = "https://example.com/?suggest={searchTerms}"; +const kSearchTerm = "foo"; +const kSearchTermIntl = "日"; +const URLTYPE_SUGGEST_JSON = "application/x-suggestions+json"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + await Services.search.init(); +}); + +add_task(async function test_extension_adding_engine() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + icons: { + 16: "foo.ico", + 32: "foo32.ico", + }, + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_form: kSearchFormURL, + search_url: kSearchEngineURL, + suggest_url: kSearchSuggestURL, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + let engine = Services.search.getEngineByName("MozSearch"); + ok(engine, "Engine should exist."); + + let { baseURI } = ext1.extension; + equal( + engine.getIconURL(), + baseURI.resolve("foo.ico"), + "16x16 icon path matches" + ); + equal( + engine.getIconURL(16), + baseURI.resolve("foo.ico"), + "16x16 icon path matches" + ); + // TODO: Bug 1871036 - Differently sized icons are currently incorrectly + // handled for add-ons. + // equal( + // engine.getIconURL(32), + // baseURI.resolve("foo32.ico"), + // "32x32 icon path matches" + // ); + + let expectedSuggestURL = kSearchSuggestURL.replace( + "{searchTerms}", + kSearchTerm + ); + let submissionSuggest = engine.getSubmission( + kSearchTerm, + URLTYPE_SUGGEST_JSON + ); + let encodedSubmissionURL = engine.getSubmission(kSearchTermIntl).uri.spec; + let testSubmissionURL = kSearchEngineURL.replace( + "{searchTerms}", + encodeURIComponent(kSearchTermIntl) + ); + equal( + encodedSubmissionURL, + testSubmissionURL, + "Encoded UTF-8 URLs should match" + ); + + equal( + submissionSuggest.uri.spec, + expectedSuggestURL, + "Suggest URLs should match" + ); + + equal(engine.searchForm, kSearchFormURL, "Search form URLs should match"); + await ext1.unload(); + await delay(); + + engine = Services.search.getEngineByName("MozSearch"); + ok(!engine, "Engine should not exist"); +}); + +add_task(async function test_extension_adding_engine_with_spaces() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "MozSearch ", + keyword: "MozSearch", + search_url: "https://example.com/?q={searchTerms}", + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + let engine = Services.search.getEngineByName("MozSearch"); + ok(engine, "Engine should exist."); + + await ext1.unload(); + await delay(); + + engine = Services.search.getEngineByName("MozSearch"); + ok(!engine, "Engine should not exist"); +}); + +add_task(async function test_upgrade_default_position_engine() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_url: "https://example.com/?q={searchTerms}", + }, + }, + browser_specific_settings: { + gecko: { + id: "testengine@mozilla.com", + }, + }, + version: "0.1", + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + let engine = Services.search.getEngineByName("MozSearch"); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.moveEngine(engine, 1); + + await ext1.upgrade({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_url: "https://example.com/?q={searchTerms}", + }, + }, + browser_specific_settings: { + gecko: { + id: "testengine@mozilla.com", + }, + }, + version: "0.2", + }, + useAddonManager: "temporary", + }); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + engine = Services.search.getEngineByName("MozSearch"); + equal( + Services.search.defaultEngine, + engine, + "Default engine should still be MozSearch" + ); + equal( + (await Services.search.getEngines()).map(e => e.name).indexOf(engine.name), + 1, + "Engine is in position 1" + ); + + await ext1.unload(); + await delay(); + + engine = Services.search.getEngineByName("MozSearch"); + ok(!engine, "Engine should not exist"); +}); + +add_task(async function test_extension_get_params() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_url: kSearchEngineURL, + search_url_get_params: "foo=bar&bar=foo", + suggest_url: kSearchSuggestURL, + suggest_url_get_params: "foo=bar&bar=foo", + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + let engine = Services.search.getEngineByName("MozSearch"); + ok(engine, "Engine should exist."); + + let url = engine.wrappedJSObject._getURLOfType("text/html"); + equal(url.method, "GET", "Search URLs method is GET"); + + let expectedURL = kSearchEngineURL.replace("{searchTerms}", kSearchTerm); + let submission = engine.getSubmission(kSearchTerm); + equal( + submission.uri.spec, + `${expectedURL}&foo=bar&bar=foo`, + "Search URLs should match" + ); + + let expectedSuggestURL = kSearchSuggestURL.replace( + "{searchTerms}", + kSearchTerm + ); + let submissionSuggest = engine.getSubmission( + kSearchTerm, + URLTYPE_SUGGEST_JSON + ); + equal( + submissionSuggest.uri.spec, + `${expectedSuggestURL}&foo=bar&bar=foo`, + "Suggest URLs should match" + ); + + await ext1.unload(); +}); + +add_task(async function test_extension_post_params() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_url: kSearchEngineURL, + search_url_post_params: "foo=bar&bar=foo", + suggest_url: kSearchSuggestURL, + suggest_url_post_params: "foo=bar&bar=foo", + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + let engine = Services.search.getEngineByName("MozSearch"); + ok(engine, "Engine should exist."); + + let url = engine.wrappedJSObject._getURLOfType("text/html"); + equal(url.method, "POST", "Search URLs method is POST"); + + let expectedURL = kSearchEngineURL.replace("{searchTerms}", kSearchTerm); + let submission = engine.getSubmission(kSearchTerm); + equal(submission.uri.spec, expectedURL, "Search URLs should match"); + // postData is a nsIMIMEInputStream which contains a nsIStringInputStream. + equal( + submission.postData.data.data, + "foo=bar&bar=foo", + "Search postData should match" + ); + + let expectedSuggestURL = kSearchSuggestURL.replace( + "{searchTerms}", + kSearchTerm + ); + let submissionSuggest = engine.getSubmission( + kSearchTerm, + URLTYPE_SUGGEST_JSON + ); + equal( + submissionSuggest.uri.spec, + expectedSuggestURL, + "Suggest URLs should match" + ); + equal( + submissionSuggest.postData.data.data, + "foo=bar&bar=foo", + "Suggest postData should match" + ); + + await ext1.unload(); +}); + +add_task(async function test_extension_no_query_params() { + const ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_url: "https://example.com/{searchTerms}", + suggest_url: "https://example.com/suggest/{searchTerms}", + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + let engine = Services.search.getEngineByName("MozSearch"); + ok(engine, "Engine should exist."); + + const encodedSubmissionURL = engine.getSubmission(kSearchTermIntl).uri.spec; + const testSubmissionURL = + "https://example.com/" + encodeURIComponent(kSearchTermIntl); + equal( + encodedSubmissionURL, + testSubmissionURL, + "Encoded UTF-8 URLs should match" + ); + + const expectedSuggestURL = "https://example.com/suggest/" + kSearchTerm; + let submissionSuggest = engine.getSubmission( + kSearchTerm, + URLTYPE_SUGGEST_JSON + ); + equal( + submissionSuggest.uri.spec, + expectedSuggestURL, + "Suggest URLs should match" + ); + + await ext1.unload(); + await delay(); + + engine = Services.search.getEngineByName("MozSearch"); + ok(!engine, "Engine should not exist"); +}); + +add_task(async function test_extension_empty_suggestUrl() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + default_locale: "en", + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_url: kSearchEngineURL, + search_url_post_params: "foo=bar&bar=foo", + suggest_url: "__MSG_suggestUrl__", + suggest_url_get_params: "__MSG_suggestUrlGetParams__", + }, + }, + }, + useAddonManager: "temporary", + files: { + "_locales/en/messages.json": { + suggestUrl: { + message: "", + }, + suggestUrlGetParams: { + message: "", + }, + }, + }, + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + let engine = Services.search.getEngineByName("MozSearch"); + ok(engine, "Engine should exist."); + + let url = engine.wrappedJSObject._getURLOfType("text/html"); + equal(url.method, "POST", "Search URLs method is POST"); + + let expectedURL = kSearchEngineURL.replace("{searchTerms}", kSearchTerm); + let submission = engine.getSubmission(kSearchTerm); + equal(submission.uri.spec, expectedURL, "Search URLs should match"); + // postData is a nsIMIMEInputStream which contains a nsIStringInputStream. + equal( + submission.postData.data.data, + "foo=bar&bar=foo", + "Search postData should match" + ); + + let submissionSuggest = engine.getSubmission( + kSearchTerm, + URLTYPE_SUGGEST_JSON + ); + ok(!submissionSuggest, "There should be no suggest URL."); + + await ext1.unload(); +}); + +add_task(async function test_extension_empty_suggestUrl_with_params() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + default_locale: "en", + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_url: kSearchEngineURL, + search_url_post_params: "foo=bar&bar=foo", + suggest_url: "__MSG_suggestUrl__", + suggest_url_get_params: "__MSG_suggestUrlGetParams__", + }, + }, + }, + useAddonManager: "temporary", + files: { + "_locales/en/messages.json": { + suggestUrl: { + message: "", + }, + suggestUrlGetParams: { + message: "abc", + }, + }, + }, + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + let engine = Services.search.getEngineByName("MozSearch"); + ok(engine, "Engine should exist."); + + let url = engine.wrappedJSObject._getURLOfType("text/html"); + equal(url.method, "POST", "Search URLs method is POST"); + + let expectedURL = kSearchEngineURL.replace("{searchTerms}", kSearchTerm); + let submission = engine.getSubmission(kSearchTerm); + equal(submission.uri.spec, expectedURL, "Search URLs should match"); + // postData is a nsIMIMEInputStream which contains a nsIStringInputStream. + equal( + submission.postData.data.data, + "foo=bar&bar=foo", + "Search postData should match" + ); + + let submissionSuggest = engine.getSubmission( + kSearchTerm, + URLTYPE_SUGGEST_JSON + ); + ok(!submissionSuggest, "There should be no suggest URL."); + + await ext1.unload(); +}); + +async function checkBadUrl(searchProviderKey, urlValue) { + let normalized = await ExtensionTestUtils.normalizeManifest({ + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_url: "https://example.com/", + [searchProviderKey]: urlValue, + }, + }, + }); + + ok( + /Error processing chrome_settings_overrides\.search_provider[^:]*: .* must match/.test( + normalized.error + ), + `Expected error for ${searchProviderKey}:${urlValue} "${normalized.error}"` + ); +} + +async function checkValidUrl(urlValue) { + let normalized = await ExtensionTestUtils.normalizeManifest({ + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_form: urlValue, + search_url: urlValue, + suggest_url: urlValue, + }, + }, + }); + equal(normalized.error, undefined, `Valid search_provider url: ${urlValue}`); +} + +add_task(async function test_extension_not_allow_http() { + await checkBadUrl("search_form", "http://example.com/{searchTerms}"); + await checkBadUrl("search_url", "http://example.com/{searchTerms}"); + await checkBadUrl("suggest_url", "http://example.com/{searchTerms}"); +}); + +add_task(async function test_manifest_disallows_http_localhost_prefix() { + await checkBadUrl("search_url", "http://localhost.example.com"); + await checkBadUrl("search_url", "http://localhost.example.com/"); + await checkBadUrl("search_url", "http://127.0.0.1.example.com/"); + await checkBadUrl("search_url", "http://localhost:1234@example.com/"); +}); + +add_task(async function test_manifest_allow_http_for_localhost() { + await checkValidUrl("http://localhost"); + await checkValidUrl("http://localhost/"); + await checkValidUrl("http://localhost:/"); + await checkValidUrl("http://localhost:1/"); + await checkValidUrl("http://localhost:65535/"); + + await checkValidUrl("http://127.0.0.1"); + await checkValidUrl("http://127.0.0.1:"); + await checkValidUrl("http://127.0.0.1:/"); + await checkValidUrl("http://127.0.0.1/"); + await checkValidUrl("http://127.0.0.1:80/"); + + await checkValidUrl("http://[::1]"); + await checkValidUrl("http://[::1]:"); + await checkValidUrl("http://[::1]:/"); + await checkValidUrl("http://[::1]/"); + await checkValidUrl("http://[::1]:80/"); +}); + +add_task(async function test_extension_allow_http_for_localhost() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_url: "http://localhost/{searchTerms}", + suggest_url: "http://localhost/suggest/{searchTerms}", + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + let engine = Services.search.getEngineByName("MozSearch"); + ok(engine, "Engine should exist."); + + await ext1.unload(); +}); + +add_task(async function test_search_favicon_mv3() { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + let normalized = await ExtensionTestUtils.normalizeManifest({ + manifest_version: 3, + chrome_settings_overrides: { + search_provider: { + name: "HTTP Icon in MV3", + search_url: "https://example.org/", + favicon_url: "https://example.org/icon.png", + }, + }, + }); + Assert.ok( + normalized.error.endsWith("must be a relative URL"), + "Should have an error" + ); + normalized = await ExtensionTestUtils.normalizeManifest({ + manifest_version: 3, + chrome_settings_overrides: { + search_provider: { + name: "HTTP Icon in MV3", + search_url: "https://example.org/", + favicon_url: "/icon.png", + }, + }, + }); + Assert.ok(!normalized.error, "Should not have an error"); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search_mozParam.js b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search_mozParam.js new file mode 100644 index 0000000000..3248c5cefa --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search_mozParam.js @@ -0,0 +1,239 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); +const { SearchTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/SearchTestUtils.sys.mjs" +); +const { NimbusFeatures } = ChromeUtils.importESModule( + "resource://nimbus/ExperimentAPI.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +let { promiseShutdownManager, promiseStartupManager } = AddonTestUtils; + +// Note: these lists should be kept in sync with the lists in +// browser/components/extensions/test/xpcshell/data/test/manifest.json +// These params are conditional based on how search is initiated. +const mozParams = [ + { + name: "test-0", + condition: "purpose", + purpose: "contextmenu", + value: "0", + }, + { name: "test-1", condition: "purpose", purpose: "searchbar", value: "1" }, + { name: "test-2", condition: "purpose", purpose: "homepage", value: "2" }, + { name: "test-3", condition: "purpose", purpose: "keyword", value: "3" }, + { name: "test-4", condition: "purpose", purpose: "newtab", value: "4" }, +]; +// These params are always included. +const params = [ + { name: "simple", value: "5" }, + { name: "term", value: "{searchTerms}" }, + { name: "lang", value: "{language}" }, + { name: "locale", value: "{moz:locale}" }, + { name: "prefval", condition: "pref", pref: "code" }, +]; + +add_task(async function setup() { + let readyStub = sinon.stub(NimbusFeatures.search, "ready").resolves(); + let updateStub = sinon.stub(NimbusFeatures.search, "onUpdate"); + await promiseStartupManager(); + await SearchTestUtils.useTestEngines("data", null, [ + { + webExtension: { + id: "test@search.mozilla.org", + }, + appliesTo: [ + { + included: { everywhere: true }, + default: "yes", + }, + ], + }, + ]); + await Services.search.init(); + registerCleanupFunction(async () => { + await promiseShutdownManager(); + readyStub.restore(); + updateStub.restore(); + }); +}); + +/* This tests setting moz params. */ +add_task(async function test_extension_setting_moz_params() { + let defaultBranch = Services.prefs.getDefaultBranch("browser.search."); + defaultBranch.setCharPref("param.code", "good"); + + let engine = Services.search.getEngineByName("MozParamsTest"); + + let extraParams = []; + for (let p of params) { + if (p.condition == "pref") { + extraParams.push(`${p.name}=good`); + } else if (p.value == "{searchTerms}") { + extraParams.push(`${p.name}=test`); + } else if (p.value == "{language}") { + extraParams.push(`${p.name}=${Services.locale.requestedLocale || "*"}`); + } else if (p.value == "{moz:locale}") { + extraParams.push(`${p.name}=${Services.locale.requestedLocale}`); + } else { + extraParams.push(`${p.name}=${p.value}`); + } + } + let paramStr = extraParams.join("&"); + + for (let p of mozParams) { + let expectedURL = engine.getSubmission( + "test", + null, + p.condition == "purpose" ? p.purpose : null + ).uri.spec; + equal( + expectedURL, + `https://example.com/?q=test&${p.name}=${p.value}&${paramStr}`, + "search url is expected" + ); + } + + defaultBranch.setCharPref("param.code", ""); +}); + +add_task(async function test_nimbus_params() { + let sandbox = sinon.createSandbox(); + let stub = sandbox.stub(NimbusFeatures.search, "getVariable"); + // These values should match the nimbusParams below and the data/test/manifest.json + // search engine configuration + stub.withArgs("extraParams").returns([ + { + key: "nimbus-key-1", + value: "nimbus-value-1", + }, + { + key: "nimbus-key-2", + value: "nimbus-value-2", + }, + ]); + + Assert.ok( + NimbusFeatures.search.onUpdate.called, + "Called to initialize the cache" + ); + + // Populate the cache with the `getVariable` mock values + NimbusFeatures.search.onUpdate.firstCall.args[0](); + + let engine = Services.search.getEngineByName("MozParamsTest"); + + // Note: these lists should be kept in sync with the lists in + // browser/components/extensions/test/xpcshell/data/test/manifest.json + // These params are conditional based on how search is initiated. + const nimbusParams = [ + { name: "experimenter-1", condition: "pref", pref: "nimbus-key-1" }, + { name: "experimenter-2", condition: "pref", pref: "nimbus-key-2" }, + ]; + const experimentCache = { + "nimbus-key-1": "nimbus-value-1", + "nimbus-key-2": "nimbus-value-2", + }; + + let extraParams = []; + for (let p of params) { + if (p.value == "{searchTerms}") { + extraParams.push(`${p.name}=test`); + } else if (p.value == "{language}") { + extraParams.push(`${p.name}=${Services.locale.requestedLocale || "*"}`); + } else if (p.value == "{moz:locale}") { + extraParams.push(`${p.name}=${Services.locale.requestedLocale}`); + } else if (p.condition !== "pref") { + // Ignoring pref parameters + extraParams.push(`${p.name}=${p.value}`); + } + } + for (let p of nimbusParams) { + if (p.condition == "pref") { + extraParams.push(`${p.name}=${experimentCache[p.pref]}`); + } + } + let paramStr = extraParams.join("&"); + for (let p of mozParams) { + let expectedURL = engine.getSubmission( + "test", + null, + p.condition == "purpose" ? p.purpose : null + ).uri.spec; + equal( + expectedURL, + `https://example.com/?q=test&${p.name}=${p.value}&${paramStr}`, + "search url is expected" + ); + } + + sandbox.restore(); +}); + +add_task(async function test_extension_setting_moz_params_fail() { + // Ensure that the test infra does not automatically make + // this privileged. + AddonTestUtils.usePrivilegedSignatures = false; + Services.prefs.setCharPref( + "extensions.installedDistroAddon.test@mochitest", + "" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: "test1@mochitest" }, + }, + chrome_settings_overrides: { + search_provider: { + name: "MozParamsTest1", + search_url: "https://example.com/", + params: [ + { + name: "testParam", + condition: "purpose", + purpose: "contextmenu", + value: "0", + }, + { name: "prefval", condition: "pref", pref: "code" }, + { name: "q", value: "{searchTerms}" }, + ], + }, + }, + }, + useAddonManager: "permanent", + }); + await extension.startup(); + await AddonTestUtils.waitForSearchProviderStartup(extension); + equal( + extension.extension.isPrivileged, + false, + "extension is not priviledged" + ); + let engine = Services.search.getEngineByName("MozParamsTest1"); + let expectedURL = engine.getSubmission("test", null, "contextmenu").uri.spec; + equal( + expectedURL, + "https://example.com/?q=test", + "engine cannot have conditional or pref params" + ); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_shutdown.js b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_shutdown.js new file mode 100644 index 0000000000..851efd6b2a --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_shutdown.js @@ -0,0 +1,109 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); +// Lazily import ExtensionParent to allow AddonTestUtils.createAppInfo to +// override Services.appinfo. +ChromeUtils.defineESModuleGetters(this, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +add_task(async function shutdown_during_search_provider_startup() { + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + chrome_settings_overrides: { + search_provider: { + is_default: true, + name: "dummy name", + search_url: "https://example.com/", + }, + }, + }, + }); + + info("Starting up search extension"); + await extension.startup(); + let extStartPromise = AddonTestUtils.waitForSearchProviderStartup(extension, { + // Search provider registration is expected to be pending because the search + // service has not been initialized yet. + expectPending: true, + }); + + let initialized = false; + Services.search.promiseInitialized.then(() => { + initialized = true; + }); + + await extension.addon.disable(); + + info("Extension managed to shut down despite the uninitialized search"); + // Initialize search after extension shutdown to check that it does not cause + // any problems, and that the test can continue to test uninstall behavior. + Assert.ok(!initialized, "Search service should not have been initialized"); + + extension.addon.enable(); + await extension.awaitStartup(); + + // Check that uninstall is blocked until the search registration at startup + // has finished. This registration only finished once the search service is + // initialized. + let uninstallingPromise = new Promise(resolve => { + let Management = ExtensionParent.apiManager; + Management.on("uninstall", function listener(eventName, { id }) { + Management.off("uninstall", listener); + Assert.equal(id, extension.id, "Expected extension"); + resolve(); + }); + }); + + let extRestartPromise = AddonTestUtils.waitForSearchProviderStartup( + extension, + { + // Search provider registration is expected to be pending again, + // because the search service has still not been initialized yet. + expectPending: true, + } + ); + + let uninstalledPromise = extension.addon.uninstall(); + let uninstalled = false; + uninstalledPromise.then(() => { + uninstalled = true; + }); + + await uninstallingPromise; + Assert.ok(!uninstalled, "Uninstall should not be finished yet"); + Assert.ok(!initialized, "Search service should still be uninitialized"); + await Services.search.init(); + Assert.ok(initialized, "Search service should be initialized"); + + // After initializing the search service, the search provider registration + // promises should settle eventually. + + // Despite the interrupted startup, the promise should still resolve without + // an error. + await extStartPromise; + // The extension that is still active. The promise should just resolve. + await extRestartPromise; + + // After initializing the search service, uninstall should eventually finish. + await uninstalledPromise; + + await AddonTestUtils.promiseShutdownManager(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_settings_validate.js b/browser/components/extensions/test/xpcshell/test_ext_settings_validate.js new file mode 100644 index 0000000000..2f0d36f6e8 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_settings_validate.js @@ -0,0 +1,193 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); + +const { AboutNewTab } = ChromeUtils.importESModule( + "resource:///modules/AboutNewTab.sys.mjs" +); + +// Lazy load to avoid having Services.appinfo cached first. +ChromeUtils.defineESModuleGetters(this, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", +}); + +const { HomePage } = ChromeUtils.importESModule( + "resource:///modules/HomePage.sys.mjs" +); + +AddonTestUtils.init(this); + +// Allow for unsigned addons. +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +add_task(async function test_settings_modules_not_loaded() { + await ExtensionParent.apiManager.lazyInit(); + // Test that no settings modules are loaded. + let modules = Array.from(ExtensionParent.apiManager.settingsModules); + ok(modules.length, "we have settings modules"); + for (let name of modules) { + ok( + !ExtensionParent.apiManager.getModule(name).loaded, + `${name} is not loaded` + ); + } +}); + +add_task(async function test_settings_validated() { + let defaultNewTab = AboutNewTab.newTabURL; + equal(defaultNewTab, "about:newtab", "Newtab url is default."); + let defaultHomepageURL = HomePage.get(); + equal(defaultHomepageURL, "about:home", "Home page url is default."); + + let xpi = await AddonTestUtils.createTempWebExtensionFile({ + manifest: { + version: "1.0", + browser_specific_settings: { gecko: { id: "test@mochi" } }, + chrome_url_overrides: { + newtab: "/newtab", + }, + chrome_settings_overrides: { + homepage: "https://example.com/", + }, + }, + }); + let extension = ExtensionTestUtils.expectExtension("test@mochi"); + let file = await AddonTestUtils.manuallyInstall(xpi); + await AddonTestUtils.promiseStartupManager(); + await extension.awaitStartup(); + + equal( + HomePage.get(), + "https://example.com/", + "Home page url is extension controlled." + ); + ok( + AboutNewTab.newTabURL.endsWith("/newtab"), + "newTabURL is extension controlled." + ); + + await AddonTestUtils.promiseShutdownManager(); + // After shutdown, delete the xpi file. + Services.obs.notifyObservers(xpi, "flush-cache-entry"); + try { + file.remove(true); + } catch (e) { + ok(false, e); + } + await AddonTestUtils.cleanupTempXPIs(); + + // Restart everything, the ExtensionAddonObserver should handle updating state. + let prefChanged = TestUtils.waitForPrefChange("browser.startup.homepage"); + await AddonTestUtils.promiseStartupManager(); + await prefChanged; + + equal(HomePage.get(), defaultHomepageURL, "Home page url is default."); + equal(AboutNewTab.newTabURL, defaultNewTab, "newTabURL is reset to default."); + await AddonTestUtils.promiseShutdownManager(); +}); + +add_task(async function test_settings_validated_safemode() { + let defaultNewTab = AboutNewTab.newTabURL; + equal(defaultNewTab, "about:newtab", "Newtab url is default."); + let defaultHomepageURL = HomePage.get(); + equal(defaultHomepageURL, "about:home", "Home page url is default."); + + function isDefaultSettings(postfix) { + equal( + HomePage.get(), + defaultHomepageURL, + `Home page url is default ${postfix}.` + ); + equal( + AboutNewTab.newTabURL, + defaultNewTab, + `newTabURL is default ${postfix}.` + ); + } + + function isExtensionSettings(postfix) { + equal( + HomePage.get(), + "https://example.com/", + `Home page url is extension controlled ${postfix}.` + ); + ok( + AboutNewTab.newTabURL.endsWith("/newtab"), + `newTabURL is extension controlled ${postfix}.` + ); + } + + async function switchSafeMode(inSafeMode) { + await AddonTestUtils.promiseShutdownManager(); + AddonTestUtils.appInfo.inSafeMode = inSafeMode; + await AddonTestUtils.promiseStartupManager(); + return AddonManager.getAddonByID("test@mochi"); + } + + let xpi = await AddonTestUtils.createTempWebExtensionFile({ + manifest: { + version: "1.0", + browser_specific_settings: { gecko: { id: "test@mochi" } }, + chrome_url_overrides: { + newtab: "/newtab", + }, + chrome_settings_overrides: { + homepage: "https://example.com/", + }, + }, + }); + let extension = ExtensionTestUtils.expectExtension("test@mochi"); + await AddonTestUtils.manuallyInstall(xpi); + await AddonTestUtils.promiseStartupManager(); + await extension.awaitStartup(); + + isExtensionSettings("on extension startup"); + + // Disable in safemode and verify settings are removed in normal mode. + let addon = await switchSafeMode(true); + await addon.disable(); + addon = await switchSafeMode(false); + isDefaultSettings("after disabling addon during safemode"); + + // Enable in safemode and verify settings are back in normal mode. + addon = await switchSafeMode(true); + await addon.enable(); + addon = await switchSafeMode(false); + isExtensionSettings("after enabling addon during safemode"); + + // Uninstall in safemode and verify settings are removed in normal mode. + addon = await switchSafeMode(true); + await addon.uninstall(); + addon = await switchSafeMode(false); + isDefaultSettings("after uninstalling addon during safemode"); + + await AddonTestUtils.promiseShutdownManager(); + await AddonTestUtils.cleanupTempXPIs(); +}); + +// There are more settings modules than used in this test file, they should have been +// loaded during the test extensions uninstall. Ensure that all settings modules have +// been loaded. +add_task(async function test_settings_modules_loaded() { + // Test that all settings modules are loaded. + let modules = Array.from(ExtensionParent.apiManager.settingsModules); + ok(modules.length, "we have settings modules"); + for (let name of modules) { + ok(ExtensionParent.apiManager.getModule(name).loaded, `${name} was loaded`); + } +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_topSites.js b/browser/components/extensions/test/xpcshell/test_ext_topSites.js new file mode 100644 index 0000000000..8064ade1e8 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_topSites.js @@ -0,0 +1,293 @@ +"use strict"; + +const { PlacesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesUtils.sys.mjs" +); +const { NewTabUtils } = ChromeUtils.importESModule( + "resource://gre/modules/NewTabUtils.sys.mjs" +); +const { PlacesTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PlacesTestUtils.sys.mjs" +); + +const SEARCH_SHORTCUTS_EXPERIMENT_PREF = + "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts"; + +// A small 1x1 test png +const IMAGE_1x1 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="; + +add_task(async function test_topSites() { + Services.prefs.setBoolPref(SEARCH_SHORTCUTS_EXPERIMENT_PREF, false); + let visits = []; + const numVisits = 15; // To make sure we get frecency. + let visitDate = new Date(1999, 9, 9, 9, 9).getTime(); + + async function setVisit(visit) { + for (let j = 0; j < numVisits; ++j) { + visitDate -= 1000; + visit.visits.push({ date: new Date(visitDate) }); + } + visits.push(visit); + await PlacesUtils.history.insert(visit); + } + // Stick a couple sites into history. + for (let i = 0; i < 2; ++i) { + await setVisit({ + url: `http://example${i}.com/`, + title: `visit${i}`, + visits: [], + }); + await setVisit({ + url: `http://www.example${i}.com/foobar`, + title: `visit${i}-www`, + visits: [], + }); + } + NewTabUtils.init(); + + // Insert a favicon to show that favicons are not returned by default. + let faviconData = new Map(); + faviconData.set("http://example0.com", IMAGE_1x1); + await PlacesTestUtils.addFavicons(faviconData); + + // Ensure our links show up in activityStream. + let links = await NewTabUtils.activityStreamLinks.getTopSites({ + onePerDomain: false, + topsiteFrecency: 1, + }); + + equal( + links.length, + visits.length, + "Top sites has been successfully initialized" + ); + + // Drop the visits.visits for later testing. + visits = visits.map(v => { + return { url: v.url, title: v.title, favicon: undefined, type: "url" }; + }); + + // Test that results from all providers are returned by default. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["topSites"], + }, + background() { + browser.test.onMessage.addListener(async options => { + let sites = await browser.topSites.get(options); + browser.test.sendMessage("sites", sites); + }); + }, + }); + + await extension.startup(); + + function getSites(options) { + extension.sendMessage(options); + return extension.awaitMessage("sites"); + } + + Assert.deepEqual( + [visits[0], visits[2]], + await getSites(), + "got topSites default" + ); + Assert.deepEqual( + visits, + await getSites({ onePerDomain: false }), + "got topSites all links" + ); + + NewTabUtils.activityStreamLinks.blockURL(visits[0]); + ok( + NewTabUtils.blockedLinks.isBlocked(visits[0]), + `link ${visits[0].url} is blocked` + ); + + Assert.deepEqual( + [visits[2], visits[1]], + await getSites(), + "got topSites with blocked links filtered out" + ); + Assert.deepEqual( + [visits[0], visits[2]], + await getSites({ includeBlocked: true }), + "got topSites with blocked links included" + ); + + // Test favicon result + let topSites = await getSites({ includeBlocked: true, includeFavicon: true }); + equal(topSites[0].favicon, IMAGE_1x1, "received favicon"); + + equal( + 1, + (await getSites({ limit: 1, includeBlocked: true })).length, + "limit 1 topSite" + ); + + NewTabUtils.uninit(); + await extension.unload(); + await PlacesUtils.history.clear(); + Services.prefs.clearUserPref(SEARCH_SHORTCUTS_EXPERIMENT_PREF); +}); + +// Test pinned likns and search shortcuts. +add_task(async function test_topSites_complete() { + Services.prefs.setBoolPref(SEARCH_SHORTCUTS_EXPERIMENT_PREF, true); + NewTabUtils.init(); + let time = new Date(); + let pinnedIndex = 0; + let entries = [ + { + url: `http://pinned1.com/`, + title: "pinned1", + type: "url", + pinned: pinnedIndex++, + visitDate: time, + }, + { + url: `http://search1.com/`, + title: "@search1", + type: "search", + pinned: pinnedIndex++, + visitDate: new Date(--time), + }, + { + url: `https://amazon.com`, + title: "@amazon", + type: "search", + visitDate: new Date(--time), + }, + { + url: `http://history1.com/`, + title: "history1", + type: "url", + visitDate: new Date(--time), + }, + { + url: `http://history2.com/`, + title: "history2", + type: "url", + visitDate: new Date(--time), + }, + { + url: `https://blocked1.com/`, + title: "blocked1", + type: "blocked", + visitDate: new Date(--time), + }, + ]; + + for (let entry of entries) { + // Build up frecency. + await PlacesUtils.history.insert({ + url: entry.url, + title: entry.title, + visits: new Array(15).fill({ + date: entry.visitDate, + transition: PlacesUtils.history.TRANSITIONS.LINK, + }), + }); + // Insert a favicon to show that favicons are not returned by default. + await PlacesTestUtils.addFavicons(new Map([[entry.url, IMAGE_1x1]])); + if (entry.pinned !== undefined) { + let info = + entry.type == "search" + ? { url: entry.url, label: entry.title, searchTopSite: true } + : { url: entry.url, title: entry.title }; + NewTabUtils.pinnedLinks.pin(info, entry.pinned); + } + if (entry.type == "blocked") { + NewTabUtils.activityStreamLinks.blockURL({ url: entry.url }); + } + } + + // Some transformation is necessary to match output data. + let expectedResults = entries + .filter(e => e.type != "blocked") + .map(e => { + e.favicon = undefined; + delete e.visitDate; + delete e.pinned; + return e; + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["topSites"], + }, + background() { + browser.test.onMessage.addListener(async options => { + let sites = await browser.topSites.get(options); + browser.test.sendMessage("sites", sites); + }); + }, + }); + + await extension.startup(); + + // Test that results are returned by the API. + function getSites(options) { + extension.sendMessage(options); + return extension.awaitMessage("sites"); + } + + Assert.deepEqual( + expectedResults, + await getSites({ includePinned: true, includeSearchShortcuts: true }), + "got topSites all links" + ); + + // Test no shortcuts. + dump(JSON.stringify(await getSites({ includePinned: true })) + "\n"); + Assert.ok( + !(await getSites({ includePinned: true })).some( + link => link.type == "search" + ), + "should get no shortcuts" + ); + + // Test favicons. + let topSites = await getSites({ + includePinned: true, + includeSearchShortcuts: true, + includeFavicon: true, + }); + Assert.ok( + topSites.every(f => f.favicon == IMAGE_1x1), + "favicon is correct" + ); + + // Test options.limit. + Assert.equal( + 1, + ( + await getSites({ + includePinned: true, + includeSearchShortcuts: true, + limit: 1, + }) + ).length, + "limit to 1 topSite" + ); + + // Clear history for a pinned entry, then check results. + await PlacesUtils.history.remove("http://pinned1.com/"); + let links = await getSites({ includePinned: true }); + Assert.ok( + links.find(link => link.url == "http://pinned1.com/"), + "Check unvisited pinned links are returned." + ); + links = await getSites(); + Assert.ok( + !links.find(link => link.url == "http://pinned1.com/"), + "Check unvisited pinned links are not returned." + ); + + await extension.unload(); + NewTabUtils.uninit(); + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + Services.prefs.clearUserPref(SEARCH_SHORTCUTS_EXPERIMENT_PREF); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_url_overrides_newtab.js b/browser/components/extensions/test/xpcshell/test_ext_url_overrides_newtab.js new file mode 100644 index 0000000000..9ea6c4eea6 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_url_overrides_newtab.js @@ -0,0 +1,340 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + Management: "resource://gre/modules/Extension.sys.mjs", +}); + +const { AboutNewTab } = ChromeUtils.importESModule( + "resource:///modules/AboutNewTab.sys.mjs" +); + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const { + createAppInfo, + promiseRestartManager, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42"); + +function awaitEvent(eventName) { + return new Promise(resolve => { + Management.once(eventName, (e, ...args) => resolve(...args)); + }); +} + +const DEFAULT_NEW_TAB_URL = AboutNewTab.newTabURL; + +add_task(async function test_multiple_extensions_overriding_newtab_page() { + const NEWTAB_URI_2 = "webext-newtab-1.html"; + const NEWTAB_URI_3 = "webext-newtab-2.html"; + const EXT_2_ID = "ext2@tests.mozilla.org"; + const EXT_3_ID = "ext3@tests.mozilla.org"; + + const CONTROLLED_BY_THIS = "controlled_by_this_extension"; + const CONTROLLED_BY_OTHER = "controlled_by_other_extensions"; + const NOT_CONTROLLABLE = "not_controllable"; + + const NEW_TAB_PRIVATE_ALLOWED = "browser.newtab.privateAllowed"; + const NEW_TAB_EXTENSION_CONTROLLED = "browser.newtab.extensionControlled"; + + function background() { + browser.test.onMessage.addListener(async msg => { + switch (msg) { + case "checkNewTabPage": + let newTabPage = await browser.browserSettings.newTabPageOverride.get( + {} + ); + browser.test.sendMessage("newTabPage", newTabPage); + break; + case "trySet": + let setResult = await browser.browserSettings.newTabPageOverride.set({ + value: "foo", + }); + browser.test.assertFalse( + setResult, + "Calling newTabPageOverride.set returns false." + ); + browser.test.sendMessage("newTabPageSet"); + break; + case "tryClear": + let clearResult = + await browser.browserSettings.newTabPageOverride.clear({}); + browser.test.assertFalse( + clearResult, + "Calling newTabPageOverride.clear returns false." + ); + browser.test.sendMessage("newTabPageCleared"); + break; + } + }); + } + + async function checkNewTabPageOverride( + ext, + expectedValue, + expectedLevelOfControl + ) { + ext.sendMessage("checkNewTabPage"); + let newTabPage = await ext.awaitMessage("newTabPage"); + + ok( + newTabPage.value.endsWith(expectedValue), + `newTabPageOverride setting returns the expected value ending with: ${expectedValue}.` + ); + equal( + newTabPage.levelOfControl, + expectedLevelOfControl, + `newTabPageOverride setting returns the expected levelOfControl: ${expectedLevelOfControl}.` + ); + } + + function verifyNewTabSettings(ext, expectedLevelOfControl) { + if (expectedLevelOfControl !== NOT_CONTROLLABLE) { + // Verify the preferences are set as expected. + let policy = WebExtensionPolicy.getByID(ext.id); + equal( + policy && policy.privateBrowsingAllowed, + Services.prefs.getBoolPref(NEW_TAB_PRIVATE_ALLOWED), + "private browsing flag set correctly" + ); + ok( + Services.prefs.getBoolPref(NEW_TAB_EXTENSION_CONTROLLED), + `extension controlled flag set correctly` + ); + } else { + ok( + !Services.prefs.prefHasUserValue(NEW_TAB_PRIVATE_ALLOWED), + "controlled flag reset" + ); + ok( + !Services.prefs.prefHasUserValue(NEW_TAB_EXTENSION_CONTROLLED), + "controlled flag reset" + ); + } + } + + let extObj = { + manifest: { + chrome_url_overrides: {}, + permissions: ["browserSettings"], + }, + useAddonManager: "temporary", + background, + }; + + let ext1 = ExtensionTestUtils.loadExtension(extObj); + + extObj.manifest.chrome_url_overrides = { newtab: NEWTAB_URI_2 }; + extObj.manifest.browser_specific_settings = { gecko: { id: EXT_2_ID } }; + let ext2 = ExtensionTestUtils.loadExtension(extObj); + + extObj.manifest.chrome_url_overrides = { newtab: NEWTAB_URI_3 }; + extObj.manifest.browser_specific_settings.gecko.id = EXT_3_ID; + extObj.incognitoOverride = "spanning"; + let ext3 = ExtensionTestUtils.loadExtension(extObj); + + equal( + AboutNewTab.newTabURL, + DEFAULT_NEW_TAB_URL, + "newTabURL is set to the default." + ); + + await promiseStartupManager(); + + await ext1.startup(); + equal( + AboutNewTab.newTabURL, + DEFAULT_NEW_TAB_URL, + "newTabURL is still set to the default." + ); + + await checkNewTabPageOverride(ext1, AboutNewTab.newTabURL, NOT_CONTROLLABLE); + verifyNewTabSettings(ext1, NOT_CONTROLLABLE); + + await ext2.startup(); + ok( + AboutNewTab.newTabURL.endsWith(NEWTAB_URI_2), + "newTabURL is overridden by the second extension." + ); + await checkNewTabPageOverride(ext1, NEWTAB_URI_2, CONTROLLED_BY_OTHER); + verifyNewTabSettings(ext2, CONTROLLED_BY_THIS); + + // Verify that calling set and clear do nothing. + ext2.sendMessage("trySet"); + await ext2.awaitMessage("newTabPageSet"); + await checkNewTabPageOverride(ext1, NEWTAB_URI_2, CONTROLLED_BY_OTHER); + verifyNewTabSettings(ext2, CONTROLLED_BY_THIS); + + ext2.sendMessage("tryClear"); + await ext2.awaitMessage("newTabPageCleared"); + await checkNewTabPageOverride(ext1, NEWTAB_URI_2, CONTROLLED_BY_OTHER); + verifyNewTabSettings(ext2, CONTROLLED_BY_THIS); + + // Disable the second extension. + let addon = await AddonManager.getAddonByID(EXT_2_ID); + let disabledPromise = awaitEvent("shutdown"); + await addon.disable(); + await disabledPromise; + equal( + AboutNewTab.newTabURL, + DEFAULT_NEW_TAB_URL, + "newTabURL url is reset to the default after second extension is disabled." + ); + await checkNewTabPageOverride(ext1, AboutNewTab.newTabURL, NOT_CONTROLLABLE); + verifyNewTabSettings(ext1, NOT_CONTROLLABLE); + + // Re-enable the second extension. + let enabledPromise = awaitEvent("ready"); + await addon.enable(); + await enabledPromise; + ok( + AboutNewTab.newTabURL.endsWith(NEWTAB_URI_2), + "newTabURL is overridden by the second extension." + ); + await checkNewTabPageOverride(ext2, NEWTAB_URI_2, CONTROLLED_BY_THIS); + verifyNewTabSettings(ext2, CONTROLLED_BY_THIS); + + await ext1.unload(); + ok( + AboutNewTab.newTabURL.endsWith(NEWTAB_URI_2), + "newTabURL is still overridden by the second extension." + ); + await checkNewTabPageOverride(ext2, NEWTAB_URI_2, CONTROLLED_BY_THIS); + verifyNewTabSettings(ext2, CONTROLLED_BY_THIS); + + await ext3.startup(); + ok( + AboutNewTab.newTabURL.endsWith(NEWTAB_URI_3), + "newTabURL is overridden by the third extension." + ); + await checkNewTabPageOverride(ext2, NEWTAB_URI_3, CONTROLLED_BY_OTHER); + verifyNewTabSettings(ext3, CONTROLLED_BY_THIS); + + // Disable the second extension. + disabledPromise = awaitEvent("shutdown"); + await addon.disable(); + await disabledPromise; + ok( + AboutNewTab.newTabURL.endsWith(NEWTAB_URI_3), + "newTabURL is still overridden by the third extension." + ); + await checkNewTabPageOverride(ext3, NEWTAB_URI_3, CONTROLLED_BY_THIS); + verifyNewTabSettings(ext3, CONTROLLED_BY_THIS); + + // Re-enable the second extension. + enabledPromise = awaitEvent("ready"); + await addon.enable(); + await enabledPromise; + ok( + AboutNewTab.newTabURL.endsWith(NEWTAB_URI_3), + "newTabURL is still overridden by the third extension." + ); + await checkNewTabPageOverride(ext3, NEWTAB_URI_3, CONTROLLED_BY_THIS); + verifyNewTabSettings(ext3, CONTROLLED_BY_THIS); + + await ext3.unload(); + ok( + AboutNewTab.newTabURL.endsWith(NEWTAB_URI_2), + "newTabURL reverts to being overridden by the second extension." + ); + await checkNewTabPageOverride(ext2, NEWTAB_URI_2, CONTROLLED_BY_THIS); + verifyNewTabSettings(ext2, CONTROLLED_BY_THIS); + + await ext2.unload(); + equal( + AboutNewTab.newTabURL, + DEFAULT_NEW_TAB_URL, + "newTabURL url is reset to the default." + ); + ok( + !Services.prefs.prefHasUserValue(NEW_TAB_PRIVATE_ALLOWED), + "controlled flag reset" + ); + ok( + !Services.prefs.prefHasUserValue(NEW_TAB_EXTENSION_CONTROLLED), + "controlled flag reset" + ); + + await promiseShutdownManager(); +}); + +// Tests that we handle the upgrade/downgrade process correctly +// when an extension is installed temporarily on top of a permanently +// installed one. +add_task(async function test_temporary_installation() { + const ID = "newtab@tests.mozilla.org"; + const PAGE1 = "page1.html"; + const PAGE2 = "page2.html"; + + equal( + AboutNewTab.newTabURL, + DEFAULT_NEW_TAB_URL, + "newTabURL is set to the default." + ); + + await promiseStartupManager(); + + let permanent = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: ID }, + }, + chrome_url_overrides: { + newtab: PAGE1, + }, + }, + useAddonManager: "permanent", + }); + + await permanent.startup(); + ok( + AboutNewTab.newTabURL.endsWith(PAGE1), + "newTabURL is overridden by permanent extension." + ); + + let temporary = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: ID }, + }, + chrome_url_overrides: { + newtab: PAGE2, + }, + }, + useAddonManager: "temporary", + }); + + await temporary.startup(); + ok( + AboutNewTab.newTabURL.endsWith(PAGE2), + "newTabURL is overridden by temporary extension." + ); + + await promiseRestartManager(); + await permanent.awaitStartup(); + + ok( + AboutNewTab.newTabURL.endsWith(PAGE1), + "newTabURL is back to the value set by permanent extension." + ); + + await permanent.unload(); + + equal( + AboutNewTab.newTabURL, + DEFAULT_NEW_TAB_URL, + "newTabURL is set back to the default." + ); + await promiseShutdownManager(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_url_overrides_newtab_update.js b/browser/components/extensions/test/xpcshell/test_ext_url_overrides_newtab_update.js new file mode 100644 index 0000000000..17ee81e5ef --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_url_overrides_newtab_update.js @@ -0,0 +1,127 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AboutNewTab } = ChromeUtils.importESModule( + "resource:///modules/AboutNewTab.sys.mjs" +); + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const { + createAppInfo, + createTempWebExtensionFile, + promiseCompleteAllInstalls, + promiseFindAddonUpdates, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +// Allow for unsigned addons. +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42"); + +add_task(async function test_url_overrides_newtab_update() { + const EXTENSION_ID = "test_url_overrides_update@tests.mozilla.org"; + const NEWTAB_URI = "webext-newtab-1.html"; + const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity"; + + const testServer = createHttpServer(); + const port = testServer.identity.primaryPort; + + // The test extension uses an insecure update url. + Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false); + + testServer.registerPathHandler("/test_update.json", (request, response) => { + response.write(`{ + "addons": { + "${EXTENSION_ID}": { + "updates": [ + { + "version": "2.0", + "update_link": "http://localhost:${port}/addons/test_url_overrides-2.0.xpi" + } + ] + } + } + }`); + }); + + let webExtensionFile = createTempWebExtensionFile({ + manifest: { + version: "2.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + }); + + testServer.registerFile( + "/addons/test_url_overrides-2.0.xpi", + webExtensionFile + ); + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + update_url: `http://localhost:${port}/test_update.json`, + }, + }, + chrome_url_overrides: { newtab: NEWTAB_URI }, + }, + }); + + let defaultNewTabURL = AboutNewTab.newTabURL; + equal( + AboutNewTab.newTabURL, + defaultNewTabURL, + `Default newtab url is ${defaultNewTabURL}.` + ); + + await extension.startup(); + + equal( + extension.version, + "1.0", + "The installed addon has the expected version." + ); + ok( + AboutNewTab.newTabURL.endsWith(NEWTAB_URI), + "Newtab url is overridden by the extension." + ); + + let update = await promiseFindAddonUpdates(extension.addon); + let install = update.updateAvailable; + + await promiseCompleteAllInstalls([install]); + + await extension.awaitStartup(); + + equal( + extension.version, + "2.0", + "The updated addon has the expected version." + ); + equal( + AboutNewTab.newTabURL, + defaultNewTabURL, + "Newtab url reverted to the default after update." + ); + + await extension.unload(); + + await promiseShutdownManager(); +}); diff --git a/browser/components/extensions/test/xpcshell/xpcshell.toml b/browser/components/extensions/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..e98f696264 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/xpcshell.toml @@ -0,0 +1,69 @@ +[DEFAULT] +skip-if = ["os == 'android'"] # bug 1730213 +head = "head.js" +firefox-appdir = "browser" +tags = "webextensions condprof" +dupe-manifest = "" + +["test_ext_bookmarks.js"] +skip-if = ["condprof"] # Bug 1769184 - by design for now + +["test_ext_browsingData_downloads.js"] + +["test_ext_browsingData_passwords.js"] +skip-if = ["tsan"] # Times out, bug 1612707 + +["test_ext_browsingData_settings.js"] + +["test_ext_chrome_settings_overrides_home.js"] + +["test_ext_chrome_settings_overrides_update.js"] + +["test_ext_distribution_popup.js"] + +["test_ext_history.js"] + +["test_ext_homepage_overrides_private.js"] + +["test_ext_manifest.js"] + +["test_ext_manifest_commands.js"] +run-sequentially = "very high failure rate in parallel" + +["test_ext_manifest_omnibox.js"] + +["test_ext_manifest_permissions.js"] + +["test_ext_menu_caller.js"] + +["test_ext_menu_startup.js"] + +["test_ext_normandyAddonStudy.js"] + +["test_ext_pageAction_shutdown.js"] + +["test_ext_pkcs11_management.js"] + +["test_ext_settings_overrides_defaults.js"] +skip-if = ["condprof"] # Bug 1776135 - by design, modifies search settings at start of test +support-files = [ + "data/test/manifest.json", + "data/test2/manifest.json", +] + +["test_ext_settings_overrides_search.js"] + +["test_ext_settings_overrides_search_mozParam.js"] +skip-if = ["condprof"] # Bug 1776652 +support-files = ["data/test/manifest.json"] + +["test_ext_settings_overrides_shutdown.js"] + +["test_ext_settings_validate.js"] + +["test_ext_topSites.js"] +skip-if = ["condprof"] # Bug 1769184 - by design for now + +["test_ext_url_overrides_newtab.js"] + +["test_ext_url_overrides_newtab_update.js"] diff --git a/browser/components/firefoxview/OpenTabs.sys.mjs b/browser/components/firefoxview/OpenTabs.sys.mjs new file mode 100644 index 0000000000..ac247f5e8f --- /dev/null +++ b/browser/components/firefoxview/OpenTabs.sys.mjs @@ -0,0 +1,410 @@ +/* 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 provides the means to monitor and query for tab collections against open + * browser windows and allow listeners to be notified of changes to those collections. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + EveryWindow: "resource:///modules/EveryWindow.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +const TAB_ATTRS_TO_WATCH = Object.freeze([ + "attention", + "image", + "label", + "muted", + "soundplaying", + "titlechanged", +]); +const TAB_CHANGE_EVENTS = Object.freeze([ + "TabAttrModified", + "TabClose", + "TabMove", + "TabOpen", + "TabPinned", + "TabUnpinned", +]); +const TAB_RECENCY_CHANGE_EVENTS = Object.freeze([ + "activate", + "TabAttrModified", + "TabClose", + "TabOpen", + "TabSelect", + "TabAttrModified", +]); + +// Debounce tab/tab recency changes and dispatch max once per frame at 60fps +const CHANGES_DEBOUNCE_MS = 1000 / 60; + +/** + * A sort function used to order tabs by most-recently seen and active. + */ +export function lastSeenActiveSort(a, b) { + let dt = b.lastSeenActive - a.lastSeenActive; + if (dt) { + return dt; + } + // try to break a deadlock by sorting the selected tab higher + if (!(a.selected || b.selected)) { + return 0; + } + return a.selected ? -1 : 1; +} + +/** + * Provides a object capable of monitoring and accessing tab collections for either + * private or non-private browser windows. As the class extends EventTarget, consumers + * should add event listeners for the change events. + * + * @param {boolean} options.usePrivateWindows + Constrain to only windows that match this privateness. Defaults to false. + * @param {Window | null} options.exclusiveWindow + * Constrain to only a specific window. + */ +class OpenTabsTarget extends EventTarget { + #changedWindowsByType = { + TabChange: new Set(), + TabRecencyChange: new Set(), + }; + #dispatchChangesTask; + #started = false; + #watchedWindows = new Set(); + + #exclusiveWindowWeakRef = null; + usePrivateWindows = false; + + constructor(options = {}) { + super(); + this.usePrivateWindows = !!options.usePrivateWindows; + + if (options.exclusiveWindow) { + this.exclusiveWindow = options.exclusiveWindow; + this.everyWindowCallbackId = `opentabs-${this.exclusiveWindow.windowGlobalChild.innerWindowId}`; + } else { + this.everyWindowCallbackId = `opentabs-${ + this.usePrivateWindows ? "private" : "non-private" + }`; + } + } + + get exclusiveWindow() { + return this.#exclusiveWindowWeakRef?.get(); + } + set exclusiveWindow(newValue) { + if (newValue) { + this.#exclusiveWindowWeakRef = Cu.getWeakReference(newValue); + } else { + this.#exclusiveWindowWeakRef = null; + } + } + + includeWindowFilter(win) { + if (this.#exclusiveWindowWeakRef) { + return win == this.exclusiveWindow; + } + return ( + win.gBrowser && + !win.closed && + this.usePrivateWindows == lazy.PrivateBrowsingUtils.isWindowPrivate(win) + ); + } + + get currentWindows() { + return lazy.EveryWindow.readyWindows.filter(win => + this.includeWindowFilter(win) + ); + } + + /** + * A promise that resolves to all matched windows once their delayedStartupPromise resolves + */ + get readyWindowsPromise() { + let windowList = Array.from( + Services.wm.getEnumerator("navigator:browser") + ).filter(win => { + // avoid waiting for windows we definitely don't care about + if (this.#exclusiveWindowWeakRef) { + return this.exclusiveWindow == win; + } + return ( + this.usePrivateWindows == lazy.PrivateBrowsingUtils.isWindowPrivate(win) + ); + }); + return Promise.allSettled( + windowList.map(win => win.delayedStartupPromise) + ).then(() => { + // re-filter the list as properties might have changed in the interim + return windowList.filter(win => this.includeWindowFilter); + }); + } + + haveListenersForEvent(eventType) { + switch (eventType) { + case "TabChange": + return Services.els.hasListenersFor(this, "TabChange"); + case "TabRecencyChange": + return Services.els.hasListenersFor(this, "TabRecencyChange"); + default: + return false; + } + } + + get haveAnyListeners() { + return ( + this.haveListenersForEvent("TabChange") || + this.haveListenersForEvent("TabRecencyChange") + ); + } + + /* + * @param {string} type + * Either "TabChange" or "TabRecencyChange" + * @param {Object|Function} listener + * @param {Object} [options] + */ + addEventListener(type, listener, options) { + let hadListeners = this.haveAnyListeners; + super.addEventListener(type, listener, options); + + // if this is the first listener, start up all the window & tab monitoring + if (!hadListeners && this.haveAnyListeners) { + this.start(); + } + } + + /* + * @param {string} type + * Either "TabChange" or "TabRecencyChange" + * @param {Object|Function} listener + */ + removeEventListener(type, listener) { + let hadListeners = this.haveAnyListeners; + super.removeEventListener(type, listener); + + // if this was the last listener, we can stop all the window & tab monitoring + if (hadListeners && !this.haveAnyListeners) { + this.stop(); + } + } + + /** + * Begin watching for tab-related events from all browser windows matching the instance's private property + */ + start() { + if (this.#started) { + return; + } + // EveryWindow will call #watchWindow for each open window once its delayedStartupPromise resolves. + lazy.EveryWindow.registerCallback( + this.everyWindowCallbackId, + win => this.#watchWindow(win), + win => this.#unwatchWindow(win) + ); + this.#started = true; + } + + /** + * Stop watching for tab-related events from all browser windows and clean up. + */ + stop() { + if (this.#started) { + lazy.EveryWindow.unregisterCallback(this.everyWindowCallbackId); + this.#started = false; + } + for (let changedWindows of Object.values(this.#changedWindowsByType)) { + changedWindows.clear(); + } + this.#watchedWindows.clear(); + this.#dispatchChangesTask?.disarm(); + } + + /** + * Add listeners for tab-related events from the given window. The consumer's + * listeners will always be notified at least once for newly-watched window. + */ + #watchWindow(win) { + if (!this.includeWindowFilter(win)) { + return; + } + this.#watchedWindows.add(win); + const { tabContainer } = win.gBrowser; + tabContainer.addEventListener("TabAttrModified", this); + tabContainer.addEventListener("TabClose", this); + tabContainer.addEventListener("TabMove", this); + tabContainer.addEventListener("TabOpen", this); + tabContainer.addEventListener("TabPinned", this); + tabContainer.addEventListener("TabUnpinned", this); + tabContainer.addEventListener("TabSelect", this); + win.addEventListener("activate", this); + + this.#scheduleEventDispatch("TabChange", {}); + this.#scheduleEventDispatch("TabRecencyChange", {}); + } + + /** + * Remove all listeners for tab-related events from the given window. + * Consumers will always be notified at least once for unwatched window. + */ + #unwatchWindow(win) { + // We check the window is in our watchedWindows collection rather than currentWindows + // as the unwatched window may not match the criteria we used to watch it anymore, + // and we need to unhook our event listeners regardless. + if (this.#watchedWindows.has(win)) { + this.#watchedWindows.delete(win); + + const { tabContainer } = win.gBrowser; + tabContainer.removeEventListener("TabAttrModified", this); + tabContainer.removeEventListener("TabClose", this); + tabContainer.removeEventListener("TabMove", this); + tabContainer.removeEventListener("TabOpen", this); + tabContainer.removeEventListener("TabPinned", this); + tabContainer.removeEventListener("TabSelect", this); + tabContainer.removeEventListener("TabUnpinned", this); + win.removeEventListener("activate", this); + + this.#scheduleEventDispatch("TabChange", {}); + this.#scheduleEventDispatch("TabRecencyChange", {}); + } + } + + /** + * Flag the need to notify all our consumers of a change to open tabs. + * Repeated calls within approx 16ms will be consolidated + * into one event dispatch. + */ + #scheduleEventDispatch(eventType, { sourceWindowId } = {}) { + if (!this.haveListenersForEvent(eventType)) { + return; + } + + this.#changedWindowsByType[eventType].add(sourceWindowId); + // Queue up an event dispatch - we use a deferred task to make this less noisy by + // consolidating multiple change events into one. + if (!this.#dispatchChangesTask) { + this.#dispatchChangesTask = new lazy.DeferredTask(() => { + this.#dispatchChanges(); + }, CHANGES_DEBOUNCE_MS); + } + this.#dispatchChangesTask.arm(); + } + + #dispatchChanges() { + this.#dispatchChangesTask?.disarm(); + for (let [eventType, changedWindowIds] of Object.entries( + this.#changedWindowsByType + )) { + if (this.haveListenersForEvent(eventType) && changedWindowIds.size) { + this.dispatchEvent( + new CustomEvent(eventType, { + detail: { + windowIds: [...changedWindowIds], + }, + }) + ); + changedWindowIds.clear(); + } + } + } + + /* + * @param {Window} win + * @param {boolean} sortByRecency + * @returns {Array} + * The list of visible tabs for the browser window + */ + getTabsForWindow(win, sortByRecency = false) { + if (this.currentWindows.includes(win)) { + const { visibleTabs } = win.gBrowser; + return sortByRecency + ? visibleTabs.toSorted(lastSeenActiveSort) + : [...visibleTabs]; + } + return []; + } + + /* + * @returns {Array} + * A by-recency-sorted, aggregated list of tabs from all the same-privateness browser windows. + */ + getRecentTabs() { + const tabs = []; + for (let win of this.currentWindows) { + tabs.push(...this.getTabsForWindow(win)); + } + tabs.sort(lastSeenActiveSort); + return tabs; + } + + handleEvent({ detail, target, type }) { + const win = target.ownerGlobal; + // NOTE: we already filtered on privateness by not listening for those events + // from private/not-private windows + if ( + type == "TabAttrModified" && + !detail.changed.some(attr => TAB_ATTRS_TO_WATCH.includes(attr)) + ) { + return; + } + + if (TAB_RECENCY_CHANGE_EVENTS.includes(type)) { + this.#scheduleEventDispatch("TabRecencyChange", { + sourceWindowId: win.windowGlobalChild.innerWindowId, + }); + } + if (TAB_CHANGE_EVENTS.includes(type)) { + this.#scheduleEventDispatch("TabChange", { + sourceWindowId: win.windowGlobalChild.innerWindowId, + }); + } + } +} + +const gExclusiveWindows = new (class { + perWindowInstances = new WeakMap(); + constructor() { + Services.obs.addObserver(this, "domwindowclosed"); + } + observe(subject, topic, data) { + let win = subject; + let winTarget = this.perWindowInstances.get(win); + if (winTarget) { + winTarget.stop(); + this.perWindowInstances.delete(win); + } + } +})(); + +/** + * Get an OpenTabsTarget instance constrained to a specific window. + * + * @param {Window} exclusiveWindow + * @returns {OpenTabsTarget} + */ +const getTabsTargetForWindow = function (exclusiveWindow) { + let instance = gExclusiveWindows.perWindowInstances.get(exclusiveWindow); + if (instance) { + return instance; + } + instance = new OpenTabsTarget({ + exclusiveWindow, + }); + gExclusiveWindows.perWindowInstances.set(exclusiveWindow, instance); + return instance; +}; + +const NonPrivateTabs = new OpenTabsTarget({ + usePrivateWindows: false, +}); + +const PrivateTabs = new OpenTabsTarget({ + usePrivateWindows: true, +}); + +export { NonPrivateTabs, PrivateTabs, getTabsTargetForWindow }; diff --git a/browser/components/firefoxview/card-container.css b/browser/components/firefoxview/card-container.css new file mode 100644 index 0000000000..953437bec1 --- /dev/null +++ b/browser/components/firefoxview/card-container.css @@ -0,0 +1,171 @@ +/* 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/. */ + +.card-container { + padding: 8px; + border-radius: 8px; + background-color: var(--fxview-background-color-secondary); + margin-block-end: 24px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); + + &[isOpenTabsView] { + margin-block-end: 0; + } +} + +@media (prefers-contrast) { + .card-container { + border: 1px solid CanvasText; + } +} + +.card-container-header { + display: inline-flex; + gap: 14px; + width: 100%; + align-items: center; + cursor: pointer; + border-radius: 1px; + outline-offset: 4px; + padding: 6px; + padding-inline-end: 0; + margin-block-end: 6px; + height: 24px; +} + +.card-container-header[withViewAll] { + width: 83%; +} + +.card-container-header[hidden] { + display: none; +} + +.card-container-header[toggleDisabled] { + cursor: auto; +} + +.view-all-link { + color: var(--fxview-primary-action-background); + float: inline-end; + outline-offset: 6px; + border-radius: 1px; + width: 12%; + text-align: end; + padding: 6px; + padding-inline-start: 0; +} + +.card-container-header:focus-visible, +.view-all-link:focus-visible { + outline: 2px solid var(--in-content-focus-outline-color); +} + +.chevron-icon { + background-image: url("chrome://global/skin/icons/arrow-up.svg"); + padding: 2px; + display: inline-block; + justify-self: start; + fill: currentColor; + margin-block: 0; + width: 16px; + height: 16px; + background-position: center; + -moz-context-properties: fill; + border: none; + background-color: transparent; + background-repeat: no-repeat; + border-radius: 4px; +} + +.chevron-icon:hover { + background-color: var(--fxview-element-background-hover); +} + +@media (prefers-contrast) { + .chevron-icon { + border: 1px solid ButtonText; + color: ButtonText; + } + + .chevron-icon:hover { + border: 1px solid SelectedItem; + color: SelectedItem; + } + + .chevron-icon:active { + color: SelectedItem; + } + + .chevron-icon, + .chevron-icon:hover, + .chevron-icon:active { + background-color: ButtonFace; + } +} + +.card-container:not([open]) .chevron-icon { + background-image: url("chrome://global/skin/icons/arrow-down.svg"); +} + +.card-container:not([open]) a { + display: none; +} + +::slotted([slot=header]), +::slotted([slot=secondary-header]) { + align-self: center; + margin: 0; + font-size: 1.13em; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; + white-space: nowrap; +} + +::slotted([slot=header]) { + flex: 1; + width: 0; +} + +::slotted([slot=secondary-header]) { + padding-inline-end: 1em; +} + +.card-container-footer { + display: flex; + justify-content: center; + color: var(--fxview-primary-action-background); + cursor: pointer; +} + +::slotted([slot=footer]:not([hidden])) { + text-decoration: underline; + display: inline-block; + outline-offset: 2px; + border-radius: 2px; + margin-block: 0.5rem; +} + +@media (max-width: 39rem) { + .card-container-header[withViewAll] { + width: 76%; + } + .view-all-link { + width: 20%; + } +} + +.card-container.inner { + border: 1px solid var(--fxview-border); + box-shadow: none; + margin-block: 8px 0; +} + +details.empty-state { + box-shadow: none; + border: 1px solid var(--fxview-border); + border-radius: 8px; +} diff --git a/browser/components/firefoxview/card-container.mjs b/browser/components/firefoxview/card-container.mjs new file mode 100644 index 0000000000..b58f42204a --- /dev/null +++ b/browser/components/firefoxview/card-container.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/. */ + +import { + classMap, + html, + ifDefined, + when, +} from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +/** + * A collapsible card container to be used throughout Firefox View + * + * @property {string} sectionLabel - The aria-label used for the section landmark if the header is hidden with hideHeader + * @property {boolean} hideHeader - Optional property given if the card container should not display a header + * @property {boolean} isEmptyState - Optional property given if the card is used within an empty state + * @property {boolean} isInnerCard - Optional property given if the card a nested card within another card and given a border rather than box-shadow + * @property {boolean} preserveCollapseState - Whether or not the expanded/collapsed state should persist + * @property {string} shortPageName - Page name that the 'View all' link will navigate to and the preserveCollapseState pref will use + * @property {boolean} showViewAll - True if you need to display a 'View all' header link to navigate + * @property {boolean} toggleDisabled - Optional property given if the card container should not be collapsible + * @property {boolean} removeBlockEndMargin - True if you need to remove the block end margin on the card container + */ +class CardContainer extends MozLitElement { + constructor() { + super(); + this.initiallyExpanded = true; + this.isExpanded = false; + this.visible = false; + } + + static properties = { + sectionLabel: { type: String }, + hideHeader: { type: Boolean }, + isExpanded: { type: Boolean }, + isEmptyState: { type: Boolean }, + isInnerCard: { type: Boolean }, + preserveCollapseState: { type: Boolean }, + shortPageName: { type: String }, + showViewAll: { type: Boolean }, + toggleDisabled: { type: Boolean }, + removeBlockEndMargin: { type: Boolean }, + visible: { type: Boolean }, + }; + + static queries = { + detailsEl: "details", + mainSlot: "slot[name=main]", + summaryEl: "summary", + viewAllLink: ".view-all-link", + }; + + get detailsExpanded() { + return this.detailsEl.hasAttribute("open"); + } + + get detailsOpenPrefValue() { + const prefName = this.shortPageName + ? `browser.tabs.firefox-view.ui-state.${this.shortPageName}.open` + : null; + if (prefName && Services.prefs.prefHasUserValue(prefName)) { + return Services.prefs.getBoolPref(prefName); + } + return null; + } + + connectedCallback() { + super.connectedCallback(); + this.isExpanded = this.detailsOpenPrefValue ?? this.initiallyExpanded; + } + + onToggleContainer() { + if (this.isExpanded == this.detailsExpanded) { + return; + } + this.isExpanded = this.detailsExpanded; + + this.updateTabLists(); + + if (!this.shortPageName) { + return; + } + + if (this.preserveCollapseState) { + const prefName = this.shortPageName + ? `browser.tabs.firefox-view.ui-state.${this.shortPageName}.open` + : null; + Services.prefs.setBoolPref(prefName, this.isExpanded); + } + + // Record telemetry + Services.telemetry.recordEvent( + "firefoxview_next", + this.isExpanded ? "card_expanded" : "card_collapsed", + "card_container", + null, + { + data_type: this.shortPageName, + } + ); + } + + viewAllClicked() { + this.dispatchEvent( + new CustomEvent("card-container-view-all", { + bubbles: true, + composed: true, + }) + ); + } + + willUpdate(changes) { + if (changes.has("visible")) { + this.updateTabLists(); + } + } + + updateTabLists() { + let tabLists = this.querySelectorAll("fxview-tab-list"); + if (tabLists) { + tabLists.forEach(tabList => { + tabList.updatesPaused = !this.visible || !this.isExpanded; + }); + } + } + + render() { + return html` + +
    + ${when( + this.toggleDisabled, + () => html`
    + + + + + + + +
    `, + () => html`
    + + + + + + + +
    ` + )} +
    + `; + } +} +customElements.define("card-container", CardContainer); diff --git a/browser/components/firefoxview/content/callout-tab-pickup-dark.svg b/browser/components/firefoxview/content/callout-tab-pickup-dark.svg new file mode 100644 index 0000000000..b38684c38a --- /dev/null +++ b/browser/components/firefoxview/content/callout-tab-pickup-dark.svg @@ -0,0 +1,4 @@ + + diff --git a/browser/components/firefoxview/content/callout-tab-pickup.svg b/browser/components/firefoxview/content/callout-tab-pickup.svg new file mode 100644 index 0000000000..1ccc36dcc8 --- /dev/null +++ b/browser/components/firefoxview/content/callout-tab-pickup.svg @@ -0,0 +1,4 @@ + + diff --git a/browser/components/firefoxview/content/category-history.svg b/browser/components/firefoxview/content/category-history.svg new file mode 100644 index 0000000000..a6dc259483 --- /dev/null +++ b/browser/components/firefoxview/content/category-history.svg @@ -0,0 +1,6 @@ + + + + diff --git a/browser/components/firefoxview/content/category-opentabs.svg b/browser/components/firefoxview/content/category-opentabs.svg new file mode 100644 index 0000000000..2172558a42 --- /dev/null +++ b/browser/components/firefoxview/content/category-opentabs.svg @@ -0,0 +1,6 @@ + + + + diff --git a/browser/components/firefoxview/content/category-recentbrowsing.svg b/browser/components/firefoxview/content/category-recentbrowsing.svg new file mode 100644 index 0000000000..f4c523dafa --- /dev/null +++ b/browser/components/firefoxview/content/category-recentbrowsing.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/browser/components/firefoxview/content/category-recentlyclosed.svg b/browser/components/firefoxview/content/category-recentlyclosed.svg new file mode 100644 index 0000000000..7cac65ac58 --- /dev/null +++ b/browser/components/firefoxview/content/category-recentlyclosed.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/browser/components/firefoxview/content/category-syncedtabs.svg b/browser/components/firefoxview/content/category-syncedtabs.svg new file mode 100644 index 0000000000..bd9749743c --- /dev/null +++ b/browser/components/firefoxview/content/category-syncedtabs.svg @@ -0,0 +1,6 @@ + + + + diff --git a/browser/components/firefoxview/content/history-empty.svg b/browser/components/firefoxview/content/history-empty.svg new file mode 100644 index 0000000000..4fb4d5021c --- /dev/null +++ b/browser/components/firefoxview/content/history-empty.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + diff --git a/browser/components/firefoxview/content/recentlyclosed-empty.svg b/browser/components/firefoxview/content/recentlyclosed-empty.svg new file mode 100644 index 0000000000..e8bd265df0 --- /dev/null +++ b/browser/components/firefoxview/content/recentlyclosed-empty.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/components/firefoxview/content/synced-tabs-error.svg b/browser/components/firefoxview/content/synced-tabs-error.svg new file mode 100644 index 0000000000..b2a322ef74 --- /dev/null +++ b/browser/components/firefoxview/content/synced-tabs-error.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/browser/components/firefoxview/firefox-view-notification-manager.sys.mjs b/browser/components/firefoxview/firefox-view-notification-manager.sys.mjs new file mode 100644 index 0000000000..3f9056a7cd --- /dev/null +++ b/browser/components/firefoxview/firefox-view-notification-manager.sys.mjs @@ -0,0 +1,112 @@ +/* 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 new file mode 100644 index 0000000000..8923905769 --- /dev/null +++ b/browser/components/firefoxview/firefox-view-places-query.sys.mjs @@ -0,0 +1,187 @@ +/* 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/firefox-view-synced-tabs-error-handler.sys.mjs b/browser/components/firefoxview/firefox-view-synced-tabs-error-handler.sys.mjs new file mode 100644 index 0000000000..ee82bec3ca --- /dev/null +++ b/browser/components/firefoxview/firefox-view-synced-tabs-error-handler.sys.mjs @@ -0,0 +1,187 @@ +/* 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 SyncedTabsErrorHandler singleton, which handles + * error states for synced tabs. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + UIState: "resource://services-sync/UIState.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "syncUtils", () => { + return ChromeUtils.importESModule("resource://services-sync/util.sys.mjs") + .Utils; +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gNetworkLinkService", + "@mozilla.org/network/network-link-service;1", + "nsINetworkLinkService" +); + +const FXA_ENABLED = "identity.fxaccounts.enabled"; +const NETWORK_STATUS_CHANGED = "network:offline-status-changed"; +const SYNC_SERVICE_ERROR = "weave:service:sync:error"; +const SYNC_SERVICE_FINISHED = "weave:service:sync:finish"; +const TOPIC_DEVICESTATE_CHANGED = "firefox-view.devicestate.changed"; + +const ErrorType = Object.freeze({ + SYNC_ERROR: "sync-error", + FXA_ADMIN_DISABLED: "fxa-admin-disabled", + NETWORK_OFFLINE: "network-offline", + SYNC_DISCONNECTED: "sync-disconnected", + PASSWORD_LOCKED: "password-locked", + SIGNED_OUT: "signed-out", +}); + +export const SyncedTabsErrorHandler = { + init() { + this.networkIsOnline = + lazy.gNetworkLinkService.linkStatusKnown && + lazy.gNetworkLinkService.isLinkUp; + this.syncIsConnected = lazy.UIState.get().syncEnabled; + this.syncIsWorking = true; + + Services.obs.addObserver(this, NETWORK_STATUS_CHANGED); + Services.obs.addObserver(this, lazy.UIState.ON_UPDATE); + Services.obs.addObserver(this, SYNC_SERVICE_ERROR); + Services.obs.addObserver(this, SYNC_SERVICE_FINISHED); + Services.obs.addObserver(this, TOPIC_DEVICESTATE_CHANGED); + + return this; + }, + + get fxaSignedIn() { + let { UIState } = lazy; + let syncState = UIState.get(); + return ( + UIState.isReady() && + syncState.status === UIState.STATUS_SIGNED_IN && + // syncEnabled just checks the "services.sync.username" pref has a value + syncState.syncEnabled + ); + }, + + getErrorType() { + // this ordering is important for dealing with multiple errors at once + const errorStates = { + [ErrorType.NETWORK_OFFLINE]: !this.networkIsOnline, + [ErrorType.FXA_ADMIN_DISABLED]: Services.prefs.prefIsLocked(FXA_ENABLED), + [ErrorType.PASSWORD_LOCKED]: this.isPrimaryPasswordLocked, + [ErrorType.SIGNED_OUT]: + lazy.UIState.get().status === lazy.UIState.STATUS_LOGIN_FAILED, + [ErrorType.SYNC_DISCONNECTED]: !this.syncIsConnected, + [ErrorType.SYNC_ERROR]: !this.syncIsWorking && !this.syncHasWorked, + }; + + for (let [type, value] of Object.entries(errorStates)) { + if (value) { + return type; + } + } + return null; + }, + + getFluentStringsForErrorType(type) { + return Object.freeze(this._errorStateStringMappings[type]); + }, + + get isPrimaryPasswordLocked() { + return lazy.syncUtils.mpLocked(); + }, + + isSyncReady() { + const fxaStatus = lazy.UIState.get().status; + return ( + this.networkIsOnline && + (this.syncIsWorking || this.syncHasWorked) && + !Services.prefs.prefIsLocked(FXA_ENABLED) && + // it's an error for sync to not be connected if we are signed-in, + // or for sync to be connected if the FxA status is "login_failed", + // which can happen if a user updates their password on another device + ((!this.syncIsConnected && fxaStatus !== lazy.UIState.STATUS_SIGNED_IN) || + (this.syncIsConnected && + fxaStatus !== lazy.UIState.STATUS_LOGIN_FAILED)) && + // We treat a locked primary password as an error if we are signed-in. + // If the user dismisses the prompt to unlock, they can use the "Try again" button to prompt again + (!this.isPrimaryPasswordLocked || !this.fxaSignedIn) + ); + }, + + observe(_, topic, data) { + switch (topic) { + case NETWORK_STATUS_CHANGED: + this.networkIsOnline = data == "online"; + break; + case lazy.UIState.ON_UPDATE: + this.syncIsConnected = lazy.UIState.get().syncEnabled; + break; + case SYNC_SERVICE_ERROR: + if (lazy.UIState.get().status == lazy.UIState.STATUS_SIGNED_IN) { + this.syncIsWorking = false; + } + break; + case SYNC_SERVICE_FINISHED: + if (!this.syncIsWorking) { + this.syncIsWorking = true; + this.syncHasWorked = true; + } + break; + case TOPIC_DEVICESTATE_CHANGED: + this.syncHasWorked = false; + } + }, + + ErrorType, + + // We map the error state strings to Fluent string IDs so that it's easier + // to change strings in the future without having to update all of the + // error state strings. + _errorStateStringMappings: { + [ErrorType.SYNC_ERROR]: { + header: "firefoxview-tabpickup-sync-error-header", + description: "firefoxview-tabpickup-generic-sync-error-description", + buttonLabel: "firefoxview-tabpickup-sync-error-primarybutton", + }, + [ErrorType.FXA_ADMIN_DISABLED]: { + header: "firefoxview-tabpickup-fxa-admin-disabled-header", + description: "firefoxview-tabpickup-fxa-disabled-by-policy-description", + // The button is hidden for this errorState, so we don't include the + // buttonLabel property. + }, + [ErrorType.NETWORK_OFFLINE]: { + header: "firefoxview-tabpickup-network-offline-header", + description: "firefoxview-tabpickup-network-offline-description", + buttonLabel: "firefoxview-tabpickup-network-offline-primarybutton", + }, + [ErrorType.SYNC_DISCONNECTED]: { + header: "firefoxview-tabpickup-sync-disconnected-header", + description: "firefoxview-tabpickup-sync-disconnected-description", + buttonLabel: "firefoxview-tabpickup-sync-disconnected-primarybutton", + }, + [ErrorType.PASSWORD_LOCKED]: { + header: "firefoxview-tabpickup-password-locked-header", + description: "firefoxview-tabpickup-password-locked-description", + buttonLabel: "firefoxview-tabpickup-password-locked-primarybutton", + link: { + label: "firefoxview-tabpickup-password-locked-link", + href: + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "primary-password-stored-logins", + }, + }, + [ErrorType.SIGNED_OUT]: { + header: "firefoxview-tabpickup-signed-out-header", + description: "firefoxview-tabpickup-signed-out-description2", + buttonLabel: "firefoxview-tabpickup-signed-out-primarybutton", + }, + }, +}.init(); diff --git a/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs b/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs new file mode 100644 index 0000000000..4c43eea1b6 --- /dev/null +++ b/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs @@ -0,0 +1,653 @@ +/* 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 TabsSetupFlowManager singleton, which manages the state and + * diverse inputs which drive the Firefox View synced tabs setup flow + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "resource://gre/modules/Log.sys.mjs", + SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", + SyncedTabsErrorHandler: + "resource:///modules/firefox-view-synced-tabs-error-handler.sys.mjs", + UIState: "resource://services-sync/UIState.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "syncUtils", () => { + return ChromeUtils.importESModule("resource://services-sync/util.sys.mjs") + .Utils; +}); + +ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ).getFxAccountsSingleton(); +}); + +const SYNC_TABS_PREF = "services.sync.engine.tabs"; +const TOPIC_TABS_CHANGED = "services.sync.tabs.changed"; +const LOGGING_PREF = "browser.tabs.firefox-view.logLevel"; +const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed"; +const TOPIC_DEVICESTATE_CHANGED = "firefox-view.devicestate.changed"; +const TOPIC_DEVICELIST_UPDATED = "fxaccounts:devicelist_updated"; +const NETWORK_STATUS_CHANGED = "network:offline-status-changed"; +const SYNC_SERVICE_ERROR = "weave:service:sync:error"; +const FXA_DEVICE_CONNECTED = "fxaccounts:device_connected"; +const FXA_DEVICE_DISCONNECTED = "fxaccounts:device_disconnected"; +const SYNC_SERVICE_FINISHED = "weave:service:sync:finish"; +const PRIMARY_PASSWORD_UNLOCKED = "passwordmgr-crypto-login"; + +function openTabInWindow(window, url) { + const { switchToTabHavingURI } = + window.docShell.chromeEventHandler.ownerGlobal; + switchToTabHavingURI(url, true, {}); +} + +export const TabsSetupFlowManager = new (class { + constructor() { + this.QueryInterface = ChromeUtils.generateQI(["nsIObserver"]); + + this.setupState = new Map(); + this.resetInternalState(); + this._currentSetupStateName = ""; + this.syncIsConnected = lazy.UIState.get().syncEnabled; + this.didFxaTabOpen = false; + + this.registerSetupState({ + uiStateIndex: 0, + name: "error-state", + exitConditions: () => { + return lazy.SyncedTabsErrorHandler.isSyncReady(); + }, + }); + this.registerSetupState({ + uiStateIndex: 1, + name: "not-signed-in", + exitConditions: () => { + return this.fxaSignedIn; + }, + }); + this.registerSetupState({ + uiStateIndex: 2, + name: "connect-secondary-device", + exitConditions: () => { + return this.secondaryDeviceConnected; + }, + }); + this.registerSetupState({ + uiStateIndex: 3, + name: "disabled-tab-sync", + exitConditions: () => { + return this.syncTabsPrefEnabled; + }, + }); + this.registerSetupState({ + uiStateIndex: 4, + name: "synced-tabs-loaded", + exitConditions: () => { + // This is the end state + return false; + }, + }); + + Services.obs.addObserver(this, lazy.UIState.ON_UPDATE); + Services.obs.addObserver(this, TOPIC_DEVICELIST_UPDATED); + Services.obs.addObserver(this, NETWORK_STATUS_CHANGED); + Services.obs.addObserver(this, SYNC_SERVICE_ERROR); + Services.obs.addObserver(this, SYNC_SERVICE_FINISHED); + Services.obs.addObserver(this, TOPIC_TABS_CHANGED); + Services.obs.addObserver(this, PRIMARY_PASSWORD_UNLOCKED); + Services.obs.addObserver(this, FXA_DEVICE_CONNECTED); + Services.obs.addObserver(this, FXA_DEVICE_DISCONNECTED); + + // this.syncTabsPrefEnabled will track the value of the tabs pref + XPCOMUtils.defineLazyPreferenceGetter( + this, + "syncTabsPrefEnabled", + SYNC_TABS_PREF, + false, + () => { + this.maybeUpdateUI(true); + } + ); + + this._lastFxASignedIn = this.fxaSignedIn; + this.logger.debug( + "TabsSetupFlowManager constructor, fxaSignedIn:", + this._lastFxASignedIn + ); + this.onSignedInChange(); + } + + resetInternalState() { + // assign initial values for all the managed internal properties + delete this._lastFxASignedIn; + this._currentSetupStateName = "not-signed-in"; + this._shouldShowSuccessConfirmation = false; + this._didShowMobilePromo = false; + this.abortWaitingForTabs(); + + Services.obs.notifyObservers(null, TOPIC_DEVICESTATE_CHANGED); + + // keep track of what is connected so we can respond to changes + this._deviceStateSnapshot = { + mobileDeviceConnected: this.mobileDeviceConnected, + secondaryDeviceConnected: this.secondaryDeviceConnected, + }; + // keep track of tab-pickup-container instance visibilities + this._viewVisibilityStates = new Map(); + } + + get isPrimaryPasswordLocked() { + return lazy.syncUtils.mpLocked(); + } + + uninit() { + Services.obs.removeObserver(this, lazy.UIState.ON_UPDATE); + Services.obs.removeObserver(this, TOPIC_DEVICELIST_UPDATED); + Services.obs.removeObserver(this, NETWORK_STATUS_CHANGED); + Services.obs.removeObserver(this, SYNC_SERVICE_ERROR); + Services.obs.removeObserver(this, SYNC_SERVICE_FINISHED); + Services.obs.removeObserver(this, TOPIC_TABS_CHANGED); + Services.obs.removeObserver(this, PRIMARY_PASSWORD_UNLOCKED); + Services.obs.removeObserver(this, FXA_DEVICE_CONNECTED); + Services.obs.removeObserver(this, FXA_DEVICE_DISCONNECTED); + } + get hasVisibleViews() { + return Array.from(this._viewVisibilityStates.values()).reduce( + (hasVisible, visibility) => { + return hasVisible || visibility == "visible"; + }, + false + ); + } + get currentSetupState() { + return this.setupState.get(this._currentSetupStateName); + } + get isTabSyncSetupComplete() { + return this.currentSetupState.uiStateIndex >= 4; + } + get uiStateIndex() { + return this.currentSetupState.uiStateIndex; + } + get fxaSignedIn() { + let { UIState } = lazy; + let syncState = UIState.get(); + return ( + UIState.isReady() && + syncState.status === UIState.STATUS_SIGNED_IN && + // syncEnabled just checks the "services.sync.username" pref has a value + syncState.syncEnabled + ); + } + + get secondaryDeviceConnected() { + if (!this.fxaSignedIn) { + return false; + } + let recentDevices = lazy.fxAccounts.device?.recentDeviceList?.length; + return recentDevices > 1; + } + get mobileDeviceConnected() { + if (!this.fxaSignedIn) { + return false; + } + let mobileClients = lazy.fxAccounts.device.recentDeviceList?.filter( + device => device.type == "mobile" || device.type == "tablet" + ); + return mobileClients?.length > 0; + } + get shouldShowMobilePromo() { + return ( + this.syncIsConnected && + this.fxaSignedIn && + this.currentSetupState.uiStateIndex >= 4 && + !this.mobileDeviceConnected && + !this.mobilePromoDismissedPref + ); + } + get shouldShowMobileConnectedSuccess() { + return ( + this.currentSetupState.uiStateIndex >= 3 && + this._shouldShowSuccessConfirmation && + this.mobileDeviceConnected + ); + } + get logger() { + if (!this._log) { + let setupLog = lazy.Log.repository.getLogger("FirefoxView.TabsSetup"); + setupLog.manageLevelFromPref(LOGGING_PREF); + setupLog.addAppender( + new lazy.Log.ConsoleAppender(new lazy.Log.BasicFormatter()) + ); + this._log = setupLog; + } + return this._log; + } + + registerSetupState(state) { + this.setupState.set(state.name, state); + } + + async observe(subject, topic, data) { + switch (topic) { + case lazy.UIState.ON_UPDATE: + this.logger.debug("Handling UIState update"); + this.syncIsConnected = lazy.UIState.get().syncEnabled; + if (this._lastFxASignedIn !== this.fxaSignedIn) { + this.onSignedInChange(); + } else { + await this.maybeUpdateUI(); + } + this._lastFxASignedIn = this.fxaSignedIn; + break; + case TOPIC_DEVICELIST_UPDATED: + this.logger.debug("Handling observer notification:", topic, data); + const { deviceStateChanged, deviceAdded } = await this.refreshDevices(); + if (deviceStateChanged) { + await this.maybeUpdateUI(true); + } + if (deviceAdded && this.secondaryDeviceConnected) { + this.logger.debug("device was added"); + this._deviceAddedResultsNeverSeen = true; + if (this.hasVisibleViews) { + this.startWaitingForNewDeviceTabs(); + } + } + break; + case FXA_DEVICE_CONNECTED: + case FXA_DEVICE_DISCONNECTED: + await lazy.fxAccounts.device.refreshDeviceList({ ignoreCached: true }); + await this.maybeUpdateUI(true); + break; + case SYNC_SERVICE_ERROR: + this.logger.debug(`Handling ${SYNC_SERVICE_ERROR}`); + if (lazy.UIState.get().status == lazy.UIState.STATUS_SIGNED_IN) { + this.abortWaitingForTabs(); + await this.maybeUpdateUI(true); + } + break; + case NETWORK_STATUS_CHANGED: + this.abortWaitingForTabs(); + await this.maybeUpdateUI(true); + break; + case SYNC_SERVICE_FINISHED: + this.logger.debug(`Handling ${SYNC_SERVICE_FINISHED}`); + // We intentionally leave any empty-tabs timestamp + // as we may be still waiting for a sync that delivers some tabs + this._waitingForNextTabSync = false; + await this.maybeUpdateUI(true); + break; + case TOPIC_TABS_CHANGED: + this.stopWaitingForTabs(); + break; + case PRIMARY_PASSWORD_UNLOCKED: + this.logger.debug(`Handling ${PRIMARY_PASSWORD_UNLOCKED}`); + this.tryToClearError(); + break; + } + } + + updateViewVisibility(instanceId, visibility) { + const wasVisible = this.hasVisibleViews; + this.logger.debug( + `updateViewVisibility for instance: ${instanceId}, visibility: ${visibility}` + ); + if (visibility == "unloaded") { + this._viewVisibilityStates.delete(instanceId); + } else { + this._viewVisibilityStates.set(instanceId, visibility); + } + const isVisible = this.hasVisibleViews; + if (isVisible && !wasVisible) { + // If we're already timing waiting for tabs from a newly-added device + // we might be able to stop + if (this._noTabsVisibleFromAddedDeviceTimestamp) { + return this.stopWaitingForNewDeviceTabs(); + } + if (this._deviceAddedResultsNeverSeen) { + // If this is the first time a view has been visible since a device was added + // we may want to start the empty-tabs visible timer + return this.startWaitingForNewDeviceTabs(); + } + } + if (!isVisible) { + this.logger.debug( + "Resetting timestamp and tabs pending flags as there are no visible views" + ); + // if there's no view visible, we're not really waiting anymore + this.abortWaitingForTabs(); + } + return null; + } + + get waitingForTabs() { + return ( + // signed in & at least 1 other device is syncing indicates there's something to wait for + this.secondaryDeviceConnected && this._waitingForNextTabSync + ); + } + + abortWaitingForTabs() { + this._waitingForNextTabSync = false; + // also clear out the device-added / tabs pending flags + this._noTabsVisibleFromAddedDeviceTimestamp = 0; + this._deviceAddedResultsNeverSeen = false; + } + + startWaitingForTabs() { + if (!this._waitingForNextTabSync) { + this._waitingForNextTabSync = true; + Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED); + } + } + + async stopWaitingForTabs() { + const wasWaiting = this.waitingForTabs; + if (this.hasVisibleViews && this._deviceAddedResultsNeverSeen) { + await this.stopWaitingForNewDeviceTabs(); + } + this._waitingForNextTabSync = false; + if (wasWaiting) { + Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED); + } + } + + async onSignedInChange() { + this.logger.debug("onSignedInChange, fxaSignedIn:", this.fxaSignedIn); + // update UI to make the state change + await this.maybeUpdateUI(true); + if (!this.fxaSignedIn) { + // As we just signed out, ensure the waiting flag is reset for next time around + this.abortWaitingForTabs(); + return; + } + + // Now we need to figure out if we have recently synced tabs to show + // Or, if we are going to need to trigger a tab sync for them + const recentTabs = await lazy.SyncedTabs.getRecentTabs(50); + + if (!this.fxaSignedIn) { + // We got signed-out in the meantime. We should get an ON_UPDATE which will put us + // back in the right state, so we just do nothing here + return; + } + + // When SyncedTabs has resolved the getRecentTabs promise, + // we also know we can update devices-related internal state + const { deviceStateChanged } = await this.refreshDevices(); + if (deviceStateChanged) { + this.logger.debug( + "onSignedInChange, after refreshDevices, calling maybeUpdateUI" + ); + // give the UI an opportunity to update as secondaryDeviceConnected or + // mobileDeviceConnected have changed value + await this.maybeUpdateUI(true); + } + + // If we can't get recent tabs, we need to trigger a request for them + const tabSyncNeeded = !recentTabs?.length; + this.logger.debug("onSignedInChange, tabSyncNeeded:", tabSyncNeeded); + + if (tabSyncNeeded) { + this.startWaitingForTabs(); + this.logger.debug( + "isPrimaryPasswordLocked:", + this.isPrimaryPasswordLocked + ); + this.logger.debug("onSignedInChange, no recentTabs, calling syncTabs"); + // If the syncTabs call rejects or resolves false we need to clear the waiting + // flag and update UI + this.syncTabs() + .catch(ex => { + this.logger.debug("onSignedInChange, syncTabs rejected:", ex); + this.stopWaitingForTabs(); + }) + .then(willSync => { + if (!willSync) { + this.logger.debug("onSignedInChange, no tab sync expected"); + this.stopWaitingForTabs(); + } + }); + } + } + + async startWaitingForNewDeviceTabs() { + // if we're already waiting for tabs, don't reset + if (this._noTabsVisibleFromAddedDeviceTimestamp) { + return; + } + + // take a timestamp whenever the latest device is added and we have 0 tabs to show, + // allowing us to track how long we show an empty list after a new device is added + const hasRecentTabs = (await lazy.SyncedTabs.getRecentTabs(1)).length; + if (this.hasVisibleViews && !hasRecentTabs) { + this._noTabsVisibleFromAddedDeviceTimestamp = Date.now(); + this.logger.debug( + "New device added with 0 synced tabs to show, storing timestamp:", + this._noTabsVisibleFromAddedDeviceTimestamp + ); + } + } + + async stopWaitingForNewDeviceTabs() { + if (!this._noTabsVisibleFromAddedDeviceTimestamp) { + return; + } + const recentTabs = await lazy.SyncedTabs.getRecentTabs(1); + if (recentTabs.length) { + // We have been waiting for > 0 tabs after a newly-added device, record + // the time elapsed + const elapsed = Date.now() - this._noTabsVisibleFromAddedDeviceTimestamp; + this.logger.debug( + "stopWaitingForTabs, resetting _noTabsVisibleFromAddedDeviceTimestamp and recording telemetry:", + Math.round(elapsed / 1000) + ); + this._noTabsVisibleFromAddedDeviceTimestamp = 0; + this._deviceAddedResultsNeverSeen = false; + Services.telemetry.recordEvent( + "firefoxview", + "synced_tabs_empty", + "since_device_added", + Math.round(elapsed / 1000).toString() + ); + } else { + // we are still waiting for some tabs to show... + this.logger.debug( + "stopWaitingForTabs: Still no recent tabs, we are still waiting" + ); + } + } + + async refreshDevices() { + // If current device not found in recent device list, refresh device list + if ( + !lazy.fxAccounts.device.recentDeviceList?.some( + device => device.isCurrentDevice + ) + ) { + await lazy.fxAccounts.device.refreshDeviceList({ ignoreCached: true }); + } + + // compare new values to the previous values + const mobileDeviceConnected = this.mobileDeviceConnected; + const secondaryDeviceConnected = this.secondaryDeviceConnected; + const oldDevicesCount = this._deviceStateSnapshot?.devicesCount ?? 0; + const devicesCount = lazy.fxAccounts.device?.recentDeviceList?.length ?? 0; + + this.logger.debug( + `refreshDevices, mobileDeviceConnected: ${mobileDeviceConnected}, `, + `secondaryDeviceConnected: ${secondaryDeviceConnected}` + ); + + let deviceStateChanged = + this._deviceStateSnapshot.mobileDeviceConnected != + mobileDeviceConnected || + this._deviceStateSnapshot.secondaryDeviceConnected != + secondaryDeviceConnected; + if ( + mobileDeviceConnected && + !this._deviceStateSnapshot.mobileDeviceConnected + ) { + // a mobile device was added, show success if we previously showed the promo + this._shouldShowSuccessConfirmation = this._didShowMobilePromo; + } else if ( + !mobileDeviceConnected && + this._deviceStateSnapshot.mobileDeviceConnected + ) { + // no mobile device connected now, reset + this._shouldShowSuccessConfirmation = false; + } + this._deviceStateSnapshot = { + mobileDeviceConnected, + secondaryDeviceConnected, + devicesCount, + }; + if (deviceStateChanged) { + this.logger.debug("refreshDevices: device state did change"); + if (!secondaryDeviceConnected) { + this.logger.debug( + "We lost a device, now claim sync hasn't worked before." + ); + Services.obs.notifyObservers(null, TOPIC_DEVICESTATE_CHANGED); + } + } else { + this.logger.debug("refreshDevices: no device state change"); + } + return { + deviceStateChanged, + deviceAdded: oldDevicesCount < devicesCount, + }; + } + + async maybeUpdateUI(forceUpdate = false) { + let nextSetupStateName = this._currentSetupStateName; + let errorState = null; + let stateChanged = false; + + // state transition conditions + for (let state of this.setupState.values()) { + nextSetupStateName = state.name; + if (!state.exitConditions()) { + this.logger.debug( + "maybeUpdateUI, conditions not met to exit state: ", + nextSetupStateName + ); + break; + } + } + + let setupState = this.currentSetupState; + const state = this.setupState.get(nextSetupStateName); + const uiStateIndex = state.uiStateIndex; + + if ( + uiStateIndex == 0 || + nextSetupStateName != this._currentSetupStateName + ) { + setupState = state; + this._currentSetupStateName = nextSetupStateName; + stateChanged = true; + } + this.logger.debug( + "maybeUpdateUI, will notify update?:", + stateChanged, + forceUpdate + ); + if (stateChanged || forceUpdate) { + if (this.shouldShowMobilePromo) { + this._didShowMobilePromo = true; + } + if (uiStateIndex == 0) { + // Use idleDispatch() to give observers a chance to resolve before + // determining the new state. + errorState = await new Promise(resolve => { + ChromeUtils.idleDispatch(() => { + resolve(lazy.SyncedTabsErrorHandler.getErrorType()); + }); + }); + this.logger.debug("maybeUpdateUI, in error state:", errorState); + } + Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED, errorState); + } + if ("function" == typeof setupState.enter) { + setupState.enter(); + } + } + + async openFxASignup(window) { + if (!(await lazy.fxAccounts.constructor.canConnectAccount())) { + return; + } + const url = + await lazy.fxAccounts.constructor.config.promiseConnectAccountURI( + "fx-view" + ); + this.didFxaTabOpen = true; + openTabInWindow(window, url, true); + Services.telemetry.recordEvent( + "firefoxview_next", + "fxa_continue", + "sync", + null + ); + } + + async openFxAPairDevice(window) { + const url = await lazy.fxAccounts.constructor.config.promisePairingURI({ + entrypoint: "fx-view", + }); + this.didFxaTabOpen = true; + openTabInWindow(window, url, true); + Services.telemetry.recordEvent( + "firefoxview_next", + "fxa_mobile", + "sync", + null, + { + has_devices: this.secondaryDeviceConnected.toString(), + } + ); + } + + syncOpenTabs(containerElem) { + // Flip the pref on. + // The observer should trigger re-evaluating state and advance to next step + Services.prefs.setBoolPref(SYNC_TABS_PREF, true); + } + + async syncOnPageReload() { + if (lazy.UIState.isReady() && this.fxaSignedIn) { + this.startWaitingForTabs(); + await this.syncTabs(true); + } + } + + tryToClearError() { + if (lazy.UIState.isReady() && this.fxaSignedIn) { + this.startWaitingForTabs(); + if (this.isPrimaryPasswordLocked) { + lazy.syncUtils.ensureMPUnlocked(); + } + this.logger.debug("tryToClearError: triggering new tab sync"); + this.syncTabs(); + Services.tm.dispatchToMainThread(() => {}); + } else { + this.logger.debug( + `tryToClearError: unable to sync, isReady: ${lazy.UIState.isReady()}, fxaSignedIn: ${ + this.fxaSignedIn + }` + ); + } + } + // For easy overriding in tests + syncTabs(force = false) { + return lazy.SyncedTabs.syncTabs(force); + } +})(); diff --git a/browser/components/firefoxview/firefoxview.css b/browser/components/firefoxview/firefoxview.css new file mode 100644 index 0000000000..48cf5a9490 --- /dev/null +++ b/browser/components/firefoxview/firefoxview.css @@ -0,0 +1,187 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + + @import url("chrome://global/skin/in-content/common.css"); + +:root { + /* override --in-content-page-background from common-shared.css */ + background-color: transparent; + --fxview-background-color: var(--newtab-background-color, var(--in-content-page-background)); + --fxview-background-color-secondary: var(--newtab-background-color-secondary, #FFFFFF); + --fxview-element-background-hover: color-mix(in srgb, var(--fxview-background-color) 90%, currentColor); + --fxview-element-background-active: color-mix(in srgb, var(--fxview-background-color) 80%, currentColor); + --fxview-text-primary-color: var(--newtab-text-primary-color, var(--in-content-page-color)); + --fxview-text-secondary-color: color-mix(in srgb, currentColor 70%, transparent); + --fxview-text-color-hover: var(--newtab-text-primary-color); + --fxview-primary-action-background: var(--newtab-primary-action-background, #0061e0); + --fxview-border: var(--fc-border-light, #CFCFD8); + + /* ensure utility button hover states match those of the rest of the page */ + --in-content-button-background-hover: var(--fxview-element-background-hover); + --in-content-button-background-active: var(--fxview-element-background-active); + --in-content-button-text-color-hover: var(--fxview-text-color-hover); + + --fxview-sidebar-width: 288px; + --fxview-margin-top: 72px; + --fxview-card-padding-inline: 4px; + + /* copy over newtab background color from activity-stream-[os].css files */ + --newtab-background-color: #F9F9FB; + + --fxview-card-header-font-weight: 500; +} + +@media (prefers-color-scheme: dark) { + :root { + --fxview-element-background-hover: color-mix(in srgb, var(--fxview-background-color) 80%, white); + --fxview-element-background-active: color-mix(in srgb, var(--fxview-background-color) 60%, white); + --fxview-border: #8F8F9D; + + /* copy over newtab colors from activity-stream-[os].css files */ + --newtab-background-color: #2B2A33; + --newtab-background-color-secondary: #42414d; + --newtab-primary-action-background: #00ddff; + } +} + +@media (prefers-contrast) { + :root { + --fxview-element-background-hover: ButtonText; + --fxview-element-background-active: ButtonText; + --fxview-text-color-hover: ButtonFace; + --fxview-border: var(--fc-border-hcm, -moz-dialogtext); + --newtab-primary-action-background: LinkText; + --newtab-background-color: Canvas; + --newtab-background-color-secondary: Canvas; + } +} + +@media (max-width: 52rem) { + :root { + --fxview-sidebar-width: 82px; + } +} + +body { + display: grid; + gap: 12px; + grid-template-columns: var(--fxview-sidebar-width) 1fr; + background-color: var(--fxview-background-color); + color: var(--fxview-text-primary-color); +} + +.main-container { + width: 90%; + margin: 0 auto; + min-width: 43rem; + max-width: 71rem; +} + +@media (min-width: 120rem) { + .main-container { + margin-inline-start: 148px; + } +} + +.page-header { + margin: 0; +} + +fxview-category-button:focus-visible { + outline-offset: var(--in-content-focus-outline-inset); +} + +fxview-category-button[name="recentbrowsing"]::part(icon) { + background-image: url("chrome://browser/content/firefoxview/category-recentbrowsing.svg"); +} +fxview-category-button[name="opentabs"]::part(icon) { + background-image: url("chrome://browser/content/firefoxview/category-opentabs.svg"); +} +fxview-category-button[name="recentlyclosed"]::part(icon) { + background-image: url("chrome://browser/content/firefoxview/category-recentlyclosed.svg"); +} +fxview-category-button[name="syncedtabs"]::part(icon) { + background-image: url("chrome://browser/content/firefoxview/category-syncedtabs.svg"); +} +fxview-category-button[name="history"]::part(icon) { + background-image: url("chrome://browser/content/firefoxview/category-history.svg"); +} + +fxview-tab-list.with-dismiss-button::part(secondary-button) { + background-image: url("chrome://global/skin/icons/close.svg"); +} + +fxview-tab-list.with-context-menu::part(secondary-button) { + background-image: url("chrome://global/skin/icons/more.svg"); +} + +.sticky-container { + position: sticky; + top: 0; + padding-block: var(--fxview-margin-top) 33px; + z-index: 1; + display: flex; + flex-direction: column; + gap: 35px; +} + +.sticky-container.bottom-fade { + /* + * padding-inline is doubled to allow for the negative margin below to offset the + * container so that the box-shadows on the cards are hidden as they pass underneath. + */ + padding-inline: calc(var(--fxview-card-padding-inline) * 2); + margin: 0 calc(var(--fxview-card-padding-inline) * -1); + + background: + linear-gradient( + to bottom, + var(--fxview-background-color) 0%, + var(--fxview-background-color) 95%, + transparent 100% + ); + /* When you use HCM or set custom colors, you can't use a gradient. */ + @media (forced-colors) { + background: var(--fxview-background-color); + } +} + +.cards-container { + padding-inline: var(--fxview-card-padding-inline); +} + +view-opentabs-contextmenu { + display: contents; +} + +/* This should be supported within panel-{item,list} rather than modifying it */ +panel-item::part(button) { + padding-inline-start: 12px; + cursor: pointer; +} + +panel-item::part(button):hover { + background-color: var(--fxview-element-background-hover); + color: var(--fxview-text-color-hover); +} + +panel-item::part(button):hover:active { + background-color: var(--fxview-element-background-active); +} + +panel-list { + overflow-y: visible; +} + +fxview-category-navigation { + overflow-y: auto; +} + +fxview-category-navigation h1 { + margin-block: 0; +} + +fxview-empty-state:not([isSelectedTab]) button[slot="primary-action"] { + margin-inline-start: 0; +} diff --git a/browser/components/firefoxview/firefoxview.html b/browser/components/firefoxview/firefoxview.html new file mode 100644 index 0000000000..1f53a1d0c9 --- /dev/null +++ b/browser/components/firefoxview/firefoxview.html @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

    + + + + + + + + + + +
    +
    +
    + + +
    + +
    +
    + +
    +
    + +
    +
    + + + + +
    +
    +
    + + + diff --git a/browser/components/firefoxview/firefoxview.mjs b/browser/components/firefoxview/firefoxview.mjs new file mode 100644 index 0000000000..77f4c06cc7 --- /dev/null +++ b/browser/components/firefoxview/firefoxview.mjs @@ -0,0 +1,189 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +let pageList = []; +let categoryPagesDeck = null; +let categoryNavigation = null; +let activeComponent = null; +let searchKeyboardShortcut = null; + +const { topChromeWindow } = window.browsingContext; + +function onHashChange() { + let page = document.location?.hash.substring(1); + if (!page || !pageList.includes(page)) { + page = "recentbrowsing"; + } + changePage(page); +} + +function changePage(page) { + categoryPagesDeck.selectedViewName = page; + categoryNavigation.currentCategory = page; + if (categoryNavigation.categoryButtons.includes(document.activeElement)) { + let currentCategoryButton = categoryNavigation.categoryButtons.find( + categoryButton => categoryButton.name === page + ); + (currentCategoryButton || categoryNavigation.categoryButtons[0]).focus(); + } +} + +function onPagesDeckViewChange() { + for (const child of categoryPagesDeck.children) { + if (child.getAttribute("name") == categoryPagesDeck.selectedViewName) { + child.enter(); + activeComponent = child; + } else { + child.exit(); + } + } +} + +function recordNavigationTelemetry(source, eventTarget) { + let page = "recentbrowsing"; + if (source === "category-navigation") { + page = eventTarget.parentNode.currentCategory; + } else if (source === "view-all") { + page = eventTarget.shortPageName; + } + // Record telemetry + Services.telemetry.recordEvent( + "firefoxview_next", + "change_page", + "navigation", + null, + { + page, + source, + } + ); +} + +async function updateSearchTextboxSize() { + const msgs = [ + { id: "firefoxview-search-text-box-recentbrowsing" }, + { id: "firefoxview-search-text-box-opentabs" }, + { id: "firefoxview-search-text-box-recentlyclosed" }, + { id: "firefoxview-search-text-box-syncedtabs" }, + { id: "firefoxview-search-text-box-history" }, + ]; + let maxLength = 30; + for (const msg of await document.l10n.formatMessages(msgs)) { + const placeholder = msg.attributes[0].value; + maxLength = Math.max(maxLength, placeholder.length); + } + for (const child of categoryPagesDeck.children) { + child.searchTextboxSize = maxLength; + } +} + +async function updateSearchKeyboardShortcut() { + const [message] = await topChromeWindow.document.l10n.formatMessages([ + { id: "find-shortcut" }, + ]); + const key = message.attributes[0].value; + searchKeyboardShortcut = key.toLocaleLowerCase(); +} + +window.addEventListener("DOMContentLoaded", async () => { + recordEnteredTelemetry(); + + categoryNavigation = document.querySelector("fxview-category-navigation"); + categoryPagesDeck = document.querySelector("named-deck"); + + for (const item of categoryNavigation.categoryButtons) { + pageList.push(item.getAttribute("name")); + } + window.addEventListener("hashchange", onHashChange); + window.addEventListener("change-category", function (event) { + location.hash = event.target.getAttribute("name"); + window.scrollTo(0, 0); + recordNavigationTelemetry("category-navigation", event.target); + }); + window.addEventListener("card-container-view-all", function (event) { + recordNavigationTelemetry("view-all", event.originalTarget); + }); + + categoryPagesDeck.addEventListener("view-changed", onPagesDeckViewChange); + + // set the initial state + onHashChange(); + onPagesDeckViewChange(); + await updateSearchTextboxSize(); + await updateSearchKeyboardShortcut(); + + if (Cu.isInAutomation) { + Services.obs.notifyObservers(null, "firefoxview-entered"); + } +}); + +document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") { + recordEnteredTelemetry(); + if (Cu.isInAutomation) { + // allow all the component visibilitychange handlers to execute before notifying + requestAnimationFrame(() => { + Services.obs.notifyObservers(null, "firefoxview-entered"); + }); + } + } +}); + +function recordEnteredTelemetry() { + Services.telemetry.recordEvent( + "firefoxview_next", + "entered", + "firefoxview", + null, + { + page: document.location?.hash?.substring(1) || "recentbrowsing", + } + ); +} + +document.addEventListener("keydown", e => { + if (e.getModifierState("Accel") && e.key === searchKeyboardShortcut) { + activeComponent.searchTextbox?.focus(); + } +}); + +window.addEventListener( + "unload", + () => { + // Clear out the document so the disconnectedCallback will trigger + // properly and all of the custom elements can cleanup. + document.body.textContent = ""; + topChromeWindow.removeEventListener("command", onCommand); + Services.obs.removeObserver(onLocalesChanged, "intl:app-locales-changed"); + }, + { once: true } +); + +topChromeWindow.addEventListener("command", onCommand); +Services.obs.addObserver(onLocalesChanged, "intl:app-locales-changed"); + +function onCommand(e) { + if (document.hidden || !e.target.closest("#contentAreaContextMenu")) { + return; + } + const item = + e.target.closest("#context-openlinkinusercontext-menu") || e.target; + Services.telemetry.recordEvent( + "firefoxview_next", + "browser_context_menu", + "tabs", + null, + { + menu_action: item.id, + page: location.hash?.substring(1) || "recentbrowsing", + } + ); +} + +function onLocalesChanged() { + requestIdleCallback(() => { + updateSearchTextboxSize(); + updateSearchKeyboardShortcut(); + }); +} diff --git a/browser/components/firefoxview/fxview-category-button.css b/browser/components/firefoxview/fxview-category-button.css new file mode 100644 index 0000000000..1bce29f343 --- /dev/null +++ b/browser/components/firefoxview/fxview-category-button.css @@ -0,0 +1,125 @@ +/* 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 { + border-radius: 4px; +} + +button { + background-color: initial; + border: 1px solid var(--in-content-primary-button-border-color); + -moz-context-properties: fill, fill-opacity; + fill: currentColor; + display: grid; + grid-template-columns: min-content 1fr; + gap: 12px; + align-items: center; + font-size: inherit; + width: 100%; + font-weight: normal; + border-radius: 4px; + color: inherit; + text-align: start; + transition: background-color 150ms; + padding: var(--fxviewcategorynav-button-padding); +} + +button:hover { + cursor: pointer; +} + +@media not (prefers-contrast) { + button { + border-inline-start: 2px solid transparent; + border-inline-end: none; + border-block: none; + } + + button:hover, + button[selected]:hover { + background-color: var(--in-content-button-background-hover); + border-color: var(--in-content-button-border-color-hover); + } + + button[selected]:hover { + border-inline-start-color: inherit; + } + + button[selected], + button[selected]:hover { + border-inline-start: 2px solid; + } + + button[selected]:not(:focus-visible) { + border-start-start-radius: 0; + border-end-start-radius: 0; + } + + button[selected]:not(:hover) { + color: var(--in-content-accent-color); + background-color: color-mix(in srgb, var(--fxview-primary-action-background) 5%, transparent); + border-inline-start-color: var(--in-content-accent-color); + } +} + +@media (prefers-color-scheme: dark) { + button[selected] { + background-color: color-mix(in srgb, var(--fxview-primary-action-background) 12%, transparent); + } +} + +button:focus-visible, +button[selected]:focus-visible { + outline: var(--focus-outline); + outline-offset: var(--focus-outline-offset); +} + +.category-icon { + background-color: initial; + background-size: 20px; + background-repeat: no-repeat; + background-position: center; + height: 20px; + width: 20px; + -moz-context-properties: fill; + fill: currentColor; +} + +@media (prefers-contrast) { + button { + transition: none; + border-color: ButtonText; + background-color: var(--in-content-button-background); + } + + button:hover { + color: SelectedItem; + } + + button[selected] { + color: SelectedItemText; + background-color: SelectedItem; + border-color: SelectedItem; + } +} + +slot { + font-size: 1.13em; + line-height: 1.4; + margin: 0; + padding-inline-start: 0; + user-select: none; +} + +@media (max-width: 52rem) { + button { + grid-template-columns: min-content; + justify-content: center; + margin-inline: 0; + } + + slot { + display: none; + } +} diff --git a/browser/components/firefoxview/fxview-category-navigation.css b/browser/components/firefoxview/fxview-category-navigation.css new file mode 100644 index 0000000000..571059699b --- /dev/null +++ b/browser/components/firefoxview/fxview-category-navigation.css @@ -0,0 +1,60 @@ +/* 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 { + --fxviewcategorynav-button-padding: 8px; + margin-inline-start: 42px; + position: sticky; + top: 0; + height: 100vh; +} + +nav { + display: grid; + grid-template-rows: min-content 1fr auto; + gap: 25px; + margin-block-start: var(--fxview-margin-top); +} + +.category-nav-header { + /* Align the header text/icon with the category button icons */ + margin-inline-start: var(--fxviewcategorynav-button-padding); +} + +.category-nav-buttons, +::slotted(.category-nav-footer) { + display: grid; + grid-template-columns: 1fr; + grid-auto-rows: min-content; + gap: 4px; +} + +@media (prefers-contrast) { + .category-nav-buttons { + gap: 8px; + } +} + +@media (prefers-reduced-motion) { + /* (See Bug 1610081) Setting border-inline-end to add clear differentiation between side navigation and main content area */ + :host { + border-inline-end: 1px solid var(--in-content-border-color); + } +} + +@media (max-width: 52rem) { + :host { + grid-template-rows: 1fr auto; + } + + .category-nav-header { + display: none; + } + + .category-nav-buttons, + ::slotted(.category-nav-footer) { + justify-content: center; + grid-template-columns: min-content; + } +} diff --git a/browser/components/firefoxview/fxview-category-navigation.mjs b/browser/components/firefoxview/fxview-category-navigation.mjs new file mode 100644 index 0000000000..abacd17df1 --- /dev/null +++ b/browser/components/firefoxview/fxview-category-navigation.mjs @@ -0,0 +1,150 @@ +/* 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"; + +export default class FxviewCategoryNavigation extends MozLitElement { + static properties = { + currentCategory: { type: String }, + }; + + static queries = { + categoryButtonsSlot: "slot[name=category-button]", + }; + + get categoryButtons() { + return this.categoryButtonsSlot + .assignedNodes() + .filter(node => !node.hidden); + } + + onChangeCategory(e) { + this.currentCategory = e.target.name; + } + + handleFocus(e) { + if (e.key == "ArrowDown" || e.key == "ArrowRight") { + e.preventDefault(); + this.focusNextCategory(); + } else if (e.key == "ArrowUp" || e.key == "ArrowLeft") { + e.preventDefault(); + this.focusPreviousCategory(); + } + } + + focusPreviousCategory() { + let categoryButtons = this.categoryButtons; + let currentIndex = categoryButtons.findIndex(b => b.selected); + let prev = categoryButtons[currentIndex - 1]; + if (prev) { + prev.activate(); + prev.focus(); + } + } + + focusNextCategory() { + let categoryButtons = this.categoryButtons; + let currentIndex = categoryButtons.findIndex(b => b.selected); + let next = categoryButtons[currentIndex + 1]; + if (next) { + next.activate(); + next.focus(); + } + } + + render() { + return html` + + + `; + } + + updated() { + let categorySelected = false; + let assignedCategories = this.categoryButtons; + for (let button of assignedCategories) { + button.selected = button.name == this.currentCategory; + categorySelected = categorySelected || button.selected; + } + if (!categorySelected && assignedCategories.length) { + // Current category has no matching category, reset to the first category. + assignedCategories[0].activate(); + } + } +} +customElements.define("fxview-category-navigation", FxviewCategoryNavigation); + +export class FxviewCategoryButton extends MozLitElement { + static properties = { + selected: { type: Boolean }, + }; + + static queries = { + buttonEl: "button", + }; + + connectedCallback() { + super.connectedCallback(); + this.setAttribute("role", "tab"); + } + + get name() { + return this.getAttribute("name"); + } + + activate() { + this.dispatchEvent( + new CustomEvent("change-category", { + bubbles: true, + composed: true, + }) + ); + } + + render() { + return html` + + + `; + } + + updated() { + this.setAttribute("aria-selected", this.selected); + this.setAttribute("tabindex", this.selected ? 0 : -1); + } +} +customElements.define("fxview-category-button", FxviewCategoryButton); diff --git a/browser/components/firefoxview/fxview-empty-state.css b/browser/components/firefoxview/fxview-empty-state.css new file mode 100644 index 0000000000..80b4099e6a --- /dev/null +++ b/browser/components/firefoxview/fxview-empty-state.css @@ -0,0 +1,99 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +@import url("chrome://global/skin/design-system/text-and-typography.css"); + +[slot="main"] { + display: flex; + gap: 40px; + align-items: center; + padding: 36px; +} + +[slot="main"].selectedTab { + flex-direction: column; + text-align: center; + gap: 22px; + height: 264px; +} + +[slot="main"].selectedTab .header { + justify-content: center; +} + +[slot="main"].imageHidden .image-container { + display: none; +} + +[slot="main"].imageHidden .main { + display: flex; + flex: 1; + justify-content: center; +} + +.image-container { + min-width: 150px; + text-align: center; +} + +.image { + -moz-context-properties: fill, stroke, fill-opacity; + fill: var(--fxview-background-color-secondary); + stroke: var(--fxview-text-primary-color); +} + +.header { + margin-block: 0; + align-items: center; + gap: 8px; + + :host(.search-results) & { + font-size: unset; + + & span { + overflow-wrap: anywhere; + } + } + + &:not([hidden]) { + display: flex; + } +} + +.icon { + background-position: center center; + background-repeat: no-repeat; + width: 20px; + height: 20px; + + &:not([hidden]) { + display: inline-block; + } +} + + +.info { + -moz-context-properties: fill; + fill: var(--in-content-primary-button-background); +} + +.description { + color: var(--text-color-deemphasized); + margin-block: 4px 15px; +} + +.description.secondary { + margin-block-start: 16px; +} + +.main a { + color: var(--fxview-primary-action-background); +} + +img.greyscale { + filter: grayscale(100%); + @media not (prefers-contrast) { + opacity: 0.5; + } +} diff --git a/browser/components/firefoxview/fxview-empty-state.mjs b/browser/components/firefoxview/fxview-empty-state.mjs new file mode 100644 index 0000000000..9e6bc488fa --- /dev/null +++ b/browser/components/firefoxview/fxview-empty-state.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 { + html, + ifDefined, + classMap, + repeat, +} from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +/** + * An empty state card to be used throughout Firefox View + * + * @property {string} headerIconUrl - (Optional) The chrome:// url for an icon to be displayed within the header + * @property {string} headerLabel - (Optional) The l10n id for the header text for the empty/error state + * @property {object} headerArgs - (Optional) The l10n args for the header text for the empty/error state + * @property {string} isInnerCard - (Optional) True if the card is displayed within another card and needs a border instead of box shadow + * @property {boolean} isSelectedTab - (Optional) True if the component is the selected navigation tab - defaults to false + * @property {Array} descriptionLabels - (Optional) An array of l10n ids for the secondary description text for the empty/error state + * @property {object} descriptionLink - (Optional) An object describing the l10n name and url needed within a description label + * @property {string} mainImageUrl - (Optional) The chrome:// url for the main image of the empty/error state + * @property {string} errorGrayscale - (Optional) The image should be shown in gray scale + */ +class FxviewEmptyState extends MozLitElement { + constructor() { + super(); + this.isSelectedTab = false; + this.descriptionLabels = []; + this.headerArgs = {}; + } + + static properties = { + headerLabel: { type: String }, + headerArgs: { type: Object }, + headerIconUrl: { type: String }, + isInnerCard: { type: Boolean }, + isSelectedTab: { type: Boolean }, + descriptionLabels: { type: Array }, + desciptionLink: { type: Object }, + mainImageUrl: { type: String }, + errorGrayscale: { type: Boolean }, + }; + + static queries = { + headerEl: ".header", + descriptionEls: { all: ".description" }, + }; + + linkTemplate(descriptionLink) { + if (!descriptionLink) { + return html``; + } + return html` `; + } + + render() { + return html` + + +
    +
    + +
    +
    + + ${repeat( + this.descriptionLabels, + descLabel => descLabel, + (descLabel, index) => html`

    + ${this.linkTemplate(this.descriptionLink)} +

    ` + )} + +
    +
    +
    + `; + } +} +customElements.define("fxview-empty-state", FxviewEmptyState); diff --git a/browser/components/firefoxview/fxview-search-textbox.css b/browser/components/firefoxview/fxview-search-textbox.css new file mode 100644 index 0000000000..82c33c8069 --- /dev/null +++ b/browser/components/firefoxview/fxview-search-textbox.css @@ -0,0 +1,78 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +.search-container { + border: 1px solid var(--fxview-border); + border-radius: var(--border-radius-small); + color: var(--fxview-text-primary-color); + display: inline-flex; + overflow: hidden; + position: relative; + + &:focus-within { + overflow: visible; + } +} + +.search-icon { + background-image: url(chrome://global/skin/icons/search-textbox.svg); + background-position: center; + background-repeat: no-repeat; + background-size: 16px; + fill: currentColor; + -moz-context-properties: fill; + height: 16px; + width: 16px; + position: absolute; + top: 0; + bottom: 0; + margin: auto 0; + padding: 2px; +} + +.search-icon:dir(ltr) { + left: 8px; +} + +.search-icon:dir(rtl) { + right: 8px; +} + +input { + border: none; + padding-block-start: 8px; + padding-block-end: 8px; + padding-inline-start: 32px; + padding-inline-end: 32px; +} + +.clear-icon { + background-image: url(chrome://global/skin/icons/close-12.svg); + background-position: center; + background-repeat: no-repeat; + background-size: 16px; + fill: currentColor; + -moz-context-properties: fill; + cursor: pointer; + height: 16px; + width: 16px; + position: absolute; + top: 0; + bottom: 0; + margin: auto 0; + padding: 2px; +} + +.clear-icon:hover { + background-color: var(--fxview-element-background-hover); + color: var(--fxview-text-color-hover); +} + +.clear-icon:dir(ltr) { + right: 8px; +} + +.clear-icon:dir(rtl) { + left: 8px; +} diff --git a/browser/components/firefoxview/fxview-search-textbox.mjs b/browser/components/firefoxview/fxview-search-textbox.mjs new file mode 100644 index 0000000000..1332f5f3f6 --- /dev/null +++ b/browser/components/firefoxview/fxview-search-textbox.mjs @@ -0,0 +1,143 @@ +/* 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 } from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", +}); + +const SEARCH_DEBOUNCE_RATE_MS = 500; +const SEARCH_DEBOUNCE_TIMEOUT_MS = 1000; + +/** + * A search box that displays a search icon and is clearable. Updates to the + * search query trigger a `fxview-search-textbox-query` event with the current + * query value. + * + * 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. + * + * @property {string} placeholder + * The placeholder text for the search box. + * @property {number} size + * The width (number of characters) of the search box. + * @property {string} pageName + * The hash for the page name that the search input is located on. + */ +export default class FxviewSearchTextbox extends MozLitElement { + static properties = { + placeholder: { type: String }, + size: { type: Number }, + pageName: { type: String }, + }; + + static queries = { + clearButton: ".clear-icon", + input: "input", + }; + + #query = ""; + + constructor() { + super(); + this.searchTask = new lazy.DeferredTask( + () => this.#dispatchQueryEvent(), + SEARCH_DEBOUNCE_RATE_MS, + SEARCH_DEBOUNCE_TIMEOUT_MS + ); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (!this.searchTask?.isFinalized) { + this.searchTask?.finalize(); + } + } + + focus() { + this.input.focus(); + } + + blur() { + this.input.blur(); + } + + onInput(event) { + this.#query = event.target.value.trim(); + event.preventDefault(); + this.onSearch(); + } + + /** + * Handler for query updates from keyboard input, and textbox clears from 'X' + * button. + */ + onSearch() { + this.searchTask?.arm(); + this.requestUpdate(); + } + + clear(event) { + if ( + event.type == "click" || + (event.type == "keydown" && event.code == "Enter") || + (event.type == "keydown" && event.code == "Space") + ) { + this.#query = ""; + event.preventDefault(); + this.onSearch(); + } + } + + #dispatchQueryEvent() { + window.scrollTo(0, 0); + this.dispatchEvent( + new CustomEvent("fxview-search-textbox-query", { + bubbles: true, + composed: true, + detail: { query: this.#query }, + }) + ); + + Services.telemetry.recordEvent( + "firefoxview_next", + "search_initiated", + "search", + null, + { + page: this.pageName, + } + ); + } + + render() { + return html` + +
    +
    + +
    +
    `; + } +} + +customElements.define("fxview-search-textbox", FxviewSearchTextbox); diff --git a/browser/components/firefoxview/fxview-tab-list.css b/browser/components/firefoxview/fxview-tab-list.css new file mode 100644 index 0000000000..d32d9c9c08 --- /dev/null +++ b/browser/components/firefoxview/fxview-tab-list.css @@ -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/. */ + + .fxview-tab-list { + display: grid; + grid-template-columns: min-content 3fr min-content 2fr 1fr 1fr min-content min-content; + gap: 6px; +} + +:host([compactRows]) .fxview-tab-list { + grid-template-columns: min-content 1fr min-content min-content min-content; +} + +virtual-list { + display: grid; + grid-column: span 9; + grid-template-columns: subgrid; + + .top-padding, + .bottom-padding { + grid-column: span 9; + } +} diff --git a/browser/components/firefoxview/fxview-tab-list.mjs b/browser/components/firefoxview/fxview-tab-list.mjs new file mode 100644 index 0000000000..055540722a --- /dev/null +++ b/browser/components/firefoxview/fxview-tab-list.mjs @@ -0,0 +1,919 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + classMap, + html, + ifDefined, + repeat, + styleMap, + when, +} from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; +import { escapeRegExp } from "./helpers.mjs"; + +const NOW_THRESHOLD_MS = 91000; +const FXVIEW_ROW_HEIGHT_PX = 32; +const lazy = {}; +let XPCOMUtils; + +if (!window.IS_STORYBOOK) { + XPCOMUtils = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" + ).XPCOMUtils; + XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "virtualListEnabledPref", + "browser.firefox-view.virtual-list.enabled" + ); + ChromeUtils.defineLazyGetter(lazy, "relativeTimeFormat", () => { + return new Services.intl.RelativeTimeFormat(undefined, { + style: "narrow", + }); + }); + + ChromeUtils.defineESModuleGetters(lazy, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", + }); +} + +/** + * A list of clickable tab items + * + * @property {boolean} compactRows - Whether to hide the URL and date/time for each tab. + * @property {string} dateTimeFormat - Expected format for date and/or time + * @property {string} hasPopup - The aria-haspopup attribute for the secondary action, if required + * @property {number} maxTabsLength - The max number of tabs for the list + * @property {Array} tabItems - Items to show in the tab list + * @property {string} searchQuery - The query string to highlight, if provided. + */ +export default class FxviewTabList extends MozLitElement { + constructor() { + super(); + window.MozXULElement.insertFTLIfNeeded("toolkit/branding/brandings.ftl"); + window.MozXULElement.insertFTLIfNeeded("browser/fxviewTabList.ftl"); + this.activeIndex = 0; + this.currentActiveElementId = "fxview-tab-row-main"; + this.hasPopup = null; + this.dateTimeFormat = "relative"; + this.maxTabsLength = 25; + this.tabItems = []; + this.compactRows = false; + this.updatesPaused = true; + this.#register(); + } + + static properties = { + activeIndex: { type: Number }, + compactRows: { type: Boolean }, + currentActiveElementId: { type: String }, + dateTimeFormat: { type: String }, + hasPopup: { type: String }, + maxTabsLength: { type: Number }, + tabItems: { type: Array }, + updatesPaused: { type: Boolean }, + searchQuery: { type: String }, + }; + + static queries = { + rowEls: { all: "fxview-tab-row" }, + rootVirtualListEl: "virtual-list", + }; + + willUpdate(changes) { + this.activeIndex = Math.min( + Math.max(this.activeIndex, 0), + this.tabItems.length - 1 + ); + + if (changes.has("dateTimeFormat") || changes.has("updatesPaused")) { + this.clearIntervalTimer(); + if ( + !this.updatesPaused && + this.dateTimeFormat == "relative" && + !window.IS_STORYBOOK + ) { + this.startIntervalTimer(); + this.onIntervalUpdate(); + } + } + + if (this.maxTabsLength > 0) { + // Can set maxTabsLength to -1 to have no max + this.tabItems = this.tabItems.slice(0, this.maxTabsLength); + } + } + + startIntervalTimer() { + this.clearIntervalTimer(); + this.intervalID = setInterval( + () => this.onIntervalUpdate(), + this.timeMsPref + ); + } + + clearIntervalTimer() { + if (this.intervalID) { + clearInterval(this.intervalID); + delete this.intervalID; + } + } + + #register() { + if (!window.IS_STORYBOOK) { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "timeMsPref", + "browser.tabs.firefox-view.updateTimeMs", + NOW_THRESHOLD_MS, + (prefName, oldVal, newVal) => { + this.clearIntervalTimer(); + if (!this.isConnected) { + return; + } + this.startIntervalTimer(); + this.requestUpdate(); + } + ); + } + } + + connectedCallback() { + super.connectedCallback(); + if ( + !this.updatesPaused && + this.dateTimeFormat === "relative" && + !window.IS_STORYBOOK + ) { + this.startIntervalTimer(); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.clearIntervalTimer(); + } + + async getUpdateComplete() { + await super.getUpdateComplete(); + await Promise.all(Array.from(this.rowEls).map(item => item.updateComplete)); + } + + onIntervalUpdate() { + this.requestUpdate(); + Array.from(this.rowEls).forEach(fxviewTabRow => + fxviewTabRow.requestUpdate() + ); + } + + /** + * Focuses the expected element (either the link or button) within fxview-tab-row + * The currently focused/active element ID within a row is stored in this.currentActiveElementId + */ + handleFocusElementInRow(e) { + let fxviewTabRow = e.target; + if (e.code == "ArrowUp") { + // Focus either the link or button of the previous row based on this.currentActiveElementId + e.preventDefault(); + this.focusPrevRow(); + } else if (e.code == "ArrowDown") { + // Focus either the link or button of the next row based on this.currentActiveElementId + e.preventDefault(); + this.focusNextRow(); + } else if (e.code == "ArrowRight") { + // Focus either the link or the button in the current row and + // set this.currentActiveElementId to that element's ID + e.preventDefault(); + if (document.dir == "rtl") { + if ( + (fxviewTabRow.soundPlaying || fxviewTabRow.muted) && + this.currentActiveElementId === "fxview-tab-row-secondary-button" + ) { + this.currentActiveElementId = fxviewTabRow.focusMediaButton(); + } else { + this.currentActiveElementId = fxviewTabRow.focusLink(); + } + } else if ( + (fxviewTabRow.soundPlaying || fxviewTabRow.muted) && + this.currentActiveElementId === "fxview-tab-row-main" + ) { + this.currentActiveElementId = fxviewTabRow.focusMediaButton(); + } else { + this.currentActiveElementId = fxviewTabRow.focusButton(); + } + } else if (e.code == "ArrowLeft") { + // Focus either the link or the button in the current row and + // set this.currentActiveElementId to that element's ID + e.preventDefault(); + if (document.dir == "rtl") { + if ( + (fxviewTabRow.soundPlaying || fxviewTabRow.muted) && + this.currentActiveElementId === "fxview-tab-row-main" + ) { + this.currentActiveElementId = fxviewTabRow.focusMediaButton(); + } else { + this.currentActiveElementId = fxviewTabRow.focusButton(); + } + } else if ( + (fxviewTabRow.soundPlaying || fxviewTabRow.muted) && + this.currentActiveElementId === "fxview-tab-row-secondary-button" + ) { + this.currentActiveElementId = fxviewTabRow.focusMediaButton(); + } else { + this.currentActiveElementId = fxviewTabRow.focusLink(); + } + } + } + + focusPrevRow() { + this.focusIndex(this.activeIndex - 1); + } + + focusNextRow() { + this.focusIndex(this.activeIndex + 1); + } + + async focusIndex(index) { + // Focus link or button of item + if (lazy.virtualListEnabledPref) { + let row = this.rootVirtualListEl.getItem(index); + if (!row) { + return; + } + let subList = this.rootVirtualListEl.getSubListForItem(index); + if (!subList) { + return; + } + this.activeIndex = index; + + // In Bug 1866845, these manual updates to the sublists should be removed + // and scrollIntoView() should also be iterated on so that we aren't constantly + // moving the focused item to the center of the viewport + for (const sublist of Array.from(this.rootVirtualListEl.children)) { + await sublist.requestUpdate(); + await sublist.updateComplete; + } + row.scrollIntoView({ block: "center" }); + row.focus(); + } else if (index >= 0 && index < this.rowEls?.length) { + this.rowEls[index].focus(); + this.activeIndex = index; + } + } + + shouldUpdate(changes) { + if (changes.has("updatesPaused")) { + if (this.updatesPaused) { + this.clearIntervalTimer(); + } + } + return !this.updatesPaused; + } + + itemTemplate = (tabItem, i) => { + let time; + if (tabItem.time || tabItem.closedAt) { + let stringTime = (tabItem.time || tabItem.closedAt).toString(); + // Different APIs return time in different units, so we use + // the length to decide if it's milliseconds or nanoseconds. + if (stringTime.length === 16) { + time = (tabItem.time || tabItem.closedAt) / 1000; + } else { + time = tabItem.time || tabItem.closedAt; + } + } + return html` + + `; + }; + + render() { + if (this.searchQuery && this.tabItems.length === 0) { + return this.#emptySearchResultsTemplate(); + } + return html` + +
    + ${when( + lazy.virtualListEnabledPref, + () => html` + + ` + )} + ${when( + !lazy.virtualListEnabledPref, + () => html` + ${this.tabItems.map((tabItem, i) => this.itemTemplate(tabItem, i))} + ` + )} +
    + + `; + } + + #emptySearchResultsTemplate() { + return html` + `; + } +} +customElements.define("fxview-tab-list", FxviewTabList); + +/** + * A tab item that displays favicon, title, url, and time of last access + * + * @property {boolean} active - Should current item have focus on keydown + * @property {boolean} compact - Whether to hide the URL and date/time for this tab. + * @property {object} containerObj - Info about an open tab's container if within one + * @property {string} currentActiveElementId - ID of currently focused element within each tab item + * @property {string} dateTimeFormat - Expected format for date and/or time + * @property {string} hasPopup - The aria-haspopup attribute for the secondary action, if required + * @property {boolean} isBookmark - Whether an open tab is bookmarked + * @property {number} closedId - The tab ID for when the tab item was closed. + * @property {number} sourceClosedId - The closedId of the closed window its from if applicable + * @property {number} sourceWindowId - The sessionstore id of the window its from if applicable + * @property {string} favicon - The favicon for the tab item. + * @property {boolean} muted - Whether an open tab is muted + * @property {boolean} pinned - Whether an open tab is pinned + * @property {string} primaryL10nId - The l10n id used for the primary action element + * @property {string} primaryL10nArgs - The l10n args used for the primary action element + * @property {string} secondaryL10nId - The l10n id used for the secondary action button + * @property {string} secondaryL10nArgs - The l10n args used for the secondary action element + * @property {boolean} attention - Whether to show a notification dot + * @property {boolean} soundPlaying - Whether an open tab has soundPlaying + * @property {object} tabElement - The MozTabbrowserTab element for the tab item. + * @property {number} time - The timestamp for when the tab was last accessed. + * @property {string} title - The title for the tab item. + * @property {boolean} titleChanged - Whether the title has changed for an open tab + * @property {string} url - The url for the tab item. + * @property {number} timeMsPref - The frequency in milliseconds of updates to relative time + * @property {string} searchQuery - The query string to highlight, if provided. + */ +export class FxviewTabRow extends MozLitElement { + constructor() { + super(); + this.active = false; + this.currentActiveElementId = "fxview-tab-row-main"; + } + + static properties = { + active: { type: Boolean }, + compact: { type: Boolean }, + containerObj: { type: Object }, + currentActiveElementId: { type: String }, + dateTimeFormat: { type: String }, + favicon: { type: String }, + hasPopup: { type: String }, + isBookmark: { type: Boolean }, + muted: { type: Boolean }, + pinned: { type: Boolean }, + primaryL10nId: { type: String }, + primaryL10nArgs: { type: String }, + secondaryL10nId: { type: String }, + secondaryL10nArgs: { type: String }, + soundPlaying: { type: Boolean }, + closedId: { type: Number }, + sourceClosedId: { type: Number }, + sourceWindowId: { type: String }, + tabElement: { type: Object }, + time: { type: Number }, + title: { type: String }, + titleChanged: { type: Boolean }, + attention: { type: Boolean }, + timeMsPref: { type: Number }, + url: { type: String }, + searchQuery: { type: String }, + }; + + static queries = { + mainEl: ".fxview-tab-row-main", + buttonEl: "#fxview-tab-row-secondary-button:not([hidden])", + mediaButtonEl: "#fxview-tab-row-media-button", + }; + + get currentFocusable() { + let focusItem = this.renderRoot.getElementById(this.currentActiveElementId); + if (!focusItem) { + focusItem = this.renderRoot.getElementById("fxview-tab-row-main"); + } + return focusItem; + } + + focus() { + this.currentFocusable.focus(); + } + + focusButton() { + this.buttonEl.focus(); + return this.buttonEl.id; + } + + focusMediaButton() { + this.mediaButtonEl.focus(); + return this.mediaButtonEl.id; + } + + focusLink() { + this.mainEl.focus(); + return this.mainEl.id; + } + + dateFluentArgs(timestamp, dateTimeFormat) { + if (dateTimeFormat === "date" || dateTimeFormat === "dateTime") { + return JSON.stringify({ date: timestamp }); + } + return null; + } + + dateFluentId(timestamp, dateTimeFormat, _nowThresholdMs = NOW_THRESHOLD_MS) { + if (!timestamp) { + return null; + } + if (dateTimeFormat === "relative") { + const elapsed = Date.now() - timestamp; + if (elapsed <= _nowThresholdMs || !lazy.relativeTimeFormat) { + // Use a different string for very recent timestamps + return "fxviewtabrow-just-now-timestamp"; + } + return null; + } else if (dateTimeFormat === "date" || dateTimeFormat === "dateTime") { + return "fxviewtabrow-date"; + } + return null; + } + + relativeTime(timestamp, dateTimeFormat, _nowThresholdMs = NOW_THRESHOLD_MS) { + if (dateTimeFormat === "relative") { + const elapsed = Date.now() - timestamp; + if (elapsed > _nowThresholdMs && lazy.relativeTimeFormat) { + return lazy.relativeTimeFormat.formatBestUnit(new Date(timestamp)); + } + } + return null; + } + + timeFluentId(dateTimeFormat) { + if (dateTimeFormat === "time" || dateTimeFormat === "dateTime") { + return "fxviewtabrow-time"; + } + return null; + } + + formatURIForDisplay(uriString) { + return !window.IS_STORYBOOK + ? lazy.BrowserUtils.formatURIStringForDisplay(uriString) + : uriString; + } + + 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; + } + + getContainerClasses() { + let containerClasses = ["fxview-tab-row-container-indicator", "icon"]; + if (this.containerObj) { + let { icon, color } = this.containerObj; + containerClasses.push(`identity-icon-${icon}`); + containerClasses.push(`identity-color-${color}`); + } + return containerClasses; + } + + primaryActionHandler(event) { + if ( + (event.type == "click" && !event.altKey) || + (event.type == "keydown" && event.code == "Enter") || + (event.type == "keydown" && event.code == "Space") + ) { + event.preventDefault(); + if (!window.IS_STORYBOOK) { + this.dispatchEvent( + new CustomEvent("fxview-tab-list-primary-action", { + bubbles: true, + composed: true, + detail: { originalEvent: event, item: this }, + }) + ); + } + } + } + + secondaryActionHandler(event) { + if ( + (event.type == "click" && event.detail && !event.altKey) || + // detail=0 is from keyboard + (event.type == "click" && !event.detail) + ) { + event.preventDefault(); + this.dispatchEvent( + new CustomEvent("fxview-tab-list-secondary-action", { + bubbles: true, + composed: true, + detail: { originalEvent: event, item: this }, + }) + ); + } + } + + muteOrUnmuteTab() { + this.tabElement.toggleMuteAudio(); + this.muted = !this.muted; + } + + render() { + const title = this.title; + const relativeString = this.relativeTime( + this.time, + this.dateTimeFormat, + !window.IS_STORYBOOK ? this.timeMsPref : NOW_THRESHOLD_MS + ); + const dateString = this.dateFluentId( + this.time, + this.dateTimeFormat, + !window.IS_STORYBOOK ? this.timeMsPref : NOW_THRESHOLD_MS + ); + const dateArgs = this.dateFluentArgs(this.time, this.dateTimeFormat); + const timeString = this.timeFluentId(this.dateTimeFormat); + const time = this.time; + const timeArgs = JSON.stringify({ time }); + return html` + ${when( + this.containerObj, + () => html` + + ` + )} + + +
    + + + + + ${when( + this.searchQuery, + () => this.#highlightSearchMatches(this.searchQuery, title), + () => title + )} + + + + ${when( + this.searchQuery, + () => + this.#highlightSearchMatches( + this.searchQuery, + this.formatURIForDisplay(this.url) + ), + () => this.formatURIForDisplay(this.url) + )} + + + + ${relativeString} + + + + + ${when( + (this.soundPlaying || this.muted) && !this.pinned, + () => html``, + () => html`` + )} + ${when( + this.secondaryL10nId && this.secondaryActionHandler, + () => html`` + )} + `; + } + + /** + * Find all matches of query within the given string, and compute the result + * to be rendered. + * + * @param {string} query + * @param {string} string + */ + #highlightSearchMatches(query, string) { + const fragments = []; + const regex = RegExp(escapeRegExp(query), "dgi"); + let prevIndexEnd = 0; + let result; + while ((result = regex.exec(string)) !== null) { + const [indexStart, indexEnd] = result.indices[0]; + fragments.push(string.substring(prevIndexEnd, indexStart)); + fragments.push( + html`${string.substring(indexStart, indexEnd)}` + ); + prevIndexEnd = regex.lastIndex; + } + fragments.push(string.substring(prevIndexEnd)); + return fragments; + } +} + +customElements.define("fxview-tab-row", FxviewTabRow); + +export class VirtualList extends MozLitElement { + static properties = { + items: { type: Array }, + template: { type: Function }, + activeIndex: { type: Number }, + itemOffset: { type: Number }, + maxRenderCountEstimate: { type: Number, state: true }, + itemHeightEstimate: { type: Number, state: true }, + isAlwaysVisible: { type: Boolean }, + isVisible: { type: Boolean, state: true }, + isSubList: { type: Boolean }, + }; + + createRenderRoot() { + return this; + } + + constructor() { + super(); + this.activeIndex = 0; + this.itemOffset = 0; + this.items = []; + this.subListItems = []; + this.itemHeightEstimate = FXVIEW_ROW_HEIGHT_PX; + this.maxRenderCountEstimate = Math.max( + 40, + 2 * Math.ceil(window.innerHeight / this.itemHeightEstimate) + ); + this.isSubList = false; + this.isVisible = false; + this.intersectionObserver = new IntersectionObserver( + ([entry]) => (this.isVisible = entry.isIntersecting), + { root: this.ownerDocument } + ); + this.resizeObserver = new ResizeObserver(([entry]) => { + if (entry.contentRect?.height > 0) { + // Update properties on top-level virtual-list + this.parentElement.itemHeightEstimate = entry.contentRect.height; + this.parentElement.maxRenderCountEstimate = Math.max( + 40, + 2 * Math.ceil(window.innerHeight / this.itemHeightEstimate) + ); + } + }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.intersectionObserver.disconnect(); + this.resizeObserver.disconnect(); + } + + triggerIntersectionObserver() { + this.intersectionObserver.unobserve(this); + this.intersectionObserver.observe(this); + } + + getSubListForItem(index) { + if (this.isSubList) { + throw new Error("Cannot get sublist for item"); + } + return this.children[parseInt(index / this.maxRenderCountEstimate, 10)]; + } + + getItem(index) { + if (!this.isSubList) { + return this.getSubListForItem(index)?.getItem( + index % this.maxRenderCountEstimate + ); + } + return this.children[index]; + } + + willUpdate(changedProperties) { + if (changedProperties.has("items") && !this.isSubList) { + this.subListItems = []; + for (let i = 0; i < this.items.length; i += this.maxRenderCountEstimate) { + this.subListItems.push( + this.items.slice(i, i + this.maxRenderCountEstimate) + ); + } + this.triggerIntersectionObserver(); + } + } + + recalculateAfterWindowResize() { + this.maxRenderCountEstimate = Math.max( + 40, + 2 * Math.ceil(window.innerHeight / this.itemHeightEstimate) + ); + } + + firstUpdated() { + this.intersectionObserver.observe(this); + if (this.isSubList && this.children[0]) { + this.resizeObserver.observe(this.children[0]); + } + } + + updated(changedProperties) { + this.updateListHeight(changedProperties); + } + + updateListHeight(changedProperties) { + if ( + changedProperties.has("isAlwaysVisible") || + changedProperties.has("isVisible") + ) { + this.style.height = + this.isAlwaysVisible || this.isVisible + ? "auto" + : `${this.items.length * this.itemHeightEstimate}px`; + } + } + + get renderItems() { + return this.isSubList ? this.items : this.subListItems; + } + + subListTemplate = (data, i) => { + return html``; + }; + + itemTemplate = (data, i) => this.template(data, this.itemOffset + i); + + render() { + if (this.isAlwaysVisible || this.isVisible) { + return html` + ${repeat( + this.renderItems, + (data, i) => i, + this.isSubList ? this.itemTemplate : this.subListTemplate + )} + `; + } + return ""; + } +} + +customElements.define("virtual-list", VirtualList); diff --git a/browser/components/firefoxview/fxview-tab-row.css b/browser/components/firefoxview/fxview-tab-row.css new file mode 100644 index 0000000000..ceb059a33b --- /dev/null +++ b/browser/components/firefoxview/fxview-tab-row.css @@ -0,0 +1,204 @@ +/* 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 { + --fxviewtabrow-element-background-hover: color-mix(in srgb, currentColor 14%, transparent); + --fxviewtabrow-element-background-active: color-mix(in srgb, currentColor 21%, transparent); + display: grid; + grid-template-columns: subgrid; + grid-column: span 9; + align-items: stretch; + border-radius: 4px; +} + +@media (prefers-contrast) { + :host { + --fxviewtabrow-element-background-hover: ButtonText; + --fxviewtabrow-element-background-active: ButtonText; + --fxviewtabrow-text-color-hover: ButtonFace; + } +} + +.fxview-tab-row-main { + display: grid; + grid-template-columns: subgrid; + grid-column: span 6; + gap: 16px; + border-radius: 4px; + align-items: center; + padding: 4px 8px; + user-select: none; + cursor: pointer; + text-decoration: none; +} + +.fxview-tab-row-main, +.fxview-tab-row-main:visited, +.fxview-tab-row-main:hover:active, +.fxview-tab-row-button { + color: inherit; +} + +.fxview-tab-row-main:hover, +.fxview-tab-row-button.ghost-button.icon-button:enabled:hover { + background-color: var(--fxviewtabrow-element-background-hover); + color: var(--fxviewtabrow-text-color-hover); +} + +.fxview-tab-row-main:hover:active, +.fxview-tab-row-button.ghost-button.icon-button:enabled:hover:active { + background-color: var(--fxviewtabrow-element-background-active); +} + +@media (prefers-contrast) { + .fxview-tab-row-main, + .fxview-tab-row-main:hover, + .fxview-tab-row-main:active { + background-color: transparent; + border: 1px solid LinkText; + color: LinkText; + } + + .fxview-tab-row-main:visited .fxview-tab-row-main:visited:hover { + border: 1px solid VisitedText; + color: VisitedText; + } +} + +.fxview-tab-row-favicon-wrapper { + height: 16px; + + .fxview-tab-row-favicon::after { + display: block; + content: ""; + background-size: 12px; + background-position: center; + background-repeat: no-repeat; + position: absolute; + height: 12px; + width: 12px; + -moz-context-properties: fill, stroke; + fill: currentColor; + stroke: var(--fxview-background-color-secondary); + } + + &.bookmark .fxview-tab-row-favicon::after { + background-image: url("chrome://browser/skin/bookmark-12.svg"); + inset-block-start: 9px; + inset-inline-end: -6px; + fill: var(--fxview-primary-action-background); + } + + &.notification .fxview-tab-row-favicon::after { + background-image: radial-gradient(circle, light-dark(rgb(42, 195, 162), rgb(84, 255, 189)), light-dark(rgb(42, 195, 162), rgb(84, 255, 189)) 2px, transparent 2px); + height: 4px; + width: 100%; + inset-block-start: 20px; + } + + &.soundplaying .fxview-tab-row-favicon::after { + background-image: url("chrome://global/skin/media/audio.svg"); + inset-block-start: -5px; + inset-inline-end: -7px; + border-radius: 100%; + background-color: var(--fxview-background-color-secondary); + padding: 2px; + } + + &.muted .fxview-tab-row-favicon::after { + background-image: url("chrome://global/skin/media/audio-muted.svg"); + inset-block-start: -5px; + inset-inline-end: -7px; + border-radius: 100%; + background-color: var(--fxview-background-color-secondary); + padding: 2px; + } +} + +.fxview-tab-row-favicon { + background-size: cover; + -moz-context-properties: fill; + fill: currentColor; + display: inline-block; + min-height: 16px; + min-width: 16px; + position: relative; +} + +.fxview-tab-row-title { + text-align: match-parent; +} + +.fxview-tab-row-container-indicator { + height: 16px; + width: 16px; + background-image: var(--identity-icon); + background-size: cover; + -moz-context-properties: fill; + fill: var(--identity-icon-color); +} + +.fxview-tab-row-url { + color: var(--text-color-deemphasized); + text-decoration-line: underline; + direction: ltr; + text-align: match-parent; +} + +.fxview-tab-row-date, +.fxview-tab-row-time { + color: var(--text-color-deemphasized); + white-space: nowrap; +} + +.fxview-tab-row-url, +.fxview-tab-row-time { + font-weight: 400; +} + +.fxview-tab-row-button { + margin: 0; + cursor: pointer; + min-width: 0; + background-color: transparent; + + &[muted="true"], + &[soundplaying="true"] { + background-size: 16px; + background-repeat: no-repeat; + background-position: center; + -moz-context-properties: fill; + fill: currentColor; + } + + &[muted="true"] { + background-image: url("chrome://global/skin/media/audio-muted.svg"); + } + + &[soundplaying="true"] { + background-image: url("chrome://global/skin/media/audio.svg"); + } +} + +@media (prefers-contrast) { + .fxview-tab-row-button { + border: 1px solid ButtonText; + color: ButtonText; + } + + .fxview-tab-row-button.ghost-button.icon-button:enabled:hover { + border: 1px solid SelectedItem; + color: SelectedItem; + } + + .fxview-tab-row-button.ghost-button.icon-button:enabled:active { + color: SelectedItem; + } + + .fxview-tab-row-button.ghost-button.icon-button:enabled, + .fxview-tab-row-button.ghost-button.icon-button:enabled:hover, + .fxview-tab-row-button.ghost-button.icon-button:enabled:active { + background-color: ButtonFace; + } +} diff --git a/browser/components/firefoxview/helpers.mjs b/browser/components/firefoxview/helpers.mjs new file mode 100644 index 0000000000..3cb308a587 --- /dev/null +++ b/browser/components/firefoxview/helpers.mjs @@ -0,0 +1,175 @@ +/* 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 = {}; +const loggersByName = new Map(); + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", + Log: "resource://gre/modules/Log.sys.mjs", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "relativeTimeFormat", () => { + return new Services.intl.RelativeTimeFormat(undefined, { style: "narrow" }); +}); + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "searchEnabledPref", + "browser.firefox-view.search.enabled" +); + +// Cutoff of 1.5 minutes + 1 second to determine what text string to display +export const NOW_THRESHOLD_MS = 91000; + +// Configure logging level via this pref +export const LOGGING_PREF = "browser.tabs.firefox-view.logLevel"; + +export const MAX_TABS_FOR_RECENT_BROWSING = 5; + +export function formatURIForDisplay(uriString) { + return lazy.BrowserUtils.formatURIStringForDisplay(uriString); +} + +export function convertTimestamp( + timestamp, + fluentStrings, + _nowThresholdMs = NOW_THRESHOLD_MS +) { + if (!timestamp) { + // It's marginally better to show nothing instead of "53 years ago" + return ""; + } + const elapsed = Date.now() - timestamp; + let formattedTime; + if (elapsed <= _nowThresholdMs) { + // Use a different string for very recent timestamps + formattedTime = fluentStrings.formatValueSync( + "firefoxview-just-now-timestamp" + ); + } else { + formattedTime = lazy.relativeTimeFormat.formatBestUnit(new Date(timestamp)); + } + return formattedTime; +} + +export function createFaviconElement(image, targetURI = "") { + let favicon = document.createElement("div"); + favicon.style.backgroundImage = `url('${getImageUrl(image, targetURI)}')`; + favicon.classList.add("favicon"); + return favicon; +} + +export function getImageUrl(icon, targetURI) { + return icon ? lazy.PlacesUIUtils.getImageURL(icon) : `page-icon:${targetURI}`; +} + +/** + * This function doesn't just copy the link to the clipboard, it creates a + * URL object on the clipboard, so when it's pasted into an application that + * supports it, it displays the title as a link. + */ +export function placeLinkOnClipboard(title, uri) { + let node = { + type: 0, + title, + uri, + }; + + // Copied from doCommand/placesCmd_copy in PlacesUIUtils.sys.mjs + + // This is a little hacky, but there is a lot of code in Places that handles + // clipboard stuff, so it's easier to reuse. + + // This order is _important_! It controls how this and other applications + // select data to be inserted based on type. + let contents = [ + { type: lazy.PlacesUtils.TYPE_X_MOZ_URL, entries: [] }, + { type: lazy.PlacesUtils.TYPE_HTML, entries: [] }, + { type: lazy.PlacesUtils.TYPE_PLAINTEXT, entries: [] }, + ]; + + contents.forEach(function (content) { + content.entries.push(lazy.PlacesUtils.wrapNode(node, content.type)); + }); + + let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + xferable.init(null); + + function addData(type, data) { + xferable.addDataFlavor(type); + xferable.setTransferData(type, lazy.PlacesUtils.toISupportsString(data)); + } + + contents.forEach(function (content) { + addData(content.type, content.entries.join(lazy.PlacesUtils.endl)); + }); + + Services.clipboard.setData(xferable, null, Ci.nsIClipboard.kGlobalClipboard); +} + +/** + * Check the user preference to enable search functionality in Firefox View. + * + * @returns {boolean} The preference value. + */ +export function isSearchEnabled() { + return lazy.searchEnabledPref; +} + +/** + * 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 + components or features. + */ + +export function getLogger(loggerName) { + if (!loggersByName.has(loggerName)) { + let logger = lazy.Log.repository.getLogger(`FirefoxView.${loggerName}`); + logger.manageLevelFromPref(LOGGING_PREF); + logger.addAppender( + new lazy.Log.ConsoleAppender(new lazy.Log.BasicFormatter()) + ); + loggersByName.set(loggerName, logger); + } + return loggersByName.get(loggerName); +} + +export function escapeHtmlEntities(text) { + return (text || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/browser/components/firefoxview/history.css b/browser/components/firefoxview/history.css new file mode 100644 index 0000000000..dd2786a8c7 --- /dev/null +++ b/browser/components/firefoxview/history.css @@ -0,0 +1,80 @@ +/* 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/. */ + +.history-sort-options:not([hidden]) { + display: flex; + gap: 24px; +} + +.history-sort-option { + display: flex; + align-items: center; + gap: 8px; + + & label { + white-space: nowrap; + } +} + +.show-all-history-footer { + text-align: center; + margin-block-end: 24px; +} + +.import-history-banner .banner-text { + display: flex; + flex-direction: column; + font-size: 0.95rem; + gap: 6px; +} + +.import-history-banner .banner-text span:first-child { + font-weight: 600; +} + +.import-history-banner [slot="main"] { + display: grid; + grid-template-columns: 1fr auto; + gap: 16px; + padding: 8px; +} + +.import-history-banner .buttons { + display: flex; + align-items: center; + gap: 16px; +} + +.choose-browser { + font-size: 0.87em; + cursor: pointer; +} + +.import-history-banner .close { + background-image: url("chrome://global/skin/icons/close-12.svg"); + background-repeat: no-repeat; + background-position: center center; + -moz-context-properties: fill; + fill: currentColor; + min-width: auto; + min-height: auto; + width: 24px; + height: 24px; + margin: 0; + padding: 0; + flex-shrink: 0; +} + +dialog { + border: 1px solid transparent; + border-radius: 8px; + box-shadow: 0 2px 6px 0 rgba(0,0,0,0.3); + padding: 24px; +} + +@media (prefers-color-scheme: dark) { + dialog { + --in-content-page-background: #42414d; + } +} diff --git a/browser/components/firefoxview/history.mjs b/browser/components/firefoxview/history.mjs new file mode 100644 index 0000000000..935cc037e9 --- /dev/null +++ b/browser/components/firefoxview/history.mjs @@ -0,0 +1,656 @@ +/* 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, + when, +} from "chrome://global/content/vendor/lit.all.mjs"; +import { escapeHtmlEntities, isSearchEnabled } from "./helpers.mjs"; +import { ViewPage } from "./viewpage.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://browser/content/migration/migration-wizard.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", + FirefoxViewPlacesQuery: + "resource:///modules/firefox-view-places-query.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", +}); + +let XPCOMUtils = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +).XPCOMUtils; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "maxRowsPref", + "browser.firefox-view.max-history-rows", + -1 +); + +const NEVER_REMEMBER_HISTORY_PREF = "browser.privatebrowsing.autostart"; +const HAS_IMPORTED_HISTORY_PREF = "browser.migrate.interactions.history"; +const IMPORT_HISTORY_DISMISSED_PREF = + "browser.tabs.firefox-view.importHistory.dismissed"; + +const SEARCH_RESULTS_LIMIT = 300; + +class HistoryInView extends ViewPage { + constructor() { + super(); + this._started = false; + this.allHistoryItems = new Map(); + this.historyMapByDate = []; + this.historyMapBySite = []; + // Setting maxTabsLength to -1 for no max + this.maxTabsLength = -1; + this.placesQuery = new lazy.FirefoxViewPlacesQuery(); + this.searchQuery = ""; + this.searchResults = null; + this.sortOption = "date"; + this.profileAge = 8; + this.fullyUpdated = false; + this.cumulativeSearches = 0; + } + + start() { + if (this._started) { + return; + } + this._started = true; + + this.#updateAllHistoryItems(); + this.placesQuery.observeHistory(data => this.#updateAllHistoryItems(data)); + + this.toggleVisibilityInCardContainer(); + } + + async connectedCallback() { + super.connectedCallback(); + await this.updateHistoryData(); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "importHistoryDismissedPref", + IMPORT_HISTORY_DISMISSED_PREF, + false, + () => { + this.requestUpdate(); + } + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "hasImportedHistoryPref", + HAS_IMPORTED_HISTORY_PREF, + false, + () => { + this.requestUpdate(); + } + ); + if (!this.importHistoryDismissedPref && !this.hasImportedHistoryPrefs) { + let profileAccessor = await lazy.ProfileAge(); + let profileCreateTime = await profileAccessor.created; + let timeNow = new Date().getTime(); + let profileAge = timeNow - profileCreateTime; + // Convert milliseconds to days + this.profileAge = profileAge / 1000 / 60 / 60 / 24; + } + } + + stop() { + if (!this._started) { + return; + } + this._started = false; + this.placesQuery.close(); + + this.toggleVisibilityInCardContainer(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.stop(); + this.migrationWizardDialog?.removeEventListener( + "MigrationWizard:Close", + this.migrationWizardDialog + ); + } + + async #updateAllHistoryItems(allHistoryItems) { + if (allHistoryItems) { + this.allHistoryItems = allHistoryItems; + } else { + await this.updateHistoryData(); + } + this.resetHistoryMaps(); + this.lists.forEach(list => list.requestUpdate()); + await this.#updateSearchResults(); + } + + async #updateSearchResults() { + if (this.searchQuery) { + try { + this.searchResults = await this.placesQuery.searchHistory( + this.searchQuery, + SEARCH_RESULTS_LIMIT + ); + } catch (e) { + // Connection interrupted, ignore. + } + } else { + this.searchResults = null; + } + } + + viewVisibleCallback() { + this.start(); + } + + viewHiddenCallback() { + this.stop(); + } + + static queries = { + cards: { all: "card-container:not([hidden])" }, + migrationWizardDialog: "#migrationWizardDialog", + emptyState: "fxview-empty-state", + lists: { all: "fxview-tab-list" }, + showAllHistoryBtn: ".show-all-history-button", + searchTextbox: "fxview-search-textbox", + sortInputs: { all: "input[name=history-sort-option]" }, + panelList: "panel-list", + }; + + static properties = { + ...ViewPage.properties, + allHistoryItems: { type: Map }, + historyMapByDate: { type: Array }, + historyMapBySite: { type: Array }, + // Making profileAge a reactive property for testing + profileAge: { type: Number }, + searchResults: { type: Array }, + sortOption: { type: String }, + }; + + async getUpdateComplete() { + await super.getUpdateComplete(); + await Promise.all(Array.from(this.cards).map(card => card.updateComplete)); + } + + async updateHistoryData() { + this.allHistoryItems = await this.placesQuery.getHistory({ + daysOld: 60, + limit: lazy.maxRowsPref, + sortBy: this.sortOption, + }); + } + + resetHistoryMaps() { + this.historyMapByDate = []; + this.historyMapBySite = []; + } + + createHistoryMaps() { + if (this.sortOption === "date" && !this.historyMapByDate.length) { + const { + visitsFromToday, + visitsFromYesterday, + visitsByDay, + visitsByMonth, + } = this.placesQuery; + + // Add visits from today and yesterday. + if (visitsFromToday.length) { + this.historyMapByDate.push({ + l10nId: "firefoxview-history-date-today", + items: visitsFromToday, + }); + } + if (visitsFromYesterday.length) { + this.historyMapByDate.push({ + l10nId: "firefoxview-history-date-yesterday", + items: visitsFromYesterday, + }); + } + + // Add visits from this month, grouped by day. + visitsByDay.forEach(visits => { + this.historyMapByDate.push({ + l10nId: "firefoxview-history-date-this-month", + items: visits, + }); + }); + + // Add visits from previous months, grouped by month. + visitsByMonth.forEach(visits => { + this.historyMapByDate.push({ + l10nId: "firefoxview-history-date-prev-month", + items: visits, + }); + }); + } else if (this.sortOption === "site" && !this.historyMapBySite.length) { + this.historyMapBySite = Array.from( + this.allHistoryItems.entries(), + ([domain, items]) => ({ + domain, + items, + l10nId: domain ? null : "firefoxview-history-site-localhost", + }) + ).sort((a, b) => a.domain.localeCompare(b.domain)); + } + } + + onPrimaryAction(e) { + // Record telemetry + Services.telemetry.recordEvent( + "firefoxview_next", + "history", + "visits", + null, + {} + ); + + if (this.searchQuery) { + const searchesHistogram = Services.telemetry.getKeyedHistogramById( + "FIREFOX_VIEW_CUMULATIVE_SEARCHES" + ); + searchesHistogram.add("history", this.cumulativeSearches); + this.cumulativeSearches = 0; + } + + let currentWindow = this.getWindow(); + if (currentWindow.openTrustedLinkIn) { + let where = lazy.BrowserUtils.whereToOpenLink( + e.detail.originalEvent, + false, + true + ); + if (where == "current") { + where = "tab"; + } + currentWindow.openTrustedLinkIn(e.originalTarget.url, where); + } + } + + onSecondaryAction(e) { + this.triggerNode = e.originalTarget; + e.target.querySelector("panel-list").toggle(e.detail.originalEvent); + } + + deleteFromHistory(e) { + lazy.PlacesUtils.history.remove(this.triggerNode.url); + this.recordContextMenuTelemetry("delete-from-history", e); + } + + async onChangeSortOption(e) { + this.sortOption = e.target.value; + Services.telemetry.recordEvent( + "firefoxview_next", + "sort_history", + "tabs", + null, + { + sort_type: this.sortOption, + search_start: this.searchQuery ? "true" : "false", + } + ); + await this.updateHistoryData(); + await this.#updateSearchResults(); + } + + showAllHistory() { + // Record telemetry + Services.telemetry.recordEvent( + "firefoxview_next", + "show_all_history", + "tabs", + null, + {} + ); + + // Open History view in Library window + this.getWindow().PlacesCommandHook.showPlacesOrganizer("History"); + } + + async openMigrationWizard() { + let migrationWizardDialog = this.migrationWizardDialog; + + if (migrationWizardDialog.open) { + return; + } + + await customElements.whenDefined("migration-wizard"); + + // If we've been opened before, remove the old wizard and insert a + // new one to put it back into its starting state. + if (!migrationWizardDialog.firstElementChild) { + let wizard = document.createElement("migration-wizard"); + wizard.toggleAttribute("dialog-mode", true); + migrationWizardDialog.appendChild(wizard); + } + migrationWizardDialog.firstElementChild.requestState(); + + this.migrationWizardDialog.addEventListener( + "MigrationWizard:Close", + function (e) { + e.currentTarget.close(); + } + ); + + migrationWizardDialog.showModal(); + } + + shouldShowImportBanner() { + return ( + this.profileAge < 8 && + !this.hasImportedHistoryPref && + !this.importHistoryDismissedPref + ); + } + + dismissImportHistory() { + Services.prefs.setBoolPref(IMPORT_HISTORY_DISMISSED_PREF, true); + } + + updated() { + this.fullyUpdated = true; + if (this.lists?.length) { + this.toggleVisibilityInCardContainer(); + } + } + + panelListTemplate() { + return html` + + +
    + + +
    + +
    + `; + } + + /** + * The template to use for cards-container. + */ + get cardsTemplate() { + if (this.searchResults) { + return this.#searchResultsTemplate(); + } else if (this.allHistoryItems.size) { + return this.#historyCardsTemplate(); + } + return this.#emptyMessageTemplate(); + } + + #historyCardsTemplate() { + let cardsTemplate = []; + if (this.sortOption === "date" && this.historyMapByDate.length) { + this.historyMapByDate.forEach(historyItem => { + if (historyItem.items.length) { + let dateArg = JSON.stringify({ date: historyItem.items[0].time }); + cardsTemplate.push(html` +

    + + ${this.panelListTemplate()} + +
    `); + } + }); + } else if (this.historyMapBySite.length) { + this.historyMapBySite.forEach(historyItem => { + if (historyItem.items.length) { + cardsTemplate.push(html` +

    + ${historyItem.domain} +

    + + ${this.panelListTemplate()} + +
    `); + } + }); + } + return cardsTemplate; + } + + #emptyMessageTemplate() { + let descriptionHeader; + let descriptionLabels; + let descriptionLink; + if (Services.prefs.getBoolPref(NEVER_REMEMBER_HISTORY_PREF, false)) { + // History pref set to never remember history + descriptionHeader = "firefoxview-dont-remember-history-empty-header"; + descriptionLabels = [ + "firefoxview-dont-remember-history-empty-description", + "firefoxview-dont-remember-history-empty-description-two", + ]; + descriptionLink = { + url: "about:preferences#privacy", + name: "history-settings-url-two", + }; + } else { + descriptionHeader = "firefoxview-history-empty-header"; + descriptionLabels = [ + "firefoxview-history-empty-description", + "firefoxview-history-empty-description-two", + ]; + descriptionLink = { + url: "about:preferences#privacy", + name: "history-settings-url", + }; + } + return html` + + + `; + } + + #searchResultsTemplate() { + return html` +

    + ${when( + this.searchResults.length, + () => + html`

    ` + )} + + ${this.panelListTemplate()} + +
    `; + } + + render() { + if (!this.selectedTab) { + return null; + } + return html` + + + +
    + +
    + ${when( + isSearchEnabled(), + () => html`
    + +
    ` + )} +
    + + +
    +
    + + +
    +
    +
    +
    + +
    + +
    + + +
    +
    +
    + ${this.cardsTemplate} +
    + + `; + } + + async onSearchQuery(e) { + this.searchQuery = e.detail.query; + this.cumulativeSearches = this.searchQuery + ? this.cumulativeSearches + 1 + : 0; + this.#updateSearchResults(); + } + + willUpdate(changedProperties) { + this.fullyUpdated = false; + if (this.allHistoryItems.size && !changedProperties.has("sortOption")) { + // onChangeSortOption() will update history data once it has been fetched + // from the API. + this.createHistoryMaps(); + } + } +} +customElements.define("view-history", HistoryInView); diff --git a/browser/components/firefoxview/jar.mn b/browser/components/firefoxview/jar.mn new file mode 100644 index 0000000000..27eeaaef80 --- /dev/null +++ b/browser/components/firefoxview/jar.mn @@ -0,0 +1,40 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +browser.jar: + content/browser/firefoxview/card-container.css + content/browser/firefoxview/card-container.mjs + content/browser/firefoxview/firefoxview.html + content/browser/firefoxview/firefoxview.mjs + content/browser/firefoxview/history.css + content/browser/firefoxview/history.mjs + content/browser/firefoxview/opentabs.mjs + content/browser/firefoxview/view-opentabs.css + content/browser/firefoxview/syncedtabs.mjs + content/browser/firefoxview/view-syncedtabs.css + content/browser/firefoxview/recentbrowsing.mjs + content/browser/firefoxview/firefoxview.css + content/browser/firefoxview/fxview-category-button.css + content/browser/firefoxview/fxview-category-navigation.css + content/browser/firefoxview/fxview-category-navigation.mjs + content/browser/firefoxview/fxview-empty-state.css + content/browser/firefoxview/fxview-empty-state.mjs + content/browser/firefoxview/helpers.mjs + content/browser/firefoxview/fxview-search-textbox.css + content/browser/firefoxview/fxview-search-textbox.mjs + content/browser/firefoxview/fxview-tab-list.css + content/browser/firefoxview/fxview-tab-list.mjs + content/browser/firefoxview/fxview-tab-row.css + content/browser/firefoxview/recentlyclosed.mjs + content/browser/firefoxview/viewpage.mjs + content/browser/firefoxview/history-empty.svg (content/history-empty.svg) + content/browser/firefoxview/category-history.svg (content/category-history.svg) + content/browser/firefoxview/category-opentabs.svg (content/category-opentabs.svg) + content/browser/firefoxview/category-recentbrowsing.svg (content/category-recentbrowsing.svg) + content/browser/firefoxview/category-recentlyclosed.svg (content/category-recentlyclosed.svg) + content/browser/firefoxview/category-syncedtabs.svg (content/category-syncedtabs.svg) + content/browser/firefoxview/recentlyclosed-empty.svg (content/recentlyclosed-empty.svg) + content/browser/firefoxview/synced-tabs-error.svg (content/synced-tabs-error.svg) + content/browser/callout-tab-pickup.svg (content/callout-tab-pickup.svg) + content/browser/callout-tab-pickup-dark.svg (content/callout-tab-pickup-dark.svg) diff --git a/browser/components/firefoxview/moz.build b/browser/components/firefoxview/moz.build new file mode 100644 index 0000000000..894deceffd --- /dev/null +++ b/browser/components/firefoxview/moz.build @@ -0,0 +1,22 @@ +# -*- 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/. + +JAR_MANIFESTS += ["jar.mn"] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Firefox View") + +EXTRA_JS_MODULES += [ + "*.sys.mjs", +] + +TESTING_JS_MODULES += [ + "tests/browser/FirefoxViewTestUtils.sys.mjs", +] + +BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.toml"] + +MOCHITEST_CHROME_MANIFESTS += ["tests/chrome/chrome.toml"] diff --git a/browser/components/firefoxview/opentabs.mjs b/browser/components/firefoxview/opentabs.mjs new file mode 100644 index 0000000000..6ac63a4b3f --- /dev/null +++ b/browser/components/firefoxview/opentabs.mjs @@ -0,0 +1,834 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + classMap, + html, + map, + when, +} from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; +import { + getLogger, + isSearchEnabled, + placeLinkOnClipboard, + searchTabList, + MAX_TABS_FOR_RECENT_BROWSING, +} from "./helpers.mjs"; +import { ViewPage, ViewPageContent } from "./viewpage.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContextualIdentityService: + "resource://gre/modules/ContextualIdentityService.sys.mjs", + NonPrivateTabs: "resource:///modules/OpenTabs.sys.mjs", + getTabsTargetForWindow: "resource:///modules/OpenTabs.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ).getFxAccountsSingleton(); +}); + +/** + * A collection of open tabs grouped by window. + * + * @property {Array} windows + * A list of windows with the same privateness + * @property {string} sortOption + * The sorting order of open tabs: + * - "recency": Sorted by recent activity. (For recent browsing, this is the only option.) + * - "tabStripOrder": Match the order in which they appear on the tab strip. + */ +class OpenTabsInView extends ViewPage { + static properties = { + ...ViewPage.properties, + windows: { type: Array }, + searchQuery: { type: String }, + sortOption: { type: String }, + }; + static queries = { + viewCards: { all: "view-opentabs-card" }, + optionsContainer: ".open-tabs-options", + searchTextbox: "fxview-search-textbox", + }; + + initialWindowsReady = false; + currentWindow = null; + openTabsTarget = null; + + constructor() { + super(); + this._started = false; + this.windows = []; + this.currentWindow = this.getWindow(); + if (lazy.PrivateBrowsingUtils.isWindowPrivate(this.currentWindow)) { + this.openTabsTarget = lazy.getTabsTargetForWindow(this.currentWindow); + } else { + this.openTabsTarget = lazy.NonPrivateTabs; + } + this.searchQuery = ""; + this.sortOption = this.recentBrowsing + ? "recency" + : Services.prefs.getStringPref( + "browser.tabs.firefox-view.ui-state.opentabs.sort-option", + "recency" + ); + } + + start() { + if (this._started) { + return; + } + this._started = true; + this.#setupTabChangeListener(); + + // To resolve the race between this component wanting to render all the windows' + // tabs, while those windows are still potentially opening, flip this property + // once the promise resolves and we'll bail out of rendering until then. + this.openTabsTarget.readyWindowsPromise.finally(() => { + this.initialWindowsReady = true; + this._updateWindowList(); + }); + + for (let card of this.viewCards) { + card.paused = false; + card.viewVisibleCallback?.(); + } + + if (this.recentBrowsing) { + this.recentBrowsingElement.addEventListener( + "fxview-search-textbox-query", + this + ); + } + } + + shouldUpdate(changedProperties) { + if (!this.initialWindowsReady) { + return false; + } + return super.shouldUpdate(changedProperties); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.stop(); + } + + stop() { + if (!this._started) { + return; + } + this._started = false; + this.paused = true; + + this.openTabsTarget.removeEventListener("TabChange", this); + this.openTabsTarget.removeEventListener("TabRecencyChange", this); + + for (let card of this.viewCards) { + card.paused = true; + card.viewHiddenCallback?.(); + } + + if (this.recentBrowsing) { + this.recentBrowsingElement.removeEventListener( + "fxview-search-textbox-query", + this + ); + } + } + + viewVisibleCallback() { + this.start(); + } + + viewHiddenCallback() { + this.stop(); + } + + #setupTabChangeListener() { + if (this.sortOption === "recency") { + this.openTabsTarget.addEventListener("TabRecencyChange", this); + this.openTabsTarget.removeEventListener("TabChange", this); + } else { + this.openTabsTarget.removeEventListener("TabRecencyChange", this); + this.openTabsTarget.addEventListener("TabChange", this); + } + } + + render() { + if (this.recentBrowsing) { + return this.getRecentBrowsingTemplate(); + } + let currentWindowIndex, currentWindowTabs; + let index = 1; + const otherWindows = []; + this.windows.forEach(win => { + const tabs = this.openTabsTarget.getTabsForWindow( + win, + this.sortOption === "recency" + ); + if (win === this.currentWindow) { + currentWindowIndex = index++; + currentWindowTabs = tabs; + } else { + otherWindows.push([index++, tabs, win]); + } + }); + + const cardClasses = classMap({ + "height-limited": this.windows.length > 3, + "width-limited": this.windows.length > 1, + }); + let cardCount; + if (this.windows.length <= 1) { + cardCount = "one"; + } else if (this.windows.length === 2) { + cardCount = "two"; + } else { + cardCount = "three-or-more"; + } + return html` + + +
    + +
    + ${when( + isSearchEnabled(), + () => html`
    + +
    ` + )} +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    + ${when( + currentWindowIndex && currentWindowTabs, + () => + html` + + ` + )} + ${map( + otherWindows, + ([winID, tabs, win]) => html` + + ` + )} +
    + `; + } + + onSearchQuery(e) { + this.searchQuery = e.detail.query; + } + + onChangeSortOption(e) { + this.sortOption = e.target.value; + this.#setupTabChangeListener(); + if (!this.recentBrowsing) { + Services.prefs.setStringPref( + "browser.tabs.firefox-view.ui-state.opentabs.sort-option", + this.sortOption + ); + } + } + + /** + * Render a template for the 'Recent browsing' page, which shows a shorter list of + * open tabs in the current window. + * + * @returns {TemplateResult} + * The recent browsing template. + */ + getRecentBrowsingTemplate() { + const tabs = this.openTabsTarget.getRecentTabs(); + return html``; + } + + handleEvent({ detail, target, type }) { + if (this.recentBrowsing && type === "fxview-search-textbox-query") { + this.onSearchQuery({ detail }); + return; + } + let windowIds; + switch (type) { + case "TabRecencyChange": + case "TabChange": + // if we're switching away from our tab, we can halt any updates immediately + if (!this.isSelectedBrowserTab) { + this.stop(); + return; + } + windowIds = detail.windowIds; + this._updateWindowList(); + break; + } + if (this.recentBrowsing) { + return; + } + if (windowIds?.length) { + // there were tab changes to one or more windows + for (let winId of windowIds) { + const cardForWin = this.shadowRoot.querySelector( + `view-opentabs-card[data-inner-id="${winId}"]` + ); + if (this.searchQuery) { + cardForWin?.updateSearchResults(); + } + cardForWin?.requestUpdate(); + } + } else { + let winId = window.windowGlobalChild.innerWindowId; + let cardForWin = this.shadowRoot.querySelector( + `view-opentabs-card[data-inner-id="${winId}"]` + ); + if (this.searchQuery) { + cardForWin?.updateSearchResults(); + } + } + } + + async _updateWindowList() { + this.windows = this.openTabsTarget.currentWindows; + } +} +customElements.define("view-opentabs", OpenTabsInView); + +/** + * A card which displays a list of open tabs for a window. + * + * @property {boolean} showMore + * Whether to force all tabs to be shown, regardless of available space. + * @property {MozTabbrowserTab[]} tabs + * The open tabs to show. + * @property {string} title + * The window title. + */ +class OpenTabsInViewCard extends ViewPageContent { + static properties = { + showMore: { type: Boolean }, + tabs: { type: Array }, + title: { type: String }, + recentBrowsing: { type: Boolean }, + searchQuery: { type: String }, + searchResults: { type: Array }, + showAll: { type: Boolean }, + cumulativeSearches: { type: Number }, + }; + static MAX_TABS_FOR_COMPACT_HEIGHT = 7; + + constructor() { + super(); + this.showMore = false; + this.tabs = []; + this.title = ""; + this.recentBrowsing = false; + this.devices = []; + this.searchQuery = ""; + this.searchResults = null; + this.showAll = false; + this.cumulativeSearches = 0; + } + + static queries = { + cardEl: "card-container", + tabContextMenu: "view-opentabs-contextmenu", + tabList: "fxview-tab-list", + }; + + openContextMenu(e) { + let { originalEvent } = e.detail; + this.tabContextMenu.toggle({ + triggerNode: e.originalTarget, + originalEvent, + }); + } + + getMaxTabsLength() { + if (this.recentBrowsing && !this.showAll) { + return MAX_TABS_FOR_RECENT_BROWSING; + } else if (this.classList.contains("height-limited") && !this.showMore) { + return OpenTabsInViewCard.MAX_TABS_FOR_COMPACT_HEIGHT; + } + return -1; + } + + isShowAllLinkVisible() { + return ( + this.recentBrowsing && + this.searchQuery && + this.searchResults.length > MAX_TABS_FOR_RECENT_BROWSING && + !this.showAll + ); + } + + toggleShowMore(event) { + if ( + event.type == "click" || + (event.type == "keydown" && event.code == "Enter") || + (event.type == "keydown" && event.code == "Space") + ) { + event.preventDefault(); + this.showMore = !this.showMore; + } + } + + enableShowAll(event) { + if ( + event.type == "click" || + (event.type == "keydown" && event.code == "Enter") || + (event.type == "keydown" && event.code == "Space") + ) { + event.preventDefault(); + Services.telemetry.recordEvent( + "firefoxview_next", + "search_show_all", + "showallbutton", + null, + { + section: "opentabs", + } + ); + this.showAll = true; + } + } + + onTabListRowClick(event) { + const tab = event.originalTarget.tabElement; + const browserWindow = tab.ownerGlobal; + browserWindow.focus(); + browserWindow.gBrowser.selectedTab = tab; + + Services.telemetry.recordEvent( + "firefoxview_next", + "open_tab", + "tabs", + null, + { + page: this.recentBrowsing ? "recentbrowsing" : "opentabs", + window: this.title || "Window 1 (Current)", + } + ); + if (this.searchQuery) { + const searchesHistogram = Services.telemetry.getKeyedHistogramById( + "FIREFOX_VIEW_CUMULATIVE_SEARCHES" + ); + searchesHistogram.add( + this.recentBrowsing ? "recentbrowsing" : "opentabs", + this.cumulativeSearches + ); + this.cumulativeSearches = 0; + } + } + + viewVisibleCallback() { + this.getRootNode().host.toggleVisibilityInCardContainer(true); + } + + viewHiddenCallback() { + this.getRootNode().host.toggleVisibilityInCardContainer(true); + } + + firstUpdated() { + this.getRootNode().host.toggleVisibilityInCardContainer(true); + } + + render() { + return html` + + + ${when( + this.recentBrowsing, + () => html`

    `, + () => html`

    ${this.title}

    ` + )} +
    + + +
    + ${when( + this.recentBrowsing, + () => html`
    `, + () => + html`
    ` + )} +
    + `; + } + + willUpdate(changedProperties) { + if (changedProperties.has("searchQuery")) { + this.showAll = false; + this.cumulativeSearches = this.searchQuery + ? this.cumulativeSearches + 1 + : 0; + } + if (changedProperties.has("searchQuery") || changedProperties.has("tabs")) { + this.updateSearchResults(); + } + } + + updateSearchResults() { + this.searchResults = this.searchQuery + ? searchTabList(this.searchQuery, getTabListItems(this.tabs)) + : null; + } +} +customElements.define("view-opentabs-card", OpenTabsInViewCard); + +/** + * A context menu of actions available for open tab list items. + */ +class OpenTabsContextMenu extends MozLitElement { + static properties = { + devices: { type: Array }, + triggerNode: { type: Object }, + }; + + static queries = { + panelList: "panel-list", + }; + + constructor() { + super(); + this.triggerNode = null; + this.devices = []; + } + + get logger() { + return getLogger("OpenTabsContextMenu"); + } + + get ownerViewPage() { + return this.ownerDocument.querySelector("view-opentabs"); + } + + async fetchDevices() { + const currentWindow = this.ownerViewPage.getWindow(); + if (currentWindow?.gSync) { + try { + await lazy.fxAccounts.device.refreshDeviceList(); + } catch (e) { + this.logger.warn("Could not refresh the FxA device list", e); + } + this.devices = currentWindow.gSync.getSendTabTargets(); + } + } + + async toggle({ triggerNode, originalEvent }) { + if (this.panelList?.open) { + // the menu will close so avoid all the other work to update its contents + this.panelList.toggle(originalEvent); + return; + } + this.triggerNode = triggerNode; + await this.fetchDevices(); + await this.getUpdateComplete(); + this.panelList.toggle(originalEvent); + } + + copyLink(e) { + placeLinkOnClipboard(this.triggerNode.title, this.triggerNode.url); + this.ownerViewPage.recordContextMenuTelemetry("copy-link", e); + } + + closeTab(e) { + const tab = this.triggerNode.tabElement; + tab?.ownerGlobal.gBrowser.removeTab(tab); + this.ownerViewPage.recordContextMenuTelemetry("close-tab", e); + } + + moveTabsToStart(e) { + const tab = this.triggerNode.tabElement; + tab?.ownerGlobal.gBrowser.moveTabsToStart(tab); + this.ownerViewPage.recordContextMenuTelemetry("move-tab-start", e); + } + + moveTabsToEnd(e) { + const tab = this.triggerNode.tabElement; + tab?.ownerGlobal.gBrowser.moveTabsToEnd(tab); + this.ownerViewPage.recordContextMenuTelemetry("move-tab-end", e); + } + + moveTabsToWindow(e) { + const tab = this.triggerNode.tabElement; + tab?.ownerGlobal.gBrowser.replaceTabsWithWindow(tab); + this.ownerViewPage.recordContextMenuTelemetry("move-tab-window", e); + } + + moveMenuTemplate() { + const tab = this.triggerNode?.tabElement; + if (!tab) { + return null; + } + const browserWindow = tab.ownerGlobal; + const tabs = browserWindow?.gBrowser.visibleTabs || []; + const position = tabs.indexOf(tab); + + return html` + + ${position > 0 + ? html`` + : null} + ${position < tabs.length - 1 + ? html`` + : null} + + + `; + } + + async sendTabToDevice(e) { + let deviceId = e.target.getAttribute("device-id"); + let device = this.devices.find(dev => dev.id == deviceId); + const viewPage = this.ownerViewPage; + viewPage.recordContextMenuTelemetry("send-tab-device", e); + + if (device && this.triggerNode) { + await viewPage + .getWindow() + .gSync.sendTabToDevice( + this.triggerNode.url, + [device], + this.triggerNode.title + ); + } + } + + sendTabTemplate() { + return html` + ${this.devices.map(device => { + return html` + ${device.name} + `; + })} + `; + } + + render() { + const tab = this.triggerNode?.tabElement; + if (!tab) { + return null; + } + + return html` + + + + ${this.moveMenuTemplate()} +
    + + ${this.devices.length >= 1 + ? html`${this.sendTabTemplate()}` + : null} +
    + `; + } +} +customElements.define("view-opentabs-contextmenu", OpenTabsContextMenu); + +/** + * Checks if a given tab is within a container (contextual identity) + * + * @param {MozTabbrowserTab[]} tab + * Tab to fetch container info on. + * @returns {object[]} + * Container object. + */ +function getContainerObj(tab) { + let userContextId = tab.getAttribute("usercontextid"); + let containerObj = null; + if (userContextId) { + containerObj = + lazy.ContextualIdentityService.getPublicIdentityFromId(userContextId); + } + return containerObj; +} + +/** + * Convert a list of tabs into the format expected by the fxview-tab-list + * component. + * + * @param {MozTabbrowserTab[]} tabs + * Tabs to format. + * @returns {object[]} + * Formatted objects. + */ +function getTabListItems(tabs) { + let filtered = tabs?.filter( + tab => !tab.closing && !tab.hidden && !tab.pinned + ); + + return filtered.map(tab => { + const url = tab.linkedBrowser?.currentURI?.spec || ""; + return { + attention: tab.hasAttribute("attention"), + containerObj: getContainerObj(tab), + icon: tab.getAttribute("image"), + muted: tab.hasAttribute("muted"), + pinned: tab.pinned, + primaryL10nId: "firefoxview-opentabs-tab-row", + primaryL10nArgs: JSON.stringify({ url }), + secondaryL10nId: "fxviewtabrow-options-menu-button", + secondaryL10nArgs: JSON.stringify({ tabTitle: tab.label }), + soundPlaying: tab.hasAttribute("soundplaying"), + tabElement: tab, + time: tab.lastAccessed, + title: tab.label, + titleChanged: tab.hasAttribute("titlechanged"), + url, + }; + }); +} diff --git a/browser/components/firefoxview/recentbrowsing.mjs b/browser/components/firefoxview/recentbrowsing.mjs new file mode 100644 index 0000000000..cd832d2c2f --- /dev/null +++ b/browser/components/firefoxview/recentbrowsing.mjs @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { html, when } from "chrome://global/content/vendor/lit.all.mjs"; +import { ViewPage } from "./viewpage.mjs"; +import { isSearchEnabled } from "./helpers.mjs"; + +class RecentBrowsingInView extends ViewPage { + constructor() { + super(); + this.pageType = "recentbrowsing"; + } + + static queries = { + searchTextbox: "fxview-search-textbox", + }; + + static properties = { + ...ViewPage.properties, + }; + + viewVisibleCallback() { + for (let child of this.children) { + let childView = child.firstElementChild; + childView.paused = false; + childView.viewVisibleCallback(); + } + } + + viewHiddenCallback() { + for (let child of this.children) { + let childView = child.firstElementChild; + childView.paused = true; + childView.viewHiddenCallback(); + } + } + + render() { + return html` + +
    + + ${when( + isSearchEnabled(), + () => html`
    + +
    ` + )} +
    +
    + +
    + `; + } +} +customElements.define("view-recentbrowsing", RecentBrowsingInView); diff --git a/browser/components/firefoxview/recentlyclosed.mjs b/browser/components/firefoxview/recentlyclosed.mjs new file mode 100644 index 0000000000..6e7e06c1f4 --- /dev/null +++ b/browser/components/firefoxview/recentlyclosed.mjs @@ -0,0 +1,473 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + classMap, + html, + ifDefined, + when, +} from "chrome://global/content/vendor/lit.all.mjs"; +import { + isSearchEnabled, + searchTabList, + MAX_TABS_FOR_RECENT_BROWSING, +} from "./helpers.mjs"; +import { ViewPage } from "./viewpage.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://browser/content/firefoxview/card-container.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://browser/content/firefoxview/fxview-tab-list.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +const SS_NOTIFY_CLOSED_OBJECTS_CHANGED = "sessionstore-closed-objects-changed"; +const SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH = "sessionstore-browser-shutdown-flush"; +const NEVER_REMEMBER_HISTORY_PREF = "browser.privatebrowsing.autostart"; +const INCLUDE_CLOSED_TABS_FROM_CLOSED_WINDOWS = + "browser.sessionstore.closedTabsFromClosedWindows"; + +function getWindow() { + return window.browsingContext.embedderWindowGlobal.browsingContext.window; +} + +class RecentlyClosedTabsInView extends ViewPage { + constructor() { + super(); + this._started = false; + this.boundObserve = (...args) => this.observe(...args); + this.firstUpdateComplete = false; + this.fullyUpdated = false; + this.maxTabsLength = this.recentBrowsing + ? MAX_TABS_FOR_RECENT_BROWSING + : -1; + this.recentlyClosedTabs = []; + this.searchQuery = ""; + this.searchResults = null; + this.showAll = false; + this.cumulativeSearches = 0; + } + + static properties = { + ...ViewPage.properties, + searchResults: { type: Array }, + showAll: { type: Boolean }, + cumulativeSearches: { type: Number }, + }; + + static queries = { + cardEl: "card-container", + emptyState: "fxview-empty-state", + searchTextbox: "fxview-search-textbox", + tabList: "fxview-tab-list", + }; + + observe(subject, topic, data) { + if ( + topic == SS_NOTIFY_CLOSED_OBJECTS_CHANGED || + (topic == SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH && + subject.ownerGlobal == getWindow()) + ) { + this.updateRecentlyClosedTabs(); + } + } + + start() { + if (this._started) { + return; + } + this._started = true; + this.paused = false; + this.updateRecentlyClosedTabs(); + + Services.obs.addObserver( + this.boundObserve, + SS_NOTIFY_CLOSED_OBJECTS_CHANGED + ); + Services.obs.addObserver( + this.boundObserve, + SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH + ); + + if (this.recentBrowsing) { + this.recentBrowsingElement.addEventListener( + "fxview-search-textbox-query", + this + ); + } + + this.toggleVisibilityInCardContainer(); + } + + stop() { + if (!this._started) { + return; + } + this._started = false; + + Services.obs.removeObserver( + this.boundObserve, + SS_NOTIFY_CLOSED_OBJECTS_CHANGED + ); + Services.obs.removeObserver( + this.boundObserve, + SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH + ); + + if (this.recentBrowsing) { + this.recentBrowsingElement.removeEventListener( + "fxview-search-textbox-query", + this + ); + } + + this.toggleVisibilityInCardContainer(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.stop(); + } + + handleEvent(event) { + if (this.recentBrowsing && event.type === "fxview-search-textbox-query") { + this.onSearchQuery(event); + } + } + + // We remove all the observers when the instance is not visible to the user + viewHiddenCallback() { + this.stop(); + } + + // We add observers and check for changes to the session store once the user return to this tab. + // or the instance becomes visible to the user + viewVisibleCallback() { + this.start(); + } + + firstUpdated() { + this.firstUpdateComplete = true; + } + + getTabStateValue(tab, key) { + let value = ""; + const tabEntries = tab.state.entries; + const activeIndex = tab.state.index - 1; + + if (activeIndex >= 0 && tabEntries[activeIndex]) { + value = tabEntries[activeIndex][key]; + } + + return value; + } + + updateRecentlyClosedTabs() { + let recentlyClosedTabsData = lazy.SessionStore.getClosedTabData( + getWindow() + ); + if (Services.prefs.getBoolPref(INCLUDE_CLOSED_TABS_FROM_CLOSED_WINDOWS)) { + recentlyClosedTabsData.push( + ...lazy.SessionStore.getClosedTabDataFromClosedWindows() + ); + } + // sort the aggregated list to most-recently-closed first + recentlyClosedTabsData.sort((a, b) => a.closedAt < b.closedAt); + this.recentlyClosedTabs = recentlyClosedTabsData; + this.normalizeRecentlyClosedData(); + if (this.searchQuery) { + this.#updateSearchResults(); + } + this.requestUpdate(); + } + + normalizeRecentlyClosedData() { + // Normalize data for fxview-tabs-list + this.recentlyClosedTabs.forEach(recentlyClosedItem => { + const targetURI = this.getTabStateValue(recentlyClosedItem, "url"); + recentlyClosedItem.time = recentlyClosedItem.closedAt; + recentlyClosedItem.icon = recentlyClosedItem.image; + recentlyClosedItem.primaryL10nId = "fxviewtabrow-tabs-list-tab"; + recentlyClosedItem.primaryL10nArgs = JSON.stringify({ + targetURI: typeof targetURI === "string" ? targetURI : "", + }); + recentlyClosedItem.secondaryL10nId = + "firefoxview-closed-tabs-dismiss-tab"; + recentlyClosedItem.secondaryL10nArgs = JSON.stringify({ + tabTitle: recentlyClosedItem.title, + }); + recentlyClosedItem.url = targetURI; + }); + } + + onReopenTab(e) { + const closedId = parseInt(e.originalTarget.closedId, 10); + const sourceClosedId = parseInt(e.originalTarget.sourceClosedId, 10); + if (isNaN(sourceClosedId)) { + lazy.SessionStore.undoCloseById(closedId, getWindow()); + } else { + lazy.SessionStore.undoClosedTabFromClosedWindow( + { sourceClosedId }, + closedId, + getWindow() + ); + } + + // Record telemetry + let tabClosedAt = parseInt(e.originalTarget.time); + const position = + Array.from(this.tabList.rowEls).indexOf(e.originalTarget) + 1; + + let now = Date.now(); + let deltaSeconds = (now - tabClosedAt) / 1000; + Services.telemetry.recordEvent( + "firefoxview_next", + "recently_closed", + "tabs", + null, + { + position: position.toString(), + delta: deltaSeconds.toString(), + page: this.recentBrowsing ? "recentbrowsing" : "recentlyclosed", + } + ); + if (this.searchQuery) { + const searchesHistogram = Services.telemetry.getKeyedHistogramById( + "FIREFOX_VIEW_CUMULATIVE_SEARCHES" + ); + searchesHistogram.add( + this.recentBrowsing ? "recentbrowsing" : "recentlyclosed", + this.cumulativeSearches + ); + this.cumulativeSearches = 0; + } + } + + onDismissTab(e) { + const closedId = parseInt(e.originalTarget.closedId, 10); + const sourceClosedId = parseInt(e.originalTarget.sourceClosedId, 10); + const sourceWindowId = e.originalTarget.souceWindowId; + if (sourceWindowId || !isNaN(sourceClosedId)) { + lazy.SessionStore.forgetClosedTabById(closedId, { + sourceClosedId, + sourceWindowId, + }); + } else { + lazy.SessionStore.forgetClosedTabById(closedId); + } + + // Record telemetry + let tabClosedAt = parseInt(e.originalTarget.time); + const position = + Array.from(this.tabList.rowEls).indexOf(e.originalTarget) + 1; + + let now = Date.now(); + let deltaSeconds = (now - tabClosedAt) / 1000; + Services.telemetry.recordEvent( + "firefoxview_next", + "dismiss_closed_tab", + "tabs", + null, + { + position: position.toString(), + delta: deltaSeconds.toString(), + page: this.recentBrowsing ? "recentbrowsing" : "recentlyclosed", + } + ); + } + + willUpdate() { + this.fullyUpdated = false; + } + + updated() { + this.fullyUpdated = true; + this.toggleVisibilityInCardContainer(); + } + + async scheduleUpdate() { + // Only defer initial update + if (!this.firstUpdateComplete) { + await new Promise(resolve => setTimeout(resolve)); + } + super.scheduleUpdate(); + } + + emptyMessageTemplate() { + let descriptionHeader; + let descriptionLabels; + let descriptionLink; + if (Services.prefs.getBoolPref(NEVER_REMEMBER_HISTORY_PREF, false)) { + // History pref set to never remember history + descriptionHeader = "firefoxview-dont-remember-history-empty-header"; + descriptionLabels = [ + "firefoxview-dont-remember-history-empty-description", + "firefoxview-dont-remember-history-empty-description-two", + ]; + descriptionLink = { + url: "about:preferences#privacy", + name: "history-settings-url-two", + }; + } else { + descriptionHeader = "firefoxview-recentlyclosed-empty-header"; + descriptionLabels = [ + "firefoxview-recentlyclosed-empty-description", + "firefoxview-recentlyclosed-empty-description-two", + ]; + descriptionLink = { + url: "about:firefoxview#history", + name: "history-url", + sameTarget: "true", + }; + } + return html` + + + `; + } + + render() { + return html` + + ${when( + !this.recentBrowsing, + () => html`
    + + ${when( + isSearchEnabled(), + () => html`
    + +
    ` + )} +
    ` + )} +
    + +

    + ${when( + this.recentlyClosedTabs.length, + () => + html` + + ` + )} + ${when( + this.recentBrowsing && !this.recentlyClosedTabs.length, + () => html`
    ${this.emptyMessageTemplate()}
    ` + )} + ${when( + this.isShowAllLinkVisible(), + () => html`
    ` + )} +
    + ${when( + this.selectedTab && !this.recentlyClosedTabs.length, + () => html`
    ${this.emptyMessageTemplate()}
    ` + )} +
    + `; + } + + onSearchQuery(e) { + this.searchQuery = e.detail.query; + this.showAll = false; + this.cumulativeSearches = this.searchQuery + ? this.cumulativeSearches + 1 + : 0; + this.#updateSearchResults(); + } + + #updateSearchResults() { + this.searchResults = this.searchQuery + ? searchTabList(this.searchQuery, this.recentlyClosedTabs) + : null; + } + + isShowAllLinkVisible() { + return ( + this.recentBrowsing && + this.searchQuery && + this.searchResults.length > MAX_TABS_FOR_RECENT_BROWSING && + !this.showAll + ); + } + + enableShowAll(event) { + if ( + event.type == "click" || + (event.type == "keydown" && event.code == "Enter") || + (event.type == "keydown" && event.code == "Space") + ) { + event.preventDefault(); + this.showAll = true; + Services.telemetry.recordEvent( + "firefoxview_next", + "search_show_all", + "showallbutton", + null, + { + section: "recentlyclosed", + } + ); + } + } +} +customElements.define("view-recentlyclosed", RecentlyClosedTabsInView); diff --git a/browser/components/firefoxview/syncedtabs.mjs b/browser/components/firefoxview/syncedtabs.mjs new file mode 100644 index 0000000000..5320f8cb41 --- /dev/null +++ b/browser/components/firefoxview/syncedtabs.mjs @@ -0,0 +1,725 @@ +/* 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, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", + SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", +}); + +const { SyncedTabsErrorHandler } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-synced-tabs-error-handler.sys.mjs" +); +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +import { + html, + ifDefined, + when, +} from "chrome://global/content/vendor/lit.all.mjs"; +import { ViewPage } from "./viewpage.mjs"; +import { + escapeHtmlEntities, + isSearchEnabled, + searchTabList, + MAX_TABS_FOR_RECENT_BROWSING, +} from "./helpers.mjs"; + +const SYNCED_TABS_CHANGED = "services.sync.tabs.changed"; +const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed"; +const UI_OPEN_STATE = "browser.tabs.firefox-view.ui-state.tab-pickup.open"; + +class SyncedTabsInView extends ViewPage { + constructor() { + super(); + this._started = false; + this.boundObserve = (...args) => this.observe(...args); + this._currentSetupStateIndex = -1; + this.errorState = null; + this._id = Math.floor(Math.random() * 10e6); + this.currentSyncedTabs = []; + if (this.recentBrowsing) { + this.maxTabsLength = MAX_TABS_FOR_RECENT_BROWSING; + } else { + // Setting maxTabsLength to -1 for no max + this.maxTabsLength = -1; + } + this.devices = []; + this.fullyUpdated = false; + this.searchQuery = ""; + this.showAll = false; + this.cumulativeSearches = 0; + } + + static properties = { + ...ViewPage.properties, + errorState: { type: Number }, + currentSyncedTabs: { type: Array }, + _currentSetupStateIndex: { type: Number }, + devices: { type: Array }, + searchQuery: { type: String }, + showAll: { type: Boolean }, + cumulativeSearches: { type: Number }, + }; + + static queries = { + cardEls: { all: "card-container" }, + emptyState: "fxview-empty-state", + searchTextbox: "fxview-search-textbox", + tabLists: { all: "fxview-tab-list" }, + }; + + connectedCallback() { + super.connectedCallback(); + this.addEventListener("click", this); + } + + start() { + if (this._started) { + return; + } + this._started = true; + Services.obs.addObserver(this.boundObserve, TOPIC_SETUPSTATE_CHANGED); + Services.obs.addObserver(this.boundObserve, SYNCED_TABS_CHANGED); + + this.updateStates(); + this.onVisibilityChange(); + + if (this.recentBrowsing) { + this.recentBrowsingElement.addEventListener( + "fxview-search-textbox-query", + this + ); + } + } + + stop() { + if (!this._started) { + return; + } + this._started = false; + TabsSetupFlowManager.updateViewVisibility(this._id, "unloaded"); + this.onVisibilityChange(); + + Services.obs.removeObserver(this.boundObserve, TOPIC_SETUPSTATE_CHANGED); + Services.obs.removeObserver(this.boundObserve, SYNCED_TABS_CHANGED); + + if (this.recentBrowsing) { + this.recentBrowsingElement.removeEventListener( + "fxview-search-textbox-query", + this + ); + } + } + + willUpdate(changedProperties) { + if (changedProperties.has("searchQuery")) { + this.cumulativeSearches = this.searchQuery + ? this.cumulativeSearches + 1 + : 0; + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.stop(); + } + + handleEvent(event) { + if (event.type == "click" && event.target.dataset.action) { + const { ErrorType } = SyncedTabsErrorHandler; + switch (event.target.dataset.action) { + case `${ErrorType.SYNC_ERROR}`: + case `${ErrorType.NETWORK_OFFLINE}`: + case `${ErrorType.PASSWORD_LOCKED}`: { + TabsSetupFlowManager.tryToClearError(); + break; + } + case `${ErrorType.SIGNED_OUT}`: + case "sign-in": { + TabsSetupFlowManager.openFxASignup(event.target.ownerGlobal); + break; + } + case "add-device": { + TabsSetupFlowManager.openFxAPairDevice(event.target.ownerGlobal); + break; + } + case "sync-tabs-disabled": { + TabsSetupFlowManager.syncOpenTabs(event.target); + break; + } + case `${ErrorType.SYNC_DISCONNECTED}`: { + const win = event.target.ownerGlobal; + const { switchToTabHavingURI } = + win.docShell.chromeEventHandler.ownerGlobal; + switchToTabHavingURI( + "about:preferences?action=choose-what-to-sync#sync", + true, + {} + ); + break; + } + } + } + if (event.type == "change") { + TabsSetupFlowManager.syncOpenTabs(event.target); + } + if (this.recentBrowsing && event.type === "fxview-search-textbox-query") { + this.onSearchQuery(event); + } + } + + viewVisibleCallback() { + this.start(); + } + + viewHiddenCallback() { + this.stop(); + } + + onVisibilityChange() { + const isOpen = this.open; + const isVisible = this.isVisible; + if (isVisible && isOpen) { + this.update(); + TabsSetupFlowManager.updateViewVisibility(this._id, "visible"); + } else { + TabsSetupFlowManager.updateViewVisibility( + this._id, + isVisible ? "closed" : "hidden" + ); + } + + this.toggleVisibilityInCardContainer(); + } + + async observe(subject, topic, errorState) { + if (topic == TOPIC_SETUPSTATE_CHANGED) { + this.updateStates(errorState); + } + if (topic == SYNCED_TABS_CHANGED) { + this.getSyncedTabData(); + } + } + + updateStates(errorState) { + let stateIndex = TabsSetupFlowManager.uiStateIndex; + errorState = errorState || SyncedTabsErrorHandler.getErrorType(); + + if (stateIndex == 4 && this._currentSetupStateIndex !== stateIndex) { + // trigger an initial request for the synced tabs list + this.getSyncedTabData(); + } + + this._currentSetupStateIndex = stateIndex; + this.errorState = errorState; + } + + actionMappings = { + "sign-in": { + header: "firefoxview-syncedtabs-signin-header", + description: "firefoxview-syncedtabs-signin-description", + buttonLabel: "firefoxview-syncedtabs-signin-primarybutton", + }, + "add-device": { + header: "firefoxview-syncedtabs-adddevice-header", + description: "firefoxview-syncedtabs-adddevice-description", + buttonLabel: "firefoxview-syncedtabs-adddevice-primarybutton", + descriptionLink: { + name: "url", + url: "https://support.mozilla.org/kb/how-do-i-set-sync-my-computer#w_connect-additional-devices-to-sync", + }, + }, + "sync-tabs-disabled": { + header: "firefoxview-syncedtabs-synctabs-header", + description: "firefoxview-syncedtabs-synctabs-description", + buttonLabel: "firefoxview-tabpickup-synctabs-primarybutton", + }, + loading: { + header: "firefoxview-syncedtabs-loading-header", + description: "firefoxview-syncedtabs-loading-description", + }, + }; + + generateMessageCard({ error = false, action, errorState }) { + errorState = errorState || this.errorState; + let header, + description, + descriptionLink, + buttonLabel, + headerIconUrl, + mainImageUrl; + let descriptionArray; + if (error) { + let link; + ({ header, description, link, buttonLabel } = + SyncedTabsErrorHandler.getFluentStringsForErrorType(errorState)); + action = `${errorState}`; + headerIconUrl = "chrome://global/skin/icons/info-filled.svg"; + mainImageUrl = + "chrome://browser/content/firefoxview/synced-tabs-error.svg"; + descriptionArray = [description]; + if (errorState == "password-locked") { + descriptionLink = {}; + // This is ugly, but we need to special case this link so we can + // coexist with the old view. + descriptionArray.push("firefoxview-syncedtab-password-locked-link"); + descriptionLink.name = "syncedtab-password-locked-link"; + descriptionLink.url = link.href; + } + } else { + header = this.actionMappings[action].header; + description = this.actionMappings[action].description; + buttonLabel = this.actionMappings[action].buttonLabel; + descriptionLink = this.actionMappings[action].descriptionLink; + mainImageUrl = + "chrome://browser/content/firefoxview/synced-tabs-error.svg"; + descriptionArray = [description]; + } + + return html` + + + + `; + } + + onOpenLink(event) { + let currentWindow = this.getWindow(); + if (currentWindow.openTrustedLinkIn) { + let where = lazy.BrowserUtils.whereToOpenLink( + event.detail.originalEvent, + false, + true + ); + if (where == "current") { + where = "tab"; + } + currentWindow.openTrustedLinkIn(event.originalTarget.url, where); + Services.telemetry.recordEvent( + "firefoxview_next", + "synced_tabs", + "tabs", + null, + { + page: this.recentBrowsing ? "recentbrowsing" : "syncedtabs", + } + ); + } + if (this.searchQuery) { + const searchesHistogram = Services.telemetry.getKeyedHistogramById( + "FIREFOX_VIEW_CUMULATIVE_SEARCHES" + ); + searchesHistogram.add( + this.recentBrowsing ? "recentbrowsing" : "syncedtabs", + this.cumulativeSearches + ); + this.cumulativeSearches = 0; + } + } + + onContextMenu(e) { + this.triggerNode = e.originalTarget; + e.target.querySelector("panel-list").toggle(e.detail.originalEvent); + } + + panelListTemplate() { + return html` + + + +
    + +
    + `; + } + + noDeviceTabsTemplate(deviceName, deviceType, isSearchResultsEmpty = false) { + const template = html`

    + + ${deviceName} +

    + ${when( + isSearchResultsEmpty, + () => html` +
    + `, + () => html` +
    + ` + )}`; + return this.recentBrowsing + ? template + : html`${template}`; + } + + onSearchQuery(e) { + this.searchQuery = e.detail.query; + this.showAll = false; + } + + deviceTemplate(deviceName, deviceType, tabItems) { + return html`

    + + ${deviceName} +

    + + ${this.panelListTemplate()} + `; + } + + generateTabList() { + let renderArray = []; + let renderInfo = {}; + for (let tab of this.currentSyncedTabs) { + if (!(tab.client in renderInfo)) { + renderInfo[tab.client] = { + name: tab.device, + deviceType: tab.deviceType, + tabs: [], + }; + } + renderInfo[tab.client].tabs.push(tab); + } + + // Add devices without tabs + for (let device of this.devices) { + if (!(device.id in renderInfo)) { + renderInfo[device.id] = { + name: device.name, + deviceType: device.clientType, + tabs: [], + }; + } + } + + for (let id in renderInfo) { + let tabItems = this.searchQuery + ? searchTabList(this.searchQuery, this.getTabItems(renderInfo[id].tabs)) + : this.getTabItems(renderInfo[id].tabs); + if (tabItems.length) { + const template = this.recentBrowsing + ? this.deviceTemplate( + renderInfo[id].name, + renderInfo[id].deviceType, + tabItems + ) + : html`${this.deviceTemplate( + renderInfo[id].name, + renderInfo[id].deviceType, + tabItems + )} + `; + renderArray.push(template); + if (this.isShowAllLinkVisible(tabItems)) { + renderArray.push(html` `); + } + } else { + // Check renderInfo[id].tabs.length to determine whether to display an + // empty tab list message or empty search results message. + // If there are no synced tabs, we always display the empty tab list + // message, even if there is an active search query. + renderArray.push( + this.noDeviceTabsTemplate( + renderInfo[id].name, + renderInfo[id].deviceType, + Boolean(renderInfo[id].tabs.length) + ) + ); + } + } + return renderArray; + } + + isShowAllLinkVisible(tabItems) { + return ( + this.recentBrowsing && + this.searchQuery && + tabItems.length > this.maxTabsLength && + !this.showAll + ); + } + + enableShowAll(event) { + if ( + event.type == "click" || + (event.type == "keydown" && event.code == "Enter") || + (event.type == "keydown" && event.code == "Space") + ) { + event.preventDefault(); + this.showAll = true; + Services.telemetry.recordEvent( + "firefoxview_next", + "search_show_all", + "showallbutton", + null, + { + section: "syncedtabs", + } + ); + } + } + + generateCardContent() { + switch (this._currentSetupStateIndex) { + case 0 /* error-state */: + if (this.errorState) { + return this.generateMessageCard({ error: true }); + } + return this.generateMessageCard({ action: "loading" }); + case 1 /* not-signed-in */: + if (Services.prefs.prefHasUserValue("services.sync.lastversion")) { + // If this pref is set, the user has signed out of sync. + // This path is also taken if we are disconnected from sync. See bug 1784055 + return this.generateMessageCard({ + error: true, + errorState: "signed-out", + }); + } + return this.generateMessageCard({ action: "sign-in" }); + case 2 /* connect-secondary-device*/: + return this.generateMessageCard({ action: "add-device" }); + case 3 /* disabled-tab-sync */: + return this.generateMessageCard({ action: "sync-tabs-disabled" }); + case 4 /* synced-tabs-loaded*/: + // There seems to be an edge case where sync says everything worked + // fine but we have no devices. + if (!this.devices.length) { + return this.generateMessageCard({ action: "add-device" }); + } + return this.generateTabList(); + } + return html``; + } + + render() { + this.open = + !TabsSetupFlowManager.isTabSyncSetupComplete || + Services.prefs.getBoolPref(UI_OPEN_STATE, true); + + let renderArray = []; + renderArray.push(html` `); + renderArray.push(html` `); + + if (!this.recentBrowsing) { + renderArray.push(html`
    + + ${when( + isSearchEnabled() || this._currentSetupStateIndex === 4, + () => html`
    + ${when( + isSearchEnabled(), + () => html`
    + +
    ` + )} + ${when( + this._currentSetupStateIndex === 4, + () => html` + + ` + )} +
    ` + )} +
    `); + } + + if (this.recentBrowsing) { + renderArray.push( + html` + > +

    +
    ${this.generateCardContent()}
    +
    ` + ); + } else { + renderArray.push( + html`
    ${this.generateCardContent()}
    ` + ); + } + return renderArray; + } + + async onReload() { + await TabsSetupFlowManager.syncOnPageReload(); + } + + getTabItems(tabs) { + tabs = tabs || this.tabs; + return tabs?.map(tab => ({ + icon: tab.icon, + title: tab.title, + time: tab.lastUsed * 1000, + url: tab.url, + primaryL10nId: "firefoxview-tabs-list-tab-button", + primaryL10nArgs: JSON.stringify({ targetURI: tab.url }), + secondaryL10nId: "fxviewtabrow-options-menu-button", + secondaryL10nArgs: JSON.stringify({ tabTitle: tab.title }), + })); + } + + updateTabsList(syncedTabs) { + if (!syncedTabs.length) { + this.currentSyncedTabs = syncedTabs; + this.sendTabTelemetry(0); + } + + const tabsToRender = syncedTabs; + + // Return early if new tabs are the same as previous ones + if ( + JSON.stringify(tabsToRender) == JSON.stringify(this.currentSyncedTabs) + ) { + return; + } + + this.currentSyncedTabs = tabsToRender; + // Record the full tab count + this.sendTabTelemetry(syncedTabs.length); + } + + async getSyncedTabData() { + this.devices = await lazy.SyncedTabs.getTabClients(); + let tabs = await lazy.SyncedTabs.createRecentTabsList(this.devices, 50, { + removeAllDupes: false, + removeDeviceDupes: true, + }); + + this.updateTabsList(tabs); + } + + updated() { + this.fullyUpdated = true; + this.toggleVisibilityInCardContainer(); + } + + sendTabTelemetry(numTabs) { + /* + Services.telemetry.recordEvent( + "firefoxview_next", + "synced_tabs", + "tabs", + null, + { + count: numTabs.toString(), + } + ); +*/ + } +} +customElements.define("view-syncedtabs", SyncedTabsInView); diff --git a/browser/components/firefoxview/tests/browser/FirefoxViewTestUtils.sys.mjs b/browser/components/firefoxview/tests/browser/FirefoxViewTestUtils.sys.mjs new file mode 100644 index 0000000000..3fd2bf95e3 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/FirefoxViewTestUtils.sys.mjs @@ -0,0 +1,177 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +import { BrowserTestUtils } from "resource://testing-common/BrowserTestUtils.sys.mjs"; +import { Assert } from "resource://testing-common/Assert.sys.mjs"; +import { TestUtils } from "resource://testing-common/TestUtils.sys.mjs"; + +var testScope; + +/** + * Module consumers can optionally initialize the module + * + * @param {object} scope + * object with SimpleTest and info properties. + */ +function init(scope) { + testScope = scope; +} + +function getFirefoxViewURL() { + return "about:firefoxview"; +} + +function assertFirefoxViewTab(win) { + Assert.ok(win.FirefoxViewHandler.tab, "Firefox View tab exists"); + Assert.ok(win.FirefoxViewHandler.tab?.hidden, "Firefox View tab is hidden"); + Assert.equal( + win.gBrowser.visibleTabs.indexOf(win.FirefoxViewHandler.tab), + -1, + "Firefox View tab is not in the list of visible tabs" + ); +} + +async function assertFirefoxViewTabSelected(win) { + assertFirefoxViewTab(win); + Assert.ok( + win.FirefoxViewHandler.tab.selected, + "Firefox View tab is selected" + ); + await BrowserTestUtils.browserLoaded( + win.FirefoxViewHandler.tab.linkedBrowser + ); +} + +async function openFirefoxViewTab(win) { + if (!testScope?.SimpleTest) { + throw new Error( + "Must initialize FirefoxViewTestUtils with a test scope which has a SimpleTest property" + ); + } + await testScope.SimpleTest.promiseFocus(win); + let fxviewTab = win.FirefoxViewHandler.tab; + let alreadyLoaded = + fxviewTab?.linkedBrowser.currentURI.spec.includes(getFirefoxViewURL()) && + fxviewTab?.linkedBrowser?.contentDocument?.readyState == "complete"; + let enteredPromise = alreadyLoaded + ? Promise.resolve() + : TestUtils.topicObserved("firefoxview-entered"); + + if (!fxviewTab?.selected) { + await BrowserTestUtils.synthesizeMouseAtCenter( + "#firefox-view-button", + { type: "mousedown" }, + win.browsingContext + ); + await TestUtils.waitForTick(); + } + + fxviewTab = win.FirefoxViewHandler.tab; + assertFirefoxViewTab(win); + Assert.ok( + win.FirefoxViewHandler.tab.selected, + "Firefox View tab is selected" + ); + + testScope.info( + "openFirefoxViewTab, waiting for complete readyState, visible and firefoxview-entered" + ); + await Promise.all([ + TestUtils.waitForCondition(() => { + const document = fxviewTab.linkedBrowser.contentDocument; + return ( + document.readyState == "complete" && + document.visibilityState == "visible" + ); + }), + enteredPromise, + ]); + testScope.info("openFirefoxViewTab, ready resolved"); + return fxviewTab; +} + +function closeFirefoxViewTab(win) { + if (win.FirefoxViewHandler.tab) { + win.gBrowser.removeTab(win.FirefoxViewHandler.tab); + } + Assert.ok( + !win.FirefoxViewHandler.tab, + "Reference to Firefox View tab got removed when closing the tab" + ); +} + +/** + * Run a task with Firefox View open. + * + * @param {object} options + * Options object. + * @param {boolean} [options.openNewWindow] + * Whether to run the task in a new window. If false, the current window will + * be used. + * @param {boolean} [options.resetFlowManager] + * Whether to reset the internal state of TabsSetupFlowManager before running + * the task. + * @param {Window} [options.win] + * The window in which to run the task. + * @param {(MozBrowser) => any} taskFn + * The task to run. It can be asynchronous. + * @returns {any} + * The value returned by the task. + */ +async function withFirefoxView( + { openNewWindow = false, resetFlowManager = true, win = null }, + taskFn +) { + if (!win) { + win = openNewWindow + ? await BrowserTestUtils.openNewBrowserWindow() + : Services.wm.getMostRecentBrowserWindow(); + } + if (resetFlowManager) { + const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" + ); + // reset internal state so we aren't reacting to whatever state the last invocation left behind + TabsSetupFlowManager.resetInternalState(); + } + // Setting this pref allows the test to run as expected with a keyboard on MacOS + await win.SpecialPowers.pushPrefEnv({ + set: [["accessibility.tabfocus", 7]], + }); + let tab = await openFirefoxViewTab(win); + let originalWindow = tab.ownerGlobal; + let result = await taskFn(tab.linkedBrowser); + let finalWindow = tab.ownerGlobal; + if (originalWindow == finalWindow && !tab.closing && tab.linkedBrowser) { + // taskFn may resolve within a tick after opening a new tab. + // We shouldn't remove the newly opened tab in the same tick. + // Wait for the next tick here. + await TestUtils.waitForTick(); + BrowserTestUtils.removeTab(tab); + } else { + Services.console.logStringMessage( + "withFirefoxView: Tab was already closed before " + + "removeTab would have been called" + ); + } + await win.SpecialPowers.popPrefEnv(); + if (openNewWindow) { + await BrowserTestUtils.closeWindow(win); + } + return result; +} + +function isFirefoxViewTabSelectedInWindow(win) { + return win.gBrowser.selectedBrowser.currentURI.spec == getFirefoxViewURL(); +} + +export { + init, + withFirefoxView, + assertFirefoxViewTab, + assertFirefoxViewTabSelected, + openFirefoxViewTab, + closeFirefoxViewTab, + isFirefoxViewTabSelectedInWindow, + getFirefoxViewURL, +}; diff --git a/browser/components/firefoxview/tests/browser/browser.toml b/browser/components/firefoxview/tests/browser/browser.toml new file mode 100644 index 0000000000..8e2005760b --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser.toml @@ -0,0 +1,74 @@ +[DEFAULT] +support-files = ["head.js"] +prefs = [ + "browser.sessionstore.closedTabsFromAllWindows=true", + "browser.sessionstore.closedTabsFromClosedWindows=true", + "browser.tabs.firefox-view.logLevel=All", +] + +["browser_dragDrop_after_opening_fxViewTab.js"] + +["browser_entrypoint_management.js"] + +["browser_feature_callout.js"] +skip-if = ["true"] # Bug 1869605 and # Bug 1870296 + +["browser_feature_callout_position.js"] +skip-if = ["true"] # Bug 1869605 and # Bug 1870296 + +["browser_feature_callout_resize.js"] +skip-if = ["true"] # Bug 1869605 and # Bug 1870296 + +["browser_feature_callout_targeting.js"] +skip-if = ["true"] # Bug 1869605 and # Bug 1870296 + +["browser_feature_callout_theme.js"] +skip-if = ["true"] # Bug 1869605 and # Bug 1870296 + +["browser_firefoxview.js"] + +["browser_firefoxview_tab.js"] + +["browser_notification_dot.js"] +skip-if = ["true"] # Bug 1851453 + +["browser_opentabs_changes.js"] + +["browser_reload_firefoxview.js"] + +["browser_tab_close_last_tab.js"] + +["browser_tab_on_close_warning.js"] + +["browser_firefoxview_paused.js"] + +["browser_firefoxview_general_telemetry.js"] + +["browser_firefoxview_navigation.js"] + +["browser_firefoxview_search_telemetry.js"] + +["browser_firefoxview_virtual_list.js"] + +["browser_history_firefoxview.js"] +skip-if = ["true"] # Bug 1877594 + +["browser_opentabs_firefoxview.js"] + +["browser_opentabs_cards.js"] +fail-if = ["a11y_checks"] # Bugs 1858041, 1854625, and 1872174 clicked Show all link is not accessible because it is "hidden" when clicked + +["browser_opentabs_recency.js"] +skip-if = [ + "os == 'win'", + "os == 'mac' && verify", + "os == 'linux'" +] # macos times out, see bug 1857293, skipped for windows, see bug 1858460, skipped for linux, see bug 1875877 + +["browser_opentabs_tab_indicators.js"] + +["browser_recentlyclosed_firefoxview.js"] + +["browser_syncedtabs_errors_firefoxview.js"] + +["browser_syncedtabs_firefoxview.js"] diff --git a/browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js b/browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js new file mode 100644 index 0000000000..9ce547238a --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js @@ -0,0 +1,120 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that dragging and dropping tabs into tabbrowser works as intended + * after opening the Firefox View tab for RTL builds. There was an issue where + * tabs from dragged links were not dropped in the correct tab indexes + * for RTL builds because logic for RTL builds did not take into consideration + * hidden tabs like the Firefox View tab. This test makes sure that this behavior does not reoccur. + */ +add_task(async function () { + info("Setting browser to RTL locale"); + await SpecialPowers.pushPrefEnv({ set: [["intl.l10n.pseudo", "bidi"]] }); + + // window.RTL_UI doesn't update in existing windows when this pref is changed, + // so we need to test in a new window. + let win = await BrowserTestUtils.openNewBrowserWindow(); + + const TEST_ROOT = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ); + let newTab = win.gBrowser.tabs[0]; + + let waitForTestTabPromise = BrowserTestUtils.waitForNewTab( + win.gBrowser, + TEST_ROOT + "file_new_tab_page.html" + ); + let testTab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + TEST_ROOT + "file_new_tab_page.html" + ); + await waitForTestTabPromise; + + let linkSrcEl = win.document.querySelector("a"); + ok(linkSrcEl, "Link exists"); + + let dropPromise = BrowserTestUtils.waitForEvent( + win.gBrowser.tabContainer, + "drop" + ); + + /** + * There should be 2 tabs: + * 1. new tab (auto-generated) + * 2. test tab + */ + is(win.gBrowser.visibleTabs.length, 2, "There should be 2 tabs"); + + // Now open Firefox View tab + info("Opening Firefox View tab"); + await openFirefoxViewTab(win); + + /** + * There should be 2 visible tabs: + * 1. new tab (auto-generated) + * 2. test tab + * Firefox View tab is hidden. + */ + is( + win.gBrowser.visibleTabs.length, + 2, + "There should still be 2 visible tabs after opening Firefox View tab" + ); + + info("Switching to test tab"); + await BrowserTestUtils.switchTab(win.gBrowser, testTab); + + let waitForDraggedTabPromise = BrowserTestUtils.waitForNewTab( + win.gBrowser, + "https://example.com/#test" + ); + + info("Dragging link between test tab and new tab"); + EventUtils.synthesizeDrop( + linkSrcEl, + testTab, + [[{ type: "text/plain", data: "https://example.com/#test" }]], + "link", + win, + win, + { + clientX: testTab.getBoundingClientRect().right, + } + ); + + info("Waiting for drop event"); + await dropPromise; + info("Waiting for dragged tab to be created"); + let draggedTab = await waitForDraggedTabPromise; + + /** + * There should be 3 visible tabs: + * 1. new tab (auto-generated) + * 2. new tab from dragged link + * 3. test tab + * + * In RTL build, it should appear in the following order: + * | + */ + is(win.gBrowser.visibleTabs.length, 3, "There should be 3 tabs"); + is( + win.gBrowser.visibleTabs.indexOf(newTab), + 0, + "New tab should still be rightmost visible tab" + ); + is( + win.gBrowser.visibleTabs.indexOf(draggedTab), + 1, + "Dragged link should positioned at new index" + ); + is( + win.gBrowser.visibleTabs.indexOf(testTab), + 2, + "Test tab should be to the left of dragged tab" + ); + + await BrowserTestUtils.closeWindow(win); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_entrypoint_management.js b/browser/components/firefoxview/tests/browser/browser_entrypoint_management.js new file mode 100644 index 0000000000..ef6b0c99f5 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_entrypoint_management.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_removing_button_should_close_tab() { + await withFirefoxView({}, async browser => { + let win = browser.ownerGlobal; + let tab = browser.getTabBrowser().getTabForBrowser(browser); + let button = win.document.getElementById("firefox-view-button"); + await win.gCustomizeMode.removeFromArea(button, "toolbar-context-menu"); + ok(!tab.isConnected, "Tab should have been removed."); + isnot(win.gBrowser.selectedTab, tab, "A different tab should be selected."); + }); + CustomizableUI.reset(); +}); + +add_task(async function test_button_auto_readd() { + await withFirefoxView({}, async browser => { + let { FirefoxViewHandler } = browser.ownerGlobal; + + CustomizableUI.removeWidgetFromArea("firefox-view-button"); + ok( + !CustomizableUI.getPlacementOfWidget("firefox-view-button"), + "Button has no placement" + ); + ok(!FirefoxViewHandler.tab, "Shouldn't have tab reference"); + ok(!FirefoxViewHandler.button, "Shouldn't have button reference"); + + FirefoxViewHandler.openTab(); + ok(FirefoxViewHandler.tab, "Tab re-opened"); + ok(FirefoxViewHandler.button, "Button re-added"); + let placement = CustomizableUI.getPlacementOfWidget("firefox-view-button"); + is( + placement.area, + CustomizableUI.AREA_TABSTRIP, + "Button re-added to the tabs toolbar" + ); + is(placement.position, 0, "Button re-added as the first toolbar element"); + }); + CustomizableUI.reset(); +}); + +add_task(async function test_button_moved() { + await withFirefoxView({}, async browser => { + let { FirefoxViewHandler } = browser.ownerGlobal; + CustomizableUI.addWidgetToArea( + "firefox-view-button", + CustomizableUI.AREA_NAVBAR, + 0 + ); + is( + FirefoxViewHandler.button.closest("toolbar").id, + "nav-bar", + "Button is in the navigation toolbar" + ); + }); + await withFirefoxView({}, async browser => { + let { FirefoxViewHandler } = browser.ownerGlobal; + is( + FirefoxViewHandler.button.closest("toolbar").id, + "nav-bar", + "Button remains in the navigation toolbar" + ); + }); + CustomizableUI.reset(); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_feature_callout.js b/browser/components/firefoxview/tests/browser/browser_feature_callout.js new file mode 100644 index 0000000000..3fd2ee517d --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_feature_callout.js @@ -0,0 +1,746 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +const { MessageLoaderUtils } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); + +const { BuiltInThemes } = ChromeUtils.importESModule( + "resource:///modules/BuiltInThemes.sys.mjs" +); + +const defaultPrefValue = getPrefValueByScreen(1); + +add_setup(async function () { + requestLongerTimeout(3); + registerCleanupFunction(() => ASRouter.resetMessageState()); +}); + +add_task(async function feature_callout_renders_in_firefox_view() { + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, defaultPrefValue]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + + ok( + document.querySelector(calloutSelector), + "Feature Callout element exists" + ); + } + ); +}); + +add_task(async function feature_callout_is_not_shown_twice() { + // Third comma-separated value of the pref is set to a string value once a user completes the tour + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, '{"screen":"","complete":true}']], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + ok( + !document.querySelector(calloutSelector), + "Feature Callout tour does not render if the user finished it previously" + ); + } + ); + Services.prefs.clearUserPref(featureTourPref); +}); + +add_task(async function feature_callout_syncs_across_visits_and_tabs() { + // Second comma-separated value of the pref is the id + // of the last viewed screen of the feature tour + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, '{"screen":"FEATURE_CALLOUT_1","complete":false}']], + }); + // Open an about:firefoxview tab + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:firefoxview" + ); + let tab1Doc = tab1.linkedBrowser.contentWindow.document; + launchFeatureTourIn(tab1.linkedBrowser.contentWindow); + await waitForCalloutScreen(tab1Doc, "FEATURE_CALLOUT_1"); + + ok( + tab1Doc.querySelector(".FEATURE_CALLOUT_1"), + "First tab's Feature Callout shows the tour screen saved in the user pref" + ); + + // Open a second about:firefoxview tab + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:firefoxview" + ); + let tab2Doc = tab2.linkedBrowser.contentWindow.document; + launchFeatureTourIn(tab2.linkedBrowser.contentWindow); + await waitForCalloutScreen(tab2Doc, "FEATURE_CALLOUT_1"); + + ok( + tab2Doc.querySelector(".FEATURE_CALLOUT_1"), + "Second tab's Feature Callout shows the tour screen saved in the user pref" + ); + + await clickCTA(tab2Doc); + await waitForCalloutScreen(tab2Doc, "FEATURE_CALLOUT_2"); + + gBrowser.selectedTab = tab1; + tab1.focus(); + await waitForCalloutScreen(tab1Doc, "FEATURE_CALLOUT_2"); + ok( + tab1Doc.querySelector(".FEATURE_CALLOUT_2"), + "First tab's Feature Callout advances to the next screen when the tour is advanced in second tab" + ); + + await clickCTA(tab1Doc); + gBrowser.selectedTab = tab1; + await waitForCalloutRemoved(tab1Doc); + + ok( + !tab1Doc.body.querySelector(calloutSelector), + "Feature Callout is removed in first tab after being dismissed in first tab" + ); + + gBrowser.selectedTab = tab2; + tab2.focus(); + await waitForCalloutRemoved(tab2Doc); + + ok( + !tab2Doc.body.querySelector(calloutSelector), + "Feature Callout is removed in second tab after tour was dismissed in first tab" + ); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + Services.prefs.clearUserPref(featureTourPref); +}); + +add_task(async function feature_callout_closes_on_dismiss() { + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, '{"screen":"FEATURE_CALLOUT_2","complete":false}']], + }); + const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR"); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + const spy = new TelemetrySpy(sandbox); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_2"); + + document.querySelector(".dismiss-button").click(); + await waitForCalloutRemoved(document); + + ok( + !document.querySelector(calloutSelector), + "Callout is removed from screen on dismiss" + ); + + let tourComplete = JSON.parse( + Services.prefs.getStringPref(featureTourPref) + ).complete; + ok( + tourComplete, + `Tour is recorded as complete in ${featureTourPref} preference value` + ); + + // Test that appropriate telemetry is sent + spy.assertCalledWith({ + event: "CLICK_BUTTON", + event_context: { + source: "dismiss_button", + page: "about:firefoxview", + }, + message_id: sinon.match("FEATURE_CALLOUT_2"), + }); + spy.assertCalledWith({ + event: "DISMISS", + event_context: { + source: "dismiss_button", + page: "about:firefoxview", + }, + message_id: sinon.match("FEATURE_CALLOUT_2"), + }); + } + ); + sandbox.restore(); +}); + +add_task(async function feature_callout_arrow_position_attribute_exists() { + const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR"); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + const callout = await BrowserTestUtils.waitForCondition( + () => + document.querySelector(`${calloutSelector}[arrow-position="top"]`), + "Waiting for callout to render" + ); + is( + callout.getAttribute("arrow-position"), + "top", + "Arrow position attribute exists on parent container" + ); + } + ); + sandbox.restore(); +}); + +add_task(async function feature_callout_arrow_is_not_flipped_on_ltr() { + const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR"); + testMessage.message.content.screens[0].anchors[0].arrow_position = "start"; + testMessage.message.content.screens[0].anchors[0].selector = + "span.brand-feature-name"; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + const callout = await BrowserTestUtils.waitForCondition( + () => + document.querySelector( + `${calloutSelector}[arrow-position="inline-start"]:not(.hidden)` + ), + "Waiting for callout to render" + ); + is( + callout.getAttribute("arrow-position"), + "inline-start", + "Feature callout has inline-start arrow position when arrow_position is set to 'start'" + ); + ok( + !callout.classList.contains("hidden"), + "Feature Callout is not hidden" + ); + } + ); + sandbox.restore(); +}); + +add_task(async function feature_callout_respects_cfr_features_pref() { + async function toggleCFRFeaturesPref(value) { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", + value, + ], + ], + }); + } + + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, defaultPrefValue]], + }); + + await toggleCFRFeaturesPref(true); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + ok( + document.querySelector(calloutSelector), + "Feature Callout element exists" + ); + } + ); + + await SpecialPowers.popPrefEnv(); + await toggleCFRFeaturesPref(false); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + ok( + !document.querySelector(calloutSelector), + "Feature Callout element was not created because CFR pref was disabled" + ); + } + ); + + await SpecialPowers.popPrefEnv(); +}); + +add_task( + async function feature_callout_tab_pickup_reminder_primary_click_elm() { + Services.prefs.setBoolPref("identity.fxaccounts.enabled", false); + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + const expectedUrl = + await fxAccounts.constructor.config.promiseConnectAccountURI("fx-view"); + info(`Expected FxA URL: ${expectedUrl}`); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + let tabOpened = new Promise(resolve => { + gBrowser.tabContainer.addEventListener( + "TabOpen", + event => { + let newTab = event.target; + let newBrowser = newTab.linkedBrowser; + let result = newTab; + BrowserTestUtils.waitForDocLoadAndStopIt( + expectedUrl, + newBrowser + ).then(() => resolve(result)); + }, + { once: true } + ); + }); + + info("Waiting for callout to render"); + await waitForCalloutScreen( + document, + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + + info("Clicking primary button"); + let calloutRemoved = waitForCalloutRemoved(document); + await clickCTA(document); + let openedTab = await tabOpened; + ok(openedTab, "FxA sign in page opened"); + // The callout should be removed when primary CTA is clicked + await calloutRemoved; + BrowserTestUtils.removeTab(openedTab); + } + ); + Services.prefs.clearUserPref("identity.fxaccounts.enabled"); + sandbox.restore(); + } +); + +add_task(async function feature_callout_dismiss_on_timeout() { + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, `{"screen":"","complete":true}`]], + }); + const screenId = "FIREFOX_VIEW_TAB_PICKUP_REMINDER"; + let testMessage = getCalloutMessageById(screenId); + // Configure message with a dismiss action on tab container click + testMessage.message.content.screens[0].content.page_event_listeners = [ + { + params: { type: "timeout", options: { once: true, interval: 5000 } }, + action: { dismiss: true, type: "CANCEL" }, + }, + ]; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + const telemetrySpy = new TelemetrySpy(sandbox); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + let onInterval; + let startedInterval = new Promise(resolve => { + sandbox + .stub(browser.contentWindow, "setInterval") + .callsFake((fn, ms) => { + Assert.strictEqual( + ms, + 5000, + "setInterval called with 5 second interval" + ); + onInterval = fn; + resolve(); + return 1; + }); + }); + + launchFeatureTourIn(browser.contentWindow); + + info("Waiting for callout to render"); + await startedInterval; + await waitForCalloutScreen(document, screenId); + + info("Ending timeout"); + onInterval(); + await waitForCalloutRemoved(document); + + // Test that appropriate telemetry is sent + telemetrySpy.assertCalledWith({ + event: "PAGE_EVENT", + event_context: { + action: "CANCEL", + reason: "TIMEOUT", + source: "timeout", + page: "about:firefoxview", + }, + message_id: screenId, + }); + telemetrySpy.assertCalledWith({ + event: "DISMISS", + event_context: { + source: "PAGE_EVENT:timeout", + page: "about:firefoxview", + }, + message_id: screenId, + }); + } + ); + Services.prefs.clearUserPref("browser.firefox-view.view-count"); + sandbox.restore(); + ASRouter.resetMessageState(); +}); + +add_task(async function feature_callout_advance_tour_on_page_click() { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + featureTourPref, + JSON.stringify({ + screen: "FEATURE_CALLOUT_1", + complete: false, + }), + ], + ], + }); + + // Add page action listeners to the built-in messages. + let testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR"); + // Configure message with a dismiss action on tab container click + testMessage.message.content.screens.forEach(screen => { + screen.content.page_event_listeners = [ + { + params: { type: "click", selectors: ".brand-logo" }, + action: JSON.parse( + JSON.stringify(screen.content.primary_button.action) + ), + }, + ]; + }); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + info("Clicking page container"); + // We intentionally turn off a11y_checks, because the following click + // is send to dismiss the feature callout using an alternative way of + // the callout dismissal, where other ways are accessible, therefore + // this test can be ignored. + AccessibilityUtils.setEnv({ + mustHaveAccessibleRule: false, + }); + document.querySelector(".brand-logo").click(); + AccessibilityUtils.resetEnv(); + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_2"); + info("Clicking page container"); + // We intentionally turn off a11y_checks, because the following click + // is send to dismiss the feature callout using an alternative way of + // the callout dismissal, where other ways are accessible, therefore + // this test can be ignored. + AccessibilityUtils.setEnv({ + mustHaveAccessibleRule: false, + }); + document.querySelector(".brand-logo").click(); + AccessibilityUtils.resetEnv(); + + await waitForCalloutRemoved(document); + let tourComplete = JSON.parse( + Services.prefs.getStringPref(featureTourPref) + ).complete; + ok( + tourComplete, + `Tour is recorded as complete in ${featureTourPref} preference value` + ); + } + ); + + sandbox.restore(); + ASRouter.resetMessageState(); +}); + +add_task(async function feature_callout_dismiss_on_escape() { + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, `{"screen":"","complete":true}`]], + }); + const screenId = "FIREFOX_VIEW_TAB_PICKUP_REMINDER"; + let testMessage = getCalloutMessageById(screenId); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + const spy = new TelemetrySpy(sandbox); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + info("Waiting for callout to render"); + await waitForCalloutScreen(document, screenId); + + info("Pressing escape"); + // Press Escape to close + EventUtils.synthesizeKey("KEY_Escape", {}, browser.contentWindow); + await waitForCalloutRemoved(document); + + // Test that appropriate telemetry is sent + spy.assertCalledWith({ + event: "DISMISS", + event_context: { + source: "KEY_Escape", + page: "about:firefoxview", + }, + message_id: screenId, + }); + } + ); + Services.prefs.clearUserPref("browser.firefox-view.view-count"); + sandbox.restore(); + ASRouter.resetMessageState(); +}); + +add_task(async function test_firefox_view_spotlight_promo() { + // Prevent attempts to fetch CFR messages remotely. + const sandbox = sinon.createSandbox(); + let remoteSettingsStub = sandbox.stub( + MessageLoaderUtils, + "_remoteSettingsLoader" + ); + remoteSettingsStub.resolves([]); + + await SpecialPowers.pushPrefEnv({ + clear: [ + [featureTourPref], + ["browser.newtabpage.activity-stream.asrouter.providers.cfr"], + ], + }); + ASRouter.resetMessageState(); + + let dialogOpenPromise = BrowserTestUtils.promiseAlertDialogOpen( + null, + "chrome://browser/content/spotlight.html", + { isSubDialog: true } + ); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + launchFeatureTourIn(browser.contentWindow); + + info("Waiting for the Fx View Spotlight promo to open"); + let dialogBrowser = await dialogOpenPromise; + let primaryBtnSelector = ".action-buttons button.primary"; + await TestUtils.waitForCondition( + () => dialogBrowser.document.querySelector("main.DEFAULT_MODAL_UI"), + `Should render main.DEFAULT_MODAL_UI` + ); + + dialogBrowser.document.querySelector(primaryBtnSelector).click(); + info("Fx View Spotlight promo clicked"); + + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + ok( + document.querySelector(calloutSelector), + "Feature Callout element exists" + ); + info("Feature tour started"); + await clickCTA(document); + } + ); + + ok(remoteSettingsStub.called, "Tried to load CFR messages"); + sandbox.restore(); + await SpecialPowers.popPrefEnv(); + ASRouter.resetMessageState(); +}); + +add_task(async function feature_callout_returns_default_fxview_focus_to_top() { + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, defaultPrefValue]], + }); + const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR"); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + + ok( + document.querySelector(calloutSelector), + "Feature Callout element exists" + ); + + document.querySelector(".dismiss-button").click(); + await waitForCalloutRemoved(document); + + Assert.strictEqual( + document.activeElement.localName, + "body", + "by default focus returns to the document body after callout closes" + ); + } + ); + sandbox.restore(); + await SpecialPowers.popPrefEnv(); + ASRouter.resetMessageState(); +}); + +add_task( + async function feature_callout_returns_moved_fxview_focus_to_previous() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + await waitForCalloutScreen( + document, + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + + // change focus to recently-closed-tabs-container + let recentlyClosedHeaderSection = document.querySelector( + "#recently-closed-tabs-header-section" + ); + recentlyClosedHeaderSection.focus(); + + // close the callout dialog + document.querySelector(".dismiss-button").click(); + await waitForCalloutRemoved(document); + + // verify that the focus landed in the right place + Assert.strictEqual( + document.activeElement.id, + "recently-closed-tabs-header-section", + "when focus changes away from callout it reverts after callout closes" + ); + } + ); + sandbox.restore(); + } +); + +add_task(async function feature_callout_does_not_display_arrow_if_hidden() { + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, defaultPrefValue]], + }); + const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR"); + testMessage.message.content.screens[0].anchors[0].hide_arrow = true; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + + is( + getComputedStyle( + document.querySelector(`${calloutSelector} .arrow-box`) + ).getPropertyValue("display"), + "none", + "callout arrow is not visible" + ); + } + ); + sandbox.restore(); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_feature_callout_position.js b/browser/components/firefoxview/tests/browser/browser_feature_callout_position.js new file mode 100644 index 0000000000..fcb66719d9 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_position.js @@ -0,0 +1,445 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +const defaultPrefValue = getPrefValueByScreen(1); + +const squareWidth = 24; +const arrowWidth = Math.hypot(squareWidth, squareWidth); +const arrowHeight = arrowWidth / 2; +let overlap = 5 - arrowHeight; + +add_task( + async function feature_callout_first_screen_positioned_below_element() { + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, defaultPrefValue]], + }); + const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR"); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + let parentBottom = document + .querySelector("#tab-pickup-container") + .getBoundingClientRect().bottom; + let containerTop = document + .querySelector(calloutSelector) + .getBoundingClientRect().top; + + isfuzzy( + parentBottom - containerTop, + overlap, + 1, // add 1px fuzziness to account for possible subpixel rounding + "Feature Callout is positioned below parent element with the arrow overlapping by 5px" + ); + } + ); + sandbox.restore(); + } +); + +add_task( + async function feature_callout_second_screen_positioned_right_of_element() { + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, getPrefValueByScreen(2)]], + }); + const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR"); + testMessage.message.content.screens[1].anchors = [ + { selector: ".brand-logo", arrow_position: "start" }, + ]; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_2"); + + let parentRight = document + .querySelector(".brand-logo") + .getBoundingClientRect().right; + let containerLeft = document + .querySelector(calloutSelector) + .getBoundingClientRect().left; + isfuzzy( + parentRight - containerLeft, + overlap, + 1, + "Feature Callout is positioned right of parent element with the arrow overlapping by 5px" + ); + } + ); + sandbox.restore(); + } +); + +add_task( + async function feature_callout_second_screen_positioned_above_element() { + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, getPrefValueByScreen(2)]], + }); + const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR"); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_2"); + let parentTop = document + .querySelector("#recently-closed-tabs-container") + .getBoundingClientRect().top; + let containerBottom = document + .querySelector(calloutSelector) + .getBoundingClientRect().bottom; + + Assert.greaterOrEqual( + parentTop, + containerBottom - 5 - 1, + "Feature Callout is positioned above parent element with 5px overlap" + ); + } + ); + sandbox.restore(); + } +); + +add_task( + async function feature_callout_third_screen_position_respects_RTL_layouts() { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set layout direction to right to left + ["intl.l10n.pseudo", "bidi"], + [featureTourPref, getPrefValueByScreen(2)], + ], + }); + + const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR"); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + const parent = document.querySelector( + "#recently-closed-tabs-container" + ); + parent.style.gridArea = "1/2"; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_2"); + let parentRight = parent.getBoundingClientRect().right; + let containerLeft = document + .querySelector(calloutSelector) + .getBoundingClientRect().left; + + Assert.lessOrEqual( + parentRight, + containerLeft + 5 + 1, + "Feature Callout is positioned right of parent element when callout is set to 'end' in RTL layouts" + ); + } + ); + + await SpecialPowers.popPrefEnv(); + sandbox.restore(); + } +); + +add_task( + async function feature_callout_is_repositioned_if_parent_container_is_toggled() { + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, defaultPrefValue]], + }); + const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR"); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + const parentEl = document.querySelector("#tab-pickup-container"); + const calloutStartingTopPosition = + document.querySelector(calloutSelector).style.top; + + //container has been toggled/minimized + parentEl.removeAttribute("open", ""); + await BrowserTestUtils.waitForMutationCondition( + document.querySelector(calloutSelector), + { attributes: true }, + () => + document.querySelector(calloutSelector).style.top != + calloutStartingTopPosition + ); + isnot( + document.querySelector(calloutSelector).style.top, + calloutStartingTopPosition, + "Feature Callout position is recalculated when parent element is toggled" + ); + await closeCallout(document); + } + ); + sandbox.restore(); + } +); + +// This test should be moved into a surface agnostic test suite with bug 1793656. +add_task(async function feature_callout_top_end_positioning() { + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, defaultPrefValue]], + }); + const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR"); + testMessage.message.content.screens[0].anchors[0].arrow_position = "top-end"; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + let parent = document.querySelector("#tab-pickup-container"); + let container = document.querySelector(calloutSelector); + let parentLeft = parent.getBoundingClientRect().left; + let containerLeft = container.getBoundingClientRect().left; + + is( + container.getAttribute("arrow-position"), + "top-end", + "Feature Callout container has the expected top-end arrow-position attribute" + ); + isfuzzy( + containerLeft - parent.clientWidth + container.offsetWidth, + parentLeft, + 1, // Display scaling can cause up to 1px difference in layout + "Feature Callout's right edge is approximately aligned with parent element's right edge" + ); + + await closeCallout(document); + } + ); + sandbox.restore(); +}); + +// This test should be moved into a surface agnostic test suite with bug 1793656. +add_task(async function feature_callout_top_start_positioning() { + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, defaultPrefValue]], + }); + const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR"); + testMessage.message.content.screens[0].anchors[0].arrow_position = + "top-start"; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + let parent = document.querySelector("#tab-pickup-container"); + let container = document.querySelector(calloutSelector); + let parentLeft = parent.getBoundingClientRect().left; + let containerLeft = container.getBoundingClientRect().left; + + is( + container.getAttribute("arrow-position"), + "top-start", + "Feature Callout container has the expected top-start arrow-position attribute" + ); + isfuzzy( + containerLeft, + parentLeft, + 1, // Display scaling can cause up to 1px difference in layout + "Feature Callout's left edge is approximately aligned with parent element's left edge" + ); + + await closeCallout(document); + } + ); + sandbox.restore(); +}); + +// This test should be moved into a surface agnostic test suite with bug 1793656. +add_task( + async function feature_callout_top_end_position_respects_RTL_layouts() { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set layout direction to right to left + ["intl.l10n.pseudo", "bidi"], + [featureTourPref, defaultPrefValue], + ], + }); + + const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR"); + testMessage.message.content.screens[0].anchors[0].arrow_position = + "top-end"; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + let parent = document.querySelector("#tab-pickup-container"); + let container = document.querySelector(calloutSelector); + let parentLeft = parent.getBoundingClientRect().left; + let containerLeft = container.getBoundingClientRect().left; + + is( + container.getAttribute("arrow-position"), + "top-start", + "In RTL mode, the feature callout container has the expected top-start arrow-position attribute" + ); + is( + containerLeft, + parentLeft, + "In RTL mode, the feature Callout's left edge is aligned with parent element's left edge" + ); + + await closeCallout(document); + } + ); + + await SpecialPowers.popPrefEnv(); + sandbox.restore(); + } +); + +add_task(async function feature_callout_is_larger_than_its_parent() { + let testMessage = { + message: { + id: "FIREFOX_VIEW_FEATURE_TOUR", + template: "feature_callout", + content: { + id: "FIREFOX_VIEW_FEATURE_TOUR", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FEATURE_CALLOUT_1", + anchors: [ + { selector: ".brand-feature-name", arrow_position: "end" }, + ], + content: { + position: "callout", + title: "callout-firefox-view-tab-pickup-title", + subtitle: { + string_id: "callout-firefox-view-tab-pickup-subtitle", + }, + logo: { + imageURL: "chrome://browser/content/callout-tab-pickup.svg", + darkModeImageURL: + "chrome://browser/content/callout-tab-pickup-dark.svg", + height: "128px", // .brand-feature-name has a height of 32px + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + }, + }, + ], + }, + }, + }; + + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, defaultPrefValue]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + let parent = document.querySelector(".brand-feature-name"); + let container = document.querySelector(calloutSelector); + let parentHeight = parent.offsetHeight; + let containerHeight = container.offsetHeight; + + let parentPositionTop = + parent.getBoundingClientRect().top + window.scrollY; + let containerPositionTop = + container.getBoundingClientRect().top + window.scrollY; + Assert.greater( + containerHeight, + parentHeight, + "Feature Callout is height is larger than parent element when callout is configured at end of callout" + ); + Assert.less( + containerPositionTop, + parentPositionTop, + "Feature Callout is positioned higher that parent element when callout is configured at end of callout" + ); + isfuzzy( + containerHeight / 2 + containerPositionTop, + parentHeight / 2 + parentPositionTop, + 1, // Display scaling can cause up to 1px difference in layout + "Feature Callout is centered equally to parent element when callout is configured at end of callout" + ); + await ASRouter.resetMessageState(); + } + ); + sandbox.restore(); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_feature_callout_resize.js b/browser/components/firefoxview/tests/browser/browser_feature_callout_resize.js new file mode 100644 index 0000000000..cbc0547717 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_resize.js @@ -0,0 +1,178 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function getArrowPosition(doc) { + let callout = doc.querySelector(calloutSelector); + return callout.getAttribute("arrow-position"); +} + +add_setup(async function setup() { + let originalWidth = window.outerWidth; + let originalHeight = window.outerHeight; + registerCleanupFunction(async () => { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:firefoxview" }, + async browser => window.FullZoom.reset(browser) + ); + window.resizeTo(originalWidth, originalHeight); + }); + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:firefoxview" }, + async browser => window.FullZoom.setZoom(0.5, browser) + ); +}); + +add_task(async function feature_callout_is_repositioned_if_it_does_not_fit() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.max_tabs_undo", 1]], + }); + const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR"); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:firefoxview" }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + browser.contentWindow.resizeTo(1550, 1000); + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + await BrowserTestUtils.waitForCondition(() => { + if (getArrowPosition(document) === "top") { + return true; + } + browser.contentWindow.resizeTo(1550, 1000); + return false; + }); + is( + getArrowPosition(document), + "top", + "On first screen at 1550x1000, the callout is positioned below the parent element" + ); + + let startingTop = document.querySelector(calloutSelector).style.top; + browser.contentWindow.resizeTo(1800, 400); + // Wait for callout to be repositioned + await BrowserTestUtils.waitForMutationCondition( + document.querySelector(calloutSelector), + { attributeFilter: ["style"], attributes: true }, + () => document.querySelector(calloutSelector).style.top != startingTop + ); + await BrowserTestUtils.waitForCondition(() => { + if (getArrowPosition(document) === "inline-start") { + return true; + } + browser.contentWindow.resizeTo(1800, 400); + return false; + }); + is( + getArrowPosition(document), + "inline-start", + "On first screen at 1800x400, the callout is positioned to the right of the parent element" + ); + + startingTop = document.querySelector(calloutSelector).style.top; + browser.contentWindow.resizeTo(1100, 600); + await BrowserTestUtils.waitForMutationCondition( + document.querySelector(calloutSelector), + { attributeFilter: ["style"], attributes: true }, + () => document.querySelector(calloutSelector).style.top != startingTop + ); + await BrowserTestUtils.waitForCondition(() => { + if (getArrowPosition(document) === "top") { + return true; + } + browser.contentWindow.resizeTo(1100, 600); + return false; + }); + is( + getArrowPosition(document), + "top", + "On first screen at 1100x600, the callout is positioned below the parent element" + ); + } + ); + sandbox.restore(); +}); + +add_task(async function feature_callout_is_repositioned_rtl() { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set layout direction to right to left + ["intl.l10n.pseudo", "bidi"], + ["browser.sessionstore.max_tabs_undo", 1], + ], + }); + + const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR"); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:firefoxview" }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + browser.contentWindow.resizeTo(1550, 1000); + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + await BrowserTestUtils.waitForCondition(() => { + if (getArrowPosition(document) === "top") { + return true; + } + browser.contentWindow.resizeTo(1550, 1000); + return false; + }); + is( + getArrowPosition(document), + "top", + "On first screen at 1550x1000, the callout is positioned below the parent element" + ); + + let startingTop = document.querySelector(calloutSelector).style.top; + browser.contentWindow.resizeTo(1800, 400); + // Wait for callout to be repositioned + await BrowserTestUtils.waitForMutationCondition( + document.querySelector(calloutSelector), + { attributeFilter: ["style"], attributes: true }, + () => document.querySelector(calloutSelector).style.top != startingTop + ); + await BrowserTestUtils.waitForCondition(() => { + if (getArrowPosition(document) === "inline-end") { + return true; + } + browser.contentWindow.resizeTo(1800, 400); + return false; + }); + is( + getArrowPosition(document), + "inline-end", + "On first screen at 1800x400, the callout is positioned to the right of the parent element" + ); + + startingTop = document.querySelector(calloutSelector).style.top; + browser.contentWindow.resizeTo(1100, 600); + await BrowserTestUtils.waitForMutationCondition( + document.querySelector(calloutSelector), + { attributeFilter: ["style"], attributes: true }, + () => document.querySelector(calloutSelector).style.top != startingTop + ); + await BrowserTestUtils.waitForCondition(() => { + if (getArrowPosition(document) === "top") { + return true; + } + browser.contentWindow.resizeTo(1100, 600); + return false; + }); + is( + getArrowPosition(document), + "top", + "On first screen at 1100x600, the callout is positioned below the parent element" + ); + } + ); + sandbox.restore(); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js b/browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js new file mode 100644 index 0000000000..a4f9c6b65e --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js @@ -0,0 +1,175 @@ +"use strict"; + +add_task( + async function test_firefox_view_tab_pick_up_not_signed_in_targeting() { + ASRouter.resetMessageState(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.firefox-view.feature-tour", `{"screen":"","complete":true}`], + ], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.view-count", 3]], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["identity.fxaccounts.enabled", false]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + await waitForCalloutScreen( + document, + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + ok( + document.querySelector(".featureCallout"), + "Firefox:View Tab Pickup should be displayed." + ); + + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + } + ); + } +); + +add_task( + async function test_firefox_view_tab_pick_up_sync_not_enabled_targeting() { + ASRouter.resetMessageState(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.firefox-view.feature-tour", `{"screen":"","complete":true}`], + ], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.view-count", 3]], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["identity.fxaccounts.enabled", true]], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.engine.tabs", false]], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.username", false]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + await waitForCalloutScreen( + document, + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + ok( + document.querySelector(".featureCallout"), + "Firefox:View Tab Pickup should be displayed." + ); + + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + } + ); + } +); + +add_task( + async function test_firefox_view_tab_pick_up_wait_24_hours_after_spotlight() { + const TWENTY_FIVE_HOURS_IN_MS = 25 * 60 * 60 * 1000; + + ASRouter.resetMessageState(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.firefox-view.feature-tour", `{"screen":"","complete":true}`], + ], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.view-count", 3]], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["identity.fxaccounts.enabled", false]], + }); + + ASRouter.setState({ + messageImpressions: { FIREFOX_VIEW_SPOTLIGHT: [Date.now()] }, + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + ok( + !document.querySelector(".featureCallout"), + "Tab Pickup reminder should not be displayed when the Spotlight message introducing the tour was viewed less than 24 hours ago." + ); + } + ); + + ASRouter.setState({ + messageImpressions: { + FIREFOX_VIEW_SPOTLIGHT: [Date.now() - TWENTY_FIVE_HOURS_IN_MS], + }, + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + launchFeatureTourIn(browser.contentWindow); + + await waitForCalloutScreen( + document, + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + ok( + document.querySelector(".featureCallout"), + "Tab Pickup reminder can be displayed when the Spotlight message introducing the tour was viewed over 24 hours ago." + ); + + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + } + ); + } +); diff --git a/browser/components/firefoxview/tests/browser/browser_feature_callout_theme.js b/browser/components/firefoxview/tests/browser/browser_feature_callout_theme.js new file mode 100644 index 0000000000..f5fd77e4ad --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_theme.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { FeatureCallout } = ChromeUtils.importESModule( + "resource:///modules/asrouter/FeatureCallout.sys.mjs" +); + +async function testCallout(config) { + const featureCallout = new FeatureCallout(config); + const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR"); + const screen = testMessage.message.content.screens[1]; + screen.anchors[0].selector = "body"; + testMessage.message.content.screens = [screen]; + featureCallout.showFeatureCallout(testMessage.message); + await waitForCalloutScreen(config.win.document, screen.id); + testStyles(config); + return { featureCallout }; +} + +function testStyles({ win, theme }) { + const calloutEl = win.document.querySelector(calloutSelector); + const calloutStyle = win.getComputedStyle(calloutEl); + for (const type of ["light", "dark", "hcm"]) { + const appliedTheme = Object.assign( + {}, + FeatureCallout.themePresets[theme.preset], + theme + ); + const scheme = appliedTheme[type]; + for (const name of FeatureCallout.themePropNames) { + Assert.equal( + !!calloutStyle.getPropertyValue(`--fc-${name}-${type}`), + !!(scheme?.[name] || appliedTheme.all?.[name]), + `Theme property --fc-${name}-${type} is set` + ); + } + } +} + +add_task(async function feature_callout_chrome_theme() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + await testCallout({ + win, + location: "chrome", + context: "chrome", + browser: win.gBrowser.selectedBrowser, + theme: { preset: "chrome" }, + }); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function feature_callout_pdfjs_theme() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + await testCallout({ + win, + location: "pdfjs", + context: "chrome", + browser: win.gBrowser.selectedBrowser, + theme: { preset: "pdfjs", simulateContent: true }, + }); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function feature_callout_content_theme() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + browser => + testCallout({ + win: browser.contentWindow, + location: "about:firefoxview", + context: "content", + theme: { preset: "themed-content" }, + }) + ); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_firefoxview.js new file mode 100644 index 0000000000..33467941a4 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function about_firefoxview_smoke_test() { + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + // sanity check the important regions exist on this page + ok( + document.querySelector("fxview-category-navigation"), + "fxview-category-navigation element exists" + ); + ok(document.querySelector("named-deck"), "named-deck element exists"); + }); +}); + +add_task(async function test_aria_roles() { + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + is(document.location.href, "about:firefoxview"); + + is( + document.querySelector("main").getAttribute("role"), + "application", + "The main element has role='application'" + ); + // Purge session history to ensure recently closed empty state is shown + Services.obs.notifyObservers(null, "browser:purge-session-history"); + let recentlyClosedComponent = document.querySelector( + "view-recentlyclosed[slot=recentlyclosed]" + ); + await TestUtils.waitForCondition( + () => recentlyClosedComponent.fullyUpdated + ); + let recentlyClosedEmptyState = recentlyClosedComponent.emptyState; + let descriptionEls = recentlyClosedEmptyState.descriptionEls; + is( + descriptionEls[1].querySelector("a").getAttribute("aria-details"), + "card-container", + "The link within the recently closed empty state has the expected 'aria-details' attribute." + ); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs[slot=syncedtabs]" + ); + let syncedTabsEmptyState = syncedTabsComponent.emptyState; + is( + syncedTabsEmptyState.querySelector("button").getAttribute("aria-details"), + "empty-container", + "The button within the synced tabs empty state has the expected 'aria-details' attribute." + ); + + // Test keyboard navigation from card-container summary + // elements to links/buttons in empty states + const tab = async shiftKey => { + info(`Tab${shiftKey ? " + Shift" : ""}`); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey }); + }; + recentlyClosedComponent.cardEl.summaryEl.focus(); + ok( + recentlyClosedComponent.cardEl.summaryEl.matches(":focus"), + "Focus should be on the summary element within the recently closed card-container" + ); + // Purge session history to ensure recently closed empty state is shown + Services.obs.notifyObservers(null, "browser:purge-session-history"); + await TestUtils.waitForCondition( + () => recentlyClosedComponent.fullyUpdated + ); + await tab(); + ok( + descriptionEls[1].querySelector("a").matches(":focus"), + "Focus should be on the link within the recently closed empty state" + ); + await tab(); + const shadowRoot = + SpecialPowers.wrap(syncedTabsComponent).openOrClosedShadowRoot; + ok( + shadowRoot.querySelector("card-container").summaryEl.matches(":focus"), + "Focus should be on summary element of the synced tabs card-container" + ); + await tab(); + ok( + syncedTabsEmptyState.querySelector("button").matches(":focus"), + "Focus should be on button element of the synced tabs empty state" + ); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_general_telemetry.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_general_telemetry.js new file mode 100644 index 0000000000..51d5caa032 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_general_telemetry.js @@ -0,0 +1,368 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const CARD_COLLAPSED_EVENT = [ + ["firefoxview_next", "card_collapsed", "card_container", undefined], +]; +const CARD_EXPANDED_EVENT = [ + ["firefoxview_next", "card_expanded", "card_container", undefined], +]; +let tabSelectedTelemetry = [ + "firefoxview_next", + "tab_selected", + "toolbarbutton", + undefined, + {}, +]; +let enteredTelemetry = [ + "firefoxview_next", + "entered", + "firefoxview", + undefined, + { page: "recentbrowsing" }, +]; + +add_setup(async () => { + registerCleanupFunction(async () => { + await SpecialPowers.popPrefEnv(); + clearHistory(); + }); +}); + +add_task(async function firefox_view_entered_telemetry() { + await clearAllParentTelemetryEvents(); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + let enteredAndTabSelectedEvents = [tabSelectedTelemetry, enteredTelemetry]; + await telemetryEvent(enteredAndTabSelectedEvents); + + enteredTelemetry[4] = { page: "recentlyclosed" }; + enteredAndTabSelectedEvents = [tabSelectedTelemetry, enteredTelemetry]; + + navigateToCategory(document, "recentlyclosed"); + await clearAllParentTelemetryEvents(); + await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:robots"); + is( + gBrowser.selectedBrowser.currentURI.spec, + "about:robots", + "The selected tab is about:robots" + ); + await switchToFxViewTab(browser.ownerGlobal); + await telemetryEvent(enteredAndTabSelectedEvents); + await SpecialPowers.popPrefEnv(); + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } + }); +}); + +add_task(async function test_collapse_and_expand_card() { + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + // Test using Recently Closed card on Recent Browsing page + let recentlyClosedComponent = document.querySelector( + "view-recentlyclosed[slot=recentlyclosed]" + ); + await TestUtils.waitForCondition( + () => recentlyClosedComponent.fullyUpdated + ); + let cardContainer = recentlyClosedComponent.cardEl; + is( + cardContainer.isExpanded, + true, + "The card-container is expanded initially" + ); + await clearAllParentTelemetryEvents(); + // Click the summary to collapse the details disclosure + EventUtils.synthesizeMouseAtCenter(cardContainer.summaryEl, {}, content); + is( + cardContainer.detailsEl.hasAttribute("open"), + false, + "The card-container is collapsed" + ); + await telemetryEvent(CARD_COLLAPSED_EVENT); + // Click the summary again to expand the details disclosure + EventUtils.synthesizeMouseAtCenter(cardContainer.summaryEl, {}, content); + is( + cardContainer.detailsEl.hasAttribute("open"), + true, + "The card-container is expanded" + ); + await telemetryEvent(CARD_EXPANDED_EVENT); + }); +}); + +add_task(async function test_change_page_telemetry() { + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + let changePageEvent = [ + [ + "firefoxview_next", + "change_page", + "navigation", + undefined, + { page: "recentlyclosed", source: "category-navigation" }, + ], + ]; + await clearAllParentTelemetryEvents(); + navigateToCategory(document, "recentlyclosed"); + await telemetryEvent(changePageEvent); + navigateToCategory(document, "recentbrowsing"); + + let openTabsComponent = document.querySelector( + "view-opentabs[slot=opentabs]" + ); + let cardContainer = + openTabsComponent.shadowRoot.querySelector("view-opentabs-card").cardEl; + let viewAllLink = cardContainer.viewAllLink; + changePageEvent = [ + [ + "firefoxview_next", + "change_page", + "navigation", + undefined, + { page: "opentabs", source: "view-all" }, + ], + ]; + await clearAllParentTelemetryEvents(); + EventUtils.synthesizeMouseAtCenter(viewAllLink, {}, content); + await telemetryEvent(changePageEvent); + }); +}); + +add_task(async function test_browser_context_menu_telemetry() { + const menu = document.getElementById("contentAreaContextMenu"); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await clearAllParentTelemetryEvents(); + + // Test browser context menu options + const openTabsComponent = document.querySelector("view-opentabs"); + await TestUtils.waitForCondition( + () => + openTabsComponent.shadowRoot.querySelector("view-opentabs-card").tabList + .rowEls.length + ); + const [openTabsRow] = + openTabsComponent.shadowRoot.querySelector("view-opentabs-card").tabList + .rowEls; + const promisePopup = BrowserTestUtils.waitForEvent(menu, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + openTabsRow, + { type: "contextmenu" }, + content + ); + await promisePopup; + const promiseNewWindow = BrowserTestUtils.waitForNewWindow(); + menu.activateItem(menu.querySelector("#context-openlink")); + await telemetryEvent([ + [ + "firefoxview_next", + "browser_context_menu", + "tabs", + null, + { menu_action: "context-openlink", page: "recentbrowsing" }, + ], + ]); + + // Clean up extra window + const win = await promiseNewWindow; + await BrowserTestUtils.closeWindow(win); + }); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_context_menu_new_window_telemetry() { + await PlacesUtils.history.insert({ + url: URLs[0], + title: "Example Domain 1", + visits: [{ date: new Date() }], + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + is( + document.location.href, + "about:firefoxview", + "The Recent browsing page is showing." + ); + + // Test history context menu options + await navigateToCategoryAndWait(document, "history"); + let historyComponent = document.querySelector("view-history"); + await TestUtils.waitForCondition(() => historyComponent.fullyUpdated); + await TestUtils.waitForCondition( + () => historyComponent.lists[0].rowEls.length + ); + let firstTabList = historyComponent.lists[0]; + let firstItem = firstTabList.rowEls[0]; + let panelList = historyComponent.panelList; + EventUtils.synthesizeMouseAtCenter(firstItem.buttonEl, {}, content); + await BrowserTestUtils.waitForEvent(panelList, "shown"); + await clearAllParentTelemetryEvents(); + let panelItems = Array.from(panelList.children).filter( + panelItem => panelItem.nodeName === "PANEL-ITEM" + ); + let openInNewWindowOption = panelItems[1]; + let contextMenuEvent = [ + [ + "firefoxview_next", + "context_menu", + "tabs", + undefined, + { menu_action: "open-in-new-window", data_type: "history" }, + ], + ]; + let newWindowPromise = BrowserTestUtils.waitForNewWindow({ + url: URLs[0], + }); + EventUtils.synthesizeMouseAtCenter(openInNewWindowOption, {}, content); + let win = await newWindowPromise; + await telemetryEvent(contextMenuEvent); + await BrowserTestUtils.closeWindow(win); + info("New window closed."); + + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } + }); +}); + +add_task(async function test_context_menu_private_window_telemetry() { + await PlacesUtils.history.insert({ + url: URLs[0], + title: "Example Domain 1", + visits: [{ date: new Date() }], + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + is( + document.location.href, + "about:firefoxview", + "The Recent browsing page is showing." + ); + + // Test history context menu options + await navigateToCategoryAndWait(document, "history"); + let historyComponent = document.querySelector("view-history"); + await TestUtils.waitForCondition(() => historyComponent.fullyUpdated); + await TestUtils.waitForCondition( + () => historyComponent.lists[0].rowEls.length + ); + let firstTabList = historyComponent.lists[0]; + let firstItem = firstTabList.rowEls[0]; + let panelList = historyComponent.panelList; + EventUtils.synthesizeMouseAtCenter(firstItem.buttonEl, {}, content); + await BrowserTestUtils.waitForEvent(panelList, "shown"); + await clearAllParentTelemetryEvents(); + let panelItems = Array.from(panelList.children).filter( + panelItem => panelItem.nodeName === "PANEL-ITEM" + ); + + EventUtils.synthesizeMouseAtCenter(firstItem.buttonEl, {}, content); + info("Context menu button clicked."); + await BrowserTestUtils.waitForEvent(panelList, "shown"); + info("Context menu shown."); + await clearAllParentTelemetryEvents(); + let openInPrivateWindowOption = panelItems[2]; + let contextMenuEvent = [ + [ + "firefoxview_next", + "context_menu", + "tabs", + undefined, + { menu_action: "open-in-private-window", data_type: "history" }, + ], + ]; + let newWindowPromise = BrowserTestUtils.waitForNewWindow({ + url: URLs[0], + }); + EventUtils.synthesizeMouseAtCenter(openInPrivateWindowOption, {}, content); + info("Open in private window context menu option clicked."); + let win = await newWindowPromise; + info("New private window opened."); + await telemetryEvent(contextMenuEvent); + ok( + PrivateBrowsingUtils.isWindowPrivate(win), + "Should have opened a private window." + ); + await BrowserTestUtils.closeWindow(win); + info("New private window closed."); + + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } + }); +}); + +add_task(async function test_context_menu_delete_from_history_telemetry() { + await PlacesUtils.history.clear(); + await PlacesUtils.history.insert({ + url: URLs[0], + title: "Example Domain 1", + visits: [{ date: new Date() }], + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + is( + document.location.href, + "about:firefoxview", + "The Recent browsing page is showing." + ); + + // Test history context menu options + await navigateToCategoryAndWait(document, "history"); + let historyComponent = document.querySelector("view-history"); + await TestUtils.waitForCondition(() => historyComponent.fullyUpdated); + await TestUtils.waitForCondition( + () => historyComponent.lists[0].rowEls.length + ); + let firstTabList = historyComponent.lists[0]; + let firstItem = firstTabList.rowEls[0]; + let panelList = historyComponent.panelList; + EventUtils.synthesizeMouseAtCenter(firstItem.buttonEl, {}, content); + await BrowserTestUtils.waitForEvent(panelList, "shown"); + await clearAllParentTelemetryEvents(); + let panelItems = Array.from(panelList.children).filter( + panelItem => panelItem.nodeName === "PANEL-ITEM" + ); + + EventUtils.synthesizeMouseAtCenter(firstItem.buttonEl, {}, content); + info("Context menu button clicked."); + await BrowserTestUtils.waitForEvent(panelList, "shown"); + info("Context menu shown."); + await clearAllParentTelemetryEvents(); + let deleteFromHistoryOption = panelItems[0]; + ok( + deleteFromHistoryOption.textContent.includes("Delete"), + "Delete from history button is present in the context menu." + ); + let contextMenuEvent = [ + [ + "firefoxview_next", + "context_menu", + "tabs", + undefined, + { menu_action: "delete-from-history", data_type: "history" }, + ], + ]; + EventUtils.synthesizeMouseAtCenter(deleteFromHistoryOption, {}, content); + info("Delete from history context menu option clicked."); + + await TestUtils.waitForCondition( + () => + !historyComponent.paused && + historyComponent.fullyUpdated && + !historyComponent.lists.length + ); + await telemetryEvent(contextMenuEvent); + + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_navigation.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_navigation.js new file mode 100644 index 0000000000..80206dd945 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_navigation.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const URL_BASE = `${getFirefoxViewURL()}#`; + +function assertCorrectPage(document, name, event) { + is( + document.location.hash, + `#${name}`, + `Navigation button for ${name} navigates to ${URL_BASE + name} on ${event}.` + ); + is( + document.querySelector("named-deck").selectedViewName, + name, + "The correct deck child is selected" + ); +} + +add_task(async function test_side_component_navigation_by_click() { + await withFirefoxView({}, async browser => { + await SimpleTest.promiseFocus(browser); + + const { document } = browser.contentWindow; + let win = browser.ownerGlobal; + const categoryButtons = document.querySelectorAll("fxview-category-button"); + + for (let element of categoryButtons) { + const name = element.name; + let buttonClicked = BrowserTestUtils.waitForEvent( + element.buttonEl, + "click", + win + ); + + info(`Clicking navigation button for ${name}`); + EventUtils.synthesizeMouseAtCenter(element.buttonEl, {}, content); + await buttonClicked; + + assertCorrectPage(document, name, "click"); + } + }); +}); + +add_task(async function test_side_component_navigation_by_keyboard() { + await withFirefoxView({}, async browser => { + await SimpleTest.promiseFocus(browser); + + const { document } = browser.contentWindow; + let win = browser.ownerGlobal; + const categoryButtons = document.querySelectorAll("fxview-category-button"); + const firstButton = categoryButtons[0]; + + firstButton.focus(); + is( + document.activeElement, + firstButton, + "The first category button has focus" + ); + + for (let element of Array.from(categoryButtons).slice(1)) { + const name = element.name; + let buttonFocused = BrowserTestUtils.waitForEvent(element, "focus", win); + + info(`Focus is on ${document.activeElement.name}`); + info(`Arrow down on navigation to ${name}`); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + await buttonFocused; + + assertCorrectPage(document, name, "key press"); + } + }); +}); + +add_task(async function test_direct_navigation_to_correct_category() { + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + const categoryButtons = document.querySelectorAll("fxview-category-button"); + const namedDeck = document.querySelector("named-deck"); + + for (let element of categoryButtons) { + const name = element.name; + + info(`Navigating to ${URL_BASE + name}`); + document.location.assign(URL_BASE + name); + await BrowserTestUtils.waitForCondition(() => { + return namedDeck.selectedViewName === name; + }, "Wait for navigation to complete"); + + is( + namedDeck.selectedViewName, + name, + `The correct deck child for category ${name} is selected` + ); + } + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_paused.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_paused.js new file mode 100644 index 0000000000..c95ac4fcf5 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_paused.js @@ -0,0 +1,407 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const tabURL1 = "data:,Tab1"; +const tabURL2 = "data:,Tab2"; +const tabURL3 = "data:,Tab3"; + +const { NonPrivateTabs } = ChromeUtils.importESModule( + "resource:///modules/OpenTabs.sys.mjs" +); +const TestTabs = {}; + +function getTopLevelViewElements(document) { + return { + recentBrowsingView: document.querySelector( + "named-deck > view-recentbrowsing" + ), + recentlyClosedView: document.querySelector( + "named-deck > view-recentlyclosed" + ), + openTabsView: document.querySelector("named-deck > view-opentabs"), + }; +} + +async function getElements(document) { + let { recentBrowsingView, recentlyClosedView, openTabsView } = + getTopLevelViewElements(document); + let recentBrowsingOpenTabsView = + recentBrowsingView.querySelector("view-opentabs"); + let recentBrowsingOpenTabsList = + recentBrowsingOpenTabsView?.viewCards[0]?.tabList; + let recentBrowsingRecentlyClosedTabsView = recentBrowsingView.querySelector( + "view-recentlyclosed" + ); + await TestUtils.waitForCondition( + () => recentBrowsingRecentlyClosedTabsView.fullyUpdated + ); + let recentBrowsingRecentlyClosedTabsList = + recentBrowsingRecentlyClosedTabsView?.tabList; + if (recentlyClosedView.firstUpdateComplete) { + await TestUtils.waitForCondition(() => recentlyClosedView.fullyUpdated); + } + let recentlyClosedList = recentlyClosedView.tabList; + await openTabsView.openTabsTarget.readyWindowsPromise; + await openTabsView.updateComplete; + let openTabsList = + openTabsView.shadowRoot.querySelector("view-opentabs-card")?.tabList; + + return { + // recentbrowsing + recentBrowsingView, + recentBrowsingOpenTabsView, + recentBrowsingOpenTabsList, + recentBrowsingRecentlyClosedTabsView, + recentBrowsingRecentlyClosedTabsList, + + // recentlyclosed + recentlyClosedView, + recentlyClosedList, + + // opentabs + openTabsView, + openTabsList, + }; +} + +async function nextFrame(global = window) { + await new Promise(resolve => { + global.requestAnimationFrame(() => { + global.requestAnimationFrame(resolve); + }); + }); +} + +async function setupOpenAndClosedTabs() { + TestTabs.tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + tabURL1 + ); + TestTabs.tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + tabURL2 + ); + TestTabs.tab3 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + tabURL3 + ); + // close a tab so we have recently-closed tabs content + await SessionStoreTestUtils.closeTab(TestTabs.tab3); +} + +function assertSpiesCalled(spiesMap, expectCalled) { + let message = expectCalled ? "to be called" : "to not be called"; + for (let [elem, renderSpy] of spiesMap.entries()) { + is( + expectCalled, + renderSpy.called, + `Expected the render method spy on element ${elem.localName} ${message}` + ); + } +} + +async function checkFxRenderCalls(browser, elements, selectedView) { + const sandbox = sinon.createSandbox(); + const topLevelViews = getTopLevelViewElements(browser.contentDocument); + + // sanity-check the selectedView we were given + ok( + Object.values(topLevelViews).find(view => view == selectedView), + `The selected view is in the topLevelViews` + ); + + const elementSpies = new Map(); + const viewSpies = new Map(); + + for (let [elemName, elem] of Object.entries(topLevelViews)) { + let spy; + if (elem.render.isSinonProxy) { + spy = elem.render; + } else { + info(`Creating spy for render on element: ${elemName}`); + spy = sandbox.spy(elem, "render"); + } + viewSpies.set(elem, spy); + } + for (let [elemName, elem] of Object.entries(elements)) { + let spy; + if (elem.render.isSinonProxy) { + spy = elem.render; + } else { + info(`Creating spy for render on element: ${elemName}`); + spy = sandbox.spy(elem, "render"); + } + elementSpies.set(elem, spy); + } + + info("test switches to tab2"); + let tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabRecencyChange" + ); + await BrowserTestUtils.switchTab(gBrowser, TestTabs.tab2); + await tabChangeRaised; + info( + "TabRecencyChange event was raised, check no render() methods were called" + ); + assertSpiesCalled(viewSpies, false); + assertSpiesCalled(elementSpies, false); + for (let renderSpy of [...viewSpies.values(), ...elementSpies.values()]) { + renderSpy.resetHistory(); + } + + // check all the top-level views are paused + ok( + topLevelViews.recentBrowsingView.paused, + "The recent-browsing view is paused" + ); + ok( + topLevelViews.recentlyClosedView.paused, + "The recently-closed tabs view is paused" + ); + ok(topLevelViews.openTabsView.paused, "The open tabs view is paused"); + + await nextFrame(); + info("test removes tab1"); + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabRecencyChange" + ); + await BrowserTestUtils.removeTab(TestTabs.tab1); + await tabChangeRaised; + + assertSpiesCalled(viewSpies, false); + assertSpiesCalled(elementSpies, false); + + for (let renderSpy of [...viewSpies.values(), ...elementSpies.values()]) { + renderSpy.resetHistory(); + } + + info("test will re-open fxview"); + await openFirefoxViewTab(window); + await nextFrame(); + + assertSpiesCalled(elementSpies, true); + ok( + selectedView.render.called, + `Render was called on the selected top-level view: ${selectedView.localName}` + ); + + // check all the other views did not render + viewSpies.delete(selectedView); + assertSpiesCalled(viewSpies, false); + + sandbox.restore(); +} + +add_task(async function test_recentbrowsing() { + await setupOpenAndClosedTabs(); + + await withFirefoxView({}, async browser => { + const document = browser.contentDocument; + is(document.querySelector("named-deck").selectedViewName, "recentbrowsing"); + + const { + recentBrowsingView, + recentBrowsingOpenTabsView, + recentBrowsingOpenTabsList, + recentBrowsingRecentlyClosedTabsView, + recentBrowsingRecentlyClosedTabsList, + } = await getElements(document); + + ok(recentBrowsingView, "Found the recent-browsing view"); + ok(recentBrowsingOpenTabsView, "Found the recent-browsing open tabs view"); + ok(recentBrowsingOpenTabsList, "Found the recent-browsing open tabs list"); + ok( + recentBrowsingRecentlyClosedTabsView, + "Found the recent-browsing recently-closed tabs view" + ); + ok( + recentBrowsingRecentlyClosedTabsList, + "Found the recent-browsing recently-closed tabs list" + ); + + // Collapse the Open Tabs card + let cardContainer = recentBrowsingOpenTabsView.viewCards[0]?.cardEl; + await EventUtils.synthesizeMouseAtCenter( + cardContainer.summaryEl, + {}, + content + ); + await TestUtils.waitForCondition( + () => !cardContainer.detailsEl.hasAttribute("open") + ); + + ok( + recentBrowsingOpenTabsList.updatesPaused, + "The Open Tabs list is paused after its card is collapsed." + ); + ok( + !recentBrowsingOpenTabsList.intervalID, + "The intervalID for the Open Tabs list is undefined while updates are paused." + ); + + // Expand the Open Tabs card + await EventUtils.synthesizeMouseAtCenter( + cardContainer.summaryEl, + {}, + content + ); + await TestUtils.waitForCondition(() => + cardContainer.detailsEl.hasAttribute("open") + ); + + ok( + !recentBrowsingOpenTabsList.updatesPaused, + "The Open Tabs list is unpaused after its card is expanded." + ); + ok( + recentBrowsingOpenTabsList.intervalID, + "The intervalID for the Open Tabs list is defined while updates are unpaused." + ); + + // Collapse the Recently Closed card + let recentlyClosedCardContainer = + recentBrowsingRecentlyClosedTabsView.cardEl; + await EventUtils.synthesizeMouseAtCenter( + recentlyClosedCardContainer.summaryEl, + {}, + content + ); + await TestUtils.waitForCondition( + () => !recentlyClosedCardContainer.detailsEl.hasAttribute("open") + ); + + ok( + recentBrowsingRecentlyClosedTabsList.updatesPaused, + "The Recently Closed list is paused after its card is collapsed." + ); + ok( + !recentBrowsingRecentlyClosedTabsList.intervalID, + "The intervalID for the Open Tabs list is undefined while updates are paused." + ); + + // Expand the Recently Closed card + await EventUtils.synthesizeMouseAtCenter( + recentlyClosedCardContainer.summaryEl, + {}, + content + ); + await TestUtils.waitForCondition(() => + recentlyClosedCardContainer.detailsEl.hasAttribute("open") + ); + + ok( + !recentBrowsingRecentlyClosedTabsList.updatesPaused, + "The Recently Closed list is unpaused after its card is expanded." + ); + ok( + recentBrowsingRecentlyClosedTabsList.intervalID, + "The intervalID for the Recently Closed list is defined while updates are unpaused." + ); + + await checkFxRenderCalls( + browser, + { + recentBrowsingView, + recentBrowsingOpenTabsView, + recentBrowsingOpenTabsList, + recentBrowsingRecentlyClosedTabsView, + recentBrowsingRecentlyClosedTabsList, + }, + recentBrowsingView + ); + }); + await BrowserTestUtils.removeTab(TestTabs.tab2); +}); + +add_task(async function test_opentabs() { + await setupOpenAndClosedTabs(); + + await withFirefoxView({}, async browser => { + const document = browser.contentDocument; + const { openTabsView } = getTopLevelViewElements(document); + + await navigateToCategoryAndWait(document, "opentabs"); + + const { openTabsList } = await getElements(document); + ok(openTabsView, "Found the open tabs view"); + ok(openTabsList, "Found the first open tabs list"); + ok(!openTabsView.paused, "The open tabs view is un-paused"); + is(openTabsView.slot, "selected", "The open tabs view is selected"); + + // Collapse the Open Tabs card + let cardContainer = openTabsView.viewCards[0]?.cardEl; + await EventUtils.synthesizeMouseAtCenter( + cardContainer.summaryEl, + {}, + content + ); + await TestUtils.waitForCondition( + () => !cardContainer.detailsEl.hasAttribute("open") + ); + + ok( + openTabsList.updatesPaused, + "The Open Tabs list is paused after its card is collapsed." + ); + ok( + !openTabsList.intervalID, + "The intervalID for the Open Tabs list is undefined while updates are paused." + ); + + // Expand the Open Tabs card + await EventUtils.synthesizeMouseAtCenter( + cardContainer.summaryEl, + {}, + content + ); + await TestUtils.waitForCondition(() => + cardContainer.detailsEl.hasAttribute("open") + ); + + ok( + !openTabsList.updatesPaused, + "The Open Tabs list is unpaused after its card is expanded." + ); + ok( + openTabsList.intervalID, + "The intervalID for the Open Tabs list is defined while updates are unpaused." + ); + + await checkFxRenderCalls( + browser, + { + openTabsView, + openTabsList, + }, + openTabsView + ); + }); + await BrowserTestUtils.removeTab(TestTabs.tab2); +}); + +add_task(async function test_recentlyclosed() { + await setupOpenAndClosedTabs(); + + await withFirefoxView({}, async browser => { + const document = browser.contentDocument; + const { recentlyClosedView } = getTopLevelViewElements(document); + await navigateToCategoryAndWait(document, "recentlyclosed"); + + const { recentlyClosedList } = await getElements(document); + ok(recentlyClosedView, "Found the recently-closed view"); + ok(recentlyClosedList, "Found the recently-closed list"); + ok(!recentlyClosedView.paused, "The recently-closed view is un-paused"); + + await checkFxRenderCalls( + browser, + { + recentlyClosedView, + recentlyClosedList, + }, + recentlyClosedView + ); + }); + await BrowserTestUtils.removeTab(TestTabs.tab2); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_search_telemetry.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_search_telemetry.js new file mode 100644 index 0000000000..2ea2429c15 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_search_telemetry.js @@ -0,0 +1,629 @@ +let gInitialTab; +let gInitialTabURL; + +const NUMBER_OF_TABS = 6; + +const syncedTabsData = [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + tabs: Array(NUMBER_OF_TABS) + .fill({ + type: "tab", + title: "Internet for people, not profits - Mozilla", + icon: "https://www.mozilla.org/media/img/favicons/mozilla/favicon.d25d81d39065.ico", + client: 1, + }) + .map((tab, i) => ({ ...tab, url: URLs[i] })), + }, +]; + +const searchEvent = page => { + return [ + ["firefoxview_next", "search_initiated", "search", undefined, { page }], + ]; +}; + +const cleanUp = () => { + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } +}; + +add_setup(async () => { + gInitialTab = gBrowser.selectedTab; + gInitialTabURL = gBrowser.selectedBrowser.currentURI.spec; + registerCleanupFunction(async () => { + clearHistory(); + }); +}); + +add_task(async function test_search_initiated_telemetry() { + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await clearAllParentTelemetryEvents(); + + is(document.location.hash, "", "Searching within recent browsing."); + const recentBrowsing = document.querySelector("view-recentbrowsing"); + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter( + recentBrowsing.searchTextbox, + {}, + content + ); + EventUtils.sendString("example.com", content); + await telemetryEvent(searchEvent("recentbrowsing")); + + await navigateToCategoryAndWait(document, "opentabs"); + await clearAllParentTelemetryEvents(); + is(document.location.hash, "#opentabs", "Searching within open tabs."); + const openTabs = document.querySelector("named-deck > view-opentabs"); + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter(openTabs.searchTextbox, {}, content); + EventUtils.sendString("example.com", content); + await telemetryEvent(searchEvent("opentabs")); + + await navigateToCategoryAndWait(document, "recentlyclosed"); + await clearAllParentTelemetryEvents(); + is( + document.location.hash, + "#recentlyclosed", + "Searching within recently closed." + ); + const recentlyClosed = document.querySelector( + "named-deck > view-recentlyclosed" + ); + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter( + recentlyClosed.searchTextbox, + {}, + content + ); + EventUtils.sendString("example.com", content); + await telemetryEvent(searchEvent("recentlyclosed")); + + await navigateToCategoryAndWait(document, "syncedtabs"); + await clearAllParentTelemetryEvents(); + is(document.location.hash, "#syncedtabs", "Searching within synced tabs."); + const syncedTabs = document.querySelector("named-deck > view-syncedtabs"); + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter(syncedTabs.searchTextbox, {}, content); + EventUtils.sendString("example.com", content); + await telemetryEvent(searchEvent("syncedtabs")); + + await navigateToCategoryAndWait(document, "history"); + await clearAllParentTelemetryEvents(); + is(document.location.hash, "#history", "Searching within history."); + const history = document.querySelector("named-deck > view-history"); + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter(history.searchTextbox, {}, content); + EventUtils.sendString("example.com", content); + await telemetryEvent(searchEvent("history")); + + await clearAllParentTelemetryEvents(); + }); +}); + +add_task(async function test_show_all_recentlyclosed_telemetry() { + for (let i = 0; i < NUMBER_OF_TABS; i++) { + await open_then_close(URLs[1]); + } + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + const recentBrowsing = document.querySelector("view-recentbrowsing"); + + info("Input a search query."); + EventUtils.synthesizeMouseAtCenter( + recentBrowsing.searchTextbox, + {}, + content + ); + EventUtils.sendString("example.com", content); + const recentlyclosedSlot = recentBrowsing.querySelector( + "[slot='recentlyclosed']" + ); + await TestUtils.waitForCondition( + () => + recentlyclosedSlot.tabList.rowEls.length === 5 && + recentlyclosedSlot.shadowRoot.querySelector( + "[data-l10n-id='firefoxview-show-all']" + ), + "Expected search results are not shown yet." + ); + await clearAllParentTelemetryEvents(); + + info("Click the Show All link."); + const showAllButton = recentlyclosedSlot.shadowRoot.querySelector( + "[data-l10n-id='firefoxview-show-all']" + ); + await TestUtils.waitForCondition(() => !showAllButton.hidden); + ok(!showAllButton.hidden, "Show all button is visible"); + await TestUtils.waitForCondition(() => { + EventUtils.synthesizeMouseAtCenter(showAllButton, {}, content); + if (recentlyclosedSlot.tabList.rowEls.length === NUMBER_OF_TABS) { + return true; + } + return false; + }, "All search results are not shown."); + + await telemetryEvent([ + [ + "firefoxview_next", + "search_show_all", + "showallbutton", + null, + { section: "recentlyclosed" }, + ], + ]); + }); +}); + +add_task(async function test_show_all_opentabs_telemetry() { + for (let i = 0; i < NUMBER_OF_TABS; i++) { + await BrowserTestUtils.openNewForegroundTab(gBrowser, URLs[1]); + } + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + const recentBrowsing = document.querySelector("view-recentbrowsing"); + + info("Input a search query."); + EventUtils.synthesizeMouseAtCenter( + recentBrowsing.searchTextbox, + {}, + content + ); + EventUtils.sendString(URLs[1], content); + const opentabsSlot = recentBrowsing.querySelector("[slot='opentabs']"); + await TestUtils.waitForCondition( + () => opentabsSlot.viewCards[0].tabList.rowEls.length === 5, + "Expected search results are not shown yet." + ); + await clearAllParentTelemetryEvents(); + + info("Click the Show All link."); + const showAllButton = opentabsSlot.viewCards[0].shadowRoot.querySelector( + "[data-l10n-id='firefoxview-show-all']" + ); + await TestUtils.waitForCondition(() => !showAllButton.hidden); + ok(!showAllButton.hidden, "Show all button is visible"); + await TestUtils.waitForCondition(() => { + EventUtils.synthesizeMouseAtCenter(showAllButton, {}, content); + if (opentabsSlot.viewCards[0].tabList.rowEls.length === NUMBER_OF_TABS) { + return true; + } + return false; + }, "All search results are not shown."); + + await telemetryEvent([ + [ + "firefoxview_next", + "search_initiated", + "search", + null, + { page: "recentbrowsing" }, + ], + [ + "firefoxview_next", + "search_show_all", + "showallbutton", + null, + { section: "opentabs" }, + ], + ]); + }); + + await SimpleTest.promiseFocus(window); + await promiseAllButPrimaryWindowClosed(); + await BrowserTestUtils.switchTab(gBrowser, gInitialTab); + await closeFirefoxViewTab(window); + + cleanUp(); +}); + +add_task(async function test_show_all_syncedtabs_telemetry() { + TabsSetupFlowManager.resetInternalState(); + + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData); + let getRecentTabsResult = mockTabs1; + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n` + ); + return Promise.resolve(getRecentTabsResult); + }); + sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => { + return Promise.resolve(syncedTabsData); + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + const recentBrowsing = document.querySelector("view-recentbrowsing"); + + info("Input a search query."); + EventUtils.synthesizeMouseAtCenter( + recentBrowsing.searchTextbox, + {}, + content + ); + EventUtils.sendString("mozilla", content); + const syncedtabsSlot = recentBrowsing.querySelector("[slot='syncedtabs']"); + await TestUtils.waitForCondition( + () => + syncedtabsSlot.fullyUpdated && + syncedtabsSlot.tabLists.length === 1 && + Promise.all( + Array.from(syncedtabsSlot.tabLists).map( + tabList => tabList.updateComplete + ) + ), + "Synced Tabs component is done updating." + ); + syncedtabsSlot.tabLists[0].scrollIntoView(); + await TestUtils.waitForCondition( + () => syncedtabsSlot.tabLists[0].rowEls.length === 5, + "Expected search results are not shown yet." + ); + await clearAllParentTelemetryEvents(); + + const showAllButton = await TestUtils.waitForCondition(() => + syncedtabsSlot.shadowRoot.querySelector( + "[data-l10n-id='firefoxview-show-all']" + ) + ); + info("Scroll show all button into view."); + showAllButton.scrollIntoView(); + await TestUtils.waitForCondition(() => !showAllButton.hidden); + ok(!showAllButton.hidden, "Show all button is visible"); + info("Click the Show All link."); + await TestUtils.waitForCondition(() => { + EventUtils.synthesizeMouseAtCenter(showAllButton, {}, content); + if (syncedtabsSlot.tabLists[0].rowEls.length === NUMBER_OF_TABS) { + return true; + } + return false; + }, "All search results are not shown."); + + await telemetryEvent([ + [ + "firefoxview_next", + "search_initiated", + "search", + null, + { page: "recentbrowsing" }, + ], + [ + "firefoxview_next", + "search_show_all", + "showallbutton", + null, + { section: "syncedtabs" }, + ], + ]); + }); + + await tearDown(sandbox); +}); + +add_task(async function test_sort_history_search_telemetry() { + for (let i = 0; i < NUMBER_OF_TABS; i++) { + await open_then_close(URLs[i]); + } + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToCategoryAndWait(document, "history"); + const historyComponent = document.querySelector("view-history"); + + const searchTextbox = await TestUtils.waitForCondition( + () => historyComponent.searchTextbox, + "The search textbox is displayed." + ); + + info("Input a search query."); + EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content); + EventUtils.sendString("example.com", content); + await TestUtils.waitForCondition(() => { + const { rowEls } = historyComponent.lists[0]; + return rowEls.length === 1; + }, "There is one matching search result."); + await clearAllParentTelemetryEvents(); + // Select sort by site option + await EventUtils.synthesizeMouseAtCenter( + historyComponent.sortInputs[1], + {}, + content + ); + await TestUtils.waitForCondition(() => historyComponent.fullyUpdated); + await telemetryEvent([ + [ + "firefoxview_next", + "sort_history", + "tabs", + null, + { sort_type: "site", search_start: "true" }, + ], + ]); + await clearAllParentTelemetryEvents(); + + // Select sort by date option + await EventUtils.synthesizeMouseAtCenter( + historyComponent.sortInputs[0], + {}, + content + ); + await TestUtils.waitForCondition(() => historyComponent.fullyUpdated); + await telemetryEvent([ + [ + "firefoxview_next", + "sort_history", + "tabs", + null, + { sort_type: "date", search_start: "true" }, + ], + ]); + }); +}); + +add_task(async function test_cumulative_searches_recent_browsing_telemetry() { + const cumulativeSearchesHistogram = + TelemetryTestUtils.getAndClearKeyedHistogram( + "FIREFOX_VIEW_CUMULATIVE_SEARCHES" + ); + await PlacesUtils.history.clear(); + await open_then_close(URLs[0]); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + is(document.location.hash, "", "Searching within recent browsing."); + const recentBrowsing = document.querySelector("view-recentbrowsing"); + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter( + recentBrowsing.searchTextbox, + {}, + content + ); + EventUtils.sendString(URLs[0], content); + const recentlyclosedSlot = recentBrowsing.querySelector( + "[slot='recentlyclosed']" + ); + await TestUtils.waitForCondition( + () => + recentlyclosedSlot?.tabList?.rowEls?.length && + recentlyclosedSlot?.searchQuery, + "Expected search results are not shown yet." + ); + + EventUtils.synthesizeMouseAtCenter( + recentlyclosedSlot.tabList.rowEls[0].mainEl, + {}, + content + ); + await TestUtils.waitForCondition( + () => "recentbrowsing" in cumulativeSearchesHistogram.snapshot(), + `recentbrowsing key not found in cumulativeSearchesHistogram snapshot: ${JSON.stringify( + cumulativeSearchesHistogram.snapshot() + )}` + ); + TelemetryTestUtils.assertKeyedHistogramValue( + cumulativeSearchesHistogram, + "recentbrowsing", + 1, + 1 + ); + }); + + cleanUp(); +}); + +add_task(async function test_cumulative_searches_recently_closed_telemetry() { + const cumulativeSearchesHistogram = + TelemetryTestUtils.getAndClearKeyedHistogram( + "FIREFOX_VIEW_CUMULATIVE_SEARCHES" + ); + await PlacesUtils.history.clear(); + await open_then_close(URLs[0]); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + await navigateToCategoryAndWait(document, "recentlyclosed"); + is( + document.location.hash, + "#recentlyclosed", + "Searching within recently closed." + ); + const recentlyClosed = document.querySelector( + "named-deck > view-recentlyclosed" + ); + const searchTextbox = await TestUtils.waitForCondition(() => { + return recentlyClosed.searchTextbox; + }); + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content); + EventUtils.sendString(URLs[0], content); + // eslint-disable-next-line no-unused-vars + const [recentlyclosedSlot, tabList] = await waitForRecentlyClosedTabsList( + document + ); + await TestUtils.waitForCondition(() => recentlyclosedSlot?.searchQuery); + + await click_recently_closed_tab_item(tabList[0]); + + TelemetryTestUtils.assertKeyedHistogramValue( + cumulativeSearchesHistogram, + "recentlyclosed", + 1, + 1 + ); + }); + + cleanUp(); +}); + +add_task(async function test_cumulative_searches_open_tabs_telemetry() { + const cumulativeSearchesHistogram = + TelemetryTestUtils.getAndClearKeyedHistogram( + "FIREFOX_VIEW_CUMULATIVE_SEARCHES" + ); + await PlacesUtils.history.clear(); + await BrowserTestUtils.openNewForegroundTab(gBrowser, URLs[0]); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + await navigateToCategoryAndWait(document, "opentabs"); + is(document.location.hash, "#opentabs", "Searching within open tabs."); + const openTabs = document.querySelector("named-deck > view-opentabs"); + + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter(openTabs.searchTextbox, {}, content); + EventUtils.sendString(URLs[0], content); + let cards; + await TestUtils.waitForCondition(() => { + cards = getOpenTabsCards(openTabs); + return cards.length == 1; + }); + await TestUtils.waitForCondition( + () => cards[0].tabList.rowEls.length === 1 && openTabs?.searchQuery, + "Expected search results are not shown yet." + ); + + info("Click a search result tab"); + EventUtils.synthesizeMouseAtCenter( + cards[0].tabList.rowEls[0].mainEl, + {}, + content + ); + }); + + TelemetryTestUtils.assertKeyedHistogramValue( + cumulativeSearchesHistogram, + "opentabs", + 1, + 1 + ); + + cleanUp(); +}); + +add_task(async function test_cumulative_searches_history_telemetry() { + const cumulativeSearchesHistogram = + TelemetryTestUtils.getAndClearKeyedHistogram( + "FIREFOX_VIEW_CUMULATIVE_SEARCHES" + ); + await PlacesUtils.history.clear(); + await open_then_close(URLs[0]); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + await navigateToCategoryAndWait(document, "history"); + is(document.location.hash, "#history", "Searching within history."); + const history = document.querySelector("named-deck > view-history"); + const searchTextbox = await TestUtils.waitForCondition(() => { + return history.searchTextbox; + }); + + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content); + EventUtils.sendString(URLs[0], content); + await TestUtils.waitForCondition( + () => + history.fullyUpdated && + history?.lists[0].rowEls?.length === 1 && + history?.searchQuery, + "Expected search results are not shown yet." + ); + + info("Click a search result tab"); + EventUtils.synthesizeMouseAtCenter( + history.lists[0].rowEls[0].mainEl, + {}, + content + ); + + TelemetryTestUtils.assertKeyedHistogramValue( + cumulativeSearchesHistogram, + "history", + 1, + 1 + ); + }); + + cleanUp(); +}); + +add_task(async function test_cumulative_searches_syncedtabs_telemetry() { + const cumulativeSearchesHistogram = + TelemetryTestUtils.getAndClearKeyedHistogram( + "FIREFOX_VIEW_CUMULATIVE_SEARCHES" + ); + await PlacesUtils.history.clear(); + TabsSetupFlowManager.resetInternalState(); + + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData); + let getRecentTabsResult = mockTabs1; + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n` + ); + return Promise.resolve(getRecentTabsResult); + }); + sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => { + return Promise.resolve(syncedTabsData); + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + await navigateToCategoryAndWait(document, "syncedtabs"); + is(document.location.hash, "#syncedtabs", "Searching within synced tabs."); + let syncedTabs = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + + info("Input a search query."); + EventUtils.synthesizeMouseAtCenter(syncedTabs.searchTextbox, {}, content); + EventUtils.sendString(URLs[0], content); + await TestUtils.waitForCondition( + () => + syncedTabs.fullyUpdated && + syncedTabs.tabLists.length === 1 && + Promise.all( + Array.from(syncedTabs.tabLists).map(tabList => tabList.updateComplete) + ), + "Synced Tabs component is done updating." + ); + await TestUtils.waitForCondition( + () => + syncedTabs.tabLists[0].rowEls.length === 1 && syncedTabs?.searchQuery, + "Expected search results are not shown yet." + ); + + info("Click a search result tab"); + EventUtils.synthesizeMouseAtCenter( + syncedTabs.tabLists[0].rowEls[0].mainEl, + {}, + content + ); + + TelemetryTestUtils.assertKeyedHistogramValue( + cumulativeSearchesHistogram, + "syncedtabs", + 1, + 1 + ); + }); + + cleanUp(); + await tearDown(sandbox); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js new file mode 100644 index 0000000000..f1ac7d6742 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js @@ -0,0 +1,370 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +async function expectFocusAfterKey( + aKey, + aFocus, + aAncestorOk = false, + aWindow = window +) { + let res = aKey.match(/^(Shift\+)?(?:(.)|(.+))$/); + let shift = Boolean(res[1]); + let key; + if (res[2]) { + key = res[2]; // Character. + } else { + key = "KEY_" + res[3]; // Tab, ArrowRight, etc. + } + let expected; + let friendlyExpected; + if (typeof aFocus == "string") { + expected = aWindow.document.getElementById(aFocus); + friendlyExpected = aFocus; + } else { + expected = aFocus; + if (aFocus == aWindow.gURLBar.inputField) { + friendlyExpected = "URL bar input"; + } else if (aFocus == aWindow.gBrowser.selectedBrowser) { + friendlyExpected = "Web document"; + } + } + info("Listening on item " + (expected.id || expected.className)); + let focused = BrowserTestUtils.waitForEvent(expected, "focus", aAncestorOk); + EventUtils.synthesizeKey(key, { shiftKey: shift }, aWindow); + let receivedEvent = await focused; + info( + "Got focus on item: " + + (receivedEvent.target.id || receivedEvent.target.className) + ); + ok(true, friendlyExpected + " focused after " + aKey + " pressed"); +} + +function forceFocus(aElem) { + aElem.setAttribute("tabindex", "-1"); + aElem.focus(); + aElem.removeAttribute("tabindex"); +} + +function triggerClickOn(target, options) { + let promise = BrowserTestUtils.waitForEvent(target, "click"); + if (AppConstants.platform == "macosx") { + options.metaKey = options.ctrlKey; + delete options.ctrlKey; + } + EventUtils.synthesizeMouseAtCenter(target, options); + return promise; +} + +async function add_new_tab(URL) { + let tab = BrowserTestUtils.addTab(gBrowser, URL); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + return tab; +} + +add_task(async function aria_attributes() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + is( + win.FirefoxViewHandler.button.getAttribute("role"), + "button", + "Firefox View button should have the 'button' ARIA role" + ); + await openFirefoxViewTab(win); + isnot( + win.FirefoxViewHandler.button.getAttribute("aria-controls"), + "", + "Firefox View button should have non-empty `aria-controls` attribute" + ); + is( + win.FirefoxViewHandler.button.getAttribute("aria-controls"), + win.FirefoxViewHandler.tab.linkedPanel, + "Firefox View button should refence the hidden tab's linked panel via `aria-controls`" + ); + is( + win.FirefoxViewHandler.button.getAttribute("aria-pressed"), + "true", + 'Firefox View button should have `aria-pressed="true"` upon selecting it' + ); + win.BrowserOpenTab(); + is( + win.FirefoxViewHandler.button.getAttribute("aria-pressed"), + "false", + 'Firefox View button should have `aria-pressed="false"` upon selecting a different tab' + ); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function load_opens_new_tab() { + await withFirefoxView({ openNewWindow: true }, async browser => { + let win = browser.ownerGlobal; + ok(win.FirefoxViewHandler.tab.selected, "Firefox View tab is selected"); + win.gURLBar.focus(); + win.gURLBar.value = "https://example.com"; + let newTabOpened = BrowserTestUtils.waitForEvent( + win.gBrowser.tabContainer, + "TabOpen" + ); + EventUtils.synthesizeKey("KEY_Enter", {}, win); + info( + "Waiting for new tab to open from the address bar in the Firefox View tab" + ); + await newTabOpened; + assertFirefoxViewTab(win); + ok( + !win.FirefoxViewHandler.tab.selected, + "Firefox View tab is not selected anymore (new tab opened in the foreground)" + ); + }); +}); + +add_task(async function homepage_new_tab() { + await withFirefoxView({ openNewWindow: true }, async browser => { + let win = browser.ownerGlobal; + ok(win.FirefoxViewHandler.tab.selected, "Firefox View tab is selected"); + let newTabOpened = BrowserTestUtils.waitForEvent( + win.gBrowser.tabContainer, + "TabOpen" + ); + win.BrowserHome(); + info("Waiting for BrowserHome() to open a new tab"); + await newTabOpened; + assertFirefoxViewTab(win); + ok( + !win.FirefoxViewHandler.tab.selected, + "Firefox View tab is not selected anymore (home page opened in the foreground)" + ); + }); +}); + +add_task(async function number_tab_select_shortcut() { + await withFirefoxView({}, async browser => { + let win = browser.ownerGlobal; + EventUtils.synthesizeKey( + "1", + AppConstants.MOZ_WIDGET_GTK ? { altKey: true } : { accelKey: true }, + win + ); + ok( + !win.FirefoxViewHandler.tab.selected, + "Number shortcut to select the first tab skipped the Firefox View tab" + ); + }); +}); + +add_task(async function accel_w_behavior() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await openFirefoxViewTab(win); + EventUtils.synthesizeKey("w", { accelKey: true }, win); + ok(!win.FirefoxViewHandler.tab, "Accel+w closed the Firefox View tab"); + await openFirefoxViewTab(win); + win.gBrowser.selectedTab = win.gBrowser.visibleTabs[0]; + info( + "Waiting for Accel+W in the only visible tab to close the window, ignoring the presence of the hidden Firefox View tab" + ); + let windowClosed = BrowserTestUtils.windowClosed(win); + EventUtils.synthesizeKey("w", { accelKey: true }, win); + await windowClosed; +}); + +add_task(async function undo_close_tab() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is( + SessionStore.getClosedTabCountForWindow(win), + 0, + "Closed tab count after purging session history" + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "about:about" + ); + await TestUtils.waitForTick(); + + let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + win.gBrowser.removeTab(tab); + await sessionUpdatePromise; + is( + SessionStore.getClosedTabCountForWindow(win), + 1, + "Closing about:about added to the closed tab count" + ); + + let viewTab = await openFirefoxViewTab(win); + await TestUtils.waitForTick(); + sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(viewTab); + closeFirefoxViewTab(win); + await sessionUpdatePromise; + is( + SessionStore.getClosedTabCountForWindow(win), + 1, + "Closing the Firefox View tab did not add to the closed tab count" + ); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_firefoxview_view_count() { + const startViews = 2; + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.view-count", startViews]], + }); + + let tab = await openFirefoxViewTab(window); + + Assert.strictEqual( + SpecialPowers.getIntPref("browser.firefox-view.view-count"), + startViews + 1, + "View count pref value is incremented when tab is selected" + ); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_add_ons_cant_unhide_fx_view() { + // Test that add-ons can't unhide the Firefox View tab by calling + // browser.tabs.show(). See bug 1791770 for details. + let win = await BrowserTestUtils.openNewBrowserWindow(); + let tab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "about:about" + ); + let viewTab = await openFirefoxViewTab(win); + win.gBrowser.hideTab(tab); + + ok(tab.hidden, "Regular tab is hidden"); + ok(viewTab.hidden, "Firefox View tab is hidden"); + + win.gBrowser.showTab(tab); + win.gBrowser.showTab(viewTab); + + ok(!tab.hidden, "Add-on showed regular hidden tab"); + ok(viewTab.hidden, "Add-on did not show Firefox View tab"); + + await BrowserTestUtils.closeWindow(win); +}); + +// Test navigation to first visible tab when the +// Firefox View button is present and active. +add_task(async function testFirstTabFocusableWhenFxViewOpen() { + await withFirefoxView({}, async browser => { + let win = browser.ownerGlobal; + ok(win.FirefoxViewHandler.tab.selected, "Firefox View tab is selected"); + let fxViewBtn = win.document.getElementById("firefox-view-button"); + forceFocus(fxViewBtn); + is( + win.document.activeElement, + fxViewBtn, + "Firefox View button focused for start of test" + ); + let firstVisibleTab = win.gBrowser.visibleTabs[0]; + await expectFocusAfterKey("Tab", firstVisibleTab, false, win); + let activeElement = win.document.activeElement; + let expectedElement = firstVisibleTab; + is(activeElement, expectedElement, "First visible tab should be focused"); + }); +}); + +// Test that Firefox View tab is not multiselectable +add_task(async function testFxViewNotMultiselect() { + await withFirefoxView({}, async browser => { + let win = browser.ownerGlobal; + Assert.ok( + win.FirefoxViewHandler.tab.selected, + "Firefox View tab is selected" + ); + let tab2 = await add_new_tab("https://www.mozilla.org"); + let fxViewBtn = win.document.getElementById("firefox-view-button"); + + info("We multi-select a visible tab with ctrl key down"); + await triggerClickOn(tab2, { ctrlKey: true }); + Assert.ok( + tab2.multiselected && gBrowser._multiSelectedTabsSet.has(tab2), + "Second visible tab is (multi) selected" + ); + Assert.equal(gBrowser.multiSelectedTabsCount, 1, "One tab is selected."); + Assert.notEqual( + fxViewBtn, + gBrowser.selectedTab, + "Fx View tab doesn't have focus" + ); + + // Ctrl/Cmd click tab2 again to deselect it + await triggerClickOn(tab2, { ctrlKey: true }); + + info("We multi-select visible tabs with shift key down"); + await triggerClickOn(tab2, { shiftKey: true }); + Assert.ok( + tab2.multiselected && gBrowser._multiSelectedTabsSet.has(tab2), + "Second visible tab is (multi) selected" + ); + Assert.equal(gBrowser.multiSelectedTabsCount, 2, "Two tabs are selected."); + Assert.notEqual( + fxViewBtn, + gBrowser.selectedTab, + "Fx View tab doesn't have focus" + ); + + BrowserTestUtils.removeTab(tab2); + }); +}); + +add_task(async function testFxViewEntryPointsInPrivateBrowsing() { + async function checkMenu(win, expectedEnabled) { + await SimpleTest.promiseFocus(win); + const toolsMenu = win.document.getElementById("tools-menu"); + const fxViewMenuItem = toolsMenu.querySelector("#menu_openFirefoxView"); + const menuShown = BrowserTestUtils.waitForEvent(toolsMenu, "popupshown"); + + toolsMenu.openMenu(true); + await menuShown; + Assert.equal( + BrowserTestUtils.isVisible(fxViewMenuItem), + expectedEnabled, + `Firefox view menu item is ${expectedEnabled ? "enabled" : "hidden"}` + ); + const menuHidden = BrowserTestUtils.waitForEvent(toolsMenu, "popuphidden"); + toolsMenu.menupopup.hidePopup(); + await menuHidden; + } + + async function checkEntryPointsInWindow(win, expectedVisible) { + const fxViewBtn = win.document.getElementById("firefox-view-button"); + + if (AppConstants.platform != "macosx") { + await checkMenu(win, expectedVisible); + } + // check the tab button + Assert.equal( + BrowserTestUtils.isVisible(fxViewBtn), + expectedVisible, + `#${fxViewBtn.id} is ${ + expectedVisible ? "visible" : "hidden" + } as expected` + ); + } + + info("Check permanent private browsing"); + // Setting permanent private browsing normally requires a restart. + // We'll emulate by manually setting the attribute it controls manually + await SpecialPowers.pushPrefEnv({ + set: [["browser.privatebrowsing.autostart", true]], + }); + const newWin = await BrowserTestUtils.openNewBrowserWindow(); + newWin.document.documentElement.setAttribute( + "privatebrowsingmode", + "permanent" + ); + await checkEntryPointsInWindow(newWin, false); + await BrowserTestUtils.closeWindow(newWin); + await SpecialPowers.popPrefEnv(); + + info("Check defaults (non-private)"); + await SimpleTest.promiseFocus(window); + await checkEntryPointsInWindow(window, true); + + info("Check private (temporary) browsing"); + const privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await checkEntryPointsInWindow(privateWin, false); + await BrowserTestUtils.closeWindow(privateWin); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_virtual_list.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_virtual_list.js new file mode 100644 index 0000000000..501deb8e68 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_virtual_list.js @@ -0,0 +1,85 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const VIRTUAL_LIST_ENABLED_PREF = "browser.firefox-view.virtual-list.enabled"; + +add_setup(async () => { + await SpecialPowers.pushPrefEnv({ + set: [[VIRTUAL_LIST_ENABLED_PREF, true]], + }); + registerCleanupFunction(async () => { + await SpecialPowers.popPrefEnv(); + clearHistory(); + }); +}); + +add_task(async function test_max_render_count_on_win_resize() { + const now = new Date(); + await PlacesUtils.history.insertMany([ + { + url: "https://example.net/", + visits: [{ date: now }], + }, + ]); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + is( + document.location.href, + getFirefoxViewURL(), + "Firefox View is loaded to the Recent Browsing page." + ); + + await navigateToCategoryAndWait(document, "history"); + + let historyComponent = document.querySelector("view-history"); + let tabList = historyComponent.lists[0]; + let rootVirtualList = tabList.rootVirtualListEl; + + const initialHeight = window.outerHeight; + const initialWidth = window.outerWidth; + const initialMaxRenderCount = rootVirtualList.maxRenderCountEstimate; + info(`The initial maxRenderCountEstimate is ${initialMaxRenderCount}`); + info(`The initial innerHeight is ${window.innerHeight}`); + + // Resize window with new height value + const newHeight = 540; + window.resizeTo(initialWidth, newHeight); + await TestUtils.waitForCondition( + () => window.outerHeight >= newHeight, + `The window has been resized with outer height of ${window.outerHeight} instead of ${newHeight}.` + ); + await TestUtils.waitForCondition( + () => + rootVirtualList.updateComplete && + rootVirtualList.maxRenderCountEstimate < initialMaxRenderCount, + `Max render count ${rootVirtualList.maxRenderCountEstimate} is not less than initial max render count ${initialMaxRenderCount}` + ); + const newMaxRenderCount = rootVirtualList.maxRenderCountEstimate; + + Assert.strictEqual( + rootVirtualList.maxRenderCountEstimate, + newMaxRenderCount, + `The maxRenderCountEstimate on the virtual-list is now ${newMaxRenderCount}` + ); + + // Restore initial window size + resizeTo(initialWidth, initialHeight); + await TestUtils.waitForCondition( + () => + window.outerWidth >= initialHeight && window.outerWidth >= initialWidth, + `The window has been resized with outer height of ${window.outerHeight} instead of ${initialHeight}.` + ); + info(`The final innerHeight is ${window.innerHeight}`); + await TestUtils.waitForCondition( + () => + rootVirtualList.updateComplete && + rootVirtualList.maxRenderCountEstimate > newMaxRenderCount, + `Max render count ${rootVirtualList.maxRenderCountEstimate} is not greater than new max render count ${newMaxRenderCount}` + ); + + info( + `The maxRenderCountEstimate on the virtual-list is greater than ${newMaxRenderCount} after window resize` + ); + gBrowser.removeTab(gBrowser.selectedTab); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_history_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_history_firefoxview.js new file mode 100644 index 0000000000..a6c697e398 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_history_firefoxview.js @@ -0,0 +1,544 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +ChromeUtils.defineESModuleGetters(globalThis, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); +const { ProfileAge } = ChromeUtils.importESModule( + "resource://gre/modules/ProfileAge.sys.mjs" +); + +const HAS_IMPORTED_HISTORY_PREF = "browser.migrate.interactions.history"; +const IMPORT_HISTORY_DISMISSED_PREF = + "browser.tabs.firefox-view.importHistory.dismissed"; +const HISTORY_EVENT = [["firefoxview_next", "history", "visits", undefined]]; +const SHOW_ALL_HISTORY_EVENT = [ + ["firefoxview_next", "show_all_history", "tabs", undefined], +]; + +const NEVER_REMEMBER_HISTORY_PREF = "browser.privatebrowsing.autostart"; +const DAY_MS = 24 * 60 * 60 * 1000; +const today = new Date(); +const yesterday = new Date(Date.now() - DAY_MS); +const twoDaysAgo = new Date(Date.now() - DAY_MS * 2); +const threeDaysAgo = new Date(Date.now() - DAY_MS * 3); +const fourDaysAgo = new Date(Date.now() - DAY_MS * 4); +const oneMonthAgo = new Date(today); + +// Set the date for the first day of the last month +oneMonthAgo.setDate(1); +if (oneMonthAgo.getMonth() === 0) { + // If today's date is in January, use first day in December from the previous year + oneMonthAgo.setMonth(11); + oneMonthAgo.setFullYear(oneMonthAgo.getFullYear() - 1); +} else { + oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1); +} + +function isElInViewport(element) { + const boundingRect = element.getBoundingClientRect(); + return ( + boundingRect.top >= 0 && + boundingRect.left >= 0 && + boundingRect.bottom <= + (window.innerHeight || document.documentElement.clientHeight) && + boundingRect.right <= + (window.innerWidth || document.documentElement.clientWidth) + ); +} + +async function historyComponentReady(historyComponent) { + await TestUtils.waitForCondition( + () => + [...historyComponent.allHistoryItems.values()].reduce( + (acc, { length }) => acc + length, + 0 + ) === 24 + ); + + let expected = historyComponent.historyMapByDate.length; + let actual = historyComponent.cards.length; + + is(expected, actual, `Total number of cards should be ${expected}`); +} + +async function historyTelemetry() { + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 1; + }, + "Waiting for history firefoxview telemetry event.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + HISTORY_EVENT, + { category: "firefoxview_next" }, + { clear: true, process: "parent" } + ); +} + +async function sortHistoryTelemetry(sortHistoryEvent) { + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 1; + }, + "Waiting for sort_history firefoxview telemetry event.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + sortHistoryEvent, + { category: "firefoxview_next" }, + { clear: true, process: "parent" } + ); +} + +async function showAllHistoryTelemetry() { + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 1; + }, + "Waiting for show_all_history firefoxview telemetry event.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + SHOW_ALL_HISTORY_EVENT, + { category: "firefoxview_next" }, + { clear: true, process: "parent" } + ); +} + +async function addHistoryItems(dateAdded) { + await PlacesUtils.history.insert({ + url: URLs[0], + title: "Example Domain 1", + visits: [{ date: dateAdded }], + }); + await PlacesUtils.history.insert({ + url: URLs[1], + title: "Example Domain 2", + visits: [{ date: dateAdded }], + }); + await PlacesUtils.history.insert({ + url: URLs[2], + title: "Example Domain 3", + visits: [{ date: dateAdded }], + }); + await PlacesUtils.history.insert({ + url: URLs[3], + title: "Example Domain 4", + visits: [{ date: dateAdded }], + }); +} + +add_setup(async () => { + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.search.enabled", true]], + }); + registerCleanupFunction(async () => { + await SpecialPowers.popPrefEnv(); + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function test_list_ordering() { + await PlacesUtils.history.clear(); + await addHistoryItems(today); + await addHistoryItems(yesterday); + await addHistoryItems(twoDaysAgo); + await addHistoryItems(threeDaysAgo); + await addHistoryItems(fourDaysAgo); + await addHistoryItems(oneMonthAgo); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + await navigateToCategoryAndWait(document, "history"); + + let historyComponent = document.querySelector("view-history"); + historyComponent.profileAge = 8; + + await historyComponentReady(historyComponent); + + let firstCard = historyComponent.cards[0]; + + info("The first card should have a header for 'Today'."); + await BrowserTestUtils.waitForMutationCondition( + firstCard.querySelector("[slot=header]"), + { attributes: true }, + () => + document.l10n.getAttributes(firstCard.querySelector("[slot=header]")) + .id === "firefoxview-history-date-today" + ); + + // Select first history item in first card + await clearAllParentTelemetryEvents(); + await TestUtils.waitForCondition(() => { + return historyComponent.lists[0].rowEls.length; + }); + let firstHistoryLink = historyComponent.lists[0].rowEls[0].mainEl; + let promiseHidden = BrowserTestUtils.waitForEvent( + document, + "visibilitychange" + ); + await EventUtils.synthesizeMouseAtCenter(firstHistoryLink, {}, content); + await historyTelemetry(); + await promiseHidden; + await openFirefoxViewTab(browser.ownerGlobal); + + // Test number of cards when sorted by site/domain + await clearAllParentTelemetryEvents(); + let sortHistoryEvent = [ + [ + "firefoxview_next", + "sort_history", + "tabs", + undefined, + { sort_type: "site", search_start: "false" }, + ], + ]; + // Select sort by site option + await EventUtils.synthesizeMouseAtCenter( + historyComponent.sortInputs[1], + {}, + content + ); + await TestUtils.waitForCondition(() => historyComponent.fullyUpdated); + await sortHistoryTelemetry(sortHistoryEvent); + + let expectedNumOfCards = historyComponent.historyMapBySite.length; + + info(`Total number of cards should be ${expectedNumOfCards}`); + await BrowserTestUtils.waitForMutationCondition( + historyComponent.shadowRoot, + { childList: true, subtree: true }, + () => expectedNumOfCards === historyComponent.cards.length + ); + + await clearAllParentTelemetryEvents(); + sortHistoryEvent = [ + [ + "firefoxview_next", + "sort_history", + "tabs", + undefined, + { sort_type: "date", search_start: "false" }, + ], + ]; + // Select sort by date option + await EventUtils.synthesizeMouseAtCenter( + historyComponent.sortInputs[0], + {}, + content + ); + await TestUtils.waitForCondition(() => historyComponent.fullyUpdated); + await sortHistoryTelemetry(sortHistoryEvent); + + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } + }); +}); + +add_task(async function test_empty_states() { + await PlacesUtils.history.clear(); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + await navigateToCategoryAndWait(document, "history"); + + let historyComponent = document.querySelector("view-history"); + historyComponent.profileAge = 8; + await TestUtils.waitForCondition(() => historyComponent.emptyState); + let emptyStateCard = historyComponent.emptyState; + ok( + emptyStateCard.headerEl.textContent.includes( + "Get back to where you’ve been" + ), + "Initial empty state header has the expected text." + ); + ok( + emptyStateCard.descriptionEls[0].textContent.includes( + "As you browse, the pages you visit will be listed here." + ), + "Initial empty state description has the expected text." + ); + + // Test empty state when History mode is set to never remember + Services.prefs.setBoolPref(NEVER_REMEMBER_HISTORY_PREF, true); + // Manually update the history component from the test, since changing this setting + // in about:preferences will require a browser reload + historyComponent.requestUpdate(); + await TestUtils.waitForCondition(() => historyComponent.fullyUpdated); + emptyStateCard = historyComponent.emptyState; + ok( + emptyStateCard.headerEl.textContent.includes("Nothing to show"), + "Empty state with never remember history header has the expected text." + ); + ok( + emptyStateCard.descriptionEls[1].textContent.includes( + "remember your activity as you browse. To change that" + ), + "Empty state with never remember history description has the expected text." + ); + // Reset History mode to Remember + Services.prefs.setBoolPref(NEVER_REMEMBER_HISTORY_PREF, false); + // Manually update the history component from the test, since changing this setting + // in about:preferences will require a browser reload + historyComponent.requestUpdate(); + await TestUtils.waitForCondition(() => historyComponent.fullyUpdated); + + // Test import history banner shows if profile age is 7 days or less and + // user hasn't already imported history from another browser + Services.prefs.setBoolPref(IMPORT_HISTORY_DISMISSED_PREF, false); + Services.prefs.setBoolPref(HAS_IMPORTED_HISTORY_PREF, true); + ok(!historyComponent.cards.length, "Import history banner not shown yet"); + historyComponent.profileAge = 0; + await TestUtils.waitForCondition(() => historyComponent.fullyUpdated); + ok( + !historyComponent.cards.length, + "Import history banner still not shown yet" + ); + Services.prefs.setBoolPref(HAS_IMPORTED_HISTORY_PREF, false); + await TestUtils.waitForCondition(() => historyComponent.fullyUpdated); + ok( + historyComponent.cards[0].textContent.includes( + "Import history from another browser" + ), + "Import history banner is shown" + ); + let importHistoryCloseButton = + historyComponent.cards[0].querySelector("button.close"); + importHistoryCloseButton.click(); + await TestUtils.waitForCondition(() => historyComponent.fullyUpdated); + ok( + Services.prefs.getBoolPref(IMPORT_HISTORY_DISMISSED_PREF, true) && + !historyComponent.cards.length, + "Import history banner has been dismissed." + ); + // Reset profileAge to greater than 7 to avoid affecting other tests + historyComponent.profileAge = 8; + Services.prefs.setBoolPref(IMPORT_HISTORY_DISMISSED_PREF, false); + + gBrowser.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test_observers_removed_when_view_is_hidden() { + await PlacesUtils.history.clear(); + const NEW_TAB_URL = "https://example.com"; + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + NEW_TAB_URL + ); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToCategoryAndWait(document, "history"); + const historyComponent = document.querySelector("view-history"); + historyComponent.profileAge = 8; + let visitList = await TestUtils.waitForCondition(() => + historyComponent.cards?.[0]?.querySelector("fxview-tab-list") + ); + info("The list should show a visit from the new tab."); + await TestUtils.waitForCondition(() => visitList.rowEls.length === 1); + + let promiseHidden = BrowserTestUtils.waitForEvent( + document, + "visibilitychange" + ); + await BrowserTestUtils.switchTab(gBrowser, tab); + await promiseHidden; + const { date } = await PlacesUtils.history + .fetch(NEW_TAB_URL, { + includeVisits: true, + }) + .then(({ visits }) => visits[0]); + await addHistoryItems(date); + is( + visitList.rowEls.length, + 1, + "The list does not update when Firefox View is hidden." + ); + + info("The list should update when Firefox View is visible."); + await openFirefoxViewTab(browser.ownerGlobal); + visitList = await TestUtils.waitForCondition(() => + historyComponent.cards?.[0]?.querySelector("fxview-tab-list") + ); + await TestUtils.waitForCondition(() => visitList.rowEls.length > 1); + + BrowserTestUtils.removeTab(tab); + }); +}); + +add_task(async function test_show_all_history_telemetry() { + await PlacesUtils.history.clear(); + await addHistoryItems(today); + await addHistoryItems(yesterday); + await addHistoryItems(twoDaysAgo); + await addHistoryItems(threeDaysAgo); + await addHistoryItems(fourDaysAgo); + await addHistoryItems(oneMonthAgo); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + await navigateToCategoryAndWait(document, "history"); + + let historyComponent = document.querySelector("view-history"); + historyComponent.profileAge = 8; + await historyComponentReady(historyComponent); + + await clearAllParentTelemetryEvents(); + let showAllHistoryBtn = historyComponent.showAllHistoryBtn; + showAllHistoryBtn.scrollIntoView(); + await EventUtils.synthesizeMouseAtCenter(showAllHistoryBtn, {}, content); + await showAllHistoryTelemetry(); + + // Make sure library window is shown + await TestUtils.waitForCondition(() => + Services.wm.getMostRecentWindow("Places:Organizer") + ); + let library = Services.wm.getMostRecentWindow("Places:Organizer"); + await BrowserTestUtils.closeWindow(library); + gBrowser.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test_search_history() { + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToCategoryAndWait(document, "history"); + const historyComponent = document.querySelector("view-history"); + historyComponent.profileAge = 8; + await historyComponentReady(historyComponent); + const searchTextbox = await TestUtils.waitForCondition( + () => historyComponent.searchTextbox, + "The search textbox is displayed." + ); + + info("Input a search query."); + EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content); + EventUtils.sendString("Example Domain 1", content); + await BrowserTestUtils.waitForMutationCondition( + historyComponent.shadowRoot, + { childList: true, subtree: true }, + () => + historyComponent.cards.length === 1 && + document.l10n.getAttributes( + historyComponent.cards[0].querySelector("[slot=header]") + ).id === "firefoxview-search-results-header" + ); + await TestUtils.waitForCondition(() => { + const { rowEls } = historyComponent.lists[0]; + return rowEls.length === 1 && rowEls[0].mainEl.href === URLs[0]; + }, "There is one matching search result."); + + info("Input a bogus search query."); + EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content); + EventUtils.sendString("Bogus Query", content); + await TestUtils.waitForCondition(() => { + const tabList = historyComponent.lists[0]; + return tabList?.shadowRoot.querySelector("fxview-empty-state"); + }, "There are no matching search results."); + + info("Clear the search query."); + EventUtils.synthesizeMouseAtCenter(searchTextbox.clearButton, {}, content); + await BrowserTestUtils.waitForMutationCondition( + historyComponent.shadowRoot, + { childList: true, subtree: true }, + () => + historyComponent.cards.length === + historyComponent.historyMapByDate.length + ); + searchTextbox.blur(); + + info("Input a bogus search query with keyboard."); + EventUtils.synthesizeKey("f", { accelKey: true }, content); + EventUtils.sendString("Bogus Query", content); + await TestUtils.waitForCondition(() => { + const tabList = historyComponent.lists[0]; + return tabList?.shadowRoot.querySelector("fxview-empty-state"); + }, "There are no matching search results."); + + info("Clear the search query with keyboard."); + is( + historyComponent.shadowRoot.activeElement, + searchTextbox, + "Search input is focused" + ); + EventUtils.synthesizeKey("KEY_Tab", {}, content); + ok( + searchTextbox.clearButton.matches(":focus-visible"), + "Clear Search button is focused" + ); + EventUtils.synthesizeKey("KEY_Enter", {}, content); + await BrowserTestUtils.waitForMutationCondition( + historyComponent.shadowRoot, + { childList: true, subtree: true }, + () => + historyComponent.cards.length === + historyComponent.historyMapByDate.length + ); + }); +}); + +add_task(async function test_persist_collapse_card_after_view_change() { + await PlacesUtils.history.clear(); + await addHistoryItems(today); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToCategoryAndWait(document, "history"); + const historyComponent = document.querySelector("view-history"); + historyComponent.profileAge = 8; + await TestUtils.waitForCondition( + () => + [...historyComponent.allHistoryItems.values()].reduce( + (acc, { length }) => acc + length, + 0 + ) === 4 + ); + let firstHistoryCard = historyComponent.cards[0]; + ok( + firstHistoryCard.isExpanded, + "The first history card is expanded initially." + ); + + // Collapse history card + EventUtils.synthesizeMouseAtCenter(firstHistoryCard.summaryEl, {}, content); + is( + firstHistoryCard.detailsEl.hasAttribute("open"), + false, + "The first history card is now collapsed." + ); + + // Switch to a new view and then back to History + await navigateToCategoryAndWait(document, "syncedtabs"); + await navigateToCategoryAndWait(document, "history"); + + // Check that first history card is still collapsed after changing view + ok( + !firstHistoryCard.isExpanded, + "The first history card is still collapsed after changing view." + ); + + await PlacesUtils.history.clear(); + gBrowser.removeTab(gBrowser.selectedTab); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_notification_dot.js b/browser/components/firefoxview/tests/browser/browser_notification_dot.js new file mode 100644 index 0000000000..0fa747d40f --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_notification_dot.js @@ -0,0 +1,392 @@ +/* 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_opentabs_cards.js b/browser/components/firefoxview/tests/browser/browser_opentabs_cards.js new file mode 100644 index 0000000000..d57aa3cad1 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_opentabs_cards.js @@ -0,0 +1,628 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URL = "about:robots"; +const ROW_URL_ID = "fxview-tab-row-url"; +const ROW_DATE_ID = "fxview-tab-row-date"; + +let gInitialTab; +let gInitialTabURL; +const { NonPrivateTabs } = ChromeUtils.importESModule( + "resource:///modules/OpenTabs.sys.mjs" +); + +add_setup(function () { + // This test opens a lot of windows and tabs and might run long on slower configurations + requestLongerTimeout(2); + gInitialTab = gBrowser.selectedTab; + gInitialTabURL = gBrowser.selectedBrowser.currentURI.spec; +}); + +async function navigateToOpenTabs(browser) { + const document = browser.contentDocument; + if (document.querySelector("named-deck").selectedViewName != "opentabs") { + await navigateToCategoryAndWait(browser.contentDocument, "opentabs"); + } +} + +function getOpenTabsComponent(browser) { + return browser.contentDocument.querySelector("named-deck > view-opentabs"); +} + +function getCards(browser) { + return getOpenTabsComponent(browser).shadowRoot.querySelectorAll( + "view-opentabs-card" + ); +} + +async function cleanup() { + await SimpleTest.promiseFocus(window); + await promiseAllButPrimaryWindowClosed(); + await BrowserTestUtils.switchTab(gBrowser, gInitialTab); + await closeFirefoxViewTab(window); + + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } + + is( + BrowserWindowTracker.orderedWindows.length, + 1, + "One window at the end of test cleanup" + ); + Assert.deepEqual( + gBrowser.tabs.map(tab => tab.linkedBrowser.currentURI.spec), + [gInitialTabURL], + "One about:blank tab open at the end up test cleanup" + ); +} + +async function getRowsForCard(card) { + await TestUtils.waitForCondition(() => card.tabList.rowEls.length); + return card.tabList.rowEls; +} + +/** + * Verify that there are the expected number of cards, and that each card has + * the expected URLs in order. + * + * @param {tabbrowser} browser + * The browser to verify in. + * @param {string[][]} expected + * The expected URLs for each card. + */ +async function checkTabLists(browser, expected) { + const cards = getCards(browser); + is(cards.length, expected.length, `There are ${expected.length} windows.`); + for (let i = 0; i < cards.length; i++) { + const tabItems = await getRowsForCard(cards[i]); + const actual = Array.from(tabItems).map(({ url }) => url); + Assert.deepEqual( + actual, + expected[i], + "Tab list has items with URLs in the expected order" + ); + } +} + +add_task(async function open_tab_same_window() { + await openFirefoxViewTab(window).then(async viewTab => { + const browser = viewTab.linkedBrowser; + await navigateToOpenTabs(browser); + const openTabs = getOpenTabsComponent(browser); + await openTabs.openTabsTarget.readyWindowsPromise; + await openTabs.updateComplete; + + await checkTabLists(browser, [[gInitialTabURL]]); + let promiseHidden = BrowserTestUtils.waitForEvent( + browser.contentDocument, + "visibilitychange" + ); + let tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabChange" + ); + await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + await promiseHidden; + await tabChangeRaised; + }); + + const [originalTab, newTab] = gBrowser.visibleTabs; + + await openFirefoxViewTab(window).then(async viewTab => { + const browser = viewTab.linkedBrowser; + const openTabs = getOpenTabsComponent(browser); + setSortOption(openTabs, "tabStripOrder"); + await openTabs.openTabsTarget.readyWindowsPromise; + await openTabs.updateComplete; + + await checkTabLists(browser, [[gInitialTabURL, TEST_URL]]); + let promiseHidden = BrowserTestUtils.waitForEvent( + browser.contentDocument, + "visibilitychange" + ); + const cards = getCards(browser); + const tabItems = await getRowsForCard(cards[0]); + tabItems[0].mainEl.click(); + await promiseHidden; + }); + + await BrowserTestUtils.waitForCondition( + () => originalTab.selected, + "The original tab is selected." + ); + + await openFirefoxViewTab(window).then(async viewTab => { + const browser = viewTab.linkedBrowser; + const cards = getCards(browser); + let tabItems = await getRowsForCard(cards[0]); + + let promiseHidden = BrowserTestUtils.waitForEvent( + browser.contentDocument, + "visibilitychange" + ); + + tabItems[1].mainEl.click(); + await promiseHidden; + }); + + await BrowserTestUtils.waitForCondition( + () => newTab.selected, + "The new tab is selected." + ); + + await openFirefoxViewTab(window).then(async viewTab => { + const browser = viewTab.linkedBrowser; + let tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabChange" + ); + + info("Bring the new tab to the front."); + gBrowser.moveTabTo(newTab, 0); + + await tabChangeRaised; + await checkTabLists(browser, [[TEST_URL, gInitialTabURL]]); + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabChange" + ); + await BrowserTestUtils.removeTab(newTab); + await tabChangeRaised; + + await checkTabLists(browser, [[gInitialTabURL]]); + const [card] = getCards(browser); + const [row] = await getRowsForCard(card); + ok( + !row.shadowRoot.getElementById("fxview-tab-row-url").hidden, + "The URL is displayed, since we have one window." + ); + ok( + !row.shadowRoot.getElementById("fxview-tab-row-date").hidden, + "The date is displayed, since we have one window." + ); + }); + + await cleanup(); +}); + +add_task(async function open_tab_new_window() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + let winFocused; + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, TEST_URL); + + info("Open fxview in new window"); + await openFirefoxViewTab(win).then(async viewTab => { + const browser = viewTab.linkedBrowser; + await navigateToOpenTabs(browser); + const openTabs = getOpenTabsComponent(browser); + setSortOption(openTabs, "tabStripOrder"); + await openTabs.openTabsTarget.readyWindowsPromise; + await openTabs.updateComplete; + + await checkTabLists(browser, [ + [gInitialTabURL, TEST_URL], + [gInitialTabURL], + ]); + const cards = getCards(browser); + const originalWinRows = await getRowsForCard(cards[1]); + const [row] = originalWinRows; + ok( + row.shadowRoot.getElementById("fxview-tab-row-url").hidden, + "The URL is hidden, since we have two windows." + ); + ok( + row.shadowRoot.getElementById("fxview-tab-row-date").hidden, + "The date is hidden, since we have two windows." + ); + info("Select a tab from the original window."); + let tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabRecencyChange" + ); + winFocused = BrowserTestUtils.waitForEvent(window, "focus", true); + originalWinRows[0].mainEl.click(); + await tabChangeRaised; + }); + + info("Wait for the original window to be focused"); + await winFocused; + + await openFirefoxViewTab(window).then(async viewTab => { + const browser = viewTab.linkedBrowser; + await navigateToOpenTabs(browser); + const openTabs = getOpenTabsComponent(browser); + await openTabs.openTabsTarget.readyWindowsPromise; + await openTabs.updateComplete; + + const cards = getCards(browser); + is(cards.length, 2, "There are two windows."); + const newWinRows = await getRowsForCard(cards[1]); + + info("Select a tab from the new window."); + winFocused = BrowserTestUtils.waitForEvent(win, "focus", true); + let tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabRecencyChange" + ); + newWinRows[0].mainEl.click(); + await tabChangeRaised; + }); + info("Wait for the new window to be focused"); + await winFocused; + await cleanup(); +}); + +add_task(async function open_tab_new_private_window() { + await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + await SimpleTest.promiseFocus(window); + await openFirefoxViewTab(window).then(async viewTab => { + const browser = viewTab.linkedBrowser; + await navigateToOpenTabs(browser); + const openTabs = getOpenTabsComponent(browser); + await openTabs.openTabsTarget.readyWindowsPromise; + await openTabs.updateComplete; + + const cards = getCards(browser); + is(cards.length, 1, "The private window is not displayed."); + }); + await cleanup(); +}); + +add_task(async function open_tab_new_window_sort_by_recency() { + info("Open new tabs in a new window."); + const newWindow = await BrowserTestUtils.openNewBrowserWindow(); + const tabs = [ + newWindow.gBrowser.selectedTab, + await BrowserTestUtils.openNewForegroundTab(newWindow.gBrowser, URLs[0]), + await BrowserTestUtils.openNewForegroundTab(newWindow.gBrowser, URLs[1]), + ]; + + info("Open Firefox View in the original window."); + await openFirefoxViewTab(window).then(async ({ linkedBrowser }) => { + await navigateToOpenTabs(linkedBrowser); + const openTabs = getOpenTabsComponent(linkedBrowser); + setSortOption(openTabs, "recency"); + await openTabs.openTabsTarget.readyWindowsPromise; + await openTabs.updateComplete; + + await checkTabLists(linkedBrowser, [ + [gInitialTabURL], + [URLs[1], URLs[0], gInitialTabURL], + ]); + info("Select tabs in the new window to trigger recency changes."); + await SimpleTest.promiseFocus(newWindow); + await BrowserTestUtils.switchTab(newWindow.gBrowser, tabs[1]); + await BrowserTestUtils.switchTab(newWindow.gBrowser, tabs[0]); + await SimpleTest.promiseFocus(window); + await TestUtils.waitForCondition(async () => { + const [, secondCard] = getCards(linkedBrowser); + const tabItems = await getRowsForCard(secondCard); + return tabItems[0].url === gInitialTabURL; + }); + await checkTabLists(linkedBrowser, [ + [gInitialTabURL], + [gInitialTabURL, URLs[0], URLs[1]], + ]); + }); + await cleanup(); +}); + +add_task(async function styling_for_multiple_windows() { + await openFirefoxViewTab(window).then(async viewTab => { + const browser = viewTab.linkedBrowser; + await navigateToOpenTabs(browser); + const openTabs = getOpenTabsComponent(browser); + setSortOption(openTabs, "tabStripOrder"); + await openTabs.openTabsTarget.readyWindowsPromise; + await openTabs.updateComplete; + + ok( + openTabs.shadowRoot.querySelector("[card-count=one]"), + "The container shows one column when one window is open." + ); + }); + + await BrowserTestUtils.openNewBrowserWindow(); + let tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabChange" + ); + await NonPrivateTabs.readyWindowsPromise; + await tabChangeRaised; + is( + NonPrivateTabs.currentWindows.length, + 2, + "NonPrivateTabs now has 2 currentWindows" + ); + + info("switch to firefox view in the first window"); + SimpleTest.promiseFocus(window); + await openFirefoxViewTab(window).then(async viewTab => { + const browser = viewTab.linkedBrowser; + const openTabs = getOpenTabsComponent(browser); + await openTabs.openTabsTarget.readyWindowsPromise; + await openTabs.updateComplete; + is( + openTabs.openTabsTarget.currentWindows.length, + 2, + "There should be 2 current windows" + ); + ok( + openTabs.shadowRoot.querySelector("[card-count=two]"), + "The container shows two columns when two windows are open." + ); + }); + await BrowserTestUtils.openNewBrowserWindow(); + tabChangeRaised = BrowserTestUtils.waitForEvent(NonPrivateTabs, "TabChange"); + await NonPrivateTabs.readyWindowsPromise; + await tabChangeRaised; + is( + NonPrivateTabs.currentWindows.length, + 3, + "NonPrivateTabs now has 2 currentWindows" + ); + + SimpleTest.promiseFocus(window); + await openFirefoxViewTab(window).then(async viewTab => { + const browser = viewTab.linkedBrowser; + const openTabs = getOpenTabsComponent(browser); + await openTabs.openTabsTarget.readyWindowsPromise; + await openTabs.updateComplete; + + ok( + openTabs.shadowRoot.querySelector("[card-count=three-or-more]"), + "The container shows three columns when three windows are open." + ); + }); + await cleanup(); +}); + +add_task(async function toggle_show_more_link() { + const tabEntry = url => ({ + entries: [{ url, triggeringPrincipal_base64 }], + }); + const NUMBER_OF_WINDOWS = 4; + const NUMBER_OF_TABS = 42; + const browserState = { windows: [] }; + for (let windowIndex = 0; windowIndex < NUMBER_OF_WINDOWS; windowIndex++) { + const winData = { tabs: [] }; + let tabCount = windowIndex == NUMBER_OF_WINDOWS - 1 ? NUMBER_OF_TABS : 1; + for (let i = 0; i < tabCount; i++) { + winData.tabs.push(tabEntry(`data:,Window${windowIndex}-Tab${i}`)); + } + winData.selected = winData.tabs.length; + browserState.windows.push(winData); + } + // use Session restore to batch-open windows and tabs + await SessionStoreTestUtils.promiseBrowserState(browserState); + // restoring this state requires an update to the initial tab globals + // so cleanup expects the right thing + gInitialTab = gBrowser.selectedTab; + gInitialTabURL = gBrowser.selectedBrowser.currentURI.spec; + + const windows = Array.from(Services.wm.getEnumerator("navigator:browser")); + is(windows.length, NUMBER_OF_WINDOWS, "There are four browser windows."); + + const tab = (win = window) => { + info("Tab"); + EventUtils.synthesizeKey("KEY_Tab", {}, win); + }; + + const enter = (win = window) => { + info("Enter"); + EventUtils.synthesizeKey("KEY_Enter", {}, win); + }; + + let lastCard; + + SimpleTest.promiseFocus(window); + await openFirefoxViewTab(window).then(async viewTab => { + const browser = viewTab.linkedBrowser; + await navigateToOpenTabs(browser); + const openTabs = getOpenTabsComponent(browser); + await openTabs.openTabsTarget.readyWindowsPromise; + await openTabs.updateComplete; + + const cards = getCards(browser); + is(cards.length, NUMBER_OF_WINDOWS, "There are four windows."); + lastCard = cards[NUMBER_OF_WINDOWS - 1]; + }); + + await openFirefoxViewTab(window).then(async viewTab => { + const browser = viewTab.linkedBrowser; + const openTabs = getOpenTabsComponent(browser); + await openTabs.openTabsTarget.readyWindowsPromise; + await openTabs.updateComplete; + Assert.less( + (await getRowsForCard(lastCard)).length, + NUMBER_OF_TABS, + "Not all tabs are shown yet." + ); + info("Toggle the Show More link."); + lastCard.shadowRoot.querySelector("div[slot=footer]").click(); + await BrowserTestUtils.waitForMutationCondition( + lastCard.shadowRoot, + { childList: true, subtree: true }, + async () => (await getRowsForCard(lastCard)).length === NUMBER_OF_TABS + ); + + info("Toggle the Show Less link."); + lastCard.shadowRoot.querySelector("div[slot=footer]").click(); + await BrowserTestUtils.waitForMutationCondition( + lastCard.shadowRoot, + { childList: true, subtree: true }, + async () => (await getRowsForCard(lastCard)).length < NUMBER_OF_TABS + ); + + // Setting this pref allows the test to run as expected with a keyboard on MacOS + await SpecialPowers.pushPrefEnv({ + set: [["accessibility.tabfocus", 7]], + }); + + info("Toggle the Show More link with keyboard."); + lastCard.shadowRoot.querySelector("card-container").summaryEl.focus(); + // Tab to first item in the list + tab(); + // Tab to the footer + tab(); + enter(); + await BrowserTestUtils.waitForMutationCondition( + lastCard.shadowRoot, + { childList: true, subtree: true }, + async () => (await getRowsForCard(lastCard)).length === NUMBER_OF_TABS + ); + + info("Toggle the Show Less link with keyboard."); + lastCard.shadowRoot.querySelector("card-container").summaryEl.focus(); + // Tab to first item in the list + tab(); + // Tab to the footer + tab(); + enter(); + await BrowserTestUtils.waitForMutationCondition( + lastCard.shadowRoot, + { childList: true, subtree: true }, + async () => (await getRowsForCard(lastCard)).length < NUMBER_OF_TABS + ); + + await SpecialPowers.popPrefEnv(); + }); + await cleanup(); +}); + +add_task(async function search_open_tabs() { + // Open a new window and navigate to TEST_URL. Then, when we search for + // TEST_URL, it should show a search result in the new window's card. + const win = await BrowserTestUtils.openNewBrowserWindow(); + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, TEST_URL); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.search.enabled", true]], + }); + await openFirefoxViewTab(window).then(async viewTab => { + const browser = viewTab.linkedBrowser; + await navigateToOpenTabs(browser); + const openTabs = getOpenTabsComponent(browser); + await openTabs.openTabsTarget.readyWindowsPromise; + await openTabs.updateComplete; + + const cards = getCards(browser); + is(cards.length, 2, "There are two windows."); + const winTabs = await getRowsForCard(cards[0]); + const newWinTabs = await getRowsForCard(cards[1]); + + info("Input a search query."); + EventUtils.synthesizeMouseAtCenter(openTabs.searchTextbox, {}, content); + EventUtils.sendString(TEST_URL, content); + await TestUtils.waitForCondition( + () => openTabs.viewCards[0].tabList.rowEls.length === 0, + "There are no matching search results in the original window." + ); + await TestUtils.waitForCondition( + () => openTabs.viewCards[1].tabList.rowEls.length === 1, + "There is one matching search result in the new window." + ); + + info("Clear the search query."); + EventUtils.synthesizeMouseAtCenter( + openTabs.searchTextbox.clearButton, + {}, + content + ); + await TestUtils.waitForCondition( + () => openTabs.viewCards[0].tabList.rowEls.length === winTabs.length, + "The original window's list is restored." + ); + await TestUtils.waitForCondition( + () => openTabs.viewCards[1].tabList.rowEls.length === newWinTabs.length, + "The new window's list is restored." + ); + openTabs.searchTextbox.blur(); + + info("Input a search query with keyboard."); + EventUtils.synthesizeKey("f", { accelKey: true }, content); + EventUtils.sendString(TEST_URL, content); + await TestUtils.waitForCondition( + () => openTabs.viewCards[0].tabList.rowEls.length === 0, + "There are no matching search results in the original window." + ); + await TestUtils.waitForCondition( + () => openTabs.viewCards[1].tabList.rowEls.length === 1, + "There is one matching search result in the new window." + ); + + info("Clear the search query with keyboard."); + is( + openTabs.shadowRoot.activeElement, + openTabs.searchTextbox, + "Search input is focused" + ); + EventUtils.synthesizeKey("KEY_Tab", {}, content); + ok( + openTabs.searchTextbox.clearButton.matches(":focus-visible"), + "Clear Search button is focused" + ); + EventUtils.synthesizeKey("KEY_Enter", {}, content); + await TestUtils.waitForCondition( + () => openTabs.viewCards[0].tabList.rowEls.length === winTabs.length, + "The original window's list is restored." + ); + await TestUtils.waitForCondition( + () => openTabs.viewCards[1].tabList.rowEls.length === newWinTabs.length, + "The new window's list is restored." + ); + }); + + await SpecialPowers.popPrefEnv(); + await cleanup(); +}); + +add_task(async function search_open_tabs_recent_browsing() { + const NUMBER_OF_TABS = 6; + const win = await BrowserTestUtils.openNewBrowserWindow(); + for (let i = 0; i < NUMBER_OF_TABS; i++) { + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, TEST_URL); + } + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.search.enabled", true]], + }); + await openFirefoxViewTab(window).then(async viewTab => { + const browser = viewTab.linkedBrowser; + await navigateToCategoryAndWait(browser.contentDocument, "recentbrowsing"); + const recentBrowsing = browser.contentDocument.querySelector( + "view-recentbrowsing" + ); + + info("Input a search query."); + EventUtils.synthesizeMouseAtCenter( + recentBrowsing.searchTextbox, + {}, + content + ); + EventUtils.sendString(TEST_URL, content); + const slot = recentBrowsing.querySelector("[slot='opentabs']"); + await TestUtils.waitForCondition( + () => slot.viewCards[0].tabList.rowEls.length === 5, + "Not all search results are shown yet." + ); + + info("Click the Show All link."); + const showAllLink = await TestUtils.waitForCondition(() => { + const elt = slot.viewCards[0].shadowRoot.querySelector( + "[data-l10n-id='firefoxview-show-all']" + ); + EventUtils.synthesizeMouseAtCenter(elt, {}, content); + if (slot.viewCards[0].tabList.rowEls.length === NUMBER_OF_TABS) { + return elt; + } + return false; + }, "All search results are shown."); + is(showAllLink.role, "link", "The show all control is a link."); + ok(BrowserTestUtils.isHidden(showAllLink), "The show all link is hidden."); + }); + await SpecialPowers.popPrefEnv(); + await cleanup(); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_opentabs_changes.js b/browser/components/firefoxview/tests/browser/browser_opentabs_changes.js new file mode 100644 index 0000000000..c293afa8cd --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_opentabs_changes.js @@ -0,0 +1,541 @@ +const { NonPrivateTabs, getTabsTargetForWindow } = ChromeUtils.importESModule( + "resource:///modules/OpenTabs.sys.mjs" +); +let privateTabsChanges; + +const tabURL1 = "data:text/html,Tab1Tab1"; +const tabURL2 = "data:text/html,Tab2Tab2"; +const tabURL3 = "data:text/html,Tab3Tab3"; +const tabURL4 = "data:text/html,Tab4Tab4"; + +const nonPrivateListener = sinon.stub(); +const privateListener = sinon.stub(); + +function tabUrl(tab) { + return tab.linkedBrowser.currentURI?.spec; +} + +function getWindowId(win) { + return win.windowGlobalChild.innerWindowId; +} + +async function setup(tabChangeEventName) { + nonPrivateListener.resetHistory(); + privateListener.resetHistory(); + + NonPrivateTabs.addEventListener(tabChangeEventName, nonPrivateListener); + + await TestUtils.waitForTick(); + is( + NonPrivateTabs.currentWindows.length, + 1, + "NonPrivateTabs has 1 window a tick after adding the event listener" + ); + + info("Opening new windows"); + let win0 = window, + win1 = await BrowserTestUtils.openNewBrowserWindow(), + privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + BrowserTestUtils.startLoadingURIString( + win1.gBrowser.selectedBrowser, + tabURL1 + ); + await BrowserTestUtils.browserLoaded(win1.gBrowser.selectedBrowser); + + // load a tab with a title/label we can easily verify + BrowserTestUtils.startLoadingURIString( + privateWin.gBrowser.selectedBrowser, + tabURL2 + ); + await BrowserTestUtils.browserLoaded(privateWin.gBrowser.selectedBrowser); + + is( + win1.gBrowser.selectedTab.label, + "Tab1", + "Check the tab label in the new non-private window" + ); + is( + privateWin.gBrowser.selectedTab.label, + "Tab2", + "Check the tab label in the new private window" + ); + + privateTabsChanges = getTabsTargetForWindow(privateWin); + privateTabsChanges.addEventListener(tabChangeEventName, privateListener); + is( + privateTabsChanges, + getTabsTargetForWindow(privateWin), + "getTabsTargetForWindow reuses a single instance per exclusive window" + ); + + await TestUtils.waitForTick(); + is( + NonPrivateTabs.currentWindows.length, + 2, + "NonPrivateTabs has 2 windows once openNewBrowserWindow resolves" + ); + is( + privateTabsChanges.currentWindows.length, + 1, + "privateTabsChanges has 1 window once openNewBrowserWindow resolves" + ); + + await SimpleTest.promiseFocus(win0); + info("setup, win0 has id: " + getWindowId(win0)); + info("setup, win1 has id: " + getWindowId(win1)); + info("setup, privateWin has id: " + getWindowId(privateWin)); + info("setup,waiting for both private and nonPrivateListener to be called"); + await TestUtils.waitForCondition(() => { + return nonPrivateListener.called && privateListener.called; + }); + nonPrivateListener.resetHistory(); + privateListener.resetHistory(); + + const cleanup = async eventName => { + NonPrivateTabs.removeEventListener(eventName, nonPrivateListener); + privateTabsChanges.removeEventListener(eventName, privateListener); + await SimpleTest.promiseFocus(window); + await promiseAllButPrimaryWindowClosed(); + }; + return { windows: [win0, win1, privateWin], cleanup }; +} + +add_task(async function test_TabChanges() { + const { windows, cleanup } = await setup("TabChange"); + const [win0, win1, privateWin] = windows; + let tabChangeRaised; + let changeEvent; + + info( + "Verify that manipulating tabs in a non-private window dispatches events on the correct target" + ); + for (let win of [win0, win1]) { + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabChange" + ); + let newTab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + tabURL1 + ); + changeEvent = await tabChangeRaised; + Assert.deepEqual( + changeEvent.detail.windowIds, + [getWindowId(win)], + "The event had the correct window id" + ); + + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabChange" + ); + const navigateUrl = "https://example.org/"; + BrowserTestUtils.startLoadingURIString(newTab.linkedBrowser, navigateUrl); + await BrowserTestUtils.browserLoaded( + newTab.linkedBrowser, + null, + navigateUrl + ); + // navigation in a tab changes the label which should produce a change event + changeEvent = await tabChangeRaised; + Assert.deepEqual( + changeEvent.detail.windowIds, + [getWindowId(win)], + "The event had the correct window id" + ); + + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabChange" + ); + BrowserTestUtils.removeTab(newTab); + // navigation in a tab changes the label which should produce a change event + changeEvent = await tabChangeRaised; + Assert.deepEqual( + changeEvent.detail.windowIds, + [getWindowId(win)], + "The event had the correct window id" + ); + } + + info( + "make sure a change to a private window doesnt dispatch on a nonprivate target" + ); + nonPrivateListener.resetHistory(); + privateListener.resetHistory(); + + tabChangeRaised = BrowserTestUtils.waitForEvent( + privateTabsChanges, + "TabChange" + ); + BrowserTestUtils.addTab(privateWin.gBrowser, tabURL1); + changeEvent = await tabChangeRaised; + info( + `Check windowIds adding tab to private window: ${getWindowId( + privateWin + )}: ${JSON.stringify(changeEvent.detail.windowIds)}` + ); + Assert.deepEqual( + changeEvent.detail.windowIds, + [getWindowId(privateWin)], + "The event had the correct window id" + ); + await TestUtils.waitForTick(); + Assert.ok( + nonPrivateListener.notCalled, + "A private tab change shouldnt raise a tab change event on the non-private target" + ); + + info("testTabChanges complete"); + await cleanup("TabChange"); +}); + +add_task(async function test_TabRecencyChange() { + const { windows, cleanup } = await setup("TabRecencyChange"); + const [win0, win1, privateWin] = windows; + + let tabChangeRaised; + let changeEvent; + let sortedTabs; + + info("Open some tabs in the non-private windows"); + for (let win of [win0, win1]) { + for (let url of [tabURL1, tabURL2]) { + let tab = BrowserTestUtils.addTab(win.gBrowser, url); + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabChange" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await tabChangeRaised; + } + } + + info("Verify switching tabs produces the expected event and result"); + nonPrivateListener.resetHistory(); + privateListener.resetHistory(); + + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabRecencyChange" + ); + BrowserTestUtils.switchTab(win0.gBrowser, win0.gBrowser.tabs.at(-1)); + changeEvent = await tabChangeRaised; + + Assert.deepEqual( + changeEvent.detail.windowIds, + [getWindowId(win0)], + "The recency change event had the correct window id" + ); + Assert.ok( + nonPrivateListener.called, + "Sanity check that the non-private tabs listener was called" + ); + Assert.ok( + privateListener.notCalled, + "The private tabs listener was not called" + ); + + sortedTabs = NonPrivateTabs.getRecentTabs(); + is( + sortedTabs[0], + win0.gBrowser.selectedTab, + "The most-recent tab is the selected tab" + ); + + info("Verify switching window produces the expected event and result"); + nonPrivateListener.resetHistory(); + privateListener.resetHistory(); + + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabRecencyChange" + ); + await SimpleTest.promiseFocus(win1); + changeEvent = await tabChangeRaised; + Assert.deepEqual( + changeEvent.detail.windowIds, + [getWindowId(win1)], + "The recency change event had the correct window id" + ); + Assert.ok( + nonPrivateListener.called, + "Sanity check that the non-private tabs listener was called" + ); + Assert.ok( + privateListener.notCalled, + "The private tabs listener was not called" + ); + + sortedTabs = NonPrivateTabs.getRecentTabs(); + is( + sortedTabs[0], + win1.gBrowser.selectedTab, + "The most-recent tab is the selected tab in the current window" + ); + + info("Verify behavior with private window changes"); + nonPrivateListener.resetHistory(); + privateListener.resetHistory(); + + tabChangeRaised = BrowserTestUtils.waitForEvent( + privateTabsChanges, + "TabRecencyChange" + ); + await SimpleTest.promiseFocus(privateWin); + changeEvent = await tabChangeRaised; + Assert.deepEqual( + changeEvent.detail.windowIds, + [getWindowId(privateWin)], + "The recency change event had the correct window id" + ); + Assert.ok( + nonPrivateListener.notCalled, + "The non-private listener got no recency-change events from the private window" + ); + Assert.ok( + privateListener.called, + "Sanity check the private tabs listener was called" + ); + + sortedTabs = privateTabsChanges.getRecentTabs(); + is( + sortedTabs[0], + privateWin.gBrowser.selectedTab, + "The most-recent tab is the selected tab in the current window" + ); + sortedTabs = NonPrivateTabs.getRecentTabs(); + is( + sortedTabs[0], + win1.gBrowser.selectedTab, + "The most-recent non-private tab is still the selected tab in the previous non-private window" + ); + + info("Verify adding a tab to a private window does the right thing"); + nonPrivateListener.resetHistory(); + privateListener.resetHistory(); + + tabChangeRaised = BrowserTestUtils.waitForEvent( + privateTabsChanges, + "TabRecencyChange" + ); + await BrowserTestUtils.openNewForegroundTab(privateWin.gBrowser, tabURL3); + changeEvent = await tabChangeRaised; + Assert.deepEqual( + changeEvent.detail.windowIds, + [getWindowId(privateWin)], + "The event had the correct window id" + ); + Assert.ok( + nonPrivateListener.notCalled, + "The non-private listener got no recency-change events from the private window" + ); + sortedTabs = privateTabsChanges.getRecentTabs(); + is( + tabUrl(sortedTabs[0]), + tabURL3, + "The most-recent tab is the tab we just opened in the private window" + ); + + nonPrivateListener.resetHistory(); + privateListener.resetHistory(); + + tabChangeRaised = BrowserTestUtils.waitForEvent( + privateTabsChanges, + "TabRecencyChange" + ); + BrowserTestUtils.switchTab(privateWin.gBrowser, privateWin.gBrowser.tabs[0]); + changeEvent = await tabChangeRaised; + Assert.deepEqual( + changeEvent.detail.windowIds, + [getWindowId(privateWin)], + "The event had the correct window id" + ); + Assert.ok( + nonPrivateListener.notCalled, + "The non-private listener got no recency-change events from the private window" + ); + sortedTabs = privateTabsChanges.getRecentTabs(); + is( + sortedTabs[0], + privateWin.gBrowser.selectedTab, + "The most-recent tab is the selected tab in the private window" + ); + + info("Verify switching back to a non-private does the right thing"); + nonPrivateListener.resetHistory(); + privateListener.resetHistory(); + + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabRecencyChange" + ); + await SimpleTest.promiseFocus(win1); + await tabChangeRaised; + if (privateListener.called) { + info(`The private listener was called ${privateListener.callCount} times`); + } + Assert.ok( + privateListener.notCalled, + "The private listener got no recency-change events for the non-private window" + ); + Assert.ok( + nonPrivateListener.called, + "Sanity-check the non-private listener got a recency-change event for the non-private window" + ); + + sortedTabs = privateTabsChanges.getRecentTabs(); + is( + sortedTabs[0], + privateWin.gBrowser.selectedTab, + "The most-recent private tab is unchanged" + ); + + sortedTabs = NonPrivateTabs.getRecentTabs(); + is( + sortedTabs[0], + win1.gBrowser.selectedTab, + "The most-recent non-private tab is the selected tab in the current window" + ); + + await cleanup("TabRecencyChange"); + while (win0.gBrowser.tabs.length > 1) { + info( + "Removing last tab:" + + win0.gBrowser.tabs.at(-1).linkedBrowser.currentURI.spec + ); + BrowserTestUtils.removeTab(win0.gBrowser.tabs.at(-1)); + info("Removed, tabs.length:" + win0.gBrowser.tabs.length); + } +}); + +add_task(async function test_tabNavigations() { + const { windows, cleanup } = await setup("TabChange"); + const [, win1, privateWin] = windows; + + // also listen for TabRecencyChange events + const nonPrivateRecencyListener = sinon.stub(); + const privateRecencyListener = sinon.stub(); + privateTabsChanges.addEventListener( + "TabRecencyChange", + privateRecencyListener + ); + NonPrivateTabs.addEventListener( + "TabRecencyChange", + nonPrivateRecencyListener + ); + + info( + `Verify navigating in tab generates TabChange & TabRecencyChange events` + ); + let loaded = BrowserTestUtils.browserLoaded(win1.gBrowser.selectedBrowser); + win1.gBrowser.selectedBrowser.loadURI(Services.io.newURI(tabURL4), { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + info("waiting for the load into win1 tab to complete"); + await loaded; + info("waiting for listeners to be called"); + await BrowserTestUtils.waitForCondition(() => { + return nonPrivateListener.called && nonPrivateRecencyListener.called; + }); + ok(!privateListener.called, "The private TabChange listener was not called"); + ok( + !privateRecencyListener.called, + "The private TabRecencyChange listener was not called" + ); + + nonPrivateListener.resetHistory(); + privateListener.resetHistory(); + nonPrivateRecencyListener.resetHistory(); + privateRecencyListener.resetHistory(); + + // Now verify the same with a private window + info( + `Verify navigating in private tab generates TabChange & TabRecencyChange events` + ); + ok( + !nonPrivateListener.called, + "The non-private TabChange listener is not yet called" + ); + + loaded = BrowserTestUtils.browserLoaded(privateWin.gBrowser.selectedBrowser); + privateWin.gBrowser.selectedBrowser.loadURI( + Services.io.newURI("about:robots"), + { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + } + ); + info("waiting for the load into privateWin tab to complete"); + await loaded; + info("waiting for the privateListeners to be called"); + await BrowserTestUtils.waitForCondition(() => { + return privateListener.called && privateRecencyListener.called; + }); + ok( + !nonPrivateListener.called, + "The non-private TabChange listener was not called" + ); + ok( + !nonPrivateRecencyListener.called, + "The non-private TabRecencyChange listener was not called" + ); + + // cleanup + privateTabsChanges.removeEventListener( + "TabRecencyChange", + privateRecencyListener + ); + NonPrivateTabs.removeEventListener( + "TabRecencyChange", + nonPrivateRecencyListener + ); + + await cleanup(); +}); + +add_task(async function test_tabsFromPrivateWindows() { + const { cleanup } = await setup("TabChange"); + const private2Listener = sinon.stub(); + + const private2Win = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + waitForTabURL: "about:privatebrowsing", + }); + const private2TabsChanges = getTabsTargetForWindow(private2Win); + private2TabsChanges.addEventListener("TabChange", private2Listener); + ok( + privateTabsChanges !== getTabsTargetForWindow(private2Win), + "getTabsTargetForWindow creates a distinct instance for a different private window" + ); + + await BrowserTestUtils.waitForCondition(() => private2Listener.called); + + ok( + !privateListener.called, + "No TabChange event was raised by opening a different private window" + ); + privateListener.resetHistory(); + private2Listener.resetHistory(); + + BrowserTestUtils.addTab(private2Win.gBrowser, tabURL1); + await BrowserTestUtils.waitForCondition(() => private2Listener.called); + ok( + !privateListener.called, + "No TabChange event was raised by adding tab to a different private window" + ); + + is( + privateTabsChanges.getRecentTabs().length, + 1, + "The recent tab count for the first private window tab target only reports the tabs for its associated windodw" + ); + is( + private2TabsChanges.getRecentTabs().length, + 2, + "The recent tab count for a 2nd private window tab target only reports the tabs for its associated windodw" + ); + + await cleanup("TabChange"); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_opentabs_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_opentabs_firefoxview.js new file mode 100644 index 0000000000..57d0f8d031 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_opentabs_firefoxview.js @@ -0,0 +1,423 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URL1 = "about:robots"; +const TEST_URL2 = "https://example.org/"; +const TEST_URL3 = "about:mozilla"; + +const fxaDevicesWithCommands = [ + { + id: 1, + name: "My desktop device", + availableCommands: { "https://identity.mozilla.com/cmd/open-uri": "test" }, + lastAccessTime: Date.now(), + }, + { + id: 2, + name: "My mobile device", + availableCommands: { "https://identity.mozilla.com/cmd/open-uri": "boo" }, + lastAccessTime: Date.now() + 60000, // add 30min + }, +]; + +const { NonPrivateTabs } = ChromeUtils.importESModule( + "resource:///modules/OpenTabs.sys.mjs" +); + +async function getRowsForCard(card) { + await TestUtils.waitForCondition(() => card.tabList.rowEls.length); + return card.tabList.rowEls; +} + +async function add_new_tab(URL) { + let tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabChange" + ); + let tab = BrowserTestUtils.addTab(gBrowser, URL); + // wait so we can reliably compare the tab URL + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await tabChangeRaised; + return tab; +} + +function getVisibleTabURLs(win = window) { + return win.gBrowser.visibleTabs.map(tab => tab.linkedBrowser.currentURI.spec); +} + +function getTabRowURLs(rows) { + return Array.from(rows).map(row => row.url); +} + +async function waitUntilRowsMatch(openTabs, cardIndex, expectedURLs) { + let card; + + info( + "moreMenuSetup: openTabs has openTabsTarget?:" + !!openTabs?.openTabsTarget + ); + //await openTabs.openTabsTarget.readyWindowsPromise; + info( + `waitUntilRowsMatch, wait for there to be at least ${cardIndex + 1} cards` + ); + await BrowserTestUtils.waitForCondition(() => { + if (!openTabs.initialWindowsReady) { + info("openTabs.initialWindowsReady isn't true"); + return false; + } + try { + card = getOpenTabsCards(openTabs)[cardIndex]; + } catch (ex) { + info("Calling getOpenTabsCards produced exception: " + ex.message); + } + return !!card; + }, "Waiting for openTabs to be ready and to get the cards"); + + const expectedURLsAsString = JSON.stringify(expectedURLs); + info(`Waiting for row URLs to match ${expectedURLs.join(", ")}`); + await BrowserTestUtils.waitForMutationCondition( + card.shadowRoot, + { characterData: true, childList: true, subtree: true }, + async () => { + let rows = await getRowsForCard(card); + return ( + rows.length == expectedURLs.length && + JSON.stringify(getTabRowURLs(rows)) == expectedURLsAsString + ); + } + ); +} + +async function getContextMenuPanelListForCard(card) { + let menuContainer = card.shadowRoot.querySelector( + "view-opentabs-contextmenu" + ); + ok(menuContainer, "Found the menuContainer for card"); + await TestUtils.waitForCondition( + () => menuContainer.panelList, + "Waiting for the context menu's panel-list to be rendered" + ); + ok( + menuContainer.panelList, + "Found the panelList in the card's view-opentabs-contextmenu" + ); + return menuContainer.panelList; +} + +async function openContextMenuForItem(tabItem, card) { + // click on the item's button element (more menu) + // and wait for the panel list to be shown + tabItem.buttonEl.click(); + // NOTE: menu must populate with devices data before it can be rendered + // so the creation of the panel-list can be async + let panelList = await getContextMenuPanelListForCard(card); + await BrowserTestUtils.waitForEvent(panelList, "shown"); + return panelList; +} + +async function moreMenuSetup() { + await add_new_tab(TEST_URL2); + await add_new_tab(TEST_URL3); + + // once we've opened a few tabs, navigate to the open tabs section in firefox view + await clickFirefoxViewButton(window); + const document = window.FirefoxViewHandler.tab.linkedBrowser.contentDocument; + + await navigateToCategoryAndWait(document, "opentabs"); + + let openTabs = document.querySelector("view-opentabs[name=opentabs]"); + setSortOption(openTabs, "tabStripOrder"); + await openTabs.openTabsTarget.readyWindowsPromise; + + info("waiting for openTabs' first card rows"); + await waitUntilRowsMatch(openTabs, 0, getVisibleTabURLs()); + + let cards = getOpenTabsCards(openTabs); + is(cards.length, 1, "There is one open window."); + + let rows = await getRowsForCard(cards[0]); + + let firstTab = rows[0]; + + firstTab.scrollIntoView(); + is( + isElInViewport(firstTab), + true, + "first tab list item is visible in viewport" + ); + + return [cards, rows]; +} + +add_task(async function test_more_menus() { + await withFirefoxView({}, async browser => { + let win = browser.ownerGlobal; + let shown, menuHidden; + + gBrowser.selectedTab = gBrowser.visibleTabs[0]; + Assert.equal( + gBrowser.selectedTab.linkedBrowser.currentURI.spec, + "about:blank", + "Selected tab is about:blank" + ); + + info(`Loading ${TEST_URL1} into the selected about:blank tab`); + let tabLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + win.gURLBar.focus(); + win.gURLBar.value = TEST_URL1; + EventUtils.synthesizeKey("KEY_Enter", {}, win); + await tabLoaded; + + info("Waiting for moreMenuSetup to resolve"); + let [cards, rows] = await moreMenuSetup(); + Assert.deepEqual( + getVisibleTabURLs(), + [TEST_URL1, TEST_URL2, TEST_URL3], + "Prepared 3 open tabs" + ); + + let firstTab = rows[0]; + // Open the panel list (more menu) from the first list item + let panelList = await openContextMenuForItem(firstTab, cards[0]); + + // Close Tab menu item + info("Panel list shown. Clicking on panel-item"); + let panelItem = panelList.querySelector( + "panel-item[data-l10n-id=fxviewtabrow-close-tab]" + ); + let panelItemButton = panelItem.shadowRoot.querySelector( + "button[role=menuitem]" + ); + ok(panelItem, "Close Tab panel item exists"); + ok( + panelItemButton, + "Close Tab panel item button with role=menuitem exists" + ); + + await clearAllParentTelemetryEvents(); + let contextMenuEvent = [ + [ + "firefoxview_next", + "context_menu", + "tabs", + undefined, + { menu_action: "close-tab", data_type: "opentabs" }, + ], + ]; + + // close a tab via the menu + let tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabChange" + ); + menuHidden = BrowserTestUtils.waitForEvent(panelList, "hidden"); + panelItemButton.click(); + info("Waiting for result of closing a tab via the menu"); + await tabChangeRaised; + await cards[0].getUpdateComplete(); + await menuHidden; + await telemetryEvent(contextMenuEvent); + + Assert.deepEqual( + getVisibleTabURLs(), + [TEST_URL2, TEST_URL3], + "Got the expected 2 open tabs" + ); + + let openTabs = cards[0].ownerDocument.querySelector( + "view-opentabs[name=opentabs]" + ); + await waitUntilRowsMatch(openTabs, 0, [TEST_URL2, TEST_URL3]); + + // Move Tab submenu item + firstTab = rows[0]; + is(firstTab.url, TEST_URL2, `First tab list item is ${TEST_URL2}`); + + panelList = await openContextMenuForItem(firstTab, cards[0]); + let moveTabsPanelItem = panelList.querySelector( + "panel-item[data-l10n-id=fxviewtabrow-move-tab]" + ); + + let moveTabsSubmenuList = moveTabsPanelItem.shadowRoot.querySelector( + "panel-list[id=move-tab-menu]" + ); + ok(moveTabsSubmenuList, "Move tabs submenu panel list exists"); + + // navigate down to the "Move tabs" submenu option, and + // open it with the right arrow key + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + shown = BrowserTestUtils.waitForEvent(moveTabsSubmenuList, "shown"); + EventUtils.synthesizeKey("KEY_ArrowRight", {}); + await shown; + + await clearAllParentTelemetryEvents(); + contextMenuEvent = [ + [ + "firefoxview_next", + "context_menu", + "tabs", + null, + { menu_action: "move-tab-end", data_type: "opentabs" }, + ], + ]; + + // click on the first option, which should be "Move to the end" since + // this is the first tab + menuHidden = BrowserTestUtils.waitForEvent(panelList, "hidden"); + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabChange" + ); + EventUtils.synthesizeKey("KEY_Enter", {}); + info("Waiting for result of moving a tab via the menu"); + await telemetryEvent(contextMenuEvent); + await menuHidden; + await tabChangeRaised; + + Assert.deepEqual( + getVisibleTabURLs(), + [TEST_URL3, TEST_URL2], + "The last tab became the first tab" + ); + + // this entire "move tabs" submenu test can be reordered above + // closing a tab since it very clearly reveals the issues + // outlined in bug 1852622 when there are 3 or more tabs open + // and one is moved via the more menus. + await waitUntilRowsMatch(openTabs, 0, [TEST_URL3, TEST_URL2]); + + // Copy Link menu item (copyLink function that's called is a member of Viewpage.mjs) + panelList = await openContextMenuForItem(firstTab, cards[0]); + firstTab = rows[0]; + panelItem = panelList.querySelector( + "panel-item[data-l10n-id=fxviewtabrow-copy-link]" + ); + panelItemButton = panelItem.shadowRoot.querySelector( + "button[role=menuitem]" + ); + ok(panelItem, "Copy link panel item exists"); + ok( + panelItemButton, + "Copy link panel item button with role=menuitem exists" + ); + + await clearAllParentTelemetryEvents(); + contextMenuEvent = [ + [ + "firefoxview_next", + "context_menu", + "tabs", + null, + { menu_action: "copy-link", data_type: "opentabs" }, + ], + ]; + + menuHidden = BrowserTestUtils.waitForEvent(panelList, "hidden"); + panelItemButton.click(); + info("Waiting for menuHidden"); + await menuHidden; + info("Waiting for telemetryEvent"); + await telemetryEvent(contextMenuEvent); + + let copiedText = SpecialPowers.getClipboardData( + "text/plain", + Ci.nsIClipboard.kGlobalClipboard + ); + is(copiedText, TEST_URL3, "The correct url has been copied and pasted"); + + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs[0]); + } + }); +}); + +add_task(async function test_send_device_submenu() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + ], + }); + sandbox + .stub(gSync, "getSendTabTargets") + .callsFake(() => fxaDevicesWithCommands); + + await withFirefoxView({}, async browser => { + // TEST_URL2 is our only tab, left over from previous test + Assert.deepEqual( + getVisibleTabURLs(), + [TEST_URL2], + `We initially have a single ${TEST_URL2} tab` + ); + let shown; + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + let [cards, rows] = await moreMenuSetup(document); + + let firstTab = rows[0]; + let panelList = await openContextMenuForItem(firstTab, cards[0]); + + let sendTabPanelItem = panelList.querySelector( + "panel-item[data-l10n-id=fxviewtabrow-send-tab]" + ); + + ok(sendTabPanelItem, "Send tabs to device submenu panel item exists"); + + let sendTabSubmenuList = sendTabPanelItem.shadowRoot.querySelector( + "panel-list[id=send-tab-menu]" + ); + ok(sendTabSubmenuList, "Send tabs to device submenu panel list exists"); + + // navigate down to the "Send tabs" submenu option, and + // open it with the right arrow key + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + + shown = BrowserTestUtils.waitForEvent(sendTabSubmenuList, "shown"); + EventUtils.synthesizeKey("KEY_ArrowRight", {}); + await shown; + + let expectation = sandbox + .mock(gSync) + .expects("sendTabToDevice") + .once() + .withExactArgs( + TEST_URL2, + [fxaDevicesWithCommands[0]], + "mochitest index /" + ) + .returns(true); + + await clearAllParentTelemetryEvents(); + let contextMenuEvent = [ + [ + "firefoxview_next", + "context_menu", + "tabs", + null, + { menu_action: "send-tab-device", data_type: "opentabs" }, + ], + ]; + + // click on the first device and verify it was "sent" + let menuHidden = BrowserTestUtils.waitForEvent(panelList, "hidden"); + EventUtils.synthesizeKey("KEY_Enter", {}); + + expectation.verify(); + await telemetryEvent(contextMenuEvent); + await menuHidden; + + sandbox.restore(); + TabsSetupFlowManager.resetInternalState(); + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs[0]); + } + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_opentabs_recency.js b/browser/components/firefoxview/tests/browser/browser_opentabs_recency.js new file mode 100644 index 0000000000..e5beb4700a --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_opentabs_recency.js @@ -0,0 +1,408 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + This test checks the recent-browsing view of open tabs in about:firefoxview next + presents the correct tab data in the correct order. +*/ + +const tabURL1 = "data:,Tab1"; +const tabURL2 = "data:,Tab2"; +const tabURL3 = "data:,Tab3"; +const tabURL4 = "data:,Tab4"; + +let gInitialTab; +let gInitialTabURL; +const { NonPrivateTabs } = ChromeUtils.importESModule( + "resource:///modules/OpenTabs.sys.mjs" +); + +add_setup(function () { + gInitialTab = gBrowser.selectedTab; + gInitialTabURL = tabUrl(gInitialTab); +}); + +function tabUrl(tab) { + return tab.linkedBrowser.currentURI?.spec; +} + +async function minimizeWindow(win) { + let promiseSizeModeChange = BrowserTestUtils.waitForEvent( + win, + "sizemodechange" + ); + win.minimize(); + await promiseSizeModeChange; + ok( + !win.gBrowser.selectedTab.linkedBrowser.docShellIsActive, + "Docshell should be Inactive" + ); + ok(win.document.hidden, "Top level window should be hidden"); +} + +async function restoreWindow(win) { + ok(win.document.hidden, "Top level window should be hidden"); + let promiseSizeModeChange = BrowserTestUtils.waitForEvent( + win, + "sizemodechange" + ); + + // Check if we also need to wait for occlusion to be updated. + let promiseOcclusion; + let willWaitForOcclusion = win.isFullyOccluded; + if (willWaitForOcclusion) { + // Not only do we need to wait for the occlusionstatechange event, + // we also have to wait *one more event loop* to ensure that the + // other listeners to the occlusionstatechange events have fired. + // Otherwise, our browsing context might not have become active + // at the point where we receive the occlusionstatechange event. + promiseOcclusion = BrowserTestUtils.waitForEvent( + win, + "occlusionstatechange" + ).then(() => new Promise(resolve => SimpleTest.executeSoon(resolve))); + } else { + promiseOcclusion = Promise.resolve(); + } + + info("Calling window.restore"); + win.restore(); + // From browser/base/content/test/general/browser_minimize.js: + // On Ubuntu `window.restore` doesn't seem to work, use a timer to make the + // test fail faster and more cleanly than with a test timeout. + info( + `Waiting for sizemodechange ${ + willWaitForOcclusion ? "and occlusionstatechange " : "" + }event` + ); + let timer; + await Promise.race([ + Promise.all([promiseSizeModeChange, promiseOcclusion]), + new Promise((resolve, reject) => { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + timer = setTimeout(() => { + reject( + `timed out waiting for sizemodechange sizemodechange ${ + willWaitForOcclusion ? "and occlusionstatechange " : "" + }event` + ); + }, 5000); + }), + ]); + clearTimeout(timer); + ok( + win.gBrowser.selectedTab.linkedBrowser.docShellIsActive, + "Docshell should be active again" + ); + ok(!win.document.hidden, "Top level window should be visible"); +} + +async function prepareOpenTabs(urls, win = window) { + const reusableTabURLs = ["about:newtab", "about:blank"]; + const gBrowser = win.gBrowser; + + for (let url of urls) { + if ( + gBrowser.visibleTabs.length == 1 && + reusableTabURLs.includes(gBrowser.selectedBrowser.currentURI.spec) + ) { + // we'll load into this tab rather than opening a new one + info( + `Loading ${url} into blank tab: ${gBrowser.selectedBrowser.currentURI.spec}` + ); + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, url); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, null, url); + } else { + info(`Loading ${url} into new tab`); + await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + } + await new Promise(res => win.requestAnimationFrame(res)); + } + Assert.equal( + gBrowser.visibleTabs.length, + urls.length, + `Prepared ${urls.length} tabs as expected` + ); + Assert.equal( + tabUrl(gBrowser.selectedTab), + urls[urls.length - 1], + "The selectedTab is the last of the URLs given as expected" + ); +} + +async function cleanup(...windowsToClose) { + await Promise.all( + windowsToClose.map(win => BrowserTestUtils.closeWindow(win)) + ); + + while (gBrowser.visibleTabs.length > 1) { + await SessionStoreTestUtils.closeTab(gBrowser.tabs.at(-1)); + } + if (gBrowser.selectedBrowser.currentURI.spec !== gInitialTabURL) { + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + gInitialTabURL + ); + await BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + null, + gInitialTabURL + ); + } +} + +function getOpenTabsComponent(browser) { + return browser.contentDocument.querySelector( + "view-recentbrowsing view-opentabs" + ); +} + +async function checkTabList(browser, expected) { + const tabsView = getOpenTabsComponent(browser); + const openTabsCard = tabsView.shadowRoot.querySelector("view-opentabs-card"); + await tabsView.getUpdateComplete(); + const tabList = openTabsCard.shadowRoot.querySelector("fxview-tab-list"); + Assert.ok(tabList, "Found the tab list element"); + await TestUtils.waitForCondition(() => tabList.rowEls.length); + let actual = Array.from(tabList.rowEls).map(row => row.url); + Assert.deepEqual( + actual, + expected, + "Tab list has items with URLs in the expected order" + ); +} + +add_task(async function test_single_window_tabs() { + await prepareOpenTabs([tabURL1, tabURL2]); + await openFirefoxViewTab(window).then(async viewTab => { + const browser = viewTab.linkedBrowser; + await checkTabList(browser, [tabURL2, tabURL1]); + + // switch to the first tab + let promiseHidden = BrowserTestUtils.waitForEvent( + browser.contentDocument, + "visibilitychange" + ); + + let tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabRecencyChange" + ); + await BrowserTestUtils.switchTab(gBrowser, gBrowser.visibleTabs[0]); + await promiseHidden; + await tabChangeRaised; + }); + + // and check the results in the open tabs section of Recent Browsing + await openFirefoxViewTab(window).then(async viewTab => { + const browser = viewTab.linkedBrowser; + await checkTabList(browser, [tabURL1, tabURL2]); + }); + await cleanup(); +}); + +add_task(async function test_multiple_window_tabs() { + const fxViewURL = getFirefoxViewURL(); + const win1 = window; + let tabChangeRaised; + await prepareOpenTabs([tabURL1, tabURL2]); + const win2 = await BrowserTestUtils.openNewBrowserWindow(); + await prepareOpenTabs([tabURL3, tabURL4], win2); + + // to avoid confusing the results by activating different windows, + // check fxview in the current window - which is win2 + info("Switching to fxview tab in win2"); + await openFirefoxViewTab(win2).then(async viewTab => { + const browser = viewTab.linkedBrowser; + await checkTabList(browser, [tabURL4, tabURL3, tabURL2, tabURL1]); + + Assert.equal( + tabUrl(win2.gBrowser.selectedTab), + fxViewURL, + `The selected tab in window 2 is ${fxViewURL}` + ); + + info("Switching to first tab (tab3) in win2"); + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabRecencyChange" + ); + let promiseHidden = BrowserTestUtils.waitForEvent( + browser.contentDocument, + "visibilitychange" + ); + await BrowserTestUtils.switchTab( + win2.gBrowser, + win2.gBrowser.visibleTabs[0] + ); + Assert.equal( + tabUrl(win2.gBrowser.selectedTab), + tabURL3, + `The selected tab in window 2 is ${tabURL3}` + ); + await tabChangeRaised; + await promiseHidden; + }); + + info("Opening fxview in win2 to confirm tab3 is most recent"); + await openFirefoxViewTab(win2).then(async viewTab => { + const browser = viewTab.linkedBrowser; + info("Check result of selecting 1ist tab in window 2"); + await checkTabList(browser, [tabURL3, tabURL4, tabURL2, tabURL1]); + }); + + info("Focusing win1, where tab2 should be selected"); + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabRecencyChange" + ); + await SimpleTest.promiseFocus(win1); + await tabChangeRaised; + Assert.equal( + tabUrl(win1.gBrowser.selectedTab), + tabURL2, + `The selected tab in window 1 is ${tabURL2}` + ); + + info("Opening fxview in win1 to confirm tab2 is most recent"); + await openFirefoxViewTab(win1).then(async viewTab => { + const browser = viewTab.linkedBrowser; + info( + "In fxview, check result of activating window 1, where tab 2 is selected" + ); + await checkTabList(browser, [tabURL2, tabURL3, tabURL4, tabURL1]); + + let promiseHidden = BrowserTestUtils.waitForEvent( + browser.contentDocument, + "visibilitychange" + ); + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabRecencyChange" + ); + info("Switching to first visible tab (tab1) in win1"); + await BrowserTestUtils.switchTab( + win1.gBrowser, + win1.gBrowser.visibleTabs[0] + ); + await promiseHidden; + await tabChangeRaised; + }); + + // check result in the fxview in the 1st window + info("Opening fxview in win1 to confirm tab1 is most recent"); + await openFirefoxViewTab(win1).then(async viewTab => { + const browser = viewTab.linkedBrowser; + info("Check result of selecting 1st tab in win1"); + await checkTabList(browser, [tabURL1, tabURL2, tabURL3, tabURL4]); + }); + + await cleanup(win2); +}); + +add_task(async function test_windows_activation() { + const win1 = window; + await prepareOpenTabs([tabURL1], win1); + let fxViewTab; + let tabChangeRaised; + info("switch to firefox-view and leave it selected"); + await openFirefoxViewTab(win1).then(tab => (fxViewTab = tab)); + + const win2 = await BrowserTestUtils.openNewBrowserWindow(); + await prepareOpenTabs([tabURL2], win2); + + const win3 = await BrowserTestUtils.openNewBrowserWindow(); + await prepareOpenTabs([tabURL3], win3); + await tabChangeRaised; + + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabRecencyChange" + ); + await SimpleTest.promiseFocus(win1); + await tabChangeRaised; + + const browser = fxViewTab.linkedBrowser; + await checkTabList(browser, [tabURL3, tabURL2, tabURL1]); + + info("switch to win2 and confirm its selected tab becomes most recent"); + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabRecencyChange" + ); + await SimpleTest.promiseFocus(win2); + await tabChangeRaised; + await checkTabList(browser, [tabURL2, tabURL3, tabURL1]); + await cleanup(win2, win3); +}); + +add_task(async function test_minimize_restore_windows() { + const win1 = window; + let tabChangeRaised; + await prepareOpenTabs([tabURL1, tabURL2]); + const win2 = await BrowserTestUtils.openNewBrowserWindow(); + await prepareOpenTabs([tabURL3, tabURL4], win2); + + // to avoid confusing the results by activating different windows, + // check fxview in the current window - which is win2 + info("Opening fxview in win2 to confirm tab4 is most recent"); + await openFirefoxViewTab(win2).then(async viewTab => { + const browser = viewTab.linkedBrowser; + await checkTabList(browser, [tabURL4, tabURL3, tabURL2, tabURL1]); + + let promiseHidden = BrowserTestUtils.waitForEvent( + browser.contentDocument, + "visibilitychange" + ); + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabRecencyChange" + ); + info("Switching to the first tab (tab3) in 2nd window"); + await BrowserTestUtils.switchTab( + win2.gBrowser, + win2.gBrowser.visibleTabs[0] + ); + await promiseHidden; + await tabChangeRaised; + }); + + // then minimize the window, focusing the 1st window + info("Minimizing win2, leaving tab 3 selected"); + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabRecencyChange" + ); + await minimizeWindow(win2); + info("Focusing win1, where tab2 is selected - making it most recent"); + await SimpleTest.promiseFocus(win1); + await tabChangeRaised; + + Assert.equal( + tabUrl(win1.gBrowser.selectedTab), + tabURL2, + `The selected tab in window 1 is ${tabURL2}` + ); + + info("Opening fxview in win1 to confirm tab2 is most recent"); + await openFirefoxViewTab(win1).then(async viewTab => { + const browser = viewTab.linkedBrowser; + await checkTabList(browser, [tabURL2, tabURL3, tabURL4, tabURL1]); + info( + "Restoring win2 and focusing it - which should make its selected tab most recent" + ); + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabRecencyChange" + ); + await restoreWindow(win2); + await SimpleTest.promiseFocus(win2); + await tabChangeRaised; + + info( + "Checking tab order in fxview in win1, to confirm tab3 is most recent" + ); + await checkTabList(browser, [tabURL3, tabURL2, tabURL4, tabURL1]); + }); + + await cleanup(win2); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_opentabs_tab_indicators.js b/browser/components/firefoxview/tests/browser/browser_opentabs_tab_indicators.js new file mode 100644 index 0000000000..1375052125 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_opentabs_tab_indicators.js @@ -0,0 +1,207 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { NonPrivateTabs } = ChromeUtils.importESModule( + "resource:///modules/OpenTabs.sys.mjs" +); + +let pageWithAlert = + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + "http://example.com/browser/browser/base/content/test/tabPrompts/openPromptOffTimeout.html"; +let pageWithSound = + "http://mochi.test:8888/browser/dom/base/test/file_audioLoop.html"; + +function cleanup() { + // Cleanup + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs[0]); + } +} + +add_task(async function test_notification_dot_indicator() { + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + let win = browser.ownerGlobal; + await navigateToCategoryAndWait(document, "opentabs"); + // load page that opens prompt when page is hidden + let openedTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + pageWithAlert, + true + ); + let openedTabGotAttentionPromise = BrowserTestUtils.waitForAttribute( + "attention", + openedTab + ); + + let tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabChange" + ); + + await switchToFxViewTab(); + + let openTabs = document.querySelector("view-opentabs[name=opentabs]"); + + await openedTabGotAttentionPromise; + await tabChangeRaised; + await openTabs.updateComplete; + + await TestUtils.waitForCondition( + () => openTabs.viewCards[0].tabList.rowEls[1].attention, + "The opened tab doesn't have the attention property, so no notification dot is shown." + ); + + info("The newly opened tab has a notification dot."); + + // Switch back to other tab to close prompt before cleanup + await BrowserTestUtils.switchTab(gBrowser, openedTab); + EventUtils.synthesizeKey("KEY_Enter", {}, win); + + cleanup(); + }); +}); + +add_task(async function test_container_indicator() { + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + let win = browser.ownerGlobal; + + // Load a page in a container tab + let userContextId = 1; + let containerTab = BrowserTestUtils.addTab(win.gBrowser, URLs[0], { + userContextId, + }); + + await BrowserTestUtils.browserLoaded( + containerTab.linkedBrowser, + false, + URLs[0] + ); + + await navigateToCategoryAndWait(document, "opentabs"); + + let openTabs = document.querySelector("view-opentabs[name=opentabs]"); + + await TestUtils.waitForCondition( + () => + containerTab.getAttribute("usercontextid") === userContextId.toString(), + "The container tab doesn't have the usercontextid attribute." + ); + await openTabs.updateComplete; + await TestUtils.waitForCondition( + () => openTabs.viewCards[0].tabList?.rowEls.length, + "The tab list hasn't rendered." + ); + info("openTabs component has finished updating."); + + let containerTabElem = openTabs.viewCards[0].tabList.rowEls[1]; + + await TestUtils.waitForCondition( + () => containerTabElem.containerObj, + "The container tab element isn't marked in Fx View." + ); + + ok( + containerTabElem.shadowRoot + .querySelector(".fxview-tab-row-container-indicator") + .classList.contains("identity-color-blue"), + "The container color is blue." + ); + + info("The newly opened tab is marked as a container tab."); + + cleanup(); + }); +}); + +add_task(async function test_sound_playing_muted_indicator() { + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToCategoryAndWait(document, "opentabs"); + + // Load a page in a container tab + let soundTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + pageWithSound, + true + ); + + let tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabChange" + ); + + await switchToFxViewTab(); + + let openTabs = document.querySelector("view-opentabs[name=opentabs]"); + + await TestUtils.waitForCondition(() => + soundTab.hasAttribute("soundplaying") + ); + await tabChangeRaised; + await openTabs.updateComplete; + await TestUtils.waitForCondition( + () => openTabs.viewCards[0].tabList?.rowEls.length, + "The tab list hasn't rendered." + ); + + let soundPlayingTabElem = openTabs.viewCards[0].tabList.rowEls[1]; + + await TestUtils.waitForCondition(() => soundPlayingTabElem.soundPlaying); + + ok( + soundPlayingTabElem.mediaButtonEl, + "The tab has the mute button showing." + ); + + tabChangeRaised = BrowserTestUtils.waitForEvent( + NonPrivateTabs, + "TabChange" + ); + + // Mute the tab + EventUtils.synthesizeMouseAtCenter( + soundPlayingTabElem.mediaButtonEl, + {}, + content + ); + + await TestUtils.waitForCondition( + () => soundTab.hasAttribute("muted"), + "The tab doesn't have the muted attribute." + ); + await tabChangeRaised; + await openTabs.updateComplete; + + await TestUtils.waitForCondition(() => soundPlayingTabElem.muted); + + ok( + soundPlayingTabElem.mediaButtonEl, + "The tab has the unmute button showing." + ); + + // Mute and unmute the tab and make sure the element in Fx View updates + soundTab.toggleMuteAudio(); + await tabChangeRaised; + await openTabs.updateComplete; + await TestUtils.waitForCondition(() => soundPlayingTabElem.soundPlaying); + + ok( + soundPlayingTabElem.mediaButtonEl, + "The tab has the mute button showing." + ); + + soundTab.toggleMuteAudio(); + await tabChangeRaised; + await openTabs.updateComplete; + await TestUtils.waitForCondition(() => soundPlayingTabElem.muted); + + ok( + soundPlayingTabElem.mediaButtonEl, + "The tab has the unmute button showing." + ); + + cleanup(); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_recentlyclosed_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_recentlyclosed_firefoxview.js new file mode 100644 index 0000000000..313d86416e --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_recentlyclosed_firefoxview.js @@ -0,0 +1,600 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +requestLongerTimeout(2); + +ChromeUtils.defineESModuleGetters(globalThis, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +const NEVER_REMEMBER_HISTORY_PREF = "browser.privatebrowsing.autostart"; +const SEARCH_ENABLED_PREF = "browser.firefox-view.search.enabled"; +const RECENTLY_CLOSED_EVENT = [ + ["firefoxview_next", "recently_closed", "tabs", undefined], +]; +const DISMISS_CLOSED_TAB_EVENT = [ + ["firefoxview_next", "dismiss_closed_tab", "tabs", undefined], +]; +const initialTab = gBrowser.selectedTab; + +async function restore_tab(itemElem, browser, expectedURL) { + info(`Restoring tab ${itemElem.url}`); + let tabRestored = BrowserTestUtils.waitForNewTab( + browser.getTabBrowser(), + expectedURL + ); + await click_recently_closed_tab_item(itemElem, "main"); + await tabRestored; +} + +async function dismiss_tab(itemElem) { + info(`Dismissing tab ${itemElem.url}`); + return click_recently_closed_tab_item(itemElem, "dismiss"); +} + +async function tabTestCleanup() { + await promiseAllButPrimaryWindowClosed(); + for (let tab of gBrowser.visibleTabs) { + if (tab == initialTab) { + continue; + } + await TabStateFlusher.flush(tab.linkedBrowser); + let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + BrowserTestUtils.removeTab(tab); + await sessionUpdatePromise; + } + Services.obs.notifyObservers(null, "browser:purge-session-history"); +} + +async function prepareSingleClosedTab() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is( + SessionStore.getClosedTabCount(window), + 0, + "Closed tab count after purging session history" + ); + await open_then_close(URLs[0]); + return { + cleanup: tabTestCleanup, + }; +} + +async function prepareClosedTabs() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is( + SessionStore.getClosedTabCount(window), + 0, + "Closed tab count after purging session history" + ); + is( + SessionStore.getClosedTabCountFromClosedWindows(), + 0, + "Closed tab count after purging session history" + ); + + await open_then_close(URLs[0]); + await open_then_close(URLs[1]); + + // create 1 recently-closed tabs in a 2nd window + info("Opening win2 and open/closing tabs in it"); + const win2 = await BrowserTestUtils.openNewBrowserWindow(); + // open a non-transitory, worth-keeping tab to ensure window data is saved on close + await BrowserTestUtils.openNewForegroundTab(win2.gBrowser, "about:mozilla"); + await open_then_close(URLs[2], win2); + + info("Opening win3 and open/closing a tab in it"); + const win3 = await BrowserTestUtils.openNewBrowserWindow(); + // open a non-transitory, worth-keeping tab to ensure window data is saved on close + await BrowserTestUtils.openNewForegroundTab(win3.gBrowser, "about:mozilla"); + await open_then_close(URLs[3], win3); + + // close the 3rd window with its 1 recently-closed tab + info("closing win3 and waiting for sessionstore-closed-objects-changed"); + await BrowserTestUtils.closeWindow(win3); + + // refocus and bring the initial window to the foreground + await SimpleTest.promiseFocus(window); + + // this is the order we expect for all the recently-closed tabs + const expectedURLs = [ + "https://example.org/", // URLS[3] + "https://example.net/", // URLS[2] + "https://www.example.com/", // URLS[1] + "http://mochi.test:8888/browser/", // URLS[0] + ]; + const preparedClosedTabCount = expectedURLs.length; + + const closedTabsFromClosedWindowsCount = + SessionStore.getClosedTabCountFromClosedWindows(); + is( + closedTabsFromClosedWindowsCount, + 1, + "Expected 1 closed tab from a closed window" + ); + + const closedTabsFromOpenWindowsCount = SessionStore.getClosedTabCount({ + sourceWindow: window, + closedTabsFromClosedWindows: false, + }); + const actualClosedTabCount = SessionStore.getClosedTabCount(); + is( + closedTabsFromOpenWindowsCount, + 3, + "Expected 3 closed tabs currently-open windows" + ); + + is( + actualClosedTabCount, + preparedClosedTabCount, + `SessionStore reported the expected number (${actualClosedTabCount}) of closed tabs` + ); + + return { + cleanup: tabTestCleanup, + // return a list of the tab urls we closed in the order we closed them + closedTabURLs: [...URLs.slice(0, 4)], + expectedURLs, + }; +} + +async function recentlyClosedTelemetry() { + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 1; + }, + "Waiting for recently_closed firefoxview telemetry event.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + RECENTLY_CLOSED_EVENT, + { category: "firefoxview_next" }, + { clear: true, process: "parent" } + ); +} + +async function recentlyClosedDismissTelemetry() { + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 1; + }, + "Waiting for dismiss_closed_tab firefoxview telemetry event.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + DISMISS_CLOSED_TAB_EVENT, + { category: "firefoxview_next" }, + { clear: true, process: "parent" } + ); +} + +add_setup(async () => { + await SpecialPowers.pushPrefEnv({ + set: [[SEARCH_ENABLED_PREF, true]], + }); + registerCleanupFunction(async () => { + await SpecialPowers.popPrefEnv(); + clearHistory(); + }); +}); + +/** + * Asserts that we get the expected initial recently-closed tab list item + */ +add_task(async function test_initial_closed_tab() { + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + is(document.location.href, getFirefoxViewURL()); + await navigateToCategoryAndWait(document, "recentlyclosed"); + let { cleanup } = await prepareSingleClosedTab(); + await switchToFxViewTab(window); + let [listItems] = await waitForRecentlyClosedTabsList(document); + + Assert.strictEqual( + listItems.rowEls.length, + 1, + "Initial list item is rendered." + ); + + await cleanup(); + }); +}); + +/** + * Asserts that we get the expected order recently-closed tab list items given a known + * sequence of tab closures + */ +add_task(async function test_list_ordering() { + let { cleanup, expectedURLs } = await prepareClosedTabs(); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await clearAllParentTelemetryEvents(); + navigateToCategory(document, "recentlyclosed"); + let [cardMainSlotNode, listItems] = await waitForRecentlyClosedTabsList( + document + ); + + is( + cardMainSlotNode.tagName.toLowerCase(), + "fxview-tab-list", + "The tab list component is rendered." + ); + + Assert.deepEqual( + Array.from(listItems).map(el => el.url), + expectedURLs, + "The initial list has rendered the expected tab items in the right order" + ); + }); + await cleanup(); +}); + +/** + * Asserts that an out-of-band update to recently-closed tabs results in the correct update to the tab list + */ +add_task(async function test_list_updates() { + let { cleanup, expectedURLs } = await prepareClosedTabs(); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + navigateToCategory(document, "recentlyclosed"); + + let [listElem, listItems] = await waitForRecentlyClosedTabsList(document); + Assert.deepEqual( + Array.from(listItems).map(el => el.url), + expectedURLs, + "The initial list has rendered the expected tab items in the right order" + ); + + // the first tab we opened and closed is the last in the list + let closedTabItem = listItems[listItems.length - 1]; + is( + closedTabItem.url, + "http://mochi.test:8888/browser/", + "Sanity-check the least-recently closed tab is https://example.org/" + ); + info( + `Restore the last (least-recently) closed tab ${closedTabItem.url}, closedId: ${closedTabItem.closedId} and wait for sessionstore-closed-objects-changed` + ); + let promiseClosedObjectsChanged = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + SessionStore.undoCloseById(closedTabItem.closedId); + await promiseClosedObjectsChanged; + await clickFirefoxViewButton(window); + + // we expect the last item to be removed + expectedURLs.pop(); + listItems = listElem.rowEls; + + is( + listItems.length, + 3, + `Three tabs are shown in the list: ${Array.from(listItems).map( + el => el.url + )}, of ${expectedURLs.length} expectedURLs: ${expectedURLs}` + ); + Assert.deepEqual( + Array.from(listItems).map(el => el.url), + expectedURLs, + "The updated list has rendered the expected tab items in the right order" + ); + + // forget the window the most-recently closed tab was in and verify the list is correctly updated + closedTabItem = listItems[0]; + promiseClosedObjectsChanged = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + SessionStore.forgetClosedWindowById(closedTabItem.sourceClosedId); + await promiseClosedObjectsChanged; + await clickFirefoxViewButton(window); + + listItems = listElem.rowEls; + expectedURLs.shift(); // we expect to have removed the firsts URL from the list + is(listItems.length, 2, "Two tabs are shown in the list."); + Assert.deepEqual( + Array.from(listItems).map(el => el.url), + expectedURLs, + "After forgetting the closed window that owned the last recent tab, we have expected tab items in the right order" + ); + }); + await cleanup(); +}); + +/** + * Asserts that tabs that have been recently closed can be + * restored by clicking on the list item + */ +add_task(async function test_restore_tab() { + let { cleanup, expectedURLs } = await prepareClosedTabs(); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + navigateToCategory(document, "recentlyclosed"); + + let [listElem, listItems] = await waitForRecentlyClosedTabsList(document); + Assert.deepEqual( + Array.from(listItems).map(el => el.url), + expectedURLs, + "The initial list has rendered the expected tab items in the right order" + ); + let closeTabItem = listItems[0]; + info( + `Restoring the first closed tab ${closeTabItem.url}, closedId: ${closeTabItem.closedId}, sourceClosedId: ${closeTabItem.sourceClosedId} and waiting for sessionstore-closed-objects-changed` + ); + await clearAllParentTelemetryEvents(); + await restore_tab(closeTabItem, browser, closeTabItem.url); + await recentlyClosedTelemetry(); + await clickFirefoxViewButton(window); + + listItems = listElem.rowEls; + is(listItems.length, 3, "Three tabs are shown in the list."); + + closeTabItem = listItems[listItems.length - 1]; + await clearAllParentTelemetryEvents(); + await restore_tab(closeTabItem, browser, closeTabItem.url); + await recentlyClosedTelemetry(); + await clickFirefoxViewButton(window); + + listItems = listElem.rowEls; + is(listItems.length, 2, "Two tabs are shown in the list."); + + listItems = listElem.rowEls; + is(listItems.length, 2, "Two tabs are shown in the list."); + }); + await cleanup(); +}); + +/** + * Asserts that tabs that have been recently closed can be + * dismissed by clicking on their respective dismiss buttons. + */ +add_task(async function test_dismiss_tab() { + let { cleanup, expectedURLs } = await prepareClosedTabs(); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + navigateToCategory(document, "recentlyclosed"); + + let [listElem, listItems] = await waitForRecentlyClosedTabsList(document); + await clearAllParentTelemetryEvents(); + + info("calling dismiss_tab on the top, most-recently closed tab"); + let closedTabItem = listItems[0]; + + // dismiss the first tab and verify the list is correctly updated + await dismiss_tab(closedTabItem); + await listElem.getUpdateComplete; + + info("check telemetry results"); + await recentlyClosedDismissTelemetry(); + + listItems = listElem.rowEls; + expectedURLs.shift(); // we expect to have removed the first URL from the list + Assert.deepEqual( + Array.from(listItems).map(el => el.url), + expectedURLs, + "After dismissing the most-recent tab we have expected tab items in the right order" + ); + + // dismiss the last tab and verify the list is correctly updated + closedTabItem = listItems[listItems.length - 1]; + await dismiss_tab(closedTabItem); + await listElem.getUpdateComplete; + + listItems = listElem.rowEls; + expectedURLs.pop(); // we expect to have removed the last URL from the list + let actualClosedTabCount = + SessionStore.getClosedTabCount(window) + + SessionStore.getClosedTabCountFromClosedWindows(); + Assert.equal( + actualClosedTabCount, + 2, + "After dismissing the least-recent tab, SessionStore has 2 left" + ); + Assert.deepEqual( + Array.from(listItems).map(el => el.url), + expectedURLs, + "After dismissing the least-recent tab we have expected tab items in the right order" + ); + }); + await cleanup(); +}); + +add_task(async function test_empty_states() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is( + SessionStore.getClosedTabCount(window), + 0, + "Closed tab count after purging session history" + ); + is( + SessionStore.getClosedTabCountFromClosedWindows(), + 0, + "Closed tabs-from-closed-windows count after purging session history" + ); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + is(document.location.href, "about:firefoxview"); + + navigateToCategory(document, "recentlyclosed"); + let recentlyClosedComponent = document.querySelector( + "view-recentlyclosed:not([slot=recentlyclosed])" + ); + + await TestUtils.waitForCondition(() => recentlyClosedComponent.emptyState); + let emptyStateCard = recentlyClosedComponent.emptyState; + ok( + emptyStateCard.headerEl.textContent.includes("Closed a tab too soon"), + "Initial empty state header has the expected text." + ); + ok( + emptyStateCard.descriptionEls[0].textContent.includes( + "Here you’ll find the tabs you recently closed" + ), + "Initial empty state description has the expected text." + ); + + // Test empty state when History mode is set to never remember + Services.prefs.setBoolPref(NEVER_REMEMBER_HISTORY_PREF, true); + // Manually update the recentlyclosed component from the test, since changing this setting + // in about:preferences will require a browser reload + recentlyClosedComponent.requestUpdate(); + await TestUtils.waitForCondition( + () => recentlyClosedComponent.fullyUpdated + ); + emptyStateCard = recentlyClosedComponent.emptyState; + ok( + emptyStateCard.headerEl.textContent.includes("Nothing to show"), + "Empty state with never remember history header has the expected text." + ); + ok( + emptyStateCard.descriptionEls[1].textContent.includes( + "remember your activity as you browse. To change that" + ), + "Empty state with never remember history description has the expected text." + ); + // Reset History mode to Remember + Services.prefs.setBoolPref(NEVER_REMEMBER_HISTORY_PREF, false); + gBrowser.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test_observers_removed_when_view_is_hidden() { + clearHistory(); + + await open_then_close(URLs[0]); + + await withFirefoxView({}, async function (browser) { + const { document } = browser.contentWindow; + navigateToCategory(document, "recentlyclosed"); + const [listElem] = await waitForRecentlyClosedTabsList(document); + is(listElem.rowEls.length, 1); + + const gBrowser = browser.getTabBrowser(); + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URLs[1]); + await open_then_close(URLs[2]); + await open_then_close(URLs[3]); + await open_then_close(URLs[4]); + is( + listElem.rowEls.length, + 1, + "The list does not update when Firefox View is hidden." + ); + + await switchToFxViewTab(browser.ownerGlobal); + info("The list should update when Firefox View is visible."); + await BrowserTestUtils.waitForMutationCondition( + listElem, + { childList: true }, + () => listElem.rowEls.length === 4 + ); + + BrowserTestUtils.removeTab(tab); + }); +}); + +add_task(async function test_search() { + let { cleanup, expectedURLs } = await prepareClosedTabs(); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + navigateToCategory(document, "recentlyclosed"); + const [listElem] = await waitForRecentlyClosedTabsList(document); + const recentlyClosedComponent = document.querySelector( + "view-recentlyclosed:not([slot=recentlyclosed])" + ); + const { searchTextbox, tabList } = recentlyClosedComponent; + + info("Input a search query."); + EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content); + EventUtils.sendString("example.com", content); + await TestUtils.waitForCondition( + () => listElem.rowEls.length === 1, + "There is one matching search result." + ); + + info("Clear the search query."); + EventUtils.synthesizeMouseAtCenter(searchTextbox.clearButton, {}, content); + await TestUtils.waitForCondition( + () => listElem.rowEls.length === expectedURLs.length, + "The original list is restored." + ); + searchTextbox.blur(); + + info("Input a bogus search query with keyboard."); + EventUtils.synthesizeKey("f", { accelKey: true }, content); + EventUtils.sendString("Bogus Query", content); + await TestUtils.waitForCondition( + () => tabList.shadowRoot.querySelector("fxview-empty-state"), + "There are no matching search results." + ); + + info("Clear the search query with keyboard."); + EventUtils.synthesizeMouseAtCenter(searchTextbox.clearButton, {}, content); + + is( + recentlyClosedComponent.shadowRoot.activeElement, + searchTextbox, + "Search input is focused" + ); + EventUtils.synthesizeKey("KEY_Tab", {}, content); + EventUtils.synthesizeKey("KEY_Enter", {}, content); + await TestUtils.waitForCondition( + () => listElem.rowEls.length === expectedURLs.length, + "The original list is restored." + ); + }); + await cleanup(); +}); + +add_task(async function test_search_recent_browsing() { + const NUMBER_OF_TABS = 6; + clearHistory(); + for (let i = 0; i < NUMBER_OF_TABS; i++) { + await open_then_close(URLs[1]); + } + await withFirefoxView({}, async function (browser) { + const { document } = browser.contentWindow; + + info("Input a search query."); + await navigateToCategoryAndWait(document, "recentbrowsing"); + const recentBrowsing = document.querySelector("view-recentbrowsing"); + EventUtils.synthesizeMouseAtCenter( + recentBrowsing.searchTextbox, + {}, + content + ); + EventUtils.sendString("example.com", content); + const slot = recentBrowsing.querySelector("[slot='recentlyclosed']"); + await TestUtils.waitForCondition( + () => + slot.tabList.rowEls.length === 5 && + slot.shadowRoot.querySelector("[data-l10n-id='firefoxview-show-all']"), + "Not all search results are shown yet." + ); + + info("Click the Show All link."); + const showAllLink = slot.shadowRoot.querySelector( + "[data-l10n-id='firefoxview-show-all']" + ); + is(showAllLink.role, "link", "The show all control is a link."); + EventUtils.synthesizeMouseAtCenter(showAllLink, {}, content); + await TestUtils.waitForCondition( + () => slot.tabList.rowEls.length === NUMBER_OF_TABS, + "All search results are shown." + ); + ok(BrowserTestUtils.isHidden(showAllLink), "The show all link is hidden."); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_reload_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_reload_firefoxview.js new file mode 100644 index 0000000000..f9a226bbf2 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_reload_firefoxview.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* + Ensures that the Firefox View tab can be reloaded via: + - Clicking the Refresh button in the toolbar + - Using the various keyboard shortcuts +*/ +add_task(async function test_reload_firefoxview() { + await withFirefoxView({}, async browser => { + let reloadButton = document.getElementById("reload-button"); + let tabLoaded = BrowserTestUtils.browserLoaded(browser); + EventUtils.synthesizeMouseAtCenter(reloadButton, {}, browser.ownerGlobal); + await tabLoaded; + ok(true, "Firefox View loaded after clicking the Reload button"); + + let keys = [ + ["R", { accelKey: true }], + ["R", { accelKey: true, shift: true }], + ["VK_F5", {}], + ]; + + if (AppConstants.platform != "macosx") { + keys.push(["VK_F5", { accelKey: true }]); + } + + for (let key of keys) { + tabLoaded = BrowserTestUtils.browserLoaded(browser); + EventUtils.synthesizeKey(key[0], key[1], browser.ownerGlobal); + await tabLoaded; + ok(true, `Firefox view loaded after using ${key}`); + } + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_syncedtabs_errors_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_syncedtabs_errors_firefoxview.js new file mode 100644 index 0000000000..15dba68551 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_syncedtabs_errors_firefoxview.js @@ -0,0 +1,141 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { LoginTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" +); + +async function setupWithDesktopDevices(state = UIState.STATUS_SIGNED_IN) { + const sandbox = setupSyncFxAMocks({ + state, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + { + id: 2, + name: "Other Device", + type: "desktop", + tabs: [], + }, + ], + }); + return sandbox; +} + +async function tearDown(sandbox) { + sandbox?.restore(); + Services.prefs.clearUserPref("services.sync.lastTabFetch"); + Services.prefs.clearUserPref("identity.fxaccounts.enabled"); +} + +add_setup(async function () { + // gSync.init() is called in a requestIdleCallback. Force its initialization. + gSync.init(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["services.sync.engine.tabs", true], + ["identity.fxaccounts.enabled", true], + ], + }); + + registerCleanupFunction(async function () { + // reset internal state so it doesn't affect the next tests + TabsSetupFlowManager.resetInternalState(); + await tearDown(gSandbox); + }); +}); + +add_task(async function test_network_offline() { + const sandbox = await setupWithDesktopDevices(); + sandbox.spy(TabsSetupFlowManager, "tryToClearError"); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToCategoryAndWait(document, "syncedtabs"); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + Services.obs.notifyObservers( + null, + "network:offline-status-changed", + "offline" + ); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated); + await BrowserTestUtils.waitForMutationCondition( + syncedTabsComponent.shadowRoot.querySelector(".cards-container"), + { childList: true }, + () => syncedTabsComponent.shadowRoot.innerHTML.includes("network-offline") + ); + + let emptyState = + syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); + ok( + emptyState.getAttribute("headerlabel").includes("network-offline"), + "Network offline message is shown" + ); + emptyState.querySelector("button[data-action='network-offline']").click(); + + await BrowserTestUtils.waitForCondition( + () => TabsSetupFlowManager.tryToClearError.calledOnce + ); + + ok( + TabsSetupFlowManager.tryToClearError.calledOnce, + "TabsSetupFlowManager.tryToClearError() was called once" + ); + + emptyState = + syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); + ok( + emptyState.getAttribute("headerlabel").includes("network-offline"), + "Network offline message is still shown" + ); + + Services.obs.notifyObservers( + null, + "network:offline-status-changed", + "online" + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_sync_error() { + const sandbox = await setupWithDesktopDevices(); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToCategoryAndWait(document, "syncedtabs"); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + Services.obs.notifyObservers(null, "weave:service:sync:error"); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated); + await BrowserTestUtils.waitForMutationCondition( + syncedTabsComponent.shadowRoot.querySelector(".cards-container"), + { childList: true }, + () => syncedTabsComponent.shadowRoot.innerHTML.includes("sync-error") + ); + + let emptyState = + syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); + ok( + emptyState.getAttribute("headerlabel").includes("sync-error"), + "Correct message should show when there's a sync service error" + ); + + // Clear the error. + Services.obs.notifyObservers(null, "weave:service:sync:finish"); + }); + await tearDown(sandbox); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js new file mode 100644 index 0000000000..8a3c63985b --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js @@ -0,0 +1,747 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + registerCleanupFunction(() => { + // reset internal state so it doesn't affect the next tests + TabsSetupFlowManager.resetInternalState(); + }); + + // gSync.init() is called in a requestIdleCallback. Force its initialization. + gSync.init(); + + registerCleanupFunction(async function () { + await tearDown(gSandbox); + }); +}); + +async function promiseTabListsUpdated({ tabLists }) { + for (const tabList of tabLists) { + await tabList.updateComplete; + } + await TestUtils.waitForTick(); +} + +add_task(async function test_unconfigured_initial_state() { + const sandbox = setupMocks({ + state: UIState.STATUS_NOT_CONFIGURED, + syncEnabled: false, + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToCategoryAndWait(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + + let emptyState = + syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); + ok( + emptyState.getAttribute("headerlabel").includes("syncedtabs-signin"), + "Signin message is shown" + ); + + // Test telemetry for signing into Firefox Accounts. + await clearAllParentTelemetryEvents(); + EventUtils.synthesizeMouseAtCenter( + emptyState.querySelector(`button[data-action="sign-in"]`), + {}, + browser.contentWindow + ); + await TestUtils.waitForCondition( + () => + Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS + ).parent?.length >= 1, + "Waiting for fxa_continue firefoxview telemetry event.", + 200, + 100 + ); + TelemetryTestUtils.assertEvents( + [["firefoxview_next", "fxa_continue", "sync"]], + { category: "firefoxview_next" }, + { clear: true, process: "parent" } + ); + await BrowserTestUtils.removeTab(browser.ownerGlobal.gBrowser.selectedTab); + }); + await tearDown(sandbox); +}); + +add_task(async function test_signed_in() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + ], + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToCategoryAndWait(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated); + let emptyState = + syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); + ok( + emptyState.getAttribute("headerlabel").includes("syncedtabs-adddevice"), + "Add device message is shown" + ); + + // Test telemetry for adding a device. + await clearAllParentTelemetryEvents(); + EventUtils.synthesizeMouseAtCenter( + emptyState.querySelector(`button[data-action="add-device"]`), + {}, + browser.contentWindow + ); + await TestUtils.waitForCondition( + () => + Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS + ).parent?.length >= 1, + "Waiting for fxa_mobile firefoxview telemetry event.", + 200, + 100 + ); + TelemetryTestUtils.assertEvents( + [["firefoxview_next", "fxa_mobile", "sync"]], + { category: "firefoxview_next" }, + { clear: true, process: "parent" } + ); + await BrowserTestUtils.removeTab(browser.ownerGlobal.gBrowser.selectedTab); + }); + await tearDown(sandbox); +}); + +add_task(async function test_no_synced_tabs() { + 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: "desktop", + tabs: [], + }, + ], + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToCategoryAndWait(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated); + let emptyState = + syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); + ok( + emptyState.getAttribute("headerlabel").includes("syncedtabs-synctabs"), + "Enable synced tabs message is shown" + ); + }); + await tearDown(sandbox); + Services.prefs.setBoolPref("services.sync.engine.tabs", true); +}); + +add_task(async function test_no_error_for_two_desktop() { + 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: "desktop", + tabs: [], + }, + ], + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToCategoryAndWait(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated); + let emptyState = + syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); + is(emptyState, null, "No empty state should be shown"); + let noTabs = syncedTabsComponent.shadowRoot.querySelectorAll(".notabs"); + is(noTabs.length, 1, "Should be 1 empty device"); + }); + await tearDown(sandbox); +}); + +add_task(async function test_empty_state() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + { + id: 2, + name: "Other Desktop", + type: "desktop", + tabs: [], + }, + { + id: 3, + name: "Other Mobile", + type: "phone", + tabs: [], + }, + ], + }); + + await withFirefoxView({ openNewWindow: true }, async browser => { + const { document } = browser.contentWindow; + await navigateToCategoryAndWait(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated); + let noTabs = syncedTabsComponent.shadowRoot.querySelectorAll(".notabs"); + is(noTabs.length, 2, "Should be 2 empty devices"); + + let headers = + syncedTabsComponent.shadowRoot.querySelectorAll("h3[slot=header]"); + ok( + headers[0].textContent.includes("Other Desktop"), + "Text is correct (Desktop)" + ); + ok(headers[0].innerHTML.includes("icon desktop"), "Icon should be desktop"); + ok( + headers[1].textContent.includes("Other Mobile"), + "Text is correct (Mobile)" + ); + ok(headers[1].innerHTML.includes("icon phone"), "Icon should be phone"); + }); + await tearDown(sandbox); +}); + +add_task(async function test_tabs() { + TabsSetupFlowManager.resetInternalState(); + + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData1); + let getRecentTabsResult = mockTabs1; + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n` + ); + return Promise.resolve(getRecentTabsResult); + }); + sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => { + return Promise.resolve(syncedTabsData1); + }); + + await withFirefoxView({ openNewWindow: true }, async browser => { + const { document } = browser.contentWindow; + await navigateToCategoryAndWait(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated); + + let headers = + syncedTabsComponent.shadowRoot.querySelectorAll("h3[slot=header]"); + ok( + headers[0].textContent.includes("My desktop"), + "Text is correct (My desktop)" + ); + ok(headers[0].innerHTML.includes("icon desktop"), "Icon should be desktop"); + ok( + headers[1].textContent.includes("My iphone"), + "Text is correct (My iphone)" + ); + ok(headers[1].innerHTML.includes("icon phone"), "Icon should be phone"); + + let tabLists = syncedTabsComponent.tabLists; + await TestUtils.waitForCondition(() => { + return tabLists[0].rowEls.length; + }); + let tabRow1 = tabLists[0].rowEls; + ok( + tabRow1[0].shadowRoot.textContent.includes, + "Internet for people, not profits - Mozilla" + ); + ok(tabRow1[1].shadowRoot.textContent.includes, "Sandboxes - Sinon.JS"); + is(tabRow1.length, 2, "Correct number of rows are displayed."); + let tabRow2 = tabLists[1].shadowRoot.querySelectorAll("fxview-tab-row"); + is(tabRow2.length, 2, "Correct number of rows are dispayed."); + ok(tabRow1[0].shadowRoot.textContent.includes, "The Guardian"); + ok(tabRow1[1].shadowRoot.textContent.includes, "The Times"); + + // Test telemetry for opening a tab. + await clearAllParentTelemetryEvents(); + EventUtils.synthesizeMouseAtCenter(tabRow1[0], {}, browser.contentWindow); + await TestUtils.waitForCondition( + () => + Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS + ).parent?.length >= 1, + "Waiting for synced_tabs firefoxview telemetry event.", + 200, + 100 + ); + TelemetryTestUtils.assertEvents( + [ + [ + "firefoxview_next", + "synced_tabs", + "tabs", + null, + { page: "syncedtabs" }, + ], + ], + { category: "firefoxview_next" }, + { clear: true, process: "parent" } + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_empty_desktop_same_name() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "A Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + { + id: 2, + name: "A Device", + type: "desktop", + tabs: [], + }, + ], + }); + + await withFirefoxView({ openNewWindow: true }, async browser => { + const { document } = browser.contentWindow; + await navigateToCategoryAndWait(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated); + let noTabs = syncedTabsComponent.shadowRoot.querySelectorAll(".notabs"); + is(noTabs.length, 1, "Should be 1 empty devices"); + + let headers = + syncedTabsComponent.shadowRoot.querySelectorAll("h3[slot=header]"); + ok( + headers[0].textContent.includes("A Device"), + "Text is correct (Desktop)" + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_empty_desktop_same_name_three() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "A Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + { + id: 2, + name: "A Device", + type: "desktop", + tabs: [], + }, + { + id: 3, + name: "A Device", + type: "desktop", + tabs: [], + }, + ], + }); + + await withFirefoxView({ openNewWindow: true }, async browser => { + const { document } = browser.contentWindow; + await navigateToCategoryAndWait(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated); + let noTabs = syncedTabsComponent.shadowRoot.querySelectorAll(".notabs"); + is(noTabs.length, 2, "Should be 2 empty devices"); + + let headers = + syncedTabsComponent.shadowRoot.querySelectorAll("h3[slot=header]"); + ok( + headers[0].textContent.includes("A Device"), + "Text is correct (Desktop)" + ); + ok( + headers[1].textContent.includes("A Device"), + "Text is correct (Desktop)" + ); + }); + await tearDown(sandbox); +}); + +add_task(async function search_synced_tabs() { + TabsSetupFlowManager.resetInternalState(); + + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData1); + let getRecentTabsResult = mockTabs1; + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n` + ); + return Promise.resolve(getRecentTabsResult); + }); + sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => { + return Promise.resolve(syncedTabsData1); + }); + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.search.enabled", true]], + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToCategoryAndWait(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated); + + is(syncedTabsComponent.cardEls.length, 2, "There are two device cards."); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list") && + syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls + .length && + syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list") && + syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls + .length, + "The tab list has loaded for the first two cards." + ); + let deviceOneTabs = + syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls; + let deviceTwoTabs = + syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls; + + info("Input a search query."); + EventUtils.synthesizeMouseAtCenter( + syncedTabsComponent.searchTextbox, + {}, + content + ); + EventUtils.sendString("Mozilla", content); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Synced Tabs component is done updating." + ); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list") && + syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls + .length, + "The tab list has loaded for the first card." + ); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls + .length === 1, + "There is one matching search result for the first device." + ); + await TestUtils.waitForCondition( + () => !syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list"), + "There are no matching search results for the second device." + ); + + info("Clear the search query."); + EventUtils.synthesizeMouseAtCenter( + syncedTabsComponent.searchTextbox.clearButton, + {}, + content + ); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Synced Tabs component is done updating." + ); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list") && + syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls + .length && + syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list") && + syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls + .length, + "The tab list has loaded for the first two cards." + ); + deviceOneTabs = + syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls; + deviceTwoTabs = + syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls; + await TestUtils.waitForCondition( + () => + syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls + .length === deviceOneTabs.length, + "The original device's list is restored." + ); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls + .length === deviceTwoTabs.length, + "The new devices's list is restored." + ); + syncedTabsComponent.searchTextbox.blur(); + + info("Input a search query with keyboard."); + EventUtils.synthesizeKey("f", { accelKey: true }, content); + EventUtils.sendString("Mozilla", content); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Synced Tabs component is done updating." + ); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list") && + syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls + .length, + "The tab list has loaded for the first card." + ); + await TestUtils.waitForCondition(() => { + return ( + syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls + .length === 1 + ); + }, "There is one matching search result for the first device."); + await TestUtils.waitForCondition( + () => !syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list"), + "There are no matching search results for the second device." + ); + + info("Clear the search query with keyboard."); + is( + syncedTabsComponent.shadowRoot.activeElement, + syncedTabsComponent.searchTextbox, + "Search input is focused" + ); + EventUtils.synthesizeKey("KEY_Tab", {}, content); + ok( + syncedTabsComponent.searchTextbox.clearButton.matches(":focus-visible"), + "Clear Search button is focused" + ); + EventUtils.synthesizeKey("KEY_Enter", {}, content); + await TestUtils.waitForCondition( + () => syncedTabsComponent.fullyUpdated, + "Synced Tabs component is done updating." + ); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list") && + syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls + .length && + syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list") && + syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls + .length, + "The tab list has loaded for the first two cards." + ); + deviceOneTabs = + syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls; + deviceTwoTabs = + syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls; + await TestUtils.waitForCondition( + () => + syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls + .length === deviceOneTabs.length, + "The original device's list is restored." + ); + await TestUtils.waitForCondition( + () => + syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls + .length === deviceTwoTabs.length, + "The new devices's list is restored." + ); + }); + await SpecialPowers.popPrefEnv(); + await tearDown(sandbox); +}); + +add_task(async function search_synced_tabs_recent_browsing() { + const NUMBER_OF_TABS = 6; + TabsSetupFlowManager.resetInternalState(); + const sandbox = setupRecentDeviceListMocks(); + const tabClients = [ + { + id: 1, + type: "client", + name: "My desktop", + clientType: "desktop", + tabs: Array(NUMBER_OF_TABS).fill({ + type: "tab", + title: "Internet for people, not profits - Mozilla", + url: "https://www.mozilla.org/", + icon: "https://www.mozilla.org/media/img/favicons/mozilla/favicon.d25d81d39065.ico", + client: 1, + }), + }, + { + id: 2, + type: "client", + name: "My iphone", + clientType: "phone", + tabs: [ + { + type: "tab", + title: "Mount Everest - Wikipedia", + url: "https://en.wikipedia.org/wiki/Mount_Everest", + icon: "https://www.wikipedia.org/static/favicon/wikipedia.ico", + client: 2, + }, + ], + }, + ]; + sandbox + .stub(SyncedTabs, "getRecentTabs") + .resolves(getMockTabData(tabClients)); + sandbox.stub(SyncedTabs, "getTabClients").resolves(tabClients); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.search.enabled", true]], + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + await navigateToCategoryAndWait(document, "recentbrowsing"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + const recentBrowsing = document.querySelector("view-recentbrowsing"); + const slot = recentBrowsing.querySelector("[slot='syncedtabs']"); + + // Test that all tab lists repopulate when clearing out searched terms (Bug 1869895 & Bug 1873212) + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter( + recentBrowsing.searchTextbox, + {}, + content + ); + EventUtils.sendString("Mozilla", content); + await TestUtils.waitForCondition( + () => slot.fullyUpdated && slot.tabLists.length === 1, + "Synced Tabs component is done updating." + ); + await promiseTabListsUpdated(slot); + info("Scroll first card into view."); + slot.tabLists[0].scrollIntoView(); + await TestUtils.waitForCondition( + () => slot.tabLists[0].rowEls.length === 5, + "The first card is populated." + ); + EventUtils.synthesizeMouseAtCenter( + recentBrowsing.searchTextbox, + {}, + content + ); + EventUtils.synthesizeKey("KEY_Backspace", { repeat: 5 }); + await TestUtils.waitForCondition( + () => slot.fullyUpdated && slot.tabLists.length === 2, + "Synced Tabs component is done updating." + ); + await promiseTabListsUpdated(slot); + info("Scroll second card into view."); + slot.tabLists[1].scrollIntoView(); + await TestUtils.waitForCondition( + () => + slot.tabLists[0].rowEls.length === 5 && + slot.tabLists[1].rowEls.length === 1, + "Both cards are populated." + ); + info("Clear the search query."); + EventUtils.synthesizeKey("KEY_Backspace", { repeat: 2 }); + + info("Input a search query"); + EventUtils.synthesizeMouseAtCenter( + recentBrowsing.searchTextbox, + {}, + content + ); + EventUtils.sendString("Mozilla", content); + await TestUtils.waitForCondition( + () => slot.fullyUpdated && slot.tabLists.length === 2, + "Synced Tabs component is done updating." + ); + await promiseTabListsUpdated(slot); + await TestUtils.waitForCondition( + () => slot.tabLists[0].rowEls.length === 5, + "Not all search results are shown yet." + ); + + info("Click the Show All link."); + const showAllLink = await TestUtils.waitForCondition(() => + slot.shadowRoot.querySelector("[data-l10n-id='firefoxview-show-all']") + ); + is(showAllLink.role, "link", "The show all control is a link."); + EventUtils.synthesizeMouseAtCenter(showAllLink, {}, content); + await TestUtils.waitForCondition( + () => slot.tabLists[0].rowEls.length === NUMBER_OF_TABS, + "All search results are shown." + ); + ok(BrowserTestUtils.isHidden(showAllLink), "The show all link is hidden."); + }); + await SpecialPowers.popPrefEnv(); + await tearDown(sandbox); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_tab_close_last_tab.js b/browser/components/firefoxview/tests/browser/browser_tab_close_last_tab.js new file mode 100644 index 0000000000..e7aed1c429 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_tab_close_last_tab.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = "https://example.com/"; + +add_task(async function closing_last_tab_should_not_switch_to_fx_view() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.closeWindowWithLastTab", false]], + }); + info("Opening window..."); + const win = await BrowserTestUtils.openNewBrowserWindow({ + waitForTabURL: "about:newtab", + }); + const firstTab = win.gBrowser.selectedTab; + info("Opening Firefox View tab..."); + await openFirefoxViewTab(win); + info("Switch back to new tab..."); + await BrowserTestUtils.switchTab(win.gBrowser, firstTab); + info("Load web page in new tab..."); + const loaded = BrowserTestUtils.browserLoaded( + win.gBrowser.selectedBrowser, + false, + URL + ); + BrowserTestUtils.startLoadingURIString(win.gBrowser.selectedBrowser, URL); + await loaded; + info("Opening new browser tab..."); + const secondTab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + URL + ); + info("Close all browser tabs..."); + await BrowserTestUtils.removeTab(firstTab); + await BrowserTestUtils.removeTab(secondTab); + isnot( + win.gBrowser.selectedTab, + win.FirefoxViewHandler.tab, + "The selected tab should not be the Firefox View tab" + ); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js b/browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js new file mode 100644 index 0000000000..9980980c29 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +class DialogObserver { + constructor() { + this.wasOpened = false; + Services.obs.addObserver(this, "common-dialog-loaded"); + } + cleanup() { + Services.obs.removeObserver(this, "common-dialog-loaded"); + } + observe(win, topic) { + if (topic == "common-dialog-loaded") { + this.wasOpened = true; + // Close dialog. + win.document.querySelector("dialog").getButton("cancel").click(); + } + } +} + +add_task( + async function on_close_warning_should_not_show_for_firefox_view_tab() { + const dialogObserver = new DialogObserver(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.warnOnClose", true]], + }); + info("Opening window..."); + const win = await BrowserTestUtils.openNewBrowserWindow(); + info("Opening Firefox View tab..."); + await openFirefoxViewTab(win); + info("Trigger warnAboutClosingWindow()"); + win.BrowserTryToCloseWindow(); + await BrowserTestUtils.closeWindow(win); + ok(!dialogObserver.wasOpened, "Dialog was not opened"); + dialogObserver.cleanup(); + } +); + +add_task( + async function on_close_warning_should_not_show_for_firefox_view_tab_non_macos() { + let initialTab = gBrowser.selectedTab; + const dialogObserver = new DialogObserver(); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.tabs.warnOnClose", true], + ["browser.warnOnQuit", true], + ], + }); + info("Opening Firefox View tab..."); + await openFirefoxViewTab(window); + info('Trigger "quit-application-requested"'); + canQuitApplication("lastwindow", "close-button"); + ok(!dialogObserver.wasOpened, "Dialog was not opened"); + await BrowserTestUtils.switchTab(gBrowser, initialTab); + closeFirefoxViewTab(window); + dialogObserver.cleanup(); + } +); diff --git a/browser/components/firefoxview/tests/browser/head.js b/browser/components/firefoxview/tests/browser/head.js new file mode 100644 index 0000000000..b0b41b759d --- /dev/null +++ b/browser/components/firefoxview/tests/browser/head.js @@ -0,0 +1,708 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { + getFirefoxViewURL, + withFirefoxView, + assertFirefoxViewTab, + assertFirefoxViewTabSelected, + openFirefoxViewTab, + closeFirefoxViewTab, + isFirefoxViewTabSelectedInWindow, + init: FirefoxViewTestUtilsInit, +} = ChromeUtils.importESModule( + "resource://testing-common/FirefoxViewTestUtils.sys.mjs" +); + +/* exported testVisibility */ + +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); +const { UIState } = ChromeUtils.importESModule( + "resource://services-sync/UIState.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { FeatureCalloutMessages } = ChromeUtils.importESModule( + "resource:///modules/asrouter/FeatureCalloutMessages.sys.mjs" +); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL; +const { SessionStoreTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/SessionStoreTestUtils.sys.mjs" +); +SessionStoreTestUtils.init(this, window); +FirefoxViewTestUtilsInit(this, window); + +ChromeUtils.defineESModuleGetters(this, { + AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.sys.mjs", + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", + SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", + TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs", +}); + +const calloutId = "feature-callout"; +const calloutSelector = `#${calloutId}.featureCallout`; +const CTASelector = `#${calloutId} :is(.primary, .secondary)`; + +/** + * URLs used for browser_recently_closed_tabs_keyboard and + * browser_firefoxview_accessibility + */ +const URLs = [ + "http://mochi.test:8888/browser/", + "https://www.example.com/", + "https://example.net/", + "https://example.org/", + "about:robots", + "https://www.mozilla.org/", +]; + +const syncedTabsData1 = [ + { + id: 1, + type: "client", + name: "My desktop", + clientType: "desktop", + lastModified: 1655730486760, + tabs: [ + { + type: "tab", + title: "Sandboxes - Sinon.JS", + url: "https://sinonjs.org/releases/latest/sandbox/", + icon: "https://sinonjs.org/assets/images/favicon.png", + lastUsed: 1655391592, // Thu Jun 16 2022 14:59:52 GMT+0000 + client: 1, + }, + { + type: "tab", + title: "Internet for people, not profits - Mozilla", + url: "https://www.mozilla.org/", + icon: "https://www.mozilla.org/media/img/favicons/mozilla/favicon.d25d81d39065.ico", + lastUsed: 1655730486, // Mon Jun 20 2022 13:08:06 GMT+0000 + client: 1, + }, + ], + }, + { + id: 2, + type: "client", + name: "My iphone", + clientType: "phone", + lastModified: 1655727832930, + tabs: [ + { + type: "tab", + title: "The Guardian", + url: "https://www.theguardian.com/", + icon: "page-icon:https://www.theguardian.com/", + lastUsed: 1655291890, // Wed Jun 15 2022 11:18:10 GMT+0000 + client: 2, + }, + { + type: "tab", + title: "The Times", + url: "https://www.thetimes.co.uk/", + icon: "page-icon:https://www.thetimes.co.uk/", + lastUsed: 1655727485, // Mon Jun 20 2022 12:18:05 GMT+0000 + client: 2, + }, + ], + }, +]; + +async function clearAllParentTelemetryEvents() { + // Clear everything. + await TestUtils.waitForCondition(() => { + Services.telemetry.clearEvents(); + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).parent; + return !events || !events.length; + }); +} + +function testVisibility(browser, expected) { + const { document } = browser.contentWindow; + for (let [selector, shouldBeVisible] of Object.entries( + expected.expectedVisible + )) { + const elem = document.querySelector(selector); + if (shouldBeVisible) { + ok( + BrowserTestUtils.isVisible(elem), + `Expected ${selector} to be visible` + ); + } else { + ok(BrowserTestUtils.isHidden(elem), `Expected ${selector} to be hidden`); + } + } +} + +async function waitForElementVisible(browser, selector, isVisible = true) { + const { document } = browser.contentWindow; + const elem = document.querySelector(selector); + if (!isVisible && !elem) { + return; + } + ok(elem, `Got element with selector: ${selector}`); + + await BrowserTestUtils.waitForMutationCondition( + elem, + { + attributeFilter: ["hidden"], + }, + () => { + return isVisible + ? BrowserTestUtils.isVisible(elem) + : BrowserTestUtils.isHidden(elem); + } + ); +} + +async function waitForVisibleSetupStep(browser, expected) { + const { document } = browser.contentWindow; + + const deck = document.querySelector(".sync-setup-container"); + const nextStepElem = deck.querySelector(expected.expectedVisible); + const stepElems = deck.querySelectorAll(".setup-step"); + + await BrowserTestUtils.waitForMutationCondition( + deck, + { + attributeFilter: ["selected-view"], + }, + () => { + return BrowserTestUtils.isVisible(nextStepElem); + } + ); + + for (let elem of stepElems) { + if (elem == nextStepElem) { + ok( + BrowserTestUtils.isVisible(elem), + `Expected ${elem.id || elem.className} to be visible` + ); + } else { + ok( + BrowserTestUtils.isHidden(elem), + `Expected ${elem.id || elem.className} to be hidden` + ); + } + } +} + +var gMockFxaDevices = null; +var gUIStateStatus; +var gSandbox; +function setupSyncFxAMocks({ fxaDevices = null, state, syncEnabled = true }) { + gUIStateStatus = state || UIState.STATUS_SIGNED_IN; + if (gSandbox) { + gSandbox.restore(); + } + const sandbox = (gSandbox = sinon.createSandbox()); + gMockFxaDevices = fxaDevices; + sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => fxaDevices); + sandbox.stub(UIState, "get").callsFake(() => { + return { + status: gUIStateStatus, + syncEnabled, + email: + gUIStateStatus === UIState.STATUS_NOT_CONFIGURED + ? undefined + : "email@example.com", + }; + }); + + return sandbox; +} + +function setupRecentDeviceListMocks() { + const sandbox = sinon.createSandbox(); + sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => [ + { + id: 1, + name: "My desktop", + isCurrentDevice: true, + type: "desktop", + }, + { + id: 2, + name: "My iphone", + type: "mobile", + }, + ]); + + sandbox.stub(UIState, "get").returns({ + status: UIState.STATUS_SIGNED_IN, + syncEnabled: true, + email: "email@example.com", + }); + + return sandbox; +} + +function getMockTabData(clients) { + return SyncedTabs._internal._createRecentTabsList(clients, 10); +} + +async function setupListState(browser) { + // Skip the synced tabs sign up flow to get to a loaded list state + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.engine.tabs", true]], + }); + + UIState.refresh(); + const recentFetchTime = Math.floor(Date.now() / 1000); + info("updating lastFetch:" + recentFetchTime); + Services.prefs.setIntPref("services.sync.lastTabFetch", recentFetchTime); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + await waitForElementVisible(browser, "#tabpickup-steps", false); + await waitForElementVisible(browser, "#tabpickup-tabs-container", true); + + const tabsContainer = browser.contentWindow.document.querySelector( + "#tabpickup-tabs-container" + ); + await tabsContainer.tabListAdded; + await BrowserTestUtils.waitForMutationCondition( + tabsContainer, + { attributeFilter: ["class"], attributes: true }, + () => { + return !tabsContainer.classList.contains("loading"); + } + ); + info("tabsContainer isn't loading anymore, returning"); +} + +async function touchLastTabFetch() { + // lastTabFetch stores a timestamp in *seconds*. + const nowSeconds = Math.floor(Date.now() / 1000); + info("updating lastFetch:" + nowSeconds); + Services.prefs.setIntPref("services.sync.lastTabFetch", nowSeconds); + // wait so all pref observers can complete + await TestUtils.waitForTick(); +} + +let gUIStateSyncEnabled; +function setupMocks({ fxaDevices = null, state, syncEnabled = true }) { + gUIStateStatus = state || UIState.STATUS_SIGNED_IN; + gUIStateSyncEnabled = syncEnabled; + if (gSandbox) { + gSandbox.restore(); + } + const sandbox = (gSandbox = sinon.createSandbox()); + gMockFxaDevices = fxaDevices; + sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => fxaDevices); + sandbox.stub(UIState, "get").callsFake(() => { + return { + status: gUIStateStatus, + // Sometimes syncEnabled is not present on UIState, for example when the user signs + // out the state is just { status: "not_configured" } + ...(gUIStateSyncEnabled != undefined && { + syncEnabled: gUIStateSyncEnabled, + }), + }; + }); + // This is converting the device list to a client list. + // There are two primary differences: + // 1. The client list doesn't return the current device. + // 2. It uses clientType instead of type. + let tabClients = fxaDevices ? [...fxaDevices] : []; + for (let client of tabClients) { + client.clientType = client.type; + } + tabClients = tabClients.filter(device => !device.isCurrentDevice); + sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => { + return Promise.resolve(tabClients); + }); + return sandbox; +} + +async function tearDown(sandbox) { + sandbox?.restore(); + Services.prefs.clearUserPref("services.sync.lastTabFetch"); +} + +const featureTourPref = "browser.firefox-view.feature-tour"; +const launchFeatureTourIn = win => { + const { FeatureCallout } = ChromeUtils.importESModule( + "resource:///modules/asrouter/FeatureCallout.sys.mjs" + ); + let callout = new FeatureCallout({ + win, + pref: { name: featureTourPref }, + location: "about:firefoxview", + context: "content", + theme: { preset: "themed-content" }, + }); + callout.showFeatureCallout(); + return callout; +}; + +/** + * Returns a value that can be used to set + * `browser.firefox-view.feature-tour` to change the feature tour's + * UI state. + * + * @see FeatureCalloutMessages.sys.mjs for valid values of "screen" + * + * @param {number} screen The full ID of the feature callout screen + * @returns {string} JSON string used to set + * `browser.firefox-view.feature-tour` + */ +const getPrefValueByScreen = screen => { + return JSON.stringify({ + screen: `FEATURE_CALLOUT_${screen}`, + complete: false, + }); +}; + +/** + * Wait for a feature callout screen of given parameters to be shown + * + * @param {Document} doc the document where the callout appears. + * @param {string} screenPostfix The full ID of the feature callout screen. + */ +const waitForCalloutScreen = async (doc, screenPostfix) => { + await BrowserTestUtils.waitForCondition(() => + doc.querySelector(`${calloutSelector}:not(.hidden) .${screenPostfix}`) + ); +}; + +/** + * Waits for the feature callout screen to be removed. + * + * @param {Document} doc The document where the callout appears. + */ +const waitForCalloutRemoved = async doc => { + await BrowserTestUtils.waitForCondition(() => { + return !doc.body.querySelector(calloutSelector); + }); +}; + +/** + * NOTE: Should be replaced with synthesizeMouseAtCenter for + * simulating user input. See Bug 1798322 + * + * Clicks the primary button in the feature callout dialog + * + * @param {document} doc Firefox View document + */ +const clickCTA = async doc => { + doc.querySelector(CTASelector).click(); +}; + +/** + * Closes a feature callout via a click to the dismiss button. + * + * @param {Document} doc The document where the callout appears. + */ +const closeCallout = async doc => { + // close the callout dialog + const dismissBtn = doc.querySelector(`${calloutSelector} .dismiss-button`); + if (!dismissBtn) { + return; + } + doc.querySelector(`${calloutSelector} .dismiss-button`).click(); + await BrowserTestUtils.waitForCondition(() => { + return !document.querySelector(calloutSelector); + }); +}; + +/** + * Get a Feature Callout message by id. + * + * @param {string} id + * The message id. + */ +const getCalloutMessageById = id => { + return { + message: FeatureCalloutMessages.getMessages().find(m => m.id === id), + }; +}; + +/** + * Create a sinon sandbox with `sendTriggerMessage` stubbed + * to return a specified test message for featureCalloutCheck. + * + * @param {object} testMessage + * @param {string} [source="about:firefoxview"] + */ +const createSandboxWithCalloutTriggerStub = ( + testMessage, + source = "about:firefoxview" +) => { + const firefoxViewMatch = sinon.match({ + id: "featureCalloutCheck", + context: { source }, + }); + const sandbox = sinon.createSandbox(); + const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage"); + sendTriggerStub.withArgs(firefoxViewMatch).resolves(testMessage); + sendTriggerStub.callThrough(); + return sandbox; +}; + +/** + * A helper to check that correct telemetry was sent by AWSendEventTelemetry. + * This is a wrapper around sinon's spy functionality. + * + * @example + * let spy = new TelemetrySpy(); + * element.click(); + * spy.assertCalledWith({ event: "CLICK" }); + * spy.restore(); + */ +class TelemetrySpy { + /** + * @param {object} [sandbox] A pre-existing sinon sandbox to build the spy in. + * If not provided, a new sandbox will be created. + */ + constructor(sandbox = sinon.createSandbox()) { + this.sandbox = sandbox; + this.spy = this.sandbox + .spy(AboutWelcomeParent.prototype, "onContentMessage") + .withArgs("AWPage:TELEMETRY_EVENT"); + registerCleanupFunction(() => this.restore()); + } + /** + * Assert that AWSendEventTelemetry sent the expected telemetry object. + * + * @param {object} expectedData + */ + assertCalledWith(expectedData) { + let match = this.spy.calledWith("AWPage:TELEMETRY_EVENT", expectedData); + if (match) { + ok(true, "Expected telemetry sent"); + } else if (this.spy.called) { + ok( + false, + "Wrong telemetry sent: " + JSON.stringify(this.spy.lastCall.args) + ); + } else { + ok(false, "No telemetry sent"); + } + } + reset() { + this.spy.resetHistory(); + } + restore() { + this.sandbox.restore(); + } +} + +/** + * Helper function to open and close a tab so the recently + * closed tabs list can have data. + * + * @param {string} url + * @returns {Promise} Promise that resolves when the session store + * has been updated after closing the tab. + */ +async function open_then_close(url, win = window) { + return SessionStoreTestUtils.openAndCloseTab(win, url); +} + +/** + * Clears session history. Used to clear out the recently closed tabs list. + * + */ +function clearHistory() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); +} + +/** + * Cleanup function for tab pickup tests. + * + */ +function cleanup_tab_pickup() { + Services.prefs.clearUserPref("services.sync.engine.tabs"); + Services.prefs.clearUserPref("services.sync.lastTabFetch"); +} + +function isFirefoxViewTabSelected(win = window) { + return isFirefoxViewTabSelectedInWindow(win); +} + +function promiseAllButPrimaryWindowClosed() { + let windows = []; + for (let win of BrowserWindowTracker.orderedWindows) { + if (win != window) { + windows.push(win); + } + } + return Promise.all(windows.map(BrowserTestUtils.closeWindow)); +} + +registerCleanupFunction(() => { + // ensure all the stubs are restored, regardless of any exceptions + // that might have prevented it + gSandbox?.restore(); +}); + +function navigateToCategory(document, category) { + const navigation = document.querySelector("fxview-category-navigation"); + let navButton = Array.from(navigation.categoryButtons).filter( + categoryButton => { + return categoryButton.name === category; + } + )[0]; + navButton.buttonEl.click(); +} + +async function navigateToCategoryAndWait(document, category) { + info(`navigateToCategoryAndWait, for ${category}`); + const navigation = document.querySelector("fxview-category-navigation"); + const win = document.ownerGlobal; + SimpleTest.promiseFocus(win); + let navButton = Array.from(navigation.categoryButtons).find( + categoryButton => { + return categoryButton.name === category; + } + ); + const namedDeck = document.querySelector("named-deck"); + + await BrowserTestUtils.waitForCondition( + () => navButton.getBoundingClientRect().height, + `Waiting for ${category} button to be clickable` + ); + + EventUtils.synthesizeMouseAtCenter(navButton, {}, win); + + await BrowserTestUtils.waitForCondition(() => { + let selectedView = Array.from(namedDeck.children).find( + child => child.slot == "selected" + ); + return ( + namedDeck.selectedViewName == category && + selectedView?.getBoundingClientRect().height + ); + }, `Waiting for ${category} to be visible`); +} + +/** + * Switch to the Firefox View tab. + * + * @param {Window} [win] + * The window to use, if specified. Defaults to the global window instance. + * @returns {Promise} + * The tab switched to. + */ +async function switchToFxViewTab(win = window) { + return BrowserTestUtils.switchTab(win.gBrowser, win.FirefoxViewHandler.tab); +} + +function isElInViewport(element) { + const boundingRect = element.getBoundingClientRect(); + return ( + boundingRect.top >= 0 && + boundingRect.left >= 0 && + boundingRect.bottom <= + (window.innerHeight || document.documentElement.clientHeight) && + boundingRect.right <= + (window.innerWidth || document.documentElement.clientWidth) + ); +} + +// TODO once we port over old tests, helpers and cleanup old firefox view +// we should decide whether to keep this or openFirefoxViewTab. +async function clickFirefoxViewButton(win) { + await BrowserTestUtils.synthesizeMouseAtCenter( + "#firefox-view-button", + { type: "mousedown" }, + win.browsingContext + ); +} + +/** + * Wait for and assert telemetry events. + * + * @param {Array} eventDetails + * Nested array of event details + */ +async function telemetryEvent(eventDetails) { + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 1; + }, + "Waiting for firefoxview_next telemetry event.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + eventDetails, + { category: "firefoxview_next" }, + { clear: true, process: "parent" } + ); +} + +function setSortOption(component, value) { + info(`Sort by ${value}.`); + const el = component.optionsContainer.querySelector( + `input[value='${value}']` + ); + EventUtils.synthesizeMouseAtCenter(el, {}, el.ownerGlobal); +} + +function getOpenTabsCards(openTabs) { + return openTabs.shadowRoot.querySelectorAll("view-opentabs-card"); +} + +async function click_recently_closed_tab_item(itemElem, itemProperty = "") { + // Make sure the firefoxview tab still has focus + is( + itemElem.ownerDocument.location.href, + "about:firefoxview#recentlyclosed", + "about:firefoxview is the selected tab and showing the Recently closed view page" + ); + + // Scroll to the tab element to ensure dismiss button is visible + itemElem.scrollIntoView(); + is(isElInViewport(itemElem), true, "Tab is visible in viewport"); + let clickTarget; + switch (itemProperty) { + case "dismiss": + clickTarget = itemElem.buttonEl; + break; + default: + clickTarget = itemElem.mainEl; + break; + } + + const closedObjectsChangePromise = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + EventUtils.synthesizeMouseAtCenter(clickTarget, {}, itemElem.ownerGlobal); + await closedObjectsChangePromise; +} + +async function waitForRecentlyClosedTabsList(doc) { + let recentlyClosedComponent = doc.querySelector( + "view-recentlyclosed:not([slot=recentlyclosed])" + ); + // Check that the tabs list is rendered + await TestUtils.waitForCondition(() => { + return recentlyClosedComponent.cardEl; + }); + let cardContainer = recentlyClosedComponent.cardEl; + let cardMainSlotNode = Array.from( + cardContainer?.mainSlot?.assignedNodes() + )[0]; + await TestUtils.waitForCondition(() => { + return cardMainSlotNode.rowEls.length; + }); + return [cardMainSlotNode, cardMainSlotNode.rowEls]; +} diff --git a/browser/components/firefoxview/tests/chrome/chrome.toml b/browser/components/firefoxview/tests/chrome/chrome.toml new file mode 100644 index 0000000000..b1677430b2 --- /dev/null +++ b/browser/components/firefoxview/tests/chrome/chrome.toml @@ -0,0 +1,7 @@ +[DEFAULT] + +["test_card_container.html"] + +["test_fxview_category_navigation.html"] + +["test_fxview_tab_list.html"] diff --git a/browser/components/firefoxview/tests/chrome/test_card_container.html b/browser/components/firefoxview/tests/chrome/test_card_container.html new file mode 100644 index 0000000000..c54a70faaf --- /dev/null +++ b/browser/components/firefoxview/tests/chrome/test_card_container.html @@ -0,0 +1,122 @@ + + + + + CardContainer Tests + + + + + + + + + +

    +
    + +

    +
      +
    • History Row 1
    • +
    • History Row 2
    • +
    • History Row 3
    • +
    • History Row 4
    • +
    • History Row 5
    • +
    +
    +
    +
    +
    +
    + + diff --git a/browser/components/firefoxview/tests/chrome/test_fxview_category_navigation.html b/browser/components/firefoxview/tests/chrome/test_fxview_category_navigation.html new file mode 100644 index 0000000000..0ea0a94baf --- /dev/null +++ b/browser/components/firefoxview/tests/chrome/test_fxview_category_navigation.html @@ -0,0 +1,322 @@ + + + + + FxviewCategoryNavigation Tests + + + + + + + + +

    +
    + +
    +
    
    +
    +
    +
    diff --git a/browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html b/browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html
    new file mode 100644
    index 0000000000..22f04acab2
    --- /dev/null
    +++ b/browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html
    @@ -0,0 +1,447 @@
    +
    +
    +
    +  
    +  FxviewTabList Tests
    +  
    +  
    +  
    +  
    +  
    +  
    +
    +
    +  
    +

    +
    + + + + +
    + + +
    + + + +
    +
    +
    +
    +
    +
    + + diff --git a/browser/components/firefoxview/triage.json b/browser/components/firefoxview/triage.json new file mode 100644 index 0000000000..f740325061 --- /dev/null +++ b/browser/components/firefoxview/triage.json @@ -0,0 +1,31 @@ +{ + "triagers": { + "Jonathan Sudiaman": { + "bzmail": "jsudiaman@mozilla.com" + }, + "Kelly Cochrane": { + "bzmail": "kcochrane@mozilla.com" + }, + "Nikki Sharpley": { + "bzmail": "nsharpley@mozilla.com" + }, + "Sam Foster": { + "bzmail": "sfoster@mozilla.com" + }, + "Sarah Clements": { + "bzmail": "sclements@mozilla.com" + } + }, + "duty-start-dates": { + "2023-12-01": "Jonathan Sudiaman", + "2024-03-15": "Kelly Cochrane", + "2024-07-01": "Sarah Clements", + "2024-10-01": "Nikki Sharpley", + "2025-01-01": "Sam Foster", + "2025-04-01": "Jonathan Sudiaman", + "2025-07-01": "Kelly Cochrane", + "2025-10-01": "Sarah Clements", + "2026-01-01": "Nikki Sharpley", + "2026-03-01": "Sam Foster" + } +} diff --git a/browser/components/firefoxview/view-opentabs.css b/browser/components/firefoxview/view-opentabs.css new file mode 100644 index 0000000000..c1d1a320f8 --- /dev/null +++ b/browser/components/firefoxview/view-opentabs.css @@ -0,0 +1,44 @@ +/* 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/. */ + +.view-opentabs-card-container { + display: grid; + gap: 1em; +} + +[card-count="one"] { + grid-template-columns: 1fr; +} + +[card-count="two"] { + grid-template-columns: repeat(2, 1fr); +} + +[card-count="three-or-more"] { + grid-template-columns: repeat(3, 1fr); + + @media (max-width: 85rem) { + /* Switch to 2-column layout on narrow viewports */ + grid-template-columns: repeat(2, 1fr); + } +} + +.open-tabs-options, .open-tabs-sort-wrapper { + display: flex; + gap: 24px; +} + +.open-tabs-options { + flex-wrap: wrap; +} + +.open-tabs-sort-option { + display: flex; + align-items: center; + gap: 8px; + + & label { + white-space: nowrap; + } +} diff --git a/browser/components/firefoxview/view-syncedtabs.css b/browser/components/firefoxview/view-syncedtabs.css new file mode 100644 index 0000000000..990a40408c --- /dev/null +++ b/browser/components/firefoxview/view-syncedtabs.css @@ -0,0 +1,118 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +@import url("chrome://global/skin/in-content/common.css"); + +.icon { + display: inline-block; + width: 16px; + height: 16px; + background-position: center center; + background-repeat: no-repeat; + -moz-context-properties: fill; + fill: currentColor; +} + +.phone, +.mobile { + background-image: url('chrome://browser/skin/device-phone.svg'); +} + +.desktop { + background-image: url('chrome://browser/skin/device-desktop.svg'); +} + +.tablet { + background-image: url('chrome://browser/skin/device-tablet.svg'); +} + +h2 { + display: flex; + align-items: center; +} + +h3.device-header { + display: grid; + align-items: center; + cursor: inherit; + font-weight: var(--fxview-card-header-font-weight); + font-size: 1em; + grid-template-columns: min-content 1fr; + gap: 0 16px; + margin: 0; +} + +h3.device-header:not([slot="header"]) { + margin-block: 0.7em; + margin-inline: 0.5em 0; +} + +h3.device-header:not([slot="header"]):not(:first-child) { + margin-block-start: 1.6em; +} + +.notabs { + margin-block-start: 1em; + color: var(--fxview-text-secondary-color); +} + +.blackbox { + border: 1px solid var(--fxview-border); + text-align: center; + height: 70px; + border-radius: 8px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.search-results-empty { + height: unset; + min-height: 70px; + overflow-wrap: anywhere; +} + +button.primary { + white-space: nowrap; + min-width: fit-content; +} + +label { + display: flex; + align-items: center; +} + +.syncedtabs-header { + display: flex; + justify-content: space-between; + height: 34px; + align-items: center; +} + +.syncedtabs-header button { + display: flex; + align-items: center; + min-width: unset; + margin-inline-start: auto; +} + +.syncedtabs-header button .icon { + margin-inline-end: 0.7rem; +} + +.show-all-link-container { + display: flex; + justify-content: center; + color: var(--fxview-primary-action-background); + cursor: pointer; +} + +.show-all-link { + text-decoration: underline; + display: inline-block; + outline-offset: 2px; + border-radius: 2px; + margin-block: 0.5rem; +} diff --git a/browser/components/firefoxview/viewpage.mjs b/browser/components/firefoxview/viewpage.mjs new file mode 100644 index 0000000000..fee02b49d6 --- /dev/null +++ b/browser/components/firefoxview/viewpage.mjs @@ -0,0 +1,261 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +// eslint-disable-next-line import/no-unassigned-import +import "chrome://browser/content/firefoxview/card-container.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://browser/content/firefoxview/fxview-empty-state.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"; + +import { placeLinkOnClipboard } from "./helpers.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", +}); + +const WIN_RESIZE_DEBOUNCE_RATE_MS = 500; +const WIN_RESIZE_DEBOUNCE_TIMEOUT_MS = 1000; + +/** + * A base class for content container views displayed on firefox-view. + * + * @property {boolean} recentBrowsing + * Is part of the recentbrowsing page view + * @property {boolean} paused + * No content will be updated and rendered while paused + */ +export class ViewPageContent extends MozLitElement { + static get properties() { + return { + recentBrowsing: { type: Boolean }, + paused: { type: Boolean }, + }; + } + constructor() { + super(); + // don't update or render until explicitly un-paused + this.paused = true; + } + + get ownerViewPage() { + return this.closest("[type='page']") || this; + } + + get isVisible() { + if (!this.isConnected || this.ownerDocument.visibilityState != "visible") { + return false; + } + return this.ownerViewPage.selectedTab; + } + + /** + * Override this function to run a callback whenever this content is visible. + */ + viewVisibleCallback() {} + + /** + * Override this function to run a callback whenever this content is hidden. + */ + viewHiddenCallback() {} + + getWindow() { + return window.browsingContext.embedderWindowGlobal.browsingContext.window; + } + + get isSelectedBrowserTab() { + const { gBrowser } = this.getWindow(); + return gBrowser.selectedBrowser.browsingContext == window.browsingContext; + } + + copyLink(e) { + placeLinkOnClipboard(this.triggerNode.title, this.triggerNode.url); + this.recordContextMenuTelemetry("copy-link", e); + } + + openInNewWindow(e) { + this.getWindow().openTrustedLinkIn(this.triggerNode.url, "window", { + private: false, + }); + this.recordContextMenuTelemetry("open-in-new-window", e); + } + + openInNewPrivateWindow(e) { + this.getWindow().openTrustedLinkIn(this.triggerNode.url, "window", { + private: true, + }); + this.recordContextMenuTelemetry("open-in-private-window", e); + } + + recordContextMenuTelemetry(menuAction, event) { + Services.telemetry.recordEvent( + "firefoxview_next", + "context_menu", + "tabs", + null, + { + menu_action: menuAction, + data_type: event.target.panel.dataset.tabType, + } + ); + } + + shouldUpdate(changedProperties) { + return !this.paused && super.shouldUpdate(changedProperties); + } +} + +/** + * A "page" in firefox view, which may be hidden or shown by the named-deck container or + * via the owner document's visibility + * + * @property {boolean} selectedTab + * Is this page the selected view in the named-deck container + */ +export class ViewPage extends ViewPageContent { + static get properties() { + return { + selectedTab: { type: Boolean }, + searchTextboxSize: { type: Number }, + }; + } + + constructor() { + super(); + this.selectedTab = false; + this.recentBrowsing = Boolean(this.recentBrowsingElement); + this.onVisibilityChange = this.onVisibilityChange.bind(this); + this.onResize = this.onResize.bind(this); + } + + get recentBrowsingElement() { + return this.closest("VIEW-RECENTBROWSING"); + } + + onResize() { + this.windowResizeTask = new lazy.DeferredTask( + () => this.updateAllVirtualLists(), + WIN_RESIZE_DEBOUNCE_RATE_MS, + WIN_RESIZE_DEBOUNCE_TIMEOUT_MS + ); + this.windowResizeTask?.arm(); + } + + onVisibilityChange(event) { + if (this.isVisible) { + this.paused = false; + this.viewVisibleCallback(); + } else if ( + this.ownerViewPage.selectedTab && + this.ownerDocument.visibilityState == "hidden" + ) { + this.paused = true; + this.viewHiddenCallback(); + } + } + + connectedCallback() { + super.connectedCallback(); + this.ownerDocument.addEventListener( + "visibilitychange", + this.onVisibilityChange + ); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.ownerDocument.removeEventListener( + "visibilitychange", + this.onVisibilityChange + ); + this.getWindow().removeEventListener("resize", this.onResize); + } + + updateAllVirtualLists() { + if (!this.paused) { + let tabLists = []; + if (this.recentBrowsing) { + let viewComponents = this.querySelectorAll("[slot]"); + viewComponents.forEach(viewComponent => { + let currentTabLists = []; + if (viewComponent.nodeName.includes("OPENTABS")) { + viewComponent.viewCards.forEach(viewCard => { + currentTabLists.push(viewCard.tabList); + }); + } else { + currentTabLists = + viewComponent.shadowRoot.querySelectorAll("fxview-tab-list"); + } + tabLists.push(...currentTabLists); + }); + } else { + tabLists = this.shadowRoot.querySelectorAll("fxview-tab-list"); + } + tabLists.forEach(tabList => { + if (!tabList.updatesPaused && tabList.rootVirtualListEl?.isVisible) { + tabList.rootVirtualListEl.recalculateAfterWindowResize(); + } + }); + } + } + + toggleVisibilityInCardContainer(isOpenTabs) { + let cards = []; + let tabLists = []; + if (!isOpenTabs) { + cards = this.shadowRoot.querySelectorAll("card-container"); + tabLists = this.shadowRoot.querySelectorAll("fxview-tab-list"); + } else { + this.viewCards.forEach(viewCard => { + if (viewCard.cardEl) { + cards.push(viewCard.cardEl); + tabLists.push(viewCard.tabList); + } + }); + } + if (tabLists.length && cards.length) { + cards.forEach(cardEl => { + if (cardEl.visible !== !this.paused) { + cardEl.visible = !this.paused; + } else if ( + cardEl.isExpanded && + Array.from(tabLists).some( + tabList => tabList.updatesPaused !== this.paused + ) + ) { + // If card is already visible and expanded but tab-list has updatesPaused, + // update the tab-list updatesPaused prop from here instead of card-container + tabLists.forEach(tabList => { + tabList.updatesPaused = this.paused; + }); + } + }); + } + } + + enter() { + this.selectedTab = true; + if (this.isVisible) { + this.paused = false; + this.viewVisibleCallback(); + this.getWindow().addEventListener("resize", this.onResize); + } + } + + exit() { + this.selectedTab = false; + this.paused = true; + this.viewHiddenCallback(); + if (!this.windowResizeTask?.isFinalized) { + this.windowResizeTask?.finalize(); + } + this.getWindow().removeEventListener("resize", this.onResize); + } +} diff --git a/browser/components/installerprefs/InstallerPrefs.sys.mjs b/browser/components/installerprefs/InstallerPrefs.sys.mjs new file mode 100644 index 0000000000..2eb9d42fdc --- /dev/null +++ b/browser/components/installerprefs/InstallerPrefs.sys.mjs @@ -0,0 +1,141 @@ +/* -*- 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/. */ + +/** + * The installer prefs component provides a way to get a specific set of prefs + * from a profile into a place where the installer can read them. The primary + * reason for wanting to do this is so we can run things like Shield studies + * on installer features; normally those are enabled by setting a pref, but + * the installer runs outside of any profile and so has no access to prefs. + * So we need to do something else to allow it to read these prefs. + * + * The mechanism we use here is to reflect the values of a list of relevant + * prefs into registry values. One registry value is created for each pref + * that is set. Each installation of the product gets its own registry key + * (based on the path hash). This is obviously a somewhat wider scope than a + * single profile, but it should be close enough in enough cases to suit our + * purposes here. + * + * Currently this module only supports bool prefs. Other types could likely + * be added if needed, but it doesn't seem necessary for the primary use case. + */ + +// All prefs processed through this component must be in this branch. +const INSTALLER_PREFS_BRANCH = "installer."; + +// This is the list of prefs that will be reflected to the registry. It should +// be kept up to date so that it reflects the list of prefs that are in +// current use (e.g., currently active experiments). +// Only add prefs to this list which are in INSTALLER_PREFS_BRANCH; +// any others will be ignored. +const INSTALLER_PREFS_LIST = ["installer.taskbarpin.win10.enabled"]; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +// This constructor can take a list of prefs to override the default one, +// but this is really only intended for tests to use. Normal usage should be +// to leave the parameter omitted/undefined. +export function InstallerPrefs(prefsList) { + this.prefsList = prefsList || INSTALLER_PREFS_LIST; + + // Each pref to be reflected will get a value created under this key, in HKCU. + // The path will look something like: + // "Software\Mozilla\Firefox\Installer\71AE18FE3142402B\". + ChromeUtils.defineLazyGetter(this, "_registryKeyPath", function () { + const app = AppConstants.MOZ_APP_NAME; + const vendor = Services.appinfo.vendor || "Mozilla"; + const xreDirProvider = Cc[ + "@mozilla.org/xre/directory-provider;1" + ].getService(Ci.nsIXREDirProvider); + const installHash = xreDirProvider.getInstallHash(); + return `Software\\${vendor}\\${app}\\Installer\\${installHash}`; + }); +} + +InstallerPrefs.prototype = { + classID: Components.ID("{cd8a6995-1f19-4cdd-9ed1-d6263302f594}"), + contractID: "@mozilla.org/installerprefs;1", + + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + + observe(subject, topic, data) { + switch (topic) { + case "profile-after-change": { + if ( + AppConstants.platform != "win" || + !this.prefsList || + !this.prefsList.length + ) { + // This module has no work to do. + break; + } + const regKey = this._openRegKey(); + this._reflectPrefsToRegistry(regKey); + this._registerPrefListeners(); + regKey.close(); + break; + } + case "nsPref:changed": { + const regKey = this._openRegKey(); + if (this.prefsList.includes(data)) { + this._reflectOnePrefToRegistry(regKey, data); + } + regKey.close(); + break; + } + } + }, + + _registerPrefListeners() { + Services.prefs.addObserver(INSTALLER_PREFS_BRANCH, this); + }, + + _cleanRegistryKey(regKey) { + for (let i = regKey.valueCount - 1; i >= 0; --i) { + const name = regKey.getValueName(i); + if (name.startsWith(INSTALLER_PREFS_BRANCH)) { + regKey.removeValue(name); + } + } + }, + + _reflectPrefsToRegistry(regKey) { + this._cleanRegistryKey(regKey); + this.prefsList.forEach(pref => + this._reflectOnePrefToRegistry(regKey, pref) + ); + }, + + _reflectOnePrefToRegistry(regKey, pref) { + if (!pref.startsWith(INSTALLER_PREFS_BRANCH)) { + return; + } + + const value = Services.prefs.getBoolPref(pref, false); + if (value) { + regKey.writeIntValue(pref, 1); + } else { + try { + regKey.removeValue(pref); + } catch (ex) { + // This removeValue call is prone to failing because the value we + // tried to remove didn't exist. Obviously that isn't really an error + // that we need to handle. + } + } + }, + + _openRegKey() { + const key = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + Ci.nsIWindowsRegKey + ); + key.create( + key.ROOT_KEY_CURRENT_USER, + this._registryKeyPath, + key.ACCESS_READ | key.ACCESS_WRITE | key.WOW64_64 + ); + return key; + }, +}; diff --git a/browser/components/installerprefs/components.conf b/browser/components/installerprefs/components.conf new file mode 100644 index 0000000000..35be4ec2e3 --- /dev/null +++ b/browser/components/installerprefs/components.conf @@ -0,0 +1,16 @@ +# -*- 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/. + +Classes = [ + { + 'cid': '{cd8a6995-1f19-4cdd-9ed1-d6263302f594}', + 'contract_ids': ['@mozilla.org/installerprefs;1'], + 'esModule': 'resource:///modules/InstallerPrefs.sys.mjs', + 'constructor': 'InstallerPrefs', + 'categories': {'profile-after-change': 'InstallerPrefs'}, + 'processes': ProcessSelector.MAIN_PROCESS_ONLY, + }, +] diff --git a/browser/components/installerprefs/moz.build b/browser/components/installerprefs/moz.build new file mode 100644 index 0000000000..bfe931196a --- /dev/null +++ b/browser/components/installerprefs/moz.build @@ -0,0 +1,18 @@ +# -*- 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/. + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Installer") + +XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.toml"] + +EXTRA_JS_MODULES += [ + "InstallerPrefs.sys.mjs", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] diff --git a/browser/components/installerprefs/test/unit/head.js b/browser/components/installerprefs/test/unit/head.js new file mode 100644 index 0000000000..5c989fb309 --- /dev/null +++ b/browser/components/installerprefs/test/unit/head.js @@ -0,0 +1,137 @@ +/* -*- 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/. */ + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const { InstallerPrefs } = ChromeUtils.importESModule( + "resource:///modules/InstallerPrefs.sys.mjs" +); + +let gRegistryKeyPath = ""; + +function startModule(prefsList) { + // Construct an InstallerPrefs object and simulate a profile-after-change + // event on it, so that it performs its full startup procedure. + const prefsModule = new InstallerPrefs(prefsList); + prefsModule.observe(null, "profile-after-change", ""); + + gRegistryKeyPath = prefsModule._registryKeyPath; + + registerCleanupFunction(() => cleanupReflectedPrefs(prefsList)); +} + +function getRegistryKey() { + const key = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + Ci.nsIWindowsRegKey + ); + key.open( + key.ROOT_KEY_CURRENT_USER, + gRegistryKeyPath, + key.ACCESS_READ | key.WOW64_64 + ); + return key; +} + +function verifyReflectedPrefs(prefsList) { + let key; + try { + key = getRegistryKey(); + } catch (ex) { + Assert.ok(false, `Failed to open registry key: ${ex}`); + return; + } + + for (const pref of prefsList) { + if (pref.startsWith("installer.")) { + if (Services.prefs.getPrefType(pref) != Services.prefs.PREF_BOOL) { + Assert.ok( + !key.hasValue(pref), + `Pref ${pref} should not be in the registry because its type is not bool` + ); + } else if (Services.prefs.getBoolPref(pref, false)) { + Assert.ok(key.hasValue(pref), `Pref ${pref} should be in the registry`); + Assert.equal( + key.getValueType(pref), + key.TYPE_INT, + `Pref ${pref} should be type DWORD` + ); + Assert.equal( + key.readIntValue(pref), + 1, + `Pref ${pref} should have value 1` + ); + } else { + Assert.ok( + !key.hasValue(pref), + `Pref ${pref} should not be in the registry because it is false` + ); + } + } else { + Assert.ok( + !key.hasValue(pref), + `Pref ${pref} should not be in the registry because its name is invalid` + ); + } + } + + key.close(); +} + +function cleanupReflectedPrefs(prefsList) { + // Clear out the prefs themselves. + prefsList.forEach(pref => Services.prefs.clearUserPref(pref)); + + // Get the registry key path without the path hash at the end, + // then delete the subkey with the path hash. + const app = AppConstants.MOZ_APP_NAME; + const vendor = Services.appinfo.vendor || "Mozilla"; + const xreDirProvider = Cc["@mozilla.org/xre/directory-provider;1"].getService( + Ci.nsIXREDirProvider + ); + + const path = `Software\\${vendor}\\${app}\\Installer`; + + const key = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + Ci.nsIWindowsRegKey + ); + try { + key.open( + key.ROOT_KEY_CURRENT_USER, + path, + key.ACCESS_READ | key.ACCESS_WRITE | key.WOW64_64 + ); + const installHash = xreDirProvider.getInstallHash(); + key.removeChild(installHash); + } catch (ex) { + // Nothing left to clean up. + return; + } + + // If the Installer key is now empty, we need to clean it up also, because + // that would mean that this test created it. + if (key.childCount == 0) { + // Unfortunately we can't delete the actual open key, so we'll have to + // open its parent and delete the one we're after as a child. + key.close(); + const parentKey = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + Ci.nsIWindowsRegKey + ); + try { + parentKey.open( + parentKey.ROOT_KEY_CURRENT_USER, + `Software\\${vendor}\\${app}`, + parentKey.ACCESS_READ | parentKey.ACCESS_WRITE | parentKey.WOW64_64 + ); + parentKey.removeChild("Installer"); + parentKey.close(); + } catch (ex) { + // Nothing we can do, and this isn't worth failing the test over. + } + } else { + key.close(); + } +} diff --git a/browser/components/installerprefs/test/unit/test_empty_prefs_list.js b/browser/components/installerprefs/test/unit/test_empty_prefs_list.js new file mode 100644 index 0000000000..e9807546a9 --- /dev/null +++ b/browser/components/installerprefs/test/unit/test_empty_prefs_list.js @@ -0,0 +1,20 @@ +/* -*- 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/. */ + +/** + * Test passing an empty list of pref names to the module. + */ + +add_task(function () { + const PREFS_LIST = []; + + startModule(PREFS_LIST); + + Assert.throws( + getRegistryKey, + /NS_ERROR_FAILURE/, + "The registry key shouldn't have been created, so opening it should fail" + ); +}); diff --git a/browser/components/installerprefs/test/unit/test_invalid_name.js b/browser/components/installerprefs/test/unit/test_invalid_name.js new file mode 100644 index 0000000000..030bc8fae5 --- /dev/null +++ b/browser/components/installerprefs/test/unit/test_invalid_name.js @@ -0,0 +1,20 @@ +/* -*- 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/. */ + +/** + * Test that using prefs with invalid names is not allowed. + */ + +add_task(function () { + const PREFS_LIST = [ + "the.wrong.branch", + "installer.the.right.branch", + "not.a.real.pref", + ]; + + startModule(PREFS_LIST); + + verifyReflectedPrefs(PREFS_LIST); +}); diff --git a/browser/components/installerprefs/test/unit/test_nonbool_pref.js b/browser/components/installerprefs/test/unit/test_nonbool_pref.js new file mode 100644 index 0000000000..dfee59952d --- /dev/null +++ b/browser/components/installerprefs/test/unit/test_nonbool_pref.js @@ -0,0 +1,19 @@ +/* -*- 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/. */ + +/** + * Test that prefs of types other than bool are not reflected. + */ + +add_task(function () { + const PREFS_LIST = ["installer.int", "installer.string"]; + + Services.prefs.setIntPref("installer.int", 12); + Services.prefs.setStringPref("installer.string", "I'm a string"); + + startModule(PREFS_LIST); + + verifyReflectedPrefs(PREFS_LIST); +}); diff --git a/browser/components/installerprefs/test/unit/test_pref_change.js b/browser/components/installerprefs/test/unit/test_pref_change.js new file mode 100644 index 0000000000..aba37c266d --- /dev/null +++ b/browser/components/installerprefs/test/unit/test_pref_change.js @@ -0,0 +1,26 @@ +/* -*- 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/. */ + +/** + * Test that pref values are correctly updated when they change. + */ + +add_task(function () { + const PREFS_LIST = ["installer.pref"]; + + Services.prefs.setBoolPref("installer.pref", true); + + startModule(PREFS_LIST); + + verifyReflectedPrefs(PREFS_LIST); + + Services.prefs.setBoolPref("installer.pref", false); + + verifyReflectedPrefs(PREFS_LIST); + + Services.prefs.setBoolPref("installer.pref", true); + + verifyReflectedPrefs(PREFS_LIST); +}); diff --git a/browser/components/installerprefs/test/unit/test_pref_values.js b/browser/components/installerprefs/test/unit/test_pref_values.js new file mode 100644 index 0000000000..529148ad5e --- /dev/null +++ b/browser/components/installerprefs/test/unit/test_pref_values.js @@ -0,0 +1,19 @@ +/* -*- 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/. */ + +/** + * Test that pref values are reflected correctly. + */ + +add_task(function () { + const PREFS_LIST = ["installer.true.value", "installer.false.value"]; + + Services.prefs.setBoolPref("installer.true.value", true); + Services.prefs.setBoolPref("installer.false.value", false); + + startModule(PREFS_LIST); + + verifyReflectedPrefs(PREFS_LIST); +}); diff --git a/browser/components/installerprefs/test/unit/xpcshell.toml b/browser/components/installerprefs/test/unit/xpcshell.toml new file mode 100644 index 0000000000..5837507001 --- /dev/null +++ b/browser/components/installerprefs/test/unit/xpcshell.toml @@ -0,0 +1,25 @@ +[DEFAULT] +head = "head.js" +firefox-appdir = "browser" +skip-if = ["os != 'win'"] + +# These tests must all run sequentially because they use the same registry key. +# It might be possible to get around this requirement by overriding the install +# hash so each test uses a different key, and if a lot more tests are added here +# then it would be worth looking into that. + +["test_empty_prefs_list.js"] +run-sequentially = "Uses the Windows registry" +skip-if = ["os == 'win' && msix"] # https://bugzilla.mozilla.org/show_bug.cgi?id=1807932 + +["test_invalid_name.js"] +run-sequentially = "Uses the Windows registry" + +["test_nonbool_pref.js"] +run-sequentially = "Uses the Windows registry" + +["test_pref_change.js"] +run-sequentially = "Uses the Windows registry" + +["test_pref_values.js"] +run-sequentially = "Uses the Windows registry" diff --git a/browser/components/ion/content/ion.css b/browser/components/ion/content/ion.css new file mode 100644 index 0000000000..e69828a2a6 --- /dev/null +++ b/browser/components/ion/content/ion.css @@ -0,0 +1,180 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +body { + margin: 40px auto; + max-width: 664px; + font-family: 'Segoe UI', 'Ubuntu', 'Helvetica Neue', sans-serif; +} + +@media (max-width: 830px) { + body { + margin-inline-start: 16px; + margin-inline-end: 16px; + } +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-block-end: 24px; +} + +@media (max-width: 600px) { + header { + display: block; + } +} + +#locale-notification { + display: none; + text-align: center; +} + +#summary, #details, #data { + margin-block-end: 24px; +} + +#summary, #summary, #details p, #data p { + font-size: 15px; + line-height: 22px; +} + +#data ul { + padding-inline-start: 15px; +} + +#data ul li { + margin-block-end: 8px; +} + +#data a, #data strong { + font-weight: 600; +} + +h2 { + font-size: 17px; + font-weight: 600; +} + +details > summary { + user-select: none; + padding: 2px 6px; + width: 18em; + cursor: pointer; + outline: none; + font-size: 17px; + font-weight: 600; +} + +#report-title, #title { + margin-block-end: 0; +} + +@media (max-width: 600px) { + #title { + margin-block-end: 8px; + } + #enrollment-button { + margin-inline-start: 0; + } +} + +#available-studies { + font-weight: 600; +} + +.card { + display: flex; + flex-wrap: wrap; +} + +.card-icon { + width: 32px; + height: 32px; + flex-shrink: 0; +} + +.card-body { + flex-grow: 1; + margin-inline-start: 16px; +} + +.card-name { + margin: 0; + font-size: 16px; + font-weight: 600; + line-height: 1; +} + +.card-author { + margin: 0; + font-size: 14px; + font-weight: 400; +} + +.card-actions { + align-self: center; + flex-shrink: 0; + min-width: 120px; +} + +.join-button { + max-width: 200px; + margin: 0; + margin-inline-end: 16px; +} + +.card-description { + font-size: 14px; + font-weight: normal; + width: 100%; +} + +.card-data-collected { + font-size: 14px; + font-weight: normal; +} + +*[hidden] { + display: none !important; +} + +#ion-icon { + -moz-context-properties: fill; + fill: currentColor; +} + +.modal { + max-height: 90%; + max-width: min(700px, 80%); + overflow: auto; + background: var(--in-content-page-background); + color: var(--in-content-page-color); + border: 1px solid transparent; + border-radius: 3.5px; + box-shadow: 0 2px 6px 0 rgba(0,0,0,0.3); + padding: 8px 16px 0; +} + +.modal > footer { + display: flex; + justify-content: flex-end; + padding-bottom: 16px; +} + +.modal::backdrop { + background-color: rgba(0,0,0,.5); +} + +h1 > p { margin-bottom: 0} + +.consent-list { + padding: 16px; + padding-inline-start: 32px; + margin-block-end: 16px; + border: 1px solid var(--in-content-box-border-color); + background: var(--in-content-box-background); +} diff --git a/browser/components/ion/content/ion.ftl b/browser/components/ion/content/ion.ftl new file mode 100644 index 0000000000..6762eca7b1 --- /dev/null +++ b/browser/components/ion/content/ion.ftl @@ -0,0 +1,83 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +### This file is not in a locales directory to prevent it from +### being translated as the feature is still in heavy development +### and strings are likely to change often. + +-ion-brand-short-name = Ion +ion = { -ion-brand-short-name } +ion-document-title = Put your data to work for a better internet +ion-summary = { -ion-brand-short-name } puts your data to work to address some of today’s most pressing technology concerns, like misinformation, data privacy, and ethical AI. The data you agree to share with Mozilla (the makers of Firefox) helps create tools for better internet transparency and design products that give control back to the people who use them. As an { -ion-brand-short-name } participant, you’ll also have the option to contribute your data to studies sponsored by research institutions and other organizations. +ion-study-prompt = Join to enroll +ion-join-study = Join Study +ion-leave-study = Leave Study +ion-enrollment-button = Join { -ion-brand-short-name } +ion-unenrollment-button = Leave { -ion-brand-short-name } +ion-current-studies = Current Studies +ion-no-current-studies = No current studies, please check back later. +ion-end-study = End Study +ion-ended-study = Study Ended +ion-accept-participate = Accept and Participate +ion-accept-leave = Accept and Leave +ion-cancel = Cancel +ion-consent-notice = { -ion-brand-short-name } Privacy Notice +ion-consent-study-notice = { -ion-brand-short-name } Study Privacy Consent Notice + +leave-ion-consent-title = You’re leaving? +leave-ion-consent-bullet-thanks = Thank you for participating. +leave-ion-consent-bullet-manage = We’re sorry to see you go. Once you leave { -ion-brand-short-name }, we will stop all data collection and unenroll you from any active studies. We will also delete the data you’ve contributed, where applicable. Learn more about managing your { -ion-brand-short-name } data. + +leave-study-consent-title = Withdraw your contribution to this study? +leave-study-consent-bullet-manage = If you unenroll while this study is still active, { -ion-brand-short-name } will rescind your contribution to this research effort and will delete any data you’ve already contributed. Learn more about managing your { -ion-brand-short-name } data. + +ion-consent-study-title = Leaving Study +ion-consent-study-join = Accept and Join Study +ion-consent-study-leave = Accept and Leave Study + +ion-program-consent-intro = When you enroll in { -ion-brand-short-name }, you are sharing personal information. Keeping that information safe is important to us and to the integrity of { -ion-brand-short-name }. Here is how we safeguard your data and protect your identity. + +ion-program-study-intro = When you enroll in this study, you are sharing personal information. Keeping that information safe is important to us and to the integrity of { -ion-brand-short-name }. Here is how we safeguard your data and protect your identity. + + +ion-works-title = How it works: +ion-works-bullet-get-started-title = Get started. +ion-works-bullet-get-started-content = Select the { ion-enrollment-button } button, review and agree to our Privacy Notice, and answer a few (optional) demographic questions. Note that { -ion-brand-short-name } is currently open to participants in the US who are 19 or older. +ion-works-bullet-enroll-title = Enroll in studies. +ion-works-bullet-enroll-content = Share your data with studies run by { -vendor-short-name } and our { -ion-brand-short-name } research partners. You’ll have the opportunity to learn about a study’s goals, the data it collects, and its research team before you enroll. +ion-works-bullet-control-title = Stay in control. +ion-works-bullet-control-content = The { -ion-brand-short-name } icon will appear on the { -brand-product-name } toolbar. Select the icon any time you want to return to this page to update your settings, enroll in a study, or leave a study or the { -ion-brand-short-name } program. + +ion-your-data-title = Your data: why it matters and how we protect it +ion-your-data-summary = { -ion-brand-short-name } puts your data to work for a better internet. Our goal is to better understand topics like internet usage, online privacy, algorithmic bias, discrimination, and misinformation. This in turn can lead to new products that fundamentally change the tech landscape and hand more power and control back to users. + +ion-your-data-bullet-know = You’ll know the information we plan to collect before we collect it. We publish our data collection documentation, so you can confirm this for yourself. Read each privacy notice for detailed information. +ion-your-data-bullet-lengths = We prioritize securing your data and protecting your privacy. +ion-your-data-bullet-leave = You can leave the { -ion-brand-short-name } program at any time, and we’ll stop collecting data when you do. +ion-your-data-learn-more = Learn more about managing the data you share with { -ion-brand-short-name }. + +ion-us-only = Sorry, { -ion-brand-short-name } is currently only open to participants in the US. + +ion-enroll-effective-date = Effective September 1, 2020 +ion-enroll-summary = { -ion-brand-short-name } is an experimental initiative led by Mozilla to better understand how our users use and navigate the internet. { -ion-brand-short-name } is available to Firefox users in the United States who are 19 or older. +ion-enroll-demographic = When you join { -ion-brand-short-name }, we’ll ask you to provide optional demographic data. We’ll also collect basic technical and interaction data as long as you’re participating in { -ion-brand-short-name }. Once you’ve enrolled, you’ll have the opportunity to join available studies—each study will have a specific research purpose and unique privacy notice for you to review before you join it. +ion-enroll-privacy-notice = In this Privacy Notice, we detail what data the { -ion-brand-short-name } program collects and discloses, and why. Read each study’s privacy notice for information about how data is collected and handled in that particular study. We also adhere to the Mozilla Privacy Policy for how we receive, handle, and share information. +ion-enroll-data-disclosure = To see a full list of the data we collect, click here. +ion-enroll-what-we-collect = What Information We Collect: +ion-enroll-collect-demographic = Demographic data: We collect optional, self-reported demographic data from { -ion-brand-short-name } participants, including their age, gender, race/ethnicity, education level, household income, and zip code. +ion-enroll-technical-data = Technical data: We collect basic information about your device’s operating system. When Firefox sends data to us, your IP address is temporarily collected as part of our server logs. +ion-enroll-interaction-data = Interaction data: We collect data about your interactions with Firefox, like number and type of installed Firefox Add-ons and your active browsing session duration. +ion-enroll-location-data = Location data: We will use your IP address to approximate your country location, in addition to collecting your self-reported zip code (if you provide it). +ion-enroll-how-we-use = How We Use Your Information: +ion-enroll-r-and-d = We use the information we collect for for research and development, including: +ion-enroll-bullet-criteria = To determine which participants meet the criteria to be available to participate in particular research studies +ion-enroll-bullet-representative = To ensure our data sets are representative of the many users of Firefox +ion-enroll-bullet-improve-existing = To improve our existing products and services +ion-enroll-bullet-create = To create and develop new products +ion-enroll-who-we-disclose-to = Who We May Disclose Information To: +ion-enroll-who-we-disclose-bullet-gcp = Google Cloud Platform (GCP): We use GCP as our cloud-storage service. Mozilla has contracted with GCP requiring them to handle the data in ways that are approved by us. +ion-enroll-who-we-disclose-bullet-third-party = Third-party researchers: As part of being part of the { -ion-brand-short-name } program, we will offer you the ability to join studies. If necessary for the study, we may ask you to share all or some of the data collected under this Privacy Notice with the third party researcher(s) administering a study. Mozilla will contractually obligate the third party researchers to ensure that your data is handled in ways that are approved by us. +ion-enroll-who-we-disclose-bullet-public = General public: To advance our mission of being open, we may release data sets to the general public. When we do so, we will aggregate the data and remove identifying information, so the data won’t reveal the behaviors or characteristics of individual users. +ion-enroll-data-management = Data Management: +ion-enroll-data-management-learn-more = You can learn more about managing your { -ion-brand-short-name } and individual study data here. If you have any other questions regarding our privacy practices, please contact us at compliance@mozilla.com. diff --git a/browser/components/ion/content/ion.html b/browser/components/ion/content/ion.html new file mode 100644 index 0000000000..5cf05e8a36 --- /dev/null +++ b/browser/components/ion/content/ion.html @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +

    + +
    +
    +
    + +

    + + +

    +

    + + +

    +

    + + +

    +
    +
    + +

    +
      +
    • +
    • +
    • +
    +
    +

    + +

    +

    +
    + +

    +

    + +
    + + +
    +
    + +

    + +
    + + +
    +
    + +

    +

    + +
    + + +
    +
    + +

    + +
    + + +
    +
    +
    + + diff --git a/browser/components/ion/content/ion.js b/browser/components/ion/content/ion.js new file mode 100644 index 0000000000..3c34328d58 --- /dev/null +++ b/browser/components/ion/content/ion.js @@ -0,0 +1,791 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Control panel for the Ion project, formerly known as Pioneer. + * This lives in `about:ion` and provides a UI for users to un/enroll in the + * overall program, and to un/enroll from individual studies. + * + * NOTE - prefs and Telemetry both still mention Pioneer for backwards-compatibility, + * this may change in the future. + */ + +const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); + +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); + +const { TelemetryController } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryController.sys.mjs" +); + +let parserUtils = Cc["@mozilla.org/parserutils;1"].getService( + Ci.nsIParserUtils +); + +const PREF_ION_ID = "toolkit.telemetry.pioneerId"; +const PREF_ION_NEW_STUDIES_AVAILABLE = + "toolkit.telemetry.pioneer-new-studies-available"; +const PREF_ION_COMPLETED_STUDIES = + "toolkit.telemetry.pioneer-completed-studies"; + +/** + * Remote Settings keys for general content, and available studies. + */ +const CONTENT_COLLECTION_KEY = "pioneer-content-v2"; +const STUDY_ADDON_COLLECTION_KEY = "pioneer-study-addons-v2"; + +const STUDY_LEAVE_REASONS = { + USER_ABANDONED: "user-abandoned", + STUDY_ENDED: "study-ended", +}; + +const PREF_TEST_CACHED_CONTENT = "toolkit.pioneer.testCachedContent"; +const PREF_TEST_CACHED_ADDONS = "toolkit.pioneer.testCachedAddons"; +const PREF_TEST_ADDONS = "toolkit.pioneer.testAddons"; + +/** + * Use the in-tree HTML Sanitizer to ensure that HTML from remote-settings is safe to use. + * Note that RS does use content-signing, we're doing this extra step as an in-depth security measure. + * + * @param {string} htmlString - unsanitized HTML (content-signed by remote-settings) + * @returns {DocumentFragment} - sanitized DocumentFragment + */ +function sanitizeHtml(htmlString) { + const content = document.createElement("div"); + const contentFragment = parserUtils.parseFragment( + htmlString, + Ci.nsIParserUtils.SanitizerDropForms | + Ci.nsIParserUtils.SanitizerAllowStyle | + Ci.nsIParserUtils.SanitizerLogRemovals, + false, + Services.io.newURI("about:ion"), + content + ); + + return contentFragment; +} + +function showEnrollmentStatus() { + const ionId = Services.prefs.getStringPref(PREF_ION_ID, null); + + const enrollmentButton = document.getElementById("enrollment-button"); + + document.l10n.setAttributes( + enrollmentButton, + `ion-${ionId ? "un" : ""}enrollment-button` + ); + enrollmentButton.classList.toggle("primary", !ionId); + + // collapse content above the fold if enrolled, otherwise open it. + for (const section of ["details", "data"]) { + const details = document.getElementById(section); + if (ionId) { + details.removeAttribute("open"); + } else { + details.setAttribute("open", true); + } + } +} + +function toggleContentBasedOnLocale() { + const requestedLocale = Services.locale.requestedLocale; + if (requestedLocale !== "en-US") { + const localeNotificationBar = document.getElementById( + "locale-notification" + ); + localeNotificationBar.style.display = "block"; + + const reportContent = document.getElementById("report-content"); + reportContent.style.display = "none"; + } +} + +async function toggleEnrolled(studyAddonId, cachedAddons) { + let addon; + let install; + + const cachedAddon = cachedAddons.find(a => a.addon_id == studyAddonId); + + if (Cu.isInAutomation) { + install = { + install: async () => { + let testAddons = Services.prefs.getStringPref(PREF_TEST_ADDONS, "[]"); + testAddons = JSON.parse(testAddons); + + testAddons.push(studyAddonId); + Services.prefs.setStringPref( + PREF_TEST_ADDONS, + JSON.stringify(testAddons) + ); + }, + }; + + let testAddons = Services.prefs.getStringPref(PREF_TEST_ADDONS, "[]"); + testAddons = JSON.parse(testAddons); + + for (const testAddon of testAddons) { + if (testAddon == studyAddonId) { + addon = {}; + addon.uninstall = () => { + Services.prefs.setStringPref( + PREF_TEST_ADDONS, + JSON.stringify(testAddons.filter(a => a.id != testAddon.id)) + ); + }; + } + } + } else { + addon = await AddonManager.getAddonByID(studyAddonId); + install = await AddonManager.getInstallForURL(cachedAddon.sourceURI.spec); + } + + const completedStudies = Services.prefs.getStringPref( + PREF_ION_COMPLETED_STUDIES, + "{}" + ); + + const study = document.querySelector(`.card[id="${cachedAddon.addon_id}"`); + const joinBtn = study.querySelector(".join-button"); + + if (addon) { + joinBtn.disabled = true; + await addon.uninstall(); + await sendDeletionPing(studyAddonId); + + document.l10n.setAttributes(joinBtn, "ion-join-study"); + joinBtn.disabled = false; + + // Record that the user abandoned this study, since it may not be re-join-able. + if (completedStudies) { + const studies = JSON.parse(completedStudies); + studies[studyAddonId] = STUDY_LEAVE_REASONS.USER_ABANDONED; + Services.prefs.setStringPref( + PREF_ION_COMPLETED_STUDIES, + JSON.stringify(studies) + ); + } + } else { + // Check if this study is re-join-able before enrollment. + const studies = JSON.parse(completedStudies); + if (studyAddonId in studies) { + if ( + "canRejoin" in cachedAddons[studyAddonId] && + cachedAddons[studyAddonId].canRejoin === false + ) { + console.error( + `Cannot rejoin ended study ${studyAddonId}, reason: ${studies[studyAddonId]}` + ); + return; + } + } + joinBtn.disabled = true; + await install.install(); + document.l10n.setAttributes(joinBtn, "ion-leave-study"); + joinBtn.disabled = false; + + // Send an enrollment ping for this study. Note that this could be sent again + // if we are re-joining. + await sendEnrollmentPing(studyAddonId); + } + + await updateStudy(cachedAddon.addon_id); +} + +async function showAvailableStudies(cachedAddons) { + const ionId = Services.prefs.getStringPref(PREF_ION_ID, null); + const defaultAddons = cachedAddons.filter(a => a.isDefault); + if (ionId) { + for (const defaultAddon of defaultAddons) { + let addon; + let install; + if (Cu.isInAutomation) { + install = { + install: async () => { + if ( + defaultAddon.addon_id == "ion-v2-bad-default-example@mozilla.org" + ) { + throw new Error("Bad test default add-on"); + } + }, + }; + } else { + addon = await AddonManager.getAddonByID(defaultAddon.addon_id); + install = await AddonManager.getInstallForURL( + defaultAddon.sourceURI.spec + ); + } + + if (!addon) { + // Any default add-ons are required, try to reinstall. + await install.install(); + } + } + } + + const studyAddons = cachedAddons.filter(a => !a.isDefault); + for (const cachedAddon of studyAddons) { + if (!cachedAddon) { + console.error( + `about:ion - Study addon ID not found in cache: ${studyAddonId}` + ); + return; + } + + const studyAddonId = cachedAddon.addon_id; + + const study = document.createElement("div"); + study.setAttribute("id", studyAddonId); + study.setAttribute("class", "card card-no-hover"); + + if (cachedAddon.icons && 32 in cachedAddon.icons) { + const iconName = document.createElement("img"); + iconName.setAttribute("class", "card-icon"); + iconName.setAttribute("src", cachedAddon.icons[32]); + study.appendChild(iconName); + } + + const studyBody = document.createElement("div"); + studyBody.classList.add("card-body"); + study.appendChild(studyBody); + + const studyName = document.createElement("h3"); + studyName.setAttribute("class", "card-name"); + studyName.textContent = cachedAddon.name; + studyBody.appendChild(studyName); + + const studyAuthor = document.createElement("span"); + studyAuthor.setAttribute("class", "card-author"); + studyAuthor.textContent = cachedAddon.authors.name; + studyBody.appendChild(studyAuthor); + + const actions = document.createElement("div"); + actions.classList.add("card-actions"); + study.appendChild(actions); + + const joinBtn = document.createElement("button"); + joinBtn.setAttribute("id", `${studyAddonId}-join-button`); + joinBtn.classList.add("primary"); + joinBtn.classList.add("join-button"); + document.l10n.setAttributes(joinBtn, "ion-join-study"); + + joinBtn.addEventListener("click", async () => { + let addon; + if (Cu.isInAutomation) { + const testAddons = Services.prefs.getStringPref(PREF_TEST_ADDONS, "[]"); + for (const testAddon of JSON.parse(testAddons)) { + if (testAddon == studyAddonId) { + addon = {}; + addon.uninstall = () => { + Services.prefs.setStringPref(PREF_TEST_ADDONS, "[]"); + }; + } + } + } else { + addon = await AddonManager.getAddonByID(studyAddonId); + } + let joinOrLeave = addon ? "leave" : "join"; + let dialog = document.getElementById( + `${joinOrLeave}-study-consent-dialog` + ); + dialog.setAttribute("addon-id", cachedAddon.addon_id); + const consentText = dialog.querySelector( + `[id=${joinOrLeave}-study-consent]` + ); + + // Clears out any existing children with a single #text node + consentText.textContent = ""; + + const contentFragment = sanitizeHtml( + cachedAddon[`${joinOrLeave}StudyConsent`] + ); + consentText.appendChild(contentFragment); + + dialog.showModal(); + dialog.scrollTop = 0; + + const openEvent = new Event("open"); + dialog.dispatchEvent(openEvent); + }); + actions.appendChild(joinBtn); + + const studyDesc = document.createElement("div"); + studyDesc.setAttribute("class", "card-description"); + + const contentFragment = sanitizeHtml(cachedAddon.description); + studyDesc.appendChild(contentFragment); + + study.appendChild(studyDesc); + + const studyDataCollected = document.createElement("div"); + studyDataCollected.setAttribute("class", "card-data-collected"); + study.appendChild(studyDataCollected); + + const dataCollectionDetailsHeader = document.createElement("p"); + dataCollectionDetailsHeader.textContent = "This study will collect:"; + studyDataCollected.appendChild(dataCollectionDetailsHeader); + + const dataCollectionDetails = document.createElement("ul"); + for (const dataCollectionDetail of cachedAddon.dataCollectionDetails) { + const detailsBullet = document.createElement("li"); + detailsBullet.textContent = dataCollectionDetail; + dataCollectionDetails.append(detailsBullet); + } + studyDataCollected.appendChild(dataCollectionDetails); + + const availableStudies = document.getElementById("available-studies"); + availableStudies.appendChild(study); + + await updateStudy(studyAddonId); + } + + const availableStudies = document.getElementById("header-available-studies"); + document.l10n.setAttributes(availableStudies, "ion-current-studies"); +} + +async function updateStudy(studyAddonId) { + let addon; + if (Cu.isInAutomation) { + const testAddons = Services.prefs.getStringPref(PREF_TEST_ADDONS, "[]"); + for (const testAddon of JSON.parse(testAddons)) { + if (testAddon == studyAddonId) { + addon = { + uninstall() {}, + }; + } + } + } else { + addon = await AddonManager.getAddonByID(studyAddonId); + } + + const study = document.querySelector(`.card[id="${studyAddonId}"`); + + const joinBtn = study.querySelector(".join-button"); + + const ionId = Services.prefs.getStringPref(PREF_ION_ID, null); + + const completedStudies = Services.prefs.getStringPref( + PREF_ION_COMPLETED_STUDIES, + "{}" + ); + + const studies = JSON.parse(completedStudies); + if (studyAddonId in studies) { + study.style.opacity = 0.5; + joinBtn.disabled = true; + document.l10n.setAttributes(joinBtn, "ion-ended-study"); + return; + } + + if (ionId) { + study.style.opacity = 1; + joinBtn.disabled = false; + + if (addon) { + document.l10n.setAttributes(joinBtn, "ion-leave-study"); + } else { + document.l10n.setAttributes(joinBtn, "ion-join-study"); + } + } else { + document.l10n.setAttributes(joinBtn, "ion-study-prompt"); + study.style.opacity = 0.5; + joinBtn.disabled = true; + } +} + +// equivalent to what we use for Telemetry IDs +// https://searchfox.org/mozilla-central/rev/9193635dca8cfdcb68f114306194ffc860456044/toolkit/components/telemetry/app/TelemetryUtils.jsm#222 +function generateUUID() { + let str = Services.uuid.generateUUID().toString(); + return str.substring(1, str.length - 1); +} + +async function setup(cachedAddons) { + document + .getElementById("enrollment-button") + .addEventListener("click", async () => { + const ionId = Services.prefs.getStringPref(PREF_ION_ID, null); + + if (ionId) { + let dialog = document.getElementById("leave-ion-consent-dialog"); + dialog.showModal(); + dialog.scrollTop = 0; + } else { + let dialog = document.getElementById("join-ion-consent-dialog"); + dialog.showModal(); + dialog.scrollTop = 0; + } + }); + + document + .getElementById("join-ion-cancel-dialog-button") + .addEventListener("click", () => + document.getElementById("join-ion-consent-dialog").close() + ); + document + .getElementById("leave-ion-cancel-dialog-button") + .addEventListener("click", () => + document.getElementById("leave-ion-consent-dialog").close() + ); + document + .getElementById("join-study-cancel-dialog-button") + .addEventListener("click", () => + document.getElementById("join-study-consent-dialog").close() + ); + document + .getElementById("leave-study-cancel-dialog-button") + .addEventListener("click", () => + document.getElementById("leave-study-consent-dialog").close() + ); + + document + .getElementById("join-ion-accept-dialog-button") + .addEventListener("click", async event => { + const ionId = Services.prefs.getStringPref(PREF_ION_ID, null); + + if (!ionId) { + let uuid = generateUUID(); + Services.prefs.setStringPref(PREF_ION_ID, uuid); + for (const cachedAddon of cachedAddons) { + if (cachedAddon.isDefault) { + let install; + if (Cu.isInAutomation) { + install = { + install: async () => { + if ( + cachedAddon.addon_id == + "ion-v2-bad-default-example@mozilla.org" + ) { + throw new Error("Bad test default add-on"); + } + }, + }; + } else { + install = await AddonManager.getInstallForURL( + cachedAddon.sourceURI.spec + ); + } + + try { + await install.install(); + } catch (ex) { + // No need to throw here, we'll try again before letting users enroll in any studies. + console.error( + `Could not install default add-on ${cachedAddon.addon_id}` + ); + const availableStudies = + document.getElementById("available-studies"); + document.l10n.setAttributes( + availableStudies, + "ion-no-current-studies" + ); + } + } + const study = document.getElementById(cachedAddon.addon_id); + if (study) { + await updateStudy(cachedAddon.addon_id); + } + } + document.querySelector("dialog").close(); + } + // A this point we should have a valid ion id, so we should be able to send + // the enrollment ping. + await sendEnrollmentPing(); + + showEnrollmentStatus(); + }); + + document + .getElementById("leave-ion-accept-dialog-button") + .addEventListener("click", async event => { + const completedStudies = Services.prefs.getStringPref( + PREF_ION_COMPLETED_STUDIES, + "{}" + ); + const studies = JSON.parse(completedStudies); + + // Send a deletion ping for all completed studies the user has been a part of. + for (const studyAddonId in studies) { + await sendDeletionPing(studyAddonId); + } + + Services.prefs.clearUserPref(PREF_ION_COMPLETED_STUDIES); + + for (const cachedAddon of cachedAddons) { + // Record any studies that have been marked as concluded on the server, in case they re-enroll. + if ("studyEnded" in cachedAddon && cachedAddon.studyEnded === true) { + studies[cachedAddon.addon_id] = STUDY_LEAVE_REASONS.STUDY_ENDED; + + Services.prefs.setStringPref( + PREF_ION_COMPLETED_STUDIES, + JSON.stringify(studies) + ); + } + + let addon; + if (Cu.isInAutomation) { + addon = {}; + addon.id = cachedAddon.addon_id; + addon.uninstall = () => { + let testAddons = Services.prefs.getStringPref( + PREF_TEST_ADDONS, + "[]" + ); + testAddons = JSON.parse(testAddons); + + Services.prefs.setStringPref( + PREF_TEST_ADDONS, + JSON.stringify( + testAddons.filter(a => a.id != cachedAddon.addon_id) + ) + ); + }; + } else { + addon = await AddonManager.getAddonByID(cachedAddon.addon_id); + } + if (addon) { + await sendDeletionPing(addon.id); + await addon.uninstall(); + } + } + + Services.prefs.clearUserPref(PREF_ION_ID); + for (const cachedAddon of cachedAddons) { + const study = document.getElementById(cachedAddon.addon_id); + if (study) { + await updateStudy(cachedAddon.addon_id); + } + } + + document.getElementById("leave-ion-consent-dialog").close(); + showEnrollmentStatus(); + }); + + document + .getElementById("join-study-accept-dialog-button") + .addEventListener("click", async event => { + const dialog = document.getElementById("join-study-consent-dialog"); + const studyAddonId = dialog.getAttribute("addon-id"); + toggleEnrolled(studyAddonId, cachedAddons).then(dialog.close()); + }); + + document + .getElementById("leave-study-accept-dialog-button") + .addEventListener("click", async event => { + const dialog = document.getElementById("leave-study-consent-dialog"); + const studyAddonId = dialog.getAttribute("addon-id"); + await toggleEnrolled(studyAddonId, cachedAddons).then(dialog.close()); + }); + + const onAddonEvent = async addon => { + for (const cachedAddon of cachedAddons) { + if (cachedAddon.addon_id == addon.id) { + await updateStudy(addon.id); + } + } + }; + + const addonsListener = { + onEnabled: onAddonEvent, + onDisabled: onAddonEvent, + onInstalled: onAddonEvent, + onUninstalled: onAddonEvent, + }; + AddonManager.addAddonListener(addonsListener); + + window.addEventListener("unload", event => { + AddonManager.removeAddonListener(addonsListener); + }); +} + +function removeBadge() { + Services.prefs.setBoolPref(PREF_ION_NEW_STUDIES_AVAILABLE, false); + + for (let win of Services.wm.getEnumerator("navigator:browser")) { + const badge = win.document + .getElementById("ion-button") + .querySelector(".toolbarbutton-badge"); + badge.classList.remove("feature-callout"); + } +} + +// Updates Ion HTML page contents from RemoteSettings. +function updateContents(contents) { + for (const section of [ + "title", + "summary", + "details", + "data", + "joinIonConsent", + "leaveIonConsent", + ]) { + if (contents && section in contents) { + // Generate a corresponding dom-id style ID for a camel-case domId style JS attribute. + // Dynamically set the tag type based on which section is getting updated. + const domId = section + .split(/(?=[A-Z])/) + .join("-") + .toLowerCase(); + // Clears out any existing children with a single #text node. + document.getElementById(domId).textContent = ""; + + const contentFragment = sanitizeHtml(contents[section]); + document.getElementById(domId).appendChild(contentFragment); + } + } +} + +document.addEventListener("DOMContentLoaded", async domEvent => { + toggleContentBasedOnLocale(); + + showEnrollmentStatus(); + + document.addEventListener("focus", removeBadge); + removeBadge(); + + const privacyPolicyLinks = document.querySelectorAll( + ".privacy-policy,.privacy-notice" + ); + for (const privacyPolicyLink of privacyPolicyLinks) { + const privacyPolicyFormattedLink = Services.urlFormatter.formatURL( + privacyPolicyLink.href + ); + privacyPolicyLink.href = privacyPolicyFormattedLink; + } + + let cachedContent; + let cachedAddons; + if (Cu.isInAutomation) { + let testCachedAddons = Services.prefs.getStringPref( + PREF_TEST_CACHED_ADDONS, + null + ); + if (testCachedAddons) { + cachedAddons = JSON.parse(testCachedAddons); + } + + let testCachedContent = Services.prefs.getStringPref( + PREF_TEST_CACHED_CONTENT, + null + ); + if (testCachedContent) { + cachedContent = JSON.parse(testCachedContent); + } + } else { + cachedContent = await RemoteSettings(CONTENT_COLLECTION_KEY).get(); + cachedAddons = await RemoteSettings(STUDY_ADDON_COLLECTION_KEY).get(); + } + + // Replace existing contents immediately on page load. + for (const contents of cachedContent) { + updateContents(contents); + } + + for (const cachedAddon of cachedAddons) { + // Record any studies that have been marked as concluded on the server. + if ("studyEnded" in cachedAddon && cachedAddon.studyEnded === true) { + const completedStudies = Services.prefs.getStringPref( + PREF_ION_COMPLETED_STUDIES, + "{}" + ); + const studies = JSON.parse(completedStudies); + studies[cachedAddon.addon_id] = STUDY_LEAVE_REASONS.STUDY_ENDED; + + Services.prefs.setStringPref( + PREF_ION_COMPLETED_STUDIES, + JSON.stringify(studies) + ); + } + } + + await setup(cachedAddons); + + try { + await showAvailableStudies(cachedAddons); + } catch (ex) { + // No need to throw here, we'll try again before letting users enroll in any studies. + console.error(`Could not show available studies`, ex); + } +}); + +async function sendDeletionPing(studyAddonId) { + const type = "pioneer-study"; + + const options = { + studyName: studyAddonId, + addPioneerId: true, + useEncryption: true, + // NOTE - while we're not actually sending useful data in this payload, the current Pioneer v2 Telemetry + // pipeline requires that pings are shaped this way so they are routed to the correct environment. + // + // At the moment, the public key used here isn't important but we do need to use *something*. + encryptionKeyId: "discarded", + publicKey: { + crv: "P-256", + kty: "EC", + x: "XLkI3NaY3-AF2nRMspC63BT1u0Y3moXYSfss7VuQ0mk", + y: "SB0KnIW-pqk85OIEYZenoNkEyOOp5GeWQhS1KeRtEUE", + }, + schemaName: "deletion-request", + schemaVersion: 1, + // The schema namespace needs to be the study addon id, as we + // want to route the ping to the specific study table. + schemaNamespace: studyAddonId, + }; + + const payload = { + encryptedData: "", + }; + + await TelemetryController.submitExternalPing(type, payload, options); +} + +/** + * Sends a Pioneer enrollment ping. + * + * The `creationDate` provided by the telemetry APIs will be used as the timestamp for + * considering the user enrolled in pioneer and/or the study. + * + * @param {string} [studyAddonId] - optional study id. It's sent in the ping, if present, + * to signal that user enroled in the study. + */ +async function sendEnrollmentPing(studyAddonId) { + let options = { + studyName: "pioneer-meta", + addPioneerId: true, + useEncryption: true, + // NOTE - while we're not actually sending useful data in this payload, the current Pioneer v2 Telemetry + // pipeline requires that pings are shaped this way so they are routed to the correct environment. + // + // At the moment, the public key used here isn't important but we do need to use *something*. + encryptionKeyId: "discarded", + publicKey: { + crv: "P-256", + kty: "EC", + x: "XLkI3NaY3-AF2nRMspC63BT1u0Y3moXYSfss7VuQ0mk", + y: "SB0KnIW-pqk85OIEYZenoNkEyOOp5GeWQhS1KeRtEUE", + }, + schemaName: "pioneer-enrollment", + schemaVersion: 1, + // Note that the schema namespace directly informs how data is segregated after ingestion. + // If this is an enrollment ping for the pioneer program (in contrast to the enrollment to + // a specific study), use a meta namespace. + schemaNamespace: "pioneer-meta", + }; + + // If we were provided with a study id, then this is an enrollment to a study. + // Send the id alongside with the data and change the schema namespace to simplify + // the work on the ingestion pipeline. + if (typeof studyAddonId != "undefined") { + options.studyName = studyAddonId; + // The schema namespace needs to be the study addon id, as we + // want to route the ping to the specific study table. + options.schemaNamespace = studyAddonId; + } + + await TelemetryController.submitExternalPing("pioneer-study", {}, options); +} diff --git a/browser/components/ion/jar.mn b/browser/components/ion/jar.mn new file mode 100644 index 0000000000..b7582addd4 --- /dev/null +++ b/browser/components/ion/jar.mn @@ -0,0 +1,8 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +browser.jar: + content/browser/ion.html (content/ion.html) + content/browser/ion.css (content/ion.css) + content/browser/ion.js (content/ion.js) diff --git a/browser/components/ion/moz.build b/browser/components/ion/moz.build new file mode 100644 index 0000000000..7a8e9dcfe3 --- /dev/null +++ b/browser/components/ion/moz.build @@ -0,0 +1,17 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +BROWSER_CHROME_MANIFESTS += ["test/browser/browser.toml"] + +JAR_MANIFESTS += ["jar.mn"] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "General") + +TESTING_JS_MODULES += [ + "schemas/IonContentSchema.json", + "schemas/IonStudyAddonsSchema.json", +] diff --git a/browser/components/ion/schemas/IonContentSchema.json b/browser/components/ion/schemas/IonContentSchema.json new file mode 100644 index 0000000000..96bb4e7b81 --- /dev/null +++ b/browser/components/ion/schemas/IonContentSchema.json @@ -0,0 +1,39 @@ +{ + "definitions": {}, + "title": "Root", + "type": "object", + "required": [ + "title", + "summary", + "details", + "joinIonConsent", + "leaveIonConsent" + ], + "properties": { + "title": { + "$id": "#root/title", + "title": "Title", + "type": "string" + }, + "summary": { + "$id": "#root/summary", + "title": "Summary", + "type": "string" + }, + "details": { + "$id": "#root/details", + "title": "Details", + "type": "string" + }, + "joinIonConsent": { + "$id": "#root/joinIonConsent", + "title": "JoinIonconsent", + "type": "string" + }, + "leaveIonConsent": { + "$id": "#root/leaveIonConsent", + "title": "LeaveIonconsent", + "type": "string" + } + } +} diff --git a/browser/components/ion/schemas/IonStudyAddonsSchema.json b/browser/components/ion/schemas/IonStudyAddonsSchema.json new file mode 100644 index 0000000000..116b0dc1ae --- /dev/null +++ b/browser/components/ion/schemas/IonStudyAddonsSchema.json @@ -0,0 +1,159 @@ +{ + "definitions": {}, + "title": "Root", + "type": "object", + "required": [ + "addon_id", + "icons", + "name", + "version", + "sourceURI", + "description", + "privacyPolicy", + "studyType", + "authors", + "dataCollectionDetails", + "moreInfo", + "isDefault", + "studyEnded", + "joinStudyConsent", + "leaveStudyConsent" + ], + "properties": { + "addon_id": { + "$id": "#root/addon_id", + "title": "Addon_id", + "type": "string" + }, + "icons": { + "$id": "#root/icons", + "title": "Icons", + "type": "object", + "required": ["32", "64", "128"], + "properties": { + "32": { + "$id": "#root/icons/32", + "title": "32", + "type": "string" + }, + "64": { + "$id": "#root/icons/64", + "title": "64", + "type": "string" + }, + "128": { + "$id": "#root/icons/128", + "title": "128", + "type": "string" + } + } + }, + "name": { + "$id": "#root/name", + "title": "Name", + "type": "string" + }, + "version": { + "$id": "#root/version", + "title": "Version", + "type": "string" + }, + "sourceURI": { + "$id": "#root/sourceURI", + "title": "Sourceuri", + "type": "object", + "required": ["spec"], + "properties": { + "spec": { + "$id": "#root/sourceURI/spec", + "title": "Spec", + "type": "string" + } + } + }, + "description": { + "$id": "#root/description", + "title": "Description", + "type": "string" + }, + "privacyPolicy": { + "$id": "#root/privacyPolicy", + "title": "Privacypolicy", + "type": "object", + "required": ["spec"], + "properties": { + "spec": { + "$id": "#root/privacyPolicy/spec", + "title": "Spec", + "type": "string" + } + } + }, + "studyType": { + "$id": "#root/studyType", + "title": "Studytype", + "type": "string" + }, + "authors": { + "$id": "#root/authors", + "title": "Authors", + "type": "object", + "required": ["name", "url"], + "properties": { + "name": { + "$id": "#root/authors/name", + "title": "Name", + "type": "string" + }, + "url": { + "$id": "#root/authors/url", + "title": "Url", + "type": "string" + } + } + }, + "dataCollectionDetails": { + "$id": "#root/dataCollectionDetails", + "title": "Datacollectiondetails", + "type": "array", + "items": { + "$id": "#root/dataCollectionDetails/items", + "title": "Items", + "type": "string" + } + }, + "moreInfo": { + "$id": "#root/moreInfo", + "title": "Moreinfo", + "type": "object", + "required": ["spec"], + "properties": { + "spec": { + "$id": "#root/moreInfo/spec", + "title": "Spec", + "type": "string" + } + } + }, + "isDefault": { + "$id": "#root/isDefault", + "title": "Isdefault", + "type": "boolean" + }, + "studyEnded": { + "$id": "#root/studyEnded", + "title": "Studyended", + "type": "boolean" + }, + "joinStudyConsent": { + "$id": "#root/joinStudyConsent", + "title": "Joinstudyconsent", + "type": "string" + }, + "leaveStudyConsent": { + "$id": "#root/leaveStudyConsent", + "title": "Leavestudyconsent", + "type": "string" + } + } +} diff --git a/browser/components/ion/test/browser/browser.toml b/browser/components/ion/test/browser/browser.toml new file mode 100644 index 0000000000..00eae548b8 --- /dev/null +++ b/browser/components/ion/test/browser/browser.toml @@ -0,0 +1,4 @@ +[DEFAULT] + +["browser_ion_ui.js"] +fail-if = ["a11y_checks"] # Bug 1849063 clicked primary button is visually hidden and is not focusable diff --git a/browser/components/ion/test/browser/browser_ion_ui.js b/browser/components/ion/test/browser/browser_ion_ui.js new file mode 100644 index 0000000000..e956cefa25 --- /dev/null +++ b/browser/components/ion/test/browser/browser_ion_ui.js @@ -0,0 +1,1128 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { JsonSchema } = ChromeUtils.importESModule( + "resource://gre/modules/JsonSchema.sys.mjs" +); + +const { TelemetryArchive } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryArchive.sys.mjs" +); + +const { TelemetryStorage } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryStorage.sys.mjs" +); + +const ORIG_AVAILABLE_LOCALES = Services.locale.availableLocales; +const ORIG_REQUESTED_LOCALES = Services.locale.requestedLocales; + +const PREF_ION_ID = "toolkit.telemetry.pioneerId"; +const PREF_ION_NEW_STUDIES_AVAILABLE = + "toolkit.telemetry.pioneer-new-studies-available"; +const PREF_ION_COMPLETED_STUDIES = + "toolkit.telemetry.pioneer-completed-studies"; + +const PREF_TEST_CACHED_CONTENT = "toolkit.pioneer.testCachedContent"; +const PREF_TEST_CACHED_ADDONS = "toolkit.pioneer.testCachedAddons"; +const PREF_TEST_ADDONS = "toolkit.pioneer.testAddons"; + +const CACHED_CONTENT = [ + { + title: "test title

    test title line 2

    ", + summary: "

    test summary

    test summary line 2", + details: + "
    1. test details
    2. test details line 2
    3. test details line
    ", + data: "test data", + joinIonConsent: "

    test join consent

    join consent line 2

    ", + leaveIonConsent: + "

    test leave consent

    test leave consent line 2

    ", + }, +]; + +const CACHED_ADDONS = [ + { + addon_id: "ion-v2-example@mozilla.org", + icons: { + 32: "https://localhost/user-media/addon_icons/2644/2644632-32.png?modified=4a64e2bc", + 64: "https://localhost/user-media/addon_icons/2644/2644632-64.png?modified=4a64e2bc", + 128: "https://localhost/user-media/addon_icons/2644/2644632-128.png?modified=4a64e2bc", + }, + name: "Demo Study", + version: "1.0", + sourceURI: { + spec: "https://localhost", + }, + description: "Study purpose: Testing Ion.", + privacyPolicy: { + spec: "http://localhost", + }, + studyType: "extension", + authors: { + name: "Ion Developers", + url: "https://addons.mozilla.org/en-US/firefox/user/6510522/", + }, + dataCollectionDetails: ["test123", "test345"], + moreInfo: { + spec: "http://localhost", + }, + isDefault: false, + studyEnded: true, + joinStudyConsent: "test123", + leaveStudyConsent: `test345`, + }, + { + addon_id: "ion-v2-default-example@mozilla.org", + icons: { + 32: "https://localhost/user-media/addon_icons/2644/2644632-32.png?modified=4a64e2bc", + 64: "https://localhost/user-media/addon_icons/2644/2644632-64.png?modified=4a64e2bc", + 128: "https://localhost/user-media/addon_icons/2644/2644632-128.png?modified=4a64e2bc", + }, + name: "Demo Default Study", + version: "1.0", + sourceURI: { + spec: "https://localhost", + }, + description: "Study purpose: Testing Ion.", + privacyPolicy: { + spec: "http://localhost", + }, + studyType: "extension", + authors: { + name: "Ion Developers", + url: "https://addons.mozilla.org/en-US/firefox/user/6510522/", + }, + dataCollectionDetails: ["test123", "test345"], + moreInfo: { + spec: "http://localhost", + }, + isDefault: true, + studyEnded: false, + joinStudyConsent: "test456", + leaveStudyConsent: "test789", + }, + { + addon_id: "study@partner", + icons: { + 32: "https://localhost/user-media/addon_icons/2644/2644632-32.png?modified=4a64e2bc", + 64: "https://localhost/user-media/addon_icons/2644/2644632-64.png?modified=4a64e2bc", + 128: "https://localhost/user-media/addon_icons/2644/2644632-128.png?modified=4a64e2bc", + }, + name: "Example Partner Study", + version: "1.0", + sourceURI: { + spec: "https://localhost", + }, + description: "Study purpose: Testing Ion.", + privacyPolicy: { + spec: "http://localhost", + }, + studyType: "extension", + authors: { + name: "Study Partners", + url: "http://localhost", + }, + dataCollectionDetails: ["test123", "test345"], + moreInfo: { + spec: "http://localhost", + }, + isDefault: false, + studyEnded: false, + joinStudyConsent: "test012", + leaveStudyConsent: "test345", + }, + { + addon_id: "second-study@partner", + icons: { + 32: "https://localhost/user-media/addon_icons/2644/2644632-32.png?modified=4a64e2bc", + 64: "https://localhost/user-media/addon_icons/2644/2644632-64.png?modified=4a64e2bc", + 128: "https://localhost/user-media/addon_icons/2644/2644632-128.png?modified=4a64e2bc", + }, + name: "Example Second Partner Study", + version: "1.0", + sourceURI: { + spec: "https://localhost", + }, + description: "Study purpose: Testing Ion.", + privacyPolicy: { + spec: "http://localhost", + }, + studyType: "extension", + authors: { + name: "Second Study Partners", + url: "https://localhost", + }, + dataCollectionDetails: ["test123", "test345"], + moreInfo: { + spec: "http://localhost", + }, + isDefault: false, + studyEnded: false, + joinStudyConsent: "test678", + leaveStudyConsent: "test901", + }, +]; + +const CACHED_ADDONS_BAD_DEFAULT = [ + { + addon_id: "ion-v2-bad-default-example@mozilla.org", + icons: { + 32: "https://localhost/user-media/addon_icons/2644/2644632-32.png?modified=4a64e2bc", + 64: "https://localhost/user-media/addon_icons/2644/2644632-64.png?modified=4a64e2bc", + 128: "https://localhost/user-media/addon_icons/2644/2644632-128.png?modified=4a64e2bc", + }, + name: "Demo Default Study", + version: "1.0", + sourceURI: { + spec: "https://localhost", + }, + description: "Study purpose: Testing Ion.", + privacyPolicy: { + spec: "http://localhost", + }, + studyType: "extension", + authors: { + name: "Ion Developers", + url: "https://addons.mozilla.org/en-US/firefox/user/6510522/", + }, + dataCollectionDetails: ["test123", "test345"], + moreInfo: { + spec: "http://localhost", + }, + isDefault: true, + studyEnded: false, + joinStudyConsent: "test456", + leaveStudyConsent: "test789", + }, + { + addon_id: "study@partner", + icons: { + 32: "https://localhost/user-media/addon_icons/2644/2644632-32.png?modified=4a64e2bc", + 64: "https://localhost/user-media/addon_icons/2644/2644632-64.png?modified=4a64e2bc", + 128: "https://localhost/user-media/addon_icons/2644/2644632-128.png?modified=4a64e2bc", + }, + name: "Example Partner Study", + version: "1.0", + sourceURI: { + spec: "https://localhost", + }, + description: "Study purpose: Testing Ion.", + privacyPolicy: { + spec: "http://localhost", + }, + studyType: "extension", + authors: { + name: "Study Partners", + url: "http://localhost", + }, + dataCollectionDetails: ["test123", "test345"], + moreInfo: { + spec: "http://localhost", + }, + isDefault: false, + studyEnded: false, + joinStudyConsent: "test012", + leaveStudyConsent: "test345", + }, + { + addon_id: "second-study@partner", + icons: { + 32: "https://localhost/user-media/addon_icons/2644/2644632-32.png?modified=4a64e2bc", + 64: "https://localhost/user-media/addon_icons/2644/2644632-64.png?modified=4a64e2bc", + 128: "https://localhost/user-media/addon_icons/2644/2644632-128.png?modified=4a64e2bc", + }, + name: "Example Second Partner Study", + version: "1.0", + sourceURI: { + spec: "https://localhost", + }, + description: "Study purpose: Testing Ion.", + privacyPolicy: { + spec: "http://localhost", + }, + studyType: "extension", + authors: { + name: "Second Study Partners", + url: "https://localhost", + }, + dataCollectionDetails: ["test123", "test345"], + moreInfo: { + spec: "http://localhost", + }, + isDefault: false, + studyEnded: false, + joinStudyConsent: "test678", + leaveStudyConsent: "test901", + }, +]; + +const TEST_ADDONS = [ + { id: "ion-v2-example@ion.mozilla.org" }, + { id: "ion-v2-default-example@mozilla.org" }, + { id: "study@partner" }, + { id: "second-study@parnter" }, +]; + +const setupLocale = async locale => { + Services.locale.availableLocales = [locale]; + Services.locale.requestedLocales = [locale]; +}; + +const clearLocale = async () => { + Services.locale.availableLocales = ORIG_AVAILABLE_LOCALES; + Services.locale.requestedLocales = ORIG_REQUESTED_LOCALES; +}; + +add_task(async function testMockSchema() { + for (const [schemaName, values] of [ + ["IonContentSchema", CACHED_CONTENT], + ["IonStudyAddonsSchema", CACHED_ADDONS], + ]) { + const response = await fetch( + `resource://testing-common/${schemaName}.json` + ); + + const schema = await response.json(); + if (!schema) { + throw new Error(`Failed to load ${schemaName}`); + } + + const validator = new JsonSchema.Validator(schema, { shortCircuit: false }); + + for (const entry of values) { + const result = validator.validate(entry); + if (!result.valid) { + throw new Error(JSON.stringify(result.errors)); + } + } + } +}); + +add_task(async function testBadDefaultAddon() { + const cachedContent = JSON.stringify(CACHED_CONTENT); + const cachedAddons = JSON.stringify(CACHED_ADDONS_BAD_DEFAULT); + + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_TEST_CACHED_ADDONS, cachedAddons], + [PREF_TEST_CACHED_CONTENT, cachedContent], + [PREF_TEST_ADDONS, "[]"], + ], + clear: [ + [PREF_ION_ID, ""], + [PREF_ION_COMPLETED_STUDIES, "[]"], + ], + }); + + await BrowserTestUtils.withNewTab( + { + url: "about:ion", + gBrowser, + }, + async function taskFn(browser) { + const beforePref = Services.prefs.getStringPref(PREF_ION_ID, null); + Assert.strictEqual( + beforePref, + null, + "before enrollment, Ion pref is null." + ); + const enrollmentButton = + content.document.getElementById("enrollment-button"); + enrollmentButton.click(); + + const dialog = content.document.getElementById("join-ion-consent-dialog"); + ok(dialog.open, "after clicking enrollment, consent dialog is open."); + + // When a modal dialog is cancelled, the inertness for other elements + // is reverted. However, in order to have the new state (non-inert) + // effective, Firefox needs to do a frame flush. This flush is taken + // place when it's really needed. + // getBoundingClientRect forces a frame flush here to ensure the + // following click is going to work properly. + enrollmentButton.getBoundingClientRect(); + enrollmentButton.click(); + ok(dialog.open, "after retrying enrollment, consent dialog is open."); + + const acceptDialogButton = content.document.getElementById( + "join-ion-accept-dialog-button" + ); + // Wait for the enrollment button to change its label to "leave", meaning + // that the policy was accepted. + let promiseDialogAccepted = BrowserTestUtils.waitForAttribute( + "data-l10n-id", + enrollmentButton + ); + acceptDialogButton.click(); + + const ionEnrolled = Services.prefs.getStringPref(PREF_ION_ID, null); + ok(ionEnrolled, "after enrollment, Ion pref is set."); + + await promiseDialogAccepted; + Assert.equal( + document.l10n.getAttributes(enrollmentButton).id, + "ion-unenrollment-button", + "After Ion enrollment, join button is now leave button" + ); + + const availableStudies = + content.document.getElementById("available-studies"); + Assert.equal( + document.l10n.getAttributes(availableStudies).id, + "ion-no-current-studies", + "No studies are available if default add-on install fails." + ); + } + ); +}); + +add_task(async function testAboutPage() { + const cachedContent = JSON.stringify(CACHED_CONTENT); + const cachedAddons = JSON.stringify(CACHED_ADDONS); + + // Clear any previously generated archived ping before moving on + // with this test. + await TelemetryStorage.runCleanPingArchiveTask(); + + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_TEST_CACHED_ADDONS, cachedAddons], + [PREF_TEST_CACHED_CONTENT, cachedContent], + [PREF_TEST_ADDONS, "[]"], + ], + clear: [ + [PREF_ION_ID, ""], + [PREF_ION_COMPLETED_STUDIES, "[]"], + ], + }); + + await BrowserTestUtils.withNewTab( + { + url: "about:ion", + gBrowser, + }, + async function taskFn(browser) { + const beforePref = Services.prefs.getStringPref(PREF_ION_ID, null); + Assert.strictEqual( + beforePref, + null, + "before enrollment, Ion pref is null." + ); + + const beforeToolbarButton = document.getElementById("ion-button"); + ok( + beforeToolbarButton.hidden, + "before enrollment, Ion toolbar button is hidden." + ); + + const enrollmentButton = + content.document.getElementById("enrollment-button"); + + for (const section of ["details", "data"]) { + Assert.strictEqual( + content.document.getElementById(section).open, + true, + "before enrollment, dialog sections are open." + ); + } + + enrollmentButton.click(); + + const dialog = content.document.getElementById("join-ion-consent-dialog"); + ok(dialog.open, "after clicking enrollment, consent dialog is open."); + + const cancelDialogButton = content.document.getElementById( + "join-ion-cancel-dialog-button" + ); + cancelDialogButton.click(); + + ok( + !dialog.open, + "after cancelling enrollment, consent dialog is closed." + ); + + const canceledEnrollment = Services.prefs.getStringPref( + PREF_ION_ID, + null + ); + + ok( + !canceledEnrollment, + "after cancelling enrollment, Ion is not enrolled." + ); + + // When a modal dialog is cancelled, the inertness for other elements + // is reverted. However, in order to have the new state (non-inert) + // effective, Firefox needs to do a frame flush. This flush is taken + // place when it's really needed. + // getBoundingClientRect forces a frame flush here to ensure the + // following click is going to work properly. + enrollmentButton.getBoundingClientRect(); + enrollmentButton.click(); + ok(dialog.open, "after retrying enrollment, consent dialog is open."); + + const acceptDialogButton = content.document.getElementById( + "join-ion-accept-dialog-button" + ); + // Wait for the enrollment button to change its label to "leave", meaning + // that the policy was accepted. + let promiseDialogAccepted = BrowserTestUtils.waitForAttribute( + "data-l10n-id", + enrollmentButton + ); + acceptDialogButton.click(); + + const ionEnrolled = Services.prefs.getStringPref(PREF_ION_ID, null); + ok(ionEnrolled, "after enrollment, Ion pref is set."); + + await promiseDialogAccepted; + Assert.equal( + document.l10n.getAttributes(enrollmentButton).id, + "ion-unenrollment-button", + "After Ion enrollment, join button is now leave button" + ); + + const enrolledToolbarButton = document.getElementById("ion-button"); + ok( + !enrolledToolbarButton.hidden, + "after enrollment, Ion toolbar button is not hidden." + ); + + for (const section of ["details", "data"]) { + Assert.strictEqual( + content.document.getElementById(section).open, + false, + "after enrollment, dialog sections are closed." + ); + } + + for (const cachedAddon of CACHED_ADDONS) { + const addonId = cachedAddon.addon_id; + const joinButton = content.document.getElementById( + `${addonId}-join-button` + ); + + if (cachedAddon.isDefault) { + ok(!joinButton, "There is no join button for default study."); + continue; + } + + const completedStudies = Services.prefs.getStringPref( + PREF_ION_COMPLETED_STUDIES, + "{}" + ); + + const studies = JSON.parse(completedStudies); + + if (cachedAddon.studyEnded || Object.keys(studies).includes(addonId)) { + ok( + joinButton.disabled, + "Join button is disabled, study has already ended." + ); + continue; + } + + ok( + !joinButton.disabled, + "Before study enrollment, join button is enabled." + ); + + const studyCancelButton = content.document.getElementById( + "join-study-cancel-dialog-button" + ); + + const joinDialogOpen = new Promise(resolve => { + content.document + .getElementById("join-study-consent-dialog") + .addEventListener("open", () => { + // Run resolve() on the next tick. + setTimeout(() => resolve(), 0); + }); + }); + + // When a modal dialog is cancelled, the inertness for other elements + // is reverted. However, in order to have the new state (non-inert) + // effective, Firefox needs to do a frame flush. This flush is taken + // place when it's really needed. + // getBoundingClientRect forces a frame flush here to ensure the + // following click is going to work properly. + // + // Note: this initial call is required because we're cycling through + // addons. So while in the first iteration this would work, it could + // fail on the second or third. + joinButton.getBoundingClientRect(); + joinButton.click(); + + await joinDialogOpen; + + Assert.equal( + content.document.getElementById("join-study-consent").innerHTML, + `${cachedAddon.joinStudyConsent}`, + "Join consent text matches remote settings data." + ); + + studyCancelButton.click(); + + ok( + !joinButton.disabled, + "After canceling study enrollment, join button is enabled." + ); + + // When a modal dialog is cancelled, the inertness for other elements + // is reverted. However, in order to have the new state (non-inert) + // effective, Firefox needs to do a frame flush. This flush is taken + // place when it's really needed. + // getBoundingClientRect forces a frame flush here to ensure the + // following click is going to work properly. + joinButton.getBoundingClientRect(); + joinButton.click(); + + const studyAcceptButton = content.document.getElementById( + "join-study-accept-dialog-button" + ); + + // Wait for the "Join Button" to change to a "leave button". + let promiseJoinTurnsToLeave = BrowserTestUtils.waitForAttribute( + "data-l10n-id", + joinButton + ); + studyAcceptButton.click(); + await promiseJoinTurnsToLeave; + + Assert.equal( + document.l10n.getAttributes(joinButton).id, + "ion-leave-study", + "After study enrollment, join button is now leave button" + ); + + ok( + !joinButton.disabled, + "After study enrollment, leave button is enabled." + ); + + const leaveStudyCancelButton = content.document.getElementById( + "leave-study-cancel-dialog-button" + ); + + const leaveDialogOpen = new Promise(resolve => { + content.document + .getElementById("leave-study-consent-dialog") + .addEventListener("open", () => { + // Run resolve() on the next tick. + setTimeout(() => resolve(), 0); + }); + }); + + // When a modal dialog is cancelled, the inertness for other elements + // is reverted. However, in order to have the new state (non-inert) + // effective, Firefox needs to do a frame flush. This flush is taken + // place when it's really needed. + // getBoundingClientRect forces a frame flush here to ensure the + // following click is going to work properly. + joinButton.getBoundingClientRect(); + joinButton.click(); + + await leaveDialogOpen; + + Assert.equal( + content.document.getElementById("leave-study-consent").innerHTML, + `${cachedAddon.leaveStudyConsent}`, + "Leave consent text matches remote settings data." + ); + + leaveStudyCancelButton.click(); + + ok( + !joinButton.disabled, + "After canceling study leave, leave/join button is enabled." + ); + + // When a modal dialog is cancelled, the inertness for other elements + // is reverted. However, in order to have the new state (non-inert) + // effective, Firefox needs to do a frame flush. This flush is taken + // place when it's really needed. + // getBoundingClientRect forces a frame flush here to ensure the + // following click is going to work properly. + joinButton.getBoundingClientRect(); + joinButton.click(); + + const acceptStudyCancelButton = content.document.getElementById( + "leave-study-accept-dialog-button" + ); + + let promiseJoinButtonDisabled = BrowserTestUtils.waitForAttribute( + "disabled", + joinButton + ); + acceptStudyCancelButton.click(); + await promiseJoinButtonDisabled; + + ok( + joinButton.disabled, + "After leaving study, join button is disabled." + ); + + Assert.equal( + Services.prefs.getStringPref(PREF_TEST_ADDONS, null), + "[]", + "Correct add-on was uninstalled" + ); + } + + enrollmentButton.click(); + + const cancelUnenrollmentDialogButton = content.document.getElementById( + "leave-ion-cancel-dialog-button" + ); + cancelUnenrollmentDialogButton.click(); + + const ionStillEnrolled = Services.prefs.getStringPref(PREF_ION_ID, null); + + ok( + ionStillEnrolled, + "after canceling unenrollment, Ion pref is still set." + ); + + enrollmentButton.click(); + + const acceptUnenrollmentDialogButton = content.document.getElementById( + "leave-ion-accept-dialog-button" + ); + + acceptUnenrollmentDialogButton.click(); + + // Wait for deletion ping, uninstalls, and UI updates... + const ionUnenrolled = await new Promise((resolve, reject) => { + Services.prefs.addObserver( + PREF_ION_ID, + function observer(subject, topic, data) { + try { + const prefValue = Services.prefs.getStringPref(PREF_ION_ID, null); + Services.prefs.removeObserver(PREF_ION_ID, observer); + resolve(prefValue); + } catch (ex) { + Services.prefs.removeObserver(PREF_ION_ID, observer); + reject(ex); + } + } + ); + }); + + ok(!ionUnenrolled, "after accepting unenrollment, Ion pref is null."); + + const unenrolledToolbarButton = document.getElementById("ion-button"); + ok( + unenrolledToolbarButton.hidden, + "after unenrollment, Ion toolbar button is hidden." + ); + + await TelemetryStorage.testClearPendingPings(); + let pings = await TelemetryArchive.promiseArchivedPingList(); + + let pingDetails = []; + for (const ping of pings) { + Assert.equal( + ping.type, + "pioneer-study", + "ping is of expected type pioneer-study" + ); + const details = await TelemetryArchive.promiseArchivedPingById(ping.id); + pingDetails.push(details.payload.studyName); + } + + Services.prefs.setStringPref(PREF_TEST_ADDONS, "[]"); + + for (const cachedAddon of CACHED_ADDONS) { + const addonId = cachedAddon.addon_id; + + ok( + pingDetails.includes(addonId), + "each test add-on has sent a deletion ping" + ); + + const joinButton = content.document.getElementById( + `${addonId}-join-button` + ); + + if (cachedAddon.isDefault) { + ok(!joinButton, "There is no join button for default study."); + } else { + ok( + joinButton.disabled, + "After unenrollment, join button is disabled." + ); + } + + for (const section of ["details", "data"]) { + Assert.strictEqual( + content.document.getElementById(section).open, + true, + "after unenrollment, dialog sections are open." + ); + } + } + } + ); +}); + +add_task(async function testEnrollmentPings() { + const CACHED_TEST_ADDON = CACHED_ADDONS[2]; + const cachedContent = JSON.stringify(CACHED_CONTENT); + const cachedAddons = JSON.stringify([CACHED_TEST_ADDON]); + + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_TEST_CACHED_ADDONS, cachedAddons], + [PREF_TEST_CACHED_CONTENT, cachedContent], + [PREF_TEST_ADDONS, "[]"], + ], + clear: [ + [PREF_ION_ID, ""], + [PREF_ION_COMPLETED_STUDIES, "[]"], + ], + }); + + // Clear any pending pings. + await TelemetryStorage.testClearPendingPings(); + + // Check how many archived pings we already have, so that we can count new pings. + let beginPingCount = (await TelemetryArchive.promiseArchivedPingList()) + .length; + + await BrowserTestUtils.withNewTab( + { + url: "about:ion", + gBrowser, + }, + async function taskFn(browser) { + const beforePref = Services.prefs.getStringPref(PREF_ION_ID, null); + Assert.strictEqual( + beforePref, + null, + "before enrollment, Ion pref is null." + ); + + // Enroll in ion. + const enrollmentButton = + content.document.getElementById("enrollment-button"); + + let promiseDialogAccepted = BrowserTestUtils.waitForAttribute( + "data-l10n-id", + enrollmentButton + ); + + // When a modal dialog is cancelled, the inertness for other elements + // is reverted. However, in order to have the new state (non-inert) + // effective, Firefox needs to do a frame flush. This flush is taken + // place when it's really needed. + // getBoundingClientRect forces a frame flush here to ensure the + // following click is going to work properly. + enrollmentButton.getBoundingClientRect(); + enrollmentButton.click(); + + const acceptDialogButton = content.document.getElementById( + "join-ion-accept-dialog-button" + ); + acceptDialogButton.click(); + + const ionId = Services.prefs.getStringPref(PREF_ION_ID, null); + ok(ionId, "after enrollment, Ion pref is set."); + + await promiseDialogAccepted; + + // Enroll in the CACHED_TEST_ADDON study. + const joinButton = content.document.getElementById( + `${CACHED_TEST_ADDON.addon_id}-join-button` + ); + + const joinDialogOpen = new Promise(resolve => { + content.document + .getElementById("join-study-consent-dialog") + .addEventListener("open", () => { + resolve(); + }); + }); + + // When a modal dialog is cancelled, the inertness for other elements + // is reverted. However, in order to have the new state (non-inert) + // effective, Firefox needs to do a frame flush. This flush is taken + // place when it's really needed. + // getBoundingClientRect forces a frame flush here to ensure the + // following click is going to work properly. + joinButton.getBoundingClientRect(); + joinButton.click(); + + await joinDialogOpen; + + // Accept consent for the study. + const studyAcceptButton = content.document.getElementById( + "join-study-accept-dialog-button" + ); + + studyAcceptButton.click(); + + // Verify that the proper pings were generated. + let pings; + await TestUtils.waitForCondition(async function () { + pings = await TelemetryArchive.promiseArchivedPingList(); + return pings.length - beginPingCount >= 2; + }, "Wait until we have at least 2 pings in the telemetry archive"); + + let pingDetails = []; + for (const ping of pings) { + Assert.equal( + ping.type, + "pioneer-study", + "ping is of expected type pioneer-study" + ); + const details = await TelemetryArchive.promiseArchivedPingById(ping.id); + pingDetails.push({ + schemaName: details.payload.schemaName, + schemaNamespace: details.payload.schemaNamespace, + studyName: details.payload.studyName, + pioneerId: details.payload.pioneerId, + }); + } + + // We expect 1 ping with just the ion id (ion consent) and another + // with both the ion id and the study id (study consent). + ok( + pingDetails.find( + p => + p.schemaName == "pioneer-enrollment" && + p.schemaNamespace == "pioneer-meta" && + p.pioneerId == ionId && + p.studyName == "pioneer-meta" + ), + "We expect the Ion program consent to be present" + ); + + ok( + pingDetails.find( + p => + p.schemaName == "pioneer-enrollment" && + p.schemaNamespace == CACHED_TEST_ADDON.addon_id && + p.pioneerId == ionId && + p.studyName == CACHED_TEST_ADDON.addon_id + ), + "We expect the study consent to be present" + ); + } + ); +}); + +add_task(async function testIonBadge() { + await SpecialPowers.pushPrefEnv({ + set: [[PREF_ION_NEW_STUDIES_AVAILABLE, true]], + clear: [ + [PREF_ION_NEW_STUDIES_AVAILABLE, false], + [PREF_ION_ID, ""], + ], + }); + + let ionTab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:ion", + gBrowser, + }); + + const enrollmentButton = content.document.getElementById("enrollment-button"); + enrollmentButton.click(); + + let blankTab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:home", + gBrowser, + }); + + Services.prefs.setBoolPref(PREF_ION_NEW_STUDIES_AVAILABLE, true); + + const toolbarButton = document.getElementById("ion-button"); + const toolbarBadge = toolbarButton.querySelector(".toolbarbutton-badge"); + + ok( + toolbarBadge.classList.contains("feature-callout"), + "When pref is true, Ion toolbar button is called out in the current window." + ); + + toolbarButton.click(); + + await ionTab; + + ok( + !toolbarBadge.classList.contains("feature-callout"), + "When about:ion toolbar button is pressed, call-out is removed." + ); + + Services.prefs.setBoolPref(PREF_ION_NEW_STUDIES_AVAILABLE, true); + + const newWin = await BrowserTestUtils.openNewBrowserWindow(); + const newToolbarBadge = toolbarButton.querySelector(".toolbarbutton-badge"); + + ok( + newToolbarBadge.classList.contains("feature-callout"), + "When pref is true, Ion toolbar button is called out in a new window." + ); + + await BrowserTestUtils.closeWindow(newWin); + await BrowserTestUtils.removeTab(ionTab); + await BrowserTestUtils.removeTab(blankTab); +}); + +add_task(async function testContentReplacement() { + const cachedContent = JSON.stringify(CACHED_CONTENT); + + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_TEST_CACHED_CONTENT, cachedContent], + [PREF_TEST_ADDONS, "[]"], + ], + clear: [[PREF_ION_ID, ""]], + }); + + await BrowserTestUtils.withNewTab( + { + url: "about:ion", + gBrowser, + }, + async function taskFn(browser) { + // Check that text was updated from Remote Settings. + console.log("debug:", content.document.getElementById("title").innerHTML); + Assert.equal( + content.document.getElementById("title").innerHTML, + "test title

    test title line 2

    ", + "Title was replaced correctly." + ); + Assert.equal( + content.document.getElementById("summary").innerHTML, + "

    test summary

    test summary line 2", + "Summary was replaced correctly." + ); + Assert.equal( + content.document.getElementById("details").innerHTML, + "
    1. test details
    2. test details line 2
    3. test details line
    ", + "Details was replaced correctly." + ); + Assert.equal( + content.document.getElementById("data").innerHTML, + "test data", + "Data was replaced correctly." + ); + Assert.equal( + content.document.getElementById("join-ion-consent").innerHTML, + "

    test join consent

    join consent line 2

    ", + "Join consent was replaced correctly." + ); + Assert.equal( + content.document.getElementById("leave-ion-consent").innerHTML, + "

    test leave consent

    test leave consent line 2

    ", + "Leave consent was replaced correctly." + ); + } + ); +}); + +add_task(async function testBadContentReplacement() { + const cachedContent = JSON.stringify([ + { + joinIonConsent: "", + leaveIonConsent: `blob`, + }, + ]); + + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_TEST_CACHED_CONTENT, cachedContent], + [PREF_TEST_ADDONS, "[]"], + ], + clear: [[PREF_ION_ID, ""]], + }); + + await BrowserTestUtils.withNewTab( + { + url: "about:ion", + gBrowser, + }, + async function taskFn(browser) { + // Check that text was updated from Remote Settings. + Assert.equal( + content.document.getElementById("join-ion-consent").innerHTML, + "", + "Script tags are skipped." + ); + Assert.equal( + content.document.getElementById("leave-ion-consent").innerHTML, + "blob", + "Bad HREFs are stripped." + ); + } + ); +}); + +add_task(async function testLocaleGating() { + const cachedContent = JSON.stringify(CACHED_CONTENT); + const cachedAddons = JSON.stringify(CACHED_ADDONS); + + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_TEST_CACHED_ADDONS, cachedAddons], + [PREF_TEST_CACHED_CONTENT, cachedContent], + [PREF_TEST_ADDONS, "[]"], + ], + clear: [ + [PREF_ION_ID, ""], + [PREF_ION_COMPLETED_STUDIES, "[]"], + ], + }); + + await setupLocale("de"); + + await BrowserTestUtils.withNewTab( + { + url: "about:ion", + gBrowser, + }, + async function taskFn(browser) { + const localeNotificationBar = content.document.getElementById( + "locale-notification" + ); + + is( + Services.locale.requestedLocales[0], + "de", + "The requestedLocales has been set to German ('de')." + ); + + is( + getComputedStyle(localeNotificationBar).display, + "block", + "Because the page locale is German, the notification bar is not hidden." + ); + } + ); + + await clearLocale(); + + await BrowserTestUtils.withNewTab( + { + url: "about:ion", + gBrowser, + }, + async function taskFn(browser) { + const localeNotificationBar = content.document.getElementById( + "locale-notification" + ); + + is( + Services.locale.requestedLocales[0], + "en-US", + "The requestedLocales has been set to English ('en-US')." + ); + + is( + getComputedStyle(localeNotificationBar).display, + "none", + "Because the page locale is en-US, the notification bar is hidden." + ); + } + ); +}); diff --git a/browser/components/messagepreview/actors/AboutMessagePreviewChild.sys.mjs b/browser/components/messagepreview/actors/AboutMessagePreviewChild.sys.mjs new file mode 100644 index 0000000000..15c100328b --- /dev/null +++ b/browser/components/messagepreview/actors/AboutMessagePreviewChild.sys.mjs @@ -0,0 +1,58 @@ +/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +export class AboutMessagePreviewChild extends JSWindowActorChild { + handleEvent(event) { + console.log(`Received page event ${event.type}`); + } + + actorCreated() { + this.exportFunctions(); + } + + exportFunctions() { + if (this.contentWindow) { + for (const name of ["MPShowMessage", "MPIsEnabled", "MPShouldShowHint"]) { + Cu.exportFunction(this[name].bind(this), this.contentWindow, { + defineAs: name, + }); + } + } + } + + /** + * Check if the Message Preview feature is enabled. This reflects the value of + * the pref `browser.newtabpage.activity-stream.asrouter.devtoolsEnabled`. + * + * @returns {boolean} + */ + MPIsEnabled() { + return Services.prefs.getBoolPref( + "browser.newtabpage.activity-stream.asrouter.devtoolsEnabled", + false + ); + } + + /** + * Route a message to the parent process to be displayed with the relevant + * messaging surface. + * + * @param {object} message + */ + MPShowMessage(message) { + this.sendAsyncMessage(`MessagePreview:SHOW_MESSAGE`, message); + } + + /** + * Check if a hint should be shown about how to enable Message Preview. The + * hint is only displayed in local/unofficial builds. + * + * @returns {boolean} + */ + MPShouldShowHint() { + return !this.MPIsEnabled() && !AppConstants.MOZILLA_OFFICIAL; + } +} diff --git a/browser/components/messagepreview/actors/AboutMessagePreviewParent.sys.mjs b/browser/components/messagepreview/actors/AboutMessagePreviewParent.sys.mjs new file mode 100644 index 0000000000..d19eb4329f --- /dev/null +++ b/browser/components/messagepreview/actors/AboutMessagePreviewParent.sys.mjs @@ -0,0 +1,107 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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 { JsonSchema } from "resource://gre/modules/JsonSchema.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CFRPageActions: "resource:///modules/asrouter/CFRPageActions.sys.mjs", + FeatureCalloutBroker: + "resource:///modules/asrouter/FeatureCalloutBroker.sys.mjs", + InfoBar: "resource:///modules/asrouter/InfoBar.sys.mjs", + SpecialMessageActions: + "resource://messaging-system/lib/SpecialMessageActions.sys.mjs", + Spotlight: "resource:///modules/asrouter/Spotlight.sys.mjs", +}); + +function dispatchCFRAction({ type, data }, browser) { + if (type === "USER_ACTION") { + lazy.SpecialMessageActions.handleAction(data, browser); + } +} + +export class AboutMessagePreviewParent extends JSWindowActorParent { + showInfoBar(message, browser) { + lazy.InfoBar.showInfoBarMessage(browser, message, dispatchCFRAction); + } + + showSpotlight(message, browser) { + lazy.Spotlight.showSpotlightDialog(browser, message, () => {}); + } + + showCFR(message, browser) { + lazy.CFRPageActions.forceRecommendation( + browser, + message, + dispatchCFRAction + ); + } + + showFeatureCallout(message, browser) { + switch (message.trigger.id) { + case "featureCalloutCheck": + // For messagePreview, force the trigger to be something we can show + message.trigger.id = "nthTabClosed"; + lazy.FeatureCalloutBroker.showFeatureCallout(browser, message); + break; + default: + lazy.FeatureCalloutBroker.showFeatureCallout(browser, message); + } + } + + async showMessage(data) { + let message; + try { + message = JSON.parse(data); + } catch (e) { + console.error("Could not parse message", e); + return; + } + + const schema = await fetch( + "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json", + { credentials: "omit" } + ).then(rsp => rsp.json()); + + const result = JsonSchema.validate(message, schema); + if (!result.valid) { + console.error( + `Invalid message: ${JSON.stringify(result.errors, undefined, 2)}` + ); + } + + const browser = + this.browsingContext.topChromeWindow.gBrowser.selectedBrowser; + switch (message.template) { + case "infobar": + this.showInfoBar(message, browser); + return; + case "spotlight": + this.showSpotlight(message, browser); + return; + case "cfr_doorhanger": + this.showCFR(message, browser); + return; + case "feature_callout": + this.showFeatureCallout(message, browser); + return; + default: + console.error(`Unsupported message template ${message.template}`); + } + } + + receiveMessage(message) { + const { name, data } = message; + + switch (name) { + case "MessagePreview:SHOW_MESSAGE": + this.showMessage(data); + break; + default: + console.log(`Unexpected event ${name} was not handled.`); + } + } +} diff --git a/browser/components/messagepreview/jar.mn b/browser/components/messagepreview/jar.mn new file mode 100644 index 0000000000..110476d794 --- /dev/null +++ b/browser/components/messagepreview/jar.mn @@ -0,0 +1,9 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +browser.jar: + content/browser/messagepreview/messagepreview.html + content/browser/messagepreview/messagepreview.js + content/browser/messagepreview/limelight.svg + content/browser/messagepreview/messagepreview.css diff --git a/browser/components/messagepreview/limelight.svg b/browser/components/messagepreview/limelight.svg new file mode 100644 index 0000000000..938a17b3b2 --- /dev/null +++ b/browser/components/messagepreview/limelight.svg @@ -0,0 +1,4 @@ + + diff --git a/browser/components/messagepreview/messagepreview.css b/browser/components/messagepreview/messagepreview.css new file mode 100644 index 0000000000..f81c2d2623 --- /dev/null +++ b/browser/components/messagepreview/messagepreview.css @@ -0,0 +1,33 @@ +/* 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 { + position: fixed; +} + +html, +body { + height: 100%; + width: 100%; +} + +body { + background: url(chrome://browser/content/messagepreview/limelight.svg) + center/contain no-repeat; + -moz-context-properties: fill, fill-opacity; + fill: var(--in-content-icon-color); + fill-opacity: 0.2; +} + +.hint-box { + display: flex; + align-items: center; + justify-content: center; +} + +.hint { + max-width: 40em; + font-size: 1.2em; + text-align: center; +} diff --git a/browser/components/messagepreview/messagepreview.html b/browser/components/messagepreview/messagepreview.html new file mode 100644 index 0000000000..57169d3403 --- /dev/null +++ b/browser/components/messagepreview/messagepreview.html @@ -0,0 +1,29 @@ + + + + + + + + about:messagepreview + + + + + + + + diff --git a/browser/components/messagepreview/messagepreview.js b/browser/components/messagepreview/messagepreview.js new file mode 100644 index 0000000000..48e5fb1ff5 --- /dev/null +++ b/browser/components/messagepreview/messagepreview.js @@ -0,0 +1,39 @@ +/* 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/. */ + +/* global MPShowMessage, MPIsEnabled, MPShouldShowHint */ + +"use strict"; + +function decodeMessageFromUrl() { + const url = new URL(document.location.href); + + if (url.searchParams.has("json")) { + const encodedMessage = url.searchParams.get("json"); + + return atob(encodedMessage); + } + return null; +} + +function showHint() { + document.body.classList.add("hint-box"); + document.body.innerHTML = `
    Message preview is not enabled. Enable it in about:config by setting browser.newtabpage.activity-stream.asrouter.devtoolsEnabled to true.
    `; +} + +const message = decodeMessageFromUrl(); + +if (message) { + // If message preview is enabled, show the message. + if (MPIsEnabled()) { + MPShowMessage(message); + } else if (MPShouldShowHint()) { + // If running in a local build, show a hint about how to enable preview. + if (document.body) { + showHint(); + } else { + document.addEventListener("DOMContentLoaded", showHint, { once: true }); + } + } +} diff --git a/browser/components/messagepreview/moz.build b/browser/components/messagepreview/moz.build new file mode 100644 index 0000000000..5b528d73f7 --- /dev/null +++ b/browser/components/messagepreview/moz.build @@ -0,0 +1,17 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +JAR_MANIFESTS += ["jar.mn"] + +FINAL_LIBRARY = "browsercomps" + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Messaging System") + +FINAL_TARGET_FILES.actors += [ + "actors/AboutMessagePreviewChild.sys.mjs", + "actors/AboutMessagePreviewParent.sys.mjs", +] diff --git a/browser/components/metrics.yaml b/browser/components/metrics.yaml new file mode 100644 index 0000000000..81b12dc89c --- /dev/null +++ b/browser/components/metrics.yaml @@ -0,0 +1,80 @@ +# 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/. + +# Adding a new metric? We have docs for that! +# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html + +--- +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 +$tags: + - 'Firefox :: General' + +browser.launched_to_handle: + system_notification: + type: event + description: > + Recorded when Firefox launches to complete a native notification popped by + a system (chrome privileged) alert. Windows-only at the time of writing. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1788960 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1788960#c10 + data_sensitivity: + - interaction + notification_emails: + - nalexander@mozilla.com + - rtestard@mozilla.com + expires: never + extra_keys: + name: + description: > + The `name` of the system (chrome privileged) alert that Firefox was + launched to complete. + type: string + action: + description: > + The `action` of the system (chrome privileged) alert that Firefox was + launched to complete. + type: string + telemetry_mirror: BrowserLaunched_to_handle_SystemNotification_Toast + +background_update: + reasons_to_not_update: + type: string_list + description: > + Records which error was causing the background updater to fail. + This list supercedes the `background-update.reason` in + `mozapps/update/metrics.yaml` + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1795471 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1795471 + data_sensitivity: + - technical + notification_emails: + - install-update@mozilla.com + expires: never + send_in_pings: + - background-update + - metrics + lifetime: application + + time_last_update_scheduled: + type: datetime + time_unit: day + description: > + Last time the background update was triggered. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1795471 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1795471 + data_sensitivity: + - interaction + notification_emails: + - install-update@mozilla.com + expires: never + send_in_pings: + - background-update + - metrics + lifetime: application diff --git a/browser/components/migration/.eslintrc.js b/browser/components/migration/.eslintrc.js new file mode 100644 index 0000000000..34d8ceec2d --- /dev/null +++ b/browser/components/migration/.eslintrc.js @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.exports = { + extends: ["plugin:mozilla/require-jsdoc"], + rules: { + "block-scoped-var": "error", + complexity: ["error", { max: 22 }], + "max-nested-callbacks": ["error", 3], + "no-extend-native": "error", + "no-multi-str": "error", + "no-return-assign": "error", + "no-shadow": "error", + "no-unused-vars": ["error", { args: "after-used", vars: "all" }], + strict: ["error", "global"], + yoda: "error", + }, + + overrides: [ + { + files: ["head*.js"], + rules: { + "no-unused-vars": [ + "error", + { + args: "none", + vars: "local", + }, + ], + }, + }, + ], +}; diff --git a/browser/components/migration/360seMigrationUtils.sys.mjs b/browser/components/migration/360seMigrationUtils.sys.mjs new file mode 100644 index 0000000000..61d227a830 --- /dev/null +++ b/browser/components/migration/360seMigrationUtils.sys.mjs @@ -0,0 +1,190 @@ +/* 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 { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", +}); + +ChromeUtils.defineLazyGetter( + lazy, + "filenamesRegex", + () => /^360(?:default_ori|sefav)_([0-9_]+)\.favdb$/i +); + +const kBookmarksFileName = "360sefav.dat"; + +function Bookmarks(aProfileFolder) { + let file = aProfileFolder.clone(); + file.append(kBookmarksFileName); + + this._file = file; +} +Bookmarks.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + get exists() { + return this._file.exists() && this._file.isReadable(); + }, + + migrate(aCallback) { + return (async () => { + let folderMap = new Map(); + let toolbarBMs = []; + + let connection = await lazy.Sqlite.openConnection({ + path: this._file.path, + }); + + try { + let rows = await connection.execute( + `WITH RECURSIVE + bookmark(id, parent_id, is_folder, title, url, pos) AS ( + VALUES(0, -1, 1, '', '', 0) + UNION + SELECT f.id, f.parent_id, f.is_folder, f.title, f.url, f.pos + FROM tb_fav AS f + JOIN bookmark AS b ON f.parent_id = b.id + ORDER BY f.pos ASC + ) + SELECT id, parent_id, is_folder, title, url FROM bookmark WHERE id` + ); + + for (let row of rows) { + let id = parseInt(row.getResultByName("id"), 10); + let parent_id = parseInt(row.getResultByName("parent_id"), 10); + let is_folder = parseInt(row.getResultByName("is_folder"), 10); + let title = row.getResultByName("title"); + let url = row.getResultByName("url"); + + let bmToInsert; + + if (is_folder) { + bmToInsert = { + children: [], + title, + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + }; + folderMap.set(id, bmToInsert); + } else { + try { + new URL(url); + } catch (ex) { + console.error( + `Ignoring ${url} when importing from 360se because of exception:`, + ex + ); + continue; + } + + bmToInsert = { + title, + url, + }; + } + + if (folderMap.has(parent_id)) { + folderMap.get(parent_id).children.push(bmToInsert); + } else if (parent_id === 0) { + toolbarBMs.push(bmToInsert); + } + } + } finally { + await connection.close(); + } + + if (toolbarBMs.length) { + let parentGuid = lazy.PlacesUtils.bookmarks.toolbarGuid; + await MigrationUtils.insertManyBookmarksWrapper(toolbarBMs, parentGuid); + } + })().then( + () => aCallback(true), + e => { + console.error(e); + aCallback(false); + } + ); + }, +}; + +export var Qihoo360seMigrationUtils = { + async getAlternativeBookmarks({ bookmarksPath, localState }) { + let lastModificationDate = new Date(0); + let path = bookmarksPath; + let profileFolder = PathUtils.parent(bookmarksPath); + + if (await IOUtils.exists(bookmarksPath)) { + try { + let { lastModified } = await IOUtils.stat(bookmarksPath); + lastModificationDate = new Date(lastModified); + } catch (ex) { + console.error(ex); + } + } + + // Somewhat similar to source profiles, but for bookmarks only + let subDir = + (localState.sync_login_info && localState.sync_login_info.filepath) || ""; + + if (subDir) { + let legacyBookmarksPath = PathUtils.join( + profileFolder, + subDir, + kBookmarksFileName + ); + if (await IOUtils.exists(legacyBookmarksPath)) { + try { + let { lastModified } = await IOUtils.stat(legacyBookmarksPath); + lastModificationDate = new Date(lastModified); + path = legacyBookmarksPath; + } catch (ex) { + console.error(ex); + } + } + } + + let dailyBackupPath = PathUtils.join(profileFolder, subDir, "DailyBackup"); + for (const entry of await IOUtils.getChildren(dailyBackupPath, { + ignoreAbsent: true, + })) { + let filename = PathUtils.filename(entry); + let matches = lazy.filenamesRegex.exec(filename); + if (!matches) { + continue; + } + + let entryDate = new Date(matches[1].replace(/_/g, "-")); + if (entryDate < lastModificationDate) { + continue; + } + + lastModificationDate = entryDate; + path = entry; + } + + if (PathUtils.filename(path) === kBookmarksFileName) { + let resource = this.getLegacyBookmarksResource(PathUtils.parent(path)); + return { resource }; + } + return { path }; + }, + + getLegacyBookmarksResource(aParentFolder) { + let parentFolder; + try { + parentFolder = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + parentFolder.initWithPath(aParentFolder); + } catch (ex) { + console.error(ex); + return null; + } + + let bookmarks = new Bookmarks(parentFolder); + return bookmarks.exists ? bookmarks : null; + }, +}; diff --git a/browser/components/migration/ChromeMacOSLoginCrypto.sys.mjs b/browser/components/migration/ChromeMacOSLoginCrypto.sys.mjs new file mode 100644 index 0000000000..595bbc28c4 --- /dev/null +++ b/browser/components/migration/ChromeMacOSLoginCrypto.sys.mjs @@ -0,0 +1,185 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Class to handle encryption and decryption of logins stored in Chrome/Chromium + * on macOS. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gKeychainUtils", + "@mozilla.org/profile/migrator/keychainmigrationutils;1", + "nsIKeychainMigrationUtils" +); + +const gTextEncoder = new TextEncoder(); +const gTextDecoder = new TextDecoder(); + +/** + * From macOS' CommonCrypto/CommonCryptor.h + */ +const kCCBlockSizeAES128 = 16; + +/* Chromium constants */ + +/** + * kSalt from Chromium. + * + * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=43&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0 + */ +const SALT = "saltysalt"; + +/** + * kDerivedKeySizeInBits from Chromium. + * + * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=46&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0 + */ +const DERIVED_KEY_SIZE_BITS = 128; + +/** + * kEncryptionIterations from Chromium. + * + * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=49&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0 + */ +const ITERATIONS = 1003; + +/** + * kEncryptionVersionPrefix from Chromium. + * + * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=61&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0 + */ +const ENCRYPTION_VERSION_PREFIX = "v10"; + +/** + * The initialization vector is 16 space characters (character code 32 in decimal). + * + * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=220&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0 + */ +const IV = new Uint8Array(kCCBlockSizeAES128).fill(32); + +/** + * Instances of this class have a shape similar to OSCrypto so it can be dropped + * into code which uses that. This isn't implemented as OSCrypto_mac.js since + * it isn't calling into encryption functions provided by macOS but instead + * relies on OS encryption key storage in Keychain. The algorithms here are + * specific to what is needed for Chrome login storage on macOS. + */ +export class ChromeMacOSLoginCrypto { + /** + * @param {string} serviceName of the Keychain Item to use to derive a key. + * @param {string} accountName of the Keychain Item to use to derive a key. + * @param {string?} [testingPassphrase = null] A string to use as the passphrase + * to derive a key for testing purposes rather than retrieving + * it from the macOS Keychain since we don't yet have a way to + * mock the Keychain auth dialog. + */ + constructor(serviceName, accountName, testingPassphrase = null) { + // We still exercise the keychain migration utils code when using a + // `testingPassphrase` in order to get some test coverage for that + // component, even though it's expected to throw since a login item with the + // service name and account name usually won't be found. + let encKey = testingPassphrase; + try { + encKey = lazy.gKeychainUtils.getGenericPassword(serviceName, accountName); + } catch (ex) { + if (!testingPassphrase) { + throw ex; + } + } + + this.ALGORITHM = "AES-CBC"; + + this._keyPromise = crypto.subtle + .importKey("raw", gTextEncoder.encode(encKey), "PBKDF2", false, [ + "deriveKey", + ]) + .then(key => { + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: gTextEncoder.encode(SALT), + iterations: ITERATIONS, + hash: "SHA-1", + }, + key, + { name: this.ALGORITHM, length: DERIVED_KEY_SIZE_BITS }, + false, + ["decrypt", "encrypt"] + ); + }) + .catch(console.error); + } + + /** + * Convert an array containing only two bytes unsigned numbers to a string. + * + * @param {number[]} arr - the array that needs to be converted. + * @returns {string} the string representation of the array. + */ + arrayToString(arr) { + let str = ""; + for (let i = 0; i < arr.length; i++) { + str += String.fromCharCode(arr[i]); + } + return str; + } + + stringToArray(binary_string) { + let len = binary_string.length; + let bytes = new Uint8Array(len); + for (var i = 0; i < len; i++) { + bytes[i] = binary_string.charCodeAt(i); + } + return bytes; + } + + /** + * @param {Array} ciphertextArray ciphertext prefixed by the encryption version + * (see ENCRYPTION_VERSION_PREFIX). + * @returns {string} plaintext password + */ + async decryptData(ciphertextArray) { + let ciphertext = this.arrayToString(ciphertextArray); + if (!ciphertext.startsWith(ENCRYPTION_VERSION_PREFIX)) { + throw new Error("Unknown encryption version"); + } + let key = await this._keyPromise; + if (!key) { + throw new Error("Cannot decrypt without a key"); + } + let plaintext = await crypto.subtle.decrypt( + { name: this.ALGORITHM, iv: IV }, + key, + this.stringToArray(ciphertext.substring(ENCRYPTION_VERSION_PREFIX.length)) + ); + return gTextDecoder.decode(plaintext); + } + + /** + * @param {USVString} plaintext to encrypt + * @returns {string} encrypted string consisting of UTF-16 code units prefixed + * by the ENCRYPTION_VERSION_PREFIX. + */ + async encryptData(plaintext) { + let key = await this._keyPromise; + if (!key) { + throw new Error("Cannot encrypt without a key"); + } + + let ciphertext = await crypto.subtle.encrypt( + { name: this.ALGORITHM, iv: IV }, + key, + gTextEncoder.encode(plaintext) + ); + return ( + ENCRYPTION_VERSION_PREFIX + + String.fromCharCode(...new Uint8Array(ciphertext)) + ); + } +} diff --git a/browser/components/migration/ChromeMigrationUtils.sys.mjs b/browser/components/migration/ChromeMigrationUtils.sys.mjs new file mode 100644 index 0000000000..88ae3addfc --- /dev/null +++ b/browser/components/migration/ChromeMigrationUtils.sys.mjs @@ -0,0 +1,499 @@ +/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", +}); + +const S100NS_FROM1601TO1970 = 0x19db1ded53e8000; +const S100NS_PER_MS = 10; + +export var ChromeMigrationUtils = { + // Supported browsers with importable logins. + CONTEXTUAL_LOGIN_IMPORT_BROWSERS: ["chrome", "chromium-edge", "chromium"], + + _extensionVersionDirectoryNames: {}, + + // The cache for the locale strings. + // For example, the data could be: + // { + // "profile-id-1": { + // "extension-id-1": { + // "name": { + // "message": "Fake App 1" + // } + // }, + // } + _extensionLocaleStrings: {}, + + get supportsLoginsForPlatform() { + return ["macosx", "win"].includes(AppConstants.platform); + }, + + /** + * Get all extensions installed in a specific profile. + * + * @param {string} profileId - A Chrome user profile ID. For example, "Profile 1". + * @returns {Array} All installed Chrome extensions information. + */ + async getExtensionList(profileId) { + if (profileId === undefined) { + profileId = await this.getLastUsedProfileId(); + } + let path = await this.getExtensionPath(profileId); + let extensionList = []; + try { + for (const child of await IOUtils.getChildren(path)) { + const info = await IOUtils.stat(child); + if (info.type === "directory") { + const name = PathUtils.filename(child); + let extensionInformation = await this.getExtensionInformation( + name, + profileId + ); + if (extensionInformation) { + extensionList.push(extensionInformation); + } + } + } + } catch (ex) { + console.error(ex); + } + return extensionList; + }, + + /** + * Get information of a specific Chrome extension. + * + * @param {string} extensionId - The extension ID. + * @param {string} profileId - The user profile's ID. + * @returns {object} The Chrome extension information. + */ + async getExtensionInformation(extensionId, profileId) { + if (profileId === undefined) { + profileId = await this.getLastUsedProfileId(); + } + let extensionInformation = null; + try { + let manifestPath = await this.getExtensionPath(profileId); + manifestPath = PathUtils.join(manifestPath, extensionId); + // If there are multiple sub-directories in the extension directory, + // read the files in the latest directory. + let directories = await this._getSortedByVersionSubDirectoryNames( + manifestPath + ); + if (!directories[0]) { + return null; + } + + manifestPath = PathUtils.join( + manifestPath, + directories[0], + "manifest.json" + ); + let manifest = await IOUtils.readJSON(manifestPath); + // No app attribute means this is a Chrome extension not a Chrome app. + if (!manifest.app) { + const DEFAULT_LOCALE = manifest.default_locale; + let name = await this._getLocaleString( + manifest.name, + DEFAULT_LOCALE, + extensionId, + profileId + ); + let description = await this._getLocaleString( + manifest.description, + DEFAULT_LOCALE, + extensionId, + profileId + ); + if (name) { + extensionInformation = { + id: extensionId, + name, + description, + }; + } else { + throw new Error("Cannot read the Chrome extension's name property."); + } + } + } catch (ex) { + console.error(ex); + } + return extensionInformation; + }, + + /** + * Get the manifest's locale string. + * + * @param {string} key - The key of a locale string, for example __MSG_name__. + * @param {string} locale - The specific language of locale string. + * @param {string} extensionId - The extension ID. + * @param {string} profileId - The user profile's ID. + * @returns {string|null} The locale string. + */ + async _getLocaleString(key, locale, extensionId, profileId) { + if (typeof key !== "string") { + console.debug("invalid manifest key"); + return null; + } + // Return the key string if it is not a locale key. + // The key string starts with "__MSG_" and ends with "__". + // For example, "__MSG_name__". + // https://developer.chrome.com/apps/i18n + if (!key.startsWith("__MSG_") || !key.endsWith("__")) { + return key; + } + + let localeString = null; + try { + let localeFile; + if ( + this._extensionLocaleStrings[profileId] && + this._extensionLocaleStrings[profileId][extensionId] + ) { + localeFile = this._extensionLocaleStrings[profileId][extensionId]; + } else { + if (!this._extensionLocaleStrings[profileId]) { + this._extensionLocaleStrings[profileId] = {}; + } + let localeFilePath = await this.getExtensionPath(profileId); + localeFilePath = PathUtils.join(localeFilePath, extensionId); + let directories = await this._getSortedByVersionSubDirectoryNames( + localeFilePath + ); + // If there are multiple sub-directories in the extension directory, + // read the files in the latest directory. + localeFilePath = PathUtils.join( + localeFilePath, + directories[0], + "_locales", + locale, + "messages.json" + ); + localeFile = await IOUtils.readJSON(localeFilePath); + this._extensionLocaleStrings[profileId][extensionId] = localeFile; + } + const PREFIX_LENGTH = 6; + const SUFFIX_LENGTH = 2; + // Get the locale key from the string with locale prefix and suffix. + // For example, it will get the "name" sub-string from the "__MSG_name__" string. + key = key.substring(PREFIX_LENGTH, key.length - SUFFIX_LENGTH); + if (localeFile[key] && localeFile[key].message) { + localeString = localeFile[key].message; + } + } catch (ex) { + console.error(ex); + } + return localeString; + }, + + /** + * Check that a specific extension is installed or not. + * + * @param {string} extensionId - The extension ID. + * @param {string} profileId - The user profile's ID. + * @returns {boolean} Return true if the extension is installed otherwise return false. + */ + async isExtensionInstalled(extensionId, profileId) { + if (profileId === undefined) { + profileId = await this.getLastUsedProfileId(); + } + let extensionPath = await this.getExtensionPath(profileId); + let isInstalled = await IOUtils.exists( + PathUtils.join(extensionPath, extensionId) + ); + return isInstalled; + }, + + /** + * Get the last used user profile's ID. + * + * @returns {string} The last used user profile's ID. + */ + async getLastUsedProfileId() { + let localState = await this.getLocalState(); + return localState ? localState.profile.last_used : "Default"; + }, + + /** + * Get the local state file content. + * + * @param {string} chromeProjectName + * The type of Chrome data we're looking for (Chromium, Canary, etc.) + * @param {string} [dataPath=undefined] + * The data path that should be used as the parent directory when getting + * the local state. If not supplied, the data path is calculated using + * getDataPath and the chromeProjectName. + * @returns {object} The JSON-based content. + */ + async getLocalState(chromeProjectName = "Chrome", dataPath) { + let localState = null; + try { + if (!dataPath) { + dataPath = await this.getDataPath(chromeProjectName); + } + let localStatePath = PathUtils.join(dataPath, "Local State"); + localState = JSON.parse(await IOUtils.readUTF8(localStatePath)); + } catch (ex) { + // Don't report the error if it's just a file not existing. + if (ex.name != "NotFoundError") { + console.error(ex); + } + throw ex; + } + return localState; + }, + + /** + * Get the path of Chrome extension directory. + * + * @param {string} profileId - The user profile's ID. + * @returns {string} The path of Chrome extension directory. + */ + async getExtensionPath(profileId) { + return PathUtils.join(await this.getDataPath(), profileId, "Extensions"); + }, + + /** + * Get the path of an application data directory. + * + * @param {string} chromeProjectName - The Chrome project name, e.g. "Chrome", "Canary", etc. + * Defaults to "Chrome". + * @returns {string} The path of application data directory. + */ + async getDataPath(chromeProjectName = "Chrome") { + const SNAP_REAL_HOME = "SNAP_REAL_HOME"; + + const SUB_DIRECTORIES = { + win: { + Brave: [ + ["LocalAppData", "BraveSoftware", "Brave-Browser", "User Data"], + ], + Chrome: [["LocalAppData", "Google", "Chrome", "User Data"]], + "Chrome Beta": [["LocalAppData", "Google", "Chrome Beta", "User Data"]], + Chromium: [["LocalAppData", "Chromium", "User Data"]], + Canary: [["LocalAppData", "Google", "Chrome SxS", "User Data"]], + Edge: [["LocalAppData", "Microsoft", "Edge", "User Data"]], + "Edge Beta": [["LocalAppData", "Microsoft", "Edge Beta", "User Data"]], + "360 SE": [["AppData", "360se6", "User Data"]], + Opera: [["AppData", "Opera Software", "Opera Stable"]], + "Opera GX": [["AppData", "Opera Software", "Opera GX Stable"]], + Vivaldi: [["LocalAppData", "Vivaldi", "User Data"]], + }, + macosx: { + Brave: [ + ["ULibDir", "Application Support", "BraveSoftware", "Brave-Browser"], + ], + Chrome: [["ULibDir", "Application Support", "Google", "Chrome"]], + Chromium: [["ULibDir", "Application Support", "Chromium"]], + Canary: [["ULibDir", "Application Support", "Google", "Chrome Canary"]], + Edge: [["ULibDir", "Application Support", "Microsoft Edge"]], + "Edge Beta": [ + ["ULibDir", "Application Support", "Microsoft Edge Beta"], + ], + "Opera GX": [ + ["ULibDir", "Application Support", "com.operasoftware.OperaGX"], + ], + Opera: [["ULibDir", "Application Support", "com.operasoftware.Opera"]], + Vivaldi: [["ULibDir", "Application Support", "Vivaldi"]], + }, + linux: { + Brave: [["Home", ".config", "BraveSoftware", "Brave-Browser"]], + Chrome: [["Home", ".config", "google-chrome"]], + "Chrome Beta": [["Home", ".config", "google-chrome-beta"]], + "Chrome Dev": [["Home", ".config", "google-chrome-unstable"]], + Chromium: [ + ["Home", ".config", "chromium"], + + // If we're installed normally, we can look for Chromium installed + // as a Snap on Ubuntu Linux by looking here. + ["Home", "snap", "chromium", "common", "chromium"], + + // If we're installed as a Snap, "Home" is a special place that + // the Snap environment has given us, and the Chromium data is + // not within it. We want to, instead, start at the path set + // on the environment variable "SNAP_REAL_HOME". + // See: https://snapcraft.io/docs/environment-variables#heading--snap-real-home + [SNAP_REAL_HOME, "snap", "chromium", "common", "chromium"], + ], + // Opera GX is not available on Linux. + // Canary is not available on Linux. + // Edge is not available on Linux. + Opera: [["Home", ".config", "opera"]], + Vivaldi: [["Home", ".config", "vivaldi"]], + }, + }; + let options = SUB_DIRECTORIES[AppConstants.platform][chromeProjectName]; + if (!options) { + return null; + } + + for (let subfolders of options) { + let rootDir = subfolders[0]; + try { + let targetPath; + + if (rootDir == SNAP_REAL_HOME) { + targetPath = Services.env.get("SNAP_REAL_HOME"); + } else { + targetPath = Services.dirsvc.get(rootDir, Ci.nsIFile).path; + } + + targetPath = PathUtils.join(targetPath, ...subfolders.slice(1)); + if (await IOUtils.exists(targetPath)) { + return targetPath; + } + } catch (ex) { + // The path logic here shouldn't error, so log it: + console.error(ex); + } + } + return null; + }, + + /** + * Get the directory objects sorted by version number. + * + * @param {string} path - The path to the extension directory. + * otherwise return all file/directory object. + * @returns {Array} The file/directory object array. + */ + async _getSortedByVersionSubDirectoryNames(path) { + if (this._extensionVersionDirectoryNames[path]) { + return this._extensionVersionDirectoryNames[path]; + } + + let entries = []; + try { + for (const child of await IOUtils.getChildren(path)) { + const info = await IOUtils.stat(child); + if (info.type === "directory") { + const name = PathUtils.filename(child); + entries.push(name); + } + } + } catch (ex) { + console.error(ex); + entries = []; + } + + // The directory name is the version number string of the extension. + // For example, it could be "1.0_0", "1.0_1", "1.0_2", 1.1_0, 1.1_1, or 1.1_2. + // The "1.0_1" strings mean that the "1.0_0" directory is existed and you install the version 1.0 again. + // https://chromium.googlesource.com/chromium/src/+/0b58a813992b539a6b555ad7959adfad744b095a/chrome/common/extensions/extension_file_util_unittest.cc + entries.sort((a, b) => Services.vc.compare(b, a)); + + this._extensionVersionDirectoryNames[path] = entries; + return entries; + }, + + /** + * Convert Chrome time format to Date object. Google Chrome uses FILETIME / 10 as time. + * FILETIME is based on the same structure of Windows. + * + * @param {number} aTime Chrome time + * @param {string|number|Date} aFallbackValue a date or timestamp (valid argument + * for the Date constructor) that will be used if the chrometime value passed is + * invalid. + * @returns {Date} converted Date object + */ + chromeTimeToDate(aTime, aFallbackValue) { + // The date value may be 0 in some cases. Because of the subtraction below, + // that'd generate a date before the unix epoch, which can upset consumers + // due to the unix timestamp then being negative. Catch this case: + if (!aTime) { + return new Date(aFallbackValue); + } + return new Date((aTime * S100NS_PER_MS - S100NS_FROM1601TO1970) / 10000); + }, + + /** + * Convert Date object to Chrome time format. For details on Chrome time, see + * chromeTimeToDate. + * + * @param {Date|number} aDate Date object or integer equivalent + * @returns {number} Chrome time + */ + dateToChromeTime(aDate) { + return (aDate * 10000 + S100NS_FROM1601TO1970) / S100NS_PER_MS; + }, + + /** + * Returns an array of chromium browser ids that have importable logins. + */ + _importableLoginsCache: null, + async getImportableLogins(formOrigin) { + // Only provide importable if we actually support importing. + if (!this.supportsLoginsForPlatform) { + return undefined; + } + + // Lazily fill the cache with all importable login browsers. + if (!this._importableLoginsCache) { + this._importableLoginsCache = new Map(); + + // Just handle these chromium-based browsers for now. + for (const browserId of this.CONTEXTUAL_LOGIN_IMPORT_BROWSERS) { + // Skip if there's no profile data. + const migrator = await lazy.MigrationUtils.getMigrator(browserId); + if (!migrator) { + continue; + } + + // Check each profile for logins. + const dataPath = await migrator._getChromeUserDataPathIfExists(); + for (const profile of await migrator.getSourceProfiles()) { + const path = PathUtils.join(dataPath, profile.id, "Login Data"); + // Skip if login data is missing. + if (!(await IOUtils.exists(path))) { + console.error(`Missing file at ${path}`); + continue; + } + + try { + for (const row of await lazy.MigrationUtils.getRowsFromDBWithoutLocks( + path, + `Importable ${browserId} logins`, + `SELECT origin_url + FROM logins + WHERE blacklisted_by_user = 0` + )) { + const url = row.getString(0); + try { + // Initialize an array if it doesn't exist for the origin yet. + const origin = lazy.LoginHelper.getLoginOrigin(url); + const entries = this._importableLoginsCache.get(origin) || []; + if (!entries.length) { + this._importableLoginsCache.set(origin, entries); + } + + // Add the browser if it doesn't exist yet. + if (!entries.includes(browserId)) { + entries.push(browserId); + } + } catch (ex) { + console.error( + `Failed to process importable url ${url} from ${browserId}`, + ex + ); + } + } + } catch (ex) { + console.error( + `Failed to get importable logins from ${browserId}`, + ex + ); + } + } + } + } + return this._importableLoginsCache.get(formOrigin); + }, +}; diff --git a/browser/components/migration/ChromeProfileMigrator.sys.mjs b/browser/components/migration/ChromeProfileMigrator.sys.mjs new file mode 100644 index 0000000000..342ac3f376 --- /dev/null +++ b/browser/components/migration/ChromeProfileMigrator.sys.mjs @@ -0,0 +1,1253 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=2 ts=2 sts=2 et */ +/* 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 AUTH_TYPE = { + SCHEME_HTML: 0, + SCHEME_BASIC: 1, + SCHEME_DIGEST: 2, +}; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; +import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ChromeMigrationUtils: "resource:///modules/ChromeMigrationUtils.sys.mjs", + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + Qihoo360seMigrationUtils: "resource:///modules/360seMigrationUtils.sys.mjs", + MigrationWizardConstants: + "chrome://browser/content/migration/migration-wizard-constants.mjs", +}); + +/** + * Converts an array of chrome bookmark objects into one our own places code + * understands. + * + * @param {object[]} items Chrome Bookmark items to be inserted on this parent + * @param {set} bookmarkURLAccumulator Accumulate all imported bookmark urls to be used for importing favicons + * @param {Function} errorAccumulator function that gets called with any errors + * thrown so we don't drop them on the floor. + * @returns {object[]} + */ +function convertBookmarks(items, bookmarkURLAccumulator, errorAccumulator) { + let itemsToInsert = []; + for (let item of items) { + try { + if (item.type == "url") { + if (item.url.trim().startsWith("chrome:")) { + // Skip invalid internal URIs. Creating an actual URI always reports + // messages to the console because Gecko has its own concept of how + // chrome:// URIs should be formed, so we avoid doing that. + continue; + } + if (item.url.trim().startsWith("edge:")) { + // Don't import internal Microsoft Edge URIs as they won't resolve within Firefox. + continue; + } + itemsToInsert.push({ url: item.url, title: item.name }); + bookmarkURLAccumulator.add({ url: item.url }); + } else if (item.type == "folder") { + let folderItem = { + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + title: item.name, + }; + folderItem.children = convertBookmarks( + item.children, + bookmarkURLAccumulator, + errorAccumulator + ); + itemsToInsert.push(folderItem); + } + } catch (ex) { + console.error(ex); + errorAccumulator(ex); + } + } + return itemsToInsert; +} + +/** + * Chrome profile migrator. This can also be used as a parent class for + * migrators for browsers that are variants of Chrome. + */ +export class ChromeProfileMigrator extends MigratorBase { + /** + * On Ubuntu Linux, when the browser is installed as a Snap package, + * we must request permission to read data from other browsers. We + * make that request by opening up a native file picker in folder + * selection mode and instructing the user to navigate to the folder + * that the other browser's user data resides in. + * + * For Snap packages, this gives the browser read access - but it does + * so through a temporary symlink that does not match the original user + * data path. Effectively, the user data directory is remapped to a + * temporary location on the file system. We record these remaps here, + * keyed on the original data directory. + * + * @type {Map} + */ + #dataPathRemappings = new Map(); + + static get key() { + return "chrome"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chrome"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/chrome.png"; + } + + get _chromeUserDataPathSuffix() { + return "Chrome"; + } + + async hasPermissions() { + let dataPath = await this._getChromeUserDataPathIfExists(); + if (!dataPath) { + return true; + } + + let localStatePath = PathUtils.join(dataPath, "Local State"); + try { + // Read one byte since on snap we can check existence even without being able + // to read the file. + await IOUtils.read(localStatePath, { maxBytes: 1 }); + return true; + } catch (ex) { + console.error("No permissions for local state folder."); + } + return false; + } + + async getPermissions(win) { + // Get the original path to the user data and ignore any existing remapping. + // This allows us to set a new remapping if the user navigates the platforms + // filepicker to a different directory on a second permission request attempt. + let originalDataPath = await this._getChromeUserDataPathIfExists( + true /* noRemapping */ + ); + // Keep prompting the user until they pick something that grants us access + // to Chrome's local state directory. + while (!(await this.hasPermissions())) { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init(win, "", Ci.nsIFilePicker.modeGetFolder); + fp.filterIndex = 1; + // Now wait for the filepicker to open and close. If the user picks + // the local state folder, the OS should grant us read access to everything + // inside, so we don't need to check or do anything else with what's + // returned by the filepicker. + let result = await new Promise(resolve => fp.open(resolve)); + // Bail if the user cancels the dialog: + if (result == Ci.nsIFilePicker.returnCancel) { + return false; + } + + let file = fp.file; + if (file && file.path != originalDataPath) { + this.#dataPathRemappings.set(originalDataPath, file.path); + } + } + return true; + } + + async canGetPermissions() { + if ( + !Services.prefs.getBoolPref( + "browser.migrate.chrome.get_permissions.enabled" + ) + ) { + return false; + } + + if (await MigrationUtils.canGetPermissionsOnPlatform()) { + let dataPath = await this._getChromeUserDataPathIfExists(); + if (dataPath) { + let localStatePath = PathUtils.join(dataPath, "Local State"); + if (await IOUtils.exists(localStatePath)) { + return dataPath; + } + } + } + return false; + } + + _keychainServiceName = "Chrome Safe Storage"; + + _keychainAccountName = "Chrome"; + + /** + * Returns a Promise that resolves to the data path containing the + * Local State and profile directories for this browser. + * + * @param {boolean} [noRemapping=false] + * Set to true to bypass any remapping that might have occurred on + * platforms where the data path changes once permission has been + * granted. + * @returns {Promise} + */ + async _getChromeUserDataPathIfExists(noRemapping = false) { + if (this._chromeUserDataPath) { + // Skip looking up any remapping if `noRemapping` was passed. This is + // helpful if the caller needs create a new remapping and overwrite + // an old remapping, as "real" user data path is used as a key for + // the remapping. + if (noRemapping) { + return this._chromeUserDataPath; + } + + let remappedPath = this.#dataPathRemappings.get(this._chromeUserDataPath); + return remappedPath || this._chromeUserDataPath; + } + let path = await lazy.ChromeMigrationUtils.getDataPath( + this._chromeUserDataPathSuffix + ); + let exists = path && (await IOUtils.exists(path)); + if (exists) { + this._chromeUserDataPath = path; + } else { + this._chromeUserDataPath = null; + } + return this._chromeUserDataPath; + } + + async getResources(aProfile) { + if (!(await this.hasPermissions())) { + return []; + } + + let chromeUserDataPath = await this._getChromeUserDataPathIfExists(); + if (chromeUserDataPath) { + let profileFolder = chromeUserDataPath; + if (aProfile) { + profileFolder = PathUtils.join(chromeUserDataPath, aProfile.id); + } + if (await IOUtils.exists(profileFolder)) { + let possibleResourcePromises = [ + GetBookmarksResource(profileFolder, this.constructor.key), + GetHistoryResource(profileFolder), + GetFormdataResource(profileFolder), + GetExtensionsResource(aProfile.id, this.constructor.key), + ]; + if (lazy.ChromeMigrationUtils.supportsLoginsForPlatform) { + possibleResourcePromises.push( + this._GetPasswordsResource(profileFolder), + this._GetPaymentMethodsResource(profileFolder) + ); + } + + // Some of these Promises might reject due to things like database + // corruptions. We absorb those rejections here and filter them + // out so that we only try to import the resources that don't appear + // corrupted. + let possibleResources = await Promise.allSettled( + possibleResourcePromises + ); + return possibleResources + .filter(promise => { + return promise.status == "fulfilled" && promise.value !== null; + }) + .map(promise => promise.value); + } + } + return []; + } + + async getLastUsedDate() { + let sourceProfiles = await this.getSourceProfiles(); + if (!sourceProfiles) { + return new Date(0); + } + let chromeUserDataPath = await this._getChromeUserDataPathIfExists(); + if (!chromeUserDataPath) { + return new Date(0); + } + let datePromises = sourceProfiles.map(async profile => { + let basePath = PathUtils.join(chromeUserDataPath, profile.id); + let fileDatePromises = ["Bookmarks", "History", "Cookies"].map( + async leafName => { + let path = PathUtils.join(basePath, leafName); + let info = await IOUtils.stat(path).catch(() => null); + return info ? info.lastModified : 0; + } + ); + let dates = await Promise.all(fileDatePromises); + return Math.max(...dates); + }); + let datesOuter = await Promise.all(datePromises); + datesOuter.push(0); + return new Date(Math.max(...datesOuter)); + } + + async getSourceProfiles() { + if ("__sourceProfiles" in this) { + return this.__sourceProfiles; + } + + let chromeUserDataPath = await this._getChromeUserDataPathIfExists(); + if (!chromeUserDataPath) { + return []; + } + + let localState; + let profiles = []; + try { + localState = await lazy.ChromeMigrationUtils.getLocalState( + this._chromeUserDataPathSuffix, + chromeUserDataPath + ); + let info_cache = localState.profile.info_cache; + for (let profileFolderName in info_cache) { + profiles.push({ + id: profileFolderName, + name: info_cache[profileFolderName].name || profileFolderName, + }); + } + } catch (e) { + // Avoid reporting NotFoundErrors from trying to get local state. + if (localState || e.name != "NotFoundError") { + console.error("Error detecting Chrome profiles: ", e); + } + + // If we didn't have permission to read the local state, return the + // empty array. The user might have the opportunity to request + // permission using `hasPermission` and `getPermission`. + if (e.name == "NotAllowedError") { + return []; + } + + // If we weren't able to detect any profiles above, fallback to the Default profile. + let defaultProfilePath = PathUtils.join(chromeUserDataPath, "Default"); + if (await IOUtils.exists(defaultProfilePath)) { + profiles = [ + { + id: "Default", + name: "Default", + }, + ]; + } + } + + let profileResources = await Promise.all( + profiles.map(async profile => ({ + profile, + resources: await this.getResources(profile), + })) + ); + + // Only list profiles from which any data can be imported + this.__sourceProfiles = profileResources + .filter(({ resources }) => { + return resources && !!resources.length; + }, this) + .map(({ profile }) => profile); + return this.__sourceProfiles; + } + + async _GetPasswordsResource(aProfileFolder) { + let loginPath = PathUtils.join(aProfileFolder, "Login Data"); + if (!(await IOUtils.exists(loginPath))) { + return null; + } + + let tempFilePath = null; + if (MigrationUtils.IS_LINUX_SNAP_PACKAGE) { + tempFilePath = await IOUtils.createUniqueFile( + PathUtils.tempDir, + "Login Data" + ); + await IOUtils.copy(loginPath, tempFilePath); + loginPath = tempFilePath; + } + + let { + _chromeUserDataPathSuffix, + _keychainServiceName, + _keychainAccountName, + _keychainMockPassphrase = null, + } = this; + + let countQuery = `SELECT COUNT(*) FROM logins WHERE blacklisted_by_user = 0`; + + let countRows = await MigrationUtils.getRowsFromDBWithoutLocks( + loginPath, + "Chrome passwords", + countQuery + ); + + if (!countRows[0].getResultByName("COUNT(*)")) { + return null; + } + + return { + type: MigrationUtils.resourceTypes.PASSWORDS, + + async migrate(aCallback) { + let rows = await MigrationUtils.getRowsFromDBWithoutLocks( + loginPath, + "Chrome passwords", + `SELECT origin_url, action_url, username_element, username_value, + password_element, password_value, signon_realm, scheme, date_created, + times_used FROM logins WHERE blacklisted_by_user = 0` + ) + .catch(ex => { + console.error(ex); + aCallback(false); + }) + .finally(() => { + return tempFilePath && IOUtils.remove(tempFilePath); + }); + + // If the promise was rejected we will have already called aCallback, + // so we can just return here. + if (!rows) { + return; + } + + // If there are no relevant rows, return before initializing crypto and + // thus prompting for Keychain access on macOS. + if (!rows.length) { + aCallback(true); + return; + } + + let crypto; + try { + if (AppConstants.platform == "win") { + let { ChromeWindowsLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeWindowsLoginCrypto.sys.mjs" + ); + crypto = new ChromeWindowsLoginCrypto(_chromeUserDataPathSuffix); + } else if (AppConstants.platform == "macosx") { + let { ChromeMacOSLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeMacOSLoginCrypto.sys.mjs" + ); + crypto = new ChromeMacOSLoginCrypto( + _keychainServiceName, + _keychainAccountName, + _keychainMockPassphrase + ); + } else { + aCallback(false); + return; + } + } catch (ex) { + // Handle the user canceling Keychain access or other OSCrypto errors. + console.error(ex); + aCallback(false); + return; + } + + let logins = []; + let fallbackCreationDate = new Date(); + for (let row of rows) { + try { + let origin_url = lazy.NetUtil.newURI( + row.getResultByName("origin_url") + ); + // Ignore entries for non-http(s)/ftp URLs because we likely can't + // use them anyway. + const kValidSchemes = new Set(["https", "http", "ftp"]); + if (!kValidSchemes.has(origin_url.scheme)) { + continue; + } + let loginInfo = { + username: row.getResultByName("username_value"), + password: await crypto.decryptData( + row.getResultByName("password_value"), + null + ), + origin: origin_url.prePath, + formActionOrigin: null, + httpRealm: null, + usernameElement: row.getResultByName("username_element"), + passwordElement: row.getResultByName("password_element"), + timeCreated: lazy.ChromeMigrationUtils.chromeTimeToDate( + row.getResultByName("date_created") + 0, + fallbackCreationDate + ).getTime(), + timesUsed: row.getResultByName("times_used") + 0, + }; + + switch (row.getResultByName("scheme")) { + case AUTH_TYPE.SCHEME_HTML: + let action_url = row.getResultByName("action_url"); + if (!action_url) { + // If there is no action_url, store the wildcard "" value. + // See the `formActionOrigin` IDL comments. + loginInfo.formActionOrigin = ""; + break; + } + let action_uri = lazy.NetUtil.newURI(action_url); + if (!kValidSchemes.has(action_uri.scheme)) { + continue; // This continues the outer for loop. + } + loginInfo.formActionOrigin = action_uri.prePath; + break; + case AUTH_TYPE.SCHEME_BASIC: + case AUTH_TYPE.SCHEME_DIGEST: + // signon_realm format is URIrealm, so we need remove URI + loginInfo.httpRealm = row + .getResultByName("signon_realm") + .substring(loginInfo.origin.length + 1); + break; + default: + throw new Error( + "Login data scheme type not supported: " + + row.getResultByName("scheme") + ); + } + logins.push(loginInfo); + } catch (e) { + console.error(e); + } + } + try { + if (logins.length) { + await MigrationUtils.insertLoginsWrapper(logins); + } + } catch (e) { + console.error(e); + } + if (crypto.finalize) { + crypto.finalize(); + } + aCallback(true); + }, + }; + } + async _GetPaymentMethodsResource(aProfileFolder) { + if ( + !Services.prefs.getBoolPref( + "browser.migrate.chrome.payment_methods.enabled", + false + ) + ) { + return null; + } + + let paymentMethodsPath = PathUtils.join(aProfileFolder, "Web Data"); + + if (!(await IOUtils.exists(paymentMethodsPath))) { + return null; + } + + let tempFilePath = null; + if (MigrationUtils.IS_LINUX_SNAP_PACKAGE) { + tempFilePath = await IOUtils.createUniqueFile( + PathUtils.tempDir, + "Web Data" + ); + await IOUtils.copy(paymentMethodsPath, tempFilePath); + paymentMethodsPath = tempFilePath; + } + + let rows = await MigrationUtils.getRowsFromDBWithoutLocks( + paymentMethodsPath, + "Chrome Credit Cards", + "SELECT name_on_card, card_number_encrypted, expiration_month, expiration_year FROM credit_cards" + ) + .catch(ex => { + console.error(ex); + }) + .finally(() => { + return tempFilePath && IOUtils.remove(tempFilePath); + }); + + if (!rows?.length) { + return null; + } + + let { + _chromeUserDataPathSuffix, + _keychainServiceName, + _keychainAccountName, + _keychainMockPassphrase = null, + } = this; + + return { + type: MigrationUtils.resourceTypes.PAYMENT_METHODS, + + async migrate(aCallback) { + let crypto; + try { + if (AppConstants.platform == "win") { + let { ChromeWindowsLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeWindowsLoginCrypto.sys.mjs" + ); + crypto = new ChromeWindowsLoginCrypto(_chromeUserDataPathSuffix); + } else if (AppConstants.platform == "macosx") { + let { ChromeMacOSLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeMacOSLoginCrypto.sys.mjs" + ); + crypto = new ChromeMacOSLoginCrypto( + _keychainServiceName, + _keychainAccountName, + _keychainMockPassphrase + ); + } else { + aCallback(false); + return; + } + } catch (ex) { + // Handle the user canceling Keychain access or other OSCrypto errors. + console.error(ex); + aCallback(false); + return; + } + + let cards = []; + for (let row of rows) { + cards.push({ + "cc-name": row.getResultByName("name_on_card"), + "cc-number": await crypto.decryptData( + row.getResultByName("card_number_encrypted"), + null + ), + "cc-exp-month": parseInt( + row.getResultByName("expiration_month"), + 10 + ), + "cc-exp-year": parseInt(row.getResultByName("expiration_year"), 10), + }); + } + + await MigrationUtils.insertCreditCardsWrapper(cards); + aCallback(true); + }, + }; + } +} + +async function GetBookmarksResource(aProfileFolder, aBrowserKey) { + let bookmarksPath = PathUtils.join(aProfileFolder, "Bookmarks"); + let faviconsPath = PathUtils.join(aProfileFolder, "Favicons"); + + if (aBrowserKey === "chromium-360se") { + let localState = {}; + try { + localState = await lazy.ChromeMigrationUtils.getLocalState("360 SE"); + } catch (ex) { + console.error(ex); + } + + let alternativeBookmarks = + await lazy.Qihoo360seMigrationUtils.getAlternativeBookmarks({ + bookmarksPath, + localState, + }); + if (alternativeBookmarks.resource) { + return alternativeBookmarks.resource; + } + + bookmarksPath = alternativeBookmarks.path; + } + + if (!(await IOUtils.exists(bookmarksPath))) { + return null; + } + + let tempFilePath = null; + if (MigrationUtils.IS_LINUX_SNAP_PACKAGE) { + tempFilePath = await IOUtils.createUniqueFile( + PathUtils.tempDir, + "Favicons" + ); + await IOUtils.copy(faviconsPath, tempFilePath); + faviconsPath = tempFilePath; + } + + // check to read JSON bookmarks structure and see if any bookmarks exist else return null + // Parse Chrome bookmark file that is JSON format + let bookmarkJSON = await IOUtils.readJSON(bookmarksPath); + let other = bookmarkJSON.roots.other.children.length; + let bookmarkBar = bookmarkJSON.roots.bookmark_bar.children.length; + let synced = bookmarkJSON.roots.synced.children.length; + + if (!other && !bookmarkBar && !synced) { + return null; + } + return { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + migrate(aCallback) { + return (async function () { + let gotErrors = false; + let errorGatherer = function () { + gotErrors = true; + }; + + let faviconRows = []; + try { + faviconRows = await MigrationUtils.getRowsFromDBWithoutLocks( + faviconsPath, + "Chrome Bookmark Favicons", + `select fav.id, fav.url, map.page_url, bit.image_data FROM favicons as fav + INNER JOIN favicon_bitmaps bit ON (fav.id = bit.icon_id) + INNER JOIN icon_mapping map ON (map.icon_id = bit.icon_id)` + ); + } catch (ex) { + console.error(ex); + } finally { + if (tempFilePath) { + await IOUtils.remove(tempFilePath); + } + } + + // Create Hashmap for favicons + let faviconMap = new Map(); + for (let faviconRow of faviconRows) { + // First, try to normalize the URI: + try { + let uri = lazy.NetUtil.newURI( + faviconRow.getResultByName("page_url") + ); + faviconMap.set(uri.spec, { + faviconData: faviconRow.getResultByName("image_data"), + uri, + }); + } catch (e) { + // Couldn't parse the URI, so just skip it. + continue; + } + } + + let roots = bookmarkJSON.roots; + let bookmarkURLAccumulator = new Set(); + + // Importing bookmark bar items + if (roots.bookmark_bar.children && roots.bookmark_bar.children.length) { + // Toolbar + let parentGuid = lazy.PlacesUtils.bookmarks.toolbarGuid; + let bookmarks = convertBookmarks( + roots.bookmark_bar.children, + bookmarkURLAccumulator, + errorGatherer + ); + await MigrationUtils.insertManyBookmarksWrapper( + bookmarks, + parentGuid + ); + } + + // Importing Other Bookmarks items + if (roots.other.children && roots.other.children.length) { + // Other Bookmarks + let parentGuid = lazy.PlacesUtils.bookmarks.unfiledGuid; + let bookmarks = convertBookmarks( + roots.other.children, + bookmarkURLAccumulator, + errorGatherer + ); + await MigrationUtils.insertManyBookmarksWrapper( + bookmarks, + parentGuid + ); + } + + // Importing synced Bookmarks items + if (roots.synced.children && roots.synced.children.length) { + // Synced Bookmarks + let parentGuid = lazy.PlacesUtils.bookmarks.unfiledGuid; + let bookmarks = convertBookmarks( + roots.synced.children, + bookmarkURLAccumulator, + errorGatherer + ); + await MigrationUtils.insertManyBookmarksWrapper( + bookmarks, + parentGuid + ); + } + + // Find all favicons with associated bookmarks + let favicons = []; + for (let bookmark of bookmarkURLAccumulator) { + try { + let uri = lazy.NetUtil.newURI(bookmark.url); + let favicon = faviconMap.get(uri.spec); + if (favicon) { + favicons.push(favicon); + } + } catch (e) { + // Couldn't parse the bookmark URI, so just skip + continue; + } + } + + // Import Bookmark Favicons + MigrationUtils.insertManyFavicons(favicons); + if (gotErrors) { + throw new Error("The migration included errors."); + } + })().then( + () => aCallback(true), + () => aCallback(false) + ); + }, + }; +} + +async function GetHistoryResource(aProfileFolder) { + let historyPath = PathUtils.join(aProfileFolder, "History"); + if (!(await IOUtils.exists(historyPath))) { + return null; + } + + let tempFilePath = null; + if (MigrationUtils.IS_LINUX_SNAP_PACKAGE) { + tempFilePath = await IOUtils.createUniqueFile(PathUtils.tempDir, "History"); + await IOUtils.copy(historyPath, tempFilePath); + historyPath = tempFilePath; + } + + let countQuery = "SELECT COUNT(*) FROM urls WHERE hidden = 0"; + + let countRows = await MigrationUtils.getRowsFromDBWithoutLocks( + historyPath, + "Chrome history", + countQuery + ); + if (!countRows[0].getResultByName("COUNT(*)")) { + return null; + } + return { + type: MigrationUtils.resourceTypes.HISTORY, + + migrate(aCallback) { + (async function () { + const LIMIT = Services.prefs.getIntPref( + "browser.migrate.chrome.history.limit" + ); + + let query = + "SELECT url, title, last_visit_time, typed_count FROM urls WHERE hidden = 0"; + let maxAge = lazy.ChromeMigrationUtils.dateToChromeTime( + Date.now() - MigrationUtils.HISTORY_MAX_AGE_IN_MILLISECONDS + ); + query += " AND last_visit_time > " + maxAge; + + if (LIMIT) { + query += " ORDER BY last_visit_time DESC LIMIT " + LIMIT; + } + + let rows; + try { + rows = await MigrationUtils.getRowsFromDBWithoutLocks( + historyPath, + "Chrome history", + query + ); + } finally { + if (tempFilePath) { + await IOUtils.remove(tempFilePath); + } + } + + let pageInfos = []; + let fallbackVisitDate = new Date(); + for (let row of rows) { + try { + // if having typed_count, we changes transition type to typed. + let transition = lazy.PlacesUtils.history.TRANSITIONS.LINK; + if (row.getResultByName("typed_count") > 0) { + transition = lazy.PlacesUtils.history.TRANSITIONS.TYPED; + } + + pageInfos.push({ + title: row.getResultByName("title"), + url: new URL(row.getResultByName("url")), + visits: [ + { + transition, + date: lazy.ChromeMigrationUtils.chromeTimeToDate( + row.getResultByName("last_visit_time"), + fallbackVisitDate + ), + }, + ], + }); + } catch (e) { + console.error(e); + } + } + + if (pageInfos.length) { + await MigrationUtils.insertVisitsWrapper(pageInfos); + } + })().then( + () => { + aCallback(true); + }, + ex => { + console.error(ex); + aCallback(false); + } + ); + }, + }; +} + +async function GetFormdataResource(aProfileFolder) { + let formdataPath = PathUtils.join(aProfileFolder, "Web Data"); + if (!(await IOUtils.exists(formdataPath))) { + return null; + } + let countQuery = "SELECT COUNT(*) FROM autofill"; + + let tempFilePath = null; + if (MigrationUtils.IS_LINUX_SNAP_PACKAGE) { + tempFilePath = await IOUtils.createUniqueFile( + PathUtils.tempDir, + "Web Data" + ); + await IOUtils.copy(formdataPath, tempFilePath); + formdataPath = tempFilePath; + } + + let countRows = await MigrationUtils.getRowsFromDBWithoutLocks( + formdataPath, + "Chrome formdata", + countQuery + ); + if (!countRows[0].getResultByName("COUNT(*)")) { + return null; + } + return { + type: MigrationUtils.resourceTypes.FORMDATA, + + async migrate(aCallback) { + let query = + "SELECT name, value, count, date_created, date_last_used FROM autofill"; + let rows; + + try { + rows = await MigrationUtils.getRowsFromDBWithoutLocks( + formdataPath, + "Chrome formdata", + query + ); + } finally { + if (tempFilePath) { + await IOUtils.remove(tempFilePath); + } + } + + let addOps = []; + for (let row of rows) { + try { + let fieldname = row.getResultByName("name"); + let value = row.getResultByName("value"); + if (fieldname && value) { + addOps.push({ + op: "add", + fieldname, + value, + timesUsed: row.getResultByName("count"), + firstUsed: row.getResultByName("date_created") * 1000, + lastUsed: row.getResultByName("date_last_used") * 1000, + }); + } + } catch (e) { + console.error(e); + } + } + + try { + await lazy.FormHistory.update(addOps); + } catch (e) { + console.error(e); + aCallback(false); + return; + } + + aCallback(true); + }, + }; +} + +async function GetExtensionsResource(aProfileId, aBrowserKey = "chrome") { + if ( + !Services.prefs.getBoolPref( + "browser.migrate.chrome.extensions.enabled", + false + ) + ) { + return null; + } + let extensions = await lazy.ChromeMigrationUtils.getExtensionList(aProfileId); + if (!extensions.length || aBrowserKey !== "chrome") { + return null; + } + + return { + type: MigrationUtils.resourceTypes.EXTENSIONS, + async migrate(callback) { + let ids = extensions.map(extension => extension.id); + let [progressValue, importedExtensions] = + await MigrationUtils.installExtensionsWrapper(aBrowserKey, ids); + let details = { + progressValue, + totalExtensions: extensions, + importedExtensions, + }; + if ( + progressValue == lazy.MigrationWizardConstants.PROGRESS_VALUE.INFO || + progressValue == lazy.MigrationWizardConstants.PROGRESS_VALUE.SUCCESS + ) { + callback(true, details); + } else { + callback(false); + } + }, + }; +} + +/** + * Chromium migrator + */ +export class ChromiumProfileMigrator extends ChromeProfileMigrator { + static get key() { + return "chromium"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chromium"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/chromium.png"; + } + + _chromeUserDataPathSuffix = "Chromium"; + _keychainServiceName = "Chromium Safe Storage"; + _keychainAccountName = "Chromium"; +} + +/** + * Chrome Canary + * Not available on Linux + */ +export class CanaryProfileMigrator extends ChromeProfileMigrator { + static get key() { + return "canary"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-canary"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/canary.png"; + } + + get _chromeUserDataPathSuffix() { + return "Canary"; + } + + get _keychainServiceName() { + return "Chromium Safe Storage"; + } + + get _keychainAccountName() { + return "Chromium"; + } +} + +/** + * Chrome Dev - Linux only (not available in Mac and Windows) + */ +export class ChromeDevMigrator extends ChromeProfileMigrator { + static get key() { + return "chrome-dev"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chrome-dev"; + } + + _chromeUserDataPathSuffix = "Chrome Dev"; + _keychainServiceName = "Chromium Safe Storage"; + _keychainAccountName = "Chromium"; +} + +/** + * Chrome Beta migrator + */ +export class ChromeBetaMigrator extends ChromeProfileMigrator { + static get key() { + return "chrome-beta"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chrome-beta"; + } + + _chromeUserDataPathSuffix = "Chrome Beta"; + _keychainServiceName = "Chromium Safe Storage"; + _keychainAccountName = "Chromium"; +} + +/** + * Brave migrator + */ +export class BraveProfileMigrator extends ChromeProfileMigrator { + static get key() { + return "brave"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-brave"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/brave.png"; + } + + _chromeUserDataPathSuffix = "Brave"; + _keychainServiceName = "Brave Browser Safe Storage"; + _keychainAccountName = "Brave Browser"; +} + +/** + * Edge (Chromium-based) migrator + */ +export class ChromiumEdgeMigrator extends ChromeProfileMigrator { + static get key() { + return "chromium-edge"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chromium-edge"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/edge.png"; + } + + _chromeUserDataPathSuffix = "Edge"; + _keychainServiceName = "Microsoft Edge Safe Storage"; + _keychainAccountName = "Microsoft Edge"; +} + +/** + * Edge Beta (Chromium-based) migrator + */ +export class ChromiumEdgeBetaMigrator extends ChromeProfileMigrator { + static get key() { + return "chromium-edge-beta"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chromium-edge-beta"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/edgebeta.png"; + } + + _chromeUserDataPathSuffix = "Edge Beta"; + _keychainServiceName = "Microsoft Edge Safe Storage"; + _keychainAccountName = "Microsoft Edge"; +} + +/** + * Chromium 360 migrator + */ +export class Chromium360seMigrator extends ChromeProfileMigrator { + static get key() { + return "chromium-360se"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chromium-360se"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/360.png"; + } + + _chromeUserDataPathSuffix = "360 SE"; + _keychainServiceName = "Microsoft Edge Safe Storage"; + _keychainAccountName = "Microsoft Edge"; +} + +/** + * Opera migrator + */ +export class OperaProfileMigrator extends ChromeProfileMigrator { + static get key() { + return "opera"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-opera"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/opera.png"; + } + + _chromeUserDataPathSuffix = "Opera"; + _keychainServiceName = "Opera Safe Storage"; + _keychainAccountName = "Opera"; + + getSourceProfiles() { + return null; + } +} + +/** + * Opera GX migrator + */ +export class OperaGXProfileMigrator extends ChromeProfileMigrator { + static get key() { + return "opera-gx"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-opera-gx"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/operagx.png"; + } + + _chromeUserDataPathSuffix = "Opera GX"; + _keychainServiceName = "Opera Safe Storage"; + _keychainAccountName = "Opera"; + + getSourceProfiles() { + return null; + } +} + +/** + * Vivaldi migrator + */ +export class VivaldiProfileMigrator extends ChromeProfileMigrator { + static get key() { + return "vivaldi"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-vivaldi"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/vivaldi.png"; + } + + _chromeUserDataPathSuffix = "Vivaldi"; + _keychainServiceName = "Vivaldi Safe Storage"; + _keychainAccountName = "Vivaldi"; +} diff --git a/browser/components/migration/ChromeWindowsLoginCrypto.sys.mjs b/browser/components/migration/ChromeWindowsLoginCrypto.sys.mjs new file mode 100644 index 0000000000..4d11eb5fa4 --- /dev/null +++ b/browser/components/migration/ChromeWindowsLoginCrypto.sys.mjs @@ -0,0 +1,175 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Class to handle encryption and decryption of logins stored in Chrome/Chromium + * on Windows. + */ + +import { ChromeMigrationUtils } from "resource:///modules/ChromeMigrationUtils.sys.mjs"; + +import { OSCrypto } from "resource://gre/modules/OSCrypto_win.sys.mjs"; + +/** + * These constants should match those from Chromium. + * + * @see https://source.chromium.org/chromium/chromium/src/+/master:components/os_crypt/os_crypt_win.cc + */ +const AEAD_KEY_LENGTH = 256 / 8; +const ALGORITHM_NAME = "AES-GCM"; +const DPAPI_KEY_PREFIX = "DPAPI"; +const ENCRYPTION_VERSION_PREFIX = "v10"; +const NONCE_LENGTH = 96 / 8; + +const gTextDecoder = new TextDecoder(); +const gTextEncoder = new TextEncoder(); + +/** + * Instances of this class have a shape similar to OSCrypto so it can be dropped + * into code which uses that. The algorithms here are + * specific to what is needed for Chrome login storage on Windows. + */ +export class ChromeWindowsLoginCrypto { + /** + * @param {string} userDataPathSuffix The unique identifier for the variant of + * Chrome that is having its logins imported. These are the keys in the + * SUB_DIRECTORIES object in ChromeMigrationUtils.getDataPath. + */ + constructor(userDataPathSuffix) { + this.osCrypto = new OSCrypto(); + + // Lazily decrypt the key from "Chrome"s local state using OSCrypto and save + // it as the master key to decrypt or encrypt passwords. + ChromeUtils.defineLazyGetter(this, "_keyPromise", async () => { + let keyData; + try { + // NB: For testing, allow directory service to be faked before getting. + const localState = await ChromeMigrationUtils.getLocalState( + userDataPathSuffix + ); + const withHeader = atob(localState.os_crypt.encrypted_key); + if (!withHeader.startsWith(DPAPI_KEY_PREFIX)) { + throw new Error("Invalid key format"); + } + const encryptedKey = withHeader.slice(DPAPI_KEY_PREFIX.length); + keyData = this.osCrypto.decryptData(encryptedKey, null, "bytes"); + } catch (ex) { + console.error(`${userDataPathSuffix} os_crypt key:`, ex); + + // Use a generic key that will fail for actually encrypted data, but for + // testing it'll be consistent for both encrypting and decrypting. + keyData = AEAD_KEY_LENGTH; + } + return crypto.subtle.importKey( + "raw", + new Uint8Array(keyData), + ALGORITHM_NAME, + false, + ["decrypt", "encrypt"] + ); + }); + } + + /** + * Must be invoked once after last use of any of the provided helpers. + */ + finalize() { + this.osCrypto.finalize(); + } + + /** + * Convert an array containing only two bytes unsigned numbers to a string. + * + * @param {number[]} arr - the array that needs to be converted. + * @returns {string} the string representation of the array. + */ + arrayToString(arr) { + let str = ""; + for (let i = 0; i < arr.length; i++) { + str += String.fromCharCode(arr[i]); + } + return str; + } + + stringToArray(binary_string) { + const len = binary_string.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binary_string.charCodeAt(i); + } + return bytes; + } + + /** + * @param {string} ciphertext ciphertext optionally prefixed by the encryption version + * (see ENCRYPTION_VERSION_PREFIX). + * @returns {string} plaintext password + */ + async decryptData(ciphertext) { + const ciphertextString = this.arrayToString(ciphertext); + return ciphertextString.startsWith(ENCRYPTION_VERSION_PREFIX) + ? this._decryptV10(ciphertext) + : this._decryptUnversioned(ciphertextString); + } + + async _decryptUnversioned(ciphertext) { + return this.osCrypto.decryptData(ciphertext); + } + + async _decryptV10(ciphertext) { + const key = await this._keyPromise; + if (!key) { + throw new Error("Cannot decrypt without a key"); + } + + // Split the nonce/iv from the rest of the encrypted value and decrypt. + const nonceIndex = ENCRYPTION_VERSION_PREFIX.length; + const cipherIndex = nonceIndex + NONCE_LENGTH; + const iv = new Uint8Array(ciphertext.slice(nonceIndex, cipherIndex)); + const algorithm = { + name: ALGORITHM_NAME, + iv, + }; + const cipherArray = new Uint8Array(ciphertext.slice(cipherIndex)); + const plaintext = await crypto.subtle.decrypt(algorithm, key, cipherArray); + return gTextDecoder.decode(new Uint8Array(plaintext)); + } + + /** + * @param {USVString} plaintext to encrypt + * @param {?string} version to encrypt default unversioned + * @returns {string} encrypted string consisting of UTF-16 code units prefixed + * by the ENCRYPTION_VERSION_PREFIX. + */ + async encryptData(plaintext, version = undefined) { + return version === ENCRYPTION_VERSION_PREFIX + ? this._encryptV10(plaintext) + : this._encryptUnversioned(plaintext); + } + + async _encryptUnversioned(plaintext) { + return this.osCrypto.encryptData(plaintext); + } + + async _encryptV10(plaintext) { + const key = await this._keyPromise; + if (!key) { + throw new Error("Cannot encrypt without a key"); + } + + // Encrypt and concatenate the prefix, nonce/iv and encrypted value. + const iv = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH)); + const algorithm = { + name: ALGORITHM_NAME, + iv, + }; + const plainArray = gTextEncoder.encode(plaintext); + const ciphertext = await crypto.subtle.encrypt(algorithm, key, plainArray); + return ( + ENCRYPTION_VERSION_PREFIX + + this.arrayToString(iv) + + this.arrayToString(new Uint8Array(ciphertext)) + ); + } +} diff --git a/browser/components/migration/ESEDBReader.sys.mjs b/browser/components/migration/ESEDBReader.sys.mjs new file mode 100644 index 0000000000..479bc2fb9b --- /dev/null +++ b/browser/components/migration/ESEDBReader.sys.mjs @@ -0,0 +1,799 @@ +/* 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 { ctypes } from "resource://gre/modules/ctypes.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineLazyGetter(lazy, "log", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + let consoleOptions = { + maxLogLevelPref: "browser.esedbreader.loglevel", + prefix: "ESEDBReader", + }; + return new ConsoleAPI(consoleOptions); +}); + +// We have a globally unique identifier for ESE instances. A new one +// is used for each different database opened. +let gESEInstanceCounter = 0; + +// We limit the length of strings that we read from databases. +const MAX_STR_LENGTH = 64 * 1024; + +// Kernel-related types: +export const KERNEL = {}; + +KERNEL.FILETIME = new ctypes.StructType("FILETIME", [ + { dwLowDateTime: ctypes.uint32_t }, + { dwHighDateTime: ctypes.uint32_t }, +]); +KERNEL.SYSTEMTIME = new ctypes.StructType("SYSTEMTIME", [ + { wYear: ctypes.uint16_t }, + { wMonth: ctypes.uint16_t }, + { wDayOfWeek: ctypes.uint16_t }, + { wDay: ctypes.uint16_t }, + { wHour: ctypes.uint16_t }, + { wMinute: ctypes.uint16_t }, + { wSecond: ctypes.uint16_t }, + { wMilliseconds: ctypes.uint16_t }, +]); + +// DB column types, cribbed from the ESE header +export var COLUMN_TYPES = { + JET_coltypBit: 1 /* True, False, or NULL */, + JET_coltypUnsignedByte: 2 /* 1-byte integer, unsigned */, + JET_coltypShort: 3 /* 2-byte integer, signed */, + JET_coltypLong: 4 /* 4-byte integer, signed */, + JET_coltypCurrency: 5 /* 8 byte integer, signed */, + JET_coltypIEEESingle: 6 /* 4-byte IEEE single precision */, + JET_coltypIEEEDouble: 7 /* 8-byte IEEE double precision */, + JET_coltypDateTime: 8 /* Integral date, fractional time */, + JET_coltypBinary: 9 /* Binary data, < 255 bytes */, + JET_coltypText: 10 /* ANSI text, case insensitive, < 255 bytes */, + JET_coltypLongBinary: 11 /* Binary data, long value */, + JET_coltypLongText: 12 /* ANSI text, long value */, + + JET_coltypUnsignedLong: 14 /* 4-byte unsigned integer */, + JET_coltypLongLong: 15 /* 8-byte signed integer */, + JET_coltypGUID: 16 /* 16-byte globally unique identifier */, +}; + +// Not very efficient, but only used for error messages +function getColTypeName(numericValue) { + return ( + Object.keys(COLUMN_TYPES).find(t => COLUMN_TYPES[t] == numericValue) || + "unknown" + ); +} + +// All type constants and method wrappers go on this object: +export const ESE = {}; + +ESE.JET_ERR = ctypes.long; +ESE.JET_PCWSTR = ctypes.char16_t.ptr; +// The ESE header calls this JET_API_PTR, but because it isn't ever used as a +// pointer, I opted for a different name. +// Note that this is defined differently on 32 vs. 64-bit in the header. +ESE.JET_API_ITEM = + ctypes.voidptr_t.size == 4 ? ctypes.unsigned_long : ctypes.uint64_t; +ESE.JET_INSTANCE = ESE.JET_API_ITEM; +ESE.JET_SESID = ESE.JET_API_ITEM; +ESE.JET_TABLEID = ESE.JET_API_ITEM; +ESE.JET_COLUMNID = ctypes.unsigned_long; +ESE.JET_GRBIT = ctypes.unsigned_long; +ESE.JET_COLTYP = ctypes.unsigned_long; +ESE.JET_DBID = ctypes.unsigned_long; + +ESE.JET_COLUMNDEF = new ctypes.StructType("JET_COLUMNDEF", [ + { cbStruct: ctypes.unsigned_long }, + { columnid: ESE.JET_COLUMNID }, + { coltyp: ESE.JET_COLTYP }, + { wCountry: ctypes.unsigned_short }, // sepcifies the country/region for the column definition + { langid: ctypes.unsigned_short }, + { cp: ctypes.unsigned_short }, + { wCollate: ctypes.unsigned_short } /* Must be 0 */, + { cbMax: ctypes.unsigned_long }, + { grbit: ESE.JET_GRBIT }, +]); + +// Track open databases +let gOpenDBs = new Map(); + +// Track open libraries +export let gLibs = {}; + +function convertESEError(errorCode) { + switch (errorCode) { + case -1213 /* JET_errPageSizeMismatch */: + case -1002 /* JET_errInvalidName*/: + case -1507 /* JET_errColumnNotFound */: + // The DB format has changed and we haven't updated this migration code: + return "The database format has changed, error code: " + errorCode; + case -1032 /* JET_errFileAccessDenied */: + case -1207 /* JET_errDatabaseLocked */: + case -1302 /* JET_errTableLocked */: + return "The database or table is locked, error code: " + errorCode; + case -1305 /* JET_errObjectNotFound */: + return "The table/object was not found."; + case -1809 /* JET_errPermissionDenied*/: + case -1907 /* JET_errAccessDenied */: + return "Access or permission denied, error code: " + errorCode; + case -1044 /* JET_errInvalidFilename */: + return "Invalid file name"; + case -1811 /* JET_errFileNotFound */: + return "File not found"; + case -550 /* JET_errDatabaseDirtyShutdown */: + return "Database in dirty shutdown state (without the requisite logs?)"; + case -514 /* JET_errBadLogVersion */: + return "Database log version does not match the version of ESE in use."; + default: + return "Unknown error: " + errorCode; + } +} + +function handleESEError( + method, + methodName, + shouldThrow = true, + errorLog = true +) { + return function () { + let rv; + try { + rv = method.apply(null, arguments); + } catch (ex) { + lazy.log.error("Error calling into ctypes method", methodName, ex); + throw ex; + } + let resultCode = parseInt(rv.toString(10), 10); + if (resultCode < 0) { + if (errorLog) { + lazy.log.error("Got error " + resultCode + " calling " + methodName); + } + if (shouldThrow) { + throw new Error(convertESEError(rv)); + } + } else if (resultCode > 0 && errorLog) { + lazy.log.warn("Got warning " + resultCode + " calling " + methodName); + } + return resultCode; + }; +} + +export function declareESEFunction(methodName, ...args) { + let declaration = ["Jet" + methodName, ctypes.winapi_abi, ESE.JET_ERR].concat( + args + ); + let ctypeMethod = gLibs.ese.declare.apply(gLibs.ese, declaration); + ESE[methodName] = handleESEError(ctypeMethod, methodName); + ESE["FailSafe" + methodName] = handleESEError(ctypeMethod, methodName, false); + ESE["Manual" + methodName] = handleESEError( + ctypeMethod, + methodName, + false, + false + ); +} + +function declareESEFunctions() { + declareESEFunction( + "GetDatabaseFileInfoW", + ESE.JET_PCWSTR, + ctypes.voidptr_t, + ctypes.unsigned_long, + ctypes.unsigned_long + ); + + declareESEFunction( + "GetSystemParameterW", + ESE.JET_INSTANCE, + ESE.JET_SESID, + ctypes.unsigned_long, + ESE.JET_API_ITEM.ptr, + ESE.JET_PCWSTR, + ctypes.unsigned_long + ); + declareESEFunction( + "SetSystemParameterW", + ESE.JET_INSTANCE.ptr, + ESE.JET_SESID, + ctypes.unsigned_long, + ESE.JET_API_ITEM, + ESE.JET_PCWSTR + ); + declareESEFunction("CreateInstanceW", ESE.JET_INSTANCE.ptr, ESE.JET_PCWSTR); + declareESEFunction("Init", ESE.JET_INSTANCE.ptr); + + declareESEFunction( + "BeginSessionW", + ESE.JET_INSTANCE, + ESE.JET_SESID.ptr, + ESE.JET_PCWSTR, + ESE.JET_PCWSTR + ); + declareESEFunction( + "AttachDatabaseW", + ESE.JET_SESID, + ESE.JET_PCWSTR, + ESE.JET_GRBIT + ); + declareESEFunction("DetachDatabaseW", ESE.JET_SESID, ESE.JET_PCWSTR); + declareESEFunction( + "OpenDatabaseW", + ESE.JET_SESID, + ESE.JET_PCWSTR, + ESE.JET_PCWSTR, + ESE.JET_DBID.ptr, + ESE.JET_GRBIT + ); + declareESEFunction( + "OpenTableW", + ESE.JET_SESID, + ESE.JET_DBID, + ESE.JET_PCWSTR, + ctypes.voidptr_t, + ctypes.unsigned_long, + ESE.JET_GRBIT, + ESE.JET_TABLEID.ptr + ); + + declareESEFunction( + "GetColumnInfoW", + ESE.JET_SESID, + ESE.JET_DBID, + ESE.JET_PCWSTR, + ESE.JET_PCWSTR, + ctypes.voidptr_t, + ctypes.unsigned_long, + ctypes.unsigned_long + ); + + declareESEFunction( + "Move", + ESE.JET_SESID, + ESE.JET_TABLEID, + ctypes.long, + ESE.JET_GRBIT + ); + + declareESEFunction( + "RetrieveColumn", + ESE.JET_SESID, + ESE.JET_TABLEID, + ESE.JET_COLUMNID, + ctypes.voidptr_t, + ctypes.unsigned_long, + ctypes.unsigned_long.ptr, + ESE.JET_GRBIT, + ctypes.voidptr_t + ); + + declareESEFunction("CloseTable", ESE.JET_SESID, ESE.JET_TABLEID); + declareESEFunction( + "CloseDatabase", + ESE.JET_SESID, + ESE.JET_DBID, + ESE.JET_GRBIT + ); + + declareESEFunction("EndSession", ESE.JET_SESID, ESE.JET_GRBIT); + + declareESEFunction("Term", ESE.JET_INSTANCE); +} + +function unloadLibraries() { + lazy.log.debug("Unloading"); + if (gOpenDBs.size) { + lazy.log.error("Shouldn't unload libraries before DBs are closed!"); + for (let db of gOpenDBs.values()) { + db._close(); + } + } + for (let k of Object.keys(ESE)) { + delete ESE[k]; + } + gLibs.ese.close(); + gLibs.kernel.close(); + delete gLibs.ese; + delete gLibs.kernel; +} + +export function loadLibraries() { + Services.obs.addObserver(unloadLibraries, "xpcom-shutdown"); + gLibs.ese = ctypes.open("esent.dll"); + gLibs.kernel = ctypes.open("kernel32.dll"); + KERNEL.FileTimeToSystemTime = gLibs.kernel.declare( + "FileTimeToSystemTime", + ctypes.winapi_abi, + ctypes.int, + KERNEL.FILETIME.ptr, + KERNEL.SYSTEMTIME.ptr + ); + + declareESEFunctions(); +} + +function ESEDB(rootPath, dbPath, logPath) { + lazy.log.info("Created db"); + this.rootPath = rootPath; + this.dbPath = dbPath; + this.logPath = logPath; + this._references = 0; + this._init(); +} + +ESEDB.prototype = { + rootPath: null, + dbPath: null, + logPath: null, + _opened: false, + _attached: false, + _sessionCreated: false, + _instanceCreated: false, + _dbId: null, + _sessionId: null, + _instanceId: null, + + _init() { + if (!gLibs.ese) { + loadLibraries(); + } + this.incrementReferenceCounter(); + this._internalOpen(); + }, + + _internalOpen() { + try { + let dbinfo = new ctypes.unsigned_long(); + ESE.GetDatabaseFileInfoW( + this.dbPath, + dbinfo.address(), + ctypes.unsigned_long.size, + 17 + ); + + let pageSize = ctypes.UInt64.lo(dbinfo.value); + ESE.SetSystemParameterW( + null, + 0, + 64 /* JET_paramDatabasePageSize*/, + pageSize, + null + ); + + this._instanceId = new ESE.JET_INSTANCE(); + ESE.CreateInstanceW( + this._instanceId.address(), + "firefox-dbreader-" + gESEInstanceCounter++ + ); + this._instanceCreated = true; + + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 0 /* JET_paramSystemPath*/, + 0, + this.rootPath + ); + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 1 /* JET_paramTempPath */, + 0, + this.rootPath + ); + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 2 /* JET_paramLogFilePath*/, + 0, + this.logPath + ); + + // Shouldn't try to call JetTerm if the following call fails. + this._instanceCreated = false; + ESE.Init(this._instanceId.address()); + this._instanceCreated = true; + this._sessionId = new ESE.JET_SESID(); + ESE.BeginSessionW( + this._instanceId, + this._sessionId.address(), + null, + null + ); + this._sessionCreated = true; + + const JET_bitDbReadOnly = 1; + ESE.AttachDatabaseW(this._sessionId, this.dbPath, JET_bitDbReadOnly); + this._attached = true; + this._dbId = new ESE.JET_DBID(); + ESE.OpenDatabaseW( + this._sessionId, + this.dbPath, + null, + this._dbId.address(), + JET_bitDbReadOnly + ); + this._opened = true; + } catch (ex) { + try { + this._close(); + } catch (innerException) { + console.error(innerException); + } + // Make sure caller knows we failed. + throw ex; + } + gOpenDBs.set(this.dbPath, this); + }, + + checkForColumn(tableName, columnName) { + if (!this._opened) { + throw new Error("The database was closed!"); + } + + let columnInfo; + try { + columnInfo = this._getColumnInfo(tableName, [{ name: columnName }]); + } catch (ex) { + return null; + } + return columnInfo[0]; + }, + + tableExists(tableName) { + if (!this._opened) { + throw new Error("The database was closed!"); + } + + let tableId = new ESE.JET_TABLEID(); + let rv = ESE.ManualOpenTableW( + this._sessionId, + this._dbId, + tableName, + null, + 0, + 4 /* JET_bitTableReadOnly */, + tableId.address() + ); + if (rv == -1305 /* JET_errObjectNotFound */) { + return false; + } + if (rv < 0) { + lazy.log.error("Got error " + rv + " calling OpenTableW"); + throw new Error(convertESEError(rv)); + } + + if (rv > 0) { + lazy.log.error("Got warning " + rv + " calling OpenTableW"); + } + ESE.FailSafeCloseTable(this._sessionId, tableId); + return true; + }, + + *tableItems(tableName, columns) { + if (!this._opened) { + throw new Error("The database was closed!"); + } + + let tableOpened = false; + let tableId; + try { + tableId = this._openTable(tableName); + tableOpened = true; + + let columnInfo = this._getColumnInfo(tableName, columns); + + let rv = ESE.ManualMove( + this._sessionId, + tableId, + -2147483648 /* JET_MoveFirst */, + 0 + ); + if (rv == -1603 /* JET_errNoCurrentRecord */) { + // There are no rows in the table. + this._closeTable(tableId); + return; + } + if (rv != 0) { + throw new Error(convertESEError(rv)); + } + + do { + let rowContents = {}; + for (let column of columnInfo) { + let [buffer, bufferSize] = this._getBufferForColumn(column); + // We handle errors manually so we accurately deal with NULL values. + let err = ESE.ManualRetrieveColumn( + this._sessionId, + tableId, + column.id, + buffer.address(), + bufferSize, + null, + 0, + null + ); + rowContents[column.name] = this._convertResult(column, buffer, err); + } + yield rowContents; + } while ( + ESE.ManualMove(this._sessionId, tableId, 1 /* JET_MoveNext */, 0) === 0 + ); + } catch (ex) { + if (tableOpened) { + this._closeTable(tableId); + } + throw ex; + } + this._closeTable(tableId); + }, + + _openTable(tableName) { + let tableId = new ESE.JET_TABLEID(); + ESE.OpenTableW( + this._sessionId, + this._dbId, + tableName, + null, + 0, + 4 /* JET_bitTableReadOnly */, + tableId.address() + ); + return tableId; + }, + + _getBufferForColumn(column) { + let buffer; + if (column.type == "string") { + let wchar_tArray = ctypes.ArrayType(ctypes.char16_t); + // size on the column is in bytes, 2 bytes to a wchar, so: + let charCount = column.dbSize >> 1; + buffer = new wchar_tArray(charCount); + } else if (column.type == "boolean") { + buffer = new ctypes.uint8_t(); + } else if (column.type == "date") { + buffer = new KERNEL.FILETIME(); + } else if (column.type == "guid") { + let byteArray = ctypes.ArrayType(ctypes.uint8_t); + buffer = new byteArray(column.dbSize); + } else { + throw new Error("Unknown type " + column.type); + } + return [buffer, buffer.constructor.size]; + }, + + _convertResult(column, buffer, err) { + if (err != 0) { + if (err == 1004) { + // Deal with null values: + buffer = null; + } else { + console.error( + "Unexpected JET error: ", + err, + "; retrieving value for column ", + column.name + ); + throw new Error(convertESEError(err)); + } + } + if (column.type == "string") { + return buffer ? buffer.readString() : ""; + } + if (column.type == "boolean") { + return buffer ? buffer.value == 255 : false; + } + if (column.type == "guid") { + if (buffer.length != 16) { + console.error( + "Buffer size for guid field ", + column.id, + " should have been 16!" + ); + return ""; + } + let rv = "{"; + for (let i = 0; i < 16; i++) { + if (i == 4 || i == 6 || i == 8 || i == 10) { + rv += "-"; + } + let byteValue = buffer.addressOfElement(i).contents; + // Ensure there's a leading 0 + rv += ("0" + byteValue.toString(16)).substr(-2); + } + return rv + "}"; + } + if (column.type == "date") { + if (!buffer) { + return null; + } + let systemTime = new KERNEL.SYSTEMTIME(); + let result = KERNEL.FileTimeToSystemTime( + buffer.address(), + systemTime.address() + ); + if (result == 0) { + throw new Error(ctypes.winLastError); + } + + // System time is in UTC, so we use Date.UTC to get milliseconds from epoch, + // then divide by 1000 to get seconds, and round down: + return new Date( + Date.UTC( + systemTime.wYear, + systemTime.wMonth - 1, + systemTime.wDay, + systemTime.wHour, + systemTime.wMinute, + systemTime.wSecond, + systemTime.wMilliseconds + ) + ); + } + return undefined; + }, + + _getColumnInfo(tableName, columns) { + let rv = []; + for (let column of columns) { + let columnInfoFromDB = new ESE.JET_COLUMNDEF(); + ESE.GetColumnInfoW( + this._sessionId, + this._dbId, + tableName, + column.name, + columnInfoFromDB.address(), + ESE.JET_COLUMNDEF.size, + 0 /* JET_ColInfo */ + ); + let dbType = parseInt(columnInfoFromDB.coltyp.toString(10), 10); + let dbSize = parseInt(columnInfoFromDB.cbMax.toString(10), 10); + if (column.type == "string") { + if ( + dbType != COLUMN_TYPES.JET_coltypLongText && + dbType != COLUMN_TYPES.JET_coltypText + ) { + throw new Error( + "Invalid column type for column " + + column.name + + "; expected text type, got type " + + getColTypeName(dbType) + ); + } + if (dbSize > MAX_STR_LENGTH) { + throw new Error( + "Column " + + column.name + + " has more than 64k data in it. This API is not designed to handle data that large." + ); + } + } else if (column.type == "boolean") { + if (dbType != COLUMN_TYPES.JET_coltypBit) { + throw new Error( + "Invalid column type for column " + + column.name + + "; expected bit type, got type " + + getColTypeName(dbType) + ); + } + } else if (column.type == "date") { + if (dbType != COLUMN_TYPES.JET_coltypLongLong) { + throw new Error( + "Invalid column type for column " + + column.name + + "; expected long long type, got type " + + getColTypeName(dbType) + ); + } + } else if (column.type == "guid") { + if (dbType != COLUMN_TYPES.JET_coltypGUID) { + throw new Error( + "Invalid column type for column " + + column.name + + "; expected guid type, got type " + + getColTypeName(dbType) + ); + } + } else if (column.type) { + throw new Error( + "Unknown column type " + + column.type + + " requested for column " + + column.name + + ", don't know what to do." + ); + } + + rv.push({ + name: column.name, + id: columnInfoFromDB.columnid, + type: column.type, + dbSize, + dbType, + }); + } + return rv; + }, + + _closeTable(tableId) { + ESE.FailSafeCloseTable(this._sessionId, tableId); + }, + + _close() { + this._internalClose(); + gOpenDBs.delete(this.dbPath); + }, + + _internalClose() { + if (this._opened) { + lazy.log.debug("close db"); + ESE.FailSafeCloseDatabase(this._sessionId, this._dbId, 0); + lazy.log.debug("finished close db"); + this._opened = false; + } + if (this._attached) { + lazy.log.debug("detach db"); + ESE.FailSafeDetachDatabaseW(this._sessionId, this.dbPath); + this._attached = false; + } + if (this._sessionCreated) { + lazy.log.debug("end session"); + ESE.FailSafeEndSession(this._sessionId, 0); + this._sessionCreated = false; + } + if (this._instanceCreated) { + lazy.log.debug("term"); + ESE.FailSafeTerm(this._instanceId); + this._instanceCreated = false; + } + }, + + incrementReferenceCounter() { + this._references++; + }, + + decrementReferenceCounter() { + this._references--; + if (this._references <= 0) { + this._close(); + } + }, +}; + +export let ESEDBReader = { + openDB(rootDir, dbFile, logDir) { + let dbFilePath = dbFile.path; + if (gOpenDBs.has(dbFilePath)) { + let db = gOpenDBs.get(dbFilePath); + db.incrementReferenceCounter(); + return db; + } + // ESE is really picky about the trailing slashes according to the docs, + // so we do as we're told and ensure those are there: + return new ESEDB(rootDir.path + "\\", dbFilePath, logDir.path + "\\"); + }, + + async dbLocked(dbFile) { + const utils = Cc[ + "@mozilla.org/profile/migrator/edgemigrationutils;1" + ].createInstance(Ci.nsIEdgeMigrationUtils); + + const locked = await utils.isDbLocked(dbFile); + + if (locked) { + console.error(`ESE DB at ${dbFile.path} is locked.`); + } + + return locked; + }, + + closeDB(db) { + db.decrementReferenceCounter(); + }, + + COLUMN_TYPES, +}; diff --git a/browser/components/migration/EdgeProfileMigrator.sys.mjs b/browser/components/migration/EdgeProfileMigrator.sys.mjs new file mode 100644 index 0000000000..d495727ec9 --- /dev/null +++ b/browser/components/migration/EdgeProfileMigrator.sys.mjs @@ -0,0 +1,582 @@ +/* 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 { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; +import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; +import { MSMigrationUtils } from "resource:///modules/MSMigrationUtils.sys.mjs"; + +const EDGE_COOKIE_PATH_OPTIONS = ["", "#!001\\", "#!002\\"]; +const EDGE_COOKIES_SUFFIX = "MicrosoftEdge\\Cookies"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + ESEDBReader: "resource:///modules/ESEDBReader.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +const kEdgeRegistryRoot = + "SOFTWARE\\Classes\\Local Settings\\Software\\" + + "Microsoft\\Windows\\CurrentVersion\\AppContainer\\Storage\\" + + "microsoft.microsoftedge_8wekyb3d8bbwe\\MicrosoftEdge"; +const kEdgeDatabasePath = "AC\\MicrosoftEdge\\User\\Default\\DataStore\\Data\\"; + +ChromeUtils.defineLazyGetter(lazy, "gEdgeDatabase", function () { + let edgeDir = MSMigrationUtils.getEdgeLocalDataFolder(); + if (!edgeDir) { + return null; + } + edgeDir.appendRelativePath(kEdgeDatabasePath); + if (!edgeDir.exists() || !edgeDir.isReadable() || !edgeDir.isDirectory()) { + return null; + } + let expectedLocation = edgeDir.clone(); + expectedLocation.appendRelativePath( + "nouser1\\120712-0049\\DBStore\\spartan.edb" + ); + if ( + expectedLocation.exists() && + expectedLocation.isReadable() && + expectedLocation.isFile() + ) { + expectedLocation.normalize(); + return expectedLocation; + } + // We used to recurse into arbitrary subdirectories here, but that code + // went unused, so it likely isn't necessary, even if we don't understand + // where the magic folders above come from, they seem to be the same for + // everyone. Just return null if they're not there: + return null; +}); + +/** + * Get rows from a table in the Edge DB as an array of JS objects. + * + * @param {string} tableName the name of the table to read. + * @param {string[]|Function} columns a list of column specifiers + * (see ESEDBReader.jsm) or a function that + * generates them based on the database + * reference once opened. + * @param {nsIFile} dbFile the database file to use. Defaults to + * the main Edge database. + * @param {Function} filterFn Optional. A function that is called for each row. + * Only rows for which it returns a truthy + * value are included in the result. + * @returns {Array} An array of row objects. + */ +function readTableFromEdgeDB( + tableName, + columns, + dbFile = lazy.gEdgeDatabase, + filterFn = null +) { + let database; + let rows = []; + try { + let logFile = dbFile.parent; + logFile.append("LogFiles"); + database = lazy.ESEDBReader.openDB(dbFile.parent, dbFile, logFile); + + if (typeof columns == "function") { + columns = columns(database); + } + + let tableReader = database.tableItems(tableName, columns); + for (let row of tableReader) { + if (!filterFn || filterFn(row)) { + rows.push(row); + } + } + } catch (ex) { + console.error( + "Failed to extract items from table ", + tableName, + " in Edge database at ", + dbFile.path, + " due to the following error: ", + ex + ); + // Deliberately make this fail so we expose failure in the UI: + throw ex; + } finally { + if (database) { + lazy.ESEDBReader.closeDB(database); + } + } + return rows; +} + +function EdgeTypedURLMigrator() {} + +EdgeTypedURLMigrator.prototype = { + type: MigrationUtils.resourceTypes.HISTORY, + + get _typedURLs() { + if (!this.__typedURLs) { + this.__typedURLs = MSMigrationUtils.getTypedURLs(kEdgeRegistryRoot); + } + return this.__typedURLs; + }, + + get exists() { + return this._typedURLs.size > 0; + }, + + migrate(aCallback) { + let typedURLs = this._typedURLs; + let pageInfos = []; + let now = new Date(); + let maxDate = new Date( + Date.now() - MigrationUtils.HISTORY_MAX_AGE_IN_MILLISECONDS + ); + + for (let [urlString, time] of typedURLs) { + let visitDate = time ? lazy.PlacesUtils.toDate(time) : now; + if (time && visitDate < maxDate) { + continue; + } + + let url; + try { + url = new URL(urlString); + if (!["http:", "https:", "ftp:"].includes(url.protocol)) { + continue; + } + } catch (ex) { + console.error(ex); + continue; + } + + pageInfos.push({ + url, + visits: [ + { + transition: lazy.PlacesUtils.history.TRANSITIONS.TYPED, + date: time ? lazy.PlacesUtils.toDate(time) : new Date(), + }, + ], + }); + } + + if (!pageInfos.length) { + aCallback(typedURLs.size == 0); + return; + } + + MigrationUtils.insertVisitsWrapper(pageInfos).then( + () => aCallback(true), + () => aCallback(false) + ); + }, +}; + +function EdgeTypedURLDBMigrator(dbOverride) { + this.dbOverride = dbOverride; +} + +EdgeTypedURLDBMigrator.prototype = { + type: MigrationUtils.resourceTypes.HISTORY, + + get db() { + return this.dbOverride || lazy.gEdgeDatabase; + }, + + get exists() { + return !!this.db; + }, + + migrate(callback) { + this._migrateTypedURLsFromDB().then( + () => callback(true), + ex => { + console.error(ex); + callback(false); + } + ); + }, + + async _migrateTypedURLsFromDB() { + if (await lazy.ESEDBReader.dbLocked(this.db)) { + throw new Error("Edge seems to be running - its database is locked."); + } + let columns = [ + { name: "URL", type: "string" }, + { name: "AccessDateTimeUTC", type: "date" }, + ]; + + let typedUrls = []; + try { + typedUrls = readTableFromEdgeDB("TypedUrls", columns, this.db); + } catch (ex) { + // Maybe the table doesn't exist (older versions of Win10). + // Just fall through and we'll return because there's no data. + // The `readTableFromEdgeDB` helper will report errors to the + // console anyway. + } + if (!typedUrls.length) { + return; + } + + let pageInfos = []; + + const kDateCutOff = new Date( + Date.now() - MigrationUtils.HISTORY_MAX_AGE_IN_MILLISECONDS + ); + for (let typedUrlInfo of typedUrls) { + try { + let date = typedUrlInfo.AccessDateTimeUTC; + if (!date) { + date = kDateCutOff; + } else if (date < kDateCutOff) { + continue; + } + + let url = new URL(typedUrlInfo.URL); + if (!["http:", "https:", "ftp:"].includes(url.protocol)) { + continue; + } + + pageInfos.push({ + url, + visits: [ + { + transition: lazy.PlacesUtils.history.TRANSITIONS.TYPED, + date, + }, + ], + }); + } catch (ex) { + console.error(ex); + } + } + await MigrationUtils.insertVisitsWrapper(pageInfos); + }, +}; + +function EdgeReadingListMigrator(dbOverride) { + this.dbOverride = dbOverride; +} + +EdgeReadingListMigrator.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + get db() { + return this.dbOverride || lazy.gEdgeDatabase; + }, + + get exists() { + return !!this.db; + }, + + migrate(callback) { + this._migrateReadingList(lazy.PlacesUtils.bookmarks.menuGuid).then( + () => callback(true), + ex => { + console.error(ex); + callback(false); + } + ); + }, + + async _migrateReadingList(parentGuid) { + if (await lazy.ESEDBReader.dbLocked(this.db)) { + throw new Error("Edge seems to be running - its database is locked."); + } + let columnFn = db => { + let columns = [ + { name: "URL", type: "string" }, + { name: "Title", type: "string" }, + { name: "AddedDate", type: "date" }, + ]; + + // Later versions have an IsDeleted column: + let isDeletedColumn = db.checkForColumn("ReadingList", "IsDeleted"); + if ( + isDeletedColumn && + isDeletedColumn.dbType == lazy.ESEDBReader.COLUMN_TYPES.JET_coltypBit + ) { + columns.push({ name: "IsDeleted", type: "boolean" }); + } + return columns; + }; + + let filterFn = row => { + return !row.IsDeleted; + }; + + let readingListItems = readTableFromEdgeDB( + "ReadingList", + columnFn, + this.db, + filterFn + ); + if (!readingListItems.length) { + return; + } + + let destFolderGuid = await this._ensureReadingListFolder(parentGuid); + let bookmarks = []; + for (let item of readingListItems) { + let dateAdded = item.AddedDate || new Date(); + // Avoid including broken URLs: + try { + new URL(item.URL); + } catch (ex) { + continue; + } + bookmarks.push({ url: item.URL, title: item.Title, dateAdded }); + } + await MigrationUtils.insertManyBookmarksWrapper(bookmarks, destFolderGuid); + }, + + async _ensureReadingListFolder(parentGuid) { + if (!this.__readingListFolderGuid) { + let folderTitle = await MigrationUtils.getLocalizedString( + "migration-imported-edge-reading-list" + ); + let folderSpec = { + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid, + title: folderTitle, + }; + this.__readingListFolderGuid = ( + await MigrationUtils.insertBookmarkWrapper(folderSpec) + ).guid; + } + return this.__readingListFolderGuid; + }, +}; + +function EdgeBookmarksMigrator(dbOverride) { + this.dbOverride = dbOverride; +} + +EdgeBookmarksMigrator.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + get db() { + return this.dbOverride || lazy.gEdgeDatabase; + }, + + get TABLE_NAME() { + return "Favorites"; + }, + + get exists() { + if (!("_exists" in this)) { + this._exists = !!this.db; + } + return this._exists; + }, + + migrate(callback) { + this._migrateBookmarks().then( + () => callback(true), + ex => { + console.error(ex); + callback(false); + } + ); + }, + + async _migrateBookmarks() { + if (await lazy.ESEDBReader.dbLocked(this.db)) { + throw new Error("Edge seems to be running - its database is locked."); + } + let { toplevelBMs, toolbarBMs } = this._fetchBookmarksFromDB(); + if (toplevelBMs.length) { + let parentGuid = lazy.PlacesUtils.bookmarks.menuGuid; + await MigrationUtils.insertManyBookmarksWrapper(toplevelBMs, parentGuid); + } + if (toolbarBMs.length) { + let parentGuid = lazy.PlacesUtils.bookmarks.toolbarGuid; + await MigrationUtils.insertManyBookmarksWrapper(toolbarBMs, parentGuid); + } + }, + + _fetchBookmarksFromDB() { + let folderMap = new Map(); + let columns = [ + { name: "URL", type: "string" }, + { name: "Title", type: "string" }, + { name: "DateUpdated", type: "date" }, + { name: "IsFolder", type: "boolean" }, + { name: "IsDeleted", type: "boolean" }, + { name: "ParentId", type: "guid" }, + { name: "ItemId", type: "guid" }, + ]; + let filterFn = row => { + if (row.IsDeleted) { + return false; + } + if (row.IsFolder) { + folderMap.set(row.ItemId, row); + } + return true; + }; + let bookmarks = readTableFromEdgeDB( + this.TABLE_NAME, + columns, + this.db, + filterFn + ); + let toplevelBMs = [], + toolbarBMs = []; + for (let bookmark of bookmarks) { + let bmToInsert; + // Ignore invalid URLs: + if (!bookmark.IsFolder) { + try { + new URL(bookmark.URL); + } catch (ex) { + console.error( + `Ignoring ${bookmark.URL} when importing from Edge because of exception: ${ex}` + ); + continue; + } + bmToInsert = { + dateAdded: bookmark.DateUpdated || new Date(), + title: bookmark.Title, + url: bookmark.URL, + }; + } /* bookmark.IsFolder */ else { + // Ignore the favorites bar bookmark itself. + if (bookmark.Title == "_Favorites_Bar_") { + continue; + } + if (!bookmark._childrenRef) { + bookmark._childrenRef = []; + } + bmToInsert = { + title: bookmark.Title, + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + dateAdded: bookmark.DateUpdated || new Date(), + children: bookmark._childrenRef, + }; + } + + if (!folderMap.has(bookmark.ParentId)) { + toplevelBMs.push(bmToInsert); + } else { + let parent = folderMap.get(bookmark.ParentId); + if (parent.Title == "_Favorites_Bar_") { + toolbarBMs.push(bmToInsert); + continue; + } + if (!parent._childrenRef) { + parent._childrenRef = []; + } + parent._childrenRef.push(bmToInsert); + } + } + return { toplevelBMs, toolbarBMs }; + }, +}; + +function getCookiesPaths() { + let folders = []; + let edgeDir = MSMigrationUtils.getEdgeLocalDataFolder(); + if (edgeDir) { + edgeDir.append("AC"); + for (let path of EDGE_COOKIE_PATH_OPTIONS) { + let folder = edgeDir.clone(); + let fullPath = path + EDGE_COOKIES_SUFFIX; + folder.appendRelativePath(fullPath); + if (folder.exists() && folder.isReadable() && folder.isDirectory()) { + folders.push(fullPath); + } + } + } + return folders; +} + +/** + * Edge (EdgeHTML) profile migrator + */ +export class EdgeProfileMigrator extends MigratorBase { + static get key() { + return "edge"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-edge-legacy"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/edge.png"; + } + + getBookmarksMigratorForTesting(dbOverride) { + return new EdgeBookmarksMigrator(dbOverride); + } + + getReadingListMigratorForTesting(dbOverride) { + return new EdgeReadingListMigrator(dbOverride); + } + + getHistoryDBMigratorForTesting(dbOverride) { + return new EdgeTypedURLDBMigrator(dbOverride); + } + + getHistoryRegistryMigratorForTesting() { + return new EdgeTypedURLMigrator(); + } + + getResources() { + let resources = [ + new EdgeBookmarksMigrator(), + new EdgeTypedURLMigrator(), + new EdgeTypedURLDBMigrator(), + new EdgeReadingListMigrator(), + ]; + let windowsVaultFormPasswordsMigrator = + MSMigrationUtils.getWindowsVaultFormPasswordsMigrator(); + windowsVaultFormPasswordsMigrator.name = "EdgeVaultFormPasswords"; + resources.push(windowsVaultFormPasswordsMigrator); + return resources.filter(r => r.exists); + } + + async getLastUsedDate() { + // Don't do this if we don't have a single profile (see the comment for + // sourceProfiles) or if we can't find the database file: + let sourceProfiles = await this.getSourceProfiles(); + if (sourceProfiles !== null || !lazy.gEdgeDatabase) { + return Promise.resolve(new Date(0)); + } + let logFilePath = PathUtils.join( + lazy.gEdgeDatabase.parent.path, + "LogFiles", + "edb.log" + ); + let dbPath = lazy.gEdgeDatabase.path; + let datePromises = [logFilePath, dbPath, ...getCookiesPaths()].map(path => { + return IOUtils.stat(path) + .then(info => info.lastModified) + .catch(() => 0); + }); + datePromises.push( + new Promise(resolve => { + let typedURLs = new Map(); + try { + typedURLs = MSMigrationUtils.getTypedURLs(kEdgeRegistryRoot); + } catch (ex) {} + let times = [0, ...typedURLs.values()]; + // dates is an array of PRTimes, which are in microseconds - convert to milliseconds + resolve(Math.max.apply(Math, times) / 1000); + }) + ); + return Promise.all(datePromises).then(dates => { + return new Date(Math.max.apply(Math, dates)); + }); + } + + /** + * @returns {Array|null} + * Somewhat counterintuitively, this returns + * |null| to indicate "There is only 1 (default) profile". + * See MigrationUtils.sys.mjs for slightly more info on how sourceProfiles is used. + */ + getSourceProfiles() { + return null; + } +} diff --git a/browser/components/migration/FileMigrators.sys.mjs b/browser/components/migration/FileMigrators.sys.mjs new file mode 100644 index 0000000000..3384011c13 --- /dev/null +++ b/browser/components/migration/FileMigrators.sys.mjs @@ -0,0 +1,359 @@ +/* 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, { + BookmarkHTMLUtils: "resource://gre/modules/BookmarkHTMLUtils.sys.mjs", + BookmarkJSONUtils: "resource://gre/modules/BookmarkJSONUtils.sys.mjs", + LoginCSVImport: "resource://gre/modules/LoginCSVImport.sys.mjs", + MigrationWizardConstants: + "chrome://browser/content/migration/migration-wizard-constants.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "gFluentStrings", function () { + return new Localization([ + "branding/brand.ftl", + "browser/migrationWizard.ftl", + ]); +}); + +/** + * Base class for a migration that involves reading a single file off of + * the disk that the user picks using a file picker. The file might be + * generated by another browser or some other application. + */ +export class FileMigratorBase { + /** + * This must be overridden to return a simple string identifier for the + * migrator, for example "password-csv". This key is what + * is used as an identifier when calling MigrationUtils.getFileMigrator. + * + * @type {string} + */ + static get key() { + throw new Error("FileMigrator.key must be overridden."); + } + + /** + * This must be overridden to return a Fluent string ID mapping to the display + * name for this migrator. These strings should be defined in migrationWizard.ftl. + * + * @type {string} + */ + static get displayNameL10nID() { + throw new Error("FileMigrator.displayNameL10nID must be overridden."); + } + + /** + * This getter should get overridden to return an icon url to represent the + * file to be imported from. By default, this will just use the default Favicon + * image. + * + * @type {string} + */ + static get brandImage() { + return "chrome://global/skin/icons/defaultFavicon.svg"; + } + + /** + * Returns true if the migrator is configured to be enabled. + * + * @type {boolean} + * true if the migrator should be shown in the migration wizard. + */ + get enabled() { + throw new Error("FileMigrator.enabled must be overridden."); + } + + /** + * This getter should be overridden to return a Fluent string ID for what + * the migration wizard header should be while the file migration is + * underway. + * + * @type {string} + */ + get progressHeaderL10nID() { + throw new Error("FileMigrator.progressHeaderL10nID must be overridden."); + } + + /** + * This getter should be overridden to return a Fluent string ID for what + * the migration wizard header should be while the file migration is + * done. + * + * @type {string} + */ + get successHeaderL10nID() { + throw new Error("FileMigrator.progressHeaderL10nID must be overridden."); + } + + /** + * @typedef {object} FilePickerConfiguration + * @property {string} title + * The title that should be assigned to the native file picker window. + * @property {FilePickerConfigurationFilter[]} filters + * One or more extension filters that should be applied to the native + * file picker window to make selection easier. + */ + + /** + * @typedef {object} FilePickerConfigurationFilter + * @property {string} title + * The title for the filter. Example: "CSV Files" + * @property {string} extensionPattern + * A matching pattern for the filter. Example: "*.csv" + */ + + /** + * A subclass of FileMigratorBase will eventually open a native file picker + * for the user to select the file from their file system. + * + * Subclasses need to override this method in order to configure the + * native file picker. + * + * @returns {Promise} + */ + async getFilePickerConfig() { + throw new Error("FileMigrator.getFilePickerConfig must be overridden."); + } + + /** + * Returns a list of one or more resource types that should appear to be + * in progress of migrating while the file migration occurs. Notably, + * this does not need to match the resource types that are returned by + * `FileMigratorBase.migrate`. + * + * @type {string[]} + * An array of resource types from the + * MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES set. + */ + get displayedResourceTypes() { + throw new Error("FileMigrator.displayedResourceTypes must be overridden"); + } + + /** + * Called to perform the file migration once the user makes a selection + * from the native file picker. This will not be called if the user + * chooses to cancel the native file picker. + * + * @param {string} filePath + * The path that the user selected from the native file picker. + */ + // eslint-disable-next-line no-unused-vars + async migrate(filePath) { + throw new Error("FileMigrator.migrate must be overridden."); + } +} + +/** + * A file migrator for importing passwords from CSV or TSV files. CSV + * files are more common, so this is what we show as the file type for + * the display name, but this FileMigrator accepts both. + */ +export class PasswordFileMigrator extends FileMigratorBase { + static get key() { + return "file-password-csv"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-file-password-csv"; + } + + static get brandImage() { + return "chrome://branding/content/document.ico"; + } + + get enabled() { + return Services.prefs.getBoolPref( + "signon.management.page.fileImport.enabled", + false + ); + } + + get displayedResourceTypes() { + return [ + lazy.MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .PASSWORDS_FROM_FILE, + ]; + } + + get progressHeaderL10nID() { + return "migration-passwords-from-file-progress-header"; + } + + get successHeaderL10nID() { + return "migration-passwords-from-file-success-header"; + } + + async getFilePickerConfig() { + let [title, csvFilterTitle, tsvFilterTitle] = + await lazy.gFluentStrings.formatValues([ + { id: "migration-passwords-from-file-picker-title" }, + { id: "migration-passwords-from-file-csv-filter-title" }, + { id: "migration-passwords-from-file-tsv-filter-title" }, + ]); + + return { + title, + filters: [ + { + title: csvFilterTitle, + extensionPattern: "*.csv", + }, + { + title: tsvFilterTitle, + extensionPattern: "*.tsv", + }, + ], + }; + } + + async migrate(filePath) { + try { + let summary = await lazy.LoginCSVImport.importFromCSV(filePath); + let newEntries = 0; + let updatedEntries = 0; + for (let entry of summary) { + if (entry.result == "added") { + newEntries++; + } else if (entry.result == "modified") { + updatedEntries++; + } + } + let [newMessage, updatedMessage] = await lazy.gFluentStrings.formatValues( + [ + { + id: "migration-wizard-progress-success-new-passwords", + args: { newEntries }, + }, + { + id: "migration-wizard-progress-success-updated-passwords", + args: { updatedEntries }, + }, + ] + ); + + Services.prefs.setBoolPref( + "browser.migrate.interactions.csvpasswords", + true + ); + + return { + [lazy.MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .PASSWORDS_NEW]: newMessage, + [lazy.MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .PASSWORDS_UPDATED]: updatedMessage, + }; + } catch (e) { + console.error(e); + + let errorMessage = await lazy.gFluentStrings.formatValue( + "migration-passwords-from-file-no-valid-data" + ); + throw new Error(errorMessage); + } + } +} + +/** + * A file migrator for importing bookmarks from a HTML or JSON file. + * + * @class BookmarksFileMigrator + * @augments {FileMigratorBase} + */ +export class BookmarksFileMigrator extends FileMigratorBase { + static get key() { + return "file-bookmarks"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-file-bookmarks"; + } + + static get brandImage() { + return "chrome://branding/content/document.ico"; + } + + get enabled() { + return Services.prefs.getBoolPref( + "browser.migrate.bookmarks-file.enabled", + false + ); + } + + get displayedResourceTypes() { + return [ + lazy.MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .BOOKMARKS_FROM_FILE, + ]; + } + + get progressHeaderL10nID() { + return "migration-bookmarks-from-file-progress-header"; + } + + get successHeaderL10nID() { + return "migration-bookmarks-from-file-success-header"; + } + + async getFilePickerConfig() { + let [title, htmlFilterTitle, jsonFilterTitle] = + await lazy.gFluentStrings.formatValues([ + { id: "migration-bookmarks-from-file-picker-title" }, + { id: "migration-bookmarks-from-file-html-filter-title" }, + { id: "migration-bookmarks-from-file-json-filter-title" }, + ]); + + return { + title, + filters: [ + { + title: htmlFilterTitle, + extensionPattern: "*.html", + }, + { + title: jsonFilterTitle, + extensionPattern: "*.json", + }, + ], + }; + } + + async migrate(filePath) { + try { + let pathCheck = filePath.toLowerCase(); + let importedCount; + + if (pathCheck.endsWith("html")) { + importedCount = await lazy.BookmarkHTMLUtils.importFromFile(filePath); + } else if (pathCheck.endsWith("json") || pathCheck.endsWith("jsonlz4")) { + importedCount = await lazy.BookmarkJSONUtils.importFromFile(filePath); + } + + if (!importedCount) { + // The catch will cause us to show a default error message. + throw new Error(); + } + + let importedMessage = await lazy.gFluentStrings.formatValue( + "migration-wizard-progress-success-new-bookmarks", + { + newEntries: importedCount, + } + ); + return { + [lazy.MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .BOOKMARKS_FROM_FILE]: importedMessage, + }; + } catch (e) { + console.error(e); + + let errorMessage = await lazy.gFluentStrings.formatValue( + "migration-bookmarks-from-file-no-valid-data" + ); + throw new Error(errorMessage); + } + } +} diff --git a/browser/components/migration/FirefoxProfileMigrator.sys.mjs b/browser/components/migration/FirefoxProfileMigrator.sys.mjs new file mode 100644 index 0000000000..27a15b65fb --- /dev/null +++ b/browser/components/migration/FirefoxProfileMigrator.sys.mjs @@ -0,0 +1,400 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sw=2 ts=2 sts=2 et */ +/* 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/. */ + +/* + * Migrates from a Firefox profile in a lossy manner in order to clean up a + * user's profile. Data is only migrated where the benefits outweigh the + * potential problems caused by importing undesired/invalid configurations + * from the source profile. + */ + +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; + +import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + PlacesBackups: "resource://gre/modules/PlacesBackups.sys.mjs", + ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", + SessionMigration: "resource:///modules/sessionstore/SessionMigration.sys.mjs", +}); + +/** + * Firefox profile migrator. Currently, this class only does "pave over" + * migrations, where various parts of an old profile overwrite a new + * profile. This is distinct from other migrators which attempt to import + * old profile data into the existing profile. + * + * This migrator is what powers the "Profile Refresh" mechanism. + */ +export class FirefoxProfileMigrator extends MigratorBase { + static get key() { + return "firefox"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-firefox"; + } + + static get brandImage() { + return "chrome://branding/content/icon128.png"; + } + + _getAllProfiles() { + let allProfiles = new Map(); + let profileService = Cc[ + "@mozilla.org/toolkit/profile-service;1" + ].getService(Ci.nsIToolkitProfileService); + for (let profile of profileService.profiles) { + let rootDir = profile.rootDir; + + if ( + rootDir.exists() && + rootDir.isReadable() && + !rootDir.equals(MigrationUtils.profileStartup.directory) + ) { + allProfiles.set(profile.name, rootDir); + } + } + return allProfiles; + } + + getSourceProfiles() { + let sorter = (a, b) => { + return a.id.toLocaleLowerCase().localeCompare(b.id.toLocaleLowerCase()); + }; + + return [...this._getAllProfiles().keys()] + .map(x => ({ id: x, name: x })) + .sort(sorter); + } + + _getFileObject(dir, fileName) { + let file = dir.clone(); + file.append(fileName); + + // File resources are monolithic. We don't make partial copies since + // they are not expected to work alone. Return null to avoid trying to + // copy non-existing files. + return file.exists() ? file : null; + } + + getResources(aProfile) { + let sourceProfileDir = aProfile + ? this._getAllProfiles().get(aProfile.id) + : Cc["@mozilla.org/toolkit/profile-service;1"].getService( + Ci.nsIToolkitProfileService + ).defaultProfile.rootDir; + if ( + !sourceProfileDir || + !sourceProfileDir.exists() || + !sourceProfileDir.isReadable() + ) { + return null; + } + + // Being a startup-only migrator, we can rely on + // MigrationUtils.profileStartup being set. + let currentProfileDir = MigrationUtils.profileStartup.directory; + + // Surely data cannot be imported from the current profile. + if (sourceProfileDir.equals(currentProfileDir)) { + return null; + } + + return this._getResourcesInternal(sourceProfileDir, currentProfileDir); + } + + getLastUsedDate() { + // We always pretend we're really old, so that we don't mess + // up the determination of which browser is the most 'recent' + // to import from. + return Promise.resolve(new Date(0)); + } + + _getResourcesInternal(sourceProfileDir, currentProfileDir) { + let getFileResource = (aMigrationType, aFileNames) => { + let files = []; + for (let fileName of aFileNames) { + let file = this._getFileObject(sourceProfileDir, fileName); + if (file) { + files.push(file); + } + } + if (!files.length) { + return null; + } + return { + type: aMigrationType, + migrate(aCallback) { + for (let file of files) { + file.copyTo(currentProfileDir, ""); + } + aCallback(true); + }, + }; + }; + + let _oldRawPrefsMemoized = null; + async function readOldPrefs() { + if (!_oldRawPrefsMemoized) { + let prefsPath = PathUtils.join(sourceProfileDir.path, "prefs.js"); + if (await IOUtils.exists(prefsPath)) { + _oldRawPrefsMemoized = await IOUtils.readUTF8(prefsPath, { + encoding: "utf-8", + }); + } + } + + return _oldRawPrefsMemoized; + } + + function savePrefs() { + // If we've used the pref service to write prefs for the new profile, it's too + // early in startup for the service to have a profile directory, so we have to + // manually tell it where to save the prefs file. + let newPrefsFile = currentProfileDir.clone(); + newPrefsFile.append("prefs.js"); + Services.prefs.savePrefFile(newPrefsFile); + } + + let types = MigrationUtils.resourceTypes; + let places = getFileResource(types.HISTORY, [ + "places.sqlite", + "places.sqlite-wal", + ]); + let favicons = getFileResource(types.HISTORY, [ + "favicons.sqlite", + "favicons.sqlite-wal", + ]); + let cookies = getFileResource(types.COOKIES, [ + "cookies.sqlite", + "cookies.sqlite-wal", + ]); + let passwords = getFileResource(types.PASSWORDS, [ + "logins.json", + "key3.db", + "key4.db", + ]); + let formData = getFileResource(types.FORMDATA, [ + "formhistory.sqlite", + "autofill-profiles.json", + ]); + let bookmarksBackups = getFileResource(types.OTHERDATA, [ + lazy.PlacesBackups.profileRelativeFolderPath, + ]); + let dictionary = getFileResource(types.OTHERDATA, ["persdict.dat"]); + + let session; + if (Services.env.get("MOZ_RESET_PROFILE_MIGRATE_SESSION")) { + // We only want to restore the previous firefox session if the profile refresh was + // triggered by user. The MOZ_RESET_PROFILE_MIGRATE_SESSION would be set when a user-triggered + // profile refresh happened in nsAppRunner.cpp. Hence, we detect the MOZ_RESET_PROFILE_MIGRATE_SESSION + // to see if session data migration is required. + Services.env.set("MOZ_RESET_PROFILE_MIGRATE_SESSION", ""); + let sessionCheckpoints = this._getFileObject( + sourceProfileDir, + "sessionCheckpoints.json" + ); + let sessionFile = this._getFileObject( + sourceProfileDir, + "sessionstore.jsonlz4" + ); + if (sessionFile) { + session = { + type: types.SESSION, + migrate(aCallback) { + sessionCheckpoints.copyTo( + currentProfileDir, + "sessionCheckpoints.json" + ); + let newSessionFile = currentProfileDir.clone(); + newSessionFile.append("sessionstore.jsonlz4"); + let migrationPromise = lazy.SessionMigration.migrate( + sessionFile.path, + newSessionFile.path + ); + migrationPromise.then( + function () { + let buildID = Services.appinfo.platformBuildID; + let mstone = Services.appinfo.platformVersion; + // Force the browser to one-off resume the session that we give it: + Services.prefs.setBoolPref( + "browser.sessionstore.resume_session_once", + true + ); + // Reset the homepage_override prefs so that the browser doesn't override our + // session with the "what's new" page: + Services.prefs.setCharPref( + "browser.startup.homepage_override.mstone", + mstone + ); + Services.prefs.setCharPref( + "browser.startup.homepage_override.buildID", + buildID + ); + savePrefs(); + aCallback(true); + }, + function () { + aCallback(false); + } + ); + }, + }; + } + } + + // Sync/FxA related data + let sync = { + name: "sync", // name is used only by tests. + type: types.OTHERDATA, + migrate: async aCallback => { + // Try and parse a signedInUser.json file from the source directory and + // if we can, copy it to the new profile and set sync's username pref + // (which acts as a de-facto flag to indicate if sync is configured) + try { + let oldPath = PathUtils.join( + sourceProfileDir.path, + "signedInUser.json" + ); + let exists = await IOUtils.exists(oldPath); + if (exists) { + let data = await IOUtils.readJSON(oldPath); + if (data && data.accountData && data.accountData.email) { + let username = data.accountData.email; + // copy the file itself. + await IOUtils.copy( + oldPath, + PathUtils.join(currentProfileDir.path, "signedInUser.json") + ); + // Now we need to know whether Sync is actually configured for this + // user. The only way we know is by looking at the prefs file from + // the old profile. We avoid trying to do a full parse of the prefs + // file and even avoid parsing the single string value we care + // about. + let oldRawPrefs = await readOldPrefs(); + if (/^user_pref\("services\.sync\.username"/m.test(oldRawPrefs)) { + // sync's configured in the source profile - ensure it is in the + // new profile too. + // Write it to prefs.js and flush the file. + Services.prefs.setStringPref( + "services.sync.username", + username + ); + savePrefs(); + } + } + } + } catch (ex) { + aCallback(false); + return; + } + aCallback(true); + }, + }; + + // Telemetry related migrations. + let times = { + name: "times", // name is used only by tests. + type: types.OTHERDATA, + migrate: aCallback => { + let file = this._getFileObject(sourceProfileDir, "times.json"); + if (file) { + file.copyTo(currentProfileDir, ""); + } + // And record the fact a migration (ie, a reset) happened. + let recordMigration = async () => { + try { + let profileTimes = await lazy.ProfileAge(currentProfileDir.path); + await profileTimes.recordProfileReset(); + aCallback(true); + } catch (e) { + aCallback(false); + } + }; + + recordMigration(); + }, + }; + let telemetry = { + name: "telemetry", // name is used only by tests... + type: types.OTHERDATA, + migrate: async aCallback => { + let createSubDir = name => { + let dir = currentProfileDir.clone(); + dir.append(name); + dir.create(Ci.nsIFile.DIRECTORY_TYPE, lazy.FileUtils.PERMS_DIRECTORY); + return dir; + }; + + // If the 'datareporting' directory exists we migrate files from it. + let dataReportingDir = this._getFileObject( + sourceProfileDir, + "datareporting" + ); + if (dataReportingDir && dataReportingDir.isDirectory()) { + // Copy only specific files. + let toCopy = ["state.json", "session-state.json"]; + + let dest = createSubDir("datareporting"); + let enumerator = dataReportingDir.directoryEntries; + while (enumerator.hasMoreElements()) { + let file = enumerator.nextFile; + if (file.isDirectory() || !toCopy.includes(file.leafName)) { + continue; + } + file.copyTo(dest, ""); + } + } + + try { + let oldRawPrefs = await readOldPrefs(); + let writePrefs = false; + const PREFS = ["bookmarks", "csvpasswords", "history", "passwords"]; + + for (let pref of PREFS) { + let fullPref = `browser\.migrate\.interactions\.${pref}`; + let regex = new RegExp('^user_pref\\("' + fullPref, "m"); + if (regex.test(oldRawPrefs)) { + Services.prefs.setBoolPref(fullPref, true); + writePrefs = true; + } + } + + if (writePrefs) { + savePrefs(); + } + } catch (e) { + aCallback(false); + return; + } + + aCallback(true); + }, + }; + + return [ + places, + cookies, + passwords, + formData, + dictionary, + bookmarksBackups, + session, + sync, + times, + telemetry, + favicons, + ].filter(r => r); + } + + get startupOnlyMigrator() { + return true; + } +} diff --git a/browser/components/migration/IEProfileMigrator.sys.mjs b/browser/components/migration/IEProfileMigrator.sys.mjs new file mode 100644 index 0000000000..d0fd504e1a --- /dev/null +++ b/browser/components/migration/IEProfileMigrator.sys.mjs @@ -0,0 +1,133 @@ +/* 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 { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; +import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; +import { MSMigrationUtils } from "resource:///modules/MSMigrationUtils.sys.mjs"; + +import { PlacesUtils } from "resource://gre/modules/PlacesUtils.sys.mjs"; + +// Resources + +function History() {} + +History.prototype = { + type: MigrationUtils.resourceTypes.HISTORY, + + get exists() { + return true; + }, + + migrate: function H_migrate(aCallback) { + let pageInfos = []; + let typedURLs = MSMigrationUtils.getTypedURLs( + "Software\\Microsoft\\Internet Explorer" + ); + let now = new Date(); + let maxDate = new Date( + Date.now() - MigrationUtils.HISTORY_MAX_AGE_IN_MILLISECONDS + ); + + for (let entry of Cc[ + "@mozilla.org/profile/migrator/iehistoryenumerator;1" + ].createInstance(Ci.nsISimpleEnumerator)) { + let url = entry.get("uri").QueryInterface(Ci.nsIURI); + // MSIE stores some types of URLs in its history that we don't handle, + // like HTMLHelp and others. Since we don't properly map handling for + // all of them we just avoid importing them. + if (!["http", "https", "ftp", "file"].includes(url.scheme)) { + continue; + } + + let title = entry.get("title"); + // Embed visits have no title and don't need to be imported. + if (!title.length) { + continue; + } + + // The typed urls are already fixed-up, so we can use them for comparison. + let transition = typedURLs.has(url.spec) + ? PlacesUtils.history.TRANSITIONS.LINK + : PlacesUtils.history.TRANSITIONS.TYPED; + + let time = entry.get("time"); + + let visitDate = time ? PlacesUtils.toDate(time) : null; + if (visitDate && visitDate < maxDate) { + continue; + } + + pageInfos.push({ + url, + title, + visits: [ + { + transition, + // use the current date if we have no visits for this entry. + date: visitDate ?? now, + }, + ], + }); + } + + // Check whether there is any history to import. + if (!pageInfos.length) { + aCallback(true); + return; + } + + MigrationUtils.insertVisitsWrapper(pageInfos).then( + () => aCallback(true), + () => aCallback(false) + ); + }, +}; + +/** + * Internet Explorer profile migrator + */ +export class IEProfileMigrator extends MigratorBase { + static get key() { + return "ie"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-ie"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/ie.png"; + } + + getResources() { + let resources = [MSMigrationUtils.getBookmarksMigrator(), new History()]; + let windowsVaultFormPasswordsMigrator = + MSMigrationUtils.getWindowsVaultFormPasswordsMigrator(); + windowsVaultFormPasswordsMigrator.name = "IEVaultFormPasswords"; + resources.push(windowsVaultFormPasswordsMigrator); + return resources.filter(r => r.exists); + } + + async getLastUsedDate() { + const datePromises = ["Favs", "CookD"].map(dirId => { + const { path } = Services.dirsvc.get(dirId, Ci.nsIFile); + return IOUtils.stat(path) + .then(info => info.lastModified) + .catch(() => 0); + }); + + const dates = await Promise.all(datePromises); + + try { + const typedURLs = MSMigrationUtils.getTypedURLs( + "Software\\Microsoft\\Internet Explorer" + ); + // typedURLs.values() returns an array of PRTimes, which are in + // microseconds - convert to milliseconds + dates.push(Math.max(0, ...typedURLs.values()) / 1000); + } catch (ex) {} + + return new Date(Math.max(...dates)); + } +} diff --git a/browser/components/migration/InternalTestingProfileMigrator.sys.mjs b/browser/components/migration/InternalTestingProfileMigrator.sys.mjs new file mode 100644 index 0000000000..7174757b96 --- /dev/null +++ b/browser/components/migration/InternalTestingProfileMigrator.sys.mjs @@ -0,0 +1,76 @@ +/* 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 { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; +import { MigrationWizardConstants } from "chrome://browser/content/migration/migration-wizard-constants.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", +}); + +/** + * A stub of a migrator used for automated testing only. + */ +export class InternalTestingProfileMigrator extends MigratorBase { + static get key() { + return "internal-testing"; + } + + static get displayNameL10nID() { + return "Internal Testing Migrator"; + } + + static get sourceID() { + return 1; + } + + getSourceProfiles() { + return Promise.resolve([InternalTestingProfileMigrator.testProfile]); + } + + // We will create a single MigratorResource for each resource type that + // just immediately reports a successful migration. + getResources(aProfile) { + if ( + !aProfile || + aProfile.id != InternalTestingProfileMigrator.testProfile.id + ) { + throw new Error( + "InternalTestingProfileMigrator.getResources expects test profile." + ); + } + return Object.values(lazy.MigrationUtils.resourceTypes).map(type => { + return { + type, + migrate: callback => { + if (type == lazy.MigrationUtils.resourceTypes.EXTENSIONS) { + callback(true, { + progressValue: MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + totalExtensions: [], + importedExtensions: [], + }); + } else { + callback(true /* success */); + } + }, + }; + }); + } + + /** + * Clears the MigratorResources that are normally cached by the + * MigratorBase parent class after a call to getResources. This + * allows our automated tests to try different resource availability + * scenarios between tests. + */ + flushResourceCache() { + this._resourcesByProfile = null; + } + + static get testProfile() { + return { id: "test-profile", name: "Some test profile" }; + } +} diff --git a/browser/components/migration/MSMigrationUtils.sys.mjs b/browser/components/migration/MSMigrationUtils.sys.mjs new file mode 100644 index 0000000000..8d9a666e66 --- /dev/null +++ b/browser/components/migration/MSMigrationUtils.sys.mjs @@ -0,0 +1,749 @@ +/* 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 { ctypes } from "resource://gre/modules/ctypes.sys.mjs"; +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + WindowsRegistry: "resource://gre/modules/WindowsRegistry.sys.mjs", +}); + +const EDGE_FAVORITES = "AC\\MicrosoftEdge\\User\\Default\\Favorites"; +const FREE_CLOSE_FAILED = 0; +const INTERNET_EXPLORER_EDGE_GUID = [ + 0x3ccd5499, 0x4b1087a8, 0x886015a2, 0x553bdd88, +]; +const RESULT_SUCCESS = 0; +const VAULT_ENUMERATE_ALL_ITEMS = 512; +const WEB_CREDENTIALS_VAULT_ID = [ + 0x4bf4c442, 0x41a09b8a, 0x4add80b3, 0x28db4d70, +]; + +const wintypes = { + BOOL: ctypes.int, + DWORD: ctypes.uint32_t, + DWORDLONG: ctypes.uint64_t, + CHAR: ctypes.char, + PCHAR: ctypes.char.ptr, + LPCWSTR: ctypes.char16_t.ptr, + PDWORD: ctypes.uint32_t.ptr, + VOIDP: ctypes.voidptr_t, + WORD: ctypes.uint16_t, +}; + +// TODO: Bug 1202978 - Refactor MSMigrationUtils ctypes helpers +function CtypesKernelHelpers() { + this._structs = {}; + this._functions = {}; + this._libs = {}; + + this._structs.SYSTEMTIME = new ctypes.StructType("SYSTEMTIME", [ + { wYear: wintypes.WORD }, + { wMonth: wintypes.WORD }, + { wDayOfWeek: wintypes.WORD }, + { wDay: wintypes.WORD }, + { wHour: wintypes.WORD }, + { wMinute: wintypes.WORD }, + { wSecond: wintypes.WORD }, + { wMilliseconds: wintypes.WORD }, + ]); + + this._structs.FILETIME = new ctypes.StructType("FILETIME", [ + { dwLowDateTime: wintypes.DWORD }, + { dwHighDateTime: wintypes.DWORD }, + ]); + + try { + this._libs.kernel32 = ctypes.open("Kernel32"); + + this._functions.FileTimeToSystemTime = this._libs.kernel32.declare( + "FileTimeToSystemTime", + ctypes.winapi_abi, + wintypes.BOOL, + this._structs.FILETIME.ptr, + this._structs.SYSTEMTIME.ptr + ); + } catch (ex) { + this.finalize(); + } +} + +CtypesKernelHelpers.prototype = { + /** + * Must be invoked once after last use of any of the provided helpers. + */ + finalize() { + this._structs = {}; + this._functions = {}; + for (let key in this._libs) { + let lib = this._libs[key]; + try { + lib.close(); + } catch (ex) {} + } + this._libs = {}; + }, + + /** + * Converts a FILETIME struct (2 DWORDS), to a SYSTEMTIME struct, + * and then deduces the number of seconds since the epoch (which + * is the data we want for the cookie expiry date). + * + * @param {number} aTimeHi + * Least significant DWORD. + * @param {number} aTimeLo + * Most significant DWORD. + * @returns {number} the number of seconds since the epoch + */ + fileTimeToSecondsSinceEpoch(aTimeHi, aTimeLo) { + let fileTime = this._structs.FILETIME(); + fileTime.dwLowDateTime = aTimeLo; + fileTime.dwHighDateTime = aTimeHi; + let systemTime = this._structs.SYSTEMTIME(); + let result = this._functions.FileTimeToSystemTime( + fileTime.address(), + systemTime.address() + ); + if (result == 0) { + throw new Error(ctypes.winLastError); + } + + // System time is in UTC, so we use Date.UTC to get milliseconds from epoch, + // then divide by 1000 to get seconds, and round down: + return Math.floor( + Date.UTC( + systemTime.wYear, + systemTime.wMonth - 1, + systemTime.wDay, + systemTime.wHour, + systemTime.wMinute, + systemTime.wSecond, + systemTime.wMilliseconds + ) / 1000 + ); + }, +}; + +function CtypesVaultHelpers() { + this._structs = {}; + this._functions = {}; + + this._structs.GUID = new ctypes.StructType("GUID", [ + { id: wintypes.DWORD.array(4) }, + ]); + + this._structs.VAULT_ITEM_ELEMENT = new ctypes.StructType( + "VAULT_ITEM_ELEMENT", + [ + // not documented + { schemaElementId: wintypes.DWORD }, + // not documented + { unknown1: wintypes.DWORD }, + // vault type + { type: wintypes.DWORD }, + // not documented + { unknown2: wintypes.DWORD }, + // value of the item + { itemValue: wintypes.LPCWSTR }, + // not documented + { unknown3: wintypes.CHAR.array(12) }, + ] + ); + + this._structs.VAULT_ELEMENT = new ctypes.StructType("VAULT_ELEMENT", [ + // vault item schemaId + { schemaId: this._structs.GUID }, + // a pointer to the name of the browser VAULT_ITEM_ELEMENT + { pszCredentialFriendlyName: wintypes.LPCWSTR }, + // a pointer to the url VAULT_ITEM_ELEMENT + { pResourceElement: this._structs.VAULT_ITEM_ELEMENT.ptr }, + // a pointer to the username VAULT_ITEM_ELEMENT + { pIdentityElement: this._structs.VAULT_ITEM_ELEMENT.ptr }, + // not documented + { pAuthenticatorElement: this._structs.VAULT_ITEM_ELEMENT.ptr }, + // not documented + { pPackageSid: this._structs.VAULT_ITEM_ELEMENT.ptr }, + // time stamp in local format + { lowLastModified: wintypes.DWORD }, + { highLastModified: wintypes.DWORD }, + // not documented + { flags: wintypes.DWORD }, + // not documented + { dwPropertiesCount: wintypes.DWORD }, + // not documented + { pPropertyElements: this._structs.VAULT_ITEM_ELEMENT.ptr }, + ]); + + try { + this._vaultcliLib = ctypes.open("vaultcli.dll"); + + this._functions.VaultOpenVault = this._vaultcliLib.declare( + "VaultOpenVault", + ctypes.winapi_abi, + wintypes.DWORD, + // GUID + this._structs.GUID.ptr, + // Flags + wintypes.DWORD, + // Vault Handle + wintypes.VOIDP.ptr + ); + this._functions.VaultEnumerateItems = this._vaultcliLib.declare( + "VaultEnumerateItems", + ctypes.winapi_abi, + wintypes.DWORD, + // Vault Handle + wintypes.VOIDP, + // Flags + wintypes.DWORD, + // Items Count + wintypes.PDWORD, + // Items + ctypes.voidptr_t + ); + this._functions.VaultCloseVault = this._vaultcliLib.declare( + "VaultCloseVault", + ctypes.winapi_abi, + wintypes.DWORD, + // Vault Handle + wintypes.VOIDP + ); + this._functions.VaultGetItem = this._vaultcliLib.declare( + "VaultGetItem", + ctypes.winapi_abi, + wintypes.DWORD, + // Vault Handle + wintypes.VOIDP, + // Schema Id + this._structs.GUID.ptr, + // Resource + this._structs.VAULT_ITEM_ELEMENT.ptr, + // Identity + this._structs.VAULT_ITEM_ELEMENT.ptr, + // Package Sid + this._structs.VAULT_ITEM_ELEMENT.ptr, + // HWND Owner + wintypes.DWORD, + // Flags + wintypes.DWORD, + // Items + this._structs.VAULT_ELEMENT.ptr.ptr + ); + this._functions.VaultFree = this._vaultcliLib.declare( + "VaultFree", + ctypes.winapi_abi, + wintypes.DWORD, + // Memory + this._structs.VAULT_ELEMENT.ptr + ); + } catch (ex) { + this.finalize(); + } +} + +CtypesVaultHelpers.prototype = { + /** + * Must be invoked once after last use of any of the provided helpers. + */ + finalize() { + this._structs = {}; + this._functions = {}; + try { + this._vaultcliLib.close(); + } catch (ex) {} + this._vaultcliLib = null; + }, +}; + +var gEdgeDir; +function getEdgeLocalDataFolder() { + if (gEdgeDir) { + return gEdgeDir.clone(); + } + let packages = Services.dirsvc.get("LocalAppData", Ci.nsIFile); + packages.append("Packages"); + let edgeDir = packages.clone(); + edgeDir.append("Microsoft.MicrosoftEdge_8wekyb3d8bbwe"); + try { + if (edgeDir.exists() && edgeDir.isReadable() && edgeDir.isDirectory()) { + gEdgeDir = edgeDir; + return edgeDir.clone(); + } + + // Let's try the long way: + let dirEntries = packages.directoryEntries; + while (dirEntries.hasMoreElements()) { + let subDir = dirEntries.nextFile; + if ( + subDir.leafName.startsWith("Microsoft.MicrosoftEdge") && + subDir.isReadable() && + subDir.isDirectory() + ) { + gEdgeDir = subDir; + return subDir.clone(); + } + } + } catch (ex) { + console.error( + "Exception trying to find the Edge favorites directory: ", + ex + ); + } + return null; +} + +function Bookmarks(migrationType) { + this._migrationType = migrationType; +} + +Bookmarks.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + get exists() { + return !!this._favoritesFolder; + }, + + get importedAppLabel() { + return this._migrationType == MSMigrationUtils.MIGRATION_TYPE_IE + ? "IE" + : "Edge"; + }, + + __favoritesFolder: null, + get _favoritesFolder() { + if (!this.__favoritesFolder) { + if (this._migrationType == MSMigrationUtils.MIGRATION_TYPE_IE) { + let favoritesFolder = Services.dirsvc.get("Favs", Ci.nsIFile); + if (favoritesFolder.exists() && favoritesFolder.isReadable()) { + this.__favoritesFolder = favoritesFolder; + } + } else if (this._migrationType == MSMigrationUtils.MIGRATION_TYPE_EDGE) { + let edgeDir = getEdgeLocalDataFolder(); + if (edgeDir) { + edgeDir.appendRelativePath(EDGE_FAVORITES); + if ( + edgeDir.exists() && + edgeDir.isReadable() && + edgeDir.isDirectory() + ) { + this.__favoritesFolder = edgeDir; + } + } + } + } + return this.__favoritesFolder; + }, + + __toolbarFolderName: null, + get _toolbarFolderName() { + if (!this.__toolbarFolderName) { + if (this._migrationType == MSMigrationUtils.MIGRATION_TYPE_IE) { + // Retrieve the name of IE's favorites subfolder that holds the bookmarks + // in the toolbar. This was previously stored in the registry and changed + // in IE7 to always be called "Links". + let folderName = lazy.WindowsRegistry.readRegKey( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + "Software\\Microsoft\\Internet Explorer\\Toolbar", + "LinksFolderName" + ); + this.__toolbarFolderName = folderName || "Links"; + } else { + this.__toolbarFolderName = "Links"; + } + } + return this.__toolbarFolderName; + }, + + migrate: function B_migrate(aCallback) { + return (async () => { + // Import to the bookmarks menu. + let folderGuid = lazy.PlacesUtils.bookmarks.menuGuid; + await this._migrateFolder(this._favoritesFolder, folderGuid); + })().then( + () => aCallback(true), + e => { + console.error(e); + aCallback(false); + } + ); + }, + + async _migrateFolder(aSourceFolder, aDestFolderGuid) { + let { bookmarks, favicons } = await this._getBookmarksInFolder( + aSourceFolder + ); + if (!bookmarks.length) { + return; + } + + await MigrationUtils.insertManyBookmarksWrapper(bookmarks, aDestFolderGuid); + MigrationUtils.insertManyFavicons(favicons); + }, + + /** + * Iterates through a bookmark folder to obtain whatever information from each bookmark is needed elsewhere. This function also recurses into child folders. + * + * @param {nsIFile} aSourceFolder the folder to search for bookmarks and subfolders. + * @returns {Promise} An object with the following properties: + * {Object[]} bookmarks: + * An array of Objects with these properties: + * {number} type: A type mapping to one of the types in nsINavBookmarksService + * {string} title: The title of the bookmark + * {Object[]} children: An array of objects with the same structure as this one. + * + * {Object[]} favicons + * An array of Objects with these properties: + * {Uint8Array} faviconData: The binary data of a favicon + * {nsIURI} uri: The URI of the associated bookmark + */ + async _getBookmarksInFolder(aSourceFolder) { + // TODO (bug 741993): the favorites order is stored in the Registry, at + // HCU\Software\Microsoft\Windows\CurrentVersion\Explorer\MenuOrder\Favorites + // for IE, and in a similar location for Edge. + // Until we support it, bookmarks are imported in alphabetical order. + let entries = aSourceFolder.directoryEntries; + let rv = []; + let favicons = []; + while (entries.hasMoreElements()) { + let entry = entries.nextFile; + try { + // Make sure that entry.path == entry.target to not follow .lnk folder + // shortcuts which could lead to infinite cycles. + // Don't use isSymlink(), since it would throw for invalid + // lnk files pointing to URLs or to unresolvable paths. + if (entry.path == entry.target && entry.isDirectory()) { + let isBookmarksFolder = + entry.leafName == this._toolbarFolderName && + entry.parent.equals(this._favoritesFolder); + if (isBookmarksFolder && entry.isReadable()) { + // Import to the bookmarks toolbar. + let folderGuid = lazy.PlacesUtils.bookmarks.toolbarGuid; + await this._migrateFolder(entry, folderGuid); + } else if (entry.isReadable()) { + let { bookmarks: childBookmarks, favicons: childFavicons } = + await this._getBookmarksInFolder(entry); + favicons = favicons.concat(childFavicons); + rv.push({ + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + title: entry.leafName, + children: childBookmarks, + }); + } + } else { + // Strip the .url extension, to both check this is a valid link file, + // and get the associated title. + let matches = entry.leafName.match(/(.+)\.url$/i); + if (matches) { + let fileHandler = Cc[ + "@mozilla.org/network/protocol;1?name=file" + ].getService(Ci.nsIFileProtocolHandler); + let uri = fileHandler.readURLFile(entry); + // Silently failing in the event that the alternative data stream for the favicon doesn't exist + try { + let faviconData = await IOUtils.read(entry.path + ":favicon"); + favicons.push({ faviconData, uri }); + } catch {} + + rv.push({ url: uri, title: matches[1] }); + } + } + } catch (ex) { + console.error( + "Unable to import ", + this.importedAppLabel, + " favorite (", + entry.leafName, + "): ", + ex + ); + } + } + return { bookmarks: rv, favicons }; + }, +}; + +function getTypedURLs(registryKeyPath) { + // The list of typed URLs is a sort of annotation stored in the registry. + // The number of entries stored is not UI-configurable, but has changed + // between different Windows versions. We just keep reading up to the first + // non-existing entry to support different limits / states of the registry. + let typedURLs = new Map(); + let typedURLKey = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + Ci.nsIWindowsRegKey + ); + let typedURLTimeKey = Cc[ + "@mozilla.org/windows-registry-key;1" + ].createInstance(Ci.nsIWindowsRegKey); + let cTypes = new CtypesKernelHelpers(); + try { + try { + typedURLKey.open( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + registryKeyPath + "\\TypedURLs", + Ci.nsIWindowsRegKey.ACCESS_READ + ); + } catch (ex) { + // Ignore errors opening this registry key - if it doesn't work, there's + // no way we can get useful info here. + return typedURLs; + } + try { + typedURLTimeKey.open( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + registryKeyPath + "\\TypedURLsTime", + Ci.nsIWindowsRegKey.ACCESS_READ + ); + } catch (ex) { + typedURLTimeKey = null; + } + let entryName; + for ( + let entry = 1; + typedURLKey.hasValue((entryName = "url" + entry)); + entry++ + ) { + let url = typedURLKey.readStringValue(entryName); + // If we can't get a date for whatever reason, default to 6 months ago + let timeTyped = Date.now() - 31536000 / 2; + if (typedURLTimeKey && typedURLTimeKey.hasValue(entryName)) { + let urlTime = ""; + try { + urlTime = typedURLTimeKey.readBinaryValue(entryName); + } catch (ex) { + console.error("Couldn't read url time for ", entryName); + } + if (urlTime.length == 8) { + let urlTimeHex = []; + for (let i = 0; i < 8; i++) { + let c = urlTime.charCodeAt(i).toString(16); + if (c.length == 1) { + c = "0" + c; + } + urlTimeHex.unshift(c); + } + try { + let hi = parseInt(urlTimeHex.slice(0, 4).join(""), 16); + let lo = parseInt(urlTimeHex.slice(4, 8).join(""), 16); + // Convert to seconds since epoch: + let secondsSinceEpoch = cTypes.fileTimeToSecondsSinceEpoch(hi, lo); + + // If the date is very far in the past, just use the default + if (secondsSinceEpoch > Date.now() / 1000000) { + // Callers expect PRTime, which is microseconds since epoch: + timeTyped = secondsSinceEpoch * 1000; + } + } catch (ex) { + // Ignore conversion exceptions. Callers will have to deal + // with the fallback value. + } + } + } + typedURLs.set(url, timeTyped * 1000); + } + } catch (ex) { + console.error("Error reading typed URL history: ", ex); + } finally { + if (typedURLKey) { + typedURLKey.close(); + } + if (typedURLTimeKey) { + typedURLTimeKey.close(); + } + cTypes.finalize(); + } + return typedURLs; +} + +// Migrator for form passwords +function WindowsVaultFormPasswords() {} + +WindowsVaultFormPasswords.prototype = { + type: MigrationUtils.resourceTypes.PASSWORDS, + + get exists() { + // check if there are passwords available for migration. + return this.migrate(() => {}, true); + }, + + /** + * If aOnlyCheckExists is false, import the form passwords from the vault + * and then call the aCallback. + * Otherwise, check if there are passwords in the vault. + * + * @param {Function} aCallback - a callback called when the migration is done. + * @param {boolean} [aOnlyCheckExists=false] - if aOnlyCheckExists is true, just check if there are some + * passwords to migrate. Import the passwords from the vault and call aCallback otherwise. + * @returns {boolean} true if there are passwords in the vault and aOnlyCheckExists is set to true, + * false if there is no password in the vault and aOnlyCheckExists is set to true, undefined if + * aOnlyCheckExists is set to false. + */ + async migrate(aCallback, aOnlyCheckExists = false) { + // check if the vault item is an IE/Edge one + function _isIEOrEdgePassword(id) { + return ( + id[0] == INTERNET_EXPLORER_EDGE_GUID[0] && + id[1] == INTERNET_EXPLORER_EDGE_GUID[1] && + id[2] == INTERNET_EXPLORER_EDGE_GUID[2] && + id[3] == INTERNET_EXPLORER_EDGE_GUID[3] + ); + } + + let ctypesVaultHelpers = new CtypesVaultHelpers(); + let ctypesKernelHelpers = new CtypesKernelHelpers(); + let migrationSucceeded = true; + let successfulVaultOpen = false; + let error, vault; + try { + // web credentials vault id + let vaultGuid = new ctypesVaultHelpers._structs.GUID( + WEB_CREDENTIALS_VAULT_ID + ); + error = new wintypes.DWORD(); + // web credentials vault + vault = new wintypes.VOIDP(); + // open the current vault using the vaultGuid + error = ctypesVaultHelpers._functions.VaultOpenVault( + vaultGuid.address(), + 0, + vault.address() + ); + if (error != RESULT_SUCCESS) { + throw new Error("Unable to open Vault: " + error); + } + successfulVaultOpen = true; + + let item = new ctypesVaultHelpers._structs.VAULT_ELEMENT.ptr(); + let itemCount = new wintypes.DWORD(); + // enumerate all the available items. This api is going to return a table of all the + // available items and item is going to point to the first element of this table. + error = ctypesVaultHelpers._functions.VaultEnumerateItems( + vault, + VAULT_ENUMERATE_ALL_ITEMS, + itemCount.address(), + item.address() + ); + if (error != RESULT_SUCCESS) { + throw new Error("Unable to enumerate Vault items: " + error); + } + + let logins = []; + for (let j = 0; j < itemCount.value; j++) { + try { + // if it's not an ie/edge password, skip it + if (!_isIEOrEdgePassword(item.contents.schemaId.id)) { + continue; + } + let url = + item.contents.pResourceElement.contents.itemValue.readString(); + let realURL; + try { + realURL = Services.io.newURI(url); + } catch (ex) { + /* leave realURL as null */ + } + if (!realURL || !["http", "https", "ftp"].includes(realURL.scheme)) { + // Ignore items for non-URLs or URLs that aren't HTTP(S)/FTP + continue; + } + + // if aOnlyCheckExists is set to true, the purpose of the call is to return true if there is at + // least a password which is true in this case because a password was by now already found + if (aOnlyCheckExists) { + return true; + } + let username = + item.contents.pIdentityElement.contents.itemValue.readString(); + // the current login credential object + let credential = new ctypesVaultHelpers._structs.VAULT_ELEMENT.ptr(); + error = ctypesVaultHelpers._functions.VaultGetItem( + vault, + item.contents.schemaId.address(), + item.contents.pResourceElement, + item.contents.pIdentityElement, + null, + 0, + 0, + credential.address() + ); + if (error != RESULT_SUCCESS) { + throw new Error("Unable to get item: " + error); + } + + let password = + credential.contents.pAuthenticatorElement.contents.itemValue.readString(); + let creation = Date.now(); + try { + // login manager wants time in milliseconds since epoch, so convert + // to seconds since epoch and multiply to get milliseconds: + creation = + ctypesKernelHelpers.fileTimeToSecondsSinceEpoch( + item.contents.highLastModified, + item.contents.lowLastModified + ) * 1000; + } catch (ex) { + // Ignore exceptions in the dates and just create the login for right now. + } + // create a new login + logins.push({ + username, + password, + origin: realURL.prePath, + timeCreated: creation, + }); + + // close current item + error = ctypesVaultHelpers._functions.VaultFree(credential); + if (error == FREE_CLOSE_FAILED) { + throw new Error("Unable to free item: " + error); + } + } catch (e) { + migrationSucceeded = false; + console.error(e); + } finally { + // move to next item in the table returned by VaultEnumerateItems + item = item.increment(); + } + } + + if (logins.length) { + await MigrationUtils.insertLoginsWrapper(logins); + } + } catch (e) { + console.error(e); + migrationSucceeded = false; + } finally { + if (successfulVaultOpen) { + // close current vault + error = ctypesVaultHelpers._functions.VaultCloseVault(vault); + if (error == FREE_CLOSE_FAILED) { + console.error("Unable to close vault: ", error); + } + } + ctypesKernelHelpers.finalize(); + ctypesVaultHelpers.finalize(); + aCallback(migrationSucceeded); + } + if (aOnlyCheckExists) { + return false; + } + return undefined; + }, +}; + +export var MSMigrationUtils = { + MIGRATION_TYPE_IE: 1, + MIGRATION_TYPE_EDGE: 2, + CtypesKernelHelpers, + getBookmarksMigrator(migrationType = this.MIGRATION_TYPE_IE) { + return new Bookmarks(migrationType); + }, + getWindowsVaultFormPasswordsMigrator() { + return new WindowsVaultFormPasswords(); + }, + getTypedURLs, + getEdgeLocalDataFolder, +}; diff --git a/browser/components/migration/MigrationUtils.sys.mjs b/browser/components/migration/MigrationUtils.sys.mjs new file mode 100644 index 0000000000..000b471ee6 --- /dev/null +++ b/browser/components/migration/MigrationUtils.sys.mjs @@ -0,0 +1,1175 @@ +/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AMBrowserExtensionsImport: "resource://gre/modules/AddonManager.sys.mjs", + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", + MigrationWizardConstants: + "chrome://browser/content/migration/migration-wizard-constants.mjs", +}); + +ChromeUtils.defineLazyGetter( + lazy, + "gCanGetPermissionsOnPlatformPromise", + () => { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + return fp.isModeSupported(Ci.nsIFilePicker.modeGetFolder); + } +); + +var gMigrators = null; +var gFileMigrators = null; +var gProfileStartup = null; +var gL10n = null; + +let gForceExitSpinResolve = false; +let gKeepUndoData = false; +let gUndoData = null; + +function getL10n() { + if (!gL10n) { + gL10n = new Localization(["browser/migrationWizard.ftl"]); + } + return gL10n; +} + +const MIGRATOR_MODULES = Object.freeze({ + EdgeProfileMigrator: { + moduleURI: "resource:///modules/EdgeProfileMigrator.sys.mjs", + platforms: ["win"], + }, + FirefoxProfileMigrator: { + moduleURI: "resource:///modules/FirefoxProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, + IEProfileMigrator: { + moduleURI: "resource:///modules/IEProfileMigrator.sys.mjs", + platforms: ["win"], + }, + SafariProfileMigrator: { + moduleURI: "resource:///modules/SafariProfileMigrator.sys.mjs", + platforms: ["macosx"], + }, + + // The following migrators are all variants of the ChromeProfileMigrator + + BraveProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, + CanaryProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["macosx", "win"], + }, + ChromeProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, + ChromeBetaMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux", "win"], + }, + ChromeDevMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux"], + }, + ChromiumProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, + Chromium360seMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["win"], + }, + ChromiumEdgeMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["macosx", "win"], + }, + ChromiumEdgeBetaMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["macosx", "win"], + }, + OperaProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, + VivaldiProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, + OperaGXProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["macosx", "win"], + }, + + InternalTestingProfileMigrator: { + moduleURI: "resource:///modules/InternalTestingProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, +}); + +const FILE_MIGRATOR_MODULES = Object.freeze({ + PasswordFileMigrator: { + moduleURI: "resource:///modules/FileMigrators.sys.mjs", + }, + BookmarksFileMigrator: { + moduleURI: "resource:///modules/FileMigrators.sys.mjs", + }, +}); + +/** + * The singleton MigrationUtils service. This service is the primary mechanism + * by which migrations from other browsers to this browser occur. The singleton + * instance of this class is exported from this module as `MigrationUtils`. + */ +class MigrationUtils { + constructor() { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "HISTORY_MAX_AGE_IN_DAYS", + "browser.migrate.history.maxAgeInDays", + 180 + ); + + ChromeUtils.registerWindowActor("MigrationWizard", { + parent: { + esModuleURI: "resource:///actors/MigrationWizardParent.sys.mjs", + }, + + child: { + esModuleURI: "resource:///actors/MigrationWizardChild.sys.mjs", + events: { + "MigrationWizard:RequestState": { wantUntrusted: true }, + "MigrationWizard:BeginMigration": { wantUntrusted: true }, + "MigrationWizard:RequestSafariPermissions": { wantUntrusted: true }, + "MigrationWizard:SelectSafariPasswordFile": { wantUntrusted: true }, + "MigrationWizard:OpenAboutAddons": { wantUntrusted: true }, + "MigrationWizard:PermissionsNeeded": { wantUntrusted: true }, + "MigrationWizard:GetPermissions": { wantUntrusted: true }, + }, + }, + + includeChrome: true, + allFrames: true, + matches: [ + "about:welcome", + "about:welcome?*", + "about:preferences", + "chrome://browser/content/migration/migration-dialog-window.html", + "chrome://browser/content/spotlight.html", + "about:firefoxview", + ], + }); + + ChromeUtils.defineLazyGetter(this, "IS_LINUX_SNAP_PACKAGE", () => { + if ( + AppConstants.platform != "linux" || + !Cc["@mozilla.org/gio-service;1"] + ) { + return false; + } + + let gIOSvc = Cc["@mozilla.org/gio-service;1"].getService( + Ci.nsIGIOService + ); + return gIOSvc.isRunningUnderSnap; + }); + } + + resourceTypes = Object.freeze({ + ALL: 0x0000, + /* 0x01 used to be used for settings, but was removed. */ + COOKIES: 0x0002, + HISTORY: 0x0004, + FORMDATA: 0x0008, + PASSWORDS: 0x0010, + BOOKMARKS: 0x0020, + OTHERDATA: 0x0040, + SESSION: 0x0080, + PAYMENT_METHODS: 0x0100, + EXTENSIONS: 0x0200, + }); + + /** + * Helper for implementing simple asynchronous cases of migration resources' + * |migrate(aCallback)| (see MigratorBase). If your |migrate| method + * just waits for some file to be read, for example, and then migrates + * everything right away, you can wrap the async-function with this helper + * and not worry about notifying the callback. + * + * @example + * // For example, instead of writing: + * setTimeout(function() { + * try { + * .... + * aCallback(true); + * } + * catch() { + * aCallback(false); + * } + * }, 0); + * + * // You may write: + * setTimeout(MigrationUtils.wrapMigrateFunction(function() { + * if (importingFromMosaic) + * throw Cr.NS_ERROR_UNEXPECTED; + * }, aCallback), 0); + * + * // ... and aCallback will be called with aSuccess=false when importing + * // from Mosaic, or with aSuccess=true otherwise. + * + * @param {Function} aFunction + * the function that will be called sometime later. If aFunction + * throws when it's called, aCallback(false) is called, otherwise + * aCallback(true) is called. + * @param {Function} aCallback + * the callback function passed to |migrate|. + * @returns {Function} + * the wrapped function. + */ + wrapMigrateFunction(aFunction, aCallback) { + return function () { + let success = false; + try { + aFunction.apply(null, arguments); + success = true; + } catch (ex) { + console.error(ex); + } + // Do not change this to call aCallback directly in try try & catch + // blocks, because if aCallback throws, we may end up calling aCallback + // twice. + aCallback(success); + }; + } + + /** + * Gets localized string corresponding to l10n-id + * + * @param {string} aKey + * The key of the id of the localization to retrieve. + * @param {object} [aArgs=undefined] + * An optional map of arguments to the id. + * @returns {Promise} + * A promise that resolves to the retrieved localization. + */ + getLocalizedString(aKey, aArgs) { + let l10n = getL10n(); + return l10n.formatValue(aKey, aArgs); + } + + /** + * Get all the rows corresponding to a select query from a database, without + * requiring a lock on the database. If fetching data fails (because someone + * else tried to write to the DB at the same time, for example), we will + * retry the fetch after a 100ms timeout, up to 10 times. + * + * @param {string} path + * The file path to the database we want to open. + * @param {string} description + * A developer-readable string identifying what kind of database we're + * trying to open. + * @param {string} selectQuery + * The SELECT query to use to fetch the rows. + * @param {Promise} [testDelayPromise] + * An optional promise to await for after the first loop, used in tests. + * + * @returns {Promise} + * A promise that resolves to an array of rows. The promise will be + * rejected if the read/fetch failed even after retrying. + */ + getRowsFromDBWithoutLocks( + path, + description, + selectQuery, + testDelayPromise = null + ) { + let dbOptions = { + readOnly: true, + ignoreLockingMode: true, + path, + }; + + const RETRYLIMIT = 10; + const RETRYINTERVAL = 100; + return (async function innerGetRows() { + let rows = null; + for (let retryCount = RETRYLIMIT; retryCount; retryCount--) { + // Attempt to get the rows. If this succeeds, we will bail out of the loop, + // close the database in a failsafe way, and pass the rows back. + // If fetching the rows throws, we will wait RETRYINTERVAL ms + // and try again. This will repeat a maximum of RETRYLIMIT times. + let db; + let didOpen = false; + let previousExceptionMessage = null; + try { + db = await lazy.Sqlite.openConnection(dbOptions); + didOpen = true; + rows = await db.execute(selectQuery); + break; + } catch (ex) { + if (previousExceptionMessage != ex.message) { + console.error(ex); + } + previousExceptionMessage = ex.message; + if (ex.name == "NS_ERROR_FILE_CORRUPTED") { + break; + } + } finally { + try { + if (didOpen) { + await db.close(); + } + } catch (ex) {} + } + await Promise.all([ + new Promise(resolve => lazy.setTimeout(resolve, RETRYINTERVAL)), + testDelayPromise, + ]); + } + if (!rows) { + throw new Error( + "Couldn't get rows from the " + description + " database." + ); + } + return rows; + })(); + } + + get #migrators() { + if (!gMigrators) { + gMigrators = new Map(); + for (let [symbol, { moduleURI, platforms }] of Object.entries( + MIGRATOR_MODULES + )) { + if (platforms.includes(AppConstants.platform)) { + let { [symbol]: migratorClass } = + ChromeUtils.importESModule(moduleURI); + if (gMigrators.has(migratorClass.key)) { + console.error( + "A pre-existing migrator exists with key " + + `${migratorClass.key}. Not registering.` + ); + continue; + } + gMigrators.set(migratorClass.key, new migratorClass()); + } + } + } + return gMigrators; + } + + get #fileMigrators() { + if (!gFileMigrators) { + gFileMigrators = new Map(); + for (let [symbol, { moduleURI }] of Object.entries( + FILE_MIGRATOR_MODULES + )) { + let { [symbol]: migratorClass } = ChromeUtils.importESModule(moduleURI); + if (gFileMigrators.has(migratorClass.key)) { + console.error( + "A pre-existing file migrator exists with key " + + `${migratorClass.key}. Not registering.` + ); + continue; + } + gFileMigrators.set(migratorClass.key, new migratorClass()); + } + } + return gFileMigrators; + } + + forceExitSpinResolve() { + gForceExitSpinResolve = true; + } + + spinResolve(promise) { + if (!(promise instanceof Promise)) { + return promise; + } + let done = false; + let result = null; + let error = null; + gForceExitSpinResolve = false; + promise + .catch(e => { + error = e; + }) + .then(r => { + result = r; + done = true; + }); + + Services.tm.spinEventLoopUntil( + "MigrationUtils.jsm:MU_spinResolve", + () => done || gForceExitSpinResolve + ); + if (!done) { + throw new Error("Forcefully exited event loop."); + } else if (error) { + throw error; + } else { + return result; + } + } + + /** + * Returns the migrator for the given source, if any data is available + * for this source, or if permissions are required in order to read + * data from this source. Returns null otherwise. + * + * @param {string} aKey + * Internal name of the migration source. See `availableMigratorKeys` + * for supported values by OS. + * @returns {Promise} + * A profile migrator implementing nsIBrowserProfileMigrator, if it can + * import any data, null otherwise. + */ + async getMigrator(aKey) { + let migrator = this.#migrators.get(aKey); + if (!migrator) { + console.error(`Could not find a migrator class for key ${aKey}`); + return null; + } + + try { + if (!migrator) { + return null; + } + + if ( + (await migrator.isSourceAvailable()) || + (!(await migrator.hasPermissions()) && migrator.canGetPermissions()) + ) { + return migrator; + } + + return null; + } catch (ex) { + console.error(ex); + return null; + } + } + + getFileMigrator(aKey) { + let migrator = this.#fileMigrators.get(aKey); + if (!migrator) { + console.error(`Could not find a file migrator class for key ${aKey}`); + return null; + } + return migrator; + } + + /** + * Returns true if a migrator is registered with key aKey. No check is made + * to determine if a profile exists that the migrator can migrate from. + * + * @param {string} aKey + * Internal name of the migration source. See `availableMigratorKeys` + * for supported values by OS. + * @returns {boolean} + */ + migratorExists(aKey) { + return this.#migrators.has(aKey); + } + + /** + * Figure out what is the default browser, and if there is a migrator + * for it, return that migrator's internal name. + * + * For the time being, the "internal name" of a migrator is its contract-id + * trailer (e.g. ie for @mozilla.org/profile/migrator;1?app=browser&type=ie), + * but it will soon be exposed properly. + * + * @returns {string} + */ + getMigratorKeyForDefaultBrowser() { + // Canary uses the same description as Chrome so we can't distinguish them. + // Edge Beta on macOS uses "Microsoft Edge" with no "beta" indication. + const APP_DESC_TO_KEY = { + "Internet Explorer": "ie", + "Microsoft Edge": "edge", + Safari: "safari", + Firefox: "firefox", + Nightly: "firefox", + Opera: "opera", + Vivaldi: "vivaldi", + "Opera GX": "opera-gx", + "Brave Web Browser": "brave", // Windows, Linux + Brave: "brave", // OS X + "Google Chrome": "chrome", // Windows, Linux + Chrome: "chrome", // OS X + Chromium: "chromium", // Windows, OS X + "Chromium Web Browser": "chromium", // Linux + "360\u5b89\u5168\u6d4f\u89c8\u5668": "chromium-360se", + }; + + let key = ""; + try { + let browserDesc = Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .getApplicationDescription("http"); + key = APP_DESC_TO_KEY[browserDesc] || ""; + // Handle devedition, as well as "FirefoxNightly" on OS X. + if (!key && browserDesc.startsWith("Firefox")) { + key = "firefox"; + } + } catch (ex) { + console.error("Could not detect default browser: ", ex); + } + + return key; + } + + /** + * True if we're in the process of a startup migration. + * + * @type {boolean} + */ + get isStartupMigration() { + return gProfileStartup != null; + } + + /** + * In the case of startup migration, this is set to the nsIProfileStartup + * instance passed to ProfileMigrator's migrate. + * + * @see showMigrationWizard + * @type {nsIProfileStartup|null} + */ + get profileStartup() { + return gProfileStartup; + } + + /** + * Show the migration wizard in about:preferences, or if there is not an existing + * browser window open, in a new top-level dialog window. + * + * NB: If you add new consumers, please add a migration entry point constant to + * MIGRATION_ENTRYPOINTS and supply that entrypoint with the entrypoint property + * in the aOptions argument. + * + * @param {Window} [aOpener=null] + * optional; the window that asks to open the wizard. + * @param {object} [aOptions=null] + * optional named arguments for the migration wizard. + * @param {string} [aOptions.entrypoint=undefined] + * migration entry point constant. See MIGRATION_ENTRYPOINTS. + * @param {string} [aOptions.migratorKey=undefined] + * The key for which migrator to use automatically. This is the key that is exposed + * as a static getter on the migrator class. + * @param {MigratorBase} [aOptions.migrator=undefined] + * A migrator instance to use automatically. + * @param {boolean} [aOptions.isStartupMigration=undefined] + * True if this is a startup migration. + * @param {boolean} [aOptions.skipSourceSelection=undefined] + * True if the source selection page of the wizard should be skipped. + * @param {string} [aOptions.profileId] + * An identifier for the profile to use when migrating. + * @returns {Promise} + * If an about:preferences tab can be opened, this will resolve when + * that tab has been switched to. Otherwise, this will resolve + * just after opening the top-level dialog window. + */ + showMigrationWizard(aOpener, aOptions) { + // When migration is kicked off from about:welcome, there are + // a few different behaviors that we want to test, controlled + // by a preference that is instrumented for Nimbus. The pref + // has the following possible states: + // + // "autoclose": + // The user will be directed to the migration wizard in + // about:preferences, but once the wizard is dismissed, + // the tab will close. + // + // "standalone": + // The migration wizard will open in a new top-level content + // window. + // + // "default" / other + // The user will be directed to the migration wizard in + // about:preferences. The tab will not close once the + // user closes the wizard. + let aboutWelcomeBehavior = Services.prefs.getCharPref( + "browser.migrate.content-modal.about-welcome-behavior", + "default" + ); + + let entrypoint = aOptions.entrypoint || this.MIGRATION_ENTRYPOINTS.UNKNOWN; + Services.telemetry + .getHistogramById("FX_MIGRATION_ENTRY_POINT_CATEGORICAL") + .add(entrypoint); + + let openStandaloneWindow = blocking => { + let features = "dialog,centerscreen,resizable=no"; + + if (blocking) { + features += ",modal"; + } + + Services.ww.openWindow( + aOpener, + "chrome://browser/content/migration/migration-dialog-window.html", + "_blank", + features, + { + options: aOptions, + } + ); + return Promise.resolve(); + }; + + if (aOptions.isStartupMigration) { + // Record that the uninstaller requested a profile refresh + if (Services.env.get("MOZ_UNINSTALLER_PROFILE_REFRESH")) { + Services.env.set("MOZ_UNINSTALLER_PROFILE_REFRESH", ""); + Services.telemetry.scalarSet( + "migration.uninstaller_profile_refresh", + true + ); + } + + openStandaloneWindow(true /* blocking */); + return Promise.resolve(); + } + + if (aOpener?.openPreferences) { + if (aOptions.entrypoint == this.MIGRATION_ENTRYPOINTS.NEWTAB) { + if (aboutWelcomeBehavior == "autoclose") { + return aOpener.openPreferences("general-migrate-autoclose"); + } else if (aboutWelcomeBehavior == "standalone") { + openStandaloneWindow(false /* blocking */); + return Promise.resolve(); + } + } + return aOpener.openPreferences("general-migrate"); + } + + // If somehow we failed to open about:preferences, fall back to opening + // the top-level window. + openStandaloneWindow(false /* blocking */); + return Promise.resolve(); + } + + /** + * Show the migration wizard for startup-migration. This should only be + * called by ProfileMigrator (see ProfileMigrator.js), which implements + * nsIProfileMigrator. This runs asynchronously if we are running an + * automigration. + * + * @param {nsIProfileStartup} aProfileStartup + * the nsIProfileStartup instance provided to ProfileMigrator.migrate. + * @param {string|null} [aMigratorKey=null] + * If set, the migration wizard will import from the corresponding + * migrator, bypassing the source-selection page. Otherwise, the + * source-selection page will be displayed, either with the default + * browser selected, if it could be detected and if there is a + * migrator for it, or with the first option selected as a fallback + * @param {string|null} [aProfileToMigrate=null] + * If set, the migration wizard will import from the profile indicated. + * @throws + * if aMigratorKey is invalid or if it points to a non-existent + * source. + */ + startupMigration(aProfileStartup, aMigratorKey, aProfileToMigrate) { + this.spinResolve( + this.asyncStartupMigration( + aProfileStartup, + aMigratorKey, + aProfileToMigrate + ) + ); + } + + async asyncStartupMigration( + aProfileStartup, + aMigratorKey, + aProfileToMigrate + ) { + if (!aProfileStartup) { + throw new Error( + "an profile-startup instance is required for startup-migration" + ); + } + gProfileStartup = aProfileStartup; + + let skipSourceSelection = false, + migrator = null, + migratorKey = ""; + if (aMigratorKey) { + migrator = await this.getMigrator(aMigratorKey); + if (!migrator) { + // aMigratorKey must point to a valid source, so, if it doesn't + // cleanup and throw. + this.finishMigration(); + throw new Error( + "startMigration was asked to open auto-migrate from " + + "a non-existent source: " + + aMigratorKey + ); + } + migratorKey = aMigratorKey; + skipSourceSelection = true; + } else { + let defaultBrowserKey = this.getMigratorKeyForDefaultBrowser(); + if (defaultBrowserKey) { + migrator = await this.getMigrator(defaultBrowserKey); + if (migrator) { + migratorKey = defaultBrowserKey; + } + } + } + + if (!migrator) { + let migrators = await Promise.all( + this.availableMigratorKeys.map(key => this.getMigrator(key)) + ); + // If there's no migrator set so far, ensure that there is at least one + // migrator available before opening the wizard. + // Note that we don't need to check the default browser first, because + // if that one existed we would have used it in the block above this one. + if (!migrators.some(m => m)) { + // None of the keys produced a usable migrator, so finish up here: + this.finishMigration(); + return; + } + } + + let isRefresh = + migrator && + skipSourceSelection && + migratorKey == AppConstants.MOZ_APP_NAME; + + let entrypoint = this.MIGRATION_ENTRYPOINTS.FIRSTRUN; + if (isRefresh) { + entrypoint = this.MIGRATION_ENTRYPOINTS.FXREFRESH; + } + + this.showMigrationWizard(null, { + entrypoint, + migratorKey, + migrator, + isStartupMigration: !!aProfileStartup, + skipSourceSelection, + profileId: aProfileToMigrate, + }); + } + + /** + * This is only pseudo-private because some tests and helper functions + * still expect to be able to directly access it. + */ + _importQuantities = { + bookmarks: 0, + logins: 0, + history: 0, + cards: 0, + extensions: 0, + }; + + getImportedCount(type) { + if (!this._importQuantities.hasOwnProperty(type)) { + throw new Error( + `Unknown import data type "${type}" passed to getImportedCount` + ); + } + return this._importQuantities[type]; + } + + insertBookmarkWrapper(bookmark) { + this._importQuantities.bookmarks++; + let insertionPromise = lazy.PlacesUtils.bookmarks.insert(bookmark); + if (!gKeepUndoData) { + return insertionPromise; + } + // If we keep undo data, add a promise handler that stores the undo data once + // the bookmark has been inserted in the DB, and then returns the bookmark. + let { parentGuid } = bookmark; + return insertionPromise.then(bm => { + let { guid, lastModified, type } = bm; + gUndoData.get("bookmarks").push({ + parentGuid, + guid, + lastModified, + type, + }); + return bm; + }); + } + + insertManyBookmarksWrapper(bookmarks, parent) { + let insertionPromise = lazy.PlacesUtils.bookmarks.insertTree({ + guid: parent, + children: bookmarks, + }); + return insertionPromise.then( + insertedItems => { + this._importQuantities.bookmarks += insertedItems.length; + if (gKeepUndoData) { + let bmData = gUndoData.get("bookmarks"); + for (let bm of insertedItems) { + let { parentGuid, guid, lastModified, type } = bm; + bmData.push({ parentGuid, guid, lastModified, type }); + } + } + if (parent == lazy.PlacesUtils.bookmarks.toolbarGuid) { + lazy.PlacesUIUtils.maybeToggleBookmarkToolbarVisibility( + true /* aForceVisible */ + ).catch(console.error); + } + }, + ex => console.error(ex) + ); + } + + insertVisitsWrapper(pageInfos) { + let now = new Date(); + // Ensure that none of the dates are in the future. If they are, rewrite + // them to be now. This means we don't loose history entries, but they will + // be valid for the history store. + for (let pageInfo of pageInfos) { + for (let visit of pageInfo.visits) { + if (visit.date && visit.date > now) { + visit.date = now; + } + } + } + this._importQuantities.history += pageInfos.length; + if (gKeepUndoData) { + this.#updateHistoryUndo(pageInfos); + } + return lazy.PlacesUtils.history.insertMany(pageInfos); + } + + async insertLoginsWrapper(logins) { + this._importQuantities.logins += logins.length; + let inserted = await lazy.LoginHelper.maybeImportLogins(logins); + // Note that this means that if we import a login that has a newer password + // than we know about, we will update the login, and an undo of the import + // will not revert this. This seems preferable over removing the login + // outright or storing the old password in the undo file. + if (gKeepUndoData) { + for (let { guid, timePasswordChanged } of inserted) { + gUndoData.get("logins").push({ guid, timePasswordChanged }); + } + } + } + + /** + * Iterates through the favicons, sniffs for a mime type, + * and uses the mime type to properly import the favicon. + * + * @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) { + 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 fakeFaviconURI = Services.io.newURI( + "fake-favicon-uri:" + faviconDataItem.uri.spec + ); + lazy.PlacesUtils.favicons.replaceFaviconData( + fakeFaviconURI, + faviconDataItem.faviconData, + mimeType + ); + lazy.PlacesUtils.favicons.setAndFetchFaviconForPage( + faviconDataItem.uri, + fakeFaviconURI, + true, + lazy.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + } + } + + async insertCreditCardsWrapper(cards) { + this._importQuantities.cards += cards.length; + let { formAutofillStorage } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillStorage.sys.mjs" + ); + + await formAutofillStorage.initialize(); + for (let card of cards) { + try { + await formAutofillStorage.creditCards.add(card); + } catch (e) { + console.error("Failed to insert credit card due to error: ", e, card); + } + } + } + + /** + * Responsible for calling the AddonManager API that ultimately installs the + * matched add-ons. + * + * @param {string} migratorKey a migrator key that we pass to + * `AMBrowserExtensionsImport` as the "browser + * identifier" used to match add-ons + * @param {string[]} extensionIDs a list of extension IDs from another browser + */ + async installExtensionsWrapper(migratorKey, extensionIDs) { + const totalExtensions = extensionIDs.length; + + let importedAddonIDs = []; + try { + const result = await lazy.AMBrowserExtensionsImport.stageInstalls( + migratorKey, + extensionIDs + ); + importedAddonIDs = result.importedAddonIDs; + } catch (e) { + console.error(`Failed to import extensions: ${e}`); + } + + this._importQuantities.extensions += importedAddonIDs.length; + + if (!importedAddonIDs.length) { + return [ + lazy.MigrationWizardConstants.PROGRESS_VALUE.WARNING, + importedAddonIDs, + ]; + } + if (totalExtensions == importedAddonIDs.length) { + return [ + lazy.MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + importedAddonIDs, + ]; + } + return [ + lazy.MigrationWizardConstants.PROGRESS_VALUE.INFO, + importedAddonIDs, + ]; + } + + initializeUndoData() { + gKeepUndoData = true; + gUndoData = new Map([ + ["bookmarks", []], + ["visits", []], + ["logins", []], + ]); + } + + async #postProcessUndoData(state) { + if (!state) { + return state; + } + let bookmarkFolders = state + .get("bookmarks") + .filter(b => b.type == lazy.PlacesUtils.bookmarks.TYPE_FOLDER); + + let bookmarkFolderData = []; + let bmPromises = bookmarkFolders.map(({ guid }) => { + // Ignore bookmarks where the promise doesn't resolve (ie that are missing) + // Also check that the bookmark fetch returns isn't null before adding it. + return lazy.PlacesUtils.bookmarks.fetch(guid).then( + bm => bm && bookmarkFolderData.push(bm), + () => {} + ); + }); + + await Promise.all(bmPromises); + let folderLMMap = new Map( + bookmarkFolderData.map(b => [b.guid, b.lastModified]) + ); + for (let bookmark of bookmarkFolders) { + let lastModified = folderLMMap.get(bookmark.guid); + // If the bookmark was deleted, the map will be returning null, so check: + if (lastModified) { + bookmark.lastModified = lastModified; + } + } + return state; + } + + stopAndRetrieveUndoData() { + let undoData = gUndoData; + gUndoData = null; + gKeepUndoData = false; + return this.#postProcessUndoData(undoData); + } + + #updateHistoryUndo(pageInfos) { + let visits = gUndoData.get("visits"); + let visitMap = new Map(visits.map(v => [v.url, v])); + for (let pageInfo of pageInfos) { + let visitCount = pageInfo.visits.length; + let first, last; + if (visitCount > 1) { + let dates = pageInfo.visits.map(v => v.date); + first = Math.min.apply(Math, dates); + last = Math.max.apply(Math, dates); + } else { + first = last = pageInfo.visits[0].date; + } + let url = pageInfo.url; + if (url instanceof Ci.nsIURI) { + url = pageInfo.url.spec; + } else if (typeof url != "string") { + pageInfo.url.href; + } + + try { + new URL(url); + } catch (ex) { + // This won't save and we won't need to 'undo' it, so ignore this URL. + continue; + } + if (!visitMap.has(url)) { + visitMap.set(url, { url, visitCount, first, last }); + } else { + let currentData = visitMap.get(url); + currentData.visitCount += visitCount; + currentData.first = Math.min(currentData.first, first); + currentData.last = Math.max(currentData.last, last); + } + } + gUndoData.set("visits", Array.from(visitMap.values())); + } + + /** + * Cleans up references to migrators and nsIProfileInstance instances. + */ + finishMigration() { + gMigrators = null; + gProfileStartup = null; + gL10n = null; + } + + get availableMigratorKeys() { + return [...this.#migrators.keys()]; + } + + get availableFileMigrators() { + return [...this.#fileMigrators.values()]; + } + + /** + * Enum for the entrypoint that is being used to start migration. + * Callers can use the MIGRATION_ENTRYPOINTS getter to use these. + * + * These values are what's written into the + * FX_MIGRATION_ENTRY_POINT_CATEGORICAL histogram after a migration. + * + * @see MIGRATION_ENTRYPOINTS + * @readonly + * @enum {string} + */ + #MIGRATION_ENTRYPOINTS_ENUM = Object.freeze({ + /** The entrypoint was not supplied */ + UNKNOWN: "unknown", + + /** Migration is occurring at startup */ + FIRSTRUN: "firstrun", + + /** Migration is occurring at after a profile refresh */ + FXREFRESH: "fxrefresh", + + /** Migration is being started from the Library window */ + PLACES: "places", + + /** Migration is being started from our password management UI */ + PASSWORDS: "passwords", + + /** Migration is being started from the default about:home/about:newtab */ + NEWTAB: "newtab", + + /** Migration is being started from the File menu */ + FILE_MENU: "file_menu", + + /** Migration is being started from the Help menu */ + HELP_MENU: "help_menu", + + /** Migration is being started from the Bookmarks Toolbar */ + BOOKMARKS_TOOLBAR: "bookmarks_toolbar", + + /** Migration is being started from about:preferences */ + PREFERENCES: "preferences", + + /** Migration is being started from about:firefoxview */ + FIREFOX_VIEW: "firefox_view", + }); + + /** + * Returns an enum that should be used to record the entrypoint for + * starting a migration. + * + * @returns {number} + */ + get MIGRATION_ENTRYPOINTS() { + return this.#MIGRATION_ENTRYPOINTS_ENUM; + } + + /** + * Enum for the numeric value written to the FX_MIGRATION_SOURCE_BROWSER. + * histogram + * + * @see getSourceIdForTelemetry + * @readonly + * @enum {number} + */ + #SOURCE_NAME_TO_ID_MAPPING_ENUM = Object.freeze({ + nothing: 1, + firefox: 2, + edge: 3, + ie: 4, + chrome: 5, + "chrome-beta": 5, + "chrome-dev": 5, + chromium: 6, + canary: 7, + safari: 8, + "chromium-360se": 9, + "chromium-edge": 10, + "chromium-edge-beta": 10, + brave: 11, + opera: 12, + "opera-gx": 14, + vivaldi: 13, + }); + + getSourceIdForTelemetry(sourceName) { + return this.#SOURCE_NAME_TO_ID_MAPPING_ENUM[sourceName] || 0; + } + + get HISTORY_MAX_AGE_IN_MILLISECONDS() { + return this.HISTORY_MAX_AGE_IN_DAYS * 24 * 60 * 60 * 1000; + } + + /** + * Determines whether or not the underlying platform supports creating + * native file pickers that can do folder selection, which is a + * pre-requisite for getting read-access permissions for data from other + * browsers that we can import from. + * + * @returns {Promise} + */ + canGetPermissionsOnPlatform() { + return lazy.gCanGetPermissionsOnPlatformPromise; + } +} + +const MigrationUtilsSingleton = new MigrationUtils(); + +export { MigrationUtilsSingleton as MigrationUtils }; diff --git a/browser/components/migration/MigrationWizardChild.sys.mjs b/browser/components/migration/MigrationWizardChild.sys.mjs new file mode 100644 index 0000000000..fc8fe4b19d --- /dev/null +++ b/browser/components/migration/MigrationWizardChild.sys.mjs @@ -0,0 +1,400 @@ +/* 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 { MigrationWizardConstants } from "chrome://browser/content/migration/migration-wizard-constants.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "SHOW_IMPORT_ALL_PREF", + "browser.migrate.content-modal.import-all.enabled", + false +); + +/** + * This class is responsible for updating the state of a + * component, and for listening for events from that component to perform + * various migration functions. + */ +export class MigrationWizardChild extends JSWindowActorChild { + #wizardEl = null; + + /** + * Retrieves the list of browsers and profiles from the parent process, and then + * puts the migration wizard onto the selection page showing the list that they + * can import from. + * + * @param {boolean} [allowOnlyFileMigrators=null] + * Set to true if showing the selection page is allowed if no browser migrators + * are found. If not true, and no browser migrators are found, then the wizard + * will be sent to the NO_BROWSERS_FOUND page. + * @param {string} [migratorKey=null] + * If set, this will automatically select the first associated migrator with that + * migratorKey in the selector. If not set, the first item in the retrieved list + * of migrators will be selected. + * @param {string} [fileImportErrorMessage=null] + * If set, this will display an error message below the browser / profile selector + * indicating that something had previously gone wrong with an import of type + * MIGRATOR_TYPES.FILE. + */ + async #populateMigrators( + allowOnlyFileMigrators, + migratorKey, + fileImportErrorMessage + ) { + let migrators = await this.sendQuery("GetAvailableMigrators"); + let hasBrowserMigrators = migrators.some(migrator => { + return migrator.type == MigrationWizardConstants.MIGRATOR_TYPES.BROWSER; + }); + let hasFileMigrators = migrators.some(migrator => { + return migrator.type == MigrationWizardConstants.MIGRATOR_TYPES.FILE; + }); + if (!hasBrowserMigrators && !allowOnlyFileMigrators) { + this.setComponentState({ + page: MigrationWizardConstants.PAGES.NO_BROWSERS_FOUND, + hasFileMigrators, + }); + this.#sendTelemetryEvent("no_browsers_found"); + } else { + this.setComponentState({ + migrators, + page: MigrationWizardConstants.PAGES.SELECTION, + showImportAll: lazy.SHOW_IMPORT_ALL_PREF, + migratorKey, + fileImportErrorMessage, + }); + } + } + + /** + * General event handler function for events dispatched from the + * component. + * + * @param {Event} event + * The DOM event being handled. + * @returns {Promise} + */ + async handleEvent(event) { + this.#wizardEl = event.target; + + switch (event.type) { + case "MigrationWizard:RequestState": { + this.#sendTelemetryEvent("opened"); + await this.#requestState(event.detail?.allowOnlyFileMigrators); + break; + } + + case "MigrationWizard:BeginMigration": { + let extraArgs = this.#recordBeginMigrationEvent(event.detail); + + let hasPermissions = await this.sendQuery("CheckPermissions", { + key: event.detail.key, + type: event.detail.type, + }); + + if (!hasPermissions) { + if (event.detail.key == "safari") { + this.#sendTelemetryEvent("safari_perms"); + this.setComponentState({ + page: MigrationWizardConstants.PAGES.SAFARI_PERMISSION, + }); + } else { + console.error( + `A migrator with key ${event.detail.key} needs permissions, ` + + "and no UI exists for that right now." + ); + } + return; + } + + await this.beginMigration(event.detail, extraArgs); + break; + } + + case "MigrationWizard:RequestSafariPermissions": { + let success = await this.sendQuery("RequestSafariPermissions"); + if (success) { + let extraArgs = this.#constructExtraArgs(event.detail); + await this.beginMigration(event.detail, extraArgs); + } + break; + } + + case "MigrationWizard:SelectSafariPasswordFile": { + let path = await this.sendQuery("SelectSafariPasswordFile"); + if (path) { + event.detail.safariPasswordFilePath = path; + + let passwordResourceIndex = event.detail.resourceTypes.indexOf( + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS + ); + event.detail.resourceTypes.splice(passwordResourceIndex, 1); + + let extraArgs = this.#constructExtraArgs(event.detail); + await this.beginMigration(event.detail, extraArgs); + } + break; + } + + case "MigrationWizard:OpenAboutAddons": { + this.sendAsyncMessage("OpenAboutAddons"); + break; + } + + case "MigrationWizard:PermissionsNeeded": { + // In theory, the migrator permissions might be requested on any + // platform - but in practice, this only happens on Linux, so that's + // why the event is named linux_perms. + this.#sendTelemetryEvent("linux_perms", { + migrator_key: event.detail.key, + }); + break; + } + + case "MigrationWizard:GetPermissions": { + let success = await this.sendQuery("GetPermissions", { + key: event.detail.key, + }); + if (success) { + await this.#requestState(true /* allowOnlyFileMigrators */); + } + break; + } + } + } + + async #requestState(allowOnlyFileMigrators) { + this.setComponentState({ + page: MigrationWizardConstants.PAGES.LOADING, + }); + + await this.#populateMigrators(allowOnlyFileMigrators); + + this.#wizardEl.dispatchEvent( + new this.contentWindow.CustomEvent("MigrationWizard:Ready", { + bubbles: true, + }) + ); + } + + /** + * Sends a message to the parent actor to record Event Telemetry. + * + * @param {string} type + * The type of event being recorded. + * @param {object} [args=null] + * Optional extra_args to supply for the event. + */ + #sendTelemetryEvent(type, args) { + this.sendAsyncMessage("RecordEvent", { type, args }); + } + + /** + * Constructs extra arguments to pass to some Event Telemetry based + * on the MigrationDetails passed up from the MigrationWizard. + * + * See migration-wizard.mjs for a definition of MigrationDetails. + * + * @param {object} migrationDetails + * A MigrationDetails object. + * @returns {object} + */ + #constructExtraArgs(migrationDetails) { + let extraArgs = { + migrator_key: migrationDetails.key, + history: "0", + formdata: "0", + passwords: "0", + bookmarks: "0", + payment_methods: "0", + extensions: "0", + other: 0, + }; + + for (let type of migrationDetails.resourceTypes) { + switch (type) { + case MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY: { + extraArgs.history = "1"; + break; + } + + case MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA: { + extraArgs.formdata = "1"; + break; + } + + case MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS: { + extraArgs.passwords = "1"; + break; + } + + case MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS: { + extraArgs.bookmarks = "1"; + break; + } + + case MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS: { + extraArgs.extensions = "1"; + break; + } + + case MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES + .PAYMENT_METHODS: { + extraArgs.payment_methods = "1"; + break; + } + + default: { + extraArgs.other++; + } + } + } + + // Event Telemetry extra arguments expect strings for every value, so + // now we coerce our "other" count into a string. + extraArgs.other = String(extraArgs.other); + return extraArgs; + } + + /** + * This migration wizard combines a lot of steps (selecting the browser, profile, + * resources, and starting the migration) into a single page. This helper method + * records Event Telemetry for each of those actions at the same time when a + * migration begins. + * + * This method returns the extra_args object that was constructed for the + * resources_selected and migration_started event so that a + * "migration_finished" event can use the same extra_args without + * regenerating it. + * + * See migration-wizard.mjs for a definition of MigrationDetails. + * + * @param {object} migrationDetails + * A MigrationDetails object. + * @returns {object} + */ + #recordBeginMigrationEvent(migrationDetails) { + this.#sendTelemetryEvent("browser_selected", { + migrator_key: migrationDetails.key, + }); + + if (migrationDetails.profile) { + this.#sendTelemetryEvent("profile_selected", { + migrator_key: migrationDetails.key, + }); + } + + let extraArgs = this.#constructExtraArgs(migrationDetails); + + extraArgs.configured = String(Number(migrationDetails.expandedDetails)); + this.#sendTelemetryEvent("resources_selected", extraArgs); + delete extraArgs.configured; + + this.#sendTelemetryEvent("migration_started", extraArgs); + return extraArgs; + } + + /** + * Sends a message to the parent actor to attempt a migration. + * + * See migration-wizard.mjs for a definition of MigrationDetails. + * + * @param {object} migrationDetails + * A MigrationDetails object. + * @param {object} extraArgs + * Extra argument object to pass to the Event Telemetry for finishing + * the migration. + * @returns {Promise} + * Returns a Promise that resolves after the parent responds to the migration + * message. + */ + async beginMigration(migrationDetails, extraArgs) { + if ( + migrationDetails.key == "safari" && + migrationDetails.resourceTypes.includes( + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS + ) && + !migrationDetails.safariPasswordFilePath + ) { + this.#sendTelemetryEvent("safari_password_file"); + this.setComponentState({ + page: MigrationWizardConstants.PAGES.SAFARI_PASSWORD_PERMISSION, + }); + return; + } + + extraArgs = await this.sendQuery("Migrate", { + migrationDetails, + extraArgs, + }); + this.#sendTelemetryEvent("migration_finished", extraArgs); + + this.#wizardEl.dispatchEvent( + new this.contentWindow.CustomEvent("MigrationWizard:DoneMigration", { + bubbles: true, + }) + ); + } + + /** + * General message handler function for messages received from the + * associated MigrationWizardParent JSWindowActor. + * + * @param {ReceiveMessageArgument} message + * The message received from the MigrationWizardParent. + */ + receiveMessage(message) { + switch (message.name) { + case "UpdateProgress": { + this.setComponentState({ + page: MigrationWizardConstants.PAGES.PROGRESS, + progress: message.data.progress, + key: message.data.key, + }); + break; + } + case "UpdateFileImportProgress": { + this.setComponentState({ + page: MigrationWizardConstants.PAGES.FILE_IMPORT_PROGRESS, + progress: message.data.progress, + title: message.data.title, + }); + break; + } + case "FileImportProgressError": { + this.#populateMigrators( + true, + message.data.migratorKey, + message.data.fileImportErrorMessage + ); + break; + } + } + } + + /** + * Calls the `setState` method on the component. The + * state is cloned into the execution scope of this.#wizardEl. + * + * @param {object} state The state object that a + * component expects. See the documentation for the element's setState + * method for more details. + */ + setComponentState(state) { + if (!this.#wizardEl) { + return; + } + // We waive XrayWrappers in the event that the element is embedded in + // a document without system privileges, like about:welcome. + Cu.waiveXrays(this.#wizardEl).setState( + Cu.cloneInto( + state, + // ownerGlobal doesn't exist in content windows. + // eslint-disable-next-line mozilla/use-ownerGlobal + this.#wizardEl.ownerDocument.defaultView + ) + ); + } +} diff --git a/browser/components/migration/MigrationWizardParent.sys.mjs b/browser/components/migration/MigrationWizardParent.sys.mjs new file mode 100644 index 0000000000..deb0e89007 --- /dev/null +++ b/browser/components/migration/MigrationWizardParent.sys.mjs @@ -0,0 +1,834 @@ +/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; +import { E10SUtils } from "resource://gre/modules/E10SUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineLazyGetter(lazy, "gFluentStrings", function () { + return new Localization([ + "branding/brand.ftl", + "browser/migrationWizard.ftl", + ]); +}); + +ChromeUtils.defineESModuleGetters(lazy, { + FirefoxProfileMigrator: "resource:///modules/FirefoxProfileMigrator.sys.mjs", + InternalTestingProfileMigrator: + "resource:///modules/InternalTestingProfileMigrator.sys.mjs", + LoginCSVImport: "resource://gre/modules/LoginCSVImport.sys.mjs", + MigrationWizardConstants: + "chrome://browser/content/migration/migration-wizard-constants.mjs", + PasswordFileMigrator: "resource:///modules/FileMigrators.sys.mjs", +}); + +if (AppConstants.platform == "macosx") { + ChromeUtils.defineESModuleGetters(lazy, { + SafariProfileMigrator: "resource:///modules/SafariProfileMigrator.sys.mjs", + }); +} + +/** + * Set to true once the first instance of MigrationWizardParent has received + * a "GetAvailableMigrators" message. + */ +let gHasOpenedBefore = false; + +/** + * This class is responsible for communicating with MigrationUtils to do the + * actual heavy-lifting of any kinds of migration work, based on messages from + * the associated MigrationWizardChild. + */ +export class MigrationWizardParent extends JSWindowActorParent { + constructor() { + super(); + Services.telemetry.setEventRecordingEnabled("browser.migration", true); + } + + didDestroy() { + Services.obs.notifyObservers(this, "MigrationWizard:Destroyed"); + MigrationUtils.finishMigration(); + } + + /** + * General message handler function for messages received from the + * associated MigrationWizardChild JSWindowActor. + * + * @param {ReceiveMessageArgument} message + * The message received from the MigrationWizardChild. + * @returns {Promise} + */ + async receiveMessage(message) { + // Some belt-and-suspenders here, mainly because the migration-wizard + // component can be embedded in less privileged content pages, so let's + // make sure that any messages from content are coming from the privileged + // about content process type. + if ( + !this.browsingContext.currentWindowGlobal.isInProcess && + this.browsingContext.currentRemoteType != + E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE + ) { + throw new Error( + "MigrationWizardParent: received message from the wrong content process type." + ); + } + + switch (message.name) { + case "GetAvailableMigrators": { + let start = Cu.now(); + + let availableMigrators = []; + for (const key of MigrationUtils.availableMigratorKeys) { + availableMigrators.push(this.#getMigratorAndProfiles(key)); + } + + // Wait for all getMigrator calls to resolve in parallel + let results = await Promise.all(availableMigrators); + + for (const migrator of MigrationUtils.availableFileMigrators.values()) { + results.push(await this.#serializeFileMigrator(migrator)); + } + + // Each migrator might give us a single MigratorProfileInstance, + // or an Array of them, so we flatten them out and filter out + // any that ended up going wrong and returning null from the + // #getMigratorAndProfiles call. + let filteredResults = results + .flat() + .filter(result => result) + .sort((a, b) => { + return b.lastModifiedDate - a.lastModifiedDate; + }); + + let elapsed = Cu.now() - start; + if (!gHasOpenedBefore) { + gHasOpenedBefore = true; + Services.telemetry.scalarSet( + "migration.time_to_produce_migrator_list", + elapsed + ); + } + + return filteredResults; + } + + case "Migrate": { + let { migrationDetails, extraArgs } = message.data; + if ( + migrationDetails.type == + lazy.MigrationWizardConstants.MIGRATOR_TYPES.BROWSER + ) { + return this.#doBrowserMigration(migrationDetails, extraArgs); + } else if ( + migrationDetails.type == + lazy.MigrationWizardConstants.MIGRATOR_TYPES.FILE + ) { + let window = this.browsingContext.topChromeWindow; + await this.#doFileMigration(window, migrationDetails.key); + return extraArgs; + } + break; + } + + case "CheckPermissions": { + if ( + message.data.type == + lazy.MigrationWizardConstants.MIGRATOR_TYPES.BROWSER + ) { + let migrator = await MigrationUtils.getMigrator(message.data.key); + return migrator.hasPermissions(); + } + return true; + } + + case "RequestSafariPermissions": { + let safariMigrator = await MigrationUtils.getMigrator("safari"); + return safariMigrator.getPermissions( + this.browsingContext.topChromeWindow + ); + } + + case "SelectSafariPasswordFile": { + return this.#selectSafariPasswordFile( + this.browsingContext.topChromeWindow + ); + } + + case "RecordEvent": { + this.#recordEvent(message.data.type, message.data.args); + break; + } + + case "OpenAboutAddons": { + let browser = this.browsingContext.top.embedderElement; + this.#openAboutAddons(browser); + break; + } + + case "GetPermissions": { + let migrator = await MigrationUtils.getMigrator(message.data.key); + return migrator.getPermissions(this.browsingContext.topChromeWindow); + } + } + + return null; + } + + /** + * Used for recording telemetry in the migration wizard. + * + * @param {string} type + * The type of event being recorded. + * @param {object} args + * The data to pass to telemetry when the event is recorded. + */ + #recordEvent(type, args = null) { + Services.telemetry.recordEvent( + "browser.migration", + type, + "wizard", + null, + args + ); + } + + /** + * Gets the FileMigrator associated with the passed in key, and then opens + * a native file picker configured for that migrator. Once the user selects + * a file from the native file picker, this is then passed to the + * FileMigrator.migrate method. + * + * As the migration occurs, this will send UpdateProgress messages to the + * MigrationWizardChild to show the beginning and then the ending state of + * the migration. + * + * @param {DOMWindow} window + * The window that the native file picker should be associated with. This + * cannot be null. See nsIFilePicker.init for more details. + * @param {string} key + * The unique identification key for a file migrator. + * @returns {Promise} + * Resolves once the file migrator's migrate method has resolved. + */ + async #doFileMigration(window, key) { + let fileMigrator = MigrationUtils.getFileMigrator(key); + let filePickerConfig = await fileMigrator.getFilePickerConfig(); + + let { result, path } = await new Promise(resolve => { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init(window, filePickerConfig.title, Ci.nsIFilePicker.modeOpen); + + for (let filter of filePickerConfig.filters) { + fp.appendFilter(filter.title, filter.extensionPattern); + } + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.open(async fileOpenResult => { + resolve({ result: fileOpenResult, path: fp.file.path }); + }); + }); + + if (result == Ci.nsIFilePicker.returnCancel) { + // If the user cancels out of the file picker, the migration wizard should + // still be in the state that lets the user re-open the file picker if + // they closed it by accident, so we don't have to do anything else here. + return; + } + + let progress = {}; + for (let resourceType of fileMigrator.displayedResourceTypes) { + progress[resourceType] = { + value: lazy.MigrationWizardConstants.PROGRESS_VALUE.LOADING, + message: "", + }; + } + + let [progressHeaderString, successHeaderString] = + await lazy.gFluentStrings.formatValues([ + fileMigrator.progressHeaderL10nID, + fileMigrator.successHeaderL10nID, + ]); + + this.sendAsyncMessage("UpdateFileImportProgress", { + title: progressHeaderString, + progress, + }); + + let migrationResult; + try { + migrationResult = await fileMigrator.migrate(path); + } catch (e) { + this.sendAsyncMessage("FileImportProgressError", { + migratorKey: key, + fileImportErrorMessage: e.message, + }); + return; + } + + let successProgress = {}; + for (let resourceType in migrationResult) { + successProgress[resourceType] = { + value: lazy.MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + message: migrationResult[resourceType], + }; + } + this.sendAsyncMessage("UpdateFileImportProgress", { + title: successHeaderString, + progress: successProgress, + }); + } + + /** + * Handles a request to open a native file picker to get the path to a + * CSV file that contains passwords exported from Safari. The returned + * path is in the form of a string, or `null` if the user cancelled the + * native picker. + * + * @param {DOMWindow} window + * The window that the native file picker should be associated with. This + * cannot be null. See nsIFilePicker.init for more details. + * @returns {Promise} + */ + async #selectSafariPasswordFile(window) { + let fileMigrator = MigrationUtils.getFileMigrator( + lazy.PasswordFileMigrator.key + ); + let filePickerConfig = await fileMigrator.getFilePickerConfig(); + + let { result, path } = await new Promise(resolve => { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init(window, filePickerConfig.title, Ci.nsIFilePicker.modeOpen); + + for (let filter of filePickerConfig.filters) { + fp.appendFilter(filter.title, filter.extensionPattern); + } + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.open(async fileOpenResult => { + resolve({ result: fileOpenResult, path: fp.file.path }); + }); + }); + + if (result == Ci.nsIFilePicker.returnCancel) { + // If the user cancels out of the file picker, the migration wizard should + // still be in the state that lets the user re-open the file picker if + // they closed it by accident, so we don't have to do anything else here. + return null; + } + + return path; + } + + /** + * Calls into MigrationUtils to perform a migration given the parameters + * sent via the wizard. + * + * @param {MigrationDetails} migrationDetails + * See migration-wizard.mjs for a definition of MigrationDetails. + * @param {object} extraArgs + * Extra argument object that will be passed to the Event Telemetry for + * finishing the migration. This was initialized in the child actor, and + * will be sent back down to it to write to Telemetry once migration + * completes. + * + * @returns {Promise} + * Resolves once the Migration:Ended observer notification has fired, + * passing the extraArgs for Telemetry back with any relevant properties + * updated. + */ + async #doBrowserMigration(migrationDetails, extraArgs) { + Services.telemetry + .getHistogramById("FX_MIGRATION_SOURCE_BROWSER") + .add(MigrationUtils.getSourceIdForTelemetry(migrationDetails.key)); + + let migrator = await MigrationUtils.getMigrator(migrationDetails.key); + let availableResourceTypes = await migrator.getMigrateData( + migrationDetails.profile + ); + let resourceTypesToMigrate = 0; + let progress = {}; + let migrationUsageHist = + Services.telemetry.getKeyedHistogramById("FX_MIGRATION_USAGE"); + + for (let resourceTypeName of migrationDetails.resourceTypes) { + let resourceType = MigrationUtils.resourceTypes[resourceTypeName]; + if (availableResourceTypes & resourceType) { + resourceTypesToMigrate |= resourceType; + progress[resourceTypeName] = { + value: lazy.MigrationWizardConstants.PROGRESS_VALUE.LOADING, + message: "", + }; + + if (!migrationDetails.autoMigration) { + migrationUsageHist.add(migrationDetails.key, Math.log2(resourceType)); + } + } + } + + if ( + migrationDetails.key == lazy.SafariProfileMigrator?.key && + migrationDetails.safariPasswordFilePath + ) { + // The caller supplied a password export file for Safari. We're going to + // pretend that there was a PASSWORDS resource for Safari to represent + // the state of importing from that file. + progress[ + lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS + ] = { + value: lazy.MigrationWizardConstants.PROGRESS_VALUE.LOADING, + message: "", + }; + + this.sendAsyncMessage("UpdateProgress", { + key: migrationDetails.key, + progress, + }); + + try { + let summary = await lazy.LoginCSVImport.importFromCSV( + migrationDetails.safariPasswordFilePath + ); + let quantity = summary.filter(entry => entry.result == "added").length; + + progress[ + lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS + ] = { + value: lazy.MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + message: await lazy.gFluentStrings.formatValue( + "migration-wizard-progress-success-passwords", + { + quantity, + } + ), + }; + } catch (e) { + progress[ + lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS + ] = { + value: lazy.MigrationWizardConstants.PROGRESS_VALUE.WARNING, + message: await lazy.gFluentStrings.formatValue( + "migration-passwords-from-file-no-valid-data" + ), + }; + } + } + + this.sendAsyncMessage("UpdateProgress", { + key: migrationDetails.key, + progress, + }); + + // It's possible that only a Safari password file path was sent up, and + // there's nothing left to migrate, in which case we're done here. + if ( + migrationDetails.safariPasswordFilePath && + !migrationDetails.resourceTypes.length + ) { + return extraArgs; + } + + try { + await migrator.migrate( + resourceTypesToMigrate, + false, + migrationDetails.profile, + async (resourceTypeNum, success, details) => { + // Unfortunately, MigratorBase hands us the the numeric value of the + // MigrationUtils.resourceType for this callback. For now, we'll just + // do a look-up to map it to the right constant. + let foundResourceTypeName; + for (let resourceTypeName in MigrationUtils.resourceTypes) { + if ( + MigrationUtils.resourceTypes[resourceTypeName] == resourceTypeNum + ) { + foundResourceTypeName = resourceTypeName; + break; + } + } + + if (!foundResourceTypeName) { + console.error( + "Could not find a resource type for value: ", + resourceTypeNum + ); + } else { + if (!success) { + Services.telemetry + .getKeyedHistogramById("FX_MIGRATION_ERRORS") + .add(migrationDetails.key, Math.log2(resourceTypeNum)); + } + if ( + foundResourceTypeName == + lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS + ) { + if (!success) { + // did not match any extensions + extraArgs.extensions = + lazy.MigrationWizardConstants.EXTENSIONS_IMPORT_RESULT.NONE_MATCHED; + progress[foundResourceTypeName] = { + value: lazy.MigrationWizardConstants.PROGRESS_VALUE.WARNING, + message: await lazy.gFluentStrings.formatValue( + "migration-wizard-progress-no-matched-extensions" + ), + linkURL: Services.urlFormatter.formatURLPref( + "extensions.getAddons.link.url" + ), + linkText: await lazy.gFluentStrings.formatValue( + "migration-wizard-progress-extensions-addons-link" + ), + }; + } else if ( + details?.progressValue == + lazy.MigrationWizardConstants.PROGRESS_VALUE.SUCCESS + ) { + // did match all extensions + extraArgs.extensions = + lazy.MigrationWizardConstants.EXTENSIONS_IMPORT_RESULT.ALL_MATCHED; + progress[foundResourceTypeName] = { + value: lazy.MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + message: await lazy.gFluentStrings.formatValue( + "migration-wizard-progress-success-extensions", + { + quantity: details.totalExtensions.length, + } + ), + }; + } else if ( + details?.progressValue == + lazy.MigrationWizardConstants.PROGRESS_VALUE.INFO + ) { + // did match some extensions + extraArgs.extensions = + lazy.MigrationWizardConstants.EXTENSIONS_IMPORT_RESULT.PARTIAL_MATCH; + progress[foundResourceTypeName] = { + value: lazy.MigrationWizardConstants.PROGRESS_VALUE.INFO, + message: await lazy.gFluentStrings.formatValue( + "migration-wizard-progress-partial-success-extensions", + { + matched: details.importedExtensions.length, + quantity: details.totalExtensions.length, + } + ), + linkURL: + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "import-data-another-browser", + linkText: await lazy.gFluentStrings.formatValue( + "migration-wizard-progress-extensions-support-link" + ), + }; + } + } else { + progress[foundResourceTypeName] = { + value: success + ? lazy.MigrationWizardConstants.PROGRESS_VALUE.SUCCESS + : lazy.MigrationWizardConstants.PROGRESS_VALUE.WARNING, + message: await this.#getStringForImportQuantity( + migrationDetails.key, + foundResourceTypeName + ), + }; + } + this.sendAsyncMessage("UpdateProgress", { + key: migrationDetails.key, + progress, + }); + } + } + ); + } catch (e) { + console.error(e); + } + + return extraArgs; + } + + /** + * @typedef {object} MigratorProfileInstance + * An object that describes a single user profile (or the default + * user profile) for a particular migrator. + * @property {string} key + * The unique identification key for a migrator. + * @property {string} displayName + * The display name for the migrator that will be shown to the user + * in the wizard. + * @property {string[]} resourceTypes + * An array of strings, where each string represents a resource type + * that can be imported for this migrator and profile. The strings + * should be one of the key values of + * MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. + * + * Example: ["HISTORY", "FORMDATA", "PASSWORDS", "BOOKMARKS"] + * @property {object|null} profile + * A description of the user profile that the migrator can import. + * @property {string} profile.id + * A unique ID for the user profile. + * @property {string} profile.name + * The display name for the user profile. + */ + + /** + * Asynchronously fetches a migrator for a particular key, and then + * also gets any user profiles that exist on for that migrator. Resolves + * to null if something goes wrong getting information about the migrator + * or any of the user profiles. + * + * @param {string} key + * The unique identification key for a migrator. + * @returns {Promise} + */ + async #getMigratorAndProfiles(key) { + try { + let migrator = await MigrationUtils.getMigrator(key); + if (!migrator?.enabled) { + return null; + } + + if (!(await migrator.hasPermissions())) { + // If we're unable to get permissions for this migrator, then we + // just don't bother showing it. + let permissionsPath = await migrator.canGetPermissions(); + if (!permissionsPath) { + return null; + } + return this.#serializeMigratorAndProfile( + migrator, + null, + false /* hasPermissions */, + permissionsPath + ); + } + + let sourceProfiles = await migrator.getSourceProfiles(); + if (Array.isArray(sourceProfiles)) { + if (!sourceProfiles.length) { + return null; + } + + Services.telemetry.keyedScalarAdd( + "migration.discovered_migrators", + key, + sourceProfiles.length + ); + + let result = []; + for (let profile of sourceProfiles) { + result.push( + await this.#serializeMigratorAndProfile(migrator, profile) + ); + } + return result; + } + + Services.telemetry.keyedScalarAdd( + "migration.discovered_migrators", + key, + 1 + ); + return this.#serializeMigratorAndProfile(migrator, sourceProfiles); + } catch (e) { + console.error(`Could not get migrator with key ${key}`, e); + } + return null; + } + + /** + * Asynchronously fetches information about what resource types can be + * migrated for a particular migrator and user profile, and then packages + * the migrator, user profile data, and resource type data into an object + * that can be sent down to the MigrationWizardChild. + * + * @param {MigratorBase} migrator + * A migrator subclass of MigratorBase. + * @param {object|null} profileObj + * The user profile object representing the profile to get information + * about. This object is usually gotten by calling getSourceProfiles on + * the migrator. + * @param {boolean} [hasPermissions=true] + * Whether or not the migrator has permission to read the data for the + * other browser. It is expected that the caller will have already + * computed this by calling hasPermissions() on the migrator, and + * passing the result into this method. This is true by default. + * @param {string} [permissionsPath=undefined] + * The path that the selected migrator needs read access to in order to + * do a migration, in the event that hasPermissions is false. This is + * undefined if hasPermissions is true. + * @returns {Promise} + */ + async #serializeMigratorAndProfile( + migrator, + profileObj, + hasPermissions = true, + permissionsPath + ) { + let [profileMigrationData, lastModifiedDate] = await Promise.all([ + migrator.getMigrateData(profileObj), + migrator.getLastUsedDate(), + ]); + + let availableResourceTypes = []; + + // Even if we don't have permissions, we'll show the resources available + // for Safari. For Safari, the workflow is to request permissions only + // after the resources have been selected. + if ( + hasPermissions || + migrator.constructor.key == lazy.SafariProfileMigrator?.key + ) { + for (let resourceType in MigrationUtils.resourceTypes) { + // Normally, we check each possible resourceType to see if we have one or + // more corresponding resourceTypes in profileMigrationData. The exception + // is for Safari, where the migrator does not expose a PASSWORDS resource + // type, but we allow the user to express that they'd like to import + // passwords from it anyways. This is because the Safari migration flow is + // special, and allows the user to import passwords from a file exported + // from Safari. + if ( + profileMigrationData & MigrationUtils.resourceTypes[resourceType] || + (migrator.constructor.key == lazy.SafariProfileMigrator?.key && + MigrationUtils.resourceTypes[resourceType] == + MigrationUtils.resourceTypes.PASSWORDS && + Services.prefs.getBoolPref( + "signon.management.page.fileImport.enabled", + false + )) + ) { + availableResourceTypes.push(resourceType); + } + } + } + + let displayName; + + if (migrator.constructor.key == lazy.InternalTestingProfileMigrator.key) { + // In the case of the InternalTestingProfileMigrator, which is never seen + // by users outside of testing, we don't make our localization community + // localize it's display name, and just display the ID instead. + displayName = migrator.constructor.displayNameL10nID; + } else { + displayName = await lazy.gFluentStrings.formatValue( + migrator.constructor.displayNameL10nID + ); + } + + return { + type: lazy.MigrationWizardConstants.MIGRATOR_TYPES.BROWSER, + key: migrator.constructor.key, + displayName, + brandImage: migrator.constructor.brandImage, + resourceTypes: availableResourceTypes, + profile: profileObj, + lastModifiedDate, + hasPermissions, + permissionsPath, + }; + } + + /** + * Returns the "success" string for a particular resource type after + * migration has completed. + * + * @param {string} migratorKey + * The key for the migrator being used. + * @param {string} resourceTypeStr + * A string mapping to one of the key values of + * MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. + * @returns {Promise} + * The success string for the resource type after migration has completed. + */ + #getStringForImportQuantity(migratorKey, resourceTypeStr) { + if (migratorKey == lazy.FirefoxProfileMigrator.key) { + return ""; + } + + switch (resourceTypeStr) { + case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS: { + let quantity = MigrationUtils.getImportedCount("bookmarks"); + let stringID = "migration-wizard-progress-success-bookmarks"; + + if ( + lazy.MigrationWizardConstants.USES_FAVORITES.includes(migratorKey) + ) { + stringID = "migration-wizard-progress-success-favorites"; + } + + return lazy.gFluentStrings.formatValue(stringID, { + quantity, + }); + } + case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY: { + return lazy.gFluentStrings.formatValue( + "migration-wizard-progress-success-history", + { + maxAgeInDays: MigrationUtils.HISTORY_MAX_AGE_IN_DAYS, + } + ); + } + case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS: { + let quantity = MigrationUtils.getImportedCount("logins"); + return lazy.gFluentStrings.formatValue( + "migration-wizard-progress-success-passwords", + { + quantity, + } + ); + } + case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA: { + return lazy.gFluentStrings.formatValue( + "migration-wizard-progress-success-formdata" + ); + } + case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES + .PAYMENT_METHODS: { + let quantity = MigrationUtils.getImportedCount("cards"); + return lazy.gFluentStrings.formatValue( + "migration-wizard-progress-success-payment-methods", + { + quantity, + } + ); + } + default: { + return ""; + } + } + } + + /** + * Returns a Promise that resolves to a serializable representation of a + * FileMigrator for sending down to the MigrationWizard. + * + * @param {FileMigrator} fileMigrator + * The FileMigrator to serialize. + * @returns {Promise} + * The serializable representation of the FileMigrator, or null if the + * migrator is disabled. + */ + async #serializeFileMigrator(fileMigrator) { + if (!fileMigrator.enabled) { + return null; + } + + return { + type: lazy.MigrationWizardConstants.MIGRATOR_TYPES.FILE, + key: fileMigrator.constructor.key, + displayName: await lazy.gFluentStrings.formatValue( + fileMigrator.constructor.displayNameL10nID + ), + brandImage: fileMigrator.constructor.brandImage, + resourceTypes: [], + }; + } + + /** + * Opens the about:addons page in a new background tab in the same window + * as the passed browser. + * + * @param {Element} browser + * The browser element requesting that about:addons opens. + */ + #openAboutAddons(browser) { + let window = browser.ownerGlobal; + window.openTrustedLinkIn("about:addons", "tab", { inBackground: true }); + } +} diff --git a/browser/components/migration/MigratorBase.sys.mjs b/browser/components/migration/MigratorBase.sys.mjs new file mode 100644 index 0000000000..52bfc87b3e --- /dev/null +++ b/browser/components/migration/MigratorBase.sys.mjs @@ -0,0 +1,599 @@ +/* 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 TOPIC_WILL_IMPORT_BOOKMARKS = + "initial-migration-will-import-default-bookmarks"; +const TOPIC_DID_IMPORT_BOOKMARKS = + "initial-migration-did-import-default-bookmarks"; +const TOPIC_PLACES_DEFAULTS_FINISHED = "places-browser-init-complete"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BookmarkHTMLUtils: "resource://gre/modules/BookmarkHTMLUtils.sys.mjs", + FirefoxProfileMigrator: "resource:///modules/FirefoxProfileMigrator.sys.mjs", + MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + ResponsivenessMonitor: "resource://gre/modules/ResponsivenessMonitor.sys.mjs", +}); + +/** + * @typedef {object} MigratorResource + * A resource returned by a subclass of MigratorBase that can migrate + * data to this browser. + * @property {number} type + * A bitfield with bits from MigrationUtils.resourceTypes flipped to indicate + * what this resource represents. A resource can represent one or more types + * of data, for example HISTORY and FORMDATA. + * @property {Function} migrate + * A function that will actually perform the migration of this resource's + * data into this browser. + */ + +/** + * Shared prototype for migrators. + * + * To implement a migrator: + * 1. Import this module. + * 2. Create a subclass of MigratorBase for your new migrator. + * 3. Override the `key` static getter with a unique identifier for the browser + * that this migrator migrates from. + * 4. If the migrator supports multiple profiles, override the sourceProfiles + * Here we default for single-profile migrator. + * 5. Implement getResources(aProfile) (see below). + * 6. For startup-only migrators, override |startupOnlyMigrator|. + * 7. Add the migrator to the MIGRATOR_MODULES structure in MigrationUtils.sys.mjs. + */ +export class MigratorBase { + /** + * This must be overridden to return a simple string identifier for the + * migrator, for example "firefox", "chrome", "opera-gx". This key is what + * is used as an identifier when calling MigrationUtils.getMigrator. + * + * @type {string} + */ + static get key() { + throw new Error("MigratorBase.key must be overridden."); + } + + /** + * This must be overridden to return a Fluent string ID mapping to the display + * name for this migrator. These strings should be defined in migrationWizard.ftl. + * + * @type {string} + */ + static get displayNameL10nID() { + throw new Error("MigratorBase.displayNameL10nID must be overridden."); + } + + /** + * This method should get overridden to return an icon url of the browser + * to be imported from. By default, this will just use the default Favicon + * image. + * + * @type {string} + */ + static get brandImage() { + return "chrome://global/skin/icons/defaultFavicon.svg"; + } + + /** + * OVERRIDE IF AND ONLY IF the source supports multiple profiles. + * + * Returns array of profile objects from which data may be imported. The object + * should have the following keys: + * id - a unique string identifier for the profile + * name - a pretty name to display to the user in the UI + * + * Only profiles from which data can be imported should be listed. Otherwise + * the behavior of the migration wizard isn't well-defined. + * + * For a single-profile source (e.g. safari, ie), this returns null, + * and not an empty array. That is the default implementation. + * + * @abstract + * @returns {object[]|null} + */ + getSourceProfiles() { + return null; + } + + /** + * MUST BE OVERRIDDEN. + * + * Returns an array of "migration resources" objects for the given profile, + * or for the "default" profile, if the migrator does not support multiple + * profiles. + * + * Each migration resource should provide: + * - a |type| getter, returning any of the migration resource types (see + * MigrationUtils.resourceTypes). + * + * - a |migrate| method, taking two arguments, + * aCallback(bool success, object details), for migrating the data for + * this resource. It may do its job synchronously or asynchronously. + * Either way, it must call aCallback(bool aSuccess, object details) + * when it's done. In the case of an exception thrown from |migrate|, + * it's taken as if aCallback(false, {}) is called. The details + * argument is sometimes optional, but conditional on how the + * migration wizard wants to display the migration state for the + * resource. + * + * Note: In the case of a simple asynchronous implementation, you may find + * MigrationUtils.wrapMigrateFunction handy for handling aCallback easily. + * + * For each migration type listed in MigrationUtils.resourceTypes, multiple + * migration resources may be provided. This practice is useful when the + * data for a certain migration type is independently stored in few + * locations. For example, the mac version of Safari stores its "reading list" + * bookmarks in a separate property list. + * + * Note that the importation of a particular migration type is reported as + * successful if _any_ of its resources succeeded to import (that is, called, + * |aCallback(true, {})|). However, completion-status for a particular migration + * type is reported to the UI only once all of its migrators have called + * aCallback. + * + * NOTE: The returned array should only include resources from which data + * can be imported. So, for example, before adding a resource for the + * BOOKMARKS migration type, you should check if you should check that the + * bookmarks file exists. + * + * @abstract + * @param {object|string} aProfile + * The profile from which data may be imported, or an empty string + * in the case of a single-profile migrator. + * In the case of multiple-profiles migrator, it is guaranteed that + * aProfile is a value returned by the sourceProfiles getter (see + * above). + * @returns {Promise|MigratorResource[]} + */ + // eslint-disable-next-line no-unused-vars + getResources(aProfile) { + throw new Error("getResources must be overridden"); + } + + /** + * OVERRIDE in order to provide an estimate of when the last time was + * that somebody used the browser. It is OK that this is somewhat fuzzy - + * history may not be available (or be wiped or not present due to e.g. + * incognito mode). + * + * If not overridden, the promise will resolve to the Unix epoch. + * + * @returns {Promise} + * A Promise that resolves to the last used date. + */ + getLastUsedDate() { + return Promise.resolve(new Date(0)); + } + + /** + * OVERRIDE IF AND ONLY IF the migrator is a startup-only migrator (For now, + * that is just the Firefox migrator, see bug 737381). Default: false. + * + * Startup-only migrators are different in two ways: + * - they may only be used during startup. + * - the user-profile is half baked during migration. The folder exists, + * but it's only accessible through MigrationUtils.profileStartup. + * The migrator can call MigrationUtils.profileStartup.doStartup + * at any point in order to initialize the profile. + * + * @returns {boolean} + * true if the migrator is start-up only. + */ + get startupOnlyMigrator() { + return false; + } + + /** + * Returns true if the migrator is configured to be enabled. This is + * controlled by the `browser.migrate..enabled` boolean + * preference. + * + * @returns {boolean} + * true if the migrator should be shown in the migration wizard. + */ + get enabled() { + let key = this.constructor.key; + return Services.prefs.getBoolPref(`browser.migrate.${key}.enabled`, false); + } + + /** + * Subclasses should implement this if special checks need to be made to determine + * if certain permissions need to be requested before data can be imported. + * The returned Promise resolves to true if the required permissions have + * been granted and a migration could proceed. + * + * @returns {Promise} + */ + async hasPermissions() { + return Promise.resolve(true); + } + + /** + * Subclasses should implement this if special permissions need to be + * requested from the user or the operating system in order to perform + * a migration with this MigratorBase. This will be called only if + * hasPermissions resolves to false. + * + * The returned Promise will resolve to true if permissions were successfully + * obtained, and false otherwise. Implementors should ensure that if a call + * to getPermissions resolves to true, that the MigratorBase will be able to + * get read access to all of the resources it needs to do a migration. + * + * @param {DOMWindow} win + * The top-level DOM window hosting the UI that is requesting the permission. + * This can be used to, for example, anchor a file picker window to the + * same window that is hosting the migration UI. + * @returns {Promise} + */ + // eslint-disable-next-line no-unused-vars + async getPermissions(win) { + return Promise.resolve(true); + } + + /** + * @returns {Promise} + */ + async canGetPermissions() { + return Promise.resolve(false); + } + + /** + * This method returns a number that is the bitwise OR of all resource + * types that are available in aProfile. See MigrationUtils.resourceTypes + * for each resource type. + * + * @param {object|string} aProfile + * The profile from which data may be imported, or an empty string + * in the case of a single-profile migrator. + * @returns {number} + */ + async getMigrateData(aProfile) { + let resources = await this.#getMaybeCachedResources(aProfile); + if (!resources) { + return 0; + } + let types = resources.map(r => r.type); + return types.reduce((a, b) => { + a |= b; + return a; + }, 0); + } + + /** + * @see MigrationUtils + * + * @param {number} aItems + * A bitfield with bits from MigrationUtils.resourceTypes flipped to indicate + * what types of resources should be migrated. + * @param {boolean} aStartup + * True if this migration is occurring during startup. + * @param {object|string} aProfile + * The other browser profile that is being migrated from. + * @param {Function|null} aProgressCallback + * An optional callback that will be fired once a resourceType has finished + * migrating. The callback will be passed the numeric representation of the + * resource type followed by a boolean indicating whether or not the resource + * was migrated successfully and optionally an object containing additional + * details. + */ + async migrate(aItems, aStartup, aProfile, aProgressCallback = () => {}) { + let resources = await this.#getMaybeCachedResources(aProfile); + if (!resources.length) { + throw new Error("migrate called for a non-existent source"); + } + + if (aItems != lazy.MigrationUtils.resourceTypes.ALL) { + resources = resources.filter(r => aItems & r.type); + } + + // Used to periodically give back control to the main-thread loop. + let unblockMainThread = function () { + return new Promise(resolve => { + Services.tm.dispatchToMainThread(resolve); + }); + }; + + let getHistogramIdForResourceType = (resourceType, template) => { + if (resourceType == lazy.MigrationUtils.resourceTypes.HISTORY) { + return template.replace("*", "HISTORY"); + } + if (resourceType == lazy.MigrationUtils.resourceTypes.BOOKMARKS) { + return template.replace("*", "BOOKMARKS"); + } + if (resourceType == lazy.MigrationUtils.resourceTypes.PASSWORDS) { + return template.replace("*", "LOGINS"); + } + return null; + }; + + let browserKey = this.constructor.key; + + let maybeStartTelemetryStopwatch = resourceType => { + let histogramId = getHistogramIdForResourceType( + resourceType, + "FX_MIGRATION_*_IMPORT_MS" + ); + if (histogramId) { + TelemetryStopwatch.startKeyed(histogramId, browserKey); + } + return histogramId; + }; + + let maybeStartResponsivenessMonitor = resourceType => { + let responsivenessMonitor; + let responsivenessHistogramId = getHistogramIdForResourceType( + resourceType, + "FX_MIGRATION_*_JANK_MS" + ); + if (responsivenessHistogramId) { + responsivenessMonitor = new lazy.ResponsivenessMonitor(); + } + return { responsivenessMonitor, responsivenessHistogramId }; + }; + + let maybeFinishResponsivenessMonitor = ( + responsivenessMonitor, + histogramId + ) => { + if (responsivenessMonitor) { + let accumulatedDelay = responsivenessMonitor.finish(); + if (histogramId) { + try { + Services.telemetry + .getKeyedHistogramById(histogramId) + .add(browserKey, accumulatedDelay); + } catch (ex) { + console.error(histogramId, ": ", ex); + } + } + } + }; + + let collectQuantityTelemetry = () => { + for (let resourceType of Object.keys( + lazy.MigrationUtils._importQuantities + )) { + let histogramId = + "FX_MIGRATION_" + resourceType.toUpperCase() + "_QUANTITY"; + try { + Services.telemetry + .getKeyedHistogramById(histogramId) + .add( + browserKey, + lazy.MigrationUtils._importQuantities[resourceType] + ); + } catch (ex) { + console.error(histogramId, ": ", ex); + } + } + }; + + let collectMigrationTelemetry = resourceType => { + // We don't want to collect this if the migration is occurring due to a + // profile refresh. + if (this.constructor.key == lazy.FirefoxProfileMigrator.key) { + return; + } + + let prefKey = null; + switch (resourceType) { + case lazy.MigrationUtils.resourceTypes.BOOKMARKS: { + prefKey = "browser.migrate.interactions.bookmarks"; + break; + } + case lazy.MigrationUtils.resourceTypes.HISTORY: { + prefKey = "browser.migrate.interactions.history"; + break; + } + case lazy.MigrationUtils.resourceTypes.PASSWORDS: { + prefKey = "browser.migrate.interactions.passwords"; + break; + } + default: { + return; + } + } + + if (prefKey) { + Services.prefs.setBoolPref(prefKey, true); + } + }; + + // Called either directly or through the bookmarks import callback. + let doMigrate = async function () { + let resourcesGroupedByItems = new Map(); + resources.forEach(function (resource) { + if (!resourcesGroupedByItems.has(resource.type)) { + resourcesGroupedByItems.set(resource.type, new Set()); + } + resourcesGroupedByItems.get(resource.type).add(resource); + }); + + if (resourcesGroupedByItems.size == 0) { + throw new Error("No items to import"); + } + + let notify = function (aMsg, aItemType) { + Services.obs.notifyObservers(null, aMsg, aItemType); + }; + + for (let resourceType of Object.keys( + lazy.MigrationUtils._importQuantities + )) { + lazy.MigrationUtils._importQuantities[resourceType] = 0; + } + notify("Migration:Started"); + for (let [migrationType, itemResources] of resourcesGroupedByItems) { + notify("Migration:ItemBeforeMigrate", migrationType); + + let stopwatchHistogramId = maybeStartTelemetryStopwatch(migrationType); + + let { responsivenessMonitor, responsivenessHistogramId } = + maybeStartResponsivenessMonitor(migrationType); + + let itemSuccess = false; + for (let res of itemResources) { + let completeDeferred = Promise.withResolvers(); + let resourceDone = function (aSuccess, details) { + itemResources.delete(res); + itemSuccess |= aSuccess; + if (itemResources.size == 0) { + notify( + itemSuccess + ? "Migration:ItemAfterMigrate" + : "Migration:ItemError", + migrationType + ); + collectMigrationTelemetry(migrationType); + + aProgressCallback(migrationType, itemSuccess, details); + + resourcesGroupedByItems.delete(migrationType); + + if (stopwatchHistogramId) { + TelemetryStopwatch.finishKeyed( + stopwatchHistogramId, + browserKey + ); + } + + maybeFinishResponsivenessMonitor( + responsivenessMonitor, + responsivenessHistogramId + ); + + if (resourcesGroupedByItems.size == 0) { + collectQuantityTelemetry(); + + notify("Migration:Ended"); + } + } + completeDeferred.resolve(); + }; + + // If migrate throws, an error occurred, and the callback + // (itemMayBeDone) might haven't been called. + try { + res.migrate(resourceDone); + } catch (ex) { + console.error(ex); + resourceDone(false); + } + + await completeDeferred.promise; + await unblockMainThread(); + } + } + }; + + if ( + lazy.MigrationUtils.isStartupMigration && + !this.startupOnlyMigrator && + Services.policies.isAllowed("defaultBookmarks") + ) { + lazy.MigrationUtils.profileStartup.doStartup(); + // First import the default bookmarks. + // Note: We do not need to do so for the Firefox migrator + // (=startupOnlyMigrator), as it just copies over the places database + // from another profile. + await (async function () { + // Tell nsBrowserGlue we're importing default bookmarks. + let browserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService( + Ci.nsIObserver + ); + browserGlue.observe(null, TOPIC_WILL_IMPORT_BOOKMARKS, ""); + + // Import the default bookmarks. We ignore whether or not we succeed. + await lazy.BookmarkHTMLUtils.importFromURL( + "chrome://browser/content/default-bookmarks.html", + { + replace: true, + source: lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, + } + ).catch(console.error); + + // We'll tell nsBrowserGlue we've imported bookmarks, but before that + // we need to make sure we're going to know when it's finished + // initializing places: + let placesInitedPromise = new Promise(resolve => { + let onPlacesInited = function () { + Services.obs.removeObserver( + onPlacesInited, + TOPIC_PLACES_DEFAULTS_FINISHED + ); + resolve(); + }; + Services.obs.addObserver( + onPlacesInited, + TOPIC_PLACES_DEFAULTS_FINISHED + ); + }); + browserGlue.observe(null, TOPIC_DID_IMPORT_BOOKMARKS, ""); + await placesInitedPromise; + await doMigrate(); + })(); + return; + } + await doMigrate(); + } + + /** + * Checks to see if one or more profiles exist for the browser that this + * migrator migrates from. + * + * @returns {Promise} + * True if one or more profiles exists that this migrator can migrate + * resources from. + */ + async isSourceAvailable() { + if (this.startupOnlyMigrator && !lazy.MigrationUtils.isStartupMigration) { + return false; + } + + // For a single-profile source, check if any data is available. + // For multiple-profiles source, make sure that at least one + // profile is available. + let exists = false; + try { + let profiles = await this.getSourceProfiles(); + if (!profiles) { + let resources = await this.#getMaybeCachedResources(""); + if (resources && resources.length) { + exists = true; + } + } else { + exists = !!profiles.length; + } + } catch (ex) { + console.error(ex); + } + return exists; + } + + /*** PRIVATE STUFF - DO NOT OVERRIDE ***/ + + /** + * Returns resources for a particular profile and then caches them for later + * lookups. + * + * @param {object|string} aProfile + * The profile that resources are being imported from. + * @returns {Promise} + */ + async #getMaybeCachedResources(aProfile) { + let profileKey = aProfile ? aProfile.id : ""; + if (this._resourcesByProfile) { + if (profileKey in this._resourcesByProfile) { + return this._resourcesByProfile[profileKey]; + } + } else { + this._resourcesByProfile = {}; + } + this._resourcesByProfile[profileKey] = await this.getResources(aProfile); + return this._resourcesByProfile[profileKey]; + } +} diff --git a/browser/components/migration/ProfileMigrator.sys.mjs b/browser/components/migration/ProfileMigrator.sys.mjs new file mode 100644 index 0000000000..5d3b8baba7 --- /dev/null +++ b/browser/components/migration/ProfileMigrator.sys.mjs @@ -0,0 +1,15 @@ +/* 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 { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; + +export function ProfileMigrator() {} + +ProfileMigrator.prototype = { + migrate: MigrationUtils.startupMigration.bind(MigrationUtils), + QueryInterface: ChromeUtils.generateQI(["nsIProfileMigrator"]), + classDescription: "Profile Migrator", + contractID: "@mozilla.org/toolkit/profile-migrator;1", + classID: Components.ID("6F8BB968-C14F-4D6F-9733-6C6737B35DCE"), +}; diff --git a/browser/components/migration/SafariProfileMigrator.sys.mjs b/browser/components/migration/SafariProfileMigrator.sys.mjs new file mode 100644 index 0000000000..418d2b66c8 --- /dev/null +++ b/browser/components/migration/SafariProfileMigrator.sys.mjs @@ -0,0 +1,678 @@ +/* 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 { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs"; + +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; +import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PropertyListUtils: "resource://gre/modules/PropertyListUtils.sys.mjs", +}); + +// NSDate epoch is Jan 1, 2001 UTC +const NS_DATE_EPOCH_MS = new Date("2001-01-01T00:00:00-00:00").getTime(); + +// Convert NSDate timestamp to UNIX timestamp. +function parseNSDate(cocoaDateStr) { + let asDouble = parseFloat(cocoaDateStr); + if (!isNaN(asDouble)) { + return new Date(NS_DATE_EPOCH_MS + asDouble * 1000); + } + return new Date(); +} + +// Convert UNIX timestamp to NSDate timestamp. +function msToNSDate(ms) { + return parseFloat(ms - NS_DATE_EPOCH_MS) / 1000; +} + +function Bookmarks(aBookmarksFile) { + this._file = aBookmarksFile; +} +Bookmarks.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + migrate: function B_migrate(aCallback) { + return (async () => { + let dict = await new Promise(resolve => + lazy.PropertyListUtils.read(this._file, resolve) + ); + if (!dict) { + throw new Error("Could not read Bookmarks.plist"); + } + let children = dict.get("Children"); + if (!children) { + throw new Error("Invalid Bookmarks.plist format"); + } + + let collection = + dict.get("Title") == "com.apple.ReadingList" + ? this.READING_LIST_COLLECTION + : this.ROOT_COLLECTION; + await this._migrateRootCollection(children, collection); + })().then( + () => aCallback(true), + e => { + console.error(e); + aCallback(false); + } + ); + }, + + // Bookmarks collections in Safari. Constants for migrateCollection. + ROOT_COLLECTION: 0, + MENU_COLLECTION: 1, + TOOLBAR_COLLECTION: 2, + READING_LIST_COLLECTION: 3, + + /** + * Start the migration of a Safari collection of bookmarks by retrieving favicon data. + * + * @param {object[]} aEntries + * The collection's children + * @param {number} aCollection + * One of the _COLLECTION values above. + */ + async _migrateRootCollection(aEntries, aCollection) { + // First, try to get the favicon data of a user's bookmarks. + // In Safari, Favicons are stored as files with a unique name: + // the MD5 hash of the UUID of an SQLite entry in favicons.db. + // Thus, we must create a map from bookmark URLs -> their favicon entry's UUID. + let bookmarkURLToUUIDMap = new Map(); + + const faviconFolder = FileUtils.getDir("ULibDir", [ + "Safari", + "Favicon Cache", + ]).path; + let dbPath = PathUtils.join(faviconFolder, "favicons.db"); + + try { + // If there is an error getting favicon data, we catch the error and move on. + // In this case, the bookmarkURLToUUIDMap will be left empty. + let rows = await MigrationUtils.getRowsFromDBWithoutLocks( + dbPath, + "Safari favicons", + `SELECT I.uuid, I.url AS favicon_url, P.url + FROM icon_info I + INNER JOIN page_url P ON I.uuid = P.uuid;` + ); + + if (rows) { + // Convert the rows from our SQLite database into a map from bookmark url to uuid + for (let row of rows) { + let uniqueURL = Services.io.newURI(row.getResultByName("url")).spec; + + // Normalize the URL by removing any trailing slashes. We'll make sure to do + // the same when doing look-ups during a migration. + if (uniqueURL.endsWith("/")) { + uniqueURL = uniqueURL.replace(/\/+$/, ""); + } + bookmarkURLToUUIDMap.set(uniqueURL, row.getResultByName("uuid")); + } + } + } catch (ex) { + console.error(ex); + } + + await this._migrateCollection(aEntries, aCollection, bookmarkURLToUUIDMap); + }, + + /** + * Recursively migrate a Safari collection of bookmarks. + * + * @param {object[]} aEntries + * The collection's children + * @param {number} aCollection + * One of the _COLLECTION values above + * @param {Map} bookmarkURLToUUIDMap + * A map from a bookmark's URL to the UUID of its entry in the favicons.db database + * @returns {Promise} + * Resolves after the bookmarks and favicons have been inserted into the + * appropriate databases. + */ + async _migrateCollection(aEntries, aCollection, bookmarkURLToUUIDMap) { + // A collection of bookmarks in Safari resembles places roots. In the + // property list files (Bookmarks.plist, ReadingList.plist) they are + // stored as regular bookmarks folders, and thus can only be distinguished + // from by their names and places in the hierarchy. + + let entriesFiltered = []; + if (aCollection == this.ROOT_COLLECTION) { + for (let entry of aEntries) { + let type = entry.get("WebBookmarkType"); + if (type == "WebBookmarkTypeList" && entry.has("Children")) { + let title = entry.get("Title"); + let children = entry.get("Children"); + if (title == "BookmarksBar") { + await this._migrateCollection( + children, + this.TOOLBAR_COLLECTION, + bookmarkURLToUUIDMap + ); + } else if (title == "BookmarksMenu") { + await this._migrateCollection( + children, + this.MENU_COLLECTION, + bookmarkURLToUUIDMap + ); + } else if (title == "com.apple.ReadingList") { + await this._migrateCollection( + children, + this.READING_LIST_COLLECTION, + bookmarkURLToUUIDMap + ); + } else if (entry.get("ShouldOmitFromUI") !== true) { + entriesFiltered.push(entry); + } + } else if (type == "WebBookmarkTypeLeaf") { + entriesFiltered.push(entry); + } + } + } else { + entriesFiltered = aEntries; + } + + if (!entriesFiltered.length) { + return; + } + + let folderGuid = -1; + switch (aCollection) { + case this.ROOT_COLLECTION: { + // In Safari, it is possible (though quite cumbersome) to move + // bookmarks to the bookmarks root, which is the parent folder of + // all bookmarks "collections". That is somewhat in parallel with + // both the places root and the unfiled-bookmarks root. + // Because the former is only an implementation detail in our UI, + // the unfiled root seems to be the best choice. + folderGuid = lazy.PlacesUtils.bookmarks.unfiledGuid; + break; + } + case this.MENU_COLLECTION: { + folderGuid = lazy.PlacesUtils.bookmarks.menuGuid; + break; + } + case this.TOOLBAR_COLLECTION: { + folderGuid = lazy.PlacesUtils.bookmarks.toolbarGuid; + break; + } + case this.READING_LIST_COLLECTION: { + // Reading list items are imported as regular bookmarks. + // They are imported under their own folder, created either under the + // bookmarks menu (in the case of startup migration). + let readingListTitle = await MigrationUtils.getLocalizedString( + "migration-imported-safari-reading-list" + ); + folderGuid = ( + await MigrationUtils.insertBookmarkWrapper({ + parentGuid: lazy.PlacesUtils.bookmarks.menuGuid, + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + title: readingListTitle, + }) + ).guid; + break; + } + default: + throw new Error("Unexpected value for aCollection!"); + } + if (folderGuid == -1) { + throw new Error("Invalid folder GUID"); + } + + await this._migrateEntries( + entriesFiltered, + folderGuid, + bookmarkURLToUUIDMap + ); + }, + + /** + * Migrates bookmarks and favicons from Safari to Firefox. + * + * @param {object[]} entries + * The Safari collection's children + * @param {number} parentGuid + * GUID of the collection folder + * @param {Map} bookmarkURLToUUIDMap + * A map from a bookmark's URL to the UUID of its entry in the favicons.db database + */ + async _migrateEntries(entries, parentGuid, bookmarkURLToUUIDMap) { + let { convertedEntries, favicons } = await this._convertEntries( + entries, + bookmarkURLToUUIDMap + ); + + await MigrationUtils.insertManyBookmarksWrapper( + convertedEntries, + parentGuid + ); + + MigrationUtils.insertManyFavicons(favicons); + }, + + /** + * Converts Safari collection entries into a suitable format for + * inserting bookmarks and favicons. + * + * @param {object[]} entries + * The collection's children + * @param {Map} bookmarkURLToUUIDMap + * A map from a bookmark's URL to the UUID of its entry in the favicons.db database + * @returns {object[]} + * Returns an object with an array of converted bookmark entries and favicons + */ + async _convertEntries(entries, bookmarkURLToUUIDMap) { + let favicons = []; + let convertedEntries = []; + + const faviconFolder = FileUtils.getDir("ULibDir", [ + "Safari", + "Favicon Cache", + ]).path; + + for (const entry of entries) { + let type = entry.get("WebBookmarkType"); + if (type == "WebBookmarkTypeList" && entry.has("Children")) { + let convertedChildren = await this._convertEntries( + entry.get("Children"), + bookmarkURLToUUIDMap + ); + favicons.push(...convertedChildren.favicons); + convertedEntries.push({ + title: entry.get("Title"), + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + children: convertedChildren.convertedEntries, + }); + } else if (type == "WebBookmarkTypeLeaf" && entry.has("URLString")) { + // Check we understand this URL before adding it: + let url = entry.get("URLString"); + try { + new URL(url); + } catch (ex) { + console.error( + `Ignoring ${url} when importing from Safari because of exception:`, + ex + ); + continue; + } + let title; + if (entry.has("URIDictionary")) { + title = entry.get("URIDictionary").get("title"); + } + convertedEntries.push({ url, title }); + + try { + // Try to get the favicon data for each bookmark we have. + // We use uri.spec as our unique identifier since bookmark links + // don't completely match up in the Safari data. + let uri = Services.io.newURI(url); + let uriSpec = uri.spec; + + // Safari's favicon database doesn't include forward slashes for + // the page URLs, despite adding them in the Bookmarks.plist file. + // We'll strip any off here for our favicon lookup. + if (uriSpec.endsWith("/")) { + uriSpec = uriSpec.replace(/\/+$/, ""); + } + + let uuid = bookmarkURLToUUIDMap.get(uriSpec); + if (uuid) { + // Hash the UUID with md5 to give us the favicon file name. + let hashedUUID = lazy.PlacesUtils.md5(uuid, { + format: "hex", + }).toUpperCase(); + let faviconFile = PathUtils.join( + faviconFolder, + "favicons", + hashedUUID + ); + let faviconData = await IOUtils.read(faviconFile); + favicons.push({ faviconData, uri }); + } + } catch (error) { + // Even if we fail, still continue the import process + // since favicons aren't as essential as the bookmarks themselves. + console.error(error); + } + } + } + + return { convertedEntries, favicons }; + }, +}; + +async function GetHistoryResource() { + let dbPath = FileUtils.getDir("ULibDir", ["Safari", "History.db"]).path; + let maxAge = msToNSDate( + Date.now() - MigrationUtils.HISTORY_MAX_AGE_IN_MILLISECONDS + ); + + // If we have read access to the Safari profile directory, check to + // see if there's any history to import. If we can't access the profile + // directory, let's assume that there's history to import and give the + // user the option to migrate it. + let canReadHistory = false; + try { + // 'stat' is always allowed, but reading is somehow not, if the user hasn't + // allowed it: + await IOUtils.read(dbPath, { maxBytes: 1 }); + canReadHistory = true; + } catch (ex) { + console.error( + "Cannot yet read from Safari profile directory. Will presume history exists for import." + ); + } + + if (canReadHistory) { + let countQuery = ` + SELECT COUNT(*) + FROM history_items LEFT JOIN history_visits + ON history_items.id = history_visits.history_item + WHERE history_visits.visit_time > ${maxAge} + LIMIT 1;`; + + let countResult = await MigrationUtils.getRowsFromDBWithoutLocks( + dbPath, + "Safari history", + countQuery + ); + + if (!countResult[0].getResultByName("COUNT(*)")) { + return null; + } + } + + let selectQuery = ` + SELECT + history_items.url as history_url, + history_visits.title as history_title, + history_visits.visit_time as history_time + FROM history_items LEFT JOIN history_visits + ON history_items.id = history_visits.history_item + WHERE history_visits.visit_time > ${maxAge};`; + + return { + type: MigrationUtils.resourceTypes.HISTORY, + + async migrate(callback) { + callback(await this._migrate()); + }, + + async _migrate() { + let historyRows; + + try { + historyRows = await MigrationUtils.getRowsFromDBWithoutLocks( + dbPath, + "Safari history", + selectQuery + ); + + if (!historyRows.length) { + console.log("No history found"); + return false; + } + } catch (ex) { + console.error(ex); + return false; + } + + let pageInfos = []; + for (let row of historyRows) { + try { + pageInfos.push({ + title: row.getResultByName("history_title"), + url: new URL(row.getResultByName("history_url")), + visits: [ + { + transition: lazy.PlacesUtils.history.TRANSITIONS.TYPED, + date: parseNSDate(row.getResultByName("history_time")), + }, + ], + }); + } catch (e) { + console.error("Could not create a history row: ", e); + } + } + await MigrationUtils.insertVisitsWrapper(pageInfos); + + return true; + }, + }; +} + +/** + * Safari's preferences property list is independently used for three purposes: + * (a) importation of preferences + * (b) importation of search strings + * (c) retrieving the home page. + * + * So, rather than reading it three times, it's cached and managed here. + * + * @param {nsIFile} aPreferencesFile + * The .plist file to be read. + */ +function MainPreferencesPropertyList(aPreferencesFile) { + this._file = aPreferencesFile; + this._callbacks = []; +} +MainPreferencesPropertyList.prototype = { + /** + * @see PropertyListUtils.read + * @param {Function} aCallback + * A callback called with an Object representing the key-value pairs + * read out of the .plist file. + */ + read: function MPPL_read(aCallback) { + if ("_dict" in this) { + aCallback(this._dict); + return; + } + + let alreadyReading = !!this._callbacks.length; + this._callbacks.push(aCallback); + if (!alreadyReading) { + lazy.PropertyListUtils.read(this._file, aDict => { + this._dict = aDict; + for (let callback of this._callbacks) { + try { + callback(aDict); + } catch (ex) { + console.error(ex); + } + } + this._callbacks.splice(0); + }); + } + }, +}; + +function SearchStrings(aMainPreferencesPropertyListInstance) { + this._mainPreferencesPropertyList = aMainPreferencesPropertyListInstance; +} +SearchStrings.prototype = { + type: MigrationUtils.resourceTypes.OTHERDATA, + + migrate: function SS_migrate(aCallback) { + this._mainPreferencesPropertyList.read( + MigrationUtils.wrapMigrateFunction(function migrateSearchStrings(aDict) { + if (!aDict) { + throw new Error("Could not get preferences dictionary"); + } + + if (aDict.has("RecentSearchStrings")) { + let recentSearchStrings = aDict.get("RecentSearchStrings"); + if (recentSearchStrings && recentSearchStrings.length) { + let changes = recentSearchStrings.map(searchString => ({ + op: "add", + fieldname: "searchbar-history", + value: searchString, + })); + lazy.FormHistory.update(changes); + } + } + }, aCallback) + ); + }, +}; + +/** + * Safari migrator + */ +export class SafariProfileMigrator extends MigratorBase { + static get key() { + return "safari"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-safari"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/safari.png"; + } + + async getResources() { + let profileDir = FileUtils.getDir("ULibDir", ["Safari"]); + if (!profileDir.exists()) { + return null; + } + + let resources = []; + let pushProfileFileResource = function (aFileName, aConstructor) { + let file = profileDir.clone(); + file.append(aFileName); + if (file.exists()) { + resources.push(new aConstructor(file)); + } + }; + + pushProfileFileResource("Bookmarks.plist", Bookmarks); + + // The Reading List feature was introduced at the same time in Windows and + // Mac versions of Safari. Not surprisingly, they are stored in the same + // format in both versions. Surpsingly, only on Windows there is a + // separate property list for it. This code is used on mac too, because + // Apple may fix this at some point. + pushProfileFileResource("ReadingList.plist", Bookmarks); + + let prefs = this.mainPreferencesPropertyList; + if (prefs) { + resources.push(new SearchStrings(prefs)); + } + + resources.push(GetHistoryResource()); + + resources = await Promise.all(resources); + + return resources.filter(r => r != null); + } + + async getLastUsedDate() { + const profileDir = FileUtils.getDir("ULibDir", ["Safari"]); + const dates = await Promise.all( + ["Bookmarks.plist", "History.db"].map(file => { + const path = PathUtils.join(profileDir.path, file); + return IOUtils.stat(path) + .then(info => info.lastModified) + .catch(() => 0); + }) + ); + + return new Date(Math.max(...dates)); + } + + async hasPermissions() { + if (this._hasPermissions) { + return true; + } + // Check if we have access to some key files, but only if they exist. + let historyTarget = FileUtils.getDir("ULibDir", ["Safari", "History.db"]); + let bookmarkTarget = FileUtils.getDir("ULibDir", [ + "Safari", + "Bookmarks.plist", + ]); + let faviconTarget = FileUtils.getDir("ULibDir", [ + "Safari", + "Favicon Cache", + "favicons.db", + ]); + try { + let historyExists = await IOUtils.exists(historyTarget.path); + let bookmarksExists = await IOUtils.exists(bookmarkTarget.path); + let faviconsExists = await IOUtils.exists(faviconTarget.path); + // We now know which files exist, which is always allowed. + // To determine if we have read permissions, try to read a single byte + // from each file that exists, which will throw if we need permissions. + if (historyExists) { + await IOUtils.read(historyTarget.path, { maxBytes: 1 }); + } + if (bookmarksExists) { + await IOUtils.read(bookmarkTarget.path, { maxBytes: 1 }); + } + if (faviconsExists) { + await IOUtils.read(faviconTarget.path, { maxBytes: 1 }); + } + this._hasPermissions = true; + return true; + } catch (ex) { + return false; + } + } + + async getPermissions(win) { + // Keep prompting the user until they pick something that grants us access + // to Safari's bookmarks and favicons or they cancel out of the file open panel. + while (!(await this.hasPermissions())) { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + // The title (second arg) is not displayed on macOS, so leave it blank. + fp.init(win, "", Ci.nsIFilePicker.modeGetFolder); + fp.filterIndex = 1; + fp.displayDirectory = FileUtils.getDir("ULibDir", [""]); + // Now wait for the filepicker to open and close. If the user picks + // the Safari folder, macOS will grant us read access to everything + // inside, so we don't need to check or do anything else with what's + // returned by the filepicker. + let result = await new Promise(resolve => fp.open(resolve)); + // Bail if the user cancels the dialog: + if (result == Ci.nsIFilePicker.returnCancel) { + return false; + } + } + return true; + } + + async canGetPermissions() { + if (await MigrationUtils.canGetPermissionsOnPlatform()) { + const profileDir = FileUtils.getDir("ULibDir", ["Safari"]); + if (await IOUtils.exists(profileDir.path)) { + return profileDir.path; + } + } + return false; + } + + get mainPreferencesPropertyList() { + if (this._mainPreferencesPropertyList === undefined) { + let file = FileUtils.getDir("UsrPrfs", []); + if (file.exists()) { + file.append("com.apple.Safari.plist"); + if (file.exists()) { + this._mainPreferencesPropertyList = new MainPreferencesPropertyList( + file + ); + return this._mainPreferencesPropertyList; + } + } + this._mainPreferencesPropertyList = null; + return this._mainPreferencesPropertyList; + } + return this._mainPreferencesPropertyList; + } +} diff --git a/browser/components/migration/components.conf b/browser/components/migration/components.conf new file mode 100644 index 0000000000..06b2d4b446 --- /dev/null +++ b/browser/components/migration/components.conf @@ -0,0 +1,37 @@ +# -*- 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/. + +XP_WIN = buildconfig.substs['OS_ARCH'] == 'WINNT' +XP_MACOSX = buildconfig.substs['MOZ_WIDGET_TOOLKIT'] == 'cocoa' + +Classes = [ + { + 'cid': '{6F8BB968-C14F-4D6F-9733-6C6737B35DCE}', + 'contract_ids': ['@mozilla.org/toolkit/profile-migrator;1'], + 'esModule': 'resource:///modules/ProfileMigrator.sys.mjs', + 'constructor': 'ProfileMigrator', + }, +] + +if XP_WIN: + Classes += [ + { + 'cid': '{c214cadc-2033-445e-8800-3fe25ee8d368}', + 'contract_ids': ['@mozilla.org/profile/migrator/edgemigrationutils;1'], + 'type': 'mozilla::nsEdgeMigrationUtils', + 'headers': ['nsEdgeMigrationUtils.h'], + }, + ] + +if XP_MACOSX: + Classes += [ + { + 'cid': '{647bf80c-cd35-4ce6-b904-fd586b97ae48}', + 'contract_ids': ['@mozilla.org/profile/migrator/keychainmigrationutils;1'], + 'type': 'nsKeychainMigrationUtils', + 'headers': ['nsKeychainMigrationUtils.h'], + }, + ] diff --git a/browser/components/migration/content/aboutWelcomeBack.xhtml b/browser/components/migration/content/aboutWelcomeBack.xhtml new file mode 100644 index 0000000000..0777cc56e9 --- /dev/null +++ b/browser/components/migration/content/aboutWelcomeBack.xhtml @@ -0,0 +1,126 @@ + + + +%htmlDTD; ]> + + + + + + + + + + + + + + + + + + + + diff --git a/browser/components/migration/content/migration-dialog-window.js b/browser/components/migration/content/migration-dialog-window.js new file mode 100644 index 0000000000..b16a348a35 --- /dev/null +++ b/browser/components/migration/content/migration-dialog-window.js @@ -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/. */ + +"use strict"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", + MigrationWizardConstants: + "chrome://browser/content/migration/migration-wizard-constants.mjs", +}); + +/** + * This file manages a MigrationWizard embedded in a dialog that runs + * in a top-level dialog window. It's main responsibility is to listen + * for dialog-specific events from the embedded MigrationWizard and to + * respond appropriately to them. + * + * A single object argument is expected to be passed when opening + * this dialog. + * + * @param {object} window.arguments.0 + * @param {object} window.arguments.0.options + * A series of options for configuring the dialog. See + * MigrationUtils.showMigrationWizard for a description of this + * object. + */ + +const MigrationDialog = { + _wiz: null, + + init() { + addEventListener("load", this); + }, + + onLoad() { + this._wiz = document.getElementById("wizard"); + this._wiz.addEventListener("MigrationWizard:Close", this); + document.addEventListener("keypress", this); + + let args = window.arguments[0]; + // When opened via nsIWindowWatcher.openWindow, the arguments are + // passed through C++, and they arrive to us wrapped as an XPCOM + // object. We use wrappedJSObject to get at the underlying JS + // object. + if (args instanceof Ci.nsISupports) { + args = args.wrappedJSObject; + } + + let observer = new ResizeObserver(() => { + window.sizeToContent(); + }); + observer.observe(this._wiz); + + customElements.whenDefined("migration-wizard").then(() => { + if (args.options?.skipSourceSelection) { + // This is an automigration for a profile refresh, so begin migration + // automatically once ready. + this.doProfileRefresh( + args.options.migratorKey, + args.options.migrator, + args.options.profileId + ); + } else { + this._wiz.requestState(); + } + }); + }, + + handleEvent(event) { + switch (event.type) { + case "load": { + this.onLoad(); + break; + } + case "keypress": { + if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) { + window.close(); + } + break; + } + case "MigrationWizard:Close": { + window.close(); + break; + } + } + }, + + async doProfileRefresh(migratorKey, migrator, profileId) { + let profile = { id: profileId }; + let resourceTypeData = await migrator.getMigrateData(profile); + let resourceTypeStrs = []; + for (let type in lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES) { + if (resourceTypeData & lazy.MigrationUtils.resourceTypes[type]) { + resourceTypeStrs.push( + lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES[type] + ); + } + } + + this._wiz.doAutoImport(migratorKey, profile, resourceTypeStrs); + this._wiz.addEventListener( + "MigrationWizard:DoneMigration", + () => { + setTimeout(() => { + window.close(); + }, 5000); + }, + { once: true } + ); + }, +}; + +MigrationDialog.init(); diff --git a/browser/components/migration/content/migration-wizard-constants.mjs b/browser/components/migration/content/migration-wizard-constants.mjs new file mode 100644 index 0000000000..18673fc5b6 --- /dev/null +++ b/browser/components/migration/content/migration-wizard-constants.mjs @@ -0,0 +1,124 @@ +/* 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/. */ + +export const MigrationWizardConstants = Object.freeze({ + MIGRATOR_TYPES: Object.freeze({ + BROWSER: "browser", + FILE: "file", + }), + + /** + * A mapping of a page identification string to the IDs used by the + * various wizard pages. These are used by MigrationWizard.setState + * to set the current page. + * + * @type {Object} + */ + PAGES: Object.freeze({ + LOADING: "loading", + SELECTION: "selection", + PROGRESS: "progress", + FILE_IMPORT_PROGRESS: "file-import-progress", + SAFARI_PERMISSION: "safari-permission", + SAFARI_PASSWORD_PERMISSION: "safari-password-permission", + NO_BROWSERS_FOUND: "no-browsers-found", + }), + + /** + * A mapping of a progress value string. These are used by + * MigrationWizard.#onShowingProgress to update the UI accordingly. + * + * @type {Object} + */ + PROGRESS_VALUE: Object.freeze({ + LOADING: 1, + SUCCESS: 2, + WARNING: 3, + INFO: 4, + }), + + /** + * Returns a mapping of a resource type to a string used to identify + * the associated resource group in the wizard via a data-resource-type + * attribute. The keys are used to set which items should be shown and + * in what state in #onShowingProgress. + * + * @type {Object} + */ + DISPLAYED_RESOURCE_TYPES: Object.freeze({ + // The DISPLAYED_RESOURCE_TYPES should have their keys match those + // in MigrationUtils.resourceTypes. + + // This is a little silly, but JavaScript doesn't have a notion of + // enums. The advantage of this set-up is that these constants values + // can be used to access the MigrationUtils.resourceTypes constants, + // are reasonably readable as DOM attributes, and easily serialize / + // deserialize. + HISTORY: "HISTORY", + FORMDATA: "FORMDATA", + PASSWORDS: "PASSWORDS", + BOOKMARKS: "BOOKMARKS", + PAYMENT_METHODS: "PAYMENT_METHODS", + EXTENSIONS: "EXTENSIONS", + + COOKIES: "COOKIES", + SESSION: "SESSION", + OTHERDATA: "OTHERDATA", + }), + + DISPLAYED_FILE_RESOURCE_TYPES: Object.freeze({ + // When migrating passwords from a file, we first show the progress + // for a single PASSWORDS_FROM_FILE resource type, and then upon + // completion, show two different resource types - one for new + // passwords imported from the file, and one for existing passwords + // that were updated from the file. + PASSWORDS_FROM_FILE: "PASSWORDS_FROM_FILE", + PASSWORDS_NEW: "PASSWORDS_NEW", + PASSWORDS_UPDATED: "PASSWORDS_UPDATED", + BOOKMARKS_FROM_FILE: "BOOKMARKS_FROM_FILE", + }), + + /** + * Returns a mapping of a resource type to a string used to identify + * the associated resource group in the wizard via a data-resource-type + * attribute. The keys are for resource types that are only ever shown + * for profile resets. + * + * @type {Object} + */ + PROFILE_RESET_ONLY_RESOURCE_TYPES: Object.freeze({ + COOKIES: "COOKIES", + SESSION: "SESSION", + OTHERDATA: "OTHERDATA", + }), + + /** + * The set of keys that maps to migrators that use the term "favorites" + * in the place of "bookmarks". This tends to be browsers from Microsoft. + */ + USES_FAVORITES: Object.freeze([ + "chromium-edge", + "chromium-edge-beta", + "edge", + "ie", + ]), + + /** + * The values that are set on the extension extra key for the + * migration_finished telemetry event. The definition of that event + * defines it as: + * + * "3" if all extensions were matched after import. "2" if only some + * extensions were matched. "1" if none were matched, and "0" if extensions + * weren't selected for import. + * + * @type {Object} + */ + EXTENSIONS_IMPORT_RESULT: Object.freeze({ + NOT_IMPORTED: "0", + NONE_MATCHED: "1", + PARTIAL_MATCH: "2", + ALL_MATCHED: "3", + }), +}); diff --git a/browser/components/migration/content/migration-wizard.mjs b/browser/components/migration/content/migration-wizard.mjs new file mode 100644 index 0000000000..7643e4bce3 --- /dev/null +++ b/browser/components/migration/content/migration-wizard.mjs @@ -0,0 +1,1372 @@ +/* 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-unassigned-import +import "chrome://global/content/elements/moz-button-group.mjs"; +import { MigrationWizardConstants } from "chrome://browser/content/migration/migration-wizard-constants.mjs"; + +/** + * This component contains the UI that steps users through migrating their + * data from other browsers to this one. This component only contains very + * basic logic and structure for the UI, and most of the state management + * occurs in the MigrationWizardChild JSWindowActor. + */ +export class MigrationWizard extends HTMLElement { + static #template = null; + + #deck = null; + #browserProfileSelector = null; + #browserProfileSelectorList = null; + #resourceTypeList = null; + #shadowRoot = null; + #importButton = null; + #importFromFileButton = null; + #chooseImportFromFile = null; + #getPermissionsButton = null; + #safariPermissionButton = null; + #safariPasswordImportSkipButton = null; + #safariPasswordImportSelectButton = null; + #selectAllCheckbox = null; + #resourceSummary = null; + #expandedDetails = false; + #extensionsSuccessLink = null; + + static get markup() { + return ` + + `; + } + + static get fragment() { + if (!MigrationWizard.#template) { + let parser = new DOMParser(); + let doc = parser.parseFromString(MigrationWizard.markup, "text/html"); + MigrationWizard.#template = document.importNode( + doc.querySelector("template"), + true + ); + } + return MigrationWizard.#template.content.cloneNode(true); + } + + constructor() { + super(); + const shadow = this.attachShadow({ mode: "open" }); + + if (window.MozXULElement) { + window.MozXULElement.insertFTLIfNeeded("branding/brand.ftl"); + window.MozXULElement.insertFTLIfNeeded("browser/migrationWizard.ftl"); + } + document.l10n.connectRoot(shadow); + + shadow.appendChild(MigrationWizard.fragment); + + this.#deck = shadow.querySelector("#wizard-deck"); + this.#browserProfileSelector = shadow.querySelector( + "#browser-profile-selector" + ); + this.#resourceSummary = shadow.querySelector("#resource-selection-summary"); + this.#resourceSummary.addEventListener("click", this); + + let cancelCloseButtons = shadow.querySelectorAll(".cancel-close"); + for (let button of cancelCloseButtons) { + button.addEventListener("click", this); + } + + let finishButtons = shadow.querySelectorAll(".finish-button"); + for (let button of finishButtons) { + button.addEventListener("click", this); + } + + this.#importButton = shadow.querySelector("#import"); + this.#importButton.addEventListener("click", this); + this.#importFromFileButton = shadow.querySelector("#import-from-file"); + this.#importFromFileButton.addEventListener("click", this); + this.#chooseImportFromFile = shadow.querySelector( + "#choose-import-from-file" + ); + this.#chooseImportFromFile.addEventListener("click", this); + this.#getPermissionsButton = shadow.querySelector("#get-permissions"); + this.#getPermissionsButton.addEventListener("click", this); + + this.#browserProfileSelector.addEventListener("click", this); + this.#resourceTypeList = shadow.querySelector("#resource-type-list"); + this.#resourceTypeList.addEventListener("change", this); + + this.#safariPermissionButton = shadow.querySelector( + "#safari-request-permissions" + ); + this.#safariPermissionButton.addEventListener("click", this); + + this.#selectAllCheckbox = shadow.querySelector("#select-all").control; + + this.#safariPasswordImportSkipButton = shadow.querySelector( + "#safari-password-import-skip" + ); + this.#safariPasswordImportSkipButton.addEventListener("click", this); + + this.#safariPasswordImportSelectButton = shadow.querySelector( + "#safari-password-import-select" + ); + this.#safariPasswordImportSelectButton.addEventListener("click", this); + + this.#extensionsSuccessLink = shadow.querySelector( + "#extensions-success-link" + ); + this.#extensionsSuccessLink.addEventListener("click", this); + + this.#shadowRoot = shadow; + } + + connectedCallback() { + if (this.hasAttribute("auto-request-state")) { + this.requestState(); + } + } + + requestState() { + this.dispatchEvent( + new CustomEvent("MigrationWizard:RequestState", { bubbles: true }) + ); + } + + /** + * This setter can be used in the event that the MigrationWizard is being + * inserted via Lit, and the caller wants to set state declaratively using + * a property expression. + * + * @param {object} state + * The state object to pass to setState. + * @see MigrationWizard.setState. + */ + set state(state) { + this.setState(state); + } + + /** + * This is the main entrypoint for updating the state and appearance of + * the wizard. + * + * @param {object} state The state to be represented by the component. + * @param {string} state.page The page of the wizard to display. This should + * be one of the MigrationWizardConstants.PAGES constants. + */ + setState(state) { + switch (state.page) { + case MigrationWizardConstants.PAGES.SELECTION: { + this.#onShowingSelection(state); + break; + } + case MigrationWizardConstants.PAGES.PROGRESS: { + this.#onShowingProgress(state); + break; + } + case MigrationWizardConstants.PAGES.FILE_IMPORT_PROGRESS: { + this.#onShowingFileImportProgress(state); + break; + } + case MigrationWizardConstants.PAGES.NO_BROWSERS_FOUND: { + this.#onShowingNoBrowsersFound(state); + break; + } + } + + this.#deck.toggleAttribute( + "aria-busy", + state.page == MigrationWizardConstants.PAGES.LOADING + ); + this.#deck.setAttribute("selected-view", `page-${state.page}`); + + if (window.IS_STORYBOOK) { + this.#updateForStorybook(); + } + } + + get #dialogMode() { + return this.hasAttribute("dialog-mode"); + } + + #ensureSelectionDropdown() { + if (this.#browserProfileSelectorList) { + return; + } + this.#browserProfileSelectorList = document.createElement("panel-list"); + this.#browserProfileSelectorList.toggleAttribute( + "min-width-from-anchor", + true + ); + this.#browserProfileSelectorList.addEventListener("click", this); + + if (document.createXULElement) { + let panel = document.createXULElement("panel"); + panel.appendChild(this.#browserProfileSelectorList); + this.#shadowRoot.appendChild(panel); + } else { + this.#shadowRoot.appendChild(this.#browserProfileSelectorList); + } + } + + /** + * Reacts to changes to the browser / profile selector dropdown. This + * should update the list of resource types to match what's supported + * by the selected migrator and profile. + * + * @param {Element} panelItem the selected + */ + #onBrowserProfileSelectionChanged(panelItem) { + this.#browserProfileSelector.selectedPanelItem = panelItem; + this.#browserProfileSelector.querySelector("#migrator-name").textContent = + panelItem.displayName; + this.#browserProfileSelector.querySelector("#profile-name").textContent = + panelItem.profile?.name || ""; + + if (panelItem.brandImage) { + this.#browserProfileSelector.querySelector( + ".migrator-icon" + ).style.content = `url(${panelItem.brandImage})`; + } else { + this.#browserProfileSelector.querySelector( + ".migrator-icon" + ).style.content = "url(chrome://global/skin/icons/defaultFavicon.svg)"; + } + + let key = panelItem.getAttribute("key"); + let resourceTypes = panelItem.resourceTypes; + + for (let child of this.#resourceTypeList.querySelectorAll( + "label[data-resource-type]" + )) { + child.hidden = true; + child.control.checked = false; + } + + for (let resourceType of resourceTypes) { + let resourceLabel = this.#resourceTypeList.querySelector( + `label[data-resource-type="${resourceType}"]` + ); + if (resourceLabel) { + resourceLabel.hidden = false; + resourceLabel.control.checked = true; + + let labelSpan = resourceLabel.querySelector( + "span[default-data-l10n-id]" + ); + if (labelSpan) { + if (MigrationWizardConstants.USES_FAVORITES.includes(key)) { + document.l10n.setAttributes( + labelSpan, + labelSpan.getAttribute("ie-edge-data-l10n-id") + ); + } else { + document.l10n.setAttributes( + labelSpan, + labelSpan.getAttribute("default-data-l10n-id") + ); + } + } + } + } + let selectAll = this.#shadowRoot.querySelector("#select-all").control; + selectAll.checked = true; + + this.#displaySelectedResources(); + this.#browserProfileSelector.selectedPanelItem = panelItem; + + let selectionPage = this.#shadowRoot.querySelector( + "div[name='page-selection']" + ); + selectionPage.setAttribute("migrator-type", panelItem.getAttribute("type")); + + // Safari currently has a special flow for requesting permissions that + // occurs _after_ resource selection, so we don't show this message + // for that migrator. + let showNoPermissionsMessage = + panelItem.getAttribute("type") == + MigrationWizardConstants.MIGRATOR_TYPES.BROWSER && + !panelItem.hasPermissions && + panelItem.getAttribute("key") != "safari"; + + selectionPage.toggleAttribute("no-permissions", showNoPermissionsMessage); + if (showNoPermissionsMessage) { + let step2 = selectionPage.querySelector( + ".migration-no-permissions-instructions-step2" + ); + step2.setAttribute( + "data-l10n-args", + JSON.stringify({ permissionsPath: panelItem.permissionsPath }) + ); + + this.dispatchEvent( + new CustomEvent("MigrationWizard:PermissionsNeeded", { + bubbles: true, + detail: { + key, + }, + }) + ); + } + + selectionPage.toggleAttribute( + "no-resources", + panelItem.getAttribute("type") == + MigrationWizardConstants.MIGRATOR_TYPES.BROWSER && + !resourceTypes.length && + panelItem.hasPermissions + ); + } + + /** + * Called when showing the browser/profile selection page of the wizard. + * + * @param {object} state + * The state object passed into setState. The following properties are + * used: + * @param {string[]} state.migrators + * An array of source browser names that can be migrated from. + * @param {string} [state.migratorKey=null] + * The key for a migrator to automatically select in the migrators array. + * If not defined, the first item in the array will be selected. + * @param {string} [state.fileImportErrorMessage=null] + * An error message to display in the event that an attempt at doing a + * file import failed. File import failures are special in that they send + * the wizard back to the selection page with an error message. If not + * defined, it is presumed that a file import error has not occurred. + */ + #onShowingSelection(state) { + this.#ensureSelectionDropdown(); + this.#browserProfileSelectorList.textContent = ""; + + let selectionPage = this.#shadowRoot.querySelector( + "div[name='page-selection']" + ); + + let details = this.#shadowRoot.querySelector("details"); + + if (this.hasAttribute("force-show-import-all")) { + let forceShowImportAll = + this.getAttribute("force-show-import-all") == "true"; + selectionPage.toggleAttribute("show-import-all", forceShowImportAll); + details.open = !forceShowImportAll; + } else { + selectionPage.toggleAttribute("show-import-all", state.showImportAll); + details.open = !state.showImportAll; + } + + this.#expandedDetails = false; + + for (let migrator of state.migrators) { + let opt = document.createElement("panel-item"); + opt.setAttribute("key", migrator.key); + opt.setAttribute("type", migrator.type); + opt.profile = migrator.profile; + opt.displayName = migrator.displayName; + opt.resourceTypes = migrator.resourceTypes; + opt.hasPermissions = migrator.hasPermissions; + opt.permissionsPath = migrator.permissionsPath; + opt.brandImage = migrator.brandImage; + + let button = opt.shadowRoot.querySelector("button"); + if (migrator.brandImage) { + button.style.backgroundImage = `url(${migrator.brandImage})`; + } + + if (migrator.profile) { + document.l10n.setAttributes( + opt, + "migration-wizard-selection-option-with-profile", + { + sourceBrowser: migrator.displayName, + profileName: migrator.profile.name, + } + ); + } else { + document.l10n.setAttributes( + opt, + "migration-wizard-selection-option-without-profile", + { + sourceBrowser: migrator.displayName, + } + ); + } + + this.#browserProfileSelectorList.appendChild(opt); + } + + if (state.migrators.length) { + this.#onBrowserProfileSelectionChanged( + this.#browserProfileSelectorList.firstElementChild + ); + } + + if (state.migratorKey) { + let panelItem = this.#browserProfileSelectorList.querySelector( + `panel-item[key="${state.migratorKey}"]` + ); + this.#onBrowserProfileSelectionChanged(panelItem); + } + + let fileImportErrorMessageEl = selectionPage.querySelector( + "#file-import-error-message" + ); + + if (state.fileImportErrorMessage) { + fileImportErrorMessageEl.textContent = state.fileImportErrorMessage; + selectionPage.toggleAttribute("file-import-error", true); + } else { + fileImportErrorMessageEl.textContent = ""; + selectionPage.toggleAttribute("file-import-error", false); + } + + // Since this is called before the named-deck actually switches to + // show the selection page, we cannot focus this button immediately. + // Instead, we use a rAF to queue this up for focusing before the + // next paint. + requestAnimationFrame(() => { + this.#browserProfileSelector.focus({ focusVisible: false }); + }); + } + + /** + * @typedef {object} ProgressState + * The migration progress state for a resource. + * @property {number} value + * One of the values from MigrationWizardConstants.PROGRESS_VALUE. + * @property {string} [message=undefined] + * An optional message to display underneath the resource in + * the progress dialog. This message is only shown when value + * is not LOADING. + * @property {string} [linkURL=undefined] + * The URL for an optional link to appear after the status message. + * This will only be shown if linkText is also not-empty. + * @property {string} [linkText=undefined] + * The text for an optional link to appear after the status message. + * This will only be shown if linkURL is also not-empty. + */ + + /** + * Called when showing the progress / success page of the wizard. + * + * @param {object} state + * The state object passed into setState. The following properties are + * used: + * @param {string} state.key + * The key of the migrator being used. + * @param {Object} state.progress + * An object whose keys match one of DISPLAYED_RESOURCE_TYPES. + * + * Any resource type not included in state.progress will be hidden. + */ + #onShowingProgress(state) { + // Any resource progress group not included in state.progress is hidden. + let progressPage = this.#shadowRoot.querySelector( + "div[name='page-progress']" + ); + let resourceGroups = progressPage.querySelectorAll( + ".resource-progress-group" + ); + this.#extensionsSuccessLink.textContent = ""; + + let totalProgressGroups = Object.keys(state.progress).length; + let remainingProgressGroups = totalProgressGroups; + let totalWarnings = 0; + + for (let group of resourceGroups) { + let resourceType = group.dataset.resourceType; + if (!state.progress.hasOwnProperty(resourceType)) { + group.hidden = true; + continue; + } + group.hidden = false; + + let progressIcon = group.querySelector(".progress-icon"); + let messageText = group.querySelector("span.message-text"); + let supportLink = group.querySelector(".support-text"); + + let labelSpan = group.querySelector("span[default-data-l10n-id]"); + if (labelSpan) { + if (MigrationWizardConstants.USES_FAVORITES.includes(state.key)) { + document.l10n.setAttributes( + labelSpan, + labelSpan.getAttribute("ie-edge-data-l10n-id") + ); + } else { + document.l10n.setAttributes( + labelSpan, + labelSpan.getAttribute("default-data-l10n-id") + ); + } + } + messageText.textContent = ""; + + if (supportLink) { + supportLink.textContent = ""; + supportLink.removeAttribute("href"); + } + let progressValue = state.progress[resourceType].value; + switch (progressValue) { + case MigrationWizardConstants.PROGRESS_VALUE.LOADING: { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-in-progress" + ); + progressIcon.setAttribute("state", "loading"); + messageText.textContent = ""; + supportLink.textContent = ""; + supportLink.removeAttribute("href"); + // With no status text, we re-insert the   so that the status + // text area does not fully collapse. + messageText.appendChild(document.createTextNode("\u00A0")); + break; + } + case MigrationWizardConstants.PROGRESS_VALUE.SUCCESS: { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-completed" + ); + progressIcon.setAttribute("state", "success"); + messageText.textContent = state.progress[resourceType].message; + if ( + resourceType == + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS + ) { + messageText.textContent = ""; + this.#extensionsSuccessLink.textContent = + state.progress[resourceType].message; + } + remainingProgressGroups--; + break; + } + case MigrationWizardConstants.PROGRESS_VALUE.WARNING: { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-completed" + ); + progressIcon.setAttribute("state", "warning"); + messageText.textContent = state.progress[resourceType].message; + supportLink.textContent = state.progress[resourceType].linkText; + supportLink.href = state.progress[resourceType].linkURL; + remainingProgressGroups--; + totalWarnings++; + break; + } + case MigrationWizardConstants.PROGRESS_VALUE.INFO: { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-completed" + ); + progressIcon.setAttribute("state", "info"); + messageText.textContent = state.progress[resourceType].message; + supportLink.textContent = state.progress[resourceType].linkText; + supportLink.href = state.progress[resourceType].linkURL; + if ( + resourceType == + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS + ) { + messageText.textContent = ""; + this.#extensionsSuccessLink.textContent = + state.progress[resourceType].message; + } + remainingProgressGroups--; + break; + } + } + } + + let migrationDone = remainingProgressGroups == 0; + let headerL10nID = "migration-wizard-progress-header"; + + if (migrationDone) { + if (totalWarnings) { + headerL10nID = "migration-wizard-progress-done-with-warnings-header"; + } else { + headerL10nID = "migration-wizard-progress-done-header"; + } + } + + let header = this.#shadowRoot.getElementById("progress-header"); + document.l10n.setAttributes(header, headerL10nID); + + let finishButtons = progressPage.querySelectorAll(".finish-button"); + let cancelButton = progressPage.querySelector(".cancel-close"); + + for (let finishButton of finishButtons) { + finishButton.hidden = !migrationDone; + } + + cancelButton.hidden = migrationDone; + + if (migrationDone) { + // Since this might be called before the named-deck actually switches to + // show the progress page, we cannot focus this button immediately. + // Instead, we use a rAF to queue this up for focusing before the + // next paint. + requestAnimationFrame(() => { + let button = this.#dialogMode + ? progressPage.querySelector(".done-button") + : progressPage.querySelector(".continue-button"); + button.focus({ focusVisible: false }); + }); + } + } + + /** + * Called when showing the progress / success page of the wizard for + * files. + * + * @param {object} state + * The state object passed into setState. The following properties are + * used: + * @param {string} state.title + * The string to display in the header. + * @param {Object} state.progress + * An object whose keys match one of DISPLAYED_FILE_RESOURCE_TYPES. + * + * Any resource type not included in state.progress will be hidden. + */ + #onShowingFileImportProgress(state) { + // Any resource progress group not included in state.progress is hidden. + let progressPage = this.#shadowRoot.querySelector( + "div[name='page-file-import-progress']" + ); + let resourceGroups = progressPage.querySelectorAll( + ".resource-progress-group" + ); + let totalProgressGroups = Object.keys(state.progress).length; + let remainingProgressGroups = totalProgressGroups; + + for (let group of resourceGroups) { + let resourceType = group.dataset.resourceType; + if (!state.progress.hasOwnProperty(resourceType)) { + group.hidden = true; + continue; + } + group.hidden = false; + + let progressIcon = group.querySelector(".progress-icon"); + let messageText = group.querySelector(".message-text"); + + let progressValue = state.progress[resourceType].value; + switch (progressValue) { + case MigrationWizardConstants.PROGRESS_VALUE.LOADING: { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-in-progress" + ); + progressIcon.setAttribute("state", "loading"); + messageText.textContent = ""; + // With no status text, we re-insert the   so that the status + // text area does not fully collapse. + messageText.appendChild(document.createTextNode("\u00A0")); + break; + } + case MigrationWizardConstants.PROGRESS_VALUE.SUCCESS: { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-completed" + ); + progressIcon.setAttribute("state", "success"); + messageText.textContent = state.progress[resourceType].message; + remainingProgressGroups--; + break; + } + case MigrationWizardConstants.PROGRESS_VALUE.WARNING: { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-completed" + ); + progressIcon.setAttribute("state", "warning"); + messageText.textContent = state.progress[resourceType].message; + remainingProgressGroups--; + break; + } + default: { + console.error( + "Unrecognized state for file migration: ", + progressValue + ); + } + } + } + + let migrationDone = remainingProgressGroups == 0; + let header = this.#shadowRoot.getElementById("file-import-progress-header"); + header.textContent = state.title; + + let doneButton = progressPage.querySelector(".primary"); + let cancelButton = progressPage.querySelector(".cancel-close"); + doneButton.hidden = !migrationDone; + cancelButton.hidden = migrationDone; + + if (migrationDone) { + // Since this might be called before the named-deck actually switches to + // show the progress page, we cannot focus this button immediately. + // Instead, we use a rAF to queue this up for focusing before the + // next paint. + requestAnimationFrame(() => { + doneButton.focus({ focusVisible: false }); + }); + } + } + + /** + * Called when showing the "no browsers found" page of the wizard. + * + * @param {object} state + * The state object passed into setState. The following properties are + * used: + * @param {string} state.hasFileMigrators + * True if at least one FileMigrator is available for use. + */ + #onShowingNoBrowsersFound(state) { + this.#chooseImportFromFile.hidden = !state.hasFileMigrators; + } + + /** + * Certain parts of the MigrationWizard need to be modified slightly + * in order to work properly with Storybook. This method should be called + * to apply those changes after changing state. + */ + #updateForStorybook() { + // The CSS mask used for the progress spinner cannot be loaded via + // chrome:// URIs in Storybook. We work around this by exposing the + // progress elements as custom parts that the MigrationWizard story + // can style on its own. + this.#shadowRoot.querySelectorAll(".progress-icon").forEach(progressEl => { + if (progressEl.getAttribute("state") == "loading") { + progressEl.setAttribute("part", "progress-spinner"); + } else { + progressEl.removeAttribute("part"); + } + }); + } + + /** + * A public method for starting a migration without the user needing + * to choose a browser, profile or resource types. This is typically + * done only for doing a profile reset. + * + * @param {string} migratorKey + * The key associated with the migrator to use. + * @param {object|null} profile + * A representation of a browser profile. When not null, this is an + * object with a string "id" property, and a string "name" property. + * @param {string[]} resourceTypes + * An array of resource types that import should occur for. These + * strings should be from MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. + */ + doAutoImport(migratorKey, profile, resourceTypes) { + let migrationEventDetail = this.#gatherMigrationEventDetails({ + migratorKey, + profile, + resourceTypes, + }); + + this.dispatchEvent( + new CustomEvent("MigrationWizard:BeginMigration", { + bubbles: true, + detail: migrationEventDetail, + }) + ); + } + + /** + * Takes the current state of the selections page and bundles them + * up into a MigrationWizard:BeginMigration event that can be handled + * externally to perform the actual migration. + */ + #doImport() { + let migrationEventDetail = this.#gatherMigrationEventDetails(); + + this.dispatchEvent( + new CustomEvent("MigrationWizard:BeginMigration", { + bubbles: true, + detail: migrationEventDetail, + }) + ); + } + + /** + * @typedef {object} MigrationDetails + * @property {string} key + * The key for a MigratorBase subclass. + * @property {object|null} profile + * A representation of a browser profile. This is serialized and originally + * sent down from the parent via the GetAvailableMigrators message. + * @property {string[]} resourceTypes + * An array of resource types that the user is attempted to import. These + * strings should be from MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. + * @property {boolean} hasPermissions + * True if this MigrationWizardChild told us that the associated + * MigratorBase subclass for the key has enough permission to read + * the requested resources. + * @property {boolean} expandedDetails + * True if the user clicked on the element to expand the resource + * type list. + * @property {boolean} autoMigration + * True if the migration is occurring automatically, without the user + * having selected any items explicitly from the wizard. + * @property {string} [safariPasswordFilePath=null] + * An optional string argument that points to the path of a passwords + * export file from Safari. This file will have password imported from if + * supplied. This argument is ignored if the key is not for the + * Safari browser. + */ + + /** + * Pulls information from the DOM state of the MigrationWizard and constructs + * and returns an object that can be used to begin migration via and event + * sent to the MigrationWizardChild. If autoMigrationDetails is provided, + * this information is used to construct the object instead of the DOM state. + * + * @param {object} [autoMigrationDetails=null] + * Provided iff an automatic migration is being invoked. In that case, the + * details are constructed from this object rather than the wizard DOM state. + * @param {string} autoMigrationDetails.migratorKey + * The key of the migrator to do automatic migration from. + * @param {object|null} autoMigrationDetails.profile + * A representation of a browser profile. When not null, this is an + * object with a string "id" property, and a string "name" property. + * @param {string[]} autoMigrationDetails.resourceTypes + * An array of resource types that import should occur for. These + * strings should be from MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. + * @returns {MigrationDetails} details + */ + #gatherMigrationEventDetails(autoMigrationDetails) { + if (autoMigrationDetails?.migratorKey) { + let { migratorKey, profile, resourceTypes } = autoMigrationDetails; + + return { + key: migratorKey, + type: MigrationWizardConstants.MIGRATOR_TYPES.BROWSER, + profile, + resourceTypes, + hasPermissions: true, + expandedDetails: this.#expandedDetails, + autoMigration: true, + }; + } + + let panelItem = this.#browserProfileSelector.selectedPanelItem; + let key = panelItem.getAttribute("key"); + let type = panelItem.getAttribute("type"); + let profile = panelItem.profile; + let hasPermissions = panelItem.hasPermissions; + + let resourceTypeFields = this.#resourceTypeList.querySelectorAll( + "label[data-resource-type]" + ); + let resourceTypes = []; + for (let resourceTypeField of resourceTypeFields) { + if (resourceTypeField.control.checked) { + resourceTypes.push(resourceTypeField.dataset.resourceType); + } + } + + return { + key, + type, + profile, + resourceTypes, + hasPermissions, + expandedDetails: this.#expandedDetails, + autoMigration: false, + }; + } + + /** + * Sends a request to gain read access to the Safari profile folder on + * macOS, and upon gaining access, performs a migration using the current + * settings as gathered by #gatherMigrationEventDetails + */ + #requestSafariPermissions() { + let migrationEventDetail = this.#gatherMigrationEventDetails(); + this.dispatchEvent( + new CustomEvent("MigrationWizard:RequestSafariPermissions", { + bubbles: true, + detail: migrationEventDetail, + }) + ); + } + + /** + * Sends a request to get a string path for a passwords file exported + * from Safari. + */ + #selectSafariPasswordFile() { + let migrationEventDetail = this.#gatherMigrationEventDetails(); + this.dispatchEvent( + new CustomEvent("MigrationWizard:SelectSafariPasswordFile", { + bubbles: true, + detail: migrationEventDetail, + }) + ); + } + + /** + * Sends a request to get read permissions for the data associated + * with the selected browser. + */ + #getPermissions() { + let migrationEventDetail = this.#gatherMigrationEventDetails(); + this.dispatchEvent( + new CustomEvent("MigrationWizard:GetPermissions", { + bubbles: true, + detail: migrationEventDetail, + }) + ); + } + + /** + * Changes selected-data-header text and selected-data text based on + * how many resources are checked + */ + async #displaySelectedResources() { + let resourceTypeLabels = this.#resourceTypeList.querySelectorAll( + "label:not([hidden])[data-resource-type]" + ); + let panelItem = this.#browserProfileSelector.selectedPanelItem; + let key = panelItem.getAttribute("key"); + + let totalResources = resourceTypeLabels.length; + let checkedResources = 0; + + let selectedData = this.#shadowRoot.querySelector(".selected-data"); + let selectedDataArray = []; + let resourceTypeToLabelIDs = { + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS]: + "migration-list-bookmark-label", + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS]: + "migration-list-password-label", + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY]: + "migration-list-history-label", + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS]: + "migration-list-extensions-label", + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA]: + "migration-list-autofill-label", + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PAYMENT_METHODS]: + "migration-list-payment-methods-label", + }; + + if (MigrationWizardConstants.USES_FAVORITES.includes(key)) { + resourceTypeToLabelIDs[ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS + ] = "migration-list-favorites-label"; + } + + let resourceTypes = Object.keys(resourceTypeToLabelIDs); + let labelIds = Object.values(resourceTypeToLabelIDs).map(id => { + return { id }; + }); + let labels = await document.l10n.formatValues(labelIds); + let resourceTypeLabelMapping = new Map(); + for (let i = 0; i < resourceTypes.length; ++i) { + let resourceType = resourceTypes[i]; + resourceTypeLabelMapping.set(resourceType, labels[i]); + } + let formatter = new Intl.ListFormat(undefined, { + style: "long", + type: "conjunction", + }); + for (let resourceTypeLabel of resourceTypeLabels) { + if (resourceTypeLabel.control.checked) { + selectedDataArray.push( + resourceTypeLabelMapping.get(resourceTypeLabel.dataset.resourceType) + ); + checkedResources++; + } + } + if (selectedDataArray.length) { + selectedDataArray[0] = + selectedDataArray[0].charAt(0).toLocaleUpperCase() + + selectedDataArray[0].slice(1); + selectedData.textContent = formatter.format(selectedDataArray); + } else { + selectedData.textContent = "\u00A0"; + } + + let selectedDataHeader = this.#shadowRoot.querySelector( + ".selected-data-header" + ); + + let importButton = this.#shadowRoot.querySelector("#import"); + importButton.disabled = checkedResources == 0; + + if (checkedResources == 0) { + document.l10n.setAttributes( + selectedDataHeader, + "migration-no-selected-data-label" + ); + } else if (checkedResources < totalResources) { + document.l10n.setAttributes( + selectedDataHeader, + "migration-selected-data-label" + ); + } else { + document.l10n.setAttributes( + selectedDataHeader, + "migration-all-available-data-label" + ); + } + + let selectionPage = this.#shadowRoot.querySelector( + "div[name='page-selection']" + ); + selectionPage.toggleAttribute("single-item", totalResources == 1); + + this.dispatchEvent( + new CustomEvent("MigrationWizard:ResourcesUpdated", { bubbles: true }) + ); + } + + handleEvent(event) { + 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(); + } + 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(); + } + break; + } + } + } +} + +if (globalThis.customElements) { + customElements.define("migration-wizard", MigrationWizard); +} diff --git a/browser/components/migration/docs/index.rst b/browser/components/migration/docs/index.rst new file mode 100644 index 0000000000..be22b951cb --- /dev/null +++ b/browser/components/migration/docs/index.rst @@ -0,0 +1,16 @@ +.. _components/migration: + +========= +Migration +========= + +The migration component is responsible for bringing data from outside applications running on the same computer into Firefox. This is typically done via a wizard where users can choose what types of data to migrate over. + +The migrator is also used during a "Profile Refresh" to pave over a newly created Firefox profile with some data from an older one. + +.. toctree:: + :maxdepth: 3 + + migration-utils + migrators + migration-wizard diff --git a/browser/components/migration/docs/migration-utils.rst b/browser/components/migration/docs/migration-utils.rst new file mode 100644 index 0000000000..c1ecb41d8b --- /dev/null +++ b/browser/components/migration/docs/migration-utils.rst @@ -0,0 +1,5 @@ +======================== +MigrationUtils Reference +======================== +.. js:autoclass:: MigrationUtils + :members: diff --git a/browser/components/migration/docs/migration-wizard-architecture-diagram.svg b/browser/components/migration/docs/migration-wizard-architecture-diagram.svg new file mode 100644 index 0000000000..4c5fbf5bc5 --- /dev/null +++ b/browser/components/migration/docs/migration-wizard-architecture-diagram.svg @@ -0,0 +1,128 @@ + + + + + + + +
    +
    +
    + (X)HTML Document +
    +
    +
    +
    + (X)HTML Document +
    + + + +
    +
    +
    + <migration-wizard> +
    +
    +
    +
    + <migration-wizard> +
    + + + +
    +
    +
    + MigrationWizardChild +
    +
    +
    +
    + MigrationWizardChild +
    + + + + +
    +
    +
    + JSWindowActor messages +
    +
    +
    +
    + JSWindowActor messages +
    + + + + +
    +
    +
    + Direct function calls +
    +
    +
    +
    + Direct function calls +
    + + + +
    +
    +
    + MigrationWizardParent +
    +
    +
    +
    + MigrationWizardParent +
    + + + +
    +
    +
    + MigrationUtils +
    +
    +
    +
    + MigrationUtils +
    + + + + +
    +
    +
    + DOM Events +
    +
    +
    +
    + DOM Events +
    + + + + +
    +
    +
    + Direct function calls +
    +
    +
    +
    + Direct function calls +
    +
    diff --git a/browser/components/migration/docs/migration-wizard.rst b/browser/components/migration/docs/migration-wizard.rst new file mode 100644 index 0000000000..320b429db5 --- /dev/null +++ b/browser/components/migration/docs/migration-wizard.rst @@ -0,0 +1,72 @@ +========================== +Migration Wizard Reference +========================== + +The migration wizard is the piece of UI that allows users to migrate from other browsers to Firefox. + +The migration wizard can be embedded in the following contexts: + +1. In a top level stand-alone dialog window +2. Within privileged ``about:`` pages, like ``about:welcome``, and ``about:preferences`` + +To accommodate these contexts, the migration wizard was developed as a reusable component using pure HTML, with an architecture that decouples the control of the wizard from how the wizard is presented to the user. This architecture not only helps to ensure that the wizard can function similarly in these different contexts, but also makes the component viewable in tools like Storybook for easier development. + + +High-level Overview +------------------- + +The following diagram tries to illustrate how the pieces of the migration wizard fit together: + +.. image:: migration-wizard-architecture-diagram.svg + +``MigrationWizard`` reusable component +====================================== + +The ``MigrationWizard`` reusable component (````) is a custom element that can be imported from ``migration-wizard.mjs``. The module is expected to load in a DOM window context, whereupon the custom element is automatically registered for that document. + +After binding to the document, if the ``MigrationWizard`` has the ``auto-request-state`` attribute set on it, it will dispatch a ``MigrationWizard:RequestState`` custom event, which causes a ``MigrationWizardChild`` to instantiate and be associated with it. After receiving the migrator state from the ``MigrationWizardParent``, the ``MigrationWizardChild`` will dispatch a ``MigrationWizard:Ready`` event on the ``MigrationWizard``, mainly to aid in testing. The ``auto-request-state`` attribute is useful in situations where the ``MigrationWizard`` element is being used declaratively. + +If the ``auto-request-state`` attribute is not set, calling ``requestState()`` on the ``MigrationWizard`` will perform the above step. This is useful in situations where the ``MigrationWizard`` element is being constructed dynamically and the callers wants finer-grain control over when the state will be requested. + +Notably, the ``MigrationWizard`` does not contain any internal logic or privileged code to perform any migrations or to directly interact with the migration mechanisms. Its sole function is to accept input from the user and emit that input as events. The associated ``MigrationWizardChild`` will listen for those events, and take care of calling into the ``MigrationWizard`` to update the state of the reusable component. This means that the reusable component can be embedded in unprivileged contexts and have its states presented in a tool like Storybook. + +If the ``MigrationWizard`` is embedded in a dialog, it should have the ``dialog-mode`` attribute set on it so that dialog-appropriate buttons and styles are applied. + +``MigrationWizardConstants`` +============================ + +The ``MigrationWizardConstants`` module exports a single object of the same name. The properties of that object are constants that can be used to set the state of a ``MigrationWizard`` instance using ``MigrationWizard.setState``. + +``MigrationWizardChild`` +========================= + +The ``MigrationWizardChild`` is a ``JSWindowActorChild`` (see `JSActors`_) that is responsible for listening for events from a ``MigrationWizard``, and then either updating the state of that ``MigrationWizard`` immediately, or to message its paired ``MigrationWizardParent`` to perform tasks with ``MigrationUtils``. + + .. note:: + While a ``MigrationWizardChild`` can run in a content process (for out-of-process pages like ``about:welcome``), it can also run in parent-process contexts - for example, within the parent-process ``about:preferences`` page, or standalone window dialog. The same flow of events and messaging applies in all contexts. + +The ``MigrationWizardChild`` also waives Xrays so that it can directly call the ``setState`` method to update the appearance of the ``MigrationWizard``. See `XrayVision`_ for much more information on Xrays. + +.. js:autoclass:: MigrationWizardChild + :members: + +``MigrationWizardParent`` +========================= + +The ``MigrationWizardParent`` is a ``JSWindowActorParent`` (see `JSActors`_) that is responsible for listening for messages from the paired ``MigrationWizardChild`` to perform operations with ``MigrationUtils``. Effectively, all of the heavy lifting of actually performing the migrations will be kicked off by the ``MigrationWizardParent`` by calling into ``MigrationUtils``. State updates for things like migration progress will be sent back down to the ``MigrationWizardChild`` to then be reflected in the appearance of the ``MigrationWizard``. + +Since the ``MigrationWizard`` might be embedded in unprivileged documents, additional checks are placed in the message handler for ``MigrationWizardParent`` to ensure that the document is either running in the parent process or the privileged about content process. The `JSActors`_ registration for ``MigrationWizardParent`` and ``MigrationWizardChild`` also ensures that the actors only load for built-in documents. + +.. js:autoclass:: MigrationWizardParent + :members: + +``migration-dialog-window.html`` +================================ + +This document is meant for being loaded in a window dialog, and embeds the ``MigrationWizard`` reusable component, setting ``dialog-mode`` on it. It listens for dialog-specific events from the ``MigrationWizard``, such as ``MigrationWizard:Close``, which indicates that a "Cancel" button that should close the dialog was clicked. + +Pages like ``about:preferences`` or ``about:welcome`` can embed the ``MigrationWizard`` component directly, rather than use ``migration-dialog-window.html``. + + +.. _JSActors: /dom/ipc/jsactors.html +.. _XrayVision: /dom/scriptSecurity/xray_vision.html diff --git a/browser/components/migration/docs/migrators.rst b/browser/components/migration/docs/migrators.rst new file mode 100644 index 0000000000..a8694343d5 --- /dev/null +++ b/browser/components/migration/docs/migrators.rst @@ -0,0 +1,112 @@ +=================== +Migrators Reference +=================== + +There are currently two types of migrators: browser migrators, and file migrators. Browser migrators will migrate various resources from another browser. A file migrator allows the user to migrate data through an intermediary file (like passwords from a .CSV file). + +Browser migrators +================= + +MigratorBase class +------------------ +.. js:autoclass:: MigratorBase + :members: + +Chrome and Chrome variant migrators +----------------------------------- + +The ``ChromeProfileMigrator`` is subclassed ino order to provide migration capabilities for variants of the Chrome browser. + +ChromeProfileMigrator class +=========================== +.. js:autoclass:: ChromeProfileMigrator + :members: + +BraveProfileMigrator class +========================== +.. js:autoclass:: BraveProfileMigrator + :members: + +CanaryProfileMigrator class +=========================== +.. js:autoclass:: CanaryProfileMigrator + :members: + +ChromeBetaMigrator class +======================== +.. js:autoclass:: ChromeBetaMigrator + :members: + +ChromeDevMigrator class +======================= +.. js:autoclass:: ChromeDevMigrator + :members: + +Chromium360seMigrator class +=========================== +.. js:autoclass:: Chromium360seMigrator + :members: + +ChromiumEdgeMigrator class +========================== +.. js:autoclass:: ChromiumEdgeMigrator + :members: + +ChromiumEdgeBetaMigrator class +============================== +.. js:autoclass:: ChromiumEdgeBetaMigrator + :members: + +ChromiumProfileMigrator class +============================= +.. js:autoclass:: ChromiumProfileMigrator + :members: + +OperaProfileMigrator class +========================== +.. js:autoclass:: OperaProfileMigrator + :members: + +OperaGXProfileMigrator class +============================ +.. js:autoclass:: OperaGXProfileMigrator + :members: + +VivaldiProfileMigrator class +============================ +.. js:autoclass:: VivaldiProfileMigrator + :members: + +EdgeProfileMigrator class +------------------------- +.. js:autoclass:: EdgeProfileMigrator + :members: + +FirefoxProfileMigrator class +---------------------------- +.. js:autoclass:: FirefoxProfileMigrator + :members: + +IEProfileMigrator class +----------------------- +.. js:autoclass:: IEProfileMigrator + :members: + +File migrators +============== + +.. js:autofunction:: FilePickerConfigurationFilter + :short-name: + +.. js:autofunction:: FilePickerConfiguration + :short-name: + +FileMigratorBase class +---------------------- +.. js:autoclass:: FileMigratorBase + :members: + +PasswordFileMigrator class +-------------------------- +.. js:autoclass:: PasswordFileMigrator + :members: diff --git a/browser/components/migration/jar.mn b/browser/components/migration/jar.mn new file mode 100644 index 0000000000..4eabe74555 --- /dev/null +++ b/browser/components/migration/jar.mn @@ -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/. + +browser.jar: + content/browser/aboutWelcomeBack.xhtml (content/aboutWelcomeBack.xhtml) + content/browser/migration/migration-dialog-window.html (content/migration-dialog-window.html) + content/browser/migration/migration-dialog-window.js (content/migration-dialog-window.js) + content/browser/migration/migration-wizard.mjs (content/migration-wizard.mjs) + content/browser/migration/migration-wizard-constants.mjs (content/migration-wizard-constants.mjs) +#ifdef XP_WIN + content/browser/migration/brands/360.png (content/brands/360.png) + content/browser/migration/brands/ie.png (content/brands/ie.png) +#endif + +#if defined(XP_MACOSX) || defined(XP_WIN) + content/browser/migration/brands/canary.png (content/brands/canary.png) + content/browser/migration/brands/edge.png (content/brands/edge.png) + content/browser/migration/brands/edgebeta.png (content/brands/edgebeta.png) +#endif + + content/browser/migration/brands/brave.png (content/brands/brave.png) + content/browser/migration/brands/chrome.png (content/brands/chrome.png) + content/browser/migration/brands/chromium.png (content/brands/chromium.png) + content/browser/migration/brands/opera.png (content/brands/opera.png) + content/browser/migration/brands/operagx.png (content/brands/operagx.png) + content/browser/migration/brands/vivaldi.png (content/brands/vivaldi.png) + +#ifdef XP_MACOSX + content/browser/migration/brands/safari.png (content/brands/safari.png) +#endif diff --git a/browser/components/migration/metrics.yaml b/browser/components/migration/metrics.yaml new file mode 100644 index 0000000000..a7d6d2481d --- /dev/null +++ b/browser/components/migration/metrics.yaml @@ -0,0 +1,42 @@ +# 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/. + +# Adding a new metric? We have docs for that! +# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html + +--- +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 +$tags: + - 'Firefox :: Migration' + +browser.migration: + matched_extensions: + type: string_list + description: > + Records a list of the Chrome extension IDs that were successfully + matched to Firefox equivalents from the list downloaded from AMO. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1807023 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1807023 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + + unmatched_extensions: + type: string_list + description: > + Records a list of the Chrome extension IDs that were unsuccessfully + matched to Firefox equivalents from the list downloaded from AMO. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1807023 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1807023 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never diff --git a/browser/components/migration/moz.build b/browser/components/migration/moz.build new file mode 100644 index 0000000000..8c958ab7d4 --- /dev/null +++ b/browser/components/migration/moz.build @@ -0,0 +1,85 @@ +# -*- 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/. + +XPCSHELL_TESTS_MANIFESTS += ["tests/unit/xpcshell.toml"] + +MARIONETTE_MANIFESTS += ["tests/marionette/manifest.toml"] + +MOCHITEST_CHROME_MANIFESTS += ["tests/chrome/chrome.toml"] + +BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.toml"] + +SPHINX_TREES["docs"] = "docs" + +JAR_MANIFESTS += ["jar.mn"] + +XPIDL_SOURCES += [ + "nsIEdgeMigrationUtils.idl", +] + +XPIDL_MODULE = "migration" + +EXTRA_JS_MODULES += [ + "ChromeMigrationUtils.sys.mjs", + "ChromeProfileMigrator.sys.mjs", + "FileMigrators.sys.mjs", + "FirefoxProfileMigrator.sys.mjs", + "InternalTestingProfileMigrator.sys.mjs", + "MigrationUtils.sys.mjs", + "MigratorBase.sys.mjs", + "ProfileMigrator.sys.mjs", +] + +FINAL_TARGET_FILES.actors = [ + "MigrationWizardChild.sys.mjs", + "MigrationWizardParent.sys.mjs", +] + +if CONFIG["OS_ARCH"] == "WINNT": + if CONFIG["ENABLE_TESTS"]: + DIRS += [ + "tests/unit/insertIEHistory", + ] + EXPORTS += [ + "nsEdgeMigrationUtils.h", + ] + UNIFIED_SOURCES += [ + "nsEdgeMigrationUtils.cpp", + "nsIEHistoryEnumerator.cpp", + ] + EXTRA_JS_MODULES += [ + "360seMigrationUtils.sys.mjs", + "ChromeWindowsLoginCrypto.sys.mjs", + "EdgeProfileMigrator.sys.mjs", + "ESEDBReader.sys.mjs", + "IEProfileMigrator.sys.mjs", + "MSMigrationUtils.sys.mjs", + ] + +if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa": + EXPORTS += [ + "nsKeychainMigrationUtils.h", + ] + EXTRA_JS_MODULES += [ + "ChromeMacOSLoginCrypto.sys.mjs", + "SafariProfileMigrator.sys.mjs", + ] + UNIFIED_SOURCES += [ + "nsKeychainMigrationUtils.mm", + ] + XPIDL_SOURCES += [ + "nsIKeychainMigrationUtils.idl", + ] + + +XPCOM_MANIFESTS += [ + "components.conf", +] + +FINAL_LIBRARY = "browsercomps" + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Migration") diff --git a/browser/components/migration/nsEdgeMigrationUtils.cpp b/browser/components/migration/nsEdgeMigrationUtils.cpp new file mode 100644 index 0000000000..a8d76c1405 --- /dev/null +++ b/browser/components/migration/nsEdgeMigrationUtils.cpp @@ -0,0 +1,61 @@ +/* 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 "nsEdgeMigrationUtils.h" + +#include "mozilla/dom/Promise.h" +#include "nsCOMPtr.h" +#include "nsIEventTarget.h" + +#include + +namespace mozilla { + +NS_IMPL_ISUPPORTS(nsEdgeMigrationUtils, nsIEdgeMigrationUtils) + +NS_IMETHODIMP +nsEdgeMigrationUtils::IsDbLocked(nsIFile* aFile, JSContext* aCx, + dom::Promise** aPromise) { + NS_ENSURE_ARG_POINTER(aFile); + + nsString path; + nsresult rv = aFile->GetPath(path); + NS_ENSURE_SUCCESS(rv, rv); + + ErrorResult err; + RefPtr promise = + dom::Promise::Create(xpc::CurrentNativeGlobal(aCx), err); + + if (MOZ_UNLIKELY(err.Failed())) { + return err.StealNSResult(); + } + + nsMainThreadPtrHandle promiseHolder( + new nsMainThreadPtrHolder("nsEdgeMigrationUtils Promise", + promise)); + + NS_DispatchBackgroundTask(NS_NewRunnableFunction( + __func__, + [promiseHolder = std::move(promiseHolder), path = std::move(path)]() { + HANDLE file = ::CreateFileW(path.get(), GENERIC_READ, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, 0, nullptr); + + bool locked = true; + if (file != INVALID_HANDLE_VALUE) { + locked = false; + ::CloseHandle(file); + } + + NS_DispatchToMainThread(NS_NewRunnableFunction( + __func__, [promiseHolder = std::move(promiseHolder), locked]() { + promiseHolder.get()->MaybeResolve(locked); + })); + })); + + promise.forget(aPromise); + + return NS_OK; +} + +} // namespace mozilla diff --git a/browser/components/migration/nsEdgeMigrationUtils.h b/browser/components/migration/nsEdgeMigrationUtils.h new file mode 100644 index 0000000000..d85fff10ad --- /dev/null +++ b/browser/components/migration/nsEdgeMigrationUtils.h @@ -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/. */ + +#ifndef nsedgemigrationutils__h__ +#define nsedgemigrationutils__h__ + +#include "nsISupportsImpl.h" +#include "nsIEdgeMigrationUtils.h" + +namespace mozilla { + +class nsEdgeMigrationUtils final : public nsIEdgeMigrationUtils { + NS_DECL_ISUPPORTS + NS_DECL_NSIEDGEMIGRATIONUTILS + + private: + ~nsEdgeMigrationUtils() = default; +}; + +} // namespace mozilla + +#endif diff --git a/browser/components/migration/nsIEHistoryEnumerator.cpp b/browser/components/migration/nsIEHistoryEnumerator.cpp new file mode 100644 index 0000000000..497b92bab7 --- /dev/null +++ b/browser/components/migration/nsIEHistoryEnumerator.cpp @@ -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/. */ + +#include "nsIEHistoryEnumerator.h" + +#include +#include + +#include "nsArrayEnumerator.h" +#include "nsComponentManagerUtils.h" +#include "nsCOMArray.h" +#include "nsIURI.h" +#include "nsNetUtil.h" +#include "nsString.h" +#include "nsWindowsMigrationUtils.h" +#include "prtime.h" + +//////////////////////////////////////////////////////////////////////////////// +//// nsIEHistoryEnumerator + +nsIEHistoryEnumerator::nsIEHistoryEnumerator() { ::CoInitialize(nullptr); } + +nsIEHistoryEnumerator::~nsIEHistoryEnumerator() { ::CoUninitialize(); } + +void nsIEHistoryEnumerator::EnsureInitialized() { + if (mURLEnumerator) return; + + HRESULT hr = + ::CoCreateInstance(CLSID_CUrlHistory, nullptr, CLSCTX_INPROC_SERVER, + IID_IUrlHistoryStg2, getter_AddRefs(mIEHistory)); + if (FAILED(hr)) return; + + hr = mIEHistory->EnumUrls(getter_AddRefs(mURLEnumerator)); + if (FAILED(hr)) return; +} + +NS_IMETHODIMP +nsIEHistoryEnumerator::HasMoreElements(bool* _retval) { + *_retval = false; + + EnsureInitialized(); + MOZ_ASSERT(mURLEnumerator, + "Should have instanced an IE History URLEnumerator"); + if (!mURLEnumerator) return NS_OK; + + STATURL statURL; + ULONG fetched; + + // First argument is not implemented, so doesn't matter what we pass. + HRESULT hr = mURLEnumerator->Next(1, &statURL, &fetched); + if (FAILED(hr) || fetched != 1UL) { + // Reached the last entry. + return NS_OK; + } + + nsCOMPtr uri; + if (statURL.pwcsUrl) { + nsDependentString url(statURL.pwcsUrl); + nsresult rv = NS_NewURI(getter_AddRefs(uri), url); + ::CoTaskMemFree(statURL.pwcsUrl); + if (NS_FAILED(rv)) { + // Got a corrupt or invalid URI, continue to the next entry. + return HasMoreElements(_retval); + } + } + + nsDependentString title(statURL.pwcsTitle ? statURL.pwcsTitle : L""); + + bool lastVisitTimeIsValid; + PRTime lastVisited = WinMigrationFileTimeToPRTime(&(statURL.ftLastVisited), + &lastVisitTimeIsValid); + + mCachedNextEntry = do_CreateInstance("@mozilla.org/hash-property-bag;1"); + MOZ_ASSERT(mCachedNextEntry, "Should have instanced a new property bag"); + if (mCachedNextEntry) { + mCachedNextEntry->SetPropertyAsInterface(u"uri"_ns, uri); + mCachedNextEntry->SetPropertyAsAString(u"title"_ns, title); + if (lastVisitTimeIsValid) { + mCachedNextEntry->SetPropertyAsInt64(u"time"_ns, lastVisited); + } + + *_retval = true; + } + + if (statURL.pwcsTitle) ::CoTaskMemFree(statURL.pwcsTitle); + + return NS_OK; +} + +NS_IMETHODIMP +nsIEHistoryEnumerator::GetNext(nsISupports** _retval) { + *_retval = nullptr; + + EnsureInitialized(); + MOZ_ASSERT(mURLEnumerator, + "Should have instanced an IE History URLEnumerator"); + if (!mURLEnumerator) return NS_OK; + + if (!mCachedNextEntry) { + bool hasMore = false; + nsresult rv = this->HasMoreElements(&hasMore); + if (NS_FAILED(rv)) { + return rv; + } + if (!hasMore) { + return NS_ERROR_FAILURE; + } + } + + NS_ADDREF(*_retval = mCachedNextEntry); + // Release the cached entry, so it can't be returned twice. + mCachedNextEntry = nullptr; + + return NS_OK; +} diff --git a/browser/components/migration/nsIEHistoryEnumerator.h b/browser/components/migration/nsIEHistoryEnumerator.h new file mode 100644 index 0000000000..cd0c202bfc --- /dev/null +++ b/browser/components/migration/nsIEHistoryEnumerator.h @@ -0,0 +1,39 @@ +/* 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 iehistoryenumerator___h___ +#define iehistoryenumerator___h___ + +#include + +#include "mozilla/Attributes.h" +#include "nsCOMPtr.h" +#include "nsIWritablePropertyBag2.h" +#include "nsSimpleEnumerator.h" + +class nsIEHistoryEnumerator final : public nsSimpleEnumerator { + public: + NS_DECL_NSISIMPLEENUMERATOR + + nsIEHistoryEnumerator(); + + const nsID& DefaultInterface() override { + return NS_GET_IID(nsIWritablePropertyBag2); + } + + private: + ~nsIEHistoryEnumerator() override; + + /** + * Initializes the history reader, if needed. + */ + void EnsureInitialized(); + + RefPtr mIEHistory; + RefPtr mURLEnumerator; + + nsCOMPtr mCachedNextEntry; +}; + +#endif diff --git a/browser/components/migration/nsIEdgeMigrationUtils.idl b/browser/components/migration/nsIEdgeMigrationUtils.idl new file mode 100644 index 0000000000..8c15d00251 --- /dev/null +++ b/browser/components/migration/nsIEdgeMigrationUtils.idl @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" +#include "nsIFile.idl" + +/** + * Utilities for migrating from legacy (non-Chromimum-based) Edge. + */ +[builtinclass, scriptable, uuid(9c7b7436-a17c-4c03-ba66-aeb5ae070126)] +interface nsIEdgeMigrationUtils : nsISupports { + /** + * Determine if the Edge database is locked for writing. + * + * @param aFile The path to the Edge database. + * + * @returns A promise that is resolved to whether or not the given database + * could be opened for writing. + */ + [implicit_jscontext] + Promise isDbLocked(in nsIFile aFile); +}; diff --git a/browser/components/migration/nsIKeychainMigrationUtils.idl b/browser/components/migration/nsIKeychainMigrationUtils.idl new file mode 100644 index 0000000000..e0a9db4ddf --- /dev/null +++ b/browser/components/migration/nsIKeychainMigrationUtils.idl @@ -0,0 +1,12 @@ +/* -*- 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 "nsISupports.idl" + +[scriptable, uuid(647bf80c-cd35-4ce6-b904-fd586b97ae48)] +interface nsIKeychainMigrationUtils : nsISupports +{ + ACString getGenericPassword(in ACString aServiceName, in ACString aAccountName); +}; diff --git a/browser/components/migration/nsKeychainMigrationUtils.h b/browser/components/migration/nsKeychainMigrationUtils.h new file mode 100644 index 0000000000..343c24086e --- /dev/null +++ b/browser/components/migration/nsKeychainMigrationUtils.h @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef nsKeychainMigrationUtils_h__ +#define nsKeychainMigrationUtils_h__ + +#include + +#include "nsIKeychainMigrationUtils.h" + +class nsKeychainMigrationUtils : public nsIKeychainMigrationUtils { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSIKEYCHAINMIGRATIONUTILS + + nsKeychainMigrationUtils(){}; + + protected: + virtual ~nsKeychainMigrationUtils(){}; +}; + +#endif diff --git a/browser/components/migration/nsKeychainMigrationUtils.mm b/browser/components/migration/nsKeychainMigrationUtils.mm new file mode 100644 index 0000000000..85c09c9ef3 --- /dev/null +++ b/browser/components/migration/nsKeychainMigrationUtils.mm @@ -0,0 +1,68 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsKeychainMigrationUtils.h" + +#include + +#include "mozilla/Logging.h" + +#include "nsCocoaUtils.h" +#include "nsString.h" + +using namespace mozilla; + +LazyLogModule gKeychainUtilsLog("keychainmigrationutils"); + +NS_IMPL_ISUPPORTS(nsKeychainMigrationUtils, nsIKeychainMigrationUtils) + +NS_IMETHODIMP +nsKeychainMigrationUtils::GetGenericPassword(const nsACString& aServiceName, + const nsACString& aAccountName, + nsACString& aKey) { + // To retrieve a secret, we create a CFDictionary of the form: + // { class: generic password, + // service: the given service name + // account: the given account name, + // match limit: match one, + // return attributes: true, + // return data: true } + // This searches for and returns the attributes and data for the secret + // matching the given service and account names. We then extract the data + // (i.e. the secret) and return it. + NSDictionary* searchDictionary = @{ + (__bridge NSString*) + kSecClass : (__bridge NSString*)kSecClassGenericPassword, + (__bridge NSString*) + kSecAttrService : nsCocoaUtils::ToNSString(aServiceName), + (__bridge NSString*) + kSecAttrAccount : nsCocoaUtils::ToNSString(aAccountName), + (__bridge NSString*)kSecMatchLimit : (__bridge NSString*)kSecMatchLimitOne, + (__bridge NSString*)kSecReturnAttributes : @YES, + (__bridge NSString*)kSecReturnData : @YES + }; + + CFTypeRef item; + // https://developer.apple.com/documentation/security/1398306-secitemcopymatching + OSStatus rv = + SecItemCopyMatching((__bridge CFDictionaryRef)searchDictionary, &item); + if (rv != errSecSuccess) { + MOZ_LOG(gKeychainUtilsLog, LogLevel::Debug, + ("SecItemCopyMatching failed: %d", rv)); + return NS_ERROR_FAILURE; + } + NSDictionary* resultDict = [(__bridge NSDictionary*)item autorelease]; + NSData* secret = [resultDict objectForKey:(__bridge NSString*)kSecValueData]; + if (!secret) { + MOZ_LOG(gKeychainUtilsLog, LogLevel::Debug, ("objectForKey failed")); + return NS_ERROR_FAILURE; + } + if ([secret length] != 0) { + // We assume that the data is UTF-8 encoded since that seems to be common + // and Keychain Access shows it with that encoding. + aKey.Assign(reinterpret_cast([secret bytes]), [secret length]); + } + + return NS_OK; +} diff --git a/browser/components/migration/nsWindowsMigrationUtils.h b/browser/components/migration/nsWindowsMigrationUtils.h new file mode 100644 index 0000000000..4541759485 --- /dev/null +++ b/browser/components/migration/nsWindowsMigrationUtils.h @@ -0,0 +1,33 @@ +/* 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 windowsmigrationutils__h__ +#define windowsmigrationutils__h__ + +#include "prtime.h" + +static PRTime WinMigrationFileTimeToPRTime(FILETIME* filetime, bool* isValid) { + SYSTEMTIME st; + *isValid = ::FileTimeToSystemTime(filetime, &st); + if (!*isValid) { + return 0; + } + PRExplodedTime prt; + prt.tm_year = st.wYear; + // SYSTEMTIME's day-of-month parameter is 1-based, + // PRExplodedTime's is 0-based. + prt.tm_month = st.wMonth - 1; + prt.tm_mday = st.wDay; + prt.tm_hour = st.wHour; + prt.tm_min = st.wMinute; + prt.tm_sec = st.wSecond; + prt.tm_usec = st.wMilliseconds * 1000; + prt.tm_wday = 0; + prt.tm_yday = 0; + prt.tm_params.tp_gmt_offset = 0; + prt.tm_params.tp_dst_offset = 0; + return PR_ImplodeTime(&prt); +} + +#endif diff --git a/browser/components/migration/tests/browser/browser.toml b/browser/components/migration/tests/browser/browser.toml new file mode 100644 index 0000000000..637c6da345 --- /dev/null +++ b/browser/components/migration/tests/browser/browser.toml @@ -0,0 +1,50 @@ +[DEFAULT] +head = "head.js" +prefs = [ + "browser.migrate.internal-testing.enabled=true", + "dom.window.sizeToContent.enabled=true", +] +support-files = ["../head-common.js"] + +["browser_aboutwelcome_behavior.js"] + +["browser_dialog_cancel_close.js"] + +["browser_dialog_open.js"] + +["browser_dialog_resize.js"] + +["browser_disabled_migrator.js"] + +["browser_do_migration.js"] + +["browser_entrypoint_telemetry.js"] + +["browser_extension_migration.js"] +skip-if = ["win11_2009"] # Bug 1840718 + +["browser_file_migration.js"] +skip-if = [ + "os == 'win' && debug", # Bug 1827995 + "a11y_checks", # Bug 1858037 to investigate intermittent a11y_checks results (fails on Try, passes on Autoland) +] +support-files = ["dummy_file.csv"] + +["browser_ie_edge_bookmarks_success_strings.js"] +skip-if = ["a11y_checks"] # Bug 1858037 to investigate intermittent a11y_checks results (fails on Try, passes on Autoland) + +["browser_misc_telemetry.js"] +skip-if = ["a11y_checks"] # Bug 1858037 to investigate intermittent a11y_checks results (fails on Autoland, passes on Try) + +["browser_no_browsers_state.js"] + +["browser_only_file_migrators.js"] + +["browser_permissions.js"] +skip-if = ["a11y_checks"] # Bug 1858037 and 1855492 to investigate intermittent a11y_checks results (fails on Try, passes on Autoland) + +["browser_safari_passwords.js"] +run-if = ["os == 'mac'"] + +["browser_safari_permissions.js"] +run-if = ["os == 'mac'"] diff --git a/browser/components/migration/tests/browser/browser_aboutwelcome_behavior.js b/browser/components/migration/tests/browser/browser_aboutwelcome_behavior.js new file mode 100644 index 0000000000..72c90851e2 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_aboutwelcome_behavior.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if browser.migrate.content-modal.about-welcome-behavior + * is "autoclose", that closing the migration dialog when opened with the + * NEWTAB entrypoint (which currently only occurs from about:welcome), + * will result in the about:preferences tab closing too. + */ +add_task(async function test_autoclose_from_welcome() { + await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank"); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.migrate.content-modal.about-welcome-behavior", "autoclose"], + ], + }); + + let migrationDialogPromise = waitForMigrationWizardDialogTab(); + MigrationUtils.showMigrationWizard(window, { + entrypoint: MigrationUtils.MIGRATION_ENTRYPOINTS.NEWTAB, + }); + + let prefsBrowser = await migrationDialogPromise; + let prefsTab = gBrowser.getTabForBrowser(prefsBrowser); + + let tabClosed = BrowserTestUtils.waitForTabClosing(prefsTab); + + let dialog = prefsBrowser.contentDocument.querySelector( + "#migrationWizardDialog" + ); + + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + await BrowserTestUtils.synthesizeKey("VK_ESCAPE", {}, prefsBrowser); + await dialogClosed; + await tabClosed; + Assert.ok(true, "Preferences tab closed with autoclose behavior."); +}); + +/** + * Tests that if browser.migrate.content-modal.about-welcome-behavior + * is "default", that closing the migration dialog when opened with the + * NEWTAB entrypoint (which currently only occurs from about:welcome), + * will result in the about:preferences tab still staying open. + */ +add_task(async function test_no_autoclose_from_welcome() { + // Create a new blank tab which about:preferences will open into. + await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank"); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.migrate.content-modal.about-welcome-behavior", "default"]], + }); + + let migrationDialogPromise = waitForMigrationWizardDialogTab(); + MigrationUtils.showMigrationWizard(window, { + entrypoint: MigrationUtils.MIGRATION_ENTRYPOINTS.NEWTAB, + }); + + let prefsBrowser = await migrationDialogPromise; + let prefsTab = gBrowser.getTabForBrowser(prefsBrowser); + + let dialog = prefsBrowser.contentDocument.querySelector( + "#migrationWizardDialog" + ); + + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + await BrowserTestUtils.synthesizeKey("VK_ESCAPE", {}, prefsBrowser); + await dialogClosed; + Assert.ok(!prefsTab.closing, "about:preferences tab is not closing."); + + BrowserTestUtils.removeTab(prefsTab); +}); + +/** + * Tests that if browser.migrate.content-modal.about-welcome-behavior + * is "standalone", that opening the migration wizard from the NEWTAB + * entrypoint opens the migration wizard in a standalone top-level + * window. + */ +add_task(async function test_no_autoclose_from_welcome() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.migrate.content-modal.about-welcome-behavior", "standalone"], + ], + }); + + let windowOpened = BrowserTestUtils.domWindowOpened(); + MigrationUtils.showMigrationWizard(window, { + entrypoint: MigrationUtils.MIGRATION_ENTRYPOINTS.NEWTAB, + }); + let dialogWin = await windowOpened; + Assert.ok(dialogWin, "Top-level dialog window opened for the migrator."); + await BrowserTestUtils.waitForEvent(dialogWin, "MigrationWizard:Ready"); + + let dialogClosed = BrowserTestUtils.domWindowClosed(dialogWin); + dialogWin.close(); + await dialogClosed; +}); diff --git a/browser/components/migration/tests/browser/browser_dialog_cancel_close.js b/browser/components/migration/tests/browser/browser_dialog_cancel_close.js new file mode 100644 index 0000000000..8fe510cf30 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_dialog_cancel_close.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that pressing "Cancel" from the selection page of the migration + * dialog closes the dialog when opened in about:preferences as an HTML5 + * dialog. + */ +add_task(async function test_cancel_close() { + await withMigrationWizardDialog(async prefsWin => { + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let wizard = dialog.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let cancelButton = shadow.querySelector( + 'div[name="page-selection"] .cancel-close' + ); + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + cancelButton.click(); + await dialogClosed; + Assert.ok(true, "Clicking the cancel button closed the dialog."); + }); +}); + +/** + * Tests that pressing "Cancel" from the selection page of the migration + * dialog closes the dialog when opened in stand-alone window. + */ +add_task(async function test_cancel_close() { + let promiseWinLoaded = BrowserTestUtils.domWindowOpened().then(win => { + return BrowserTestUtils.waitForEvent(win, "MigrationWizard:Ready"); + }); + + let win = Services.ww.openWindow( + window, + DIALOG_URL, + "_blank", + "dialog,centerscreen", + {} + ); + await promiseWinLoaded; + + win.sizeToContent(); + let wizard = win.document.querySelector("#wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let cancelButton = shadow.querySelector( + 'div[name="page-selection"] .cancel-close' + ); + + let windowClosed = BrowserTestUtils.windowClosed(win); + cancelButton.click(); + await windowClosed; + Assert.ok(true, "Window was closed."); +}); diff --git a/browser/components/migration/tests/browser/browser_dialog_open.js b/browser/components/migration/tests/browser/browser_dialog_open.js new file mode 100644 index 0000000000..1ec43f0ea6 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_dialog_open.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that we can open the migration dialog in an about:preferences + * HTML5 dialog when calling MigrationUtils.showMigrationWizard within a + * tabbrowser window execution context. + */ +add_task(async function test_migration_dialog_open_in_tab_dialog_box() { + let migrationDialogPromise = waitForMigrationWizardDialogTab(); + MigrationUtils.showMigrationWizard(window, {}); + let prefsBrowser = await migrationDialogPromise; + Assert.ok(true, "Migration dialog was opened"); + let dialog = prefsBrowser.contentDocument.querySelector( + "#migrationWizardDialog" + ); + + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + await BrowserTestUtils.synthesizeKey("VK_ESCAPE", {}, prefsBrowser); + await dialogClosed; + BrowserTestUtils.startLoadingURIString(prefsBrowser, "about:blank"); + await BrowserTestUtils.browserLoaded(prefsBrowser); +}); + +/** + * Tests that we can open the migration dialog in a stand-alone window + * when calling MigrationUtils.showMigrationWizard with a null opener + * argument, or a non-tabbrowser window context. + */ +add_task(async function test_migration_dialog_open_in_xul_window() { + let firstWindowOpened = BrowserTestUtils.domWindowOpened(); + MigrationUtils.showMigrationWizard(null, {}); + let firstDialogWin = await firstWindowOpened; + + await BrowserTestUtils.waitForEvent(firstDialogWin, "MigrationWizard:Ready"); + + Assert.ok(true, "Migration dialog was opened"); + + // Now open a second migration dialog, using the first as the window + // argument. + + let secondWindowOpened = BrowserTestUtils.domWindowOpened(); + MigrationUtils.showMigrationWizard(firstDialogWin, {}); + let secondDialogWin = await secondWindowOpened; + + await BrowserTestUtils.waitForEvent(secondDialogWin, "MigrationWizard:Ready"); + + for (let dialogWin of [firstDialogWin, secondDialogWin]) { + let dialogClosed = BrowserTestUtils.domWindowClosed(dialogWin); + dialogWin.close(); + await dialogClosed; + } +}); diff --git a/browser/components/migration/tests/browser/browser_dialog_resize.js b/browser/components/migration/tests/browser/browser_dialog_resize.js new file mode 100644 index 0000000000..8fb05faf2c --- /dev/null +++ b/browser/components/migration/tests/browser/browser_dialog_resize.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if the MigrationWizard resizes when opened inside of a + * XUL window, that it causes the containing XUL window to resize + * appropriately. + */ +add_task(async function test_migration_dialog_resize_in_xul_window() { + let windowOpened = BrowserTestUtils.domWindowOpened(); + MigrationUtils.showMigrationWizard(null, {}); + let dialogWin = await windowOpened; + + await BrowserTestUtils.waitForEvent(dialogWin, "MigrationWizard:Ready"); + + let wizard = dialogWin.document.body.querySelector("#wizard"); + let height = wizard.getBoundingClientRect().height; + + let windowResizePromise = BrowserTestUtils.waitForEvent(dialogWin, "resize"); + wizard.style.height = height + 100 + "px"; + await windowResizePromise; + Assert.ok(true, "Migration dialog window resized."); + + let dialogClosed = BrowserTestUtils.domWindowClosed(dialogWin); + dialogWin.close(); + await dialogClosed; +}); diff --git a/browser/components/migration/tests/browser/browser_disabled_migrator.js b/browser/components/migration/tests/browser/browser_disabled_migrator.js new file mode 100644 index 0000000000..782666f6a6 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_disabled_migrator.js @@ -0,0 +1,131 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { MigratorBase } = ChromeUtils.importESModule( + "resource:///modules/MigratorBase.sys.mjs" +); + +/** + * Tests that the InternalTestingProfileMigrator is listed in + * the new migration wizard selector when enabled. + */ +add_task(async function test_enabled_migrator() { + await withMigrationWizardDialog(async prefsWin => { + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let wizard = dialog.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let selector = shadow.querySelector("#browser-profile-selector"); + selector.click(); + + await new Promise(resolve => { + shadow + .querySelector("panel-list") + .addEventListener("shown", resolve, { once: true }); + }); + + let panelItem = shadow.querySelector( + `panel-item[key="${InternalTestingProfileMigrator.key}"]` + ); + + Assert.ok( + panelItem, + "The InternalTestingProfileMigrator panel-item exists." + ); + panelItem.click(); + + Assert.ok( + selector.innerText.includes("Internal Testing Migrator"), + "Testing for enabled internal testing migrator" + ); + }); +}); + +/** + * Tests that the InternalTestingProfileMigrator is not listed in + * the new migration wizard selector when disabled. + */ +add_task(async function test_disabling_migrator() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.migrate.internal-testing.enabled", false]], + }); + + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let internalTestingMigrator = new InternalTestingProfileMigrator(); + + // We create a fake migrator that we know will still be present after + // disabling the InternalTestingProfileMigrator so that we don't switch + // the wizard to the NO_BROWSERS_FOUND page, which we're not testing here. + let fakeMigrator = new FakeMigrator(); + + let getMigratorStub = sandbox.stub(MigrationUtils, "getMigrator"); + getMigratorStub + .withArgs("internal-testing") + .resolves(internalTestingMigrator); + getMigratorStub.withArgs("fake-migrator").resolves(fakeMigrator); + + sandbox.stub(MigrationUtils, "availableMigratorKeys").get(() => { + return ["internal-testing", "fake-migrator"]; + }); + + await withMigrationWizardDialog(async prefsWin => { + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let wizard = dialog.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let selector = shadow.querySelector("#browser-profile-selector"); + selector.click(); + + await new Promise(resolve => { + shadow + .querySelector("panel-list") + .addEventListener("shown", resolve, { once: true }); + }); + + let panelItem = shadow.querySelector( + `panel-item[key="${InternalTestingProfileMigrator.key}"]` + ); + + Assert.ok( + !panelItem, + "The panel-item for the InternalTestingProfileMigrator does not exist" + ); + }); + + sandbox.restore(); +}); + +/** + * A stub of a migrator used for automated testing only. + */ +class FakeMigrator extends MigratorBase { + static get key() { + return "fake-migrator"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-firefox"; + } + + // We will create a single MigratorResource for each resource type that + // just immediately reports a successful migration. + getResources() { + return Object.values(MigrationUtils.resourceTypes).map(type => { + return { + type, + migrate: callback => { + callback(true /* success */); + }, + }; + }); + } + + // We need to override enabled() to always return true for testing purposes. + get enabled() { + return true; + } +} diff --git a/browser/components/migration/tests/browser/browser_do_migration.js b/browser/components/migration/tests/browser/browser_do_migration.js new file mode 100644 index 0000000000..fab9641960 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_do_migration.js @@ -0,0 +1,209 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the MigrationWizard can be used to successfully migrate + * using the InternalTestingProfileMigrator in a few scenarios. + */ +add_task(async function test_successful_migrations() { + // Scenario 1: A single resource type is available. + let migration = waitForTestMigration( + [MigrationUtils.resourceTypes.BOOKMARKS], + [MigrationUtils.resourceTypes.BOOKMARKS], + InternalTestingProfileMigrator.testProfile + ); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let selector = shadow.querySelector("#browser-profile-selector"); + + await new Promise(resolve => prefsWin.requestAnimationFrame(resolve)); + Assert.equal(shadow.activeElement, selector, "Selector should be focused."); + + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + selectResourceTypesAndStartMigration(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + ]); + await migration; + await wizardDone; + + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let doneButton = shadow.querySelector( + "div[name='page-progress'] .done-button" + ); + + await new Promise(resolve => prefsWin.requestAnimationFrame(resolve)); + Assert.equal( + shadow.activeElement, + doneButton, + "Done button should be focused." + ); + + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + doneButton.click(); + await dialogClosed; + assertQuantitiesShown(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + ]); + }); + + // We should make sure that the migration.time_to_produce_migrator_list + // scalar was set, since we know that at least one migration wizard has + // been opened. + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + Assert.ok( + scalars["migration.time_to_produce_migrator_list"] > 0, + "Non-zero scalar value recorded for migration.time_to_produce_migrator_list" + ); + + // Scenario 2: Several resource types are available, but only 1 + // is checked / expected. + migration = waitForTestMigration( + [ + MigrationUtils.resourceTypes.BOOKMARKS, + MigrationUtils.resourceTypes.PASSWORDS, + ], + [MigrationUtils.resourceTypes.PASSWORDS], + InternalTestingProfileMigrator.testProfile + ); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let selector = shadow.querySelector("#browser-profile-selector"); + + await new Promise(resolve => prefsWin.requestAnimationFrame(resolve)); + Assert.equal(shadow.activeElement, selector, "Selector should be focused."); + + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + selectResourceTypesAndStartMigration(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS, + ]); + await migration; + await wizardDone; + + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let doneButton = shadow.querySelector( + "div[name='page-progress'] .done-button" + ); + + await new Promise(resolve => prefsWin.requestAnimationFrame(resolve)); + Assert.equal( + shadow.activeElement, + doneButton, + "Done button should be focused." + ); + + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + doneButton.click(); + await dialogClosed; + assertQuantitiesShown(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS, + ]); + }); + + // Scenario 3: Several resource types are available, all are checked. + let allResourceTypeStrs = Object.values( + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES + ).filter(resourceStr => { + return !MigrationWizardConstants.PROFILE_RESET_ONLY_RESOURCE_TYPES[ + resourceStr + ]; + }); + + let allResourceTypes = allResourceTypeStrs.map(resourceTypeStr => { + return MigrationUtils.resourceTypes[resourceTypeStr]; + }); + + migration = waitForTestMigration( + allResourceTypes, + allResourceTypes, + InternalTestingProfileMigrator.testProfile + ); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let selector = shadow.querySelector("#browser-profile-selector"); + + await new Promise(resolve => prefsWin.requestAnimationFrame(resolve)); + Assert.equal(shadow.activeElement, selector, "Selector should be focused."); + + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + selectResourceTypesAndStartMigration(wizard, allResourceTypeStrs); + await migration; + await wizardDone; + assertQuantitiesShown(wizard, allResourceTypeStrs); + }); +}); + +/** + * Tests that if somehow the Migration Wizard requests to import a + * resource type that the migrator doesn't have the ability to import, + * that it's ignored and the migration completes normally. + */ +add_task(async function test_invalid_resource_type() { + let migration = waitForTestMigration( + [MigrationUtils.resourceTypes.BOOKMARKS], + [MigrationUtils.resourceTypes.BOOKMARKS], + InternalTestingProfileMigrator.testProfile + ); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + + // The Migration Wizard _shouldn't_ display anything except BOOKMARKS, + // since that's the only resource type that the selected migrator is + // supposed to currently support, but we'll check the other checkboxes + // even though they're hidden just to see what happens. + selectResourceTypesAndStartMigration(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA, + ]); + await migration; + await wizardDone; + + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let shadow = wizard.openOrClosedShadowRoot; + let doneButton = shadow.querySelector( + "div[name='page-progress'] .done-button" + ); + + await new Promise(resolve => prefsWin.requestAnimationFrame(resolve)); + Assert.equal( + shadow.activeElement, + doneButton, + "Done button should be focused." + ); + + assertQuantitiesShown(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + ]); + + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + doneButton.click(); + await dialogClosed; + }); +}); diff --git a/browser/components/migration/tests/browser/browser_entrypoint_telemetry.js b/browser/components/migration/tests/browser/browser_entrypoint_telemetry.js new file mode 100644 index 0000000000..a1bb23d7fc --- /dev/null +++ b/browser/components/migration/tests/browser/browser_entrypoint_telemetry.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const HISTOGRAM_ID = "FX_MIGRATION_ENTRY_POINT_CATEGORICAL"; + +async function showThenCloseMigrationWizardViaEntrypoint(entrypoint) { + let openedPromise = BrowserTestUtils.waitForMigrationWizard(window); + + MigrationUtils.showMigrationWizard(window, { + entrypoint, + }); + + let wizardTab = await openedPromise; + Assert.ok(wizardTab, "Migration wizard opened."); + + await BrowserTestUtils.removeTab(wizardTab); +} + +add_setup(async () => { + // Load the initial tab at example.com. This makes it so that if + // when we load the wizard in about:preferences, we'll load the + // about:preferences page in a new tab rather than overtaking the + // initial one. This makes cleanup of the wizard more explicit, since + // we can just close the tab. + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, "https://example.com"); + await BrowserTestUtils.browserLoaded(browser); +}); + +/** + * Tests that the entrypoint passed to MigrationUtils.showMigrationWizard gets + * written to both the FX_MIGRATION_ENTRY_POINT_CATEGORICAL histogram. + */ +add_task(async function test_entrypoints() { + let histogram = TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_ID); + + // Let's arbitrarily pick the "Bookmarks" entrypoint, and make sure this + // is recorded. + await showThenCloseMigrationWizardViaEntrypoint( + MigrationUtils.MIGRATION_ENTRYPOINTS.BOOKMARKS + ); + let entrypointIndex = getEntrypointHistogramIndex( + MigrationUtils.MIGRATION_ENTRYPOINTS.BOOKMARKS + ); + + TelemetryTestUtils.assertHistogram(histogram, entrypointIndex, 1); + + histogram = TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_ID); + + // Now let's pick the "Preferences" entrypoint, and make sure this + // is recorded. + await showThenCloseMigrationWizardViaEntrypoint( + MigrationUtils.MIGRATION_ENTRYPOINTS.PREFERENCES + ); + entrypointIndex = getEntrypointHistogramIndex( + MigrationUtils.MIGRATION_ENTRYPOINTS.PREFERENCES + ); + + TelemetryTestUtils.assertHistogram(histogram, entrypointIndex, 1); + + histogram = TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_ID); + + // Finally, check the fallback by passing in something invalid as an entrypoint. + await showThenCloseMigrationWizardViaEntrypoint(undefined); + entrypointIndex = getEntrypointHistogramIndex( + MigrationUtils.MIGRATION_ENTRYPOINTS.UNKNOWN + ); + + TelemetryTestUtils.assertHistogram(histogram, entrypointIndex, 1); +}); diff --git a/browser/components/migration/tests/browser/browser_extension_migration.js b/browser/components/migration/tests/browser/browser_extension_migration.js new file mode 100644 index 0000000000..e9c3c65e6d --- /dev/null +++ b/browser/components/migration/tests/browser/browser_extension_migration.js @@ -0,0 +1,238 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let gFluentStrings = new Localization([ + "branding/brand.ftl", + "browser/migrationWizard.ftl", +]); + +/** + * Ensures that the wizard is on the progress page and that the extension + * resource group matches a particular state. + * + * @param {Element} wizard + * The element to inspect. + * @param {number} state + * One of the constants from MigrationWizardConstants.PROGRESS_VALUE, + * describing what state the resource group should be in. + * @param {object} description + * An object to express more details of how the resource group should be + * displayed. + * @param {string} description.message + * The message that should be displayed for the resource group. This message + * maybe be contained in different elements depending on the state. + * @param {string} description.linkURL + * The URL for the element that should be displayed to the user for the + * particular state. + * @param {string} description.linkText + * The text content for the element that should be displayed to the user + * for the particular state. + * @returns {Promise} + */ +async function assertExtensionsProgressState(wizard, state, description) { + let shadow = wizard.openOrClosedShadowRoot; + + // Make sure that we're showing the progress page first. + let deck = shadow.querySelector("#wizard-deck"); + Assert.equal( + deck.selectedViewName, + `page-${MigrationWizardConstants.PAGES.PROGRESS}` + ); + + let progressGroup = shadow.querySelector( + `.resource-progress-group[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS}"` + ); + + let progressIcon = progressGroup.querySelector(".progress-icon"); + let messageText = progressGroup.querySelector("span.message-text"); + let supportLink = progressGroup.querySelector(".support-text"); + let extensionsSuccessLink = progressGroup.querySelector( + "#extensions-success-link" + ); + + if (state == MigrationWizardConstants.PROGRESS_VALUE.SUCCESS) { + Assert.stringMatches(progressIcon.getAttribute("state"), "success"); + Assert.stringMatches(messageText.textContent, ""); + Assert.stringMatches(supportLink.textContent, ""); + await assertSuccessLink(extensionsSuccessLink, description.message); + } else if (state == MigrationWizardConstants.PROGRESS_VALUE.WARNING) { + Assert.stringMatches(progressIcon.getAttribute("state"), "warning"); + Assert.stringMatches(messageText.textContent, description.message); + Assert.stringMatches(supportLink.textContent, description.linkText); + Assert.stringMatches(supportLink.href, description.linkURL); + await assertSuccessLink(extensionsSuccessLink, ""); + } else if (state == MigrationWizardConstants.PROGRESS_VALUE.INFO) { + Assert.stringMatches(progressIcon.getAttribute("state"), "info"); + Assert.stringMatches(supportLink.textContent, ""); + await assertSuccessLink(extensionsSuccessLink, description.message); + } +} + +/** + * Checks that the extensions migration success link has the right + * text content, and if the text content is non-blank, ensures that + * clicking on the link opens up about:addons in a background tab. + * + * The about:addons tab will be automatically closed before proceeding. + * + * @param {Element} link + * The extensions migration success link element. + * @param {string} message + * The expected string to appear in the link textContent. If the + * link is not expected to appear, this should be the empty string. + * @returns {Promise} + */ +async function assertSuccessLink(link, message) { + Assert.stringMatches(link.textContent, message); + if (message) { + let aboutAddonsOpened = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:addons" + ); + EventUtils.synthesizeMouseAtCenter(link, {}, link.ownerGlobal); + let tab = await aboutAddonsOpened; + BrowserTestUtils.removeTab(tab); + } +} + +/** + * Checks the case where no extensions were matched. + */ +add_task(async function test_extension_migration_no_matched_extensions() { + let migration = waitForTestMigration( + [MigrationUtils.resourceTypes.EXTENSIONS], + [MigrationUtils.resourceTypes.EXTENSIONS], + InternalTestingProfileMigrator.testProfile, + [MigrationUtils.resourceTypes.EXTENSIONS], + 3 /* totalExtensions */, + 0 /* matchedExtensions */ + ); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + selectResourceTypesAndStartMigration(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS, + ]); + await migration; + await wizardDone; + await assertExtensionsProgressState( + wizard, + MigrationWizardConstants.PROGRESS_VALUE.WARNING, + { + message: await gFluentStrings.formatValue( + "migration-wizard-progress-no-matched-extensions" + ), + linkURL: Services.urlFormatter.formatURLPref( + "extensions.getAddons.link.url" + ), + linkText: await gFluentStrings.formatValue( + "migration-wizard-progress-extensions-addons-link" + ), + } + ); + }); +}); + +/** + * Checks the case where some but not all extensions were matched. + */ +add_task( + async function test_extension_migration_partially_matched_extensions() { + const TOTAL_EXTENSIONS = 3; + const TOTAL_MATCHES = 1; + + let migration = waitForTestMigration( + [MigrationUtils.resourceTypes.EXTENSIONS], + [MigrationUtils.resourceTypes.EXTENSIONS], + InternalTestingProfileMigrator.testProfile, + [], + TOTAL_EXTENSIONS, + TOTAL_MATCHES + ); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + selectResourceTypesAndStartMigration(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS, + ]); + await migration; + await wizardDone; + await assertExtensionsProgressState( + wizard, + MigrationWizardConstants.PROGRESS_VALUE.INFO, + { + message: await gFluentStrings.formatValue( + "migration-wizard-progress-partial-success-extensions", + { + matched: TOTAL_MATCHES, + quantity: TOTAL_EXTENSIONS, + } + ), + linkText: await gFluentStrings.formatValue( + "migration-wizard-progress-extensions-support-link" + ), + } + ); + }); + } +); + +/** + * Checks the case where all extensions were matched. + */ +add_task(async function test_extension_migration_fully_matched_extensions() { + const TOTAL_EXTENSIONS = 15; + const TOTAL_MATCHES = TOTAL_EXTENSIONS; + + let migration = waitForTestMigration( + [MigrationUtils.resourceTypes.EXTENSIONS], + [MigrationUtils.resourceTypes.EXTENSIONS], + InternalTestingProfileMigrator.testProfile, + [], + TOTAL_EXTENSIONS, + TOTAL_MATCHES + ); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + selectResourceTypesAndStartMigration(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS, + ]); + await migration; + await wizardDone; + await assertExtensionsProgressState( + wizard, + MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + { + message: await gFluentStrings.formatValue( + "migration-wizard-progress-success-extensions", + { + quantity: TOTAL_EXTENSIONS, + } + ), + linkURL: "", + linkText: "", + } + ); + }); +}); diff --git a/browser/components/migration/tests/browser/browser_file_migration.js b/browser/components/migration/tests/browser/browser_file_migration.js new file mode 100644 index 0000000000..04241d29d5 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_file_migration.js @@ -0,0 +1,306 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { FileMigratorBase } = ChromeUtils.importESModule( + "resource:///modules/FileMigrators.sys.mjs" +); + +const DUMMY_FILEMIGRATOR_KEY = "dummy-file-migrator"; +const DUMMY_FILEPICKER_TITLE = "Some dummy file picker title"; +const DUMMY_FILTER_TITLE = "Some file type"; +const DUMMY_EXTENSION_PATTERN = "*.test"; +const TEST_FILE_PATH = getTestFilePath("dummy_file.csv"); + +/** + * A subclass of FileMigratorBase that doesn't do anything, but + * is useful for testing. + * + * Notably, the `migrate` method is not overridden here. Tests that + * use this class should use Sinon to stub out the migrate method. + */ +class DummyFileMigrator extends FileMigratorBase { + static get key() { + return DUMMY_FILEMIGRATOR_KEY; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-file-password-csv"; + } + + static get brandImage() { + return "chrome://branding/content/document.ico"; + } + + get enabled() { + return true; + } + + get progressHeaderL10nID() { + return "migration-passwords-from-file-progress-header"; + } + + get successHeaderL10nID() { + return "migration-passwords-from-file-success-header"; + } + + async getFilePickerConfig() { + return Promise.resolve({ + title: DUMMY_FILEPICKER_TITLE, + filters: [ + { + title: DUMMY_FILTER_TITLE, + extensionPattern: DUMMY_EXTENSION_PATTERN, + }, + ], + }); + } + + get displayedResourceTypes() { + return [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS]; + } +} + +const { MockFilePicker } = SpecialPowers; + +add_setup(async () => { + // We use MockFilePicker to simulate a native file picker, and prepare it + // to return a dummy file pointed at TEST_FILE_PATH. The file at + // TEST_FILE_PATH is not required (nor expected) to exist. + MockFilePicker.init(window); + registerCleanupFunction(() => { + MockFilePicker.cleanup(); + }); +}); + +/** + * Tests the flow of selecting a file migrator (in this case, + * the DummyFileMigrator), getting the file picker opened for it, + * and then passing the path of the selected file to the migrator. + */ +add_task(async function test_file_migration() { + let migrator = new DummyFileMigrator(); + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + // First, use Sinon to insert our DummyFileMigrator as the only available + // file migrator. + sandbox.stub(MigrationUtils, "getFileMigrator").callsFake(() => { + return migrator; + }); + sandbox.stub(MigrationUtils, "availableFileMigrators").get(() => { + return [migrator]; + }); + + // This is the expected success state that our DummyFileMigrator will + // return as the final progress update to the migration wizard. + const SUCCESS_STATE = { + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_NEW]: + "2 added", + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_UPDATED]: + "1 updated", + }; + + let migrateStub = sandbox.stub(migrator, "migrate").callsFake(filePath => { + Assert.equal(filePath, TEST_FILE_PATH); + return SUCCESS_STATE; + }); + + let dummyFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + dummyFile.initWithPath(TEST_FILE_PATH); + let filePickerShownPromise = new Promise(resolve => { + MockFilePicker.showCallback = () => { + Assert.ok(true, "Filepicker shown."); + MockFilePicker.setFiles([dummyFile]); + resolve(); + }; + }); + MockFilePicker.returnValue = MockFilePicker.returnOK; + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + + // Now select our DummyFileMigrator from the list. + let selector = shadow.querySelector("#browser-profile-selector"); + selector.click(); + + info("Waiting for panel-list shown"); + await new Promise(resolve => { + shadow + .querySelector("panel-list") + .addEventListener("shown", resolve, { once: true }); + }); + + info("Panel list shown. Clicking on panel-item"); + let panelItem = shadow.querySelector( + `panel-item[key="${DUMMY_FILEMIGRATOR_KEY}"]` + ); + panelItem.click(); + + // Selecting a file migrator from the selector should automatically + // open the file picker, so we await it here. Once the file is + // selected, migration should begin immediately. + + info("Waiting for file picker"); + await filePickerShownPromise; + await wizardDone; + Assert.ok(migrateStub.called, "Migrate on DummyFileMigrator was called."); + + // At this point, with migration having completed, we should be showing + // the PROGRESS page with the SUCCESS_STATE represented. + let deck = shadow.querySelector("#wizard-deck"); + Assert.equal( + deck.selectedViewName, + `page-${MigrationWizardConstants.PAGES.FILE_IMPORT_PROGRESS}` + ); + + // We expect only the displayed resource types in SUCCESS_STATE are + // displayed now. + let progressGroups = shadow.querySelectorAll( + "div[name='page-file-import-progress'] .resource-progress-group" + ); + for (let progressGroup of progressGroups) { + let expectedMessageText = + SUCCESS_STATE[progressGroup.dataset.resourceType]; + if (expectedMessageText) { + let progressIcon = progressGroup.querySelector(".progress-icon"); + Assert.stringMatches( + progressIcon.getAttribute("state"), + "success", + "Should be showing completed state." + ); + + let messageText = + progressGroup.querySelector(".message-text").textContent; + Assert.equal(messageText, expectedMessageText); + } else { + Assert.ok( + BrowserTestUtils.isHidden(progressGroup), + `Resource progress group for ${progressGroup.dataset.resourceType}` + + ` should be hidden.` + ); + } + } + }); + + sandbox.restore(); +}); + +/** + * Tests that the migration wizard will go back to the selection page and + * show an error message if the migration for a FileMigrator throws an + * exception. + */ +add_task(async function test_file_migration_error() { + let migrator = new DummyFileMigrator(); + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + // First, use Sinon to insert our DummyFileMigrator as the only available + // file migrator. + sandbox.stub(MigrationUtils, "getFileMigrator").callsFake(() => { + return migrator; + }); + sandbox.stub(MigrationUtils, "availableFileMigrators").get(() => { + return [migrator]; + }); + + const ERROR_MESSAGE = "This is my error message"; + + let migrateStub = sandbox.stub(migrator, "migrate").callsFake(() => { + throw new Error(ERROR_MESSAGE); + }); + + let dummyFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + dummyFile.initWithPath(TEST_FILE_PATH); + let filePickerShownPromise = new Promise(resolve => { + MockFilePicker.showCallback = () => { + Assert.ok(true, "Filepicker shown."); + MockFilePicker.setFiles([dummyFile]); + resolve(); + }; + }); + MockFilePicker.returnValue = MockFilePicker.returnOK; + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + + // Now select our DummyFileMigrator from the list. + let selector = shadow.querySelector("#browser-profile-selector"); + selector.click(); + + info("Waiting for panel-list shown"); + await new Promise(resolve => { + shadow + .querySelector("panel-list") + .addEventListener("shown", resolve, { once: true }); + }); + + info("Panel list shown. Clicking on panel-item"); + let panelItem = shadow.querySelector( + `panel-item[key="${DUMMY_FILEMIGRATOR_KEY}"]` + ); + panelItem.click(); + + // Selecting a file migrator from the selector should automatically + // open the file picker, so we await it here. Once the file is + // selected, migration should begin immediately. + + info("Waiting for file picker"); + await filePickerShownPromise; + await wizardDone; + Assert.ok(migrateStub.called, "Migrate on DummyFileMigrator was called."); + + // At this point, with migration having completed, we should be showing + // the SELECTION page again with the ERROR_MESSAGE displayed. + let deck = shadow.querySelector("#wizard-deck"); + await BrowserTestUtils.waitForMutationCondition( + deck, + { attributeFilter: ["selected-view"] }, + () => { + return ( + deck.getAttribute("selected-view") == + "page-" + MigrationWizardConstants.PAGES.SELECTION + ); + } + ); + + Assert.equal( + selector.selectedPanelItem.getAttribute("key"), + DUMMY_FILEMIGRATOR_KEY, + "Should have the file migrator selected." + ); + + let errorMessageContainer = shadow.querySelector(".file-import-error"); + Assert.ok( + BrowserTestUtils.isVisible(errorMessageContainer), + "Should be showing the error message container" + ); + + let fileImportErrorMessage = shadow.querySelector( + "#file-import-error-message" + ).textContent; + Assert.equal(fileImportErrorMessage, ERROR_MESSAGE); + }); + + sandbox.restore(); +}); diff --git a/browser/components/migration/tests/browser/browser_ie_edge_bookmarks_success_strings.js b/browser/components/migration/tests/browser/browser_ie_edge_bookmarks_success_strings.js new file mode 100644 index 0000000000..34e8a8de2e --- /dev/null +++ b/browser/components/migration/tests/browser/browser_ie_edge_bookmarks_success_strings.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the progress strings that the Migration Wizard shows + * during migrations for IE and Edge uses the term "Favorites" rather + * then "Bookmarks". + */ +add_task(async function test_ie_edge_bookmarks_success_strings() { + for (let key of ["ie", "edge", "internal-testing"]) { + let sandbox = sinon.createSandbox(); + + sandbox.stub(InternalTestingProfileMigrator, "key").get(() => { + return key; + }); + + sandbox.stub(MigrationUtils, "availableMigratorKeys").get(() => { + return key; + }); + + let testingMigrator = new InternalTestingProfileMigrator(); + sandbox.stub(MigrationUtils, "getMigrator").callsFake(() => { + return Promise.resolve(testingMigrator); + }); + + let migration = waitForTestMigration( + [MigrationUtils.resourceTypes.BOOKMARKS], + [MigrationUtils.resourceTypes.BOOKMARKS], + InternalTestingProfileMigrator.testProfile + ); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + selectResourceTypesAndStartMigration( + wizard, + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS], + key + ); + await migration; + + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let shadow = wizard.openOrClosedShadowRoot; + + // If we were using IE or Edge (EdgeHTLM), then the success message should + // include the word "favorites". Otherwise, we expect it to include + // the word "bookmarks". + let bookmarksProgressGroup = shadow.querySelector( + `.resource-progress-group[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS}"` + ); + let messageTextElement = + bookmarksProgressGroup.querySelector(".message-text"); + + await BrowserTestUtils.waitForCondition(() => { + return messageTextElement.textContent.trim(); + }); + + let messageText = messageTextElement.textContent.toLowerCase(); + + if (key == "internal-testing") { + Assert.ok( + messageText.includes("bookmarks"), + `Message text should refer to bookmarks: ${messageText}.` + ); + } else { + Assert.ok( + messageText.includes("favorites"), + `Message text should refer to favorites: ${messageText}` + ); + } + + let doneButton = shadow.querySelector( + "div[name='page-progress'] .done-button" + ); + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + doneButton.click(); + await dialogClosed; + await wizardDone; + }); + + sandbox.restore(); + } +}); diff --git a/browser/components/migration/tests/browser/browser_misc_telemetry.js b/browser/components/migration/tests/browser/browser_misc_telemetry.js new file mode 100644 index 0000000000..4fc6518e49 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_misc_telemetry.js @@ -0,0 +1,178 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ChromeProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/ChromeProfileMigrator.sys.mjs" +); + +/** + * Tests that if the migration wizard is opened when the + * MOZ_UNINSTALLER_PROFILE_REFRESH environment variable is defined, + * that the migration.uninstaller_profile_refresh scalar is set, + * and the environment variable is cleared. + */ +add_task(async function test_uninstaller_migration() { + if (AppConstants.platform != "win") { + return; + } + + Services.env.set("MOZ_UNINSTALLER_PROFILE_REFRESH", "1"); + let wizardPromise = BrowserTestUtils.domWindowOpened(); + // Opening the migration wizard this way is a blocking function, so + // we delegate it to a runnable. + executeSoon(() => { + MigrationUtils.showMigrationWizard(null, { isStartupMigration: true }); + }); + let wizardWin = await wizardPromise; + + await BrowserTestUtils.waitForEvent(wizardWin, "MigrationWizard:Ready"); + + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + TelemetryTestUtils.assertScalar( + scalars, + "migration.uninstaller_profile_refresh", + 1 + ); + + Assert.equal( + Services.env.get("MOZ_UNINSTALLER_PROFILE_REFRESH"), + "", + "Cleared MOZ_UNINSTALLER_PROFILE_REFRESH environment variable." + ); + await BrowserTestUtils.closeWindow(wizardWin); +}); + +/** + * Tests that we populate the migration.discovered_migrators keyed scalar + * with a count of discovered browsers and profiles. + */ +add_task(async function test_discovered_migrators_keyed_scalar() { + Services.telemetry.clearScalars(); + + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + // We'll pretend that this system only has the + // InternalTestingProfileMigrator and ChromeProfileMigrator around to + // start + sandbox.stub(MigrationUtils, "availableMigratorKeys").get(() => { + return ["internal-testing", "chrome"]; + }); + + // The InternalTestingProfileMigrator by default returns a single profile + // from `getSourceProfiles`, and now we'll just prepare the + // ChromeProfileMigrator to return two fake profiles. + sandbox.stub(ChromeProfileMigrator.prototype, "getSourceProfiles").resolves([ + { id: "chrome-test-1", name: "Chrome test profile 1" }, + { id: "chrome-test-2", name: "Chrome test profile 2" }, + ]); + + sandbox + .stub(ChromeProfileMigrator.prototype, "hasPermissions") + .resolves(true); + + // We also need to ensure that the ChromeProfileMigrator actually has + // some resources to migrate, otherwise it won't get listed. + sandbox + .stub(ChromeProfileMigrator.prototype, "getResources") + .callsFake(() => { + return Promise.resolve( + Object.values(MigrationUtils.resourceType).map(resourceType => { + return { + type: resourceType, + migrate: () => {}, + }; + }) + ); + }); + + await withMigrationWizardDialog(async () => { + let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "migration.discovered_migrators", + InternalTestingProfileMigrator.key, + 1 + ); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "migration.discovered_migrators", + ChromeProfileMigrator.key, + 2 + ); + }); + + // Now, reset, and we'll try the case where a migrator returns `null` from + // `getSourceProfiles` using the InternalTestingProfileMigrator again. + sandbox.restore(); + + sandbox = sinon.createSandbox(); + + sandbox.stub(MigrationUtils, "availableMigratorKeys").get(() => { + return ["internal-testing"]; + }); + + sandbox + .stub(ChromeProfileMigrator.prototype, "getSourceProfiles") + .resolves(null); + + await withMigrationWizardDialog(async () => { + let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "migration.discovered_migrators", + InternalTestingProfileMigrator.key, + 1 + ); + }); + + sandbox.restore(); +}); + +/** + * Tests that we write to the FX_MIGRATION_ERRORS histogram when a + * resource fails to migrate properly. + */ +add_task(async function test_fx_migration_errors() { + let migration = waitForTestMigration( + [ + MigrationUtils.resourceTypes.BOOKMARKS, + MigrationUtils.resourceTypes.PASSWORDS, + ], + [ + MigrationUtils.resourceTypes.BOOKMARKS, + MigrationUtils.resourceTypes.PASSWORDS, + ], + InternalTestingProfileMigrator.testProfile, + [MigrationUtils.resourceTypes.PASSWORDS] + ); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + + selectResourceTypesAndStartMigration(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS, + ]); + await migration; + await wizardDone; + + assertQuantitiesShown( + wizard, + [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS, + ], + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS] + ); + }); +}); diff --git a/browser/components/migration/tests/browser/browser_no_browsers_state.js b/browser/components/migration/tests/browser/browser_no_browsers_state.js new file mode 100644 index 0000000000..cd4677f31d --- /dev/null +++ b/browser/components/migration/tests/browser/browser_no_browsers_state.js @@ -0,0 +1,92 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the wizard switches to the NO_BROWSERS_FOUND page + * when no migrators are detected. + */ +add_task(async function test_browser_no_programs() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + sandbox.stub(MigrationUtils, "availableMigratorKeys").get(() => { + return []; + }); + + // Let's enable the Passwords CSV import by default so that it appears + // as a file migrator. + await SpecialPowers.pushPrefEnv({ + set: [["signon.management.page.fileImport.enabled", true]], + }); + + await withMigrationWizardDialog(async prefsWin => { + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let wizard = dialog.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let deck = shadow.querySelector("#wizard-deck"); + + await BrowserTestUtils.waitForMutationCondition( + deck, + { attributeFilter: ["selected-view"] }, + () => { + return ( + deck.getAttribute("selected-view") == + "page-" + MigrationWizardConstants.PAGES.NO_BROWSERS_FOUND + ); + } + ); + + Assert.ok( + true, + "Went to no browser page after attempting to search for migrators." + ); + let chooseImportFromFile = shadow.querySelector("#choose-import-from-file"); + Assert.ok( + !chooseImportFromFile.hidden, + "Selecting a file migrator should still be possible." + ); + }); + + // Now disable all file migrators to make sure that the "Import from file" + // button is hidden. + await SpecialPowers.pushPrefEnv({ + set: [ + ["signon.management.page.fileImport.enabled", false], + ["browser.migrate.bookmarks-file.enabled", false], + ], + }); + + await withMigrationWizardDialog(async prefsWin => { + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let wizard = dialog.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let deck = shadow.querySelector("#wizard-deck"); + + await BrowserTestUtils.waitForMutationCondition( + deck, + { attributeFilter: ["selected-view"] }, + () => { + return ( + deck.getAttribute("selected-view") == + "page-" + MigrationWizardConstants.PAGES.NO_BROWSERS_FOUND + ); + } + ); + + Assert.ok( + true, + "Went to no browser page after attempting to search for migrators." + ); + let chooseImportFromFile = shadow.querySelector("#choose-import-from-file"); + Assert.ok( + chooseImportFromFile.hidden, + "Selecting a file migrator should not be possible." + ); + }); + + sandbox.restore(); +}); diff --git a/browser/components/migration/tests/browser/browser_only_file_migrators.js b/browser/components/migration/tests/browser/browser_only_file_migrators.js new file mode 100644 index 0000000000..ca18a8c0d5 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_only_file_migrators.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the NO_BROWSERS_FOUND page has a button to redirect to the + * selection page when only file migrators are found. + */ +add_task(async function test_only_file_migrators() { + await SpecialPowers.pushPrefEnv({ + set: [["signon.management.page.fileImport.enabled", true]], + }); + + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + sandbox.stub(MigrationUtils, "availableMigratorKeys").get(() => { + return []; + }); + + await withMigrationWizardDialog(async prefsWin => { + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let wizard = dialog.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let deck = shadow.querySelector("#wizard-deck"); + + await BrowserTestUtils.waitForMutationCondition( + deck, + { attributeFilter: ["selected-view"] }, + () => { + return ( + deck.getAttribute("selected-view") == + "page-" + MigrationWizardConstants.PAGES.NO_BROWSERS_FOUND + ); + } + ); + + let chooseImportFileButton = shadow.querySelector( + "#choose-import-from-file" + ); + + let changedToSelectionPage = BrowserTestUtils.waitForMutationCondition( + deck, + { attributeFilter: ["selected-view"] }, + () => { + return ( + deck.getAttribute("selected-view") == + "page-" + MigrationWizardConstants.PAGES.SELECTION + ); + } + ); + chooseImportFileButton.click(); + await changedToSelectionPage; + + // No browser migrators should be listed. + let browserMigratorItems = shadow.querySelectorAll( + `panel-item[type="${MigrationWizardConstants.MIGRATOR_TYPES.BROWSER}"]` + ); + Assert.ok(!browserMigratorItems.length, "No browser migrators listed."); + + // Check to make sure there's at least one file migrator listed. + let fileMigratorItems = shadow.querySelectorAll( + `panel-item[type="${MigrationWizardConstants.MIGRATOR_TYPES.FILE}"]` + ); + + Assert.ok(!!fileMigratorItems.length, "Listed at least one file migrator."); + }); +}); diff --git a/browser/components/migration/tests/browser/browser_permissions.js b/browser/components/migration/tests/browser/browser_permissions.js new file mode 100644 index 0000000000..35d902bb37 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_permissions.js @@ -0,0 +1,166 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.migrate.chrome.get_permissions.enabled", true]], + }); +}); + +/** + * Tests that the migration wizard can request permission from + * the user to read from other browser data directories when + * explicit permission needs to be granted. + * + * This can occur when, for example, Firefox is installed as a + * Snap on Ubuntu Linux. In this state, Firefox does not have + * direct read access to other browser's data directories (although) + * it can tell if they exist. For Chromium-based browsers, this + * means we cannot tell what profiles nor resources are available + * for Chromium-based browsers without read permissions. + * + * Note that the Safari migrator is not tested here, as it has + * its own special permission flow. This is because we can + * determine what resources Safari has before requiring permissions, + * and (as of this writing) Safari does not support multiple + * user profiles. + */ +add_task(async function test_permissions() { + Services.telemetry.clearEvents(); + + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + sandbox + .stub(InternalTestingProfileMigrator.prototype, "canGetPermissions") + .resolves("/some/path"); + + let hasPermissionsStub = sandbox + .stub(InternalTestingProfileMigrator.prototype, "hasPermissions") + .resolves(false); + + let testingMigrator = await MigrationUtils.getMigrator( + InternalTestingProfileMigrator.key + ); + Assert.ok( + testingMigrator, + "Got migrator, even though we don't yet have permission to read its resources." + ); + + sandbox.stub(testingMigrator, "getPermissions").callsFake(async () => { + testingMigrator.flushResourceCache(); + hasPermissionsStub.resolves(true); + return Promise.resolve(true); + }); + + let getResourcesStub = sandbox + .stub(testingMigrator, "getResources") + .resolves([]); + + let migration = waitForTestMigration( + [MigrationUtils.resourceTypes.BOOKMARKS], + [MigrationUtils.resourceTypes.BOOKMARKS], + InternalTestingProfileMigrator.testProfile + ); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + + // Clear out any pre-existing events events have been logged + Services.telemetry.clearEvents(); + TelemetryTestUtils.assertNumberOfEvents(0); + + let panelItem = shadow.querySelector( + `panel-item[key="${InternalTestingProfileMigrator.key}"]` + ); + panelItem.click(); + + let resourceList = shadow.querySelector(".resource-selection-details"); + Assert.ok( + BrowserTestUtils.isHidden(resourceList), + "Resources list is hidden." + ); + let importButton = shadow.querySelector("#import"); + Assert.ok(BrowserTestUtils.isHidden(importButton), "Import button hidden."); + let noPermissionsMessage = shadow.querySelector(".no-permissions-message"); + Assert.ok( + BrowserTestUtils.isVisible(noPermissionsMessage), + "No permissions message shown." + ); + let getPermissionButton = shadow.querySelector("#get-permissions"); + Assert.ok( + BrowserTestUtils.isVisible(getPermissionButton), + "Get permissions button shown." + ); + + // Now put the permissions functions back into their default + // state - which is the "permission granted" state. + getResourcesStub.restore(); + hasPermissionsStub.restore(); + + let refreshDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:Ready" + ); + + getPermissionButton.click(); + + await refreshDone; + Assert.ok(true, "Refreshed migrator list."); + + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + selectResourceTypesAndStartMigration(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + ]); + await migration; + await wizardDone; + + assertQuantitiesShown(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + ]); + + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let doneButton = shadow.querySelector( + "div[name='page-progress'] .done-button" + ); + + await new Promise(resolve => prefsWin.requestAnimationFrame(resolve)); + Assert.equal( + shadow.activeElement, + doneButton, + "Done button should be focused." + ); + + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + doneButton.click(); + await dialogClosed; + }); + + TelemetryTestUtils.assertEvents( + [ + { + category: "browser.migration", + method: "linux_perms", + object: "wizard", + value: null, + extra: { + migrator_key: InternalTestingProfileMigrator.key, + }, + }, + ], + { + category: "browser.migration", + method: "linux_perms", + object: "wizard", + } + ); +}); diff --git a/browser/components/migration/tests/browser/browser_safari_passwords.js b/browser/components/migration/tests/browser/browser_safari_passwords.js new file mode 100644 index 0000000000..c005342b46 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_safari_passwords.js @@ -0,0 +1,468 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SafariProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/SafariProfileMigrator.sys.mjs" +); +const { LoginCSVImport } = ChromeUtils.importESModule( + "resource://gre/modules/LoginCSVImport.sys.mjs" +); + +const TEST_FILE_PATH = getTestFilePath("dummy_file.csv"); + +// We use MockFilePicker to simulate a native file picker, and prepare it +// to return a dummy file pointed at TEST_FILE_PATH. The file at +// TEST_FILE_PATH is not required (nor expected) to exist. +const { MockFilePicker } = SpecialPowers; + +add_setup(async function () { + MockFilePicker.init(window); + registerCleanupFunction(() => { + MockFilePicker.cleanup(); + }); + await SpecialPowers.pushPrefEnv({ + set: [["signon.management.page.fileImport.enabled", true]], + }); +}); + +/** + * A helper function that does most of the heavy lifting for the tests in + * this file. Specfically, it takes care of: + * + * 1. Stubbing out the various hunks of the SafariProfileMigrator in order + * to simulate a migration without actually performing one, since the + * migrator itself isn't being tested here. + * 2. Stubbing out parts of MigrationUtils and LoginCSVImport to have a + * consistent reporting on how many things are imported. + * 3. Setting up the MockFilePicker if expectsFilePicker is true to return + * the TEST_FILE_PATH. + * 4. Opens up the migration wizard, and chooses to import both BOOKMARKS + * and PASSWORDS, and then clicks "Import". + * 5. Waits for the migration wizard to show the Safari password import + * instructions. + * 6. Runs taskFn + * 7. Closes the migration dialog. + * + * @param {boolean} expectsFilePicker + * True if the MockFilePicker should be set up to return TEST_FILE_PATH. + * @param {boolean} migrateBookmarks + * True if bookmarks should be migrated alongside passwords. If not, only + * passwords will be migrated. + * @param {boolean} shouldPasswordImportFail + * True if importing from the CSV file should fail. + * @param {Function} taskFn + * An asynchronous function that takes the following parameters in this + * order: + * + * {Element} wizard + * The opened migration wizard + * {Promise} filePickerShownPromise + * A Promise that resolves once the MockFilePicker has closed. This is + * undefined if expectsFilePicker was false. + * {object} importFromCSVStub + * The Sinon stub object for LoginCSVImport.importFromCSV. This can be + * used to check to see whether it was called. + * {Promise} didMigration + * A Promise that resolves to true once the migration completes. + * {Promise} wizardDone + * A Promise that resolves once the migration wizard reports that a + * migration has completed. + * @returns {Promise} + */ +async function testSafariPasswordHelper( + expectsFilePicker, + migrateBookmarks, + shouldPasswordImportFail, + taskFn +) { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let safariMigrator = new SafariProfileMigrator(); + sandbox.stub(MigrationUtils, "getMigrator").resolves(safariMigrator); + + // We're not testing the permission flow here, so let's pretend that we + // always have permission to read resources from the disk. + sandbox + .stub(SafariProfileMigrator.prototype, "hasPermissions") + .resolves(true); + + // Have the migrator claim that only BOOKMARKS are only available. + sandbox + .stub(SafariProfileMigrator.prototype, "getMigrateData") + .resolves(MigrationUtils.resourceTypes.BOOKMARKS); + + let migrateStub; + let didMigration = new Promise(resolve => { + migrateStub = sandbox + .stub(SafariProfileMigrator.prototype, "migrate") + .callsFake((aResourceTypes, aStartup, aProfile, aProgressCallback) => { + if (!migrateBookmarks) { + Assert.ok( + false, + "Should not have called migrate when only migrating Safari passwords." + ); + } + + Assert.ok( + !aStartup, + "Migrator should not have been called as a startup migration." + ); + Assert.ok( + aResourceTypes & MigrationUtils.resourceTypes.BOOKMARKS, + "Should have requested to migrate the BOOKMARKS resource." + ); + Assert.ok( + !(aResourceTypes & MigrationUtils.resourceTypes.PASSWORDS), + "Should not have requested to migrate the PASSWORDS resource." + ); + + aProgressCallback(MigrationUtils.resourceTypes.BOOKMARKS, true); + Services.obs.notifyObservers(null, "Migration:Ended"); + resolve(); + }); + }); + + // We'll pretend we added EXPECTED_QUANTITY passwords from the Safari + // password file. + let results = []; + for (let i = 0; i < EXPECTED_QUANTITY; ++i) { + results.push({ result: "added" }); + } + let importFromCSVStub = sandbox.stub(LoginCSVImport, "importFromCSV"); + + if (shouldPasswordImportFail) { + importFromCSVStub.rejects(new Error("Some error message")); + } else { + importFromCSVStub.resolves(results); + } + + sandbox.stub(MigrationUtils, "_importQuantities").value({ + bookmarks: EXPECTED_QUANTITY, + }); + + let filePickerShownPromise; + + if (expectsFilePicker) { + MockFilePicker.reset(); + + let dummyFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + dummyFile.initWithPath(TEST_FILE_PATH); + filePickerShownPromise = new Promise(resolve => { + MockFilePicker.showCallback = () => { + Assert.ok(true, "Filepicker shown."); + MockFilePicker.setFiles([dummyFile]); + resolve(); + }; + }); + MockFilePicker.returnValue = MockFilePicker.returnOK; + } + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + + let shadow = wizard.openOrClosedShadowRoot; + + info("Choosing Safari"); + let panelItem = shadow.querySelector( + `panel-item[key="${SafariProfileMigrator.key}"]` + ); + panelItem.click(); + + let resourceTypeList = shadow.querySelector("#resource-type-list"); + + // Let's choose whether to import BOOKMARKS first. + let bookmarksNode = resourceTypeList.querySelector( + `label[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS}"]` + ); + bookmarksNode.control.checked = migrateBookmarks; + + // Let's make sure that PASSWORDS is displayed despite the migrator only + // (currently) returning BOOKMARKS as an available resource to migrate. + let passwordsNode = resourceTypeList.querySelector( + `label[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS}"]` + ); + Assert.ok( + !passwordsNode.hidden, + "PASSWORDS should be available to import from." + ); + passwordsNode.control.checked = true; + + let deck = shadow.querySelector("#wizard-deck"); + let switchedToSafariPermissionPage = + BrowserTestUtils.waitForMutationCondition( + deck, + { attributeFilter: ["selected-view"] }, + () => { + return ( + deck.getAttribute("selected-view") == + "page-" + MigrationWizardConstants.PAGES.SAFARI_PASSWORD_PERMISSION + ); + } + ); + + let importButton = shadow.querySelector("#import"); + importButton.click(); + await switchedToSafariPermissionPage; + Assert.ok(true, "Went to Safari permission page after attempting import."); + + await taskFn( + wizard, + filePickerShownPromise, + importFromCSVStub, + didMigration, + migrateStub, + wizardDone + ); + + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let doneButton = shadow.querySelector( + "div[name='page-progress'] .done-button" + ); + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + + doneButton.click(); + await dialogClosed; + }); + + sandbox.restore(); + MockFilePicker.reset(); +} + +/** + * Tests the flow of importing passwords from Safari via an + * exported CSV file. + */ +add_task(async function test_safari_password_do_import() { + await testSafariPasswordHelper( + true, + true, + false, + async ( + wizard, + filePickerShownPromise, + importFromCSVStub, + didMigration, + migrateStub, + wizardDone + ) => { + let shadow = wizard.openOrClosedShadowRoot; + let safariPasswordImportSelect = shadow.querySelector( + "#safari-password-import-select" + ); + safariPasswordImportSelect.click(); + await filePickerShownPromise; + Assert.ok(true, "File picker was shown."); + + await didMigration; + Assert.ok(importFromCSVStub.called, "Importing from CSV was called."); + + await wizardDone; + + assertQuantitiesShown(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS, + ]); + } + ); +}); + +/** + * Tests that only passwords get imported if the user only opts + * to import passwords, and that nothing else gets imported. + */ +add_task(async function test_safari_password_only_do_import() { + await testSafariPasswordHelper( + true, + false, + false, + async ( + wizard, + filePickerShownPromise, + importFromCSVStub, + didMigration, + migrateStub, + wizardDone + ) => { + let shadow = wizard.openOrClosedShadowRoot; + let safariPasswordImportSelect = shadow.querySelector( + "#safari-password-import-select" + ); + safariPasswordImportSelect.click(); + await filePickerShownPromise; + Assert.ok(true, "File picker was shown."); + + await wizardDone; + + assertQuantitiesShown(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS, + ]); + + Assert.ok(importFromCSVStub.called, "Importing from CSV was called."); + Assert.ok( + !migrateStub.called, + "SafariProfileMigrator.migrate was not called." + ); + } + ); +}); + +/** + * Tests the flow of importing passwords from Safari when the file + * import fails. + */ +add_task(async function test_safari_password_empty_csv_file() { + await testSafariPasswordHelper( + true, + true, + true, + async ( + wizard, + filePickerShownPromise, + importFromCSVStub, + didMigration, + migrateStub, + wizardDone + ) => { + let shadow = wizard.openOrClosedShadowRoot; + let safariPasswordImportSelect = shadow.querySelector( + "#safari-password-import-select" + ); + safariPasswordImportSelect.click(); + await filePickerShownPromise; + Assert.ok(true, "File picker was shown."); + + await didMigration; + Assert.ok(importFromCSVStub.called, "Importing from CSV was called."); + + await wizardDone; + + let headerL10nID = + shadow.querySelector("#progress-header").dataset.l10nId; + Assert.equal( + headerL10nID, + "migration-wizard-progress-done-with-warnings-header" + ); + + let progressGroup = shadow.querySelector( + `.resource-progress-group[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS}"` + ); + let progressIcon = progressGroup.querySelector(".progress-icon"); + let messageText = + progressGroup.querySelector(".message-text").textContent; + + Assert.equal( + progressIcon.getAttribute("state"), + "warning", + "Icon should be in the warning state." + ); + Assert.stringMatches( + messageText, + /file doesn’t include any valid password data/ + ); + } + ); +}); + +/** + * Tests that the user can skip importing passwords from Safari. + */ +add_task(async function test_safari_password_skip() { + await testSafariPasswordHelper( + false, + true, + false, + async ( + wizard, + filePickerShownPromise, + importFromCSVStub, + didMigration, + migrateStub, + wizardDone + ) => { + let shadow = wizard.openOrClosedShadowRoot; + let safariPasswordImportSkip = shadow.querySelector( + "#safari-password-import-skip" + ); + safariPasswordImportSkip.click(); + + await didMigration; + Assert.ok(!MockFilePicker.shown, "Never showed the file picker."); + Assert.ok( + !importFromCSVStub.called, + "Importing from CSV was never called." + ); + + await wizardDone; + + assertQuantitiesShown(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + ]); + } + ); +}); + +/** + * Tests that importing from passwords for Safari doesn't exist if + * signon.management.page.fileImport.enabled is false. + */ +add_task(async function test_safari_password_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [["signon.management.page.fileImport.enabled", false]], + }); + + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let safariMigrator = new SafariProfileMigrator(); + sandbox.stub(MigrationUtils, "getMigrator").resolves(safariMigrator); + + // We're not testing the permission flow here, so let's pretend that we + // always have permission to read resources from the disk. + sandbox + .stub(SafariProfileMigrator.prototype, "hasPermissions") + .resolves(true); + + // Have the migrator claim that only BOOKMARKS are only available. + sandbox + .stub(SafariProfileMigrator.prototype, "getMigrateData") + .resolves(MigrationUtils.resourceTypes.BOOKMARKS); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + + let shadow = wizard.openOrClosedShadowRoot; + + info("Choosing Safari"); + let panelItem = shadow.querySelector( + `panel-item[key="${SafariProfileMigrator.key}"]` + ); + panelItem.click(); + + let resourceTypeList = shadow.querySelector("#resource-type-list"); + + // Let's make sure that PASSWORDS is displayed despite the migrator only + // (currently) returning BOOKMARKS as an available resource to migrate. + let passwordsNode = resourceTypeList.querySelector( + `label[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS}"]` + ); + Assert.ok( + passwordsNode.hidden, + "PASSWORDS should not be available to import from." + ); + }); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/migration/tests/browser/browser_safari_permissions.js b/browser/components/migration/tests/browser/browser_safari_permissions.js new file mode 100644 index 0000000000..bac56866f0 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_safari_permissions.js @@ -0,0 +1,136 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SafariProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/SafariProfileMigrator.sys.mjs" +); + +/** + * Tests that if we don't have permission to read the contents + * of ~/Library/Safari, that we can ask for permission to do that. + * + * This involves presenting the user with some instructions, and then + * showing a native folder picker for the user to select the + * ~/Library/Safari folder. This seems to give us read access to the + * folder contents. + * + * Revoking permissions for reading the ~/Library/Safari folder is + * not something that we know how to do just yet. It seems to be + * something involving macOS's System Integrity Protection. This test + * mocks out and simulates the actual permissions mechanism to make + * this test run reliably and repeatably. + */ +add_task(async function test_safari_permissions() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let hasPermissionsStub = sandbox + .stub(SafariProfileMigrator.prototype, "hasPermissions") + .resolves(false); + + let safariMigrator = await MigrationUtils.getMigrator( + SafariProfileMigrator.key + ); + Assert.ok( + safariMigrator, + "Got migrator, even though we don't yet have permission to read its resources." + ); + + sandbox.stub(MigrationUtils, "getMigrator").resolves(safariMigrator); + + sandbox.stub(safariMigrator, "getPermissions").callsFake(async () => { + hasPermissionsStub.resolves(true); + return Promise.resolve(true); + }); + + sandbox.stub(safariMigrator, "getResources").callsFake(() => { + return Promise.resolve([ + { + type: MigrationUtils.resourceTypes.BOOKMARKS, + migrate: () => {}, + }, + ]); + }); + + let didMigration = new Promise(resolve => { + sandbox + .stub(safariMigrator, "migrate") + .callsFake((aResourceTypes, aStartup, aProfile, aProgressCallback) => { + Assert.ok( + !aStartup, + "Migrator should not have been called as a startup migration." + ); + + aProgressCallback(MigrationUtils.resourceTypes.BOOKMARKS); + Services.obs.notifyObservers(null, "Migration:Ended"); + resolve(); + }); + }); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + + let shadow = wizard.openOrClosedShadowRoot; + + info("Choosing Safari"); + let panelItem = shadow.querySelector( + `panel-item[key="${SafariProfileMigrator.key}"]` + ); + panelItem.click(); + + // Let's just choose "Bookmarks" for now. + let resourceTypeList = shadow.querySelector("#resource-type-list"); + let resourceNodes = resourceTypeList.querySelectorAll( + `label[data-resource-type]` + ); + for (let resourceNode of resourceNodes) { + resourceNode.control.checked = + resourceNode.dataset.resourceType == + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS; + } + + let deck = shadow.querySelector("#wizard-deck"); + let switchedToSafariPermissionPage = + BrowserTestUtils.waitForMutationCondition( + deck, + { attributeFilter: ["selected-view"] }, + () => { + return ( + deck.getAttribute("selected-view") == + "page-" + MigrationWizardConstants.PAGES.SAFARI_PERMISSION + ); + } + ); + + let importButton = shadow.querySelector("#import"); + importButton.click(); + await switchedToSafariPermissionPage; + Assert.ok(true, "Went to Safari permission page after attempting import."); + + let requestPermissions = shadow.querySelector( + "#safari-request-permissions" + ); + requestPermissions.click(); + await didMigration; + Assert.ok(true, "Completed migration"); + + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let doneButton = shadow.querySelector( + "div[name='page-progress'] .done-button" + ); + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + + doneButton.click(); + await dialogClosed; + await wizardDone; + }); +}); diff --git a/browser/components/migration/tests/browser/dummy_file.csv b/browser/components/migration/tests/browser/dummy_file.csv new file mode 100644 index 0000000000..48a099ab76 --- /dev/null +++ b/browser/components/migration/tests/browser/dummy_file.csv @@ -0,0 +1 @@ +This file intentionally left blank. \ No newline at end of file diff --git a/browser/components/migration/tests/browser/head.js b/browser/components/migration/tests/browser/head.js new file mode 100644 index 0000000000..d3d188a7e1 --- /dev/null +++ b/browser/components/migration/tests/browser/head.js @@ -0,0 +1,534 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from ../head-common.js */ + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/migration/tests/browser/head-common.js", + this +); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { InternalTestingProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/InternalTestingProfileMigrator.sys.mjs" +); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const DIALOG_URL = + "chrome://browser/content/migration/migration-dialog-window.html"; + +/** + * We'll have this be our magic number of quantities of various imports. + * We will use Sinon to prepare MigrationUtils to presume that this was + * how many of each quantity-supported resource type was imported. + */ +const EXPECTED_QUANTITY = 123; + +/** + * These are the resource types that currently display their import success + * message with a quantity. + */ +const RESOURCE_TYPES_WITH_QUANTITIES = [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PAYMENT_METHODS, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS, +]; + +/** + * The withMigrationWizardDialog callback, called after the + * dialog has loaded and the wizard is ready. + * + * @callback withMigrationWizardDialogCallback + * @param {DOMWindow} window + * The content window of the migration wizard subdialog frame. + * @returns {Promise} + */ + +/** + * Opens the migration wizard HTML5 dialog in about:preferences in the + * current window's selected tab, runs an async taskFn, and then + * cleans up by loading about:blank in the tab before resolving. + * + * @param {withMigrationWizardDialogCallback} taskFn + * An async test function to be called while the migration wizard + * dialog is open. + * @returns {Promise} + */ +async function withMigrationWizardDialog(taskFn) { + let migrationDialogPromise = waitForMigrationWizardDialogTab(); + await MigrationUtils.showMigrationWizard(window, {}); + let prefsBrowser = await migrationDialogPromise; + + try { + await taskFn(prefsBrowser.contentWindow); + } finally { + if (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.getTabForBrowser(prefsBrowser)); + } else { + BrowserTestUtils.startLoadingURIString(prefsBrowser, "about:blank"); + await BrowserTestUtils.browserLoaded(prefsBrowser); + } + } +} + +/** + * Returns a Promise that resolves when an about:preferences tab opens + * in the current window which loads the migration wizard dialog. + * The Promise will wait until the migration wizard reports that it + * is ready with the "MigrationWizard:Ready" event. + * + * @returns {Promise} + * Resolves with the about:preferences browser element. + */ +async function waitForMigrationWizardDialogTab() { + let wizardReady = BrowserTestUtils.waitForEvent( + window, + "MigrationWizard:Ready" + ); + + let tab; + if (gBrowser.selectedTab.isEmpty) { + tab = gBrowser.selectedTab; + await BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, url => { + return url.startsWith("about:preferences"); + }); + } else { + tab = await BrowserTestUtils.waitForNewTab(gBrowser, url => { + return url.startsWith("about:preferences"); + }); + } + + await wizardReady; + info("Done waiting - migration subdialog loaded and ready."); + + return tab.linkedBrowser; +} + +/** + * A helper function that prepares the InternalTestingProfileMigrator + * with some set of fake available resources, and resolves a Promise + * when the InternalTestingProfileMigrator is used for a migration. + * + * @param {number[]} availableResourceTypes + * An array of resource types from MigrationUtils.resourcesTypes. + * A single MigrationResource will be created per type, with a + * no-op migrate function. + * @param {number[]} expectedResourceTypes + * An array of resource types from MigrationUtils.resourceTypes. + * These are the resource types that are expected to be passed + * to the InternalTestingProfileMigrator.migrate function. + * @param {object|string} expectedProfile + * The profile object or string that is expected to be passed + * to the InternalTestingProfileMigrator.migrate function. + * @param {number[]} [errorResourceTypes=[]] + * Resource types that we should pretend have failed to complete + * their migration properly. + * @param {number} [totalExtensions=1] + * If migrating extensions, the total that should be reported to + * have been found from the source browser. + * @param {number} [matchedExtensions=1] + * If migrating extensions, the number of extensions that should + * be reported as having equivalent matches for this browser. + * @returns {Promise} + */ +async function waitForTestMigration( + availableResourceTypes, + expectedResourceTypes, + expectedProfile, + errorResourceTypes = [], + totalExtensions = 1, + matchedExtensions = 1 +) { + let sandbox = sinon.createSandbox(); + let sourceHistogram = TelemetryTestUtils.getAndClearHistogram( + "FX_MIGRATION_SOURCE_BROWSER" + ); + let usageHistogram = + TelemetryTestUtils.getAndClearKeyedHistogram("FX_MIGRATION_USAGE"); + let errorHistogram = TelemetryTestUtils.getAndClearKeyedHistogram( + "FX_MIGRATION_ERRORS" + ); + + // Fake out the getResources method of the migrator so that we return + // a single fake MigratorResource per availableResourceType. + sandbox + .stub(InternalTestingProfileMigrator.prototype, "getResources") + .callsFake(aProfile => { + Assert.deepEqual( + aProfile, + expectedProfile, + "Should have gotten the expected profile." + ); + return Promise.resolve( + availableResourceTypes.map(resourceType => { + return { + type: resourceType, + migrate: () => {}, + }; + }) + ); + }); + + sandbox.stub(MigrationUtils, "_importQuantities").value({ + bookmarks: EXPECTED_QUANTITY, + history: EXPECTED_QUANTITY, + logins: EXPECTED_QUANTITY, + cards: EXPECTED_QUANTITY, + }); + + sandbox + .stub(MigrationUtils, "getSourceIdForTelemetry") + .withArgs(InternalTestingProfileMigrator.key) + .returns(InternalTestingProfileMigrator.sourceID); + + // Fake out the migrate method of the migrator and assert that the + // next time it's called, its arguments match our expectations. + return new Promise(resolve => { + sandbox + .stub(InternalTestingProfileMigrator.prototype, "migrate") + .callsFake((aResourceTypes, aStartup, aProfile, aProgressCallback) => { + Assert.ok( + !aStartup, + "Migrator should not have been called as a startup migration." + ); + + let bitMask = 0; + for (let resourceType of expectedResourceTypes) { + bitMask |= resourceType; + } + + Assert.deepEqual( + aResourceTypes, + bitMask, + "Got the expected resource types" + ); + Assert.deepEqual( + aProfile, + expectedProfile, + "Got the expected profile object" + ); + + for (let resourceType of expectedResourceTypes) { + let shouldError = errorResourceTypes.includes(resourceType); + if ( + resourceType == MigrationUtils.resourceTypes.EXTENSIONS && + !shouldError + ) { + let progressValue; + if (totalExtensions == matchedExtensions) { + progressValue = MigrationWizardConstants.PROGRESS_VALUE.SUCCESS; + } else if ( + totalExtensions > matchedExtensions && + matchedExtensions + ) { + progressValue = MigrationWizardConstants.PROGRESS_VALUE.INFO; + } else { + Assert.ok( + false, + "Total and matched extensions should be greater than 0 on success." + + `Total: ${totalExtensions}, Matched: ${matchedExtensions}` + ); + } + aProgressCallback(resourceType, !shouldError, { + totalExtensions: Array(totalExtensions), + importedExtensions: Array(matchedExtensions), + progressValue, + }); + } else { + aProgressCallback(resourceType, !shouldError); + } + } + + let usageHistogramSnapshot = + usageHistogram.snapshot()[InternalTestingProfileMigrator.key]; + + let errorHistogramSnapshot = + errorHistogram.snapshot()[InternalTestingProfileMigrator.key]; + + for (let resourceTypeName in MigrationUtils.resourceTypes) { + let resourceType = MigrationUtils.resourceTypes[resourceTypeName]; + if (resourceType == MigrationUtils.resourceTypes.ALL) { + continue; + } + + if (expectedResourceTypes.includes(resourceType)) { + Assert.equal( + usageHistogramSnapshot.values[Math.log2(resourceType)], + 1, + `Should have set resource type ${resourceTypeName} on the FX_MIGRATION_USAGE keyed histogram.` + ); + + if (errorResourceTypes.includes(resourceType)) { + Assert.equal( + errorHistogramSnapshot.values[Math.log2(resourceType)], + 1, + `Should have set resource type ${resourceTypeName} on the FX_MIGRATION_ERRORS keyed histogram.` + ); + } + } else { + let value = usageHistogramSnapshot.values[Math.log2(resourceType)]; + Assert.ok( + value === 0 || value === undefined, + `Should not have set resource type ${resourceTypeName} on the FX_MIGRATION_USAGE keyed histogram.` + ); + } + } + + Services.obs.notifyObservers(null, "Migration:Ended"); + + TelemetryTestUtils.assertHistogram( + sourceHistogram, + InternalTestingProfileMigrator.sourceID, + 1 + ); + + resolve(); + }); + }).finally(async () => { + sandbox.restore(); + + // MigratorBase caches resources fetched by the getResources method + // as a performance optimization. In order to allow different tests + // to have different available resources, we call into a special + // method of InternalTestingProfileMigrator that clears that + // cache. + let migrator = await MigrationUtils.getMigrator( + InternalTestingProfileMigrator.key + ); + migrator.flushResourceCache(); + }); +} + +/** + * Takes a MigrationWizard element and chooses the + * InternalTestingProfileMigrator as the browser to migrate from. Then, it + * checks the checkboxes associated with the selectedResourceTypes and + * unchecks the rest before clicking the "Import" button. + * + * @param {Element} wizard + * The MigrationWizard element. + * @param {string[]} selectedResourceTypes + * An array of resource type strings from + * MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. + * @param {string} [migratorKey=InternalTestingProfileMigrator.key] + * The key for the migrator to use. Defaults to the + * InternalTestingProfileMigrator. + */ +async function selectResourceTypesAndStartMigration( + wizard, + selectedResourceTypes, + migratorKey = InternalTestingProfileMigrator.key +) { + let shadow = wizard.openOrClosedShadowRoot; + + // First, select the InternalTestingProfileMigrator browser. + let selector = shadow.querySelector("#browser-profile-selector"); + selector.click(); + + await new Promise(resolve => { + shadow + .querySelector("panel-list") + .addEventListener("shown", resolve, { once: true }); + }); + + let panelItem = shadow.querySelector(`panel-item[key="${migratorKey}"]`); + panelItem.click(); + + // And then check the right checkboxes for the resource types. + let resourceTypeList = shadow.querySelector("#resource-type-list"); + for (let resourceType of getChoosableResourceTypes()) { + let node = resourceTypeList.querySelector( + `label[data-resource-type="${resourceType}"]` + ); + node.control.checked = selectedResourceTypes.includes(resourceType); + } + + let importButton = shadow.querySelector("#import"); + importButton.click(); +} + +/** + * Assert that the resource types passed in expectedResourceTypes are + * showing a success state after a migration, and if they are part of + * the RESOURCE_TYPES_WITH_QUANTITIES group, that they're showing the + * EXPECTED_QUANTITY magic number in their success message. Otherwise, + * we (currently) check that they show the empty string. + * + * @param {Element} wizard + * The MigrationWizard element. + * @param {string[]} expectedResourceTypes + * An array of resource type strings from + * MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. + * @param {string[]} [warningResourceTypes=[]] + * An array of resource type strings from + * MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. These + * are the resources that should be showing a warning message. + */ +function assertQuantitiesShown( + wizard, + expectedResourceTypes, + warningResourceTypes = [] +) { + let shadow = wizard.openOrClosedShadowRoot; + + // Make sure that we're showing the progress page first. + let deck = shadow.querySelector("#wizard-deck"); + Assert.equal( + deck.selectedViewName, + `page-${MigrationWizardConstants.PAGES.PROGRESS}` + ); + + let headerL10nID = shadow.querySelector("#progress-header").dataset.l10nId; + if (warningResourceTypes.length) { + Assert.equal( + headerL10nID, + "migration-wizard-progress-done-with-warnings-header" + ); + } else { + Assert.equal(headerL10nID, "migration-wizard-progress-done-header"); + } + + // Go through each displayed resource and make sure that only the + // ones that are expected are shown, and are showing the right + // success message. + + let progressGroups = shadow.querySelectorAll(".resource-progress-group"); + for (let progressGroup of progressGroups) { + if (expectedResourceTypes.includes(progressGroup.dataset.resourceType)) { + let progressIcon = progressGroup.querySelector(".progress-icon"); + let messageText = + progressGroup.querySelector(".message-text").textContent; + + if (warningResourceTypes.includes(progressGroup.dataset.resourceType)) { + Assert.equal( + progressIcon.getAttribute("state"), + "warning", + "Should be showing the warning icon state." + ); + } else { + Assert.equal( + progressIcon.getAttribute("state"), + "success", + "Should be showing the success icon state." + ); + } + + if ( + RESOURCE_TYPES_WITH_QUANTITIES.includes( + progressGroup.dataset.resourceType + ) + ) { + if ( + progressGroup.dataset.resourceType == + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY + ) { + // HISTORY is a special case that doesn't show the number of imported + // history entries, but instead shows the maximum number of days of history + // that might have been imported. + Assert.notEqual( + messageText.indexOf(MigrationUtils.HISTORY_MAX_AGE_IN_DAYS), + -1, + `Found expected maximum number of days of history: ${messageText}` + ); + } else if ( + progressGroup.dataset.resourceType == + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA + ) { + // FORMDATA is another special case, because we simply show "Form history" as + // the message string, rather than a particular quantity. + Assert.equal( + messageText, + "Form history", + `Found expected form data string: ${messageText}` + ); + } else if ( + progressGroup.dataset.resourceType == + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS + ) { + // waitForTestMigration by default sets up a "successful" migration of 1 + // extension. + Assert.stringMatches(messageText, "1 extension"); + } else { + Assert.notEqual( + messageText.indexOf(EXPECTED_QUANTITY), + -1, + `Found expected quantity in message string: ${messageText}` + ); + } + } else { + // If you've found yourself here, and this is failing, it's probably because you've + // updated MigrationWizardParent.#getStringForImportQuantity to return a string for + // a resource type that's not in RESOURCE_TYPES_WITH_QUANTITIES, and you'll need + // to modify this function to check for that string. + Assert.equal( + messageText, + "", + "Expected the empty string if the resource type " + + "isn't in RESOURCE_TYPES_WITH_QUANTITIES" + ); + } + } else { + Assert.ok( + BrowserTestUtils.isHidden(progressGroup), + `Resource progress group for ${progressGroup.dataset.resourceType}` + + ` should be hidden.` + ); + } + } +} + +/** + * Translates an entrypoint string into the proper numeric value for the + * FX_MIGRATION_ENTRY_POINT_CATEGORICAL histogram. + * + * @param {string} entrypoint + * The entrypoint to translate from MIGRATION_ENTRYPOINTS. + * @returns {number} + * The numeric index value for the FX_MIGRATION_ENTRY_POINT_CATEGORICAL + * histogram. + */ +function getEntrypointHistogramIndex(entrypoint) { + switch (entrypoint) { + case MigrationUtils.MIGRATION_ENTRYPOINTS.FIRSTRUN: { + return 1; + } + case MigrationUtils.MIGRATION_ENTRYPOINTS.FXREFRESH: { + return 2; + } + case MigrationUtils.MIGRATION_ENTRYPOINTS.PLACES: { + return 3; + } + case MigrationUtils.MIGRATION_ENTRYPOINTS.PASSWORDS: { + return 4; + } + case MigrationUtils.MIGRATION_ENTRYPOINTS.NEWTAB: { + return 5; + } + case MigrationUtils.MIGRATION_ENTRYPOINTS.FILE_MENU: { + return 6; + } + case MigrationUtils.MIGRATION_ENTRYPOINTS.HELP_MENU: { + return 7; + } + case MigrationUtils.MIGRATION_ENTRYPOINTS.BOOKMARKS_TOOLBAR: { + return 8; + } + case MigrationUtils.MIGRATION_ENTRYPOINTS.PREFERENCES: { + return 9; + } + case MigrationUtils.MIGRATION_ENTRYPOINTS.UNKNOWN: + // Intentional fall-through + default: { + return 0; // Unknown + } + } +} diff --git a/browser/components/migration/tests/chrome/chrome.toml b/browser/components/migration/tests/chrome/chrome.toml new file mode 100644 index 0000000000..8f1c943f31 --- /dev/null +++ b/browser/components/migration/tests/chrome/chrome.toml @@ -0,0 +1,5 @@ +[DEFAULT] +skip-if = ["os == 'android'"] +support-files = ["../head-common.js"] + +["test_migration_wizard.html"] diff --git a/browser/components/migration/tests/chrome/test_migration_wizard.html b/browser/components/migration/tests/chrome/test_migration_wizard.html new file mode 100644 index 0000000000..d991cce114 --- /dev/null +++ b/browser/components/migration/tests/chrome/test_migration_wizard.html @@ -0,0 +1,1533 @@ + + + + + Basic tests for the Migration Wizard component + + + + + + + + +

    +
    + +
    +
    
    +  
    +
    diff --git a/browser/components/migration/tests/head-common.js b/browser/components/migration/tests/head-common.js
    new file mode 100644
    index 0000000000..025d3e5a16
    --- /dev/null
    +++ b/browser/components/migration/tests/head-common.js
    @@ -0,0 +1,24 @@
    +/* Any copyright is dedicated to the Public Domain.
    +   http://creativecommons.org/publicdomain/zero/1.0/ */
    +
    +"use strict";
    +
    +const { MigrationWizardConstants } = ChromeUtils.importESModule(
    +  "chrome://browser/content/migration/migration-wizard-constants.mjs"
    +);
    +
    +/**
    + * Returns the constant strings from MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES
    + * that aren't also part of MigrationWizardConstants.PROFILE_RESET_ONLY_RESOURCE_TYPES.
    + *
    + * This is the set of resources that the user can actually choose to migrate via
    + * checkboxes.
    + *
    + * @returns {string[]}
    + */
    +function getChoosableResourceTypes() {
    +  return Object.keys(MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES).filter(
    +    resourceType =>
    +      !MigrationWizardConstants.PROFILE_RESET_ONLY_RESOURCE_TYPES[resourceType]
    +  );
    +}
    diff --git a/browser/components/migration/tests/marionette/manifest.toml b/browser/components/migration/tests/marionette/manifest.toml
    new file mode 100644
    index 0000000000..1e3b536ee2
    --- /dev/null
    +++ b/browser/components/migration/tests/marionette/manifest.toml
    @@ -0,0 +1,4 @@
    +[DEFAULT]
    +run-if = ["buildapp == 'browser'"]
    +
    +["test_refresh_firefox.py"]
    diff --git a/browser/components/migration/tests/marionette/test_refresh_firefox.py b/browser/components/migration/tests/marionette/test_refresh_firefox.py
    new file mode 100644
    index 0000000000..ea5d6bce99
    --- /dev/null
    +++ b/browser/components/migration/tests/marionette/test_refresh_firefox.py
    @@ -0,0 +1,703 @@
    +import os
    +import time
    +
    +from marionette_driver.errors import NoAlertPresentException
    +from marionette_harness import MarionetteTestCase
    +
    +
    +# Holds info about things we need to cleanup after the tests are done.
    +class PendingCleanup:
    +    desktop_backup_path = None
    +    reset_profile_path = None
    +    reset_profile_local_path = None
    +
    +    def __init__(self, profile_name_to_remove):
    +        self.profile_name_to_remove = profile_name_to_remove
    +
    +
    +class TestFirefoxRefresh(MarionetteTestCase):
    +    _sandbox = "firefox-refresh"
    +
    +    _username = "marionette-test-login"
    +    _password = "marionette-test-password"
    +    _bookmarkURL = "about:mozilla"
    +    _bookmarkText = "Some bookmark from Marionette"
    +
    +    _cookieHost = "firefox-refresh.marionette-test.mozilla.org"
    +    _cookiePath = "some/cookie/path"
    +    _cookieName = "somecookie"
    +    _cookieValue = "some cookie value"
    +
    +    _historyURL = "http://firefox-refresh.marionette-test.mozilla.org/"
    +    _historyTitle = "Test visit for Firefox Reset"
    +
    +    _formHistoryFieldName = "some-very-unique-marionette-only-firefox-reset-field"
    +    _formHistoryValue = "special-pumpkin-value"
    +
    +    _formAutofillAvailable = False
    +    _formAutofillAddressGuid = None
    +
    +    _expectedURLs = ["about:robots", "about:mozilla"]
    +
    +    def savePassword(self):
    +        self.runAsyncCode(
    +            """
    +          let [username, password, resolve] = arguments;
    +          let myLogin = new global.LoginInfo(
    +            "test.marionette.mozilla.com",
    +            "http://test.marionette.mozilla.com/some/form/",
    +            null,
    +            username,
    +            password,
    +            "username",
    +            "password"
    +          );
    +          Services.logins.addLoginAsync(myLogin)
    +            .then(() => resolve(false), resolve);
    +        """,
    +            script_args=(self._username, self._password),
    +        )
    +
    +    def createBookmarkInMenu(self):
    +        error = self.runAsyncCode(
    +            """
    +          // let url = arguments[0];
    +          // let title = arguments[1];
    +          // let resolve = arguments[arguments.length - 1];
    +          let [url, title, resolve] = arguments;
    +          PlacesUtils.bookmarks.insert({
    +            parentGuid: PlacesUtils.bookmarks.menuGuid, url, title
    +          }).then(() => resolve(false), resolve);
    +        """,
    +            script_args=(self._bookmarkURL, self._bookmarkText),
    +        )
    +        if error:
    +            print(error)
    +
    +    def createBookmarksOnToolbar(self):
    +        error = self.runAsyncCode(
    +            """
    +          let resolve = arguments[arguments.length - 1];
    +          let children = [];
    +          for (let i = 1; i <= 5; i++) {
    +            children.push({url: `about:rights?p=${i}`, title: `Bookmark ${i}`});
    +          }
    +          PlacesUtils.bookmarks.insertTree({
    +            guid: PlacesUtils.bookmarks.toolbarGuid,
    +            children
    +          }).then(() => resolve(false), resolve);
    +        """
    +        )
    +        if error:
    +            print(error)
    +
    +    def createHistory(self):
    +        error = self.runAsyncCode(
    +            """
    +          let resolve = arguments[arguments.length - 1];
    +          PlacesUtils.history.insert({
    +            url: arguments[0],
    +            title: arguments[1],
    +            visits: [{
    +              date: new Date(Date.now() - 5000),
    +              referrer: "about:mozilla"
    +            }]
    +          }).then(() => resolve(false),
    +                  ex => resolve("Unexpected error in adding visit: " + ex));
    +        """,
    +            script_args=(self._historyURL, self._historyTitle),
    +        )
    +        if error:
    +            print(error)
    +
    +    def createFormHistory(self):
    +        error = self.runAsyncCode(
    +            """
    +          let updateDefinition = {
    +            op: "add",
    +            fieldname: arguments[0],
    +            value: arguments[1],
    +            firstUsed: (Date.now() - 5000) * 1000,
    +          };
    +          let resolve = arguments[arguments.length - 1];
    +          global.FormHistory.update(updateDefinition).then(() => {
    +            resolve(false);
    +          }, error => {
    +            resolve("Unexpected error in adding formhistory: " + error);
    +          });
    +        """,
    +            script_args=(self._formHistoryFieldName, self._formHistoryValue),
    +        )
    +        if error:
    +            print(error)
    +
    +    def createFormAutofill(self):
    +        if not self._formAutofillAvailable:
    +            return
    +        self._formAutofillAddressGuid = self.runAsyncCode(
    +            """
    +          let resolve = arguments[arguments.length - 1];
    +          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",
    +          };
    +          return global.formAutofillStorage.initialize().then(() => {
    +            return global.formAutofillStorage.addresses.add(TEST_ADDRESS_1);
    +          }).then(resolve);
    +        """
    +        )
    +
    +    def createCookie(self):
    +        self.runCode(
    +            """
    +          // Expire in 15 minutes:
    +          let expireTime = Math.floor(Date.now() / 1000) + 15 * 60;
    +          Services.cookies.add(arguments[0], arguments[1], arguments[2], arguments[3],
    +                               true, false, false, expireTime, {},
    +                               Ci.nsICookie.SAMESITE_NONE, Ci.nsICookie.SCHEME_UNSET);
    +        """,
    +            script_args=(
    +                self._cookieHost,
    +                self._cookiePath,
    +                self._cookieName,
    +                self._cookieValue,
    +            ),
    +        )
    +
    +    def createSession(self):
    +        self.runAsyncCode(
    +            """
    +          let resolve = arguments[arguments.length - 1];
    +          const COMPLETE_STATE = Ci.nsIWebProgressListener.STATE_STOP +
    +                                 Ci.nsIWebProgressListener.STATE_IS_NETWORK;
    +          let { TabStateFlusher } = ChromeUtils.importESModule(
    +            "resource:///modules/sessionstore/TabStateFlusher.sys.mjs"
    +          );
    +          let expectedURLs = Array.from(arguments[0])
    +          gBrowser.addTabsProgressListener({
    +            onStateChange(browser, webprogress, request, flags, status) {
    +              try {
    +                request && request.QueryInterface(Ci.nsIChannel);
    +              } catch (ex) {}
    +              let uriLoaded = request.originalURI && request.originalURI.spec;
    +              if ((flags & COMPLETE_STATE == COMPLETE_STATE) && uriLoaded &&
    +                  expectedURLs.includes(uriLoaded)) {
    +                TabStateFlusher.flush(browser).then(function() {
    +                  expectedURLs.splice(expectedURLs.indexOf(uriLoaded), 1);
    +                  if (!expectedURLs.length) {
    +                    gBrowser.removeTabsProgressListener(this);
    +                    resolve();
    +                  }
    +                });
    +              }
    +            }
    +          });
    +          let expectedTabs = new Set();
    +          for (let url of expectedURLs) {
    +            expectedTabs.add(gBrowser.addTab(url, {
    +              triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
    +            }));
    +          }
    +          // Close any other tabs that might be open:
    +          let allTabs = Array.from(gBrowser.tabs);
    +          for (let tab of allTabs) {
    +            if (!expectedTabs.has(tab)) {
    +              gBrowser.removeTab(tab);
    +            }
    +          }
    +        """,  # NOQA: E501
    +            script_args=(self._expectedURLs,),
    +        )
    +
    +    def createFxa(self):
    +        # This script will write an entry to the login manager and create
    +        # a signedInUser.json in the profile dir.
    +        self.runAsyncCode(
    +            """
    +          let resolve = arguments[arguments.length - 1];
    +          let { FxAccountsStorageManager } = ChromeUtils.import(
    +            "resource://gre/modules/FxAccountsStorage.jsm"
    +          );
    +          let storage = new FxAccountsStorageManager();
    +          let data = {email: "test@test.com", uid: "uid", keyFetchToken: "top-secret"};
    +          storage.initialize(data);
    +          storage.finalize().then(resolve);
    +        """
    +        )
    +
    +    def createSync(self):
    +        # This script will write the canonical preference which indicates a user
    +        # is signed into sync.
    +        self.marionette.execute_script(
    +            """
    +            Services.prefs.setStringPref("services.sync.username", "test@test.com");
    +        """
    +        )
    +
    +    def checkPassword(self):
    +        loginInfo = self.runAsyncCode(
    +            """
    +          let [resolve] = arguments;
    +          Services.logins.searchLoginsAsync({
    +            origin: "test.marionette.mozilla.com",
    +            formActionOrigin: "http://test.marionette.mozilla.com/some/form/",
    +          }).then(ary => resolve(ary.length ? ary : {username: "null", password: "null"}));
    +        """
    +        )
    +        self.assertEqual(len(loginInfo), 1)
    +        self.assertEqual(loginInfo[0]["username"], self._username)
    +        self.assertEqual(loginInfo[0]["password"], self._password)
    +
    +        loginCount = self.runAsyncCode(
    +            """
    +          let resolve = arguments[arguments.length - 1];
    +          Services.logins.getAllLogins().then(logins => resolve(logins.length));
    +        """
    +        )
    +        # Note that we expect 2 logins - one from us, one from sync.
    +        self.assertEqual(loginCount, 2, "No other logins are present")
    +
    +    def checkBookmarkInMenu(self):
    +        titleInBookmarks = self.runAsyncCode(
    +            """
    +          let [url, resolve] = arguments;
    +          PlacesUtils.bookmarks.fetch({url}).then(
    +            bookmark => resolve(bookmark ? bookmark.title : ""),
    +            ex => resolve(ex)
    +          );
    +        """,
    +            script_args=(self._bookmarkURL,),
    +        )
    +        self.assertEqual(titleInBookmarks, self._bookmarkText)
    +
    +    def checkBookmarkToolbarVisibility(self):
    +        toolbarVisible = self.marionette.execute_script(
    +            """
    +          const BROWSER_DOCURL = AppConstants.BROWSER_CHROME_URL;
    +          return Services.xulStore.getValue(BROWSER_DOCURL, "PersonalToolbar", "collapsed");
    +        """
    +        )
    +        if toolbarVisible == "":
    +            toolbarVisible = "false"
    +        self.assertEqual(toolbarVisible, "false")
    +
    +    def checkHistory(self):
    +        historyResult = self.runAsyncCode(
    +            """
    +          let resolve = arguments[arguments.length - 1];
    +          PlacesUtils.history.fetch(arguments[0]).then(pageInfo => {
    +            if (!pageInfo) {
    +              resolve("No visits found");
    +            } else {
    +              resolve(pageInfo);
    +            }
    +          }).catch(e => {
    +            resolve("Unexpected error in fetching page: " + e);
    +          });
    +        """,
    +            script_args=(self._historyURL,),
    +        )
    +        if type(historyResult) == str:
    +            self.fail(historyResult)
    +            return
    +
    +        self.assertEqual(historyResult["title"], self._historyTitle)
    +
    +    def checkFormHistory(self):
    +        formFieldResults = self.runAsyncCode(
    +            """
    +          let resolve = arguments[arguments.length - 1];
    +          let results = [];
    +          global.FormHistory.search(["value"], {fieldname: arguments[0]})
    +            .then(resolve);
    +        """,
    +            script_args=(self._formHistoryFieldName,),
    +        )
    +        if type(formFieldResults) == str:
    +            self.fail(formFieldResults)
    +            return
    +
    +        formFieldResultCount = len(formFieldResults)
    +        self.assertEqual(
    +            formFieldResultCount,
    +            1,
    +            "Should have exactly 1 entry for this field, got %d" % formFieldResultCount,
    +        )
    +        if formFieldResultCount == 1:
    +            self.assertEqual(formFieldResults[0]["value"], self._formHistoryValue)
    +
    +        formHistoryCount = self.runAsyncCode(
    +            """
    +          let [resolve] = arguments;
    +          global.FormHistory.count({}).then(resolve);
    +        """
    +        )
    +        self.assertEqual(
    +            formHistoryCount, 1, "There should be only 1 entry in the form history"
    +        )
    +
    +    def checkFormAutofill(self):
    +        if not self._formAutofillAvailable:
    +            return
    +
    +        formAutofillResults = self.runAsyncCode(
    +            """
    +          let resolve = arguments[arguments.length - 1];
    +          return global.formAutofillStorage.initialize().then(() => {
    +            return global.formAutofillStorage.addresses.getAll()
    +          }).then(resolve);
    +        """,
    +        )
    +        if type(formAutofillResults) == str:
    +            self.fail(formAutofillResults)
    +            return
    +
    +        formAutofillAddressCount = len(formAutofillResults)
    +        self.assertEqual(
    +            formAutofillAddressCount,
    +            1,
    +            "Should have exactly 1 saved address, got %d" % formAutofillAddressCount,
    +        )
    +        if formAutofillAddressCount == 1:
    +            self.assertEqual(
    +                formAutofillResults[0]["guid"], self._formAutofillAddressGuid
    +            )
    +
    +    def checkCookie(self):
    +        cookieInfo = self.runCode(
    +            """
    +          try {
    +            let cookies = Services.cookies.getCookiesFromHost(arguments[0], {});
    +            let cookie = null;
    +            for (let hostCookie of cookies) {
    +              // getCookiesFromHost returns any cookie from the BASE host.
    +              if (hostCookie.rawHost != arguments[0])
    +                continue;
    +              if (cookie != null) {
    +                return "more than 1 cookie! That shouldn't happen!";
    +              }
    +              cookie = hostCookie;
    +            }
    +            return {path: cookie.path, name: cookie.name, value: cookie.value};
    +          } catch (ex) {
    +            return "got exception trying to fetch cookie: " + ex;
    +          }
    +        """,
    +            script_args=(self._cookieHost,),
    +        )
    +        if not isinstance(cookieInfo, dict):
    +            self.fail(cookieInfo)
    +            return
    +        self.assertEqual(cookieInfo["path"], self._cookiePath)
    +        self.assertEqual(cookieInfo["value"], self._cookieValue)
    +        self.assertEqual(cookieInfo["name"], self._cookieName)
    +
    +    def checkSession(self):
    +        tabURIs = self.runCode(
    +            """
    +          return [... gBrowser.browsers].map(b => b.currentURI && b.currentURI.spec)
    +        """
    +        )
    +        self.assertSequenceEqual(tabURIs, ["about:welcomeback"])
    +
    +        # Dismiss modal dialog if any. This is mainly to dismiss the check for
    +        # default browser dialog if it shows up.
    +        try:
    +            alert = self.marionette.switch_to_alert()
    +            alert.dismiss()
    +        except NoAlertPresentException:
    +            pass
    +
    +        tabURIs = self.runAsyncCode(
    +            """
    +          let resolve = arguments[arguments.length - 1]
    +          let mm = gBrowser.selectedBrowser.messageManager;
    +
    +          window.addEventListener("SSWindowStateReady", function() {
    +            window.addEventListener("SSTabRestored", function() {
    +              resolve(Array.from(gBrowser.browsers, b => b.currentURI?.spec));
    +            }, { capture: false, once: true });
    +          }, { capture: false, once: true });
    +
    +          let fs = function() {
    +            if (content.document.readyState === "complete") {
    +              content.document.getElementById("errorTryAgain").click();
    +            } else {
    +              content.window.addEventListener("load", function(event) {
    +                content.document.getElementById("errorTryAgain").click();
    +              }, { once: true });
    +            }
    +          };
    +
    +          Services.prefs.setBoolPref("security.allow_parent_unrestricted_js_loads", true);
    +          mm.loadFrameScript("data:application/javascript,(" + fs.toString() + ")()", true);
    +          Services.prefs.setBoolPref("security.allow_parent_unrestricted_js_loads", false);
    +        """  # NOQA: E501
    +        )
    +        self.assertSequenceEqual(tabURIs, self._expectedURLs)
    +
    +    def checkFxA(self):
    +        result = self.runAsyncCode(
    +            """
    +          let { FxAccountsStorageManager } = ChromeUtils.import(
    +            "resource://gre/modules/FxAccountsStorage.jsm"
    +          );
    +          let resolve = arguments[arguments.length - 1];
    +          let storage = new FxAccountsStorageManager();
    +          let result = {};
    +          storage.initialize();
    +          storage.getAccountData().then(data => {
    +            result.accountData = data;
    +            return storage.finalize();
    +          }).then(() => {
    +            resolve(result);
    +          }).catch(err => {
    +            resolve(err.toString());
    +          });
    +        """
    +        )
    +        if type(result) != dict:
    +            self.fail(result)
    +            return
    +        self.assertEqual(result["accountData"]["email"], "test@test.com")
    +        self.assertEqual(result["accountData"]["uid"], "uid")
    +        self.assertEqual(result["accountData"]["keyFetchToken"], "top-secret")
    +
    +    def checkSync(self, expect_sync_user):
    +        pref_value = self.marionette.execute_script(
    +            """
    +            return Services.prefs.getStringPref("services.sync.username", null);
    +        """
    +        )
    +        expected_value = "test@test.com" if expect_sync_user else None
    +        self.assertEqual(pref_value, expected_value)
    +
    +    def checkStartupMigrationStateCleared(self):
    +        result = self.runCode(
    +            """
    +          let { MigrationUtils } = ChromeUtils.importESModule(
    +            "resource:///modules/MigrationUtils.sys.mjs"
    +          );
    +          return MigrationUtils.isStartupMigration;
    +        """
    +        )
    +        self.assertFalse(result)
    +
    +    def checkProfile(self, has_migrated=False, expect_sync_user=True):
    +        self.checkPassword()
    +        self.checkBookmarkInMenu()
    +        self.checkHistory()
    +        self.checkFormHistory()
    +        self.checkFormAutofill()
    +        self.checkCookie()
    +        self.checkFxA()
    +        self.checkSync(expect_sync_user)
    +        if has_migrated:
    +            self.checkBookmarkToolbarVisibility()
    +            self.checkSession()
    +            self.checkStartupMigrationStateCleared()
    +
    +    def createProfileData(self):
    +        self.savePassword()
    +        self.createBookmarkInMenu()
    +        self.createBookmarksOnToolbar()
    +        self.createHistory()
    +        self.createFormHistory()
    +        self.createFormAutofill()
    +        self.createCookie()
    +        self.createSession()
    +        self.createFxa()
    +        self.createSync()
    +
    +    def setUpScriptData(self):
    +        self.marionette.set_context(self.marionette.CONTEXT_CHROME)
    +        self.runCode(
    +            """
    +          window.global = {};
    +          global.LoginInfo = Components.Constructor("@mozilla.org/login-manager/loginInfo;1", "nsILoginInfo", "init");
    +          global.profSvc = Cc["@mozilla.org/toolkit/profile-service;1"].getService(Ci.nsIToolkitProfileService);
    +          global.Preferences = ChromeUtils.importESModule(
    +            "resource://gre/modules/Preferences.sys.mjs"
    +          ).Preferences;
    +          global.FormHistory = ChromeUtils.import(
    +            "resource://gre/modules/FormHistory.jsm"
    +          ).FormHistory;
    +        """  # NOQA: E501
    +        )
    +        self._formAutofillAvailable = self.runCode(
    +            """
    +          try {
    +            global.formAutofillStorage = ChromeUtils.import(
    +              "resource://formautofill/FormAutofillStorage.jsm"
    +            ).formAutofillStorage;
    +          } catch(e) {
    +            return false;
    +          }
    +          return true;
    +        """  # NOQA: E501
    +        )
    +
    +    def runCode(self, script, *args, **kwargs):
    +        return self.marionette.execute_script(
    +            script, new_sandbox=False, sandbox=self._sandbox, *args, **kwargs
    +        )
    +
    +    def runAsyncCode(self, script, *args, **kwargs):
    +        return self.marionette.execute_async_script(
    +            script, new_sandbox=False, sandbox=self._sandbox, *args, **kwargs
    +        )
    +
    +    def setUp(self):
    +        MarionetteTestCase.setUp(self)
    +        self.setUpScriptData()
    +
    +        self.cleanups = []
    +
    +    def tearDown(self):
    +        # Force yet another restart with a clean profile to disconnect from the
    +        # profile and environment changes we've made, to leave a more or less
    +        # blank slate for the next person.
    +        self.marionette.restart(in_app=False, clean=True)
    +        self.setUpScriptData()
    +
    +        # Super
    +        MarionetteTestCase.tearDown(self)
    +
    +        # A helper to deal with removing a load of files
    +        import mozfile
    +
    +        for cleanup in self.cleanups:
    +            if cleanup.desktop_backup_path:
    +                mozfile.remove(cleanup.desktop_backup_path)
    +
    +            if cleanup.reset_profile_path:
    +                # Remove ourselves from profiles.ini
    +                self.runCode(
    +                    """
    +                  let name = arguments[0];
    +                  let profile = global.profSvc.getProfileByName(name);
    +                  profile.remove(false)
    +                  global.profSvc.flush();
    +                """,
    +                    script_args=(cleanup.profile_name_to_remove,),
    +                )
    +                # Remove the local profile dir if it's not the same as the profile dir:
    +                different_path = (
    +                    cleanup.reset_profile_local_path != cleanup.reset_profile_path
    +                )
    +                if cleanup.reset_profile_local_path and different_path:
    +                    mozfile.remove(cleanup.reset_profile_local_path)
    +
    +                # And delete all the files.
    +                mozfile.remove(cleanup.reset_profile_path)
    +
    +    def doReset(self):
    +        profileName = "marionette-test-profile-" + str(int(time.time() * 1000))
    +        cleanup = PendingCleanup(profileName)
    +        self.runCode(
    +            """
    +          // Ensure the current (temporary) profile is in profiles.ini:
    +          let profD = Services.dirsvc.get("ProfD", Ci.nsIFile);
    +          let profileName = arguments[1];
    +          let myProfile = global.profSvc.createProfile(profD, profileName);
    +          global.profSvc.flush()
    +
    +          // Now add the reset parameters:
    +          let prefsToKeep = Array.from(Services.prefs.getChildList("marionette."));
    +          // Add all the modified preferences set from geckoinstance.py to avoid
    +          // non-local connections.
    +          prefsToKeep = prefsToKeep.concat(JSON.parse(
    +              Services.env.get("MOZ_MARIONETTE_REQUIRED_PREFS")));
    +          let prefObj = {};
    +          for (let pref of prefsToKeep) {
    +            prefObj[pref] = global.Preferences.get(pref);
    +          }
    +          Services.env.set("MOZ_MARIONETTE_PREF_STATE_ACROSS_RESTARTS", JSON.stringify(prefObj));
    +          Services.env.set("MOZ_RESET_PROFILE_RESTART", "1");
    +          Services.env.set("XRE_PROFILE_PATH", arguments[0]);
    +        """,
    +            script_args=(
    +                self.marionette.instance.profile.profile,
    +                profileName,
    +            ),
    +        )
    +
    +        profileLeafName = os.path.basename(
    +            os.path.normpath(self.marionette.instance.profile.profile)
    +        )
    +
    +        # Now restart the browser to get it reset:
    +        self.marionette.restart(clean=False, in_app=True)
    +        self.setUpScriptData()
    +
    +        # Determine the new profile path (we'll need to remove it when we're done)
    +        [cleanup.reset_profile_path, cleanup.reset_profile_local_path] = self.runCode(
    +            """
    +          let profD = Services.dirsvc.get("ProfD", Ci.nsIFile);
    +          let localD = Services.dirsvc.get("ProfLD", Ci.nsIFile);
    +          return [profD.path, localD.path];
    +        """
    +        )
    +
    +        # Determine the backup path
    +        cleanup.desktop_backup_path = self.runCode(
    +            """
    +          let container;
    +          try {
    +            container = Services.dirsvc.get("Desk", Ci.nsIFile);
    +          } catch (ex) {
    +            container = Services.dirsvc.get("Home", Ci.nsIFile);
    +          }
    +          let bundle = Services.strings.createBundle("chrome://mozapps/locale/profile/profileSelection.properties");
    +          let dirName = bundle.formatStringFromName("resetBackupDirectory", [Services.appinfo.name]);
    +          container.append(dirName);
    +          container.append(arguments[0]);
    +          return container.path;
    +        """,  # NOQA: E501
    +            script_args=(profileLeafName,),
    +        )
    +
    +        self.assertTrue(
    +            os.path.isdir(cleanup.reset_profile_path),
    +            "Reset profile path should be present",
    +        )
    +        self.assertTrue(
    +            os.path.isdir(cleanup.desktop_backup_path),
    +            "Backup profile path should be present",
    +        )
    +        self.assertIn(cleanup.profile_name_to_remove, cleanup.reset_profile_path)
    +        return cleanup
    +
    +    def testResetEverything(self):
    +        self.createProfileData()
    +
    +        self.checkProfile(expect_sync_user=True)
    +
    +        this_cleanup = self.doReset()
    +        self.cleanups.append(this_cleanup)
    +
    +        # Now check that we're doing OK...
    +        self.checkProfile(has_migrated=True, expect_sync_user=True)
    +
    +    def testFxANoSync(self):
    +        # This test doesn't need to repeat all the non-sync tests...
    +        # Setup FxA but *not* sync
    +        self.createFxa()
    +
    +        self.checkFxA()
    +        self.checkSync(False)
    +
    +        this_cleanup = self.doReset()
    +        self.cleanups.append(this_cleanup)
    +
    +        self.checkFxA()
    +        self.checkSync(False)
    diff --git a/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Favicons b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Favicons
    new file mode 100644
    index 0000000000..fddee798b3
    Binary files /dev/null and b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Favicons differ
    diff --git a/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Login Data b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Login Data
    new file mode 100644
    index 0000000000..7e6e843a03
    Binary files /dev/null and b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Login Data differ
    diff --git a/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Web Data b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Web Data
    new file mode 100644
    index 0000000000..c557c9b851
    Binary files /dev/null and b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Web Data differ
    diff --git a/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Local State b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Local State
    new file mode 100644
    index 0000000000..3f3fecb651
    --- /dev/null
    +++ b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Local State	
    @@ -0,0 +1,5 @@
    +{
    +  "os_crypt" : {
    +    "encrypted_key" : "RFBBUEk/ThisNPAPIKeyCanOnlyBeDecryptedByTheOriginalDeviceSoThisWillThrowFromDecryptData"
    +  }
    +}
    diff --git a/browser/components/migration/tests/unit/AppData/LocalWithNoData/Google/Chrome/User Data/Default/Login Data b/browser/components/migration/tests/unit/AppData/LocalWithNoData/Google/Chrome/User Data/Default/Login Data
    new file mode 100644
    index 0000000000..fd135624c4
    Binary files /dev/null and b/browser/components/migration/tests/unit/AppData/LocalWithNoData/Google/Chrome/User Data/Default/Login Data differ
    diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat
    new file mode 100644
    index 0000000000..1835c33583
    Binary files /dev/null and b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat differ
    diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/360Bookmarks b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/360Bookmarks
    new file mode 100644
    index 0000000000..f51195f54c
    --- /dev/null
    +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/360Bookmarks	
    @@ -0,0 +1 @@
    +Encrypted canonical bookmarks storage, since 360 SE 10
    diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2020_08_28.favdb b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2020_08_28.favdb
    new file mode 100644
    index 0000000000..ea466a25bf
    --- /dev/null
    +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2020_08_28.favdb	
    @@ -0,0 +1,3 @@
    +{
    +    "note": "Plain text bookmarks backup, since 360 SE 10."
    +}
    diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2021_12_02.favdb b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2021_12_02.favdb
    new file mode 100644
    index 0000000000..ea466a25bf
    --- /dev/null
    +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2021_12_02.favdb	
    @@ -0,0 +1,3 @@
    +{
    +    "note": "Plain text bookmarks backup, since 360 SE 10."
    +}
    diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2020_08_28.favdb b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2020_08_28.favdb
    new file mode 100644
    index 0000000000..32b4002a32
    --- /dev/null
    +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2020_08_28.favdb	
    @@ -0,0 +1 @@
    +Encrypted bookmarks backup, since 360 SE 10.
    diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2021_12_02.favdb b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2021_12_02.favdb
    new file mode 100644
    index 0000000000..32b4002a32
    --- /dev/null
    +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2021_12_02.favdb	
    @@ -0,0 +1 @@
    +Encrypted bookmarks backup, since 360 SE 10.
    diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat
    new file mode 100644
    index 0000000000..440e7145bd
    --- /dev/null
    +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat	
    @@ -0,0 +1 @@
    +Bookmarks storage in legacy SQLite format.
    diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/DailyBackup/360sefav_2020_08_28.favdb b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/DailyBackup/360sefav_2020_08_28.favdb
    new file mode 100644
    index 0000000000..d5d939629c
    --- /dev/null
    +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/DailyBackup/360sefav_2020_08_28.favdb	
    @@ -0,0 +1,3 @@
    +{
    +    "note": "Plain text bookmarks backup, 360 SE 9."
    +}
    diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/Bookmarks b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/Bookmarks
    new file mode 100644
    index 0000000000..6f47e5a55c
    --- /dev/null
    +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/Bookmarks	
    @@ -0,0 +1,3 @@
    +{
    +    "note": "Plain text canonical bookmarks storage, 360 SE 9."
    +}
    diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Local State b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Local State
    new file mode 100644
    index 0000000000..dd3fecce45
    --- /dev/null
    +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Local State	
    @@ -0,0 +1,12 @@
    +{
    +    "profile": {
    +        "info_cache": {
    +            "Default": {
    +                "name": "用户1"
    +            }
    +        }
    +    },
    +    "sync_login_info": {
    +        "filepath": "0f3ab103a522f4463ecacc36d34eb996"
    +    }
    +}
    diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Cookies b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Cookies
    new file mode 100644
    index 0000000000..83d855cb33
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Cookies differ
    diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/_locales/en_US/messages.json b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/_locales/en_US/messages.json
    new file mode 100644
    index 0000000000..44e855edbd
    --- /dev/null
    +++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/_locales/en_US/messages.json	
    @@ -0,0 +1,9 @@
    +{
    +   "description": {
    +      "description": "Extension description in manifest. Should not exceed 132 characters.",
    +      "message": "It is the description of fake app 1."
    +   },
    +   "name": {
    +      "message": "Fake App 1"
    +   }
    +}
    diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/manifest.json b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/manifest.json
    new file mode 100644
    index 0000000000..1550bf1c0e
    --- /dev/null
    +++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/manifest.json	
    @@ -0,0 +1,10 @@
    +{
    +   "app": {
    +      "launch": {
    +         "local_path": "main.html"
    +      }
    +   },
    +   "default_locale": "en_US",
    +   "description": "__MSG_description__",
    +   "name": "__MSG_name__"
    +}
    diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/_locales/en_US/messages.json b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/_locales/en_US/messages.json
    new file mode 100644
    index 0000000000..11657460d8
    --- /dev/null
    +++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/_locales/en_US/messages.json	
    @@ -0,0 +1,9 @@
    +{
    +   "description": {
    +      "description": "Extension description in manifest. Should not exceed 132 characters.",
    +      "message": "It is the description of fake extension 1."
    +   },
    +   "name": {
    +      "message": "Fake Extension 1"
    +   }
    +}
    diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/manifest.json b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/manifest.json
    new file mode 100644
    index 0000000000..5ceced8031
    --- /dev/null
    +++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/manifest.json	
    @@ -0,0 +1,5 @@
    +{
    +   "default_locale": "en_US",
    +   "description": "__MSG_description__",
    +   "name": "__MSG_name__"
    +}
    diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-2/1.0_0/manifest.json b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-2/1.0_0/manifest.json
    new file mode 100644
    index 0000000000..983c37560c
    --- /dev/null
    +++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-2/1.0_0/manifest.json	
    @@ -0,0 +1,4 @@
    +{
    +   "default_locale": "en_US",
    +   "name": "Fake Extension 2"
    +}
    diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryCorrupt b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryCorrupt
    new file mode 100644
    index 0000000000..8585f308c5
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryCorrupt differ
    diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryMaster b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryMaster
    new file mode 100644
    index 0000000000..7fb19903b0
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryMaster differ
    diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Login Data b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Login Data
    new file mode 100644
    index 0000000000..19b8542b98
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Login Data differ
    diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Local State b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Local State
    new file mode 100644
    index 0000000000..01b99455e4
    --- /dev/null
    +++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Local State	
    @@ -0,0 +1,22 @@
    +{
    +  "profile" : {
    +    "info_cache" : {
    +      "Default" : {
    +        "active_time" : 1430950755.65137,
    +        "is_using_default_name" : true,
    +        "is_ephemeral" : false,
    +        "is_omitted_from_profile_list" : false,
    +        "user_name" : "",
    +        "background_apps" : false,
    +        "is_using_default_avatar" : true,
    +        "avatar_icon" : "chrome://theme/IDR_PROFILE_AVATAR_0",
    +        "name" : "Person 1"
    +      }
    +    },
    +    "profiles_created" : 1,
    +    "last_used" : "Default",
    +    "last_active_profiles" : [
    +      "Default"
    +    ]
    +  }
    +}
    diff --git a/browser/components/migration/tests/unit/Library/Safari/Bookmarks.plist b/browser/components/migration/tests/unit/Library/Safari/Bookmarks.plist
    new file mode 100644
    index 0000000000..a9c33e1b1a
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Bookmarks.plist differ
    diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db
    new file mode 100644
    index 0000000000..dd5d0c7512
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db differ
    diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-lock b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-lock
    new file mode 100644
    index 0000000000..e69de29bb2
    diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-shm b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-shm
    new file mode 100644
    index 0000000000..edd607898b
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-shm differ
    diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-wal b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-wal
    new file mode 100644
    index 0000000000..e145119298
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-wal differ
    diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/04860FA3D07D8936B87D2B965317C6E9 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/04860FA3D07D8936B87D2B965317C6E9
    new file mode 100644
    index 0000000000..1c6741c165
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/04860FA3D07D8936B87D2B965317C6E9 differ
    diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/19A777E4F7BDA0C0E350D6C681B6E271 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/19A777E4F7BDA0C0E350D6C681B6E271
    new file mode 100644
    index 0000000000..47b40f707f
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/19A777E4F7BDA0C0E350D6C681B6E271 differ
    diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/2558A57A1AE576AA31F0DCD1364B3F42 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/2558A57A1AE576AA31F0DCD1364B3F42
    new file mode 100644
    index 0000000000..2a4c30b31e
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/2558A57A1AE576AA31F0DCD1364B3F42 differ
    diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/57D1907A1EBDA1889AA85B8AB7A90804 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/57D1907A1EBDA1889AA85B8AB7A90804
    new file mode 100644
    index 0000000000..f4996ba082
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/57D1907A1EBDA1889AA85B8AB7A90804 differ
    diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/6EEDD53B65A19CB364EB6FB07DEACF80 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/6EEDD53B65A19CB364EB6FB07DEACF80
    new file mode 100644
    index 0000000000..f519ce9ad2
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/6EEDD53B65A19CB364EB6FB07DEACF80 differ
    diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/7F65370AD319C7B294EDF2E2BEBA880F b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/7F65370AD319C7B294EDF2E2BEBA880F
    new file mode 100644
    index 0000000000..e70021849b
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/7F65370AD319C7B294EDF2E2BEBA880F differ
    diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/999E2BD5CD612AA550F222A1088DB3D8 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/999E2BD5CD612AA550F222A1088DB3D8
    new file mode 100644
    index 0000000000..559502b02b
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/999E2BD5CD612AA550F222A1088DB3D8 differ
    diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/9D8A6E2153D42043A7AE0430B41D374A b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/9D8A6E2153D42043A7AE0430B41D374A
    new file mode 100644
    index 0000000000..89ed9a1c39
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/9D8A6E2153D42043A7AE0430B41D374A differ
    diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/A21F634481CF5188329FD2052F07ADBC b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/A21F634481CF5188329FD2052F07ADBC
    new file mode 100644
    index 0000000000..7b86185e67
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/A21F634481CF5188329FD2052F07ADBC differ
    diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/BC2288B5BA9B7BE352BA586257442E08 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/BC2288B5BA9B7BE352BA586257442E08
    new file mode 100644
    index 0000000000..a1d03856b5
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/BC2288B5BA9B7BE352BA586257442E08 differ
    diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/CFFC3831D8E7201BF8B77728FC79B52B b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/CFFC3831D8E7201BF8B77728FC79B52B
    new file mode 100644
    index 0000000000..ba1145ca83
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/CFFC3831D8E7201BF8B77728FC79B52B differ
    diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/F3FA61DDA95B78A8B5F2C392C0382137 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/F3FA61DDA95B78A8B5F2C392C0382137
    new file mode 100644
    index 0000000000..82339b3b1d
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/F3FA61DDA95B78A8B5F2C392C0382137 differ
    diff --git a/browser/components/migration/tests/unit/Library/Safari/HistoryStrangeEntries.db b/browser/components/migration/tests/unit/Library/Safari/HistoryStrangeEntries.db
    new file mode 100644
    index 0000000000..533daba3df
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/HistoryStrangeEntries.db differ
    diff --git a/browser/components/migration/tests/unit/Library/Safari/HistoryTemplate.db b/browser/components/migration/tests/unit/Library/Safari/HistoryTemplate.db
    new file mode 100644
    index 0000000000..5a317c70e8
    Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/HistoryTemplate.db differ
    diff --git a/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Default/Login Data b/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Default/Login Data
    new file mode 100644
    index 0000000000..b2d425eb4a
    Binary files /dev/null and b/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Default/Login Data differ
    diff --git a/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Local State b/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Local State
    new file mode 100644
    index 0000000000..bb03d6b9a1
    --- /dev/null
    +++ b/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Local State	
    @@ -0,0 +1,22 @@
    +{
    +  "profile" : {
    +    "info_cache" : {
    +      "Default" : {
    +        "active_time" : 1430950755.65137,
    +        "is_using_default_name" : true,
    +        "is_ephemeral" : false,
    +        "is_omitted_from_profile_list" : false,
    +        "user_name" : "",
    +        "background_apps" : false,
    +        "is_using_default_avatar" : true,
    +        "avatar_icon" : "chrome://theme/IDR_PROFILE_AVATAR_0",
    +        "name" : "Person With No Data"
    +      }
    +    },
    +    "profiles_created" : 1,
    +    "last_used" : "Default",
    +    "last_active_profiles" : [
    +      "Default"
    +    ]
    +  }
    +}
    diff --git a/browser/components/migration/tests/unit/bookmarks.exported.html b/browser/components/migration/tests/unit/bookmarks.exported.html
    new file mode 100644
    index 0000000000..5a9ec43325
    --- /dev/null
    +++ b/browser/components/migration/tests/unit/bookmarks.exported.html
    @@ -0,0 +1,32 @@
    +
    +
    +
    +
    +Bookmarks
    +

    Bookmarks Menu

    + +

    +

    Mozilla Firefox

    +

    +

    Help and Tutorials +
    Customize Firefox +
    Get Involved +
    About Us +

    +


    test

    +

    +

    test post keyword +

    +

    Bookmarks Toolbar

    +

    +

    Getting Started +
    Latest Headlines +

    +

    Other Bookmarks

    +

    +

    Example.tld +

    +

    diff --git a/browser/components/migration/tests/unit/bookmarks.exported.json b/browser/components/migration/tests/unit/bookmarks.exported.json new file mode 100644 index 0000000000..2a73f00b31 --- /dev/null +++ b/browser/components/migration/tests/unit/bookmarks.exported.json @@ -0,0 +1,194 @@ +{ + "guid": "root________", + "title": "", + "index": 0, + "dateAdded": 1685116351936000, + "lastModified": 1685372151518000, + "id": 1, + "typeCode": 2, + "type": "text/x-moz-place-container", + "root": "placesRoot", + "children": [ + { + "guid": "menu________", + "title": "menu", + "index": 0, + "dateAdded": 1685116351936000, + "lastModified": 1685116352325000, + "id": 2, + "typeCode": 2, + "type": "text/x-moz-place-container", + "root": "bookmarksMenuFolder", + "children": [ + { + "guid": "jCs_9YrgXKq7", + "title": "Firefox Nightly Resources", + "index": 0, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 7, + "typeCode": 2, + "type": "text/x-moz-place-container", + "children": [ + { + "guid": "xwdRLsUWYFwm", + "title": "Firefox Nightly blog", + "index": 0, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 8, + "typeCode": 1, + "iconUri": "fake-favicon-uri:https://blog.nightly.mozilla.org/", + "type": "text/x-moz-place", + "uri": "https://blog.nightly.mozilla.org/" + }, + { + "guid": "uhdiDrWjH0-n", + "title": "Mozilla Bug Tracker", + "index": 1, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 9, + "typeCode": 1, + "iconUri": "fake-favicon-uri:https://bugzilla.mozilla.org/", + "type": "text/x-moz-place", + "uri": "https://bugzilla.mozilla.org/", + "keyword": "bz", + "postData": null + }, + { + "guid": "zOK7d-gjJ5Vy", + "title": "Mozilla Developer Network", + "index": 2, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 10, + "typeCode": 1, + "iconUri": "fake-favicon-uri:https://developer.mozilla.org/", + "type": "text/x-moz-place", + "uri": "https://developer.mozilla.org/", + "keyword": "mdn", + "postData": null + }, + { + "guid": "7gcb4320A_y6", + "title": "Nightly Tester Tools", + "index": 3, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 11, + "typeCode": 1, + "iconUri": "fake-favicon-uri:https://addons.mozilla.org/firefox/addon/nightly-tester-tools/", + "type": "text/x-moz-place", + "uri": "https://addons.mozilla.org/firefox/addon/nightly-tester-tools/" + }, + { + "guid": "c4753lDvJwNE", + "title": "All your crashes", + "index": 4, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 12, + "typeCode": 1, + "iconUri": "fake-favicon-uri:about:crashes", + "type": "text/x-moz-place", + "uri": "about:crashes" + }, + { + "guid": "IyYGIH9VCs2t", + "title": "Planet Mozilla", + "index": 5, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 13, + "typeCode": 1, + "iconUri": "fake-favicon-uri:https://planet.mozilla.org/", + "type": "text/x-moz-place", + "uri": "https://planet.mozilla.org/" + } + ] + } + ] + }, + { + "guid": "toolbar_____", + "title": "toolbar", + "index": 1, + "dateAdded": 1685116351936000, + "lastModified": 1685372151518000, + "id": 3, + "typeCode": 2, + "type": "text/x-moz-place-container", + "root": "toolbarFolder", + "children": [ + { + "guid": "5jN1vdzOEnHx", + "title": "Get Involved", + "index": 0, + "dateAdded": 1685116352413000, + "lastModified": 1685116352413000, + "id": 14, + "typeCode": 1, + "iconUri": "fake-favicon-uri:https://www.mozilla.org/contribute/?utm_medium=firefox-desktop&utm_source=bookmarks-toolbar&utm_campaign=new-users-nightly&utm_content=-global", + "type": "text/x-moz-place", + "uri": "https://www.mozilla.org/contribute/?utm_medium=firefox-desktop&utm_source=bookmarks-toolbar&utm_campaign=new-users-nightly&utm_content=-global" + }, + { + "guid": "5RsMT9sWsmIe", + "title": "Why More Psychiatrists Think Mindfulness Can Help Treat ADHD", + "index": 1, + "dateAdded": 1685372143048000, + "lastModified": 1685372143048000, + "id": 15, + "typeCode": 1, + "type": "text/x-moz-place", + "uri": "https://getpocket.com/explore/item/why-more-psychiatrists-think-mindfulness-can-help-treat-adhd?utm_source=pocket-newtab" + }, + { + "guid": "ejoNUqAfEMQL", + "title": "Your New Favorite Weeknight Recipe Is Meat-Free (and Easy, Too)", + "index": 2, + "dateAdded": 1685372148200000, + "lastModified": 1685372148200000, + "id": 16, + "typeCode": 1, + "type": "text/x-moz-place", + "uri": "https://getpocket.com/collections/your-new-favorite-weeknight-recipe-is-meat-free-and-easy-too?utm_source=pocket-newtab" + }, + { + "guid": "O5QCiQ1zrqHY", + "title": "8 Natural Ways to Repel Insects Without Bug Spray", + "index": 3, + "dateAdded": 1685372151518000, + "lastModified": 1685372151518000, + "id": 17, + "typeCode": 1, + "type": "text/x-moz-place", + "uri": "https://getpocket.com/explore/item/8-natural-ways-to-repel-insects-without-bug-spray?utm_source=pocket-newtab" + } + ] + }, + { + "guid": "unfiled_____", + "title": "unfiled", + "index": 3, + "dateAdded": 1685116351936000, + "lastModified": 1685116352272000, + "id": 5, + "typeCode": 2, + "type": "text/x-moz-place-container", + "root": "unfiledBookmarksFolder" + }, + { + "guid": "mobile______", + "title": "mobile", + "index": 4, + "dateAdded": 1685116351968000, + "lastModified": 1685116352272000, + "id": 6, + "typeCode": 2, + "type": "text/x-moz-place-container", + "root": "mobileFolder" + } + ] +} diff --git a/browser/components/migration/tests/unit/bookmarks.invalid.html b/browser/components/migration/tests/unit/bookmarks.invalid.html new file mode 100644 index 0000000000..900ec52e1d --- /dev/null +++ b/browser/components/migration/tests/unit/bookmarks.invalid.html @@ -0,0 +1 @@ +This shouldn't cause anything to be imported. diff --git a/browser/components/migration/tests/unit/head_migration.js b/browser/components/migration/tests/unit/head_migration.js new file mode 100644 index 0000000000..9900f34232 --- /dev/null +++ b/browser/components/migration/tests/unit/head_migration.js @@ -0,0 +1,260 @@ +"use strict"; + +var { MigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/MigrationUtils.sys.mjs" +); +var { LoginHelper } = ChromeUtils.importESModule( + "resource://gre/modules/LoginHelper.sys.mjs" +); +var { NetUtil } = ChromeUtils.importESModule( + "resource://gre/modules/NetUtil.sys.mjs" +); +var { PlacesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesUtils.sys.mjs" +); +var { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +var { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); +var { PlacesTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PlacesTestUtils.sys.mjs" +); +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", +}); + +// Initialize profile. +var gProfD = do_get_profile(); + +var { getAppInfo, newAppInfo, updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" +); +updateAppInfo(); + +/** + * Migrates the requested resource and waits for the migration to be complete. + * + * @param {MigratorBase} migrator + * The migrator being used to migrate the data. + * @param {number} resourceType + * This is a bitfield with bits from nsIBrowserProfileMigrator flipped to indicate what + * resources should be migrated. + * @param {object|string|null} [aProfile=null] + * The profile to be migrated. If set to null, the default profile will be + * migrated. + * @param {boolean} succeeds + * True if this migration is expected to succeed. + * @returns {Promise>} + * An array of the results from each nsIObserver topics being observed to + * verify if the migration succeeded or failed. Those results are 2-element + * arrays of [subject, data]. + */ +async function promiseMigration( + migrator, + resourceType, + aProfile = null, + succeeds = null +) { + // Ensure resource migration is available. + let availableSources = await migrator.getMigrateData(aProfile); + Assert.ok( + (availableSources & resourceType) > 0, + "Resource supported by migrator" + ); + let promises = [TestUtils.topicObserved("Migration:Ended")]; + + if (succeeds !== null) { + // Check that the specific resource type succeeded + promises.push( + TestUtils.topicObserved( + succeeds ? "Migration:ItemAfterMigrate" : "Migration:ItemError", + (_, data) => data == resourceType + ) + ); + } + + // Start the migration. + migrator.migrate(resourceType, null, aProfile); + + return Promise.all(promises); +} +/** + * Function that returns a favicon url for a given page url + * + * @param {string} uri + * The Bookmark URI + * @returns {string} faviconURI + * The Favicon URI + */ +async function getFaviconForPageURI(uri) { + let faviconURI = await new Promise(resolve => { + PlacesUtils.favicons.getFaviconDataForPage(uri, favURI => { + resolve(favURI); + }); + }); + return faviconURI; +} + +/** + * Takes an array of page URIs and checks that the favicon was imported for each page URI + * + * @param {Array} pageURIs An array of page URIs + */ +async function assertFavicons(pageURIs) { + for (let uri of pageURIs) { + let faviconURI = await getFaviconForPageURI(uri); + Assert.ok(faviconURI, `Got favicon for ${uri.spec}`); + } +} + +/** + * Replaces a directory service entry with a given nsIFile. + * + * @param {string} key + * The nsIDirectoryService directory key to register a fake path for. + * For example: "AppData", "ULibDir". + * @param {nsIFile} file + * The nsIFile to map the key to. Note that this nsIFile should represent + * a directory and not an individual file. + * @see nsDirectoryServiceDefs.h for the list of directories that can be + * overridden. + */ +function registerFakePath(key, file) { + let dirsvc = Services.dirsvc.QueryInterface(Ci.nsIProperties); + let originalFile; + try { + // If a file is already provided save it and undefine, otherwise set will + // throw for persistent entries (ones that are cached). + originalFile = dirsvc.get(key, Ci.nsIFile); + dirsvc.undefine(key); + } catch (e) { + // dirsvc.get will throw if nothing provides for the key and dirsvc.undefine + // will throw if it's not a persistent entry, in either case we don't want + // to set the original file in cleanup. + originalFile = undefined; + } + + dirsvc.set(key, file); + registerCleanupFunction(() => { + dirsvc.undefine(key); + if (originalFile) { + dirsvc.set(key, originalFile); + } + }); +} + +function getRootPath() { + let dirKey; + if (AppConstants.platform == "win") { + dirKey = "LocalAppData"; + } else if (AppConstants.platform == "macosx") { + dirKey = "ULibDir"; + } else { + dirKey = "Home"; + } + return Services.dirsvc.get(dirKey, Ci.nsIFile).path; +} + +/** + * Returns a PRTime value for the current date minus daysAgo number + * of days. + * + * @param {number} daysAgo + * How many days in the past from now the returned date should be. + * @returns {number} + */ +function PRTimeDaysAgo(daysAgo) { + return PlacesUtils.toPRTime(Date.now() - daysAgo * 24 * 60 * 60 * 1000); +} + +/** + * Returns a Date value for the current date minus daysAgo number + * of days. + * + * @param {number} daysAgo + * How many days in the past from now the returned date should be. + * @returns {Date} + */ +function dateDaysAgo(daysAgo) { + return new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); +} + +/** + * Constructs and returns a data structure consistent with the Chrome + * browsers bookmarks storage. This data structure can then be serialized + * to JSON and written to disk to simulate a Chrome browser's bookmarks + * database. + * + * @param {number} [totalBookmarks=100] + * How many bookmarks to create. + * @returns {object} + */ +function createChromeBookmarkStructure(totalBookmarks = 100) { + let bookmarksData = { + roots: { + bookmark_bar: { children: [] }, + other: { children: [] }, + synced: { children: [] }, + }, + }; + const MAX_BMS = totalBookmarks; + let barKids = bookmarksData.roots.bookmark_bar.children; + let menuKids = bookmarksData.roots.other.children; + let syncedKids = bookmarksData.roots.synced.children; + let currentMenuKids = menuKids; + let currentBarKids = barKids; + let currentSyncedKids = syncedKids; + for (let i = 0; i < MAX_BMS; i++) { + currentBarKids.push({ + url: "https://www.chrome-bookmark-bar-bookmark" + i + ".com", + name: "bookmark " + i, + type: "url", + }); + currentMenuKids.push({ + url: "https://www.chrome-menu-bookmark" + i + ".com", + name: "bookmark for menu " + i, + type: "url", + }); + currentSyncedKids.push({ + url: "https://www.chrome-synced-bookmark" + i + ".com", + name: "bookmark for synced " + i, + type: "url", + }); + if (i % 20 == 19) { + let nextFolder = { + name: "toolbar folder " + Math.ceil(i / 20), + type: "folder", + children: [], + }; + currentBarKids.push(nextFolder); + currentBarKids = nextFolder.children; + + nextFolder = { + name: "menu folder " + Math.ceil(i / 20), + type: "folder", + children: [], + }; + currentMenuKids.push(nextFolder); + currentMenuKids = nextFolder.children; + + nextFolder = { + name: "synced folder " + Math.ceil(i / 20), + type: "folder", + children: [], + }; + currentSyncedKids.push(nextFolder); + currentSyncedKids = nextFolder.children; + } + } + return bookmarksData; +} diff --git a/browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp b/browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp new file mode 100644 index 0000000000..dea79a9289 --- /dev/null +++ b/browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Insert URLs into Internet Explorer (IE) history so we can test importing + * them. + * + * See API docs at + * https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/ms774949(v%3dvs.85) + */ + +#include // IUrlHistoryStg +#include // SID_SUrlHistory + +int main(int argc, char** argv) { + HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + if (FAILED(hr)) { + CoUninitialize(); + return -1; + } + IUrlHistoryStg* ieHist; + + hr = + ::CoCreateInstance(CLSID_CUrlHistory, nullptr, CLSCTX_INPROC_SERVER, + IID_IUrlHistoryStg, reinterpret_cast(&ieHist)); + if (FAILED(hr)) return -2; + + hr = ieHist->AddUrl(L"http://www.mozilla.org/1", L"Mozilla HTTP Test", 0); + if (FAILED(hr)) return -3; + + hr = + ieHist->AddUrl(L"https://www.mozilla.org/2", L"Mozilla HTTPS Test 🦊", 0); + if (FAILED(hr)) return -4; + + CoUninitialize(); + + return 0; +} diff --git a/browser/components/migration/tests/unit/insertIEHistory/moz.build b/browser/components/migration/tests/unit/insertIEHistory/moz.build new file mode 100644 index 0000000000..61ca96d48a --- /dev/null +++ b/browser/components/migration/tests/unit/insertIEHistory/moz.build @@ -0,0 +1,19 @@ +# -*- 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/. + +FINAL_TARGET = "_tests/xpcshell/browser/components/migration/tests/unit" + +Program("InsertIEHistory") +OS_LIBS += [ + "ole32", + "uuid", +] +SOURCES += [ + "InsertIEHistory.cpp", +] + +NO_PGO = True +DisableStlWrapping() diff --git a/browser/components/migration/tests/unit/test_360seMigrationUtils.js b/browser/components/migration/tests/unit/test_360seMigrationUtils.js new file mode 100644 index 0000000000..3a882b516d --- /dev/null +++ b/browser/components/migration/tests/unit/test_360seMigrationUtils.js @@ -0,0 +1,164 @@ +"use strict"; + +const { Qihoo360seMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/360seMigrationUtils.sys.mjs" +); + +const parentPath = do_get_file("AppData/Roaming/360se6/User Data").path; +const loggedInPath = "0f3ab103a522f4463ecacc36d34eb996"; +const loggedInBackup = PathUtils.join( + parentPath, + "Default", + loggedInPath, + "DailyBackup", + "360default_ori_2021_12_02.favdb" +); +const loggedOutBackup = PathUtils.join( + parentPath, + "Default", + "DailyBackup", + "360default_ori_2021_12_02.favdb" +); + +function getSqlitePath(profileId) { + return PathUtils.join(parentPath, profileId, loggedInPath, "360sefav.dat"); +} + +add_task(async function test_360se10_logged_in() { + const sqlitePath = getSqlitePath("Default"); + await IOUtils.setModificationTime(sqlitePath); + await IOUtils.copy( + PathUtils.join(parentPath, "Default", "360Bookmarks"), + PathUtils.join(parentPath, "Default", loggedInPath) + ); + await IOUtils.copy(loggedOutBackup, loggedInBackup); + + const alternativeBookmarks = + await Qihoo360seMigrationUtils.getAlternativeBookmarks({ + bookmarksPath: PathUtils.join(parentPath, "Default", "Bookmarks"), + localState: { + sync_login_info: { + filepath: loggedInPath, + }, + }, + }); + Assert.ok( + alternativeBookmarks.resource && alternativeBookmarks.resource.exists, + "Should return the legacy bookmark resource." + ); + Assert.strictEqual( + alternativeBookmarks.path, + undefined, + "Should not return any path to plain text bookmarks." + ); +}); + +add_task(async function test_360se10_logged_in_outdated_sqlite() { + const sqlitePath = getSqlitePath("Default"); + await IOUtils.setModificationTime( + sqlitePath, + new Date("2020-08-18").valueOf() + ); + + const alternativeBookmarks = + await Qihoo360seMigrationUtils.getAlternativeBookmarks({ + bookmarksPath: PathUtils.join(parentPath, "Default", "Bookmarks"), + localState: { + sync_login_info: { + filepath: loggedInPath, + }, + }, + }); + Assert.strictEqual( + alternativeBookmarks.resource, + undefined, + "Should not return the outdated legacy bookmark resource." + ); + Assert.strictEqual( + alternativeBookmarks.path, + loggedInBackup, + "Should return path to the most recent plain text bookmarks backup." + ); + + await IOUtils.setModificationTime(sqlitePath); +}); + +add_task(async function test_360se10_logged_out() { + const alternativeBookmarks = + await Qihoo360seMigrationUtils.getAlternativeBookmarks({ + bookmarksPath: PathUtils.join(parentPath, "Default", "Bookmarks"), + localState: { + sync_login_info: { + filepath: "", + }, + }, + }); + Assert.strictEqual( + alternativeBookmarks.resource, + undefined, + "Should not return the legacy bookmark resource." + ); + Assert.strictEqual( + alternativeBookmarks.path, + loggedOutBackup, + "Should return path to the most recent plain text bookmarks backup." + ); +}); + +add_task(async function test_360se9_logged_in_outdated_sqlite() { + const sqlitePath = getSqlitePath("Default4SE9Test"); + await IOUtils.setModificationTime( + sqlitePath, + new Date("2020-08-18").valueOf() + ); + + const alternativeBookmarks = + await Qihoo360seMigrationUtils.getAlternativeBookmarks({ + bookmarksPath: PathUtils.join(parentPath, "Default4SE9Test", "Bookmarks"), + localState: { + sync_login_info: { + filepath: loggedInPath, + }, + }, + }); + Assert.strictEqual( + alternativeBookmarks.resource, + undefined, + "Should not return the legacy bookmark resource." + ); + Assert.strictEqual( + alternativeBookmarks.path, + PathUtils.join( + parentPath, + "Default4SE9Test", + loggedInPath, + "DailyBackup", + "360sefav_2020_08_28.favdb" + ), + "Should return path to the most recent plain text bookmarks backup." + ); + + await IOUtils.setModificationTime(sqlitePath); +}); + +add_task(async function test_360se9_logged_out() { + const alternativeBookmarks = + await Qihoo360seMigrationUtils.getAlternativeBookmarks({ + bookmarksPath: PathUtils.join(parentPath, "Default4SE9Test", "Bookmarks"), + localState: { + sync_login_info: { + filepath: "", + }, + }, + }); + Assert.strictEqual( + alternativeBookmarks.resource, + undefined, + "Should not return the legacy bookmark resource." + ); + Assert.strictEqual( + alternativeBookmarks.path, + PathUtils.join(parentPath, "Default4SE9Test", "Bookmarks"), + "Should return path to the plain text canonical bookmarks." + ); +}); diff --git a/browser/components/migration/tests/unit/test_360se_bookmarks.js b/browser/components/migration/tests/unit/test_360se_bookmarks.js new file mode 100644 index 0000000000..e4f42d1880 --- /dev/null +++ b/browser/components/migration/tests/unit/test_360se_bookmarks.js @@ -0,0 +1,62 @@ +"use strict"; + +const { CustomizableUI } = ChromeUtils.importESModule( + "resource:///modules/CustomizableUI.sys.mjs" +); + +add_task(async function () { + registerFakePath("AppData", do_get_file("AppData/Roaming/")); + + let migrator = await MigrationUtils.getMigrator("chromium-360se"); + // Sanity check for the source. + Assert.ok(await migrator.isSourceAvailable()); + + let importedToBookmarksToolbar = false; + let itemsSeen = { bookmarks: 0, folders: 0 }; + + let listener = events => { + for (let event of events) { + itemsSeen[ + event.itemType == PlacesUtils.bookmarks.TYPE_FOLDER + ? "folders" + : "bookmarks" + ]++; + if (event.parentGuid == PlacesUtils.bookmarks.toolbarGuid) { + importedToBookmarksToolbar = true; + } + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + let observerNotified = false; + Services.obs.addObserver((aSubject, aTopic, aData) => { + let [toolbar, visibility] = JSON.parse(aData); + Assert.equal( + toolbar, + CustomizableUI.AREA_BOOKMARKS, + "Notification should be received for bookmarks toolbar" + ); + Assert.equal( + visibility, + "true", + "Notification should say to reveal the bookmarks toolbar" + ); + observerNotified = true; + }, "browser-set-toolbar-visibility"); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.BOOKMARKS, { + id: "Default", + }); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + + // Check the bookmarks have been imported to all the expected parents. + Assert.ok(importedToBookmarksToolbar, "Bookmarks imported in the toolbar"); + Assert.equal(itemsSeen.bookmarks, 8, "Should import all bookmarks."); + Assert.equal(itemsSeen.folders, 2, "Should import all folders."); + // Check that the telemetry matches: + Assert.equal( + MigrationUtils._importQuantities.bookmarks, + itemsSeen.bookmarks + itemsSeen.folders, + "Telemetry reporting correct." + ); + Assert.ok(observerNotified, "The observer should be notified upon migration"); +}); diff --git a/browser/components/migration/tests/unit/test_BookmarksFileMigrator.js b/browser/components/migration/tests/unit/test_BookmarksFileMigrator.js new file mode 100644 index 0000000000..ec10097e76 --- /dev/null +++ b/browser/components/migration/tests/unit/test_BookmarksFileMigrator.js @@ -0,0 +1,134 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { BookmarksFileMigrator } = ChromeUtils.importESModule( + "resource:///modules/FileMigrators.sys.mjs" +); + +const { MigrationWizardConstants } = ChromeUtils.importESModule( + "chrome://browser/content/migration/migration-wizard-constants.mjs" +); + +/** + * Tests that the BookmarksFileMigrator properly subclasses FileMigratorBase + * and delegates to BookmarkHTMLUtils or BookmarkJSONUtils. + * + * Normally, we'd override the BookmarkHTMLUtils and BookmarkJSONUtils methods + * in our test here so that we just ensure that they're called with the + * right arguments, rather than testing their behaviour. Unfortunately, both + * BookmarkHTMLUtils and BookmarkJSONUtils are frozen with Object.freeze, which + * prevents sinon from stubbing out any of their methods. Rather than unfreezing + * those objects just for testing, we test the whole flow end-to-end, including + * the import to Places. + */ + +add_setup(() => { + Services.prefs.setBoolPref("browser.migrate.bookmarks-file.enabled", true); + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + Services.prefs.clearUserPref("browser.migrate.bookmarks-file.enabled"); + }); +}); + +/** + * First check that the BookmarksFileMigrator implements the required parts + * of the parent class. + */ +add_task(async function test_BookmarksFileMigrator_members() { + let migrator = new BookmarksFileMigrator(); + Assert.ok( + migrator.constructor.key, + `BookmarksFileMigrator implements static getter 'key'` + ); + + Assert.ok( + migrator.constructor.displayNameL10nID, + `BookmarksFileMigrator implements static getter 'displayNameL10nID'` + ); + + Assert.ok( + migrator.constructor.brandImage, + `BookmarksFileMigrator implements static getter 'brandImage'` + ); + + Assert.ok( + migrator.progressHeaderL10nID, + `BookmarksFileMigrator implements getter 'progressHeaderL10nID'` + ); + + Assert.ok( + migrator.successHeaderL10nID, + `BookmarksFileMigrator implements getter 'successHeaderL10nID'` + ); + + Assert.ok( + await migrator.getFilePickerConfig(), + `BookmarksFileMigrator implements method 'getFilePickerConfig'` + ); + + Assert.ok( + migrator.displayedResourceTypes, + `BookmarksFileMigrator implements getter 'displayedResourceTypes'` + ); + + Assert.ok(migrator.enabled, `BookmarksFileMigrator is enabled`); +}); + +add_task(async function test_BookmarksFileMigrator_HTML() { + let migrator = new BookmarksFileMigrator(); + const EXPECTED_SUCCESS_STATE = { + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .BOOKMARKS_FROM_FILE]: "8 bookmarks", + }; + + const BOOKMARKS_PATH = PathUtils.join( + do_get_cwd().path, + "bookmarks.exported.html" + ); + + let result = await migrator.migrate(BOOKMARKS_PATH); + + Assert.deepEqual( + result, + EXPECTED_SUCCESS_STATE, + "Returned the expected success state." + ); +}); + +add_task(async function test_BookmarksFileMigrator_JSON() { + let migrator = new BookmarksFileMigrator(); + + const EXPECTED_SUCCESS_STATE = { + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .BOOKMARKS_FROM_FILE]: "10 bookmarks", + }; + + const BOOKMARKS_PATH = PathUtils.join( + do_get_cwd().path, + "bookmarks.exported.json" + ); + + let result = await migrator.migrate(BOOKMARKS_PATH); + + Assert.deepEqual( + result, + EXPECTED_SUCCESS_STATE, + "Returned the expected success state." + ); +}); + +add_task(async function test_BookmarksFileMigrator_invalid() { + let migrator = new BookmarksFileMigrator(); + + const INVALID_FILE_PATH = PathUtils.join( + do_get_cwd().path, + "bookmarks.invalid.html" + ); + + await Assert.rejects( + migrator.migrate(INVALID_FILE_PATH), + /Pick another file/ + ); +}); diff --git a/browser/components/migration/tests/unit/test_ChromeMigrationUtils.js b/browser/components/migration/tests/unit/test_ChromeMigrationUtils.js new file mode 100644 index 0000000000..32a8d1b4bc --- /dev/null +++ b/browser/components/migration/tests/unit/test_ChromeMigrationUtils.js @@ -0,0 +1,87 @@ +"use strict"; + +const { ChromeMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/ChromeMigrationUtils.sys.mjs" +); + +// Setup chrome user data path for all platforms. +ChromeMigrationUtils.getDataPath = () => { + return Promise.resolve( + do_get_file("Library/Application Support/Google/Chrome/").path + ); +}; + +add_task(async function test_getExtensionList_function() { + let extensionList = await ChromeMigrationUtils.getExtensionList("Default"); + Assert.equal( + extensionList.length, + 2, + "There should be 2 extensions installed." + ); + Assert.deepEqual( + extensionList.find(extension => extension.id == "fake-extension-1"), + { + id: "fake-extension-1", + name: "Fake Extension 1", + description: "It is the description of fake extension 1.", + }, + "First extension should match expectations." + ); + Assert.deepEqual( + extensionList.find(extension => extension.id == "fake-extension-2"), + { + id: "fake-extension-2", + name: "Fake Extension 2", + // There is no description in the `manifest.json` file of this extension. + description: null, + }, + "Second extension should match expectations." + ); +}); + +add_task(async function test_getExtensionInformation_function() { + let extension = await ChromeMigrationUtils.getExtensionInformation( + "fake-extension-1", + "Default" + ); + Assert.deepEqual( + extension, + { + id: "fake-extension-1", + name: "Fake Extension 1", + description: "It is the description of fake extension 1.", + }, + "Should get the extension information correctly." + ); +}); + +add_task(async function test_getLocaleString_function() { + let name = await ChromeMigrationUtils._getLocaleString( + "__MSG_name__", + "en_US", + "fake-extension-1", + "Default" + ); + Assert.deepEqual( + name, + "Fake Extension 1", + "The value of __MSG_name__ locale key is Fake Extension 1." + ); +}); + +add_task(async function test_isExtensionInstalled_function() { + let isInstalled = await ChromeMigrationUtils.isExtensionInstalled( + "fake-extension-1", + "Default" + ); + Assert.ok(isInstalled, "The fake-extension-1 extension should be installed."); +}); + +add_task(async function test_getLastUsedProfileId_function() { + let profileId = await ChromeMigrationUtils.getLastUsedProfileId(); + Assert.equal( + profileId, + "Default", + "The last used profile ID should be Default." + ); +}); diff --git a/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js b/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js new file mode 100644 index 0000000000..ca75595ea9 --- /dev/null +++ b/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js @@ -0,0 +1,141 @@ +"use strict"; + +const { ChromeMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/ChromeMigrationUtils.sys.mjs" +); + +const SUB_DIRECTORIES = { + win: { + Chrome: ["Google", "Chrome", "User Data"], + Chromium: ["Chromium", "User Data"], + Canary: ["Google", "Chrome SxS", "User Data"], + }, + macosx: { + Chrome: ["Application Support", "Google", "Chrome"], + Chromium: ["Application Support", "Chromium"], + Canary: ["Application Support", "Google", "Chrome Canary"], + }, + linux: { + Chrome: [".config", "google-chrome"], + Chromium: [".config", "chromium"], + Canary: [], + }, +}; + +add_task(async function setup_fakePaths() { + let pathId; + if (AppConstants.platform == "macosx") { + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + pathId = "LocalAppData"; + } else { + pathId = "Home"; + } + + registerFakePath(pathId, do_get_file("chromefiles/", true)); +}); + +add_task(async function test_getDataPath_function() { + let projects = ["Chrome", "Chromium", "Canary"]; + let rootPath = getRootPath(); + + for (let project of projects) { + let subfolders = SUB_DIRECTORIES[AppConstants.platform][project]; + + await IOUtils.makeDirectory(PathUtils.join(rootPath, ...subfolders), { + createAncestor: true, + ignoreExisting: true, + }); + } + + let chromeUserDataPath = await ChromeMigrationUtils.getDataPath("Chrome"); + let chromiumUserDataPath = await ChromeMigrationUtils.getDataPath("Chromium"); + let canaryUserDataPath = await ChromeMigrationUtils.getDataPath("Canary"); + if (AppConstants.platform == "win") { + Assert.equal( + chromeUserDataPath, + PathUtils.join(getRootPath(), "Google", "Chrome", "User Data"), + "Should get the path of Chrome data directory." + ); + Assert.equal( + chromiumUserDataPath, + PathUtils.join(getRootPath(), "Chromium", "User Data"), + "Should get the path of Chromium data directory." + ); + Assert.equal( + canaryUserDataPath, + PathUtils.join(getRootPath(), "Google", "Chrome SxS", "User Data"), + "Should get the path of Canary data directory." + ); + } else if (AppConstants.platform == "macosx") { + Assert.equal( + chromeUserDataPath, + PathUtils.join(getRootPath(), "Application Support", "Google", "Chrome"), + "Should get the path of Chrome data directory." + ); + Assert.equal( + chromiumUserDataPath, + PathUtils.join(getRootPath(), "Application Support", "Chromium"), + "Should get the path of Chromium data directory." + ); + Assert.equal( + canaryUserDataPath, + PathUtils.join( + getRootPath(), + "Application Support", + "Google", + "Chrome Canary" + ), + "Should get the path of Canary data directory." + ); + } else { + Assert.equal( + chromeUserDataPath, + PathUtils.join(getRootPath(), ".config", "google-chrome"), + "Should get the path of Chrome data directory." + ); + Assert.equal( + chromiumUserDataPath, + PathUtils.join(getRootPath(), ".config", "chromium"), + "Should get the path of Chromium data directory." + ); + Assert.equal(canaryUserDataPath, null, "Should get null for Canary."); + } +}); + +add_task(async function test_getExtensionPath_function() { + let extensionPath = await ChromeMigrationUtils.getExtensionPath("Default"); + let expectedPath; + if (AppConstants.platform == "win") { + expectedPath = PathUtils.join( + getRootPath(), + "Google", + "Chrome", + "User Data", + "Default", + "Extensions" + ); + } else if (AppConstants.platform == "macosx") { + expectedPath = PathUtils.join( + getRootPath(), + "Application Support", + "Google", + "Chrome", + "Default", + "Extensions" + ); + } else { + expectedPath = PathUtils.join( + getRootPath(), + ".config", + "google-chrome", + "Default", + "Extensions" + ); + } + Assert.equal( + extensionPath, + expectedPath, + "Should get the path of extensions directory." + ); +}); diff --git a/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path_chromium_snap.js b/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path_chromium_snap.js new file mode 100644 index 0000000000..5011991536 --- /dev/null +++ b/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path_chromium_snap.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ChromeMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/ChromeMigrationUtils.sys.mjs" +); + +const SUB_DIRECTORIES = { + linux: { + Chromium: [".config", "chromium"], + SnapChromium: ["snap", "chromium", "common", "chromium"], + }, +}; + +add_task(async function setup_fakePaths() { + let pathId; + if (AppConstants.platform == "macosx") { + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + pathId = "LocalAppData"; + } else { + pathId = "Home"; + } + + registerFakePath(pathId, do_get_file("chromefiles/", true)); +}); + +add_task(async function test_getDataPath_function() { + let rootPath = getRootPath(); + let chromiumSubFolders = SUB_DIRECTORIES[AppConstants.platform].Chromium; + // must remove normal chromium path + await IOUtils.remove(PathUtils.join(rootPath, ...chromiumSubFolders), { + ignoreAbsent: true, + }); + + let snapChromiumSubFolders = + SUB_DIRECTORIES[AppConstants.platform].SnapChromium; + // must create snap chromium path + await IOUtils.makeDirectory( + PathUtils.join(rootPath, ...snapChromiumSubFolders), + { + createAncestor: true, + ignoreExisting: true, + } + ); + + let chromiumUserDataPath = await ChromeMigrationUtils.getDataPath("Chromium"); + Assert.equal( + chromiumUserDataPath, + PathUtils.join(getRootPath(), ...snapChromiumSubFolders), + "Should get the path of Snap Chromium data directory." + ); +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_bookmarks.js b/browser/components/migration/tests/unit/test_Chrome_bookmarks.js new file mode 100644 index 0000000000..d115cda412 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_bookmarks.js @@ -0,0 +1,205 @@ +"use strict"; + +const { CustomizableUI } = ChromeUtils.importESModule( + "resource:///modules/CustomizableUI.sys.mjs" +); + +const { PlacesUIUtils } = ChromeUtils.importESModule( + "resource:///modules/PlacesUIUtils.sys.mjs" +); + +let rootDir = do_get_file("chromefiles/", true); + +add_task(async function setup_fakePaths() { + let pathId; + if (AppConstants.platform == "macosx") { + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + pathId = "LocalAppData"; + } else { + pathId = "Home"; + } + registerFakePath(pathId, rootDir); +}); + +add_task(async function setup_initialBookmarks() { + let bookmarks = []; + for (let i = 0; i < PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE + 1; i++) { + bookmarks.push({ url: "https://example.com/" + i, title: "" + i }); + } + + // Ensure we have enough items in both the menu and toolbar to trip creating a "from" folder. + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: bookmarks, + }); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: bookmarks, + }); +}); + +async function testBookmarks(migratorKey, subDirs) { + if (AppConstants.platform == "macosx") { + subDirs.unshift("Application Support"); + } else if (AppConstants.platform == "win") { + subDirs.push("User Data"); + } else { + subDirs.unshift(".config"); + } + + let target = rootDir.clone(); + + // Pretend this is the default profile + while (subDirs.length) { + target.append(subDirs.shift()); + } + + let localStatePath = PathUtils.join(target.path, "Local State"); + await IOUtils.writeJSON(localStatePath, []); + + target.append("Default"); + + await IOUtils.makeDirectory(target.path, { + createAncestor: true, + ignoreExisting: true, + }); + + // Copy Favicons database into Default profile + const sourcePath = do_get_file( + "AppData/Local/Google/Chrome/User Data/Default/Favicons" + ).path; + await IOUtils.copy(sourcePath, target.path); + + // Get page url for each favicon + let faviconURIs = await MigrationUtils.getRowsFromDBWithoutLocks( + sourcePath, + "Chrome Bookmark Favicons", + `select page_url from icon_mapping` + ); + + target.append("Bookmarks"); + await IOUtils.remove(target.path, { ignoreAbsent: true }); + + let bookmarksData = createChromeBookmarkStructure(); + await IOUtils.writeJSON(target.path, bookmarksData); + + let migrator = await MigrationUtils.getMigrator(migratorKey); + Assert.ok(await migrator.hasPermissions(), "Has permissions"); + // Sanity check for the source. + Assert.ok(await migrator.isSourceAvailable()); + + let itemsSeen = { bookmarks: 0, folders: 0 }; + let listener = events => { + for (let event of events) { + itemsSeen[ + event.itemType == PlacesUtils.bookmarks.TYPE_FOLDER + ? "folders" + : "bookmarks" + ]++; + } + }; + + PlacesUtils.observers.addListener(["bookmark-added"], listener); + const PROFILE = { + id: "Default", + name: "Default", + }; + let observerNotified = false; + Services.obs.addObserver((aSubject, aTopic, aData) => { + let [toolbar, visibility] = JSON.parse(aData); + Assert.equal( + toolbar, + CustomizableUI.AREA_BOOKMARKS, + "Notification should be received for bookmarks toolbar" + ); + Assert.equal( + visibility, + "true", + "Notification should say to reveal the bookmarks toolbar" + ); + observerNotified = true; + }, "browser-set-toolbar-visibility"); + const initialToolbarCount = await getFolderItemCount( + PlacesUtils.bookmarks.toolbarGuid + ); + const initialUnfiledCount = await getFolderItemCount( + PlacesUtils.bookmarks.unfiledGuid + ); + const initialmenuCount = await getFolderItemCount( + PlacesUtils.bookmarks.menuGuid + ); + + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.BOOKMARKS, + PROFILE + ); + const postToolbarCount = await getFolderItemCount( + PlacesUtils.bookmarks.toolbarGuid + ); + const postUnfiledCount = await getFolderItemCount( + PlacesUtils.bookmarks.unfiledGuid + ); + const postmenuCount = await getFolderItemCount( + PlacesUtils.bookmarks.menuGuid + ); + + Assert.equal( + postUnfiledCount - initialUnfiledCount, + 210, + "Should have seen 210 items in unsorted bookmarks" + ); + Assert.equal( + postToolbarCount - initialToolbarCount, + 105, + "Should have seen 105 items in toolbar" + ); + Assert.equal( + postmenuCount - initialmenuCount, + 0, + "Should have seen 0 items in menu toolbar" + ); + + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + + Assert.equal(itemsSeen.bookmarks, 300, "Should have seen 300 bookmarks."); + Assert.equal(itemsSeen.folders, 15, "Should have seen 15 folders."); + Assert.equal( + MigrationUtils._importQuantities.bookmarks, + itemsSeen.bookmarks + itemsSeen.folders, + "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); +} + +add_task(async function test_Chrome() { + // Expire all favicons before the test to make sure favicons are imported + PlacesUtils.favicons.expireAllFavicons(); + let subDirs = + AppConstants.platform == "linux" ? ["google-chrome"] : ["Google", "Chrome"]; + await testBookmarks("chrome", subDirs); +}); + +add_task(async function test_ChromiumEdge() { + PlacesUtils.favicons.expireAllFavicons(); + if (AppConstants.platform == "linux") { + // Edge isn't available on Linux. + return; + } + let subDirs = + AppConstants.platform == "macosx" + ? ["Microsoft Edge"] + : ["Microsoft", "Edge"]; + await testBookmarks("chromium-edge", subDirs); +}); + +async function getFolderItemCount(guid) { + let results = await PlacesUtils.promiseBookmarksTree(guid); + + return results.itemsCount; +} diff --git a/browser/components/migration/tests/unit/test_Chrome_corrupt_history.js b/browser/components/migration/tests/unit/test_Chrome_corrupt_history.js new file mode 100644 index 0000000000..31541f9fdb --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_corrupt_history.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let rootDir = do_get_file("chromefiles/", true); + +const SOURCE_PROFILE_DIR = "Library/Application Support/Google/Chrome/Default/"; +const PROFILE = { + id: "Default", + name: "Person 1", +}; + +add_setup(async function setup_fake_paths() { + let pathId; + if (AppConstants.platform == "macosx") { + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + pathId = "LocalAppData"; + } else { + pathId = "Home"; + } + registerFakePath(pathId, rootDir); + + let file = do_get_file(`${SOURCE_PROFILE_DIR}HistoryCorrupt`); + file.copyTo(file.parent, "History"); + + registerCleanupFunction(() => { + let historyFile = do_get_file(`${SOURCE_PROFILE_DIR}History`, true); + try { + historyFile.remove(false); + } catch (ex) { + // It is ok if this doesn't exist. + if (ex.result != Cr.NS_ERROR_FILE_NOT_FOUND) { + throw ex; + } + } + }); + + let subDirs = + AppConstants.platform == "linux" ? ["google-chrome"] : ["Google", "Chrome"]; + + if (AppConstants.platform == "macosx") { + subDirs.unshift("Application Support"); + } else if (AppConstants.platform == "win") { + subDirs.push("User Data"); + } else { + subDirs.unshift(".config"); + } + + let target = rootDir.clone(); + // Pretend this is the default profile + subDirs.push("Default"); + while (subDirs.length) { + target.append(subDirs.shift()); + } + + await IOUtils.makeDirectory(target.path, { + createAncestor: true, + ignoreExisting: true, + }); + + target.append("Bookmarks"); + await IOUtils.remove(target.path, { ignoreAbsent: true }); + + let bookmarksData = createChromeBookmarkStructure(); + await IOUtils.writeJSON(target.path, bookmarksData); +}); + +add_task(async function test_corrupt_history() { + let migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok(await migrator.isSourceAvailable()); + + let data = await migrator.getMigrateData(PROFILE); + Assert.ok( + data & MigrationUtils.resourceTypes.BOOKMARKS, + "Bookmarks resource available." + ); + Assert.ok( + !(data & MigrationUtils.resourceTypes.HISTORY), + "Corrupt history resource unavailable." + ); +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_credit_cards.js b/browser/components/migration/tests/unit/test_Chrome_credit_cards.js new file mode 100644 index 0000000000..5c4d3517d2 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_credit_cards.js @@ -0,0 +1,239 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* global structuredClone */ + +const PROFILE = { + id: "Default", + name: "Default", +}; + +const PAYMENT_METHODS = [ + { + name_on_card: "Name Name", + card_number: "4532962432748929", // Visa + expiration_month: 3, + expiration_year: 2027, + }, + { + name_on_card: "Name Name Name", + card_number: "5359908373796416", // Mastercard + expiration_month: 5, + expiration_year: 2028, + }, + { + name_on_card: "Name", + card_number: "346624461807588", // AMEX + expiration_month: 4, + expiration_year: 2026, + }, +]; + +let OSKeyStoreTestUtils; +add_setup(async function os_key_store_setup() { + ({ OSKeyStoreTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/OSKeyStoreTestUtils.sys.mjs" + )); + OSKeyStoreTestUtils.setup(); + registerCleanupFunction(async function cleanup() { + await OSKeyStoreTestUtils.cleanup(); + }); +}); + +let rootDir = do_get_file("chromefiles/", true); + +function checkCardsAreEqual(importedCard, testCard, id) { + const CC_NUMBER_RE = /^(\*+)(.{4})$/; + + Assert.equal( + importedCard["cc-name"], + testCard.name_on_card, + "The two logins ID " + id + " have the same name on card" + ); + + let matches = CC_NUMBER_RE.exec(importedCard["cc-number"]); + Assert.notEqual(matches, null); + Assert.equal(importedCard["cc-number"].length, testCard.card_number.length); + Assert.equal(testCard.card_number.endsWith(matches[2]), true); + Assert.notEqual(importedCard["cc-number-encrypted"], ""); + + Assert.equal( + importedCard["cc-exp-month"], + testCard.expiration_month, + "The two logins ID " + id + " have the same expiration month" + ); + Assert.equal( + importedCard["cc-exp-year"], + testCard.expiration_year, + "The two logins ID " + id + " have the same expiration year" + ); +} + +add_task(async function setup_fakePaths() { + let pathId; + if (AppConstants.platform == "macosx") { + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + pathId = "LocalAppData"; + } else { + pathId = "Home"; + } + registerFakePath(pathId, rootDir); +}); + +add_task(async function test_credit_cards() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + todo_check_true( + OSKeyStoreTestUtils.canTestOSKeyStoreLogin(), + "Cannot test OS key store login on official builds." + ); + return; + } + + let loginCrypto; + let profilePathSegments; + + let mockMacOSKeychain = { + passphrase: "bW96aWxsYWZpcmVmb3g=", + serviceName: "TESTING Chrome Safe Storage", + accountName: "TESTING Chrome", + }; + if (AppConstants.platform == "macosx") { + let { ChromeMacOSLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeMacOSLoginCrypto.sys.mjs" + ); + loginCrypto = new ChromeMacOSLoginCrypto( + mockMacOSKeychain.serviceName, + mockMacOSKeychain.accountName, + mockMacOSKeychain.passphrase + ); + profilePathSegments = [ + "Application Support", + "Google", + "Chrome", + "Default", + ]; + } else if (AppConstants.platform == "win") { + let { ChromeWindowsLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeWindowsLoginCrypto.sys.mjs" + ); + loginCrypto = new ChromeWindowsLoginCrypto("Chrome"); + profilePathSegments = ["Google", "Chrome", "User Data", "Default"]; + } else { + throw new Error("Not implemented"); + } + + let target = rootDir.clone(); + let defaultFolderPath = PathUtils.join(target.path, ...profilePathSegments); + let webDataPath = PathUtils.join(defaultFolderPath, "Web Data"); + let localStatePath = defaultFolderPath.replace("Default", ""); + + await IOUtils.makeDirectory(defaultFolderPath, { + createAncestor: true, + ignoreExisting: true, + }); + + // Copy Web Data database into Default profile + const sourcePathWebData = do_get_file( + "AppData/Local/Google/Chrome/User Data/Default/Web Data" + ).path; + await IOUtils.copy(sourcePathWebData, webDataPath); + + const sourcePathLocalState = do_get_file( + "AppData/Local/Google/Chrome/User Data/Local State" + ).path; + await IOUtils.copy(sourcePathLocalState, localStatePath); + + let dbConn = await Sqlite.openConnection({ path: webDataPath }); + + for (let card of PAYMENT_METHODS) { + let encryptedCardNumber = await loginCrypto.encryptData(card.card_number); + let cardNumberEncryptedValue = new Uint8Array( + loginCrypto.stringToArray(encryptedCardNumber) + ); + + let cardCopy = structuredClone(card); + + cardCopy.card_number_encrypted = cardNumberEncryptedValue; + delete cardCopy.card_number; + + await dbConn.execute( + `INSERT INTO credit_cards + (name_on_card, card_number_encrypted, expiration_month, expiration_year) + VALUES (:name_on_card, :card_number_encrypted, :expiration_month, :expiration_year) + `, + cardCopy + ); + } + + await dbConn.close(); + + let migrator = await MigrationUtils.getMigrator("chrome"); + if (AppConstants.platform == "macosx") { + Object.assign(migrator, { + _keychainServiceName: mockMacOSKeychain.serviceName, + _keychainAccountName: mockMacOSKeychain.accountName, + _keychainMockPassphrase: mockMacOSKeychain.passphrase, + }); + } + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + Services.prefs.setBoolPref( + "browser.migrate.chrome.payment_methods.enabled", + false + ); + Assert.ok( + !( + (await migrator.getMigrateData(PROFILE)) & + MigrationUtils.resourceTypes.PAYMENT_METHODS + ), + "Should be able to disable migrating payment methods" + ); + // Clear the cached resources now so that a re-check for payment methods + // will look again. + delete migrator._resourcesByProfile[PROFILE.id]; + + Services.prefs.setBoolPref( + "browser.migrate.chrome.payment_methods.enabled", + true + ); + + Assert.ok( + (await migrator.getMigrateData(PROFILE)) & + MigrationUtils.resourceTypes.PAYMENT_METHODS, + "Should be able to enable migrating payment methods" + ); + + let { formAutofillStorage } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillStorage.sys.mjs" + ); + await formAutofillStorage.initialize(); + + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.PAYMENT_METHODS, + PROFILE + ); + + let cards = await formAutofillStorage.creditCards.getAll(); + + Assert.equal( + cards.length, + PAYMENT_METHODS.length, + "Check there are still the same number of credit cards after re-importing the data" + ); + Assert.equal( + cards.length, + MigrationUtils._importQuantities.cards, + "Check telemetry matches the actual import." + ); + + for (let i = 0; i < PAYMENT_METHODS.length; i++) { + checkCardsAreEqual(cards[i], PAYMENT_METHODS[i], i + 1); + } +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_extensions.js b/browser/components/migration/tests/unit/test_Chrome_extensions.js new file mode 100644 index 0000000000..7aa7a94194 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_extensions.js @@ -0,0 +1,165 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AMBrowserExtensionsImport, AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); +const { ChromeMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/ChromeMigrationUtils.sys.mjs" +); +const { ChromeProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/ChromeProfileMigrator.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +const TEST_SERVER = AddonTestUtils.createHttpServer({ hosts: ["example.com"] }); + +const PROFILE = { + id: "Default", + name: "Person 1", +}; + +const IMPORTED_ADDON_1 = { + name: "A Firefox extension", + version: "1.0", + browser_specific_settings: { gecko: { id: "some-ff@extension" } }, +}; + +// Setup chrome user data path for all platforms. +ChromeMigrationUtils.getDataPath = () => { + return Promise.resolve( + do_get_file("Library/Application Support/Google/Chrome/").path + ); +}; + +const mockAddonRepository = ({ addons = [] } = {}) => { + return { + async getMappedAddons(browserID, extensionIDs) { + Assert.equal(browserID, "chrome", "expected browser ID"); + // Sort extension IDs to have a predictable order. + extensionIDs.sort(); + Assert.deepEqual( + extensionIDs, + ["fake-extension-1", "fake-extension-2"], + "expected an array of 2 extension IDs" + ); + return Promise.resolve({ + addons, + matchedIDs: [], + unmatchedIDs: [], + }); + }, + }; +}; + +add_setup(async function setup() { + await AddonTestUtils.promiseStartupManager(); + + // Create a Firefox XPI that we can use during the import. + const xpi = AddonTestUtils.createTempWebExtensionFile({ + manifest: IMPORTED_ADDON_1, + }); + TEST_SERVER.registerFile(`/addons/addon-1.xpi`, xpi); + + // Override the `AddonRepository` in `AMBrowserExtensionsImport` with our own + // mock so that we control the add-ons that are mapped to the list of Chrome + // extension IDs. + const addons = [ + { + id: IMPORTED_ADDON_1.browser_specific_settings.gecko.id, + name: IMPORTED_ADDON_1.name, + version: IMPORTED_ADDON_1.version, + sourceURI: Services.io.newURI(`http://example.com/addons/addon-1.xpi`), + icons: {}, + }, + ]; + AMBrowserExtensionsImport._addonRepository = mockAddonRepository({ addons }); + + registerCleanupFunction(() => { + // Clear the add-on repository override. + AMBrowserExtensionsImport._addonRepository = null; + }); +}); + +add_task( + { pref_set: [["browser.migrate.chrome.extensions.enabled", true]] }, + async function test_import_extensions() { + const migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + let promiseTopic = TestUtils.topicObserved( + "webextension-imported-addons-pending" + ); + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.EXTENSIONS, + PROFILE, + true + ); + await promiseTopic; + // When this property is `true`, the UI should show a badge on the appmenu + // button, and the user can finalize the import later. + Assert.ok( + AMBrowserExtensionsImport.canCompleteOrCancelInstalls, + "expected some add-ons to have been imported" + ); + + // Let's actually complete the import programatically below. + promiseTopic = TestUtils.topicObserved( + "webextension-imported-addons-complete" + ); + await AMBrowserExtensionsImport.completeInstalls(); + await Promise.all([ + AddonTestUtils.promiseInstallEvent("onInstallEnded"), + promiseTopic, + ]); + + // The add-on should be installed and therefore it can be uninstalled. + const addon = await AddonManager.getAddonByID( + IMPORTED_ADDON_1.browser_specific_settings.gecko.id + ); + await addon.uninstall(); + } +); + +/** + * Test that, for now at least, the extension resource type is only made + * available for Chrome and none of the derivitive browsers. + */ +add_task( + { pref_set: [["browser.migrate.chrome.extensions.enabled", true]] }, + async function test_only_chrome_migrates_extensions() { + for (const key of MigrationUtils.availableMigratorKeys) { + let migrator = await MigrationUtils.getMigrator(key); + + if (migrator instanceof ChromeProfileMigrator && key != "chrome") { + info("Testing migrator with key " + key); + Assert.ok( + await migrator.isSourceAvailable(), + "First check the source exists" + ); + let resourceTypes = await migrator.getMigrateData(PROFILE); + Assert.ok( + !(resourceTypes & MigrationUtils.resourceTypes.EXTENSIONS), + "Should not offer the extension resource type" + ); + } + } + } +); diff --git a/browser/components/migration/tests/unit/test_Chrome_formdata.js b/browser/components/migration/tests/unit/test_Chrome_formdata.js new file mode 100644 index 0000000000..1dc411cb14 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_formdata.js @@ -0,0 +1,118 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { FormHistory } = ChromeUtils.importESModule( + "resource://gre/modules/FormHistory.sys.mjs" +); + +let rootDir = do_get_file("chromefiles/", true); + +add_setup(async function setup_fakePaths() { + let pathId; + if (AppConstants.platform == "macosx") { + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + pathId = "LocalAppData"; + } else { + pathId = "Home"; + } + registerFakePath(pathId, rootDir); +}); + +/** + * This function creates a testing database in the default profile, + * populates it with 10 example data entries,migrates the database, + * and then searches for each entry to ensure it exists in the FormHistory. + * + * @async + * @param {string} migratorKey + * A string that identifies the type of migrator object to be retrieved. + * @param {Array} subDirs + * An array of strings that specifies the subdirectories for the target profile directory. + * @returns {Promise} + * A Promise that resolves when the migration is completed. + */ +async function testFormdata(migratorKey, subDirs) { + if (AppConstants.platform == "macosx") { + subDirs.unshift("Application Support"); + } else if (AppConstants.platform == "win") { + subDirs.push("User Data"); + } else { + subDirs.unshift(".config"); + } + + let target = rootDir.clone(); + // Pretend this is the default profile + subDirs.push("Default"); + while (subDirs.length) { + target.append(subDirs.shift()); + } + + await IOUtils.makeDirectory(target.path, { + createAncestor: true, + ignoreExisting: true, + }); + + target.append("Web Data"); + await IOUtils.remove(target.path, { ignoreAbsent: true }); + + // Clear any search history results + await FormHistory.update({ op: "remove" }); + + let dbConn = await Sqlite.openConnection({ path: target.path }); + + await dbConn.execute( + `CREATE TABLE "autofill" (name VARCHAR, value VARCHAR, value_lower VARCHAR, date_created INTEGER DEFAULT 0, date_last_used INTEGER DEFAULT 0, count INTEGER DEFAULT 1, PRIMARY KEY (name, value))` + ); + for (let i = 0; i < 10; i++) { + await dbConn.execute( + `INSERT INTO autofill VALUES (:name, :value, :value_lower, :date_created, :date_last_used, :count)`, + { + name: `name${i}`, + value: `example${i}`, + value_lower: `example${i}`, + date_created: Math.round(Date.now() / 1000) - i * 10000, + date_last_used: Date.now(), + count: i, + } + ); + } + await dbConn.close(); + + let migrator = await MigrationUtils.getMigrator(migratorKey); + // Sanity check for the source. + Assert.ok(await migrator.isSourceAvailable()); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.FORMDATA, { + id: "Default", + name: "Person 1", + }); + + for (let i = 0; i < 10; i++) { + let results = await FormHistory.search(["fieldname", "value"], { + fieldname: `name${i}`, + value: `example${i}`, + }); + Assert.ok(results.length, `Should have item${i} in FormHistory`); + } +} + +add_task(async function test_Chrome() { + let subDirs = + AppConstants.platform == "linux" ? ["google-chrome"] : ["Google", "Chrome"]; + await testFormdata("chrome", subDirs); +}); + +add_task(async function test_ChromiumEdge() { + if (AppConstants.platform == "linux") { + // Edge isn't available on Linux. + return; + } + let subDirs = + AppConstants.platform == "macosx" + ? ["Microsoft Edge"] + : ["Microsoft", "Edge"]; + await testFormdata("chromium-edge", subDirs); +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_history.js b/browser/components/migration/tests/unit/test_Chrome_history.js new file mode 100644 index 0000000000..c88a6380c2 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_history.js @@ -0,0 +1,206 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ChromeMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/ChromeMigrationUtils.sys.mjs" +); + +const SOURCE_PROFILE_DIR = "Library/Application Support/Google/Chrome/Default/"; + +const PROFILE = { + id: "Default", + name: "Person 1", +}; + +/** + * TEST_URLS reflects the data stored in '${SOURCE_PROFILE_DIR}HistoryMaster'. + * The main object reflects the data in the 'urls' table. The visits property + * reflects the associated data in the 'visits' table. + */ +const TEST_URLS = [ + { + id: 1, + url: "http://example.com/", + title: "test", + visit_count: 1, + typed_count: 0, + last_visit_time: 13193151310368000, + hidden: 0, + visits: [ + { + id: 1, + url: 1, + visit_time: 13193151310368000, + from_visit: 0, + transition: 805306370, + segment_id: 0, + visit_duration: 10745006, + incremented_omnibox_typed_score: 0, + }, + ], + }, + { + id: 2, + url: "http://invalid.com/", + title: "test2", + visit_count: 1, + typed_count: 0, + last_visit_time: 13193154948901000, + hidden: 0, + visits: [ + { + id: 2, + url: 2, + visit_time: 13193154948901000, + from_visit: 0, + transition: 805306376, + segment_id: 0, + visit_duration: 6568270, + incremented_omnibox_typed_score: 0, + }, + ], + }, +]; + +async function setVisitTimes(time) { + let loginDataFile = do_get_file(`${SOURCE_PROFILE_DIR}History`); + let dbConn = await Sqlite.openConnection({ path: loginDataFile.path }); + + await dbConn.execute(`UPDATE urls SET last_visit_time = :last_visit_time`, { + last_visit_time: time, + }); + await dbConn.execute(`UPDATE visits SET visit_time = :visit_time`, { + visit_time: time, + }); + + await dbConn.close(); +} + +function setExpectedVisitTimes(time) { + for (let urlInfo of TEST_URLS) { + urlInfo.last_visit_time = time; + urlInfo.visits[0].visit_time = time; + } +} + +function assertEntryMatches(entry, urlInfo, dateWasInFuture = false) { + info(`Checking url: ${urlInfo.url}`); + Assert.ok(entry, `Should have stored an entry`); + + Assert.equal(entry.url, urlInfo.url, "Should have the correct URL"); + Assert.equal(entry.title, urlInfo.title, "Should have the correct title"); + Assert.equal( + entry.visits.length, + urlInfo.visits.length, + "Should have the correct number of visits" + ); + + for (let index in urlInfo.visits) { + Assert.equal( + entry.visits[index].transition, + PlacesUtils.history.TRANSITIONS.LINK, + "Should have Link type transition" + ); + + if (dateWasInFuture) { + Assert.lessOrEqual( + entry.visits[index].date.getTime(), + new Date().getTime(), + "Should have moved the date to no later than the current date." + ); + } else { + Assert.equal( + entry.visits[index].date.getTime(), + ChromeMigrationUtils.chromeTimeToDate( + urlInfo.visits[index].visit_time, + new Date() + ).getTime(), + "Should have the correct date" + ); + } + } +} + +function setupHistoryFile() { + removeHistoryFile(); + let file = do_get_file(`${SOURCE_PROFILE_DIR}HistoryMaster`); + file.copyTo(file.parent, "History"); +} + +function removeHistoryFile() { + let file = do_get_file(`${SOURCE_PROFILE_DIR}History`, true); + try { + file.remove(false); + } catch (ex) { + // It is ok if this doesn't exist. + if (ex.result != Cr.NS_ERROR_FILE_NOT_FOUND) { + throw ex; + } + } +} + +add_task(async function setup() { + registerFakePath("ULibDir", do_get_file("Library/")); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + removeHistoryFile(); + }); +}); + +add_task(async function test_import() { + setupHistoryFile(); + await PlacesUtils.history.clear(); + // Update to ~10 days ago since the date can't be too old or Places may expire it. + const pastDate = new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 10); + const pastChromeTime = ChromeMigrationUtils.dateToChromeTime(pastDate); + await setVisitTimes(pastChromeTime); + setExpectedVisitTimes(pastChromeTime); + + let migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.HISTORY, + PROFILE + ); + + for (let urlInfo of TEST_URLS) { + let entry = await PlacesUtils.history.fetch(urlInfo.url, { + includeVisits: true, + }); + assertEntryMatches(entry, urlInfo); + } +}); + +add_task(async function test_import_future_date() { + setupHistoryFile(); + await PlacesUtils.history.clear(); + const futureDate = new Date().getTime() + 6000 * 60 * 24; + await setVisitTimes(ChromeMigrationUtils.dateToChromeTime(futureDate)); + + let migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.HISTORY, + PROFILE + ); + + for (let urlInfo of TEST_URLS) { + let entry = await PlacesUtils.history.fetch(urlInfo.url, { + includeVisits: true, + }); + assertEntryMatches(entry, urlInfo, true); + } +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_passwords.js b/browser/components/migration/tests/unit/test_Chrome_passwords.js new file mode 100644 index 0000000000..374b697c75 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_passwords.js @@ -0,0 +1,373 @@ +"use strict"; + +const PROFILE = { + id: "Default", + name: "Person 1", +}; + +const TEST_LOGINS = [ + { + id: 1, // id of the row in the chrome login db + username: "username", + password: "password", + origin: "https://c9.io", + formActionOrigin: "https://c9.io", + httpRealm: null, + usernameField: "inputEmail", + passwordField: "inputPassword", + timeCreated: 1437418416037, + timePasswordChanged: 1437418416037, + timesUsed: 1, + }, + { + id: 2, + username: "username@gmail.com", + password: "password2", + origin: "https://accounts.google.com", + formActionOrigin: "https://accounts.google.com", + httpRealm: null, + usernameField: "Email", + passwordField: "Passwd", + timeCreated: 1437418446598, + timePasswordChanged: 1437418446598, + timesUsed: 6, + }, + { + id: 3, + username: "username", + password: "password3", + origin: "https://www.facebook.com", + formActionOrigin: "https://www.facebook.com", + httpRealm: null, + usernameField: "email", + passwordField: "pass", + timeCreated: 1437418478851, + timePasswordChanged: 1437418478851, + timesUsed: 1, + }, + { + id: 4, + username: "user", + password: "اقرأPÀßwörd", + origin: "http://httpbin.org", + formActionOrigin: null, + httpRealm: "me@kennethreitz.com", // Digest auth. + usernameField: "", + passwordField: "", + timeCreated: 1437787462368, + timePasswordChanged: 1437787462368, + timesUsed: 1, + }, + { + id: 5, + username: "buser", + password: "bpassword", + origin: "http://httpbin.org", + formActionOrigin: null, + httpRealm: "Fake Realm", // Basic auth. + usernameField: "", + passwordField: "", + timeCreated: 1437787539233, + timePasswordChanged: 1437787539233, + timesUsed: 1, + }, + { + id: 6, + username: "username", + password: "password6", + origin: "https://www.example.com", + formActionOrigin: "", // NULL `action_url` + httpRealm: null, + usernameField: "", + passwordField: "pass", + timeCreated: 1557291348878, + timePasswordChanged: 1557291348878, + timesUsed: 1, + }, + { + id: 7, + version: "v10", + username: "username", + password: "password", + origin: "https://v10.io", + formActionOrigin: "https://v10.io", + httpRealm: null, + usernameField: "inputEmail", + passwordField: "inputPassword", + timeCreated: 1437418416037, + timePasswordChanged: 1437418416037, + timesUsed: 1, + }, +]; + +var loginCrypto; +var dbConn; + +async function promiseSetPassword(login) { + const encryptedString = await loginCrypto.encryptData( + login.password, + login.version + ); + info(`promiseSetPassword: ${encryptedString}`); + const passwordValue = new Uint8Array( + loginCrypto.stringToArray(encryptedString) + ); + return dbConn.execute( + `UPDATE logins + SET password_value = :password_value + WHERE rowid = :rowid + `, + { password_value: passwordValue, rowid: login.id } + ); +} + +function checkLoginsAreEqual(passwordManagerLogin, chromeLogin, id) { + passwordManagerLogin.QueryInterface(Ci.nsILoginMetaInfo); + + Assert.equal( + passwordManagerLogin.username, + chromeLogin.username, + "The two logins ID " + id + " have the same username" + ); + Assert.equal( + passwordManagerLogin.password, + chromeLogin.password, + "The two logins ID " + id + " have the same password" + ); + Assert.equal( + passwordManagerLogin.origin, + chromeLogin.origin, + "The two logins ID " + id + " have the same origin" + ); + Assert.equal( + passwordManagerLogin.formActionOrigin, + chromeLogin.formActionOrigin, + "The two logins ID " + id + " have the same formActionOrigin" + ); + Assert.equal( + passwordManagerLogin.httpRealm, + chromeLogin.httpRealm, + "The two logins ID " + id + " have the same httpRealm" + ); + Assert.equal( + passwordManagerLogin.usernameField, + chromeLogin.usernameField, + "The two logins ID " + id + " have the same usernameElement" + ); + Assert.equal( + passwordManagerLogin.passwordField, + chromeLogin.passwordField, + "The two logins ID " + id + " have the same passwordElement" + ); + Assert.equal( + passwordManagerLogin.timeCreated, + chromeLogin.timeCreated, + "The two logins ID " + id + " have the same timeCreated" + ); + Assert.equal( + passwordManagerLogin.timePasswordChanged, + chromeLogin.timePasswordChanged, + "The two logins ID " + id + " have the same timePasswordChanged" + ); + Assert.equal( + passwordManagerLogin.timesUsed, + chromeLogin.timesUsed, + "The two logins ID " + id + " have the same timesUsed" + ); +} + +function generateDifferentLogin(login) { + const newLogin = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance( + Ci.nsILoginInfo + ); + + newLogin.init( + login.origin, + login.formActionOrigin, + null, + login.username, + login.password + 1, + login.usernameField + 1, + login.passwordField + 1 + ); + newLogin.QueryInterface(Ci.nsILoginMetaInfo); + newLogin.timeCreated = login.timeCreated + 1; + newLogin.timePasswordChanged = login.timePasswordChanged + 1; + newLogin.timesUsed = login.timesUsed + 1; + return newLogin; +} + +add_task(async function setup() { + let dirSvcPath; + let pathId; + let profilePathSegments; + + // Use a mock service and account name to avoid a Keychain auth. prompt that + // would block the test from finishing if Chrome has already created a matching + // Keychain entry. This allows us to still exercise the keychain lookup code. + // The mock encryption passphrase is used when the Keychain item isn't found. + const mockMacOSKeychain = { + passphrase: "bW96aWxsYWZpcmVmb3g=", + serviceName: "TESTING Chrome Safe Storage", + accountName: "TESTING Chrome", + }; + if (AppConstants.platform == "macosx") { + const { ChromeMacOSLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeMacOSLoginCrypto.sys.mjs" + ); + loginCrypto = new ChromeMacOSLoginCrypto( + mockMacOSKeychain.serviceName, + mockMacOSKeychain.accountName, + mockMacOSKeychain.passphrase + ); + dirSvcPath = "Library/"; + pathId = "ULibDir"; + profilePathSegments = [ + "Application Support", + "Google", + "Chrome", + "Default", + "Login Data", + ]; + } else if (AppConstants.platform == "win") { + const { ChromeWindowsLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeWindowsLoginCrypto.sys.mjs" + ); + loginCrypto = new ChromeWindowsLoginCrypto("Chrome"); + dirSvcPath = "AppData/Local/"; + pathId = "LocalAppData"; + profilePathSegments = [ + "Google", + "Chrome", + "User Data", + "Default", + "Login Data", + ]; + } else { + throw new Error("Not implemented"); + } + const dirSvcFile = do_get_file(dirSvcPath); + registerFakePath(pathId, dirSvcFile); + + info(PathUtils.join(dirSvcFile.path, ...profilePathSegments)); + const loginDataFilePath = PathUtils.join( + dirSvcFile.path, + ...profilePathSegments + ); + dbConn = await Sqlite.openConnection({ path: loginDataFilePath }); + + if (AppConstants.platform == "macosx") { + const migrator = await MigrationUtils.getMigrator("chrome"); + Object.assign(migrator, { + _keychainServiceName: mockMacOSKeychain.serviceName, + _keychainAccountName: mockMacOSKeychain.accountName, + _keychainMockPassphrase: mockMacOSKeychain.passphrase, + }); + } + + registerCleanupFunction(() => { + Services.logins.removeAllUserFacingLogins(); + if (loginCrypto.finalize) { + loginCrypto.finalize(); + } + return dbConn.close(); + }); +}); + +add_task(async function test_importIntoEmptyDB() { + for (const login of TEST_LOGINS) { + await promiseSetPassword(login); + } + + const migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + let logins = await Services.logins.getAllLogins(); + Assert.equal(logins.length, 0, "There are no logins initially"); + + // Migrate the logins. + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.PASSWORDS, + PROFILE, + true + ); + + logins = await Services.logins.getAllLogins(); + Assert.equal( + logins.length, + TEST_LOGINS.length, + "Check login count after importing the data" + ); + Assert.equal( + logins.length, + MigrationUtils._importQuantities.logins, + "Check telemetry matches the actual import." + ); + + for (let i = 0; i < TEST_LOGINS.length; i++) { + checkLoginsAreEqual(logins[i], TEST_LOGINS[i], i + 1); + } +}); + +// Test that existing logins for the same primary key don't get overwritten +add_task(async function test_importExistingLogins() { + const migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + Services.logins.removeAllUserFacingLogins(); + let logins = await Services.logins.getAllLogins(); + Assert.equal( + logins.length, + 0, + "There are no logins after removing all of them" + ); + + const newLogins = []; + + // Create 3 new logins that are different but where the key properties are still the same. + for (let i = 0; i < 3; i++) { + newLogins.push(generateDifferentLogin(TEST_LOGINS[i])); + await Services.logins.addLoginAsync(newLogins[i]); + } + + logins = await Services.logins.getAllLogins(); + Assert.equal( + logins.length, + newLogins.length, + "Check login count after the insertion" + ); + + for (let i = 0; i < newLogins.length; i++) { + checkLoginsAreEqual(logins[i], newLogins[i], i + 1); + } + // Migrate the logins. + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.PASSWORDS, + PROFILE, + true + ); + + logins = await Services.logins.getAllLogins(); + Assert.equal( + logins.length, + TEST_LOGINS.length, + "Check there are still the same number of logins after re-importing the data" + ); + Assert.equal( + logins.length, + MigrationUtils._importQuantities.logins, + "Check telemetry matches the actual import." + ); + + for (let i = 0; i < newLogins.length; i++) { + checkLoginsAreEqual(logins[i], newLogins[i], i + 1); + } +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_passwords_emptySource.js b/browser/components/migration/tests/unit/test_Chrome_passwords_emptySource.js new file mode 100644 index 0000000000..0e6993fded --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_passwords_emptySource.js @@ -0,0 +1,43 @@ +"use strict"; + +const { ChromeProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/ChromeProfileMigrator.sys.mjs" +); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +add_task(async function test_importEmptyDBWithoutAuthPrompts() { + let dirSvcPath; + let pathId; + + if (AppConstants.platform == "macosx") { + dirSvcPath = "LibraryWithNoData/"; + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + dirSvcPath = "AppData/LocalWithNoData/"; + pathId = "LocalAppData"; + } else { + throw new Error("Not implemented"); + } + let dirSvcFile = do_get_file(dirSvcPath); + registerFakePath(pathId, dirSvcFile); + + let sandbox = sinon.createSandbox(); + sandbox + .stub(ChromeProfileMigrator.prototype, "canGetPermissions") + .resolves(true); + sandbox + .stub(ChromeProfileMigrator.prototype, "hasPermissions") + .resolves(true); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + !migrator, + "Migrator should not be available since there are no passwords" + ); +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_permissions.js b/browser/components/migration/tests/unit/test_Chrome_permissions.js new file mode 100644 index 0000000000..6dfd8bcceb --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_permissions.js @@ -0,0 +1,205 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if the migrator does not have the permission to + * read from the Chrome data directory, that it can request + * permission to read from it, if the system allows. + */ + +const { ChromeMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/ChromeMigrationUtils.sys.mjs" +); + +const { ChromeProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/ChromeProfileMigrator.sys.mjs" +); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +const { MockFilePicker } = ChromeUtils.importESModule( + "resource://testing-common/MockFilePicker.sys.mjs" +); + +let gTempDir; + +add_setup(async () => { + Services.prefs.setBoolPref( + "browser.migrate.chrome.get_permissions.enabled", + true + ); + gTempDir = do_get_tempdir(); + await IOUtils.writeJSON(PathUtils.join(gTempDir.path, "Local State"), []); + + MockFilePicker.init(globalThis); + registerCleanupFunction(() => { + MockFilePicker.cleanup(); + }); +}); + +/** + * Tests that canGetPermissions will return false if the platform does + * not allow for folder selection in the native file picker, and returns + * the data path otherwise. + */ +add_task(async function test_canGetPermissions() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let migrator = new ChromeProfileMigrator(); + let canGetPermissionsStub = sandbox + .stub(MigrationUtils, "canGetPermissionsOnPlatform") + .resolves(false); + sandbox.stub(ChromeMigrationUtils, "getDataPath").resolves(gTempDir.path); + + Assert.ok( + !(await migrator.canGetPermissions()), + "Should not be able to get permissions." + ); + + canGetPermissionsStub.resolves(true); + + Assert.equal( + await migrator.canGetPermissions(), + gTempDir.path, + "Should be able to get the permissions path." + ); + + sandbox.restore(); +}); + +/** + * Tests that getPermissions will show the native file picker in a + * loop until either the user cancels or selects a folder that grants + * read permissions to the data directory. + */ +add_task(async function test_getPermissions() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let migrator = new ChromeProfileMigrator(); + sandbox.stub(MigrationUtils, "canGetPermissionsOnPlatform").resolves(true); + sandbox.stub(ChromeMigrationUtils, "getDataPath").resolves(gTempDir.path); + let hasPermissionsStub = sandbox + .stub(migrator, "hasPermissions") + .resolves(false); + + Assert.equal( + await migrator.canGetPermissions(), + gTempDir.path, + "Should be able to get the permissions path." + ); + + let filePickerSeenCount = 0; + + let filePickerShownPromise = new Promise(resolve => { + MockFilePicker.showCallback = () => { + Assert.ok(true, "Filepicker shown."); + MockFilePicker.useDirectory([gTempDir.path]); + filePickerSeenCount++; + if (filePickerSeenCount > 3) { + Assert.ok(true, "File picker looped 3 times."); + hasPermissionsStub.resolves(true); + resolve(); + } + }; + }); + MockFilePicker.returnValue = MockFilePicker.returnOK; + Assert.ok( + await migrator.getPermissions(), + "Should report that we got permissions." + ); + + await filePickerShownPromise; + + // Make sure that the user can also hit "cancel" and that we + // file picker loop. + + hasPermissionsStub.resolves(false); + + filePickerSeenCount = 0; + filePickerShownPromise = new Promise(resolve => { + MockFilePicker.showCallback = () => { + Assert.ok(true, "Filepicker shown."); + filePickerSeenCount++; + Assert.equal(filePickerSeenCount, 1, "Saw the picker once."); + resolve(); + }; + }); + MockFilePicker.returnValue = MockFilePicker.returnCancel; + Assert.ok( + !(await migrator.getPermissions()), + "Should report that we didn't get permissions." + ); + await filePickerShownPromise; + + sandbox.restore(); +}); + +/** + * Tests that if the native file picker chooses a different directory + * than the one we originally asked for, that we remap attempts to + * read profiles from that new directory. This is because Ubuntu Snaps + * will return us paths from the native file picker that are symlinks + * to the original directories. + */ +add_task(async function test_remapDirectories() { + let remapDir = new FileUtils.File( + await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "test-chrome-migration" + ) + ); + let localStatePath = PathUtils.join(remapDir.path, "Local State"); + await IOUtils.writeJSON(localStatePath, []); + + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let migrator = new ChromeProfileMigrator(); + sandbox.stub(MigrationUtils, "canGetPermissionsOnPlatform").resolves(true); + sandbox.stub(ChromeMigrationUtils, "getDataPath").resolves(gTempDir.path); + let hasPermissionsStub = sandbox + .stub(migrator, "hasPermissions") + .resolves(false); + + Assert.equal( + await migrator.canGetPermissions(), + gTempDir.path, + "Should be able to get the permissions path." + ); + + let filePickerShownPromise = new Promise(resolve => { + MockFilePicker.showCallback = () => { + Assert.ok(true, "Filepicker shown."); + MockFilePicker.useDirectory([remapDir.path]); + hasPermissionsStub.resolves(true); + resolve(); + }; + }); + MockFilePicker.returnValue = MockFilePicker.returnOK; + Assert.ok( + await migrator.getPermissions(), + "Should report that we got permissions." + ); + + Assert.equal( + PathUtils.normalize(await migrator.canGetPermissions()), + PathUtils.normalize(remapDir.path), + "Should be able to get the remapped permissions path." + ); + + await filePickerShownPromise; + + sandbox.restore(); +}); diff --git a/browser/components/migration/tests/unit/test_Edge_db_migration.js b/browser/components/migration/tests/unit/test_Edge_db_migration.js new file mode 100644 index 0000000000..3b2672d9d8 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Edge_db_migration.js @@ -0,0 +1,849 @@ +"use strict"; + +const { ctypes } = ChromeUtils.importESModule( + "resource://gre/modules/ctypes.sys.mjs" +); +const { ESE, KERNEL, gLibs, COLUMN_TYPES, declareESEFunction, loadLibraries } = + ChromeUtils.importESModule("resource:///modules/ESEDBReader.sys.mjs"); +const { EdgeProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/EdgeProfileMigrator.sys.mjs" +); + +let gESEInstanceCounter = 1; + +ESE.JET_COLUMNCREATE_W = new ctypes.StructType("JET_COLUMNCREATE_W", [ + { cbStruct: ctypes.unsigned_long }, + { szColumnName: ESE.JET_PCWSTR }, + { coltyp: ESE.JET_COLTYP }, + { cbMax: ctypes.unsigned_long }, + { grbit: ESE.JET_GRBIT }, + { pvDefault: ctypes.voidptr_t }, + { cbDefault: ctypes.unsigned_long }, + { cp: ctypes.unsigned_long }, + { columnid: ESE.JET_COLUMNID }, + { err: ESE.JET_ERR }, +]); + +function createColumnCreationWrapper({ name, type, cbMax }) { + // We use a wrapper object because we need to be sure the JS engine won't GC + // data that we're "only" pointing to. + let wrapper = {}; + wrapper.column = new ESE.JET_COLUMNCREATE_W(); + wrapper.column.cbStruct = ESE.JET_COLUMNCREATE_W.size; + let wchar_tArray = ctypes.ArrayType(ctypes.char16_t); + wrapper.name = new wchar_tArray(name.length + 1); + wrapper.name.value = String(name); + wrapper.column.szColumnName = wrapper.name; + wrapper.column.coltyp = type; + let fallback = 0; + switch (type) { + case COLUMN_TYPES.JET_coltypText: + fallback = 255; + // Intentional fall-through + case COLUMN_TYPES.JET_coltypLongText: + wrapper.column.cbMax = cbMax || fallback || 64 * 1024; + break; + case COLUMN_TYPES.JET_coltypGUID: + wrapper.column.cbMax = 16; + break; + case COLUMN_TYPES.JET_coltypBit: + wrapper.column.cbMax = 1; + break; + case COLUMN_TYPES.JET_coltypLongLong: + wrapper.column.cbMax = 8; + break; + default: + throw new Error("Unknown column type!"); + } + + wrapper.column.columnid = new ESE.JET_COLUMNID(); + wrapper.column.grbit = 0; + wrapper.column.pvDefault = null; + wrapper.column.cbDefault = 0; + wrapper.column.cp = 0; + + return wrapper; +} + +// "forward declarations" of indexcreate and setinfo structs, which we don't use. +ESE.JET_INDEXCREATE = new ctypes.StructType("JET_INDEXCREATE"); +ESE.JET_SETINFO = new ctypes.StructType("JET_SETINFO"); + +ESE.JET_TABLECREATE_W = new ctypes.StructType("JET_TABLECREATE_W", [ + { cbStruct: ctypes.unsigned_long }, + { szTableName: ESE.JET_PCWSTR }, + { szTemplateTableName: ESE.JET_PCWSTR }, + { ulPages: ctypes.unsigned_long }, + { ulDensity: ctypes.unsigned_long }, + { rgcolumncreate: ESE.JET_COLUMNCREATE_W.ptr }, + { cColumns: ctypes.unsigned_long }, + { rgindexcreate: ESE.JET_INDEXCREATE.ptr }, + { cIndexes: ctypes.unsigned_long }, + { grbit: ESE.JET_GRBIT }, + { tableid: ESE.JET_TABLEID }, + { cCreated: ctypes.unsigned_long }, +]); + +function createTableCreationWrapper(tableName, columns) { + let wrapper = {}; + let wchar_tArray = ctypes.ArrayType(ctypes.char16_t); + wrapper.name = new wchar_tArray(tableName.length + 1); + wrapper.name.value = String(tableName); + wrapper.table = new ESE.JET_TABLECREATE_W(); + wrapper.table.cbStruct = ESE.JET_TABLECREATE_W.size; + wrapper.table.szTableName = wrapper.name; + wrapper.table.szTemplateTableName = null; + wrapper.table.ulPages = 1; + wrapper.table.ulDensity = 0; + let columnArrayType = ESE.JET_COLUMNCREATE_W.array(columns.length); + wrapper.columnAry = new columnArrayType(); + wrapper.table.rgcolumncreate = wrapper.columnAry.addressOfElement(0); + wrapper.table.cColumns = columns.length; + wrapper.columns = []; + for (let i = 0; i < columns.length; i++) { + let column = columns[i]; + let columnWrapper = createColumnCreationWrapper(column); + wrapper.columnAry.addressOfElement(i).contents = columnWrapper.column; + wrapper.columns.push(columnWrapper); + } + wrapper.table.rgindexcreate = null; + wrapper.table.cIndexes = 0; + return wrapper; +} + +function convertValueForWriting(value, valueType) { + let buffer; + let valueOfValueType = ctypes.UInt64.lo(valueType); + switch (valueOfValueType) { + case COLUMN_TYPES.JET_coltypLongLong: + if (value instanceof Date) { + buffer = new KERNEL.FILETIME(); + let sysTime = new KERNEL.SYSTEMTIME(); + sysTime.wYear = value.getUTCFullYear(); + sysTime.wMonth = value.getUTCMonth() + 1; + sysTime.wDay = value.getUTCDate(); + sysTime.wHour = value.getUTCHours(); + sysTime.wMinute = value.getUTCMinutes(); + sysTime.wSecond = value.getUTCSeconds(); + sysTime.wMilliseconds = value.getUTCMilliseconds(); + let rv = KERNEL.SystemTimeToFileTime( + sysTime.address(), + buffer.address() + ); + if (!rv) { + throw new Error("Failed to get FileTime."); + } + return [buffer, KERNEL.FILETIME.size]; + } + throw new Error("Unrecognized value for longlong column"); + case COLUMN_TYPES.JET_coltypLongText: + let wchar_tArray = ctypes.ArrayType(ctypes.char16_t); + buffer = new wchar_tArray(value.length + 1); + buffer.value = String(value); + return [buffer, buffer.length * 2]; + case COLUMN_TYPES.JET_coltypBit: + buffer = new ctypes.uint8_t(); + // Bizarre boolean values, but whatever: + buffer.value = value ? 255 : 0; + return [buffer, 1]; + case COLUMN_TYPES.JET_coltypGUID: + let byteArray = ctypes.ArrayType(ctypes.uint8_t); + buffer = new byteArray(16); + let j = 0; + for (let i = 0; i < value.length; i++) { + if (!/[0-9a-f]/i.test(value[i])) { + continue; + } + let byteAsHex = value.substr(i, 2); + buffer[j++] = parseInt(byteAsHex, 16); + i++; + } + return [buffer, 16]; + } + + throw new Error("Unknown type " + valueType); +} + +let initializedESE = false; + +let eseDBWritingHelpers = { + setupDB(dbFile, tables) { + if (!initializedESE) { + initializedESE = true; + loadLibraries(); + + KERNEL.SystemTimeToFileTime = gLibs.kernel.declare( + "SystemTimeToFileTime", + ctypes.winapi_abi, + ctypes.bool, + KERNEL.SYSTEMTIME.ptr, + KERNEL.FILETIME.ptr + ); + + declareESEFunction( + "CreateDatabaseW", + ESE.JET_SESID, + ESE.JET_PCWSTR, + ESE.JET_PCWSTR, + ESE.JET_DBID.ptr, + ESE.JET_GRBIT + ); + declareESEFunction( + "CreateTableColumnIndexW", + ESE.JET_SESID, + ESE.JET_DBID, + ESE.JET_TABLECREATE_W.ptr + ); + declareESEFunction("BeginTransaction", ESE.JET_SESID); + declareESEFunction("CommitTransaction", ESE.JET_SESID, ESE.JET_GRBIT); + declareESEFunction( + "PrepareUpdate", + ESE.JET_SESID, + ESE.JET_TABLEID, + ctypes.unsigned_long + ); + declareESEFunction( + "Update", + ESE.JET_SESID, + ESE.JET_TABLEID, + ctypes.voidptr_t, + ctypes.unsigned_long, + ctypes.unsigned_long.ptr + ); + declareESEFunction( + "SetColumn", + ESE.JET_SESID, + ESE.JET_TABLEID, + ESE.JET_COLUMNID, + ctypes.voidptr_t, + ctypes.unsigned_long, + ESE.JET_GRBIT, + ESE.JET_SETINFO.ptr + ); + ESE.SetSystemParameterW( + null, + 0, + 64 /* JET_paramDatabasePageSize*/, + 8192, + null + ); + } + + let rootPath = dbFile.parent.path + "\\"; + let logPath = rootPath + "LogFiles\\"; + + try { + this._instanceId = new ESE.JET_INSTANCE(); + ESE.CreateInstanceW( + this._instanceId.address(), + "firefox-dbwriter-" + gESEInstanceCounter++ + ); + this._instanceCreated = true; + + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 0 /* JET_paramSystemPath*/, + 0, + rootPath + ); + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 1 /* JET_paramTempPath */, + 0, + rootPath + ); + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 2 /* JET_paramLogFilePath*/, + 0, + logPath + ); + // Shouldn't try to call JetTerm if the following call fails. + this._instanceCreated = false; + ESE.Init(this._instanceId.address()); + this._instanceCreated = true; + this._sessionId = new ESE.JET_SESID(); + ESE.BeginSessionW( + this._instanceId, + this._sessionId.address(), + null, + null + ); + this._sessionCreated = true; + + this._dbId = new ESE.JET_DBID(); + this._dbPath = rootPath + "spartan.edb"; + ESE.CreateDatabaseW( + this._sessionId, + this._dbPath, + null, + this._dbId.address(), + 0 + ); + this._opened = this._attached = true; + + for (let [tableName, data] of tables) { + let { rows, columns } = data; + let tableCreationWrapper = createTableCreationWrapper( + tableName, + columns + ); + ESE.CreateTableColumnIndexW( + this._sessionId, + this._dbId, + tableCreationWrapper.table.address() + ); + this._tableId = tableCreationWrapper.table.tableid; + + let columnIdMap = new Map(); + if (rows.length) { + // Iterate over the struct we passed into ESENT because they have the + // created column ids. + let columnCount = ctypes.UInt64.lo( + tableCreationWrapper.table.cColumns + ); + let columnsPassed = tableCreationWrapper.table.rgcolumncreate; + for (let i = 0; i < columnCount; i++) { + let column = columnsPassed.contents; + columnIdMap.set(column.szColumnName.readString(), column); + columnsPassed = columnsPassed.increment(); + } + ESE.ManualMove( + this._sessionId, + this._tableId, + -2147483648 /* JET_MoveFirst */, + 0 + ); + ESE.BeginTransaction(this._sessionId); + for (let row of rows) { + ESE.PrepareUpdate( + this._sessionId, + this._tableId, + 0 /* JET_prepInsert */ + ); + for (let columnName in row) { + let col = columnIdMap.get(columnName); + let colId = col.columnid; + let [val, valSize] = convertValueForWriting( + row[columnName], + col.coltyp + ); + /* JET_bitSetOverwriteLV */ + ESE.SetColumn( + this._sessionId, + this._tableId, + colId, + val.address(), + valSize, + 4, + null + ); + } + let actualBookmarkSize = new ctypes.unsigned_long(); + ESE.Update( + this._sessionId, + this._tableId, + null, + 0, + actualBookmarkSize.address() + ); + } + ESE.CommitTransaction( + this._sessionId, + 0 /* JET_bitWaitLastLevel0Commit */ + ); + } + } + } finally { + try { + this._close(); + } catch (ex) { + console.error(ex); + } + } + }, + + _close() { + if (this._tableId) { + ESE.FailSafeCloseTable(this._sessionId, this._tableId); + delete this._tableId; + } + if (this._opened) { + ESE.FailSafeCloseDatabase(this._sessionId, this._dbId, 0); + this._opened = false; + } + if (this._attached) { + ESE.FailSafeDetachDatabaseW(this._sessionId, this._dbPath); + this._attached = false; + } + if (this._sessionCreated) { + ESE.FailSafeEndSession(this._sessionId, 0); + this._sessionCreated = false; + } + if (this._instanceCreated) { + ESE.FailSafeTerm(this._instanceId); + this._instanceCreated = false; + } + }, +}; + +add_task(async function () { + let tempFile = Services.dirsvc.get("TmpD", Ci.nsIFile); + tempFile.append("fx-xpcshell-edge-db"); + tempFile.createUnique(tempFile.DIRECTORY_TYPE, 0o600); + + let db = tempFile.clone(); + db.append("spartan.edb"); + + let logs = tempFile.clone(); + logs.append("LogFiles"); + logs.create(tempFile.DIRECTORY_TYPE, 0o600); + + let creationDate = new Date(Date.now() - 5000); + const kEdgeMenuParent = "62d07e2b-5f0d-4e41-8426-5f5ec9717beb"; + let bookmarkReferenceItems = [ + { + URL: "http://www.mozilla.org/", + Title: "Mozilla", + DateUpdated: new Date(creationDate.valueOf() + 100), + ItemId: "1c00c10a-15f6-4618-92dd-22575102a4da", + ParentId: kEdgeMenuParent, + IsFolder: false, + IsDeleted: false, + }, + { + Title: "Folder", + DateUpdated: new Date(creationDate.valueOf() + 200), + ItemId: "564b21f2-05d6-4f7d-8499-304d00ccc3aa", + ParentId: kEdgeMenuParent, + IsFolder: true, + IsDeleted: false, + }, + { + Title: "Item in folder", + URL: "http://www.iteminfolder.org/", + DateUpdated: new Date(creationDate.valueOf() + 300), + ItemId: "c295ddaf-04a1-424a-866c-0ebde011e7c8", + ParentId: "564b21f2-05d6-4f7d-8499-304d00ccc3aa", + IsFolder: false, + IsDeleted: false, + }, + { + Title: "Deleted folder", + DateUpdated: new Date(creationDate.valueOf() + 400), + ItemId: "a547573c-4d4d-4406-a736-5b5462d93bca", + ParentId: kEdgeMenuParent, + IsFolder: true, + IsDeleted: true, + }, + { + Title: "Deleted item", + URL: "http://www.deleteditem.org/", + DateUpdated: new Date(creationDate.valueOf() + 500), + ItemId: "37a574bb-b44b-4bbc-a414-908615536435", + ParentId: kEdgeMenuParent, + IsFolder: false, + IsDeleted: true, + }, + { + Title: "Item in deleted folder (should be in root)", + URL: "http://www.itemindeletedfolder.org/", + DateUpdated: new Date(creationDate.valueOf() + 600), + ItemId: "74dd1cc3-4c5d-471f-bccc-7bc7c72fa621", + ParentId: "a547573c-4d4d-4406-a736-5b5462d93bca", + IsFolder: false, + IsDeleted: false, + }, + { + Title: "_Favorites_Bar_", + DateUpdated: new Date(creationDate.valueOf() + 700), + ItemId: "921dc8a0-6c83-40ef-8df1-9bd1c5c56aaf", + ParentId: kEdgeMenuParent, + IsFolder: true, + IsDeleted: false, + }, + { + Title: "Item in favorites bar", + URL: "http://www.iteminfavoritesbar.org/", + DateUpdated: new Date(creationDate.valueOf() + 800), + ItemId: "9f2b1ff8-b651-46cf-8f41-16da8bcb6791", + ParentId: "921dc8a0-6c83-40ef-8df1-9bd1c5c56aaf", + IsFolder: false, + IsDeleted: false, + }, + ]; + + let readingListReferenceItems = [ + { + Title: "Some mozilla page", + URL: "http://www.mozilla.org/somepage/", + AddedDate: new Date(creationDate.valueOf() + 900), + ItemId: "c88426fd-52a7-419d-acbc-d2310e8afebe", + IsDeleted: false, + }, + { + Title: "Some other page", + URL: "https://www.example.org/somepage/", + AddedDate: new Date(creationDate.valueOf() + 1000), + ItemId: "a35fc843-5d5a-4d1e-9be8-45214be24b5c", + IsDeleted: false, + }, + ]; + + // The following entries are expected to be skipped as being too old to + // migrate. + let expiredTypedURLsReferenceItems = [ + { + URL: "https://expired1.invalid/", + AccessDateTimeUTC: dateDaysAgo(500), + }, + { + URL: "https://expired2.invalid/", + AccessDateTimeUTC: dateDaysAgo(300), + }, + { + URL: "https://expired3.invalid/", + AccessDateTimeUTC: dateDaysAgo(190), + }, + ]; + + // The following entries should be new enough to migrate. + let unexpiredTypedURLsReferenceItems = [ + { + URL: "https://unexpired1.invalid/", + AccessDateTimeUTC: dateDaysAgo(179), + }, + { + URL: "https://unexpired2.invalid/", + AccessDateTimeUTC: dateDaysAgo(50), + }, + { + URL: "https://unexpired3.invalid/", + }, + ]; + + let typedURLsReferenceItems = [ + ...expiredTypedURLsReferenceItems, + ...unexpiredTypedURLsReferenceItems, + ]; + + Assert.ok( + MigrationUtils.HISTORY_MAX_AGE_IN_DAYS < 300, + "This test expects the current pref to be less than the youngest expired visit." + ); + Assert.ok( + MigrationUtils.HISTORY_MAX_AGE_IN_DAYS > 160, + "This test expects the current pref to be greater than the oldest unexpired visit." + ); + + eseDBWritingHelpers.setupDB( + db, + new Map([ + [ + "Favorites", + { + columns: [ + { type: COLUMN_TYPES.JET_coltypLongText, name: "URL", cbMax: 4096 }, + { + type: COLUMN_TYPES.JET_coltypLongText, + name: "Title", + cbMax: 4096, + }, + { type: COLUMN_TYPES.JET_coltypLongLong, name: "DateUpdated" }, + { type: COLUMN_TYPES.JET_coltypGUID, name: "ItemId" }, + { type: COLUMN_TYPES.JET_coltypBit, name: "IsDeleted" }, + { type: COLUMN_TYPES.JET_coltypBit, name: "IsFolder" }, + { type: COLUMN_TYPES.JET_coltypGUID, name: "ParentId" }, + ], + rows: bookmarkReferenceItems, + }, + ], + [ + "ReadingList", + { + columns: [ + { type: COLUMN_TYPES.JET_coltypLongText, name: "URL", cbMax: 4096 }, + { + type: COLUMN_TYPES.JET_coltypLongText, + name: "Title", + cbMax: 4096, + }, + { type: COLUMN_TYPES.JET_coltypLongLong, name: "AddedDate" }, + { type: COLUMN_TYPES.JET_coltypGUID, name: "ItemId" }, + { type: COLUMN_TYPES.JET_coltypBit, name: "IsDeleted" }, + ], + rows: readingListReferenceItems, + }, + ], + [ + "TypedURLs", + { + columns: [ + { type: COLUMN_TYPES.JET_coltypLongText, name: "URL", cbMax: 4096 }, + { + type: COLUMN_TYPES.JET_coltypLongLong, + name: "AccessDateTimeUTC", + }, + ], + rows: typedURLsReferenceItems, + }, + ], + ]) + ); + + // Manually create an EdgeProfileMigrator rather than going through + // MigrationUtils.getMigrator to avoid the user data availability check, since + // we're mocking out that stuff. + let migrator = new EdgeProfileMigrator(); + let bookmarksMigrator = migrator.getBookmarksMigratorForTesting(db); + Assert.ok(bookmarksMigrator.exists, "Should recognize db we just created"); + + let seenBookmarks = []; + let listener = events => { + for (let event of events) { + let { + id, + itemType, + url, + title, + dateAdded, + guid, + index, + parentGuid, + parentId, + } = event; + if (title.startsWith("Deleted")) { + ok(false, "Should not see deleted items being bookmarked!"); + } + seenBookmarks.push({ + id, + parentId, + index, + itemType, + url, + title, + dateAdded, + guid, + parentGuid, + }); + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + + let migrateResult = await new Promise(resolve => + bookmarksMigrator.migrate(resolve) + ).catch(ex => { + console.error(ex); + Assert.ok(false, "Got an exception trying to migrate data! " + ex); + return false; + }); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + Assert.ok(migrateResult, "Migration should succeed"); + Assert.equal( + seenBookmarks.length, + 5, + "Should have seen 5 items being bookmarked." + ); + Assert.equal( + seenBookmarks.length, + MigrationUtils._importQuantities.bookmarks, + "Telemetry should have items" + ); + + let menuParents = seenBookmarks.filter( + item => item.parentGuid == PlacesUtils.bookmarks.menuGuid + ); + Assert.equal( + menuParents.length, + 3, + "Bookmarks are added to the menu without a folder" + ); + let toolbarParents = seenBookmarks.filter( + item => item.parentGuid == PlacesUtils.bookmarks.toolbarGuid + ); + Assert.equal( + toolbarParents.length, + 1, + "Should have a single item added to the toolbar" + ); + let menuParentGuid = PlacesUtils.bookmarks.menuGuid; + let toolbarParentGuid = PlacesUtils.bookmarks.toolbarGuid; + + let expectedTitlesInMenu = bookmarkReferenceItems + .filter(item => item.ParentId == kEdgeMenuParent) + .map(item => item.Title); + // Hacky, but seems like much the simplest way: + expectedTitlesInMenu.push("Item in deleted folder (should be in root)"); + let expectedTitlesInToolbar = bookmarkReferenceItems + .filter(item => item.ParentId == "921dc8a0-6c83-40ef-8df1-9bd1c5c56aaf") + .map(item => item.Title); + + for (let bookmark of seenBookmarks) { + let shouldBeInMenu = expectedTitlesInMenu.includes(bookmark.title); + let shouldBeInToolbar = expectedTitlesInToolbar.includes(bookmark.title); + if (bookmark.title == "Folder") { + Assert.equal( + bookmark.itemType, + PlacesUtils.bookmarks.TYPE_FOLDER, + "Bookmark " + bookmark.title + " should be a folder" + ); + } else { + Assert.notEqual( + bookmark.itemType, + PlacesUtils.bookmarks.TYPE_FOLDER, + "Bookmark " + bookmark.title + " should not be a folder" + ); + } + + if (shouldBeInMenu) { + Assert.equal( + bookmark.parentGuid, + menuParentGuid, + "Item '" + bookmark.title + "' should be in menu" + ); + } else if (shouldBeInToolbar) { + Assert.equal( + bookmark.parentGuid, + toolbarParentGuid, + "Item '" + bookmark.title + "' should be in toolbar" + ); + } else if ( + bookmark.guid == menuParentGuid || + bookmark.guid == toolbarParentGuid + ) { + Assert.ok( + true, + "Expect toolbar and menu folders to not be in menu or toolbar" + ); + } else { + // Bit hacky, but we do need to check this. + Assert.equal( + bookmark.title, + "Item in folder", + "Subfoldered item shouldn't be in menu or toolbar" + ); + let parent = seenBookmarks.find( + maybeParent => maybeParent.guid == bookmark.parentGuid + ); + Assert.equal( + parent && parent.title, + "Folder", + "Subfoldered item should be in subfolder labeled 'Folder'" + ); + } + + let dbItem = bookmarkReferenceItems.find( + someItem => bookmark.title == someItem.Title + ); + if (!dbItem) { + Assert.ok( + [menuParentGuid, toolbarParentGuid].includes(bookmark.guid), + "This item should be one of the containers" + ); + } else { + Assert.equal(dbItem.URL || "", bookmark.url, "URL is correct"); + Assert.equal( + dbItem.DateUpdated.valueOf(), + new Date(bookmark.dateAdded).valueOf(), + "Date added is correct" + ); + } + } + + MigrationUtils._importQuantities.bookmarks = 0; + seenBookmarks = []; + listener = events => { + for (let event of events) { + let { + id, + itemType, + url, + title, + dateAdded, + guid, + index, + parentGuid, + parentId, + } = event; + seenBookmarks.push({ + id, + parentId, + index, + itemType, + url, + title, + dateAdded, + guid, + parentGuid, + }); + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + + let readingListMigrator = migrator.getReadingListMigratorForTesting(db); + Assert.ok(readingListMigrator.exists, "Should recognize db we just created"); + migrateResult = await new Promise(resolve => + readingListMigrator.migrate(resolve) + ).catch(ex => { + console.error(ex); + Assert.ok(false, "Got an exception trying to migrate data! " + ex); + return false; + }); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + Assert.ok(migrateResult, "Migration should succeed"); + Assert.equal( + seenBookmarks.length, + 3, + "Should have seen 3 items being bookmarked (2 items + 1 folder)." + ); + Assert.equal( + seenBookmarks.length, + MigrationUtils._importQuantities.bookmarks, + "Telemetry should have items" + ); + let readingListContainerLabel = await MigrationUtils.getLocalizedString( + "migration-imported-edge-reading-list" + ); + + for (let bookmark of seenBookmarks) { + if (readingListContainerLabel == bookmark.title) { + continue; + } + let referenceItem = readingListReferenceItems.find( + item => item.Title == bookmark.title + ); + Assert.ok(referenceItem, "Should have imported what we expected"); + Assert.equal(referenceItem.URL, bookmark.url, "Should have the right URL"); + readingListReferenceItems.splice( + readingListReferenceItems.findIndex(item => item.Title == bookmark.title), + 1 + ); + } + Assert.ok( + !readingListReferenceItems.length, + "Should have seen all expected items." + ); + + let historyDBMigrator = migrator.getHistoryDBMigratorForTesting(db); + await new Promise(resolve => { + historyDBMigrator.migrate(resolve); + }); + Assert.ok(true, "History DB migration done!"); + for (let expiredEntry of expiredTypedURLsReferenceItems) { + let entry = await PlacesUtils.history.fetch(expiredEntry.URL, { + includeVisits: true, + }); + Assert.equal(entry, null, "Should not have found an entry."); + } + + for (let unexpiredEntry of unexpiredTypedURLsReferenceItems) { + let entry = await PlacesUtils.history.fetch(unexpiredEntry.URL, { + includeVisits: true, + }); + Assert.equal(entry.url, unexpiredEntry.URL, "Should have the correct URL"); + Assert.ok(!!entry.visits.length, "Should have some visits"); + } +}); diff --git a/browser/components/migration/tests/unit/test_Edge_registry_migration.js b/browser/components/migration/tests/unit/test_Edge_registry_migration.js new file mode 100644 index 0000000000..2a400f7858 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Edge_registry_migration.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { EdgeProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/EdgeProfileMigrator.sys.mjs" +); +const { MSMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/MSMigrationUtils.sys.mjs" +); + +/** + * Tests that history visits loaded from the registry from Edge (EdgeHTML) + * that have a visit date older than maxAgeInDays days do not get imported. + */ +add_task(async function test_Edge_history_past_max_days() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + Assert.ok( + MigrationUtils.HISTORY_MAX_AGE_IN_DAYS < 300, + "This test expects the current pref to be less than the youngest expired visit." + ); + Assert.ok( + MigrationUtils.HISTORY_MAX_AGE_IN_DAYS > 160, + "This test expects the current pref to be greater than the oldest unexpired visit." + ); + + const EXPIRED_VISITS = [ + ["https://test1.invalid/", dateDaysAgo(500).getTime() * 1000], + ["https://test2.invalid/", dateDaysAgo(450).getTime() * 1000], + ["https://test3.invalid/", dateDaysAgo(300).getTime() * 1000], + ]; + + const UNEXPIRED_VISITS = [ + ["https://test4.invalid/"], + ["https://test5.invalid/", dateDaysAgo(160).getTime() * 1000], + ["https://test6.invalid/", dateDaysAgo(50).getTime() * 1000], + ["https://test7.invalid/", dateDaysAgo(0).getTime() * 1000], + ]; + + const ALL_VISITS = [...EXPIRED_VISITS, ...UNEXPIRED_VISITS]; + + // Fake out the getResources method of the migrator so that we return + // a single fake MigratorResource per availableResourceType. + sandbox.stub(MSMigrationUtils, "getTypedURLs").callsFake(() => { + return new Map(ALL_VISITS); + }); + + // Manually create an EdgeProfileMigrator rather than going through + // MigrationUtils.getMigrator to avoid the user data availability check, since + // we're mocking out that stuff. + let migrator = new EdgeProfileMigrator(); + let registryTypedHistoryMigrator = + migrator.getHistoryRegistryMigratorForTesting(); + await new Promise(resolve => { + registryTypedHistoryMigrator.migrate(resolve); + }); + Assert.ok(true, "History from registry migration done!"); + + for (let expiredEntry of EXPIRED_VISITS) { + let entry = await PlacesUtils.history.fetch(expiredEntry[0], { + includeVisits: true, + }); + Assert.equal(entry, null, "Should not have found an entry."); + } + + for (let unexpiredEntry of UNEXPIRED_VISITS) { + let entry = await PlacesUtils.history.fetch(unexpiredEntry[0], { + includeVisits: true, + }); + Assert.equal(entry.url, unexpiredEntry[0], "Should have the correct URL"); + Assert.ok(!!entry.visits.length, "Should have some visits"); + } +}); diff --git a/browser/components/migration/tests/unit/test_IE_bookmarks.js b/browser/components/migration/tests/unit/test_IE_bookmarks.js new file mode 100644 index 0000000000..9816bb16e3 --- /dev/null +++ b/browser/components/migration/tests/unit/test_IE_bookmarks.js @@ -0,0 +1,30 @@ +"use strict"; + +add_task(async function () { + let migrator = await MigrationUtils.getMigrator("ie"); + // Sanity check for the source. + Assert.ok(await migrator.isSourceAvailable(), "Check migrator source"); + + // Since this test doesn't mock out the favorites, execution is dependent + // on the actual favorites stored on the local machine's IE favorites database. + // As such, we can't assert that bookmarks were migrated to both the bookmarks + // menu and the bookmarks toolbar. + let itemCount = 0; + let listener = events => { + for (let event of events) { + if (event.itemType == PlacesUtils.bookmarks.TYPE_BOOKMARK) { + info("bookmark added: " + event.parentGuid); + itemCount++; + } + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.BOOKMARKS); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + Assert.equal( + MigrationUtils._importQuantities.bookmarks, + itemCount, + "Ensure telemetry matches actual number of imported items." + ); +}); diff --git a/browser/components/migration/tests/unit/test_IE_history.js b/browser/components/migration/tests/unit/test_IE_history.js new file mode 100644 index 0000000000..f9a1e719a2 --- /dev/null +++ b/browser/components/migration/tests/unit/test_IE_history.js @@ -0,0 +1,187 @@ +"use strict"; + +const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +// These match what we add to IE via InsertIEHistory.exe. +const TEST_ENTRIES = [ + { + url: "http://www.mozilla.org/1", + title: "Mozilla HTTP Test", + }, + { + url: "https://www.mozilla.org/2", + // Test character encoding with a fox emoji: + title: "Mozilla HTTPS Test 🦊", + }, +]; + +function insertIEHistory() { + let file = do_get_file("InsertIEHistory.exe", false); + let process = Cc["@mozilla.org/process/util;1"].createInstance(Ci.nsIProcess); + process.init(file); + + let args = []; + process.run(true, args, args.length); + + Assert.ok(!process.isRunning, "Should be done running"); + Assert.equal(process.exitValue, 0, "Check exit code"); +} + +add_task(async function setup() { + await PlacesUtils.history.clear(); + + insertIEHistory(); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function test_IE_history() { + let migrator = await MigrationUtils.getMigrator("ie"); + Assert.ok(await migrator.isSourceAvailable(), "Source is available"); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.HISTORY); + + for (let { url, title } of TEST_ENTRIES) { + let entry = await PlacesUtils.history.fetch(url, { includeVisits: true }); + Assert.equal(entry.url, url, "Should have the correct URL"); + Assert.equal(entry.title, title, "Should have the correct title"); + Assert.ok(!!entry.visits.length, "Should have some visits"); + } + + await PlacesUtils.history.clear(); +}); + +/** + * Tests that history visits from IE that have a visit date older than + * maxAgeInDays days do not get imported. + */ +add_task(async function test_IE_history_past_max_days() { + // The InsertIEHistory program inserts two history visits using the MS COM + // IUrlHistoryStg interface. That interface does not allow us to dictate + // the visit times of those history visits. Thankfully, we can temporarily + // mock out the @mozilla.org/profile/migrator/iehistoryenumerator;1 to return + // some entries that we expect to expire. + + /** + * An implmentation of nsISimpleEnumerator that wraps a JavaScript Array. + */ + class nsSimpleEnumerator { + #items; + #nextIndex; + + constructor(items) { + this.#items = items; + this.#nextIndex = 0; + } + + hasMoreElements() { + return this.#nextIndex < this.#items.length; + } + + getNext() { + if (!this.hasMoreElements()) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + } + + return this.#items[this.#nextIndex++]; + } + + [Symbol.iterator]() { + return this.#items.values(); + } + + QueryInterface = ChromeUtils.generateQI(["nsISimpleEnumerator"]); + } + + Assert.ok( + MigrationUtils.HISTORY_MAX_AGE_IN_DAYS < 300, + "This test expects the current pref to be less than the youngest expired visit." + ); + Assert.ok( + MigrationUtils.HISTORY_MAX_AGE_IN_DAYS > 160, + "This test expects the current pref to be greater than the oldest unexpired visit." + ); + + const EXPIRED_VISITS = [ + new Map([ + ["uri", Services.io.newURI("https://test1.invalid")], + ["title", "Test history visit 1"], + ["time", PRTimeDaysAgo(500)], + ]), + new Map([ + ["uri", Services.io.newURI("https://test2.invalid")], + ["title", "Test history visit 2"], + ["time", PRTimeDaysAgo(450)], + ]), + new Map([ + ["uri", Services.io.newURI("https://test3.invalid")], + ["title", "Test history visit 3"], + ["time", PRTimeDaysAgo(300)], + ]), + ]; + + const UNEXPIRED_VISITS = [ + new Map([ + ["uri", Services.io.newURI("https://test4.invalid")], + ["title", "Test history visit 4"], + ]), + new Map([ + ["uri", Services.io.newURI("https://test5.invalid")], + ["title", "Test history visit 5"], + ["time", PRTimeDaysAgo(160)], + ]), + new Map([ + ["uri", Services.io.newURI("https://test6.invalid")], + ["title", "Test history visit 6"], + ["time", PRTimeDaysAgo(50)], + ]), + new Map([ + ["uri", Services.io.newURI("https://test7.invalid")], + ["title", "Test history visit 7"], + ["time", PRTimeDaysAgo(0)], + ]), + ]; + + let fakeIEHistoryEnumerator = MockRegistrar.register( + "@mozilla.org/profile/migrator/iehistoryenumerator;1", + new nsSimpleEnumerator([...EXPIRED_VISITS, ...UNEXPIRED_VISITS]) + ); + registerCleanupFunction(() => { + MockRegistrar.unregister(fakeIEHistoryEnumerator); + }); + + let migrator = await MigrationUtils.getMigrator("ie"); + Assert.ok(await migrator.isSourceAvailable(), "Source is available"); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.HISTORY); + + for (let visit of EXPIRED_VISITS) { + let entry = await PlacesUtils.history.fetch(visit.get("uri").spec, { + includeVisits: true, + }); + Assert.equal(entry, null, "Should not have found an entry."); + } + + for (let visit of UNEXPIRED_VISITS) { + let entry = await PlacesUtils.history.fetch(visit.get("uri"), { + includeVisits: true, + }); + Assert.equal( + entry.url, + visit.get("uri").spec, + "Should have the correct URL" + ); + Assert.equal( + entry.title, + visit.get("title"), + "Should have the correct title" + ); + Assert.ok(!!entry.visits.length, "Should have some visits"); + } + + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js b/browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js new file mode 100644 index 0000000000..83748d870d --- /dev/null +++ b/browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js @@ -0,0 +1,29 @@ +"use strict"; + +let tmpFile = FileUtils.getDir("TmpD", []); +let dbConn; + +add_task(async function setup() { + tmpFile.append("TestDB"); + dbConn = await Sqlite.openConnection({ path: tmpFile.path }); + + registerCleanupFunction(async () => { + await dbConn.close(); + await IOUtils.remove(tmpFile.path); + }); +}); + +add_task(async function testgetRowsFromDBWithoutLocksRetries() { + let deferred = Promise.withResolvers(); + let promise = MigrationUtils.getRowsFromDBWithoutLocks( + tmpFile.path, + "Temp DB", + "SELECT * FROM moz_temp_table", + deferred.promise + ); + await new Promise(resolve => do_timeout(50, resolve)); + dbConn + .execute("CREATE TABLE moz_temp_table (id INTEGER PRIMARY KEY)") + .then(deferred.resolve); + await promise; +}); diff --git a/browser/components/migration/tests/unit/test_PasswordFileMigrator.js b/browser/components/migration/tests/unit/test_PasswordFileMigrator.js new file mode 100644 index 0000000000..e22f207c5d --- /dev/null +++ b/browser/components/migration/tests/unit/test_PasswordFileMigrator.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PasswordFileMigrator } = ChromeUtils.importESModule( + "resource:///modules/FileMigrators.sys.mjs" +); +const { LoginCSVImport } = ChromeUtils.importESModule( + "resource://gre/modules/LoginCSVImport.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { MigrationWizardConstants } = ChromeUtils.importESModule( + "chrome://browser/content/migration/migration-wizard-constants.mjs" +); + +add_setup(async function () { + Services.prefs.setBoolPref("signon.management.page.fileImport.enabled", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.management.page.fileImport.enabled"); + }); +}); + +/** + * Tests that the PasswordFileMigrator properly subclasses FileMigratorBase + * and delegates to the LoginCSVImport module. + */ +add_task(async function test_PasswordFileMigrator() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let migrator = new PasswordFileMigrator(); + Assert.ok( + migrator.constructor.key, + "PasswordFileMigrator implements static getter 'key'" + ); + Assert.ok( + migrator.constructor.displayNameL10nID, + "PasswordFileMigrator implements static getter 'displayNameL10nID'" + ); + Assert.ok( + await migrator.getFilePickerConfig(), + "PasswordFileMigrator returns something for getFilePickerConfig()" + ); + Assert.ok( + migrator.displayedResourceTypes, + "PasswordFileMigrator returns something for displayedResourceTypes" + ); + Assert.ok(migrator.enabled, "PasswordFileMigrator is enabled."); + + const IMPORT_SUMMARY = [ + { + result: "added", + }, + { + result: "added", + }, + { + result: "modified", + }, + ]; + const EXPECTED_SUCCESS_STATE = { + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_NEW]: + "2 added", + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_UPDATED]: + "1 updated", + }; + const FAKE_PATH = "some/fake/path.csv"; + + let importFromCSVStub = sandbox + .stub(LoginCSVImport, "importFromCSV") + .callsFake(somePath => { + Assert.equal(somePath, FAKE_PATH, "Got expected path"); + return Promise.resolve(IMPORT_SUMMARY); + }); + let result = await migrator.migrate(FAKE_PATH); + + Assert.ok(importFromCSVStub.called, "The stub should have been called."); + Assert.deepEqual( + result, + EXPECTED_SUCCESS_STATE, + "Got back the expected success state." + ); + + sandbox.restore(); +}); + +/** + * Tests that the PasswordFileMigrator will throw an exception with a + * consistent error message if the LoginCSVImport function rejects. + */ +add_task(async function test_PasswordFileMigrator_exception() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let migrator = new PasswordFileMigrator(); + + const FAKE_PATH = "some/fake/path.csv"; + + sandbox.stub(LoginCSVImport, "importFromCSV").callsFake(() => { + return Promise.reject("Some error"); + }); + + await Assert.rejects( + migrator.migrate(FAKE_PATH), + /The file doesn’t include any valid password data/ + ); + + sandbox.restore(); +}); diff --git a/browser/components/migration/tests/unit/test_Safari_bookmarks.js b/browser/components/migration/tests/unit/test_Safari_bookmarks.js new file mode 100644 index 0000000000..85be9f0049 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Safari_bookmarks.js @@ -0,0 +1,85 @@ +"use strict"; + +const { CustomizableUI } = ChromeUtils.importESModule( + "resource:///modules/CustomizableUI.sys.mjs" +); + +add_task(async function () { + registerFakePath("ULibDir", do_get_file("Library/")); + const faviconPath = do_get_file( + "Library/Safari/Favicon Cache/favicons.db" + ).path; + + let migrator = await MigrationUtils.getMigrator("safari"); + // Sanity check for the source. + Assert.ok(await migrator.isSourceAvailable()); + + // Wait for the imported bookmarks. We don't check that "From Safari" + // folders are created on the toolbar since the profile + // we're importing to has less than 3 bookmarks in the destination + // so a "From Safari" folder isn't created. + let expectedParentGuids = [PlacesUtils.bookmarks.toolbarGuid]; + let itemCount = 0; + + let gotFolder = false; + let listener = events => { + for (let event of events) { + itemCount++; + if ( + event.itemType == PlacesUtils.bookmarks.TYPE_FOLDER && + event.title == "Food and Travel" + ) { + gotFolder = true; + } + if (expectedParentGuids.length) { + let index = expectedParentGuids.indexOf(event.parentGuid); + Assert.ok(index != -1, "Found expected parent"); + expectedParentGuids.splice(index, 1); + } + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + let observerNotified = false; + Services.obs.addObserver((aSubject, aTopic, aData) => { + let [toolbar, visibility] = JSON.parse(aData); + Assert.equal( + toolbar, + CustomizableUI.AREA_BOOKMARKS, + "Notification should be received for bookmarks toolbar" + ); + Assert.equal( + visibility, + "true", + "Notification should say to reveal the bookmarks toolbar" + ); + observerNotified = true; + }, "browser-set-toolbar-visibility"); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.BOOKMARKS); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + + // Check the bookmarks have been imported to all the expected parents. + Assert.ok(!expectedParentGuids.length, "No more expected parents"); + Assert.ok(gotFolder, "Should have seen the folder get imported"); + Assert.equal(itemCount, 14, "Should import all 14 items."); + // Check that the telemetry matches: + Assert.equal( + MigrationUtils._importQuantities.bookmarks, + itemCount, + "Telemetry reporting correct." + ); + + // Check that favicons migrated + let faviconURIs = await MigrationUtils.getRowsFromDBWithoutLocks( + faviconPath, + "Safari Bookmark Favicons", + `SELECT I.uuid, I.url AS favicon_url, P.url + FROM icon_info I + INNER JOIN page_url P ON I.uuid = P.uuid;` + ); + let pageUrls = Array.from(faviconURIs, row => + Services.io.newURI(row.getResultByName("url")) + ); + await assertFavicons(pageUrls); + Assert.ok(observerNotified, "The observer should be notified upon migration"); +}); diff --git a/browser/components/migration/tests/unit/test_Safari_history.js b/browser/components/migration/tests/unit/test_Safari_history.js new file mode 100644 index 0000000000..c5b1210073 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Safari_history.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const HISTORY_TEMPLATE_FILE_PATH = "Library/Safari/HistoryTemplate.db"; +const HISTORY_FILE_PATH = "Library/Safari/History.db"; + +// We want this to be some recent time, so we'll always add some time to our +// dates to keep them ~ five days ago. +const MS_FROM_REFERENCE_TIME = + new Date() - new Date("May 31, 2023 00:00:00 UTC"); + +const TEST_URLS = [ + { + url: "http://example.com/", + title: "Example Domain", + time: 706743588.04751, + jsTime: 1685050788047 + MS_FROM_REFERENCE_TIME, + visits: 1, + }, + { + url: "http://mozilla.org/", + title: "", + time: 706743581.133386, + jsTime: 1685050781133 + MS_FROM_REFERENCE_TIME, + visits: 1, + }, + { + url: "https://www.mozilla.org/en-CA/", + title: "Internet for people, not profit - Mozilla", + time: 706743581.133679, + jsTime: 1685050781133 + MS_FROM_REFERENCE_TIME, + visits: 1, + }, +]; + +async function setupHistoryFile() { + removeHistoryFile(); + let file = do_get_file(HISTORY_TEMPLATE_FILE_PATH); + file.copyTo(file.parent, "History.db"); + await updateVisitTimes(); +} + +function removeHistoryFile() { + let file = do_get_file(HISTORY_FILE_PATH, true); + try { + file.remove(false); + } catch (ex) { + // It is ok if this doesn't exist. + if (ex.result != Cr.NS_ERROR_FILE_NOT_FOUND) { + throw ex; + } + } +} + +async function updateVisitTimes() { + let cocoaDifference = MS_FROM_REFERENCE_TIME / 1000; + let historyFile = do_get_file(HISTORY_FILE_PATH); + let dbConn = await Sqlite.openConnection({ path: historyFile.path }); + await dbConn.execute( + "UPDATE history_visits SET visit_time = visit_time + :difference;", + { difference: cocoaDifference } + ); + await dbConn.close(); +} + +add_setup(async function setup() { + registerFakePath("ULibDir", do_get_file("Library/")); + await setupHistoryFile(); + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + removeHistoryFile(); + }); +}); + +add_task(async function testHistoryImport() { + await PlacesUtils.history.clear(); + + let migrator = await MigrationUtils.getMigrator("safari"); + await promiseMigration(migrator, MigrationUtils.resourceTypes.HISTORY); + + for (let urlInfo of TEST_URLS) { + let entry = await PlacesUtils.history.fetch(urlInfo.url, { + includeVisits: true, + }); + + Assert.equal(entry.url, urlInfo.url, "Should have the correct URL"); + Assert.equal(entry.title, urlInfo.title, "Should have the correct title"); + Assert.equal( + entry.visits.length, + urlInfo.visits, + "Should have the correct number of visits" + ); + Assert.equal( + entry.visits[0].date.getTime(), + urlInfo.jsTime, + "Should have the correct date" + ); + } +}); 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 new file mode 100644 index 0000000000..2578353e35 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Safari_history_strange_entries.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PlacesQuery } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesQuery.sys.mjs" +); + +const HISTORY_FILE_PATH = "Library/Safari/History.db"; +const HISTORY_STRANGE_ENTRIES_FILE_PATH = + "Library/Safari/HistoryStrangeEntries.db"; + +// By default, our migrators will cut off migrating any history older than +// 180 days. In order to make sure this test continues to run correctly +// in the future, we copy the reference database to History.db, and then +// use Sqlite.sys.mjs to connect to it and manually update all of the visit +// times to be "now", so that they all fall within the 180 day window. The +// Nov 10th date below is right around when the reference database visit +// entries were created. +// +// This update occurs in `updateVisitTimes`. +const MS_SINCE_SNAPSHOT_TIME = + new Date() - new Date("Nov 10, 2022 00:00:00 UTC"); + +async function setupHistoryFile() { + removeHistoryFile(); + let file = do_get_file(HISTORY_STRANGE_ENTRIES_FILE_PATH); + file.copyTo(file.parent, "History.db"); + await updateVisitTimes(); +} + +function removeHistoryFile() { + let file = do_get_file(HISTORY_FILE_PATH, true); + try { + file.remove(false); + } catch (ex) { + // It is ok if this doesn't exist. + if (ex.result != Cr.NS_ERROR_FILE_NOT_FOUND) { + throw ex; + } + } +} + +add_setup(async function setup() { + registerFakePath("ULibDir", do_get_file("Library/")); + await setupHistoryFile(); + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + removeHistoryFile(); + }); +}); + +async function updateVisitTimes() { + let cocoaSnapshotDelta = MS_SINCE_SNAPSHOT_TIME / 1000; + let historyFile = do_get_file(HISTORY_FILE_PATH); + let dbConn = await Sqlite.openConnection({ path: historyFile.path }); + + await dbConn.execute( + "UPDATE history_visits SET visit_time = visit_time + :cocoaSnapshotDelta;", + { + cocoaSnapshotDelta, + } + ); + + await dbConn.close(); +} + +/** + * Tests that we can import successfully from Safari when Safari's history + * database contains malformed URLs. + */ +add_task(async function testHistoryImportStrangeEntries() { + await PlacesUtils.history.clear(); + + let placesQuery = new PlacesQuery(); + let emptyHistory = await placesQuery.getHistory(); + Assert.equal(emptyHistory.size, 0, "Empty history should indeed be empty."); + + const EXPECTED_MIGRATED_SITES = 10; + const EXPECTED_MIGRATED_VISTS = 23; + + let historyFile = do_get_file(HISTORY_FILE_PATH); + let dbConn = await Sqlite.openConnection({ path: historyFile.path }); + let [rowCountResult] = await dbConn.execute( + "SELECT COUNT(*) FROM history_visits" + ); + Assert.greater( + rowCountResult.getResultByName("COUNT(*)"), + EXPECTED_MIGRATED_VISTS, + "There are more total rows than valid rows" + ); + await dbConn.close(); + + let migrator = await MigrationUtils.getMigrator("safari"); + await promiseMigration(migrator, MigrationUtils.resourceTypes.HISTORY); + let migratedHistory = await placesQuery.getHistory({ sortBy: "site" }); + let siteCount = migratedHistory.size; + let visitCount = 0; + for (let [, visits] of migratedHistory) { + visitCount += visits.length; + } + Assert.equal( + siteCount, + EXPECTED_MIGRATED_SITES, + "Should have migrated all valid history sites" + ); + Assert.equal( + visitCount, + EXPECTED_MIGRATED_VISTS, + "Should have migrated all valid history visits" + ); + + placesQuery.close(); +}); diff --git a/browser/components/migration/tests/unit/test_Safari_permissions.js b/browser/components/migration/tests/unit/test_Safari_permissions.js new file mode 100644 index 0000000000..eaa6c7788e --- /dev/null +++ b/browser/components/migration/tests/unit/test_Safari_permissions.js @@ -0,0 +1,145 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if the migrator does not have the permission to + * read from the Safari data directory, that it can request + * permission to read from it, if the system allows. + */ + +const { SafariProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/SafariProfileMigrator.sys.mjs" +); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +const { MockFilePicker } = ChromeUtils.importESModule( + "resource://testing-common/MockFilePicker.sys.mjs" +); + +let gDataDir; + +add_setup(async () => { + let tempDir = do_get_tempdir(); + gDataDir = PathUtils.join(tempDir.path, "Safari"); + await IOUtils.makeDirectory(gDataDir); + + registerFakePath("ULibDir", tempDir); + + MockFilePicker.init(globalThis); + registerCleanupFunction(() => { + MockFilePicker.cleanup(); + }); +}); + +/** + * Tests that canGetPermissions will return false if the platform does + * not allow for folder selection in the native file picker, and returns + * the data path otherwise. + */ +add_task(async function test_canGetPermissions() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let migrator = new SafariProfileMigrator(); + // Not being able to get a folder picker is not a problem on macOS, but + // we'll test that case anyways. + let canGetPermissionsStub = sandbox + .stub(MigrationUtils, "canGetPermissionsOnPlatform") + .resolves(false); + + Assert.ok( + !(await migrator.canGetPermissions()), + "Should not be able to get permissions." + ); + + canGetPermissionsStub.resolves(true); + + Assert.equal( + await migrator.canGetPermissions(), + gDataDir, + "Should be able to get the permissions path." + ); + + sandbox.restore(); +}); + +/** + * Tests that getPermissions will show the native file picker in a + * loop until either the user cancels or selects a folder that grants + * read permissions to the data directory. + */ +add_task(async function test_getPermissions() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let migrator = new SafariProfileMigrator(); + sandbox.stub(MigrationUtils, "canGetPermissionsOnPlatform").resolves(true); + let hasPermissionsStub = sandbox + .stub(migrator, "hasPermissions") + .resolves(false); + + Assert.equal( + await migrator.canGetPermissions(), + gDataDir, + "Should be able to get the permissions path." + ); + + let filePickerSeenCount = 0; + + let filePickerShownPromise = new Promise(resolve => { + MockFilePicker.showCallback = () => { + Assert.ok(true, "Filepicker shown."); + MockFilePicker.useDirectory([gDataDir]); + filePickerSeenCount++; + if (filePickerSeenCount > 3) { + Assert.ok(true, "File picker looped 3 times."); + hasPermissionsStub.resolves(true); + resolve(); + } + }; + }); + MockFilePicker.returnValue = MockFilePicker.returnOK; + + // This is a little awkward, but we need to ensure that the + // filePickerShownPromise resolves first before we await + // the getPermissionsPromise in order to get the correct + // filePickerSeenCount. + let getPermissionsPromise = migrator.getPermissions(); + await filePickerShownPromise; + Assert.ok( + await getPermissionsPromise, + "Should report that we got permissions." + ); + + // Make sure that the user can also hit "cancel" and that we + // file picker loop. + + hasPermissionsStub.resolves(false); + + filePickerSeenCount = 0; + filePickerShownPromise = new Promise(resolve => { + MockFilePicker.showCallback = () => { + Assert.ok(true, "Filepicker shown."); + filePickerSeenCount++; + Assert.equal(filePickerSeenCount, 1, "Saw the picker once."); + resolve(); + }; + }); + MockFilePicker.returnValue = MockFilePicker.returnCancel; + Assert.ok( + !(await migrator.getPermissions()), + "Should report that we didn't get permissions." + ); + await filePickerShownPromise; + + sandbox.restore(); +}); diff --git a/browser/components/migration/tests/unit/test_fx_telemetry.js b/browser/components/migration/tests/unit/test_fx_telemetry.js new file mode 100644 index 0000000000..68e34beab3 --- /dev/null +++ b/browser/components/migration/tests/unit/test_fx_telemetry.js @@ -0,0 +1,436 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +const { FirefoxProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/FirefoxProfileMigrator.sys.mjs" +); +const { InternalTestingProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/InternalTestingProfileMigrator.sys.mjs" +); +const { LoginCSVImport } = ChromeUtils.importESModule( + "resource://gre/modules/LoginCSVImport.sys.mjs" +); +const { PasswordFileMigrator } = ChromeUtils.importESModule( + "resource:///modules/FileMigrators.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +// These preferences are set to true anytime MigratorBase.migrate +// successfully completes a migration of their type. +const BOOKMARKS_PREF = "browser.migrate.interactions.bookmarks"; +const CSV_PASSWORDS_PREF = "browser.migrate.interactions.csvpasswords"; +const HISTORY_PREF = "browser.migrate.interactions.history"; +const PASSWORDS_PREF = "browser.migrate.interactions.passwords"; + +function readFile(file) { + let stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + stream.init(file, -1, -1, Ci.nsIFileInputStream.CLOSE_ON_EOF); + + let sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( + Ci.nsIScriptableInputStream + ); + sis.init(stream); + let contents = sis.read(file.fileSize); + sis.close(); + return contents; +} + +function checkDirectoryContains(dir, files) { + print("checking " + dir.path + " - should contain " + Object.keys(files)); + let seen = new Set(); + for (let file of dir.directoryEntries) { + print("found file: " + file.path); + Assert.ok(file.leafName in files, file.leafName + " exists, but shouldn't"); + + let expectedContents = files[file.leafName]; + if (typeof expectedContents != "string") { + // it's a subdir - recurse! + Assert.ok(file.isDirectory(), "should be a subdir"); + let newDir = dir.clone(); + newDir.append(file.leafName); + checkDirectoryContains(newDir, expectedContents); + } else { + Assert.ok(!file.isDirectory(), "should be a regular file"); + let contents = readFile(file); + Assert.equal(contents, expectedContents); + } + seen.add(file.leafName); + } + let missing = []; + for (let x in files) { + if (!seen.has(x)) { + missing.push(x); + } + } + Assert.deepEqual(missing, [], "no missing files in " + dir.path); +} + +function getTestDirs() { + // we make a directory structure in a temp dir which mirrors what we are + // testing. + let tempDir = do_get_tempdir(); + let srcDir = tempDir.clone(); + srcDir.append("test_source_dir"); + srcDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + let targetDir = tempDir.clone(); + targetDir.append("test_target_dir"); + targetDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + // no need to cleanup these dirs - the xpcshell harness will do it for us. + return [srcDir, targetDir]; +} + +function writeToFile(dir, leafName, contents) { + let file = dir.clone(); + file.append(leafName); + + let outputStream = FileUtils.openFileOutputStream(file); + outputStream.write(contents, contents.length); + outputStream.close(); +} + +function createSubDir(dir, subDirName) { + let subDir = dir.clone(); + subDir.append(subDirName); + subDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + return subDir; +} + +async function promiseMigrator(name, srcDir, targetDir) { + // As the FirefoxProfileMigrator is a startup-only migrator, we import its + // module and instantiate it directly rather than going through MigrationUtils, + // to bypass that availability check. + let migrator = new FirefoxProfileMigrator(); + let migrators = migrator._getResourcesInternal(srcDir, targetDir); + for (let m of migrators) { + if (m.name == name) { + return new Promise(resolve => m.migrate(resolve)); + } + } + throw new Error("failed to find the " + name + " migrator"); +} + +function promiseTelemetryMigrator(srcDir, targetDir) { + return promiseMigrator("telemetry", srcDir, targetDir); +} + +add_task(async function test_empty() { + let [srcDir, targetDir] = getTestDirs(); + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true with empty directories"); + // check both are empty + checkDirectoryContains(srcDir, {}); + checkDirectoryContains(targetDir, {}); +}); + +add_task(async function test_migrate_files() { + let [srcDir, targetDir] = getTestDirs(); + + // Set up datareporting files, some to copy, some not. + let stateContent = JSON.stringify({ + clientId: "68d5474e-19dc-45c1-8e9a-81fca592707c", + }); + let sessionStateContent = "foobar 5432"; + let subDir = createSubDir(srcDir, "datareporting"); + writeToFile(subDir, "state.json", stateContent); + writeToFile(subDir, "session-state.json", sessionStateContent); + writeToFile(subDir, "other.file", "do not copy"); + + let archived = createSubDir(subDir, "archived"); + writeToFile(archived, "other.file", "do not copy"); + + // Set up FHR files, they should not be copied. + writeToFile(srcDir, "healthreport.sqlite", "do not copy"); + writeToFile(srcDir, "healthreport.sqlite-wal", "do not copy"); + subDir = createSubDir(srcDir, "healthreport"); + writeToFile(subDir, "state.json", "do not copy"); + writeToFile(subDir, "other.file", "do not copy"); + + // Perform migration. + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok( + ok, + "callback should have been true with important telemetry files copied" + ); + + checkDirectoryContains(targetDir, { + datareporting: { + "state.json": stateContent, + "session-state.json": sessionStateContent, + }, + }); +}); + +add_task(async function test_datareporting_not_dir() { + let [srcDir, targetDir] = getTestDirs(); + + writeToFile(srcDir, "datareporting", "I'm a file but should be a directory"); + + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok( + ok, + "callback should have been true even though the directory was a file" + ); + + checkDirectoryContains(targetDir, {}); +}); + +add_task(async function test_datareporting_empty() { + let [srcDir, targetDir] = getTestDirs(); + + // Migrate with an empty 'datareporting' subdir. + createSubDir(srcDir, "datareporting"); + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + // We should end up with no migrated files. + checkDirectoryContains(targetDir, { + datareporting: {}, + }); +}); + +add_task(async function test_healthreport_empty() { + let [srcDir, targetDir] = getTestDirs(); + + // Migrate with no 'datareporting' and an empty 'healthreport' subdir. + createSubDir(srcDir, "healthreport"); + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + // We should end up with no migrated files. + checkDirectoryContains(targetDir, {}); +}); + +add_task(async function test_datareporting_many() { + let [srcDir, targetDir] = getTestDirs(); + + // Create some datareporting files. + let subDir = createSubDir(srcDir, "datareporting"); + let shouldBeCopied = "should be copied"; + writeToFile(subDir, "state.json", shouldBeCopied); + writeToFile(subDir, "session-state.json", shouldBeCopied); + writeToFile(subDir, "something.else", "should not"); + createSubDir(subDir, "emptyDir"); + + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + checkDirectoryContains(targetDir, { + datareporting: { + "state.json": shouldBeCopied, + "session-state.json": shouldBeCopied, + }, + }); +}); + +add_task(async function test_no_session_state() { + let [srcDir, targetDir] = getTestDirs(); + + // Check that migration still works properly if we only have state.json. + let subDir = createSubDir(srcDir, "datareporting"); + let stateContent = "abcd984"; + writeToFile(subDir, "state.json", stateContent); + + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + checkDirectoryContains(targetDir, { + datareporting: { + "state.json": stateContent, + }, + }); +}); + +add_task(async function test_no_state() { + let [srcDir, targetDir] = getTestDirs(); + + // Check that migration still works properly if we only have session-state.json. + let subDir = createSubDir(srcDir, "datareporting"); + let sessionStateContent = "abcd512"; + writeToFile(subDir, "session-state.json", sessionStateContent); + + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + checkDirectoryContains(targetDir, { + datareporting: { + "session-state.json": sessionStateContent, + }, + }); +}); + +add_task(async function test_times_migration() { + let [srcDir, targetDir] = getTestDirs(); + + // create a times.json in the source directory. + let contents = JSON.stringify({ created: 1234 }); + writeToFile(srcDir, "times.json", contents); + + let earliest = Date.now(); + let ok = await promiseMigrator("times", srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + let latest = Date.now(); + + let timesFile = targetDir.clone(); + timesFile.append("times.json"); + + let raw = readFile(timesFile); + let times = JSON.parse(raw); + Assert.ok(times.reset >= earliest && times.reset <= latest); + // and it should have left the creation time alone. + Assert.equal(times.created, 1234); +}); + +/** + * Tests that when importing bookmarks, history, or passwords, we + * set interaction prefs. These preferences are sent using + * TelemetryEnvironment.sys.mjs. + */ +add_task(async function test_interaction_telemetry() { + let testingMigrator = await MigrationUtils.getMigrator( + InternalTestingProfileMigrator.key + ); + + Services.prefs.clearUserPref(BOOKMARKS_PREF); + Services.prefs.clearUserPref(HISTORY_PREF); + Services.prefs.clearUserPref(PASSWORDS_PREF); + + // Ensure that these prefs start false. + Assert.ok(!Services.prefs.getBoolPref(BOOKMARKS_PREF)); + Assert.ok(!Services.prefs.getBoolPref(HISTORY_PREF)); + Assert.ok(!Services.prefs.getBoolPref(PASSWORDS_PREF)); + + await testingMigrator.migrate( + MigrationUtils.resourceTypes.BOOKMARKS, + false, + InternalTestingProfileMigrator.testProfile + ); + Assert.ok( + Services.prefs.getBoolPref(BOOKMARKS_PREF), + "Bookmarks pref should have been set." + ); + Assert.ok(!Services.prefs.getBoolPref(HISTORY_PREF)); + Assert.ok(!Services.prefs.getBoolPref(PASSWORDS_PREF)); + + await testingMigrator.migrate( + MigrationUtils.resourceTypes.HISTORY, + false, + InternalTestingProfileMigrator.testProfile + ); + Assert.ok( + Services.prefs.getBoolPref(BOOKMARKS_PREF), + "Bookmarks pref should have been set." + ); + Assert.ok( + Services.prefs.getBoolPref(HISTORY_PREF), + "History pref should have been set." + ); + Assert.ok(!Services.prefs.getBoolPref(PASSWORDS_PREF)); + + await testingMigrator.migrate( + MigrationUtils.resourceTypes.PASSWORDS, + false, + InternalTestingProfileMigrator.testProfile + ); + Assert.ok( + Services.prefs.getBoolPref(BOOKMARKS_PREF), + "Bookmarks pref should have been set." + ); + Assert.ok( + Services.prefs.getBoolPref(HISTORY_PREF), + "History pref should have been set." + ); + Assert.ok( + Services.prefs.getBoolPref(PASSWORDS_PREF), + "Passwords pref should have been set." + ); + + // Now make sure that we still record these if we migrate a + // series of resources at the same time. + Services.prefs.clearUserPref(BOOKMARKS_PREF); + Services.prefs.clearUserPref(HISTORY_PREF); + Services.prefs.clearUserPref(PASSWORDS_PREF); + + await testingMigrator.migrate( + MigrationUtils.resourceTypes.ALL, + false, + InternalTestingProfileMigrator.testProfile + ); + Assert.ok( + Services.prefs.getBoolPref(BOOKMARKS_PREF), + "Bookmarks pref should have been set." + ); + Assert.ok( + Services.prefs.getBoolPref(HISTORY_PREF), + "History pref should have been set." + ); + Assert.ok( + Services.prefs.getBoolPref(PASSWORDS_PREF), + "Passwords pref should have been set." + ); +}); + +/** + * Tests that when importing passwords from a CSV file using the + * migration wizard, we set an interaction pref. This preference + * is sent using TelemetryEnvironment.sys.mjs. + */ +add_task(async function test_csv_password_interaction_telemetry() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let testingMigrator = new PasswordFileMigrator(); + + Services.prefs.clearUserPref(CSV_PASSWORDS_PREF); + Assert.ok(!Services.prefs.getBoolPref(CSV_PASSWORDS_PREF)); + + sandbox.stub(LoginCSVImport, "importFromCSV").resolves([]); + await testingMigrator.migrate("some/fake/path.csv"); + + Assert.ok( + Services.prefs.getBoolPref(CSV_PASSWORDS_PREF), + "CSV import pref should have been set." + ); + + sandbox.restore(); +}); + +/** + * Tests that interaction preferences used for TelemetryEnvironment are + * persisted across profile resets. + */ +add_task(async function test_interaction_telemetry_persist_across_reset() { + const PREFS = ` +user_pref("${BOOKMARKS_PREF}", true); +user_pref("${CSV_PASSWORDS_PREF}", true); +user_pref("${HISTORY_PREF}", true); +user_pref("${PASSWORDS_PREF}", true); + `; + + let [srcDir, targetDir] = getTestDirs(); + writeToFile(srcDir, "prefs.js", PREFS); + + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + let prefsPath = PathUtils.join(targetDir.path, "prefs.js"); + Assert.ok(await IOUtils.exists(prefsPath), "Prefs should have been written."); + let writtenPrefsString = await IOUtils.readUTF8(prefsPath); + for (let prefKey of [ + BOOKMARKS_PREF, + CSV_PASSWORDS_PREF, + HISTORY_PREF, + PASSWORDS_PREF, + ]) { + const EXPECTED = `user_pref("${prefKey}", true);`; + Assert.ok(writtenPrefsString.includes(EXPECTED), "Found persisted pref."); + } +}); diff --git a/browser/components/migration/tests/unit/xpcshell.toml b/browser/components/migration/tests/unit/xpcshell.toml new file mode 100644 index 0000000000..b599a64362 --- /dev/null +++ b/browser/components/migration/tests/unit/xpcshell.toml @@ -0,0 +1,95 @@ +[DEFAULT] +head = "head_migration.js" +tags = "condprof" +firefox-appdir = "browser" +skip-if = ["os == 'android'"] # bug 1730213 +prefs = ["browser.migrate.showBookmarksToolbarAfterMigration=true"] +support-files = [ + "Library/**", + "AppData/**", + "bookmarks.exported.html", + "bookmarks.exported.json", + "bookmarks.invalid.html", +] + +["test_360seMigrationUtils.js"] +run-if = ["os == 'win'"] + +["test_360se_bookmarks.js"] +run-if = ["os == 'win'"] + +["test_BookmarksFileMigrator.js"] + +["test_ChromeMigrationUtils.js"] + +["test_ChromeMigrationUtils_path.js"] + +["test_ChromeMigrationUtils_path_chromium_snap.js"] +run-if = ["os == 'linux'"] + +["test_Chrome_bookmarks.js"] + +["test_Chrome_corrupt_history.js"] + +["test_Chrome_credit_cards.js"] +skip-if = [ + "os == 'linux'", + "os == 'android'", + "condprof", # bug 1769154 - not realistic for condprof +] + +["test_Chrome_extensions.js"] + +["test_Chrome_formdata.js"] + +["test_Chrome_history.js"] +skip-if = ["os != 'mac'"] # Relies on ULibDir + +["test_Chrome_passwords.js"] +skip-if = [ + "os == 'linux'", + "os == 'android'", + "condprof", # bug 1769154 - not realistic for condprof +] + +["test_Chrome_passwords_emptySource.js"] +skip-if = [ + "os == 'linux'", + "os == 'android'", + "condprof", # bug 1769154 - not realistic for condprof +] +support-files = ["LibraryWithNoData/**"] + +["test_Chrome_permissions.js"] + +["test_Edge_db_migration.js"] +run-if = ["os == 'win'"] + +["test_Edge_registry_migration.js"] +run-if = ["os == 'win'"] + +["test_IE_bookmarks.js"] +run-if = ["os == 'win' && bits == 64"] # bug 1392396 + +["test_IE_history.js"] +run-if = ["os == 'win'"] +skip-if = ["os == 'win' && msix"] # https://bugzilla.mozilla.org/show_bug.cgi?id=1807928 + +["test_MigrationUtils_timedRetry.js"] +skip-if = ["os == 'mac' && !debug"] #Bug 1558330 + +["test_PasswordFileMigrator.js"] + +["test_Safari_bookmarks.js"] +run-if = ["os == 'mac'"] + +["test_Safari_history.js"] +run-if = ["os == 'mac'"] + +["test_Safari_history_strange_entries.js"] +run-if = ["os == 'mac'"] + +["test_Safari_permissions.js"] +run-if = ["os == 'mac'"] + +["test_fx_telemetry.js"] diff --git a/browser/components/moz.build b/browser/components/moz.build new file mode 100644 index 0000000000..6fd8695565 --- /dev/null +++ b/browser/components/moz.build @@ -0,0 +1,117 @@ +# -*- 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/. + +with Files("**"): + BUG_COMPONENT = ("Firefox", "General") + +with Files("distribution.sys.mjs"): + BUG_COMPONENT = ("Firefox", "Distributions") + +with Files("tests/**"): + BUG_COMPONENT = ("Firefox", "General") + +with Files("tests/browser/browser_contentpermissionprompt.js"): + BUG_COMPONENT = ("Firefox", "Site Permissions") + +with Files("tests/unit/test_distribution.js"): + BUG_COMPONENT = ("Firefox", "Distributions") + +with Files("safebrowsing/**"): + BUG_COMPONENT = ("Toolkit", "Safe Browsing") + +with Files("controlcenter/**"): + BUG_COMPONENT = ("Firefox", "General") + + +DIRS += [ + "about", + "aboutlogins", + "aboutwelcome", + "asrouter", + "attribution", + "contentanalysis", + "contextualidentity", + "customizableui", + "doh", + "downloads", + "enterprisepolicies", + "extensions", + "firefoxview", + "ion", + "messagepreview", + "migration", + "newtab", + "originattributes", + "pagedata", + "places", + "pocket", + "preferences", + "privatebrowsing", + "prompts", + "protections", + "protocolhandler", + "reportbrokensite", + "resistfingerprinting", + "screenshots", + "search", + "sessionstore", + "shell", + "shopping", + "syncedtabs", + "tabpreview", + "tabunloader", + "textrecognition", + "translations", + "uitour", + "urlbar", +] + +DIRS += ["build"] + + +if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa": + DIRS += ["touchbar"] +elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows": + DIRS += ["installerprefs"] + +XPIDL_SOURCES += [ + "nsIBrowserHandler.idl", +] + +XPIDL_MODULE = "browsercompsbase" + +XPCOM_MANIFESTS += [ + "components.conf", +] + +EXTRA_COMPONENTS += [ + "BrowserComponents.manifest", +] + +EXTRA_JS_MODULES += [ + "BrowserContentHandler.sys.mjs", + "BrowserGlue.sys.mjs", + "distribution.sys.mjs", +] + +if CONFIG["MOZ_DEBUG"] or CONFIG["MOZ_DEV_EDITION"] or CONFIG["NIGHTLY_BUILD"]: + EXTRA_JS_MODULES += [ + "StartupRecorder.sys.mjs", + ] + +BROWSER_CHROME_MANIFESTS += [ + "safebrowsing/content/test/browser.toml", + "tests/browser/browser.toml", +] + +if CONFIG["MOZ_UPDATER"]: + BROWSER_CHROME_MANIFESTS += [ + "tests/browser/whats_new_page/browser.toml", + ] + +MARIONETTE_MANIFESTS += ["tests/marionette/manifest.toml"] + +XPCSHELL_TESTS_MANIFESTS += ["tests/unit/xpcshell.toml"] diff --git a/browser/components/newtab/.eslintrc.js b/browser/components/newtab/.eslintrc.js new file mode 100644 index 0000000000..5fc7a4dcff --- /dev/null +++ b/browser/components/newtab/.eslintrc.js @@ -0,0 +1,184 @@ +/* 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/. */ + +module.exports = { + // When adding items to this file please check for effects on sub-directories. + plugins: ["import", "react", "jsx-a11y"], + settings: { + react: { + version: "16.2.0", + }, + }, + extends: ["plugin:jsx-a11y/recommended"], + overrides: [ + { + // TODO: Bug 1773467 - Move these to .mjs or figure out a generic way + // to identify these as modules. + files: [ + "content-src/**/*.js", + "test/schemas/**/*.js", + "test/unit/**/*.js", + ], + parserOptions: { + sourceType: "module", + }, + }, + { + // These files use fluent-dom to insert content + files: [ + "content-src/asrouter/templates/OnboardingMessage/**", + "content-src/asrouter/templates/FirstRun/**", + "content-src/components/TopSites/**", + "content-src/components/MoreRecommendations/MoreRecommendations.jsx", + "content-src/components/CollapsibleSection/CollapsibleSection.jsx", + "content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx", + "content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx", + "content-src/components/CustomizeMenu/**", + ], + rules: { + "jsx-a11y/anchor-has-content": "off", + "jsx-a11y/heading-has-content": "off", + "jsx-a11y/label-has-associated-control": "off", + "jsx-a11y/no-onchange": "off", + }, + }, + { + files: [ + "bin/**", + "content-src/**", + "loaders/**", + "tools/**", + "test/unit/**", + ], + env: { + node: true, + }, + }, + { + // Use a configuration that's appropriate for modules, workers and + // non-production files. + files: ["*.jsm", "lib/cache.worker.js", "test/**"], + rules: { + "no-implicit-globals": "off", + }, + }, + { + files: ["content-src/**", "test/unit/**"], + rules: { + // Disallow commonjs in these directories. + "import/no-commonjs": 2, + }, + }, + { + // These tests simulate the browser environment. + files: "test/unit/**", + env: { + browser: true, + mocha: true, + }, + globals: { + assert: true, + chai: true, + sinon: true, + }, + }, + { + files: "test/**", + rules: { + "func-name-matching": 0, + "lines-between-class-members": 0, + }, + }, + ], + rules: { + "fetch-options/no-fetch-credentials": "error", + + "react/jsx-boolean-value": ["error", "always"], + "react/jsx-key": "error", + "react/jsx-no-bind": [ + "error", + { allowArrowFunctions: true, allowFunctions: true }, + ], + "react/jsx-no-comment-textnodes": "error", + "react/jsx-no-duplicate-props": "error", + "react/jsx-no-target-blank": "error", + "react/jsx-no-undef": "error", + "react/jsx-pascal-case": "error", + "react/jsx-uses-react": "error", + "react/jsx-uses-vars": "error", + "react/no-access-state-in-setstate": "error", + "react/no-danger": "error", + "react/no-deprecated": "error", + "react/no-did-mount-set-state": "error", + "react/no-did-update-set-state": "error", + "react/no-direct-mutation-state": "error", + "react/no-is-mounted": "error", + "react/no-unknown-property": "error", + "react/require-render-return": "error", + + "accessor-pairs": ["error", { setWithoutGet: true, getWithoutSet: false }], + "array-callback-return": "error", + "block-scoped-var": "error", + "consistent-this": ["error", "use-bind"], + eqeqeq: "error", + "func-name-matching": "error", + "getter-return": "error", + "guard-for-in": "error", + "max-depth": ["error", 4], + "max-nested-callbacks": ["error", 4], + "max-params": ["error", 6], + "max-statements": ["error", 50], + "new-cap": ["error", { newIsCap: true, capIsNew: false }], + "no-alert": "error", + "no-console": ["error", { allow: ["error"] }], + "no-div-regex": "error", + "no-duplicate-imports": "error", + "no-eq-null": "error", + "no-extend-native": "error", + "no-extra-label": "error", + "no-implicit-coercion": ["error", { allow: ["!!"] }], + "no-implicit-globals": "error", + "no-loop-func": "error", + "no-multi-assign": "error", + "no-multi-str": "error", + "no-new": "error", + "no-new-func": "error", + "no-octal-escape": "error", + "no-param-reassign": "error", + "no-proto": "error", + "no-prototype-builtins": "error", + "no-return-assign": ["error", "except-parens"], + "no-script-url": "error", + "no-shadow": "error", + "no-template-curly-in-string": "error", + "no-undef-init": "error", + "no-unmodified-loop-condition": "error", + "no-unused-expressions": "error", + "no-use-before-define": "error", + "no-useless-computed-key": "error", + "no-useless-constructor": "error", + "no-useless-rename": "error", + "no-var": "error", + "no-void": ["error", { allowAsStatement: true }], + "one-var": ["error", "never"], + "operator-assignment": ["error", "always"], + "prefer-destructuring": [ + "error", + { + AssignmentExpression: { array: true }, + VariableDeclarator: { array: true, object: true }, + }, + ], + "prefer-numeric-literals": "error", + "prefer-promise-reject-errors": "error", + "prefer-rest-params": "error", + "prefer-spread": "error", + "prefer-template": "error", + radix: ["error", "always"], + "sort-vars": "error", + "symbol-description": "error", + "vars-on-top": "error", + yoda: ["error", "never"], + }, +}; diff --git a/browser/components/newtab/.nvmrc b/browser/components/newtab/.nvmrc new file mode 100644 index 0000000000..e65243f2ea --- /dev/null +++ b/browser/components/newtab/.nvmrc @@ -0,0 +1 @@ +16.19.0 diff --git a/browser/components/newtab/AboutNewTabService.sys.mjs b/browser/components/newtab/AboutNewTabService.sys.mjs new file mode 100644 index 0000000000..e73e1b1880 --- /dev/null +++ b/browser/components/newtab/AboutNewTabService.sys.mjs @@ -0,0 +1,510 @@ +/** + * 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/. + */ + +/** + * The nsIAboutNewTabService is accessed by the AboutRedirector anytime + * about:home, about:newtab or about:welcome are requested. The primary + * job of an nsIAboutNewTabService is to tell the AboutRedirector what + * resources to actually load for those requests. + * + * The nsIAboutNewTabService is not involved when the user has overridden + * the default about:home or about:newtab pages. + * + * There are two implementations of this service - one for the parent + * process, and one for content processes. Each one has some secondary + * responsibilties that are process-specific. + * + * The need for two implementations is an unfortunate consequence of how + * document loading and process redirection for about: pages currently + * works in Gecko. The commonalities between the two implementations has + * been put into an abstract base class. + */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { E10SUtils } from "resource://gre/modules/E10SUtils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BasePromiseWorker: "resource://gre/modules/PromiseWorker.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", +}); + +/** + * BEWARE: Do not add variables for holding state in the global scope. + * Any state variables should be properties of the appropriate class + * below. This is to avoid confusion where the state is set in one process, + * but not in another. + * + * Constants are fine in the global scope. + */ + +const PREF_ABOUT_HOME_CACHE_TESTING = + "browser.startup.homepage.abouthome_cache.testing"; +const ABOUT_WELCOME_URL = + "chrome://browser/content/aboutwelcome/aboutwelcome.html"; + +const CACHE_WORKER_URL = "resource://activity-stream/lib/cache.worker.js"; + +const IS_PRIVILEGED_PROCESS = + Services.appinfo.remoteType === E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE; + +const PREF_SEPARATE_PRIVILEGEDABOUT_CONTENT_PROCESS = + "browser.tabs.remote.separatePrivilegedContentProcess"; +const PREF_ACTIVITY_STREAM_DEBUG = "browser.newtabpage.activity-stream.debug"; + +/** + * The AboutHomeStartupCacheChild is responsible for connecting the + * nsIAboutNewTabService with a cached document and script for about:home + * if one happens to exist. The AboutHomeStartupCacheChild is only ever + * handed the streams for those caches when the "privileged about content + * process" first launches, so subsequent loads of about:home do not read + * from this cache. + * + * See https://firefox-source-docs.mozilla.org/browser/components/newtab/docs/v2-system-addon/about_home_startup_cache.html + * for further details. + */ +export const AboutHomeStartupCacheChild = { + _initted: false, + CACHE_REQUEST_MESSAGE: "AboutHomeStartupCache:CacheRequest", + CACHE_RESPONSE_MESSAGE: "AboutHomeStartupCache:CacheResponse", + CACHE_USAGE_RESULT_MESSAGE: "AboutHomeStartupCache:UsageResult", + STATES: { + UNAVAILABLE: 0, + UNCONSUMED: 1, + PAGE_CONSUMED: 2, + PAGE_AND_SCRIPT_CONSUMED: 3, + FAILED: 4, + DISQUALIFIED: 5, + }, + REQUEST_TYPE: { + PAGE: 0, + SCRIPT: 1, + }, + _state: 0, + _consumerBCID: null, + + /** + * Called via a process script very early on in the process lifetime. This + * prepares the AboutHomeStartupCacheChild to pass an nsIChannel back to + * the nsIAboutNewTabService when the initial about:home document is + * eventually requested. + * + * @param pageInputStream (nsIInputStream) + * The stream for the cached page markup. + * @param scriptInputStream (nsIInputStream) + * The stream for the cached script to run on the page. + */ + init(pageInputStream, scriptInputStream) { + if ( + !IS_PRIVILEGED_PROCESS && + !Services.prefs.getBoolPref(PREF_ABOUT_HOME_CACHE_TESTING, false) + ) { + throw new Error( + "Can only instantiate in the privileged about content processes." + ); + } + + if (!lazy.NimbusFeatures.abouthomecache.getVariable("enabled")) { + return; + } + + if (this._initted) { + throw new Error("AboutHomeStartupCacheChild already initted."); + } + + Services.obs.addObserver(this, "memory-pressure"); + Services.cpmm.addMessageListener(this.CACHE_REQUEST_MESSAGE, this); + + this._pageInputStream = pageInputStream; + this._scriptInputStream = scriptInputStream; + this._initted = true; + this.setState(this.STATES.UNCONSUMED); + }, + + /** + * A function that lets us put the AboutHomeStartupCacheChild back into + * its initial state. This is used by tests to let us simulate the startup + * behaviour of the module without having to manually launch a new privileged + * about content process every time. + */ + uninit() { + if (!Services.prefs.getBoolPref(PREF_ABOUT_HOME_CACHE_TESTING, false)) { + throw new Error( + "Cannot uninit AboutHomeStartupCacheChild unless testing." + ); + } + + if (!this._initted) { + return; + } + + Services.obs.removeObserver(this, "memory-pressure"); + Services.cpmm.removeMessageListener(this.CACHE_REQUEST_MESSAGE, this); + + if (this._cacheWorker) { + this._cacheWorker.terminate(); + this._cacheWorker = null; + } + + this._pageInputStream = null; + this._scriptInputStream = null; + this._initted = false; + this._state = this.STATES.UNAVAILABLE; + this._consumerBCID = null; + }, + + /** + * A public method called from nsIAboutNewTabService that attempts + * return an nsIChannel for a cached about:home document that we + * were initialized with. If we failed to be initted with the + * cache, or the input streams that we were sent have no data + * yet available, this function returns null. The caller should + * fall back to generating the page dynamically. + * + * This function will be called when loading about:home, or + * about:home?jscache - the latter returns the cached script. + * + * It is expected that the same BrowsingContext that loads the cached + * page will also load the cached script. + * + * @param uri (nsIURI) + * The URI for the requested page, as passed by nsIAboutNewTabService. + * @param loadInfo (nsILoadInfo) + * The nsILoadInfo for the requested load, as passed by + * nsIAboutNewWTabService. + * @return nsIChannel or null. + */ + maybeGetCachedPageChannel(uri, loadInfo) { + if (!this._initted) { + return null; + } + + if (this._state >= this.STATES.PAGE_AND_SCRIPT_CONSUMED) { + return null; + } + + let requestType = + uri.query === "jscache" + ? this.REQUEST_TYPE.SCRIPT + : this.REQUEST_TYPE.PAGE; + + // If this is a page request, then we need to be in the UNCONSUMED state, + // since we expect the page request to come first. If this is a script + // request, we expect to be in PAGE_CONSUMED state, since the page cache + // stream should he been consumed already. + if ( + (requestType === this.REQUEST_TYPE.PAGE && + this._state !== this.STATES.UNCONSUMED) || + (requestType === this.REQUEST_TYPE_SCRIPT && + this._state !== this.STATES.PAGE_CONSUMED) + ) { + return null; + } + + // If by this point, we don't have anything in the streams, + // then either the cache was too slow to give us data, or the cache + // doesn't exist. The caller should fall back to generating the + // page dynamically. + // + // We only do this on the page request, because by the time + // we get to the script request, we should have already drained + // the page input stream. + if (requestType === this.REQUEST_TYPE.PAGE) { + try { + if ( + !this._scriptInputStream.available() || + !this._pageInputStream.available() + ) { + this.setState(this.STATES.FAILED); + this.reportUsageResult(false /* success */); + return null; + } + } catch (e) { + this.setState(this.STATES.FAILED); + if (e.result === Cr.NS_BASE_STREAM_CLOSED) { + this.reportUsageResult(false /* success */); + return null; + } + throw e; + } + } + + if ( + requestType === this.REQUEST_TYPE.SCRIPT && + this._consumerBCID !== loadInfo.browsingContextID + ) { + // Some other document is somehow requesting the script - one + // that didn't originally request the page. This is not allowed. + this.setState(this.STATES.FAILED); + return null; + } + + let channel = Cc[ + "@mozilla.org/network/input-stream-channel;1" + ].createInstance(Ci.nsIInputStreamChannel); + channel.QueryInterface(Ci.nsIChannel); + channel.setURI(uri); + channel.loadInfo = loadInfo; + channel.contentStream = + requestType === this.REQUEST_TYPE.PAGE + ? this._pageInputStream + : this._scriptInputStream; + + if (requestType === this.REQUEST_TYPE.SCRIPT) { + this.setState(this.STATES.PAGE_AND_SCRIPT_CONSUMED); + this.reportUsageResult(true /* success */); + } else { + this.setState(this.STATES.PAGE_CONSUMED); + // Stash the BrowsingContext ID so that when the script stream + // attempts to be consumed, we ensure that it's from the same + // BrowsingContext that loaded the page. + this._consumerBCID = loadInfo.browsingContextID; + } + + return channel; + }, + + /** + * This function takes the state information required to generate + * the about:home cache markup and script, and then generates that + * markup in script asynchronously. Once that's done, a message + * is sent to the parent process with the nsIInputStream's for the + * markup and script contents. + * + * @param state (Object) + * The Redux state of the about:home document to render. + * @return Promise + * @resolves undefined + * After the message with the nsIInputStream's have been sent to + * the parent. + */ + async constructAndSendCache(state) { + if (!IS_PRIVILEGED_PROCESS) { + throw new Error("Wrong process type."); + } + + let worker = this.getOrCreateWorker(); + + TelemetryStopwatch.start("FX_ABOUTHOME_CACHE_CONSTRUCTION"); + + let { page, script } = await worker + .post("construct", [state]) + .finally(() => { + TelemetryStopwatch.finish("FX_ABOUTHOME_CACHE_CONSTRUCTION"); + }); + + let pageInputStream = Cc[ + "@mozilla.org/io/string-input-stream;1" + ].createInstance(Ci.nsIStringInputStream); + + pageInputStream.setUTF8Data(page); + + let scriptInputStream = Cc[ + "@mozilla.org/io/string-input-stream;1" + ].createInstance(Ci.nsIStringInputStream); + + scriptInputStream.setUTF8Data(script); + + Services.cpmm.sendAsyncMessage(this.CACHE_RESPONSE_MESSAGE, { + pageInputStream, + scriptInputStream, + }); + }, + + _cacheWorker: null, + getOrCreateWorker() { + if (this._cacheWorker) { + return this._cacheWorker; + } + + this._cacheWorker = new lazy.BasePromiseWorker(CACHE_WORKER_URL); + return this._cacheWorker; + }, + + receiveMessage(message) { + if (message.name === this.CACHE_REQUEST_MESSAGE) { + let { state } = message.data; + this.constructAndSendCache(state); + } + }, + + reportUsageResult(success) { + Services.cpmm.sendAsyncMessage(this.CACHE_USAGE_RESULT_MESSAGE, { + success, + }); + }, + + observe(subject, topic, data) { + if (topic === "memory-pressure" && this._cacheWorker) { + this._cacheWorker.terminate(); + this._cacheWorker = null; + } + }, + + /** + * Transitions the AboutHomeStartupCacheChild from one state + * to the next, where each state is defined in this.STATES. + * + * States can only be transitioned in increasing order, otherwise + * an error is logged. + */ + setState(state) { + if (state > this._state) { + this._state = state; + } else { + console.error( + "AboutHomeStartupCacheChild could not transition from state " + + `${this._state} to ${state}`, + new Error().stack + ); + } + }, + + /** + * If the cache hasn't been used, transitions it into the DISQUALIFIED + * state so that it cannot be used. This should be called if it's been + * determined that about:newtab is going to be loaded, which doesn't + * use the cache. + */ + disqualifyCache() { + if (this._state === this.STATES.UNCONSUMED) { + this.setState(this.STATES.DISQUALIFIED); + this.reportUsageResult(false /* success */); + } + }, +}; + +/** + * This is an abstract base class for the nsIAboutNewTabService + * implementations that has some common methods and properties. + */ +class BaseAboutNewTabService { + constructor() { + if (!AppConstants.RELEASE_OR_BETA) { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "activityStreamDebug", + PREF_ACTIVITY_STREAM_DEBUG, + false + ); + } else { + this.activityStreamDebug = false; + } + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "privilegedAboutProcessEnabled", + PREF_SEPARATE_PRIVILEGEDABOUT_CONTENT_PROCESS, + false + ); + + this.classID = Components.ID("{cb36c925-3adc-49b3-b720-a5cc49d8a40e}"); + this.QueryInterface = ChromeUtils.generateQI([ + "nsIAboutNewTabService", + "nsIObserver", + ]); + } + + /** + * Returns the default URL. + * + * This URL depends on various activity stream prefs. Overriding + * the newtab page has no effect on the result of this function. + */ + get defaultURL() { + // Generate the desired activity stream resource depending on state, e.g., + // "resource://activity-stream/prerendered/activity-stream.html" + // "resource://activity-stream/prerendered/activity-stream-debug.html" + // "resource://activity-stream/prerendered/activity-stream-noscripts.html" + return [ + "resource://activity-stream/prerendered/", + "activity-stream", + // Debug version loads dev scripts but noscripts separately loads scripts + this.activityStreamDebug && !this.privilegedAboutProcessEnabled + ? "-debug" + : "", + this.privilegedAboutProcessEnabled ? "-noscripts" : "", + ".html", + ].join(""); + } + + get welcomeURL() { + /* + * Returns the about:welcome URL + * + * This is calculated in the same way the default URL is. + */ + + lazy.NimbusFeatures.aboutwelcome.recordExposureEvent({ once: true }); + if (lazy.NimbusFeatures.aboutwelcome.getVariable("enabled") ?? true) { + return ABOUT_WELCOME_URL; + } + return this.defaultURL; + } + + aboutHomeChannel(uri, loadInfo) { + throw Components.Exception( + "AboutHomeChannel not implemented for this process.", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } +} + +/** + * The child-process implementation of nsIAboutNewTabService, + * which also does the work of redirecting about:home loads to + * the about:home startup cache if its available. + */ +class AboutNewTabChildService extends BaseAboutNewTabService { + aboutHomeChannel(uri, loadInfo) { + if (IS_PRIVILEGED_PROCESS) { + let cacheChannel = AboutHomeStartupCacheChild.maybeGetCachedPageChannel( + uri, + loadInfo + ); + if (cacheChannel) { + return cacheChannel; + } + } + + let pageURI = Services.io.newURI(this.defaultURL); + let fileChannel = Services.io.newChannelFromURIWithLoadInfo( + pageURI, + loadInfo + ); + fileChannel.originalURI = uri; + return fileChannel; + } + + get defaultURL() { + if (IS_PRIVILEGED_PROCESS) { + // This is a bit of a hack, but attempting to load about:newtab will + // enter this code path in order to get at the expected URL, and we + // can use that to disqualify the about:home cache, since we don't + // use it for about:newtab loads, and we don't want the about:home + // cache to be wildly out of date when about:home is eventually + // loaded (for example, in the first new window). + AboutHomeStartupCacheChild.disqualifyCache(); + } + + return super.defaultURL; + } +} + +/** + * The AboutNewTabStubService is a function called in both the main and + * content processes when trying to get at the nsIAboutNewTabService. This + * function does the job of choosing the appropriate implementation of + * nsIAboutNewTabService for the process type. + */ +export function AboutNewTabStubService() { + if (Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT) { + return new BaseAboutNewTabService(); + } + return new AboutNewTabChildService(); +} diff --git a/browser/components/newtab/bin/render-activity-stream-html.js b/browser/components/newtab/bin/render-activity-stream-html.js new file mode 100644 index 0000000000..41b77c35db --- /dev/null +++ b/browser/components/newtab/bin/render-activity-stream-html.js @@ -0,0 +1,188 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-disable no-console */ +const fs = require("fs"); +const { mkdir } = require("shelljs"); +const path = require("path"); +const { pathToFileURL } = require("url"); +const chalk = require("chalk"); + +const DEFAULT_OPTIONS = { + // Glob leading from CWD to the parent of the intended prerendered directory. + // Starting in newtab/bin/ and we want to write to newtab/prerendered/ so we + // go up one level. + addonPath: "..", + // depends on the registration in browser/components/newtab/jar.mn + baseUrl: "resource://activity-stream/", +}; + +/** + * templateHTML - Generates HTML for activity stream, given some options and + * prerendered HTML if necessary. + * + * @param {obj} options + * {str} options.baseUrl The base URL for all local assets + * {bool} options.debug Should we use dev versions of JS libraries? + * {bool} options.noscripts Should we include scripts in the prerendered files? + * @return {str} An HTML document as a string + */ +function templateHTML(options) { + const debugString = options.debug ? "-dev" : ""; + // This list must match any similar ones in AboutNewTabChild.sys.mjs + const scripts = [ + "chrome://browser/content/contentSearchUI.js", + "chrome://browser/content/contentSearchHandoffUI.js", + "chrome://browser/content/contentTheme.js", + `${options.baseUrl}vendor/react${debugString}.js`, + `${options.baseUrl}vendor/react-dom${debugString}.js`, + `${options.baseUrl}vendor/prop-types.js`, + `${options.baseUrl}vendor/redux.js`, + `${options.baseUrl}vendor/react-redux.js`, + `${options.baseUrl}vendor/react-transition-group.js`, + `${options.baseUrl}data/content/activity-stream.bundle.js`, + `${options.baseUrl}data/content/newtab-render.js`, + ]; + + // Add spacing and script tags + const scriptRender = `\n${scripts + .map(script => ` `) + .join("\n")}`; + + // The markup below needs to be formatted by Prettier. But any diff after + // running this script should be caught by try-runnner.js + return ` + + + + + + + + + + + + + + + + + + +
    ${options.noscripts ? "" : scriptRender} + + + +`.trimLeft(); +} + +/** + * writeFiles - Writes to the desired files the result of a template given + * various prerendered data and options. + * + * @param {string} destPath Path to write the files to + * @param {Map} filesMap Mapping of a string file name to templater + * @param {Object} options Various options for the templater + */ +function writeFiles(destPath, filesMap, options) { + for (const [file, templater] of filesMap) { + fs.writeFileSync(path.join(destPath, file), templater({ options })); + console.log(chalk.green(`✓ ${file}`)); + } +} + +const STATIC_FILES = new Map([ + ["activity-stream.html", ({ options }) => templateHTML(options)], + [ + "activity-stream-debug.html", + ({ options }) => templateHTML(Object.assign({}, options, { debug: true })), + ], + [ + "activity-stream-noscripts.html", + ({ options }) => + templateHTML(Object.assign({}, options, { noscripts: true })), + ], +]); + +/** + * main - Parses command line arguments, generates html and js with templates, + * and writes files to their specified locations. + */ +async function main() { + const { default: meow } = await import("meow"); + const fileUrl = pathToFileURL(__filename); + const cli = meow( + ` + Usage + $ node ./bin/render-activity-stream-html.js [options] + + Options + -a PATH, --addon-path PATH Path to the parent of the target directory. + default: "${DEFAULT_OPTIONS.addonPath}" + -b URL, --base-url URL Base URL for assets. + default: "${DEFAULT_OPTIONS.baseUrl}" + --help Show this help message. +`, + { + description: false, + // `pkg` is a tiny optimization. It prevents meow from looking for a package + // that doesn't technically exist. meow searches for a package and changes + // the process name to the package name. It resolves to the newtab + // package.json, which would give a confusing name and be wasteful. + pkg: { + name: "render-activity-stream-html", + version: "0.0.0", + }, + // `importMeta` is required by meow 10+. It was added to support ESM, but + // meow now requires it, and no longer supports CJS style imports. But it + // only uses import.meta.url, which can be polyfilled like this: + importMeta: { url: fileUrl }, + flags: { + addonPath: { + type: "string", + alias: "a", + default: DEFAULT_OPTIONS.addonPath, + }, + baseUrl: { + type: "string", + alias: "b", + default: DEFAULT_OPTIONS.baseUrl, + }, + }, + } + ); + + const options = Object.assign({ debug: false }, cli.flags || {}); + const addonPath = path.resolve(__dirname, options.addonPath); + const prerenderedPath = path.join(addonPath, "prerendered"); + console.log(`Writing prerendered files to ${prerenderedPath}:`); + + mkdir("-p", prerenderedPath); + writeFiles(prerenderedPath, STATIC_FILES, options); +} + +main(); diff --git a/browser/components/newtab/bin/try-runner.js b/browser/components/newtab/bin/try-runner.js new file mode 100644 index 0000000000..93b88fac23 --- /dev/null +++ b/browser/components/newtab/bin/try-runner.js @@ -0,0 +1,366 @@ +/* eslint-disable no-console */ +/* 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 . */ + +/* + * A small test runner/reporter for node-based tests, + * which are run via taskcluster node(debugger). + * + * Forked from + * https://searchfox.org/mozilla-central/rev/c3453c7a0427eb27d467e1582f821f402aed9850/devtools/client/debugger/bin/try-runner.js + */ + +const { execFileSync } = require("child_process"); +const { readFileSync, writeFileSync } = require("fs"); +const path = require("path"); +const { pathToFileURL } = require("url"); +const chalk = require("chalk"); + +function logErrors(tool, errors) { + for (const error of errors) { + console.log(`TEST-UNEXPECTED-FAIL | ${tool} | ${error}`); + } + return errors; +} + +function execOut(...args) { + let exitCode = 0; + let out; + let err; + + try { + out = execFileSync(...args, { + silent: false, + }); + } catch (e) { + // For debugging on (eg) try server... + // + // if (e) { + // logErrors("execOut", ["execFileSync returned exception: ", e]); + // } + + out = e && e.stdout; + err = e && e.stderr; + exitCode = e && e.status; + } + return { exitCode, out: out && out.toString(), err: err && err.toString() }; +} + +function logStart(name) { + console.log(`TEST-START | ${name}`); +} + +function logSkip(name) { + console.log(`TEST-SKIP | ${name}`); +} + +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + +const tests = { + bundles() { + logStart("bundles"); + + const items = { + "Activity Stream bundle": { + path: path.join("data", "content", "activity-stream.bundle.js"), + }, + "activity-stream.html": { + path: path.join("prerendered", "activity-stream.html"), + }, + "activity-stream-debug.html": { + path: path.join("prerendered", "activity-stream-debug.html"), + }, + "activity-stream-noscripts.html": { + path: path.join("prerendered", "activity-stream-noscripts.html"), + }, + "activity-stream-linux.css": { + path: path.join("css", "activity-stream-linux.css"), + }, + "activity-stream-mac.css": { + path: path.join("css", "activity-stream-mac.css"), + }, + "activity-stream-windows.css": { + path: path.join("css", "activity-stream-windows.css"), + }, + // These should get split out to their own try-runner eventually (bug 1866170). + "about:welcome bundle": { + path: path.join( + "../", + "aboutwelcome", + "content", + "aboutwelcome.bundle.js" + ), + }, + "aboutwelcome.css": { + path: path.join("../", "aboutwelcome", "content", "aboutwelcome.css"), + extraCheck: content => { + if (content.match(/^\s*@import/m)) { + return "aboutwelcome.css contains an @import statement. We should not import styles through the stylesheet, because it is loaded in multiple environments, including the browser chrome for feature callouts. To add other stylesheets to about:welcome or spotlight, add them to aboutwelcome.html or spotlight.html instead."; + } + return null; + }, + }, + // These should get split out to their own try-runner eventually (bug 1866170). + "about:asrouter bundle": { + path: path.join( + "../", + "asrouter", + "content", + "asrouter-admin.bundle.js" + ), + }, + "ASRouterAdmin.css": { + path: path.join( + "../", + "asrouter", + "content", + "components", + "ASRouterAdmin", + "ASRouterAdmin.css" + ), + }, + }; + const errors = []; + + for (const name of Object.keys(items)) { + const item = items[name]; + item.before = readFileSync(item.path, item.encoding || "utf8"); + } + + let newtabBundleExitCode = execOut(npmCommand, ["run", "bundle"]).exitCode; + + // Until we split out the try runner for about:welcome out into its own + // script, we manually run its bundle script. + let cwd = process.cwd(); + process.chdir("../aboutwelcome"); + let welcomeBundleExitCode = execOut(npmCommand, ["run", "bundle"]).exitCode; + process.chdir(cwd); + + // Same thing for about:asrouter + process.chdir("../asrouter"); + let asrouterBundleExitCode = execOut(npmCommand, [ + "run", + "bundle", + ]).exitCode; + process.chdir(cwd); + + for (const name of Object.keys(items)) { + const item = items[name]; + const after = readFileSync(item.path, item.encoding || "utf8"); + + if (item.before !== after) { + errors.push(`${name} out of date`); + } + + if (item.extraCheck) { + const extraError = item.extraCheck(after); + if (extraError) { + errors.push(extraError); + } + } + } + + if (newtabBundleExitCode !== 0) { + errors.push("newtab npm:bundle did not run successfully"); + } + + if (welcomeBundleExitCode !== 0) { + errors.push("about:welcome npm:bundle did not run successfully"); + } + + if (asrouterBundleExitCode !== 0) { + errors.push("about:asrouter npm:bundle did not run successfully"); + } + + logErrors("bundles", errors); + return errors.length === 0; + }, + + karma() { + logStart(`karma ${process.cwd()}`); + + const errors = []; + const { exitCode, out } = execOut(npmCommand, [ + "run", + "testmc:unit", + // , "--", "--log-level", "--verbose", + // to debug the karma integration, uncomment the above line + ]); + + // karma spits everything to stdout, not stderr, so if nothing came back on + // stdout, give up now. + if (!out) { + return false; + } + + // Detect mocha failures + let jsonContent; + try { + // Note that this will be overwritten at each run, but that shouldn't + // matter. + jsonContent = readFileSync(path.join("logs", "karma-run-results.json")); + } catch (ex) { + console.error("exception reading karma-run-results.json: ", ex); + return false; + } + const results = JSON.parse(jsonContent); + // eslint-disable-next-line guard-for-in + for (let testArray in results.result) { + let failedTests = Array.from(results.result[testArray]).filter( + test => !test.success && !test.skipped + ); + + errors.push( + ...failedTests.map( + test => `${test.suite.join(":")} ${test.description}: ${test.log[0]}` + ) + ); + } + + // Detect istanbul failures (coverage thresholds set in karma config) + const coverage = out.match(/ERROR.+coverage-istanbul.+/g); + if (coverage) { + errors.push(...coverage.map(line => line.match(/Coverage.+/)[0])); + } + + logErrors(`karma ${process.cwd()}`, errors); + + console.log("-----karma stdout below this line---"); + console.log(out); + console.log("-----karma stdout above this line---"); + + // Pass if there's no detected errors and nothing unexpected. + return errors.length === 0 && !exitCode; + }, + + welcomekarma() { + let cwd = process.cwd(); + process.chdir("../aboutwelcome"); + const result = this.karma(); + process.chdir(cwd); + return result; + }, + + asrouterkarma() { + let cwd = process.cwd(); + process.chdir("../asrouter"); + const result = this.karma(); + process.chdir(cwd); + return result; + }, + + zipCodeCoverage() { + logStart("zipCodeCoverage"); + + const newtabCoveragePath = "logs/coverage/lcov.info"; + const welcomeCoveragePath = "../aboutwelcome/logs/coverage/lcov.info"; + const asrouterCoveragePath = "../asrouter/logs/coverage/lcov.info"; + + let newtabCoverage = readFileSync(newtabCoveragePath, "utf8"); + const welcomeCoverage = readFileSync(welcomeCoveragePath, "utf8"); + const asrouterCoverage = readFileSync(asrouterCoveragePath, "utf8"); + + newtabCoverage = `${newtabCoverage}${welcomeCoverage}${asrouterCoverage}`; + writeFileSync(newtabCoveragePath, newtabCoverage, "utf8"); + + const { exitCode, out } = execOut("zip", [ + "-j", + "logs/coverage/code-coverage-grcov", + "logs/coverage/lcov.info", + ]); + + console.log("zipCodeCoverage log output: ", out); + + if (!exitCode) { + return true; + } + + return false; + }, +}; + +async function main() { + const { default: meow } = await import("meow"); + const fileUrl = pathToFileURL(__filename); + const cli = meow( + ` + Usage + $ node bin/try-runner.js [options] + + Options + -t NAME, --test NAME Run only the specified test. If not specified, + all tests will be run. Argument can be passed + multiple times to run multiple tests. + --help Show this help message. + + Examples + $ node bin/try-runner.js bundles karma + $ node bin/try-runner.js -t karma -t zip +`, + { + description: false, + // `pkg` is a tiny optimization. It prevents meow from looking for a package + // that doesn't technically exist. meow searches for a package and changes + // the process name to the package name. It resolves to the newtab + // package.json, which would give a confusing name and be wasteful. + pkg: { + name: "try-runner", + version: "1.0.0", + }, + // `importMeta` is required by meow 10+. It was added to support ESM, but + // meow now requires it, and no longer supports CJS style imports. But it + // only uses import.meta.url, which can be polyfilled like this: + importMeta: { url: fileUrl }, + flags: { + test: { + type: "string", + isMultiple: true, + alias: "t", + }, + }, + } + ); + const aliases = { + bundle: "bundles", + build: "bundles", + coverage: "karma", + cov: "karma", + zip: "zipCodeCoverage", + welcomecoverage: "welcomekarma", + welcomecov: "welcomekarma", + asroutercoverage: "asrouterkarma", + asroutercov: "asrouterkarma", + }; + + const inputs = [...cli.input, ...cli.flags.test].map(input => + (aliases[input] || input).toLowerCase() + ); + + function shouldRunTest(name) { + if (inputs.length) { + return inputs.includes(name.toLowerCase()); + } + return true; + } + + const results = []; + for (const name of Object.keys(tests)) { + if (shouldRunTest(name)) { + results.push([name, tests[name]()]); + } else { + logSkip(name); + } + } + + for (const [name, result] of results) { + // colorize output based on result + console.log(result ? chalk.green(`✓ ${name}`) : chalk.red(`✗ ${name}`)); + } + + const success = results.every(([, result]) => result); + process.exitCode = success ? 0 : 1; + console.log("CODE", process.exitCode); +} + +main(); diff --git a/browser/components/newtab/bin/vendor.js b/browser/components/newtab/bin/vendor.js new file mode 100644 index 0000000000..3d929dcf4b --- /dev/null +++ b/browser/components/newtab/bin/vendor.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +/* 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 no-console */ + +const { cp, set } = require("shelljs"); +const path = require("path"); + +const filesToVendor = { + // XXX currently these two licenses are identical. Perhaps we should check + // in case that changes at some point in the future. + "react/LICENSE": "REACT_AND_REACT_DOM_LICENSE", + "react/umd/react.production.min.js": "react.js", + "react/umd/react.development.js": "react-dev.js", + "react-dom/umd/react-dom.production.min.js": "react-dom.js", + "react-dom/umd/react-dom.development.js": "react-dom-dev.js", + "react-dom/umd/react-dom-server.browser.production.min.js": + "react-dom-server.js", + "react-redux/LICENSE.md": "REACT_REDUX_LICENSE", + "react-redux/dist/react-redux.min.js": "react-redux.js", + "react-transition-group/dist/react-transition-group.min.js": + "react-transition-group.js", + "react-transition-group/LICENSE": "REACT_TRANSITION_GROUP_LICENSE", +}; + +set("-v"); // Echo all the copy commands so the user can see what's going on +for (let srcPath of Object.keys(filesToVendor)) { + cp( + path.join("node_modules", srcPath), + path.join("vendor", filesToVendor[srcPath]) + ); +} + +console.log(` +Check to see if any license files have changed, and, if so, be sure to update +https://searchfox.org/mozilla-central/source/toolkit/content/license.html`); diff --git a/browser/components/newtab/common/Actions.sys.mjs b/browser/components/newtab/common/Actions.sys.mjs new file mode 100644 index 0000000000..df5c9f0c91 --- /dev/null +++ b/browser/components/newtab/common/Actions.sys.mjs @@ -0,0 +1,457 @@ +/* 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/. */ + +export const MAIN_MESSAGE_TYPE = "ActivityStream:Main"; +export const CONTENT_MESSAGE_TYPE = "ActivityStream:Content"; +export const PRELOAD_MESSAGE_TYPE = "ActivityStream:PreloadedBrowser"; +export const UI_CODE = 1; +export const BACKGROUND_PROCESS = 2; + +/** + * globalImportContext - Are we in UI code (i.e. react, a dom) or some kind of background process? + * Use this in action creators if you need different logic + * for ui/background processes. + */ +export const globalImportContext = + typeof Window === "undefined" ? BACKGROUND_PROCESS : UI_CODE; + +// Create an object that avoids accidental differing key/value pairs: +// { +// INIT: "INIT", +// UNINIT: "UNINIT" +// } +export const actionTypes = {}; + +for (const type of [ + "ABOUT_SPONSORED_TOP_SITES", + "ADDONS_INFO_REQUEST", + "ADDONS_INFO_RESPONSE", + "ARCHIVE_FROM_POCKET", + "AS_ROUTER_INITIALIZED", + "AS_ROUTER_PREF_CHANGED", + "AS_ROUTER_TARGETING_UPDATE", + "AS_ROUTER_TELEMETRY_USER_EVENT", + "BLOCK_URL", + "BOOKMARK_URL", + "CLEAR_PREF", + "COPY_DOWNLOAD_LINK", + "DELETE_BOOKMARK_BY_ID", + "DELETE_FROM_POCKET", + "DELETE_HISTORY_URL", + "DIALOG_CANCEL", + "DIALOG_OPEN", + "DISABLE_SEARCH", + "DISCOVERY_STREAM_COLLECTION_DISMISSIBLE_TOGGLE", + "DISCOVERY_STREAM_CONFIG_CHANGE", + "DISCOVERY_STREAM_CONFIG_RESET", + "DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS", + "DISCOVERY_STREAM_CONFIG_SETUP", + "DISCOVERY_STREAM_CONFIG_SET_VALUE", + "DISCOVERY_STREAM_DEV_EXPIRE_CACHE", + "DISCOVERY_STREAM_DEV_IDLE_DAILY", + "DISCOVERY_STREAM_DEV_SYNC_RS", + "DISCOVERY_STREAM_DEV_SYSTEM_TICK", + "DISCOVERY_STREAM_EXPERIMENT_DATA", + "DISCOVERY_STREAM_FEEDS_UPDATE", + "DISCOVERY_STREAM_FEED_UPDATE", + "DISCOVERY_STREAM_IMPRESSION_STATS", + "DISCOVERY_STREAM_LAYOUT_RESET", + "DISCOVERY_STREAM_LAYOUT_UPDATE", + "DISCOVERY_STREAM_LINK_BLOCKED", + "DISCOVERY_STREAM_LOADED_CONTENT", + "DISCOVERY_STREAM_PERSONALIZATION_INIT", + "DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED", + "DISCOVERY_STREAM_PERSONALIZATION_OVERRIDE", + "DISCOVERY_STREAM_PERSONALIZATION_RESET", + "DISCOVERY_STREAM_PERSONALIZATION_TOGGLE", + "DISCOVERY_STREAM_PERSONALIZATION_UPDATED", + "DISCOVERY_STREAM_POCKET_STATE_INIT", + "DISCOVERY_STREAM_POCKET_STATE_SET", + "DISCOVERY_STREAM_PREFS_SETUP", + "DISCOVERY_STREAM_RECENT_SAVES", + "DISCOVERY_STREAM_RETRY_FEED", + "DISCOVERY_STREAM_SPOCS_CAPS", + "DISCOVERY_STREAM_SPOCS_ENDPOINT", + "DISCOVERY_STREAM_SPOCS_PLACEMENTS", + "DISCOVERY_STREAM_SPOCS_UPDATE", + "DISCOVERY_STREAM_SPOC_BLOCKED", + "DISCOVERY_STREAM_SPOC_IMPRESSION", + "DISCOVERY_STREAM_USER_EVENT", + "DOWNLOAD_CHANGED", + "FAKE_FOCUS_SEARCH", + "FILL_SEARCH_TERM", + "HANDOFF_SEARCH_TO_AWESOMEBAR", + "HIDE_PERSONALIZE", + "HIDE_PRIVACY_INFO", + "INIT", + "NEW_TAB_INIT", + "NEW_TAB_INITIAL_STATE", + "NEW_TAB_LOAD", + "NEW_TAB_REHYDRATED", + "NEW_TAB_STATE_REQUEST", + "NEW_TAB_UNLOAD", + "OPEN_DOWNLOAD_FILE", + "OPEN_LINK", + "OPEN_NEW_WINDOW", + "OPEN_PRIVATE_WINDOW", + "OPEN_WEBEXT_SETTINGS", + "PARTNER_LINK_ATTRIBUTION", + "PLACES_BOOKMARKS_REMOVED", + "PLACES_BOOKMARK_ADDED", + "PLACES_HISTORY_CLEARED", + "PLACES_LINKS_CHANGED", + "PLACES_LINKS_DELETED", + "PLACES_LINK_BLOCKED", + "PLACES_SAVED_TO_POCKET", + "POCKET_CTA", + "POCKET_LINK_DELETED_OR_ARCHIVED", + "POCKET_LOGGED_IN", + "POCKET_WAITING_FOR_SPOC", + "PREFS_INITIAL_VALUES", + "PREF_CHANGED", + "PREVIEW_REQUEST", + "PREVIEW_REQUEST_CANCEL", + "PREVIEW_RESPONSE", + "REMOVE_DOWNLOAD_FILE", + "RICH_ICON_MISSING", + "SAVE_SESSION_PERF_DATA", + "SAVE_TO_POCKET", + "SCREENSHOT_UPDATED", + "SECTION_DEREGISTER", + "SECTION_DISABLE", + "SECTION_ENABLE", + "SECTION_MOVE", + "SECTION_OPTIONS_CHANGED", + "SECTION_REGISTER", + "SECTION_UPDATE", + "SECTION_UPDATE_CARD", + "SETTINGS_CLOSE", + "SETTINGS_OPEN", + "SET_PREF", + "SHOW_DOWNLOAD_FILE", + "SHOW_FIREFOX_ACCOUNTS", + "SHOW_PERSONALIZE", + "SHOW_PRIVACY_INFO", + "SHOW_SEARCH", + "SKIPPED_SIGNIN", + "SOV_UPDATED", + "SUBMIT_EMAIL", + "SUBMIT_SIGNIN", + "SYSTEM_TICK", + "TELEMETRY_IMPRESSION_STATS", + "TELEMETRY_USER_EVENT", + "TOP_SITES_CANCEL_EDIT", + "TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL", + "TOP_SITES_EDIT", + "TOP_SITES_INSERT", + "TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL", + "TOP_SITES_ORGANIC_IMPRESSION_STATS", + "TOP_SITES_PIN", + "TOP_SITES_PREFS_UPDATED", + "TOP_SITES_SPONSORED_IMPRESSION_STATS", + "TOP_SITES_UNPIN", + "TOP_SITES_UPDATED", + "TOTAL_BOOKMARKS_REQUEST", + "TOTAL_BOOKMARKS_RESPONSE", + "UNINIT", + "UPDATE_PINNED_SEARCH_SHORTCUTS", + "UPDATE_SEARCH_SHORTCUTS", + "UPDATE_SECTION_PREFS", + "WEBEXT_CLICK", + "WEBEXT_DISMISS", +]) { + actionTypes[type] = type; +} + +// Helper function for creating routed actions between content and main +// Not intended to be used by consumers +function _RouteMessage(action, options) { + const meta = action.meta ? { ...action.meta } : {}; + if (!options || !options.from || !options.to) { + throw new Error( + "Routed Messages must have options as the second parameter, and must at least include a .from and .to property." + ); + } + // For each of these fields, if they are passed as an option, + // add them to the action. If they are not defined, remove them. + ["from", "to", "toTarget", "fromTarget", "skipMain", "skipLocal"].forEach( + o => { + if (typeof options[o] !== "undefined") { + meta[o] = options[o]; + } else if (meta[o]) { + delete meta[o]; + } + } + ); + return { ...action, meta }; +} + +/** + * AlsoToMain - Creates a message that will be dispatched locally and also sent to the Main process. + * + * @param {object} action Any redux action (required) + * @param {object} options + * @param {bool} skipLocal Used by OnlyToMain to skip the main reducer + * @param {string} fromTarget The id of the content port from which the action originated. (optional) + * @return {object} An action with added .meta properties + */ +function AlsoToMain(action, fromTarget, skipLocal) { + return _RouteMessage(action, { + from: CONTENT_MESSAGE_TYPE, + to: MAIN_MESSAGE_TYPE, + fromTarget, + skipLocal, + }); +} + +/** + * OnlyToMain - Creates a message that will be sent to the Main process and skip the local reducer. + * + * @param {object} action Any redux action (required) + * @param {object} options + * @param {string} fromTarget The id of the content port from which the action originated. (optional) + * @return {object} An action with added .meta properties + */ +function OnlyToMain(action, fromTarget) { + return AlsoToMain(action, fromTarget, true); +} + +/** + * BroadcastToContent - Creates a message that will be dispatched to main and sent to ALL content processes. + * + * @param {object} action Any redux action (required) + * @return {object} An action with added .meta properties + */ +function BroadcastToContent(action) { + return _RouteMessage(action, { + from: MAIN_MESSAGE_TYPE, + to: CONTENT_MESSAGE_TYPE, + }); +} + +/** + * AlsoToOneContent - Creates a message that will be will be dispatched to the main store + * and also sent to a particular Content process. + * + * @param {object} action Any redux action (required) + * @param {string} target The id of a content port + * @param {bool} skipMain Used by OnlyToOneContent to skip the main process + * @return {object} An action with added .meta properties + */ +function AlsoToOneContent(action, target, skipMain) { + if (!target) { + throw new Error( + "You must provide a target ID as the second parameter of AlsoToOneContent. If you want to send to all content processes, use BroadcastToContent" + ); + } + return _RouteMessage(action, { + from: MAIN_MESSAGE_TYPE, + to: CONTENT_MESSAGE_TYPE, + toTarget: target, + skipMain, + }); +} + +/** + * OnlyToOneContent - Creates a message that will be sent to a particular Content process + * and skip the main reducer. + * + * @param {object} action Any redux action (required) + * @param {string} target The id of a content port + * @return {object} An action with added .meta properties + */ +function OnlyToOneContent(action, target) { + return AlsoToOneContent(action, target, true); +} + +/** + * AlsoToPreloaded - Creates a message that dispatched to the main reducer and also sent to the preloaded tab. + * + * @param {object} action Any redux action (required) + * @return {object} An action with added .meta properties + */ +function AlsoToPreloaded(action) { + return _RouteMessage(action, { + from: MAIN_MESSAGE_TYPE, + to: PRELOAD_MESSAGE_TYPE, + }); +} + +/** + * UserEvent - A telemetry ping indicating a user action. This should only + * be sent from the UI during a user session. + * + * @param {object} data Fields to include in the ping (source, etc.) + * @return {object} An AlsoToMain action + */ +function UserEvent(data) { + return AlsoToMain({ + type: actionTypes.TELEMETRY_USER_EVENT, + data, + }); +} + +/** + * DiscoveryStreamUserEvent - A telemetry ping indicating a user action from Discovery Stream. This should only + * be sent from the UI during a user session. + * + * @param {object} data Fields to include in the ping (source, etc.) + * @return {object} An AlsoToMain action + */ +function DiscoveryStreamUserEvent(data) { + return AlsoToMain({ + type: actionTypes.DISCOVERY_STREAM_USER_EVENT, + data, + }); +} + +/** + * ASRouterUserEvent - A telemetry ping indicating a user action from AS router. This should only + * be sent from the UI during a user session. + * + * @param {object} data Fields to include in the ping (source, etc.) + * @return {object} An AlsoToMain action + */ +function ASRouterUserEvent(data) { + return AlsoToMain({ + type: actionTypes.AS_ROUTER_TELEMETRY_USER_EVENT, + data, + }); +} + +/** + * ImpressionStats - A telemetry ping indicating an impression stats. + * + * @param {object} data Fields to include in the ping + * @param {int} importContext (For testing) Override the import context for testing. + * #return {object} An action. For UI code, a AlsoToMain action. + */ +function ImpressionStats(data, importContext = globalImportContext) { + const action = { + type: actionTypes.TELEMETRY_IMPRESSION_STATS, + data, + }; + return importContext === UI_CODE ? AlsoToMain(action) : action; +} + +/** + * DiscoveryStreamImpressionStats - A telemetry ping indicating an impression stats in Discovery Stream. + * + * @param {object} data Fields to include in the ping + * @param {int} importContext (For testing) Override the import context for testing. + * #return {object} An action. For UI code, a AlsoToMain action. + */ +function DiscoveryStreamImpressionStats( + data, + importContext = globalImportContext +) { + const action = { + type: actionTypes.DISCOVERY_STREAM_IMPRESSION_STATS, + data, + }; + return importContext === UI_CODE ? AlsoToMain(action) : action; +} + +/** + * DiscoveryStreamLoadedContent - A telemetry ping indicating a content gets loaded in Discovery Stream. + * + * @param {object} data Fields to include in the ping + * @param {int} importContext (For testing) Override the import context for testing. + * #return {object} An action. For UI code, a AlsoToMain action. + */ +function DiscoveryStreamLoadedContent( + data, + importContext = globalImportContext +) { + const action = { + type: actionTypes.DISCOVERY_STREAM_LOADED_CONTENT, + data, + }; + return importContext === UI_CODE ? AlsoToMain(action) : action; +} + +function SetPref(name, value, importContext = globalImportContext) { + const action = { type: actionTypes.SET_PREF, data: { name, value } }; + return importContext === UI_CODE ? AlsoToMain(action) : action; +} + +function WebExtEvent(type, data, importContext = globalImportContext) { + if (!data || !data.source) { + throw new Error( + 'WebExtEvent actions should include a property "source", the id of the webextension that should receive the event.' + ); + } + const action = { type, data }; + return importContext === UI_CODE ? AlsoToMain(action) : action; +} + +export const actionCreators = { + BroadcastToContent, + UserEvent, + DiscoveryStreamUserEvent, + ASRouterUserEvent, + ImpressionStats, + AlsoToOneContent, + OnlyToOneContent, + AlsoToMain, + OnlyToMain, + AlsoToPreloaded, + SetPref, + WebExtEvent, + DiscoveryStreamImpressionStats, + DiscoveryStreamLoadedContent, +}; + +// These are helpers to test for certain kinds of actions +export const actionUtils = { + isSendToMain(action) { + if (!action.meta) { + return false; + } + return ( + action.meta.to === MAIN_MESSAGE_TYPE && + action.meta.from === CONTENT_MESSAGE_TYPE + ); + }, + isBroadcastToContent(action) { + if (!action.meta) { + return false; + } + if (action.meta.to === CONTENT_MESSAGE_TYPE && !action.meta.toTarget) { + return true; + } + return false; + }, + isSendToOneContent(action) { + if (!action.meta) { + return false; + } + if (action.meta.to === CONTENT_MESSAGE_TYPE && action.meta.toTarget) { + return true; + } + return false; + }, + isSendToPreloaded(action) { + if (!action.meta) { + return false; + } + return ( + action.meta.to === PRELOAD_MESSAGE_TYPE && + action.meta.from === MAIN_MESSAGE_TYPE + ); + }, + isFromMain(action) { + if (!action.meta) { + return false; + } + return ( + action.meta.from === MAIN_MESSAGE_TYPE && + action.meta.to === CONTENT_MESSAGE_TYPE + ); + }, + getPortIdOfSender(action) { + return (action.meta && action.meta.fromTarget) || null; + }, + _RouteMessage, +}; diff --git a/browser/components/newtab/common/Dedupe.sys.mjs b/browser/components/newtab/common/Dedupe.sys.mjs new file mode 100644 index 0000000000..eedca8a0ee --- /dev/null +++ b/browser/components/newtab/common/Dedupe.sys.mjs @@ -0,0 +1,36 @@ +/* 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/. */ + +export class Dedupe { + constructor(createKey) { + this.createKey = createKey || this.defaultCreateKey; + } + + defaultCreateKey(item) { + return item; + } + + /** + * Dedupe any number of grouped elements favoring those from earlier groups. + * + * @param {Array} groups Contains an arbitrary number of arrays of elements. + * @returns {Array} A matching array of each provided group deduped. + */ + group(...groups) { + const globalKeys = new Set(); + const result = []; + for (const values of groups) { + const valueMap = new Map(); + for (const value of values) { + const key = this.createKey(value); + if (!globalKeys.has(key) && !valueMap.has(key)) { + valueMap.set(key, value); + } + } + result.push(valueMap); + valueMap.forEach((value, key) => globalKeys.add(key)); + } + return result.map(m => Array.from(m.values())); + } +} diff --git a/browser/components/newtab/common/Reducers.sys.mjs b/browser/components/newtab/common/Reducers.sys.mjs new file mode 100644 index 0000000000..d4f879b834 --- /dev/null +++ b/browser/components/newtab/common/Reducers.sys.mjs @@ -0,0 +1,855 @@ +/* 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 { actionTypes as at } from "resource://activity-stream/common/Actions.sys.mjs"; +import { Dedupe } from "resource://activity-stream/common/Dedupe.sys.mjs"; + +export const TOP_SITES_DEFAULT_ROWS = 1; +export const TOP_SITES_MAX_SITES_PER_ROW = 8; +const PREF_COLLECTION_DISMISSIBLE = "discoverystream.isCollectionDismissible"; + +const dedupe = new Dedupe(site => site && site.url); + +export const INITIAL_STATE = { + App: { + // Have we received real data from the app yet? + initialized: false, + locale: "", + isForStartupCache: false, + customizeMenuVisible: false, + }, + ASRouter: { initialized: false }, + TopSites: { + // Have we received real data from history yet? + initialized: false, + // The history (and possibly default) links + rows: [], + // Used in content only to dispatch action to TopSiteForm. + editForm: null, + // Used in content only to open the SearchShortcutsForm modal. + showSearchShortcutsForm: false, + // The list of available search shortcuts. + searchShortcuts: [], + // The "Share-of-Voice" allocations generated by TopSitesFeed + sov: { + ready: false, + positions: [ + // {position: 0, assignedPartner: "amp"}, + // {position: 1, assignedPartner: "moz-sales"}, + ], + }, + }, + Prefs: { + initialized: false, + values: { featureConfig: {} }, + }, + Dialog: { + visible: false, + data: {}, + }, + Sections: [], + Pocket: { + isUserLoggedIn: null, + pocketCta: {}, + waitingForSpoc: true, + }, + // This is the new pocket configurable layout state. + DiscoveryStream: { + // This is a JSON-parsed copy of the discoverystream.config pref value. + config: { enabled: false }, + layout: [], + isPrivacyInfoModalVisible: false, + isCollectionDismissible: false, + feeds: { + data: { + // "https://foo.com/feed1": {lastUpdated: 123, data: [], personalized: false} + }, + loaded: false, + }, + spocs: { + spocs_endpoint: "", + lastUpdated: null, + data: { + // "spocs": {title: "", context: "", items: [], personalized: false}, + // "placement1": {title: "", context: "", items: [], personalized: false}, + }, + loaded: false, + frequency_caps: [], + blocked: [], + placements: [], + }, + experimentData: { + utmSource: "pocket-newtab", + utmCampaign: undefined, + utmContent: undefined, + }, + recentSavesData: [], + isUserLoggedIn: false, + recentSavesEnabled: false, + }, + Personalization: { + lastUpdated: null, + initialized: false, + }, + Search: { + // When search hand-off is enabled, we render a big button that is styled to + // look like a search textbox. If the button is clicked, we style + // the button as if it was a focused search box and show a fake cursor but + // really focus the awesomebar without the focus styles ("hidden focus"). + fakeFocus: false, + // Hide the search box after handing off to AwesomeBar and user starts typing. + hide: false, + }, +}; + +function App(prevState = INITIAL_STATE.App, action) { + switch (action.type) { + case at.INIT: + return Object.assign({}, prevState, action.data || {}, { + initialized: true, + }); + case at.TOP_SITES_UPDATED: + // Toggle `isForStartupCache` when receiving the `TOP_SITES_UPDATE` action + // so that sponsored tiles can be rendered as usual. See Bug 1826360. + return Object.assign({}, prevState, action.data || {}, { + isForStartupCache: false, + }); + case at.SHOW_PERSONALIZE: + return Object.assign({}, prevState, { + customizeMenuVisible: true, + }); + case at.HIDE_PERSONALIZE: + return Object.assign({}, prevState, { + customizeMenuVisible: false, + }); + default: + return prevState; + } +} + +function ASRouter(prevState = INITIAL_STATE.ASRouter, action) { + switch (action.type) { + case at.AS_ROUTER_INITIALIZED: + return { ...action.data, initialized: true }; + default: + return prevState; + } +} + +/** + * insertPinned - Inserts pinned links in their specified slots + * + * @param {array} a list of links + * @param {array} a list of pinned links + * @return {array} resulting list of links with pinned links inserted + */ +export function insertPinned(links, pinned) { + // Remove any pinned links + const pinnedUrls = pinned.map(link => link && link.url); + let newLinks = links.filter(link => + link ? !pinnedUrls.includes(link.url) : false + ); + newLinks = newLinks.map(link => { + if (link && link.isPinned) { + delete link.isPinned; + delete link.pinIndex; + } + return link; + }); + + // Then insert them in their specified location + pinned.forEach((val, index) => { + if (!val) { + return; + } + let link = Object.assign({}, val, { isPinned: true, pinIndex: index }); + if (index > newLinks.length) { + newLinks[index] = link; + } else { + newLinks.splice(index, 0, link); + } + }); + + return newLinks; +} + +function TopSites(prevState = INITIAL_STATE.TopSites, action) { + let hasMatch; + let newRows; + switch (action.type) { + case at.TOP_SITES_UPDATED: + if (!action.data || !action.data.links) { + return prevState; + } + return Object.assign( + {}, + prevState, + { initialized: true, rows: action.data.links }, + action.data.pref ? { pref: action.data.pref } : {} + ); + case at.TOP_SITES_PREFS_UPDATED: + return Object.assign({}, prevState, { pref: action.data.pref }); + case at.TOP_SITES_EDIT: + return Object.assign({}, prevState, { + editForm: { + index: action.data.index, + previewResponse: null, + }, + }); + case at.TOP_SITES_CANCEL_EDIT: + return Object.assign({}, prevState, { editForm: null }); + case at.TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL: + return Object.assign({}, prevState, { showSearchShortcutsForm: true }); + case at.TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL: + return Object.assign({}, prevState, { showSearchShortcutsForm: false }); + case at.PREVIEW_RESPONSE: + if ( + !prevState.editForm || + action.data.url !== prevState.editForm.previewUrl + ) { + return prevState; + } + return Object.assign({}, prevState, { + editForm: { + index: prevState.editForm.index, + previewResponse: action.data.preview, + previewUrl: action.data.url, + }, + }); + case at.PREVIEW_REQUEST: + if (!prevState.editForm) { + return prevState; + } + return Object.assign({}, prevState, { + editForm: { + index: prevState.editForm.index, + previewResponse: null, + previewUrl: action.data.url, + }, + }); + case at.PREVIEW_REQUEST_CANCEL: + if (!prevState.editForm) { + return prevState; + } + return Object.assign({}, prevState, { + editForm: { + index: prevState.editForm.index, + previewResponse: null, + }, + }); + case at.SCREENSHOT_UPDATED: + newRows = prevState.rows.map(row => { + if (row && row.url === action.data.url) { + hasMatch = true; + return Object.assign({}, row, { screenshot: action.data.screenshot }); + } + return row; + }); + return hasMatch + ? Object.assign({}, prevState, { rows: newRows }) + : prevState; + case at.PLACES_BOOKMARK_ADDED: + if (!action.data) { + return prevState; + } + newRows = prevState.rows.map(site => { + if (site && site.url === action.data.url) { + const { bookmarkGuid, bookmarkTitle, dateAdded } = action.data; + return Object.assign({}, site, { + bookmarkGuid, + bookmarkTitle, + bookmarkDateCreated: dateAdded, + }); + } + return site; + }); + return Object.assign({}, prevState, { rows: newRows }); + case at.PLACES_BOOKMARKS_REMOVED: + if (!action.data) { + return prevState; + } + newRows = prevState.rows.map(site => { + if (site && action.data.urls.includes(site.url)) { + const newSite = Object.assign({}, site); + delete newSite.bookmarkGuid; + delete newSite.bookmarkTitle; + delete newSite.bookmarkDateCreated; + return newSite; + } + return site; + }); + return Object.assign({}, prevState, { rows: newRows }); + case at.PLACES_LINKS_DELETED: + if (!action.data) { + return prevState; + } + newRows = prevState.rows.filter( + site => !action.data.urls.includes(site.url) + ); + return Object.assign({}, prevState, { rows: newRows }); + case at.UPDATE_SEARCH_SHORTCUTS: + return { ...prevState, searchShortcuts: action.data.searchShortcuts }; + case at.SOV_UPDATED: + const sov = { + ready: action.data.ready, + positions: action.data.positions, + }; + return { ...prevState, sov }; + default: + return prevState; + } +} + +function Dialog(prevState = INITIAL_STATE.Dialog, action) { + switch (action.type) { + case at.DIALOG_OPEN: + return Object.assign({}, prevState, { visible: true, data: action.data }); + case at.DIALOG_CANCEL: + return Object.assign({}, prevState, { visible: false }); + case at.DELETE_HISTORY_URL: + return Object.assign({}, INITIAL_STATE.Dialog); + default: + return prevState; + } +} + +function Prefs(prevState = INITIAL_STATE.Prefs, action) { + let newValues; + switch (action.type) { + case at.PREFS_INITIAL_VALUES: + return Object.assign({}, prevState, { + initialized: true, + values: action.data, + }); + case at.PREF_CHANGED: + newValues = Object.assign({}, prevState.values); + newValues[action.data.name] = action.data.value; + return Object.assign({}, prevState, { values: newValues }); + default: + return prevState; + } +} + +function Sections(prevState = INITIAL_STATE.Sections, action) { + let hasMatch; + let newState; + switch (action.type) { + case at.SECTION_DEREGISTER: + return prevState.filter(section => section.id !== action.data); + case at.SECTION_REGISTER: + // If section exists in prevState, update it + newState = prevState.map(section => { + if (section && section.id === action.data.id) { + hasMatch = true; + return Object.assign({}, section, action.data); + } + return section; + }); + // Otherwise, append it + if (!hasMatch) { + const initialized = !!(action.data.rows && !!action.data.rows.length); + const section = Object.assign( + { title: "", rows: [], enabled: false }, + action.data, + { initialized } + ); + newState.push(section); + } + return newState; + case at.SECTION_UPDATE: + newState = prevState.map(section => { + if (section && section.id === action.data.id) { + // If the action is updating rows, we should consider initialized to be true. + // This can be overridden if initialized is defined in the action.data + const initialized = action.data.rows ? { initialized: true } : {}; + + // Make sure pinned cards stay at their current position when rows are updated. + // Disabling a section (SECTION_UPDATE with empty rows) does not retain pinned cards. + if ( + action.data.rows && + !!action.data.rows.length && + section.rows.find(card => card.pinned) + ) { + const rows = Array.from(action.data.rows); + section.rows.forEach((card, index) => { + if (card.pinned) { + // Only add it if it's not already there. + if (rows[index].guid !== card.guid) { + rows.splice(index, 0, card); + } + } + }); + return Object.assign( + {}, + section, + initialized, + Object.assign({}, action.data, { rows }) + ); + } + + return Object.assign({}, section, initialized, action.data); + } + return section; + }); + + if (!action.data.dedupeConfigurations) { + return newState; + } + + action.data.dedupeConfigurations.forEach(dedupeConf => { + newState = newState.map(section => { + if (section.id === dedupeConf.id) { + const dedupedRows = dedupeConf.dedupeFrom.reduce( + (rows, dedupeSectionId) => { + const dedupeSection = newState.find( + s => s.id === dedupeSectionId + ); + const [, newRows] = dedupe.group(dedupeSection.rows, rows); + return newRows; + }, + section.rows + ); + + return Object.assign({}, section, { rows: dedupedRows }); + } + + return section; + }); + }); + + return newState; + case at.SECTION_UPDATE_CARD: + return prevState.map(section => { + if (section && section.id === action.data.id && section.rows) { + const newRows = section.rows.map(card => { + if (card.url === action.data.url) { + return Object.assign({}, card, action.data.options); + } + return card; + }); + return Object.assign({}, section, { rows: newRows }); + } + return section; + }); + case at.PLACES_BOOKMARK_ADDED: + if (!action.data) { + return prevState; + } + return prevState.map(section => + Object.assign({}, section, { + rows: section.rows.map(item => { + // find the item within the rows that is attempted to be bookmarked + if (item.url === action.data.url) { + const { bookmarkGuid, bookmarkTitle, dateAdded } = action.data; + return Object.assign({}, item, { + bookmarkGuid, + bookmarkTitle, + bookmarkDateCreated: dateAdded, + type: "bookmark", + }); + } + return item; + }), + }) + ); + case at.PLACES_SAVED_TO_POCKET: + if (!action.data) { + return prevState; + } + return prevState.map(section => + Object.assign({}, section, { + rows: section.rows.map(item => { + if (item.url === action.data.url) { + return Object.assign({}, item, { + open_url: action.data.open_url, + pocket_id: action.data.pocket_id, + title: action.data.title, + type: "pocket", + }); + } + return item; + }), + }) + ); + case at.PLACES_BOOKMARKS_REMOVED: + if (!action.data) { + return prevState; + } + return prevState.map(section => + Object.assign({}, section, { + rows: section.rows.map(item => { + // find the bookmark within the rows that is attempted to be removed + if (action.data.urls.includes(item.url)) { + const newSite = Object.assign({}, item); + delete newSite.bookmarkGuid; + delete newSite.bookmarkTitle; + delete newSite.bookmarkDateCreated; + if (!newSite.type || newSite.type === "bookmark") { + newSite.type = "history"; + } + return newSite; + } + return item; + }), + }) + ); + case at.PLACES_LINKS_DELETED: + if (!action.data) { + return prevState; + } + return prevState.map(section => + Object.assign({}, section, { + rows: section.rows.filter( + site => !action.data.urls.includes(site.url) + ), + }) + ); + case at.PLACES_LINK_BLOCKED: + if (!action.data) { + return prevState; + } + return prevState.map(section => + Object.assign({}, section, { + rows: section.rows.filter(site => site.url !== action.data.url), + }) + ); + case at.DELETE_FROM_POCKET: + case at.ARCHIVE_FROM_POCKET: + return prevState.map(section => + Object.assign({}, section, { + rows: section.rows.filter( + site => site.pocket_id !== action.data.pocket_id + ), + }) + ); + default: + return prevState; + } +} + +function Pocket(prevState = INITIAL_STATE.Pocket, action) { + switch (action.type) { + case at.POCKET_WAITING_FOR_SPOC: + return { ...prevState, waitingForSpoc: action.data }; + case at.POCKET_LOGGED_IN: + return { ...prevState, isUserLoggedIn: !!action.data }; + case at.POCKET_CTA: + return { + ...prevState, + pocketCta: { + ctaButton: action.data.cta_button, + ctaText: action.data.cta_text, + ctaUrl: action.data.cta_url, + useCta: action.data.use_cta, + }, + }; + default: + return prevState; + } +} + +function Personalization(prevState = INITIAL_STATE.Personalization, action) { + switch (action.type) { + case at.DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED: + return { + ...prevState, + lastUpdated: action.data.lastUpdated, + }; + case at.DISCOVERY_STREAM_PERSONALIZATION_INIT: + return { + ...prevState, + initialized: true, + }; + case at.DISCOVERY_STREAM_PERSONALIZATION_RESET: + return { ...INITIAL_STATE.Personalization }; + default: + return prevState; + } +} + +// eslint-disable-next-line complexity +function DiscoveryStream(prevState = INITIAL_STATE.DiscoveryStream, action) { + // Return if action data is empty, or spocs or feeds data is not loaded + const isNotReady = () => + !action.data || !prevState.spocs.loaded || !prevState.feeds.loaded; + + const handlePlacements = handleSites => { + const { data, placements } = prevState.spocs; + const result = {}; + + const forPlacement = placement => { + const placementSpocs = data[placement.name]; + + if ( + !placementSpocs || + !placementSpocs.items || + !placementSpocs.items.length + ) { + return; + } + + result[placement.name] = { + ...placementSpocs, + items: handleSites(placementSpocs.items), + }; + }; + + if (!placements || !placements.length) { + [{ name: "spocs" }].forEach(forPlacement); + } else { + placements.forEach(forPlacement); + } + return result; + }; + + const nextState = handleSites => ({ + ...prevState, + spocs: { + ...prevState.spocs, + data: handlePlacements(handleSites), + }, + feeds: { + ...prevState.feeds, + data: Object.keys(prevState.feeds.data).reduce( + (accumulator, feed_url) => { + accumulator[feed_url] = { + data: { + ...prevState.feeds.data[feed_url].data, + recommendations: handleSites( + prevState.feeds.data[feed_url].data.recommendations + ), + }, + }; + return accumulator; + }, + {} + ), + }, + }); + + switch (action.type) { + case at.DISCOVERY_STREAM_CONFIG_CHANGE: + // Fall through to a separate action is so it doesn't trigger a listener update on init + case at.DISCOVERY_STREAM_CONFIG_SETUP: + return { ...prevState, config: action.data || {} }; + case at.DISCOVERY_STREAM_EXPERIMENT_DATA: + return { ...prevState, experimentData: action.data || {} }; + case at.DISCOVERY_STREAM_LAYOUT_UPDATE: + return { + ...prevState, + layout: action.data.layout || [], + }; + case at.DISCOVERY_STREAM_COLLECTION_DISMISSIBLE_TOGGLE: + return { + ...prevState, + isCollectionDismissible: action.data.value, + }; + case at.DISCOVERY_STREAM_PREFS_SETUP: + return { + ...prevState, + recentSavesEnabled: action.data.recentSavesEnabled, + pocketButtonEnabled: action.data.pocketButtonEnabled, + saveToPocketCard: action.data.saveToPocketCard, + hideDescriptions: action.data.hideDescriptions, + compactImages: action.data.compactImages, + imageGradient: action.data.imageGradient, + newSponsoredLabel: action.data.newSponsoredLabel, + titleLines: action.data.titleLines, + descLines: action.data.descLines, + readTime: action.data.readTime, + }; + case at.DISCOVERY_STREAM_RECENT_SAVES: + return { + ...prevState, + recentSavesData: action.data.recentSaves, + }; + case at.DISCOVERY_STREAM_POCKET_STATE_SET: + return { + ...prevState, + isUserLoggedIn: action.data.isUserLoggedIn, + }; + case at.HIDE_PRIVACY_INFO: + return { + ...prevState, + isPrivacyInfoModalVisible: false, + }; + case at.SHOW_PRIVACY_INFO: + return { + ...prevState, + isPrivacyInfoModalVisible: true, + }; + case at.DISCOVERY_STREAM_LAYOUT_RESET: + return { ...INITIAL_STATE.DiscoveryStream, config: prevState.config }; + case at.DISCOVERY_STREAM_FEEDS_UPDATE: + return { + ...prevState, + feeds: { + ...prevState.feeds, + loaded: true, + }, + }; + case at.DISCOVERY_STREAM_FEED_UPDATE: + const newData = {}; + newData[action.data.url] = action.data.feed; + return { + ...prevState, + feeds: { + ...prevState.feeds, + data: { + ...prevState.feeds.data, + ...newData, + }, + }, + }; + case at.DISCOVERY_STREAM_SPOCS_CAPS: + return { + ...prevState, + spocs: { + ...prevState.spocs, + frequency_caps: [...prevState.spocs.frequency_caps, ...action.data], + }, + }; + case at.DISCOVERY_STREAM_SPOCS_ENDPOINT: + return { + ...prevState, + spocs: { + ...INITIAL_STATE.DiscoveryStream.spocs, + spocs_endpoint: + action.data.url || + INITIAL_STATE.DiscoveryStream.spocs.spocs_endpoint, + }, + }; + case at.DISCOVERY_STREAM_SPOCS_PLACEMENTS: + return { + ...prevState, + spocs: { + ...prevState.spocs, + placements: + action.data.placements || + INITIAL_STATE.DiscoveryStream.spocs.placements, + }, + }; + case at.DISCOVERY_STREAM_SPOCS_UPDATE: + if (action.data) { + return { + ...prevState, + spocs: { + ...prevState.spocs, + lastUpdated: action.data.lastUpdated, + data: action.data.spocs, + loaded: true, + }, + }; + } + return prevState; + case at.DISCOVERY_STREAM_SPOC_BLOCKED: + return { + ...prevState, + spocs: { + ...prevState.spocs, + blocked: [...prevState.spocs.blocked, action.data.url], + }, + }; + case at.DISCOVERY_STREAM_LINK_BLOCKED: + return isNotReady() + ? prevState + : nextState(items => + items.filter(item => item.url !== action.data.url) + ); + + case at.PLACES_SAVED_TO_POCKET: + const addPocketInfo = item => { + if (item.url === action.data.url) { + return Object.assign({}, item, { + open_url: action.data.open_url, + pocket_id: action.data.pocket_id, + context_type: "pocket", + }); + } + return item; + }; + return isNotReady() + ? prevState + : nextState(items => items.map(addPocketInfo)); + + case at.DELETE_FROM_POCKET: + case at.ARCHIVE_FROM_POCKET: + return isNotReady() + ? prevState + : nextState(items => + items.filter(item => item.pocket_id !== action.data.pocket_id) + ); + + case at.PLACES_BOOKMARK_ADDED: + const updateBookmarkInfo = item => { + if (item.url === action.data.url) { + const { bookmarkGuid, bookmarkTitle, dateAdded } = action.data; + return Object.assign({}, item, { + bookmarkGuid, + bookmarkTitle, + bookmarkDateCreated: dateAdded, + context_type: "bookmark", + }); + } + return item; + }; + return isNotReady() + ? prevState + : nextState(items => items.map(updateBookmarkInfo)); + + case at.PLACES_BOOKMARKS_REMOVED: + const removeBookmarkInfo = item => { + if (action.data.urls.includes(item.url)) { + const newSite = Object.assign({}, item); + delete newSite.bookmarkGuid; + delete newSite.bookmarkTitle; + delete newSite.bookmarkDateCreated; + if (!newSite.context_type || newSite.context_type === "bookmark") { + newSite.context_type = "removedBookmark"; + } + return newSite; + } + return item; + }; + return isNotReady() + ? prevState + : nextState(items => items.map(removeBookmarkInfo)); + case at.PREF_CHANGED: + if (action.data.name === PREF_COLLECTION_DISMISSIBLE) { + return { + ...prevState, + isCollectionDismissible: action.data.value, + }; + } + return prevState; + default: + return prevState; + } +} + +function Search(prevState = INITIAL_STATE.Search, action) { + switch (action.type) { + case at.DISABLE_SEARCH: + return Object.assign({ ...prevState, disable: true }); + case at.FAKE_FOCUS_SEARCH: + return Object.assign({ ...prevState, fakeFocus: true }); + case at.SHOW_SEARCH: + return Object.assign({ ...prevState, disable: false, fakeFocus: false }); + default: + return prevState; + } +} + +export const reducers = { + TopSites, + App, + ASRouter, + Prefs, + Dialog, + Sections, + Pocket, + Personalization, + DiscoveryStream, + Search, +}; diff --git a/browser/components/newtab/components.conf b/browser/components/newtab/components.conf new file mode 100644 index 0000000000..7064595496 --- /dev/null +++ b/browser/components/newtab/components.conf @@ -0,0 +1,14 @@ +# -*- 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/. + +Classes = [ + { + 'cid': '{dfcd2adc-7867-4d3a-ba70-17501f208142}', + 'contract_ids': ['@mozilla.org/browser/aboutnewtab-service;1'], + 'esModule': 'resource:///modules/AboutNewTabService.sys.mjs', + 'constructor': 'AboutNewTabStubService', + }, +] diff --git a/browser/components/newtab/components/CustomElements/paragraph.js b/browser/components/newtab/components/CustomElements/paragraph.js new file mode 100644 index 0000000000..dce8a229a4 --- /dev/null +++ b/browser/components/newtab/components/CustomElements/paragraph.js @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// This is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const { RemoteL10n } = ChromeUtils.importESModule( + "resource:///modules/asrouter/RemoteL10n.sys.mjs" + ); + class MozTextParagraph extends HTMLElement { + constructor() { + super(); + + this._content = null; + } + + get fluentAttributeValues() { + const attributes = {}; + for (let name of this.getAttributeNames()) { + if (name.startsWith("fluent-variable-")) { + let value = this.getAttribute(name); + // Attribute value is a string, in some cases that is not useful + // for example instantiating a Date object will fail. We try to + // convert all possible integers back. + if (value.match(/^\d+/)) { + value = parseInt(value, 10); + } + attributes[name.replace(/^fluent-variable-/, "")] = value; + } + } + + return attributes; + } + + render() { + if (this.getAttribute("fluent-remote-id") && this._content) { + RemoteL10n.l10n.setAttributes( + this._content, + this.getAttribute("fluent-remote-id"), + this.fluentAttributeValues + ); + } + } + + static get observedAttributes() { + return ["fluent-remote-id"]; + } + + attributeChangedCallback(name, oldValue, newValue) { + this.render(); + } + + connectedCallback() { + if (this.shadowRoot) { + this.render(); + return; + } + + const shadowRoot = this.attachShadow({ mode: "open" }); + this._content = document.createElement("span"); + shadowRoot.appendChild(this._content); + + this.render(); + RemoteL10n.l10n.translateFragment(this._content); + } + } + + customElements.define("remote-text", MozTextParagraph); +} diff --git a/browser/components/newtab/content-src/activity-stream.jsx b/browser/components/newtab/content-src/activity-stream.jsx new file mode 100644 index 0000000000..c588e8e850 --- /dev/null +++ b/browser/components/newtab/content-src/activity-stream.jsx @@ -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 http://mozilla.org/MPL/2.0/. */ + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { Base } from "content-src/components/Base/Base"; +import { DetectUserSessionStart } from "content-src/lib/detect-user-session-start"; +import { initStore } from "content-src/lib/init-store"; +import { Provider } from "react-redux"; +import React from "react"; +import ReactDOM from "react-dom"; +import { reducers } from "common/Reducers.sys.mjs"; + +export const NewTab = ({ store }) => ( + + + +); + +export function renderWithoutState() { + const store = initStore(reducers); + new DetectUserSessionStart(store).sendEventOrAddListener(); + + // If this document has already gone into the background by the time we've reached + // here, we can deprioritize requesting the initial state until the event loop + // frees up. If, however, the visibility changes, we then send the request. + let didRequest = false; + let requestIdleCallbackId = 0; + function doRequest() { + if (!didRequest) { + if (requestIdleCallbackId) { + cancelIdleCallback(requestIdleCallbackId); + } + didRequest = true; + store.dispatch(ac.AlsoToMain({ type: at.NEW_TAB_STATE_REQUEST })); + } + } + + if (document.hidden) { + requestIdleCallbackId = requestIdleCallback(doRequest); + addEventListener("visibilitychange", doRequest, { once: true }); + } else { + doRequest(); + } + + ReactDOM.hydrate(, document.getElementById("root")); +} + +export function renderCache(initialState) { + const store = initStore(reducers, initialState); + new DetectUserSessionStart(store).sendEventOrAddListener(); + + ReactDOM.hydrate(, document.getElementById("root")); +} diff --git a/browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx b/browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx new file mode 100644 index 0000000000..3aab52cdff --- /dev/null +++ b/browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; + +export function A11yLinkButton(props) { + // function for merging classes, if necessary + let className = "a11y-link-button"; + if (props.className) { + className += ` ${props.className}`; + } + return ( + + ); +} diff --git a/browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss b/browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss new file mode 100644 index 0000000000..c87fc93b60 --- /dev/null +++ b/browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss @@ -0,0 +1,13 @@ + +.a11y-link-button { + border: 0; + padding: 0; + cursor: pointer; + text-align: unset; + color: var(--newtab-primary-action-background); + + &:hover, + &:focus { + text-decoration: underline; + } +} diff --git a/browser/components/newtab/content-src/components/Base/Base.jsx b/browser/components/newtab/content-src/components/Base/Base.jsx new file mode 100644 index 0000000000..0580267f26 --- /dev/null +++ b/browser/components/newtab/content-src/components/Base/Base.jsx @@ -0,0 +1,262 @@ +/* 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 "common/Actions.sys.mjs"; +import { DiscoveryStreamAdmin } from "content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin"; +import { ConfirmDialog } from "content-src/components/ConfirmDialog/ConfirmDialog"; +import { connect } from "react-redux"; +import { DiscoveryStreamBase } from "content-src/components/DiscoveryStreamBase/DiscoveryStreamBase"; +import { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary"; +import { CustomizeMenu } from "content-src/components/CustomizeMenu/CustomizeMenu"; +import React from "react"; +import { Search } from "content-src/components/Search/Search"; +import { Sections } from "content-src/components/Sections/Sections"; + +export const PrefsButton = ({ onClick, icon }) => ( +
    +
    +); + +// Returns a function will not be continuously triggered when called. The +// function will be triggered if called again after `wait` milliseconds. +function debounce(func, wait) { + let timer; + return (...args) => { + if (timer) { + return; + } + + let wakeUp = () => { + timer = null; + }; + + timer = setTimeout(wakeUp, wait); + func.apply(this, args); + }; +} + +export class _Base extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + message: {}, + }; + this.notifyContent = this.notifyContent.bind(this); + } + + notifyContent(state) { + this.setState(state); + } + + componentWillUnmount() { + this.updateTheme(); + } + + componentWillUpdate() { + this.updateTheme(); + } + + updateTheme() { + const bodyClassName = [ + "activity-stream", + // If we skipped the about:welcome overlay and removed the CSS classes + // we don't want to add them back to the Activity Stream view + document.body.classList.contains("inline-onboarding") + ? "inline-onboarding" + : "", + ] + .filter(v => v) + .join(" "); + global.document.body.className = bodyClassName; + } + + render() { + const { props } = this; + const { App } = props; + const isDevtoolsEnabled = props.Prefs.values["asrouter.devtoolsEnabled"]; + + if (!App.initialized) { + return null; + } + + return ( + + + + {isDevtoolsEnabled ? ( + + ) : null} + + + ); + } +} + +export class BaseContent extends React.PureComponent { + constructor(props) { + super(props); + this.openPreferences = this.openPreferences.bind(this); + this.openCustomizationMenu = this.openCustomizationMenu.bind(this); + this.closeCustomizationMenu = this.closeCustomizationMenu.bind(this); + this.handleOnKeyDown = this.handleOnKeyDown.bind(this); + this.onWindowScroll = debounce(this.onWindowScroll.bind(this), 5); + this.setPref = this.setPref.bind(this); + this.state = { fixedSearch: false }; + } + + componentDidMount() { + global.addEventListener("scroll", this.onWindowScroll); + global.addEventListener("keydown", this.handleOnKeyDown); + } + + componentWillUnmount() { + global.removeEventListener("scroll", this.onWindowScroll); + global.removeEventListener("keydown", this.handleOnKeyDown); + } + + onWindowScroll() { + const prefs = this.props.Prefs.values; + const SCROLL_THRESHOLD = prefs["logowordmark.alwaysVisible"] ? 179 : 34; + if (global.scrollY > SCROLL_THRESHOLD && !this.state.fixedSearch) { + this.setState({ fixedSearch: true }); + } else if (global.scrollY <= SCROLL_THRESHOLD && this.state.fixedSearch) { + this.setState({ fixedSearch: false }); + } + } + + openPreferences() { + this.props.dispatch(ac.OnlyToMain({ type: at.SETTINGS_OPEN })); + this.props.dispatch(ac.UserEvent({ event: "OPEN_NEWTAB_PREFS" })); + } + + openCustomizationMenu() { + this.props.dispatch({ type: at.SHOW_PERSONALIZE }); + this.props.dispatch(ac.UserEvent({ event: "SHOW_PERSONALIZE" })); + } + + closeCustomizationMenu() { + if (this.props.App.customizeMenuVisible) { + this.props.dispatch({ type: at.HIDE_PERSONALIZE }); + this.props.dispatch(ac.UserEvent({ event: "HIDE_PERSONALIZE" })); + } + } + + handleOnKeyDown(e) { + if (e.key === "Escape") { + this.closeCustomizationMenu(); + } + } + + setPref(pref, value) { + this.props.dispatch(ac.SetPref(pref, value)); + } + + render() { + const { props } = this; + const { App } = props; + const { initialized, customizeMenuVisible } = App; + const prefs = props.Prefs.values; + + const isDiscoveryStream = + props.DiscoveryStream.config && props.DiscoveryStream.config.enabled; + let filteredSections = props.Sections.filter( + section => section.id !== "topstories" + ); + + const pocketEnabled = + prefs["feeds.section.topstories"] && prefs["feeds.system.topstories"]; + const noSectionsEnabled = + !prefs["feeds.topsites"] && + !pocketEnabled && + filteredSections.filter(section => section.enabled).length === 0; + const searchHandoffEnabled = prefs["improvesearch.handoffToAwesomebar"]; + const enabledSections = { + topSitesEnabled: prefs["feeds.topsites"], + pocketEnabled: prefs["feeds.section.topstories"], + highlightsEnabled: prefs["feeds.section.highlights"], + showSponsoredTopSitesEnabled: prefs.showSponsoredTopSites, + showSponsoredPocketEnabled: prefs.showSponsored, + showRecentSavesEnabled: prefs.showRecentSaves, + topSitesRowsCount: prefs.topSitesRows, + }; + + const pocketRegion = prefs["feeds.system.topstories"]; + const mayHaveSponsoredStories = prefs["system.showSponsored"]; + const { mayHaveSponsoredTopSites } = prefs; + + const outerClassName = [ + "outer-wrapper", + isDiscoveryStream && pocketEnabled && "ds-outer-wrapper-search-alignment", + isDiscoveryStream && "ds-outer-wrapper-breakpoint-override", + prefs.showSearch && + this.state.fixedSearch && + !noSectionsEnabled && + "fixed-search", + prefs.showSearch && noSectionsEnabled && "only-search", + prefs["logowordmark.alwaysVisible"] && "visible-logo", + ] + .filter(v => v) + .join(" "); + + return ( +
    + + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions*/} +
    +
    + {prefs.showSearch && ( +
    + + + +
    + )} +
    + {isDiscoveryStream ? ( + + + + ) : ( + + )} +
    + +
    +
    +
    + ); + } +} + +export const Base = connect(state => ({ + App: state.App, + Prefs: state.Prefs, + Sections: state.Sections, + DiscoveryStream: state.DiscoveryStream, + Search: state.Search, +}))(_Base); diff --git a/browser/components/newtab/content-src/components/Base/_Base.scss b/browser/components/newtab/content-src/components/Base/_Base.scss new file mode 100644 index 0000000000..1282173df5 --- /dev/null +++ b/browser/components/newtab/content-src/components/Base/_Base.scss @@ -0,0 +1,126 @@ +.outer-wrapper { + color: var(--newtab-text-primary-color); + display: flex; + flex-grow: 1; + min-height: 100vh; + padding: ($section-spacing + $section-vertical-padding) $base-gutter $base-gutter; + + &.ds-outer-wrapper-breakpoint-override { + padding: 30px 0 32px; + + @media(min-width: $break-point-medium) { + padding: 30px 32px 32px; + } + } + + &.only-search { + display: block; + padding-top: 134px; + } + + a { + color: var(--newtab-primary-action-background); + } +} + +main { + margin: auto; + width: $wrapper-default-width; + padding: 0; + + section { + margin-bottom: $section-spacing; + position: relative; + } + + .hide-main & { + visibility: hidden; + } + + @media (min-width: $break-point-medium) { + width: $wrapper-max-width-medium; + } + + @media (min-width: $break-point-large) { + width: $wrapper-max-width-large; + } + + @media (min-width: $break-point-widest) { + width: $wrapper-max-width-widest; + } +} + +.ds-outer-wrapper-search-alignment { + main { + // This override is to ensure while Discovery Stream loads, + // the search bar does not jump around. (it sticks to the top) + margin: 0 auto; + } +} + +.ds-outer-wrapper-breakpoint-override { + main { + width: 266px; + padding-bottom: 0; + + @media (min-width: $break-point-medium) { + width: 510px; + } + + @media (min-width: $break-point-large) { + width: 746px; + } + + @media (min-width: $break-point-widest) { + width: 986px; + } + } +} + +.base-content-fallback { + // Make the error message be centered against the viewport + height: 100vh; +} + +.body-wrapper { + // Hide certain elements so the page structure is fixed, e.g., placeholders, + // while avoiding flashes of changing content, e.g., icons and text + $selectors-to-hide: '.section-title, .sections-list .section:last-of-type, .topics'; + + #{$selectors-to-hide} { + opacity: 0; + } + + &.on { + #{$selectors-to-hide} { + opacity: 1; + } + } +} + +.non-collapsible-section { + padding: 0 $section-horizontal-padding; +} + +.prefs-button { + button { + background-color: transparent; + border: 0; + border-radius: 2px; + cursor: pointer; + inset-inline-end: 15px; + padding: 15px; + position: fixed; + top: 15px; + z-index: 1000; + + &:hover, + &:focus { + background-color: var(--newtab-element-hover-color); + } + + &:active { + background-color: var(--newtab-element-active-color); + } + } +} diff --git a/browser/components/newtab/content-src/components/Card/Card.jsx b/browser/components/newtab/content-src/components/Card/Card.jsx new file mode 100644 index 0000000000..9d03377f1b --- /dev/null +++ b/browser/components/newtab/content-src/components/Card/Card.jsx @@ -0,0 +1,362 @@ +/* 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 "common/Actions.sys.mjs"; +import { cardContextTypes } from "./types"; +import { connect } from "react-redux"; +import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; +import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; +import React from "react"; +import { ScreenshotUtils } from "content-src/lib/screenshot-utils"; + +// Keep track of pending image loads to only request once +const gImageLoading = new Map(); + +/** + * Card component. + * Cards are found within a Section component and contain information about a link such + * as preview image, page title, page description, and some context about if the page + * was visited, bookmarked, trending etc... + * Each Section can make an unordered list of Cards which will create one instane of + * this class. Each card will then get a context menu which reflects the actions that + * can be done on this Card. + */ +export class _Card extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + activeCard: null, + imageLoaded: false, + cardImage: null, + }; + this.onMenuButtonUpdate = this.onMenuButtonUpdate.bind(this); + this.onLinkClick = this.onLinkClick.bind(this); + } + + /** + * Helper to conditionally load an image and update state when it loads. + */ + async maybeLoadImage() { + // No need to load if it's already loaded or no image + const { cardImage } = this.state; + if (!cardImage) { + return; + } + + const imageUrl = cardImage.url; + if (!this.state.imageLoaded) { + // Initialize a promise to share a load across multiple card updates + if (!gImageLoading.has(imageUrl)) { + const loaderPromise = new Promise((resolve, reject) => { + const loader = new Image(); + loader.addEventListener("load", resolve); + loader.addEventListener("error", reject); + loader.src = imageUrl; + }); + + // Save and remove the promise only while it's pending + gImageLoading.set(imageUrl, loaderPromise); + loaderPromise + .catch(ex => ex) + .then(() => gImageLoading.delete(imageUrl)) + .catch(); + } + + // Wait for the image whether just started loading or reused promise + try { + await gImageLoading.get(imageUrl); + } catch (ex) { + // Ignore the failed image without changing state + return; + } + + // Only update state if we're still waiting to load the original image + if ( + ScreenshotUtils.isRemoteImageLocal( + this.state.cardImage, + this.props.link.image + ) && + !this.state.imageLoaded + ) { + this.setState({ imageLoaded: true }); + } + } + } + + /** + * Helper to obtain the next state based on nextProps and prevState. + * + * NOTE: Rename this method to getDerivedStateFromProps when we update React + * to >= 16.3. We will need to update tests as well. We cannot rename this + * method to getDerivedStateFromProps now because there is a mismatch in + * the React version that we are using for both testing and production. + * (i.e. react-test-render => "16.3.2", react => "16.2.0"). + * + * See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43. + */ + static getNextStateFromProps(nextProps, prevState) { + const { image } = nextProps.link; + const imageInState = ScreenshotUtils.isRemoteImageLocal( + prevState.cardImage, + image + ); + let nextState = null; + + // Image is updating. + if (!imageInState && nextProps.link) { + nextState = { imageLoaded: false }; + } + + if (imageInState) { + return nextState; + } + + // Since image was updated, attempt to revoke old image blob URL, if it exists. + ScreenshotUtils.maybeRevokeBlobObjectURL(prevState.cardImage); + + nextState = nextState || {}; + nextState.cardImage = ScreenshotUtils.createLocalImageObject(image); + + return nextState; + } + + onMenuButtonUpdate(isOpen) { + if (isOpen) { + this.setState({ activeCard: this.props.index }); + } else { + this.setState({ activeCard: null }); + } + } + + /** + * Report to telemetry additional information about the item. + */ + _getTelemetryInfo() { + // Filter out "history" type for being the default + if (this.props.link.type !== "history") { + return { value: { card_type: this.props.link.type } }; + } + + return null; + } + + onLinkClick(event) { + event.preventDefault(); + const { altKey, button, ctrlKey, metaKey, shiftKey } = event; + if (this.props.link.type === "download") { + this.props.dispatch( + ac.OnlyToMain({ + type: at.OPEN_DOWNLOAD_FILE, + data: Object.assign(this.props.link, { + event: { button, ctrlKey, metaKey, shiftKey }, + }), + }) + ); + } else { + this.props.dispatch( + ac.OnlyToMain({ + type: at.OPEN_LINK, + data: Object.assign(this.props.link, { + event: { altKey, button, ctrlKey, metaKey, shiftKey }, + }), + }) + ); + } + if (this.props.isWebExtension) { + this.props.dispatch( + ac.WebExtEvent(at.WEBEXT_CLICK, { + source: this.props.eventSource, + url: this.props.link.url, + action_position: this.props.index, + }) + ); + } else { + this.props.dispatch( + ac.UserEvent( + Object.assign( + { + event: "CLICK", + source: this.props.eventSource, + action_position: this.props.index, + }, + this._getTelemetryInfo() + ) + ) + ); + + if (this.props.shouldSendImpressionStats) { + this.props.dispatch( + ac.ImpressionStats({ + source: this.props.eventSource, + click: 0, + tiles: [{ id: this.props.link.guid, pos: this.props.index }], + }) + ); + } + } + } + + componentDidMount() { + this.maybeLoadImage(); + } + + componentDidUpdate() { + this.maybeLoadImage(); + } + + // NOTE: Remove this function when we update React to >= 16.3 since React will + // call getDerivedStateFromProps automatically. We will also need to + // rename getNextStateFromProps to getDerivedStateFromProps. + componentWillMount() { + const nextState = _Card.getNextStateFromProps(this.props, this.state); + if (nextState) { + this.setState(nextState); + } + } + + // NOTE: Remove this function when we update React to >= 16.3 since React will + // call getDerivedStateFromProps automatically. We will also need to + // rename getNextStateFromProps to getDerivedStateFromProps. + componentWillReceiveProps(nextProps) { + const nextState = _Card.getNextStateFromProps(nextProps, this.state); + if (nextState) { + this.setState(nextState); + } + } + + componentWillUnmount() { + ScreenshotUtils.maybeRevokeBlobObjectURL(this.state.cardImage); + } + + render() { + const { + index, + className, + link, + dispatch, + contextMenuOptions, + eventSource, + shouldSendImpressionStats, + } = this.props; + const { props } = this; + const title = link.title || link.hostname; + const isContextMenuOpen = this.state.activeCard === index; + // Display "now" as "trending" until we have new strings #3402 + const { icon, fluentID } = + cardContextTypes[link.type === "now" ? "trending" : link.type] || {}; + const hasImage = this.state.cardImage || link.hasImage; + const imageStyle = { + backgroundImage: this.state.cardImage + ? `url(${this.state.cardImage.url})` + : "none", + }; + const outerClassName = [ + "card-outer", + className, + isContextMenuOpen && "active", + props.placeholder && "placeholder", + ] + .filter(v => v) + .join(" "); + + return ( +
  • + +
  • + ); + } +} +_Card.defaultProps = { link: {} }; +export const Card = connect(state => ({ + platform: state.Prefs.values.platform, +}))(_Card); +export const PlaceholderCard = props => ( + +); diff --git a/browser/components/newtab/content-src/components/Card/_Card.scss b/browser/components/newtab/content-src/components/Card/_Card.scss new file mode 100644 index 0000000000..74288ff07f --- /dev/null +++ b/browser/components/newtab/content-src/components/Card/_Card.scss @@ -0,0 +1,333 @@ +@use 'sass:math'; + +/* stylelint-disable max-nesting-depth */ + +.card-outer { + @include context-menu-button; + + background: var(--newtab-background-color-secondary); + border-radius: $border-radius-new; + display: inline-block; + height: $card-height; + margin-inline-end: $base-gutter; + position: relative; + width: 100%; + + &:is(:focus):not(.placeholder) { + @include ds-focus; + + transition: none; + } + + &:hover { + box-shadow: none; + transition: none; + } + + &.placeholder { + background: transparent; + + .card-preview-image-outer, + .card-context { + display: none; + } + } + + .card { + border-radius: $border-radius-new; + box-shadow: $shadow-card; + height: 100%; + } + + > a { + color: inherit; + display: block; + height: 100%; + outline: none; + position: absolute; + width: 100%; + + &:is(:focus) { + .card { + @include ds-focus; + } + } + + &:is(.active, :focus) { + .card { + @include fade-in-card; + } + + .card-title { + color: var(--newtab-primary-action-background); + } + } + } + + &:is(:hover, :focus, .active):not(.placeholder) { + @include context-menu-button-hover; + + outline: none; + + .card-title { + color: var(--newtab-primary-action-background); + } + + .alternate ~ .card-host-name { + display: none; + } + + .card-host-name.alternate { + display: block; + } + } + + .card-preview-image-outer { + background-color: var(--newtab-element-secondary-color); + border-radius: $border-radius-new $border-radius-new 0 0; + height: $card-preview-image-height; + overflow: hidden; + position: relative; + + &::after { + border-bottom: 1px solid var(--newtab-card-hairline-color); + bottom: 0; + content: ''; + position: absolute; + width: 100%; + } + + .card-preview-image { + background-position: center; + background-repeat: no-repeat; + background-size: cover; + height: 100%; + opacity: 0; + transition: opacity 1s $photon-easing; + width: 100%; + + &.loaded { + opacity: 1; + } + } + } + + .card-details { + padding: 15px 16px 12px; + } + + .card-text { + max-height: 4 * $card-text-line-height + $card-title-margin; + overflow: hidden; + + &.no-host-name, + &.no-context { + max-height: 5 * $card-text-line-height + $card-title-margin; + } + + &.no-host-name.no-context { + max-height: 6 * $card-text-line-height + $card-title-margin; + } + + &:not(.no-description) .card-title { + max-height: 3 * $card-text-line-height; + overflow: hidden; + } + } + + .card-host-name { + color: var(--newtab-text-secondary-color); + font-size: 10px; + overflow: hidden; + padding-bottom: 4px; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; + } + + .card-host-name.alternate { display: none; } + + .card-title { + font-size: 14px; + font-weight: 600; + line-height: $card-text-line-height; + margin: 0 0 $card-title-margin; + word-wrap: break-word; + } + + .card-description { + font-size: 12px; + line-height: $card-text-line-height; + margin: 0; + overflow: hidden; + word-wrap: break-word; + } + + .card-context { + bottom: 0; + color: var(--newtab-text-secondary-color); + display: flex; + font-size: 11px; + inset-inline-start: 0; + padding: 9px 16px 9px 14px; + position: absolute; + } + + .card-context-icon { + fill: var(--newtab-text-secondary-color); + height: 22px; + margin-inline-end: 6px; + } + + .card-context-label { + flex-grow: 1; + line-height: 22px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.normal-cards { + .card-outer { + // Wide layout styles + @media (min-width: $break-point-widest) { + $line-height: 23px; + + height: $card-height-large; + + .card-preview-image-outer { + height: $card-preview-image-height-large; + } + + .card-details { + padding: 13px 16px 12px; + } + + .card-text { + max-height: 6 * $line-height + $card-title-margin; + } + + .card-host-name { + font-size: 12px; + padding-bottom: 5px; + } + + .card-title { + font-size: 17px; + line-height: $line-height; + margin-bottom: 0; + } + + .card-text:not(.no-description) { + .card-title { + max-height: 3 * $line-height; + } + } + + .card-description { + font-size: 15px; + line-height: $line-height; + } + + .card-context { + bottom: 4px; + font-size: 14px; + } + } + } +} + +.compact-cards { + $card-detail-vertical-spacing: 12px; + $card-title-font-size: 12px; + + .card-outer { + height: $card-height-compact; + + .card-preview-image-outer { + height: $card-preview-image-height-compact; + } + + .card-details { + padding: $card-detail-vertical-spacing 16px; + } + + .card-host-name { + line-height: 10px; + } + + .card-text { + .card-title, + &:not(.no-description) .card-title { + font-size: $card-title-font-size; + line-height: $card-title-font-size + 1; + max-height: $card-title-font-size + 5; + overflow: hidden; + padding: 0 0 4px; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .card-description { + display: none; + } + + .card-context { + $icon-size: 16px; + $container-size: 32px; + + background-color: var(--newtab-background-color-secondary); + border-radius: math.div($container-size, 2); + clip-path: inset(-1px -1px $container-size - ($card-height-compact - $card-preview-image-height-compact - 2 * $card-detail-vertical-spacing)); + height: $container-size; + width: $container-size; + padding: math.div($container-size - $icon-size, 2); + // The -1 at the end is so both opacity borders don't overlap, which causes bug 1629483 + top: $card-preview-image-height-compact - math.div($container-size, 2) - 1; + inset-inline-end: 12px; + inset-inline-start: auto; + + &::after { + border: 1px solid var(--newtab-card-hairline-color); + border-bottom: 0; + border-radius: math.div($container-size, 2) + 1 math.div($container-size, 2) + 1 0 0; + content: ''; + position: absolute; + height: math.div($container-size + 2, 2); + width: $container-size + 2; + top: -1px; + left: -1px; + } + + .card-context-icon { + margin-inline-end: 0; + height: $icon-size; + width: $icon-size; + + &.icon-bookmark-added { + fill: $bookmark-icon-fill; + } + + &.icon-download { + fill: $download-icon-fill; + } + + &.icon-pocket { + fill: $pocket-icon-fill; + } + } + + .card-context-label { + display: none; + } + } + } + + @media not all and (min-width: $break-point-widest) { + .hide-for-narrow { + display: none; + } + } +} diff --git a/browser/components/newtab/content-src/components/Card/types.js b/browser/components/newtab/content-src/components/Card/types.js new file mode 100644 index 0000000000..0b17eea408 --- /dev/null +++ b/browser/components/newtab/content-src/components/Card/types.js @@ -0,0 +1,30 @@ +/* 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/. */ + +export const cardContextTypes = { + history: { + fluentID: "newtab-label-visited", + icon: "history-item", + }, + removedBookmark: { + fluentID: "newtab-label-removed-bookmark", + icon: "bookmark-removed", + }, + bookmark: { + fluentID: "newtab-label-bookmarked", + icon: "bookmark-added", + }, + trending: { + fluentID: "newtab-label-recommended", + icon: "trending", + }, + pocket: { + fluentID: "newtab-label-saved", + icon: "pocket", + }, + download: { + fluentID: "newtab-label-download", + icon: "download", + }, +}; diff --git a/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx b/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx new file mode 100644 index 0000000000..679e8e137f --- /dev/null +++ b/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx @@ -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 { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; +import React from "react"; +import { connect } from "react-redux"; + +/** + * A section that can collapse. As of bug 1710937, it can no longer collapse. + * See bug 1727365 for follow-up work to simplify this component. + */ +export class _CollapsibleSection extends React.PureComponent { + constructor(props) { + super(props); + this.onBodyMount = this.onBodyMount.bind(this); + this.onMenuButtonMouseEnter = this.onMenuButtonMouseEnter.bind(this); + this.onMenuButtonMouseLeave = this.onMenuButtonMouseLeave.bind(this); + this.onMenuUpdate = this.onMenuUpdate.bind(this); + this.state = { + menuButtonHover: false, + showContextMenu: false, + }; + this.setContextMenuButtonRef = this.setContextMenuButtonRef.bind(this); + } + + setContextMenuButtonRef(element) { + this.contextMenuButtonRef = element; + } + + onBodyMount(node) { + this.sectionBody = node; + } + + onMenuButtonMouseEnter() { + this.setState({ menuButtonHover: true }); + } + + onMenuButtonMouseLeave() { + this.setState({ menuButtonHover: false }); + } + + onMenuUpdate(showContextMenu) { + this.setState({ showContextMenu }); + } + + render() { + const { isAnimating, maxHeight, menuButtonHover, showContextMenu } = + this.state; + const { id, collapsed, learnMore, title, subTitle } = this.props; + const active = menuButtonHover || showContextMenu; + let bodyStyle; + if (isAnimating && !collapsed) { + bodyStyle = { maxHeight }; + } else if (!isAnimating && collapsed) { + bodyStyle = { display: "none" }; + } + let titleStyle; + if (this.props.hideTitle) { + titleStyle = { visibility: "hidden" }; + } + const hasSubtitleClassName = subTitle ? `has-subtitle` : ``; + return ( +
    + + +
    + {this.props.children} +
    +
    +
    + ); + } +} + +_CollapsibleSection.defaultProps = { + document: global.document || { + addEventListener: () => {}, + removeEventListener: () => {}, + visibilityState: "hidden", + }, +}; + +export const CollapsibleSection = connect(state => ({ + Prefs: state.Prefs, +}))(_CollapsibleSection); diff --git a/browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss b/browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss new file mode 100644 index 0000000000..10cc58a1b1 --- /dev/null +++ b/browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss @@ -0,0 +1,108 @@ +/* stylelint-disable max-nesting-depth */ + +.collapsible-section { + padding: $section-vertical-padding $section-horizontal-padding; + + .section-title-container { + margin: 0; + + &.has-subtitle { + display: flex; + flex-direction: column; + + @media (min-width: $break-point-large) { + flex-direction: row; + align-items: baseline; + justify-content: space-between; + } + } + } + + .section-title { + font-size: $section-title-font-size; + font-weight: 600; + color: var(--newtab-text-primary-color); + + &.grey-title { + color: var(--newtab-text-primary-color); + display: inline-block; + fill: var(--newtab-text-primary-color); + vertical-align: middle; + } + + .section-title-contents { + // Center "What's Pocket?" for "mobile" viewport + @media (max-width: $break-point-medium - 1) { + display: block; + + .learn-more-link-wrapper { + display: block; + text-align: center; + + .learn-more-link { + margin-inline-start: 0; + } + } + } + + vertical-align: top; + } + } + + .section-sub-title { + font-size: 14px; + line-height: 16px; + color: var(--newtab-text-secondary-color); + opacity: 0.3; + } + + .section-top-bar { + min-height: 19px; + margin-bottom: 13px; + position: relative; + } + + &.active { + background: var(--newtab-element-hover-color); + border-radius: 4px; + } + + .learn-more-link { + font-size: 13px; + margin-inline-start: 12px; + + a { + color: var(--newtab-primary-action-background); + } + } + + .section-body-fallback { + height: $card-height; + } + + .section-body { + // This is so the top sites favicon and card dropshadows don't get clipped during animation: + $horizontal-padding: 7px; + + margin: 0 (-$horizontal-padding); + padding: 0 $horizontal-padding; + + &.animating { + overflow: hidden; + pointer-events: none; + } + } + + &[data-section-id='topsites'] { + .section-top-bar { + display: none; + } + } + + // Hide first story card for the medium breakpoint to prevent orphaned third story + &[data-section-id='topstories'] .card-outer:first-child { + @media (min-width: $break-point-medium) and (max-width: $break-point-large - 1) { + display: none; + } + } +} diff --git a/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx b/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx new file mode 100644 index 0000000000..4efd8c712e --- /dev/null +++ b/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx @@ -0,0 +1,177 @@ +/* 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 "common/Actions.sys.mjs"; +import { perfService as perfSvc } from "content-src/lib/perf-service"; +import React from "react"; + +// Currently record only a fixed set of sections. This will prevent data +// from custom sections from showing up or from topstories. +const RECORDED_SECTIONS = ["highlights", "topsites"]; + +export class ComponentPerfTimer extends React.Component { + constructor(props) { + super(props); + // Just for test dependency injection: + this.perfSvc = this.props.perfSvc || perfSvc; + + this._sendBadStateEvent = this._sendBadStateEvent.bind(this); + this._sendPaintedEvent = this._sendPaintedEvent.bind(this); + this._reportMissingData = false; + this._timestampHandled = false; + this._recordedFirstRender = false; + } + + componentDidMount() { + if (!RECORDED_SECTIONS.includes(this.props.id)) { + return; + } + + this._maybeSendPaintedEvent(); + } + + componentDidUpdate() { + if (!RECORDED_SECTIONS.includes(this.props.id)) { + return; + } + + this._maybeSendPaintedEvent(); + } + + /** + * Call the given callback after the upcoming frame paints. + * + * @note Both setTimeout and requestAnimationFrame are throttled when the page + * is hidden, so this callback may get called up to a second or so after the + * requestAnimationFrame "paint" for hidden tabs. + * + * Newtabs hidden while loading will presumably be fairly rare (other than + * preloaded tabs, which we will be filtering out on the server side), so such + * cases should get lost in the noise. + * + * If we decide that it's important to find out when something that's hidden + * has "painted", however, another option is to post a message to this window. + * That should happen even faster than setTimeout, and, at least as of this + * writing, it's not throttled in hidden windows in Firefox. + * + * @param {Function} callback + * + * @returns void + */ + _afterFramePaint(callback) { + requestAnimationFrame(() => setTimeout(callback, 0)); + } + + _maybeSendBadStateEvent() { + // Follow up bugs: + // https://github.com/mozilla/activity-stream/issues/3691 + if (!this.props.initialized) { + // Remember to report back when data is available. + this._reportMissingData = true; + } else if (this._reportMissingData) { + this._reportMissingData = false; + // Report how long it took for component to become initialized. + this._sendBadStateEvent(); + } + } + + _maybeSendPaintedEvent() { + // If we've already handled a timestamp, don't do it again. + if (this._timestampHandled || !this.props.initialized) { + return; + } + + // And if we haven't, we're doing so now, so remember that. Even if + // something goes wrong in the callback, we can't try again, as we'd be + // sending back the wrong data, and we have to do it here, so that other + // calls to this method while waiting for the next frame won't also try to + // handle it. + this._timestampHandled = true; + this._afterFramePaint(this._sendPaintedEvent); + } + + /** + * Triggered by call to render. Only first call goes through due to + * `_recordedFirstRender`. + */ + _ensureFirstRenderTsRecorded() { + // Used as t0 for recording how long component took to initialize. + if (!this._recordedFirstRender) { + this._recordedFirstRender = true; + // topsites_first_render_ts, highlights_first_render_ts. + const key = `${this.props.id}_first_render_ts`; + this.perfSvc.mark(key); + } + } + + /** + * Creates `SAVE_SESSION_PERF_DATA` with timestamp in ms + * of how much longer the data took to be ready for display than it would + * have been the ideal case. + * https://github.com/mozilla/ping-centre/issues/98 + */ + _sendBadStateEvent() { + // highlights_data_ready_ts, topsites_data_ready_ts. + const dataReadyKey = `${this.props.id}_data_ready_ts`; + this.perfSvc.mark(dataReadyKey); + + try { + const firstRenderKey = `${this.props.id}_first_render_ts`; + // value has to be Int32. + const value = parseInt( + this.perfSvc.getMostRecentAbsMarkStartByName(dataReadyKey) - + this.perfSvc.getMostRecentAbsMarkStartByName(firstRenderKey), + 10 + ); + this.props.dispatch( + ac.OnlyToMain({ + type: at.SAVE_SESSION_PERF_DATA, + // highlights_data_late_by_ms, topsites_data_late_by_ms. + data: { [`${this.props.id}_data_late_by_ms`]: value }, + }) + ); + } catch (ex) { + // If this failed, it's likely because the `privacy.resistFingerprinting` + // pref is true. + } + } + + _sendPaintedEvent() { + // Record first_painted event but only send if topsites. + if (this.props.id !== "topsites") { + return; + } + + // topsites_first_painted_ts. + const key = `${this.props.id}_first_painted_ts`; + this.perfSvc.mark(key); + + try { + const data = {}; + data[key] = this.perfSvc.getMostRecentAbsMarkStartByName(key); + + this.props.dispatch( + ac.OnlyToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data, + }) + ); + } catch (ex) { + // If this failed, it's likely because the `privacy.resistFingerprinting` + // pref is true. We should at least not blow up, and should continue + // to set this._timestampHandled to avoid going through this again. + } + } + + render() { + if (RECORDED_SECTIONS.includes(this.props.id)) { + this._ensureFirstRenderTsRecorded(); + this._maybeSendBadStateEvent(); + } + return this.props.children; + } +} diff --git a/browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx b/browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx new file mode 100644 index 0000000000..f69e540079 --- /dev/null +++ b/browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx @@ -0,0 +1,103 @@ +/* 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 } from "common/Actions.sys.mjs"; +import { connect } from "react-redux"; +import React from "react"; + +/** + * ConfirmDialog component. + * One primary action button, one cancel button. + * + * Content displayed is controlled by `data` prop the component receives. + * Example: + * data: { + * // Any sort of data needed to be passed around by actions. + * payload: site.url, + * // Primary button AlsoToMain action. + * action: "DELETE_HISTORY_URL", + * // Primary button USerEvent action. + * userEvent: "DELETE", + * // Array of locale ids to display. + * message_body: ["confirm_history_delete_p1", "confirm_history_delete_notice_p2"], + * // Text for primary button. + * confirm_button_string_id: "menu_action_delete" + * }, + */ +export class _ConfirmDialog extends React.PureComponent { + constructor(props) { + super(props); + this._handleCancelBtn = this._handleCancelBtn.bind(this); + this._handleConfirmBtn = this._handleConfirmBtn.bind(this); + } + + _handleCancelBtn() { + this.props.dispatch({ type: actionTypes.DIALOG_CANCEL }); + this.props.dispatch( + ac.UserEvent({ + event: actionTypes.DIALOG_CANCEL, + source: this.props.data.eventSource, + }) + ); + } + + _handleConfirmBtn() { + this.props.data.onConfirm.forEach(this.props.dispatch); + } + + _renderModalMessage() { + const message_body = this.props.data.body_string_id; + + if (!message_body) { + return null; + } + + return ( + + {message_body.map(msg => ( +

    + ))} + + ); + } + + render() { + if (!this.props.visible) { + return null; + } + + return ( +

    +
    +
    +
    + {this.props.data.icon && ( + + )} + {this._renderModalMessage()} +
    +
    +
    +
    +
    + ); + } +} + +export const ConfirmDialog = connect(state => state.Dialog)(_ConfirmDialog); diff --git a/browser/components/newtab/content-src/components/ConfirmDialog/_ConfirmDialog.scss b/browser/components/newtab/content-src/components/ConfirmDialog/_ConfirmDialog.scss new file mode 100644 index 0000000000..ca9940ffc5 --- /dev/null +++ b/browser/components/newtab/content-src/components/ConfirmDialog/_ConfirmDialog.scss @@ -0,0 +1,68 @@ +.confirmation-dialog { + .modal { + box-shadow: $shadow-secondary; + left: 0; + margin: auto; + position: fixed; + right: 0; + top: 20%; + width: 400px; + } + + section { + margin: 0; + } + + .modal-message { + display: flex; + padding: 16px; + padding-bottom: 0; + + p { + margin: 0; + margin-bottom: 16px; + } + } + + .actions { + border: 0; + display: flex; + flex-wrap: nowrap; + padding: 0 16px; + + button { + margin-inline-end: 16px; + padding-inline-end: 18px; + padding-inline-start: 18px; + white-space: normal; + width: 50%; + + &.done { + margin-inline-end: 0; + margin-inline-start: 0; + } + } + } + + .icon { + margin-inline-end: 16px; + } +} + +.modal-overlay { + background: var(--newtab-overlay-color); + height: 100%; + left: 0; + position: fixed; + top: 0; + width: 100%; + z-index: 11001; +} + +.modal { + background: var(--newtab-background-color-secondary); + border: $border-secondary; + border-radius: 5px; + font-size: 15px; + z-index: 11002; +} diff --git a/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx b/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx new file mode 100644 index 0000000000..5ea6a57f71 --- /dev/null +++ b/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx @@ -0,0 +1,176 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { connect } from "react-redux"; + +export class ContextMenu extends React.PureComponent { + constructor(props) { + super(props); + this.hideContext = this.hideContext.bind(this); + this.onShow = this.onShow.bind(this); + this.onClick = this.onClick.bind(this); + } + + hideContext() { + this.props.onUpdate(false); + } + + onShow() { + if (this.props.onShow) { + this.props.onShow(); + } + } + + componentDidMount() { + this.onShow(); + setTimeout(() => { + global.addEventListener("click", this.hideContext); + }, 0); + } + + componentWillUnmount() { + global.removeEventListener("click", this.hideContext); + } + + onClick(event) { + // Eat all clicks on the context menu so they don't bubble up to window. + // This prevents the context menu from closing when clicking disabled items + // or the separators. + event.stopPropagation(); + } + + render() { + // Disabling focus on the menu span allows the first tab to focus on the first menu item instead of the wrapper. + return ( + // eslint-disable-next-line jsx-a11y/interactive-supports-focus + +
      + {this.props.options.map((option, i) => + option.type === "separator" ? ( +
    • + ) : ( + option.type !== "empty" && ( + + ) + ) + )} +
    +
    + ); + } +} + +export class _ContextMenuItem extends React.PureComponent { + constructor(props) { + super(props); + this.onClick = this.onClick.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onKeyUp = this.onKeyUp.bind(this); + this.focusFirst = this.focusFirst.bind(this); + } + + onClick(event) { + this.props.hideContext(); + this.props.option.onClick(event); + } + + // Focus the first menu item if the menu was accessed via the keyboard. + focusFirst(button) { + if (this.props.keyboardAccess && button) { + button.focus(); + } + } + + // This selects the correct node based on the key pressed + focusSibling(target, key) { + const parent = target.parentNode; + const closestSiblingSelector = + key === "ArrowUp" ? "previousSibling" : "nextSibling"; + if (!parent[closestSiblingSelector]) { + return; + } + if (parent[closestSiblingSelector].firstElementChild) { + parent[closestSiblingSelector].firstElementChild.focus(); + } else { + parent[closestSiblingSelector][ + closestSiblingSelector + ].firstElementChild.focus(); + } + } + + onKeyDown(event) { + const { option } = this.props; + switch (event.key) { + case "Tab": + // tab goes down in context menu, shift + tab goes up in context menu + // if we're on the last item, one more tab will close the context menu + // similarly, if we're on the first item, one more shift + tab will close it + if ( + (event.shiftKey && option.first) || + (!event.shiftKey && option.last) + ) { + this.props.hideContext(); + } + break; + case "ArrowUp": + case "ArrowDown": + event.preventDefault(); + this.focusSibling(event.target, event.key); + break; + case "Enter": + case " ": + event.preventDefault(); + this.props.hideContext(); + option.onClick(); + break; + case "Escape": + this.props.hideContext(); + break; + } + } + + // Prevents the default behavior of spacebar + // scrolling the page & auto-triggering buttons. + onKeyUp(event) { + if (event.key === " ") { + event.preventDefault(); + } + } + + render() { + const { option } = this.props; + return ( +
  • + +
  • + ); + } +} + +export const ContextMenuItem = connect(state => ({ + Prefs: state.Prefs, +}))(_ContextMenuItem); diff --git a/browser/components/newtab/content-src/components/ContextMenu/ContextMenuButton.jsx b/browser/components/newtab/content-src/components/ContextMenu/ContextMenuButton.jsx new file mode 100644 index 0000000000..0364f5386a --- /dev/null +++ b/browser/components/newtab/content-src/components/ContextMenu/ContextMenuButton.jsx @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; + +export class ContextMenuButton extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + showContextMenu: false, + contextMenuKeyboard: false, + }; + this.onClick = this.onClick.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onUpdate = this.onUpdate.bind(this); + } + + openContextMenu(isKeyBoard, event) { + 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 }); + } + + render() { + const { tooltipArgs, tooltip, children, refFunction } = this.props; + const { showContextMenu, contextMenuKeyboard } = this.state; + + return ( + +
    + + ); + } +} diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx b/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx new file mode 100644 index 0000000000..3d33f6fde7 --- /dev/null +++ b/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx @@ -0,0 +1,85 @@ +/* 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 { BackgroundsSection } from "content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection"; +import { ContentSection } from "content-src/components/CustomizeMenu/ContentSection/ContentSection"; +import { connect } from "react-redux"; +import React from "react"; +import { CSSTransition } from "react-transition-group"; + +export class _CustomizeMenu extends React.PureComponent { + constructor(props) { + super(props); + this.onEntered = this.onEntered.bind(this); + this.onExited = this.onExited.bind(this); + } + + onEntered() { + if (this.closeButton) { + this.closeButton.focus(); + } + } + + onExited() { + if (this.openButton) { + this.openButton.focus(); + } + } + + render() { + return ( + + +

    + + + + + + + + + + + + + +
    + + Personalization Last Updated{relativeTime(lastUpdated) || "(no data)"}Personalization Initialized{initialized ? "true" : "false"}
    + + ); + } +} + +export class DiscoveryStreamAdminUI extends React.PureComponent { + constructor(props) { + super(props); + this.restorePrefDefaults = this.restorePrefDefaults.bind(this); + this.setConfigValue = this.setConfigValue.bind(this); + this.expireCache = this.expireCache.bind(this); + this.refreshCache = this.refreshCache.bind(this); + this.idleDaily = this.idleDaily.bind(this); + this.systemTick = this.systemTick.bind(this); + this.syncRemoteSettings = this.syncRemoteSettings.bind(this); + this.onStoryToggle = this.onStoryToggle.bind(this); + this.state = { + toggledStories: {}, + }; + } + + setConfigValue(name, value) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_CONFIG_SET_VALUE, + data: { name, value }, + }) + ); + } + + restorePrefDefaults(event) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS, + }) + ); + } + + refreshCache() { + const { config } = this.props.state.DiscoveryStream; + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_CONFIG_CHANGE, + data: config, + }) + ); + } + + dispatchSimpleAction(type) { + this.props.dispatch( + ac.OnlyToMain({ + type, + }) + ); + } + + systemTick() { + this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_SYSTEM_TICK); + } + + expireCache() { + this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE); + } + + idleDaily() { + this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_IDLE_DAILY); + } + + syncRemoteSettings() { + this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_SYNC_RS); + } + + renderComponent(width, component) { + return ( + + + + + + + + + + + {component.feed && this.renderFeed(component.feed)} + +
    Type{component.type}Width{width}
    + ); + } + + renderFeedData(url) { + const { feeds } = this.props.state.DiscoveryStream; + const feed = feeds.data[url].data; + return ( + +

    Feed url: {url}

    + + + {feed.recommendations?.map(story => this.renderStoryData(story))} + +
    +
    + ); + } + + renderFeedsData() { + const { feeds } = this.props.state.DiscoveryStream; + return ( + + {Object.keys(feeds.data).map(url => this.renderFeedData(url))} + + ); + } + + renderSpocs() { + const { spocs } = this.props.state.DiscoveryStream; + let spocsData = []; + if (spocs.data && spocs.data.spocs && spocs.data.spocs.items) { + spocsData = spocs.data.spocs.items || []; + } + + return ( + + + + + + + + + + + + +
    spocs_endpoint{spocs.spocs_endpoint}Data last fetched{relativeTime(spocs.lastUpdated)}
    +

    Spoc data

    + + {spocsData.map(spoc => this.renderStoryData(spoc))} +
    +

    Spoc frequency caps

    + + + {spocs.frequency_caps.map(spoc => this.renderStoryData(spoc))} + +
    +
    + ); + } + + onStoryToggle(story) { + const { toggledStories } = this.state; + this.setState({ + toggledStories: { + ...toggledStories, + [story.id]: !toggledStories[story.id], + }, + }); + } + + renderStoryData(story) { + let storyData = ""; + if (this.state.toggledStories[story.id]) { + storyData = JSON.stringify(story, null, 2); + } + return ( + + + + {story.id}
    +
    + + + +
    {storyData}
    + + + ); + } + + renderFeed(feed) { + const { feeds } = this.props.state.DiscoveryStream; + if (!feed.url) { + return null; + } + return ( + + + Feed url + {feed.url} + + + Data last fetched + + {relativeTime( + feeds.data[feed.url] ? feeds.data[feed.url].lastUpdated : null + ) || "(no data)"} + + + + ); + } + + render() { + const prefToggles = "enabled collapsible".split(" "); + const { config, layout } = this.props.state.DiscoveryStream; + const personalized = + this.props.otherPrefs["discoverystream.personalization.enabled"]; + return ( +
    + {" "} + +
    + {" "} + {" "} + +
    + + + + {prefToggles.map(pref => ( + + + + ))} + +
    + +
    +

    Layout

    + {layout.map((row, rowIndex) => ( +
    + {row.components.map((component, componentIndex) => ( +
    + {this.renderComponent(row.width, component)} +
    + ))} +
    + ))} +

    Personalization

    + +

    Spocs

    + {this.renderSpocs()} +

    Feeds Data

    + {this.renderFeedsData()} +
    + ); + } +} + +export class DiscoveryStreamAdminInner extends React.PureComponent { + constructor(props) { + super(props); + this.setState = this.setState.bind(this); + } + + render() { + return ( +
    +
    +

    Discovery Stream Admin

    + +

    + {" "} + + Need to access the ASRouter Admin dev tools?{" "} + + Click here + + +

    + + + + +
    +
    + ); + } +} + +export class CollapseToggle extends React.PureComponent { + constructor(props) { + super(props); + this.onCollapseToggle = this.onCollapseToggle.bind(this); + this.state = { collapsed: false }; + } + + get renderAdmin() { + const { props } = this; + return props.location.hash && props.location.hash.startsWith("#devtools"); + } + + onCollapseToggle(e) { + e.preventDefault(); + this.setState(state => ({ collapsed: !state.collapsed })); + } + + setBodyClass() { + if (this.renderAdmin && !this.state.collapsed) { + global.document.body.classList.add("no-scroll"); + } else { + global.document.body.classList.remove("no-scroll"); + } + } + + componentDidMount() { + this.setBodyClass(); + } + + componentDidUpdate() { + this.setBodyClass(); + } + + componentWillUnmount() { + global.document.body.classList.remove("no-scroll"); + } + + render() { + const { props } = this; + const { renderAdmin } = this; + const isCollapsed = this.state.collapsed || !renderAdmin; + const label = `${isCollapsed ? "Expand" : "Collapse"} devtools`; + return ( + + + + + {renderAdmin ? ( + + ) : null} + + ); + } +} + +const _DiscoveryStreamAdmin = props => ( + + + +); + +export const DiscoveryStreamAdmin = connect(state => ({ + Sections: state.Sections, + DiscoveryStream: state.DiscoveryStream, + Personalization: state.Personalization, + Prefs: state.Prefs, +}))(_DiscoveryStreamAdmin); diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.scss b/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.scss new file mode 100644 index 0000000000..a01227dd3d --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.scss @@ -0,0 +1,337 @@ +/* stylelint-disable max-nesting-depth */ + +.discoverystream-admin-toggle { + position: fixed; + top: 50px; + inset-inline-end: 15px; + border: 0; + background: none; + z-index: 1; + border-radius: 2px; + + .icon-devtools { + background-image: url('chrome://global/skin/icons/developer.svg'); + padding: 15px; + } + + &:dir(rtl) { + transform: scaleX(-1); + } + + &:hover { + background: var(--newtab-element-hover-color); + } + + &.expanded { + background: $black-20; + } +} + +.discoverystream-admin { + $border-color: var(--newtab-border-color); + $monospace: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', + 'Source Code Pro', monospace; + $sidebar-width: 240px; + + position: fixed; + top: 0; + inset-inline-start: 0; + width: 100%; + background: var(--newtab-background-color); + height: 100%; + overflow-y: scroll; + margin: 0 auto; + font-size: 14px; + padding-inline-start: $sidebar-width; + color: var(--newtab-text-primary-color); + + &.collapsed { + display: none; + } + + .sidebar { + inset-inline-start: 0; + position: fixed; + width: $sidebar-width; + padding: 30px 20px; + + ul { + margin: 0; + padding: 0; + list-style: none; + } + + li a { + padding: 10px 34px; + display: block; + color: var(--lwt-sidebar-text-color); + + &:hover { + background: var(--newtab-background-color-secondary); + } + } + } + + h1 { + font-weight: 200; + font-size: 32px; + } + + h2 .button, + p .button { + font-size: 14px; + padding: 6px 12px; + margin-inline-start: 5px; + margin-bottom: 0; + } + + .general-textarea { + direction: ltr; + width: 740px; + height: 500px; + overflow: auto; + resize: none; + border-radius: 4px; + display: flex; + } + + .wnp-textarea { + direction: ltr; + width: 740px; + height: 500px; + overflow: auto; + resize: none; + border-radius: 4px; + display: flex; + } + + .json-button { + display: inline-flex; + font-size: 10px; + padding: 4px 10px; + margin-bottom: 6px; + margin-inline-end: 4px; + + &:hover { + background-color: var(--newtab-element-hover-color); + box-shadow: none; + } + } + + table { + border-collapse: collapse; + width: 100%; + + &.minimal-table { + border-collapse: collapse; + border: 1px solid $border-color; + + td { + padding: 8px; + } + + td:first-child { + width: 1%; + white-space: nowrap; + } + + td:not(:first-child) { + font-family: $monospace; + } + } + + &.errorReporting { + tr { + border: 1px solid var(--newtab-background-color-secondary); + } + + td { + padding: 4px; + + &[rowspan] { + border: 1px solid var(--newtab-background-color-secondary); + } + } + } + } + + .sourceLabel { + background: var(--newtab-background-color-secondary); + padding: 2px 5px; + border-radius: 3px; + + &.isDisabled { + background: $email-input-invalid; + color: var(--newtab-status-error); + } + } + + .message-item { + &:first-child td { + border-top: 1px solid $border-color; + } + + td { + vertical-align: top; + padding: 8px; + border-bottom: 1px solid $border-color; + + &.min { + width: 1%; + white-space: nowrap; + } + + &.message-summary { + width: 60%; + } + + &.button-column { + width: 15%; + } + + &:first-child { + border-inline-start: 1px solid $border-color; + } + + &:last-child { + border-inline-end: 1px solid $border-color; + } + } + + &.blocked { + .message-id, + .message-summary { + opacity: 0.5; + } + + .message-id { + opacity: 0.5; + } + } + + .message-id { + font-family: $monospace; + font-size: 12px; + } + } + + .providerUrl { + font-size: 12px; + } + + pre { + background: var(--newtab-background-color-secondary); + margin: 0; + padding: 8px; + font-size: 12px; + max-width: 750px; + overflow: auto; + font-family: $monospace; + } + + .errorState { + border: $input-error-border; + } + + .helpLink { + padding: 10px; + display: flex; + background: $black-10; + border-radius: 3px; + align-items: center; + + a { + text-decoration: underline; + } + + .icon { + min-width: 18px; + min-height: 18px; + } + } + + .ds-component { + margin-bottom: 20px; + } + + .modalOverlayInner { + height: 80%; + } + + .clearButton { + border: 0; + padding: 4px; + border-radius: 4px; + display: flex; + + &:hover { + background: var(--newtab-element-hover-color); + } + } + + .collapsed { + display: none; + } + + .icon { + display: inline-table; + cursor: pointer; + width: 18px; + height: 18px; + } + + .button { + &:disabled, + &:disabled:active { + opacity: 0.5; + cursor: unset; + box-shadow: none; + } + } + + .impressions-section { + display: flex; + flex-direction: column; + gap: 16px; + + .impressions-item { + display: flex; + flex-flow: column nowrap; + padding: 8px; + border: 1px solid $border-color; + border-radius: 5px; + + .impressions-inner-box { + display: flex; + flex-flow: row nowrap; + gap: 8px; + } + + .impressions-category { + font-size: 1.15em; + white-space: nowrap; + flex-grow: 0.1; + } + + .impressions-buttons { + display: flex; + flex-direction: column; + gap: 8px; + + button { + margin: 0; + } + } + + .impressions-editor { + display: flex; + flex-grow: 1.5; + + .general-textarea { + width: auto; + flex-grow: 1; + } + } + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/SimpleHashRouter.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/SimpleHashRouter.jsx new file mode 100644 index 0000000000..9c3fd8579c --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/SimpleHashRouter.jsx @@ -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/. */ + +import React from "react"; + +export class SimpleHashRouter extends React.PureComponent { + constructor(props) { + super(props); + this.onHashChange = this.onHashChange.bind(this); + this.state = { hash: global.location.hash }; + } + + onHashChange() { + this.setState({ hash: global.location.hash }); + } + + componentWillMount() { + global.addEventListener("hashchange", this.onHashChange); + } + + componentWillUnmount() { + global.removeEventListener("hashchange", this.onHashChange); + } + + render() { + const [, ...routes] = this.state.hash.split("-"); + return React.cloneElement(this.props.children, { + location: { + hash: this.state.hash, + routes, + }, + }); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx new file mode 100644 index 0000000000..dff122b366 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx @@ -0,0 +1,386 @@ +/* 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 { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid"; +import { CollectionCardGrid } from "content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid"; +import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection"; +import { connect } from "react-redux"; +import { DSMessage } from "content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage"; +import { DSPrivacyModal } from "content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal"; +import { DSSignup } from "content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup"; +import { DSTextPromo } from "content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo"; +import { Highlights } from "content-src/components/DiscoveryStreamComponents/Highlights/Highlights"; +import { HorizontalRule } from "content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule"; +import { Navigation } from "content-src/components/DiscoveryStreamComponents/Navigation/Navigation"; +import { PrivacyLink } from "content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink"; +import React from "react"; +import { SectionTitle } from "content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle"; +import { selectLayoutRender } from "content-src/lib/selectLayoutRender"; +import { TopSites } from "content-src/components/TopSites/TopSites"; + +const ALLOWED_CSS_URL_PREFIXES = [ + "chrome://", + "resource://", + "https://img-getpocket.cdn.mozilla.net/", +]; +const DUMMY_CSS_SELECTOR = "DUMMY#CSS.SELECTOR"; + +/** + * Validate a CSS declaration. The values are assumed to be normalized by CSSOM. + */ +export function isAllowedCSS(property, value) { + // Bug 1454823: INTERNAL properties, e.g., -moz-context-properties, are + // exposed but their values aren't resulting in getting nothing. Fortunately, + // we don't care about validating the values of the current set of properties. + if (value === undefined) { + return true; + } + + // Make sure all urls are of the allowed protocols/prefixes + const urls = value.match(/url\("[^"]+"\)/g); + return ( + !urls || + urls.every(url => + ALLOWED_CSS_URL_PREFIXES.some(prefix => url.slice(5).startsWith(prefix)) + ) + ); +} + +export class _DiscoveryStreamBase extends React.PureComponent { + constructor(props) { + super(props); + this.onStyleMount = this.onStyleMount.bind(this); + } + + onStyleMount(style) { + // Unmounting style gets rid of old styles, so nothing else to do + if (!style) { + return; + } + + const { sheet } = style; + const styles = JSON.parse(style.dataset.styles); + styles.forEach((row, rowIndex) => { + row.forEach((component, componentIndex) => { + // Nothing to do without optional styles overrides + if (!component) { + return; + } + + Object.entries(component).forEach(([selectors, declarations]) => { + // Start with a dummy rule to validate declarations and selectors + sheet.insertRule(`${DUMMY_CSS_SELECTOR} {}`); + const [rule] = sheet.cssRules; + + // Validate declarations and remove any offenders. CSSOM silently + // discards invalid entries, so here we apply extra restrictions. + rule.style = declarations; + [...rule.style].forEach(property => { + const value = rule.style[property]; + if (!isAllowedCSS(property, value)) { + console.error(`Bad CSS declaration ${property}: ${value}`); + rule.style.removeProperty(property); + } + }); + + // Set the actual desired selectors scoped to the component + const prefix = `.ds-layout > .ds-column:nth-child(${ + rowIndex + 1 + }) .ds-column-grid > :nth-child(${componentIndex + 1})`; + // NB: Splitting on "," doesn't work with strings with commas, but + // we're okay with not supporting those selectors + rule.selectorText = selectors + .split(",") + .map( + selector => + prefix + + // Assume :pseudo-classes are for component instead of descendant + (selector[0] === ":" ? "" : " ") + + selector + ) + .join(","); + + // CSSOM silently ignores bad selectors, so we'll be noisy instead + if (rule.selectorText === DUMMY_CSS_SELECTOR) { + console.error(`Bad CSS selector ${selectors}`); + } + }); + }); + }); + } + + renderComponent(component, embedWidth) { + switch (component.type) { + case "Highlights": + return ; + case "TopSites": + return ( +
    + +
    + ); + case "TextPromo": + return ( + + ); + case "Signup": + return ( + + ); + case "Message": + return ( + + ); + case "SectionTitle": + return ; + case "Navigation": + return ( + + ); + case "CollectionCardGrid": + const { DiscoveryStream } = this.props; + return ( + + ); + case "CardGrid": + return ( + + ); + case "HorizontalRule": + return ; + case "PrivacyLink": + return ; + default: + return
    {component.type}
    ; + } + } + + renderStyles(styles) { + // Use json string as both the key and styles to render so React knows when + // to unmount and mount a new instance for new styles. + const json = JSON.stringify(styles); + return + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/components/newtab/data/content/assets/glyph-cfr-feature-16.svg b/browser/components/newtab/data/content/assets/glyph-cfr-feature-16.svg new file mode 100644 index 0000000000..871b48ca45 --- /dev/null +++ b/browser/components/newtab/data/content/assets/glyph-cfr-feature-16.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/newtab/data/content/assets/glyph-mail-16.svg b/browser/components/newtab/data/content/assets/glyph-mail-16.svg new file mode 100644 index 0000000000..8c211c5567 --- /dev/null +++ b/browser/components/newtab/data/content/assets/glyph-mail-16.svg @@ -0,0 +1,4 @@ + + diff --git a/browser/components/newtab/data/content/assets/glyph-maximize-16.svg b/browser/components/newtab/data/content/assets/glyph-maximize-16.svg new file mode 100644 index 0000000000..2f45557cfa --- /dev/null +++ b/browser/components/newtab/data/content/assets/glyph-maximize-16.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/newtab/data/content/assets/glyph-minimize-16.svg b/browser/components/newtab/data/content/assets/glyph-minimize-16.svg new file mode 100644 index 0000000000..6bc93fa5e0 --- /dev/null +++ b/browser/components/newtab/data/content/assets/glyph-minimize-16.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/newtab/data/content/assets/glyph-modal-delete-20.svg b/browser/components/newtab/data/content/assets/glyph-modal-delete-20.svg new file mode 100644 index 0000000000..592f9569a1 --- /dev/null +++ b/browser/components/newtab/data/content/assets/glyph-modal-delete-20.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/browser/components/newtab/data/content/assets/glyph-newWindow-16.svg b/browser/components/newtab/data/content/assets/glyph-newWindow-16.svg new file mode 100644 index 0000000000..0b09bfde5a --- /dev/null +++ b/browser/components/newtab/data/content/assets/glyph-newWindow-16.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/newtab/data/content/assets/glyph-open-file-16.svg b/browser/components/newtab/data/content/assets/glyph-open-file-16.svg new file mode 100644 index 0000000000..a2a23f09eb --- /dev/null +++ b/browser/components/newtab/data/content/assets/glyph-open-file-16.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/newtab/data/content/assets/glyph-pin-16.svg b/browser/components/newtab/data/content/assets/glyph-pin-16.svg new file mode 100644 index 0000000000..c951bc1c9d --- /dev/null +++ b/browser/components/newtab/data/content/assets/glyph-pin-16.svg @@ -0,0 +1,6 @@ + + + + diff --git a/browser/components/newtab/data/content/assets/glyph-pocket-archive-16.svg b/browser/components/newtab/data/content/assets/glyph-pocket-archive-16.svg new file mode 100644 index 0000000000..10cf13c4d2 --- /dev/null +++ b/browser/components/newtab/data/content/assets/glyph-pocket-archive-16.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/newtab/data/content/assets/glyph-pocket-delete-16.svg b/browser/components/newtab/data/content/assets/glyph-pocket-delete-16.svg new file mode 100644 index 0000000000..95bb4d3edb --- /dev/null +++ b/browser/components/newtab/data/content/assets/glyph-pocket-delete-16.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/newtab/data/content/assets/glyph-unpin-16.svg b/browser/components/newtab/data/content/assets/glyph-unpin-16.svg new file mode 100644 index 0000000000..2352839340 --- /dev/null +++ b/browser/components/newtab/data/content/assets/glyph-unpin-16.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/newtab/data/content/assets/glyph-webextension-16.svg b/browser/components/newtab/data/content/assets/glyph-webextension-16.svg new file mode 100644 index 0000000000..b29ea04bf2 --- /dev/null +++ b/browser/components/newtab/data/content/assets/glyph-webextension-16.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/newtab/data/content/assets/icon-removed-bookmark.svg b/browser/components/newtab/data/content/assets/icon-removed-bookmark.svg new file mode 100644 index 0000000000..e222da3bfd --- /dev/null +++ b/browser/components/newtab/data/content/assets/icon-removed-bookmark.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/newtab/data/content/assets/pocket-onboarding.avif b/browser/components/newtab/data/content/assets/pocket-onboarding.avif new file mode 100644 index 0000000000..9bd1b3e524 Binary files /dev/null and b/browser/components/newtab/data/content/assets/pocket-onboarding.avif differ diff --git a/browser/components/newtab/data/content/assets/pocket-onboarding@2x.avif b/browser/components/newtab/data/content/assets/pocket-onboarding@2x.avif new file mode 100644 index 0000000000..6817e85f2f Binary files /dev/null and b/browser/components/newtab/data/content/assets/pocket-onboarding@2x.avif differ diff --git a/browser/components/newtab/data/content/assets/pocket-swoosh.svg b/browser/components/newtab/data/content/assets/pocket-swoosh.svg new file mode 100644 index 0000000000..0d81c7c453 --- /dev/null +++ b/browser/components/newtab/data/content/assets/pocket-swoosh.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/browser/components/newtab/data/content/assets/remote/mountain.svg b/browser/components/newtab/data/content/assets/remote/mountain.svg new file mode 100644 index 0000000000..4511148820 --- /dev/null +++ b/browser/components/newtab/data/content/assets/remote/mountain.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/browser/components/newtab/data/content/assets/remote/umbrella.png b/browser/components/newtab/data/content/assets/remote/umbrella.png new file mode 100644 index 0000000000..3488d135c6 Binary files /dev/null and b/browser/components/newtab/data/content/assets/remote/umbrella.png differ diff --git a/browser/components/newtab/data/content/assets/spinner.svg b/browser/components/newtab/data/content/assets/spinner.svg new file mode 100644 index 0000000000..2964a31731 --- /dev/null +++ b/browser/components/newtab/data/content/assets/spinner.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/browser/components/newtab/data/content/newtab-render.js b/browser/components/newtab/data/content/newtab-render.js new file mode 100644 index 0000000000..4fd46fad03 --- /dev/null +++ b/browser/components/newtab/data/content/newtab-render.js @@ -0,0 +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/. */ +"use strict"; + +// exported by activity-stream.bundle.js +if (window.__FROM_STARTUP_CACHE__) { + window.NewtabRenderUtils.renderCache(window.__STARTUP_STATE__); +} else { + window.NewtabRenderUtils.renderWithoutState(); +} diff --git a/browser/components/newtab/data/content/tippytop/favicons/adidas.png b/browser/components/newtab/data/content/tippytop/favicons/adidas.png new file mode 100644 index 0000000000..fd7123958c Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/adidas.png differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/aliexpress-com.ico b/browser/components/newtab/data/content/tippytop/favicons/aliexpress-com.ico new file mode 100644 index 0000000000..99b86e13aa Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/aliexpress-com.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/allegro-pl.ico b/browser/components/newtab/data/content/tippytop/favicons/allegro-pl.ico new file mode 100644 index 0000000000..42b4f90149 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/allegro-pl.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/amazon.ico b/browser/components/newtab/data/content/tippytop/favicons/amazon.ico new file mode 100644 index 0000000000..1c39eaf8fe Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/amazon.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/avito-ru.ico b/browser/components/newtab/data/content/tippytop/favicons/avito-ru.ico new file mode 100644 index 0000000000..c41847b27a Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/avito-ru.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/baidu-com.png b/browser/components/newtab/data/content/tippytop/favicons/baidu-com.png new file mode 100644 index 0000000000..e63737eb30 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/baidu-com.png differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/bbc-uk.ico b/browser/components/newtab/data/content/tippytop/favicons/bbc-uk.ico new file mode 100644 index 0000000000..8f62b07af8 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/bbc-uk.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/bing-com.ico b/browser/components/newtab/data/content/tippytop/favicons/bing-com.ico new file mode 100644 index 0000000000..fdc021cfeb Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/bing-com.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/ctrip-com.ico b/browser/components/newtab/data/content/tippytop/favicons/ctrip-com.ico new file mode 100644 index 0000000000..fa44291d84 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/ctrip-com.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/duckduckgo-com.ico b/browser/components/newtab/data/content/tippytop/favicons/duckduckgo-com.ico new file mode 100644 index 0000000000..3ad20825c1 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/duckduckgo-com.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/ebay.ico b/browser/components/newtab/data/content/tippytop/favicons/ebay.ico new file mode 100644 index 0000000000..3af7a36484 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/ebay.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/etsy.ico b/browser/components/newtab/data/content/tippytop/favicons/etsy.ico new file mode 100644 index 0000000000..a94f3efd4f Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/etsy.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/facebook-com.ico b/browser/components/newtab/data/content/tippytop/favicons/facebook-com.ico new file mode 100644 index 0000000000..8ce319b8f7 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/facebook-com.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/geico.png b/browser/components/newtab/data/content/tippytop/favicons/geico.png new file mode 100644 index 0000000000..3f61497dd8 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/geico.png differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/google-com.ico b/browser/components/newtab/data/content/tippytop/favicons/google-com.ico new file mode 100644 index 0000000000..82339b3b1d Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/google-com.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/hrblock.ico b/browser/components/newtab/data/content/tippytop/favicons/hrblock.ico new file mode 100644 index 0000000000..e0d7be35e0 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/hrblock.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/ifeng-com.ico b/browser/components/newtab/data/content/tippytop/favicons/ifeng-com.ico new file mode 100644 index 0000000000..b0003e058f Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/ifeng-com.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/iqiyi-com.ico b/browser/components/newtab/data/content/tippytop/favicons/iqiyi-com.ico new file mode 100644 index 0000000000..4b179bf4d5 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/iqiyi-com.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/leboncoin-fr.png b/browser/components/newtab/data/content/tippytop/favicons/leboncoin-fr.png new file mode 100644 index 0000000000..e23e2a34b0 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/leboncoin-fr.png differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/nike.ico b/browser/components/newtab/data/content/tippytop/favicons/nike.ico new file mode 100644 index 0000000000..7788d580af Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/nike.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/ok-ru.ico b/browser/components/newtab/data/content/tippytop/favicons/ok-ru.ico new file mode 100644 index 0000000000..7db8914287 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/ok-ru.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/olx-pl.ico b/browser/components/newtab/data/content/tippytop/favicons/olx-pl.ico new file mode 100644 index 0000000000..b2a28638f8 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/olx-pl.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/reddit-com.png b/browser/components/newtab/data/content/tippytop/favicons/reddit-com.png new file mode 100644 index 0000000000..3c09931835 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/reddit-com.png differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/samsung.ico b/browser/components/newtab/data/content/tippytop/favicons/samsung.ico new file mode 100644 index 0000000000..eb8c814256 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/samsung.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/turbotax.png b/browser/components/newtab/data/content/tippytop/favicons/turbotax.png new file mode 100644 index 0000000000..c1d52f99fb Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/turbotax.png differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/twitter-com.ico b/browser/components/newtab/data/content/tippytop/favicons/twitter-com.ico new file mode 100644 index 0000000000..e5aaff4379 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/twitter-com.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/vk-com.ico b/browser/components/newtab/data/content/tippytop/favicons/vk-com.ico new file mode 100644 index 0000000000..0066072c39 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/vk-com.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/vodafone.png b/browser/components/newtab/data/content/tippytop/favicons/vodafone.png new file mode 100644 index 0000000000..1a4ba0089e Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/vodafone.png differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/weibo-com.ico b/browser/components/newtab/data/content/tippytop/favicons/weibo-com.ico new file mode 100644 index 0000000000..11a88045ec Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/weibo-com.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/wikipedia-org.ico b/browser/components/newtab/data/content/tippytop/favicons/wikipedia-org.ico new file mode 100644 index 0000000000..e70021849b Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/wikipedia-org.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/wix.ico b/browser/components/newtab/data/content/tippytop/favicons/wix.ico new file mode 100644 index 0000000000..cabcb650a7 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/wix.ico differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/wykop-pl.png b/browser/components/newtab/data/content/tippytop/favicons/wykop-pl.png new file mode 100644 index 0000000000..5aae5b17f2 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/wykop-pl.png differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/yandex-com.png b/browser/components/newtab/data/content/tippytop/favicons/yandex-com.png new file mode 100644 index 0000000000..d1c3f3f8b1 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/yandex-com.png differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/yandex-ru.png b/browser/components/newtab/data/content/tippytop/favicons/yandex-ru.png new file mode 100644 index 0000000000..eb187398c7 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/yandex-ru.png differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/youtube-com.png b/browser/components/newtab/data/content/tippytop/favicons/youtube-com.png new file mode 100644 index 0000000000..b0c05d0716 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/youtube-com.png differ diff --git a/browser/components/newtab/data/content/tippytop/favicons/zhihu-com.ico b/browser/components/newtab/data/content/tippytop/favicons/zhihu-com.ico new file mode 100644 index 0000000000..c83d0e6d86 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/favicons/zhihu-com.ico differ diff --git a/browser/components/newtab/data/content/tippytop/images/adidas@2x.png b/browser/components/newtab/data/content/tippytop/images/adidas@2x.png new file mode 100644 index 0000000000..f07c17a9a8 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/adidas@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/aliexpress-com@2x.png b/browser/components/newtab/data/content/tippytop/images/aliexpress-com@2x.png new file mode 100644 index 0000000000..76fac2e935 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/aliexpress-com@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/allegro-pl@2x.png b/browser/components/newtab/data/content/tippytop/images/allegro-pl@2x.png new file mode 100644 index 0000000000..7aa6ffd4b3 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/allegro-pl@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/amazon@2x.png b/browser/components/newtab/data/content/tippytop/images/amazon@2x.png new file mode 100644 index 0000000000..fb20eea921 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/amazon@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/avito-ru@2x.png b/browser/components/newtab/data/content/tippytop/images/avito-ru@2x.png new file mode 100644 index 0000000000..9ba32a8d96 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/avito-ru@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/baidu-com@2x.png b/browser/components/newtab/data/content/tippytop/images/baidu-com@2x.png new file mode 100644 index 0000000000..b7662dd21f Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/baidu-com@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/bbc-uk@2x.png b/browser/components/newtab/data/content/tippytop/images/bbc-uk@2x.png new file mode 100644 index 0000000000..e019ac3de6 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/bbc-uk@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/bing-com@2x.svg b/browser/components/newtab/data/content/tippytop/images/bing-com@2x.svg new file mode 100644 index 0000000000..1afdee989a --- /dev/null +++ b/browser/components/newtab/data/content/tippytop/images/bing-com@2x.svg @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/components/newtab/data/content/tippytop/images/ctrip-com@2x.png b/browser/components/newtab/data/content/tippytop/images/ctrip-com@2x.png new file mode 100644 index 0000000000..76a81da5af Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/ctrip-com@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/duckduckgo-com@2x.svg b/browser/components/newtab/data/content/tippytop/images/duckduckgo-com@2x.svg new file mode 100644 index 0000000000..a28cc833cd --- /dev/null +++ b/browser/components/newtab/data/content/tippytop/images/duckduckgo-com@2x.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/browser/components/newtab/data/content/tippytop/images/ebay@2x.png b/browser/components/newtab/data/content/tippytop/images/ebay@2x.png new file mode 100644 index 0000000000..744e2442ff Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/ebay@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/etsy@2x.jpg b/browser/components/newtab/data/content/tippytop/images/etsy@2x.jpg new file mode 100644 index 0000000000..4bd477ca6a Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/etsy@2x.jpg differ diff --git a/browser/components/newtab/data/content/tippytop/images/facebook-com@2x.png b/browser/components/newtab/data/content/tippytop/images/facebook-com@2x.png new file mode 100644 index 0000000000..8827157878 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/facebook-com@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/geico@2x.jpg b/browser/components/newtab/data/content/tippytop/images/geico@2x.jpg new file mode 100644 index 0000000000..938b7948e9 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/geico@2x.jpg differ diff --git a/browser/components/newtab/data/content/tippytop/images/google-com@2x.png b/browser/components/newtab/data/content/tippytop/images/google-com@2x.png new file mode 100644 index 0000000000..263bd973b1 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/google-com@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/hrblock@2x.png b/browser/components/newtab/data/content/tippytop/images/hrblock@2x.png new file mode 100644 index 0000000000..ba66c46b72 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/hrblock@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/ifeng-com@2x.png b/browser/components/newtab/data/content/tippytop/images/ifeng-com@2x.png new file mode 100644 index 0000000000..f7099f334e Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/ifeng-com@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/iqiyi-com@2x.png b/browser/components/newtab/data/content/tippytop/images/iqiyi-com@2x.png new file mode 100644 index 0000000000..901d536ad9 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/iqiyi-com@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/leboncoin-fr@2x.png b/browser/components/newtab/data/content/tippytop/images/leboncoin-fr@2x.png new file mode 100644 index 0000000000..af293fa8c3 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/leboncoin-fr@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/nike@2x.jpg b/browser/components/newtab/data/content/tippytop/images/nike@2x.jpg new file mode 100644 index 0000000000..ac5d639d12 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/nike@2x.jpg differ diff --git a/browser/components/newtab/data/content/tippytop/images/ok-ru@2x.png b/browser/components/newtab/data/content/tippytop/images/ok-ru@2x.png new file mode 100644 index 0000000000..c771bf3ad9 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/ok-ru@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/olx-pl@2x.png b/browser/components/newtab/data/content/tippytop/images/olx-pl@2x.png new file mode 100644 index 0000000000..964cd2df10 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/olx-pl@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/reddit-com@2x.png b/browser/components/newtab/data/content/tippytop/images/reddit-com@2x.png new file mode 100644 index 0000000000..3b8833d6c6 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/reddit-com@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/samsung@2x.jpg b/browser/components/newtab/data/content/tippytop/images/samsung@2x.jpg new file mode 100644 index 0000000000..dec2346e9f Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/samsung@2x.jpg differ diff --git a/browser/components/newtab/data/content/tippytop/images/turbotax@2x.jpg b/browser/components/newtab/data/content/tippytop/images/turbotax@2x.jpg new file mode 100644 index 0000000000..625703943f Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/turbotax@2x.jpg differ diff --git a/browser/components/newtab/data/content/tippytop/images/twitter-com@2x.png b/browser/components/newtab/data/content/tippytop/images/twitter-com@2x.png new file mode 100644 index 0000000000..e5835ff98a Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/twitter-com@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/vk-com@2x.png b/browser/components/newtab/data/content/tippytop/images/vk-com@2x.png new file mode 100644 index 0000000000..b4c14412a4 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/vk-com@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/vodafone@2x.jpg b/browser/components/newtab/data/content/tippytop/images/vodafone@2x.jpg new file mode 100644 index 0000000000..2597063b47 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/vodafone@2x.jpg differ diff --git a/browser/components/newtab/data/content/tippytop/images/weibo-com@2x.png b/browser/components/newtab/data/content/tippytop/images/weibo-com@2x.png new file mode 100644 index 0000000000..e047eaac87 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/weibo-com@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/wikipedia-org@2x.png b/browser/components/newtab/data/content/tippytop/images/wikipedia-org@2x.png new file mode 100644 index 0000000000..53cf1af1c6 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/wikipedia-org@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/wix@2x.jpg b/browser/components/newtab/data/content/tippytop/images/wix@2x.jpg new file mode 100644 index 0000000000..473caf38a3 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/wix@2x.jpg differ diff --git a/browser/components/newtab/data/content/tippytop/images/wykop-pl@2x.png b/browser/components/newtab/data/content/tippytop/images/wykop-pl@2x.png new file mode 100644 index 0000000000..fbde175696 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/wykop-pl@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/yandex-com@2x.png b/browser/components/newtab/data/content/tippytop/images/yandex-com@2x.png new file mode 100644 index 0000000000..ebea409306 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/yandex-com@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/yandex-ru@2x.png b/browser/components/newtab/data/content/tippytop/images/yandex-ru@2x.png new file mode 100644 index 0000000000..3d4ffd15c3 Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/yandex-ru@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/youtube-com@2x.png b/browser/components/newtab/data/content/tippytop/images/youtube-com@2x.png new file mode 100644 index 0000000000..6f1d7a1d7b Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/youtube-com@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/images/zhihu-com@2x.png b/browser/components/newtab/data/content/tippytop/images/zhihu-com@2x.png new file mode 100644 index 0000000000..a1a9db845e Binary files /dev/null and b/browser/components/newtab/data/content/tippytop/images/zhihu-com@2x.png differ diff --git a/browser/components/newtab/data/content/tippytop/top_sites.json b/browser/components/newtab/data/content/tippytop/top_sites.json new file mode 100644 index 0000000000..82764a0527 --- /dev/null +++ b/browser/components/newtab/data/content/tippytop/top_sites.json @@ -0,0 +1,182 @@ +[ + { + "domains": ["adidas.co.uk", "adidas.de", "adidas.fr"], + "image_url": "images/adidas@2x.png", + "favicon_url": "favicons/adidas.png" + }, + { + "domains": ["aliexpress.com"], + "image_url": "images/aliexpress-com@2x.png", + "favicon_url": "favicons/aliexpress-com.ico" + }, + { + "domains": ["allegro.pl"], + "image_url": "images/allegro-pl@2x.png", + "favicon_url": "favicons/allegro-pl.ico" + }, + { + "domains": ["amazon.ae", "amazon.ca", "amazon.cn", "amazon.co.jp", "amazon.co.uk", "amazon.com", "amazon.com.au", "amazon.com.br", "amazon.com.mx", "amazon.de", "amazon.es", "amazon.fr", "amazon.in", "amazon.it", "amazon.nl", "amazon.sa", "amazon.se", "amazon.sg", "amazon.com.tr"], + "image_url": "images/amazon@2x.png", + "favicon_url": "favicons/amazon.ico" + }, + { + "domains": ["avito.ru"], + "image_url": "images/avito-ru@2x.png", + "favicon_url": "favicons/avito-ru.ico" + }, + { + "domains": ["baidu.com"], + "image_url": "images/baidu-com@2x.png", + "favicon_url": "favicons/baidu-com.png" + }, + { + "domains": ["bbc.co.uk"], + "image_url": "images/bbc-uk@2x.png", + "favicon_url": "favicons/bbc-uk.ico" + }, + { + "domains": ["bing.com"], + "image_url": "images/bing-com@2x.svg", + "favicon_url": "favicons/bing-com.ico" + }, + { + "domains": ["ctrip.com"], + "image_url": "images/ctrip-com@2x.png", + "favicon_url": "favicons/ctrip-com.ico" + }, + { + "domains": ["duckduckgo.com"], + "image_url": "images/duckduckgo-com@2x.svg", + "favicon_url": "favicons/duckduckgo-com.ico" + }, + { + "domains": ["mx.ebay.com", "benl.ebay.be", "befr.ebay.be", "ebay.ca", "ebay.ch", "ebay.co.jp", "ebay.co.uk", "ebay.com", "ebay.com.au", "ebay.de", "ebay.es", "ebay.fr", "ebay.ie", "ebay.in", "ebay.it", "ebay.nl"], + "image_url": "images/ebay@2x.png", + "favicon_url": "favicons/ebay.ico" + }, + { + "domains": ["etsy.com"], + "image_url": "images/etsy@2x.jpg", + "favicon_url": "favicons/etsy.ico" + }, + { + "domains": ["facebook.com"], + "image_url": "images/facebook-com@2x.png", + "favicon_url": "favicons/facebook-com.ico" + }, + { + "domains": ["geico.com"], + "image_url": "images/geico@2x.jpg", + "favicon_url": "favicons/geico.png" + }, + { + "domains": ["google.com"], + "image_url": "images/google-com@2x.png", + "favicon_url": "favicons/google-com.ico" + }, + { + "domains": ["hrblock.com"], + "image_url": "images/hrblock@2x.png", + "favicon_url": "favicons/hrblock.ico" + }, + { + "domains": ["ifeng.com"], + "image_url": "images/ifeng-com@2x.png", + "favicon_url": "favicons/ifeng-com.ico" + }, + { + "domains": ["iqiyi.com"], + "image_url": "images/iqiyi-com@2x.png", + "favicon_url": "favicons/iqiyi-com.ico" + }, + { + "domains": ["leboncoin.fr"], + "image_url": "images/leboncoin-fr@2x.png", + "favicon_url": "favicons/leboncoin-fr.png" + }, + { + "domains": ["nike.com"], + "image_url": "images/nike@2x.jpg", + "favicon_url": "favicons/nike.ico" + }, + { + "domains": ["ok.ru"], + "image_url": "images/ok-ru@2x.png", + "favicon_url": "favicons/ok-ru.ico" + }, + { + "domains": ["olx.pl"], + "image_url": "images/olx-pl@2x.png", + "favicon_url": "favicons/olx-pl.ico" + }, + { + "domains": ["reddit.com"], + "image_url": "images/reddit-com@2x.png", + "favicon_url": "favicons/reddit-com.png" + }, + { + "domains": ["samsung.com"], + "image_url": "images/samsung@2x.jpg", + "favicon_url": "favicons/samsung.ico" + }, + { + "domains": ["turbotax.intuit.com"], + "image_url": "images/turbotax@2x.jpg", + "favicon_url": "favicons/turbotax.png" + }, + { + "domains": ["twitter.com"], + "image_url": "images/twitter-com@2x.png", + "favicon_url": "favicons/twitter-com.ico" + }, + { + "domains": ["vk.com"], + "image_url": "images/vk-com@2x.png", + "favicon_url": "favicons/vk-com.ico" + }, + { + "domains": ["vodafone.co.uk"], + "image_url": "images/vodafone@2x.jpg", + "favicon_url": "favicons/vodafone.png" + }, + { + "domains": ["weibo.com"], + "image_url": "images/weibo-com@2x.png", + "favicon_url": "favicons/weibo-com.ico" + }, + { + "domains": ["wikipedia.org"], + "image_url": "images/wikipedia-org@2x.png", + "favicon_url": "favicons/wikipedia-org.ico" + }, + { + "domains": ["wix.com"], + "image_url": "images/wix@2x.jpg", + "favicon_url": "favicons/wix.ico" + }, + { + "domains": ["wykop.pl"], + "image_url": "images/wykop-pl@2x.png", + "favicon_url": "favicons/wykop-pl.png" + }, + { + "domains": ["yandex.com", "yandex.com.tr"], + "image_url": "images/yandex-com@2x.png", + "favicon_url": "favicons/yandex-com.png" + }, + { + "domains": ["yandex.by", "yandex.kz", "yandex.ru", "yandex.ua", "yandex.uz"], + "image_url": "images/yandex-ru@2x.png", + "favicon_url": "favicons/yandex-ru.png" + }, + { + "domains": ["youtube.com"], + "image_url": "images/youtube-com@2x.png", + "favicon_url": "favicons/youtube-com.png" + }, + { + "domains": ["zhihu.com"], + "image_url": "images/zhihu-com@2x.png", + "favicon_url": "favicons/zhihu-com.ico" + } +] diff --git a/browser/components/newtab/docs/index.rst b/browser/components/newtab/docs/index.rst new file mode 100644 index 0000000000..48cf01c331 --- /dev/null +++ b/browser/components/newtab/docs/index.rst @@ -0,0 +1,119 @@ +====================== +Firefox Home (New Tab) +====================== + +All files related to Firefox Home, which includes content that appears on ``about:home`` and +``about:newtab``, can be found in the ``browser/components/newtab`` directory. +Some of these source files (such as ``.js``, ``.jsx``, and ``.scss``) require an additional build step. +We are working on migrating this to work with ``mach``, but in the meantime, please +follow the following steps if you need to make changes in this directory: + +For ``.jsm`` or ``.sys.mjs`` files (system modules) +--------------------------------------------------- + +No build step is necessary. Use ``mach`` and run mochitests according to your regular Firefox workflow. + +For ``.js``, ``.jsx``, ``.scss``, or ``.css`` files +--------------------------------------------------- + +Prerequisites +````````````` + +You will need the following: + +- Node.js 10+ (On Mac, the best way to install Node.js is to use the install link on the `Node.js homepage`_) +- npm (packaged with Node.js) + +To install dependencies, run the following from the root of the mozilla-central repository. +(Using ``mach`` to call ``npm`` and ``node`` commands will ensure you're using the correct versions of Node and npm.) + +.. code-block:: shell + + (cd browser/components/newtab && ../../../mach npm install) + + +Which files should you edit? +```````````````````````````` + +You should not make changes to ``.js`` or ``.css`` files in ``browser/components/newtab/css`` or +``browser/components/newtab/data`` directory. Instead, you should edit the ``.jsx``, ``.js``, and ``.scss`` source files +in ``browser/components/newtab/content-src`` directory. These files will be compiled into the ``.js`` and ``.css`` files. + + +Building assets and running Firefox +----------------------------------- + +To build assets and run Firefox, run the following from the root of the mozilla-central repository: + +.. code-block:: shell + + ./mach npm run bundle --prefix=browser/components/newtab && ./mach build && ./mach run + +Continuous development / debugging +---------------------------------- +Running ``./mach npm run watchmc --prefix=browser/components/newtab`` will start a process that watches files in +``activity-stream`` and rebuilds the bundled files when JS or CSS files change. + +**IMPORTANT NOTE**: This task will add inline source maps to help with debugging, which changes the memory footprint. +Do not use the ``watchmc`` task for profiling or performance testing! + +Running tests +------------- +The majority of New Tab / Messaging unit tests are written using +`mocha `_, and other errors that may show up there are +`SCSS `_ issues flagged by +`stylelint `_. These things are all run using +``npm test`` under the ``newtab`` slug in Treeherder/Try, so if that slug turns +red, these tests are what is failing. To execute them, do this: + +.. code-block:: shell + + ./mach npm test --prefix=browser/components/newtab + +These tests are not currently run by ``mach test``, but there's a +`task filed to fix that `_. + +Windows isn't currently supported by ``npm test`` +(`path/invocation difference `_). +To run newtab specific tests that aren't covered by ``mach lint`` and +``mach test``: + +.. code-block:: shell + + ./mach npm run lint:stylelint --prefix=browser/components/newtab + ./mach npm run testmc:build --prefix=browser/components/newtab + ./mach npm run testmc:unit --prefix=browser/components/newtab + +Mochitests and xpcshell tests run normally, using ``mach test``. + +Code Coverage +------------- +Our testing setup will run code coverage tools in addition to just the unit +tests. It will error out if the code coverage metrics don't meet certain thresholds. + +If you see any missing test coverage, you can inspect the coverage report by +running + +.. code-block:: shell + + ./mach npm test --prefix=browser/components/newtab && + ./mach npm run debugcoverage --prefix=browser/components/newtab + +Discovery Stream Developer tools +-------------------------------- + +You can access the developer tools for the Discovery Stream components of about:newtab by +visiting `about:config` and setting `browser.newtabpage.activity-stream.asrouter.devtoolsEnabled` +to `true`. + +Then, go to any `about:newtab` page and click on the wrench icon in the top-right corner. + +Detailed Docs +------------- +.. toctree:: + :titlesonly: + :glob: + + v2-system-addon/* + +.. _Node.js homepage: https://nodejs.org/ diff --git a/browser/components/newtab/docs/v2-system-addon/about_home_startup_cache.md b/browser/components/newtab/docs/v2-system-addon/about_home_startup_cache.md new file mode 100644 index 0000000000..0366bd3e29 --- /dev/null +++ b/browser/components/newtab/docs/v2-system-addon/about_home_startup_cache.md @@ -0,0 +1,86 @@ +# The `about:home` startup cache + +By default, a user's browser session starts with a single window and a single tab, pointed at about:home. This means that it's important to ensure that `about:home` loads as quickly as possible to provide a fast overall startup experience. + +`about:home`, which is functionally identical to `about:newtab`, is generated dynamically by calculating an appropriate state object in the parent process, and passing it down to a content process into the React library in order to render the final interactive page. This is problematic during the startup sequence, as calculating that initial state can be computationally expensive, and requires multiple reads from the disk. + +The `about:home` startup cache is an attempt to address this expense. It works by assuming that between browser sessions, `about:home` _usually_ doesn't need to change. + +## Components of the `about:home` startup cache mechanism + +There are 3 primary components to the cache mechanism: + +### The HTTP Cache + +The HTTP cache is normally used for caching webpages retrieved over the network, but seemed like the right fit for storage of the `about:home` cache as well. + +The HTTP cache is usually queried by the networking stack when browsing the web. The HTTP cache is, however, not typically queried when accessing `chrome://` or `resource://` URLs, so we have to do it ourselves, manually for the `about:home` case. This means giving `about:home` special capabilities for populating and reading from the HTTP cache. In order to avoid potential security issues, this requires that we sequester `about:home` / `about:newtab` in their own special content process. The "privileged about content process" exists for this purpose, and is also used for `about:logins` and `about:certificate`. + +The HTTP cache lives in the parent process, and so any read and write operations need to be initiated in the parent process. Thankfully, however, the HTTP cache accepts data using `nsIOutputStream` and serves it using `nsIInputStream`. We can send `nsIInputStream` over the message manager, and convert an `nsIInputStream` into an `nsIOutputStream`, so we have everything we need to efficiently communicate with the "privileged about content process" to save and retrieve page data. + +The official documentation for the HTTP cache [can be found here](https://firefox-source-docs.mozilla.org/networking/cache2/doc.html). + +### `AboutHomeStartupCache` + +This singleton component lives inside of `BrowserGlue` to avoid having to load yet another JSM out of the `omni.ja` file in the parent process during startup. + +`AboutHomeStartupCache` is responsible for feeding the "privileged about content process" with the `nsIInputStream`'s that it needs to present the initial `about:home` document. It is also responsible for populating the cache with updated versions of `about:home` that are sent by the "privileged about content process". + +Since accessing the HTTP cache is asynchronous, there is an opportunity for a race, where the cache can either be accessed and available before the initial `about:home` is requested, or after. To accommodate for both cases, the `AboutHomeStartupCache` constructs `nsIPipe` instances, which it sends down to the "privileged about content process" as soon as one launches. + +If the HTTP cache entry is already available when the process launches, and cached data is available, we connect the cache to the `nsIPipe`'s to stream the data down to the "privileged about content process". + +If the HTTP cache is not yet available, we hold references to those `nsIPipe` instances, and wait until the cache entry is available. Only then do we connect the cache entry to the `nsIPipe` instances to send the data down to the "privileged about content process". + +### `AboutNewTabService` + +The `AboutNewTabService` is used by the `AboutRedirector` in both the parent and content processes to determine how to handle attempts to load `about:home` and `about:newtab`. + +There are distinct versions of the `AboutNewTabService` - one for the parent process (`BaseAboutNewTabService`), and one for content processes (`AboutNewTabChildService`, which inherits from `BaseAboutNewTabService`). + +The `AboutRedirector`, when running inside of a "privileged about content process" knows to direct attempts to load `about:home` to `AboutNewTabChildService`'s `aboutHomeCacheChannel` method. This method is then responsible for choosing whether or not to return an `nsIChannel` for the cached document, or for the dynamically generated version of `about:home`. + +### `AboutHomeStartupCacheChild` + +This singleton component lives inside of the "privileged about content process", and is initialized as soon as the message is received from the parent that includes the `nsIInputStream`'s that will be used to potentially load from the cache. + +When the `AboutRedirector` in the "privileged about content process" notices that a request has been made to `about:home`, it asks `nsIAboutNewTabService` to return a new `nsIChannel` for that document. The `AboutNewTabChildService` then checks to see if the `AboutHomeStartupCacheChild` can return an `nsIChannel` for any cached content. + +If, at this point, nothing has been streamed from the parent, we fall back to loading the dynamic `about:home` document. This might occur if the cache doesn't exist yet, or if we were too slow to pull it off of the disk. Subsequent attempts to load `about:home` will bypass the cache and load the dynamic document instead. This is true even if the privileged about content process crashes and a new one is created. + +The `AboutHomeStartupCacheChild` will also be responsible for generating the cache periodically. Periodically, the `AboutNewTabService` will send down the most up-to-date state for `about:home` from the parent process, and then the `AboutHomeStartupCacheChild` will generate document markup using ReactDOMServer within a `ChromeWorker`. After that's generated, the "privileged about content process" will send up `nsIInputStream` instances for both the markup and the script for the initial page state. The `AboutHomeStartupCache` singleton inside of `BrowserGlue` is responsible for receiving those `nsIInputStream`'s and persisting them in the HTTP cache for the next start. + +## What is cached? + +Two things are cached: + +1. The raw HTML mark-up of `about:home`. +2. A small chunk of JavaScript that "hydrates" the markup through the React libraries, allowing the page to become interactive after painting. + +The JavaScript being cached cannot be put directly into the HTML mark-up as inline script due to the CSP of `about:home`, which does not allow inline scripting. Instead, we load a script from `about:home?jscache`. This goes through the same mechanism for retrieving the HTML document from the cache, but instead pulls down the cached script. + +If the HTML mark-up is cached, then we presume that the script is also cached. We cannot cache one and not the other. If only one cache exists, or only one has been sent down to the "privileged about content process" by the time the `about:home` document is requested, then we fallback to loading the dynamic `about:home` document. + +## Refreshing the cache + +The cache is refreshed periodically by having `ActivityStreamMessageChannel` tell `AboutHomeStartupCache` when it has sent any messages down to the preloaded `about:newtab`. In general, such messages are a good hint that something visual has updated for the next `about:newtab`, and that the cache should probably be refreshed. + +`AboutHomeStartupCache` debounces notifications about such messages, since they tend to be bursty. + +## Invalidating the cache + +It's possible that the composition or layout of `about:home` will change over time from release to release. When this occurs, it might be desirable to invalidate any pre-existing cache that might exist for a user, so that they don't see an outdated `about:home` on startup. + +To do this, we set a version number on the cache entry, and ensure that the version number is equal to our expectations on startup. If the version number does not match our expectation, then the cache is discarded and the `about:home` document will be rendered dynamically. + +The version number is currently set to the application build ID. This means that when the application updates, the cache is invalidated on the first restart after a browser update is applied. + +## Handling errors + +`about:home` is typically the first thing that the user sees upon starting the browser. It is critically important that it function quickly and correctly. If anything happens to go wrong when retrieving or saving to the cache, we should fall back to generating the document dynamically. + +As an example, it's theoretically possible for the browser to crash while in the midst of saving to the cache. In that case, we might have a partial document saved, or a partial script saved - neither of which is acceptable. + +Thankfully, the HTTP cache was designed with resilience in mind, so partially written entries are automatically discarded, which allows us to fall back to the dynamic page generation mode. + +As additional redundancy to that resilience, we also make sure to create a new nsICacheEntry every time the cache is populated, and write the version metadata as the last step. Since the version metadata is written last, we know that if it's missing when we try to load the cache that the writing of the page and the script did not complete, and that we should fall back to dynamically rendering the page. diff --git a/browser/components/newtab/docs/v2-system-addon/data_events.md b/browser/components/newtab/docs/v2-system-addon/data_events.md new file mode 100644 index 0000000000..78236bc3b1 --- /dev/null +++ b/browser/components/newtab/docs/v2-system-addon/data_events.md @@ -0,0 +1,19 @@ +# Metrics we collect + +By default, the about:newtab, about:welcome and about:home pages in Firefox (the pages you see when you open a new tab and when you start the browser), will send data back to Mozilla servers about usage of these pages. The intent is to collect data in order to improve the user's experience while using Activity Stream. Data about your specific browsing behaior or the sites you visit is **never transmitted to any Mozilla server**. At any time, it is easy to **turn off** this data collection by [opting out of Firefox telemetry](https://support.mozilla.org/kb/share-telemetry-data-mozilla-help-improve-firefox). + +Data is sent to our servers in the form of discrete HTTPS 'pings' or messages whenever you do some action on the Activity Stream about:home, about:newtab or about:welcome pages. We try to minimize the amount and frequency of pings by batching them together. + +At Mozilla, [we take your privacy very seriously](https://www.mozilla.org/privacy/). The Activity Stream page will never send any data that could personally identify you. We do not transmit what you are browsing, searches you perform or any private settings. Activity Stream does not set or send cookies, and uses [Transport Layer Security](https://en.wikipedia.org/wiki/Transport_Layer_Security) to securely transmit data to Mozilla servers. + +The data collected in the Activity Stream is documented +(along with the other data collected in Firefox Desktop) +in the [Glean Dictionary](https://dictionary.telemetry.mozilla.org/apps/firefox_desktop). + +Ping specifically collected on Firefox Home (New Tab) include: +* ["newtab"](https://dictionary.telemetry.mozilla.org/apps/firefox_desktop/pings/newtab) +* ["pocket-button"](https://dictionary.telemetry.mozilla.org/apps/firefox_desktop/pings/pocket-button) +* ["top-sites"](https://dictionary.telemetry.mozilla.org/apps/firefox_desktop/pings/top-sites) +* ["quick-suggest"](https://dictionary.telemetry.mozilla.org/apps/firefox_desktop/pings/quick-suggest) +* ["messaging-system"](https://dictionary.telemetry.mozilla.org/apps/firefox_desktop/pings/messaging-system) +* ["spoc"](https://dictionary.telemetry.mozilla.org/apps/firefox_desktop/pings/spoc) diff --git a/browser/components/newtab/docs/v2-system-addon/geo_locale.md b/browser/components/newtab/docs/v2-system-addon/geo_locale.md new file mode 100644 index 0000000000..4641e5d001 --- /dev/null +++ b/browser/components/newtab/docs/v2-system-addon/geo_locale.md @@ -0,0 +1,23 @@ +# Custom `geo`, `locale`, and update channels + +There are instances where you may need to change your local build's locale, geo, and update channel (such as changes to the visibility of Discovery Stream on a per-geo/locale basis in `ActivityStream.sys.mjs`). + +## Changing update channel + +- Change `app.update.channel` to desired value (eg: `release`) by editing `LOCAL_BUILD/Contents/Resources/defaults/pref/channel-prefs.js`. (**NOTE:** Changing pref `app.update.channel` from `about:config` seems to have no effect!) + +## Changing geo + +- Set `browser.search.region` to desired geo (eg `CA`) + +## Changing locale + +*Note: These prefs are only configurable on a nightly or local build.* + +- Toggle `extensions.langpacks.signatures.required` to `false` +- Toggle `xpinstall.signatures.required` to `false` +- Toggle `intl.multilingual.downloadEnabled` to `true` +- Toggle `intl.multilingual.enabled` to `true` +- For Mac and Linux builds, open the [langpack](https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central-l10n/linux-x86_64/xpi/) for target locale in your local build (eg `firefox-70.0a1.en-CA.langpack.xpi` if you want an `en-CA` locale). +- For Windows, use [https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central-l10n/](https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central-l10n/) +- In `about:preferences` click "Set Alternatives" under "Language", move desired locale to the top position, click OK, click "Apply And Restart" diff --git a/browser/components/newtab/docs/v2-system-addon/mochitests.md b/browser/components/newtab/docs/v2-system-addon/mochitests.md new file mode 100644 index 0000000000..da77874401 --- /dev/null +++ b/browser/components/newtab/docs/v2-system-addon/mochitests.md @@ -0,0 +1,26 @@ +# Mochitests + +We use [mochitests](https://firefox-source-docs.mozilla.org/testing/browser-chrome/) to do functional (and possibly integration) testing. Mochitests are part of Firefox and allow us to test activity stream literally as you would use it. + +Mochitests live in `test/browser`, and as of this writing, they are all the `browser-chrome` flavor of mochitests. They currently only run against the bootstrapped version of the add-on in system-addon, not the test pilot version at the top level directory. + +## Adding New Tests + +If you add new tests, make sure to list them in the `browser.ini` file. You will see the other tests there. Add a new entry with the same format as the others. You can also add new JS or HTML files by listing in under `support-files`. Make sure to start your test name with "browser_", so that the test suite knows the pick it up. E.g: "browser_as_my_new_test.js". + +## Writing Tests + +Here are a few tips for writing mochitests: + +* Only write mochitests for testing the interaction of multiple components on the page and to make sure that the protocol is working. +* If you need to access the content page, use `ContentTask.spawn`: + +```js +ContentTask.spawn(gBrowser.selectedBrowser, null, function* () { + content.wrappedJSObject.foo(); +}); +``` + +The above calls the function `foo` that exists in the page itself. You can also access the DOM this way: `content.document.querySelector`, if you want to click a button or do other things. You can even you use assertions inside this callback to check DOM state. + +* Nobody likes to see intermittent oranges in their tests, so read the [docs on how to avoid them](https://firefox-source-docs.mozilla.org/testing/intermittent/)! diff --git a/browser/components/newtab/docs/v2-system-addon/preferences.md b/browser/components/newtab/docs/v2-system-addon/preferences.md new file mode 100644 index 0000000000..ec6ba82491 --- /dev/null +++ b/browser/components/newtab/docs/v2-system-addon/preferences.md @@ -0,0 +1,270 @@ +# Preferences + +## Preference branch + +The preference branch for activity stream is `browser.newtabpage.activity-stream.`. +Any preferences defined in the preference configuration will be relative to that +branch. For example, if a preference is defined with the name `foo`, the full +preference as it is displayed in `about:config` will be `browser.newtabpage.activity-stream.foo`. + +## Defining new preferences + +All preferences for Activity Stream should be defined in the `PREFS_CONFIG` Array +found in `lib/ActivityStream.sys.mjs`. +The configuration object should have a `name` (the name of the pref), a `title` +that describes the functionality of the pref, and a `value`, the default value +of the pref. Optionally a `getValue` function can be provided to dynamically +generate a default pref value based on args, e.g., geo and locale. For +developers-specific defaults, an optional `value_local_dev` will be used instead +of `value`. For example: + +```js +{ + name: "telemetry.log", + title: "Log telemetry events in the console", + value: false, + value_local_dev: true, + getValue: ({geo}) => geo === "CA" +} +``` + +### IMPORTANT: Setting test-specific values for Mozilla Central + +If a feed or feature behind a pref makes any network calls or would other be +disruptive for automated tests and that pref is on by default, make sure you +disable it for tests in Mozilla Central. + +You should create a bug in Bugzilla and a patch that adds lines to turn off your +pref in the following files: + +- layout/tools/reftest/reftest-preferences.js +- testing/profiles/prefs_general.js +- testing/talos/talos/config.py + +You can see an example in [this patch](https://github.com/mozilla/activity-stream/pull/2977). + +## Reading, setting, and observing preferences from `.jsm`s + +To read/set/observe Activity Stream preferences, construct a `Prefs` instance found in `lib/ActivityStreamPrefs.sys.mjs`. + +```js +// Import Prefs +const { Prefs } = ChromeUtils.importESModule( + "resource://activity-stream/lib/ActivityStreamPrefs.sys.mjs" +); + +// Create an instance +const prefs = new Prefs(); +``` + +The `Prefs` utility will set the Activity Stream branch for you by default, so you +don't need to worry about prefixing every pref with `browser.newtabpage.activity-stream.`: + +```js +const prefs = new Prefs(); + +// This will return the value of browser.newtabpage.activity-stream.foo +prefs.get("foo"); + +// This will set the value of browser.newtabpage.activity-stream.foo to true +prefs.set("foo", true); + +// This will call aCallback when browser.newtabpage.activity-stream.foo is changed +prefs.observe("foo", aCallback); + +// This will stop listening to browser.newtabpage.activity-stream.foo +prefs.ignore("foo", aCallback); +``` + +See :searchfox:`toolkit/modules/Preferences.sys.mjs ` +for more information about what methods are available. + +## Discovery Stream Preferences + +Preferences specific to the Discovery Stream are nested under the sub-branch `browser.newtabpage.activity-stream.discoverystream` (with the exception of `browser.newtabpage.blocked`). + +### `browser.newtabpage.activity-stream.discoverystream.flight.blocks` + +- Type: `string (JSON)` +- Default: `{}` +- Pref Type: AS + +Not intended for user configuration, but is programmatically updated. Used for tracking blocked flight IDs when a user dismisses a SPOC. Keys are flight IDs. Values don't have a specific meaning. + +### `browser.newtabpage.blocked` + +- Type: `string (JSON)` +- Default: `null` +- Pref Type: AS + +Not intended for user configuration, but is programmatically updated. Used for tracking blocked story IDs when a user dismisses one. Keys are story IDs. Values don't have a specific meaning. + +### `browser.newtabpage.activity-stream.discoverystream.config` + +- Type `string (JSON)` +- Default: + ```json + { + "api_key_pref": "extensions.pocket.oAuthConsumerKey", + "collapsible": true, + "enabled": true, + "personalized": true, + } + ``` + - `api_key_pref` (string): The name of a variable containing the key for the Pocket API. + - `collapsible` (boolean): Controls whether the sections in new tab can be collapsed. + - `enabled` (boolean): Controls whether DS is turned on and is programmatically set based on a user's locale. DS enablement is a logical `AND` of this and the value of `browser.newtabpage.activity-stream.discoverystream.enabled`. + - `personalized` (boolean): When this is `true` personalized content based on browsing history will be displayed. + - `unused_key` (string): This is not set by default and is unused by this codebase. It's a standardized way to differentiate configurations to prevent experiment participants from being unenrolled. + +### `browser.newtabpage.activity-stream.discoverystream.enabled` + +- Type: `boolean` +- Default: `true` +- Pref Type: Firefox + +When this is set to `true` the Discovery Stream experience will show up if `enabled` is also `true` on `browser.newtabpage.activity-stream.discoverystream.config`. Otherwise the old Activity Stream experience will be shown. + +### `browser.newtabpage.activity-stream.discoverystream.endpointSpocsClear` + +- Type: `string (URL)` +- Default: `https://spocs.getpocket.com/user` +- Pref Type: AS + +Endpoint for when a user opts-out of sponsored content to delete the corresponding data from the ad server. + +### `browser.newtabpage.activity-stream.discoverystream.endpoints` + +- Type: `string (URLs, CSV)` +- Default: `https://getpocket.cdn.mozilla.net/,https://spocs.getpocket.com/` +- Pref Type: AS + +A list of endpoints that are allowed to be used by Discovery Stream for remote content (eg: story metadata) and configuration (eg: remote layout definitions for experimentation). + +### `browser.newtabpage.activity-stream.discoverystream.hardcoded-basic-layout` + +- Type: `boolean` +- Default: `false` +- Pref Type: Firefox + +If this is `false` the default hardcoded layout is used, and if it's `true` then an alternate hardcoded layout (that currently simulates the older AS experience) is used. + +### `browser.newtabpage.activity-stream.discoverystream.rec.impressions` + +- Type: `string (JSON)` +- Default: `{}` +- Pref Type: AS + +Programmatically generated hash table where the keys are recommendation IDs and the values are timestamps representing the first impression. + +### `browser.newtabpage.activity-stream.discoverystream.spoc.impressions` + +- Type: `string (JSON)` +- Default: `{}` +- Pref Type: AS + +Programmatically generated hash table where the keys are sponsored content IDs and the values are arrays of timestamps for every impression. + +### `browser.newtabpage.activity-stream.discoverystream.locale-list-config` + +- Type: `string (CSV, locales)` +- Default: `null` +- Pref Type: Firefox + +A comma separated list of locales that by default have stories enabled in newtab. It overrides what might be in region-stories-config. So if I set this to "en-US,en-CA,en-GB", all users with a English browser would see newtab stories, even if their region was not in region-stories-config list. + +### `browser.newtabpage.activity-stream.discoverystream.region-stories-config` + +- Type: `string (CSV, regions)` +- Default: `US,DE,CA,GB,IE,CH,AT,BE` +- Pref Type: Firefox + +A comma separated list of geos that by default have stories enabled in newtab. It matches the client's geo with that list, then looks for a matching locale. + +### `browser.newtabpage.activity-stream.discoverystream.region-spocs-config` + +- Type: `string (CSV, regions)` +- Default: `US,CA,DE` +- Pref Type: Firefox + +A comma separated list of geos that by default have spocs enabled in newtab. It matches the client's geo with that list. + +### `browser.newtabpage.activity-stream.discoverystream.region-layout-config` + +- Type: `string (CSV, regions)` +- Default: `US,CA,GB,DE,IE,CH,AT,BE` +- Pref Type: Firefox + +A comma separated list of geos that have 7 rows of stories enabled in newtab. It matches the client's geo with that list. + +### `browser.newtabpage.activity-stream.discoverystream.region-basic-layout` + +- Type: `boolean` +- Default: false +- Pref Type: AS + +If this is `true` newtabs with stories enabled see 1 row. It is set programmatically based on the result from region-layout-config. + +### `browser.newtabpage.activity-stream.discoverystream.spocs-endpoint` + +- Type: `string (URL)` +- Default: `null` +- Pref Type: Firefox + +Override to specify endpoint for SPOCs. Will take precedence over remote and hardcoded layout SPOC endpoints. + +### `browser.newtabpage.activity-stream.discoverystream.personalization.version` + +- Type: `integer` +- Default: `1` +- Pref Type: Firefox + +This controls what version of personalization we should use to score newtab stories. + +### `browser.newtabpage.activity-stream.discoverystream.personalization.modelKeys` + +- Type: `string (CSV)` +- Default: `nb_model_arts_and_entertainment, nb_model_autos_and_vehicles, nb_model_beauty_and_fitness, nb_model_blogging_resources_and_services, nb_model_books_and_literature, nb_model_business_and_industrial, nb_model_computers_and_electronics, nb_model_finance, nb_model_food_and_drink, nb_model_games, nb_model_health, nb_model_hobbies_and_leisure, nb_model_home_and_garden, nb_model_internet_and_telecom, nb_model_jobs_and_education, nb_model_law_and_government, nb_model_online_communities, nb_model_people_and_society, nb_model_pets_and_animals, nb_model_real_estate, nb_model_reference, nb_model_science, nb_model_shopping, nb_model_sports, nb_model_travel` +- Pref Type: Firefox + +This is a configuration for personalization version 2. It is a list of topics the algorithm uses to score stories by. + +### `browser.newtabpage.activity-stream.discoverystream.recs.personalized` + +- Type: `boolean` +- Default: false +- Pref Type: Firefox + +This controls if newtab story personalization includes regular stories or not. See spocs.personalized for sponsored content. + +### `browser.newtabpage.activity-stream.discoverystream.spocs.personalized` + +- Type: `boolean` +- Default: true +- Pref Type: Firefox + +This controls if newtab story personalization includes sponsored content or not. See recs.personalized for regular stories. + +### `browser.newtabpage.activity-stream.discoverystream.isCollectionDismissible` + +- Type: `boolean` +- Default: true +- Pref Type: Firefox + +This controls if newtab story collections are dismissible or not. + +### `browser.newtabpage.activity-stream.feeds.section.topstories` + +- Type: `boolean` +- Default: true +- Pref Type: Firefox + +This controls if the user should see newtab stories or not. It is set by the user via about:preferences#home + +### `browser.newtabpage.activity-stream.feeds.system.topstories` + +- Type: `boolean` +- Default: false +- Pref Type: AS + +Not intended for user configuration, but is programmatically set. It also controls if the user should see newtab stories or not. It is set at run time, and computed based on the locale/region. diff --git a/browser/components/newtab/docs/v2-system-addon/sections.md b/browser/components/newtab/docs/v2-system-addon/sections.md new file mode 100644 index 0000000000..332cf5b26d --- /dev/null +++ b/browser/components/newtab/docs/v2-system-addon/sections.md @@ -0,0 +1,82 @@ +# Sections + +Each section in Activity Stream displays data from a corresponding section feed +in a standardised `Section` UI component. Each section feed is responsible for +listening to events and updating the section options such as the title, icon, +and rows (the cards for the section to display). + +The `Section` UI component displays the rows provided by the section feed. If no +rows are available it displays an empty state consisting of an icon and a +message. Optionally, the section may have a info option menu that is displayed +when users hover over the info icon. + +On load, `SectionsManager` and `SectionsFeed` in `SectionsManager.sys.mjs` add the +sections configured in the `BUILT_IN_SECTIONS` map to the state. These sections +are initially disabled, so aren't visible. The section's feed may use the +methods provided by the `SectionsManager` to enable its section and update its +properties. + +The section configuration in `BUILT_IN_SECTIONS` consists of a generator +function keyed by the pref name for the section feed. The generator function +takes an `options` argument as the only parameter, which is passed the object +stored as serialised JSON in the pref `{feed_pref_name}.options`, or the empty +object if this doesn't exist. The generator returns a section configuration +object which may have the following properties: + +```{eval-rst} ++--------------------+---------------------+-------------------------------------------------------------------------------------------------------------------+ +| Property | Type | Description | ++====================+=====================+===================================================================================================================+ +| id | String | Non-optional unique id. | ++--------------------+---------------------+-------------------------------------------------------------------------------------------------------------------+ +| title | Localisation object | Has property `id`, the string localisation id, and optionally a `values` object to fill in placeholders. | ++--------------------+---------------------+-------------------------------------------------------------------------------------------------------------------+ +| icon | String | Icon id. New icons should be added in icons.scss. | ++--------------------+---------------------+-------------------------------------------------------------------------------------------------------------------+ +| maxRows | Integer | Maximum number of rows of cards to display. Should be >= 1. | ++--------------------+---------------------+-------------------------------------------------------------------------------------------------------------------+ +| contextMenuOptions | Array of strings | The menu options to provide in the card context menus. | ++--------------------+---------------------+-------------------------------------------------------------------------------------------------------------------+ +| shouldHidePref | Boolean | If true, will the section preference in the preferences pane will not be shown. | ++--------------------+---------------------+-------------------------------------------------------------------------------------------------------------------+ +| pref | Object | Configures the section preference to show in the preferences pane. Has properties `titleString` and `descString`. | ++--------------------+---------------------+-------------------------------------------------------------------------------------------------------------------+ +| emptyState | Object | Configures the empty state of the section. Has properties `message` and `icon`. | ++--------------------+---------------------+-------------------------------------------------------------------------------------------------------------------+ +``` + +## Section feeds + +Each section feed should be controlled by the pref `feeds.section.{section_id}`. + +### Enabling the section + +The section feed must listen for the events `INIT` (dispatched when Activity +Stream is initialised) and `FEED_INIT` (dispatched when a feed is re-enabled +having been turned off, with the feed id as the `data`). On these events it must +call `SectionsManager.enableSection(id)`. Care should be taken that this happens +only once `SectionsManager` has also initialised; the feed can use the method +`SectionsManager.onceInitialized()`. + +### Disabling the section + +The section feed must have an `uninit` method. This is called when the section +feed is disabled by turning the section's pref off. In `uninit` the feed must +call `SectionsManager.disableSection(id)`. This will remove the section's UI +component from every existing Activity Stream page. + +### Updating the section rows + +The section feed can call `SectionsManager.updateSection(id, options)` to update +section options. The `rows` array property of `options` stores the cards of +sites to display. Each card object may have the following properties: + +```js +{ + type, // One of the types in Card/types.js, e.g. "Trending" + title, // Title string + description, // Description string + image, // Image url + url // Site url +} +``` diff --git a/browser/components/newtab/docs/v2-system-addon/telemetry.md b/browser/components/newtab/docs/v2-system-addon/telemetry.md new file mode 100644 index 0000000000..848c931717 --- /dev/null +++ b/browser/components/newtab/docs/v2-system-addon/telemetry.md @@ -0,0 +1,10 @@ +# Telemetry checklist + +Adding telemetry generally involves a few steps: + +1. File a "user story" bug about who wants what question answered. This will be used to track the client-side implementation as well as the data review request. If the server side changes are needed, ask Nan (:nanj / @ncloudio) if in doubt, bugs will be filed separately as dependencies. +1. Implement as usual... +1. Get review from Nan on the data schema and the documentation changes. +1. Request `data-review` of your documentation changes from a [data steward](https://wiki.mozilla.org/Firefox/Data_Collection) to ensure suitability for collection controlled by the opt-out `datareporting.healthreport.uploadEnabled` pref. Download and fill out the [data review request form](https://github.com/mozilla/data-review/blob/master/request.md) and then attach it as a text file on Bugzilla so you can r? a data steward. We've been working with Chris H-C (:chutten) for the Firefox specific telemetry, and Kenny Long (kenny@getpocket.com) for the Pocket specific telemetry, they are the best candidates for the review work as they know well about the context. +1. After landing the implementation, check with Nan to make sure the pings are making it to the database. +1. Once data flows in, you can build dashboard for the new telemetry on [Redash](https://sql.telemetry.mozilla.org/dashboards). If you're looking for some help about Redash or dashboard building, Nan is the guy for that. diff --git a/browser/components/newtab/docs/v2-system-addon/tippytop.md b/browser/components/newtab/docs/v2-system-addon/tippytop.md new file mode 100644 index 0000000000..37111135c7 --- /dev/null +++ b/browser/components/newtab/docs/v2-system-addon/tippytop.md @@ -0,0 +1,40 @@ +# TippyTop in Activity Stream +TippyTop, a collection of icons from the Alexa top sites, provides high quality images for the Top Sites in Activity Stream. The TippyTop manifest is hosted on S3, and then moved to [Remote Settings](https://remote-settings.readthedocs.io/en/latest/index.html) since Firefox 63. In this document, we'll cover how we produce and manage TippyTop manifest for Activity Stream. + +## TippyTop manifest production +TippyTop manifest is produced by [tippy-top-sites](https://github.com/mozilla/tippy-top-sites). + +```sh +# set up the environment, only needed for the first time +$ pip install -r requirements.txt +$ python make_manifest.py --count 2000 > icons.json # Alexa top 2000 sites +``` + +Because the manifest is hosted remotely, we use another repo [tippytop-service](https://github.com/mozilla-services/tippytop-service) for the version control and deployment. Ask :nanj or :r1cky for permission to access this private repo. + +## TippyTop manifest publishing +For each new manifest release, firstly you should tag it in the tippytop-service repo, then publish it as follows: + +### For Firefox 62 and below +File a deploy bug with the tagged version at Bugzilla as [Activity Streams: Application Servers](https://bugzilla.mozilla.org/enter_bug.cgi?product=Firefox&component=Activity%20Streams%3A%20Application%20Servers), assign it to our system engineer :jbuck, he will take care of the rest. + +### For Firefox 63 and beyond +Activity Stream started using Remote Settings to manage TippyTop manifest since Firefox 63. To be able to publish new manifest, you need to be in the author&reviewer group of Remote Settings. See more details in this [mana page](https://mana.mozilla.org/wiki/pages/viewpage.action?pageId=66655528). You can also ask :nanj or :leplatram to get this set up for you. +To publish the manifest to Remote Settings, go to the tippytop-service repo, and run the script as follows, + +```sh +# set up the remote setting, only needed for the first time +$ python3 -m venv .venv +$ source .venv/bin/activate +$ pip install -r requirements.txt + +# publish it to prod +$ source .venv/bin/activate +# It will ask you for your LDAP user name and password. +$ ./upload2remotesettings.py prod +``` + +After uploading it to Remote Setting, you can request for review in the [dashboard](https://remote-settings.mozilla.org/v1/admin/). Note that you will need to log in the Mozilla LDAP VPN for both uploading and accessing Remote Setting's dashboard. Once your request gets approved by the reviewer, the new manifest will be content signed and published to production. + +## TippyTop Viewer +You can use this [viewer](https://mozilla.github.io/tippy-top-sites/manifest-viewer/) to load all the icons in the current manifest. diff --git a/browser/components/newtab/docs/v2-system-addon/unit_testing_guide.md b/browser/components/newtab/docs/v2-system-addon/unit_testing_guide.md new file mode 100644 index 0000000000..c3dd369a2d --- /dev/null +++ b/browser/components/newtab/docs/v2-system-addon/unit_testing_guide.md @@ -0,0 +1,149 @@ +# Unit testing + +## Overview + +Our unit tests in Activity Stream are written with mocha, chai, and sinon, and run +with karma. They include unit tests for both content code (React components, etc.) +and `.jsm`s. + +You can find unit tests in `tests/unit`. + +## Execution + +To run the unit tests once, execute `npm test`. + +To run unit tests continuously (i.e. in "test-driven development" mode), you can +run `npm run tddmc`. + +## Debugging + +To debug tests, you should run them in continuous mode with `npm run tddmc`. +In the Firefox window that is opened (it should say "Karma... - connected"), +click the "debug" button and open your console to see test output, set +breakpoints, etc. + +Unfortunately, source maps for tests do not currently work in Firefox. If you need +to see line numbers, you can run the tests with Chrome by running +`npm install --save-dev karma-chrome-launcher && npm run tddmc -- --browsers Chrome` + +## Where to put new tests + +If you are creating a new test, add it to a subdirectory of the `tests/unit` +that corresponds to the file you are testing. Tests should end with `.test.js` or +`.test.jsx` if the test includes any jsx. + +For example, if the file you are testing is `lib/Foo.jsm`, the test +file should be `test/unit/lib/Foo.test.js` + +## Mocha tests + +All our unit tests are written with [mocha](https://mochajs.org), which injects +globals like `describe`, `it`, `beforeEach`, and others. It can be used to write +synchronous or asynchronous tests: + +```js +describe("FooModule", () => { + // A synchronous test + it("should create an instance", () => { + assert.instanceOf(new FooModule(), FooModule); + }); + describe("#meaningOfLife", () => { + // An asynchronous test + it("should eventually get the meaning of life", async () => { + const foo = new FooModule(); + const result = await foo.meaningOfLife(); + assert.equal(result, 42); + }); + }); +}); +``` + +## Assertions + +To write assertions, use the globally available `assert` object (this is provided +by karma-chai, so you do not need to `require` it). + +For example: + +```js +assert.equal(foo, 3); +assert.propertyVal(someObj, "foo", 3); +assert.calledOnce(someStub); +``` + +You can use any of the assertions from: + +- [`chai`](http://chaijs.com/api/assert/). +- [`sinon-chai`](https://github.com/domenic/sinon-chai#assertions) + +### Custom assertions + +We have some custom assertions for checking various types of actions: + +#### `.isUserEventAction(action)` + +Asserts that a given `action` is a valid User Event, i.e. that it contains only +expected/valid properties for User Events in Activity Stream. + +```js +// This will pass +assert.isUserEventAction(ac.UserEvent({event: "CLICK"})); + +// This will fail +assert.isUserEventAction({type: "FOO"}); + +// This will fail because BLOOP is not a valid event type +assert.isUserEventAction(ac.UserEvent({event: "BLOOP"})); +``` + +## Overriding globals in `.jsm`s + +Most `.jsm`s you will be testing use `Cu.import` or `XPCOMUtils` to inject globals. +In order to add mocks/stubs/fakes for these globals, you should use the `GlobalOverrider` +utility in `test/unit/utils`: + +```js +const {GlobalOverrider} = require("test/unit/utils"); +describe("MyModule", () => { + let globals; + let sandbox; + beforeEach(() => { + globals = new GlobalOverrider(); + sandbox = globals.sandbox; // this is a sinon sandbox + // This will inject a "AboutNewTab" global before each test + globals.set("AboutNewTab", {override: sandbox.stub()}); + }); + // globals.restore() clears any globals you added as well as the sinon sandbox + afterEach(() => globals.restore()); +}); +``` + +## Testing React components + +You should use the [enzyme](https://github.com/airbnb/enzyme) suite of test utilities +to test React Components for Activity Stream. + +Where possible, use the [shallow rendering method](https://github.com/airbnb/enzyme/blob/master/docs/api/shallow.md) (this will avoid unnecessarily +rendering child components): + +```js +const React = require("react"); +const {shallow} = require("enzyme"); + +describe("", () => { + it("should be hidden by default", () => { + const wrapper = shallow(); + assert.isTrue(wrapper.find(".wrapper").props().hidden); + }); +}); +``` + +If you need to, you can also do [Full DOM rendering](https://github.com/airbnb/enzyme/blob/master/docs/api/mount.md) +with enzyme's `mount` utility. + +```js +const React = require("react"); +const {mount} = require("enzyme"); +... +const wrapper = mount(); +``` diff --git a/browser/components/newtab/jar.mn b/browser/components/newtab/jar.mn new file mode 100644 index 0000000000..d7b9912862 --- /dev/null +++ b/browser/components/newtab/jar.mn @@ -0,0 +1,40 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +browser.jar: +% resource activity-stream %res/activity-stream/ contentaccessible=yes +% content activity-stream %content/activity-stream/ contentaccessible=yes + res/activity-stream/lib/ (./lib/*) + res/activity-stream/common/ (./common/*) + res/activity-stream/vendor/Redux.sys.mjs (./vendor/Redux.sys.mjs) + res/activity-stream/vendor/react.js (./vendor/react.js) + res/activity-stream/vendor/react-dom.js (./vendor/react-dom.js) + res/activity-stream/vendor/react-dom-server.js (./vendor/react-dom-server.js) +#ifndef RELEASE_OR_BETA + res/activity-stream/vendor/react-dev.js (./vendor/react-dev.js) + res/activity-stream/vendor/react-dom-dev.js (./vendor/react-dom-dev.js) +#endif + res/activity-stream/vendor/prop-types.js (./vendor/prop-types.js) + res/activity-stream/vendor/react-transition-group.js (./vendor/react-transition-group.js) + res/activity-stream/vendor/redux.js (./vendor/redux.js) + res/activity-stream/vendor/react-redux.js (./vendor/react-redux.js) +* res/activity-stream/data/content/abouthomecache/page.html.template (./data/content/abouthomecache/page.html.template) +* res/activity-stream/data/content/abouthomecache/script.js.template (./data/content/abouthomecache/script.js.template) + content/activity-stream/data/content/assets/ (./data/content/assets/*) + content/activity-stream/data/content/tippytop/ (./data/content/tippytop/*) + res/activity-stream/data/content/activity-stream.bundle.js (./data/content/activity-stream.bundle.js) + res/activity-stream/data/content/newtab-render.js (./data/content/newtab-render.js) + res/activity-stream/data/custom-elements/ (./components/CustomElements/*) +#ifdef XP_MACOSX + content/activity-stream/css/activity-stream.css (./css/activity-stream-mac.css) +#elifdef XP_WIN + content/activity-stream/css/activity-stream.css (./css/activity-stream-windows.css) +#else + content/activity-stream/css/activity-stream.css (./css/activity-stream-linux.css) +#endif + res/activity-stream/prerendered/activity-stream.html (./prerendered/activity-stream.html) +#ifndef RELEASE_OR_BETA + res/activity-stream/prerendered/activity-stream-debug.html (./prerendered/activity-stream-debug.html) +#endif + res/activity-stream/prerendered/activity-stream-noscripts.html (./prerendered/activity-stream-noscripts.html) diff --git a/browser/components/newtab/karma.mc.config.js b/browser/components/newtab/karma.mc.config.js new file mode 100644 index 0000000000..78ef457865 --- /dev/null +++ b/browser/components/newtab/karma.mc.config.js @@ -0,0 +1,287 @@ +/* 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 path = require("path"); +const webpack = require("webpack"); +const { ResourceUriPlugin } = require("./tools/resourceUriPlugin"); + +const PATHS = { + // Where is the entry point for the unit tests? + testEntryFile: path.resolve(__dirname, "test/unit/unit-entry.js"), + + // A glob-style pattern matching all unit tests + testFilesPattern: "test/unit/**/*.js", + + // The base directory of all source files (used for path resolution in webpack importing) + moduleResolveDirectory: __dirname, + + // a RegEx matching all Cu.import statements of local files + resourcePathRegEx: /^resource:\/\/activity-stream\//, + + coverageReportingPath: "logs/coverage/", +}; + +// When tweaking here, be sure to review the docs about the execution ordering +// semantics of the preprocessors array, as they are somewhat odd. +const preprocessors = {}; +preprocessors[PATHS.testFilesPattern] = [ + "webpack", // require("karma-webpack") + "sourcemap", // require("karma-sourcemap-loader") +]; + +module.exports = function (config) { + const isTDD = config.tdd; + const browsers = isTDD ? ["Firefox"] : ["FirefoxHeadless"]; // require("karma-firefox-launcher") + config.set({ + singleRun: !isTDD, + browsers, + customLaunchers: { + FirefoxHeadless: { + base: "Firefox", + flags: ["--headless"], + }, + }, + frameworks: [ + "chai", // require("chai") require("karma-chai") + "mocha", // require("mocha") require("karma-mocha") + "sinon", // require("sinon") require("karma-sinon") + ], + reporters: [ + "coverage-istanbul", // require("karma-coverage") + "mocha", // require("karma-mocha-reporter") + + // for bin/try-runner.js to parse the output easily + "json", // require("karma-json-reporter") + ], + jsonReporter: { + // So this doesn't get interleaved with other karma output + stdout: false, + outputFile: path.join("logs", "karma-run-results.json"), + }, + coverageIstanbulReporter: { + reports: ["lcov", "text-summary"], // for some reason "lcov" reallys means "lcov" and "html" + "report-config": { + // so the full m-c path gets printed; needed for https://coverage.moz.tools/ integration + lcov: { + projectRoot: "../../..", + }, + }, + dir: PATHS.coverageReportingPath, + // This will make karma fail if coverage reporting is less than the minimums here + thresholds: !isTDD && { + each: { + statements: 100, + lines: 100, + functions: 100, + branches: 66, + overrides: { + "lib/AboutPreferences.sys.mjs": { + statements: 98, + lines: 98, + functions: 94, + branches: 66, + }, + /** + * TelemetryFeed.sys.mjs is tested via an xpcshell test + */ + "lib/TelemetryFeed.sys.mjs": { + statements: 10, + lines: 10, + functions: 9, + branches: 0, + }, + "content-src/lib/init-store.js": { + statements: 98, + lines: 98, + functions: 100, + branches: 100, + }, + "lib/ActivityStreamStorage.sys.mjs": { + statements: 100, + lines: 100, + functions: 100, + branches: 83, + }, + "lib/DownloadsManager.sys.mjs": { + statements: 100, + lines: 100, + functions: 100, + branches: 78, + }, + /** + * PlacesFeed.sys.mjs is tested via an xpcshell test + */ + "lib/PlacesFeed.sys.mjs": { + statements: 7, + lines: 7, + functions: 8, + branches: 0, + }, + "lib/UTEventReporting.sys.mjs": { + statements: 100, + lines: 100, + functions: 100, + branches: 75, + }, + "lib/Screenshots.sys.mjs": { + statements: 94, + lines: 94, + functions: 75, + branches: 84, + }, + /** + * Store.sys.mjs is tested via an xpcshell test + */ + "lib/Store.sys.mjs": { + statements: 8, + lines: 8, + functions: 0, + branches: 0, + }, + /** + * TopSitesFeed.sys.mjs is tested via an xpcshell test + */ + "lib/TopSitesFeed.sys.mjs": { + statements: 9, + lines: 9, + functions: 5, + branches: 0, + }, + /** + * TopStoresFeed.sys.mjs is not tested in automation and is slated + * for eventual removal. + */ + "lib/TopStoriesFeed.sys.mjs": { + statements: 0, + lines: 0, + functions: 0, + branches: 0, + }, + "content-src/components/DiscoveryStreamComponents/**/*.jsx": { + statements: 90.48, + lines: 90.48, + functions: 85.71, + branches: 68.75, + }, + "content-src/asrouter/**/*.jsx": { + statements: 57, + lines: 58, + functions: 60, + branches: 50, + }, + "content-src/components/DiscoveryStreamAdmin/*.jsx": { + statements: 0, + lines: 0, + functions: 0, + branches: 0, + }, + "content-src/components/CustomizeMenu/**/*.jsx": { + statements: 0, + lines: 0, + functions: 0, + branches: 0, + }, + "content-src/components/CustomizeMenu/*.jsx": { + statements: 0, + lines: 0, + functions: 0, + branches: 0, + }, + "content-src/lib/link-menu-options.js": { + statements: 96, + lines: 96, + functions: 96, + branches: 70, + }, + "content-src/components/**/*.jsx": { + statements: 51.1, + lines: 52.38, + functions: 31.2, + branches: 31.2, + }, + }, + }, + }, + }, + files: [PATHS.testEntryFile], + preprocessors, + webpack: { + mode: "none", + devtool: "inline-source-map", + // This loader allows us to override required files in tests + resolveLoader: { + alias: { inject: path.join(__dirname, "loaders/inject-loader") }, + }, + // This resolve config allows us to import with paths relative to the root directory, e.g. "lib/ActivityStream.sys.mjs" + resolve: { + extensions: [".js", ".jsx"], + modules: [PATHS.moduleResolveDirectory, "node_modules"], + fallback: { + stream: require.resolve("stream-browserify"), + buffer: require.resolve("buffer"), + }, + alias: { + asrouter: path.join(__dirname, "../asrouter"), + }, + }, + plugins: [ + // The ResourceUriPlugin handles translating resource URIs in import + // statements in .mjs files to paths on the filesystem. + new ResourceUriPlugin({ + resourcePathRegExes: [ + [ + new RegExp("^resource://activity-stream/"), + path.join(__dirname, "./"), + ], + ], + }), + new webpack.DefinePlugin({ + "process.env.NODE_ENV": JSON.stringify("development"), + }), + ], + externals: { + // enzyme needs these for backwards compatibility with 0.13. + // see https://github.com/airbnb/enzyme/blob/master/docs/guides/webpack.md#using-enzyme-with-webpack + "react/addons": true, + "react/lib/ReactContext": true, + "react/lib/ExecutionEnvironment": true, + }, + module: { + rules: [ + { + test: /\.js$/, + exclude: [/node_modules\/(?!@fluent\/).*/, /test/], + loader: "babel-loader", + }, + { + test: /\.jsx$/, + exclude: /node_modules/, + loader: "babel-loader", + options: { + presets: ["@babel/preset-react"], + }, + }, + { + test: /\.md$/, + use: "raw-loader", + }, + { + enforce: "post", + test: /\.js[mx]?$/, + loader: "@jsdevtools/coverage-istanbul-loader", + options: { esModules: true }, + include: [ + path.resolve("content-src"), + path.resolve("lib"), + path.resolve("common"), + ], + exclude: [path.resolve("test"), path.resolve("vendor")], + }, + ], + }, + }, + // Silences some overly-verbose logging of individual module builds + webpackMiddleware: { noInfo: true }, + }); +}; diff --git a/browser/components/newtab/lib/AboutPreferences.sys.mjs b/browser/components/newtab/lib/AboutPreferences.sys.mjs new file mode 100644 index 0000000000..33f7ecdaeb --- /dev/null +++ b/browser/components/newtab/lib/AboutPreferences.sys.mjs @@ -0,0 +1,298 @@ +/* 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 { + actionTypes as at, + actionCreators as ac, +} from "resource://activity-stream/common/Actions.sys.mjs"; + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +export const PREFERENCES_LOADED_EVENT = "home-pane-loaded"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", +}); + +// These "section" objects are formatted in a way to be similar to the ones from +// SectionsManager to construct the preferences view. +const PREFS_BEFORE_SECTIONS = () => [ + { + id: "search", + pref: { + feed: "showSearch", + titleString: "home-prefs-search-header", + }, + icon: "chrome://global/skin/icons/search-glass.svg", + }, + { + id: "topsites", + pref: { + feed: "feeds.topsites", + titleString: "home-prefs-shortcuts-header", + descString: "home-prefs-shortcuts-description", + get nestedPrefs() { + return Services.prefs.getBoolPref("browser.topsites.useRemoteSetting") + ? [ + { + name: "showSponsoredTopSites", + titleString: "home-prefs-shortcuts-by-option-sponsored", + eventSource: "SPONSORED_TOP_SITES", + }, + ] + : []; + }, + }, + icon: "chrome://browser/skin/topsites.svg", + maxRows: 4, + rowsPref: "topSitesRows", + eventSource: "TOP_SITES", + }, +]; + +export class AboutPreferences { + init() { + Services.obs.addObserver(this, PREFERENCES_LOADED_EVENT); + } + + uninit() { + Services.obs.removeObserver(this, PREFERENCES_LOADED_EVENT); + } + + onAction(action) { + switch (action.type) { + case at.INIT: + this.init(); + break; + case at.UNINIT: + this.uninit(); + break; + case at.SETTINGS_OPEN: + action._target.browser.ownerGlobal.openPreferences("paneHome"); + break; + // This is used to open the web extension settings page for an extension + case at.OPEN_WEBEXT_SETTINGS: + action._target.browser.ownerGlobal.BrowserOpenAddonsMgr( + `addons://detail/${encodeURIComponent(action.data)}` + ); + break; + } + } + + handleDiscoverySettings(sections) { + // Deep copy object to not modify original Sections state in store + let sectionsCopy = JSON.parse(JSON.stringify(sections)); + sectionsCopy.forEach(obj => { + if (obj.id === "topstories") { + obj.rowsPref = ""; + } + }); + return sectionsCopy; + } + + setupUserEvent(element, eventSource) { + element.addEventListener("command", e => { + const { checked } = e.target; + if (typeof checked === "boolean") { + this.store.dispatch( + ac.UserEvent({ + event: "PREF_CHANGED", + source: eventSource, + value: { status: checked, menu_source: "ABOUT_PREFERENCES" }, + }) + ); + } + }); + } + + observe(window) { + const discoveryStreamConfig = this.store.getState().DiscoveryStream.config; + let sections = this.store.getState().Sections; + + if (discoveryStreamConfig.enabled) { + sections = this.handleDiscoverySettings(sections); + } + + const featureConfig = lazy.NimbusFeatures.newtab.getAllVariables() || {}; + + this.renderPreferences(window, [ + ...PREFS_BEFORE_SECTIONS(featureConfig), + ...sections, + ]); + } + + /** + * Render preferences to an about:preferences content window with the provided + * preferences structure. + */ + renderPreferences({ document, Preferences, gHomePane }, prefStructure) { + // Helper to create a new element and append it + const createAppend = (tag, parent, options) => + parent.appendChild(document.createXULElement(tag, options)); + + // Helper to get fluentIDs sometimes encase in an object + const getString = message => + typeof message !== "object" ? message : message.id; + + // Helper to link a UI element to a preference for updating + const linkPref = (element, name, type) => { + const fullPref = `browser.newtabpage.activity-stream.${name}`; + element.setAttribute("preference", fullPref); + Preferences.add({ id: fullPref, type }); + + // Prevent changing the UI if the preference can't be changed + element.disabled = Preferences.get(fullPref).locked; + }; + + // Insert a new group immediately after the homepage one + const homeGroup = document.getElementById("homepageGroup"); + const contentsGroup = homeGroup.insertAdjacentElement( + "afterend", + homeGroup.cloneNode() + ); + contentsGroup.id = "homeContentsGroup"; + contentsGroup.setAttribute("data-subcategory", "contents"); + const homeHeader = createAppend("label", contentsGroup).appendChild( + document.createElementNS(HTML_NS, "h2") + ); + document.l10n.setAttributes(homeHeader, "home-prefs-content-header2"); + + const homeDescription = createAppend("description", contentsGroup); + homeDescription.classList.add("description-deemphasized"); + + document.l10n.setAttributes( + homeDescription, + "home-prefs-content-description2" + ); + + // Add preferences for each section + prefStructure.forEach(sectionData => { + const { + id, + pref: prefData, + icon = "webextension", + maxRows, + rowsPref, + shouldHidePref, + eventSource, + } = sectionData; + const { + feed: name, + titleString = {}, + descString, + nestedPrefs = [], + } = prefData || {}; + + // Don't show any sections that we don't want to expose in preferences UI + if (shouldHidePref) { + return; + } + + // Use full icon spec for certain protocols or fall back to packaged icon + const iconUrl = !icon.search(/^(chrome|moz-extension|resource):/) + ? icon + : `chrome://activity-stream/content/data/content/assets/glyph-${icon}-16.svg`; + + // Add the main preference for turning on/off a section + const sectionVbox = createAppend("vbox", contentsGroup); + sectionVbox.setAttribute("data-subcategory", id); + const checkbox = createAppend("checkbox", sectionVbox); + checkbox.classList.add("section-checkbox"); + checkbox.setAttribute("src", iconUrl); + // Setup a user event if we have an event source for this pref. + if (eventSource) { + this.setupUserEvent(checkbox, eventSource); + } + document.l10n.setAttributes( + checkbox, + getString(titleString), + titleString.values + ); + + 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); + checkbox.classList.add("tail-with-learn-more"); + + const link = createAppend("label", sponsoredHbox, { is: "text-link" }); + link.classList.add("learn-sponsored"); + link.setAttribute("href", sectionData.pref.learnMore.link.href); + document.l10n.setAttributes(link, sectionData.pref.learnMore.link.id); + } + + // Add more details for the section (e.g., description, more prefs) + const detailVbox = createAppend("vbox", sectionVbox); + detailVbox.classList.add("indent"); + if (descString) { + const description = createAppend("description", detailVbox); + description.classList.add("indent", "text-deemphasized"); + document.l10n.setAttributes( + description, + getString(descString), + descString.values + ); + + // Add a rows dropdown if we have a pref to control and a maximum + if (rowsPref && maxRows) { + const detailHbox = createAppend("hbox", detailVbox); + detailHbox.setAttribute("align", "center"); + description.setAttribute("flex", 1); + detailHbox.appendChild(description); + + // Add box so the search tooltip is positioned correctly + const tooltipBox = createAppend("hbox", detailHbox); + + // Add appropriate number of localized entries to the dropdown + const menulist = createAppend("menulist", tooltipBox); + menulist.setAttribute("crop", "none"); + const menupopup = createAppend("menupopup", menulist); + for (let num = 1; num <= maxRows; num++) { + const item = createAppend("menuitem", menupopup); + document.l10n.setAttributes( + item, + "home-prefs-sections-rows-option", + { num } + ); + item.setAttribute("value", num); + } + linkPref(menulist, rowsPref, "int"); + } + } + + const subChecks = []; + const fullName = `browser.newtabpage.activity-stream.${sectionData.pref.feed}`; + const pref = Preferences.get(fullName); + + // Add a checkbox pref for any nested preferences + nestedPrefs.forEach(nested => { + const subcheck = createAppend("checkbox", detailVbox); + // Setup a user event if we have an event source for this pref. + if (nested.eventSource) { + this.setupUserEvent(subcheck, nested.eventSource); + } + subcheck.classList.add("indent"); + document.l10n.setAttributes(subcheck, nested.titleString); + linkPref(subcheck, nested.name, "bool"); + subChecks.push(subcheck); + subcheck.disabled = !pref._value; + subcheck.hidden = nested.hidden; + }); + + // Disable any nested checkboxes if the parent pref is not enabled. + pref.on("change", () => { + subChecks.forEach(subcheck => { + subcheck.disabled = !pref._value; + }); + }); + }); + + // Update the visibility of the Restore Defaults btn based on checked prefs + gHomePane.toggleRestoreDefaultsBtn(); + } +} diff --git a/browser/components/newtab/lib/ActivityStream.sys.mjs b/browser/components/newtab/lib/ActivityStream.sys.mjs new file mode 100644 index 0000000000..f2287fe45e --- /dev/null +++ b/browser/components/newtab/lib/ActivityStream.sys.mjs @@ -0,0 +1,700 @@ +/* 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/. */ + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module. This +// is because the Karma test environment already stubs out +// AppConstants, and overrides importESModule to be a no-op (which +// can't be done for a static import statement). + +// eslint-disable-next-line mozilla/use-static-import +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutPreferences: "resource://activity-stream/lib/AboutPreferences.sys.mjs", + DEFAULT_SITES: "resource://activity-stream/lib/DefaultSites.sys.mjs", + DefaultPrefs: "resource://activity-stream/lib/ActivityStreamPrefs.sys.mjs", + DiscoveryStreamFeed: + "resource://activity-stream/lib/DiscoveryStreamFeed.sys.mjs", + FaviconFeed: "resource://activity-stream/lib/FaviconFeed.sys.mjs", + HighlightsFeed: "resource://activity-stream/lib/HighlightsFeed.sys.mjs", + NewTabInit: "resource://activity-stream/lib/NewTabInit.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + PrefsFeed: "resource://activity-stream/lib/PrefsFeed.sys.mjs", + PlacesFeed: "resource://activity-stream/lib/PlacesFeed.sys.mjs", + RecommendationProvider: + "resource://activity-stream/lib/RecommendationProvider.sys.mjs", + Region: "resource://gre/modules/Region.sys.mjs", + SectionsFeed: "resource://activity-stream/lib/SectionsManager.sys.mjs", + Store: "resource://activity-stream/lib/Store.sys.mjs", + SystemTickFeed: "resource://activity-stream/lib/SystemTickFeed.sys.mjs", + TelemetryFeed: "resource://activity-stream/lib/TelemetryFeed.sys.mjs", + TopSitesFeed: "resource://activity-stream/lib/TopSitesFeed.sys.mjs", + TopStoriesFeed: "resource://activity-stream/lib/TopStoriesFeed.sys.mjs", +}); + +// NB: Eagerly load modules that will be loaded/constructed/initialized in the +// common case to avoid the overhead of wrapping and detecting lazy loading. +import { + actionCreators as ac, + actionTypes as at, +} from "resource://activity-stream/common/Actions.sys.mjs"; + +const REGION_BASIC_CONFIG = + "browser.newtabpage.activity-stream.discoverystream.region-basic-config"; + +// Determine if spocs should be shown for a geo/locale +function showSpocs({ geo }) { + const spocsGeoString = + lazy.NimbusFeatures.pocketNewtab.getVariable("regionSpocsConfig") || ""; + const spocsGeo = spocsGeoString.split(",").map(s => s.trim()); + return spocsGeo.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([ + [ + "default.sites", + { + title: + "Comma-separated list of default top sites to fill in behind visited sites", + getValue: ({ geo }) => + lazy.DEFAULT_SITES.get(lazy.DEFAULT_SITES.has(geo) ? geo : ""), + }, + ], + [ + "feeds.section.topstories.options", + { + title: "Configuration options for top stories feed", + // This is a dynamic pref as it depends on the feed being shown or not + getValue: args => + JSON.stringify({ + api_key_pref: "extensions.pocket.oAuthConsumerKey", + // Use the opposite value as what default value the feed would have used + hidden: !PREFS_CONFIG.get("feeds.system.topstories").getValue(args), + provider_icon: "chrome://global/skin/icons/pocket.svg", + provider_name: "Pocket", + read_more_endpoint: + "https://getpocket.com/explore/trending?src=fx_new_tab", + stories_endpoint: `https://getpocket.cdn.mozilla.net/v3/firefox/global-recs?version=3&consumer_key=$apiKey&locale_lang=${ + args.locale + }&feed_variant=${ + showSpocs(args) ? "default_spocs_on" : "default_spocs_off" + }`, + stories_referrer: "https://getpocket.com/recommendations", + topics_endpoint: `https://getpocket.cdn.mozilla.net/v3/firefox/trending-topics?version=2&consumer_key=$apiKey&locale_lang=${args.locale}`, + show_spocs: showSpocs(args), + }), + }, + ], + [ + "feeds.topsites", + { + title: "Displays Top Sites on the New Tab Page", + value: true, + }, + ], + [ + "hideTopSitesTitle", + { + title: + "Hide the top sites section's title, including the section and collapse icons", + value: false, + }, + ], + [ + "showSponsored", + { + title: "User pref for sponsored Pocket content", + value: true, + }, + ], + [ + "system.showSponsored", + { + title: "System pref for sponsored Pocket content", + // This pref is dynamic as the sponsored content depends on the region + getValue: showSpocs, + }, + ], + [ + "showSponsoredTopSites", + { + title: "Show sponsored top sites", + value: true, + }, + ], + [ + "pocketCta", + { + title: "Pocket cta and button for logged out users.", + value: JSON.stringify({ + cta_button: "", + cta_text: "", + cta_url: "", + use_cta: false, + }), + }, + ], + [ + "showSearch", + { + title: "Show the Search bar", + value: true, + }, + ], + [ + "topSitesRows", + { + title: "Number of rows of Top Sites to display", + value: 1, + }, + ], + [ + "telemetry", + { + title: "Enable system error and usage data collection", + value: true, + value_local_dev: false, + }, + ], + [ + "telemetry.ut.events", + { + title: "Enable Unified Telemetry event data collection", + value: AppConstants.EARLY_BETA_OR_EARLIER, + value_local_dev: false, + }, + ], + [ + "telemetry.structuredIngestion.endpoint", + { + title: "Structured Ingestion telemetry server endpoint", + value: "https://incoming.telemetry.mozilla.org/submit", + }, + ], + [ + "section.highlights.includeVisited", + { + title: + "Boolean flag that decides whether or not to show visited pages in highlights.", + value: true, + }, + ], + [ + "section.highlights.includeBookmarks", + { + title: + "Boolean flag that decides whether or not to show bookmarks in highlights.", + value: true, + }, + ], + [ + "section.highlights.includePocket", + { + title: + "Boolean flag that decides whether or not to show saved Pocket stories in highlights.", + value: true, + }, + ], + [ + "section.highlights.includeDownloads", + { + title: + "Boolean flag that decides whether or not to show saved recent Downloads in highlights.", + value: true, + }, + ], + [ + "section.highlights.rows", + { + title: "Number of rows of Highlights to display", + value: 1, + }, + ], + [ + "section.topstories.rows", + { + title: "Number of rows of Top Stories to display", + value: 1, + }, + ], + [ + "sectionOrder", + { + title: "The rendering order for the sections", + value: "topsites,topstories,highlights", + }, + ], + [ + "improvesearch.noDefaultSearchTile", + { + title: "Remove tiles that are the same as the default search", + value: true, + }, + ], + [ + "improvesearch.topSiteSearchShortcuts.searchEngines", + { + title: + "An ordered, comma-delimited list of search shortcuts that we should try and pin", + // This pref is dynamic as the shortcuts vary depending on the region + getValue: ({ geo }) => { + if (!geo) { + return ""; + } + const searchShortcuts = []; + if (geo === "CN") { + searchShortcuts.push("baidu"); + } else if (["BY", "KZ", "RU", "TR"].includes(geo)) { + searchShortcuts.push("yandex"); + } else { + searchShortcuts.push("google"); + } + if (["DE", "FR", "GB", "IT", "JP", "US"].includes(geo)) { + searchShortcuts.push("amazon"); + } + return searchShortcuts.join(","); + }, + }, + ], + [ + "improvesearch.topSiteSearchShortcuts.havePinned", + { + title: + "A comma-delimited list of search shortcuts that have previously been pinned", + value: "", + }, + ], + [ + "asrouter.devtoolsEnabled", + { + title: "Are the asrouter devtools enabled?", + value: false, + }, + ], + [ + "asrouter.providers.onboarding", + { + title: "Configuration for onboarding provider", + value: JSON.stringify({ + id: "onboarding", + type: "local", + localProvider: "OnboardingMessageProvider", + enabled: true, + // Block specific messages from this local provider + exclude: [], + }), + }, + ], + // See browser/app/profile/firefox.js for other ASR preferences. They must be defined there to enable roll-outs. + [ + "discoverystream.flight.blocks", + { + title: "Track flight blocks", + skipBroadcast: true, + value: "{}", + }, + ], + [ + "discoverystream.config", + { + title: "Configuration for the new pocket new tab", + getValue: ({ geo, locale }) => { + return JSON.stringify({ + api_key_pref: "extensions.pocket.oAuthConsumerKey", + collapsible: true, + enabled: true, + }); + }, + }, + ], + [ + "discoverystream.endpoints", + { + title: + "Endpoint prefixes (comma-separated) that are allowed to be requested", + value: + "https://getpocket.cdn.mozilla.net/,https://firefox-api-proxy.cdn.mozilla.net/,https://spocs.getpocket.com/", + }, + ], + [ + "discoverystream.isCollectionDismissible", + { + title: "Allows Pocket story collections to be dismissed", + value: false, + }, + ], + [ + "discoverystream.onboardingExperience.dismissed", + { + title: "Allows the user to dismiss the new Pocket onboarding experience", + skipBroadcast: true, + alsoToPreloaded: true, + value: false, + }, + ], + [ + "discoverystream.region-basic-layout", + { + title: "Decision to use basic layout based on region.", + getValue: ({ geo }) => { + const preffedRegionsString = + Services.prefs.getStringPref(REGION_BASIC_CONFIG) || ""; + // If no regions are set to basic, + // we don't need to bother checking against the region. + // We are also not concerned if geo is not set, + // because stories are going to be empty until we have geo. + if (!preffedRegionsString) { + return false; + } + const preffedRegions = preffedRegionsString + .split(",") + .map(s => s.trim()); + + return preffedRegions.includes(geo); + }, + }, + ], + [ + "discoverystream.spoc.impressions", + { + title: "Track spoc impressions", + skipBroadcast: true, + value: "{}", + }, + ], + [ + "discoverystream.endpointSpocsClear", + { + title: + "Endpoint for when a user opts-out of sponsored content to delete the user's data from the ad server.", + value: "https://spocs.getpocket.com/user", + }, + ], + [ + "discoverystream.rec.impressions", + { + title: "Track rec impressions", + skipBroadcast: true, + value: "{}", + }, + ], + [ + "showRecentSaves", + { + title: "Control whether a user wants recent saves visible on Newtab", + value: true, + }, + ], +]); + +// Array of each feed's FEEDS_CONFIG factory and values to add to PREFS_CONFIG +const FEEDS_DATA = [ + { + name: "aboutpreferences", + factory: () => new lazy.AboutPreferences(), + title: "about:preferences rendering", + value: true, + }, + { + name: "newtabinit", + factory: () => new lazy.NewTabInit(), + title: "Sends a copy of the state to each new tab that is opened", + value: true, + }, + { + name: "places", + factory: () => new lazy.PlacesFeed(), + title: "Listens for and relays various Places-related events", + value: true, + }, + { + name: "prefs", + factory: () => new lazy.PrefsFeed(PREFS_CONFIG), + title: "Preferences", + value: true, + }, + { + name: "sections", + factory: () => new lazy.SectionsFeed(), + title: "Manages sections", + value: true, + }, + { + name: "section.highlights", + factory: () => new lazy.HighlightsFeed(), + title: "Fetches content recommendations from places db", + value: false, + }, + { + name: "system.topstories", + factory: () => + new lazy.TopStoriesFeed(PREFS_CONFIG.get("discoverystream.config")), + title: + "System pref that fetches content recommendations from a configurable content provider", + // Dynamically determine if Pocket should be shown for a geo / locale + getValue: ({ geo, locale }) => { + // If we don't have geo, we don't want to flash the screen with stories while geo loads. + // Best to display nothing until geo is ready. + if (!geo) { + return false; + } + const preffedRegionsBlockString = + lazy.NimbusFeatures.pocketNewtab.getVariable("regionStoriesBlock") || + ""; + const preffedRegionsString = + lazy.NimbusFeatures.pocketNewtab.getVariable("regionStoriesConfig") || + ""; + const preffedLocaleListString = + lazy.NimbusFeatures.pocketNewtab.getVariable("localeListConfig") || ""; + const preffedBlockRegions = preffedRegionsBlockString + .split(",") + .map(s => s.trim()); + const preffedRegions = preffedRegionsString.split(",").map(s => s.trim()); + const preffedLocales = preffedLocaleListString + .split(",") + .map(s => s.trim()); + const locales = { + US: ["en-CA", "en-GB", "en-US"], + CA: ["en-CA", "en-GB", "en-US"], + GB: ["en-CA", "en-GB", "en-US"], + AU: ["en-CA", "en-GB", "en-US"], + NZ: ["en-CA", "en-GB", "en-US"], + IN: ["en-CA", "en-GB", "en-US"], + IE: ["en-CA", "en-GB", "en-US"], + ZA: ["en-CA", "en-GB", "en-US"], + CH: ["de"], + BE: ["de"], + DE: ["de"], + AT: ["de"], + IT: ["it"], + FR: ["fr"], + ES: ["es-ES"], + PL: ["pl"], + JP: ["ja", "ja-JP-mac"], + }[geo]; + + const regionBlocked = preffedBlockRegions.includes(geo); + const localeEnabled = locale && preffedLocales.includes(locale); + const regionEnabled = + preffedRegions.includes(geo) && !!locales && locales.includes(locale); + return !regionBlocked && (localeEnabled || regionEnabled); + }, + }, + { + name: "systemtick", + factory: () => new lazy.SystemTickFeed(), + title: "Produces system tick events to periodically check for data expiry", + value: true, + }, + { + name: "telemetry", + factory: () => new lazy.TelemetryFeed(), + title: "Relays telemetry-related actions to PingCentre", + value: true, + }, + { + name: "favicon", + factory: () => new lazy.FaviconFeed(), + title: "Fetches tippy top manifests from remote service", + value: true, + }, + { + name: "system.topsites", + factory: () => new lazy.TopSitesFeed(), + title: "Queries places and gets metadata for Top Sites section", + value: true, + }, + { + name: "recommendationprovider", + factory: () => new lazy.RecommendationProvider(), + title: "Handles setup and interaction for the personality provider", + value: true, + }, + { + name: "discoverystreamfeed", + factory: () => new lazy.DiscoveryStreamFeed(), + title: "Handles new pocket ui for the new tab page", + value: true, + }, +]; + +const FEEDS_CONFIG = new Map(); +for (const config of FEEDS_DATA) { + const pref = `feeds.${config.name}`; + FEEDS_CONFIG.set(pref, config.factory); + PREFS_CONFIG.set(pref, config); +} + +export class ActivityStream { + /** + * constructor - Initializes an instance of ActivityStream + */ + constructor() { + this.initialized = false; + this.store = new lazy.Store(); + this.feeds = FEEDS_CONFIG; + this._defaultPrefs = new lazy.DefaultPrefs(PREFS_CONFIG); + } + + init() { + try { + this._updateDynamicPrefs(); + this._defaultPrefs.init(); + Services.obs.addObserver(this, "intl:app-locales-changed"); + + // Look for outdated user pref values that might have been accidentally + // persisted when restoring the original pref value at the end of an + // experiment across versions with a different default value. + const DS_CONFIG = + "browser.newtabpage.activity-stream.discoverystream.config"; + if ( + Services.prefs.prefHasUserValue(DS_CONFIG) && + [ + // Firefox 66 + `{"api_key_pref":"extensions.pocket.oAuthConsumerKey","enabled":false,"show_spocs":true,"layout_endpoint":"https://getpocket.com/v3/newtab/layout?version=1&consumer_key=$apiKey&layout_variant=basic"}`, + // Firefox 67 + `{"api_key_pref":"extensions.pocket.oAuthConsumerKey","enabled":false,"show_spocs":true,"layout_endpoint":"https://getpocket.cdn.mozilla.net/v3/newtab/layout?version=1&consumer_key=$apiKey&layout_variant=basic"}`, + // Firefox 68 + `{"api_key_pref":"extensions.pocket.oAuthConsumerKey","collapsible":true,"enabled":false,"show_spocs":true,"hardcoded_layout":true,"personalized":false,"layout_endpoint":"https://getpocket.cdn.mozilla.net/v3/newtab/layout?version=1&consumer_key=$apiKey&layout_variant=basic"}`, + ].includes(Services.prefs.getStringPref(DS_CONFIG)) + ) { + Services.prefs.clearUserPref(DS_CONFIG); + } + + // Hook up the store and let all feeds and pages initialize + this.store.init( + this.feeds, + ac.BroadcastToContent({ + type: at.INIT, + data: { + locale: this.locale, + }, + meta: { + isStartup: true, + }, + }), + { type: at.UNINIT } + ); + + this.initialized = true; + } catch (e) { + // TelemetryFeed could be unavailable if the telemetry is disabled, or + // the telemetry feed is not yet initialized. + const telemetryFeed = this.store.feeds.get("feeds.telemetry"); + if (telemetryFeed) { + telemetryFeed.handleUndesiredEvent({ + data: { event: "ADDON_INIT_FAILED" }, + }); + } + throw e; + } + } + + /** + * Check if an old pref has a custom value to migrate. Clears the pref so that + * it's the default after migrating (to avoid future need to migrate). + * + * @param oldPrefName {string} Pref to check and migrate + * @param cbIfNotDefault {function} Callback that gets the current pref value + */ + _migratePref(oldPrefName, cbIfNotDefault) { + // Nothing to do if the user doesn't have a custom value + if (!Services.prefs.prefHasUserValue(oldPrefName)) { + return; + } + + // Figure out what kind of pref getter to use + let prefGetter; + switch (Services.prefs.getPrefType(oldPrefName)) { + case Services.prefs.PREF_BOOL: + prefGetter = "getBoolPref"; + break; + case Services.prefs.PREF_INT: + prefGetter = "getIntPref"; + break; + case Services.prefs.PREF_STRING: + prefGetter = "getStringPref"; + break; + } + + // Give the callback the current value then clear the pref + cbIfNotDefault(Services.prefs[prefGetter](oldPrefName)); + Services.prefs.clearUserPref(oldPrefName); + } + + uninit() { + if (this.geo === "") { + Services.obs.removeObserver(this, lazy.Region.REGION_TOPIC); + } + + Services.obs.removeObserver(this, "intl:app-locales-changed"); + + this.store.uninit(); + this.initialized = false; + } + + _updateDynamicPrefs() { + // Save the geo pref if we have it + if (lazy.Region.home) { + this.geo = lazy.Region.home; + } else if (this.geo !== "") { + // Watch for geo changes and use a dummy value for now + Services.obs.addObserver(this, lazy.Region.REGION_TOPIC); + this.geo = ""; + } + + this.locale = Services.locale.appLocaleAsBCP47; + + // Update the pref config of those with dynamic values + for (const pref of PREFS_CONFIG.keys()) { + // Only need to process dynamic prefs + const prefConfig = PREFS_CONFIG.get(pref); + if (!prefConfig.getValue) { + continue; + } + + // Have the dynamic pref just reuse using existing default, e.g., those + // set via Autoconfig or policy + try { + const existingDefault = this._defaultPrefs.get(pref); + if (existingDefault !== undefined && prefConfig.value === undefined) { + prefConfig.getValue = () => existingDefault; + } + } catch (ex) { + // We get NS_ERROR_UNEXPECTED for prefs that have a user value (causing + // default branch to believe there's a type) but no actual default value + } + + // Compute the dynamic value (potentially generic based on dummy geo) + const newValue = prefConfig.getValue({ + geo: this.geo, + locale: this.locale, + }); + + // If there's an existing value and it has changed, that means we need to + // overwrite the default with the new value. + if (prefConfig.value !== undefined && prefConfig.value !== newValue) { + this._defaultPrefs.set(pref, newValue); + } + + prefConfig.value = newValue; + } + } + + observe(subject, topic, data) { + switch (topic) { + case "intl:app-locales-changed": + case lazy.Region.REGION_TOPIC: + this._updateDynamicPrefs(); + break; + } + } +} diff --git a/browser/components/newtab/lib/ActivityStreamMessageChannel.sys.mjs b/browser/components/newtab/lib/ActivityStreamMessageChannel.sys.mjs new file mode 100644 index 0000000000..de9d2cb800 --- /dev/null +++ b/browser/components/newtab/lib/ActivityStreamMessageChannel.sys.mjs @@ -0,0 +1,333 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutHomeStartupCache: "resource:///modules/BrowserGlue.sys.mjs", + AboutNewTabParent: "resource:///actors/AboutNewTabParent.sys.mjs", +}); + +import { + actionCreators as ac, + actionTypes as at, + actionUtils as au, +} from "resource://activity-stream/common/Actions.sys.mjs"; + +const ABOUT_NEW_TAB_URL = "about:newtab"; + +export const DEFAULT_OPTIONS = { + dispatch(action) { + throw new Error( + `\nMessageChannel: Received action ${action.type}, but no dispatcher was defined.\n` + ); + }, + pageURL: ABOUT_NEW_TAB_URL, + outgoingMessageName: "ActivityStream:MainToContent", + incomingMessageName: "ActivityStream:ContentToMain", +}; + +export class ActivityStreamMessageChannel { + /** + * ActivityStreamMessageChannel - This module connects a Redux store to the new tab page actor. + * You should use the BroadcastToContent, AlsoToOneContent, and AlsoToMain action creators + * in common/Actions.sys.mjs to help you create actions that will be automatically routed + * to the correct location. + * + * @param {object} options + * @param {function} options.dispatch The dispatch method from a Redux store + * @param {string} options.pageURL The URL to which the channel is attached, such as about:newtab. + * @param {string} options.outgoingMessageName The name of the message sent to child processes + * @param {string} options.incomingMessageName The name of the message received from child processes + * @return {ActivityStreamMessageChannel} + */ + constructor(options = {}) { + Object.assign(this, DEFAULT_OPTIONS, options); + + this.middleware = this.middleware.bind(this); + this.onMessage = this.onMessage.bind(this); + this.onNewTabLoad = this.onNewTabLoad.bind(this); + this.onNewTabUnload = this.onNewTabUnload.bind(this); + this.onNewTabInit = this.onNewTabInit.bind(this); + } + + /** + * Get an iterator over the loaded tab objects. + */ + get loadedTabs() { + // In the test, AboutNewTabParent is not defined. + return lazy.AboutNewTabParent?.loadedTabs || new Map(); + } + + /** + * middleware - Redux middleware that looks for AlsoToOneContent and BroadcastToContent type + * actions, and sends them out. + * + * @param {object} store A redux store + * @return {function} Redux middleware + */ + middleware(store) { + return next => action => { + const skipMain = action.meta && action.meta.skipMain; + if (au.isSendToOneContent(action)) { + this.send(action); + } else if (au.isBroadcastToContent(action)) { + this.broadcast(action); + } else if (au.isSendToPreloaded(action)) { + this.sendToPreloaded(action); + } + + if (!skipMain) { + next(action); + } + }; + } + + /** + * onActionFromContent - Handler for actions from a content processes + * + * @param {object} action A Redux action + * @param {string} targetId The portID of the port that sent the message + */ + onActionFromContent(action, targetId) { + this.dispatch(ac.AlsoToMain(action, this.validatePortID(targetId))); + } + + /** + * broadcast - Sends an action to all ports + * + * @param {object} action A Redux action + */ + broadcast(action) { + // We're trying to update all tabs, so signal the AboutHomeStartupCache + // that its likely time to refresh the cache. + lazy.AboutHomeStartupCache.onPreloadedNewTabMessage(); + + for (let { actor } of this.loadedTabs.values()) { + try { + actor.sendAsyncMessage(this.outgoingMessageName, action); + } catch (e) { + // The target page is closed/closing by the user or test, so just ignore. + } + } + } + + /** + * send - Sends an action to a specific port + * + * @param {obj} action A redux action; it should contain a portID in the meta.toTarget property + */ + send(action) { + const targetId = action.meta && action.meta.toTarget; + const target = this.getTargetById(targetId); + try { + target.sendAsyncMessage(this.outgoingMessageName, action); + } catch (e) { + // The target page is closed/closing by the user or test, so just ignore. + } + } + + /** + * A valid portID is a combination of process id and a port number. + * It is generated in AboutNewTabChild.sys.mjs. + */ + validatePortID(id) { + if (typeof id !== "string" || !id.includes(":")) { + console.error("Invalid portID"); + } + + return id; + } + + /** + * getTargetById - Retrieve the message target by portID, if it exists + * + * @param {string} id A portID + * @return {obj|null} The message target, if it exists. + */ + getTargetById(id) { + this.validatePortID(id); + + for (let { portID, actor } of this.loadedTabs.values()) { + if (portID === id) { + return actor; + } + } + return null; + } + + /** + * sendToPreloaded - Sends an action to each preloaded browser, if any + * + * @param {obj} action A redux action + */ + sendToPreloaded(action) { + // We're trying to update the preloaded about:newtab, so signal + // the AboutHomeStartupCache that its likely time to refresh + // the cache. + lazy.AboutHomeStartupCache.onPreloadedNewTabMessage(); + + const preloadedActors = this.getPreloadedActors(); + if (preloadedActors && action.data) { + for (let preloadedActor of preloadedActors) { + try { + preloadedActor.sendAsyncMessage(this.outgoingMessageName, action); + } catch (e) { + // The preloaded page is no longer available, so just ignore. + } + } + } + } + + /** + * getPreloadedActors - Retrieve the preloaded actors + * + * @return {Array|null} An array of actors belonging to the preloaded browsers, or null + * if there aren't any preloaded browsers + */ + getPreloadedActors() { + let preloadedActors = []; + for (let { actor, browser } of this.loadedTabs.values()) { + if (this.isPreloadedBrowser(browser)) { + preloadedActors.push(actor); + } + } + return preloadedActors.length ? preloadedActors : null; + } + + /** + * isPreloadedBrowser - Returns true if the passed browser has been preloaded + * for faster rendering of new tabs. + * + * @param {} A to check. + * @return {bool} True if the browser is preloaded. + * if there aren't any preloaded browsers + */ + isPreloadedBrowser(browser) { + return browser.getAttribute("preloadedState") === "preloaded"; + } + + simulateMessagesForExistingTabs() { + // Some pages might have already loaded, so we won't get the usual message + for (const loadedTab of this.loadedTabs.values()) { + let simulatedDetails = { + actor: loadedTab.actor, + browser: loadedTab.browser, + browsingContext: loadedTab.browsingContext, + portID: loadedTab.portID, + url: loadedTab.url, + simulated: true, + }; + + this.onActionFromContent( + { + type: at.NEW_TAB_INIT, + data: simulatedDetails, + }, + loadedTab.portID + ); + + if (loadedTab.loaded) { + this.tabLoaded(simulatedDetails); + } + } + + // It's possible that those existing tabs had sent some messages up + // to us before the feeds / ActivityStreamMessageChannel was ready. + // + // AboutNewTabParent takes care of queueing those for us, so + // now that we're ready, we can flush these queued messages. + lazy.AboutNewTabParent.flushQueuedMessagesFromContent(); + } + + /** + * onNewTabInit - Handler for special RemotePage:Init message fired + * on initialization. + * + * @param {obj} msg The messsage from a page that was just initialized + * @param {obj} tabDetails details about a loaded tab + * + * tabDetails contains: + * actor, browser, browsingContext, portID, url + */ + onNewTabInit(msg, tabDetails) { + this.onActionFromContent( + { + type: at.NEW_TAB_INIT, + data: tabDetails, + }, + msg.data.portID + ); + } + + /** + * onNewTabLoad - Handler for special RemotePage:Load message fired on page load. + * + * @param {obj} msg The messsage from a page that was just loaded + * @param {obj} tabDetails details about a loaded tab, similar to onNewTabInit + */ + onNewTabLoad(msg, tabDetails) { + this.tabLoaded(tabDetails); + } + + tabLoaded(tabDetails) { + tabDetails.loaded = true; + + let { browser } = tabDetails; + if ( + this.isPreloadedBrowser(browser) && + browser.ownerGlobal.windowState !== browser.ownerGlobal.STATE_MINIMIZED && + !browser.ownerGlobal.isFullyOccluded + ) { + // As a perceived performance optimization, if this loaded Activity Stream + // happens to be a preloaded browser in a window that is not minimized or + // occluded, have it render its layers to the compositor now to increase + // the odds that by the time we switch to the tab, the layers are already + // ready to present to the user. + browser.renderLayers = true; + } + + this.onActionFromContent({ type: at.NEW_TAB_LOAD }, tabDetails.portID); + } + + /** + * onNewTabUnloadLoad - Handler for special RemotePage:Unload message fired + * on page unload. + * + * @param {obj} msg The messsage from a page that was just unloaded + * @param {obj} tabDetails details about a loaded tab, similar to onNewTabInit + */ + onNewTabUnload(msg, tabDetails) { + this.onActionFromContent({ type: at.NEW_TAB_UNLOAD }, tabDetails.portID); + } + + /** + * onMessage - Handles custom messages from content. It expects all messages to + * be formatted as Redux actions, and dispatches them to this.store + * + * @param {obj} msg A custom message from content + * @param {obj} msg.action A Redux action (e.g. {type: "HELLO_WORLD"}) + * @param {obj} msg.target A message target + * @param {obj} tabDetails details about a loaded tab, similar to onNewTabInit + */ + onMessage(msg, tabDetails) { + if (!msg.data || !msg.data.type) { + console.error( + new Error( + `Received an improperly formatted message from ${tabDetails.portID}` + ) + ); + return; + } + let action = {}; + Object.assign(action, msg.data); + // target is used to access a browser reference that came from the content + // and should only be used in feeds (not reducers) + action._target = { + browser: tabDetails.browser, + }; + + this.onActionFromContent(action, tabDetails.portID); + } +} diff --git a/browser/components/newtab/lib/ActivityStreamPrefs.sys.mjs b/browser/components/newtab/lib/ActivityStreamPrefs.sys.mjs new file mode 100644 index 0000000000..192ff30288 --- /dev/null +++ b/browser/components/newtab/lib/ActivityStreamPrefs.sys.mjs @@ -0,0 +1,100 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module. This +// is because the Karma test environment already stubs out +// AppConstants, and overrides importESModule to be a no-op (which +// can't be done for a static import statement). + +// eslint-disable-next-line mozilla/use-static-import +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +// eslint-disable-next-line mozilla/use-static-import +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +const ACTIVITY_STREAM_PREF_BRANCH = "browser.newtabpage.activity-stream."; + +export class Prefs extends Preferences { + /** + * Prefs - A wrapper around Preferences that always sets the branch to + * ACTIVITY_STREAM_PREF_BRANCH + */ + constructor(branch = ACTIVITY_STREAM_PREF_BRANCH) { + super({ branch }); + this._branchObservers = new Map(); + } + + ignoreBranch(listener) { + const observer = this._branchObservers.get(listener); + this._prefBranch.removeObserver("", observer); + this._branchObservers.delete(listener); + } + + observeBranch(listener) { + const observer = (subject, topic, pref) => { + listener.onPrefChanged(pref, this.get(pref)); + }; + this._prefBranch.addObserver("", observer); + this._branchObservers.set(listener, observer); + } +} + +export class DefaultPrefs extends Preferences { + /** + * DefaultPrefs - A helper for setting and resetting default prefs for the add-on + * + * @param {Map} config A Map with {string} key of the pref name and {object} + * value with the following pref properties: + * {string} .title (optional) A description of the pref + * {bool|string|number} .value The default value for the pref + * @param {string} branch (optional) The pref branch (defaults to ACTIVITY_STREAM_PREF_BRANCH) + */ + constructor(config, branch = ACTIVITY_STREAM_PREF_BRANCH) { + super({ + branch, + defaultBranch: true, + }); + this._config = config; + } + + /** + * init - Set default prefs for all prefs in the config + */ + init() { + // Local developer builds (with the default mozconfig) aren't OFFICIAL + const IS_UNOFFICIAL_BUILD = !AppConstants.MOZILLA_OFFICIAL; + + for (const pref of this._config.keys()) { + try { + // Avoid replacing existing valid default pref values, e.g., those set + // via Autoconfig or policy + if (this.get(pref) !== undefined) { + continue; + } + } catch (ex) { + // We get NS_ERROR_UNEXPECTED for prefs that have a user value (causing + // default branch to believe there's a type) but no actual default value + } + + const prefConfig = this._config.get(pref); + let value; + if (IS_UNOFFICIAL_BUILD && "value_local_dev" in prefConfig) { + value = prefConfig.value_local_dev; + } else { + value = prefConfig.value; + } + + try { + this.set(pref, value); + } catch (ex) { + // Potentially the user somehow set an unexpected value type, so we fail + // to set a default of our expected type + } + } + } +} diff --git a/browser/components/newtab/lib/ActivityStreamStorage.sys.mjs b/browser/components/newtab/lib/ActivityStreamStorage.sys.mjs new file mode 100644 index 0000000000..1e128ec3f2 --- /dev/null +++ b/browser/components/newtab/lib/ActivityStreamStorage.sys.mjs @@ -0,0 +1,119 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs", +}); + +export class ActivityStreamStorage { + /** + * @param storeNames Array of strings used to create all the required stores + */ + constructor({ storeNames, telemetry }) { + if (!storeNames) { + throw new Error("storeNames required"); + } + + this.dbName = "ActivityStream"; + this.dbVersion = 3; + this.storeNames = storeNames; + this.telemetry = telemetry; + } + + get db() { + return this._db || (this._db = this.createOrOpenDb()); + } + + /** + * Public method that binds the store required by the consumer and exposes + * the private db getters and setters. + * + * @param storeName String name of desired store + */ + getDbTable(storeName) { + if (this.storeNames.includes(storeName)) { + return { + get: this._get.bind(this, storeName), + getAll: this._getAll.bind(this, storeName), + set: this._set.bind(this, storeName), + }; + } + + throw new Error(`Store name ${storeName} does not exist.`); + } + + async _getStore(storeName) { + return (await this.db).objectStore(storeName, "readwrite"); + } + + _get(storeName, key) { + return this._requestWrapper(async () => + (await this._getStore(storeName)).get(key) + ); + } + + _getAll(storeName) { + return this._requestWrapper(async () => + (await this._getStore(storeName)).getAll() + ); + } + + _set(storeName, key, value) { + return this._requestWrapper(async () => + (await this._getStore(storeName)).put(value, key) + ); + } + + _openDatabase() { + return lazy.IndexedDB.open(this.dbName, { version: this.dbVersion }, db => { + // If provided with array of objectStore names we need to create all the + // individual stores + this.storeNames.forEach(store => { + if (!db.objectStoreNames.contains(store)) { + this._requestWrapper(() => db.createObjectStore(store)); + } + }); + }); + } + + /** + * createOrOpenDb - Open a db (with this.dbName) if it exists. + * If it does not exist, create it. + * If an error occurs, deleted the db and attempt to + * re-create it. + * @returns Promise that resolves with a db instance + */ + async createOrOpenDb() { + try { + const db = await this._openDatabase(); + return db; + } catch (e) { + if (this.telemetry) { + this.telemetry.handleUndesiredEvent({ event: "INDEXEDDB_OPEN_FAILED" }); + } + await lazy.IndexedDB.deleteDatabase(this.dbName); + return this._openDatabase(); + } + } + + async _requestWrapper(request) { + let result = null; + try { + result = await request(); + } catch (e) { + if (this.telemetry) { + this.telemetry.handleUndesiredEvent({ event: "TRANSACTION_FAILED" }); + } + throw e; + } + + return result; + } +} + +export function getDefaultOptions(options) { + return { collapsed: !!options.collapsed }; +} diff --git a/browser/components/newtab/lib/DefaultSites.sys.mjs b/browser/components/newtab/lib/DefaultSites.sys.mjs new file mode 100644 index 0000000000..ea49cccc03 --- /dev/null +++ b/browser/components/newtab/lib/DefaultSites.sys.mjs @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const DEFAULT_SITES_MAP = new Map([ + // This first item is the global list fallback for any unexpected geos + [ + "", + "https://www.youtube.com/,https://www.facebook.com/,https://www.wikipedia.org/,https://www.reddit.com/,https://www.amazon.com/,https://twitter.com/", + ], + [ + "US", + "https://www.youtube.com/,https://www.facebook.com/,https://www.amazon.com/,https://www.reddit.com/,https://www.wikipedia.org/,https://twitter.com/", + ], + [ + "CA", + "https://www.youtube.com/,https://www.facebook.com/,https://www.reddit.com/,https://www.wikipedia.org/,https://www.amazon.ca/,https://twitter.com/", + ], + [ + "DE", + "https://www.youtube.com/,https://www.facebook.com/,https://www.amazon.de/,https://www.ebay.de/,https://www.wikipedia.org/,https://www.reddit.com/", + ], + [ + "PL", + "https://www.youtube.com/,https://www.facebook.com/,https://allegro.pl/,https://www.wikipedia.org/,https://www.olx.pl/,https://www.wykop.pl/", + ], + [ + "RU", + "https://vk.com/,https://www.youtube.com/,https://ok.ru/,https://www.avito.ru/,https://www.aliexpress.com/,https://www.wikipedia.org/", + ], + [ + "GB", + "https://www.youtube.com/,https://www.facebook.com/,https://www.reddit.com/,https://www.amazon.co.uk/,https://www.bbc.co.uk/,https://www.ebay.co.uk/", + ], + [ + "FR", + "https://www.youtube.com/,https://www.facebook.com/,https://www.wikipedia.org/,https://www.amazon.fr/,https://www.leboncoin.fr/,https://twitter.com/", + ], + [ + "CN", + "https://www.baidu.com/,https://www.zhihu.com/,https://www.ifeng.com/,https://weibo.com/,https://www.ctrip.com/,https://www.iqiyi.com/", + ], +]); + +// Immutable for export. +export const DEFAULT_SITES = Object.freeze(DEFAULT_SITES_MAP); diff --git a/browser/components/newtab/lib/DiscoveryStreamFeed.sys.mjs b/browser/components/newtab/lib/DiscoveryStreamFeed.sys.mjs new file mode 100644 index 0000000000..257036b9da --- /dev/null +++ b/browser/components/newtab/lib/DiscoveryStreamFeed.sys.mjs @@ -0,0 +1,2265 @@ +/* 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, { + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", + pktApi: "chrome://pocket/content/pktApi.sys.mjs", + PersistentCache: "resource://activity-stream/lib/PersistentCache.sys.mjs", + Region: "resource://gre/modules/Region.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", +}); + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module. This +// is because the Karma test environment already stubs out +// setTimeout / clearTimeout, and overrides importESModule +// to be a no-op (which can't be done for a static import statement). + +// eslint-disable-next-line mozilla/use-static-import +const { setTimeout, clearTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); +import { + actionTypes as at, + actionCreators as ac, +} from "resource://activity-stream/common/Actions.sys.mjs"; + +const CACHE_KEY = "discovery_stream"; +const STARTUP_CACHE_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000; // 1 week +const COMPONENT_FEEDS_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes +const SPOCS_FEEDS_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes +const DEFAULT_RECS_EXPIRE_TIME = 60 * 60 * 1000; // 1 hour +const MAX_LIFETIME_CAP = 500; // Guard against misconfiguration on the server +const FETCH_TIMEOUT = 45 * 1000; +const SPOCS_URL = "https://spocs.getpocket.com/spocs"; +const FEED_URL = + "https://getpocket.cdn.mozilla.net/v3/firefox/global-recs?version=3&consumer_key=$apiKey&locale_lang=$locale®ion=$region&count=30"; +const PREF_CONFIG = "discoverystream.config"; +const PREF_ENDPOINTS = "discoverystream.endpoints"; +const PREF_IMPRESSION_ID = "browser.newtabpage.activity-stream.impressionId"; +const PREF_ENABLED = "discoverystream.enabled"; +const PREF_HARDCODED_BASIC_LAYOUT = "discoverystream.hardcoded-basic-layout"; +const PREF_SPOCS_ENDPOINT = "discoverystream.spocs-endpoint"; +const PREF_SPOCS_ENDPOINT_QUERY = "discoverystream.spocs-endpoint-query"; +const PREF_REGION_BASIC_LAYOUT = "discoverystream.region-basic-layout"; +const PREF_USER_TOPSTORIES = "feeds.section.topstories"; +const PREF_SYSTEM_TOPSTORIES = "feeds.system.topstories"; +const PREF_USER_TOPSITES = "feeds.topsites"; +const PREF_SYSTEM_TOPSITES = "feeds.system.topsites"; +const PREF_SPOCS_CLEAR_ENDPOINT = "discoverystream.endpointSpocsClear"; +const PREF_SHOW_SPONSORED = "showSponsored"; +const PREF_SYSTEM_SHOW_SPONSORED = "system.showSponsored"; +const PREF_SHOW_SPONSORED_TOPSITES = "showSponsoredTopSites"; +// Nimbus variable to enable the SOV feature for sponsored tiles. +const NIMBUS_VARIABLE_CONTILE_SOV_ENABLED = "topSitesContileSovEnabled"; +const PREF_SPOC_IMPRESSIONS = "discoverystream.spoc.impressions"; +const PREF_FLIGHT_BLOCKS = "discoverystream.flight.blocks"; +const PREF_REC_IMPRESSIONS = "discoverystream.rec.impressions"; +const PREF_COLLECTIONS_ENABLED = + "discoverystream.sponsored-collections.enabled"; +const PREF_POCKET_BUTTON = "extensions.pocket.enabled"; +const PREF_COLLECTION_DISMISSIBLE = "discoverystream.isCollectionDismissible"; + +let getHardcodedLayout; + +export class DiscoveryStreamFeed { + constructor() { + // Internal state for checking if we've intialized all our data + this.loaded = false; + + // Persistent cache for remote endpoint data. + this.cache = new lazy.PersistentCache(CACHE_KEY, true); + this.locale = Services.locale.appLocaleAsBCP47; + this._impressionId = this.getOrCreateImpressionId(); + // Internal in-memory cache for parsing json prefs. + this._prefCache = {}; + } + + getOrCreateImpressionId() { + let impressionId = Services.prefs.getCharPref(PREF_IMPRESSION_ID, ""); + if (!impressionId) { + impressionId = String(Services.uuid.generateUUID()); + Services.prefs.setCharPref(PREF_IMPRESSION_ID, impressionId); + } + return impressionId; + } + + get config() { + if (this._prefCache.config) { + return this._prefCache.config; + } + try { + this._prefCache.config = JSON.parse( + this.store.getState().Prefs.values[PREF_CONFIG] + ); + } catch (e) { + // istanbul ignore next + this._prefCache.config = {}; + // istanbul ignore next + console.error( + `Could not parse preference. Try resetting ${PREF_CONFIG} in about:config.`, + e + ); + } + this._prefCache.config.enabled = + this._prefCache.config.enabled && + this.store.getState().Prefs.values[PREF_ENABLED]; + + return this._prefCache.config; + } + + resetConfigDefauts() { + this.store.dispatch({ + type: at.CLEAR_PREF, + data: { + name: PREF_CONFIG, + }, + }); + } + + get region() { + return lazy.Region.home; + } + + get isBff() { + if (this._isBff === undefined) { + const pocketConfig = + this.store.getState().Prefs.values?.pocketConfig || {}; + + const preffedRegionBffConfigString = pocketConfig.regionBffConfig || ""; + const preffedRegionBffConfig = preffedRegionBffConfigString + .split(",") + .map(s => s.trim()); + const regionBff = preffedRegionBffConfig.includes(this.region); + this._isBff = regionBff; + } + + return this._isBff; + } + + get showSpocs() { + // High level overall sponsored check, if one of these is true, + // we know we need some sort of spoc control setup. + return this.showSponsoredStories || this.showSponsoredTopsites; + } + + get showSponsoredStories() { + // Combine user-set sponsored opt-out with Mozilla-set config + return ( + this.store.getState().Prefs.values[PREF_SHOW_SPONSORED] && + this.store.getState().Prefs.values[PREF_SYSTEM_SHOW_SPONSORED] + ); + } + + get showSponsoredTopsites() { + const placements = this.getPlacements(); + // Combine user-set sponsored opt-out with placement data + return !!( + this.store.getState().Prefs.values[PREF_SHOW_SPONSORED_TOPSITES] && + placements.find(placement => placement.name === "sponsored-topsites") + ); + } + + get showStories() { + // Combine user-set stories opt-out with Mozilla-set config + return ( + this.store.getState().Prefs.values[PREF_SYSTEM_TOPSTORIES] && + this.store.getState().Prefs.values[PREF_USER_TOPSTORIES] + ); + } + + get showTopsites() { + // Combine user-set topsites opt-out with Mozilla-set config + return ( + this.store.getState().Prefs.values[PREF_SYSTEM_TOPSITES] && + this.store.getState().Prefs.values[PREF_USER_TOPSITES] + ); + } + + get personalized() { + return this.recommendationProvider.personalized; + } + + get recommendationProvider() { + if (this._recommendationProvider) { + return this._recommendationProvider; + } + this._recommendationProvider = this.store.feeds.get( + "feeds.recommendationprovider" + ); + return this._recommendationProvider; + } + + setupConfig(isStartup = false) { + // Send the initial state of the pref on our reducer + this.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_CONFIG_SETUP, + data: this.config, + meta: { + isStartup, + }, + }) + ); + } + + setupPrefs(isStartup = false) { + const pocketNewtabExperiment = lazy.ExperimentAPI.getExperimentMetaData({ + featureId: "pocketNewtab", + }); + + const pocketNewtabRollout = lazy.ExperimentAPI.getRolloutMetaData({ + featureId: "pocketNewtab", + }); + + // We want to know if the user is in an experiment or rollout, + // but we prioritize experiments over rollouts. + const experimentMetaData = pocketNewtabExperiment || pocketNewtabRollout; + + let utmSource = "pocket-newtab"; + let utmCampaign = experimentMetaData?.slug; + let utmContent = experimentMetaData?.branch?.slug; + + this.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_EXPERIMENT_DATA, + data: { + utmSource, + utmCampaign, + utmContent, + }, + meta: { + isStartup, + }, + }) + ); + + const pocketButtonEnabled = Services.prefs.getBoolPref(PREF_POCKET_BUTTON); + + const nimbusConfig = this.store.getState().Prefs.values?.pocketConfig || {}; + const { region } = this.store.getState().Prefs.values; + + this.setupSpocsCacheUpdateTime(); + const saveToPocketCardRegions = nimbusConfig.saveToPocketCardRegions + ?.split(",") + .map(s => s.trim()); + const saveToPocketCard = + pocketButtonEnabled && + (nimbusConfig.saveToPocketCard || + saveToPocketCardRegions?.includes(region)); + + const hideDescriptionsRegions = nimbusConfig.hideDescriptionsRegions + ?.split(",") + .map(s => s.trim()); + const hideDescriptions = + nimbusConfig.hideDescriptions || + hideDescriptionsRegions?.includes(region); + + // We don't BroadcastToContent for this, as the changes may + // shift around elements on an open newtab the user is currently reading. + // So instead we AlsoToPreloaded so the next tab is updated. + // This is because setupPrefs is called by the system and not a user interaction. + this.store.dispatch( + ac.AlsoToPreloaded({ + type: at.DISCOVERY_STREAM_PREFS_SETUP, + data: { + recentSavesEnabled: nimbusConfig.recentSavesEnabled, + pocketButtonEnabled, + saveToPocketCard, + hideDescriptions, + compactImages: nimbusConfig.compactImages, + imageGradient: nimbusConfig.imageGradient, + newSponsoredLabel: nimbusConfig.newSponsoredLabel, + titleLines: nimbusConfig.titleLines, + descLines: nimbusConfig.descLines, + readTime: nimbusConfig.readTime, + }, + meta: { + isStartup, + }, + }) + ); + + this.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_COLLECTION_DISMISSIBLE_TOGGLE, + data: { + value: + this.store.getState().Prefs.values[PREF_COLLECTION_DISMISSIBLE], + }, + meta: { + isStartup, + }, + }) + ); + } + + async setupPocketState(target) { + let dispatch = action => + this.store.dispatch(ac.OnlyToOneContent(action, target)); + const isUserLoggedIn = lazy.pktApi.isUserLoggedIn(); + dispatch({ + type: at.DISCOVERY_STREAM_POCKET_STATE_SET, + data: { + isUserLoggedIn, + }, + }); + + // If we're not logged in, don't bother fetching recent saves, we're done. + if (isUserLoggedIn) { + let recentSaves = await lazy.pktApi.getRecentSavesCache(); + if (recentSaves) { + // We have cache, so we can use those. + dispatch({ + type: at.DISCOVERY_STREAM_RECENT_SAVES, + data: { + recentSaves, + }, + }); + } else { + // We don't have cache, so fetch fresh stories. + lazy.pktApi.getRecentSaves({ + success(data) { + dispatch({ + type: at.DISCOVERY_STREAM_RECENT_SAVES, + data: { + recentSaves: data, + }, + }); + }, + error(error) {}, + }); + } + } + } + + uninitPrefs() { + // Reset in-memory cache + this._prefCache = {}; + } + + async fetchFromEndpoint(rawEndpoint, options = {}) { + if (!rawEndpoint) { + console.error("Tried to fetch endpoint but none was configured."); + return null; + } + + const apiKeyPref = this.config.api_key_pref; + const apiKey = Services.prefs.getCharPref(apiKeyPref, ""); + + const endpoint = rawEndpoint + .replace("$apiKey", apiKey) + .replace("$locale", this.locale) + .replace("$region", this.region); + + try { + // Make sure the requested endpoint is allowed + const allowed = this.store + .getState() + .Prefs.values[PREF_ENDPOINTS].split(","); + if (!allowed.some(prefix => endpoint.startsWith(prefix))) { + throw new Error(`Not one of allowed prefixes (${allowed})`); + } + + const controller = new AbortController(); + const { signal } = controller; + + const fetchPromise = fetch(endpoint, { + ...options, + credentials: "omit", + signal, + }); + // istanbul ignore next + const timeoutId = setTimeout(() => { + controller.abort(); + }, FETCH_TIMEOUT); + + const response = await fetchPromise; + if (!response.ok) { + throw new Error(`Unexpected status (${response.status})`); + } + clearTimeout(timeoutId); + + return response.json(); + } catch (error) { + console.error(`Failed to fetch ${endpoint}:`, error.message); + } + return null; + } + + get spocsCacheUpdateTime() { + if (this._spocsCacheUpdateTime) { + return this._spocsCacheUpdateTime; + } + this.setupSpocsCacheUpdateTime(); + return this._spocsCacheUpdateTime; + } + + setupSpocsCacheUpdateTime() { + const nimbusConfig = this.store.getState().Prefs.values?.pocketConfig || {}; + const { spocsCacheTimeout } = nimbusConfig; + const MAX_TIMEOUT = 30; + const MIN_TIMEOUT = 5; + // We do a bit of min max checking the the configured value is between + // 5 and 30 minutes, to protect against unreasonable values. + if ( + spocsCacheTimeout && + spocsCacheTimeout <= MAX_TIMEOUT && + spocsCacheTimeout >= MIN_TIMEOUT + ) { + // This value is in minutes, but we want ms. + this._spocsCacheUpdateTime = spocsCacheTimeout * 60 * 1000; + } else { + // The const is already in ms. + this._spocsCacheUpdateTime = SPOCS_FEEDS_UPDATE_TIME; + } + } + + /** + * Returns true if data in the cache for a particular key has expired or is missing. + * @param {object} cachedData data returned from cache.get() + * @param {string} key a cache key + * @param {string?} url for "feed" only, the URL of the feed. + * @param {boolean} is this check done at initial browser load + */ + isExpired({ cachedData, key, url, isStartup }) { + const { spocs, feeds } = cachedData; + const updateTimePerComponent = { + spocs: this.spocsCacheUpdateTime, + feed: COMPONENT_FEEDS_UPDATE_TIME, + }; + const EXPIRATION_TIME = isStartup + ? STARTUP_CACHE_EXPIRE_TIME + : updateTimePerComponent[key]; + switch (key) { + case "spocs": + return !spocs || !(Date.now() - spocs.lastUpdated < EXPIRATION_TIME); + case "feed": + return ( + !feeds || + !feeds[url] || + !(Date.now() - feeds[url].lastUpdated < EXPIRATION_TIME) + ); + default: + // istanbul ignore next + throw new Error(`${key} is not a valid key`); + } + } + + async _checkExpirationPerComponent() { + const cachedData = (await this.cache.get()) || {}; + const { feeds } = cachedData; + return { + spocs: this.showSpocs && this.isExpired({ cachedData, key: "spocs" }), + feeds: + this.showStories && + (!feeds || + Object.keys(feeds).some(url => + this.isExpired({ cachedData, key: "feed", url }) + )), + }; + } + + /** + * Returns true if any data for the cached endpoints has expired or is missing. + */ + async checkIfAnyCacheExpired() { + const expirationPerComponent = await this._checkExpirationPerComponent(); + return expirationPerComponent.spocs || expirationPerComponent.feeds; + } + + updatePlacements(sendUpdate, layout, isStartup = false) { + const placements = []; + const placementsMap = {}; + for (const row of layout.filter(r => r.components && r.components.length)) { + for (const component of row.components.filter( + c => c.placement && c.spocs + )) { + // If we find a valid placement, we set it to this value. + let placement; + + // We need to check to see if this placement is on or not. + // If this placement has a prefs array, check against that. + if (component.spocs.prefs) { + // Check every pref in the array to see if this placement is turned on. + if ( + component.spocs.prefs.length && + component.spocs.prefs.every( + p => this.store.getState().Prefs.values[p] + ) + ) { + // This placement is on. + placement = component.placement; + } + } else if (this.showSponsoredStories) { + // If we do not have a prefs array, use old check. + // This is because Pocket spocs uses an old non pref method. + placement = component.placement; + } + + // Validate this placement and check for dupes. + if (placement?.name && !placementsMap[placement.name]) { + placementsMap[placement.name] = placement; + placements.push(placement); + } + } + } + + // Update placements data. + // Even if we have no placements, we still want to update it to clear it. + sendUpdate({ + type: at.DISCOVERY_STREAM_SPOCS_PLACEMENTS, + data: { placements }, + meta: { + isStartup, + }, + }); + } + + /** + * Adds a query string to a URL. + * A query can be any string literal accepted by https://developer.mozilla.org/docs/Web/API/URLSearchParams + * Examples: "?foo=1&bar=2", "&foo=1&bar=2", "foo=1&bar=2", "?bar=2" or "bar=2" + */ + addEndpointQuery(url, query) { + if (!query) { + return url; + } + + const urlObject = new URL(url); + const params = new URLSearchParams(query); + + for (let [key, val] of params.entries()) { + urlObject.searchParams.append(key, val); + } + + return urlObject.toString(); + } + + parseGridPositions(csvPositions) { + let gridPositions; + + // Only accept parseable non-negative integers + try { + gridPositions = csvPositions.map(index => { + let parsedInt = parseInt(index, 10); + + if (!isNaN(parsedInt) && parsedInt >= 0) { + return parsedInt; + } + + throw new Error("Bad input"); + }); + } catch (e) { + // Catch spoc positions that are not numbers or negative, and do nothing. + // We have hard coded backup positions. + gridPositions = undefined; + } + + return gridPositions; + } + + generateFeedUrl(isBff) { + if (isBff) { + return `https://${lazy.NimbusFeatures.saveToPocket.getVariable( + "bffApi" + )}/desktop/v1/recommendations?locale=$locale®ion=$region&count=30`; + } + return FEED_URL; + } + + loadLayout(sendUpdate, isStartup) { + let layoutData = {}; + let url = ""; + + const isBasicLayout = + this.config.hardcoded_basic_layout || + this.store.getState().Prefs.values[PREF_HARDCODED_BASIC_LAYOUT] || + this.store.getState().Prefs.values[PREF_REGION_BASIC_LAYOUT]; + + const sponsoredCollectionsEnabled = + this.store.getState().Prefs.values[PREF_COLLECTIONS_ENABLED]; + + const pocketConfig = this.store.getState().Prefs.values?.pocketConfig || {}; + const onboardingExperience = + this.isBff && pocketConfig.onboardingExperience; + const { spocTopsitesPlacementEnabled } = pocketConfig; + + let items = isBasicLayout ? 3 : 21; + if (pocketConfig.fourCardLayout || pocketConfig.hybridLayout) { + items = isBasicLayout ? 4 : 24; + } + + const ctaButtonSponsors = pocketConfig.ctaButtonSponsors + ?.split(",") + .map(s => s.trim().toLowerCase()); + let ctaButtonVariant = ""; + // We specifically against hard coded values, instead of applying whatever is in the pref. + // This is to ensure random class names from a user modified pref doesn't make it into the class list. + if ( + pocketConfig.ctaButtonVariant === "variant-a" || + pocketConfig.ctaButtonVariant === "variant-b" + ) { + ctaButtonVariant = pocketConfig.ctaButtonVariant; + } + + const prepConfArr = arr => { + return arr + ?.split(",") + .filter(item => item) + .map(item => parseInt(item, 10)); + }; + + const spocAdTypes = prepConfArr(pocketConfig.spocAdTypes); + const spocZoneIds = prepConfArr(pocketConfig.spocZoneIds); + const spocTopsitesAdTypes = prepConfArr(pocketConfig.spocTopsitesAdTypes); + const spocTopsitesZoneIds = prepConfArr(pocketConfig.spocTopsitesZoneIds); + const { spocSiteId } = pocketConfig; + let spocPlacementData; + let spocTopsitesPlacementData; + let spocsUrl; + + if (spocAdTypes?.length && spocZoneIds?.length) { + spocPlacementData = { + ad_types: spocAdTypes, + zone_ids: spocZoneIds, + }; + } + + if (spocTopsitesAdTypes?.length && spocTopsitesZoneIds?.length) { + spocTopsitesPlacementData = { + ad_types: spocTopsitesAdTypes, + zone_ids: spocTopsitesZoneIds, + }; + } + + if (spocSiteId) { + const newUrl = new URL(SPOCS_URL); + newUrl.searchParams.set("site", spocSiteId); + spocsUrl = newUrl.href; + } + + let feedUrl = this.generateFeedUrl(this.isBff); + + // Set layout config. + // Changing values in this layout in memory object is unnecessary. + layoutData = getHardcodedLayout({ + spocsUrl, + feedUrl, + items, + sponsoredCollectionsEnabled, + spocPlacementData, + spocTopsitesPlacementEnabled, + spocTopsitesPlacementData, + spocPositions: this.parseGridPositions( + pocketConfig.spocPositions?.split(`,`) + ), + spocTopsitesPositions: this.parseGridPositions( + pocketConfig.spocTopsitesPositions?.split(`,`) + ), + widgetPositions: this.parseGridPositions( + pocketConfig.widgetPositions?.split(`,`) + ), + widgetData: [ + ...(this.locale.startsWith("en-") ? [{ type: "TopicsWidget" }] : []), + ], + hybridLayout: pocketConfig.hybridLayout, + hideCardBackground: pocketConfig.hideCardBackground, + fourCardLayout: pocketConfig.fourCardLayout, + newFooterSection: pocketConfig.newFooterSection, + compactGrid: pocketConfig.compactGrid, + // For now essentialReadsHeader and editorsPicksHeader are English only. + essentialReadsHeader: + this.locale.startsWith("en-") && pocketConfig.essentialReadsHeader, + editorsPicksHeader: + this.locale.startsWith("en-") && pocketConfig.editorsPicksHeader, + onboardingExperience, + // For now button variants are for experimentation and English only. + ctaButtonSponsors: this.locale.startsWith("en-") ? ctaButtonSponsors : [], + ctaButtonVariant: this.locale.startsWith("en-") ? ctaButtonVariant : "", + }); + + sendUpdate({ + type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, + data: layoutData, + meta: { + isStartup, + }, + }); + + if (layoutData.spocs) { + url = + this.store.getState().Prefs.values[PREF_SPOCS_ENDPOINT] || + this.config.spocs_endpoint || + layoutData.spocs.url; + + const spocsEndpointQuery = + this.store.getState().Prefs.values[PREF_SPOCS_ENDPOINT_QUERY]; + + // For QA, testing, or debugging purposes, there may be a query string to add. + url = this.addEndpointQuery(url, spocsEndpointQuery); + + if ( + url && + url !== this.store.getState().DiscoveryStream.spocs.spocs_endpoint + ) { + sendUpdate({ + type: at.DISCOVERY_STREAM_SPOCS_ENDPOINT, + data: { + url, + }, + meta: { + isStartup, + }, + }); + this.updatePlacements(sendUpdate, layoutData.layout, isStartup); + } + } + } + + /** + * buildFeedPromise - Adds the promise result to newFeeds and + * pushes a promise to newsFeedsPromises. + * @param {Object} Has both newFeedsPromises (Array) and newFeeds (Object) + * @param {Boolean} isStartup We have different cache handling for startup. + * @returns {Function} We return a function so we can contain + * the scope for isStartup and the promises object. + * Combines feed results and promises for each component with a feed. + */ + buildFeedPromise( + { newFeedsPromises, newFeeds }, + isStartup = false, + sendUpdate + ) { + return component => { + const { url } = component.feed; + + if (!newFeeds[url]) { + // We initially stub this out so we don't fetch dupes, + // we then fill in with the proper object inside the promise. + newFeeds[url] = {}; + const feedPromise = this.getComponentFeed(url, isStartup); + + feedPromise + .then(feed => { + // If we stored the result of filter in feed cache as it happened, + // I think we could reduce doing this for cache fetches. + // Bug https://bugzilla.mozilla.org/show_bug.cgi?id=1606277 + newFeeds[url] = this.filterRecommendations(feed); + sendUpdate({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { + feed: newFeeds[url], + url, + }, + meta: { + isStartup, + }, + }); + }) + .catch( + /* istanbul ignore next */ error => { + console.error( + `Error trying to load component feed ${url}:`, + error + ); + } + ); + newFeedsPromises.push(feedPromise); + } + }; + } + + filterRecommendations(feed) { + if ( + feed && + feed.data && + feed.data.recommendations && + feed.data.recommendations.length + ) { + const { data: recommendations } = this.filterBlocked( + feed.data.recommendations + ); + return { + ...feed, + data: { + ...feed.data, + recommendations, + }, + }; + } + return feed; + } + + /** + * reduceFeedComponents - Filters out components with no feeds, and combines + * all feeds on this component with the feeds from other components. + * @param {Boolean} isStartup We have different cache handling for startup. + * @returns {Function} We return a function so we can contain the scope for isStartup. + * Reduces feeds into promises and feed data. + */ + reduceFeedComponents(isStartup, sendUpdate) { + return (accumulator, row) => { + row.components + .filter(component => component && component.feed) + .forEach(this.buildFeedPromise(accumulator, isStartup, sendUpdate)); + return accumulator; + }; + } + + /** + * buildFeedPromises - Filters out rows with no components, + * and gets us a promise for each unique feed. + * @param {Object} layout This is the Discovery Stream layout object. + * @param {Boolean} isStartup We have different cache handling for startup. + * @returns {Object} An object with newFeedsPromises (Array) and newFeeds (Object), + * we can Promise.all newFeedsPromises to get completed data in newFeeds. + */ + buildFeedPromises(layout, isStartup, sendUpdate) { + const initialData = { + newFeedsPromises: [], + newFeeds: {}, + }; + return layout + .filter(row => row && row.components) + .reduce(this.reduceFeedComponents(isStartup, sendUpdate), initialData); + } + + async loadComponentFeeds(sendUpdate, isStartup = false) { + const { DiscoveryStream } = this.store.getState(); + + if (!DiscoveryStream || !DiscoveryStream.layout) { + return; + } + + // Reset the flag that indicates whether or not at least one API request + // was issued to fetch the component feed in `getComponentFeed()`. + this.componentFeedFetched = false; + const { newFeedsPromises, newFeeds } = this.buildFeedPromises( + DiscoveryStream.layout, + isStartup, + sendUpdate + ); + + // Each promise has a catch already built in, so no need to catch here. + await Promise.all(newFeedsPromises); + + if (this.componentFeedFetched) { + this.cleanUpTopRecImpressionPref(newFeeds); + } + await this.cache.set("feeds", newFeeds); + sendUpdate({ + type: at.DISCOVERY_STREAM_FEEDS_UPDATE, + meta: { + isStartup, + }, + }); + } + + getPlacements() { + const { placements } = this.store.getState().DiscoveryStream.spocs; + return placements; + } + + // I wonder, can this be better as a reducer? + // See Bug https://bugzilla.mozilla.org/show_bug.cgi?id=1606717 + placementsForEach(callback) { + this.getPlacements().forEach(callback); + } + + // Bug 1567271 introduced meta data on a list of spocs. + // This involved moving the spocs array into an items prop. + // However, old data could still be returned, and cached data might also be old. + // For ths reason, we want to ensure if we don't find an items array, + // we use the previous array placement, and then stub out title and context to empty strings. + // We need to do this *after* both fresh fetches and cached data to reduce repetition. + normalizeSpocsItems(spocs) { + const items = spocs.items || spocs; + const title = spocs.title || ""; + const context = spocs.context || ""; + const sponsor = spocs.sponsor || ""; + // We do not stub sponsored_by_override with an empty string. It is an override, and an empty string + // explicitly means to override the client to display an empty string. + // An empty string is not an no op in this case. Undefined is the proper no op here. + const { sponsored_by_override } = spocs; + // Undefined is fine here. It's optional and only used by collections. + // If we leave it out, you get a collection that cannot be dismissed. + const { flight_id } = spocs; + return { + items, + title, + context, + sponsor, + sponsored_by_override, + ...(flight_id ? { flight_id } : {}), + }; + } + + updateSponsoredCollectionsPref(collectionEnabled = false) { + const currentState = + this.store.getState().Prefs.values[PREF_COLLECTIONS_ENABLED]; + + // If the current state does not match the new state, update the pref. + if (currentState !== collectionEnabled) { + this.store.dispatch( + ac.SetPref(PREF_COLLECTIONS_ENABLED, collectionEnabled) + ); + } + } + + async loadSpocs(sendUpdate, isStartup) { + const cachedData = (await this.cache.get()) || {}; + let spocsState = cachedData.spocs; + let placements = this.getPlacements(); + + if ( + this.showSpocs && + placements?.length && + this.isExpired({ cachedData, key: "spocs", isStartup }) + ) { + // We optimistically set this to true, because if SOV is not ready, we fetch them. + let useTopsitesPlacement = true; + + // If SOV is turned off or not available, we optimistically fetch sponsored topsites. + if ( + lazy.NimbusFeatures.pocketNewtab.getVariable( + NIMBUS_VARIABLE_CONTILE_SOV_ENABLED + ) + ) { + let { positions, ready } = this.store.getState().TopSites.sov; + if (ready) { + // We don't need to await here, because we don't need it now. + this.cache.set("sov", positions); + } else { + // If SOV is not available, and there is a SOV cache, use it. + positions = cachedData.sov; + } + + if (positions?.length) { + // If SOV is ready and turned on, we can check if we need moz-sales position. + useTopsitesPlacement = positions.some( + allocation => allocation.assignedPartner === "moz-sales" + ); + } + } + + // We can filter out the topsite placement from the fetch. + if (!useTopsitesPlacement) { + placements = placements.filter( + placement => placement.name !== "sponsored-topsites" + ); + } + + if (placements?.length) { + const endpoint = + this.store.getState().DiscoveryStream.spocs.spocs_endpoint; + + const headers = new Headers(); + headers.append("content-type", "application/json"); + + const apiKeyPref = this.config.api_key_pref; + const apiKey = Services.prefs.getCharPref(apiKeyPref, ""); + + const spocsResponse = await this.fetchFromEndpoint(endpoint, { + method: "POST", + headers, + body: JSON.stringify({ + pocket_id: this._impressionId, + version: 2, + consumer_key: apiKey, + ...(placements.length ? { placements } : {}), + }), + }); + + if (spocsResponse) { + spocsState = { + lastUpdated: Date.now(), + spocs: { + ...spocsResponse, + }, + }; + + if (spocsResponse.settings && spocsResponse.settings.feature_flags) { + this.store.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_OVERRIDE, + data: { + override: !spocsResponse.settings.feature_flags.spoc_v2, + }, + }) + ); + this.updateSponsoredCollectionsPref( + spocsResponse.settings.feature_flags.collections + ); + } + + const spocsResultPromises = this.getPlacements().map( + async placement => { + const freshSpocs = spocsState.spocs[placement.name]; + + if (!freshSpocs) { + return; + } + + // spocs can be returns as an array, or an object with an items array. + // We want to normalize this so all our spocs have an items array. + // There can also be some meta data for title and context. + // This is mostly because of backwards compat. + const { + items: normalizedSpocsItems, + title, + context, + sponsor, + sponsored_by_override, + } = this.normalizeSpocsItems(freshSpocs); + + if (!normalizedSpocsItems || !normalizedSpocsItems.length) { + // In the case of old data, we still want to ensure we normalize the data structure, + // even if it's empty. We expect the empty data to be an object with items array, + // and not just an empty array. + spocsState.spocs = { + ...spocsState.spocs, + [placement.name]: { + title, + context, + items: [], + }, + }; + return; + } + + // Migrate flight_id + const { data: migratedSpocs } = + this.migrateFlightId(normalizedSpocsItems); + + const { data: capResult } = this.frequencyCapSpocs(migratedSpocs); + + const { data: blockedResults } = this.filterBlocked(capResult); + + const { data: scoredResults, personalized } = + await this.scoreItems(blockedResults, "spocs"); + + spocsState.spocs = { + ...spocsState.spocs, + [placement.name]: { + title, + context, + sponsor, + sponsored_by_override, + personalized, + items: scoredResults, + }, + }; + } + ); + await Promise.all(spocsResultPromises); + + this.cleanUpFlightImpressionPref(spocsState.spocs); + } else { + console.error("No response for spocs_endpoint prop"); + } + } + } + + // Use good data if we have it, otherwise nothing. + // We can have no data if spocs set to off. + // We can have no data if request fails and there is no good cache. + // We want to send an update spocs or not, so client can render something. + spocsState = + spocsState && spocsState.spocs + ? spocsState + : { + lastUpdated: Date.now(), + spocs: {}, + }; + await this.cache.set("spocs", { + lastUpdated: spocsState.lastUpdated, + spocs: spocsState.spocs, + }); + + sendUpdate({ + type: at.DISCOVERY_STREAM_SPOCS_UPDATE, + data: { + lastUpdated: spocsState.lastUpdated, + spocs: spocsState.spocs, + }, + meta: { + isStartup, + }, + }); + } + + async clearSpocs() { + const endpoint = + this.store.getState().Prefs.values[PREF_SPOCS_CLEAR_ENDPOINT]; + if (!endpoint) { + return; + } + const headers = new Headers(); + headers.append("content-type", "application/json"); + + await this.fetchFromEndpoint(endpoint, { + method: "DELETE", + headers, + body: JSON.stringify({ + pocket_id: this._impressionId, + }), + }); + } + + observe(subject, topic, data) { + switch (topic) { + case "nsPref:changed": + // If the Pocket button was turned on or off, we need to update the cards + // because cards show menu options for the Pocket button that need to be removed. + if (data === PREF_POCKET_BUTTON) { + this.configReset(); + } + break; + } + } + + /* + * This function is used to sort any type of story, both spocs and recs. + * This uses hierarchical sorting, first sorting by priority, then by score within a priority. + * This function could be sorting an array of spocs or an array of recs. + * A rec would have priority undefined, and a spoc would probably have a priority set. + * Priority is sorted ascending, so low numbers are the highest priority. + * Score is sorted descending, so high numbers are the highest score. + * Undefined priority values are considered the lowest priority. + * A negative priority is considered the same as undefined, lowest priority. + * A negative priority is unlikely and not currently supported or expected. + * A negative score is a possible use case. + */ + sortItem(a, b) { + // If the priorities are the same, sort based on score. + // If both item priorities are undefined, + // we can safely sort via score. + if (a.priority === b.priority) { + return b.score - a.score; + } else if (!a.priority || a.priority <= 0) { + // If priority is undefined or an unexpected value, + // consider it lowest priority. + return 1; + } else if (!b.priority || b.priority <= 0) { + // Also consider this case lowest priority. + return -1; + } + // Our primary sort for items with priority. + return a.priority - b.priority; + } + + async scoreItems(items, type) { + const spocsPersonalized = + this.store.getState().Prefs.values?.pocketConfig?.spocsPersonalized; + const recsPersonalized = + this.store.getState().Prefs.values?.pocketConfig?.recsPersonalized; + const personalizedByType = + type === "feed" ? recsPersonalized : spocsPersonalized; + // If this is initialized, we are ready to go. + const personalized = this.store.getState().Personalization.initialized; + + const data = ( + await Promise.all( + items.map(item => this.scoreItem(item, personalizedByType)) + ) + ) + // Sort by highest scores. + .sort(this.sortItem); + + return { data, personalized }; + } + + async scoreItem(item, personalizedByType) { + item.score = item.item_score; + if (item.score !== 0 && !item.score) { + item.score = 1; + } + if (this.personalized && personalizedByType) { + await this.recommendationProvider.calculateItemRelevanceScore(item); + } + return item; + } + + filterBlocked(data) { + if (data && data.length) { + let flights = this.readDataPref(PREF_FLIGHT_BLOCKS); + const filteredItems = data.filter(item => { + const blocked = + lazy.NewTabUtils.blockedLinks.isBlocked({ url: item.url }) || + flights[item.flight_id]; + return !blocked; + }); + return { data: filteredItems }; + } + return { data }; + } + + // For backwards compatibility, older spoc endpoint don't have flight_id, + // but instead had campaign_id we can use + // + // @param {Object} data An object that might have a SPOCS array. + // @returns {Object} An object with a property `data` as the result. + migrateFlightId(spocs) { + if (spocs && spocs.length) { + return { + data: spocs.map(s => { + return { + ...s, + ...(s.flight_id || s.campaign_id + ? { + flight_id: s.flight_id || s.campaign_id, + } + : {}), + ...(s.caps + ? { + caps: { + ...s.caps, + flight: s.caps.flight || s.caps.campaign, + }, + } + : {}), + }; + }), + }; + } + return { data: spocs }; + } + + // Filter spocs based on frequency caps + // + // @param {Object} data An object that might have a SPOCS array. + // @returns {Object} An object with a property `data` as the result, and a property + // `filterItems` as the frequency capped items. + frequencyCapSpocs(spocs) { + if (spocs && spocs.length) { + const impressions = this.readDataPref(PREF_SPOC_IMPRESSIONS); + const caps = []; + const result = spocs.filter(s => { + const isBelow = this.isBelowFrequencyCap(impressions, s); + if (!isBelow) { + caps.push(s); + } + return isBelow; + }); + // send caps to redux if any. + if (caps.length) { + this.store.dispatch({ + type: at.DISCOVERY_STREAM_SPOCS_CAPS, + data: caps, + }); + } + return { data: result, filtered: caps }; + } + return { data: spocs, filtered: [] }; + } + + // Frequency caps are based on flight, which may include multiple spocs. + // We currently support two types of frequency caps: + // - lifetime: Indicates how many times spocs from a flight can be shown in total + // - period: Indicates how many times spocs from a flight can be shown within a period + // + // So, for example, the feed configuration below defines that for flight 1 no more + // than 5 spocs can be shown in total, and no more than 2 per hour. + // "flight_id": 1, + // "caps": { + // "lifetime": 5, + // "flight": { + // "count": 2, + // "period": 3600 + // } + // } + isBelowFrequencyCap(impressions, spoc) { + const flightImpressions = impressions[spoc.flight_id]; + if (!flightImpressions) { + return true; + } + + const lifetime = spoc.caps && spoc.caps.lifetime; + + const lifeTimeCap = Math.min( + lifetime || MAX_LIFETIME_CAP, + MAX_LIFETIME_CAP + ); + const lifeTimeCapExceeded = flightImpressions.length >= lifeTimeCap; + if (lifeTimeCapExceeded) { + return false; + } + + const flightCap = spoc.caps && spoc.caps.flight; + if (flightCap) { + const flightCapExceeded = + flightImpressions.filter(i => Date.now() - i < flightCap.period * 1000) + .length >= flightCap.count; + return !flightCapExceeded; + } + return true; + } + + async retryFeed(feed) { + const { url } = feed; + const result = await this.getComponentFeed(url); + const newFeed = this.filterRecommendations(result); + this.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { + feed: newFeed, + url, + }, + }) + ); + } + + async getComponentFeed(feedUrl, isStartup) { + const cachedData = (await this.cache.get()) || {}; + const { feeds } = cachedData; + + let feed = feeds ? feeds[feedUrl] : null; + if (this.isExpired({ cachedData, key: "feed", url: feedUrl, isStartup })) { + let options = {}; + if (this.isBff) { + const headers = new Headers(); + const oAuthConsumerKey = lazy.NimbusFeatures.saveToPocket.getVariable( + "oAuthConsumerKeyBff" + ); + headers.append("consumer_key", oAuthConsumerKey); + options = { + method: "GET", + headers, + }; + } + + const feedResponse = await this.fetchFromEndpoint(feedUrl, options); + if (feedResponse) { + const { settings = {} } = feedResponse; + let { recommendations } = feedResponse; + if (this.isBff) { + recommendations = feedResponse.data.map(item => ({ + id: item.tileId, + url: item.url, + title: item.title, + excerpt: item.excerpt, + publisher: item.publisher, + time_to_read: item.timeToRead, + raw_image_src: item.imageUrl, + recommendation_id: item.recommendationId, + })); + } + const { data: scoredItems, personalized } = await this.scoreItems( + recommendations, + "feed" + ); + const { recsExpireTime } = settings; + const rotatedItems = this.rotate(scoredItems, recsExpireTime); + this.componentFeedFetched = true; + feed = { + lastUpdated: Date.now(), + personalized, + data: { + settings, + recommendations: rotatedItems, + status: "success", + }, + }; + } else { + console.error("No response for feed"); + } + } + + // If we have no feed at this point, both fetch and cache failed for some reason. + return ( + feed || { + data: { + status: "failed", + }, + } + ); + } + + /** + * Called at startup to update cached data in the background. + */ + async _maybeUpdateCachedData() { + const expirationPerComponent = await this._checkExpirationPerComponent(); + // Pass in `store.dispatch` to send the updates only to main + if (expirationPerComponent.spocs) { + await this.loadSpocs(this.store.dispatch); + } + if (expirationPerComponent.feeds) { + await this.loadComponentFeeds(this.store.dispatch); + } + } + + async scoreFeeds(feedsState) { + if (feedsState.data) { + const feeds = {}; + const feedsPromises = Object.keys(feedsState.data).map(url => { + let feed = feedsState.data[url]; + if (feed.personalized) { + // Feed was previously personalized then cached, we don't need to do this again. + return Promise.resolve(); + } + const feedPromise = this.scoreItems(feed.data.recommendations, "feed"); + feedPromise.then(({ data: scoredItems, personalized }) => { + const { recsExpireTime } = feed.data.settings; + const recommendations = this.rotate(scoredItems, recsExpireTime); + feed = { + ...feed, + personalized, + data: { + ...feed.data, + recommendations, + }, + }; + + feeds[url] = feed; + + this.store.dispatch( + ac.AlsoToPreloaded({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { + feed, + url, + }, + }) + ); + }); + return feedPromise; + }); + await Promise.all(feedsPromises); + await this.cache.set("feeds", feeds); + } + } + + async scoreSpocs(spocsState) { + const spocsResultPromises = this.getPlacements().map(async placement => { + const nextSpocs = spocsState.data[placement.name] || {}; + const { items } = nextSpocs; + + if (nextSpocs.personalized || !items || !items.length) { + return; + } + + const { data: scoreResult, personalized } = await this.scoreItems( + items, + "spocs" + ); + + spocsState.data = { + ...spocsState.data, + [placement.name]: { + ...nextSpocs, + personalized, + items: scoreResult, + }, + }; + }); + await Promise.all(spocsResultPromises); + + // Update cache here so we don't need to re calculate scores on loads from cache. + // Related Bug 1606276 + await this.cache.set("spocs", { + lastUpdated: spocsState.lastUpdated, + spocs: spocsState.data, + }); + this.store.dispatch( + ac.AlsoToPreloaded({ + type: at.DISCOVERY_STREAM_SPOCS_UPDATE, + data: { + lastUpdated: spocsState.lastUpdated, + spocs: spocsState.data, + }, + }) + ); + } + + /** + * @typedef {Object} RefreshAll + * @property {boolean} updateOpenTabs - Sends updates to open tabs immediately if true, + * updates in background if false + * @property {boolean} isStartup - When the function is called at browser startup + * + * Refreshes component feeds, and spocs in order if caches have expired. + * @param {RefreshAll} options + */ + async refreshAll(options = {}) { + const { updateOpenTabs, isStartup } = options; + + const dispatch = updateOpenTabs + ? action => this.store.dispatch(ac.BroadcastToContent(action)) + : this.store.dispatch; + + this.loadLayout(dispatch, isStartup); + if (this.showStories || this.showTopsites) { + const promises = []; + // We could potentially have either or both sponsored topsites or stories. + // We only make one fetch, and control which to request when we fetch. + // So for now we only care if we need to make this request at all. + const spocsPromise = this.loadSpocs(dispatch, isStartup).catch(error => + console.error("Error trying to load spocs feeds:", error) + ); + promises.push(spocsPromise); + if (this.showStories) { + const storiesPromise = this.loadComponentFeeds( + dispatch, + isStartup + ).catch(error => + console.error("Error trying to load component feeds:", error) + ); + promises.push(storiesPromise); + } + await Promise.all(promises); + if (isStartup) { + // We don't pass isStartup in _maybeUpdateCachedData on purpose, + // because startup loads have a longer cache timer, + // and we want this to update in the background sooner. + await this._maybeUpdateCachedData(); + } + } + } + + // We have to rotate stories on the client so that + // active stories are at the front of the list, followed by stories that have expired + // impressions i.e. have been displayed for longer than recsExpireTime. + rotate(recommendations, recsExpireTime) { + const maxImpressionAge = Math.max( + recsExpireTime * 1000 || DEFAULT_RECS_EXPIRE_TIME, + DEFAULT_RECS_EXPIRE_TIME + ); + const impressions = this.readDataPref(PREF_REC_IMPRESSIONS); + const expired = []; + const active = []; + for (const item of recommendations) { + if ( + impressions[item.id] && + Date.now() - impressions[item.id] >= maxImpressionAge + ) { + expired.push(item); + } else { + active.push(item); + } + } + return active.concat(expired); + } + + enableStories() { + if (this.config.enabled) { + // If stories are being re enabled, ensure we have stories. + this.refreshAll({ updateOpenTabs: true }); + } + } + + async enable(options = {}) { + await this.refreshAll(options); + this.loaded = true; + } + + async reset() { + this.resetDataPrefs(); + await this.resetCache(); + this.resetState(); + } + + async resetCache() { + await this.resetAllCache(); + } + + async resetContentCache() { + await this.cache.set("feeds", {}); + await this.cache.set("spocs", {}); + await this.cache.set("sov", {}); + } + + async resetAllCache() { + await this.resetContentCache(); + // Reset in-memory caches. + this._isBff = undefined; + this._spocsCacheUpdateTime = undefined; + } + + resetDataPrefs() { + this.writeDataPref(PREF_SPOC_IMPRESSIONS, {}); + this.writeDataPref(PREF_REC_IMPRESSIONS, {}); + this.writeDataPref(PREF_FLIGHT_BLOCKS, {}); + } + + resetState() { + // Reset reducer + this.store.dispatch( + ac.BroadcastToContent({ type: at.DISCOVERY_STREAM_LAYOUT_RESET }) + ); + this.setupPrefs(false /* isStartup */); + this.loaded = false; + } + + async onPrefChange() { + // We always want to clear the cache/state if the pref has changed + await this.reset(); + if (this.config.enabled) { + // Load data from all endpoints + await this.enable({ updateOpenTabs: true }); + } + } + + // This is a request to change the config from somewhere. + // Can be from a specific pref related to Discovery Stream, + // or can be a generic request from an external feed that + // something changed. + configReset() { + this._prefCache.config = null; + this.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_CONFIG_CHANGE, + data: this.config, + }) + ); + } + + recordFlightImpression(flightId) { + let impressions = this.readDataPref(PREF_SPOC_IMPRESSIONS); + + const timeStamps = impressions[flightId] || []; + timeStamps.push(Date.now()); + impressions = { ...impressions, [flightId]: timeStamps }; + + this.writeDataPref(PREF_SPOC_IMPRESSIONS, impressions); + } + + recordTopRecImpressions(recId) { + let impressions = this.readDataPref(PREF_REC_IMPRESSIONS); + if (!impressions[recId]) { + impressions = { ...impressions, [recId]: Date.now() }; + this.writeDataPref(PREF_REC_IMPRESSIONS, impressions); + } + } + + recordBlockFlightId(flightId) { + const flights = this.readDataPref(PREF_FLIGHT_BLOCKS); + if (!flights[flightId]) { + flights[flightId] = 1; + this.writeDataPref(PREF_FLIGHT_BLOCKS, flights); + } + } + + cleanUpFlightImpressionPref(data) { + let flightIds = []; + this.placementsForEach(placement => { + const newSpocs = data[placement.name]; + if (!newSpocs) { + return; + } + + const items = newSpocs.items || []; + flightIds = [...flightIds, ...items.map(s => `${s.flight_id}`)]; + }); + if (flightIds && flightIds.length) { + this.cleanUpImpressionPref( + id => !flightIds.includes(id), + PREF_SPOC_IMPRESSIONS + ); + } + } + + // Clean up rec impression pref by removing all stories that are no + // longer part of the response. + cleanUpTopRecImpressionPref(newFeeds) { + // Need to build a single list of stories. + const activeStories = Object.keys(newFeeds) + .filter(currentValue => newFeeds[currentValue].data) + .reduce((accumulator, currentValue) => { + const { recommendations } = newFeeds[currentValue].data; + return accumulator.concat(recommendations.map(i => `${i.id}`)); + }, []); + this.cleanUpImpressionPref( + id => !activeStories.includes(id), + PREF_REC_IMPRESSIONS + ); + } + + writeDataPref(pref, impressions) { + this.store.dispatch(ac.SetPref(pref, JSON.stringify(impressions))); + } + + readDataPref(pref) { + const prefVal = this.store.getState().Prefs.values[pref]; + return prefVal ? JSON.parse(prefVal) : {}; + } + + cleanUpImpressionPref(isExpired, pref) { + const impressions = this.readDataPref(pref); + let changed = false; + + Object.keys(impressions).forEach(id => { + if (isExpired(id)) { + changed = true; + delete impressions[id]; + } + }); + + if (changed) { + this.writeDataPref(pref, impressions); + } + } + + onCollectionsChanged() { + // Update layout, and reload any off screen tabs. + // This does not change any existing open tabs. + // It also doesn't update any spoc or rec data, just the layout. + const dispatch = action => this.store.dispatch(ac.AlsoToPreloaded(action)); + this.loadLayout(dispatch, false); + } + + async onPrefChangedAction(action) { + switch (action.data.name) { + case PREF_CONFIG: + case PREF_ENABLED: + case PREF_HARDCODED_BASIC_LAYOUT: + case PREF_SPOCS_ENDPOINT: + case PREF_SPOCS_ENDPOINT_QUERY: + case PREF_SPOCS_CLEAR_ENDPOINT: + case PREF_ENDPOINTS: + // This is a config reset directly related to Discovery Stream pref. + this.configReset(); + break; + case PREF_COLLECTIONS_ENABLED: + this.onCollectionsChanged(); + break; + case PREF_USER_TOPSITES: + case PREF_SYSTEM_TOPSITES: + if ( + !( + this.showTopsites || + (this.showStories && this.showSponsoredStories) + ) + ) { + // Ensure we delete any remote data potentially related to spocs. + this.clearSpocs(); + } + break; + case PREF_USER_TOPSTORIES: + case PREF_SYSTEM_TOPSTORIES: + if ( + !( + this.showStories || + (this.showTopsites && this.showSponsoredTopsites) + ) + ) { + // Ensure we delete any remote data potentially related to spocs. + this.clearSpocs(); + } + if (action.data.value) { + this.enableStories(); + } + break; + // Check if spocs was disabled. Remove them if they were. + case PREF_SHOW_SPONSORED: + case PREF_SHOW_SPONSORED_TOPSITES: + const dispatch = update => + this.store.dispatch(ac.BroadcastToContent(update)); + // We refresh placements data because one of the spocs were turned off. + this.updatePlacements( + dispatch, + this.store.getState().DiscoveryStream.layout + ); + // Currently the order of this is important. + // We need to check this after updatePlacements is called, + // because some of the spoc logic depends on the result of placement updates. + if ( + !( + (this.showSponsoredStories || + (this.showTopSites && this.showSponsoredTopSites)) && + (this.showSponsoredTopsites || + (this.showStories && this.showSponsoredStories)) + ) + ) { + // Ensure we delete any remote data potentially related to spocs. + this.clearSpocs(); + } + // Placements have changed so consider spocs expired, and reload them. + await this.cache.set("spocs", {}); + await this.loadSpocs(dispatch); + break; + } + } + + async onAction(action) { + switch (action.type) { + case at.INIT: + // During the initialization of Firefox: + // 1. Set-up listeners and initialize the redux state for config; + this.setupConfig(true /* isStartup */); + this.setupPrefs(true /* isStartup */); + // 2. If config.enabled is true, start loading data. + if (this.config.enabled) { + await this.enable({ updateOpenTabs: true, isStartup: true }); + } + Services.prefs.addObserver(PREF_POCKET_BUTTON, this); + break; + case at.DISCOVERY_STREAM_DEV_SYSTEM_TICK: + case at.SYSTEM_TICK: + // Only refresh if we loaded once in .enable() + if ( + this.config.enabled && + this.loaded && + (await this.checkIfAnyCacheExpired()) + ) { + await this.refreshAll({ updateOpenTabs: false }); + } + break; + case at.DISCOVERY_STREAM_DEV_SYNC_RS: + lazy.RemoteSettings.pollChanges(); + break; + case at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE: + // Personalization scores update at a slower interval than content, so in order to debug, + // we want to be able to expire just content to trigger the earlier expire times. + await this.resetContentCache(); + break; + case at.DISCOVERY_STREAM_CONFIG_SET_VALUE: + // Use the original string pref to then set a value instead of + // this.config which has some modifications + this.store.dispatch( + ac.SetPref( + PREF_CONFIG, + JSON.stringify({ + ...JSON.parse(this.store.getState().Prefs.values[PREF_CONFIG]), + [action.data.name]: action.data.value, + }) + ) + ); + break; + case at.DISCOVERY_STREAM_POCKET_STATE_INIT: + this.setupPocketState(action.meta.fromTarget); + break; + case at.DISCOVERY_STREAM_PERSONALIZATION_UPDATED: + if (this.personalized) { + const { feeds, spocs } = this.store.getState().DiscoveryStream; + const spocsPersonalized = + this.store.getState().Prefs.values?.pocketConfig?.spocsPersonalized; + const recsPersonalized = + this.store.getState().Prefs.values?.pocketConfig?.recsPersonalized; + if (recsPersonalized && feeds.loaded) { + this.scoreFeeds(feeds); + } + if (spocsPersonalized && spocs.loaded) { + this.scoreSpocs(spocs); + } + } + break; + case at.DISCOVERY_STREAM_CONFIG_RESET: + // This is a generic config reset likely related to an external feed pref. + this.configReset(); + break; + case at.DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS: + this.resetConfigDefauts(); + break; + case at.DISCOVERY_STREAM_RETRY_FEED: + this.retryFeed(action.data.feed); + break; + case at.DISCOVERY_STREAM_CONFIG_CHANGE: + // When the config pref changes, load or unload data as needed. + await this.onPrefChange(); + break; + case at.DISCOVERY_STREAM_IMPRESSION_STATS: + if ( + action.data.tiles && + action.data.tiles[0] && + action.data.tiles[0].id + ) { + this.recordTopRecImpressions(action.data.tiles[0].id); + } + break; + case at.DISCOVERY_STREAM_SPOC_IMPRESSION: + if (this.showSpocs) { + this.recordFlightImpression(action.data.flightId); + + // Apply frequency capping to SPOCs in the redux store, only update the + // store if the SPOCs are changed. + const spocsState = this.store.getState().DiscoveryStream.spocs; + + let frequencyCapped = []; + this.placementsForEach(placement => { + const spocs = spocsState.data[placement.name]; + if (!spocs || !spocs.items) { + return; + } + + const { data: capResult, filtered } = this.frequencyCapSpocs( + spocs.items + ); + frequencyCapped = [...frequencyCapped, ...filtered]; + + spocsState.data = { + ...spocsState.data, + [placement.name]: { + ...spocs, + items: capResult, + }, + }; + }); + + if (frequencyCapped.length) { + // Update cache here so we don't need to re calculate frequency caps on loads from cache. + await this.cache.set("spocs", { + lastUpdated: spocsState.lastUpdated, + spocs: spocsState.data, + }); + + this.store.dispatch( + ac.AlsoToPreloaded({ + type: at.DISCOVERY_STREAM_SPOCS_UPDATE, + data: { + lastUpdated: spocsState.lastUpdated, + spocs: spocsState.data, + }, + }) + ); + } + } + break; + // This is fired from the browser, it has no concept of spocs, flight or pocket. + // We match the blocked url with our available spoc urls to see if there is a match. + // I suspect we *could* instead do this in BLOCK_URL but I'm not sure. + case at.PLACES_LINK_BLOCKED: + if (this.showSpocs) { + let blockedItems = []; + const spocsState = this.store.getState().DiscoveryStream.spocs; + + this.placementsForEach(placement => { + const spocs = spocsState.data[placement.name]; + if (spocs && spocs.items && spocs.items.length) { + const blockedResults = []; + const blocks = spocs.items.filter(s => { + const blocked = s.url === action.data.url; + if (!blocked) { + blockedResults.push(s); + } + return blocked; + }); + + blockedItems = [...blockedItems, ...blocks]; + + spocsState.data = { + ...spocsState.data, + [placement.name]: { + ...spocs, + items: blockedResults, + }, + }; + } + }); + + if (blockedItems.length) { + // Update cache here so we don't need to re calculate blocks on loads from cache. + await this.cache.set("spocs", { + lastUpdated: spocsState.lastUpdated, + spocs: spocsState.data, + }); + + // If we're blocking a spoc, we want open tabs to have + // a slightly different treatment from future tabs. + // AlsoToPreloaded updates the source data and preloaded tabs with a new spoc. + // BroadcastToContent updates open tabs with a non spoc instead of a new spoc. + this.store.dispatch( + ac.AlsoToPreloaded({ + type: at.DISCOVERY_STREAM_LINK_BLOCKED, + data: action.data, + }) + ); + this.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_SPOC_BLOCKED, + data: action.data, + }) + ); + break; + } + } + + this.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_LINK_BLOCKED, + data: action.data, + }) + ); + break; + case at.UNINIT: + // When this feed is shutting down: + this.uninitPrefs(); + this._recommendationProvider = null; + Services.prefs.removeObserver(PREF_POCKET_BUTTON, this); + break; + case at.BLOCK_URL: { + // If we block a story that also has a flight_id + // we want to record that as blocked too. + // This is because a single flight might have slightly different urls. + action.data.forEach(site => { + const { flight_id } = site; + if (flight_id) { + this.recordBlockFlightId(flight_id); + } + }); + break; + } + case at.PREF_CHANGED: + await this.onPrefChangedAction(action); + if (action.data.name === "pocketConfig") { + await this.onPrefChange(); + this.setupPrefs(false /* isStartup */); + } + break; + } + } +} + +/* This function generates a hardcoded layout each call. + This is because modifying the original object would + persist across pref changes and system_tick updates. + + NOTE: There is some branching logic in the template. + `spocsUrl` Changing the url for spocs is used for adding a siteId query param. + `feedUrl` Where to fetch stories from. + `items` How many items to include in the primary card grid. + `spocPositions` Changes the position of spoc cards. + `spocTopsitesPositions` Changes the position of spoc topsites. + `spocPlacementData` Used to set the spoc content. + `spocTopsitesPlacementEnabled` Tuns on and off the sponsored topsites placement. + `spocTopsitesPlacementData` Used to set spoc content for topsites. + `sponsoredCollectionsEnabled` Tuns on and off the sponsored collection section. + `hybridLayout` Changes cards to smaller more compact cards only for specific breakpoints. + `hideCardBackground` Removes Pocket card background and borders. + `fourCardLayout` Enable four Pocket cards per row. + `newFooterSection` Changes the layout of the topics section. + `compactGrid` Reduce the number of pixels between the Pocket cards. + `essentialReadsHeader` Updates the Pocket section header and title to say "Today’s Essential Reads", moves the "Recommended by Pocket" header to the right side. + `editorsPicksHeader` Updates the Pocket section header and title to say "Editor’s Picks", if used with essentialReadsHeader, creates a second section 2 rows down for editorsPicks. + `onboardingExperience` Show new users some UI explaining Pocket above the Pocket section. + `ctaButtonSponsors` An array of sponsors we want to show a cta button on the card for. + `ctaButtonVariant` Sets the variant for the cta sponsor button. +*/ +getHardcodedLayout = ({ + spocsUrl = SPOCS_URL, + feedUrl = FEED_URL, + items = 21, + spocPositions = [1, 5, 7, 11, 18, 20], + spocTopsitesPositions = [1], + spocPlacementData = { ad_types: [3617], zone_ids: [217758, 217995] }, + spocTopsitesPlacementEnabled = false, + spocTopsitesPlacementData = { ad_types: [3120], zone_ids: [280143] }, + widgetPositions = [], + widgetData = [], + sponsoredCollectionsEnabled = false, + hybridLayout = false, + hideCardBackground = false, + fourCardLayout = false, + newFooterSection = false, + compactGrid = false, + essentialReadsHeader = false, + editorsPicksHeader = false, + onboardingExperience = false, + ctaButtonSponsors = [], + ctaButtonVariant = "", +}) => ({ + lastUpdate: Date.now(), + spocs: { + url: spocsUrl, + }, + layout: [ + { + width: 12, + components: [ + { + type: "TopSites", + header: { + title: { + id: "newtab-section-header-topsites", + }, + }, + ...(spocTopsitesPlacementEnabled && spocTopsitesPlacementData + ? { + placement: { + name: "sponsored-topsites", + ad_types: spocTopsitesPlacementData.ad_types, + zone_ids: spocTopsitesPlacementData.zone_ids, + }, + spocs: { + probability: 1, + prefs: [PREF_SHOW_SPONSORED_TOPSITES], + positions: spocTopsitesPositions.map(position => { + return { index: position }; + }), + }, + } + : {}), + properties: {}, + }, + ...(sponsoredCollectionsEnabled + ? [ + { + type: "CollectionCardGrid", + properties: { + items: 3, + }, + header: { + title: "", + }, + placement: { + name: "sponsored-collection", + ad_types: [3617], + zone_ids: [217759, 218031], + }, + spocs: { + probability: 1, + positions: [ + { + index: 0, + }, + { + index: 1, + }, + { + index: 2, + }, + ], + }, + }, + ] + : []), + { + type: "Message", + essentialReadsHeader, + editorsPicksHeader, + header: { + title: { + id: "newtab-section-header-stories", + }, + subtitle: "", + link_text: { + id: "newtab-pocket-learn-more", + }, + link_url: "https://getpocket.com/firefox/new_tab_learn_more", + icon: "chrome://global/skin/icons/pocket.svg", + }, + properties: {}, + styles: { + ".ds-message": "margin-bottom: -20px", + }, + }, + { + type: "CardGrid", + properties: { + items, + hybridLayout, + hideCardBackground, + fourCardLayout, + compactGrid, + essentialReadsHeader, + editorsPicksHeader, + onboardingExperience, + ctaButtonSponsors, + ctaButtonVariant, + }, + widgets: { + positions: widgetPositions.map(position => { + return { index: position }; + }), + data: widgetData, + }, + cta_variant: "link", + header: { + title: "", + }, + placement: { + name: "spocs", + ad_types: spocPlacementData.ad_types, + zone_ids: spocPlacementData.zone_ids, + }, + feed: { + embed_reference: null, + url: feedUrl, + }, + spocs: { + probability: 1, + positions: spocPositions.map(position => { + return { index: position }; + }), + }, + }, + { + type: "Navigation", + newFooterSection, + properties: { + alignment: "left-align", + links: [ + { + name: "Self improvement", + url: "https://getpocket.com/explore/self-improvement?utm_source=pocket-newtab", + }, + { + name: "Food", + url: "https://getpocket.com/explore/food?utm_source=pocket-newtab", + }, + { + name: "Entertainment", + url: "https://getpocket.com/explore/entertainment?utm_source=pocket-newtab", + }, + { + name: "Health & fitness", + url: "https://getpocket.com/explore/health?utm_source=pocket-newtab", + }, + { + name: "Science", + url: "https://getpocket.com/explore/science?utm_source=pocket-newtab", + }, + { + name: "More recommendations ›", + url: "https://getpocket.com/explore?utm_source=pocket-newtab", + }, + ], + extraLinks: [ + { + name: "Career", + url: "https://getpocket.com/explore/career?utm_source=pocket-newtab", + }, + { + name: "Technology", + url: "https://getpocket.com/explore/technology?utm_source=pocket-newtab", + }, + ], + privacyNoticeURL: { + url: "https://www.mozilla.org/privacy/firefox/#recommend-relevant-content", + title: { + id: "newtab-section-menu-privacy-notice", + }, + }, + }, + header: { + title: { + id: "newtab-pocket-read-more", + }, + }, + styles: { + ".ds-navigation": "margin-top: -10px;", + }, + }, + ...(newFooterSection + ? [ + { + type: "PrivacyLink", + properties: { + url: "https://www.mozilla.org/privacy/firefox/", + title: { + id: "newtab-section-menu-privacy-notice", + }, + }, + }, + ] + : []), + ], + }, + ], +}); diff --git a/browser/components/newtab/lib/DownloadsManager.sys.mjs b/browser/components/newtab/lib/DownloadsManager.sys.mjs new file mode 100644 index 0000000000..f095645d41 --- /dev/null +++ b/browser/components/newtab/lib/DownloadsManager.sys.mjs @@ -0,0 +1,188 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { actionTypes as at } from "resource://activity-stream/common/Actions.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DownloadsCommon: "resource:///modules/DownloadsCommon.sys.mjs", + DownloadsViewUI: "resource:///modules/DownloadsViewUI.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", +}); + +const DOWNLOAD_CHANGED_DELAY_TIME = 1000; // time in ms to delay timer for downloads changed events + +export class DownloadsManager { + constructor(store) { + this._downloadData = null; + this._store = null; + this._downloadItems = new Map(); + this._downloadTimer = null; + } + + setTimeout(callback, delay) { + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(callback, delay, Ci.nsITimer.TYPE_ONE_SHOT); + return timer; + } + + formatDownload(download) { + let referrer = download.source.referrerInfo?.originalReferrer?.spec || null; + return { + hostname: new URL(download.source.url).hostname, + url: download.source.url, + path: download.target.path, + title: lazy.DownloadsViewUI.getDisplayName(download), + description: + lazy.DownloadsViewUI.getSizeWithUnits(download) || + lazy.DownloadsCommon.strings.sizeUnknown, + referrer, + date_added: download.endTime, + }; + } + + init(store) { + this._store = store; + this._downloadData = lazy.DownloadsCommon.getData( + null /* null for non-private downloads */, + true, + false, + true + ); + this._downloadData.addView(this); + } + + onDownloadAdded(download) { + if (!this._downloadItems.has(download.source.url)) { + this._downloadItems.set(download.source.url, download); + + // On startup, all existing downloads fire this notification, so debounce them + if (this._downloadTimer) { + this._downloadTimer.delay = DOWNLOAD_CHANGED_DELAY_TIME; + } else { + this._downloadTimer = this.setTimeout(() => { + this._downloadTimer = null; + this._store.dispatch({ type: at.DOWNLOAD_CHANGED }); + }, DOWNLOAD_CHANGED_DELAY_TIME); + } + } + } + + onDownloadRemoved(download) { + if (this._downloadItems.has(download.source.url)) { + this._downloadItems.delete(download.source.url); + this._store.dispatch({ type: at.DOWNLOAD_CHANGED }); + } + } + + async getDownloads( + threshold, + { + numItems = this._downloadItems.size, + onlySucceeded = false, + onlyExists = false, + } + ) { + if (!threshold) { + return []; + } + let results = []; + + // Only get downloads within the time threshold specified and sort by recency + const downloadThreshold = Date.now() - threshold; + let downloads = [...this._downloadItems.values()] + .filter(download => download.endTime > downloadThreshold) + .sort((download1, download2) => download1.endTime < download2.endTime); + + for (const download of downloads) { + // Ignore blocked links, but allow long (data:) uris to avoid high CPU + if ( + download.source.url.length < 10000 && + lazy.NewTabUtils.blockedLinks.isBlocked(download.source) + ) { + continue; + } + + // Only include downloads where the file still exists + if (onlyExists) { + // Refresh download to ensure the 'exists' attribute is up to date + await download.refresh(); + if (!download.target.exists) { + continue; + } + } + // Only include downloads that were completed successfully + if (onlySucceeded) { + if (!download.succeeded) { + continue; + } + } + const formattedDownloadForHighlights = this.formatDownload(download); + results.push(formattedDownloadForHighlights); + if (results.length === numItems) { + break; + } + } + return results; + } + + uninit() { + if (this._downloadData) { + this._downloadData.removeView(this); + this._downloadData = null; + } + if (this._downloadTimer) { + this._downloadTimer.cancel(); + this._downloadTimer = null; + } + } + + onAction(action) { + let doDownloadAction = callback => { + let download = this._downloadItems.get(action.data.url); + if (download) { + callback(download); + } + }; + + switch (action.type) { + case at.COPY_DOWNLOAD_LINK: + doDownloadAction(download => { + lazy.DownloadsCommon.copyDownloadLink(download); + }); + break; + case at.REMOVE_DOWNLOAD_FILE: + doDownloadAction(download => { + lazy.DownloadsCommon.deleteDownload(download).catch(console.error); + }); + break; + case at.SHOW_DOWNLOAD_FILE: + doDownloadAction(download => { + lazy.DownloadsCommon.showDownloadedFile( + new lazy.FileUtils.File(download.target.path) + ); + }); + break; + case at.OPEN_DOWNLOAD_FILE: + const win = action._target.browser.ownerGlobal; + const openWhere = + action.data.event && win.whereToOpenLink(action.data.event); + doDownloadAction(download => { + lazy.DownloadsCommon.openDownload(download, { + // Replace "current" or unknown value with "tab" as the default behavior + // for opening downloads when handled internally + openWhere: ["window", "tab", "tabshifted"].includes(openWhere) + ? openWhere + : "tab", + }); + }); + break; + case at.UNINIT: + this.uninit(); + break; + } + } +} diff --git a/browser/components/newtab/lib/FaviconFeed.sys.mjs b/browser/components/newtab/lib/FaviconFeed.sys.mjs new file mode 100644 index 0000000000..a76566d3e8 --- /dev/null +++ b/browser/components/newtab/lib/FaviconFeed.sys.mjs @@ -0,0 +1,198 @@ +/* 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 { actionTypes as at } from "resource://activity-stream/common/Actions.sys.mjs"; +import { getDomain } from "resource://activity-stream/lib/TippyTopProvider.sys.mjs"; + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module. This +// is because the Karma test environment already stubs out +// RemoteSettings, and overrides importESModule to be a no-op (which +// can't be done for a static import statement). + +// eslint-disable-next-line mozilla/use-static-import +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +const MIN_FAVICON_SIZE = 96; + +/** + * Get favicon info (uri and size) for a uri from Places. + * + * @param uri {nsIURI} Page to check for favicon data + * @returns A promise of an object (possibly null) containing the data + */ +function getFaviconInfo(uri) { + return new Promise(resolve => + lazy.PlacesUtils.favicons.getFaviconDataForPage( + uri, + // Package up the icon data in an object if we have it; otherwise null + (iconUri, faviconLength, favicon, mimeType, faviconSize) => + resolve(iconUri ? { iconUri, faviconSize } : null), + lazy.NewTabUtils.activityStreamProvider.THUMB_FAVICON_SIZE + ) + ); +} + +/** + * Fetches visit paths for a given URL from its most recent visit in Places. + * + * Note that this includes the URL itself as well as all the following + * permenent&temporary redirected URLs if any. + * + * @param {String} a URL string + * + * @returns {Array} Returns an array containing objects as + * {int} visit_id: ID of the visit in moz_historyvisits. + * {String} url: URL of the redirected URL. + */ +async function fetchVisitPaths(url) { + const query = ` + WITH RECURSIVE path(visit_id) + AS ( + SELECT v.id + FROM moz_places h + JOIN moz_historyvisits v + ON v.place_id = h.id + WHERE h.url_hash = hash(:url) AND h.url = :url + AND v.visit_date = h.last_visit_date + + UNION + + SELECT id + FROM moz_historyvisits + JOIN path + ON visit_id = from_visit + WHERE visit_type IN + (${lazy.PlacesUtils.history.TRANSITIONS.REDIRECT_PERMANENT}, + ${lazy.PlacesUtils.history.TRANSITIONS.REDIRECT_TEMPORARY}) + ) + SELECT visit_id, ( + SELECT ( + SELECT url + FROM moz_places + WHERE id = place_id) + FROM moz_historyvisits + WHERE id = visit_id) AS url + FROM path + `; + + const visits = + await lazy.NewTabUtils.activityStreamProvider.executePlacesQuery(query, { + columns: ["visit_id", "url"], + params: { url }, + }); + return visits; +} + +/** + * Fetch favicon for a url by following its redirects in Places. + * + * This can improve the rich icon coverage for Top Sites since Places only + * associates the favicon to the final url if the original one gets redirected. + * Note this is not an urgent request, hence it is dispatched to the main + * thread idle handler to avoid any possible performance impact. + */ +export async function fetchIconFromRedirects(url) { + const visitPaths = await fetchVisitPaths(url); + if (visitPaths.length > 1) { + const lastVisit = visitPaths.pop(); + const redirectedUri = Services.io.newURI(lastVisit.url); + const iconInfo = await getFaviconInfo(redirectedUri); + if (iconInfo && iconInfo.faviconSize >= MIN_FAVICON_SIZE) { + lazy.PlacesUtils.favicons.setAndFetchFaviconForPage( + Services.io.newURI(url), + iconInfo.iconUri, + false, + lazy.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + } + } +} + +export class FaviconFeed { + constructor() { + this._queryForRedirects = new Set(); + } + + /** + * fetchIcon attempts to fetch a rich icon for the given url from two sources. + * First, it looks up the tippy top feed, if it's still missing, then it queries + * the places for rich icon with its most recent visit in order to deal with + * the redirected visit. See Bug 1421428 for more details. + */ + async fetchIcon(url) { + // Avoid initializing and fetching icons if prefs are turned off + if (!this.shouldFetchIcons) { + return; + } + + const site = await this.getSite(getDomain(url)); + if (!site) { + if (!this._queryForRedirects.has(url)) { + this._queryForRedirects.add(url); + Services.tm.idleDispatchToMainThread(() => fetchIconFromRedirects(url)); + } + return; + } + + let iconUri = Services.io.newURI(site.image_url); + // The #tippytop is to be able to identify them for telemetry. + iconUri = iconUri.mutate().setRef("tippytop").finalize(); + lazy.PlacesUtils.favicons.setAndFetchFaviconForPage( + Services.io.newURI(url), + iconUri, + false, + lazy.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + } + + /** + * Get the site tippy top data from Remote Settings. + */ + async getSite(domain) { + const sites = await this.tippyTop.get({ + filters: { domain }, + syncIfEmpty: false, + }); + return sites.length ? sites[0] : null; + } + + /** + * Get the tippy top collection from Remote Settings. + */ + get tippyTop() { + if (!this._tippyTop) { + this._tippyTop = RemoteSettings("tippytop"); + } + return this._tippyTop; + } + + /** + * Determine if we should be fetching and saving icons. + */ + get shouldFetchIcons() { + return Services.prefs.getBoolPref("browser.chrome.site_icons"); + } + + onAction(action) { + switch (action.type) { + case at.RICH_ICON_MISSING: + this.fetchIcon(action.data.url); + break; + } + } +} diff --git a/browser/components/newtab/lib/FilterAdult.sys.mjs b/browser/components/newtab/lib/FilterAdult.sys.mjs new file mode 100644 index 0000000000..a60ba3baa6 --- /dev/null +++ b/browser/components/newtab/lib/FilterAdult.sys.mjs @@ -0,0 +1,3040 @@ +/* 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/. */ + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module. This +// is because the Karma test environment already stubs out +// XPCOMUtils, and overrides importESModule to be a no-op (which +// can't be done for a static import statement). + +// eslint-disable-next-line mozilla/use-static-import +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "gFilterAdultEnabled", + "browser.newtabpage.activity-stream.filterAdult", + true +); + +// Keep a Set of adult base domains for lookup (initialized at end of file) +let gAdultSet; + +// Keep a hasher for repeated hashings +let gCryptoHash = null; + +/** + * Run some text through md5 and return the base64 result. + */ +function md5Hash(text) { + // Lazily create a reusable hasher + if (gCryptoHash === null) { + gCryptoHash = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + } + + gCryptoHash.init(gCryptoHash.MD5); + + // Convert the text to a byte array for hashing + gCryptoHash.update( + text.split("").map(c => c.charCodeAt(0)), + text.length + ); + + // Request the has result as ASCII base64 + return gCryptoHash.finish(true); +} + +export const FilterAdult = { + /** + * Filter out any link objects that have a url with an adult base domain. + * + * @param {string[]} links + * An array of links to test. + * @returns {string[]} + * A filtered array without adult links. + */ + filter(links) { + if (!lazy.gFilterAdultEnabled) { + return links; + } + + return links.filter(({ url }) => { + try { + const uri = Services.io.newURI(url); + return !gAdultSet.has(md5Hash(Services.eTLD.getBaseDomain(uri))); + } catch (ex) { + return true; + } + }); + }, + + /** + * Determine if the supplied url is an adult url or not. + * + * @param {string} url + * The url to test. + * @returns {boolean} + * True if it is an adult url. + */ + isAdultUrl(url) { + if (!lazy.gFilterAdultEnabled) { + return false; + } + try { + const uri = Services.io.newURI(url); + return gAdultSet.has(md5Hash(Services.eTLD.getBaseDomain(uri))); + } catch (ex) { + return false; + } + }, + + /** + * For tests, adds a domain to the adult list. + */ + addDomainToList(url) { + gAdultSet.add( + md5Hash(Services.eTLD.getBaseDomain(Services.io.newURI(url))) + ); + }, + + /** + * For tests, removes a domain to the adult list. + */ + removeDomainFromList(url) { + gAdultSet.delete( + md5Hash(Services.eTLD.getBaseDomain(Services.io.newURI(url))) + ); + }, +}; + +// These are md5 hashes of base domains to be filtered out. Originally from: +// https://hg.mozilla.org/mozilla-central/log/default/browser/base/content/newtab/newTab.inadjacent.json +gAdultSet = new Set([ + "+/UCpAhZhz368iGioEO8aQ==", + "+1e7jvUo8f2/2l0TFrQqfA==", + "+1gcqAqaRZwCj5BGiZp3CA==", + "+25t/2lo0FUEtWYK8LdQZQ==", + "+8PiQt6O7pJI/nIvQpDaAg==", + "+CLf5witKkuOvPCulTlkqw==", + "+CvLiih/gf2ugXAF+LgWqw==", + "+DWs0vvFGt6d3mzdcsdsyA==", + "+H0Rglt/HnhZwdty2hsDHg==", + "+L1FDsr5VQtuYc2Is5QGjw==", + "+LJYVZl1iPrdMU3L5+nxZw==", + "+Mp+JIyO0XC5urvMyi3wvQ==", + "+NMUaQ7XPsAi0rk7tTT9wQ==", + "+NmjwjsPhGJh9bM10SFkLw==", + "+OERSmo7OQUUjudkccSMOA==", + "+OLntmlsMBBYPREPnS6iVw==", + "+OXdvbTxHtSoLg7bZMho4w==", + "+P5q4YD1Rr5SX26Xr+tzlw==", + "+PUVXkoTqHxJHO18z4KMfw==", + "+Pl0bSMBAdXpRIA+zE02JA==", + "+QosBAnSM2h4lsKuBlqEZw==", + "+S+WXgVDSU1oGmCzGwuT3g==", + "+SclwwY8R2RPrnX54Z+A6w==", + "+VfRcTBQ80KSeJRdg0cDfw==", + "+WpF8+poKmHPUBB4UYh/ig==", + "+YVxSyViJfrme/ENe1zA7A==", + "+YrqTEJlJCv0A2RHQ8tr1A==", + "+ZozWaPWw8ws1cE5DJACeg==", + "+aF4ilbjQbLpAuFXQEYMWQ==", + "+dBv88reDrjEz6a2xX3Hzw==", + "+dIEf5FBrHpkjmwUmGS6eg==", + "+edqJYGvcy1AH2mEjJtSIg==", + "+fcjH2kZKNj8quOytUk4nQ==", + "+gO0bg8LY+py2dLM1sM7Ag==", + "+gbitI/gpxebN/rK7qj8Fw==", + "+gpHnUj2GWocP74t5XWz4w==", + "+jVN/3ASc2O44sX6ab8/cg==", + "+mJLK+6qq8xFv7O/mbILTw==", + "+n0K7OB2ItzhySZ4rhUrMg==", + "+p8pofUlwn8vV6Rp6+sz9g==", + "+tuUmnRDRWVLA+1k0dcUvg==", + "+zBkeHF4P8vLzk1iO1Zn3Q==", + "//eHwmDOQRSrv+k9C/k3ZQ==", + "/2Chaw2M9DzsadFFkCu6WQ==", + "/2c4oNniwhL3z5IOngfggg==", + "/2jGyMekNu7U136K+2N3Jg==", + "/Bwpt5fllzDHq2Ul6v86fA==", + "/DJgKE9ouibewuZ2QEnk6w==", + "/DiUApY7cVp5W9o24rkgRA==", + "/FchS2nPezycB8Bcqc2dbg==", + "/FdZzSprPnNDPwbhV1C0Cg==", + "/FsJYFNe+7UvsSkiotNJEQ==", + "/G26n5Xoviqldr5sg/Jl3w==", + "/HU2+fBqfWTEuqINc0UZSA==", + "/IarsLzJB8bf0AupJJ+/Eg==", + "/KYZdUWrkfxSsIrp46xxow==", + "/MEOgAhwb7F0nBnV4tIRZA==", + "/MeHciFhvFzQsCIw39xIZA==", + "/Ph/6l/lFNVqxAje1+PgFA==", + "/SP6pOdYFzcAl2OL05z4uQ==", + "/TSsi/AwKHtP6kQaeReI3w==", + "/VnKh/NDv7y/bfO6CWsLaQ==", + "/XC/FmMIOdhMTPqmy4DfUA==", + "/XjB6c5fxFGcKVAQ4o+OMw==", + "/YuQw7oAF08KDptxJEBS9g==", + "/a+bLXOq02sa/s8h7PhUTg==", + "/a9O7kWeXa0le45ab3+nVw==", + "/c34NtdUZAHWIwGl3JM8Tw==", + "/cJ0Nn5YbXeUpOHMfWXNHQ==", + "/cdR1i5TuQvO+u3Ov3b0KQ==", + "/gi3UZmunVOIXhZSktZ8zQ==", + "/hFhjFGJx2wRfz6hyrIpvA==", + "/jDVt9dRIn+o4IQ1DPwbsg==", + "/jH6imhTPZ/tHI4gYz2+HA==", + "/kGxvyEokQsVz0xlKzCn2A==", + "/mFp3GFkGNLhx2CiDvJv4A==", + "/mrqas0eDX+sFUNJvCQY8g==", + "/n1RLTTVpygre1dl36PDwQ==", + "/ngbFuKIAVpdSwsA3VxvNw==", + "/p/aCTIhi1bU0/liuO/a2Q==", + "/u5W2Gab4GgCMIc4KTp2mg==", + "/wIZAye9h1TUiZmDW0ZmYA==", + "/wiA2ltAuWyBhIvQAYBTQw==", + "/y/jHHEpUu5TR+R2o96kXA==", + "/zFLRvi75UL8qvg+a6zqGg==", + "00TVKawojyqrJkC7YqT41Q==", + "022B0oiRMx8Xb4Af98mTvQ==", + "02im2RooJQ/9UfUrh5LO+A==", + "0G93AxGPVwmr66ZOleM90A==", + "0HN6MIGtkdzNPsrGs611xA==", + "0K4NBxqEa3RYpnrkrD/XjQ==", + "0L0FVcH5Dlj3oL8+e9Na7g==", + "0NrvBuyjcJ2q6yaHpz/FOA==", + "0ODJyWKJSfObo+FNdRQkkA==", + "0QB0OUW5x2JLHfrtmpZQ+w==", + "0QCQORCYfLuSbq94Sbt0bQ==", + "0QbH4oI8IjZ9BRcqRyvvDQ==", + "0QxPAqRF8inBuFEEzNmLjA==", + "0SkC/4PtnX1bMYgD6r6CLA==", + "0TxcYwG72dT7Tg+eG8pP1w==", + "0UeRwDID2RBIikInqFI7uw==", + "0VsaJHR0Ms8zegsCpAKoyg==", + "0Y6iiZjCwPDwD/CwJzfioQ==", + "0ZEC3hy411LkOhKblvTcqg==", + "0ZRGz+oj2infCAkuKKuHiQ==", + "0a4SafpDIe8V4FlFWYkMHw==", + "0b/xj6fd0x+aB8EB0LC4SA==", + "0bj069wXgEJbw7dpiPr8Tg==", + "0dIeIM5Zvm5nSVWLy94LWg==", + "0e8hM3E5tnABRyy29A8yFw==", + "0egBaMnAf0CQEXf1pCIKnA==", + "0fN+eHlbRS6mVZBbH/B9FQ==", + "0fnruVOCxEczscBuv4yL9A==", + "0fpe9E6m3eLp/5j5rLrz2Q==", + "0klouNfZRHFFpdHi4ZR2hA==", + "0nOg18ZJ/NicqVUz5Jr0Hg==", + "0ofMbUCA3/v5L8lHnX4S5w==", + "0p1jMr06OyBoXQuSLYN4aQ==", + "0p8YbEMxeb73HbAfvPLQRw==", + "0q+erphtrB+6HBnnYg7O6w==", + "0rTYcuVYdilO7zEfKrxY3A==", + "0rfG4gRugAwVP0i3AGVxxg==", + "0u+0WHr7WI6IlVBBgiRi6w==", + "0yJ7TQYzcp3DXVSvwavr+w==", + "1+A9FCGP3bZhk6gU3LQtNg==", + "1+XWdu4qCqLLVjqkKz3nmA==", + "1+qmrbC8c7MJ6pxmDMcKuA==", + "1/Hxu8M9N/oNwk8bCj4FNQ==", + "1/SGIab+NnizimUmNDC4wA==", + "1/ZheMsbojazxt31j/l3iA==", + "10OltdxPXOvfatJuwPVKbQ==", + "11FE2kknwYi2Qu0JUKMn3A==", + "11U5XEwfMI7avx014LfC8g==", + "16d+fhFlgayu3ttKVV/pbg==", + "16iT/jCcPDrJEfi2bE5F+Q==", + "18RKixTv12q3xoBLz6eKiA==", + "18ndtDM9UaNfBR1cr3SHdA==", + "19yQHaBemtlgo2QkU5M6jQ==", + "1AeReq55UQotRQVKJ66pmg==", + "1ApqwW7pE+XUB2Cs2M6y7g==", + "1B5gxGQSGzVKoNd5Ol4N7g==", + "1BjsijOzgHt/0i36ZGffoQ==", + "1C50kisi9nvyVJNfq2hOEQ==", + "1E3pMgAHOnHx3ALdNoHr8Q==", + "1EI9aa955ejNo1dJepcZJw==", + "1FSrgkUXgZot2CsmbAtkPw==", + "1Gpj4TPXhdPEI4zfQFsOCg==", + "1HDgfU7xU7LWO/BXsODZAQ==", + "1I+UVx3krrD4NhzO7dgfHQ==", + "1JI9bT92UzxI8txjhst9LQ==", + "1JRgSHnfAQFQtSkFTttkqQ==", + "1LPC0BzhJbepHTSAiZ3QTw==", + "1MIn73MLroxXirrb+vyg2Q==", + "1Oykse0jQVbuR3MvW5ot4A==", + "1Pmnur6TbZ9cmemvu0+dSA==", + "1PvTn90xwZJPoVfyT5/uIQ==", + "1QGhj9NONF2rC44UdO+Izw==", + "1RQZ2pWSxT+RKyhBigtSFg==", + "1Vtrv6QUAfiYQjlLTpNovg==", + "1WIi4I62GqkjDXOYqHWJfQ==", + "1Wc8jQlDSB4Dp32wkL2odw==", + "1X14kHeKwGmLeYqpe60XEA==", + "1YO9G8qAhLIu2rShvekedw==", + "1Ym0lyBJ9aFjhJb/GdUPvQ==", + "1b2uf+CdVjufqiVpUShvHw==", + "1buQEv2YlH/ljTgH0uJEtw==", + "1cj1Fpd3+UiBAOahEhsluA==", + "1d7RPHdZ9qzAbG3Vi9BdFA==", + "1dhq3ozNCx0o4dV1syLVDA==", + "1dsKN1nG6upj7kKTKuJWsQ==", + "1eCHcz4swFH+uRhiilOinQ==", + "1eRUCdIJe3YGD5jOMbkkOg==", + "1fztTtQWNMIMSAc5Hr6jMQ==", + "1gA65t5FiBTEgMELTQFUPQ==", + "1jBaRO8Bg5l6TH7qJ8EPiw==", + "1k8tL2xmGFVYMgKUcmDcEw==", + "1lCcQWGDePPYco4vYrA5vw==", + "1m1yD4L9A7Q1Ot+wCsrxJQ==", + "1mw6LfTiirFyfjejf8QNGA==", + "1nXByug2eKq0kR3H3VjnWQ==", + "1tpM0qgdo7JDFwvT0TD78g==", + "1vqRt79ukuvdJNyIlIag8Q==", + "1wBuHqS1ciup31WTfm3NPg==", + "1xWx5V3G9murZP7srljFmA==", + "1zDfWw5LdG20ClNP1HYxgw==", + "203EqmJI9Q4tWxTJaBdSzA==", + "23C4eh3yBb5n/RNZeTyJkA==", + "23d9B9Gz5kUOi1I//EYsSQ==", + "24H9q+E8pgCEdFS7JO5kzQ==", + "25w3ZRUzCvJwAVHYCIO5uw==", + "26+yXbqI+fmIZsYl4UhUzw==", + "26Wmdp6SkKN74W0/XPcnmA==", + "29EybnMEO95Ng4l/qK4NWQ==", + "2Ct+pLXrK6Ku1f4qehjurQ==", + "2D6yhuABiaFFoXz0Lh0C+w==", + "2DNbXVgesUa7PgYQ4zX5Lw==", + "2E41e0MgM3WhFx2oasIQeA==", + "2HHqeGRMfzf3RXwVybx+ZQ==", + "2Hc5oyl0AYRy2VzcDKy+VA==", + "2QQtKtBAm2AjJ5c0WQ6BQA==", + "2QS/6OBA1T01NlIbfkTYJg==", + "2RFaMPlSbVuoEqKXgkIa5A==", + "2SI4F7Vvde2yjzMLAwxOog==", + "2SwIiUwT4vRZPrg7+vZqDA==", + "2W6lz1Z7PhkvObEAg2XKJw==", + "2Wvk/kouEEOY0evUkQLhOQ==", + "2XrR2hjDEvx8MQpHk9dnjw==", + "2aDK0tGNgMLyxT+BQPDE8Q==", + "2aIx9UdMxxZWvrfeJ+DcTw==", + "2abfl3N46tznOpr+94VONQ==", + "2bsIpvnGcFhTCSrK9EW1FQ==", + "2hEzujfG3mR5uQJXbvOPTQ==", + "2j83jrPwPfYlpJJ2clEBYQ==", + "2ksediOVrh4asSBxKcudTg==", + "2melaInV0wnhBpiI3da6/A==", + "2nSTEYzLK77h5Rgyti+ULQ==", + "2os5s7j7Tl46ZmoZJH8FjA==", + "2rOkEVl90EPqfHOF5q2FYw==", + "2rhjiY0O0Lo36wTHjmlNyw==", + "2vm7g3rk1ACJOTCXkLB3zA==", + "2wesXiib76wM9sqRZ7JYwQ==", + "2ywo4t5PPSVUCWDwUlOVwQ==", + "3++dZXzZ6AFEz7hK+i5hww==", + "3+9nURtBK3FKn0J9DQDa3g==", + "3+zsjCi7TnJhti//YXK35w==", + "3/1puZTGSrD9qNKPGaUZww==", + "300hoYyMR/mk1mfWJxS8/w==", + "301utVPZ93AnPLYbsiJggw==", + "312g8iTB9oJgk/OqcgR7Cw==", + "342VOUOxoLHUqtHANt83Hw==", + "36XDmX6j542q+Oei1/x0gw==", + "37Nkh06O979nt7xzspOFyQ==", + "3AKEYQqpkfW7CZMFQZoxOw==", + "3AVYtcIv7A5mVbVnQMaCeA==", + "3BjLFon1Il0SsjxHE2A1LQ==", + "3CJbrUdW68E3Drhe4ahUnQ==", + "3EhLkC9NqD3A6ApV6idmgg==", + "3Ejtsqw3Iep/UQd0tXnSlg==", + "3FH4D31nKV13sC9RpRZFIg==", + "3Gg9N7vjAfQEYOtQKuF/Eg==", + "3HPOzIZxoaQAmWRy9OkoSg==", + "3JhnM6G4L06NHt31lR0zXA==", + "3L3KEBHhgDwH615w4OvgZA==", + "3Leu2Sc+YOntJFlrvhaXeg==", + "3P2aJxV8Trll2GH9ptElYA==", + "3RTtSaMp1TZegJo5gFtwwA==", + "3TbRZtFtsh9ez8hqZuTDeA==", + "3TjntNWtpG7VqBt3729L6Q==", + "3UBYBMejKInSbCHRoJJ7dg==", + "3UNJ37f+gnNyYk9yLFeoYA==", + "3WVBP9fyAiBPZAq3DpMwOQ==", + "3Wfj05vCLFAB9vII5AU9tw==", + "3WwITQML938W9+MUM56a3A==", + "3XyoREdvhmSbyvAbgw2y/A==", + "3Y4w0nETru3SiSVUMcWXqw==", + "3Y6/HqS1trYc9Dh778sefg==", + "3YXp1PmMldUjBz3hC6ItbA==", + "3djRJvkZk9O2bZeUTe+7xQ==", + "3go7bJ9WqH/PPUTjNP3q/Q==", + "3hVslsq98QCDIiO40JNOuA==", + "3iC21ByW/YVL+pSyppanWw==", + "3itfXtlLPRmPCSYaSvc39Q==", + "3j0kFUZ6g+yeeEljx+WXGg==", + "3jmCreW5ytSuGfmeLv7NfQ==", + "3jqsY8/xTWELmu/az3Daug==", + "3kREs/qaMX0AwFXN0LO5ow==", + "3ltw31yJuAl4VT6MieEXXw==", + "3nthUmLZ30HxQrzr2d7xFA==", + "3oMTbWf7Bv83KRlfjNWQZA==", + "3pi3aNVq1QNJmu1j0iyL0g==", + "3rbml1D0gfXnwOs5jRZ3gA==", + "3sNJJIx1NnjYcgJhjOLJOg==", + "3v09RHCPTLUztqapThYaHg==", + "3xw8+0/WU51Yz4TWIMK8mw==", + "3y5Xk65ShGvWFbQxcZaQAQ==", + "3yDD+xT8iRfUVdxcc7RxKw==", + "3yavzOJ1mM44pOSFLLszgA==", + "4+htiqjEz9oq0YcI/ErBVg==", + "40HzgVKYnqIb6NJhpSIF0A==", + "40gCrW4YWi+2lkqMSPKBPg==", + "41WEjhYUlG6jp2UPGj11eQ==", + "444F9T6Y7J67Y9sULG81qg==", + "46FCwqh+eMkf+czjhjworw==", + "46piyANQVvvLqcoMq5G8tQ==", + "49jZr/mEW6fvnyzskyN40w==", + "49z/15Nx9Og7dN9ebVqIzg==", + "4A+RHIw+aDzw0rSRYfbc7g==", + "4BkqgraeXY7yaI1FE07Evw==", + "4CfEP8TeMKX33ktwgifGgA==", + "4DIPP/yWRgRuFqVeqIyxMQ==", + "4FBBtWPvqJ3dv4w25tRHiQ==", + "4ID0PHTzIMZz2rQqDGBVfA==", + "4KJZPCE9NKTfzFxl76GWjg==", + "4LtQrahKXVtsbXrEzYU1zQ==", + "4LvQSicqsgxQFWauqlcEjw==", + "4NHQwbb3zWq2klqbT/pG6g==", + "4NP8EFFJyPcuQKnBSxzKgQ==", + "4PBaoeEwUj79njftnYYqLg==", + "4Qinl7cWmVeLJgah8bcNkw==", + "4SdHWowXgCpCDL28jEFpAw==", + "4TQkMnRsXBobbtnBmfPKnA==", + "4VR5LiXLew6Nyn91zH9L4w==", + "4WO6eT0Rh6sokb29zSJQnQ==", + "4WRdAjiUmOQg2MahsunjAg==", + "4WcFEswYU/HHQPw77DYnyA==", + "4XNUmgwxsqDYsNmPkgNQYQ==", + "4Xh/B3C16rrjbES+FM1W8g==", + "4ZFYKa7ZgvHyZLS6WpM8gA==", + "4aPU6053cfMLHgLwAZJRNg==", + "4ekt4m38G9m599xJCmhlug==", + "4erEA42TqGA9K4iFKkxMMA==", + "4ifNsmjYf1iOn2YpMfzihg==", + "4iiCq+HhC+hPMldNQMt0NA==", + "4itEKfbRCJvqlgKnyEdIOQ==", + "4jeOFKuKpCmMXUVJSh9y0g==", + "4kXlJNuT79XXf1HuuFOlHw==", + "4kj0S8XlmhHXoUP7dQItUw==", + "4mQVNv7FHj+/O6XFqWFt/Q==", + "4mig4AMLUw+T/ect9p4CfA==", + "4qMSNAxichi3ori/pR+o0w==", + "4rrSL6N0wyucuxeRELfAmw==", + "4u3eyKc+y3uRnkASrgBVUw==", + "4wnUAbPT3AHRJrPwTTEjyw==", + "4xojeUxTFmMLGm6jiMYh/Q==", + "4yEkKp2FYZ09mAhw2IcrrA==", + "4yVqq66iHYQjiTSxGgX2oA==", + "4yrFNgqWq17zVCyffULocA==", + "50jASqzGm4VyHJbFv8qVRA==", + "50xwiYvGQytEDyVgeeOnMg==", + "51yLpfEdvqXmtB6+q27/AQ==", + "520wTzrysiRi2Td92Zq0HQ==", + "53UccFNzMi9mKmdeD82vAw==", + "54XELlPm8gBvx8D5bN3aUg==", + "59ipbMH7cKBsF9bNf4PLeQ==", + "5CMadLqS2KWwwMCpzlDmLw==", + "5DDb7fFJQEb3XTc3YyOTjg==", + "5HovoyHtul8lXh+z8ywq9A==", + "5I/heFSQG/UpWGx0uhAqGQ==", + "5KOgetfZR+O2wHQSKt41BQ==", + "5LJqHFRyIwQKA4HbtqAYQQ==", + "5LuFDNKzMd2BzpWEIYO2Ww==", + "5M3dFrAOemzQ0MAbA8bI5w==", + "5N2oi2pB69NxeNt08yPLhw==", + "5NEP7Xt7ynj6xCzWzt21hQ==", + "5Nk2Z94DhlIdfG5HNgvBbQ==", + "5PfGtbH9fmVuNnq83xIIgQ==", + "5Q/Y2V0iSVTK8HE8JerEig==", + "5S5/asYfWjOwnzYpbK6JDw==", + "5SbwLDNT6sBOy6nONtUcTg==", + "5T39s5CtSrK5awMPUcEWJg==", + "5VO1inwXMvLDBQSOahT6rg==", + "5VY++KiWgo7jXSdFJsPN3A==", + "5Wcq+6hgnWsQZ/bojERpUw==", + "5Yrj6uevT8wHRyqqgnSfeg==", + "5dUry23poD+0wxZ3hH6WmA==", + "5eHStFN7wEmIE+uuRwIlPQ==", + "5eXpiczlRdmqMYSaodOUiQ==", + "5gGoDPTc/sOIDLngmlEq4A==", + "5jHgQF4SfO/zy9xy9t+9dw==", + "5jyuDp82Fux+B0+zlx8EXw==", + "5kvyy902llnYGQdn2Py04w==", + "5l6kDfjtZjkTZPJvNNOVFw==", + "5lfLJAk1L3QzGMML3fOuSw==", + "5m1ijXEW+4RTNGZsDA/rxQ==", + "5oD/aGqoakxaezq43x0Tvw==", + "5pje7qyz8BRsa8U4a4rmoA==", + "5pqqzC/YmRIMA9tMFPi7rg==", + "5r1ZsGkrzNQEpgt/gENibw==", + "5u2PdDcIY3RQgtchSGDCGg==", + "5ugVOraop5P5z5XLlYPJyQ==", + "5w/c9WkI/FA+4lOtdPxoww==", + "5w4FbRhWACP7k2WnNitiHg==", + "6+jhreeBLfw64tJ+Nhyipw==", + "600bwlyhcy754W1E6tuyYg==", + "600mjiWke4u0CDaSQKLOOg==", + "60suecbWRfexSh7C67RENA==", + "61V74uIjaSfZM8au1dxr1A==", + "62RHCbpGU8Hb+Ubn+SCTBg==", + "63OTPaKM0xCfJOy9EDto+Q==", + "64AA4jLHXc1Dp15aMaGVcA==", + "64QzHOYX0A9++FqRzZRHlQ==", + "64YsV2qeDxk2Q6WK/h7OqA==", + "65KhGKUBFQubRRIEdh9SwQ==", + "6706ncrH1OANFnaK6DUMqQ==", + "68jPYo3znYoU4uWI7FH3/g==", + "68nqDtXOuxF7DSw6muEZvg==", + "6ACvJNfryPSjGOK39ov8Qg==", + "6CjtF1S2Y6RCbhl7hMsD+g==", + "6G2bD3Y7qbGmfPqH9TqLFA==", + "6GXHGF62/+jZ7PfIBlMxZw==", + "6HGeEPyTAu9oiKhNVLjQnA==", + "6HnWgYNKohqhoa1tnjjU3A==", + "6M6QapJ5xtMXfiD3bMaiLA==", + "6NP81geiL14BeQW6TpLnUA==", + "6PzjncEw2wHZg7SP7SQk9w==", + "6QAtjOK9enNLRhcVa2iaTg==", + "6QUGE2S8oFYx4T4nW56cCw==", + "6W79FmpUN1ByNtv5IEXY4w==", + "6WhHPWlqEUqXC52rHGRHjA==", + "6XYqR2WvDzx4fWO7BIOTjA==", + "6Z9myGCF5ylWljgIYAmhqw==", + "6ZKmm7IW7IdWuVytLr68CQ==", + "6ZMs9vCzK9lsbS6eyzZlIA==", + "6b7ue29cBDsvmj1VSa5njw==", + "6c0iuya20Ys8BsvoI4iQaQ==", + "6cTETZ9iebhWl+4W5CB+YQ==", + "6dshA8knH5qqD+KmR/kdSQ==", + "6e8boFcyc8iF0/tHVje4eQ==", + "6erpZS36qZRXeZ9RN9L+kw==", + "6fWom3YoKvW6NIg6y9o9CQ==", + "6k2cuk0McTThSMW/QRHfjA==", + "6lVSzYUQ/r0ep4W2eCzFpg==", + "6leyDVmC5jglAa98NQ3+Hg==", + "6nwR+e9Qw0qp8qIwH9S/Mg==", + "6o5g9JfKLKQ2vBPqKs6kjg==", + "6rIWazDEWU5WPZHLkqznuQ==", + "6rqK8sjLPJUIp7ohkEwfZg==", + "6sBemZt4qY/TBwqk3YcLOQ==", + "6sNP0rzCCm3w976I2q2s/w==", + "6tfM6dx3R5TiVKaqYQjnCg==", + "6txm8z4/LGCH0cpaet/Hsg==", + "6uMF5i0b/xsk55DlPumT7A==", + "6uT7LZiWjLnnqnnSEW4e/Q==", + "6v3eTZtPYBfKFSjfOo2UaA==", + "6wkfN8hyKmKU6tG3YetCmw==", + "6z8CRivao3IMyV4p4gMh7g==", + "71w3aSvuh2mBLtdqJCN3wA==", + "734u4Y1R3u7UNUnD+wWUoA==", + "74FW/QYTzr/P1k6QwVHMcw==", + "778O1hdVKHLG2q9dycUS0Q==", + "78b8sDBp28zUlYPV5UTnYw==", + "79uTykH43voFC3XhHHUzKg==", + "7E6V6/zSjbtqraG7Umj+Jw==", + "7Ephy+mklG2Y3MFdqmXqlA==", + "7Eqzyb+Kep+dIahYJWNNxQ==", + "7GgNLBppgAKcgJCDSsRqOQ==", + "7J3FoFGuTIW36q0PZkgBiw==", + "7K8l6KoP0BH82/WMLntfrg==", + "7R5rFaXCxM3moIUtoCfM2g==", + "7Tauesu7bgs5lJmQROVFiQ==", + "7VHlLw20dWck+I8tCEZilA==", + "7W9aF7dxnL+E8lbS/F7brg==", + "7XRiYvytcwscemlxd9iXIQ==", + "7Y87wVJok20UfuwkGbXxLg==", + "7b0oo4+qphu6HRvJq6qkHQ==", + "7bM/pn4G7g7Zl6Xf1r62Lg==", + "7br49X11xc2GxQLSpZWjKQ==", + "7btpMFgeGkUsiTtsmNxGQA==", + "7cnUHeaPO8txZGGWHL9tKg==", + "7dz+W494zwU5sg63v5flCg==", + "7k5rBuh8FbTTI4TP87wBPQ==", + "7l0RMKbONGS/goW/M+gnMQ==", + "7mxU5fJl/c6dXss9H3vGcQ==", + "7nr3zyWL+HHtJhRrCPhYZA==", + "7p4NpnoNSQR7ISg+w+4yFg==", + "7pkUY2UzSbGnwLvyRrbxfA==", + "7sCJ4RxbxRqVnF4MBoKfuQ==", + "7w3b73nN/fIBvuLuGZDCYQ==", + "7w4PDRJxptG8HMe/ijL6cQ==", + "7wgT9WIiMVcrj48PVAMIgw==", + "7xDIG/80SnhgxAYPL9YJtg==", + "7xTKFcog69nTmMfr5qFUTA==", + "80C9TB9/XT1gGFfQDJxRoA==", + "80PCwYh4llIKAplcDvMj4g==", + "80UE+Ivby3nwplO/HA7cPw==", + "81ZH3SO0NrOO+xoR/Ngw1g==", + "81iQLU+YwxNwq4of6e9z7A==", + "81nkjWtpBhqhvOp6K8dcWg==", + "81pAhreEPxcKse+++h1qBg==", + "82hTTe1Nr4N2g7zwgGjxkw==", + "83ERX2XJV3ST4XwvN7YWCg==", + "83WGpQGWyt6mCV+emaomog==", + "83wtvSoSP9FVBsdWaiWfpA==", + "861mBNvjIkVgkBiocCUj/Q==", + "88PNi9+yn3Bp4/upgxtWGA==", + "88tB/HgUIUnqWXEX++b5Aw==", + "897ptlztTjr7yk+pk8MT0Q==", + "8AfCSZC0uasVON9Y/0P2Pw==", + "8B12CamjOGzJDnQ+RkUf4w==", + "8BLkvEkfnOizJq0OTCYGzw==", + "8CjmgWQSAAGcXX9kz3kssw==", + "8Cm19vJW8ivhFPy0oQXVNA==", + "8DtgIyYiNFqDc5qVrpFUng==", + "8GyPup4QAiolFJ9v80/Nkw==", + "8JVHFRwAd/SCLU0CRJYofg==", + "8LNNoHe6rEQyJ0ebl151Mw==", + "8M0kSvjn5KN8bjsMdUqKZQ==", + "8N3mhHt29FZDHn1P2WH1wQ==", + "8OFxXwnPmrogpNoueZlC4Q==", + "8QK7emHS6rAcAF5QQemW/A==", + "8RtLlzkGEiisy1v9Xo0sbw==", + "8VqeoQELbCs232+Mu+HblA==", + "8WU1vLKV1GhrL7oS9PpABg==", + "8ZBiwr842ZMKphlqmNngHw==", + "8ZFPMJJYVJHsfRpU4DigSg==", + "8ZqmPJDnQSOFXvNMRQYG2Q==", + "8c+lvG5sZNimvx9NKNH3ug==", + "8cXqZub6rjgJXmh1CYJBOg==", + "8dBIsHMEAk7aoArLZKDZtg==", + "8dUcSkd2qnX5lD9B+fUe+Q==", + "8dbyfox/isKLsnVjQNsEXg==", + "8fJLQeIHaTnJ8wGqUiKU6g==", + "8g08gjG/QtvAYer32xgNAg==", + "8hsfXqi4uiuL+bV1VrHqCw==", + "8iYdEleTXGM+Wc85/7vU9w==", + "8j9GVPiFdfIRm/+ho7hpoA==", + "8nOTDhFyZ8YUA4b6M5p84w==", + "8snljTGo/uICl9q0Hxy7/A==", + "8uP4HUnSodw88yoiWXOIcw==", + "8vLA9MOdmLTo3Qg+/2GzLA==", + "8vr+ERVrM99dp+IGnCWDGQ==", + "8ylI1AS3QJpAi3I/NLMYdg==", + "9+hjTVMQUsvVKs7Tmp52tg==", + "90dtIMq0ozJXezT2r79vMQ==", + "91+Yms6Oy/rP0rVjha5z9w==", + "91LQuW6bMSxl10J/UDX23A==", + "91SdBFJEZ65M+ixGaprY/A==", + "91VcAVv7YDzkC1XtluPigw==", + "91vfsZ7Lx9x5gqWTOdM4sg==", + "96ORaz1JRHY1Gk8H74+C2g==", + "99+SBN45LwKCPfrjUKRPmw==", + "9Bet5waJF5/ZvsYaHUVEjQ==", + "9DRHdyX8ECKHUoEsGuqR4Q==", + "9DtM1vls4rFTdrSnQ7uWXw==", + "9FdpxlIFu11qIPdO7WC5nw==", + "9Gkw+hvsR/tFY1cO89topg==", + "9J53kk+InE3CKa7cPyCXMw==", + "9JKIJrlQjhNSC46H3Cstcw==", + "9L6yLO93sRN70+3qq3ObfA==", + "9MDG0WeBPpjGJLEmUJgBWg==", + "9QFYrCXsGsInUb4SClS3cQ==", + "9RGIQ2qyevNbSSEF36xk/A==", + "9RXymE9kCkDvBzWGyMgIWA==", + "9SUOfKtfKmkGICJnvbIDMg==", + "9SgfpAY0UhNC6sYGus9GgQ==", + "9T7gB0ZkdWB0VpbKIXiujQ==", + "9TalxEyFgy6hFCM73hgb7Q==", + "9UhKmKtr4vMzXTEn74BEhg==", + "9W57pTzc572EvSURqwrRhw==", + "9Y1ZmfiHJd9vCiZ6KfO1xQ==", + "9aKH1u5+4lgYhhLztQ4KWA==", + "9ajIS45NTicqRANzRhDWFA==", + "9bAWYElyRN1oJ6eJwPtCtQ==", + "9cvHJmim9e0pOaoUEtiM6A==", + "9dbn0Kzwr9adCEfBJh78uQ==", + "9iB7+VwXRbi6HLkWyh9/kg==", + "9inw7xzbqAnZDKOl/MfCqA==", + "9jxA/t3TQx8dQ+FBsn/YCg==", + "9k17UqdR1HzlF7OBAjpREA==", + "9k1u/5TgPmXrsx3/NsYUhg==", + "9lLhHcrPWI4EsA4fHIIXuw==", + "9nMltdrrBmM5ESBY2FRjGA==", + "9oQ/SVNJ4Ye9lq8AaguGAQ==", + "9oUawSwUGOmb0sDn3XS6og==", + "9onh6QKp70glZk9cX3s34A==", + "9pdeedz1UZUlv8jPfPeZ1g==", + "9pk75mBzhmcdT+koHvgDlw==", + "9qWLbRLXWIBJUXYjYhY2pg==", + "9rL8nC/VbSqrvnUtH9WsxQ==", + "9reBKZ1Rp6xcdH1pFQacjw==", + "9s3ar9q32Y5A3tla5GW/2Q==", + "9sYLg75/hudZaBA3FrzKHw==", + "9tiibT8V9VwnPOErWGNT3w==", + "9vEgJVJLEfed6wJ7hBUGgQ==", + "9viAzLFGYYudBYFu7kFamg==", + "9vmJUS7WIVOlhMqwipAknQ==", + "9wUIeSgNN36SFxy8v2unVg==", + "9xIgKpZGqq0/OU6wM5ZSHw==", + "9xmtuClkFlpz/X5E9JBWBA==", + "A+DLpIlYyCb9DaarpLN76g==", + "A2ODff+ImIkreJtDPUVrlg==", + "A3dX2ShyL9+WOi6MNJBoYQ==", + "A6TLWhipfymkjPYq8kaoDQ==", + "AChOz8avRYsvxlbWcorQ3w==", + "AEpTVUQhIEJGlXJB6rS26A==", + "AFdelaqvxRj6T3YdLgCFyg==", + "AGd0rcLnQ0n+meYyJur1Pw==", + "AGoVLd0QPcXnTedT5T95JQ==", + "ALJWKUImVE40MbEooqsrng==", + "ALlGgVDO8So71ccX0D6u2g==", + "AMfL0rH+g8c0VqOUSgNzQw==", + "ARCWkHAnVgBOIkCDQ19ZuA==", + "ARKIvf4+zRF8eCvUITWPng==", + "ATmMzriwGLl+M3ppkfcZNA==", + "AUGmvZkpkKBry5bHZn4DJA==", + "AV/YJfdoDUdRcrXVwinhQg==", + "AVjwqrTBQH1VREuBlOyUOg==", + "AX1HxQKXD12Yv5HWi39aPQ==", + "AYxGETZs477n2sa1Ulu/RQ==", + "AZs3v4KJYxdi8T1gjVjI2Q==", + "AcKwfS8FRVqb72uSkDNY/Q==", + "AcbG0e6xN8pZfYAv7QJe1Q==", + "Af9j1naGtnZf0u1LyYmK1w==", + "AfVPdxD3FyfwwNrQnVNQ7A==", + "AgDJsaW0LkpGE65Kxk5+IA==", + "Ahpi9+nl13kPTdzL+jgqMw==", + "AiMtfedwGcddA+XYNc+21g==", + "AjHz9GkRTFPjrqBokCDzFw==", + "Ak3rlzEOds6ykivfg39xmw==", + "AkAes5oErTaJiGD2I4A1Pw==", + "AklOdt9/2//3ylUhWebHRw==", + "Al8+d/dlOA5BXsUc5GL8Tg==", + "Ao1Zc0h5AdSHtYt1caWZnQ==", + "AoN/pnK4KEUaGw4V9SFjpg==", + "ApiuEPWr8UjuRyJjsYZQBw==", + "AqHVaj3JcR44hnMzUPvVYg==", + "Ar1Eb/f/LtuIjXnnVPYQlA==", + "Ar9N1VYgE7riwmcrM3bA2Q==", + "AsAHrIkMgc3RRWnklY9lJw==", + "AvdeYb9XNOUFWiiz+XGfng==", + "AwPTZpC28NJQhf5fNiJuLA==", + "AxEjImKz4tMFieSo7m60Sg==", + "AyWlT+EGzIXc395zTlEU5Q==", + "B+TsxQZf0IiQrU8X9S4dsQ==", + "B0TaUQ6dKhPfSc5V/MjLEQ==", + "B1VVUbl8pU0Phyl1RYrmBg==", + "B6reUwMkQFaCHb9BYZExpw==", + "BA18GEAOOyVXO2yZt2U35w==", + "BAJ+/jbk2HyobezZyB9LiQ==", + "BB/R8oQOcoE4j63Hrh8ifg==", + "BB9PTlwKAWkExt3kKC/Wog==", + "BDNM1u/9mefjuW1YM2DuBg==", + "BDbfe/xa9Mz1lVD82ZYRGA==", + "BH+rkZWQjTp7au6vtll/CQ==", + "BL3buzSCV78rCXNEhUhuKQ==", + "BLJk9wA88z6e0IQNrWJIVw==", + "BLbTFLSb4mkxMaq4/B2khg==", + "BMOi5JmFUg5sCkbTTffXHw==", + "BMZB1FwvAuEqyrd0rZrEzw==", + "BPT4PQxeQcsZsUQl33VGmg==", + "BTiGLT6XdZIpFBc91IJY6g==", + "BV1moliPL15M14xkL+H1zw==", + "BW0A06zoQw7S+YMGaegT7g==", + "BXGlq54wIH6R3OdYfSSDRw==", + "BYpHADmEnzBsegdYTv8B5Q==", + "BYz52gYI/Z6AbYbjWefcEA==", + "BZTzHJGhzhs3mCXHDqMjnQ==", + "BaRwTrc5ulyKbW4+QqD0dw==", + "BhKO1s1O693Fjy1LItR/Jw==", + "BjfOelfc1IBgmUxMJFjlbQ==", + "BlCgDd7EYDIqnoAiKOXX6Q==", + "BophnnMszW5o+ywgb+3Qbw==", + "Bq82MoMcDjIo/exqd/6UoA==", + "BuDVDLl0OGdomEcr+73XhQ==", + "BuENxPg7JNrWXcCxBltOPg==", + "Bv4mNIC72KppYw/nHQxfpQ==", + "Bvk8NX4l6WktLcRDRKsK/A==", + "BwRA+tMtwEvth28IwpZx+w==", + "BxFP+4o6PSlGN78eSVT1pA==", + "BxsDnI8jXr4lBwDbyHaYXw==", + "Byhi4ymFqqH8uIeoMRvPug==", + "BzkNYH03gF/mQY71RwO3VA==", + "C+Ssp+v1r+00+qiTy2d7kA==", + "C4QEzQKGxyRi2rjwioHttA==", + "C65PZm8rZxJ6tTEb6d08Eg==", + "C7UaoIEXsVRxjeA0u99Qmw==", + "CBAGa5l95f3hVzNi6MPWeQ==", + "CCK+6Dr72G3WlNCzV7nmqw==", + "CDsanJz7e3r/eQe+ZYFeVQ==", + "CF1sAlhjDQY/KWOBnSSveA==", + "CHLHizLruvCrVi9chj9sXA==", + "CHsFJfsvZkPWDXkA6ZMsDQ==", + "CJoZn5wdTXbhrWO5LkiW0g==", + "CLPzjXKGGpJ0VrkSJp7wPQ==", + "CPDs+We/1wvsGdaiqxzeCQ==", + "CQ0PPwgdG3N6Ohfwx1C8xA==", + "CQpJFrpOvcQhsTXIlJli+Q==", + "CRiL6zpjfznhGXhCIbz8pQ==", + "CRmAj3JcasAb4iZ9ZbNIbw==", + "CT3ldhWpS1SEEmPtjejR/Q==", + "CT9g8mKsIN/VeHLSTFJcNQ==", + "CUCjG2UaEBmiYWQc6+AS1Q==", + "CUEueo8QXRxkfVdfNIk/gg==", + "CWBGcRFYwZ0va6115vV/oQ==", + "CX/N/lHckmAtHKysYtGdZA==", + "CXMKIdGvm60bgfsNc+Imvg==", + "CYJB3qy5GalPLAv1KGFEZA==", + "CZNoTy26VUQirvYxSPc/5A==", + "CZbd+UoTz0Qu1kkCS3k8Xg==", + "CazLJMJjQMeHhYLwXW7YNg==", + "Ci7sS7Yi1+IwAM3VMAB4ew==", + "CiiUeJ0LeWfm7+gmEmYXtg==", + "CkDIoAFLlIRXra78bxT/ZA==", + "CkZUmKBAGu0FLpgPDrybpw==", + "Cl1u5nGyXaoGyDmNdt38Bw==", + "CmBf5qchS1V3C2mS6Rl4bw==", + "CmVD6nh8b/04/6JV9SovlA==", + "CmkmWcMK4eqPBcRbdnQvhw==", + "CnIwpRVC2URVfoiymnsdYQ==", + "CoLvjQDQGldGDqRxfQo+WQ==", + "CrJDgdfzOea2M2hVedTrIg==", + "CsPkyTZADMnKcgSuNu1qxg==", + "CtDj/h2Q/lRey20G8dzSgA==", + "CuGIxWhRLN7AalafBZLCKQ==", + "Cv079ZF55RnbsDT27MOQIA==", + "Cz1G77hsDtAjpe0WzEgQog==", + "CzP13PM/mNpJcJg8JD3s6w==", + "CzSumIcYrZlxOUwUnLR2Zw==", + "CzWhuxwYbNB/Ffj/uSCtbw==", + "D09afzGpwCEH0EgZUSmIZA==", + "D0Qt9sRlMaPnOv1xaq+XUg==", + "D0W5F7gKMljoG5rlue1jrg==", + "D175i+2bZ7aWa4quSSkQpA==", + "D2JcY4zWwqaCKebLM8lPiQ==", + "D31ZticrjGWAO45l5hFh7A==", + "D5ibbo8UJMfFZ48RffuhgQ==", + "D5jaV+HtXkSpSxJPmaBDXg==", + "D66Suu3tWBD+eurBpPXfjA==", + "D7piVoB2NJlBxK5owyo4+g==", + "D7wN7b5u5PKkMaLJBP9Ksw==", + "DA+3fjr7mgpwf6BZcExj0w==", + "DB706G73NpBSRS8TKQOVZw==", + "DBKrdpCE0awppxST4o/zzg==", + "DCjgaGV5hgSVtFY5tcwkuA==", + "DCvI9byhw0wOFwF1uP6xIQ==", + "DDitrRSvovaiXe2nfAtp4g==", + "DEaZD/8aWV6+zkiLSVN/gA==", + "DG2Qe2DqPs5MkZPOqX363Q==", + "DJ+a37tCaGF5OgUhG+T0NA==", + "DJmrmNRKARzsTCKSMLmcNA==", + "DJoy1NSZZw87oxWGlNHhfg==", + "DJscTYNFPyPmTb57g/1w+Q==", + "DKApp/alXiaPSRNm3MfSuA==", + "DLzHkTjjuH6LpWHo2ITD0Q==", + "DMHmyn2U2n+UXxkqdvKpnA==", + "DO1/jfP/xBI9N0RJNqB2Rw==", + "DQJRsUwO1fOuGlkgJavcwQ==", + "DQQB/l55iPN9XcySieNX3A==", + "DQeib845UqBMEl96sqsaSg==", + "DQlZWBgdTCoYB1tJrNS5YQ==", + "DRiFNojs7wM8sfkWcmLnhQ==", + "DWKsPfKDAtfuwgmc2dKUNg==", + "DY0IolKTYlW+jbKLPAlYjQ==", + "DYWCPUq/hpjr6puBE7KBHg==", + "DbWQI3H2tcJsVJThszfHGA==", + "DdaT4JLC7U0EkF50LzIj9w==", + "DdiNGiOSoIZxrMrGNvqkXw==", + "DinJuuBX9OKsK5fUtcaTcQ==", + "DjHszpS8Dgocv3oQkW/VZQ==", + "DjeSrUoWW2QAZOAybeLGJg==", + "Dk0L/lQizPEb3Qud6VHb1Q==", + "DmxgZsQg+Qy1GP0fPkW3VA==", + "Dmyb+a7/QFsU4d2cVQsxDw==", + "DnF6TYSJxlc+cwdfevLYng==", + "Do3aqbRKtmlQI2fXtSZfxQ==", + "DoiItHSms0B9gYmunVbRkQ==", + "DqzWt1gfyu/e7RQl5zWnuQ==", + "Dt6hvhPJu94CJpiyJ5uUkg==", + "Dt8Q5ORzTmpPR2Wdk0k+Aw==", + "DuEKxykezAvyaFO2/5ZmKQ==", + "Dulw855DfgIwiK7hr3X8vg==", + "Duz/8Ebbd0w6oHwOs0Wnwg==", + "DwOTyyCoUfaSShHZx9u6xg==", + "DwP0MQf71VsqvAbAMtC3QQ==", + "DwrNdmU5VFFf3TwCCcptPA==", + "Dz90OhYEjpaJ/pxwg1Qxhg==", + "E+02smwQGBIxv42LIF2Y4Q==", + "E1CvxFbuu9AYW604mnpGTw==", + "E2LR1aZ3DcdCBuVT7BhReA==", + "E2v8Kk60qVpQ232YzjS2ow==", + "E3jMjAgXwvwR8PA53g4+PQ==", + "E4NtzxQruLcetC23zKVIng==", + "E4ojRDwGsIiyuxBuXHsKBA==", + "E8yMPK7W0SIGTK6gIqhxiQ==", + "E9IlDyULLdeaVUzN6eky8g==", + "E9ajQQMe02gyUiW3YLjO/A==", + "E9yeifEZtpqlD0N3pomnGw==", + "EATnlYm0p3h04cLAL95JgA==", + "EC0+iUdSZvmIEzipXgj7Gg==", + "EGLOaMe6Nvzs/cmb7pNpbg==", + "EJgedRYsZPc4cT9rlwaZhg==", + "EKU3OVlT4b/8j3MTBqpMNg==", + "ENFfP93LA257G6pXQkmIdg==", + "EUXQZwLgnDG+C8qxVoBNdw==", + "EXveRXjzsjh8zbbQY2pM9g==", + "EZVQGsXTZvht1qedRLF8bQ==", + "EbGG4X18upaiVQmPfwKytg==", + "EdvIAKdRAXj7e42mMlFOGQ==", + "Ee4A3lTMLQ7iDQ7b8QP8Qg==", + "EfXDc6h69aBPE6qsB+6+Ig==", + "Egs14xVbRWjfBBX7X5Z60g==", + "Ej7W3+67kCIng3yulXGpRQ==", + "ElTNyMR4Rg8ApKrPw88WPg==", + "Epm0d/DvXkOFeM4hoPCBrg==", + "EqMlrz1to7HG4GIFTPaehQ==", + "EqYq2aVOrdX5r7hBqUJP7g==", + "Err1mbWJud80JNsDEmXcYg==", + "EuGWtIbyKToOe6DN3NkVpQ==", + "Ev/xjTi7akYBI7IeZJ4Igw==", + "EvSB+rCggob2RBeXyDQRvQ==", + "Ex3x5HeDPhgO2S9jjCFy4g==", + "EyIsYQxgFa4huyo/Lomv7g==", + "EzjbinBHx3Wr08eXpH3HXA==", + "F50iXjRo1aSTr37GQQXuJA==", + "F58ktE4O0f7C9HdsXYm+lw==", + "F5FcNti7lUa9DyF2iEpBug==", + "F5bs0GGWBx9eBwcJJpXbqg==", + "F8l+Qd9TZgzV+r8G584lKA==", + "F8tEIT5EhcvLNRU5f0zlXQ==", + "FA+nK6mpFWdD0kLFcEdhxA==", + "FAXzjjIr8l1nsQFPpgxM/g==", + "FCLQocqxxhJeleARZ6kSPg==", + "FH5Z60RXXUiDk+dSZBxD3g==", + "FHvI0IVNvih8tC7JgzvCOw==", + "FI2WhaSMb3guFLe3e9il8Q==", + "FIOCTEbzb2+KMCnEdJ7jZw==", + "FL/j3GJBuXdAo54JYiWklQ==", + "FLvED9nB9FEl9LqPn7OOrA==", + "FN7oLGBQGHXXn5dLnr/ElA==", + "FNvQqYoe0s/SogpAB7Hr1Q==", + "FUQySDFodnRhr+NUsWt0KA==", + "FV/D5uSco+Iz8L+5t7E8SA==", + "FWphIPZMumqnXr1glnbK4w==", + "FXzaxi3nAXBc8WZfFElQeA==", + "FbxScyuRacAQkdQ034ShTA==", + "FcFcn4qmPse5mJCX5yNlsA==", + "FcKjlHKfQAGoovtpf+DxWQ==", + "Fd0c8f2eykUp9GYhqOcKoA==", + "Fd2fYFs8vtjws2kx1gf6Rw==", + "FeRovookFQIsXmHXUJhGOw==", + "FhthAO5IkMyW4dFwpFS7RA==", + "Fiy3hkcGZQjNKSQP9vRqyA==", + "FltEN+7NKvzt+XAktHpfHA==", + "FnVNxl5AFH1AieYru2ZG+A==", + "FoJZ61VrU8i084pAuoWhDQ==", + "FpWDTLTDmkUhH/Sgo+g1Gg==", + "FpgdsQ2OG+bVEy3AeuLXFQ==", + "FqWLkhWl0iiD/u2cp+XK9A==", + "FrTgaF5YZCNkyfR1kVzTLQ==", + "Ft2wXUokFdUf6d2Y/lwriw==", + "FtxpWdhEmC6MT61qQv4DGA==", + "FuWspiqu5g8Eeli5Az+BkA==", + "FxnbKnuDct4OWcnFMT/a5w==", + "Fz8EI+ZpYlbcttSHs5PfpA==", + "FzqIpOcTsckSNHExrl+9jg==", + "Fzuq+Wg7clo6DTujNrxsSA==", + "G+sGF13VXPH4Ih6XgFEXxg==", + "G/PA+kt0N+jXDVKjR/054A==", + "G0LChrb0OE5YFqsfTpIL1Q==", + "G0MlFNCbRjXk4ekcPO/chQ==", + "G2UponGde3/Z+9b2m9abpQ==", + "G37U8XTFyshfCs7qzFxATg==", + "G3PmmPGHaWHpPW30xQgm3Q==", + "G4qzBI1sFP2faN+tlRL/Bw==", + "G736AX070whraDxChqUrqw==", + "G7J/za99BFbAZH+Q+/B8WA==", + "G8LFBop8u6IIng+gQuVg3w==", + "GA8k6GQ20DGduVoC+gieRA==", + "GCYI9Dn1h3gOuueKc7pdKA==", + "GDMqfhPQN0PxfJPnK1Bb9A==", + "GF0lY77rx1NQzAsZpFtXIQ==", + "GF2yvI9UWf1WY7V7HXmKPA==", + "GFRJoPcXlkKSvJRuBOAYHQ==", + "GG8a3BlwGrYIwZH9j3cnPA==", + "GHEdXgGWOeOa6RuPMF0xXg==", + "GIHKW6plyLra0BmMOurFgA==", + "GKzs8mlnQQc58CyOBTlfIg==", + "GLDNTSwygNBmuFwCIm7HtA==", + "GLmWLXURlUOJ+PMjpWEXVA==", + "GLnS9wDCje7TOMvBX9jJVA==", + "GNak/LFeoHWlTdLW1iU4eg==", + "GNrMvNXQkW7PydlyJa+f1w==", + "GQJxu1SoMBH14KPV/G/KrQ==", + "GSWncBq4nwomZCBoxCULww==", + "GT6WUDXiheKAM7tPg3he9A==", + "GTNttXfMniNhrbhn92Aykg==", + "GUiinC3vgBjbQC2ybMrMNQ==", + "GW1Uaq622QamiiF24QUA0g==", + "GWwJ32SZqD5wldrXUdNTLA==", + "GdTanUprpE3X/YjJDPpkhQ==", + "Gdf4VEDLBrKJNQ8qzDsIyw==", + "GglPoW5fvr4JSM3Zv99oiA==", + "GhpJfRSWZigLg/azTssyVA==", + "Ghuj9hAyfehmYgebBktfgA==", + "GmC+0rNDMIR+YbUudoNUXw==", + "GnJKlRzmgKN9vWyGfMq3aA==", + "GncGQgmWpI/fZyb/6zaFCg==", + "GrSbnecYAC3j5gtoKntL0A==", + "Gt4/MMrLBErhbFjGbiNqQQ==", + "GzbeM7snhe+M+J7X+gAsQw==", + "H+NHjk/GJDh/GaNzMQSzjg==", + "H+yPRiooEh5J7lAJB4RZ7Q==", + "H0UMAUfHFQH92A2AXRCBKA==", + "H1NJEI+fvOQbI51kaNQQjQ==", + "H1y2iXVaQYwP0SakN6sa+Q==", + "H1zH9I8RwfEy5DGz3z+dHw==", + "H6HPFAcdHFbQUNrYnB74dA==", + "H6j2nPbBaxHecXruxiWYkA==", + "HBRzLacCVYfwUVGzrefZYg==", + "HCbHUfsTDl6+bxPjT57lrA==", + "HCu4ZMrcLMZbPXbTlWuvvQ==", + "HDxGhvdQwGh0aLRYEGFqnw==", + "HEcOaEd9zCoOVbEmroSvJg==", + "HEghmKg3GN60K7otpeNhaA==", + "HFCQEiZf7/SNc+oNSkkwlA==", + "HFHMGgfOeO0UPrray1G+Zw==", + "HGxe+5/kkh6R9GXzEOOFHA==", + "HHxn4iIQ7m0tF1rSd+BZBg==", + "HI4ZIE5s8ez8Rb+Mv39FxA==", + "HITIVoFoWNg04NExe13dNA==", + "HJYgUxFZ66fRT8Ka73RaUg==", + "HK0yf7F97bkf1VYCrEFoWA==", + "HK9xG03FjgCy8vSR+hx8+Q==", + "HLesnV3DL+FhWF3h6RXe8g==", + "HLxROy6fx/mLXFTDSX4eLA==", + "HMQarkPWOUDIg5+5ja2dBQ==", + "HMWOlMmzocOIiJ7yG1YaDQ==", + "HOi+vsGAae4vhr+lJ5ATnQ==", + "HPvYV94ufwiNHEImu4OYvQ==", + "HRF3WL/ue3/QlYyu7NUTrA==", + "HRWYX2XOdsOqYzCcqkwIyw==", + "HYylUirJRqLm+dkp39fSOQ==", + "HaHTsLzx7V3G1SFknXpGxA==", + "HaIRV9SNPRTPDOSX9sK/bg==", + "HaSc7MZphCMysTy2JbTJkw==", + "Hb+pdSavvJ9lUXkSVZW8Og==", + "HbT6W1Ssd3W7ApKzrmsbcg==", + "HbXv8InyZqFT7i3VrllBgg==", + "HdB7Se47cWjPgpJN0pZuiA==", + "HdXg64DBy5WcL5fRRiUVOg==", + "HeQbUuBM9sqfXFXRBDISSw==", + "HfvsiCQN/3mT0FabCU5ygQ==", + "HgIFX42oUdRPu7sKAXhNWg==", + "HhBHt5lQauNl7EZXpsDHJA==", + "HiAgt86AyznvbI2pnLalVQ==", + "HjlPM2FQWdILUXHalIhQ5w==", + "HjyxyL0db2hGDq2ZjwOOhg==", + "HkbdaMuDTPBDnt3wAn5RpQ==", + "Hm6MG6BXbAGURVJKWRM6ZA==", + "HnVfyqgJ+1xSsN4deTXcIA==", + "HoaBBw2aPCyhh0f5GxF+/Q==", + "Hs3vUOOs2TWQdQZHs+FaQQ==", + "Hst3yfyTB7yBUinvVzYROQ==", + "HtDXgMuF8PJ1haWk88S0Ew==", + "HuDuxs2KiGqmeyY1s1PjpQ==", + "HwLSUie8bzH+pOJT3XQFyg==", + "HxEU37uBMeiR5y8q/pM42g==", + "Hy1nqC40l5ItxumkIC2LAA==", + "I+wVQA+jpPTJ6xEsAlYucg==", + "I07W2eDQwe6DVsm1zHKM8A==", + "I5qDndyelK4Njv4YrX7S6w==", + "I9KNZC1tijiG1T72C4cVqQ==", + "IA1jmtfpYkz/E2wD0+27WA==", + "IADk81pIu8NIL/+9Fi94pA==", + "IAMInfSYb76GxDlAr1dsTg==", + "ICPdBCdONUqPwD5BXU5lrw==", + "IEz72W2/W8xBx5aCobUFOQ==", + "IHhyR6+5sZXTH+/NrghIPg==", + "IHyIeMad23fSDisblwyfpA==", + "IKgNa2oPaFVGYnOsL+GC5Q==", + "INNBBin5ePwTyhPIyndHHg==", + "IPLD9nT5EEYG9ioaSIYuuA==", + "ITYL3tDwddEdWSD6J6ULaA==", + "ITZ3P47ALS0JguFms6/cDA==", + "IUZ5aGpkJ9rLgSg6oAmMlw==", + "IUwVHH6+8/0c+nOrjclOWA==", + "IWZnTJ3Hb9qw9HAK/M9gTw==", + "IYIP2UBRyWetVfYLRsi1SQ==", + "IYIbEaErHoFBn8sTT9ICIQ==", + "IbN736G1Px5bsYqE5gW1JQ==", + "IdadoCPmSgHDHzn1zyf8Jw==", + "IdmcpJXyVDajzeiGZixhSA==", + "IhHyHbHGyQS+VawxteLP0w==", + "IhpXs1TK7itQ3uTzZPRP5Q==", + "IindlAnepkazs5DssBCPhA==", + "IjmLaf3stWDAwvjzNbJpQA==", + "Ily2MKoFI1zr5LxBy93EmQ==", + "Iqszlv4R49UevjGxIPMhIA==", + "IrDuBrVu1HWm0BthAHyOLQ==", + "Is3uxoSNqoIo5I15z6Z2UQ==", + "IshzWega6zr3979khNVFQQ==", + "It+K/RCYMOfNrDZxo7lbcA==", + "IwLbkL33z+LdTjaFYh93kg==", + "IwfeA6d0cT4nDTCCRhK+pA==", + "J/PNYu4y6ZMWFFXsAhaoow==", + "J/eAtAPswMELIj8K2ai+Xg==", + "J0NauydfKsACUUEpMhQg8A==", + "J1nYqJ7tIQK1+a/3sMXI/Q==", + "J2NFyb8cXEpZyxWDthYQiA==", + "J4MC9He6oqjOWsYQh9nl3Q==", + "J8v2f6hWFu8oLuwhOeoQjA==", + "JATLdpQm//SQnkyCfI5x7Q==", + "JBkbaBiorCtFq9M9lSUdMg==", + "JC8Q+8yOJ52NvtVeyHo68w==", + "JFFeXsFsMA59iNtZey7LAA==", + "JFHutgSe1/SlcYKIbNNYwQ==", + "JFi6N1PlrpKaYECOnI7GFg==", + "JGEy6VP3sz3LHiyT2UwNHQ==", + "JGeqHRQpf4No74aCs+YTfA==", + "JGx8sTyvr4bLREIhSqpFkw==", + "JHBjKpCgSgrNNACZW1W+1w==", + "JIC8R48jGVqro6wmG2KXIw==", + "JJJkp1TpuDx5wrua2Wml7g==", + "JJbzQ/trOeqQomsKXKwUpQ==", + "JKg64m6mU7C/CkTwVn4ASg==", + "JKmZqz9cUnj6eTsWnFaB0A==", + "JKphO0UYjFqcbPr6EeBuqg==", + "JLq/DrW2f26NaRwfpDXIEA==", + "JPxEncA4IkfBDvpjHsQzig==", + "JQf9UmutPh3tAnu7FDk3nA==", + "JSr/lqDej81xqUvd/O2s7w==", + "JSyhTcHLTfzHsPrxJyiVrA==", + "JSyq2MIuObPnEgEUDyALjQ==", + "JVSLiwurnCelNBiG2nflpQ==", + "JXCYeWjFqcdSf6QwB54G+A==", + "JYJvOZ4CHktLrYJyAbdOnA==", + "JZRjdJLgZ+S0ieWVDj8IJg==", + "Ja3ECL7ClwDrWMTdcSQ6Ug==", + "JaYQXntiyznQzrTlEeZMIw==", + "Jbxl8Nw1vlHO9rtu0q/Fpg==", + "Jcxjli2tcIAjCe+5LyvqdQ==", + "Je1UESovkBa9T6wS0hevLw==", + "JgXSPXDqaS1G9NqmJXZG0A==", + "JgxNrUlL8wutG04ogKFPvw==", + "JipruVZx4ban3Zo5nNM37g==", + "Jit0X0srSNFnn8Ymi1EY+g==", + "Jj4IrSVpqQnhFrzNvylSzA==", + "Jm862vBTCYbv/V4T1t46+Q==", + "JnE6BK0vpWIhNkaeaYNUzw==", + "JoATsk/aJH0UcDchFMksWA==", + "JquDByOmaQEpFb47ZJ4+JA==", + "JrKGKAKdjfAaYeQH8Y2ZRQ==", + "Js7g8Dr6XsnGURA4UNF0Ug==", + "Jt4Eg6MJn8O4Ph/K2LeSUA==", + "Ju4YwtPw+MKzpbC0wJsZow==", + "JvXTdChcE3AqMbFYTT3/wg==", + "JyIDGL1m/w+pQDOyyeYupA==", + "JyUJEnU6hJu8x2NCnGrYFw==", + "JzW+yhrjXW1ivKu3mUXPXg==", + "K1CGbMfhlhIuS0YHLG30PQ==", + "K1RL+tLjICBvMupe7QppIQ==", + "K1RgR6HR5uDEQgZ32TAFgA==", + "K2gk9zWGd0lJFRMQ1AjQ/Q==", + "K3NBEG8jJTJbSrYSOC3FKw==", + "K4VS+DDkTdBblG93l2eNkA==", + "K4yZNVoqHjXNhrZzz2gTew==", + "K5lhaAIZkGeP5rH2ebSJFw==", + "K8PVQhEJCEH1ghwOdztjRw==", + "K9A87aMlJC8XB9LuFM913g==", + "KCJJfgLe00+tjSfP6EBcUg==", + "KGI/cXVz6v6CfL8H6akcUQ==", + "KI7tQFYW38zYHOzkKp9/lQ==", + "KO2XVYyNZadcQv8aCNn5JA==", + "KOm8PTa+ICgDrgK9QxCJZw==", + "KOmdvm+wJuZ/nT/o1+xOuw==", + "KPh6TwYpspne4KZA6NyMbw==", + "KQw25X4LnQ9is+qdqfxo0w==", + "KR401XBdgCrtVDSaXqPEiA==", + "KSorNz/PLR/YYkxaj1fuqw==", + "KSumhnbKxMXQDkZIpDSWmQ==", + "KTjwL+qswa+Bid8xLdjMTg==", + "KXuFON8tMBizNkCC48ICLA==", + "KXvdjZ3rRKn60djPTCENGA==", + "KYuUNrkTvjUWQovw9dNakA==", + "Kh/J1NpDBGoyDU+Mrnnxkg==", + "KhUT2buOXavGCpcDOcbOYg==", + "KhrIIHfqXl9zGE9aGrkRVg==", + "Kj1QI+s9261S3lTtPKd9eg==", + "KjfL7YyVqmCJGBGDFdJ0gw==", + "KjnL3x+56r3M2pDj1pPihA==", + "KkXlgPJPen6HLxbNn5llBw==", + "KkwQL0DeUM3nPFfHb2ej+A==", + "KlY5TGg0pR/57TVX+ik1KQ==", + "KmcGEE0pacQ/HDUgjlt7Pg==", + "KodYHHN62zESrXUye7M01g==", + "Koiog/hpN7ew5kgJbty34A==", + "Kt6BTG1zdeBZ3nlVk+BZKQ==", + "KuNY8qAJBce+yUIluW8AYw==", + "KujFdhhgB9q4oJfjYMSsLg==", + "KyLQxi5UP+qOiyZl0PoHNQ==", + "KzWdWPP2gH0DoMYV4ndJRg==", + "Kzs+/IZJO8v4uIv9mlyJ2Q==", + "L+N/6geuokiLPPSDXM9Qkg==", + "L2D7G0btrwxl9V4dP3XM5Q==", + "L2IeUnATZHqOPcrnW2APbA==", + "L2RofFWDO0fVgSz4D2mtdw==", + "L3Jt5dHQpWQk74IAuDOL8g==", + "L4+C6I7ausPl6JbIbmozAg==", + "LATQEY7f47i77M6p11wjWA==", + "LCj4hI520tA685Sscq6uLw==", + "LCvz/h9hbouXCmdWDPGWqg==", + "LDuBcL5r3PUuzKKZ9x6Kfw==", + "LEVYAE54618FrlXkDN01Kw==", + "LFcpCtnSnsCPD2gT/RA+Zg==", + "LGwcvetzQ3QqKjNh5vA8vw==", + "LHQETSI5zsejvDaPpsO29g==", + "LJeLdqmriyAQp+QjZGFkdQ==", + "LJtRcR70ug6UHiuqbT6NGw==", + "LKyOFgUKKGUU/PxpFYMILw==", + "LMCZqd3UoF/kHHwzTdj7Tw==", + "LMEtzh0+J27+4zORfcjITw==", + "LPYFDbTEp5nGtG6uO8epSw==", + "LQttmX92SI94+hDNVd8Gtw==", + "LSN9GmT6LUHlCAMFqpuPIA==", + "LUWxfy4lfgB5wUrqCOUisw==", + "LWWfRqgtph1XrpxF4N64TA==", + "LWd0+N3M94n81qd346LfJQ==", + "LZAKplVoNjeQgfaHqkyEJA==", + "La0gzdbDyXUq6YAXeKPuJA==", + "LawT9ZygiVtBk0XJ+KkQgQ==", + "LbPp1oL0t3K2BAlIN+l8DA==", + "LblwOqNiciHmt2NXjd89tg==", + "LcF0OqPWrcpHby8RwXz1Yg==", + "LcoJBEPTlSsQwfuoKQUxEw==", + "LhqRc9oewY4XaaXTcnXIHQ==", + "Lo1xTCEWSxVuIGEbBEkVxA==", + "LoUv/f2lcWpjftzpdivMww==", + "LpoayYsTO8WLFLCSh2kf2w==", + "Lqel4GdU0ZkfoJVXI5WC/Q==", + "LqgzKxbI6WTMz0AMIDJR5w==", + "LsmsPokAwWNCuC74MaqFCQ==", + "Lt/pVD4TFRoiikmgAxEWEw==", + "Lu02ic/E94s42A14m7NGCA==", + "LyPXOoOPMieqINtX8C9Zag==", + "LyYPOZKm8bBegMr5NTSBfg==", + "M/cQja3uIk1im9++brbBOA==", + "M0ESOGwJ4WZ4Ons1ljP0bQ==", + "M20iX2sUfw5SXaZLZYlTaA==", + "M2JMnViESVHTZaru6LDM6w==", + "M2suCoFHJ5fh9oKEpUG3xA==", + "M55eersiJuN9v61r8DoAjQ==", + "M98hjSxCwvZ27aBaJTGozQ==", + "M9oqlPb63e0kZE0zWOm+JQ==", + "MArbGuIAGnw4+fw6mZIxaw==", + "MBjMU/17AXBK0tqyARZP5w==", + "MFeXfNZy6Q9wBfZmPQy3xg==", + "MI+HSMRh8KTW+Afiaxd/Fw==", + "MJ1FuK8PXcmnBAG9meU84A==", + "MK7AqlJIGqK2+K5mCvMXRQ==", + "ML7ipnY/g8mA1PUIju1j8Q==", + "MLHt6Ak288G0RGhCVaOeqA==", + "MLlVniZ08FHAS5xe+ZKRaA==", + "MMaegl2Md9s/wOx5o9564w==", + "MN94B0r5CNAF9sl3Kccdbw==", + "MOrAbuJTyGKPC6MgYJlx5Q==", + "MQYM3BT77i35LG9HcqxY2Q==", + "MQvAr+OOfnYnr/Il/2Ubkg==", + "MUkRa/PjeWMhbCTq43g6Aw==", + "MVoxyIA+emaulH8Oks8Weg==", + "MWcV03ULc0vSt/pFPYPvFA==", + "MbI04HlTGCoc/6WDejwtaQ==", + "MdvhC1cuXqni/0mtQlSOCw==", + "MeKXnEfxeuQu9t3r/qWvcw==", + "MfkyURTBfkNZwB+wZKjP4g==", + "Mj87ajJ/yR41XwAbFzJbcA==", + "Ml3mi1lGS1IspHp3dYYClg==", + "MlKWxeEh8404vXenBLq4bw==", + "MlOOZOwcRGIkifaktEq0aQ==", + "MnStiFQAr3QlaRZ02SYGaQ==", + "Mofqu40zMRrlcGRLS42eBw==", + "MpAwWMt7bcs4eL7hCSLudQ==", + "MqqDg9Iyt4k3vYVW5F+LDw==", + "Mr5mCtC53+wwmwujOU/fWw==", + "MrbEUlTagbesBNg0OemHpw==", + "MrxR3cJaDHp0t3jQNThEyg==", + "MsCloSmTFoBpm7XWYb+ueQ==", + "Muf2Eafcf9G3U2ZvQ9OgtQ==", + "MvMbvZNKbXFe2XdN+HtnpQ==", + "N+K1ibXAOyMWdfYctNDSZQ==", + "N/HgDydvaXuJvTCBhG/KtA==", + "N2KovXW14hN/6+iWa1Yv3g==", + "N2X7KWekNN+fMmwyXgKD5w==", + "N3YDSkBUqSmrmNvZZx4a1Q==", + "N4/mQFyhDpPzmihjFJJn6w==", + "N65PqIWiQeS082D6qpfrAg==", + "N7fHwb397tuQHtBz1P80ZQ==", + "N8dXCawxSBX40fgRRSDqlQ==", + "N9nD7BGEM7LDwWIMDB+rEQ==", + "NBmB/cQfS+ipERd7j9+oVg==", + "ND2hYtAIQGMxBF7o7+u7nQ==", + "ND9l4JWcncRaSLATsq0LVw==", + "NDZWIhhixq7NT8baJUR4VQ==", + "NGApiVkDSwzO45GT57GDQw==", + "NKGY0ANVZ0gnUtzVx1pKSw==", + "NKRzJndo2uXNiNppVnqy1g==", + "NMbAjbnuK7EkVeY3CQI5VA==", + "NN/ymVQNa17JOTGr6ki3eQ==", + "NOmu8oZc6CcKLu+Wfz2YOQ==", + "NQVQfN3nIg9ipHiFh4BvfQ==", + "NRyFx6jqO/oo9ojvbYzsAg==", + "NSrzwNlB0bde3ph8k6ZQcQ==", + "NZtcY8fIpSKPso/KA6ZfzA==", + "Nc5kiwXCAyjpzt43G5RF1A==", + "NdULoUDGhIolzw1PyYKV0A==", + "NdVyHoTbBhX6Umz/9vbi0g==", + "Ndx5LDiVyyTz/Fh3oBTgvA==", + "Nf9fbRHm844KZ2sqUjNgkA==", + "NfxVYc3RNWZwzh2RmfXpiA==", + "Ng5v/B9Z10TTfsDFQ/XrXQ==", + "NhZbSq0CjDNOAIvBHBM9zA==", + "NiQ/m4DZXUbpca9aZdzWAw==", + "NiawWuMBDo0Q3P2xK/vnLQ==", + "NjeDgQ1nzH1XGRnLNqCmSg==", + "NmQrsmb8PVP05qnSulPe5Q==", + "NmWmDxwK5FpKlZbo0Rt8RA==", + "NoX8lkY+kd2GPuGjp+s0tQ==", + "NquRbPn8fFQhBrUCQeRRoQ==", + "Nr4zGo5VUrjXbI8Lr4YVWQ==", + "Nsd+DfRX6L54xs+iWeMjCQ==", + "NtwqUO3SKZE/9MXLbTJo/g==", + "NuBYjwlxadAH+vLWYRZ3bg==", + "NvkR0inSzAdetpI4SOXGhw==", + "NvurnIHin4O+wNP7MnrZ1w==", + "NxSdT2+MUkQN49pyNO2bJw==", + "NyF+4VRog7etp90B9FuEjA==", + "O/EizzJSuFY8MpusBRn7Tg==", + "O1ckWUwuhD44MswpaD6/rw==", + "O209ftgvu0vSr0UZywRFXA==", + "O538ibsrI4gkE5tfwjxjmg==", + "O5N2yd+QQggPBinQ+zIhtQ==", + "O7JiE0bbp583G6ZWRGBcfw==", + "O839JUrR+JS30/nOp428QA==", + "OChiB4BzcRE8Qxilu6TgJg==", + "OEJ40VmMDYzc2ESEMontRA==", + "OERGn45uzfDfglzFFn6JAg==", + "OFLn4wun6lq484I7f6yEwg==", + "OGpsXRHlaN8BvZftxh1e7A==", + "OHJBT2SEv5b5NxBpiAf7oQ==", + "OIwtfdq37eQ0qoXuB2j7Hw==", + "OMO4pqzfcbQ11YO4nkTXfg==", + "OONAvFS/kmH7+vPhAGTNSg==", + "OOS6wQCJsXH8CsWEidB35A==", + "OVHqwV8oQMC5KSMzd5VemA==", + "OaNpzwshdHUZMphQXa6i8w==", + "Oc3BqTF3ZBW3xE0QsnFn/A==", + "OlpA9HsF8MBh7b45WZSSlg==", + "OlwHO6Sg2zIwsCOCRu0HiQ==", + "Omi2ZB9kdR1HrVP2nueQkA==", + "Omr+zPWVucPCSfkgOzLmSQ==", + "OnmvXbyT2BYsSDJYZhLScA==", + "OpC/sL320wl5anx6AVEL+A==", + "OpL+vHwPasW30s2E1TYgpA==", + "OrqJKjRndcZ8OjE3cSQv7g==", + "Otz/PgYOEZ1CQDW54FWJIQ==", + "OwArFF1hpdBupCkanpwT+Q==", + "OwIGvTh8FPFqa4ijNkguAw==", + "Owg8qCpjZa+PmbhZew6/sw==", + "OzFRv+PzPqTNmOnvZGoo5g==", + "OzH7jTcyeM7RPVFtBdakpQ==", + "OzMR5D2LriC5yrVd5hchnA==", + "P0Pc8owrqt6spdf7FgBFSw==", + "P14k+fyz0TG9yIPdojp52w==", + "P3y5MoXrkRTSLhCdLlnc4A==", + "P430CeF2MDkuq11YdjvV8A==", + "P5WPQc5NOaK7WQiRtFabkw==", + "P5fucOJhtcRIoElFJS4ffg==", + "P5wS+xB8srW4a5KDp/JVkA==", + "P7eMlOz9YUcJO+pJy0Kpkw==", + "P8lUiLFoL100c9YSQWYqDA==", + "PAlx9+U+yQCAc5Fi0BOG0w==", + "PBULPuFXb6V3Di713n3Gug==", + "PCOGl7GIqbizAKj/sZmlwQ==", + "PD+yHtJxZJ2XEvjIPIJHsQ==", + "PF0lpolQQXlpc3qTLMBk8w==", + "PHwJ5ZAqqftZ4ypr8H1qiQ==", + "PKtXc4x4DEjM45dnmPWzyg==", + "PMCWKgog/G+GFZcIruSONw==", + "PMvG4NqJP76kMRAup6TSZA==", + "PPa7BDMpRdxJdBxkuWCxKA==", + "PTAm/jGkie7OlgVOvPKpaA==", + "PTW+fhZq/ErxHqpM0DZwHQ==", + "PXC6ZpdMH0ATis/jGW12iA==", + "PaROi5U16Tk35p0EKX5JpA==", + "ParhxI6RtLETBSwB0vwChQ==", + "PbDVq2Iw1eeM8c2o/XYdTA==", + "PbnxuVerGwHyshkumqAARg==", + "Pc+u0MAzp4lndTz4m6oQ5w==", + "PcdBtV8pfKU0YbDpsjPgwg==", + "PcoVtZrS1x1Q+6nfm4f80w==", + "PdBgXFq5mBqNxgCiqaRnkw==", + "PeJS+mXnAA6jQ0WxybRQ8w==", + "PfkWkSbAxIt1Iso0znW0+Q==", + "PggVPQL5YKqSU/1asihcrg==", + "PibGJQNw7VHPTgqeCzGUGA==", + "Po0lhBfiMaXhl+vYh1D8gA==", + "PolhKCedOsplEcaX4hQ0YQ==", + "Pp1ZMxJ8yajdbfKM4HAQxA==", + "PqLCd/pwc+q5GkL6MB0jTg==", + "Pt3i49uweYVgWze3OjkjJA==", + "Pu9pEf+Tek3J+3jmQNqrKw==", + "Pv9FWQEDLKnG/9K9EIz4Gw==", + "PwvPBc+4L73xK22S9kTrdA==", + "PxReytUUn/BbxYTFMu1r2Q==", + "PybPZhJErbRTuAafrrkb3g==", + "Q0TJZxpn3jk67L7N+YDaNA==", + "Q1pdQadt12anX1QRmU2Y/A==", + "Q3TpCE+wnmH/1h/EPWsBtQ==", + "Q4bfQslDSqU64MOQbBQEUw==", + "Q6vGRQiNwoyz7bDETGvi5g==", + "Q7Df6zGwvb4rC+EtIKfaSw==", + "Q7teXmTHAC5qBy+t7ugf0w==", + "Q8RVI/kRbKuXa8HAQD7zUA==", + "QAz7FA+jpz9GgLvwdoNTEQ==", + "QCpzCTReHxGm5lcLsgwPCA==", + "QGYFMpkv37CS2wmyp42ppg==", + "QH36wzyIhh6I56Vnx79hRA==", + "QH3lAwOYBAJ0Fd5pULAZqw==", + "QIKjir/ppRyS63BwUcHWmw==", + "QJEbr3+42P9yiAfrekKdRQ==", + "QTz21WkhpPjfK8YoBrpo+w==", + "QV0OG5bpjrjku4AzDvp9yw==", + "QVwuN66yPajcjiRnVk/V8g==", + "QWURrsEgxbJ8MWcaRmOWqw==", + "Qc+XYy2qyWJ5VVwd2PExbw==", + "Qf7JFJJuuacSzl6djUT2EQ==", + "Qg1ubGl+orphvT990e5ZPA==", + "QiozlNcQCbqXtwItWExqJQ==", + "QmSBVvdk0tqH9RAicXq2zA==", + "QmcURiMzmVeUNaYPSOtTTg==", + "QoUC9nyK1BAzoUVnBLV2zw==", + "QoqHzpHDHTwQD5UF30NruQ==", + "QozQL0DTtr+PXNKifv6l6g==", + "Qrh7OEHjp80IW+YzQwzlJg==", + "QsquNcCZL9wv7oZFqm64vQ==", + "QtD35QhE8sAccPrDnhtQmQ==", + "Qv6wWP4PpycDGxe7EZNSCw==", + "QvYZxsLdu+3nV/WhY1DsYg==", + "Qx6rVv9Xj8CBjqikWI9KFA==", + "QyyiJ5I/OZC50o89fa5EmQ==", + "R+beucURp/H5jLs4kW6wmg==", + "R/y6+JJP8rzz1KITJ4qWBw==", + "R1TCCfgltnXBvt5AiUnCtQ==", + "R2OOV18CV/YpWL1xzr/VQg==", + "R2Use39If2C0FVBP7KDerA==", + "R36O31Pj8jn0AWSuqI7X2Q==", + "R3ijnutzvK6IKV3AKHQZSA==", + "R5oOM58zdbVxFSDQnNWqeA==", + "R6Me6sSGP5xpNI8R0xGOWw==", + "R6cO8GzYfOGTIi773jtkXw==", + "R81DX/5a7DYKkS4CU+TL+w==", + "R8FxgXWKBpEVbnl41+tWEw==", + "R8ULpSNu9FcCwXZM0QedSg==", + "R906Kxp2VFVR3VD+o6Vxcw==", + "R97chlspND/sE9/HMScXjQ==", + "RAAw14BA1ws5Wu/rU7oegw==", + "RAECgYZmcF4WxcFcZ4A0Ww==", + "RBMv0IxXEO3o7MnV47Bzow==", + "RClzwwKh51rbB4ekl99EZA==", + "RDgGGxTtcPvRg/5KRRlz4w==", + "REnDNe9mGfqVGZt+GdsmjQ==", + "RHKCMAqrPjvUYt13BVcmvw==", + "RHToSGASrwEmvzjX6VPvNQ==", + "RIVYGO2smx9rmRoDVYMPXw==", + "RIZYDgXqsIdTf9o2Tp/S7g==", + "RJJqFMeiCZHdsqs72J17MQ==", + "RKVDdE1AkILTFndYWi9wFg==", + "RM5CpIiB94Sqxi462G7caA==", + "RNK9G1hfuz3ETY/RmA9+aA==", + "RNdyt6ZRGvwYG5Ws3QTuEA==", + "ROSt+NlEoiPFtpRqKtDUrQ==", + "RQOlmzHwQKFpafKPJj0D8w==", + "RQywrOLZEKw9+kG6qTzr3g==", + "RUmhye56tQu9xXs4SRJpOQ==", + "RVD3Ij6sRwwxTUDAxwELtA==", + "RWI0HfpP7643OSEZR8kxzw==", + "RYkDwwng6eeffPHxt8iD9A==", + "RZTpYKxOAH9JgF1QFGN+hw==", + "RfSwpO/ywQx4lfgeYlBr2w==", + "RgtwfY5pTolKrUGT+6Pp6g==", + "RhcqXY4OsZlVVF7ZlkTeRw==", + "RiahBXX2JbPzt8baPiP/8g==", + "RkQK9S1ezo+dFYHQP57qrw==", + "RlNPyhgYOIn28R4vKCVtYA==", + "RnOXOygwJFqrD+DlM3R5Ew==", + "RnxOYPSQdHS6fw4KkDJtrA==", + "RppDe/WGt1Ed6Vqg1+cCkQ==", + "RqYpA5AY7mKPaSxoQfI1CA==", + "RrE3B3X/SJi3CqCUlTYwaw==", + "Rrq0ak9YexLqqbSD4SSXlw==", + "Rs8deApkoosIJSfX7NXtAA==", + "RuLeQHP1wHsxhdmYMcgtrQ==", + "RvXWAFwM+mUAPW1MjPBaHA==", + "Rvchz/xjcY9uKiDAkRBMmA==", + "Rww3qkF3kWSd+AaMT0kfdw==", + "RxmdoO8ak8y/HzMSIm+yBQ==", + "Ry3zgZ6KHrpNyb7+Tt2Pkw==", + "RzeH+G3gvuK1z+nJGYqARQ==", + "S+b37XhKRm8cDwRb1gSsKQ==", + "S2MAIYeDQeJ1pl9vhtYtUg==", + "S3VQa6DH+BdlSrxT/g6B5g==", + "S47hklz3Ow+n5aY6+qsCoA==", + "S4RvORcJ3m6WhnAgV4YfYA==", + "S4rFuiKLFKZ+cL7ldiTwpg==", + "S7Vjy/gOWp0HozPP1RUOZw==", + "S8jlvuYuankCnvIvMVMzmg==", + "S9L29U2P5K8wNW+sWbiH7w==", + "SCO9nQncEcyVXGCtx30Jdg==", + "SChDh/Np1HyTPWfICfE1uA==", + "SDi5+FoP9bMyKYp+vVv1XA==", + "SEGu+cSbeeeZg4xWwsSErQ==", + "SEIZhyguLoyH7So0p1KY0A==", + "SESKbGF35rjO64gktmLTWA==", + "SElc2+YVi3afE1eG1MI7dQ==", + "SFn78uklZfMtKoz2N0xDaQ==", + "SIuKH/Qediq0TyvqUF93HQ==", + "SM7E98MyViSSS9G0Pwzwyw==", + "SNPYH4r/J9vpciGN2ybP5Q==", + "SOdpdrk2ayeyv0xWdNuy9g==", + "SPGpjEJrpflv1hF0qsFlPw==", + "SPHU6ES1WVm0Mu2LB+YjrA==", + "SSKhl2L3Mvy93DcZulADtA==", + "SUAwMWLMml8uGqagz5oqhQ==", + "SVFbcjXbV7HRg+7jUrzpwg==", + "SVLHWPCCH7GPVCF7QApPbw==", + "SVuEYfQ9FGyVMo1672n0Yg==", + "SbMjjI8/P8B9a9H2G0wHEQ==", + "Scto+9TWxj1eZgvNKo+a9A==", + "SfwnYZCKP1iUJyU1yq4eKg==", + "SiSlasZ+6U2IZYogqr2UPg==", + "Slu3z535ijcs5kzDnR7kfA==", + "SmRWEzqddY9ucGAP5jXjAg==", + "Sr9c0ReRpkDYGAiqSy683g==", + "Srl4HivgHMxMOUHyM3jvNw==", + "StDtLMlCI75g4XC59mESEQ==", + "StoXC7TBzyRViPzytAlzyQ==", + "StpQm/cQF8cT0LFzKUhC5w==", + "SusSOsWNoAerAIMBVWHtfA==", + "Swjn3YkWgj0uxbZ1Idtk+A==", + "SzCGM8ypE58FLaR1+1ccxQ==", + "Szko0IPE7RX2+mfsWczrMg==", + "T/6gSz2HwWJDFIVrmcm8Ug==", + "T1pMWdoNDpIsHF8nKuOn2A==", + "T6LA+daQqRI38iDKZTdg1A==", + "T7waQc3PvTFr0yWGKmFQdQ==", + "T9WoUJNwp8h4Yydixbx6nA==", + "TA9WjiLAFgJubLN4StPwLw==", + "TAD0Lk95CD86vbwrcRogaQ==", + "TBQpcKq2huNC5OmI2wzRQw==", + "TDrq23VUdzEU/8L5i8jRJQ==", + "TGB+FIzzKnouLh5bAiVOQg==", + "THfzE2G2NVKKfO+A2TjeFw==", + "THs1r8ZEPChSGrrhrNTlsA==", + "TI90EuS/bHq/CAlX32UFXg==", + "TIKadc6FAaRWSQUg5OATgg==", + "TIWSM78m0RprwgPGK/e0JA==", + "TLJbasOoVO435E5NE5JDcA==", + "TNyvLixb03aP2f8cDozzfA==", + "TSGL3iQYUgVg/O9SBKP9EA==", + "TSPFvkgw6uLsJh66Ou0H9w==", + "TVlHoi8J7sOZ2Ti7Dm92cQ==", + "TXab/hqNGWaSK+fXAoB2bg==", + "TYlnrwgyeZoRgOpBYneRAg==", + "TZ3ATPOFjNqFGSKY3vP2Hw==", + "TZT86wXfzFffjt0f95UF5w==", + "TafM7nTE5d+tBpRCsb8TjQ==", + "TahqPgS7kEg+y6Df0HBASw==", + "TcFinyBrUoAEcLzWdFymow==", + "TcGhAJHRr7eMwGeFgpFBhg==", + "TcyyXrSsQsnz0gJ36w4Dxw==", + "TeBGJCqSqbzvljIh9viAqA==", + "TfHvdbl2M4deg65QKBTPng==", + "TfNHjSTV8w6Pg6+FaGlxvA==", + "TgWe70YalDPyyUz6n88ujg==", + "Tk5MAqd1gyHpkYi8ErlbWg==", + "TlJizlASbPtShZhkPww4UA==", + "Tm4zk2Lmg8w4ITMI31NfTA==", + "Tmx0suRHzlUK4FdBivwOwA==", + "Tp52d1NndiC9w3crFqFm9g==", + "TrLmfgwaNATh24eSrOT+pw==", + "TrWS+reCJ0vbrDNT5HDR9w==", + "Tu6w6DtX2RJJ3Ym3o3QAWw==", + "TuaG3wRdM9BWKAxh2UmAsg==", + "Tud+AMyuFkWYYZ73yoJGpQ==", + "Tug3eh+28ttyf+U7jfpg5w==", + "U+bB5NjFIuQr/Y5UpXHwxA==", + "U+oTpcjhc0E+6UjP11OE/Q==", + "U0KmEI6e5zJkaI4YJyA5Ew==", + "U49SfOBeqQV9wzsNkboi8Q==", + "U6VQghxOXsydh3Naa5Nz4A==", + "U9kE50Wq5/EHO03c5hE4Ug==", + "UAqf4owQ+EmrE45hBcUMEw==", + "UEMwF4kwgIGxGT4jrBhMPQ==", + "UHpge5Bldt9oPGo2oxnYvQ==", + "UIXytIHyVODxlrg+eQoARA==", + "UK+R+hAoVeZ4xvsoZjdWpw==", + "UNRlg6+CYVOt68NwgufGNA==", + "UNdKik7Vy23LjjPzEdzNsg==", + "UNt7CNMtltJWq8giDciGyA==", + "UP7NXAE0uxHRXUAWPhto0w==", + "UP9mmAKzeQqGhod7NCqzhg==", + "UPYR575ASaBSZIR3aX1IgQ==", + "UPzS4LR3p/h0u69+7YemrQ==", + "UQTQk5rrs6lEb1a+nkLwfg==", + "USCvrMEm/Wqeu9oX6FrgcQ==", + "USq1iF90eUv41QBebs3bhw==", + "UTmTgvl+vGiCDQpLXyVgOg==", + "UVEZPoH9cysC+17MKHFraw==", + "UXUNYEOffgW3AdBs7zTMFA==", + "UZoibx+y1YJy/uRSa9Oa2w==", + "Ua6aO6HwM+rY4sPR19CNFA==", + "UbABE6ECnjB+9YvblE9CYw==", + "UbSFw5jtyLk5MealqJw++A==", + "Ugt8HVC/aUzyWpiHd0gCOQ==", + "UgvtdE2eBZBUCAJG/6c0og==", + "Uh1mvZNGehK1AaI4a1auKQ==", + "Uje3Ild84sN41JEg3PEHDg==", + "UjmDFO7uzjl4RZDPeMeNyg==", + "Um1ftRBycvb+363a90Osog==", + "Umd+5fTcxa3mzRFDL9Z8Ww==", + "Uo+FIhw1mfjF6/M8cE1c/Q==", + "Uo1ebgsOxc3eDRds1ah3ag==", + "UreSZCIdDgloih8KLeX7gg==", + "UtLYUlQJ02oKcjNR3l+ktg==", + "Uudn69Kcv2CGz2FbfJSSEA==", + "UvC1WADanMrhT+gPp/yVqA==", + "Uw6Iw+TP9ZdZGm2b/DAmkg==", + "UwqBVd4Wfias4ElOjk2BzQ==", + "Uy4QI8D2y1bq/HDNItCtAw==", + "UymZUnEEQWVnLDdRemv+Tw==", + "UzPPFSXgeV7KW4CN5GIQXA==", + "V+QzdKh5gxTPp2yPC9ZNEg==", + "V/xG5QFyx1pihimKmAo8ZA==", + "V1fvtnJ0L3sluj9nI5KzRw==", + "V2P75JFB4Se9h7TCUMfeNA==", + "V5HEaY3v9agOhsbYOAZgJA==", + "V5HKdaTHjA8IzvHNd9C51g==", + "V6CRKrKezPwsRdbm0DJ2Yg==", + "V6zyoX6MERIybGhhULnZiw==", + "V7eji28JSg3vTi30BCS7gw==", + "V8m51xgUgywRoV6BGKUrgg==", + "V8q+xz4ljszLZMrOMOngug==", + "V9G1we3DOIQGKXjjPqIppQ==", + "V9vkAanK+Pkc4FGAokJsTA==", + "VAg/aU5nl72O+cdNuPRO4g==", + "VCL3xfPVCL5RjihQM59fgg==", + "VE4sLM5bKlLdk85sslxiLQ==", + "VGRCSrgGTkBNb8sve0fYnQ==", + "VH70dN82yPCRctmAHMfCig==", + "VI8pgqBZeGWNaxkuqQVe7g==", + "VIC7inSiqzM6v9VqtXDyCw==", + "VIkS30v268x+M1GCcq/A8A==", + "VJt2kPVBLEBpGpgvuv1oUw==", + "VK95g27ws2C6J2h/7rC2qA==", + "VOB+9Bcfu8aHKGdNO0iMRw==", + "VOvrzqiZ1EHw+ZzzTWtpsw==", + "VPa7DG6v7KnzMvtJPb88LQ==", + "VPqyIomYm7HbK5biVDvlpw==", + "VQIpquUqmeyt/q6OgxzduQ==", + "VRnx+kd6VdxChwsfbo1oeQ==", + "VUDsc9RMS1fSM43c+Jo9dQ==", + "VWNDBOtjiiI4uVNntOlu/A==", + "VWb8U4jF/Ic0+wpoXi/y/g==", + "VWy9lB5t4fNCp4O/4n8S4w==", + "VX+cVXV8p9i5EBTMoiQOQQ==", + "VXu4ARjq7DS2IR/gT24Pfw==", + "VZX1FnyC8NS2k3W+RGQm4g==", + "VaJc9vtYlqJbRPGb5Tf0ow==", + "VbCoGr8apEcN7xfdaVwVXw==", + "VbHoWmtiiPdABvkbt+3XKQ==", + "Vg2E5qEDfC+QxZTZDCu9yQ==", + "VhYGC8KYe5Up+UJ2OTLKUw==", + "Vik8tGNxO0xfdV0pFmmFDw==", + "ViweSJuNWbx5Lc49ETEs/A==", + "VjclDY8HN4fSpB263jsEiQ==", + "VllbOAjeW3Dpbj5lp2OSmA==", + "VoPth5hDHhkQcrQTxHXbuw==", + "VpmBstwR7qPVqPgKYQTA3g==", + "VsXEBIaMkVftkxt1kIh7TA==", + "Vu0E+IJXBnc25x4n41kQig==", + "VzQ1NwNv9btxUzxwVqvHQg==", + "VznvTPAAwAev+yhl9oZT0w==", + "W+M4BcYNmjj7xAximDGWsA==", + "W/0s1x3Qm+wN8DhROk6FrQ==", + "W/5ThNLu43uT1O+fg0Fzwg==", + "W04GeDh+Tk/I1S85KlozRA==", + "W2x0SBzSIsTRgyWUCOZ/lg==", + "W4CfeVp9mXgk04flryL7iA==", + "W4utAK3ws0zjiba/3i91YA==", + "W5now3RWSzzMDAxsHSl++Q==", + "W8bATujVUT80v2XGJTKXDg==", + "W8y32OLHihfeV0XFw7LmOg==", + "WADmxH7R6B4LR+W6HqQQ6A==", + "WBu0gJmmjVdVbjDmQOkU6w==", + "WGKFTWJac8uehn3N59yHJw==", + "WHutPin+uUEqtrA7L8878A==", + "WKehT4nGF2T7aKuzABDMlA==", + "WLsh3UF4WXdHwgnbKEwRlQ==", + "WLwpjgr9KzevuogoHZaVUw==", + "WN7lFJfw4lSnTCcbmt5nsg==", + "WNfDNaWUOqABQ6c6kR+eyw==", + "WQMffxULFKJ+bun6NrCURA==", + "WQznrwqvMhUlM3CzmbhAOQ==", + "WRjYdKdtnd1G9e/vFXCt0g==", + "WRoJMO0BCJyn5V6qnpUi4Q==", + "WTr3q/gDkmB4Zyj7Ly20+w==", + "WVhfn2yJZ43qCTu0TVWJwA==", + "WWN44lbUnEdHmxSfMCZc6w==", + "WY7mCUGvpXrC8gkBB46euw==", + "WbAdlac/PhYUq7J2+n5f+w==", + "Wd0dOs7eIMqW5wnILTQBtg==", + "WdCWezJU4JK43EOZ9YHVdg==", + "Wf2olJCYZRGTTZxZoBePuQ==", + "WjDqf1LyFyhdd8qkwWk+MA==", + "WkSJpxBa45XJRWWZFee7hw==", + "Wn+Vj4eiWx0WPUHr3nFbyA==", + "WnHK5ZQDR6Da5cGODXeo0A==", + "WrJMOuXSLKKzgmIDALkyNw==", + "WtT0QAERZSiIt2SFDiAizg==", + "WwraoO97OTalvavjUsqhxQ==", + "Wx9jh/teM0LJHrvTScssyQ==", + "WyCFB4+6lVtlzu3ExHAGbQ==", + "WzjvUJ4jZAEK7sBqw+m07A==", + "X/Gha4Ajjm/GStp/tv+Jvw==", + "X1PaCfEDScclLtOTiF5JUw==", + "X2Tawm2Cra6H7WtXi1Z4Qw==", + "X2YfnPXgF2VHVX95ZcBaxQ==", + "X4hrgqMIcApsjA9qOWBoCw==", + "X4kdXUuhcUqMSduqhfLpxA==", + "X4o0OkTz0ec70mzgwRfltA==", + "X6Ln4si8G5aKar52ZH/FEQ==", + "X6ulLp4noBgefQTsbuIbYQ==", + "X9QAaNjgiOeAWSphrGtyVw==", + "XA2hUgq3GVPpxtRYiqnclg==", + "XAq/C+XyR6m3uzzLlMWO5Q==", + "XEwOJG24eaEtAuBWtMxhwg==", + "XF/yncdoT4ruPeXCxEhl9Q==", + "XGAXhUFjORwKmAq9gGEcRg==", + "XHHEg/8KZioW/4/wgSEkbQ==", + "XHjrTLXkm/bBY/BewmJcCQ==", + "XJihma9zSRrXLC+T+VcFDA==", + "XLq/nWX8lQqjxsK9jlCqUg==", + "XOG1PYgqoG8gVLIbVLTQgg==", + "XSb71ae0v+yDxNF5HJXGbQ==", + "XTCcsVfEvqxnjc0K5PLcyw==", + "XV13yK0QypJXmgI+dj4KYw==", + "XV5MYe0Q7YMtoBD6/iMdSw==", + "XVVy3e6dTnO3HpgD6BtwQw==", + "XXFr0WUuGsH5nXPas7hR3Q==", + "Xconi1dtldH90Wou9swggw==", + "XddlSluOH6VkR7spFIFmdQ==", + "XdkxmYYooeDKzy7PXVigBQ==", + "XePy/hhnQwHXFeXUQQ55Vg==", + "XfBOCJwi2dezYzLe316ivw==", + "XfY+QUriCAA1+3QAsswdgg==", + "XgPHx2+ULpm14IOZU2lrDg==", + "XjjrIpsmATV/lyln4tPb+g==", + "Xo8ZjXOIoXlBjFCGdlPuZw==", + "XpGXh76RDgXC4qnTCsnNHA==", + "XqFSbgvgZn0CpaZoZiRauQ==", + "XqTK/2QuGWj50tGmiDxysA==", + "XqUO7ULEYhDOuT/I2J8BOA==", + "XqW7UBTobbV4lt1yfh0LZw==", + "XrFDomoH2qFjQ2jJ2yp9lA==", + "XsF7R12agx/KkRWl0TyXRA==", + "Xv0mNYedaBc57RrcbHr9OA==", + "XwKWd03sAz8MmvJEuN08xA==", + "Y1Nm3omeWX2MXaCjDDYnWQ==", + "Y1flEyZZAYxauMo4cmtJ1w==", + "Y26jxXvl79RcffH8O8b9Ew==", + "Y5KKN7t/v9JSxG/m1GMPSA==", + "Y5XR8Igvau/h+c1pRgKayg==", + "Y5iDQySR2c3MK7RPMCgSrw==", + "Y78dviyBS3Jq9zoRD5sZtQ==", + "Y7OofF9eUvp7qlpgdrzvkg==", + "Y7XpxIwsGK3Lm/7jX/rRmg==", + "Y7iDCWYrO1coopM3RZWIPg==", + "YA+zdEC+yEgFWRIgS1Eiqw==", + "YA0kMTJ82PYuLA4pkn4rfw==", + "YHM6NNHjmodv+G0mRLK7kw==", + "YK+q7uJObkQZvOwQ9hplMg==", + "YLz+HA6qIneP+4naavq44Q==", + "YNqIHCmBp/EbCgaPKJ7phw==", + "YPgMthbpcBN2CMkugV60hQ==", + "YVlRQHQglkbj3J2nHiP/Hw==", + "YXHQ3JI9+oca8pc/jMH6mA==", + "YZ39RIXpeLAhyMgmW2vfkQ==", + "YZt6HwCvdI5DRQqndA/hBQ==", + "YaUKOTyByjUvp1XaoLiW5Q==", + "YfbfE3WyYOW7083Y8sGfwQ==", + "YgVpC5d5V6K/BpOD663yQA==", + "YhLEPsi/TNyeUJw69SPYzQ==", + "Yig+Wh18VIqdsmwtwfoUQw==", + "Yjm5tSq1ejZn3aWqqysNvA==", + "YmaksRzoU+OwlpiEaBDYaQ==", + "YmjZJyNfHN5FaTL/HAm8ww==", + "YodhkayN5wsgPZEYN7/KNA==", + "YrEP9z2WPQ8l7TY1qWncDA==", + "YtZ8CYfnIpMd2FFA5fJ+1Q==", + "Yw4ztKv6yqxK9U1L0noFXg==", + "Yy2pPhITTmkEwoudXizHqQ==", + "YzTV0esAxBFVls3e0qRsnA==", + "Z+bsbVP91KrJvxrujBLrrQ==", + "Z0sjccxzKylgEiPCFBqPSA==", + "Z2MkqmpQXdlctCTCUDPyzw==", + "Z2rwGmVEMCY6nCfHO3qOzw==", + "Z5B+uOmPZbpbFWHpI9WhPw==", + "Z8T1b9RsUWf59D06MUrXCQ==", + "Z9bDWIgcq6XwMoU2ECDR5Q==", + "ZAQHWU6RMg4IadOxuaukyw==", + "ZCdad3AwhVArttapWFwT/Q==", + "ZH5Es/4lJ+D5KEkF1BVSGg==", + "ZIZx4MehWTVXPN9cVQBmyA==", + "ZItMIn1vhGqAlpDHclg0Ig==", + "ZJY+hujfd58mTKTdsmHoQQ==", + "ZJc7GV0Yb6MrXkpDVIuc8g==", + "ZKXxq9yr7NGBOHidht34uQ==", + "ZKeTDCboOgCptrjSfgu0xw==", + "ZKvox7BaQg4/p5jIX69Umw==", + "ZNrjP1fLdQpGykFXoLBNPw==", + "ZQ0ZnTsZKWxbRj7Tilh24Q==", + "ZQSDYgpsimK+lYGdXBWE/w==", + "ZRWyfXyXqAaOEjkzWl949Q==", + "ZRnR6i+5WKMRfs3BDRBCJg==", + "ZSmN8mmI9lDEHkJqBBg0Nw==", + "ZV8mEgJweIYk0/l0BFKetA==", + "ZVnErH1Si4u51QoT0OT7pA==", + "ZWXfE3uGU91WpPMGyknmqw==", + "ZXeMG5eqQpZO/SGKC4WQkA==", + "ZYW30FfgwHmW6nAbUGmwzA==", + "ZZImGypBWwYOAW43xDRWCQ==", + "ZaPsR9X77SNt7dLjMJUh8A==", + "ZbLVNTQSVZQWTNgC4ZGfQg==", + "ZcuIvc8fDI+2uF0I0uLiVA==", + "ZfRlID+pC1Rr4IY14jolMw==", + "ZgdpqFrVGiaHkh9o3rDszg==", + "ZgjifTVKmxOieco81gnccQ==", + "ZiJ/kJ9GneF3TIEm08lfvQ==", + "ZlBNHAiYsfaEEiPQ1z+rCA==", + "ZlOAnCLV1PkR0kb3E+Nfuw==", + "ZmVpw1TUVuT13Zw/MNI5hQ==", + "ZmblZauRqO5tGysY3/0kDw==", + "ZoNSxARrRiKZF5Wvpg7bew==", + "Zqd6+81TwYuiIgLrToFOTQ==", + "ZqjnqxZE/BjOUY0CMdVl0g==", + "ZqkmoGB0p5uT5J6XBGh7Tw==", + "ZrCezGLz38xKmzAom6yCTQ==", + "ZrCnZB/U/vcqEtI1cSvnww==", + "ZtWvgitOSRDWq7LAKYYd4Q==", + "ZtmnX24AwYAXHb2ZDC6MeQ==", + "ZuayB6IpbeITokKGVi9R5w==", + "ZvvxwDd0I6MsYd7aobjLUA==", + "ZyDh3vCQWzS5DI1zSasXWA==", + "ZybIEGf1Rn/26vlHmuMxhw==", + "ZydKlOpn2ySBW0G3uAqwuw==", + "ZygAjaN62XhW5smlLkks+Q==", + "Zyo0fzewcqXiKe2mAwKx5g==", + "ZyoaR1cMiKAsElmYZqKjLA==", + "Zz/5VMbw1TqwazReplvsEg==", + "ZzT5b0dYQXkQHTXySpWEaA==", + "ZzduJxTnXLD9EPKMn1LI4Q==", + "a/Y6IAVFv0ykRs9WD+ming==", + "a1aL8zQ+ie3YPogE3hyFFg==", + "a4EYNljinYTx9vb1VvUA6A==", + "a4rPqbDWiMivVzaRxvAj7g==", + "a5gZ5uuRrXEAjgaoh7PXAg==", + "a6IszND1m+6w+W+CvseC7g==", + "a6vem8n6WmRZAalDrHNP0g==", + "a7Pv1SOWYnkhIUC22dhdDA==", + "aD4QvtMlr8Lk/zZgZ6zIMg==", + "aEnHUfn7UE/Euh6jsMuZ7g==", + "aFJuE/s+Kbge4ppn+wulkA==", + "aIPde9CtyZrhbHLK740bfw==", + "aJFbBhYtMbTyMFBFIz/dTA==", + "aK9nybtiIBUvxgs1iQFgsw==", + "aLY2pCT0WfFO5EJyinLpPg==", + "aLh1XEUrfR9W82gzusKcOg==", + "aMa1yVA71/w6Uf1Szc9rMA==", + "aMmrAzoRWLOMPHhBuxczKg==", + "aN5x46Gw1VihRalwCt1CGg==", + "aOeJZUIZM9YWjIEokFPnzQ==", + "aRpdnrOyu5mWB1P5YMbvOA==", + "aRrcmH+Ud3mF1vEXcpEm4w==", + "aTWiWjyeSDVY/q8y9xc2zg==", + "aWZRql2IUPVe9hS3dxgVfQ==", + "aXqiibI6BpW3qilV6izHaQ==", + "aXrbsro7KLV8s4I4NMi4Eg==", + "aXs9qTEXLTkN956ch3pnOA==", + "aY6B28XdPnuYnbOy9uSP8A==", + "adJAjAFyR2ne1puEgRiH+g==", + "adT+OjEB2kqpeYi4kQ6FPg==", + "afMd/Hr3rYz/l7a3CfdDjg==", + "ahAbmGJZvUOXrcK6OydNGQ==", + "alJtvTAD7dH/zss/Ek1DMQ==", + "alqHQBz8V446EdzuVfeY5Q==", + "anyANMnNkUqr3JuPJz5Qzw==", + "apWEPWUvMC24Y+2vTSLXoA==", + "aqcOby9QyEbizPsgO3g0yw==", + "ash1r2J6B0PUxJe8P0otVQ==", + "asouSfUjJa8yfMG7BBe+fA==", + "auvG6kWMnhCMi7c7e9eHrw==", + "avFTp3rS6z5zxQUZQuaBHQ==", + "avZp5K7zJvRvJvpLSldNAw==", + "aw4CzX8pYbPVMuNrGCEcWg==", + "axEl7xXt/bwlvxKhI7hx4g==", + "ayBGGPEy++biljvGcwIjXA==", + "aySnrShOW4/xRSzl/dtSKQ==", + "ays5/F7JANIgPHN0vp2dqQ==", + "b06KGv5zDYsTxyTbQ9/eyA==", + "b0vZfEyuTja2JYMa20Rtbg==", + "b16O4LF7sVqB7aLU2f3F1A==", + "b3BQG9/9qDNC/bNSTBY/sQ==", + "b3q8kjHJPj9DWrz3yNgwjQ==", + "b4BoZmzVErvuynxirLxn0w==", + "b4aFwwcWMXsSdgS1AdFOXA==", + "b53qqLnrTBthRXmmnuXWvw==", + "b6rrRA0W247O+FfvDHbVCQ==", + "b85nxzs8xiHxaqezuDVWvg==", + "b8BZV1NfBdLi70ir4vYvZg==", + "bA2kaTpeXflTElTnQRp6GQ==", + "bBEndaOStXBpAK79FrgHaw==", + "bG+P+p34t/IJ1ubRiWg6IA==", + "bGGUhiG9SqJMHQWitXTcYQ==", + "bIk7Fa6SW7X18hfDjTKowg==", + "bJ1cZW7KsXmoLw0BcoppJg==", + "bJgsuw29cO2WozqsGZxl7w==", + "bK045TkBlz+/3+6n6Qwvrg==", + "bL2FuwsPT7a7oserJQnPcw==", + "bLEntCrCHFy9pg3T3gbBzg==", + "bLd38ZNkVeuhf0joEAxnBQ==", + "bLsStF0DDebpO+xulqGNtg==", + "bMWFvjM8eVezU1ZXKmdgqw==", + "bMb1ia0rElr2ZpZVhva0Jw==", + "bNDKcFu8T5Y6OoLSV+o/Sw==", + "bNq/hj0Cjt4lkLQeVxDVdQ==", + "bO55S58bqDiRWXSAIUGJKw==", + "bPRX2zl+K1S0iWAWUn1DZw==", + "bQ7J5mebp38rfP/fuqQOsg==", + "bQKkL+/KUCsAXlwwIH0N3w==", + "bTNRjJm+FfSQVfd56nNNqQ==", + "bUF0JIfS4uKd3JZj2xotLQ==", + "bUxQBaqKyvlSHcuRL9whjg==", + "bV9r7j2kNJpDCEM5E2339Q==", + "bWwtTFlhO3xEh/pdw0uWaQ==", + "bb/U8UynPHwczew/hxLQxw==", + "bbBsi6tXMVWyq3SDVTIXUg==", + "beSrliUu0BOadCWmx+yZyA==", + "bfUD03N2PRDT+MZ+WFVtow==", + "bhVbgJ4Do4v56D9mBuR/EA==", + "birqO8GOwGEI97zYaHyAuw==", + "bjLZ7ot/X/vWSVx4EYwMCg==", + "bkRdUHAksJZGzE1gugizYQ==", + "blygTgAHZJ3NzyAT33Bfww==", + "bs2QG8yYWxPzhtyMqO6u3A==", + "bsHIShcLS134C+dTxFQHyA==", + "bvbMJZMHScwjJALxEyGIyg==", + "bvyB6OEwhwCIfJ6KRhjnRw==", + "bz294kSG4egZnH2dJ8HwEg==", + "bzVeU2qM9zHuzf7cVIsSZw==", + "bzXXzQGZs8ustv0K4leklA==", + "c1wbFbN7AdUERO/xVPJlgw==", + "c3WVxyC5ZFtzGeQlH5Gw+w==", + "c5Tc7rTFXNJqYyc0ppW+Iw==", + "c5q/8n7Oeffv3B1snHM/lA==", + "c5ymZKqx/td1MiS2ERiz9A==", + "c6Yhwy/q3j7skXq52l36Ww==", + "cBBOQn7ZjxDku0CUrxq2ng==", + "cFFE2R4GztNoftYkqalqUQ==", + "cHSj5dpQ04h/WyefjABfmQ==", + "cHkOsVd80Rgwepeweq4S1g==", + "cLR0Ry4/N5swqga1R6QDMw==", + "cMo6l1EQESx1rIo+R4Vogg==", + "cNsC9bH30eM1EZS6IdEdtQ==", + "cSHSg9xJz/3F6kc+hKXkwg==", + "cT3PwwS6ALZA/na9NjtdzA==", + "cTvDd8okNUx0RCMer6O8sw==", + "cUyqCa7Oue934riyC17F8g==", + "cVhdRFuZaW/09CYPmtNv5g==", + "cWUg7AfqhiiEmBIu+ryImA==", + "cWdlhVZD7NWHUGte24tMjg==", + "cXpfd6Io6Glj2/QzrDMCvA==", + "ca+kx+kf7JuZ3pfYKDwFlg==", + "caepyBOAFu0MxbcXrGf6TA==", + "catI+QUNk3uJ+mUBY3bY8Q==", + "cbBXgB1WQ/i8Xul0bYY2fg==", + "ccK42Lm8Tsv73YMVZRwL6A==", + "cchuqe+CWCJpoakjHLvUfA==", + "ccmy4GVuX967KaQyycmO0w==", + "ccy3Ke2k4+evIw0agHlh3w==", + "cdWUm6uLNzR/knuj2x75eA==", + "cffrYrBX3UQhfX1TbAF+GQ==", + "cfh5VZFmIqJH/bKboDvtlA==", + "cgSEbLqqvDsNUyeA3ryJ6Q==", + "chwv4+xbEAa93PHg8q9zgQ==", + "ck86G8HsbXflyrK7MBntLg==", + "ckugAisBNX18eQz+EnEjjw==", + "cl4t9FXabQg7tbh1g7a0OA==", + "coGEgMVs2b314qrXMjNumQ==", + "cszpMdGbsbe6BygqMlnC9Q==", + "ctJYJegZhG42i+vnPFWAWw==", + "cu4ZluwohhfIYLkWp72pqA==", + "cuQslgfqD2VOMhAdnApHrA==", + "cvMJ714elj/HUh89a9lzOQ==", + "cvOg7N4DmTM+ok1NBLyBiQ==", + "cvZT1pvNbIL8TWg+SoTZdA==", + "cvrGmub2LoJ+FaM5HTPt9A==", + "cw1gBLtxH/m4H7dSM7yvFg==", + "cwBNvZc0u4bGABo88YUsVQ==", + "cxpZ4bloGv734LBf4NpVhA==", + "cxqHS4UbPolcYUwMMzgoOA==", + "czBWiYsQtNFrksWwoQxlOw==", + "d+ctfXU0j07rpRRzb5/HDA==", + "d/Wd3Ma1xYyoMByPQnA9Cw==", + "d0NBFiwGlQNclKObRtGVMQ==", + "d0VAZLbLcDUgLgIfT1GmVQ==", + "d0qvm3bl38rRCpYdWqolCQ==", + "d13Rj3NJdcat0K/kxlHLFw==", + "dAq8/1JSQf1f4QPLUitp0g==", + "dCDaYYrgASXPMGFRV0RCGg==", + "dChBe9QR29ObPFu/9PusLg==", + "dFSavcNwGd8OaLUdWq3sng==", + "dFetwmFw+D6bPMAZodUMZQ==", + "dG98w8MynOoX7aWmkvt+jg==", + "dGjcKAOGBd4gIjJq7fL+qQ==", + "dGrf9SWJ13+eWS6BtmKCNw==", + "dJHKDkfMFJeoULg7U4wwDQ==", + "dK2DU3t1ns+DWDwfBvH3SQ==", + "dL6n/JsK+Iq6UTbQuo/GOw==", + "dM9up4vKQV5LeX82j//1jQ==", + "dMRx4Mf6LrN64tiJuyWmDw==", + "dNTU+/2DdZyGGTdc+3KMhQ==", + "dNq2InSVDGnYXjkxPNPRxA==", + "dOS+mVCy3rFX9FvpkTxGXA==", + "dRFCIbVu0Y8XbjG5i+UFCQ==", + "dTMoNd6DDr1Tu8tuZWLudw==", + "dUx1REyXKiDFAABooqrKEA==", + "dVh/XMTUIx1nYN4q1iH1bA==", + "dXDPnL1ggEoBqR13aaW9HA==", + "dZg5w8rFETMp9SgW7m0gfg==", + "dZgMquvZmfLqP4EcFaWCiA==", + "daBhAvmE9shDgmciDAC5eg==", + "dhTevyxTYAuKbdLWhG47Kw==", + "dihDsG7+6aocG6M9BWrCzQ==", + "dmAfbd9F0OJHRAhNMEkRsA==", + "dml2gqLPsKpbIZ93zTXwCQ==", + "dnvatwSEcl73ROwcZ4bbIQ==", + "dpSTNOCPFHN5yGoMpl1EUA==", + "dqVw2q2nhCvTcW82MT7z0g==", + "drfODfDI6GyMW7hzkmzQvA==", + "dsueq9eygFXILDC7ZpamuA==", + "dtnE401dC0zRWU0S/QOTAg==", + "duRFqmvqF93uf/vWn8aOmg==", + "dxWv00FN/2Cgmgq9U3NVDQ==", + "e/nWuo5YalCAFKsoJmFyFA==", + "e2xLFVavnZIUUtxJx+qa1g==", + "e369ZIQjxMZJtopA//G55Q==", + "e4B3HmWjW+6hQzcOLru6Xg==", + "e5KCqQ/1GAyVMRNgQpYf6g==", + "e5l9ZiNWXglpw6nVCtO8JQ==", + "e5txnNRcGs2a9+mBFcF1Qg==", + "e9GqAEnk8XI5ix6kJuieNQ==", + "eAOEgF5N80A/oDVnlZYRAw==", + "eBapvE+hdyFTsZ0y5yrahg==", + "eC/RcoCVQBlXdE9WtcgXIw==", + "eCy/T+a8kXggn1L8SQwgvA==", + "eDWsx4isnr2xPveBOGc7Hw==", + "eDcyiPaB954q5cPXcuxAQw==", + "eFimq+LuHi42byKnBeqnZQ==", + "eFkXKRd2dwu/KWI5ZFpEzw==", + "eJDUejE/Ez/7kV+S74PDYg==", + "eJFIQh/TR7JriMzYiTw4Sg==", + "eJLrGwPRa6NgWiOrw1pA7w==", + "eJlcN+gJnqAnctbWSIO9uA==", + "eKQCVzLuzoCLcB4im8147A==", + "eLYKLr4labZeLiRrDJ9mnA==", + "ePlsM/iOMme2jEUYwi15ng==", + "eQ45Mvf5in9xKrP6/qjYbg==", + "eRwaYiog2DdlGQyaltCMJg==", + "eS/vTdSlMUnpmnl1PbHjyw==", + "eTMPXa60OTGjSPmvR4IgGw==", + "eV+RwWPiGEB+76bqvw+hbA==", + "eWgLAqJOU+fdn8raHb9HCw==", + "eXFOya6x5inTdGwJx/xtUQ==", + "eYAQWuWZX2346VMCD6s7/A==", + "eYE9No9sN5kUZ5ePEyS3+Q==", + "eddhS+FkXxiUnbPoCd5JJw==", + "edlXkskLx287vOBZ9+gVYg==", + "ehfPlu6YctzzpQmFiQDxGA==", + "ehwc2vvwNUAI7MxU4MWQZw==", + "ejfikwrSPMqEHjZAk3DMkA==", + "emVLJVzha7ui5OFHPJzeRQ==", + "enj9VEzLbmeOyYugTmdGfQ==", + "epY+dsm5EMoXnZCnO4WSHw==", + "es/L9iW8wsyLeC5S4Q8t+g==", + "eshD40tvOA6bXb0Fs/cH3A==", + "etRjRvfL/IwceY/IJ1tgzQ==", + "euxzbIq4vfGYoY3s1QmLcw==", + "evaWFoxZNQcRszIRnxqB+A==", + "ewPT4dM12nDWEDoRfiZZnA==", + "ewe/P3pJLYu/kMb5tpvVog==", + "ezsm4aFd6+DO9FUxz0A8Pg==", + "f/BjtP5fmFw2dRHgocbFlg==", + "f07bdNVAe9x+cAMdF1bByQ==", + "f09F7+1LRolRL5nZTcfKGA==", + "f0H/AFSx2KLZi9kVx5BAZg==", + "f1+fHgR5rDPsCZOzqrHM7Q==", + "f1Gs++Iilgq9GHukcnBG3w==", + "f1h+Vp+xmdZsZIziHrB2+g==", + "f5Xo7F1uaiM760Qbt978iw==", + "f6Ye5F0Lkn34uLVDCzogFQ==", + "f6iLrMpxKhFxIlfRsFAuew==", + "f9ywiGXsz+PuEsLTV3zIbQ==", + "fAKFfwlCOyhtdBK6yNnsNg==", + "fDOUzPTU2ndpbH0vgkgrJQ==", + "fFvXa1dbMoOOoWZdHxPGjw==", + "fHL+fHtDxhALZFb9W/uHuw==", + "fHNpW230mNib08aB7IM3XQ==", + "fKalNdhsyxTt1w08bv9fJA==", + "fM5uYpkvJFArnYiQ3MrQnA==", + "fO0+6TsjL+45p9mSsMRiIg==", + "fOARCnIg/foF/6tm7m9+3w==", + "fQS0jnQMnHBn7+JZWkiE/g==", + "fS471/rN4K2m10mUwGFuLg==", + "fSANOaHD0Koaqg7AoieY9A==", + "fU32wmMeD44UsFSqFY0wBA==", + "fU5ZZ1bIVsV+eXxOpGWo/Q==", + "fUAy3f9bAglLvZWvkO2Lug==", + "fVCRaPsTCKEVLkoF4y3zEw==", + "fW3QZyq5UixIA1mP6eWgqQ==", + "fX4G68hFL7DmEmjbWlCBJQ==", + "fY9VATklOvceDfHZDDk57A==", + "fZrj3wGQSt8RXv0ykJROcQ==", + "fbTm027Ms0/tEzbGnKZMDA==", + "fdqt93OrpG13KAJ5cASvkg==", + "fgXfRuqFfAu8qxbTi4bmhA==", + "fgdUFvQPb5h+Rqz8pzLsmw==", + "fhcbn9xE/6zobqQ2niSBgA==", + "fiv0DJivQeqUkrzDNlluRw==", + "fmC+85h5WBuk8fDEUWPjtQ==", + "fo3JL+2kPgDWfP+CCrFlFw==", + "foPAmiABJ3IXBoed2EgQXA==", + "foXSDEUwMhfHWJSmSejsQg==", + "fpXijBOM3Ai1RkmHven5Ww==", + "fsW2DaKYTCC7gswCT+ByQQ==", + "fsoXIbq0T0nmSpW8b+bj+g==", + "fsrX00onlGvfsuiCc35pGg==", + "ftsf2qztw3NC78ep/CZXWQ==", + "fv/PW8oexJYWf5De30fdLQ==", + "fvm0IQfnbfZFETg9v3z/Fg==", + "fxg/vQq9WPpmQsqQ4RFYaA==", + "fy54Milpa7KZH/zgrDmMXQ==", + "fzkmVWKhJsxyCwiqB/ULnQ==", + "g/z9yk94XaeBRFj4hqPzdw==", + "g0GbRp2hFVIdc7ct7Ky7ag==", + "g0aTR8aJ0uVy3YvGYu5xrw==", + "g0kHTNRI7x/lAsr92EEppw==", + "g0lWrzEYMntVIahC7i0O2g==", + "g1ELwsk6hQ+RAY1BH640Pg==", + "g2nh2xENCFOpHZfdEXnoQA==", + "g5EzTJ0KA4sO3+Opss3LMg==", + "g6udffWh7qUnSIo1Ldn3eA==", + "g6zSo8BvLuKqdmBFM1ejLA==", + "g8TcogVxHpw7uhgNFt5VCQ==", + "gAoV4BZYdW1Wm712YXOhWQ==", + "gB8wkuIzvuDAIhDtNT1gyA==", + "gBgJF0PiGEfcUnXF0RO7/w==", + "gC7gUwGumN7GNlWwfIOjJQ==", + "gDLjxT7vm07arF4SRX5/Vg==", + "gDxqUdxxeXDYhJk9zcrNyA==", + "gEHGeR2F82OgBeAlnYhRSw==", + "gFEnTI8os2BfRGqx9p5x8w==", + "gGLz3Ss+amU7y6JF09jq7A==", + "gICaI06E9scnisonpvqCsA==", + "gK7dhke5ChQzlYc/bcIkcg==", + "gR0sgItXIH8hE4FVs9Q07w==", + "gR3B8usSEb0NLos51BmJQg==", + "gTB2zM3RPm27mUQRXc/YRg==", + "gTnsH3IzALFscTZ1JkA9pw==", + "gU3gu8Y5CYVPqHrZmLYHbQ==", + "gUNP5w7ANJm257qjFxSJrA==", + "gW0oKhtQQ7BxozxUWw5XvQ==", + "gXlb7bbRqHXusTE5deolGA==", + "gYGQBLo5TdMyXks0LsZhsQ==", + "gYgCu/qUpXWryubJauuPNw==", + "gYnznEt9r97haD/j2Cko7g==", + "gYvdNJCDDQmNhtJ6NKSuTA==", + "gZNJ1Qq6OcnwXqc+jXzMLQ==", + "gZWTFt5CuLqMz6OhWL+hqQ==", + "gaEtlJtD6ZjF5Ftx0IFt0A==", + "gf1Ypna/Tt+TZ08Y+GcvGg==", + "gfhkPuMvjoC3CGcnOvki3Q==", + "gfnbviaVhKvv1UvlRGznww==", + "ggIfX1J4dX3xQoHnHUI7VA==", + "gglLMohmJDPRGMY1XKndjQ==", + "ghp8sWGKWw20S/z1tbTxFg==", + "ginkFyNVMwkZLE49AbfqfA==", + "gkrg0NR0iCaL7edq0vtewA==", + "glnqaRfwm6NxivtB2nySzw==", + "gnAIpoCyl3mQytLFgBEgGA==", + "gnez1VrH+UHT8C/SB9qGdA==", + "gnkadeCgjdmLdlu/AjBZJg==", + "goSgZ8N5UbT5NMnW3PjIlQ==", + "gqehq46BhFX2YLknuMv02w==", + "gsC/mWD8KFblxB0JxNuqJw==", + "gvvyX5ATi4q9NhnwxRxC8w==", + "gwyVIrTk5o0YMKQq4lpJ+Q==", + "gxwbqZDHLbQVqXjaq42BCg==", + "h+KRDKIvyVUBmRjv1LcCyg==", + "h0MH5NGFfChgmRJ3E/R3HQ==", + "h13Xuonj+0dD1xH86IhSyQ==", + "h1NNwMy0RjQmLloSw1hvdg==", + "h2B0ty0GobQhDnFqmKOpKQ==", + "h2cnQQF2/R3Mq2hWdDdrTg==", + "h3vYYI9yhpSZV2MQMJtwFQ==", + "h5HsEsObPuPFqREfynVblw==", + "h7Fc+eT/GuC8iWI+YTD0UQ==", + "hCzsi1yDv9ja5/o7t94j9Q==", + "hDGa2yLwNvgBd/v6mxmQaQ==", + "hDILjSpTLqJpiSSSGu445A==", + "hIABph+vhtSF5kkZQtOCTA==", + "hIJA+1QGuKEj+3ijniyBSQ==", + "hIjgi20+km+Ks23NJ4VQ6Q==", + "hJ8leLNuJ6DK5V8scnDaZQ==", + "hJSP7CostefBkJrwVEjKHA==", + "hK8KhTFcR06onlIJjTji/Q==", + "hKOsXOBoFTl/K4xE+RNHDA==", + "hN9bmMHfmnVBVr+7Ibd2Ng==", + "hNHqznsrIVRSQdII6crkww==", + "hP7dSa8lLn9KTE/Z0s4GVQ==", + "hPnPQOhz4QKhZi02KD6C+A==", + "hRxbdeniAVFgKUgB9Q3Y+g==", + "hSNZWNKUtDtMo6otkXA/DA==", + "hSkY45CeB6Ilvh0Io4W6cg==", + "hUWqqG1QwYgGC5uXJpCvJw==", + "hW9DJA1YCxHmVUAF7rhSmQ==", + "hWoxz5HhE50oYBNRoPp1JQ==", + "hY82j+sUQQRpCi6CCGea5A==", + "hZlX6qOfwxW5SPfqtRqaMw==", + "hdzol5dk//Q6tCm4+OndIA==", + "hf9HFxWRNX2ucH8FLS7ytA==", + "hfcH5Az2M7rp+EjtVpPwsg==", + "hiYg+aVzdBUDCG0CXz9kCw==", + "hkOBNoHbno2iNR7t3/d4vg==", + "hlMumZ7RJFpILuKs09ABtw==", + "hlu7os0KtAkpBTBV6D2jyQ==", + "hlvtFGW8r0PkbUAYXEM+Hw==", + "hnCUnoxofUiqQvrxl73M8w==", + "hq35Fjgvrcx6I9e6egWS4w==", + "hqeSvwu8eqA072iidlJBAw==", + "htDbVu1xGhCRd8qoMlBoMg==", + "htNVAogFakQkTX6GHoCVXg==", + "hv5GrLEIjPb4bGOi8RSO0w==", + "hvsZ5JmVevK1zclFYmxHaw==", + "hy303iin+Wm7JA6MeelwiQ==", + "i2sSvrTh/RdLJX0uKhbrew==", + "i42XumprV/aDT5R0HcmfIQ==", + "i6ZYpFwsyWyMJNgqUMSV1A==", + "i6r+mZfyhZyqlYv56o0H+w==", + "i8XXN7jcrmhnrOVDV8a2Hw==", + "i9IRqAqKjBTppsxtPB7rdw==", + "iANKiuMqWzrHSk9nbPe3bQ==", + "iCF+GWw9/YGQXsOOPAnPHQ==", + "iCnm5fPmSmxsIzuRK6osrA==", + "iFtadcw8v6betKka9yaJfg==", + "iGI9uqMoBBAjPszpxjZBWQ==", + "iGuY4VxcotHvMFXuXum7KA==", + "iGykaF+h4p46HhrWqL8Ffg==", + "iIWxFdolLcnXqIjPMg+5kQ==", + "iIm8c9uDotr87Aij+4vnMw==", + "iJ2nT8w8LuK11IXYqBK+YA==", + "iK0dWKHjVVexuXvMWJV9pg==", + "iPwX3SbbG9ez9HoHsrHbKw==", + "iQ304I1hmLZktA1d1cuOJA==", + "iS9wumBV5ktCTefFzKYfkA==", + "iSeH0JFSGK73F470Rhtesw==", + "iUsUCB0mfRsE9KPEQctIzw==", + "iVDd2Zk7vwmEh97LkOONpQ==", + "iWNlSnwrtCmVF89B+DZqOQ==", + "ibsb1ncaLZXAYgGkMO7tjQ==", + "ieEAgvK9LsWh2t6DsQOpWA==", + "ifZM0gBm9g9L09YlL+vXBg==", + "ifuJCv9ZA84Vz1FYAPsyEA==", + "ilBBNK/IV69xKTShvI94fQ==", + "imZ+mwiT22sW2M9alcUFfg==", + "inrUwXyKikpOW0y2Kl1wGw==", + "ionqS0piAOY2LeSReAz4zg==", + "ipPPjxpXHS1tcykXmrHPMQ==", + "irnD9K8bsT+up/JUrxPw6A==", + "iruDC5MeywV4yA8o1tw/KQ==", + "isep9d+Q7DEUf0W7CJJYzw==", + "itPtn+JaO4i7wz2wOPOmDQ==", + "iu5csar0IQQBOTgw5OvJwQ==", + "iujlt9fXcUXEYc+T2s5UjA==", + "iwKBOGDTFzV4aXgDGfyUkw==", + "izeyFvXOumNgVyLrbKW45g==", + "j+8/VARfbQSYhHzj0KPurQ==", + "j+lDhAnWAyso+1N8cm85hQ==", + "j4FBMnNfdBwx0VsDeTvhFg==", + "j8nMH8mK/0Aae7ZkqyPgdg==", + "j8to4gtSIRYpCogv2TESuQ==", + "jCgdKXsBCgf7giUKnr6paQ==", + "jEdanvXKyZdZJG6mj/3FWw==", + "jEqP0dyHKHiUjZ9dNNGTlQ==", + "jGHMJqbj6X1NdTDyWmXYAQ==", + "jHOoSl3ldFYr9YErEBnD3w==", + "jKJn4czwUl/6wtZklcMsSg==", + "jLI3XpVfjJ6IzrwOc4g9Pw==", + "jLkmUZ6fV56GfhC0nkh4GA==", + "jMZKSMP2THqwpWqJNJRWdw==", + "jNJQ6otieHBYIXA9LjXprg==", + "jNcMS2zX1iSZN9uYnb2EIg==", + "jOPdd330tB6+7C29a9wn0Q==", + "jQVlDU+HjZ2OHSDBidxX5A==", + "jQjyjWCEo9nWFjP4O8lehw==", + "jS0JuioLGAVaHdo/96JFoQ==", + "jTg9Y6EfpON4CRFOq0QovA==", + "jTmPbq+wh30+yJ/dRXk1cA==", + "jV/D2B11NLXZRH77sG9lBw==", + "jWsC7kdp2YmIZpfXGUimiA==", + "jZMDIu95ITTjaUX0pk4V5g==", + "jd6IpPJwOJW1otHKtKZ5Gw==", + "jdRzkUJrWxrqoyNH9paHfQ==", + "jdVMQqApseHH3fd91NFhxg==", + "jfegbZSZWkDoPulFomVntA==", + "jgNijyoj2JrQNSlUv4gk4A==", + "ji+1YHlRvzevs3q5Uw1gfA==", + "ji306HRiq965zb8EZD2uig==", + "jiV+b/1EFMnHG6J0hHpzBg==", + "jjNMPXbmpFNsCpWY0cv3eg==", + "jkUpkLoIXuu7aSH8ZghIAQ==", + "joDXdLpXvRjOqkRiYaD/Sw==", + "jon1y9yMEGfiIBjsDeeJdA==", + "jp5Em/0Ml4Txr1ptTUQjpg==", + "jpNUgFnanr9Sxvj2xbBXZw==", + "jpjpNjL1IKzJdGqWujhxCw==", + "jqPQ0aOuvOJte/ghI1RVng==", + "jrRH0aTUYCOpPLZwzwPRfQ==", + "jrfRznO0nAz6tZM1mHOKIA==", + "jt9Ocr9D8EwGRgrXVz//aQ==", + "jx7rpxbm1NaUMcE2ktg5sA==", + "jz7QlwxCIzysP39Cgro8jg==", + "k+IBS52XdOe5/hLp28ufnA==", + "k/Aou2Jmyh8Bu3k8/+ndsQ==", + "k/OVIllJvW6BefaLEPq7DA==", + "k/pBSWE2BvUsvJhA9Zl5uw==", + "k0XIjxp2vFG7sTrKcfAihA==", + "k1DPiH6NkOFXP/r3N12GyA==", + "k2KP9oPMnHmFlZO6u6tgyw==", + "k6OmSlaSZ5CB0i7SD9LczQ==", + "k8eZxqwxiN/ievXdLSEL/w==", + "kBAB2PSjXwqoQOXNrv80AA==", + "kFrRjz7Cf2KvLtz9X6oD+w==", + "kGeXrHEN6o7h5qJYcThCPw==", + "kHcBZXoxnFJ+GMwBZ/xhfQ==", + "kIGxCUxSlNgsKZ45Al1lWw==", + "kJdY3XEdJS/hyHdR+IN0GA==", + "kMUdiwM7WR8KGOucLK4Brw==", + "kNGIV3+jQmJlZDTXy1pnyA==", + "kRnBEH6ILR5GNSmjHYOclw==", + "kSUectNPXpXNg+tIveTFRw==", + "kTCHqcb3Cos51o8cL+MXcg==", + "kUhyc3G8Zvx8+q5q5nVEhw==", + "kUudvRfA33uJDzHIShQd3Q==", + "kWPUUi7x9kKKa6nJ+FDR5Q==", + "kZ/mZZg9YSDmk2rCGChYAg==", + "kZ0D191c/uv4YMG15yVLDw==", + "kZkmDatUOdIqs7GzH3nI1A==", + "ka7pMp8eSiv92WgAsz2vdA==", + "kcJ1acgBv6FtUhV8KuWoow==", + "kgKWQJJQKLUuD2VYKIKvxA==", + "kggaIvN2tlbZdZRI8S5Apw==", + "kgyUtd8MFe0tuuxDEUZA9w==", + "kh51WUI5TRnKhur6ZEpRTQ==", + "kj5WqpRCjWAfjM7ULMcuPQ==", + "kjWYVC7Eok2w2YT4rrI+IA==", + "kkbX+a00dfiTgbMI+aJpMg==", + "kly/2kE4/7ffbO34WTgoGg==", + "knYKU74onR6NkGVjQLezZg==", + "kq26VyDyJTH/eM6QvS2cMw==", + "kr8tw1+3NxoPExnAtTmfxg==", + "ksOFI9C7IrDNk4OP6SpPgw==", + "kuWGANwzNRpG4XmY7KjjNg==", + "kvAaIJb+aRAfKK104dxFAA==", + "kwlAQhR2jPMmfLTAwcmoxw==", + "kydoXVaNcx1peR5g6i588g==", + "kzGNkWh3fz27cZer4BspUQ==", + "kzTl7WH/JXsX1fqgnuTOgw==", + "kzXsrxWRnWhkA82LsLRYog==", + "kzYddqiMsY3EYrpxve2/CQ==", + "l+x2QhxG8wb5AQbcRxXlmA==", + "l0E0U/CJsyCVSTsXW4Fp+w==", + "l2NppPcweAtmA1V2CNdk2Q==", + "l2ZB9TvT68rn8AAN4MdxWw==", + "l2mAbuFF3QBIUILDODiUHQ==", + "l4ddTxbTCW5UmZW+KRmx6A==", + "l5f3I6osM9oxLRAwnUnc5A==", + "l6QHU5JsJExNoOnqxBPVbw==", + "l6Ssc04/CnsqUua9ELu2iQ==", + "l8/KMItWaW3n4g1Yot/rcQ==", + "lC5EumoIcctvxYqwELqIqw==", + "lFUq6PGk9dBRtUuiEW7Cug==", + "lHN2dn2cUKJ8ocVL3vEhUQ==", + "lJFPmPWcDzDp5B2S8Ad8AA==", + "lK2xe+OuPutp4os0ZAZx5w==", + "lM/EhwTsbivA7MDecaVTPw==", + "lMaO8Yf+6YNowGyhDkPhQA==", + "lMjip5hbCjkD9JQjuhewDg==", + "lNF8PvUIN02NattcGi5u4g==", + "lON3WM0uMJ30F8poBMvAjQ==", + "lOPJhHqCtMRFZfWMX/vFZQ==", + "lTE6u9G/RzvmbuAzq2J2/Q==", + "lV70RNlE++04G1KFB3BMXA==", + "lY+tivtsfvU0LJzBQ6itYQ==", + "lacCCRiWdquNm4YRO7FoKA==", + "leDlMcM+B1mDE8k5SWtUeg==", + "lf1fwA0YoWUZaEybE+LyMQ==", + "lfOLLyZNbsWQgHRhicr4ag==", + "lffapwUUgaQOIqLz2QPbAg==", + "lhAOM81Ej6YZYBu45pQYgg==", + "lizovLQxu6L9sbafNQuShQ==", + "lkl6XkrTMUpXi46dPxTPxg==", + "lkzFdvtBx5bV6xZO0cxK7g==", + "ll2M0QQzBsj5OFi02fv3Yg==", + "llOvGOUDVfX68jKnAlvVRA==", + "llujnWE17U8MIHmx4SbrSA==", + "lqhgbgEqROAdfzEnJ17eXA==", + "lsBTMnse2BgPS6wvPbe7JA==", + "luO1R8dUM9gy1E2lojRQoA==", + "luR/kvHLwA6tSdLeTM4TzA==", + "lwYQm2ynA3ik2gE1m11IEg==", + "lyfqic/AbEJbCiw+wA01FA==", + "lz+SeifYXxamOLs1FsFmSQ==", + "lzUQ1o7JAbdJYpmEqi6KnQ==", + "m+eh+ZqS74w2q0vejBkjaw==", + "m/Lp4U75AQyk9c8cX14HJg==", + "m06wctjNc3o7iyBHDMZs2w==", + "m3XYojKO+I6PXlVRUQBC3w==", + "m416yrrAlv+YPClGvGh+qQ==", + "m5JIUETVXcRza4VL4xlJbg==", + "m6get5wjq5j1i5abnpXuZQ==", + "m6srF+pMehggHB1tdoxlPg==", + "m9iuy4UtsjmyPzy6FTTZvw==", + "mAiD16zf+rCc7Qzxjd5buA==", + "mAzsVkijuqihhmhNTTz65g==", + "mDXHuOmI4ayjy2kLSHku1Q==", + "mI0eT4Rlr7QerMIngcu/ng==", + "mMLhjdWNnZ8zts9q+a2v3g==", + "mMfn8OaKBxtetweulho+xQ==", + "mNlYGAOPc6KIMW8ITyBzIg==", + "mNv2Q67zePjk/jbQuvkAFA==", + "mPk1IsU5DmDFA/Ym5+1ojw==", + "mPwCyD0yrIDonVi+fhXyEQ==", + "mS99D+CXhwyfVt8xJ+dJZA==", + "mSJF9dJnxZ15lTC6ilbJ2A==", + "mSstwJq7IkJ0JBJ5T8xDKg==", + "mTAqtg6oi0iytHQCaSVUsA==", + "mTLBkP+yGHsdk5g7zLjVUw==", + "mU4CqbAwpwqegxJaOz9ofQ==", + "mUek9NkXm8HiVhQ6YXiyzA==", + "mVT74Eht+gAowINoMKV7IQ==", + "mW6TCje9Zg2Ep7nzmDjSYQ==", + "mXBfDUt/sBW5OUZs2sihvw==", + "mXPtbPaoNAAlGmUMmJEWBQ==", + "mXZ4JeBwT2WJQL4a/Tm4jQ==", + "mXycPfF5zOvcj1p4hnikWw==", + "mc45FSMtzdw2PTcEBwHWPw==", + "md6zNd7ZBn3qArYqQz7/fw==", + "me61ST+JrXM5k3/a11gRAA==", + "meHzY9dIF7llDpFQo1gyMg==", + "miiOqnhtef1ODjFzMHnxjA==", + "mjFBVRJ7TgnJx+Q74xllPg==", + "mjQS8CpyGnsZIDOIEdYUxg==", + "mk1CKDah7EzDJEdhL22B7w==", + "mmRob7iyTkTLDu8ObmTPow==", + "mnalaO6xJucSiZ0+99r3Cg==", + "mpOtwBvle+nyY6lUBwTemw==", + "mpWNaUH9kn4WY26DWNAh3Q==", + "mr1qjhliRfl87wPOrJbFQg==", + "mrinv7KooPQPrLCNTRWCFg==", + "mrxlFD3FBqpSZr1kuuwxGg==", + "msstzxq++XO0AqNTmA7Bmg==", + "mxug34EekabLz0JynutfBg==", + "myzvc+2MfxGD9uuvZYdnqQ==", + "n+xYzfKmMoB3lWkdZ+D3rg==", + "n1M2dgFPpmaICP+JwxHUug==", + "n1ixvP7SfwYT3L2iWpJg6A==", + "n5GA+pA9mO/f4RN9NL9lNg==", + "n6QVaozMGniCO0PCwGQZ6w==", + "n7Bns42aTungqxKkRfQ5OQ==", + "n7KL1Kv027TSxBVwzt9qeA==", + "n7h9v2N1gOcvMuBEf8uThw==", + "nDAsSla+9XfAlQSPsXtzPA==", + "nE72uQToQFVLOzcu/nMjww==", + "nFBXCPeiwxK9mLXPScXzTA==", + "nFPDZGZowr3XXLmDVpo7hg==", + "nGzPc0kI/EduVjiK7bzM6Q==", + "nHTsDl0xeQPC5zNRnoa0Rw==", + "nHUpYmfV59fe3RWaXhPs3Q==", + "nL4iEd3b5v4Y9fHWDs+Lrw==", + "nMuMtK/Zkb3Xr34oFuX/Lg==", + "nNaGqigseHw30DaAhjBU3g==", + "nOiwBFnXxCBfPCHYITgqNg==", + "nR3ACzeVF5YcLX6Gj6AGyQ==", + "nULSbtw2dXbfVjZh33pDiA==", + "nUgYO7/oVNSX8fJqP2dbdg==", + "nVDxVhaa2o38gd1XJgE3aw==", + "nW3zZshjZEoM8KVJoVfnuQ==", + "nY/H7vThZ+dDxoPRyql+Cg==", + "neQoa8pvETr07blVMN3pgA==", + "nf8x+F03kOpMhsCSUWEhVg==", + "ng1Q0A7ljho3TUWWYl46sw==", + "nhAnHuCGXcYlqzOxrrEe1g==", + "nkbLVLvh3ClKED97+nH+7Q==", + "nkedTagkmf6YE4tEY+0fKw==", + "nknBKPgb7US42v8A0fTl/w==", + "nmD7fEU4u7/4+W/pkC4/0Q==", + "nqpKfidczdgrNaAyPi7BOQ==", + "nqtQI1bSM7DCO9P1jGV97Q==", + "nsnX3tKkN1elr18E31tXDw==", + "nvLEpj6ZZF3LWH3wUB6lKg==", + "nvUKoKfC6j8fz3gEDQrc/w==", + "nvmBgp0YlUrdZ05INsEE8Q==", + "nwtCsN1xEYaHvEOPzBv+qQ==", + "nx/U4Tode5ILux4DSR+QMg==", + "nxDGRpePV3H4NChn4eLwag==", + "nyaekSYTKzfSeSfPrB114Q==", + "nykEOLL/o7h0cs0yvdeT2g==", + "o+areESiXgSO0Lby56cBeg==", + "o+nYS4TqJc6XOiuUzEpC3A==", + "o/Y4U6rWfsUCXJ72p5CUGw==", + "o1uhaQg5/zfne84BFAINUQ==", + "o1zeXHJEKevURAAbUE/Vog==", + "o5XVEpdP4OXH0NEO4Yfc/A==", + "o64LDtKq/Fulf1PkVfFcyg==", + "o7y4zQXQAryST2cak4gVbw==", + "o9tdzmIu+3J/EYU4YWyTkA==", + "oAHVGBSJ2cf4dVnb/KEYmw==", + "oDca3JEdRb4vONT9GUUsaQ==", + "oFNMOKbQXcydxnp8fUNOHw==", + "oFanDWdePmmZN0xqwpUukA==", + "oGH7SMLI2/qjd9Vnhi3s0A==", + "oIU19xAvLJwQSZzIH577aA==", + "oIWwTbkVS5DDL47mY9/1KQ==", + "oKt57TPe4PogmsGssc3Cbg==", + "oLWWIn/2AbKRHnddr2og9g==", + "oMJLQTH1wW7LvOV0KRx/dw==", + "oNOI17POQCAkDwj6lJsYOA==", + "oONlXCW4aAqGczQ/bUllBw==", + "oPcxgoismve6+jXyIKK6AQ==", + "oPlhC4ebXdkIDazeMSn1fQ==", + "oQjugfjraFziga1BcwRLRA==", + "oR8rvIZoeoaZ/ufpo0htfQ==", + "oSnrpW4UmmVXtUGWqLq+tQ==", + "oUqO4HrBvkpSL781qAC9+w==", + "oVlG+0rjrg2tdFImxIeVBA==", + "oad5SwflzN0vfNcyEyF4EA==", + "obW3kzv2KBvuckU7F+tfjA==", + "ocRh5LR1ZIN9Johnht8fhQ==", + "ocpLRASvTgqfkY20YlVFHQ==", + "ocvA1/NbyxM0hanwwY6EiA==", + "odGhKtO4bDW5R8SYiI5yCg==", + "ogcuGHUZJkmv+vCz567a2g==", + "ohK6EftXOqBzIMI+5XnESw==", + "ojZY7Gi2QJXE/fp6Wy31iA==", + "ojf6uL85EuEYgLvHoGhUrw==", + "ojugpLIfzflgU2lonfdGxA==", + "ol9xhVTG9e1wNo50JdZbOA==", + "olTSlmirL9MFhKORiOKYkQ==", + "omAjyj1l6gyQAlBGfdxJTw==", + "onFcHOO1c3pDdfCb5N4WkQ==", + "oqlkgrYe9aCOwHXddxuyag==", + "oxoZP897lgMg/KLcZAtkAg==", + "oyYtf08AkWLR52bXm5+sKw==", + "ozVqYsmUueKifb4lDyVyrg==", + "p+bx+/WQWALXEBCTnIMr4w==", + "p/48hurJ1kh2FFPpyChzJg==", + "p/7qM5+Lwzw1/lIPY91YxQ==", + "p0eNK7zJd7D/HEGaVOrtrQ==", + "p2JPOX8yDQ0agG+tUyyT/g==", + "p3V7NfveB6cNxFW7+XQNeQ==", + "p48i7AfSSAyTdJSyHvOONw==", + "p73gSu4d+4T/ZNNkIv9Nlw==", + "p8W1LgFuW6JSOKjHkx3+aA==", + "pCQmlnn3BxhsV2GwqjRhXg==", + "pFKzcRHSUBqSMtkEJvrR1Q==", + "pGQEWJ38hb/ZYy2P1+FIuw==", + "pHo1O5zrCHCiLvopP2xaWw==", + "pHozgRyMiEmyzThtJnY4MQ==", + "pKaTI+TfcV3p/sxbd2e7YQ==", + "pT1raq2fChffFSIBX3fRiA==", + "pUfWmRXo70yGkUD/x5oIvA==", + "pVG1hL96/+hQ+58rJJy6/A==", + "pVgjGg4TeTNhKimyOu3AAw==", + "pW4gDKtVLj48gNz6V17QdA==", + "pZfn6IiG+V28fN8E2hawDQ==", + "pa8nkpAAzDKUldWjIvYMYg==", + "pcoBh5ic7baSD4TZWb3BSw==", + "pdPwUHauXOowaq9hpL2yFw==", + "pdaY6kZ8+QqkMOInvvACNA==", + "peMW+rpwmXrSwplVuB/gTA==", + "pfGcaa49SM3S6yJIPk/EJQ==", + "plXHHzA8X9QGwWzlJxhLRw==", + "pnJnBzAJlO4j3IRqcfmhkQ==", + "prCOYlboBnzmLEBG/OeVrQ==", + "prOsOG0adI4o+oz50moipw==", + "pulldyBt2sw6QDvTrCh6zw==", + "pv/m2mA/RJiEQu2Qyfv9RA==", + "pvXHwJ3dwf9GDzfDD9JI3g==", + "pw1jplCdTC+b0ThX0FXOjw==", + "pxuSWn1u+bHtRjyh2Z8veA==", + "pyrUqiZ98gVXxlXQNXv5fA==", + "pzC8Y0Vj9MPBy3YXR32z6w==", + "q/siBRjx6wNu+OTvpFKDwA==", + "q4z6A4l3nhX3smTmXr+Sig==", + "q5g3c8tnQTW2EjNfb2sukw==", + "q6LG0VzO1oxiogAAU63hyg==", + "q7m/EtZySBjZNBjQ5m1hKw==", + "q8YF9G2jqydAxSqwyyys5Q==", + "qA0sTaeNPNIiQbjIe1bOgQ==", + "qCPfJTR8ecTw6u6b1yHibA==", + "qE/h/Z+6buZWf+cmPdhxog==", + "qIFpKKwUmztsBpJgMaVvSg==", + "qIUJPanWmGzTD1XxvHp+6w==", + "qNOSm15bdkIDSc/iUr+UTQ==", + "qNyy6Fc0b8oOMWqqaliZ/w==", + "qO4HlyHMK5ygX+6HbwQe8w==", + "qOEIUWtGm5vx/+fg4tuazg==", + "qP1cCE4zsKGTPhjbcpczMw==", + "qQQwJ/aF87BbnLu3okXxaw==", + "qYHdgFAXhF/XcW4lxqfvWQ==", + "qYuo5vY8V3tZx41Kh9/4Dw==", + "qZ2q5j2gH3O56xqxkNhlIA==", + "qaTdVEeZ6S8NMOxfm+wOMA==", + "qcpeZWUlPllQYZU6mHVwUw==", + "qenHZKKlTUiEFv6goKM/Mw==", + "qkvEep4vvXhc2ZJ6R449Mg==", + "qngzBJbiTB4fivrdnE5gOg==", + "qnkFUlJ8QT322JuCI3LQgg==", + "qnsBdl050y9cUaWxbCczRw==", + "qnzWszsyJhYtx8wkMN6b1g==", + "qoK2keBg3hdbn7Q24kkVXg==", + "qpFJZqzkklby+u1UT3c1iA==", + "qt5CsMts2aD4lw/4Q6bHYQ==", + "qxALQrqHoDq9d91nU0DckA==", + "qyRmvxh8p4j4f+61c10ZFQ==", + "r/b5px/UImGNjT/X5sYjuA==", + "r0QffVKB9OD9yGsOtqzlhA==", + "r0hAwlS0mPZVfCSB+2G6uQ==", + "r1VGXWeqGeGbfKjigaAS+Q==", + "r2f2MyT+ww1g9uEBzdYI1w==", + "r36kVMpF+9J+sfI3GeGqow==", + "r3lQAYOYhwlLnDWQIunKqg==", + "r95wJtP5rsTExKMS7QhHcw==", + "rBt6L/KLT7eybxKt5wtFdg==", + "rCxoo4TP/+fupXMuIM0sDA==", + "rHagXw+CkF3uEWPWDKXvog==", + "rIMXaCaozDvrdpvpWvyZOQ==", + "rJ9qVn8/2nOxexWzqIHlcQ==", + "rJCuanCy51ydVD4nInf9IQ==", + "rKAQxu80Q8g1EEhW5Wh8tg==", + "rKb3TBM4EPx/RErFOFVCnQ==", + "rLZII1R6EGus+tYCiUtm6g==", + "rM/BOovNgnvebKMxZQdk7g==", + "rMm9bHK69h0fcMkMdGgeeA==", + "rOYeIcB+Rg5V6JG2k4zS2w==", + "rSvhrHyIlnIBlfNJqemEbw==", + "rTwJggSxTbwIYdp07ly0LA==", + "rUp5Mfc57+A8Q29SPcvH/Q==", + "rWliqgfZ3/uCRBOZ9sMmdA==", + "rXGWY/Gq+ZEsmvBHUfFMmQ==", + "rXSbbRABEf4Ymtda45w8Fw==", + "rXfWkabSPN+23Ei1bdxfmQ==", + "rXtGpN17Onx8LnccJnXwJQ==", + "rZKD8oJnIj5fSNGiccfcvA==", + "raKMXnnX6PFFsbloDqyVzQ==", + "raYifKqev8pASjjuV+UTKQ==", + "rcY4Ot40678ByCfqvGOGdg==", + "rdeftHE7gwAT67wwhCmkYQ==", + "rfPTskbnoh3hRJH6ZAzQRg==", + "rgcXxjx3pDLotH7TTfAoZw==", + "rh7bzsTQ1UZjG7amysr0Gg==", + "rhgtLQh0F9bRA6IllM7AGw==", + "ri4AOITPdB1YHyXV+5S51g==", + "rkeLYwMZ1/pW2EmIibALfA==", + "rlXt6zKE7DswUl0oWGOQUQ==", + "rqHKB91H3qVuQAm+Ym5cUA==", + "rqucO37p86LpzehR/asCSQ==", + "rs2QrN4qzAHCHhkcrAvIfA==", + "rtJdfki8fG6CB36CADp0QA==", + "rtd6mqFgGe98mqO0pFGbSw==", + "rueNryrchijjmWaA3kljYg==", + "rvE64KQGkVkbl07y7JwBqw==", + "rwplpbNJz0ADUHTmzAj15Q==", + "rwtF86ZAbWyKI6kLn4+KBw==", + "rxfACPLtKXbYua18l3WlUw==", + "rzj6mjHCcMEouL66083BAg==", + "s+eHg5K9zZ2Jozu5Oya9ZQ==", + "s/BZAhh1cTV3JCDUQsV8mA==", + "s2AKVTwrY65/SWqQxDGJQg==", + "s5+78jS4hQYrFtxqTW3g1Q==", + "s5RUHVRNAoKMuPR/Jkfc2Q==", + "s7iW1M6gkAMp+D/3jHY58w==", + "s8NpalwgPdHPla7Zi9FJ3w==", + "sBpytpE38xz0zYeT+0qc2A==", + "sC11Rf/mau3FG5SnON4+vQ==", + "sCLMrLjEUQ6P1L8tz90Kxg==", + "sEeblUmISi1HK4omrWuPTA==", + "sGLPmr568+SalaQr8SE/PA==", + "sLJrshdEANp0qk2xOUtTnQ==", + "sLdxIKap0ZfC3GpUk3gjog==", + "sNmW2b2Ud7dZi3qOF8O8EQ==", + "sQAxqWXeiu/Su0pnnXgI9A==", + "sQskMBELEq86o1SJGQqfzg==", + "sQzCwNDlRsSH7iB9cTbBcg==", + "sS6QcitMPdvUBLiMXkWQkw==", + "sWLcS+m4aWk31BiBF+vfJQ==", + "sXlFMSTBFnq0STHj6cS/8w==", + "sa2DECaqYH1z1/AFhpHi+g==", + "saEpnDGBSZWqeXSJm34eOA==", + "scCQPl0em2Zmv/RQYar60g==", + "sfIClgTMtZo9CM9MHaoqhQ==", + "sfowXUMdN2mCoBVrUzulZg==", + "sfte/o9vVNyida/yLvqADA==", + "siHwJx6EgeB1gBT9z/vTyw==", + "skrQRB9xbOsiSA19YgAdIQ==", + "snGTzo540cCqgBjxrfNpKw==", + "soBA65OmZdfBGJkBmY/4Iw==", + "spHVvA/pc7nF9Q4ON020+w==", + "spJI3xFUlpCDqzg0XCxopA==", + "sr3UXbMg5zzkRduFx/as7g==", + "sw+bmpzqsM4gEQtnqocQLQ==", + "swJhrPwllq5JORWiP5EkDA==", + "swsVVsPi/5aPFBGP+jmPIw==", + "syeBfQBUmkXNWCZ1GV8xSA==", + "t+bYn9UqrzKiuxAYGF7RLA==", + "t0WN8TwMLgi8UVEImoFXKg==", + "t2EkpUsLOEOsrnep0nZSmA==", + "t2vWMIh2BvfDSQaz5T1TZw==", + "t3Txxjq43e/CtQmfQTKwWg==", + "t5U+VMsTtlWAAWSW+00SfQ==", + "t5wh9JGSkQO78QoQoEqvXA==", + "t7HaNlXL16fVwjgSXmeOAQ==", + "t8pjhdyNJirkvYgWIO/eKg==", + "tBQDfy48FnIOZI04rxfdcA==", + "tFMJRXfWE9g78O1uBUxeqQ==", + "tFmWYH82I3zb+ymk5dhepA==", + "tG+rpfJBXlyGXxTmkceiKA==", + "tHDbi43e6k6uBgO0hA+Uiw==", + "tIqwBotg052wGBL65DZ+yA==", + "tJt6VDdAPEemBUvnoc4viA==", + "tOdlnsE3L3XCBDJRmb/OqA==", + "tOkYq1BZY152/7IJ6ZYKUg==", + "tU31r8zla146sqczdKXufg==", + "tVhXk9Ff3wAg56FbdNtcFg==", + "tVvWdA+JqH0HR2OlNVRoag==", + "tVw8U1AsslIFmQs4H1xshg==", + "tX8X8KoxUQ8atFSCxgwE1Q==", + "tXVb5f90k9l3e1oK2NGXog==", + "tXuu7YpZOuMLTv87NjKerA==", + "tY916jrSySzrL+YTcVmYKQ==", + "tYeIZjIm0tVEsYxH1iIiUQ==", + "tb5+2dmYALJibez1W4zXgA==", + "td7nDgTDmKPSODRusMcupw==", + "tdgI9v7cqJsgCAeW1Fii1A==", + "tdiTXKrkqxstDasT0D5BPA==", + "tejpAZp7y32SO2+o4OGvwQ==", + "tfgO55QqUyayjDfQh+Zo1Q==", + "tj2rWvF2Fl+XIccctj8Mhw==", + "tnUtJ/DQX9WaVJyTgemsUA==", + "tq5xUJt8GtjDIh1b48SthQ==", + "tr+U/vt+MIGXPRQYYWJfRg==", + "trjM81KANPZrg9iSThWx6Q==", + "tsiqwelcBAMU/HpLGBtMGw==", + "twPn6wTGqI0aR//0wP3xtA==", + "twjiDKJM7528oIu/el4Zbg==", + "tzV7ixFH37ze4zuLILTlfA==", + "u/QxrP1NOM/bOJlJlsi/jQ==", + "u2WQlcMxOACy6VbJXK4FwA==", + "u5cUPxM6/spLIV8VidPrAA==", + "uC2lzm7HaMAoczJO6Z/IhQ==", + "uChFnF0oCwARhAOz/d47eA==", + "uESeJe/nYrHCq4RQbrNpGA==", + "uExgqZkkJnZj252l5dKAGg==", + "uIkVijg7RPi/1j7c18G1qA==", + "uJZGw3IY2nCcdVeWW1geNQ==", + "uMq8cDVWFD+tpn8aeP8Pqg==", + "uNWFZlP7DA96sf+LWiAhtQ==", + "uNzpptKjihEfKRo5A1nWmw==", + "uO+uK1DntCxVRr1KttfUIw==", + "uOHrw37yF9oLLVd16nUpeg==", + "uOkMpYy/7DYYoethJdixfQ==", + "uPdjKJIGzN7pbGZDZdCGaA==", + "uPi8TsGY3vQsMVo/nsbgVQ==", + "uPm+cF4Jq08S5pQhYFjU8A==", + "uPnL9tboMZo0Kl2fe24CmA==", + "uQs79rbD/wEakMUxqMI48A==", + "uSIiF1r9F18avZczmlEuMQ==", + "uT6WRh5UpVdeABssoP2VTg==", + "uTA0XbiH3fTeVV7u5z0b3w==", + "uTHBqApdKOAgdwX3cjrCYQ==", + "uU1TX5DoDg6EcFKgFcn0GA==", + "uXuPA/2KJbb7ZX+NymN3dw==", + "uXvr6vi5kazZ9BCg2PWPJA==", + "uZ2gUA74/7Q33tI2TcGQlg==", + "ucLMWnNDSqE4NOCGWvcGWw==", + "udU65VtsvJspYmamiOsgXw==", + "ueODvMv/f9ZD8O0aIHn4sg==", + "ugY8rTtJkN4CXWMVcRZiZw==", + "uhT12XY79CtbwhcSfAmAXQ==", + "ulLuTZqhEDkX0EJ3xwRP9A==", + "ulpDxLeQnIRPnq6oaah2AA==", + "up2MVDi9ve+s83/nwNtZ7Q==", + "uqe3rFveJ2JIkcZQ3ZMXHQ==", + "uqp92lAqjec8UQYfyjaEZw==", + "ur9JDCVNwzSH4q4ngDlHNQ==", + "uu+ncs63SdQIvG6z4r7Q3Q==", + "uuiJ+yB7JLDh2ulthM0mjg==", + "uvKYnKE01D5r7kR9UQyo5A==", + "uvzmRcvgepW6mZbMfYgcNw==", + "uwA6N5LptSXqIBkTO0Jd7Q==", + "uwGivY3/C9WK+dirRPJZ4A==", + "uzEgwx1iAXAvWPKSVwYSeQ==", + "uzkNhmo2d08tv5AmnyqkoQ==", + "v/PshI6JjkL9nojLlMNfhg==", + "v0Bvws1WYVoEgDt8xmVKew==", + "v1AWe5qb5y3vSKFb7ADeEw==", + "v4xIYrfPGILEbD/LwVDDzA==", + "v6jZicMNM3ysm3U5xu0HoQ==", + "v7BrkRmK0FfWSHunTRHQFQ==", + "vCekQ2nOQKiN/q8Be/qwZg==", + "vFFzkWgGyw6OPADONtEojQ==", + "vFox1d3llOeBeCUZGvTy0A==", + "vFtC0B2oe1gck28JOM1dyg==", + "vGKknndb4j6VTV8DxeT4fQ==", + "vHGjRRSlZHJIliCwIkCAmQ==", + "vHVXsAMQqc0qp7HA5Q+YkA==", + "vHmQUl4WHXs1E/Shh+TeyA==", + "vIORTYSHFIXk5E2NyIvWcQ==", + "vMuaLvAntJB5o7lmt/kVXA==", + "vOJ55zFdgPPauPyFYBf01w==", + "vRgkZZGVN7YZrlml0vxrKA==", + "vSKsa0JhLCe9QFZKkcj58Q==", + "vTAmgfq3GxL4+ubXpzwk5w==", + "vUC0HlTTHj6qNHwfviDtAw==", + "vUE8Iw3NyWXURpXyoNJdaw==", + "vWn9OPnrJgfPavg4D6T/HQ==", + "vX7RIhatQeXAMr1+OjzhZw==", + "vZtL0yWpSIA+9v8i23bZSg==", + "vb6Agwzk4JG0Nn7qRPPFMQ==", + "vbyiKeDCQ4q9dDRI1Q0Ong==", + "vg3jozLXEmAnmJwdfcEN0g==", + "vhdFtKVH4bVatb4n8KzeXw==", + "vjrSYGUpeKOtJ2cNgLFg2g==", + "vljJciS+uuIvL7XXm5688g==", + "vmqfGJE6r4yDahtU/HLrxw==", + "vnOJ3e9Zd4wPx8PX7QgZzQ==", + "voO3krg4sdy4Iu+MZEr8+g==", + "vqYHQ3MnHrAIAr1QHwfIag==", + "vsRNZx4thFFFPneubKq1Fw==", + "vvEH5A39TTe1AOC11rRCLA==", + "vvh9vAIrXjIwLVkuJb5oDQ==", + "vwno3vugCvt6ooT3CD4qIQ==", + "w+jzM0I5DRzoUiLS/9QIMQ==", + "w0PKdssv+Zc5J/BbphoxpA==", + "w1zN28mSrI/gqHsgs4ME3A==", + "w3G+qXXqqKi8F5s+qvkBUg==", + "w5N/aHbtOIKzcvG3GlMjGA==", + "wDiGoFEfIVEDyyc4VpwhWQ==", + "wEJDulZafLuXCvcqBYioFQ==", + "wHA+D5cObfV3kGORCdEknw==", + "wI7JrSPQwYHpv2lRsQu9nQ==", + "wIfvvLKC61gOpsddUFjVog==", + "wJ4uCrl4DPg70ltw1dZO3w==", + "wJKFMqh6MGctWfasjHrPEg==", + "wJpepvmtQQ3sz3tVFDnFqw==", + "wK6Srd83eLigZ11Q20XGrg==", + "wM8tnXO4PDlLVHspZFcjYw==", + "wMOE/pEKVIklE75xjt6b6w==", + "wMum67lfk5E1ohUObJgrOg==", + "wMyJLQJdmrC2TSeFkIuSvQ==", + "wOc4TbwQGUwOC1B3BEZ4OQ==", + "wOhbpTzmFla8R0kI9OiHaA==", + "wPhJcp7U7IVX83szbIOOxQ==", + "wQKL8Ga6JQkpZ7yymDkC3w==", + "wR2Gxb07nkaPcZHlEjr8iA==", + "wRqaDZVHHurp5whOQ1kDbQ==", + "wTO49YX/ePHMWtcoxUAHpw==", + "wUYhs4j3W9nIywu1HIv2JA==", + "wVfSZYjMjbTsD2gaSbwuqQ==", + "wX2URK6eDDHeEOF3cgPgHA==", + "wX70jKLKJApHnhyK0r6t3A==", + "wajwXfWz2J+O+NVaj6j2UQ==", + "wc+8ohFWgOF4VlSYiZIGwQ==", + "wdRyYjaM11VmqkkxV/5bsA==", + "wfwuxn+Vja1DNwiDwL2pcQ==", + "wgH1GlUxWi6/yLLFzE76uQ==", + "who8uUamlHWHXnBf7dwy4A==", + "wlWxtQDJ+siGhN2fJn3qtw==", + "wnfYUctNK+UPwefX5y4/Rw==", + "wpZqFkKafFpLcykN2IISqg==", + "wqUJ1Gq1Yz2cXFkbcCmzHQ==", + "wqWqe0KRjZlUIrGgEOG9Mg==", + "wrewZ0hoHODf7qmoGcOd7g==", + "wsp+vmW8sEqXYVURd/gjHA==", + "wt+qDLU38kzNU75ZYi3Hbw==", + "wtyAZIfhomcHe9dLbYoSvA==", + "wux5Y8AipBnc5tJapTzgEQ==", + "wv4NC9CIpwuGf/nOQYe/oA==", + "wxkb8evGEaGf/rg/1XUWiA==", + "wy/Z8505o4sVovk4UuBp1A==", + "wyqmQGB6vgRVrYtmB2vB7w==", + "wyx5mnUMgP5wjykjAfTO7w==", + "x+8rwkqKCv0juoT5m1A4eg==", + "x/BIDm6TKMhqu/gtb3kGyw==", + "x/MpsQvziUpW40nNUHDS5Q==", + "x0eIHCvQLd2jdDaXwSWTYQ==", + "x1A74vg/hwwjAx6GrkU8zw==", + "x2NpqNnqRihktNzpxmepkQ==", + "x2nSgcTjA3oGgI8mMgiqjw==", + "x5lyMArsv1MuJmEFlWCnNw==", + "x5zMDuW66467ofgL3spLUQ==", + "x6M66krXSi0EhppwmDmsxA==", + "x6lNRGgJcRxgKTlzhc1WPg==", + "x8kRVzohTdhkryvYeMvkMw==", + "x9TIZ9Ua++3BX+MpjgTuWA==", + "x9VwDdFPp/rJ+SF16ooWYg==", + "xAAipGfHTGTjp9Qk1MR8RQ==", + "xJi0T+psHOXMivSOVpMWeQ==", + "xLm/bJBonpTs0PwsF0DvRg==", + "xMIHeno2qj3V8q9H1xezeg==", + "xNilc7UOu1kyP0+nK5MrLw==", + "xPe76nHyHmald6kmMQsKdg==", + "xQpYjaAmrQudWgsdu24J0A==", + "xTizUioizbMQxD0T6fy/EQ==", + "xUXEE7OBBCudsQnuj5ycOA==", + "xWYecfzAtXT9WyQ8NYY/hw==", + "xX6atcCApI08oVLjjLteLg==", + "xYD8jrCDmuQna+p1ebnKDQ==", + "xbBxUP9JyY0wDgHDipBHeg==", + "xdCCdP8SNBOK3IsX6PiPQA==", + "xdmY+qyoxxuRZa9kuNpDEg==", + "xfYZ6qhWNBqqJ0PdWRjOwA==", + "xfjBQk3CrNjhufdPIhr91A==", + "xiFlcSfa/gnPiO+LwbixcQ==", + "xiyRfVG0EfBA+rCk+tgWRQ==", + "xjA21QjNdThLW3VV7SCnrg==", + "xjTMO2mvtpvwQrounD4e8g==", + "xktOghh1S9nIX6fXWnT+Ug==", + "xmGgK3W5y+oCd0K2u8XjZQ==", + "xmsYnsJq78/f9xuKuQ2pBQ==", + "xoPSM86Se+1hHX0y3hhdkw==", + "xs8J3cesq7lDhP/dNltqOw==", + "xsCZVhCk2qJmOqvUjK3Y8Q==", + "xsf0m31Am0W9eLhopAkfnA==", + "xukOAM0QVsA72qEy0yku9A==", + "xvipmmwKdYt4eoKvvRnjEg==", + "xweGAZf+Yb3TtwR/sGmGIA==", + "xzGzN5Hhbh0m/KezjNvXbQ==", + "y+1I05LDAYJ09tKMs3zW6g==", + "y+cl1/Knb9MZPz8nBB0M+w==", + "y/e3HSdg7T19FanRpJ7+7Q==", + "y1J+o6DC2sETFsySgpDZyA==", + "y2JOIoIiT9cV1VxplZPraQ==", + "y2Tn2gmhKs5WKc01ce74rg==", + "y4/HohCJxtt+cT7nLJB08w==", + "y4Y4mSSTw/WrIdRpktc5Hw==", + "y4iBxAMn/KzMmaWShdYiIw==", + "y4mfEDerrhaqApDdhP5vjA==", + "y7yS9x3yshVhMpDbQtfYOQ==", + "yCu+DVU/ceMTOZ5h/7wQTg==", + "yD3Dd4ToRrl53k/2NSCJiw==", + "yDrAd1ot38soBk7zKdnT8A==", + "yKLLiqzxfrCsr6+Rm6kx1Q==", + "yKrsKX4/1B1C0TyvciNz5w==", + "yL1DwlIIREPuyuCFULi0uw==", + "yLAhLNezvqVHmN1SfMRrPw==", + "yOE90OHQdyOfrAgwDvn2gA==", + "yPIeWcW8+3HjDagegrN8bw==", + "yQCLV9IoPyXEOaj3IdFMWw==", + "yQmNZnp/JZywbBiZs3gecA==", + "yS/yMnJDHW0iaOsbj4oPTg==", + "yTVJKBn72RjakMBXDoBKHg==", + "yTgN5xFIdz1MzFS6xMl5uQ==", + "yU3N0HMSP5etuHPNrVkZtg==", + "yV3IbbTWAbHMhMGVvgb/ZQ==", + "yYBIS9PZbKo7Gram7IXWPA==", + "yYVW07lOZHdgtX42xJONIA==", + "yYmnM/WOgi+48Rw7foGyXA==", + "yYp4iuI5f/y/l1AEJxYolQ==", + "ybpTgPr3SjJ12Rj5lC/IMA==", + "ycjv4XkS5O7zcF3sqq9MwQ==", + "yctId8ltkl3+xqi9bj+RqA==", + "ydVj2odhergi+2zGUwK4/A==", + "yf06Slv9l3IZEjVqvxP2aA==", + "yfAaL0MMtSXPQ37pBdmHxQ==", + "yhI5jHlfFJxu4eV5VJO2zQ==", + "yhRi5M9Etuu9HSu4d24i3w==", + "yhexr/OFKfZl0o3lS70e4w==", + "ylA6sU7Kaf9fMNIx1+sIlw==", + "ymtA8EMPMgmMcimWZZ0A1Q==", + "ynaj4XjU27b7XbqPyxI8Ig==", + "yqQPU4jT9XvRABZgNQXjgg==", + "yqtj8GfLaUHYv/BsdjxIVw==", + "ysRQ+7Aq7eVLOp88KnFVMA==", + "ytDXLDBqWiU1w3sTurYmaw==", + "yteeQr3ub2lDXgLziZV+DQ==", + "yxCyBXqGWA735JEyljDP7Q==", + "z+1oDVy8GJ5u/UDF+bIQdA==", + "z/e5M2lE9qh3bzB97jZCKA==", + "z0BU//aSjYHAkGGk3ZSGNg==", + "z20AAnvj7WsfJeOu3vemlA==", + "z3L2BNjQOMOfTVBUxcpnRA==", + "z4Bft++f72QeDh4PWGr/sw==", + "z4oKy2wKH+sbNSgGjbdHGw==", + "z5DveTu377UW8IHnsiUGZg==", + "z920R8eahJPiTsifrPYdxA==", + "z9cd+Qj+ueX34Zf3997MNQ==", + "zCRZgVsHbQZcVMHd9pGD3A==", + "zCpibjrZOA3FQ4lYt0WoVA==", + "zDSQ3NJuUGkVOlvVCATRwA==", + "zDUZCzQesFjO1JI3PwDjfg==", + "zEzWZ6l7EKoVUxvk/l78Mw==", + "zJ7ScHNxr2leCDNNcuDApA==", + "zNLlWGW/aKBhUwQZ4DZWoQ==", + "zVupSPz7cD0v/mD/eUIIjg==", + "zZtYkKU50PPEj6qSbO5/Sw==", + "za4rzveYVMFe3Gw531DQJQ==", + "zaqyy3GaJ7cp8qDoLJWcTw==", + "zbjXhZaeyMfdTb2zxvmRMg==", + "zeELfk015D5krExLKRUYtg==", + "zeHF6fdeqcOId3fRUGscRw==", + "zgEyxj/sCs63O98sZS94Yw==", + "zi04Yc01ZheuFAQc59E45A==", + "zirOtGUXeRL22ezfotZfQg==", + "zm+z+OOyHhljV2TjA3U9zw==", + "zrZWcqQsUE3ocWE0fG+SOA==", + "ztULoqHvCOE6qV7ocqa4/w==", + "zwQ/3MzTJ9rfBmrANIh14w==", + "zwY6tCjjya/bgrYaCncaag==", + "zxsSqovedB3HT99jVblCnQ==", + "zyA9f5J7mw5InjhcfeumAQ==", +]); diff --git a/browser/components/newtab/lib/HighlightsFeed.sys.mjs b/browser/components/newtab/lib/HighlightsFeed.sys.mjs new file mode 100644 index 0000000000..c603b886da --- /dev/null +++ b/browser/components/newtab/lib/HighlightsFeed.sys.mjs @@ -0,0 +1,322 @@ +/* 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 { actionTypes as at } from "resource://activity-stream/common/Actions.sys.mjs"; + +import { shortURL } from "resource://activity-stream/lib/ShortURL.sys.mjs"; +import { + TOP_SITES_DEFAULT_ROWS, + TOP_SITES_MAX_SITES_PER_ROW, +} from "resource://activity-stream/common/Reducers.sys.mjs"; +import { Dedupe } from "resource://activity-stream/common/Dedupe.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DownloadsManager: "resource://activity-stream/lib/DownloadsManager.sys.mjs", + FilterAdult: "resource://activity-stream/lib/FilterAdult.sys.mjs", + LinksCache: "resource://activity-stream/lib/LinksCache.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", + PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs", + Screenshots: "resource://activity-stream/lib/Screenshots.sys.mjs", + SectionsManager: "resource://activity-stream/lib/SectionsManager.sys.mjs", +}); + +const HIGHLIGHTS_MAX_LENGTH = 16; + +export const MANY_EXTRA_LENGTH = + HIGHLIGHTS_MAX_LENGTH * 5 + + TOP_SITES_DEFAULT_ROWS * TOP_SITES_MAX_SITES_PER_ROW; + +export const SECTION_ID = "highlights"; +export const SYNC_BOOKMARKS_FINISHED_EVENT = "weave:engine:sync:applied"; +export const BOOKMARKS_RESTORE_SUCCESS_EVENT = "bookmarks-restore-success"; +export const BOOKMARKS_RESTORE_FAILED_EVENT = "bookmarks-restore-failed"; +const RECENT_DOWNLOAD_THRESHOLD = 36 * 60 * 60 * 1000; + +export class HighlightsFeed { + constructor() { + this.dedupe = new Dedupe(this._dedupeKey); + this.linksCache = new lazy.LinksCache( + lazy.NewTabUtils.activityStreamLinks, + "getHighlights", + ["image"] + ); + lazy.PageThumbs.addExpirationFilter(this); + this.downloadsManager = new lazy.DownloadsManager(); + } + + _dedupeKey(site) { + // Treat bookmarks, pocket, and downloaded items as un-dedupable, otherwise show one of a url + return ( + site && + (site.pocket_id || site.type === "bookmark" || site.type === "download" + ? {} + : site.url) + ); + } + + init() { + Services.obs.addObserver(this, SYNC_BOOKMARKS_FINISHED_EVENT); + Services.obs.addObserver(this, BOOKMARKS_RESTORE_SUCCESS_EVENT); + Services.obs.addObserver(this, BOOKMARKS_RESTORE_FAILED_EVENT); + lazy.SectionsManager.onceInitialized(this.postInit.bind(this)); + } + + postInit() { + lazy.SectionsManager.enableSection(SECTION_ID, true /* isStartup */); + this.fetchHighlights({ broadcast: true, isStartup: true }); + this.downloadsManager.init(this.store); + } + + uninit() { + lazy.SectionsManager.disableSection(SECTION_ID); + lazy.PageThumbs.removeExpirationFilter(this); + Services.obs.removeObserver(this, SYNC_BOOKMARKS_FINISHED_EVENT); + Services.obs.removeObserver(this, BOOKMARKS_RESTORE_SUCCESS_EVENT); + Services.obs.removeObserver(this, BOOKMARKS_RESTORE_FAILED_EVENT); + } + + observe(subject, topic, data) { + // When we receive a notification that a sync has happened for bookmarks, + // or Places finished importing or restoring bookmarks, refresh highlights + const manyBookmarksChanged = + (topic === SYNC_BOOKMARKS_FINISHED_EVENT && data === "bookmarks") || + topic === BOOKMARKS_RESTORE_SUCCESS_EVENT || + topic === BOOKMARKS_RESTORE_FAILED_EVENT; + if (manyBookmarksChanged) { + this.fetchHighlights({ broadcast: true }); + } + } + + filterForThumbnailExpiration(callback) { + const state = this.store + .getState() + .Sections.find(section => section.id === SECTION_ID); + + callback( + state && state.initialized + ? state.rows.reduce((acc, site) => { + // Screenshots call in `fetchImage` will search for preview_image_url or + // fallback to URL, so we prevent both from being expired. + acc.push(site.url); + if (site.preview_image_url) { + acc.push(site.preview_image_url); + } + return acc; + }, []) + : [] + ); + } + + /** + * Chronologically sort highlights of all types except 'visited'. Then just append + * the rest at the end of highlights. + * @param {Array} pages The full list of links to order. + * @return {Array} A sorted array of highlights + */ + _orderHighlights(pages) { + const splitHighlights = { chronologicalCandidates: [], visited: [] }; + for (let page of pages) { + if (page.type === "history") { + splitHighlights.visited.push(page); + } else { + splitHighlights.chronologicalCandidates.push(page); + } + } + + return splitHighlights.chronologicalCandidates + .sort((a, b) => a.date_added < b.date_added) + .concat(splitHighlights.visited); + } + + /** + * Refresh the highlights data for content. + * @param {bool} options.broadcast Should the update be broadcasted. + */ + async fetchHighlights(options = {}) { + // If TopSites are enabled we need them for deduping, so wait for + // TOP_SITES_UPDATED. We also need the section to be registered to update + // state, so wait for postInit triggered by lazy.SectionsManager initializing. + if ( + (!this.store.getState().TopSites.initialized && + this.store.getState().Prefs.values["feeds.system.topsites"] && + this.store.getState().Prefs.values["feeds.topsites"]) || + !this.store.getState().Sections.length + ) { + return; + } + + // We broadcast when we want to force an update, so get fresh links + if (options.broadcast) { + this.linksCache.expire(); + } + + // Request more than the expected length to allow for items being removed by + // deduping against Top Sites or multiple history from the same domain, etc. + const manyPages = await this.linksCache.request({ + numItems: MANY_EXTRA_LENGTH, + excludeBookmarks: + !this.store.getState().Prefs.values[ + "section.highlights.includeBookmarks" + ], + excludeHistory: + !this.store.getState().Prefs.values[ + "section.highlights.includeVisited" + ], + excludePocket: + !this.store.getState().Prefs.values["section.highlights.includePocket"], + }); + + if ( + this.store.getState().Prefs.values["section.highlights.includeDownloads"] + ) { + // We only want 1 download that is less than 36 hours old, and the file currently exists + let results = await this.downloadsManager.getDownloads( + RECENT_DOWNLOAD_THRESHOLD, + { numItems: 1, onlySucceeded: true, onlyExists: true } + ); + if (results.length) { + // We only want 1 download, the most recent one + manyPages.push({ + ...results[0], + type: "download", + }); + } + } + + const orderedPages = this._orderHighlights(manyPages); + + // Remove adult highlights if we need to + const checkedAdult = lazy.FilterAdult.filter(orderedPages); + + // Remove any Highlights that are in Top Sites already + const [, deduped] = this.dedupe.group( + this.store.getState().TopSites.rows, + checkedAdult + ); + + // Keep all "bookmark"s and at most one (most recent) "history" per host + const highlights = []; + const hosts = new Set(); + for (const page of deduped) { + const hostname = shortURL(page); + // Skip this history page if we already something from the same host + if (page.type === "history" && hosts.has(hostname)) { + continue; + } + + // If we already have the image for the card, use that immediately. Else + // asynchronously fetch the image. NEVER fetch a screenshot for downloads + if (!page.image && page.type !== "download") { + this.fetchImage(page, options.isStartup); + } + + // Adjust the type for 'history' items that are also 'bookmarked' when we + // want to include bookmarks + if ( + page.type === "history" && + page.bookmarkGuid && + this.store.getState().Prefs.values[ + "section.highlights.includeBookmarks" + ] + ) { + page.type = "bookmark"; + } + + // We want the page, so update various fields for UI + Object.assign(page, { + hasImage: page.type !== "download", // Downloads do not have an image - all else types fall back to a screenshot + hostname, + type: page.type, + pocket_id: page.pocket_id, + }); + + // Add the "bookmark", "pocket", or not-skipped "history" + highlights.push(page); + hosts.add(hostname); + + // Remove internal properties that might be updated after dispatch + delete page.__sharedCache; + + // Skip the rest if we have enough items + if (highlights.length === HIGHLIGHTS_MAX_LENGTH) { + break; + } + } + + const { initialized } = this.store + .getState() + .Sections.find(section => section.id === SECTION_ID); + // Broadcast when required or if it is the first update. + const shouldBroadcast = options.broadcast || !initialized; + + lazy.SectionsManager.updateSection( + SECTION_ID, + { rows: highlights }, + shouldBroadcast, + options.isStartup + ); + } + + /** + * Fetch an image for a given highlight and update the card with it. If no + * image is available then fallback to fetching a screenshot. + */ + fetchImage(page, isStartup = false) { + // Request a screenshot if we don't already have one pending + const { preview_image_url: imageUrl, url } = page; + return lazy.Screenshots.maybeCacheScreenshot( + page, + imageUrl || url, + "image", + image => { + lazy.SectionsManager.updateSectionCard( + SECTION_ID, + url, + { image }, + true, + isStartup + ); + } + ); + } + + onAction(action) { + // Relay the downloads actions to DownloadsManager - it is a child of HighlightsFeed + this.downloadsManager.onAction(action); + switch (action.type) { + case at.INIT: + this.init(); + break; + case at.SYSTEM_TICK: + case at.TOP_SITES_UPDATED: + this.fetchHighlights({ + broadcast: false, + isStartup: !!action.meta?.isStartup, + }); + break; + case at.PREF_CHANGED: + // Update existing pages when the user changes what should be shown + if (action.data.name.startsWith("section.highlights.include")) { + this.fetchHighlights({ broadcast: true }); + } + break; + case at.PLACES_HISTORY_CLEARED: + case at.PLACES_LINK_BLOCKED: + case at.DOWNLOAD_CHANGED: + case at.POCKET_LINK_DELETED_OR_ARCHIVED: + this.fetchHighlights({ broadcast: true }); + break; + case at.PLACES_LINKS_CHANGED: + case at.PLACES_SAVED_TO_POCKET: + this.linksCache.expire(); + this.fetchHighlights({ broadcast: false }); + break; + case at.UNINIT: + this.uninit(); + break; + } + } +} diff --git a/browser/components/newtab/lib/LinksCache.sys.mjs b/browser/components/newtab/lib/LinksCache.sys.mjs new file mode 100644 index 0000000000..0dfb89e74e --- /dev/null +++ b/browser/components/newtab/lib/LinksCache.sys.mjs @@ -0,0 +1,133 @@ +/* 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 should be slightly less than SYSTEM_TICK_INTERVAL as timer +// comparisons are too exact while the async/await functionality will make the +// last recorded time a little bit later. This causes the comparasion to skip +// updates. +// It should be 10% less than SYSTEM_TICK to update at least once every 5 mins. +// https://github.com/mozilla/activity-stream/pull/3695#discussion_r144678214 +const EXPIRATION_TIME = 4.5 * 60 * 1000; // 4.5 minutes + +/** + * Cache link results from a provided object property and refresh after some + * amount of time has passed. Allows for migrating data from previously cached + * links to the new links with the same url. + */ +export class LinksCache { + /** + * Create a links cache for a given object property. + * + * @param {object} linkObject Object containing the link property + * @param {string} linkProperty Name of property on object to access + * @param {array} properties Optional properties list to migrate to new links. + * @param {function} shouldRefresh Optional callback receiving the old and new + * options to refresh even when not expired. + */ + constructor( + linkObject, + linkProperty, + properties = [], + shouldRefresh = () => {} + ) { + this.clear(); + + // Allow getting links from both methods and array properties + this.linkGetter = options => { + const ret = linkObject[linkProperty]; + return typeof ret === "function" ? ret.call(linkObject, options) : ret; + }; + + // Always migrate the shared cache data in addition to any custom properties + this.migrateProperties = ["__sharedCache", ...properties]; + this.shouldRefresh = shouldRefresh; + } + + /** + * Clear the cached data. + */ + clear() { + this.cache = Promise.resolve([]); + this.lastOptions = {}; + this.expire(); + } + + /** + * Force the next request to update the cache. + */ + expire() { + delete this.lastUpdate; + } + + /** + * Request data and update the cache if necessary. + * + * @param {object} options Optional data to pass to the underlying method. + * @returns {promise(array)} Links array with objects that can be modified. + */ + async request(options = {}) { + // Update the cache if the data has been expired + const now = Date.now(); + if ( + this.lastUpdate === undefined || + now > this.lastUpdate + EXPIRATION_TIME || + // Allow custom rules around refreshing based on options + this.shouldRefresh(this.lastOptions, options) + ) { + // Update request state early so concurrent requests can refer to it + this.lastOptions = options; + this.lastUpdate = now; + + // Save a promise before awaits, so other requests wait for correct data + // eslint-disable-next-line no-async-promise-executor + this.cache = new Promise(async (resolve, reject) => { + try { + // Allow fast lookup of old links by url that might need to migrate + const toMigrate = new Map(); + for (const oldLink of await this.cache) { + if (oldLink) { + toMigrate.set(oldLink.url, oldLink); + } + } + + // Update the cache with migrated links without modifying source objects + resolve( + (await this.linkGetter(options)).map(link => { + // Keep original array hole positions + if (!link) { + return link; + } + + // Migrate data to the new link copy if we have an old link + const newLink = Object.assign({}, link); + const oldLink = toMigrate.get(newLink.url); + if (oldLink) { + for (const property of this.migrateProperties) { + const oldValue = oldLink[property]; + if (oldValue !== undefined) { + newLink[property] = oldValue; + } + } + } else { + // Share data among link copies and new links from future requests + newLink.__sharedCache = {}; + } + // Provide a helper to update the cached link + newLink.__sharedCache.updateLink = (property, value) => { + newLink[property] = value; + }; + + return newLink; + }) + ); + } catch (error) { + reject(error); + } + }); + } + + // Provide a shallow copy of the cached link objects for callers to modify + return (await this.cache).map(link => link && Object.assign({}, link)); + } +} diff --git a/browser/components/newtab/lib/NewTabInit.sys.mjs b/browser/components/newtab/lib/NewTabInit.sys.mjs new file mode 100644 index 0000000000..db30e009ec --- /dev/null +++ b/browser/components/newtab/lib/NewTabInit.sys.mjs @@ -0,0 +1,55 @@ +/* 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.sys.mjs"; + +/** + * NewTabInit - A placeholder for now. This will send a copy of the state to all + * newly opened tabs. + */ +export class NewTabInit { + constructor() { + this._repliedEarlyTabs = new Map(); + } + + reply(target) { + // Skip this reply if we already replied to an early tab + if (this._repliedEarlyTabs.get(target)) { + return; + } + + const action = { + type: at.NEW_TAB_INITIAL_STATE, + data: this.store.getState(), + }; + this.store.dispatch(ac.AlsoToOneContent(action, target)); + + // Remember that this early tab has already gotten a rehydration response in + // case it thought we lost its initial REQUEST and asked again + if (this._repliedEarlyTabs.has(target)) { + this._repliedEarlyTabs.set(target, true); + } + } + + onAction(action) { + switch (action.type) { + case at.NEW_TAB_STATE_REQUEST: + this.reply(action.meta.fromTarget); + break; + case at.NEW_TAB_INIT: + // Initialize data for early tabs that might REQUEST twice + if (action.data.simulated) { + this._repliedEarlyTabs.set(action.data.portID, false); + } + break; + case at.NEW_TAB_UNLOAD: + // Clean up for any tab (no-op if not an early tab) + this._repliedEarlyTabs.delete(action.meta.fromTarget); + break; + } + } +} diff --git a/browser/components/newtab/lib/PersistentCache.sys.mjs b/browser/components/newtab/lib/PersistentCache.sys.mjs new file mode 100644 index 0000000000..1db9ca102e --- /dev/null +++ b/browser/components/newtab/lib/PersistentCache.sys.mjs @@ -0,0 +1,90 @@ +/* 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 file (disk) based persistent cache of a JSON serializable object. + */ +export class PersistentCache { + /** + * Create a cache object based on a name. + * + * @param {string} name Name of the cache. It will be used to create the filename. + * @param {boolean} preload (optional). Whether the cache should be preloaded from file. Defaults to false. + */ + constructor(name, preload = false) { + this.name = name; + this._filename = `activity-stream.${name}.json`; + if (preload) { + this._load(); + } + } + + /** + * Set a value to be cached with the specified key. + * + * @param {string} key The cache key. + * @param {object} value The data to be cached. + */ + async set(key, value) { + const data = await this._load(); + data[key] = value; + await this._persist(data); + } + + /** + * Get a value from the cache. + * + * @param {string} key (optional) The cache key. If not provided, we return the full cache. + * @returns {object} The cached data. + */ + async get(key) { + const data = await this._load(); + return key ? data[key] : data; + } + + /** + * Load the cache into memory if it isn't already. + */ + _load() { + return ( + this._cache || + // eslint-disable-next-line no-async-promise-executor + (this._cache = new Promise(async (resolve, reject) => { + let filepath; + try { + filepath = PathUtils.join(PathUtils.localProfileDir, this._filename); + } catch (error) { + reject(error); + return; + } + + let data = {}; + try { + data = await IOUtils.readJSON(filepath); + } catch (error) { + if ( + // isInstance() is not available in node unit test. It should be safe to use instanceof as it's directly from IOUtils. + // eslint-disable-next-line mozilla/use-isInstance + !(error instanceof DOMException) || + error.name !== "NotFoundError" + ) { + console.error(`Failed to parse ${this._filename}:`, error.message); + } + } + + resolve(data); + })) + ); + } + + /** + * Persist the cache to file. + */ + async _persist(data) { + const filepath = PathUtils.join(PathUtils.localProfileDir, this._filename); + await IOUtils.writeJSON(filepath, data, { + tmpPath: `${filepath}.tmp`, + }); + } +} diff --git a/browser/components/newtab/lib/PersonalityProvider/NaiveBayesTextTagger.mjs b/browser/components/newtab/lib/PersonalityProvider/NaiveBayesTextTagger.mjs new file mode 100644 index 0000000000..d5930e3147 --- /dev/null +++ b/browser/components/newtab/lib/PersonalityProvider/NaiveBayesTextTagger.mjs @@ -0,0 +1,60 @@ +/* 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/. */ + +export class NaiveBayesTextTagger { + constructor(model, toksToTfIdfVector) { + this.model = model; + this.toksToTfIdfVector = toksToTfIdfVector; + } + + /** + * Determines if the tokenized text belongs to class according to binary naive Bayes + * classifier. Returns an object containing the class label ("label"), and + * the log probability ("logProb") that the text belongs to that class. If + * the positive class is more likely, then "label" is the positive class + * label. If the negative class is matched, then "label" is set to null. + */ + tagTokens(tokens) { + let fv = this.toksToTfIdfVector(tokens, this.model.vocab_idfs); + + let bestLogProb = null; + let bestClassId = -1; + let bestClassLabel = null; + let logSumExp = 0.0; // will be P(x). Used to create a proper probability + for (let classId = 0; classId < this.model.classes.length; classId++) { + let classModel = this.model.classes[classId]; + let classLogProb = classModel.log_prior; + + // dot fv with the class model + for (let pair of Object.values(fv)) { + let [termId, tfidf] = pair; + classLogProb += tfidf * classModel.feature_log_probs[termId]; + } + + if (bestLogProb === null || classLogProb > bestLogProb) { + bestLogProb = classLogProb; + bestClassId = classId; + } + logSumExp += Math.exp(classLogProb); + } + + // now normalize the probability by dividing by P(x) + logSumExp = Math.log(logSumExp); + bestLogProb -= logSumExp; + if (bestClassId === this.model.positive_class_id) { + bestClassLabel = this.model.positive_class_label; + } else { + bestClassLabel = null; + } + + let confident = + bestClassId === this.model.positive_class_id && + bestLogProb > this.model.positive_class_threshold_log_prob; + return { + label: bestClassLabel, + logProb: bestLogProb, + confident, + }; + } +} diff --git a/browser/components/newtab/lib/PersonalityProvider/NmfTextTagger.mjs b/browser/components/newtab/lib/PersonalityProvider/NmfTextTagger.mjs new file mode 100644 index 0000000000..5c77152d8d --- /dev/null +++ b/browser/components/newtab/lib/PersonalityProvider/NmfTextTagger.mjs @@ -0,0 +1,58 @@ +/* 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/. */ + +export class NmfTextTagger { + constructor(model, toksToTfIdfVector) { + this.model = model; + this.toksToTfIdfVector = toksToTfIdfVector; + } + + /** + * A multiclass classifier that scores tokenized text against several classes through + * inference of a nonnegative matrix factorization of TF-IDF vectors and + * class labels. Returns a map of class labels as string keys to scores. + * (Higher is more confident.) All classes get scored, so it is up to + * consumer of this data determine what classes are most valuable. + */ + tagTokens(tokens) { + let fv = this.toksToTfIdfVector(tokens, this.model.vocab_idfs); + let fve = Object.values(fv); + + // normalize by the sum of the vector + let sum = 0.0; + for (let pair of fve) { + // eslint-disable-next-line prefer-destructuring + sum += pair[1]; + } + for (let i = 0; i < fve.length; i++) { + // eslint-disable-next-line prefer-destructuring + fve[i][1] /= sum; + } + + // dot the document with each topic vector so that we can transform it into + // the latent space + let toksInLatentSpace = []; + for (let topicVect of this.model.topic_word) { + let fvDotTwv = 0; + // dot fv with each topic word vector + for (let pair of fve) { + let [termId, tfidf] = pair; + fvDotTwv += tfidf * topicVect[termId]; + } + toksInLatentSpace.push(fvDotTwv); + } + + // now project toksInLatentSpace back into class space + let predictions = {}; + Object.keys(this.model.document_topic).forEach(topic => { + let score = 0; + for (let i = 0; i < toksInLatentSpace.length; i++) { + score += toksInLatentSpace[i] * this.model.document_topic[topic][i]; + } + predictions[topic] = score; + }); + + return predictions; + } +} diff --git a/browser/components/newtab/lib/PersonalityProvider/PersonalityProvider.sys.mjs b/browser/components/newtab/lib/PersonalityProvider/PersonalityProvider.sys.mjs new file mode 100644 index 0000000000..406a0fa200 --- /dev/null +++ b/browser/components/newtab/lib/PersonalityProvider/PersonalityProvider.sys.mjs @@ -0,0 +1,277 @@ +/* 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, { + BasePromiseWorker: "resource://gre/modules/PromiseWorker.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + Utils: "resource://services-settings/Utils.sys.mjs", +}); + +const RECIPE_NAME = "personality-provider-recipe"; +const MODELS_NAME = "personality-provider-models"; + +export class PersonalityProvider { + constructor(modelKeys) { + this.modelKeys = modelKeys; + this.onSync = this.onSync.bind(this); + this.setup(); + } + + setScores(scores) { + this.scores = scores || {}; + this.interestConfig = this.scores.interestConfig; + this.interestVector = this.scores.interestVector; + } + + get personalityProviderWorker() { + if (this._personalityProviderWorker) { + return this._personalityProviderWorker; + } + + this._personalityProviderWorker = new lazy.BasePromiseWorker( + "resource://activity-stream/lib/PersonalityProvider/PersonalityProvider.worker.mjs", + { type: "module" } + ); + + return this._personalityProviderWorker; + } + + get baseAttachmentsURL() { + // Returning a promise, so we can have an async getter. + return this._getBaseAttachmentsURL(); + } + + async _getBaseAttachmentsURL() { + if (this._baseAttachmentsURL) { + return this._baseAttachmentsURL; + } + const server = lazy.Utils.SERVER_URL; + const serverInfo = await ( + await fetch(`${server}/`, { + credentials: "omit", + }) + ).json(); + const { + capabilities: { + attachments: { base_url }, + }, + } = serverInfo; + this._baseAttachmentsURL = base_url; + return this._baseAttachmentsURL; + } + + setup() { + this.setupSyncAttachment(RECIPE_NAME); + this.setupSyncAttachment(MODELS_NAME); + } + + teardown() { + this.teardownSyncAttachment(RECIPE_NAME); + this.teardownSyncAttachment(MODELS_NAME); + if (this._personalityProviderWorker) { + this._personalityProviderWorker.terminate(); + } + } + + setupSyncAttachment(collection) { + lazy.RemoteSettings(collection).on("sync", this.onSync); + } + + teardownSyncAttachment(collection) { + lazy.RemoteSettings(collection).off("sync", this.onSync); + } + + onSync(event) { + this.personalityProviderWorker.post("onSync", [event]); + } + + /** + * Gets contents of the attachment if it already exists on file, + * and if not attempts to download it. + */ + getAttachment(record) { + return this.personalityProviderWorker.post("getAttachment", [record]); + } + + /** + * Returns a Recipe from remote settings to be consumed by a RecipeExecutor. + * A Recipe is a set of instructions on how to processes a RecipeExecutor. + */ + async getRecipe() { + if (!this.recipes || !this.recipes.length) { + const result = await lazy.RemoteSettings(RECIPE_NAME).get(); + this.recipes = await Promise.all( + result.map(async record => ({ + ...(await this.getAttachment(record)), + recordKey: record.key, + })) + ); + } + return this.recipes[0]; + } + + /** + * Grabs a slice of browse history for building a interest vector + */ + async fetchHistory(columns, beginTimeSecs, endTimeSecs) { + let sql = `SELECT url, title, visit_count, frecency, last_visit_date, description + FROM moz_places + WHERE last_visit_date >= ${beginTimeSecs * 1000000} + AND last_visit_date < ${endTimeSecs * 1000000}`; + columns.forEach(requiredColumn => { + sql += ` AND IFNULL(${requiredColumn}, '') <> ''`; + }); + sql += " LIMIT 30000"; + + const { activityStreamProvider } = lazy.NewTabUtils; + const history = await activityStreamProvider.executePlacesQuery(sql, { + columns, + params: {}, + }); + + return history; + } + + /** + * Handles setup and metrics of history fetch. + */ + async getHistory() { + let endTimeSecs = new Date().getTime() / 1000; + let beginTimeSecs = endTimeSecs - this.interestConfig.history_limit_secs; + if ( + !this.interestConfig || + !this.interestConfig.history_required_fields || + !this.interestConfig.history_required_fields.length + ) { + return []; + } + let history = await this.fetchHistory( + this.interestConfig.history_required_fields, + beginTimeSecs, + endTimeSecs + ); + + return history; + } + + async setBaseAttachmentsURL() { + await this.personalityProviderWorker.post("setBaseAttachmentsURL", [ + await this.baseAttachmentsURL, + ]); + } + + async setInterestConfig() { + this.interestConfig = this.interestConfig || (await this.getRecipe()); + await this.personalityProviderWorker.post("setInterestConfig", [ + this.interestConfig, + ]); + } + + async setInterestVector() { + await this.personalityProviderWorker.post("setInterestVector", [ + this.interestVector, + ]); + } + + async fetchModels() { + const models = await lazy.RemoteSettings(MODELS_NAME).get(); + return this.personalityProviderWorker.post("fetchModels", [models]); + } + + async generateTaggers() { + await this.personalityProviderWorker.post("generateTaggers", [ + this.modelKeys, + ]); + } + + async generateRecipeExecutor() { + await this.personalityProviderWorker.post("generateRecipeExecutor"); + } + + async createInterestVector() { + const history = await this.getHistory(); + + const interestVectorResult = await this.personalityProviderWorker.post( + "createInterestVector", + [history] + ); + + return interestVectorResult; + } + + async init(callback) { + await this.setBaseAttachmentsURL(); + await this.setInterestConfig(); + if (!this.interestConfig) { + return; + } + + // We always generate a recipe executor, no cache used here. + // This is because the result of this is an object with + // functions (taggers) so storing it in cache is not possible. + // Thus we cannot use it to rehydrate anything. + const fetchModelsResult = await this.fetchModels(); + // If this fails, log an error and return. + if (!fetchModelsResult.ok) { + return; + } + await this.generateTaggers(); + await this.generateRecipeExecutor(); + + // If we don't have a cached vector, create a new one. + if (!this.interestVector) { + const interestVectorResult = await this.createInterestVector(); + // If that failed, log an error and return. + if (!interestVectorResult.ok) { + return; + } + this.interestVector = interestVectorResult.interestVector; + } + + // This happens outside the createInterestVector call above, + // because create can be skipped if rehydrating from cache. + // In that case, the interest vector is provided and not created, so we just set it. + await this.setInterestVector(); + + this.initialized = true; + if (callback) { + callback(); + } + } + + async calculateItemRelevanceScore(pocketItem) { + if (!this.initialized) { + return pocketItem.item_score || 1; + } + const itemRelevanceScore = await this.personalityProviderWorker.post( + "calculateItemRelevanceScore", + [pocketItem] + ); + if (!itemRelevanceScore) { + return -1; + } + const { scorableItem, rankingVector } = itemRelevanceScore; + // Put the results on the item for debugging purposes. + pocketItem.scorableItem = scorableItem; + pocketItem.rankingVector = rankingVector; + return rankingVector.score; + } + + /** + * Returns an object holding the personalization scores of this provider instance. + */ + getScores() { + return { + // We cannot return taggers here. + // What we return here goes into persistent cache, and taggers have functions on it. + // If we attempted to save taggers into persistent cache, it would store it to disk, + // and the next time we load it, it would start thowing function is not defined. + interestConfig: this.interestConfig, + interestVector: this.interestVector, + }; + } +} diff --git a/browser/components/newtab/lib/PersonalityProvider/PersonalityProvider.worker.mjs b/browser/components/newtab/lib/PersonalityProvider/PersonalityProvider.worker.mjs new file mode 100644 index 0000000000..49797f4f2b --- /dev/null +++ b/browser/components/newtab/lib/PersonalityProvider/PersonalityProvider.worker.mjs @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { PersonalityProviderWorker } from "resource://activity-stream/lib/PersonalityProvider/PersonalityProviderWorkerClass.mjs"; + +import { PromiseWorker } from "resource://gre/modules/workers/PromiseWorker.mjs"; + +const personalityProviderWorker = new PersonalityProviderWorker(); + +// This is boiler plate worker stuff that connects it to the main thread PromiseWorker. +const worker = new PromiseWorker.AbstractWorker(); +worker.dispatch = function (method, args = []) { + return personalityProviderWorker[method](...args); +}; +worker.postMessage = function (message, ...transfers) { + self.postMessage(message, ...transfers); +}; +worker.close = function () { + self.close(); +}; + +self.addEventListener("message", msg => worker.handleMessage(msg)); +self.addEventListener("unhandledrejection", function (error) { + throw error.reason; +}); diff --git a/browser/components/newtab/lib/PersonalityProvider/PersonalityProviderWorkerClass.mjs b/browser/components/newtab/lib/PersonalityProvider/PersonalityProviderWorkerClass.mjs new file mode 100644 index 0000000000..372c061dd9 --- /dev/null +++ b/browser/components/newtab/lib/PersonalityProvider/PersonalityProviderWorkerClass.mjs @@ -0,0 +1,306 @@ +/* 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 { + tokenize, + toksToTfIdfVector, +} from "resource://activity-stream/lib/PersonalityProvider/Tokenize.mjs"; +import { NaiveBayesTextTagger } from "resource://activity-stream/lib/PersonalityProvider/NaiveBayesTextTagger.mjs"; +import { NmfTextTagger } from "resource://activity-stream/lib/PersonalityProvider/NmfTextTagger.mjs"; +import { RecipeExecutor } from "resource://activity-stream/lib/PersonalityProvider/RecipeExecutor.mjs"; + +// A helper function to create a hash out of a file. +async function _getFileHash(filepath) { + const data = await IOUtils.read(filepath); + // File is an instance of Uint8Array + const digest = await crypto.subtle.digest("SHA-256", data); + const uint8 = new Uint8Array(digest); + // return the two-digit hexadecimal code for a byte + const toHex = b => b.toString(16).padStart(2, "0"); + return Array.from(uint8, toHex).join(""); +} + +/** + * V2 provider builds and ranks an interest profile (also called an “interest vector”) off the browse history. + * This allows Firefox to classify pages into topics, by examining the text found on the page. + * It does this by looking at the history text content, title, and description. + */ +export class PersonalityProviderWorker { + async getPersonalityProviderDir() { + const personalityProviderDir = PathUtils.join( + await PathUtils.getLocalProfileDir(), + "personality-provider" + ); + + // Cache this so we don't need to await again. + this.getPersonalityProviderDir = () => + Promise.resolve(personalityProviderDir); + return personalityProviderDir; + } + + setBaseAttachmentsURL(url) { + this.baseAttachmentsURL = url; + } + + setInterestConfig(interestConfig) { + this.interestConfig = interestConfig; + } + + setInterestVector(interestVector) { + this.interestVector = interestVector; + } + + onSync(event) { + const { + data: { created, updated, deleted }, + } = event; + // Remove every removed attachment. + const toRemove = deleted.concat(updated.map(u => u.old)); + toRemove.forEach(record => this.deleteAttachment(record)); + + // Download every new/updated attachment. + const toDownload = created.concat(updated.map(u => u.new)); + // maybeDownloadAttachment is async but we don't care inside onSync. + toDownload.forEach(record => this.maybeDownloadAttachment(record)); + } + + /** + * Attempts to download the attachment, but only if it doesn't already exist. + */ + async maybeDownloadAttachment(record, retries = 3) { + const { + attachment: { filename, hash, size }, + } = record; + await IOUtils.makeDirectory(await this.getPersonalityProviderDir()); + const localFilePath = PathUtils.join( + await this.getPersonalityProviderDir(), + filename + ); + + let retry = 0; + while ( + retry++ < retries && + // exists is an issue for perf because I might not need to call it. + (!(await IOUtils.exists(localFilePath)) || + (await IOUtils.stat(localFilePath)).size !== size || + (await _getFileHash(localFilePath)) !== hash) + ) { + await this._downloadAttachment(record); + } + } + + /** + * Downloads the attachment to disk assuming the dir already exists + * and any existing files matching the filename are clobbered. + */ + async _downloadAttachment(record) { + const { + attachment: { location: loc, filename }, + } = record; + const remoteFilePath = this.baseAttachmentsURL + loc; + const localFilePath = PathUtils.join( + await this.getPersonalityProviderDir(), + filename + ); + + const xhr = new XMLHttpRequest(); + // Set false here for a synchronous request, because we're in a worker. + xhr.open("GET", remoteFilePath, false); + xhr.setRequestHeader("Accept-Encoding", "gzip"); + xhr.responseType = "arraybuffer"; + xhr.withCredentials = false; + xhr.send(null); + + if (xhr.status !== 200) { + console.error(`Failed to fetch ${remoteFilePath}: ${xhr.statusText}`); + return; + } + + const buffer = xhr.response; + const bytes = new Uint8Array(buffer); + + await IOUtils.write(localFilePath, bytes, { + tmpPath: `${localFilePath}.tmp`, + }); + } + + async deleteAttachment(record) { + const { + attachment: { filename }, + } = record; + await IOUtils.makeDirectory(await this.getPersonalityProviderDir()); + const path = PathUtils.join( + await this.getPersonalityProviderDir(), + filename + ); + + await IOUtils.remove(path, { ignoreAbsent: true }); + // Cleanup the directory if it is empty, do nothing if it is not empty. + try { + await IOUtils.remove(await this.getPersonalityProviderDir(), { + ignoreAbsent: true, + }); + } catch (e) { + // This is likely because the directory is not empty, so we don't care. + } + } + + /** + * Gets contents of the attachment if it already exists on file, + * and if not attempts to download it. + */ + async getAttachment(record) { + const { + attachment: { filename }, + } = record; + const filepath = PathUtils.join( + await this.getPersonalityProviderDir(), + filename + ); + + try { + await this.maybeDownloadAttachment(record); + return await IOUtils.readJSON(filepath); + } catch (error) { + console.error(`Failed to load ${filepath}: ${error.message}`); + } + return {}; + } + + async fetchModels(models) { + this.models = await Promise.all( + models.map(async record => ({ + ...(await this.getAttachment(record)), + recordKey: record.key, + })) + ); + if (!this.models.length) { + return { + ok: false, + }; + } + return { + ok: true, + }; + } + + generateTaggers(modelKeys) { + if (!this.taggers) { + let nbTaggers = []; + let nmfTaggers = {}; + + for (let model of this.models) { + if (!modelKeys.includes(model.recordKey)) { + continue; + } + if (model.model_type === "nb") { + nbTaggers.push(new NaiveBayesTextTagger(model, toksToTfIdfVector)); + } else if (model.model_type === "nmf") { + nmfTaggers[model.parent_tag] = new NmfTextTagger( + model, + toksToTfIdfVector + ); + } + } + this.taggers = { nbTaggers, nmfTaggers }; + } + } + + /** + * Sets and generates a Recipe Executor. + * A Recipe Executor is a set of actions that can be consumed by a Recipe. + * The Recipe determines the order and specifics of which the actions are called. + */ + generateRecipeExecutor() { + const recipeExecutor = new RecipeExecutor( + this.taggers.nbTaggers, + this.taggers.nmfTaggers, + tokenize + ); + this.recipeExecutor = recipeExecutor; + } + + /** + * Examines the user's browse history and returns an interest vector that + * describes the topics the user frequently browses. + */ + createInterestVector(historyObj) { + let interestVector = {}; + + for (let historyRec of historyObj) { + let ivItem = this.recipeExecutor.executeRecipe( + historyRec, + this.interestConfig.history_item_builder + ); + if (ivItem === null) { + continue; + } + interestVector = this.recipeExecutor.executeCombinerRecipe( + interestVector, + ivItem, + this.interestConfig.interest_combiner + ); + if (interestVector === null) { + return null; + } + } + + const finalResult = this.recipeExecutor.executeRecipe( + interestVector, + this.interestConfig.interest_finalizer + ); + + return { + ok: true, + interestVector: finalResult, + }; + } + + /** + * Calculates a score of a Pocket item when compared to the user's interest + * vector. Returns the score. Higher scores are better. Assumes this.interestVector + * is populated. + */ + calculateItemRelevanceScore(pocketItem) { + const { personalization_models } = pocketItem; + let scorableItem; + + // If the server provides some models, we can just use them, + // and skip generating them. + if (personalization_models && Object.keys(personalization_models).length) { + scorableItem = { + id: pocketItem.id, + item_tags: personalization_models, + item_score: pocketItem.item_score, + item_sort_id: 1, + }; + } else { + scorableItem = this.recipeExecutor.executeRecipe( + pocketItem, + this.interestConfig.item_to_rank_builder + ); + if (scorableItem === null) { + return null; + } + } + + // We're doing a deep copy on an object. + let rankingVector = JSON.parse(JSON.stringify(this.interestVector)); + + Object.keys(scorableItem).forEach(key => { + rankingVector[key] = scorableItem[key]; + }); + + rankingVector = this.recipeExecutor.executeRecipe( + rankingVector, + this.interestConfig.item_ranker + ); + + if (rankingVector === null) { + return null; + } + + return { scorableItem, rankingVector }; + } +} diff --git a/browser/components/newtab/lib/PersonalityProvider/RecipeExecutor.mjs b/browser/components/newtab/lib/PersonalityProvider/RecipeExecutor.mjs new file mode 100644 index 0000000000..4f420c0812 --- /dev/null +++ b/browser/components/newtab/lib/PersonalityProvider/RecipeExecutor.mjs @@ -0,0 +1,1119 @@ +/* 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/. */ + +/** + * RecipeExecutor is the core feature engineering pipeline for the in-browser + * personalization work. These pipelines are called "recipes". A recipe is an + * array of objects that define a "step" in the recipe. A step is simply an + * object with a field "function" that specifies what is being done in the step + * along with other fields that are semantically defined for that step. + * + * There are two types of recipes "builder" recipes and "combiner" recipes. Builder + * recipes mutate an object until it matches some set of critera. Combiner + * recipes take two objects, (a "left" and a "right"), and specify the steps + * to merge the right object into the left object. + * + * A short nonsense example recipe is: + * [ {"function": "get_url_domain", "path_length": 1, "field": "url", "dest": "url_domain"}, + * {"function": "nb_tag", "fields": ["title", "description"]}, + * {"function": "conditionally_nmf_tag", "fields": ["title", "description"]} ] + * + * Recipes are sandboxed by the fact that the step functions must be explicitly + * allowed. Functions allowed for builder recipes are specifed in the + * RecipeExecutor.ITEM_BUILDER_REGISTRY, while combiner functions are allowed + * in RecipeExecutor.ITEM_COMBINER_REGISTRY . + */ +export class RecipeExecutor { + constructor(nbTaggers, nmfTaggers, tokenize) { + this.ITEM_BUILDER_REGISTRY = { + nb_tag: this.naiveBayesTag, + conditionally_nmf_tag: this.conditionallyNmfTag, + accept_item_by_field_value: this.acceptItemByFieldValue, + tokenize_url: this.tokenizeUrl, + get_url_domain: this.getUrlDomain, + tokenize_field: this.tokenizeField, + copy_value: this.copyValue, + keep_top_k: this.keepTopK, + scalar_multiply: this.scalarMultiply, + elementwise_multiply: this.elementwiseMultiply, + vector_multiply: this.vectorMultiply, + scalar_add: this.scalarAdd, + vector_add: this.vectorAdd, + make_boolean: this.makeBoolean, + allow_fields: this.allowFields, + filter_by_value: this.filterByValue, + l2_normalize: this.l2Normalize, + prob_normalize: this.probNormalize, + set_default: this.setDefault, + lookup_value: this.lookupValue, + copy_to_map: this.copyToMap, + scalar_multiply_tag: this.scalarMultiplyTag, + apply_softmax_tags: this.applySoftmaxTags, + }; + this.ITEM_COMBINER_REGISTRY = { + combiner_add: this.combinerAdd, + combiner_max: this.combinerMax, + combiner_collect_values: this.combinerCollectValues, + }; + this.nbTaggers = nbTaggers; + this.nmfTaggers = nmfTaggers; + this.tokenize = tokenize; + } + + /** + * Determines the type of a field. Valid types are: + * string + * number + * array + * map (strings to anything) + */ + _typeOf(data) { + let t = typeof data; + if (t === "object") { + if (data === null) { + return "null"; + } + if (Array.isArray(data)) { + return "array"; + } + return "map"; + } + return t; + } + + /** + * Returns a scalar, either because it was a constant, or by + * looking it up from the item. Allows for a default value if the lookup + * fails. + */ + _lookupScalar(item, k, dfault) { + if (this._typeOf(k) === "number") { + return k; + } else if ( + this._typeOf(k) === "string" && + k in item && + this._typeOf(item[k]) === "number" + ) { + return item[k]; + } + return dfault; + } + + /** + * Simply appends all the strings from a set fields together. If the field + * is a list, then the cells of the list are append. + */ + _assembleText(item, fields) { + let textArr = []; + for (let field of fields) { + if (field in item) { + let type = this._typeOf(item[field]); + if (type === "string") { + textArr.push(item[field]); + } else if (type === "array") { + for (let ele of item[field]) { + textArr.push(String(ele)); + } + } else { + textArr.push(String(item[field])); + } + } + } + return textArr.join(" "); + } + + /** + * Runs the naive bayes text taggers over a set of text fields. Stores the + * results in new fields: + * nb_tags: a map of text strings to probabilites + * nb_tokens: the tokenized text that was tagged + * + * Config: + * fields: an array containing a list of fields to concatenate and tag + */ + naiveBayesTag(item, config) { + let text = this._assembleText(item, config.fields); + let tokens = this.tokenize(text); + let tags = {}; + let extended_tags = {}; + + for (let nbTagger of this.nbTaggers) { + let result = nbTagger.tagTokens(tokens); + if (result.label !== null && result.confident) { + extended_tags[result.label] = result; + tags[result.label] = Math.exp(result.logProb); + } + } + item.nb_tags = tags; + item.nb_tags_extended = extended_tags; + item.nb_tokens = tokens; + return item; + } + + /** + * Selectively runs NMF text taggers depending on which tags were found + * by the naive bayes taggers. Writes the results in into new fields: + * nmf_tags_parent_weights: map of pareent tags to probabilites of those parent tags + * nmf_tags: map of strings to maps of strings to probabilities + * nmf_tags_parent map of child tags to parent tags + * + * Config: + * Not configurable + */ + conditionallyNmfTag(item, config) { + let nestedNmfTags = {}; + let parentTags = {}; + let parentWeights = {}; + + if (!("nb_tags" in item) || !("nb_tokens" in item)) { + return null; + } + + Object.keys(item.nb_tags).forEach(parentTag => { + let nmfTagger = this.nmfTaggers[parentTag]; + if (nmfTagger !== undefined) { + nestedNmfTags[parentTag] = {}; + parentWeights[parentTag] = item.nb_tags[parentTag]; + let nmfTags = nmfTagger.tagTokens(item.nb_tokens); + Object.keys(nmfTags).forEach(nmfTag => { + nestedNmfTags[parentTag][nmfTag] = nmfTags[nmfTag]; + parentTags[nmfTag] = parentTag; + }); + } + }); + + item.nmf_tags = nestedNmfTags; + item.nmf_tags_parent = parentTags; + item.nmf_tags_parent_weights = parentWeights; + + return item; + } + + /** + * Checks a field's value against another value (either from another field + * or a constant). If the test passes, then the item is emitted, otherwise + * the pipeline is aborted. + * + * Config: + * field Field to read the value to test. Left side of operator. + * op one of ==, !=, <, <=, >, >= + * rhsValue Constant value to compare against. Right side of operator. + * rhsField Field to read value to compare against. Right side of operator. + * + * NOTE: rhsValue takes precidence over rhsField. + */ + acceptItemByFieldValue(item, config) { + if (!(config.field in item)) { + return null; + } + let rhs = null; + if ("rhsValue" in config) { + rhs = config.rhsValue; + } else if ("rhsField" in config && config.rhsField in item) { + rhs = item[config.rhsField]; + } + if (rhs === null) { + return null; + } + + if ( + // eslint-disable-next-line eqeqeq + (config.op === "==" && item[config.field] == rhs) || + // eslint-disable-next-line eqeqeq + (config.op === "!=" && item[config.field] != rhs) || + (config.op === "<" && item[config.field] < rhs) || + (config.op === "<=" && item[config.field] <= rhs) || + (config.op === ">" && item[config.field] > rhs) || + (config.op === ">=" && item[config.field] >= rhs) + ) { + return item; + } + + return null; + } + + /** + * Splits a URL into text-like tokens. + * + * Config: + * field Field containing a URL + * dest Field to write the tokens to as an array of strings + * + * NOTE: Any initial 'www' on the hostname is removed. + */ + tokenizeUrl(item, config) { + if (!(config.field in item)) { + return null; + } + + let url = new URL(item[config.field]); + let domain = url.hostname; + if (domain.startsWith("www.")) { + domain = domain.substring(4); + } + let toks = this.tokenize(domain); + let pathToks = this.tokenize( + decodeURIComponent(url.pathname.replace(/\+/g, " ")) + ); + for (let tok of pathToks) { + toks.push(tok); + } + for (let pair of url.searchParams.entries()) { + let k = this.tokenize(decodeURIComponent(pair[0].replace(/\+/g, " "))); + for (let tok of k) { + toks.push(tok); + } + if (pair[1] !== null && pair[1] !== "") { + let v = this.tokenize(decodeURIComponent(pair[1].replace(/\+/g, " "))); + for (let tok of v) { + toks.push(tok); + } + } + } + item[config.dest] = toks; + + return item; + } + + /** + * Gets the hostname (minus any initial "www." along with the left most + * directories on the path. + * + * Config: + * field Field containing the URL + * dest Field to write the array of strings to + * path_length OPTIONAL (DEFAULT: 0) Number of leftmost subdirectories to include + */ + getUrlDomain(item, config) { + if (!(config.field in item)) { + return null; + } + + let url = new URL(item[config.field]); + let domain = url.hostname.toLocaleLowerCase(); + if (domain.startsWith("www.")) { + domain = domain.substring(4); + } + item[config.dest] = domain; + let pathLength = 0; + if ("path_length" in config) { + pathLength = config.path_length; + } + if (pathLength > 0) { + item[config.dest] += url.pathname + .toLocaleLowerCase() + .split("/") + .slice(0, pathLength + 1) + .join("/"); + } + + return item; + } + + /** + * Splits a field into tokens. + * Config: + * field Field containing a string to tokenize + * dest Field to write the array of strings to + */ + tokenizeField(item, config) { + if (!(config.field in item)) { + return null; + } + + item[config.dest] = this.tokenize(item[config.field]); + + return item; + } + + /** + * Deep copy from one field to another. + * Config: + * src Field to read from + * dest Field to write to + */ + copyValue(item, config) { + if (!(config.src in item)) { + return null; + } + + item[config.dest] = JSON.parse(JSON.stringify(item[config.src])); + + return item; + } + + /** + * Converts a field containing a map of strings to a map of strings + * to numbers, to a map of strings to numbers containing at most k elements. + * This operation is performed by first, promoting all the subkeys up one + * level, and then taking the top (or bottom) k values. + * + * Config: + * field Points to a map of strings to a map of strings to numbers + * k Maximum number of items to keep + * descending OPTIONAL (DEFAULT: True) Sorts score in descending order + * (i.e. keeps maximum) + */ + keepTopK(item, config) { + if (!(config.field in item)) { + return null; + } + let k = this._lookupScalar(item, config.k, 1048576); + let descending = !("descending" in config) || config.descending !== false; + + // we can't sort by the values in the map, so we have to convert this + // to an array, and then sort. + let sortable = []; + Object.keys(item[config.field]).forEach(outerKey => { + let innerType = this._typeOf(item[config.field][outerKey]); + if (innerType === "map") { + Object.keys(item[config.field][outerKey]).forEach(innerKey => { + sortable.push({ + key: innerKey, + value: item[config.field][outerKey][innerKey], + }); + }); + } else { + sortable.push({ key: outerKey, value: item[config.field][outerKey] }); + } + }); + + sortable.sort((a, b) => { + if (descending) { + return b.value - a.value; + } + return a.value - b.value; + }); + + // now take the top k + let newMap = {}; + let i = 0; + for (let pair of sortable) { + if (i >= k) { + break; + } + newMap[pair.key] = pair.value; + i++; + } + item[config.field] = newMap; + + return item; + } + + /** + * Scalar multiplies a vector by some constant + * + * Config: + * field Points to: + * a map of strings to numbers + * an array of numbers + * a number + * k Either a number, or a string. If it's a number then This + * is the scalar value to multiply by. If it's a string, + * the value in the pointed to field is used. + * default OPTIONAL (DEFAULT: 0), If k is a string, and no numeric + * value is found, then use this value. + */ + scalarMultiply(item, config) { + if (!(config.field in item)) { + return null; + } + let k = this._lookupScalar(item, config.k, config.dfault); + + let fieldType = this._typeOf(item[config.field]); + if (fieldType === "number") { + item[config.field] *= k; + } else if (fieldType === "array") { + for (let i = 0; i < item[config.field].length; i++) { + item[config.field][i] *= k; + } + } else if (fieldType === "map") { + Object.keys(item[config.field]).forEach(key => { + item[config.field][key] *= k; + }); + } else { + return null; + } + + return item; + } + + /** + * Elementwise multiplies either two maps or two arrays together, storing + * the result in left. If left and right are of the same type, results in an + * error. + * + * Maps are special case. For maps the left must be a nested map such as: + * { k1: { k11: 1, k12: 2}, k2: { k21: 3, k22: 4 } } and right needs to be + * simple map such as: { k1: 5, k2: 6} . The operation is then to mulitply + * every value of every right key, to every value every subkey where the + * parent keys match. Using the previous examples, the result would be: + * { k1: { k11: 5, k12: 10 }, k2: { k21: 18, k22: 24 } } . + * + * Config: + * left + * right + */ + elementwiseMultiply(item, config) { + if (!(config.left in item) || !(config.right in item)) { + return null; + } + let leftType = this._typeOf(item[config.left]); + if (leftType !== this._typeOf(item[config.right])) { + return null; + } + if (leftType === "array") { + if (item[config.left].length !== item[config.right].length) { + return null; + } + for (let i = 0; i < item[config.left].length; i++) { + item[config.left][i] *= item[config.right][i]; + } + } else if (leftType === "map") { + Object.keys(item[config.left]).forEach(outerKey => { + let r = 0.0; + if (outerKey in item[config.right]) { + r = item[config.right][outerKey]; + } + Object.keys(item[config.left][outerKey]).forEach(innerKey => { + item[config.left][outerKey][innerKey] *= r; + }); + }); + } else if (leftType === "number") { + item[config.left] *= item[config.right]; + } else { + return null; + } + + return item; + } + + /** + * Vector multiplies (i.e. dot products) two vectors and stores the result in + * third field. Both vectors must either by maps, or arrays of numbers with + * the same length. + * + * Config: + * left A field pointing to either a map of strings to numbers, + * or an array of numbers + * right A field pointing to either a map of strings to numbers, + * or an array of numbers + * dest The field to store the dot product. + */ + vectorMultiply(item, config) { + if (!(config.left in item) || !(config.right in item)) { + return null; + } + + let leftType = this._typeOf(item[config.left]); + if (leftType !== this._typeOf(item[config.right])) { + return null; + } + + let destVal = 0.0; + if (leftType === "array") { + if (item[config.left].length !== item[config.right].length) { + return null; + } + for (let i = 0; i < item[config.left].length; i++) { + destVal += item[config.left][i] * item[config.right][i]; + } + } else if (leftType === "map") { + Object.keys(item[config.left]).forEach(key => { + if (key in item[config.right]) { + destVal += item[config.left][key] * item[config.right][key]; + } + }); + } else { + return null; + } + + item[config.dest] = destVal; + return item; + } + + /** + * Adds a constant value to all elements in the field. Mathematically, + * this is the same as taking a 1-vector, scalar multiplying it by k, + * and then vector adding it to a field. + * + * Config: + * field A field pointing to either a map of strings to numbers, + * or an array of numbers + * k Either a number, or a string. If it's a number then This + * is the scalar value to multiply by. If it's a string, + * the value in the pointed to field is used. + * default OPTIONAL (DEFAULT: 0), If k is a string, and no numeric + * value is found, then use this value. + */ + scalarAdd(item, config) { + let k = this._lookupScalar(item, config.k, config.dfault); + if (!(config.field in item)) { + return null; + } + + let fieldType = this._typeOf(item[config.field]); + if (fieldType === "array") { + for (let i = 0; i < item[config.field].length; i++) { + item[config.field][i] += k; + } + } else if (fieldType === "map") { + Object.keys(item[config.field]).forEach(key => { + item[config.field][key] += k; + }); + } else if (fieldType === "number") { + item[config.field] += k; + } else { + return null; + } + + return item; + } + + /** + * Adds two vectors together and stores the result in left. + * + * Config: + * left A field pointing to either a map of strings to numbers, + * or an array of numbers + * right A field pointing to either a map of strings to numbers, + * or an array of numbers + */ + vectorAdd(item, config) { + if (!(config.left in item)) { + return this.copyValue(item, { src: config.right, dest: config.left }); + } + if (!(config.right in item)) { + return null; + } + + let leftType = this._typeOf(item[config.left]); + if (leftType !== this._typeOf(item[config.right])) { + return null; + } + if (leftType === "array") { + if (item[config.left].length !== item[config.right].length) { + return null; + } + for (let i = 0; i < item[config.left].length; i++) { + item[config.left][i] += item[config.right][i]; + } + return item; + } else if (leftType === "map") { + Object.keys(item[config.right]).forEach(key => { + let v = 0; + if (key in item[config.left]) { + v = item[config.left][key]; + } + item[config.left][key] = v + item[config.right][key]; + }); + return item; + } + + return null; + } + + /** + * Converts a vector from real values to boolean integers. (i.e. either 1/0 + * or 1/-1). + * + * Config: + * field Field containing either a map of strings to numbers or + * an array of numbers to convert. + * threshold OPTIONAL (DEFAULT: 0) Values above this will be replaced + * with 1.0. Those below will be converted to 0. + * keep_negative OPTIONAL (DEFAULT: False) If true, values below the + * threshold will be converted to -1 instead of 0. + */ + makeBoolean(item, config) { + if (!(config.field in item)) { + return null; + } + let threshold = this._lookupScalar(item, config.threshold, 0.0); + let type = this._typeOf(item[config.field]); + if (type === "array") { + for (let i = 0; i < item[config.field].length; i++) { + if (item[config.field][i] > threshold) { + item[config.field][i] = 1.0; + } else if (config.keep_negative) { + item[config.field][i] = -1.0; + } else { + item[config.field][i] = 0.0; + } + } + } else if (type === "map") { + Object.keys(item[config.field]).forEach(key => { + let value = item[config.field][key]; + if (value > threshold) { + item[config.field][key] = 1.0; + } else if (config.keep_negative) { + item[config.field][key] = -1.0; + } else { + item[config.field][key] = 0.0; + } + }); + } else if (type === "number") { + let value = item[config.field]; + if (value > threshold) { + item[config.field] = 1.0; + } else if (config.keep_negative) { + item[config.field] = -1.0; + } else { + item[config.field] = 0.0; + } + } else { + return null; + } + + return item; + } + + /** + * Removes all keys from the item except for the ones specified. + * + * fields An array of strings indicating the fields to keep + */ + allowFields(item, config) { + let newItem = {}; + for (let ele of config.fields) { + if (ele in item) { + newItem[ele] = item[ele]; + } + } + return newItem; + } + + /** + * Removes all keys whose value does not exceed some threshold. + * + * Config: + * field Points to a map of strings to numbers + * threshold Values must exceed this value, otherwise they are removed. + */ + filterByValue(item, config) { + if (!(config.field in item)) { + return null; + } + let threshold = this._lookupScalar(item, config.threshold, 0.0); + let filtered = {}; + Object.keys(item[config.field]).forEach(key => { + let value = item[config.field][key]; + if (value > threshold) { + filtered[key] = value; + } + }); + item[config.field] = filtered; + + return item; + } + + /** + * Rewrites a field so that its values are now L2 normed. + * + * Config: + * field Points to a map of strings to numbers, or an array of numbers + */ + l2Normalize(item, config) { + if (!(config.field in item)) { + return null; + } + let data = item[config.field]; + let type = this._typeOf(data); + if (type === "array") { + let norm = 0.0; + for (let datum of data) { + norm += datum * datum; + } + norm = Math.sqrt(norm); + if (norm !== 0) { + for (let i = 0; i < data.length; i++) { + data[i] /= norm; + } + } + } else if (type === "map") { + let norm = 0.0; + Object.keys(data).forEach(key => { + norm += data[key] * data[key]; + }); + norm = Math.sqrt(norm); + if (norm !== 0) { + Object.keys(data).forEach(key => { + data[key] /= norm; + }); + } + } else { + return null; + } + + item[config.field] = data; + + return item; + } + + /** + * Rewrites a field so that all of its values sum to 1.0 + * + * Config: + * field Points to a map of strings to numbers, or an array of numbers + */ + probNormalize(item, config) { + if (!(config.field in item)) { + return null; + } + let data = item[config.field]; + let type = this._typeOf(data); + if (type === "array") { + let norm = 0.0; + for (let datum of data) { + norm += datum; + } + if (norm !== 0) { + for (let i = 0; i < data.length; i++) { + data[i] /= norm; + } + } + } else if (type === "map") { + let norm = 0.0; + Object.keys(item[config.field]).forEach(key => { + norm += item[config.field][key]; + }); + if (norm !== 0) { + Object.keys(item[config.field]).forEach(key => { + item[config.field][key] /= norm; + }); + } + } else { + return null; + } + + return item; + } + + /** + * Stores a value, if it is not already present + * + * Config: + * field field to write to if it is missing + * value value to store in that field + */ + setDefault(item, config) { + let val = this._lookupScalar(item, config.value, config.value); + if (!(config.field in item)) { + item[config.field] = val; + } + + return item; + } + + /** + * Selctively promotes an value from an inner map up to the outer map + * + * Config: + * haystack Points to a map of strings to values + * needle Key inside the map we should promote up + * dest Where we should write the value of haystack[needle] + */ + lookupValue(item, config) { + if (config.haystack in item && config.needle in item[config.haystack]) { + item[config.dest] = item[config.haystack][config.needle]; + } + + return item; + } + + /** + * Demotes a field into a map + * + * Config: + * src Field to copy + * dest_map Points to a map + * dest_key Key inside dest_map to copy src to + */ + copyToMap(item, config) { + if (config.src in item) { + if (!(config.dest_map in item)) { + item[config.dest_map] = {}; + } + item[config.dest_map][config.dest_key] = item[config.src]; + } + + return item; + } + + /** + * Config: + * field Points to a string to number map + * k Scalar to multiply the values by + * log_scale Boolean, if true, then the values will be transformed + * by a logrithm prior to multiplications + */ + scalarMultiplyTag(item, config) { + let EPSILON = 0.000001; + if (!(config.field in item)) { + return null; + } + let k = this._lookupScalar(item, config.k, 1); + let type = this._typeOf(item[config.field]); + if (type === "map") { + Object.keys(item[config.field]).forEach(parentKey => { + Object.keys(item[config.field][parentKey]).forEach(key => { + let v = item[config.field][parentKey][key]; + if (config.log_scale) { + v = Math.log(v + EPSILON); + } + item[config.field][parentKey][key] = v * k; + }); + }); + } else { + return null; + } + + return item; + } + + /** + * Independently applies softmax across all subtags. + * + * Config: + * field Points to a map of strings with values being another map of strings + */ + applySoftmaxTags(item, config) { + let type = this._typeOf(item[config.field]); + if (type !== "map") { + return null; + } + + let abort = false; + let softmaxSum = {}; + Object.keys(item[config.field]).forEach(tag => { + if (this._typeOf(item[config.field][tag]) !== "map") { + abort = true; + return; + } + if (abort) { + return; + } + softmaxSum[tag] = 0; + Object.keys(item[config.field][tag]).forEach(subtag => { + if (this._typeOf(item[config.field][tag][subtag]) !== "number") { + abort = true; + return; + } + let score = item[config.field][tag][subtag]; + softmaxSum[tag] += Math.exp(score); + }); + }); + if (abort) { + return null; + } + + Object.keys(item[config.field]).forEach(tag => { + Object.keys(item[config.field][tag]).forEach(subtag => { + item[config.field][tag][subtag] = + Math.exp(item[config.field][tag][subtag]) / softmaxSum[tag]; + }); + }); + + return item; + } + + /** + * Vector adds a field and stores the result in left. + * + * Config: + * field The field to vector add + */ + combinerAdd(left, right, config) { + if (!(config.field in right)) { + return left; + } + let type = this._typeOf(right[config.field]); + if (!(config.field in left)) { + if (type === "map") { + left[config.field] = {}; + } else if (type === "array") { + left[config.field] = []; + } else if (type === "number") { + left[config.field] = 0; + } else { + return null; + } + } + if (type !== this._typeOf(left[config.field])) { + return null; + } + if (type === "map") { + Object.keys(right[config.field]).forEach(key => { + if (!(key in left[config.field])) { + left[config.field][key] = 0; + } + left[config.field][key] += right[config.field][key]; + }); + } else if (type === "array") { + for (let i = 0; i < right[config.field].length; i++) { + if (i < left[config.field].length) { + left[config.field][i] += right[config.field][i]; + } else { + left[config.field].push(right[config.field][i]); + } + } + } else if (type === "number") { + left[config.field] += right[config.field]; + } else { + return null; + } + + return left; + } + + /** + * Stores the maximum value of the field in left. + * + * Config: + * field The field to vector add + */ + combinerMax(left, right, config) { + if (!(config.field in right)) { + return left; + } + let type = this._typeOf(right[config.field]); + if (!(config.field in left)) { + if (type === "map") { + left[config.field] = {}; + } else if (type === "array") { + left[config.field] = []; + } else if (type === "number") { + left[config.field] = 0; + } else { + return null; + } + } + if (type !== this._typeOf(left[config.field])) { + return null; + } + if (type === "map") { + Object.keys(right[config.field]).forEach(key => { + if ( + !(key in left[config.field]) || + right[config.field][key] > left[config.field][key] + ) { + left[config.field][key] = right[config.field][key]; + } + }); + } else if (type === "array") { + for (let i = 0; i < right[config.field].length; i++) { + if (i < left[config.field].length) { + if (left[config.field][i] < right[config.field][i]) { + left[config.field][i] = right[config.field][i]; + } + } else { + left[config.field].push(right[config.field][i]); + } + } + } else if (type === "number") { + if (left[config.field] < right[config.field]) { + left[config.field] = right[config.field]; + } + } else { + return null; + } + + return left; + } + + /** + * Associates a value in right with another value in right. This association + * is then stored in a map in left. + * + * For example: If a sequence of rights is: + * { 'tags': {}, 'url_domain': 'maseratiusa.com/maserati', 'time': 41 } + * { 'tags': {}, 'url_domain': 'mbusa.com/mercedes', 'time': 21 } + * { 'tags': {}, 'url_domain': 'maseratiusa.com/maserati', 'time': 34 } + * + * Then assuming a 'sum' operation, left can build a map that would look like: + * { + * 'maseratiusa.com/maserati': 75, + * 'mbusa.com/mercedes': 21, + * } + * + * Fields: + * left_field field in the left to store / update the map + * right_key_field Field in the right to use as a key + * right_value_field Field in the right to use as a value + * operation One of "sum", "max", "overwrite", "count" + */ + combinerCollectValues(left, right, config) { + let op; + if (config.operation === "sum") { + op = (a, b) => a + b; + } else if (config.operation === "max") { + op = (a, b) => (a > b ? a : b); + } else if (config.operation === "overwrite") { + op = (a, b) => b; + } else if (config.operation === "count") { + op = (a, b) => a + 1; + } else { + return null; + } + if (!(config.left_field in left)) { + left[config.left_field] = {}; + } + if ( + !(config.right_key_field in right) || + !(config.right_value_field in right) + ) { + return left; + } + + let key = right[config.right_key_field]; + let rightValue = right[config.right_value_field]; + let leftValue = 0.0; + if (key in left[config.left_field]) { + leftValue = left[config.left_field][key]; + } + + left[config.left_field][key] = op(leftValue, rightValue); + + return left; + } + + /** + * Executes a recipe. Returns an object on success, or null on failure. + */ + executeRecipe(item, recipe) { + let newItem = item; + if (recipe) { + for (let step of recipe) { + let op = this.ITEM_BUILDER_REGISTRY[step.function]; + if (op === undefined) { + return null; + } + newItem = op.call(this, newItem, step); + if (newItem === null) { + break; + } + } + } + return newItem; + } + + /** + * Executes a recipe. Returns an object on success, or null on failure. + */ + executeCombinerRecipe(item1, item2, recipe) { + let newItem1 = item1; + for (let step of recipe) { + let op = this.ITEM_COMBINER_REGISTRY[step.function]; + if (op === undefined) { + return null; + } + newItem1 = op.call(this, newItem1, item2, step); + if (newItem1 === null) { + break; + } + } + + return newItem1; + } +} diff --git a/browser/components/newtab/lib/PersonalityProvider/Tokenize.mjs b/browser/components/newtab/lib/PersonalityProvider/Tokenize.mjs new file mode 100644 index 0000000000..740b2fc541 --- /dev/null +++ b/browser/components/newtab/lib/PersonalityProvider/Tokenize.mjs @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Unicode specifies certain mnemonics for code pages and character classes. +// They call them "character properties" https://en.wikipedia.org/wiki/Unicode_character_property . +// These mnemonics are have been adopted by many regular expression libraries, +// however the standard Javascript regexp system doesn't support unicode +// character properties, so we have to define these ourself. +// +// Each of these sections contains the characters values / ranges for specific +// character property: Whitespace, Symbol (S), Punctuation (P), Number (N), +// Mark (M), and Letter (L). +const UNICODE_SPACE = + "\x20\xA0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000"; +const UNICODE_SYMBOL = + "\\x24\\x2B\x3C-\x3E\\x5E\x60\\x7C\x7E\xA2-\xA6\xA8\xA9\xAC\xAE-\xB1\xB4\xB8\xD7\xF7\u02C2-\u02C5\u02D2-\u02DF\u02E5-\u02EB\u02ED\u02EF-\u02FF\u0375\u0384\u0385\u03F6\u0482\u058D-\u058F\u0606-\u0608\u060B\u060E\u060F\u06DE\u06E9\u06FD\u06FE\u07F6\u09F2\u09F3\u09FA\u09FB\u0AF1\u0B70\u0BF3-\u0BFA\u0C7F\u0D4F\u0D79\u0E3F\u0F01-\u0F03\u0F13\u0F15-\u0F17\u0F1A-\u0F1F\u0F34\u0F36\u0F38\u0FBE-\u0FC5\u0FC7-\u0FCC\u0FCE\u0FCF\u0FD5-\u0FD8\u109E\u109F\u1390-\u1399\u17DB\u1940\u19DE-\u19FF\u1B61-\u1B6A\u1B74-\u1B7C\u1FBD\u1FBF-\u1FC1\u1FCD-\u1FCF\u1FDD-\u1FDF\u1FED-\u1FEF\u1FFD\u1FFE\u2044\u2052\u207A-\u207C\u208A-\u208C\u20A0-\u20BE\u2100\u2101\u2103-\u2106\u2108\u2109\u2114\u2116-\u2118\u211E-\u2123\u2125\u2127\u2129\u212E\u213A\u213B\u2140-\u2144\u214A-\u214D\u214F\u218A\u218B\u2190-\u2307\u230C-\u2328\u232B-\u23FE\u2400-\u2426\u2440-\u244A\u249C-\u24E9\u2500-\u2767\u2794-\u27C4\u27C7-\u27E5\u27F0-\u2982\u2999-\u29D7\u29DC-\u29FB\u29FE-\u2B73\u2B76-\u2B95\u2B98-\u2BB9\u2BBD-\u2BC8\u2BCA-\u2BD1\u2BEC-\u2BEF\u2CE5-\u2CEA\u2E80-\u2E99\u2E9B-\u2EF3\u2F00-\u2FD5\u2FF0-\u2FFB\u3004\u3012\u3013\u3020\u3036\u3037\u303E\u303F\u309B\u309C\u3190\u3191\u3196-\u319F\u31C0-\u31E3\u3200-\u321E\u322A-\u3247\u3250\u3260-\u327F\u328A-\u32B0\u32C0-\u32FE\u3300-\u33FF\u4DC0-\u4DFF\uA490-\uA4C6\uA700-\uA716\uA720\uA721\uA789\uA78A\uA828-\uA82B\uA836-\uA839\uAA77-\uAA79\uAB5B\uFB29\uFBB2-\uFBC1\uFDFC\uFDFD\uFE62\uFE64-\uFE66\uFE69\uFF04\uFF0B\uFF1C-\uFF1E\uFF3E\uFF40\uFF5C\uFF5E\uFFE0-\uFFE6\uFFE8-\uFFEE\uFFFC\uFFFD"; +const UNICODE_PUNCT = + "\x21-\x23\x25-\\x2A\x2C-\x2F\x3A\x3B\\x3F\x40\\x5B-\\x5D\x5F\\x7B\x7D\xA1\xA7\xAB\xB6\xB7\xBB\xBF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061E\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u0AF0\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166D\u166E\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2010-\u2027\u2030-\u2043\u2045-\u2051\u2053-\u205E\u207D\u207E\u208D\u208E\u2308-\u230B\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E44\u3001-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65"; + +const UNICODE_NUMBER = + "0-9\xB2\xB3\xB9\xBC-\xBE\u0660-\u0669\u06F0-\u06F9\u07C0-\u07C9\u0966-\u096F\u09E6-\u09EF\u09F4-\u09F9\u0A66-\u0A6F\u0AE6-\u0AEF\u0B66-\u0B6F\u0B72-\u0B77\u0BE6-\u0BF2\u0C66-\u0C6F\u0C78-\u0C7E\u0CE6-\u0CEF\u0D58-\u0D5E\u0D66-\u0D78\u0DE6-\u0DEF\u0E50-\u0E59\u0ED0-\u0ED9\u0F20-\u0F33\u1040-\u1049\u1090-\u1099\u1369-\u137C\u16EE-\u16F0\u17E0-\u17E9\u17F0-\u17F9\u1810-\u1819\u1946-\u194F\u19D0-\u19DA\u1A80-\u1A89\u1A90-\u1A99\u1B50-\u1B59\u1BB0-\u1BB9\u1C40-\u1C49\u1C50-\u1C59\u2070\u2074-\u2079\u2080-\u2089\u2150-\u2182\u2185-\u2189\u2460-\u249B\u24EA-\u24FF\u2776-\u2793\u2CFD\u3007\u3021-\u3029\u3038-\u303A\u3192-\u3195\u3220-\u3229\u3248-\u324F\u3251-\u325F\u3280-\u3289\u32B1-\u32BF\uA620-\uA629\uA6E6-\uA6EF\uA830-\uA835\uA8D0-\uA8D9\uA900-\uA909\uA9D0-\uA9D9\uA9F0-\uA9F9\uAA50-\uAA59\uABF0-\uABF9\uFF10-\uFF19"; +const UNICODE_MARK = + "\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u08D4-\u08E1\u08E3-\u0903\u093A-\u093C\u093E-\u094F\u0951-\u0957\u0962\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E2\u09E3\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2\u0AE3\u0B01-\u0B03\u0B3C\u0B3E-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B62\u0B63\u0B82\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C00-\u0C03\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81-\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CE2\u0CE3\u0D01-\u0D03\u0D3E-\u0D44\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D62\u0D63\u0D82\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2\u0DF3\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EB9\u0EBB\u0EBC\u0EC8-\u0ECD\u0F18\u0F19\u0F35\u0F37\u0F39\u0F3E\u0F3F\u0F71-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102B-\u103E\u1056-\u1059\u105E-\u1060\u1062-\u1064\u1067-\u106D\u1071-\u1074\u1082-\u108D\u108F\u109A-\u109D\u135D-\u135F\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u17B4-\u17D3\u17DD\u180B-\u180D\u1885\u1886\u18A9\u1920-\u192B\u1930-\u193B\u1A17-\u1A1B\u1A55-\u1A5E\u1A60-\u1A7C\u1A7F\u1AB0-\u1ABE\u1B00-\u1B04\u1B34-\u1B44\u1B6B-\u1B73\u1B80-\u1B82\u1BA1-\u1BAD\u1BE6-\u1BF3\u1C24-\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE8\u1CED\u1CF2-\u1CF4\u1CF8\u1CF9\u1DC0-\u1DF5\u1DFB-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302F\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA823-\uA827\uA880\uA881\uA8B4-\uA8C5\uA8E0-\uA8F1\uA926-\uA92D\uA947-\uA953\uA980-\uA983\uA9B3-\uA9C0\uA9E5\uAA29-\uAA36\uAA43\uAA4C\uAA4D\uAA7B-\uAA7D\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEB-\uAAEF\uAAF5\uAAF6\uABE3-\uABEA\uABEC\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F"; +const UNICODE_LETTER = + "A-Za-z\xAA\xB5\xBA\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0-\u08B4\u08B6-\u08BD\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C60\u0C61\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D5F-\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16F1-\u16F8\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1C80-\u1C88\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FD5\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA7AE\uA7B0-\uA7B7\uA7F7-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA8FD\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uA9E0-\uA9E4\uA9E6-\uA9EF\uA9FA-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB65\uAB70-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC"; + +const REGEXP_SPLITS = new RegExp( + `[${UNICODE_SPACE}${UNICODE_SYMBOL}${UNICODE_PUNCT}]+` +); +// Match all token characters, so okay for regex to split multiple code points +// eslint-disable-next-line no-misleading-character-class +const REGEXP_ALPHANUMS = new RegExp( + `^[${UNICODE_NUMBER}${UNICODE_MARK}${UNICODE_LETTER}]+$` +); + +/** + * Downcases the text, and splits it into consecutive alphanumeric characters. + * This is locale aware, and so will not strip accents. This uses "word + * breaks", and os is not appropriate for languages without them + * (e.g. Chinese). + */ +export function tokenize(text) { + return text + .toLocaleLowerCase() + .split(REGEXP_SPLITS) + .filter(tok => tok.match(REGEXP_ALPHANUMS)); +} + +/** + * Converts a sequence of tokens into an L2 normed TF-IDF. Any terms that are + * not preindexed (i.e. does have a computed inverse document frequency) will + * be dropped. + */ +export function toksToTfIdfVector(tokens, vocab_idfs) { + let tfidfs = {}; + + // calcualte the term frequencies + for (let tok of tokens) { + if (!(tok in vocab_idfs)) { + continue; + } + if (!(tok in tfidfs)) { + tfidfs[tok] = [vocab_idfs[tok][0], 1]; + } else { + tfidfs[tok][1]++; + } + } + + // now multiply by the log inverse document frequencies, then take + // the L2 norm of this. + let l2Norm = 0.0; + Object.keys(tfidfs).forEach(tok => { + tfidfs[tok][1] *= vocab_idfs[tok][1]; + l2Norm += tfidfs[tok][1] * tfidfs[tok][1]; + }); + l2Norm = Math.sqrt(l2Norm); + Object.keys(tfidfs).forEach(tok => { + tfidfs[tok][1] /= l2Norm; + }); + + return tfidfs; +} diff --git a/browser/components/newtab/lib/PlacesFeed.sys.mjs b/browser/components/newtab/lib/PlacesFeed.sys.mjs new file mode 100644 index 0000000000..70011412f8 --- /dev/null +++ b/browser/components/newtab/lib/PlacesFeed.sys.mjs @@ -0,0 +1,572 @@ +/* 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, + actionUtils as au, +} from "resource://activity-stream/common/Actions.sys.mjs"; + +import { shortURL } from "resource://activity-stream/lib/ShortURL.sys.mjs"; + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module. This +// is because the Karma test environment already stubs out +// AboutNewTab, and overrides importESModule to be a no-op (which +// can't be done for a static import statement). + +// eslint-disable-next-line mozilla/use-static-import +const { AboutNewTab } = ChromeUtils.importESModule( + "resource:///modules/AboutNewTab.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + PartnerLinkAttribution: "resource:///modules/PartnerLinkAttribution.sys.mjs", + pktApi: "chrome://pocket/content/pktApi.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +const LINK_BLOCKED_EVENT = "newtab-linkBlocked"; +const PLACES_LINKS_CHANGED_DELAY_TIME = 1000; // time in ms to delay timer for places links changed events + +// The pref to store the blocked sponsors of the sponsored Top Sites. +// The value of this pref is an array (JSON serialized) of hostnames of the +// blocked sponsors. +const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors"; + +/** + * PlacesObserver - observes events from PlacesUtils.observers + */ +class PlacesObserver { + constructor(dispatch) { + this.dispatch = dispatch; + this.QueryInterface = ChromeUtils.generateQI(["nsISupportsWeakReference"]); + this.handlePlacesEvent = this.handlePlacesEvent.bind(this); + } + + handlePlacesEvent(events) { + const removedPages = []; + const removedBookmarks = []; + + for (const { + itemType, + source, + dateAdded, + guid, + title, + url, + isRemovedFromStore, + isTagging, + type, + } of events) { + switch (type) { + case "history-cleared": + this.dispatch({ type: at.PLACES_HISTORY_CLEARED }); + break; + case "page-removed": + if (isRemovedFromStore) { + removedPages.push(url); + } + break; + case "bookmark-added": + // Skips items that are not bookmarks (like folders), about:* pages or + // default bookmarks, added when the profile is created. + if ( + isTagging || + itemType !== lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK || + source === lazy.PlacesUtils.bookmarks.SOURCES.IMPORT || + source === lazy.PlacesUtils.bookmarks.SOURCES.RESTORE || + source === lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP || + source === lazy.PlacesUtils.bookmarks.SOURCES.SYNC || + (!url.startsWith("http://") && !url.startsWith("https://")) + ) { + return; + } + + this.dispatch({ type: at.PLACES_LINKS_CHANGED }); + this.dispatch({ + type: at.PLACES_BOOKMARK_ADDED, + data: { + bookmarkGuid: guid, + bookmarkTitle: title, + dateAdded: dateAdded * 1000, + url, + }, + }); + break; + case "bookmark-removed": + if ( + isTagging || + (itemType === lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK && + source !== lazy.PlacesUtils.bookmarks.SOURCES.IMPORT && + source !== lazy.PlacesUtils.bookmarks.SOURCES.RESTORE && + source !== + lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP && + source !== lazy.PlacesUtils.bookmarks.SOURCES.SYNC) + ) { + removedBookmarks.push(url); + } + break; + } + } + + if (removedPages.length || removedBookmarks.length) { + this.dispatch({ type: at.PLACES_LINKS_CHANGED }); + } + + if (removedPages.length) { + this.dispatch({ + type: at.PLACES_LINKS_DELETED, + data: { urls: removedPages }, + }); + } + + if (removedBookmarks.length) { + this.dispatch({ + type: at.PLACES_BOOKMARKS_REMOVED, + data: { urls: removedBookmarks }, + }); + } + } +} + +export class PlacesFeed { + constructor() { + this.placesChangedTimer = null; + this.customDispatch = this.customDispatch.bind(this); + this.placesObserver = new PlacesObserver(this.customDispatch); + } + + addObservers() { + lazy.PlacesUtils.observers.addListener( + ["bookmark-added", "bookmark-removed", "history-cleared", "page-removed"], + this.placesObserver.handlePlacesEvent + ); + + Services.obs.addObserver(this, LINK_BLOCKED_EVENT); + } + + /** + * setTimeout - A custom function that creates an nsITimer that can be cancelled + * + * @param {func} callback A function to be executed after the timer expires + * @param {int} delay The time (in ms) the timer should wait before the function is executed + */ + setTimeout(callback, delay) { + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(callback, delay, Ci.nsITimer.TYPE_ONE_SHOT); + return timer; + } + + customDispatch(action) { + // If we are changing many links at once, delay this action and only dispatch + // one action at the end + if (action.type === at.PLACES_LINKS_CHANGED) { + if (this.placesChangedTimer) { + this.placesChangedTimer.delay = PLACES_LINKS_CHANGED_DELAY_TIME; + } else { + this.placesChangedTimer = this.setTimeout(() => { + this.placesChangedTimer = null; + this.store.dispatch(ac.OnlyToMain(action)); + }, PLACES_LINKS_CHANGED_DELAY_TIME); + } + } else { + // To avoid blocking Places notifications on expensive work, run it at the + // next tick of the events loop. + Services.tm.dispatchToMainThread(() => + this.store.dispatch(ac.BroadcastToContent(action)) + ); + } + } + + removeObservers() { + if (this.placesChangedTimer) { + this.placesChangedTimer.cancel(); + this.placesChangedTimer = null; + } + lazy.PlacesUtils.observers.removeListener( + ["bookmark-added", "bookmark-removed", "history-cleared", "page-removed"], + this.placesObserver.handlePlacesEvent + ); + Services.obs.removeObserver(this, LINK_BLOCKED_EVENT); + } + + /** + * observe - An observer for the LINK_BLOCKED_EVENT. + * Called when a link is blocked. + * Links can be blocked outside of newtab, + * which is why we need to listen to this + * on such a generic level. + * + * @param {null} subject + * @param {str} topic The name of the event + * @param {str} value The data associated with the event + */ + observe(subject, topic, value) { + if (topic === LINK_BLOCKED_EVENT) { + this.store.dispatch( + ac.BroadcastToContent({ + type: at.PLACES_LINK_BLOCKED, + data: { url: value }, + }) + ); + } + } + + /** + * Open a link in a desired destination defaulting to action's event. + */ + openLink(action, where = "", isPrivate = false) { + const params = { + private: isPrivate, + targetBrowser: action._target.browser, + forceForeground: false, // This ensure we maintain user preference for how to open new tabs. + globalHistoryOptions: { + triggeringSponsoredURL: action.data.sponsored_tile_id + ? action.data.url + : undefined, + }, + }; + + // Always include the referrer (even for http links) if we have one + const { event, referrer, typedBonus } = action.data; + if (referrer) { + const ReferrerInfo = Components.Constructor( + "@mozilla.org/referrer-info;1", + "nsIReferrerInfo", + "init" + ); + params.referrerInfo = new ReferrerInfo( + Ci.nsIReferrerInfo.UNSAFE_URL, + true, + Services.io.newURI(referrer) + ); + } + + // Pocket gives us a special reader URL to open their stories in + const urlToOpen = + action.data.type === "pocket" ? action.data.open_url : action.data.url; + + try { + let uri = Services.io.newURI(urlToOpen); + if (!["http", "https"].includes(uri.scheme)) { + throw new Error( + `Can't open link using ${uri.scheme} protocol from the new tab page.` + ); + } + } catch (e) { + console.error(e); + return; + } + + // Mark the page as typed for frecency bonus before opening the link + if (typedBonus) { + lazy.PlacesUtils.history.markPageAsTyped(Services.io.newURI(urlToOpen)); + } + + const win = action._target.browser.ownerGlobal; + win.openTrustedLinkIn( + urlToOpen, + where || win.whereToOpenLink(event), + params + ); + + // If there's an original URL e.g. using the unprocessed %YYYYMMDDHH% tag, + // add a visit for that so it may become a frecent top site. + if (action.data.original_url) { + lazy.PlacesUtils.history.insert({ + url: action.data.original_url, + visits: [{ transition: lazy.PlacesUtils.history.TRANSITION_TYPED }], + }); + } + } + + async saveToPocket(site, browser) { + const sendToPocket = + lazy.NimbusFeatures.pocketNewtab.getVariable("sendToPocket"); + // An experiment to send the user directly to Pocket's signup page. + if (sendToPocket && !lazy.pktApi.isUserLoggedIn()) { + const pocketNewtabExperiment = lazy.ExperimentAPI.getExperiment({ + featureId: "pocketNewtab", + }); + const pocketSiteHost = Services.prefs.getStringPref( + "extensions.pocket.site" + ); // getpocket.com + let utmSource = "firefox_newtab_save_button"; + // We want to know if the user is in a Pocket newtab related experiment. + let utmCampaign = pocketNewtabExperiment?.slug; + let utmContent = pocketNewtabExperiment?.branch?.slug; + + const url = new URL(`https://${pocketSiteHost}/signup`); + url.searchParams.append("utm_source", utmSource); + if (utmCampaign && utmContent) { + url.searchParams.append("utm_campaign", utmCampaign); + url.searchParams.append("utm_content", utmContent); + } + + const win = browser.ownerGlobal; + win.openTrustedLinkIn(url.href, "tab"); + return; + } + + const { url, title } = site; + try { + let data = await lazy.NewTabUtils.activityStreamLinks.addPocketEntry( + url, + title, + browser + ); + if (data) { + this.store.dispatch( + ac.BroadcastToContent({ + type: at.PLACES_SAVED_TO_POCKET, + data: { + url, + open_url: data.item.open_url, + title, + pocket_id: data.item.item_id, + }, + }) + ); + } + } catch (err) { + console.error(err); + } + } + + /** + * Deletes an item from a user's saved to Pocket feed + * @param {int} itemID + * The unique ID given by Pocket for that item; used to look the item up when deleting + */ + async deleteFromPocket(itemID) { + try { + await lazy.NewTabUtils.activityStreamLinks.deletePocketEntry(itemID); + this.store.dispatch({ type: at.POCKET_LINK_DELETED_OR_ARCHIVED }); + } catch (err) { + console.error(err); + } + } + + /** + * Archives an item from a user's saved to Pocket feed + * @param {int} itemID + * The unique ID given by Pocket for that item; used to look the item up when archiving + */ + async archiveFromPocket(itemID) { + try { + await lazy.NewTabUtils.activityStreamLinks.archivePocketEntry(itemID); + this.store.dispatch({ type: at.POCKET_LINK_DELETED_OR_ARCHIVED }); + } catch (err) { + console.error(err); + } + } + + /** + * Sends an attribution request for Top Sites interactions. + * @param {object} data + * Attribution paramters from a Top Site. + */ + makeAttributionRequest(data) { + let args = Object.assign( + { + campaignID: Services.prefs.getStringPref( + "browser.partnerlink.campaign.topsites" + ), + }, + data + ); + lazy.PartnerLinkAttribution.makeRequest(args); + } + + async fillSearchTopSiteTerm({ _target, data }) { + const searchEngine = await Services.search.getEngineByAlias(data.label); + _target.browser.ownerGlobal.gURLBar.search(data.label, { + searchEngine, + searchModeEntry: "topsites_newtab", + }); + } + + _getDefaultSearchEngine(isPrivateWindow) { + return Services.search[ + isPrivateWindow ? "defaultPrivateEngine" : "defaultEngine" + ]; + } + + handoffSearchToAwesomebar(action) { + const { _target, data, meta } = action; + const searchEngine = this._getDefaultSearchEngine( + lazy.PrivateBrowsingUtils.isBrowserPrivate(_target.browser) + ); + const urlBar = _target.browser.ownerGlobal.gURLBar; + let isFirstChange = true; + + const newtabSession = AboutNewTab.activityStream.store.feeds + .get("feeds.telemetry") + ?.sessions.get(au.getPortIdOfSender(action)); + if (!data || !data.text) { + urlBar.setHiddenFocus(); + } else { + urlBar.handoff(data.text, searchEngine, newtabSession?.session_id); + isFirstChange = false; + } + + const checkFirstChange = () => { + // Check if this is the first change since we hidden focused. If it is, + // remove hidden focus styles, prepend the search alias and hide the + // in-content search. + if (isFirstChange) { + isFirstChange = false; + urlBar.removeHiddenFocus(true); + urlBar.handoff("", searchEngine, newtabSession?.session_id); + this.store.dispatch( + ac.OnlyToOneContent({ type: at.DISABLE_SEARCH }, meta.fromTarget) + ); + urlBar.removeEventListener("compositionstart", checkFirstChange); + urlBar.removeEventListener("paste", checkFirstChange); + } + }; + + const onKeydown = ev => { + // Check if the keydown will cause a value change. + if (ev.key.length === 1 && !ev.altKey && !ev.ctrlKey && !ev.metaKey) { + checkFirstChange(); + } + // If the Esc button is pressed, we are done. Show in-content search and cleanup. + if (ev.key === "Escape") { + onDone(); // eslint-disable-line no-use-before-define + } + }; + + const onDone = ev => { + // We are done. Show in-content search again and cleanup. + this.store.dispatch( + ac.OnlyToOneContent({ type: at.SHOW_SEARCH }, meta.fromTarget) + ); + + const forceSuppressFocusBorder = ev?.type === "mousedown"; + urlBar.removeHiddenFocus(forceSuppressFocusBorder); + + urlBar.removeEventListener("keydown", onKeydown); + urlBar.removeEventListener("mousedown", onDone); + urlBar.removeEventListener("blur", onDone); + urlBar.removeEventListener("compositionstart", checkFirstChange); + urlBar.removeEventListener("paste", checkFirstChange); + }; + + urlBar.addEventListener("keydown", onKeydown); + urlBar.addEventListener("mousedown", onDone); + urlBar.addEventListener("blur", onDone); + urlBar.addEventListener("compositionstart", checkFirstChange); + urlBar.addEventListener("paste", checkFirstChange); + } + + /** + * Add the hostnames of the given urls to the Top Sites sponsor blocklist. + * + * @param {array} urls + * An array of the objects structured as `{ url }` + */ + addToBlockedTopSitesSponsors(urls) { + const blockedPref = JSON.parse( + Services.prefs.getStringPref(TOP_SITES_BLOCKED_SPONSORS_PREF, "[]") + ); + const merged = new Set([...blockedPref, ...urls.map(url => shortURL(url))]); + + Services.prefs.setStringPref( + TOP_SITES_BLOCKED_SPONSORS_PREF, + JSON.stringify([...merged]) + ); + } + + onAction(action) { + switch (action.type) { + case at.INIT: + // Briefly avoid loading services for observing for better startup timing + Services.tm.dispatchToMainThread(() => this.addObservers()); + break; + case at.UNINIT: + this.removeObservers(); + break; + case at.ABOUT_SPONSORED_TOP_SITES: { + const url = `${Services.urlFormatter.formatURLPref( + "app.support.baseURL" + )}sponsor-privacy`; + const win = action._target.browser.ownerGlobal; + win.openTrustedLinkIn(url, "tab"); + break; + } + case at.BLOCK_URL: { + if (action.data) { + let sponsoredTopSites = []; + action.data.forEach(site => { + const { url, pocket_id, isSponsoredTopSite } = site; + lazy.NewTabUtils.activityStreamLinks.blockURL({ url, pocket_id }); + if (isSponsoredTopSite) { + sponsoredTopSites.push({ url }); + } + }); + if (sponsoredTopSites.length) { + this.addToBlockedTopSitesSponsors(sponsoredTopSites); + } + } + break; + } + case at.BOOKMARK_URL: + lazy.NewTabUtils.activityStreamLinks.addBookmark( + action.data, + action._target.browser.ownerGlobal + ); + break; + case at.DELETE_BOOKMARK_BY_ID: + lazy.NewTabUtils.activityStreamLinks.deleteBookmark(action.data); + break; + case at.DELETE_HISTORY_URL: { + const { url, forceBlock, pocket_id } = action.data; + lazy.NewTabUtils.activityStreamLinks.deleteHistoryEntry(url); + if (forceBlock) { + lazy.NewTabUtils.activityStreamLinks.blockURL({ url, pocket_id }); + } + break; + } + case at.OPEN_NEW_WINDOW: + this.openLink(action, "window"); + break; + case at.OPEN_PRIVATE_WINDOW: + this.openLink(action, "window", true); + break; + case at.SAVE_TO_POCKET: + this.saveToPocket(action.data.site, action._target.browser); + break; + case at.DELETE_FROM_POCKET: + this.deleteFromPocket(action.data.pocket_id); + break; + case at.ARCHIVE_FROM_POCKET: + this.archiveFromPocket(action.data.pocket_id); + break; + case at.FILL_SEARCH_TERM: + this.fillSearchTopSiteTerm(action); + break; + case at.HANDOFF_SEARCH_TO_AWESOMEBAR: + this.handoffSearchToAwesomebar(action); + break; + case at.OPEN_LINK: { + this.openLink(action); + break; + } + case at.PARTNER_LINK_ATTRIBUTION: + this.makeAttributionRequest(action.data); + break; + } + } +} + +// Exported for testing only +PlacesFeed.PlacesObserver = PlacesObserver; diff --git a/browser/components/newtab/lib/PrefsFeed.sys.mjs b/browser/components/newtab/lib/PrefsFeed.sys.mjs new file mode 100644 index 0000000000..1c6f9b0d45 --- /dev/null +++ b/browser/components/newtab/lib/PrefsFeed.sys.mjs @@ -0,0 +1,273 @@ +/* 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.sys.mjs"; +import { Prefs } from "resource://activity-stream/lib/ActivityStreamPrefs.sys.mjs"; + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module. This +// is because the Karma test environment already stubs out +// AppConstants, and overrides importESModule to be a no-op (which +// can't be done for a static import statement). + +// eslint-disable-next-line mozilla/use-static-import +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + Region: "resource://gre/modules/Region.sys.mjs", +}); + +export class PrefsFeed { + constructor(prefMap) { + this._prefMap = prefMap; + this._prefs = new Prefs(); + this.onExperimentUpdated = this.onExperimentUpdated.bind(this); + this.onPocketExperimentUpdated = this.onPocketExperimentUpdated.bind(this); + } + + onPrefChanged(name, value) { + const prefItem = this._prefMap.get(name); + if (prefItem) { + let action = "BroadcastToContent"; + if (prefItem.skipBroadcast) { + action = "OnlyToMain"; + if (prefItem.alsoToPreloaded) { + action = "AlsoToPreloaded"; + } + } + + this.store.dispatch( + ac[action]({ + type: at.PREF_CHANGED, + data: { name, value }, + }) + ); + } + } + + _setStringPref(values, key, defaultValue) { + this._setPref(values, key, defaultValue, Services.prefs.getStringPref); + } + + _setBoolPref(values, key, defaultValue) { + this._setPref(values, key, defaultValue, Services.prefs.getBoolPref); + } + + _setIntPref(values, key, defaultValue) { + this._setPref(values, key, defaultValue, Services.prefs.getIntPref); + } + + _setPref(values, key, defaultValue, getPrefFunction) { + let value = getPrefFunction( + `browser.newtabpage.activity-stream.${key}`, + defaultValue + ); + values[key] = value; + this._prefMap.set(key, { value }); + } + + /** + * Handler for when experiment data updates. + */ + onExperimentUpdated(event, reason) { + const value = lazy.NimbusFeatures.newtab.getAllVariables() || {}; + this.store.dispatch( + ac.BroadcastToContent({ + type: at.PREF_CHANGED, + data: { + name: "featureConfig", + value, + }, + }) + ); + } + + /** + * Handler for Pocket specific experiment data updates. + */ + onPocketExperimentUpdated(event, reason) { + const value = lazy.NimbusFeatures.pocketNewtab.getAllVariables() || {}; + // Loaded experiments are set up inside init() + if ( + reason !== "feature-experiment-loaded" && + reason !== "feature-rollout-loaded" + ) { + this.store.dispatch( + ac.BroadcastToContent({ + type: at.PREF_CHANGED, + data: { + name: "pocketConfig", + value, + }, + }) + ); + } + } + + init() { + this._prefs.observeBranch(this); + lazy.NimbusFeatures.newtab.onUpdate(this.onExperimentUpdated); + lazy.NimbusFeatures.pocketNewtab.onUpdate(this.onPocketExperimentUpdated); + + this._storage = this.store.dbStorage.getDbTable("sectionPrefs"); + + // Get the initial value of each activity stream pref + const values = {}; + for (const name of this._prefMap.keys()) { + values[name] = this._prefs.get(name); + } + + // These are not prefs, but are needed to determine stuff in content that can only be + // computed in main process + values.isPrivateBrowsingEnabled = lazy.PrivateBrowsingUtils.enabled; + values.platform = AppConstants.platform; + + // Save the geo pref if we have it + if (lazy.Region.home) { + values.region = lazy.Region.home; + this.geo = values.region; + } else if (this.geo !== "") { + // Watch for geo changes and use a dummy value for now + Services.obs.addObserver(this, lazy.Region.REGION_TOPIC); + this.geo = ""; + } + + // Get the firefox accounts url for links and to send firstrun metrics to. + values.fxa_endpoint = Services.prefs.getStringPref( + "browser.newtabpage.activity-stream.fxaccounts.endpoint", + "https://accounts.firefox.com" + ); + + // Get the firefox update channel with values as default, nightly, beta or release + values.appUpdateChannel = Services.prefs.getStringPref( + "app.update.channel", + "" + ); + + // Read the pref for search shortcuts top sites experiment from firefox.js and store it + // in our internal list of prefs to watch + let searchTopSiteExperimentPrefValue = Services.prefs.getBoolPref( + "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts" + ); + values["improvesearch.topSiteSearchShortcuts"] = + searchTopSiteExperimentPrefValue; + this._prefMap.set("improvesearch.topSiteSearchShortcuts", { + value: searchTopSiteExperimentPrefValue, + }); + + values.mayHaveSponsoredTopSites = Services.prefs.getBoolPref( + "browser.topsites.useRemoteSetting" + ); + + // Read the pref for search hand-off from firefox.js and store it + // in our internal list of prefs to watch + let handoffToAwesomebarPrefValue = Services.prefs.getBoolPref( + "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar" + ); + values["improvesearch.handoffToAwesomebar"] = handoffToAwesomebarPrefValue; + this._prefMap.set("improvesearch.handoffToAwesomebar", { + value: handoffToAwesomebarPrefValue, + }); + + // Add experiment values and default values + values.featureConfig = lazy.NimbusFeatures.newtab.getAllVariables() || {}; + values.pocketConfig = + lazy.NimbusFeatures.pocketNewtab.getAllVariables() || {}; + this._setBoolPref(values, "logowordmark.alwaysVisible", false); + this._setBoolPref(values, "feeds.section.topstories", false); + this._setBoolPref(values, "discoverystream.enabled", false); + this._setBoolPref( + values, + "discoverystream.sponsored-collections.enabled", + false + ); + this._setBoolPref(values, "discoverystream.isCollectionDismissible", false); + this._setBoolPref(values, "discoverystream.hardcoded-basic-layout", false); + this._setBoolPref(values, "discoverystream.personalization.enabled", false); + this._setBoolPref(values, "discoverystream.personalization.override"); + this._setStringPref( + values, + "discoverystream.personalization.modelKeys", + "" + ); + this._setStringPref(values, "discoverystream.spocs-endpoint", ""); + this._setStringPref(values, "discoverystream.spocs-endpoint-query", ""); + this._setStringPref(values, "newNewtabExperience.colors", ""); + + // Set the initial state of all prefs in redux + this.store.dispatch( + ac.BroadcastToContent({ + type: at.PREFS_INITIAL_VALUES, + data: values, + meta: { + isStartup: true, + }, + }) + ); + } + + uninit() { + this.removeListeners(); + } + + removeListeners() { + this._prefs.ignoreBranch(this); + lazy.NimbusFeatures.newtab.offUpdate(this.onExperimentUpdated); + lazy.NimbusFeatures.pocketNewtab.offUpdate(this.onPocketExperimentUpdated); + if (this.geo === "") { + Services.obs.removeObserver(this, lazy.Region.REGION_TOPIC); + } + } + + async _setIndexedDBPref(id, value) { + const name = id === "topsites" ? id : `feeds.section.${id}`; + try { + await this._storage.set(name, value); + } catch (e) { + console.error("Could not set section preferences."); + } + } + + observe(subject, topic, data) { + switch (topic) { + case lazy.Region.REGION_TOPIC: + this.store.dispatch( + ac.BroadcastToContent({ + type: at.PREF_CHANGED, + data: { name: "region", value: lazy.Region.home }, + }) + ); + break; + } + } + + onAction(action) { + switch (action.type) { + case at.INIT: + this.init(); + break; + case at.UNINIT: + this.uninit(); + break; + case at.CLEAR_PREF: + Services.prefs.clearUserPref(this._prefs._branchStr + action.data.name); + break; + case at.SET_PREF: + this._prefs.set(action.data.name, action.data.value); + break; + case at.UPDATE_SECTION_PREFS: + this._setIndexedDBPref(action.data.id, action.data.value); + break; + } + } +} diff --git a/browser/components/newtab/lib/RecommendationProvider.sys.mjs b/browser/components/newtab/lib/RecommendationProvider.sys.mjs new file mode 100644 index 0000000000..03e976544f --- /dev/null +++ b/browser/components/newtab/lib/RecommendationProvider.sys.mjs @@ -0,0 +1,291 @@ +/* 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, { + PersistentCache: "resource://activity-stream/lib/PersistentCache.sys.mjs", + PersonalityProvider: + "resource://activity-stream/lib/PersonalityProvider/PersonalityProvider.sys.mjs", +}); + +import { + actionTypes as at, + actionCreators as ac, +} from "resource://activity-stream/common/Actions.sys.mjs"; + +const CACHE_KEY = "personalization"; +const PREF_PERSONALIZATION_MODEL_KEYS = + "discoverystream.personalization.modelKeys"; +const PREF_USER_TOPSTORIES = "feeds.section.topstories"; +const PREF_SYSTEM_TOPSTORIES = "feeds.system.topstories"; +const PREF_PERSONALIZATION = "discoverystream.personalization.enabled"; +const MIN_PERSONALIZATION_UPDATE_TIME = 12 * 60 * 60 * 1000; // 12 hours +const PREF_PERSONALIZATION_OVERRIDE = + "discoverystream.personalization.override"; + +// The main purpose of this class is to handle interactions with the recommendation provider. +// A recommendation provider scores a list of stories, currently this is a personality provider. +// So all calls to the provider, anything involved with the setup of the provider, +// accessing prefs for the provider, or updaing devtools with provider state, is contained in here. +export class RecommendationProvider { + constructor() { + // Persistent cache for remote endpoint data. + this.cache = new lazy.PersistentCache(CACHE_KEY, true); + } + + async setProvider(isStartup = false, scores) { + // A provider is already set. This can happen when new stories come in + // and we need to update their scores. + // We can use the existing one, a fresh one is created after startup. + // Using the existing one might be a bit out of date, + // but it's fine for now. We can rely on restarts for updates. + // See bug 1629931 for improvements to this. + if (!this.provider) { + this.provider = new lazy.PersonalityProvider(this.modelKeys); + this.provider.setScores(scores); + } + + if (this.provider && this.provider.init) { + await this.provider.init(); + this.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_INIT, + meta: { + isStartup, + }, + }) + ); + } + } + + async enable(isStartup) { + await this.loadPersonalizationScoresCache(isStartup); + Services.obs.addObserver(this, "idle-daily"); + this.loaded = true; + this.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_UPDATED, + meta: { + isStartup, + }, + }) + ); + } + + get showStories() { + // Combine user-set stories opt-out with Mozilla-set config + return ( + this.store.getState().Prefs.values[PREF_SYSTEM_TOPSTORIES] && + this.store.getState().Prefs.values[PREF_USER_TOPSTORIES] + ); + } + + get personalized() { + // If stories are not displayed, no point in trying to personalize them. + if (!this.showStories) { + return false; + } + const spocsPersonalized = + this.store.getState().Prefs.values?.pocketConfig?.spocsPersonalized; + const recsPersonalized = + this.store.getState().Prefs.values?.pocketConfig?.recsPersonalized; + const personalization = + this.store.getState().Prefs.values[PREF_PERSONALIZATION]; + + // There is a server sent flag to keep personalization on. + // If the server stops sending this, we turn personalization off, + // until the server starts returning the signal. + const overrideState = + this.store.getState().Prefs.values[PREF_PERSONALIZATION_OVERRIDE]; + + return ( + personalization && + !overrideState && + (spocsPersonalized || recsPersonalized) + ); + } + + get modelKeys() { + if (!this._modelKeys) { + this._modelKeys = + this.store.getState().Prefs.values[PREF_PERSONALIZATION_MODEL_KEYS]; + } + + return this._modelKeys; + } + + /* + * This creates a new recommendationProvider using fresh data, + * It's run on a last updated timer. This is the opposite of loadPersonalizationScoresCache. + * This is also much slower so we only trigger this in the background on idle-daily. + * It causes new profiles to pick up personalization slowly because the first time + * a new profile is run you don't have any old cache to use, so it needs to wait for the first + * idle-daily. Older profiles can rely on cache during the idle-daily gap. Idle-daily is + * usually run once every 24 hours. + */ + async updatePersonalizationScores() { + if ( + !this.personalized || + Date.now() - this.personalizationLastUpdated < + MIN_PERSONALIZATION_UPDATE_TIME + ) { + return; + } + + await this.setProvider(); + + const personalization = { scores: this.provider.getScores() }; + this.personalizationLastUpdated = Date.now(); + + this.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED, + data: { + lastUpdated: this.personalizationLastUpdated, + }, + }) + ); + personalization._timestamp = this.personalizationLastUpdated; + this.cache.set("personalization", personalization); + } + + /* + * This just re hydrates the provider from cache. + * We can call this on startup because it's generally fast. + * It reports to devtools the last time the data in the cache was updated. + */ + async loadPersonalizationScoresCache(isStartup = false) { + const cachedData = (await this.cache.get()) || {}; + const { personalization } = cachedData; + + if (this.personalized && personalization?.scores) { + await this.setProvider(isStartup, personalization.scores); + + this.personalizationLastUpdated = personalization._timestamp; + + this.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED, + data: { + lastUpdated: this.personalizationLastUpdated, + }, + meta: { + isStartup, + }, + }) + ); + } + } + + // This turns personalization on/off if the server sends the override command. + // The server sends a true signal to keep personalization on. So a malfunctioning + // server would more likely mistakenly turn off personalization, and not turn it on. + // This is safer, because the override is for cases where personalization is causing issues. + // So having it mistakenly go off is safe, but it mistakenly going on could be bad. + personalizationOverride(overrideCommand) { + // Are we currently in an override state. + // This is useful to know if we want to do a cleanup. + const overrideState = + this.store.getState().Prefs.values[PREF_PERSONALIZATION_OVERRIDE]; + + // Is this profile currently set to be personalized. + const personalization = + this.store.getState().Prefs.values[PREF_PERSONALIZATION]; + + // If we have an override command, profile is currently personalized, + // and is not currently being overridden, we can set the override pref. + if (overrideCommand && personalization && !overrideState) { + this.store.dispatch(ac.SetPref(PREF_PERSONALIZATION_OVERRIDE, true)); + } + + // This is if we need to revert an override and do cleanup. + // We do this if we are in an override state, + // but not currently receiving the override signal. + if (!overrideCommand && overrideState) { + this.store.dispatch({ + type: at.CLEAR_PREF, + data: { name: PREF_PERSONALIZATION_OVERRIDE }, + }); + } + } + + async calculateItemRelevanceScore(item) { + if (this.provider) { + const scoreResult = await this.provider.calculateItemRelevanceScore(item); + if (scoreResult === 0 || scoreResult) { + item.score = scoreResult; + } + } + } + + teardown() { + if (this.provider && this.provider.teardown) { + // This removes any in memory listeners if available. + this.provider.teardown(); + } + if (this.loaded) { + Services.obs.removeObserver(this, "idle-daily"); + } + this.loaded = false; + } + + async resetState() { + this._modelKeys = null; + this.personalizationLastUpdated = null; + this.provider = null; + await this.cache.set("personalization", {}); + this.store.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_RESET, + }) + ); + } + + async observe(subject, topic, data) { + switch (topic) { + case "idle-daily": + await this.updatePersonalizationScores(); + this.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_UPDATED, + }) + ); + break; + } + } + + async onAction(action) { + switch (action.type) { + case at.INIT: + await this.enable(true /* isStartup */); + break; + case at.DISCOVERY_STREAM_CONFIG_CHANGE: + this.teardown(); + await this.resetState(); + await this.enable(); + break; + case at.DISCOVERY_STREAM_DEV_IDLE_DAILY: + Services.obs.notifyObservers(null, "idle-daily"); + break; + case at.PREF_CHANGED: + switch (action.data.name) { + case PREF_PERSONALIZATION_MODEL_KEYS: + this.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_CONFIG_RESET, + }) + ); + break; + } + break; + case at.DISCOVERY_STREAM_PERSONALIZATION_TOGGLE: + let enabled = this.store.getState().Prefs.values[PREF_PERSONALIZATION]; + this.store.dispatch(ac.SetPref(PREF_PERSONALIZATION, !enabled)); + break; + case at.DISCOVERY_STREAM_PERSONALIZATION_OVERRIDE: + this.personalizationOverride(action.data.override); + break; + } + } +} diff --git a/browser/components/newtab/lib/Screenshots.sys.mjs b/browser/components/newtab/lib/Screenshots.sys.mjs new file mode 100644 index 0000000000..e5423bd52f --- /dev/null +++ b/browser/components/newtab/lib/Screenshots.sys.mjs @@ -0,0 +1,140 @@ +/* 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/. */ + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module. This +// is because the Karma test environment already stubs out +// XPCOMUtils, and overrides importESModule to be a no-op (which +// can't be done for a static import statement). + +// eslint-disable-next-line mozilla/use-static-import +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BackgroundPageThumbs: "resource://gre/modules/BackgroundPageThumbs.sys.mjs", + PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +const GREY_10 = "#F9F9FA"; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "gPrivilegedAboutProcessEnabled", + "browser.tabs.remote.separatePrivilegedContentProcess", + false +); + +export const Screenshots = { + /** + * Get a screenshot / thumbnail for a url. Either returns the disk cached + * image or initiates a background request for the url. + * + * @param url {string} The url to get a thumbnail + * @return {Promise} Resolves a custom object or null if failed + */ + async getScreenshotForURL(url) { + try { + await lazy.BackgroundPageThumbs.captureIfMissing(url, { + backgroundColor: GREY_10, + }); + + // The privileged about content process is able to use the moz-page-thumb + // protocol, so if it's enabled, send that down. + if (lazy.gPrivilegedAboutProcessEnabled) { + return lazy.PageThumbs.getThumbnailURL(url); + } + + // Otherwise, for normal content processes, we fallback to using + // Blob URIs for the screenshots. + const imgPath = lazy.PageThumbs.getThumbnailPath(url); + + const filePathResponse = await fetch(`file://${imgPath}`); + const fileContents = await filePathResponse.blob(); + + // Check if the file is empty, which indicates there isn't actually a + // thumbnail, so callers can show a failure state. + if (fileContents.size === 0) { + return null; + } + + return { path: imgPath, data: fileContents }; + } catch (err) { + console.error(`getScreenshot(${url}) failed:`, err); + } + + // We must have failed to get the screenshot, so persist the failure by + // storing an empty file. Future calls will then skip requesting and return + // failure, so do the same thing here. The empty file should not expire with + // the usual filtering process to avoid repeated background requests, which + // can cause unwanted high CPU, network and memory usage - Bug 1384094 + try { + await lazy.PageThumbs._store(url, url, null, true); + } catch (err) { + // Probably failed to create the empty file, but not much more we can do. + } + return null; + }, + + /** + * Checks if all the open windows are private browsing windows. If so, we do not + * want to collect screenshots. If there exists at least 1 non-private window, + * we are ok to collect screenshots. + */ + _shouldGetScreenshots() { + for (let win of Services.wm.getEnumerator("navigator:browser")) { + if (!lazy.PrivateBrowsingUtils.isWindowPrivate(win)) { + // As soon as we encounter 1 non-private window, screenshots are fair game. + return true; + } + } + return false; + }, + + /** + * Conditionally get a screenshot for a link if there's no existing pending + * screenshot. Updates the cached link's desired property with the result. + * + * @param link {object} Link object to update + * @param url {string} Url to get a screenshot of + * @param property {string} Name of property on object to set + @ @param onScreenshot {function} Callback for when the screenshot loads + */ + async maybeCacheScreenshot(link, url, property, onScreenshot) { + // If there are only private windows open, do not collect screenshots + if (!this._shouldGetScreenshots()) { + return; + } + // __sharedCache may not exist yet for links from default top sites that + // don't have a default tippy top icon. + if (!link.__sharedCache) { + link.__sharedCache = { + updateLink(prop, val) { + link[prop] = val; + }, + }; + } + const cache = link.__sharedCache; + // Nothing to do if we already have a pending screenshot or + // if a previous request failed and returned null. + if (cache.fetchingScreenshot || link[property] !== undefined) { + return; + } + + // Save the promise to the cache so other links get it immediately + cache.fetchingScreenshot = this.getScreenshotForURL(url); + + // Clean up now that we got the screenshot + const screenshot = await cache.fetchingScreenshot; + delete cache.fetchingScreenshot; + + // Update the cache for future links and call back for existing content + cache.updateLink(property, screenshot); + onScreenshot(screenshot); + }, +}; diff --git a/browser/components/newtab/lib/SearchShortcuts.sys.mjs b/browser/components/newtab/lib/SearchShortcuts.sys.mjs new file mode 100644 index 0000000000..1448f87ca4 --- /dev/null +++ b/browser/components/newtab/lib/SearchShortcuts.sys.mjs @@ -0,0 +1,73 @@ +/* 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/. */ + +// List of sites we match against Topsites in order to identify sites +// that should be converted to search Topsites +export const SEARCH_SHORTCUTS = [ + { keyword: "@amazon", shortURL: "amazon", url: "https://amazon.com" }, + { keyword: "@\u767E\u5EA6", shortURL: "baidu", url: "https://baidu.com" }, + { keyword: "@google", shortURL: "google", url: "https://google.com" }, + { + keyword: "@\u044F\u043D\u0434\u0435\u043A\u0441", + shortURL: "yandex", + url: "https://yandex.com", + }, +]; + +// These can be added via the editor but will not be added organically +export const CUSTOM_SEARCH_SHORTCUTS = [ + ...SEARCH_SHORTCUTS, + { keyword: "@bing", shortURL: "bing", url: "https://bing.com" }, + { + keyword: "@duckduckgo", + shortURL: "duckduckgo", + url: "https://duckduckgo.com", + }, + { keyword: "@ebay", shortURL: "ebay", url: "https://ebay.com" }, + { keyword: "@twitter", shortURL: "twitter", url: "https://twitter.com" }, + { + keyword: "@wikipedia", + shortURL: "wikipedia", + url: "https://wikipedia.org", + }, +]; + +// Note: you must add the activity stream branch to the beginning of this if using outside activity stream +export const SEARCH_SHORTCUTS_EXPERIMENT = + "improvesearch.topSiteSearchShortcuts"; + +export const SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF = + "improvesearch.topSiteSearchShortcuts.searchEngines"; + +export const SEARCH_SHORTCUTS_HAVE_PINNED_PREF = + "improvesearch.topSiteSearchShortcuts.havePinned"; + +export function getSearchProvider(candidateShortURL) { + return ( + SEARCH_SHORTCUTS.filter(match => candidateShortURL === match.shortURL)[0] || + null + ); +} + +// Get the search form URL for a given search keyword. This allows us to pick +// different tippytop icons for the different variants. Sush as yandex.com vs. yandex.ru. +// See more details in bug 1643523. +export async function getSearchFormURL(keyword) { + const engine = await Services.search.getEngineByAlias(keyword); + return engine?.wrappedJSObject._searchForm; +} + +// Check topsite against predefined list of valid search engines +// https://searchfox.org/mozilla-central/rev/ca869724246f4230b272ed1c8b9944596e80d920/toolkit/components/search/nsSearchService.js#939 +export async function checkHasSearchEngine(keyword) { + try { + return !!(await Services.search.getAppProvidedEngines()).find( + e => e.aliases.includes(keyword) && !e.hidden + ); + } catch { + // When the search service has not successfully initialized, + // there will be no search engines ready. + return false; + } +} diff --git a/browser/components/newtab/lib/SectionsManager.sys.mjs b/browser/components/newtab/lib/SectionsManager.sys.mjs new file mode 100644 index 0000000000..96bba0c9ea --- /dev/null +++ b/browser/components/newtab/lib/SectionsManager.sys.mjs @@ -0,0 +1,715 @@ +/* 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/. */ + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module. This +// is because the Karma test environment already stubs out +// EventEmitter, and overrides importESModule to be a no-op (which +// can't be done for a static import statement). + +// eslint-disable-next-line mozilla/use-static-import +const { EventEmitter } = ChromeUtils.importESModule( + "resource://gre/modules/EventEmitter.sys.mjs" +); +import { + actionCreators as ac, + actionTypes as at, +} from "resource://activity-stream/common/Actions.sys.mjs"; +import { getDefaultOptions } from "resource://activity-stream/lib/ActivityStreamStorage.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +/* + * Generators for built in sections, keyed by the pref name for their feed. + * Built in sections may depend on options stored as serialised JSON in the pref + * `${feed_pref_name}.options`. + */ + +const BUILT_IN_SECTIONS = ({ newtab, pocketNewtab }) => ({ + "feeds.section.topstories": options => ({ + id: "topstories", + pref: { + titleString: { + id: "home-prefs-recommended-by-header-generic", + }, + descString: { + id: "home-prefs-recommended-by-description-generic", + }, + nestedPrefs: [ + ...(Services.prefs.getBoolPref( + "browser.newtabpage.activity-stream.system.showSponsored", + true + ) + ? [ + { + name: "showSponsored", + titleString: + "home-prefs-recommended-by-option-sponsored-stories", + icon: "icon-info", + eventSource: "POCKET_SPOCS", + }, + ] + : []), + ...(pocketNewtab.recentSavesEnabled + ? [ + { + name: "showRecentSaves", + titleString: "home-prefs-recommended-by-option-recent-saves", + icon: "icon-info", + eventSource: "POCKET_RECENT_SAVES", + }, + ] + : []), + ], + learnMore: { + link: { + href: "https://getpocket.com/firefox/new_tab_learn_more", + id: "home-prefs-recommended-by-learn-more", + }, + }, + }, + shouldHidePref: options.hidden, + eventSource: "TOP_STORIES", + icon: options.provider_icon, + title: { + id: "newtab-section-header-stories", + }, + learnMore: { + link: { + href: "https://getpocket.com/firefox/new_tab_learn_more", + message: { id: "newtab-pocket-learn-more" }, + }, + }, + compactCards: false, + rowsPref: "section.topstories.rows", + maxRows: 4, + availableLinkMenuOptions: [ + "CheckBookmarkOrArchive", + "CheckSavedToPocket", + "Separator", + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + ], + emptyState: { + message: { + id: "newtab-empty-section-topstories-generic", + }, + icon: "check", + }, + shouldSendImpressionStats: true, + dedupeFrom: ["highlights"], + }), + "feeds.section.highlights": options => ({ + id: "highlights", + pref: { + titleString: { + id: "home-prefs-recent-activity-header", + }, + descString: { + id: "home-prefs-recent-activity-description", + }, + nestedPrefs: [ + { + name: "section.highlights.includeVisited", + titleString: "home-prefs-highlights-option-visited-pages", + }, + { + name: "section.highlights.includeBookmarks", + titleString: "home-prefs-highlights-options-bookmarks", + }, + { + name: "section.highlights.includeDownloads", + titleString: "home-prefs-highlights-option-most-recent-download", + }, + { + name: "section.highlights.includePocket", + titleString: "home-prefs-highlights-option-saved-to-pocket", + hidden: !Services.prefs.getBoolPref( + "extensions.pocket.enabled", + true + ), + }, + ], + }, + shouldHidePref: false, + eventSource: "HIGHLIGHTS", + icon: "chrome://global/skin/icons/highlights.svg", + title: { + id: "newtab-section-header-recent-activity", + }, + compactCards: true, + rowsPref: "section.highlights.rows", + maxRows: 4, + emptyState: { + message: { id: "newtab-empty-section-highlights" }, + icon: "chrome://global/skin/icons/highlights.svg", + }, + shouldSendImpressionStats: false, + }), +}); + +export const SectionsManager = { + ACTIONS_TO_PROXY: ["WEBEXT_CLICK", "WEBEXT_DISMISS"], + CONTEXT_MENU_PREFS: { CheckSavedToPocket: "extensions.pocket.enabled" }, + CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES: { + history: [ + "CheckBookmark", + "CheckSavedToPocket", + "Separator", + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + "DeleteUrl", + ], + bookmark: [ + "CheckBookmark", + "CheckSavedToPocket", + "Separator", + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + "DeleteUrl", + ], + pocket: [ + "ArchiveFromPocket", + "CheckSavedToPocket", + "Separator", + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + ], + download: [ + "OpenFile", + "ShowFile", + "Separator", + "GoToDownloadPage", + "CopyDownloadLink", + "Separator", + "RemoveDownload", + "BlockUrl", + ], + }, + initialized: false, + sections: new Map(), + async init(prefs = {}, storage) { + this._storage = storage; + const featureConfig = { + newtab: lazy.NimbusFeatures.newtab.getAllVariables() || {}, + pocketNewtab: lazy.NimbusFeatures.pocketNewtab.getAllVariables() || {}, + }; + + for (const feedPrefName of Object.keys(BUILT_IN_SECTIONS(featureConfig))) { + const optionsPrefName = `${feedPrefName}.options`; + await this.addBuiltInSection(feedPrefName, prefs[optionsPrefName]); + + this._dedupeConfiguration = []; + this.sections.forEach(section => { + if (section.dedupeFrom) { + this._dedupeConfiguration.push({ + id: section.id, + dedupeFrom: section.dedupeFrom, + }); + } + }); + } + + Object.keys(this.CONTEXT_MENU_PREFS).forEach(k => + Services.prefs.addObserver(this.CONTEXT_MENU_PREFS[k], this) + ); + + this.initialized = true; + this.emit(this.INIT); + }, + observe(subject, topic, data) { + switch (topic) { + case "nsPref:changed": + for (const pref of Object.keys(this.CONTEXT_MENU_PREFS)) { + if (data === this.CONTEXT_MENU_PREFS[pref]) { + this.updateSections(); + } + } + break; + } + }, + updateSectionPrefs(id, collapsed) { + const section = this.sections.get(id); + if (!section) { + return; + } + + const updatedSection = Object.assign({}, section, { + pref: Object.assign({}, section.pref, collapsed), + }); + this.updateSection(id, updatedSection, true); + }, + async addBuiltInSection(feedPrefName, optionsPrefValue = "{}") { + let options; + let storedPrefs; + const featureConfig = { + newtab: lazy.NimbusFeatures.newtab.getAllVariables() || {}, + pocketNewtab: lazy.NimbusFeatures.pocketNewtab.getAllVariables() || {}, + }; + try { + options = JSON.parse(optionsPrefValue); + } catch (e) { + options = {}; + console.error(`Problem parsing options pref for ${feedPrefName}`); + } + try { + storedPrefs = (await this._storage.get(feedPrefName)) || {}; + } catch (e) { + storedPrefs = {}; + console.error(`Problem getting stored prefs for ${feedPrefName}`); + } + const defaultSection = + BUILT_IN_SECTIONS(featureConfig)[feedPrefName](options); + const section = Object.assign({}, defaultSection, { + pref: Object.assign( + {}, + defaultSection.pref, + getDefaultOptions(storedPrefs) + ), + }); + section.pref.feed = feedPrefName; + this.addSection(section.id, Object.assign(section, { options })); + }, + addSection(id, options) { + this.updateLinkMenuOptions(options, id); + this.sections.set(id, options); + this.emit(this.ADD_SECTION, id, options); + }, + removeSection(id) { + this.emit(this.REMOVE_SECTION, id); + this.sections.delete(id); + }, + enableSection(id, isStartup = false) { + this.updateSection(id, { enabled: true }, true, isStartup); + this.emit(this.ENABLE_SECTION, id); + }, + disableSection(id) { + this.updateSection( + id, + { enabled: false, rows: [], initialized: false }, + true + ); + this.emit(this.DISABLE_SECTION, id); + }, + updateSections() { + this.sections.forEach((section, id) => + this.updateSection(id, section, true) + ); + }, + updateSection(id, options, shouldBroadcast, isStartup = false) { + this.updateLinkMenuOptions(options, id); + if (this.sections.has(id)) { + const optionsWithDedupe = Object.assign({}, options, { + dedupeConfigurations: this._dedupeConfiguration, + }); + this.sections.set(id, Object.assign(this.sections.get(id), options)); + this.emit( + this.UPDATE_SECTION, + id, + optionsWithDedupe, + shouldBroadcast, + isStartup + ); + } + }, + + /** + * Save metadata to places db and add a visit for that URL. + */ + updateBookmarkMetadata({ url }) { + this.sections.forEach((section, id) => { + if (id === "highlights") { + // Skip Highlights cards, we already have that metadata. + return; + } + if (section.rows) { + section.rows.forEach(card => { + if ( + card.url === url && + card.description && + card.title && + card.image + ) { + lazy.PlacesUtils.history.update({ + url: card.url, + title: card.title, + description: card.description, + previewImageURL: card.image, + }); + // Highlights query skips bookmarks with no visits. + lazy.PlacesUtils.history.insert({ + url, + title: card.title, + visits: [{}], + }); + } + }); + } + }); + }, + + /** + * Sets the section's context menu options. These are all available context menu + * options minus the ones that are tied to a pref (see CONTEXT_MENU_PREFS) set + * to false. + * + * @param options section options + * @param id section ID + */ + updateLinkMenuOptions(options, id) { + if (options.availableLinkMenuOptions) { + options.contextMenuOptions = options.availableLinkMenuOptions.filter( + o => + !this.CONTEXT_MENU_PREFS[o] || + Services.prefs.getBoolPref(this.CONTEXT_MENU_PREFS[o]) + ); + } + + // Once we have rows, we can give each card it's own context menu based on it's type. + // We only want to do this for highlights because those have different data types. + // All other sections (built by the web extension API) will have the same context menu per section + if (options.rows && id === "highlights") { + this._addCardTypeLinkMenuOptions(options.rows); + } + }, + + /** + * Sets each card in highlights' context menu options based on the card's type. + * (See types.js for a list of types) + * + * @param rows section rows containing a type for each card + */ + _addCardTypeLinkMenuOptions(rows) { + for (let card of rows) { + if (!this.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES[card.type]) { + console.error( + `No context menu for highlight type ${card.type} is configured` + ); + } else { + card.contextMenuOptions = + this.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES[card.type]; + + // Remove any options that shouldn't be there based on CONTEXT_MENU_PREFS. + // For example: If the Pocket extension is disabled, we should remove the CheckSavedToPocket option + // for each card that has it + card.contextMenuOptions = card.contextMenuOptions.filter( + o => + !this.CONTEXT_MENU_PREFS[o] || + Services.prefs.getBoolPref(this.CONTEXT_MENU_PREFS[o]) + ); + } + } + }, + + /** + * Update a specific section card by its url. This allows an action to be + * broadcast to all existing pages to update a specific card without having to + * also force-update the rest of the section's cards and state on those pages. + * + * @param id The id of the section with the card to be updated + * @param url The url of the card to update + * @param options The options to update for the card + * @param shouldBroadcast Whether or not to broadcast the update + * @param isStartup If this update is during startup. + */ + updateSectionCard(id, url, options, shouldBroadcast, isStartup = false) { + if (this.sections.has(id)) { + const card = this.sections.get(id).rows.find(elem => elem.url === url); + if (card) { + Object.assign(card, options); + } + this.emit( + this.UPDATE_SECTION_CARD, + id, + url, + options, + shouldBroadcast, + isStartup + ); + } + }, + removeSectionCard(sectionId, url) { + if (!this.sections.has(sectionId)) { + return; + } + const rows = this.sections + .get(sectionId) + .rows.filter(row => row.url !== url); + this.updateSection(sectionId, { rows }, true); + }, + onceInitialized(callback) { + if (this.initialized) { + callback(); + } else { + this.once(this.INIT, callback); + } + }, + uninit() { + Object.keys(this.CONTEXT_MENU_PREFS).forEach(k => + Services.prefs.removeObserver(this.CONTEXT_MENU_PREFS[k], this) + ); + SectionsManager.initialized = false; + }, +}; + +for (const action of [ + "ACTION_DISPATCHED", + "ADD_SECTION", + "REMOVE_SECTION", + "ENABLE_SECTION", + "DISABLE_SECTION", + "UPDATE_SECTION", + "UPDATE_SECTION_CARD", + "INIT", + "UNINIT", +]) { + SectionsManager[action] = action; +} + +EventEmitter.decorate(SectionsManager); + +export class SectionsFeed { + constructor() { + this.init = this.init.bind(this); + this.onAddSection = this.onAddSection.bind(this); + this.onRemoveSection = this.onRemoveSection.bind(this); + this.onUpdateSection = this.onUpdateSection.bind(this); + this.onUpdateSectionCard = this.onUpdateSectionCard.bind(this); + } + + init() { + SectionsManager.on(SectionsManager.ADD_SECTION, this.onAddSection); + SectionsManager.on(SectionsManager.REMOVE_SECTION, this.onRemoveSection); + SectionsManager.on(SectionsManager.UPDATE_SECTION, this.onUpdateSection); + SectionsManager.on( + SectionsManager.UPDATE_SECTION_CARD, + this.onUpdateSectionCard + ); + // Catch any sections that have already been added + SectionsManager.sections.forEach((section, id) => + this.onAddSection( + SectionsManager.ADD_SECTION, + id, + section, + true /* isStartup */ + ) + ); + } + + uninit() { + SectionsManager.uninit(); + SectionsManager.emit(SectionsManager.UNINIT); + SectionsManager.off(SectionsManager.ADD_SECTION, this.onAddSection); + SectionsManager.off(SectionsManager.REMOVE_SECTION, this.onRemoveSection); + SectionsManager.off(SectionsManager.UPDATE_SECTION, this.onUpdateSection); + SectionsManager.off( + SectionsManager.UPDATE_SECTION_CARD, + this.onUpdateSectionCard + ); + } + + onAddSection(event, id, options, isStartup = false) { + if (options) { + this.store.dispatch( + ac.BroadcastToContent({ + type: at.SECTION_REGISTER, + data: Object.assign({ id }, options), + meta: { + isStartup, + }, + }) + ); + + // Make sure the section is in sectionOrder pref. Otherwise, prepend it. + const orderedSections = this.orderedSectionIds; + if (!orderedSections.includes(id)) { + orderedSections.unshift(id); + this.store.dispatch( + ac.SetPref("sectionOrder", orderedSections.join(",")) + ); + } + } + } + + onRemoveSection(event, id) { + this.store.dispatch( + ac.BroadcastToContent({ type: at.SECTION_DEREGISTER, data: id }) + ); + } + + onUpdateSection( + event, + id, + options, + shouldBroadcast = false, + isStartup = false + ) { + if (options) { + const action = { + type: at.SECTION_UPDATE, + data: Object.assign(options, { id }), + meta: { + isStartup, + }, + }; + this.store.dispatch( + shouldBroadcast + ? ac.BroadcastToContent(action) + : ac.AlsoToPreloaded(action) + ); + } + } + + onUpdateSectionCard( + event, + id, + url, + options, + shouldBroadcast = false, + isStartup = false + ) { + if (options) { + const action = { + type: at.SECTION_UPDATE_CARD, + data: { id, url, options }, + meta: { + isStartup, + }, + }; + this.store.dispatch( + shouldBroadcast + ? ac.BroadcastToContent(action) + : ac.AlsoToPreloaded(action) + ); + } + } + + get orderedSectionIds() { + return this.store.getState().Prefs.values.sectionOrder.split(","); + } + + get enabledSectionIds() { + let sections = this.store + .getState() + .Sections.filter(section => section.enabled) + .map(s => s.id); + // Top Sites is a special case. Append if the feed is enabled. + if (this.store.getState().Prefs.values["feeds.topsites"]) { + sections.push("topsites"); + } + return sections; + } + + moveSection(id, direction) { + const orderedSections = this.orderedSectionIds; + const enabledSections = this.enabledSectionIds; + let index = orderedSections.indexOf(id); + orderedSections.splice(index, 1); + if (direction > 0) { + // "Move Down" + while (index < orderedSections.length) { + // If the section at the index is enabled/visible, insert moved section after. + // Otherwise, move on to the next spot and check it. + if (enabledSections.includes(orderedSections[index++])) { + break; + } + } + } else { + // "Move Up" + while (index > 0) { + // If the section at the previous index is enabled/visible, insert moved section there. + // Otherwise, move on to the previous spot and check it. + index--; + if (enabledSections.includes(orderedSections[index])) { + break; + } + } + } + + orderedSections.splice(index, 0, id); + this.store.dispatch(ac.SetPref("sectionOrder", orderedSections.join(","))); + } + + async onAction(action) { + switch (action.type) { + case at.INIT: + SectionsManager.onceInitialized(this.init); + break; + // Wait for pref values, as some sections have options stored in prefs + case at.PREFS_INITIAL_VALUES: + SectionsManager.init( + action.data, + this.store.dbStorage.getDbTable("sectionPrefs") + ); + break; + case at.PREF_CHANGED: { + if (action.data) { + const matched = action.data.name.match( + /^(feeds.section.(\S+)).options$/i + ); + if (matched) { + await SectionsManager.addBuiltInSection( + matched[1], + action.data.value + ); + this.store.dispatch({ + type: at.SECTION_OPTIONS_CHANGED, + data: matched[2], + }); + } + } + break; + } + case at.UPDATE_SECTION_PREFS: + SectionsManager.updateSectionPrefs(action.data.id, action.data.value); + break; + case at.PLACES_BOOKMARK_ADDED: + SectionsManager.updateBookmarkMetadata(action.data); + break; + case at.WEBEXT_DISMISS: + if (action.data) { + SectionsManager.removeSectionCard( + action.data.source, + action.data.url + ); + } + break; + case at.SECTION_DISABLE: + SectionsManager.disableSection(action.data); + break; + case at.SECTION_ENABLE: + SectionsManager.enableSection(action.data); + break; + case at.SECTION_MOVE: + this.moveSection(action.data.id, action.data.direction); + break; + case at.UNINIT: + this.uninit(); + break; + } + if ( + SectionsManager.ACTIONS_TO_PROXY.includes(action.type) && + SectionsManager.sections.size > 0 + ) { + SectionsManager.emit( + SectionsManager.ACTION_DISPATCHED, + action.type, + action.data + ); + } + } +} diff --git a/browser/components/newtab/lib/ShortURL.sys.mjs b/browser/components/newtab/lib/ShortURL.sys.mjs new file mode 100644 index 0000000000..6ee34c20dd --- /dev/null +++ b/browser/components/newtab/lib/ShortURL.sys.mjs @@ -0,0 +1,88 @@ +/* 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/. */ + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module. This +// is because the Karma test environment already stubs out +// XPCOMUtils, and overrides importESModule to be a no-op (which +// can't be done for a static import statement). + +// eslint-disable-next-line mozilla/use-static-import +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "IDNService", + "@mozilla.org/network/idn-service;1", + "nsIIDNService" +); + +/** + * Properly convert internationalized domain names. + * @param {string} host Domain hostname. + * @returns {string} Hostname suitable to be displayed. + */ +function handleIDNHost(hostname) { + try { + return lazy.IDNService.convertToDisplayIDN(hostname, {}); + } catch (e) { + // If something goes wrong (e.g. host is an IP address) just fail back + // to the full domain. + return hostname; + } +} + +/** + * Get the effective top level domain of a host. + * @param {string} host The host to be analyzed. + * @return {str} The suffix or empty string if there's no suffix. + */ +export function getETLD(host) { + try { + return Services.eTLD.getPublicSuffixFromHost(host); + } catch (err) { + return ""; + } +} + +/** + * shortURL - Creates a short version of a link's url, used for display purposes + * e.g. {url: http://www.foosite.com} => "foosite" + * + * @param {obj} link A link object + * {str} link.url (required)- The url of the link + * @return {str} A short url + */ +export function shortURL({ url }) { + if (!url) { + return ""; + } + + // Make sure we have a valid / parseable url + let parsed; + try { + parsed = new URL(url); + } catch (ex) { + // Not entirely sure what we have, but just give it back + return url; + } + + // Clean up the url (lowercase hostname via URL and remove www.) + const hostname = parsed.hostname.replace(/^www\./i, ""); + + // Remove the eTLD (e.g., com, net) and the preceding period from the hostname + const eTLD = getETLD(hostname); + const eTLDExtra = eTLD.length ? -(eTLD.length + 1) : Infinity; + + // Ideally get the short eTLD-less host but fall back to longer url parts + return ( + handleIDNHost(hostname.slice(0, eTLDExtra) || hostname) || + parsed.pathname || + parsed.href + ); +} diff --git a/browser/components/newtab/lib/SiteClassifier.sys.mjs b/browser/components/newtab/lib/SiteClassifier.sys.mjs new file mode 100644 index 0000000000..64c7309bf5 --- /dev/null +++ b/browser/components/newtab/lib/SiteClassifier.sys.mjs @@ -0,0 +1,103 @@ +/* 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/. */ + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module. This +// is because the Karma test environment already stubs out +// RemoteSettings, and overrides importESModule to be a no-op (which +// can't be done for a static import statement). + +// eslint-disable-next-line mozilla/use-static-import +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); + +// Returns whether the passed in params match the criteria. +// To match, they must contain all the params specified in criteria and the values +// must match if a value is provided in criteria. +function _hasParams(criteria, params) { + for (let param of criteria) { + const val = params.get(param.key); + if ( + val === null || + (param.value && param.value !== val) || + (param.prefix && !val.startsWith(param.prefix)) + ) { + return false; + } + } + return true; +} + +/** + * classifySite + * Classifies a given URL into a category based on classification data from RemoteSettings. + * The data from remote settings can match a category by one of the following: + * - match the exact URL + * - match the hostname or second level domain (sld) + * - match query parameter(s), and optionally their values or prefixes + * - match both (hostname or sld) and query parameter(s) + * + * The data looks like: + * [{ + * "type": "hostname-and-params-match", + * "criteria": [ + * { + * "url": "https://matchurl.com", + * "hostname": "matchhostname.com", + * "sld": "secondleveldomain", + * "params": [ + * { + * "key": "matchparam", + * "value": "matchvalue", + * "prefix": "matchpPrefix", + * }, + * ], + * }, + * ], + * "weight": 300, + * },...] + */ +export async function classifySite(url, RS = RemoteSettings) { + let category = "other"; + let parsedURL; + + // Try to parse the url. + for (let _url of [url, `https://${url}`]) { + try { + parsedURL = new URL(_url); + break; + } catch (e) {} + } + + if (parsedURL) { + // If we parsed successfully, find a match. + const hostname = parsedURL.hostname.replace(/^www\./i, ""); + const params = parsedURL.searchParams; + // NOTE: there will be an initial/default local copy of the data in m-c. + // Therefore, this should never return an empty list []. + const siteTypes = await RS("sites-classification").get(); + const sortedSiteTypes = siteTypes.sort( + (x, y) => (y.weight || 0) - (x.weight || 0) + ); + for (let type of sortedSiteTypes) { + for (let criteria of type.criteria) { + if (criteria.url && criteria.url !== url) { + continue; + } + if (criteria.hostname && criteria.hostname !== hostname) { + continue; + } + if (criteria.sld && criteria.sld !== hostname.split(".")[0]) { + continue; + } + if (criteria.params && !_hasParams(criteria.params, params)) { + continue; + } + return type.type; + } + } + } + return category; +} diff --git a/browser/components/newtab/lib/Store.sys.mjs b/browser/components/newtab/lib/Store.sys.mjs new file mode 100644 index 0000000000..3a4fdfa98d --- /dev/null +++ b/browser/components/newtab/lib/Store.sys.mjs @@ -0,0 +1,188 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { ActivityStreamMessageChannel } from "resource://activity-stream/lib/ActivityStreamMessageChannel.sys.mjs"; +import { ActivityStreamStorage } from "resource://activity-stream/lib/ActivityStreamStorage.sys.mjs"; +import { Prefs } from "resource://activity-stream/lib/ActivityStreamPrefs.sys.mjs"; +import { reducers } from "resource://activity-stream/common/Reducers.sys.mjs"; +import { redux } from "resource://activity-stream/vendor/Redux.sys.mjs"; + +/** + * Store - This has a similar structure to a redux store, but includes some extra + * functionality to allow for routing of actions between the Main processes + * and child processes via a ActivityStreamMessageChannel. + * It also accepts an array of "Feeds" on inititalization, which + * can listen for any action that is dispatched through the store. + */ +export class Store { + /** + * constructor - The redux store and message manager are created here, + * but no listeners are added until "init" is called. + */ + constructor() { + this._middleware = this._middleware.bind(this); + // Bind each redux method so we can call it directly from the Store. E.g., + // store.dispatch() will call store._store.dispatch(); + for (const method of ["dispatch", "getState", "subscribe"]) { + this[method] = (...args) => this._store[method](...args); + } + this.feeds = new Map(); + this._prefs = new Prefs(); + this._messageChannel = new ActivityStreamMessageChannel({ + dispatch: this.dispatch, + }); + this._store = redux.createStore( + redux.combineReducers(reducers), + redux.applyMiddleware(this._middleware, this._messageChannel.middleware) + ); + this.storage = null; + } + + /** + * _middleware - This is redux middleware consumed by redux.createStore. + * it calls each feed's .onAction method, if one + * is defined. + */ + _middleware() { + return next => action => { + next(action); + for (const store of this.feeds.values()) { + if (store.onAction) { + store.onAction(action); + } + } + }; + } + + /** + * initFeed - Initializes a feed by calling its constructor function + * + * @param {string} feedName The name of a feed, as defined in the object + * passed to Store.init + * @param {Action} initAction An optional action to initialize the feed + */ + initFeed(feedName, initAction) { + const feed = this._feedFactories.get(feedName)(); + feed.store = this; + this.feeds.set(feedName, feed); + if (initAction && feed.onAction) { + feed.onAction(initAction); + } + } + + /** + * uninitFeed - Removes a feed and calls its uninit function if defined + * + * @param {string} feedName The name of a feed, as defined in the object + * passed to Store.init + * @param {Action} uninitAction An optional action to uninitialize the feed + */ + uninitFeed(feedName, uninitAction) { + const feed = this.feeds.get(feedName); + if (!feed) { + return; + } + if (uninitAction && feed.onAction) { + feed.onAction(uninitAction); + } + this.feeds.delete(feedName); + } + + /** + * onPrefChanged - Listener for handling feed changes. + */ + onPrefChanged(name, value) { + if (this._feedFactories.has(name)) { + if (value) { + this.initFeed(name, this._initAction); + } else { + this.uninitFeed(name, this._uninitAction); + } + } + } + + /** + * init - Initializes the ActivityStreamMessageChannel channel, and adds feeds. + * + * Note that it intentionally initializes the TelemetryFeed first so that the + * addon is able to report the init errors from other feeds. + * + * @param {Map} feedFactories A Map of feeds with the name of the pref for + * the feed as the key and a function that + * constructs an instance of the feed. + * @param {Action} initAction An optional action that will be dispatched + * to feeds when they're created. + * @param {Action} uninitAction An optional action for when feeds uninit. + */ + async init(feedFactories, initAction, uninitAction) { + this._feedFactories = feedFactories; + this._initAction = initAction; + this._uninitAction = uninitAction; + + const telemetryKey = "feeds.telemetry"; + if (feedFactories.has(telemetryKey) && this._prefs.get(telemetryKey)) { + this.initFeed(telemetryKey); + } + + await this._initIndexedDB(telemetryKey); + + for (const pref of feedFactories.keys()) { + if (pref !== telemetryKey && this._prefs.get(pref)) { + this.initFeed(pref); + } + } + + this._prefs.observeBranch(this); + + // Dispatch an initial action after all enabled feeds are ready + if (initAction) { + this.dispatch(initAction); + } + + // Dispatch NEW_TAB_INIT/NEW_TAB_LOAD events after INIT event. + this._messageChannel.simulateMessagesForExistingTabs(); + } + + async _initIndexedDB(telemetryKey) { + // "snippets" is the name of one storage space, but these days it is used + // not for snippet-related data (snippets were removed in bug 1715158), + // but storage for impression or session data for all ASRouter messages. + // + // We keep the name "snippets" to avoid having to do an IndexedDB database + // migration. + this.dbStorage = new ActivityStreamStorage({ + storeNames: ["sectionPrefs", "snippets"], + }); + // Accessing the db causes the object stores to be created / migrated. + // This needs to happen before other instances try to access the db, which + // would update only a subset of the stores to the latest version. + try { + await this.dbStorage.db; // eslint-disable-line no-unused-expressions + } catch (e) { + this.dbStorage.telemetry = null; + } + } + + /** + * uninit - Uninitalizes each feed, clears them, and destroys the message + * manager channel. + * + * @return {type} description + */ + uninit() { + if (this._uninitAction) { + this.dispatch(this._uninitAction); + } + this._prefs.ignoreBranch(this); + this.feeds.clear(); + this._feedFactories = null; + } + + /** + * getMessageChannel - Used by the AboutNewTabParent actor to get the message channel. + */ + getMessageChannel() { + return this._messageChannel; + } +} diff --git a/browser/components/newtab/lib/SystemTickFeed.sys.mjs b/browser/components/newtab/lib/SystemTickFeed.sys.mjs new file mode 100644 index 0000000000..d87860fab2 --- /dev/null +++ b/browser/components/newtab/lib/SystemTickFeed.sys.mjs @@ -0,0 +1,70 @@ +/* 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 { actionTypes as at } from "resource://activity-stream/common/Actions.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + clearInterval: "resource://gre/modules/Timer.sys.mjs", + setInterval: "resource://gre/modules/Timer.sys.mjs", +}); + +// Frequency at which SYSTEM_TICK events are fired +export const SYSTEM_TICK_INTERVAL = 5 * 60 * 1000; + +export class SystemTickFeed { + init() { + this._idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService( + Ci.nsIUserIdleService + ); + this._hasObserver = false; + this.setTimer(); + } + + setTimer() { + this.intervalId = lazy.setInterval(() => { + if (this._idleService.idleTime > SYSTEM_TICK_INTERVAL) { + this.cancelTimer(); + Services.obs.addObserver(this, "user-interaction-active"); + this._hasObserver = true; + return; + } + this.dispatchTick(); + }, SYSTEM_TICK_INTERVAL); + } + + cancelTimer() { + lazy.clearInterval(this.intervalId); + this.intervalId = null; + } + + observe() { + this.dispatchTick(); + Services.obs.removeObserver(this, "user-interaction-active"); + this._hasObserver = false; + this.setTimer(); + } + + dispatchTick() { + ChromeUtils.idleDispatch(() => + this.store.dispatch({ type: at.SYSTEM_TICK }) + ); + } + + onAction(action) { + switch (action.type) { + case at.INIT: + this.init(); + break; + case at.UNINIT: + this.cancelTimer(); + if (this._hasObserver) { + Services.obs.removeObserver(this, "user-interaction-active"); + this._hasObserver = false; + } + break; + } + } +} diff --git a/browser/components/newtab/lib/TelemetryFeed.sys.mjs b/browser/components/newtab/lib/TelemetryFeed.sys.mjs new file mode 100644 index 0000000000..99bed168a8 --- /dev/null +++ b/browser/components/newtab/lib/TelemetryFeed.sys.mjs @@ -0,0 +1,1122 @@ +/* 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/. */ + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on these module. This +// is because the Karma test environment already stubs out +// XPCOMUtils, and overrides importESModule to be a no-op (which +// can't be done for a static import statement). MESSAGE_TYPES_HASH / msg +// isn't something that the tests for this module seem to rely on in the +// Karma environment, but if that ever becomes the case, we should import +// those into unit-entry like we do for the ASRouter tests. + +// eslint-disable-next-line mozilla/use-static-import +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +// eslint-disable-next-line mozilla/use-static-import +const { MESSAGE_TYPE_HASH: msg } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ActorConstants.sys.mjs" +); + +import { + actionTypes as at, + actionUtils as au, +} from "resource://activity-stream/common/Actions.sys.mjs"; +import { Prefs } from "resource://activity-stream/lib/ActivityStreamPrefs.sys.mjs"; +import { classifySite } from "resource://activity-stream/lib/SiteClassifier.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", + AboutWelcomeTelemetry: + "resource:///modules/aboutwelcome/AboutWelcomeTelemetry.sys.mjs", + ClientID: "resource://gre/modules/ClientID.sys.mjs", + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + ExtensionSettingsStore: + "resource://gre/modules/ExtensionSettingsStore.sys.mjs", + HomePage: "resource:///modules/HomePage.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetrySession: "resource://gre/modules/TelemetrySession.sys.mjs", + UTEventReporting: "resource://activity-stream/lib/UTEventReporting.sys.mjs", + UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs", + pktApi: "chrome://pocket/content/pktApi.sys.mjs", +}); +ChromeUtils.defineLazyGetter( + lazy, + "Telemetry", + () => new lazy.AboutWelcomeTelemetry() +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "handoffToAwesomebarPrefValue", + "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar", + false, + (preference, previousValue, new_value) => + Glean.newtabHandoffPreference.enabled.set(new_value) +); + +// This is a mapping table between the user preferences and its encoding code +export const USER_PREFS_ENCODING = { + showSearch: 1 << 0, + "feeds.topsites": 1 << 1, + "feeds.section.topstories": 1 << 2, + "feeds.section.highlights": 1 << 3, + showSponsored: 1 << 5, + "asrouter.userprefs.cfr.addons": 1 << 6, + "asrouter.userprefs.cfr.features": 1 << 7, + showSponsoredTopSites: 1 << 8, +}; + +export const PREF_IMPRESSION_ID = "impressionId"; +export const TELEMETRY_PREF = "telemetry"; +export const EVENTS_TELEMETRY_PREF = "telemetry.ut.events"; + +// Used as the missing value for timestamps in the session ping +const TIMESTAMP_MISSING_VALUE = -1; + +// Page filter for onboarding telemetry, any value other than these will +// be set as "other" +const ONBOARDING_ALLOWED_PAGE_VALUES = [ + "about:welcome", + "about:home", + "about:newtab", +]; + +ChromeUtils.defineLazyGetter( + lazy, + "browserSessionId", + () => lazy.TelemetrySession.getMetadata("").sessionId +); + +// The scalar category for TopSites of Contextual Services +const SCALAR_CATEGORY_TOPSITES = "contextual.services.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 ACTIVITY_STREAM_PREF_BRANCH = "browser.newtabpage.activity-stream."; +const NEWTAB_PING_PREFS = { + showSearch: Glean.newtabSearch.enabled, + "feeds.topsites": Glean.topsites.enabled, + showSponsoredTopSites: Glean.topsites.sponsoredEnabled, + "feeds.section.topstories": Glean.pocket.enabled, + showSponsored: Glean.pocket.sponsoredStoriesEnabled, + topSitesRows: Glean.topsites.rows, +}; +const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors"; + +export class TelemetryFeed { + constructor() { + this.sessions = new Map(); + this._prefs = new Prefs(); + this._impressionId = this.getOrCreateImpressionId(); + this._aboutHomeSeen = false; + this._classifySite = classifySite; + this._browserOpenNewtabStart = null; + } + + get telemetryEnabled() { + return this._prefs.get(TELEMETRY_PREF); + } + + get eventTelemetryEnabled() { + return this._prefs.get(EVENTS_TELEMETRY_PREF); + } + + get telemetryClientId() { + Object.defineProperty(this, "telemetryClientId", { + value: lazy.ClientID.getClientID(), + }); + return this.telemetryClientId; + } + + get processStartTs() { + let startupInfo = Services.startup.getStartupInfo(); + let processStartTs = startupInfo.process.getTime(); + + Object.defineProperty(this, "processStartTs", { + value: processStartTs, + }); + return this.processStartTs; + } + + init() { + this._beginObservingNewtabPingPrefs(); + Services.obs.addObserver( + this.browserOpenNewtabStart, + "browser-open-newtab-start" + ); + // Set two scalars for the "deletion-request" ping (See bug 1602064 and 1729474) + Services.telemetry.scalarSet( + "deletion.request.impression_id", + this._impressionId + ); + Services.telemetry.scalarSet("deletion.request.context_id", lazy.contextId); + Glean.newtab.locale.set(Services.locale.appLocaleAsBCP47); + Glean.newtabHandoffPreference.enabled.set( + lazy.handoffToAwesomebarPrefValue + ); + } + + getOrCreateImpressionId() { + let impressionId = this._prefs.get(PREF_IMPRESSION_ID); + if (!impressionId) { + impressionId = String(Services.uuid.generateUUID()); + this._prefs.set(PREF_IMPRESSION_ID, impressionId); + } + return impressionId; + } + + browserOpenNewtabStart() { + let now = Cu.now(); + this._browserOpenNewtabStart = Math.round(this.processStartTs + now); + + ChromeUtils.addProfilerMarker( + "UserTiming", + now, + "browser-open-newtab-start" + ); + } + + setLoadTriggerInfo(port) { + // XXX note that there is a race condition here; we're assuming that no + // other tab will be interleaving calls to browserOpenNewtabStart and + // when at.NEW_TAB_INIT gets triggered by RemotePages and calls this + // method. For manually created windows, it's hard to imagine us hitting + // this race condition. + // + // However, for session restore, where multiple windows with multiple tabs + // might be restored much closer together in time, it's somewhat less hard, + // though it should still be pretty rare. + // + // The fix to this would be making all of the load-trigger notifications + // return some data with their notifications, and somehow propagate that + // data through closures into the tab itself so that we could match them + // + // As of this writing (very early days of system add-on perf telemetry), + // the hypothesis is that hitting this race should be so rare that makes + // more sense to live with the slight data inaccuracy that it would + // introduce, rather than doing the correct but complicated thing. It may + // well be worth reexamining this hypothesis after we have more experience + // with the data. + + let data_to_save; + try { + if (!this._browserOpenNewtabStart) { + throw new Error("No browser-open-newtab-start recorded."); + } + data_to_save = { + load_trigger_ts: this._browserOpenNewtabStart, + load_trigger_type: "menu_plus_or_keyboard", + }; + } catch (e) { + // if no mark was returned, we have nothing to save + return; + } + this.saveSessionPerfData(port, data_to_save); + } + + /** + * Lazily initialize UTEventReporting to send pings + */ + get utEvents() { + Object.defineProperty(this, "utEvents", { + value: new lazy.UTEventReporting(), + }); + return this.utEvents; + } + + /** + * Get encoded user preferences, multiple prefs will be combined via bitwise OR operator + */ + get userPreferences() { + let prefs = 0; + + for (const pref of Object.keys(USER_PREFS_ENCODING)) { + if (this._prefs.get(pref)) { + prefs |= USER_PREFS_ENCODING[pref]; + } + } + return prefs; + } + + /** + * Check if it is in the CFR experiment cohort by querying against the + * experiment manager of Messaging System + * + * @return {bool} + */ + get isInCFRCohort() { + const experimentData = lazy.ExperimentAPI.getExperimentMetaData({ + featureId: "cfr", + }); + if (experimentData && experimentData.slug) { + return true; + } + + return false; + } + + /** + * addSession - Start tracking a new session + * + * @param {string} id the portID of the open session + * @param {string} the URL being loaded for this session (optional) + * @return {obj} Session object + */ + addSession(id, url) { + // XXX refactor to use setLoadTriggerInfo or saveSessionPerfData + + // "unexpected" will be overwritten when appropriate + let load_trigger_type = "unexpected"; + let load_trigger_ts; + + if (!this._aboutHomeSeen && url === "about:home") { + this._aboutHomeSeen = true; + + // XXX note that this will be incorrectly set in the following cases: + // session_restore following by clicking on the toolbar button, + // or someone who has changed their default home page preference to + // something else and later clicks the toolbar. It will also be + // incorrectly unset if someone changes their "Home Page" preference to + // about:newtab. + // + // That said, the ratio of these mistakes to correct cases should + // be very small, and these issues should follow away as we implement + // the remaining load_trigger_type values for about:home in issue 3556. + // + // XXX file a bug to implement remaining about:home cases so this + // problem will go away and link to it here. + load_trigger_type = "first_window_opened"; + + // The real perceived trigger of first_window_opened is the OS-level + // clicking of the icon. We express this by using the process start + // absolute timestamp. + load_trigger_ts = this.processStartTs; + } + + const session = { + session_id: String(Services.uuid.generateUUID()), + // "unknown" will be overwritten when appropriate + page: url ? url : "unknown", + perf: { + load_trigger_type, + is_preloaded: false, + }, + }; + + if (load_trigger_ts) { + session.perf.load_trigger_ts = load_trigger_ts; + } + + this.sessions.set(id, session); + return session; + } + + /** + * endSession - Stop tracking a session + * + * @param {string} portID the portID of the session that just closed + */ + endSession(portID) { + const session = this.sessions.get(portID); + + if (!session) { + // It's possible the tab was never visible – in which case, there was no user session. + return; + } + + Glean.newtab.closed.record({ newtab_visit_id: session.session_id }); + if ( + this.telemetryEnabled && + (lazy.NimbusFeatures.glean.getVariable("newtabPingEnabled") ?? true) + ) { + GleanPings.newtab.submit("newtab_session_end"); + } + + if (session.perf.visibility_event_rcvd_ts) { + let absNow = this.processStartTs + Cu.now(); + session.session_duration = Math.round( + absNow - session.perf.visibility_event_rcvd_ts + ); + + // Rounding all timestamps in perf to ease the data processing on the backend. + // NB: use `TIMESTAMP_MISSING_VALUE` if the value is missing. + session.perf.visibility_event_rcvd_ts = Math.round( + session.perf.visibility_event_rcvd_ts + ); + session.perf.load_trigger_ts = Math.round( + session.perf.load_trigger_ts || TIMESTAMP_MISSING_VALUE + ); + session.perf.topsites_first_painted_ts = Math.round( + session.perf.topsites_first_painted_ts || TIMESTAMP_MISSING_VALUE + ); + } else { + // This session was never shown (i.e. the hidden preloaded newtab), there was no user session either. + this.sessions.delete(portID); + return; + } + + let sessionEndEvent = this.createSessionEndEvent(session); + this.sendUTEvent(sessionEndEvent, this.utEvents.sendSessionEndEvent); + this.sessions.delete(portID); + } + + /** + * handleNewTabInit - Handle NEW_TAB_INIT, which creates a new session and sets the a flag + * for session.perf based on whether or not this new tab is preloaded + * + * @param {obj} action the Action object + */ + handleNewTabInit(action) { + const session = this.addSession( + au.getPortIdOfSender(action), + action.data.url + ); + session.perf.is_preloaded = + action.data.browser.getAttribute("preloadedState") === "preloaded"; + } + + /** + * createPing - Create a ping with common properties + * + * @param {string} id The portID of the session, if a session is relevant (optional) + * @return {obj} A telemetry ping + */ + createPing(portID) { + const ping = { + addon_version: Services.appinfo.appBuildID, + locale: Services.locale.appLocaleAsBCP47, + user_prefs: this.userPreferences, + }; + + // If the ping is part of a user session, add session-related info + if (portID) { + const session = this.sessions.get(portID) || this.addSession(portID); + Object.assign(ping, { session_id: session.session_id }); + + if (session.page) { + Object.assign(ping, { page: session.page }); + } + } + return ping; + } + + createUserEvent(action) { + return Object.assign( + this.createPing(au.getPortIdOfSender(action)), + action.data, + { action: "activity_stream_user_event" } + ); + } + + createSessionEndEvent(session) { + return Object.assign(this.createPing(), { + session_id: session.session_id, + page: session.page, + session_duration: session.session_duration, + action: "activity_stream_session", + perf: session.perf, + profile_creation_date: + lazy.TelemetryEnvironment.currentEnvironment.profile.resetDate || + lazy.TelemetryEnvironment.currentEnvironment.profile.creationDate, + }); + } + + /** + * Create a ping for AS router event. The client_id is set to "n/a" by default, + * different component can override this by its own telemetry collection policy. + */ + async createASRouterEvent(action) { + let event = { + ...action.data, + addon_version: Services.appinfo.appBuildID, + locale: Services.locale.appLocaleAsBCP47, + }; + const session = this.sessions.get(au.getPortIdOfSender(action)); + if (event.event_context && typeof event.event_context === "object") { + event.event_context = JSON.stringify(event.event_context); + } + switch (event.action) { + case "cfr_user_event": + event = await this.applyCFRPolicy(event); + break; + case "badge_user_event": + case "whats-new-panel_user_event": + event = await this.applyWhatsNewPolicy(event); + break; + case "infobar_user_event": + event = await this.applyInfoBarPolicy(event); + break; + case "spotlight_user_event": + event = await this.applySpotlightPolicy(event); + break; + case "toast_notification_user_event": + event = await this.applyToastNotificationPolicy(event); + break; + case "moments_user_event": + event = await this.applyMomentsPolicy(event); + break; + case "onboarding_user_event": + event = await this.applyOnboardingPolicy(event, session); + break; + case "asrouter_undesired_event": + event = this.applyUndesiredEventPolicy(event); + break; + default: + event = { ping: event }; + break; + } + return event; + } + + /** + * Per Bug 1484035, CFR metrics comply with following policies: + * 1). In release, it collects impression_id and bucket_id + * 2). In prerelease, it collects client_id and message_id + * 3). In shield experiments conducted in release, it collects client_id and message_id + * 4). In Private Browsing windows, unless in experiment, collects impression_id and bucket_id + */ + async applyCFRPolicy(ping) { + if ( + (lazy.UpdateUtils.getUpdateChannel(true) === "release" || + ping.is_private) && + !this.isInCFRCohort + ) { + ping.message_id = "n/a"; + ping.impression_id = this._impressionId; + } else { + ping.client_id = await this.telemetryClientId; + } + delete ping.action; + delete ping.is_private; + return { ping, pingType: "cfr" }; + } + + /** + * Per Bug 1482134, all the metrics for What's New panel use client_id in + * all the release channels + */ + async applyWhatsNewPolicy(ping) { + ping.client_id = await this.telemetryClientId; + ping.browser_session_id = lazy.browserSessionId; + // Attach page info to `event_context` if there is a session associated with this ping + delete ping.action; + return { ping, pingType: "whats-new-panel" }; + } + + async applyInfoBarPolicy(ping) { + ping.client_id = await this.telemetryClientId; + ping.browser_session_id = lazy.browserSessionId; + delete ping.action; + return { ping, pingType: "infobar" }; + } + + async applySpotlightPolicy(ping) { + ping.client_id = await this.telemetryClientId; + ping.browser_session_id = lazy.browserSessionId; + delete ping.action; + return { ping, pingType: "spotlight" }; + } + + async applyToastNotificationPolicy(ping) { + ping.client_id = await this.telemetryClientId; + ping.browser_session_id = lazy.browserSessionId; + delete ping.action; + return { ping, pingType: "toast_notification" }; + } + + /** + * Per Bug 1484035, Moments metrics comply with following policies: + * 1). In release, it collects impression_id, and treats bucket_id as message_id + * 2). In prerelease, it collects client_id and message_id + * 3). In shield experiments conducted in release, it collects client_id and message_id + */ + async applyMomentsPolicy(ping) { + if ( + lazy.UpdateUtils.getUpdateChannel(true) === "release" && + !this.isInCFRCohort + ) { + ping.message_id = "n/a"; + ping.impression_id = this._impressionId; + } else { + ping.client_id = await this.telemetryClientId; + } + delete ping.action; + return { ping, pingType: "moments" }; + } + + /** + * Per Bug 1482134, all the metrics for Onboarding in AS router use client_id in + * all the release channels + */ + async applyOnboardingPolicy(ping, session) { + ping.client_id = await this.telemetryClientId; + ping.browser_session_id = lazy.browserSessionId; + // Attach page info to `event_context` if there is a session associated with this ping + if (ping.action === "onboarding_user_event" && session && session.page) { + let event_context; + + try { + event_context = ping.event_context + ? JSON.parse(ping.event_context) + : {}; + } catch (e) { + // If `ping.event_context` is not a JSON serialized string, then we create a `value` + // key for it + event_context = { value: ping.event_context }; + } + + if (ONBOARDING_ALLOWED_PAGE_VALUES.includes(session.page)) { + event_context.page = session.page; + } else { + console.error(`Invalid 'page' for Onboarding event: ${session.page}`); + } + ping.event_context = JSON.stringify(event_context); + } + delete ping.action; + return { ping, pingType: "onboarding" }; + } + + applyUndesiredEventPolicy(ping) { + ping.impression_id = this._impressionId; + delete ping.action; + return { ping, pingType: "undesired-events" }; + } + + sendUTEvent(event_object, eventFunction) { + if (this.telemetryEnabled && this.eventTelemetryEnabled) { + eventFunction(event_object); + } + } + + handleTopSitesSponsoredImpressionStats(action) { + const { data } = action; + const { + type, + position, + source, + advertiser: advertiser_name, + tile_id, + } = data; + // Legacy telemetry expects 1-based tile positions. + const legacyTelemetryPosition = position + 1; + + let pingType; + + const session = this.sessions.get(au.getPortIdOfSender(action)); + if (type === "impression") { + pingType = "topsites-impression"; + Services.telemetry.keyedScalarAdd( + `${SCALAR_CATEGORY_TOPSITES}.impression`, + `${source}_${legacyTelemetryPosition}`, + 1 + ); + if (session) { + Glean.topsites.impression.record({ + advertiser_name, + tile_id, + newtab_visit_id: session.session_id, + is_sponsored: true, + position, + }); + } + } else if (type === "click") { + pingType = "topsites-click"; + Services.telemetry.keyedScalarAdd( + `${SCALAR_CATEGORY_TOPSITES}.click`, + `${source}_${legacyTelemetryPosition}`, + 1 + ); + if (session) { + Glean.topsites.click.record({ + advertiser_name, + tile_id, + newtab_visit_id: session.session_id, + is_sponsored: true, + position, + }); + } + } else { + console.error("Unknown ping type for sponsored TopSites impression"); + return; + } + + Glean.topSites.pingType.set(pingType); + Glean.topSites.position.set(legacyTelemetryPosition); + Glean.topSites.source.set(source); + Glean.topSites.tileId.set(tile_id); + if (data.reporting_url) { + Glean.topSites.reportingUrl.set(data.reporting_url); + } + Glean.topSites.advertiser.set(advertiser_name); + Glean.topSites.contextId.set(lazy.contextId); + GleanPings.topSites.submit(); + } + + handleTopSitesOrganicImpressionStats(action) { + const session = this.sessions.get(au.getPortIdOfSender(action)); + if (!session) { + return; + } + + switch (action.data?.type) { + case "impression": + Glean.topsites.impression.record({ + newtab_visit_id: session.session_id, + is_sponsored: false, + position: action.data.position, + }); + break; + + case "click": + Glean.topsites.click.record({ + newtab_visit_id: session.session_id, + is_sponsored: false, + position: action.data.position, + }); + break; + + default: + break; + } + } + + handleUserEvent(action) { + let userEvent = this.createUserEvent(action); + this.sendUTEvent(userEvent, this.utEvents.sendUserEvent); + } + + handleDiscoveryStreamUserEvent(action) { + const pocket_logged_in_status = lazy.pktApi.isUserLoggedIn(); + Glean.pocket.isSignedIn.set(pocket_logged_in_status); + this.handleUserEvent({ + ...action, + data: { + ...(action.data || {}), + value: { + ...(action.data?.value || {}), + pocket_logged_in_status, + }, + }, + }); + const session = this.sessions.get(au.getPortIdOfSender(action)); + switch (action.data?.event) { + case "CLICK": + const { card_type, topic, recommendation_id, tile_id, shim } = + action.data.value ?? {}; + if ( + action.data.source === "POPULAR_TOPICS" || + card_type === "topics_widget" + ) { + Glean.pocket.topicClick.record({ + newtab_visit_id: session.session_id, + topic, + }); + } else if (["spoc", "organic"].includes(card_type)) { + Glean.pocket.click.record({ + newtab_visit_id: session.session_id, + is_sponsored: card_type === "spoc", + position: action.data.action_position, + recommendation_id, + tile_id, + }); + if (shim) { + Glean.pocket.shim.set(shim); + GleanPings.spoc.submit("click"); + } + } + break; + case "SAVE_TO_POCKET": + Glean.pocket.save.record({ + newtab_visit_id: session.session_id, + is_sponsored: action.data.value?.card_type === "spoc", + position: action.data.action_position, + recommendation_id: action.data.value?.recommendation_id, + tile_id: action.data.value?.tile_id, + }); + if (action.data.value?.shim) { + Glean.pocket.shim.set(action.data.value.shim); + GleanPings.spoc.submit("save"); + } + break; + } + } + + async handleASRouterUserEvent(action) { + const { ping, pingType } = await this.createASRouterEvent(action); + if (!pingType) { + console.error("Unknown ping type for ASRouter telemetry"); + return; + } + + // Now that the action has become a ping, we can echo it to Glean. + if (this.telemetryEnabled) { + lazy.Telemetry.submitGleanPingForPing({ ...ping, pingType }); + } + } + + /** + * This function is used by ActivityStreamStorage to report errors + * trying to access IndexedDB. + */ + SendASRouterUndesiredEvent(data) { + this.handleASRouterUserEvent({ + data: { ...data, action: "asrouter_undesired_event" }, + }); + } + + async sendPageTakeoverData() { + if (this.telemetryEnabled) { + const value = {}; + let homeAffected = false; + let newtabCategory = "disabled"; + let homePageCategory = "disabled"; + + // Check whether or not about:home and about:newtab are set to a custom URL. + // If so, classify them. + if (Services.prefs.getBoolPref("browser.newtabpage.enabled")) { + newtabCategory = "enabled"; + if ( + lazy.AboutNewTab.newTabURLOverridden && + !lazy.AboutNewTab.newTabURL.startsWith("moz-extension://") + ) { + value.newtab_url_category = await this._classifySite( + lazy.AboutNewTab.newTabURL + ); + newtabCategory = value.newtab_url_category; + } + } + // Check if the newtab page setting is controlled by an extension. + await lazy.ExtensionSettingsStore.initialize(); + const newtabExtensionInfo = lazy.ExtensionSettingsStore.getSetting( + "url_overrides", + "newTabURL" + ); + if (newtabExtensionInfo && newtabExtensionInfo.id) { + value.newtab_extension_id = newtabExtensionInfo.id; + newtabCategory = "extension"; + } + + const homePageURL = lazy.HomePage.get(); + if ( + !["about:home", "about:blank"].includes(homePageURL) && + !homePageURL.startsWith("moz-extension://") + ) { + value.home_url_category = await this._classifySite(homePageURL); + homeAffected = true; + homePageCategory = value.home_url_category; + } + const homeExtensionInfo = lazy.ExtensionSettingsStore.getSetting( + "prefs", + "homepage_override" + ); + if (homeExtensionInfo && homeExtensionInfo.id) { + value.home_extension_id = homeExtensionInfo.id; + homeAffected = true; + homePageCategory = "extension"; + } + if (!homeAffected && !lazy.HomePage.overridden) { + homePageCategory = "enabled"; + } + + Glean.newtab.newtabCategory.set(newtabCategory); + Glean.newtab.homepageCategory.set(homePageCategory); + if (lazy.NimbusFeatures.glean.getVariable("newtabPingEnabled") ?? true) { + GleanPings.newtab.submit("component_init"); + } + } + } + + onAction(action) { + switch (action.type) { + case at.INIT: + this.init(); + this.sendPageTakeoverData(); + break; + case at.NEW_TAB_INIT: + this.handleNewTabInit(action); + break; + case at.NEW_TAB_UNLOAD: + this.endSession(au.getPortIdOfSender(action)); + break; + case at.SAVE_SESSION_PERF_DATA: + this.saveSessionPerfData(au.getPortIdOfSender(action), action.data); + break; + case at.DISCOVERY_STREAM_IMPRESSION_STATS: + this.handleDiscoveryStreamImpressionStats( + au.getPortIdOfSender(action), + action.data + ); + break; + case at.DISCOVERY_STREAM_USER_EVENT: + this.handleDiscoveryStreamUserEvent(action); + break; + case at.TELEMETRY_USER_EVENT: + this.handleUserEvent(action); + break; + // The next few action types come from ASRouter, which doesn't use + // Actions from Actions.jsm, but uses these other custom strings. + case msg.TOOLBAR_BADGE_TELEMETRY: + // Intentional fall-through + case msg.TOOLBAR_PANEL_TELEMETRY: + // Intentional fall-through + case msg.MOMENTS_PAGE_TELEMETRY: + // Intentional fall-through + case msg.DOORHANGER_TELEMETRY: + // Intentional fall-through + case msg.INFOBAR_TELEMETRY: + // Intentional fall-through + case msg.SPOTLIGHT_TELEMETRY: + // Intentional fall-through + case msg.TOAST_NOTIFICATION_TELEMETRY: + // Intentional fall-through + case at.AS_ROUTER_TELEMETRY_USER_EVENT: + this.handleASRouterUserEvent(action); + break; + case at.TOP_SITES_SPONSORED_IMPRESSION_STATS: + this.handleTopSitesSponsoredImpressionStats(action); + break; + case at.TOP_SITES_ORGANIC_IMPRESSION_STATS: + this.handleTopSitesOrganicImpressionStats(action); + break; + case at.UNINIT: + this.uninit(); + break; + case at.ABOUT_SPONSORED_TOP_SITES: + this.handleAboutSponsoredTopSites(action); + break; + case at.BLOCK_URL: + this.handleBlockUrl(action); + break; + } + } + + 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? + if (!session) { + return; + } + + // Despite the action name, this is actually a bulk dismiss action: + // it can be applied to multiple topsites simultaneously. + const { data } = action; + for (const datum of data) { + if (datum.is_pocket_card) { + // There is no instrumentation for Pocket dismissals (yet). + continue; + } + const { position, advertiser_name, tile_id, isSponsoredTopSite } = datum; + Glean.topsites.dismiss.record({ + advertiser_name, + tile_id, + newtab_visit_id: session.session_id, + is_sponsored: !!isSponsoredTopSite, + position, + }); + } + } + + handleAboutSponsoredTopSites(action) { + const session = this.sessions.get(au.getPortIdOfSender(action)); + const { data } = action; + const { position, advertiser_name, tile_id } = data; + + if (session) { + Glean.topsites.showPrivacyClick.record({ + advertiser_name, + tile_id, + newtab_visit_id: session.session_id, + position, + }); + } + } + + /** + * Handle impression stats actions from Discovery Stream. + * + * @param {String} port The session port with which this is associated + * @param {Object} data The impression data structured as {source: "SOURCE", tiles: [{id: 123}]} + * + */ + handleDiscoveryStreamImpressionStats(port, data) { + let session = this.sessions.get(port); + + if (!session) { + throw new Error("Session does not exist."); + } + + const { tiles } = data; + tiles.forEach(tile => { + Glean.pocket.impression.record({ + newtab_visit_id: session.session_id, + is_sponsored: tile.type === "spoc", + position: tile.pos, + recommendation_id: tile.recommendation_id, + tile_id: tile.id, + }); + if (tile.shim) { + Glean.pocket.shim.set(tile.shim); + GleanPings.spoc.submit("impression"); + } + }); + } + + /** + * Take all enumerable members of the data object and merge them into + * the session.perf object for the given port, so that it is sent to the + * server when the session ends. All members of the data object should + * be valid values of the perf object, as defined in pings.js and the + * data*.md documentation. + * + * @note Any existing keys with the same names already in the + * session perf object will be overwritten by values passed in here. + * + * @param {String} port The session with which this is associated + * @param {Object} data The perf data to be + */ + saveSessionPerfData(port, data) { + // XXX should use try/catch and send a bad state indicator if this + // get blows up. + let session = this.sessions.get(port); + + // XXX Partial workaround for #3118; avoids the worst incorrect associations + // of times with browsers, by associating the load trigger with the + // visibility event as the user is most likely associating the trigger to + // the tab just shown. This helps avoid associating with a preloaded + // browser as those don't get the event until shown. Better fix for more + // cases forthcoming. + // + // XXX the about:home check (and the corresponding test) should go away + // once the load_trigger stuff in addSession is refactored into + // setLoadTriggerInfo. + // + if (data.visibility_event_rcvd_ts && session.page !== "about:home") { + this.setLoadTriggerInfo(port); + } + + let timestamp = data.topsites_first_painted_ts; + + if ( + timestamp && + session.page === "about:home" && + !lazy.HomePage.overridden && + Services.prefs.getIntPref("browser.startup.page") === 1 + ) { + lazy.AboutNewTab.maybeRecordTopsitesPainted(timestamp); + } + + Object.assign(session.perf, data); + + if (data.visibility_event_rcvd_ts && !session.newtabOpened) { + session.newtabOpened = true; + const source = ONBOARDING_ALLOWED_PAGE_VALUES.includes(session.page) + ? session.page + : "other"; + Glean.newtab.opened.record({ + newtab_visit_id: session.session_id, + source, + }); + } + } + + _beginObservingNewtabPingPrefs() { + Services.prefs.addObserver(ACTIVITY_STREAM_PREF_BRANCH, this); + + for (const pref of Object.keys(NEWTAB_PING_PREFS)) { + const fullPrefName = ACTIVITY_STREAM_PREF_BRANCH + pref; + this._setNewtabPrefMetrics(fullPrefName, false); + } + Glean.pocket.isSignedIn.set(lazy.pktApi.isUserLoggedIn()); + + Services.prefs.addObserver(TOP_SITES_BLOCKED_SPONSORS_PREF, this); + this._setBlockedSponsorsMetrics(); + } + + _stopObservingNewtabPingPrefs() { + Services.prefs.removeObserver(ACTIVITY_STREAM_PREF_BRANCH, this); + Services.prefs.removeObserver(TOP_SITES_BLOCKED_SPONSORS_PREF, this); + } + + observe(subject, topic, data) { + if (data === TOP_SITES_BLOCKED_SPONSORS_PREF) { + this._setBlockedSponsorsMetrics(); + } else { + this._setNewtabPrefMetrics(data, true); + } + } + + _setNewtabPrefMetrics(fullPrefName, isChanged) { + const pref = fullPrefName.slice(ACTIVITY_STREAM_PREF_BRANCH.length); + if (!Object.hasOwn(NEWTAB_PING_PREFS, pref)) { + return; + } + const metric = NEWTAB_PING_PREFS[pref]; + switch (Services.prefs.getPrefType(fullPrefName)) { + case Services.prefs.PREF_BOOL: + metric.set(Services.prefs.getBoolPref(fullPrefName)); + break; + + case Services.prefs.PREF_INT: + metric.set(Services.prefs.getIntPref(fullPrefName)); + break; + } + if (isChanged) { + switch (fullPrefName) { + case `${ACTIVITY_STREAM_PREF_BRANCH}feeds.topsites`: + case `${ACTIVITY_STREAM_PREF_BRANCH}showSponsoredTopSites`: + Glean.topsites.prefChanged.record({ + pref_name: fullPrefName, + new_value: Services.prefs.getBoolPref(fullPrefName), + }); + break; + } + } + } + + _setBlockedSponsorsMetrics() { + let blocklist; + try { + blocklist = JSON.parse( + Services.prefs.getStringPref(TOP_SITES_BLOCKED_SPONSORS_PREF, "[]") + ); + } catch (e) {} + if (blocklist) { + Glean.newtab.blockedSponsors.set(blocklist); + } + } + + uninit() { + this._stopObservingNewtabPingPrefs(); + + try { + Services.obs.removeObserver( + this.browserOpenNewtabStart, + "browser-open-newtab-start" + ); + } catch (e) { + // Operation can fail when uninit is called before + // init has finished setting up the observer + } + + // Only uninit if the getter has initialized it + if (Object.prototype.hasOwnProperty.call(this, "utEvents")) { + this.utEvents.uninit(); + } + + // TODO: Send any unfinished sessions + } +} diff --git a/browser/components/newtab/lib/TippyTopProvider.sys.mjs b/browser/components/newtab/lib/TippyTopProvider.sys.mjs new file mode 100644 index 0000000000..8f32516119 --- /dev/null +++ b/browser/components/newtab/lib/TippyTopProvider.sys.mjs @@ -0,0 +1,60 @@ +/* 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 TIPPYTOP_PATH = "chrome://activity-stream/content/data/content/tippytop/"; +const TIPPYTOP_JSON_PATH = + "chrome://activity-stream/content/data/content/tippytop/top_sites.json"; + +/* + * Get a domain from a url optionally stripping subdomains. + */ +export function getDomain(url, strip = "www.") { + let domain = ""; + try { + domain = new URL(url).hostname; + } catch (ex) {} + if (strip === "*") { + try { + domain = Services.eTLD.getBaseDomainFromHost(domain); + } catch (ex) {} + } else if (domain.startsWith(strip)) { + domain = domain.slice(strip.length); + } + return domain; +} + +export class TippyTopProvider { + constructor() { + this._sitesByDomain = new Map(); + this.initialized = false; + } + + async init() { + // Load the Tippy Top sites from the json manifest. + try { + for (const site of await ( + await fetch(TIPPYTOP_JSON_PATH, { + credentials: "omit", + }) + ).json()) { + for (const domain of site.domains) { + this._sitesByDomain.set(domain, site); + } + } + this.initialized = true; + } catch (error) { + console.error("Failed to load tippy top manifest."); + } + } + + processSite(site, strip) { + const tippyTop = this._sitesByDomain.get(getDomain(site.url, strip)); + if (tippyTop) { + site.tippyTopIcon = TIPPYTOP_PATH + tippyTop.image_url; + site.smallFavicon = TIPPYTOP_PATH + tippyTop.favicon_url; + site.backgroundColor = tippyTop.background_color; + } + return site; + } +} diff --git a/browser/components/newtab/lib/TopSitesFeed.sys.mjs b/browser/components/newtab/lib/TopSitesFeed.sys.mjs new file mode 100644 index 0000000000..db21411fdd --- /dev/null +++ b/browser/components/newtab/lib/TopSitesFeed.sys.mjs @@ -0,0 +1,2007 @@ +/* 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.sys.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("TopSitesFeed"); +}); + +// `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( + ([k, 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, v]) => k.startsWith(provider)) + .map(([k, v]) => 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; + } +} + +export class TopSitesFeed { + 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 = []; + const 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, + }); + for (let link of cache) { + 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 {bool} options.broadcast Should the update be broadcasted. + * @param {bool} options.isStartup Being called while TopSitesFeed is initting. + */ + async refresh(options = {}) { + if (!this._startedUp && !options.isStartup) { + // Initial refresh still pending. + return; + } + 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)); + } + } + + // 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 link cached topsite object + * @param url where to fetch the image from + * @param 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 customScreenshotURL {string} The URL used to capture the screenshot + * @param target {string} 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 + */ + 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; + } + } +} diff --git a/browser/components/newtab/lib/TopStoriesFeed.sys.mjs b/browser/components/newtab/lib/TopStoriesFeed.sys.mjs new file mode 100644 index 0000000000..be030649dd --- /dev/null +++ b/browser/components/newtab/lib/TopStoriesFeed.sys.mjs @@ -0,0 +1,731 @@ +/* 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 { + actionTypes as at, + actionCreators as ac, +} from "resource://activity-stream/common/Actions.sys.mjs"; +import { Prefs } from "resource://activity-stream/lib/ActivityStreamPrefs.sys.mjs"; +import { shortURL } from "resource://activity-stream/lib/ShortURL.sys.mjs"; +import { SectionsManager } from "resource://activity-stream/lib/SectionsManager.sys.mjs"; +import { PersistentCache } from "resource://activity-stream/lib/PersistentCache.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", + pktApi: "chrome://pocket/content/pktApi.sys.mjs", +}); + +export const STORIES_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes +export const TOPICS_UPDATE_TIME = 3 * 60 * 60 * 1000; // 3 hours +const STORIES_NOW_THRESHOLD = 24 * 60 * 60 * 1000; // 24 hours +export const DEFAULT_RECS_EXPIRE_TIME = 60 * 60 * 1000; // 1 hour +export const SECTION_ID = "topstories"; +const IMPRESSION_SOURCE = "TOP_STORIES"; + +export const SPOC_IMPRESSION_TRACKING_PREF = + "feeds.section.topstories.spoc.impressions"; + +const DISCOVERY_STREAM_PREF_ENABLED = "discoverystream.enabled"; +const DISCOVERY_STREAM_PREF_ENABLED_PATH = + "browser.newtabpage.activity-stream.discoverystream.enabled"; +export const REC_IMPRESSION_TRACKING_PREF = + "feeds.section.topstories.rec.impressions"; +const PREF_USER_TOPSTORIES = "feeds.section.topstories"; +const MAX_LIFETIME_CAP = 500; // Guard against misconfiguration on the server +const DISCOVERY_STREAM_PREF = "discoverystream.config"; + +export class TopStoriesFeed { + constructor(ds) { + // Use discoverystream config pref default values for fast path and + // if needed lazy load activity stream top stories feed based on + // actual user preference when INIT and PREF_CHANGED is invoked + this.discoveryStreamEnabled = + ds && + ds.value && + JSON.parse(ds.value).enabled && + Services.prefs.getBoolPref(DISCOVERY_STREAM_PREF_ENABLED_PATH, false); + if (!this.discoveryStreamEnabled) { + this.initializeProperties(); + } + } + + initializeProperties() { + this.contentUpdateQueue = []; + this.spocCampaignMap = new Map(); + this.cache = new PersistentCache(SECTION_ID, true); + this._prefs = new Prefs(); + this.propertiesInitialized = true; + } + + async onInit() { + SectionsManager.enableSection(SECTION_ID, true /* isStartup */); + if (this.discoveryStreamEnabled) { + return; + } + + try { + const { options } = SectionsManager.sections.get(SECTION_ID); + const apiKey = this.getApiKeyFromPref(options.api_key_pref); + this.stories_endpoint = this.produceFinalEndpointUrl( + options.stories_endpoint, + apiKey + ); + this.topics_endpoint = this.produceFinalEndpointUrl( + options.topics_endpoint, + apiKey + ); + this.read_more_endpoint = options.read_more_endpoint; + this.stories_referrer = options.stories_referrer; + this.show_spocs = options.show_spocs; + this.storiesLastUpdated = 0; + this.topicsLastUpdated = 0; + this.storiesLoaded = false; + this.dispatchPocketCta(this._prefs.get("pocketCta"), false); + + // Cache is used for new page loads, which shouldn't have changed data. + // If we have changed data, cache should be cleared, + // and last updated should be 0, and we can fetch. + let { stories, topics } = await this.loadCachedData(); + if (this.storiesLastUpdated === 0) { + stories = await this.fetchStories(); + } + if (this.topicsLastUpdated === 0) { + topics = await this.fetchTopics(); + } + this.doContentUpdate({ stories, topics }, true); + this.storiesLoaded = true; + + // This is filtered so an update function can return true to retry on the next run + this.contentUpdateQueue = this.contentUpdateQueue.filter(update => + update() + ); + } catch (e) { + console.error(`Problem initializing top stories feed: ${e.message}`); + } + } + + init() { + SectionsManager.onceInitialized(this.onInit.bind(this)); + } + + async clearCache() { + await this.cache.set("stories", {}); + await this.cache.set("topics", {}); + await this.cache.set("spocs", {}); + } + + uninit() { + this.storiesLoaded = false; + SectionsManager.disableSection(SECTION_ID); + } + + getPocketState(target) { + const action = { + type: at.POCKET_LOGGED_IN, + data: lazy.pktApi.isUserLoggedIn(), + }; + this.store.dispatch(ac.OnlyToOneContent(action, target)); + } + + dispatchPocketCta(data, shouldBroadcast) { + const action = { type: at.POCKET_CTA, data: JSON.parse(data) }; + this.store.dispatch( + shouldBroadcast + ? ac.BroadcastToContent(action) + : ac.AlsoToPreloaded(action) + ); + } + + /** + * doContentUpdate - Updates topics and stories in the topstories section. + * + * Sections have one update action for the whole section. + * Redux creates a state race condition if you call the same action, + * twice, concurrently. Because of this, doContentUpdate is + * one place to update both topics and stories in a single action. + * + * Section updates used old topics if none are available, + * but clear stories if none are available. Because of this, if no + * stories are passed, we instead use the existing stories in state. + * + * @param {Object} This is an object with potential new stories or topics. + * @param {Boolean} shouldBroadcast If we should update existing tabs or not. For first page + * loads or pref changes, we want to update existing tabs, + * for system tick or other updates we do not. + */ + doContentUpdate({ stories, topics }, shouldBroadcast) { + let updateProps = {}; + if (stories) { + updateProps.rows = stories; + } else { + const { Sections } = this.store.getState(); + if (Sections && Sections.find) { + updateProps.rows = Sections.find(s => s.id === SECTION_ID).rows; + } + } + if (topics) { + Object.assign(updateProps, { + topics, + read_more_endpoint: this.read_more_endpoint, + }); + } + + // We should only be calling this once per init. + this.dispatchUpdateEvent(shouldBroadcast, updateProps); + } + + async fetchStories() { + if (!this.stories_endpoint) { + return null; + } + try { + const response = await fetch(this.stories_endpoint, { + credentials: "omit", + }); + if (!response.ok) { + throw new Error( + `Stories endpoint returned unexpected status: ${response.status}` + ); + } + + const body = await response.json(); + this.updateSettings(body.settings); + this.stories = this.rotate(this.transform(body.recommendations)); + this.cleanUpTopRecImpressionPref(); + + if (this.show_spocs && body.spocs) { + this.spocCampaignMap = new Map( + body.spocs.map(s => [s.id, `${s.campaign_id}`]) + ); + this.spocs = this.transform(body.spocs); + this.cleanUpCampaignImpressionPref(); + } + this.storiesLastUpdated = Date.now(); + body._timestamp = this.storiesLastUpdated; + this.cache.set("stories", body); + } catch (error) { + console.error(`Failed to fetch content: ${error.message}`); + } + return this.stories; + } + + async loadCachedData() { + const data = await this.cache.get(); + let stories = data.stories && data.stories.recommendations; + let topics = data.topics && data.topics.topics; + + if (stories && !!stories.length && this.storiesLastUpdated === 0) { + this.updateSettings(data.stories.settings); + this.stories = this.rotate(this.transform(stories)); + this.storiesLastUpdated = data.stories._timestamp; + if (data.stories.spocs && data.stories.spocs.length) { + this.spocCampaignMap = new Map( + data.stories.spocs.map(s => [s.id, `${s.campaign_id}`]) + ); + this.spocs = this.transform(data.stories.spocs); + this.cleanUpCampaignImpressionPref(); + } + } + if (topics && !!topics.length && this.topicsLastUpdated === 0) { + this.topics = topics; + this.topicsLastUpdated = data.topics._timestamp; + } + + return { topics: this.topics, stories: this.stories }; + } + + transform(items) { + if (!items) { + return []; + } + + const calcResult = items + .filter(s => !lazy.NewTabUtils.blockedLinks.isBlocked({ url: s.url })) + .map(s => { + let mapped = { + guid: s.id, + hostname: s.domain || shortURL(Object.assign({}, s, { url: s.url })), + type: + Date.now() - s.published_timestamp * 1000 <= STORIES_NOW_THRESHOLD + ? "now" + : "trending", + context: s.context, + icon: s.icon, + title: s.title, + description: s.excerpt, + image: this.normalizeUrl(s.image_src), + referrer: this.stories_referrer, + url: s.url, + score: s.item_score || 1, + spoc_meta: this.show_spocs + ? { campaign_id: s.campaign_id, caps: s.caps } + : {}, + }; + + // Very old cached spocs may not contain an `expiration_timestamp` property + if (s.expiration_timestamp) { + mapped.expiration_timestamp = s.expiration_timestamp; + } + + return mapped; + }) + .sort(this.compareScore); + + return calcResult; + } + + async fetchTopics() { + if (!this.topics_endpoint) { + return null; + } + try { + const response = await fetch(this.topics_endpoint, { + credentials: "omit", + }); + if (!response.ok) { + throw new Error( + `Topics endpoint returned unexpected status: ${response.status}` + ); + } + const body = await response.json(); + const { topics } = body; + if (topics) { + this.topics = topics; + this.topicsLastUpdated = Date.now(); + body._timestamp = this.topicsLastUpdated; + this.cache.set("topics", body); + } + } catch (error) { + console.error(`Failed to fetch topics: ${error.message}`); + } + return this.topics; + } + + dispatchUpdateEvent(shouldBroadcast, data) { + SectionsManager.updateSection(SECTION_ID, data, shouldBroadcast); + } + + compareScore(a, b) { + return b.score - a.score; + } + + updateSettings(settings = {}) { + this.spocsPerNewTabs = settings.spocsPerNewTabs || 1; // Probability of a new tab getting a spoc [0,1] + this.recsExpireTime = settings.recsExpireTime; + } + + // We rotate stories on the client so that + // active stories are at the front of the list, followed by stories that have expired + // impressions i.e. have been displayed for longer than recsExpireTime. + rotate(items) { + if (items.length <= 3) { + return items; + } + + const maxImpressionAge = Math.max( + this.recsExpireTime * 1000 || DEFAULT_RECS_EXPIRE_TIME, + DEFAULT_RECS_EXPIRE_TIME + ); + const impressions = this.readImpressionsPref(REC_IMPRESSION_TRACKING_PREF); + const expired = []; + const active = []; + for (const item of items) { + if ( + impressions[item.guid] && + Date.now() - impressions[item.guid] >= maxImpressionAge + ) { + expired.push(item); + } else { + active.push(item); + } + } + return active.concat(expired); + } + + getApiKeyFromPref(apiKeyPref) { + if (!apiKeyPref) { + return apiKeyPref; + } + + return ( + this._prefs.get(apiKeyPref) || Services.prefs.getCharPref(apiKeyPref) + ); + } + + produceFinalEndpointUrl(url, apiKey) { + if (!url) { + return url; + } + if (url.includes("$apiKey") && !apiKey) { + throw new Error(`An API key was specified but none configured: ${url}`); + } + return url.replace("$apiKey", apiKey); + } + + // Need to remove parenthesis from image URLs as React will otherwise + // fail to render them properly as part of the card template. + normalizeUrl(url) { + if (url) { + return url.replace(/\(/g, "%28").replace(/\)/g, "%29"); + } + return url; + } + + shouldShowSpocs() { + return this.show_spocs && this.store.getState().Prefs.values.showSponsored; + } + + dispatchSpocDone(target) { + const action = { type: at.POCKET_WAITING_FOR_SPOC, data: false }; + this.store.dispatch(ac.OnlyToOneContent(action, target)); + } + + filterSpocs() { + if (!this.shouldShowSpocs()) { + return []; + } + + if (Math.random() > this.spocsPerNewTabs) { + return []; + } + + if (!this.spocs || !this.spocs.length) { + // We have stories but no spocs so there's nothing to do and this update can be + // removed from the queue. + return []; + } + + // Filter spocs based on frequency caps + const impressions = this.readImpressionsPref(SPOC_IMPRESSION_TRACKING_PREF); + let spocs = this.spocs.filter(s => + this.isBelowFrequencyCap(impressions, s) + ); + + // Filter out expired spocs based on `expiration_timestamp` + spocs = spocs.filter(spoc => { + // If cached data is so old it doesn't contain this property, assume the spoc is ok to show + if (!(`expiration_timestamp` in spoc)) { + return true; + } + // `expiration_timestamp` is the number of seconds elapsed since January 1, 1970 00:00:00 UTC + return spoc.expiration_timestamp * 1000 > Date.now(); + }); + + return spocs; + } + + maybeAddSpoc(target) { + const updateContent = () => { + let spocs = this.filterSpocs(); + + if (!spocs.length) { + this.dispatchSpocDone(target); + return false; + } + + // Create a new array with a spoc inserted at index 2 + const section = this.store + .getState() + .Sections.find(s => s.id === SECTION_ID); + let rows = section.rows.slice(0, this.stories.length); + rows.splice(2, 0, Object.assign(spocs[0], { pinned: true })); + + // Send a content update to the target tab + const action = { + type: at.SECTION_UPDATE, + data: Object.assign({ rows }, { id: SECTION_ID }), + }; + this.store.dispatch(ac.OnlyToOneContent(action, target)); + this.dispatchSpocDone(target); + return false; + }; + + if (this.storiesLoaded) { + updateContent(); + } else { + // Delay updating tab content until initial data has been fetched + this.contentUpdateQueue.push(updateContent); + } + } + + // Frequency caps are based on campaigns, which may include multiple spocs. + // We currently support two types of frequency caps: + // - lifetime: Indicates how many times spocs from a campaign can be shown in total + // - period: Indicates how many times spocs from a campaign can be shown within a period + // + // So, for example, the feed configuration below defines that for campaign 1 no more + // than 5 spocs can be show in total, and no more than 2 per hour. + // "campaign_id": 1, + // "caps": { + // "lifetime": 5, + // "campaign": { + // "count": 2, + // "period": 3600 + // } + // } + isBelowFrequencyCap(impressions, spoc) { + const campaignImpressions = impressions[spoc.spoc_meta.campaign_id]; + if (!campaignImpressions) { + return true; + } + + const lifeTimeCap = Math.min( + spoc.spoc_meta.caps && spoc.spoc_meta.caps.lifetime, + MAX_LIFETIME_CAP + ); + const lifeTimeCapExceeded = campaignImpressions.length >= lifeTimeCap; + if (lifeTimeCapExceeded) { + return false; + } + + const campaignCap = + (spoc.spoc_meta.caps && spoc.spoc_meta.caps.campaign) || {}; + const campaignCapExceeded = + campaignImpressions.filter( + i => Date.now() - i < campaignCap.period * 1000 + ).length >= campaignCap.count; + return !campaignCapExceeded; + } + + // Clean up campaign impression pref by removing all campaigns that are no + // longer part of the response, and are therefore considered inactive. + cleanUpCampaignImpressionPref() { + const campaignIds = new Set(this.spocCampaignMap.values()); + this.cleanUpImpressionPref( + id => !campaignIds.has(id), + SPOC_IMPRESSION_TRACKING_PREF + ); + } + + // Clean up rec impression pref by removing all stories that are no + // longer part of the response. + cleanUpTopRecImpressionPref() { + const activeStories = new Set(this.stories.map(s => `${s.guid}`)); + this.cleanUpImpressionPref( + id => !activeStories.has(id), + REC_IMPRESSION_TRACKING_PREF + ); + } + + /** + * Cleans up the provided impression pref (spocs or recs). + * + * @param isExpired predicate (boolean-valued function) that returns whether or not + * the impression for the given key is expired. + * @param pref the impression pref to clean up. + */ + cleanUpImpressionPref(isExpired, pref) { + const impressions = this.readImpressionsPref(pref); + let changed = false; + + Object.keys(impressions).forEach(id => { + if (isExpired(id)) { + changed = true; + delete impressions[id]; + } + }); + + if (changed) { + this.writeImpressionsPref(pref, impressions); + } + } + + // Sets a pref mapping campaign IDs to timestamp arrays. + // The timestamps represent impressions which are used to calculate frequency caps. + recordCampaignImpression(campaignId) { + let impressions = this.readImpressionsPref(SPOC_IMPRESSION_TRACKING_PREF); + + const timeStamps = impressions[campaignId] || []; + timeStamps.push(Date.now()); + impressions = Object.assign(impressions, { [campaignId]: timeStamps }); + + this.writeImpressionsPref(SPOC_IMPRESSION_TRACKING_PREF, impressions); + } + + // Sets a pref mapping story (rec) IDs to a single timestamp (time of first impression). + // We use these timestamps to guarantee a story doesn't stay on top for longer than + // configured in the feed settings (settings.recsExpireTime). + recordTopRecImpressions(topItems) { + let impressions = this.readImpressionsPref(REC_IMPRESSION_TRACKING_PREF); + let changed = false; + + topItems.forEach(t => { + if (!impressions[t]) { + changed = true; + impressions = Object.assign(impressions, { [t]: Date.now() }); + } + }); + + if (changed) { + this.writeImpressionsPref(REC_IMPRESSION_TRACKING_PREF, impressions); + } + } + + readImpressionsPref(pref) { + const prefVal = this._prefs.get(pref); + return prefVal ? JSON.parse(prefVal) : {}; + } + + writeImpressionsPref(pref, impressions) { + this._prefs.set(pref, JSON.stringify(impressions)); + } + + async removeSpocs() { + // Quick hack so that SPOCS are removed from all open and preloaded tabs when + // they are disabled. The longer term fix should probably be to remove them + // in the Reducer. + await this.clearCache(); + this.uninit(); + this.init(); + } + + lazyLoadTopStories(options = {}) { + let { dsPref, userPref } = options; + if (!dsPref) { + dsPref = this.store.getState().Prefs.values[DISCOVERY_STREAM_PREF]; + } + if (!userPref) { + userPref = this.store.getState().Prefs.values[PREF_USER_TOPSTORIES]; + } + + try { + this.discoveryStreamEnabled = + JSON.parse(dsPref).enabled && + this.store.getState().Prefs.values[DISCOVERY_STREAM_PREF_ENABLED]; + } catch (e) { + // Load activity stream top stories if fail to determine discovery stream state + this.discoveryStreamEnabled = false; + } + + // Return without invoking initialization if top stories are loaded, or preffed off. + if (this.storiesLoaded || !userPref) { + return; + } + + if (!this.discoveryStreamEnabled && !this.propertiesInitialized) { + this.initializeProperties(); + } + this.init(); + } + + handleDisabled(action) { + switch (action.type) { + case at.INIT: + this.lazyLoadTopStories(); + break; + case at.PREF_CHANGED: + if (action.data.name === DISCOVERY_STREAM_PREF) { + this.lazyLoadTopStories({ dsPref: action.data.value }); + } + if (action.data.name === DISCOVERY_STREAM_PREF_ENABLED) { + this.lazyLoadTopStories(); + } + if (action.data.name === PREF_USER_TOPSTORIES) { + if (action.data.value) { + // init topstories if value if true. + this.lazyLoadTopStories({ userPref: action.data.value }); + } else { + this.uninit(); + } + } + break; + case at.UNINIT: + this.uninit(); + break; + } + } + + async onAction(action) { + if (this.discoveryStreamEnabled) { + this.handleDisabled(action); + return; + } + switch (action.type) { + // Check discoverystream pref and load activity stream top stories only if needed + case at.INIT: + this.lazyLoadTopStories(); + break; + case at.SYSTEM_TICK: + let stories; + let topics; + if (Date.now() - this.storiesLastUpdated >= STORIES_UPDATE_TIME) { + stories = await this.fetchStories(); + } + if (Date.now() - this.topicsLastUpdated >= TOPICS_UPDATE_TIME) { + topics = await this.fetchTopics(); + } + this.doContentUpdate({ stories, topics }, false); + break; + case at.UNINIT: + this.uninit(); + break; + case at.NEW_TAB_REHYDRATED: + this.getPocketState(action.meta.fromTarget); + this.maybeAddSpoc(action.meta.fromTarget); + break; + case at.SECTION_OPTIONS_CHANGED: + if (action.data === SECTION_ID) { + await this.clearCache(); + this.uninit(); + this.init(); + } + break; + case at.PLACES_LINK_BLOCKED: + if (this.spocs) { + this.spocs = this.spocs.filter(s => s.url !== action.data.url); + } + break; + case at.TELEMETRY_IMPRESSION_STATS: { + // We want to make sure we only track impressions from Top Stories, + // otherwise unexpected things that are not properly handled can happen. + // Example: Impressions from spocs on Discovery Stream can cause the + // Top Stories impressions pref to continuously grow, see bug #1523408 + if (action.data.source === IMPRESSION_SOURCE) { + const payload = action.data; + const viewImpression = !( + "click" in payload || + "block" in payload || + "pocket" in payload + ); + if (payload.tiles && viewImpression) { + if (this.shouldShowSpocs()) { + payload.tiles.forEach(t => { + if (this.spocCampaignMap.has(t.id)) { + this.recordCampaignImpression(this.spocCampaignMap.get(t.id)); + } + }); + } + const topRecs = payload.tiles + .filter(t => !this.spocCampaignMap.has(t.id)) + .map(t => t.id); + this.recordTopRecImpressions(topRecs); + } + } + break; + } + case at.PREF_CHANGED: + if (action.data.name === DISCOVERY_STREAM_PREF) { + this.lazyLoadTopStories({ dsPref: action.data.value }); + } + if (action.data.name === PREF_USER_TOPSTORIES) { + if (action.data.value) { + // init topstories if value if true. + this.lazyLoadTopStories({ userPref: action.data.value }); + } else { + this.uninit(); + } + } + // Check if spocs was disabled. Remove them if they were. + if (action.data.name === "showSponsored" && !action.data.value) { + await this.removeSpocs(); + } + if (action.data.name === "pocketCta") { + this.dispatchPocketCta(action.data.value, true); + } + break; + } + } +} diff --git a/browser/components/newtab/lib/UTEventReporting.sys.mjs b/browser/components/newtab/lib/UTEventReporting.sys.mjs new file mode 100644 index 0000000000..8da7824415 --- /dev/null +++ b/browser/components/newtab/lib/UTEventReporting.sys.mjs @@ -0,0 +1,62 @@ +/* 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/. */ + +/** + * Note: the schema can be found in + * https://searchfox.org/mozilla-central/source/toolkit/components/telemetry/Events.yaml + */ +const EXTRAS_FIELD_NAMES = [ + "addon_version", + "session_id", + "page", + "user_prefs", + "action_position", +]; + +export class UTEventReporting { + constructor() { + Services.telemetry.setEventRecordingEnabled("activity_stream", true); + this.sendUserEvent = this.sendUserEvent.bind(this); + this.sendSessionEndEvent = this.sendSessionEndEvent.bind(this); + } + + _createExtras(data) { + // Make a copy of the given data and delete/modify it as needed. + let utExtras = Object.assign({}, data); + for (let field of Object.keys(utExtras)) { + if (EXTRAS_FIELD_NAMES.includes(field)) { + utExtras[field] = String(utExtras[field]); + continue; + } + delete utExtras[field]; + } + return utExtras; + } + + sendUserEvent(data) { + let mainFields = ["event", "source"]; + let eventFields = mainFields.map(field => String(data[field]) || null); + + Services.telemetry.recordEvent( + "activity_stream", + "event", + ...eventFields, + this._createExtras(data) + ); + } + + sendSessionEndEvent(data) { + Services.telemetry.recordEvent( + "activity_stream", + "end", + "session", + String(data.session_duration), + this._createExtras(data) + ); + } + + uninit() { + Services.telemetry.setEventRecordingEnabled("activity_stream", false); + } +} diff --git a/browser/components/newtab/lib/cache.worker.js b/browser/components/newtab/lib/cache.worker.js new file mode 100644 index 0000000000..1195da05fa --- /dev/null +++ b/browser/components/newtab/lib/cache.worker.js @@ -0,0 +1,203 @@ +/* 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/. */ + +/* global ReactDOMServer, NewtabRenderUtils */ + +const PAGE_TEMPLATE_RESOURCE_PATH = + "resource://activity-stream/data/content/abouthomecache/page.html.template"; +const SCRIPT_TEMPLATE_RESOURCE_PATH = + "resource://activity-stream/data/content/abouthomecache/script.js.template"; + +// If we don't stub these functions out, React throws warnings in the console +// upon being loaded. +let window = self; +window.requestAnimationFrame = () => {}; +window.cancelAnimationFrame = () => {}; +window.ASRouterMessage = () => { + return Promise.resolve(); +}; +window.ASRouterAddParentListener = () => {}; +window.ASRouterRemoveParentListener = () => {}; + +/* import-globals-from /toolkit/components/workerloader/require.js */ +importScripts("resource://gre/modules/workers/require.js"); + +{ + let oldChromeUtils = ChromeUtils; + + // ChromeUtils is defined inside of a Worker, but we don't want the + // activity-stream.bundle.js to detect it when loading, since that results + // in it attempting to import JSMs on load, which is not allowed in + // a Worker. So we temporarily clear ChromeUtils so that activity-stream.bundle.js + // thinks its being loaded in content scope. + // + // eslint-disable-next-line no-implicit-globals, no-global-assign + ChromeUtils = undefined; + + /* import-globals-from ../vendor/react.js */ + /* import-globals-from ../vendor/react-dom.js */ + /* import-globals-from ../vendor/react-dom-server.js */ + /* import-globals-from ../vendor/redux.js */ + /* import-globals-from ../vendor/react-transition-group.js */ + /* import-globals-from ../vendor/prop-types.js */ + /* import-globals-from ../vendor/react-redux.js */ + /* import-globals-from ../data/content/activity-stream.bundle.js */ + importScripts( + "resource://activity-stream/vendor/react.js", + "resource://activity-stream/vendor/react-dom.js", + "resource://activity-stream/vendor/react-dom-server.js", + "resource://activity-stream/vendor/redux.js", + "resource://activity-stream/vendor/react-transition-group.js", + "resource://activity-stream/vendor/prop-types.js", + "resource://activity-stream/vendor/react-redux.js", + "resource://activity-stream/data/content/activity-stream.bundle.js" + ); + + // eslint-disable-next-line no-global-assign, no-implicit-globals + ChromeUtils = oldChromeUtils; +} + +let PromiseWorker = require("resource://gre/modules/workers/PromiseWorker.js"); + +let Agent = { + _templates: null, + + /** + * Synchronously loads the template files off of the file + * system, and returns them as an object. If the Worker has loaded + * these templates before, a cached copy of the templates is returned + * instead. + * + * @return Object + * An object with the following properties: + * + * pageTemplate (String): + * The template for the document markup. + * + * scriptTempate (String): + * The template for the script. + */ + getOrCreateTemplates() { + if (this._templates) { + return this._templates; + } + + const templateResources = new Map([ + ["pageTemplate", PAGE_TEMPLATE_RESOURCE_PATH], + ["scriptTemplate", SCRIPT_TEMPLATE_RESOURCE_PATH], + ]); + + this._templates = {}; + + for (let [name, path] of templateResources) { + const xhr = new XMLHttpRequest(); + // Using a synchronous XHR in a worker is fine. + xhr.open("GET", path, false); + xhr.responseType = "text"; + xhr.send(null); + this._templates[name] = xhr.responseText; + } + + return this._templates; + }, + + /** + * Constructs the cached about:home document using ReactDOMServer. This will + * be called when "construct" messages are sent to this PromiseWorker. + * + * @param state (Object) + * The most recent Activity Stream Redux state. + * @return Object + * An object with the following properties: + * + * page (String): + * The generated markup for the document. + * + * script (String): + * The generated script for the document. + */ + construct(state) { + // If anything in this function throws an exception, PromiseWorker + // runs the risk of leaving the Promise associated with this method + // forever unresolved. This is particularly bad when this method is + // called via AsyncShutdown, since the forever unresolved Promise can + // result in a AsyncShutdown timeout crash. + // + // To help ensure that no matter what, the Promise resolves with something, + // we wrap the whole operation in a try/catch. + try { + return this._construct(state); + } catch (e) { + console.error("about:home startup cache construction failed:", e); + return { page: null, script: null }; + } + }, + + /** + * Internal method that actually does the work of constructing the cached + * about:home document using ReactDOMServer. This should be called from + * `construct` only. + * + * @param state (Object) + * The most recent Activity Stream Redux state. + * @return Object + * An object with the following properties: + * + * page (String): + * The generated markup for the document. + * + * script (String): + * The generated script for the document. + */ + _construct(state) { + state.App.isForStartupCache = true; + + // ReactDOMServer.renderToString expects a Redux store to pull + // the state from, so we mock out a minimal store implementation. + let fakeStore = { + getState() { + return state; + }, + dispatch() {}, + }; + + let markup = ReactDOMServer.renderToString( + NewtabRenderUtils.NewTab({ + store: fakeStore, + isFirstrun: false, + }) + ); + + let { pageTemplate, scriptTemplate } = this.getOrCreateTemplates(); + let cacheTime = new Date().toUTCString(); + let page = pageTemplate + .replace("{{ MARKUP }}", markup) + .replace("{{ CACHE_TIME }}", cacheTime); + let script = scriptTemplate.replace( + "{{ STATE }}", + JSON.stringify(state, null, "\t") + ); + + return { page, script }; + }, +}; + +// This boilerplate connects the PromiseWorker to the Agent so +// that messages from the main thread map to methods on the +// Agent. +let worker = new PromiseWorker.AbstractWorker(); +worker.dispatch = function (method, args = []) { + return Agent[method](...args); +}; +worker.postMessage = function (result, ...transfers) { + self.postMessage(result, ...transfers); +}; +worker.close = function () { + self.close(); +}; + +self.addEventListener("message", msg => worker.handleMessage(msg)); +self.addEventListener("unhandledrejection", function (error) { + throw error.reason; +}); diff --git a/browser/components/newtab/loaders/inject-loader.js b/browser/components/newtab/loaders/inject-loader.js new file mode 100644 index 0000000000..8729baf270 --- /dev/null +++ b/browser/components/newtab/loaders/inject-loader.js @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Note: this is based on https://github.com/plasticine/inject-loader, +// patched to make istanbul work properly + +const loaderUtils = require("loader-utils"); +const QUOTE_REGEX_STRING = "['|\"]{1}"; + +const hasOnlyExcludeFlags = query => + Object.keys(query).filter(key => query[key] === true).length === 0; +const escapePath = path => path.replace("/", "\\/"); + +function createRequireStringRegex(query) { + const regexArray = []; + + // if there is no query then replace everything + if (Object.keys(query).length === 0) { + regexArray.push("([^\\)]+)"); + } else if (hasOnlyExcludeFlags(query)) { + // if there are only negation matches in the query then replace everything + // except them + Object.keys(query).forEach(key => + regexArray.push(`(?!${QUOTE_REGEX_STRING}${escapePath(key)})`) + ); + regexArray.push("([^\\)]+)"); + } else { + regexArray.push(`(${QUOTE_REGEX_STRING}(`); + regexArray.push( + Object.keys(query) + .map(key => escapePath(key)) + .join("|") + ); + regexArray.push(`)${QUOTE_REGEX_STRING})`); + } + + // Wrap the regex to match `require()` + regexArray.unshift("require\\("); + regexArray.push("\\)"); + + return new RegExp(regexArray.join(""), "g"); +} + +module.exports = function inject(src) { + if (this.cacheable) { + this.cacheable(); + } + const regex = createRequireStringRegex( + loaderUtils.urlToRequest(this.resourcePath) || {} + ); + + return `module.exports = function inject(injections) { + var module = {exports: {}}; + var exports = module.exports; + ${src.replace(regex, "(injections[$1] || /* istanbul ignore next */ $&)")} + return module.exports; +}\n`; +}; diff --git a/browser/components/newtab/metrics.yaml b/browser/components/newtab/metrics.yaml new file mode 100644 index 0000000000..04845fcda0 --- /dev/null +++ b/browser/components/newtab/metrics.yaml @@ -0,0 +1,1589 @@ +# 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/. + +# Adding a new metric? We have docs for that! +# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html + +--- +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 +$tags: + - 'Firefox :: New Tab Page' + +newtab: + locale: + type: string + description: > + The application's locale as of when newtab's TelemetryFeed was init. + Comes from `Services.local.appLocaleAsBCP47`. + Looks like `en-US`. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1766887 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786670 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1766887 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786670#c3 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105#c11 + data_sensitivity: + - interaction + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + send_in_pings: + - newtab + lifetime: application + + newtab_category: + type: string + description: > + The current setting of the newtab page. + One of ["enabled", "disabled", "extension"] or any value from + SiteClassifier like "known-hijacker" or "social-media". + Similar to Activity Stream's PAGE_TAKEOVER_DATA event's + `newtab_url_category`. + Sampled once after newtab init. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105#c11 + data_sensitivity: + - technical + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + send_in_pings: + - newtab + lifetime: application + + homepage_category: + type: string + description: > + The current setting of the home page. + One of ["enabled", "disabled", "extension"] or any value from + SiteClassifier like "known-hijacker" or "social-media". + Similar to Activity Stream's PAGE_TAKEOVER_DATA event's + `home_url_category`. + Sampled once after newtab init. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105#c11 + data_sensitivity: + - technical + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + send_in_pings: + - newtab + lifetime: application + + opened: + type: event + description: > + Recorded when newtab UI is opened via `about:newtab` or `about:home` or + `about:welcome` and has been made visible (see `visibility_event_rcvd_ts` + in + [detect-user-session-start.js](https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/lib/detect-user-session-start.js)). + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1766887 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786670 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1766887 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786670#c3 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105#c11 + data_sensitivity: + - interaction + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + extra_keys: + newtab_visit_id: &newtab_visit_id + description: > + The id of this newtab visit. + Allows you to separate multiple simultaneous newtabs and + build an event timeline of actions taken from this newtab. + type: string + source: + description: > + The source that opened this newtab. + One of + * `about:newtab` + * `about:home` + * `about:welcome` + * `other` + (See `ONBOARDING_ALLOWED_PAGE_VALUES`). + type: string + send_in_pings: + - newtab + + closed: + type: event + description: > + Recorded when newtab UI is closed by + * navigation + * closing the tab + + Doesn't mean that the newtab was ever visible to a user. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1766887 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786670 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1766887 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786670#c3 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105#c11 + data_sensitivity: + - interaction + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + extra_keys: + newtab_visit_id: *newtab_visit_id + send_in_pings: + - newtab + + blocked_sponsors: + type: string_list + description: > + The advertiser names that have been dismissed by the user. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1828234 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1828234#c1 + data_sensitivity: + - interaction + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + - ttran@mozilla.com + expires: never + send_in_pings: + - newtab + lifetime: application + + sov_allocation: + type: string_list + description: > + The partner group assignment for sov + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1840311 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1840311#c3 + data_sensitivity: + - technical + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + - ttran@mozilla.com + expires: never + send_in_pings: + - newtab + lifetime: application + +newtab.search: + enabled: + lifetime: application + type: boolean + description: > + Whether the search input is enabled on the newtab. + Corresponds to the value of the + `browser.newtabpage.activity-stream.showSearch` pref. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105#c11 + data_sensitivity: + - technical + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + send_in_pings: + - newtab + +newtab.handoff_preference: + enabled: + lifetime: application + type: boolean + description: > + Records whether the + browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar preference is + enabled or disabled + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1864496 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1864496 + data_sensitivity: + - interaction + expires: 128 + notification_emails: + - fx-search-telemetry@mozilla.com + + +topsites: + enabled: + lifetime: application + type: boolean + description: > + Whether "topsites" is enabled on the newtab. + AKA the "Shortcuts" section. + Corresponds to the value of the + `browser.newtabpage.activity-stream.feeds.topsites` pref. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105#c11 + data_sensitivity: + - technical + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + send_in_pings: + - newtab + + sponsored_enabled: + lifetime: application + type: boolean + description: > + Whether sponsored topsites are enabled on the newtab. + AKA the "Sponsored Shortcuts" section. + Corresponds to the value of the + `browser.newtabpage.activity-stream.showSponsoredTopSites` pref. + Can be `true` even if topsites.enabled is `false`. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105#c11 + data_sensitivity: + - technical + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + send_in_pings: + - newtab + + impression: + type: event + description: > + Recorded when topsite tiles are loaded. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1766887 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786670 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820707 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1766887 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786670#c3 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105#c11 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820707#c3 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1821556#c3 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1824842#c7 + data_sensitivity: + - interaction + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + extra_keys: + advertiser_name: &advertiser_name + description: > + The name of the advertiser of the tile + type: string + tile_id: &tile_id + description: > + The tile id of the advertiser provided by Contile. Like `74357`. + type: quantity + newtab_visit_id: *newtab_visit_id + is_sponsored: &is_sponsored + description: Whether the topsite tile was sponsored. + type: boolean + position: &topsite_position + description: The position (0-index) of the topsite tile. + type: quantity + send_in_pings: + - newtab + + click: + type: event + description: > + Recorded when a topsite tile is clicked. + Only happens on click. Not on middle-click. Not on "Open in new Tab"-like + options in the context menu. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1766887 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786670 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820707 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1766887 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786670#c3 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105#c11 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820707#c3 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1821556#c3 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1824842#c7 + data_sensitivity: + - interaction + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + extra_keys: + advertiser_name: *advertiser_name + tile_id: *tile_id + newtab_visit_id: *newtab_visit_id + is_sponsored: *is_sponsored + position: *topsite_position + send_in_pings: + - newtab + + show_privacy_click: + type: event + description: > + Recorded when the "Our Sponsors and Your Privacy" menu item in the three- + dots menu of a sponsored topsite is clicked. + Corresponds to the receipt of a dispatched `ABOUT_SPONSORED_TOP_SITES` + action by `TelemetryFeed`. + bugs: + - https://mozilla-hub.atlassian.net/browse/DENG-1364 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_sensitivity: [interaction] + notification_emails: + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - sbetancourt@mozilla.com + expires: never + extra_keys: + advertiser_name: *advertiser_name + tile_id: *tile_id + newtab_visit_id: *newtab_visit_id + position: *topsite_position + send_in_pings: + - newtab + + dismiss: + type: event + description: > + Recorded when the "Dismiss" menu item in the three-dots menu of a topsite + is clicked. + Corresponds to the receipt of a dispatched `BLOCK_URL` action by + `TelemetryFeed`. + Applies to both sponsored and non-sponsored topsites. + `advertiser_name` is only provided for sponsored topsites. + bugs: + - https://mozilla-hub.atlassian.net/browse/DENG-1363 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_sensitivity: [interaction] + notification_emails: + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - sbetancourt@mozilla.com + - kdemtchouk@mozilla.com + - mbowerman@mozilla.com + expires: never + extra_keys: + advertiser_name: *advertiser_name + tile_id: *tile_id + newtab_visit_id: *newtab_visit_id + is_sponsored: *is_sponsored + position: *topsite_position + send_in_pings: + - newtab + + pref_changed: + type: event + description: > + Recorded when specific topsites prefs have changed. + + The list of possible prefs is presently: + * browser.newtabpage.activity-stream.feeds.topsites + * browser.newtabpage.activity-stream.showSponsoredTopSites + bugs: + - https://mozilla-hub.atlassian.net/browse/D0-1293 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_sensitivity: [interaction] + notification_emails: + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - sbetancourt@mozilla.com + - kdemtchouk@mozilla.com + - mbowerman@mozilla.com + expires: never + extra_keys: + pref_name: + description: The full name of the pref whose value just changed. + type: string + new_value: + description: The new (current) value the pref just changed to. + type: boolean + send_in_pings: + - newtab + + rows: + lifetime: application + type: quantity + unit: integer + description: > + The number of topsite tile rows configured to be shown on the newtab + page. Corresponds to the value of the + `browser.newtabpage.activity-stream.topSitesRows` pref. This is not the + number of rows actually seen by the user: if the browser window is + partially off-screen, or isn't wide enough to accommodate eight tiles per + row, the actual number of rows may be different. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1821556 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1821556#c3 + data_sensitivity: + - interaction + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + send_in_pings: + - newtab + + sponsored_tiles_configured: + lifetime: application + type: quantity + unit: integer + description: > + The number of topsite tiles configured to be shown on newtab. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1862493 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi + data_sensitivity: + - technical + notification_emails: + - gleonard@mozilla.com + expires: never + send_in_pings: + - newtab + + sponsored_tiles_received: + lifetime: application + type: text + description: > + The stringified JSON of tiles processed for display (array of objects). + Includes tiles not displayed and reason for not displaying. + Fields included: advertiser, provider, display_position, + display_fail_reason. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi + data_sensitivity: + - web_activity + notification_emails: + - gleonard@mozilla.com + expires: never + send_in_pings: + - newtab + +pocket: + is_signed_in: + lifetime: application + type: boolean + description: > + Whether the Firefox user is signed in to Pocket. + Does not correspond to a pref, so its value is resampled at newtab's + component init and whenever there is a Discovery Stream user event. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105#c11 + data_sensitivity: + - technical + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + send_in_pings: + - newtab + + enabled: + lifetime: application + type: boolean + description: > + Whether Pocket is enabled on the newtab. + AKA the "Recommended by Pocket" section. + Corresponds to the value of the + `browser.newtabpage.activity-stream.feeds.section.topstories` pref. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105#c11 + data_sensitivity: + - technical + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + send_in_pings: + - newtab + + sponsored_stories_enabled: + lifetime: application + type: boolean + description: > + Whether Pocket sponsored stories are enabled on the newtab. + Corresponds to the value of the + `browser.newtabpage.activity-stream.showSponsored` pref. + Can be `true` even if pocket.enabled is `false`. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105#c11 + data_sensitivity: + - technical + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + send_in_pings: + - newtab + + impression: + type: event + description: > + Recorded when a pocket tile is visible to the user. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1854245 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1862670 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105#c11 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1854245 + data_sensitivity: + - interaction + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + extra_keys: + newtab_visit_id: *newtab_visit_id + is_sponsored: &is_sponsored_pocket + description: Whether the pocket tile was sponsored (has an ad shim). + type: boolean + position: &pocket_position + description: The position (0-index) of the pocket tile. + type: quantity + recommendation_id: &recommendation_id + description: > + The id from the Pocket API response that returned the recommendation. + Like "{61934fe5-fbb0-4f4e-b9dd-7eab5f6ee9cd}". + type: string + tile_id: &pocket_tile_id + description: > + A content identifier. + For organic Pocket recommendations it is an opaque id produced by + Pocket's recommendation systems. + For sponsored Pocket content it is Kevel's "ad ID". + type: quantity + send_in_pings: + - newtab + + click: + type: event + description: > + Recorded when a pocket tile is clicked. + Only happens on click. Not on middle-click. Not on "Open in new Tab"-like + options in the context menu. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1854245 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1862670 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105#c11 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1854245 + data_sensitivity: + - interaction + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + extra_keys: + newtab_visit_id: *newtab_visit_id + is_sponsored: *is_sponsored_pocket + position: *pocket_position + recommendation_id: *recommendation_id + tile_id: *pocket_tile_id + send_in_pings: + - newtab + + save: + type: event + description: > + Recorded when a user decides to save a pocket tile. + Does not mean it ends up successfully saved. + Just that the user clicked on "Save to Pocket" in the little pocket + tile menu. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1854245 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1862670 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105#c11 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1854245 + data_sensitivity: + - interaction + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + extra_keys: + newtab_visit_id: *newtab_visit_id + is_sponsored: *is_sponsored_pocket + position: *pocket_position + recommendation_id: *recommendation_id + tile_id: *pocket_tile_id + send_in_pings: + - newtab + + topic_click: + type: event + description: > + Recorded when a pocket "Popular Topic" is clicked. + Only happens on click. Not on middle-click. Not on "Open in new Tab"-like + options in the context menu. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1854245 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786612 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105#c11 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1854245 + data_sensitivity: + - interaction + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + extra_keys: + newtab_visit_id: *newtab_visit_id + topic: + description: The topic that was clicked on. Like "entertainment". + type: string + send_in_pings: + - newtab + + shim: + type: text + lifetime: ping + description: | + Opaque partner identifier for a given ad impression or engagement action, + unique per market and region. + Pocket + [proxies requests to ad partners](https://github.com/Pocket/proxy-server/) + and provides them solely with market, region, and action to generate these + shims. Thus, though the contents of this field are obscure, they cannot + identify clients. + At time of writing this information is a comma-separated trio. + The first item is an index into the proxy server's list of acceptable http + endpoints for contacting the ad service. The second item is a + several-hundred-byte base64-encoded JSON-encoded struct with fields for, + amongst other things, market and region. The third is unknown, but appears + to be a signature or checksum. + This shim should not be sent with the client_id. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1862670 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1862670 + data_sensitivity: + - stored_content # Required for text type, and to encourage scrutiny + notification_emails: + - chutten@mozilla.com + - najiang@mozilla.com + expires: never + send_in_pings: + - spoc + + +messaging_system: + event_context_parse_error: + type: counter + lifetime: ping + description: | + How often we failed to parse event_context as JSON. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - technical + notification_emails: + - dmosedale@mozilla.com + - pmcmanis@mozilla.com + expires: never + send_in_pings: + - messaging-system + + event_reason: + type: string + lifetime: ping + description: | + The event_context's `reason`. Likely something like + "welcome-window-closed" or "app-shut-down",. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - interaction + notification_emails: + - dmosedale@mozilla.com + - pmcmanis@mozilla.com + expires: never + send_in_pings: + - messaging-system + + event_page: + type: string + lifetime: ping + description: | + The event_context's `page`. Almost always "about:welcome". + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - interaction + notification_emails: + - dmosedale@mozilla.com + - pmcmanis@mozilla.com + expires: never + send_in_pings: + - messaging-system + + event_source: + type: string + lifetime: ping + description: | + The event_context's `source`. Likely something like "primary_button". + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - interaction + notification_emails: + - dmosedale@mozilla.com + - pmcmanis@mozilla.com + expires: never + send_in_pings: + - messaging-system + + event_context: + type: text + lifetime: ping + description: | + The stringified JSON of `event_context`. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - web_activity + notification_emails: + - dmosedale@mozilla.com + - pmcmanis@mozilla.com + expires: never + send_in_pings: + - messaging-system + + event_screen_family: + type: text + lifetime: ping + description: | + A string identifier of the message family derived from the message id + (e.g. MR_WELCOME_DEFAULT). + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1867627 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - web_activity + notification_emails: + - pmcmanis@mozilla.com + - dmosedale@mozilla.com + - nsauermann@mozilla.com + expires: never + send_in_pings: + - messaging-system + + event_screen_id: + type: text + lifetime: ping + description: | + A string identifier of the message screen id + (e.g. AW_MOBILE_DOWNLOAD). + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1867627 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - web_activity + notification_emails: + - pmcmanis@mozilla.com + - dmosedale@mozilla.com + - nsauermann@mozilla.com + expires: never + send_in_pings: + - messaging-system + + event_screen_initials: + type: text + lifetime: ping + description: | + A string identifier of the message screen initials + (e.g. 'EMAG' for EASY_SETUP, MOBILE_DOWNLOADS, AMO, GRATITUDE). + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1867627 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - web_activity + notification_emails: + - pmcmanis@mozilla.com + - dmosedale@mozilla.com + - nsauermann@mozilla.com + expires: never + send_in_pings: + - messaging-system + + event_screen_index: + type: quantity + unit: integer + lifetime: ping + description: | + A number identifier of the screen index in a sequence of screens + (e.g. 0 for first message). + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - web_activity + notification_emails: + - pmcmanis@mozilla.com + - dmosedale@mozilla.com + - nsauermann@mozilla.com + expires: never + send_in_pings: + - messaging-system + + message_id: + type: text + lifetime: ping + description: | + A string identifier of the message in Activity Stream Router. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - web_activity + notification_emails: + - pmcmanis@mozilla.com + - dmosedale@mozilla.com + expires: never + send_in_pings: + - messaging-system + + event: + type: string + description: > + The type of event. Any user defined string + (e.g. “IMPRESSION”, “CLICK_BUTTON”, "INDEXEDDB_OPEN_FAILED", “SESSION_END”) + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - interaction + notification_emails: + - pmcmanis@mozilla.com + - dmosedale@mozilla.com + expires: never + send_in_pings: + - messaging-system + + ping_type: + type: string + description: > + Type of event the ping is capturing. + e.g. "cfr", "whats-new-panel", "onboarding" + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - interaction + notification_emails: + - pmcmanis@mozilla.com + - dmosedale@mozilla.com + expires: never + send_in_pings: + - messaging-system + + source: + type: string + description: > + The source of the interaction described by the other metrics. + e.g. "frecent_links", "newtab", "CFR" + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - interaction + notification_emails: + - pmcmanis@mozilla.com + - dmosedale@mozilla.com + expires: never + send_in_pings: + - messaging-system + + client_id: + type: uuid + lifetime: ping + description: | + The client_id according to Telemetry. + Might not always have a value due to policy around specific types of + ping being sent. Value may be the canary client id + `c0ffeec0-ffee-c0ff-eec0-ffeec0ffeec0` + in pings near when the data upload pref is disabled (if Telemetry gets + to go first), or between when a client_id has been removed and when it + has been regenerated. + Present only in some circumstances (see + [bug 1484035]https://bugzilla.mozilla.org/show_bug.cgi?id=1484035)). + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1755549 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1484035 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1755549 + data_sensitivity: + - technical + notification_emails: + - dmosedale@mozilla.com + - pmcmanis@mozilla.com + expires: never + send_in_pings: + - messaging-system + + locale: + type: string + lifetime: ping + description: > + The locale as supplied to the messaging system by + `Services.locale.appLocaleAsBCP47`. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - technical + notification_emails: + - pmcmanis@mozilla.com + - dmosedale@mozilla.com + expires: never + send_in_pings: + - messaging-system + + browser_session_id: + type: uuid + lifetime: ping + # Disable yamllint for long lines. + # yamllint disable + description: > + The Legacy Telemetry browser "session id". + Identifies a specific period from application start to shutdown. + See [the "main" ping docs](https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/data/main-ping.html) + for details. + # yamllint enable + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - technical + notification_emails: + - pmcmanis@mozilla.com + - dmosedale@mozilla.com + expires: never + send_in_pings: + - messaging-system + + impression_id: + type: uuid + lifetime: ping + description: > + The unique impression identifier for a specific client. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - technical + notification_emails: + - pmcmanis@mozilla.com + - dmosedale@mozilla.com + expires: never + send_in_pings: + - messaging-system + + bucket_id: + type: string + lifetime: ping + description: > + A name shared between multiple messages that may individually be too + targetted. + e.g. a message that gets shown on specific websites or a message asking + about personal information. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - technical + notification_emails: + - pmcmanis@mozilla.com + - dmosedale@mozilla.com + expires: never + send_in_pings: + - messaging-system + + addon_version: + type: string + lifetime: ping + description: > + Used to hold the system addon's version, + now is almost certainly an echo of the app's build id. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - technical + notification_emails: + - pmcmanis@mozilla.com + - dmosedale@mozilla.com + expires: never + send_in_pings: + - messaging-system + + unknown_key_count: + type: counter + description: | + The sum of all unknown keys counted. + Useful for testing. + Can be removed after bug 1600008 is resolved. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - technical + notification_emails: + - chutten@mozilla.com + expires: never + send_in_pings: + - messaging-system + + unknown_keys: + type: labeled_counter + description: | + Ping keys supplied to the messaging system for which + we did not have a corresponding metric mapped to how often they attempted + to be recorded. + You may have forgotten to define an appropriate metric in + `browser/components/newtab/metrics.yaml`. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - technical + notification_emails: + - dmosedale@mozilla.com + - pmcmanis@mozilla.com + expires: never + send_in_pings: + - messaging-system + + glean_ping_for_ping_failures: + type: counter + description: | + How often something went awry within + `AboutWelcome.submitGleanPingForPing`, preventing ping submission. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - technical + notification_emails: + - dmosedale@mozilla.com + - pmcmanis@mozilla.com + - chutten@mozilla.com + expires: never + send_in_pings: + - metrics + + invalid_nested_data: + type: labeled_counter + description: | + We received a ping with non-scalar data on a field of this name. + If this is existing pre-PingCentre-replacement data, you may need to + augment the logic in + `AboutWelcome.submitGleanPingForPing` like the other `handledKeys`. + If this is for new, post-PingCentre-replacement data, you should + probably prefer a flat structure. + If you're unsure, please ask in + [the #glean channel](https://chat.mozilla.org/#/room/#glean:mozilla.org). + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - technical + notification_emails: + - dmosedale@mozilla.com + - pmcmanis@mozilla.com + - chutten@mozilla.com + expires: never + send_in_pings: + - messaging-system + + +messaging_system.attribution: + source: + type: string + lifetime: ping + description: | + Attribution's source, possibly derived from the utm parameter of the same + name. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - interaction + notification_emails: + - dmosedale@mozilla.com + - pmcmanis@mozilla.com + expires: never + send_in_pings: + - messaging-system + + medium: + type: string + lifetime: ping + description: | + Attribution's medium, possibly derived from the utm parameter of the same + name. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - interaction + notification_emails: + - dmosedale@mozilla.com + - pmcmanis@mozilla.com + expires: never + send_in_pings: + - messaging-system + + campaign: + type: string + lifetime: ping + description: | + Attribution's campaign, possibly derived from the utm parameter of the + same name. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - interaction + notification_emails: + - dmosedale@mozilla.com + - pmcmanis@mozilla.com + expires: never + send_in_pings: + - messaging-system + + content: + type: string + lifetime: ping + description: | + Attribution's content, possibly derived from the utm parameter of the + same name. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - interaction + notification_emails: + - dmosedale@mozilla.com + - pmcmanis@mozilla.com + expires: never + send_in_pings: + - messaging-system + + experiment: + type: string + lifetime: ping + description: | + Attribution's experiment key. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - interaction + notification_emails: + - dmosedale@mozilla.com + - pmcmanis@mozilla.com + expires: never + send_in_pings: + - messaging-system + + variation: + type: string + lifetime: ping + description: | + Attribution's variation key. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - interaction + notification_emails: + - dmosedale@mozilla.com + - pmcmanis@mozilla.com + expires: never + send_in_pings: + - messaging-system + + ua: + type: string + lifetime: ping + description: | + Attribution's ua key. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - interaction + notification_emails: + - dmosedale@mozilla.com + - pmcmanis@mozilla.com + expires: never + send_in_pings: + - messaging-system + + dltoken: + type: string + lifetime: ping + description: | + String representation of the dltoken identifying the particular + installer used to install this Firefox. + Likely a UUID, if present. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - interaction + notification_emails: + - dmosedale@mozilla.com + - pmcmanis@mozilla.com + expires: never + send_in_pings: + - messaging-system + + msstoresignedin: + type: string + lifetime: ping + description: | + Either the string "true" or the string "false" to indicate whether the + attributed install came from the Microsoft store and, if so, whether the + user was signed in at the time. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1756209 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1756209 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - interaction + notification_emails: + - dmosedale@mozilla.com + - pmcmanis@mozilla.com + expires: never + send_in_pings: + - messaging-system + + dlsource: + type: string + lifetime: ping + description: | + Mozilla-specific download "source" name. Could be something like + "mozillaci" to identify that the installer came from + `{archive|ftp}.mozilla.org`. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1819997 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1819997 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - interaction + notification_emails: + - dmosedale@mozilla.com + - pmcmanis@mozilla.com + expires: never + send_in_pings: + - messaging-system + + unknown_keys: + type: labeled_counter + description: | + Attribution keys supplied to the messaging system for which + we did not have a corresponding metric, and the count of how + often that happened. + Either add this key to a list of known attribution keys in + `AboutWelcomeTelemetry` to suppress or define an appropriate metric in + `browser/components/newtab/metrics.yaml` to collect. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_sensitivity: + - technical + notification_emails: + - dmosedale@mozilla.com + - pmcmanis@mozilla.com + expires: never + send_in_pings: + - messaging-system + +top_sites: # Replacement for PingCentre "topsites-impression|click" pings. + ping_type: + type: string + description: > + The ping's type. In other situations might be designated by an event's + name or an interaction field. E.g. "topsites-impression", + "topsites-click". + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1836283 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1836283 + data_sensitivity: + - interaction + notification_emails: + - najiang@mozilla.com + expires: never + send_in_pings: + - top-sites + + position: + type: quantity + unit: topsite position + description: > + The position (1-based) of the topsites item being interatcted with. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1836283 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1836283 + data_sensitivity: + - interaction + notification_emails: + - najiang@mozilla.com + expires: never + send_in_pings: + - top-sites + + source: + type: string + description: > + The source of the interaction. Always set to "newtab". + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1836283 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1836283 + data_sensitivity: + - interaction + notification_emails: + - najiang@mozilla.com + expires: never + send_in_pings: + - top-sites + + tile_id: + type: string + description: > + String-encoded number for the tile's sponsored tile id. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1836283 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1836283 + data_sensitivity: + - interaction + notification_emails: + - najiang@mozilla.com + expires: never + send_in_pings: + - top-sites + + reporting_url: + type: url + description: > + The url to report this interaction to. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1836283 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1836283 + data_sensitivity: + - web_activity + notification_emails: + - najiang@mozilla.com + expires: never + send_in_pings: + - top-sites + + advertiser: + type: string + description: > + The name of the advertiser providing the sponsored TopSite. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1836283 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1836283 + data_sensitivity: + - interaction + - web_activity + notification_emails: + - najiang@mozilla.com + expires: never + send_in_pings: + - top-sites + + context_id: + type: uuid + description: > + An identifier to identify users for Contextual Services user interaction pings. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1836283 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1836283 + data_sensitivity: + - technical + notification_emails: + - najiang@mozilla.com + expires: never + send_in_pings: + - top-sites diff --git a/browser/components/newtab/moz.build b/browser/components/newtab/moz.build new file mode 100644 index 0000000000..0d3bddb968 --- /dev/null +++ b/browser/components/newtab/moz.build @@ -0,0 +1,35 @@ +# -*- 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/. + +with Files("**"): + BUG_COMPONENT = ("Firefox", "New Tab Page") + +BROWSER_CHROME_MANIFESTS += [ + "test/browser/abouthomecache/browser.toml", + "test/browser/browser.toml", +] + +SPHINX_TREES["docs"] = "docs" + +XPCSHELL_TESTS_MANIFESTS += [ + "test/xpcshell/xpcshell.toml", +] + +XPIDL_SOURCES += [ + "nsIAboutNewTabService.idl", +] + +XPIDL_MODULE = "browser-newtab" + +EXTRA_JS_MODULES += [ + "AboutNewTabService.sys.mjs", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +JAR_MANIFESTS += ["jar.mn"] diff --git a/browser/components/newtab/nsIAboutNewTabService.idl b/browser/components/newtab/nsIAboutNewTabService.idl new file mode 100644 index 0000000000..e8ddd0381f --- /dev/null +++ b/browser/components/newtab/nsIAboutNewTabService.idl @@ -0,0 +1,39 @@ +/* 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 "nsISupports.idl" + +/** + * Allows to override about:newtab to point to a different location + * than the one specified within AboutRedirector.cpp + */ + +interface nsIChannel; +interface nsIURI; +interface nsILoadInfo; + +[scriptable, uuid(dfcd2adc-7867-4d3a-ba70-17501f208142)] +interface nsIAboutNewTabService : nsISupports +{ + /** + * Returns the default URL (local or activity stream depending on pref) + */ + readonly attribute ACString defaultURL; + + /** + * In the "privileged about content process", if about:home is being + * retrieved, the AboutRedirector will call this function to get the + * nsIChannel for the document. This gives the nsIAboutNewTabService + * the opportunity to provide a cached document for about:home. If + * no cache exists, the nsIChannel will be for the normal dynamically + * generated about:home document. + */ + nsIChannel aboutHomeChannel(in nsIURI aURI, + in nsILoadInfo aLoadInfo); + + /** + * Returns the about:welcome URL. + */ + readonly attribute ACString welcomeURL; +}; diff --git a/browser/components/newtab/package-lock.json b/browser/components/newtab/package-lock.json new file mode 100644 index 0000000000..7a9fead1b8 --- /dev/null +++ b/browser/components/newtab/package-lock.json @@ -0,0 +1,12455 @@ +{ + "name": "activity-streams", + "version": "1.14.3", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "activity-streams", + "version": "1.14.3", + "license": "MPL-2.0", + "dependencies": { + "@fluent/bundle": "0.17.1", + "@fluent/react": "0.15.0", + "react": "16.13.1", + "react-dom": "16.13.1", + "react-redux": "7.2.6", + "react-transition-group": "4.4.2", + "redux": "4.1.2" + }, + "devDependencies": { + "@babel/core": "7.23.5", + "@babel/preset-react": "7.23.3", + "@jsdevtools/coverage-istanbul-loader": "^3.0.5", + "acorn": "8.5.0", + "babel-loader": "8.2.3", + "babel-plugin-jsm-to-esmodules": "0.6.0", + "buffer": "6.0.3", + "chai": "4.3.4", + "enzyme": "3.11.0", + "enzyme-adapter-react-16": "1.15.6", + "joi-browser": "13.4.0", + "karma": "6.4.2", + "karma-chai": "0.1.0", + "karma-coverage-istanbul-reporter": "3.0.3", + "karma-firefox-launcher": "2.1.2", + "karma-json-reporter": "1.2.1", + "karma-mocha": "2.0.1", + "karma-mocha-reporter": "2.2.5", + "karma-sinon": "1.0.5", + "karma-sourcemap-loader": "0.3.8", + "karma-webpack": "5.0.0", + "loader-utils": "3.2.1", + "lodash": "4.17.21", + "mocha": "9.2.2", + "mock-raf": "1.0.1", + "npm-run-all": "4.1.5", + "postcss-scss": "4.0.6", + "prop-types": "15.7.2", + "raw-loader": "4.0.2", + "rimraf": "3.0.2", + "sass": "1.43.4", + "shelljs": "0.8.5", + "sinon": "12.0.1", + "stream-browserify": "3.0.0", + "util": "0.10.4", + "webpack": "5.89.0", + "webpack-cli": "4.9.1", + "yamscripts": "0.1.0" + }, + "engines": { + "//": "when changing node versions, also edit .nvmrc", + "firefox": ">=45.0 <=*", + "node": "16.19.*", + "npm": "8.19.3" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", + "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", + "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.5.tgz", + "integrity": "sha512-Cwc2XjUrG4ilcfOw4wBAK+enbdgwAcAJCfGUItPBKR7Mjw4aEfAFYrLxeRp4jWgtNIKn3n2AlBOfwwafl+42/g==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.5", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.23.5", + "@babel/parser": "^7.23.5", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.5", + "@babel/types": "^7.23.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", + "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.23.6", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", + "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", + "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-validator-option": "^7.23.5", + "browserslist": "^4.22.2", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", + "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.23.8", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.8.tgz", + "integrity": "sha512-KDqYz4PiOWvDFrdHLPhKtCThtIcKVy6avWD2oG4GEvyQ+XDZwHD4YQd+H2vNMnq2rkdxsDkU82T+Vk8U/WXHRQ==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", + "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.23.3.tgz", + "integrity": "sha512-EB2MELswq55OHUoRZLGg/zC7QWUKfNLpE57m/S2yr1uEneIgsTgrSzXP3NXEsMkVn76OlaVVnzN+ugObuYGwhg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.23.3.tgz", + "integrity": "sha512-GnvhtVfA2OAtzdX58FJxU19rhoGeQzyVndw3GgtdECQvQFXPEZIOVULHVZGAYmOgmqjXpVpfocAbSjh99V/Fqw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.23.4.tgz", + "integrity": "sha512-5xOpoPguCZCRbo/JeHlloSkTA8Bld1J/E1/kLfD1nsuiW1m8tduTA1ERCgIZokDflX/IBzKcqR3l7VlRgiIfHA==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-jsx": "^7.23.3", + "@babel/types": "^7.23.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.22.5.tgz", + "integrity": "sha512-bDhuzwWMuInwCYeDeMzyi7TaBgRQei6DqxhbyniL7/VG4RSS7HtSL2QbY4eESy1KJqlWt8g3xeEBGPuo+XqC8A==", + "dev": true, + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.23.3.tgz", + "integrity": "sha512-qMFdSS+TUhB7Q/3HVPnEdYJDQIk57jkntAwSuz9xfSE4n+3I+vHYCli3HoHawN1Z3RfCz/y1zXA/JXjG6cVImQ==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.23.3.tgz", + "integrity": "sha512-tbkHOS9axH6Ysf2OUEqoSZ6T3Fa2SrNH6WTWSPBboxKzdxNc9qOICeLXkNG0ZEwbQ1HY8liwOce4aN/Ceyuq6w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-validator-option": "^7.22.15", + "@babel/plugin-transform-react-display-name": "^7.23.3", + "@babel/plugin-transform-react-jsx": "^7.22.15", + "@babel/plugin-transform-react-jsx-development": "^7.22.5", + "@babel/plugin-transform-react-pure-annotations": "^7.23.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.23.8", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.8.tgz", + "integrity": "sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.7.tgz", + "integrity": "sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.6", + "@babel/types": "^7.23.6", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", + "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@fluent/bundle": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@fluent/bundle/-/bundle-0.17.1.tgz", + "integrity": "sha512-CRFNT9QcSFAeFDneTF59eyv3JXFGhIIN4boUO2y22YmsuuKLyDk+N1I/NQUYz9Ab63e6V7T6vItoZIG/2oOOuw==", + "engines": { + "node": ">=12.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@fluent/react": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@fluent/react/-/react-0.15.0.tgz", + "integrity": "sha512-qUMfaHman+UciOELQc5hnFAv0VerUR6+9gEBCRk9RR66XS13syt91ZElNOTHWe2Ofv70cxAGaJ5Yff4MRPg5Ow==", + "dependencies": { + "@fluent/sequence": "^0.8.0", + "cached-iterable": "^0.3.0" + }, + "engines": { + "node": ">=14.0.0", + "npm": ">=7.0.0" + }, + "peerDependencies": { + "@fluent/bundle": ">=0.16.0 <0.18.0", + "react": ">=16.8.0" + } + }, + "node_modules/@fluent/sequence": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@fluent/sequence/-/sequence-0.8.0.tgz", + "integrity": "sha512-eV5QlEEVV/wR3AFQLXO67x4yPRPQXyqke0c8yucyMSeW36B3ecZyVFlY1UprzrfFV8iPJB4TAehDy/dLGbvQ1Q==", + "engines": { + "node": ">=14.0.0", + "npm": ">=7.0.0" + }, + "peerDependencies": { + "@fluent/bundle": ">= 0.13.0" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", + "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.21", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.21.tgz", + "integrity": "sha512-SRfKmRe1KvYnxjEMtxEr+J4HIeMX5YBg/qhRHpxEIGjhX1rshcHlnFUE9K0GazhVKWM7B+nARSkV8LuvJdJ5/g==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsdevtools/coverage-istanbul-loader": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@jsdevtools/coverage-istanbul-loader/-/coverage-istanbul-loader-3.0.5.tgz", + "integrity": "sha512-EUCPEkaRPvmHjWAAZkWMT7JDzpw7FKB00WTISaiXsbNOd5hCHg77XLA8sLYLFDo1zepYLo2w7GstN8YBqRXZfA==", + "dev": true, + "dependencies": { + "convert-source-map": "^1.7.0", + "istanbul-lib-instrument": "^4.0.3", + "loader-utils": "^2.0.0", + "merge-source-map": "^1.1.0", + "schema-utils": "^2.7.0" + } + }, + "node_modules/@jsdevtools/coverage-istanbul-loader/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true + }, + "node_modules/@jsdevtools/coverage-istanbul-loader/node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", + "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-6.1.3.tgz", + "integrity": "sha512-nhOb2dWPeb1sd3IQXL/dVPnKHDOAFfvichtBf4xV00/rU1QbPCQqKMbvIheIjqwVjh7qIgf2AHTHi391yMOMpQ==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.6.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==", + "dev": true + }, + "node_modules/@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", + "dev": true + }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/eslint": { + "version": "8.56.2", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.2.tgz", + "integrity": "sha512-uQDwm1wFHmbBbCZCqAlq6Do9LYwByNZHWzXppSnay9SuwJ+VRbjkbLABer54kcPnMSlG6Fdiy2yaFXm/z9Z5gw==", + "dev": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.11.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz", + "integrity": "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.11", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" + }, + "node_modules/@types/react": { + "version": "18.2.47", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.47.tgz", + "integrity": "sha512-xquNkkOirwyCgoClNk85BjP+aqnIS+ckAJ8i37gAbDs14jfW/J23f2GItAf33oiUPQnqNMALiFeoM9Y5mbjpVQ==", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-redux": { + "version": "7.1.33", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.33.tgz", + "integrity": "sha512-NF8m5AjWCkert+fosDsN3hAlHzpjSiXlVy9EgQEmLoBhaNXbmyeGs/aj5dQzKuF+/q+S7JQagorGDW8pJ28Hmg==", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" + }, + "node_modules/@ungap/promise-all-settled": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", + "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", + "dev": true + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", + "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", + "dev": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", + "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", + "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", + "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", + "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", + "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", + "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/helper-wasm-section": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-opt": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6", + "@webassemblyjs/wast-printer": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", + "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", + "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", + "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", + "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.2.0.tgz", + "integrity": "sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==", + "dev": true, + "peerDependencies": { + "webpack": "4.x.x || 5.x.x", + "webpack-cli": "4.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.5.0.tgz", + "integrity": "sha512-e8tSXZpw2hPl2uMJY6fsMswaok5FdlGNRTktvFk2sD8RjH0hE2+XistawJx1vmKteh4NmGmNUrp+Tb2w+udPcQ==", + "dev": true, + "dependencies": { + "envinfo": "^7.7.3" + }, + "peerDependencies": { + "webpack-cli": "4.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.7.0.tgz", + "integrity": "sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==", + "dev": true, + "peerDependencies": { + "webpack-cli": "4.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.5.0.tgz", + "integrity": "sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-assertions": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", + "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "dev": true, + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/airbnb-prop-types": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.16.0.tgz", + "integrity": "sha512-7WHOFolP/6cS96PhKNrslCLMYAI8yB1Pp6u6XmxozQOiZbsI5ycglZr5cHhBFfuRcQQjzCMith5ZPZdYiJCxUg==", + "dev": true, + "dependencies": { + "array.prototype.find": "^2.1.1", + "function.prototype.name": "^1.1.2", + "is-regex": "^1.1.0", + "object-is": "^1.1.2", + "object.assign": "^4.1.0", + "object.entries": "^1.1.2", + "prop-types": "^15.7.2", + "prop-types-exact": "^1.2.0", + "react-is": "^16.13.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + }, + "peerDependencies": { + "react": "^0.14 || ^15.0.0 || ^16.0.0-alpha" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", + "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", + "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "is-array-buffer": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.filter": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array.prototype.filter/-/array.prototype.filter-1.0.3.tgz", + "integrity": "sha512-VizNcj/RGJiUyQBgzwxzE5oHdeuXY5hSbbmKMlphj1cy1Vl7Pn2asCGbSrru6hSQjmCzqTBPVWAF/whmEOVHbw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-array-method-boxes-properly": "^1.0.0", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.find": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.2.2.tgz", + "integrity": "sha512-DRumkfW97iZGOfn+lIXbkVrXL04sfYKX+EfOodo8XboR5sxPDVvOjZTF/rysusa9lmhmSOeD6Vp6RKQP+eP4Tg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", + "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-loader": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.3.tgz", + "integrity": "sha512-n4Zeta8NC3QAsuyiizu0GkmRcQ6clkV9WFUnUf1iXP//IeSKbWjofW3UHyZVwlOB4y039YQKefawyTn64Zwbuw==", + "dev": true, + "dependencies": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^1.4.0", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + }, + "engines": { + "node": ">= 8.9" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "webpack": ">=2" + } + }, + "node_modules/babel-loader/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/babel-loader/node_modules/loader-utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz", + "integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==", + "dev": true, + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/babel-plugin-jsm-to-esmodules": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jsm-to-esmodules/-/babel-plugin-jsm-to-esmodules-0.6.0.tgz", + "integrity": "sha512-463Yuq2sLkjoGHl5vPYUQQONnDjxnmxZuhsR1swL5N76hDFGyYZAVd6HoS4E02jBF8bORpS4aFmdr1XjEZ0buQ==", + "dev": true + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "dev": true, + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/body-parser": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, + "node_modules/browserslist": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", + "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001565", + "electron-to-chromium": "^1.4.601", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.13" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cached-iterable": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/cached-iterable/-/cached-iterable-0.3.0.tgz", + "integrity": "sha512-MDqM6TpBVebZD4UDtmlFp8EjVtRcsB6xt9aRdWymjk0fWVUUGgmt/V7o0H0gkI2Tkvv8B0ucjidZm4mLosdlWw==", + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/call-bind": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001576", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001576.tgz", + "integrity": "sha512-ff5BdakGe2P3SQsMsiqmt1Lc8221NR1VzHj5jXN5vBny9A6fpze94HiVV/n7XRosOlsShJcvMv5mdnpjOGCEgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chai": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.4.tgz", + "integrity": "sha512-yS5H68VYOCtN1cjfwumDSuzn/9c+yza4f3reKXlE5rUg7SFcCEy90gJvydNgOYtblyf4Zi6jIWRnXOgErta0KA==", + "dev": true, + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.2", + "deep-eql": "^3.0.1", + "get-func-name": "^2.0.0", + "pathval": "^1.1.1", + "type-detect": "^4.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "dev": true, + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cors/node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/cross-spawn/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/custom-event": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", + "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", + "dev": true + }, + "node_modules/date-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", + "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", + "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "dev": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/di": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", + "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", + "dev": true + }, + "node_modules/diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "dev": true + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dom-serialize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", + "integrity": "sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==", + "dev": true, + "dependencies": { + "custom-event": "~1.0.0", + "ent": "~2.2.0", + "extend": "^3.0.0", + "void-elements": "^2.0.0" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dev": true, + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.4.629", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.629.tgz", + "integrity": "sha512-5UUkr3k3CZ/k+9Sw7vaaIMyOzMC0XbPyprKI3n0tbKDqkzTDOjK4izm7DxlkueRMim6ZZQ1ja9F7hoFVplHihA==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/engine.io": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.4.tgz", + "integrity": "sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==", + "dev": true, + "dependencies": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz", + "integrity": "sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", + "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz", + "integrity": "sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA==", + "dev": true + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/envinfo": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.11.0.tgz", + "integrity": "sha512-G9/6xF1FPbIw0TtalAMaVPpiq2aDEuKLXM314jPVAO9r2fo2a4BLqMNkmRS7O/xPPZ+COAhGIz3ETvHEV3eUcg==", + "dev": true, + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/enzyme": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.11.0.tgz", + "integrity": "sha512-Dw8/Gs4vRjxY6/6i9wU0V+utmQO9kvh9XLnz3LIudviOnVYDEe2ec+0k+NQoMamn1VrjKgCUOWj5jG/5M5M0Qw==", + "dev": true, + "dependencies": { + "array.prototype.flat": "^1.2.3", + "cheerio": "^1.0.0-rc.3", + "enzyme-shallow-equal": "^1.0.1", + "function.prototype.name": "^1.1.2", + "has": "^1.0.3", + "html-element-map": "^1.2.0", + "is-boolean-object": "^1.0.1", + "is-callable": "^1.1.5", + "is-number-object": "^1.0.4", + "is-regex": "^1.0.5", + "is-string": "^1.0.5", + "is-subset": "^0.1.1", + "lodash.escape": "^4.0.1", + "lodash.isequal": "^4.5.0", + "object-inspect": "^1.7.0", + "object-is": "^1.0.2", + "object.assign": "^4.1.0", + "object.entries": "^1.1.1", + "object.values": "^1.1.1", + "raf": "^3.4.1", + "rst-selector-parser": "^2.2.3", + "string.prototype.trim": "^1.2.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/enzyme-adapter-react-16": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.15.6.tgz", + "integrity": "sha512-yFlVJCXh8T+mcQo8M6my9sPgeGzj85HSHi6Apgf1Cvq/7EL/J9+1JoJmJsRxZgyTvPMAqOEpRSu/Ii/ZpyOk0g==", + "dev": true, + "dependencies": { + "enzyme-adapter-utils": "^1.14.0", + "enzyme-shallow-equal": "^1.0.4", + "has": "^1.0.3", + "object.assign": "^4.1.2", + "object.values": "^1.1.2", + "prop-types": "^15.7.2", + "react-is": "^16.13.1", + "react-test-renderer": "^16.0.0-0", + "semver": "^5.7.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + }, + "peerDependencies": { + "enzyme": "^3.0.0", + "react": "^16.0.0-0", + "react-dom": "^16.0.0-0" + } + }, + "node_modules/enzyme-adapter-react-16/node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/enzyme-adapter-react-16/node_modules/react-test-renderer": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.14.0.tgz", + "integrity": "sha512-L8yPjqPE5CZO6rKsKXRO/rVPiaCOy0tQQJbC+UjPNlobl5mad59lvPjwFsQHTvL03caVDIVr9x9/OSgDe6I5Eg==", + "dev": true, + "dependencies": { + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "react-is": "^16.8.6", + "scheduler": "^0.19.1" + }, + "peerDependencies": { + "react": "^16.14.0" + } + }, + "node_modules/enzyme-adapter-react-16/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/enzyme-adapter-utils": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.14.1.tgz", + "integrity": "sha512-JZgMPF1QOI7IzBj24EZoDpaeG/p8Os7WeBZWTJydpsH7JRStc7jYbHE4CmNQaLqazaGFyLM8ALWA3IIZvxW3PQ==", + "dev": true, + "dependencies": { + "airbnb-prop-types": "^2.16.0", + "function.prototype.name": "^1.1.5", + "has": "^1.0.3", + "object.assign": "^4.1.4", + "object.fromentries": "^2.0.5", + "prop-types": "^15.8.1", + "semver": "^5.7.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + }, + "peerDependencies": { + "react": "0.13.x || 0.14.x || ^15.0.0-0 || ^16.0.0-0" + } + }, + "node_modules/enzyme-adapter-utils/node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/enzyme-adapter-utils/node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/enzyme-adapter-utils/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/enzyme-shallow-equal": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.5.tgz", + "integrity": "sha512-i6cwm7hN630JXenxxJFBKzgLC3hMTafFQXflvzHgPmDhOBhxUWDe8AeRv1qp2/uWJ2Y8z5yLWMzmAfkTOiOCZg==", + "dev": true, + "dependencies": { + "has": "^1.0.3", + "object-is": "^1.1.5" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", + "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.5", + "es-set-tostringtag": "^2.0.1", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.2", + "get-symbol-description": "^1.0.0", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0", + "internal-slot": "^1.0.5", + "is-array-buffer": "^3.0.2", + "is-callable": "^1.2.7", + "is-negative-zero": "^2.0.2", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.12", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", + "safe-regex-test": "^1.0.0", + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", + "typed-array-length": "^1.0.4", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-array-method-boxes-properly": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", + "dev": true + }, + "node_modules/es-module-lexer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz", + "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==", + "dev": true + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", + "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2", + "has-tostringtag": "^1.0.0", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/execa/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/execa/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/execa/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/finalhandler/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dev": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flatted": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", + "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", + "dev": true + }, + "node_modules/follow-redirects": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", + "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/growl": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", + "dev": true, + "engines": { + "node": ">=4.x" + } + }, + "node_modules/has": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.4.tgz", + "integrity": "sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", + "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "node_modules/html-element-map": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/html-element-map/-/html-element-map-1.3.1.tgz", + "integrity": "sha512-6XMlxrAFX4UEEGxctfFnmrFaaZFNf9i5fNuV5wZ3WWQ4FVaNP1aX1LkX9j2mfEx1NpjeE/rL3nmgEn23GdFmrg==", + "dev": true, + "dependencies": { + "array.prototype.filter": "^1.0.0", + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/import-local": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dev": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/internal-slot": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", + "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", + "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.0", + "is-typed-array": "^1.1.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", + "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-subset": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz", + "integrity": "sha512-6Ybun0IkarhmEqxXCNw/C0bna6Zb/TkfUX9UbwJtK6ObwAVCxmAP308WWTHviM/zAqXk05cdhYsUsZeGQh99iw==", + "dev": true + }, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.11" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", + "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==", + "dev": true, + "dependencies": { + "@babel/core": "^7.7.5", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.0.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-report/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/istanbul-lib-source-maps": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-3.0.6.tgz", + "integrity": "sha512-R47KzMtDJH6X4/YW9XTx+jrLnZnscW4VpNN+1PViSYTejLVPWv7oov+Duf8YQSPyVRUvueQqz1TcsC6mooZTXw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^2.0.5", + "make-dir": "^2.1.0", + "rimraf": "^2.6.3", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/istanbul-lib-coverage": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz", + "integrity": "sha512-8aXznuEPCJvGnMSRft4udDRDtb1V3pkQkMMI5LI+6HuQz5oQ4J2UFn1H82raA3qJtyOLkkwVqICBQkjnGtn5mA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "dependencies": { + "pify": "^4.0.1", + "semver": "^5.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", + "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/joi-browser": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/joi-browser/-/joi-browser-13.4.0.tgz", + "integrity": "sha512-TfzJd2JaJ/lg/gU+q5j9rLAjnfUNF9DUmXTP9w+GfmG79LjFOXFeM7hIFuXCBcZCivUDFwd9l1btTV9rhHumtQ==", + "dev": true + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true + }, + "node_modules/karma": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.2.tgz", + "integrity": "sha512-C6SU/53LB31BEgRg+omznBEMY4SjHU3ricV6zBcAe1EeILKkeScr+fZXtaI5WyDbkVowJxxAI6h73NcFPmXolQ==", + "dev": true, + "dependencies": { + "@colors/colors": "1.5.0", + "body-parser": "^1.19.0", + "braces": "^3.0.2", + "chokidar": "^3.5.1", + "connect": "^3.7.0", + "di": "^0.0.1", + "dom-serialize": "^2.2.1", + "glob": "^7.1.7", + "graceful-fs": "^4.2.6", + "http-proxy": "^1.18.1", + "isbinaryfile": "^4.0.8", + "lodash": "^4.17.21", + "log4js": "^6.4.1", + "mime": "^2.5.2", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.5", + "qjobs": "^1.2.0", + "range-parser": "^1.2.1", + "rimraf": "^3.0.2", + "socket.io": "^4.4.1", + "source-map": "^0.6.1", + "tmp": "^0.2.1", + "ua-parser-js": "^0.7.30", + "yargs": "^16.1.1" + }, + "bin": { + "karma": "bin/karma" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/karma-chai": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/karma-chai/-/karma-chai-0.1.0.tgz", + "integrity": "sha512-mqKCkHwzPMhgTYca10S90aCEX9+HjVjjrBFAsw36Zj7BlQNbokXXCAe6Ji04VUMsxcY5RLP7YphpfO06XOubdg==", + "dev": true, + "peerDependencies": { + "chai": "*", + "karma": ">=0.10.9" + } + }, + "node_modules/karma-coverage-istanbul-reporter": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/karma-coverage-istanbul-reporter/-/karma-coverage-istanbul-reporter-3.0.3.tgz", + "integrity": "sha512-wE4VFhG/QZv2Y4CdAYWDbMmcAHeS926ZIji4z+FkB2aF/EposRb6DP6G5ncT/wXhqUfAb/d7kZrNKPonbvsATw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^3.0.6", + "istanbul-reports": "^3.0.2", + "minimatch": "^3.0.4" + }, + "funding": { + "url": "https://github.com/sponsors/mattlewis92" + } + }, + "node_modules/karma-firefox-launcher": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/karma-firefox-launcher/-/karma-firefox-launcher-2.1.2.tgz", + "integrity": "sha512-VV9xDQU1QIboTrjtGVD4NCfzIH7n01ZXqy/qpBhnOeGVOkG5JYPEm8kuSd7psHE6WouZaQ9Ool92g8LFweSNMA==", + "dev": true, + "dependencies": { + "is-wsl": "^2.2.0", + "which": "^2.0.1" + } + }, + "node_modules/karma-json-reporter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/karma-json-reporter/-/karma-json-reporter-1.2.1.tgz", + "integrity": "sha512-ASmvranNhUN0ctSuAZKeWISW9Nf4AteMcVy8rJVjS7Qk+qWgssag/nw+yivHWKDROztVFn7TdamHOETMPCkvgA==", + "dev": true, + "peerDependencies": { + "karma": ">=0.9" + } + }, + "node_modules/karma-mocha": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/karma-mocha/-/karma-mocha-2.0.1.tgz", + "integrity": "sha512-Tzd5HBjm8his2OA4bouAsATYEpZrp9vC7z5E5j4C5Of5Rrs1jY67RAwXNcVmd/Bnk1wgvQRou0zGVLey44G4tQ==", + "dev": true, + "dependencies": { + "minimist": "^1.2.3" + } + }, + "node_modules/karma-mocha-reporter": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/karma-mocha-reporter/-/karma-mocha-reporter-2.2.5.tgz", + "integrity": "sha512-Hr6nhkIp0GIJJrvzY8JFeHpQZNseuIakGac4bpw8K1+5F0tLb6l7uvXRa8mt2Z+NVwYgCct4QAfp2R2QP6o00w==", + "dev": true, + "dependencies": { + "chalk": "^2.1.0", + "log-symbols": "^2.1.0", + "strip-ansi": "^4.0.0" + }, + "peerDependencies": { + "karma": ">=0.13" + } + }, + "node_modules/karma-sinon": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/karma-sinon/-/karma-sinon-1.0.5.tgz", + "integrity": "sha512-wrkyAxJmJbn75Dqy17L/8aILJWFm7znd1CE8gkyxTBFnjMSOe2XTJ3P30T8SkxWZHmoHX0SCaUJTDBEoXs25Og==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + }, + "peerDependencies": { + "karma": ">=0.10", + "sinon": "*" + } + }, + "node_modules/karma-sourcemap-loader": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/karma-sourcemap-loader/-/karma-sourcemap-loader-0.3.8.tgz", + "integrity": "sha512-zorxyAakYZuBcHRJE+vbrK2o2JXLFWK8VVjiT/6P+ltLBUGUvqTEkUiQ119MGdOrK7mrmxXHZF1/pfT6GgIZ6g==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.2" + } + }, + "node_modules/karma-webpack": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/karma-webpack/-/karma-webpack-5.0.0.tgz", + "integrity": "sha512-+54i/cd3/piZuP3dr54+NcFeKOPnys5QeM1IY+0SPASwrtHsliXUiCL50iW+K9WWA7RvamC4macvvQ86l3KtaA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3", + "minimatch": "^3.0.4", + "webpack-merge": "^4.1.5" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/loader-utils": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz", + "integrity": "sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==", + "dev": true, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "node_modules/lodash.escape": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz", + "integrity": "sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw==", + "dev": true + }, + "node_modules/lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", + "dev": true + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "dev": true + }, + "node_modules/log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "dev": true, + "dependencies": { + "chalk": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log4js": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", + "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", + "dev": true, + "dependencies": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "flatted": "^3.2.7", + "rfdc": "^1.3.0", + "streamroller": "^3.1.5" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/merge-source-map": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.1.0.tgz", + "integrity": "sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw==", + "dev": true, + "dependencies": { + "source-map": "^0.6.1" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mocha": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-9.2.2.tgz", + "integrity": "sha512-L6XC3EdwT6YrIk0yXpavvLkn8h+EU+Y5UcCHKECyMbdUIxyMuZj4bX4U9e1nvnvUUvQVsV2VHQr5zLdcUkhW/g==", + "dev": true, + "dependencies": { + "@ungap/promise-all-settled": "1.1.2", + "ansi-colors": "4.1.1", + "browser-stdout": "1.3.1", + "chokidar": "3.5.3", + "debug": "4.3.3", + "diff": "5.0.0", + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + "glob": "7.2.0", + "growl": "1.10.5", + "he": "1.2.0", + "js-yaml": "4.1.0", + "log-symbols": "4.1.0", + "minimatch": "4.2.1", + "ms": "2.1.3", + "nanoid": "3.3.1", + "serialize-javascript": "6.0.0", + "strip-json-comments": "3.1.1", + "supports-color": "8.1.1", + "which": "2.0.2", + "workerpool": "6.2.0", + "yargs": "16.2.0", + "yargs-parser": "20.2.4", + "yargs-unparser": "2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mochajs" + } + }, + "node_modules/mocha/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/mocha/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/mocha/node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mocha/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/mocha/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/mocha/node_modules/debug": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", + "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/mocha/node_modules/debug/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/mocha/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mocha/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/mocha/node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-4.2.1.tgz", + "integrity": "sha512-9Uq1ChtSZO+Mxa/CL1eGizn2vRn3MlLgzhT0Iz8zaY8NdvxvB0d5QdPFmCKf7JKA9Lerx5vRrnwO03jsSfGG9g==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/mock-raf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mock-raf/-/mock-raf-1.0.1.tgz", + "integrity": "sha512-+25y56bblLzEnv+G4ODsHNck07A5uP5HFfu/1VBKeFrUXoFT9oru+R+jLxLz6rwdM5drUHFdqX9LYBsMP4dz/w==", + "dev": true, + "dependencies": { + "object-assign": "^3.0.0" + } + }, + "node_modules/moo": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", + "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", + "dev": true + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz", + "integrity": "sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==", + "dev": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "dev": true, + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" + }, + "funding": { + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node_modules/nise": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.7.tgz", + "integrity": "sha512-wWtNUhkT7k58uvWTB/Gy26eA/EJKtPZFVAhEilN5UYVmmGRYOURbejRUyKm0Uu9XVEW7K5nBOZfR8VMB4QR2RQ==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/text-encoding": "^0.7.2", + "just-extend": "^6.2.0", + "path-to-regexp": "^6.2.1" + } + }, + "node_modules/nise/node_modules/@sinonjs/commons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", + "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/nise/node_modules/@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "dev": true + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz", + "integrity": "sha512-jHP15vXVGeVh1HuaA2wY6lxk+whK/x4KBG88VXeRma7CCun7iGD5qPc4eYykQ9sdQvg8jkwFKsSxHln2ybW3xQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", + "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.7.tgz", + "integrity": "sha512-jCBs/0plmPsOnrKAfFQXRG2NFjlhZgjjcBLSmTnEhU8U6vVTsVe8ANeQJCHTl3gSsI4J+0emOoCgoKlmQPMgmA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", + "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", + "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dev": true, + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "dev": true, + "dependencies": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-to-regexp": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==", + "dev": true + }, + "node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true, + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/postcss": { + "version": "8.4.33", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz", + "integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "peer": true, + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-scss": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.6.tgz", + "integrity": "sha512-rLDPhJY4z/i4nVFZ27j9GqLxj1pwxE80eAzUNRMXtcpipFYIeowerzBgG3yJhMtObGEXidtIgbUpQ3eLDsf5OQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + } + ], + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.19" + } + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + } + }, + "node_modules/prop-types-exact": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/prop-types-exact/-/prop-types-exact-1.2.0.tgz", + "integrity": "sha512-K+Tk3Kd9V0odiXFP9fwDHUYRyvK3Nun3GVyPapSIs5OBkITAm15W0CPFD/YKTkMUAbc0b9CUwRQp2ybiBIq+eA==", + "dev": true, + "dependencies": { + "has": "^1.0.3", + "object.assign": "^4.1.0", + "reflect.ownkeys": "^0.2.0" + } + }, + "node_modules/prop-types/node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/qjobs": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", + "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", + "dev": true, + "engines": { + "node": ">=0.9" + } + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dev": true, + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "dev": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "dev": true + }, + "node_modules/randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "dev": true, + "dependencies": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-loader": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz", + "integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==", + "dev": true, + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/raw-loader/node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/raw-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/react": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react/-/react-16.13.1.tgz", + "integrity": "sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.13.1.tgz", + "integrity": "sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag==", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.19.1" + }, + "peerDependencies": { + "react": "^16.13.1" + } + }, + "node_modules/react-dom/node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-redux": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz", + "integrity": "sha512-10RPdsz0UUrRL1NZE0ejTkucnclYSgXp5q+tB5SWx2qeG2ZJQJyymgAhwKy73yiL/13btfB6fPr+rgbMAaZIAQ==", + "dependencies": { + "@babel/runtime": "^7.15.4", + "@types/react-redux": "^7.1.20", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^17.0.2" + }, + "peerDependencies": { + "react": "^16.8.3 || ^17" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/react-redux/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, + "node_modules/react-transition-group": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz", + "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/react/node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", + "dev": true, + "dependencies": { + "resolve": "^1.1.6" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/redux": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz", + "integrity": "sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, + "node_modules/reflect.ownkeys": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz", + "integrity": "sha512-qOLsBKHCpSOFKK1NUOCGC5VyeufB6lEsFe92AL2bhIJsacZS1qdoOZSbPk3MYKuT2cFlRDnulKXuuElIrMjGUg==", + "dev": true + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "set-function-name": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/rfdc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", + "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==", + "dev": true + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rst-selector-parser": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz", + "integrity": "sha512-nDG1rZeP6oFTLN6yNDV/uiAvs1+FS/KlrEwh7+y7dpuApDBy6bI2HTBcc0/V8lv9OTqfyD34eF7au2pm8aBbhA==", + "dev": true, + "dependencies": { + "lodash.flattendeep": "^4.4.0", + "nearley": "^2.7.10" + } + }, + "node_modules/safe-array-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", + "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safe-regex-test": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.2.tgz", + "integrity": "sha512-83S9w6eFq12BBIJYvjMux6/dkirb8+4zJRA9cxNBVb7Wq5fJBW+Xze48WqR8pxua7bDuAaaAxtVVd4Idjp1dBQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "get-intrinsic": "^1.2.2", + "is-regex": "^1.1.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "node_modules/sass": { + "version": "1.43.4", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.43.4.tgz", + "integrity": "sha512-/ptG7KE9lxpGSYiXn7Ar+lKOv37xfWsZRtFYal2QHNigyVQDx685VFT/h7ejVr+R8w7H4tmUgtulsKl5YpveOg==", + "dev": true, + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/scheduler": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", + "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "node_modules/scheduler/node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/schema-utils": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shelljs": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", + "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", + "dev": true, + "dependencies": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + }, + "bin": { + "shjs": "bin/shjs" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/sinon": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-12.0.1.tgz", + "integrity": "sha512-iGu29Xhym33ydkAT+aNQFBINakjq69kKO6ByPvTsm3yyIACfyQttRTP03aBP/I8GfhFmLzrnKwNNkr0ORb1udg==", + "deprecated": "16.1.1", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.8.3", + "@sinonjs/fake-timers": "^8.1.0", + "@sinonjs/samsam": "^6.0.2", + "diff": "^5.0.0", + "nise": "^5.1.0", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/socket.io": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.4.tgz", + "integrity": "sha512-DcotgfP1Zg9iP/dH9zvAQcWrE0TtbMVwXmlV4T4mqsvY+gw+LqUGPfx2AoVyRk0FLME+GQhufDMyacFmw7ksqw==", + "dev": true, + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.5.2", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.2.tgz", + "integrity": "sha512-87C3LO/NOMc+eMcpcxUBebGjkpMDkNBS9tf7KJqcDsmL936EChtVva71Dw2q4tQcuVC+hAUy4an2NO/sYXmwRA==", + "dev": true, + "dependencies": { + "ws": "~8.11.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dev": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.16", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz", + "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==", + "dev": true + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stream-browserify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", + "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", + "dev": true, + "dependencies": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } + }, + "node_modules/streamroller": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", + "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", + "dev": true, + "dependencies": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "fs-extra": "^8.1.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.padend": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.5.tgz", + "integrity": "sha512-DOB27b/2UTTD+4myKUFh+/fXWcu/UDyASIXfg+7VzoCNNGOfWvoyU/x5pvVHr++ztyt/oSYI1BcWBBG/hmlNjA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==", + "dev": true, + "dependencies": { + "ansi-regex": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/terser": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.26.0.tgz", + "integrity": "sha512-dytTGoE2oHgbNV9nTzgBEPaqAWvcJNl66VZ0BkJqlvp71IjO8CxdBx/ykCNb47cLnCmCvRZ6ZR0tLkqvZCdVBQ==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", + "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.20", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.26.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser-webpack-plugin/node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/terser/node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dev": true, + "dependencies": { + "rimraf": "^3.0.0" + }, + "engines": { + "node": ">=8.17.0" + } + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", + "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", + "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", + "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "is-typed-array": "^1.1.9" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ua-parser-js": { + "version": "0.7.37", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.37.tgz", + "integrity": "sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "engines": { + "node": "*" + } + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dev": true, + "dependencies": { + "inherits": "2.0.3" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/util/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", + "integrity": "sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/watchpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", + "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "dev": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.89.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.89.0.tgz", + "integrity": "sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==", + "dev": true, + "dependencies": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^1.0.0", + "@webassemblyjs/ast": "^1.11.5", + "@webassemblyjs/wasm-edit": "^1.11.5", + "@webassemblyjs/wasm-parser": "^1.11.5", + "acorn": "^8.7.1", + "acorn-import-assertions": "^1.9.0", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.15.0", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.9", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.7", + "watchpack": "^2.4.0", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.9.1.tgz", + "integrity": "sha512-JYRFVuyFpzDxMDB+v/nanUdQYcZtqFPGzmlW4s+UkPMFhSpfRNmf1z4AwYcHJVdvEFAM7FFCQdNTpsBYhDLusQ==", + "dev": true, + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^1.1.0", + "@webpack-cli/info": "^1.4.0", + "@webpack-cli/serve": "^1.6.0", + "colorette": "^2.0.14", + "commander": "^7.0.0", + "execa": "^5.0.0", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^2.2.0", + "rechoir": "^0.7.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "webpack": "4.x.x || 5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "@webpack-cli/migrate": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/webpack-cli/node_modules/interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/webpack-cli/node_modules/rechoir": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", + "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", + "dev": true, + "dependencies": { + "resolve": "^1.9.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/webpack-cli/node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "dev": true, + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/webpack-merge": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-4.2.2.tgz", + "integrity": "sha512-TUE1UGoTX2Cd42j3krGYqObZbOD+xF7u28WB7tfUordytSjbWTIjK/8V0amkBfTYN4/pB/GIDlJZZ657BGG19g==", + "dev": true, + "dependencies": { + "lodash": "^4.17.15" + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.4", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true + }, + "node_modules/workerpool": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.0.tgz", + "integrity": "sha512-Rsk5qQHJ9eowMH28Jwhe8HEbmdYDX4lwoMWshiCXugjtHqMD9ZbiqSDLxcsfdqsETPzVUtX5s1Z5kStiIM6l4A==", + "dev": true + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yamljs": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/yamljs/-/yamljs-0.3.0.tgz", + "integrity": "sha512-C/FsVVhht4iPQYXOInoxUM/1ELSf9EsgKH34FofQOp6hwCPrW4vG4w5++TED3xRUo8gD7l0P1J1dLlDYzODsTQ==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "glob": "^7.0.5" + }, + "bin": { + "json2yaml": "bin/json2yaml", + "yaml2json": "bin/yaml2json" + } + }, + "node_modules/yamljs/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/yamscripts": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yamscripts/-/yamscripts-0.1.0.tgz", + "integrity": "sha512-i4ThS58KwsK83qSrrc8YZiBqgdl3WewWcWZ4fPdrh7A+qiRU9kXMcIKzngOC7VpJ2nTsWvHG6TcK3JHXpBxACA==", + "dev": true, + "dependencies": { + "colors": "^1.3.2", + "fs-extra": "^7.0.0", + "minimist": "^1.2.0", + "yamljs": "^0.3.0" + }, + "bin": { + "yamscripts": "bin/yamscripts.js" + } + }, + "node_modules/yamscripts/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.4", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", + "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + }, + "dependencies": { + "@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@babel/code-frame": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", + "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "dev": true, + "requires": { + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" + } + }, + "@babel/compat-data": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", + "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "dev": true + }, + "@babel/core": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.5.tgz", + "integrity": "sha512-Cwc2XjUrG4ilcfOw4wBAK+enbdgwAcAJCfGUItPBKR7Mjw4aEfAFYrLxeRp4jWgtNIKn3n2AlBOfwwafl+42/g==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.5", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.23.5", + "@babel/parser": "^7.23.5", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.5", + "@babel/types": "^7.23.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + } + }, + "@babel/generator": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", + "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", + "dev": true, + "requires": { + "@babel/types": "^7.23.6", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + } + }, + "@babel/helper-annotate-as-pure": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", + "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", + "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-validator-option": "^7.23.5", + "browserslist": "^4.22.2", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + } + }, + "@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true + }, + "@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "requires": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dev": true, + "requires": { + "@babel/types": "^7.22.15" + } + }, + "@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", + "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "dev": true + }, + "@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "dev": true + }, + "@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "dev": true + }, + "@babel/helpers": { + "version": "7.23.8", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.8.tgz", + "integrity": "sha512-KDqYz4PiOWvDFrdHLPhKtCThtIcKVy6avWD2oG4GEvyQ+XDZwHD4YQd+H2vNMnq2rkdxsDkU82T+Vk8U/WXHRQ==", + "dev": true, + "requires": { + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, + "@babel/highlight": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", + "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", + "dev": true + }, + "@babel/plugin-syntax-jsx": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.23.3.tgz", + "integrity": "sha512-EB2MELswq55OHUoRZLGg/zC7QWUKfNLpE57m/S2yr1uEneIgsTgrSzXP3NXEsMkVn76OlaVVnzN+ugObuYGwhg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-react-display-name": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.23.3.tgz", + "integrity": "sha512-GnvhtVfA2OAtzdX58FJxU19rhoGeQzyVndw3GgtdECQvQFXPEZIOVULHVZGAYmOgmqjXpVpfocAbSjh99V/Fqw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-react-jsx": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.23.4.tgz", + "integrity": "sha512-5xOpoPguCZCRbo/JeHlloSkTA8Bld1J/E1/kLfD1nsuiW1m8tduTA1ERCgIZokDflX/IBzKcqR3l7VlRgiIfHA==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-jsx": "^7.23.3", + "@babel/types": "^7.23.4" + } + }, + "@babel/plugin-transform-react-jsx-development": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.22.5.tgz", + "integrity": "sha512-bDhuzwWMuInwCYeDeMzyi7TaBgRQei6DqxhbyniL7/VG4RSS7HtSL2QbY4eESy1KJqlWt8g3xeEBGPuo+XqC8A==", + "dev": true, + "requires": { + "@babel/plugin-transform-react-jsx": "^7.22.5" + } + }, + "@babel/plugin-transform-react-pure-annotations": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.23.3.tgz", + "integrity": "sha512-qMFdSS+TUhB7Q/3HVPnEdYJDQIk57jkntAwSuz9xfSE4n+3I+vHYCli3HoHawN1Z3RfCz/y1zXA/JXjG6cVImQ==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/preset-react": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.23.3.tgz", + "integrity": "sha512-tbkHOS9axH6Ysf2OUEqoSZ6T3Fa2SrNH6WTWSPBboxKzdxNc9qOICeLXkNG0ZEwbQ1HY8liwOce4aN/Ceyuq6w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-validator-option": "^7.22.15", + "@babel/plugin-transform-react-display-name": "^7.23.3", + "@babel/plugin-transform-react-jsx": "^7.22.15", + "@babel/plugin-transform-react-jsx-development": "^7.22.5", + "@babel/plugin-transform-react-pure-annotations": "^7.23.3" + } + }, + "@babel/runtime": { + "version": "7.23.8", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.8.tgz", + "integrity": "sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw==", + "requires": { + "regenerator-runtime": "^0.14.0" + } + }, + "@babel/template": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" + } + }, + "@babel/traverse": { + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.7.tgz", + "integrity": "sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.6", + "@babel/types": "^7.23.6", + "debug": "^4.3.1", + "globals": "^11.1.0" + } + }, + "@babel/types": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", + "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", + "dev": true, + "requires": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + } + }, + "@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true + }, + "@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true + }, + "@fluent/bundle": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@fluent/bundle/-/bundle-0.17.1.tgz", + "integrity": "sha512-CRFNT9QcSFAeFDneTF59eyv3JXFGhIIN4boUO2y22YmsuuKLyDk+N1I/NQUYz9Ab63e6V7T6vItoZIG/2oOOuw==" + }, + "@fluent/react": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@fluent/react/-/react-0.15.0.tgz", + "integrity": "sha512-qUMfaHman+UciOELQc5hnFAv0VerUR6+9gEBCRk9RR66XS13syt91ZElNOTHWe2Ofv70cxAGaJ5Yff4MRPg5Ow==", + "requires": { + "@fluent/sequence": "^0.8.0", + "cached-iterable": "^0.3.0" + } + }, + "@fluent/sequence": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@fluent/sequence/-/sequence-0.8.0.tgz", + "integrity": "sha512-eV5QlEEVV/wR3AFQLXO67x4yPRPQXyqke0c8yucyMSeW36B3ecZyVFlY1UprzrfFV8iPJB4TAehDy/dLGbvQ1Q==", + "requires": {} + }, + "@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true + }, + "@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "requires": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true + }, + "@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true + }, + "@jridgewell/source-map": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", + "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.21", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.21.tgz", + "integrity": "sha512-SRfKmRe1KvYnxjEMtxEr+J4HIeMX5YBg/qhRHpxEIGjhX1rshcHlnFUE9K0GazhVKWM7B+nARSkV8LuvJdJ5/g==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "@jsdevtools/coverage-istanbul-loader": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@jsdevtools/coverage-istanbul-loader/-/coverage-istanbul-loader-3.0.5.tgz", + "integrity": "sha512-EUCPEkaRPvmHjWAAZkWMT7JDzpw7FKB00WTISaiXsbNOd5hCHg77XLA8sLYLFDo1zepYLo2w7GstN8YBqRXZfA==", + "dev": true, + "requires": { + "convert-source-map": "^1.7.0", + "istanbul-lib-instrument": "^4.0.3", + "loader-utils": "^2.0.0", + "merge-source-map": "^1.1.0", + "schema-utils": "^2.7.0" + }, + "dependencies": { + "convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true + }, + "loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + } + } + }, + "@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", + "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.7.0" + } + }, + "@sinonjs/samsam": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-6.1.3.tgz", + "integrity": "sha512-nhOb2dWPeb1sd3IQXL/dVPnKHDOAFfvichtBf4xV00/rU1QbPCQqKMbvIheIjqwVjh7qIgf2AHTHi391yMOMpQ==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.6.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, + "@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==", + "dev": true + }, + "@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", + "dev": true + }, + "@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/eslint": { + "version": "8.56.2", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.2.tgz", + "integrity": "sha512-uQDwm1wFHmbBbCZCqAlq6Do9LYwByNZHWzXppSnay9SuwJ+VRbjkbLABer54kcPnMSlG6Fdiy2yaFXm/z9Z5gw==", + "dev": true, + "requires": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "requires": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "@types/hoist-non-react-statics": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, + "@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "@types/node": { + "version": "20.11.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz", + "integrity": "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==", + "dev": true, + "requires": { + "undici-types": "~5.26.4" + } + }, + "@types/prop-types": { + "version": "15.7.11", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" + }, + "@types/react": { + "version": "18.2.47", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.47.tgz", + "integrity": "sha512-xquNkkOirwyCgoClNk85BjP+aqnIS+ckAJ8i37gAbDs14jfW/J23f2GItAf33oiUPQnqNMALiFeoM9Y5mbjpVQ==", + "requires": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "@types/react-redux": { + "version": "7.1.33", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.33.tgz", + "integrity": "sha512-NF8m5AjWCkert+fosDsN3hAlHzpjSiXlVy9EgQEmLoBhaNXbmyeGs/aj5dQzKuF+/q+S7JQagorGDW8pJ28Hmg==", + "requires": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, + "@types/scheduler": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" + }, + "@ungap/promise-all-settled": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", + "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", + "dev": true + }, + "@webassemblyjs/ast": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", + "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", + "dev": true, + "requires": { + "@webassemblyjs/helper-numbers": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + } + }, + "@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true + }, + "@webassemblyjs/helper-api-error": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true + }, + "@webassemblyjs/helper-buffer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", + "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==", + "dev": true + }, + "@webassemblyjs/helper-numbers": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", + "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, + "requires": { + "@webassemblyjs/floating-point-hex-parser": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true + }, + "@webassemblyjs/helper-wasm-section": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", + "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6" + } + }, + "@webassemblyjs/ieee754": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", + "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, + "requires": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "@webassemblyjs/leb128": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", + "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, + "requires": { + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/utf8": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true + }, + "@webassemblyjs/wasm-edit": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", + "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/helper-wasm-section": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-opt": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6", + "@webassemblyjs/wast-printer": "1.11.6" + } + }, + "@webassemblyjs/wasm-gen": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", + "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "@webassemblyjs/wasm-opt": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", + "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6" + } + }, + "@webassemblyjs/wasm-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", + "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "@webassemblyjs/wast-printer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", + "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "@webpack-cli/configtest": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.2.0.tgz", + "integrity": "sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==", + "dev": true, + "requires": {} + }, + "@webpack-cli/info": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.5.0.tgz", + "integrity": "sha512-e8tSXZpw2hPl2uMJY6fsMswaok5FdlGNRTktvFk2sD8RjH0hE2+XistawJx1vmKteh4NmGmNUrp+Tb2w+udPcQ==", + "dev": true, + "requires": { + "envinfo": "^7.7.3" + } + }, + "@webpack-cli/serve": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.7.0.tgz", + "integrity": "sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==", + "dev": true, + "requires": {} + }, + "@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "requires": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + } + }, + "acorn": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.5.0.tgz", + "integrity": "sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q==", + "dev": true + }, + "acorn-import-assertions": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", + "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "dev": true, + "requires": {} + }, + "airbnb-prop-types": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.16.0.tgz", + "integrity": "sha512-7WHOFolP/6cS96PhKNrslCLMYAI8yB1Pp6u6XmxozQOiZbsI5ycglZr5cHhBFfuRcQQjzCMith5ZPZdYiJCxUg==", + "dev": true, + "requires": { + "array.prototype.find": "^2.1.1", + "function.prototype.name": "^1.1.2", + "is-regex": "^1.1.0", + "object-is": "^1.1.2", + "object.assign": "^4.1.0", + "object.entries": "^1.1.2", + "prop-types": "^15.7.2", + "prop-types-exact": "^1.2.0", + "react-is": "^16.13.1" + } + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "requires": {} + }, + "ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true + }, + "ansi-regex": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", + "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "array-buffer-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", + "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "is-array-buffer": "^3.0.1" + } + }, + "array.prototype.filter": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array.prototype.filter/-/array.prototype.filter-1.0.3.tgz", + "integrity": "sha512-VizNcj/RGJiUyQBgzwxzE5oHdeuXY5hSbbmKMlphj1cy1Vl7Pn2asCGbSrru6hSQjmCzqTBPVWAF/whmEOVHbw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-array-method-boxes-properly": "^1.0.0", + "is-string": "^1.0.7" + } + }, + "array.prototype.find": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.2.2.tgz", + "integrity": "sha512-DRumkfW97iZGOfn+lIXbkVrXL04sfYKX+EfOodo8XboR5sxPDVvOjZTF/rysusa9lmhmSOeD6Vp6RKQP+eP4Tg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + } + }, + "array.prototype.flat": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + } + }, + "arraybuffer.prototype.slice": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", + "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "dev": true, + "requires": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" + } + }, + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true + }, + "available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "dev": true + }, + "babel-loader": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.3.tgz", + "integrity": "sha512-n4Zeta8NC3QAsuyiizu0GkmRcQ6clkV9WFUnUf1iXP//IeSKbWjofW3UHyZVwlOB4y039YQKefawyTn64Zwbuw==", + "dev": true, + "requires": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^1.4.0", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + }, + "dependencies": { + "json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "loader-utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz", + "integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + } + } + } + }, + "babel-plugin-jsm-to-esmodules": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jsm-to-esmodules/-/babel-plugin-jsm-to-esmodules-0.6.0.tgz", + "integrity": "sha512-463Yuq2sLkjoGHl5vPYUQQONnDjxnmxZuhsR1swL5N76hDFGyYZAVd6HoS4E02jBF8bORpS4aFmdr1XjEZ0buQ==", + "dev": true + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true + }, + "base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "dev": true + }, + "big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true + }, + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true + }, + "body-parser": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "dev": true, + "requires": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + } + } + }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, + "browserslist": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", + "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001565", + "electron-to-chromium": "^1.4.601", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.13" + } + }, + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true + }, + "cached-iterable": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/cached-iterable/-/cached-iterable-0.3.0.tgz", + "integrity": "sha512-MDqM6TpBVebZD4UDtmlFp8EjVtRcsB6xt9aRdWymjk0fWVUUGgmt/V7o0H0gkI2Tkvv8B0ucjidZm4mLosdlWw==" + }, + "call-bind": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "dev": true, + "requires": { + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" + } + }, + "camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true + }, + "caniuse-lite": { + "version": "1.0.30001576", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001576.tgz", + "integrity": "sha512-ff5BdakGe2P3SQsMsiqmt1Lc8221NR1VzHj5jXN5vBny9A6fpze94HiVV/n7XRosOlsShJcvMv5mdnpjOGCEgg==", + "dev": true + }, + "chai": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.4.tgz", + "integrity": "sha512-yS5H68VYOCtN1cjfwumDSuzn/9c+yza4f3reKXlE5rUg7SFcCEy90gJvydNgOYtblyf4Zi6jIWRnXOgErta0KA==", + "dev": true, + "requires": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.2", + "deep-eql": "^3.0.1", + "get-func-name": "^2.0.0", + "pathval": "^1.1.1", + "type-detect": "^4.0.5" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "requires": { + "get-func-name": "^2.0.2" + } + }, + "cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "dev": true, + "requires": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + } + }, + "cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "requires": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + } + }, + "chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + } + }, + "chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true + }, + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + } + } + }, + "clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true + }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "dev": true, + "requires": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + } + } + }, + "content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true + }, + "convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "dev": true + }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "requires": { + "object-assign": "^4", + "vary": "^1" + }, + "dependencies": { + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true + } + } + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "dependencies": { + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dev": true, + "requires": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + } + }, + "css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true + }, + "csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "custom-event": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", + "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", + "dev": true + }, + "date-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", + "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", + "dev": true + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true + }, + "deep-eql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", + "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "dev": true, + "requires": { + "type-detect": "^4.0.0" + } + }, + "define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dev": true, + "requires": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + } + }, + "define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "requires": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + } + }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true + }, + "destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true + }, + "di": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", + "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", + "dev": true + }, + "diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "dev": true + }, + "discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "dev": true + }, + "dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "dom-serialize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", + "integrity": "sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==", + "dev": true, + "requires": { + "custom-event": "~1.0.0", + "ent": "~2.2.0", + "extend": "^3.0.0", + "void-elements": "^2.0.0" + } + }, + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dev": true, + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true + }, + "electron-to-chromium": { + "version": "1.4.629", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.629.tgz", + "integrity": "sha512-5UUkr3k3CZ/k+9Sw7vaaIMyOzMC0XbPyprKI3n0tbKDqkzTDOjK4izm7DxlkueRMim6ZZQ1ja9F7hoFVplHihA==", + "dev": true + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true + }, + "engine.io": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.4.tgz", + "integrity": "sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==", + "dev": true, + "requires": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0" + } + }, + "engine.io-parser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz", + "integrity": "sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==", + "dev": true + }, + "enhanced-resolve": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", + "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + } + }, + "ent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz", + "integrity": "sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA==", + "dev": true + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true + }, + "envinfo": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.11.0.tgz", + "integrity": "sha512-G9/6xF1FPbIw0TtalAMaVPpiq2aDEuKLXM314jPVAO9r2fo2a4BLqMNkmRS7O/xPPZ+COAhGIz3ETvHEV3eUcg==", + "dev": true + }, + "enzyme": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.11.0.tgz", + "integrity": "sha512-Dw8/Gs4vRjxY6/6i9wU0V+utmQO9kvh9XLnz3LIudviOnVYDEe2ec+0k+NQoMamn1VrjKgCUOWj5jG/5M5M0Qw==", + "dev": true, + "requires": { + "array.prototype.flat": "^1.2.3", + "cheerio": "^1.0.0-rc.3", + "enzyme-shallow-equal": "^1.0.1", + "function.prototype.name": "^1.1.2", + "has": "^1.0.3", + "html-element-map": "^1.2.0", + "is-boolean-object": "^1.0.1", + "is-callable": "^1.1.5", + "is-number-object": "^1.0.4", + "is-regex": "^1.0.5", + "is-string": "^1.0.5", + "is-subset": "^0.1.1", + "lodash.escape": "^4.0.1", + "lodash.isequal": "^4.5.0", + "object-inspect": "^1.7.0", + "object-is": "^1.0.2", + "object.assign": "^4.1.0", + "object.entries": "^1.1.1", + "object.values": "^1.1.1", + "raf": "^3.4.1", + "rst-selector-parser": "^2.2.3", + "string.prototype.trim": "^1.2.1" + } + }, + "enzyme-adapter-react-16": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.15.6.tgz", + "integrity": "sha512-yFlVJCXh8T+mcQo8M6my9sPgeGzj85HSHi6Apgf1Cvq/7EL/J9+1JoJmJsRxZgyTvPMAqOEpRSu/Ii/ZpyOk0g==", + "dev": true, + "requires": { + "enzyme-adapter-utils": "^1.14.0", + "enzyme-shallow-equal": "^1.0.4", + "has": "^1.0.3", + "object.assign": "^4.1.2", + "object.values": "^1.1.2", + "prop-types": "^15.7.2", + "react-is": "^16.13.1", + "react-test-renderer": "^16.0.0-0", + "semver": "^5.7.0" + }, + "dependencies": { + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true + }, + "react-test-renderer": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.14.0.tgz", + "integrity": "sha512-L8yPjqPE5CZO6rKsKXRO/rVPiaCOy0tQQJbC+UjPNlobl5mad59lvPjwFsQHTvL03caVDIVr9x9/OSgDe6I5Eg==", + "dev": true, + "requires": { + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "react-is": "^16.8.6", + "scheduler": "^0.19.1" + } + }, + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true + } + } + }, + "enzyme-adapter-utils": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.14.1.tgz", + "integrity": "sha512-JZgMPF1QOI7IzBj24EZoDpaeG/p8Os7WeBZWTJydpsH7JRStc7jYbHE4CmNQaLqazaGFyLM8ALWA3IIZvxW3PQ==", + "dev": true, + "requires": { + "airbnb-prop-types": "^2.16.0", + "function.prototype.name": "^1.1.5", + "has": "^1.0.3", + "object.assign": "^4.1.4", + "object.fromentries": "^2.0.5", + "prop-types": "^15.8.1", + "semver": "^5.7.1" + }, + "dependencies": { + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true + }, + "prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true + } + } + }, + "enzyme-shallow-equal": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.5.tgz", + "integrity": "sha512-i6cwm7hN630JXenxxJFBKzgLC3hMTafFQXflvzHgPmDhOBhxUWDe8AeRv1qp2/uWJ2Y8z5yLWMzmAfkTOiOCZg==", + "dev": true, + "requires": { + "has": "^1.0.3", + "object-is": "^1.1.5" + } + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "es-abstract": { + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", + "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", + "dev": true, + "requires": { + "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.5", + "es-set-tostringtag": "^2.0.1", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.2", + "get-symbol-description": "^1.0.0", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0", + "internal-slot": "^1.0.5", + "is-array-buffer": "^3.0.2", + "is-callable": "^1.2.7", + "is-negative-zero": "^2.0.2", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.12", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", + "safe-regex-test": "^1.0.0", + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", + "typed-array-length": "^1.0.4", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.13" + } + }, + "es-array-method-boxes-properly": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", + "dev": true + }, + "es-module-lexer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz", + "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==", + "dev": true + }, + "es-set-tostringtag": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", + "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "dev": true, + "requires": { + "get-intrinsic": "^1.2.2", + "has-tostringtag": "^1.0.0", + "hasown": "^2.0.0" + } + }, + "es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "requires": { + "hasown": "^2.0.0" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true + }, + "eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + }, + "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + } + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true + }, + "events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true + }, + "execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "dependencies": { + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + } + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "dev": true, + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dev": true, + "requires": { + "ee-first": "1.1.1" + } + } + } + }, + "find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + } + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true + }, + "flatted": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", + "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", + "dev": true + }, + "follow-redirects": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", + "dev": true + }, + "for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "requires": { + "is-callable": "^1.1.3" + } + }, + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true + }, + "function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + } + }, + "functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true + }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true + }, + "get-intrinsic": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "dev": true, + "requires": { + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + } + }, + "get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true + }, + "get-symbol-description": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", + "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + } + }, + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dev": true, + "requires": { + "define-properties": "^1.1.3" + } + }, + "gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "requires": { + "get-intrinsic": "^1.1.3" + } + }, + "graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "growl": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", + "dev": true + }, + "has": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.4.tgz", + "integrity": "sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==", + "dev": true + }, + "has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true + }, + "has-property-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", + "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "dev": true, + "requires": { + "get-intrinsic": "^1.2.2" + } + }, + "has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "dev": true + }, + "has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true + }, + "has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dev": true, + "requires": { + "has-symbols": "^1.0.2" + } + }, + "hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, + "requires": { + "function-bind": "^1.1.2" + } + }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true + }, + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "requires": { + "react-is": "^16.7.0" + } + }, + "hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "html-element-map": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/html-element-map/-/html-element-map-1.3.1.tgz", + "integrity": "sha512-6XMlxrAFX4UEEGxctfFnmrFaaZFNf9i5fNuV5wZ3WWQ4FVaNP1aX1LkX9j2mfEx1NpjeE/rL3nmgEn23GdFmrg==", + "dev": true, + "requires": { + "array.prototype.filter": "^1.0.0", + "call-bind": "^1.0.2" + } + }, + "html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "dev": true, + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "requires": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "dependencies": { + "statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true + } + } + }, + "http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "requires": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + } + }, + "human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true + }, + "import-local": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dev": true, + "requires": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + } + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "internal-slot": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", + "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", + "dev": true, + "requires": { + "get-intrinsic": "^1.2.2", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + } + }, + "interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "dev": true + }, + "is-array-buffer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", + "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.0", + "is-typed-array": "^1.1.10" + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "requires": { + "has-bigints": "^1.0.1" + } + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, + "is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true + }, + "is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "requires": { + "hasown": "^2.0.0" + } + }, + "is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-negative-zero": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "dev": true + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, + "is-shared-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", + "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2" + } + }, + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true + }, + "is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-subset": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz", + "integrity": "sha512-6Ybun0IkarhmEqxXCNw/C0bna6Zb/TkfUX9UbwJtK6ObwAVCxmAP308WWTHviM/zAqXk05cdhYsUsZeGQh99iw==", + "dev": true + }, + "is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "requires": { + "has-symbols": "^1.0.2" + } + }, + "is-typed-array": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "dev": true, + "requires": { + "which-typed-array": "^1.1.11" + } + }, + "is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true + }, + "is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2" + } + }, + "is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "requires": { + "is-docker": "^2.0.0" + } + }, + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true + }, + "istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true + }, + "istanbul-lib-instrument": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", + "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==", + "dev": true, + "requires": { + "@babel/core": "^7.7.5", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.0.0", + "semver": "^6.3.0" + } + }, + "istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "requires": { + "semver": "^7.5.3" + } + }, + "semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } + }, + "istanbul-lib-source-maps": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-3.0.6.tgz", + "integrity": "sha512-R47KzMtDJH6X4/YW9XTx+jrLnZnscW4VpNN+1PViSYTejLVPWv7oov+Duf8YQSPyVRUvueQqz1TcsC6mooZTXw==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^2.0.5", + "make-dir": "^2.1.0", + "rimraf": "^2.6.3", + "source-map": "^0.6.1" + }, + "dependencies": { + "istanbul-lib-coverage": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz", + "integrity": "sha512-8aXznuEPCJvGnMSRft4udDRDtb1V3pkQkMMI5LI+6HuQz5oQ4J2UFn1H82raA3qJtyOLkkwVqICBQkjnGtn5mA==", + "dev": true + }, + "make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "requires": { + "pify": "^4.0.1", + "semver": "^5.6.0" + } + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true + } + } + }, + "istanbul-reports": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", + "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "dev": true, + "requires": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + } + }, + "jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "joi-browser": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/joi-browser/-/joi-browser-13.4.0.tgz", + "integrity": "sha512-TfzJd2JaJ/lg/gU+q5j9rLAjnfUNF9DUmXTP9w+GfmG79LjFOXFeM7hIFuXCBcZCivUDFwd9l1btTV9rhHumtQ==", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true + }, + "karma": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.2.tgz", + "integrity": "sha512-C6SU/53LB31BEgRg+omznBEMY4SjHU3ricV6zBcAe1EeILKkeScr+fZXtaI5WyDbkVowJxxAI6h73NcFPmXolQ==", + "dev": true, + "requires": { + "@colors/colors": "1.5.0", + "body-parser": "^1.19.0", + "braces": "^3.0.2", + "chokidar": "^3.5.1", + "connect": "^3.7.0", + "di": "^0.0.1", + "dom-serialize": "^2.2.1", + "glob": "^7.1.7", + "graceful-fs": "^4.2.6", + "http-proxy": "^1.18.1", + "isbinaryfile": "^4.0.8", + "lodash": "^4.17.21", + "log4js": "^6.4.1", + "mime": "^2.5.2", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.5", + "qjobs": "^1.2.0", + "range-parser": "^1.2.1", + "rimraf": "^3.0.2", + "socket.io": "^4.4.1", + "source-map": "^0.6.1", + "tmp": "^0.2.1", + "ua-parser-js": "^0.7.30", + "yargs": "^16.1.1" + } + }, + "karma-chai": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/karma-chai/-/karma-chai-0.1.0.tgz", + "integrity": "sha512-mqKCkHwzPMhgTYca10S90aCEX9+HjVjjrBFAsw36Zj7BlQNbokXXCAe6Ji04VUMsxcY5RLP7YphpfO06XOubdg==", + "dev": true, + "requires": {} + }, + "karma-coverage-istanbul-reporter": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/karma-coverage-istanbul-reporter/-/karma-coverage-istanbul-reporter-3.0.3.tgz", + "integrity": "sha512-wE4VFhG/QZv2Y4CdAYWDbMmcAHeS926ZIji4z+FkB2aF/EposRb6DP6G5ncT/wXhqUfAb/d7kZrNKPonbvsATw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^3.0.6", + "istanbul-reports": "^3.0.2", + "minimatch": "^3.0.4" + } + }, + "karma-firefox-launcher": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/karma-firefox-launcher/-/karma-firefox-launcher-2.1.2.tgz", + "integrity": "sha512-VV9xDQU1QIboTrjtGVD4NCfzIH7n01ZXqy/qpBhnOeGVOkG5JYPEm8kuSd7psHE6WouZaQ9Ool92g8LFweSNMA==", + "dev": true, + "requires": { + "is-wsl": "^2.2.0", + "which": "^2.0.1" + } + }, + "karma-json-reporter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/karma-json-reporter/-/karma-json-reporter-1.2.1.tgz", + "integrity": "sha512-ASmvranNhUN0ctSuAZKeWISW9Nf4AteMcVy8rJVjS7Qk+qWgssag/nw+yivHWKDROztVFn7TdamHOETMPCkvgA==", + "dev": true, + "requires": {} + }, + "karma-mocha": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/karma-mocha/-/karma-mocha-2.0.1.tgz", + "integrity": "sha512-Tzd5HBjm8his2OA4bouAsATYEpZrp9vC7z5E5j4C5Of5Rrs1jY67RAwXNcVmd/Bnk1wgvQRou0zGVLey44G4tQ==", + "dev": true, + "requires": { + "minimist": "^1.2.3" + } + }, + "karma-mocha-reporter": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/karma-mocha-reporter/-/karma-mocha-reporter-2.2.5.tgz", + "integrity": "sha512-Hr6nhkIp0GIJJrvzY8JFeHpQZNseuIakGac4bpw8K1+5F0tLb6l7uvXRa8mt2Z+NVwYgCct4QAfp2R2QP6o00w==", + "dev": true, + "requires": { + "chalk": "^2.1.0", + "log-symbols": "^2.1.0", + "strip-ansi": "^4.0.0" + } + }, + "karma-sinon": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/karma-sinon/-/karma-sinon-1.0.5.tgz", + "integrity": "sha512-wrkyAxJmJbn75Dqy17L/8aILJWFm7znd1CE8gkyxTBFnjMSOe2XTJ3P30T8SkxWZHmoHX0SCaUJTDBEoXs25Og==", + "dev": true, + "requires": {} + }, + "karma-sourcemap-loader": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/karma-sourcemap-loader/-/karma-sourcemap-loader-0.3.8.tgz", + "integrity": "sha512-zorxyAakYZuBcHRJE+vbrK2o2JXLFWK8VVjiT/6P+ltLBUGUvqTEkUiQ119MGdOrK7mrmxXHZF1/pfT6GgIZ6g==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2" + } + }, + "karma-webpack": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/karma-webpack/-/karma-webpack-5.0.0.tgz", + "integrity": "sha512-+54i/cd3/piZuP3dr54+NcFeKOPnys5QeM1IY+0SPASwrtHsliXUiCL50iW+K9WWA7RvamC4macvvQ86l3KtaA==", + "dev": true, + "requires": { + "glob": "^7.1.3", + "minimatch": "^3.0.4", + "webpack-merge": "^4.1.5" + } + }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true + }, + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + } + }, + "loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true + }, + "loader-utils": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz", + "integrity": "sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==", + "dev": true + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "lodash.escape": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz", + "integrity": "sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw==", + "dev": true + }, + "lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", + "dev": true + }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "dev": true + }, + "log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "dev": true, + "requires": { + "chalk": "^2.0.1" + } + }, + "log4js": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", + "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", + "dev": true, + "requires": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "flatted": "^3.2.7", + "rfdc": "^1.3.0", + "streamroller": "^3.1.5" + } + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true + }, + "memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true + }, + "merge-source-map": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.1.0.tgz", + "integrity": "sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw==", + "dev": true, + "requires": { + "source-map": "^0.6.1" + } + }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "requires": { + "mime-db": "1.52.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true + }, + "mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "requires": { + "minimist": "^1.2.6" + } + }, + "mocha": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-9.2.2.tgz", + "integrity": "sha512-L6XC3EdwT6YrIk0yXpavvLkn8h+EU+Y5UcCHKECyMbdUIxyMuZj4bX4U9e1nvnvUUvQVsV2VHQr5zLdcUkhW/g==", + "dev": true, + "requires": { + "@ungap/promise-all-settled": "1.1.2", + "ansi-colors": "4.1.1", + "browser-stdout": "1.3.1", + "chokidar": "3.5.3", + "debug": "4.3.3", + "diff": "5.0.0", + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + "glob": "7.2.0", + "growl": "1.10.5", + "he": "1.2.0", + "js-yaml": "4.1.0", + "log-symbols": "4.1.0", + "minimatch": "4.2.1", + "ms": "2.1.3", + "nanoid": "3.3.1", + "serialize-javascript": "6.0.0", + "strip-json-comments": "3.1.1", + "supports-color": "8.1.1", + "which": "2.0.2", + "workerpool": "6.2.0", + "yargs": "16.2.0", + "yargs-parser": "20.2.4", + "yargs-unparser": "2.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "debug": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", + "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", + "dev": true, + "requires": { + "ms": "2.1.2" + }, + "dependencies": { + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "dependencies": { + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + } + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "requires": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + } + }, + "minimatch": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-4.2.1.tgz", + "integrity": "sha512-9Uq1ChtSZO+Mxa/CL1eGizn2vRn3MlLgzhT0Iz8zaY8NdvxvB0d5QdPFmCKf7JKA9Lerx5vRrnwO03jsSfGG9g==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "mock-raf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mock-raf/-/mock-raf-1.0.1.tgz", + "integrity": "sha512-+25y56bblLzEnv+G4ODsHNck07A5uP5HFfu/1VBKeFrUXoFT9oru+R+jLxLz6rwdM5drUHFdqX9LYBsMP4dz/w==", + "dev": true, + "requires": { + "object-assign": "^3.0.0" + } + }, + "moo": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", + "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "nanoid": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz", + "integrity": "sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==", + "dev": true + }, + "nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "dev": true, + "requires": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + } + }, + "negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true + }, + "neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "nise": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.7.tgz", + "integrity": "sha512-wWtNUhkT7k58uvWTB/Gy26eA/EJKtPZFVAhEilN5UYVmmGRYOURbejRUyKm0Uu9XVEW7K5nBOZfR8VMB4QR2RQ==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/text-encoding": "^0.7.2", + "just-extend": "^6.2.0", + "path-to-regexp": "^6.2.1" + }, + "dependencies": { + "@sinonjs/commons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", + "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0" + } + } + } + }, + "node-releases": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "dev": true + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + }, + "dependencies": { + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true + } + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + } + }, + "npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "requires": { + "path-key": "^3.0.0" + }, + "dependencies": { + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + } + } + }, + "nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "requires": { + "boolbase": "^1.0.0" + } + }, + "object-assign": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz", + "integrity": "sha512-jHP15vXVGeVh1HuaA2wY6lxk+whK/x4KBG88VXeRma7CCun7iGD5qPc4eYykQ9sdQvg8jkwFKsSxHln2ybW3xQ==", + "dev": true + }, + "object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "dev": true + }, + "object-is": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", + "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + } + }, + "object.entries": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.7.tgz", + "integrity": "sha512-jCBs/0plmPsOnrKAfFQXRG2NFjlhZgjjcBLSmTnEhU8U6vVTsVe8ANeQJCHTl3gSsI4J+0emOoCgoKlmQPMgmA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "object.fromentries": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", + "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "object.values": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", + "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "requires": { + "ee-first": "1.1.1" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dev": true, + "requires": { + "entities": "^4.4.0" + } + }, + "parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "dev": true, + "requires": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "path-to-regexp": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==", + "dev": true + }, + "path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "dev": true + }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + }, + "pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + } + } + }, + "postcss": { + "version": "8.4.33", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz", + "integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==", + "dev": true, + "peer": true, + "requires": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "dependencies": { + "nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "peer": true + } + } + }, + "postcss-scss": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.6.tgz", + "integrity": "sha512-rLDPhJY4z/i4nVFZ27j9GqLxj1pwxE80eAzUNRMXtcpipFYIeowerzBgG3yJhMtObGEXidtIgbUpQ3eLDsf5OQ==", + "dev": true, + "requires": {} + }, + "prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + }, + "dependencies": { + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + } + } + }, + "prop-types-exact": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/prop-types-exact/-/prop-types-exact-1.2.0.tgz", + "integrity": "sha512-K+Tk3Kd9V0odiXFP9fwDHUYRyvK3Nun3GVyPapSIs5OBkITAm15W0CPFD/YKTkMUAbc0b9CUwRQp2ybiBIq+eA==", + "dev": true, + "requires": { + "has": "^1.0.3", + "object.assign": "^4.1.0", + "reflect.ownkeys": "^0.2.0" + } + }, + "punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true + }, + "qjobs": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", + "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", + "dev": true + }, + "qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dev": true, + "requires": { + "side-channel": "^1.0.4" + } + }, + "raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "dev": true, + "requires": { + "performance-now": "^2.1.0" + } + }, + "railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "dev": true + }, + "randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "dev": true, + "requires": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + } + }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true + }, + "raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "requires": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, + "raw-loader": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz", + "integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==", + "dev": true, + "requires": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "dependencies": { + "loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + }, + "schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "react": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react/-/react-16.13.1.tgz", + "integrity": "sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2" + }, + "dependencies": { + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + } + } + }, + "react-dom": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.13.1.tgz", + "integrity": "sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.19.1" + }, + "dependencies": { + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + } + } + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "react-redux": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz", + "integrity": "sha512-10RPdsz0UUrRL1NZE0ejTkucnclYSgXp5q+tB5SWx2qeG2ZJQJyymgAhwKy73yiL/13btfB6fPr+rgbMAaZIAQ==", + "requires": { + "@babel/runtime": "^7.15.4", + "@types/react-redux": "^7.1.20", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^17.0.2" + }, + "dependencies": { + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + } + } + }, + "react-transition-group": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz", + "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + } + }, + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "requires": { + "picomatch": "^2.2.1" + } + }, + "rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", + "dev": true, + "requires": { + "resolve": "^1.1.6" + } + }, + "redux": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz", + "integrity": "sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==", + "requires": { + "@babel/runtime": "^7.9.2" + } + }, + "reflect.ownkeys": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz", + "integrity": "sha512-qOLsBKHCpSOFKK1NUOCGC5VyeufB6lEsFe92AL2bhIJsacZS1qdoOZSbPk3MYKuT2cFlRDnulKXuuElIrMjGUg==", + "dev": true + }, + "regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, + "regexp.prototype.flags": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "set-function-name": "^2.0.0" + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true + }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, + "resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "requires": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, + "resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "requires": { + "resolve-from": "^5.0.0" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true + }, + "rfdc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", + "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "rst-selector-parser": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz", + "integrity": "sha512-nDG1rZeP6oFTLN6yNDV/uiAvs1+FS/KlrEwh7+y7dpuApDBy6bI2HTBcc0/V8lv9OTqfyD34eF7au2pm8aBbhA==", + "dev": true, + "requires": { + "lodash.flattendeep": "^4.4.0", + "nearley": "^2.7.10" + } + }, + "safe-array-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", + "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + }, + "safe-regex-test": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.2.tgz", + "integrity": "sha512-83S9w6eFq12BBIJYvjMux6/dkirb8+4zJRA9cxNBVb7Wq5fJBW+Xze48WqR8pxua7bDuAaaAxtVVd4Idjp1dBQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.5", + "get-intrinsic": "^1.2.2", + "is-regex": "^1.1.4" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "sass": { + "version": "1.43.4", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.43.4.tgz", + "integrity": "sha512-/ptG7KE9lxpGSYiXn7Ar+lKOv37xfWsZRtFYal2QHNigyVQDx685VFT/h7ejVr+R8w7H4tmUgtulsKl5YpveOg==", + "dev": true, + "requires": { + "chokidar": ">=3.0.0 <4.0.0" + } + }, + "scheduler": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", + "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + }, + "dependencies": { + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + } + } + }, + "schema-utils": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + } + }, + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + }, + "serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, + "set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "dev": true, + "requires": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + } + }, + "set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dev": true, + "requires": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + } + }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true + }, + "shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "requires": { + "kind-of": "^6.0.2" + } + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true + }, + "shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "dev": true + }, + "shelljs": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", + "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", + "dev": true, + "requires": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + } + }, + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + } + }, + "signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "sinon": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-12.0.1.tgz", + "integrity": "sha512-iGu29Xhym33ydkAT+aNQFBINakjq69kKO6ByPvTsm3yyIACfyQttRTP03aBP/I8GfhFmLzrnKwNNkr0ORb1udg==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.8.3", + "@sinonjs/fake-timers": "^8.1.0", + "@sinonjs/samsam": "^6.0.2", + "diff": "^5.0.0", + "nise": "^5.1.0", + "supports-color": "^7.2.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "socket.io": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.4.tgz", + "integrity": "sha512-DcotgfP1Zg9iP/dH9zvAQcWrE0TtbMVwXmlV4T4mqsvY+gw+LqUGPfx2AoVyRk0FLME+GQhufDMyacFmw7ksqw==", + "dev": true, + "requires": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.5.2", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + } + }, + "socket.io-adapter": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.2.tgz", + "integrity": "sha512-87C3LO/NOMc+eMcpcxUBebGjkpMDkNBS9tf7KJqcDsmL936EChtVva71Dw2q4tQcuVC+hAUy4an2NO/sYXmwRA==", + "dev": true, + "requires": { + "ws": "~8.11.0" + } + }, + "socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dev": true, + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true, + "peer": true + }, + "source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.16", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz", + "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==", + "dev": true + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true + }, + "stream-browserify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", + "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", + "dev": true, + "requires": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } + }, + "streamroller": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", + "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", + "dev": true, + "requires": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "fs-extra": "^8.1.0" + } + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "requires": { + "safe-buffer": "~5.2.0" + } + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + } + } + }, + "string.prototype.padend": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.5.tgz", + "integrity": "sha512-DOB27b/2UTTD+4myKUFh+/fXWcu/UDyASIXfg+7VzoCNNGOfWvoyU/x5pvVHr++ztyt/oSYI1BcWBBG/hmlNjA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "string.prototype.trim": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "string.prototype.trimend": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "string.prototype.trimstart": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true + }, + "strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true + }, + "tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true + }, + "terser": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.26.0.tgz", + "integrity": "sha512-dytTGoE2oHgbNV9nTzgBEPaqAWvcJNl66VZ0BkJqlvp71IjO8CxdBx/ykCNb47cLnCmCvRZ6ZR0tLkqvZCdVBQ==", + "dev": true, + "requires": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "dependencies": { + "acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true + } + } + }, + "terser-webpack-plugin": { + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", + "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "^0.3.20", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.26.0" + }, + "dependencies": { + "schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + }, + "serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + } + } + }, + "tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dev": true, + "requires": { + "rimraf": "^3.0.0" + } + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true + }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "typed-array-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", + "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "is-typed-array": "^1.1.10" + } + }, + "typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + } + }, + "typed-array-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", + "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "dev": true, + "requires": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + } + }, + "typed-array-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", + "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "is-typed-array": "^1.1.9" + } + }, + "ua-parser-js": { + "version": "0.7.37", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.37.tgz", + "integrity": "sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA==", + "dev": true + }, + "unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + } + }, + "undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true + }, + "update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "requires": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + } + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dev": true, + "requires": { + "inherits": "2.0.3" + }, + "dependencies": { + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true + } + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true + }, + "void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", + "integrity": "sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==", + "dev": true + }, + "watchpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", + "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "dev": true, + "requires": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + } + }, + "webpack": { + "version": "5.89.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.89.0.tgz", + "integrity": "sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==", + "dev": true, + "requires": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^1.0.0", + "@webassemblyjs/ast": "^1.11.5", + "@webassemblyjs/wasm-edit": "^1.11.5", + "@webassemblyjs/wasm-parser": "^1.11.5", + "acorn": "^8.7.1", + "acorn-import-assertions": "^1.9.0", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.15.0", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.9", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.7", + "watchpack": "^2.4.0", + "webpack-sources": "^3.2.3" + }, + "dependencies": { + "acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true + }, + "schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "webpack-cli": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.9.1.tgz", + "integrity": "sha512-JYRFVuyFpzDxMDB+v/nanUdQYcZtqFPGzmlW4s+UkPMFhSpfRNmf1z4AwYcHJVdvEFAM7FFCQdNTpsBYhDLusQ==", + "dev": true, + "requires": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^1.1.0", + "@webpack-cli/info": "^1.4.0", + "@webpack-cli/serve": "^1.6.0", + "colorette": "^2.0.14", + "commander": "^7.0.0", + "execa": "^5.0.0", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^2.2.0", + "rechoir": "^0.7.0", + "webpack-merge": "^5.7.3" + }, + "dependencies": { + "commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true + }, + "interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "dev": true + }, + "rechoir": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", + "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", + "dev": true, + "requires": { + "resolve": "^1.9.0" + } + }, + "webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "dev": true, + "requires": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + } + } + } + }, + "webpack-merge": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-4.2.2.tgz", + "integrity": "sha512-TUE1UGoTX2Cd42j3krGYqObZbOD+xF7u28WB7tfUordytSjbWTIjK/8V0amkBfTYN4/pB/GIDlJZZ657BGG19g==", + "dev": true, + "requires": { + "lodash": "^4.17.15" + } + }, + "webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "requires": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + } + }, + "which-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "dev": true, + "requires": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.4", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + } + }, + "wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true + }, + "workerpool": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.0.tgz", + "integrity": "sha512-Rsk5qQHJ9eowMH28Jwhe8HEbmdYDX4lwoMWshiCXugjtHqMD9ZbiqSDLxcsfdqsETPzVUtX5s1Z5kStiIM6l4A==", + "dev": true + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + } + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "dev": true, + "requires": {} + }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "yamljs": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/yamljs/-/yamljs-0.3.0.tgz", + "integrity": "sha512-C/FsVVhht4iPQYXOInoxUM/1ELSf9EsgKH34FofQOp6hwCPrW4vG4w5++TED3xRUo8gD7l0P1J1dLlDYzODsTQ==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "glob": "^7.0.5" + }, + "dependencies": { + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + } + } + }, + "yamscripts": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yamscripts/-/yamscripts-0.1.0.tgz", + "integrity": "sha512-i4ThS58KwsK83qSrrc8YZiBqgdl3WewWcWZ4fPdrh7A+qiRU9kXMcIKzngOC7VpJ2nTsWvHG6TcK3JHXpBxACA==", + "dev": true, + "requires": { + "colors": "^1.3.2", + "fs-extra": "^7.0.0", + "minimist": "^1.2.0", + "yamljs": "^0.3.0" + }, + "dependencies": { + "fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + } + } + }, + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + } + }, + "yargs-parser": { + "version": "20.2.4", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", + "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "dev": true + }, + "yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "requires": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + } + }, + "yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true + } + } +} diff --git a/browser/components/newtab/package.json b/browser/components/newtab/package.json new file mode 100644 index 0000000000..b878deb975 --- /dev/null +++ b/browser/components/newtab/package.json @@ -0,0 +1,115 @@ +{ + "name": "activity-streams", + "description": "A rich visual history feed and a reimagined home page make it easier than ever to find exactly what you're looking for in Firefox.\n\nLearn more about this Test Pilot experiment at https://testpilot.firefox.com/.", + "version": "1.14.3", + "author": "Mozilla (https://mozilla.org/)", + "bugs": { + "url": "https://github.com/mozilla/activity-stream/issues" + }, + "dependencies": { + "@fluent/bundle": "0.17.1", + "@fluent/react": "0.15.0", + "react": "16.13.1", + "react-dom": "16.13.1", + "react-redux": "7.2.6", + "react-transition-group": "4.4.2", + "redux": "4.1.2" + }, + "devDependencies": { + "@babel/core": "7.23.5", + "@babel/preset-react": "7.23.3", + "@jsdevtools/coverage-istanbul-loader": "^3.0.5", + "acorn": "8.5.0", + "babel-loader": "8.2.3", + "babel-plugin-jsm-to-esmodules": "0.6.0", + "buffer": "6.0.3", + "chai": "4.3.4", + "enzyme": "3.11.0", + "enzyme-adapter-react-16": "1.15.6", + "joi-browser": "13.4.0", + "karma": "6.4.2", + "karma-chai": "0.1.0", + "karma-coverage-istanbul-reporter": "3.0.3", + "karma-firefox-launcher": "2.1.2", + "karma-json-reporter": "1.2.1", + "karma-mocha": "2.0.1", + "karma-mocha-reporter": "2.2.5", + "karma-sinon": "1.0.5", + "karma-sourcemap-loader": "0.3.8", + "karma-webpack": "5.0.0", + "loader-utils": "3.2.1", + "lodash": "4.17.21", + "mocha": "9.2.2", + "mock-raf": "1.0.1", + "npm-run-all": "4.1.5", + "postcss-scss": "4.0.6", + "prop-types": "15.7.2", + "raw-loader": "4.0.2", + "rimraf": "3.0.2", + "sass": "1.43.4", + "shelljs": "0.8.5", + "sinon": "12.0.1", + "stream-browserify": "3.0.0", + "util": "0.10.4", + "webpack": "5.89.0", + "webpack-cli": "4.9.1", + "yamscripts": "0.1.0" + }, + "engines": { + "firefox": ">=45.0 <=*", + "//": "when changing node versions, also edit .nvmrc", + "node": "16.19.*", + "npm": "8.19.3" + }, + "homepage": "https://github.com/mozilla/activity-stream", + "keywords": [ + "mozilla", + "firefox", + "activity-stream" + ], + "license": "MPL-2.0", + "main": "bootstrap.js", + "repository": "mozilla/activity-stream", + "config": { + "mc_root": "../../..", + "newtab_path": "browser/components/newtab" + }, + "scripts": { + "bundle": "npm-run-all bundle:*", + "bundle:webpack": "webpack-cli --config webpack.system-addon.config.js", + "bundle:css": "sass content-src/styles:css --no-source-map", + "bundle:html": "rimraf prerendered && node ./bin/render-activity-stream-html.js", + "buildmc": "npm-run-all buildmc:*", + "buildmc:bundle": "npm run bundle", + "watchmc": "npm-run-all --parallel watchmc:*", + "watchmc:webpack": "npm run bundle:webpack -- --env development -w", + "watchmc:css": "npm run bundle:css -- --source-map --embed-sources --embed-source-map --load-path=content-src -w", + "testmc": "npm-run-all testmc:*", + "testmc:lint": "npm run lint", + "testmc:build": "npm run bundle:webpack", + "testmc:unit": "karma start karma.mc.config.js", + "tddmc": "karma start karma.mc.config.js --tdd", + "debugcoverage": "open logs/coverage/lcov-report/index.html", + "lint": "npm-run-all lint:*", + "lint:codespell": "(cd $npm_package_config_mc_root && ./mach lint -l codespell $npm_package_config_newtab_path)", + "lint:eslint": "(cd $npm_package_config_mc_root && ./mach lint -l eslint $npm_package_config_newtab_path)", + "lint:l10n": "(cd $npm_package_config_mc_root && ./mach lint -l l10n --warnings soft browser/locales/en-US/browser/newtab)", + "lint:license": "(cd $npm_package_config_mc_root && ./mach lint -l license $npm_package_config_newtab_path)", + "lint:stylelint": "(cd $npm_package_config_mc_root && ./mach lint -l stylelint $npm_package_config_newtab_path)", + "test": "npm run testmc", + "tdd": "npm run tddmc", + "vendor": "node ./bin/vendor.js", + "try": "node ./bin/try-runner.js", + "fix": "npm-run-all fix:*", + "fix:eslint": "npm run lint:eslint -- --fix", + "fix:stylelint": "npm run lint:stylelint -- --fix", + "help": "yamscripts help", + "yamscripts": "yamscripts compile", + "__": "# NOTE: THESE SCRIPTS ARE COMPILED!!! EDIT yamscripts.yml instead!!!" + }, + "title": "Activity Stream", + "permissions": { + "multiprocess": true, + "private-browsing": true + } +} diff --git a/browser/components/newtab/pings.yaml b/browser/components/newtab/pings.yaml new file mode 100644 index 0000000000..f23bc84833 --- /dev/null +++ b/browser/components/newtab/pings.yaml @@ -0,0 +1,75 @@ +# 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/. + +--- +$schema: moz://mozilla.org/schemas/glean/pings/2-0-0 + +newtab: + description: | + Newtab-related instrumentation. + Can be disabled via the `newtabPingEnabled` variable of the `glean` Nimbus + feature, or the `browser.newtabpage.ping.enabled` pref. + reasons: + newtab_session_end: | + The newtab visit ended. + Could be by navigation, being closed, etc. + component_init: | + The newtab component init'd, + and the newtab and homepage settings have been categorized. + This is mostly to ensure we hear at least once from clients configured to + not show a newtab UI. + include_client_id: true + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1766887 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1766887 + notification_emails: + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - anicholson@mozilla.com + - najiang@mozilla.com + +messaging-system: + description: | + This is a ping representing single events triggered by the messaging system + and captures some pings from About:Welcome, ASRouter, and other corners. + include_client_id: false + send_if_empty: false + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1825863 + notification_emails: + - pmcmanis@mozilla.com + - dmosedale@mozilla.com + +top-sites: + description: | + A ping representing a single event happening with or to a TopSite. + Distinguishable by its `ping_type`. + Does not contain a `client_id`, preferring a `context_id` instead. + include_client_id: false + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1836283 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1836283 + notification_emails: + - najiang@mozilla.com + +spoc: + description: | + A ping for submitting the pocket sponsored content's `shim`. + Does not contain a `client_id`. + include_client_id: false + reasons: + impression: A sponsored story was impressed upon the client. + click: A sponsored story was clicked. + save: A sponsored story was saved to Pocket. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1862670 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1862670 + notification_emails: + - najiang@mozilla.com + - chutten@mozilla.com diff --git a/browser/components/newtab/prerendered/activity-stream-debug.html b/browser/components/newtab/prerendered/activity-stream-debug.html new file mode 100644 index 0000000000..b87303eb58 --- /dev/null +++ b/browser/components/newtab/prerendered/activity-stream-debug.html @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + diff --git a/browser/components/newtab/prerendered/activity-stream-noscripts.html b/browser/components/newtab/prerendered/activity-stream-noscripts.html new file mode 100644 index 0000000000..8186565ef3 --- /dev/null +++ b/browser/components/newtab/prerendered/activity-stream-noscripts.html @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + +
    + + + diff --git a/browser/components/newtab/prerendered/activity-stream.html b/browser/components/newtab/prerendered/activity-stream.html new file mode 100644 index 0000000000..86e7f292d8 --- /dev/null +++ b/browser/components/newtab/prerendered/activity-stream.html @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + diff --git a/browser/components/newtab/test/browser/abouthomecache/browser.toml b/browser/components/newtab/test/browser/abouthomecache/browser.toml new file mode 100644 index 0000000000..1994415d9a --- /dev/null +++ b/browser/components/newtab/test/browser/abouthomecache/browser.toml @@ -0,0 +1,52 @@ +[DEFAULT] +support-files = [ + "head.js", + "../topstories.json", +] +prefs = [ + "browser.tabs.remote.separatePrivilegedContentProcess=true", + "browser.startup.homepage.abouthome_cache.enabled=true", + "browser.startup.homepage.abouthome_cache.cache_on_shutdown=false", + "browser.startup.homepage.abouthome_cache.loglevel=All", + "browser.startup.homepage.abouthome_cache.testing=true", + "browser.startup.page=1", + "browser.newtabpage.activity-stream.discoverystream.endpoints=data:", + "browser.newtabpage.activity-stream.feeds.system.topstories=true", + "browser.newtabpage.activity-stream.feeds.section.topstories=true", + "browser.newtabpage.activity-stream.feeds.system.topstories=true", + "browser.newtabpage.activity-stream.feeds.section.topstories.options={\"provider_name\":\"\"}", + "browser.newtabpage.activity-stream.telemetry.structuredIngestion=false", + "browser.newtabpage.activity-stream.discoverystream.endpoints=https://example.com", + "dom.ipc.processPrelaunch.delayMs=0", + # Bug 1694957 is why we need dom.ipc.processPrelaunch.delayMs=0 +] + +["browser_basic_endtoend.js"] + +["browser_bump_version.js"] + +["browser_disabled.js"] + +["browser_experiments_api_control.js"] + +["browser_locale_change.js"] + +["browser_no_cache.js"] + +["browser_no_cache_on_SessionStartup_restore.js"] + +["browser_no_startup_actions.js"] + +["browser_overwrite_cache.js"] + +["browser_process_crash.js"] +skip-if = [ + "!crashreporter", + "os == 'mac' && fission", # Bug 1659427; medium frequency intermittent on osx: test timed out +] + +["browser_same_consumer.js"] + +["browser_sanitize.js"] + +["browser_shutdown_timeout.js"] diff --git a/browser/components/newtab/test/browser/abouthomecache/browser_basic_endtoend.js b/browser/components/newtab/test/browser/abouthomecache/browser_basic_endtoend.js new file mode 100644 index 0000000000..bd42dd4af9 --- /dev/null +++ b/browser/components/newtab/test/browser/abouthomecache/browser_basic_endtoend.js @@ -0,0 +1,22 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the about:home cache gets written on shutdown, and read + * from in the subsequent startup. + */ +add_task(async function test_basic_behaviour() { + await withFullyLoadedAboutHome(async browser => { + // First, clear the cache to test the base case. + await clearCache(); + await simulateRestart(browser); + await ensureCachedAboutHome(browser); + + // Next, test that a subsequent restart also shows the cached + // about:home. + await simulateRestart(browser); + await ensureCachedAboutHome(browser); + }); +}); diff --git a/browser/components/newtab/test/browser/abouthomecache/browser_bump_version.js b/browser/components/newtab/test/browser/abouthomecache/browser_bump_version.js new file mode 100644 index 0000000000..726b9aa973 --- /dev/null +++ b/browser/components/newtab/test/browser/abouthomecache/browser_bump_version.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test that if the "version" metadata on the cache entry doesn't match + * the expectation that we ignore the cache and load the dynamic about:home + * document. + */ +add_task(async function test_bump_version() { + await withFullyLoadedAboutHome(async browser => { + // First, ensure that a pre-existing cache exists. + await simulateRestart(browser); + + let cacheEntry = await AboutHomeStartupCache.ensureCacheEntry(); + Assert.equal( + cacheEntry.getMetaDataElement("version"), + Services.appinfo.appBuildID, + "Cache entry should be versioned on the build ID" + ); + cacheEntry.setMetaDataElement("version", "somethingnew"); + // We don't need to shutdown write or ensure the cache wins the race, + // since we expect the cache to be blown away because the version number + // has been bumped. + await simulateRestart(browser, { + withAutoShutdownWrite: false, + ensureCacheWinsRace: false, + }); + await ensureDynamicAboutHome( + browser, + AboutHomeStartupCache.CACHE_RESULT_SCALARS.INVALIDATED + ); + }); +}); diff --git a/browser/components/newtab/test/browser/abouthomecache/browser_disabled.js b/browser/components/newtab/test/browser/abouthomecache/browser_disabled.js new file mode 100644 index 0000000000..faa79b219c --- /dev/null +++ b/browser/components/newtab/test/browser/abouthomecache/browser_disabled.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This file tests scenarios where the cache is disabled due to user + * configuration. + */ + +registerCleanupFunction(async () => { + // When the test completes, make sure we cleanup with a populated cache, + // since this is the default starting state for these tests. + await withFullyLoadedAboutHome(async browser => { + await simulateRestart(browser); + }); +}); + +/** + * Tests the case where the cache is disabled via the pref. + */ +add_task(async function test_cache_disabled() { + await withFullyLoadedAboutHome(async browser => { + await SpecialPowers.pushPrefEnv({ + set: [["browser.startup.homepage.abouthome_cache.enabled", false]], + }); + + await simulateRestart(browser); + + await ensureDynamicAboutHome( + browser, + AboutHomeStartupCache.CACHE_RESULT_SCALARS.DISABLED + ); + + await SpecialPowers.popPrefEnv(); + }); +}); + +/** + * Tests the case where the cache is disabled because the home page is + * not set at about:home. + */ +add_task(async function test_cache_custom_homepage() { + await withFullyLoadedAboutHome(async browser => { + await HomePage.set("https://example.com"); + await simulateRestart(browser); + + await ensureDynamicAboutHome( + browser, + AboutHomeStartupCache.CACHE_RESULT_SCALARS.NOT_LOADING_ABOUTHOME + ); + + HomePage.reset(); + }); +}); + +/** + * Tests the case where the cache is disabled because the session is + * configured to automatically be restored. + */ +add_task(async function test_cache_restore_session() { + await withFullyLoadedAboutHome(async browser => { + await SpecialPowers.pushPrefEnv({ + set: [["browser.startup.page", 3]], + }); + + await simulateRestart(browser); + + await ensureDynamicAboutHome( + browser, + AboutHomeStartupCache.CACHE_RESULT_SCALARS.NOT_LOADING_ABOUTHOME + ); + + await SpecialPowers.popPrefEnv(); + }); +}); + +/** + * Tests the case where the cache is disabled because about:newtab + * preloading is disabled. + */ +add_task(async function test_cache_no_preloading() { + await withFullyLoadedAboutHome(async browser => { + await SpecialPowers.pushPrefEnv({ + set: [["browser.newtab.preload", false]], + }); + + await simulateRestart(browser); + + await ensureDynamicAboutHome( + browser, + AboutHomeStartupCache.CACHE_RESULT_SCALARS.PRELOADING_DISABLED + ); + + await SpecialPowers.popPrefEnv(); + }); +}); 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 new file mode 100644 index 0000000000..a94f1fe055 --- /dev/null +++ b/browser/components/newtab/test/browser/abouthomecache/browser_experiments_api_control.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ExperimentFakes } = ChromeUtils.importESModule( + "resource://testing-common/NimbusTestUtils.sys.mjs" +); + +registerCleanupFunction(async () => { + // When the test completes, make sure we cleanup with a populated cache, + // since this is the default starting state for these tests. + await withFullyLoadedAboutHome(async browser => { + await simulateRestart(browser); + }); +}); + +/** + * Tests that the ExperimentsAPI mechanism can be used to remotely + * enable and disable the about:home startup cache. + */ +add_task(async function test_experiments_api_control() { + // First, the disabled case. + await withFullyLoadedAboutHome(async browser => { + let doEnrollmentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "abouthomecache", + value: { enabled: false }, + }); + + Assert.ok( + !NimbusFeatures.abouthomecache.getVariable("enabled"), + "NimbusFeatures should tell us that the about:home startup cache " + + "is disabled" + ); + + await simulateRestart(browser); + + await ensureDynamicAboutHome( + browser, + AboutHomeStartupCache.CACHE_RESULT_SCALARS.DISABLED + ); + + await doEnrollmentCleanup(); + }); + + // Now the enabled case. + await withFullyLoadedAboutHome(async browser => { + let doEnrollmentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "abouthomecache", + value: { enabled: true }, + }); + + Assert.ok( + NimbusFeatures.abouthomecache.getVariable("enabled"), + "NimbusFeatures should tell us that the about:home startup cache " + + "is enabled" + ); + + await simulateRestart(browser); + await ensureCachedAboutHome(browser); + await doEnrollmentCleanup(); + }); +}); diff --git a/browser/components/newtab/test/browser/abouthomecache/browser_locale_change.js b/browser/components/newtab/test/browser/abouthomecache/browser_locale_change.js new file mode 100644 index 0000000000..e9e3c619ec --- /dev/null +++ b/browser/components/newtab/test/browser/abouthomecache/browser_locale_change.js @@ -0,0 +1,30 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the about:home startup cache is cleared if the app + * locale changes. + */ +add_task(async function test_locale_change() { + await withFullyLoadedAboutHome(async browser => { + await simulateRestart(browser); + await ensureCachedAboutHome(browser); + + Services.obs.notifyObservers(null, "intl:app-locales-changed"); + await AboutHomeStartupCache.ensureCacheEntry(); + + // We're testing that switching locales blows away the cache, so we + // bypass the automatic writing of the cache on shutdown, and we + // also don't need to wait for the cache to be available. + await simulateRestart(browser, { + withAutoShutdownWrite: false, + ensureCacheWinsRace: false, + }); + await ensureDynamicAboutHome( + browser, + AboutHomeStartupCache.CACHE_RESULT_SCALARS.DOES_NOT_EXIST + ); + }); +}); diff --git a/browser/components/newtab/test/browser/abouthomecache/browser_no_cache.js b/browser/components/newtab/test/browser/abouthomecache/browser_no_cache.js new file mode 100644 index 0000000000..fdb51f8712 --- /dev/null +++ b/browser/components/newtab/test/browser/abouthomecache/browser_no_cache.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +/** + * Test that if there's no cache written, that we load the dynamic + * about:home document on startup. + */ +add_task(async function test_no_cache() { + await withFullyLoadedAboutHome(async browser => { + await clearCache(); + // We're testing the no-cache case, so we bypass the automatic writing + // of the cache on shutdown, and we also don't need to wait for the + // cache to be available. + await simulateRestart(browser, { + withAutoShutdownWrite: false, + ensureCacheWinsRace: false, + }); + await ensureDynamicAboutHome( + browser, + AboutHomeStartupCache.CACHE_RESULT_SCALARS.DOES_NOT_EXIST + ); + }); +}); diff --git a/browser/components/newtab/test/browser/abouthomecache/browser_no_cache_on_SessionStartup_restore.js b/browser/components/newtab/test/browser/abouthomecache/browser_no_cache_on_SessionStartup_restore.js new file mode 100644 index 0000000000..a312b2b44f --- /dev/null +++ b/browser/components/newtab/test/browser/abouthomecache/browser_no_cache_on_SessionStartup_restore.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if somehow about:newtab loads before about:home does, that we + * don't use the cache. This is because about:newtab doesn't use the cache, + * and so it'll inevitably be newer than what's in the about:home cache, + * which will put the about:home cache out of date the next time about:home + * eventually loads. + */ +add_task(async function test_no_cache_on_SessionStartup_restore() { + await withFullyLoadedAboutHome(async browser => { + await simulateRestart(browser, { skipAboutHomeLoad: true }); + + // We remove the preloaded browser to ensure that loading the next + // about:newtab occurs now, and not at preloading time. + NewTabPagePreloading.removePreloadedBrowser(window); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:newtab" + ); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + // The cache is disqualified because about:newtab was loaded first. + // So now it's too late to use the cache. + await ensureDynamicAboutHome( + newWin.gBrowser.selectedBrowser, + AboutHomeStartupCache.CACHE_RESULT_SCALARS.LATE + ); + + await BrowserTestUtils.closeWindow(newWin); + await BrowserTestUtils.removeTab(tab); + }); +}); diff --git a/browser/components/newtab/test/browser/abouthomecache/browser_no_startup_actions.js b/browser/components/newtab/test/browser/abouthomecache/browser_no_startup_actions.js new file mode 100644 index 0000000000..255b4c9d21 --- /dev/null +++ b/browser/components/newtab/test/browser/abouthomecache/browser_no_startup_actions.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that upon initializing Activity Stream, the cached about:home + * document does not process any actions caused by that initialization. + * This is because the restored Redux state from the cache should be enough, + * and processing any of the initialization messages from Activity Stream + * could wipe out that state and cause flicker / unnecessary redraws. + */ +add_task(async function test_no_startup_actions() { + await withFullyLoadedAboutHome(async browser => { + // Make sure we have a cached document. We simulate a restart to ensure + // that we start with a cache... that we can then clear without a problem, + // before writing a new cache. This ensures that no matter what, we're in a + // state where we have a fresh cache, regardless of what's happened in earlier + // tests. + await simulateRestart(browser); + await clearCache(); + await simulateRestart(browser); + await ensureCachedAboutHome(browser); + + // Set up a listener to monitor for actions that get dispatched in the + // browser when we fire Activity Stream up again. + await SpecialPowers.spawn(browser, [], async () => { + let xrayWindow = ChromeUtils.waiveXrays(content); + xrayWindow.nonStartupActions = []; + xrayWindow.startupActions = []; + xrayWindow.RPMAddMessageListener("ActivityStream:MainToContent", msg => { + if (msg.data.meta.isStartup) { + xrayWindow.startupActions.push(msg.data); + } else { + xrayWindow.nonStartupActions.push(msg.data); + } + }); + }); + + // The following two statements seem to be enough to simulate Activity + // Stream starting up. + AboutNewTab.activityStream.uninit(); + AboutNewTab.onBrowserReady(); + + // Much of Activity Stream initializes asynchronously. This is the easiest way + // I could find to ensure that enough of the feeds had initialized to produce + // a meaningful cached document. + await TestUtils.waitForCondition(() => { + let feed = AboutNewTab.activityStream.store.feeds.get( + "feeds.discoverystreamfeed" + ); + return feed?.loaded; + }); + + // Wait an additional few seconds for any other actions to get displayed. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 2000)); + + let [startupActions, nonStartupActions] = await SpecialPowers.spawn( + browser, + [], + async () => { + let xrayWindow = ChromeUtils.waiveXrays(content); + return [xrayWindow.startupActions, xrayWindow.nonStartupActions]; + } + ); + + Assert.ok(!!startupActions.length, "Should have seen startup actions."); + info(`Saw ${startupActions.length} startup actions.`); + + Assert.equal( + nonStartupActions.length, + 0, + "Should be no non-startup actions." + ); + + if (nonStartupActions.length) { + for (let action of nonStartupActions) { + info(`Non-startup action: ${action.type}`); + } + } + }); +}); diff --git a/browser/components/newtab/test/browser/abouthomecache/browser_overwrite_cache.js b/browser/components/newtab/test/browser/abouthomecache/browser_overwrite_cache.js new file mode 100644 index 0000000000..22df98794f --- /dev/null +++ b/browser/components/newtab/test/browser/abouthomecache/browser_overwrite_cache.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if a pre-existing about:home cache exists, that it can + * be overwritten with new information. + */ +add_task(async function test_overwrite_cache() { + await withFullyLoadedAboutHome(async browser => { + await simulateRestart(browser); + const TEST_ID = "test_overwrite_cache_h1"; + + // We need the CSP meta tag in about: pages, otherwise we hit assertions in + // debug builds. + await injectIntoCache( + ` + + + + + +

    Something new

    +
    + + + `, + "window.__FROM_STARTUP_CACHE__ = true;" + ); + await simulateRestart(browser, { withAutoShutdownWrite: false }); + + await SpecialPowers.spawn(browser, [TEST_ID], async testID => { + let target = content.document.getElementById(testID); + Assert.ok(target, "Found the target element"); + }); + }); +}); diff --git a/browser/components/newtab/test/browser/abouthomecache/browser_process_crash.js b/browser/components/newtab/test/browser/abouthomecache/browser_process_crash.js new file mode 100644 index 0000000000..d3bfa383c2 --- /dev/null +++ b/browser/components/newtab/test/browser/abouthomecache/browser_process_crash.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test that if the "privileged about content process" crashes, that it + * drops its internal reference to the "privileged about content process" + * process manager, and that a subsequent restart of that process type + * results in a dynamic document load. Also tests that crashing of + * any other content process type doesn't clear the process manager + * reference. + */ +add_task(async function test_process_crash() { + await withFullyLoadedAboutHome(async browser => { + await simulateRestart(browser); + let origProcManager = AboutHomeStartupCache._procManager; + + await BrowserTestUtils.crashFrame(browser); + Assert.notEqual( + origProcManager, + AboutHomeStartupCache._procManager, + "Should have dropped the reference to the crashed process" + ); + }); + + await withFullyLoadedAboutHome(async browser => { + // The cache should still be considered "valid and used", since it was + // used successfully before the crash. + await ensureDynamicAboutHome( + browser, + AboutHomeStartupCache.CACHE_RESULT_SCALARS.VALID_AND_USED + ); + + // Now simulate a restart to attach the AboutHomeStartupCache to + // the new privileged about content process. + await simulateRestart(browser); + }); + + let latestProcManager = AboutHomeStartupCache._procManager; + + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + await BrowserTestUtils.withNewTab("http://example.com", async browser => { + await BrowserTestUtils.crashFrame(browser); + Assert.equal( + latestProcManager, + AboutHomeStartupCache._procManager, + "Should still have the reference to the privileged about process" + ); + }); +}); + +/** + * Tests that if the "privileged about content process" crashes while + * a cache request is still underway, that the cache request resolves with + * null input streams. + */ +add_task(async function test_process_crash_while_requesting_streams() { + await withFullyLoadedAboutHome(async browser => { + await simulateRestart(browser); + let cacheStreamsPromise = AboutHomeStartupCache.requestCache(); + await BrowserTestUtils.crashFrame(browser); + let cacheStreams = await cacheStreamsPromise; + + if (!cacheStreams.pageInputStream && !cacheStreams.scriptInputStream) { + Assert.ok(true, "Page and script input streams are null."); + } else { + // It's possible (but probably rare) the parent was able to receive the + // streams before the crash occurred. In that case, we'll make sure that + // we can still read the streams. + info("Received the streams. Checking that they're readable."); + Assert.ok( + cacheStreams.pageInputStream.available(), + "Bytes available for page stream" + ); + Assert.ok( + cacheStreams.scriptInputStream.available(), + "Bytes available for script stream" + ); + } + }); +}); diff --git a/browser/components/newtab/test/browser/abouthomecache/browser_same_consumer.js b/browser/components/newtab/test/browser/abouthomecache/browser_same_consumer.js new file mode 100644 index 0000000000..75f8875f26 --- /dev/null +++ b/browser/components/newtab/test/browser/abouthomecache/browser_same_consumer.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if a page attempts to load the script stream without + * having also loaded the page stream, that it will fail and get + * the default non-cached script. + */ +add_task(async function test_same_consumer() { + await withFullyLoadedAboutHome(async browser => { + await simulateRestart(browser); + + // We need the CSP meta tag in about: pages, otherwise we hit assertions in + // debug builds. + // + // We inject a script that sets a __CACHE_CONSUMED__ property to true on + // the window element. We'll test to ensure that if we try to load the + // script cache from a different BrowsingContext that this property is + // not set. + await injectIntoCache( + ` + + + + + +

    A fake about:home page

    +
    + + `, + "window.__CACHE_CONSUMED__ = true;" + ); + await simulateRestart(browser, { withAutoShutdownWrite: false }); + + // Attempting to load the script from the cache should fail, and instead load + // the markup. + await BrowserTestUtils.withNewTab("about:home?jscache", async browser2 => { + await SpecialPowers.spawn(browser2, [], async () => { + Assert.ok( + !Cu.waiveXrays(content).__CACHE_CONSUMED__, + "Should not have found __CACHE_CONSUMED__ property" + ); + Assert.ok( + content.document.body.classList.contains("activity-stream"), + "Should have found activity-stream class on element" + ); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/browser/abouthomecache/browser_sanitize.js b/browser/components/newtab/test/browser/abouthomecache/browser_sanitize.js new file mode 100644 index 0000000000..4dc7ba2c89 --- /dev/null +++ b/browser/components/newtab/test/browser/abouthomecache/browser_sanitize.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that when sanitizing places history, session store or downloads, that + * the about:home cache gets blown away. + */ + +add_task(async function test_sanitize() { + let testFlags = [ + ["downloads", Ci.nsIClearDataService.CLEAR_DOWNLOADS], + ["places history", Ci.nsIClearDataService.CLEAR_HISTORY], + ["session history", Ci.nsIClearDataService.CLEAR_SESSION_HISTORY], + ]; + + await withFullyLoadedAboutHome(async browser => { + for (let [type, flag] of testFlags) { + await simulateRestart(browser); + await ensureCachedAboutHome(browser); + + info( + "Testing that the about:home startup cache is cleared when " + + `clearing ${type}` + ); + + await new Promise((resolve, reject) => { + Services.clearData.deleteData(flag, { + onDataDeleted(resultFlags) { + if (!resultFlags) { + resolve(); + } else { + reject(new Error(`Failed with flags: ${resultFlags}`)); + } + }, + }); + }); + + // For the purposes of the test, we don't want the write-on-shutdown + // behaviour here (because we just want to test that the cache doesn't + // exist on startup if the history data was cleared). We also therefore + // don't need to ensure that the cache wins the race. + await simulateRestart(browser, { + withAutoShutdownWrite: false, + ensureCacheWinsRace: false, + }); + await ensureDynamicAboutHome( + browser, + AboutHomeStartupCache.CACHE_RESULT_SCALARS.DOES_NOT_EXIST + ); + } + }); +}); diff --git a/browser/components/newtab/test/browser/abouthomecache/browser_shutdown_timeout.js b/browser/components/newtab/test/browser/abouthomecache/browser_shutdown_timeout.js new file mode 100644 index 0000000000..b1600bfe00 --- /dev/null +++ b/browser/components/newtab/test/browser/abouthomecache/browser_shutdown_timeout.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if there's a substantial delay in getting the cache + * streams from the privileged about content process for any reason + * during shutdown, that we timeout and let the AsyncShutdown proceed, + * rather than letting it block until AsyncShutdown causes a shutdown + * hang crash. + */ +add_task(async function test_shutdown_timeout() { + await withFullyLoadedAboutHome(async browser => { + // First, make sure the cache is populated so that later on, after + // the timeout, simulateRestart doesn't complain about not finding + // a pre-existing cache. This complaining only happens if this test + // is run in isolation. + await clearCache(); + await simulateRestart(browser); + + // Next, manually shutdown the AboutHomeStartupCacheChild so that + // it doesn't respond to requests to the cache streams. + await SpecialPowers.spawn(browser, [], async () => { + let { AboutHomeStartupCacheChild } = ChromeUtils.importESModule( + "resource:///modules/AboutNewTabService.sys.mjs" + ); + AboutHomeStartupCacheChild.uninit(); + }); + + // Then, manually dirty the cache state so that we attempt to write + // on shutdown. + AboutHomeStartupCache.onPreloadedNewTabMessage(); + + await simulateRestart(browser, { expectTimeout: true }); + + Assert.ok( + true, + "We reached here, which means shutdown didn't block forever." + ); + + // Clear the cache so that we're not in a half-persisted state. + await clearCache(); + }); +}); diff --git a/browser/components/newtab/test/browser/abouthomecache/head.js b/browser/components/newtab/test/browser/abouthomecache/head.js new file mode 100644 index 0000000000..5599b2bd10 --- /dev/null +++ b/browser/components/newtab/test/browser/abouthomecache/head.js @@ -0,0 +1,365 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let { AboutHomeStartupCache } = ChromeUtils.importESModule( + "resource:///modules/BrowserGlue.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { DiscoveryStreamFeed } = ChromeUtils.importESModule( + "resource://activity-stream/lib/DiscoveryStreamFeed.sys.mjs" +); + +// Some Activity Stream preferences are JSON encoded, and quite complex. +// Hard-coding them here or in browser.ini makes them brittle to change. +// Instead, we pull the default prefs structures and set the values that +// we need and write them to preferences here dynamically. We do this in +// its own scope to avoid polluting the global scope. +{ + const { PREFS_CONFIG } = ChromeUtils.importESModule( + "resource://activity-stream/lib/ActivityStream.sys.mjs" + ); + + let defaultDSConfig = JSON.parse( + PREFS_CONFIG.get("discoverystream.config").getValue({ + geo: "US", + locale: "en-US", + }) + ); + + // Configure Activity Stream to query for the layout JSON file that points + // at the local top stories feed. + Services.prefs.setCharPref( + "browser.newtabpage.activity-stream.discoverystream.config", + JSON.stringify(defaultDSConfig) + ); +} + +/** + * Utility function that loads about:home in the current window in a new tab, and waits + * for the Discovery Stream cards to finish loading before running the taskFn function. + * Once taskFn exits, the about:home tab will be closed. + * + * @param {function} taskFn + * A function that will be run after about:home has finished loading. This can be + * an async function. + * @return {Promise} + * @resolves {undefined} + */ +function withFullyLoadedAboutHome(taskFn) { + const sandbox = sinon.createSandbox(); + sandbox + .stub(DiscoveryStreamFeed.prototype, "generateFeedUrl") + .returns( + "https://example.com/browser/browser/components/newtab/test/browser/topstories.json" + ); + + return BrowserTestUtils.withNewTab("about:home", async browser => { + await SpecialPowers.spawn(browser, [], async () => { + await ContentTaskUtils.waitForCondition( + () => + content.document.querySelectorAll( + "[data-section-id='topstories'] .ds-card-link" + ).length, + "Waiting for Discovery Stream to be rendered." + ); + }); + + await taskFn(browser); + sandbox.restore(); + }); +} + +/** + * Shuts down the AboutHomeStartupCache components in the parent process + * and privileged about content process, and then restarts them, simulating + * the parent process having restarted. + * + * @param browser () + * A with about:home running in it. This will be reloaded + * after the restart simultion is complete, and that reload will attempt + * to read any about:home cache contents. + * @param options (object, optional) + * + * An object with the following properties: + * + * withAutoShutdownWrite (boolean, optional): + * Whether or not the shutdown part of the simulation should cause the + * shutdown handler to run, which normally causes the cache to be + * written. Setting this to false is handy if the cache has been + * specially prepared for the subsequent startup, and we don't want to + * overwrite it. This defaults to true. + * + * ensureCacheWinsRace (boolean, optional): + * Ensures that the privileged about content process will be able to + * read the bytes from the streams sent down from the HTTP cache. Use + * this to avoid the HTTP cache "losing the race" against reading the + * about:home document from the omni.ja. This defaults to true. + * + * expectTimeout (boolean, optional): + * If true, indicates that it's expected that AboutHomeStartupCache will + * timeout when shutting down. If false, such timeouts will result in + * test failures. Defaults to false. + * + * skipAboutHomeLoad (boolean, optional): + * If true, doesn't automatically load about:home after the simulated + * restart. Defaults to false. + * + * @returns Promise + * @resolves undefined + * Resolves once the restart simulation is complete, and the + * pointed at about:home finishes reloading. + */ +async function simulateRestart( + browser, + { + withAutoShutdownWrite = true, + ensureCacheWinsRace = true, + expectTimeout = false, + skipAboutHomeLoad = false, + } = {} +) { + info("Simulating restart of the browser"); + if (browser.remoteType !== E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE) { + throw new Error( + "prepareLoadFromCache should only be called on a browser " + + "loaded in the privileged about content process." + ); + } + + if (withAutoShutdownWrite && AboutHomeStartupCache.initted) { + info("Simulating shutdown write"); + let timedOut = !(await AboutHomeStartupCache.onShutdown(expectTimeout)); + if (timedOut && !expectTimeout) { + Assert.ok( + false, + "AboutHomeStartupCache shutdown unexpectedly timed out." + ); + } else if (!timedOut && expectTimeout) { + Assert.ok(false, "AboutHomeStartupCache shutdown failed to time out."); + } + info("Shutdown write done"); + } else { + info("Intentionally skipping shutdown write"); + } + + AboutHomeStartupCache.uninit(); + + info("Waiting for AboutHomeStartupCacheChild to uninit"); + await SpecialPowers.spawn(browser, [], async () => { + let { AboutHomeStartupCacheChild } = ChromeUtils.importESModule( + "resource:///modules/AboutNewTabService.sys.mjs" + ); + AboutHomeStartupCacheChild.uninit(); + }); + info("AboutHomeStartupCacheChild uninitted"); + + AboutHomeStartupCache.init(); + + if (AboutHomeStartupCache.initted) { + let processManager = browser.messageManager.processMessageManager; + let pp = browser.browsingContext.currentWindowGlobal.domProcess; + let { childID } = pp; + AboutHomeStartupCache.onContentProcessCreated(childID, processManager, pp); + + info("Waiting for AboutHomeStartupCache cache entry"); + await AboutHomeStartupCache.ensureCacheEntry(); + info("Got AboutHomeStartupCache cache entry"); + + if (ensureCacheWinsRace) { + info("Ensuring cache bytes are available"); + await SpecialPowers.spawn(browser, [], async () => { + let { AboutHomeStartupCacheChild } = ChromeUtils.importESModule( + "resource:///modules/AboutNewTabService.sys.mjs" + ); + let pageStream = AboutHomeStartupCacheChild._pageInputStream; + let scriptStream = AboutHomeStartupCacheChild._scriptInputStream; + await ContentTaskUtils.waitForCondition(() => { + return pageStream.available() && scriptStream.available(); + }); + }); + } + } + + if (!skipAboutHomeLoad) { + info("Waiting for about:home to load"); + let loaded = BrowserTestUtils.browserLoaded(browser, false, "about:home"); + BrowserTestUtils.startLoadingURIString(browser, "about:home"); + await loaded; + info("about:home loaded"); + } +} + +/** + * Writes a page string and a script string into the cache for + * the next about:home load. + * + * @param page (String) + * The HTML content to write into the cache. This cannot be the empty + * string. Note that this string should contain a node that has an + * id of "root", in order for the newtab scripts to attach correctly. + * Otherwise, an exception might get thrown which can cause shutdown + * leaks. + * @param script (String) + * The JS content to write into the cache that can be loaded via + * about:home?jscache. This cannot be the empty string. + * @returns Promise + * @resolves undefined + * When the page and script content has been successfully written. + */ +async function injectIntoCache(page, script) { + if (!page || !script) { + throw new Error("Cannot injectIntoCache with falsey values"); + } + + if (!page.includes(`id="root"`)) { + throw new Error("Page markup must include a root node."); + } + + await AboutHomeStartupCache.ensureCacheEntry(); + + let pageInputStream = Cc[ + "@mozilla.org/io/string-input-stream;1" + ].createInstance(Ci.nsIStringInputStream); + + pageInputStream.setUTF8Data(page); + + let scriptInputStream = Cc[ + "@mozilla.org/io/string-input-stream;1" + ].createInstance(Ci.nsIStringInputStream); + + scriptInputStream.setUTF8Data(script); + + await AboutHomeStartupCache.populateCache(pageInputStream, scriptInputStream); +} + +/** + * Clears out any pre-existing about:home cache. + * @returns Promise + * @resolves undefined + * Resolves when the cache is cleared. + */ +async function clearCache() { + info("Test is clearing the cache"); + AboutHomeStartupCache.clearCache(); + await AboutHomeStartupCache.ensureCacheEntry(); + info("Test has cleared the cache."); +} + +/** + * Checks that the browser.startup.abouthome_cache_result scalar was + * recorded at a particular value. + * + * @param cacheResultScalar (Number) + * One of the AboutHomeStartupCache.CACHE_RESULT_SCALARS values. + */ +function assertCacheResultScalar(cacheResultScalar) { + let parentScalars = Services.telemetry.getSnapshotForScalars("main").parent; + Assert.equal( + parentScalars["browser.startup.abouthome_cache_result"], + cacheResultScalar, + "Expected the right value set to browser.startup.abouthome_cache_result " + + "scalar." + ); +} + +/** + * Tests that the about:home document loaded in a passed was + * one from the cache. + * + * We test for this by looking for some tell-tale signs of the cached + * document: + * + * 1. The about:home?jscache `, + ], + { type: "text/html" } + ) + ); + content.document.location = url; + } + ); + + // Wait for the Blob: URI to be loaded. + await BrowserTestUtils.browserLoaded(browser, false, function (url) { + info("BrowserTestUtils.browserLoaded url=" + url); + return url.startsWith("blob:http://mochi.test:8888/"); + }); + + // We verify the blob document has correct origin attributes. + // Then we inject an iframe to it. + await SpecialPowers.spawn( + browser, + [{ firstPartyDomain: BASE_DOMAIN }], + async function (attrs) { + Assert.ok( + content.document.documentURI.startsWith("blob:http://mochi.test:8888/"), + "the document URI should be a blob URI." + ); + Assert.ok(true, "origin " + content.document.nodePrincipal.origin); + Assert.equal( + content.document.nodePrincipal.originAttributes.firstPartyDomain, + attrs.firstPartyDomain, + "The document should have firstPartyDomain" + ); + + let iframe = content.document.createElement("iframe"); + iframe.src = "http://example.com"; + iframe.id = "iframe1"; + content.document.body.appendChild(iframe); + + // Wait for the iframe to be loaded. + await new content.Promise(done => { + iframe.addEventListener( + "load", + function () { + done(); + }, + { capture: true, once: true } + ); + }); + } + ); + + // Finally we verify the iframe has correct origin attributes. + await SpecialPowers.spawn( + browser, + [{ firstPartyDomain: BASE_DOMAIN }], + async function (attrs) { + let iframe = content.document.getElementById("iframe1"); + await SpecialPowers.spawn( + iframe, + [attrs.firstPartyDomain], + function (firstPartyDomain) { + Assert.equal( + content.document.nodePrincipal.originAttributes.firstPartyDomain, + firstPartyDomain, + "iframe should inherit firstPartyDomain from blob: URI" + ); + } + ); + } + ); + + win.close(); +}); diff --git a/browser/components/originattributes/test/browser/browser_firstPartyIsolation_js_uri.js b/browser/components/originattributes/test/browser/browser_firstPartyIsolation_js_uri.js new file mode 100644 index 0000000000..8aa199d47f --- /dev/null +++ b/browser/components/originattributes/test/browser/browser_firstPartyIsolation_js_uri.js @@ -0,0 +1,90 @@ +add_setup(async function () { + Services.prefs.setBoolPref("privacy.firstparty.isolate", true); + + registerCleanupFunction(function () { + Services.prefs.clearUserPref("privacy.firstparty.isolate"); + }); +}); + +add_task(async function test_remote_window_open_js_uri() { + let win = await BrowserTestUtils.openNewBrowserWindow({ remote: true }); + let browser = win.gBrowser.selectedBrowser; + + Assert.ok(browser.isRemoteBrowser, "should be a remote browser"); + + BrowserTestUtils.startLoadingURIString(browser, `javascript:1;`); + + await BrowserTestUtils.browserLoaded(browser); + + await SpecialPowers.spawn(browser, [], async function () { + Assert.ok(true, "origin " + content.document.nodePrincipal.origin); + + Assert.ok( + content.document.nodePrincipal.isNullPrincipal, + "The principal of remote javascript: should be a NullPrincipal." + ); + + let str = content.document.nodePrincipal.originNoSuffix; + let expectDomain = + str.substring("moz-nullprincipal:{".length, str.length - 1) + ".mozilla"; + Assert.equal( + content.document.nodePrincipal.originAttributes.firstPartyDomain, + expectDomain, + "remote javascript: should have firstPartyDomain set to " + expectDomain + ); + }); + + win.close(); +}); + +add_task(async function test_remote_window_open_js_uri2() { + let win = await BrowserTestUtils.openNewBrowserWindow({ remote: true }); + let browser = win.gBrowser.selectedBrowser; + + Assert.ok(browser.isRemoteBrowser, "should be a remote browser"); + + BrowserTestUtils.startLoadingURIString( + browser, + `javascript: + let iframe = document.createElement("iframe"); + iframe.src = "http://example.com"; + iframe.id = "iframe1"; + document.body.appendChild(iframe); + void(0); + ` + ); + + await BrowserTestUtils.browserLoaded(browser, true, function (url) { + info("URL:" + url); + return url == "http://example.com/"; + }); + + await SpecialPowers.spawn(browser, [], async function () { + Assert.ok(true, "origin " + content.document.nodePrincipal.origin); + + Assert.ok( + content.document.nodePrincipal.isNullPrincipal, + "The principal of remote javascript: should be a NullPrincipal." + ); + + let str = content.document.nodePrincipal.originNoSuffix; + let expectDomain = + str.substring("moz-nullprincipal:{".length, str.length - 1) + ".mozilla"; + Assert.equal( + content.document.nodePrincipal.originAttributes.firstPartyDomain, + expectDomain, + "remote javascript: should have firstPartyDomain set to " + expectDomain + ); + + let iframe = content.document.getElementById("iframe1"); + await SpecialPowers.spawn(iframe, [expectDomain], function (domain) { + Assert.equal( + content.document.nodePrincipal.originAttributes.firstPartyDomain, + domain, + "iframe should have firstPartyDomain set to " + domain + ); + }); + }); + + win.close(); +}); diff --git a/browser/components/originattributes/test/browser/browser_firstPartyIsolation_saveAs.js b/browser/components/originattributes/test/browser/browser_firstPartyIsolation_saveAs.js new file mode 100644 index 0000000000..b51ac12849 --- /dev/null +++ b/browser/components/originattributes/test/browser/browser_firstPartyIsolation_saveAs.js @@ -0,0 +1,326 @@ +/** + * Bug 1508355 - A test case for ensuring the saving channel has a correct first + * party domain when going through different "Save ... AS." + */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/toolkit/content/tests/browser/common/mockTransfer.js", + this +); + +const TEST_FIRST_PARTY = "example.com"; +const TEST_ORIGIN = `http://${TEST_FIRST_PARTY}`; +const TEST_BASE_PATH = + "/browser/browser/components/originattributes/test/browser/"; +const TEST_PATH = `${TEST_BASE_PATH}file_saveAs.sjs`; +const TEST_PATH_VIDEO = `${TEST_BASE_PATH}file_thirdPartyChild.video.ogv`; +const TEST_PATH_IMAGE = `${TEST_BASE_PATH}file_favicon.png`; + +// For the "Save Page As" test, we will check the channel of the sub-resource +// within the page. In this case, it is a image. +const TEST_PATH_PAGE = `${TEST_BASE_PATH}file_favicon.png`; + +// For the "Save Frame As" test, we will check the channel of the sub-resource +// within the frame. In this case, it is a image. +const TEST_PATH_FRAME = `${TEST_BASE_PATH}file_favicon.png`; + +let MockFilePicker = SpecialPowers.MockFilePicker; +MockFilePicker.init(window); +const tempDir = createTemporarySaveDirectory(); +MockFilePicker.displayDirectory = tempDir; + +add_setup(async function () { + info("Setting the prefs."); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.firstparty.isolate", true], + ["dom.security.https_first", false], + ], + }); + + info("Setting MockFilePicker."); + mockTransferRegisterer.register(); + + registerCleanupFunction(function () { + mockTransferRegisterer.unregister(); + MockFilePicker.cleanup(); + tempDir.remove(true); + }); +}); + +function createTemporarySaveDirectory() { + let saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + saveDir.append("testsavedir"); + saveDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + return saveDir; +} + +function createPromiseForObservingChannel(aURL, aFirstParty) { + return new Promise(resolve => { + let observer = (aSubject, aTopic) => { + if (aTopic === "http-on-modify-request") { + let httpChannel = aSubject.QueryInterface(Ci.nsIHttpChannel); + let reqLoadInfo = httpChannel.loadInfo; + + // Make sure this is the request which we want to check. + if (!httpChannel.URI.spec.endsWith(aURL)) { + return; + } + + info(`Checking loadInfo for URI: ${httpChannel.URI.spec}\n`); + is( + reqLoadInfo.originAttributes.firstPartyDomain, + aFirstParty, + "The loadInfo has correct first party domain" + ); + + Services.obs.removeObserver(observer, "http-on-modify-request"); + resolve(); + } + }; + + Services.obs.addObserver(observer, "http-on-modify-request"); + }); +} + +function createPromiseForTransferComplete() { + return new Promise(resolve => { + MockFilePicker.showCallback = fp => { + info("MockFilePicker showCallback"); + + let fileName = fp.defaultString; + let destFile = tempDir.clone(); + destFile.append(fileName); + + MockFilePicker.setFiles([destFile]); + MockFilePicker.filterIndex = 0; // kSaveAsType_Complete + + MockFilePicker.showCallback = null; + mockTransferCallback = function (downloadSuccess) { + ok(downloadSuccess, "File should have been downloaded successfully"); + mockTransferCallback = () => {}; + resolve(); + }; + }; + }); +} + +async function doCommandForFrameType() { + info("Opening the frame sub-menu under the context menu."); + let contextMenu = document.getElementById("contentAreaContextMenu"); + let frameMenu = contextMenu.querySelector("#frame"); + let frameMenuPopup = frameMenu.menupopup; + let frameMenuPopupPromise = BrowserTestUtils.waitForEvent( + frameMenuPopup, + "popupshown" + ); + + frameMenu.openMenu(true); + await frameMenuPopupPromise; + + info("Triggering the save process."); + let saveFrameCommand = contextMenu.querySelector("#context-saveframe"); + frameMenuPopup.activateItem(saveFrameCommand); +} + +add_task(async function test_setup() { + // Make sure SearchService is ready for it to be called. + await Services.search.init(); +}); + +add_task(async function testContextMenuSaveAs() { + const TEST_DATA = [ + { type: "link", path: TEST_PATH, target: "#link1" }, + { type: "video", path: TEST_PATH_VIDEO, target: "#video1" }, + { type: "image", path: TEST_PATH_IMAGE, target: "#image1" }, + { type: "page", path: TEST_PATH_PAGE, target: "body" }, + { + type: "frame", + path: TEST_PATH_FRAME, + target: "body", + doCommandFunc: doCommandForFrameType, + }, + ]; + + for (const data of TEST_DATA) { + info(`Open a new tab for testing "Save ${data.type} as" in context menu.`); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + `${TEST_ORIGIN}${TEST_PATH}?${data.type}=1` + ); + + let popupShownPromise = BrowserTestUtils.waitForEvent( + document, + "popupshown" + ); + + let browser = gBrowser.selectedBrowser; + + if (data.type === "frame") { + browser = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => content.document.getElementById("frame1").browsingContext + ); + } + + info("Open the context menu."); + await BrowserTestUtils.synthesizeMouseAtCenter( + data.target, + { + type: "contextmenu", + button: 2, + }, + browser + ); + + await popupShownPromise; + + let transferCompletePromise = createPromiseForTransferComplete(); + let observerPromise = createPromiseForObservingChannel( + data.path, + TEST_FIRST_PARTY + ); + + let contextMenu = document.getElementById("contentAreaContextMenu"); + let popupHiddenPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + + // Select "Save As" option from context menu. + if (!data.doCommandFunc) { + let saveElement = document.getElementById(`context-save${data.type}`); + info("Triggering the save process."); + contextMenu.activateItem(saveElement); + } else { + await data.doCommandFunc(); + } + + info("Waiting for the channel."); + await observerPromise; + + info("Wait until the save is finished."); + await transferCompletePromise; + + info("Wait until the menu is closed."); + await popupHiddenPromise; + + BrowserTestUtils.removeTab(tab); + } +}); + +add_task(async function testFileMenuSavePageAs() { + info(`Open a new tab for testing "Save Page AS" in the file menu.`); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + `${TEST_ORIGIN}${TEST_PATH}?page=1` + ); + + let transferCompletePromise = createPromiseForTransferComplete(); + let observerPromise = createPromiseForObservingChannel( + TEST_PATH_PAGE, + TEST_FIRST_PARTY + ); + + let menubar = document.getElementById("main-menubar"); + let filePopup = document.getElementById("menu_FilePopup"); + + // We only use the shortcut keys to open the file menu in Windows and Linux. + // Mac doesn't have a shortcut to only open the file menu. Instead, we directly + // trigger the save in MAC without any UI interactions. + if (Services.appinfo.OS !== "Darwin") { + let menubarActive = BrowserTestUtils.waitForEvent( + menubar, + "DOMMenuBarActive" + ); + EventUtils.synthesizeKey("KEY_F10"); + await menubarActive; + + let popupShownPromise = BrowserTestUtils.waitForEvent( + filePopup, + "popupshown" + ); + // In window, it still needs one extra down key to open the file menu. + if (Services.appinfo.OS === "WINNT") { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + await popupShownPromise; + } + + info("Triggering the save process."); + let fileSavePageAsElement = document.getElementById("menu_savePage"); + fileSavePageAsElement.doCommand(); + + info("Waiting for the channel."); + await observerPromise; + + info("Wait until the save is finished."); + await transferCompletePromise; + + // Close the file menu. + if (Services.appinfo.OS !== "Darwin") { + let popupHiddenPromise = BrowserTestUtils.waitForEvent( + filePopup, + "popuphidden" + ); + filePopup.hidePopup(); + await popupHiddenPromise; + } + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function testPageInfoMediaSaveAs() { + info( + `Open a new tab for testing "Save AS" in the media panel of the page info.` + ); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + `${TEST_ORIGIN}${TEST_PATH}?pageinfo=1` + ); + + info("Open the media panel of the pageinfo."); + let pageInfo = BrowserPageInfo( + gBrowser.selectedBrowser.currentURI.spec, + "mediaTab" + ); + + await BrowserTestUtils.waitForEvent(pageInfo, "page-info-init"); + + let imageTree = pageInfo.document.getElementById("imagetree"); + let imageRowsNum = imageTree.view.rowCount; + + is(imageRowsNum, 2, "There should be two media items here."); + + for (let i = 0; i < imageRowsNum; i++) { + imageTree.view.selection.select(i); + imageTree.ensureRowIsVisible(i); + imageTree.focus(); + + let url = pageInfo.gImageView.data[i][0]; // COL_IMAGE_ADDRESS + info(`Start to save the media item with URL: ${url}`); + + let transferCompletePromise = createPromiseForTransferComplete(); + let observerPromise = createPromiseForObservingChannel( + url, + TEST_FIRST_PARTY + ); + + info("Triggering the save process."); + let saveElement = pageInfo.document.getElementById("imagesaveasbutton"); + saveElement.doCommand(); + + info("Waiting for the channel."); + await observerPromise; + + info("Wait until the save is finished."); + await transferCompletePromise; + } + + pageInfo.close(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/originattributes/test/browser/browser_httpauth.js b/browser/components/originattributes/test/browser/browser_httpauth.js new file mode 100644 index 0000000000..085d493c1b --- /dev/null +++ b/browser/components/originattributes/test/browser/browser_httpauth.js @@ -0,0 +1,79 @@ +let { HttpServer } = ChromeUtils.importESModule( + "resource://testing-common/httpd.sys.mjs" +); + +let authPromptModalType = Services.prefs.getIntPref( + "prompts.modalType.httpAuth" +); + +let commonDialogEnabled = + authPromptModalType === Services.prompt.MODAL_TYPE_WINDOW || + (authPromptModalType === Services.prompt.MODAL_TYPE_TAB && + Services.prefs.getBoolPref("prompts.tabChromePromptSubDialog")); + +let server = new HttpServer(); +server.registerPathHandler("/file.html", fileHandler); +server.start(-1); + +let BASE_URI = "http://localhost:" + server.identity.primaryPort; +let FILE_URI = BASE_URI + "/file.html"; + +let credentialQueue = []; + +// Ask the user agent for authorization. +function fileHandler(metadata, response) { + if (!metadata.hasHeader("Authorization")) { + response.setStatusLine(metadata.httpVersion, 401, "Unauthorized"); + response.setHeader("WWW-Authenticate", 'Basic realm="User Visible Realm"'); + return; + } + + // This will be "account:password" encoded in base64. + credentialQueue.push(metadata.getHeader("Authorization")); + + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + let body = ""; + response.bodyOutputStream.write(body, body.length); +} + +function onCommonDialogLoaded(subject) { + let dialog; + if (commonDialogEnabled) { + dialog = subject.Dialog; + } else { + let promptBox = + subject.ownerGlobal.gBrowser.selectedBrowser.tabModalPromptBox; + dialog = promptBox.getPrompt(subject).Dialog; + } + // Submit random account and password + dialog.ui.loginTextbox.setAttribute("value", Math.random()); + dialog.ui.password1Textbox.setAttribute("value", Math.random()); + dialog.ui.button0.click(); +} + +let authPromptTopic = commonDialogEnabled + ? "common-dialog-loaded" + : "tabmodal-dialog-loaded"; +Services.obs.addObserver(onCommonDialogLoaded, authPromptTopic); + +registerCleanupFunction(() => { + Services.obs.removeObserver(onCommonDialogLoaded, authPromptTopic); + server.stop(() => { + server = null; + }); +}); + +function getResult() { + // If two targets are isolated, they should get different credentials. + // Otherwise, the credentials will be cached and therefore the same. + return credentialQueue.shift(); +} + +async function doInit(aMode) { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.partition.network_state", false]], + }); +} + +IsolationTestTools.runTests(FILE_URI, getResult, null, doInit); diff --git a/browser/components/originattributes/test/browser/browser_imageCacheIsolation.js b/browser/components/originattributes/test/browser/browser_imageCacheIsolation.js new file mode 100644 index 0000000000..34c77f746d --- /dev/null +++ b/browser/components/originattributes/test/browser/browser_imageCacheIsolation.js @@ -0,0 +1,92 @@ +/* + * Bug 1264572 - A test case for image cache isolation. + */ + +requestLongerTimeout(2); + +let { HttpServer } = ChromeUtils.importESModule( + "resource://testing-common/httpd.sys.mjs" +); + +const NUM_ISOLATION_LOADS = 2; +const NUM_CACHED_LOADS = 1; + +let gHits = 0; + +let server = new HttpServer(); +server.registerPathHandler("/image.png", imageHandler); +server.registerPathHandler("/file.html", fileHandler); +server.start(-1); + +// Disable rcwn to make cache behavior deterministic. +let rcwnEnabled = Services.prefs.getBoolPref("network.http.rcwn.enabled"); +Services.prefs.setBoolPref("network.http.rcwn.enabled", false); + +registerCleanupFunction(() => { + Services.prefs.setBoolPref("network.http.rcwn.enabled", rcwnEnabled); + + server.stop(() => { + server = null; + }); +}); + +let BASE_URI = "http://localhost:" + server.identity.primaryPort; +let IMAGE_URI = BASE_URI + "/image.png"; +let FILE_URI = BASE_URI + "/file.html"; + +function imageHandler(metadata, response) { + info("XXX: loading image from server"); + gHits++; + response.setHeader("Cache-Control", "max-age=10000", false); + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "image/png", false); + var body = atob( + "iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAIAAADZSiLoAAAAEUlEQVQImWP4z8AAQTAamQkAhpcI+DeMzFcAAAAASUVORK5CYII=" + ); + response.bodyOutputStream.write(body, body.length); +} + +function fileHandler(metadata, response) { + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + let body = ``; + response.bodyOutputStream.write(body, body.length); +} + +async function doBefore() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.partition.network_state", false]], + }); + + // reset hit counter + info("XXX resetting gHits"); + gHits = 0; + info("XXX clearing image cache"); + let imageCache = Cc["@mozilla.org/image/tools;1"] + .getService(Ci.imgITools) + .getImgCacheForDocument(null); + imageCache.clearCache(true); + imageCache.clearCache(false); + info("XXX clearning network cache"); + Services.cache2.clear(); +} + +// the test function does nothing on purpose. +function doTest(aBrowser) { + return 0; +} + +// the check function +function doCheck(shouldIsolate, a, b) { + // if we're doing first party isolation and the image cache isolation is + // working, then gHits should be 2 because the image would have been loaded + // one per first party domain. if first party isolation is disabled, then + // gHits should be 1 since there would be one image load from the server and + // one load from the image cache. + info(`XXX check: gHits == ${gHits}, shouldIsolate == ${shouldIsolate}`); + return shouldIsolate + ? gHits == NUM_ISOLATION_LOADS + : gHits == NUM_CACHED_LOADS; +} + +IsolationTestTools.runTests(FILE_URI, doTest, doCheck, doBefore); diff --git a/browser/components/originattributes/test/browser/browser_localStorageIsolation.js b/browser/components/originattributes/test/browser/browser_localStorageIsolation.js new file mode 100644 index 0000000000..68990f6ea4 --- /dev/null +++ b/browser/components/originattributes/test/browser/browser_localStorageIsolation.js @@ -0,0 +1,33 @@ +/** + * Bug 1264567 - A test case for localStorage isolation. + */ + +const TEST_PAGE = + "http://mochi.test:8888/browser/browser/components/" + + "originattributes/test/browser/file_firstPartyBasic.html"; + +// Use a random key so we don't access it in later tests. +const key = Math.random().toString(); + +// IsolationTestTools flushes all preferences +// hence we explicitly pref off https-first mode +async function prefOffHttpsFirstMode() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.https_first", false]], + }); +} + +// Define the testing function +function doTest(aBrowser) { + return SpecialPowers.spawn(aBrowser, [key], function (contentKey) { + let value = content.localStorage.getItem(contentKey); + if (value === null) { + // No value is found, so we create one. + value = Math.random().toString(); + content.localStorage.setItem(contentKey, value); + } + return value; + }); +} + +IsolationTestTools.runTests(TEST_PAGE, doTest, null, prefOffHttpsFirstMode); diff --git a/browser/components/originattributes/test/browser/browser_permissions.js b/browser/components/originattributes/test/browser/browser_permissions.js new file mode 100644 index 0000000000..27819e6443 --- /dev/null +++ b/browser/components/originattributes/test/browser/browser_permissions.js @@ -0,0 +1,91 @@ +/** + * Bug 1282655 - Test if site permissions are universal across origin attributes. + * + * This test is testing the cookie "permission" for a specific URI. + */ + +const { PermissionTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PermissionTestUtils.sys.mjs" +); + +const TEST_PAGE = "https://example.net"; +const uri = Services.io.newURI(TEST_PAGE); + +async function disableCookies() { + Services.cookies.removeAll(); + PermissionTestUtils.add(uri, "cookie", Services.perms.DENY_ACTION); + + // A workaround for making this test working. In Bug 1330467, we separate the + // permissions between different firstPartyDomains, but not for the + // userContextID and the privateBrowsingId. So we need to manually add the + // permission for FPDs in order to make this test working. This test should be + // eventually removed once the permissions are isolated by OAs. + let principal = Services.scriptSecurityManager.createContentPrincipal(uri, { + firstPartyDomain: "example.com", + }); + PermissionTestUtils.add(principal, "cookie", Services.perms.DENY_ACTION); + + principal = Services.scriptSecurityManager.createContentPrincipal(uri, { + firstPartyDomain: "example.org", + }); + PermissionTestUtils.add(principal, "cookie", Services.perms.DENY_ACTION); +} + +async function ensureCookieNotSet(aBrowser) { + await SpecialPowers.spawn(aBrowser, [], async function () { + content.document.cookie = "key=value; SameSite=None; Secure;"; + Assert.equal( + content.document.cookie, + "", + "Setting/reading cookies should be disabled" + + " for this domain for all origin attribute combinations." + ); + }); +} + +IsolationTestTools.runTests( + TEST_PAGE, + ensureCookieNotSet, + () => true, + disableCookies +); + +async function enableCookies() { + Services.cookies.removeAll(); + PermissionTestUtils.add(uri, "cookie", Services.perms.ALLOW_ACTION); + + // A workaround for making this test working. + let principal = Services.scriptSecurityManager.createContentPrincipal(uri, { + firstPartyDomain: "example.com", + }); + PermissionTestUtils.add(principal, "cookie", Services.perms.ALLOW_ACTION); + + principal = Services.scriptSecurityManager.createContentPrincipal(uri, { + firstPartyDomain: "example.org", + }); + PermissionTestUtils.add(principal, "cookie", Services.perms.ALLOW_ACTION); +} + +async function ensureCookieSet(aBrowser) { + await SpecialPowers.spawn(aBrowser, [], function () { + content.document.cookie = "key=value; SameSite=None; Secure;"; + Assert.equal( + content.document.cookie, + "key=value", + "Setting/reading cookies should be" + + " enabled for this domain for all origin attribute combinations." + ); + }); +} + +IsolationTestTools.runTests( + TEST_PAGE, + ensureCookieSet, + () => true, + enableCookies +); + +registerCleanupFunction(() => { + SpecialPowers.clearUserPref("network.cookie.sameSite.laxByDefault"); + Services.cookies.removeAll(); +}); diff --git a/browser/components/originattributes/test/browser/browser_postMessage.js b/browser/components/originattributes/test/browser/browser_postMessage.js new file mode 100644 index 0000000000..a293213757 --- /dev/null +++ b/browser/components/originattributes/test/browser/browser_postMessage.js @@ -0,0 +1,121 @@ +/** + * Bug 1492607 - Test for assuring that postMessage cannot go across OAs. + */ + +const FPD_ONE = "http://example.com"; +const FPD_TWO = "http://example.org"; + +const TEST_BASE = "/browser/browser/components/originattributes/test/browser/"; + +add_setup(async function () { + // Make sure first party isolation is enabled. + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.firstparty.isolate", true], + ["dom.security.https_first", false], + ], + }); +}); + +async function runTestWithOptions( + aDifferentFPD, + aStarTargetOrigin, + aBlockAcrossFPD +) { + let testPageURL = aDifferentFPD + ? FPD_ONE + TEST_BASE + "file_postMessage.html" + : FPD_TWO + TEST_BASE + "file_postMessage.html"; + + // Deciding the targetOrigin according to the test setting. + let targetOrigin; + if (aStarTargetOrigin) { + targetOrigin = "*"; + } else { + targetOrigin = aDifferentFPD ? FPD_ONE : FPD_TWO; + } + let senderURL = + FPD_TWO + TEST_BASE + `file_postMessageSender.html?${targetOrigin}`; + + // Open a tab to listen messages. + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, testPageURL); + + // Use window.open() in the tab to open the sender tab. The sender tab + // will send a message through postMessage to window.opener. + let senderTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + senderURL, + true + ); + SpecialPowers.spawn(tab.linkedBrowser, [senderURL], aSenderPath => { + content.open(aSenderPath, "_blank"); + }); + + // Wait and get the tab of the sender tab. + let senderTab = await senderTabPromise; + + // The postMessage should be blocked when the first parties are different with + // the following two cases. First, it is using a non-star target origin. + // Second, it is using the star target origin and the pref + // 'privacy.firstparty.isolate.block_post_message' is true. + let shouldBlock = aDifferentFPD && (!aStarTargetOrigin || aBlockAcrossFPD); + + await SpecialPowers.spawn(tab.linkedBrowser, [shouldBlock], async aValue => { + await new Promise(resolve => { + content.addEventListener("message", async function eventHandler(aEvent) { + if (aEvent.data === "Self") { + let display = content.document.getElementById("display"); + if (aValue) { + Assert.equal( + display.innerHTML, + "", + "It should not get a message from other OA." + ); + } else { + await ContentTaskUtils.waitForCondition( + () => display.innerHTML == "Message", + "Wait for message to arrive" + ); + Assert.equal( + display.innerHTML, + "Message", + "It should get a message from the same OA." + ); + } + + content.removeEventListener("message", eventHandler); + resolve(); + } + }); + + // Trigger the content to send a postMessage to itself. + content.document.getElementById("button").click(); + }); + }); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(senderTab); +} + +add_task(async function runTests() { + for (let useDifferentFPD of [true, false]) { + for (let useStarTargetOrigin of [true, false]) { + for (let enableBlocking of [true, false]) { + if (enableBlocking) { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.firstparty.isolate.block_post_message", true]], + }); + } + + await runTestWithOptions( + useDifferentFPD, + useStarTargetOrigin, + enableBlocking + ); + + if (enableBlocking) { + await SpecialPowers.popPrefEnv(); + } + } + } + } +}); diff --git a/browser/components/originattributes/test/browser/browser_sanitize.js b/browser/components/originattributes/test/browser/browser_sanitize.js new file mode 100644 index 0000000000..61d236f249 --- /dev/null +++ b/browser/components/originattributes/test/browser/browser_sanitize.js @@ -0,0 +1,92 @@ +/** + * Bug 1270338 - Add a mochitest to ensure Sanitizer clears data for all containers + */ + +if (SpecialPowers.useRemoteSubframes) { + requestLongerTimeout(4); +} + +const CC = Components.Constructor; + +const TEST_DOMAIN = "https://example.net/"; + +async function setCookies(aBrowser) { + await SpecialPowers.spawn(aBrowser, [], function () { + content.document.cookie = "key=value"; + }); +} + +function cacheDataForContext(loadContextInfo) { + return new Promise(resolve => { + let cachedURIs = []; + let cacheVisitor = { + onCacheStorageInfo(num, consumption) {}, + onCacheEntryInfo(uri, idEnhance) { + cachedURIs.push(uri.asciiSpec); + }, + onCacheEntryVisitCompleted() { + resolve(cachedURIs); + }, + QueryInterface: ChromeUtils.generateQI(["nsICacheStorageVisitor"]), + }; + // Visiting the disk cache also visits memory storage so we do not + // need to use Services.cache2.memoryCacheStorage() here. + let storage = Services.cache2.diskCacheStorage(loadContextInfo); + storage.asyncVisitStorage(cacheVisitor, true); + }); +} + +async function checkCookiesSanitized(aBrowser) { + await SpecialPowers.spawn(aBrowser, [], function () { + Assert.equal( + content.document.cookie, + "", + "Cookies of all origin attributes should be cleared." + ); + }); +} + +function checkCacheExists(aShouldExist) { + return async function () { + let loadContextInfos = [ + Services.loadContextInfo.default, + Services.loadContextInfo.custom(false, { userContextId: 1 }), + Services.loadContextInfo.custom(false, { userContextId: 2 }), + Services.loadContextInfo.custom(false, { + firstPartyDomain: "example.com", + }), + Services.loadContextInfo.custom(false, { + firstPartyDomain: "example.org", + }), + ]; + let i = 0; + for (let loadContextInfo of loadContextInfos) { + let cacheURIs = await cacheDataForContext(loadContextInfo); + is( + cacheURIs.includes(TEST_DOMAIN), + aShouldExist, + TEST_DOMAIN + + " should " + + (aShouldExist ? "not " : "") + + "be cached for all origin attributes." + + i++ + ); + } + }; +} + +add_setup(async function () { + Services.cache2.clear(); +}); + +// This will set the cookies and the cache. +IsolationTestTools.runTests(TEST_DOMAIN, setCookies, () => true); + +add_task(checkCacheExists(true)); + +add_task(async function sanitize() { + await Sanitizer.sanitize(["cookies", "cache"]); +}); + +add_task(checkCacheExists(false)); +IsolationTestTools.runTests(TEST_DOMAIN, checkCookiesSanitized, () => true); diff --git a/browser/components/originattributes/test/browser/browser_sharedworker.js b/browser/components/originattributes/test/browser/browser_sharedworker.js new file mode 100644 index 0000000000..5fd7f180ee --- /dev/null +++ b/browser/components/originattributes/test/browser/browser_sharedworker.js @@ -0,0 +1,30 @@ +/** + * Bug 1264593 - A test case for the shared worker by first party isolation. + */ + +const TEST_DOMAIN = "https://example.net/"; +const TEST_PATH = + TEST_DOMAIN + "browser/browser/components/originattributes/test/browser/"; +const TEST_PAGE = TEST_PATH + "file_sharedworker.html"; + +async function getResultFromSharedworker(aBrowser) { + let response = await SpecialPowers.spawn(aBrowser, [], async function () { + let worker = new content.SharedWorker( + "file_shared.worker.js", + "isolationSharedWorkerTest" + ); + + let result = await new content.Promise(resolve => { + worker.port.onmessage = function (e) { + content.document.getElementById("display").innerHTML = e.data; + resolve(e.data); + }; + }); + + return result; + }); + + return response; +} + +IsolationTestTools.runTests(TEST_PAGE, getResultFromSharedworker); diff --git a/browser/components/originattributes/test/browser/browser_windowOpenerRestriction.js b/browser/components/originattributes/test/browser/browser_windowOpenerRestriction.js new file mode 100644 index 0000000000..0822ba24c9 --- /dev/null +++ b/browser/components/originattributes/test/browser/browser_windowOpenerRestriction.js @@ -0,0 +1,113 @@ +/** + * Bug 1339336 - A test case for testing pref 'privacy.firstparty.isolate.restrict_opener_access' + */ + +const CC = Components.Constructor; + +const FIRST_PARTY_OPENER = "example.com"; +const FIRST_PARTY_TARGET = "example.org"; +const OPENER_PAGE = + "https://" + + FIRST_PARTY_OPENER + + "/browser/browser/components/" + + "originattributes/test/browser/file_windowOpenerRestriction.html"; +const TARGET_PAGE = + "https://" + + FIRST_PARTY_TARGET + + "/browser/browser/components/" + + "originattributes/test/browser/file_windowOpenerRestrictionTarget.html"; + +async function testPref(aIsPrefEnabled) { + // Use a random key so we don't access it in later tests. + let cookieStr = + "key" + Math.random().toString() + "=" + Math.random().toString(); + + // Open the tab for the opener page. + let tab = BrowserTestUtils.addTab(gBrowser, OPENER_PAGE); + + // Select this tab and make sure its browser is loaded and focused. + gBrowser.selectedTab = tab; + tab.ownerGlobal.focus(); + + let browser = gBrowser.getBrowserForTab(tab); + await BrowserTestUtils.browserLoaded(browser); + + await SpecialPowers.spawn( + browser, + [{ cookieStr, page: TARGET_PAGE, isPrefEnabled: aIsPrefEnabled }], + async function (obj) { + // Acquire the iframe element. + let childFrame = content.document.getElementById("child"); + + // Insert a cookie into this iframe. + await SpecialPowers.spawn(childFrame, [obj.cookieStr], aCookieStr => { + content.document.cookie = aCookieStr + "; SameSite=None; Secure;"; + }); + + // Open the tab here and focus on it. + let openedPath = obj.page; + if (!obj.isPrefEnabled) { + // If the pref is not enabled, we pass the cookie value through the query string + // to tell the target page that it should check the cookie value. + openedPath += "?" + obj.cookieStr; + } + + // Issue the opener page to open the target page and focus on it. + content.openedWindow = content.open(openedPath); + content.openedWindow.focus(); + } + ); + + // Wait until the target page is loaded. + let targetBrowser = gBrowser.getBrowserForTab(gBrowser.selectedTab); + await BrowserTestUtils.browserLoaded(targetBrowser); + + // The target page will do the check and show the result through its title. + is( + targetBrowser.contentTitle, + "pass", + "The behavior of window.opener is correct." + ); + + // Close Tabs. + await SpecialPowers.spawn(browser, [], async function () { + content.openedWindow.close(); + }); + BrowserTestUtils.removeTab(tab); + + // Reset cookies + Services.cookies.removeAll(); +} + +add_task(async function runTests() { + let tests = [true, false]; + + // First, we test the scenario that the first party isolation is enabled. + await SpecialPowers.pushPrefEnv({ + set: [["privacy.firstparty.isolate", true]], + }); + + for (let enabled of tests) { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.firstparty.isolate.restrict_opener_access", enabled]], + }); + + await testPref(enabled); + } + + // Second, we test the scenario that the first party isolation is disabled. + await SpecialPowers.pushPrefEnv({ + set: [["privacy.firstparty.isolate", false]], + }); + + for (let enabled of tests) { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.firstparty.isolate.restrict_opener_access", enabled]], + }); + + // When first party isolation is disabled, this pref will not affect the behavior of + // window.opener. And the correct behavior here is to allow access since the iframe in + // the opener page has the same origin with the target page. + await testPref(false); + } +}); diff --git a/browser/components/originattributes/test/browser/dummy.html b/browser/components/originattributes/test/browser/dummy.html new file mode 100644 index 0000000000..1a87e28408 --- /dev/null +++ b/browser/components/originattributes/test/browser/dummy.html @@ -0,0 +1,9 @@ + + +Dummy test page + + + +

    Dummy test page

    + + diff --git a/browser/components/originattributes/test/browser/file_broadcastChannel.html b/browser/components/originattributes/test/browser/file_broadcastChannel.html new file mode 100644 index 0000000000..deb4a154ea --- /dev/null +++ b/browser/components/originattributes/test/browser/file_broadcastChannel.html @@ -0,0 +1,16 @@ + + + + + Page broadcast channel creator for first party isolation + + +
    + > + + diff --git a/browser/components/originattributes/test/browser/file_broadcastChanneliFrame.html b/browser/components/originattributes/test/browser/file_broadcastChanneliFrame.html new file mode 100644 index 0000000000..6f4c484bc9 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_broadcastChanneliFrame.html @@ -0,0 +1,15 @@ + + + + + Page broadcast channel responder for first party isolation + + +
    + + diff --git a/browser/components/originattributes/test/browser/file_cache.html b/browser/components/originattributes/test/browser/file_cache.html new file mode 100644 index 0000000000..24e09baefa --- /dev/null +++ b/browser/components/originattributes/test/browser/file_cache.html @@ -0,0 +1,33 @@ + + + + + + + + + + +
    file_cache.html
    + + + + + + + + + + + + diff --git a/browser/components/originattributes/test/browser/file_favicon.html b/browser/components/originattributes/test/browser/file_favicon.html new file mode 100644 index 0000000000..f294b47758 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_favicon.html @@ -0,0 +1,11 @@ + + + + + Favicon Test for originAttributes + + + + Favicon!! + + diff --git a/browser/components/originattributes/test/browser/file_favicon.png b/browser/components/originattributes/test/browser/file_favicon.png new file mode 100644 index 0000000000..5535363c94 Binary files /dev/null and b/browser/components/originattributes/test/browser/file_favicon.png differ diff --git a/browser/components/originattributes/test/browser/file_favicon.png^headers^ b/browser/components/originattributes/test/browser/file_favicon.png^headers^ new file mode 100644 index 0000000000..9e23c73b7f --- /dev/null +++ b/browser/components/originattributes/test/browser/file_favicon.png^headers^ @@ -0,0 +1 @@ +Cache-Control: no-cache diff --git a/browser/components/originattributes/test/browser/file_favicon_cache.html b/browser/components/originattributes/test/browser/file_favicon_cache.html new file mode 100644 index 0000000000..9e3e0114a1 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_favicon_cache.html @@ -0,0 +1,11 @@ + + + + + Favicon Test for originAttributes + + + + Third Party Favicon!! + + diff --git a/browser/components/originattributes/test/browser/file_favicon_cache.png b/browser/components/originattributes/test/browser/file_favicon_cache.png new file mode 100644 index 0000000000..5535363c94 Binary files /dev/null and b/browser/components/originattributes/test/browser/file_favicon_cache.png differ diff --git a/browser/components/originattributes/test/browser/file_favicon_thirdParty.html b/browser/components/originattributes/test/browser/file_favicon_thirdParty.html new file mode 100644 index 0000000000..e50b0c3493 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_favicon_thirdParty.html @@ -0,0 +1,11 @@ + + + + + Favicon Test for originAttributes + + + + Third Party Favicon!! + + diff --git a/browser/components/originattributes/test/browser/file_firstPartyBasic.html b/browser/components/originattributes/test/browser/file_firstPartyBasic.html new file mode 100644 index 0000000000..713187fb2c --- /dev/null +++ b/browser/components/originattributes/test/browser/file_firstPartyBasic.html @@ -0,0 +1,8 @@ + + + + First Party Isolation Tests + + + + diff --git a/browser/components/originattributes/test/browser/file_postMessage.html b/browser/components/originattributes/test/browser/file_postMessage.html new file mode 100644 index 0000000000..91255d0364 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_postMessage.html @@ -0,0 +1,27 @@ + + + + + Test page for window.postMessage + + +
    + + + + diff --git a/browser/components/originattributes/test/browser/file_postMessageSender.html b/browser/components/originattributes/test/browser/file_postMessageSender.html new file mode 100644 index 0000000000..ebdffb990f --- /dev/null +++ b/browser/components/originattributes/test/browser/file_postMessageSender.html @@ -0,0 +1,16 @@ + + + + + Test page for always sending a message to its opener through postMessage + + + + + diff --git a/browser/components/originattributes/test/browser/file_saveAs.sjs b/browser/components/originattributes/test/browser/file_saveAs.sjs new file mode 100644 index 0000000000..9b16250c76 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_saveAs.sjs @@ -0,0 +1,38 @@ +const HTTP_ORIGIN = "http://example.com"; +const SECOND_ORIGIN = "http://example.org"; +const URI_PATH = "/browser/browser/components/originattributes/test/browser/"; +const LINK_PATH = `${URI_PATH}file_saveAs.sjs`; +// Reusing existing ogv file for testing. +const VIDEO_PATH = `${URI_PATH}file_thirdPartyChild.video.ogv`; +// Reusing existing png file for testing. +const IMAGE_PATH = `${URI_PATH}file_favicon.png`; +const FRAME_PATH = `${SECOND_ORIGIN}${URI_PATH}file_saveAs.sjs?image=1`; + +function handleRequest(aRequest, aResponse) { + var params = new URLSearchParams(aRequest.queryString); + aResponse.setStatusLine(aRequest.httpVersion, 200); + aResponse.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + let contentBody = ""; + + if (params.has("link")) { + contentBody = `this is a link`; + } else if (params.has("video")) { + contentBody = ``; + } else if (params.has("image")) { + contentBody = ``; + } else if (params.has("page")) { + // We need at least one resource, like a img, a link or a script, to trigger + // downloading resources in "Save Page As". Otherwise, it will output the + // document directly without any network request. + contentBody = ``; + } else if (params.has("frame")) { + // Like "Save Page As", we need to put at least one resource in the frame. + // Here we also use a image. + contentBody = ``; + } else if (params.has("pageinfo")) { + contentBody = ` + `; + } + + aResponse.write(`${contentBody}`); +} diff --git a/browser/components/originattributes/test/browser/file_shared.worker.js b/browser/components/originattributes/test/browser/file_shared.worker.js new file mode 100644 index 0000000000..e5f0b35946 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_shared.worker.js @@ -0,0 +1,7 @@ +self.randomValue = Math.random(); + +onconnect = function (e) { + let port = e.ports[0]; + port.postMessage(self.randomValue); + port.start(); +}; diff --git a/browser/components/originattributes/test/browser/file_sharedworker.html b/browser/components/originattributes/test/browser/file_sharedworker.html new file mode 100644 index 0000000000..b9ff793bd5 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_sharedworker.html @@ -0,0 +1,10 @@ + + + + + Page SharedWorker creator for first party isolation + + +
    + + diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.audio.ogg b/browser/components/originattributes/test/browser/file_thirdPartyChild.audio.ogg new file mode 100644 index 0000000000..edda4e9128 Binary files /dev/null and b/browser/components/originattributes/test/browser/file_thirdPartyChild.audio.ogg differ diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.embed.png b/browser/components/originattributes/test/browser/file_thirdPartyChild.embed.png new file mode 100644 index 0000000000..c5916f2897 Binary files /dev/null and b/browser/components/originattributes/test/browser/file_thirdPartyChild.embed.png differ diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.favicon.png b/browser/components/originattributes/test/browser/file_thirdPartyChild.favicon.png new file mode 100644 index 0000000000..c5916f2897 Binary files /dev/null and b/browser/components/originattributes/test/browser/file_thirdPartyChild.favicon.png differ diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.fetch.html b/browser/components/originattributes/test/browser/file_thirdPartyChild.fetch.html new file mode 100644 index 0000000000..037901ad06 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_thirdPartyChild.fetch.html @@ -0,0 +1,8 @@ + + + + + +
    thirdPartyChild.fetch.html
    + + diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.font.woff b/browser/components/originattributes/test/browser/file_thirdPartyChild.font.woff new file mode 100644 index 0000000000..acda4f3d9f Binary files /dev/null and b/browser/components/originattributes/test/browser/file_thirdPartyChild.font.woff differ diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.iframe.html b/browser/components/originattributes/test/browser/file_thirdPartyChild.iframe.html new file mode 100644 index 0000000000..b047d5b412 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_thirdPartyChild.iframe.html @@ -0,0 +1,18 @@ + + + + + +
    thirdPartyChild.html
    + + + diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.img.png b/browser/components/originattributes/test/browser/file_thirdPartyChild.img.png new file mode 100644 index 0000000000..c5916f2897 Binary files /dev/null and b/browser/components/originattributes/test/browser/file_thirdPartyChild.img.png differ diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.import.js b/browser/components/originattributes/test/browser/file_thirdPartyChild.import.js new file mode 100644 index 0000000000..dbf8f83769 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_thirdPartyChild.import.js @@ -0,0 +1 @@ +// dummy script, to be called by self.importScripts(...) diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.link.css b/browser/components/originattributes/test/browser/file_thirdPartyChild.link.css new file mode 100644 index 0000000000..6f8f41b4a4 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_thirdPartyChild.link.css @@ -0,0 +1 @@ +/* Dummy CSS file, used by browser_cache.js. */ diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.object.png b/browser/components/originattributes/test/browser/file_thirdPartyChild.object.png new file mode 100644 index 0000000000..c5916f2897 Binary files /dev/null and b/browser/components/originattributes/test/browser/file_thirdPartyChild.object.png differ diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.request.html b/browser/components/originattributes/test/browser/file_thirdPartyChild.request.html new file mode 100644 index 0000000000..108ed2ffa5 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_thirdPartyChild.request.html @@ -0,0 +1,8 @@ + + + + + +
    thirdPartyChild.request.html
    + + diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.script.js b/browser/components/originattributes/test/browser/file_thirdPartyChild.script.js new file mode 100644 index 0000000000..6ddf436c09 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_thirdPartyChild.script.js @@ -0,0 +1 @@ +// Dummy child script, used by browser_cache.js diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.sharedworker.js b/browser/components/originattributes/test/browser/file_thirdPartyChild.sharedworker.js new file mode 100644 index 0000000000..b262fa10a3 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_thirdPartyChild.sharedworker.js @@ -0,0 +1 @@ +// dummy file diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.track.vtt b/browser/components/originattributes/test/browser/file_thirdPartyChild.track.vtt new file mode 100644 index 0000000000..b37cb40e45 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_thirdPartyChild.track.vtt @@ -0,0 +1,13 @@ +WEBVTT FILE + +1 +00:00:00.500 --> 00:00:02.000 D:vertical A:start +blah blah blah + +2 +00:00:02.500 --> 00:00:04.300 +this is a test + +3 +00:00:05.000 --> 00:00:07.000 +one more line diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.video.ogv b/browser/components/originattributes/test/browser/file_thirdPartyChild.video.ogv new file mode 100644 index 0000000000..68dee3cf2b Binary files /dev/null and b/browser/components/originattributes/test/browser/file_thirdPartyChild.video.ogv differ diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.worker.fetch.html b/browser/components/originattributes/test/browser/file_thirdPartyChild.worker.fetch.html new file mode 100644 index 0000000000..47e42d1e58 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_thirdPartyChild.worker.fetch.html @@ -0,0 +1,8 @@ + + + + + +
    thirdPartyChild.worker.fetch.html
    + + diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.worker.js b/browser/components/originattributes/test/browser/file_thirdPartyChild.worker.js new file mode 100644 index 0000000000..38aff85c30 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_thirdPartyChild.worker.js @@ -0,0 +1,20 @@ +var xhr = new XMLHttpRequest(); +xhr.open( + "GET", + "http://example.net/browser/browser/components/originattributes/test/browser/file_thirdPartyChild.worker.xhr.html", + true +); +xhr.send(); + +fetch( + "http://example.net/browser/browser/components/originattributes/test/browser/file_thirdPartyChild.worker.fetch.html", + { cache: "force-cache" } +); +var myRequest = new Request( + "http://example.net/browser/browser/components/originattributes/test/browser/file_thirdPartyChild.worker.request.html" +); +fetch(myRequest, { cache: "force-cache" }); + +self.importScripts( + "http://example.net/browser/browser/components/originattributes/test/browser/file_thirdPartyChild.import.js" +); diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.worker.request.html b/browser/components/originattributes/test/browser/file_thirdPartyChild.worker.request.html new file mode 100644 index 0000000000..5b5c55bfeb --- /dev/null +++ b/browser/components/originattributes/test/browser/file_thirdPartyChild.worker.request.html @@ -0,0 +1,8 @@ + + + + + +
    thirdPartyChild.worker.request.html
    + + diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.worker.xhr.html b/browser/components/originattributes/test/browser/file_thirdPartyChild.worker.xhr.html new file mode 100644 index 0000000000..9fc107f375 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_thirdPartyChild.worker.xhr.html @@ -0,0 +1,8 @@ + + + + + +
    thirdPartyChild.worker.xhr.html
    + + diff --git a/browser/components/originattributes/test/browser/file_thirdPartyChild.xhr.html b/browser/components/originattributes/test/browser/file_thirdPartyChild.xhr.html new file mode 100644 index 0000000000..f56e7b3c1f --- /dev/null +++ b/browser/components/originattributes/test/browser/file_thirdPartyChild.xhr.html @@ -0,0 +1,8 @@ + + + + + +
    thirdPartyChild.html
    + + diff --git a/browser/components/originattributes/test/browser/file_windowOpenerRestriction.html b/browser/components/originattributes/test/browser/file_windowOpenerRestriction.html new file mode 100644 index 0000000000..8925a6ccf5 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_windowOpenerRestriction.html @@ -0,0 +1,10 @@ + + + + + Test page for window.opener accessibility + + + + + diff --git a/browser/components/originattributes/test/browser/file_windowOpenerRestrictionTarget.html b/browser/components/originattributes/test/browser/file_windowOpenerRestrictionTarget.html new file mode 100644 index 0000000000..5a14834897 --- /dev/null +++ b/browser/components/originattributes/test/browser/file_windowOpenerRestrictionTarget.html @@ -0,0 +1,33 @@ + + + + + title not set + + + + + diff --git a/browser/components/originattributes/test/browser/head.js b/browser/components/originattributes/test/browser/head.js new file mode 100644 index 0000000000..bd4307fd1c --- /dev/null +++ b/browser/components/originattributes/test/browser/head.js @@ -0,0 +1,463 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const TEST_URL_PATH = + "/browser/browser/components/originattributes/test/browser/"; + +// The flags of test modes. +const TEST_MODE_FIRSTPARTY = 0; +const TEST_MODE_NO_ISOLATION = 1; +const TEST_MODE_CONTAINERS = 2; + +// The name of each mode. +const TEST_MODE_NAMES = ["first party isolation", "no isolation", "containers"]; + +// The frame types. +const TEST_TYPE_FRAME = 1; +const TEST_TYPE_IFRAME = 2; + +// The default frame setting. +const DEFAULT_FRAME_SETTING = [TEST_TYPE_IFRAME]; + +let gFirstPartyBasicPage = TEST_URL_PATH + "file_firstPartyBasic.html"; + +/** + * Add a tab for the given url with the specific user context id. + * + * @param aURL + * The url of the page. + * @param aUserContextId + * The user context id for this tab. + * + * @return tab - The tab object of this tab. + * browser - The browser object of this tab. + */ +async function openTabInUserContext(aURL, aUserContextId) { + info(`Start to open tab in specific userContextID: ${aUserContextId}.`); + let originAttributes = { + userContextId: aUserContextId, + }; + info("Create triggeringPrincipal."); + let triggeringPrincipal = + Services.scriptSecurityManager.createContentPrincipal( + makeURI(aURL), + originAttributes + ); + // Open the tab in the correct userContextId. + info("Open the tab and wait for it to be loaded."); + let tab = BrowserTestUtils.addTab(gBrowser, aURL, { + userContextId: aUserContextId, + triggeringPrincipal, + }); + + // Select tab and make sure its browser is focused. + gBrowser.selectedTab = tab; + tab.ownerGlobal.focus(); + + let browser = gBrowser.getBrowserForTab(tab); + await BrowserTestUtils.browserLoaded(browser); + info("Finished tab opening."); + + return { tab, browser }; +} + +/** + * Add a tab for a page with the given first party domain. This page will have + * an iframe which is loaded with the given url by default or you could specify + * a frame setting to create nested frames. And this function will also modify + * the 'content' in the ContentTask to the target frame's window object. + * + * @param aURL + * The url of the iframe. + * @param aFirstPartyDomain + * The first party domain. + * @param aFrameSetting + * This setting controls how frames are organized within the page. The + * setting is an array of frame types, the first item indicates the + * frame type (iframe or frame) of the first layer of the frame structure, + * and the second item indicates the second layer, and so on. The aURL will + * be loaded at the deepest layer. This is optional. + * + * @return tab - The tab object of this tab. + * browser - The browser object of this tab. + */ +async function openTabInFirstParty( + aURL, + aFirstPartyDomain, + aFrameSetting = DEFAULT_FRAME_SETTING +) { + info(`Start to open tab under first party domain "${aFirstPartyDomain}".`); + // If the first party domain ends with '/', we remove it. + if (aFirstPartyDomain.endsWith("/")) { + aFirstPartyDomain = aFirstPartyDomain.slice(0, -1); + } + + let basicPageURL = aFirstPartyDomain + gFirstPartyBasicPage; + + // Open the tab for the basic first party page. + info("Open the tab and then wait for it to be loaded."); + let tab = BrowserTestUtils.addTab(gBrowser, basicPageURL); + + // Select tab and make sure its browser is focused. + gBrowser.selectedTab = tab; + tab.ownerGlobal.focus(); + + let browser = gBrowser.getBrowserForTab(tab); + await BrowserTestUtils.browserLoaded(browser); + + // Clone the frame setting here since we will modify it later. + let frameSetting = aFrameSetting.slice(0); + let frameType; + let targetBrowsingContext; + + // Create the frame structure. + info("Create the frame structure."); + while ((frameType = frameSetting.shift())) { + if (!targetBrowsingContext) { + targetBrowsingContext = browser; + } + + let frameURL = !frameSetting.length ? aURL : basicPageURL; + + if (frameType == TEST_TYPE_FRAME) { + info("Add the frameset."); + targetBrowsingContext = await SpecialPowers.spawn( + targetBrowsingContext, + [frameURL], + async function (aFrameURL) { + // Add a frameset which carries the frame element. + let frameSet = content.document.createElement("frameset"); + frameSet.cols = "50%,50%"; + + let frame = content.document.createElement("frame"); + let dummyFrame = content.document.createElement("frame"); + + frameSet.appendChild(frame); + frameSet.appendChild(dummyFrame); + + content.document.body.appendChild(frameSet); + + // Wait for the frame to be loaded. + await new Promise(done => { + frame.addEventListener( + "load", + function () { + done(); + }, + { capture: true, once: true } + ); + + frame.setAttribute("src", aFrameURL); + }); + + return frame.browsingContext; + } + ); + } else if (frameType == TEST_TYPE_IFRAME) { + info("Add the iframe."); + targetBrowsingContext = await SpecialPowers.spawn( + targetBrowsingContext, + [frameURL], + async function (aFrameURL) { + // Add an iframe. + let frame = content.document.createElement("iframe"); + content.document.body.appendChild(frame); + + // Wait for the frame to be loaded. + await new Promise(done => { + frame.addEventListener( + "load", + function () { + done(); + }, + { capture: true, once: true } + ); + + frame.setAttribute("src", aFrameURL); + }); + + return frame.browsingContext; + } + ); + } else { + ok(false, "Invalid frame type."); + break; + } + info("Successfully added a frame"); + } + info("Finished the frame structure"); + + return { tab, browser: targetBrowsingContext }; +} + +this.IsolationTestTools = { + /** + * Adds isolation tests for first party isolation, no isolation + * and containers respectively. + * + * @param aTask + * The testing task which will be run in different settings. + */ + _add_task(aTask) { + let testSettings = [ + { + mode: TEST_MODE_FIRSTPARTY, + skip: false, + prefs: [["privacy.firstparty.isolate", true]], + }, + { + mode: TEST_MODE_NO_ISOLATION, + skip: false, + prefs: [["privacy.firstparty.isolate", false]], + }, + { + mode: TEST_MODE_CONTAINERS, + skip: false, + prefs: [["privacy.userContext.enabled", true]], + }, + ]; + + // Add test tasks. + for (let testSetting of testSettings) { + IsolationTestTools._addTaskForMode( + testSetting.mode, + testSetting.prefs, + testSetting.skip, + aTask + ); + } + }, + + _addTaskForMode(aMode, aPref, aSkip, aTask) { + if (aSkip) { + return; + } + + add_task(async function () { + info(`Starting the test for ${TEST_MODE_NAMES[aMode]}.`); + + // Before run this task, reset the preferences first. + await SpecialPowers.flushPrefEnv(); + + // Make sure preferences are set properly. + await SpecialPowers.pushPrefEnv({ set: aPref }); + + await SpecialPowers.pushPrefEnv({ set: [["dom.ipc.processCount", 1]] }); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "network.auth.non-web-content-triggered-resources-http-auth-allow", + true, + ], + ], + }); + + await aTask(aMode); + }); + }, + + /** + * Add a tab with the given tab setting, this will open different types of + * tabs according to the given test mode. A tab setting means a isolation + * target in different test mode; a tab setting indicates a first party + * domain when testing the first party isolation, it is a user context + * id when testing containers. + * + * @param aMode + * The test mode which decides what type of tabs will be opened. + * @param aURL + * The url which is going to open. + * @param aTabSettingObj + * The tab setting object includes 'firstPartyDomain' for the first party + * domain and 'userContextId' for Containers. + * @param aFrameSetting + * This setting controls how frames are organized within the page. The + * setting is an array of frame types, the first item indicates the + * frame type (iframe or frame) of the first layer of the frame structure, + * and the second item indicates the second layer, and so on. The aURL + * will be loaded at the deepest layer. This is optional. + * + * @return tab - The tab object of this tab. + * browser - The browser object of this tab. + */ + _addTab(aMode, aURL, aTabSettingObj, aFrameSetting) { + if (aMode === TEST_MODE_CONTAINERS) { + return openTabInUserContext(aURL, aTabSettingObj.userContextId); + } + + return openTabInFirstParty( + aURL, + aTabSettingObj.firstPartyDomain, + aFrameSetting + ); + }, + + /** + * Run isolation tests. The framework will run tests with standard combinations + * of prefs and tab settings, and checks whether the isolation is working. + * + * @param aURL + * The URL of the page that will be tested or an object contains 'url', + * the tested page, 'firstFrameSetting' for the frame setting of the first + * tab, and 'secondFrameSetting' for the second tab. + * @param aGetResultFuncs + * An array of functions or a single function which are responsible for + * returning the isolation result back to the framework for further checking. + * Each of these functions will be provided the browser object of the tab, + * that allows modifying or fetchings results from the page content. + * @param aCompareResultFunc + * An optional function which allows modifying the way how does framework + * check results. This function will be provided a boolean to indicate + * the isolation is no or off and two results. This function should return + * a boolean to tell that whether isolation is working. If this function + * is not given, the framework will take case checking by itself. + * @param aBeforeFunc + * An optional function which is called before any tabs are created so + * that the test case can set up/reset local state. + * @param aGetResultImmediately + * An optional boolean to ensure we get results before the next tab is opened. + */ + runTests( + aURL, + aGetResultFuncs, + aCompareResultFunc, + aBeforeFunc, + aGetResultImmediately, + aUseHttps + ) { + let pageURL; + let firstFrameSetting; + let secondFrameSetting; + + // Request a longer timeout since the test will run a test for three times + // with different settings. Thus, one test here represents three tests. + // For this reason, we triple the timeout. + requestLongerTimeout(3); + + if (typeof aURL === "string") { + pageURL = aURL; + } else if (typeof aURL === "object") { + pageURL = aURL.url; + firstFrameSetting = aURL.firstFrameSetting; + secondFrameSetting = aURL.secondFrameSetting; + } + + if (!Array.isArray(aGetResultFuncs)) { + aGetResultFuncs = [aGetResultFuncs]; + } + + let tabSettings = aUseHttps + ? [ + { firstPartyDomain: "https://example.com", userContextId: 1 }, + { firstPartyDomain: "https://example.org", userContextId: 2 }, + ] + : [ + { firstPartyDomain: "http://example.com", userContextId: 1 }, + { firstPartyDomain: "http://example.org", userContextId: 2 }, + ]; + + this._add_task(async function (aMode) { + let tabSettingA = 0; + + for (let tabSettingB of [0, 1]) { + // Give the test a chance to set up before each case is run. + if (aBeforeFunc) { + try { + await aBeforeFunc(aMode); + } catch (e) { + ok(false, `Caught error while doing testing setup: ${e}.`); + } + } + // Create Tabs. + info(`Create tab A for ${TEST_MODE_NAMES[aMode]} test.`); + let tabInfoA = await IsolationTestTools._addTab( + aMode, + pageURL, + tabSettings[tabSettingA], + firstFrameSetting + ); + info(`Finished Create tab A for ${TEST_MODE_NAMES[aMode]} test.`); + let resultsA = []; + if (aGetResultImmediately) { + try { + info( + `Immediately get result from tab A for ${TEST_MODE_NAMES[aMode]} test` + ); + for (let getResultFunc of aGetResultFuncs) { + resultsA.push(await getResultFunc(tabInfoA.browser)); + } + } catch (e) { + ok(false, `Caught error while getting result from Tab A: ${e}.`); + } + } + info(`Create tab B for ${TEST_MODE_NAMES[aMode]}.`); + let tabInfoB = await IsolationTestTools._addTab( + aMode, + pageURL, + tabSettings[tabSettingB], + secondFrameSetting + ); + info(`Finished Create tab B for ${TEST_MODE_NAMES[aMode]} test.`); + let i = 0; + for (let getResultFunc of aGetResultFuncs) { + // Fetch results from tabs. + info(`Fetching result from tab A for ${TEST_MODE_NAMES[aMode]}.`); + let resultA; + try { + resultA = aGetResultImmediately + ? resultsA[i++] + : await getResultFunc(tabInfoA.browser); + } catch (e) { + ok(false, `Caught error while getting result from Tab A: ${e}.`); + } + info(`Fetching result from tab B for ${TEST_MODE_NAMES[aMode]}.`); + let resultB; + try { + resultB = await getResultFunc(tabInfoB.browser); + } catch (e) { + ok(false, `Caught error while getting result from Tab B: ${e}.`); + } + // Compare results. + let result = false; + let shouldIsolate = + aMode !== TEST_MODE_NO_ISOLATION && tabSettingA !== tabSettingB; + if (aCompareResultFunc) { + result = await aCompareResultFunc(shouldIsolate, resultA, resultB); + } else { + result = shouldIsolate ? resultA !== resultB : resultA === resultB; + } + + let msg = + `Result of Testing ${TEST_MODE_NAMES[aMode]} for ` + + `isolation ${shouldIsolate ? "on" : "off"} with TabSettingA ` + + `${tabSettingA} and tabSettingB ${tabSettingB}` + + `, resultA = ${resultA}, resultB = ${resultB}`; + + ok(result, msg); + } + + // Close Tabs. + BrowserTestUtils.removeTab(tabInfoA.tab); + BrowserTestUtils.removeTab(tabInfoB.tab); + + // A workaround for avoiding a timing issue in Fission. This workaround + // makes sure that the shutdown process between parent and content + // is finished before the next round of testing. + if (SpecialPowers.useRemoteSubframes) { + await new Promise(resolve => { + let observer = (subject, topic, data) => { + if (topic === "ipc:content-shutdown") { + Services.obs.removeObserver(observer, "ipc:content-shutdown"); + resolve(); + } + }; + Services.obs.addObserver(observer, "ipc:content-shutdown"); + }); + } + } + }); + }, +}; diff --git a/browser/components/originattributes/test/browser/test.html b/browser/components/originattributes/test/browser/test.html new file mode 100644 index 0000000000..fe5c7a2cf7 --- /dev/null +++ b/browser/components/originattributes/test/browser/test.html @@ -0,0 +1,20 @@ + + + + + Test for Bug 1260931 + + + + + Hello World. + + diff --git a/browser/components/originattributes/test/browser/test.js b/browser/components/originattributes/test/browser/test.js new file mode 100644 index 0000000000..d290af9b06 --- /dev/null +++ b/browser/components/originattributes/test/browser/test.js @@ -0,0 +1 @@ +var i = 1; diff --git a/browser/components/originattributes/test/browser/test.js^headers^ b/browser/components/originattributes/test/browser/test.js^headers^ new file mode 100644 index 0000000000..2ebf93751b --- /dev/null +++ b/browser/components/originattributes/test/browser/test.js^headers^ @@ -0,0 +1 @@ +Set-Cookie: test=foo; SameSite=None; Secure; diff --git a/browser/components/originattributes/test/browser/test2.html b/browser/components/originattributes/test/browser/test2.html new file mode 100644 index 0000000000..370be15600 --- /dev/null +++ b/browser/components/originattributes/test/browser/test2.html @@ -0,0 +1,12 @@ + + + + + Test for Bug 1260931 + + + + + Hello World. + + diff --git a/browser/components/originattributes/test/browser/test2.js b/browser/components/originattributes/test/browser/test2.js new file mode 100644 index 0000000000..d290af9b06 --- /dev/null +++ b/browser/components/originattributes/test/browser/test2.js @@ -0,0 +1 @@ +var i = 1; diff --git a/browser/components/originattributes/test/browser/test2.js^headers^ b/browser/components/originattributes/test/browser/test2.js^headers^ new file mode 100644 index 0000000000..0fbbf8c3eb --- /dev/null +++ b/browser/components/originattributes/test/browser/test2.js^headers^ @@ -0,0 +1 @@ +Set-Cookie: test2=foo; SameSite=None; Secure; diff --git a/browser/components/originattributes/test/browser/test_firstParty.html b/browser/components/originattributes/test/browser/test_firstParty.html new file mode 100644 index 0000000000..a2028ba663 --- /dev/null +++ b/browser/components/originattributes/test/browser/test_firstParty.html @@ -0,0 +1,15 @@ + + + + + Test for Bug 1260931 + + + +
    + + + +
    + + diff --git a/browser/components/originattributes/test/browser/test_firstParty_cookie.html b/browser/components/originattributes/test/browser/test_firstParty_cookie.html new file mode 100644 index 0000000000..44547c0d75 --- /dev/null +++ b/browser/components/originattributes/test/browser/test_firstParty_cookie.html @@ -0,0 +1,13 @@ + + + + + Test for Bug 1260931 + + + + + Hello World. + + + diff --git a/browser/components/originattributes/test/browser/test_firstParty_html_redirect.html b/browser/components/originattributes/test/browser/test_firstParty_html_redirect.html new file mode 100644 index 0000000000..ac85b1d78a --- /dev/null +++ b/browser/components/originattributes/test/browser/test_firstParty_html_redirect.html @@ -0,0 +1,9 @@ + + + + + Test for Bug 1260931 + + + + diff --git a/browser/components/originattributes/test/browser/test_firstParty_http_redirect.html b/browser/components/originattributes/test/browser/test_firstParty_http_redirect.html new file mode 100644 index 0000000000..7b794a011f --- /dev/null +++ b/browser/components/originattributes/test/browser/test_firstParty_http_redirect.html @@ -0,0 +1,9 @@ + + + + + Test for Bug 1260931 + + + + diff --git a/browser/components/originattributes/test/browser/test_firstParty_http_redirect.html^headers^ b/browser/components/originattributes/test/browser/test_firstParty_http_redirect.html^headers^ new file mode 100644 index 0000000000..ba266f7c68 --- /dev/null +++ b/browser/components/originattributes/test/browser/test_firstParty_http_redirect.html^headers^ @@ -0,0 +1,2 @@ +HTTP 302 Found +Location: https://example.com/browser/browser/components/originattributes/test/browser/dummy.html diff --git a/browser/components/originattributes/test/browser/test_firstParty_http_redirect_to_same_domain.html b/browser/components/originattributes/test/browser/test_firstParty_http_redirect_to_same_domain.html new file mode 100644 index 0000000000..7b794a011f --- /dev/null +++ b/browser/components/originattributes/test/browser/test_firstParty_http_redirect_to_same_domain.html @@ -0,0 +1,9 @@ + + + + + Test for Bug 1260931 + + + + diff --git a/browser/components/originattributes/test/browser/test_firstParty_http_redirect_to_same_domain.html^headers^ b/browser/components/originattributes/test/browser/test_firstParty_http_redirect_to_same_domain.html^headers^ new file mode 100644 index 0000000000..138d6102c1 --- /dev/null +++ b/browser/components/originattributes/test/browser/test_firstParty_http_redirect_to_same_domain.html^headers^ @@ -0,0 +1,2 @@ +HTTP 302 Found +Location: https://example.net/browser/browser/components/originattributes/test/browser/dummy.html diff --git a/browser/components/originattributes/test/browser/test_firstParty_iframe_http_redirect.html b/browser/components/originattributes/test/browser/test_firstParty_iframe_http_redirect.html new file mode 100644 index 0000000000..fd7df46c15 --- /dev/null +++ b/browser/components/originattributes/test/browser/test_firstParty_iframe_http_redirect.html @@ -0,0 +1,13 @@ + + + + + Test for Bug 1260931 + + + +
    + +
    + + diff --git a/browser/components/originattributes/test/browser/test_firstParty_postMessage.html b/browser/components/originattributes/test/browser/test_firstParty_postMessage.html new file mode 100644 index 0000000000..a056909e37 --- /dev/null +++ b/browser/components/originattributes/test/browser/test_firstParty_postMessage.html @@ -0,0 +1,28 @@ + + + + + Test for Bug 1260931 + + + + +
    + + +
    + + diff --git a/browser/components/originattributes/test/browser/test_form.html b/browser/components/originattributes/test/browser/test_form.html new file mode 100644 index 0000000000..db1b900e8b --- /dev/null +++ b/browser/components/originattributes/test/browser/test_form.html @@ -0,0 +1,14 @@ + + + + + Test for Bug 1260931 + + +
    + First name:
    + Last name:
    + +
    + + diff --git a/browser/components/originattributes/test/browser/window.html b/browser/components/originattributes/test/browser/window.html new file mode 100644 index 0000000000..efcda61c10 --- /dev/null +++ b/browser/components/originattributes/test/browser/window.html @@ -0,0 +1,11 @@ + + + + + Page creating a popup + + + + + diff --git a/browser/components/originattributes/test/browser/window2.html b/browser/components/originattributes/test/browser/window2.html new file mode 100644 index 0000000000..955d2cebb0 --- /dev/null +++ b/browser/components/originattributes/test/browser/window2.html @@ -0,0 +1,11 @@ + + + + Page creating a popup + + + + + diff --git a/browser/components/originattributes/test/browser/window3.html b/browser/components/originattributes/test/browser/window3.html new file mode 100644 index 0000000000..92b473aa9d --- /dev/null +++ b/browser/components/originattributes/test/browser/window3.html @@ -0,0 +1,11 @@ + + + + Page creating a popup + + + + + diff --git a/browser/components/originattributes/test/browser/window_redirect.html b/browser/components/originattributes/test/browser/window_redirect.html new file mode 100644 index 0000000000..37beed9101 --- /dev/null +++ b/browser/components/originattributes/test/browser/window_redirect.html @@ -0,0 +1,12 @@ + + + + + Page creating a popup + + + + + diff --git a/browser/components/originattributes/test/mochitest/file_empty.html b/browser/components/originattributes/test/mochitest/file_empty.html new file mode 100644 index 0000000000..15648ec5aa --- /dev/null +++ b/browser/components/originattributes/test/mochitest/file_empty.html @@ -0,0 +1,2 @@ +

    I'm just a support file

    +

    I get loaded to do permission testing.

    diff --git a/browser/components/originattributes/test/mochitest/mochitest.toml b/browser/components/originattributes/test/mochitest/mochitest.toml new file mode 100644 index 0000000000..6db84ec300 --- /dev/null +++ b/browser/components/originattributes/test/mochitest/mochitest.toml @@ -0,0 +1,6 @@ +[DEFAULT] +skip-if = ["os == 'android'"] # bug 1730213 +support-files = ["file_empty.html"] + +["test_permissions_api.html"] +skip-if = ["xorigin"] # Hangs diff --git a/browser/components/originattributes/test/mochitest/test_permissions_api.html b/browser/components/originattributes/test/mochitest/test_permissions_api.html new file mode 100644 index 0000000000..56acae77e8 --- /dev/null +++ b/browser/components/originattributes/test/mochitest/test_permissions_api.html @@ -0,0 +1,176 @@ + + + + + + + Test for Permissions API + + + + + +
    
    +  
    +
    +
    +
    diff --git a/browser/components/pagedata/.eslintrc.js b/browser/components/pagedata/.eslintrc.js
    new file mode 100644
    index 0000000000..8ead689bcc
    --- /dev/null
    +++ b/browser/components/pagedata/.eslintrc.js
    @@ -0,0 +1,14 @@
    +/* This Source Code Form is subject to the terms of the Mozilla Public
    + * License, v. 2.0. If a copy of the MPL was not distributed with this
    + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
    +
    +"use strict";
    +
    +module.exports = {
    +  extends: ["plugin:mozilla/require-jsdoc"],
    +
    +  rules: {
    +    "mozilla/var-only-at-top-level": "error",
    +    "no-unused-expressions": "error",
    +  },
    +};
    diff --git a/browser/components/pagedata/OpenGraphPageData.sys.mjs b/browser/components/pagedata/OpenGraphPageData.sys.mjs
    new file mode 100644
    index 0000000000..8f8b361799
    --- /dev/null
    +++ b/browser/components/pagedata/OpenGraphPageData.sys.mjs
    @@ -0,0 +1,46 @@
    +/* This Source Code Form is subject to the terms of the Mozilla Public
    + * License, v. 2.0. If a copy of the MPL was not distributed with this
    + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
    +
    +/**
    + * Collects Open Graph (https://opengraphprotocol.org/) related data from a page.
    + */
    +export const OpenGraphPageData = {
    +  /**
    +   * Collects the opengraph data from the page.
    +   *
    +   * @param {Document} document
    +   *   The document to collect from
    +   *
    +   * @returns {PageData}
    +   */
    +  collect(document) {
    +    let pageData = {};
    +
    +    // Sites can technically define an Open Graph prefix other than `og:`.
    +    // However, `og:` is one of the default RDFa prefixes and it's likely
    +    // uncommon that sites use a custom prefix. If we find that metadata is
    +    // missing for common sites due to this issue, we could consider adding a
    +    // basic RDFa parser.
    +    let openGraphTags = document.querySelectorAll("meta[property^='og:'");
    +
    +    for (let tag of openGraphTags) {
    +      // Strip "og:" from the property name.
    +      let propertyName = tag.getAttribute("property").substring(3);
    +
    +      switch (propertyName) {
    +        case "description":
    +          pageData.description = tag.getAttribute("content");
    +          break;
    +        case "site_name":
    +          pageData.siteName = tag.getAttribute("content");
    +          break;
    +        case "image":
    +          pageData.image = tag.getAttribute("content");
    +          break;
    +      }
    +    }
    +
    +    return pageData;
    +  },
    +};
    diff --git a/browser/components/pagedata/PageDataChild.sys.mjs b/browser/components/pagedata/PageDataChild.sys.mjs
    new file mode 100644
    index 0000000000..51dc384526
    --- /dev/null
    +++ b/browser/components/pagedata/PageDataChild.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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
    +
    +const lazy = {};
    +
    +ChromeUtils.defineESModuleGetters(lazy, {
    +  PageDataSchema: "resource:///modules/pagedata/PageDataSchema.sys.mjs",
    +  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
    +});
    +
    +// We defer any attempt to check for page data for a short time after a page
    +// loads to allow JS to operate.
    +XPCOMUtils.defineLazyPreferenceGetter(
    +  lazy,
    +  "READY_DELAY",
    +  "browser.pagedata.readyDelay",
    +  500
    +);
    +
    +/**
    + * The actor responsible for monitoring a page for page data.
    + */
    +export class PageDataChild extends JSWindowActorChild {
    +  #isContentWindowPrivate = true;
    +  /**
    +   * Used to debounce notifications about a page being ready.
    +   *
    +   * @type {Timer | null}
    +   */
    +  #deferTimer = null;
    +
    +  /**
    +   * Called when the actor is created for a new page.
    +   */
    +  actorCreated() {
    +    this.#isContentWindowPrivate =
    +      lazy.PrivateBrowsingUtils.isContentWindowPrivate(this.contentWindow);
    +  }
    +
    +  /**
    +   * Called when the page is destroyed.
    +   */
    +  didDestroy() {
    +    if (this.#deferTimer) {
    +      this.#deferTimer.cancel();
    +    }
    +  }
    +
    +  /**
    +   * Called when the page has signalled it is done loading. This signal is
    +   * debounced by READY_DELAY.
    +   */
    +  #deferReady() {
    +    if (!this.#deferTimer) {
    +      this.#deferTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
    +    }
    +
    +    // If the timer was already running this re-starts it.
    +    this.#deferTimer.initWithCallback(
    +      () => {
    +        this.#deferTimer = null;
    +        this.sendAsyncMessage("PageData:DocumentReady", {
    +          url: this.document.documentURI,
    +        });
    +      },
    +      lazy.READY_DELAY,
    +      Ci.nsITimer.TYPE_ONE_SHOT_LOW_PRIORITY
    +    );
    +  }
    +
    +  /**
    +   * Called when a message is received from the parent process.
    +   *
    +   * @param {ReceiveMessageArgument} msg
    +   *   The received message.
    +   *
    +   * @returns {Promise | undefined}
    +   *   A promise for the requested data or undefined if no data was requested.
    +   */
    +  receiveMessage(msg) {
    +    if (this.#isContentWindowPrivate) {
    +      return undefined;
    +    }
    +
    +    switch (msg.name) {
    +      case "PageData:CheckLoaded":
    +        // The service just started in the parent. Check if this document is
    +        // already loaded.
    +        if (this.document.readystate == "complete") {
    +          this.#deferReady();
    +        }
    +        break;
    +      case "PageData:Collect":
    +        return lazy.PageDataSchema.collectPageData(this.document);
    +    }
    +
    +    return undefined;
    +  }
    +
    +  /**
    +   * DOM event handler.
    +   *
    +   * @param {Event} event
    +   *   The DOM event.
    +   */
    +  handleEvent(event) {
    +    if (this.#isContentWindowPrivate) {
    +      return;
    +    }
    +
    +    switch (event.type) {
    +      case "DOMContentLoaded":
    +      case "pageshow":
    +        this.#deferReady();
    +        break;
    +    }
    +  }
    +}
    diff --git a/browser/components/pagedata/PageDataParent.sys.mjs b/browser/components/pagedata/PageDataParent.sys.mjs
    new file mode 100644
    index 0000000000..25295adeca
    --- /dev/null
    +++ b/browser/components/pagedata/PageDataParent.sys.mjs
    @@ -0,0 +1,56 @@
    +/* 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, {
    +  PageDataService: "resource:///modules/pagedata/PageDataService.sys.mjs",
    +});
    +
    +/**
    + * Receives messages from PageDataChild and passes them to the PageData service.
    + */
    +export class PageDataParent extends JSWindowActorParent {
    +  #deferredCollection = null;
    +
    +  /**
    +   * Starts data collection in the child process. Returns a promise that
    +   * resolves to the page data or null if the page is closed before data
    +   * collection completes.
    +   *
    +   * @returns {Promise}
    +   */
    +  collectPageData() {
    +    if (!this.#deferredCollection) {
    +      this.#deferredCollection = Promise.withResolvers();
    +      this.sendQuery("PageData:Collect").then(
    +        this.#deferredCollection.resolve,
    +        this.#deferredCollection.reject
    +      );
    +    }
    +
    +    return this.#deferredCollection.promise;
    +  }
    +
    +  /**
    +   * Called when the page is destroyed.
    +   */
    +  didDestroy() {
    +    this.#deferredCollection?.resolve(null);
    +  }
    +
    +  /**
    +   * Called when a message is received from the content process.
    +   *
    +   * @param {ReceiveMessageArgument} msg
    +   *   The received message.
    +   */
    +  receiveMessage(msg) {
    +    switch (msg.name) {
    +      case "PageData:DocumentReady":
    +        lazy.PageDataService.pageLoaded(this, msg.data.url);
    +        break;
    +    }
    +  }
    +}
    diff --git a/browser/components/pagedata/PageDataSchema.sys.mjs b/browser/components/pagedata/PageDataSchema.sys.mjs
    new file mode 100644
    index 0000000000..ef3907325b
    --- /dev/null
    +++ b/browser/components/pagedata/PageDataSchema.sys.mjs
    @@ -0,0 +1,249 @@
    +/* 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, {
    +  JsonSchemaValidator:
    +    "resource://gre/modules/components-utils/JsonSchemaValidator.sys.mjs",
    +  OpenGraphPageData: "resource:///modules/pagedata/OpenGraphPageData.sys.mjs",
    +  SchemaOrgPageData: "resource:///modules/pagedata/SchemaOrgPageData.sys.mjs",
    +  TwitterPageData: "resource:///modules/pagedata/TwitterPageData.sys.mjs",
    +});
    +
    +ChromeUtils.defineLazyGetter(lazy, "logConsole", function () {
    +  return console.createInstance({
    +    prefix: "PageData",
    +    maxLogLevel: Services.prefs.getBoolPref("browser.pagedata.log", false)
    +      ? "Debug"
    +      : "Warn",
    +  });
    +});
    +
    +/**
    + * The list of page data collectors. These should be sorted in order of
    + * specificity, if the same piece of data is provided by two collectors then the
    + * earlier wins.
    + *
    + * Collectors must provide a `collect` function which will be passed the
    + * document object and should return the PageData structure. The function may be
    + * asynchronous if needed.
    + *
    + * The data returned need not be valid, collectors should return whatever they
    + * can and then we drop anything that is invalid once all data is joined.
    + */
    +ChromeUtils.defineLazyGetter(lazy, "DATA_COLLECTORS", function () {
    +  return [lazy.SchemaOrgPageData, lazy.OpenGraphPageData, lazy.TwitterPageData];
    +});
    +
    +let SCHEMAS = new Map();
    +
    +/**
    + * Loads the schema for the given name.
    + *
    + * @param {string} schemaName
    + *   The name of the schema to load.
    + */
    +async function loadSchema(schemaName) {
    +  if (SCHEMAS.has(schemaName)) {
    +    return SCHEMAS.get(schemaName);
    +  }
    +
    +  let url = `chrome://browser/content/pagedata/schemas/${schemaName.toLocaleLowerCase()}.schema.json`;
    +  let response = await fetch(url);
    +  if (!response.ok) {
    +    throw new Error(`Failed to load schema: ${response.statusText}`);
    +  }
    +
    +  let schema = await response.json();
    +  SCHEMAS.set(schemaName, schema);
    +  return schema;
    +}
    +
    +/**
    + * Validates the data using the schema with the given name.
    + *
    + * @param {string} schemaName
    + *   The name of the schema to validate against.
    + * @param {object} data
    + *   The data to validate.
    + */
    +async function validateData(schemaName, data) {
    +  let schema = await loadSchema(schemaName.toLocaleLowerCase());
    +
    +  let result = lazy.JsonSchemaValidator.validate(data, schema, {
    +    allowExplicitUndefinedProperties: true,
    +    // Allowed for future expansion of the schema.
    +    allowAdditionalProperties: true,
    +  });
    +
    +  if (!result.valid) {
    +    throw result.error;
    +  }
    +}
    +
    +/**
    + * A shared API that can be used in parent or child processes
    + */
    +export const PageDataSchema = {
    +  // Enumeration of data types. The keys must match the schema name.
    +  DATA_TYPE: Object.freeze({
    +    // Note that 1 and 2 were used as types in earlier versions and should not be used here.
    +    PRODUCT: 3,
    +    DOCUMENT: 4,
    +    ARTICLE: 5,
    +    AUDIO: 6,
    +    VIDEO: 7,
    +  }),
    +
    +  /**
    +   * Gets the data type name.
    +   *
    +   * @param {DATA_TYPE} type
    +   *   The data type from the DATA_TYPE enumeration
    +   *
    +   * @returns {string | null} The name for the type or null if not found.
    +   */
    +  nameForType(type) {
    +    for (let [name, value] of Object.entries(this.DATA_TYPE)) {
    +      if (value == type) {
    +        return name;
    +      }
    +    }
    +
    +    return null;
    +  },
    +
    +  /**
    +   * Asynchronously validates some page data against the expected schema. Throws
    +   * an exception if validation fails.
    +   *
    +   * @param {DATA_TYPE} type
    +   *   The data type from the DATA_TYPE enumeration
    +   * @param {object} data
    +   *   The page data
    +   */
    +  async validateData(type, data) {
    +    let name = this.nameForType(type);
    +
    +    if (!name) {
    +      throw new Error(`Unknown data type ${type}`);
    +    }
    +
    +    return validateData(name, data);
    +  },
    +
    +  /**
    +   * Asynchronously validates an entire PageData structure. Any invalid or
    +   * unknown data types are dropped.
    +   *
    +   * @param {PageData} pageData
    +   *   The page data
    +   *
    +   * @returns {PageData} The validated page data structure
    +   */
    +  async validatePageData(pageData) {
    +    let { data: dataMap = {}, ...general } = pageData;
    +
    +    await validateData("general", general);
    +
    +    let validData = {};
    +
    +    for (let [type, data] of Object.entries(dataMap)) {
    +      let name = this.nameForType(type);
    +      // Ignore unknown types here.
    +      if (!name) {
    +        continue;
    +      }
    +
    +      try {
    +        await validateData(name, data);
    +
    +        validData[type] = data;
    +      } catch (e) {
    +        // Invalid data is dropped.
    +      }
    +    }
    +
    +    return {
    +      ...general,
    +      data: validData,
    +    };
    +  },
    +
    +  /**
    +   * Adds new page data into an existing data set. Any existing data is not
    +   * overwritten.
    +   *
    +   * @param {PageData} existingPageData
    +   *   The existing page data
    +   * @param {PageData} newPageData
    +   *   The new page data
    +   *
    +   * @returns {PageData} The joined data.
    +   */
    +  coalescePageData(existingPageData, newPageData) {
    +    // Split out the general data from the map of specific data.
    +    let { data: existingMap = {}, ...existingGeneral } = existingPageData;
    +    let { data: newMap = {}, ...newGeneral } = newPageData;
    +
    +    Object.assign(newGeneral, existingGeneral);
    +
    +    let dataMap = {};
    +    for (let [type, data] of Object.entries(existingMap)) {
    +      if (type in newMap) {
    +        dataMap[type] = Object.assign({}, newMap[type], data);
    +      } else {
    +        dataMap[type] = data;
    +      }
    +    }
    +
    +    for (let [type, data] of Object.entries(newMap)) {
    +      if (!(type in dataMap)) {
    +        dataMap[type] = data;
    +      }
    +    }
    +
    +    return {
    +      ...newGeneral,
    +      data: dataMap,
    +    };
    +  },
    +
    +  /**
    +   * Collects page data from a DOM document.
    +   *
    +   * @param {Document} document
    +   *   The DOM document to collect data from
    +   *
    +   * @returns {Promise} The data collected or null in case of
    +   *   error.
    +   */
    +  async collectPageData(document) {
    +    lazy.logConsole.debug("Starting collection", document.documentURI);
    +
    +    let pending = lazy.DATA_COLLECTORS.map(async collector => {
    +      try {
    +        return await collector.collect(document);
    +      } catch (e) {
    +        lazy.logConsole.error("Error collecting page data", e);
    +        return null;
    +      }
    +    });
    +
    +    let pageDataList = await Promise.all(pending);
    +
    +    let pageData = pageDataList.reduce(PageDataSchema.coalescePageData, {
    +      date: Date.now(),
    +      url: document.documentURI,
    +    });
    +
    +    try {
    +      return this.validatePageData(pageData);
    +    } catch (e) {
    +      lazy.logConsole.error("Failed to collect valid page data", e);
    +      return null;
    +    }
    +  },
    +};
    diff --git a/browser/components/pagedata/PageDataService.sys.mjs b/browser/components/pagedata/PageDataService.sys.mjs
    new file mode 100644
    index 0000000000..7160705c27
    --- /dev/null
    +++ b/browser/components/pagedata/PageDataService.sys.mjs
    @@ -0,0 +1,677 @@
    +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
    +
    +import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
    +
    +const lazy = {};
    +
    +ChromeUtils.defineESModuleGetters(lazy, {
    +  BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
    +  E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
    +  HiddenFrame: "resource://gre/modules/HiddenFrame.sys.mjs",
    +});
    +
    +ChromeUtils.defineLazyGetter(lazy, "logConsole", function () {
    +  return console.createInstance({
    +    prefix: "PageData",
    +    maxLogLevel: Services.prefs.getBoolPref("browser.pagedata.log", false)
    +      ? "Debug"
    +      : "Warn",
    +  });
    +});
    +
    +XPCOMUtils.defineLazyServiceGetters(lazy, {
    +  idleService: ["@mozilla.org/widget/useridleservice;1", "nsIUserIdleService"],
    +});
    +
    +XPCOMUtils.defineLazyPreferenceGetter(
    +  lazy,
    +  "fetchIdleTime",
    +  "browser.pagedata.fetchIdleTime",
    +  300
    +);
    +
    +const ALLOWED_SCHEMES = ["http", "https", "data", "blob"];
    +
    +const BACKGROUND_WIDTH = 1024;
    +const BACKGROUND_HEIGHT = 768;
    +
    +/**
    + * Shifts the first element out of the set.
    + *
    + * @param {Set} set
    + *   The set containing elements.
    + * @returns {T | undefined} The first element in the set or undefined if
    + *   there is nothing in the set.
    + */
    +function shift(set) {
    +  let iter = set.values();
    +  let { value, done } = iter.next();
    +
    +  if (done) {
    +    return undefined;
    +  }
    +
    +  set.delete(value);
    +  return value;
    +}
    +
    +/**
    + * A manager for hidden browsers. Responsible for creating and destroying a
    + * hidden frame to hold them.
    + */
    +class HiddenBrowserManager {
    +  /**
    +   * The hidden frame if one has been created.
    +   *
    +   * @type {HiddenFrame | null}
    +   */
    +  #frame = null;
    +  /**
    +   * The number of hidden browser elements currently in use.
    +   *
    +   * @type {number}
    +   */
    +  #browsers = 0;
    +
    +  /**
    +   * Creates and returns a new hidden browser.
    +   *
    +   * @returns {Browser}
    +   */
    +  async #acquireBrowser() {
    +    this.#browsers++;
    +    if (!this.#frame) {
    +      this.#frame = new lazy.HiddenFrame();
    +    }
    +
    +    let frame = await this.#frame.get();
    +    let doc = frame.document;
    +    let browser = doc.createXULElement("browser");
    +    browser.setAttribute("remote", "true");
    +    browser.setAttribute("type", "content");
    +    browser.setAttribute(
    +      "style",
    +      `
    +        width: ${BACKGROUND_WIDTH}px;
    +        min-width: ${BACKGROUND_WIDTH}px;
    +        height: ${BACKGROUND_HEIGHT}px;
    +        min-height: ${BACKGROUND_HEIGHT}px;
    +      `
    +    );
    +    browser.setAttribute("maychangeremoteness", "true");
    +    doc.documentElement.appendChild(browser);
    +
    +    return browser;
    +  }
    +
    +  /**
    +   * Releases the given hidden browser.
    +   *
    +   * @param {Browser} browser
    +   *   The hidden browser element.
    +   */
    +  #releaseBrowser(browser) {
    +    browser.remove();
    +
    +    this.#browsers--;
    +    if (this.#browsers == 0) {
    +      this.#frame.destroy();
    +      this.#frame = null;
    +    }
    +  }
    +
    +  /**
    +   * Calls a callback function with a new hidden browser.
    +   * This function will return whatever the callback function returns.
    +   *
    +   * @param {Callback} callback
    +   *   The callback function will be called with the browser element and may
    +   *   be asynchronous.
    +   * @returns {T}
    +   */
    +  async withHiddenBrowser(callback) {
    +    let browser = await this.#acquireBrowser();
    +    try {
    +      return await callback(browser);
    +    } finally {
    +      this.#releaseBrowser(browser);
    +    }
    +  }
    +}
    +
    +/**
    + * @typedef {object} CacheEntry
    + *   An entry in the page data cache.
    + * @property {PageData | null} pageData
    + *   The data or null if there is no known data.
    + * @property {Set} actors
    + *   The actors that maintain an interest in keeping the entry cached.
    + */
    +
    +/**
    + * A cache of page data kept in memory. By default any discovered data from
    + * browsers is kept in memory until the browser element is destroyed but other
    + * actors may register an interest in keeping an entry alive beyond that.
    + */
    +class PageDataCache {
    +  /**
    +   * The contents of the cache. Keyed on page url.
    +   *
    +   * @type {Map}
    +   */
    +  #cache = new Map();
    +
    +  /**
    +   * Creates or updates an entry in the cache. If no actor has registered any
    +   * interest in keeping this page's data in memory then this will do nothing.
    +   *
    +   * @param {string} url
    +   *   The url of the page.
    +   * @param {PageData|null} pageData
    +   *   The current page data for the page.
    +   */
    +  set(url, pageData) {
    +    let entry = this.#cache.get(url);
    +
    +    if (entry) {
    +      entry.pageData = pageData;
    +    }
    +  }
    +
    +  /**
    +   * Gets any cached data for the url.
    +   *
    +   * @param {string} url
    +   *   The url of the page.
    +   * @returns {PageData | null}
    +   *   The page data if some is known.
    +   */
    +  get(url) {
    +    let entry = this.#cache.get(url);
    +    return entry?.pageData ?? null;
    +  }
    +
    +  /**
    +   * Adds a lock to an entry. This can be called before we have discovered the
    +   * data for the url.
    +   *
    +   * @param {object} actor
    +   *   Ensures the entry stays in memory until unlocked by this actor.
    +   * @param {string} url
    +   *   The url of the page.
    +   */
    +  lockData(actor, url) {
    +    let entry = this.#cache.get(url);
    +    if (entry) {
    +      entry.actors.add(actor);
    +    } else {
    +      this.#cache.set(url, {
    +        pageData: undefined,
    +        actors: new Set([actor]),
    +      });
    +    }
    +  }
    +
    +  /**
    +   * Removes a lock from an entry.
    +   *
    +   * @param {object} actor
    +   *   The lock to remove.
    +   * @param {string | undefined} [url]
    +   *   The url of the page or undefined to unlock all urls locked by this actor.
    +   */
    +  unlockData(actor, url) {
    +    let entries = [];
    +    if (url) {
    +      let entry = this.#cache.get(url);
    +      if (!entry) {
    +        return;
    +      }
    +
    +      entries.push([url, entry]);
    +    } else {
    +      entries = [...this.#cache];
    +    }
    +
    +    for (let [entryUrl, entry] of entries) {
    +      if (entry.actors.delete(actor)) {
    +        if (entry.actors.size == 0) {
    +          this.#cache.delete(entryUrl);
    +        }
    +      }
    +    }
    +  }
    +}
    +
    +/**
    + * @typedef {object} PageData
    + *   A set of discovered from a page. Other than the `data` property this is the
    + *   schema at `browser/components/pagedata/schemas/general.schema.json`.
    + * @property {string} url
    + *   The page's url.
    + * @property {number} date
    + *   The epoch based timestamp for when the data was discovered.
    + * @property {string} siteName
    + *   The page's friendly site name.
    + * @property {string} image
    + *   The page's image.
    + * @property {object} data
    + *   The map of data found which may be empty if no data was found. The key in
    + *   map is from the `PageDataSchema.DATA_TYPE` enumeration. The values are in
    + *   the format defined by the schemas at `browser/components/pagedata/schemas`.
    + */
    +
    +export const PageDataService = new (class PageDataService extends EventEmitter {
    +  /**
    +   * Caches page data discovered from browsers.
    +   *
    +   * @type {PageDataCache}
    +   */
    +  #pageDataCache = new PageDataCache();
    +
    +  /**
    +   * The number of currently running background fetches.
    +   *
    +   * @type {number}
    +   */
    +  #backgroundFetches = 0;
    +
    +  /**
    +   * The list of urls waiting to be loaded in the background.
    +   *
    +   * @type {Set}
    +   */
    +  #backgroundQueue = new Set();
    +
    +  /**
    +   * Tracks whether the user is currently idle.
    +   *
    +   * @type {boolean}
    +   */
    +  #userIsIdle = false;
    +
    +  /**
    +   * A manager for hidden browsers.
    +   *
    +   * @type {HiddenBrowserManager}
    +   */
    +  #browserManager = new HiddenBrowserManager();
    +
    +  /**
    +   * A map of hidden browsers to a resolve function that should be passed the
    +   * actor that was created for the browser.
    +   *
    +   * @type {WeakMap}
    +   */
    +  #backgroundBrowsers = new WeakMap();
    +
    +  /**
    +   * Tracks windows that have browsers with entries in the cache.
    +   *
    +   * @type {Map>}
    +   */
    +  #trackedWindows = new Map();
    +
    +  /**
    +   * Constructs the service.
    +   */
    +  constructor() {
    +    super();
    +
    +    // Limits the number of background fetches that will run at once. Set to 0 to
    +    // effectively allow an infinite number.
    +    XPCOMUtils.defineLazyPreferenceGetter(
    +      this,
    +      "MAX_BACKGROUND_FETCHES",
    +      "browser.pagedata.maxBackgroundFetches",
    +      5,
    +      () => this.#startBackgroundWorkers()
    +    );
    +  }
    +
    +  /**
    +   * Initializes a new instance of the service, not called externally.
    +   */
    +  init() {
    +    if (!Services.prefs.getBoolPref("browser.pagedata.enabled", false)) {
    +      return;
    +    }
    +
    +    ChromeUtils.registerWindowActor("PageData", {
    +      parent: {
    +        esModuleURI: "resource:///actors/PageDataParent.sys.mjs",
    +      },
    +      child: {
    +        esModuleURI: "resource:///actors/PageDataChild.sys.mjs",
    +        events: {
    +          DOMContentLoaded: {},
    +          pageshow: {},
    +        },
    +      },
    +    });
    +
    +    lazy.logConsole.debug("Service started");
    +
    +    for (let win of lazy.BrowserWindowTracker.orderedWindows) {
    +      if (!win.closed) {
    +        // Ask any existing tabs to report
    +        for (let tab of win.gBrowser.tabs) {
    +          let parent =
    +            tab.linkedBrowser.browsingContext?.currentWindowGlobal.getActor(
    +              "PageData"
    +            );
    +
    +          parent.sendAsyncMessage("PageData:CheckLoaded");
    +        }
    +      }
    +    }
    +
    +    lazy.idleService.addIdleObserver(this, lazy.fetchIdleTime);
    +  }
    +
    +  /**
    +   * Called when the service is destroyed. This is generally on shutdown so we
    +   * don't really need to do much cleanup.
    +   */
    +  uninit() {
    +    lazy.logConsole.debug("Service stopped");
    +  }
    +
    +  /**
    +   * Starts tracking for when a browser is destroyed.
    +   *
    +   * @param {Browser} browser
    +   *   The browser to track.
    +   */
    +  #trackBrowser(browser) {
    +    let window = browser.ownerGlobal;
    +
    +    let browsers = this.#trackedWindows.get(window);
    +    if (browsers) {
    +      browsers.add(browser);
    +
    +      // This window is already being tracked, no need to add listeners.
    +      return;
    +    }
    +
    +    browsers = new Set([browser]);
    +    this.#trackedWindows.set(window, browsers);
    +
    +    window.addEventListener("unload", () => {
    +      for (let closedBrowser of browsers) {
    +        this.unlockEntry(closedBrowser);
    +      }
    +
    +      this.#trackedWindows.delete(window);
    +    });
    +
    +    window.addEventListener("TabClose", ({ target: tab }) => {
    +      // Unlock any entries locked by this browser.
    +      let closedBrowser = tab.linkedBrowser;
    +      this.unlockEntry(closedBrowser);
    +      browsers.delete(closedBrowser);
    +    });
    +  }
    +
    +  /**
    +   * Requests that any page data for this url is retained in memory until
    +   * unlocked. By calling this you are committing to later call `unlockEntry`
    +   * with the same `actor` and `url` parameters.
    +   *
    +   * @param {object} actor
    +   *   The actor requesting the lock.
    +   * @param {string} url
    +   *   The url of the page to lock.
    +   */
    +  lockEntry(actor, url) {
    +    this.#pageDataCache.lockData(actor, url);
    +  }
    +
    +  /**
    +   * Notifies that an actor is no longer interested in a url.
    +   *
    +   * @param {object} actor
    +   *   The actor that requested the lock.
    +   * @param {string | undefined} [url]
    +   *   The url of the page or undefined to unlock all urls locked by this actor.
    +   */
    +  unlockEntry(actor, url) {
    +    this.#pageDataCache.unlockData(actor, url);
    +  }
    +
    +  /**
    +   * Called when the content process signals that a page is ready for data
    +   * collection.
    +   *
    +   * @param {PageDataParent} actor
    +   *   The parent actor for the page.
    +   * @param {string} url
    +   *   The url of the page.
    +   */
    +  async pageLoaded(actor, url) {
    +    let uri = Services.io.newURI(url);
    +    if (!ALLOWED_SCHEMES.includes(uri.scheme)) {
    +      return;
    +    }
    +
    +    let browser = actor.browsingContext?.embedderElement;
    +
    +    // If we don't have a browser then it went away before we could record,
    +    // so we don't know where the data came from.
    +    if (!browser) {
    +      return;
    +    }
    +
    +    // Is this a load in a background browser?
    +    let backgroundResolve = this.#backgroundBrowsers.get(browser);
    +    if (backgroundResolve) {
    +      backgroundResolve(actor);
    +      return;
    +    }
    +
    +    // Otherwise we only care about pages loaded in the tab browser.
    +    if (!this.#isATabBrowser(browser)) {
    +      return;
    +    }
    +
    +    try {
    +      let data = await actor.collectPageData();
    +      if (data) {
    +        // Keep this data alive until the browser is destroyed.
    +        this.#trackBrowser(browser);
    +        this.lockEntry(browser, data.url);
    +
    +        this.pageDataDiscovered(data);
    +      }
    +    } catch (e) {
    +      lazy.logConsole.error(e);
    +    }
    +  }
    +
    +  /**
    +   * Adds data for a url. This should generally only be called by other components of the
    +   * page data service or tests for simulating page data collection.
    +   *
    +   * @param {PageData} pageData
    +   *   The set of data discovered.
    +   */
    +  pageDataDiscovered(pageData) {
    +    lazy.logConsole.debug("Discovered page data", pageData);
    +
    +    this.#pageDataCache.set(pageData.url, {
    +      ...pageData,
    +      data: pageData.data ?? {},
    +    });
    +
    +    // Send out a notification.
    +    this.emit("page-data", pageData);
    +  }
    +
    +  /**
    +   * Retrieves any cached page data. Returns null if there is no information in the cache, this will
    +   * happen either if the page has not been browsed recently or if data collection failed for some
    +   * reason.
    +   *
    +   * @param {string} url
    +   *   The url to retrieve data for.
    +   * @returns {PageData|null}
    +   *   A `PageData` if one is cached (it may not actually contain any items of data) or null if this
    +   *   page has not been successfully checked for data recently.
    +   */
    +  getCached(url) {
    +    return this.#pageDataCache.get(url);
    +  }
    +
    +  /**
    +   * Fetches page data from the given URL using a hidden window. Note that this does not populate
    +   * the page data cache or emit the `page-data` event.
    +   *
    +   * @param {string} url
    +   *   The url to retrieve data for.
    +   * @returns {Promise}
    +   *   Resolves to the found pagedata or null in case of error.
    +   */
    +  async fetchPageData(url) {
    +    return this.#browserManager.withHiddenBrowser(async browser => {
    +      try {
    +        let { promise, resolve } = Promise.withResolvers();
    +        this.#backgroundBrowsers.set(browser, resolve);
    +
    +        let principal = Services.scriptSecurityManager.getSystemPrincipal();
    +        let oa = lazy.E10SUtils.predictOriginAttributes({
    +          browser,
    +        });
    +        let loadURIOptions = {
    +          triggeringPrincipal: principal,
    +          remoteType: lazy.E10SUtils.getRemoteTypeForURI(
    +            url,
    +            true,
    +            false,
    +            lazy.E10SUtils.DEFAULT_REMOTE_TYPE,
    +            null,
    +            oa
    +          ),
    +        };
    +        browser.fixupAndLoadURIString(url, loadURIOptions);
    +
    +        let actor = await promise;
    +        return await actor.collectPageData();
    +      } finally {
    +        this.#backgroundBrowsers.delete(browser);
    +      }
    +    });
    +  }
    +
    +  /**
    +   * Handles notifications from the idle service.
    +   *
    +   * @param {nsISupports} subject
    +   *   The notification's subject.
    +   * @param {string} topic
    +   *   The notification topic.
    +   * @param {string} data
    +   *   The data associated with the notification.
    +   */
    +  observe(subject, topic, data) {
    +    switch (topic) {
    +      case "idle":
    +        lazy.logConsole.debug("User went idle");
    +        this.#userIsIdle = true;
    +        this.#startBackgroundWorkers();
    +        break;
    +      case "active":
    +        lazy.logConsole.debug("User became active");
    +        this.#userIsIdle = false;
    +        break;
    +    }
    +  }
    +
    +  /**
    +   * Starts as many background workers as are allowed to process the background
    +   * queue.
    +   */
    +  #startBackgroundWorkers() {
    +    if (!this.#userIsIdle) {
    +      return;
    +    }
    +
    +    let toStart;
    +
    +    if (this.MAX_BACKGROUND_FETCHES) {
    +      toStart = this.MAX_BACKGROUND_FETCHES - this.#backgroundFetches;
    +    } else {
    +      toStart = this.#backgroundQueue.size;
    +    }
    +
    +    for (let i = 0; i < toStart; i++) {
    +      this.#backgroundFetch();
    +    }
    +  }
    +
    +  /**
    +   * Starts a background fetch worker which will pull urls from the queue and
    +   * load them until the queue is empty.
    +   */
    +  async #backgroundFetch() {
    +    this.#backgroundFetches++;
    +
    +    let url = shift(this.#backgroundQueue);
    +    while (url) {
    +      try {
    +        let pageData = await this.fetchPageData(url);
    +
    +        if (pageData) {
    +          this.#pageDataCache.set(url, pageData);
    +          this.emit("page-data", pageData);
    +        }
    +      } catch (e) {
    +        lazy.logConsole.error(e);
    +      }
    +
    +      // Check whether the user became active or the worker limit changed
    +      // dynamically.
    +      if (
    +        !this.#userIsIdle ||
    +        (this.MAX_BACKGROUND_FETCHES > 0 &&
    +          this.#backgroundFetches > this.MAX_BACKGROUND_FETCHES)
    +      ) {
    +        break;
    +      }
    +
    +      url = shift(this.#backgroundQueue);
    +    }
    +
    +    this.#backgroundFetches--;
    +  }
    +
    +  /**
    +   * Queues page data retrieval for a url. The page-data notification will be
    +   * generated if data becomes available.
    +   *
    +   * Check `getCached` first to ensure that data is not already in the cache.
    +   *
    +   * @param {string} url
    +   *   The url to retrieve data for.
    +   */
    +  queueFetch(url) {
    +    this.#backgroundQueue.add(url);
    +
    +    this.#startBackgroundWorkers();
    +  }
    +
    +  /**
    +   * Determines if the given browser is contained within a tab.
    +   *
    +   * @param {DOMElement} browser
    +   *   The browser element to check.
    +   * @returns {boolean}
    +   *   True if the browser element is contained within a tab.
    +   */
    +  #isATabBrowser(browser) {
    +    return browser.ownerGlobal.gBrowser?.getTabForBrowser(browser);
    +  }
    +})();
    diff --git a/browser/components/pagedata/SchemaOrgPageData.sys.mjs b/browser/components/pagedata/SchemaOrgPageData.sys.mjs
    new file mode 100644
    index 0000000000..449572c76f
    --- /dev/null
    +++ b/browser/components/pagedata/SchemaOrgPageData.sys.mjs
    @@ -0,0 +1,441 @@
    +/* 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 { PageDataSchema } from "resource:///modules/pagedata/PageDataSchema.sys.mjs";
    +
    +/**
    + * Represents an item from the schema.org specification.
    + *
    + * Every `Item` has a type and a set of properties. Each property has a string
    + * name and a list of values. It often isn't clear from the spec whether a
    + * property is expected to have a list of values or just one value so this
    + * data structure stores every property as a list and provides a simple method
    + * to get the first property value.
    + */
    +class Item {
    +  /** @type {string} The type of the item e.g. "Product" or "Person". */
    +  type;
    +
    +  /** @type {Map} Properties of the item. */
    +  properties = new Map();
    +
    +  /**
    +   * Constructors a new `Item` of the given type.
    +   *
    +   * @param {string} type
    +   *   The type of the item.
    +   */
    +  constructor(type) {
    +    this.type = type;
    +  }
    +
    +  /**
    +   * Tests whether a property has any values in this item.
    +   *
    +   * @param {string} prop
    +   *   The name of the property.
    +   * @returns {boolean}
    +   */
    +  has(prop) {
    +    return this.properties.has(prop);
    +  }
    +
    +  /**
    +   * Gets all of the values for a property. This may return an empty array if
    +   * there are no values.
    +   *
    +   * @param {string} prop
    +   *   The name of the property.
    +   * @returns {any[]}
    +   */
    +  all(prop) {
    +    return this.properties.get(prop) ?? [];
    +  }
    +
    +  /**
    +   * Gets the first value for a property.
    +   *
    +   * @param {string} prop
    +   *   The name of the property.
    +   * @returns {any}
    +   */
    +  get(prop) {
    +    return this.properties.get(prop)?.[0];
    +  }
    +
    +  /**
    +   * Sets a value for a property.
    +   *
    +   * @param {string} prop
    +   *   The name of the property.
    +   * @param {any} value
    +   *   The value of the property.
    +   */
    +  set(prop, value) {
    +    let props = this.properties.get(prop);
    +    if (props === undefined) {
    +      props = [];
    +      this.properties.set(prop, props);
    +    }
    +
    +    props.push(value);
    +  }
    +
    +  /**
    +   * Converts this item to JSON-LD.
    +   *
    +   * Single array properties are converted into simple properties.
    +   *
    +   * @returns {object}
    +   */
    +  toJsonLD() {
    +    /**
    +     * Converts a value to its JSON-LD representation.
    +     *
    +     * @param {any} val
    +     *   The value to convert.
    +     * @returns {any}
    +     */
    +    function toLD(val) {
    +      if (val instanceof Item) {
    +        return val.toJsonLD();
    +      }
    +      return val;
    +    }
    +
    +    let props = Array.from(this.properties, ([key, value]) => {
    +      if (value.length == 1) {
    +        return [key, toLD(value[0])];
    +      }
    +
    +      return [key, value.map(toLD)];
    +    });
    +
    +    return {
    +      "@type": this.type,
    +      ...Object.fromEntries(props),
    +    };
    +  }
    +}
    +
    +/**
    + * Parses the value for a given microdata property.
    + * See https://html.spec.whatwg.org/multipage/microdata.html#values for the parsing spec
    + *
    + * @param {Element} propElement
    + *   The property element.
    + * @returns {any}
    + *   The value of the property.
    + */
    +function parseMicrodataProp(propElement) {
    +  if (propElement.hasAttribute("itemscope")) {
    +    throw new Error(
    +      "Cannot parse a simple property value from an itemscope element."
    +    );
    +  }
    +
    +  const parseUrl = (urlElement, attr) => {
    +    if (!urlElement.hasAttribute(attr)) {
    +      return "";
    +    }
    +
    +    try {
    +      let url = new URL(
    +        urlElement.getAttribute(attr),
    +        urlElement.ownerDocument.documentURI
    +      );
    +      return url.toString();
    +    } catch (e) {
    +      return "";
    +    }
    +  };
    +
    +  switch (propElement.localName) {
    +    case "meta":
    +      return propElement.getAttribute("content") ?? "";
    +    case "audio":
    +    case "embed":
    +    case "iframe":
    +    case "source":
    +    case "track":
    +    case "video":
    +      return parseUrl(propElement, "src");
    +    case "img":
    +      // Some pages may be using a lazy loading approach to images, putting a
    +      // temporary image in "src" while the real image is in a differently
    +      // named attribute. So far we found "content" and "data-src" are common
    +      // names for that attribute.
    +      return (
    +        parseUrl(propElement, "content") ||
    +        parseUrl(propElement, "data-src") ||
    +        parseUrl(propElement, "src")
    +      );
    +    case "object":
    +      return parseUrl(propElement, "data");
    +    case "a":
    +    case "area":
    +    case "link":
    +      return parseUrl(propElement, "href");
    +    case "data":
    +    case "meter":
    +      return propElement.getAttribute("value");
    +    case "time":
    +      if (propElement.hasAtribute("datetime")) {
    +        return propElement.getAttribute("datetime");
    +      }
    +      return propElement.textContent;
    +    default:
    +      // Not mentioned in the spec but sites seem to use it.
    +      if (propElement.hasAttribute("content")) {
    +        return propElement.getAttribute("content");
    +      }
    +      return propElement.textContent;
    +  }
    +}
    +
    +/**
    + * Collects product data from an item.
    + *
    + * @param {Document} document
    + *   The document the item comes from.
    + * @param {PageData} pageData
    + *   The pageData object to add to.
    + * @param {Item} item
    + *   The product item.
    + */
    +function collectProduct(document, pageData, item) {
    +  if (item.has("image")) {
    +    let url = new URL(item.get("image"), document.documentURI);
    +    pageData.image = url.toString();
    +  }
    +
    +  if (item.has("description")) {
    +    pageData.description = item.get("description");
    +  }
    +
    +  pageData.data[PageDataSchema.DATA_TYPE.PRODUCT] = {
    +    name: item.get("name"),
    +  };
    +
    +  for (let offer of item.all("offers")) {
    +    if (!(offer instanceof Item) || offer.type != "Offer") {
    +      continue;
    +    }
    +
    +    let price = parseFloat(offer.get("price"));
    +    if (!isNaN(price)) {
    +      pageData.data[PageDataSchema.DATA_TYPE.PRODUCT].price = {
    +        value: price,
    +        currency: offer.get("priceCurrency"),
    +      };
    +
    +      break;
    +    }
    +  }
    +}
    +
    +/**
    + * Returns the root microdata items from the given document.
    + *
    + * @param {Document} document
    + *   The DOM document to collect from.
    + * @returns {Item[]}
    + */
    +function collectMicrodataItems(document) {
    +  // First find all of the items in the document.
    +  let itemElements = document.querySelectorAll(
    +    "[itemscope][itemtype^='https://schema.org/'], [itemscope][itemtype^='http://schema.org/']"
    +  );
    +
    +  /**
    +   * Maps elements to the closest item.
    +   *
    +   * @type {Map}
    +   */
    +  let items = new Map();
    +
    +  /**
    +   * Finds the item for an element. Throws if there is no item. Caches the
    +   * result.
    +   *
    +   * @param {Element} element
    +   *   The element to search from.
    +   * @returns {Item}
    +   */
    +  function itemFor(element) {
    +    let item = items.get(element);
    +    if (item) {
    +      return item;
    +    }
    +
    +    if (!element.parentElement) {
    +      throw new Error("Element has no parent item.");
    +    }
    +
    +    item = itemFor(element.parentElement);
    +    items.set(element, item);
    +    return item;
    +  }
    +
    +  for (let element of itemElements) {
    +    let itemType = element.getAttribute("itemtype");
    +    // Strip off the base url
    +    if (itemType.startsWith("https://")) {
    +      itemType = itemType.substring(19);
    +    } else {
    +      itemType = itemType.substring(18);
    +    }
    +
    +    items.set(element, new Item(itemType));
    +  }
    +
    +  // The initial roots are just all the items.
    +  let roots = new Set(items.values());
    +
    +  // Now find all item properties.
    +  let itemProps = document.querySelectorAll(
    +    "[itemscope][itemtype^='https://schema.org/'] [itemprop], [itemscope][itemtype^='http://schema.org/'] [itemprop]"
    +  );
    +
    +  for (let element of itemProps) {
    +    // The item is always defined above the current element.
    +    let item = itemFor(element.parentElement);
    +
    +    // The properties value is either a nested item or a simple value.
    +    let propValue = items.get(element) ?? parseMicrodataProp(element);
    +    item.set(element.getAttribute("itemprop"), propValue);
    +
    +    if (propValue instanceof Item) {
    +      // This item belongs to another item and so is not a root item.
    +      roots.delete(propValue);
    +    }
    +  }
    +
    +  return [...roots];
    +}
    +
    +/**
    + * Returns the root JSON-LD items from the given document.
    + *
    + * @param {Document} document
    + *   The DOM document to collect from.
    + * @returns {Item[]}
    + */
    +function collectJsonLDItems(document) {
    +  /**
    +   * The root items.
    +   *
    +   * @type {Item[]}
    +   */
    +  let items = [];
    +
    +  /**
    +   * Converts a JSON-LD value into an Item if appropriate.
    +   *
    +   * @param {any} val
    +   *   The value to convert.
    +   * @returns {any}
    +   */
    +  function fromLD(val) {
    +    if (typeof val == "object" && "@type" in val) {
    +      let item = new Item(val["@type"]);
    +
    +      for (let [prop, value] of Object.entries(val)) {
    +        // Ignore meta properties.
    +        if (prop.startsWith("@")) {
    +          continue;
    +        }
    +
    +        if (!Array.isArray(value)) {
    +          value = [value];
    +        }
    +
    +        item.properties.set(prop, value.map(fromLD));
    +      }
    +
    +      return item;
    +    }
    +
    +    return val;
    +  }
    +
    +  let scripts = document.querySelectorAll("script[type='application/ld+json'");
    +  for (let script of scripts) {
    +    try {
    +      let content = JSON.parse(script.textContent);
    +
    +      if (typeof content != "object") {
    +        continue;
    +      }
    +
    +      if (!("@context" in content)) {
    +        continue;
    +      }
    +
    +      if (
    +        content["@context"] != "http://schema.org" &&
    +        content["@context"] != "https://schema.org"
    +      ) {
    +        continue;
    +      }
    +
    +      let item = fromLD(content);
    +      if (item instanceof Item) {
    +        items.push(item);
    +      }
    +    } catch (e) {
    +      // Unparsable content.
    +    }
    +  }
    +
    +  return items;
    +}
    +
    +/**
    + * Collects schema.org related data from a page.
    + *
    + * Currently only supports HTML Microdata and JSON-LD formats, not RDFa.
    + */
    +export const SchemaOrgPageData = {
    +  /**
    +   * Parses and collects the schema.org items from the given document.
    +   * The returned items are the roots, i.e. the top-level items, there may be
    +   * other items as nested properties.
    +   *
    +   * @param {Document} document
    +   *   The DOM document to parse.
    +   * @returns {Item[]}
    +   */
    +  collectItems(document) {
    +    return collectMicrodataItems(document).concat(collectJsonLDItems(document));
    +  },
    +
    +  /**
    +   * Performs PageData collection from the given document.
    +   *
    +   * @param {Document} document
    +   *   The DOM document to collect from.
    +   * @returns {PageData}
    +   */
    +  collect(document) {
    +    let pageData = { data: {} };
    +
    +    let items = this.collectItems(document);
    +
    +    for (let item of items) {
    +      switch (item.type) {
    +        case "Product":
    +          if (!(PageDataSchema.DATA_TYPE.PRODUCT in pageData.data)) {
    +            collectProduct(document, pageData, item);
    +          }
    +          break;
    +        case "Organization":
    +          pageData.siteName = item.get("name");
    +          break;
    +      }
    +    }
    +
    +    return pageData;
    +  },
    +};
    diff --git a/browser/components/pagedata/TwitterPageData.sys.mjs b/browser/components/pagedata/TwitterPageData.sys.mjs
    new file mode 100644
    index 0000000000..88b06098cb
    --- /dev/null
    +++ b/browser/components/pagedata/TwitterPageData.sys.mjs
    @@ -0,0 +1,42 @@
    +/* 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/. */
    +
    +/**
    + * Collects Twitter card (https://developer.twitter.com/en/docs/twitter-for-websites/)
    + * related data from a page.
    + */
    +export const TwitterPageData = {
    +  /**
    +   * Collects the twitter data from the page.
    +   *
    +   * @param {Document} document
    +   *   The document to collect from
    +   *
    +   * @returns {PageData}
    +   */
    +  collect(document) {
    +    let pageData = {};
    +
    +    let twitterTags = document.querySelectorAll("meta[name^='twitter:'");
    +
    +    for (let tag of twitterTags) {
    +      // Strip "twitter:" from the property name.
    +      let propertyName = tag.getAttribute("name").substring(8);
    +
    +      switch (propertyName) {
    +        case "site":
    +          pageData.siteName = tag.getAttribute("content");
    +          break;
    +        case "description":
    +          pageData.description = tag.getAttribute("content");
    +          break;
    +        case "image":
    +          pageData.image = tag.getAttribute("content");
    +          break;
    +      }
    +    }
    +
    +    return pageData;
    +  },
    +};
    diff --git a/browser/components/pagedata/docs/index.md b/browser/components/pagedata/docs/index.md
    new file mode 100644
    index 0000000000..47b507d13a
    --- /dev/null
    +++ b/browser/components/pagedata/docs/index.md
    @@ -0,0 +1,50 @@
    +# PageDataService
    +
    +The page data service is responsible for collecting additional data about a page. This could include
    +information about the media on a page, product information, etc. When enabled it will automatically
    +try to find page data for pages that the user browses or it can be directed to asynchronously look
    +up the page data for a url.
    +
    +The `PageDataService` is an EventEmitter and listeners can subscribe to its notifications via the
    +`on` and `once` methods.
    +
    +The service can be enabled by setting `browser.pagedata.enabled` to true. Additional logging can be
    +enabled by setting `browser.pagedata.log` to true.
    +
    +## PageData Data Structure
    +
    +At a high level the page data service can collect many different kinds of data. When queried the
    +service will respond with a `PageData` structure which holds some general information about the
    +page, the time when the data was discovered and a map of the different types of data found. This map
    +will be empty if no specific data was found. The key of the map is from the
    +`PageDataSchema.DATA_TYPE` enumeration. The value is the JSON data which differs in structure
    +depending on the data type.
    +
    +```
    +{
    +  "url": ,
    +  "date": ,
    +  "siteName": ,
    +  "image": ,
    +  "data": ,
    +}
    +```
    +
    +## PageData Collection
    +
    +Page data is gathered in one of two ways.
    +
    +Page data is automatically gathered for webpages the user visits. This collection is trigged after
    +a short delay and then updated when necessary. Any data is cached in memory for a period of time.
    +When page data has been found a `page-data` event is emitted. The event's argument holds the
    +`PageData` structure. The `getCached` function can be used to access any cached data for a url.
    +
    +## Supported Types of page data
    +
    +The following types of page data (`PageDataSchema.DATA_TYPE`) are currently supported:
    +
    +- `PRODUCT`
    +- `DOCUMENT`
    +- `ARTICLE`
    +- `AUDIO`
    +- `VIDEO`
    diff --git a/browser/components/pagedata/jar.mn b/browser/components/pagedata/jar.mn
    new file mode 100644
    index 0000000000..19860a30ee
    --- /dev/null
    +++ b/browser/components/pagedata/jar.mn
    @@ -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/.
    +
    +browser.jar:
    +    content/browser/pagedata/schemas/ (schemas/*.json)
    diff --git a/browser/components/pagedata/moz.build b/browser/components/pagedata/moz.build
    new file mode 100644
    index 0000000000..f1e49c4e4b
    --- /dev/null
    +++ b/browser/components/pagedata/moz.build
    @@ -0,0 +1,29 @@
    +# -*- 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/.
    +
    +XPCSHELL_TESTS_MANIFESTS += [
    +    "tests/unit/xpcshell.toml",
    +]
    +BROWSER_CHROME_MANIFESTS += [
    +    "tests/browser/browser.toml",
    +]
    +
    +JAR_MANIFESTS += ["jar.mn"]
    +
    +EXTRA_JS_MODULES.pagedata += [
    +    "OpenGraphPageData.sys.mjs",
    +    "PageDataSchema.sys.mjs",
    +    "PageDataService.sys.mjs",
    +    "SchemaOrgPageData.sys.mjs",
    +    "TwitterPageData.sys.mjs",
    +]
    +
    +FINAL_TARGET_FILES.actors += [
    +    "PageDataChild.sys.mjs",
    +    "PageDataParent.sys.mjs",
    +]
    +
    +SPHINX_TREES["docs"] = "docs"
    diff --git a/browser/components/pagedata/schemas/article.schema.json b/browser/components/pagedata/schemas/article.schema.json
    new file mode 100644
    index 0000000000..e02bb11655
    --- /dev/null
    +++ b/browser/components/pagedata/schemas/article.schema.json
    @@ -0,0 +1,26 @@
    +{
    +  "$schema": "https://json-schema.org/draft/2020-12/schema",
    +  "$id": "article.schema.json",
    +  "title": "Article",
    +  "description": "An article for reading",
    +  "type": "object",
    +  "properties": {
    +    "name": {
    +      "description": "The article's name",
    +      "type": "string"
    +    },
    +    "author": {
    +      "description": "The author(s) of the article",
    +      "type": "string"
    +    },
    +    "date": {
    +      "description": "The date the article was published in ISO-8601 date or date/time format",
    +      "type": "string"
    +    },
    +    "readingTime": {
    +      "description": "The expected time to read the article in seconds",
    +      "type": "number"
    +    }
    +  },
    +  "required": ["name"]
    +}
    diff --git a/browser/components/pagedata/schemas/audio.schema.json b/browser/components/pagedata/schemas/audio.schema.json
    new file mode 100644
    index 0000000000..db1b79b55c
    --- /dev/null
    +++ b/browser/components/pagedata/schemas/audio.schema.json
    @@ -0,0 +1,34 @@
    +{
    +  "$schema": "https://json-schema.org/draft/2020-12/schema",
    +  "$id": "audio.schema.json",
    +  "title": "Audio",
    +  "description": "An audio file",
    +  "type": "object",
    +  "properties": {
    +    "name": {
    +      "description": "The audio's name",
    +      "type": "string"
    +    },
    +    "duration": {
    +      "description": "The audio's duration in seconds",
    +      "type": "number"
    +    },
    +    "artist": {
    +      "description": "The artist who created the audio",
    +      "type": "string"
    +    },
    +    "album": {
    +      "description": "For music on an album the name of the album",
    +      "type": "string"
    +    },
    +    "track": {
    +      "description": "For music on an album the number of the track on the album",
    +      "type": "number"
    +    },
    +    "genre": {
    +      "description": "The genre of the audio",
    +      "type": "string"
    +    }
    +  },
    +  "required": ["name"]
    +}
    diff --git a/browser/components/pagedata/schemas/document.schema.json b/browser/components/pagedata/schemas/document.schema.json
    new file mode 100644
    index 0000000000..849010773b
    --- /dev/null
    +++ b/browser/components/pagedata/schemas/document.schema.json
    @@ -0,0 +1,18 @@
    +{
    +  "$schema": "https://json-schema.org/draft/2020-12/schema",
    +  "$id": "document.schema.json",
    +  "title": "Document",
    +  "description": "A document of some kind, either viewable or editable",
    +  "type": "object",
    +  "properties": {
    +    "name": {
    +      "description": "The document's name",
    +      "type": "string"
    +    },
    +    "mimeType": {
    +      "description": "The document's mimetype",
    +      "type": "string"
    +    }
    +  },
    +  "required": ["name"]
    +}
    diff --git a/browser/components/pagedata/schemas/general.schema.json b/browser/components/pagedata/schemas/general.schema.json
    new file mode 100644
    index 0000000000..a400fd889b
    --- /dev/null
    +++ b/browser/components/pagedata/schemas/general.schema.json
    @@ -0,0 +1,30 @@
    +{
    +  "$schema": "https://json-schema.org/draft/2020-12/schema",
    +  "$id": "general.schema.json",
    +  "title": "General",
    +  "description": "General data about a page",
    +  "type": "object",
    +  "properties": {
    +    "url": {
    +      "description": "The page's url",
    +      "type": "string"
    +    },
    +    "date": {
    +      "description": "The date the data was collected as a timestamp",
    +      "type": "number"
    +    },
    +    "description": {
    +      "description": "A description of the page",
    +      "type": "string"
    +    },
    +    "siteName": {
    +      "description": "A friendly name for the site",
    +      "type": "string"
    +    },
    +    "image": {
    +      "description": "The url for an image representative of the page",
    +      "type": "string"
    +    }
    +  },
    +  "required": ["url", "date"]
    +}
    diff --git a/browser/components/pagedata/schemas/product.schema.json b/browser/components/pagedata/schemas/product.schema.json
    new file mode 100644
    index 0000000000..77bec76ff2
    --- /dev/null
    +++ b/browser/components/pagedata/schemas/product.schema.json
    @@ -0,0 +1,46 @@
    +{
    +  "$schema": "https://json-schema.org/draft/2020-12/schema",
    +  "$id": "product.schema.json",
    +  "title": "Product",
    +  "description": "A product that can be purchased",
    +  "type": "object",
    +  "properties": {
    +    "name": {
    +      "description": "The product's name",
    +      "type": "string"
    +    },
    +    "brand": {
    +      "description": "The product's brand",
    +      "type": "string"
    +    },
    +    "price": {
    +      "description": "The cost of a single unit",
    +      "type": "object",
    +      "properties": {
    +        "value": {
    +          "type": "number"
    +        },
    +        "currency": {
    +          "description": "The currency for the value",
    +          "type": "string"
    +        }
    +      },
    +      "required": ["value"]
    +    },
    +    "shippingCost": {
    +      "description": "The cost of shipping",
    +      "type": "object",
    +      "properties": {
    +        "value": {
    +          "type": "number"
    +        },
    +        "currency": {
    +          "description": "The currency for the value",
    +          "type": "string"
    +        }
    +      },
    +      "required": ["value"]
    +    }
    +  },
    +  "required": ["name"]
    +}
    diff --git a/browser/components/pagedata/schemas/video.schema.json b/browser/components/pagedata/schemas/video.schema.json
    new file mode 100644
    index 0000000000..1091ebfe89
    --- /dev/null
    +++ b/browser/components/pagedata/schemas/video.schema.json
    @@ -0,0 +1,38 @@
    +{
    +  "$schema": "https://json-schema.org/draft/2020-12/schema",
    +  "$id": "video.schema.json",
    +  "title": "Video",
    +  "description": "A video",
    +  "type": "object",
    +  "properties": {
    +    "name": {
    +      "description": "The video's name",
    +      "type": "string"
    +    },
    +    "duration": {
    +      "description": "The video's duration in seconds",
    +      "type": "number"
    +    },
    +    "quality": {
    +      "description": "A short description of the video's quality (e.g. 'HD', '720p')",
    +      "type": "string"
    +    },
    +    "show": {
    +      "description": "For an episode of a TV show the name of the TV show",
    +      "type": "string"
    +    },
    +    "season": {
    +      "description": "For an episode of a TV show the season number it appears in",
    +      "type": "number"
    +    },
    +    "episode": {
    +      "description": "For an episode of a TV show the number of the episode in the season",
    +      "type": "number"
    +    },
    +    "genre": {
    +      "description": "The genre of the video",
    +      "type": "string"
    +    }
    +  },
    +  "required": ["name"]
    +}
    diff --git a/browser/components/pagedata/tests/browser/browser.toml b/browser/components/pagedata/tests/browser/browser.toml
    new file mode 100644
    index 0000000000..8bcd7a539b
    --- /dev/null
    +++ b/browser/components/pagedata/tests/browser/browser.toml
    @@ -0,0 +1,16 @@
    +# 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/.
    +
    +[DEFAULT]
    +prefs = [
    +  "browser.pagedata.log=true",
    +  "browser.pagedata.enabled=true",
    +]
    +support-files = ["head.js"]
    +
    +["browser_pagedata_background.js"]
    +
    +["browser_pagedata_basic.js"]
    +
    +["browser_pagedata_cache.js"]
    diff --git a/browser/components/pagedata/tests/browser/browser_pagedata_background.js b/browser/components/pagedata/tests/browser/browser_pagedata_background.js
    new file mode 100644
    index 0000000000..bba2ae2e47
    --- /dev/null
    +++ b/browser/components/pagedata/tests/browser/browser_pagedata_background.js
    @@ -0,0 +1,48 @@
    +/* 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/. */
    +
    +/**
    + * Background load tests for the page data service.
    + */
    +
    +const TEST_URL =
    +  "data:text/html," +
    +  encodeURIComponent(`
    +    
    +    
    +      
    +      
    +      
    +      
    +      
    +      
    +    
    +    
    +    
    +    
    +`);
    +
    +add_task(async function test_pagedata_no_data() {
    +  let pageData = await PageDataService.fetchPageData(TEST_URL);
    +
    +  delete pageData.date;
    +  Assert.deepEqual(
    +    pageData,
    +    {
    +      url: TEST_URL,
    +      siteName: "@nytimes",
    +      description: "NEWARK - The guest list and parade of limousines",
    +      image:
    +        "http://graphics8.nytimes.com/images/2012/02/19/us/19whitney-span/19whitney-span-articleLarge.jpg",
    +      data: {},
    +    },
    +    "Should have returned the right data"
    +  );
    +
    +  Assert.equal(
    +    PageDataService.getCached(TEST_URL),
    +    null,
    +    "Should not have cached this data"
    +  );
    +});
    diff --git a/browser/components/pagedata/tests/browser/browser_pagedata_basic.js b/browser/components/pagedata/tests/browser/browser_pagedata_basic.js
    new file mode 100644
    index 0000000000..4984645274
    --- /dev/null
    +++ b/browser/components/pagedata/tests/browser/browser_pagedata_basic.js
    @@ -0,0 +1,64 @@
    +/* 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/. */
    +
    +/**
    + * Basic tests for the page data service.
    + */
    +
    +const TEST_URL = "https://example.com/";
    +const TEST_URL2 = "https://example.com/browser";
    +
    +add_task(async function test_pagedata_no_data() {
    +  let promise = PageDataService.once("page-data");
    +
    +  await BrowserTestUtils.withNewTab(TEST_URL, async browser => {
    +    let pageData = await promise;
    +    Assert.equal(pageData.url, TEST_URL, "Should have returned the loaded URL");
    +    Assert.deepEqual(pageData.data, {}, "Should have returned no data");
    +    Assert.deepEqual(
    +      PageDataService.getCached(TEST_URL),
    +      pageData,
    +      "Should return the same data from the cache"
    +    );
    +
    +    promise = PageDataService.once("page-data");
    +    BrowserTestUtils.startLoadingURIString(browser, TEST_URL2);
    +    await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2);
    +    pageData = await promise;
    +    Assert.equal(
    +      pageData.url,
    +      TEST_URL2,
    +      "Should have returned the loaded URL"
    +    );
    +    Assert.deepEqual(pageData.data, {}, "Should have returned no data");
    +    Assert.deepEqual(
    +      PageDataService.getCached(TEST_URL2),
    +      pageData,
    +      "Should return the same data from the cache"
    +    );
    +
    +    info("Test going back still triggers collection");
    +
    +    promise = PageDataService.once("page-data");
    +    let locationChangePromise = BrowserTestUtils.waitForLocationChange(
    +      gBrowser,
    +      TEST_URL
    +    );
    +    browser.goBack();
    +    await locationChangePromise;
    +    pageData = await promise;
    +
    +    Assert.equal(
    +      pageData.url,
    +      TEST_URL,
    +      "Should have returned the URL of the previous page"
    +    );
    +    Assert.deepEqual(pageData.data, {}, "Should have returned no data");
    +    Assert.deepEqual(
    +      PageDataService.getCached(TEST_URL),
    +      pageData,
    +      "Should return the same data from the cache"
    +    );
    +  });
    +});
    diff --git a/browser/components/pagedata/tests/browser/browser_pagedata_cache.js b/browser/components/pagedata/tests/browser/browser_pagedata_cache.js
    new file mode 100644
    index 0000000000..e41b4ea2f8
    --- /dev/null
    +++ b/browser/components/pagedata/tests/browser/browser_pagedata_cache.js
    @@ -0,0 +1,155 @@
    +/* This Source Code Form is subject to the terms of the Mozilla Public
    + * License, v. 2.0. If a copy of the MPL was not distributed with this
    + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
    +
    +/**
    + * Tests for the page data cache.
    + */
    +
    +const TEST_URL =
    +  "data:text/html," +
    +  encodeURIComponent(`
    +    
    +    
    +    
    +      
    +      
    +      
    +      
    +      
    +      
    +      
    +    
    +    
    +    
    +    
    +`);
    +
    +/**
    + * Runs a task with a new page loaded into a tab in a new browser window.
    + *
    + * @param {string} url
    + *   The url to load.
    + * @param {Function} task
    + *   The task to run. May return a promise.
    + */
    +async function withBrowserInNewWindow(url, task) {
    +  let newWin = await BrowserTestUtils.openNewBrowserWindow();
    +  let tab = await BrowserTestUtils.openNewForegroundTab(newWin.gBrowser, url);
    +  await task(tab.linkedBrowser);
    +  await BrowserTestUtils.closeWindow(newWin);
    +}
    +
    +add_task(async function test_pagedata_cache() {
    +  let promise = PageDataService.once("page-data");
    +
    +  Assert.equal(
    +    PageDataService.getCached(TEST_URL),
    +    null,
    +    "Should be no data cached."
    +  );
    +
    +  await BrowserTestUtils.withNewTab(TEST_URL, async () => {
    +    let pageData = await promise;
    +
    +    Assert.deepEqual(
    +      PageDataService.getCached(TEST_URL),
    +      pageData,
    +      "Should return the same data from the cache"
    +    );
    +
    +    delete pageData.date;
    +
    +    Assert.deepEqual(
    +      pageData,
    +      {
    +        url: TEST_URL,
    +        siteName: "@nytimes",
    +        description: "NEWARK - The guest list and parade of limousines",
    +        image:
    +          "http://graphics8.nytimes.com/images/2012/02/19/us/19whitney-span/19whitney-span-articleLarge.jpg",
    +        data: {},
    +      },
    +      "Should have returned the right data"
    +    );
    +  });
    +
    +  Assert.equal(
    +    PageDataService.getCached(TEST_URL),
    +    null,
    +    "Data should no longer be cached."
    +  );
    +
    +  promise = PageDataService.once("page-data");
    +
    +  // Checks that closing a window containing a tracked tab stops tracking the tab.
    +  await withBrowserInNewWindow(TEST_URL, async () => {
    +    let pageData = await promise;
    +
    +    Assert.deepEqual(
    +      PageDataService.getCached(TEST_URL),
    +      pageData,
    +      "Should return the same data from the cache"
    +    );
    +
    +    delete pageData.date;
    +    Assert.deepEqual(
    +      pageData,
    +      {
    +        url: TEST_URL,
    +        siteName: "@nytimes",
    +        description: "NEWARK - The guest list and parade of limousines",
    +        image:
    +          "http://graphics8.nytimes.com/images/2012/02/19/us/19whitney-span/19whitney-span-articleLarge.jpg",
    +        data: {},
    +      },
    +      "Should have returned the right data"
    +    );
    +  });
    +
    +  Assert.equal(
    +    PageDataService.getCached(TEST_URL),
    +    null,
    +    "Data should no longer be cached."
    +  );
    +
    +  let actor = {};
    +  PageDataService.lockEntry(actor, TEST_URL);
    +
    +  promise = PageDataService.once("page-data");
    +
    +  // Closing a tracked tab shouldn't expire the data here as we have another lock.
    +  await BrowserTestUtils.withNewTab(TEST_URL, async () => {
    +    await promise;
    +  });
    +
    +  promise = PageDataService.once("page-data");
    +
    +  // Closing a window with a tracked tab shouldn't expire the data here as we have another lock.
    +  await withBrowserInNewWindow(TEST_URL, async () => {
    +    await promise;
    +  });
    +
    +  let cached = PageDataService.getCached(TEST_URL);
    +  delete cached.date;
    +  Assert.deepEqual(
    +    cached,
    +    {
    +      url: TEST_URL,
    +      siteName: "@nytimes",
    +      description: "NEWARK - The guest list and parade of limousines",
    +      image:
    +        "http://graphics8.nytimes.com/images/2012/02/19/us/19whitney-span/19whitney-span-articleLarge.jpg",
    +      data: {},
    +    },
    +    "Entry should still be cached"
    +  );
    +
    +  PageDataService.unlockEntry(actor, TEST_URL);
    +
    +  Assert.equal(
    +    PageDataService.getCached(TEST_URL),
    +    null,
    +    "Data should no longer be cached."
    +  );
    +});
    diff --git a/browser/components/pagedata/tests/browser/head.js b/browser/components/pagedata/tests/browser/head.js
    new file mode 100644
    index 0000000000..b4f57cdb76
    --- /dev/null
    +++ b/browser/components/pagedata/tests/browser/head.js
    @@ -0,0 +1,8 @@
    +/* This Source Code Form is subject to the terms of the Mozilla Public
    + * License, v. 2.0. If a copy of the MPL was not distributed with this
    + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
    +
    +ChromeUtils.defineESModuleGetters(this, {
    +  PageDataSchema: "resource:///modules/pagedata/PageDataSchema.sys.mjs",
    +  PageDataService: "resource:///modules/pagedata/PageDataService.sys.mjs",
    +});
    diff --git a/browser/components/pagedata/tests/unit/head.js b/browser/components/pagedata/tests/unit/head.js
    new file mode 100644
    index 0000000000..55b002692b
    --- /dev/null
    +++ b/browser/components/pagedata/tests/unit/head.js
    @@ -0,0 +1,105 @@
    +/* Any copyright is dedicated to the Public Domain.
    + * http://creativecommons.org/publicdomain/zero/1.0/ */
    +
    +const { XPCOMUtils } = ChromeUtils.importESModule(
    +  "resource://gre/modules/XPCOMUtils.sys.mjs"
    +);
    +
    +ChromeUtils.defineESModuleGetters(this, {
    +  PageDataSchema: "resource:///modules/pagedata/PageDataSchema.sys.mjs",
    +});
    +
    +const { HttpServer } = ChromeUtils.importESModule(
    +  "resource://testing-common/httpd.sys.mjs"
    +);
    +
    +const server = new HttpServer();
    +server.start(-1);
    +
    +const SERVER_PORT = server.identity.primaryPort;
    +const BASE_URL = "http://localhost:" + SERVER_PORT;
    +const DEFAULT_PATH = "/document.html";
    +const TEST_URL = BASE_URL + DEFAULT_PATH;
    +
    +registerCleanupFunction(() => {
    +  server.stop();
    +});
    +
    +do_get_profile();
    +Services.prefs.setBoolPref("browser.pagedata.log", true);
    +
    +/**
    + * Given a string parses it as HTML into a DOM Document object.
    + *
    + * @param {string} str
    + *   The string to parse.
    + * @param {string} path
    + *   The path for the document on the server, defaults to "/document.html"
    + * @returns {Promise} the HTML DOM Document object.
    + */
    +function parseDocument(str, path = DEFAULT_PATH) {
    +  server.registerPathHandler(path, (request, response) => {
    +    response.setHeader("Content-Type", "text/html;charset=utf-8");
    +
    +    let converter = Cc[
    +      "@mozilla.org/intl/converter-output-stream;1"
    +    ].createInstance(Ci.nsIConverterOutputStream);
    +    converter.init(response.bodyOutputStream, "utf-8");
    +    converter.writeString(str);
    +  });
    +
    +  return new Promise((resolve, reject) => {
    +    let request = new XMLHttpRequest();
    +    request.responseType = "document";
    +    request.open("GET", BASE_URL + path, true);
    +
    +    request.addEventListener("error", reject);
    +    request.addEventListener("abort", reject);
    +
    +    request.addEventListener("load", function () {
    +      resolve(request.responseXML);
    +    });
    +
    +    request.send();
    +  });
    +}
    +
    +/**
    + * Parses page data from a HTML string.
    + *
    + * @param {string} str
    + *   The HTML string to parse.
    + * @param {string} path
    + *   The path for the document on the server, defaults to "/document.html"
    + * @returns {Promise} A promise that resolves to the page data found.
    + */
    +async function parsePageData(str, path) {
    +  let doc = await parseDocument(str, path);
    +  return PageDataSchema.collectPageData(doc);
    +}
    +
    +/**
    + * Verifies that the HTML string given parses to the expected page data.
    + *
    + * @param {string} str
    + *   The HTML string to parse.
    + * @param {PageData} expected
    + *   The expected pagedata excluding the date and url properties.
    + * @param {string} path
    + *   The path for the document on the server, defaults to "/document.html"
    + * @returns {Promise} A promise that resolves to the page data found.
    + */
    +async function verifyPageData(str, expected, path = DEFAULT_PATH) {
    +  let pageData = await parsePageData(str, path);
    +
    +  delete pageData.date;
    +
    +  Assert.equal(pageData.url, BASE_URL + path);
    +  delete pageData.url;
    +
    +  Assert.deepEqual(
    +    pageData,
    +    expected,
    +    "Should have seen the expected page data."
    +  );
    +}
    diff --git a/browser/components/pagedata/tests/unit/test_opengraph.js b/browser/components/pagedata/tests/unit/test_opengraph.js
    new file mode 100644
    index 0000000000..e5accaf675
    --- /dev/null
    +++ b/browser/components/pagedata/tests/unit/test_opengraph.js
    @@ -0,0 +1,67 @@
    +/* This Source Code Form is subject to the terms of the Mozilla Public
    + * License, v. 2.0. If a copy of the MPL was not distributed with this
    + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
    +
    +/**
    + * Tests that the page data service can parse Open Graph metadata.
    + */
    +
    +add_task(async function test_type_website() {
    +  await verifyPageData(
    +    `
    +      
    +      
    +      
    +        Internet for people, not profit — Mozilla
    +        
    +        
    +        
    +        
    +        
    +        
    +        
    +        
    +      
    +      
    +        

    Test page

    + + + `, + { + siteName: "Mozilla", + description: + "Mozilla is the not-for-profit behind the lightning fast Firefox browser. We put people over profit to give everyone more power online.", + image: "https://example.com/preview-image", + data: {}, + } + ); +}); + +add_task(async function test_type_movie() { + await verifyPageData( + ` + + + + Code Rush (TV Movie 2000) + + + + + + + + + + +

    Test page

    + + + `, + { + image: "https://example.com/preview-code-rush", + description: "This is the description of the movie.", + data: {}, + } + ); +}); diff --git a/browser/components/pagedata/tests/unit/test_pagedata_basic.js b/browser/components/pagedata/tests/unit/test_pagedata_basic.js new file mode 100644 index 0000000000..5d31645a4c --- /dev/null +++ b/browser/components/pagedata/tests/unit/test_pagedata_basic.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Simply tests that the notification is dispatched when new page data is + * discovered. + */ + +ChromeUtils.defineESModuleGetters(this, { + PageDataService: "resource:///modules/pagedata/PageDataService.sys.mjs", +}); + +add_task(async function test_pageDataDiscovered_notifies() { + let url = "https://www.mozilla.org/"; + + Assert.equal( + PageDataService.getCached(url), + null, + "Should be no cached data." + ); + + let promise = PageDataService.once("page-data"); + + PageDataService.pageDataDiscovered({ + url, + date: 32453456, + data: { + [PageDataSchema.DATA_TYPE.PRODUCT]: { + name: "Bolts", + price: { value: 276 }, + }, + }, + }); + + let pageData = await promise; + Assert.equal( + pageData.url, + url, + "Should have notified data for the expected url" + ); + + Assert.deepEqual( + pageData, + { + url, + date: 32453456, + data: { + [PageDataSchema.DATA_TYPE.PRODUCT]: { + name: "Bolts", + price: { value: 276 }, + }, + }, + }, + "Should have returned the correct product data" + ); + + Assert.equal( + PageDataService.getCached(url), + null, + "Should not have cached the data as there was no actor locking." + ); + + let actor = {}; + PageDataService.lockEntry(actor, url); + + PageDataService.pageDataDiscovered({ + url, + date: 32453456, + data: { + [PageDataSchema.DATA_TYPE.PRODUCT]: { + name: "Bolts", + price: { value: 276 }, + }, + }, + }); + + // Should now be in the cache. + Assert.deepEqual( + PageDataService.getCached(url), + { + url, + date: 32453456, + data: { + [PageDataSchema.DATA_TYPE.PRODUCT]: { + name: "Bolts", + price: { value: 276 }, + }, + }, + }, + "Should have cached the data" + ); + + PageDataService.unlockEntry(actor, url); + + Assert.equal( + PageDataService.getCached(url), + null, + "Should have dropped the data from the cache." + ); +}); diff --git a/browser/components/pagedata/tests/unit/test_pagedata_schema.js b/browser/components/pagedata/tests/unit/test_pagedata_schema.js new file mode 100644 index 0000000000..fcd9c4b297 --- /dev/null +++ b/browser/components/pagedata/tests/unit/test_pagedata_schema.js @@ -0,0 +1,210 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests schema validation. + */ + +add_task(async function testBasic() { + // Old data types, should not be recognised. + Assert.equal(PageDataSchema.nameForType(1), null); + Assert.equal(PageDataSchema.nameForType(2), null); + + Assert.equal( + PageDataSchema.nameForType(PageDataSchema.DATA_TYPE.VIDEO), + "VIDEO" + ); + Assert.equal( + PageDataSchema.nameForType(PageDataSchema.DATA_TYPE.PRODUCT), + "PRODUCT" + ); +}); + +add_task(async function testProduct() { + // Products must have a name + await Assert.rejects( + PageDataSchema.validateData(PageDataSchema.DATA_TYPE.PRODUCT, {}), + /missing required property 'name'/ + ); + + await PageDataSchema.validateData(PageDataSchema.DATA_TYPE.PRODUCT, { + name: "Bolts", + }); + + await PageDataSchema.validateData(PageDataSchema.DATA_TYPE.PRODUCT, { + name: "Bolts", + price: { + value: 5, + }, + }); + + await PageDataSchema.validateData(PageDataSchema.DATA_TYPE.PRODUCT, { + name: "Bolts", + price: { + value: 5, + currency: "USD", + }, + }); + + await Assert.rejects( + PageDataSchema.validateData(PageDataSchema.DATA_TYPE.PRODUCT, { + name: "Bolts", + price: { + currency: "USD", + }, + }), + /missing required property 'value'/ + ); + + await PageDataSchema.validateData(PageDataSchema.DATA_TYPE.PRODUCT, { + name: "Bolts", + shippingCost: { + value: 5, + currency: "USD", + }, + }); + + await Assert.rejects( + PageDataSchema.validateData(PageDataSchema.DATA_TYPE.PRODUCT, { + name: "Bolts", + shippingCost: { + currency: "USD", + }, + }), + /missing required property 'value'/ + ); +}); + +add_task(async function testCoalesce() { + let joined = PageDataSchema.coalescePageData({}, {}); + Assert.deepEqual(joined, { data: {} }); + + joined = PageDataSchema.coalescePageData( + { + url: "https://www.google.com/", + data: { + [PageDataSchema.DATA_TYPE.PRODUCT]: { + name: "bolts", + }, + [PageDataSchema.DATA_TYPE.VIDEO]: { + name: "My video", + duration: 500, + }, + }, + }, + { + url: "https://www.mozilla.com/", + date: 27, + siteName: "Mozilla", + data: { + [PageDataSchema.DATA_TYPE.PRODUCT]: { + name: "newname", + price: { + value: 55, + }, + }, + [PageDataSchema.DATA_TYPE.AUDIO]: { + name: "My song", + }, + }, + } + ); + + Assert.deepEqual(joined, { + url: "https://www.google.com/", + date: 27, + siteName: "Mozilla", + data: { + [PageDataSchema.DATA_TYPE.PRODUCT]: { + name: "bolts", + price: { + value: 55, + }, + }, + [PageDataSchema.DATA_TYPE.VIDEO]: { + name: "My video", + duration: 500, + }, + [PageDataSchema.DATA_TYPE.AUDIO]: { + name: "My song", + }, + }, + }); +}); + +add_task(async function testPageData() { + // Full page data needs a url and a date + await Assert.rejects( + PageDataSchema.validatePageData({}), + /missing required property 'url'/ + ); + + await Assert.rejects( + PageDataSchema.validatePageData({ url: "https://www.google.com" }), + /missing required property 'date'/ + ); + + await Assert.rejects( + PageDataSchema.validatePageData({ date: 55 }), + /missing required property 'url'/ + ); + + Assert.deepEqual( + await PageDataSchema.validatePageData({ + url: "https://www.google.com", + date: 55, + }), + { url: "https://www.google.com", date: 55, data: {} } + ); + + Assert.deepEqual( + await PageDataSchema.validatePageData({ + url: "https://www.google.com", + date: 55, + data: { + 0: { + name: "unknown", + }, + [PageDataSchema.DATA_TYPE.PRODUCT]: { + name: "Bolts", + price: { + value: 55, + }, + }, + }, + }), + { + url: "https://www.google.com", + date: 55, + data: { + [PageDataSchema.DATA_TYPE.PRODUCT]: { + name: "Bolts", + price: { + value: 55, + }, + }, + }, + } + ); + + // Should drop invalid inner data. + Assert.deepEqual( + await PageDataSchema.validatePageData({ + url: "https://www.google.com", + date: 55, + data: { + [PageDataSchema.DATA_TYPE.PRODUCT]: { + name: "Bolts", + price: { + currency: "USD", + }, + }, + }, + }), + { + url: "https://www.google.com", + date: 55, + data: {}, + } + ); +}); diff --git a/browser/components/pagedata/tests/unit/test_queue.js b/browser/components/pagedata/tests/unit/test_queue.js new file mode 100644 index 0000000000..d683c9a601 --- /dev/null +++ b/browser/components/pagedata/tests/unit/test_queue.js @@ -0,0 +1,512 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +ChromeUtils.defineESModuleGetters(this, { + PageDataService: "resource:///modules/pagedata/PageDataService.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +// Test that urls are retrieved in the expected order. +add_task(async function test_queueOrder() { + Services.prefs.setIntPref("browser.pagedata.maxBackgroundFetches", 0); + // Pretend we are idle. + PageDataService.observe(null, "idle", null); + + let pageDataResults = [ + { + date: Date.now(), + url: "http://www.mozilla.org/1", + siteName: "Mozilla", + data: {}, + }, + { + date: Date.now() - 3600, + url: "http://www.google.com/2", + siteName: "Google", + data: {}, + }, + { + date: Date.now() + 3600, + url: "http://www.example.com/3", + image: "http://www.example.com/banner.jpg", + data: {}, + }, + { + date: Date.now() / 2, + url: "http://www.wikipedia.org/4", + data: {}, + }, + { + date: Date.now() / 3, + url: "http://www.microsoft.com/5", + data: { + [PageDataSchema.DATA_TYPE.PRODUCT]: { + name: "Windows 11", + }, + }, + }, + ]; + + let requests = []; + PageDataService.fetchPageData = url => { + requests.push(url); + + for (let pageData of pageDataResults) { + if (pageData.url == url) { + return Promise.resolve(pageData); + } + } + + return Promise.reject(new Error("Unknown url")); + }; + + let { promise: completePromise, resolve } = Promise.withResolvers(); + + let results = []; + let listener = (_, pageData) => { + results.push(pageData); + if (results.length == pageDataResults.length) { + resolve(); + } + }; + + PageDataService.on("page-data", listener); + + for (let pageData of pageDataResults) { + PageDataService.queueFetch(pageData.url); + } + + await completePromise; + PageDataService.off("page-data", listener); + + Assert.deepEqual( + requests, + pageDataResults.map(pd => pd.url) + ); + + // Because our fetch implementation is essentially synchronous the results + // will be in a known order. This isn't guaranteed by the API though. + Assert.deepEqual(results, pageDataResults); + + delete PageDataService.fetchPageData; +}); + +// Tests that limiting the number of fetches works. +add_task(async function test_queueLimit() { + Services.prefs.setIntPref("browser.pagedata.maxBackgroundFetches", 3); + // Pretend we are idle. + PageDataService.observe(null, "idle", null); + + let requests = []; + PageDataService.fetchPageData = url => { + let { promise, resolve, reject } = Promise.withResolvers(); + requests.push({ url, resolve, reject }); + + return promise; + }; + + let results = []; + let listener = (_, pageData) => { + results.push(pageData?.url); + }; + + PageDataService.on("page-data", listener); + + PageDataService.queueFetch("https://www.mozilla.org/1"); + PageDataService.queueFetch("https://www.mozilla.org/2"); + PageDataService.queueFetch("https://www.mozilla.org/3"); + PageDataService.queueFetch("https://www.mozilla.org/4"); + PageDataService.queueFetch("https://www.mozilla.org/5"); + PageDataService.queueFetch("https://www.mozilla.org/6"); + PageDataService.queueFetch("https://www.mozilla.org/7"); + PageDataService.queueFetch("https://www.mozilla.org/8"); + PageDataService.queueFetch("https://www.mozilla.org/9"); + PageDataService.queueFetch("https://www.mozilla.org/10"); + PageDataService.queueFetch("https://www.mozilla.org/11"); + + await TestUtils.waitForTick(); + + Assert.deepEqual( + requests.map(r => r.url), + [ + "https://www.mozilla.org/1", + "https://www.mozilla.org/2", + "https://www.mozilla.org/3", + ] + ); + + // Completing or rejecting a request should start new ones. + + requests[1].resolve({ + date: 2345, + url: "https://www.mozilla.org/2", + siteName: "Test 2", + data: {}, + }); + + await TestUtils.waitForTick(); + + Assert.deepEqual( + requests.map(r => r.url), + [ + "https://www.mozilla.org/1", + "https://www.mozilla.org/2", + "https://www.mozilla.org/3", + "https://www.mozilla.org/4", + ] + ); + + requests[3].reject(new Error("Fail")); + + await TestUtils.waitForTick(); + + Assert.deepEqual( + requests.map(r => r.url), + [ + "https://www.mozilla.org/1", + "https://www.mozilla.org/2", + "https://www.mozilla.org/3", + "https://www.mozilla.org/4", + "https://www.mozilla.org/5", + ] + ); + + // Increasing the limit should start more requests. + Services.prefs.setIntPref("browser.pagedata.maxBackgroundFetches", 5); + + await TestUtils.waitForTick(); + + Assert.deepEqual( + requests.map(r => r.url), + [ + "https://www.mozilla.org/1", + "https://www.mozilla.org/2", + "https://www.mozilla.org/3", + "https://www.mozilla.org/4", + "https://www.mozilla.org/5", + "https://www.mozilla.org/6", + "https://www.mozilla.org/7", + ] + ); + + // Dropping the limit shouldn't start anything new. + Services.prefs.setIntPref("browser.pagedata.maxBackgroundFetches", 3); + + await TestUtils.waitForTick(); + + Assert.deepEqual( + requests.map(r => r.url), + [ + "https://www.mozilla.org/1", + "https://www.mozilla.org/2", + "https://www.mozilla.org/3", + "https://www.mozilla.org/4", + "https://www.mozilla.org/5", + "https://www.mozilla.org/6", + "https://www.mozilla.org/7", + ] + ); + + // But resolving should also not start new requests. + requests[5].resolve({ + date: 345334, + url: "https://www.mozilla.org/6", + siteName: "Test 6", + data: {}, + }); + + requests[0].resolve({ + date: 343446434, + url: "https://www.mozilla.org/1", + siteName: "Test 1", + data: {}, + }); + + await TestUtils.waitForTick(); + + Assert.deepEqual( + requests.map(r => r.url), + [ + "https://www.mozilla.org/1", + "https://www.mozilla.org/2", + "https://www.mozilla.org/3", + "https://www.mozilla.org/4", + "https://www.mozilla.org/5", + "https://www.mozilla.org/6", + "https://www.mozilla.org/7", + ] + ); + + // Until a previous request completes. + requests[4].resolve(null); + + await TestUtils.waitForTick(); + + Assert.deepEqual( + requests.map(r => r.url), + [ + "https://www.mozilla.org/1", + "https://www.mozilla.org/2", + "https://www.mozilla.org/3", + "https://www.mozilla.org/4", + "https://www.mozilla.org/5", + "https://www.mozilla.org/6", + "https://www.mozilla.org/7", + "https://www.mozilla.org/8", + ] + ); + + // Inifinite queue should work. + Services.prefs.setIntPref("browser.pagedata.maxBackgroundFetches", 0); + + await TestUtils.waitForTick(); + + Assert.deepEqual( + requests.map(r => r.url), + [ + "https://www.mozilla.org/1", + "https://www.mozilla.org/2", + "https://www.mozilla.org/3", + "https://www.mozilla.org/4", + "https://www.mozilla.org/5", + "https://www.mozilla.org/6", + "https://www.mozilla.org/7", + "https://www.mozilla.org/8", + "https://www.mozilla.org/9", + "https://www.mozilla.org/10", + "https://www.mozilla.org/11", + ] + ); + + requests[10].resolve({ + date: 345334, + url: "https://www.mozilla.org/11", + data: {}, + }); + requests[2].resolve({ + date: 345334, + url: "https://www.mozilla.org/3", + data: {}, + }); + requests[7].resolve({ + date: 345334, + url: "https://www.mozilla.org/8", + data: {}, + }); + requests[6].resolve({ + date: 345334, + url: "https://www.mozilla.org/7", + data: {}, + }); + requests[8].resolve({ + date: 345334, + url: "https://www.mozilla.org/9", + data: {}, + }); + requests[9].resolve({ + date: 345334, + url: "https://www.mozilla.org/10", + data: {}, + }); + + await TestUtils.waitForTick(); + + Assert.deepEqual( + requests.map(r => r.url), + [ + "https://www.mozilla.org/1", + "https://www.mozilla.org/2", + "https://www.mozilla.org/3", + "https://www.mozilla.org/4", + "https://www.mozilla.org/5", + "https://www.mozilla.org/6", + "https://www.mozilla.org/7", + "https://www.mozilla.org/8", + "https://www.mozilla.org/9", + "https://www.mozilla.org/10", + "https://www.mozilla.org/11", + ] + ); + + PageDataService.off("page-data", listener); + + delete PageDataService.fetchPageData; + + Assert.deepEqual(results, [ + "https://www.mozilla.org/2", + "https://www.mozilla.org/6", + "https://www.mozilla.org/1", + "https://www.mozilla.org/11", + "https://www.mozilla.org/3", + "https://www.mozilla.org/8", + "https://www.mozilla.org/7", + "https://www.mozilla.org/9", + "https://www.mozilla.org/10", + ]); +}); + +// Tests that the user idle state stops and starts fetches. +add_task(async function test_idle() { + Services.prefs.setIntPref("browser.pagedata.maxBackgroundFetches", 3); + // Pretend we are active. + PageDataService.observe(null, "active", null); + + let requests = []; + PageDataService.fetchPageData = url => { + let { promise, resolve, reject } = Promise.withResolvers(); + requests.push({ url, resolve, reject }); + + return promise; + }; + + let results = []; + let listener = (_, pageData) => { + results.push(pageData?.url); + }; + + PageDataService.on("page-data", listener); + + PageDataService.queueFetch("https://www.mozilla.org/1"); + PageDataService.queueFetch("https://www.mozilla.org/2"); + PageDataService.queueFetch("https://www.mozilla.org/3"); + PageDataService.queueFetch("https://www.mozilla.org/4"); + PageDataService.queueFetch("https://www.mozilla.org/5"); + PageDataService.queueFetch("https://www.mozilla.org/6"); + PageDataService.queueFetch("https://www.mozilla.org/7"); + + await TestUtils.waitForTick(); + + // Nothing will start when active. + Assert.deepEqual( + requests.map(r => r.url), + [] + ); + + // Pretend we are idle. + PageDataService.observe(null, "idle", null); + + await TestUtils.waitForTick(); + + Assert.deepEqual( + requests.map(r => r.url), + [ + "https://www.mozilla.org/1", + "https://www.mozilla.org/2", + "https://www.mozilla.org/3", + ] + ); + + // Completing or rejecting a request should start new ones. + + requests[1].resolve({ + date: 2345, + url: "https://www.mozilla.org/2", + data: {}, + }); + + await TestUtils.waitForTick(); + + Assert.deepEqual( + requests.map(r => r.url), + [ + "https://www.mozilla.org/1", + "https://www.mozilla.org/2", + "https://www.mozilla.org/3", + "https://www.mozilla.org/4", + ] + ); + + // But not when active + PageDataService.observe(null, "active", null); + + requests[3].resolve({ + date: 2345, + url: "https://www.mozilla.org/4", + data: {}, + }); + requests[0].resolve({ + date: 2345, + url: "https://www.mozilla.org/1", + data: {}, + }); + requests[2].resolve({ + date: 2345, + url: "https://www.mozilla.org/3", + data: {}, + }); + + await TestUtils.waitForTick(); + + Assert.deepEqual( + requests.map(r => r.url), + [ + "https://www.mozilla.org/1", + "https://www.mozilla.org/2", + "https://www.mozilla.org/3", + "https://www.mozilla.org/4", + ] + ); + + // Going idle should start more workers + PageDataService.observe(null, "idle", null); + + await TestUtils.waitForTick(); + + Assert.deepEqual( + requests.map(r => r.url), + [ + "https://www.mozilla.org/1", + "https://www.mozilla.org/2", + "https://www.mozilla.org/3", + "https://www.mozilla.org/4", + "https://www.mozilla.org/5", + "https://www.mozilla.org/6", + "https://www.mozilla.org/7", + ] + ); + + requests[4].resolve({ + date: 2345, + url: "https://www.mozilla.org/5", + data: {}, + }); + requests[5].resolve({ + date: 2345, + url: "https://www.mozilla.org/6", + data: {}, + }); + requests[6].resolve({ + date: 2345, + url: "https://www.mozilla.org/7", + data: {}, + }); + + await TestUtils.waitForTick(); + + Assert.deepEqual( + requests.map(r => r.url), + [ + "https://www.mozilla.org/1", + "https://www.mozilla.org/2", + "https://www.mozilla.org/3", + "https://www.mozilla.org/4", + "https://www.mozilla.org/5", + "https://www.mozilla.org/6", + "https://www.mozilla.org/7", + ] + ); + + PageDataService.off("page-data", listener); + + delete PageDataService.fetchPageData; + + Assert.deepEqual(results, [ + "https://www.mozilla.org/2", + "https://www.mozilla.org/4", + "https://www.mozilla.org/1", + "https://www.mozilla.org/3", + "https://www.mozilla.org/5", + "https://www.mozilla.org/6", + "https://www.mozilla.org/7", + ]); +}); diff --git a/browser/components/pagedata/tests/unit/test_schemaorg.js b/browser/components/pagedata/tests/unit/test_schemaorg.js new file mode 100644 index 0000000000..5470410e4f --- /dev/null +++ b/browser/components/pagedata/tests/unit/test_schemaorg.js @@ -0,0 +1,213 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests that the page data service can parse schema.org metadata into PageData. + */ + +add_task(async function test_single_product_microdata() { + await verifyPageData( + ` + + + + Product Info 1 + + +
    +
    + Mr. Nested Name +
    + + Mozilla +
    + +
    + + + +
    + £3.50 + +
    + + + + The most amazing microwave in the world +
    + + + `, + { + siteName: "Mozilla", + description: "The most amazing microwave in the world", + image: BASE_URL + "/bon-echo-microwave-17in.jpg", + data: { + [PageDataSchema.DATA_TYPE.PRODUCT]: { + name: "Bon Echo Microwave", + price: { + value: 3.5, + currency: "GBP", + }, + }, + }, + } + ); +}); + +add_task(async function test_single_product_json_ld() { + await verifyPageData( + ` + + + + + + + + + + `, + { + siteName: "Mozilla", + description: "The most amazing microwave in the world", + image: BASE_URL + "/bon-echo-microwave-17in.jpg", + data: { + [PageDataSchema.DATA_TYPE.PRODUCT]: { + name: "Bon Echo Microwave", + price: { + value: 3.5, + currency: "GBP", + }, + }, + }, + } + ); +}); + +add_task(async function test_single_product_combined() { + await verifyPageData( + ` + + + + + + +
    +
    + Mr. Nested Name +
    + + Mozilla +
    + + + `, + { + siteName: "Mozilla", + description: "The most amazing microwave in the world", + image: BASE_URL + "/bon-echo-microwave-17in.jpg", + data: { + [PageDataSchema.DATA_TYPE.PRODUCT]: { + name: "Bon Echo Microwave", + price: { + value: 3.5, + currency: "GBP", + }, + }, + }, + } + ); +}); + +add_task(async function test_single_multiple_microdata() { + await verifyPageData( + ` + + + + Product Info 2 + + +
    + + + +
    + £3.28 + +
    + + +
    + + + + `, + { + image: BASE_URL + "/bon-echo-microwave-17in.jpg", + data: { + [PageDataSchema.DATA_TYPE.PRODUCT]: { + name: "Bon Echo Microwave", + price: { + value: 3.28, + currency: "GBP", + }, + }, + }, + } + ); +}); diff --git a/browser/components/pagedata/tests/unit/test_schemaorg_parse.js b/browser/components/pagedata/tests/unit/test_schemaorg_parse.js new file mode 100644 index 0000000000..e002598af2 --- /dev/null +++ b/browser/components/pagedata/tests/unit/test_schemaorg_parse.js @@ -0,0 +1,193 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests that the page data service can parse schema.org metadata into Item + * structures. + */ + +const { SchemaOrgPageData } = ChromeUtils.importESModule( + "resource:///modules/pagedata/SchemaOrgPageData.sys.mjs" +); + +/** + * Collects the schema.org items from the given html string. + * + * @param {string} docStr + * The html to parse. + * @returns {Promise} + */ +async function collectItems(docStr) { + let doc = await parseDocument(docStr); + return SchemaOrgPageData.collectItems(doc); +} + +/** + * Verifies that the items parsed from the html match the expected JSON-LD + * format. + * + * @param {string} docStr + * The html to parse. + * @param {object[]} expected + * The JSON-LD objects to match to. + */ +async function verifyItems(docStr, expected) { + let items = await collectItems(docStr); + let jsonLD = items.map(item => item.toJsonLD()); + Assert.deepEqual(jsonLD, expected); +} + +add_task(async function test_microdata_parse() { + await verifyItems( + ` + + + + Product Info 1 + + +
    +
    + Mr. Nested Name +
    + + Mozilla +
    + +
    + + + +
    + £3.50 + +
    + + + + The most amazing microwave in the world +
    + + + `, + [ + { + "@type": "Organization", + employee: { + "@type": "Person", + name: "Mr. Nested Name", + }, + name: "Mozilla", + }, + { + "@type": "Product", + image: BASE_URL + "/bon-echo-microwave-17in.jpg", + url: BASE_URL + "/microwave.html", + name: "Bon Echo Microwave", + offers: { + "@type": "Offer", + price: "3.50", + priceCurrency: "GBP", + }, + gtin: "13572468", + description: "The most amazing microwave in the world", + }, + ] + ); +}); + +add_task(async function test_json_ld_parse() { + await verifyItems( + ` + + + + + + + + + + `, + [ + { + "@type": "Organization", + employee: { + "@type": "Person", + name: "Mr. Nested Name", + }, + name: "Mozilla", + }, + { + "@type": "Product", + image: "bon-echo-microwave-17in.jpg", + url: "microwave.html", + name: "Bon Echo Microwave", + offers: { + "@type": "Offer", + price: "3.50", + priceCurrency: "GBP", + }, + gtin: "13572468", + description: "The most amazing microwave in the world", + }, + ] + ); +}); + +add_task(async function test_microdata_lazy_image() { + await verifyItems( + ` + + + + Product Info 1 + + + + + + `, + [ + { + "@type": "Product", + image: BASE_URL + "/bon-echo-microwave-17in.jpg", + url: BASE_URL + "/microwave.html", + name: "Bon Echo Microwave", + }, + ] + ); +}); diff --git a/browser/components/pagedata/tests/unit/test_twitter.js b/browser/components/pagedata/tests/unit/test_twitter.js new file mode 100644 index 0000000000..a49491f5c6 --- /dev/null +++ b/browser/components/pagedata/tests/unit/test_twitter.js @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Basic tests for twitter cards. + */ + +add_task(async function test_twitter_card() { + await verifyPageData( + ` + + + + + + + + + + + + + + `, + { + siteName: "@nytimes", + description: "NEWARK - The guest list and parade of limousines", + image: + "http://graphics8.nytimes.com/images/2012/02/19/us/19whitney-span/19whitney-span-articleLarge.jpg", + data: {}, + } + ); +}); diff --git a/browser/components/pagedata/tests/unit/xpcshell.toml b/browser/components/pagedata/tests/unit/xpcshell.toml new file mode 100644 index 0000000000..a04ab47455 --- /dev/null +++ b/browser/components/pagedata/tests/unit/xpcshell.toml @@ -0,0 +1,19 @@ +[DEFAULT] +firefox-appdir = "browser" +skip-if = ["os == 'android'"] # bug 1730213 +support-files = ["head.js"] +head = "head.js" + +["test_opengraph.js"] + +["test_pagedata_basic.js"] + +["test_pagedata_schema.js"] + +["test_queue.js"] + +["test_schemaorg.js"] + +["test_schemaorg_parse.js"] + +["test_twitter.js"] diff --git a/browser/components/places/.eslintrc.js b/browser/components/places/.eslintrc.js new file mode 100644 index 0000000000..9aafb4a214 --- /dev/null +++ b/browser/components/places/.eslintrc.js @@ -0,0 +1,9 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.exports = { + extends: ["plugin:mozilla/require-jsdoc"], +}; diff --git a/browser/components/places/Interactions.sys.mjs b/browser/components/places/Interactions.sys.mjs new file mode 100644 index 0000000000..b8a6b44805 --- /dev/null +++ b/browser/components/places/Interactions.sys.mjs @@ -0,0 +1,762 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", + InteractionsBlocklist: "resource:///modules/InteractionsBlocklist.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logConsole", function () { + return console.createInstance({ + prefix: "InteractionsManager", + maxLogLevel: Services.prefs.getBoolPref( + "browser.places.interactions.log", + false + ) + ? "Debug" + : "Warn", + }); +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + idleService: ["@mozilla.org/widget/useridleservice;1", "nsIUserIdleService"], +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "pageViewIdleTime", + "browser.places.interactions.pageViewIdleTime", + 60 +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "saveInterval", + "browser.places.interactions.saveInterval", + 10000 +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "isHistoryEnabled", + "places.history.enabled", + false +); + +const DOMWINDOW_OPENED_TOPIC = "domwindowopened"; + +/** + * Returns a monotonically increasing timestamp, that is critical to distinguish + * database entries by creation time. + */ +let gLastTime = 0; +function monotonicNow() { + let time = Date.now(); + if (time == gLastTime) { + time++; + } + return (gLastTime = time); +} + +/** + * @typedef {object} DocumentInfo + * DocumentInfo is used to pass document information from the child process + * to _Interactions. + * @property {boolean} isActive + * Set to true if the document is active, i.e. visible. + * @property {string} url + * The url of the page that was interacted with. + */ + +/** + * @typedef {object} InteractionInfo + * InteractionInfo is used to store information associated with interactions. + * @property {number} totalViewTime + * Time in milliseconds that the page has been actively viewed for. + * @property {string} url + * The url of the page that was interacted with. + * @property {Interactions.DOCUMENT_TYPE} documentType + * The type of the document. + * @property {number} typingTime + * Time in milliseconds that the user typed on the page + * @property {number} keypresses + * The number of keypresses made on the page + * @property {number} scrollingTime + * Time in milliseconds that the user spent scrolling the page + * @property {number} scrollingDistance + * The distance, in pixels, that the user scrolled the page + * @property {number} created_at + * Creation time as the number of milliseconds since the epoch. + * @property {number} updated_at + * Last updated time as the number of milliseconds since the epoch. + * @property {string} referrer + * The referrer to the url of the page that was interacted with (may be empty) + * + */ + +/** + * The Interactions object sets up listeners and other approriate tools for + * obtaining interaction information and passing it to the InteractionsManager. + */ +class _Interactions { + DOCUMENT_TYPE = { + // Used when the document type is unknown. + GENERIC: 0, + // Used for pages serving media, e.g. videos. + MEDIA: 1, + }; + + /** + * This is used to store potential interactions. It maps the browser + * to the current interaction information. + * The current interaction is updated to the database when it transitions + * to non-active, which occurs before a browser tab is closed, hence this + * can be a weak map. + * + * @type {WeakMap} + */ + #interactions = new WeakMap(); + + /** + * Tracks the currently active window so that we can avoid recording + * interactions in non-active windows. + * + * @type {DOMWindow} + */ + #activeWindow = undefined; + + /** + * Tracks if the user is idle. + * + * @type {boolean} + */ + #userIsIdle = false; + + /** + * This stores the page view start time of the current page view. + * For any single page view, this may be moved multiple times as the + * associated interaction is updated for the current total page view time. + * + * @type {number} + */ + _pageViewStartTime = Cu.now(); + + /** + * Stores interactions in the database, see the {@link InteractionsStore} + * class. This is created lazily, see the `store` getter. + * + * @type {InteractionsStore | undefined} + */ + #store = undefined; + + /** + * Whether the component has been initialized. + */ + #initialized = false; + + /** + * Initializes, sets up actors and observers. + */ + init() { + if ( + !Services.prefs.getBoolPref("browser.places.interactions.enabled", false) + ) { + return; + } + + ChromeUtils.registerWindowActor("Interactions", { + parent: { + esModuleURI: "resource:///actors/InteractionsParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/InteractionsChild.sys.mjs", + events: { + DOMContentLoaded: {}, + pagehide: { mozSystemGroup: true }, + }, + }, + messageManagerGroups: ["browsers"], + }); + + this.#activeWindow = Services.wm.getMostRecentBrowserWindow(); + + for (let win of lazy.BrowserWindowTracker.orderedWindows) { + if (!win.closed) { + this.#registerWindow(win); + } + } + Services.obs.addObserver(this, DOMWINDOW_OPENED_TOPIC, true); + lazy.idleService.addIdleObserver(this, lazy.pageViewIdleTime); + this.#initialized = true; + } + + /** + * Uninitializes, removes any observers that need cleaning up manually. + */ + uninit() { + if (this.#initialized) { + lazy.idleService.removeIdleObserver(this, lazy.pageViewIdleTime); + } + } + + /** + * Resets any stored user or interaction state. + * Used by tests. + */ + async reset() { + lazy.logConsole.debug("Database reset"); + this.#interactions = new WeakMap(); + this.#userIsIdle = false; + this._pageViewStartTime = Cu.now(); + ChromeUtils.consumeInteractionData(); + await _Interactions.interactionUpdatePromise; + await this.store.reset(); + } + + /** + * Retrieve the underlying InteractionsStore object. This exists for testing + * purposes and should not be abused by production code (for example it'd be + * a bad idea to force flushes). + * + * @returns {InteractionsStore} + */ + get store() { + if (!this.#store) { + this.#store = new InteractionsStore(); + } + return this.#store; + } + + /** + * Registers the start of a new interaction. + * + * @param {Browser} browser + * The browser object associated with the interaction. + * @param {DocumentInfo} docInfo + * The document information of the page associated with the interaction. + */ + registerNewInteraction(browser, docInfo) { + if ( + !browser || + !lazy.isHistoryEnabled || + !browser.browsingContext.useGlobalHistory + ) { + return; + } + let interaction = this.#interactions.get(browser); + if (interaction && interaction.url != docInfo.url) { + this.registerEndOfInteraction(browser); + } + + if (lazy.InteractionsBlocklist.isUrlBlocklisted(docInfo.url)) { + lazy.logConsole.debug( + "Ignoring a page as the URL is blocklisted", + docInfo + ); + return; + } + + lazy.logConsole.debug("Tracking a new interaction", docInfo); + let now = monotonicNow(); + interaction = { + url: docInfo.url, + referrer: docInfo.referrer, + totalViewTime: 0, + typingTime: 0, + keypresses: 0, + scrollingTime: 0, + scrollingDistance: 0, + created_at: now, + updated_at: now, + }; + this.#interactions.set(browser, interaction); + + // Only reset the time if this is being loaded in the active tab of the + // active window. + if (docInfo.isActive && browser.ownerGlobal == this.#activeWindow) { + this._pageViewStartTime = Cu.now(); + } + } + + /** + * Registers the end of an interaction, e.g. if the user navigates away + * from the page. This will store the final interaction details and clear + * the current interaction. + * + * @param {Browser} browser + * The browser object associated with the interaction. + */ + registerEndOfInteraction(browser) { + // Not having a browser passed to us probably means the tab has gone away + // before we received the notification - due to the tab being a background + // tab. Since that will be a non-active tab, it is acceptable that we don't + // update the interaction. When switching away from active tabs, a TabSelect + // notification is generated which we handle elsewhere. + if ( + !browser || + !lazy.isHistoryEnabled || + !browser.browsingContext.useGlobalHistory + ) { + return; + } + lazy.logConsole.debug("Saw the end of an interaction"); + + this.#updateInteraction(browser); + this.#interactions.delete(browser); + } + + /** + * Updates the current interaction + * + * @param {Browser} [browser] + * The browser object that has triggered the update, if known. This is + * used to check if the browser is in the active window, and as an + * optimization to avoid obtaining the browser object. + */ + #updateInteraction(browser = undefined) { + _Interactions.#updateInteraction_async( + browser, + this.#activeWindow, + this.#userIsIdle, + this.#interactions, + this._pageViewStartTime, + this.store + ); + } + + /** + * Stores the promise created in updateInteraction_async so that we can await its fulfillment + * when sychronization is needed. + */ + static interactionUpdatePromise = Promise.resolve(); + + /** + * Returns the interactions update promise to be used when sychronization is needed from tests. + * + * @returns {Promise} + */ + get interactionUpdatePromise() { + return _Interactions.interactionUpdatePromise; + } + + /** + * Updates the current interaction on fulfillment of the asynchronous collection of scrolling interactions. + * + * @param {Browser} browser + * The browser object that has triggered the update, if known. + * @param {DOMWindow} activeWindow + * The active window. + * @param {boolean} userIsIdle + * Whether the user is idle. + * @param {WeakMap} interactions + * A map of interactions for each browser instance + * @param {number} pageViewStartTime + * The time the page was loaded. + * @param {InteractionsStore} store + * The interactions store. + */ + static async #updateInteraction_async( + browser, + activeWindow, + userIsIdle, + interactions, + pageViewStartTime, + store + ) { + if (!activeWindow || (browser && browser.ownerGlobal != activeWindow)) { + lazy.logConsole.debug( + "Not updating interaction as there is no active window" + ); + return; + } + + // We do not update the interaction when the user is idle, since we will + // have already updated it when idle was signalled. + // Sometimes an interaction may be signalled before idle is cleared, however + // worst case we'd only loose approx 2 seconds of interaction detail. + if (userIsIdle) { + lazy.logConsole.debug("Not updating interaction as the user is idle"); + return; + } + + if (!browser) { + browser = activeWindow.gBrowser.selectedTab.linkedBrowser; + } + + let interaction = interactions.get(browser); + if (!interaction) { + lazy.logConsole.debug("No interaction to update"); + return; + } + + interaction.totalViewTime += Cu.now() - pageViewStartTime; + Interactions._pageViewStartTime = Cu.now(); + + const interactionData = ChromeUtils.consumeInteractionData(); + const typing = interactionData.Typing; + if (typing) { + interaction.typingTime += typing.interactionTimeInMilliseconds; + interaction.keypresses += typing.interactionCount; + } + + // Collect the scrolling data and add the interaction to the store on completion + _Interactions.interactionUpdatePromise = + _Interactions.interactionUpdatePromise + .then(async () => ChromeUtils.collectScrollingData()) + .then( + result => { + interaction.scrollingTime += result.interactionTimeInMilliseconds; + interaction.scrollingDistance += result.scrollingDistanceInPixels; + }, + reason => { + console.error(reason); + } + ) + .then(() => { + interaction.updated_at = monotonicNow(); + + lazy.logConsole.debug("Add to store: ", interaction); + store.add(interaction); + }); + } + + /** + * Handles a window becoming active. + * + * @param {DOMWindow} win + * The window that has become active. + */ + #onActivateWindow(win) { + lazy.logConsole.debug("Window activated"); + + if (lazy.PrivateBrowsingUtils.isWindowPrivate(win)) { + return; + } + + this.#activeWindow = win; + this._pageViewStartTime = Cu.now(); + } + + /** + * Handles a window going inactive. + * + * @param {DOMWindow} win + * The window that is going inactive. + */ + #onDeactivateWindow(win) { + lazy.logConsole.debug("Window deactivate"); + + this.#updateInteraction(); + this.#activeWindow = undefined; + } + + /** + * Handles the TabSelect notification. Updates the current interaction and + * then switches it to the interaction for the new tab. The new interaction + * may be null if it doesn't exist. + * + * @param {Browser} previousBrowser + * The instance of the browser that the user switched away from. + */ + #onTabSelect(previousBrowser) { + lazy.logConsole.debug("Tab switched"); + + this.#updateInteraction(previousBrowser); + this._pageViewStartTime = Cu.now(); + } + + /** + * Handles various events and forwards them to appropriate functions. + * + * @param {DOMEvent} event + * The event that will be handled + */ + handleEvent(event) { + switch (event.type) { + case "TabSelect": + this.#onTabSelect(event.detail.previousTab.linkedBrowser); + break; + case "activate": + this.#onActivateWindow(event.target); + break; + case "deactivate": + this.#onDeactivateWindow(event.target); + break; + case "unload": + this.#unregisterWindow(event.target); + break; + } + } + + /** + * Handles notifications from the observer service. + * + * @param {nsISupports} subject + * The subject of the notification. + * @param {string} topic + * The topic of the notification. + * @param {string} data + * The data attached to the notification. + */ + observe(subject, topic, data) { + switch (topic) { + case DOMWINDOW_OPENED_TOPIC: + this.#onWindowOpen(subject); + break; + case "idle": + lazy.logConsole.debug("User went idle"); + // We save the state of the current interaction when we are notified + // that the user is idle. + this.#updateInteraction(); + this.#userIsIdle = true; + break; + case "active": + lazy.logConsole.debug("User became active"); + this.#userIsIdle = false; + this._pageViewStartTime = Cu.now(); + break; + } + } + + /** + * Handles registration of listeners in a new window. + * + * @param {DOMWindow} win + * The window to register in. + */ + #registerWindow(win) { + if (lazy.PrivateBrowsingUtils.isWindowPrivate(win)) { + return; + } + + win.addEventListener("TabSelect", this, true); + win.addEventListener("deactivate", this, true); + win.addEventListener("activate", this, true); + } + + /** + * Handles removing of listeners from a window. + * + * @param {DOMWindow} win + * The window to remove listeners from. + */ + #unregisterWindow(win) { + win.removeEventListener("TabSelect", this, true); + win.removeEventListener("deactivate", this, true); + win.removeEventListener("activate", this, true); + } + + /** + * Handles a new window being opened, waits for load and checks that + * it is a browser window, then adds listeners. + * + * @param {DOMWindow} win + * The window being opened. + */ + #onWindowOpen(win) { + win.addEventListener( + "load", + () => { + if ( + win.document.documentElement.getAttribute("windowtype") != + "navigator:browser" + ) { + return; + } + this.#registerWindow(win); + }, + { once: true } + ); + } + + QueryInterface = ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]); +} + +export const Interactions = new _Interactions(); + +/** + * Store interactions data in the Places database. + * To improve performance the writes are buffered every `saveInterval` + * milliseconds. Even if this means we could be trying to write interaction for + * pages that in the meanwhile have been removed, that's not a problem because + * we won't be able to insert entries having a NULL place_id, they will just be + * ignored. + * Use .add(interaction) to request storing of an interaction. + * Use .pendingPromise to await for any pending writes to have happened. + */ +class InteractionsStore { + /** + * Timer to run database updates on. + */ + #timer = undefined; + /** + * Tracks interactions replicating the unique index in the underlying schema. + * Interactions are keyed by url and then created_at. + * + * @type {Map>} + */ + #interactions = new Map(); + /** + * Used to unblock the queue of promises when the timer is cleared. + */ + #timerResolve = undefined; + + constructor() { + // Block async shutdown to ensure the last write goes through. + this.progress = {}; + lazy.PlacesUtils.history.shutdownClient.jsclient.addBlocker( + "Interactions.jsm:: store", + async () => this.flush(), + { fetchState: () => this.progress } + ); + + // Can be used to wait for the last pending write to have happened. + this.pendingPromise = Promise.resolve(); + } + + /** + * Synchronizes the pending interactions with the storage device. + * + * @returns {Promise} resolved when the pending data is on disk. + */ + async flush() { + if (this.#timer) { + lazy.clearTimeout(this.#timer); + this.#timerResolve(); + await this.#updateDatabase(); + } + } + + /** + * Completely clears the store and any pending writes. + * This exists for testing purposes. + */ + async reset() { + await lazy.PlacesUtils.withConnectionWrapper( + "Interactions.jsm::reset", + async db => { + await db.executeCached(`DELETE FROM moz_places_metadata`); + } + ); + if (this.#timer) { + lazy.clearTimeout(this.#timer); + this.#timer = undefined; + this.#timerResolve(); + this.#interactions.clear(); + } + } + + /** + * Registers an interaction to be stored persistently. At the end of the call + * the interaction has not yet been added to the store, tests can await + * flushStore() for that. + * + * @param {InteractionInfo} interaction + * The document information to write. + */ + add(interaction) { + lazy.logConsole.debug("Preparing interaction for storage", interaction); + + let interactionsForUrl = this.#interactions.get(interaction.url); + if (!interactionsForUrl) { + interactionsForUrl = new Map(); + this.#interactions.set(interaction.url, interactionsForUrl); + } + interactionsForUrl.set(interaction.created_at, interaction); + + if (!this.#timer) { + let promise = new Promise(resolve => { + this.#timerResolve = resolve; + this.#timer = lazy.setTimeout(() => { + this.#updateDatabase().catch(console.error).then(resolve); + }, lazy.saveInterval); + }); + this.pendingPromise = this.pendingPromise.then(() => promise); + } + } + + async #updateDatabase() { + this.#timer = undefined; + + // Reset the buffer. + let interactions = this.#interactions; + if (!interactions.size) { + return; + } + // Don't clear() this, since that would also clear interactions. + this.#interactions = new Map(); + + let params = {}; + let SQLInsertFragments = []; + let i = 0; + for (let interactionsForUrl of interactions.values()) { + for (let interaction of interactionsForUrl.values()) { + params[`url${i}`] = interaction.url; + params[`referrer${i}`] = interaction.referrer; + params[`created_at${i}`] = interaction.created_at; + params[`updated_at${i}`] = interaction.updated_at; + params[`document_type${i}`] = + interaction.documentType ?? Interactions.DOCUMENT_TYPE.GENERIC; + params[`total_view_time${i}`] = + Math.round(interaction.totalViewTime) || 0; + params[`typing_time${i}`] = Math.round(interaction.typingTime) || 0; + params[`key_presses${i}`] = interaction.keypresses || 0; + params[`scrolling_time${i}`] = + Math.round(interaction.scrollingTime) || 0; + params[`scrolling_distance${i}`] = + Math.round(interaction.scrollingDistance) || 0; + SQLInsertFragments.push(`( + (SELECT id FROM moz_places_metadata + WHERE place_id = (SELECT id FROM moz_places WHERE url_hash = hash(:url${i}) AND url = :url${i}) + AND created_at = :created_at${i}), + (SELECT id FROM moz_places WHERE url_hash = hash(:url${i}) AND url = :url${i}), + (SELECT id FROM moz_places WHERE url_hash = hash(:referrer${i}) AND url = :referrer${i} AND :referrer${i} != :url${i}), + :created_at${i}, + :updated_at${i}, + :document_type${i}, + :total_view_time${i}, + :typing_time${i}, + :key_presses${i}, + :scrolling_time${i}, + :scrolling_distance${i} + )`); + i++; + } + } + + lazy.logConsole.debug(`Storing ${i} entries in the database`); + + this.progress.pendingUpdates = i; + await lazy.PlacesUtils.withConnectionWrapper( + "Interactions.jsm::updateDatabase", + async db => { + await db.executeCached( + ` + WITH inserts (id, place_id, referrer_place_id, created_at, updated_at, document_type, total_view_time, typing_time, key_presses, scrolling_time, scrolling_distance) AS ( + VALUES ${SQLInsertFragments.join(", ")} + ) + INSERT OR REPLACE INTO moz_places_metadata ( + id, place_id, referrer_place_id, created_at, updated_at, document_type, total_view_time, typing_time, key_presses, scrolling_time, scrolling_distance + ) SELECT * FROM inserts WHERE place_id NOT NULL; + `, + params + ); + } + ); + this.progress.pendingUpdates = 0; + + Services.obs.notifyObservers(null, "places-metadata-updated"); + } +} diff --git a/browser/components/places/InteractionsBlocklist.sys.mjs b/browser/components/places/InteractionsBlocklist.sys.mjs new file mode 100644 index 0000000000..335eb25bb6 --- /dev/null +++ b/browser/components/places/InteractionsBlocklist.sys.mjs @@ -0,0 +1,281 @@ +/* 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, { + FilterAdult: "resource://activity-stream/lib/FilterAdult.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logConsole", function () { + return console.createInstance({ + prefix: "InteractionsBlocklist", + maxLogLevel: Services.prefs.getBoolPref( + "browser.places.interactions.log", + false + ) + ? "Debug" + : "Warn", + }); +}); + +// A blocklist of regular expressions. Maps base hostnames to a list regular +// expressions for URLs with that base hostname. In this context, "base +// hostname" means the hostname without any subdomains or a public suffix. For +// example, the base hostname for "https://www.maps.google.com/a/place" is +// "google". We do this mapping to improve performance; otherwise we'd have to +// check all URLs against a long list of regular expressions. The regexes are +// defined as escaped strings so that we build them lazily. +// We may want to migrate this list to Remote Settings in the future. +let HOST_BLOCKLIST = { + auth0: [ + // Auth0 OAuth. + // XXX: Used alone this could produce false positives where an auth0 URL + // appears after another valid domain and TLD, but since we limit this to + // the auth0 hostname those occurrences will be filtered out. + "^https:\\/\\/.*\\.auth0\\.com\\/login", + ], + baidu: [ + // Baidu SERP + "^(https?:\\/\\/)?(www\\.)?baidu\\.com\\/s.*(\\?|&)wd=.*", + ], + bing: [ + // Bing SERP + "^(https?:\\/\\/)?(www\\.)?bing\\.com\\/search.*(\\?|&)q=.*", + ], + duckduckgo: [ + // DuckDuckGo SERP + "^(https?:\\/\\/)?(www\\.)?duckduckgo\\.com\\/.*(\\?|&)q=.*", + ], + google: [ + // Google SERP + "^(https?:\\/\\/)?(www\\.)?google\\.(\\w|\\.){2,}\\/search.*(\\?|&)q=.*", + // Google OAuth + "^https:\\/\\/accounts\\.google\\.com\\/o\\/oauth2\\/v2\\/auth", + "^https:\\/\\/accounts\\.google\\.com\\/signin\\/oauth\\/consent", + ], + microsoftonline: [ + // Microsoft OAuth + "^https:\\/\\/login\\.microsoftonline\\.com\\/common\\/oauth2\\/v2\\.0\\/authorize", + ], + yandex: [ + // Yandex SERP + "^(https?:\\/\\/)?(www\\.)?yandex\\.(\\w|\\.){2,}\\/search.*(\\?|&)text=.*", + ], + zoom: [ + // Zoom meeting interstitial + "^(https?:\\/\\/)?(www\\.)?.*\\.zoom\\.us\\/j\\/\\d+", + ], +}; + +HOST_BLOCKLIST = new Proxy(HOST_BLOCKLIST, { + get(target, property) { + let regexes = target[property]; + if (!regexes || !Array.isArray(regexes)) { + return null; + } + + for (let i = 0; i < regexes.length; i++) { + let regex = regexes[i]; + if (typeof regex === "string") { + regex = new RegExp(regex, "i"); + if (regex) { + regexes[i] = regex; + } else { + throw new Error("Blocklist contains invalid regex."); + } + } + } + return regexes; + }, +}); + +/** + * A class that maintains a blocklist of URLs. The class exposes a method to + * check if a particular URL is contained on the blocklist. + */ +class _InteractionsBlocklist { + constructor() { + // Load custom blocklist items from pref. + try { + let customBlocklist = JSON.parse( + Services.prefs.getStringPref( + "places.interactions.customBlocklist", + "[]" + ) + ); + if (!Array.isArray(customBlocklist)) { + throw new Error(); + } + let parsedBlocklist = customBlocklist.map( + regexStr => new RegExp(regexStr) + ); + HOST_BLOCKLIST["*"] = parsedBlocklist; + } catch (ex) { + lazy.logConsole.warn("places.interactions.customBlocklist is corrupted."); + } + } + + /** + * Only certain urls can be added as Interactions, either manually or + * automatically. + * + * @returns {Map} A Map keyed by protocol, for each protocol an object may + * define stricter requirements, like extension. + */ + get urlRequirements() { + return new Map([ + ["http:", {}], + ["https:", {}], + ["file:", { extension: "pdf" }], + ]); + } + + /** + * Whether to record interactions for a given URL. + * The rules are defined in InteractionsBlocklist.urlRequirements. + * + * @param {string|URL|nsIURI} url The URL to check. + * @returns {boolean} whether the url can be recorded. + */ + canRecordUrl(url) { + let protocol, pathname; + if (typeof url == "string") { + url = new URL(url); + } + if (url instanceof Ci.nsIURI) { + protocol = url.scheme + ":"; + pathname = url.filePath; + } else { + protocol = url.protocol; + pathname = url.pathname; + } + let requirements = InteractionsBlocklist.urlRequirements.get(protocol); + return ( + requirements && + (!requirements.extension || pathname.endsWith(requirements.extension)) + ); + } + + /** + * Checks a URL against a blocklist of URLs. If the URL is blocklisted, we + * should not record an interaction. + * + * @param {string} urlToCheck + * The URL we are looking for on the blocklist. + * @returns {boolean} + * True if `url` is on a blocklist. False otherwise. + */ + isUrlBlocklisted(urlToCheck) { + if (lazy.FilterAdult.isAdultUrl(urlToCheck)) { + return true; + } + + if (!this.canRecordUrl(urlToCheck)) { + return true; + } + + // First, find the URL's base host: the hostname without any subdomains or a + // public suffix. + let url; + try { + url = new URL(urlToCheck); + if (!url) { + throw new Error(); + } + } catch (ex) { + lazy.logConsole.warn( + `Invalid URL passed to InteractionsBlocklist.isUrlBlocklisted: ${url}` + ); + return false; + } + + if (url.protocol == "file:") { + return false; + } + + let hostWithoutSuffix = lazy.UrlbarUtils.stripPublicSuffixFromHost( + url.host + ); + let [hostWithSubdomains] = lazy.UrlbarUtils.stripPrefixAndTrim( + hostWithoutSuffix, + { + stripWww: true, + trimTrailingDot: true, + } + ); + let baseHost = hostWithSubdomains.substring( + hostWithSubdomains.lastIndexOf(".") + 1 + ); + // Then fetch blocked regexes for that baseHost and compare them to the full + // URL. Also check the URL against the custom blocklist. + let regexes = HOST_BLOCKLIST[baseHost.toLocaleLowerCase()] || []; + regexes.push(...(HOST_BLOCKLIST["*"] || [])); + if (!regexes) { + return false; + } + + return regexes.some(r => r.test(url.href)); + } + + /** + * Adds a regex to HOST_BLOCKLIST. Since we can't parse the base host from + * the regex, we add it to a list of wildcard regexes. All URLs are checked + * against these wildcard regexes. Currently only exposed for tests and use in + * the console. In the future we could hook this up to a UI component. + * + * @param {string|RegExp} regexToAdd + * The regular expression to add to our blocklist. + */ + addRegexToBlocklist(regexToAdd) { + let regex; + try { + regex = new RegExp(regexToAdd, "i"); + } catch (ex) { + this.logConsole.warn("Invalid regex passed to addRegexToBlocklist."); + return; + } + + if (!HOST_BLOCKLIST["*"]) { + HOST_BLOCKLIST["*"] = []; + } + HOST_BLOCKLIST["*"].push(regex); + Services.prefs.setStringPref( + "places.interactions.customBlocklist", + JSON.stringify(HOST_BLOCKLIST["*"].map(reg => reg.toString())) + ); + } + + /** + * Removes a regex from HOST_BLOCKLIST. If `regexToRemove` is not in the + * blocklist, this is a no-op. Currently only exposed for tests and use in the + * console. In the future we could hook this up to a UI component. + * + * @param {string|RegExp} regexToRemove + * The regular expression to add to our blocklist. + */ + removeRegexFromBlocklist(regexToRemove) { + let regex; + try { + regex = new RegExp(regexToRemove, "i"); + } catch (ex) { + this.logConsole.warn("Invalid regex passed to addRegexToBlocklist."); + return; + } + + if (!HOST_BLOCKLIST["*"] || !Array.isArray(HOST_BLOCKLIST["*"])) { + return; + } + HOST_BLOCKLIST["*"] = HOST_BLOCKLIST["*"].filter( + curr => curr.source != regex.source + ); + Services.prefs.setStringPref( + "places.interactions.customBlocklist", + JSON.stringify(HOST_BLOCKLIST["*"].map(reg => reg.toString())) + ); + } +} + +export const InteractionsBlocklist = new _InteractionsBlocklist(); diff --git a/browser/components/places/InteractionsChild.sys.mjs b/browser/components/places/InteractionsChild.sys.mjs new file mode 100644 index 0000000000..ff7dd3bd8c --- /dev/null +++ b/browser/components/places/InteractionsChild.sys.mjs @@ -0,0 +1,148 @@ +/* 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, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +/** + * Listens for interactions in the child process and passes information to the + * parent. + */ +export class InteractionsChild extends JSWindowActorChild { + #progressListener; + #currentURL; + + actorCreated() { + this.isContentWindowPrivate = + lazy.PrivateBrowsingUtils.isContentWindowPrivate(this.contentWindow); + + if (this.isContentWindowPrivate) { + return; + } + + this.#progressListener = { + onLocationChange: (webProgress, request, location, flags) => { + this.onLocationChange(webProgress, request, location, flags); + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener2", + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + }; + + let webProgress = this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + webProgress.addProgressListener( + this.#progressListener, + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT | + Ci.nsIWebProgress.NOTIFY_LOCATION + ); + } + + didDestroy() { + // If the tab is closed then the docshell is no longer available. + if (!this.#progressListener || !this.docShell) { + return; + } + + let webProgress = this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + webProgress.removeProgressListener(this.#progressListener); + } + + onLocationChange(webProgress, request, location, flags) { + // We don't care about inner-frame navigations. + if (!webProgress.isTopLevel) { + return; + } + + // If this is a new document then the DOMContentLoaded event will trigger + // the new interaction instead. + if (!(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)) { + return; + } + + this.#recordNewPage(); + } + + #recordNewPage() { + if (!this.docShell.currentDocumentChannel) { + // If there is no document channel, then it is something we're not + // interested in, but we do need to know that the previous interaction + // has ended. + this.sendAsyncMessage("Interactions:PageHide"); + return; + } + + let docInfo = this.#getDocumentInfo(); + + // This may happen when the page calls replaceState or pushState with the + // same URL. We'll just consider this to not be a new page. + if (docInfo.url == this.#currentURL) { + return; + } + + this.#currentURL = docInfo.url; + + if ( + this.docShell.currentDocumentChannel instanceof Ci.nsIHttpChannel && + !this.docShell.currentDocumentChannel.requestSucceeded + ) { + return; + } + + this.sendAsyncMessage("Interactions:PageLoaded", docInfo); + } + + async handleEvent(event) { + if (this.isContentWindowPrivate) { + // No recording in private browsing mode. + return; + } + switch (event.type) { + case "DOMContentLoaded": { + this.#recordNewPage(); + break; + } + case "pagehide": { + if (!this.docShell.currentDocumentChannel) { + return; + } + + if (!this.docShell.currentDocumentChannel.requestSucceeded) { + return; + } + + this.sendAsyncMessage("Interactions:PageHide"); + break; + } + } + } + + /** + * Returns the current document information for sending to the parent process. + * + * @returns {{ isActive: boolean, url: string, referrer: * }?} + */ + #getDocumentInfo() { + let doc = this.document; + + let referrer; + if (doc.referrer) { + referrer = Services.io.newURI(doc.referrer); + } + return { + isActive: this.manager.browsingContext.isActive, + url: doc.documentURIObject.specIgnoringRef, + referrer: referrer?.specIgnoringRef, + }; + } +} diff --git a/browser/components/places/InteractionsParent.sys.mjs b/browser/components/places/InteractionsParent.sys.mjs new file mode 100644 index 0000000000..a43774ef97 --- /dev/null +++ b/browser/components/places/InteractionsParent.sys.mjs @@ -0,0 +1,33 @@ +/* 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, { + Interactions: "resource:///modules/Interactions.sys.mjs", +}); + +/** + * Receives messages from InteractionsChild and passes them to the appropriate + * interactions object. + */ +export class InteractionsParent extends JSWindowActorParent { + receiveMessage(msg) { + switch (msg.name) { + case "Interactions:PageLoaded": + lazy.Interactions.registerNewInteraction( + this.browsingContext.embedderElement, + msg.data + ); + break; + case "Interactions:PageHide": + lazy.Interactions.registerEndOfInteraction( + // This could be null if the browsing context has already gone away, + // e.g. on tab close. + this.browsingContext?.embedderElement + ); + break; + } + } +} diff --git a/browser/components/places/PlacesUIUtils.sys.mjs b/browser/components/places/PlacesUIUtils.sys.mjs new file mode 100644 index 0000000000..415a97ec6c --- /dev/null +++ b/browser/components/places/PlacesUIUtils.sys.mjs @@ -0,0 +1,2226 @@ +/* -*- 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CLIENT_NOT_CONFIGURED: "resource://services-sync/constants.sys.mjs", + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", + MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", + OpenInTabsUtils: "resource:///modules/OpenInTabsUtils.sys.mjs", + PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + Weave: "resource://services-sync/main.sys.mjs", +}); + +const gInContentProcess = + Services.appinfo.processType == Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT; +const FAVICON_REQUEST_TIMEOUT = 60 * 1000; +// Map from windows to arrays of data about pending favicon loads. +let gFaviconLoadDataMap = new Map(); + +const ITEM_CHANGED_BATCH_NOTIFICATION_THRESHOLD = 10; + +// copied from utilityOverlay.js +const TAB_DROP_TYPE = "application/x-moz-tabbrowser-tab"; + +let InternalFaviconLoader = { + /** + * Actually cancel the request, and clear the timeout for cancelling it. + * + * @param {object} options + * The options object containing: + * @param {object} options.uri + * The URI of the favicon to cancel. + * @param {number} options.innerWindowID + * The inner window ID of the window. Unused. + * @param {number} options.timerID + * The timer ID of the timeout to be cancelled + * @param {*} options.callback + * The request callback + * @param {string} reason + * The reason for cancelling the request. + */ + _cancelRequest({ uri, innerWindowID, timerID, callback }, reason) { + // Break cycle + let request = callback.request; + delete callback.request; + // Ensure we don't time out. + clearTimeout(timerID); + try { + request.cancel(); + } catch (ex) { + console.error( + `When cancelling a request for ${uri.spec} because ${reason}, it was already canceled!` + ); + } + }, + + /** + * Called for every inner that gets destroyed, only in the parent process. + * + * @param {number} innerID + * The innerID of the window. + */ + removeRequestsForInner(innerID) { + for (let [window, loadDataForWindow] of gFaviconLoadDataMap) { + let newLoadDataForWindow = loadDataForWindow.filter(loadData => { + let innerWasDestroyed = loadData.innerWindowID == innerID; + if (innerWasDestroyed) { + this._cancelRequest( + loadData, + "the inner window was destroyed or a new favicon was loaded for it" + ); + } + // Keep the items whose inner is still alive. + return !innerWasDestroyed; + }); + // Map iteration with for...of is safe against modification, so + // now just replace the old value: + gFaviconLoadDataMap.set(window, newLoadDataForWindow); + } + }, + + /** + * Called when a toplevel chrome window unloads. We use this to tidy up after ourselves, + * avoid leaks, and cancel any remaining requests. The last part should in theory be + * handled by the inner-window-destroyed handlers. We clean up just to be on the safe side. + * + * @param {DOMWindow} win + * The window that was unloaded. + */ + onUnload(win) { + let loadDataForWindow = gFaviconLoadDataMap.get(win); + if (loadDataForWindow) { + for (let loadData of loadDataForWindow) { + this._cancelRequest(loadData, "the chrome window went away"); + } + } + gFaviconLoadDataMap.delete(win); + }, + + /** + * Remove a particular favicon load's loading data from our map tracking + * load data per chrome window. + * + * @param {DOMWindow} win + * the chrome window in which we should look for this load + * @param {object} filterData + * the data we should use to find this particular load to remove. + * @param {number} filterData.innerWindowID + * The inner window ID of the window. + * @param {string} filterData.uri + * The URI of the favicon to cancel. + * @param {*} filterData.callback + * The request callback + * + * @returns {object|null} + * the loadData object we removed, or null if we didn't find any. + */ + _removeLoadDataFromWindowMap(win, { innerWindowID, uri, callback }) { + let loadDataForWindow = gFaviconLoadDataMap.get(win); + if (loadDataForWindow) { + let itemIndex = loadDataForWindow.findIndex(loadData => { + return ( + loadData.innerWindowID == innerWindowID && + loadData.uri.equals(uri) && + loadData.callback.request == callback.request + ); + }); + if (itemIndex != -1) { + let loadData = loadDataForWindow[itemIndex]; + loadDataForWindow.splice(itemIndex, 1); + return loadData; + } + } + return null; + }, + + /** + * Create a function to use as a nsIFaviconDataCallback, so we can remove cancelling + * information when the request succeeds. Note that right now there are some edge-cases, + * such as about: URIs with chrome:// favicons where the success callback is not invoked. + * This is OK: we will 'cancel' the request after the timeout (or when the window goes + * away) but that will be a no-op in such cases. + * + * @param {DOMWindow} win + * The chrome window in which the request was made. + * @param {number} id + * The inner window ID of the window. + * @returns {object} + */ + _makeCompletionCallback(win, id) { + return { + onComplete(uri) { + let loadData = InternalFaviconLoader._removeLoadDataFromWindowMap(win, { + uri, + innerWindowID: id, + callback: this, + }); + if (loadData) { + clearTimeout(loadData.timerID); + } + delete this.request; + }, + }; + }, + + ensureInitialized() { + if (this._initialized) { + return; + } + this._initialized = true; + + Services.obs.addObserver(windowGlobal => { + this.removeRequestsForInner(windowGlobal.innerWindowId); + }, "window-global-destroyed"); + }, + + loadFavicon(browser, principal, pageURI, uri, expiration, iconURI) { + this.ensureInitialized(); + let { ownerGlobal: win, innerWindowID } = browser; + if (!gFaviconLoadDataMap.has(win)) { + gFaviconLoadDataMap.set(win, []); + let unloadHandler = event => { + let doc = event.target; + let eventWin = doc.defaultView; + if (eventWin == win) { + win.removeEventListener("unload", unloadHandler); + this.onUnload(win); + } + }; + 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( + uri, + iconURI.spec, + expiration, + principal + ); + } + + let request = lazy.PlacesUtils.favicons.setAndFetchFaviconForPage( + pageURI, + uri, + false, + loadType, + callback, + principal + ); + + // Now register the result so we can cancel it if/when necessary. + if (!request) { + // The favicon service can return with success but no-op (and leave request + // as null) if the icon is the same as the page (e.g. for images) or if it is + // the favicon for an error page. In this case, we do not need to do anything else. + return; + } + callback.request = request; + let loadData = { innerWindowID, uri, callback }; + loadData.timerID = setTimeout(() => { + this._cancelRequest(loadData, "it timed out"); + this._removeLoadDataFromWindowMap(win, loadData); + }, FAVICON_REQUEST_TIMEOUT); + let loadDataForWindow = gFaviconLoadDataMap.get(win); + loadDataForWindow.push(loadData); + }, +}; + +/** + * Collects all information for a bookmark and performs editmethods + */ +class BookmarkState { + /** + * Construct a new BookmarkState. + * + * @param {object} options + * The constructor options. + * @param {object} options.info + * Either a result node or a node-like object representing the item to be edited. + * @param {string} [options.tags] + * Tags (if any) for the bookmark in a comma separated string. Empty tags are + * skipped + * @param {string} [options.keyword] + * Existing (if there are any) keyword for bookmark + * @param {boolean} [options.isFolder] + * If the item is a folder. + * @param {Array<{ title: string; url: nsIURI; }>} [options.children] + * The list of child URIs to bookmark within the folder. + * @param {boolean} [options.autosave] + * If changes to bookmark fields should be saved immediately after calling + * its respective "changed" method, rather than waiting for save() to be + * called. + * @param {number} [options.index] + * The insertion point index of the bookmark. + */ + constructor({ + info, + tags = "", + keyword = "", + isFolder = false, + children = [], + autosave = false, + index, + }) { + this._guid = info.itemGuid; + this._postData = info.postData; + this._isTagContainer = info.isTag; + this._bulkTaggingUrls = info.uris?.map(uri => uri.spec); + this._isFolder = isFolder; + this._children = children; + this._autosave = autosave; + + // Original Bookmark + this._originalState = { + title: this._isTagContainer ? info.tag : info.title, + uri: info.uri?.spec, + tags: tags + .trim() + .split(/\s*,\s*/) + .filter(tag => !!tag.length), + keyword, + parentGuid: info.parentGuid, + index, + }; + + // Edited bookmark + this._newState = {}; + } + + /** + * Save edited title for the bookmark + * + * @param {string} title + * The title of the bookmark + */ + async _titleChanged(title) { + this._newState.title = title; + await this._maybeSave(); + } + + /** + * Save edited location for the bookmark + * + * @param {string} location + * The location of the bookmark + */ + async _locationChanged(location) { + this._newState.uri = location; + await this._maybeSave(); + } + + /** + * Save edited tags for the bookmark + * + * @param {string} tags + * Comma separated list of tags + */ + async _tagsChanged(tags) { + this._newState.tags = tags; + await this._maybeSave(); + } + + /** + * Save edited keyword for the bookmark + * + * @param {string} keyword + * The keyword of the bookmark + */ + async _keywordChanged(keyword) { + this._newState.keyword = keyword; + await this._maybeSave(); + } + + /** + * Save edited parentGuid for the bookmark + * + * @param {string} parentGuid + * The parentGuid of the bookmark + */ + async _parentGuidChanged(parentGuid) { + this._newState.parentGuid = parentGuid; + await this._maybeSave(); + } + + /** + * Save changes if autosave is enabled. + */ + async _maybeSave() { + if (this._autosave) { + await this.save(); + } + } + + /** + * Create a new bookmark. + * + * @returns {string} The bookmark's GUID. + */ + async _createBookmark() { + let transactions = [ + lazy.PlacesTransactions.NewBookmark({ + parentGuid: this.parentGuid, + tags: this._newState.tags, + title: this._newState.title ?? this._originalState.title, + url: this._newState.uri ?? this._originalState.uri, + index: this._originalState.index, + }), + ]; + if (this._newState.keyword) { + transactions.push(previousResults => + lazy.PlacesTransactions.EditKeyword({ + guid: previousResults[0], + keyword: this._newState.keyword, + postData: this._postData, + }) + ); + } + let results = await lazy.PlacesTransactions.batch( + transactions, + "BookmarkState::createBookmark" + ); + this._guid = results?.[0]; + return this._guid; + } + + /** + * Create a new folder. + * + * @returns {string} The folder's GUID. + */ + async _createFolder() { + this._guid = await lazy.PlacesTransactions.NewFolder({ + parentGuid: this.parentGuid, + title: this._newState.title ?? this._originalState.title, + children: this._children, + index: this._originalState.index, + }).transact(); + return this._guid; + } + + get parentGuid() { + return this._newState.parentGuid ?? this._originalState.parentGuid; + } + + /** + * Save() API function for bookmark. + * + * @returns {string} bookmark.guid + */ + async save() { + if (this._guid === lazy.PlacesUtils.bookmarks.unsavedGuid) { + return this._isFolder ? this._createFolder() : this._createBookmark(); + } + + if (!Object.keys(this._newState).length) { + return this._guid; + } + + if (this._isTagContainer && this._newState.title) { + await lazy.PlacesTransactions.RenameTag({ + oldTag: this._originalState.title, + tag: this._newState.title, + }) + .transact() + .catch(console.error); + return this._guid; + } + + let url = this._newState.uri || this._originalState.uri; + let transactions = []; + + if (this._newState.uri) { + transactions.push( + lazy.PlacesTransactions.EditUrl({ + guid: this._guid, + url, + }) + ); + } + + for (const [key, value] of Object.entries(this._newState)) { + switch (key) { + case "title": + transactions.push( + lazy.PlacesTransactions.EditTitle({ + guid: this._guid, + title: value, + }) + ); + break; + case "tags": + const newTags = value.filter( + tag => !this._originalState.tags.includes(tag) + ); + const removedTags = this._originalState.tags.filter( + tag => !value.includes(tag) + ); + if (newTags.length) { + transactions.push( + lazy.PlacesTransactions.Tag({ + urls: this._bulkTaggingUrls || [url], + tags: newTags, + }) + ); + } + if (removedTags.length) { + transactions.push( + lazy.PlacesTransactions.Untag({ + urls: this._bulkTaggingUrls || [url], + tags: removedTags, + }) + ); + } + break; + case "keyword": + transactions.push( + lazy.PlacesTransactions.EditKeyword({ + guid: this._guid, + keyword: value, + postData: this._postData, + oldKeyword: this._originalState.keyword, + }) + ); + break; + case "parentGuid": + transactions.push( + lazy.PlacesTransactions.Move({ + guid: this._guid, + newParentGuid: this._newState.parentGuid, + }) + ); + break; + } + } + if (transactions.length) { + await lazy.PlacesTransactions.batch(transactions, "BookmarkState::save"); + } + + this._originalState = { ...this._originalState, ...this._newState }; + this._newState = {}; + return this._guid; + } +} + +export var PlacesUIUtils = { + BookmarkState, + _bookmarkToolbarTelemetryListening: false, + LAST_USED_FOLDERS_META_KEY: "bookmarks/lastusedfolders", + + lastContextMenuTriggerNode: null, + + // This allows to await for all the relevant bookmark changes to be applied + // when a bookmark dialog is closed. It is resolved to the bookmark guid, + // if a bookmark was created or modified. + lastBookmarkDialogDeferred: null, + + /** + * Obfuscates a place: URL to use it in xulstore without the risk of + leaking browsing information. Uses md5 to hash the query string. + * + * @param {URL} url + * the URL for xulstore with place: key pairs. + * @returns {string} "place:[md5_hash]" hashed url + */ + + obfuscateUrlForXulStore(url) { + if (!url.startsWith("place:")) { + throw new Error("Method must be used to only obfuscate place: uris!"); + } + let urlNoProtocol = url.substring(url.indexOf(":") + 1); + let hashedURL = lazy.PlacesUtils.md5(urlNoProtocol); + + return `place:${hashedURL}`; + }, + + /** + * Shows the bookmark dialog corresponding to the specified info. + * + * @param {object} aInfo + * Describes the item to be edited/added in the dialog. + * See documentation at the top of bookmarkProperties.js + * @param {DOMWindow} [aParentWindow] + * Owner window for the new dialog. + * + * @see documentation at the top of bookmarkProperties.js + * @returns {string} The guid of the item that was created or edited, + * undefined otherwise. + */ + async showBookmarkDialog(aInfo, aParentWindow = null) { + this.lastBookmarkDialogDeferred = Promise.withResolvers(); + + let dialogURL = "chrome://browser/content/places/bookmarkProperties.xhtml"; + let features = "centerscreen,chrome,modal,resizable=no"; + let bookmarkGuid; + + if (!aParentWindow) { + aParentWindow = Services.wm.getMostRecentWindow(null); + } + + if (aParentWindow.gDialogBox) { + await aParentWindow.gDialogBox.open(dialogURL, aInfo); + } else { + aParentWindow.openDialog(dialogURL, "", features, aInfo); + } + + if (aInfo.bookmarkState) { + bookmarkGuid = await aInfo.bookmarkState.save(); + this.lastBookmarkDialogDeferred.resolve(bookmarkGuid); + return bookmarkGuid; + } + bookmarkGuid = undefined; + this.lastBookmarkDialogDeferred.resolve(bookmarkGuid); + return bookmarkGuid; + }, + + /** + * Bookmarks one or more pages. If there is more than one, this will create + * the bookmarks in a new folder. + * + * @param {Array.} URIList + * The list of URIs to bookmark. + * @param {Array.} [hiddenRows] + * An array of rows to be hidden. + * @param {DOMWindow} [win] + * The window to use as the parent to display the bookmark dialog. + */ + async showBookmarkPagesDialog(URIList, hiddenRows = [], win = null) { + if (!URIList.length) { + return; + } + + const bookmarkDialogInfo = { action: "add", hiddenRows }; + if (URIList.length > 1) { + bookmarkDialogInfo.type = "folder"; + bookmarkDialogInfo.URIList = URIList; + } else { + bookmarkDialogInfo.type = "bookmark"; + bookmarkDialogInfo.title = URIList[0].title; + bookmarkDialogInfo.uri = URIList[0].uri; + } + + await PlacesUIUtils.showBookmarkDialog(bookmarkDialogInfo, win); + }, + + /** + * set and fetch a favicon. Can only be used from the parent process. + * + * @param {object} browser + * The XUL browser element for which we're fetching a favicon. + * @param {Principal} principal + * The loading principal to use for the fetch. + * @param {URI} pageURI + * The page URI associated to this favicon load. + * @param {URI} uri + * The URI to fetch. + * @param {number} expiration + * An optional expiration time. + * @param {URI} iconURI + * An optional data: URI holding the icon's data. + */ + loadFavicon( + browser, + principal, + pageURI, + uri, + expiration = 0, + iconURI = null + ) { + if (gInContentProcess) { + throw new Error("Can't track loads from within the child process!"); + } + InternalFaviconLoader.loadFavicon( + browser, + principal, + pageURI, + uri, + expiration, + iconURI + ); + }, + + /** + * Returns the closet ancestor places view for the given DOM node + * + * @param {DOMNode} aNode + * a DOM node + * @returns {DOMNode} the closest ancestor places view if exists, null otherwsie. + */ + getViewForNode: function PUIU_getViewForNode(aNode) { + let node = aNode; + + if (Cu.isDeadWrapper(node)) { + return null; + } + + if (node.localName == "panelview" && node._placesView) { + return node._placesView; + } + + // The view for a of which its associated menupopup is a places + // view, is the menupopup. + if ( + node.localName == "menu" && + !node._placesNode && + node.menupopup._placesView + ) { + return node.menupopup._placesView; + } + + while (Element.isInstance(node)) { + if (node._placesView) { + return node._placesView; + } + if ( + node.localName == "tree" && + node.getAttribute("is") == "places-tree" + ) { + return node; + } + + node = node.parentNode; + } + + return null; + }, + + /** + * Returns the active PlacesController for a given command. + * + * @param {DOMWindow} win The window containing the affected view + * @param {string} command The command + * @returns {PlacesController} a places controller + */ + getControllerForCommand(win, command) { + // If we're building a context menu for a non-focusable view, for example + // a menupopup, we must return the view that triggered the context menu. + let popupNode = PlacesUIUtils.lastContextMenuTriggerNode; + if (popupNode) { + let isManaged = !!popupNode.closest("#managed-bookmarks"); + if (isManaged) { + return this.managedBookmarksController; + } + let view = this.getViewForNode(popupNode); + if (view && view._contextMenuShown) { + return view.controllers.getControllerForCommand(command); + } + } + + // When we're not building a context menu, only focusable views + // are possible. Thus, we can safely use the command dispatcher. + let controller = + win.top.document.commandDispatcher.getControllerForCommand(command); + return controller || null; + }, + + /** + * Update all the Places commands for the given window. + * + * @param {DOMWindow} win The window to update. + */ + updateCommands(win) { + // Get the controller for one of the places commands. + let controller = this.getControllerForCommand(win, "placesCmd_open"); + for (let command of [ + "placesCmd_open", + "placesCmd_open:window", + "placesCmd_open:privatewindow", + "placesCmd_open:tab", + "placesCmd_new:folder", + "placesCmd_new:bookmark", + "placesCmd_new:separator", + "placesCmd_show:info", + "placesCmd_reload", + "placesCmd_sortBy:name", + "placesCmd_cut", + "placesCmd_copy", + "placesCmd_paste", + "placesCmd_delete", + "placesCmd_showInFolder", + ]) { + win.goSetCommandEnabled( + command, + controller && controller.isCommandEnabled(command) + ); + } + }, + + /** + * Executes the given command on the currently active controller. + * + * @param {DOMWindow} win The window containing the affected view + * @param {string} command The command to execute + */ + doCommand(win, command) { + let controller = this.getControllerForCommand(win, command); + if (controller && controller.isCommandEnabled(command)) { + controller.doCommand(command); + } + }, + + /** + * By calling this before visiting an URL, the visit will be associated to a + * TRANSITION_TYPED transition (if there is no a referrer). + * This is used when visiting pages from the history menu, history sidebar, + * url bar, url autocomplete results, and history searches from the places + * organizer. If this is not called visits will be marked as + * TRANSITION_LINK. + * + * @param {string} aURL + * The URL to mark as typed. + */ + markPageAsTyped: function PUIU_markPageAsTyped(aURL) { + lazy.PlacesUtils.history.markPageAsTyped( + Services.uriFixup.getFixupURIInfo(aURL).preferredURI + ); + }, + + /** + * By calling this before visiting an URL, the visit will be associated to a + * TRANSITION_BOOKMARK transition. + * This is used when visiting pages from the bookmarks menu, + * personal toolbar, and bookmarks from within the places organizer. + * If this is not called visits will be marked as TRANSITION_LINK. + * + * @param {string} aURL + * The URL to mark as TRANSITION_BOOKMARK. + */ + markPageAsFollowedBookmark: function PUIU_markPageAsFollowedBookmark(aURL) { + lazy.PlacesUtils.history.markPageAsFollowedBookmark( + Services.uriFixup.getFixupURIInfo(aURL).preferredURI + ); + }, + + /** + * By calling this before visiting an URL, any visit in frames will be + * associated to a TRANSITION_FRAMED_LINK transition. + * This is actually used to distinguish user-initiated visits in frames + * so automatic visits can be correctly ignored. + * + * @param {string} aURL + * The URL to mark as TRANSITION_FRAMED_LINK. + */ + markPageAsFollowedLink: function PUIU_markPageAsFollowedLink(aURL) { + lazy.PlacesUtils.history.markPageAsFollowedLink( + Services.uriFixup.getFixupURIInfo(aURL).preferredURI + ); + }, + + /** + * Sets the character-set for a page. The character set will not be saved + * if the window is determined to be a private browsing window. + * + * @param {string|URL|nsIURI} url The URL of the page to set the charset on. + * @param {string} charset character-set value. + * @param {DOMWindow} window The window that the charset is being set from. + * @returns {Promise} + */ + async setCharsetForPage(url, charset, window) { + if (lazy.PrivateBrowsingUtils.isWindowPrivate(window)) { + return; + } + + // UTF-8 is the default. If we are passed the value then set it to null, + // to ensure any charset is removed from the database. + if (charset.toLowerCase() == "utf-8") { + charset = null; + } + + await lazy.PlacesUtils.history.update({ + url, + annotations: new Map([[lazy.PlacesUtils.CHARSET_ANNO, charset]]), + }); + }, + + /** + * Allows opening of javascript/data URI only if the given node is + * bookmarked (see bug 224521). + * + * @param {object} aURINode + * a URI node + * @param {DOMWindow} aWindow + * a window on which a potential error alert is shown on. + * @returns {boolean} true if it's safe to open the node in the browser, false otherwise. + * + */ + checkURLSecurity: function PUIU_checkURLSecurity(aURINode, aWindow) { + if (lazy.PlacesUtils.nodeIsBookmark(aURINode)) { + return true; + } + + var uri = Services.io.newURI(aURINode.uri); + if (uri.schemeIs("javascript") || uri.schemeIs("data")) { + const [title, errorStr] = + PlacesUIUtils.promptLocalization.formatValuesSync([ + "places-error-title", + "places-load-js-data-url-error", + ]); + Services.prompt.alert(aWindow, title, errorStr); + return false; + } + return true; + }, + + /** + * Check whether or not the given node represents a removable entry (either in + * history or in bookmarks). + * + * @param {object} aNode + * a node, except the root node of a query. + * @returns {boolean} true if the aNode represents a removable entry, false otherwise. + */ + canUserRemove(aNode) { + let parentNode = aNode.parent; + if (!parentNode) { + // canUserRemove doesn't accept root nodes. + return false; + } + + // Is it a query pointing to one of the special root folders? + if (lazy.PlacesUtils.nodeIsQuery(parentNode)) { + if (lazy.PlacesUtils.nodeIsFolder(aNode)) { + let guid = lazy.PlacesUtils.getConcreteItemGuid(aNode); + // If the parent folder is not a folder, it must be a query, and so this node + // cannot be removed. + if (lazy.PlacesUtils.isRootItem(guid)) { + return false; + } + } else if (lazy.PlacesUtils.isVirtualLeftPaneItem(aNode.bookmarkGuid)) { + // If the item is a left-pane top-level item, it can't be removed. + return false; + } + } + + // If it's not a bookmark, or it's child of a query, we can remove it. + if (aNode.itemId == -1 || lazy.PlacesUtils.nodeIsQuery(parentNode)) { + return true; + } + + // Otherwise it has to be a child of an editable folder. + return !this.isFolderReadOnly(parentNode); + }, + + /** + * DO NOT USE THIS API IN ADDONS. IT IS VERY LIKELY TO CHANGE WHEN THE SWITCH + * TO GUIDS IS COMPLETE (BUG 1071511). + * + * Check whether or not the given Places node points to a folder which + * should not be modified by the user (i.e. its children should be unremovable + * and unmovable, new children should be disallowed, etc). + * These semantics are not inherited, meaning that read-only folder may + * contain editable items (for instance, the places root is read-only, but all + * of its direct children aren't). + * + * You should only pass folder nodes. + * + * @param {object} placesNode + * any folder result node. + * @throws if placesNode is not a folder result node or views is invalid. + * @returns {boolean} true if placesNode is a read-only folder, false otherwise. + */ + isFolderReadOnly(placesNode) { + if ( + typeof placesNode != "object" || + !lazy.PlacesUtils.nodeIsFolder(placesNode) + ) { + throw new Error("invalid value for placesNode"); + } + + return ( + lazy.PlacesUtils.getConcreteItemGuid(placesNode) == + lazy.PlacesUtils.bookmarks.rootGuid + ); + }, + + /** + * @param {Array} aItemsToOpen + * needs to be an array of objects of the form: + * {uri: string, isBookmark: boolean} + * @param {object} aEvent + * The associated event triggering the open. + * @param {DOMWindow} aWindow + * The window associated with the event. + */ + openTabset(aItemsToOpen, aEvent, aWindow) { + if (!aItemsToOpen.length) { + return; + } + + let browserWindow = getBrowserWindow(aWindow); + var urls = []; + let isPrivate = + browserWindow && lazy.PrivateBrowsingUtils.isWindowPrivate(browserWindow); + for (let item of aItemsToOpen) { + urls.push(item.uri); + if (isPrivate) { + continue; + } + + if (item.isBookmark) { + this.markPageAsFollowedBookmark(item.uri); + } else { + this.markPageAsTyped(item.uri); + } + } + + // whereToOpenLink doesn't return "window" when there's no browser window + // open (Bug 630255). + var where = browserWindow + ? browserWindow.whereToOpenLink(aEvent, false, true) + : "window"; + if (where == "window") { + // There is no browser window open, thus open a new one. + let args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray); + let stringsToLoad = Cc["@mozilla.org/array;1"].createInstance( + Ci.nsIMutableArray + ); + urls.forEach(url => + stringsToLoad.appendElement(lazy.PlacesUtils.toISupportsString(url)) + ); + args.appendElement(stringsToLoad); + + let features = "chrome,dialog=no,all"; + if (isPrivate) { + features += ",private"; + } + + browserWindow = Services.ww.openWindow( + aWindow, + AppConstants.BROWSER_CHROME_URL, + null, + features, + args + ); + return; + } + + var loadInBackground = where == "tabshifted"; + // For consistency, we want all the bookmarks to open in new tabs, instead + // of having one of them replace the currently focused tab. Hence we call + // loadTabs with aReplace set to false. + browserWindow.gBrowser.loadTabs(urls, { + inBackground: loadInBackground, + replace: false, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + }, + + /** + * Loads a selected node's or nodes' URLs in tabs, + * warning the user when lots of URLs are being opened + * + * @param {object | Array} nodeOrNodes + * Contains the node or nodes that we're opening in tabs + * @param {event} event + * The DOM mouse/key event with modifier keys set that track the + * user's preferred destination window or tab. + * @param {object} view + * The current view that contains the node or nodes selected for + * opening + */ + openMultipleLinksInTabs(nodeOrNodes, event, view) { + let window = view.ownerWindow; + let urlsToOpen = []; + + if (lazy.PlacesUtils.nodeIsContainer(nodeOrNodes)) { + urlsToOpen = lazy.PlacesUtils.getURLsForContainerNode(nodeOrNodes); + } else { + for (var i = 0; i < nodeOrNodes.length; i++) { + // Skip over separators and folders. + if (lazy.PlacesUtils.nodeIsURI(nodeOrNodes[i])) { + urlsToOpen.push({ + uri: nodeOrNodes[i].uri, + isBookmark: lazy.PlacesUtils.nodeIsBookmark(nodeOrNodes[i]), + }); + } + } + } + if (lazy.OpenInTabsUtils.confirmOpenInTabs(urlsToOpen.length, window)) { + if (window.updateTelemetry) { + window.updateTelemetry(urlsToOpen); + } + this.openTabset(urlsToOpen, event, window); + } + }, + + /** + * Loads the node's URL in the appropriate tab or window given the + * user's preference specified by modifier keys tracked by a + * DOM mouse/key event. + * + * @param {object} aNode + * An uri result node. + * @param {object} aEvent + * The DOM mouse/key event with modifier keys set that track the + * user's preferred destination window or tab. + */ + openNodeWithEvent: function PUIU_openNodeWithEvent(aNode, aEvent) { + let window = aEvent.target.ownerGlobal; + + let where = window.whereToOpenLink(aEvent, false, true); + if (this.loadBookmarksInTabs && lazy.PlacesUtils.nodeIsBookmark(aNode)) { + if (where == "current" && !aNode.uri.startsWith("javascript:")) { + where = "tab"; + } + let browserWindow = getBrowserWindow(window); + if (where == "tab" && browserWindow?.gBrowser.selectedTab.isEmpty) { + where = "current"; + } + } + + this._openNodeIn(aNode, where, window); + }, + + /** + * Loads the node's URL in the appropriate tab or window. + * see also URILoadingHelper's openWebLinkIn + * + * @param {object} aNode + * An uri result node. + * @param {string} aWhere + * Where to open the URL. + * @param {object} aView + * The associated view of the node being opened. + * @param {boolean} aPrivate + * True if the window being opened is private. + */ + openNodeIn: function PUIU_openNodeIn(aNode, aWhere, aView, aPrivate) { + let window = aView.ownerWindow; + this._openNodeIn(aNode, aWhere, window, { aPrivate }); + }, + + _openNodeIn: function PUIU__openNodeIn( + aNode, + aWhere, + aWindow, + { aPrivate = false, userContextId = 0 } = {} + ) { + if ( + aNode && + lazy.PlacesUtils.nodeIsURI(aNode) && + this.checkURLSecurity(aNode, aWindow) + ) { + let isBookmark = lazy.PlacesUtils.nodeIsBookmark(aNode); + + if (!lazy.PrivateBrowsingUtils.isWindowPrivate(aWindow)) { + if (isBookmark) { + this.markPageAsFollowedBookmark(aNode.uri); + } else { + this.markPageAsTyped(aNode.uri); + } + } else { + // This is a targeted fix for bug 1792163, where it was discovered + // that if you open the Library from a Private Browsing window, and then + // use the "Open in New Window" context menu item to open a new window, + // that the window will open under the wrong icon on the Windows taskbar. + aPrivate = true; + } + + const isJavaScriptURL = aNode.uri.startsWith("javascript:"); + aWindow.openTrustedLinkIn(aNode.uri, aWhere, { + allowPopups: isJavaScriptURL, + inBackground: this.loadBookmarksInBackground, + allowInheritPrincipal: isJavaScriptURL, + private: aPrivate, + userContextId, + }); + if (aWindow.updateTelemetry) { + aWindow.updateTelemetry([aNode]); + } + } + }, + + /** + * Helper for guessing scheme from an url string. + * Used to avoid nsIURI overhead in frequently called UI functions. This is not + * supposed be perfect, so use it only for UI purposes. + * + * @param {string} href The url to guess the scheme from. + * @returns {string} guessed scheme for this url string. + */ + guessUrlSchemeForUI(href) { + return href.substr(0, href.indexOf(":")); + }, + + getBestTitle: function PUIU_getBestTitle(aNode, aDoNotCutTitle) { + var title; + if (!aNode.title && lazy.PlacesUtils.nodeIsURI(aNode)) { + // if node title is empty, try to set the label using host and filename + // Services.io.newURI will throw if aNode.uri is not a valid URI + try { + var uri = Services.io.newURI(aNode.uri); + var host = uri.host; + var fileName = uri.QueryInterface(Ci.nsIURL).fileName; + // if fileName is empty, use path to distinguish labels + if (aDoNotCutTitle) { + title = host + uri.pathQueryRef; + } else { + title = + host + + (fileName + ? (host ? "/" + this.ellipsis + "/" : "") + fileName + : uri.pathQueryRef); + } + } catch (e) { + // Use (no title) for non-standard URIs (data:, javascript:, ...) + title = ""; + } + } else { + title = aNode.title; + } + + return title || this.promptLocalization.formatValueSync("places-no-title"); + }, + + shouldShowTabsFromOtherComputersMenuitem() { + let weaveOK = + lazy.Weave.Status.checkSetup() != lazy.CLIENT_NOT_CONFIGURED && + lazy.Weave.Svc.PrefBranch.getCharPref("firstSync", "") != "notReady"; + return weaveOK; + }, + + /** + * WARNING TO ADDON AUTHORS: DO NOT USE THIS METHOD. IT'S LIKELY TO BE REMOVED IN A + * FUTURE RELEASE. + * + * Checks if a place: href represents a folder shortcut. + * + * @param {string} queryString + * the query string to check (a place: href) + * @returns {boolean} whether or not queryString represents a folder shortcut. + * @throws if queryString is malformed. + */ + isFolderShortcutQueryString(queryString) { + // Based on GetSimpleBookmarksQueryFolder in nsNavHistory.cpp. + + let query = {}, + options = {}; + lazy.PlacesUtils.history.queryStringToQuery(queryString, query, options); + query = query.value; + options = options.value; + return ( + query.folderCount == 1 && + !query.hasBeginTime && + !query.hasEndTime && + !query.hasDomain && + !query.hasURI && + !query.hasSearchTerms && + !query.tags.length == 0 && + options.maxResults == 0 + ); + }, + + /** + * Helpers for consumers of editBookmarkOverlay which don't have a node as their input. + * + * Given a bookmark object for either a url bookmark or a folder, returned by + * Bookmarks.fetch (see Bookmark.jsm), this creates a node-like object suitable for + * initialising the edit overlay with it. + * + * @param {object} aFetchInfo + * a bookmark object returned by Bookmarks.fetch. + * @returns {object} a node-like object suitable for initialising editBookmarkOverlay. + * @throws if aFetchInfo is representing a separator. + */ + async promiseNodeLikeFromFetchInfo(aFetchInfo) { + if (aFetchInfo.itemType == lazy.PlacesUtils.bookmarks.TYPE_SEPARATOR) { + throw new Error("promiseNodeLike doesn't support separators"); + } + + let parent = { + bookmarkGuid: aFetchInfo.parentGuid, + type: Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER, + }; + + return Object.freeze({ + bookmarkGuid: aFetchInfo.guid, + title: aFetchInfo.title, + uri: aFetchInfo.url !== undefined ? aFetchInfo.url.href : "", + + get type() { + if (aFetchInfo.itemType == lazy.PlacesUtils.bookmarks.TYPE_FOLDER) { + return Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER; + } + + if (!this.uri.length) { + throw new Error("Unexpected item type"); + } + + if (/^place:/.test(this.uri)) { + if (this.isFolderShortcutQueryString(this.uri)) { + return Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT; + } + + return Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY; + } + + return Ci.nsINavHistoryResultNode.RESULT_TYPE_URI; + }, + + get parent() { + return parent; + }, + }); + }, + + /** + * This function wraps potentially large places transaction operations + * with batch notifications to the result node, hence switching the views + * to batch mode. If resultNode is not supplied, the function will + * pass-through to functionToWrap. + * + * @param {nsINavHistoryResult} resultNode The result node to turn on batching. + * @param {number} itemsBeingChanged The count of items being changed. If the + * count is lower than a threshold, then + * batching won't be set. + * @param {Function} functionToWrap The function to + * @returns {object} forwards the functionToWrap return value. + */ + async batchUpdatesForNode(resultNode, itemsBeingChanged, functionToWrap) { + if (!resultNode) { + return functionToWrap(); + } + + if (itemsBeingChanged > ITEM_CHANGED_BATCH_NOTIFICATION_THRESHOLD) { + resultNode.onBeginUpdateBatch(); + } + + try { + return await functionToWrap(); + } finally { + if (itemsBeingChanged > ITEM_CHANGED_BATCH_NOTIFICATION_THRESHOLD) { + resultNode.onEndUpdateBatch(); + } + } + }, + + /** + * Processes a set of transfer items that have been dropped or pasted. + * Batching will be applied where necessary. + * + * @param {Array} items A list of unwrapped nodes to process. + * @param {object} insertionPoint The requested point for insertion. + * @param {boolean} doCopy Set to true to copy the items, false will move them + * if possible. + * @param {object} view The view that should be used for batching. + * @returns {Array} Returns an empty array when the insertion point is a tag, else + * returns an array of copied or moved guids. + */ + async handleTransferItems(items, insertionPoint, doCopy, view) { + let transactions; + let itemsCount; + if (insertionPoint.isTag) { + let urls = items.filter(item => "uri" in item).map(item => item.uri); + itemsCount = urls.length; + transactions = [ + lazy.PlacesTransactions.Tag({ urls, tag: insertionPoint.tagName }), + ]; + } else { + let insertionIndex = await insertionPoint.getIndex(); + itemsCount = items.length; + transactions = getTransactionsForTransferItems( + items, + insertionIndex, + insertionPoint.guid, + !doCopy + ); + } + + // Check if we actually have something to add, if we don't it probably wasn't + // valid, or it was moving to the same location, so just ignore it. + if (!transactions.length) { + return []; + } + + let guidsToSelect = await this.batchUpdatesForNode( + getResultForBatching(view), + itemsCount, + async () => + lazy.PlacesTransactions.batch(transactions, "handleTransferItems") + ); + + // If we're inserting into a tag, we don't get the resulting guids. + return insertionPoint.isTag ? [] : guidsToSelect.flat(); + }, + + onSidebarTreeClick(event) { + // right-clicks are not handled here + if (event.button == 2) { + return; + } + + let tree = event.target.parentNode; + let cell = tree.getCellAt(event.clientX, event.clientY); + if (cell.row == -1 || cell.childElt == "twisty") { + return; + } + + // getCoordsForCellItem returns the x coordinate in logical coordinates + // (i.e., starting from the left and right sides in LTR and RTL modes, + // respectively.) Therefore, we make sure to exclude the blank area + // before the tree item icon (that is, to the left or right of it in + // LTR and RTL modes, respectively) from the click target area. + let win = tree.ownerGlobal; + let rect = tree.getCoordsForCellItem(cell.row, cell.col, "image"); + let isRTL = win.getComputedStyle(tree).direction == "rtl"; + let mouseInGutter = isRTL ? event.clientX > rect.x : event.clientX < rect.x; + + let metaKey = + AppConstants.platform === "macosx" ? event.metaKey : event.ctrlKey; + let modifKey = metaKey || event.shiftKey; + let isContainer = tree.view.isContainer(cell.row); + let openInTabs = + isContainer && + (event.button == 1 || (event.button == 0 && modifKey)) && + lazy.PlacesUtils.hasChildURIs(tree.view.nodeForTreeIndex(cell.row)); + + if (event.button == 0 && isContainer && !openInTabs) { + tree.view.toggleOpenState(cell.row); + } else if ( + !mouseInGutter && + openInTabs && + event.originalTarget.localName == "treechildren" + ) { + tree.view.selection.select(cell.row); + this.openMultipleLinksInTabs(tree.selectedNode, event, tree); + } else if ( + !mouseInGutter && + !isContainer && + event.originalTarget.localName == "treechildren" + ) { + // Clear all other selection since we're loading a link now. We must + // do this *before* attempting to load the link since openURL uses + // selection as an indication of which link to load. + tree.view.selection.select(cell.row); + this.openNodeWithEvent(tree.selectedNode, event); + } + }, + + onSidebarTreeKeyPress(event) { + let node = event.target.selectedNode; + if (node) { + if (event.keyCode == event.DOM_VK_RETURN) { + PlacesUIUtils.openNodeWithEvent(node, event); + } + } + }, + + /** + * The following function displays the URL of a node that is being + * hovered over. + * + * @param {object} event + * The event that triggered the hover. + */ + onSidebarTreeMouseMove(event) { + let treechildren = event.target; + if (treechildren.localName != "treechildren") { + return; + } + + let tree = treechildren.parentNode; + let cell = tree.getCellAt(event.clientX, event.clientY); + + // cell.row is -1 when the mouse is hovering an empty area within the tree. + // To avoid showing a URL from a previously hovered node for a currently + // hovered non-url node, we must clear the moused-over URL in these cases. + if (cell.row != -1) { + let node = tree.view.nodeForTreeIndex(cell.row); + if (lazy.PlacesUtils.nodeIsURI(node)) { + this.setMouseoverURL(node.uri, tree.ownerGlobal); + return; + } + } + this.setMouseoverURL("", tree.ownerGlobal); + }, + + setMouseoverURL(url, win) { + // When the browser window is closed with an open sidebar, the sidebar + // unload event happens after the browser's one. In this case + // top.XULBrowserWindow has been nullified already. + if (win.top.XULBrowserWindow) { + win.top.XULBrowserWindow.setOverLink(url); + } + }, + + /** + * Uncollapses PersonalToolbar if its collapsed status is not + * persisted, and user customized it or changed default bookmarks. + * + * If the user does not have a persisted value for the toolbar's + * "collapsed" attribute, try to determine whether it's customized. + * + * @param {boolean} aForceVisible Set to true to ignore if the user had + * previously collapsed the toolbar manually. + */ + NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE: 3, + async maybeToggleBookmarkToolbarVisibility(aForceVisible = false) { + const BROWSER_DOCURL = AppConstants.BROWSER_CHROME_URL; + let xulStore = Services.xulStore; + + if ( + aForceVisible || + !xulStore.hasValue(BROWSER_DOCURL, "PersonalToolbar", "collapsed") + ) { + function uncollapseToolbar() { + Services.obs.notifyObservers( + null, + "browser-set-toolbar-visibility", + JSON.stringify([lazy.CustomizableUI.AREA_BOOKMARKS, "true"]) + ); + } + // We consider the toolbar customized if it has more than + // NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE children, or if it has a persisted + // currentset value. + let toolbarIsCustomized = xulStore.hasValue( + BROWSER_DOCURL, + "PersonalToolbar", + "currentset" + ); + if (aForceVisible || toolbarIsCustomized) { + uncollapseToolbar(); + return; + } + + let numBookmarksOnToolbar = ( + await lazy.PlacesUtils.bookmarks.fetch( + lazy.PlacesUtils.bookmarks.toolbarGuid + ) + ).childCount; + if (numBookmarksOnToolbar > this.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE) { + uncollapseToolbar(); + } + } + }, + + async managedPlacesContextShowing(event) { + let menupopup = event.target; + let document = menupopup.ownerDocument; + let window = menupopup.ownerGlobal; + // We need to populate the submenus in order to have information + // to show the context menu. + if ( + menupopup.triggerNode.id == "managed-bookmarks" && + !menupopup.triggerNode.menupopup.hasAttribute("hasbeenopened") + ) { + await window.PlacesToolbarHelper.populateManagedBookmarks( + menupopup.triggerNode.menupopup + ); + } + let linkItems = [ + "placesContext_open:newtab", + "placesContext_open:newwindow", + "placesContext_openSeparator", + "placesContext_copy", + ]; + // Hide everything. We'll unhide the things we need. + Array.from(menupopup.children).forEach(function (child) { + child.hidden = true; + }); + // Store triggerNode in controller for checking if commands are enabled + this.managedBookmarksController.triggerNode = menupopup.triggerNode; + // Container in this context means a folder. + let isFolder = menupopup.triggerNode.hasAttribute("container"); + if (isFolder) { + // Disable the openContainerInTabs menuitem if there + // are no children of the menu that have links. + let openContainerInTabs_menuitem = document.getElementById( + "placesContext_openContainer:tabs" + ); + let menuitems = menupopup.triggerNode.menupopup.children; + let openContainerInTabs = Array.from(menuitems).some( + menuitem => menuitem.link + ); + openContainerInTabs_menuitem.disabled = !openContainerInTabs; + openContainerInTabs_menuitem.hidden = false; + } else { + linkItems.forEach(id => (document.getElementById(id).hidden = false)); + document.getElementById("placesContext_open:newprivatewindow").hidden = + lazy.PrivateBrowsingUtils.isWindowPrivate(window) || + !lazy.PrivateBrowsingUtils.enabled; + document.getElementById("placesContext_open:newcontainertab").hidden = + !Services.prefs.getBoolPref("privacy.userContext.enabled", false); + } + + event.target.ownerGlobal.updateCommands("places"); + }, + + placesContextShowing(event) { + let menupopup = event.target; + if (menupopup.id != "placesContext") { + // Ignore any popupshowing events from submenus + return true; + } + + PlacesUIUtils.lastContextMenuTriggerNode = menupopup.triggerNode; + + if (Services.prefs.getBoolPref("browser.tabs.loadBookmarksInTabs", false)) { + menupopup.ownerDocument + .getElementById("placesContext_open") + .removeAttribute("default"); + menupopup.ownerDocument + .getElementById("placesContext_open:newtab") + .setAttribute("default", "true"); + // else clause ensures correct behavior if pref is repeatedly toggled + } else { + menupopup.ownerDocument + .getElementById("placesContext_open:newtab") + .removeAttribute("default"); + menupopup.ownerDocument + .getElementById("placesContext_open") + .setAttribute("default", "true"); + } + + let isManaged = !!menupopup.triggerNode.closest("#managed-bookmarks"); + if (isManaged) { + this.managedPlacesContextShowing(event); + return true; + } + menupopup._view = this.getViewForNode(menupopup.triggerNode); + if (!menupopup._view) { + // This can happen if we try to invoke the context menu on + // an uninitialized places toolbar. Just bail out: + event.preventDefault(); + return false; + } + if (!this.openInTabClosesMenu) { + menupopup.ownerDocument + .getElementById("placesContext_open:newtab") + .setAttribute("closemenu", "single"); + } + return menupopup._view.buildContextMenu(menupopup); + }, + + placesContextHiding(event) { + let menupopup = event.target; + if (menupopup._view) { + menupopup._view.destroyContextMenu(); + } + + if (menupopup.id == "placesContext") { + PlacesUIUtils.lastContextMenuTriggerNode = null; + } + }, + + createContainerTabMenu(event) { + let window = event.target.ownerGlobal; + return window.createUserContextMenu(event, { isContextMenu: true }); + }, + + openInContainerTab(event) { + let userContextId = parseInt( + event.target.getAttribute("data-usercontextid") + ); + let triggerNode = this.lastContextMenuTriggerNode; + let isManaged = !!triggerNode?.closest("#managed-bookmarks"); + if (isManaged) { + let window = triggerNode.ownerGlobal; + window.openTrustedLinkIn(triggerNode.link, "tab", { userContextId }); + return; + } + let view = this.getViewForNode(triggerNode); + this._openNodeIn(view.selectedNode, "tab", view.ownerWindow, { + userContextId, + }); + }, + + openSelectionInTabs(event) { + let isManaged = + !!event.target.parentNode.triggerNode.closest("#managed-bookmarks"); + let controller; + if (isManaged) { + controller = this.managedBookmarksController; + } else { + controller = PlacesUIUtils.getViewForNode( + PlacesUIUtils.lastContextMenuTriggerNode + ).controller; + } + controller.openSelectionInTabs(event); + }, + + managedBookmarksController: { + triggerNode: null, + + openSelectionInTabs(event) { + let window = event.target.ownerGlobal; + let menuitems = event.target.parentNode.triggerNode.menupopup.children; + let items = []; + for (let i = 0; i < menuitems.length; i++) { + if (menuitems[i].link) { + let item = {}; + item.uri = menuitems[i].link; + item.isBookmark = true; + items.push(item); + } + } + PlacesUIUtils.openTabset(items, event, window); + }, + + isCommandEnabled(command) { + switch (command) { + case "placesCmd_copy": + case "placesCmd_open:window": + case "placesCmd_open:privatewindow": + case "placesCmd_open:tab": { + return true; + } + } + return false; + }, + + doCommand(command) { + let window = this.triggerNode.ownerGlobal; + switch (command) { + 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 = {}; + node.type = 0; + node.title = this.triggerNode.label; + node.uri = this.triggerNode.link; + + // Copied from _populateClipboard in controller.js + + // This order is _important_! It controls how this and other applications + // select data to be inserted based on type. + let contents = [ + { type: lazy.PlacesUtils.TYPE_X_MOZ_URL, entries: [] }, + { type: lazy.PlacesUtils.TYPE_HTML, entries: [] }, + { type: lazy.PlacesUtils.TYPE_PLAINTEXT, entries: [] }, + ]; + + contents.forEach(function (content) { + content.entries.push(lazy.PlacesUtils.wrapNode(node, content.type)); + }); + + let xferable = Cc[ + "@mozilla.org/widget/transferable;1" + ].createInstance(Ci.nsITransferable); + xferable.init(null); + + function addData(type, data) { + xferable.addDataFlavor(type); + xferable.setTransferData( + type, + lazy.PlacesUtils.toISupportsString(data) + ); + } + + contents.forEach(function (content) { + addData(content.type, content.entries.join(lazy.PlacesUtils.endl)); + }); + + Services.clipboard.setData( + xferable, + null, + Ci.nsIClipboard.kGlobalClipboard + ); + break; + case "placesCmd_open:privatewindow": + window.openTrustedLinkIn(this.triggerNode.link, "window", { + private: true, + }); + break; + case "placesCmd_open:window": + window.openTrustedLinkIn(this.triggerNode.link, "window", { + private: false, + }); + break; + case "placesCmd_open:tab": { + window.openTrustedLinkIn(this.triggerNode.link, "tab"); + } + } + }, + }, + + async maybeAddImportButton() { + if (!Services.policies.isAllowed("profileImport")) { + return; + } + + let numberOfBookmarks = await lazy.PlacesUtils.withConnectionWrapper( + "PlacesUIUtils: maybeAddImportButton", + async db => { + let rows = await db.execute( + `SELECT COUNT(*) as n FROM moz_bookmarks b + JOIN moz_bookmarks p ON p.id = b.parent + WHERE p.guid = :guid`, + { guid: lazy.PlacesUtils.bookmarks.toolbarGuid } + ); + return rows[0].getResultByName("n"); + } + ).catch(e => { + // We want to report errors, but we still want to add the button then: + console.error(e); + return 0; + }); + + if (numberOfBookmarks < 3) { + lazy.CustomizableUI.addWidgetToArea( + "import-button", + lazy.CustomizableUI.AREA_BOOKMARKS, + 0 + ); + Services.prefs.setBoolPref("browser.bookmarks.addedImportButton", true); + this.removeImportButtonWhenImportSucceeds(); + } + }, + + removeImportButtonWhenImportSucceeds() { + // If the user (re)moved the button, clear the pref and stop worrying about + // moving the item. + let placement = lazy.CustomizableUI.getPlacementOfWidget("import-button"); + if (placement?.area != lazy.CustomizableUI.AREA_BOOKMARKS) { + Services.prefs.clearUserPref("browser.bookmarks.addedImportButton"); + return; + } + // Otherwise, wait for a successful migration: + let obs = (subject, topic, data) => { + if ( + data == lazy.MigrationUtils.resourceTypes.BOOKMARKS && + lazy.MigrationUtils.getImportedCount("bookmarks") > 0 + ) { + lazy.CustomizableUI.removeWidgetFromArea("import-button"); + Services.prefs.clearUserPref("browser.bookmarks.addedImportButton"); + Services.obs.removeObserver(obs, "Migration:ItemAfterMigrate"); + Services.obs.removeObserver(obs, "Migration:ItemError"); + } + }; + Services.obs.addObserver(obs, "Migration:ItemAfterMigrate"); + Services.obs.addObserver(obs, "Migration:ItemError"); + }, + + /** + * Tries to initiate a speculative connection to a given url. This is not + * infallible, if a speculative connection cannot be initialized, it will be a + * no-op. + * + * @param {nsIURI|URL|string} url entity to initiate + * a speculative connection for. + * @param {window} window the window from where the connection is initialized. + */ + setupSpeculativeConnection(url, window) { + if ( + !Services.prefs.getBoolPref( + "browser.places.speculativeConnect.enabled", + true + ) + ) { + return; + } + if (!url.startsWith("http")) { + return; + } + try { + let uri = url instanceof Ci.nsIURI ? url : Services.io.newURI(url); + Services.io.speculativeConnect( + uri, + window.gBrowser.contentPrincipal, + null, + false + ); + } catch (ex) { + // Can't setup speculative connection for this url, just ignore it. + } + }, + + /** + * Generates a cached-favicon: link for an icon URL, that will allow to fetch + * the icon from the local favicons cache, rather than from the network. + * If the icon URL is invalid, fallbacks to the default favicon URL. + * + * @param {string} icon The url of the icon to load from local cache. + * @returns {string} a "cached-favicon:" prefixed URL, unless the original + * URL protocol refers to a local resource, then it will just pass-through + * unchanged. + */ + getImageURL(icon) { + // don't initiate a connection just to fetch a favicon (see bug 467828) + try { + return lazy.PlacesUtils.favicons.getFaviconLinkForIcon( + Services.io.newURI(icon) + ).spec; + } catch (ex) {} + return lazy.PlacesUtils.favicons.defaultFavicon.spec; + }, + + /** + * Determines the string indexes where titles differ from similar titles (where + * the first n characters are the same) in the provided list of items, and + * adds that into the item. + * + * This assumes the titles will be displayed along the lines of + * `Start of title ... place where differs` the index would be reference + * the `p` here. + * + * @param {object[]} candidates + * An array of candidates to modify. The candidates should have a `title` + * property which should be a string or null. + * The order of the array does not matter. The objects are modified + * in-place. + * If a difference to other similar titles is found then a + * `titleDifferentIndex` property will be inserted into all similar + * candidates with the index of the start of the difference. + */ + insertTitleStartDiffs(candidates) { + function findStartDifference(a, b) { + let i; + // We already know the start is the same, so skip that part. + for (i = PlacesUIUtils.similarTitlesMinChars; i < a.length; i++) { + if (a[i] != b[i]) { + return i; + } + } + if (b.length > i) { + return i; + } + // They are the same. + return -1; + } + + let longTitles = new Map(); + + for (let candidate of candidates) { + // Title is too short for us to care about, simply continue. + if ( + !candidate.title || + candidate.title.length < this.similarTitlesMinChars + ) { + continue; + } + let titleBeginning = candidate.title.slice(0, this.similarTitlesMinChars); + let matches = longTitles.get(titleBeginning); + if (matches) { + for (let match of matches) { + let startDiff = findStartDifference(candidate.title, match.title); + if (startDiff > 0) { + candidate.titleDifferentIndex = startDiff; + // If we have an existing difference index for the match, move + // it forward if this one is earlier in the string. + if ( + !("titleDifferentIndex" in match) || + match.titleDifferentIndex > startDiff + ) { + match.titleDifferentIndex = startDiff; + } + } + } + + matches.push(candidate); + } else { + longTitles.set(titleBeginning, [candidate]); + } + } + }, +}; + +/** + * Promise used by the toolbar view browser-places to determine whether we + * can start loading its content (which involves IO, and so is postponed + * during startup). + */ +PlacesUIUtils.canLoadToolbarContentPromise = new Promise(resolve => { + PlacesUIUtils.unblockToolbars = resolve; +}); + +// These are lazy getters to avoid importing PlacesUtils immediately. +ChromeUtils.defineLazyGetter(PlacesUIUtils, "PLACES_FLAVORS", () => { + return [ + lazy.PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER, + lazy.PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR, + lazy.PlacesUtils.TYPE_X_MOZ_PLACE, + ]; +}); +ChromeUtils.defineLazyGetter(PlacesUIUtils, "URI_FLAVORS", () => { + return [ + lazy.PlacesUtils.TYPE_X_MOZ_URL, + TAB_DROP_TYPE, + lazy.PlacesUtils.TYPE_PLAINTEXT, + ]; +}); +ChromeUtils.defineLazyGetter(PlacesUIUtils, "SUPPORTED_FLAVORS", () => { + return [...PlacesUIUtils.PLACES_FLAVORS, ...PlacesUIUtils.URI_FLAVORS]; +}); + +ChromeUtils.defineLazyGetter(PlacesUIUtils, "ellipsis", function () { + return Services.prefs.getComplexValue( + "intl.ellipsis", + Ci.nsIPrefLocalizedString + ).data; +}); + +ChromeUtils.defineLazyGetter(PlacesUIUtils, "promptLocalization", () => { + return new Localization( + ["browser/placesPrompts.ftl", "branding/brand.ftl"], + true + ); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + PlacesUIUtils, + "similarTitlesMinChars", + "browser.places.similarTitlesMinChars", + 20 +); +XPCOMUtils.defineLazyPreferenceGetter( + PlacesUIUtils, + "loadBookmarksInBackground", + "browser.tabs.loadBookmarksInBackground", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + PlacesUIUtils, + "loadBookmarksInTabs", + "browser.tabs.loadBookmarksInTabs", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + PlacesUIUtils, + "openInTabClosesMenu", + "browser.bookmarks.openInTabClosesMenu", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + PlacesUIUtils, + "maxRecentFolders", + "browser.bookmarks.editDialog.maxRecentFolders", + 7 +); + +XPCOMUtils.defineLazyPreferenceGetter( + PlacesUIUtils, + "defaultParentGuid", + "browser.bookmarks.defaultLocation", + "", // Avoid eagerly loading PlacesUtils. + null, + async prefValue => { + if (!prefValue) { + return lazy.PlacesUtils.bookmarks.toolbarGuid; + } + if (["toolbar", "menu", "unfiled"].includes(prefValue)) { + return lazy.PlacesUtils.bookmarks[prefValue + "Guid"]; + } + + try { + return await lazy.PlacesUtils.bookmarks + .fetch({ guid: prefValue }) + .then(bm => bm.guid); + } catch (ex) { + // The guid may have an invalid format. + return lazy.PlacesUtils.bookmarks.toolbarGuid; + } + } +); + +/** + * Determines if an unwrapped node can be moved. + * + * @param {object} unwrappedNode + * A node unwrapped by PlacesUtils.unwrapNodes(). + * @returns {boolean} True if the node can be moved, false otherwise. + */ +function canMoveUnwrappedNode(unwrappedNode) { + if ( + (unwrappedNode.concreteGuid && + lazy.PlacesUtils.isRootItem(unwrappedNode.concreteGuid)) || + (unwrappedNode.guid && lazy.PlacesUtils.isRootItem(unwrappedNode.guid)) + ) { + return false; + } + + let parentGuid = unwrappedNode.parentGuid; + if (parentGuid == lazy.PlacesUtils.bookmarks.rootGuid) { + return false; + } + + return true; +} + +/** + * This gets the most appropriate item for using for batching. In the case of multiple + * views being related, the method returns the most expensive result to batch. + * For example, if it detects the left-hand library pane, then it will look for + * and return the reference to the right-hand pane. + * + * @param {object} viewOrElement The item to check. + * @returns {object} Will return the best result node to batch, or null + * if one could not be found. + */ +function getResultForBatching(viewOrElement) { + if ( + viewOrElement && + Element.isInstance(viewOrElement) && + viewOrElement.id === "placesList" + ) { + // Note: fall back to the existing item if we can't find the right-hane pane. + viewOrElement = + viewOrElement.ownerDocument.getElementById("placeContent") || + viewOrElement; + } + + if (viewOrElement && viewOrElement.result) { + return viewOrElement.result; + } + + return null; +} + +/** + * Processes a set of transfer items and returns transactions to insert or + * move them. + * + * @param {Array} items A list of unwrapped nodes to get transactions for. + * @param {number} insertionIndex The requested index for insertion. + * @param {string} insertionParentGuid The guid of the parent folder to insert + * or move the items to. + * @param {boolean} doMove Set to true to MOVE the items if possible, false will + * copy them. + * @returns {Array} Returns an array of created PlacesTransactions. + */ +function getTransactionsForTransferItems( + items, + insertionIndex, + insertionParentGuid, + doMove +) { + let canMove = true; + for (let item of items) { + if (!PlacesUIUtils.SUPPORTED_FLAVORS.includes(item.type)) { + throw new Error(`Unsupported '${item.type}' data type`); + } + + // Work out if this is data from the same app session we're running in. + if ( + !("instanceId" in item) || + item.instanceId != lazy.PlacesUtils.instanceId + ) { + if (item.type == lazy.PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER) { + throw new Error( + "Can't copy a container from a legacy-transactions build" + ); + } + // Only log if this is one of "our" types as external items, e.g. drag from + // url bar to toolbar, shouldn't complain. + if (PlacesUIUtils.PLACES_FLAVORS.includes(item.type)) { + console.error( + "Tried to move an unmovable Places " + + "node, reverting to a copy operation." + ); + } + + // We can never move from an external copy. + canMove = false; + } + + if (doMove && canMove) { + canMove = canMoveUnwrappedNode(item); + } + } + + if (doMove && !canMove) { + doMove = false; + } + + if (doMove) { + // Move is simple, we pass the transaction a list of GUIDs and where to move + // them to. + return [ + lazy.PlacesTransactions.Move({ + guids: items.map(item => item.itemGuid), + newParentGuid: insertionParentGuid, + newIndex: insertionIndex, + }), + ]; + } + + return getTransactionsForCopy(items, insertionIndex, insertionParentGuid); +} + +/** + * Processes a set of transfer items and returns an array of transactions. + * + * @param {Array} items A list of unwrapped nodes to get transactions for. + * @param {number} insertionIndex The requested index for insertion. + * @param {string} insertionParentGuid The guid of the parent folder to insert + * or move the items to. + * @returns {Array} Returns an array of created PlacesTransactions. + */ +function getTransactionsForCopy(items, insertionIndex, insertionParentGuid) { + let transactions = []; + let index = insertionIndex; + + for (let item of items) { + let transaction; + let guid = item.itemGuid; + + if ( + PlacesUIUtils.PLACES_FLAVORS.includes(item.type) && + // For anything that is comming from within this session, we do a + // direct copy, otherwise we fallback and form a new item below. + "instanceId" in item && + item.instanceId == lazy.PlacesUtils.instanceId && + // If the Item doesn't have a guid, this could be a virtual tag query or + // other item, so fallback to inserting a new bookmark with the URI. + guid && + // For virtual root items, we fallback to creating a new bookmark, as + // we want a shortcut to be created, not a full tree copy. + !lazy.PlacesUtils.bookmarks.isVirtualRootItem(guid) && + !lazy.PlacesUtils.isVirtualLeftPaneItem(guid) + ) { + transaction = lazy.PlacesTransactions.Copy({ + guid, + newIndex: index, + newParentGuid: insertionParentGuid, + }); + } else if (item.type == lazy.PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR) { + transaction = lazy.PlacesTransactions.NewSeparator({ + index, + parentGuid: insertionParentGuid, + }); + } else { + let title = + item.type != lazy.PlacesUtils.TYPE_PLAINTEXT ? item.title : item.uri; + transaction = lazy.PlacesTransactions.NewBookmark({ + index, + parentGuid: insertionParentGuid, + title, + url: item.uri, + }); + } + + transactions.push(transaction); + + if (index != -1) { + index++; + } + } + return transactions; +} + +function getBrowserWindow(aWindow) { + // Prefer the caller window if it's a browser window, otherwise use + // the top browser window. + return aWindow && + aWindow.document.documentElement.getAttribute("windowtype") == + "navigator:browser" + ? aWindow + : lazy.BrowserWindowTracker.getTopWindow(); +} diff --git a/browser/components/places/content/bookmarkProperties.js b/browser/components/places/content/bookmarkProperties.js new file mode 100644 index 0000000000..bc98820a7b --- /dev/null +++ b/browser/components/places/content/bookmarkProperties.js @@ -0,0 +1,519 @@ +/* -*- 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/. */ + +/** + * The panel is initialized based on data given in the js object passed + * as window.arguments[0]. The object must have the following fields set: + * @ action (String). Possible values: + * - "add" - for adding a new item. + * @ type (String). Possible values: + * - "bookmark" + * - "folder" + * @ URIList (Array of nsIURI objects) - optional, list of uris to + * be bookmarked under the new folder. + * @ uri (nsIURI object) - optional, the default uri for the new item. + * The property is not used for the "folder with items" type. + * @ title (String) - optional, the default title for the new item. + * @ defaultInsertionPoint (InsertionPoint JS object) - optional, the + * default insertion point for the new item. + * @ keyword (String) - optional, the default keyword for the new item. + * @ postData (String) - optional, POST data to accompany the keyword. + * @ charSet (String) - optional, character-set to accompany the keyword. + * Notes: + * 1) If |uri| is set for a bookmark and |title| isn't, + * the dialog will query the history tables for the title associated + * with the given uri. If the dialog is set to adding a folder with + * bookmark items under it (see URIList), a default static title is + * used ("[Folder Name]"). + * 2) The index field of the default insertion point is ignored if + * the folder picker is shown. + * - "edit" - for editing a bookmark item or a folder. + * @ type (String). Possible values: + * - "bookmark" + * @ node (an nsINavHistoryResultNode object) - a node representing + * the bookmark. + * - "folder" + * @ node (an nsINavHistoryResultNode object) - a node representing + * the folder. + * @ hiddenRows (Strings array) - optional, list of rows to be hidden + * regardless of the item edited or added by the dialog. + * Possible values: + * - "title" + * - "location" + * - "keyword" + * - "tags" + * - "folderPicker" - hides both the tree and the menu. + * + * window.arguments[0].bookmarkGuid is set to the guid of the item, if the + * dialog is accepted. + */ + +/* import-globals-from editBookmark.js */ + +/* Shared Places Import - change other consumers if you change this: */ +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +ChromeUtils.defineESModuleGetters(this, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); +XPCOMUtils.defineLazyScriptGetter( + this, + "PlacesTreeView", + "chrome://browser/content/places/treeView.js" +); +XPCOMUtils.defineLazyScriptGetter( + this, + ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"], + "chrome://browser/content/places/controller.js" +); +/* End Shared Places Import */ + +const BOOKMARK_ITEM = 0; +const BOOKMARK_FOLDER = 1; + +const ACTION_EDIT = 0; +const ACTION_ADD = 1; + +var BookmarkPropertiesPanel = { + /** UI Text Strings */ + __strings: null, + get _strings() { + if (!this.__strings) { + this.__strings = document.getElementById("stringBundle"); + } + return this.__strings; + }, + + _action: null, + _itemType: null, + _uri: null, + _title: "", + _URIs: [], + _keyword: "", + _postData: null, + _charSet: "", + + _defaultInsertionPoint: null, + _hiddenRows: [], + + /** + * @returns {string} + * This method returns the correct label for the dialog's "accept" + * button based on the variant of the dialog. + */ + _getAcceptLabel: function BPP__getAcceptLabel() { + return this._strings.getString("dialogAcceptLabelSaveItem"); + }, + + /** + * @returns {string} + * This method returns the correct title for the current variant + * of this dialog. + */ + _getDialogTitle: function BPP__getDialogTitle() { + if (this._action == ACTION_ADD) { + if (this._itemType == BOOKMARK_ITEM) { + return this._strings.getString("dialogTitleAddNewBookmark2"); + } + + // add folder + if (this._itemType != BOOKMARK_FOLDER) { + throw new Error("Unknown item type"); + } + if (this._URIs.length) { + return this._strings.getString("dialogTitleAddMulti"); + } + + return this._strings.getString("dialogTitleAddBookmarkFolder"); + } + if (this._action == ACTION_EDIT) { + if (this._itemType === BOOKMARK_ITEM) { + return this._strings.getString("dialogTitleEditBookmark2"); + } + + return this._strings.getString("dialogTitleEditBookmarkFolder"); + } + return ""; + }, + + /** + * Determines the initial data for the item edited or added by this dialog + */ + async _determineItemInfo() { + let dialogInfo = window.arguments[0]; + this._action = dialogInfo.action == "add" ? ACTION_ADD : ACTION_EDIT; + this._hiddenRows = dialogInfo.hiddenRows ? dialogInfo.hiddenRows : []; + if (this._action == ACTION_ADD) { + if (!("type" in dialogInfo)) { + throw new Error("missing type property for add action"); + } + + if ("title" in dialogInfo) { + this._title = dialogInfo.title; + } + + if ("defaultInsertionPoint" in dialogInfo) { + this._defaultInsertionPoint = dialogInfo.defaultInsertionPoint; + } else { + let parentGuid = await PlacesUIUtils.defaultParentGuid; + this._defaultInsertionPoint = new PlacesInsertionPoint({ + parentGuid, + }); + } + + switch (dialogInfo.type) { + case "bookmark": + this._itemType = BOOKMARK_ITEM; + if ("uri" in dialogInfo) { + if (!(dialogInfo.uri instanceof Ci.nsIURI)) { + throw new Error("uri property should be a uri object"); + } + this._uri = dialogInfo.uri; + if (typeof this._title != "string") { + this._title = + (await PlacesUtils.history.fetch(this._uri)) || this._uri.spec; + } + } else { + this._uri = Services.io.newURI("about:blank"); + this._title = this._strings.getString("newBookmarkDefault"); + this._dummyItem = true; + } + + if ("keyword" in dialogInfo) { + this._keyword = dialogInfo.keyword; + this._isAddKeywordDialog = true; + if ("postData" in dialogInfo) { + this._postData = dialogInfo.postData; + } + if ("charSet" in dialogInfo) { + this._charSet = dialogInfo.charSet; + } + } + break; + + case "folder": + this._itemType = BOOKMARK_FOLDER; + if (!this._title) { + if ("URIList" in dialogInfo) { + this._title = this._strings.getString("bookmarkAllTabsDefault"); + this._URIs = dialogInfo.URIList; + } else { + this._title = this._strings.getString("newFolderDefault"); + this._dummyItem = true; + } + } + break; + } + } else { + // edit + this._node = dialogInfo.node; + this._title = this._node.title; + if (PlacesUtils.nodeIsFolder(this._node)) { + this._itemType = BOOKMARK_FOLDER; + } else if (PlacesUtils.nodeIsURI(this._node)) { + this._itemType = BOOKMARK_ITEM; + } + } + }, + + /** + * This method should be called by the onload of the Bookmark Properties + * dialog to initialize the state of the panel. + */ + async onDialogLoad() { + document.addEventListener("dialogaccept", function () { + BookmarkPropertiesPanel.onDialogAccept(); + }); + document.addEventListener("dialogcancel", function () { + BookmarkPropertiesPanel.onDialogCancel(); + }); + + // Disable the buttons until we have all the information required. + let acceptButton = document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept"); + acceptButton.disabled = true; + await this._determineItemInfo(); + document.title = this._getDialogTitle(); + + // Set adjustable title + let title = { raw: document.title }; + document.documentElement.setAttribute("headertitle", JSON.stringify(title)); + + let iconUrl = this._getIconUrl(); + if (iconUrl) { + document.documentElement.style.setProperty( + "--icon-url", + `url(${iconUrl})` + ); + } + + await this._initDialog(); + }, + + _getIconUrl() { + let url = "chrome://browser/skin/bookmark-hollow.svg"; + + if (this._action === ACTION_EDIT && this._itemType === BOOKMARK_ITEM) { + url = window.arguments[0]?.node?.icon; + } + + return url; + }, + + /** + * Initializes the dialog, gathering the required bookmark data. This function + * will enable the accept button (if appropraite) when it is complete. + */ + async _initDialog() { + let acceptButton = document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept"); + acceptButton.label = this._getAcceptLabel(); + let acceptButtonDisabled = false; + + // Since elements can be unhidden asynchronously, we must observe their + // mutations and resize the dialog accordingly. + this._mutationObserver = new MutationObserver(mutations => { + for (let { target, oldValue } of mutations) { + let hidden = target.getAttribute("hidden") == "true"; + if ( + target.classList.contains("hideable") && + hidden != (oldValue == "true") + ) { + // To support both kind of dialogs (window and dialog-box) we need + // both resizeBy and sizeToContent, otherwise either the dialog + // doesn't resize, or it gets empty unused space. + if (hidden) { + let diff = this._mutationObserver._heightsById.get(target.id); + window.resizeBy(0, -diff); + } else { + let diff = target.getBoundingClientRect().height; + this._mutationObserver._heightsById.set(target.id, diff); + window.resizeBy(0, diff); + } + window.sizeToContent(); + } + } + }); + this._mutationObserver._heightsById = new Map(); + this._mutationObserver.observe(document, { + subtree: true, + attributeOldValue: true, + attributeFilter: ["hidden"], + }); + + switch (this._action) { + case ACTION_EDIT: + await gEditItemOverlay.initPanel({ + node: this._node, + hiddenRows: this._hiddenRows, + focusedElement: "first", + }); + acceptButtonDisabled = gEditItemOverlay.readOnly; + break; + case ACTION_ADD: + this._node = await this._promiseNewItem(); + // Edit the new item + await gEditItemOverlay.initPanel({ + node: this._node, + hiddenRows: this._hiddenRows, + postData: this._postData, + focusedElement: "first", + }); + + // Empty location field if the uri is about:blank, this way inserting a new + // url will be easier for the user, Accept button will be automatically + // disabled by the input listener until the user fills the field. + let locationField = this._element("locationField"); + if (locationField.value == "about:blank") { + locationField.value = ""; + } + + // if this is an uri related dialog disable accept button until + // the user fills an uri value. + if (this._itemType == BOOKMARK_ITEM) { + acceptButtonDisabled = !this._inputIsValid(); + } + break; + } + + if (!gEditItemOverlay.readOnly) { + // Listen on uri fields to enable accept button if input is valid + if (this._itemType == BOOKMARK_ITEM) { + this._element("locationField").addEventListener("input", this); + if (this._isAddKeywordDialog) { + this._element("keywordField").addEventListener("input", this); + } + } + } + // Only enable the accept button once we've finished everything. + acceptButton.disabled = acceptButtonDisabled; + }, + + // EventListener + handleEvent: function BPP_handleEvent(aEvent) { + var target = aEvent.target; + switch (aEvent.type) { + case "input": + if ( + target.id == "editBMPanel_locationField" || + target.id == "editBMPanel_keywordField" + ) { + // Check uri fields to enable accept button if input is valid + document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept").disabled = !this._inputIsValid(); + } + break; + } + }, + + // nsISupports + QueryInterface: ChromeUtils.generateQI([]), + + _element: function BPP__element(aID) { + return document.getElementById("editBMPanel_" + aID); + }, + + onDialogUnload() { + // gEditItemOverlay does not exist anymore here, so don't rely on it. + this._mutationObserver.disconnect(); + delete this._mutationObserver; + + // Calling removeEventListener with arguments which do not identify any + // currently registered EventListener on the EventTarget has no effect. + this._element("locationField").removeEventListener("input", this); + this._element("keywordField").removeEventListener("input", this); + }, + + onDialogAccept() { + // We must blur current focused element to save its changes correctly + document.commandDispatcher.focusedElement?.blur(); + + // Get the states to compare bookmark and editedBookmark + window.arguments[0].bookmarkState = gEditItemOverlay._bookmarkState; + + // We have to uninit the panel first, otherwise late changes could force it + // to commit more transactions. + gEditItemOverlay.uninitPanel(true); + + window.arguments[0].bookmarkGuid = this._node.bookmarkGuid; + }, + + onDialogCancel() { + // We have to uninit the panel first, otherwise late changes could force it + // to commit more transactions. + gEditItemOverlay.uninitPanel(true); + }, + + /** + * This method checks to see if the input fields are in a valid state. + * + * @returns {boolean} true if the input is valid, false otherwise + */ + _inputIsValid: function BPP__inputIsValid() { + if ( + this._itemType == BOOKMARK_ITEM && + !this._containsValidURI("locationField") + ) { + return false; + } + if ( + this._isAddKeywordDialog && + !this._element("keywordField").value.length + ) { + return false; + } + + return true; + }, + + /** + * Determines whether the input with the given ID contains a + * string that can be converted into an nsIURI. + * + * @param {number} aTextboxID + * the ID of the textbox element whose contents we'll test + * + * @returns {boolean} true if the textbox contains a valid URI string, false otherwise + */ + _containsValidURI: function BPP__containsValidURI(aTextboxID) { + try { + var value = this._element(aTextboxID).value; + if (value) { + Services.uriFixup.getFixupURIInfo(value); + return true; + } + } catch (e) {} + return false; + }, + + /** + * [New Item Mode] Get the insertion point details for the new item, given + * dialog state and opening arguments. + * + * The container-identifier and insertion-index are returned separately in + * the form of [containerIdentifier, insertionIndex] + */ + async _getInsertionPointDetails() { + return [ + await this._defaultInsertionPoint.getIndex(), + this._defaultInsertionPoint.guid, + ]; + }, + + async _promiseNewItem() { + let [index, parentGuid] = await this._getInsertionPointDetails(); + + let info = { parentGuid, index, title: this._title }; + if (this._itemType == BOOKMARK_ITEM) { + info.url = this._uri; + if (this._keyword) { + info.keyword = this._keyword; + } + if (this._postData) { + info.postData = this._postData; + } + + if (this._charSet) { + PlacesUIUtils.setCharsetForPage(this._uri, this._charSet, window).catch( + console.error + ); + } + } else if (this._itemType == BOOKMARK_FOLDER) { + // NewFolder requires a url rather than uri. + info.children = this._URIs.map(item => { + return { url: item.uri, title: item.title }; + }); + } else { + throw new Error(`unexpected value for _itemType: ${this._itemType}`); + } + return Object.freeze({ + index, + bookmarkGuid: PlacesUtils.bookmarks.unsavedGuid, + title: this._title, + uri: this._uri ? this._uri.spec : "", + type: + this._itemType == BOOKMARK_ITEM + ? Ci.nsINavHistoryResultNode.RESULT_TYPE_URI + : Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER, + parent: { + bookmarkGuid: parentGuid, + type: Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER, + }, + children: info.children, + }); + }, +}; + +document.addEventListener("DOMContentLoaded", function () { + // Content initialization is asynchronous, thus set mozSubdialogReady + // immediately to properly wait for it. + document.mozSubdialogReady = BookmarkPropertiesPanel.onDialogLoad() + .catch(ex => console.error(`Failed to initialize dialog: ${ex}`)) + .then(() => window.sizeToContent()); +}); diff --git a/browser/components/places/content/bookmarkProperties.xhtml b/browser/components/places/content/bookmarkProperties.xhtml new file mode 100644 index 0000000000..047652a52e --- /dev/null +++ b/browser/components/places/content/bookmarkProperties.xhtml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + Interactions +
    +
    + Places Stats +
    +
    + +
    +
    +
    +

    +
    +
    +
    + + diff --git a/browser/components/places/metadataViewer/interactionsViewer.js b/browser/components/places/metadataViewer/interactionsViewer.js new file mode 100644 index 0000000000..a6a3a0e4c1 --- /dev/null +++ b/browser/components/places/metadataViewer/interactionsViewer.js @@ -0,0 +1,427 @@ +/* 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 module */ + +const { Interactions } = ChromeUtils.importESModule( + "resource:///modules/Interactions.sys.mjs" +); +const { PlacesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesUtils.sys.mjs" +); +const { PlacesDBUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesDBUtils.sys.mjs" +); + +/** + * Base class for the table display. Handles table layout and updates. + */ +class TableViewer { + /** + * Maximum number of rows to display by default. + * + * @type {number} + */ + maxRows = 100; + + /** + * The number of rows that we last filled in on the table. This allows + * tracking to know when to clear unused rows. + * + * @type {number} + */ + #lastFilledRows = 0; + + /** + * A map of columns that are displayed by default. This is set by sub-classes. + * + * - The key is the column name in the database. + * - The header is the column header on the table. + * - The modifier is a function to modify the returned value from the database + * for display. + * - includeTitle determines if the title attribute should be set on that + * column, for tooltips, e.g. if an element is likely to overflow. + * + * @type {Map} + */ + columnMap; + + /** + * A reference for the current interval timer, if any. + * + * @type {number} + */ + #timer; + + /** + * Starts the display of the table. Setting up the table display and doing + * an initial output. Also starts the interval timer. + */ + async start() { + this.setupUI(); + await this.updateDisplay(); + this.#timer = setInterval(this.updateDisplay.bind(this), 10000); + } + + /** + * Pauses updates for this table, use start() to re-start. + */ + pause() { + if (this.#timer) { + clearInterval(this.#timer); + this.#timer = null; + } + } + + /** + * Creates the initial table layout and sets the styles to match the number + * of columns. + */ + setupUI() { + document.getElementById("title").textContent = this.title; + + let viewer = document.getElementById("tableViewer"); + viewer.textContent = ""; + + // Set up the table styles. + let existingStyle = document.getElementById("tableStyle"); + let numColumns = this.columnMap.size; + let styleText = ` +#tableViewer { + display: grid; + grid-template-columns: ${this.cssGridTemplateColumns} +} + +/* Sets the first row of elements to bold. The number is the number of columns */ +#tableViewer > div:nth-child(-n+${numColumns}) { + font-weight: bold; + white-space: break-spaces; +} + +/* Highlights every other row to make visual scanning of the table easier. + The numbers need to be adapted if the number of columns changes. */ +`; + for (let i = numColumns + 1; i <= numColumns * 2 - 1; i++) { + styleText += `#tableViewer > div:nth-child(${numColumns}n+${i}):nth-child(${ + numColumns * 2 + }n+${i}),\n`; + } + styleText += `#tableViewer > div:nth-child(${numColumns}n+${ + numColumns * 2 + }):nth-child(${numColumns * 2}n+${numColumns * 2})\n +{ + background: var(--in-content-box-background-odd); +}`; + existingStyle.innerText = styleText; + + // Now set up the table itself with empty cells, this avoids having to + // create and delete rows all the time. + let tableBody = document.createDocumentFragment(); + let header = document.createDocumentFragment(); + for (let details of this.columnMap.values()) { + let columnDiv = document.createElement("div"); + columnDiv.textContent = details.header; + header.appendChild(columnDiv); + } + tableBody.appendChild(header); + + for (let i = 0; i < this.maxRows; i++) { + let row = document.createDocumentFragment(); + for (let j = 0; j < this.columnMap.size; j++) { + row.appendChild(document.createElement("div")); + } + tableBody.appendChild(row); + } + viewer.appendChild(tableBody); + + let limit = document.getElementById("tableLimit"); + limit.textContent = `Maximum rows displayed: ${this.maxRows}.`; + + this.#lastFilledRows = 0; + } + + /** + * Displays the provided data in the table. + * + * @param {object[]} rows + * An array of rows to display. The rows are objects with the values for + * the rows being the keys of the columnMap. + */ + displayData(rows) { + if (gCurrentHandler != this) { + /* Data is no more relevant for the current view. */ + return; + } + let viewer = document.getElementById("tableViewer"); + let index = this.columnMap.size; + for (let row of rows) { + for (let [column, details] of this.columnMap.entries()) { + let value = row[column]; + + if (details.includeTitle) { + viewer.children[index].setAttribute("title", value); + } + + viewer.children[index].textContent = details.modifier + ? details.modifier(value) + : value; + + index++; + } + } + let numRows = rows.length; + if (numRows < this.#lastFilledRows) { + for (let r = numRows; r < this.#lastFilledRows; r++) { + for (let c = 0; c < this.columnMap.size; c++) { + viewer.children[index].textContent = ""; + viewer.children[index].removeAttribute("title"); + index++; + } + } + } + this.#lastFilledRows = numRows; + } +} + +/** + * Viewer definition for the page metadata. + */ +const metadataHandler = new (class extends TableViewer { + title = "Interactions"; + cssGridTemplateColumns = + "max-content fit-content(100%) repeat(6, min-content) fit-content(100%);"; + + /** + * @see TableViewer.columnMap + */ + columnMap = new Map([ + ["id", { header: "ID" }], + ["url", { header: "URL", includeTitle: true }], + [ + "updated_at", + { + header: "Updated", + modifier: updatedAt => new Date(updatedAt).toLocaleString(), + }, + ], + [ + "total_view_time", + { + header: "View Time (s)", + modifier: totalViewTime => (totalViewTime / 1000).toFixed(2), + }, + ], + [ + "typing_time", + { + header: "Typing Time (s)", + modifier: typingTime => (typingTime / 1000).toFixed(2), + }, + ], + ["key_presses", { header: "Key Presses" }], + [ + "scrolling_time", + { + header: "Scroll Time (s)", + modifier: scrollingTime => (scrollingTime / 1000).toFixed(2), + }, + ], + ["scrolling_distance", { header: "Scroll Distance (pixels)" }], + ["referrer", { header: "Referrer", includeTitle: true }], + ]); + + /** + * A reference to the database connection. + * + * @type {mozIStorageConnection} + */ + #db = null; + + async #getRows(query, columns = [...this.columnMap.keys()]) { + if (!this.#db) { + this.#db = await PlacesUtils.promiseDBConnection(); + } + let rows = await this.#db.executeCached(query); + return rows.map(r => { + let result = {}; + for (let column of columns) { + result[column] = r.getResultByName(column); + } + return result; + }); + } + + /** + * Loads the current metadata from the database and updates the display. + */ + async updateDisplay() { + let rows = await this.#getRows( + `SELECT m.id AS id, h.url AS url, updated_at, total_view_time, + typing_time, key_presses, scrolling_time, scrolling_distance, h2.url as referrer + FROM moz_places_metadata m + JOIN moz_places h ON h.id = m.place_id + LEFT JOIN moz_places h2 ON h2.id = m.referrer_place_id + ORDER BY updated_at DESC + LIMIT ${this.maxRows}` + ); + this.displayData(rows); + } + + export() { + // Export all data. We only export place_id and not url so users can share their exports + // without revealing the sites they have been visiting. + return this.#getRows( + `SELECT + m.id, + m.place_id, + m.referrer_place_id, + h.origin_id, + m.updated_at, + m.total_view_time, + h.visit_count, + h.frecency, + m.typing_time, + m.key_presses, + m.scrolling_time, + m.scrolling_distance, + vall.visit_dates, + vall.visit_types + FROM moz_places_metadata m + JOIN moz_places h ON h.id = m.place_id + JOIN + (SELECT + place_id, + group_concat(visit_date, ',') AS visit_dates, + group_concat(visit_type, ',') AS visit_types + FROM moz_historyvisits + GROUP BY place_id + ORDER BY visit_date DESC + ) vall ON vall.place_id = m.place_id + ORDER BY m.place_id DESC + `, + [ + "id", + "place_id", + "referrer_place_id", + "origin_id", + "updated_at", + "total_view_time", + "visit_count", + "frecency", + "typing_time", + "key_presses", + "scrolling_time", + "scrolling_distance", + "visit_dates", + "visit_types", + ] + ); + } +})(); + +/** + * Viewer definition for the Places database stats. + */ +const placesStatsHandler = new (class extends TableViewer { + title = "Places Database Statistics"; + cssGridTemplateColumns = "fit-content(100%) repeat(5, max-content);"; + + /** + * @see TableViewer.columnMap + */ + columnMap = new Map([ + ["entity", { header: "Entity" }], + ["count", { header: "Count" }], + [ + "sizeBytes", + { + header: "Size (KiB)", + modifier: c => c / 1024, + }, + ], + [ + "sizePerc", + { + header: "Size (Perc.)", + }, + ], + [ + "efficiencyPerc", + { + header: "Space Eff. (Perc.)", + }, + ], + [ + "sequentialityPerc", + { + header: "Sequentiality (Perc.)", + }, + ], + ]); + + /** + * Loads the current metadata from the database and updates the display. + */ + async updateDisplay() { + let data = await PlacesDBUtils.getEntitiesStatsAndCounts(); + this.displayData(data); + } +})(); + +function checkPrefs() { + if ( + !Services.prefs.getBoolPref("browser.places.interactions.enabled", false) + ) { + let warning = document.getElementById("enabledWarning"); + warning.hidden = false; + } +} + +function show(selectedButton) { + let currentButton = document.querySelector(".category.selected"); + if (currentButton == selectedButton) { + return; + } + + gCurrentHandler.pause(); + currentButton.classList.remove("selected"); + selectedButton.classList.add("selected"); + switch (selectedButton.getAttribute("value")) { + case "metadata": + (gCurrentHandler = metadataHandler).start(); + metadataHandler.start(); + break; + case "places-stats": + (gCurrentHandler = placesStatsHandler).start(); + break; + } +} + +function setupListeners() { + let menu = document.getElementById("categories"); + menu.addEventListener("click", e => { + if (e.target && e.target.parentNode == menu) { + show(e.target); + } + }); + document.getElementById("export").addEventListener("click", async e => { + e.preventDefault(); + const data = await metadataHandler.export(); + + const blob = new Blob([JSON.stringify(data)], { + type: "text/json;charset=utf-8", + }); + const a = document.createElement("a"); + a.setAttribute("download", `places-${Date.now()}.json`); + a.setAttribute("href", window.URL.createObjectURL(blob)); + a.click(); + a.remove(); + }); +} + +checkPrefs(); +// Set the initial handler here. +let gCurrentHandler = metadataHandler; +gCurrentHandler.start().catch(console.error); +setupListeners(); diff --git a/browser/components/places/moz.build b/browser/components/places/moz.build new file mode 100644 index 0000000000..496e83089d --- /dev/null +++ b/browser/components/places/moz.build @@ -0,0 +1,33 @@ +# -*- 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/. + +XPCSHELL_TESTS_MANIFESTS += [ + "tests/unit/xpcshell.toml", +] +MOCHITEST_CHROME_MANIFESTS += ["tests/chrome/chrome.toml"] +BROWSER_CHROME_MANIFESTS += [ + "tests/browser/browser.toml", + "tests/browser/interactions/browser.toml", +] +MARIONETTE_MANIFESTS += ["tests/marionette/manifest.toml"] + +JAR_MANIFESTS += ["jar.mn"] + +SPHINX_TREES["/browser/places"] = "docs" + +EXTRA_JS_MODULES += [ + "Interactions.sys.mjs", + "InteractionsBlocklist.sys.mjs", + "PlacesUIUtils.sys.mjs", +] + +FINAL_TARGET_FILES.actors += [ + "InteractionsChild.sys.mjs", + "InteractionsParent.sys.mjs", +] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Bookmarks & History") diff --git a/browser/components/places/tests/browser/bookmark_dummy_1.html b/browser/components/places/tests/browser/bookmark_dummy_1.html new file mode 100644 index 0000000000..c03e0c18c0 --- /dev/null +++ b/browser/components/places/tests/browser/bookmark_dummy_1.html @@ -0,0 +1,9 @@ + + +Bookmark Dummy 1 + + + +

    Bookmark Dummy 1

    + + diff --git a/browser/components/places/tests/browser/bookmark_dummy_2.html b/browser/components/places/tests/browser/bookmark_dummy_2.html new file mode 100644 index 0000000000..229a730b32 --- /dev/null +++ b/browser/components/places/tests/browser/bookmark_dummy_2.html @@ -0,0 +1,9 @@ + + +Bookmark Dummy 2 + + + +

    Bookmark Dummy 2

    + + diff --git a/browser/components/places/tests/browser/bookmarklet_windowOpen_dummy.html b/browser/components/places/tests/browser/bookmarklet_windowOpen_dummy.html new file mode 100644 index 0000000000..54a87d3247 --- /dev/null +++ b/browser/components/places/tests/browser/bookmarklet_windowOpen_dummy.html @@ -0,0 +1,9 @@ + + +Bookmarklet windowOpen Dummy + + + +

    Bookmarklet windowOpen Dummy

    + + diff --git a/browser/components/places/tests/browser/browser.toml b/browser/components/places/tests/browser/browser.toml new file mode 100644 index 0000000000..1b0e2571d4 --- /dev/null +++ b/browser/components/places/tests/browser/browser.toml @@ -0,0 +1,236 @@ +# 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/. + +[DEFAULT] +support-files = [ + "head.js", + "framedPage.html", + "frameLeft.html", + "frameRight.html", + "sidebarpanels_click_test_page.html", + "keyword_form.html", +] + +["browser_addBookmarkForFrame.js"] + +["browser_autoshow_bookmarks_toolbar.js"] + +["browser_bookmarkMenu_hiddenWindow.js"] +skip-if = ["os != 'mac'"] # Mac-only functionality + +["browser_bookmarkProperties_addFolderDefaultButton.js"] + +["browser_bookmarkProperties_addKeywordForThisSearch.js"] + +["browser_bookmarkProperties_bookmarkAllTabs.js"] + +["browser_bookmarkProperties_cancel.js"] + +["browser_bookmarkProperties_editFolder.js"] + +["browser_bookmarkProperties_editTagContainer.js"] + +["browser_bookmarkProperties_folderSelection.js"] + +["browser_bookmarkProperties_newFolder.js"] + +["browser_bookmarkProperties_no_user_actions.js"] + +["browser_bookmarkProperties_readOnlyRoot.js"] + +["browser_bookmarkProperties_remember_folders.js"] + +["browser_bookmarkProperties_speculativeConnection.js"] + +["browser_bookmarkProperties_xulStore.js"] + +["browser_bookmark_add_tags.js"] +https_first_disabled = true + +["browser_bookmark_all_tabs.js"] +https_first_disabled = true +support-files = [ + "bookmark_dummy_1.html", + "bookmark_dummy_2.html", +] + +["browser_bookmark_backup_export_import.js"] + +["browser_bookmark_change_location.js"] + +["browser_bookmark_context_menu_contents.js"] + +["browser_bookmark_copy_folder_tree.js"] + +["browser_bookmark_folder_moveability.js"] + +["browser_bookmark_menu_ctrl_click.js"] + +["browser_bookmark_popup.js"] +skip-if = ["verify && os == 'win'"] + +["browser_bookmark_private_window.js"] + +["browser_bookmark_remove_tags.js"] + +["browser_bookmark_titles.js"] +https_first_disabled = true +support-files = ["../../../../base/content/test/general/dummy_page.html"] + +["browser_bookmarklet_windowOpen.js"] +support-files = ["bookmarklet_windowOpen_dummy.html"] + +["browser_bookmarksProperties.js"] + +["browser_bookmarks_change_title.js"] + +["browser_bookmarks_change_url.js"] + +["browser_bookmarks_sidebar_search.js"] +support-files = ["pageopeningwindow.html"] + +["browser_bookmarks_toolbar_context_menu_view_options.js"] + +["browser_bookmarks_toolbar_telemetry.js"] + +["browser_bug427633_no_newfolder_if_noip.js"] + +["browser_bug485100-change-case-loses-tag.js"] + +["browser_bug631374_tags_selector_scroll.js"] +support-files = ["favicon-normal16.png"] + +["browser_check_correct_controllers.js"] + +["browser_click_bookmarks_on_toolbar.js"] +https_first_disabled = true + +["browser_controller_onDrop.js"] + +["browser_controller_onDrop_query.js"] + +["browser_controller_onDrop_sidebar.js"] + +["browser_controller_onDrop_tagFolder.js"] + +["browser_copy_query_without_tree.js"] + +["browser_cutting_bookmarks.js"] + +["browser_default_bookmark_location.js"] + +["browser_drag_bookmarks_on_toolbar.js"] + +["browser_drag_folder_on_newTab.js"] +https_first_disabled = true + +["browser_editBookmark_keywords.js"] + +["browser_enable_toolbar_sidebar.js"] +skip-if = ["verify && debug && os == 'win'"] + +["browser_forgetthissite.js"] + +["browser_history_sidebar_search.js"] + +["browser_import_button.js"] + +["browser_library_bookmark_clear_visits.js"] + +["browser_library_bookmark_pages.js"] + +["browser_library_bulk_tag_bookmarks.js"] + +["browser_library_commands.js"] + +["browser_library_delete.js"] + +["browser_library_delete_bookmarks_in_tags.js"] + +["browser_library_delete_tags.js"] + +["browser_library_downloads.js"] + +["browser_library_left_pane_middleclick.js"] + +["browser_library_left_pane_select_hierarchy.js"] + +["browser_library_middleclick.js"] + +["browser_library_new_bookmark.js"] + +["browser_library_openFlatContainer.js"] + +["browser_library_open_all.js"] + +["browser_library_open_all_with_separator.js"] + +["browser_library_open_bookmark.js"] + +["browser_library_open_leak.js"] + +["browser_library_panel_leak.js"] + +["browser_library_sameNodeDetailsPaneOptimization.js"] + +["browser_library_search.js"] + +["browser_library_tags_visibility.js"] + +["browser_library_telemetry.js"] + +["browser_library_tree_leak.js"] + +["browser_library_views_liveupdate.js"] + +["browser_library_warnOnOpen.js"] + +["browser_markPageAsFollowedLink.js"] + +["browser_panelview_bookmarks_delete.js"] + +["browser_paste_bookmarks.js"] + +["browser_paste_into_tags.js"] + +["browser_paste_resets_cut_highlights.js"] + +["browser_remove_bookmarks.js"] + +["browser_sidebar_bookmarks_telemetry.js"] + +["browser_sidebar_history_telemetry.js"] + +["browser_sidebar_on_customization.js"] + +["browser_sidebar_open_bookmarks.js"] + +["browser_sidebarpanels_click.js"] + +["browser_sort_in_library.js"] + +["browser_stayopenmenu.js"] + +["browser_toolbar_drop_bookmarklet.js"] + +["browser_toolbar_drop_multiple_flavors.js"] + +["browser_toolbar_drop_multiple_with_bookmarklet.js"] + +["browser_toolbar_drop_text.js"] + +["browser_toolbar_library_open_recent.js"] +https_first_disabled = true + +["browser_toolbar_other_bookmarks.js"] + +["browser_toolbar_overflow.js"] + +["browser_toolbarbutton_menu_context.js"] + +["browser_toolbarbutton_menu_show_in_folder.js"] + +["browser_views_iconsupdate.js"] + +["browser_views_liveupdate.js"] diff --git a/browser/components/places/tests/browser/browser_addBookmarkForFrame.js b/browser/components/places/tests/browser/browser_addBookmarkForFrame.js new file mode 100644 index 0000000000..44a7f5e17a --- /dev/null +++ b/browser/components/places/tests/browser/browser_addBookmarkForFrame.js @@ -0,0 +1,150 @@ +/** + * Tests that the add bookmark for frame dialog functions correctly. + */ + +const BASE_URL = + "http://mochi.test:8888/browser/browser/components/places/tests/browser"; +const PAGE_URL = BASE_URL + "/framedPage.html"; +const LEFT_URL = BASE_URL + "/frameLeft.html"; +const RIGHT_URL = BASE_URL + "/frameRight.html"; + +function activateBookmarkFrame(contentAreaContextMenu) { + let popupHiddenPromise = BrowserTestUtils.waitForEvent( + contentAreaContextMenu, + "popuphidden" + ); + return async function () { + let frameMenuItem = document.getElementById("frame"); + let frameMenu = frameMenuItem.querySelector(":scope > menupopup"); + let frameMenuShown = BrowserTestUtils.waitForEvent(frameMenu, "popupshown"); + frameMenuItem.openMenu(true); + await frameMenuShown; + let bookmarkFrame = document.getElementById("context-bookmarkframe"); + frameMenu.activateItem(bookmarkFrame); + await popupHiddenPromise; + }; +} + +async function withAddBookmarkForFrame(taskFn) { + // Open a tab and wait for all the subframes to load. + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE_URL); + + let contentAreaContextMenu = document.getElementById( + "contentAreaContextMenu" + ); + + let popupShownPromise = BrowserTestUtils.waitForEvent( + contentAreaContextMenu, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#left", + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + await popupShownPromise; + + await withBookmarksDialog( + true, + activateBookmarkFrame(contentAreaContextMenu), + taskFn + ); + + BrowserTestUtils.removeTab(tab); +} + +add_task(async function test_open_add_bookmark_for_frame() { + info("Test basic opening of the add bookmark for frame dialog."); + await withAddBookmarkForFrame(async dialogWin => { + let namepicker = dialogWin.document.getElementById( + "editBMPanel_namePicker" + ); + Assert.ok(!namepicker.readOnly, "Name field is writable"); + Assert.equal(namepicker.value, "Left frame", "Name field is correct."); + + let expectedFolder = "BookmarksToolbarFolderTitle"; + let expectedFolderName = PlacesUtils.getString(expectedFolder); + + let folderPicker = dialogWin.document.getElementById( + "editBMPanel_folderMenuList" + ); + + await TestUtils.waitForCondition( + () => folderPicker.selectedItem.label == expectedFolderName, + "Dialog: The folder is the expected one." + ); + + let tagsField = dialogWin.document.getElementById("editBMPanel_tagsField"); + Assert.equal(tagsField.value, "", "Dialog: The tags field should be empty"); + }); +}); + +add_task(async function test_move_bookmark_whilst_add_bookmark_open() { + info( + "EditBookmark: Test moving a bookmark whilst the add bookmark for frame dialog is open." + ); + await PlacesUtils.bookmarks.eraseEverything(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE_URL); + + let contentAreaContextMenu = document.getElementById( + "contentAreaContextMenu" + ); + + let popupShownPromise = BrowserTestUtils.waitForEvent( + contentAreaContextMenu, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#left", + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + await popupShownPromise; + + await withBookmarksDialog( + false, + activateBookmarkFrame(contentAreaContextMenu), + async function (dialogWin) { + let expectedGuid = await PlacesUIUtils.defaultParentGuid; + let expectedFolder = "BookmarksToolbarFolderTitle"; + let expectedFolderName = PlacesUtils.getString(expectedFolder); + + let folderPicker = dialogWin.document.getElementById( + "editBMPanel_folderMenuList" + ); + + Assert.equal( + folderPicker.selectedItem.label, + expectedFolderName, + "EditBookmark: The folder is the expected one." + ); + + Assert.equal( + folderPicker.getAttribute("selectedGuid"), + expectedGuid, + "EditBookmark: Should have the correct default guid selected" + ); + + dialogWin.document.getElementById("editBMPanel_foldersExpander").click(); + let folderTree = dialogWin.document.getElementById( + "editBMPanel_folderTree" + ); + folderTree.selectItems([PlacesUtils.bookmarks.menuGuid]); + folderTree.blur(); + + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + } + ); + let url = makeURI(LEFT_URL); + // Check the bookmark has been moved as expected. + let bookmark = await PlacesUtils.bookmarks.fetch({ url }); + + Assert.equal( + bookmark.parentGuid, + PlacesUtils.bookmarks.menuGuid, + "EditBookmark: The bookmark should be moved to the expected folder." + ); + + BrowserTestUtils.removeTab(tab); + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/browser/components/places/tests/browser/browser_autoshow_bookmarks_toolbar.js b/browser/components/places/tests/browser/browser_autoshow_bookmarks_toolbar.js new file mode 100644 index 0000000000..c841eb276b --- /dev/null +++ b/browser/components/places/tests/browser/browser_autoshow_bookmarks_toolbar.js @@ -0,0 +1,165 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const LOCATION_PREF = "browser.bookmarks.defaultLocation"; +const TOOLBAR_VISIBILITY_PREF = "browser.toolbars.bookmarks.visibility"; +let bookmarkPanel; +let win; + +add_setup(async function () { + Services.prefs.clearUserPref(LOCATION_PREF); + await PlacesUtils.bookmarks.eraseEverything(); + + win = await BrowserTestUtils.openNewBrowserWindow(); + + let oldTimeout = win.StarUI._autoCloseTimeout; + // Make the timeout something big, so it doesn't interact badly with tests. + win.StarUI._autoCloseTimeout = 6000000; + + win.StarUI._createPanelIfNeeded(); + bookmarkPanel = win.document.getElementById("editBookmarkPanel"); + bookmarkPanel.setAttribute("animate", false); + + registerCleanupFunction(async () => { + bookmarkPanel = null; + win.StarUI._autoCloseTimeout = oldTimeout; + await BrowserTestUtils.closeWindow(win); + win = null; + await PlacesUtils.bookmarks.eraseEverything(); + Services.prefs.clearUserPref(LOCATION_PREF); + }); +}); + +/** + * Helper to check we've shown the toolbar + * + * @param {object} options + * Options for the test + * @param {boolean} options.showToolbar + * If the toolbar should be shown or not + * @param {string} options.expectedFolder + * The expected folder to be shown + * @param {string} options.reason + * The reason the toolbar should be shown + */ +async function checkResponse({ showToolbar, expectedFolder, reason }) { + // Check folder. + let menuList = win.document.getElementById("editBMPanel_folderMenuList"); + + Assert.equal( + menuList.label, + PlacesUtils.getString(expectedFolder), + `Should have ${expectedFolder} selected ${reason}.` + ); + + // Check toolbar: + let toolbar = win.document.getElementById("PersonalToolbar"); + Assert.equal( + !toolbar.collapsed, + showToolbar, + `Toolbar should be ${showToolbar ? "visible" : "hidden"} ${reason}.` + ); + + // Confirm and close the dialog. + let hiddenPromise = promisePopupHidden( + win.document.getElementById("editBookmarkPanel") + ); + win.document.getElementById("editBookmarkPanelRemoveButton").click(); + await hiddenPromise; +} + +/** + * Test that if we create a bookmark on the toolbar, we show the + * toolbar: + */ +add_task(async function test_new_on_toolbar() { + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url: "https://example.com/1" }, + async browser => { + let toolbar = win.document.getElementById("PersonalToolbar"); + Assert.equal( + toolbar.collapsed, + true, + "Bookmarks toolbar should start out collapsed." + ); + let shownPromise = promisePopupShown( + win.document.getElementById("editBookmarkPanel") + ); + win.document.getElementById("Browser:AddBookmarkAs").doCommand(); + await shownPromise; + await TestUtils.waitForCondition( + () => !toolbar.collapsed, + "Toolbar should be shown." + ); + let expectedFolder = "BookmarksToolbarFolderTitle"; + let reason = "when creating a bookmark there"; + await checkResponse({ showToolbar: true, expectedFolder, reason }); + } + ); +}); + +/** + * Test that if we create a bookmark on the toolbar, we do not + * show the toolbar if toolbar should never be shown: + */ +add_task(async function test_new_on_toolbar_never_show_toolbar() { + await SpecialPowers.pushPrefEnv({ + set: [[TOOLBAR_VISIBILITY_PREF, "never"]], + }); + + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url: "https://example.com/1" }, + async browser => { + let toolbar = win.document.getElementById("PersonalToolbar"); + Assert.equal( + toolbar.collapsed, + true, + "Bookmarks toolbar should start out collapsed." + ); + let shownPromise = promisePopupShown( + win.document.getElementById("editBookmarkPanel") + ); + win.document.getElementById("Browser:AddBookmarkAs").doCommand(); + await shownPromise; + let expectedFolder = "BookmarksToolbarFolderTitle"; + let reason = "when the visibility pref is 'never'"; + await checkResponse({ showToolbar: false, expectedFolder, reason }); + } + ); + + await SpecialPowers.popPrefEnv(); +}); + +/** + * Test that if we edit an existing bookmark, we don't show the toolbar. + */ +add_task(async function test_existing_on_toolbar() { + // Create the bookmark first: + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "Test for editing", + url: "https://example.com/editing-test", + }); + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url: "https://example.com/editing-test" }, + async browser => { + await TestUtils.waitForCondition( + () => win.BookmarkingUI.status == BookmarkingUI.STATUS_STARRED, + "Page should be starred." + ); + + let toolbar = win.document.getElementById("PersonalToolbar"); + Assert.equal( + toolbar.collapsed, + true, + "Bookmarks toolbar should start out collapsed." + ); + await clickBookmarkStar(win); + let expectedFolder = "BookmarksToolbarFolderTitle"; + let reason = "when editing a bookmark there"; + await checkResponse({ showToolbar: false, expectedFolder, reason }); + } + ); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkMenu_hiddenWindow.js b/browser/components/places/tests/browser/browser_bookmarkMenu_hiddenWindow.js new file mode 100644 index 0000000000..4502b65e69 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkMenu_hiddenWindow.js @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_setup(async function () { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://example.com/", + title: "Test1", + }); + + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_menu_in_hidden_window() { + let hwDoc = Services.appShell.hiddenDOMWindow.document; + let bmPopup = hwDoc.getElementById("bookmarksMenuPopup"); + var popupEvent = hwDoc.createEvent("MouseEvent"); + popupEvent.initMouseEvent( + "popupshowing", + true, + true, + Services.appShell.hiddenDOMWindow, + 0, + 0, + 0, + 0, + 0, + false, + false, + false, + false, + 0, + null + ); + bmPopup.dispatchEvent(popupEvent); + + let testMenuitem = [...bmPopup.children].find( + node => node.getAttribute("label") == "Test1" + ); + Assert.ok( + testMenuitem, + "Should have found the test bookmark in the hidden window bookmark menu" + ); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_addFolderDefaultButton.js b/browser/components/places/tests/browser/browser_bookmarkProperties_addFolderDefaultButton.js new file mode 100644 index 0000000000..0c04dbd243 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_addFolderDefaultButton.js @@ -0,0 +1,68 @@ +"use strict"; + +add_task(async function add_folder_default_button() { + info( + "Bug 475529 - Add is the default button for the new folder dialog + " + + "Bug 1206376 - Changing properties of a new bookmark while adding it " + + "acts on the last bookmark in the current container" + ); + + // Add a new bookmark at index 0 in the unfiled folder. + let insertionIndex = 0; + let newBookmark = await PlacesUtils.bookmarks.insert({ + index: insertionIndex, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/", + }); + + await withSidebarTree("bookmarks", async function (tree) { + // Select the new bookmark in the sidebar. + tree.selectItems([newBookmark.guid]); + Assert.ok( + tree.controller.isCommandEnabled("placesCmd_new:folder"), + "'placesCmd_new:folder' on current selected node is enabled" + ); + + // Create a new folder. Since the new bookmark is selected, and new items + // are inserted at the index of the currently selected item, the new folder + // will be inserted at index 0. + await withBookmarksDialog( + false, + function openDialog() { + tree.controller.doCommand("placesCmd_new:folder"); + }, + async function test(dialogWin) { + const notifications = [ + PlacesTestUtils.waitForNotification("bookmark-added", events => + events.some(e => e.title === "n") + ), + PlacesTestUtils.waitForNotification("bookmark-moved", null), + ]; + + fillBookmarkTextField("editBMPanel_namePicker", "n", dialogWin, false); + + // Confirm and close the dialog. + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + await Promise.all(notifications); + + let newFolder = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: insertionIndex, + }); + + is(newFolder.title, "n", "folder name has been edited"); + + let bm = await PlacesUtils.bookmarks.fetch(newBookmark.guid); + Assert.equal( + bm.index, + insertionIndex + 1, + "Bookmark should have been shifted to the next index" + ); + + await PlacesUtils.bookmarks.remove(newFolder); + await PlacesUtils.bookmarks.remove(newBookmark); + } + ); + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_addKeywordForThisSearch.js b/browser/components/places/tests/browser/browser_bookmarkProperties_addKeywordForThisSearch.js new file mode 100644 index 0000000000..514519810a --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_addKeywordForThisSearch.js @@ -0,0 +1,188 @@ +"use strict"; + +const TEST_URL = + "http://mochi.test:8888/browser/browser/components/places/tests/browser/keyword_form.html"; + +let contentAreaContextMenu = document.getElementById("contentAreaContextMenu"); + +add_task(async function add_keyword() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL, + }, + async function (browser) { + // We must wait for the context menu code to build metadata. + await openContextMenuForContentSelector( + browser, + '#form1 > input[name="search"]' + ); + + await withBookmarksDialog( + false, + function () { + AddKeywordForSearchField(); + contentAreaContextMenu.hidePopup(); + }, + async function (dialogWin) { + let acceptBtn = dialogWin.document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept"); + Assert.ok(acceptBtn.disabled, "Accept button is disabled"); + + let promiseKeywordNotification = PlacesTestUtils.waitForNotification( + "bookmark-keyword-changed", + events => events.some(event => event.keyword === "kw") + ); + + fillBookmarkTextField("editBMPanel_keywordField", "kw", dialogWin); + + Assert.ok(!acceptBtn.disabled, "Accept button is enabled"); + + acceptBtn.click(); + await promiseKeywordNotification; + + // After the notification, the keywords cache will update asynchronously. + info("Check the keyword entry has been created"); + let entry; + await TestUtils.waitForCondition(async function () { + entry = await PlacesUtils.keywords.fetch("kw"); + return !!entry; + }, "Unable to find the expected keyword"); + Assert.equal(entry.keyword, "kw", "keyword is correct"); + Assert.equal(entry.url.href, TEST_URL, "URL is correct"); + Assert.equal( + entry.postData, + "accenti%3D%E0%E8%EC%F2%F9&search%3D%25s", + "POST data is correct" + ); + let bm = await PlacesUtils.bookmarks.fetch({ url: TEST_URL }); + Assert.equal( + bm.parentGuid, + await PlacesUIUtils.defaultParentGuid, + "Should have created the keyword in the right folder." + ); + + info("Check the charset has been saved"); + let pageInfo = await PlacesUtils.history.fetch(TEST_URL, { + includeAnnotations: true, + }); + Assert.equal( + pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO), + "windows-1252", + "charset is correct" + ); + + // Now check getShortcutOrURI. + let data = await UrlbarUtils.getShortcutOrURIAndPostData("kw test"); + Assert.equal( + getPostDataString(data.postData), + "accenti=\u00E0\u00E8\u00EC\u00F2\u00F9&search=test", + "getShortcutOrURI POST data is correct" + ); + Assert.equal(data.url, TEST_URL, "getShortcutOrURI URL is correct"); + }, + () => PlacesUtils.bookmarks.eraseEverything() + ); + } + ); +}); + +add_task(async function reopen_same_field() { + await PlacesUtils.keywords.insert({ + url: TEST_URL, + keyword: "kw", + postData: "accenti%3D%E0%E8%EC%F2%F9&search%3D%25s", + }); + registerCleanupFunction(async function () { + await PlacesUtils.keywords.remove("kw"); + }); + // Reopening on the same input field should show the existing keyword. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL, + }, + async function (browser) { + // We must wait for the context menu code to build metadata. + await openContextMenuForContentSelector( + browser, + '#form1 > input[name="search"]' + ); + + await withBookmarksDialog( + true, + function () { + AddKeywordForSearchField(); + contentAreaContextMenu.hidePopup(); + }, + async function (dialogWin) { + let elt = dialogWin.document.getElementById( + "editBMPanel_keywordField" + ); + Assert.equal(elt.value, "kw", "Keyword should be the previous value"); + + let acceptBtn = dialogWin.document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept"); + ok(!acceptBtn.disabled, "Accept button is enabled"); + }, + () => PlacesUtils.bookmarks.eraseEverything() + ); + } + ); +}); + +add_task(async function open_other_field() { + await PlacesUtils.keywords.insert({ + url: TEST_URL, + keyword: "kw2", + postData: "search%3D%25s", + }); + registerCleanupFunction(async function () { + await PlacesUtils.keywords.remove("kw2"); + }); + // Reopening on another field of the same page that has different postData + // should not show the existing keyword. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL, + }, + async function (browser) { + // We must wait for the context menu code to build metadata. + await openContextMenuForContentSelector( + browser, + '#form2 > input[name="search"]' + ); + + await withBookmarksDialog( + true, + function () { + AddKeywordForSearchField(); + contentAreaContextMenu.hidePopup(); + }, + function (dialogWin) { + let acceptBtn = dialogWin.document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept"); + ok(acceptBtn.disabled, "Accept button is disabled"); + + let elt = dialogWin.document.getElementById( + "editBMPanel_keywordField" + ); + is(elt.value, ""); + }, + () => PlacesUtils.bookmarks.eraseEverything() + ); + } + ); +}); + +function getPostDataString(stream) { + let sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( + Ci.nsIScriptableInputStream + ); + sis.init(stream); + return sis.read(stream.available()).split("\n").pop(); +} diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_bookmarkAllTabs.js b/browser/components/places/tests/browser/browser_bookmarkProperties_bookmarkAllTabs.js new file mode 100644 index 0000000000..805f9464e3 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_bookmarkAllTabs.js @@ -0,0 +1,66 @@ +"use strict"; + +const TEST_URLS = ["about:robots", "about:mozilla"]; + +add_task(async function bookmark_all_tabs() { + let tabs = []; + for (let url of TEST_URLS) { + tabs.push(await BrowserTestUtils.openNewForegroundTab(gBrowser, url)); + } + registerCleanupFunction(async function () { + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } + await PlacesUtils.bookmarks.eraseEverything(); + }); + + await withBookmarksDialog( + false, + function open() { + document.getElementById("Browser:BookmarkAllTabs").doCommand(); + }, + async dialog => { + let acceptBtn = dialog.document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept"); + Assert.ok(!acceptBtn.disabled, "Accept button is enabled"); + + let namepicker = dialog.document.getElementById("editBMPanel_namePicker"); + Assert.ok(!namepicker.readOnly, "Name field is writable"); + let folderName = dialog.document + .getElementById("stringBundle") + .getString("bookmarkAllTabsDefault"); + Assert.equal(namepicker.value, folderName, "Name field is correct."); + + let promiseBookmarkAdded = + PlacesTestUtils.waitForNotification("bookmark-added"); + + fillBookmarkTextField("editBMPanel_namePicker", "folder", dialog); + + let folderPicker = dialog.document.getElementById( + "editBMPanel_folderMenuList" + ); + + let defaultParentGuid = await PlacesUIUtils.defaultParentGuid; + // Check the initial state of the folder picker. + await TestUtils.waitForCondition( + () => folderPicker.getAttribute("selectedGuid") == defaultParentGuid, + "The folder is the expected one." + ); + + EventUtils.synthesizeKey("VK_RETURN", {}, dialog); + await promiseBookmarkAdded; + for (const url of TEST_URLS) { + const { parentGuid } = await PlacesUtils.bookmarks.fetch({ url }); + const folder = await PlacesUtils.bookmarks.fetch({ + guid: parentGuid, + }); + is( + folder.title, + "folder", + "Should have created the bookmark in the right folder." + ); + } + } + ); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_cancel.js b/browser/components/places/tests/browser/browser_bookmarkProperties_cancel.js new file mode 100644 index 0000000000..5652358acb --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_cancel.js @@ -0,0 +1,126 @@ +"use strict"; + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +const sandbox = sinon.createSandbox(); + +registerCleanupFunction(async function () { + sandbox.restore(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +let bookmarks; // Bookmarks added via insertTree. + +add_setup(async function () { + bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "bm1", + url: "http://example.com", + }, + { + title: "bm2", + url: "http://example.com/2", + }, + ], + }); + + // Undo is called asynchronously - and not waited for. Since we're not + // expecting undo to be called, we can only tell this by stubbing it. + sandbox.stub(PlacesTransactions, "undo").returns(Promise.resolve()); +}); + +// Tests for bug 1391393 - Ensures that if the user cancels the bookmark properties +// dialog without having done any changes, then no undo is called. +add_task(async function test_cancel_with_no_changes() { + await withSidebarTree("bookmarks", async tree => { + tree.selectItems([bookmarks[0].guid]); + + // Delete the bookmark to put something in the undo history. + // Rather than calling cmd_delete, we call the remove directly, so that we + // can await on it finishing, and be guaranteed that there's something + // in the history. + await tree.controller.remove("Remove Selection"); + + tree.selectItems([bookmarks[1].guid]); + + // Now open the bookmarks dialog and cancel it. + await withBookmarksDialog( + true, + function openDialog() { + tree.controller.doCommand("placesCmd_show:info"); + }, + async function test(dialogWin) { + let acceptButton = dialogWin.document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept"); + await TestUtils.waitForCondition( + () => !acceptButton.disabled, + "The accept button should be enabled" + ); + } + ); + + // Check the bookmark is still removed. + Assert.ok( + !(await PlacesUtils.bookmarks.fetch(bookmarks[0].guid)), + "The originally removed bookmark should not exist." + ); + + Assert.ok( + await PlacesUtils.bookmarks.fetch(bookmarks[1].guid), + "The second bookmark should still exist" + ); + + Assert.ok( + PlacesTransactions.undo.notCalled, + "undo should not have been called" + ); + }); +}); + +add_task(async function test_cancel_with_changes() { + await withSidebarTree("bookmarks", async tree => { + tree.selectItems([bookmarks[1].guid]); + + // Now open the bookmarks dialog and cancel it. + await withBookmarksDialog( + true, + function openDialog() { + tree.controller.doCommand("placesCmd_show:info"); + }, + async function test(dialogWin) { + let acceptButton = dialogWin.document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept"); + await TestUtils.waitForCondition( + () => !acceptButton.disabled, + "EditBookmark: The accept button should be enabled" + ); + + let namePicker = dialogWin.document.getElementById( + "editBMPanel_namePicker" + ); + fillBookmarkTextField("editBMPanel_namePicker", "new_n", dialogWin); + + // Ensure that value in field has changed + Assert.equal( + namePicker.value, + "new_n", + "EditBookmark: The title is the expected one." + ); + } + ); + + let oldBookmark = await PlacesUtils.bookmarks.fetch(bookmarks[1].guid); + Assert.equal( + oldBookmark.title, + "bm2", + "EditBookmark: The title hasn't been changed" + ); + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_editFolder.js b/browser/components/places/tests/browser/browser_bookmarkProperties_editFolder.js new file mode 100644 index 0000000000..fae9d01bec --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_editFolder.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the Properties dialog on a folder. + +add_task(async function test_bookmark_properties_dialog_on_folder() { + info("Bug 479348 - Properties on a root should be read-only."); + + let bm = await PlacesUtils.bookmarks.insert({ + title: "folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.remove(bm); + }); + + await withSidebarTree("bookmarks", async function (tree) { + // Select the new bookmark in the sidebar. + tree.selectItems([bm.guid]); + let folder = tree.selectedNode; + Assert.equal(folder.title, "folder", "Folder title is correct"); + Assert.ok( + tree.controller.isCommandEnabled("placesCmd_show:info"), + "'placesCmd_show:info' on folder is enabled" + ); + + await withBookmarksDialog( + false, + function openDialog() { + tree.controller.doCommand("placesCmd_show:info"); + }, + async function test(dialogWin) { + // Check that the dialog is not read-only. + Assert.ok( + !dialogWin.gEditItemOverlay.readOnly, + "EditBookmark: Dialog should not be read-only" + ); + + // Check that name picker is not read only + let namepicker = dialogWin.document.getElementById( + "editBMPanel_namePicker" + ); + Assert.ok( + !namepicker.readOnly, + "EditBookmark: Name field should not be read-only" + ); + Assert.equal( + namepicker.value, + "folder", + "EditBookmark:Node title is correct" + ); + + fillBookmarkTextField("editBMPanel_namePicker", "newname", dialogWin); + namepicker.blur(); + + Assert.equal( + namepicker.value, + "newname", + "EditBookmark: The title field has been changed" + ); + + // Confirm and close the dialog. + namepicker.focus(); + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + // Ensure that the edit is finished before we hit cancel. + } + ); + + Assert.equal( + tree.selectedNode.title, + "newname", + "EditBookmark: The node has the correct title" + ); + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_editTagContainer.js b/browser/components/places/tests/browser/browser_bookmarkProperties_editTagContainer.js new file mode 100644 index 0000000000..f4e3b3e3fe --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_editTagContainer.js @@ -0,0 +1,140 @@ +"use strict"; + +add_task(async function editTagContainer() { + info("Bug 479348 - Properties on a root should be read-only."); + let uri = Services.io.newURI("http://example.com/"); + let bm = await PlacesUtils.bookmarks.insert({ + url: uri.spec, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.remove(bm); + }); + + PlacesUtils.tagging.tagURI(uri, ["tag1"]); + + let library = await promiseLibrary(); + let PlacesOrganizer = library.PlacesOrganizer; + registerCleanupFunction(async function () { + await promiseLibraryClosed(library); + }); + + PlacesOrganizer.selectLeftPaneBuiltIn("Tags"); + let tree = PlacesOrganizer._places; + let tagsContainer = tree.selectedNode; + tagsContainer.containerOpen = true; + let fooTag = tagsContainer.getChild(0); + let tagNode = fooTag; + tree.selectNode(fooTag); + Assert.equal(tagNode.title, "tag1", "EditBookmark: tagNode title is correct"); + + Assert.ok( + tree.controller.isCommandEnabled("placesCmd_show:info"), + "EditBookmark: 'placesCmd_show:info' on current selected node is enabled" + ); + + await withBookmarksDialog( + true, + function openDialog() { + tree.controller.doCommand("placesCmd_show:info"); + }, + async function test(dialogWin) { + // Check that the dialog is not read-only. + Assert.ok( + !dialogWin.gEditItemOverlay.readOnly, + "EditBookmark: Dialog should not be read-only" + ); + + // Check that name picker is not read only + let namepicker = dialogWin.document.getElementById( + "editBMPanel_namePicker" + ); + Assert.ok( + !namepicker.readOnly, + "EditBookmark: Name field should not be read-only" + ); + Assert.equal( + namepicker.value, + "tag1", + "EditBookmark: Node title is correct" + ); + + fillBookmarkTextField("editBMPanel_namePicker", "tag2", dialogWin); + + // Although we have received the expected notifications, we need + // to let everything resolve to ensure the UI is updated. + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + + Assert.equal( + namepicker.value, + "tag2", + "EditBookmark: The title field has been changed" + ); + + // Try to set an empty title, it should restore the previous one. + fillBookmarkTextField("editBMPanel_namePicker", "", dialogWin); + + Assert.equal( + namepicker.value, + "tag1", + "EditBookmark: The title field has been changed" + ); + } + ); + + // Check the tag change hasn't changed + let tags = PlacesUtils.tagging.getTagsForURI(uri); + Assert.equal(tags.length, 1, "EditBookmark: Found the right number of tags"); + Assert.deepEqual( + tags, + ["tag1"], + "EditBookmark: Found the expected unchanged tag" + ); + + await withBookmarksDialog( + false, + function openDialog() { + tree.controller.doCommand("placesCmd_show:info"); + }, + async function test(dialogWin) { + // Check that the dialog is not read-only. + Assert.ok( + !dialogWin.gEditItemOverlay.readOnly, + "Dialog should not be read-only" + ); + + // Check that name picker is not read only + let namepicker = dialogWin.document.getElementById( + "editBMPanel_namePicker" + ); + Assert.ok( + !namepicker.readOnly, + "EditBookmark: Name field should not be read-only" + ); + Assert.equal( + namepicker.value, + "tag1", + "EditBookmark: Node title is correct" + ); + + fillBookmarkTextField("editBMPanel_namePicker", "tag2", dialogWin); + + Assert.equal( + namepicker.value, + "tag2", + "EditBookmark: The title field has been changed" + ); + namepicker.blur(); + + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + } + ); + + tags = PlacesUtils.tagging.getTagsForURI(uri); + Assert.equal(tags.length, 1, "EditBookmark: Found the right number of tags"); + Assert.deepEqual( + tags, + ["tag2"], + "EditBookmark: Found the expected Y changed tag" + ); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_folderSelection.js b/browser/components/places/tests/browser/browser_bookmarkProperties_folderSelection.js new file mode 100644 index 0000000000..39f3dd8822 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_folderSelection.js @@ -0,0 +1,221 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +const TEST_URL = "about:robots"; +let bookmarkPanel; +let folders; + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + + Services.prefs.clearUserPref("browser.bookmarks.defaultLocation"); + + const tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_URL, + waitForStateStop: true, + }); + + let oldTimeout = StarUI._autoCloseTimeout; + // Make the timeout something big, so it doesn't iteract badly with tests. + StarUI._autoCloseTimeout = 6000000; + + StarUI._createPanelIfNeeded(); + bookmarkPanel = document.getElementById("editBookmarkPanel"); + bookmarkPanel.setAttribute("animate", false); + + registerCleanupFunction(async () => { + bookmarkPanel = null; + StarUI._autoCloseTimeout = oldTimeout; + BrowserTestUtils.removeTab(tab); + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_selectChoose() { + await clickBookmarkStar(); + + // Open folder selector. + let menuList = document.getElementById("editBMPanel_folderMenuList"); + let folderTreeRow = document.getElementById("editBMPanel_folderTreeRow"); + + let expectedFolder = "BookmarksToolbarFolderTitle"; + let expectedGuid = PlacesUtils.bookmarks.toolbarGuid; + + Assert.equal( + menuList.label, + PlacesUtils.getString(expectedFolder), + "Should have the expected bookmarks folder selected by default" + ); + Assert.equal( + menuList.getAttribute("selectedGuid"), + expectedGuid, + "Should have the correct default guid selected" + ); + Assert.equal( + folderTreeRow.hidden, + true, + "Should have the folder tree hidden" + ); + + let promisePopup = BrowserTestUtils.waitForEvent( + menuList.menupopup, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(menuList, {}); + await promisePopup; + + // Click the choose item. + EventUtils.synthesizeMouseAtCenter( + document.getElementById("editBMPanel_chooseFolderMenuItem"), + {} + ); + + await TestUtils.waitForCondition( + () => !folderTreeRow.hidden, + "Should show the folder tree" + ); + let folderTree = document.getElementById("editBMPanel_folderTree"); + Assert.ok(folderTree.view, "The view should have been connected"); + + Assert.equal( + menuList.getAttribute("selectedGuid"), + expectedGuid, + "Should still have the correct selected guid" + ); + Assert.equal( + menuList.label, + PlacesUtils.getString(expectedFolder), + "Should have kept the same menu label" + ); + + let input = folderTree.shadowRoot.querySelector("input"); + + let newFolderButton = document.getElementById("editBMPanel_newFolderButton"); + newFolderButton.click(); // This will start editing. + + // Wait for editing: + await TestUtils.waitForCondition(() => !input.hidden); + + // Click the arrow to collapse the list. + EventUtils.synthesizeMouseAtCenter( + document.getElementById("editBMPanel_foldersExpander"), + {} + ); + + await TestUtils.waitForCondition( + () => folderTreeRow.hidden, + "Should hide the folder tree" + ); + ok(input.hidden, "Folder tree should not be broken."); + + // Click the arrow to re-show the list. + EventUtils.synthesizeMouseAtCenter( + document.getElementById("editBMPanel_foldersExpander"), + {} + ); + + await TestUtils.waitForCondition( + () => !folderTreeRow.hidden, + "Should re-show the folder tree" + ); + ok(input.hidden, "Folder tree should still not be broken."); + + const promiseCancel = promisePopupHidden( + document.getElementById("editBookmarkPanel") + ); + document.getElementById("editBookmarkPanelRemoveButton").click(); + await promiseCancel; + Assert.ok(!folderTree.view, "The view should have been disconnected"); +}); + +add_task(async function test_selectBookmarksMenu() { + await clickBookmarkStar(); + + // Open folder selector. + let menuList = document.getElementById("editBMPanel_folderMenuList"); + + const expectedFolder = "BookmarksMenuFolderTitle"; + const expectedGuid = PlacesUtils.bookmarks.menuGuid; + + let promisePopup = BrowserTestUtils.waitForEvent( + menuList.menupopup, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(menuList, {}); + await promisePopup; + + // Click the bookmarks menu item. + EventUtils.synthesizeMouseAtCenter( + document.getElementById("editBMPanel_bmRootItem"), + {} + ); + + await TestUtils.waitForCondition( + () => menuList.getAttribute("selectedGuid") == expectedGuid, + "Should select the menu folder item" + ); + + Assert.equal( + menuList.label, + PlacesUtils.getString(expectedFolder), + "Should have updated the menu label" + ); + + // Click the arrow to show the folder tree. + EventUtils.synthesizeMouseAtCenter( + document.getElementById("editBMPanel_foldersExpander"), + {} + ); + const folderTreeRow = document.getElementById("editBMPanel_folderTreeRow"); + await BrowserTestUtils.waitForMutationCondition( + folderTreeRow, + { attributeFilter: ["hidden"] }, + () => !folderTreeRow.hidden + ); + const folderTree = document.getElementById("editBMPanel_folderTree"); + Assert.equal( + folderTree.selectedNode.bookmarkGuid, + PlacesUtils.bookmarks.virtualMenuGuid, + "Folder tree should have the correct selected guid" + ); + Assert.equal( + menuList.getAttribute("selectedGuid"), + expectedGuid, + "Menu list should have the correct selected guid" + ); + Assert.equal( + menuList.label, + PlacesUtils.getString(expectedFolder), + "Should have kept the same menu label" + ); + + // Switch back to the toolbar folder. + const { toolbarGuid } = PlacesUtils.bookmarks; + promisePopup = BrowserTestUtils.waitForEvent( + menuList.menupopup, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(menuList, {}); + await promisePopup; + EventUtils.synthesizeMouseAtCenter( + document.getElementById("editBMPanel_toolbarFolderItem"), + {} + ); + await TestUtils.waitForCondition( + () => menuList.getAttribute("selectedGuid") == toolbarGuid, + "Should select the toolbar folder item" + ); + + // Save the bookmark. + const promiseBookmarkAdded = + PlacesTestUtils.waitForNotification("bookmark-added"); + await hideBookmarksPanel(); + const [{ parentGuid }] = await promiseBookmarkAdded; + Assert.equal( + parentGuid, + toolbarGuid, + "Should have saved the bookmark in the correct folder." + ); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_newFolder.js b/browser/components/places/tests/browser/browser_bookmarkProperties_newFolder.js new file mode 100644 index 0000000000..23eec11e1d --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_newFolder.js @@ -0,0 +1,110 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +const TEST_URL = "about:robots"; +StarUI._createPanelIfNeeded(); +const bookmarkPanel = document.getElementById("editBookmarkPanel"); +let folders; + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + + bookmarkPanel.setAttribute("animate", false); + + let oldTimeout = StarUI._autoCloseTimeout; + // Make the timeout something big, so it doesn't iteract badly with tests. + StarUI._autoCloseTimeout = 6000000; + + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_URL, + waitForStateStop: true, + }); + + registerCleanupFunction(async () => { + StarUI._autoCloseTimeout = oldTimeout; + BrowserTestUtils.removeTab(tab); + bookmarkPanel.removeAttribute("animate"); + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_newFolder() { + let newBookmarkObserver = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => events.some(({ url }) => url === TEST_URL) + ); + await clickBookmarkStar(); + + // Open folder selector. + document.getElementById("editBMPanel_foldersExpander").click(); + + let folderTree = document.getElementById("editBMPanel_folderTree"); + + // Create new folder. + let newFolderButton = document.getElementById("editBMPanel_newFolderButton"); + newFolderButton.click(); + + let newFolderGuid; + let newFolderObserver = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => { + for (let { guid, itemType } of events) { + newFolderGuid = guid; + if (itemType == PlacesUtils.bookmarks.TYPE_FOLDER) { + return true; + } + } + return false; + } + ); + + let menulist = document.getElementById("editBMPanel_folderMenuList"); + + await newFolderObserver; + + // Wait for the folder to be created and for editing to start. + await TestUtils.waitForCondition( + () => folderTree.hasAttribute("editing"), + "Should be in edit mode for the new folder" + ); + + Assert.equal( + menulist.selectedItem.label, + newFolderButton.label, + "Should have the new folder selected by default" + ); + + let renameObserver = PlacesTestUtils.waitForNotification( + "bookmark-title-changed", + events => events.some(e => e.title === "f") + ); + + // Enter a new name. + EventUtils.synthesizeKey("f", {}, window); + EventUtils.synthesizeKey("VK_RETURN", {}, window); + + await renameObserver; + + await TestUtils.waitForCondition( + () => !folderTree.hasAttribute("editing"), + "Should have stopped editing the new folder" + ); + + Assert.equal( + menulist.selectedItem.label, + "f", + "Should have the new folder title" + ); + + await hideBookmarksPanel(); + await newBookmarkObserver; + let bookmark = await PlacesUtils.bookmarks.fetch({ url: TEST_URL }); + + Assert.equal( + bookmark.parentGuid, + newFolderGuid, + "The bookmark should be parented by the new folder" + ); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_no_user_actions.js b/browser/components/places/tests/browser/browser_bookmarkProperties_no_user_actions.js new file mode 100644 index 0000000000..67d1406bc1 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_no_user_actions.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const TEST_URL = "about:buildconfig"; + +add_task(async function test_change_title_from_BookmarkStar() { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_URL, + title: "Before Edit", + }); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_URL, + waitForStateStop: true, + }); + + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(tab); + await PlacesUtils.bookmarks.eraseEverything(); + }); + + StarUI._createPanelIfNeeded(); + let bookmarkPanel = document.getElementById("editBookmarkPanel"); + let shownPromise = promisePopupShown(bookmarkPanel); + + let bookmarkStar = BookmarkingUI.star; + bookmarkStar.click(); + + await shownPromise; + + window.gEditItemOverlay.toggleFolderTreeVisibility(); + + let folderTree = document.getElementById("editBMPanel_folderTree"); + + // canDrop should always return false. + let bookmarkWithId = JSON.stringify( + Object.assign({ + url: "http://example.com", + title: "Fake BM", + }) + ); + + let dt = { + dropEffect: "move", + mozCursor: "auto", + mozItemCount: 1, + types: [PlacesUtils.TYPE_X_MOZ_PLACE], + mozTypesAt(i) { + return this.types; + }, + mozGetDataAt(i) { + return bookmarkWithId; + }, + }; + + Assert.ok( + !folderTree.view.canDrop(1, Ci.nsITreeView.DROP_BEFORE, dt), + "Should not be able to drop a bookmark" + ); + + // User Actions should be disabled. + const userActions = [ + "cmd_undo", + "cmd_redo", + "cmd_cut", + "cmd_copy", + "cmd_paste", + "cmd_delete", + "cmd_selectAll", + // Anything starting with placesCmd_ should also be disabled. + "placesCmd_", + ]; + for (let action of userActions) { + Assert.ok( + !folderTree.view._controller.supportsCommand(action), + `${action} should be disabled for the folder tree in bookmarks properties` + ); + } + + let hiddenPromise = promisePopupHidden(bookmarkPanel); + let doneButton = document.getElementById("editBookmarkPanelDoneButton"); + doneButton.click(); + await hiddenPromise; +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_readOnlyRoot.js b/browser/components/places/tests/browser/browser_bookmarkProperties_readOnlyRoot.js new file mode 100644 index 0000000000..d98b7477ec --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_readOnlyRoot.js @@ -0,0 +1,67 @@ +"use strict"; + +add_task(async function test_dialog() { + info("Bug 479348 - Properties dialog on a root should be read-only."); + await withSidebarTree("bookmarks", async function (tree) { + tree.selectItems([PlacesUtils.bookmarks.unfiledGuid]); + Assert.ok( + !tree.controller.isCommandEnabled("placesCmd_show:info"), + "'placesCmd_show:info' on current selected node is disabled" + ); + + await withBookmarksDialog( + true, + function openDialog() { + // Even if the cmd is disabled, we can execute it regardless. + tree.controller.doCommand("placesCmd_show:info"); + }, + async function test(dialogWin) { + // Check that the dialog is read-only. + Assert.ok(dialogWin.gEditItemOverlay.readOnly, "Dialog is read-only"); + // Check that accept button is disabled + let acceptButton = dialogWin.document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept"); + Assert.ok(acceptButton.disabled, "Accept button is disabled"); + + // Check that name picker is read only + let namepicker = dialogWin.document.getElementById( + "editBMPanel_namePicker" + ); + Assert.ok(namepicker.readOnly, "Name field is read-only"); + Assert.equal( + namepicker.value, + PlacesUtils.getString("OtherBookmarksFolderTitle"), + "Node title is correct" + ); + } + ); + }); +}); + +add_task(async function test_library() { + info("Bug 479348 - Library info pane on a root should be read-only."); + let library = await promiseLibrary("UnfiledBookmarks"); + registerCleanupFunction(async function () { + await promiseLibraryClosed(library); + }); + let PlacesOrganizer = library.PlacesOrganizer; + let tree = PlacesOrganizer._places; + tree.focus(); + Assert.ok( + !tree.controller.isCommandEnabled("placesCmd_show:info"), + "'placesCmd_show:info' on current selected node is disabled" + ); + + // Check that the pane is read-only. + Assert.ok(library.gEditItemOverlay.readOnly, "Info pane is read-only"); + + // Check that name picker is read only + let namepicker = library.document.getElementById("editBMPanel_namePicker"); + Assert.ok(namepicker.readOnly, "Name field is read-only"); + Assert.equal( + namepicker.value, + PlacesUtils.getString("OtherBookmarksFolderTitle"), + "Node title is correct" + ); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_remember_folders.js b/browser/components/places/tests/browser/browser_bookmarkProperties_remember_folders.js new file mode 100644 index 0000000000..99da75d62f --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_remember_folders.js @@ -0,0 +1,221 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +/** + * Tests that multiple tags can be added to a bookmark using the star-shaped button, the library and the sidebar. + */ + +StarUI._createPanelIfNeeded(); +const bookmarkPanel = document.getElementById("editBookmarkPanel"); +let folders; + +async function openPopupAndSelectFolder(guid, newBookmark = false) { + await clickBookmarkStar(); + + let notificationPromise; + if (!newBookmark) { + notificationPromise = PlacesTestUtils.waitForNotification( + "bookmark-moved", + events => events.some(e => guid === e.parentGuid) + ); + } + + // Expand the folder tree. + document.getElementById("editBMPanel_foldersExpander").click(); + document.getElementById("editBMPanel_folderTree").selectItems([guid]); + + await hideBookmarksPanel(); + if (!newBookmark) { + await notificationPromise; + } +} + +async function assertRecentFolders(expectedGuids, msg) { + await clickBookmarkStar(); + + let actualGuids = []; + function getGuids() { + actualGuids = []; + const folderMenuPopup = document.getElementById( + "editBMPanel_folderMenuList" + ).menupopup; + + let separatorFound = false; + // The list of folders goes from editBMPanel_foldersSeparator to the end. + for (let child of folderMenuPopup.children) { + if (separatorFound) { + actualGuids.push(child.folderGuid); + } else if (child.id == "editBMPanel_foldersSeparator") { + separatorFound = true; + } + } + } + + // The dialog fills in the folder list asnychronously, so we might need to wait + // for that to complete. + await TestUtils.waitForCondition(() => { + getGuids(); + return actualGuids.length == expectedGuids.length; + }, `Should have opened dialog with expected recent folders for: ${msg}`); + + Assert.deepEqual(actualGuids, expectedGuids, msg); + + await hideBookmarksPanel(); + + // Give the metadata chance to be written to the database before we attempt + // to open the dialog again. + let diskGuids = []; + await TestUtils.waitForCondition(async () => { + diskGuids = await PlacesUtils.metadata.get( + PlacesUIUtils.LAST_USED_FOLDERS_META_KEY, + [] + ); + return diskGuids.length == expectedGuids.length; + }, `Should have written data to disk for: ${msg}`); + + Assert.deepEqual( + diskGuids, + expectedGuids, + `Should match the disk GUIDS for ${msg}` + ); +} + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.metadata.delete(PlacesUIUtils.LAST_USED_FOLDERS_META_KEY); + + bookmarkPanel.setAttribute("animate", false); + + let oldTimeout = StarUI._autoCloseTimeout; + // Make the timeout something big, so it doesn't iteract badly with tests. + StarUI._autoCloseTimeout = 6000000; + + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: "about:robots", + waitForStateStop: true, + }); + + folders = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "Bob", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { + title: "Place", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { + title: "Delight", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { + title: "Surprise", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { + title: "Treble Bob", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { + title: "Principal", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { + title: "Max Default Recent Folders", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { + title: "One Over Default Maximum Recent Folders", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + ], + }); + + registerCleanupFunction(async () => { + StarUI._autoCloseTimeout = oldTimeout; + BrowserTestUtils.removeTab(tab); + bookmarkPanel.removeAttribute("animate"); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.metadata.delete(PlacesUIUtils.LAST_USED_FOLDERS_META_KEY); + }); +}); + +add_task(async function test_remember_last_folder() { + await assertRecentFolders([], "Should have no recent folders to start with."); + + await openPopupAndSelectFolder(folders[0].guid, true); + + await assertRecentFolders( + [folders[0].guid], + "Should have one folder in the list." + ); +}); + +add_task(async function test_forget_oldest_folder() { + // Add some more folders. + let expectedFolders = [folders[0].guid]; + for (let i = 1; i < folders.length; i++) { + await assertRecentFolders( + expectedFolders, + "Should have only the expected folders in the list" + ); + + await openPopupAndSelectFolder(folders[i].guid); + + expectedFolders.unshift(folders[i].guid); + if (expectedFolders.length > PlacesUIUtils.maxRecentFolders) { + expectedFolders.pop(); + } + } + + await assertRecentFolders( + expectedFolders, + "Should have expired the original folder" + ); +}); + +add_task(async function test_reorder_folders() { + let expectedFolders = [ + folders[2].guid, + folders[7].guid, + folders[6].guid, + folders[5].guid, + folders[4].guid, + folders[3].guid, + folders[1].guid, + ]; + + // Take an old one and put it at the front. + await openPopupAndSelectFolder(folders[2].guid); + + await assertRecentFolders( + expectedFolders, + "Should have correctly re-ordered the list" + ); +}); + +add_task(async function test_change_max_recent_folders_pref() { + let expectedFolders = [folders[0].guid]; + + Services.prefs.setIntPref("browser.bookmarks.editDialog.maxRecentFolders", 1); + + await openPopupAndSelectFolder(folders[1].guid); + await openPopupAndSelectFolder(folders[0].guid); + + await assertRecentFolders( + expectedFolders, + "Should have only one recent folder in the bookmark edit panel" + ); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref( + "browser.bookmarks.editDialog.maxRecentFolders" + ); + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_speculativeConnection.js b/browser/components/places/tests/browser/browser_bookmarkProperties_speculativeConnection.js new file mode 100644 index 0000000000..621ea19bb9 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_speculativeConnection.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test to ensure that on "mousedown" in Toolbar we set Speculative Connection + */ + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const sandbox = sinon.createSandbox(); +let spy = sandbox + .stub(PlacesUIUtils, "setupSpeculativeConnection") + .returns(Promise.resolve()); + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + + let toolbar = document.getElementById("PersonalToolbar"); + ok(toolbar, "PersonalToolbar should not be null"); + + if (toolbar.collapsed) { + await promiseSetToolbarVisibility(toolbar, true); + registerCleanupFunction(function () { + return promiseSetToolbarVisibility(toolbar, false); + }); + } + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + sandbox.restore(); + }); +}); + +add_task(async function checkToolbarSpeculativeConnection() { + let toolbarBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "https://example.com/", + title: "Bookmark 1", + }); + let toolbarNode = getToolbarNodeForItemGuid(toolbarBookmark.guid); + + info("Synthesize mousedown on selected bookmark"); + EventUtils.synthesizeMouseAtCenter(toolbarNode, { + type: "mousedown", + }); + EventUtils.synthesizeMouseAtCenter(toolbarNode, { + type: "mouseup", + }); + + Assert.ok(spy.called, "Speculative connection for Toolbar called"); + sandbox.restore(); +}); + +add_task(async function checkMenuSpeculativeConnection() { + await PlacesUtils.bookmarks.eraseEverything(); + + info("Placing a Menu widget"); + let origBMBlocation = CustomizableUI.getPlacementOfWidget( + "bookmarks-menu-button" + ); + // Ensure BMB is available in UI. + if (!origBMBlocation) { + CustomizableUI.addWidgetToArea( + "bookmarks-menu-button", + CustomizableUI.AREA_NAVBAR + ); + } + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + // if BMB was not originally in UI, remove it. + if (!origBMBlocation) { + CustomizableUI.removeWidgetFromArea("bookmarks-menu-button"); + } + }); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://example.com/", + title: "Bookmark 2", + }); + + // Test Bookmarks Menu Button + let BMB = document.getElementById("bookmarks-menu-button"); + let BMBpopup = document.getElementById("BMB_bookmarksPopup"); + let promiseEvent = BrowserTestUtils.waitForEvent(BMBpopup, "popupshown"); + EventUtils.synthesizeMouseAtCenter(BMB, {}); + await promiseEvent; + info("Popupshown on Bookmarks-Menu-Button"); + + let menuBookmark = [...BMBpopup.children].find( + node => node.label == "Bookmark 2" + ); + + EventUtils.synthesizeMouseAtCenter(menuBookmark, { + type: "mousedown", + }); + EventUtils.synthesizeMouseAtCenter(menuBookmark, { + type: "mouseup", + }); + + Assert.ok(spy.called, "Speculative connection for Menu Button called"); + sandbox.restore(); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_xulStore.js b/browser/components/places/tests/browser/browser_bookmarkProperties_xulStore.js new file mode 100644 index 0000000000..1ba6f56949 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_xulStore.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +add_task(async function () { + let mainFolder = await PlacesUtils.bookmarks.insert({ + title: "mainFolder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + }); + + await withSidebarTree("bookmarks", async function (tree) { + await PlacesUtils.bookmarks.insertTree({ + guid: mainFolder.guid, + children: [ + { + title: "firstFolder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { + title: "secondFolder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + ], + }); + tree.selectItems([mainFolder.guid]); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + let firstFolder = tree.selectedNode.getChild(0); + + tree.selectNode(firstFolder); + info("Synthesize click on selected node to open it."); + synthesizeClickOnSelectedTreeCell(tree); + info(`Get the hashed uri starts with "place:" and hash key&value pairs.`); + let hashedKey = PlacesUIUtils.obfuscateUrlForXulStore(firstFolder.uri); + + let docUrl = "chrome://browser/content/places/bookmarksSidebar.xhtml"; + + let value = Services.xulStore.getValue(docUrl, hashedKey, "open"); + + Assert.ok(hashedKey.startsWith("place:"), "Sanity check the hashed key"); + Assert.equal(value, "true", "Check the expected xulstore value"); + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_add_tags.js b/browser/components/places/tests/browser/browser_bookmark_add_tags.js new file mode 100644 index 0000000000..b031e1d219 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_add_tags.js @@ -0,0 +1,228 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Tests that multiple tags can be added to a bookmark using the star-shaped button, the library and the sidebar. + */ +let bookmarkPanel; +let bookmarkStar; + +async function clickBookmarkStar() { + let shownPromise = promisePopupShown(bookmarkPanel); + bookmarkStar.click(); + await shownPromise; +} + +async function hideBookmarksPanel(callback) { + let hiddenPromise = promisePopupHidden(bookmarkPanel); + callback(); + await hiddenPromise; +} + +registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_add_bookmark_tags_from_bookmarkProperties() { + const TEST_URL = "about:robots"; + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "about:buildconfig", + title: "Bookmark Title", + }); + + PlacesUtils.tagging.tagURI(makeURI("about:buildconfig"), ["tag0"]); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser: win.gBrowser, + opening: TEST_URL, + waitForStateStop: true, + }); + + win.StarUI._createPanelIfNeeded(); + win.StarUI._autoCloseTimeout = 1000; + bookmarkPanel = win.document.getElementById("editBookmarkPanel"); + bookmarkPanel.setAttribute("animate", false); + bookmarkStar = win.BookmarkingUI.star; + + // Cleanup. + registerCleanupFunction(async function () { + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.closeWindow(win); + }); + + let bookmarkPanelTitle = win.document.getElementById( + "editBookmarkPanelTitle" + ); + + // The bookmarks panel is expected to auto-close after this step. + let promiseNotification = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => events.some(({ url }) => !url || url == TEST_URL) + ); + await hideBookmarksPanel(async () => { + // Click the bookmark star to bookmark the page. + await clickBookmarkStar(); + await TestUtils.waitForCondition( + () => + win.document.l10n.getAttributes(bookmarkPanelTitle).id === + "bookmarks-add-bookmark", + "Bookmark title is correct" + ); + Assert.equal( + bookmarkStar.getAttribute("starred"), + "true", + "Page is starred" + ); + }); + await promiseNotification; + + // Click the bookmark star again to add tags. + await clickBookmarkStar(); + promiseNotification = PlacesTestUtils.waitForNotification( + "bookmark-tags-changed" + ); + await TestUtils.waitForCondition( + () => + win.document.l10n.getAttributes(bookmarkPanelTitle).id === + "bookmarks-edit-bookmark", + "Bookmark title is correct" + ); + await fillBookmarkTextField("editBMPanel_tagsField", "tag1", win); + const doneButton = win.document.getElementById("editBookmarkPanelDoneButton"); + await hideBookmarksPanel(() => doneButton.click()); + await promiseNotification; + Assert.equal( + PlacesUtils.tagging.getTagsForURI(Services.io.newURI(TEST_URL)).length, + 1, + "Found the right number of tags" + ); + Assert.deepEqual( + PlacesUtils.tagging.getTagsForURI(Services.io.newURI(TEST_URL)), + ["tag1"] + ); + + // Click the bookmark star again, add more tags. + await clickBookmarkStar(); + promiseNotification = PlacesTestUtils.waitForNotification( + "bookmark-tags-changed" + ); + await fillBookmarkTextField("editBMPanel_tagsField", "tag1, tag2, tag3", win); + await hideBookmarksPanel(() => doneButton.click()); + await promiseNotification; + + const bookmarks = []; + await PlacesUtils.bookmarks.fetch({ url: TEST_URL }, bm => + bookmarks.push(bm) + ); + Assert.equal(bookmarks.length, 1, "Only one bookmark should exist"); + Assert.equal( + PlacesUtils.tagging.getTagsForURI(Services.io.newURI(TEST_URL)).length, + 3, + "Found the right number of tags" + ); + Assert.deepEqual( + PlacesUtils.tagging.getTagsForURI(Services.io.newURI(TEST_URL)), + ["tag1", "tag2", "tag3"] + ); + + // Cleanup. + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_add_bookmark_tags_from_library() { + const uri = "http://example.com/"; + + // Add a bookmark. + await PlacesUtils.bookmarks.insert({ + url: uri, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + + // Open the Library on "UnfiledBookmarks". + let library = await promiseLibrary("UnfiledBookmarks"); + + // Cleanup. + registerCleanupFunction(async function () { + await promiseLibraryClosed(library); + }); + + let bookmarkNode = library.ContentTree.view.selectedNode; + Assert.equal( + bookmarkNode.uri, + "http://example.com/", + "Found the expected bookmark" + ); + + // Add a tag to the bookmark. + fillBookmarkTextField("editBMPanel_tagsField", "tag1", library); + + await TestUtils.waitForCondition( + () => bookmarkNode.tags === "tag1", + "Node tag is correct" + ); + + // Add a new tag to the bookmark. + fillBookmarkTextField("editBMPanel_tagsField", "tag1, tag2", library); + + await TestUtils.waitForCondition( + () => bookmarkNode.tags === "tag1,tag2", + "Node tag is correct" + ); + + // Check the tag change has been completed. + let tags = PlacesUtils.tagging.getTagsForURI(Services.io.newURI(uri)); + Assert.equal(tags.length, 2, "Found the right number of tags"); + Assert.deepEqual(tags, ["tag1", "tag2"], "Found the expected tags"); + + await promiseLibraryClosed(library); + // Cleanup. + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_add_bookmark_tags_from_sidebar() { + const TEST_URL = "about:buildconfig"; + + let bookmarks = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_URL, + title: "Bookmark Title", + }); + + await withSidebarTree("bookmarks", async function (tree) { + tree.selectItems([bookmarks.guid]); + // Add one tag. + await addTags(["tag1"], tree, ["tag1"]); + // Add 2 more tags. + await addTags(["tag2", "tag3"], tree, ["tag1", "tag2", "tag3"]); + }); + + async function addTags(tagValue, tree, expected) { + await withBookmarksDialog( + false, + function openPropertiesDialog() { + tree.controller.doCommand("placesCmd_show:info"); + }, + async function test(dialogWin) { + PlacesUtils.tagging.tagURI(makeURI(TEST_URL), tagValue); + let tags = PlacesUtils.tagging.getTagsForURI( + Services.io.newURI(TEST_URL) + ); + + Assert.deepEqual(tags, expected, "Tags field is correctly populated"); + + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + } + ); + } + + // Cleanup. + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_all_tabs.js b/browser/components/places/tests/browser/browser_bookmark_all_tabs.js new file mode 100644 index 0000000000..2852bf4019 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_all_tabs.js @@ -0,0 +1,46 @@ +/** + * Test for Bug 446171 - Name field of bookmarks saved via 'Bookmark All Tabs' + * has '(null)' value if history is disabled or just in private browsing mode + */ +"use strict"; + +add_task(async function () { + const BASE_URL = + "http://example.org/browser/browser/components/places/tests/browser/"; + const TEST_PAGES = [ + BASE_URL + "bookmark_dummy_1.html", + BASE_URL + "bookmark_dummy_2.html", + BASE_URL + "bookmark_dummy_1.html", + ]; + + function promiseAddTab(url) { + return BrowserTestUtils.openNewForegroundTab(gBrowser, url); + } + + let tabs = await Promise.all(TEST_PAGES.map(promiseAddTab)); + + let URIs = PlacesCommandHook.uniqueCurrentPages; + is(URIs.length, 3, "Only unique pages are returned"); + + Assert.deepEqual( + URIs.map(URI => URI.uri.spec), + [ + "about:blank", + BASE_URL + "bookmark_dummy_1.html", + BASE_URL + "bookmark_dummy_2.html", + ], + "Correct URIs are returned" + ); + + Assert.deepEqual( + URIs.map(URI => URI.title), + ["New Tab", "Bookmark Dummy 1", "Bookmark Dummy 2"], + "Correct titles are returned" + ); + + registerCleanupFunction(async function () { + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_backup_export_import.js b/browser/components/places/tests/browser/browser_bookmark_backup_export_import.js new file mode 100644 index 0000000000..8b954a8469 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_backup_export_import.js @@ -0,0 +1,205 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests bookmarks backup export/import as JSON file. + */ + +const BASE_URL = "http://example.com/"; + +const PLACES = [ + { + guid: PlacesUtils.bookmarks.menuGuid, + prefix: "In Menu", + total: 5, + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + prefix: "In Toolbar", + total: 7, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + prefix: "In Other", + total: 8, + }, +]; + +var importExportPicker, saveDir, actualBookmarks; + +async function generateTestBookmarks() { + actualBookmarks = []; + for (let place of PLACES) { + let currentPlaceChildren = []; + for (let i = 1; i <= place.total; i++) { + currentPlaceChildren.push({ + url: `${BASE_URL}${i}`, + title: `${place.prefix} Bookmark: ${i}`, + }); + } + await PlacesUtils.bookmarks.insertTree({ + guid: place.guid, + children: currentPlaceChildren, + }); + actualBookmarks = actualBookmarks.concat(currentPlaceChildren); + } +} + +async function validateImportedBookmarksByParent( + parentGuid, + expectedChildrenTotal +) { + let currentPlace = PLACES.filter(elem => { + return elem.guid === parentGuid.toString(); + })[0]; + + let bookmarksTree = await PlacesUtils.promiseBookmarksTree(parentGuid); + + Assert.equal( + bookmarksTree.children.length, + expectedChildrenTotal, + `Imported bookmarks length should be ${expectedChildrenTotal}` + ); + + for (let importedBookmark of bookmarksTree.children) { + Assert.equal( + importedBookmark.type, + PlacesUtils.TYPE_X_MOZ_PLACE, + `Exported bookmarks should be of type bookmark` + ); + + let doesTitleContain = importedBookmark.title + .toString() + .includes(`${currentPlace.prefix} Bookmark`); + Assert.equal( + doesTitleContain, + true, + `Bookmark title should contain text: ${currentPlace.prefix} Bookmark` + ); + + let doesUriContains = importedBookmark.uri.toString().includes(BASE_URL); + Assert.equal(doesUriContains, true, "Bookmark uri should contain base url"); + } +} + +async function validateImportedBookmarks(fromPlaces) { + for (let i = 0; i < fromPlaces.length; i++) { + let parentContainer = fromPlaces[i]; + await validateImportedBookmarksByParent( + parentContainer.guid, + parentContainer.total + ); + } +} + +async function promiseImportExport(aWindow) { + saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + saveDir.append("temp-bookmarks-export"); + if (!saveDir.exists()) { + saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + } + importExportPicker.displayDirectory = saveDir; + + return new Promise(resolve => { + importExportPicker.showCallback = async () => { + let fileName = "bookmarks-backup.json"; + let destFile = saveDir.clone(); + destFile.append(fileName); + importExportPicker.setFiles([destFile]); + resolve(destFile); + }; + }); +} + +add_setup(async function () { + await promisePlacesInitComplete(); + await PlacesUtils.bookmarks.eraseEverything(); + await generateTestBookmarks(); + importExportPicker = SpecialPowers.MockFilePicker; + importExportPicker.init(window); + + registerCleanupFunction(async () => { + importExportPicker.cleanup(); + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +async function showMaintenancePopup(libraryWindow) { + let button = libraryWindow.document.getElementById("maintenanceButton"); + let popup = libraryWindow.document.getElementById("maintenanceButtonPopup"); + let shown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + + info("Clicking maintenance menu"); + + button.openMenu(true); + + await shown; + info("Maintenance popup shown"); + return popup; +} + +add_task(async function test_export_json() { + let libraryWindow = await promiseLibrary(); + let popup = await showMaintenancePopup(libraryWindow); + let hidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + + info("Activating #backupBookmarks"); + + let backupPromise = promiseImportExport(); + + popup.activateItem(popup.querySelector("#backupBookmarks")); + await hidden; + + info("Popup hidden"); + + let backupFile = await backupPromise; + await TestUtils.waitForCondition( + backupFile.exists, + "Backup file should exist" + ); + await promiseLibraryClosed(libraryWindow); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +async function showFileRestorePopup(libraryWindow) { + let parentPopup = await showMaintenancePopup(libraryWindow); + let popup = parentPopup.querySelector("#fileRestorePopup"); + let shown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + parentPopup.querySelector("#fileRestoreMenu").openMenu(true); + await shown; + return popup; +} + +add_task(async function test_import_json() { + let libraryWindow = await promiseLibrary(); + let popup = await showFileRestorePopup(libraryWindow); + + let backupPromise = promiseImportExport(); + let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen("accept"); + + let hidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + popup.activateItem(popup.querySelector("#restoreFromFile")); + await hidden; + + await backupPromise; + await dialogPromise; + + let restored = 0; + let promiseBookmarksRestored = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => events.some(() => ++restored == actualBookmarks.length) + ); + + await promiseBookmarksRestored; + await validateImportedBookmarks(PLACES); + await promiseLibraryClosed(libraryWindow); + + registerCleanupFunction(async () => { + if (saveDir) { + saveDir.remove(true); + } + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_change_location.js b/browser/components/places/tests/browser/browser_bookmark_change_location.js new file mode 100644 index 0000000000..3a82b67a93 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_change_location.js @@ -0,0 +1,213 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * Test that the bookmark location (url) can be changed from the toolbar and the sidebar. + */ +"use strict"; + +const TEST_URL = "about:buildconfig"; +const TEST_URL2 = "about:credits"; +const TEST_URL3 = "about:config"; + +// Setup. +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.toolbars.bookmarks.visibility", "always"]], + }); + + // The following initialization code is necessary to avoid a frequent + // intermittent failure in verify-fission where, due to timings, we may or + // may not import default bookmarks. We also want to avoid the empty toolbar + // placeholder shifting stuff around. + info("Ensure Places init is complete"); + let placesInitCompleteObserved = TestUtils.topicObserved( + "places-browser-init-complete" + ); + Cc["@mozilla.org/browser/browserglue;1"] + .getService(Ci.nsIObserver) + .observe(null, "browser-glue-test", "places-browser-init-complete"); + await placesInitCompleteObserved; + info("Add a bookmark to avoid showing the empty toolbar placeholder."); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "initial", + url: TEST_URL, + }); + + let toolbar = document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + if (wasCollapsed) { + info("Show the bookmarks toolbar"); + await promiseSetToolbarVisibility(toolbar, true); + info("Ensure toolbar visibility was updated"); + await BrowserTestUtils.waitForEvent( + toolbar, + "BookmarksToolbarVisibilityUpdated" + ); + } + + // Cleanup. + registerCleanupFunction(async () => { + // Collapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, false); + } + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_change_location_from_Toolbar() { + let toolbarBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "", + url: TEST_URL, + }); + + let toolbarNode = getToolbarNodeForItemGuid(toolbarBookmark.guid); + + await withBookmarksDialog( + false, + async function openPropertiesDialog() { + let placesContext = document.getElementById("placesContext"); + let promisePopup = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(toolbarNode, { + button: 2, + type: "contextmenu", + }); + await promisePopup; + + let properties = document.getElementById( + "placesContext_show_bookmark:info" + ); + placesContext.activateItem(properties); + }, + async function test(dialogWin) { + // Check the initial location. + let locationPicker = dialogWin.document.getElementById( + "editBMPanel_locationField" + ); + Assert.equal( + locationPicker.value, + TEST_URL, + "EditBookmark: The current location is the expected one." + ); + + // To check whether the lastModified field will be updated correctly. + let lastModified = _getLastModified(toolbarBookmark.guid); + + // Update the "location" field. + fillBookmarkTextField( + "editBMPanel_locationField", + TEST_URL2, + dialogWin, + false + ); + + locationPicker.blur(); + + Assert.equal( + locationPicker.value, + TEST_URL2, + "EditBookmark: The changed location is the expected one." + ); + + locationPicker.focus(); + // Confirm and close the dialog. + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + + await TestUtils.waitForCondition( + () => _getLastModified(toolbarBookmark.guid) > lastModified, + "EditBookmark: The lastModified will be greater than before updating." + ); + } + ); + + let updatedBm = await PlacesUtils.bookmarks.fetch(toolbarBookmark.guid); + Assert.equal( + updatedBm.url, + TEST_URL2, + "EditBookmark: Should have updated the bookmark location in the database." + ); +}); + +add_task(async function test_change_location_from_Sidebar() { + let bm = await PlacesUtils.bookmarks.fetch({ url: TEST_URL2 }); + + await withSidebarTree("bookmarks", async function (tree) { + tree.selectItems([bm.guid]); + + await withBookmarksDialog( + false, + function openPropertiesDialog() { + tree.controller.doCommand("placesCmd_show:info"); + }, + async function test(dialogWin) { + // Check the initial location. + let locationPicker = dialogWin.document.getElementById( + "editBMPanel_locationField" + ); + Assert.equal( + locationPicker.value, + TEST_URL2, + "Sidebar - EditBookmark: The current location is the expected one." + ); + + // To check whether the lastModified field will be updated correctly. + let lastModified = _getLastModified(bm.guid); + + // Update the "location" field. + fillBookmarkTextField( + "editBMPanel_locationField", + TEST_URL3, + dialogWin, + false + ); + + Assert.equal( + locationPicker.value, + TEST_URL3, + "Sidebar - EditBookmark: The location is changed in dialog for prefered one." + ); + + // Confirm and close the dialog. + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + + await TestUtils.waitForCondition( + () => _getLastModified(bm.guid) > lastModified, + "Sidebar - EditBookmark: The lastModified will be greater than before updating." + ); + } + ); + + let updatedBm = await PlacesUtils.bookmarks.fetch(bm.guid); + Assert.equal( + updatedBm.url, + TEST_URL3, + "Sidebar - EditBookmark: Should have updated the bookmark location in the database." + ); + }); +}); + +function _getLastModified(guid) { + const toolbarNode = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.toolbarGuid + ).root; + + try { + for (let i = 0; i < toolbarNode.childCount; i++) { + const node = toolbarNode.getChild(i); + if (node.bookmarkGuid === guid) { + return node.lastModified; + } + } + + throw new Error(`Node for ${guid} was not found`); + } finally { + toolbarNode.containerOpen = false; + } +} 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 new file mode 100644 index 0000000000..ac9120d3d6 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_context_menu_contents.js @@ -0,0 +1,798 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Test removing bookmarks from the Bookmarks Toolbar and Library. + */ +const SECOND_BOOKMARK_TITLE = "Second Bookmark Title"; +const bookmarksInfo = [ + { + title: "firefox", + url: "http://example.com", + }, + { + title: "rules", + url: "http://example.com/2", + }, + { + title: "yo", + url: "http://example.com/2", + }, +]; +const TEST_URL = "about:mozilla"; + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "userContextEnabled", + "privacy.userContext.enabled" +); + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + + let toolbar = document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + + // Uncollapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + } + + registerCleanupFunction(async () => { + // Collapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, false); + } + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +let OptionItemExists = (elementId, doc = document) => { + let optionItem = doc.getElementById(elementId); + + Assert.ok(optionItem, `Context menu contains the menuitem ${elementId}`); + Assert.ok( + BrowserTestUtils.isVisible(optionItem), + `Context menu option ${elementId} is visible` + ); +}; + +let OptionsMatchExpected = (contextMenu, expectedOptionItems) => { + let idList = []; + for (let elem of contextMenu.children) { + if ( + BrowserTestUtils.isVisible(elem) && + elem.localName !== "menuseparator" + ) { + idList.push(elem.id); + } + } + + Assert.deepEqual( + idList.sort(), + expectedOptionItems.sort(), + "Content is the same across both lists" + ); +}; + +let checkContextMenu = async (cbfunc, optionItems, doc = document) => { + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: SECOND_BOOKMARK_TITLE, + url: TEST_URL, + }); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: bookmarksInfo, + }); + + // Open and check the context menu twice, once with + // `browser.tabs.loadBookmarksInTabs` set to true and again with it set to + // false. + for (let loadBookmarksInNewTab of [true, false]) { + info( + `Running checkContextMenu: ` + JSON.stringify({ loadBookmarksInNewTab }) + ); + + Services.prefs.setBoolPref( + "browser.tabs.loadBookmarksInTabs", + loadBookmarksInNewTab + ); + + // When `loadBookmarksInTabs` is true, the usual placesContext_open:newtab + // item is hidden and placesContext_open is shown. The tasks in this test + // assume that `loadBookmarksInTabs` is false, so when a caller expects + // placesContext_open:newtab to appear but not placesContext_open, add it to + // the list of expected items when the pref is set. + let expectedOptionItems = [...optionItems]; + if ( + loadBookmarksInNewTab && + optionItems.includes("placesContext_open:newtab") && + !optionItems.includes("placesContext_open") + ) { + expectedOptionItems.push("placesContext_open"); + } + + // The caller is responsible for opening the menu, via `cbfunc()`. + let contextMenu = await cbfunc(bookmark); + + for (let item of expectedOptionItems) { + OptionItemExists(item, doc); + } + + OptionsMatchExpected(contextMenu, expectedOptionItems); + + // Check the "default" attributes on placesContext_open and + // placesContext_open:newtab. + if (expectedOptionItems.includes("placesContext_open")) { + Assert.equal( + doc.getElementById("placesContext_open").getAttribute("default"), + loadBookmarksInNewTab ? "" : "true", + `placesContext_open has the correct "default" attribute when loadBookmarksInTabs = ${loadBookmarksInNewTab}` + ); + } + if (expectedOptionItems.includes("placesContext_open:newtab")) { + Assert.equal( + doc.getElementById("placesContext_open:newtab").getAttribute("default"), + loadBookmarksInNewTab ? "true" : "", + `placesContext_open:newtab has the correct "default" attribute when loadBookmarksInTabs = ${loadBookmarksInNewTab}` + ); + } + + contextMenu.hidePopup(); + } + + Services.prefs.clearUserPref("browser.tabs.loadBookmarksInTabs"); + await PlacesUtils.bookmarks.eraseEverything(); +}; + +add_task(async function test_bookmark_contextmenu_contents() { + let optionItems = [ + "placesContext_open:newtab", + "placesContext_open:newcontainertab", + "placesContext_open:newwindow", + "placesContext_open:newprivatewindow", + "placesContext_show_bookmark:info", + "placesContext_deleteBookmark", + "placesContext_cut", + "placesContext_copy", + "placesContext_paste_group", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + "placesContext_showAllBookmarks", + "toggle_PersonalToolbar", + "show-other-bookmarks_PersonalToolbar", + ]; + if (!userContextEnabled) { + optionItems.splice( + optionItems.indexOf("placesContext_open:newcontainertab"), + 1 + ); + } + + await checkContextMenu(async function () { + let toolbarBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "Bookmark Title", + url: TEST_URL, + }); + + let toolbarNode = getToolbarNodeForItemGuid(toolbarBookmark.guid); + + let contextMenu = document.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + + EventUtils.synthesizeMouseAtCenter(toolbarNode, { + button: 2, + type: "contextmenu", + }); + await popupShownPromise; + return contextMenu; + }, optionItems); + + let tabs = []; + let contextMenuOnContent; + + await checkContextMenu(async function () { + info("Check context menu after opening context menu on content"); + const toolbarBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "Bookmark Title", + url: TEST_URL, + }); + + info("Open context menu on about:config"); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:config" + ); + tabs.push(tab); + contextMenuOnContent = document.getElementById("contentAreaContextMenu"); + const popupShownPromiseOnContent = BrowserTestUtils.waitForEvent( + contextMenuOnContent, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(tab.linkedBrowser, { + button: 2, + type: "contextmenu", + }); + await popupShownPromiseOnContent; + contextMenuOnContent.hidePopup(); + + info("Check context menu on bookmark"); + const toolbarNode = getToolbarNodeForItemGuid(toolbarBookmark.guid); + const contextMenu = document.getElementById("placesContext"); + const popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + + EventUtils.synthesizeMouseAtCenter(toolbarNode, { + button: 2, + type: "contextmenu", + }); + await popupShownPromise; + + return contextMenu; + }, optionItems); + + // We need to do a thorough cleanup to avoid leaking the window of + // 'about:config'. + for (let tab of tabs) { + const tabClosed = BrowserTestUtils.waitForTabClosing(tab); + BrowserTestUtils.removeTab(tab); + await tabClosed; + } +}); + +add_task(async function test_empty_contextmenu_contents() { + let optionItems = [ + "placesContext_openBookmarkContainer:tabs", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + "placesContext_paste", + "placesContext_showAllBookmarks", + "toggle_PersonalToolbar", + "show-other-bookmarks_PersonalToolbar", + ]; + + await checkContextMenu(async function () { + let contextMenu = document.getElementById("placesContext"); + let toolbar = document.querySelector("#PlacesToolbarItems"); + let openToolbarContextMenuPromise = BrowserTestUtils.waitForPopupEvent( + contextMenu, + "shown" + ); + + // Use the end of the toolbar because the beginning (and even middle, on + // some resolutions) might be occluded by the empty toolbar message, which + // has a different context menu. + let bounds = toolbar.getBoundingClientRect(); + EventUtils.synthesizeMouse(toolbar, bounds.width - 5, 5, { + type: "contextmenu", + }); + + await openToolbarContextMenuPromise; + return contextMenu; + }, optionItems); +}); + +add_task(async function test_separator_contextmenu_contents() { + let optionItems = [ + "placesContext_delete", + "placesContext_cut", + "placesContext_copy", + "placesContext_paste_group", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + "placesContext_showAllBookmarks", + "toggle_PersonalToolbar", + "show-other-bookmarks_PersonalToolbar", + ]; + + await checkContextMenu(async function () { + let sep = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + + let toolbarNode = getToolbarNodeForItemGuid(sep.guid); + let contextMenu = document.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + + EventUtils.synthesizeMouseAtCenter(toolbarNode, { + button: 2, + type: "contextmenu", + }); + await popupShownPromise; + return contextMenu; + }, optionItems); +}); + +add_task(async function test_folder_contextmenu_contents() { + let optionItems = [ + "placesContext_openBookmarkContainer:tabs", + "placesContext_show_folder:info", + "placesContext_deleteFolder", + "placesContext_cut", + "placesContext_copy", + "placesContext_paste_group", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + "placesContext_sortBy:name", + "placesContext_showAllBookmarks", + "toggle_PersonalToolbar", + "show-other-bookmarks_PersonalToolbar", + ]; + + await checkContextMenu(async function () { + let folder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + + let toolbarNode = getToolbarNodeForItemGuid(folder.guid); + let contextMenu = document.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + + EventUtils.synthesizeMouseAtCenter(toolbarNode, { + button: 2, + type: "contextmenu", + }); + await popupShownPromise; + return contextMenu; + }, optionItems); +}); + +add_task(async function test_sidebar_folder_contextmenu_contents() { + let optionItems = [ + "placesContext_show_folder:info", + "placesContext_deleteFolder", + "placesContext_cut", + "placesContext_copy", + "placesContext_openBookmarkContainer:tabs", + "placesContext_sortBy:name", + "placesContext_paste_group", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + ]; + + await withSidebarTree("bookmarks", async tree => { + await checkContextMenu( + async bookmark => { + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + tree.selectItems([folder.guid]); + + let contextMenu = + SidebarUI.browser.contentDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(tree, { type: "contextmenu" }); + await popupShownPromise; + return contextMenu; + }, + optionItems, + SidebarUI.browser.contentDocument + ); + }); +}); + +add_task(async function test_sidebar_multiple_folders_contextmenu_contents() { + let optionItems = [ + "placesContext_show_folder:info", + "placesContext_deleteFolder", + "placesContext_cut", + "placesContext_copy", + "placesContext_sortBy:name", + "placesContext_paste_group", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + ]; + + await withSidebarTree("bookmarks", async tree => { + await checkContextMenu( + async bookmark => { + let folder1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "folder 1", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + let folder2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "folder 2", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + tree.selectItems([folder1.guid, folder2.guid]); + + let contextMenu = + SidebarUI.browser.contentDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(tree, { type: "contextmenu" }); + await popupShownPromise; + return contextMenu; + }, + optionItems, + SidebarUI.browser.contentDocument + ); + }); +}); + +add_task(async function test_sidebar_bookmark_contextmenu_contents() { + let optionItems = [ + "placesContext_open:newtab", + "placesContext_open:newcontainertab", + "placesContext_open:newwindow", + "placesContext_open:newprivatewindow", + "placesContext_show_bookmark:info", + "placesContext_deleteBookmark", + "placesContext_cut", + "placesContext_copy", + "placesContext_paste_group", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + ]; + if (!userContextEnabled) { + optionItems.splice( + optionItems.indexOf("placesContext_open:newcontainertab"), + 1 + ); + } + + await withSidebarTree("bookmarks", async tree => { + await checkContextMenu( + async bookmark => { + tree.selectItems([bookmark.guid]); + + let contextMenu = + SidebarUI.browser.contentDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(tree, { type: "contextmenu" }); + await popupShownPromise; + return contextMenu; + }, + optionItems, + SidebarUI.browser.contentDocument + ); + }); +}); + +add_task(async function test_sidebar_bookmark_search_contextmenu_contents() { + let optionItems = [ + "placesContext_open:newtab", + "placesContext_open:newcontainertab", + "placesContext_open:newwindow", + "placesContext_open:newprivatewindow", + "placesContext_showInFolder", + "placesContext_show_bookmark:info", + "placesContext_deleteBookmark", + "placesContext_cut", + "placesContext_copy", + ]; + if (!userContextEnabled) { + optionItems.splice( + optionItems.indexOf("placesContext_open:newcontainertab"), + 1 + ); + } + + await withSidebarTree("bookmarks", async tree => { + await checkContextMenu( + async bookmark => { + info("Checking bookmark sidebar menu contents in search context"); + // Perform a search first + let searchBox = + SidebarUI.browser.contentDocument.getElementById("search-box"); + searchBox.value = SECOND_BOOKMARK_TITLE; + searchBox.doCommand(); + tree.selectItems([bookmark.guid]); + + let contextMenu = + SidebarUI.browser.contentDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(tree, { type: "contextmenu" }); + await popupShownPromise; + return contextMenu; + }, + optionItems, + SidebarUI.browser.contentDocument + ); + }); +}); + +add_task(async function test_library_bookmark_contextmenu_contents() { + let optionItems = [ + "placesContext_open", + "placesContext_open:newtab", + "placesContext_open:newcontainertab", + "placesContext_open:newwindow", + "placesContext_open:newprivatewindow", + "placesContext_deleteBookmark", + "placesContext_cut", + "placesContext_copy", + "placesContext_paste_group", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + ]; + if (!userContextEnabled) { + optionItems.splice( + optionItems.indexOf("placesContext_open:newcontainertab"), + 1 + ); + } + + await withLibraryWindow("BookmarksToolbar", async ({ left, right }) => { + await checkContextMenu( + async bookmark => { + let contextMenu = right.ownerDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + right.selectItems([bookmark.guid]); + synthesizeClickOnSelectedTreeCell(right, { type: "contextmenu" }); + await popupShownPromise; + return contextMenu; + }, + optionItems, + right.ownerDocument + ); + }); +}); + +add_task(async function test_library_bookmark_search_contextmenu_contents() { + let optionItems = [ + "placesContext_open", + "placesContext_open:newtab", + "placesContext_open:newcontainertab", + "placesContext_open:newwindow", + "placesContext_open:newprivatewindow", + "placesContext_showInFolder", + "placesContext_deleteBookmark", + "placesContext_cut", + "placesContext_copy", + ]; + if (!userContextEnabled) { + optionItems.splice( + optionItems.indexOf("placesContext_open:newcontainertab"), + 1 + ); + } + + await withLibraryWindow("BookmarksToolbar", async ({ left, right }) => { + await checkContextMenu( + async bookmark => { + info("Checking bookmark library menu contents in search context"); + // Perform a search first + let searchBox = right.ownerDocument.getElementById("searchFilter"); + searchBox.value = SECOND_BOOKMARK_TITLE; + searchBox.doCommand(); + + let contextMenu = right.ownerDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + right.selectItems([bookmark.guid]); + synthesizeClickOnSelectedTreeCell(right, { type: "contextmenu" }); + await popupShownPromise; + return contextMenu; + }, + optionItems, + right.ownerDocument + ); + }); +}); + +add_task(async function test_sidebar_mixedselection_contextmenu_contents() { + let optionItems = [ + "placesContext_delete", + "placesContext_cut", + "placesContext_copy", + "placesContext_paste_group", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + ]; + + await withSidebarTree("bookmarks", async tree => { + await checkContextMenu( + async bookmark => { + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + tree.selectItems([bookmark.guid, folder.guid]); + + let contextMenu = + SidebarUI.browser.contentDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(tree, { type: "contextmenu" }); + await popupShownPromise; + return contextMenu; + }, + optionItems, + SidebarUI.browser.contentDocument + ); + }); +}); + +add_task(async function test_sidebar_multiple_bookmarks_contextmenu_contents() { + let optionItems = [ + "placesContext_openBookmarkLinks:tabs", + "placesContext_show_bookmark:info", + "placesContext_deleteBookmark", + "placesContext_cut", + "placesContext_copy", + "placesContext_paste_group", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + ]; + + await withSidebarTree("bookmarks", async tree => { + await checkContextMenu( + async bookmark => { + let bookmark2 = await PlacesUtils.bookmarks.insert({ + url: "http://example.com/", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + tree.selectItems([bookmark.guid, bookmark2.guid]); + + let contextMenu = + SidebarUI.browser.contentDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(tree, { type: "contextmenu" }); + await popupShownPromise; + return contextMenu; + }, + optionItems, + SidebarUI.browser.contentDocument + ); + }); +}); + +add_task(async function test_sidebar_multiple_links_contextmenu_contents() { + let optionItems = [ + "placesContext_openLinks:tabs", + "placesContext_delete_history", + "placesContext_copy", + "placesContext_createBookmark", + ]; + + await withSidebarTree("history", async tree => { + await checkContextMenu( + async bookmark => { + await PlacesTestUtils.addVisits([ + "http://example-1.com/", + "http://example-2.com/", + ]); + // Sort by last visited. + tree.ownerDocument.getElementById("bylastvisited").doCommand(); + tree.selectAll(); + + let contextMenu = + SidebarUI.browser.contentDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(tree, { type: "contextmenu" }); + await popupShownPromise; + return contextMenu; + }, + optionItems, + SidebarUI.browser.contentDocument + ); + }); +}); + +add_task(async function test_sidebar_mixed_bookmarks_contextmenu_contents() { + let optionItems = [ + "placesContext_delete", + "placesContext_cut", + "placesContext_copy", + "placesContext_paste_group", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + ]; + + await withSidebarTree("bookmarks", async tree => { + await checkContextMenu( + async bookmark => { + let folder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + tree.selectItems([bookmark.guid, folder.guid]); + + let contextMenu = + SidebarUI.browser.contentDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(tree, { type: "contextmenu" }); + await popupShownPromise; + return contextMenu; + }, + optionItems, + SidebarUI.browser.contentDocument + ); + }); +}); + +add_task(async function test_library_noselection_contextmenu_contents() { + let optionItems = [ + "placesContext_openBookmarkContainer:tabs", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + "placesContext_paste", + ]; + + await withLibraryWindow("BookmarksToolbar", async ({ left, right }) => { + await checkContextMenu( + async bookmark => { + let contextMenu = right.ownerDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + right.selectItems([]); + EventUtils.synthesizeMouseAtCenter( + right.body, + { type: "contextmenu" }, + right.ownerGlobal + ); + await popupShownPromise; + return contextMenu; + }, + optionItems, + right.ownerDocument + ); + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_copy_folder_tree.js b/browser/components/places/tests/browser/browser_bookmark_copy_folder_tree.js new file mode 100644 index 0000000000..71b947f7ac --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_copy_folder_tree.js @@ -0,0 +1,172 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + let mainFolder = await PlacesUtils.bookmarks.insert({ + title: "mainFolder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + }); + + await withSidebarTree("bookmarks", async function (tree) { + const selectedNodeComparator = { + equalTitle: itemNode => { + Assert.equal( + tree.selectedNode.title, + itemNode.title, + "Select expected title" + ); + }, + equalNode: itemNode => { + Assert.equal( + tree.selectedNode.bookmarkGuid, + itemNode.guid, + "Selected the expected node" + ); + }, + equalType: itemType => { + Assert.equal(tree.selectedNode.type, itemType, "Correct type"); + }, + + equalChildCount: childrenAmount => { + Assert.equal( + tree.selectedNode.childCount, + childrenAmount, + `${childrenAmount} children` + ); + }, + }; + let urlType = Ci.nsINavHistoryResultNode.RESULT_TYPE_URI; + + info("Create tree of: folderA => subFolderA => 3 bookmarkItems"); + await PlacesUtils.bookmarks.insertTree({ + guid: mainFolder.guid, + children: [ + { + title: "FolderA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "subFolderA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "firstBM", + url: "http://example.com/1", + }, + { + title: "secondBM", + url: "http://example.com/2", + }, + { + title: "thirdBM", + url: "http://example.com/3", + }, + ], + }, + ], + }, + ], + }); + + info("Sanity check folderA, subFolderA, bookmarkItems"); + tree.selectItems([mainFolder.guid]); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + selectedNodeComparator.equalTitle(mainFolder); + selectedNodeComparator.equalChildCount(1); + + let sourceFolder = tree.selectedNode.getChild(0); + tree.selectNode(sourceFolder); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + selectedNodeComparator.equalTitle(sourceFolder); + selectedNodeComparator.equalChildCount(1); + + let subSourceFolder = tree.selectedNode.getChild(0); + tree.selectNode(subSourceFolder); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + selectedNodeComparator.equalTitle(subSourceFolder); + selectedNodeComparator.equalChildCount(3); + + let bm_2 = tree.selectedNode.getChild(1); + tree.selectNode(bm_2); + selectedNodeComparator.equalTitle(bm_2); + + info( + "Copy folder tree from sourceFolder (folderA, subFolderA, bookmarkItems)" + ); + tree.selectNode(sourceFolder); + await promiseClipboard(() => { + tree.controller.copy(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + tree.selectItems([mainFolder.guid]); + + info("Paste copy of folderA"); + await tree.controller.paste(); + + info("Sanity check copy/paste operation - mainFolder has 2 children"); + tree.selectItems([mainFolder.guid]); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + selectedNodeComparator.equalChildCount(2); + + info("Sanity check copy of folderA"); + let copySourceFolder = tree.selectedNode.getChild(1); + tree.selectNode(copySourceFolder); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + selectedNodeComparator.equalTitle(copySourceFolder); + selectedNodeComparator.equalChildCount(1); + + info("Sanity check copy subFolderA"); + let copySubSourceFolder = tree.selectedNode.getChild(0); + tree.selectNode(copySubSourceFolder); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + selectedNodeComparator.equalTitle(copySubSourceFolder); + selectedNodeComparator.equalChildCount(3); + + info("Sanity check copy BookmarkItem"); + let copyBm_1 = tree.selectedNode.getChild(0); + tree.selectNode(copyBm_1); + selectedNodeComparator.equalTitle(copyBm_1); + + info("Undo copy operation"); + await PlacesTransactions.undo(); + tree.selectItems([mainFolder.guid]); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + + info("Sanity check undo operation - mainFolder has 1 child"); + selectedNodeComparator.equalChildCount(1); + + info("Redo copy operation"); + await PlacesTransactions.redo(); + tree.selectItems([mainFolder.guid]); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + + info("Sanity check redo operation - mainFolder has 2 children"); + selectedNodeComparator.equalChildCount(2); + + info("Sanity check copy of folderA"); + copySourceFolder = tree.selectedNode.getChild(1); + tree.selectNode(copySourceFolder); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + selectedNodeComparator.equalChildCount(1); + + info("Sanity check copy subFolderA"); + copySubSourceFolder = tree.selectedNode.getChild(0); + tree.selectNode(copySubSourceFolder); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + selectedNodeComparator.equalTitle(copySubSourceFolder); + selectedNodeComparator.equalChildCount(3); + + info("Sanity check copy BookmarkItem"); + let copyBm_2 = tree.selectedNode.getChild(1); + tree.selectNode(copyBm_2); + selectedNodeComparator.equalTitle(copyBm_2); + selectedNodeComparator.equalType(urlType); + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_folder_moveability.js b/browser/components/places/tests/browser/browser_bookmark_folder_moveability.js new file mode 100644 index 0000000000..d981aa4713 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_folder_moveability.js @@ -0,0 +1,139 @@ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function () { + let root = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + }); + + await withSidebarTree("bookmarks", async function (tree) { + info("Test a regular folder"); + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: root.guid, + title: "", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + tree.selectItems([folder.guid]); + Assert.equal( + tree.selectedNode.bookmarkGuid, + folder.guid, + "Selected the expected node" + ); + Assert.equal(tree.selectedNode.type, 6, "node is a folder"); + Assert.ok( + tree.controller.canMoveNode(tree.selectedNode), + "can move regular folder node" + ); + + info("Test a folder shortcut"); + let shortcut = await PlacesUtils.bookmarks.insert({ + parentGuid: root.guid, + title: "bar", + url: `place:parent=${folder.guid}`, + }); + tree.selectItems([shortcut.guid]); + Assert.equal( + tree.selectedNode.bookmarkGuid, + shortcut.guid, + "Selected the expected node" + ); + Assert.equal(tree.selectedNode.type, 9, "node is a folder shortcut"); + Assert.equal( + PlacesUtils.getConcreteItemGuid(tree.selectedNode), + folder.guid, + "shortcut node guid and concrete guid match" + ); + Assert.ok( + tree.controller.canMoveNode(tree.selectedNode), + "can move folder shortcut node" + ); + + info("Test a query"); + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: root.guid, + title: "", + url: "http://foo.com", + }); + tree.selectItems([bookmark.guid]); + Assert.equal( + tree.selectedNode.bookmarkGuid, + bookmark.guid, + "Selected the expected node" + ); + let query = await PlacesUtils.bookmarks.insert({ + parentGuid: root.guid, + title: "bar", + url: `place:terms=foo`, + }); + tree.selectItems([query.guid]); + Assert.equal( + tree.selectedNode.bookmarkGuid, + query.guid, + "Selected the expected node" + ); + Assert.ok( + tree.controller.canMoveNode(tree.selectedNode), + "can move query node" + ); + + info("Test a tag container"); + PlacesUtils.tagging.tagURI(bookmark.url.URI, ["bar"]); + // Add the tags root query. + let tagsQuery = await PlacesUtils.bookmarks.insert({ + parentGuid: root.guid, + title: "", + url: "place:type=" + Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT, + }); + tree.selectItems([tagsQuery.guid]); + PlacesUtils.asQuery(tree.selectedNode).containerOpen = true; + Assert.equal(tree.selectedNode.childCount, 1, "has tags"); + let tagNode = tree.selectedNode.getChild(0); + Assert.ok( + !tree.controller.canMoveNode(tagNode), + "should not be able to move tag container node" + ); + tree.selectedNode.containerOpen = false; + + info( + "Test that special folders and cannot be moved but other shortcuts can." + ); + let roots = [ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.toolbarGuid, + ]; + + for (let guid of roots) { + tree.selectItems([guid]); + Assert.ok( + !tree.controller.canMoveNode(tree.selectedNode), + "shouldn't be able to move default shortcuts to roots" + ); + let s = await PlacesUtils.bookmarks.insert({ + parentGuid: root.guid, + title: "bar", + url: `place:parent=${guid}`, + }); + tree.selectItems([s.guid]); + Assert.equal( + tree.selectedNode.bookmarkGuid, + s.guid, + "Selected the expected node" + ); + Assert.ok( + tree.controller.canMoveNode(tree.selectedNode), + "should be able to move user-created shortcuts to roots" + ); + } + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_menu_ctrl_click.js b/browser/components/places/tests/browser/browser_bookmark_menu_ctrl_click.js new file mode 100644 index 0000000000..4e3d29cf21 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_menu_ctrl_click.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function onPopupEvent(popup, evt) { + let fullEvent = "popup" + evt; + return BrowserTestUtils.waitForEvent(popup, fullEvent, false, e => { + return e.target == popup; + }); +} + +add_task(async function test_bookmarks_menu() { + // On macOS, ctrl-click shouldn't open the panel because this normally opens + // the context menu. This happens via the `contextmenu` event which is created + // by widget code, so our simulated clicks do not do so, so we can't test + // anything on macOS. + if (AppConstants.platform == "macosx") { + ok(true, "The test is ignored on Mac"); + return; + } + + CustomizableUI.addWidgetToArea( + "bookmarks-menu-button", + CustomizableUI.AREA_NAVBAR, + 4 + ); + + const button = document.getElementById("bookmarks-menu-button"); + const popup = document.getElementById("BMB_bookmarksPopup"); + + let shownPromise = onPopupEvent(popup, "shown"); + EventUtils.synthesizeMouseAtCenter(button, { ctrlKey: true }); + await shownPromise; + ok(true, "Bookmarks menu shown after button pressed"); + + // Close bookmarks popup. + let hiddenPromise = onPopupEvent(popup, "hidden"); + popup.hidePopup(); + await hiddenPromise; + + CustomizableUI.reset(); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_popup.js b/browser/components/places/tests/browser/browser_bookmark_popup.js new file mode 100644 index 0000000000..616755b7e1 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_popup.js @@ -0,0 +1,712 @@ +/* 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 mozilla/no-arbitrary-setTimeout */ + +"use strict"; +requestLongerTimeout(2); + +/** + * Test opening and closing the bookmarks panel. + */ +let win; +let bookmarkPanel; +let bookmarkStar; +let bookmarkPanelTitle; +let bookmarkRemoveButton; +let editBookmarkPanelRemoveButtonRect; + +const TEST_URL = "data:text/html,"; + +add_setup(async function () { + win = await BrowserTestUtils.openNewBrowserWindow(); + + win.StarUI._createPanelIfNeeded(); + win.StarUI._closePanelQuickForTesting = true; + bookmarkPanel = win.document.getElementById("editBookmarkPanel"); + bookmarkPanel.setAttribute("animate", false); + bookmarkStar = win.BookmarkingUI.star; + bookmarkPanelTitle = win.document.getElementById("editBookmarkPanelTitle"); + bookmarkRemoveButton = win.document.getElementById( + "editBookmarkPanelRemoveButton" + ); + + registerCleanupFunction(async () => { + delete win.StarUI._closePanelQuickForTesting; + await BrowserTestUtils.closeWindow(win); + }); +}); + +function mouseout() { + let mouseOutPromise = BrowserTestUtils.waitForEvent( + bookmarkPanel, + "mouseout" + ); + EventUtils.synthesizeNativeMouseEvent({ + type: "mousemove", + target: win.gURLBar.textbox, + offsetX: 0, + offsetY: 0, + win, + }); + EventUtils.synthesizeMouse(bookmarkPanel, 0, 0, { type: "mouseout" }, win); + info("Waiting for mouseout event"); + return mouseOutPromise; +} + +async function test_bookmarks_popup({ + isNewBookmark, + popupShowFn, + popupEditFn, + shouldAutoClose, + popupHideFn, + isBookmarkRemoved, +}) { + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url: TEST_URL }, + async function (browser) { + try { + if (!isNewBookmark) { + await PlacesUtils.bookmarks.insert({ + parentGuid: await PlacesUIUtils.defaultParentGuid, + url: TEST_URL, + title: "Home Page", + }); + } + + info(`BookmarkingUI.status is ${win.BookmarkingUI.status}`); + await TestUtils.waitForCondition( + () => win.BookmarkingUI.status != win.BookmarkingUI.STATUS_UPDATING, + "BookmarkingUI should not be updating" + ); + + Assert.equal( + bookmarkStar.hasAttribute("starred"), + !isNewBookmark, + "Page should only be starred prior to popupshown if editing bookmark" + ); + Assert.equal( + bookmarkPanel.state, + "closed", + "Panel should be 'closed' to start test" + ); + let shownPromise = promisePopupShown(bookmarkPanel); + await popupShowFn(browser); + await shownPromise; + Assert.equal( + bookmarkPanel.state, + "open", + "Panel should be 'open' after shownPromise is resolved" + ); + + editBookmarkPanelRemoveButtonRect = + bookmarkRemoveButton.getBoundingClientRect(); + + if (popupEditFn) { + await popupEditFn(); + } + Assert.equal( + bookmarkStar.getAttribute("starred"), + "true", + "Page is starred" + ); + Assert.equal( + bookmarkPanelTitle.dataset.l10nId, + isNewBookmark ? "bookmarks-add-bookmark" : "bookmarks-edit-bookmark", + "title should match isEditingBookmark state" + ); + Assert.equal( + bookmarkRemoveButton.dataset.l10nId, + isNewBookmark ? "bookmark-panel-cancel" : "bookmark-panel-remove", + "remove/cancel button label should match isEditingBookmark state" + ); + + if (!shouldAutoClose) { + await new Promise(resolve => setTimeout(resolve, 400)); + Assert.equal( + bookmarkPanel.state, + "open", + "Panel should still be 'open' for non-autoclose" + ); + } + + let defaultLocation = await PlacesUIUtils.defaultParentGuid; + const promises = []; + if (isNewBookmark && !isBookmarkRemoved) { + // Expect new bookmark to be created. + promises.push( + PlacesTestUtils.waitForNotification("bookmark-added", events => + events.some( + ({ parentGuid, url }) => + parentGuid == defaultLocation && TEST_URL == url + ) + ) + ); + } + if (!isNewBookmark && isBookmarkRemoved) { + // Expect existing bookmark to be removed. + promises.push( + PlacesTestUtils.waitForNotification("bookmark-removed", events => + events.some( + ({ parentGuid, url }) => + parentGuid == defaultLocation && TEST_URL == url + ) + ) + ); + } + + promises.push(promisePopupHidden(bookmarkPanel)); + if (popupHideFn) { + await popupHideFn(); + } else { + // Move the mouse out of the way so that the panel will auto-close. + await mouseout(); + } + await Promise.all(promises); + + Assert.equal( + bookmarkStar.hasAttribute("starred"), + !isBookmarkRemoved, + "Page is starred after closing" + ); + + // Count number of bookmarks. + let count = 0; + await PlacesUtils.bookmarks.fetch({ url: TEST_URL }, () => count++); + const message = isBookmarkRemoved + ? "No bookmark should exist" + : "Only one bookmark should exist"; + Assert.equal(count, isBookmarkRemoved ? 0 : 1, message); + } finally { + let bookmark = await PlacesUtils.bookmarks.fetch({ url: TEST_URL }); + Assert.equal( + !!bookmark, + !isBookmarkRemoved, + "bookmark should not be present if a panel action should've removed it" + ); + if (bookmark) { + await PlacesUtils.bookmarks.remove(bookmark); + } + } + } + ); +} + +add_task(async function panel_shown_for_new_bookmarks_and_autocloses() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn() { + bookmarkStar.click(); + }, + shouldAutoClose: true, + isBookmarkRemoved: false, + }); +}); + +add_task( + async function panel_shown_once_for_doubleclick_on_new_bookmark_star_and_autocloses() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn() { + EventUtils.synthesizeMouse( + bookmarkStar, + 10, + 10, + { clickCount: 2 }, + win + ); + }, + shouldAutoClose: true, + isBookmarkRemoved: false, + }); + } +); + +add_task( + async function panel_shown_once_for_slow_doubleclick_on_new_bookmark_star_and_autocloses() { + todo( + false, + "bug 1250267, may need to add some tracking state to " + + "browser-places.js for this." + ); + + /* + await test_bookmarks_popup({ + isNewBookmark: true, + *popupShowFn() { + EventUtils.synthesizeMouse(bookmarkStar, 10, 10, window); + await new Promise(resolve => setTimeout(resolve, 300)); + EventUtils.synthesizeMouse(bookmarkStar, 10, 10, window); + }, + shouldAutoClose: true, + isBookmarkRemoved: false, + }); + */ + } +); + +add_task( + async function panel_shown_for_keyboardshortcut_on_new_bookmark_star_and_autocloses() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn() { + EventUtils.synthesizeKey("D", { accelKey: true }, win); + }, + shouldAutoClose: true, + isBookmarkRemoved: false, + }); + } +); + +add_task(async function panel_shown_for_new_bookmarks_mousemove_mouseout() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn() { + bookmarkStar.click(); + }, + async popupEditFn() { + let mouseMovePromise = BrowserTestUtils.waitForEvent( + bookmarkPanel, + "mousemove" + ); + EventUtils.synthesizeMouseAtCenter( + bookmarkPanel, + { type: "mousemove" }, + win + ); + info("Waiting for mousemove event"); + await mouseMovePromise; + info("Got mousemove event"); + + await new Promise(resolve => setTimeout(resolve, 400)); + is( + bookmarkPanel.state, + "open", + "Panel should still be open on mousemove" + ); + }, + async popupHideFn() { + await mouseout(); + info("Got mouseout event, should autoclose now"); + }, + shouldAutoClose: false, + isBookmarkRemoved: false, + }); +}); + +add_task(async function panel_shown_for_new_bookmark_close_with_ESC() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn() { + bookmarkStar.click(); + }, + shouldAutoClose: true, + popupHideFn() { + EventUtils.synthesizeKey("VK_ESCAPE", {}, win); + }, + isBookmarkRemoved: true, + }); +}); + +add_task(async function panel_shown_for_editing_no_autoclose_close_with_ESC() { + await test_bookmarks_popup({ + isNewBookmark: false, + popupShowFn() { + bookmarkStar.click(); + }, + shouldAutoClose: false, + popupHideFn() { + EventUtils.synthesizeKey("VK_ESCAPE", {}, win); + }, + isBookmarkRemoved: false, + }); +}); + +add_task(async function panel_shown_for_new_bookmark_keypress_no_autoclose() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn() { + bookmarkStar.click(); + }, + popupEditFn() { + EventUtils.sendChar("VK_TAB", win); + }, + shouldAutoClose: false, + popupHideFn() { + bookmarkPanel.hidePopup(); + }, + isBookmarkRemoved: false, + }); +}); + +add_task(async function bookmark_with_invalid_default_folder() { + await createAndRemoveDefaultFolder(); + + await test_bookmarks_popup({ + isNewBookmark: true, + shouldAutoClose: true, + async popupShowFn(browser) { + EventUtils.synthesizeKey("d", { accelKey: true }, win); + }, + }); +}); + +add_task( + async function panel_shown_for_new_bookmark_compositionstart_no_autoclose() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn() { + bookmarkStar.click(); + }, + async popupEditFn() { + let compositionStartPromise = BrowserTestUtils.waitForEvent( + bookmarkPanel, + "compositionstart" + ); + EventUtils.synthesizeComposition({ type: "compositionstart" }, win); + info("Waiting for compositionstart event"); + await compositionStartPromise; + info("Got compositionstart event"); + }, + shouldAutoClose: false, + popupHideFn() { + EventUtils.synthesizeComposition( + { type: "compositioncommitasis" }, + win + ); + bookmarkPanel.hidePopup(); + }, + isBookmarkRemoved: false, + }); + } +); + +add_task( + async function panel_shown_for_new_bookmark_compositionstart_mouseout_no_autoclose() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn() { + bookmarkStar.click(); + }, + async popupEditFn() { + let mouseMovePromise = BrowserTestUtils.waitForEvent( + bookmarkPanel, + "mousemove" + ); + EventUtils.synthesizeMouseAtCenter( + bookmarkPanel, + { + type: "mousemove", + }, + win + ); + info("Waiting for mousemove event"); + await mouseMovePromise; + info("Got mousemove event"); + + let compositionStartPromise = BrowserTestUtils.waitForEvent( + bookmarkPanel, + "compositionstart" + ); + EventUtils.synthesizeComposition({ type: "compositionstart" }, win); + info("Waiting for compositionstart event"); + await compositionStartPromise; + info("Got compositionstart event"); + + await mouseout(); + info("Got mouseout event, but shouldn't run autoclose"); + }, + shouldAutoClose: false, + popupHideFn() { + EventUtils.synthesizeComposition( + { type: "compositioncommitasis" }, + win + ); + bookmarkPanel.hidePopup(); + }, + isBookmarkRemoved: false, + }); + } +); + +add_task( + async function panel_shown_for_new_bookmark_compositionend_no_autoclose() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn() { + bookmarkStar.click(); + }, + async popupEditFn() { + let mouseMovePromise = BrowserTestUtils.waitForEvent( + bookmarkPanel, + "mousemove" + ); + EventUtils.synthesizeMouseAtCenter( + bookmarkPanel, + { + type: "mousemove", + }, + win + ); + info("Waiting for mousemove event"); + await mouseMovePromise; + info("Got mousemove event"); + + EventUtils.synthesizeComposition( + { + type: "compositioncommit", + data: "committed text", + }, + win + ); + }, + popupHideFn() { + bookmarkPanel.hidePopup(); + }, + shouldAutoClose: false, + isBookmarkRemoved: false, + }); + } +); + +add_task(async function contextmenu_new_bookmark_keypress_no_autoclose() { + await test_bookmarks_popup({ + isNewBookmark: true, + async popupShowFn(browser) { + let contextMenu = win.document.getElementById("contentAreaContextMenu"); + let awaitPopupShown = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + let awaitPopupHidden = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "body", + { + type: "contextmenu", + button: 2, + }, + browser + ); + await awaitPopupShown; + contextMenu.activateItem( + win.document.getElementById("context-bookmarkpage") + ); + await awaitPopupHidden; + }, + popupEditFn() { + EventUtils.sendChar("VK_TAB", win); + }, + shouldAutoClose: false, + popupHideFn() { + bookmarkPanel.hidePopup(); + }, + isBookmarkRemoved: false, + }); +}); + +add_task(async function bookmarks_menu_new_bookmark_remove_bookmark() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn(browser) { + win.document.getElementById("menu_bookmarkThisPage").doCommand(); + }, + shouldAutoClose: true, + popupHideFn() { + win.document.getElementById("editBookmarkPanelRemoveButton").click(); + }, + isBookmarkRemoved: true, + }); +}); + +add_task(async function ctrl_d_edit_bookmark_remove_bookmark() { + await test_bookmarks_popup({ + isNewBookmark: false, + popupShowFn(browser) { + EventUtils.synthesizeKey("D", { accelKey: true }, win); + }, + shouldAutoClose: true, + popupHideFn() { + win.document.getElementById("editBookmarkPanelRemoveButton").click(); + }, + isBookmarkRemoved: true, + }); +}); + +add_task(async function enter_on_remove_bookmark_should_remove_bookmark() { + if (AppConstants.platform == "macosx") { + // "Full Keyboard Access" is disabled by default, and thus doesn't allow + // keyboard navigation to the "Remove Bookmarks" button by default. + return; + } + + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn(browser) { + EventUtils.synthesizeKey("D", { accelKey: true }, win); + }, + shouldAutoClose: true, + popupHideFn() { + while ( + !win.document.activeElement || + win.document.activeElement.id != "editBookmarkPanelRemoveButton" + ) { + EventUtils.sendChar("VK_TAB", win); + } + EventUtils.sendChar("VK_RETURN", win); + }, + isBookmarkRemoved: true, + }); +}); + +add_task(async function mouse_hovering_panel_should_prevent_autoclose() { + if (AppConstants.platform != "win") { + // This test requires synthesizing native mouse movement which is + // best supported on Windows. + return; + } + await test_bookmarks_popup({ + isNewBookmark: true, + async popupShowFn() { + await EventUtils.promiseNativeMouseEvent({ + type: "mousemove", + target: win.document.documentElement, + offsetX: editBookmarkPanelRemoveButtonRect.left, + offsetY: editBookmarkPanelRemoveButtonRect.top, + }); + EventUtils.synthesizeKey("D", { accelKey: true }, win); + }, + shouldAutoClose: false, + popupHideFn() { + win.document.getElementById("editBookmarkPanelRemoveButton").click(); + }, + isBookmarkRemoved: true, + }); +}); + +add_task(async function ctrl_d_new_bookmark_mousedown_mouseout_no_autoclose() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn(browser) { + EventUtils.synthesizeKey("D", { accelKey: true }, win); + }, + async popupEditFn() { + let mouseMovePromise = BrowserTestUtils.waitForEvent( + bookmarkPanel, + "mousemove" + ); + EventUtils.synthesizeMouseAtCenter( + bookmarkPanel, + { type: "mousemove" }, + win + ); + info("Waiting for mousemove event"); + await mouseMovePromise; + info("Got mousemove event"); + + await new Promise(resolve => setTimeout(resolve, 400)); + is( + bookmarkPanel.state, + "open", + "Panel should still be open on mousemove" + ); + + EventUtils.synthesizeMouseAtCenter( + bookmarkPanelTitle, + { + button: 1, + type: "mousedown", + }, + win + ); + + await mouseout(); + }, + shouldAutoClose: false, + popupHideFn() { + win.document.getElementById("editBookmarkPanelRemoveButton").click(); + }, + isBookmarkRemoved: true, + }); +}); + +add_task(async function enter_during_autocomplete_should_prevent_autoclose() { + await test_bookmarks_popup({ + isNewBookmark: false, + async popupShowFn(browser) { + PlacesUtils.tagging.tagURI(makeURI(TEST_URL), ["Abc"]); + EventUtils.synthesizeKey("d", { accelKey: true }, win); + }, + async popupEditFn() { + // Start autocomplete with the registered tag. + let tagsField = win.document.getElementById("editBMPanel_tagsField"); + tagsField.value = ""; + let popup = win.document.getElementById("editBMPanel_tagsAutocomplete"); + let promiseShown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + tagsField.focus(); + EventUtils.sendString("a", win); + await promiseShown; + ok(promiseShown, "autocomplete shown"); + + // Select first candidate. + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + + // Type Enter key to choose the item. + EventUtils.synthesizeKey("KEY_Enter", {}, win); + + Assert.equal( + tagsField.value, + "Abc", + "Autocomplete should've inserted the selected item" + ); + }, + shouldAutoClose: false, + popupHideFn() { + EventUtils.synthesizeKey("KEY_Escape", {}, win); + }, + isBookmarkRemoved: false, + }); +}); + +add_task(async function escape_during_autocomplete_should_prevent_autoclose() { + await test_bookmarks_popup({ + isNewBookmark: false, + async popupShowFn(browser) { + PlacesUtils.tagging.tagURI(makeURI(TEST_URL), ["Abc"]); + EventUtils.synthesizeKey("d", { accelKey: true }, win); + }, + async popupEditFn() { + // Start autocomplete with the registered tag. + let tagsField = win.document.getElementById("editBMPanel_tagsField"); + tagsField.value = ""; + let popup = win.document.getElementById("editBMPanel_tagsAutocomplete"); + let promiseShown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + tagsField.focus(); + EventUtils.sendString("a", win); + await promiseShown; + ok(promiseShown, "autocomplete shown"); + + // Select first candidate. + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + + // Type Escape key to close autocomplete. + EventUtils.synthesizeKey("KEY_Escape", {}, win); + + // The text reverts to what was typed. + // Note, it's important that this is different from the previously + // inserted tag, since it will test an untag/tag undo condition. + Assert.equal( + tagsField.value, + "a", + "Autocomplete should revert to what was typed" + ); + }, + shouldAutoClose: false, + popupHideFn() { + EventUtils.synthesizeKey("KEY_Escape", {}, win); + }, + isBookmarkRemoved: false, + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_private_window.js b/browser/components/places/tests/browser/browser_bookmark_private_window.js new file mode 100644 index 0000000000..2557833816 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_private_window.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * Test that the a website can be bookmarked from a private window. + */ +"use strict"; + +const TEST_URL = "about:buildconfig"; + +// Cleanup. +registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_add_bookmark_from_private_window() { + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + let tab = await BrowserTestUtils.openNewForegroundTab(win.gBrowser, TEST_URL); + + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.closeWindow(win); + }); + + // Open the bookmark panel. + win.StarUI._createPanelIfNeeded(); + let bookmarkPanel = win.document.getElementById("editBookmarkPanel"); + let shownPromise = promisePopupShown(bookmarkPanel); + let bookmarkAddedPromise = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => events.some(({ url }) => url === TEST_URL) + ); + let bookmarkStar = win.BookmarkingUI.star; + bookmarkStar.click(); + await shownPromise; + + // Check if the bookmark star changes its state after click. + Assert.equal( + bookmarkStar.getAttribute("starred"), + "true", + "Bookmark star changed its state correctly." + ); + + // Close the bookmark panel. + let hiddenPromise = promisePopupHidden(bookmarkPanel); + let doneButton = win.document.getElementById("editBookmarkPanelDoneButton"); + doneButton.click(); + await hiddenPromise; + await bookmarkAddedPromise; + + let bm = await PlacesUtils.bookmarks.fetch({ url: TEST_URL }); + Assert.equal( + bm.url, + TEST_URL, + "The bookmark was successfully saved in the database." + ); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_remove_tags.js b/browser/components/places/tests/browser/browser_bookmark_remove_tags.js new file mode 100644 index 0000000000..ece5fdbce0 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_remove_tags.js @@ -0,0 +1,256 @@ +/** + * Tests that the bookmark tags can be removed from the bookmark star, toolbar and sidebar. + */ +"use strict"; + +const TEST_URL = "about:buildconfig"; +const TEST_URI = Services.io.newURI(TEST_URL); +const TEST_TAG = "tag"; + +// Setup. +add_setup(async function () { + let toolbar = document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + + // Uncollapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + } + + // Cleanup. + registerCleanupFunction(async () => { + // Collapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, false); + } + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_remove_tags_from_BookmarkStar() { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_URL, + title: TEST_URL, + }); + PlacesUtils.tagging.tagURI(TEST_URI, ["tag1", "tag2", "tag3", "tag4"]); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_URL, + waitForStateStop: true, + }); + + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(tab); + }); + + StarUI._createPanelIfNeeded(); + await clickBookmarkStar(); + + // Check if the "Edit This Bookmark" panel is open. + let bookmarkPanelTitle = document.getElementById("editBookmarkPanelTitle"); + Assert.equal( + document.l10n.getAttributes(bookmarkPanelTitle).id, + "bookmarks-edit-bookmark", + "Bookmark panel title is correct." + ); + + let promiseTagsChange = PlacesTestUtils.waitForNotification( + "bookmark-tags-changed" + ); + + // Update the "tags" field. + fillBookmarkTextField("editBMPanel_tagsField", "tag1, tag2, tag3", window); + let tagspicker = document.getElementById("editBMPanel_tagsField"); + await TestUtils.waitForCondition( + () => tagspicker.value === "tag1, tag2, tag3", + "Tags are correct after update." + ); + + let doneButton = document.getElementById("editBookmarkPanelDoneButton"); + doneButton.click(); + await promiseTagsChange; + + let tags = PlacesUtils.tagging.getTagsForURI(TEST_URI); + Assert.deepEqual( + tags, + ["tag1", "tag2", "tag3"], + "Should have updated the bookmark tags in the database." + ); +}); + +add_task(async function test_remove_tags_from_Toolbar() { + let toolbarBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: TEST_URL, + url: TEST_URL, + }); + + let toolbarNode = getToolbarNodeForItemGuid(toolbarBookmark.guid); + + await withBookmarksDialog( + false, + async function openPropertiesDialog() { + let placesContext = document.getElementById("placesContext"); + let promisePopup = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(toolbarNode, { + button: 2, + type: "contextmenu", + }); + await promisePopup; + + let properties = document.getElementById( + "placesContext_show_bookmark:info" + ); + placesContext.activateItem(properties, {}); + }, + async function test(dialogWin) { + let tagspicker = dialogWin.document.getElementById( + "editBMPanel_tagsField" + ); + Assert.equal( + tagspicker.value, + "tag1, tag2, tag3", + "Tags are correct before update." + ); + + let promiseTagsChange = PlacesTestUtils.waitForNotification( + "bookmark-tags-changed" + ); + + // Update the "tags" field. + fillBookmarkTextField( + "editBMPanel_tagsField", + "tag1, tag2", + dialogWin, + false + ); + await TestUtils.waitForCondition( + () => tagspicker.value === "tag1, tag2", + "Tags are correct after update." + ); + + // Confirm and close the dialog. + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + await promiseTagsChange; + + let tags = PlacesUtils.tagging.getTagsForURI(TEST_URI); + Assert.deepEqual( + tags, + ["tag1", "tag2"], + "Should have updated the bookmark tags in the database." + ); + } + ); +}); + +add_task(async function test_remove_tags_from_Sidebar() { + let bookmarks = []; + await PlacesUtils.bookmarks.fetch({ url: TEST_URL }, bm => + bookmarks.push(bm) + ); + + await withSidebarTree("bookmarks", async function (tree) { + tree.selectItems([bookmarks[0].guid]); + + await withBookmarksDialog( + false, + function openPropertiesDialog() { + tree.controller.doCommand("placesCmd_show:info"); + }, + async function test(dialogWin) { + let tagspicker = dialogWin.document.getElementById( + "editBMPanel_tagsField" + ); + Assert.equal( + tagspicker.value, + "tag1, tag2", + "Tags are correct before update." + ); + + let promiseTagsChange = PlacesTestUtils.waitForNotification( + "bookmark-tags-changed" + ); + + // Update the "tags" field. + fillBookmarkTextField( + "editBMPanel_tagsField", + "tag1", + dialogWin, + false + ); + await TestUtils.waitForCondition( + () => tagspicker.value === "tag1", + "Tags are correct after update." + ); + + // Confirm and close the dialog. + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + await promiseTagsChange; + + let tags = PlacesUtils.tagging.getTagsForURI(TEST_URI); + Assert.deepEqual( + tags, + ["tag1"], + "Should have updated the bookmark tags in the database." + ); + } + ); + }); +}); + +add_task(async function test_remove_tags_from_Library() { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_URL, + title: TEST_URL, + }); + PlacesUtils.tagging.tagURI(TEST_URI, [TEST_TAG]); + const getTags = () => PlacesUtils.tagging.getTagsForURI(TEST_URI); + + // Open the Library and select the tag. + const library = await promiseLibrary("place:tag=" + TEST_TAG); + + registerCleanupFunction(async function () { + await promiseLibraryClosed(library); + }); + + const contextMenu = library.document.getElementById("placesContext"); + const contextMenuDeleteTag = library.document.getElementById( + "placesContext_removeTag" + ); + + let firstColumn = library.ContentTree.view.columns[0]; + let firstBookmarkRect = library.ContentTree.view.getCoordsForCellItem( + 0, + firstColumn, + "bm0" + ); + + EventUtils.synthesizeMouse( + library.ContentTree.view.body, + firstBookmarkRect.x, + firstBookmarkRect.y, + { type: "contextmenu", button: 2 }, + library + ); + + await BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + + ok(getTags().includes(TEST_TAG), "Test tag exists before delete."); + + contextMenu.activateItem(contextMenuDeleteTag, {}); + + await PlacesTestUtils.waitForNotification("bookmark-tags-changed"); + await promiseLibraryClosed(library); + + ok( + await PlacesUtils.bookmarks.fetch({ url: TEST_URL }), + "Bookmark still exists after removing tag." + ); + ok(!getTags().includes(TEST_TAG), "Test tag is removed after delete."); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_titles.js b/browser/components/places/tests/browser/browser_bookmark_titles.js new file mode 100644 index 0000000000..c91f9559e7 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_titles.js @@ -0,0 +1,129 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This file is tests for the default titles that new bookmarks get. + +var tests = [ + // Common page. + { + url: "http://example.com/browser/browser/components/places/tests/browser/dummy_page.html", + title: "Dummy test page", + isError: false, + }, + // Data URI. + { + url: "data:text/html;charset=utf-8,test%20data:%20url", + title: "test data: url", + isError: false, + }, + // about:neterror + { + url: "data:application/xhtml+xml,", + title: "data:application/xhtml+xml,", + isError: true, + }, + // about:certerror + { + url: "https://untrusted.example.com/somepage.html", + title: "https://untrusted.example.com/somepage.html", + isError: true, + }, +]; + +SpecialPowers.pushPrefEnv({ + set: [["browser.bookmarks.editDialog.showForNewBookmarks", false]], +}); + +add_task(async function check_default_bookmark_title() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://www.example.com/" + ); + let browser = tab.linkedBrowser; + + // Test that a bookmark of each URI gets the corresponding default title. + for (let { url, title, isError } of tests) { + let promiseLoaded = BrowserTestUtils.browserLoaded( + browser, + false, + url, + isError + ); + BrowserTestUtils.startLoadingURIString(browser, url); + await promiseLoaded; + + await checkBookmark(url, title); + } + + // Network failure test: now that dummy_page.html is in history, bookmarking + // it should give the last known page title as the default bookmark title. + + // Simulate a network outage with offline mode. (Localhost is still + // accessible in offline mode, so disable the test proxy as well.) + BrowserOffline.toggleOfflineStatus(); + let proxy = Services.prefs.getIntPref("network.proxy.type"); + Services.prefs.setIntPref("network.proxy.type", 0); + registerCleanupFunction(function () { + BrowserOffline.toggleOfflineStatus(); + Services.prefs.setIntPref("network.proxy.type", proxy); + }); + + // LOAD_FLAGS_BYPASS_CACHE isn't good enough. So clear the cache. + Services.cache2.clear(); + + let { url, title } = tests[0]; + + let promiseLoaded = BrowserTestUtils.browserLoaded( + browser, + false, + null, + true + ); + BrowserTestUtils.startLoadingURIString(browser, url); + await promiseLoaded; + + // The offline mode test is only good if the page failed to load. + await SpecialPowers.spawn(browser, [], function () { + Assert.equal( + content.document.documentURI.substring(0, 14), + "about:neterror", + "Offline mode successfully simulated network outage." + ); + }); + await checkBookmark(url, title); + + BrowserTestUtils.removeTab(tab); +}); + +// Bookmark the current page and confirm that the new bookmark has the expected +// title. (Then delete the bookmark.) +async function checkBookmark(url, expected_title) { + Assert.equal( + gBrowser.selectedBrowser.currentURI.spec, + url, + "Trying to bookmark the expected uri" + ); + + let promiseBookmark = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => + events.some( + ({ url: eventUrl }) => + eventUrl == gBrowser.selectedBrowser.currentURI.spec + ) + ); + PlacesCommandHook.bookmarkPage(); + await promiseBookmark; + + let bookmark = await PlacesUtils.bookmarks.fetch({ url }); + + Assert.ok(bookmark, "Found the expected bookmark"); + Assert.equal( + bookmark.title, + expected_title, + "Bookmark got a good default title." + ); + + await PlacesUtils.bookmarks.remove(bookmark); +} diff --git a/browser/components/places/tests/browser/browser_bookmarklet_windowOpen.js b/browser/components/places/tests/browser/browser_bookmarklet_windowOpen.js new file mode 100644 index 0000000000..69f6298b8c --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarklet_windowOpen.js @@ -0,0 +1,79 @@ +"use strict"; + +let BASE_URL = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content/", + "https://example.com/" +); +const TEST_URL = BASE_URL + "pageopeningwindow.html"; +const DUMMY_URL = BASE_URL + "bookmarklet_windowOpen_dummy.html"; + +function makeBookmarkFor(url, keyword) { + return Promise.all([ + PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bookmarklet", + url, + }), + PlacesUtils.keywords.insert({ url, keyword }), + ]); +} + +add_task(async function openKeywordBookmarkWithWindowOpen() { + // This is the current default, but let's not assume that... + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.link.open_newwindow", 3], + ["dom.disable_open_during_load", true], + ], + }); + + let moztab; + let tabOpened = BrowserTestUtils.openNewForegroundTab( + gBrowser, + DUMMY_URL + ).then(tab => { + moztab = tab; + }); + let keywordForBM = "openmeatab"; + + let bookmarkInfo; + let bookmarkCreated = makeBookmarkFor( + "javascript:void open('" + TEST_URL + "')", + keywordForBM + ).then(values => { + bookmarkInfo = values[0]; + }); + await Promise.all([tabOpened, bookmarkCreated]); + + registerCleanupFunction(function () { + return Promise.all([ + PlacesUtils.bookmarks.remove(bookmarkInfo), + PlacesUtils.keywords.remove(keywordForBM), + ]); + }); + gURLBar.value = keywordForBM; + gURLBar.focus(); + + let tabCreatedPromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ); + EventUtils.synthesizeKey("KEY_Enter"); + + info("Waiting for tab being created"); + let { target: tab } = await tabCreatedPromise; + info("Got tab"); + let browser = tab.linkedBrowser; + if (!browser.currentURI || browser.currentURI.spec != TEST_URL) { + info("Waiting for browser load"); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL); + } + is( + browser.currentURI && browser.currentURI.spec, + TEST_URL, + "Tab with expected URL loaded." + ); + info("Waiting to remove tab"); + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(moztab); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarksProperties.js b/browser/components/places/tests/browser/browser_bookmarksProperties.js new file mode 100644 index 0000000000..8f1d783a49 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarksProperties.js @@ -0,0 +1,532 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests the bookmarks Properties dialog. + */ + +// DOM ids of Places sidebar trees. +const SIDEBAR_HISTORY_TREE_ID = "historyTree"; +const SIDEBAR_BOOKMARKS_TREE_ID = "bookmarks-view"; + +const SIDEBAR_HISTORY_ID = "viewHistorySidebar"; +const SIDEBAR_BOOKMARKS_ID = "viewBookmarksSidebar"; + +// For history sidebar. +const SIDEBAR_HISTORY_BYLASTVISITED_VIEW = "bylastvisited"; +const SIDEBAR_HISTORY_BYMOSTVISITED_VIEW = "byvisited"; +const SIDEBAR_HISTORY_BYDATE_VIEW = "byday"; +const SIDEBAR_HISTORY_BYSITE_VIEW = "bysite"; +const SIDEBAR_HISTORY_BYDATEANDSITE_VIEW = "bydateandsite"; + +// Action to execute on the current node. +const ACTION_EDIT = 0; +const ACTION_ADD = 1; + +// If action is ACTION_ADD, set type to one of those, to define what do you +// want to create. +const TYPE_FOLDER = 0; +const TYPE_BOOKMARK = 1; + +const TEST_URL = "http://www.example.com/"; + +const DIALOG_URL = "chrome://browser/content/places/bookmarkProperties.xhtml"; + +function add_bookmark(url) { + return PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url, + title: `bookmark/${url}`, + }); +} + +// Each test is an obj w/ a desc property and run method. +var gTests = []; + +// ------------------------------------------------------------------------------ +// Bug 462662 - Pressing Enter to select tag from autocomplete closes bookmarks properties dialog + +gTests.push({ + desc: "Bug 462662 - Pressing Enter to select tag from autocomplete closes bookmarks properties dialog", + sidebar: SIDEBAR_BOOKMARKS_ID, + action: ACTION_EDIT, + itemType: null, + window: null, + _bookmark: null, + _cleanShutdown: false, + + async setup() { + // Add a bookmark in unsorted bookmarks folder. + this._bookmark = await add_bookmark(TEST_URL); + Assert.ok(this._bookmark, "Correctly added a bookmark"); + + // Add a tag to this bookmark. + PlacesUtils.tagging.tagURI(Services.io.newURI(TEST_URL), ["testTag"]); + var tags = PlacesUtils.tagging.getTagsForURI(Services.io.newURI(TEST_URL)); + Assert.equal(tags[0], "testTag", "Correctly added a tag"); + }, + + selectNode(tree) { + tree.selectItems([PlacesUtils.bookmarks.unfiledGuid]); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + tree.selectItems([this._bookmark.guid]); + Assert.equal( + tree.selectedNode.bookmarkGuid, + this._bookmark.guid, + "Bookmark has been selected" + ); + }, + + async run() { + // open tags autocomplete and press enter + var tagsField = this.window.document.getElementById( + "editBMPanel_tagsField" + ); + var self = this; + + let unloadPromise = new Promise(resolve => { + this.window.addEventListener( + "unload", + function (event) { + tagsField.popup.removeEventListener( + "popuphidden", + popupListener, + true + ); + Assert.ok( + self._cleanShutdown, + "Dialog window should not be closed by pressing Enter on the autocomplete popup" + ); + executeSoon(function () { + resolve(); + }); + }, + { capture: true, once: true } + ); + }); + + var popupListener = { + handleEvent(aEvent) { + switch (aEvent.type) { + case "popuphidden": + // Everything worked fine, we can stop observing the window. + self._cleanShutdown = true; + self.window.document + .getElementById("bookmarkpropertiesdialog") + .cancelDialog(); + break; + case "popupshown": + tagsField.popup.removeEventListener("popupshown", this, true); + // In case this test fails the window will close, the test will fail + // since we didn't set _cleanShutdown. + let richlistbox = tagsField.popup.richlistbox; + // Focus and select first result. + Assert.equal( + richlistbox.itemCount, + 1, + "We have 1 autocomplete result" + ); + tagsField.popup.selectedIndex = 0; + Assert.equal( + richlistbox.selectedItems.length, + 1, + "We have selected a tag from the autocomplete popup" + ); + info("About to focus the autocomplete results"); + richlistbox.focus(); + EventUtils.synthesizeKey("VK_RETURN", {}, self.window); + break; + default: + Assert.ok(false, "unknown event: " + aEvent.type); + } + }, + }; + tagsField.popup.addEventListener("popupshown", popupListener, true); + tagsField.popup.addEventListener("popuphidden", popupListener, true); + + // Open tags autocomplete popup. + info("About to focus the tagsField"); + executeSoon(() => { + tagsField.focus(); + tagsField.value = ""; + EventUtils.synthesizeKey("t", {}, this.window); + }); + await unloadPromise; + }, + + finish() { + SidebarUI.hide(); + }, + + async cleanup() { + // Check tags have not changed. + var tags = PlacesUtils.tagging.getTagsForURI(Services.io.newURI(TEST_URL)); + Assert.equal(tags[0], "testTag", "Tag on node has not changed"); + + // Cleanup. + PlacesUtils.tagging.untagURI(Services.io.newURI(TEST_URL), ["testTag"]); + await PlacesUtils.bookmarks.remove(this._bookmark); + let bm = await PlacesUtils.bookmarks.fetch(this._bookmark.guid); + Assert.ok(!bm, "should have been removed"); + }, +}); + +// ------------------------------------------------------------------------------ +// Bug 476020 - Pressing Esc while having the tag autocomplete open closes the bookmarks panel + +gTests.push({ + desc: "Bug 476020 - Pressing Esc while having the tag autocomplete open closes the bookmarks panel", + sidebar: SIDEBAR_BOOKMARKS_ID, + action: ACTION_EDIT, + itemType: null, + window: null, + _bookmark: null, + _cleanShutdown: false, + + async setup() { + // Add a bookmark in unsorted bookmarks folder. + this._bookmark = await add_bookmark(TEST_URL); + Assert.ok(this._bookmark, "Correctly added a bookmark"); + + // Add a tag to this bookmark. + PlacesUtils.tagging.tagURI(Services.io.newURI(TEST_URL), ["testTag"]); + var tags = PlacesUtils.tagging.getTagsForURI(Services.io.newURI(TEST_URL)); + Assert.equal(tags[0], "testTag", "Correctly added a tag"); + }, + + selectNode(tree) { + tree.selectItems([PlacesUtils.bookmarks.unfiledGuid]); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + tree.selectItems([this._bookmark.guid]); + Assert.equal( + tree.selectedNode.bookmarkGuid, + this._bookmark.guid, + "Bookmark has been selected" + ); + }, + + async run() { + // open tags autocomplete and press enter + var tagsField = this.window.document.getElementById( + "editBMPanel_tagsField" + ); + var self = this; + + let hiddenPromise = new Promise(resolve => { + this.window.addEventListener( + "unload", + function (event) { + tagsField.popup.removeEventListener( + "popuphidden", + popupListener, + true + ); + Assert.ok( + self._cleanShutdown, + "Dialog window should not be closed by pressing Escape on the autocomplete popup" + ); + executeSoon(function () { + resolve(); + }); + }, + { capture: true, once: true } + ); + }); + + var popupListener = { + handleEvent(aEvent) { + switch (aEvent.type) { + case "popuphidden": + // Everything worked fine. + self._cleanShutdown = true; + self.window.document + .getElementById("bookmarkpropertiesdialog") + .cancelDialog(); + break; + case "popupshown": + tagsField.popup.removeEventListener("popupshown", this, true); + // In case this test fails the window will close, the test will fail + // since we didn't set _cleanShutdown. + let richlistbox = tagsField.popup.richlistbox; + // Focus and select first result. + Assert.equal( + richlistbox.itemCount, + 1, + "We have 1 autocomplete result" + ); + tagsField.popup.selectedIndex = 0; + Assert.equal( + richlistbox.selectedItems.length, + 1, + "We have selected a tag from the autocomplete popup" + ); + info("About to focus the autocomplete results"); + richlistbox.focus(); + EventUtils.synthesizeKey("VK_ESCAPE", {}, self.window); + break; + default: + Assert.ok(false, "unknown event: " + aEvent.type); + } + }, + }; + tagsField.popup.addEventListener("popupshown", popupListener, true); + tagsField.popup.addEventListener("popuphidden", popupListener, true); + + // Open tags autocomplete popup. + info("About to focus the tagsField"); + tagsField.focus(); + tagsField.value = ""; + EventUtils.synthesizeKey("t", {}, this.window); + await hiddenPromise; + }, + + finish() { + SidebarUI.hide(); + }, + + async cleanup() { + // Check tags have not changed. + var tags = PlacesUtils.tagging.getTagsForURI(Services.io.newURI(TEST_URL)); + Assert.equal(tags[0], "testTag", "Tag on node has not changed"); + + // Cleanup. + PlacesUtils.tagging.untagURI(Services.io.newURI(TEST_URL), ["testTag"]); + await PlacesUtils.bookmarks.remove(this._bookmark); + let bm = await PlacesUtils.bookmarks.fetch(this._bookmark.guid); + Assert.ok(!bm, "should have been removed"); + }, +}); + +// ------------------------------------------------------------------------------ +// Bug 491269 - Test that editing folder name in bookmarks properties dialog does not accept the dialog + +gTests.push({ + desc: `Bug 491269 - Test that editing folder name in bookmarks properties dialog does not accept the dialog`, + sidebar: SIDEBAR_HISTORY_ID, + dialogUrl: DIALOG_URL, + action: ACTION_ADD, + historyView: SIDEBAR_HISTORY_BYLASTVISITED_VIEW, + window: null, + + async setup() { + // Add a visit. + await PlacesTestUtils.addVisits(TEST_URL); + }, + + selectNode(tree) { + var visitNode = tree.view.nodeForTreeIndex(0); + tree.selectNode(visitNode); + Assert.equal( + tree.selectedNode.uri, + TEST_URL, + "The correct visit has been selected" + ); + Assert.equal( + tree.selectedNode.itemId, + -1, + "The selected node is not bookmarked" + ); + }, + + async run() { + // Open folder selector. + var foldersExpander = this.window.document.getElementById( + "editBMPanel_foldersExpander" + ); + var folderTree = this.window.gEditItemOverlay._folderTree; + var self = this; + + let unloadPromise = new Promise(resolve => { + this.window.addEventListener( + "unload", + event => { + Assert.ok( + self._cleanShutdown, + "Dialog window should not be closed by pressing ESC in folder name textbox" + ); + executeSoon(() => { + resolve(); + }); + }, + { capture: true, once: true } + ); + }); + + const observer = new this.window.MutationObserver( + (aMutationList, aObserver) => { + for (const mutation of aMutationList) { + if ( + mutation.type != "attributes" || + mutation.attributeName != "place" + ) { + continue; + } + aObserver.disconnect(); + executeSoon(async function () { + // Create a new folder. + var newFolderButton = self.window.document.getElementById( + "editBMPanel_newFolderButton" + ); + newFolderButton.doCommand(); + + // Wait for the folder to be created and for editing to start. + await TestUtils.waitForCondition( + () => folderTree.hasAttribute("editing"), + "We are editing new folder name in folder tree" + ); + + // Press Escape to discard editing new folder name. + EventUtils.synthesizeKey("VK_ESCAPE", {}, self.window); + Assert.ok( + !folderTree.hasAttribute("editing"), + "We have finished editing folder name in folder tree" + ); + + self._cleanShutdown = true; + + self.window.document + .getElementById("bookmarkpropertiesdialog") + .cancelDialog(); + }); + break; + } + } + ); + observer.observe(folderTree, { attributes: true }); + foldersExpander.doCommand(); + await unloadPromise; + }, + + finish() { + SidebarUI.hide(); + }, + + async cleanup() { + await PlacesTestUtils.promiseAsyncUpdates(); + + await PlacesUtils.history.clear(); + }, +}); + +// ------------------------------------------------------------------------------ + +add_task(async function test_setup() { + // This test can take some time, if we timeout too early it could run + // in the middle of other tests, or hang them. + requestLongerTimeout(2); +}); + +add_task(async function test_run() { + for (let test of gTests) { + info(`Start of test: ${test.desc}`); + await test.setup(); + + await execute_test_in_sidebar(test); + await test.run(); + + await test.cleanup(); + await test.finish(); + + info(`End of test: ${test.desc}`); + } +}); + +/** + * Global functions to run a test in Properties dialog context. + */ + +function execute_test_in_sidebar(test) { + return new Promise(resolve => { + var sidebar = document.getElementById("sidebar"); + sidebar.addEventListener( + "load", + function () { + // Need to executeSoon since the tree is initialized on sidebar load. + executeSoon(async () => { + await open_properties_dialog(test); + resolve(); + }); + }, + { capture: true, once: true } + ); + SidebarUI.show(test.sidebar); + }); +} + +async function promise_properties_window(dialogUrl = DIALOG_URL) { + let win = await BrowserTestUtils.promiseAlertDialogOpen(null, dialogUrl, { + isSubDialog: true, + }); + await SimpleTest.promiseFocus(win); + await win.document.mozSubdialogReady; + return win; +} + +async function open_properties_dialog(test) { + var sidebar = document.getElementById("sidebar"); + + // If this is history sidebar, set the required view. + if (test.sidebar == SIDEBAR_HISTORY_ID) { + sidebar.contentDocument.getElementById(test.historyView).doCommand(); + } + + // Get sidebar's Places tree. + var sidebarTreeID = + test.sidebar == SIDEBAR_BOOKMARKS_ID + ? SIDEBAR_BOOKMARKS_TREE_ID + : SIDEBAR_HISTORY_TREE_ID; + var tree = sidebar.contentDocument.getElementById(sidebarTreeID); + // The sidebar may take a moment to open from the doCommand, therefore wait + // until it has opened before continuing. + await TestUtils.waitForCondition(() => tree, "Sidebar tree has been loaded"); + + // Ask current test to select the node to edit. + test.selectNode(tree); + Assert.ok( + tree.selectedNode, + "We have a places node selected: " + tree.selectedNode.title + ); + + return new Promise(resolve => { + var command = null; + switch (test.action) { + case ACTION_EDIT: + command = "placesCmd_show:info"; + break; + case ACTION_ADD: + if (test.sidebar == SIDEBAR_BOOKMARKS_ID) { + if (test.itemType == TYPE_FOLDER) { + command = "placesCmd_new:folder"; + } else if (test.itemType == TYPE_BOOKMARK) { + command = "placesCmd_new:bookmark"; + } else { + Assert.ok( + false, + "You didn't set a valid itemType for adding an item" + ); + } + } else { + command = "placesCmd_createBookmark"; + } + break; + default: + Assert.ok(false, "You didn't set a valid action for this test"); + } + // Ensure command is enabled for this node. + Assert.ok( + tree.controller.isCommandEnabled(command), + " command '" + command + "' on current selected node is enabled" + ); + + promise_properties_window(test.dialogUrl).then(win => { + test.window = win; + resolve(); + }); + // This will open the dialog. For some reason this needs to be executed + // later, as otherwise opening the dialog throws an exception. + executeSoon(() => { + tree.controller.doCommand(command); + }); + }); +} diff --git a/browser/components/places/tests/browser/browser_bookmarks_change_title.js b/browser/components/places/tests/browser/browser_bookmarks_change_title.js new file mode 100644 index 0000000000..0e24df188a --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarks_change_title.js @@ -0,0 +1,256 @@ +/** + * Tests that the title of a bookmark can be changed from the bookmark star, toolbar, sidebar, and library. + */ +"use strict"; + +const TEST_URL = "about:buildconfig"; +const titleAfterFirstUpdate = "BookmarkStar title"; + +function getToolbarNodeForItemGuid(aItemGuid) { + var children = document.getElementById("PlacesToolbarItems").children; + for (let child of children) { + if (aItemGuid == child._placesNode.bookmarkGuid) { + return child; + } + } + return null; +} + +// Setup. +add_setup(async function () { + let toolbar = document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + + // Uncollapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + } + + registerCleanupFunction(async () => { + // Collapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, false); + } + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_change_title_from_BookmarkStar() { + let originalBm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_URL, + title: "Before Edit", + }); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser: win.gBrowser, + opening: TEST_URL, + waitForStateStop: true, + }); + + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.closeWindow(win); + }); + + win.StarUI._createPanelIfNeeded(); + let bookmarkPanel = win.document.getElementById("editBookmarkPanel"); + let shownPromise = promisePopupShown(bookmarkPanel); + + let bookmarkStar = win.BookmarkingUI.star; + bookmarkStar.click(); + + await shownPromise; + + let bookmarkPanelTitle = win.document.getElementById( + "editBookmarkPanelTitle" + ); + await BrowserTestUtils.waitForCondition( + () => + bookmarkPanelTitle.textContent === + gFluentStrings.formatValueSync("bookmarks-edit-bookmark"), + "Wait until the bookmark panel title will be changed expectedly." + ); + Assert.ok(true, "Bookmark title is correct"); + + let promiseNotification = PlacesTestUtils.waitForNotification( + "bookmark-title-changed", + events => events.some(e => e.title === titleAfterFirstUpdate) + ); + + // Update the bookmark's title. + await fillBookmarkTextField( + "editBMPanel_namePicker", + titleAfterFirstUpdate, + win + ); + + let doneButton = win.document.getElementById("editBookmarkPanelDoneButton"); + doneButton.click(); + await promiseNotification; + + let updatedBm = await PlacesUtils.bookmarks.fetch(originalBm.guid); + Assert.equal( + updatedBm.title, + titleAfterFirstUpdate, + "Should have updated the bookmark title in the database" + ); + await PlacesUtils.bookmarks.remove(originalBm.guid); +}); + +add_task(async function test_change_title_from_Toolbar() { + let toolbarBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: titleAfterFirstUpdate, + url: TEST_URL, + }); + + let toolbarNode = getToolbarNodeForItemGuid(toolbarBookmark.guid); + + await withBookmarksDialog( + false, + async function openPropertiesDialog() { + let placesContext = document.getElementById("placesContext"); + let promisePopup = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(toolbarNode, { + button: 2, + type: "contextmenu", + }); + await promisePopup; + + let properties = document.getElementById( + "placesContext_show_bookmark:info" + ); + placesContext.activateItem(properties, {}); + }, + async function test(dialogWin) { + // Ensure the dialog has initialized. + await TestUtils.waitForCondition(() => dialogWin.document.title); + + let namepicker = dialogWin.document.getElementById( + "editBMPanel_namePicker" + ); + + let editBookmarkDialogTitle = + dialogWin.document.getElementById("titleText"); + let bundle = dialogWin.document.getElementById("stringBundle"); + + Assert.equal( + bundle.getString("dialogTitleEditBookmark2"), + editBookmarkDialogTitle.textContent + ); + + Assert.equal( + namepicker.value, + titleAfterFirstUpdate, + "Name field is correct before update." + ); + + let promiseTitleChange = PlacesTestUtils.waitForNotification( + "bookmark-title-changed", + events => events.some(e => e.title === "Toolbar title") + ); + + // Update the bookmark's title. + fillBookmarkTextField( + "editBMPanel_namePicker", + "Toolbar title", + dialogWin, + false + ); + + // Confirm and close the dialog. + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + await promiseTitleChange; + + let updatedBm = await PlacesUtils.bookmarks.fetch(toolbarBookmark.guid); + Assert.equal( + updatedBm.title, + "Toolbar title", + "Should have updated the bookmark title in the database" + ); + } + ); +}); + +add_task(async function test_change_title_from_Sidebar() { + let bookmarks = []; + await PlacesUtils.bookmarks.fetch({ url: TEST_URL }, bm => + bookmarks.push(bm) + ); + + await withSidebarTree("bookmarks", async function (tree) { + tree.selectItems([bookmarks[0].guid]); + + await withBookmarksDialog( + false, + function openPropertiesDialog() { + tree.controller.doCommand("placesCmd_show:info"); + }, + async function test(dialogWin) { + let namepicker = dialogWin.document.getElementById( + "editBMPanel_namePicker" + ); + Assert.equal( + namepicker.value, + "Toolbar title", + "Name field is correct before update." + ); + + let promiseTitleChange = PlacesTestUtils.waitForNotification( + "bookmark-title-changed", + events => events.some(e => e.title === "Sidebar Title") + ); + + // Update the bookmark's title. + fillBookmarkTextField( + "editBMPanel_namePicker", + "Sidebar Title", + dialogWin, + false + ); + + // Confirm and close the dialog. + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + await promiseTitleChange; + + // Get updated bookmarks, check the new title. + bookmarks = []; + await PlacesUtils.bookmarks.fetch({ url: TEST_URL }, bm => + bookmarks.push(bm) + ); + Assert.equal( + bookmarks[0].title, + "Sidebar Title", + "Should have updated the bookmark title in the database" + ); + Assert.equal(bookmarks.length, 1, "One bookmark should exist"); + } + ); + }); +}); + +add_task(async function test_change_title_from_Library() { + info("Open library and select the bookmark."); + const library = await promiseLibrary("BookmarksToolbar"); + registerCleanupFunction(async function () { + await promiseLibraryClosed(library); + }); + library.ContentTree.view.selectNode( + library.ContentTree.view.view.nodeForTreeIndex(0) + ); + const newTitle = "Library"; + const promiseTitleChange = PlacesTestUtils.waitForNotification( + "bookmark-title-changed", + events => events.some(e => e.title === newTitle) + ); + info("Update the bookmark's title."); + fillBookmarkTextField("editBMPanel_namePicker", newTitle, library); + await promiseTitleChange; + info("The bookmark's title was updated."); + await promiseLibraryClosed(library); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarks_change_url.js b/browser/components/places/tests/browser/browser_bookmarks_change_url.js new file mode 100644 index 0000000000..ab5ad742d4 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarks_change_url.js @@ -0,0 +1,106 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* + * Test whether or not that url field in library window will update properly + * when changing it. + */ + +const TEST_URLS = ["https://example.com/", "https://example.org/"]; + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function basic() { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_URLS[0], + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_URLS[1], + }); + + info("Open library for bookmarks toolbar"); + const library = await promiseLibrary("BookmarksToolbar"); + + info("Check the initial content"); + const tree = library.document.getElementById("placeContent"); + Assert.equal(tree.view.rowCount, 2); + assertRow(tree, 0, TEST_URLS[0]); + assertRow(tree, 1, TEST_URLS[1]); + + info("Check the url"); + const newURL = `${TEST_URLS[0]}/?test`; + await updateURL(newURL, library); + + info("Check the update"); + Assert.equal(tree.view.rowCount, 2); + assertRow(tree, 0, newURL); + assertRow(tree, 1, TEST_URLS[1]); + + info("Close library window"); + await promiseLibraryClosed(library); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function whileFiltering() { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_URLS[0], + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_URLS[1], + }); + + info("Open library for bookmarks toolbar"); + const library = await promiseLibrary("BookmarksToolbar"); + + info("Check the initial content"); + const tree = library.document.getElementById("placeContent"); + Assert.equal(tree.view.rowCount, 2); + assertRow(tree, 0, TEST_URLS[0]); + assertRow(tree, 1, TEST_URLS[1]); + + info("Filter by search chars"); + library.PlacesSearchBox.search("org"); + Assert.equal(tree.view.rowCount, 1); + assertRow(tree, 0, TEST_URLS[1]); + + info("Check the url"); + const newURL = `${TEST_URLS[1]}/?test`; + await updateURL(newURL, library); + + info("Check the update"); + Assert.equal(tree.view.rowCount, 1); + assertRow(tree, 0, newURL); + + info("Close library window"); + await promiseLibraryClosed(library); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +async function updateURL(newURL, library) { + const promiseUrlChange = PlacesTestUtils.waitForNotification( + "bookmark-url-changed", + () => true + ); + fillBookmarkTextField("editBMPanel_locationField", newURL, library); + await promiseUrlChange; +} + +function assertRow(tree, targeRow, expectedUrl) { + const url = tree.view.getCellText(targeRow, tree.columns.placesContentUrl); + Assert.equal(url, expectedUrl, "URL is correct"); +} diff --git a/browser/components/places/tests/browser/browser_bookmarks_sidebar_search.js b/browser/components/places/tests/browser/browser_bookmarks_sidebar_search.js new file mode 100644 index 0000000000..a425a9b031 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarks_sidebar_search.js @@ -0,0 +1,213 @@ +/** + * Test searching for bookmarks (by title and by tag) from the Bookmarks sidebar. + */ +"use strict"; + +let sidebar = document.getElementById("sidebar"); + +const TEST_URI = "http://example.com/"; +const BOOKMARKS_COUNT = 4; +const TEST_PARENT_FOLDER = "testParentFolder"; +const TEST_SIF_URL = "http://testsif.example.com/"; +const TEST_SIF_TITLE = "TestSIF"; +const TEST_NEW_TITLE = "NewTestSIF"; + +function assertBookmarks(searchValue) { + let found = 0; + + let searchBox = sidebar.contentDocument.getElementById("search-box"); + + ok(searchBox, "search box is in context"); + + searchBox.value = searchValue; + searchBox.doCommand(); + + let tree = sidebar.contentDocument.getElementById("bookmarks-view"); + + for (let i = 0; i < tree.view.rowCount; i++) { + let cellText = tree.view.getCellText(i, tree.columns.getColumnAt(0)); + + if (cellText.includes("example page")) { + found++; + } + } + + info("Reset the search"); + searchBox.value = ""; + searchBox.doCommand(); + + is(found, BOOKMARKS_COUNT, "found expected site"); +} + +async function showInFolder(aSearchStr, aParentFolderGuid) { + let searchBox = sidebar.contentDocument.getElementById("search-box"); + + searchBox.value = aSearchStr; + searchBox.doCommand(); + + let tree = sidebar.contentDocument.getElementById("bookmarks-view"); + let theNode = tree.view._getNodeForRow(0); + let bookmarkGuid = theNode.bookmarkGuid; + + Assert.equal(theNode.uri, TEST_SIF_URL, "Found expected bookmark"); + + info("Running Show in Folder command"); + tree.selectNode(theNode); + tree.controller.doCommand("placesCmd_showInFolder"); + + let treeNode = tree.selectedNode; + Assert.equal( + treeNode.parent.bookmarkGuid, + aParentFolderGuid, + "Containing folder node is correct" + ); + Assert.equal( + treeNode.bookmarkGuid, + bookmarkGuid, + "The searched bookmark guid matches selected node" + ); + Assert.equal( + treeNode.uri, + TEST_SIF_URL, + "The searched bookmark URL matches selected node" + ); + + info("Check the title will be applied the item when changing it"); + await PlacesUtils.bookmarks.update({ + guid: theNode.bookmarkGuid, + title: TEST_NEW_TITLE, + }); + Assert.equal( + treeNode.title, + TEST_NEW_TITLE, + "New title is applied to the node" + ); +} + +add_task(async function testTree() { + // Add bookmarks and tags. + for (let i = 0; i < BOOKMARKS_COUNT; i++) { + let url = Services.io.newURI(TEST_URI + i); + + await PlacesUtils.bookmarks.insert({ + url, + title: "example page " + i, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + PlacesUtils.tagging.tagURI(url, ["test"]); + } + + await withSidebarTree("bookmarks", function () { + // Search a bookmark by its title. + assertBookmarks("example.com"); + // Search a bookmark by its tag. + assertBookmarks("test"); + }); + + // Cleanup before testing Show in Folder. + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function testShowInFolder() { + // Now test Show in Folder + let parentFolder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: TEST_PARENT_FOLDER, + }); + + await PlacesUtils.bookmarks.insert({ + parentGuid: parentFolder.guid, + title: TEST_SIF_TITLE, + url: TEST_SIF_URL, + }); + + await withSidebarTree("bookmarks", async function () { + await showInFolder(TEST_SIF_TITLE, parentFolder.guid); + }); + + // Cleanup + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function testRenameOnQueryResult() { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: TEST_SIF_TITLE, + url: TEST_SIF_URL, + }); + + await withSidebarTree("bookmarks", async function () { + const searchBox = sidebar.contentDocument.getElementById("search-box"); + + searchBox.value = TEST_SIF_TITLE; + searchBox.doCommand(); + + const tree = sidebar.contentDocument.getElementById("bookmarks-view"); + const theNode = tree.view._getNodeForRow(0); + + info("Check the found bookmark"); + Assert.equal(theNode.uri, TEST_SIF_URL, "URI of bookmark found is correct"); + Assert.equal( + theNode.title, + TEST_SIF_TITLE, + "Title of bookmark found is correct" + ); + + info("Check the title will be applied the item when changing it"); + await PlacesUtils.bookmarks.update({ + guid: theNode.bookmarkGuid, + title: TEST_NEW_TITLE, + }); + + // As the query result is refreshed once then the node also is regenerated, + // need to get the result node from the tree again. + Assert.equal( + tree.view._getNodeForRow(0).bookmarkGuid, + theNode.bookmarkGuid, + "GUID of node regenerated is correct" + ); + Assert.equal( + tree.view._getNodeForRow(0).uri, + theNode.uri, + "URI of node regenerated is correct" + ); + Assert.equal( + tree.view._getNodeForRow(0).parentGuid, + theNode.parentGuid, + "parentGuid of node regenerated is correct" + ); + Assert.equal( + tree.view._getNodeForRow(0).title, + TEST_NEW_TITLE, + "New title is applied to the node" + ); + + info("Check the new date will be applied the item when changing it"); + const now = new Date(); + await PlacesUtils.bookmarks.update({ + guid: theNode.bookmarkGuid, + dateAdded: now, + lastModified: now, + }); + + Assert.equal( + tree.view._getNodeForRow(0).uri, + theNode.uri, + "URI of node regenerated is correct" + ); + Assert.equal( + tree.view._getNodeForRow(0).dateAdded, + now.getTime() * 1000, + "New dateAdded is applied to the node" + ); + Assert.equal( + tree.view._getNodeForRow(0).lastModified, + now.getTime() * 1000, + "New lastModified is applied to the node" + ); + }); + + // Cleanup before testing Show in Folder. + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarks_toolbar_context_menu_view_options.js b/browser/components/places/tests/browser/browser_bookmarks_toolbar_context_menu_view_options.js new file mode 100644 index 0000000000..6e1f5e93c2 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarks_toolbar_context_menu_view_options.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function testPopup() { + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://example.com", + title: "firefox", + }); + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.remove(bookmark); + Services.prefs.clearUserPref("browser.toolbars.bookmarks.visibility"); + }); + + for (let state of ["always", "newtab"]) { + info(`Testing with state set to '${state}'`); + await SpecialPowers.pushPrefEnv({ + set: [["browser.toolbars.bookmarks.visibility", state]], + }); + + let newtab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: "about:newtab", + waitForLoad: false, + }); + + let bookmarksToolbar = document.getElementById("PersonalToolbar"); + await TestUtils.waitForCondition( + () => !bookmarksToolbar.collapsed, + "Wait for toolbar to become visible" + ); + ok(!bookmarksToolbar.collapsed, "Bookmarks toolbar should be visible"); + + // 1. Right-click on a bookmark and check that the submenu is visible + let bookmarkItem = bookmarksToolbar.querySelector( + `.bookmark-item[label="firefox"]` + ); + ok(bookmarkItem, "Got bookmark"); + let contextMenu = document.getElementById("placesContext"); + let popup = await openContextMenu(contextMenu, bookmarkItem); + ok( + !popup.target.querySelector("#toggle_PersonalToolbar").hidden, + "Bookmarks toolbar submenu should appear on a .bookmark-item" + ); + contextMenu.hidePopup(); + + // 2. Right-click on the empty area and check that the submenu is visible + popup = await openContextMenu(contextMenu, bookmarksToolbar); + ok( + !popup.target.querySelector("#toggle_PersonalToolbar").hidden, + "Bookmarks toolbar submenu should appear on the empty part of the toolbar" + ); + + let bookmarksToolbarMenu = document.querySelector( + "#toggle_PersonalToolbar" + ); + let subMenu = bookmarksToolbarMenu.querySelector("menupopup"); + bookmarksToolbarMenu.openMenu(true); + await BrowserTestUtils.waitForPopupEvent(subMenu, "shown"); + let menuitems = subMenu.querySelectorAll("menuitem"); + for (let menuitem of menuitems) { + let expected = menuitem.dataset.visibilityEnum == state; + is( + menuitem.getAttribute("checked"), + expected.toString(), + `The corresponding menuitem, ${menuitem.dataset.visibilityEnum}, ${ + expected ? "should" : "shouldn't" + } be checked if state=${state}` + ); + } + + contextMenu.hidePopup(); + + BrowserTestUtils.removeTab(newtab); + } +}); + +function openContextMenu(contextMenu, target) { + let popupPromise = BrowserTestUtils.waitForPopupEvent(contextMenu, "shown"); + EventUtils.synthesizeMouseAtCenter(target, { + type: "contextmenu", + button: 2, + }); + return popupPromise; +} diff --git a/browser/components/places/tests/browser/browser_bookmarks_toolbar_telemetry.js b/browser/components/places/tests/browser/browser_bookmarks_toolbar_telemetry.js new file mode 100644 index 0000000000..241710da3b --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarks_toolbar_telemetry.js @@ -0,0 +1,162 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const SCALAR_NAME = "browser.ui.customized_widgets"; +const bookmarksInfo = [ + { + title: "firefox", + url: "http://example.com", + }, + { + title: "rules", + url: "http://example.com/2", + }, + { + title: "yo", + url: "http://example.com/2", + }, +]; + +// Setup. +add_task(async function test_bookmarks_toolbar_telemetry() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.toolbars.bookmarks.visibility", "newtab"]], + }); + + // This is added during startup + await TestUtils.waitForCondition( + () => + keyedScalarExists( + "browser.ui.toolbar_widgets", + "bookmarks-bar_pinned_newtab", + true + ), + `Waiting for "bookmarks-bar_pinned_newtab" to appear in Telemetry snapshot` + ); + ok(true, `"bookmarks-bar_pinned_newtab"=true found in Telemetry`); + + await changeToolbarVisibilityViaContextMenu("never"); + await assertUIChange( + "bookmarks-bar_move_newtab_never_toolbar-context-menu", + 1 + ); + + await changeToolbarVisibilityViaContextMenu("newtab"); + await assertUIChange( + "bookmarks-bar_move_never_newtab_toolbar-context-menu", + 1 + ); + + await changeToolbarVisibilityViaContextMenu("always"); + await assertUIChange( + "bookmarks-bar_move_newtab_always_toolbar-context-menu", + 1 + ); + + // Extra windows are opened to make sure telemetry numbers aren't + // double counted since there will be multiple instances of the + // bookmarks toolbar. + let extraWindows = []; + extraWindows.push(await BrowserTestUtils.openNewBrowserWindow()); + + Services.telemetry.getSnapshotForScalars("main", true); + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: bookmarksInfo, + }); + registerCleanupFunction(() => PlacesUtils.bookmarks.remove(bookmarks)); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent"), + "browser.engagement.bookmarks_toolbar_bookmark_added", + 3, + "Bookmarks added value should be 3" + ); + + let newtab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + registerCleanupFunction(() => BrowserTestUtils.removeTab(newtab)); + let bookmarkToolbarButton = document.querySelector( + "#PlacesToolbarItems > toolbarbutton" + ); + bookmarkToolbarButton.click(); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent"), + "browser.engagement.bookmarks_toolbar_bookmark_opened", + 1, + "Bookmarks opened value should be 1" + ); + + extraWindows.push(await BrowserTestUtils.openNewBrowserWindow()); + extraWindows.push(await BrowserTestUtils.openNewBrowserWindow()); + + // Simulate dragging a bookmark within the toolbar to ensure + // that the bookmarks_toolbar_bookmark_added probe doesn't increment + let srcElement = document.querySelector("#PlacesToolbar .bookmark-item"); + let destElement = document.querySelector("#PlacesToolbar"); + await EventUtils.synthesizePlainDragAndDrop({ + srcElement, + destElement, + }); + + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent"), + "browser.engagement.bookmarks_toolbar_bookmark_added", + 3, + "Bookmarks added value should still be 3" + ); + + for (let win of extraWindows) { + await BrowserTestUtils.closeWindow(win); + } +}); + +async function changeToolbarVisibilityViaContextMenu(nextState) { + let contextMenu = document.querySelector("#toolbar-context-menu"); + let popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + let menuButton = document.getElementById("PanelUI-menu-button"); + EventUtils.synthesizeMouseAtCenter( + menuButton, + { type: "contextmenu" }, + window + ); + await popupShown; + let bookmarksToolbarMenu = document.querySelector("#toggle_PersonalToolbar"); + let subMenu = bookmarksToolbarMenu.querySelector("menupopup"); + popupShown = BrowserTestUtils.waitForEvent(subMenu, "popupshown"); + bookmarksToolbarMenu.openMenu(true); + await popupShown; + let menuItem = document.querySelector( + `menuitem[data-visibility-enum="${nextState}"]` + ); + subMenu.activateItem(menuItem); + contextMenu.hidePopup(); +} + +async function assertUIChange(key, value) { + await TestUtils.waitForCondition( + () => keyedScalarExists(SCALAR_NAME, key, value), + `Waiting for ${key} to appear in Telemetry snapshot` + ); + ok(true, `${key}=${value} found in Telemetry`); +} + +function keyedScalarExists(scalar, key, value) { + let snapshot = Services.telemetry.getSnapshotForKeyedScalars( + "main", + false + ).parent; + if (!snapshot.hasOwnProperty(scalar)) { + return false; + } + if (!snapshot[scalar].hasOwnProperty(key)) { + info(`Looking for ${key} in ${JSON.stringify(snapshot[scalar])}`); + return false; + } + return snapshot[scalar][key] == value; +} diff --git a/browser/components/places/tests/browser/browser_bug427633_no_newfolder_if_noip.js b/browser/components/places/tests/browser/browser_bug427633_no_newfolder_if_noip.js new file mode 100644 index 0000000000..6acb6fb04c --- /dev/null +++ b/browser/components/places/tests/browser/browser_bug427633_no_newfolder_if_noip.js @@ -0,0 +1,50 @@ +"use strict"; + +/** + * Bug 427633 - Disable creating a New Folder in the bookmarks dialogs if + * insertionPoint is invalid. + */ + +const TEST_URL = "about:buildconfig"; + +add_task(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_URL, + waitForStateStop: true, + }); + + registerCleanupFunction(async () => { + bookmarkPanel.removeAttribute("animate"); + await BrowserTestUtils.removeTab(tab); + await PlacesUtils.bookmarks.eraseEverything(); + }); + + StarUI._createPanelIfNeeded(); + let bookmarkPanel = document.getElementById("editBookmarkPanel"); + bookmarkPanel.setAttribute("animate", false); + + let shownPromise = promisePopupShown(bookmarkPanel); + + let bookmarkStar = BookmarkingUI.star; + bookmarkStar.click(); + + await shownPromise; + + ok(gEditItemOverlay, "gEditItemOverlay is in context"); + ok(gEditItemOverlay.initialized, "gEditItemOverlay is initialized"); + + window.gEditItemOverlay.toggleFolderTreeVisibility(); + + let tree = gEditItemOverlay._element("folderTree"); + + tree.view.selection.clearSelection(); + ok( + document.getElementById("editBMPanel_newFolderButton").disabled, + "New folder button is disabled if there's no selection" + ); + + let hiddenPromise = promisePopupHidden(bookmarkPanel); + document.getElementById("editBookmarkPanelRemoveButton").click(); + await hiddenPromise; +}); diff --git a/browser/components/places/tests/browser/browser_bug485100-change-case-loses-tag.js b/browser/components/places/tests/browser/browser_bug485100-change-case-loses-tag.js new file mode 100644 index 0000000000..9abb82c9bb --- /dev/null +++ b/browser/components/places/tests/browser/browser_bug485100-change-case-loses-tag.js @@ -0,0 +1,78 @@ +"use strict"; + +const TEST_URL = "https://www.example.com/"; +const testTag = "foo"; +const testTagUpper = "Foo"; +const testURI = Services.io.newURI(TEST_URL); + +add_task(async function test() { + // Add a bookmark. + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: "mozilla", + url: testURI, + }); + + PlacesUtils.tagging.tagURI(makeURI(TEST_URL), ["tag0"]); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + registerCleanupFunction(async () => { + await BrowserTestUtils.closeWindow(win); + }); + win.StarUI._createPanelIfNeeded(); + + await BrowserTestUtils.withNewTab( + { + gBrowser: win.gBrowser, + url: TEST_URL, + }, + async () => { + // Init panel + await TestUtils.waitForCondition( + () => BookmarkingUI.status !== BookmarkingUI.STATUS_UPDATING + ); + + await clickBookmarkStar(win); + + // add a tag + await fillBookmarkTextField("editBMPanel_tagsField", testTag, win); + + let promiseNotification = PlacesTestUtils.waitForNotification( + "bookmark-tags-changed" + ); + await hideBookmarksPanel(win); + await promiseNotification; + + // test that the tag has been added in the backend + is(PlacesUtils.tagging.getTagsForURI(testURI)[0], testTag, "tags match"); + + // change the tag + await TestUtils.waitForCondition( + () => BookmarkingUI.status !== BookmarkingUI.STATUS_UPDATING + ); + await clickBookmarkStar(win); + await fillBookmarkTextField("editBMPanel_tagsField", testTagUpper, win); + // The old sync API doesn't notify a tags change, and fixing it would be + // quite complex, so we just wait for a title change until tags are + // refactored. + promiseNotification = PlacesTestUtils.waitForNotification( + "bookmark-title-changed" + ); + await hideBookmarksPanel(win); + await promiseNotification; + + // test that the tag has been added in the backend + is( + PlacesUtils.tagging.getTagsForURI(testURI)[0], + testTagUpper, + "tags match" + ); + + // Cleanup. + PlacesUtils.tagging.untagURI(testURI, [testTag]); + await PlacesUtils.bookmarks.remove(bm.guid); + } + ); +}); diff --git a/browser/components/places/tests/browser/browser_bug631374_tags_selector_scroll.js b/browser/components/places/tests/browser/browser_bug631374_tags_selector_scroll.js new file mode 100644 index 0000000000..5d35356a69 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bug631374_tags_selector_scroll.js @@ -0,0 +1,162 @@ +/** + * This test checks that editing tags doesn't scroll the tags selector + * listbox to wrong positions. + */ + +const TEST_URL = "about:buildconfig"; + +function scrolledIntoView(item, parentItem) { + let itemRect = item.getBoundingClientRect(); + let parentItemRect = parentItem.getBoundingClientRect(); + let pointInView = y => parentItemRect.top < y && y < parentItemRect.bottom; + + // Partially visible items are also considered visible. + return pointInView(itemRect.top) || pointInView(itemRect.bottom); +} + +add_task(async function runTest() { + await PlacesUtils.bookmarks.eraseEverything(); + let tags = [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "l", + "m", + "n", + "o", + "p", + ]; + + // Add a bookmark and tag it. + let uri1 = Services.io.newURI(TEST_URL); + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "mozilla", + url: uri1.spec, + }); + PlacesUtils.tagging.tagURI(uri1, tags); + + // Add a second bookmark so that tags won't disappear when unchecked. + let uri2 = Services.io.newURI("http://www2.mozilla.org/"); + let bm2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "mozilla", + url: uri2.spec, + }); + PlacesUtils.tagging.tagURI(uri2, tags); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser: win.gBrowser, + opening: TEST_URL, + waitForStateStop: true, + }); + + registerCleanupFunction(async () => { + bookmarkPanel.removeAttribute("animate"); + await BrowserTestUtils.closeWindow(win); + await PlacesUtils.bookmarks.eraseEverything(); + }); + + win.StarUI._createPanelIfNeeded(); + let bookmarkPanel = win.document.getElementById("editBookmarkPanel"); + bookmarkPanel.setAttribute("animate", false); + let shownPromise = promisePopupShown(bookmarkPanel); + + let bookmarkStar = win.BookmarkingUI.star; + bookmarkStar.click(); + + await shownPromise; + + // Init panel. + ok(win.gEditItemOverlay, "gEditItemOverlay is in context"); + ok(win.gEditItemOverlay.initialized, "gEditItemOverlay is initialized"); + + await openTagSelector(win); + let tagsSelector = win.document.getElementById("editBMPanel_tagsSelector"); + + // Go by two so there is some untouched tag in the middle. + for (let i = 8; i < tags.length; i += 2) { + tagsSelector.selectedIndex = i; + let listItem = tagsSelector.selectedItem; + isnot(listItem, null, "Valid listItem found"); + + tagsSelector.ensureElementIsVisible(listItem); + let scrollTop = tagsSelector.scrollTop; + + ok(listItem.hasAttribute("checked"), "Item is checked " + i); + let selectedTag = listItem.label; + + // Uncheck the tag. + let promise = BrowserTestUtils.waitForEvent( + tagsSelector, + "BookmarkTagsSelectorUpdated" + ); + EventUtils.synthesizeMouseAtCenter(listItem.firstElementChild, {}, win); + await promise; + is(scrollTop, tagsSelector.scrollTop, "Scroll position did not change"); + + // The listbox is rebuilt, so we have to get the new element. + let newItem = tagsSelector.selectedItem; + isnot(newItem, null, "Valid new listItem found"); + ok(!newItem.hasAttribute("checked"), "New listItem is unchecked " + i); + is(newItem.label, selectedTag, "Correct tag is still selected"); + + // Check the tag. + promise = BrowserTestUtils.waitForEvent( + tagsSelector, + "BookmarkTagsSelectorUpdated" + ); + EventUtils.synthesizeMouseAtCenter(newItem.firstElementChild, {}, win); + await promise; + is(scrollTop, tagsSelector.scrollTop, "Scroll position did not change"); + } + + // Remove the second bookmark, then nuke some of the tags. + await PlacesUtils.bookmarks.remove(bm2); + + // Allow the tag updates to complete + await PlacesTestUtils.promiseAsyncUpdates(); + + // Doing this backwords tests more interesting paths. + for (let i = tags.length - 1; i >= 0; i -= 2) { + tagsSelector.selectedIndex = i; + let listItem = tagsSelector.selectedItem; + isnot(listItem, null, "Valid listItem found"); + + tagsSelector.ensureElementIsVisible(listItem); + + ok(listItem.hasAttribute("checked"), "Item is checked " + i); + + // Uncheck the tag. + let promise = BrowserTestUtils.waitForEvent( + tagsSelector, + "BookmarkTagsSelectorUpdated" + ); + EventUtils.synthesizeMouseAtCenter(listItem.firstElementChild, {}, win); + await promise; + } + + let hiddenPromise = promisePopupHidden(bookmarkPanel); + let doneButton = win.document.getElementById("editBookmarkPanelDoneButton"); + doneButton.click(); + await hiddenPromise; + // Cleanup. + await PlacesUtils.bookmarks.remove(bm1); +}); + +function openTagSelector(win) { + let promise = BrowserTestUtils.waitForEvent( + win.document.getElementById("editBMPanel_tagsSelector"), + "BookmarkTagsSelectorUpdated" + ); + // Open the tags selector. + win.document.getElementById("editBMPanel_tagsSelectorExpander").doCommand(); + return promise; +} diff --git a/browser/components/places/tests/browser/browser_check_correct_controllers.js b/browser/components/places/tests/browser/browser_check_correct_controllers.js new file mode 100644 index 0000000000..80095823e1 --- /dev/null +++ b/browser/components/places/tests/browser/browser_check_correct_controllers.js @@ -0,0 +1,114 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_setup(async () => { + // Ensure all bookmarks cleared before the test starts. + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test() { + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "Plain Bob", + url: "http://example.com", + }); + + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.remove(bookmark); + }); + + let sidebarBox = document.getElementById("sidebar-box"); + is(sidebarBox.hidden, true, "The sidebar should be hidden"); + + // Uncollapse the personal toolbar if needed. + let toolbar = document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + } + + let sidebar = await promiseLoadedSidebar("viewBookmarksSidebar"); + registerCleanupFunction(() => { + SidebarUI.hide(); + }); + + // Focus the tree and check if its controller is returned. + let tree = sidebar.contentDocument.getElementById("bookmarks-view"); + tree.focus(); + + let controller = PlacesUIUtils.getControllerForCommand( + window, + "placesCmd_copy" + ); + let treeController = + tree.controllers.getControllerForCommand("placesCmd_copy"); + Assert.equal(controller, treeController, "tree controller was returned"); + + // Open the context menu for a toolbar item, and check if the toolbar's + // controller is returned. + let toolbarItems = document.getElementById("PlacesToolbarItems"); + // Ensure the toolbar has displayed the bookmark. This might be async, so + // wait a little if necessary. + await TestUtils.waitForCondition( + () => toolbarItems.children.length == 1, + "Should have only one item on the toolbar" + ); + + let placesContext = document.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + EventUtils.synthesizeMouse( + toolbarItems.children[0], + 4, + 4, + { type: "contextmenu", button: 2 }, + window + ); + await popupShownPromise; + + controller = PlacesUIUtils.getControllerForCommand(window, "placesCmd_copy"); + let toolbarController = document + .getElementById("PlacesToolbar") + .controllers.getControllerForCommand("placesCmd_copy"); + Assert.equal( + controller, + toolbarController, + "the toolbar controller was returned" + ); + + let popupHiddenPromise = BrowserTestUtils.waitForEvent( + placesContext, + "popuphidden" + ); + placesContext.hidePopup(); + await popupHiddenPromise; + + // Now that the context menu is closed, try to get the tree controller again. + tree.focus(); + controller = PlacesUIUtils.getControllerForCommand(window, "placesCmd_copy"); + Assert.equal(controller, treeController, "tree controller was returned"); + + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, false); + } +}); + +function promiseLoadedSidebar(cmd) { + return new Promise(resolve => { + let sidebar = document.getElementById("sidebar"); + sidebar.addEventListener( + "load", + function () { + executeSoon(() => resolve(sidebar)); + }, + { capture: true, once: true } + ); + + SidebarUI.show(cmd); + }); +} diff --git a/browser/components/places/tests/browser/browser_click_bookmarks_on_toolbar.js b/browser/components/places/tests/browser/browser_click_bookmarks_on_toolbar.js new file mode 100644 index 0000000000..5ecf95d94e --- /dev/null +++ b/browser/components/places/tests/browser/browser_click_bookmarks_on_toolbar.js @@ -0,0 +1,234 @@ +/* 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 PREF_LOAD_BOOKMARKS_IN_TABS = "browser.tabs.loadBookmarksInTabs"; +const EXAMPLE_PAGE = "http://example.com/"; +const TEST_PAGES = [ + "about:mozilla", + "about:robots", + "javascript:window.location=%22" + EXAMPLE_PAGE + "%22", +]; + +var gBookmarkElements = []; + +function waitForBookmarkElements(expectedCount) { + let container = document.getElementById("PlacesToolbarItems"); + if (container.childElementCount == expectedCount) { + return Promise.resolve(); + } + return new Promise(resolve => { + info("Waiting for bookmarks"); + let mut = new MutationObserver(mutations => { + info("Elements appeared"); + if (container.childElementCount == expectedCount) { + resolve(); + mut.disconnect(); + } + }); + + mut.observe(container, { childList: true }); + }); +} + +function getToolbarNodeForItemGuid(aItemGuid) { + var children = document.getElementById("PlacesToolbarItems").children; + for (let child of children) { + if (aItemGuid == child._placesNode.bookmarkGuid) { + return child; + } + } + return null; +} + +function waitForLoad(browser, url) { + return BrowserTestUtils.browserLoaded(browser, false, url); +} + +function waitForNewTab(url, inBackground) { + return BrowserTestUtils.waitForNewTab(gBrowser, url).then(tab => { + if (inBackground) { + Assert.notEqual( + tab, + gBrowser.selectedTab, + `The new tab is in the background` + ); + } else { + Assert.equal( + tab, + gBrowser.selectedTab, + `The new tab is in the foreground` + ); + } + + BrowserTestUtils.removeTab(tab); + }); +} + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + let bookmarks = await Promise.all( + TEST_PAGES.map((url, index) => { + return PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: `Title ${index}`, + url, + }); + }) + ); + + let toolbar = document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + } + + await waitForBookmarkElements(TEST_PAGES.length); + for (let bookmark of bookmarks) { + let element = getToolbarNodeForItemGuid(bookmark.guid); + Assert.notEqual(element, null, "Found node on toolbar"); + + gBookmarkElements.push(element); + } + + registerCleanupFunction(async () => { + gBookmarkElements = []; + + await Promise.all( + bookmarks.map(bookmark => { + return PlacesUtils.bookmarks.remove(bookmark); + }) + ); + + // Note: hiding the toolbar before removing the bookmarks triggers + // bug 1766284. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, false); + } + }); +}); + +add_task(async function click() { + let promise = waitForLoad(gBrowser.selectedBrowser, TEST_PAGES[0]); + EventUtils.synthesizeMouseAtCenter(gBookmarkElements[0], { + button: 0, + }); + await promise; + + promise = waitForNewTab(TEST_PAGES[1], false); + EventUtils.synthesizeMouseAtCenter(gBookmarkElements[1], { + button: 0, + accelKey: true, + }); + await promise; + + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:blank", + }); + promise = waitForLoad(gBrowser.selectedBrowser, EXAMPLE_PAGE); + EventUtils.synthesizeMouseAtCenter(gBookmarkElements[2], {}); + await promise; + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function middleclick() { + let promise = waitForNewTab(TEST_PAGES[0], true); + EventUtils.synthesizeMouseAtCenter(gBookmarkElements[0], { + button: 1, + shiftKey: true, + }); + await promise; + + promise = waitForNewTab(TEST_PAGES[1], false); + EventUtils.synthesizeMouseAtCenter(gBookmarkElements[1], { + button: 1, + }); + await promise; +}); + +add_task(async function clickWithPrefSet() { + await SpecialPowers.pushPrefEnv({ + set: [[PREF_LOAD_BOOKMARKS_IN_TABS, true]], + }); + + let promise = waitForNewTab(TEST_PAGES[0], false); + EventUtils.synthesizeMouseAtCenter(gBookmarkElements[0], { + button: 0, + }); + await promise; + + // With loadBookmarksInTabs, reuse current tab if blank + for (let button of [0, 1]) { + await BrowserTestUtils.withNewTab({ gBrowser }, async tab => { + promise = waitForLoad(gBrowser.selectedBrowser, TEST_PAGES[1]); + EventUtils.synthesizeMouseAtCenter(gBookmarkElements[1], { + button, + }); + await promise; + }); + } + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function openInSameTabWithPrefSet() { + await SpecialPowers.pushPrefEnv({ + set: [[PREF_LOAD_BOOKMARKS_IN_TABS, true]], + }); + + let placesContext = document.getElementById("placesContext"); + let popupPromise = BrowserTestUtils.waitForEvent(placesContext, "popupshown"); + EventUtils.synthesizeMouseAtCenter(gBookmarkElements[0], { + button: 2, + type: "contextmenu", + }); + info("Waiting for context menu"); + await popupPromise; + + let openItem = document.getElementById("placesContext_open"); + ok(BrowserTestUtils.isVisible(openItem), "Open item should be visible"); + + info("Waiting for page to load"); + let promise = waitForLoad(gBrowser.selectedBrowser, TEST_PAGES[0]); + openItem.doCommand(); + placesContext.hidePopup(); + await promise; + + await SpecialPowers.popPrefEnv(); +}); + +// Open a tab, then quickly open the context menu to ensure that the command +// enabled state of the menuitems is updated properly. +add_task(async function quickContextMenu() { + await SpecialPowers.pushPrefEnv({ + set: [[PREF_LOAD_BOOKMARKS_IN_TABS, true]], + }); + + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, TEST_PAGES[0]); + + EventUtils.synthesizeMouseAtCenter(gBookmarkElements[0], { + button: 0, + }); + let newTab = await tabPromise; + + let placesContext = document.getElementById("placesContext"); + let promise = BrowserTestUtils.waitForEvent(placesContext, "popupshown"); + EventUtils.synthesizeMouseAtCenter(gBookmarkElements[1], { + button: 2, + type: "contextmenu", + }); + await promise; + + Assert.ok( + !document.getElementById("placesContext_open:newtab").disabled, + "Commands in context menu are enabled" + ); + + promise = BrowserTestUtils.waitForEvent(placesContext, "popuphidden"); + placesContext.hidePopup(); + await promise; + BrowserTestUtils.removeTab(newTab); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/places/tests/browser/browser_controller_onDrop.js b/browser/components/places/tests/browser/browser_controller_onDrop.js new file mode 100644 index 0000000000..cbda2612cf --- /dev/null +++ b/browser/components/places/tests/browser/browser_controller_onDrop.js @@ -0,0 +1,125 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var bookmarks; +var bookmarkIds; +var library; + +add_setup(async function () { + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + await promiseLibraryClosed(library); + }); + + bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "bm1", + url: "http://example1.com", + }, + { + title: "bm2", + url: "http://example2.com", + }, + { + title: "bm3", + url: "http://example3.com", + }, + ], + }); + + bookmarkIds = await PlacesTestUtils.promiseManyItemIds([ + bookmarks[0].guid, + bookmarks[1].guid, + bookmarks[2].guid, + ]); + + library = await promiseLibrary("UnfiledBookmarks"); +}); + +async function run_drag_test(startBookmarkIndex, insertionIndex) { + let dragBookmark = bookmarks[startBookmarkIndex]; + + library.ContentTree.view.selectItems([dragBookmark.guid]); + + let dataTransfer = { + _data: [], + dropEffect: "move", + mozCursor: "auto", + mozItemCount: 1, + types: [PlacesUtils.TYPE_X_MOZ_PLACE], + mozTypesAt(i) { + return [this._data[0].type]; + }, + mozGetDataAt(i) { + return this._data[0].data; + }, + mozSetDataAt(type, data, index) { + this._data.push({ + type, + data, + index, + }); + }, + }; + + let event = { + dataTransfer, + preventDefault() {}, + stopPropagation() {}, + }; + + library.ContentTree.view.controller.setDataTransfer(event); + + Assert.equal( + dataTransfer.mozTypesAt(0), + PlacesUtils.TYPE_X_MOZ_PLACE, + "Should have x-moz-place as the first data type." + ); + + let dataObject = JSON.parse(dataTransfer.mozGetDataAt(0)); + + Assert.equal( + dataObject.itemGuid, + dragBookmark.guid, + "Should have the correct guid." + ); + Assert.equal( + dataObject.title, + dragBookmark.title, + "Should have the correct title." + ); + + Assert.equal(dataTransfer.dropEffect, "move"); + + let ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: insertionIndex, + orientation: Ci.nsITreeView.DROP_ON, + }); + + await PlacesControllerDragHelper.onDrop(ip, dataTransfer); +} + +add_task(async function test_simple_move_down() { + let moveNotification = PlacesTestUtils.waitForNotification( + "bookmark-moved", + events => + events.some( + e => e.guid === bookmarks[0].guid && e.oldIndex == 0 && e.index == 1 + ) + ); + + await run_drag_test(0, 2); + + await moveNotification; +}); + +add_task(async function test_simple_move_up() { + await run_drag_test(2, 0); +}); diff --git a/browser/components/places/tests/browser/browser_controller_onDrop_query.js b/browser/components/places/tests/browser/browser_controller_onDrop_query.js new file mode 100644 index 0000000000..10dd6faa3c --- /dev/null +++ b/browser/components/places/tests/browser/browser_controller_onDrop_query.js @@ -0,0 +1,132 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URL = "about:buildconfig"; + +add_setup(async function () { + // Clean before and after so we don't have anything in the folders. + await PlacesUtils.bookmarks.eraseEverything(); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +// Simulating actual drag and drop is hard for a xul tree as we can't get the +// required source elements, so we have to do a lot more work by hand. +async function simulateDrop( + selectTargets, + sourceBm, + dropEffect, + targetGuid, + isVirtualRoot = false +) { + await withSidebarTree("bookmarks", async function (tree) { + for (let target of selectTargets) { + tree.selectItems([target]); + if (tree.selectedNode instanceof Ci.nsINavHistoryContainerResultNode) { + tree.selectedNode.containerOpen = true; + } + } + + let dataTransfer = { + _data: [], + dropEffect, + mozCursor: "auto", + mozItemCount: 1, + types: [PlacesUtils.TYPE_X_MOZ_PLACE], + mozTypesAt(i) { + return [this._data[0].type]; + }, + mozGetDataAt(i) { + return this._data[0].data; + }, + mozSetDataAt(type, data, index) { + this._data.push({ + type, + data, + index, + }); + }, + }; + + let event = { + dataTransfer, + preventDefault() {}, + stopPropagation() {}, + }; + + tree._controller.setDataTransfer(event); + + Assert.equal( + dataTransfer.mozTypesAt(0), + PlacesUtils.TYPE_X_MOZ_PLACE, + "Should have x-moz-place as the first data type." + ); + + let dataObject = JSON.parse(dataTransfer.mozGetDataAt(0)); + + let guid = isVirtualRoot ? dataObject.concreteGuid : dataObject.itemGuid; + + Assert.equal(guid, sourceBm.guid, "Should have the correct guid."); + Assert.equal( + dataObject.title, + PlacesUtils.bookmarks.getLocalizedTitle(sourceBm), + "Should have the correct title." + ); + + Assert.equal(dataTransfer.dropEffect, dropEffect); + + let ip = new PlacesInsertionPoint({ + parentId: await PlacesTestUtils.promiseItemId(targetGuid), + parentGuid: targetGuid, + index: 0, + orientation: Ci.nsITreeView.DROP_ON, + }); + + await PlacesControllerDragHelper.onDrop(ip, dataTransfer); + }); +} + +add_task(async function test_move_out_of_query() { + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "Fake", + url: TEST_URL, + }); + + let queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS; + + let queries = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + title: "Query", + url: `place:queryType=${queryType}&terms=Fake`, + }, + ], + }); + await simulateDrop( + [queries[0].guid, bookmark.guid], + bookmark, + "move", + PlacesUtils.bookmarks.toolbarGuid + ); + + let newBm = await PlacesUtils.bookmarks.fetch(bookmark.guid); + + Assert.equal( + newBm.parentGuid, + PlacesUtils.bookmarks.toolbarGuid, + "should have moved the bookmark to a new folder." + ); + + let oldLocationBm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: 0, + }); + + Assert.ok(!oldLocationBm, "Should not have a bookmark at the old location."); +}); diff --git a/browser/components/places/tests/browser/browser_controller_onDrop_sidebar.js b/browser/components/places/tests/browser/browser_controller_onDrop_sidebar.js new file mode 100644 index 0000000000..2637d4d724 --- /dev/null +++ b/browser/components/places/tests/browser/browser_controller_onDrop_sidebar.js @@ -0,0 +1,312 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URL = "about:buildconfig"; + +add_setup(async function () { + // The following initialization code is necessary to avoid a frequent + // intermittent failure in verify-fission where, due to timings, we may or + // may not import default bookmarks. + info("Ensure Places init is complete"); + let placesInitCompleteObserved = TestUtils.topicObserved( + "places-browser-init-complete" + ); + Cc["@mozilla.org/browser/browserglue;1"] + .getService(Ci.nsIObserver) + .observe(null, "browser-glue-test", "places-browser-init-complete"); + await placesInitCompleteObserved; + // Clean before and after so we don't have anything in the folders. + await PlacesUtils.bookmarks.eraseEverything(); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +// Simulating actual drag and drop is hard for a xul tree as we can't get the +// required source elements, so we have to do a lot more work by hand. +async function simulateDrop( + selectTargets, + sourceBm, + dropEffect, + targetGuid, + isVirtualRoot = false +) { + await withSidebarTree("bookmarks", async function (tree) { + for (let target of selectTargets) { + tree.selectItems([target]); + if (tree.selectedNode instanceof Ci.nsINavHistoryContainerResultNode) { + tree.selectedNode.containerOpen = true; + } + } + + let dataTransfer = { + _data: [], + dropEffect, + mozCursor: "auto", + mozItemCount: 1, + types: [PlacesUtils.TYPE_X_MOZ_PLACE], + mozTypesAt(i) { + return [this._data[0].type]; + }, + mozGetDataAt(i) { + return this._data[0].data; + }, + mozSetDataAt(type, data, index) { + this._data.push({ + type, + data, + index, + }); + }, + }; + + let event = { + dataTransfer, + preventDefault() {}, + stopPropagation() {}, + }; + + tree._controller.setDataTransfer(event); + + Assert.equal( + dataTransfer.mozTypesAt(0), + PlacesUtils.TYPE_X_MOZ_PLACE, + "Should have x-moz-place as the first data type." + ); + + let dataObject = JSON.parse(dataTransfer.mozGetDataAt(0)); + + let guid = isVirtualRoot ? dataObject.concreteGuid : dataObject.itemGuid; + + Assert.equal(guid, sourceBm.guid, "Should have the correct guid."); + Assert.equal( + dataObject.title, + PlacesUtils.bookmarks.getLocalizedTitle(sourceBm), + "Should have the correct title." + ); + + Assert.equal(dataTransfer.dropEffect, dropEffect); + + let ip = new PlacesInsertionPoint({ + parentId: await PlacesTestUtils.promiseItemId(targetGuid), + parentGuid: targetGuid, + index: 0, + orientation: Ci.nsITreeView.DROP_ON, + }); + + await PlacesControllerDragHelper.onDrop(ip, dataTransfer); + }); +} + +add_task(async function test_move_normal_bm_in_sidebar() { + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "Fake", + url: TEST_URL, + }); + + await simulateDrop([bm.guid], bm, "move", PlacesUtils.bookmarks.unfiledGuid); + + let newBm = await PlacesUtils.bookmarks.fetch(bm.guid); + + Assert.equal( + newBm.parentGuid, + PlacesUtils.bookmarks.unfiledGuid, + "Should have moved to the new parent." + ); + + let oldLocationBm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }); + + Assert.ok(!oldLocationBm, "Should not have a bookmark at the old location."); +}); + +add_task(async function test_try_move_root_in_sidebar() { + let menuFolder = await PlacesUtils.bookmarks.fetch( + PlacesUtils.bookmarks.menuGuid + ); + await simulateDrop( + [menuFolder.guid], + menuFolder, + "move", + PlacesUtils.bookmarks.toolbarGuid, + true + ); + + menuFolder = await PlacesUtils.bookmarks.fetch( + PlacesUtils.bookmarks.menuGuid + ); + + Assert.equal( + menuFolder.parentGuid, + PlacesUtils.bookmarks.rootGuid, + "Should have remained in the root" + ); + + let newFolder = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }); + + Assert.notEqual( + newFolder.guid, + menuFolder.guid, + "Should have created a different folder" + ); + Assert.equal( + newFolder.title, + PlacesUtils.bookmarks.getLocalizedTitle(menuFolder), + "Should have copied the folder title." + ); + Assert.equal( + newFolder.type, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + "Should have a bookmark type (for a folder shortcut)." + ); + Assert.equal( + newFolder.url, + `place:parent=${PlacesUtils.bookmarks.menuGuid}`, + "Should have the correct url for the folder shortcut." + ); +}); + +add_task(async function test_try_move_bm_within_two_root_folder_queries() { + await PlacesUtils.bookmarks.eraseEverything(); + + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "Fake", + url: TEST_URL, + }); + + let queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS; + + let queries = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + title: "Query", + url: `place:queryType=${queryType}&terms=Fake`, + }, + ], + }); + + await simulateDrop( + [queries[0].guid, bookmark.guid], + bookmark, + "move", + PlacesUtils.bookmarks.toolbarGuid + ); + + let newBm = await PlacesUtils.bookmarks.fetch(bookmark.guid); + + Assert.equal( + newBm.parentGuid, + PlacesUtils.bookmarks.toolbarGuid, + "should have moved the bookmark to a new folder." + ); +}); + +add_task(async function test_move_within_itself() { + await PlacesUtils.bookmarks.eraseEverything(); + + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "bm1", + url: "http://www.example.com/bookmark1.html", + }, + { + title: "bm2", + url: "http://www.example.com/bookmark2.html", + }, + { + title: "bm3", + url: "http://www.example.com/bookmark3.html", + }, + ], + }); + + await withSidebarTree("bookmarks", async function (tree) { + // Select the folder containing the bookmarks + // and save its index position + tree.selectItems([PlacesUtils.bookmarks.unfiledGuid]); + let unfiledFolderIndex = tree.view.treeIndexForNode(tree.selectedNode); + + let guids = bookmarks.map(bookmark => bookmark.guid); + tree.selectItems(guids); + let dataTransfer = { + _data: [], + dropEffect: "move", + mozCursor: "auto", + mozItemCount: bookmarks.length, + types: [PlacesUtils.TYPE_X_MOZ_PLACE], + mozTypesAt(i) { + return [this._data[0].type]; + }, + mozGetDataAt(i) { + return this._data[0].data; + }, + mozSetDataAt(type, data, index) { + this._data.push({ + type, + data, + index, + }); + }, + }; + + bookmarks.forEach((bookmark, index) => { + // Index positions of the newly created bookmarks + bookmark.rowIndex = unfiledFolderIndex + index + 1; + bookmark.node = tree.view.nodeForTreeIndex(bookmark.rowIndex); + bookmark.cachedBookmarkIndex = bookmark.node.bookmarkIndex; + }); + + let assertBookmarksHaveNotChangedPosition = () => { + bookmarks.forEach(bookmark => { + Assert.equal( + bookmark.node.bookmarkIndex, + bookmark.cachedBookmarkIndex, + "should not have moved the bookmark." + ); + }); + }; + + // Mimic "drag" events + let dragStartEvent = new CustomEvent("dragstart", { + bubbles: true, + }); + dragStartEvent.dataTransfer = dataTransfer; + + let dragEndEvent = new CustomEvent("dragend", { + bubbles: true, + }); + + let treeChildren = tree.view._element.children[1]; + + treeChildren.dispatchEvent(dragStartEvent); + await tree.view.drop( + bookmarks[1].rowIndex, + Ci.nsITreeView.DROP_ON, + dataTransfer + ); + treeChildren.dispatchEvent(dragEndEvent); + assertBookmarksHaveNotChangedPosition(); + + treeChildren.dispatchEvent(dragStartEvent); + await tree.view.drop( + bookmarks[2].rowIndex, + Ci.nsITreeView.DROP_ON, + dataTransfer + ); + treeChildren.dispatchEvent(dragEndEvent); + assertBookmarksHaveNotChangedPosition(); + }); +}); diff --git a/browser/components/places/tests/browser/browser_controller_onDrop_tagFolder.js b/browser/components/places/tests/browser/browser_controller_onDrop_tagFolder.js new file mode 100644 index 0000000000..64c448ec3f --- /dev/null +++ b/browser/components/places/tests/browser/browser_controller_onDrop_tagFolder.js @@ -0,0 +1,125 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +const sandbox = sinon.createSandbox(); +const TAG_NAME = "testTag"; + +var bookmarks; +var bookmarkId; + +add_setup(async function () { + registerCleanupFunction(async function () { + sandbox.restore(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + }); + + sandbox.stub(PlacesTransactions, "batch"); + sandbox.stub(PlacesTransactions, "Tag"); + + bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "bm1", + url: "http://example1.com", + }, + { + title: "bm2", + url: "http://example2.com", + tags: [TAG_NAME], + }, + ], + }); + bookmarkId = await PlacesTestUtils.promiseItemId(bookmarks[0].guid); +}); + +async function run_drag_test(startBookmarkIndex, newParentGuid) { + if (!newParentGuid) { + newParentGuid = PlacesUtils.bookmarks.unfiledGuid; + } + + // Reset the stubs so that previous test runs don't count against us. + PlacesTransactions.Tag.reset(); + PlacesTransactions.batch.reset(); + + let dragBookmark = bookmarks[startBookmarkIndex]; + + await withSidebarTree("bookmarks", async tree => { + tree.selectItems([PlacesUtils.bookmarks.unfiledGuid]); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + + // Simulating a drag-drop with a tree view turns out to be really difficult + // as you can't get a node for the source/target. Hence, we fake the + // insertion point and drag data and call the function direct. + let ip = new PlacesInsertionPoint({ + isTag: true, + tagName: TAG_NAME, + orientation: Ci.nsITreeView.DROP_ON, + }); + + let bookmarkWithId = JSON.stringify( + Object.assign( + { + id: bookmarkId, + itemGuid: dragBookmark.guid, + uri: dragBookmark.url, + }, + dragBookmark + ) + ); + + let dt = { + dropEffect: "move", + mozCursor: "auto", + mozItemCount: 1, + types: [PlacesUtils.TYPE_X_MOZ_PLACE], + mozTypesAt(i) { + return this.types; + }, + mozGetDataAt(i) { + return bookmarkWithId; + }, + }; + + await PlacesControllerDragHelper.onDrop(ip, dt); + + Assert.ok( + PlacesTransactions.Tag.calledOnce, + "Should have called PlacesTransactions.Tag at least once." + ); + + let arg = PlacesTransactions.Tag.args[0][0]; + + Assert.equal( + arg.urls.length, + 1, + "Should have called PlacesTransactions.Tag with an array of one url" + ); + Assert.equal( + arg.urls[0], + dragBookmark.url, + "Should have called PlacesTransactions.Tag with the correct url" + ); + Assert.equal( + arg.tag, + TAG_NAME, + "Should have called PlacesTransactions.Tag with the correct tag name" + ); + }); +} + +add_task(async function test_simple_drop_and_tag() { + // When we move items down the list, we'll get a drag index that is one higher + // than where we actually want to insert to - as the item is being moved up, + // everything shifts down one. Hence the index to pass to the transaction should + // be one less than the supplied index. + await run_drag_test(0, PlacesUtils.bookmarks.tagGuid); +}); diff --git a/browser/components/places/tests/browser/browser_copy_query_without_tree.js b/browser/components/places/tests/browser/browser_copy_query_without_tree.js new file mode 100644 index 0000000000..fc3adf31f2 --- /dev/null +++ b/browser/components/places/tests/browser/browser_copy_query_without_tree.js @@ -0,0 +1,113 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/* test that copying a non movable query or folder shortcut makes a new query with the same url, not a deep copy */ + +const QUERY_URL = "place:sort=8&maxResults=10"; + +add_task(async function copy_toolbar_shortcut() { + await promisePlacesInitComplete(); + + let library = await promiseLibrary(); + + registerCleanupFunction(async () => { + library.close(); + await PlacesUtils.bookmarks.eraseEverything(); + }); + + library.PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + + await promiseClipboard(function () { + library.PlacesOrganizer._places.controller.copy(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + library.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + + await library.ContentTree.view.controller.paste(); + + let toolbarCopyNode = library.ContentTree.view.view.nodeForTreeIndex(0); + is( + toolbarCopyNode.type, + Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT, + "copy is still a folder shortcut" + ); + + await PlacesUtils.bookmarks.remove(toolbarCopyNode.bookmarkGuid); + library.PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + is( + library.PlacesOrganizer._places.selectedNode.type, + Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT, + "original is still a folder shortcut" + ); +}); + +add_task(async function copy_mobile_shortcut() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.bookmarks.showMobileBookmarks", true]], + }); + await promisePlacesInitComplete(); + + let library = await promiseLibrary(); + + registerCleanupFunction(async () => { + library.close(); + await PlacesUtils.bookmarks.eraseEverything(); + }); + + library.PlacesOrganizer.selectLeftPaneContainerByHierarchy([ + PlacesUtils.virtualAllBookmarksGuid, + PlacesUtils.bookmarks.virtualMobileGuid, + ]); + + await promiseClipboard(function () { + library.PlacesOrganizer._places.controller.copy(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + library.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + + await library.ContentTree.view.controller.paste(); + + let mobileCopyNode = library.ContentTree.view.view.nodeForTreeIndex(0); + is( + mobileCopyNode.type, + Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT, + "copy is still a folder shortcut" + ); + + await PlacesUtils.bookmarks.remove(mobileCopyNode.bookmarkGuid); + library.PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + is( + library.PlacesOrganizer._places.selectedNode.type, + Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT, + "original is still a folder shortcut" + ); +}); + +add_task(async function copy_history_query() { + let library = await promiseLibrary(); + + library.PlacesOrganizer.selectLeftPaneBuiltIn("History"); + + await promiseClipboard(function () { + library.PlacesOrganizer._places.controller.copy(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + library.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + await library.ContentTree.view.controller.paste(); + + let historyCopyNode = library.ContentTree.view.view.nodeForTreeIndex(0); + is( + historyCopyNode.type, + Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY, + "copy is still a query" + ); + + await PlacesUtils.bookmarks.remove(historyCopyNode.bookmarkGuid); + library.PlacesOrganizer.selectLeftPaneBuiltIn("History"); + is( + library.PlacesOrganizer._places.selectedNode.type, + Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY, + "original is still a query" + ); +}); diff --git a/browser/components/places/tests/browser/browser_cutting_bookmarks.js b/browser/components/places/tests/browser/browser_cutting_bookmarks.js new file mode 100644 index 0000000000..7c10229f7e --- /dev/null +++ b/browser/components/places/tests/browser/browser_cutting_bookmarks.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URL = "http://example.com/"; + +add_task(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + let organizer = await promiseLibrary(); + + registerCleanupFunction(async function () { + await promiseLibraryClosed(organizer); + await PlacesUtils.bookmarks.eraseEverything(); + }); + + let PlacesOrganizer = organizer.PlacesOrganizer; + let ContentTree = organizer.ContentTree; + + // Test with multiple entries to ensure they retain their order. + let bookmarks = []; + bookmarks.push( + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: TEST_URL, + title: "0", + }) + ); + bookmarks.push( + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: TEST_URL, + title: "1", + }) + ); + bookmarks.push( + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: TEST_URL, + title: "2", + }) + ); + + await selectBookmarksIn(organizer, bookmarks, "BookmarksToolbar"); + + await promiseClipboard(() => { + info("Cutting selection"); + ContentTree.view.controller.cut(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + info("Selecting UnfiledBookmarks in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + info("Pasting clipboard"); + await ContentTree.view.controller.paste(); + + await selectBookmarksIn(organizer, bookmarks, "UnfiledBookmarks"); +}); + +var selectBookmarksIn = async function (organizer, bookmarks, aLeftPaneQuery) { + let PlacesOrganizer = organizer.PlacesOrganizer; + let ContentTree = organizer.ContentTree; + info("Selecting " + aLeftPaneQuery + " in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn(aLeftPaneQuery); + + for (let { guid } of bookmarks) { + let bookmark = await PlacesUtils.bookmarks.fetch(guid); + is( + bookmark.parentGuid, + PlacesUtils.getConcreteItemGuid(PlacesOrganizer._places.selectedNode), + "Bookmark has the right parent" + ); + } + + info("Selecting the bookmarks in the right pane"); + ContentTree.view.selectItems(bookmarks.map(bm => bm.guid)); + + for (let node of ContentTree.view.selectedNodes) { + is( + "" + node.bookmarkIndex, + node.title, + "Found the expected bookmark in the expected position" + ); + } +}; diff --git a/browser/components/places/tests/browser/browser_default_bookmark_location.js b/browser/components/places/tests/browser/browser_default_bookmark_location.js new file mode 100644 index 0000000000..9b386fd2f0 --- /dev/null +++ b/browser/components/places/tests/browser/browser_default_bookmark_location.js @@ -0,0 +1,274 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const LOCATION_PREF = "browser.bookmarks.defaultLocation"; +const TEST_URL = "about:about"; +let bookmarkPanel; +let win; + +add_setup(async function () { + Services.prefs.clearUserPref(LOCATION_PREF); + await PlacesUtils.bookmarks.eraseEverything(); + + win = await BrowserTestUtils.openNewBrowserWindow(); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser: win.gBrowser, + opening: TEST_URL, + waitForStateStop: true, + }); + + let oldTimeout = win.StarUI._autoCloseTimeout; + // Make the timeout something big, so it doesn't interact badly with tests. + win.StarUI._autoCloseTimeout = 6000000; + + win.StarUI._createPanelIfNeeded(); + bookmarkPanel = win.document.getElementById("editBookmarkPanel"); + bookmarkPanel.setAttribute("animate", false); + + registerCleanupFunction(async () => { + bookmarkPanel = null; + win.StarUI._autoCloseTimeout = oldTimeout; + await BrowserTestUtils.closeWindow(win); + win = null; + await PlacesUtils.bookmarks.eraseEverything(); + Services.prefs.clearUserPref(LOCATION_PREF); + }); +}); + +async function cancelBookmarkCreationInPanel() { + let hiddenPromise = promisePopupHidden( + win.document.getElementById("editBookmarkPanel") + ); + // Confirm and close the dialog. + + win.document.getElementById("editBookmarkPanelRemoveButton").click(); + await hiddenPromise; +} + +/** + * Helper to check the selected folder is correct. + */ +async function checkSelection() { + // Open folder selector. + let menuList = win.document.getElementById("editBMPanel_folderMenuList"); + + let expectedFolder = "BookmarksToolbarFolderTitle"; + Assert.equal( + menuList.label, + PlacesUtils.getString(expectedFolder), + `Should have ${expectedFolder} selected by default` + ); + Assert.equal( + menuList.getAttribute("selectedGuid"), + await PlacesUIUtils.defaultParentGuid, + "Should have the correct default guid selected" + ); + + await cancelBookmarkCreationInPanel(); +} + +/** + * Verify that bookmarks created with the star button go to the default + * bookmark location. + */ +add_task(async function test_star_location() { + await clickBookmarkStar(win); + await checkSelection(); +}); + +/** + * Verify that bookmarks created with the shortcut go to the default bookmark + * location. + */ +add_task(async function test_shortcut_location() { + let shownPromise = promisePopupShown( + win.document.getElementById("editBookmarkPanel") + ); + win.document.getElementById("Browser:AddBookmarkAs").doCommand(); + await shownPromise; + await checkSelection(); +}); + +// Note: Bookmarking frames is tested in browser_addBookmarkForFrame.js + +/** + * Verify that bookmarks created with the link context menu go to the default + * bookmark location. + */ +add_task(async function test_context_menu_link() { + for (let t = 0; t < 2; t++) { + if (t == 1) { + // For the second iteration, ensure that the default folder is invalid first. + await createAndRemoveDefaultFolder(); + } + + await withBookmarksDialog( + true, + async function openDialog() { + const contextMenu = win.document.getElementById( + "contentAreaContextMenu" + ); + is(contextMenu.state, "closed", "checking if popup is closed"); + let promisePopupShown = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + BrowserTestUtils.synthesizeMouseAtCenter( + "a[href*=config]", // Bookmark about:config + { type: "contextmenu", button: 2 }, + win.gBrowser.selectedBrowser + ); + await promisePopupShown; + contextMenu.activateItem( + win.document.getElementById("context-bookmarklink") + ); + }, + async function test(dialogWin) { + let expectedFolder = "BookmarksToolbarFolderTitle"; + let expectedFolderName = PlacesUtils.getString(expectedFolder); + + let folderPicker = dialogWin.document.getElementById( + "editBMPanel_folderMenuList" + ); + + // Check the initial state of the folder picker. + await TestUtils.waitForCondition( + () => folderPicker.selectedItem.label == expectedFolderName, + "The folder is the expected one." + ); + } + ); + } +}); + +/** + * Verify that if we change the location, we persist that selection. + */ +add_task(async function test_change_location_panel() { + await clickBookmarkStar(win); + + let menuList = win.document.getElementById("editBMPanel_folderMenuList"); + + let { toolbarGuid, menuGuid, unfiledGuid } = PlacesUtils.bookmarks; + + let expectedFolderGuid = toolbarGuid; + + info("Pref value: " + Services.prefs.getCharPref(LOCATION_PREF, "")); + await TestUtils.waitForCondition( + () => menuList.getAttribute("selectedGuid") == expectedFolderGuid, + "Should initially select the unfiled or toolbar item" + ); + + // Now move this new bookmark to the menu: + let promisePopup = BrowserTestUtils.waitForEvent( + menuList.menupopup, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(menuList, {}, win); + await promisePopup; + + // Make sure we wait for the bookmark to be added. + let itemAddedPromise = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => events.some(({ url }) => url === TEST_URL) + ); + + // Wait for the pref to change + let prefChangedPromise = TestUtils.waitForPrefChange(LOCATION_PREF); + + // Click the choose item. + EventUtils.synthesizeMouseAtCenter( + win.document.getElementById("editBMPanel_bmRootItem"), + {}, + win + ); + + await TestUtils.waitForCondition( + () => menuList.getAttribute("selectedGuid") == menuGuid, + "Should select the menu folder item" + ); + + info("Waiting for transactions to finish."); + await Promise.all(win.gEditItemOverlay.transactionPromises); + info("Moved; waiting to hide panel."); + + await hideBookmarksPanel(win); + info("Waiting for pref change."); + await prefChangedPromise; + info("Waiting for item to be added."); + await itemAddedPromise; + + // Check that it's in the menu, and remove the bookmark: + let bm = await PlacesUtils.bookmarks.fetch({ url: TEST_URL }); + is(bm?.parentGuid, menuGuid, "Bookmark was put in the menu."); + if (bm) { + await PlacesUtils.bookmarks.remove(bm); + } + + // Now create a new bookmark and check it starts in the menu + await clickBookmarkStar(win); + + let expectedFolder = "BookmarksMenuFolderTitle"; + Assert.equal( + menuList.label, + PlacesUtils.getString(expectedFolder), + `Should have menu folder selected by default` + ); + Assert.equal( + menuList.getAttribute("selectedGuid"), + menuGuid, + "Should have the correct default guid selected" + ); + + // Now select a different item. + promisePopup = BrowserTestUtils.waitForEvent( + menuList.menupopup, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(menuList, {}, win); + await promisePopup; + + // Click the toolbar item. + EventUtils.synthesizeMouseAtCenter( + win.document.getElementById("editBMPanel_toolbarFolderItem"), + {}, + win + ); + + await TestUtils.waitForCondition( + () => menuList.getAttribute("selectedGuid") == toolbarGuid, + "Should select the toolbar item" + ); + + await cancelBookmarkCreationInPanel(); + + is( + await PlacesUIUtils.defaultParentGuid, + menuGuid, + "Default folder should not change if we cancel the panel." + ); + + // Now open the panel for an existing bookmark whose parent doesn't match + // the default and check we don't overwrite the default folder. + let testBM = await PlacesUtils.bookmarks.insert({ + parentGuid: unfiledGuid, + title: "Yoink", + url: TEST_URL, + }); + await TestUtils.waitForCondition( + () => win.BookmarkingUI.star.hasAttribute("starred"), + "Wait for bookmark to show up for current page." + ); + await clickBookmarkStar(win); + + await hideBookmarksPanel(win); + is( + await PlacesUIUtils.defaultParentGuid, + menuGuid, + "Default folder should not change if we accept the panel, but didn't change folders." + ); + + await PlacesUtils.bookmarks.remove(testBM); +}); diff --git a/browser/components/places/tests/browser/browser_drag_bookmarks_on_toolbar.js b/browser/components/places/tests/browser/browser_drag_bookmarks_on_toolbar.js new file mode 100644 index 0000000000..ea96282f30 --- /dev/null +++ b/browser/components/places/tests/browser/browser_drag_bookmarks_on_toolbar.js @@ -0,0 +1,255 @@ +/* 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 TEST_URL = "http://www.mozilla.org"; +const TEST_TITLE = "example_title"; + +var gBookmarksToolbar = window.document.getElementById("PersonalToolbar"); +var dragDirections = { LEFT: 0, UP: 1, RIGHT: 2, DOWN: 3 }; + +/** + * Tests dragging on toolbar. + * + * We must test these 2 cases: + * - Dragging toward left, top, right should start a drag. + * - Dragging toward down should should open the container if the item is a + * container, drag the item otherwise. + * + * @param {object} aElement + * DOM node element we will drag + * @param {Array} aExpectedDragData + * Array of flavors and values in the form: + * [ ["text/plain: sometext", "text/html: sometext"], [...] ] + * Pass an empty array to check that drag even has been canceled. + * @param {number} aDirection + * Direction for the dragging gesture, see dragDirections helper object. + * @returns {Promise} Resolved once the drag gesture has been observed. + */ +function synthesizeDragWithDirection(aElement, aExpectedDragData, aDirection) { + let promise = new Promise(resolve => { + // Dragstart listener function. + gBookmarksToolbar.addEventListener("dragstart", function listener(event) { + info("A dragstart event has been trapped."); + var dataTransfer = event.dataTransfer; + is( + dataTransfer.mozItemCount, + aExpectedDragData.length, + "Number of dragged items should be the same." + ); + + for (var t = 0; t < dataTransfer.mozItemCount; t++) { + var types = dataTransfer.mozTypesAt(t); + var expecteditem = aExpectedDragData[t]; + is( + types.length, + expecteditem.length, + "Number of flavors for item " + t + " should be the same." + ); + + for (var f = 0; f < types.length; f++) { + is( + types[f], + expecteditem[f].substring(0, types[f].length), + "Flavor " + types[f] + " for item " + t + " should be the same." + ); + is( + dataTransfer.mozGetDataAt(types[f], t), + expecteditem[f].substring(types[f].length + 2), + "Contents for item " + + t + + " with flavor " + + types[f] + + " should be the same." + ); + } + } + + if (!aExpectedDragData.length) { + ok(event.defaultPrevented, "Drag has been canceled."); + } + + event.preventDefault(); + event.stopPropagation(); + + gBookmarksToolbar.removeEventListener("dragstart", listener); + + // This is likely to cause a click event, and, in case we are dragging a + // bookmark, an unwanted page visit. Prevent the click event. + aElement.addEventListener("click", prevent); + EventUtils.synthesizeMouse( + aElement, + startingPoint.x + xIncrement * 9, + startingPoint.y + yIncrement * 9, + { type: "mouseup" } + ); + aElement.removeEventListener("click", prevent); + + // Cleanup eventually opened menus. + if (aElement.localName == "menu" && aElement.open) { + aElement.open = false; + } + resolve(); + }); + }); + + var prevent = function (aEvent) { + aEvent.preventDefault(); + }; + + var xIncrement = 0; + var yIncrement = 0; + + switch (aDirection) { + case dragDirections.LEFT: + xIncrement = -1; + break; + case dragDirections.RIGHT: + xIncrement = +1; + break; + case dragDirections.UP: + yIncrement = -1; + break; + case dragDirections.DOWN: + yIncrement = +1; + break; + } + + var rect = aElement.getBoundingClientRect(); + var startingPoint = { + x: (rect.right - rect.left) / 2, + y: (rect.bottom - rect.top) / 2, + }; + + EventUtils.synthesizeMouse(aElement, startingPoint.x, startingPoint.y, { + type: "mousedown", + }); + EventUtils.synthesizeMouse( + aElement, + startingPoint.x + xIncrement * 1, + startingPoint.y + yIncrement * 1, + { type: "mousemove" } + ); + EventUtils.synthesizeMouse( + aElement, + startingPoint.x + xIncrement * 9, + startingPoint.y + yIncrement * 9, + { type: "mousemove" } + ); + + return promise; +} + +function getToolbarNodeForItemId(itemGuid) { + var children = document.getElementById("PlacesToolbarItems").children; + for (let child of children) { + if (itemGuid == child._placesNode.bookmarkGuid) { + return child; + } + } + return null; +} + +function getExpectedDataForPlacesNode(aNode) { + var wrappedNode = []; + var flavors = [ + "text/x-moz-place", + "text/x-moz-url", + "text/plain", + "text/html", + ]; + + flavors.forEach(function (aFlavor) { + var wrappedFlavor = aFlavor + ": " + PlacesUtils.wrapNode(aNode, aFlavor); + wrappedNode.push(wrappedFlavor); + }); + + return [wrappedNode]; +} + +add_setup(async function () { + var toolbar = document.getElementById("PersonalToolbar"); + var wasCollapsed = toolbar.collapsed; + + // Uncollapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + } + + registerCleanupFunction(async () => { + // Collapse the personal toolbar if needed. + await promiseSetToolbarVisibility(toolbar, false); + }); +}); + +add_task(async function test_drag_folder_on_toolbar() { + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: TEST_TITLE, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + var element = getToolbarNodeForItemId(folder.guid); + isnot(element, null, "Found node on toolbar"); + + isnot( + element._placesNode, + null, + "Toolbar node has an associated Places node." + ); + var expectedData = getExpectedDataForPlacesNode(element._placesNode); + + info("Dragging left"); + await synthesizeDragWithDirection(element, expectedData, dragDirections.LEFT); + + info("Dragging right"); + await synthesizeDragWithDirection( + element, + expectedData, + dragDirections.RIGHT + ); + + info("Dragging up"); + await synthesizeDragWithDirection(element, expectedData, dragDirections.UP); + + info("Dragging down"); + await synthesizeDragWithDirection(element, [], dragDirections.DOWN); + + await PlacesUtils.bookmarks.remove(folder); +}); + +add_task(async function test_drag_bookmark_on_toolbar() { + var bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: TEST_TITLE, + url: TEST_URL, + }); + + var element = getToolbarNodeForItemId(bookmark.guid); + isnot(element, null, "Found node on toolbar"); + + isnot( + element._placesNode, + null, + "Toolbar node has an associated Places node." + ); + var expectedData = getExpectedDataForPlacesNode(element._placesNode); + + info("Dragging left"); + await synthesizeDragWithDirection(element, expectedData, dragDirections.LEFT); + + info("Dragging right"); + await synthesizeDragWithDirection( + element, + expectedData, + dragDirections.RIGHT + ); + + info("Dragging up"); + await synthesizeDragWithDirection(element, expectedData, dragDirections.UP); + + info("Dragging down"); + synthesizeDragWithDirection(element, expectedData, dragDirections.DOWN); + + await PlacesUtils.bookmarks.remove(bookmark); +}); diff --git a/browser/components/places/tests/browser/browser_drag_folder_on_newTab.js b/browser/components/places/tests/browser/browser_drag_folder_on_newTab.js new file mode 100644 index 0000000000..9f12e57ee9 --- /dev/null +++ b/browser/components/places/tests/browser/browser_drag_folder_on_newTab.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_setup(async function () { + let toolbar = document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + + // Uncollapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + } + + // Clean before and after so we don't have anything in the folders. + await PlacesUtils.bookmarks.eraseEverything(); + + registerCleanupFunction(async function () { + // Collapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, false); + } + + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +const TEST_FOLDER_NAME = "Test folder"; + +add_task(async function test_change_location_from_Toolbar() { + let newTabButton = document.getElementById("tabs-newtab-button"); + + let children = [ + { + title: "first", + url: "http://www.example.com/first", + }, + { + title: "second", + url: "http://www.example.com/second", + }, + { + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }, + { + title: "third", + url: "http://www.example.com/third", + }, + ]; + let guid = PlacesUtils.history.makeGuid(); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + guid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: TEST_FOLDER_NAME, + children, + }, + ], + }); + + let folder = getToolbarNodeForItemGuid(guid); + + let loadedPromises = children + .filter(item => "url" in item) + .map(item => + BrowserTestUtils.waitForNewTab(gBrowser, item.url, true, true) + ); + + let srcX = 10, + srcY = 10; + // We should drag upwards, since dragging downwards opens menu instead. + let stepX = 0, + stepY = -5; + + // We need to dispatch mousemove before dragging, to populate + // PlacesToolbar._cachedMouseMoveEvent, with the cursor position after the + // first step, so that the places code detects it as dragging upward. + EventUtils.synthesizeMouse(folder, srcX + stepX, srcY + stepY, { + type: "mousemove", + }); + + await EventUtils.synthesizePlainDragAndDrop({ + srcElement: folder, + destElement: newTabButton, + srcX, + srcY, + stepX, + stepY, + }); + + let tabs = await Promise.all(loadedPromises); + + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } + + ok(true); +}); diff --git a/browser/components/places/tests/browser/browser_editBookmark_keywords.js b/browser/components/places/tests/browser/browser_editBookmark_keywords.js new file mode 100644 index 0000000000..5489a06165 --- /dev/null +++ b/browser/components/places/tests/browser/browser_editBookmark_keywords.js @@ -0,0 +1,64 @@ +"use strict"; + +const TEST_URL = "about:blank"; + +add_task(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_URL, + waitForStateStop: true, + }); + + let library = await promiseLibrary("UnfiledBookmarks"); + registerCleanupFunction(async () => { + await promiseLibraryClosed(library); + await PlacesUtils.bookmarks.eraseEverything(); + await BrowserTestUtils.removeTab(tab); + }); + + let keywordField = library.document.getElementById( + "editBMPanel_keywordField" + ); + + for (let i = 0; i < 2; ++i) { + let bm = await PlacesUtils.bookmarks.insert({ + url: `http://www.test${i}.me/`, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + + let node = library.ContentTree.view.view.nodeForTreeIndex(i); + is(node.bookmarkGuid, bm.guid, "Found the expected bookmark"); + // Select the bookmark. + library.ContentTree.view.selectNode(node); + synthesizeClickOnSelectedTreeCell(library.ContentTree.view); + + is( + library.document.getElementById("editBMPanel_keywordField").value, + "", + "The keyword field should be empty" + ); + info("Add a keyword to the bookmark"); + const promise = PlacesTestUtils.waitForNotification( + "bookmark-keyword-changed" + ); + keywordField.focus(); + keywordField.value = "kw"; + EventUtils.sendString(i.toString(), library); + keywordField.blur(); + const events = await promise; + is(events.length, 1, "Number of events fired is correct"); + const keyword = events[0].keyword; + is(keyword, `kw${i}`, "The new keyword value is correct"); + } + + for (let i = 0; i < 2; ++i) { + let entry = await PlacesUtils.keywords.fetch({ + url: `http://www.test${i}.me/`, + }); + is( + entry.keyword, + `kw${i}`, + `The keyword for http://www.test${i}.me/ is correct` + ); + } +}); diff --git a/browser/components/places/tests/browser/browser_enable_toolbar_sidebar.js b/browser/components/places/tests/browser/browser_enable_toolbar_sidebar.js new file mode 100644 index 0000000000..8d4d650984 --- /dev/null +++ b/browser/components/places/tests/browser/browser_enable_toolbar_sidebar.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * Test that the Bookmarks Toolbar and Sidebar can be enabled from the Bookmarks Menu ("View history, + * saved bookmarks, and more" button. + */ + +// Cleanup. +registerCleanupFunction(async () => { + CustomizableUI.setToolbarVisibility("PersonalToolbar", false); + CustomizableUI.removeWidgetFromArea("library-button"); + SidebarUI.hide(); +}); + +async function selectAppMenuView(buttonId, viewId) { + let btn; + await TestUtils.waitForCondition(() => { + btn = document.getElementById(buttonId); + return btn; + }, "Should have the " + buttonId + " button"); + btn.click(); + let view = document.getElementById(viewId); + let viewPromise = BrowserTestUtils.waitForEvent(view, "ViewShown"); + await viewPromise; +} + +async function openBookmarkingPanelInLibraryToolbarButton() { + await selectAppMenuView("library-button", "appMenu-libraryView"); + await selectAppMenuView( + "appMenu-library-bookmarks-button", + "PanelUI-bookmarks" + ); +} + +add_task(async function test_enable_toolbar() { + CustomizableUI.addWidgetToArea("library-button", "nav-bar"); + + await openBookmarkingPanelInLibraryToolbarButton(); + let toolbar = document.getElementById("PersonalToolbar"); + Assert.ok(toolbar.collapsed, "Bookmarks Toolbar is hidden"); + + let viewBookmarksToolbarBtn; + await TestUtils.waitForCondition(() => { + viewBookmarksToolbarBtn = document.getElementById( + "panelMenu_viewBookmarksToolbar" + ); + return viewBookmarksToolbarBtn; + }, "Should have the library 'View Bookmarks Toolbar' button."); + viewBookmarksToolbarBtn.click(); + await TestUtils.waitForCondition( + () => !toolbar.collapsed, + "Should have the Bookmarks Toolbar enabled." + ); + Assert.ok(!toolbar.collapsed, "Bookmarks Toolbar is enabled"); +}); diff --git a/browser/components/places/tests/browser/browser_forgetthissite.js b/browser/components/places/tests/browser/browser_forgetthissite.js new file mode 100644 index 0000000000..380497aed7 --- /dev/null +++ b/browser/components/places/tests/browser/browser_forgetthissite.js @@ -0,0 +1,262 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Tests the "Forget About This Site" button from the libary view +const { PromptTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromptTestUtils.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { ForgetAboutSite } = ChromeUtils.importESModule( + "resource://gre/modules/ForgetAboutSite.sys.mjs" +); + +const TEST_URIs = [ + { title: "0", uri: "http://example.com" }, + { title: "1", uri: "http://www.mozilla.org/test1" }, + { title: "2", uri: "http://www.mozilla.org/test2" }, + { title: "3", uri: "https://192.168.200.1/login.html" }, +]; + +async function setup() { + registerCleanupFunction(async function () { + // Clean up any leftover stubs. + sinon.restore(); + }); + + let places = []; + let transition = PlacesUtils.history.TRANSITION_TYPED; + TEST_URIs.forEach(({ title, uri }) => + places.push({ uri: Services.io.newURI(uri), transition, title }) + ); + await PlacesTestUtils.addVisits(places); +} + +async function teardown(organizer) { + // Close the library window. + await promiseLibraryClosed(organizer); + await PlacesUtils.history.clear(); +} + +// Selects the sites specified by sitesToSelect +// If multiple sites are selected they can't be forgotten +// Should forget selects the answer in the confirmation dialogue +// removedEntries specifies which entries should be forgotten +async function testForgetAboutThisSite( + sitesToSelect, + shouldForget, + removedEntries, + cancelConfirmWithEsc = false +) { + if (cancelConfirmWithEsc) { + ok( + !shouldForget, + "If cancelConfirmWithEsc is set we don't expect to clear entries." + ); + } + + ok(PlacesUtils, "checking PlacesUtils, running in chrome context?"); + await setup(); + let organizer = await promiseHistoryView(); + let doc = organizer.document; + let tree = doc.getElementById("placeContent"); + + //Sort by name in descreasing order + tree.view._result.sortingMode = + Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING; + + let selection = tree.view.selection; + selection.clearSelection(); + sitesToSelect.forEach(index => selection.rangedSelect(index, index, true)); + + let selectionCount = sitesToSelect.length; + is( + selection.count, + selectionCount, + "The selected range is as big as expected" + ); + // Open the context menu. + let contextmenu = doc.getElementById("placesContext"); + let popupShown = promisePopupShown(contextmenu); + + // Get cell coordinates. + let rect = tree.getCoordsForCellItem( + sitesToSelect[0], + tree.columns[0], + "text" + ); + // Initiate a context menu for the selected cell. + EventUtils.synthesizeMouse( + tree.body, + rect.x + rect.width / 2, + rect.y + rect.height / 2, + { type: "contextmenu", button: 2 }, + organizer + ); + await popupShown; + + let forgetThisSite = doc.getElementById("placesContext_deleteHost"); + let hideForgetThisSite = selectionCount > 1; + is( + forgetThisSite.hidden, + hideForgetThisSite, + `The Forget this site menu item should ${ + hideForgetThisSite ? "" : "not " + }be hidden with ${selectionCount} items selected` + ); + if (hideForgetThisSite) { + // Close the context menu. + contextmenu.hidePopup(); + await teardown(organizer); + return; + } + + // Resolves once the confirmation prompt has been closed. + let promptPromise; + + // Cancel prompt via esc key. We have to get the prompt closed promise + // ourselves. + if (cancelConfirmWithEsc) { + promptPromise = PromptTestUtils.waitForPrompt(organizer, { + modalType: Services.prompt.MODAL_TYPE_WINDOW, + promptType: "confirmEx", + }).then(dialog => { + let dialogWindow = dialog.ui.prompt; + let dialogClosedPromise = BrowserTestUtils.waitForEvent( + dialogWindow.opener, + "DOMModalDialogClosed" + ); + EventUtils.synthesizeKey("KEY_Escape", undefined, dialogWindow); + + return dialogClosedPromise; + }); + } else { + // Close prompt via buttons. PromptTestUtils supplies the closed promise. + promptPromise = PromptTestUtils.handleNextPrompt( + organizer, + { modalType: Services.prompt.MODAL_TYPE_WINDOW, promptType: "confirmEx" }, + { buttonNumClick: shouldForget ? 0 : 1 } + ); + } + + // If we cancel the prompt, create stubs to check that none of the clear + // methods are called. + if (!shouldForget) { + sinon.stub(ForgetAboutSite, "removeDataFromBaseDomain").resolves(); + sinon.stub(ForgetAboutSite, "removeDataFromDomain").resolves(); + } + + let pageRemovedEventPromise; + if (shouldForget) { + pageRemovedEventPromise = + PlacesTestUtils.waitForNotification("page-removed"); + } + + // Execute the delete command. + contextmenu.activateItem(forgetThisSite); + + // Wait for prompt to be handled. + await promptPromise; + + // If we expect to remove items, wait the page-removed event to fire. If we + // don't wait, we may test the list before any items have been removed. + await pageRemovedEventPromise; + + if (!shouldForget) { + ok( + ForgetAboutSite.removeDataFromBaseDomain.notCalled && + ForgetAboutSite.removeDataFromDomain.notCalled, + "Should not call ForgetAboutSite when the confirmation prompt is cancelled." + ); + // Remove the stubs. + sinon.restore(); + } + + // Check that the entries have been removed. + await Promise.all( + removedEntries.map(async ({ uri }) => { + Assert.ok( + !(await PlacesUtils.history.fetch(uri)), + `History entry for ${uri} has been correctly removed` + ); + }) + ); + await Promise.all( + TEST_URIs.filter(x => !removedEntries.includes(x)).map(async ({ uri }) => { + Assert.ok( + await PlacesUtils.history.fetch(uri), + `History entry for ${uri} has been kept` + ); + }) + ); + + // Cleanup. + await teardown(organizer); +} + +/* + * Opens the history view in the PlacesOrganziner window + * @returns {Promise} + * @resolves The PlacesOrganizer + */ +async function promiseHistoryView() { + let organizer = await promiseLibrary(); + + // Select History in the left pane. + let po = organizer.PlacesOrganizer; + po.selectLeftPaneBuiltIn("History"); + + let histContainer = po._places.selectedNode.QueryInterface( + Ci.nsINavHistoryContainerResultNode + ); + histContainer.containerOpen = true; + po._places.selectNode(histContainer.getChild(0)); + + return organizer; +} +/* + * @returns {Promise} + * @resolves once the popup is shown + */ +function promisePopupShown(popup) { + return new Promise(resolve => { + popup.addEventListener( + "popupshown", + function () { + resolve(); + }, + { capture: true, once: true } + ); + }); +} + +// This test makes sure that the Forget This Site command is hidden for multiple +// selections. +add_task(async function selectMultiple() { + await testForgetAboutThisSite([0, 1]); +}); + +// This test makes sure that forgetting "http://www.mozilla.org/test2" also removes "http://www.mozilla.org/test1" +add_task(async function forgettingBasedomain() { + await testForgetAboutThisSite([1], true, TEST_URIs.slice(1, 3)); +}); + +// This test makes sure that forgetting by IP address works +add_task(async function forgettingIPAddress() { + await testForgetAboutThisSite([3], true, TEST_URIs.slice(3, 4)); +}); + +// This test makes sure that forgetting file URLs works +add_task(async function dontAlwaysForget() { + await testForgetAboutThisSite([0], false, []); +}); + +// When cancelling the confirmation prompt via ESC key, no entries should be +// cleared. +add_task(async function cancelConfirmWithEsc() { + await testForgetAboutThisSite([0], false, [], true); +}); diff --git a/browser/components/places/tests/browser/browser_history_sidebar_search.js b/browser/components/places/tests/browser/browser_history_sidebar_search.js new file mode 100644 index 0000000000..44d51eec31 --- /dev/null +++ b/browser/components/places/tests/browser/browser_history_sidebar_search.js @@ -0,0 +1,71 @@ +add_task(async function test() { + let sidebar = document.getElementById("sidebar"); + + // Visited pages listed by descending visit date. + let pages = [ + "http://sidebar.mozilla.org/a", + "http://sidebar.mozilla.org/b", + "http://sidebar.mozilla.org/c", + "http://www.mozilla.org/d", + ]; + + // Number of pages that will be filtered out by the search. + const FILTERED_COUNT = 1; + + await PlacesUtils.history.clear(); + + // Add some visited page. + let time = Date.now(); + let places = []; + for (let i = 0; i < pages.length; i++) { + places.push({ + uri: NetUtil.newURI(pages[i]), + visitDate: (time - i) * 1000, + transition: PlacesUtils.history.TRANSITION_TYPED, + }); + } + await PlacesTestUtils.addVisits(places); + + await withSidebarTree("history", function () { + info("Set 'by last visited' view"); + sidebar.contentDocument.getElementById("bylastvisited").doCommand(); + let tree = sidebar.contentDocument.getElementById("historyTree"); + check_tree_order(tree, pages); + + // Set a search value. + let searchBox = sidebar.contentDocument.getElementById("search-box"); + ok(searchBox, "search box is in context"); + searchBox.value = "sidebar.mozilla"; + searchBox.doCommand(); + check_tree_order(tree, pages, -FILTERED_COUNT); + + info("Reset the search"); + searchBox.value = ""; + searchBox.doCommand(); + check_tree_order(tree, pages); + }); + + await PlacesUtils.history.clear(); +}); + +function check_tree_order(tree, pages, aNumberOfRowsDelta = 0) { + let treeView = tree.view; + let columns = tree.columns; + is(columns.count, 1, "There should be only 1 column in the sidebar"); + + let found = 0; + for (let i = 0; i < treeView.rowCount; i++) { + let node = treeView.nodeForTreeIndex(i); + // We could inherit delayed visits from previous tests, skip them. + if (!pages.includes(node.uri)) { + continue; + } + is( + node.uri, + pages[i], + "Node is in correct position based on its visit date" + ); + found++; + } + is(found, pages.length + aNumberOfRowsDelta, "Found all expected results"); +} diff --git a/browser/components/places/tests/browser/browser_import_button.js b/browser/components/places/tests/browser/browser_import_button.js new file mode 100644 index 0000000000..146fd746f8 --- /dev/null +++ b/browser/components/places/tests/browser/browser_import_button.js @@ -0,0 +1,187 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const kPref = "browser.bookmarks.addedImportButton"; + +/** + * Verify that we add the import button only if there aren't enough bookmarks + * in the toolbar. + */ +add_task(async function test_bookmark_import_button() { + let bookmarkCount = ( + await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.toolbarGuid) + ).childCount; + Assert.less(bookmarkCount, 3, "we should start with less than 3 bookmarks"); + + ok( + !document.getElementById("import-button"), + "Shouldn't have button to start with." + ); + await PlacesUIUtils.maybeAddImportButton(); + ok(document.getElementById("import-button"), "Button should be added."); + is(Services.prefs.getBoolPref(kPref), true, "Pref should be set."); + + CustomizableUI.reset(); + + // Add some bookmarks. This should stop the import button from being inserted. + let parentGuid = PlacesUtils.bookmarks.toolbarGuid; + let bookmarks = await Promise.all( + ["firefox", "rules", "yo"].map(n => + PlacesUtils.bookmarks.insert({ + parentGuid, + url: `https://example.com/${n}`, + title: n.toString(), + }) + ) + ); + + // Ensure we remove items after this task, or worst-case after this test + // file has completed. + let removeAllBookmarks = () => { + let removals = bookmarks.map(b => PlacesUtils.bookmarks.remove(b.guid)); + bookmarks = []; + return Promise.all(removals); + }; + registerCleanupFunction(removeAllBookmarks); + + await PlacesUIUtils.maybeAddImportButton(); + ok( + !document.getElementById("import-button"), + "Button should not be added if we have bookmarks." + ); + + // Just in case, for future tests we run: + CustomizableUI.reset(); + + await removeAllBookmarks(); +}); + +/** + * Verify the button gets removed when we import bookmarks successfully. + */ +add_task(async function test_bookmark_import_button_removal() { + let bookmarkCount = ( + await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.toolbarGuid) + ).childCount; + Assert.less(bookmarkCount, 3, "we should start with less than 3 bookmarks"); + + ok( + !document.getElementById("import-button"), + "Shouldn't have button to start with." + ); + await PlacesUIUtils.maybeAddImportButton(); + ok(document.getElementById("import-button"), "Button should be added."); + is(Services.prefs.getBoolPref(kPref), true, "Pref should be set."); + + PlacesUIUtils.removeImportButtonWhenImportSucceeds(); + + Services.obs.notifyObservers( + null, + "Migration:ItemAfterMigrate", + MigrationUtils.resourceTypes.BOOKMARKS + ); + + is( + Services.prefs.getBoolPref(kPref, false), + true, + "Pref should stay without import." + ); + ok(document.getElementById("import-button"), "Button should still be there."); + + // OK, actually add some bookmarks: + MigrationUtils._importQuantities.bookmarks = 5; + Services.obs.notifyObservers( + null, + "Migration:ItemAfterMigrate", + MigrationUtils.resourceTypes.BOOKMARKS + ); + + is(Services.prefs.prefHasUserValue(kPref), false, "Pref should be removed."); + ok( + !document.getElementById("import-button"), + "Button should have been removed." + ); + + // Reset this, otherwise subsequent tests are going to have a bad time. + MigrationUtils._importQuantities.bookmarks = 0; +}); + +/** + * Check that if the user removes the button, the next startup + * we clear the pref and stop monitoring to remove the item. + */ +add_task(async function test_bookmark_import_button_removal_cleanup() { + let bookmarkCount = ( + await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.toolbarGuid) + ).childCount; + Assert.less(bookmarkCount, 3, "we should start with less than 3 bookmarks"); + + ok( + !document.getElementById("import-button"), + "Shouldn't have button to start with." + ); + await PlacesUIUtils.maybeAddImportButton(); + ok(document.getElementById("import-button"), "Button should be added."); + is(Services.prefs.getBoolPref(kPref), true, "Pref should be set."); + + // Simulate the user removing the item. + CustomizableUI.removeWidgetFromArea("import-button"); + + // We'll call this next startup: + PlacesUIUtils.removeImportButtonWhenImportSucceeds(); + + // And it should clean up the pref: + is(Services.prefs.prefHasUserValue(kPref), false, "Pref should be removed."); +}); + +/** + * Check that if migration (silently) errors, we still remove the button + * _if_ we imported any bookmarks. + */ +add_task(async function test_bookmark_import_button_errors() { + let bookmarkCount = ( + await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.toolbarGuid) + ).childCount; + Assert.less(bookmarkCount, 3, "we should start with less than 3 bookmarks"); + + ok( + !document.getElementById("import-button"), + "Shouldn't have button to start with." + ); + await PlacesUIUtils.maybeAddImportButton(); + ok(document.getElementById("import-button"), "Button should be added."); + is(Services.prefs.getBoolPref(kPref), true, "Pref should be set."); + + PlacesUIUtils.removeImportButtonWhenImportSucceeds(); + + Services.obs.notifyObservers( + null, + "Migration:ItemError", + MigrationUtils.resourceTypes.BOOKMARKS + ); + + is( + Services.prefs.getBoolPref(kPref, false), + true, + "Pref should stay when fatal error happens." + ); + ok(document.getElementById("import-button"), "Button should still be there."); + + // OK, actually add some bookmarks: + MigrationUtils._importQuantities.bookmarks = 5; + Services.obs.notifyObservers( + null, + "Migration:ItemError", + MigrationUtils.resourceTypes.BOOKMARKS + ); + + is(Services.prefs.prefHasUserValue(kPref), false, "Pref should be removed."); + ok( + !document.getElementById("import-button"), + "Button should have been removed." + ); + + MigrationUtils._importQuantities.bookmarks = 0; +}); diff --git a/browser/components/places/tests/browser/browser_library_bookmark_clear_visits.js b/browser/components/places/tests/browser/browser_library_bookmark_clear_visits.js new file mode 100644 index 0000000000..fab8b4c3b0 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_bookmark_clear_visits.js @@ -0,0 +1,124 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* + * Test whether or not that Most Recent Visit of bookmark in library window will + * update properly when removing the history. + */ + +const TEST_URLS = ["https://example.com/", "https://example.org/"]; + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_URLS[0], + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_URLS[1], + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_URLS[1], + }); + PlacesUtils.tagging.tagURI(makeURI(TEST_URLS[0]), ["test"]); + PlacesUtils.tagging.tagURI(makeURI(TEST_URLS[1]), ["test"]); + + registerCleanupFunction(async () => { + PlacesUtils.tagging.untagURI(makeURI(TEST_URLS[0]), ["test"]); + PlacesUtils.tagging.untagURI(makeURI(TEST_URLS[1]), ["test"]); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function folder() { + info("Open bookmarked urls to update most recent visit time"); + await updateMostRecentVisitTime(); + + info("Open library for bookmarks toolbar"); + const library = await promiseLibrary("BookmarksToolbar"); + + info("Add Most Recent Visit column"); + await showLibraryColumn(library, "placesContentDate"); + + info("Check the initial content"); + const tree = library.document.getElementById("placeContent"); + Assert.equal(tree.view.rowCount, 3, "All bookmarks are shown"); + assertRow(tree, 0, TEST_URLS[0], true); + assertRow(tree, 1, TEST_URLS[1], true); + assertRow(tree, 2, TEST_URLS[1], true); + + info("Clear all visits data"); + await PlacesUtils.history.remove(TEST_URLS); + + info("Check whether or not the content are updated"); + assertRow(tree, 0, TEST_URLS[0], false); + assertRow(tree, 1, TEST_URLS[1], false); + assertRow(tree, 2, TEST_URLS[1], false); + + info("Close library window"); + await promiseLibraryClosed(library); +}); + +add_task(async function tags() { + info("Open bookmarked urls to update most recent visit time"); + await updateMostRecentVisitTime(); + + info("Open library for bookmarks toolbar"); + const library = await promiseLibrary("BookmarksToolbar"); + + info("Add Most Recent Visit column"); + await showLibraryColumn(library, "placesContentDate"); + + info("Open test tag"); + const PO = library.PlacesOrganizer; + PO.selectLeftPaneBuiltIn("Tags"); + const tagsNode = PO._places.selectedNode; + PlacesUtils.asContainer(tagsNode).containerOpen = true; + const tag = tagsNode.getChild(0); + PO._places.selectNode(tag); + + info("Check the initial content"); + const tree = library.ContentTree.view; + Assert.equal(tree.view.rowCount, 3, "All bookmarks are shown"); + assertRow(tree, 0, TEST_URLS[0], true); + assertRow(tree, 1, TEST_URLS[1], true); + assertRow(tree, 2, TEST_URLS[1], true); + + info("Clear all visits data"); + await PlacesUtils.history.remove(TEST_URLS); + + info("Check whether or not the content are updated"); + assertRow(tree, 0, TEST_URLS[0], false); + assertRow(tree, 1, TEST_URLS[1], false); + assertRow(tree, 2, TEST_URLS[1], false); + + info("Close library window"); + await promiseLibraryClosed(library); +}); + +async function updateMostRecentVisitTime() { + for (const url of TEST_URLS) { + const onLoaded = BrowserTestUtils.browserLoaded(gBrowser, false, url); + BrowserTestUtils.startLoadingURIString(gBrowser, url); + await onLoaded; + } +} + +function assertRow(tree, targeRow, expectedUrl, expectMostRecentVisitHasValue) { + const url = tree.view.getCellText(targeRow, tree.columns.placesContentUrl); + Assert.equal(url, expectedUrl, "URL is correct"); + const mostRecentVisit = tree.view.getCellText( + targeRow, + tree.columns.placesContentDate + ); + Assert.equal( + !!mostRecentVisit, + expectMostRecentVisitHasValue, + "Most Recent Visit data is in the cell correctly" + ); +} diff --git a/browser/components/places/tests/browser/browser_library_bookmark_pages.js b/browser/components/places/tests/browser/browser_library_bookmark_pages.js new file mode 100644 index 0000000000..474adb956d --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_bookmark_pages.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * Test that the a new bookmark is correctly selected after being created via + * the bookmark dialog. + */ +"use strict"; + +const TEST_URIS = ["https://example1.com/", "https://example2.com/"]; +let library; + +add_setup(async function () { + await PlacesTestUtils.addVisits(TEST_URIS); + + library = await promiseLibrary("History"); + + registerCleanupFunction(async function () { + await promiseLibraryClosed(library); + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function test_bookmark_page() { + library.ContentTree.view.selectPlaceURI(TEST_URIS[0]); + + await withBookmarksDialog( + true, + async () => { + // Open the context menu. + let placesContext = library.document.getElementById("placesContext"); + let promisePopup = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(library.ContentTree.view, { + button: 2, + type: "contextmenu", + }); + + await promisePopup; + let properties = library.document.getElementById( + "placesContext_createBookmark" + ); + placesContext.activateItem(properties); + }, + async dialogWin => { + Assert.strictEqual( + dialogWin.BookmarkPropertiesPanel._itemType, + 0, + "Should have loaded a bookmark dialog" + ); + Assert.equal( + dialogWin.document.getElementById("editBMPanel_locationField").value, + TEST_URIS[0], + "Should have opened the dialog with the correct uri to be bookmarked" + ); + } + ); +}); + +add_task(async function test_bookmark_pages() { + library.ContentTree.view.selectAll(); + + await withBookmarksDialog( + true, + async () => { + // Open the context menu. + let placesContext = library.document.getElementById("placesContext"); + let promisePopup = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(library.ContentTree.view, { + button: 2, + type: "contextmenu", + }); + + await promisePopup; + let properties = library.document.getElementById( + "placesContext_createBookmark" + ); + placesContext.activateItem(properties); + }, + async dialogWin => { + Assert.strictEqual( + dialogWin.BookmarkPropertiesPanel._itemType, + 1, + "Should have loaded a create bookmark folder dialog" + ); + Assert.deepEqual( + dialogWin.BookmarkPropertiesPanel._URIs.map(uri => uri.uri.spec), + // The list here is reversed, because that's the order they're shown + // in the view. + [TEST_URIS[1], TEST_URIS[0]], + "Should have got the correct URIs for adding to the folder" + ); + } + ); +}); diff --git a/browser/components/places/tests/browser/browser_library_bulk_tag_bookmarks.js b/browser/components/places/tests/browser/browser_library_bulk_tag_bookmarks.js new file mode 100644 index 0000000000..23d3b30564 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_bulk_tag_bookmarks.js @@ -0,0 +1,146 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that a tag can be added to multiple bookmarks at once from the library. + */ +"use strict"; + +const TEST_URLS = ["about:buildconfig", "about:robots"]; + +add_task(async function test_bulk_tag_from_library() { + // Create multiple bookmarks. + for (const url of TEST_URLS) { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url, + }); + } + + // Open library panel. + const library = await promiseLibrary("UnfiledBookmarks"); + const cleanupFn = async () => { + await promiseLibraryClosed(library); + await PlacesUtils.bookmarks.eraseEverything(); + }; + registerCleanupFunction(cleanupFn); + + // Add a tag to multiple bookmarks. + library.ContentTree.view.selectAll(); + const promiseAllTagsChanged = TEST_URLS.map(url => + PlacesTestUtils.waitForNotification("bookmark-tags-changed", events => + events.some(evt => evt.url === url) + ) + ); + const tag = "some, tag"; + const tagWithDuplicates = `${tag}, tag`; + fillBookmarkTextField("editBMPanel_tagsField", tagWithDuplicates, library); + await Promise.all(promiseAllTagsChanged); + await TestUtils.waitForCondition( + () => + library.document.getElementById("editBMPanel_tagsField").value === tag, + "Input field matches the new tags and duplicates are removed." + ); + + // Verify that the bookmarks were tagged successfully. + for (const url of TEST_URLS) { + Assert.deepEqual( + PlacesUtils.tagging.getTagsForURI(Services.io.newURI(url)), + ["some", "tag"], + url + " should have the correct tags." + ); + } + await cleanupFn(); +}); + +add_task(async function test_bulk_tag_tags_selector() { + // Create multiple bookmarks with a common tag. + for (const [i, url] of TEST_URLS.entries()) { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url, + }); + PlacesUtils.tagging.tagURI(Services.io.newURI(url), [ + "common", + `unique_${i}`, + ]); + } + + // Open library panel. + const library = await promiseLibrary("UnfiledBookmarks"); + const cleanupFn = async () => { + await promiseLibraryClosed(library); + await PlacesUtils.bookmarks.eraseEverything(); + }; + registerCleanupFunction(cleanupFn); + + // Open tags selector. + library.document.getElementById("editBMPanel_tagsSelectorRow").hidden = false; + + // Select all bookmarks. + const tagsSelector = library.document.getElementById( + "editBMPanel_tagsSelector" + ); + library.ContentTree.view.selectAll(); + + // Verify that the input field only shows the common tag. + await TestUtils.waitForCondition( + () => + library.document.getElementById("editBMPanel_tagsField").value === + "common", + "Input field only shows the common tag." + ); + + // Verify that the tags selector shows all tags, and only the common one is + // checked. + async function checkTagsSelector(aAvailableTags, aCheckedTags) { + let tags = await PlacesUtils.bookmarks.fetchTags(); + is(tags.length, aAvailableTags.length, "Check tags list"); + let children = tagsSelector.children; + is( + children.length, + aAvailableTags.length, + "Found expected number of tags in the tags selector" + ); + + Array.prototype.forEach.call(children, function (aChild) { + let tag = aChild.querySelector("label").getAttribute("value"); + ok(true, "Found tag '" + tag + "' in the selector"); + ok(aAvailableTags.includes(tag), "Found expected tag"); + let checked = aChild.getAttribute("checked") == "true"; + is(checked, aCheckedTags.includes(tag), "Tag is correctly marked"); + }); + } + + async function promiseTagSelectorUpdated(task) { + let promise = BrowserTestUtils.waitForEvent( + tagsSelector, + "BookmarkTagsSelectorUpdated" + ); + + await task(); + return promise; + } + + info("Check the initial common tag."); + await checkTagsSelector(["common", "unique_0", "unique_1"], ["common"]); + + // Verify that the common tag can be edited. + await promiseTagSelectorUpdated(() => { + info("Edit the common tag."); + fillBookmarkTextField("editBMPanel_tagsField", "common_updated", library); + }); + await checkTagsSelector( + ["common_updated", "unique_0", "unique_1"], + ["common_updated"] + ); + + // Verify that the common tag can be removed. + await promiseTagSelectorUpdated(() => { + info("Remove the commmon tag."); + fillBookmarkTextField("editBMPanel_tagsField", "", library); + }); + await checkTagsSelector(["unique_0", "unique_1"], []); + + await cleanupFn(); +}); diff --git a/browser/components/places/tests/browser/browser_library_commands.js b/browser/components/places/tests/browser/browser_library_commands.js new file mode 100644 index 0000000000..254c9503c2 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_commands.js @@ -0,0 +1,335 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Test enabled commands in the left pane folder of the Library. + */ + +const TEST_URI = NetUtil.newURI("http://www.mozilla.org/"); + +registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task(async function test_date_container() { + let library = await promiseLibrary(); + info("Ensure date containers under History cannot be cut but can be deleted"); + + await PlacesTestUtils.addVisits(TEST_URI); + + // Select and open the left pane "History" query. + let PO = library.PlacesOrganizer; + + PO.selectLeftPaneBuiltIn("History"); + Assert.notEqual( + PO._places.selectedNode, + null, + "We correctly selected History" + ); + + // Check that both delete and cut commands are disabled, cause this is + // a child of the left pane folder. + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_copy"), + "Copy command is enabled" + ); + Assert.ok( + !PO._places.controller.isCommandEnabled("cmd_cut"), + "Cut command is disabled" + ); + Assert.ok( + !PO._places.controller.isCommandEnabled("cmd_delete"), + "Delete command is disabled" + ); + let historyNode = PlacesUtils.asContainer(PO._places.selectedNode); + historyNode.containerOpen = true; + + // Check that we have a child container. It is "Today" container. + Assert.equal(historyNode.childCount, 1, "History node has one child"); + let todayNode = historyNode.getChild(0); + let todayNodeExpectedTitle = PlacesUtils.getString("finduri-AgeInDays-is-0"); + Assert.equal( + todayNode.title, + todayNodeExpectedTitle, + "History child is the expected container" + ); + + // Select "Today" container. + PO._places.selectNode(todayNode); + Assert.equal( + PO._places.selectedNode, + todayNode, + "We correctly selected Today container" + ); + // Check that delete command is enabled but cut command is disabled, cause + // this is an history item. + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_copy"), + "Copy command is enabled" + ); + Assert.ok( + !PO._places.controller.isCommandEnabled("cmd_cut"), + "Cut command is disabled" + ); + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_delete"), + "Delete command is enabled" + ); + + // Execute the delete command and check visit has been removed. + const promiseURIRemoved = PlacesTestUtils.waitForNotification( + "page-removed", + events => events[0].url === TEST_URI.spec + ); + PO._places.controller.doCommand("cmd_delete"); + const removeEvents = await promiseURIRemoved; + Assert.ok( + removeEvents[0].isRemovedFromStore, + "isRemovedFromStore should be true" + ); + + // Test live update of "History" query. + Assert.equal(historyNode.childCount, 0, "History node has no more children"); + + historyNode.containerOpen = false; + + Assert.ok( + !(await PlacesUtils.history.hasVisits(TEST_URI)), + "Visit has been removed" + ); + + library.close(); +}); + +add_task(async function test_query_on_toolbar() { + let library = await promiseLibrary(); + info("Ensure queries can be cut or deleted"); + + // Select and open the left pane "Bookmarks Toolbar" folder. + let PO = library.PlacesOrganizer; + + PO.selectLeftPaneBuiltIn("BookmarksToolbar"); + Assert.notEqual(PO._places.selectedNode, null, "We have a valid selection"); + Assert.equal( + PlacesUtils.getConcreteItemGuid(PO._places.selectedNode), + PlacesUtils.bookmarks.toolbarGuid, + "We have correctly selected bookmarks toolbar node." + ); + + // Check that both cut and delete commands are disabled, cause this is a child + // of the All Bookmarks special query. + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_copy"), + "Copy command is enabled" + ); + Assert.ok( + !PO._places.controller.isCommandEnabled("cmd_cut"), + "Cut command is disabled" + ); + Assert.ok( + !PO._places.controller.isCommandEnabled("cmd_delete"), + "Delete command is disabled" + ); + + let toolbarNode = PlacesUtils.asContainer(PO._places.selectedNode); + toolbarNode.containerOpen = true; + + // Add an History query to the toolbar. + let query = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "place:sort=4", + title: "special_query", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }); + + // Get first child and check it is the just inserted query. + Assert.greater(toolbarNode.childCount, 0, "Toolbar node has children"); + let queryNode = toolbarNode.getChild(0); + Assert.equal( + queryNode.title, + "special_query", + "Query node is correctly selected" + ); + + // Select query node. + PO._places.selectNode(queryNode); + Assert.equal( + PO._places.selectedNode, + queryNode, + "We correctly selected query node" + ); + + // Check that both cut and delete commands are enabled. + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_copy"), + "Copy command is enabled" + ); + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_cut"), + "Cut command is enabled" + ); + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_delete"), + "Delete command is enabled" + ); + + // Execute the delete command and check bookmark has been removed. + let promiseItemRemoved = PlacesTestUtils.waitForNotification( + "bookmark-removed", + events => events.some(event => query.guid == event.guid) + ); + PO._places.controller.doCommand("cmd_delete"); + await promiseItemRemoved; + + Assert.equal( + await PlacesUtils.bookmarks.fetch(query.guid), + null, + "Query node bookmark has been correctly removed" + ); + + toolbarNode.containerOpen = false; + + library.close(); +}); + +add_task(async function test_search_contents() { + await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/", + title: "example page", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: 0, + }); + + let library = await promiseLibrary(); + info("Ensure query contents can be cut or deleted"); + + // Select and open the left pane "Bookmarks Toolbar" folder. + let PO = library.PlacesOrganizer; + + PO.selectLeftPaneBuiltIn("BookmarksToolbar"); + Assert.notEqual(PO._places.selectedNode, null, "We have a valid selection"); + Assert.equal( + PlacesUtils.getConcreteItemGuid(PO._places.selectedNode), + PlacesUtils.bookmarks.toolbarGuid, + "We have correctly selected bookmarks toolbar node." + ); + + let searchBox = library.document.getElementById("searchFilter"); + searchBox.value = "example"; + library.PlacesSearchBox.search(searchBox.value); + + let bookmarkNode = library.ContentTree.view.selectedNode; + Assert.equal( + bookmarkNode.uri, + "http://example.com/", + "Found the expected bookmark" + ); + + // Check that both cut and delete commands are enabled. + Assert.ok( + library.ContentTree.view.controller.isCommandEnabled("cmd_copy"), + "Copy command is enabled" + ); + Assert.ok( + library.ContentTree.view.controller.isCommandEnabled("cmd_cut"), + "Cut command is enabled" + ); + Assert.ok( + library.ContentTree.view.controller.isCommandEnabled("cmd_delete"), + "Delete command is enabled" + ); + + library.close(); +}); + +add_task(async function test_tags() { + await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/", + title: "example page", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: 0, + }); + PlacesUtils.tagging.tagURI(NetUtil.newURI("http://example.com/"), ["test"]); + + let library = await promiseLibrary(); + info("Ensure query contents can be cut or deleted"); + + // Select and open the left pane "Bookmarks Toolbar" folder. + let PO = library.PlacesOrganizer; + + PO.selectLeftPaneBuiltIn("Tags"); + let tagsNode = PO._places.selectedNode; + Assert.notEqual(tagsNode, null, "We have a valid selection"); + let tagsTitle = PlacesUtils.getString("TagsFolderTitle"); + Assert.equal(tagsNode.title, tagsTitle, "Tags has been properly selected"); + + // Check that both cut and delete commands are disabled. + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_copy"), + "Copy command is enabled" + ); + Assert.ok( + !PO._places.controller.isCommandEnabled("cmd_cut"), + "Cut command is disabled" + ); + Assert.ok( + !PO._places.controller.isCommandEnabled("cmd_delete"), + "Delete command is disabled" + ); + + // Now select the tag. + PlacesUtils.asContainer(tagsNode).containerOpen = true; + let tag = tagsNode.getChild(0); + PO._places.selectNode(tag); + Assert.equal( + PO._places.selectedNode.title, + "test", + "The created tag has been properly selected" + ); + + // Check that cut is disabled but delete is enabled. + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_copy"), + "Copy command is enabled" + ); + Assert.ok( + !PO._places.controller.isCommandEnabled("cmd_cut"), + "Cut command is disabled" + ); + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_delete"), + "Delete command is enabled" + ); + + let bookmarkNode = library.ContentTree.view.selectedNode; + Assert.equal( + bookmarkNode.uri, + "http://example.com/", + "Found the expected bookmark" + ); + + // Check that both cut and delete commands are enabled. + Assert.ok( + library.ContentTree.view.controller.isCommandEnabled("cmd_copy"), + "Copy command is enabled" + ); + Assert.ok( + !library.ContentTree.view.controller.isCommandEnabled("cmd_cut"), + "Cut command is disabled" + ); + Assert.ok( + library.ContentTree.view.controller.isCommandEnabled("cmd_delete"), + "Delete command is enabled" + ); + + tagsNode.containerOpen = false; + + library.close(); +}); diff --git a/browser/components/places/tests/browser/browser_library_delete.js b/browser/components/places/tests/browser/browser_library_delete.js new file mode 100644 index 0000000000..fe95be0604 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_delete.js @@ -0,0 +1,157 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that Library correctly handles deletes. + */ + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +const TEST_URL = "http://www.batch.delete.me/"; + +var gLibrary; + +add_task(async function test_setup() { + gLibrary = await promiseLibrary(); + + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + // Close Library window. + gLibrary.close(); + }); +}); + +add_task(async function test_create_and_remove_bookmarks() { + let bmChildren = []; + for (let i = 0; i < 10; i++) { + bmChildren.push({ + title: `bm${i}`, + url: TEST_URL, + }); + } + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "deleteme", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: bmChildren, + }, + { + title: "keepme", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + ], + }); + + // Select and open the left pane "History" query. + let PO = gLibrary.PlacesOrganizer; + PO.selectLeftPaneBuiltIn("UnfiledBookmarks"); + Assert.notEqual(PO._places.selectedNode, null, "Selected unsorted bookmarks"); + + let unsortedNode = PlacesUtils.asContainer(PO._places.selectedNode); + Assert.equal(unsortedNode.childCount, 2, "Unsorted node has 2 children"); + let folderNode = unsortedNode.getChild(0); + Assert.equal( + folderNode.title, + "deleteme", + "Folder found in unsorted bookmarks" + ); + + // Check delete command is available. + PO._places.selectNode(folderNode); + Assert.equal( + PO._places.selectedNode.title, + "deleteme", + "Folder node selected" + ); + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_delete"), + "Delete command is enabled" + ); + let promiseItemRemovedNotification = PlacesTestUtils.waitForNotification( + "bookmark-removed", + events => events.some(event => event.guid == folderNode.bookmarkGuid) + ); + + // Press the delete key and check that the bookmark has been removed. + gLibrary.document.getElementById("placesList").focus(); + EventUtils.synthesizeKey("VK_DELETE", {}, gLibrary); + + await promiseItemRemovedNotification; + + Assert.ok( + !(await PlacesUtils.bookmarks.fetch({ url: TEST_URL })), + "Bookmark has been correctly removed" + ); + // Test live update. + Assert.equal(unsortedNode.childCount, 1, "Unsorted node has 1 child"); + Assert.equal(PO._places.selectedNode.title, "keepme", "Folder node selected"); +}); + +add_task(async function test_ensure_correct_selection_and_functionality() { + let PO = gLibrary.PlacesOrganizer; + let ContentTree = gLibrary.ContentTree; + // Move selection forth and back. + PO.selectLeftPaneBuiltIn("History"); + PO.selectLeftPaneBuiltIn("UnfiledBookmarks"); + // Now select the "keepme" folder in the right pane and delete it. + ContentTree.view.selectNode(ContentTree.view.result.root.getChild(0)); + Assert.equal( + ContentTree.view.selectedNode.title, + "keepme", + "Found folder in content pane" + ); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bm", + url: TEST_URL, + }); + + Assert.equal( + ContentTree.view.result.root.childCount, + 2, + "Right pane was correctly updated" + ); +}); + +add_task(async function test_repeated_remove_bookmark() { + // Select and open the left pane "History" query. + let PO = gLibrary.PlacesOrganizer; + PO.selectLeftPaneBuiltIn("UnfiledBookmarks"); + + gLibrary.document.getElementById("placesList").focus(); + let unsortedNode = PlacesUtils.asContainer(PO._places.selectedNode); + Assert.equal(unsortedNode.childCount, 1, "Unsorted node has 1 child"); + let folderNode = unsortedNode.getChild(0); + Assert.equal( + folderNode.title, + "keepme", + "Folder found in unsorted bookmarks" + ); + PO._places.selectNode(folderNode); + + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_delete"), + "Delete command is enabled" + ); + registerCleanupFunction(sinon.restore); + let spy = sinon.spy(PO._places.controller, "remove"); + let stub = sinon + .stub(PO._places.controller, "_removeRowsFromBookmarks") + .resolves(); + PO._places.controller.doCommand("cmd_delete"); + PO._places.controller.doCommand("cmd_delete"); + PO._places.controller.doCommand("cmd_delete"); + Assert.equal(spy.callCount, 3, "Should have been invoked thrice"); + Assert.equal(stub.callCount, 1, "Should have been invoked once"); + // Executing another command allows delete to go through again. + PO._places.controller.doCommand("cmd_cut"); + PO._places.controller.doCommand("cmd_delete"); + Assert.equal(spy.callCount, 4, "Should have been invoked again"); + Assert.equal(stub.callCount, 2, "Should have been invoked again"); +}); diff --git a/browser/components/places/tests/browser/browser_library_delete_bookmarks_in_tags.js b/browser/components/places/tests/browser/browser_library_delete_bookmarks_in_tags.js new file mode 100644 index 0000000000..ed124a047a --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_delete_bookmarks_in_tags.js @@ -0,0 +1,111 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Test deleting bookmarks from within tags. + */ + +registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task(async function test_tags() { + const uris = [ + Services.io.newURI("http://example.com/1"), + Services.io.newURI("http://example.com/2"), + Services.io.newURI("http://example.com/3"), + ]; + + let children = uris.map((uri, index, arr) => { + return { + title: `bm${index}`, + url: uri, + }; + }); + + // Note: we insert the uris in reverse order, so that we end up with the + // display in "logical" order of bm0 at the top, and bm2 at the bottom. + children = children.reverse(); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children, + }); + + for (let uri of uris) { + PlacesUtils.tagging.tagURI(uri, ["test"]); + } + + let library = await promiseLibrary(); + + // Select and open the left pane "Bookmarks Toolbar" folder. + let PO = library.PlacesOrganizer; + + PO.selectLeftPaneBuiltIn("Tags"); + let tagsNode = PO._places.selectedNode; + Assert.notEqual(tagsNode, null, "Should have a valid selection"); + let tagsTitle = PlacesUtils.getString("TagsFolderTitle"); + Assert.equal(tagsNode.title, tagsTitle, "Should have selected the Tags node"); + + // Now select the tag. + PlacesUtils.asContainer(tagsNode).containerOpen = true; + let tag = tagsNode.getChild(0); + PO._places.selectNode(tag); + Assert.equal( + PO._places.selectedNode.title, + "test", + "Should have selected the created tag" + ); + + let ContentTree = library.ContentTree; + + for (let i = 0; i < uris.length; i++) { + ContentTree.view.selectNode(ContentTree.view.result.root.getChild(0)); + + Assert.equal( + ContentTree.view.selectedNode.title, + `bm${i}`, + `Should have selected bm${i}` + ); + + let promiseNotification = PlacesTestUtils.waitForNotification( + "bookmark-tags-changed" + ); + + ContentTree.view.controller.doCommand("cmd_delete"); + + await promiseNotification; + + for (let j = 0; j < uris.length; j++) { + let tags = PlacesUtils.tagging.getTagsForURI(uris[j]); + if (j <= i) { + Assert.equal( + tags.length, + 0, + `There should be no tags for the URI: ${uris[j].spec}` + ); + } else { + Assert.equal( + tags.length, + 1, + `There should be one tag for the URI: ${uris[j].spec}` + ); + } + } + } + + // The tag should now not exist. + Assert.equal( + await PlacesUtils.bookmarks.fetch({ tags: ["test"] }), + null, + "There should be no URIs remaining for the tag" + ); + + tagsNode.containerOpen = false; + + library.close(); +}); diff --git a/browser/components/places/tests/browser/browser_library_delete_tags.js b/browser/components/places/tests/browser/browser_library_delete_tags.js new file mode 100644 index 0000000000..d5f3eafc63 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_delete_tags.js @@ -0,0 +1,60 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Test enabled commands in the left pane folder of the Library. + */ + +registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task(async function test_tags() { + const TEST_URI = Services.io.newURI("http://example.com/"); + + await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: TEST_URI, + title: "example page", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: 0, + }); + PlacesUtils.tagging.tagURI(TEST_URI, ["test"]); + + let library = await promiseLibrary(); + + // Select and open the left pane "Bookmarks Toolbar" folder. + let PO = library.PlacesOrganizer; + + PO.selectLeftPaneBuiltIn("Tags"); + let tagsNode = PO._places.selectedNode; + Assert.notEqual(tagsNode, null, "Should have a valid selection"); + let tagsTitle = PlacesUtils.getString("TagsFolderTitle"); + Assert.equal(tagsNode.title, tagsTitle, "Should have selected the Tags node"); + + // Now select the tag. + PlacesUtils.asContainer(tagsNode).containerOpen = true; + let tag = tagsNode.getChild(0); + PO._places.selectNode(tag); + Assert.equal( + PO._places.selectedNode.title, + "test", + "Should have selected the created tag" + ); + + PO._places.controller.doCommand("cmd_delete"); + + await PlacesTestUtils.promiseAsyncUpdates(); + + let tags = PlacesUtils.tagging.getTagsForURI(TEST_URI); + + Assert.equal(tags.length, 0, "There should be no tags for the URI"); + + tagsNode.containerOpen = false; + + library.close(); +}); diff --git a/browser/components/places/tests/browser/browser_library_downloads.js b/browser/components/places/tests/browser/browser_library_downloads.js new file mode 100644 index 0000000000..3aa37b1fed --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_downloads.js @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + * Tests bug 564900: Add folder specifically for downloads to Library left pane. + * https://bugzilla.mozilla.org/show_bug.cgi?id=564900 + * This test visits various pages then opens the Library and ensures + * that both the Downloads folder shows up and that the correct visits + * are shown in it. + */ + +add_task(async function test() { + // Add visits. + await PlacesTestUtils.addVisits([ + { + uri: "http://mozilla.org", + transition: PlacesUtils.history.TRANSITION_TYPED, + }, + { + uri: "http://google.com", + transition: PlacesUtils.history.TRANSITION_DOWNLOAD, + }, + { + uri: "http://en.wikipedia.org", + transition: PlacesUtils.history.TRANSITION_TYPED, + }, + { + uri: "http://ubuntu.org", + transition: PlacesUtils.history.TRANSITION_DOWNLOAD, + }, + ]); + + let library = await promiseLibrary("Downloads"); + + registerCleanupFunction(async () => { + await library.close(); + await PlacesUtils.history.clear(); + }); + + // Make sure Downloads is present. + Assert.notEqual( + library.PlacesOrganizer._places.selectedNode, + null, + "Downloads is present and selected" + ); + + // Check results. + let testURIs = ["http://ubuntu.org/", "http://google.com/"]; + + await TestUtils.waitForCondition( + () => + library.ContentArea.currentView.associatedElement.itemChildren.length == + testURIs.length + ); + + for (let element of library.ContentArea.currentView.associatedElement + .itemChildren) { + Assert.equal( + element._shell.download.source.url, + testURIs.shift(), + "URI matches" + ); + } +}); diff --git a/browser/components/places/tests/browser/browser_library_left_pane_middleclick.js b/browser/components/places/tests/browser/browser_library_left_pane_middleclick.js new file mode 100644 index 0000000000..fc33963199 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_left_pane_middleclick.js @@ -0,0 +1,106 @@ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests middle-clicking items in the Library. + */ + +const URIs = ["about:license", "about:mozilla"]; + +var gLibrary = null; + +add_task(async function test_setup() { + // Temporary disable history, so we won't record pages navigation. + await SpecialPowers.pushPrefEnv({ set: [["places.history.enabled", false]] }); + + // Open Library window. + gLibrary = await promiseLibrary(); + + registerCleanupFunction(async () => { + // We must close "Other Bookmarks" ready for other tests. + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + gLibrary.PlacesOrganizer._places.selectedNode.containerOpen = false; + + await PlacesUtils.bookmarks.eraseEverything(); + + // Close Library window. + await promiseLibraryClosed(gLibrary); + }); +}); + +add_task(async function test_open_folder_in_tabs() { + let children = URIs.map(url => { + return { + title: "Title", + url, + }; + }); + + // Create a new folder. + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "Folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children, + }, + ], + }); + + // Select unsorted bookmarks root in the left pane. + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + Assert.notEqual( + gLibrary.PlacesOrganizer._places.selectedNode, + null, + "We correctly have selection in the Library left pane" + ); + + // Get our bookmark in the right pane. + var folderNode = gLibrary.ContentTree.view.view.nodeForTreeIndex(0); + Assert.equal(folderNode.title, "Folder", "Found folder in the right pane"); + + gLibrary.PlacesOrganizer._places.selectedNode.containerOpen = true; + + // Now middle-click on the bookmark contained with it. + let promiseLoaded = Promise.all( + URIs.map(uri => BrowserTestUtils.waitForNewTab(gBrowser, uri, false, true)) + ); + + let bookmarkedNode = + gLibrary.PlacesOrganizer._places.selectedNode.getChild(0); + mouseEventOnCell( + gLibrary.PlacesOrganizer._places, + gLibrary.PlacesOrganizer._places.view.treeIndexForNode(bookmarkedNode), + 0, + { button: 1 } + ); + + let tabs = await promiseLoaded; + + Assert.ok(true, "Expected tabs were loaded"); + + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } +}); + +function mouseEventOnCell(aTree, aRowIndex, aColumnIndex, aEventDetails) { + var selection = aTree.view.selection; + selection.select(aRowIndex); + aTree.ensureRowIsVisible(aRowIndex); + var column = aTree.columns[aColumnIndex]; + + // get cell coordinates + var rect = aTree.getCoordsForCellItem(aRowIndex, column, "text"); + + EventUtils.synthesizeMouse( + aTree.body, + rect.x, + rect.y, + aEventDetails, + gLibrary + ); +} diff --git a/browser/components/places/tests/browser/browser_library_left_pane_select_hierarchy.js b/browser/components/places/tests/browser/browser_library_left_pane_select_hierarchy.js new file mode 100644 index 0000000000..6caaf5a9d4 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_left_pane_select_hierarchy.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +add_task(async function () { + let hierarchy = ["AllBookmarks", "BookmarksMenu"]; + + let items = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + title: "Folder 1", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "Folder 2", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "Bookmark", + url: "http://example.com", + }, + ], + }, + ], + }, + ], + }); + + hierarchy.push(items[0].guid, items[1].guid); + + let library = await promiseLibrary(); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.remove(items[0]); + await promiseLibraryClosed(library); + }); + + library.PlacesOrganizer.selectLeftPaneContainerByHierarchy(hierarchy); + + Assert.equal( + library.PlacesOrganizer._places.selectedNode.bookmarkGuid, + items[1].guid, + "Found the expected left pane selected node" + ); + + Assert.equal( + library.ContentTree.view.view.nodeForTreeIndex(0).bookmarkGuid, + items[2].guid, + "Found the expected right pane contents" + ); +}); diff --git a/browser/components/places/tests/browser/browser_library_middleclick.js b/browser/components/places/tests/browser/browser_library_middleclick.js new file mode 100644 index 0000000000..8eb0bfa008 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_middleclick.js @@ -0,0 +1,234 @@ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests middle-clicking items in the Library. + */ + +const URIs = ["about:license", "about:mozilla"]; + +var gLibrary = null; +var gTests = []; + +add_task(async function test_setup() { + // Increase timeout, this test can be quite slow due to waitForFocus calls. + requestLongerTimeout(2); + + // Temporary disable history, so we won't record pages navigation. + await SpecialPowers.pushPrefEnv({ set: [["places.history.enabled", false]] }); + + // Ensure the database is empty. + await PlacesUtils.bookmarks.eraseEverything(); + + // Open Library window. + gLibrary = await promiseLibrary(); + + registerCleanupFunction(async () => { + // We must close "Other Bookmarks" ready for other tests. + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + gLibrary.PlacesOrganizer._places.selectedNode.containerOpen = false; + + await PlacesUtils.bookmarks.eraseEverything(); + + // Close Library window. + await promiseLibraryClosed(gLibrary); + }); +}); + +gTests.push({ + desc: "Open bookmark in a new tab.", + URIs: ["about:buildconfig"], + _bookmark: null, + + async setup() { + // Add a new unsorted bookmark. + this._bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "Title", + url: this.URIs[0], + }); + + // Select unsorted bookmarks root in the left pane. + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + Assert.notEqual( + gLibrary.PlacesOrganizer._places.selectedNode, + null, + "We correctly have selection in the Library left pane" + ); + + // Get our bookmark in the right pane. + var bookmarkNode = gLibrary.ContentTree.view.view.nodeForTreeIndex(0); + Assert.equal( + bookmarkNode.uri, + this.URIs[0], + "Found bookmark in the right pane" + ); + }, + + async cleanup() { + await PlacesUtils.bookmarks.remove(this._bookmark); + }, +}); + +// ------------------------------------------------------------------------------ +// Open a folder in tabs. +// +gTests.push({ + desc: "Open a folder in tabs.", + URIs: ["about:buildconfig", "about:mozilla"], + _bookmarks: null, + + async setup() { + // Create a new folder. + let children = this.URIs.map(url => { + return { + title: "Title", + url, + }; + }); + + this._bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "Folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children, + }, + ], + }); + + // Select unsorted bookmarks root in the left pane. + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + isnot( + gLibrary.PlacesOrganizer._places.selectedNode, + null, + "We correctly have selection in the Library left pane" + ); + // Get our bookmark in the right pane. + var folderNode = gLibrary.ContentTree.view.view.nodeForTreeIndex(0); + is(folderNode.title, "Folder", "Found folder in the right pane"); + }, + + async cleanup() { + await PlacesUtils.bookmarks.remove(this._bookmarks[0]); + }, +}); + +// ------------------------------------------------------------------------------ +// Open a query in tabs. + +gTests.push({ + desc: "Open a query in tabs.", + URIs: ["about:buildconfig", "about:mozilla"], + _bookmarks: null, + _query: null, + + async setup() { + let children = this.URIs.map(url => { + return { + title: "Title", + url, + }; + }); + + this._bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "Folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children, + }, + ], + }); + + // Create a bookmarks query containing our bookmarks. + var hs = PlacesUtils.history; + var options = hs.getNewQueryOptions(); + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS; + var query = hs.getNewQuery(); + // The colon included in the terms selects only about: URIs. If not included + // we also may get pages like about.html included in the query result. + query.searchTerms = "about:"; + var queryString = hs.queryToQueryString(query, options); + this._query = await PlacesUtils.bookmarks.insert({ + index: 0, // it must be the first + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "Query", + url: queryString, + }); + + // Select unsorted bookmarks root in the left pane. + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + isnot( + gLibrary.PlacesOrganizer._places.selectedNode, + null, + "We correctly have selection in the Library left pane" + ); + // Get our bookmark in the right pane. + var folderNode = gLibrary.ContentTree.view.view.nodeForTreeIndex(0); + is(folderNode.title, "Query", "Found query in the right pane"); + }, + + async cleanup() { + await PlacesUtils.bookmarks.remove(this._bookmarks[0]); + await PlacesUtils.bookmarks.remove(this._query); + }, +}); + +async function runTest(test) { + info("Start of test: " + test.desc); + // Test setup will set Library so that the bookmark to be opened is the + // first node in the content (right pane) tree. + await test.setup(); + + // Middle click on first node in the content tree of the Library. + gLibrary.focus(); + await SimpleTest.promiseFocus(gLibrary); + + // Now middle-click on the bookmark contained with it. + let promiseLoaded = Promise.all( + test.URIs.map(uri => + BrowserTestUtils.waitForNewTab(gBrowser, uri, false, true) + ) + ); + + mouseEventOnCell(gLibrary.ContentTree.view, 0, 0, { button: 1 }); + + let tabs = await promiseLoaded; + + Assert.ok(true, "Expected tabs were loaded"); + + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } + + await test.cleanup(); +} + +add_task(async function test_all() { + for (let test of gTests) { + await runTest(test); + } +}); + +function mouseEventOnCell(aTree, aRowIndex, aColumnIndex, aEventDetails) { + var selection = aTree.view.selection; + selection.select(aRowIndex); + aTree.ensureRowIsVisible(aRowIndex); + var column = aTree.columns[aColumnIndex]; + + // get cell coordinates + var rect = aTree.getCoordsForCellItem(aRowIndex, column, "text"); + + EventUtils.synthesizeMouse( + aTree.body, + rect.x, + rect.y, + aEventDetails, + gLibrary + ); +} diff --git a/browser/components/places/tests/browser/browser_library_new_bookmark.js b/browser/components/places/tests/browser/browser_library_new_bookmark.js new file mode 100644 index 0000000000..dff7accc44 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_new_bookmark.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * Test that the a new bookmark is correctly selected after being created via + * the bookmark dialog. + */ +"use strict"; + +let bookmarks = [ + { + url: "https://example1.com", + title: "bm1", + }, + { + url: "https://example2.com", + title: "bm2", + }, + { + url: "https://example3.com", + title: "bm3", + }, +]; + +add_task(async function test_open_bookmark_from_library() { + let bm = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: bookmarks, + }); + + let library = await promiseLibrary("UnfiledBookmarks"); + + registerCleanupFunction(async function () { + await promiseLibraryClosed(library); + await PlacesUtils.bookmarks.eraseEverything(); + }); + + let bmLibrary = library.ContentTree.view.view.nodeForTreeIndex(1); + Assert.equal( + bmLibrary.title, + bm[1].title, + "EditBookmark: Found bookmark in the right pane" + ); + + library.ContentTree.view.selectNode(bmLibrary); + + let beforeUpdatedPRTime; + await withBookmarksDialog( + false, + async () => { + // Open the context menu. + let placesContext = library.document.getElementById("placesContext"); + let promisePopup = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(library.ContentTree.view, { + button: 2, + type: "contextmenu", + }); + + await promisePopup; + let properties = library.document.getElementById( + "placesContext_new:bookmark" + ); + placesContext.activateItem(properties, {}); + }, + async dialogWin => { + beforeUpdatedPRTime = Date.now() * 1000; + + fillBookmarkTextField( + "editBMPanel_locationField", + "https://example4.com/", + dialogWin, + false + ); + + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + } + ); + let node = library.ContentTree.view.selectedNode; + Assert.ok(node, "EditBookmark: Should have a selectedNode"); + Assert.equal( + node.uri, + "https://example4.com/", + "EditBookmark: Should have selected the newly created bookmark" + ); + Assert.greater( + node.lastModified, + beforeUpdatedPRTime, + "EditBookmark: The lastModified should be greater than the time of before updating" + ); + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/browser/components/places/tests/browser/browser_library_openFlatContainer.js b/browser/components/places/tests/browser/browser_library_openFlatContainer.js new file mode 100644 index 0000000000..1d9cc61f2f --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_openFlatContainer.js @@ -0,0 +1,125 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * Test opening a flat container in the right pane even if its parent in the + * left pane is closed. + */ + +var library; + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + await promiseLibraryClosed(library); + }); +}); + +add_task(async function test_open_built_in_folder() { + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "testBM", + url: "http://example.com/1", + }); + + library = await promiseLibrary("AllBookmarks"); + + library.ContentTree.view.selectItems([PlacesUtils.bookmarks.menuGuid]); + + synthesizeClickOnSelectedTreeCell(library.ContentTree.view, { + clickCount: 2, + }); + + Assert.equal( + library.PlacesOrganizer._places.selectedNode.bookmarkGuid, + PlacesUtils.bookmarks.virtualMenuGuid, + "Should have the bookmarks menu selected in the left pane." + ); + Assert.equal( + library.ContentTree.view.view.nodeForTreeIndex(0).bookmarkGuid, + bm.guid, + "Should have the expected bookmark selected in the right pane" + ); +}); + +add_task(async function test_open_new_folder_in_unfiled() { + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "Folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "Bookmark", + url: "http://example.com", + }, + ], + }, + ], + }); + + library.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + + // Ensure the container is closed. + library.PlacesOrganizer._places.selectedNode.containerOpen = false; + + let folderNode = library.ContentTree.view.view.nodeForTreeIndex(0); + Assert.equal( + folderNode.bookmarkGuid, + bookmarks[0].guid, + "Found the expected folder in the right pane" + ); + // Select the folder node in the right pane. + library.ContentTree.view.selectNode(folderNode); + + synthesizeClickOnSelectedTreeCell(library.ContentTree.view, { + clickCount: 2, + }); + + Assert.equal( + library.ContentTree.view.view.nodeForTreeIndex(0).bookmarkGuid, + bookmarks[1].guid, + "Found the expected bookmark in the right pane" + ); +}); + +add_task(async function test_open_history_query() { + const todayTitle = PlacesUtils.getString("finduri-AgeInDays-is-0"); + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com", + title: "Whittingtons", + }, + ]); + + library.PlacesOrganizer.selectLeftPaneBuiltIn("History"); + + // Ensure the container is closed. + library.PlacesOrganizer._places.selectedNode.containerOpen = false; + + let query = library.ContentTree.view.view.nodeForTreeIndex(0); + Assert.equal(query.title, todayTitle, "Should have the today query"); + + library.ContentTree.view.selectNode(query); + + synthesizeClickOnSelectedTreeCell(library.ContentTree.view, { + clickCount: 2, + }); + + Assert.equal( + library.PlacesOrganizer._places.selectedNode.title, + todayTitle, + "Should have selected the today query in the left-pane." + ); + Assert.equal( + library.ContentTree.view.view.nodeForTreeIndex(0).title, + "Whittingtons", + "Found the expected history item in the right pane" + ); +}); diff --git a/browser/components/places/tests/browser/browser_library_open_all.js b/browser/components/places/tests/browser/browser_library_open_all.js new file mode 100644 index 0000000000..18f0014936 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_open_all.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + const TEST_EXAMPLE_URL = "http://example.com/"; + const TEST_EXAMPLE_PARAMS = "?foo=1|2"; + const TEST_EXAMPLE_TITLE = "Example Domain"; + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_EXAMPLE_URL + TEST_EXAMPLE_PARAMS, + title: TEST_EXAMPLE_TITLE, + }); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_EXAMPLE_URL, + title: TEST_EXAMPLE_TITLE, + }); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_open_all_in_tabs_from_library() { + let gLibrary = await promiseLibrary("AllBookmarks"); + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + gLibrary.ContentTree.view.selectAll(); + let placesContext = gLibrary.document.getElementById("placesContext"); + let promiseContextMenu = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(gLibrary.ContentTree.view, { + button: 2, + type: "contextmenu", + }); + await promiseContextMenu; + let openTabs = gLibrary.document.getElementById( + "placesContext_openBookmarkLinks:tabs" + ); + let promiseWaitForWindow = BrowserTestUtils.waitForNewWindow(); + placesContext.activateItem(openTabs, { shiftKey: true }); + let newWindow = await promiseWaitForWindow; + + Assert.equal( + newWindow.browserDOMWindow.tabCount, + 2, + "Expected number of tabs opened in new window" + ); + + await BrowserTestUtils.closeWindow(newWindow); + await promiseLibraryClosed(gLibrary); +}); diff --git a/browser/components/places/tests/browser/browser_library_open_all_with_separator.js b/browser/components/places/tests/browser/browser_library_open_all_with_separator.js new file mode 100644 index 0000000000..a68158a1ba --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_open_all_with_separator.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + title: "Example One", + url: "https://example.com/1/", + }, + { + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }, + { + title: "Example Two", + url: "https://example.com/2/", + }, + ], + }); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_open_all_without_separator() { + let gLibrary = await promiseLibrary("AllBookmarks"); + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + gLibrary.ContentTree.view.selectAll(); + + let placesContext = gLibrary.document.getElementById("placesContext"); + let promiseContextMenu = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(gLibrary.ContentTree.view, { + button: 2, + type: "contextmenu", + }); + await promiseContextMenu; + + let openTabs = gLibrary.document.getElementById( + "placesContext_openBookmarkLinks:tabs" + ); + let promiseWaitForWindow = BrowserTestUtils.waitForNewWindow(); + placesContext.activateItem(openTabs, { shiftKey: true }); + let newWindow = await promiseWaitForWindow; + + Assert.equal( + newWindow.browserDOMWindow.tabCount, + 2, + "Expected number of tabs opened in new window" + ); + + await BrowserTestUtils.closeWindow(newWindow); + await promiseLibraryClosed(gLibrary); +}); diff --git a/browser/components/places/tests/browser/browser_library_open_bookmark.js b/browser/components/places/tests/browser/browser_library_open_bookmark.js new file mode 100644 index 0000000000..7532d7c1c9 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_open_bookmark.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * Test that the a bookmark can be opened from the Library by mouse double click. + */ +"use strict"; + +const TEST_URL = "about:buildconfig"; + +add_task(async function test_open_bookmark_from_library() { + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_URL, + title: TEST_URL, + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + let gLibrary = await promiseLibrary("UnfiledBookmarks"); + + registerCleanupFunction(async function () { + await promiseLibraryClosed(gLibrary); + await PlacesUtils.bookmarks.eraseEverything(); + await BrowserTestUtils.removeTab(tab); + }); + + let bmLibrary = gLibrary.ContentTree.view.view.nodeForTreeIndex(0); + Assert.equal(bmLibrary.title, bm.title, "Found bookmark in the right pane"); + + gLibrary.ContentTree.view.selectNode(bmLibrary); + synthesizeClickOnSelectedTreeCell(gLibrary.ContentTree.view, { + clickCount: 2, + }); + + await BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + TEST_URL + ); + Assert.ok(true, "Expected tab was loaded"); +}); diff --git a/browser/components/places/tests/browser/browser_library_open_leak.js b/browser/components/places/tests/browser/browser_library_open_leak.js new file mode 100644 index 0000000000..02a0874c76 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_open_leak.js @@ -0,0 +1,22 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Bug 474831 + * https://bugzilla.mozilla.org/show_bug.cgi?id=474831 + * + * Tests for leaks caused by simply opening and closing the Places Library + * window. Opens the Places Library window, waits for it to load, closes it, + * and finishes. + */ + +add_task(async function test_open_and_close() { + let library = await promiseLibrary(); + + Assert.ok(true, "Library has been correctly opened"); + + await promiseLibraryClosed(library); +}); diff --git a/browser/components/places/tests/browser/browser_library_panel_leak.js b/browser/components/places/tests/browser/browser_library_panel_leak.js new file mode 100644 index 0000000000..f8b536cecc --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_panel_leak.js @@ -0,0 +1,71 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Bug 433231 - Places Library leaks the nsGlobalWindow when closed with a + * history entry selected. + * https://bugzilla.mozilla.org/show_bug.cgi?id=433231 + * + * STRs: Open Library, select an history entry in History, close Library. + * ISSUE: We were adding a bookmarks observer when editing a bookmark, when + * selecting an history entry the panel was not un-initialized, and + * since an history entry does not have an itemId, the observer was + * never removed. + */ + +const TEST_URI = "http://www.mozilla.org/"; + +add_task(async function test_no_leak_closing_library_with_history_selected() { + // Add an history entry. + await PlacesTestUtils.addVisits(TEST_URI); + + let organizer = await promiseLibrary(); + + let contentTree = organizer.document.getElementById("placeContent"); + Assert.notEqual( + contentTree, + null, + "Sanity check: placeContent tree should exist" + ); + Assert.notEqual( + organizer.PlacesOrganizer, + null, + "Sanity check: PlacesOrganizer should exist" + ); + Assert.notEqual( + organizer.gEditItemOverlay, + null, + "Sanity check: gEditItemOverlay should exist" + ); + + Assert.ok( + organizer.gEditItemOverlay.initialized, + "gEditItemOverlay is initialized" + ); + Assert.notEqual( + organizer.gEditItemOverlay._paneInfo.itemGuid, + "", + "Editing a bookmark" + ); + + // Select History in the left pane. + organizer.PlacesOrganizer.selectLeftPaneBuiltIn("History"); + // Select the first history entry. + let selection = contentTree.view.selection; + selection.clearSelection(); + selection.rangedSelect(0, 0, true); + // Check the panel is editing the history entry. + Assert.equal( + organizer.gEditItemOverlay._paneInfo.itemGuid, + "", + "Editing an history entry" + ); + // Close Library window. + organizer.close(); + + // Clean up history. + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/places/tests/browser/browser_library_sameNodeDetailsPaneOptimization.js b/browser/components/places/tests/browser/browser_library_sameNodeDetailsPaneOptimization.js new file mode 100644 index 0000000000..d9b800697d --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_sameNodeDetailsPaneOptimization.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const sandbox = sinon.createSandbox(); + +/** + * This checks an optimization in the Library code, that tries to not update + * the details pane if the same node that is being edited is picked again. + * That happens for example if the focus moves to the details pane and back + * to the tree. + */ + +add_task(async function () { + let bm1 = await PlacesUtils.bookmarks.insert({ + url: "https://bookmark1.mozilla.org/", + title: "Bookmark 1", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + + let library = await promiseLibrary("UnfiledBookmarks"); + registerCleanupFunction(async () => { + sandbox.restore(); + await promiseLibraryClosed(library); + await PlacesUtils.bookmarks.remove(bm1); + }); + + let nameField = library.document.getElementById("editBMPanel_namePicker"); + let tree = library.ContentTree.view; + tree.selectItems([bm1.guid]); + Assert.equal(tree.selectedNode.title, "Bookmark 1"); + await synthesizeClickOnSelectedTreeCell(tree); + Assert.equal(nameField.value, "Bookmark 1"); + + let updateSpy = sandbox.spy(library.PlacesOrganizer, "updateDetailsPane"); + let uninitSpy = sandbox.spy(library.gEditItemOverlay, "uninitPanel"); + nameField.focus(); + await synthesizeClickOnSelectedTreeCell(tree); + Assert.ok(updateSpy.calledOnce, "should try to update the details pane"); + Assert.ok(uninitSpy.notCalled, "should skip the update cause same node"); +}); diff --git a/browser/components/places/tests/browser/browser_library_search.js b/browser/components/places/tests/browser/browser_library_search.js new file mode 100644 index 0000000000..898f664269 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_search.js @@ -0,0 +1,206 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Bug 451151 + * https://bugzilla.mozilla.org/show_bug.cgi?id=451151 + * + * Summary: + * Tests frontend Places Library searching -- search, search reset, search scope + * consistency. + * + * Details: + * Each test below + * 1. selects a folder in the left pane and ensures that the content tree is + * appropriately updated, + * 2. performs a search and ensures that the content tree is correct for the + * folder and search and that the search UI is visible and appropriate to + * folder, + * 5. resets the search and ensures that the content tree is correct and that + * the search UI is hidden, and + * 6. if folder scope was clicked, searches again and ensures folder scope + * remains selected. + */ + +const TEST_URL = "http://dummy.mozilla.org/"; +const TEST_DOWNLOAD_URL = "http://dummy.mozilla.org/dummy.pdf"; +const TEST_PARENT_FOLDER = "testParentFolder"; +const TEST_SIF_URL = "http://testsif.example.com/"; +const TEST_SIF_TITLE = "TestSIF"; + +var gLibrary; + +/** + * Performs a search for a given folder and search string and ensures that the + * URI of the right pane's content tree is as expected for the folder and search + * string. Also ensures that the search scope button is as expected after the + * search. + * + * @param {string} aFolderGuid + * the item guid of a node in the left pane's tree + * @param {string} aSearchStr + * the search text; may be empty to reset the search + */ +async function search(aFolderGuid, aSearchStr) { + let doc = gLibrary.document; + let folderTree = doc.getElementById("placesList"); + let contentTree = doc.getElementById("placeContent"); + + // First, ensure that selecting the folder in the left pane updates the + // content tree properly. + if (aFolderGuid) { + folderTree.selectItems([aFolderGuid]); + Assert.notEqual( + folderTree.selectedNode, + null, + "Sanity check: left pane tree should have selection after selecting!" + ); + + // The downloads folder never quite matches the url of the contentTree, + // probably due to the way downloads are loaded. + if (aFolderGuid !== PlacesUtils.virtualDownloadsGuid) { + Assert.equal( + folderTree.selectedNode.uri, + contentTree.place, + "Content tree's folder should be what was selected in the left pane" + ); + } + } + + // Second, ensure that searching updates the content tree and search UI + // properly. + let searchBox = doc.getElementById("searchFilter"); + searchBox.value = aSearchStr; + gLibrary.PlacesSearchBox.search(searchBox.value); + let query = {}; + PlacesUtils.history.queryStringToQuery( + contentTree.result.root.uri, + query, + {} + ); + if (aSearchStr) { + Assert.equal( + query.value.searchTerms, + aSearchStr, + "Content tree's searchTerms should be text in search box" + ); + } else { + Assert.equal( + query.value.hasSearchTerms, + false, + "Content tree's searchTerms should not exist after search reset" + ); + } +} + +async function showInFolder(aFolderGuid, aSearchStr, aParentFolderGuid) { + let doc = gLibrary.document; + let folderTree = doc.getElementById("placesList"); + let contentTree = doc.getElementById("placeContent"); + + let searchBox = doc.getElementById("searchFilter"); + searchBox.value = aSearchStr; + gLibrary.PlacesSearchBox.search(searchBox.value); + let theNode = contentTree.view._getNodeForRow(0); + let bookmarkGuid = theNode.bookmarkGuid; + + Assert.equal(theNode.uri, TEST_SIF_URL, "Found expected bookmark"); + + contentTree.selectNode(theNode); + info("Executing showInFolder"); + info("Waiting for showInFolder to select folder in tree"); + let folderSelected = BrowserTestUtils.waitForEvent(folderTree, "select"); + contentTree.controller.doCommand("placesCmd_showInFolder"); + await folderSelected; + + let treeNode = folderTree.selectedNode; + let contentNode = contentTree.selectedNode; + Assert.equal( + treeNode.bookmarkGuid, + aParentFolderGuid, + "Containing folder node selected on left tree pane" + ); + Assert.equal( + contentNode.bookmarkGuid, + bookmarkGuid, + "The searched bookmark guid matches selected node in content pane" + ); + Assert.equal( + contentNode.uri, + TEST_SIF_URL, + "The searched bookmark URL matches selected node in content pane" + ); +} + +add_task(async function test() { + // Add visits, a bookmark and a tag. + await PlacesTestUtils.addVisits([ + { + uri: Services.io.newURI(TEST_URL), + visitDate: Date.now() * 1000, + transition: PlacesUtils.history.TRANSITION_TYPED, + }, + { + uri: Services.io.newURI(TEST_DOWNLOAD_URL), + visitDate: Date.now() * 1000, + transition: PlacesUtils.history.TRANSITION_DOWNLOAD, + }, + ]); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "dummy", + url: TEST_URL, + }); + + PlacesUtils.tagging.tagURI(Services.io.newURI(TEST_URL), ["dummyTag"]); + + gLibrary = await promiseLibrary(); + + const rootsToTest = [ + PlacesUtils.virtualAllBookmarksGuid, + PlacesUtils.virtualHistoryGuid, + PlacesUtils.virtualDownloadsGuid, + ]; + + for (let root of rootsToTest) { + await search(root, "dummy"); + } + + await promiseLibraryClosed(gLibrary); + + // Cleanup before testing Show in Folder. + PlacesUtils.tagging.untagURI(Services.io.newURI(TEST_URL), ["dummyTag"]); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + + // Now test Show in Folder + gLibrary = await promiseLibrary(); + info("Test Show in Folder"); + let parentFolder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: TEST_PARENT_FOLDER, + }); + + await PlacesUtils.bookmarks.insert({ + parentGuid: parentFolder.guid, + title: TEST_SIF_TITLE, + url: TEST_SIF_URL, + }); + + await showInFolder( + PlacesUtils.virtualAllBookmarksGuid, + TEST_SIF_TITLE, + parentFolder.guid + ); + + // Cleanup + await promiseLibraryClosed(gLibrary); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/places/tests/browser/browser_library_tags_visibility.js b/browser/components/places/tests/browser/browser_library_tags_visibility.js new file mode 100644 index 0000000000..d0427ef591 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_tags_visibility.js @@ -0,0 +1,88 @@ +/* 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/. */ + +/** + * Test whether tags are shown in library window. + */ + +registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +const TEST_URI = Services.io.newURI("https://example.com/"); +const TEST_TAGS = ["tagB", "tagA"]; +const TAGS_TEXT = TEST_TAGS.sort().join(", "); + +add_task(async function base() { + const { guid } = await PlacesUtils.bookmarks.insert({ + url: TEST_URI, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + await PlacesTestUtils.addVisits(TEST_URI); + PlacesUtils.tagging.tagURI(TEST_URI, TEST_TAGS); + + const library = await promiseLibrary(); + registerCleanupFunction(() => { + library.close(); + }); + + const { + document: libraryDocument, + PlacesOrganizer: placesOrganizer, + ContentTree: contentTree, + } = library; + + info("Test in Bookmarks"); + placesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + contentTree.view.selectItems([guid]); + await assertTagsVisibility(libraryDocument, true); + + info("Test in Tags"); + placesOrganizer.selectLeftPaneBuiltIn("Tags"); + const tagsContainer = PlacesUtils.asContainer( + placesOrganizer._places.selectedNode + ); + tagsContainer.containerOpen = true; + const targetNode = tagsContainer.getChild(0); + placesOrganizer._places.selectNode(targetNode); + contentTree.view.selectItems([guid]); + await assertTagsVisibility(libraryDocument, true); + + info("Test in History"); + placesOrganizer.selectLeftPaneBuiltIn("History"); + const historyNode = PlacesUtils.asContainer( + placesOrganizer._places.selectedNode + ); + historyNode.containerOpen = true; + const todayNode = historyNode.getChild(0); + placesOrganizer._places.selectNode(todayNode); + contentTree.view.selectItems([guid]); + await assertTagsVisibility(libraryDocument, false); +}); + +async function assertTagsVisibility(libraryDocument, expectedVisible) { + const locationInput = libraryDocument.getElementById( + "editBMPanel_locationField" + ); + await BrowserTestUtils.waitForCondition( + () => locationInput.value === TEST_URI.spec, + "Wait until the panel ready" + ); + + // Check the editor area. + const tagInput = libraryDocument.getElementById("editBMPanel_tagsField"); + Assert.equal(BrowserTestUtils.isVisible(tagInput), expectedVisible); + if (expectedVisible) { + Assert.equal(tagInput.value, TAGS_TEXT); + } + + // Check the cell. + const tree = libraryDocument.getElementById("placeContent"); + const expectedCellText = expectedVisible ? TAGS_TEXT : null; + Assert.equal( + tree.view.getCellText(0, tree.columns.placesContentTags), + expectedCellText + ); +} diff --git a/browser/components/places/tests/browser/browser_library_telemetry.js b/browser/components/places/tests/browser/browser_library_telemetry.js new file mode 100644 index 0000000000..00ca4635d8 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_telemetry.js @@ -0,0 +1,413 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +// Visited pages listed by descending visit date. +const pages = [ + "https://library.mozilla.org/a", + "https://library.mozilla.org/b", + "https://library.mozilla.org/c", + "https://www.mozilla.org/d", +]; + +// The prompt returns 1 for cancelled and 0 for accepted. +let gResponse = 1; +(function replacePromptService() { + let originalPromptService = Services.prompt; + Services.prompt = { + QueryInterface: ChromeUtils.generateQI([Ci.nsIPromptService]), + confirmEx: () => gResponse, + }; + registerCleanupFunction(() => { + Services.prompt = originalPromptService; + }); +})(); + +async function searchHistory(gLibrary, searchTerm) { + let doc = gLibrary.document; + let contentTree = doc.getElementById("placeContent"); + + let searchBox = doc.getElementById("searchFilter"); + searchBox.value = searchTerm; + gLibrary.PlacesSearchBox.search(searchBox.value); + let query = {}; + PlacesUtils.history.queryStringToQuery( + contentTree.result.root.uri, + query, + {} + ); + Assert.equal( + query.value.searchTerms, + searchTerm, + "Content tree's searchTerms should be text in search box" + ); +} + +function searchBookmarks(gLibrary, searchTerm) { + let searchBox = gLibrary.document.getElementById("searchFilter"); + searchBox.value = searchTerm; + gLibrary.PlacesSearchBox.search(searchBox.value); +} + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + // Add some visited pages to history + let time = Date.now(); + let places = []; + for (let i = 0; i < pages.length; i++) { + places.push({ + uri: NetUtil.newURI(pages[i]), + visitDate: (time - i) * 1000, + transition: PlacesUtils.history.TRANSITION_TYPED, + }); + } + await PlacesTestUtils.addVisits(places); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "Mozilla", + url: "https://www.mozilla.org/", + }, + { + title: "Example", + url: "https://sidebar.mozilla.org/", + }, + ], + }); + await registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_library_history_telemetry() { + Services.telemetry.clearScalars(); + let cumulativeSearchesHistogram = Services.telemetry.getHistogramById( + "PLACES_LIBRARY_CUMULATIVE_HISTORY_SEARCHES" + ); + + let gLibrary = await promiseLibrary("History"); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.opened", + "history", + 1 + ); + + let currentSelectedLeftPaneNode = + gLibrary.PlacesOrganizer._places.selectedNode; + if ( + currentSelectedLeftPaneNode.title == "History" && + currentSelectedLeftPaneNode.hasChildren && + currentSelectedLeftPaneNode.getChild(0).title == "Today" + ) { + // Select "Today" node under History if not already selected + gLibrary.PlacesOrganizer._places.selectNode( + currentSelectedLeftPaneNode.getChild(0) + ); + } + + Assert.equal( + gLibrary.PlacesOrganizer._places.selectedNode.title, + "Today", + "The Today history sidebar button is selected" + ); + + await searchHistory(gLibrary, "mozilla"); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.search", + "history", + 1 + ); + + let firstHistoryNode = gLibrary.ContentTree.view.view.nodeForTreeIndex(0); + Assert.equal( + firstHistoryNode.uri, + pages[0], + "Found history item in the right pane" + ); + + // Double click first History link to open it + gLibrary.ContentTree.view.selectNode(firstHistoryNode); + synthesizeClickOnSelectedTreeCell(gLibrary.ContentTree.view, { + clickCount: 2, + }); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link", + "history", + 1 + ); + + TelemetryTestUtils.assertHistogram(cumulativeSearchesHistogram, 1, 1); + info("Cumulative search telemetry looks right"); + + cumulativeSearchesHistogram.clear(); + + // Close and reopen Libary window + await promiseLibraryClosed(gLibrary); + gLibrary = await promiseLibrary("History"); + + Assert.equal( + gLibrary.PlacesOrganizer._places.selectedNode.title, + "Today", + "The Today history sidebar button is selected" + ); + + // if a user tries to open all history entries and they cancel opening + // those entries in new tabs (due to max tab limit warning), + // no telemetry should be recorded + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.maxOpenBeforeWarn", 4]], + }); + + // Reject opening all tabs when prompted + gResponse = 1; + + // Open all history entries + synthesizeClickOnSelectedTreeCell(gLibrary.PlacesOrganizer._places, { + button: 1, + }); + + TelemetryTestUtils.assertScalarUnset( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link" + ); + + // Make 4 searches before opening History links + await searchHistory(gLibrary, "library a", 1); + info("First search was performed."); + await searchHistory(gLibrary, "library b", 2); + info("Second search was performed."); + await searchHistory(gLibrary, "library c", 3); + info("Third search was performed."); + await searchHistory(gLibrary, "mozilla", 4); + info("Fourth search was performed."); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.search", + "history", + 4 + ); + + // Accept opening all tabs when prompted + gResponse = 0; + + // Open all history entries + synthesizeClickOnSelectedTreeCell(gLibrary.PlacesOrganizer._places, { + button: 1, + }); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link", + "history", + 4 + ); + + TelemetryTestUtils.assertHistogram(cumulativeSearchesHistogram, 4, 1); + info("Cumulative search telemetry looks right"); + + synthesizeClickOnSelectedTreeCell(gLibrary.ContentTree.view, { + button: 2, + type: "contextmenu", + }); + + TelemetryTestUtils.assertScalarUnset( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link" + ); + + let openOption = document.getElementById("placesContext_open"); + openOption.click(); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link", + "history", + 1 + ); + + synthesizeClickOnSelectedTreeCell(gLibrary.ContentTree.view, { + button: 2, + type: "contextmenu", + }); + + TelemetryTestUtils.assertScalarUnset( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link" + ); + + let openNewTabOption = document.getElementById("placesContext_open:newtab"); + openNewTabOption.click(); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link", + "history", + 1 + ); + + let newWinOpened = BrowserTestUtils.waitForNewWindow(); + + synthesizeClickOnSelectedTreeCell(gLibrary.ContentTree.view, { + button: 2, + type: "contextmenu", + }); + + TelemetryTestUtils.assertScalarUnset( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link" + ); + + let openNewWindowOption = document.getElementById( + "placesContext_open:newwindow" + ); + openNewWindowOption.click(); + + let newWin = await newWinOpened; + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link", + "history", + 1 + ); + + await BrowserTestUtils.closeWindow(newWin); + + let newPrivateWinOpened = BrowserTestUtils.waitForNewWindow(); + + synthesizeClickOnSelectedTreeCell(gLibrary.ContentTree.view, { + button: 2, + type: "contextmenu", + }); + + TelemetryTestUtils.assertScalarUnset( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link" + ); + + let openNewPrivateWindowOption = document.getElementById( + "placesContext_open:newprivatewindow" + ); + openNewPrivateWindowOption.click(); + + let newPrivateWin = await newPrivateWinOpened; + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link", + "history", + 1 + ); + + await BrowserTestUtils.closeWindow(newPrivateWin); + + cumulativeSearchesHistogram.clear(); + await promiseLibraryClosed(gLibrary); + await SpecialPowers.popPrefEnv(); + + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } +}); + +add_task(async function test_library_bookmarks_telemetry() { + Services.telemetry.clearScalars(); + let cumulativeSearchesHistogram = TelemetryTestUtils.getAndClearHistogram( + "PLACES_LIBRARY_CUMULATIVE_BOOKMARK_SEARCHES" + ); + + let library = await promiseLibrary("AllBookmarks"); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.opened", + "bookmarks", + 1 + ); + + searchBookmarks(library, "mozilla"); + + // reset + searchBookmarks(library, ""); + + // search again + searchBookmarks(library, "moz"); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.search", + "bookmarks", + 2 + ); + + let firstNode = library.ContentTree.view.view.nodeForTreeIndex(0); + library.ContentTree.view.selectNode(firstNode); + + synthesizeClickOnSelectedTreeCell(library.ContentTree.view, { + clickCount: 2, + }); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link", + "bookmarks", + 1 + ); + + TelemetryTestUtils.assertHistogram(cumulativeSearchesHistogram, 2, 1); + + cumulativeSearchesHistogram = TelemetryTestUtils.getAndClearHistogram( + "PLACES_LIBRARY_CUMULATIVE_BOOKMARK_SEARCHES" + ); + + // do another search to make sure everything has been cleared + searchBookmarks(library, "moz"); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.search", + "bookmarks", + 1 + ); + + firstNode = library.ContentTree.view.view.nodeForTreeIndex(0); + library.ContentTree.view.selectNode(firstNode); + + synthesizeClickOnSelectedTreeCell(library.ContentTree.view, { + clickCount: 2, + }); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link", + "bookmarks", + 1 + ); + + TelemetryTestUtils.assertHistogram(cumulativeSearchesHistogram, 1, 1); + + cumulativeSearchesHistogram.clear(); + await promiseLibraryClosed(library); + + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } +}); diff --git a/browser/components/places/tests/browser/browser_library_tree_leak.js b/browser/components/places/tests/browser/browser_library_tree_leak.js new file mode 100644 index 0000000000..9e552f5c31 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_tree_leak.js @@ -0,0 +1,24 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +add_task(async function bookmark_leak_window() { + // A library window has two trees after selecting a bookmark item: + // A left tree (#placesList) and a right tree (#placeContent). + // Upon closing the window, both trees are destructed, in an unspecified + // order. In bug 1520047, a memory leak was observed when the left tree + // was destroyed last. + + let library = await promiseLibrary("BookmarksToolbar"); + let tree = library.document.getElementById("placesList"); + tree.selectItems(["toolbar_____"]); + + await synthesizeClickOnSelectedTreeCell(tree); + await promiseLibraryClosed(library); + + Assert.ok( + true, + "Closing a window after selecting a node in the tree should not cause a leak" + ); +}); diff --git a/browser/components/places/tests/browser/browser_library_views_liveupdate.js b/browser/components/places/tests/browser/browser_library_views_liveupdate.js new file mode 100644 index 0000000000..ca79768f8f --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_views_liveupdate.js @@ -0,0 +1,217 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests Library Left pane view for liveupdate. + */ + +let gLibrary = null; + +add_setup(async function () { + gLibrary = await promiseLibrary(); + await PlacesUtils.bookmarks.eraseEverything(); + + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + await promiseLibraryClosed(gLibrary); + }); +}); + +async function testInFolder(folderGuid, prefix) { + let addedBookmarks = []; + + let item = await insertAndCheckItem( + { + parentGuid: folderGuid, + title: `${prefix}1`, + url: `http://${prefix}1.mozilla.org/`, + }, + 0 + ); + item.title = `${prefix}1_edited`; + await updateAndCheckItem(item, 0); + addedBookmarks.push(item); + + item = await insertAndCheckItem( + { + parentGuid: folderGuid, + title: `${prefix}2`, + url: "place:", + }, + 0 + ); + + item.title = `${prefix}2_edited`; + await updateAndCheckItem(item, 0); + addedBookmarks.push(item); + + item = await insertAndCheckItem( + { + parentGuid: folderGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }, + 2 + ); + addedBookmarks.push(item); + + item = await insertAndCheckItem( + { + parentGuid: folderGuid, + title: `${prefix}f`, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + 1 + ); + + item.title = `${prefix}f_edited`; + await updateAndCheckItem(item, 1); + + item.index = 0; + await updateAndCheckItem(item, 0); + addedBookmarks.push(item); + + let folderGuid1 = item.guid; + + item = await insertAndCheckItem( + { + parentGuid: folderGuid1, + title: `${prefix}f1`, + url: `http://${prefix}f1.mozilla.org/`, + }, + 0 + ); + addedBookmarks.push(item); + + item = await insertAndCheckItem( + { + parentGuid: folderGuid, + title: `${prefix}f12`, + url: `http://${prefix}f12.mozilla.org/`, + }, + 4 + ); + addedBookmarks.push(item); + + // Move to a different folder and index. + item.parentGuid = folderGuid1; + item.index = 0; + await updateAndCheckItem(item, 0); + + return addedBookmarks; +} + +add_task(async function test() { + let addedBookmarks = []; + + info("*** Acting on menu bookmarks"); + addedBookmarks = addedBookmarks.concat( + await testInFolder(PlacesUtils.bookmarks.menuGuid, "bm") + ); + + info("*** Acting on toolbar bookmarks"); + addedBookmarks = addedBookmarks.concat( + await testInFolder(PlacesUtils.bookmarks.toolbarGuid, "tb") + ); + + info("*** Acting on unsorted bookmarks"); + addedBookmarks = addedBookmarks.concat( + await testInFolder(PlacesUtils.bookmarks.unfiledGuid, "ub") + ); + + // Remove bookmarks in reverse order, so that the effects are correct. + for (let i = addedBookmarks.length - 1; i >= 0; i--) { + await removeAndCheckItem(addedBookmarks[i]); + } +}); + +function selectItem(item, parentTreeIndex) { + let useLeftPane = + item.type == PlacesUtils.bookmarks.TYPE_FOLDER || // is Folder + (item.type == PlacesUtils.bookmarks.TYPE_BOOKMARK && + item.url.protocol == "place:"); // is Query + let tree = useLeftPane + ? gLibrary.PlacesOrganizer._places + : gLibrary.ContentTree.view; + tree.selectItems([item.guid]); + let treeIndex = tree.view.treeIndexForNode(tree.selectedNode); + let title = tree.view.getCellText(treeIndex, tree.columns.getColumnAt(0)); + if (useLeftPane) { + // Make the treeIndex relative to the parent, otherwise getting the right + // index value is tricky due to separators and URIs being hidden in the left + // pane. + treeIndex -= parentTreeIndex + 1; + } + return { + node: tree.selectedNode, + title, + parentRelativeTreeIndex: treeIndex, + }; +} + +function itemExists(item) { + let useLeftPane = + item.type == PlacesUtils.bookmarks.TYPE_FOLDER || // is Folder + (item.type == PlacesUtils.bookmarks.TYPE_BOOKMARK && + item.url.protocol == "place:"); // is Query + let tree = useLeftPane + ? gLibrary.PlacesOrganizer._places + : gLibrary.ContentTree.view; + tree.selectItems([item.guid], true); + return tree.selectedNode?.bookmarkGuid == item.guid; +} + +async function insertAndCheckItem(insertItem, expectedParentRelativeIndex) { + // Ensure the parent is selected before the change, this covers live updating + // better than selecting the parent later, that would just refresh all its + // children. + Assert.ok(insertItem.parentGuid, "Must have a parentGuid"); + gLibrary.PlacesOrganizer._places.selectItems([insertItem.parentGuid], true); + let tree = gLibrary.PlacesOrganizer._places; + let parentTreeIndex = tree.view.treeIndexForNode(tree.selectedNode); + + let item = await PlacesUtils.bookmarks.insert(insertItem); + + let { node, title, parentRelativeTreeIndex } = selectItem( + item, + parentTreeIndex + ); + Assert.equal(item.guid, node.bookmarkGuid, "Should find the updated node"); + Assert.equal(title, item.title, "Should have the correct title"); + Assert.equal( + parentRelativeTreeIndex, + expectedParentRelativeIndex, + "Should have the expected index" + ); + return item; +} + +async function updateAndCheckItem(updateItem, expectedParentRelativeTreeIndex) { + // Ensure the parent is selected before the change, this covers live updating + // better than selecting the parent later, that would just refresh all its + // children. + Assert.ok(updateItem.parentGuid, "Must have a parentGuid"); + gLibrary.PlacesOrganizer._places.selectItems([updateItem.parentGuid], true); + let tree = gLibrary.PlacesOrganizer._places; + let parentTreeIndex = tree.view.treeIndexForNode(tree.selectedNode); + + let item = await PlacesUtils.bookmarks.update(updateItem); + + let { node, title, parentRelativeTreeIndex } = selectItem( + item, + parentTreeIndex + ); + Assert.equal(item.guid, node.bookmarkGuid, "Should find the updated node"); + Assert.equal(title, item.title, "Should have the correct title"); + Assert.equal( + parentRelativeTreeIndex, + expectedParentRelativeTreeIndex, + "Should have the expected index" + ); + return item; +} + +async function removeAndCheckItem(itemData) { + await PlacesUtils.bookmarks.remove(itemData); + Assert.ok(!itemExists(itemData), "Should not find the updated node"); +} diff --git a/browser/components/places/tests/browser/browser_library_warnOnOpen.js b/browser/components/places/tests/browser/browser_library_warnOnOpen.js new file mode 100644 index 0000000000..4289d414df --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_warnOnOpen.js @@ -0,0 +1,159 @@ +/* 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/. */ + +/* + * Bug 1435562 - Test that browser.tabs.warnOnOpen is respected when + * opening multiple items from the Library. */ + +"use strict"; + +var gLibrary = null; + +add_setup(async function () { + // Temporarily disable history, so we won't record pages navigation. + await SpecialPowers.pushPrefEnv({ set: [["places.history.enabled", false]] }); + + // Open Library window. + gLibrary = await promiseLibrary(); + + registerCleanupFunction(async () => { + // We must close "Other Bookmarks" ready for other tests. + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + gLibrary.PlacesOrganizer._places.selectedNode.containerOpen = false; + + await PlacesUtils.bookmarks.eraseEverything(); + + // Close Library window. + await promiseLibraryClosed(gLibrary); + }); +}); + +add_task(async function test_warnOnOpenFolder() { + // Generate a list of links larger than browser.tabs.maxOpenBeforeWarn + const MAX_LINKS = 16; + let children = []; + for (let i = 0; i < MAX_LINKS; i++) { + children.push({ + title: `Folder Target ${i}`, + url: `http://example${i}.com`, + }); + } + + // Create a new folder containing our links. + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "bigFolder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children, + }, + ], + }); + info("Pushed test folder into the bookmarks tree"); + + // Select unsorted bookmarks root in the left pane. + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + info("Got selection in the Library left pane"); + + // Get our bookmark in the right pane. + gLibrary.ContentTree.view.view.nodeForTreeIndex(0); + info("Got bigFolder in the right pane"); + + gLibrary.PlacesOrganizer._places.selectedNode.containerOpen = true; + + // Middle-click on folder (opens all links in folder) and then cancel opening in the dialog + let promiseLoaded = BrowserTestUtils.promiseAlertDialog("cancel"); + let bookmarkedNode = + gLibrary.PlacesOrganizer._places.selectedNode.getChild(0); + mouseEventOnCell( + gLibrary.PlacesOrganizer._places, + gLibrary.PlacesOrganizer._places.view.treeIndexForNode(bookmarkedNode), + 0, + { button: 1 } + ); + + await promiseLoaded; + + Assert.ok( + true, + "Expected dialog was shown when attempting to open folder with lots of links" + ); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_warnOnOpenLinks() { + // Generate a list of links larger than browser.tabs.maxOpenBeforeWarn + const MAX_LINKS = 16; + let children = []; + for (let i = 0; i < MAX_LINKS; i++) { + children.push({ + title: `Highlighted Target ${i}`, + url: `http://example${i}.com`, + }); + } + + // Insert the links into the tree + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children, + }); + info("Pushed test folder into the bookmarks tree"); + + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + info("Got selection in the Library left pane"); + + // Select all the links + gLibrary.ContentTree.view.selectAll(); + + let placesContext = gLibrary.document.getElementById("placesContext"); + let promiseContextMenu = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + + // Open up the context menu and select "Open All In Tabs" (the first item in the list) + synthesizeClickOnSelectedTreeCell(gLibrary.ContentTree.view, { + button: 2, + type: "contextmenu", + }); + + await promiseContextMenu; + info("Context menu opened as expected"); + + let openTabs = gLibrary.document.getElementById( + "placesContext_openBookmarkLinks:tabs" + ); + let promiseLoaded = BrowserTestUtils.promiseAlertDialog("cancel"); + + placesContext.activateItem(openTabs, {}); + + await promiseLoaded; + + Assert.ok( + true, + "Expected dialog was shown when attempting to open lots of selected links" + ); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +function mouseEventOnCell(aTree, aRowIndex, aColumnIndex, aEventDetails) { + var selection = aTree.view.selection; + selection.select(aRowIndex); + aTree.ensureRowIsVisible(aRowIndex); + var column = aTree.columns[aColumnIndex]; + + // get cell coordinates + var rect = aTree.getCoordsForCellItem(aRowIndex, column, "text"); + + EventUtils.synthesizeMouse( + aTree.body, + rect.x, + rect.y, + aEventDetails, + gLibrary + ); +} diff --git a/browser/components/places/tests/browser/browser_markPageAsFollowedLink.js b/browser/components/places/tests/browser/browser_markPageAsFollowedLink.js new file mode 100644 index 0000000000..2daf822db3 --- /dev/null +++ b/browser/components/places/tests/browser/browser_markPageAsFollowedLink.js @@ -0,0 +1,75 @@ +/** + * Tests that visits across frames are correctly represented in the database. + */ + +const BASE_URL = + "http://mochi.test:8888/browser/browser/components/places/tests/browser"; +const PAGE_URL = BASE_URL + "/framedPage.html"; +const LEFT_URL = BASE_URL + "/frameLeft.html"; +const RIGHT_URL = BASE_URL + "/frameRight.html"; + +add_task(async function test() { + // We must wait for both frames to be loaded and the visits to be registered. + let deferredLeftFrameVisit = Promise.withResolvers(); + let deferredRightFrameVisit = Promise.withResolvers(); + + Services.obs.addObserver(function observe(subject) { + (async function () { + let url = subject.QueryInterface(Ci.nsIURI).spec; + if (url == LEFT_URL) { + is( + await getTransitionForUrl(url), + null, + "Embed visits should not get a database entry." + ); + deferredLeftFrameVisit.resolve(); + } else if (url == RIGHT_URL) { + is( + await getTransitionForUrl(url), + PlacesUtils.history.TRANSITION_FRAMED_LINK, + "User activated visits should get a FRAMED_LINK transition." + ); + Services.obs.removeObserver(observe, "uri-visit-saved"); + deferredRightFrameVisit.resolve(); + } + })(); + }, "uri-visit-saved"); + + // Open a tab and wait for all the subframes to load. + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE_URL); + + // Wait for the left frame visit to be registered. + info("Waiting left frame visit"); + await deferredLeftFrameVisit.promise; + + // Click on the link in the left frame to cause a page load in the + // right frame. + info("Clicking link"); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + content.frames[0].document.getElementById("clickme").click(); + }); + + // Wait for the right frame visit to be registered. + info("Waiting right frame visit"); + await deferredRightFrameVisit.promise; + + BrowserTestUtils.removeTab(tab); +}); + +function getTransitionForUrl(url) { + return PlacesUtils.withConnectionWrapper( + "browser_markPageAsFollowedLink", + async db => { + let rows = await db.execute( + ` + SELECT visit_type + FROM moz_historyvisits + JOIN moz_places h ON place_id = h.id + WHERE url_hash = hash(:url) AND url = :url + `, + { url } + ); + return rows.length ? rows[0].getResultByName("visit_type") : null; + } + ); +} diff --git a/browser/components/places/tests/browser/browser_panelview_bookmarks_delete.js b/browser/components/places/tests/browser/browser_panelview_bookmarks_delete.js new file mode 100644 index 0000000000..3a5527a689 --- /dev/null +++ b/browser/components/places/tests/browser/browser_panelview_bookmarks_delete.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { CustomizableUITestUtils } = ChromeUtils.importESModule( + "resource://testing-common/CustomizableUITestUtils.sys.mjs" +); +let gCUITestUtils = new CustomizableUITestUtils(window); + +const TEST_URL = "https://www.example.com/"; + +/** + * Checks that the Bookmarks subview is updated after deleting an item. + */ +add_task(async function test_panelview_bookmarks_delete() { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_URL, + title: TEST_URL, + }); + + await gCUITestUtils.openMainMenu(); + + document.getElementById("appMenu-bookmarks-button").click(); + let bookmarksView = document.getElementById("PanelUI-bookmarks"); + let promise = BrowserTestUtils.waitForEvent(bookmarksView, "ViewShown"); + await promise; + + let list = document.getElementById("panelMenu_bookmarksMenu"); + let listItem = [...list.children].find(node => node.label == TEST_URL); + + let placesContext = document.getElementById("placesContext"); + promise = BrowserTestUtils.waitForEvent(placesContext, "popupshown"); + EventUtils.synthesizeMouseAtCenter(listItem, { + button: 2, + type: "contextmenu", + }); + await promise; + + promise = new Promise(resolve => { + let observer = new MutationObserver(mutations => { + if (listItem.parentNode == null) { + Assert.ok(true, "The bookmarks list item was removed."); + observer.disconnect(); + resolve(); + } + }); + observer.observe(list, { childList: true }); + }); + let placesContextDelete = document.getElementById( + "placesContext_deleteBookmark" + ); + placesContext.activateItem(placesContextDelete, {}); + await promise; + + await gCUITestUtils.hideMainMenu(); +}); diff --git a/browser/components/places/tests/browser/browser_paste_bookmarks.js b/browser/components/places/tests/browser/browser_paste_bookmarks.js new file mode 100644 index 0000000000..8a23db4cdc --- /dev/null +++ b/browser/components/places/tests/browser/browser_paste_bookmarks.js @@ -0,0 +1,439 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URL = "http://example.com/"; +const TEST_URL1 = "https://example.com/otherbrowser/"; + +var PlacesOrganizer; +var ContentTree; + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + let organizer = await promiseLibrary(); + + registerCleanupFunction(async function () { + await promiseLibraryClosed(organizer); + await PlacesUtils.bookmarks.eraseEverything(); + }); + + PlacesOrganizer = organizer.PlacesOrganizer; + ContentTree = organizer.ContentTree; + + // Show date added column. + await showLibraryColumn(organizer, "placesContentDateAdded"); +}); + +add_task(async function paste() { + info("Selecting BookmarksToolbar in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + + let dateAdded = new Date(); + dateAdded.setHours(10); + dateAdded.setMinutes(10); + dateAdded.setSeconds(0); + + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_URL, + title: "0", + dateAdded, + }); + + ContentTree.view.selectItems([bookmark.guid]); + + await promiseClipboard(() => { + info("Cutting selection"); + ContentTree.view.controller.cut(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + info("Selecting UnfiledBookmarks in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + + info("Pasting clipboard"); + await ContentTree.view.controller.paste(); + + let tree = await PlacesUtils.promiseBookmarksTree( + PlacesUtils.bookmarks.unfiledGuid + ); + + Assert.equal( + tree.children.length, + 1, + "Should be one bookmark in the unfiled folder." + ); + Assert.equal(tree.children[0].title, "0", "Should have the correct title"); + Assert.equal(tree.children[0].uri, TEST_URL, "Should have the correct URL"); + Assert.equal( + tree.children[0].dateAdded, + PlacesUtils.toPRTime(dateAdded), + "Should have the correct date" + ); + + Assert.ok( + ContentTree.view.view + .getCellText(0, ContentTree.view.columns.placesContentDateAdded) + .startsWith(`${dateAdded.getHours()}:${dateAdded.getMinutes()}`), + "Should reflect the data added" + ); + + await PlacesUtils.bookmarks.remove(tree.children[0].guid); +}); + +add_task(async function paste_check_indexes() { + info("Selecting BookmarksToolbar in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + + let copyChildren = []; + let targetChildren = []; + for (let i = 0; i < 10; i++) { + copyChildren.push({ + url: `${TEST_URL}${i}`, + title: `Copy ${i}`, + }); + targetChildren.push({ + url: `${TEST_URL1}${i}`, + title: `Target ${i}`, + }); + } + + let copyBookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: copyChildren, + }); + + let targetBookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: targetChildren, + }); + + ContentTree.view.selectItems([ + copyBookmarks[0].guid, + copyBookmarks[3].guid, + copyBookmarks[6].guid, + copyBookmarks[9].guid, + ]); + + await promiseClipboard(() => { + info("Cutting multiple selection"); + ContentTree.view.controller.cut(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + info("Selecting UnfiledBookmarks in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + + ContentTree.view.selectItems([targetBookmarks[4].guid]); + + info("Pasting clipboard"); + await ContentTree.view.controller.paste(); + + let tree = await PlacesUtils.promiseBookmarksTree( + PlacesUtils.bookmarks.unfiledGuid + ); + + const expectedBookmarkOrder = [ + targetBookmarks[0].guid, + targetBookmarks[1].guid, + targetBookmarks[2].guid, + targetBookmarks[3].guid, + copyBookmarks[0].guid, + copyBookmarks[3].guid, + copyBookmarks[6].guid, + copyBookmarks[9].guid, + targetBookmarks[4].guid, + targetBookmarks[5].guid, + targetBookmarks[6].guid, + targetBookmarks[7].guid, + targetBookmarks[8].guid, + targetBookmarks[9].guid, + ]; + + Assert.equal( + tree.children.length, + expectedBookmarkOrder.length, + "Should be the expected amount of bookmarks in the unfiled folder." + ); + + for (let i = 0; i < expectedBookmarkOrder.length; ++i) { + Assert.equal( + tree.children[i].guid, + expectedBookmarkOrder[i], + `Should be the expected item at index ${i}` + ); + } + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function paste_check_indexes_same_folder() { + info("Selecting BookmarksToolbar in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + + let copyChildren = []; + for (let i = 0; i < 10; i++) { + copyChildren.push({ + url: `${TEST_URL}${i}`, + title: `Copy ${i}`, + }); + } + + let copyBookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: copyChildren, + }); + + ContentTree.view.selectItems([ + copyBookmarks[0].guid, + copyBookmarks[3].guid, + copyBookmarks[6].guid, + copyBookmarks[9].guid, + ]); + + await promiseClipboard(() => { + info("Cutting multiple selection"); + ContentTree.view.controller.cut(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + ContentTree.view.selectItems([copyBookmarks[4].guid]); + + info("Pasting clipboard"); + await ContentTree.view.controller.paste(); + + let tree = await PlacesUtils.promiseBookmarksTree( + PlacesUtils.bookmarks.toolbarGuid + ); + + // Although we've inserted at index 4, we've taken out two items below it, so + // we effectively insert after the third item. + const expectedBookmarkOrder = [ + copyBookmarks[1].guid, + copyBookmarks[2].guid, + copyBookmarks[0].guid, + copyBookmarks[3].guid, + copyBookmarks[6].guid, + copyBookmarks[9].guid, + copyBookmarks[4].guid, + copyBookmarks[5].guid, + copyBookmarks[7].guid, + copyBookmarks[8].guid, + ]; + + Assert.equal( + tree.children.length, + expectedBookmarkOrder.length, + "Should be the expected amount of bookmarks in the unfiled folder." + ); + + for (let i = 0; i < expectedBookmarkOrder.length; ++i) { + Assert.equal( + tree.children[i].guid, + expectedBookmarkOrder[i], + `Should be the expected item at index ${i}` + ); + } + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function paste_from_different_instance() { + let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + xferable.init(null); + + // Fake data on the clipboard to pretend this is from a different instance + // of Firefox. + let data = { + title: "test", + id: 32, + instanceId: "FAKEFAKEFAKE", + itemGuid: "ZBf_TYkrYGvW", + parent: 452, + dateAdded: 1464866275853000, + lastModified: 1507638113352000, + type: "text/x-moz-place", + uri: TEST_URL1, + }; + data = JSON.stringify(data); + + xferable.addDataFlavor(PlacesUtils.TYPE_X_MOZ_PLACE); + xferable.setTransferData( + PlacesUtils.TYPE_X_MOZ_PLACE, + PlacesUtils.toISupportsString(data) + ); + + Services.clipboard.setData(xferable, null, Ci.nsIClipboard.kGlobalClipboard); + + info("Selecting UnfiledBookmarks in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + + info("Pasting clipboard"); + await ContentTree.view.controller.paste(); + + let tree = await PlacesUtils.promiseBookmarksTree( + PlacesUtils.bookmarks.unfiledGuid + ); + + Assert.equal( + tree.children.length, + 1, + "Should be one bookmark in the unfiled folder." + ); + Assert.equal(tree.children[0].title, "test", "Should have the correct title"); + Assert.equal(tree.children[0].uri, TEST_URL1, "Should have the correct URL"); + + await PlacesUtils.bookmarks.remove(tree.children[0].guid); +}); + +add_task(async function paste_separator_from_different_instance() { + let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + xferable.init(null); + + // Fake data on the clipboard to pretend this is from a different instance + // of Firefox. + let data = { + title: "test", + id: 32, + instanceId: "FAKEFAKEFAKE", + itemGuid: "ZBf_TYkrYGvW", + parent: 452, + dateAdded: 1464866275853000, + lastModified: 1507638113352000, + type: PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR, + }; + data = JSON.stringify(data); + + xferable.addDataFlavor(PlacesUtils.TYPE_X_MOZ_PLACE); + xferable.setTransferData( + PlacesUtils.TYPE_X_MOZ_PLACE, + PlacesUtils.toISupportsString(data) + ); + + Services.clipboard.setData(xferable, null, Ci.nsIClipboard.kGlobalClipboard); + + info("Selecting UnfiledBookmarks in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + + info("Pasting clipboard"); + await ContentTree.view.controller.paste(); + + let tree = await PlacesUtils.promiseBookmarksTree( + PlacesUtils.bookmarks.unfiledGuid + ); + + Assert.equal( + tree.children.length, + 1, + "Should be one bookmark in the unfiled folder." + ); + Assert.equal( + tree.children[0].type, + PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR, + "Should have the correct type" + ); + + await PlacesUtils.bookmarks.remove(tree.children[0].guid); +}); + +add_task(async function paste_copy_check_indexes() { + info("Selecting BookmarksToolbar in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + + let copyChildren = []; + let targetChildren = []; + for (let i = 0; i < 10; i++) { + copyChildren.push({ + url: `${TEST_URL}${i}`, + title: `Copy ${i}`, + }); + targetChildren.push({ + url: `${TEST_URL1}${i}`, + title: `Target ${i}`, + }); + } + + let copyBookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: copyChildren, + }); + + let targetBookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: targetChildren, + }); + + ContentTree.view.selectItems([ + copyBookmarks[0].guid, + copyBookmarks[3].guid, + copyBookmarks[6].guid, + copyBookmarks[9].guid, + ]); + + await promiseClipboard(() => { + info("Cutting multiple selection"); + ContentTree.view.controller.copy(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + info("Selecting UnfiledBookmarks in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + + ContentTree.view.selectItems([targetBookmarks[4].guid]); + + info("Pasting clipboard"); + await ContentTree.view.controller.paste(); + + let tree = await PlacesUtils.promiseBookmarksTree( + PlacesUtils.bookmarks.unfiledGuid + ); + + const expectedBookmarkOrder = [ + targetBookmarks[0].guid, + targetBookmarks[1].guid, + targetBookmarks[2].guid, + targetBookmarks[3].guid, + 0, + 3, + 6, + 9, + targetBookmarks[4].guid, + targetBookmarks[5].guid, + targetBookmarks[6].guid, + targetBookmarks[7].guid, + targetBookmarks[8].guid, + targetBookmarks[9].guid, + ]; + + Assert.equal( + tree.children.length, + expectedBookmarkOrder.length, + "Should be the expected amount of bookmarks in the unfiled folder." + ); + + for (let i = 0; i < expectedBookmarkOrder.length; ++i) { + if (i > 3 && i <= 7) { + // Items 4 - 7 are copies of the original, so we need to compare data, rather + // than their guids. + Assert.equal( + tree.children[i].title, + copyChildren[expectedBookmarkOrder[i]].title, + `Should have the correct bookmark title at index ${i}` + ); + Assert.equal( + tree.children[i].uri, + copyChildren[expectedBookmarkOrder[i]].url, + `Should have the correct bookmark URL at index ${i}` + ); + } else { + Assert.equal( + tree.children[i].guid, + expectedBookmarkOrder[i], + `Should be the expected item at index ${i}` + ); + } + } + + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/browser/components/places/tests/browser/browser_paste_into_tags.js b/browser/components/places/tests/browser/browser_paste_into_tags.js new file mode 100644 index 0000000000..52f312d68e --- /dev/null +++ b/browser/components/places/tests/browser/browser_paste_into_tags.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const TEST_URL = Services.io.newURI("http://example.com/"); +const MOZURISPEC = Services.io.newURI("http://mozilla.com/"); + +add_task(async function () { + let organizer = await promiseLibrary(); + + ok(PlacesUtils, "PlacesUtils in scope"); + ok(PlacesUIUtils, "PlacesUIUtils in scope"); + + let PlacesOrganizer = organizer.PlacesOrganizer; + ok(PlacesOrganizer, "Places organizer in scope"); + + let ContentTree = organizer.ContentTree; + ok(ContentTree, "ContentTree is in scope"); + + let visits = { + uri: MOZURISPEC, + transition: PlacesUtils.history.TRANSITION_TYPED, + }; + await PlacesTestUtils.addVisits(visits); + + // create an initial tag to work with + let newBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: "bookmark/" + TEST_URL.spec, + url: TEST_URL, + }); + + ok(newBookmark, "A bookmark was added"); + PlacesUtils.tagging.tagURI(TEST_URL, ["foo"]); + let tags = PlacesUtils.tagging.getTagsForURI(TEST_URL); + is(tags[0], "foo", "tag is foo"); + + // focus the new tag + focusTag(PlacesOrganizer); + + let populate = () => copyHistNode(PlacesOrganizer, ContentTree); + await promiseClipboard(populate, PlacesUtils.TYPE_X_MOZ_PLACE); + + focusTag(PlacesOrganizer); + await PlacesOrganizer._places.controller.paste(); + + // re-focus the history again + PlacesOrganizer.selectLeftPaneBuiltIn("History"); + let histContainer = PlacesOrganizer._places.selectedNode; + PlacesUtils.asContainer(histContainer); + histContainer.containerOpen = true; + PlacesOrganizer._places.selectNode(histContainer.getChild(0)); + let histNode = ContentTree.view.view.nodeForTreeIndex(0); + ok(histNode, "histNode exists: " + histNode.title); + + // check to see if the history node is tagged! + tags = PlacesUtils.tagging.getTagsForURI(MOZURISPEC); + Assert.equal(tags.length, 1, "history node is tagged: " + tags.length); + + // check if a bookmark was created + let bookmarks = []; + await PlacesUtils.bookmarks.fetch({ url: MOZURISPEC }, bm => { + bookmarks.push(bm); + }); + ok(!!bookmarks.length, "bookmark exists for the tagged history item"); + + // is the bookmark visible in the UI? + // get the Unsorted Bookmarks node + PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + + // now we can see what is in the ContentTree tree + let unsortedNode = ContentTree.view.view.nodeForTreeIndex(1); + ok(unsortedNode, "unsortedNode is not null: " + unsortedNode.uri); + is(unsortedNode.uri, MOZURISPEC.spec, "node uri's are the same"); + + await promiseLibraryClosed(organizer); + + // Remove new Places data we created. + PlacesUtils.tagging.untagURI(MOZURISPEC, ["foo"]); + PlacesUtils.tagging.untagURI(TEST_URL, ["foo"]); + tags = PlacesUtils.tagging.getTagsForURI(TEST_URL); + is(tags.length, 0, "tags are gone"); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +function focusTag(PlacesOrganizer) { + PlacesOrganizer.selectLeftPaneBuiltIn("Tags"); + let tags = PlacesOrganizer._places.selectedNode; + tags.containerOpen = true; + let fooTag = tags.getChild(0); + let tagNode = fooTag; + PlacesOrganizer._places.selectNode(fooTag); + is(tagNode.title, "foo", "tagNode title is foo"); + let ip = PlacesOrganizer._places.insertionPoint; + ok(ip.isTag, "IP is a tag"); +} + +function copyHistNode(PlacesOrganizer, ContentTree) { + // focus the history object + PlacesOrganizer.selectLeftPaneBuiltIn("History"); + let histContainer = PlacesOrganizer._places.selectedNode; + PlacesUtils.asContainer(histContainer); + histContainer.containerOpen = true; + PlacesOrganizer._places.selectNode(histContainer.getChild(0)); + let histNode = ContentTree.view.view.nodeForTreeIndex(0); + ContentTree.view.selectNode(histNode); + is(histNode.uri, MOZURISPEC.spec, "historyNode exists: " + histNode.uri); + // copy the history node + ContentTree.view.controller.copy(); +} diff --git a/browser/components/places/tests/browser/browser_paste_resets_cut_highlights.js b/browser/components/places/tests/browser/browser_paste_resets_cut_highlights.js new file mode 100644 index 0000000000..caa73d137f --- /dev/null +++ b/browser/components/places/tests/browser/browser_paste_resets_cut_highlights.js @@ -0,0 +1,99 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URL = "http://example.com/"; +const TEST_URL1 = "https://example.com/otherbrowser/"; + +var PlacesOrganizer; +var ContentTree; + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + let organizer = await promiseLibrary(); + + registerCleanupFunction(async function () { + await promiseLibraryClosed(organizer); + await PlacesUtils.bookmarks.eraseEverything(); + }); + + PlacesOrganizer = organizer.PlacesOrganizer; + ContentTree = organizer.ContentTree; +}); + +add_task(async function paste() { + info("Selecting BookmarksToolbar in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + url: TEST_URL, + title: "0", + }, + { + url: TEST_URL1, + title: "1", + }, + ], + }); + + Assert.equal( + ContentTree.view.view.rowCount, + 2, + "Should have the right amount of items in the view" + ); + + ContentTree.view.selectItems([bookmarks[0].guid]); + + await promiseClipboard(() => { + info("Cutting selection"); + ContentTree.view.controller.cut(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + assertItemsHighlighted(1); + + ContentTree.view.selectItems([bookmarks[1].guid]); + + info("Pasting clipboard"); + await ContentTree.view.controller.paste(); + + assertItemsHighlighted(0); + + // And now repeat the other way around to make sure. + + await promiseClipboard(() => { + info("Cutting selection"); + ContentTree.view.controller.cut(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + assertItemsHighlighted(1); + + ContentTree.view.selectItems([bookmarks[0].guid]); + + info("Pasting clipboard"); + await ContentTree.view.controller.paste(); + + assertItemsHighlighted(0); +}); + +function assertItemsHighlighted(expectedItems) { + let column = ContentTree.view.view._tree.columns[0]; + // Check the properties of the cells to make sure nothing has a cut highlight. + let highlighedItems = 0; + for (let i = 0; i < ContentTree.view.view.rowCount; i++) { + if ( + ContentTree.view.view.getCellProperties(i, column).includes("cutting") + ) { + highlighedItems++; + } + } + + Assert.equal( + highlighedItems, + expectedItems, + "Should have the correct amount of items highlighed" + ); +} diff --git a/browser/components/places/tests/browser/browser_remove_bookmarks.js b/browser/components/places/tests/browser/browser_remove_bookmarks.js new file mode 100644 index 0000000000..8889ea11c0 --- /dev/null +++ b/browser/components/places/tests/browser/browser_remove_bookmarks.js @@ -0,0 +1,155 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Test removing bookmarks from the Bookmarks Toolbar and Library. + */ + +const TEST_URL = "about:mozilla"; + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + + let toolbar = document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + + // Uncollapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + } + + registerCleanupFunction(async () => { + // Collapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, false); + } + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_remove_bookmark_from_toolbar() { + let toolbarBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "Bookmark Title", + url: TEST_URL, + }); + + let toolbarNode = getToolbarNodeForItemGuid(toolbarBookmark.guid); + + let contextMenu = document.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + + EventUtils.synthesizeMouseAtCenter(toolbarNode, { + button: 2, + type: "contextmenu", + }); + await popupShownPromise; + + let contextMenuDeleteBookmark = document.getElementById( + "placesContext_deleteBookmark" + ); + + let removePromise = PlacesTestUtils.waitForNotification( + "bookmark-removed", + events => events.some(event => event.url == TEST_URL) + ); + + contextMenu.activateItem(contextMenuDeleteBookmark, {}); + + await removePromise; + + Assert.deepEqual( + PlacesUtils.bookmarks.fetch({ url: TEST_URL }), + {}, + "Should have removed the bookmark from the database" + ); +}); + +add_task(async function test_remove_bookmark_from_library() { + const uris = [ + "http://example.com/1", + "http://example.com/2", + "http://example.com/3", + ]; + + let children = uris.map((uri, index) => { + return { + title: `bm${index}`, + url: uri, + }; + }); + + // Insert bookmarks. + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children, + }); + + // Open the Library and select the "UnfiledBookmarks". + let library = await promiseLibrary("UnfiledBookmarks"); + + registerCleanupFunction(async function () { + await promiseLibraryClosed(library); + }); + + let PO = library.PlacesOrganizer; + + Assert.equal( + PlacesUtils.getConcreteItemGuid(PO._places.selectedNode), + PlacesUtils.bookmarks.unfiledGuid, + "Should have selected unfiled bookmarks." + ); + + let contextMenu = library.document.getElementById("placesContext"); + let contextMenuDeleteBookmark = library.document.getElementById( + "placesContext_deleteBookmark" + ); + + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + + let firstColumn = library.ContentTree.view.columns[0]; + let firstBookmarkRect = library.ContentTree.view.getCoordsForCellItem( + 0, + firstColumn, + "bm0" + ); + + EventUtils.synthesizeMouse( + library.ContentTree.view.body, + firstBookmarkRect.x, + firstBookmarkRect.y, + { type: "contextmenu", button: 2 }, + library + ); + + await popupShownPromise; + + Assert.equal( + library.ContentTree.view.result.root.childCount, + 3, + "Number of bookmarks before removal is right" + ); + + let removePromise = PlacesTestUtils.waitForNotification( + "bookmark-removed", + events => events.some(event => event.url == uris[0]) + ); + contextMenu.activateItem(contextMenuDeleteBookmark, {}); + + await removePromise; + + Assert.equal( + library.ContentTree.view.result.root.childCount, + 2, + "Should have removed the bookmark from the display" + ); +}); diff --git a/browser/components/places/tests/browser/browser_sidebar_bookmarks_telemetry.js b/browser/components/places/tests/browser/browser_sidebar_bookmarks_telemetry.js new file mode 100644 index 0000000000..10f82db286 --- /dev/null +++ b/browser/components/places/tests/browser/browser_sidebar_bookmarks_telemetry.js @@ -0,0 +1,141 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); +let bookmarks; +let folder; + +add_setup(async function () { + folder = await PlacesUtils.bookmarks.insert({ + title: "Sidebar Test Folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: folder.guid, + children: [ + { + title: "Mozilla", + url: "https://www.mozilla.org/", + }, + { + title: "Example", + url: "https://sidebar.mozilla.org/", + }, + ], + }); + + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_open_multiple_bookmarks() { + await withSidebarTree("bookmarks", async tree => { + tree.selectItems([folder.guid]); + + is( + tree.selectedNode.title, + "Sidebar Test Folder", + "The sidebar test bookmarks folder is selected" + ); + + // open all bookmarks in this folder (which is two) + synthesizeClickOnSelectedTreeCell(tree, { button: 1 }); + + // expand the "Other bookmarks" folder + synthesizeClickOnSelectedTreeCell(tree, { button: 0 }); + tree.selectItems([bookmarks[0].guid]); + + is(tree.selectedNode.title, "Mozilla", "The first bookmark is selected"); + + synthesizeClickOnSelectedTreeCell(tree, { button: 0 }); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true), + "sidebar.link", + "bookmarks", + 3 + ); + + let newWinOpened = BrowserTestUtils.waitForNewWindow(); + // open a bookmark in new window via context menu + synthesizeClickOnSelectedTreeCell(tree, { + button: 2, + type: "contextmenu", + }); + + let openNewWindowOption = document.getElementById( + "placesContext_open:newwindow" + ); + openNewWindowOption.click(); + + let newWin = await newWinOpened; + + // total bookmarks opened + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link", + "bookmarks", + 4 + ); + + Services.telemetry.clearScalars(); + BrowserTestUtils.closeWindow(newWin); + + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + }); +}); + +add_task(async function test_bookmarks_search() { + let cumulativeSearchesHistogram = TelemetryTestUtils.getAndClearHistogram( + "PLACES_BOOKMARKS_SEARCHBAR_CUMULATIVE_SEARCHES" + ); + cumulativeSearchesHistogram.clear(); + + await withSidebarTree("bookmarks", async tree => { + // Search the tree. + let searchBox = tree.ownerDocument.getElementById("search-box"); + searchBox.value = "example"; + searchBox.doCommand(); + + searchBox.value = ""; + searchBox.doCommand(); + info("Search was reset"); + + // Perform a second search. + searchBox.value = "mozilla"; + searchBox.doCommand(); + info("Second search was performed"); + + // Select the first link and click on it. + tree.selectNode(tree.view.nodeForTreeIndex(0)); + + synthesizeClickOnSelectedTreeCell(tree, { button: 0 }); + info("First link was selected and then clicked on"); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link", + "bookmarks", + 1 + ); + }); + + TelemetryTestUtils.assertHistogram(cumulativeSearchesHistogram, 2, 1); + info("Cumulative search probe is recorded"); + + cumulativeSearchesHistogram.clear(); + Services.telemetry.clearScalars(); + + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } +}); diff --git a/browser/components/places/tests/browser/browser_sidebar_history_telemetry.js b/browser/components/places/tests/browser/browser_sidebar_history_telemetry.js new file mode 100644 index 0000000000..cb27bf66ef --- /dev/null +++ b/browser/components/places/tests/browser/browser_sidebar_history_telemetry.js @@ -0,0 +1,285 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); +const firstNodeIndex = 0; + +// The prompt returns 1 for cancelled and 0 for accepted. +let gResponse = 1; +(function replacePromptService() { + let originalPromptService = Services.prompt; + Services.prompt = { + QueryInterface: ChromeUtils.generateQI([Ci.nsIPromptService]), + confirmEx: () => gResponse, + }; + registerCleanupFunction(() => { + Services.prompt = originalPromptService; + }); +})(); + +add_setup(async function () { + await PlacesUtils.history.clear(); + + // Visited pages listed by descending visit date. + let pages = [ + "https://sidebar.mozilla.org/a", + "https://sidebar.mozilla.org/b", + "https://sidebar.mozilla.org/c", + "https://www.mozilla.org/d", + ]; + + // Add some visited page. + let time = Date.now(); + let places = []; + for (let i = 0; i < pages.length; i++) { + places.push({ + uri: NetUtil.newURI(pages[i]), + visitDate: (time - i) * 1000, + transition: PlacesUtils.history.TRANSITION_TYPED, + }); + } + await PlacesTestUtils.addVisits(places); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function test_click_multiple_history_entries() { + await withSidebarTree("history", async tree => { + tree.ownerDocument.getElementById("byday").doCommand(); + + // Select the first link and click on it. + tree.selectNode(tree.view.nodeForTreeIndex(firstNodeIndex)); + is( + tree.selectedNode.title, + "Today", + "The Today history sidebar button is selected" + ); + + // if a user tries to open all history items and they cancel opening + // those items in new tabs (due to max tab limit warning), + // no telemetry should be recorded + gResponse = 1; + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.maxOpenBeforeWarn", 4]], + }); + + // open multiple history items with a single click + synthesizeClickOnSelectedTreeCell(tree, { button: 1 }); + + TelemetryTestUtils.assertScalarUnset( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link" + ); + + // if they proceed with opening history multiple history items despite the warning, + // telemetry should be recorded + gResponse = 0; + synthesizeClickOnSelectedTreeCell(tree, { button: 1 }); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link", + "history", + 4 + ); + + let parentNode = tree.selectedNode; + if (!parentNode.containerOpen) { + // Only need to open/expand container node on first run + synthesizeClickOnSelectedTreeCell(tree); + } + if (parentNode.title == "Today" && parentNode.hasChildren) { + info(`Selecting node with title ${parentNode?.getChild(0)?.title}`); + tree.selectNode(parentNode.getChild(0)); + } + + synthesizeClickOnSelectedTreeCell(tree, { + button: 2, + type: "contextmenu", + }); + + TelemetryTestUtils.assertScalarUnset( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link" + ); + + let openNewTabOption = document.getElementById("placesContext_open:newtab"); + openNewTabOption.click(); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link", + "history", + 1 + ); + + if (parentNode.title == "Today" && parentNode.hasChildren) { + tree.selectNode(parentNode.getChild(0)); + } + + let newWinOpened = BrowserTestUtils.waitForNewWindow(); + + synthesizeClickOnSelectedTreeCell(tree, { + button: 2, + type: "contextmenu", + }); + + TelemetryTestUtils.assertScalarUnset( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link" + ); + + let openNewWindowOption = document.getElementById( + "placesContext_open:newwindow" + ); + openNewWindowOption.click(); + + let newWin = await newWinOpened; + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link", + "history", + 1 + ); + + await BrowserTestUtils.closeWindow(newWin); + + if (parentNode.title == "Today" && parentNode.hasChildren) { + tree.selectNode(parentNode.getChild(0)); + } + + let newPrivateWinOpened = BrowserTestUtils.waitForNewWindow(); + + synthesizeClickOnSelectedTreeCell(tree, { + button: 2, + type: "contextmenu", + }); + + TelemetryTestUtils.assertScalarUnset( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link" + ); + + let openNewPrivateWindowOption = document.getElementById( + "placesContext_open:newprivatewindow" + ); + openNewPrivateWindowOption.click(); + + let newPrivateWin = await newPrivateWinOpened; + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link", + "history", + 1 + ); + + await BrowserTestUtils.closeWindow(newPrivateWin); + }); + + await SpecialPowers.popPrefEnv(); + + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } +}); + +add_task(async function test_search_and_filter() { + let cumulativeSearchesHistogram = Services.telemetry.getHistogramById( + "PLACES_SEARCHBAR_CUMULATIVE_SEARCHES" + ); + cumulativeSearchesHistogram.clear(); + let cumulativeFilterCountHistogram = Services.telemetry.getHistogramById( + "PLACES_SEARCHBAR_CUMULATIVE_FILTER_COUNT" + ); + cumulativeFilterCountHistogram.clear(); + + await withSidebarTree("history", async tree => { + // Apply a search filter. + tree.ownerDocument.getElementById("bylastvisited").doCommand(); + info("Search filter was changed to bylastvisited"); + + // Search the tree. + let searchBox = tree.ownerDocument.getElementById("search-box"); + searchBox.value = "sidebar.mozilla"; + searchBox.doCommand(); + info("Tree was searched with sting sidebar.mozilla"); + + searchBox.value = ""; + searchBox.doCommand(); + info("Search was reset"); + + // Perform a second search. + searchBox.value = "sidebar.mozilla"; + searchBox.doCommand(); + info("Second search was performed"); + + // Select the first link and click on it. + tree.selectNode(tree.view.nodeForTreeIndex(firstNodeIndex)); + synthesizeClickOnSelectedTreeCell(tree, { button: 1 }); + info("First link was selected and then clicked on"); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link", + "history", + 1 + ); + }); + + TelemetryTestUtils.assertHistogram(cumulativeSearchesHistogram, 2, 1); + info("Cumulative search telemetry looks right"); + + TelemetryTestUtils.assertHistogram(cumulativeFilterCountHistogram, 1, 1); + info("Cumulative search filter telemetry looks right"); + + cumulativeSearchesHistogram.clear(); + cumulativeFilterCountHistogram.clear(); + + await withSidebarTree("history", async tree => { + // Apply a search filter. + tree.ownerDocument.getElementById("byday").doCommand(); + info("First search filter applied"); + + // Apply another search filter. + tree.ownerDocument.getElementById("bylastvisited").doCommand(); + info("Second search filter applied"); + + // Apply a search filter. + tree.ownerDocument.getElementById("byday").doCommand(); + info("Third search filter applied"); + + // Search the tree. + let searchBox = tree.ownerDocument.getElementById("search-box"); + searchBox.value = "sidebar.mozilla"; + searchBox.doCommand(); + + // Select the first link and click on it. + tree.selectNode(tree.view.nodeForTreeIndex(firstNodeIndex)); + synthesizeClickOnSelectedTreeCell(tree, { button: 1 }); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link", + "history", + 1 + ); + }); + + TelemetryTestUtils.assertHistogram(cumulativeSearchesHistogram, 1, 1); + TelemetryTestUtils.assertHistogram(cumulativeFilterCountHistogram, 3, 1); + + cumulativeSearchesHistogram.clear(); + cumulativeFilterCountHistogram.clear(); + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } +}); diff --git a/browser/components/places/tests/browser/browser_sidebar_on_customization.js b/browser/components/places/tests/browser/browser_sidebar_on_customization.js new file mode 100644 index 0000000000..6e97f81dd3 --- /dev/null +++ b/browser/components/places/tests/browser/browser_sidebar_on_customization.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var gInsertedBookmarks; +add_setup(async function () { + gInsertedBookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "bm1", + url: "about:buildconfig", + }, + { + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "folder", + children: [ + { + title: "bm2", + url: "about:mozilla", + }, + ], + }, + ], + }); + registerCleanupFunction(PlacesUtils.bookmarks.eraseEverything); +}); + +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( + BrowserTestUtils.isVisible(SidebarUI.browser), + "sidebar browser is visible" + ); + Assert.ok(tree.view.result, "View result is defined"); + await TestUtils.waitForCondition( + () => tree.view.result.root.containerOpen, + "View root node should be reopened" + ); + toggleFolder(tree, gInsertedBookmarks[1].guid); + } + + await checkTreeIsFunctional(); + + info("Starting customization"); + await promiseCustomizeStart(); + + Assert.ok( + !BrowserTestUtils.isVisible(SidebarUI.browser), + "sidebar browser is hidden" + ); + Assert.ok(tree.view.result, "View result is defined"); + Assert.ok(!tree.view.result.root.containerOpen, "View root node is closed"); + + info("Ending customization"); + await promiseCustomizeEnd(); + + await checkTreeIsFunctional(); + }); +}); + +function promiseCustomizeStart(win = window) { + return new Promise(resolve => { + win.gNavToolbox.addEventListener("customizationready", resolve, { + once: true, + }); + win.gCustomizeMode.enter(); + }); +} + +function promiseCustomizeEnd(win = window) { + return new Promise(resolve => { + win.gNavToolbox.addEventListener("aftercustomization", resolve, { + once: true, + }); + win.gCustomizeMode.exit(); + }); +} + +function toggleFolder(tree, guid) { + tree.selectItems([guid]); + Assert.equal(tree.selectedNode.title, "folder"); + Assert.ok( + !PlacesUtils.asContainer(tree.selectedNode).containerOpen, + "Folder is closed" + ); + synthesizeClickOnSelectedTreeCell(tree); + Assert.ok( + PlacesUtils.asContainer(tree.selectedNode).containerOpen, + "Folder is open" + ); + synthesizeClickOnSelectedTreeCell(tree); + Assert.ok( + !PlacesUtils.asContainer(tree.selectedNode).containerOpen, + "Folder is closed" + ); +} diff --git a/browser/components/places/tests/browser/browser_sidebar_open_bookmarks.js b/browser/components/places/tests/browser/browser_sidebar_open_bookmarks.js new file mode 100644 index 0000000000..92f98b898c --- /dev/null +++ b/browser/components/places/tests/browser/browser_sidebar_open_bookmarks.js @@ -0,0 +1,134 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const PREF_LOAD_BOOKMARKS_IN_TABS = "browser.tabs.loadBookmarksInTabs"; + +var gBms; + +add_setup(async function () { + gBms = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "bm1", + url: "about:buildconfig", + }, + { + title: "bm2", + url: "about:mozilla", + }, + ], + }); + + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_open_bookmark_from_sidebar() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await withSidebarTree("bookmarks", async tree => { + tree.selectItems([gBms[0].guid]); + + let loadedPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + gBms[0].url + ); + + tree.controller.doCommand("placesCmd_open"); + + await loadedPromise; + + // An assert to make the test happy. + Assert.ok(true, "The bookmark was loaded successfully."); + }); + + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_open_bookmark_from_sidebar_keypress() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await withSidebarTree("bookmarks", async tree => { + tree.selectItems([gBms[1].guid]); + + let loadedPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + gBms[1].url + ); + + tree.focus(); + EventUtils.sendKey("return"); + + await loadedPromise; + + // An assert to make the test happy. + Assert.ok(true, "The bookmark was loaded successfully."); + }); + + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_open_bookmark_in_tab_from_sidebar() { + await SpecialPowers.pushPrefEnv({ + set: [[PREF_LOAD_BOOKMARKS_IN_TABS, true]], + }); + + await BrowserTestUtils.withNewTab({ gBrowser }, async initialTab => { + await withSidebarTree("bookmarks", async tree => { + tree.selectItems([gBms[0].guid]); + let loadedPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + gBms[0].url + ); + tree.focus(); + EventUtils.sendKey("return"); + await loadedPromise; + Assert.ok(true, "The bookmark reused the empty tab."); + + tree.selectItems([gBms[1].guid]); + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, gBms[1].url); + tree.focus(); + EventUtils.sendKey("return"); + let newTab = await newTabPromise; + Assert.ok(true, "The bookmark was opened in a new tab."); + BrowserTestUtils.removeTab(newTab); + }); + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_open_bookmark_folder_from_sidebar() { + await withSidebarTree("bookmarks", async tree => { + tree.selectItems([PlacesUtils.bookmarks.virtualUnfiledGuid]); + + Assert.equal( + tree.view.selection.getRangeCount(), + 1, + "Should only have one range selected" + ); + + let loadedPromises = []; + + for (let bm of gBms) { + loadedPromises.push( + BrowserTestUtils.waitForNewTab(gBrowser, bm.url, false, true) + ); + } + + synthesizeClickOnSelectedTreeCell(tree, { button: 1 }); + + let tabs = await Promise.all(loadedPromises); + + for (let tab of tabs) { + await BrowserTestUtils.removeTab(tab); + } + }); +}); diff --git a/browser/components/places/tests/browser/browser_sidebarpanels_click.js b/browser/components/places/tests/browser/browser_sidebarpanels_click.js new file mode 100644 index 0000000000..4b231c92b0 --- /dev/null +++ b/browser/components/places/tests/browser/browser_sidebarpanels_click.js @@ -0,0 +1,172 @@ +/* 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 test makes sure that the items in the bookmarks and history sidebar +// panels are clickable in both LTR and RTL modes. + +var sidebar; + +function pushPref(name, val) { + return SpecialPowers.pushPrefEnv({ set: [[name, val]] }); +} + +function popPref() { + return SpecialPowers.popPrefEnv(); +} + +add_task(async function test_sidebarpanels_click() { + ignoreAllUncaughtExceptions(); + + const BOOKMARKS_SIDEBAR_ID = "viewBookmarksSidebar"; + const BOOKMARKS_SIDEBAR_TREE_ID = "bookmarks-view"; + const HISTORY_SIDEBAR_ID = "viewHistorySidebar"; + const HISTORY_SIDEBAR_TREE_ID = "historyTree"; + const TEST_URL = + "http://mochi.test:8888/browser/browser/components/places/tests/browser/sidebarpanels_click_test_page.html"; + + // If a sidebar is already open, close it. + if (!document.getElementById("sidebar-box").hidden) { + ok( + false, + "Unexpected sidebar found - a previous test failed to cleanup correctly" + ); + SidebarUI.hide(); + } + + // Ensure history is clean before starting the test. + await PlacesUtils.history.clear(); + + sidebar = document.getElementById("sidebar"); + let tests = []; + + tests.push({ + _bookmark: null, + async init() { + // Add a bookmark to the Unfiled Bookmarks folder. + this._bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "test", + url: TEST_URL, + }); + }, + prepare() {}, + async selectNode(tree) { + tree.selectItems([this._bookmark.guid]); + }, + cleanup(aCallback) { + return PlacesUtils.bookmarks.remove(this._bookmark); + }, + sidebarName: BOOKMARKS_SIDEBAR_ID, + treeName: BOOKMARKS_SIDEBAR_TREE_ID, + desc: "Bookmarks sidebar test", + }); + + tests.push({ + async init() { + // Add a history entry. + let uri = Services.io.newURI(TEST_URL); + await PlacesTestUtils.addVisits({ + uri, + visitDate: Date.now() * 1000, + transition: PlacesUtils.history.TRANSITION_TYPED, + }); + }, + prepare() { + sidebar.contentDocument.getElementById("byvisited").doCommand(); + }, + selectNode(tree) { + tree.selectNode(tree.view.nodeForTreeIndex(0)); + is( + tree.selectedNode.uri, + TEST_URL, + "The correct visit has been selected" + ); + is(tree.selectedNode.itemId, -1, "The selected node is not bookmarked"); + }, + cleanup(aCallback) { + return PlacesUtils.history.clear(); + }, + sidebarName: HISTORY_SIDEBAR_ID, + treeName: HISTORY_SIDEBAR_TREE_ID, + desc: "History sidebar test", + }); + + for (let test of tests) { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser); + info("Running " + test.desc + " in LTR mode"); + await testPlacesPanel(test); + + await pushPref("intl.l10n.pseudo", "bidi"); + info("Running " + test.desc + " in RTL mode"); + await testPlacesPanel(test); + await popPref(); + + // Remove tabs created by sub-tests. + while (gBrowser.tabs.length > 1) { + gBrowser.removeTab(gBrowser.tabs[gBrowser.tabs.length - 1]); + } + } +}); + +async function testPlacesPanel(testInfo) { + await testInfo.init(); + + let promise = new Promise(resolve => { + sidebar.addEventListener( + "load", + function () { + executeSoon(async function () { + testInfo.prepare(); + + let tree = sidebar.contentDocument.getElementById(testInfo.treeName); + + // Select the inserted places item. + await testInfo.selectNode(tree); + + let promiseAlert = promiseAlertDialogObserved(); + + synthesizeClickOnSelectedTreeCell(tree); + // Now, wait for the observer to catch the alert dialog. + // If something goes wrong, the test will time out at this stage. + // Note that for the history sidebar, the URL itself is not opened, + // and Places will show the load-js-data-url-error prompt as an alert + // box, which means that the click actually worked, so it's good enough + // for the purpose of this test. + + await promiseAlert; + + executeSoon(async function () { + SidebarUI.hide(); + await testInfo.cleanup(); + resolve(); + }); + }); + }, + { capture: true, once: true } + ); + }); + + SidebarUI.show(testInfo.sidebarName); + + return promise; +} + +function promiseAlertDialogObserved() { + return new Promise(resolve => { + async function observer(subject) { + info("alert dialog observed as expected"); + Services.obs.removeObserver(observer, "common-dialog-loaded"); + Services.obs.removeObserver(observer, "tabmodal-dialog-loaded"); + + if (subject.Dialog) { + subject.Dialog.ui.button0.click(); + } else { + subject.querySelector(".tabmodalprompt-button0").click(); + } + resolve(); + } + Services.obs.addObserver(observer, "common-dialog-loaded"); + Services.obs.addObserver(observer, "tabmodal-dialog-loaded"); + }); +} diff --git a/browser/components/places/tests/browser/browser_sort_in_library.js b/browser/components/places/tests/browser/browser_sort_in_library.js new file mode 100644 index 0000000000..2eea3a3f31 --- /dev/null +++ b/browser/components/places/tests/browser/browser_sort_in_library.js @@ -0,0 +1,248 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests the following bugs: + * + * Bug 443745 - View>Sort>of "alpha" sort items is default to Z>A instead of A>Z + * https://bugzilla.mozilla.org/show_bug.cgi?id=443745 + * + * Bug 444179 - Library>Views>Sort>Sort by Tags does nothing + * https://bugzilla.mozilla.org/show_bug.cgi?id=444179 + * + * Basically, fully tests sorting the placeContent tree in the Places Library + * window. Sorting is verified by comparing the nsINavHistoryResult returned by + * placeContent.result to the expected sort values. + */ + +// Two properties of nsINavHistoryResult control the sort of the tree: +// sortingMode. sortingMode's value is one of the +// nsINavHistoryQueryOptions.SORT_BY_* constants. +// +// This lookup table maps the possible values of anonid's of the treecols to +// objects that represent the treecols' correct state after the user sorts the +// previously unsorted tree by selecting a column from the Views > Sort menu. +// sortingMode is constructed from the key and dir properties (i.e., +// SORT_BY__). +const SORT_LOOKUP_TABLE = { + title: { key: "TITLE", dir: "ASCENDING" }, + tags: { key: "TAGS", dir: "ASCENDING" }, + url: { key: "URI", dir: "ASCENDING" }, + date: { key: "DATE", dir: "DESCENDING" }, + visitCount: { key: "VISITCOUNT", dir: "DESCENDING" }, + dateAdded: { key: "DATEADDED", dir: "DESCENDING" }, + lastModified: { key: "LASTMODIFIED", dir: "DESCENDING" }, +}; + +// This is the column that's sorted if one is not specified and the tree is +// currently unsorted. Set it to a key substring in the name of one of the +// nsINavHistoryQueryOptions.SORT_BY_* constants, e.g., "TITLE", "URI". +// Method ViewMenu.setSortColumn in browser/components/places/content/places.js +// determines this value. +const DEFAULT_SORT_KEY = "TITLE"; + +// Part of the test is checking that sorts stick, so each time we sort we need +// to remember it. +var prevSortDir = null; +var prevSortKey = null; + +/** + * Ensures that the sort of aTree is aSortingMode + * + * @param {object} aTree + * the tree to check + * @param {Ci.nsINavHistoryQueryOptions} aSortingMode + * one of the Ci.nsINavHistoryQueryOptions.SORT_BY_* constants + */ +function checkSort(aTree, aSortingMode) { + // The placeContent tree's sort is determined by the nsINavHistoryResult it + // stores. Get it and check that the sort is what the caller expects. + let res = aTree.result; + isnot(res, null, "sanity check: placeContent.result should not return null"); + + // Check sortingMode. + is( + res.sortingMode, + aSortingMode, + "column should now have sortingMode " + aSortingMode + ); +} + +/** + * Sets the sort of aTree. + * + * @param {object} aOrganizerWin + * the Places window + * @param {object} aTree + * the tree to sort + * @param {boolean} aUnsortFirst + * true if the sort should be set to SORT_BY_NONE before sorting by aCol + * and aDir + * @param {boolean} aShouldFail + * true if setSortColumn should fail on aCol or aDir + * @param {object} aCol + * the column of aTree by which to sort + * @param {string} aDir + * either "ascending" or "descending" + */ +function setSort(aOrganizerWin, aTree, aUnsortFirst, aShouldFail, aCol, aDir) { + if (aUnsortFirst) { + aOrganizerWin.ViewMenu.setSortColumn(); + checkSort(aTree, Ci.nsINavHistoryQueryOptions.SORT_BY_NONE, ""); + + // Remember the sort key and direction. + prevSortKey = null; + prevSortDir = null; + } + + let failed = false; + try { + aOrganizerWin.ViewMenu.setSortColumn(aCol, aDir); + + // Remember the sort key and direction. + if (!aCol && !aDir) { + prevSortKey = null; + prevSortDir = null; + } else { + if (aCol) { + prevSortKey = SORT_LOOKUP_TABLE[aCol.getAttribute("anonid")].key; + } else if (prevSortKey === null) { + prevSortKey = DEFAULT_SORT_KEY; + } + + if (aDir) { + prevSortDir = aDir.toUpperCase(); + } else if (prevSortDir === null) { + prevSortDir = SORT_LOOKUP_TABLE[aCol.getAttribute("anonid")].dir; + } + } + } catch (exc) { + failed = true; + } + + is( + failed, + !!aShouldFail, + "setSortColumn on column " + + (aCol ? aCol.getAttribute("anonid") : "(no column)") + + " with direction " + + (aDir || "(no direction)") + + " and table previously " + + (aUnsortFirst ? "unsorted" : "sorted") + + " should " + + (aShouldFail ? "" : "not ") + + "fail" + ); +} + +/** + * Tries sorting by an invalid column and sort direction. + * + * @param {object} aOrganizerWin + * the Places window + * @param {object} aPlaceContentTree + * the placeContent tree in aOrganizerWin + */ +function testInvalid(aOrganizerWin, aPlaceContentTree) { + // Invalid column should fail by throwing an exception. + let bogusCol = document.createXULElement("treecol"); + bogusCol.setAttribute("anonid", "bogusColumn"); + setSort(aOrganizerWin, aPlaceContentTree, true, true, bogusCol, "ascending"); + + // Invalid direction reverts to SORT_BY_NONE. + setSort(aOrganizerWin, aPlaceContentTree, false, false, null, "bogus dir"); + checkSort(aPlaceContentTree, Ci.nsINavHistoryQueryOptions.SORT_BY_NONE, ""); +} + +/** + * Tests sorting aPlaceContentTree by column only and then by both column + * and direction. + * + * @param {object} aOrganizerWin + * the Places window + * @param {object} aPlaceContentTree + * the placeContent tree in aOrganizerWin + * @param {boolean} aUnsortFirst + * true if, before each sort we try, we should sort to SORT_BY_NONE + */ +function testSortByColAndDir(aOrganizerWin, aPlaceContentTree, aUnsortFirst) { + let cols = aPlaceContentTree.getElementsByTagName("treecol"); + ok(!!cols.length, "sanity check: placeContent should contain columns"); + + for (let i = 0; i < cols.length; i++) { + let col = cols.item(i); + ok( + col.hasAttribute("anonid"), + "sanity check: column " + col.id + " should have anonid" + ); + + let colId = col.getAttribute("anonid"); + ok( + colId in SORT_LOOKUP_TABLE, + "sanity check: unexpected placeContent column anonid" + ); + + let sortStr = + "SORT_BY_" + + SORT_LOOKUP_TABLE[colId].key + + "_" + + (aUnsortFirst ? SORT_LOOKUP_TABLE[colId].dir : prevSortDir); + let expectedSortMode = Ci.nsINavHistoryQueryOptions[sortStr]; + + // Test sorting by only a column. + setSort(aOrganizerWin, aPlaceContentTree, aUnsortFirst, false, col); + checkSort(aPlaceContentTree, expectedSortMode); + + // Test sorting by both a column and a direction. + ["ascending", "descending"].forEach(function (dir) { + sortStr = + "SORT_BY_" + SORT_LOOKUP_TABLE[colId].key + "_" + dir.toUpperCase(); + expectedSortMode = Ci.nsINavHistoryQueryOptions[sortStr]; + setSort(aOrganizerWin, aPlaceContentTree, aUnsortFirst, false, col, dir); + checkSort(aPlaceContentTree, expectedSortMode); + }); + } +} + +/** + * Tests sorting aPlaceContentTree by direction only. + * + * @param {object} aOrganizerWin + * the Places window + * @param {object} aPlaceContentTree + * the placeContent tree in aOrganizerWin + * @param {boolean} aUnsortFirst + * true if, before each sort we try, we should sort to SORT_BY_NONE + */ +function testSortByDir(aOrganizerWin, aPlaceContentTree, aUnsortFirst) { + ["ascending", "descending"].forEach(function (dir) { + let key = aUnsortFirst ? DEFAULT_SORT_KEY : prevSortKey; + let sortStr = "SORT_BY_" + key + "_" + dir.toUpperCase(); + let expectedSortMode = Ci.nsINavHistoryQueryOptions[sortStr]; + setSort(aOrganizerWin, aPlaceContentTree, aUnsortFirst, false, null, dir); + checkSort(aPlaceContentTree, expectedSortMode, ""); + }); +} + +function test() { + waitForExplicitFinish(); + + openLibrary(function (win) { + let tree = win.document.getElementById("placeContent"); + isnot(tree, null, "sanity check: placeContent tree should exist"); + // Run the tests. + testSortByColAndDir(win, tree, true); + testSortByColAndDir(win, tree, false); + testSortByDir(win, tree, true); + testSortByDir(win, tree, false); + testInvalid(win, tree); + // Reset the sort to SORT_BY_NONE. + setSort(win, tree, false, false); + // Close the window and finish. + win.close(); + finish(); + }); +} diff --git a/browser/components/places/tests/browser/browser_stayopenmenu.js b/browser/components/places/tests/browser/browser_stayopenmenu.js new file mode 100644 index 0000000000..ec26700ef7 --- /dev/null +++ b/browser/components/places/tests/browser/browser_stayopenmenu.js @@ -0,0 +1,267 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Menus should stay open (if pref is set) after ctrl-click, middle-click, +// and contextmenu's "Open in a new tab" click. + +async function locateBookmarkAndTestCtrlClick(menupopup) { + let testMenuitem = [...menupopup.children].find( + node => node.label == "Test1" + ); + ok(testMenuitem, "Found test bookmark."); + ok(BrowserTestUtils.isVisible(testMenuitem), "Should be visible"); + let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, null); + EventUtils.synthesizeMouseAtCenter(testMenuitem, { accelKey: true }); + let newTab = await promiseTabOpened; + ok(true, "Bookmark ctrl-click opened new tab."); + BrowserTestUtils.removeTab(newTab); + return testMenuitem; +} + +async function testContextmenu(menuitem) { + let doc = menuitem.ownerDocument; + let cm = doc.getElementById("placesContext"); + let promiseEvent = BrowserTestUtils.waitForEvent(cm, "popupshown"); + EventUtils.synthesizeMouseAtCenter(menuitem, { + type: "contextmenu", + button: 2, + }); + await promiseEvent; + let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, null); + let hidden = BrowserTestUtils.waitForEvent(cm, "popuphidden"); + cm.activateItem(doc.getElementById("placesContext_open:newtab")); + await hidden; + let newTab = await promiseTabOpened; + return newTab; +} + +add_setup(async function () { + // Ensure BMB is available in UI. + let origBMBlocation = CustomizableUI.getPlacementOfWidget( + "bookmarks-menu-button" + ); + if (!origBMBlocation) { + CustomizableUI.addWidgetToArea( + "bookmarks-menu-button", + CustomizableUI.AREA_NAVBAR + ); + } + + await SpecialPowers.pushPrefEnv({ + set: [["browser.bookmarks.openInTabClosesMenu", false]], + }); + // Ensure menubar visible. + let menubar = document.getElementById("toolbar-menubar"); + let menubarVisible = isToolbarVisible(menubar); + if (!menubarVisible) { + setToolbarVisibility(menubar, true); + info("Menubar made visible"); + } + // Ensure Bookmarks Toolbar Visible. + let toolbar = document.getElementById("PersonalToolbar"); + let toolbarHidden = toolbar.collapsed; + if (toolbarHidden) { + await promiseSetToolbarVisibility(toolbar, true); + info("Bookmarks toolbar made visible"); + } + // Create our test bookmarks. + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://example.com/", + title: "Test1", + }); + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "TEST_TITLE", + index: 0, + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: folder.guid, + url: "http://example.com/", + title: "Test1", + }); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + // if BMB was not originally in UI, remove it. + if (!origBMBlocation) { + CustomizableUI.removeWidgetFromArea("bookmarks-menu-button"); + } + // Restore menubar to original visibility. + setToolbarVisibility(menubar, menubarVisible); + // Restore original bookmarks toolbar visibility. + if (toolbarHidden) { + await promiseSetToolbarVisibility(toolbar, false); + } + }); +}); + +add_task(async function testStayopenBookmarksClicks() { + // Test Bookmarks Menu Button stayopen clicks - Ctrl-click. + let BMB = document.getElementById("bookmarks-menu-button"); + let BMBpopup = document.getElementById("BMB_bookmarksPopup"); + let promiseEvent = BrowserTestUtils.waitForEvent(BMBpopup, "popupshown"); + EventUtils.synthesizeMouseAtCenter(BMB, {}); + await promiseEvent; + info("Popupshown on Bookmarks-Menu-Button"); + var menuitem = await locateBookmarkAndTestCtrlClick(BMBpopup); + ok(BMB.open, "Bookmarks Menu Button's Popup should still be open."); + + // Test Bookmarks Menu Button stayopen clicks: middle-click. + let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, null); + EventUtils.synthesizeMouseAtCenter(menuitem, { button: 1 }); + let newTab = await promiseTabOpened; + ok(true, "Bookmark middle-click opened new tab."); + BrowserTestUtils.removeTab(newTab); + ok(BMB.open, "Bookmarks Menu Button's Popup should still be open."); + + // Test Bookmarks Menu Button stayopen clicks - 'Open in new tab' on context menu. + newTab = await testContextmenu(menuitem); + ok(true, "Bookmark contextmenu opened new tab."); + ok(BMB.open, "Bookmarks Menu Button's Popup should still be open."); + promiseEvent = BrowserTestUtils.waitForEvent(BMBpopup, "popuphidden"); + BMB.open = false; + await promiseEvent; + info("Closing menu"); + BrowserTestUtils.removeTab(newTab); + + // Test App Menu's Bookmarks Library stayopen clicks. + let appMenu = document.getElementById("PanelUI-menu-button"); + let appMenuPopup = document.getElementById("appMenu-popup"); + let PopupShownPromise = BrowserTestUtils.waitForEvent( + appMenuPopup, + "popupshown" + ); + appMenu.click(); + await PopupShownPromise; + + let BMview; + document.getElementById("appMenu-bookmarks-button").click(); + BMview = document.getElementById("PanelUI-bookmarks"); + let promise = BrowserTestUtils.waitForEvent(BMview, "ViewShown"); + await promise; + info("Bookmarks panel shown."); + + // Test App Menu's Bookmarks Library stayopen clicks: Ctrl-click. + let menu = document.getElementById("panelMenu_bookmarksMenu"); + var testMenuitem = await locateBookmarkAndTestCtrlClick(menu); + ok(appMenu.open, "Menu should remain open."); + + // Test App Menu's Bookmarks Library stayopen clicks: middle-click. + promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, null); + EventUtils.synthesizeMouseAtCenter(testMenuitem, { button: 1 }); + newTab = await promiseTabOpened; + ok(true, "Bookmark middle-click opened new tab."); + BrowserTestUtils.removeTab(newTab); + ok( + PanelView.forNode(BMview).active, + "Should still show the bookmarks subview" + ); + ok(appMenu.open, "Menu should remain open."); + + // Close the App Menu + appMenuPopup.hidePopup(); + ok(!appMenu.open, "The menu should now be closed."); + + // Disable the rest of the tests on Mac due to Mac's handling of menus being + // slightly different to the other platforms. + if (AppConstants.platform === "macosx") { + return; + } + + // Test Bookmarks Menu (menubar) stayopen clicks: Ctrl-click. + let BM = document.getElementById("bookmarksMenu"); + let BMpopup = document.getElementById("bookmarksMenuPopup"); + promiseEvent = BrowserTestUtils.waitForEvent(BMpopup, "popupshown"); + EventUtils.synthesizeMouseAtCenter(BM, {}); + await promiseEvent; + info("Popupshowing on Bookmarks Menu"); + menuitem = await locateBookmarkAndTestCtrlClick(BMpopup); + ok(BM.open, "Bookmarks Menu's Popup should still be open."); + + // Test Bookmarks Menu (menubar) stayopen clicks: middle-click. + promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, null); + EventUtils.synthesizeMouseAtCenter(menuitem, { button: 1 }); + newTab = await promiseTabOpened; + ok(true, "Bookmark middle-click opened new tab."); + BrowserTestUtils.removeTab(newTab); + ok(BM.open, "Bookmarks Menu's Popup should still be open."); + + // Test Bookmarks Menu (menubar) stayopen clicks: 'Open in new tab' on context menu. + newTab = await testContextmenu(menuitem); + ok(true, "Bookmark contextmenu opened new tab."); + BrowserTestUtils.removeTab(newTab); + ok(BM.open, "Bookmarks Menu's Popup should still be open."); + promiseEvent = BrowserTestUtils.waitForEvent(BMpopup, "popuphidden"); + BM.open = false; + await promiseEvent; + + // Test Bookmarks Toolbar stayopen clicks - Ctrl-click. + let BT = document.getElementById("PlacesToolbarItems"); + let toolbarbutton = BT.firstElementChild; + ok(toolbarbutton, "Folder should be first item on Bookmarks Toolbar."); + let buttonMenupopup = toolbarbutton.firstElementChild; + Assert.equal( + buttonMenupopup.tagName, + "menupopup", + "Found toolbar button's menupopup." + ); + promiseEvent = BrowserTestUtils.waitForEvent(buttonMenupopup, "popupshown"); + EventUtils.synthesizeMouseAtCenter(toolbarbutton, {}); + await promiseEvent; + ok(true, "Bookmarks toolbar folder's popup is open."); + menuitem = buttonMenupopup.firstElementChild.nextElementSibling; + promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, null); + EventUtils.synthesizeMouseAtCenter(menuitem, { ctrlKey: true }); + newTab = await promiseTabOpened; + ok( + true, + "Bookmark in folder on bookmark's toolbar ctrl-click opened new tab." + ); + ok( + toolbarbutton.open, + "Popup of folder on bookmark's toolbar should still be open." + ); + promiseEvent = BrowserTestUtils.waitForEvent(buttonMenupopup, "popuphidden"); + toolbarbutton.open = false; + await promiseEvent; + BrowserTestUtils.removeTab(newTab); + + // Test Bookmarks Toolbar stayopen clicks: middle-click. + promiseEvent = BrowserTestUtils.waitForEvent(buttonMenupopup, "popupshown"); + EventUtils.synthesizeMouseAtCenter(toolbarbutton, {}); + await promiseEvent; + ok(true, "Bookmarks toolbar folder's popup is open."); + promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, null); + EventUtils.synthesizeMouseAtCenter(menuitem, { button: 1 }); + newTab = await promiseTabOpened; + ok( + true, + "Bookmark in folder on Bookmarks Toolbar middle-click opened new tab." + ); + ok( + toolbarbutton.open, + "Popup of folder on bookmark's toolbar should still be open." + ); + promiseEvent = BrowserTestUtils.waitForEvent(buttonMenupopup, "popuphidden"); + toolbarbutton.open = false; + await promiseEvent; + BrowserTestUtils.removeTab(newTab); + + // Test Bookmarks Toolbar stayopen clicks: 'Open in new tab' on context menu. + promiseEvent = BrowserTestUtils.waitForEvent(buttonMenupopup, "popupshown"); + EventUtils.synthesizeMouseAtCenter(toolbarbutton, {}); + await promiseEvent; + ok(true, "Bookmarks toolbar folder's popup is open."); + newTab = await testContextmenu(menuitem); + ok(true, "Bookmark on Bookmarks Toolbar contextmenu opened new tab."); + ok( + toolbarbutton.open, + "Popup of folder on bookmark's toolbar should still be open." + ); + promiseEvent = BrowserTestUtils.waitForEvent(buttonMenupopup, "popuphidden"); + toolbarbutton.open = false; + await promiseEvent; + BrowserTestUtils.removeTab(newTab); +}); diff --git a/browser/components/places/tests/browser/browser_toolbar_drop_bookmarklet.js b/browser/components/places/tests/browser/browser_toolbar_drop_bookmarklet.js new file mode 100644 index 0000000000..9815bd595d --- /dev/null +++ b/browser/components/places/tests/browser/browser_toolbar_drop_bookmarklet.js @@ -0,0 +1,200 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const sandbox = sinon.createSandbox(); + +const URL1 = "https://example.com/1/"; +const URL2 = "https://example.com/2/"; +const BOOKMARKLET_URL = `javascript: (() => {alert('Hello, World!');})();`; +let bookmarks; + +registerCleanupFunction(async function () { + sandbox.restore(); +}); + +add_task(async function test() { + // Make sure the bookmarks bar is visible and restore its state on cleanup. + let toolbar = document.getElementById("PersonalToolbar"); + ok(toolbar, "PersonalToolbar should not be null"); + + await PlacesUtils.bookmarks.eraseEverything(); + + if (toolbar.collapsed) { + await promiseSetToolbarVisibility(toolbar, true); + registerCleanupFunction(function () { + return promiseSetToolbarVisibility(toolbar, false); + }); + } + // Setup the node we will use to be dropped. The actual node used does not + // matter because we will set its data, effect, and mimeType manually. + let placesItems = document.getElementById("PlacesToolbarItems"); + Assert.ok(placesItems, "PlacesToolbarItems should not be null"); + + /** + * Simulates a drop of a bookmarklet URI onto the bookmarks bar. + * + * @param {string} aEffect + * The effect to use for the drop operation: move, copy, or link. + */ + let simulateDragDrop = async function (aEffect) { + info("Simulates drag/drop of a new javascript:URL to the bookmarks"); + await withBookmarksDialog( + true, + function openDialog() { + EventUtils.synthesizeDrop( + toolbar, + placesItems, + [[{ type: "text/x-moz-url", data: BOOKMARKLET_URL }]], + aEffect, + window + ); + }, + async function testNameField(dialogWin) { + info("Checks that there is a javascript:URL in ShowBookmarksDialog"); + + let location = dialogWin.document.getElementById( + "editBMPanel_locationField" + ).value; + + Assert.equal( + location, + BOOKMARKLET_URL, + "Should have opened the ShowBookmarksDialog with the correct bookmarklet url to be bookmarked" + ); + } + ); + + info("Simulates drag/drop of a new URL to the bookmarks"); + let spy = sandbox + .stub(PlacesUIUtils, "showBookmarkDialog") + .returns(Promise.resolve()); + + let promise = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => events.some(({ url }) => url == URL1) + ); + + EventUtils.synthesizeDrop( + toolbar, + placesItems, + [[{ type: "text/x-moz-url", data: URL1 }]], + aEffect, + window + ); + + await promise; + Assert.ok(spy.notCalled, "ShowBookmarksDialog on drop not called for url"); + sandbox.restore(); + }; + + let effects = ["copy", "link"]; + for (let effect of effects) { + await simulateDragDrop(effect); + } + + info("Move of existing bookmark / bookmarklet on toolbar"); + // Clean previous bookmarks to ensure right ids count. + await PlacesUtils.bookmarks.eraseEverything(); + + info("Insert list of bookamrks to have bookmarks (ids) for moving"); + // Ensure bookmarks are visible on the toolbar. + let promiseBookmarksOnToolbar = BrowserTestUtils.waitForMutationCondition( + placesItems, + { childList: true }, + () => placesItems.childNodes.length == 3 + ); + bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + title: "bm1", + url: URL1, + }, + { + title: "bm2", + url: URL2, + }, + { + title: "bookmarklet", + url: BOOKMARKLET_URL, + }, + ], + }); + await promiseBookmarksOnToolbar; + + let spy = sandbox + .stub(PlacesUIUtils, "showBookmarkDialog") + .returns(Promise.resolve()); + + info("Moving existing Bookmark from position [1] to [0] on Toolbar"); + let urlMoveNotification = PlacesTestUtils.waitForNotification( + "bookmark-moved", + events => + events.some( + e => + e.parentGuid === PlacesUtils.bookmarks.toolbarGuid && + e.oldParentGuid === PlacesUtils.bookmarks.toolbarGuid && + e.oldIndex == 1 && + e.index == 0 + ) + ); + + EventUtils.synthesizeDrop( + placesItems, + placesItems.childNodes[0], + [ + [ + { + type: "text/x-moz-place", + data: PlacesUtils.wrapNode( + placesItems.childNodes[1]._placesNode, + "text/x-moz-place" + ), + }, + ], + ], + "move", + window + ); + + await urlMoveNotification; + Assert.ok(spy.notCalled, "ShowBookmarksDialog not called on move for url"); + + info("Moving existing Bookmarklet from position [2] to [1] on Toolbar"); + let bookmarkletMoveNotificatio = PlacesTestUtils.waitForNotification( + "bookmark-moved", + events => + events.some( + e => + e.parentGuid === PlacesUtils.bookmarks.toolbarGuid && + e.oldParentGuid === PlacesUtils.bookmarks.toolbarGuid && + e.oldIndex == 2 && + e.index == 1 + ) + ); + + EventUtils.synthesizeDrop( + toolbar, + placesItems.childNodes[1], + [ + [ + { + type: "text/x-moz-place", + data: PlacesUtils.wrapNode( + placesItems.childNodes[2]._placesNode, + "text/x-moz-place" + ), + }, + ], + ], + "move", + window + ); + + await bookmarkletMoveNotificatio; + Assert.ok(spy.notCalled, "ShowBookmarksDialog not called on move for url"); + sandbox.restore(); +}); diff --git a/browser/components/places/tests/browser/browser_toolbar_drop_multiple_flavors.js b/browser/components/places/tests/browser/browser_toolbar_drop_multiple_flavors.js new file mode 100644 index 0000000000..1f958989cf --- /dev/null +++ b/browser/components/places/tests/browser/browser_toolbar_drop_multiple_flavors.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Check flavors priority when dropping a url/title tuple on the toolbar. + +function getDataForType(url, title, type) { + switch (type) { + case "text/x-moz-url": + return `${url}\n${title}`; + case "text/plain": + return url; + case "text/html": + return `${title}`; + } + throw new Error("Unknown mime type"); +} + +async function testDragDrop(effect, mimeTypes) { + const url = "https://www.mozilla.org/drag_drop_test/"; + const title = "Drag & Drop Test"; + let promiseItemAddedNotification = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => events.some(e => e.url == url) + ); + + // Ensure there's no bookmark initially + let bookmark = await PlacesUtils.bookmarks.fetch({ url }); + Assert.ok(!bookmark, "There should not be a bookmark to the given URL"); + + // We use the toolbar as the drag source, as we just need almost any node + // to simulate the drag. + let toolbar = document.getElementById("PersonalToolbar"); + EventUtils.synthesizeDrop( + toolbar, + document.getElementById("PlacesToolbarItems"), + [mimeTypes.map(type => ({ type, data: getDataForType(url, title, type) }))], + effect, + window + ); + + await promiseItemAddedNotification; + + // Verify that the drop produces exactly one bookmark. + bookmark = await PlacesUtils.bookmarks.fetch({ url }); + Assert.ok(bookmark, "There should be exactly one bookmark"); + Assert.equal(bookmark.url, url, "Check bookmark URL is correct"); + Assert.equal(bookmark.title, title, "Check bookmark title was preserved"); + await PlacesUtils.bookmarks.remove(bookmark); +} + +add_task(async function test() { + registerCleanupFunction(() => PlacesUtils.bookmarks.eraseEverything()); + + // Make sure the bookmarks bar is visible and restore its state on cleanup. + let toolbar = document.getElementById("PersonalToolbar"); + if (toolbar.collapsed) { + await promiseSetToolbarVisibility(toolbar, true); + registerCleanupFunction(function () { + return promiseSetToolbarVisibility(toolbar, false); + }); + } + + // Test a bookmark drop for all of the mime types and effects. + let mimeTypes = ["text/plain", "text/html", "text/x-moz-url"]; + let effects = ["move", "copy", "link"]; + for (let effect of effects) { + await testDragDrop(effect, mimeTypes); + } +}); diff --git a/browser/components/places/tests/browser/browser_toolbar_drop_multiple_with_bookmarklet.js b/browser/components/places/tests/browser/browser_toolbar_drop_multiple_with_bookmarklet.js new file mode 100644 index 0000000000..729e456ca0 --- /dev/null +++ b/browser/components/places/tests/browser/browser_toolbar_drop_multiple_with_bookmarklet.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test() { + // Make sure the bookmarks bar is visible and restore its state on cleanup. + let toolbar = document.getElementById("PersonalToolbar"); + ok(toolbar, "PersonalToolbar should not be null"); + + await PlacesUtils.bookmarks.eraseEverything(); + + if (toolbar.collapsed) { + await promiseSetToolbarVisibility(toolbar, true); + registerCleanupFunction(function () { + return promiseSetToolbarVisibility(toolbar, false); + }); + } + // Setup the node we will use to be dropped. The actual node used does not + // matter because we will set its data, effect, and mimeType manually. + let placesItems = document.getElementById("PlacesToolbarItems"); + Assert.ok(placesItems, "PlacesToolbarItems should not be null"); + let simulateDragDrop = async function (aEffect, aMimeType) { + let urls = [ + "https://example.com/1/", + `javascript: (() => {alert('Hello, World!');})();`, + "https://example.com/2/", + ]; + + let data = urls.map(spec => spec + "\n" + spec).join("\n"); + + EventUtils.synthesizeDrop( + toolbar, + placesItems, + [[{ type: aMimeType, data }]], + aEffect, + window + ); + await PlacesTestUtils.promiseAsyncUpdates(); + for (let url of urls) { + let bookmark = await PlacesUtils.bookmarks.fetch({ url }); + Assert.ok(!bookmark, "There should be no bookmark"); + } + }; + + // Simulate a bookmark drop for all of the mime types and effects. + let mimeType = ["text/x-moz-url"]; + let effects = ["copy", "link"]; + for (let effect of effects) { + await simulateDragDrop(effect, mimeType); + } +}); diff --git a/browser/components/places/tests/browser/browser_toolbar_drop_text.js b/browser/components/places/tests/browser/browser_toolbar_drop_text.js new file mode 100644 index 0000000000..ba7a7127b0 --- /dev/null +++ b/browser/components/places/tests/browser/browser_toolbar_drop_text.js @@ -0,0 +1,136 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test() { + // Make sure the bookmarks bar is visible and restore its state on cleanup. + let toolbar = document.getElementById("PersonalToolbar"); + ok(toolbar, "PersonalToolbar should not be null"); + + if (toolbar.collapsed) { + await promiseSetToolbarVisibility(toolbar, true); + registerCleanupFunction(function () { + return promiseSetToolbarVisibility(toolbar, false); + }); + } + + // Setup the node we will use to be dropped. The actual node used does not + // matter because we will set its data, effect, and mimeType manually. + let placesItems = document.getElementById("PlacesToolbarItems"); + ok(placesItems, "PlacesToolbarItems should not be null"); + Assert.equal( + placesItems.localName, + "scrollbox", + "PlacesToolbarItems should not be null" + ); + + /** + * Simulates a drop of a URI onto the bookmarks bar. + * + * @param {string} aEffect + * The effect to use for the drop operation: move, copy, or link. + * @param {string} aMimeType + * The mime type to use for the drop operation. + */ + let simulateDragDrop = async function (aEffect, aMimeType) { + const url = "http://www.mozilla.org/D1995729-A152-4e30-8329-469B01F30AA7"; + let promiseItemAddedNotification = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => events.some(({ url: eventUrl }) => eventUrl == url) + ); + + // We use the toolbar as the drag source, as we just need almost any node + // to simulate the drag. The actual data for the drop is passed via the + // drag data. Note: The toolbar is used rather than another bookmark node, + // as we need something that is immovable from a places perspective, as this + // forces the move into a copy. + EventUtils.synthesizeDrop( + toolbar, + placesItems, + [[{ type: aMimeType, data: url }]], + aEffect, + window + ); + + await promiseItemAddedNotification; + + // Verify that the drop produces exactly one bookmark. + let bookmark = await PlacesUtils.bookmarks.fetch({ url }); + Assert.ok(bookmark, "There should be exactly one bookmark"); + + await PlacesUtils.bookmarks.remove(bookmark.guid); + + // Verify that we removed the bookmark successfully. + Assert.equal( + await PlacesUtils.bookmarks.fetch({ url }), + null, + "URI should be removed" + ); + }; + + /** + * Simulates a drop of multiple URIs onto the bookmarks bar. + * + * @param {string} aEffect + * The effect to use for the drop operation: move, copy, or link. + * @param {string} aMimeType + * The mime type to use for the drop operation. + */ + let simulateDragDropMultiple = async function (aEffect, aMimeType) { + const urls = [ + "http://www.mozilla.org/C54263C6-A484-46CF-8E2B-FE131586348A", + "http://www.mozilla.org/71381257-61E6-4376-AF7C-BF3C5FD8870D", + "http://www.mozilla.org/091A88BD-5743-4C16-A005-3D2EA3A3B71E", + ]; + let data; + if (aMimeType == "text/x-moz-url") { + data = urls.map(spec => spec + "\n" + spec).join("\n"); + } else { + data = urls.join("\n"); + } + + let promiseItemAddedNotification = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => events.some(({ url }) => url == urls[2]) + ); + + // See notes for EventUtils.synthesizeDrop in simulateDragDrop(). + EventUtils.synthesizeDrop( + toolbar, + placesItems, + [[{ type: aMimeType, data }]], + aEffect, + window + ); + + await promiseItemAddedNotification; + + // Verify that the drop produces exactly one bookmark per each URL. + for (let url of urls) { + let bookmark = await PlacesUtils.bookmarks.fetch({ url }); + Assert.equal( + typeof bookmark, + "object", + "There should be exactly one bookmark" + ); + + await PlacesUtils.bookmarks.remove(bookmark.guid); + + // Verify that we removed the bookmark successfully. + Assert.equal( + await PlacesUtils.bookmarks.fetch({ url }), + null, + "URI should be removed" + ); + } + }; + + // Simulate a bookmark drop for all of the mime types and effects. + let mimeTypes = ["text/plain", "text/x-moz-url"]; + let effects = ["move", "copy", "link"]; + for (let effect of effects) { + for (let mimeType of mimeTypes) { + await simulateDragDrop(effect, mimeType); + await simulateDragDropMultiple(effect, mimeType); + } + } +}); diff --git a/browser/components/places/tests/browser/browser_toolbar_library_open_recent.js b/browser/components/places/tests/browser/browser_toolbar_library_open_recent.js new file mode 100644 index 0000000000..5bedd02a83 --- /dev/null +++ b/browser/components/places/tests/browser/browser_toolbar_library_open_recent.js @@ -0,0 +1,158 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that recently added bookmarks can be opened. + */ + +const BASE_URL = + "http://example.org/browser/browser/components/places/tests/browser/"; +const bookmarkItems = [ + { + url: `${BASE_URL}bookmark_dummy_1.html`, + title: "Custom Title 1", + }, + { + url: `${BASE_URL}bookmark_dummy_2.html`, + title: "Custom Title 2", + }, +]; +let openedTabs = []; + +async function openBookmarksPanelInLibraryToolbarButton() { + let libraryBtn = document.getElementById("library-button"); + libraryBtn.click(); + let libView = document.getElementById("appMenu-libraryView"); + let viewShownPromise = BrowserTestUtils.waitForEvent(libView, "ViewShown"); + await viewShownPromise; + + let bookmarksButton; + await TestUtils.waitForCondition(() => { + bookmarksButton = document.getElementById( + "appMenu-library-bookmarks-button" + ); + return bookmarksButton; + }, "Should have the library bookmarks button"); + bookmarksButton.click(); + + let BookmarksView = document.getElementById("PanelUI-bookmarks"); + let viewRecentPromise = BrowserTestUtils.waitForEvent( + BookmarksView, + "ViewShown" + ); + await viewRecentPromise; +} + +async function openBookmarkedItemInNewTab(itemFromMenu) { + let placesContext = document.getElementById("placesContext"); + let openContextMenuPromise = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(itemFromMenu, { + button: 2, + type: "contextmenu", + }); + await openContextMenuPromise; + info("Opened context menu"); + + let tabCreatedPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true); + + let openInNewTabOption = document.getElementById("placesContext_open:newtab"); + placesContext.activateItem(openInNewTabOption); + info("Click open in new tab"); + + let lastOpenedTab = await tabCreatedPromise; + Assert.equal( + lastOpenedTab.linkedBrowser.currentURI.spec, + itemFromMenu._placesNode.uri, + "Should have opened the correct URI" + ); + openedTabs.push(lastOpenedTab); +} + +async function closeLibraryMenu() { + let libView = document.getElementById("appMenu-libraryView"); + let viewHiddenPromise = BrowserTestUtils.waitForEvent(libView, "ViewHiding"); + EventUtils.synthesizeKey("KEY_Escape", {}, window); + await viewHiddenPromise; +} + +async function closeTabs() { + for (var i = 0; i < openedTabs.length; i++) { + await gBrowser.removeTab(openedTabs[i]); + } + Assert.equal(gBrowser.tabs.length, 1, "Should close all opened tabs"); +} + +async function getRecentlyBookmarkedItems() { + let historyMenu = document.getElementById("panelMenu_bookmarksMenu"); + let items = historyMenu.querySelectorAll("toolbarbutton"); + Assert.ok(items, "Recently bookmarked items should exists"); + + await TestUtils.waitForCondition( + () => items[0].attributes !== "undefined", + "Custom bookmark exists" + ); + + if (items) { + Assert.equal( + items[0]._placesNode.uri, + bookmarkItems[1].url, + "Should match the expected url" + ); + Assert.equal( + items[0].getAttribute("label"), + bookmarkItems[1].title, + "Should be the expected title" + ); + Assert.equal( + items[1]._placesNode.uri, + bookmarkItems[0].url, + "Should match the expected url" + ); + Assert.equal( + items[1].getAttribute("label"), + bookmarkItems[0].title, + "Should be the expected title" + ); + } + return Array.from(items).slice(0, 2); +} + +add_setup(async function () { + let libraryButton = CustomizableUI.getPlacementOfWidget("library-button"); + if (!libraryButton) { + CustomizableUI.addWidgetToArea( + "library-button", + CustomizableUI.AREA_NAVBAR + ); + } + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: bookmarkItems, + }); + + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + CustomizableUI.reset(); + }); +}); + +add_task(async function test_recently_added() { + await openBookmarksPanelInLibraryToolbarButton(); + + let historyItems = await getRecentlyBookmarkedItems(); + + for (let item of historyItems) { + await openBookmarkedItemInNewTab(item); + } + + await closeLibraryMenu(); + + registerCleanupFunction(async () => { + await closeTabs(); + }); +}); diff --git a/browser/components/places/tests/browser/browser_toolbar_other_bookmarks.js b/browser/components/places/tests/browser/browser_toolbar_other_bookmarks.js new file mode 100644 index 0000000000..89171deb1f --- /dev/null +++ b/browser/components/places/tests/browser/browser_toolbar_other_bookmarks.js @@ -0,0 +1,601 @@ +/* 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 mozilla/no-arbitrary-setTimeout */ + +"use strict"; + +const bookmarksInfo = [ + { + title: "firefox", + url: "http://example.com", + }, + { + title: "rules", + url: "http://example.com/2", + }, + { + title: "yo", + url: "http://example.com/2", + }, +]; + +/** + * Test showing the "Other Bookmarks" folder in the bookmarks toolbar. + */ + +// Setup. +add_setup(async function () { + // Disable window occlusion. See bug 1733955 / bug 1779559. + if (navigator.platform.indexOf("Win") == 0) { + await SpecialPowers.pushPrefEnv({ + set: [["widget.windows.window_occlusion_tracking.enabled", false]], + }); + } + + let toolbar = document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + + await setupBookmarksToolbar(); + + // Cleanup. + registerCleanupFunction(async () => { + // Collapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, false); + } + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +// Test the "Other Bookmarks" folder is shown in the toolbar when +// bookmarks are stored under that folder. +add_task(async function testShowingOtherBookmarksInToolbar() { + info("Check the initial state of the Other Bookmarks folder."); + let win = await BrowserTestUtils.openNewBrowserWindow(); + await setupBookmarksToolbar(win); + ok( + !win.document.getElementById("OtherBookmarks"), + "Shouldn't have an Other Bookmarks button." + ); + await BrowserTestUtils.closeWindow(win); + + info("Check visibility of an empty Other Bookmarks folder."); + await testIsOtherBookmarksHidden(true); + + info("Ensure folder appears in toolbar when a new bookmark is added."); + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: bookmarksInfo, + }); + await testIsOtherBookmarksHidden(false); + + info("Ensure folder disappears from toolbar when no bookmarks are present."); + await PlacesUtils.bookmarks.remove(bookmarks); + await testIsOtherBookmarksHidden(true); +}); + +// Test that folder visibility is correct when moving bookmarks to an empty +// "Other Bookmarks" folder and vice versa. +add_task(async function testOtherBookmarksVisibilityWhenMovingBookmarks() { + info("Add bookmarks to Bookmarks Toolbar."); + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: bookmarksInfo, + }); + await testIsOtherBookmarksHidden(true); + + info("Move toolbar bookmarks to Other Bookmarks folder."); + await PlacesUtils.bookmarks.moveToFolder( + bookmarks.map(b => b.guid), + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.DEFAULT_INDEX + ); + await testIsOtherBookmarksHidden(false); + + info("Move bookmarks from Other Bookmarks back to the toolbar."); + await PlacesUtils.bookmarks.moveToFolder( + bookmarks.map(b => b.guid), + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.DEFAULT_INDEX + ); + await testIsOtherBookmarksHidden(true); +}); + +// Test OtherBookmarksPopup in toolbar. +add_task(async function testOtherBookmarksMenuPopup() { + info("Add bookmarks to Other Bookmarks folder."); + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: bookmarksInfo, + }); + + await testIsOtherBookmarksHidden(false); + + info("Check the popup menu has correct number of children."); + await openMenuPopup("#OtherBookmarksPopup", "#OtherBookmarks"); + testNumberOfMenuPopupChildren("#OtherBookmarksPopup", 3); + await closeMenuPopup("#OtherBookmarksPopup"); + + info("Remove a bookmark."); + await PlacesUtils.bookmarks.remove(bookmarks[0]); + + await openMenuPopup("#OtherBookmarksPopup", "#OtherBookmarks"); + testNumberOfMenuPopupChildren("#OtherBookmarksPopup", 2); + await closeMenuPopup("#OtherBookmarksPopup"); +}); + +// Test that folders in the Other Bookmarks folder expand +add_task(async function testFolderPopup() { + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "example", + url: "http://example.com/3", + }, + ], + }, + ], + }); + + info("Check for popup showing event when folder menuitem is selected."); + await openMenuPopup("#OtherBookmarksPopup", "#OtherBookmarks"); + await openMenuPopup( + "#OtherBookmarksPopup menu menupopup", + "#OtherBookmarksPopup menu" + ); + ok(true, "Folder menu stored in Other Bookmarks expands."); + testNumberOfMenuPopupChildren("#OtherBookmarksPopup menu menupopup", 1); + await closeMenuPopup("#OtherBookmarksPopup"); +}); + +add_task(async function testOnlyShowOtherFolderInBookmarksToolbar() { + await setupBookmarksToolbar(); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: bookmarksInfo, + }); + + await testIsOtherBookmarksHidden(false); + + // Test that moving the personal-bookmarks widget out of the + // Bookmarks Toolbar will hide the "Other Bookmarks" folder. + let widgetId = "personal-bookmarks"; + CustomizableUI.addWidgetToArea(widgetId, CustomizableUI.AREA_NAVBAR); + await testIsOtherBookmarksHidden(true); + + CustomizableUI.reset(); + await testIsOtherBookmarksHidden(false); +}); + +// Test that the menu popup updates when menu items are deleted from it while +// it's open. +add_task(async function testDeletingMenuItems() { + await setupBookmarksToolbar(); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: bookmarksInfo, + }); + + await testIsOtherBookmarksHidden(false); + + await openMenuPopup("#OtherBookmarksPopup", "#OtherBookmarks"); + testNumberOfMenuPopupChildren("#OtherBookmarksPopup", 3); + + info("Open context menu for popup."); + let placesContext = document.getElementById("placesContext"); + let popupEventPromise = BrowserTestUtils.waitForPopupEvent( + placesContext, + "shown" + ); + let menuitem = document.querySelector("#OtherBookmarksPopup menuitem"); + EventUtils.synthesizeMouseAtCenter(menuitem, { type: "contextmenu" }); + await popupEventPromise; + + info("Delete bookmark menu item from popup."); + let deleteMenuBookmark = document.getElementById( + "placesContext_deleteBookmark" + ); + placesContext.activateItem(deleteMenuBookmark); + + await TestUtils.waitForCondition(() => { + let popup = document.querySelector("#OtherBookmarksPopup"); + let items = popup.querySelectorAll("menuitem"); + return items.length === 2; + }, "Failed to delete bookmark menuitem. Expected 2 menu items after deletion."); + ok(true, "Menu item was removed from the popup."); + await closeMenuPopup("#OtherBookmarksPopup"); +}); + +add_task(async function no_errors_when_bookmarks_placed_in_palette() { + CustomizableUI.removeWidgetFromArea("personal-bookmarks"); + + let consoleErrors = 0; + + let errorListener = { + observe(error) { + ok(false, `${error.message}, ${error.stack}, ${JSON.stringify(error)}`); + consoleErrors++; + }, + }; + Services.console.registerListener(errorListener); + + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: bookmarksInfo, + }); + is(consoleErrors, 0, "There should be no console errors"); + + Services.console.unregisterListener(errorListener); + await PlacesUtils.bookmarks.remove(bookmarks); + CustomizableUI.reset(); +}); + +// Test "Show Other Bookmarks" menu item visibility in toolbar context menu. +add_task(async function testShowingOtherBookmarksContextMenuItem() { + await setupBookmarksToolbar(); + + info("Add bookmark to Other Bookmarks."); + let bookmark = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [{ title: "firefox", url: "http://example.com" }], + }); + + info("'Show Other Bookmarks' menu item should be checked by default."); + await testOtherBookmarksCheckedState(true); + + info("Toggle off showing the Other Bookmarks folder."); + await selectShowOtherBookmarksMenuItem(); + await testOtherBookmarksCheckedState(false); + await testIsOtherBookmarksHidden(true); + + info("Toggle on showing the Other Bookmarks folder."); + await selectShowOtherBookmarksMenuItem(); + await testOtherBookmarksCheckedState(true); + await testIsOtherBookmarksHidden(false); + + info( + "Ensure 'Show Other Bookmarks' isn't shown when Other Bookmarks is empty." + ); + await PlacesUtils.bookmarks.remove(bookmark); + await testIsOtherBookmarksMenuItemEnabled(false); + + info("Add a bookmark to the empty Other Bookmarks folder."); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [{ title: "firefox", url: "http://example.com" }], + }); + await testIsOtherBookmarksMenuItemEnabled(true); + + info( + "Ensure that displaying Other Bookmarks is consistent across separate windows." + ); + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + await TestUtils.waitForCondition(() => { + let otherBookmarks = newWin.document.getElementById("OtherBookmarks"); + return otherBookmarks && !otherBookmarks.hidden; + }, "Other Bookmarks folder failed to show in other window."); + + info("Hide the Other Bookmarks folder from the original window."); + await selectShowOtherBookmarksMenuItem(); + + await TestUtils.waitForCondition(() => { + let otherBookmarks = newWin.document.getElementById("OtherBookmarks"); + return !otherBookmarks || otherBookmarks.hidden; + }, "Other Bookmarks folder failed to be hidden in other window."); + ok(true, "Other Bookmarks was successfully hidden in other window."); + + info("Show the Other Bookmarks folder from the original window."); + await selectShowOtherBookmarksMenuItem(); + + await TestUtils.waitForCondition(() => { + let otherBookmarks = newWin.document.getElementById("OtherBookmarks"); + return otherBookmarks && !otherBookmarks.hidden; + }, "Other Bookmarks folder failed to be shown in other window."); + ok(true, "Other Bookmarks was successfully shown in other window."); + + await BrowserTestUtils.closeWindow(newWin); +}); + +// Test 'Show Other Bookmarks' isn't shown when pref is false. +add_task(async function showOtherBookmarksMenuItemPrefDisabled() { + await setupBookmarksToolbar(); + await testIsOtherBookmarksMenuItemEnabled(false); +}); + +// Test that node visibility for toolbar overflow is consisten when the "Other Bookmarks" +// folder is shown/hidden. +add_task(async function testOtherBookmarksToolbarOverFlow() { + await setupBookmarksToolbar(); + + info( + "Ensure that visible nodes when showing/hiding Other Bookmarks is consistent across separate windows." + ); + // Add bookmarks to other bookmarks + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [{ title: "firefox", url: "http://example.com" }], + }); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + // Add bookmarks to the toolbar + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: Array(100) + .fill("") + .map((_, i) => ({ title: `test ${i}`, url: `http:example.com/${i}` })), + }); + + info("Hide the Other Bookmarks folder from the original window."); + await selectShowOtherBookmarksMenuItem("#OtherBookmarks"); + await BrowserTestUtils.waitForEvent( + document.getElementById("PersonalToolbar"), + "BookmarksToolbarVisibilityUpdated" + ); + ok(true, "Nodes successfully updated for both windows."); + await testUpdatedNodeVisibility(newWin); + + info("Show the Other Bookmarks folder from the original window."); + await selectShowOtherBookmarksMenuItem("#PlacesChevron"); + await BrowserTestUtils.waitForEvent( + document.getElementById("PersonalToolbar"), + "BookmarksToolbarVisibilityUpdated" + ); + ok(true, "Nodes successfully updated for both windows."); + await testUpdatedNodeVisibility(newWin); + + await BrowserTestUtils.closeWindow(newWin); +}); + +/** + * Tests whether or not the "Other Bookmarks" folder is visible. + * + * @param {boolean} expected + * The expected state of the Other Bookmarks folder. There are 3: + * - the folder node isn't initialized and is therefore not visible, + * - the folder node is initialized and is hidden + * - the folder node is initialized and is visible + */ +async function testIsOtherBookmarksHidden(expected) { + info("Test whether or not the 'Other Bookmarks' folder is visible."); + + // Ensure the toolbar is visible. + let toolbar = document.getElementById("PersonalToolbar"); + await promiseSetToolbarVisibility(toolbar, true); + + let otherBookmarks = document.getElementById("OtherBookmarks"); + + await TestUtils.waitForCondition(() => { + otherBookmarks = document.getElementById("OtherBookmarks"); + let isHidden = !otherBookmarks || otherBookmarks.hidden; + return isHidden === expected; + }, "Other Bookmarks folder failed to change hidden state."); + + ok(true, `Other Bookmarks folder "hidden" state should be ${expected}.`); +} + +/** + * Tests number of menu items in Other Bookmarks popup. + * + * @param {string} selector + * The selector for getting the menupopup element we want to test. + * @param {number} expected + * The expected number of menuitem elements inside the menupopup. + */ +function testNumberOfMenuPopupChildren(selector, expected) { + let popup = document.querySelector(selector); + let items = popup.querySelectorAll("menuitem"); + + is( + items.length, + expected, + `Number of menu items for ${selector} should be ${expected}.` + ); +} + +/** + * Test helper for checking the 'checked' state of the "Show Other Bookmarks" menu item + * after selecting it from the context menu. + * + * @param {boolean} expectedCheckedState + * Whether or not the menu item is checked. + */ +async function testOtherBookmarksCheckedState(expectedCheckedState) { + info("Check 'Show Other Bookmarks' menu item state"); + await openToolbarContextMenu(); + + let otherBookmarksMenuItem = document.querySelector( + "#show-other-bookmarks_PersonalToolbar" + ); + + is( + otherBookmarksMenuItem.getAttribute("checked"), + `${expectedCheckedState}`, + `Other Bookmarks item's checked state should be ${expectedCheckedState}` + ); + + await closeToolbarContextMenu(); +} + +/** + * Test helper for checking whether or not the 'Show Other Bookmarks' menu item + * is enabled in the toolbar's context menu. + * + * @param {boolean} expected + * Whether or not the menu item is enabled in the toolbar conext menu. + */ +async function testIsOtherBookmarksMenuItemEnabled(expected) { + await openToolbarContextMenu(); + + let otherBookmarksMenuItem = document.querySelector( + "#show-other-bookmarks_PersonalToolbar" + ); + + is( + !otherBookmarksMenuItem.disabled, + expected, + "'Show Other Bookmarks' menu item appearance state is correct." + ); + + await closeToolbarContextMenu(); +} + +/** + * Helper for opening a menu popup. + * + * @param {string} popupSelector + * The selector for the menupopup element we want to open. + * @param {string} targetSelector + * The selector for the element with the popup showing event. + */ +async function openMenuPopup(popupSelector, targetSelector) { + let popup = document.querySelector(popupSelector); + let target = document.querySelector(targetSelector); + + EventUtils.synthesizeMouseAtCenter(target, {}); + + await BrowserTestUtils.waitForPopupEvent(popup, "shown"); +} + +/** + * Helper for closing a menu popup. + * + * @param {string} popupSelector + * The selector for the menupopup element we want to close. + */ +async function closeMenuPopup(popupSelector) { + let popup = document.querySelector(popupSelector); + + info("Closing menu popup."); + popup.hidePopup(); + await BrowserTestUtils.waitForPopupEvent(popup, "hidden"); +} + +/** + * Helper for opening the toolbar context menu. + * + * @param {string} toolbarSelector + * Optional. The selector for the toolbar context menu. + * Defaults to #PlacesToolbarItems. + */ +async function openToolbarContextMenu(toolbarSelector = "#PlacesToolbarItems") { + let contextMenu = document.getElementById("placesContext"); + let toolbar = document.querySelector(toolbarSelector); + let openToolbarContextMenuPromise = BrowserTestUtils.waitForPopupEvent( + contextMenu, + "shown" + ); + + // Use the end of the toolbar because the beginning (and even middle, on + // some resolutions) might be occluded by the empty toolbar message, which + // has a different context menu. + let bounds = toolbar.getBoundingClientRect(); + EventUtils.synthesizeMouse(toolbar, bounds.width - 5, 5, { + type: "contextmenu", + }); + + await openToolbarContextMenuPromise; +} + +/** + * Helper for closing the toolbar context menu. + */ +async function closeToolbarContextMenu() { + let contextMenu = document.getElementById("placesContext"); + let closeToolbarContextMenuPromise = BrowserTestUtils.waitForPopupEvent( + contextMenu, + "hidden" + ); + contextMenu.hidePopup(); + await closeToolbarContextMenuPromise; +} + +/** + * Helper for setting up the bookmarks toolbar state. This ensures the beginning + * of a task will always have the bookmark toolbar in a state that makes the + * Other Bookmarks folder testable. + * + * @param {object} [win] + * The window object to use. + */ +async function setupBookmarksToolbar(win = window) { + let toolbar = win.document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + + // Uncollapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + } + + await PlacesUtils.bookmarks.eraseEverything(); +} + +/** + * Helper for selecting the "Show Other Bookmarks" menu item from the bookmarks + * toolbar context menu. + * + * @param {string} selector + * Optional. The selector for the node that triggers showing the + * "Show Other Bookmarks" context menu item in the toolbar. + * Defaults to #PlacesToolbarItem when `openToolbarContextMenu` is + * called. + */ +async function selectShowOtherBookmarksMenuItem(selector) { + info("Select 'Show Other Bookmarks' menu item"); + await openToolbarContextMenu(selector); + + let otherBookmarksMenuItem = document.querySelector( + "#show-other-bookmarks_PersonalToolbar" + ); + let contextMenu = document.getElementById("placesContext"); + + contextMenu.activateItem(otherBookmarksMenuItem); + + await BrowserTestUtils.waitForPopupEvent(contextMenu, "hidden"); + await closeToolbarContextMenu(); +} + +/** + * Test helper for node visibility in the bookmarks toolbar between two windows. + * + * @param {Window} otherWin + * The other window whose toolbar items we want to compare with. + */ +function testUpdatedNodeVisibility(otherWin) { + // Get visible toolbar nodes for both the current and other windows. + let toolbarItems = document.getElementById("PlacesToolbarItems"); + let currentVisibleNodes = []; + + for (let node of toolbarItems.children) { + if (node.style.visibility === "visible") { + currentVisibleNodes.push(node); + } + } + + let otherToolbarItems = + otherWin.document.getElementById("PlacesToolbarItems"); + let otherVisibleNodes = []; + + for (let node of otherToolbarItems.children) { + if (node.style.visibility === "visible") { + otherVisibleNodes.push(node); + } + } + + let lastIdx = otherVisibleNodes.length - 1; + + is( + currentVisibleNodes[lastIdx]?.bookmarkGuid, + otherVisibleNodes[lastIdx]?.bookmarkGuid, + "Last visible toolbar bookmark is the same in both windows." + ); +} diff --git a/browser/components/places/tests/browser/browser_toolbar_overflow.js b/browser/components/places/tests/browser/browser_toolbar_overflow.js new file mode 100644 index 0000000000..3f16c2a126 --- /dev/null +++ b/browser/components/places/tests/browser/browser_toolbar_overflow.js @@ -0,0 +1,436 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests the bookmarks toolbar overflow. + */ + +var gToolbar = document.getElementById("PersonalToolbar"); +var gChevron = document.getElementById("PlacesChevron"); + +const BOOKMARKS_COUNT = 250; + +add_setup(async function () { + let wasCollapsed = gToolbar.collapsed; + await PlacesUtils.bookmarks.eraseEverything(); + + // Add bookmarks. + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: Array(BOOKMARKS_COUNT) + .fill("") + .map((_, i) => ({ url: `http://test.places.y${i}/` })), + }); + + // Toggle the bookmarks toolbar so that we start from a stable situation and + // are not affected by all bookmarks removal. + await toggleToolbar(false); + await toggleToolbar(true); + + registerCleanupFunction(async () => { + if (wasCollapsed) { + await toggleToolbar(false); + } + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function test_overflow() { + // Check that the overflow chevron is visible. + Assert.ok(!gChevron.collapsed, "The overflow chevron should be visible"); + let children = getPlacesChildren(); + Assert.ok( + children.length < BOOKMARKS_COUNT, + "Not all the nodes should be built by default" + ); + let visibleNodes = []; + for (let node of children) { + if (getComputedStyle(node).visibility == "visible") { + visibleNodes.push(node); + } + } + Assert.ok( + visibleNodes.length < children.length, + `The number of visible nodes (${visibleNodes.length}) should be smaller than the number of built nodes (${children.length})` + ); + + await test_index( + "Node at the last visible index", + visibleNodes.length - 1, + "visible" + ); + await test_index( + "Node at the first invisible index", + visibleNodes.length, + "hidden" + ); + await test_index("First non-built node", children.length, undefined); + await test_index("Later non-built node", children.length + 1, undefined); + + await test_move_index( + "Move node from last visible to first hidden", + visibleNodes.length - 1, + visibleNodes.length, + "visible", + "hidden" + ); + await test_move_index( + "Move node from fist visible to last built", + 0, + children.length - 1, + "visible", + "hidden" + ); + await test_move_index( + "Move node from fist visible to first non built", + 0, + children.length, + "visible", + undefined + ); +}); + +add_task(async function test_separator_first() { + await toggleToolbar(false); + // Check that if a separator is the first node, we still calculate overflow + // properly. + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + index: 0, + }); + await toggleToolbar(true, 2); + + let children = getPlacesChildren(); + Assert.greater(children.length, 2, "Multiple elements are visible"); + Assert.equal( + children[1]._placesNode.uri, + "http://test.places.y0/", + "Found the first bookmark" + ); + Assert.equal( + getComputedStyle(children[1]).visibility, + "visible", + "The first bookmark is visible" + ); + + await PlacesUtils.bookmarks.remove(bm); +}); + +add_task(async function test_newWindow_noOverflow() { + info( + "Check toolbar in a new widow when it was already visible and not overflowed" + ); + Assert.ok(!gToolbar.collapsed, "Toolbar is not collapsed in original window"); + await PlacesUtils.bookmarks.eraseEverything(); + // Add a single bookmark. + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://toolbar.overflow/", + title: "Example", + }); + // Add a favicon for the bookmark. + let favicon = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAA" + + "AAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="; + await PlacesTestUtils.addFavicons( + new Map([["http://toolbar.overflow/", favicon]]) + ); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + try { + let toolbar = win.document.getElementById("PersonalToolbar"); + Assert.ok(!toolbar.collapsed, "The toolbar is not collapsed"); + let content = win.document.getElementById("PlacesToolbarItems"); + await TestUtils.waitForCondition(() => { + return ( + content.children.length == 1 && + content.children[0].hasAttribute("image") + ); + }); + let chevron = win.document.getElementById("PlacesChevron"); + Assert.ok(chevron.collapsed, "The chevron should be collapsed"); + } finally { + await BrowserTestUtils.closeWindow(win); + } +}); + +async function test_index(desc, index, expected) { + info(desc); + let children = getPlacesChildren(); + let originalLen = children.length; + let nodeExisted = children.length > index; + let previousNodeIsVisible = + nodeExisted && + getComputedStyle(children[index - 1]).visibility == "visible"; + let promise = promiseUpdateVisibility( + expected == "visible" || previousNodeIsVisible + ); + + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://test.places.added/", + index, + }); + Assert.equal(bm.index, index, "Sanity check the bookmark index"); + await promise; + children = getPlacesChildren(); + + if (!expected) { + Assert.ok( + children.length <= index, + "The new node should not have been added" + ); + } else { + Assert.equal( + children[index]._placesNode.bookmarkGuid, + bm.guid, + "Found the added bookmark at the expected position" + ); + Assert.equal( + getComputedStyle(children[index]).visibility, + expected, + `The bookmark node should be ${expected}` + ); + } + Assert.equal( + children.length, + originalLen, + "Number of built nodes should stay the same" + ); + + info("Remove the node"); + promise = promiseUpdateVisibility(expected == "visible"); + + await PlacesUtils.bookmarks.remove(bm); + await promise; + children = getPlacesChildren(); + + if (expected && nodeExisted) { + Assert.equal( + children[index]._placesNode.uri, + `http://test.places.y${index}/`, + "Found the previous bookmark at the expected position" + ); + Assert.equal( + getComputedStyle(children[index]).visibility, + expected, + `The bookmark node should be ${expected}` + ); + } + Assert.equal( + children.length, + originalLen, + "Number of built nodes should stay the same" + ); +} + +async function test_move_index(desc, fromIndex, toIndex, original, expected) { + info(desc); + let children = getPlacesChildren(); + let originalLen = children.length; + let movedGuid = children[fromIndex]._placesNode.bookmarkGuid; + let existingGuid = children[toIndex] + ? children[toIndex]._placesNode.bookmarkGuid + : null; + let existingIndex = fromIndex < toIndex ? toIndex - 1 : toIndex + 1; + + Assert.equal( + getComputedStyle(children[fromIndex]).visibility, + original, + `The bookmark node should be ${original}` + ); + let promise = promiseUpdateVisibility( + original == "visible" || expected == "visible" + ); + + await PlacesUtils.bookmarks.update({ + guid: movedGuid, + index: toIndex, + }); + await promise; + children = getPlacesChildren(); + + if (!expected) { + Assert.ok( + children.length <= toIndex, + "Node in the new position is not expected" + ); + Assert.ok( + children[originalLen - 1], + "We should keep number of built nodes consistent" + ); + } else { + Assert.equal( + children[toIndex]._placesNode.bookmarkGuid, + movedGuid, + "Found the moved bookmark at the expected position" + ); + Assert.equal( + getComputedStyle(children[toIndex]).visibility, + expected, + `The destination bookmark node should be ${expected}` + ); + } + Assert.equal( + getComputedStyle(children[fromIndex]).visibility, + original, + `The origin bookmark node should be ${original}` + ); + if (existingGuid) { + Assert.equal( + children[existingIndex]._placesNode.bookmarkGuid, + existingGuid, + "Found the pushed away bookmark at the expected position" + ); + } + Assert.equal( + children.length, + originalLen, + "Number of built nodes should stay the same" + ); + + info("Moving back the node"); + promise = promiseUpdateVisibility( + original == "visible" || expected == "visible" + ); + + await PlacesUtils.bookmarks.update({ + guid: movedGuid, + index: fromIndex, + }); + await promise; + children = getPlacesChildren(); + + Assert.equal( + children[fromIndex]._placesNode.bookmarkGuid, + movedGuid, + "Found the moved bookmark at the expected position" + ); + if (expected) { + Assert.equal( + getComputedStyle(children[toIndex]).visibility, + expected, + `The bookmark node should be ${expected}` + ); + } + Assert.equal( + getComputedStyle(children[fromIndex]).visibility, + original, + `The bookmark node should be ${original}` + ); + if (existingGuid) { + Assert.equal( + children[toIndex]._placesNode.bookmarkGuid, + existingGuid, + "Found the pushed away bookmark at the expected position" + ); + } + Assert.equal( + children.length, + originalLen, + "Number of built nodes should stay the same" + ); +} + +add_task(async function test_separator_first() { + // Check that if there are only separators, we still show nodes properly. + await toggleToolbar(false); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + index: 0, + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + index: 0, + }); + await toggleToolbar(true, 2); + + let children = getPlacesChildren(); + Assert.equal(children.length, 2, "The expected elements are visible"); + Assert.equal( + getComputedStyle(children[0]).visibility, + "visible", + "The first bookmark is visible" + ); + Assert.equal( + getComputedStyle(children[1]).visibility, + "visible", + "The second bookmark is visible" + ); + + await toggleToolbar(false); + await PlacesUtils.bookmarks.eraseEverything(); +}); + +/** + * If the passed-in condition is fulfilled, awaits for the toolbar nodes + * visibility to have been updated. + * + * @param {boolean} [condition] Awaits for visibility only if this condition is true. + * @returns {Promise} resolved when the condition is not fulfilled or the + * visilibily update happened. + */ +function promiseUpdateVisibility(condition = true) { + if (condition) { + return BrowserTestUtils.waitForEvent( + gToolbar, + "BookmarksToolbarVisibilityUpdated" + ); + } + return Promise.resolve(); +} + +/** + * Returns an array of toolbar children that are Places nodes, ignoring things + * like the chevron or other additional buttons. + * + * @returns {Array} An array of Places element nodes. + */ +function getPlacesChildren() { + return Array.prototype.filter.call( + document.getElementById("PlacesToolbarItems").children, + c => c._placesNode?.itemId + ); +} + +/** + * Toggles the toolbar on or off. + * + * @param {boolean} show Whether to show or hide the toolbar. + * @param {number} [expectedMinChildCount] Optional number of Places nodes that + * should be visible on the toolbar. + */ +async function toggleToolbar(show, expectedMinChildCount = 0) { + let promiseReady = Promise.resolve(); + if (show) { + promiseReady = promiseUpdateVisibility(); + } + + await promiseSetToolbarVisibility(gToolbar, show); + await promiseReady; + + if (show) { + if (getPlacesChildren().length < expectedMinChildCount) { + await new Promise(resolve => { + info("Waiting for bookmark elements to appear"); + let mut = new MutationObserver(mutations => { + let children = getPlacesChildren(); + info(`${children.length} bookmark elements appeared`); + if (children.length >= expectedMinChildCount) { + resolve(); + mut.disconnect(); + } + }); + mut.observe(document.getElementById("PlacesToolbarItems"), { + childList: true, + }); + }); + } + } +} diff --git a/browser/components/places/tests/browser/browser_toolbarbutton_menu_context.js b/browser/components/places/tests/browser/browser_toolbarbutton_menu_context.js new file mode 100644 index 0000000000..a87f26caab --- /dev/null +++ b/browser/components/places/tests/browser/browser_toolbarbutton_menu_context.js @@ -0,0 +1,72 @@ +CustomizableUI.addWidgetToArea( + "bookmarks-menu-button", + CustomizableUI.AREA_NAVBAR, + 4 +); +var bookmarksMenuButton = document.getElementById("bookmarks-menu-button"); +var BMB_menuPopup = document.getElementById("BMB_bookmarksPopup"); +var BMB_showAllBookmarks = document.getElementById("BMB_bookmarksShowAll"); +var contextMenu = document.getElementById("placesContext"); +var newBookmarkItem = document.getElementById("placesContext_new:bookmark"); + +waitForExplicitFinish(); +add_task(async function testPopup() { + info("Checking popup context menu before moving the bookmarks button"); + await checkPopupContextMenu(); + let pos = CustomizableUI.getPlacementOfWidget( + "bookmarks-menu-button" + ).position; + let target = CustomizableUI.AREA_FIXED_OVERFLOW_PANEL; + CustomizableUI.addWidgetToArea("bookmarks-menu-button", target); + CustomizableUI.addWidgetToArea( + "bookmarks-menu-button", + CustomizableUI.AREA_NAVBAR, + pos + ); + info("Checking popup context menu after moving the bookmarks button"); + await checkPopupContextMenu(); + CustomizableUI.reset(); +}); + +async function checkPopupContextMenu() { + let clickTarget = bookmarksMenuButton; + BMB_menuPopup.setAttribute("style", "transition: none;"); + let popupShownPromise = onPopupEvent(BMB_menuPopup, "shown"); + EventUtils.synthesizeMouseAtCenter(clickTarget, {}); + info("Waiting for bookmarks menu to be shown."); + await popupShownPromise; + let contextMenuShownPromise = onPopupEvent(contextMenu, "shown"); + EventUtils.synthesizeMouseAtCenter(BMB_showAllBookmarks, { + type: "contextmenu", + button: 2, + }); + info("Waiting for context menu on bookmarks menu to be shown."); + await contextMenuShownPromise; + ok( + !newBookmarkItem.hasAttribute("disabled"), + "New bookmark item shouldn't be disabled" + ); + let contextMenuHiddenPromise = onPopupEvent(contextMenu, "hidden"); + contextMenu.hidePopup(); + BMB_menuPopup.removeAttribute("style"); + info("Waiting for context menu on bookmarks menu to be hidden."); + await contextMenuHiddenPromise; + let popupHiddenPromise = onPopupEvent(BMB_menuPopup, "hidden"); + // Can't use synthesizeMouseAtCenter because the dropdown panel is in the way + EventUtils.synthesizeKey("KEY_Escape"); + info("Waiting for bookmarks menu to be hidden."); + await popupHiddenPromise; +} + +function onPopupEvent(popup, evt) { + let fullEvent = "popup" + evt; + return new Promise(resolve => { + let onPopupHandler = e => { + if (e.target == popup) { + popup.removeEventListener(fullEvent, onPopupHandler); + resolve(); + } + }; + popup.addEventListener(fullEvent, onPopupHandler); + }); +} 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 new file mode 100644 index 0000000000..02720cfa2e --- /dev/null +++ b/browser/components/places/tests/browser/browser_toolbarbutton_menu_show_in_folder.js @@ -0,0 +1,92 @@ +/** + * This test checks that the Show in Folder context menu item in the + * bookmarks menu under the app menu actually shows the bookmark in + * its folder location in the sidebar. + */ +"use strict"; + +const TEST_PARENT_FOLDER = "The Parent Folder"; +const TEST_URL = "https://example.com/"; +const TEST_TITLE = "Test Bookmark"; + +let appMenuButton = document.getElementById("PanelUI-menu-button"); +let bookmarksAppMenu = document.getElementById("PanelUI-bookmarks"); +let sidebarWasAlreadyOpen = SidebarUI.isOpen; + +const { CustomizableUITestUtils } = ChromeUtils.importESModule( + "resource://testing-common/CustomizableUITestUtils.sys.mjs" +); +let gCUITestUtils = new CustomizableUITestUtils(window); + +add_task(async function toolbarBookmarkShowInFolder() { + let parentFolder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: TEST_PARENT_FOLDER, + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: parentFolder.guid, + url: TEST_URL, + title: TEST_TITLE, + }); + + // Open app menu and select bookmarks view + await gCUITestUtils.openMainMenu(); + let appMenuBookmarks = document.getElementById("appMenu-bookmarks-button"); + appMenuBookmarks.click(); + let bookmarksView = document.getElementById("PanelUI-bookmarks"); + let bmViewPromise = BrowserTestUtils.waitForEvent(bookmarksView, "ViewShown"); + await bmViewPromise; + + // Find the test bookmark and open the context menu on it + let list = document.getElementById("panelMenu_bookmarksMenu"); + let listItem = [...list.children].find(node => node.label == TEST_TITLE); + let placesContext = document.getElementById("placesContext"); + let contextPromise = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(listItem, { + button: 2, + type: "contextmenu", + }); + await contextPromise; + + // Select Show in Folder and wait for the sidebar to show up + let sidebarShownPromise = BrowserTestUtils.waitForEvent( + window, + "SidebarShown" + ); + placesContext.activateItem( + document.getElementById("placesContext_showInFolder") + ); + await sidebarShownPromise; + + // Get the sidebar tree element and find the selected node + let sidebar = document.getElementById("sidebar"); + let tree = sidebar.contentDocument.getElementById("bookmarks-view"); + let treeNode = tree.selectedNode; + + Assert.equal( + treeNode.parent.bookmarkGuid, + parentFolder.guid, + "Containing folder node is correct" + ); + Assert.equal( + treeNode.title, + listItem.label, + "The bookmark title matches selected node" + ); + Assert.equal( + treeNode.uri, + TEST_URL, + "The bookmark URL matches selected node" + ); + + // Cleanup + await PlacesUtils.bookmarks.eraseEverything(); + if (!sidebarWasAlreadyOpen) { + SidebarUI.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 new file mode 100644 index 0000000000..1799a9665b --- /dev/null +++ b/browser/components/places/tests/browser/browser_views_iconsupdate.js @@ -0,0 +1,132 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +/** + * Tests Places views (toolbar, tree) for icons update. + * The menu is not tested since it uses the same code as the toolbar. + */ + +add_task(async function () { + const PAGE_URI = NetUtil.newURI("http://places.test/"); + const ICON_URI = NetUtil.newURI( + "http://mochi.test:8888/browser/browser/components/places/tests/browser/favicon-normal16.png" + ); + + info("Uncollapse the personal toolbar if needed"); + let toolbar = document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + registerCleanupFunction(async function () { + await promiseSetToolbarVisibility(toolbar, false); + }); + } + + info("Open the bookmarks sidebar"); + let sidebar = document.getElementById("sidebar"); + let promiseSidebarLoaded = new Promise(resolve => { + sidebar.addEventListener("load", resolve, { capture: true, once: true }); + }); + SidebarUI.show("viewBookmarksSidebar"); + registerCleanupFunction(() => { + SidebarUI.hide(); + }); + await promiseSidebarLoaded; + + // Add a bookmark to the bookmarks toolbar. + let bm = await PlacesUtils.bookmarks.insert({ + url: PAGE_URI, + title: "test icon", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.remove(bm); + }); + + // The icon is read asynchronously from the network, we don't have an easy way + // to wait for that. + await new Promise(resolve => { + setTimeout(resolve, 3000); + }); + + let toolbarElt = getNodeForToolbarItem(bm.guid); + let toolbarShot1 = TestUtils.screenshotArea(toolbarElt, window); + let sidebarRect = await getRectForSidebarItem(bm.guid); + let sidebarShot1 = TestUtils.screenshotArea(sidebarRect, window); + + info("Toolbar: " + toolbarShot1); + info("Sidebar: " + sidebarShot1); + + let iconURI = await new Promise(resolve => { + PlacesUtils.favicons.setAndFetchFaviconForPage( + PAGE_URI, + ICON_URI, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + resolve, + Services.scriptSecurityManager.getSystemPrincipal() + ); + }); + Assert.ok(iconURI.equals(ICON_URI), "Succesfully set the icon"); + + // The icon is read asynchronously from the network, we don't have an easy way + // to wait for that, thus we must poll. + await TestUtils.waitForCondition(() => { + // Assert.notEqual truncates the strings, so it is unusable here for failure + // debugging purposes. + let toolbarShot2 = TestUtils.screenshotArea(toolbarElt, window); + if (toolbarShot1 != toolbarShot2) { + info("After toolbar: " + toolbarShot2); + } + return toolbarShot1 != toolbarShot2; + }, "Waiting for the toolbar icon to update"); + + await TestUtils.waitForCondition(() => { + let sidebarShot2 = TestUtils.screenshotArea(sidebarRect, window); + if (sidebarShot1 != sidebarShot2) { + info("After sidebar: " + sidebarShot2); + } + return sidebarShot1 != sidebarShot2; + }, "Waiting for the sidebar icon to update"); +}); + +/** + * Get Element for a bookmark in the bookmarks toolbar. + * + * @param {string} guid + * GUID of the item to search. + * @returns {object} DOM Node of the element. + */ +function getNodeForToolbarItem(guid) { + return Array.from( + document.getElementById("PlacesToolbarItems").children + ).find(child => child._placesNode && child._placesNode.bookmarkGuid == guid); +} + +/** + * Get a rect for a bookmark in the bookmarks sidebar + * + * @param {string} guid + * GUID of the item to search. + * @returns {object} DOM Node of the element. + */ +async function getRectForSidebarItem(guid) { + let sidebar = document.getElementById("sidebar"); + let tree = sidebar.contentDocument.getElementById("bookmarks-view"); + tree.selectItems([guid]); + let treerect = tree.getBoundingClientRect(); + let cellrect = tree.getCoordsForCellItem( + tree.currentIndex, + tree.columns[0], + "cell" + ); + + // Adjust the position for the tree and sidebar. + return { + left: treerect.left + cellrect.left + sidebar.getBoundingClientRect().left, + top: treerect.top + cellrect.top + sidebar.getBoundingClientRect().top, + width: cellrect.width, + height: cellrect.height, + }; +} diff --git a/browser/components/places/tests/browser/browser_views_liveupdate.js b/browser/components/places/tests/browser/browser_views_liveupdate.js new file mode 100644 index 0000000000..cce35941f3 --- /dev/null +++ b/browser/components/places/tests/browser/browser_views_liveupdate.js @@ -0,0 +1,493 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests Places views (menu, toolbar, tree) for liveupdate. + */ + +var toolbar = document.getElementById("PersonalToolbar"); +var wasCollapsed = toolbar.collapsed; + +/** + * Simulates popup opening causing it to populate. + * We cannot just use menu.open, since it would not work on Mac due to native menubar. + * + * @param {object} aPopup + * The popup element + */ +function fakeOpenPopup(aPopup) { + var popupEvent = document.createEvent("MouseEvent"); + popupEvent.initMouseEvent( + "popupshowing", + true, + true, + window, + 0, + 0, + 0, + 0, + 0, + false, + false, + false, + false, + 0, + null + ); + aPopup.dispatchEvent(popupEvent); +} + +async function testInFolder(folderGuid, prefix) { + let addedBookmarks = []; + let item = await PlacesUtils.bookmarks.insert({ + parentGuid: folderGuid, + title: `${prefix}1`, + url: `http://${prefix}1.mozilla.org/`, + }); + await bookmarksObserver.assertViewsUpdatedCorrectly(); + + item.title = `${prefix}1_edited`; + await PlacesUtils.bookmarks.update(item); + await bookmarksObserver.assertViewsUpdatedCorrectly(); + + addedBookmarks.push(item); + + item = await PlacesUtils.bookmarks.insert({ + parentGuid: folderGuid, + title: `${prefix}2`, + url: "place:", + }); + await bookmarksObserver.assertViewsUpdatedCorrectly(); + + item.title = `${prefix}2_edited`; + await PlacesUtils.bookmarks.update(item); + await bookmarksObserver.assertViewsUpdatedCorrectly(); + addedBookmarks.push(item); + + item = await PlacesUtils.bookmarks.insert({ + parentGuid: folderGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }); + await bookmarksObserver.assertViewsUpdatedCorrectly(); + addedBookmarks.push(item); + + item = await PlacesUtils.bookmarks.insert({ + parentGuid: folderGuid, + title: `${prefix}f`, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + await bookmarksObserver.assertViewsUpdatedCorrectly(); + + item.title = `${prefix}f_edited`; + await PlacesUtils.bookmarks.update(item); + await bookmarksObserver.assertViewsUpdatedCorrectly(); + addedBookmarks.push(item); + + item = await PlacesUtils.bookmarks.insert({ + parentGuid: item.guid, + title: `${prefix}f1`, + url: `http://${prefix}f1.mozilla.org/`, + }); + await bookmarksObserver.assertViewsUpdatedCorrectly(); + addedBookmarks.push(item); + + item.index = 0; + item.parentGuid = folderGuid; + await PlacesUtils.bookmarks.update(item); + await bookmarksObserver.assertViewsUpdatedCorrectly(); + + return addedBookmarks; +} + +add_task(async function test() { + // Uncollapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + } + + // Open bookmarks menu. + var popup = document.getElementById("bookmarksMenuPopup"); + ok(popup, "Menu popup element exists"); + fakeOpenPopup(popup); + + // Open bookmarks sidebar. + await withSidebarTree("bookmarks", async () => { + // Add observers. + bookmarksObserver.handlePlacesEvents = + bookmarksObserver.handlePlacesEvents.bind(bookmarksObserver); + PlacesUtils.observers.addListener( + ["bookmark-added", "bookmark-removed"], + bookmarksObserver.handlePlacesEvents + ); + var addedBookmarks = []; + + // MENU + info("*** Acting on menu bookmarks"); + addedBookmarks = addedBookmarks.concat( + await testInFolder(PlacesUtils.bookmarks.menuGuid, "bm") + ); + + // TOOLBAR + info("*** Acting on toolbar bookmarks"); + addedBookmarks = addedBookmarks.concat( + await testInFolder(PlacesUtils.bookmarks.toolbarGuid, "tb") + ); + + // UNSORTED + info("*** Acting on unsorted bookmarks"); + addedBookmarks = addedBookmarks.concat( + await testInFolder(PlacesUtils.bookmarks.unfiledGuid, "ub") + ); + + // Remove all added bookmarks. + for (let bm of addedBookmarks) { + // If we remove an item after its containing folder has been removed, + // this will throw, but we can ignore that. + try { + await PlacesUtils.bookmarks.remove(bm); + } catch (ex) {} + await bookmarksObserver.assertViewsUpdatedCorrectly(); + } + + // Remove observers. + PlacesUtils.observers.removeListener( + ["bookmark-added", "bookmark-removed"], + bookmarksObserver.handlePlacesEvents + ); + }); + + // Collapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, false); + } +}); + +/** + * The observer is where magic happens, for every change we do it will look for + * nodes positions in the affected views. + */ +var bookmarksObserver = { + _notifications: [], + + handlePlacesEvents(events) { + for (let { type, parentGuid, guid, index } of events) { + switch (type) { + case "bookmark-added": + this._notifications.push([ + "assertItemAdded", + parentGuid, + guid, + index, + ]); + break; + case "bookmark-removed": + this._notifications.push(["assertItemRemoved", parentGuid, guid]); + break; + } + } + }, + + async assertViewsUpdatedCorrectly() { + for (let notification of this._notifications) { + let assertFunction = notification.shift(); + + let views = await getViewsForFolder(notification.shift()); + Assert.greater( + views.length, + 0, + "Should have found one or more views for the parent folder." + ); + + await this[assertFunction](views, ...notification); + } + + this._notifications = []; + }, + + async assertItemAdded(views, guid, expectedIndex) { + for (let i = 0; i < views.length; i++) { + let [node, index] = searchItemInView(guid, views[i]); + Assert.notEqual(node, null, "Should have found the view in " + views[i]); + Assert.equal( + index, + expectedIndex, + "Should have found the node at the expected index" + ); + } + }, + + async assertItemRemoved(views, guid) { + for (let i = 0; i < views.length; i++) { + let [node] = searchItemInView(guid, views[i]); + Assert.equal(node, null, "Should not have found the node"); + } + }, + + async assertItemMoved(views, guid, newIndex) { + // Check that item has been moved in the correct position. + for (let i = 0; i < views.length; i++) { + let [node, index] = searchItemInView(guid, views[i]); + Assert.notEqual(node, null, "Should have found the view in " + views[i]); + Assert.equal( + index, + newIndex, + "Should have found the node at the expected index" + ); + } + }, + + async assertItemChanged(views, guid, newValue) { + let validator = function (aElementOrTreeIndex) { + if (typeof aElementOrTreeIndex == "number") { + let sidebar = document.getElementById("sidebar"); + let tree = sidebar.contentDocument.getElementById("bookmarks-view"); + let cellText = tree.view.getCellText( + aElementOrTreeIndex, + tree.columns.getColumnAt(0) + ); + if (!newValue) { + return ( + cellText == + PlacesUIUtils.getBestTitle( + tree.view.nodeForTreeIndex(aElementOrTreeIndex), + true + ) + ); + } + return cellText == newValue; + } + if (!newValue && aElementOrTreeIndex.localName != "toolbarbutton") { + return ( + aElementOrTreeIndex.getAttribute("label") == + PlacesUIUtils.getBestTitle(aElementOrTreeIndex._placesNode) + ); + } + return aElementOrTreeIndex.getAttribute("label") == newValue; + }; + + for (let i = 0; i < views.length; i++) { + let [node, , valid] = searchItemInView(guid, views[i], validator); + Assert.notEqual(node, null, "Should have found the view in " + views[i]); + Assert.equal( + node.title, + newValue, + "Node should have the correct new title" + ); + Assert.ok(valid, "Node element should have the correct label"); + } + }, +}; + +/** + * Search an item guid in a view. + * + * @param {string} itemGuid + * item guid of the item to search. + * @param {string} view + * either "toolbar", "menu" or "sidebar" + * @param {Function} validator + * function to check validity of the found node element. + * @returns {Array} + * [node, index, valid] or [null, null, false] if not found. + */ +function searchItemInView(itemGuid, view, validator) { + switch (view) { + case "toolbar": + return getNodeForToolbarItem(itemGuid, validator); + case "menu": + return getNodeForMenuItem(itemGuid, validator); + case "sidebar": + return getNodeForSidebarItem(itemGuid, validator); + } + + return [null, null, false]; +} + +/** + * Get places node and index for an itemGuid in bookmarks toolbar view. + * + * @param {string} itemGuid + * item guid of the item to search. + * @param {Function} validator + * function to check validity of the found node element. + * @returns {Array} + * [node, index] or [null, null] if not found. + */ +function getNodeForToolbarItem(itemGuid, validator) { + var placesToolbarItems = document.getElementById("PlacesToolbarItems"); + + function findNode(aContainer) { + var children = aContainer.children; + for (var i = 0, staticNodes = 0; i < children.length; i++) { + var child = children[i]; + + // Is this a Places node? + if (!child._placesNode) { + staticNodes++; + continue; + } + + if (child._placesNode.bookmarkGuid == itemGuid) { + let valid = validator ? validator(child) : true; + return [child._placesNode, i - staticNodes, valid]; + } + + // Don't search in queries, they could contain our item in a + // different position. Search only folders + if (PlacesUtils.nodeIsFolder(child._placesNode)) { + var popup = child.menupopup; + popup.openPopup(); + var foundNode = findNode(popup); + popup.hidePopup(); + if (foundNode[0] != null) { + return foundNode; + } + } + } + return [null, null]; + } + + return findNode(placesToolbarItems); +} + +/** + * Get places node and index for an itemGuid in bookmarks menu view. + * + * @param {string} itemGuid + * item guid of the item to search. + * @param {Function} validator + * function to check validity of the found node element. + * @returns {Array} + * [node, index] or [null, null] if not found. + */ +function getNodeForMenuItem(itemGuid, validator) { + var menu = document.getElementById("bookmarksMenu"); + + function findNode(aContainer) { + var children = aContainer.children; + for (var i = 0, staticNodes = 0; i < children.length; i++) { + var child = children[i]; + + // Is this a Places node? + if (!child._placesNode) { + staticNodes++; + continue; + } + + if (child._placesNode.bookmarkGuid == itemGuid) { + let valid = validator ? validator(child) : true; + return [child._placesNode, i - staticNodes, valid]; + } + + // Don't search in queries, they could contain our item in a + // different position. Search only folders + if (PlacesUtils.nodeIsFolder(child._placesNode)) { + var popup = child.lastElementChild; + fakeOpenPopup(popup); + var foundNode = findNode(popup); + + child.open = false; + if (foundNode[0] != null) { + return foundNode; + } + } + } + return [null, null, false]; + } + + return findNode(menu.lastElementChild); +} + +/** + * Get places node and index for an itemGuid in sidebar tree view. + * + * @param {string} itemGuid + * item guid of the item to search. + * @param {Function} validator + * function to check validity of the found node element. + * @returns {Array} + * [node, index] or [null, null] if not found. + */ +function getNodeForSidebarItem(itemGuid, validator) { + var sidebar = document.getElementById("sidebar"); + var tree = sidebar.contentDocument.getElementById("bookmarks-view"); + + function findNode(aContainerIndex) { + if (tree.view.isContainerEmpty(aContainerIndex)) { + return [null, null, false]; + } + + // The rowCount limit is just for sanity, but we will end looping when + // we have checked the last child of this container or we have found node. + for (var i = aContainerIndex + 1; i < tree.view.rowCount; i++) { + var node = tree.view.nodeForTreeIndex(i); + + if (node.bookmarkGuid == itemGuid) { + // Minus one because we want relative index inside the container. + let valid = validator ? validator(i) : true; + return [node, i - tree.view.getParentIndex(i) - 1, valid]; + } + + if (PlacesUtils.nodeIsFolder(node)) { + // Open container. + tree.view.toggleOpenState(i); + // Search inside it. + var foundNode = findNode(i); + // Close container. + tree.view.toggleOpenState(i); + // Return node if found. + if (foundNode[0] != null) { + return foundNode; + } + } + + // We have finished walking this container. + if (!tree.view.hasNextSibling(aContainerIndex + 1, i)) { + break; + } + } + return [null, null, false]; + } + + // Root node is hidden, so we need to manually walk the first level. + for (var i = 0; i < tree.view.rowCount; i++) { + // Open container. + tree.view.toggleOpenState(i); + // Search inside it. + var foundNode = findNode(i); + // Close container. + tree.view.toggleOpenState(i); + // Return node if found. + if (foundNode[0] != null) { + return foundNode; + } + } + return [null, null, false]; +} + +/** + * Get views affected by changes to a folder. + * + * @param {string} folderGuid + * item guid of the folder we have changed. + * @returns {Array<"toolbar" | "menu" | "sidebar">} + * subset of views: ["toolbar", "menu", "sidebar"] + */ +async function getViewsForFolder(folderGuid) { + let rootGuid = folderGuid; + while (!PlacesUtils.isRootItem(rootGuid)) { + let itemData = await PlacesUtils.bookmarks.fetch(rootGuid); + rootGuid = itemData.parentGuid; + } + + switch (rootGuid) { + case PlacesUtils.bookmarks.toolbarGuid: + return ["toolbar", "sidebar"]; + case PlacesUtils.bookmarks.menuGuid: + return ["menu", "sidebar"]; + case PlacesUtils.bookmarks.unfiledGuid: + return ["sidebar"]; + } + return []; +} diff --git a/browser/components/places/tests/browser/favicon-normal16.png b/browser/components/places/tests/browser/favicon-normal16.png new file mode 100644 index 0000000000..62b69a3d03 Binary files /dev/null and b/browser/components/places/tests/browser/favicon-normal16.png differ diff --git a/browser/components/places/tests/browser/frameLeft.html b/browser/components/places/tests/browser/frameLeft.html new file mode 100644 index 0000000000..5a54fe353b --- /dev/null +++ b/browser/components/places/tests/browser/frameLeft.html @@ -0,0 +1,8 @@ + + + Left frame + + + Open page in the right frame. + + diff --git a/browser/components/places/tests/browser/frameRight.html b/browser/components/places/tests/browser/frameRight.html new file mode 100644 index 0000000000..226accc349 --- /dev/null +++ b/browser/components/places/tests/browser/frameRight.html @@ -0,0 +1,8 @@ + + + Right Frame + + + This is the right frame. + + diff --git a/browser/components/places/tests/browser/framedPage.html b/browser/components/places/tests/browser/framedPage.html new file mode 100644 index 0000000000..58c5bbd79e --- /dev/null +++ b/browser/components/places/tests/browser/framedPage.html @@ -0,0 +1,9 @@ + + + Framed page + + + + + + diff --git a/browser/components/places/tests/browser/head.js b/browser/components/places/tests/browser/head.js new file mode 100644 index 0000000000..21790d54aa --- /dev/null +++ b/browser/components/places/tests/browser/head.js @@ -0,0 +1,570 @@ +ChromeUtils.defineESModuleGetters(this, { + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(this, "gFluentStrings", function () { + return new Localization(["branding/brand.ftl", "browser/browser.ftl"], true); +}); + +function openLibrary(callback, aLeftPaneRoot) { + let library = window.openDialog( + "chrome://browser/content/places/places.xhtml", + "", + "chrome,toolbar=yes,dialog=no,resizable", + aLeftPaneRoot + ); + waitForFocus(function () { + checkLibraryPaneVisibility(library, aLeftPaneRoot); + callback(library); + }, library); + + return library; +} + +/** + * Returns a handle to a Library window. + * If one is opens returns itm otherwise it opens a new one. + * + * @param {object} aLeftPaneRoot + * Hierarchy to open and select in the left pane. + * @returns {Promise} + * Resolves to the handle to the library window. + */ +function promiseLibrary(aLeftPaneRoot) { + return new Promise(resolve => { + let library = Services.wm.getMostRecentWindow("Places:Organizer"); + if (library && !library.closed) { + if (aLeftPaneRoot) { + library.PlacesOrganizer.selectLeftPaneContainerByHierarchy( + aLeftPaneRoot + ); + } + checkLibraryPaneVisibility(library, aLeftPaneRoot); + resolve(library); + } else { + openLibrary(resolve, aLeftPaneRoot); + } + }); +} + +function promiseLibraryClosed(organizer) { + return new Promise(resolve => { + if (organizer.closed) { + resolve(); + return; + } + // Wait for the Organizer window to actually be closed + organizer.addEventListener( + "unload", + function () { + executeSoon(resolve); + }, + { once: true } + ); + + // Close Library window. + organizer.close(); + }); +} + +function checkLibraryPaneVisibility(library, selectedPane) { + // Make sure right view is shown + if (selectedPane == "Downloads") { + Assert.ok( + library.ContentTree.view.hidden, + "Bookmark/History tree is hidden" + ); + Assert.ok( + !library.document.getElementById("downloadsListBox").hidden, + "Downloads are shown" + ); + } else { + Assert.ok( + !library.ContentTree.view.hidden, + "Bookmark/History tree is shown" + ); + Assert.ok( + library.document.getElementById("downloadsListBox").hidden, + "Downloads are hidden" + ); + } + + // Check currentView getter + Assert.ok(!library.ContentArea.currentView.hidden, "Current view is shown"); +} + +/** + * Waits for a clipboard operation to complete, looking for the expected type. + * + * @see waitForClipboard + * + * @param {Function} aPopulateClipboardFn + * Function to populate the clipboard. + * @param {string} aFlavor + * Data flavor to expect. + * @returns {Promise} + * A promise that is resolved with the data. + */ +function promiseClipboard(aPopulateClipboardFn, aFlavor) { + return new Promise((resolve, reject) => { + waitForClipboard( + data => !!data, + aPopulateClipboardFn, + resolve, + reject, + aFlavor + ); + }); +} + +function synthesizeClickOnSelectedTreeCell(aTree, aOptions) { + if (aTree.view.selection.count < 1) { + throw new Error("The test node should be successfully selected"); + } + // Get selection rowID. + let min = {}, + max = {}; + aTree.view.selection.getRangeAt(0, min, max); + let rowID = min.value; + aTree.ensureRowIsVisible(rowID); + // Calculate the click coordinates. + var rect = aTree.getCoordsForCellItem(rowID, aTree.columns[0], "text"); + var x = rect.x + rect.width / 2; + var y = rect.y + rect.height / 2; + if (aTree.id == "bookmarks-view" || aTree.id == "historyTree") { + // We are purposefully keeping the main element unlabeled, because in + // this specific case, the on-screen label for either "Bookmarks" or + // "History" sidebar is positioned closely to the tree, visually and in DOM. + // We want to avoid making a screen reader user to listen to a redundant + // announcement, therefore no accessible name is provided to the container + // and we account for this in a11y-checks: + AccessibilityUtils.setEnv({ + labelRule: false, + }); + } + // Simulate the click. + EventUtils.synthesizeMouse( + aTree.body, + x, + y, + aOptions || {}, + aTree.ownerGlobal + ); + AccessibilityUtils.resetEnv(); +} + +/** + * Makes the specified toolbar visible or invisible and returns a Promise object + * that is resolved when the toolbar has completed any animations associated + * with hiding or showing the toolbar. + * + * Note that this code assumes that changes to a toolbar's visibility trigger + * a transition on the max-height property of the toolbar element. + * Changes to this styling could cause the returned Promise object to be + * resolved too early or not at all. + * + * @param {object} aToolbar + * The toolbar to update. + * @param {boolean} aVisible + * True to make the toolbar visible, false to make it hidden. + * + * @returns {Promise} Any animation associated with updating the toolbar's + * visibility has finished. + */ +function promiseSetToolbarVisibility(aToolbar, aVisible) { + if (isToolbarVisible(aToolbar) != aVisible) { + let visibilityChanged = TestUtils.waitForCondition( + () => aToolbar.collapsed != aVisible + ); + setToolbarVisibility(aToolbar, aVisible, undefined, false); + return visibilityChanged; + } + return Promise.resolve(); +} + +/** + * Helper function to determine if the given toolbar is in the visible + * state according to its autohide/collapsed attribute. + * + * @param {object} aToolbar The toolbar to query. + * + * @returns {boolean} True if the relevant attribute on |aToolbar| indicates it is + * visible, false otherwise. + */ +function isToolbarVisible(aToolbar) { + let hidingAttribute = + aToolbar.getAttribute("type") == "menubar" ? "autohide" : "collapsed"; + let hidingValue = aToolbar.getAttribute(hidingAttribute).toLowerCase(); + // Check for both collapsed="true" and collapsed="collapsed" + return hidingValue !== "true" && hidingValue !== hidingAttribute; +} + +/** + * Executes a task after opening the bookmarks dialog, then cancels the dialog. + * + * @param {boolean} autoCancel + * whether to automatically cancel the dialog at the end of the task + * @param {Function} openFn + * generator function causing the dialog to open + * @param {Function} taskFn + * the task to execute once the dialog is open + * @param {Function} closeFn + * A function to be used to wait for pending work when the dialog is + * closing. It is passed the dialog window handle and should return a promise. + * @returns {string} guid + * Bookmark guid + */ +var withBookmarksDialog = async function (autoCancel, openFn, taskFn, closeFn) { + let dialogUrl = "chrome://browser/content/places/bookmarkProperties.xhtml"; + let closed = false; + // We can't show the in-window prompt for windows which don't have + // gDialogBox, like the library (Places:Organizer) window. + let hasDialogBox = !!Services.wm.getMostRecentWindow("").gDialogBox; + let dialogPromise; + if (hasDialogBox) { + dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(null, dialogUrl, { + isSubDialog: true, + }); + } else { + dialogPromise = BrowserTestUtils.domWindowOpenedAndLoaded(null, win => { + return win.document.documentURI.startsWith(dialogUrl); + }).then(win => { + ok( + win.location.href.startsWith(dialogUrl), + "The bookmark properties dialog is open: " + win.location.href + ); + // This is needed for the overlay. + return SimpleTest.promiseFocus(win).then(() => win); + }); + } + let dialogClosePromise = dialogPromise.then(win => { + if (!hasDialogBox) { + return BrowserTestUtils.domWindowClosed(win); + } + let container = win.top.document.getElementById("window-modal-dialog"); + return BrowserTestUtils.waitForEvent(container, "close").then(() => { + return BrowserTestUtils.waitForMutationCondition( + container, + { childList: true, attributes: true }, + () => !container.hasChildNodes() && !container.open + ); + }); + }); + dialogClosePromise.then(() => { + closed = true; + }); + + info("withBookmarksDialog: opening the dialog"); + // The dialog might be modal and could block our events loop, so executeSoon. + executeSoon(openFn); + + info("withBookmarksDialog: waiting for the dialog"); + let dialogWin = await dialogPromise; + + // Ensure overlay is loaded + info("waiting for the overlay to be loaded"); + await dialogWin.document.mozSubdialogReady; + + // Check the first input is focused. + let doc = dialogWin.document; + let elt = doc.querySelector('input:not([hidden="true"])'); + ok(elt, "There should be an input to focus."); + + if (elt) { + info("waiting for focus on the first textfield"); + await TestUtils.waitForCondition( + () => doc.activeElement == elt, + "The first non collapsed input should have been focused" + ); + } + + info("withBookmarksDialog: executing the task"); + + let closePromise = () => Promise.resolve(); + if (closeFn) { + closePromise = closeFn(dialogWin); + } + let guid; + try { + await taskFn(dialogWin); + } finally { + if (!closed && autoCancel) { + info("withBookmarksDialog: canceling the dialog"); + doc.getElementById("bookmarkpropertiesdialog").cancelDialog(); + await closePromise; + } + guid = await PlacesUIUtils.lastBookmarkDialogDeferred.promise; + // Give the dialog a little time to close itself. + await dialogClosePromise; + } + return guid; +}; + +/** + * Opens the contextual menu on the element pointed by the given selector. + * + * @param {object} browser + * The associated browser element. + * @param {object} selector + * Valid selector syntax + * @returns {Promise} + * Returns a Promise that resolves once the context menu has been + * opened. + */ +var openContextMenuForContentSelector = async function (browser, selector) { + info("wait for the context menu"); + let contextPromise = BrowserTestUtils.waitForEvent( + document.getElementById("contentAreaContextMenu"), + "popupshown" + ); + await SpecialPowers.spawn(browser, [{ selector }], async function (args) { + let doc = content.document; + let elt = doc.querySelector(args.selector); + dump(`openContextMenuForContentSelector: found ${elt}\n`); + + /* Open context menu so chrome can access the element */ + const domWindowUtils = content.windowUtils; + let rect = elt.getBoundingClientRect(); + let left = rect.left + rect.width / 2; + let top = rect.top + rect.height / 2; + domWindowUtils.sendMouseEvent( + "contextmenu", + left, + top, + 2, + 1, + 0, + false, + 0, + 0, + true + ); + }); + await contextPromise; +}; + +/** + * Fills a bookmarks dialog text field ensuring to cause expected edit events. + * + * @param {string} id + * id of the text field + * @param {string} text + * text to fill in + * @param {object} win + * dialog window + * @param {boolean} [blur] + * whether to blur at the end. + */ +function fillBookmarkTextField(id, text, win, blur = true) { + let elt = win.document.getElementById(id); + elt.focus(); + elt.select(); + if (!text) { + EventUtils.synthesizeKey("VK_DELETE", {}, win); + } else { + for (let c of text.split("")) { + EventUtils.synthesizeKey(c, {}, win); + } + } + if (blur) { + elt.blur(); + } +} + +/** + * Executes a task after opening the bookmarks or history sidebar. Takes care + * of closing the sidebar once done. + * + * @param {string} type + * either "bookmarks" or "history". + * @param {Function} taskFn + * The task to execute once the sidebar is ready. Will get the Places + * tree view as input. + */ +var withSidebarTree = async function (type, taskFn) { + let sidebar = document.getElementById("sidebar"); + info("withSidebarTree: waiting sidebar load"); + let sidebarLoadedPromise = new Promise(resolve => { + sidebar.addEventListener( + "load", + function () { + executeSoon(resolve); + }, + { capture: true, once: true } + ); + }); + let sidebarId = + type == "bookmarks" ? "viewBookmarksSidebar" : "viewHistorySidebar"; + SidebarUI.show(sidebarId); + await sidebarLoadedPromise; + + let treeId = type == "bookmarks" ? "bookmarks-view" : "historyTree"; + let tree = sidebar.contentDocument.getElementById(treeId); + + // Need to executeSoon since the tree is initialized on sidebar load. + info("withSidebarTree: executing the task"); + try { + await taskFn(tree); + } finally { + SidebarUI.hide(); + } +}; + +/** + * Executes a task after opening the Library on a given root. Takes care + * of closing the library once done. + * + * @param {string} hierarchy + * The left pane hierarchy to open. + * @param {Function} taskFn + * The task to execute once the Library is ready. + * Will get { left, right } trees as argument. + */ +var withLibraryWindow = async function (hierarchy, taskFn) { + let library = await promiseLibrary(hierarchy); + let left = library.document.getElementById("placesList"); + let right = library.document.getElementById("placeContent"); + info("withLibrary: executing the task"); + try { + await taskFn({ left, right }); + } finally { + await promiseLibraryClosed(library); + } +}; + +function promisePlacesInitComplete() { + const gBrowserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService( + Ci.nsIObserver + ); + + let placesInitCompleteObserved = TestUtils.topicObserved( + "places-browser-init-complete" + ); + + gBrowserGlue.observe( + null, + "browser-glue-test", + "places-browser-init-complete" + ); + + return placesInitCompleteObserved; +} + +// Function copied from browser/base/content/test/general/head.js. +function promisePopupShown(popup) { + return new Promise(resolve => { + if (popup.state == "open") { + resolve(); + } else { + let onPopupShown = event => { + popup.removeEventListener("popupshown", onPopupShown); + resolve(); + }; + popup.addEventListener("popupshown", onPopupShown); + } + }); +} + +// Function copied from browser/base/content/test/general/head.js. +function promisePopupHidden(popup) { + return new Promise(resolve => { + let onPopupHidden = event => { + popup.removeEventListener("popuphidden", onPopupHidden); + resolve(); + }; + popup.addEventListener("popuphidden", onPopupHidden); + }); +} + +// Identify a bookmark node in the Bookmarks Toolbar by its guid. +function getToolbarNodeForItemGuid(itemGuid) { + let children = document.getElementById("PlacesToolbarItems").childNodes; + for (let child of children) { + if (itemGuid === child._placesNode.bookmarkGuid) { + return child; + } + } + return null; +} + +// Open the bookmarks Star UI by clicking the star button on the address bar. +async function clickBookmarkStar(win = window) { + let shownPromise = promisePopupShown( + win.document.getElementById("editBookmarkPanel") + ); + win.BookmarkingUI.star.click(); + await shownPromise; + + // Additionally await for the async init to complete. + let menuList = win.document.getElementById("editBMPanel_folderMenuList"); + await BrowserTestUtils.waitForMutationCondition( + menuList, + { attributes: true }, + () => !!menuList.getAttribute("selectedGuid"), + "Should select the menu folder item" + ); +} + +// Close the bookmarks Star UI by clicking the "Done" button. +async function hideBookmarksPanel(win = window) { + let hiddenPromise = promisePopupHidden( + win.document.getElementById("editBookmarkPanel") + ); + // Confirm and close the dialog. + win.document.getElementById("editBookmarkPanelDoneButton").click(); + await hiddenPromise; +} + +// Create a temporary folder, set it as the default folder, +// then remove the folder. This is used to ensure that the +// default folder gets reset properly. +async function createAndRemoveDefaultFolder() { + let tempFolder = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "temp folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + ], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.bookmarks.defaultLocation", tempFolder[0].guid]], + }); + + let defaultGUID = await PlacesUIUtils.defaultParentGuid; + is(defaultGUID, tempFolder[0].guid, "check default guid"); + + await PlacesUtils.bookmarks.remove(tempFolder); +} + +async function showLibraryColumn(library, columnName) { + const viewMenu = library.document.getElementById("viewMenu"); + const viewMenuPopup = library.document.getElementById("viewMenuPopup"); + const onViewMenuPopup = new Promise(resolve => { + viewMenuPopup.addEventListener("popupshown", () => resolve(), { + once: true, + }); + }); + EventUtils.synthesizeMouseAtCenter(viewMenu, {}, library); + await onViewMenuPopup; + + const viewColumns = library.document.getElementById("viewColumns"); + const viewColumnsPopup = viewColumns.querySelector("menupopup"); + const onViewColumnsPopup = new Promise(resolve => { + viewColumnsPopup.addEventListener("popupshown", () => resolve(), { + once: true, + }); + }); + EventUtils.synthesizeMouseAtCenter(viewColumns, {}, library); + await onViewColumnsPopup; + + const columnMenu = library.document.getElementById(`menucol_${columnName}`); + EventUtils.synthesizeMouseAtCenter(columnMenu, {}, library); +} + +registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.bookmarks.defaultLocation"); + await PlacesTransactions.clearTransactionsHistory(true, true); +}); diff --git a/browser/components/places/tests/browser/interactions/browser.toml b/browser/components/places/tests/browser/interactions/browser.toml new file mode 100644 index 0000000000..ce39403409 --- /dev/null +++ b/browser/components/places/tests/browser/interactions/browser.toml @@ -0,0 +1,36 @@ +[DEFAULT] +prefs = [ + "browser.places.interactions.enabled=true", + "browser.places.interactions.log=true", + "browser.places.interactions.scrolling_timeout_ms=50", + "general.smoothScroll=false", +] + +support-files = [ + "head.js", + "../keyword_form.html", + "scrolling.html", + "scrolling_subframe.html", +] + +["browser_interactions_blocklist.js"] + +["browser_interactions_clearHistory.js"] + +["browser_interactions_disabledHistory.js"] + +["browser_interactions_referrer.js"] + +["browser_interactions_scrolling.js"] +skip-if = ["apple_silicon && fission"] # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs + +["browser_interactions_scrolling_dom_history.js"] +skip-if = ["os == 'mac'"] # Bug 1756157: Randomly times out on macOS + +["browser_interactions_typing.js"] + +["browser_interactions_typing_dom_history.js"] + +["browser_interactions_view_time.js"] + +["browser_interactions_view_time_dom_history.js"] diff --git a/browser/components/places/tests/browser/interactions/browser_interactions_blocklist.js b/browser/components/places/tests/browser/interactions/browser_interactions_blocklist.js new file mode 100644 index 0000000000..e7a880637b --- /dev/null +++ b/browser/components/places/tests/browser/interactions/browser_interactions_blocklist.js @@ -0,0 +1,105 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that interactions are not recorded for sites on the blocklist. + */ + +const ALLOWED_TEST_URL = "http://mochi.test:8888/"; +const BLOCKED_TEST_URL = "https://example.com/browser"; + +ChromeUtils.defineESModuleGetters(this, { + FilterAdult: "resource://activity-stream/lib/FilterAdult.sys.mjs", + InteractionsBlocklist: "resource:///modules/InteractionsBlocklist.sys.mjs", +}); + +add_setup(async function () { + let oldBlocklistValue = Services.prefs.getStringPref( + "places.interactions.customBlocklist", + "[]" + ); + Services.prefs.setStringPref("places.interactions.customBlocklist", "[]"); + + registerCleanupFunction(async () => { + Services.prefs.setStringPref( + "places.interactions.customBlocklist", + oldBlocklistValue + ); + }); +}); +/** + * Loads the blocked URL, then loads about:blank to trigger the end of the + * interaction with the blocked URL. + * + * @param {boolean} expectRecording + * True if we expect the blocked URL to have been recorded in the database. + */ +async function loadBlockedUrl(expectRecording) { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(ALLOWED_TEST_URL, async browser => { + Interactions._pageViewStartTime = Cu.now() - 10000; + + BrowserTestUtils.startLoadingURIString(browser, BLOCKED_TEST_URL); + await BrowserTestUtils.browserLoaded(browser, false, BLOCKED_TEST_URL); + + await assertDatabaseValues([ + { + url: ALLOWED_TEST_URL, + totalViewTime: 10000, + }, + ]); + + Interactions._pageViewStartTime = Cu.now() - 20000; + + BrowserTestUtils.startLoadingURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + if (expectRecording) { + await assertDatabaseValues([ + { + url: ALLOWED_TEST_URL, + totalViewTime: 10000, + }, + { + url: BLOCKED_TEST_URL, + totalViewTime: 20000, + }, + ]); + } else { + await assertDatabaseValues([ + { + url: ALLOWED_TEST_URL, + totalViewTime: 10000, + }, + ]); + } + }); +} + +add_task(async function test_regexp() { + info("Record BLOCKED_TEST_URL because it is not yet blocklisted."); + await loadBlockedUrl(true); + + info("Add BLOCKED_TEST_URL to the blocklist and verify it is not recorded."); + let blockedRegex = /^(https?:\/\/)?example\.com\/browser/i; + InteractionsBlocklist.addRegexToBlocklist(blockedRegex); + Assert.equal( + Services.prefs.getStringPref("places.interactions.customBlocklist", "[]"), + JSON.stringify([blockedRegex.toString()]) + ); + await loadBlockedUrl(false); + + info("Remove BLOCKED_TEST_URL from the blocklist and verify it is recorded."); + InteractionsBlocklist.removeRegexFromBlocklist(blockedRegex); + Assert.equal( + Services.prefs.getStringPref("places.interactions.customBlocklist", "[]"), + JSON.stringify([]) + ); + await loadBlockedUrl(true); +}); + +add_task(async function test_adult() { + FilterAdult.addDomainToList("https://example.com/browser"); + await loadBlockedUrl(false); + FilterAdult.removeDomainFromList("https://example.com/browser"); +}); diff --git a/browser/components/places/tests/browser/interactions/browser_interactions_clearHistory.js b/browser/components/places/tests/browser/interactions/browser_interactions_clearHistory.js new file mode 100644 index 0000000000..37b99ba974 --- /dev/null +++ b/browser/components/places/tests/browser/interactions/browser_interactions_clearHistory.js @@ -0,0 +1,106 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests we clear interactions when history is cleared. + */ + +"use strict"; + +const TEST_URL = + "https://example.com/browser/browser/components/places/tests/browser/keyword_form.html"; +const TEST_URL_2 = + "https://example.org/browser/browser/components/places/tests/browser/keyword_form.html"; +const TEST_URL_AWAY = "https://example.com/browser"; + +const sentence = "The quick brown fox jumps over the lazy dog."; + +async function sendTextToInput(browser, text) { + // Reset to later verify that the provided text matches the value. + await SpecialPowers.spawn(browser, [], function () { + const input = content.document.querySelector( + "#form1 > input[name='search']" + ); + input.focus(); + input.value = ""; + }); + + EventUtils.sendString(text); + + await SpecialPowers.spawn(browser, [{ text }], async function (args) { + await ContentTaskUtils.waitForCondition( + () => + content.document.querySelector("#form1 > input[name='search']").value == + args.text, + "Text has been set on input" + ); + }); +} + +add_setup(async function () { + await Interactions.reset(); + await PlacesUtils.bookmarks.insert({ + url: TEST_URL, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + registerCleanupFunction(async () => { + await Interactions.reset(); + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_clear_history() { + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await sendTextToInput(browser, sentence); + + BrowserTestUtils.startLoadingURIString(browser, TEST_URL_2); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL_2); + + await sendTextToInput(browser, sentence); + + BrowserTestUtils.startLoadingURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + }); + + Assert.ok( + await PlacesUtils.history.hasVisits(TEST_URL), + "Check visits were added" + ); + Assert.ok( + await PlacesUtils.history.hasVisits(TEST_URL_2), + "Check visits were added" + ); + + info("Check interactions were added"); + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length, + typingTimeIsGreaterThan: 0, + }, + { + url: TEST_URL_2, + keypresses: sentence.length, + typingTimeIsGreaterThan: 0, + }, + ]); + + await PlacesUtils.history.clear(); + + Assert.ok( + await PlacesTestUtils.isPageInDB(TEST_URL), + "Bookmarked page remains in the database" + ); + Assert.ok( + !(await PlacesUtils.history.hasVisits(TEST_URL)), + "Check visits were removed" + ); + Assert.ok( + !(await PlacesTestUtils.isPageInDB(TEST_URL_2)), + "Non bookmarked page was removed from the database" + ); + + info("Check all interactions have been removed."); + await assertDatabaseValues([]); +}); diff --git a/browser/components/places/tests/browser/interactions/browser_interactions_disabledHistory.js b/browser/components/places/tests/browser/interactions/browser_interactions_disabledHistory.js new file mode 100644 index 0000000000..a5a62207cf --- /dev/null +++ b/browser/components/places/tests/browser/interactions/browser_interactions_disabledHistory.js @@ -0,0 +1,102 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests we don't record interactions when history is disabled, even if the page + * is bookmarked. + */ + +"use strict"; + +const TEST_URL = + "https://example.com/browser/browser/components/places/tests/browser/keyword_form.html"; +const TEST_URL_AWAY = "https://example.com/browser"; + +const sentence = "The quick brown fox jumps over the lazy dog."; + +async function sendTextToInput(browser, text) { + // Reset to later verify that the provided text matches the value. + await SpecialPowers.spawn(browser, [], function () { + const input = content.document.querySelector( + "#form1 > input[name='search']" + ); + input.focus(); + input.value = ""; + }); + + EventUtils.sendString(text); + + await SpecialPowers.spawn(browser, [{ text }], async function (args) { + await ContentTaskUtils.waitForCondition( + () => + content.document.querySelector("#form1 > input[name='search']").value == + args.text, + "Text has been set on input" + ); + }); +} + +add_setup(async function () { + await Interactions.reset(); + await PlacesUtils.bookmarks.insert({ + url: TEST_URL, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + registerCleanupFunction(async () => { + await Interactions.reset(); + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_disabled_history() { + await SpecialPowers.pushPrefEnv({ + set: [["places.history.enabled", false]], + }); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await sendTextToInput(browser, sentence); + + BrowserTestUtils.startLoadingURIString(browser, TEST_URL_AWAY); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL_AWAY); + + await assertDatabaseValues([]); + + BrowserTestUtils.startLoadingURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([]); + }); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_disableGlobalHistory() { + await Interactions.reset(); + + await PlacesUtils.history.clear(); + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser)); + let browser = tab.linkedBrowser; + browser.setAttribute("disableglobalhistory", "true"); + + BrowserTestUtils.startLoadingURIString(browser, TEST_URL); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL); + Assert.ok( + !browser.browsingContext.useGlobalHistory, + "browserContext should be updated after the first load" + ); + + await sendTextToInput(browser, sentence); + + BrowserTestUtils.startLoadingURIString(browser, TEST_URL_AWAY); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL_AWAY); + + Assert.ok( + !(await PlacesUtils.history.hasVisits(TEST_URL)), + "Check visits were not added" + ); + Assert.ok( + !(await PlacesUtils.history.hasVisits(TEST_URL_AWAY)), + "Check visits were not added" + ); + await assertDatabaseValues([]); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/places/tests/browser/interactions/browser_interactions_referrer.js b/browser/components/places/tests/browser/interactions/browser_interactions_referrer.js new file mode 100644 index 0000000000..fda93bfc5a --- /dev/null +++ b/browser/components/places/tests/browser/interactions/browser_interactions_referrer.js @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests page view time recording for interactions. + */ + +const TEST_REFERRER_URL = "https://example.org/browser"; +const TEST_URL = "https://example.org/browser/browser"; + +add_task(async function test_interactions_referrer() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_REFERRER_URL, async browser => { + let ReferrerInfo = Components.Constructor( + "@mozilla.org/referrer-info;1", + "nsIReferrerInfo", + "init" + ); + + // Load a new URI with a specific referrer. + let referrerInfo1 = new ReferrerInfo( + Ci.nsIReferrerInfo.EMPTY, + true, + Services.io.newURI(TEST_REFERRER_URL) + ); + browser.loadURI(Services.io.newURI(TEST_URL), { + referrerInfo: referrerInfo1, + triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal( + {} + ), + }); + await BrowserTestUtils.browserLoaded(browser, true, TEST_URL); + }); + await assertDatabaseValues([ + { + url: TEST_REFERRER_URL, + referrer_url: null, + }, + { + url: TEST_URL, + referrer_url: TEST_REFERRER_URL, + }, + ]); +}); diff --git a/browser/components/places/tests/browser/interactions/browser_interactions_scrolling.js b/browser/components/places/tests/browser/interactions/browser_interactions_scrolling.js new file mode 100644 index 0000000000..59428a485d --- /dev/null +++ b/browser/components/places/tests/browser/interactions/browser_interactions_scrolling.js @@ -0,0 +1,162 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Test reporting of scrolling interactions. + */ + +"use strict"; + +const TEST_URL = + "https://example.com/browser/browser/components/places/tests/browser/interactions/scrolling.html"; +const TEST_URL2 = "https://example.com/browser"; + +async function waitForScrollEvent(aBrowser, aTask) { + let promise = BrowserTestUtils.waitForContentEvent(aBrowser, "scroll"); + + // This forces us to send a message to the browser's process and receive a response which ensures + // that the message sent to register the scroll event listener will also have been processed by + // the content process. Without this it is possible for our scroll task to send a higher priority + // message which can be processed by the content process before the message to register the scroll + // event listener. + await SpecialPowers.spawn(aBrowser, [], () => {}); + + await aTask(); + await promise; +} + +add_task(async function test_no_scrolling() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + BrowserTestUtils.startLoadingURIString(browser, TEST_URL2); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2); + + await assertDatabaseValues([ + { + url: TEST_URL, + exactscrollingDistance: 0, + exactscrollingTime: 0, + }, + ]); + }); +}); + +add_task(async function test_arrow_key_down_scroll() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await SpecialPowers.spawn(browser, [], function () { + const heading = content.document.getElementById("heading"); + heading.focus(); + }); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + BrowserTestUtils.startLoadingURIString(browser, TEST_URL2); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2); + + await assertDatabaseValues([ + { + url: TEST_URL, + scrollingDistanceIsGreaterThan: 0, + scrollingTimeIsGreaterThan: 0, + }, + ]); + }); +}); + +add_task(async function test_scrollIntoView() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await waitForScrollEvent(browser, () => + SpecialPowers.spawn(browser, [], function () { + const heading = content.document.getElementById("middleHeading"); + heading.scrollIntoView(); + }) + ); + + BrowserTestUtils.startLoadingURIString(browser, TEST_URL2); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2); + + // JS-triggered scrolling should not be reported + await assertDatabaseValues([ + { + url: TEST_URL, + exactscrollingDistance: 0, + exactscrollingTime: 0, + }, + ]); + }); +}); + +add_task(async function test_anchor_click() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await waitForScrollEvent(browser, () => + SpecialPowers.spawn(browser, [], function () { + const anchor = content.document.getElementById("to_bottom_anchor"); + anchor.click(); + }) + ); + + BrowserTestUtils.startLoadingURIString(browser, TEST_URL2); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2); + + // The scrolling resulting from clicking on an anchor should not be reported + await assertDatabaseValues([ + { + url: TEST_URL, + exactscrollingDistance: 0, + exactscrollingTime: 0, + }, + ]); + }); +}); + +add_task(async function test_window_scrollBy() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await waitForScrollEvent(browser, () => + SpecialPowers.spawn(browser, [], function () { + content.scrollBy(0, 100); + }) + ); + + BrowserTestUtils.startLoadingURIString(browser, TEST_URL2); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2); + + // The scrolling resulting from the window.scrollBy() call should not be reported + await assertDatabaseValues([ + { + url: TEST_URL, + exactscrollingDistance: 0, + exactscrollingTime: 0, + }, + ]); + }); +}); + +add_task(async function test_window_scrollTo() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await waitForScrollEvent(browser, () => + SpecialPowers.spawn(browser, [], function () { + content.scrollTo(0, 200); + }) + ); + + BrowserTestUtils.startLoadingURIString(browser, TEST_URL2); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2); + + // The scrolling resulting from the window.scrollTo() call should not be reported + await assertDatabaseValues([ + { + url: TEST_URL, + exactscrollingDistance: 0, + exactscrollingTime: 0, + }, + ]); + }); +}); diff --git a/browser/components/places/tests/browser/interactions/browser_interactions_scrolling_dom_history.js b/browser/components/places/tests/browser/interactions/browser_interactions_scrolling_dom_history.js new file mode 100644 index 0000000000..b825ce615e --- /dev/null +++ b/browser/components/places/tests/browser/interactions/browser_interactions_scrolling_dom_history.js @@ -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/. */ + +/** + * Test reporting of scrolling interactions after DOM history API use. + */ + +"use strict"; + +const TEST_URL = + "https://example.com/browser/browser/components/places/tests/browser/interactions/scrolling.html"; +const TEST_URL2 = "https://example.com/browser"; + +async function waitForScrollEvent(aBrowser, aTask) { + let promise = BrowserTestUtils.waitForContentEvent(aBrowser, "scroll"); + + // This forces us to send a message to the browser's process and receive a response which ensures + // that the message sent to register the scroll event listener will also have been processed by + // the content process. Without this it is possible for our scroll task to send a higher priority + // message which can be processed by the content process before the message to register the scroll + // event listener. + await SpecialPowers.spawn(aBrowser, [], () => {}); + + await aTask(); + await promise; +} + +add_task(async function test_scroll_pushState() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await SpecialPowers.spawn(browser, [], function () { + const heading = content.document.getElementById("heading"); + heading.focus(); + }); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + await ContentTask.spawn(browser, TEST_URL2, url => { + content.history.pushState(null, "", url); + }); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + BrowserTestUtils.startLoadingURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + scrollingDistanceIsGreaterThan: 0, + scrollingTimeIsGreaterThan: 0, + }, + { + url: TEST_URL2, + scrollingDistanceIsGreaterThan: 0, + scrollingTimeIsGreaterThan: 0, + }, + ]); + }); +}); + +add_task(async function test_scroll_pushState_sameUrl() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await SpecialPowers.spawn(browser, [], function () { + const heading = content.document.getElementById("heading"); + heading.focus(); + }); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + await ContentTask.spawn(browser, TEST_URL, url => { + content.history.pushState(null, "", url); + }); + + // As the page hasn't changed there will be no interactions saved yet. + await assertDatabaseValues([]); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + BrowserTestUtils.startLoadingURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + scrollingDistanceIsGreaterThan: 0, + scrollingTimeIsGreaterThan: 0, + }, + ]); + }); +}); + +add_task(async function test_scroll_replaceState() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await SpecialPowers.spawn(browser, [], function () { + const heading = content.document.getElementById("heading"); + heading.focus(); + }); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + await ContentTask.spawn(browser, TEST_URL2, url => { + content.history.replaceState(null, "", url); + }); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + BrowserTestUtils.startLoadingURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + scrollingDistanceIsGreaterThan: 0, + scrollingTimeIsGreaterThan: 0, + }, + { + url: TEST_URL2, + scrollingDistanceIsGreaterThan: 0, + scrollingTimeIsGreaterThan: 0, + }, + ]); + }); +}); + +add_task(async function test_scroll_replaceState_sameUrl() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await SpecialPowers.spawn(browser, [], function () { + const heading = content.document.getElementById("heading"); + heading.focus(); + }); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + await ContentTask.spawn(browser, TEST_URL, url => { + content.history.replaceState(null, "", url); + }); + + // As the page hasn't changed there will be no interactions saved yet. + await assertDatabaseValues([]); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + BrowserTestUtils.startLoadingURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + scrollingDistanceIsGreaterThan: 0, + scrollingTimeIsGreaterThan: 0, + }, + ]); + }); +}); + +add_task(async function test_scroll_hashchange() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await SpecialPowers.spawn(browser, [], function () { + const heading = content.document.getElementById("heading"); + heading.focus(); + }); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + await ContentTask.spawn(browser, TEST_URL + "#foo", url => { + content.history.replaceState(null, "", url); + }); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + BrowserTestUtils.startLoadingURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + scrollingDistanceIsGreaterThan: 0, + scrollingTimeIsGreaterThan: 0, + }, + ]); + }); +}); diff --git a/browser/components/places/tests/browser/interactions/browser_interactions_typing.js b/browser/components/places/tests/browser/interactions/browser_interactions_typing.js new file mode 100644 index 0000000000..99269c3265 --- /dev/null +++ b/browser/components/places/tests/browser/interactions/browser_interactions_typing.js @@ -0,0 +1,410 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests reporting of typing interactions. + */ + +"use strict"; + +const TEST_URL = + "https://example.com/browser/browser/components/places/tests/browser/keyword_form.html"; +const TEST_URL2 = "https://example.com/browser"; +const TEST_URL3 = + "https://example.com/browser/browser/base/content/test/contextMenu/subtst_contextmenu_input.html"; + +const sentence = "The quick brown fox jumps over the lazy dog."; +const sentenceFragments = [ + "The quick", + " brown fox", + " jumps over the lazy dog.", +]; +const longSentence = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas ut purus a libero cursus scelerisque. In hac habitasse platea dictumst. Quisque posuere ante sed consequat volutpat."; + +// For tests where it matters reduces the maximum time between keypresses to a length that we can +// afford to delay the test by. +const PREF_TYPING_DELAY = "browser.places.interactions.typing_timeout_ms"; +const POST_TYPING_DELAY = 150; + +async function sendTextToInput(browser, text) { + await SpecialPowers.spawn(browser, [], function () { + const input = content.document.querySelector( + "#form1 > input[name='search']" + ); + input.focus(); + input.value = ""; // Reset to later verify that the provided text matches the value + }); + + EventUtils.sendString(text); + + await SpecialPowers.spawn(browser, [{ text }], async function (args) { + await ContentTaskUtils.waitForCondition( + () => + content.document.querySelector("#form1 > input[name='search']").value == + args.text, + "Text has been set on input" + ); + }); +} + +add_task(async function test_load_and_navigate_away_no_keypresses() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + BrowserTestUtils.startLoadingURIString(browser, TEST_URL2); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: 0, + exactTypingTime: 0, + }, + ]); + + BrowserTestUtils.startLoadingURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: 0, + exactTypingTime: 0, + }, + { + url: TEST_URL2, + keypresses: 0, + exactTypingTime: 0, + }, + ]); + }); +}); + +add_task(async function test_load_type_and_navigate_away() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await sendTextToInput(browser, sentence); + + BrowserTestUtils.startLoadingURIString(browser, TEST_URL2); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length, + typingTimeIsGreaterThan: 0, + }, + ]); + + BrowserTestUtils.startLoadingURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length, + typingTimeIsGreaterThan: 0, + }, + { + url: TEST_URL2, + keypresses: 0, + exactTypingTime: 0, + }, + ]); + }); +}); + +add_task(async function test_no_typing_close_tab() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => {}); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: 0, + exactTypingTime: 0, + }, + ]); +}); + +add_task(async function test_typing_close_tab() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await sendTextToInput(browser, sentence); + }); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length, + typingTimeIsGreaterThan: 0, + }, + ]); +}); + +add_task(async function test_single_key_typing_and_delay() { + await Interactions.reset(); + + Services.prefs.setIntPref(PREF_TYPING_DELAY, POST_TYPING_DELAY); + + try { + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + // Single keystrokes with a delay between each, are not considered typing + const text = ["T", "h", "e"]; + + for (let i = 0; i < text.length; i++) { + await sendTextToInput(browser, text[i]); + + // We do need to wait here because typing is defined as a series of keystrokes followed by a delay. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, POST_TYPING_DELAY * 2)); + } + }); + } finally { + Services.prefs.clearUserPref(PREF_TYPING_DELAY); + } + + // Since we typed single keys with delays between each, there should be no typing added to the database + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: 0, + exactTypingTime: 0, + }, + ]); +}); + +add_task(async function test_double_key_typing_and_delay() { + await Interactions.reset(); + + // Test three 2-key typing bursts. + const text = ["Ab", "cd", "ef"]; + + const testStartTime = Cu.now(); + + Services.prefs.setIntPref(PREF_TYPING_DELAY, POST_TYPING_DELAY); + + try { + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + for (let i = 0; i < text.length; i++) { + await sendTextToInput(browser, text[i]); + + // We do need to wait here because typing is defined as a series of keystrokes followed by a delay. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, POST_TYPING_DELAY * 2)); + } + }); + } finally { + Services.prefs.clearUserPref(PREF_TYPING_DELAY); + } + + // All keys should be recorded + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: text.reduce( + (accumulator, current) => accumulator + current.length, + 0 + ), + typingTimeIsLessThan: Cu.now() - testStartTime, + }, + ]); +}); + +add_task(async function test_typing_and_delay() { + await Interactions.reset(); + + const testStartTime = Cu.now(); + + Services.prefs.setIntPref(PREF_TYPING_DELAY, POST_TYPING_DELAY); + + try { + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + for (let i = 0; i < sentenceFragments.length; i++) { + await sendTextToInput(browser, sentenceFragments[i]); + + // We do need to wait here because typing is defined as a series of keystrokes followed by a delay. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, POST_TYPING_DELAY * 2)); + } + }); + } finally { + Services.prefs.clearUserPref(PREF_TYPING_DELAY); + } + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentenceFragments.reduce( + (accumulator, current) => accumulator + current.length, + 0 + ), + typingTimeIsGreaterThan: 0, + typingTimeIsLessThan: Cu.now() - testStartTime, + }, + ]); +}); + +add_task(async function test_typing_and_reload() { + await Interactions.reset(); + + const testStartTime = Cu.now(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await sendTextToInput(browser, sentenceFragments[0]); + + info("reload"); + browser.reload(); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL); + + // First typing should have been recorded + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentenceFragments[0].length, + typingTimeIsGreaterThan: 0, + }, + ]); + + await sendTextToInput(browser, sentenceFragments[1]); + + info("reload"); + browser.reload(); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL); + + // Second typing should have been recorded + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentenceFragments[0].length, + typingTimeIsGreaterThan: 0, + typingTimeIsLessThan: Cu.now() - testStartTime, + }, + { + url: TEST_URL, + keypresses: sentenceFragments[1].length, + typingTimeIsGreaterThan: 0, + typingTimeIsLessThan: Cu.now() - testStartTime, + }, + ]); + }); +}).skip(); // Bug 1749328 - intermittent failure: dropping the 2nd interaction after the 2nd reload + +add_task(async function test_switch_tabs_no_typing() { + await Interactions.reset(); + + let tab1 = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + }); + + let tab2 = BrowserTestUtils.addTab(gBrowser, TEST_URL2); + await BrowserTestUtils.browserLoaded(tab2.linkedBrowser, false, TEST_URL2); + + info("Switch to second tab"); + gBrowser.selectedTab = tab2; + + // Only the interaction of the first tab should be recorded so far, and with no typing + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: 0, + exactTypingTime: 0, + }, + ]); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); + +add_task(async function test_typing_switch_tabs() { + await Interactions.reset(); + + let tab1 = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + }); + + await sendTextToInput(tab1.linkedBrowser, sentence); + + let tab2 = BrowserTestUtils.addTab(gBrowser, TEST_URL3); + await BrowserTestUtils.browserLoaded(tab2.linkedBrowser, false, TEST_URL3); + + info("Switch to second tab"); + await BrowserTestUtils.switchTab(gBrowser, tab2); + + // Only the interaction of the first tab should be recorded so far + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length, + typingTimeIsGreaterThan: 0, + }, + ]); + + const tab1TyingTime = await getDatabaseValue(TEST_URL, "typingTime"); + + info("Switch back to first tab"); + await BrowserTestUtils.switchTab(gBrowser, tab1); + + // The interaction of the second tab should now be recorded (no typing) + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length, + exactTypingTime: tab1TyingTime, + }, + { + url: TEST_URL3, + keypresses: 0, + exactTypingTime: 0, + }, + ]); + + info("Switch back to the second tab"); + await BrowserTestUtils.switchTab(gBrowser, tab2); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length, + exactTypingTime: tab1TyingTime, + }, + { + url: TEST_URL3, + keypresses: 0, + exactTypingTime: 0, + }, + ]); + + // Typing into the second tab + await SpecialPowers.spawn(tab2.linkedBrowser, [], function () { + const input = content.document.getElementById("input_text"); + input.focus(); + }); + await EventUtils.sendString(longSentence); + await TestUtils.waitForTick(); + + info("Switch back to first tab"); + await BrowserTestUtils.switchTab(gBrowser, tab1); + + // The interaction of the second tab should now also be recorded (with typing) + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length, + exactTypingTime: tab1TyingTime, + }, + { + url: TEST_URL3, + keypresses: longSentence.length, + typingTimeIsGreaterThan: 0, + }, + ]); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/places/tests/browser/interactions/browser_interactions_typing_dom_history.js b/browser/components/places/tests/browser/interactions/browser_interactions_typing_dom_history.js new file mode 100644 index 0000000000..e64050ec1d --- /dev/null +++ b/browser/components/places/tests/browser/interactions/browser_interactions_typing_dom_history.js @@ -0,0 +1,171 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests reporting of typing interactions after DOM history API usage. + */ + +"use strict"; + +const TEST_URL = + "https://example.com/browser/browser/components/places/tests/browser/keyword_form.html"; +const TEST_URL2 = "https://example.com/browser"; + +const sentence = "The quick brown fox jumps over the lazy dog."; + +async function sendTextToInput(browser, text) { + await SpecialPowers.spawn(browser, [], function () { + const input = content.document.querySelector( + "#form1 > input[name='search']" + ); + input.focus(); + input.value = ""; // Reset to later verify that the provided text matches the value + }); + + EventUtils.sendString(text); + + await SpecialPowers.spawn(browser, [{ text }], async function (args) { + await ContentTaskUtils.waitForCondition( + () => + content.document.querySelector("#form1 > input[name='search']").value == + args.text, + "Text has been set on input" + ); + }); +} + +add_task(async function test_typing_pushState() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await sendTextToInput(browser, sentence); + + await ContentTask.spawn(browser, TEST_URL2, url => { + content.history.pushState(null, "", url); + }); + + await sendTextToInput(browser, sentence); + + BrowserTestUtils.startLoadingURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length, + typingTimeIsGreaterThan: 0, + }, + { + url: TEST_URL2, + keypresses: sentence.length, + typingTimeIsGreaterThan: 0, + }, + ]); + }); +}); + +add_task(async function test_typing_pushState_sameUrl() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await sendTextToInput(browser, sentence); + + await ContentTask.spawn(browser, TEST_URL, url => { + content.history.pushState(null, "", url); + }); + + await sendTextToInput(browser, sentence); + + BrowserTestUtils.startLoadingURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length * 2, + typingTimeIsGreaterThan: 0, + }, + ]); + }); +}); + +add_task(async function test_typing_replaceState() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await sendTextToInput(browser, sentence); + + await ContentTask.spawn(browser, TEST_URL2, url => { + content.history.replaceState(null, "", url); + }); + + await sendTextToInput(browser, sentence); + + BrowserTestUtils.startLoadingURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length, + typingTimeIsGreaterThan: 0, + }, + { + url: TEST_URL2, + keypresses: sentence.length, + typingTimeIsGreaterThan: 0, + }, + ]); + }); +}); + +add_task(async function test_typing_replaceState_sameUrl() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await sendTextToInput(browser, sentence); + + await ContentTask.spawn(browser, TEST_URL, url => { + content.history.replaceState(null, "", url); + }); + + await sendTextToInput(browser, sentence); + + BrowserTestUtils.startLoadingURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length * 2, + typingTimeIsGreaterThan: 0, + }, + ]); + }); +}); + +add_task(async function test_typing_hashchange() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await sendTextToInput(browser, sentence); + + await ContentTask.spawn(browser, TEST_URL + "#foo", url => { + content.location = url; + }); + + await sendTextToInput(browser, sentence); + + BrowserTestUtils.startLoadingURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length * 2, + typingTimeIsGreaterThan: 0, + }, + ]); + }); +}); diff --git a/browser/components/places/tests/browser/interactions/browser_interactions_view_time.js b/browser/components/places/tests/browser/interactions/browser_interactions_view_time.js new file mode 100644 index 0000000000..278ae10228 --- /dev/null +++ b/browser/components/places/tests/browser/interactions/browser_interactions_view_time.js @@ -0,0 +1,410 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests page view time recording for interactions. + */ + +const TEST_URL = "https://example.com/"; +const TEST_URL2 = "https://example.com/browser"; +const TEST_URL3 = "https://example.com/browser/browser"; +const TEST_URL4 = "https://example.com/browser/browser/components"; + +add_task(async function test_interactions_simple_load_and_navigate_away() { + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + Interactions._pageViewStartTime = Cu.now() - 10000; + + BrowserTestUtils.startLoadingURIString(browser, TEST_URL2); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + ]); + + Interactions._pageViewStartTime = Cu.now() - 20000; + + BrowserTestUtils.startLoadingURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + { + url: TEST_URL2, + totalViewTime: 20000, + }, + ]); + }); +}); + +add_task(async function test_interactions_simple_load_and_change_to_non_http() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + Interactions._pageViewStartTime = Cu.now() - 10000; + + BrowserTestUtils.startLoadingURIString(browser, "about:support"); + await BrowserTestUtils.browserLoaded(browser, false, "about:support"); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + ]); + }); +}); + +add_task(async function test_interactions_close_tab() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + Interactions._pageViewStartTime = Cu.now() - 20000; + }); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 20000, + }, + ]); +}); + +add_task(async function test_interactions_background_tab() { + await Interactions.reset(); + let tab1 = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + }); + + let tab2 = BrowserTestUtils.addTab(gBrowser, TEST_URL2); + await BrowserTestUtils.browserLoaded(tab2.linkedBrowser, false, TEST_URL2); + + Interactions._pageViewStartTime = Cu.now() - 10000; + + BrowserTestUtils.removeTab(tab2); + + // This is checking a non-action, so let the event queue clear to try and + // detect any unexpected database writes. We wait for a few ticks to + // make it more likely. however if this fails it may show up as an + // intermittent. + await TestUtils.waitForTick(); + await TestUtils.waitForTick(); + await TestUtils.waitForTick(); + + await assertDatabaseValues([]); + + BrowserTestUtils.removeTab(tab1); + + // Only the interaction in the visible tab should have been recorded. + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + ]); +}); + +add_task(async function test_interactions_switch_tabs() { + await Interactions.reset(); + let tab1 = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + }); + + let tab2 = BrowserTestUtils.addTab(gBrowser, TEST_URL2); + await BrowserTestUtils.browserLoaded(tab2.linkedBrowser, false, TEST_URL2); + + info("Switch to second tab"); + Interactions._pageViewStartTime = Cu.now() - 10000; + gBrowser.selectedTab = tab2; + + // Only the interaction of the first tab should be recorded so far. + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + ]); + let tab1ViewTime = await getDatabaseValue(TEST_URL, "totalViewTime"); + + info("Switch back to first tab"); + Interactions._pageViewStartTime = Cu.now() - 20000; + gBrowser.selectedTab = tab1; + + // The interaction of the second tab should now be recorded. + await assertDatabaseValues([ + { + url: TEST_URL, + exactTotalViewTime: tab1ViewTime, + }, + { + url: TEST_URL2, + totalViewTime: 20000, + }, + ]); + + info("Switch to second tab again"); + Interactions._pageViewStartTime = Cu.now() - 30000; + gBrowser.selectedTab = tab2; + + // The interaction of the second tab should now be recorded. + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: tab1ViewTime + 30000, + }, + { + url: TEST_URL2, + totalViewTime: 20000, + }, + ]); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); + +add_task(async function test_interactions_switch_windows() { + await Interactions.reset(); + + // Open a tab in the first window. + let tabInOriginalWindow = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + }); + + // and then load the second window. + Interactions._pageViewStartTime = Cu.now() - 10000; + + let otherWin = await BrowserTestUtils.openNewBrowserWindow(); + + BrowserTestUtils.startLoadingURIString( + otherWin.gBrowser.selectedBrowser, + TEST_URL2 + ); + await BrowserTestUtils.browserLoaded( + otherWin.gBrowser.selectedBrowser, + false, + TEST_URL2 + ); + await SimpleTest.promiseFocus(otherWin); + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + ]); + let originalWindowViewTime = await getDatabaseValue( + TEST_URL, + "totalViewTime" + ); + + info("Switch back to original window"); + Interactions._pageViewStartTime = Cu.now() - 20000; + await SimpleTest.promiseFocus(window); + await assertDatabaseValues([ + { + url: TEST_URL, + exactTotalViewTime: originalWindowViewTime, + }, + { + url: TEST_URL2, + totalViewTime: 20000, + }, + ]); + let newWindowViewTime = await getDatabaseValue(TEST_URL2, "totalViewTime"); + + info("Switch back to new window"); + Interactions._pageViewStartTime = Cu.now() - 30000; + await SimpleTest.promiseFocus(otherWin); + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: originalWindowViewTime + 30000, + }, + { + url: TEST_URL2, + exactTotalViewTime: newWindowViewTime, + }, + ]); + + BrowserTestUtils.removeTab(tabInOriginalWindow); + await BrowserTestUtils.closeWindow(otherWin); +}); + +add_task(async function test_interactions_loading_in_unfocused_windows() { + await Interactions.reset(); + + let otherWin = await BrowserTestUtils.openNewBrowserWindow(); + + BrowserTestUtils.startLoadingURIString( + otherWin.gBrowser.selectedBrowser, + TEST_URL + ); + await BrowserTestUtils.browserLoaded( + otherWin.gBrowser.selectedBrowser, + false, + TEST_URL + ); + + Interactions._pageViewStartTime = Cu.now() - 10000; + + BrowserTestUtils.startLoadingURIString( + otherWin.gBrowser.selectedBrowser, + TEST_URL2 + ); + await BrowserTestUtils.browserLoaded( + otherWin.gBrowser.selectedBrowser, + false, + TEST_URL2 + ); + + // Only the interaction of the first tab should be recorded so far. + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + ]); + let newWindowViewTime = await getDatabaseValue(TEST_URL, "totalViewTime"); + + // Open a tab in the background window, and then navigate somewhere else, + // this should not record an intereaction. + let tabInOriginalWindow = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL3, + }); + + Interactions._pageViewStartTime = Cu.now() - 20000; + + BrowserTestUtils.startLoadingURIString( + tabInOriginalWindow.linkedBrowser, + TEST_URL4 + ); + await BrowserTestUtils.browserLoaded( + tabInOriginalWindow.linkedBrowser, + false, + TEST_URL4 + ); + + // Only the interaction of the first tab should be recorded so far. + await assertDatabaseValues([ + { + url: TEST_URL, + exactTotalViewTime: newWindowViewTime, + }, + ]); + + BrowserTestUtils.removeTab(tabInOriginalWindow); + await BrowserTestUtils.closeWindow(otherWin); +}); + +add_task(async function test_interactions_private_browsing() { + await Interactions.reset(); + + // Open a tab in the first window. + let tabInOriginalWindow = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + }); + + // and then load the second window. + Interactions._pageViewStartTime = Cu.now() - 10000; + + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + BrowserTestUtils.startLoadingURIString( + privateWin.gBrowser.selectedBrowser, + TEST_URL2 + ); + await BrowserTestUtils.browserLoaded( + privateWin.gBrowser.selectedBrowser, + false, + TEST_URL2 + ); + await SimpleTest.promiseFocus(privateWin); + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + ]); + let originalWindowViewTime = await getDatabaseValue( + TEST_URL, + "totalViewTime" + ); + + info("Switch back to original window"); + Interactions._pageViewStartTime = Cu.now() - 20000; + // As we're checking for a non-action, wait for the focus to have definitely + // completed, and then let the event queues clear. + await SimpleTest.promiseFocus(window); + await TestUtils.waitForTick(); + + // The private window site should not be recorded. + await assertDatabaseValues([ + { + url: TEST_URL, + exactTotalViewTime: originalWindowViewTime, + }, + ]); + + info("Switch back to new window"); + Interactions._pageViewStartTime = Cu.now() - 30000; + await SimpleTest.promiseFocus(privateWin); + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: originalWindowViewTime + 30000, + }, + ]); + + BrowserTestUtils.removeTab(tabInOriginalWindow); + await BrowserTestUtils.closeWindow(privateWin); +}); + +add_task(async function test_interactions_idle() { + await Interactions.reset(); + let lastViewTime; + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + Interactions._pageViewStartTime = Cu.now() - 10000; + + Interactions.observe(null, "idle", ""); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + ]); + lastViewTime = await getDatabaseValue(TEST_URL, "totalViewTime"); + + Interactions._pageViewStartTime = Cu.now() - 20000; + + Interactions.observe(null, "active", ""); + + await assertDatabaseValues([ + { + url: TEST_URL, + exactTotalViewTime: lastViewTime, + }, + ]); + + Interactions._pageViewStartTime = Cu.now() - 30000; + }); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: lastViewTime + 30000, + maxViewTime: lastViewTime + 30000 + 10000, + }, + ]); +}); diff --git a/browser/components/places/tests/browser/interactions/browser_interactions_view_time_dom_history.js b/browser/components/places/tests/browser/interactions/browser_interactions_view_time_dom_history.js new file mode 100644 index 0000000000..857a846417 --- /dev/null +++ b/browser/components/places/tests/browser/interactions/browser_interactions_view_time_dom_history.js @@ -0,0 +1,123 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests page view time recording for interactions after DOM history API usage. + */ + +const TEST_URL = "https://example.com/"; +const TEST_URL2 = "https://example.com/browser"; + +add_task(async function test_interactions_pushState() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + Interactions._pageViewStartTime = Cu.now() - 10000; + + await ContentTask.spawn(browser, TEST_URL2, url => { + content.history.pushState(null, "", url); + }); + + Interactions._pageViewStartTime = Cu.now() - 20000; + }); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + { + url: TEST_URL2, + totalViewTime: 20000, + }, + ]); +}); + +add_task(async function test_interactions_pushState_sameUrl() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + Interactions._pageViewStartTime = Cu.now() - 10000; + + await ContentTask.spawn(browser, TEST_URL, url => { + content.history.pushState(null, "", url); + }); + + Interactions._pageViewStartTime = Cu.now() - 20000; + }); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 20000, + }, + ]); +}); + +add_task(async function test_interactions_replaceState() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + Interactions._pageViewStartTime = Cu.now() - 10000; + + await ContentTask.spawn(browser, TEST_URL2, url => { + content.history.replaceState(null, "", url); + }); + + Interactions._pageViewStartTime = Cu.now() - 20000; + }); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + { + url: TEST_URL2, + totalViewTime: 20000, + }, + ]); +}); + +add_task(async function test_interactions_replaceState_sameUrl() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + Interactions._pageViewStartTime = Cu.now() - 10000; + + await ContentTask.spawn(browser, TEST_URL, url => { + content.history.replaceState(null, "", url); + }); + + Interactions._pageViewStartTime = Cu.now() - 20000; + }); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 20000, + }, + ]); +}); + +add_task(async function test_interactions_hashchange() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + Interactions._pageViewStartTime = Cu.now() - 10000; + + await ContentTask.spawn(browser, TEST_URL + "#foo", url => { + content.location = url; + }); + + Interactions._pageViewStartTime = Cu.now() - 20000; + }); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 20000, + }, + ]); +}); diff --git a/browser/components/places/tests/browser/interactions/head.js b/browser/components/places/tests/browser/interactions/head.js new file mode 100644 index 0000000000..92a096cc5f --- /dev/null +++ b/browser/components/places/tests/browser/interactions/head.js @@ -0,0 +1,201 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { Interactions } = ChromeUtils.importESModule( + "resource:///modules/Interactions.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "pageViewIdleTime", + "browser.places.interactions.pageViewIdleTime", + 60 +); + +add_setup(async function global_setup() { + // Disable idle management because it interacts with our code, causing + // unexpected intermittent failures, we'll fake idle notifications when + // we need to test it. + let idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService( + Ci.nsIUserIdleService + ); + idleService.removeIdleObserver(Interactions, pageViewIdleTime); + registerCleanupFunction(() => { + idleService.addIdleObserver(Interactions, pageViewIdleTime); + }); + + // Clean interactions for each test. + await Interactions.reset(); + registerCleanupFunction(async () => { + await Interactions.reset(); + }); +}); + +/** + * Ensures that a list of interactions have been permanently stored. + * + * @param {Array} expected list of interactions to be found. + * @param {boolean} [dontFlush] Avoid flushing pending data. + */ +async function assertDatabaseValues(expected, { dontFlush = false } = {}) { + await Interactions.interactionUpdatePromise; + if (!dontFlush) { + await Interactions.store.flush(); + } + + let interactions = await PlacesUtils.withConnectionWrapper( + "head.js::assertDatabaseValues", + async db => { + let rows = await db.execute(` + SELECT h.url AS url, h2.url as referrer_url, total_view_time, key_presses, typing_time, scrolling_time, scrolling_distance + FROM moz_places_metadata m + JOIN moz_places h ON h.id = m.place_id + LEFT JOIN moz_places h2 ON h2.id = m.referrer_place_id + ORDER BY created_at ASC + `); + return rows.map(r => ({ + url: r.getResultByName("url"), + referrerUrl: r.getResultByName("referrer_url"), + keypresses: r.getResultByName("key_presses"), + typingTime: r.getResultByName("typing_time"), + totalViewTime: r.getResultByName("total_view_time"), + scrollingTime: r.getResultByName("scrolling_time"), + scrollingDistance: r.getResultByName("scrolling_distance"), + })); + } + ); + info( + `Found ${interactions.length} interactions:\n ${JSON.stringify( + interactions + )}` + ); + Assert.equal( + interactions.length, + expected.length, + "Found the expected number of entries" + ); + for (let i = 0; i < Math.min(expected.length, interactions.length); i++) { + let actual = interactions[i]; + Assert.equal( + actual.url, + expected[i].url, + "Should have saved the page into the database" + ); + + if (expected[i].exactTotalViewTime != undefined) { + Assert.equal( + actual.totalViewTime, + expected[i].exactTotalViewTime, + "Should have kept the exact time." + ); + } else if (expected[i].totalViewTime != undefined) { + Assert.greaterOrEqual( + actual.totalViewTime, + expected[i].totalViewTime, + "Should have stored the interaction time" + ); + } + + if (expected[i].maxViewTime != undefined) { + Assert.less( + actual.totalViewTime, + expected[i].maxViewTime, + "Should have recorded an interaction below the maximum expected" + ); + } + + if (expected[i].keypresses != undefined) { + Assert.equal( + actual.keypresses, + expected[i].keypresses, + "Should have saved the keypresses into the database" + ); + } + + if (expected[i].exactTypingTime != undefined) { + Assert.equal( + actual.typingTime, + expected[i].exactTypingTime, + "Should have stored the exact typing time." + ); + } else if (expected[i].typingTimeIsGreaterThan != undefined) { + Assert.greater( + actual.typingTime, + expected[i].typingTimeIsGreaterThan, + "Should have stored at least this amount of typing time." + ); + } else if (expected[i].typingTimeIsLessThan != undefined) { + Assert.less( + actual.typingTime, + expected[i].typingTimeIsLessThan, + "Should have stored less than this amount of typing time." + ); + } + + if (expected[i].exactScrollingDistance != undefined) { + Assert.equal( + actual.scrollingDistance, + expected[i].exactScrollingDistance, + "Should have scrolled by exactly least this distance" + ); + } else if (expected[i].exactScrollingTime != undefined) { + Assert.greater( + actual.scrollingTime, + expected[i].exactScrollingTime, + "Should have scrolled for exactly least this duration" + ); + } + + if (expected[i].scrollingDistanceIsGreaterThan != undefined) { + Assert.greater( + actual.scrollingDistance, + expected[i].scrollingDistanceIsGreaterThan, + "Should have scrolled by at least this distance" + ); + } else if (expected[i].scrollingTimeIsGreaterThan != undefined) { + Assert.greater( + actual.scrollingTime, + expected[i].scrollingTimeIsGreaterThan, + "Should have scrolled for at least this duration" + ); + } + } +} + +/** + * Ensures that a list of interactions have been permanently stored. + * + * @param {string} url The url to query. + * @param {string} property The property to extract. + */ +async function getDatabaseValue(url, property) { + await Interactions.store.flush(); + const PROP_TRANSLATOR = { + totalViewTime: "total_view_time", + keypresses: "key_presses", + typingTime: "typing_time", + }; + property = PROP_TRANSLATOR[property] || property; + + return PlacesUtils.withConnectionWrapper( + "head.js::getDatabaseValue", + async db => { + let rows = await db.execute( + ` + SELECT * FROM moz_places_metadata m + JOIN moz_places h ON h.id = m.place_id + WHERE url = :url + ORDER BY created_at DESC + `, + { url } + ); + return rows?.[0].getResultByName(property); + } + ); +} diff --git a/browser/components/places/tests/browser/interactions/scrolling.html b/browser/components/places/tests/browser/interactions/scrolling.html new file mode 100644 index 0000000000..ce435097e6 --- /dev/null +++ b/browser/components/places/tests/browser/interactions/scrolling.html @@ -0,0 +1,121 @@ + + + + + + + + + +

    Scrolling interaction tests

    + +


    + +
    +
    + Lorem ipsum dolor sit amet, consectetur adipiscing elit.
    Maecenas vitae condimentum erat, vel luctus nulla.
    Praesent nulla magna, rhoncus quis velit eu, dapibus efficitur lectus.
    Duis varius eleifend ex.
    Vivamus tristique faucibus tortor, in commodo felis posuere eu.
    Sed aliquam tristique enim at volutpat.
    Phasellus a aliquam quam, ac hendrerit libero.
    Vivamus erat mi, mattis sit amet gravida id, malesuada ac lacus.
    Ut commodo lobortis egestas.
    Vivamus vulputate nisi non ligula ullamcorper pulvinar.
    Phasellus tincidunt leo et venenatis auctor.
    Proin ac urna et risus ullamcorper viverra.
    Morbi non nunc at augue tincidunt scelerisque.
    Proin vel ligula erat.
    Aliquam dignissim pharetra leo, sit amet molestie sem efficitur non. + Donec mattis porttitor consectetur.
    Nulla vulputate metus quis tellus pharetra, id maximus lacus mattis.
    Donec a mi mattis, feugiat lectus a, rutrum leo.
    Suspendisse et tellus urna.
    Quisque porta ex at efficitur venenatis.
    Sed volutpat malesuada mauris, sed dapibus dolor faucibus sed.
    Pellentesque tristique tortor a nisl imperdiet convallis.
    Curabitur ornare pharetra lacus at luctus.
    +
    +
    + +
    + +

    + + click to scroll to bottom + + +
    + + +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    + +
    +
    Scroll inside me!
    +
    + +

    Middle

    + This is the middle anchor #1 + + click to scroll to top + +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    +









    + This is the bottom anchor #3 + + + diff --git a/browser/components/places/tests/browser/interactions/scrolling_subframe.html b/browser/components/places/tests/browser/interactions/scrolling_subframe.html new file mode 100644 index 0000000000..55ad70f295 --- /dev/null +++ b/browser/components/places/tests/browser/interactions/scrolling_subframe.html @@ -0,0 +1,25 @@ + + + + + + + + + Subframe Content
    + Lorem ipsum dolor sit amet, consectetur adipiscing elit.
    Maecenas vitae condimentum erat, vel luctus nulla.
    Praesent nulla magna, rhoncus quis velit eu, dapibus efficitur lectus.
    Duis varius eleifend ex.
    Vivamus tristique faucibus tortor, in commodo felis posuere eu.
    Sed aliquam tristique enim at volutpat.
    Phasellus a aliquam quam, ac hendrerit libero.
    Vivamus erat mi, mattis sit amet gravida id, malesuada ac lacus.
    Ut commodo lobortis egestas.
    Vivamus vulputate nisi non ligula ullamcorper pulvinar.
    Phasellus tincidunt leo et venenatis auctor.
    Proin ac urna et risus ullamcorper viverra.
    Morbi non nunc at augue tincidunt scelerisque.
    Proin vel ligula erat.
    Aliquam dignissim pharetra leo, sit amet molestie sem efficitur non. + Donec mattis porttitor consectetur.
    Nulla vulputate metus quis tellus pharetra, id maximus lacus mattis.
    Donec a mi mattis, feugiat lectus a, rutrum leo.
    Suspendisse et tellus urna.
    Quisque porta ex at efficitur venenatis.
    Sed volutpat malesuada mauris, sed dapibus dolor faucibus sed.
    Pellentesque tristique tortor a nisl imperdiet convallis.
    Curabitur ornare pharetra lacus at luctus.
    + + + diff --git a/browser/components/places/tests/browser/keyword_form.html b/browser/components/places/tests/browser/keyword_form.html new file mode 100644 index 0000000000..a881c0d5ad --- /dev/null +++ b/browser/components/places/tests/browser/keyword_form.html @@ -0,0 +1,17 @@ + + + + + + + +
    + + +
    +
    + + +
    + + diff --git a/browser/components/places/tests/browser/pageopeningwindow.html b/browser/components/places/tests/browser/pageopeningwindow.html new file mode 100644 index 0000000000..855f67626e --- /dev/null +++ b/browser/components/places/tests/browser/pageopeningwindow.html @@ -0,0 +1,10 @@ + +Hi, I was opened via a
    + diff --git a/browser/components/places/tests/browser/sidebarpanels_click_test_page.html b/browser/components/places/tests/browser/sidebarpanels_click_test_page.html new file mode 100644 index 0000000000..c73eaa5403 --- /dev/null +++ b/browser/components/places/tests/browser/sidebarpanels_click_test_page.html @@ -0,0 +1,7 @@ + + + browser_sidebarpanels_click.js test page + + + + diff --git a/browser/components/places/tests/chrome/chrome.toml b/browser/components/places/tests/chrome/chrome.toml new file mode 100644 index 0000000000..ca953fe898 --- /dev/null +++ b/browser/components/places/tests/chrome/chrome.toml @@ -0,0 +1,14 @@ +[DEFAULT] +support-files = ["head.js"] + +["test_0_bug510634.xhtml"] + +["test_bug549192.xhtml"] + +["test_bug549491.xhtml"] + +["test_bug1163447_selectItems_through_shortcut.xhtml"] + +["test_selectItems_on_nested_tree.xhtml"] + +["test_treeview_date.xhtml"] diff --git a/browser/components/places/tests/chrome/head.js b/browser/components/places/tests/chrome/head.js new file mode 100644 index 0000000000..6a19fd89d3 --- /dev/null +++ b/browser/components/places/tests/chrome/head.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Services.scriptloader.loadSubScript( + "chrome://global/content/globalOverlay.js", + this +); +Services.scriptloader.loadSubScript( + "chrome://browser/content/utilityOverlay.js", + this +); + +ChromeUtils.defineESModuleGetters(this, { + BrowserTestUtils: "resource://testing-common/BrowserTestUtils.sys.mjs", + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", +}); + +ChromeUtils.defineESModuleGetters(window, { + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs", +}); + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +XPCOMUtils.defineLazyScriptGetter( + window, + ["PlacesTreeView"], + "chrome://browser/content/places/treeView.js" +); +XPCOMUtils.defineLazyScriptGetter( + window, + ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"], + "chrome://browser/content/places/controller.js" +); diff --git a/browser/components/places/tests/chrome/test_0_bug510634.xhtml b/browser/components/places/tests/chrome/test_0_bug510634.xhtml new file mode 100644 index 0000000000..8cac56ff7c --- /dev/null +++ b/browser/components/places/tests/chrome/test_0_bug510634.xhtml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + diff --git a/browser/components/places/tests/chrome/test_bug1163447_selectItems_through_shortcut.xhtml b/browser/components/places/tests/chrome/test_bug1163447_selectItems_through_shortcut.xhtml new file mode 100644 index 0000000000..03f5d92572 --- /dev/null +++ b/browser/components/places/tests/chrome/test_bug1163447_selectItems_through_shortcut.xhtml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + diff --git a/browser/components/places/tests/chrome/test_bug549192.xhtml b/browser/components/places/tests/chrome/test_bug549192.xhtml new file mode 100644 index 0000000000..9f00e8b9c5 --- /dev/null +++ b/browser/components/places/tests/chrome/test_bug549192.xhtml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + diff --git a/browser/components/places/tests/chrome/test_bug549491.xhtml b/browser/components/places/tests/chrome/test_bug549491.xhtml new file mode 100644 index 0000000000..03fee4cc06 --- /dev/null +++ b/browser/components/places/tests/chrome/test_bug549491.xhtml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + diff --git a/browser/components/places/tests/chrome/test_selectItems_on_nested_tree.xhtml b/browser/components/places/tests/chrome/test_selectItems_on_nested_tree.xhtml new file mode 100644 index 0000000000..6dc2d33041 --- /dev/null +++ b/browser/components/places/tests/chrome/test_selectItems_on_nested_tree.xhtml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + diff --git a/browser/components/places/tests/chrome/test_treeview_date.xhtml b/browser/components/places/tests/chrome/test_treeview_date.xhtml new file mode 100644 index 0000000000..8a7853194d --- /dev/null +++ b/browser/components/places/tests/chrome/test_treeview_date.xhtml @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + diff --git a/browser/components/places/tests/marionette/manifest.toml b/browser/components/places/tests/marionette/manifest.toml new file mode 100644 index 0000000000..648f933814 --- /dev/null +++ b/browser/components/places/tests/marionette/manifest.toml @@ -0,0 +1,4 @@ +[DEFAULT] +run-if = ["buildapp == 'browser'"] + +["test_reopen_from_library.py"] diff --git a/browser/components/places/tests/marionette/test_reopen_from_library.py b/browser/components/places/tests/marionette/test_reopen_from_library.py new file mode 100644 index 0000000000..28f6ff3cd6 --- /dev/null +++ b/browser/components/places/tests/marionette/test_reopen_from_library.py @@ -0,0 +1,164 @@ +# 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 textwrap + +from marionette_driver import Wait +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestReopenFromLibrary(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestReopenFromLibrary, self).setUp() + + self.original_showForNewBookmarks_pref = self.marionette.get_pref( + "browser.bookmarks.editDialog.showForNewBookmarks" + ) + self.original_loadBookmarksInTabs_pref = self.marionette.get_pref( + "browser.tabs.loadBookmarksInTabs" + ) + + self.marionette.set_pref( + "browser.bookmarks.editDialog.showForNewBookmarks", False + ) + self.marionette.set_pref("browser.tabs.loadBookmarksInTabs", True) + + def tearDown(self): + self.close_all_windows() + + self.marionette.restart(in_app=False, clean=True) + + super(TestReopenFromLibrary, self).tearDown() + + def test_open_bookmark_from_library_with_no_browser_window_open(self): + bookmark_url = self.marionette.absolute_url("empty.html") + self.marionette.navigate(bookmark_url) + + self.marionette.set_context(self.marionette.CONTEXT_CHROME) + + star_button = self.marionette.find_element("id", "star-button-box") + + # Await for async updates to the star button before clicking on it, as + # clicks are ignored during status updates. + script = """\ + return window.BookmarkingUI.status != window.BookmarkingUI.STATUS_UPDATING; + """ + Wait(self.marionette).until( + lambda _: self.marionette.execute_script(textwrap.dedent(script)), + message="Failed waiting for star updates", + ) + + star_button.click() + + star_image = self.marionette.find_element("id", "star-button") + + def check(_): + return "true" in star_image.get_attribute("starred") + + Wait(self.marionette).until(check, message="Failed to star the page") + + win = self.open_chrome_window( + "chrome://browser/content/places/places.xhtml", False + ) + + self.marionette.close_chrome_window() + + self.marionette.switch_to_window(win) + + # Tree elements can't be accessed in the same way as regular elements, + # so this uses some code from the places tests and EventUtils.js to + # select the bookmark in the tree and then double-click it. + script = """\ + window.PlacesOrganizer.selectLeftPaneContainerByHierarchy( + PlacesUtils.bookmarks.virtualToolbarGuid + ); + + // Bookmarks may be imported and shift the expected one, so search + // for it. + let node; + for (let i = 1; i < window.ContentTree.view.result.root.childCount; ++i) { + node = window.ContentTree.view.view.nodeForTreeIndex(i); + if (node.uri.endsWith("empty.html")) { + break; + } + } + + window.ContentTree.view.selectNode(node); + + // Based on synthesizeDblClickOnSelectedTreeCell + let tree = window.ContentTree.view; + + if (tree.view.selection.count < 1) { + throw new Error("The test node should be successfully selected"); + } + // Get selection rowID. + let min = {}; + let max = {}; + tree.view.selection.getRangeAt(0, min, max); + let rowID = min.value; + tree.ensureRowIsVisible(rowID); + // Calculate the click coordinates. + let rect = tree.getCoordsForCellItem(rowID, tree.columns[0], "text"); + let x = rect.x + rect.width / 2; + let y = rect.y + rect.height / 2; + let treeBodyRect = tree.body.getBoundingClientRect(); + return [treeBodyRect.left + x, treeBodyRect.top + y] + """ + + position = self.marionette.execute_script(textwrap.dedent(script)) + # These must be integers for pointer_move + x = round(position[0]) + y = round(position[1]) + + self.marionette.actions.sequence( + "pointer", "pointer_id", {"pointerType": "mouse"} + ).pointer_move(x, y).click().click().perform() + + def window_with_url_open(_): + urls_in_windows = self.get_urls_for_windows() + + for urls in urls_in_windows: + if bookmark_url in urls: + return True + return False + + Wait(self.marionette).until( + window_with_url_open, + message="Failed to open the browser window from the library", + ) + + # Closes the library window. + self.marionette.close_chrome_window() + + def get_urls_for_windows(self): + # There's no guarantee that Marionette will return us an + # iterator for the opened windows that will match the + # order within our window list. Instead, we'll convert + # the list of URLs within each open window to a set of + # tuples that will allow us to do a direct comparison + # while allowing the windows to be in any order. + opened_urls = set() + for win in self.marionette.chrome_window_handles: + urls = tuple(self.get_urls_for_window(win)) + opened_urls.add(urls) + + return opened_urls + + def get_urls_for_window(self, win): + orig_handle = self.marionette.current_chrome_window_handle + + try: + self.marionette.switch_to_window(win) + return self.marionette.execute_script( + """ + if (!window?.gBrowser) { + return []; + } + return window.gBrowser.tabs.map(tab => { + return tab.linkedBrowser.currentURI.spec; + }); + """ + ) + finally: + self.marionette.switch_to_window(orig_handle) diff --git a/browser/components/places/tests/unit/bookmarks.glue.html b/browser/components/places/tests/unit/bookmarks.glue.html new file mode 100644 index 0000000000..07b22e9b3f --- /dev/null +++ b/browser/components/places/tests/unit/bookmarks.glue.html @@ -0,0 +1,16 @@ + + + +Bookmarks +

    Bookmarks Menu

    + +

    +

    example +

    Bookmarks Toolbar

    +
    Add bookmarks to this folder to see them displayed on the Bookmarks Toolbar +

    +

    example +

    +

    diff --git a/browser/components/places/tests/unit/bookmarks.glue.json b/browser/components/places/tests/unit/bookmarks.glue.json new file mode 100644 index 0000000000..069f605d29 --- /dev/null +++ b/browser/components/places/tests/unit/bookmarks.glue.json @@ -0,0 +1,83 @@ +{ + "title": "", + "id": 1, + "dateAdded": 1233157910552624, + "lastModified": 1233157955206833, + "type": "text/x-moz-place-container", + "root": "placesRoot", + "children": [ + { + "title": "Bookmarks Menu", + "id": 2, + "parent": 1, + "dateAdded": 1233157910552624, + "lastModified": 1233157993171424, + "type": "text/x-moz-place-container", + "root": "bookmarksMenuFolder", + "children": [ + { + "title": "examplejson", + "id": 27, + "parent": 2, + "dateAdded": 1233157972101126, + "lastModified": 1233157984999673, + "type": "text/x-moz-place", + "uri": "http://example.com/" + } + ] + }, + { + "index": 1, + "title": "Bookmarks Toolbar", + "id": 3, + "parent": 1, + "dateAdded": 1233157910552624, + "lastModified": 1233157972101126, + "annos": [ + { + "name": "bookmarkProperties/description", + "flags": 0, + "expires": 4, + "mimeType": null, + "type": 3, + "value": "Add bookmarks to this folder to see them displayed on the Bookmarks Toolbar" + } + ], + "type": "text/x-moz-place-container", + "root": "toolbarFolder", + "children": [ + { + "title": "examplejson", + "id": 26, + "parent": 3, + "dateAdded": 1233157972101126, + "lastModified": 1233157984999673, + "type": "text/x-moz-place", + "uri": "http://example.com/" + } + ] + }, + { + "index": 2, + "title": "Tags", + "id": 4, + "parent": 1, + "dateAdded": 1233157910552624, + "lastModified": 1233157910582667, + "type": "text/x-moz-place-container", + "root": "tagsFolder", + "children": [] + }, + { + "index": 3, + "title": "Other Bookmarks", + "id": 5, + "parent": 1, + "dateAdded": 1233157910552624, + "lastModified": 1233157911033315, + "type": "text/x-moz-place-container", + "root": "unfiledBookmarksFolder", + "children": [] + } + ] +} diff --git a/browser/components/places/tests/unit/corruptDB.sqlite b/browser/components/places/tests/unit/corruptDB.sqlite new file mode 100644 index 0000000000..b234246cac Binary files /dev/null and b/browser/components/places/tests/unit/corruptDB.sqlite differ diff --git a/browser/components/places/tests/unit/distribution.ini b/browser/components/places/tests/unit/distribution.ini new file mode 100644 index 0000000000..a25c40fed3 --- /dev/null +++ b/browser/components/places/tests/unit/distribution.ini @@ -0,0 +1,30 @@ +# Distribution Configuration File +# Bug 516444 demo + +[Global] +id=516444 +version=1.0 +about=Test distribution file + +[BookmarksToolbar] +item.1.title=Toolbar Link Before +item.1.link=https://example.org/toolbar/before/ +item.1.icon=https://example.org/favicon.png +item.1.iconData=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg== +item.2.type=default +item.3.type=folder +item.3.title=Toolbar Folder After +item.3.folderId=1 + +[BookmarksMenu] +item.1.title=Menu Link Before +item.1.link=https://example.org/menu/before/ +item.1.icon=https://example.org/favicon.png +item.1.iconData=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg== +item.2.type=default +item.3.title=Menu Link After +item.3.link=https://example.org/menu/after/ + +[BookmarksFolder-1] +item.1.title=Toolbar Link Folder +item.1.link=https://example.org/toolbar/folder/ diff --git a/browser/components/places/tests/unit/head_bookmarks.js b/browser/components/places/tests/unit/head_bookmarks.js new file mode 100644 index 0000000000..db6bfe8f0f --- /dev/null +++ b/browser/components/places/tests/unit/head_bookmarks.js @@ -0,0 +1,82 @@ +/* -*- 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 common head. +/* import-globals-from ../../../../../toolkit/components/places/tests/head_common.js */ +var commonFile = do_get_file( + "../../../../../toolkit/components/places/tests/head_common.js", + false +); +if (commonFile) { + let uri = Services.io.newFileURI(commonFile); + Services.scriptloader.loadSubScript(uri.spec, this); +} + +// Put any other stuff relative to this test folder below. + +ChromeUtils.defineESModuleGetters(this, { + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", +}); + +// Needed by some test that relies on having an app registered. +const { updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" +); +updateAppInfo({ + name: "PlacesTest", + ID: "{230de50e-4cd1-11dc-8314-0800200c9a66}", + version: "1", + platformVersion: "", +}); + +// Default bookmarks constants. +const DEFAULT_BOOKMARKS_ON_TOOLBAR = 1; +const DEFAULT_BOOKMARKS_ON_MENU = 1; + +var createCorruptDB = async function () { + let dbPath = PathUtils.join(PathUtils.profileDir, "places.sqlite"); + await IOUtils.remove(dbPath); + + // Create a corrupt database. + let src = PathUtils.join(do_get_cwd().path, "corruptDB.sqlite"); + await IOUtils.copy(src, dbPath); + + // Check there's a DB now. + Assert.ok(await IOUtils.exists(dbPath), "should have a DB now"); +}; + +const SINGLE_TRY_TIMEOUT = 100; +const NUMBER_OF_TRIES = 30; + +/** + * Similar to waitForConditionPromise, but poll for an asynchronous value + * every SINGLE_TRY_TIMEOUT ms, for no more than tryCount times. + * + * @param {Function} promiseFn + * A function to generate a promise, which resolves to the expected + * asynchronous value. + * @param {msg} timeoutMsg + * The reason to reject the returned promise with. + * @param {number} [tryCount] + * Maximum times to try before rejecting the returned promise with + * timeoutMsg, defaults to NUMBER_OF_TRIES. + * @returns {Promise} to the asynchronous value being polled. + * @throws if the asynchronous value is not available after tryCount attempts. + */ +var waitForResolvedPromise = async function ( + promiseFn, + timeoutMsg, + tryCount = NUMBER_OF_TRIES +) { + let tries = 0; + do { + try { + let value = await promiseFn(); + return value; + } catch (ex) {} + await new Promise(resolve => do_timeout(SINGLE_TRY_TIMEOUT, resolve)); + } while (++tries <= tryCount); + throw new Error(timeoutMsg); +}; diff --git a/browser/components/places/tests/unit/test_PUIU_batchUpdatesForNode.js b/browser/components/places/tests/unit/test_PUIU_batchUpdatesForNode.js new file mode 100644 index 0000000000..58b68d7574 --- /dev/null +++ b/browser/components/places/tests/unit/test_PUIU_batchUpdatesForNode.js @@ -0,0 +1,107 @@ +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +/* eslint-disable mozilla/use-chromeutils-generateqi */ + +add_task(async function test_no_result_node() { + let functionSpy = sinon.stub().returns(Promise.resolve()); + + await PlacesUIUtils.batchUpdatesForNode(null, 1, functionSpy); + + Assert.ok( + functionSpy.calledOnce, + "Passing a null result node should still call the wrapped function" + ); +}); + +add_task(async function test_under_batch_threshold() { + let functionSpy = sinon.stub().returns(Promise.resolve()); + let resultNode = { + QueryInterface() { + return this; + }, + onBeginUpdateBatch: sinon.spy(), + onEndUpdateBatch: sinon.spy(), + }; + + await PlacesUIUtils.batchUpdatesForNode(resultNode, 1, functionSpy); + + Assert.ok(functionSpy.calledOnce, "Wrapped function should be called once"); + Assert.ok( + resultNode.onBeginUpdateBatch.notCalled, + "onBeginUpdateBatch should not have been called" + ); + Assert.ok( + resultNode.onEndUpdateBatch.notCalled, + "onEndUpdateBatch should not have been called" + ); +}); + +add_task(async function test_over_batch_threshold() { + let functionSpy = sinon.stub().callsFake(() => { + Assert.ok( + resultNode.onBeginUpdateBatch.calledOnce, + "onBeginUpdateBatch should have been called before the function" + ); + Assert.ok( + resultNode.onEndUpdateBatch.notCalled, + "onEndUpdateBatch should not have been called before the function" + ); + + return Promise.resolve(); + }); + let resultNode = { + QueryInterface() { + return this; + }, + onBeginUpdateBatch: sinon.spy(), + onEndUpdateBatch: sinon.spy(), + }; + + await PlacesUIUtils.batchUpdatesForNode(resultNode, 100, functionSpy); + + Assert.ok(functionSpy.calledOnce, "Wrapped function should be called once"); + Assert.ok( + resultNode.onBeginUpdateBatch.calledOnce, + "onBeginUpdateBatch should have been called" + ); + Assert.ok( + resultNode.onEndUpdateBatch.calledOnce, + "onEndUpdateBatch should have been called" + ); +}); + +add_task(async function test_wrapped_function_throws() { + let error = new Error("Failed!"); + let functionSpy = sinon.stub().throws(error); + let resultNode = { + QueryInterface() { + return this; + }, + onBeginUpdateBatch: sinon.spy(), + onEndUpdateBatch: sinon.spy(), + }; + + let raisedError; + try { + await PlacesUIUtils.batchUpdatesForNode(resultNode, 100, functionSpy); + } catch (ex) { + raisedError = ex; + } + + Assert.ok(functionSpy.calledOnce, "Wrapped function should be called once"); + Assert.ok( + resultNode.onBeginUpdateBatch.calledOnce, + "onBeginUpdateBatch should have been called" + ); + Assert.ok( + resultNode.onEndUpdateBatch.calledOnce, + "onEndUpdateBatch should have been called" + ); + Assert.equal( + raisedError, + error, + "batchUpdatesForNode should have raised the error from the wrapped function" + ); +}); diff --git a/browser/components/places/tests/unit/test_PUIU_setCharsetForPage.js b/browser/components/places/tests/unit/test_PUIU_setCharsetForPage.js new file mode 100644 index 0000000000..db213971e9 --- /dev/null +++ b/browser/components/places/tests/unit/test_PUIU_setCharsetForPage.js @@ -0,0 +1,141 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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 { PrivateBrowsingUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PrivateBrowsingUtils.sys.mjs" +); + +const UTF8 = "UTF-8"; +const UTF16 = "UTF-16"; + +const TEST_URI = "http://foo.com"; +const TEST_BOOKMARKED_URI = "http://bar.com"; + +add_task(function setup() { + let savedIsWindowPrivateFunc = PrivateBrowsingUtils.isWindowPrivate; + PrivateBrowsingUtils.isWindowPrivate = () => false; + + registerCleanupFunction(() => { + PrivateBrowsingUtils.isWindowPrivate = savedIsWindowPrivateFunc; + }); +}); + +add_task(async function test_simple_add() { + // add pages to history + await PlacesTestUtils.addVisits(TEST_URI); + await PlacesTestUtils.addVisits(TEST_BOOKMARKED_URI); + + // create bookmarks on TEST_BOOKMARKED_URI + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_BOOKMARKED_URI, + title: TEST_BOOKMARKED_URI.spec, + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_BOOKMARKED_URI, + title: TEST_BOOKMARKED_URI.spec, + }); + + // set charset on not-bookmarked page + await PlacesUIUtils.setCharsetForPage(TEST_URI, UTF16, {}); + // set charset on bookmarked page + await PlacesUIUtils.setCharsetForPage(TEST_BOOKMARKED_URI, UTF16, {}); + + let pageInfo = await PlacesUtils.history.fetch(TEST_URI, { + includeAnnotations: true, + }); + Assert.equal( + pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO), + UTF16, + "Should return correct charset for a not-bookmarked page" + ); + + pageInfo = await PlacesUtils.history.fetch(TEST_BOOKMARKED_URI, { + includeAnnotations: true, + }); + Assert.equal( + pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO), + UTF16, + "Should return correct charset for a bookmarked page" + ); + + await PlacesUtils.history.clear(); + + pageInfo = await PlacesUtils.history.fetch(TEST_URI, { + includeAnnotations: true, + }); + Assert.ok( + !pageInfo, + "Should not return pageInfo for a page after history cleared" + ); + + pageInfo = await PlacesUtils.history.fetch(TEST_BOOKMARKED_URI, { + includeAnnotations: true, + }); + Assert.equal( + pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO), + UTF16, + "Charset should still be set for a bookmarked page after history clear" + ); + + await PlacesUIUtils.setCharsetForPage(TEST_BOOKMARKED_URI, ""); + pageInfo = await PlacesUtils.history.fetch(TEST_BOOKMARKED_URI, { + includeAnnotations: true, + }); + Assert.strictEqual( + pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO), + undefined, + "Should not have a charset after it has been removed from the page" + ); +}); + +add_task(async function test_utf8_clears_saved_anno() { + await PlacesUtils.history.clear(); + await PlacesTestUtils.addVisits(TEST_URI); + + // set charset on bookmarked page + await PlacesUIUtils.setCharsetForPage(TEST_URI, UTF16, {}); + + let pageInfo = await PlacesUtils.history.fetch(TEST_URI, { + includeAnnotations: true, + }); + Assert.equal( + pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO), + UTF16, + "Should return correct charset for a not-bookmarked page" + ); + + // Now set the bookmark to a UTF-8 charset. + await PlacesUIUtils.setCharsetForPage(TEST_URI, UTF8, {}); + + pageInfo = await PlacesUtils.history.fetch(TEST_URI, { + includeAnnotations: true, + }); + Assert.strictEqual( + pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO), + undefined, + "Should have removed the charset for a UTF-8 page." + ); +}); + +add_task(async function test_private_browsing_not_saved() { + await PlacesUtils.history.clear(); + await PlacesTestUtils.addVisits(TEST_URI); + + // set charset on bookmarked page, but pretend this is a private browsing window. + PrivateBrowsingUtils.isWindowPrivate = () => true; + await PlacesUIUtils.setCharsetForPage(TEST_URI, UTF16, {}); + + let pageInfo = await PlacesUtils.history.fetch(TEST_URI, { + includeAnnotations: true, + }); + Assert.strictEqual( + pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO), + undefined, + "Should not have set the charset in a private browsing window." + ); +}); diff --git a/browser/components/places/tests/unit/test_PUIU_title_difference_spotter.js b/browser/components/places/tests/unit/test_PUIU_title_difference_spotter.js new file mode 100644 index 0000000000..aaa4db6bb2 --- /dev/null +++ b/browser/components/places/tests/unit/test_PUIU_title_difference_spotter.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the difference indication for titles. + */ + +const TESTS = [ + { + title: null, + expected: undefined, + }, + { + title: "Short title", + expected: undefined, + }, + { + title: "Short title2", + expected: undefined, + }, + { + title: "Long title same as another", + expected: undefined, + }, + { + title: "Long title same as another", + expected: undefined, + }, + { + title: "Long title with difference at the end 1", + expected: 38, + }, + { + title: "Long title with difference at the end 2", + expected: 38, + }, + { + title: "A long title with difference 123456 in the middle.", + expected: 30, + }, + { + title: "A long title with difference 135246 in the middle.", + expected: 30, + }, + { + title: + "Some long titles with variable 12345678 differences to 13572468 other titles", + expected: 32, + }, + { + title: + "Some long titles with variable 12345678 differences to 15263748 other titles", + expected: 32, + }, + { + title: + "Some long titles with variable 15263748 differences to 12345678 other titles", + expected: 32, + }, + { + title: "One long title which will be shorter than the other one", + expected: 40, + }, + { + title: + "One long title which will be shorter that the other one (not this one)", + expected: 40, + }, +]; + +add_task(async function test_difference_finding() { + PlacesUIUtils.insertTitleStartDiffs(TESTS); + + for (let result of TESTS) { + Assert.equal( + result.titleDifferentIndex, + result.expected, + `Should have returned the correct index for "${result.title}"` + ); + } +}); diff --git a/browser/components/places/tests/unit/test_browserGlue_bookmarkshtml.js b/browser/components/places/tests/unit/test_browserGlue_bookmarkshtml.js new file mode 100644 index 0000000000..65a038487a --- /dev/null +++ b/browser/components/places/tests/unit/test_browserGlue_bookmarkshtml.js @@ -0,0 +1,33 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests that nsBrowserGlue correctly exports bookmarks.html at shutdown if + * browser.bookmarks.autoExportHTML is set to true. + */ + +add_task(async function () { + remove_bookmarks_html(); + + Services.prefs.setBoolPref("browser.bookmarks.autoExportHTML", true); + registerCleanupFunction(() => + Services.prefs.clearUserPref("browser.bookmarks.autoExportHTML") + ); + + // Initialize nsBrowserGlue before Places. + Cc["@mozilla.org/browser/browserglue;1"].getService(Ci.nsISupports); + + // Initialize Places through the History Service. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_CREATE + ); + + Services.obs.addObserver(function observer() { + Services.obs.removeObserver(observer, "profile-before-change"); + check_bookmarks_html(); + }, "profile-before-change"); +}); diff --git a/browser/components/places/tests/unit/test_browserGlue_corrupt.js b/browser/components/places/tests/unit/test_browserGlue_corrupt.js new file mode 100644 index 0000000000..0514b06bbc --- /dev/null +++ b/browser/components/places/tests/unit/test_browserGlue_corrupt.js @@ -0,0 +1,53 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests that nsBrowserGlue correctly restores bookmarks from a JSON backup if + * database is corrupt and one backup is available. + */ + +function run_test() { + // Create our bookmarks.html from bookmarks.glue.html. + create_bookmarks_html("bookmarks.glue.html"); + + remove_all_JSON_backups(); + + // Create our JSON backup from bookmarks.glue.json. + create_JSON_backup("bookmarks.glue.json"); + + run_next_test(); +} + +registerCleanupFunction(function () { + remove_bookmarks_html(); + remove_all_JSON_backups(); + return PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_main() { + // Create a corrupt database. + await createCorruptDB(); + + // Initialize nsBrowserGlue before Places. + Cc["@mozilla.org/browser/browserglue;1"].getService(Ci.nsISupports); + + // Check the database was corrupt. + // nsBrowserGlue uses databaseStatus to manage initialization. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_CORRUPT + ); + + // The test will continue once restore has finished. + await promiseTopicObserved("places-browser-init-complete"); + + // Check that JSON backup has been restored. + let bm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }); + Assert.equal(bm.title, "examplejson"); +}); diff --git a/browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup.js b/browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup.js new file mode 100644 index 0000000000..a0bc93c74c --- /dev/null +++ b/browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup.js @@ -0,0 +1,47 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests that nsBrowserGlue correctly imports from bookmarks.html if database + * is corrupt but a JSON backup is not available. + */ + +function run_test() { + // Create our bookmarks.html from bookmarks.glue.html. + create_bookmarks_html("bookmarks.glue.html"); + + // Remove JSON backup from profile. + remove_all_JSON_backups(); + + run_next_test(); +} + +registerCleanupFunction(remove_bookmarks_html); + +add_task(async function () { + // Create a corrupt database. + await createCorruptDB(); + + // Initialize nsBrowserGlue before Places. + Cc["@mozilla.org/browser/browserglue;1"].getService(Ci.nsISupports); + + // Check the database was corrupt. + // nsBrowserGlue uses databaseStatus to manage initialization. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_CORRUPT + ); + + // The test will continue once import has finished. + await promiseTopicObserved("places-browser-init-complete"); + + // Check that bookmarks html has been restored. + let bm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }); + Assert.equal(bm.title, "example"); +}); diff --git a/browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup_default.js b/browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup_default.js new file mode 100644 index 0000000000..031d82d9e2 --- /dev/null +++ b/browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup_default.js @@ -0,0 +1,50 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests that nsBrowserGlue correctly restores default bookmarks if database is + * corrupt, nor a JSON backup nor bookmarks.html are available. + */ + +function run_test() { + // Remove bookmarks.html from profile. + remove_bookmarks_html(); + + // Remove JSON backup from profile. + remove_all_JSON_backups(); + + run_next_test(); +} + +add_task(async function () { + // Create a corrupt database. + await createCorruptDB(); + + // Initialize nsBrowserGlue before Places. + Cc["@mozilla.org/browser/browserglue;1"].getService(Ci.nsISupports); + + // Check the database was corrupt. + // nsBrowserGlue uses databaseStatus to manage initialization. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_CORRUPT + ); + + // The test will continue once import has finished. + await promiseTopicObserved("places-browser-init-complete"); + + // Check that default bookmarks have been restored. + let bm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }); + + // Bug 1283076: Nightly bookmark points to Get Involved page, not Getting Started one + let chanTitle = AppConstants.NIGHTLY_BUILD + ? "Get Involved" + : "Getting Started"; + Assert.equal(bm.title, chanTitle); +}); diff --git a/browser/components/places/tests/unit/test_browserGlue_distribution.js b/browser/components/places/tests/unit/test_browserGlue_distribution.js new file mode 100644 index 0000000000..5fafba457f --- /dev/null +++ b/browser/components/places/tests/unit/test_browserGlue_distribution.js @@ -0,0 +1,143 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that nsBrowserGlue correctly imports bookmarks from distribution.ini. + */ + +const PREF_BMPROCESSED = "distribution.516444.bookmarksProcessed"; +const PREF_DISTRIBUTION_ID = "distribution.id"; + +const TOPICDATA_DISTRIBUTION_CUSTOMIZATION = "force-distribution-customization"; +const TOPIC_CUSTOMIZATION_COMPLETE = "distribution-customization-complete"; +const TOPIC_BROWSERGLUE_TEST = "browser-glue-test"; + +function run_test() { + // Set special pref to load distribution.ini from the profile folder. + Services.prefs.setBoolPref("distribution.testing.loadFromProfile", true); + + // Copy distribution.ini file to the profile dir. + let distroDir = gProfD.clone(); + distroDir.leafName = "distribution"; + let iniFile = distroDir.clone(); + iniFile.append("distribution.ini"); + if (iniFile.exists()) { + iniFile.remove(false); + print("distribution.ini already exists, did some test forget to cleanup?"); + } + + let testDistributionFile = gTestDir.clone(); + testDistributionFile.append("distribution.ini"); + testDistributionFile.copyTo(distroDir, "distribution.ini"); + Assert.ok(testDistributionFile.exists()); + + run_next_test(); +} + +registerCleanupFunction(function () { + // Remove the distribution file, even if the test failed, otherwise all + // next tests will import it. + let iniFile = gProfD.clone(); + iniFile.leafName = "distribution"; + iniFile.append("distribution.ini"); + if (iniFile.exists()) { + iniFile.remove(false); + } + Assert.ok(!iniFile.exists()); +}); + +add_task(async function () { + let { DistributionCustomizer } = ChromeUtils.importESModule( + "resource:///modules/distribution.sys.mjs" + ); + let distribution = new DistributionCustomizer(); + + let glue = Cc["@mozilla.org/browser/browserglue;1"].getService( + Ci.nsIObserver + ); + // Initialize Places through the History Service and check that a new + // database has been created. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_CREATE + ); + // Force distribution. + glue.observe( + null, + TOPIC_BROWSERGLUE_TEST, + TOPICDATA_DISTRIBUTION_CUSTOMIZATION + ); + + // Test will continue on customization complete notification. + await promiseTopicObserved(TOPIC_CUSTOMIZATION_COMPLETE); + + // Check the custom bookmarks exist on menu. + let menuItem = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }); + Assert.equal(menuItem.title, "Menu Link Before"); + Assert.ok( + menuItem.guid.startsWith(distribution.BOOKMARK_GUID_PREFIX), + "Guid of this bookmark has expected prefix" + ); + + menuItem = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + index: 1 + DEFAULT_BOOKMARKS_ON_MENU, + }); + Assert.equal(menuItem.title, "Menu Link After"); + + // Check no favicon exists for this bookmark + await Assert.rejects( + waitForResolvedPromise( + () => { + return PlacesUtils.promiseFaviconData(menuItem.url.href); + }, + "Favicon not found", + 10 + ), + /Favicon\snot\sfound/, + "Favicon not found" + ); + + // Check the custom bookmarks exist on toolbar. + let toolbarItem = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }); + Assert.equal(toolbarItem.title, "Toolbar Link Before"); + + // Check the custom favicon exist for this bookmark + let faviconItem = await waitForResolvedPromise( + () => { + return PlacesUtils.promiseFaviconData(toolbarItem.url.href); + }, + "Favicon not found", + 10 + ); + Assert.equal(faviconItem.uri.spec, "https://example.org/favicon.png"); + Assert.greater(faviconItem.dataLen, 0); + Assert.equal(faviconItem.mimeType, "image/png"); + + let base64Icon = + "data:image/png;base64," + + base64EncodeString(String.fromCharCode.apply(String, faviconItem.data)); + Assert.equal(base64Icon, SMALLPNG_DATA_URI.spec); + + toolbarItem = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 1 + DEFAULT_BOOKMARKS_ON_TOOLBAR, + }); + Assert.equal(toolbarItem.title, "Toolbar Folder After"); + Assert.ok( + toolbarItem.guid.startsWith(distribution.FOLDER_GUID_PREFIX), + "Guid of this folder has expected prefix" + ); + + // Check the bmprocessed pref has been created. + Assert.ok(Services.prefs.getBoolPref(PREF_BMPROCESSED)); + + // Check distribution prefs have been created. + Assert.equal(Services.prefs.getCharPref(PREF_DISTRIBUTION_ID), "516444"); +}); diff --git a/browser/components/places/tests/unit/test_browserGlue_migrate.js b/browser/components/places/tests/unit/test_browserGlue_migrate.js new file mode 100644 index 0000000000..f0f88d2d17 --- /dev/null +++ b/browser/components/places/tests/unit/test_browserGlue_migrate.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that nsBrowserGlue does not overwrite bookmarks imported from the + * migrators. They usually run before nsBrowserGlue, so if we find any + * bookmark on init, we should not try to import. + */ + +function run_test() { + // Create our bookmarks.html from bookmarks.glue.html. + create_bookmarks_html("bookmarks.glue.html"); + + // Remove current database file. + clearDB(); + + run_next_test(); +} + +registerCleanupFunction(remove_bookmarks_html); + +add_task(async function test_migrate_bookmarks() { + // Initialize Places through the History Service and check that a new + // database has been created. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_CREATE + ); + + // A migrator would run before nsBrowserGlue Places initialization, so mimic + // that behavior adding a bookmark and notifying the migration. + let bg = Cc["@mozilla.org/browser/browserglue;1"].getService(Ci.nsIObserver); + bg.observe(null, "initial-migration-will-import-default-bookmarks", null); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://mozilla.org/", + title: "migrated", + }); + + let promise = promiseTopicObserved("places-browser-init-complete"); + bg.observe(null, "initial-migration-did-import-default-bookmarks", null); + await promise; + + // Check the created bookmark still exists. + let bm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }); + Assert.equal(bm.title, "migrated"); + + // Check that we have not imported any new bookmark. + Assert.ok( + !(await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + index: 1, + })) + ); + + Assert.ok( + !(await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + })) + ); +}); diff --git a/browser/components/places/tests/unit/test_browserGlue_prefs.js b/browser/components/places/tests/unit/test_browserGlue_prefs.js new file mode 100644 index 0000000000..af1dc3db0e --- /dev/null +++ b/browser/components/places/tests/unit/test_browserGlue_prefs.js @@ -0,0 +1,161 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that nsBrowserGlue is correctly interpreting the preferences settable + * by the user or by other components. + */ + +const PREF_IMPORT_BOOKMARKS_HTML = "browser.places.importBookmarksHTML"; +const PREF_RESTORE_DEFAULT_BOOKMARKS = + "browser.bookmarks.restore_default_bookmarks"; +const PREF_AUTO_EXPORT_HTML = "browser.bookmarks.autoExportHTML"; + +const TOPIC_BROWSERGLUE_TEST = "browser-glue-test"; +const TOPICDATA_FORCE_PLACES_INIT = "test-force-places-init"; + +var bg = Cc["@mozilla.org/browser/browserglue;1"].getService(Ci.nsIObserver); + +add_task(async function setup() { + // Create our bookmarks.html from bookmarks.glue.html. + create_bookmarks_html("bookmarks.glue.html"); + + remove_all_JSON_backups(); + + // Create our JSON backup from bookmarks.glue.json. + create_JSON_backup("bookmarks.glue.json"); + + registerCleanupFunction(function () { + remove_bookmarks_html(); + remove_all_JSON_backups(); + + return PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +function simulatePlacesInit() { + info("Simulate Places init"); + // Force nsBrowserGlue::_initPlaces(). + bg.observe(null, TOPIC_BROWSERGLUE_TEST, TOPICDATA_FORCE_PLACES_INIT); + return promiseTopicObserved("places-browser-init-complete"); +} + +add_task(async function test_checkPreferences() { + // Initialize Places through the History Service and check that a new + // database has been created. + let promiseComplete = promiseTopicObserved("places-browser-init-complete"); + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_CREATE + ); + await promiseComplete; + + // Ensure preferences status. + Assert.ok(!Services.prefs.getBoolPref(PREF_AUTO_EXPORT_HTML)); + + Assert.throws( + () => Services.prefs.getBoolPref(PREF_IMPORT_BOOKMARKS_HTML), + /NS_ERROR_UNEXPECTED/ + ); + Assert.throws( + () => Services.prefs.getBoolPref(PREF_RESTORE_DEFAULT_BOOKMARKS), + /NS_ERROR_UNEXPECTED/ + ); +}); + +add_task(async function test_import() { + info("Import from bookmarks.html if importBookmarksHTML is true."); + + await PlacesUtils.bookmarks.eraseEverything(); + + // Sanity check: we should not have any bookmark on the toolbar. + Assert.ok( + !(await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + })) + ); + + // Set preferences. + Services.prefs.setBoolPref(PREF_IMPORT_BOOKMARKS_HTML, true); + + await simulatePlacesInit(); + + // Check bookmarks.html has been imported. + let bm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }); + Assert.equal(bm.title, "example"); + + // Check preferences have been reverted. + Assert.ok(!Services.prefs.getBoolPref(PREF_IMPORT_BOOKMARKS_HTML)); +}); + +add_task(async function test_restore() { + info( + "restore from default bookmarks.html if " + + "restore_default_bookmarks is true." + ); + + await PlacesUtils.bookmarks.eraseEverything(); + + // Sanity check: we should not have any bookmark on the toolbar. + Assert.ok( + !(await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + })) + ); + + // Set preferences. + Services.prefs.setBoolPref(PREF_RESTORE_DEFAULT_BOOKMARKS, true); + + await simulatePlacesInit(); + + // Check bookmarks.html has been restored. + Assert.ok( + await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }) + ); + + // Check preferences have been reverted. + Assert.ok(!Services.prefs.getBoolPref(PREF_RESTORE_DEFAULT_BOOKMARKS)); +}); + +add_task(async function test_restore_import() { + info( + "setting both importBookmarksHTML and " + + "restore_default_bookmarks should restore defaults." + ); + + await PlacesUtils.bookmarks.eraseEverything(); + + // Sanity check: we should not have any bookmark on the toolbar. + Assert.ok( + !(await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + })) + ); + + // Set preferences. + Services.prefs.setBoolPref(PREF_IMPORT_BOOKMARKS_HTML, true); + Services.prefs.setBoolPref(PREF_RESTORE_DEFAULT_BOOKMARKS, true); + + await simulatePlacesInit(); + + // Check bookmarks.html has been restored. + Assert.ok( + await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }) + ); + + // Check preferences have been reverted. + Assert.ok(!Services.prefs.getBoolPref(PREF_RESTORE_DEFAULT_BOOKMARKS)); + Assert.ok(!Services.prefs.getBoolPref(PREF_IMPORT_BOOKMARKS_HTML)); +}); diff --git a/browser/components/places/tests/unit/test_browserGlue_restore.js b/browser/components/places/tests/unit/test_browserGlue_restore.js new file mode 100644 index 0000000000..98c8d1d2d6 --- /dev/null +++ b/browser/components/places/tests/unit/test_browserGlue_restore.js @@ -0,0 +1,55 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests that nsBrowserGlue correctly restores bookmarks from a JSON backup if + * database has been created and one backup is available. + */ + +function run_test() { + // Create our bookmarks.html from bookmarks.glue.html. + create_bookmarks_html("bookmarks.glue.html"); + + remove_all_JSON_backups(); + + // Create our JSON backup from bookmarks.glue.json. + create_JSON_backup("bookmarks.glue.json"); + + // Remove current database file. + clearDB(); + + run_next_test(); +} + +registerCleanupFunction(function () { + remove_bookmarks_html(); + remove_all_JSON_backups(); + return PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_main() { + // Initialize nsBrowserGlue before Places. + Cc["@mozilla.org/browser/browserglue;1"].getService(Ci.nsISupports); + + // Initialize Places through the History Service. + let hs = Cc["@mozilla.org/browser/nav-history-service;1"].getService( + Ci.nsINavHistoryService + ); + + // Check a new database has been created. + // nsBrowserGlue uses databaseStatus to manage initialization. + Assert.equal(hs.databaseStatus, hs.DATABASE_STATUS_CREATE); + + // The test will continue once restore has finished. + await promiseTopicObserved("places-browser-init-complete"); + + // Check that JSON backup has been restored. + let bm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }); + Assert.equal(bm.title, "examplejson"); +}); diff --git a/browser/components/places/tests/unit/test_clearHistory_shutdown.js b/browser/components/places/tests/unit/test_clearHistory_shutdown.js new file mode 100644 index 0000000000..27b432e569 --- /dev/null +++ b/browser/components/places/tests/unit/test_clearHistory_shutdown.js @@ -0,0 +1,183 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests that requesting clear history at shutdown will really clear history. + */ + +const URIS = [ + "http://a.example1.com/", + "http://b.example1.com/", + "http://b.example2.com/", + "http://c.example3.com/", +]; + +const FTP_URL = "ftp://localhost/clearHistoryOnShutdown/"; + +const { Sanitizer } = ChromeUtils.importESModule( + "resource:///modules/Sanitizer.sys.mjs" +); + +// Send the profile-after-change notification to the form history component to ensure +// that it has been initialized. +var formHistoryStartup = Cc[ + "@mozilla.org/satchel/form-history-startup;1" +].getService(Ci.nsIObserver); +formHistoryStartup.observe(null, "profile-after-change", null); +ChromeUtils.defineESModuleGetters(this, { + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", +}); + +var timeInMicroseconds = Date.now() * 1000; + +add_task(async function test_execute() { + info("Initialize browserglue before Places"); + + // Avoid default bookmarks import. + let glue = Cc["@mozilla.org/browser/browserglue;1"].getService( + Ci.nsIObserver + ); + glue.observe(null, "initial-migration-will-import-default-bookmarks", null); + Sanitizer.onStartup(); + + Services.prefs.setBoolPref(Sanitizer.PREF_SHUTDOWN_BRANCH + "cache", true); + Services.prefs.setBoolPref(Sanitizer.PREF_SHUTDOWN_BRANCH + "cookies", true); + Services.prefs.setBoolPref( + Sanitizer.PREF_SHUTDOWN_BRANCH + "offlineApps", + true + ); + Services.prefs.setBoolPref(Sanitizer.PREF_SHUTDOWN_BRANCH + "history", true); + Services.prefs.setBoolPref( + Sanitizer.PREF_SHUTDOWN_BRANCH + "downloads", + true + ); + Services.prefs.setBoolPref(Sanitizer.PREF_SHUTDOWN_BRANCH + "cookies", true); + Services.prefs.setBoolPref(Sanitizer.PREF_SHUTDOWN_BRANCH + "formData", true); + Services.prefs.setBoolPref(Sanitizer.PREF_SHUTDOWN_BRANCH + "sessions", true); + Services.prefs.setBoolPref( + Sanitizer.PREF_SHUTDOWN_BRANCH + "siteSettings", + true + ); + + Services.prefs.setBoolPref(Sanitizer.PREF_SANITIZE_ON_SHUTDOWN, true); + + info("Add visits."); + for (let aUrl of URIS) { + await PlacesTestUtils.addVisits({ + uri: uri(aUrl), + visitDate: timeInMicroseconds++, + transition: PlacesUtils.history.TRANSITION_TYPED, + }); + } + info("Add cache."); + await storeCache(FTP_URL, "testData"); + info("Add form history."); + await addFormHistory(); + Assert.equal(await getFormHistoryCount(), 1, "Added form history"); + + info("Simulate and wait shutdown."); + await shutdownPlaces(); + + Assert.equal(await getFormHistoryCount(), 0, "Form history cleared"); + + let stmt = DBConn(true).createStatement( + "SELECT id FROM moz_places WHERE url = :page_url " + ); + + try { + URIS.forEach(function (aUrl) { + stmt.params.page_url = aUrl; + Assert.ok(!stmt.executeStep()); + stmt.reset(); + }); + } finally { + stmt.finalize(); + } + + info("Check cache"); + // Check cache. + await checkCache(FTP_URL); +}); + +function addFormHistory() { + let now = Date.now() * 1000; + return FormHistory.update({ + op: "add", + fieldname: "testfield", + value: "test", + timesUsed: 1, + firstUsed: now, + lastUsed: now, + }); +} + +async function getFormHistoryCount() { + return FormHistory.count({ fieldname: "testfield" }); +} + +function storeCache(aURL, aContent) { + let cache = Services.cache2; + let storage = cache.diskCacheStorage(Services.loadContextInfo.default); + + return new Promise(resolve => { + let storeCacheListener = { + onCacheEntryCheck(entry) { + return Ci.nsICacheEntryOpenCallback.ENTRY_WANTED; + }, + + onCacheEntryAvailable(entry, isnew, status) { + Assert.equal(status, Cr.NS_OK); + + entry.setMetaDataElement("servertype", "0"); + var os = entry.openOutputStream(0, -1); + + var written = os.write(aContent, aContent.length); + if (written != aContent.length) { + do_throw( + "os.write has not written all data!\n" + + " Expected: " + + written + + "\n" + + " Actual: " + + aContent.length + + "\n" + ); + } + os.close(); + entry.close(); + resolve(); + }, + }; + + storage.asyncOpenURI( + Services.io.newURI(aURL), + "", + Ci.nsICacheStorage.OPEN_NORMALLY, + storeCacheListener + ); + }); +} + +function checkCache(aURL) { + let cache = Services.cache2; + let storage = cache.diskCacheStorage(Services.loadContextInfo.default); + + return new Promise(resolve => { + let checkCacheListener = { + onCacheEntryAvailable(entry, isnew, status) { + Assert.equal(status, Cr.NS_ERROR_CACHE_KEY_NOT_FOUND); + resolve(); + }, + }; + + storage.asyncOpenURI( + Services.io.newURI(aURL), + "", + Ci.nsICacheStorage.OPEN_READONLY, + checkCacheListener + ); + }); +} diff --git a/browser/components/places/tests/unit/test_interactions_blocklist.js b/browser/components/places/tests/unit/test_interactions_blocklist.js new file mode 100644 index 0000000000..0e81f80af2 --- /dev/null +++ b/browser/components/places/tests/unit/test_interactions_blocklist.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that blocked sites are caught by InteractionsBlocklist. + */ + +ChromeUtils.defineESModuleGetters(this, { + InteractionsBlocklist: "resource:///modules/InteractionsBlocklist.sys.mjs", +}); + +let BLOCKED_URLS = [ + "https://www.bing.com/search?q=mozilla", + "https://duckduckgo.com/?q=a+test&kp=1&t=ffab", + "https://www.google.com/search?q=mozilla", + "https://www.google.ca/search?q=test", + "https://mozilla.zoom.us/j/123456789", + "https://yandex.az/search/?text=mozilla", + "https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=1&rsv_idx=1&ch=&tn=baidu&bar=&wd=mozilla&rn=&fenlei=256&oq=&rsv_pq=970f2b8f001757b9&rsv_t=1f5d2V2o80HPdZtZnhodwkc7nZXTvDI1zwdPy%2FAeomnvFFGIrU1F3D9WoK4&rqlang=cn", + "https://accounts.google.com/o/oauth2/v2/auth/identifier/foobar", + "https://auth.mozilla.auth0.com/login/foobar", + "https://accounts.google.com/signin/oauth/consent/foobar", + "https://accounts.google.com/o/oauth2/v2/auth?client_id=ZZZ", + "https://login.microsoftonline.com/common/oauth2/v2.0/authorize/foobar", +]; + +let ALLOWED_URLS = [ + "https://example.com", + "https://zoom.us/pricing", + "https://www.google.ca/maps/place/Toronto,+ON/@43.7181557,-79.5181414,11z/data=!3m1!4b1!4m5!3m4!1s0x89d4cb90d7c63ba5:0x323555502ab4c477!8m2!3d43.653226!4d-79.3831843", + "https://example.com/https://auth.mozilla.auth0.com/login/foobar", +]; + +// Tests that initializing InteractionsBlocklist loads the regexes from the +// customBlocklist pref on initialization. This subtest should always be the +// first one in this file. +add_task(async function blockedOnInit() { + Services.prefs.setStringPref( + "places.interactions.customBlocklist", + '["^(https?:\\\\/\\\\/)?mochi.test"]' + ); + Assert.ok( + InteractionsBlocklist.isUrlBlocklisted("https://mochi.test"), + "mochi.test is blocklisted." + ); + InteractionsBlocklist.removeRegexFromBlocklist("^(https?:\\/\\/)?mochi.test"); + Assert.ok( + !InteractionsBlocklist.isUrlBlocklisted("https://mochi.test"), + "mochi.test is not blocklisted." + ); +}); + +add_task(async function test() { + for (let url of BLOCKED_URLS) { + Assert.ok( + InteractionsBlocklist.isUrlBlocklisted(url), + `${url} is blocklisted.` + ); + } + + for (let url of ALLOWED_URLS) { + Assert.ok( + !InteractionsBlocklist.isUrlBlocklisted(url), + `${url} is not blocklisted.` + ); + } +}); diff --git a/browser/components/places/tests/unit/test_invalid_defaultLocation.js b/browser/components/places/tests/unit/test_invalid_defaultLocation.js new file mode 100644 index 0000000000..e533d051d0 --- /dev/null +++ b/browser/components/places/tests/unit/test_invalid_defaultLocation.js @@ -0,0 +1,24 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Test that if browser.bookmarks.defaultLocation contains an invalid GUID, + * PlacesUIUtils.defaultParentGuid will return a proper default value. + */ + +add_task(async function () { + Services.prefs.setCharPref( + "browser.bookmarks.defaultLocation", + "useOtherBookmarks" + ); + + info( + "Checking that default parent guid was set back to the toolbar because of invalid preferable guid" + ); + Assert.equal( + await PlacesUIUtils.defaultParentGuid, + PlacesUtils.bookmarks.toolbarGuid, + "Default parent guid is a toolbar guid" + ); +}); diff --git a/browser/components/places/tests/unit/xpcshell.toml b/browser/components/places/tests/unit/xpcshell.toml new file mode 100644 index 0000000000..75727e359a --- /dev/null +++ b/browser/components/places/tests/unit/xpcshell.toml @@ -0,0 +1,38 @@ +[DEFAULT] +head = "head_bookmarks.js" +firefox-appdir = "browser" +skip-if = ["os == 'android'"] # bug 1730213 +support-files = [ + "bookmarks.glue.html", + "bookmarks.glue.json", + "corruptDB.sqlite", + "distribution.ini", +] + +["test_PUIU_batchUpdatesForNode.js"] + +["test_PUIU_setCharsetForPage.js"] + +["test_PUIU_title_difference_spotter.js"] + +["test_browserGlue_bookmarkshtml.js"] + +["test_browserGlue_corrupt.js"] + +["test_browserGlue_corrupt_nobackup.js"] + +["test_browserGlue_corrupt_nobackup_default.js"] + +["test_browserGlue_distribution.js"] + +["test_browserGlue_migrate.js"] + +["test_browserGlue_prefs.js"] + +["test_browserGlue_restore.js"] + +["test_clearHistory_shutdown.js"] + +["test_interactions_blocklist.js"] + +["test_invalid_defaultLocation.js"] diff --git a/browser/components/pocket/.eslintrc.js b/browser/components/pocket/.eslintrc.js new file mode 100644 index 0000000000..0a09319f0c --- /dev/null +++ b/browser/components/pocket/.eslintrc.js @@ -0,0 +1,18 @@ +/* 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/. */ + +module.exports = { + plugins: [ + "react", // require("eslint-plugin-react") + ], + settings: { + react: { + version: "17.0.2", + }, + }, + rules: { + "react/jsx-uses-react": 2, + "react/jsx-uses-vars": 2, + }, +}; diff --git a/browser/components/pocket/.nvmrc b/browser/components/pocket/.nvmrc new file mode 100644 index 0000000000..58a4133d91 --- /dev/null +++ b/browser/components/pocket/.nvmrc @@ -0,0 +1 @@ +16.13.0 diff --git a/browser/components/pocket/README.md b/browser/components/pocket/README.md new file mode 100644 index 0000000000..6c4c808c35 --- /dev/null +++ b/browser/components/pocket/README.md @@ -0,0 +1,39 @@ +# Pocket Integration + +This is mostly everything related to Pocket in the browser. There are a few exceptions, like newtab, some reader mode integration, and some JSWindowActors, that live in other places. + +Primarily though this directory includes code for setting up the save to Pocket button, setting up the panels that are used once the Pocket button is clicked, setting up the Pocket context menu item, and a little scaffolding for the reader mode Pocket integration. + +## Basic Code Structure + +We have three primary areas code wise. + +There are some JSMs that handle communication with the browser. This includes some telemetry, some API functions usable by other parts of the browser, like newtab, and some initialization and setup code. These files live in `/content` + +There is also some standard js, html, and css that run inside the panels. Panels are the contents inside the drop downs if you click the save to Pocket button. Panels run in their own browser process, and have their own js. This js also has a build/bundle step. These files live in `/content/panels`. We have three panels. There is a sign up panel that is displayed if you click the save to Pocket button while not signed in. There is a saved panel, if you click the save to Pocket button while signed in, and on a page that is savable. Finally there is a home panel, if you click the save to Pocket button while signed in, on a page that is not savable, like about:home. + +## Build Panels + +We use webpack and node to build the panel bundle. So if you change anything in `/content/panels/js` or `/content/panels/css`, you probably need to build the bundle. + +The build step makes changes to the bundle files, that need to be included in your patch. + +### Prerequisites + +You need node.js installed, and a working local build of Firefox. The current or active version of node is probably fine. At the time of this writing, node version 14 and up is active, and is recommended. + +### How to Build + +From `/browser/components/pocket` + +If you're making a patch that's ready for review: +run `npm install` +then `npm run build` + +For active development instead of `npm run build` use `npm run watch`, which should update bundles as you work. + +## React and JSX + +We use React and JSX for most of the panel html and js. You can find the React components in `/content/panels/js/components`. + +We are trying to keep the React implementation and dependencies as small as possible. diff --git a/browser/components/pocket/content/Pocket.sys.mjs b/browser/components/pocket/content/Pocket.sys.mjs new file mode 100644 index 0000000000..25e8fccdf8 --- /dev/null +++ b/browser/components/pocket/content/Pocket.sys.mjs @@ -0,0 +1,56 @@ +/* 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, { + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", +}); + +export var Pocket = { + get site() { + return Services.prefs.getCharPref("extensions.pocket.site"); + }, + get listURL() { + return "https://" + Pocket.site + "/firefox_learnmore?src=ff_library"; + }, + + _initPanelView(window) { + let urlToSave = Pocket._urlToSave; + let titleToSave = Pocket._titleToSave; + Pocket._urlToSave = null; + Pocket._titleToSave = null; + // ViewShowing fires immediately before it creates the contents, + // in lieu of an AfterViewShowing event, just spin the event loop. + window.setTimeout(function () { + if (urlToSave) { + window.pktUI.tryToSaveUrl(urlToSave, titleToSave); + } else { + window.pktUI.tryToSaveCurrentPage(); + } + }, 0); + }, + + _urlToSave: null, + _titleToSave: null, + savePage(browser, url, title) { + // We want to target the top browser which has the Pocket panel UI, + // which might not be the browser saving the article. + const ownerGlobal = browser?.ownerGlobal?.top; + const ownerDocument = ownerGlobal?.document; + + if (!ownerDocument || !ownerGlobal?.PanelUI) { + return; + } + + let widget = lazy.CustomizableUI.getWidget("save-to-pocket-button"); + let anchorNode = widget.areaType + ? widget.forWindow(ownerGlobal).anchor + : ownerDocument.getElementById("PanelUI-menu-button"); + + this._urlToSave = url; + this._titleToSave = title; + ownerGlobal.PanelUI.showSubView("PanelUI-savetopocket", anchorNode); + }, +}; diff --git a/browser/components/pocket/content/SaveToPocket.sys.mjs b/browser/components/pocket/content/SaveToPocket.sys.mjs new file mode 100644 index 0000000000..60674acc82 --- /dev/null +++ b/browser/components/pocket/content/SaveToPocket.sys.mjs @@ -0,0 +1,245 @@ +/* -*- 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + AboutReaderParent: "resource:///actors/AboutReaderParent.sys.mjs", + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", + Pocket: "chrome://pocket/content/Pocket.sys.mjs", +}); + +var PocketCustomizableWidget = { + init() { + lazy.CustomizableUI.createWidget({ + id: "save-to-pocket-button", + l10nId: "save-to-pocket-button", + type: "view", + viewId: "PanelUI-savetopocket", + // This closes any open Pocket panels if you change location. + locationSpecific: true, + onViewShowing(aEvent) { + let panelView = aEvent.target; + let panelNode = panelView.querySelector( + ".PanelUI-savetopocket-container" + ); + let doc = panelNode.ownerDocument; + let frame = doc.createXULElement("browser"); + + frame.setAttribute("type", "content"); + frame.setAttribute("remote", "true"); + frame.setAttribute("remoteType", "privilegedabout"); + frame.setAttribute("maychangeremoteness", "true"); + frame.setAttribute("autocompletepopup", "PopupAutoComplete"); + panelNode.appendChild(frame); + + SaveToPocket.onShownInToolbarPanel(panelNode, frame); + }, + onViewHiding(aEvent) { + let panelView = aEvent.target; + let panelNode = panelView.querySelector( + ".PanelUI-savetopocket-container" + ); + + panelNode.textContent = ""; + SaveToPocket.updateToolbarNodeState(panelNode.ownerGlobal); + }, + }); + }, + shutdown() { + lazy.CustomizableUI.destroyWidget("save-to-pocket-button"); + }, +}; + +var PocketOverlay = { + startup() { + PocketCustomizableWidget.init(); + }, + shutdown() { + PocketCustomizableWidget.shutdown(); + }, +}; + +function browserWindows() { + return Services.wm.getEnumerator("navigator:browser"); +} + +export var SaveToPocket = { + init() { + // migrate enabled pref + if (Services.prefs.prefHasUserValue("browser.pocket.enabled")) { + Services.prefs.setBoolPref( + "extensions.pocket.enabled", + Services.prefs.getBoolPref("browser.pocket.enabled") + ); + Services.prefs.clearUserPref("browser.pocket.enabled"); + } + // Only define the pref getter now, so we don't get notified for the + // migrated pref above. + XPCOMUtils.defineLazyPreferenceGetter( + this, + "prefEnabled", + "extensions.pocket.enabled", + true, + this.onPrefChange.bind(this) + ); + if (this.prefEnabled) { + PocketOverlay.startup(); + } else { + // We avoid calling onPrefChange or similar here, because we don't want to + // shut down things that haven't started up, or broadcast unnecessary messages. + this.updateElements(false); + Services.obs.addObserver(this, "browser-delayed-startup-finished"); + } + lazy.AboutReaderParent.addMessageListener("Reader:OnSetup", this); + lazy.AboutReaderParent.addMessageListener( + "Reader:Clicked-pocket-button", + this + ); + }, + + observe(subject, topic, data) { + if (topic == "browser-delayed-startup-finished") { + // We only get here if pocket is disabled; the observer is removed when + // we're enabled. + this.updateElementsInWindow(subject, false); + } + }, + + _readerButtonData: { + id: "pocket-button", + l10nId: "about-reader-toolbar-savetopocket", + telemetryId: "save-to-pocket", + image: "chrome://global/skin/icons/pocket.svg", + }, + + onPrefChange(pref, oldValue, newValue) { + if (!newValue) { + lazy.AboutReaderParent.broadcastAsyncMessage("Reader:RemoveButton", { + id: "pocket-button", + }); + PocketOverlay.shutdown(); + Services.obs.addObserver(this, "browser-delayed-startup-finished"); + } else { + lazy.AboutReaderParent.broadcastAsyncMessage( + "Reader:AddButton", + this._readerButtonData + ); + Services.obs.removeObserver(this, "browser-delayed-startup-finished"); + PocketOverlay.startup(); + } + this.updateElements(newValue); + }, + + // Sets or removes the "pocketed" attribute on the Pocket urlbar button as + // necessary. + updateToolbarNodeState(browserWindow) { + const toolbarNode = browserWindow.document.getElementById( + "save-to-pocket-button" + ); + if (!toolbarNode || toolbarNode.hidden) { + return; + } + + let browser = browserWindow.gBrowser.selectedBrowser; + + let pocketedInnerWindowID = this.innerWindowIDsByBrowser.get(browser); + if (pocketedInnerWindowID == browser.innerWindowID) { + // The current window in this browser is pocketed. + toolbarNode.setAttribute("pocketed", "true"); + } else { + // The window isn't pocketed. + toolbarNode.removeAttribute("pocketed"); + } + }, + + // For pocketed inner windows, this maps their s to those inner + // window IDs. If a browser's inner window changes, then the mapped ID will + // be out of date, meaning that the new inner window has not been pocketed. + // If a browser goes away, then it'll be gone from this map too since it's + // weak. To tell whether a window has been pocketed then, look up its browser + // in this map and compare the mapped inner window ID to the ID of the current + // inner window. + get innerWindowIDsByBrowser() { + delete this.innerWindowIDsByBrowser; + return (this.innerWindowIDsByBrowser = new WeakMap()); + }, + + onLocationChange(browserWindow) { + this.updateToolbarNodeState(browserWindow); + }, + + /** + * Functions related to the Pocket panel UI. + */ + onShownInToolbarPanel(panel, frame) { + let window = panel.ownerGlobal; + window.pktUI.setToolbarPanelFrame(frame); + lazy.Pocket._initPanelView(window); + }, + + // If an item is saved to Pocket, we cache the browser's inner window ID, + // so if you navigate to that tab again, we can check the ID + // and see if we need to update the toolbar icon. + itemSaved() { + const browserWindow = Services.wm.getMostRecentWindow("navigator:browser"); + const browser = browserWindow.gBrowser.selectedBrowser; + SaveToPocket.innerWindowIDsByBrowser.set(browser, browser.innerWindowID); + }, + + // If an item is removed from Pocket, we remove that browser's inner window ID, + // so if you navigate to that tab again, we can check the ID + // and see if we need to update the toolbar icon. + itemDeleted() { + const browserWindow = Services.wm.getMostRecentWindow("navigator:browser"); + const browser = browserWindow.gBrowser.selectedBrowser; + SaveToPocket.innerWindowIDsByBrowser.delete(browser); + }, + + updateElements(enabled) { + // loop through windows and show/hide all our elements. + for (let win of browserWindows()) { + this.updateElementsInWindow(win, enabled); + } + }, + + updateElementsInWindow(win, enabled) { + if (enabled) { + win.document.documentElement.removeAttribute("pocketdisabled"); + } else { + win.document.documentElement.setAttribute("pocketdisabled", "true"); + } + }, + + receiveMessage(message) { + if (!this.prefEnabled) { + return; + } + switch (message.name) { + case "Reader:OnSetup": { + // Tell the reader about our button. + message.target.sendMessageToActor( + "Reader:AddButton", + this._readerButtonData, + "AboutReader" + ); + break; + } + case "Reader:Clicked-pocket-button": { + let pocketPanel = message.target.ownerDocument.querySelector( + "#customizationui-widget-panel" + ); + if (pocketPanel?.getAttribute("panelopen")) { + pocketPanel.hidePopup(); + } else { + // Saves the currently viewed page. + lazy.Pocket.savePage(message.target); + } + break; + } + } + }, +}; diff --git a/browser/components/pocket/content/panels/css/global.scss b/browser/components/pocket/content/panels/css/global.scss new file mode 100644 index 0000000000..b1f2cb0776 --- /dev/null +++ b/browser/components/pocket/content/panels/css/global.scss @@ -0,0 +1,67 @@ +// Mixins + +@mixin theme_dark { + @at-root body.theme_dark & { + @content; + } +} + +html { + font: menu; +} + +body { + &.theme_dark { + background: #42414c; + color: #FBFBFE; + } +} + +hr { + margin: 12px -8px; + background-color: #F0F0F4; + height: 1px; + border: none; + + @include theme_dark { + background-color: #52525E; + } +} + +.header_large { + margin: 12px 0 8px; + font-size: 1.25rem; + line-height: 1.65rem; + + .stp_button { + margin: 0; + } +} + +.header_medium { + margin: 12px 0 8px; + font-size: 1.1rem; + line-height: 1.35rem; +} + +.header_small { + margin: 12px 0 8px; + font-size: 0.95rem; + line-height: 1.16rem; +} + +.header_flex { + display: flex; + align-items: center; + justify-content: space-between; +} + +.header_center { + text-align: center; +} + +p { + margin: 8px 0; + font-size: 0.95rem; + line-height: 1.16rem; +} diff --git a/browser/components/pocket/content/panels/css/home.scss b/browser/components/pocket/content/panels/css/home.scss new file mode 100644 index 0000000000..af0849708b --- /dev/null +++ b/browser/components/pocket/content/panels/css/home.scss @@ -0,0 +1,117 @@ +/* stylelint-disable max-nesting-depth */ + +.pkt_ext_containerhome, +.pkt_ext_wrapperhome { + overflow: hidden; +} + +.pkt_ext_home { + line-height: 20px; + color: #363636; + + a { + color: #008078; + text-decoration: none; + } + + a, p { + font-size: 0.9em; + } + + .pkt_ext_hr { + height: 1px; + background: linear-gradient(90deg, #83EDB8 0%, #83EDB8 0%, #83EDB8 0.01%, #1CB0A8 33.15%, #EF4056 67.4%, #FCB643 100%); + } + + .pkt_ext_detail { + margin: 18px 20px; + } + + .pkt_ext_header { + display: flex; + justify-content: space-between; + align-items: center; + .pkt_ext_mylist_icon { + background: url(../img/list-view.svg) no-repeat; + background-size: contain; + height: 1.2em; + width: 1.2em; + margin-inline-end: 8px; + } + a { + height: 36px; + display: flex; + align-items: center; + box-sizing: border-box; + padding: 0 8px; + margin: 12px; + border-radius: 4px; + &:hover { + background: #F5F5F5; + } + } + .pkt_ext_logo { + background: url(../img/pocketlogo.svg) bottom right no-repeat; + background-size: contain; + height: 32px; + width: 123px; + margin: 0 20px; + } + } + .pkt_ext_detail { + a { + display: block; + } + h2 { + font-weight: 600; + font-size: 1em; + } + h2, + p { + margin: 8px 0; + } + h3 { + font-weight: 600; + font-size: 1em; + margin: 12px 0; + } + .pkt_ext_more { + margin: 19px 0; + .pkt_ext_chevron_right { + background: url(../img/chevron-right.svg) no-repeat; + background-size: contain; + height: 1.2em; + width: 1.2em; + } + ul { + list-style-type: none; + padding: 0; + line-height: 14px; + li { + a { + height: 44px; + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + border-bottom: 1px solid #EAEAEA; + } + } + } + } + .pkt_ext_discover { + line-height: 12px; + margin: 20px 0; + height: 40px; + width: 100%; + display: flex; + justify-content: center; + align-items: center; + border-radius: 4px; + &:hover { + background: #F5F5F5; + } + } + } +} diff --git a/browser/components/pocket/content/panels/css/main.compiled.css b/browser/components/pocket/content/panels/css/main.compiled.css new file mode 100644 index 0000000000..d0af7dfc8a --- /dev/null +++ b/browser/components/pocket/content/panels/css/main.compiled.css @@ -0,0 +1,2346 @@ +/*! normalize.css v3.0.2 | MIT License | git.io/normalize */ +/** + * 1. Set default font family to sans-serif. + * 2. Prevent iOS text size adjust after orientation change, without disabling + * user zoom. + */ +html { + font-family: sans-serif; /* 1 */ +} + +/** + * Remove default margin. + */ +body { + margin: 0; +} + +/* HTML5 display definitions + ========================================================================== */ +/** + * Correct `block` display not defined for any HTML5 element in IE 8/9. + * Correct `block` display not defined for `details` or `summary` in IE 10/11 + * and Firefox. + * Correct `block` display not defined for `main` in IE 11. + */ +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +main, +menu, +nav, +section, +summary { + display: block; +} + +/** + * 1. Correct `inline-block` display not defined in IE 8/9. + * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. + */ +audio, +canvas, +progress, +video { + display: inline-block; /* 1 */ + vertical-align: baseline; /* 2 */ +} + +/** + * Prevent modern browsers from displaying `audio` without controls. + * Remove excess height in iOS 5 devices. + */ +audio:not([controls]) { + display: none; + height: 0; +} + +/** + * Address `[hidden]` styling not present in IE 8/9/10. + * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. + */ +[hidden], +template { + display: none; +} + +/* Links + ========================================================================== */ +/** + * Remove the gray background color from active links in IE 10. + */ +a { + background-color: transparent; +} + +/** + * Improve readability when focused and also mouse hovered in all browsers. + */ +a:active, +a:hover { + outline: 0; +} + +/* Text-level semantics + ========================================================================== */ +/** + * Address styling not present in IE 8/9/10/11, Safari, and Chrome. + */ +abbr[title] { + border-bottom: 1px dotted; +} + +/** + * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. + */ +b, +strong { + font-weight: bold; +} + +/** + * Address styling not present in Safari and Chrome. + */ +dfn { + font-style: italic; +} + +/** + * Address variable `h1` font-size and margin within `section` and `article` + * contexts in Firefox 4+, Safari, and Chrome. + */ +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/** + * Address styling not present in IE 8/9. + */ +mark { + background: #ff0; + color: #000; +} + +/** + * Address inconsistent and variable font size in all browsers. + */ +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` affecting `line-height` in all browsers. + */ +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sup { + top: -0.5em; +} + +sub { + bottom: -0.25em; +} + +/* Embedded content + ========================================================================== */ +/** + * Remove border when inside `a` element in IE 8/9/10. + */ +img { + border: 0; +} + +/** + * Correct overflow not hidden in IE 9/10/11. + */ +svg:not(:root) { + overflow: hidden; +} + +/* Grouping content + ========================================================================== */ +/** + * Address margin not present in IE 8/9 and Safari. + */ +figure { + margin: 1em 40px; +} + +/** + * Address differences between Firefox and other browsers. + */ +hr { + -moz-box-sizing: content-box; + box-sizing: content-box; + height: 0; +} + +/** + * Contain overflow in all browsers. + */ +pre { + overflow: auto; +} + +/** + * Address odd `em`-unit font size rendering in all browsers. + */ +code, +kbd, +pre, +samp { + font-family: monospace, monospace; + font-size: 1em; +} + +/* Forms + ========================================================================== */ +/** + * Known limitation: by default, Chrome and Safari on OS X allow very limited + * styling of `select`, unless a `border` property is set. + */ +/** + * 1. Correct color not being inherited. + * Known issue: affects color of disabled elements. + * 2. Correct font properties not being inherited. + * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. + */ +button, +input, +optgroup, +select, +textarea { + color: inherit; /* 1 */ + font: inherit; /* 2 */ + margin: 0; /* 3 */ +} + +/** + * Address `overflow` set to `hidden` in IE 8/9/10/11. + */ +button { + overflow: visible; +} + +/** + * Address inconsistent `text-transform` inheritance for `button` and `select`. + * All other form control elements do not inherit `text-transform` values. + * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. + * Correct `select` style inheritance in Firefox. + */ +button, +select { + text-transform: none; +} + +/** + * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` + * and `video` controls. + * 2. Correct inability to style clickable `input` types in iOS. + * 3. Improve usability and consistency of cursor style between image-type + * `input` and others. + */ +button, +html input[type=button], +input[type=reset], +input[type=submit] { + cursor: pointer; /* 3 */ +} + +/** + * Re-set default cursor for disabled elements. + */ +button[disabled], +html input[disabled] { + cursor: default; +} + +/** + * Remove inner padding and border in Firefox 4+. + */ +button::-moz-focus-inner, +input::-moz-focus-inner { + border: 0; + padding: 0; +} + +/** + * Address Firefox 4+ setting `line-height` on `input` using `!important` in + * the UA stylesheet. + */ +input { + line-height: normal; +} + +/** + * It's recommended that you don't attempt to style these elements. + * Firefox's implementation doesn't respect box-sizing, padding, or width. + * + * 1. Address box sizing set to `content-box` in IE 8/9/10. + * 2. Remove excess padding in IE 8/9/10. + */ +input[type=checkbox], +input[type=radio] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * 1. Address `appearance` set to `searchfield` in Safari and Chrome. + * 2. Address `box-sizing` set to `border-box` in Safari and Chrome + * (include `-moz` to future-proof). + */ +input[type=search] { + box-sizing: content-box; +} + +/** + * Define consistent border, margin, and padding. + */ +fieldset { + border: 1px solid #c0c0c0; + margin: 0 2px; + padding: 0.35em 0.625em 0.75em; +} + +/** + * 1. Correct `color` not being inherited in IE 8/9/10/11. + * 2. Remove padding so people aren't caught out if they zero out fieldsets. + */ +legend { + border: 0; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Remove default vertical scrollbar in IE 8/9/10/11. + */ +textarea { + overflow: auto; +} + +/** + * Don't inherit the `font-weight` (applied by a rule above). + * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. + */ +optgroup { + font-weight: bold; +} + +/* Tables + ========================================================================== */ +/** + * Remove most spacing between table cells. + */ +table { + border-collapse: collapse; + border-spacing: 0; +} + +td, +th { + padding: 0; +} + +/* Normalization for FF panel defauts + ========================================================================== */ +html { + outline: none; + padding: 0; +} + +a { + color: #0095dd; + margin: 0; + outline: none; + padding: 0; + text-decoration: none; +} + +a:hover, +a:active, +a:focus { + color: #008acb; + text-decoration: underline; +} + +a:active { + color: #006b9d; +} + +html { + font: menu; +} + +body.theme_dark { + background: #42414c; + color: #FBFBFE; +} + +hr { + margin: 12px -8px; + background-color: #F0F0F4; + height: 1px; + border: none; +} +body.theme_dark hr { + background-color: #52525E; +} + +.header_large { + margin: 12px 0 8px; + font-size: 1.25rem; + line-height: 1.65rem; +} +.header_large .stp_button { + margin: 0; +} + +.header_medium { + margin: 12px 0 8px; + font-size: 1.1rem; + line-height: 1.35rem; +} + +.header_small { + margin: 12px 0 8px; + font-size: 0.95rem; + line-height: 1.16rem; +} + +.header_flex { + display: flex; + align-items: center; + justify-content: space-between; +} + +.header_center { + text-align: center; +} + +p { + margin: 8px 0; + font-size: 0.95rem; + line-height: 1.16rem; +} + +.stp_panel_container { + overflow: hidden; +} + +.stp_panel { + padding: 0 16px; + margin: 16px 0 12px; +} + +/* saved.css + * + * Description: + * With base elements out of the way, this sets all custom styling for the page saved dialog. + * + * Contents: + * Global + * Loading spinner + * Core detail + * Tag entry + * Recent/suggested tags + * Premium upsell + * Token input/autocomplete + * Overflow mode + * Language overrides + */ +/*=Global +--------------------------------------------------------------------------------------- */ +.pkt_ext_containersaved { + background-color: #fbfbfb; + border-radius: 4px; + display: block; + padding: 0; + position: relative; + text-align: center; + overflow: hidden; +} + +.pkt_ext_cf:after { + content: " "; + display: table; + clear: both; +} + +.pkt_ext_containersaved .pkt_ext_tag_detail, +.pkt_ext_containersaved .pkt_ext_recenttag_detail, +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail { + margin: 0 auto; + padding: 0.25em 1em; + position: relative; + width: auto; +} + +/*=Loading spinner +--------------------------------------------------------------------------------------- */ +@keyframes pkt_ext_spin { + to { + transform: rotate(1turn); + } +} +.pkt_ext_containersaved .pkt_ext_loadingspinner { + display: inline-block; + height: 2.5em; + inset-inline-start: 50%; + margin-block: 2em 0; + margin-inline: -1.25em 0; + font-size: 10px; + text-indent: 999em; + position: absolute; + top: 4em; + overflow: hidden; + width: 2.5em; + animation: pkt_ext_spin 0.7s infinite steps(8); +} + +.pkt_ext_containersaved .pkt_ext_loadingspinner:before, +.pkt_ext_containersaved .pkt_ext_loadingspinner:after, +.pkt_ext_containersaved .pkt_ext_loadingspinner > div:before, +.pkt_ext_containersaved .pkt_ext_loadingspinner > div:after { + content: ""; + position: absolute; + top: 0; + inset-inline-start: 1.125em; + width: 0.25em; + height: 0.75em; + border-radius: 0.2em; + background: #eee; + box-shadow: 0 1.75em #eee; + transform-origin: 50% 1.25em; +} + +.pkt_ext_containersaved .pkt_ext_loadingspinner:before { + background: #555; +} + +.pkt_ext_containersaved .pkt_ext_loadingspinner:after { + transform: rotate(-45deg); + background: #777; +} + +.pkt_ext_containersaved .pkt_ext_loadingspinner > div:before { + transform: rotate(-90deg); + background: #999; +} + +.pkt_ext_containersaved .pkt_ext_loadingspinner > div:after { + transform: rotate(-135deg); + background: #bbb; +} + +/*=Core detail +--------------------------------------------------------------------------------------- */ +.pkt_ext_containersaved .pkt_ext_initload { + inset-inline-start: 0; + position: absolute; + top: 0; + width: 100%; +} + +.pkt_ext_containersaved .pkt_ext_detail { + opacity: 0; + position: relative; + padding-bottom: 1.25em; +} + +.pkt_ext_container_detailactive .pkt_ext_initload { + opacity: 0; +} + +.pkt_ext_container_detailactive .pkt_ext_initload .pkt_ext_loadingspinner, +.pkt_ext_container_finalstate .pkt_ext_initload .pkt_ext_loadingspinner { + animation: none; +} + +.pkt_ext_container_detailactive .pkt_ext_detail { + max-height: 20em; + opacity: 1; +} + +.pkt_ext_container_finalstate .pkt_ext_edit_msg, +.pkt_ext_container_finalstate .pkt_ext_tag_detail, +.pkt_ext_container_finalstate .pkt_ext_suggestedtag_detail, +.pkt_ext_container_finalstate .pkt_ext_item_actions { + opacity: 0; + transition: opacity 0.2s ease-out; +} + +.pkt_ext_container_finalerrorstate .pkt_ext_edit_msg, +.pkt_ext_container_finalerrorstate .pkt_ext_tag_detail, +.pkt_ext_container_finalerrorstate .pkt_ext_suggestedtag_detail, +.pkt_ext_container_finalerrorstate .pkt_ext_item_actions { + display: none; + transition: none; +} + +.pkt_ext_containersaved h2 { + background: transparent; + border: none; + color: #333; + display: block; + float: none; + font-size: 1.2em; + font-weight: normal; + letter-spacing: normal; + line-height: 1; + margin: 19px 0 4px; + padding: 0; + position: relative; + text-align: start; + text-transform: none; +} + +@keyframes fade_in_out { + 0% { + opacity: 1; + } + 50% { + opacity: 0; + } + 100% { + opacity: 1; + } +} +.pkt_ext_container_finalstate h2 { + animation: fade_in_out 0.4s ease-out; +} + +.pkt_ext_container_finalerrorstate h2 { + animation: none; + color: #d74345; +} + +.pkt_ext_containersaved .pkt_ext_errordetail { + display: none; + font-size: 0.9em; + font-weight: normal; + inset-inline-start: 6.4em; + max-width: 21em; + opacity: 0; + position: absolute; + top: 2.7em; + text-align: start; + visibility: hidden; +} + +.pkt_ext_container_finalerrorstate { + max-height: 133px; +} + +.pkt_ext_container_finalerrorstate .pkt_ext_errordetail { + display: block; + opacity: 1; + visibility: visible; +} + +.pkt_ext_containersaved .pkt_ext_logo { + background: image-set(url(../img/pocketlogosolo@1x.png), url(../img/pocketlogosolo@2x.png) 2x), center center no-repeat; + display: block; + float: inline-start; + height: 40px; + padding: 1.25em 1em; + position: relative; + width: 44px; +} + +.pkt_ext_container_finalerrorstate .pkt_ext_logo { + background-image: image-set(url(../img/pocketerror@1x.png), url(../img/pocketerror@2x.png) 2x); + height: 44px; + width: 44px; +} + +.pkt_ext_containersaved .pkt_ext_topdetail { + float: inline-start; +} + +.pkt_ext_containersaved .pkt_ext_edit_msg_container { + position: relative; +} +.pkt_ext_containersaved .pkt_ext_edit_msg_container .pkt_ext_edit_msg { + box-sizing: border-box; + display: none; + font-size: 0.75em; + inset-inline-start: auto; + padding: 0 1.4em; + position: absolute; + text-align: start; + top: 0; + width: 100%; + margin: 0; +} +.pkt_ext_containersaved .pkt_ext_edit_msg_container .pkt_ext_edit_msg.pkt_ext_edit_msg_error { + color: #d74345; +} +.pkt_ext_containersaved .pkt_ext_edit_msg_container .pkt_ext_edit_msg.pkt_ext_edit_msg_active { + display: block; +} + +.pkt_ext_containersaved .pkt_ext_item_actions { + background: transparent; + float: none; + height: auto; + margin-bottom: 1em; + margin-top: 0; + width: auto; +} + +.pkt_ext_containersaved .pkt_ext_item_actions_disabled { + opacity: 0.5; +} + +.pkt_ext_container_finalstate .pkt_ext_item_actions_disabled { + opacity: 0; +} + +.pkt_ext_containersaved .pkt_ext_item_actions ul { + background: none; + display: block; + float: none; + height: auto; + margin: 0; + padding: 0; + width: 100%; +} + +.pkt_ext_containersaved .pkt_ext_item_actions li { + box-sizing: border-box; + background: none; + border: 0; + float: inline-start; + list-style: none; + line-height: 0.8; + height: auto; + padding-inline-end: 0.4em; + width: auto; +} + +.pkt_ext_containersaved .pkt_ext_item_actions li:before { + content: none; +} + +.pkt_ext_containersaved .pkt_ext_item_actions .pkt_ext_actions_separator { + border-inline-start: 2px solid #777; + height: 1em; + margin-top: 0.3em; + padding: 0; + width: 10px; +} + +.pkt_ext_containersaved .pkt_ext_item_actions a { + background: transparent; + color: #0095dd; + display: block; + font-feature-settings: normal; + font-size: 0.9em; + font-weight: normal; + letter-spacing: normal; + line-height: inherit; + height: auto; + margin: 0; + padding: 0.5em; + float: inline-start; + text-align: start; + text-decoration: none; + text-transform: none; +} + +.pkt_ext_containersaved .pkt_ext_item_actions a:hover, +.pkt_ext_containersaved .pkt_ext_item_actions a:focus { + color: #008acb; + text-decoration: underline; +} + +.pkt_ext_containersaved .pkt_ext_item_actions a:before, +.pkt_ext_containersaved .pkt_ext_item_actions a:after { + background: transparent; + display: none; +} + +.pkt_ext_containersaved .pkt_ext_item_actions_disabled a { + cursor: default; +} + +.pkt_ext_containersaved .pkt_ext_item_actions .pkt_ext_openpocket { + float: inline-end; + padding-inline-end: 0.7em; + text-align: end; +} + +.pkt_ext_containersaved .pkt_ext_item_actions .pkt_ext_removeitem { + padding-inline-start: 0; +} + +.pkt_ext_containersaved .pkt_ext_close { + background: image-set(url(../img/tag_close@1x.png), url(../img/tag_close@2x.png) 2x) center center no-repeat; + color: #333; + display: block; + font-size: 0.8em; + height: 10px; + inset-inline-end: 0.5em; + overflow: hidden; + position: absolute; + text-align: center; + text-indent: -9999px; + top: -1em; + width: 10px; +} + +.pkt_ext_containersaved .pkt_ext_close:hover { + color: #000; + text-decoration: none; +} + +/*=Tag entry +--------------------------------------------------------------------------------------- */ +.pkt_ext_containersaved .pkt_ext_tag_detail { + border: 1px solid #c1c1c1; + border-radius: 2px; + clear: both; + margin: 0 1em; + padding: 0; + display: flex; +} + +.pkt_ext_containersaved .pkt_ext_tag_error { + border: none; +} + +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper { + box-sizing: border-box; + flex: 1; + background-color: #fff; + border-inline-end: 1px solid #c3c3c3; + color: #333; + display: block; + float: none; + font-size: 0.875em; + list-style: none; + margin: 0; + overflow: hidden; + padding: 0.25em 0.5em; + width: 14em; + padding-inline: 0.5em; +} + +.pkt_ext_containersaved .pkt_ext_tag_error .pkt_ext_tag_input_wrapper { + border: 1px solid #d74345; +} + +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper .token-input-list { + display: block; + height: 1.7em; + overflow: hidden; + position: relative; + width: 60em; +} + +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper .token-input-list, +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper li { + font-size: 1em; +} + +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper li { + height: auto; + width: auto; +} + +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper li:before { + content: none; +} + +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper input { + border: 0; + box-shadow: none; + background-color: #fff; + color: #333; + font-size: 1em; + float: inline-start; + line-height: normal; + height: auto; + min-height: 0; + min-width: 5em; + padding: 3px 2px 1px; + text-transform: none; +} + +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper input::placeholder { + color: #a9a9a9; + letter-spacing: normal; + text-transform: none; +} + +.pkt_ext_containersaved .input_disabled { + cursor: default; + opacity: 0.5; +} + +.pkt_ext_containersaved .pkt_ext_btn { + box-sizing: border-box; + color: #333; + float: none; + font-size: 1em; + letter-spacing: normal; + height: 2.2em; + min-width: 4em; + padding: 0.5em 0; + text-decoration: none; + text-transform: none; + width: auto; +} + +.pkt_ext_containersaved .pkt_ext_btn:hover { + background-color: #ebebeb; +} + +.pkt_ext_containersaved .pkt_ext_btn:active { + background-color: #dadada; +} + +.pkt_ext_containersaved .pkt_ext_btn_disabled, +.pkt_ext_containersaved .pkt_ext_btn_disabled:hover, +.pkt_ext_containersaved .pkt_ext_btn_disabled:active { + background-color: transparent; + cursor: default; + opacity: 0.4; +} + +.pkt_ext_containersaved .pkt_ext_tag_error .pkt_ext_btn { + border: 1px solid #c3c3c3; + border-block-width: 1px; + border-inline-width: 0 1px; + height: 2.35em; +} + +.pkt_ext_containersaved .autocomplete-suggestions { + margin-top: 2.2em; +} + +/*=Recent/suggested tags +--------------------------------------------------------------------------------------- */ +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail { + box-sizing: border-box; + clear: both; + inset-inline-start: 0; + opacity: 0; + min-height: 110px; + visibility: hidden; + width: 100%; +} + +.pkt_ext_container_detailactive .pkt_ext_suggestedtag_detail { + opacity: 1; + visibility: visible; +} + +.pkt_ext_container_finalstate .pkt_ext_suggestedtag_detail { + opacity: 0; + visibility: hidden; +} + +.pkt_ext_containersaved .pkt_ext_recenttag_detail h4, +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail h4 { + color: #333; + font-size: 1em; + font-weight: normal; + font-style: normal; + letter-spacing: normal; + margin: 0.5em 0; + text-align: start; + text-transform: none; +} + +.pkt_ext_containersaved .pkt_ext_recenttag_detail .pkt_ext_loadingspinner, +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail .pkt_ext_loadingspinner { + display: none; + position: absolute; +} + +.pkt_ext_containersaved .pkt_ext_recenttag_detail_loading .pkt_ext_loadingspinner, +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail_loading .pkt_ext_loadingspinner { + display: block; + font-size: 6px; + inset-inline-start: 48%; +} + +.pkt_ext_containersaved .pkt_ext_recenttag_detail ul, +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail ul { + display: block; + margin: 0; + height: 2em; + overflow: hidden; + padding: 2px 0 0; +} + +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail ul { + height: auto; + margin: 0; + max-height: 4em; + padding-top: 6px; +} + +.pkt_ext_containersaved .pkt_ext_recenttag_detail li, +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail li { + background: none; + float: inline-start; + height: inherit; + line-height: 1.5; + list-style: none; + margin-bottom: 0.5em; + width: inherit; +} + +.pkt_ext_containersaved .pkt_ext_recenttag_detail li:before, +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail li:before { + content: none; +} + +.pkt_ext_containersaved .pkt_ext_recenttag_detail .recenttag_msg, +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail .suggestedtag_msg { + color: #333; + font-size: 0.8125em; + line-height: 1.2; + inset-inline-start: auto; + position: absolute; + text-align: start; + top: 2em; +} + +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail .suggestedtag_msg { + margin-inline-end: 1.3em; +} + +.pkt_ext_containersaved .token_tag { + border-radius: 4px; + background: #f7f7f7; + border: 1px solid #c3c3c3; + color: #333; + font-size: 1em; + font-weight: normal; + letter-spacing: normal; + margin-inline-end: 0.5em; + padding: 0.125em 0.625em; + text-decoration: none; + text-transform: none; +} + +.pkt_ext_containersaved .token_tag:hover { + background-color: #008acb; + border-color: #008acb; + color: #fff; + text-decoration: none; +} + +.pkt_ext_containersaved .token_tag:before, +.pkt_ext_containersaved .token_tag:after { + content: none; +} + +.pkt_ext_containersaved .token_tag:hover span { + background-image: image-set(url(../img/tag_closeactive@1x.png), url(../img/tag_closeactive@2x.png) 2x); +} + +.pkt_ext_containersaved .pkt_ext_recenttag_detail_disabled .token_tag, +.pkt_ext_containersaved .pkt_ext_recenttag_detail_disabled .token_tag:hover, +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail_disabled .token_tag, +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail_disabled .token_tag:hover { + background-color: #f7f7f7; + cursor: default; + opacity: 0.5; +} + +.pkt_ext_containersaved .token_tag_inactive { + display: none; +} + +/*=Premium upsell +--------------------------------------------------------------------------------------- */ +.pkt_ext_detail .pkt_ext_premupsell { + background-color: #50bbb6; + display: block; + padding: 1.5em 0; + text-align: center; +} + +.pkt_ext_premupsell h4 { + color: #fff; + font-size: 1em; + margin-bottom: 1em; +} + +.pkt_ext_premupsell a { + color: #28605d; + border-bottom: 1px solid #47a7a3; + font-weight: normal; +} + +.pkt_ext_premupsell a:hover { + color: #14302f; +} + +/*=Token input/autocomplete +--------------------------------------------------------------------------------------- */ +.token-input-dropdown-tag { + border-radius: 4px; + box-sizing: border-box; + background: #fff; + border: 1px solid #cdcdcd; + margin-top: 0.5em; + inset-inline-start: 0 !important; + overflow-y: auto; + top: 1.9em !important; + z-index: 9000; +} + +.token-input-dropdown-tag ul { + height: inherit; + max-height: 115px; + margin: 0; + overflow: auto; + padding: 0.5em 0; +} + +.token-input-dropdown-tag ul li { + background: none; + color: #333; + font-weight: normal; + font-size: 1em; + float: none; + height: inherit; + letter-spacing: normal; + list-style: none; + padding: 0.75em; + text-align: start; + text-transform: none; + width: inherit; +} + +.token-input-dropdown-tag ul li:before { + content: none; +} + +.token-input-dropdown ul li.token-input-selected-dropdown-item { + background-color: #008acb; + color: #fff; +} + +.token-input-list { + list-style: none; + margin: 0; + padding: 0; +} + +.token-input-list li { + text-align: start; + list-style: none; +} + +.token-input-list li input { + border: 0; + background-color: white; +} + +.pkt_ext_containersaved .token-input-token { + background: none; + border-radius: 4px; + border: 1px solid #c3c3c3; + overflow: hidden; + margin: 0 0.2em; + padding: 0 8px; + background-color: #f7f7f7; + color: #000; + font-weight: normal; + cursor: default; + line-height: 1.5; + display: block; + width: auto; + float: inline-start; +} + +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper_disabled { + position: relative; +} + +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper_disabled input { + opacity: 0.5; +} + +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper_disabled .token-input-list { + opacity: 0.5; +} + +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper_disabled .pkt_ext_tag_input_blocker { + height: 100%; + inset-inline-start: 0; + position: absolute; + top: 0; + width: 100%; + z-index: 5; +} + +.pkt_ext_containersaved .token-input-token p { + display: inline-block; + font-size: 1em; + font-weight: normal; + line-height: inherit; + letter-spacing: normal; + padding: 0; + margin: 0; + text-transform: none; + vertical-align: top; + width: auto; + unicode-bidi: plaintext; +} + +.pkt_ext_containersaved .token-input-token p:before { + content: none; + width: 0; +} + +.pkt_ext_containersaved .token-input-token span { + background: image-set(url(../img/tag_close@1x.png), url(../img/tag_close@2x.png) 2x) center center no-repeat; + cursor: pointer; + display: inline-block; + height: 8px; + margin-block: 0; + margin-inline: 8px 0; + overflow: hidden; + width: 8px; + text-indent: -99px; +} + +.pkt_ext_containersaved .token-input-selected-token { + background-color: #008acb; + border-color: #008acb; + color: #fff; +} + +.pkt_ext_containersaved .token-input-selected-token span { + background-image: image-set(url(../img/tag_closeactive@1x.png), url(../img/tag_closeactive@2x.png) 2x); +} + +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper_disabled .token-input-selected-token { + background-color: #f7f7f7; +} + +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper_disabled .token-input-selected-token span { + color: #bbb; +} + +/*=Language overrides +--------------------------------------------------------------------------------------- */ +.pkt_ext_saved_es .pkt_ext_btn { + min-width: 5em; +} + +.pkt_ext_saved_de .pkt_ext_btn, +.pkt_ext_saved_ru .pkt_ext_btn { + min-width: 6em; +} + +/*=Coral Button +--------------------------------------------------------------------------------------- */ +button { + padding: 0; + margin: 0; + background: none; + border: 0; + outline: none; + color: inherit; + font: inherit; + overflow: visible; +} + +.pkt_ext_button { + padding: 3px; + background-color: #EF4056; + color: #FFF; + text-align: center; + cursor: pointer; + height: 32px; + box-sizing: border-box; + width: 320px; + margin: 0 auto; + border-radius: 2px; + font-size: 1em; +} + +.pkt_ext_button:hover, +.pkt_ext_button:active { + background-color: #d5374b; +} + +/* alt button */ +.pkt_ext_blue_button { + background-color: #0060df; + color: #FFF; +} + +.pkt_ext_blue_button:hover { + background-color: #003eaa; +} + +.pkt_ext_blue_button:active { + background-color: #002275; +} + +.pkt_ext_ffx_icon:after { + position: absolute; + height: 22px; + width: 22px; + top: -3px; + inset-inline-start: -28px; + content: ""; + background-image: url(../img/signup_firefoxlogo@2x.png); + background-size: 22px 22px; + background-repeat: no-repeat; +} + +.pkt_ext_subshell { + display: none; + border-top: 1px solid #c1c1c1; + background: #ebebeb; + width: 100%; +} + +.pkt_ext_subshell hr { + display: none; +} + +.recs_enabled .pkt_ext_subshell hr { + display: block; + border: 0; + border-top: 1px solid #D7D7DB; + margin: 0; +} + +.pkt_ext_item_recs { + text-align: start; + margin: 0 auto; + padding: 0.25em 1em; +} + +.pkt_ext_item_recs header { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 12px; +} + +.pkt_ext_item_recs header h4 { + color: #333; + font-size: 1em; + font-weight: normal; + font-style: normal; + letter-spacing: normal; + margin: 0.5em 0; + text-align: start; + text-transform: none; +} + +.pkt_ext_item_recs header a { + font-style: normal; + font-weight: 500; + font-size: 1em; + line-height: 20px; + color: #0095DD; +} + +.pkt_ext_containersaved .pkt_ext_item_recs ol { + padding: 0; + margin: 0 0 10px; + list-style: none; +} + +.pkt_ext_containersaved .pkt_ext_item_recs li { + float: none; + display: flex; + font-style: normal; + font-weight: normal; + line-height: 18px; + margin: 0 -1em; + min-height: 60px; +} + +.pkt_ext_containersaved .pkt_ext_item_recs li a { + padding-block: 8px; + padding-inline: 1em 40px; + background: url(../img/open.svg) top 8px right 14px no-repeat; + flex-grow: 1; +} + +.pkt_ext_containersaved .pkt_ext_item_recs li a:dir(rtl) { + background-position-x: left 14px; +} + +.pkt_ext_containersaved .pkt_ext_item_recs li:hover, +.pkt_ext_containersaved .pkt_ext_item_recs li a:focus { + background-color: rgba(12, 12, 13, 0.1); +} + +.pkt_ext_containersaved .pkt_ext_item_recs li:active { + background-color: rgba(12, 12, 13, 0.2); +} + +.pkt_ext_containersaved .pkt_ext_item_recs .pkt_ext_item_recs_link:hover { + text-decoration: none; +} + +.pkt_ext_containersaved .pkt_ext_item_recs .rec-thumb { + width: 40px; + height: 40px; + float: inline-start; + margin: 0; + margin-inline-end: 12px; + border-radius: 2px; +} + +.pkt_ext_containersaved .pkt_ext_item_recs .rec-thumb:-moz-broken { + display: none; +} + +.pkt_ext_containersaved .pkt_ext_item_recs p { + -webkit-box-orient: vertical; + display: -webkit-box; + overflow: hidden; + word-break: break-word; + font-style: normal; + font-weight: normal; + margin: 0; +} + +.pkt_ext_containersaved .pkt_ext_item_recs .rec-title { + -webkit-line-clamp: 2; + font-size: 1em; + line-height: 18px; + color: #0C0C0D; +} + +.pkt_ext_containersaved .pkt_ext_item_recs .rec-source { + -webkit-line-clamp: 1; + font-size: 0.9em; + line-height: 16px; + color: #737373; +} + +/* signup.css + * + * Description: + * With base elements out of the way, this sets all custom styling for the extension. + * + * Contents: + * Global + * Core detail + * Core detail - storyboard + * Buttons + * Overflow mode + * Language overrides + */ +/*=Global +--------------------------------------------------------------------------------------- */ +.pkt_ext_containersignup { + background-color: #ebebeb; + color: #333; + display: block; + margin: 0; + padding: 0; + position: relative; + text-align: center; + overflow: hidden; +} + +.pkt_ext_containersignup_inactive { + animation: pkt_ext_hide 0.3s ease-out; + opacity: 0; + visibility: hidden; +} + +.pkt_ext_cf:after { + content: " "; + display: table; + clear: both; +} + +@keyframes pkt_ext_hide { + 0% { + opacity: 1; + visibility: visible; + } + 99% { + opacity: 0; + visibility: visible; + } + 100% { + opacity: 0; + visibility: hidden; + } +} +/*=Core detail +--------------------------------------------------------------------------------------- */ +.pkt_ext_containersignup p { + font-size: 1em; + color: #333; + line-height: 1.3; + margin: 0 auto 1.5em; + max-width: 260px; +} + +.pkt_ext_containersignup a { + color: #4c8fd0; +} + +.pkt_ext_containersignup a:hover { + color: #3076b9; +} + +.pkt_ext_containersignup .pkt_ext_introdetail { + background-color: #fbfbfb; + border: 1px solid #c1c1c1; + border-width: 0 0 1px; +} + +.pkt_ext_containersignup .pkt_ext_logo { + background: image-set(url(../img/pocketlogo@1x.png), url(../img/pocketlogo@2x.png) 2x) center bottom no-repeat; + display: block; + height: 32px; + margin: 0 auto 15px; + padding-top: 25px; + position: relative; + text-indent: -9999px; + width: 123px; +} + +.pkt_ext_containersignup .pkt_ext_introimg { + background: image-set(url(../img/pocketsignup_hero@1x.png), url(../img/pocketsignup_hero@2x.png) 2x) center center no-repeat; + display: block; + height: 125px; + margin: 0 auto; + position: relative; + text-indent: -9999px; + width: 255px; +} + +.pkt_ext_containersignup .pkt_ext_tagline { + margin-bottom: 0.5em; +} + +.pkt_ext_containersignup .pkt_ext_learnmore { + font-size: 0.9em; +} + +.pkt_ext_signupdetail { + overflow: hidden; +} + +.pkt_ext_signupdetail h4 { + font-size: 0.9em; + font-weight: normal; +} + +.pkt_ext_signupdetail .btn-container { + position: relative; + margin-bottom: 0.8em; +} + +.pkt_ext_containersignup .ff_signuphelp { + background: image-set(url(../img/signup_help@1x.png), url(../img/signup_help@2x.png) 2x) center center no-repeat; + display: block; + height: 18px; + margin-top: -9px; + inset-inline-end: -15px; + position: absolute; + text-indent: -9999px; + width: 18px; + top: 50%; +} + +.pkt_ext_containersignup .alreadyhave { + font-size: 0.9em; + max-width: 320px; + margin-top: 15px; +} + +/*=Core detail - storyboard +--------------------------------------------------------------------------------------- */ +.pkt_ext_introstory { + align-items: center; + display: flex; + padding: 20px; +} + +.pkt_ext_introstory:after { + clear: both; + content: ""; + display: table; +} + +.pkt_ext_introstory p { + margin-bottom: 0; + text-align: start; +} + +.pkt_ext_introstoryone { + padding-block: 20px 15px; + padding-inline: 20px 18px; +} + +.pkt_ext_introstorytwo { + padding-block: 3px 0; + padding-inline: 20px 0; +} + +.pkt_ext_introstorytwo .pkt_ext_tagline { + margin-bottom: 1.5em; +} + +.pkt_ext_introstory_text { + flex: 1; +} + +.pkt_ext_introstoryone_img, +.pkt_ext_introstorytwo_img { + display: block; + overflow: hidden; + position: relative; + text-indent: -999px; +} + +.pkt_ext_introstoryone_img { + background: image-set(url(../img/pocketsignup_button@1x.png), url(../img/pocketsignup_button@2x.png) 2x) center right no-repeat; + height: 82px; + padding-block: 0; + padding-inline: 0.7em 0; + width: 82px; +} + +.pkt_ext_introstoryone_img:dir(rtl) { + background-position-x: left; +} + +.pkt_ext_introstorytwo_img { + background: image-set(url(../img/pocketsignup_devices@1x.png), url(../img/pocketsignup_devices@2x.png) 2x) bottom right no-repeat; + height: 110px; + padding-block: 1em 0; + padding-inline: 0.7em 0; + width: 124px; +} + +.pkt_ext_introstorytwo_img:dir(rtl) { + background-position-x: left; +} + +.pkt_ext_introstorydivider { + border-top: 1px solid #c1c1c1; + height: 1px; + margin: 0 auto; + width: 125px; +} + +/*=Buttons +--------------------------------------------------------------------------------------- */ +.pkt_ext_containersignup .btn { + background-color: #0096dd; + border: 1px solid #0095dd; + border-radius: 2px; + color: #fff; + display: inline-block; + font-size: 1.1em; + font-weight: normal; + line-height: 1; + margin: 0; + padding: 11px 45px; + text-align: center; + text-decoration: none; + text-shadow: 0 -1px 0 rgba(142, 4, 17, 0.5); + transition: background-color 0.1s linear; + width: auto; +} + +.pkt_ext_containersignup .btn-secondary { + background-color: #fbfbfb; + border-color: #c1c1c1; + color: #444; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); +} + +.pkt_ext_containersignup .btn-small { + padding: 6px 20px; +} + +.pkt_ext_containersignup .btn:hover { + background-color: #008acb; + color: #fff; + text-decoration: none; +} + +.pkt_ext_containersignup .btn-secondary:hover, +.pkt_ext_containersignup .btn-important:hover { + background-color: #f6f6f6; + color: #222; +} + +.pkt_ext_containersignup .btn-disabled { + background-image: none; + color: #ccc; + color: rgba(255, 255, 255, 0.6); + cursor: default; + opacity: 0.9; +} + +.pkt_ext_containersignup .signup-btn-firefox, +.pkt_ext_containersignup .signup-btn-email, +.pkt_ext_containersignup .signupinterim-btn-login, +.pkt_ext_containersignup .signupinterim-btn-signup, +.pkt_ext_containersignup .forgot-btn-submit, +.pkt_ext_containersignup .forgotreset-btn-change { + min-width: 12.125em; + padding: 0.8em 1.1875em; + box-sizing: content-box; +} + +.pkt_ext_containersignup .signup-btn-email { + position: relative; + z-index: 10; +} + +.pkt_ext_containersignup .signup-btn-firefox { + min-width: 14.5em; + position: relative; + padding: 0; +} + +.pkt_ext_containersignup .signup-btn-firefox .logo { + background: image-set(url(../img/signup_firefoxlogo@1x.png), url(../img/signup_firefoxlogo@2x.png) 2x) center center no-repeat; + height: 2.6em; + inset-inline-start: 10px; + margin: 0; + padding: 0; + width: 22px; + position: absolute; +} + +.pkt_ext_containersignup .forgotreset-btn-change { + margin-bottom: 2em; +} + +.pkt_ext_containersignup .signup-btn-firefox .text { + display: inline-block; + padding: 0.8em 1.625em; + position: relative; + text-shadow: none; + white-space: nowrap; +} + +.pkt_ext_containersignup .signup-btn-firefox .text { + color: #fff; +} + +.pkt_ext_containersignup .btn-disabled .text { + color: #ccc; + color: rgba(255, 255, 255, 0.6); +} + +/*=Language overrides +--------------------------------------------------------------------------------------- */ +.pkt_ext_signup_de .pkt_ext_introstoryone_img { + margin-inline-end: -5px; + padding-inline-start: 0; +} + +.pkt_ext_signup_de .pkt_ext_introstorytwo .pkt_ext_tagline, +.pkt_ext_signup_es .pkt_ext_introstorytwo .pkt_ext_tagline, +.pkt_ext_signup_ja .pkt_ext_introstorytwo .pkt_ext_tagline, +.pkt_ext_signup_ru .pkt_ext_introstorytwo .pkt_ext_tagline { + margin-bottom: 0.5em; +} + +.pkt_ext_signup_ja .signup-btn-firefox .text, +.pkt_ext_signup_ru .signup-btn-firefox .text { + inset-inline-start: 15px; +} + +.pkt_ext_signup_de .signup-btn-firefox .logo, +.pkt_ext_signup_ja .signup-btn-firefox .logo, +.pkt_ext_signup_ru .signup-btn-firefox .logo { + height: 2.4em; +} + +@media (min-resolution: 1.1dppx) { + .pkt_ext_signup_de .signup-btn-firefox .logo, +.pkt_ext_signup_ja .signup-btn-firefox .logo, +.pkt_ext_signup_ru .signup-btn-firefox .logo { + height: 2.5em; + } +} +.pkt_ext_signup_de .signup-btn-email, +.pkt_ext_signup_ja .signup-btn-email, +.pkt_ext_signup_ru .signup-btn-email { + min-width: 13em; + padding: 0.8533em 1.2667em; +} + +.pkt_ext_signup_de .pkt_ext_logo, +.pkt_ext_signup_es .pkt_ext_logo, +.pkt_ext_signup_ru .pkt_ext_logo { + padding-top: 15px; +} + +.pkt_ext_signup_overflow.pkt_ext_signup_de .signup-btn-firefox .logo, +.pkt_ext_signup_overflow.pkt_ext_signup_es .signup-btn-firefox .logo, +.pkt_ext_signup_overflow.pkt_ext_signup_ja .signup-btn-firefox .logo, +.pkt_ext_signup_overflow.pkt_ext_signup_ru .signup-btn-firefox .logo { + display: none; +} + +/* stylelint-disable max-nesting-depth */ +.pkt_ext_containerhome, +.pkt_ext_wrapperhome { + overflow: hidden; +} + +.pkt_ext_home { + line-height: 20px; + color: #363636; +} +.pkt_ext_home a { + color: #008078; + text-decoration: none; +} +.pkt_ext_home a, .pkt_ext_home p { + font-size: 0.9em; +} +.pkt_ext_home .pkt_ext_hr { + height: 1px; + background: linear-gradient(90deg, #83EDB8 0%, #83EDB8 0%, #83EDB8 0.01%, #1CB0A8 33.15%, #EF4056 67.4%, #FCB643 100%); +} +.pkt_ext_home .pkt_ext_detail { + margin: 18px 20px; +} +.pkt_ext_home .pkt_ext_header { + display: flex; + justify-content: space-between; + align-items: center; +} +.pkt_ext_home .pkt_ext_header .pkt_ext_mylist_icon { + background: url(../img/list-view.svg) no-repeat; + background-size: contain; + height: 1.2em; + width: 1.2em; + margin-inline-end: 8px; +} +.pkt_ext_home .pkt_ext_header a { + height: 36px; + display: flex; + align-items: center; + box-sizing: border-box; + padding: 0 8px; + margin: 12px; + border-radius: 4px; +} +.pkt_ext_home .pkt_ext_header a:hover { + background: #F5F5F5; +} +.pkt_ext_home .pkt_ext_header .pkt_ext_logo { + background: url(../img/pocketlogo.svg) bottom right no-repeat; + background-size: contain; + height: 32px; + width: 123px; + margin: 0 20px; +} +.pkt_ext_home .pkt_ext_detail a { + display: block; +} +.pkt_ext_home .pkt_ext_detail h2 { + font-weight: 600; + font-size: 1em; +} +.pkt_ext_home .pkt_ext_detail h2, +.pkt_ext_home .pkt_ext_detail p { + margin: 8px 0; +} +.pkt_ext_home .pkt_ext_detail h3 { + font-weight: 600; + font-size: 1em; + margin: 12px 0; +} +.pkt_ext_home .pkt_ext_detail .pkt_ext_more { + margin: 19px 0; +} +.pkt_ext_home .pkt_ext_detail .pkt_ext_more .pkt_ext_chevron_right { + background: url(../img/chevron-right.svg) no-repeat; + background-size: contain; + height: 1.2em; + width: 1.2em; +} +.pkt_ext_home .pkt_ext_detail .pkt_ext_more ul { + list-style-type: none; + padding: 0; + line-height: 14px; +} +.pkt_ext_home .pkt_ext_detail .pkt_ext_more ul li a { + height: 44px; + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + border-bottom: 1px solid #EAEAEA; +} +.pkt_ext_home .pkt_ext_detail .pkt_ext_discover { + line-height: 12px; + margin: 20px 0; + height: 40px; + width: 100%; + display: flex; + justify-content: center; + align-items: center; + border-radius: 4px; +} +.pkt_ext_home .pkt_ext_detail .pkt_ext_discover:hover { + background: #F5F5F5; +} + +#stp_style_guide { + border: 1px solid #ddd; + margin: 20px auto; + padding: 20px; + width: 260px; +} +#stp_style_guide #dark_mode_toggle { + text-align: end; +} +body.theme_dark #stp_style_guide { + background: #42414c; +} + +#stp_style_guide .stp_superheader { + margin: 0; +} +#stp_style_guide .stp_styleguide_h4 { + border-bottom: 1px solid #ccc; + margin: 20px 0; +} +#stp_style_guide .stp_styleguide_h5 { + font-size: 10px; + margin: 10px 0; +} + +.stp_tag_picker .stp_tag_picker_tags { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 8px; + border: 1px solid #8F8F9D; + border-radius: 4px; + font-style: normal; + font-weight: normal; + font-size: 1rem; + line-height: 1.2rem; + color: #15141A; + margin-bottom: 10px; +} +.stp_tag_picker .stp_tag_picker_tag { + background: #F0F0F4; + border-radius: 4px; + color: #15141A; + display: inline-block; + font-size: 0.85rem; + line-height: 1rem; + font-style: normal; + font-weight: 600; + padding: 0 8px; + transition: background-color 200ms ease-in-out; +} +body.theme_dark .stp_tag_picker .stp_tag_picker_tag { + background: #2B2A33; + color: #FBFBFB; +} + +.stp_tag_picker .recent_tags .stp_tag_picker_tag { + margin-inline-end: 5px; +} +.stp_tag_picker .stp_tag_picker_tag_remove { + padding-top: 5px; + padding-bottom: 5px; + padding-inline-end: 5px; + color: #5B5B66; + font-weight: 400; +} +.stp_tag_picker .stp_tag_picker_tag_remove:hover { + color: #3E3E44; +} +.stp_tag_picker .stp_tag_picker_tag_remove:focus { + color: #3E3E44; + outline: 2px solid #0060df; + outline-offset: -4px; +} +body.theme_dark .stp_tag_picker .stp_tag_picker_tag_remove { + color: #8F8F9D; +} +body.theme_dark .stp_tag_picker .stp_tag_picker_tag_remove:hover { + color: #fff; +} +body.theme_dark .stp_tag_picker .stp_tag_picker_tag_remove:focus { + outline: 2px solid #00DDFF; +} + +.stp_tag_picker .stp_tag_picker_tag_duplicate { + background-color: #bbb; +} +body.theme_dark .stp_tag_picker .stp_tag_picker_tag_duplicate { + background-color: #666; +} + +.stp_tag_picker .stp_tag_picker_input_wrapper { + display: flex; + flex-grow: 1; +} +.stp_tag_picker .stp_tag_picker_input { + flex-grow: 1; + border: 1px solid #8F8F9D; + padding: 0 6px; + border-start-start-radius: 4px; + border-end-start-radius: 4px; +} +.stp_tag_picker .stp_tag_picker_input:focus { + border: 1px solid #0060DF; + outline: 1px solid #0060DF; +} +body.theme_dark .stp_tag_picker .stp_tag_picker_input { + background: none; + color: #FBFBFB; +} +body.theme_dark .stp_tag_picker .stp_tag_picker_input:focus { + border: 1px solid #00DDFF; + outline: 1px solid #00DDFF; +} + +.stp_tag_picker .stp_tag_picker_button { + font-size: 0.95rem; + line-height: 1.1rem; + padding: 4px 6px; + background-color: #F0F0F4; + border: 1px solid #8F8F9D; + border-inline-start: none; + border-start-end-radius: 4px; + border-end-end-radius: 4px; +} +.stp_tag_picker .stp_tag_picker_button:disabled { + color: #8F8F9D; +} +.stp_tag_picker .stp_tag_picker_button:hover:enabled { + background-color: #DADADF; +} +.stp_tag_picker .stp_tag_picker_button:focus:enabled { + border: 1px solid #0060DF; + outline: 1px solid #0060DF; +} +body.theme_dark .stp_tag_picker .stp_tag_picker_button { + background-color: #2B2A33; + color: #FBFBFB; +} +body.theme_dark .stp_tag_picker .stp_tag_picker_button:disabled { + color: #666; +} +body.theme_dark .stp_tag_picker .stp_tag_picker_button:hover:enabled { + background-color: #53535d; +} +body.theme_dark .stp_tag_picker .stp_tag_picker_button:focus:enabled { + border: 1px solid #00DDFF; + outline: 1px solid #00DDFF; +} + +/* stylelint-disable max-nesting-depth */ +.stp_popular_topics { + padding: 0; +} +.stp_popular_topics .stp_popular_topic { + display: inline-block; +} +.stp_popular_topics .stp_popular_topic .stp_popular_topic_link { + display: inline-block; + background: #F0F0F4; + border-radius: 4px; + font-size: 0.85rem; + line-height: 1rem; + font-style: normal; + font-weight: 600; + margin-inline-end: 8px; + margin-bottom: 8px; + padding: 4px 8px; + color: #000; +} +.stp_popular_topics .stp_popular_topic .stp_popular_topic_link:focus { + text-decoration: none; + background: #F0F0F4; + outline: 2px solid #0060df; + outline-offset: 2px; +} +.stp_popular_topics .stp_popular_topic .stp_popular_topic_link:hover { + background: #E0E0E6; + text-decoration: none; +} +.stp_popular_topics .stp_popular_topic .stp_popular_topic_link:active { + background: #CFCFD8; +} +.stp_popular_topics .stp_popular_topic .stp_popular_topic_link::after { + content: " >"; +} +body.theme_dark .stp_popular_topics .stp_popular_topic .stp_popular_topic_link { + background: #2B2A33; + color: #FBFBFE; +} +body.theme_dark .stp_popular_topics .stp_popular_topic .stp_popular_topic_link:focus { + outline: 2px solid #00DDFF; +} +body.theme_dark .stp_popular_topics .stp_popular_topic .stp_popular_topic_link:hover { + background: #53535d; +} + +.stp_article_list { + padding: 0; + list-style: none; +} +.stp_article_list .stp_article_list_saved_article, +.stp_article_list .stp_article_list_link { + display: flex; + border-radius: 4px; + padding: 8px; + margin: 0 -8px; +} +.stp_article_list .stp_article_list_link:hover, .stp_article_list .stp_article_list_link:focus { + text-decoration: none; + background-color: #ECECEE; +} +body.theme_dark .stp_article_list .stp_article_list_link:hover, body.theme_dark .stp_article_list .stp_article_list_link:focus { + background-color: #2B2A33; +} + +.stp_article_list .stp_article_list_thumb, +.stp_article_list .stp_article_list_thumb_placeholder { + width: 40px; + height: 40px; + border-radius: 4px; + margin-inline-end: 8px; + background-color: #ECECEE; + flex-shrink: 0; +} +.stp_article_list .stp_article_list_header { + font-style: normal; + font-weight: 600; + font-size: 0.95rem; + line-height: 1.18rem; + color: #15141A; + margin: 0 0 4px; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + display: -webkit-box; + overflow: hidden; + word-break: break-word; +} +body.theme_dark .stp_article_list .stp_article_list_header { + color: #FBFBFE; +} + +.stp_article_list .stp_article_list_publisher { + font-style: normal; + font-weight: normal; + font-size: 0.95rem; + line-height: 1.18rem; + color: #52525E; + margin: 4px 0 0; +} +body.theme_dark .stp_article_list .stp_article_list_publisher { + color: #CFCFD8; +} + +.stp_header { + display: flex; + justify-content: space-between; + align-items: center; + margin: 16px 0 12px; + font-weight: 600; +} +.stp_header .stp_header_logo { + background: url(../img/pocketlogo.svg) bottom center no-repeat; + background-size: contain; + height: 32px; + width: 121px; +} +body.theme_dark .stp_header .stp_header_logo { + background-image: url(../img/pocketlogo-dark.svg); +} + +.stp_header .stp_button { + margin: 0; +} + +.stp_button { + cursor: pointer; + display: inline-block; + margin: 12px 0; +} +.stp_button:hover { + text-decoration: none; +} +.stp_button.stp_button_text { + color: #0060DF; + font-size: 0.95rem; + line-height: 1.2rem; + font-style: normal; + font-weight: 600; +} +.stp_button.stp_button_text:focus { + text-decoration: underline; +} +.stp_button.stp_button_text:hover { + color: #0250BB; + text-decoration: none; +} +.stp_button.stp_button_text:active { + color: #054096; +} +body.theme_dark .stp_button.stp_button_text { + color: #00DDFF; +} + +.stp_button.stp_button_primary { + align-items: center; + background: #0060DF; + border-radius: 4px; + color: #FBFBFE; + font-size: 0.85rem; + line-height: 1rem; + font-style: normal; + font-weight: 600; + justify-content: center; + padding: 6px 12px; +} +.stp_button.stp_button_primary:focus { + text-decoration: none; + background: #0060DF; + outline: 2px solid #0060df; + outline-offset: 2px; +} +.stp_button.stp_button_primary:hover { + background: #0250BB; +} +.stp_button.stp_button_primary:active { + background: #054096; +} +body.theme_dark .stp_button.stp_button_primary { + background: #00DDFF; + color: #15141A; +} +body.theme_dark .stp_button.stp_button_primary:hover { + background: #80ebfe; +} +body.theme_dark .stp_button.stp_button_primary:focus { + outline: 2px solid #00DDFF; +} + +.stp_button.stp_button_secondary { + align-items: center; + background: #F0F0F4; + border-radius: 4px; + color: #15141A; + font-size: 0.85rem; + line-height: 1rem; + font-style: normal; + font-weight: 600; + padding: 6px 12px; +} +.stp_button.stp_button_secondary:focus { + text-decoration: none; + background: #F0F0F4; + outline: 2px solid #0060df; + outline-offset: 2px; +} +.stp_button.stp_button_secondary:hover { + background: #E0E0E6; +} +.stp_button.stp_button_secondary:active { + background: #CFCFD8; +} +body.theme_dark .stp_button.stp_button_secondary { + background: #2B2A33; + color: #FBFBFE; +} +body.theme_dark .stp_button.stp_button_secondary:focus { + outline: 2px solid #00DDFF; +} +body.theme_dark .stp_button.stp_button_secondary:hover { + background: #53535d; +} + +.stp_button_wide .stp_button { + display: block; + margin: 12px 0; + text-align: center; + padding: 8px 12px; +} +.stp_button_wide .stp_button.stp_button_primary { + font-size: 1.1rem; + line-height: 1.35rem; +} +.stp_button_wide .stp_button.stp_button_secondary { + font-size: 0.85rem; + line-height: 1rem; +} + +.stp_button_wide .stp_button { + display: block; + margin: 12px 0; + text-align: center; +} + +body.stp_signup_body { + overflow: hidden; +} + +.stp_panel_signup .stp_signup_content_wrapper { + margin: 12px 0 20px; +} +.stp_panel_signup .stp_signup_img_rainbow_reader { + background: url(../img/rainbow-reader.svg) bottom center no-repeat; + background-size: contain; + height: 72px; + width: 82px; + float: inline-end; + margin-inline-start: 16px; +} + +body.stp_saved_body { + overflow: hidden; +} + +.stp_panel_error { + margin: 23px 0 32px; +} +.stp_panel_error .stp_panel_error_icon { + float: inline-start; + margin-block: 6px 16px; + margin-inline: 7px 17px; + background-image: url(../img/pocketerror@1x.png); + height: 44px; + width: 44px; +} + +/*# sourceMappingURL=main.compiled.css.map */ diff --git a/browser/components/pocket/content/panels/css/main.scss b/browser/components/pocket/content/panels/css/main.scss new file mode 100644 index 0000000000..7a3111210a --- /dev/null +++ b/browser/components/pocket/content/panels/css/main.scss @@ -0,0 +1,17 @@ +@import "./normalize"; +@import "./global"; +@import "./panel"; +@import "./saved"; +@import "./signup"; +@import "./home"; +@import "./styleguide"; + +// Components + +@import "../js/components/TagPicker/TagPicker"; +@import "../js/components/PopularTopics/PopularTopics"; +@import "../js/components/ArticleList/ArticleList"; +@import "../js/components/Header/Header"; +@import "../js/components/Button/Button"; +@import "../js/components/Signup/Signup"; +@import "../js/components/Saved/Saved"; diff --git a/browser/components/pocket/content/panels/css/normalize.scss b/browser/components/pocket/content/panels/css/normalize.scss new file mode 100644 index 0000000000..af461f1e0b --- /dev/null +++ b/browser/components/pocket/content/panels/css/normalize.scss @@ -0,0 +1,425 @@ +/*! normalize.css v3.0.2 | MIT License | git.io/normalize */ + +/** + * 1. Set default font family to sans-serif. + * 2. Prevent iOS text size adjust after orientation change, without disabling + * user zoom. + */ + +html { + font-family: sans-serif; /* 1 */ +} + +/** + * Remove default margin. + */ + +body { + margin: 0; +} + +/* HTML5 display definitions + ========================================================================== */ + +/** + * Correct `block` display not defined for any HTML5 element in IE 8/9. + * Correct `block` display not defined for `details` or `summary` in IE 10/11 + * and Firefox. + * Correct `block` display not defined for `main` in IE 11. + */ + +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +main, +menu, +nav, +section, +summary { + display: block; +} + +/** + * 1. Correct `inline-block` display not defined in IE 8/9. + * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. + */ + +audio, +canvas, +progress, +video { + display: inline-block; /* 1 */ + vertical-align: baseline; /* 2 */ +} + +/** + * Prevent modern browsers from displaying `audio` without controls. + * Remove excess height in iOS 5 devices. + */ + +audio:not([controls]) { + display: none; + height: 0; +} + +/** + * Address `[hidden]` styling not present in IE 8/9/10. + * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. + */ + +[hidden], +template { + display: none; +} + +/* Links + ========================================================================== */ + +/** + * Remove the gray background color from active links in IE 10. + */ + +a { + background-color: transparent; +} + +/** + * Improve readability when focused and also mouse hovered in all browsers. + */ + +a:active, +a:hover { + outline: 0; +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Address styling not present in IE 8/9/10/11, Safari, and Chrome. + */ + +abbr[title] { + border-bottom: 1px dotted; +} + +/** + * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. + */ + +b, +strong { + font-weight: bold; +} + +/** + * Address styling not present in Safari and Chrome. + */ + +dfn { + font-style: italic; +} + +/** + * Address variable `h1` font-size and margin within `section` and `article` + * contexts in Firefox 4+, Safari, and Chrome. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/** + * Address styling not present in IE 8/9. + */ + +mark { + background: #ff0; + color: #000; +} + +/** + * Address inconsistent and variable font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` affecting `line-height` in all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sup { + top: -0.5em; +} + +sub { + bottom: -0.25em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove border when inside `a` element in IE 8/9/10. + */ + +img { + border: 0; +} + +/** + * Correct overflow not hidden in IE 9/10/11. + */ + +svg:not(:root) { + overflow: hidden; +} + +/* Grouping content + ========================================================================== */ + +/** + * Address margin not present in IE 8/9 and Safari. + */ + +figure { + margin: 1em 40px; +} + +/** + * Address differences between Firefox and other browsers. + */ + +hr { + -moz-box-sizing: content-box; + box-sizing: content-box; + height: 0; +} + +/** + * Contain overflow in all browsers. + */ + +pre { + overflow: auto; +} + +/** + * Address odd `em`-unit font size rendering in all browsers. + */ + +code, +kbd, +pre, +samp { + font-family: monospace, monospace; + font-size: 1em; +} + +/* Forms + ========================================================================== */ + +/** + * Known limitation: by default, Chrome and Safari on OS X allow very limited + * styling of `select`, unless a `border` property is set. + */ + +/** + * 1. Correct color not being inherited. + * Known issue: affects color of disabled elements. + * 2. Correct font properties not being inherited. + * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. + */ + +button, +input, +optgroup, +select, +textarea { + color: inherit; /* 1 */ + font: inherit; /* 2 */ + margin: 0; /* 3 */ +} + +/** + * Address `overflow` set to `hidden` in IE 8/9/10/11. + */ + +button { + overflow: visible; +} + +/** + * Address inconsistent `text-transform` inheritance for `button` and `select`. + * All other form control elements do not inherit `text-transform` values. + * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. + * Correct `select` style inheritance in Firefox. + */ + +button, +select { + text-transform: none; +} + +/** + * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` + * and `video` controls. + * 2. Correct inability to style clickable `input` types in iOS. + * 3. Improve usability and consistency of cursor style between image-type + * `input` and others. + */ + +button, +html input[type="button"], /* 1 */ +input[type="reset"], +input[type="submit"] { + cursor: pointer; /* 3 */ +} + +/** + * Re-set default cursor for disabled elements. + */ + +button[disabled], +html input[disabled] { + cursor: default; +} + +/** + * Remove inner padding and border in Firefox 4+. + */ + +button::-moz-focus-inner, +input::-moz-focus-inner { + border: 0; + padding: 0; +} + +/** + * Address Firefox 4+ setting `line-height` on `input` using `!important` in + * the UA stylesheet. + */ + +input { + line-height: normal; +} + +/** + * It's recommended that you don't attempt to style these elements. + * Firefox's implementation doesn't respect box-sizing, padding, or width. + * + * 1. Address box sizing set to `content-box` in IE 8/9/10. + * 2. Remove excess padding in IE 8/9/10. + */ + +input[type="checkbox"], +input[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * 1. Address `appearance` set to `searchfield` in Safari and Chrome. + * 2. Address `box-sizing` set to `border-box` in Safari and Chrome + * (include `-moz` to future-proof). + */ + +input[type="search"] { + box-sizing: content-box; +} + +/** + * Define consistent border, margin, and padding. + */ + +fieldset { + border: 1px solid #c0c0c0; + margin: 0 2px; + padding: 0.35em 0.625em 0.75em; +} + +/** + * 1. Correct `color` not being inherited in IE 8/9/10/11. + * 2. Remove padding so people aren't caught out if they zero out fieldsets. + */ + +legend { + border: 0; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Remove default vertical scrollbar in IE 8/9/10/11. + */ + +textarea { + overflow: auto; +} + +/** + * Don't inherit the `font-weight` (applied by a rule above). + * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. + */ + +optgroup { + font-weight: bold; +} + +/* Tables + ========================================================================== */ + +/** + * Remove most spacing between table cells. + */ + +table { + border-collapse: collapse; + border-spacing: 0; +} + +td, +th { + padding: 0; +} + +/* Normalization for FF panel defauts + ========================================================================== */ +html { + outline: none; + padding: 0; +} + +a { + color: #0095dd; + margin: 0; + outline: none; + padding: 0; + text-decoration: none; +} + +a:hover, +a:active, +a:focus { + color: #008acb; + text-decoration: underline; +} + +a:active { + color: #006b9d; +} diff --git a/browser/components/pocket/content/panels/css/panel.scss b/browser/components/pocket/content/panels/css/panel.scss new file mode 100644 index 0000000000..e89ba4e0fe --- /dev/null +++ b/browser/components/pocket/content/panels/css/panel.scss @@ -0,0 +1,8 @@ +.stp_panel_container { + overflow: hidden; +} + +.stp_panel { + padding: 0 16px; + margin: 16px 0 12px; +} diff --git a/browser/components/pocket/content/panels/css/saved.scss b/browser/components/pocket/content/panels/css/saved.scss new file mode 100644 index 0000000000..756adde27a --- /dev/null +++ b/browser/components/pocket/content/panels/css/saved.scss @@ -0,0 +1,903 @@ +/* saved.css + * + * Description: + * With base elements out of the way, this sets all custom styling for the page saved dialog. + * + * Contents: + * Global + * Loading spinner + * Core detail + * Tag entry + * Recent/suggested tags + * Premium upsell + * Token input/autocomplete + * Overflow mode + * Language overrides + */ + +/*=Global +--------------------------------------------------------------------------------------- */ +.pkt_ext_containersaved { + background-color: #fbfbfb; + border-radius: 4px; + display: block; + padding: 0; + position: relative; + text-align: center; + overflow: hidden; +} +.pkt_ext_cf:after { + content: " "; + display:table; + clear:both; +} +.pkt_ext_containersaved .pkt_ext_tag_detail, +.pkt_ext_containersaved .pkt_ext_recenttag_detail, +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail { + margin: 0 auto; + padding: 0.25em 1em; + position: relative; + width: auto; +} + +/*=Loading spinner +--------------------------------------------------------------------------------------- */ +@keyframes pkt_ext_spin { + to { + transform: rotate(1turn); + } +} +.pkt_ext_containersaved .pkt_ext_loadingspinner { + display: inline-block; + height: 2.5em; + inset-inline-start: 50%; + margin-block: 2em 0; + margin-inline: -1.25em 0; + font-size: 10px; + text-indent: 999em; + position: absolute; + top: 4em; + overflow: hidden; + width: 2.5em; + animation: pkt_ext_spin 0.7s infinite steps(8); +} +.pkt_ext_containersaved .pkt_ext_loadingspinner:before, +.pkt_ext_containersaved .pkt_ext_loadingspinner:after, +.pkt_ext_containersaved .pkt_ext_loadingspinner > div:before, +.pkt_ext_containersaved .pkt_ext_loadingspinner > div:after { + content: ''; + position: absolute; + top: 0; + inset-inline-start: 1.125em; + width: 0.25em; + height: 0.75em; + border-radius: .2em; + background: #eee; + box-shadow: 0 1.75em #eee; + transform-origin: 50% 1.25em; +} +.pkt_ext_containersaved .pkt_ext_loadingspinner:before { + background: #555; +} +.pkt_ext_containersaved .pkt_ext_loadingspinner:after { + transform: rotate(-45deg); + background: #777; +} +.pkt_ext_containersaved .pkt_ext_loadingspinner > div:before { + transform: rotate(-90deg); + background: #999; +} +.pkt_ext_containersaved .pkt_ext_loadingspinner > div:after { + transform: rotate(-135deg); + background: #bbb; +} + +/*=Core detail +--------------------------------------------------------------------------------------- */ +.pkt_ext_containersaved .pkt_ext_initload { + inset-inline-start: 0; + position: absolute; + top: 0; + width: 100%; +} +.pkt_ext_containersaved .pkt_ext_detail { + opacity: 0; + position: relative; + padding-bottom: 1.25em; +} +.pkt_ext_container_detailactive .pkt_ext_initload { + opacity: 0; +} +.pkt_ext_container_detailactive .pkt_ext_initload .pkt_ext_loadingspinner, +.pkt_ext_container_finalstate .pkt_ext_initload .pkt_ext_loadingspinner { + animation: none; +} +.pkt_ext_container_detailactive .pkt_ext_detail { + max-height: 20em; + opacity: 1; +} +.pkt_ext_container_finalstate .pkt_ext_edit_msg, +.pkt_ext_container_finalstate .pkt_ext_tag_detail, +.pkt_ext_container_finalstate .pkt_ext_suggestedtag_detail, +.pkt_ext_container_finalstate .pkt_ext_item_actions { + opacity: 0; + transition: opacity 0.2s ease-out; +} +.pkt_ext_container_finalerrorstate .pkt_ext_edit_msg, +.pkt_ext_container_finalerrorstate .pkt_ext_tag_detail, +.pkt_ext_container_finalerrorstate .pkt_ext_suggestedtag_detail, +.pkt_ext_container_finalerrorstate .pkt_ext_item_actions { + display: none; + transition: none; +} +.pkt_ext_containersaved h2 { + background: transparent; + border: none; + color: #333; + display: block; + float: none; + font-size: 1.2em; + font-weight: normal; + letter-spacing: normal; + line-height: 1; + margin: 19px 0 4px; + padding: 0; + position: relative; + text-align: start; + text-transform: none; +} +@keyframes fade_in_out { + 0% { + opacity: 1; + } + 50% { + opacity: 0; + } + 100% { + opacity: 1; + } +} +.pkt_ext_container_finalstate h2 { + animation: fade_in_out 0.4s ease-out; +} +.pkt_ext_container_finalerrorstate h2 { + animation: none; + color: #d74345; +} +.pkt_ext_containersaved .pkt_ext_errordetail { + display: none; + font-size: 0.9em; + font-weight: normal; + inset-inline-start: 6.4em; + max-width: 21em; + opacity: 0; + position: absolute; + top: 2.7em; + text-align: start; + visibility: hidden; +} +.pkt_ext_container_finalerrorstate { + max-height: 133px; +} +.pkt_ext_container_finalerrorstate .pkt_ext_errordetail { + display: block; + opacity: 1; + visibility: visible; +} +.pkt_ext_containersaved .pkt_ext_logo { + background: image-set(url(../img/pocketlogosolo@1x.png), url(../img/pocketlogosolo@2x.png) 2x), center center no-repeat; + display: block; + float: inline-start; + height: 40px; + padding: 1.25em 1em; + position: relative; + width: 44px; +} +.pkt_ext_container_finalerrorstate .pkt_ext_logo { + background-image: image-set(url(../img/pocketerror@1x.png), url(../img/pocketerror@2x.png) 2x); + height: 44px; + width: 44px; +} +.pkt_ext_containersaved .pkt_ext_topdetail { + float: inline-start; +} +.pkt_ext_containersaved { + .pkt_ext_edit_msg_container { + position: relative; + .pkt_ext_edit_msg { + box-sizing: border-box; + display: none; + font-size: 0.75em; + inset-inline-start: auto; + padding: 0 1.4em; + position: absolute; + text-align: start; + top: 0; + width: 100%; + margin: 0; + &.pkt_ext_edit_msg_error { + color: #d74345; + } + &.pkt_ext_edit_msg_active { + display: block; + } + } + } +} +.pkt_ext_containersaved .pkt_ext_item_actions { + background: transparent; + float: none; + height: auto; + margin-bottom: 1em; + margin-top: 0; + width: auto; +} +.pkt_ext_containersaved .pkt_ext_item_actions_disabled { + opacity: 0.5; +} +.pkt_ext_container_finalstate .pkt_ext_item_actions_disabled { + opacity: 0; +} +.pkt_ext_containersaved .pkt_ext_item_actions ul { + background: none; + display: block; + float: none; + height: auto; + margin: 0; + padding: 0; + width: 100%; +} +.pkt_ext_containersaved .pkt_ext_item_actions li { + box-sizing: border-box; + background: none; + border: 0; + float: inline-start; + list-style: none; + line-height: 0.8; + height: auto; + padding-inline-end: 0.4em; + width: auto; +} +.pkt_ext_containersaved .pkt_ext_item_actions li:before { + content: none; +} +.pkt_ext_containersaved .pkt_ext_item_actions .pkt_ext_actions_separator { + border-inline-start: 2px solid #777; + height: 1em; + margin-top: 0.3em; + padding: 0; + width: 10px; +} +.pkt_ext_containersaved .pkt_ext_item_actions a { + background: transparent; + color: #0095dd; + display: block; + font-feature-settings: normal; + font-size: 0.9em; + font-weight: normal; + letter-spacing: normal; + line-height: inherit; + height: auto; + margin: 0; + padding: 0.5em; + float: inline-start; + text-align: start; + text-decoration: none; + text-transform: none; +} +.pkt_ext_containersaved .pkt_ext_item_actions a:hover, +.pkt_ext_containersaved .pkt_ext_item_actions a:focus { + color: #008acb; + text-decoration: underline; +} +.pkt_ext_containersaved .pkt_ext_item_actions a:before, +.pkt_ext_containersaved .pkt_ext_item_actions a:after { + background: transparent; + display: none; +} +.pkt_ext_containersaved .pkt_ext_item_actions_disabled a { + cursor: default; +} +.pkt_ext_containersaved .pkt_ext_item_actions .pkt_ext_openpocket { + float: inline-end; + padding-inline-end: 0.7em; + text-align: end; +} +.pkt_ext_containersaved .pkt_ext_item_actions .pkt_ext_removeitem { + padding-inline-start: 0; +} +.pkt_ext_containersaved .pkt_ext_close { + background: image-set(url(../img/tag_close@1x.png), url(../img/tag_close@2x.png) 2x) center center no-repeat; + color: #333; + display: block; + font-size: 0.8em; + height: 10px; + inset-inline-end: 0.5em; + overflow: hidden; + position: absolute; + text-align: center; + text-indent: -9999px; + top: -1em; + width: 10px; +} +.pkt_ext_containersaved .pkt_ext_close:hover { + color: #000; + text-decoration: none; +} + +/*=Tag entry +--------------------------------------------------------------------------------------- */ +.pkt_ext_containersaved .pkt_ext_tag_detail { + border: 1px solid #c1c1c1; + border-radius: 2px; + clear: both; + margin: 0 1em; + padding: 0; + display: flex; +} +.pkt_ext_containersaved .pkt_ext_tag_error { + border: none; +} +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper { + box-sizing: border-box; + flex: 1; + background-color: #fff; + border-inline-end: 1px solid #c3c3c3; + color: #333; + display: block; + float: none; + font-size: 0.875em; + list-style: none; + margin: 0; + overflow: hidden; + padding: 0.25em 0.5em; + width: 14em; + padding-inline: 0.5em; +} +.pkt_ext_containersaved .pkt_ext_tag_error .pkt_ext_tag_input_wrapper { + border: 1px solid #d74345; +} +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper .token-input-list { + display: block; + height: 1.7em; + overflow: hidden; + position: relative; + width: 60em; +} +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper .token-input-list, +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper li { + font-size: 1em; +} +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper li { + height: auto; + width: auto; +} +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper li:before { + content: none; +} +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper input { + border: 0; + box-shadow: none; + background-color: #fff; + color: #333; + font-size: 1em; + float: inline-start; + line-height: normal; + height: auto; + min-height: 0; + min-width: 5em; + padding: 3px 2px 1px; + text-transform: none; +} +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper input::placeholder { + color: #a9a9a9; + letter-spacing: normal; + text-transform: none; +} +.pkt_ext_containersaved .input_disabled { + cursor: default; + opacity: 0.5; +} +.pkt_ext_containersaved .pkt_ext_btn { + box-sizing: border-box; + color: #333; + float: none; + font-size: 1em; + letter-spacing: normal; + height: 2.2em; + min-width: 4em; + padding: 0.5em 0; + text-decoration: none; + text-transform: none; + width: auto; +} +.pkt_ext_containersaved .pkt_ext_btn:hover { + background-color: #ebebeb; +} +.pkt_ext_containersaved .pkt_ext_btn:active { + background-color: #dadada; +} +.pkt_ext_containersaved .pkt_ext_btn_disabled, +.pkt_ext_containersaved .pkt_ext_btn_disabled:hover, +.pkt_ext_containersaved .pkt_ext_btn_disabled:active { + background-color: transparent; + cursor: default; + opacity: 0.4; +} +.pkt_ext_containersaved .pkt_ext_tag_error .pkt_ext_btn { + border: 1px solid #c3c3c3; + border-block-width: 1px; + border-inline-width: 0 1px; + height: 2.35em; +} +.pkt_ext_containersaved .autocomplete-suggestions { + margin-top: 2.2em; +} + +/*=Recent/suggested tags +--------------------------------------------------------------------------------------- */ +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail { + box-sizing: border-box; + clear: both; + inset-inline-start: 0; + opacity: 0; + min-height: 110px; + visibility: hidden; + width: 100%; +} +.pkt_ext_container_detailactive .pkt_ext_suggestedtag_detail { + opacity: 1; + visibility: visible; +} +.pkt_ext_container_finalstate .pkt_ext_suggestedtag_detail { + opacity: 0; + visibility: hidden; +} + +.pkt_ext_containersaved .pkt_ext_recenttag_detail h4, +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail h4 { + color: #333; + font-size: 1em; + font-weight: normal; + font-style: normal; + letter-spacing: normal; + margin: 0.5em 0; + text-align: start; + text-transform: none; +} +.pkt_ext_containersaved .pkt_ext_recenttag_detail .pkt_ext_loadingspinner, +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail .pkt_ext_loadingspinner { + display: none; + position: absolute; +} +.pkt_ext_containersaved .pkt_ext_recenttag_detail_loading .pkt_ext_loadingspinner, +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail_loading .pkt_ext_loadingspinner { + display: block; + font-size: 6px; + inset-inline-start: 48%; +} +.pkt_ext_containersaved .pkt_ext_recenttag_detail ul, +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail ul { + display: block; + margin: 0; + height: 2em; + overflow: hidden; + padding: 2px 0 0; +} +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail ul { + height: auto; + margin: 0; + max-height: 4em; + padding-top: 6px; +} +.pkt_ext_containersaved .pkt_ext_recenttag_detail li, +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail li { + background: none; + float: inline-start; + height: inherit; + line-height: 1.5; + list-style: none; + margin-bottom: 0.5em; + width: inherit; +} +.pkt_ext_containersaved .pkt_ext_recenttag_detail li:before, +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail li:before { + content: none; +} +.pkt_ext_containersaved .pkt_ext_recenttag_detail .recenttag_msg, +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail .suggestedtag_msg { + color: #333; + font-size: 0.8125em; + line-height: 1.2; + inset-inline-start: auto; + position: absolute; + text-align: start; + top: 2em; +} +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail .suggestedtag_msg { + margin-inline-end: 1.3em; +} +.pkt_ext_containersaved .token_tag { + border-radius: 4px; + background: #f7f7f7; + border: 1px solid #c3c3c3; + color: #333; + font-size: 1em; + font-weight: normal; + letter-spacing: normal; + margin-inline-end: 0.5em; + padding: 0.125em 0.625em; + text-decoration: none; + text-transform: none; +} +.pkt_ext_containersaved .token_tag:hover { + background-color: #008acb; + border-color: #008acb; + color: #fff; + text-decoration: none; +} +.pkt_ext_containersaved .token_tag:before, +.pkt_ext_containersaved .token_tag:after { + content: none; +} +.pkt_ext_containersaved .token_tag:hover span { + background-image: image-set(url(../img/tag_closeactive@1x.png), url(../img/tag_closeactive@2x.png) 2x); +} +.pkt_ext_containersaved .pkt_ext_recenttag_detail_disabled .token_tag, +.pkt_ext_containersaved .pkt_ext_recenttag_detail_disabled .token_tag:hover, +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail_disabled .token_tag, +.pkt_ext_containersaved .pkt_ext_suggestedtag_detail_disabled .token_tag:hover { + background-color: #f7f7f7; + cursor: default; + opacity: 0.5; +} +.pkt_ext_containersaved .token_tag_inactive { + display: none; +} + +/*=Premium upsell +--------------------------------------------------------------------------------------- */ +.pkt_ext_detail .pkt_ext_premupsell { + background-color: #50bbb6; + display: block; + padding: 1.5em 0; + text-align: center; +} +.pkt_ext_premupsell h4 { + color: #fff; + font-size: 1em; + margin-bottom: 1em; +} +.pkt_ext_premupsell a { + color: #28605d; + border-bottom: 1px solid #47a7a3; + font-weight: normal; +} +.pkt_ext_premupsell a:hover { + color: #14302f; +} + +/*=Token input/autocomplete +--------------------------------------------------------------------------------------- */ +.token-input-dropdown-tag { + border-radius: 4px; + box-sizing: border-box; + background: #fff; + border: 1px solid #cdcdcd; + margin-top: 0.5em; + inset-inline-start: 0 !important; + overflow-y: auto; + top: 1.9em !important; + z-index: 9000; +} +.token-input-dropdown-tag ul { + height: inherit; + max-height: 115px; + margin: 0; + overflow: auto; + padding: 0.5em 0; +} +.token-input-dropdown-tag ul li { + background: none; + color: #333; + font-weight: normal; + font-size: 1em; + float: none; + height: inherit; + letter-spacing: normal; + list-style: none; + padding: 0.75em; + text-align: start; + text-transform: none; + width: inherit; +} +.token-input-dropdown-tag ul li:before { + content: none; +} +.token-input-dropdown ul li.token-input-selected-dropdown-item { + background-color: #008acb; + color: #fff; +} +.token-input-list { + list-style: none; + margin: 0; + padding: 0; +} +.token-input-list li { + text-align: start; + list-style: none; +} +.token-input-list li input { + border: 0; + background-color: white; +} +.pkt_ext_containersaved .token-input-token { + background: none; + border-radius: 4px; + border: 1px solid #c3c3c3; + overflow: hidden; + margin: 0 0.2em; + padding: 0 8px; + background-color: #f7f7f7; + color: #000; + font-weight: normal; + cursor: default; + line-height: 1.5; + display: block; + width: auto; + float: inline-start; +} +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper_disabled { + position: relative; +} +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper_disabled input { + opacity: 0.5; +} +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper_disabled .token-input-list { + opacity: 0.5; +} +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper_disabled .pkt_ext_tag_input_blocker { + height: 100%; + inset-inline-start: 0; + position: absolute; + top: 0; + width: 100%; + z-index: 5; +} +.pkt_ext_containersaved .token-input-token p { + display: inline-block; + font-size: 1em; + font-weight: normal; + line-height: inherit; + letter-spacing: normal; + padding: 0; + margin: 0; + text-transform: none; + vertical-align: top; + width: auto; + unicode-bidi: plaintext; +} +.pkt_ext_containersaved .token-input-token p:before { + content: none; + width: 0; +} +.pkt_ext_containersaved .token-input-token span { + background: image-set(url(../img/tag_close@1x.png), url(../img/tag_close@2x.png) 2x) center center no-repeat; + cursor: pointer; + display: inline-block; + height: 8px; + margin-block: 0; + margin-inline: 8px 0; + overflow: hidden; + width: 8px; + text-indent: -99px; +} +.pkt_ext_containersaved .token-input-selected-token { + background-color: #008acb; + border-color: #008acb; + color: #fff; +} +.pkt_ext_containersaved .token-input-selected-token span { + background-image: image-set(url(../img/tag_closeactive@1x.png), url(../img/tag_closeactive@2x.png) 2x); +} +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper_disabled .token-input-selected-token { + background-color: #f7f7f7; +} +.pkt_ext_containersaved .pkt_ext_tag_input_wrapper_disabled .token-input-selected-token span { + color: #bbb; +} + +/*=Language overrides +--------------------------------------------------------------------------------------- */ +.pkt_ext_saved_es .pkt_ext_btn { + min-width: 5em; +} +.pkt_ext_saved_de .pkt_ext_btn, +.pkt_ext_saved_ru .pkt_ext_btn { + min-width: 6em; +} + +/*=Coral Button +--------------------------------------------------------------------------------------- */ +button { + padding: 0; + margin: 0; + background: none; + border: 0; + outline: none; + color: inherit; + font: inherit; + overflow: visible; +} + +.pkt_ext_button { + padding: 3px; + background-color: #EF4056; + color: #FFF; + text-align: center; + cursor: pointer; + height: 32px; + box-sizing: border-box; + width: 320px; + margin: 0 auto; + border-radius: 2px; + font-size: 1em; +} + +.pkt_ext_button:hover, +.pkt_ext_button:active { + background-color: #d5374b; +} + +/* alt button */ +.pkt_ext_blue_button { + background-color: #0060df; + color: #FFF; +} + +.pkt_ext_blue_button:hover { + background-color: #003eaa; +} + +.pkt_ext_blue_button:active { + background-color: #002275; +} + +.pkt_ext_ffx_icon:after { + position: absolute; + height: 22px; + width: 22px; + top: -3px; + inset-inline-start: -28px; + content: ""; + background-image: url(../img/signup_firefoxlogo@2x.png); + background-size: 22px 22px; + background-repeat: no-repeat; +} + +.pkt_ext_subshell { + display: none; + border-top: 1px solid #c1c1c1; + background: #ebebeb; + width: 100%; +} + +.pkt_ext_subshell hr { + display: none; +} + +.recs_enabled .pkt_ext_subshell hr { + display: block; + border: 0; + border-top: 1px solid #D7D7DB; + margin: 0; +} + +.pkt_ext_item_recs { + text-align: start; + margin: 0 auto; + padding: 0.25em 1em; +} + +.pkt_ext_item_recs header { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 12px; +} + +.pkt_ext_item_recs header h4 { + color: #333; + font-size: 1em; + font-weight: normal; + font-style: normal; + letter-spacing: normal; + margin: 0.5em 0; + text-align: start; + text-transform: none; +} + +.pkt_ext_item_recs header a { + font-style: normal; + font-weight: 500; + font-size: 1em; + line-height: 20px; + color: #0095DD; +} + +.pkt_ext_containersaved .pkt_ext_item_recs ol { + padding: 0; + margin: 0 0 10px; + list-style: none; +} + +.pkt_ext_containersaved .pkt_ext_item_recs li { + float: none; + display: flex; + font-style: normal; + font-weight: normal; + line-height: 18px; + margin: 0 -1em; + min-height: 60px; +} + +.pkt_ext_containersaved .pkt_ext_item_recs li a { + padding-block: 8px; + padding-inline: 1em 40px; + background: url(../img/open.svg) top 8px right 14px no-repeat; + flex-grow: 1; +} + +.pkt_ext_containersaved .pkt_ext_item_recs li a:dir(rtl) { + background-position-x: left 14px; +} + +.pkt_ext_containersaved .pkt_ext_item_recs li:hover, +.pkt_ext_containersaved .pkt_ext_item_recs li a:focus { + background-color: rgba(12, 12, 13, 0.1); +} + +.pkt_ext_containersaved .pkt_ext_item_recs li:active { + background-color: rgba(12, 12, 13, 0.2); +} + +.pkt_ext_containersaved .pkt_ext_item_recs .pkt_ext_item_recs_link:hover { + text-decoration: none; +} + +.pkt_ext_containersaved .pkt_ext_item_recs .rec-thumb { + width: 40px; + height: 40px; + float: inline-start; + margin: 0; + margin-inline-end: 12px; + border-radius: 2px; +} + +.pkt_ext_containersaved .pkt_ext_item_recs .rec-thumb:-moz-broken { + display: none; +} + +.pkt_ext_containersaved .pkt_ext_item_recs p { + -webkit-box-orient: vertical; + display: -webkit-box; + overflow: hidden; + word-break: break-word; + font-style: normal; + font-weight: normal; + margin: 0; +} + +.pkt_ext_containersaved .pkt_ext_item_recs .rec-title { + -webkit-line-clamp: 2; + font-size: 1em; + line-height: 18px; + color: #0C0C0D; +} + +.pkt_ext_containersaved .pkt_ext_item_recs .rec-source { + -webkit-line-clamp: 1; + font-size: 0.9em; + line-height: 16px; + color: #737373; +} diff --git a/browser/components/pocket/content/panels/css/signup.scss b/browser/components/pocket/content/panels/css/signup.scss new file mode 100644 index 0000000000..d046f1afdc --- /dev/null +++ b/browser/components/pocket/content/panels/css/signup.scss @@ -0,0 +1,324 @@ +/* signup.css + * + * Description: + * With base elements out of the way, this sets all custom styling for the extension. + * + * Contents: + * Global + * Core detail + * Core detail - storyboard + * Buttons + * Overflow mode + * Language overrides + */ + +/*=Global +--------------------------------------------------------------------------------------- */ +.pkt_ext_containersignup { + background-color: #ebebeb; + color: #333; + display: block; + margin: 0; + padding: 0; + position: relative; + text-align: center; + overflow: hidden; +} +.pkt_ext_containersignup_inactive { + animation: pkt_ext_hide 0.3s ease-out; + opacity: 0; + visibility: hidden; +} +.pkt_ext_cf:after { + content: " "; + display: table; + clear: both; +} +@keyframes pkt_ext_hide { + 0% { + opacity: 1; + visibility: visible; + } + 99% { + opacity: 0; + visibility: visible; + } + 100% { + opacity: 0; + visibility: hidden; + } +} + +/*=Core detail +--------------------------------------------------------------------------------------- */ +.pkt_ext_containersignup p { + font-size: 1em; + color: #333; + line-height: 1.3; + margin: 0 auto 1.5em; + max-width: 260px; +} +.pkt_ext_containersignup a { + color: #4c8fd0; +} +.pkt_ext_containersignup a:hover { + color: #3076b9; +} +.pkt_ext_containersignup .pkt_ext_introdetail { + background-color: #fbfbfb; + border: 1px solid #c1c1c1; + border-width: 0 0 1px; +} +.pkt_ext_containersignup .pkt_ext_logo { + background: image-set(url(../img/pocketlogo@1x.png), url(../img/pocketlogo@2x.png) 2x) center bottom no-repeat; + display: block; + height: 32px; + margin: 0 auto 15px; + padding-top: 25px; + position: relative; + text-indent: -9999px; + width: 123px; +} +.pkt_ext_containersignup .pkt_ext_introimg { + background: image-set(url(../img/pocketsignup_hero@1x.png), url(../img/pocketsignup_hero@2x.png) 2x) center center no-repeat; + display: block; + height: 125px; + margin: 0 auto; + position: relative; + text-indent: -9999px; + width: 255px; +} +.pkt_ext_containersignup .pkt_ext_tagline { + margin-bottom: 0.5em; +} +.pkt_ext_containersignup .pkt_ext_learnmore { + font-size: 0.9em; +} +.pkt_ext_signupdetail { + overflow: hidden; +} +.pkt_ext_signupdetail h4 { + font-size: 0.9em; + font-weight: normal; +} +.pkt_ext_signupdetail .btn-container { + position: relative; + margin-bottom: 0.8em; +} +.pkt_ext_containersignup .ff_signuphelp { + background: image-set(url(../img/signup_help@1x.png), url(../img/signup_help@2x.png) 2x) center center no-repeat; + display: block; + height: 18px; + margin-top: -9px; + inset-inline-end: -15px; + position: absolute; + text-indent: -9999px; + width: 18px; + top: 50%; +} +.pkt_ext_containersignup .alreadyhave { + font-size: 0.9em; + max-width: 320px; + margin-top: 15px; +} + +/*=Core detail - storyboard +--------------------------------------------------------------------------------------- */ +.pkt_ext_introstory { + align-items: center; + display: flex; + padding: 20px; +} +.pkt_ext_introstory:after { + clear: both; + content: ""; + display: table; +} +.pkt_ext_introstory p { + margin-bottom: 0; + text-align: start; +} +.pkt_ext_introstoryone { + padding-block: 20px 15px; + padding-inline: 20px 18px; +} +.pkt_ext_introstorytwo { + padding-block: 3px 0; + padding-inline: 20px 0; +} +.pkt_ext_introstorytwo .pkt_ext_tagline { + margin-bottom: 1.5em; +} +.pkt_ext_introstory_text { + flex: 1; +} +.pkt_ext_introstoryone_img, +.pkt_ext_introstorytwo_img { + display: block; + overflow: hidden; + position: relative; + text-indent: -999px; +} +.pkt_ext_introstoryone_img { + background: image-set(url(../img/pocketsignup_button@1x.png), url(../img/pocketsignup_button@2x.png) 2x) center right no-repeat; + height: 82px; + padding-block: 0; + padding-inline: 0.7em 0; + width: 82px; +} +.pkt_ext_introstoryone_img:dir(rtl) { + background-position-x: left; +} +.pkt_ext_introstorytwo_img { + background: image-set(url(../img/pocketsignup_devices@1x.png), url(../img/pocketsignup_devices@2x.png) 2x) bottom right no-repeat; + height: 110px; + padding-block: 1em 0; + padding-inline: 0.7em 0; + width: 124px; +} +.pkt_ext_introstorytwo_img:dir(rtl) { + background-position-x: left; +} +.pkt_ext_introstorydivider { + border-top: 1px solid #c1c1c1; + height: 1px; + margin: 0 auto; + width: 125px; +} + +/*=Buttons +--------------------------------------------------------------------------------------- */ +.pkt_ext_containersignup .btn { + background-color: #0096dd; + border: 1px solid #0095dd; + border-radius: 2px; + color: #fff; + display: inline-block; + font-size: 1.1em; + font-weight: normal; + line-height: 1; + margin: 0; + padding: 11px 45px; + text-align: center; + text-decoration: none; + text-shadow: 0 -1px 0 rgba(142,4,17,0.5); + transition: background-color 0.1s linear; + width: auto; +} +.pkt_ext_containersignup .btn-secondary { + background-color: #fbfbfb; + border-color: #c1c1c1; + color: #444; + text-shadow: 0 1px 0 rgba(255,255,255,0.5); +} +.pkt_ext_containersignup .btn-small { + padding: 6px 20px; +} +.pkt_ext_containersignup .btn:hover { + background-color: #008acb; + color: #fff; + text-decoration: none; +} +.pkt_ext_containersignup .btn-secondary:hover, +.pkt_ext_containersignup .btn-important:hover { + background-color: #f6f6f6; + color: #222; +} +.pkt_ext_containersignup .btn-disabled { + background-image: none; + color: #ccc; + color: rgba(255,255,255,0.6); + cursor: default; + opacity: 0.9; +} +.pkt_ext_containersignup .signup-btn-firefox, +.pkt_ext_containersignup .signup-btn-email, +.pkt_ext_containersignup .signupinterim-btn-login, +.pkt_ext_containersignup .signupinterim-btn-signup, +.pkt_ext_containersignup .forgot-btn-submit, +.pkt_ext_containersignup .forgotreset-btn-change { + min-width: 12.125em; + padding: 0.8em 1.1875em; + box-sizing: content-box; +} +.pkt_ext_containersignup .signup-btn-email { + position: relative; + z-index: 10; +} +.pkt_ext_containersignup .signup-btn-firefox { + min-width: 14.5em; + position: relative; + padding: 0; +} +.pkt_ext_containersignup .signup-btn-firefox .logo { + background: image-set(url(../img/signup_firefoxlogo@1x.png), url(../img/signup_firefoxlogo@2x.png) 2x) center center no-repeat; + height: 2.6em; + inset-inline-start: 10px; + margin: 0; + padding: 0; + width: 22px; + position: absolute; +} +.pkt_ext_containersignup .forgotreset-btn-change { + margin-bottom: 2em; +} +.pkt_ext_containersignup .signup-btn-firefox .text { + display: inline-block; + padding: 0.8em 1.625em; + position: relative; + text-shadow: none; + white-space: nowrap; +} +.pkt_ext_containersignup .signup-btn-firefox .text { + color: #fff; +} +.pkt_ext_containersignup .btn-disabled .text { + color: #ccc; + color: rgba(255,255,255,0.6); +} + +/*=Language overrides +--------------------------------------------------------------------------------------- */ +.pkt_ext_signup_de .pkt_ext_introstoryone_img { + margin-inline-end: -5px; + padding-inline-start: 0; +} +.pkt_ext_signup_de .pkt_ext_introstorytwo .pkt_ext_tagline, +.pkt_ext_signup_es .pkt_ext_introstorytwo .pkt_ext_tagline, +.pkt_ext_signup_ja .pkt_ext_introstorytwo .pkt_ext_tagline, +.pkt_ext_signup_ru .pkt_ext_introstorytwo .pkt_ext_tagline { + margin-bottom: 0.5em; +} +.pkt_ext_signup_ja .signup-btn-firefox .text, +.pkt_ext_signup_ru .signup-btn-firefox .text { + inset-inline-start: 15px; +} +.pkt_ext_signup_de .signup-btn-firefox .logo, +.pkt_ext_signup_ja .signup-btn-firefox .logo, +.pkt_ext_signup_ru .signup-btn-firefox .logo { + height: 2.4em; +} +@media (min-resolution: 1.1dppx) { + .pkt_ext_signup_de .signup-btn-firefox .logo, + .pkt_ext_signup_ja .signup-btn-firefox .logo, + .pkt_ext_signup_ru .signup-btn-firefox .logo { + height: 2.5em; + } +} +.pkt_ext_signup_de .signup-btn-email, +.pkt_ext_signup_ja .signup-btn-email, +.pkt_ext_signup_ru .signup-btn-email { + min-width: 13em; + padding: 0.8533em 1.2667em; +} +.pkt_ext_signup_de .pkt_ext_logo, +.pkt_ext_signup_es .pkt_ext_logo, +.pkt_ext_signup_ru .pkt_ext_logo { + padding-top: 15px; +} +.pkt_ext_signup_overflow.pkt_ext_signup_de .signup-btn-firefox .logo, +.pkt_ext_signup_overflow.pkt_ext_signup_es .signup-btn-firefox .logo, +.pkt_ext_signup_overflow.pkt_ext_signup_ja .signup-btn-firefox .logo, +.pkt_ext_signup_overflow.pkt_ext_signup_ru .signup-btn-firefox .logo { + display: none; +} diff --git a/browser/components/pocket/content/panels/css/styleguide.scss b/browser/components/pocket/content/panels/css/styleguide.scss new file mode 100644 index 0000000000..9310d6ba98 --- /dev/null +++ b/browser/components/pocket/content/panels/css/styleguide.scss @@ -0,0 +1,28 @@ +#stp_style_guide { + #dark_mode_toggle { + text-align: end; + } + + border: 1px solid #ddd; + margin: 20px auto; + padding: 20px; + width: 260px; + + @include theme_dark { + background: #42414c; + } + + .stp_superheader { + margin: 0; + } + + .stp_styleguide_h4 { + border-bottom: 1px solid #ccc; + margin: 20px 0; + } + + .stp_styleguide_h5 { + font-size: 10px; + margin: 10px 0; + } +} diff --git a/browser/components/pocket/content/panels/fonts/FiraSans-Regular.woff b/browser/components/pocket/content/panels/fonts/FiraSans-Regular.woff new file mode 100644 index 0000000000..f466cdda9b Binary files /dev/null and b/browser/components/pocket/content/panels/fonts/FiraSans-Regular.woff differ diff --git a/browser/components/pocket/content/panels/home.html b/browser/components/pocket/content/panels/home.html new file mode 100644 index 0000000000..44f087af90 --- /dev/null +++ b/browser/components/pocket/content/panels/home.html @@ -0,0 +1,20 @@ + + + + + + + + + Pocket: Home + + + + + + + + diff --git a/browser/components/pocket/content/panels/img/chevron-right.svg b/browser/components/pocket/content/panels/img/chevron-right.svg new file mode 100644 index 0000000000..ea4f72e649 --- /dev/null +++ b/browser/components/pocket/content/panels/img/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/browser/components/pocket/content/panels/img/list-view.svg b/browser/components/pocket/content/panels/img/list-view.svg new file mode 100644 index 0000000000..234d1866ea --- /dev/null +++ b/browser/components/pocket/content/panels/img/list-view.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/browser/components/pocket/content/panels/img/open.svg b/browser/components/pocket/content/panels/img/open.svg new file mode 100644 index 0000000000..49406f04ba --- /dev/null +++ b/browser/components/pocket/content/panels/img/open.svg @@ -0,0 +1,3 @@ + + + diff --git a/browser/components/pocket/content/panels/img/pocketerror@1x.png b/browser/components/pocket/content/panels/img/pocketerror@1x.png new file mode 100644 index 0000000000..059812678a Binary files /dev/null and b/browser/components/pocket/content/panels/img/pocketerror@1x.png differ diff --git a/browser/components/pocket/content/panels/img/pocketerror@2x.png b/browser/components/pocket/content/panels/img/pocketerror@2x.png new file mode 100644 index 0000000000..f462f30a6c Binary files /dev/null and b/browser/components/pocket/content/panels/img/pocketerror@2x.png differ diff --git a/browser/components/pocket/content/panels/img/pocketlogo-dark.svg b/browser/components/pocket/content/panels/img/pocketlogo-dark.svg new file mode 100644 index 0000000000..7ec3f7dac0 --- /dev/null +++ b/browser/components/pocket/content/panels/img/pocketlogo-dark.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/browser/components/pocket/content/panels/img/pocketlogo.svg b/browser/components/pocket/content/panels/img/pocketlogo.svg new file mode 100644 index 0000000000..4b8dc67d6c --- /dev/null +++ b/browser/components/pocket/content/panels/img/pocketlogo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/browser/components/pocket/content/panels/img/pocketlogo@1x.png b/browser/components/pocket/content/panels/img/pocketlogo@1x.png new file mode 100644 index 0000000000..4ae1a84dc9 Binary files /dev/null and b/browser/components/pocket/content/panels/img/pocketlogo@1x.png differ diff --git a/browser/components/pocket/content/panels/img/pocketlogo@2x.png b/browser/components/pocket/content/panels/img/pocketlogo@2x.png new file mode 100644 index 0000000000..86d4264d06 Binary files /dev/null and b/browser/components/pocket/content/panels/img/pocketlogo@2x.png differ diff --git a/browser/components/pocket/content/panels/img/pocketlogosolo@1x.png b/browser/components/pocket/content/panels/img/pocketlogosolo@1x.png new file mode 100644 index 0000000000..0af50f1f10 Binary files /dev/null and b/browser/components/pocket/content/panels/img/pocketlogosolo@1x.png differ diff --git a/browser/components/pocket/content/panels/img/pocketlogosolo@2x.png b/browser/components/pocket/content/panels/img/pocketlogosolo@2x.png new file mode 100644 index 0000000000..e3e203172f Binary files /dev/null and b/browser/components/pocket/content/panels/img/pocketlogosolo@2x.png differ diff --git a/browser/components/pocket/content/panels/img/pocketsignup_button@1x.png b/browser/components/pocket/content/panels/img/pocketsignup_button@1x.png new file mode 100644 index 0000000000..e0cb05a51a Binary files /dev/null and b/browser/components/pocket/content/panels/img/pocketsignup_button@1x.png differ diff --git a/browser/components/pocket/content/panels/img/pocketsignup_button@2x.png b/browser/components/pocket/content/panels/img/pocketsignup_button@2x.png new file mode 100644 index 0000000000..6f26cee95d Binary files /dev/null and b/browser/components/pocket/content/panels/img/pocketsignup_button@2x.png differ diff --git a/browser/components/pocket/content/panels/img/pocketsignup_devices@1x.png b/browser/components/pocket/content/panels/img/pocketsignup_devices@1x.png new file mode 100644 index 0000000000..effa073c60 Binary files /dev/null and b/browser/components/pocket/content/panels/img/pocketsignup_devices@1x.png differ diff --git a/browser/components/pocket/content/panels/img/pocketsignup_devices@2x.png b/browser/components/pocket/content/panels/img/pocketsignup_devices@2x.png new file mode 100644 index 0000000000..8a539070ad Binary files /dev/null and b/browser/components/pocket/content/panels/img/pocketsignup_devices@2x.png differ diff --git a/browser/components/pocket/content/panels/img/pocketsignup_hero@1x.png b/browser/components/pocket/content/panels/img/pocketsignup_hero@1x.png new file mode 100644 index 0000000000..6659c0843c Binary files /dev/null and b/browser/components/pocket/content/panels/img/pocketsignup_hero@1x.png differ diff --git a/browser/components/pocket/content/panels/img/pocketsignup_hero@2x.png b/browser/components/pocket/content/panels/img/pocketsignup_hero@2x.png new file mode 100644 index 0000000000..d31d610f54 Binary files /dev/null and b/browser/components/pocket/content/panels/img/pocketsignup_hero@2x.png differ diff --git a/browser/components/pocket/content/panels/img/rainbow-reader.svg b/browser/components/pocket/content/panels/img/rainbow-reader.svg new file mode 100644 index 0000000000..ce3cf54fb4 --- /dev/null +++ b/browser/components/pocket/content/panels/img/rainbow-reader.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/browser/components/pocket/content/panels/img/signup_firefoxlogo@1x.png b/browser/components/pocket/content/panels/img/signup_firefoxlogo@1x.png new file mode 100644 index 0000000000..d8c9474477 Binary files /dev/null and b/browser/components/pocket/content/panels/img/signup_firefoxlogo@1x.png differ diff --git a/browser/components/pocket/content/panels/img/signup_firefoxlogo@2x.png b/browser/components/pocket/content/panels/img/signup_firefoxlogo@2x.png new file mode 100644 index 0000000000..c027d53d3f Binary files /dev/null and b/browser/components/pocket/content/panels/img/signup_firefoxlogo@2x.png differ diff --git a/browser/components/pocket/content/panels/img/signup_help@1x.png b/browser/components/pocket/content/panels/img/signup_help@1x.png new file mode 100644 index 0000000000..e11d56b6a8 Binary files /dev/null and b/browser/components/pocket/content/panels/img/signup_help@1x.png differ diff --git a/browser/components/pocket/content/panels/img/signup_help@2x.png b/browser/components/pocket/content/panels/img/signup_help@2x.png new file mode 100644 index 0000000000..2b37abf0e0 Binary files /dev/null and b/browser/components/pocket/content/panels/img/signup_help@2x.png differ diff --git a/browser/components/pocket/content/panels/img/tag_close@1x.png b/browser/components/pocket/content/panels/img/tag_close@1x.png new file mode 100644 index 0000000000..334ad03f70 Binary files /dev/null and b/browser/components/pocket/content/panels/img/tag_close@1x.png differ diff --git a/browser/components/pocket/content/panels/img/tag_close@2x.png b/browser/components/pocket/content/panels/img/tag_close@2x.png new file mode 100644 index 0000000000..c6834cc305 Binary files /dev/null and b/browser/components/pocket/content/panels/img/tag_close@2x.png differ diff --git a/browser/components/pocket/content/panels/img/tag_closeactive@1x.png b/browser/components/pocket/content/panels/img/tag_closeactive@1x.png new file mode 100644 index 0000000000..196004b2f8 Binary files /dev/null and b/browser/components/pocket/content/panels/img/tag_closeactive@1x.png differ diff --git a/browser/components/pocket/content/panels/img/tag_closeactive@2x.png b/browser/components/pocket/content/panels/img/tag_closeactive@2x.png new file mode 100644 index 0000000000..a1512f6ada Binary files /dev/null and b/browser/components/pocket/content/panels/img/tag_closeactive@2x.png differ diff --git a/browser/components/pocket/content/panels/js/components/ArticleList/ArticleList.jsx b/browser/components/pocket/content/panels/js/components/ArticleList/ArticleList.jsx new file mode 100644 index 0000000000..25679bc638 --- /dev/null +++ b/browser/components/pocket/content/panels/js/components/ArticleList/ArticleList.jsx @@ -0,0 +1,139 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useState } from "react"; +import TelemetryLink from "../TelemetryLink/TelemetryLink"; + +function ArticleUrl(props) { + // We turn off the link if we're either a saved article, or if the url doesn't exist. + if (props.savedArticle || !props.url) { + return ( +

    {props.children}
    + ); + } + return ( + + {props.children} + + ); +} + +function Article(props) { + function encodeThumbnail(rawSource) { + return rawSource + ? `https://img-getpocket.cdn.mozilla.net/80x80/filters:format(jpeg):quality(60):no_upscale():strip_exif()/${encodeURIComponent( + rawSource + )}` + : null; + } + + const [thumbnailLoaded, setThumbnailLoaded] = useState(false); + const [thumbnailLoadFailed, setThumbnailLoadFailed] = useState(false); + + const { + article, + savedArticle, + position, + source, + model, + utmParams, + openInPocketReader, + } = props; + + if (!article.url && !article.resolved_url && !article.given_url) { + return null; + } + const url = new URL(article.url || article.resolved_url || article.given_url); + const urlSearchParams = new URLSearchParams(utmParams); + + if ( + openInPocketReader && + article.item_id && + !url.href.match(/getpocket\.com\/read/) + ) { + url.href = `https://getpocket.com/read/${article.item_id}`; + } + + for (let [key, val] of urlSearchParams.entries()) { + url.searchParams.set(key, val); + } + + // Using array notation because there is a key titled `1` (`images` is an object) + const thumbnail = + article.thumbnail || + encodeThumbnail(article?.top_image_url || article?.images?.["1"]?.src); + const alt = article.alt || "thumbnail image"; + const title = article.title || article.resolved_title || article.given_title; + // Sometimes domain_metadata is not there, depending on the source. + const publisher = + article.publisher || + article.domain_metadata?.name || + article.resolved_domain; + + return ( +
  • + + <> + {thumbnail && !thumbnailLoadFailed ? ( + {alt} { + setThumbnailLoaded(true); + }} + onError={() => { + setThumbnailLoadFailed(true); + }} + style={{ + visibility: thumbnailLoaded ? `visible` : `hidden`, + }} + /> + ) : ( +
    + )} +
    +
    {title}
    +

    {publisher}

    +
    + + +
  • + ); +} + +function ArticleList(props) { + return ( +
      + {props.articles?.map((article, position) => ( +
      + ))} +
    + ); +} + +export default ArticleList; diff --git a/browser/components/pocket/content/panels/js/components/ArticleList/ArticleList.scss b/browser/components/pocket/content/panels/js/components/ArticleList/ArticleList.scss new file mode 100644 index 0000000000..261367433d --- /dev/null +++ b/browser/components/pocket/content/panels/js/components/ArticleList/ArticleList.scss @@ -0,0 +1,65 @@ +.stp_article_list { + padding: 0; + list-style: none; + + .stp_article_list_saved_article, + .stp_article_list_link { + display: flex; + border-radius: 4px; + padding: 8px; + margin: 0 -8px; + } + + .stp_article_list_link { + &:hover, &:focus { + text-decoration: none; + background-color: #ECECEE; + + @include theme_dark { + background-color: #2B2A33; + } + } + } + + .stp_article_list_thumb, + .stp_article_list_thumb_placeholder { + width: 40px; + height: 40px; + border-radius: 4px; + margin-inline-end: 8px; + background-color: #ECECEE; + flex-shrink: 0; + } + + .stp_article_list_header { + font-style: normal; + font-weight: 600; + font-size: 0.95rem; + line-height: 1.18rem; + color: #15141A; + margin: 0 0 4px; + + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + display: -webkit-box; + overflow: hidden; + word-break: break-word; + + @include theme_dark { + color: #FBFBFE; + } + } + + .stp_article_list_publisher { + font-style: normal; + font-weight: normal; + font-size: 0.95rem; + line-height: 1.18rem; + color: #52525E; + margin: 4px 0 0; + + @include theme_dark { + color: #CFCFD8; + } + } +} diff --git a/browser/components/pocket/content/panels/js/components/Button/Button.jsx b/browser/components/pocket/content/panels/js/components/Button/Button.jsx new file mode 100644 index 0000000000..7f3d2ea7ce --- /dev/null +++ b/browser/components/pocket/content/panels/js/components/Button/Button.jsx @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import TelemetryLink from "../TelemetryLink/TelemetryLink"; + +function Button(props) { + return ( + + {props.children} + + ); +} + +export default Button; diff --git a/browser/components/pocket/content/panels/js/components/Button/Button.scss b/browser/components/pocket/content/panels/js/components/Button/Button.scss new file mode 100644 index 0000000000..c6001e19da --- /dev/null +++ b/browser/components/pocket/content/panels/js/components/Button/Button.scss @@ -0,0 +1,142 @@ +.stp_button { + cursor: pointer; + display: inline-block; + margin: 12px 0; + + &:hover { + text-decoration: none; + } + + &.stp_button_text { + color: #0060DF; + font-size: 0.95rem; + line-height: 1.2rem; + font-style: normal; + font-weight: 600; + + &:focus { + text-decoration: underline; + } + + &:hover { + color: #0250BB; + text-decoration: none; + } + + &:active { + color: #054096; + } + + @include theme_dark { + color: #00DDFF; + } + } + + &.stp_button_primary { + align-items: center; + background: #0060DF; + border-radius: 4px; + color: #FBFBFE; + font-size: 0.85rem; + line-height: 1rem; + font-style: normal; + font-weight: 600; + justify-content: center; + padding: 6px 12px; + + &:focus { + text-decoration: none; + background: #0060DF; + outline: 2px solid #0060df; + outline-offset: 2px; + } + + &:hover { + background: #0250BB; + } + + &:active { + background: #054096; + } + + @include theme_dark { + background: #00DDFF; + color: #15141A; + + &:hover { + background: #80ebfe; + } + + &:focus { + outline: 2px solid #00DDFF; + } + } + } + + &.stp_button_secondary { + align-items: center; + background: #F0F0F4; + border-radius: 4px; + color: #15141A; + font-size: 0.85rem; + line-height: 1rem; + font-style: normal; + font-weight: 600; + padding: 6px 12px; + + &:focus { + text-decoration: none; + background: #F0F0F4; + outline: 2px solid #0060df; + outline-offset: 2px; + } + + &:hover { + background: #E0E0E6; + } + + &:active { + background: #CFCFD8; + } + + @include theme_dark { + background: #2B2A33; + color: #FBFBFE; + + &:focus { + outline: 2px solid #00DDFF; + } + + &:hover { + background: #53535d; + } + } + } +} + +.stp_button_wide { + .stp_button { + display: block; + margin: 12px 0; + text-align: center; + padding: 8px 12px; + + &.stp_button_primary { + font-size: 1.1rem; + line-height: 1.35rem; + } + + &.stp_button_secondary { + font-size: 0.85rem; + line-height: 1rem; + } + } +} + +.stp_button_wide { + .stp_button { + display: block; + margin: 12px 0; + text-align: center; + } +} diff --git a/browser/components/pocket/content/panels/js/components/Header/Header.jsx b/browser/components/pocket/content/panels/js/components/Header/Header.jsx new file mode 100644 index 0000000000..be60fe764c --- /dev/null +++ b/browser/components/pocket/content/panels/js/components/Header/Header.jsx @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; + +function Header(props) { + return ( +

    +
    + {props.children} +

    + ); +} + +export default Header; diff --git a/browser/components/pocket/content/panels/js/components/Header/Header.scss b/browser/components/pocket/content/panels/js/components/Header/Header.scss new file mode 100644 index 0000000000..f6e4eca9d5 --- /dev/null +++ b/browser/components/pocket/content/panels/js/components/Header/Header.scss @@ -0,0 +1,22 @@ +.stp_header { + display: flex; + justify-content: space-between; + align-items: center; + margin: 16px 0 12px; + font-weight: 600; + + .stp_header_logo { + background: url(../img/pocketlogo.svg) bottom center no-repeat; + background-size: contain; + height: 32px; + width: 121px; + + @include theme_dark { + background-image: url(../img/pocketlogo-dark.svg); + } + } + + .stp_button { + margin: 0; + } +} diff --git a/browser/components/pocket/content/panels/js/components/Home/Home.jsx b/browser/components/pocket/content/panels/js/components/Home/Home.jsx new file mode 100644 index 0000000000..1036876725 --- /dev/null +++ b/browser/components/pocket/content/panels/js/components/Home/Home.jsx @@ -0,0 +1,175 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useState, useEffect, useCallback } from "react"; +import Header from "../Header/Header"; +import ArticleList from "../ArticleList/ArticleList"; +import PopularTopics from "../PopularTopics/PopularTopics"; +import Button from "../Button/Button"; +import panelMessaging from "../../messages"; + +function Home(props) { + const { + locale, + topics, + pockethost, + hideRecentSaves, + utmSource, + utmCampaign, + utmContent, + } = props; + + const [{ articles, status }, setArticlesState] = useState({ + articles: [], + // Can be success, loading, or error. + status: "", + }); + + const utmParams = `utm_source=${utmSource}${ + utmCampaign && utmContent + ? `&utm_campaign=${utmCampaign}&utm_content=${utmContent}` + : `` + }`; + + const loadingRecentSaves = useCallback(resp => { + setArticlesState(prevState => ({ + ...prevState, + status: "loading", + })); + }, []); + + const renderRecentSaves = useCallback(resp => { + const { data } = resp; + + if (data.status === "error") { + setArticlesState(prevState => ({ + ...prevState, + status: "error", + })); + return; + } + + setArticlesState({ + articles: data, + status: "success", + }); + }, []); + + useEffect(() => { + if (!hideRecentSaves) { + // We don't display the loading message until instructed. This is because cache + // loads should be fast, so using the loading message for cache just adds loading jank. + panelMessaging.addMessageListener( + "PKT_loadingRecentSaves", + loadingRecentSaves + ); + + panelMessaging.addMessageListener( + "PKT_renderRecentSaves", + renderRecentSaves + ); + } + }, [hideRecentSaves, loadingRecentSaves, renderRecentSaves]); + + useEffect(() => { + // tell back end we're ready + panelMessaging.sendMessage("PKT_show_home"); + }, []); + + let recentSavesSection = null; + + if (status === "error" || hideRecentSaves) { + recentSavesSection = ( +

    + ); + } else if (status === "loading") { + recentSavesSection = ( + + ); + } else if (status === "success") { + if (articles?.length) { + recentSavesSection = ( + <> +

    + {articles.length > 3 ? ( + <> + + + + + + ) : ( + + )} + + ); + } else { + recentSavesSection = ( + <> +

    +

    + + ); + } + } + + return ( +
    +
    +
    + +
    +
    + {recentSavesSection} +
    + {pockethost && locale?.startsWith("en") && topics?.length && ( + <> +

    Explore popular topics:

    + + + )} +
    +
    + ); +} + +export default Home; diff --git a/browser/components/pocket/content/panels/js/components/PopularTopics/PopularTopics.jsx b/browser/components/pocket/content/panels/js/components/PopularTopics/PopularTopics.jsx new file mode 100644 index 0000000000..517bd6d53b --- /dev/null +++ b/browser/components/pocket/content/panels/js/components/PopularTopics/PopularTopics.jsx @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import TelemetryLink from "../TelemetryLink/TelemetryLink"; + +function PopularTopics(props) { + return ( +
      + {props.topics?.map((topic, position) => ( +
    • + + {topic.title} + +
    • + ))} +
    + ); +} + +export default PopularTopics; diff --git a/browser/components/pocket/content/panels/js/components/PopularTopics/PopularTopics.scss b/browser/components/pocket/content/panels/js/components/PopularTopics/PopularTopics.scss new file mode 100644 index 0000000000..530e07d576 --- /dev/null +++ b/browser/components/pocket/content/panels/js/components/PopularTopics/PopularTopics.scss @@ -0,0 +1,56 @@ +/* stylelint-disable max-nesting-depth */ + +.stp_popular_topics { + padding: 0; + + .stp_popular_topic { + display: inline-block; + + .stp_popular_topic_link { + display: inline-block; + background: #F0F0F4; + border-radius: 4px; + font-size: 0.85rem; + line-height: 1rem; + font-style: normal; + font-weight: 600; + margin-inline-end: 8px; + margin-bottom: 8px; + padding: 4px 8px; + color: #000; + + &:focus { + text-decoration: none; + background: #F0F0F4; + outline: 2px solid #0060df; + outline-offset: 2px; + } + + &:hover { + background: #E0E0E6; + text-decoration: none; + } + + &:active { + background: #CFCFD8; + } + + &::after { + content: " >"; + } + + @include theme_dark { + background: #2B2A33; + color: #FBFBFE; + + &:focus { + outline: 2px solid #00DDFF; + } + + &:hover { + background: #53535d; + } + } + } + } +} diff --git a/browser/components/pocket/content/panels/js/components/Saved/Saved.jsx b/browser/components/pocket/content/panels/js/components/Saved/Saved.jsx new file mode 100644 index 0000000000..502c73b0a5 --- /dev/null +++ b/browser/components/pocket/content/panels/js/components/Saved/Saved.jsx @@ -0,0 +1,175 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useState, useEffect } from "react"; +import Header from "../Header/Header"; +import Button from "../Button/Button"; +import ArticleList from "../ArticleList/ArticleList"; +import TagPicker from "../TagPicker/TagPicker"; +import panelMessaging from "../../messages"; + +function Saved(props) { + const { locale, pockethost, utmSource, utmCampaign, utmContent } = props; + // savedStatus can be success, loading, or error. + const [{ savedStatus, savedErrorId, itemId, itemUrl }, setSavedStatusState] = + useState({ savedStatus: "loading" }); + // removedStatus can be removed, removing, or error. + const [{ removedStatus, removedErrorMessage }, setRemovedStatusState] = + useState({}); + const [savedStory, setSavedStoryState] = useState(); + const [articleInfoAttempted, setArticleInfoAttempted] = useState(); + const utmParams = `utm_source=${utmSource}${ + utmCampaign && utmContent + ? `&utm_campaign=${utmCampaign}&utm_content=${utmContent}` + : `` + }`; + + function removeItem(event) { + event.preventDefault(); + setRemovedStatusState({ removedStatus: "removing" }); + panelMessaging.sendMessage( + "PKT_deleteItem", + { + itemId, + }, + function (resp) { + const { data } = resp; + if (data.status == "success") { + setRemovedStatusState({ removedStatus: "removed" }); + } else if (data.status == "error") { + let errorMessage = ""; + // The server returns English error messages, so in the case of + // non English, we do our best with a generic translated error. + if (data.error.message && locale?.startsWith("en")) { + errorMessage = data.error.message; + } + setRemovedStatusState({ + removedStatus: "error", + removedErrorMessage: errorMessage, + }); + } + } + ); + } + + useEffect(() => { + // Wait confirmation of save before flipping to final saved state + panelMessaging.addMessageListener("PKT_saveLink", function (resp) { + const { data } = resp; + if (data.status == "error") { + // Use localizedKey or fallback to a generic catch all error. + setSavedStatusState({ + savedStatus: "error", + savedErrorId: + data?.error?.localizedKey || "pocket-panel-saved-error-generic", + }); + return; + } + + // Success, so no localized error id needed. + setSavedStatusState({ + savedStatus: "success", + itemId: data.item?.item_id, + itemUrl: data.item?.given_url, + savedErrorId: "", + }); + }); + + panelMessaging.addMessageListener( + "PKT_articleInfoFetched", + function (resp) { + setSavedStoryState(resp?.data?.item_preview); + } + ); + + panelMessaging.addMessageListener( + "PKT_getArticleInfoAttempted", + function (resp) { + setArticleInfoAttempted(true); + } + ); + + // tell back end we're ready + panelMessaging.sendMessage("PKT_show_saved"); + }, []); + + if (savedStatus === "error") { + return ( +
    +
    +
    +

    +

    +

    +
    + ); + } + + return ( +
    +
    +
    + +
    +
    + {!removedStatus && savedStatus === "success" && ( + <> +

    + + +

    + {savedStory && ( + + )} + {articleInfoAttempted && } + + )} + {savedStatus === "loading" && ( +

    + )} + {removedStatus === "removing" && ( +

    + )} + {removedStatus === "removed" && ( +

    + )} + {removedStatus === "error" && ( + <> +

    + {removedErrorMessage &&

    {removedErrorMessage}

    } + + )} +

    +
    + ); +} + +export default Saved; diff --git a/browser/components/pocket/content/panels/js/components/Saved/Saved.scss b/browser/components/pocket/content/panels/js/components/Saved/Saved.scss new file mode 100644 index 0000000000..08003f14e1 --- /dev/null +++ b/browser/components/pocket/content/panels/js/components/Saved/Saved.scss @@ -0,0 +1,17 @@ +body { + &.stp_saved_body { + overflow: hidden; + } +} + +.stp_panel_error { + margin: 23px 0 32px; + .stp_panel_error_icon { + float: inline-start; + margin-block: 6px 16px; + margin-inline: 7px 17px; + background-image: url(../img/pocketerror@1x.png); + height: 44px; + width: 44px; + } +} diff --git a/browser/components/pocket/content/panels/js/components/Signup/Signup.jsx b/browser/components/pocket/content/panels/js/components/Signup/Signup.jsx new file mode 100644 index 0000000000..7e628c0ad5 --- /dev/null +++ b/browser/components/pocket/content/panels/js/components/Signup/Signup.jsx @@ -0,0 +1,79 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import Header from "../Header/Header"; +import Button from "../Button/Button"; + +function Signup(props) { + const { locale, pockethost, utmSource, utmCampaign, utmContent } = props; + const utmParams = `utm_source=${utmSource}${ + utmCampaign && utmContent + ? `&utm_campaign=${utmCampaign}&utm_content=${utmContent}` + : `` + }`; + return ( +
    +
    +
    + +
    +
    + {locale?.startsWith("en") ? ( + <> +
    +

    +

    +

    +
    +
    +
    +
    +
    +

    + Get thought-provoking article recommendations +

    +

    + Find stories that go deep into a subject or offer a new + perspective. +

    +
    + + ) : ( +
    +

    +

    + +

    + +

    + )} +
    + + + +
    +
    + ); +} + +export default Signup; diff --git a/browser/components/pocket/content/panels/js/components/Signup/Signup.scss b/browser/components/pocket/content/panels/js/components/Signup/Signup.scss new file mode 100644 index 0000000000..21b34ddcb6 --- /dev/null +++ b/browser/components/pocket/content/panels/js/components/Signup/Signup.scss @@ -0,0 +1,19 @@ +body { + &.stp_signup_body { + overflow: hidden; + } +} + +.stp_panel_signup { + .stp_signup_content_wrapper { + margin: 12px 0 20px; + } + .stp_signup_img_rainbow_reader { + background: url(../img/rainbow-reader.svg) bottom center no-repeat; + background-size: contain; + height: 72px; + width: 82px; + float: inline-end; + margin-inline-start: 16px; + } +} diff --git a/browser/components/pocket/content/panels/js/components/TagPicker/TagPicker.jsx b/browser/components/pocket/content/panels/js/components/TagPicker/TagPicker.jsx new file mode 100644 index 0000000000..9c1f658a80 --- /dev/null +++ b/browser/components/pocket/content/panels/js/components/TagPicker/TagPicker.jsx @@ -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/. */ + +import React, { useState, useEffect } from "react"; +import panelMessaging from "../../messages"; + +function TagPicker(props) { + const [tags, setTags] = useState(props.tags); // New tag group to store + const [allTags, setAllTags] = useState([]); // All tags ever used (in no particular order) + const [recentTags, setRecentTags] = useState([]); // Most recently used tags + const [duplicateTag, setDuplicateTag] = useState(null); + const [inputValue, setInputValue] = useState(""); + + // Status can be success, waiting, or error. + const [{ tagInputStatus, tagInputErrorMessage }, setTagInputStatus] = + useState({ + tagInputStatus: "", + tagInputErrorMessage: "", + }); + + let handleKeyDown = e => { + const enterKey = e.keyCode === 13; + const commaKey = e.keyCode === 188; + const tabKey = inputValue && e.keyCode === 9; + + // Submit tags on enter with no input. + // Enter tag on comma, tab, or enter with input. + // Tab to next element with no input. + if (commaKey || enterKey || tabKey) { + e.preventDefault(); + if (inputValue) { + addTag(inputValue.trim()); + setInputValue(``); // Clear out input + } else if (enterKey) { + submitTags(); + } + } + }; + + let addTag = tagToAdd => { + if (!tagToAdd?.length) { + return; + } + + let newDuplicateTag = tags.find(item => item === tagToAdd); + + if (!newDuplicateTag) { + setTags([...tags, tagToAdd]); + } else { + setDuplicateTag(newDuplicateTag); + + setTimeout(() => { + setDuplicateTag(null); + }, 1000); + } + }; + + let removeTag = index => { + let updatedTags = tags.slice(0); // Shallow copied array + updatedTags.splice(index, 1); + setTags(updatedTags); + }; + + let submitTags = () => { + let tagsToSubmit = []; + + if (tags?.length) { + tagsToSubmit = tags; + } + + // Capture tags that have been typed in but not explicitly added to the tag collection + if (inputValue?.trim().length) { + tagsToSubmit.push(inputValue.trim()); + } + + if (!props.itemUrl || !tagsToSubmit?.length) { + return; + } + + setTagInputStatus({ + tagInputStatus: "waiting", + tagInputErrorMessage: "", + }); + panelMessaging.sendMessage( + "PKT_addTags", + { + url: props.itemUrl, + tags: tagsToSubmit, + }, + function (resp) { + const { data } = resp; + + if (data.status === "success") { + setTagInputStatus({ + tagInputStatus: "success", + tagInputErrorMessage: "", + }); + } else if (data.status === "error") { + setTagInputStatus({ + tagInputStatus: "error", + tagInputErrorMessage: data.error.message, + }); + } + } + ); + }; + + useEffect(() => { + panelMessaging.sendMessage("PKT_getTags", {}, resp => { + setAllTags(resp?.data?.tags); + }); + }, []); + + useEffect(() => { + panelMessaging.sendMessage("PKT_getRecentTags", {}, resp => { + setRecentTags(resp?.data?.recentTags); + }); + }, []); + + return ( +
    + {!tagInputStatus && ( + <> +

    +
    + {tags.map((tag, i) => ( +
    + + {tag} +
    + ))} +
    + setInputValue(e.target.value)} + onKeyDown={e => handleKeyDown(e)} + maxlength="25" + /> + + {allTags + .sort((a, b) => a.search(inputValue) - b.search(inputValue)) + .map(item => ( + +
    +
    +
    + {recentTags + .slice(0, 3) + .filter(recentTag => { + return !tags.find(item => item === recentTag); + }) + .map(tag => ( +
    + +
    + ))} +
    + + )} + {tagInputStatus === "waiting" && ( +

    + )} + {tagInputStatus === "success" && ( +

    + )} + {tagInputStatus === "error" && ( +

    {tagInputErrorMessage}

    + )} +
    + ); +} + +export default TagPicker; diff --git a/browser/components/pocket/content/panels/js/components/TagPicker/TagPicker.scss b/browser/components/pocket/content/panels/js/components/TagPicker/TagPicker.scss new file mode 100644 index 0000000000..215307d079 --- /dev/null +++ b/browser/components/pocket/content/panels/js/components/TagPicker/TagPicker.scss @@ -0,0 +1,141 @@ +.stp_tag_picker { + .stp_tag_picker_tags { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 8px; + border: 1px solid #8F8F9D; + border-radius: 4px; + font-style: normal; + font-weight: normal; + font-size: 1rem; + line-height: 1.2rem; + color: #15141A; + margin-bottom: 10px; + } + + .stp_tag_picker_tag { + background: #F0F0F4; + border-radius: 4px; + color: #15141A; + display: inline-block; + font-size: 0.85rem; + line-height: 1rem; + font-style: normal; + font-weight: 600; + padding: 0 8px; + transition: background-color 200ms ease-in-out; + + @include theme_dark { + background: #2B2A33; + color: #FBFBFB; + } + } + + .recent_tags .stp_tag_picker_tag { + margin-inline-end: 5px; + } + + .stp_tag_picker_tag_remove { + padding-top: 5px; + padding-bottom: 5px; + padding-inline-end: 5px; + color: #5B5B66; + font-weight: 400; + + &:hover { + color: #3E3E44; + } + + &:focus { + color: #3E3E44; + outline: 2px solid #0060df; + outline-offset: -4px; + } + + @include theme_dark { + color: #8F8F9D; + + &:hover { + color: #fff; + } + + &:focus { + outline: 2px solid #00DDFF; + } + } + } + + .stp_tag_picker_tag_duplicate { + background-color: #bbb; + + @include theme_dark { + background-color: #666; + } + } + + .stp_tag_picker_input_wrapper { + display: flex; + flex-grow: 1; + } + + .stp_tag_picker_input { + flex-grow: 1; + border: 1px solid #8F8F9D; + padding: 0 6px; + border-start-start-radius: 4px; + border-end-start-radius: 4px; + + &:focus { + border: 1px solid #0060DF; + outline: 1px solid #0060DF; + } + + @include theme_dark { + background: none; + color: #FBFBFB; + + &:focus { + border: 1px solid #00DDFF; + outline: 1px solid #00DDFF; + } + } + } + + .stp_tag_picker_button { + font-size: 0.95rem; + line-height: 1.1rem; + padding: 4px 6px; + background-color: #F0F0F4; + border: 1px solid #8F8F9D; + border-inline-start: none; + border-start-end-radius: 4px; + border-end-end-radius: 4px; + &:disabled { + color: #8F8F9D; + } + &:hover:enabled { + background-color: #DADADF; + } + &:focus:enabled { + border: 1px solid #0060DF; + outline: 1px solid #0060DF; + } + + @include theme_dark { + background-color: #2B2A33; + color: #FBFBFB; + &:disabled { + color: #666; + } + &:hover:enabled { + background-color: #53535d; + } + &:focus:enabled { + border: 1px solid #00DDFF; + outline: 1px solid #00DDFF; + } + } + } +} diff --git a/browser/components/pocket/content/panels/js/components/TelemetryLink/TelemetryLink.jsx b/browser/components/pocket/content/panels/js/components/TelemetryLink/TelemetryLink.jsx new file mode 100644 index 0000000000..c23a24897f --- /dev/null +++ b/browser/components/pocket/content/panels/js/components/TelemetryLink/TelemetryLink.jsx @@ -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/. */ + +import React from "react"; +import panelMessaging from "../../messages"; + +function TelemetryLink(props) { + function onClick(event) { + if (props.onClick) { + props.onClick(event); + } else { + event.preventDefault(); + panelMessaging.sendMessage("PKT_openTabWithUrl", { + url: event.currentTarget.getAttribute(`href`), + source: props.source, + model: props.model, + position: props.position, + }); + } + } + + return ( + + {props.children} + + ); +} + +export default TelemetryLink; diff --git a/browser/components/pocket/content/panels/js/home/entry.js b/browser/components/pocket/content/panels/js/home/entry.js new file mode 100644 index 0000000000..56d8134577 --- /dev/null +++ b/browser/components/pocket/content/panels/js/home/entry.js @@ -0,0 +1,17 @@ +/* global PKT_PANEL:false */ + +function onDOMLoaded() { + if (!window.thePKT_PANEL) { + var thePKT_PANEL = new PKT_PANEL(); + /* global thePKT_PANEL */ + window.thePKT_PANEL = thePKT_PANEL; + thePKT_PANEL.initHome(); + } + window.thePKT_PANEL.create(); +} + +if (document.readyState != `loading`) { + onDOMLoaded(); +} else { + document.addEventListener(`DOMContentLoaded`, onDOMLoaded); +} diff --git a/browser/components/pocket/content/panels/js/home/overlay.jsx b/browser/components/pocket/content/panels/js/home/overlay.jsx new file mode 100644 index 0000000000..4d49a09470 --- /dev/null +++ b/browser/components/pocket/content/panels/js/home/overlay.jsx @@ -0,0 +1,48 @@ +/* +HomeOverlay is the view itself and contains all of the methods to manipute the overlay and messaging. +It does not contain any logic for saving or communication with the extension or server. +*/ + +import React from "react"; +import ReactDOM from "react-dom"; +import Home from "../components/Home/Home.jsx"; + +var HomeOverlay = function (options) { + this.inited = false; + this.active = false; +}; + +HomeOverlay.prototype = { + create({ pockethost }) { + const { searchParams } = new URL(window.location.href); + const locale = searchParams.get(`locale`) || ``; + const hideRecentSaves = searchParams.get(`hiderecentsaves`) === `true`; + const utmSource = searchParams.get(`utmSource`); + const utmCampaign = searchParams.get(`utmCampaign`); + const utmContent = searchParams.get(`utmContent`); + + if (this.active) { + return; + } + + this.active = true; + + ReactDOM.render( + , + document.querySelector(`body`) + ); + + if (window?.matchMedia(`(prefers-color-scheme: dark)`).matches) { + document.querySelector(`body`).classList.add(`theme_dark`); + } + }, +}; + +export default HomeOverlay; diff --git a/browser/components/pocket/content/panels/js/main.bundle.js b/browser/components/pocket/content/panels/js/main.bundle.js new file mode 100644 index 0000000000..36e2f82973 --- /dev/null +++ b/browser/components/pocket/content/panels/js/main.bundle.js @@ -0,0 +1,1238 @@ +/******/ (() => { // webpackBootstrap +/******/ "use strict"; +/******/ var __webpack_modules__ = ({ + +/***/ 720: +/***/ ((__unused_webpack___webpack_module__, __unused_webpack___webpack_exports__, __webpack_require__) => { + + +// EXTERNAL MODULE: ./node_modules/react/index.js +var react = __webpack_require__(504); +// EXTERNAL MODULE: ./node_modules/react-dom/index.js +var react_dom = __webpack_require__(104); +;// CONCATENATED MODULE: ./content/panels/js/components/Header/Header.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/. */ + + +function Header(props) { + return /*#__PURE__*/react.createElement("h1", { + className: "stp_header" + }, /*#__PURE__*/react.createElement("div", { + className: "stp_header_logo" + }), props.children); +} +/* harmony default export */ const Header_Header = (Header); +;// CONCATENATED MODULE: ./content/panels/js/messages.mjs +/* global RPMRemoveMessageListener:false, RPMAddMessageListener:false, RPMSendAsyncMessage:false */ + +var pktPanelMessaging = { + removeMessageListener(messageId, callback) { + RPMRemoveMessageListener(messageId, callback); + }, + + addMessageListener(messageId, callback = () => {}) { + RPMAddMessageListener(messageId, callback); + }, + + sendMessage(messageId, payload = {}, callback) { + if (callback) { + // If we expect something back, we use RPMSendAsyncMessage and not RPMSendQuery. + // Even though RPMSendQuery returns something, our frame could be closed at any moment, + // and we don't want to close a RPMSendQuery promise loop unexpectedly. + // So instead we setup a response event. + const responseMessageId = `${messageId}_response`; + var responseListener = responsePayload => { + callback(responsePayload); + this.removeMessageListener(responseMessageId, responseListener); + }; + + this.addMessageListener(responseMessageId, responseListener); + } + + // Send message + RPMSendAsyncMessage(messageId, payload); + }, + + // Click helper to reduce bugs caused by oversight + // from different implementations of similar code. + clickHelper(element, { source = "", position }) { + element?.addEventListener(`click`, event => { + event.preventDefault(); + + this.sendMessage("PKT_openTabWithUrl", { + url: event.currentTarget.getAttribute(`href`), + source, + position, + }); + }); + }, + + log() { + RPMSendAsyncMessage("PKT_log", arguments); + }, +}; + +/* harmony default export */ const messages = (pktPanelMessaging); + +;// CONCATENATED MODULE: ./content/panels/js/components/TelemetryLink/TelemetryLink.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/. */ + + + +function TelemetryLink(props) { + function onClick(event) { + if (props.onClick) { + props.onClick(event); + } else { + event.preventDefault(); + messages.sendMessage("PKT_openTabWithUrl", { + url: event.currentTarget.getAttribute(`href`), + source: props.source, + model: props.model, + position: props.position + }); + } + } + return /*#__PURE__*/react.createElement("a", { + href: props.href, + onClick: onClick, + target: "_blank", + className: props.className + }, props.children); +} +/* harmony default export */ const TelemetryLink_TelemetryLink = (TelemetryLink); +;// CONCATENATED MODULE: ./content/panels/js/components/ArticleList/ArticleList.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/. */ + + + +function ArticleUrl(props) { + // We turn off the link if we're either a saved article, or if the url doesn't exist. + if (props.savedArticle || !props.url) { + return /*#__PURE__*/react.createElement("div", { + className: "stp_article_list_saved_article" + }, props.children); + } + return /*#__PURE__*/react.createElement(TelemetryLink_TelemetryLink, { + className: "stp_article_list_link", + href: props.url, + source: props.source, + position: props.position, + model: props.model + }, props.children); +} +function Article(props) { + function encodeThumbnail(rawSource) { + return rawSource ? `https://img-getpocket.cdn.mozilla.net/80x80/filters:format(jpeg):quality(60):no_upscale():strip_exif()/${encodeURIComponent(rawSource)}` : null; + } + const [thumbnailLoaded, setThumbnailLoaded] = (0,react.useState)(false); + const [thumbnailLoadFailed, setThumbnailLoadFailed] = (0,react.useState)(false); + const { + article, + savedArticle, + position, + source, + model, + utmParams, + openInPocketReader + } = props; + if (!article.url && !article.resolved_url && !article.given_url) { + return null; + } + const url = new URL(article.url || article.resolved_url || article.given_url); + const urlSearchParams = new URLSearchParams(utmParams); + if (openInPocketReader && article.item_id && !url.href.match(/getpocket\.com\/read/)) { + url.href = `https://getpocket.com/read/${article.item_id}`; + } + for (let [key, val] of urlSearchParams.entries()) { + url.searchParams.set(key, val); + } + + // Using array notation because there is a key titled `1` (`images` is an object) + const thumbnail = article.thumbnail || encodeThumbnail(article?.top_image_url || article?.images?.["1"]?.src); + const alt = article.alt || "thumbnail image"; + const title = article.title || article.resolved_title || article.given_title; + // Sometimes domain_metadata is not there, depending on the source. + const publisher = article.publisher || article.domain_metadata?.name || article.resolved_domain; + return /*#__PURE__*/react.createElement("li", { + className: "stp_article_list_item" + }, /*#__PURE__*/react.createElement(ArticleUrl, { + url: url.href, + savedArticle: savedArticle, + position: position, + source: source, + model: model, + utmParams: utmParams + }, /*#__PURE__*/react.createElement(react.Fragment, null, thumbnail && !thumbnailLoadFailed ? /*#__PURE__*/react.createElement("img", { + className: "stp_article_list_thumb", + src: thumbnail, + alt: alt, + width: "40", + height: "40", + onLoad: () => { + setThumbnailLoaded(true); + }, + onError: () => { + setThumbnailLoadFailed(true); + }, + style: { + visibility: thumbnailLoaded ? `visible` : `hidden` + } + }) : /*#__PURE__*/react.createElement("div", { + className: "stp_article_list_thumb_placeholder" + }), /*#__PURE__*/react.createElement("div", { + className: "stp_article_list_meta" + }, /*#__PURE__*/react.createElement("header", { + className: "stp_article_list_header" + }, title), /*#__PURE__*/react.createElement("p", { + className: "stp_article_list_publisher" + }, publisher))))); +} +function ArticleList(props) { + return /*#__PURE__*/react.createElement("ul", { + className: "stp_article_list" + }, props.articles?.map((article, position) => /*#__PURE__*/react.createElement(Article, { + article: article, + savedArticle: props.savedArticle, + position: position, + source: props.source, + model: props.model, + utmParams: props.utmParams, + openInPocketReader: props.openInPocketReader + }))); +} +/* harmony default export */ const ArticleList_ArticleList = (ArticleList); +;// CONCATENATED MODULE: ./content/panels/js/components/PopularTopics/PopularTopics.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/. */ + + + +function PopularTopics(props) { + return /*#__PURE__*/react.createElement("ul", { + className: "stp_popular_topics" + }, props.topics?.map((topic, position) => /*#__PURE__*/react.createElement("li", { + key: `item-${topic.topic}`, + className: "stp_popular_topic" + }, /*#__PURE__*/react.createElement(TelemetryLink_TelemetryLink, { + className: "stp_popular_topic_link", + href: `https://${props.pockethost}/explore/${topic.topic}?${props.utmParams}`, + source: props.source, + position: position + }, topic.title)))); +} +/* harmony default export */ const PopularTopics_PopularTopics = (PopularTopics); +;// CONCATENATED MODULE: ./content/panels/js/components/Button/Button.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/. */ + + + +function Button(props) { + return /*#__PURE__*/react.createElement(TelemetryLink_TelemetryLink, { + href: props.url, + onClick: props.onClick, + className: `stp_button${props?.style && ` stp_button_${props.style}`}`, + source: props.source + }, props.children); +} +/* harmony default export */ const Button_Button = (Button); +;// CONCATENATED MODULE: ./content/panels/js/components/Home/Home.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/. */ + + + + + + + +function Home(props) { + const { + locale, + topics, + pockethost, + hideRecentSaves, + utmSource, + utmCampaign, + utmContent + } = props; + const [{ + articles, + status + }, setArticlesState] = (0,react.useState)({ + articles: [], + // Can be success, loading, or error. + status: "" + }); + const utmParams = `utm_source=${utmSource}${utmCampaign && utmContent ? `&utm_campaign=${utmCampaign}&utm_content=${utmContent}` : ``}`; + const loadingRecentSaves = (0,react.useCallback)(resp => { + setArticlesState(prevState => ({ + ...prevState, + status: "loading" + })); + }, []); + const renderRecentSaves = (0,react.useCallback)(resp => { + const { + data + } = resp; + if (data.status === "error") { + setArticlesState(prevState => ({ + ...prevState, + status: "error" + })); + return; + } + setArticlesState({ + articles: data, + status: "success" + }); + }, []); + (0,react.useEffect)(() => { + if (!hideRecentSaves) { + // We don't display the loading message until instructed. This is because cache + // loads should be fast, so using the loading message for cache just adds loading jank. + messages.addMessageListener("PKT_loadingRecentSaves", loadingRecentSaves); + messages.addMessageListener("PKT_renderRecentSaves", renderRecentSaves); + } + }, [hideRecentSaves, loadingRecentSaves, renderRecentSaves]); + (0,react.useEffect)(() => { + // tell back end we're ready + messages.sendMessage("PKT_show_home"); + }, []); + let recentSavesSection = null; + if (status === "error" || hideRecentSaves) { + recentSavesSection = /*#__PURE__*/react.createElement("h3", { + className: "header_medium", + "data-l10n-id": "pocket-panel-home-new-user-cta" + }); + } else if (status === "loading") { + recentSavesSection = /*#__PURE__*/react.createElement("span", { + "data-l10n-id": "pocket-panel-home-most-recent-saves-loading" + }); + } else if (status === "success") { + if (articles?.length) { + recentSavesSection = /*#__PURE__*/react.createElement(react.Fragment, null, /*#__PURE__*/react.createElement("h3", { + className: "header_medium", + "data-l10n-id": "pocket-panel-home-most-recent-saves" + }), articles.length > 3 ? /*#__PURE__*/react.createElement(react.Fragment, null, /*#__PURE__*/react.createElement(ArticleList_ArticleList, { + articles: articles.slice(0, 3), + source: "home_recent_save", + utmParams: utmParams, + openInPocketReader: true + }), /*#__PURE__*/react.createElement("span", { + className: "stp_button_wide" + }, /*#__PURE__*/react.createElement(Button_Button, { + style: "secondary", + url: `https://${pockethost}/a?${utmParams}`, + source: "home_view_list" + }, /*#__PURE__*/react.createElement("span", { + "data-l10n-id": "pocket-panel-button-show-all" + })))) : /*#__PURE__*/react.createElement(ArticleList_ArticleList, { + articles: articles, + source: "home_recent_save", + utmParams: utmParams + })); + } else { + recentSavesSection = /*#__PURE__*/react.createElement(react.Fragment, null, /*#__PURE__*/react.createElement("h3", { + className: "header_medium", + "data-l10n-id": "pocket-panel-home-new-user-cta" + }), /*#__PURE__*/react.createElement("h3", { + className: "header_medium", + "data-l10n-id": "pocket-panel-home-new-user-message" + })); + } + } + return /*#__PURE__*/react.createElement("div", { + className: "stp_panel_container" + }, /*#__PURE__*/react.createElement("div", { + className: "stp_panel stp_panel_home" + }, /*#__PURE__*/react.createElement(Header_Header, null, /*#__PURE__*/react.createElement(Button_Button, { + style: "primary", + url: `https://${pockethost}/a?${utmParams}`, + source: "home_view_list" + }, /*#__PURE__*/react.createElement("span", { + "data-l10n-id": "pocket-panel-header-my-saves" + }))), /*#__PURE__*/react.createElement("hr", null), recentSavesSection, /*#__PURE__*/react.createElement("hr", null), pockethost && locale?.startsWith("en") && topics?.length && /*#__PURE__*/react.createElement(react.Fragment, null, /*#__PURE__*/react.createElement("h3", { + className: "header_medium" + }, "Explore popular topics:"), /*#__PURE__*/react.createElement(PopularTopics_PopularTopics, { + topics: topics, + pockethost: pockethost, + utmParams: utmParams, + source: "home_popular_topic" + })))); +} +/* harmony default export */ const Home_Home = (Home); +;// CONCATENATED MODULE: ./content/panels/js/home/overlay.jsx +/* +HomeOverlay is the view itself and contains all of the methods to manipute the overlay and messaging. +It does not contain any logic for saving or communication with the extension or server. +*/ + + + + +var HomeOverlay = function (options) { + this.inited = false; + this.active = false; +}; +HomeOverlay.prototype = { + create({ + pockethost + }) { + const { + searchParams + } = new URL(window.location.href); + const locale = searchParams.get(`locale`) || ``; + const hideRecentSaves = searchParams.get(`hiderecentsaves`) === `true`; + const utmSource = searchParams.get(`utmSource`); + const utmCampaign = searchParams.get(`utmCampaign`); + const utmContent = searchParams.get(`utmContent`); + if (this.active) { + return; + } + this.active = true; + react_dom.render( /*#__PURE__*/react.createElement(Home_Home, { + locale: locale, + hideRecentSaves: hideRecentSaves, + pockethost: pockethost, + utmSource: utmSource, + utmCampaign: utmCampaign, + utmContent: utmContent + }), document.querySelector(`body`)); + if (window?.matchMedia(`(prefers-color-scheme: dark)`).matches) { + document.querySelector(`body`).classList.add(`theme_dark`); + } + } +}; +/* harmony default export */ const overlay = (HomeOverlay); +;// CONCATENATED MODULE: ./content/panels/js/components/Signup/Signup.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/. */ + + + + +function Signup(props) { + const { + locale, + pockethost, + utmSource, + utmCampaign, + utmContent + } = props; + const utmParams = `utm_source=${utmSource}${utmCampaign && utmContent ? `&utm_campaign=${utmCampaign}&utm_content=${utmContent}` : ``}`; + return /*#__PURE__*/react.createElement("div", { + className: "stp_panel_container" + }, /*#__PURE__*/react.createElement("div", { + className: "stp_panel stp_panel_signup" + }, /*#__PURE__*/react.createElement(Header_Header, null, /*#__PURE__*/react.createElement(Button_Button, { + style: "secondary", + url: `https://${pockethost}/login?${utmParams}`, + source: "log_in" + }, /*#__PURE__*/react.createElement("span", { + "data-l10n-id": "pocket-panel-signup-login" + }))), /*#__PURE__*/react.createElement("hr", null), locale?.startsWith("en") ? /*#__PURE__*/react.createElement(react.Fragment, null, /*#__PURE__*/react.createElement("div", { + className: "stp_signup_content_wrapper" + }, /*#__PURE__*/react.createElement("h3", { + className: "header_medium", + "data-l10n-id": "pocket-panel-signup-cta-a-fix" + }), /*#__PURE__*/react.createElement("p", { + "data-l10n-id": "pocket-panel-signup-cta-b-updated" + })), /*#__PURE__*/react.createElement("div", { + className: "stp_signup_content_wrapper" + }, /*#__PURE__*/react.createElement("hr", null)), /*#__PURE__*/react.createElement("div", { + className: "stp_signup_content_wrapper" + }, /*#__PURE__*/react.createElement("div", { + className: "stp_signup_img_rainbow_reader" + }), /*#__PURE__*/react.createElement("h3", { + className: "header_medium" + }, "Get thought-provoking article recommendations"), /*#__PURE__*/react.createElement("p", null, "Find stories that go deep into a subject or offer a new perspective."))) : /*#__PURE__*/react.createElement("div", { + className: "stp_signup_content_wrapper" + }, /*#__PURE__*/react.createElement("h3", { + className: "header_large", + "data-l10n-id": "pocket-panel-signup-cta-a-fix" + }), /*#__PURE__*/react.createElement("p", { + "data-l10n-id": "pocket-panel-signup-cta-b-short" + }), /*#__PURE__*/react.createElement("strong", null, /*#__PURE__*/react.createElement("p", { + "data-l10n-id": "pocket-panel-signup-cta-c-updated" + }))), /*#__PURE__*/react.createElement("hr", null), /*#__PURE__*/react.createElement("span", { + className: "stp_button_wide" + }, /*#__PURE__*/react.createElement(Button_Button, { + style: "primary", + url: `https://${pockethost}/ff_signup?${utmParams}`, + source: "sign_up_1" + }, /*#__PURE__*/react.createElement("span", { + "data-l10n-id": "pocket-panel-button-activate" + }))))); +} +/* harmony default export */ const Signup_Signup = (Signup); +;// CONCATENATED MODULE: ./content/panels/js/signup/overlay.jsx +/* +SignupOverlay is the view itself and contains all of the methods to manipute the overlay and messaging. +It does not contain any logic for saving or communication with the extension or server. +*/ + + + + + +var SignupOverlay = function (options) { + this.inited = false; + this.active = false; + this.create = function ({ + pockethost + }) { + // Extract local variables passed into template via URL query params + const { + searchParams + } = new URL(window.location.href); + const locale = searchParams.get(`locale`) || ``; + const utmSource = searchParams.get(`utmSource`); + const utmCampaign = searchParams.get(`utmCampaign`); + const utmContent = searchParams.get(`utmContent`); + if (this.active) { + return; + } + this.active = true; + + // Create actual content + react_dom.render( /*#__PURE__*/react.createElement(Signup_Signup, { + pockethost: pockethost, + utmSource: utmSource, + utmCampaign: utmCampaign, + utmContent: utmContent, + locale: locale + }), document.querySelector(`body`)); + if (window?.matchMedia(`(prefers-color-scheme: dark)`).matches) { + document.querySelector(`body`).classList.add(`theme_dark`); + } + + // tell back end we're ready + messages.sendMessage("PKT_show_signup"); + }; +}; +/* harmony default export */ const signup_overlay = (SignupOverlay); +;// CONCATENATED MODULE: ./content/panels/js/components/TagPicker/TagPicker.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/. */ + + + +function TagPicker(props) { + const [tags, setTags] = (0,react.useState)(props.tags); // New tag group to store + const [allTags, setAllTags] = (0,react.useState)([]); // All tags ever used (in no particular order) + const [recentTags, setRecentTags] = (0,react.useState)([]); // Most recently used tags + const [duplicateTag, setDuplicateTag] = (0,react.useState)(null); + const [inputValue, setInputValue] = (0,react.useState)(""); + + // Status can be success, waiting, or error. + const [{ + tagInputStatus, + tagInputErrorMessage + }, setTagInputStatus] = (0,react.useState)({ + tagInputStatus: "", + tagInputErrorMessage: "" + }); + let handleKeyDown = e => { + const enterKey = e.keyCode === 13; + const commaKey = e.keyCode === 188; + const tabKey = inputValue && e.keyCode === 9; + + // Submit tags on enter with no input. + // Enter tag on comma, tab, or enter with input. + // Tab to next element with no input. + if (commaKey || enterKey || tabKey) { + e.preventDefault(); + if (inputValue) { + addTag(inputValue.trim()); + setInputValue(``); // Clear out input + } else if (enterKey) { + submitTags(); + } + } + }; + let addTag = tagToAdd => { + if (!tagToAdd?.length) { + return; + } + let newDuplicateTag = tags.find(item => item === tagToAdd); + if (!newDuplicateTag) { + setTags([...tags, tagToAdd]); + } else { + setDuplicateTag(newDuplicateTag); + setTimeout(() => { + setDuplicateTag(null); + }, 1000); + } + }; + let removeTag = index => { + let updatedTags = tags.slice(0); // Shallow copied array + updatedTags.splice(index, 1); + setTags(updatedTags); + }; + let submitTags = () => { + let tagsToSubmit = []; + if (tags?.length) { + tagsToSubmit = tags; + } + + // Capture tags that have been typed in but not explicitly added to the tag collection + if (inputValue?.trim().length) { + tagsToSubmit.push(inputValue.trim()); + } + if (!props.itemUrl || !tagsToSubmit?.length) { + return; + } + setTagInputStatus({ + tagInputStatus: "waiting", + tagInputErrorMessage: "" + }); + messages.sendMessage("PKT_addTags", { + url: props.itemUrl, + tags: tagsToSubmit + }, function (resp) { + const { + data + } = resp; + if (data.status === "success") { + setTagInputStatus({ + tagInputStatus: "success", + tagInputErrorMessage: "" + }); + } else if (data.status === "error") { + setTagInputStatus({ + tagInputStatus: "error", + tagInputErrorMessage: data.error.message + }); + } + }); + }; + (0,react.useEffect)(() => { + messages.sendMessage("PKT_getTags", {}, resp => { + setAllTags(resp?.data?.tags); + }); + }, []); + (0,react.useEffect)(() => { + messages.sendMessage("PKT_getRecentTags", {}, resp => { + setRecentTags(resp?.data?.recentTags); + }); + }, []); + return /*#__PURE__*/react.createElement("div", { + className: "stp_tag_picker" + }, !tagInputStatus && /*#__PURE__*/react.createElement(react.Fragment, null, /*#__PURE__*/react.createElement("h3", { + className: "header_small", + "data-l10n-id": "pocket-panel-signup-add-tags" + }), /*#__PURE__*/react.createElement("div", { + className: "stp_tag_picker_tags" + }, tags.map((tag, i) => /*#__PURE__*/react.createElement("div", { + className: `stp_tag_picker_tag${duplicateTag === tag ? ` stp_tag_picker_tag_duplicate` : ``}` + }, /*#__PURE__*/react.createElement("button", { + onClick: () => removeTag(i), + className: `stp_tag_picker_tag_remove` + }, "X"), tag)), /*#__PURE__*/react.createElement("div", { + className: "stp_tag_picker_input_wrapper" + }, /*#__PURE__*/react.createElement("input", { + className: "stp_tag_picker_input", + type: "text", + list: "tag-list", + value: inputValue, + onChange: e => setInputValue(e.target.value), + onKeyDown: e => handleKeyDown(e), + maxlength: "25" + }), /*#__PURE__*/react.createElement("datalist", { + id: "tag-list" + }, allTags.sort((a, b) => a.search(inputValue) - b.search(inputValue)).map(item => /*#__PURE__*/react.createElement("option", { + key: item, + value: item + }))), /*#__PURE__*/react.createElement("button", { + className: "stp_tag_picker_button", + disabled: !inputValue?.length && !tags.length, + "data-l10n-id": "pocket-panel-saved-save-tags", + onClick: () => submitTags() + }))), /*#__PURE__*/react.createElement("div", { + className: "recent_tags" + }, recentTags.slice(0, 3).filter(recentTag => { + return !tags.find(item => item === recentTag); + }).map(tag => /*#__PURE__*/react.createElement("div", { + className: "stp_tag_picker_tag" + }, /*#__PURE__*/react.createElement("button", { + className: "stp_tag_picker_tag_remove", + onClick: () => addTag(tag) + }, "+ ", tag))))), tagInputStatus === "waiting" && /*#__PURE__*/react.createElement("h3", { + className: "header_large", + "data-l10n-id": "pocket-panel-saved-processing-tags" + }), tagInputStatus === "success" && /*#__PURE__*/react.createElement("h3", { + className: "header_large", + "data-l10n-id": "pocket-panel-saved-tags-saved" + }), tagInputStatus === "error" && /*#__PURE__*/react.createElement("h3", { + className: "header_small" + }, tagInputErrorMessage)); +} +/* harmony default export */ const TagPicker_TagPicker = (TagPicker); +;// CONCATENATED MODULE: ./content/panels/js/components/Saved/Saved.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/. */ + + + + + + + +function Saved(props) { + const { + locale, + pockethost, + utmSource, + utmCampaign, + utmContent + } = props; + // savedStatus can be success, loading, or error. + const [{ + savedStatus, + savedErrorId, + itemId, + itemUrl + }, setSavedStatusState] = (0,react.useState)({ + savedStatus: "loading" + }); + // removedStatus can be removed, removing, or error. + const [{ + removedStatus, + removedErrorMessage + }, setRemovedStatusState] = (0,react.useState)({}); + const [savedStory, setSavedStoryState] = (0,react.useState)(); + const [articleInfoAttempted, setArticleInfoAttempted] = (0,react.useState)(); + const utmParams = `utm_source=${utmSource}${utmCampaign && utmContent ? `&utm_campaign=${utmCampaign}&utm_content=${utmContent}` : ``}`; + function removeItem(event) { + event.preventDefault(); + setRemovedStatusState({ + removedStatus: "removing" + }); + messages.sendMessage("PKT_deleteItem", { + itemId + }, function (resp) { + const { + data + } = resp; + if (data.status == "success") { + setRemovedStatusState({ + removedStatus: "removed" + }); + } else if (data.status == "error") { + let errorMessage = ""; + // The server returns English error messages, so in the case of + // non English, we do our best with a generic translated error. + if (data.error.message && locale?.startsWith("en")) { + errorMessage = data.error.message; + } + setRemovedStatusState({ + removedStatus: "error", + removedErrorMessage: errorMessage + }); + } + }); + } + (0,react.useEffect)(() => { + // Wait confirmation of save before flipping to final saved state + messages.addMessageListener("PKT_saveLink", function (resp) { + const { + data + } = resp; + if (data.status == "error") { + // Use localizedKey or fallback to a generic catch all error. + setSavedStatusState({ + savedStatus: "error", + savedErrorId: data?.error?.localizedKey || "pocket-panel-saved-error-generic" + }); + return; + } + + // Success, so no localized error id needed. + setSavedStatusState({ + savedStatus: "success", + itemId: data.item?.item_id, + itemUrl: data.item?.given_url, + savedErrorId: "" + }); + }); + messages.addMessageListener("PKT_articleInfoFetched", function (resp) { + setSavedStoryState(resp?.data?.item_preview); + }); + messages.addMessageListener("PKT_getArticleInfoAttempted", function (resp) { + setArticleInfoAttempted(true); + }); + + // tell back end we're ready + messages.sendMessage("PKT_show_saved"); + }, []); + if (savedStatus === "error") { + return /*#__PURE__*/react.createElement("div", { + className: "stp_panel_container" + }, /*#__PURE__*/react.createElement("div", { + className: "stp_panel stp_panel_error" + }, /*#__PURE__*/react.createElement("div", { + className: "stp_panel_error_icon" + }), /*#__PURE__*/react.createElement("h3", { + className: "header_large", + "data-l10n-id": "pocket-panel-saved-error-not-saved" + }), /*#__PURE__*/react.createElement("p", { + "data-l10n-id": savedErrorId + }))); + } + return /*#__PURE__*/react.createElement("div", { + className: "stp_panel_container" + }, /*#__PURE__*/react.createElement("div", { + className: "stp_panel stp_panel_saved" + }, /*#__PURE__*/react.createElement(Header_Header, null, /*#__PURE__*/react.createElement(Button_Button, { + style: "primary", + url: `https://${pockethost}/a?${utmParams}`, + source: "view_list" + }, /*#__PURE__*/react.createElement("span", { + "data-l10n-id": "pocket-panel-header-my-saves" + }))), /*#__PURE__*/react.createElement("hr", null), !removedStatus && savedStatus === "success" && /*#__PURE__*/react.createElement(react.Fragment, null, /*#__PURE__*/react.createElement("h3", { + className: "header_large header_flex" + }, /*#__PURE__*/react.createElement("span", { + "data-l10n-id": "pocket-panel-saved-page-saved-b" + }), /*#__PURE__*/react.createElement(Button_Button, { + style: "text", + onClick: removeItem + }, /*#__PURE__*/react.createElement("span", { + "data-l10n-id": "pocket-panel-button-remove" + }))), savedStory && /*#__PURE__*/react.createElement(ArticleList_ArticleList, { + articles: [savedStory], + openInPocketReader: true, + utmParams: utmParams + }), articleInfoAttempted && /*#__PURE__*/react.createElement(TagPicker_TagPicker, { + tags: [], + itemUrl: itemUrl + })), savedStatus === "loading" && /*#__PURE__*/react.createElement("h3", { + className: "header_large", + "data-l10n-id": "pocket-panel-saved-saving-tags" + }), removedStatus === "removing" && /*#__PURE__*/react.createElement("h3", { + className: "header_large header_center", + "data-l10n-id": "pocket-panel-saved-processing-remove" + }), removedStatus === "removed" && /*#__PURE__*/react.createElement("h3", { + className: "header_large header_center", + "data-l10n-id": "pocket-panel-saved-removed-updated" + }), removedStatus === "error" && /*#__PURE__*/react.createElement(react.Fragment, null, /*#__PURE__*/react.createElement("h3", { + className: "header_large", + "data-l10n-id": "pocket-panel-saved-error-remove" + }), removedErrorMessage && /*#__PURE__*/react.createElement("p", null, removedErrorMessage)))); +} +/* harmony default export */ const Saved_Saved = (Saved); +;// CONCATENATED MODULE: ./content/panels/js/saved/overlay.jsx +/* +SavedOverlay is the view itself and contains all of the methods to manipute the overlay and messaging. +It does not contain any logic for saving or communication with the extension or server. +*/ + + + + +var SavedOverlay = function (options) { + this.inited = false; + this.active = false; +}; +SavedOverlay.prototype = { + create({ + pockethost + }) { + if (this.active) { + return; + } + this.active = true; + const { + searchParams + } = new URL(window.location.href); + const locale = searchParams.get(`locale`) || ``; + const utmSource = searchParams.get(`utmSource`); + const utmCampaign = searchParams.get(`utmCampaign`); + const utmContent = searchParams.get(`utmContent`); + react_dom.render( /*#__PURE__*/react.createElement(Saved_Saved, { + locale: locale, + pockethost: pockethost, + utmSource: utmSource, + utmCampaign: utmCampaign, + utmContent: utmContent + }), document.querySelector(`body`)); + if (window?.matchMedia(`(prefers-color-scheme: dark)`).matches) { + document.querySelector(`body`).classList.add(`theme_dark`); + } + } +}; +/* harmony default export */ const saved_overlay = (SavedOverlay); +;// CONCATENATED MODULE: ./content/panels/js/style-guide/overlay.jsx + + + + + + + +var StyleGuideOverlay = function (options) {}; +StyleGuideOverlay.prototype = { + create() { + // TODO: Wrap popular topics component in JSX to work without needing an explicit container hierarchy for styling + react_dom.render( /*#__PURE__*/react.createElement("div", null, /*#__PURE__*/react.createElement("h3", null, "JSX Components:"), /*#__PURE__*/react.createElement("h4", { + className: "stp_styleguide_h4" + }, "Buttons"), /*#__PURE__*/react.createElement("h5", { + className: "stp_styleguide_h5" + }, "text"), /*#__PURE__*/react.createElement(Button_Button, { + style: "text", + url: "https://example.org", + source: "styleguide" + }, "Text Button"), /*#__PURE__*/react.createElement("h5", { + className: "stp_styleguide_h5" + }, "primary"), /*#__PURE__*/react.createElement(Button_Button, { + style: "primary", + url: "https://example.org", + source: "styleguide" + }, "Primary Button"), /*#__PURE__*/react.createElement("h5", { + className: "stp_styleguide_h5" + }, "secondary"), /*#__PURE__*/react.createElement(Button_Button, { + style: "secondary", + url: "https://example.org", + source: "styleguide" + }, "Secondary Button"), /*#__PURE__*/react.createElement("h5", { + className: "stp_styleguide_h5" + }, "primary wide"), /*#__PURE__*/react.createElement("span", { + className: "stp_button_wide" + }, /*#__PURE__*/react.createElement(Button_Button, { + style: "primary", + url: "https://example.org", + source: "styleguide" + }, "Primary Wide Button")), /*#__PURE__*/react.createElement("h5", { + className: "stp_styleguide_h5" + }, "secondary wide"), /*#__PURE__*/react.createElement("span", { + className: "stp_button_wide" + }, /*#__PURE__*/react.createElement(Button_Button, { + style: "secondary", + url: "https://example.org", + source: "styleguide" + }, "Secondary Wide Button")), /*#__PURE__*/react.createElement("h4", { + className: "stp_styleguide_h4" + }, "Header"), /*#__PURE__*/react.createElement(Header_Header, null, /*#__PURE__*/react.createElement(Button_Button, { + style: "primary", + url: "https://example.org", + source: "styleguide" + }, "View My List")), /*#__PURE__*/react.createElement("h4", { + className: "stp_styleguide_h4" + }, "PopularTopics"), /*#__PURE__*/react.createElement(PopularTopics_PopularTopics, { + pockethost: `getpocket.com`, + source: `styleguide`, + utmParams: `utm_source=styleguide`, + topics: [{ + title: "Self Improvement", + topic: "self-improvement" + }, { + title: "Food", + topic: "food" + }, { + title: "Entertainment", + topic: "entertainment" + }, { + title: "Science", + topic: "science" + }] + }), /*#__PURE__*/react.createElement("h4", { + className: "stp_styleguide_h4" + }, "ArticleList"), /*#__PURE__*/react.createElement(ArticleList_ArticleList, { + source: `styleguide`, + articles: [{ + title: "Article Title", + publisher: "Publisher", + thumbnail: "https://img-getpocket.cdn.mozilla.net/80x80/https://www.raritanheadwaters.org/wp-content/uploads/2020/04/red-fox.jpg", + url: "https://example.org", + alt: "Alt Text" + }, { + title: "Article Title (No Publisher)", + thumbnail: "https://img-getpocket.cdn.mozilla.net/80x80/https://www.raritanheadwaters.org/wp-content/uploads/2020/04/red-fox.jpg", + url: "https://example.org", + alt: "Alt Text" + }, { + title: "Article Title (No Thumbnail)", + publisher: "Publisher", + url: "https://example.org", + alt: "Alt Text" + }] + }), /*#__PURE__*/react.createElement("h4", { + className: "stp_styleguide_h4" + }, "TagPicker"), /*#__PURE__*/react.createElement(TagPicker_TagPicker, { + tags: [`futurism`, `politics`, `mozilla`] + }), /*#__PURE__*/react.createElement("h3", null, "Typography:"), /*#__PURE__*/react.createElement("h2", { + className: "header_large" + }, ".header_large"), /*#__PURE__*/react.createElement("h3", { + className: "header_medium" + }, ".header_medium"), /*#__PURE__*/react.createElement("p", null, "paragraph"), /*#__PURE__*/react.createElement("h3", null, "Native Elements:"), /*#__PURE__*/react.createElement("h4", { + className: "stp_styleguide_h4" + }, "Horizontal Rule"), /*#__PURE__*/react.createElement("hr", null)), document.querySelector(`#stp_style_guide_components`)); + } +}; +/* harmony default export */ const style_guide_overlay = (StyleGuideOverlay); +;// CONCATENATED MODULE: ./content/panels/js/main.mjs +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* global RPMGetStringPref:false */ + + + + + + + +var PKT_PANEL = function () {}; + +PKT_PANEL.prototype = { + initHome() { + this.overlay = new overlay(); + this.init(); + }, + + initSignup() { + this.overlay = new signup_overlay(); + this.init(); + }, + + initSaved() { + this.overlay = new saved_overlay(); + this.init(); + }, + + initStyleGuide() { + this.overlay = new style_guide_overlay(); + this.init(); + }, + + setupObservers() { + this.setupMutationObserver(); + // Mutation observer isn't always enough for fast loading, static pages. + // Sometimes the mutation observer fires before the page is totally visible. + // In this case, the resize tries to fire with 0 height, + // and because it's a static page, it only does one mutation. + // So in this case, we have a backup intersection observer that fires when + // the page is first visible, and thus, the page is going to guarantee a height. + this.setupIntersectionObserver(); + }, + + init() { + if (this.inited) { + return; + } + this.setupObservers(); + this.inited = true; + }, + + resizeParent() { + let clientHeight = document.body.clientHeight; + if (this.overlay.tagsDropdownOpen) { + clientHeight = Math.max(clientHeight, 252); + } + + // We can ignore 0 height here. + // We rely on intersection observer to do the + // resize for 0 height loads. + if (clientHeight) { + messages.sendMessage("PKT_resizePanel", { + width: document.body.clientWidth, + height: clientHeight, + }); + } + }, + + setupIntersectionObserver() { + const observer = new IntersectionObserver(entries => { + if (entries.find(e => e.isIntersecting)) { + this.resizeParent(); + observer.unobserve(document.body); + } + }); + observer.observe(document.body); + }, + + setupMutationObserver() { + // Select the node that will be observed for mutations + const targetNode = document.body; + + // Options for the observer (which mutations to observe) + const config = { attributes: false, childList: true, subtree: true }; + + // Callback function to execute when mutations are observed + const callback = (mutationList, observer) => { + mutationList.forEach(mutation => { + switch (mutation.type) { + case "childList": { + /* One or more children have been added to and/or removed + from the tree. + (See mutation.addedNodes and mutation.removedNodes.) */ + this.resizeParent(); + break; + } + } + }); + }; + + // Create an observer instance linked to the callback function + const observer = new MutationObserver(callback); + + // Start observing the target node for configured mutations + observer.observe(targetNode, config); + }, + + create() { + const pockethost = + RPMGetStringPref("extensions.pocket.site") || "getpocket.com"; + this.overlay.create({ pockethost }); + }, +}; + +window.PKT_PANEL = PKT_PANEL; +window.pktPanelMessaging = messages; + + +/***/ }) + +/******/ }); +/************************************************************************/ +/******/ // The module cache +/******/ var __webpack_module_cache__ = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ // Check if module is in cache +/******/ var cachedModule = __webpack_module_cache__[moduleId]; +/******/ if (cachedModule !== undefined) { +/******/ return cachedModule.exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = __webpack_module_cache__[moduleId] = { +/******/ // no module.id needed +/******/ // no module.loaded needed +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/******/ // expose the modules object (__webpack_modules__) +/******/ __webpack_require__.m = __webpack_modules__; +/******/ +/************************************************************************/ +/******/ /* webpack/runtime/chunk loaded */ +/******/ (() => { +/******/ var deferred = []; +/******/ __webpack_require__.O = (result, chunkIds, fn, priority) => { +/******/ if(chunkIds) { +/******/ priority = priority || 0; +/******/ for(var i = deferred.length; i > 0 && deferred[i - 1][2] > priority; i--) deferred[i] = deferred[i - 1]; +/******/ deferred[i] = [chunkIds, fn, priority]; +/******/ return; +/******/ } +/******/ var notFulfilled = Infinity; +/******/ for (var i = 0; i < deferred.length; i++) { +/******/ var [chunkIds, fn, priority] = deferred[i]; +/******/ var fulfilled = true; +/******/ for (var j = 0; j < chunkIds.length; j++) { +/******/ if ((priority & 1 === 0 || notFulfilled >= priority) && Object.keys(__webpack_require__.O).every((key) => (__webpack_require__.O[key](chunkIds[j])))) { +/******/ chunkIds.splice(j--, 1); +/******/ } else { +/******/ fulfilled = false; +/******/ if(priority < notFulfilled) notFulfilled = priority; +/******/ } +/******/ } +/******/ if(fulfilled) { +/******/ deferred.splice(i--, 1) +/******/ var r = fn(); +/******/ if (r !== undefined) result = r; +/******/ } +/******/ } +/******/ return result; +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/hasOwnProperty shorthand */ +/******/ (() => { +/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) +/******/ })(); +/******/ +/******/ /* webpack/runtime/jsonp chunk loading */ +/******/ (() => { +/******/ // no baseURI +/******/ +/******/ // object to store loaded and loading chunks +/******/ // undefined = chunk not loaded, null = chunk preloaded/prefetched +/******/ // [resolve, reject, Promise] = chunk loading, 0 = chunk loaded +/******/ var installedChunks = { +/******/ 590: 0 +/******/ }; +/******/ +/******/ // no chunk on demand loading +/******/ +/******/ // no prefetching +/******/ +/******/ // no preloaded +/******/ +/******/ // no HMR +/******/ +/******/ // no HMR manifest +/******/ +/******/ __webpack_require__.O.j = (chunkId) => (installedChunks[chunkId] === 0); +/******/ +/******/ // install a JSONP callback for chunk loading +/******/ var webpackJsonpCallback = (parentChunkLoadingFunction, data) => { +/******/ var [chunkIds, moreModules, runtime] = data; +/******/ // add "moreModules" to the modules object, +/******/ // then flag all "chunkIds" as loaded and fire callback +/******/ var moduleId, chunkId, i = 0; +/******/ if(chunkIds.some((id) => (installedChunks[id] !== 0))) { +/******/ for(moduleId in moreModules) { +/******/ if(__webpack_require__.o(moreModules, moduleId)) { +/******/ __webpack_require__.m[moduleId] = moreModules[moduleId]; +/******/ } +/******/ } +/******/ if(runtime) var result = runtime(__webpack_require__); +/******/ } +/******/ if(parentChunkLoadingFunction) parentChunkLoadingFunction(data); +/******/ for(;i < chunkIds.length; i++) { +/******/ chunkId = chunkIds[i]; +/******/ if(__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) { +/******/ installedChunks[chunkId][0](); +/******/ } +/******/ installedChunks[chunkId] = 0; +/******/ } +/******/ return __webpack_require__.O(result); +/******/ } +/******/ +/******/ var chunkLoadingGlobal = self["webpackChunksave_to_pocket_ff"] = self["webpackChunksave_to_pocket_ff"] || []; +/******/ chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0)); +/******/ chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal)); +/******/ })(); +/******/ +/************************************************************************/ +/******/ +/******/ // startup +/******/ // Load entry module and return exports +/******/ // This entry module depends on other loaded chunks and execution need to be delayed +/******/ var __webpack_exports__ = __webpack_require__.O(undefined, [968], () => (__webpack_require__(720))) +/******/ __webpack_exports__ = __webpack_require__.O(__webpack_exports__); +/******/ +/******/ })() +; \ No newline at end of file diff --git a/browser/components/pocket/content/panels/js/main.bundle.js.LICENSE.txt b/browser/components/pocket/content/panels/js/main.bundle.js.LICENSE.txt new file mode 100644 index 0000000000..f42811bde3 --- /dev/null +++ b/browser/components/pocket/content/panels/js/main.bundle.js.LICENSE.txt @@ -0,0 +1,23 @@ +/*!***********************************!*\ + !*** ./content/panels/js/main.js ***! + \***********************************/ + +/*!***************************************!*\ + !*** ./content/panels/js/messages.js ***! + \***************************************/ + +/*!*******************************************!*\ + !*** ./content/panels/js/home/overlay.js ***! + \*******************************************/ + +/*!********************************************!*\ + !*** ./content/panels/js/saved/overlay.js ***! + \********************************************/ + +/*!*********************************************!*\ + !*** ./content/panels/js/signup/overlay.js ***! + \*********************************************/ + +/*!********************************************************!*\ + !*** ./content/panels/js/components/PopularTopics.jsx ***! + \********************************************************/ diff --git a/browser/components/pocket/content/panels/js/main.mjs b/browser/components/pocket/content/panels/js/main.mjs new file mode 100644 index 0000000000..b5ae0e9c3a --- /dev/null +++ b/browser/components/pocket/content/panels/js/main.mjs @@ -0,0 +1,118 @@ +/* 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/. */ +/* global RPMGetStringPref:false */ + +import HomeOverlay from "./home/overlay.jsx"; +import SignupOverlay from "./signup/overlay.jsx"; +import SavedOverlay from "./saved/overlay.jsx"; +import StyleGuideOverlay from "./style-guide/overlay.jsx"; +import pktPanelMessaging from "./messages.mjs"; + +var PKT_PANEL = function () {}; + +PKT_PANEL.prototype = { + initHome() { + this.overlay = new HomeOverlay(); + this.init(); + }, + + initSignup() { + this.overlay = new SignupOverlay(); + this.init(); + }, + + initSaved() { + this.overlay = new SavedOverlay(); + this.init(); + }, + + initStyleGuide() { + this.overlay = new StyleGuideOverlay(); + this.init(); + }, + + setupObservers() { + this.setupMutationObserver(); + // Mutation observer isn't always enough for fast loading, static pages. + // Sometimes the mutation observer fires before the page is totally visible. + // In this case, the resize tries to fire with 0 height, + // and because it's a static page, it only does one mutation. + // So in this case, we have a backup intersection observer that fires when + // the page is first visible, and thus, the page is going to guarantee a height. + this.setupIntersectionObserver(); + }, + + init() { + if (this.inited) { + return; + } + this.setupObservers(); + this.inited = true; + }, + + resizeParent() { + let clientHeight = document.body.clientHeight; + if (this.overlay.tagsDropdownOpen) { + clientHeight = Math.max(clientHeight, 252); + } + + // We can ignore 0 height here. + // We rely on intersection observer to do the + // resize for 0 height loads. + if (clientHeight) { + pktPanelMessaging.sendMessage("PKT_resizePanel", { + width: document.body.clientWidth, + height: clientHeight, + }); + } + }, + + setupIntersectionObserver() { + const observer = new IntersectionObserver(entries => { + if (entries.find(e => e.isIntersecting)) { + this.resizeParent(); + observer.unobserve(document.body); + } + }); + observer.observe(document.body); + }, + + setupMutationObserver() { + // Select the node that will be observed for mutations + const targetNode = document.body; + + // Options for the observer (which mutations to observe) + const config = { attributes: false, childList: true, subtree: true }; + + // Callback function to execute when mutations are observed + const callback = (mutationList, observer) => { + mutationList.forEach(mutation => { + switch (mutation.type) { + case "childList": { + /* One or more children have been added to and/or removed + from the tree. + (See mutation.addedNodes and mutation.removedNodes.) */ + this.resizeParent(); + break; + } + } + }); + }; + + // Create an observer instance linked to the callback function + const observer = new MutationObserver(callback); + + // Start observing the target node for configured mutations + observer.observe(targetNode, config); + }, + + create() { + const pockethost = + RPMGetStringPref("extensions.pocket.site") || "getpocket.com"; + this.overlay.create({ pockethost }); + }, +}; + +window.PKT_PANEL = PKT_PANEL; +window.pktPanelMessaging = pktPanelMessaging; diff --git a/browser/components/pocket/content/panels/js/messages.mjs b/browser/components/pocket/content/panels/js/messages.mjs new file mode 100644 index 0000000000..c91511be43 --- /dev/null +++ b/browser/components/pocket/content/panels/js/messages.mjs @@ -0,0 +1,50 @@ +/* global RPMRemoveMessageListener:false, RPMAddMessageListener:false, RPMSendAsyncMessage:false */ + +var pktPanelMessaging = { + removeMessageListener(messageId, callback) { + RPMRemoveMessageListener(messageId, callback); + }, + + addMessageListener(messageId, callback = () => {}) { + RPMAddMessageListener(messageId, callback); + }, + + sendMessage(messageId, payload = {}, callback) { + if (callback) { + // If we expect something back, we use RPMSendAsyncMessage and not RPMSendQuery. + // Even though RPMSendQuery returns something, our frame could be closed at any moment, + // and we don't want to close a RPMSendQuery promise loop unexpectedly. + // So instead we setup a response event. + const responseMessageId = `${messageId}_response`; + var responseListener = responsePayload => { + callback(responsePayload); + this.removeMessageListener(responseMessageId, responseListener); + }; + + this.addMessageListener(responseMessageId, responseListener); + } + + // Send message + RPMSendAsyncMessage(messageId, payload); + }, + + // Click helper to reduce bugs caused by oversight + // from different implementations of similar code. + clickHelper(element, { source = "", position }) { + element?.addEventListener(`click`, event => { + event.preventDefault(); + + this.sendMessage("PKT_openTabWithUrl", { + url: event.currentTarget.getAttribute(`href`), + source, + position, + }); + }); + }, + + log() { + RPMSendAsyncMessage("PKT_log", arguments); + }, +}; + +export default pktPanelMessaging; diff --git a/browser/components/pocket/content/panels/js/saved/entry.js b/browser/components/pocket/content/panels/js/saved/entry.js new file mode 100644 index 0000000000..b022c7a5e9 --- /dev/null +++ b/browser/components/pocket/content/panels/js/saved/entry.js @@ -0,0 +1,17 @@ +/* global PKT_PANEL:false */ + +function onDOMLoaded() { + if (!window.thePKT_PANEL) { + var thePKT_PANEL = new PKT_PANEL(); + /* global thePKT_PANEL */ + window.thePKT_PANEL = thePKT_PANEL; + thePKT_PANEL.initSaved(); + } + window.thePKT_PANEL.create(); +} + +if (document.readyState != `loading`) { + onDOMLoaded(); +} else { + document.addEventListener(`DOMContentLoaded`, onDOMLoaded); +} diff --git a/browser/components/pocket/content/panels/js/saved/overlay.jsx b/browser/components/pocket/content/panels/js/saved/overlay.jsx new file mode 100644 index 0000000000..ab2617f112 --- /dev/null +++ b/browser/components/pocket/content/panels/js/saved/overlay.jsx @@ -0,0 +1,46 @@ +/* +SavedOverlay is the view itself and contains all of the methods to manipute the overlay and messaging. +It does not contain any logic for saving or communication with the extension or server. +*/ + +import React from "react"; +import ReactDOM from "react-dom"; +import Saved from "../components/Saved/Saved.jsx"; + +var SavedOverlay = function (options) { + this.inited = false; + this.active = false; +}; + +SavedOverlay.prototype = { + create({ pockethost }) { + if (this.active) { + return; + } + + this.active = true; + + const { searchParams } = new URL(window.location.href); + const locale = searchParams.get(`locale`) || ``; + const utmSource = searchParams.get(`utmSource`); + const utmCampaign = searchParams.get(`utmCampaign`); + const utmContent = searchParams.get(`utmContent`); + + ReactDOM.render( + , + document.querySelector(`body`) + ); + + if (window?.matchMedia(`(prefers-color-scheme: dark)`).matches) { + document.querySelector(`body`).classList.add(`theme_dark`); + } + }, +}; + +export default SavedOverlay; diff --git a/browser/components/pocket/content/panels/js/signup/entry.js b/browser/components/pocket/content/panels/js/signup/entry.js new file mode 100644 index 0000000000..4beae984bb --- /dev/null +++ b/browser/components/pocket/content/panels/js/signup/entry.js @@ -0,0 +1,17 @@ +/* global PKT_PANEL:false */ + +function onDOMLoaded() { + if (!window.thePKT_PANEL) { + var thePKT_PANEL = new PKT_PANEL(); + /* global thePKT_PANEL */ + window.thePKT_PANEL = thePKT_PANEL; + thePKT_PANEL.initSignup(); + } + window.thePKT_PANEL.create(); +} + +if (document.readyState != `loading`) { + onDOMLoaded(); +} else { + document.addEventListener(`DOMContentLoaded`, onDOMLoaded); +} diff --git a/browser/components/pocket/content/panels/js/signup/overlay.jsx b/browser/components/pocket/content/panels/js/signup/overlay.jsx new file mode 100644 index 0000000000..6143afbc83 --- /dev/null +++ b/browser/components/pocket/content/panels/js/signup/overlay.jsx @@ -0,0 +1,50 @@ +/* +SignupOverlay is the view itself and contains all of the methods to manipute the overlay and messaging. +It does not contain any logic for saving or communication with the extension or server. +*/ + +import React from "react"; +import ReactDOM from "react-dom"; +import pktPanelMessaging from "../messages.mjs"; +import Signup from "../components/Signup/Signup.jsx"; + +var SignupOverlay = function (options) { + this.inited = false; + this.active = false; + + this.create = function ({ pockethost }) { + // Extract local variables passed into template via URL query params + const { searchParams } = new URL(window.location.href); + const locale = searchParams.get(`locale`) || ``; + const utmSource = searchParams.get(`utmSource`); + const utmCampaign = searchParams.get(`utmCampaign`); + const utmContent = searchParams.get(`utmContent`); + + if (this.active) { + return; + } + + this.active = true; + + // Create actual content + ReactDOM.render( + , + document.querySelector(`body`) + ); + + if (window?.matchMedia(`(prefers-color-scheme: dark)`).matches) { + document.querySelector(`body`).classList.add(`theme_dark`); + } + + // tell back end we're ready + pktPanelMessaging.sendMessage("PKT_show_signup"); + }; +}; + +export default SignupOverlay; diff --git a/browser/components/pocket/content/panels/js/style-guide/entry.js b/browser/components/pocket/content/panels/js/style-guide/entry.js new file mode 100644 index 0000000000..56ecbc0aef --- /dev/null +++ b/browser/components/pocket/content/panels/js/style-guide/entry.js @@ -0,0 +1,45 @@ +/* global PKT_PANEL:false */ + +function onDOMLoaded() { + if (!window.thePKT_PANEL) { + var thePKT_PANEL = new PKT_PANEL(); + /* global thePKT_PANEL */ + window.thePKT_PANEL = thePKT_PANEL; + thePKT_PANEL.initStyleGuide(); + } + window.thePKT_PANEL.overlay.create(); + + setupDarkModeUI(); +} + +function setupDarkModeUI() { + let isDarkModeEnabled = window?.matchMedia( + `(prefers-color-scheme: dark)` + ).matches; + let elDarkModeToggle = document.querySelector(`#dark_mode_toggle input`); + let elBody = document.querySelector(`body`); + + function setTheme() { + if (isDarkModeEnabled) { + elBody.classList.add(`theme_dark`); + elDarkModeToggle.checked = true; + } else { + elBody.classList.remove(`theme_dark`); + elDarkModeToggle.checked = false; + } + } + + setTheme(); + + elDarkModeToggle.addEventListener(`click`, function (e) { + e.preventDefault; + isDarkModeEnabled = !isDarkModeEnabled; + setTheme(); + }); +} + +if (document.readyState != `loading`) { + onDOMLoaded(); +} else { + document.addEventListener(`DOMContentLoaded`, onDOMLoaded); +} diff --git a/browser/components/pocket/content/panels/js/style-guide/overlay.jsx b/browser/components/pocket/content/panels/js/style-guide/overlay.jsx new file mode 100644 index 0000000000..fbc0dac069 --- /dev/null +++ b/browser/components/pocket/content/panels/js/style-guide/overlay.jsx @@ -0,0 +1,106 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import Header from "../components/Header/Header.jsx"; +import ArticleList from "../components/ArticleList/ArticleList.jsx"; +import Button from "../components/Button/Button.jsx"; +import PopularTopics from "../components/PopularTopics/PopularTopics.jsx"; +import TagPicker from "../components/TagPicker/TagPicker.jsx"; + +var StyleGuideOverlay = function (options) {}; + +StyleGuideOverlay.prototype = { + create() { + // TODO: Wrap popular topics component in JSX to work without needing an explicit container hierarchy for styling + ReactDOM.render( +
    +

    JSX Components:

    +

    Buttons

    +
    text
    + +
    primary
    + +
    secondary
    + +
    primary wide
    + + + +
    secondary wide
    + + + +

    Header

    +
    + +
    +

    PopularTopics

    + +

    ArticleList

    + +

    TagPicker

    + +

    Typography:

    +

    .header_large

    +

    .header_medium

    +

    paragraph

    +

    Native Elements:

    +

    Horizontal Rule

    +
    +
    , + document.querySelector(`#stp_style_guide_components`) + ); + }, +}; + +export default StyleGuideOverlay; diff --git a/browser/components/pocket/content/panels/js/vendor.bundle.js b/browser/components/pocket/content/panels/js/vendor.bundle.js new file mode 100644 index 0000000000..01fd78a61a --- /dev/null +++ b/browser/components/pocket/content/panels/js/vendor.bundle.js @@ -0,0 +1,451 @@ +"use strict"; +(self["webpackChunksave_to_pocket_ff"] = self["webpackChunksave_to_pocket_ff"] || []).push([[968],{ + +/***/ 516: +/***/ ((__unused_webpack_module, exports, __webpack_require__) => { + +/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +/* + Modernizr 3.0.0pre (Custom Build) | MIT +*/ +var aa=__webpack_require__(504),ca=__webpack_require__(712);function p(a){for(var b="https://reactjs.org/docs/error-decoder.html?invariant="+a,c=1;cb}return!1}function v(a,b,c,d,e,f,g){this.acceptsBooleans=2===b||3===b||4===b;this.attributeName=d;this.attributeNamespace=e;this.mustUseProperty=c;this.propertyName=a;this.type=b;this.sanitizeURL=f;this.removeEmptyString=g}var z={}; +"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(a){z[a]=new v(a,0,!1,a,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(a){var b=a[0];z[b]=new v(b,1,!1,a[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(a){z[a]=new v(a,2,!1,a.toLowerCase(),null,!1,!1)}); +["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(a){z[a]=new v(a,2,!1,a,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(a){z[a]=new v(a,3,!1,a.toLowerCase(),null,!1,!1)}); +["checked","multiple","muted","selected"].forEach(function(a){z[a]=new v(a,3,!0,a,null,!1,!1)});["capture","download"].forEach(function(a){z[a]=new v(a,4,!1,a,null,!1,!1)});["cols","rows","size","span"].forEach(function(a){z[a]=new v(a,6,!1,a,null,!1,!1)});["rowSpan","start"].forEach(function(a){z[a]=new v(a,5,!1,a.toLowerCase(),null,!1,!1)});var ra=/[\-:]([a-z])/g;function sa(a){return a[1].toUpperCase()} +"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(a){var b=a.replace(ra, +sa);z[b]=new v(b,1,!1,a,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(a){var b=a.replace(ra,sa);z[b]=new v(b,1,!1,a,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(a){var b=a.replace(ra,sa);z[b]=new v(b,1,!1,a,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(a){z[a]=new v(a,1,!1,a.toLowerCase(),null,!1,!1)}); +z.xlinkHref=new v("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(a){z[a]=new v(a,1,!1,a.toLowerCase(),null,!0,!0)}); +function ta(a,b,c,d){var e=z.hasOwnProperty(b)?z[b]:null;if(null!==e?0!==e.type:d||!(2h||e[g]!==f[h]){var k="\n"+e[g].replace(" at new "," at ");a.displayName&&k.includes("")&&(k=k.replace("",a.displayName));return k}while(1<=g&&0<=h)}break}}}finally{Na=!1,Error.prepareStackTrace=c}return(a=a?a.displayName||a.name:"")?Ma(a):""} +function Pa(a){switch(a.tag){case 5:return Ma(a.type);case 16:return Ma("Lazy");case 13:return Ma("Suspense");case 19:return Ma("SuspenseList");case 0:case 2:case 15:return a=Oa(a.type,!1),a;case 11:return a=Oa(a.type.render,!1),a;case 1:return a=Oa(a.type,!0),a;default:return""}} +function Qa(a){if(null==a)return null;if("function"===typeof a)return a.displayName||a.name||null;if("string"===typeof a)return a;switch(a){case ya:return"Fragment";case wa:return"Portal";case Aa:return"Profiler";case za:return"StrictMode";case Ea:return"Suspense";case Fa:return"SuspenseList"}if("object"===typeof a)switch(a.$$typeof){case Ca:return(a.displayName||"Context")+".Consumer";case Ba:return(a._context.displayName||"Context")+".Provider";case Da:var b=a.render;a=a.displayName;a||(a=b.displayName|| +b.name||"",a=""!==a?"ForwardRef("+a+")":"ForwardRef");return a;case Ga:return b=a.displayName||null,null!==b?b:Qa(a.type)||"Memo";case Ha:b=a._payload;a=a._init;try{return Qa(a(b))}catch(c){}}return null} +function Ra(a){var b=a.type;switch(a.tag){case 24:return"Cache";case 9:return(b.displayName||"Context")+".Consumer";case 10:return(b._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return a=b.render,a=a.displayName||a.name||"",b.displayName||(""!==a?"ForwardRef("+a+")":"ForwardRef");case 7:return"Fragment";case 5:return b;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return Qa(b);case 8:return b===za?"StrictMode":"Mode";case 22:return"Offscreen"; +case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if("function"===typeof b)return b.displayName||b.name||null;if("string"===typeof b)return b}return null}function Sa(a){switch(typeof a){case "boolean":case "number":case "string":case "undefined":return a;case "object":return a;default:return""}} +function Ta(a){var b=a.type;return(a=a.nodeName)&&"input"===a.toLowerCase()&&("checkbox"===b||"radio"===b)} +function Ua(a){var b=Ta(a)?"checked":"value",c=Object.getOwnPropertyDescriptor(a.constructor.prototype,b),d=""+a[b];if(!a.hasOwnProperty(b)&&"undefined"!==typeof c&&"function"===typeof c.get&&"function"===typeof c.set){var e=c.get,f=c.set;Object.defineProperty(a,b,{configurable:!0,get:function(){return e.call(this)},set:function(a){d=""+a;f.call(this,a)}});Object.defineProperty(a,b,{enumerable:c.enumerable});return{getValue:function(){return d},setValue:function(a){d=""+a},stopTracking:function(){a._valueTracker= +null;delete a[b]}}}}function Va(a){a._valueTracker||(a._valueTracker=Ua(a))}function Wa(a){if(!a)return!1;var b=a._valueTracker;if(!b)return!0;var c=b.getValue();var d="";a&&(d=Ta(a)?a.checked?"true":"false":a.value);a=d;return a!==c?(b.setValue(a),!0):!1}function Xa(a){a=a||("undefined"!==typeof document?document:void 0);if("undefined"===typeof a)return null;try{return a.activeElement||a.body}catch(b){return a.body}} +function Ya(a,b){var c=b.checked;return A({},b,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:null!=c?c:a._wrapperState.initialChecked})}function Za(a,b){var c=null==b.defaultValue?"":b.defaultValue,d=null!=b.checked?b.checked:b.defaultChecked;c=Sa(null!=b.value?b.value:c);a._wrapperState={initialChecked:d,initialValue:c,controlled:"checkbox"===b.type||"radio"===b.type?null!=b.checked:null!=b.value}}function ab(a,b){b=b.checked;null!=b&&ta(a,"checked",b,!1)} +function bb(a,b){ab(a,b);var c=Sa(b.value),d=b.type;if(null!=c)if("number"===d){if(0===c&&""===a.value||a.value!=c)a.value=""+c}else a.value!==""+c&&(a.value=""+c);else if("submit"===d||"reset"===d){a.removeAttribute("value");return}b.hasOwnProperty("value")?cb(a,b.type,c):b.hasOwnProperty("defaultValue")&&cb(a,b.type,Sa(b.defaultValue));null==b.checked&&null!=b.defaultChecked&&(a.defaultChecked=!!b.defaultChecked)} +function db(a,b,c){if(b.hasOwnProperty("value")||b.hasOwnProperty("defaultValue")){var d=b.type;if(!("submit"!==d&&"reset"!==d||void 0!==b.value&&null!==b.value))return;b=""+a._wrapperState.initialValue;c||b===a.value||(a.value=b);a.defaultValue=b}c=a.name;""!==c&&(a.name="");a.defaultChecked=!!a._wrapperState.initialChecked;""!==c&&(a.name=c)} +function cb(a,b,c){if("number"!==b||Xa(a.ownerDocument)!==a)null==c?a.defaultValue=""+a._wrapperState.initialValue:a.defaultValue!==""+c&&(a.defaultValue=""+c)}var eb=Array.isArray; +function fb(a,b,c,d){a=a.options;if(b){b={};for(var e=0;e"+b.valueOf().toString()+"";for(b=mb.firstChild;a.firstChild;)a.removeChild(a.firstChild);for(;b.firstChild;)a.appendChild(b.firstChild)}}); +function ob(a,b){if(b){var c=a.firstChild;if(c&&c===a.lastChild&&3===c.nodeType){c.nodeValue=b;return}}a.textContent=b} +var pb={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0, +zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},qb=["Webkit","ms","Moz","O"];Object.keys(pb).forEach(function(a){qb.forEach(function(b){b=b+a.charAt(0).toUpperCase()+a.substring(1);pb[b]=pb[a]})});function rb(a,b,c){return null==b||"boolean"===typeof b||""===b?"":c||"number"!==typeof b||0===b||pb.hasOwnProperty(a)&&pb[a]?(""+b).trim():b+"px"} +function sb(a,b){a=a.style;for(var c in b)if(b.hasOwnProperty(c)){var d=0===c.indexOf("--"),e=rb(c,b[c],d);"float"===c&&(c="cssFloat");d?a.setProperty(c,e):a[c]=e}}var tb=A({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0}); +function ub(a,b){if(b){if(tb[a]&&(null!=b.children||null!=b.dangerouslySetInnerHTML))throw Error(p(137,a));if(null!=b.dangerouslySetInnerHTML){if(null!=b.children)throw Error(p(60));if("object"!==typeof b.dangerouslySetInnerHTML||!("__html"in b.dangerouslySetInnerHTML))throw Error(p(61));}if(null!=b.style&&"object"!==typeof b.style)throw Error(p(62));}} +function vb(a,b){if(-1===a.indexOf("-"))return"string"===typeof b.is;switch(a){case "annotation-xml":case "color-profile":case "font-face":case "font-face-src":case "font-face-uri":case "font-face-format":case "font-face-name":case "missing-glyph":return!1;default:return!0}}var wb=null;function xb(a){a=a.target||a.srcElement||window;a.correspondingUseElement&&(a=a.correspondingUseElement);return 3===a.nodeType?a.parentNode:a}var yb=null,zb=null,Ab=null; +function Bb(a){if(a=Cb(a)){if("function"!==typeof yb)throw Error(p(280));var b=a.stateNode;b&&(b=Db(b),yb(a.stateNode,a.type,b))}}function Eb(a){zb?Ab?Ab.push(a):Ab=[a]:zb=a}function Fb(){if(zb){var a=zb,b=Ab;Ab=zb=null;Bb(a);if(b)for(a=0;a>>=0;return 0===a?32:31-(pc(a)/qc|0)|0}var rc=64,sc=4194304; +function tc(a){switch(a&-a){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return a&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return a&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824; +default:return a}}function uc(a,b){var c=a.pendingLanes;if(0===c)return 0;var d=0,e=a.suspendedLanes,f=a.pingedLanes,g=c&268435455;if(0!==g){var h=g&~e;0!==h?d=tc(h):(f&=g,0!==f&&(d=tc(f)))}else g=c&~e,0!==g?d=tc(g):0!==f&&(d=tc(f));if(0===d)return 0;if(0!==b&&b!==d&&0===(b&e)&&(e=d&-d,f=b&-b,e>=f||16===e&&0!==(f&4194240)))return b;0!==(d&4)&&(d|=c&16);b=a.entangledLanes;if(0!==b)for(a=a.entanglements,b&=d;0c;c++)b.push(a);return b} +function Ac(a,b,c){a.pendingLanes|=b;536870912!==b&&(a.suspendedLanes=0,a.pingedLanes=0);a=a.eventTimes;b=31-oc(b);a[b]=c}function Bc(a,b){var c=a.pendingLanes&~b;a.pendingLanes=b;a.suspendedLanes=0;a.pingedLanes=0;a.expiredLanes&=b;a.mutableReadLanes&=b;a.entangledLanes&=b;b=a.entanglements;var d=a.eventTimes;for(a=a.expirationTimes;0=be),ee=String.fromCharCode(32),fe=!1; +function ge(a,b){switch(a){case "keyup":return-1!==$d.indexOf(b.keyCode);case "keydown":return 229!==b.keyCode;case "keypress":case "mousedown":case "focusout":return!0;default:return!1}}function he(a){a=a.detail;return"object"===typeof a&&"data"in a?a.data:null}var ie=!1;function je(a,b){switch(a){case "compositionend":return he(b);case "keypress":if(32!==b.which)return null;fe=!0;return ee;case "textInput":return a=b.data,a===ee&&fe?null:a;default:return null}} +function ke(a,b){if(ie)return"compositionend"===a||!ae&&ge(a,b)?(a=nd(),md=ld=kd=null,ie=!1,a):null;switch(a){case "paste":return null;case "keypress":if(!(b.ctrlKey||b.altKey||b.metaKey)||b.ctrlKey&&b.altKey){if(b.char&&1=b)return{node:c,offset:b-a};a=d}a:{for(;c;){if(c.nextSibling){c=c.nextSibling;break a}c=c.parentNode}c=void 0}c=Je(c)}}function Le(a,b){return a&&b?a===b?!0:a&&3===a.nodeType?!1:b&&3===b.nodeType?Le(a,b.parentNode):"contains"in a?a.contains(b):a.compareDocumentPosition?!!(a.compareDocumentPosition(b)&16):!1:!1} +function Me(){for(var a=window,b=Xa();b instanceof a.HTMLIFrameElement;){try{var c="string"===typeof b.contentWindow.location.href}catch(d){c=!1}if(c)a=b.contentWindow;else break;b=Xa(a.document)}return b}function Ne(a){var b=a&&a.nodeName&&a.nodeName.toLowerCase();return b&&("input"===b&&("text"===a.type||"search"===a.type||"tel"===a.type||"url"===a.type||"password"===a.type)||"textarea"===b||"true"===a.contentEditable)} +function Oe(a){var b=Me(),c=a.focusedElem,d=a.selectionRange;if(b!==c&&c&&c.ownerDocument&&Le(c.ownerDocument.documentElement,c)){if(null!==d&&Ne(c))if(b=d.start,a=d.end,void 0===a&&(a=b),"selectionStart"in c)c.selectionStart=b,c.selectionEnd=Math.min(a,c.value.length);else if(a=(b=c.ownerDocument||document)&&b.defaultView||window,a.getSelection){a=a.getSelection();var e=c.textContent.length,f=Math.min(d.start,e);d=void 0===d.end?f:Math.min(d.end,e);!a.extend&&f>d&&(e=d,d=f,f=e);e=Ke(c,f);var g=Ke(c, +d);e&&g&&(1!==a.rangeCount||a.anchorNode!==e.node||a.anchorOffset!==e.offset||a.focusNode!==g.node||a.focusOffset!==g.offset)&&(b=b.createRange(),b.setStart(e.node,e.offset),a.removeAllRanges(),f>d?(a.addRange(b),a.extend(g.node,g.offset)):(b.setEnd(g.node,g.offset),a.addRange(b)))}b=[];for(a=c;a=a.parentNode;)1===a.nodeType&&b.push({element:a,left:a.scrollLeft,top:a.scrollTop});"function"===typeof c.focus&&c.focus();for(c=0;c=document.documentMode,Qe=null,Re=null,Se=null,Te=!1; +function Ue(a,b,c){var d=c.window===c?c.document:9===c.nodeType?c:c.ownerDocument;Te||null==Qe||Qe!==Xa(d)||(d=Qe,"selectionStart"in d&&Ne(d)?d={start:d.selectionStart,end:d.selectionEnd}:(d=(d.ownerDocument&&d.ownerDocument.defaultView||window).getSelection(),d={anchorNode:d.anchorNode,anchorOffset:d.anchorOffset,focusNode:d.focusNode,focusOffset:d.focusOffset}),Se&&Ie(Se,d)||(Se=d,d=oe(Re,"onSelect"),0Tf||(a.current=Sf[Tf],Sf[Tf]=null,Tf--)}function G(a,b){Tf++;Sf[Tf]=a.current;a.current=b}var Vf={},H=Uf(Vf),Wf=Uf(!1),Xf=Vf;function Yf(a,b){var c=a.type.contextTypes;if(!c)return Vf;var d=a.stateNode;if(d&&d.__reactInternalMemoizedUnmaskedChildContext===b)return d.__reactInternalMemoizedMaskedChildContext;var e={},f;for(f in c)e[f]=b[f];d&&(a=a.stateNode,a.__reactInternalMemoizedUnmaskedChildContext=b,a.__reactInternalMemoizedMaskedChildContext=e);return e} +function Zf(a){a=a.childContextTypes;return null!==a&&void 0!==a}function $f(){E(Wf);E(H)}function ag(a,b,c){if(H.current!==Vf)throw Error(p(168));G(H,b);G(Wf,c)}function bg(a,b,c){var d=a.stateNode;b=b.childContextTypes;if("function"!==typeof d.getChildContext)return c;d=d.getChildContext();for(var e in d)if(!(e in b))throw Error(p(108,Ra(a)||"Unknown",e));return A({},c,d)} +function cg(a){a=(a=a.stateNode)&&a.__reactInternalMemoizedMergedChildContext||Vf;Xf=H.current;G(H,a);G(Wf,Wf.current);return!0}function dg(a,b,c){var d=a.stateNode;if(!d)throw Error(p(169));c?(a=bg(a,b,Xf),d.__reactInternalMemoizedMergedChildContext=a,E(Wf),E(H),G(H,a)):E(Wf);G(Wf,c)}var eg=null,fg=!1,gg=!1;function hg(a){null===eg?eg=[a]:eg.push(a)}function ig(a){fg=!0;hg(a)} +function jg(){if(!gg&&null!==eg){gg=!0;var a=0,b=C;try{var c=eg;for(C=1;a>=g;e-=g;rg=1<<32-oc(b)+e|c<w?(x=u,u=null):x=u.sibling;var n=r(e,u,h[w],k);if(null===n){null===u&&(u=x);break}a&&u&&null===n.alternate&&b(e,u);g=f(n,g,w);null===m?l=n:m.sibling=n;m=n;u=x}if(w===h.length)return c(e,u),I&&tg(e,w),l;if(null===u){for(;ww?(x=m,m=null):x=m.sibling;var t=r(e,m,n.value,k);if(null===t){null===m&&(m=x);break}a&&m&&null===t.alternate&&b(e,m);g=f(t,g,w);null===u?l=t:u.sibling=t;u=t;m=x}if(n.done)return c(e, +m),I&&tg(e,w),l;if(null===m){for(;!n.done;w++,n=h.next())n=q(e,n.value,k),null!==n&&(g=f(n,g,w),null===u?l=n:u.sibling=n,u=n);I&&tg(e,w);return l}for(m=d(e,m);!n.done;w++,n=h.next())n=y(m,e,w,n.value,k),null!==n&&(a&&null!==n.alternate&&m.delete(null===n.key?w:n.key),g=f(n,g,w),null===u?l=n:u.sibling=n,u=n);a&&m.forEach(function(a){return b(e,a)});I&&tg(e,w);return l}function J(a,d,f,h){"object"===typeof f&&null!==f&&f.type===ya&&null===f.key&&(f=f.props.children);if("object"===typeof f&&null!==f){switch(f.$$typeof){case va:a:{for(var k= +f.key,l=d;null!==l;){if(l.key===k){k=f.type;if(k===ya){if(7===l.tag){c(a,l.sibling);d=e(l,f.props.children);d.return=a;a=d;break a}}else if(l.elementType===k||"object"===typeof k&&null!==k&&k.$$typeof===Ha&&uh(k)===l.type){c(a,l.sibling);d=e(l,f.props);d.ref=sh(a,l,f);d.return=a;a=d;break a}c(a,l);break}else b(a,l);l=l.sibling}f.type===ya?(d=Ah(f.props.children,a.mode,h,f.key),d.return=a,a=d):(h=yh(f.type,f.key,f.props,null,a.mode,h),h.ref=sh(a,d,f),h.return=a,a=h)}return g(a);case wa:a:{for(l=f.key;null!== +d;){if(d.key===l)if(4===d.tag&&d.stateNode.containerInfo===f.containerInfo&&d.stateNode.implementation===f.implementation){c(a,d.sibling);d=e(d,f.children||[]);d.return=a;a=d;break a}else{c(a,d);break}else b(a,d);d=d.sibling}d=zh(f,a.mode,h);d.return=a;a=d}return g(a);case Ha:return l=f._init,J(a,d,l(f._payload),h)}if(eb(f))return n(a,d,f,h);if(Ka(f))return t(a,d,f,h);th(a,f)}return"string"===typeof f&&""!==f||"number"===typeof f?(f=""+f,null!==d&&6===d.tag?(c(a,d.sibling),d=e(d,f),d.return=a,a=d): +(c(a,d),d=xh(f,a.mode,h),d.return=a,a=d),g(a)):c(a,d)}return J}var Bh=vh(!0),Ch=vh(!1),Dh={},Eh=Uf(Dh),Fh=Uf(Dh),Gh=Uf(Dh);function Hh(a){if(a===Dh)throw Error(p(174));return a}function Ih(a,b){G(Gh,b);G(Fh,a);G(Eh,Dh);a=b.nodeType;switch(a){case 9:case 11:b=(b=b.documentElement)?b.namespaceURI:lb(null,"");break;default:a=8===a?b.parentNode:b,b=a.namespaceURI||null,a=a.tagName,b=lb(b,a)}E(Eh);G(Eh,b)}function Jh(){E(Eh);E(Fh);E(Gh)} +function Kh(a){Hh(Gh.current);var b=Hh(Eh.current);var c=lb(b,a.type);b!==c&&(G(Fh,a),G(Eh,c))}function Lh(a){Fh.current===a&&(E(Eh),E(Fh))}var M=Uf(0); +function Mh(a){for(var b=a;null!==b;){if(13===b.tag){var c=b.memoizedState;if(null!==c&&(c=c.dehydrated,null===c||"$?"===c.data||"$!"===c.data))return b}else if(19===b.tag&&void 0!==b.memoizedProps.revealOrder){if(0!==(b.flags&128))return b}else if(null!==b.child){b.child.return=b;b=b.child;continue}if(b===a)break;for(;null===b.sibling;){if(null===b.return||b.return===a)return null;b=b.return}b.sibling.return=b.return;b=b.sibling}return null}var Nh=[]; +function Oh(){for(var a=0;ac?c:4;a(!0);var d=Qh.transition;Qh.transition={};try{a(!1),b()}finally{C=c,Qh.transition=d}}function Fi(){return di().memoizedState} +function Gi(a,b,c){var d=lh(a);c={lane:d,action:c,hasEagerState:!1,eagerState:null,next:null};if(Hi(a))Ii(b,c);else if(c=Yg(a,b,c,d),null!==c){var e=L();mh(c,a,d,e);Ji(c,b,d)}} +function ri(a,b,c){var d=lh(a),e={lane:d,action:c,hasEagerState:!1,eagerState:null,next:null};if(Hi(a))Ii(b,e);else{var f=a.alternate;if(0===a.lanes&&(null===f||0===f.lanes)&&(f=b.lastRenderedReducer,null!==f))try{var g=b.lastRenderedState,h=f(g,c);e.hasEagerState=!0;e.eagerState=h;if(He(h,g)){var k=b.interleaved;null===k?(e.next=e,Xg(b)):(e.next=k.next,k.next=e);b.interleaved=e;return}}catch(l){}finally{}c=Yg(a,b,e,d);null!==c&&(e=L(),mh(c,a,d,e),Ji(c,b,d))}} +function Hi(a){var b=a.alternate;return a===N||null!==b&&b===N}function Ii(a,b){Th=Sh=!0;var c=a.pending;null===c?b.next=b:(b.next=c.next,c.next=b);a.pending=b}function Ji(a,b,c){if(0!==(c&4194240)){var d=b.lanes;d&=a.pendingLanes;c|=d;b.lanes=c;Cc(a,c)}} +var ai={readContext:Vg,useCallback:Q,useContext:Q,useEffect:Q,useImperativeHandle:Q,useInsertionEffect:Q,useLayoutEffect:Q,useMemo:Q,useReducer:Q,useRef:Q,useState:Q,useDebugValue:Q,useDeferredValue:Q,useTransition:Q,useMutableSource:Q,useSyncExternalStore:Q,useId:Q,unstable_isNewReconciler:!1},Yh={readContext:Vg,useCallback:function(a,b){ci().memoizedState=[a,void 0===b?null:b];return a},useContext:Vg,useEffect:vi,useImperativeHandle:function(a,b,c){c=null!==c&&void 0!==c?c.concat([a]):null;return ti(4194308, +4,yi.bind(null,b,a),c)},useLayoutEffect:function(a,b){return ti(4194308,4,a,b)},useInsertionEffect:function(a,b){return ti(4,2,a,b)},useMemo:function(a,b){var c=ci();b=void 0===b?null:b;a=a();c.memoizedState=[a,b];return a},useReducer:function(a,b,c){var d=ci();b=void 0!==c?c(b):b;d.memoizedState=d.baseState=b;a={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:a,lastRenderedState:b};d.queue=a;a=a.dispatch=Gi.bind(null,N,a);return[d.memoizedState,a]},useRef:function(a){var b= +ci();a={current:a};return b.memoizedState=a},useState:qi,useDebugValue:Ai,useDeferredValue:function(a){return ci().memoizedState=a},useTransition:function(){var a=qi(!1),b=a[0];a=Ei.bind(null,a[1]);ci().memoizedState=a;return[b,a]},useMutableSource:function(){},useSyncExternalStore:function(a,b,c){var d=N,e=ci();if(I){if(void 0===c)throw Error(p(407));c=c()}else{c=b();if(null===R)throw Error(p(349));0!==(Rh&30)||ni(d,b,c)}e.memoizedState=c;var f={value:c,getSnapshot:b};e.queue=f;vi(ki.bind(null,d, +f,a),[a]);d.flags|=2048;li(9,mi.bind(null,d,f,c,b),void 0,null);return c},useId:function(){var a=ci(),b=R.identifierPrefix;if(I){var c=sg;var d=rg;c=(d&~(1<<32-oc(d)-1)).toString(32)+c;b=":"+b+"R"+c;c=Uh++;0\x3c/script>",a=a.removeChild(a.firstChild)): +"string"===typeof d.is?a=g.createElement(c,{is:d.is}):(a=g.createElement(c),"select"===c&&(g=a,d.multiple?g.multiple=!0:d.size&&(g.size=d.size))):a=g.createElementNS(a,c);a[Of]=b;a[Pf]=d;Aj(a,b,!1,!1);b.stateNode=a;a:{g=vb(c,d);switch(c){case "dialog":D("cancel",a);D("close",a);e=d;break;case "iframe":case "object":case "embed":D("load",a);e=d;break;case "video":case "audio":for(e=0;eHj&&(b.flags|=128,d=!0,Ej(f,!1),b.lanes=4194304)}else{if(!d)if(a=Mh(g),null!==a){if(b.flags|=128,d=!0,c=a.updateQueue,null!==c&&(b.updateQueue=c,b.flags|=4),Ej(f,!0),null===f.tail&&"hidden"===f.tailMode&&!g.alternate&&!I)return S(b),null}else 2*B()-f.renderingStartTime>Hj&&1073741824!==c&&(b.flags|=128,d=!0,Ej(f,!1),b.lanes=4194304);f.isBackwards?(g.sibling=b.child,b.child=g):(c=f.last,null!==c?c.sibling=g:b.child=g,f.last=g)}if(null!==f.tail)return b=f.tail,f.rendering= +b,f.tail=b.sibling,f.renderingStartTime=B(),b.sibling=null,c=M.current,G(M,d?c&1|2:c&1),b;S(b);return null;case 22:case 23:return Ij(),d=null!==b.memoizedState,null!==a&&null!==a.memoizedState!==d&&(b.flags|=8192),d&&0!==(b.mode&1)?0!==(gj&1073741824)&&(S(b),b.subtreeFlags&6&&(b.flags|=8192)):S(b),null;case 24:return null;case 25:return null}throw Error(p(156,b.tag));} +function Jj(a,b){wg(b);switch(b.tag){case 1:return Zf(b.type)&&$f(),a=b.flags,a&65536?(b.flags=a&-65537|128,b):null;case 3:return Jh(),E(Wf),E(H),Oh(),a=b.flags,0!==(a&65536)&&0===(a&128)?(b.flags=a&-65537|128,b):null;case 5:return Lh(b),null;case 13:E(M);a=b.memoizedState;if(null!==a&&null!==a.dehydrated){if(null===b.alternate)throw Error(p(340));Ig()}a=b.flags;return a&65536?(b.flags=a&-65537|128,b):null;case 19:return E(M),null;case 4:return Jh(),null;case 10:return Rg(b.type._context),null;case 22:case 23:return Ij(), +null;case 24:return null;default:return null}}var Kj=!1,U=!1,Lj="function"===typeof WeakSet?WeakSet:Set,V=null;function Mj(a,b){var c=a.ref;if(null!==c)if("function"===typeof c)try{c(null)}catch(d){W(a,b,d)}else c.current=null}function Nj(a,b,c){try{c()}catch(d){W(a,b,d)}}var Oj=!1; +function Pj(a,b){Cf=dd;a=Me();if(Ne(a)){if("selectionStart"in a)var c={start:a.selectionStart,end:a.selectionEnd};else a:{c=(c=a.ownerDocument)&&c.defaultView||window;var d=c.getSelection&&c.getSelection();if(d&&0!==d.rangeCount){c=d.anchorNode;var e=d.anchorOffset,f=d.focusNode;d=d.focusOffset;try{c.nodeType,f.nodeType}catch(F){c=null;break a}var g=0,h=-1,k=-1,l=0,m=0,q=a,r=null;b:for(;;){for(var y;;){q!==c||0!==e&&3!==q.nodeType||(h=g+e);q!==f||0!==d&&3!==q.nodeType||(k=g+d);3===q.nodeType&&(g+= +q.nodeValue.length);if(null===(y=q.firstChild))break;r=q;q=y}for(;;){if(q===a)break b;r===c&&++l===e&&(h=g);r===f&&++m===d&&(k=g);if(null!==(y=q.nextSibling))break;q=r;r=q.parentNode}q=y}c=-1===h||-1===k?null:{start:h,end:k}}else c=null}c=c||{start:0,end:0}}else c=null;Df={focusedElem:a,selectionRange:c};dd=!1;for(V=b;null!==V;)if(b=V,a=b.child,0!==(b.subtreeFlags&1028)&&null!==a)a.return=b,V=a;else for(;null!==V;){b=V;try{var n=b.alternate;if(0!==(b.flags&1024))switch(b.tag){case 0:case 11:case 15:break; +case 1:if(null!==n){var t=n.memoizedProps,J=n.memoizedState,x=b.stateNode,w=x.getSnapshotBeforeUpdate(b.elementType===b.type?t:Lg(b.type,t),J);x.__reactInternalSnapshotBeforeUpdate=w}break;case 3:var u=b.stateNode.containerInfo;1===u.nodeType?u.textContent="":9===u.nodeType&&u.documentElement&&u.removeChild(u.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(p(163));}}catch(F){W(b,b.return,F)}a=b.sibling;if(null!==a){a.return=b.return;V=a;break}V=b.return}n=Oj;Oj=!1;return n} +function Qj(a,b,c){var d=b.updateQueue;d=null!==d?d.lastEffect:null;if(null!==d){var e=d=d.next;do{if((e.tag&a)===a){var f=e.destroy;e.destroy=void 0;void 0!==f&&Nj(b,c,f)}e=e.next}while(e!==d)}}function Rj(a,b){b=b.updateQueue;b=null!==b?b.lastEffect:null;if(null!==b){var c=b=b.next;do{if((c.tag&a)===a){var d=c.create;c.destroy=d()}c=c.next}while(c!==b)}}function Sj(a){var b=a.ref;if(null!==b){var c=a.stateNode;switch(a.tag){case 5:a=c;break;default:a=c}"function"===typeof b?b(a):b.current=a}} +function Tj(a){var b=a.alternate;null!==b&&(a.alternate=null,Tj(b));a.child=null;a.deletions=null;a.sibling=null;5===a.tag&&(b=a.stateNode,null!==b&&(delete b[Of],delete b[Pf],delete b[of],delete b[Qf],delete b[Rf]));a.stateNode=null;a.return=null;a.dependencies=null;a.memoizedProps=null;a.memoizedState=null;a.pendingProps=null;a.stateNode=null;a.updateQueue=null}function Uj(a){return 5===a.tag||3===a.tag||4===a.tag} +function Vj(a){a:for(;;){for(;null===a.sibling;){if(null===a.return||Uj(a.return))return null;a=a.return}a.sibling.return=a.return;for(a=a.sibling;5!==a.tag&&6!==a.tag&&18!==a.tag;){if(a.flags&2)continue a;if(null===a.child||4===a.tag)continue a;else a.child.return=a,a=a.child}if(!(a.flags&2))return a.stateNode}} +function Wj(a,b,c){var d=a.tag;if(5===d||6===d)a=a.stateNode,b?8===c.nodeType?c.parentNode.insertBefore(a,b):c.insertBefore(a,b):(8===c.nodeType?(b=c.parentNode,b.insertBefore(a,c)):(b=c,b.appendChild(a)),c=c._reactRootContainer,null!==c&&void 0!==c||null!==b.onclick||(b.onclick=Bf));else if(4!==d&&(a=a.child,null!==a))for(Wj(a,b,c),a=a.sibling;null!==a;)Wj(a,b,c),a=a.sibling} +function Xj(a,b,c){var d=a.tag;if(5===d||6===d)a=a.stateNode,b?c.insertBefore(a,b):c.appendChild(a);else if(4!==d&&(a=a.child,null!==a))for(Xj(a,b,c),a=a.sibling;null!==a;)Xj(a,b,c),a=a.sibling}var X=null,Yj=!1;function Zj(a,b,c){for(c=c.child;null!==c;)ak(a,b,c),c=c.sibling} +function ak(a,b,c){if(lc&&"function"===typeof lc.onCommitFiberUnmount)try{lc.onCommitFiberUnmount(kc,c)}catch(h){}switch(c.tag){case 5:U||Mj(c,b);case 6:var d=X,e=Yj;X=null;Zj(a,b,c);X=d;Yj=e;null!==X&&(Yj?(a=X,c=c.stateNode,8===a.nodeType?a.parentNode.removeChild(c):a.removeChild(c)):X.removeChild(c.stateNode));break;case 18:null!==X&&(Yj?(a=X,c=c.stateNode,8===a.nodeType?Kf(a.parentNode,c):1===a.nodeType&&Kf(a,c),bd(a)):Kf(X,c.stateNode));break;case 4:d=X;e=Yj;X=c.stateNode.containerInfo;Yj=!0; +Zj(a,b,c);X=d;Yj=e;break;case 0:case 11:case 14:case 15:if(!U&&(d=c.updateQueue,null!==d&&(d=d.lastEffect,null!==d))){e=d=d.next;do{var f=e,g=f.destroy;f=f.tag;void 0!==g&&(0!==(f&2)?Nj(c,b,g):0!==(f&4)&&Nj(c,b,g));e=e.next}while(e!==d)}Zj(a,b,c);break;case 1:if(!U&&(Mj(c,b),d=c.stateNode,"function"===typeof d.componentWillUnmount))try{d.props=c.memoizedProps,d.state=c.memoizedState,d.componentWillUnmount()}catch(h){W(c,b,h)}Zj(a,b,c);break;case 21:Zj(a,b,c);break;case 22:c.mode&1?(U=(d=U)||null!== +c.memoizedState,Zj(a,b,c),U=d):Zj(a,b,c);break;default:Zj(a,b,c)}}function bk(a){var b=a.updateQueue;if(null!==b){a.updateQueue=null;var c=a.stateNode;null===c&&(c=a.stateNode=new Lj);b.forEach(function(b){var d=ck.bind(null,a,b);c.has(b)||(c.add(b),b.then(d,d))})}} +function dk(a,b){var c=b.deletions;if(null!==c)for(var d=0;de&&(e=g);d&=~f}d=e;d=B()-d;d=(120>d?120:480>d?480:1080>d?1080:1920>d?1920:3E3>d?3E3:4320>d?4320:1960*mk(d/1960))-d;if(10a?16:a;if(null===xk)var d=!1;else{a=xk;xk=null;yk=0;if(0!==(K&6))throw Error(p(331));var e=K;K|=4;for(V=a.current;null!==V;){var f=V,g=f.child;if(0!==(V.flags&16)){var h=f.deletions;if(null!==h){for(var k=0;kB()-gk?Lk(a,0):sk|=c);Ek(a,b)}function Zk(a,b){0===b&&(0===(a.mode&1)?b=1:(b=sc,sc<<=1,0===(sc&130023424)&&(sc=4194304)));var c=L();a=Zg(a,b);null!==a&&(Ac(a,b,c),Ek(a,c))}function vj(a){var b=a.memoizedState,c=0;null!==b&&(c=b.retryLane);Zk(a,c)} +function ck(a,b){var c=0;switch(a.tag){case 13:var d=a.stateNode;var e=a.memoizedState;null!==e&&(c=e.retryLane);break;case 19:d=a.stateNode;break;default:throw Error(p(314));}null!==d&&d.delete(b);Zk(a,c)}var Wk; +Wk=function(a,b,c){if(null!==a)if(a.memoizedProps!==b.pendingProps||Wf.current)Ug=!0;else{if(0===(a.lanes&c)&&0===(b.flags&128))return Ug=!1,zj(a,b,c);Ug=0!==(a.flags&131072)?!0:!1}else Ug=!1,I&&0!==(b.flags&1048576)&&ug(b,ng,b.index);b.lanes=0;switch(b.tag){case 2:var d=b.type;jj(a,b);a=b.pendingProps;var e=Yf(b,H.current);Tg(b,c);e=Xh(null,b,d,a,e,c);var f=bi();b.flags|=1;"object"===typeof e&&null!==e&&"function"===typeof e.render&&void 0===e.$$typeof?(b.tag=1,b.memoizedState=null,b.updateQueue= +null,Zf(d)?(f=!0,cg(b)):f=!1,b.memoizedState=null!==e.state&&void 0!==e.state?e.state:null,ah(b),e.updater=nh,b.stateNode=e,e._reactInternals=b,rh(b,d,a,c),b=kj(null,b,d,!0,f,c)):(b.tag=0,I&&f&&vg(b),Yi(null,b,e,c),b=b.child);return b;case 16:d=b.elementType;a:{jj(a,b);a=b.pendingProps;e=d._init;d=e(d._payload);b.type=d;e=b.tag=$k(d);a=Lg(d,a);switch(e){case 0:b=dj(null,b,d,a,c);break a;case 1:b=ij(null,b,d,a,c);break a;case 11:b=Zi(null,b,d,a,c);break a;case 14:b=aj(null,b,d,Lg(d.type,a),c);break a}throw Error(p(306, +d,""));}return b;case 0:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:Lg(d,e),dj(a,b,d,e,c);case 1:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:Lg(d,e),ij(a,b,d,e,c);case 3:a:{lj(b);if(null===a)throw Error(p(387));d=b.pendingProps;f=b.memoizedState;e=f.element;bh(a,b);gh(b,d,null,c);var g=b.memoizedState;d=g.element;if(f.isDehydrated)if(f={element:d,isDehydrated:!1,cache:g.cache,pendingSuspenseBoundaries:g.pendingSuspenseBoundaries,transitions:g.transitions},b.updateQueue.baseState= +f,b.memoizedState=f,b.flags&256){e=Ki(Error(p(423)),b);b=mj(a,b,d,c,e);break a}else if(d!==e){e=Ki(Error(p(424)),b);b=mj(a,b,d,c,e);break a}else for(yg=Lf(b.stateNode.containerInfo.firstChild),xg=b,I=!0,zg=null,c=Ch(b,null,d,c),b.child=c;c;)c.flags=c.flags&-3|4096,c=c.sibling;else{Ig();if(d===e){b=$i(a,b,c);break a}Yi(a,b,d,c)}b=b.child}return b;case 5:return Kh(b),null===a&&Eg(b),d=b.type,e=b.pendingProps,f=null!==a?a.memoizedProps:null,g=e.children,Ef(d,e)?g=null:null!==f&&Ef(d,f)&&(b.flags|=32), +hj(a,b),Yi(a,b,g,c),b.child;case 6:return null===a&&Eg(b),null;case 13:return pj(a,b,c);case 4:return Ih(b,b.stateNode.containerInfo),d=b.pendingProps,null===a?b.child=Bh(b,null,d,c):Yi(a,b,d,c),b.child;case 11:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:Lg(d,e),Zi(a,b,d,e,c);case 7:return Yi(a,b,b.pendingProps,c),b.child;case 8:return Yi(a,b,b.pendingProps.children,c),b.child;case 12:return Yi(a,b,b.pendingProps.children,c),b.child;case 10:a:{d=b.type._context;e=b.pendingProps;f=b.memoizedProps; +g=e.value;G(Mg,d._currentValue);d._currentValue=g;if(null!==f)if(He(f.value,g)){if(f.children===e.children&&!Wf.current){b=$i(a,b,c);break a}}else for(f=b.child,null!==f&&(f.return=b);null!==f;){var h=f.dependencies;if(null!==h){g=f.child;for(var k=h.firstContext;null!==k;){if(k.context===d){if(1===f.tag){k=ch(-1,c&-c);k.tag=2;var l=f.updateQueue;if(null!==l){l=l.shared;var m=l.pending;null===m?k.next=k:(k.next=m.next,m.next=k);l.pending=k}}f.lanes|=c;k=f.alternate;null!==k&&(k.lanes|=c);Sg(f.return, +c,b);h.lanes|=c;break}k=k.next}}else if(10===f.tag)g=f.type===b.type?null:f.child;else if(18===f.tag){g=f.return;if(null===g)throw Error(p(341));g.lanes|=c;h=g.alternate;null!==h&&(h.lanes|=c);Sg(g,c,b);g=f.sibling}else g=f.child;if(null!==g)g.return=f;else for(g=f;null!==g;){if(g===b){g=null;break}f=g.sibling;if(null!==f){f.return=g.return;g=f;break}g=g.return}f=g}Yi(a,b,e.children,c);b=b.child}return b;case 9:return e=b.type,d=b.pendingProps.children,Tg(b,c),e=Vg(e),d=d(e),b.flags|=1,Yi(a,b,d,c), +b.child;case 14:return d=b.type,e=Lg(d,b.pendingProps),e=Lg(d.type,e),aj(a,b,d,e,c);case 15:return cj(a,b,b.type,b.pendingProps,c);case 17:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:Lg(d,e),jj(a,b),b.tag=1,Zf(d)?(a=!0,cg(b)):a=!1,Tg(b,c),ph(b,d,e),rh(b,d,e,c),kj(null,b,d,!0,a,c);case 19:return yj(a,b,c);case 22:return ej(a,b,c)}throw Error(p(156,b.tag));};function Gk(a,b){return ac(a,b)} +function al(a,b,c,d){this.tag=a;this.key=c;this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null;this.index=0;this.ref=null;this.pendingProps=b;this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null;this.mode=d;this.subtreeFlags=this.flags=0;this.deletions=null;this.childLanes=this.lanes=0;this.alternate=null}function Bg(a,b,c,d){return new al(a,b,c,d)}function bj(a){a=a.prototype;return!(!a||!a.isReactComponent)} +function $k(a){if("function"===typeof a)return bj(a)?1:0;if(void 0!==a&&null!==a){a=a.$$typeof;if(a===Da)return 11;if(a===Ga)return 14}return 2} +function wh(a,b){var c=a.alternate;null===c?(c=Bg(a.tag,b,a.key,a.mode),c.elementType=a.elementType,c.type=a.type,c.stateNode=a.stateNode,c.alternate=a,a.alternate=c):(c.pendingProps=b,c.type=a.type,c.flags=0,c.subtreeFlags=0,c.deletions=null);c.flags=a.flags&14680064;c.childLanes=a.childLanes;c.lanes=a.lanes;c.child=a.child;c.memoizedProps=a.memoizedProps;c.memoizedState=a.memoizedState;c.updateQueue=a.updateQueue;b=a.dependencies;c.dependencies=null===b?null:{lanes:b.lanes,firstContext:b.firstContext}; +c.sibling=a.sibling;c.index=a.index;c.ref=a.ref;return c} +function yh(a,b,c,d,e,f){var g=2;d=a;if("function"===typeof a)bj(a)&&(g=1);else if("string"===typeof a)g=5;else a:switch(a){case ya:return Ah(c.children,e,f,b);case za:g=8;e|=8;break;case Aa:return a=Bg(12,c,b,e|2),a.elementType=Aa,a.lanes=f,a;case Ea:return a=Bg(13,c,b,e),a.elementType=Ea,a.lanes=f,a;case Fa:return a=Bg(19,c,b,e),a.elementType=Fa,a.lanes=f,a;case Ia:return qj(c,e,f,b);default:if("object"===typeof a&&null!==a)switch(a.$$typeof){case Ba:g=10;break a;case Ca:g=9;break a;case Da:g=11; +break a;case Ga:g=14;break a;case Ha:g=16;d=null;break a}throw Error(p(130,null==a?a:typeof a,""));}b=Bg(g,c,b,e);b.elementType=a;b.type=d;b.lanes=f;return b}function Ah(a,b,c,d){a=Bg(7,a,d,b);a.lanes=c;return a}function qj(a,b,c,d){a=Bg(22,a,d,b);a.elementType=Ia;a.lanes=c;a.stateNode={isHidden:!1};return a}function xh(a,b,c){a=Bg(6,a,null,b);a.lanes=c;return a} +function zh(a,b,c){b=Bg(4,null!==a.children?a.children:[],a.key,b);b.lanes=c;b.stateNode={containerInfo:a.containerInfo,pendingChildren:null,implementation:a.implementation};return b} +function bl(a,b,c,d,e){this.tag=b;this.containerInfo=a;this.finishedWork=this.pingCache=this.current=this.pendingChildren=null;this.timeoutHandle=-1;this.callbackNode=this.pendingContext=this.context=null;this.callbackPriority=0;this.eventTimes=zc(0);this.expirationTimes=zc(-1);this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0;this.entanglements=zc(0);this.identifierPrefix=d;this.onRecoverableError=e;this.mutableSourceEagerHydrationData= +null}function cl(a,b,c,d,e,f,g,h,k){a=new bl(a,b,c,h,k);1===b?(b=1,!0===f&&(b|=8)):b=0;f=Bg(3,null,null,b);a.current=f;f.stateNode=a;f.memoizedState={element:d,isDehydrated:c,cache:null,transitions:null,pendingSuspenseBoundaries:null};ah(f);return a}function dl(a,b,c){var d=3 { + + + +function checkDCE() { + /* global __REACT_DEVTOOLS_GLOBAL_HOOK__ */ + if ( + typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ === 'undefined' || + typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE !== 'function' + ) { + return; + } + if (false) {} + try { + // Verify that the code above has been dead code eliminated (DCE'd). + __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(checkDCE); + } catch (err) { + // DevTools shouldn't crash React, no matter what. + // We should still report in case we break this code. + console.error(err); + } +} + +if (true) { + // DCE check should happen before ReactDOM bundle executes so that + // DevTools can report bad minification during injection. + checkDCE(); + module.exports = __webpack_require__(516); +} else {} + + +/***/ }), + +/***/ 28: +/***/ ((__unused_webpack_module, exports) => { + +/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +var l=Symbol.for("react.element"),n=Symbol.for("react.portal"),p=Symbol.for("react.fragment"),q=Symbol.for("react.strict_mode"),r=Symbol.for("react.profiler"),t=Symbol.for("react.provider"),u=Symbol.for("react.context"),v=Symbol.for("react.forward_ref"),w=Symbol.for("react.suspense"),x=Symbol.for("react.memo"),y=Symbol.for("react.lazy"),z=Symbol.iterator;function A(a){if(null===a||"object"!==typeof a)return null;a=z&&a[z]||a["@@iterator"];return"function"===typeof a?a:null} +var B={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},C=Object.assign,D={};function E(a,b,e){this.props=a;this.context=b;this.refs=D;this.updater=e||B}E.prototype.isReactComponent={}; +E.prototype.setState=function(a,b){if("object"!==typeof a&&"function"!==typeof a&&null!=a)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,a,b,"setState")};E.prototype.forceUpdate=function(a){this.updater.enqueueForceUpdate(this,a,"forceUpdate")};function F(){}F.prototype=E.prototype;function G(a,b,e){this.props=a;this.context=b;this.refs=D;this.updater=e||B}var H=G.prototype=new F; +H.constructor=G;C(H,E.prototype);H.isPureReactComponent=!0;var I=Array.isArray,J=Object.prototype.hasOwnProperty,K={current:null},L={key:!0,ref:!0,__self:!0,__source:!0}; +function M(a,b,e){var d,c={},k=null,h=null;if(null!=b)for(d in void 0!==b.ref&&(h=b.ref),void 0!==b.key&&(k=""+b.key),b)J.call(b,d)&&!L.hasOwnProperty(d)&&(c[d]=b[d]);var g=arguments.length-2;if(1===g)c.children=e;else if(1 { + + + +if (true) { + module.exports = __webpack_require__(28); +} else {} + + +/***/ }), + +/***/ 328: +/***/ ((__unused_webpack_module, exports) => { + +/** + * @license React + * scheduler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +function f(a,b){var c=a.length;a.push(b);a:for(;0>>1,e=a[d];if(0>>1;dg(C,c))ng(x,C)?(a[d]=x,a[n]=c,d=n):(a[d]=C,a[m]=c,d=m);else if(ng(x,c))a[d]=x,a[n]=c,d=n;else break a}}return b} +function g(a,b){var c=a.sortIndex-b.sortIndex;return 0!==c?c:a.id-b.id}if("object"===typeof performance&&"function"===typeof performance.now){var l=performance;exports.unstable_now=function(){return l.now()}}else{var p=Date,q=p.now();exports.unstable_now=function(){return p.now()-q}}var r=[],t=[],u=1,v=null,y=3,z=!1,A=!1,B=!1,D="function"===typeof setTimeout?setTimeout:null,E="function"===typeof clearTimeout?clearTimeout:null,F="undefined"!==typeof setImmediate?setImmediate:null; +"undefined"!==typeof navigator&&void 0!==navigator.scheduling&&void 0!==navigator.scheduling.isInputPending&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function G(a){for(var b=h(t);null!==b;){if(null===b.callback)k(t);else if(b.startTime<=a)k(t),b.sortIndex=b.expirationTime,f(r,b);else break;b=h(t)}}function H(a){B=!1;G(a);if(!A)if(null!==h(r))A=!0,I(J);else{var b=h(t);null!==b&&K(H,b.startTime-a)}} +function J(a,b){A=!1;B&&(B=!1,E(L),L=-1);z=!0;var c=y;try{G(b);for(v=h(r);null!==v&&(!(v.expirationTime>b)||a&&!M());){var d=v.callback;if("function"===typeof d){v.callback=null;y=v.priorityLevel;var e=d(v.expirationTime<=b);b=exports.unstable_now();"function"===typeof e?v.callback=e:v===h(r)&&k(r);G(b)}else k(r);v=h(r)}if(null!==v)var w=!0;else{var m=h(t);null!==m&&K(H,m.startTime-b);w=!1}return w}finally{v=null,y=c,z=!1}}var N=!1,O=null,L=-1,P=5,Q=-1; +function M(){return exports.unstable_now()-Qa||125d?(a.sortIndex=c,f(t,a),null===h(r)&&a===h(t)&&(B?(E(L),L=-1):B=!0,K(H,c-d))):(a.sortIndex=e,f(r,a),A||z||(A=!0,I(J)));return a}; +exports.unstable_shouldYield=M;exports.unstable_wrapCallback=function(a){var b=y;return function(){var c=y;y=b;try{return a.apply(this,arguments)}finally{y=c}}}; + + +/***/ }), + +/***/ 712: +/***/ ((module, __unused_webpack_exports, __webpack_require__) => { + + + +if (true) { + module.exports = __webpack_require__(328); +} else {} + + +/***/ }) + +}]); \ No newline at end of file diff --git a/browser/components/pocket/content/panels/js/vendor.bundle.js.LICENSE.txt b/browser/components/pocket/content/panels/js/vendor.bundle.js.LICENSE.txt new file mode 100644 index 0000000000..d5075bd9ca --- /dev/null +++ b/browser/components/pocket/content/panels/js/vendor.bundle.js.LICENSE.txt @@ -0,0 +1,32 @@ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ + +/** @license React v0.20.2 + * scheduler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v17.0.2 + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v17.0.2 + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ diff --git a/browser/components/pocket/content/panels/license.txt b/browser/components/pocket/content/panels/license.txt new file mode 100644 index 0000000000..7f3f806bab --- /dev/null +++ b/browser/components/pocket/content/panels/license.txt @@ -0,0 +1,35 @@ + +Unless where otherwise noted, the following license applies to the files +within this directory and descendents of this directory. + +POCKET MARKS + +Notwithstanding the permitted uses of the Software (as defined below) pursuant +to the license set forth below, "Pocket," "Read It Later" and the Pocket icon +and logos (collectively, the “Pocket Marks”) are registered and common law +trademarks of Read It Later, Inc. This means that, while you have considerable +freedom to redistribute and modify the Software, there are tight restrictions +on your ability to use the Pocket Marks. This license does not grant you any +rights to use the Pocket Marks except as they are embodied in the Software. + +--- + +SOFTWARE + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/browser/components/pocket/content/panels/saved.html b/browser/components/pocket/content/panels/saved.html new file mode 100644 index 0000000000..7042a59c9e --- /dev/null +++ b/browser/components/pocket/content/panels/saved.html @@ -0,0 +1,20 @@ + + + + + + + + + Pocket: Page Saved + + + + + + + + diff --git a/browser/components/pocket/content/panels/signup.html b/browser/components/pocket/content/panels/signup.html new file mode 100644 index 0000000000..349d3432c3 --- /dev/null +++ b/browser/components/pocket/content/panels/signup.html @@ -0,0 +1,21 @@ + + + + + + + + + + Pocket: Sign Up + + + + + + + + diff --git a/browser/components/pocket/content/panels/style-guide.html b/browser/components/pocket/content/panels/style-guide.html new file mode 100644 index 0000000000..cdc3368178 --- /dev/null +++ b/browser/components/pocket/content/panels/style-guide.html @@ -0,0 +1,34 @@ + + + + + + + + + Pocket: Style Guide + + + + + + + +
    +
    +
    + + +
    +
    +

    + Save To Pocket:
    + Style Guide +

    +
    +
    + + diff --git a/browser/components/pocket/content/pktApi.sys.mjs b/browser/components/pocket/content/pktApi.sys.mjs new file mode 100644 index 0000000000..16d0948b36 --- /dev/null +++ b/browser/components/pocket/content/pktApi.sys.mjs @@ -0,0 +1,901 @@ +/* + * LICENSE + * + * POCKET MARKS + * + * Notwithstanding the permitted uses of the Software (as defined below) pursuant to the license set forth below, "Pocket," "Read It Later" and the Pocket icon and logos (collectively, the “Pocket Marks”) are registered and common law trademarks of Read It Later, Inc. This means that, while you have considerable freedom to redistribute and modify the Software, there are tight restrictions on your ability to use the Pocket Marks. This license does not grant you any rights to use the Pocket Marks except as they are embodied in the Software. + * + * --- + * + * SOFTWARE + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/* + * Pocket API module + * + * Public API Documentation: http://getpocket.com/developer/ + * + * + * Definition of keys stored in preferences to preserve user state: + * premium_status: Current premium status for logged in user if available + * Can be 0 for no premium and 1 for premium + * latestSince: Last timestamp a save happened + * tags: All tags for logged in user + * usedTags: All used tags from within the extension sorted by recency + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "gCookieFirstPartyIsolate", + "privacy.firstparty.isolate", + false +); + +const DB_NAME = "SaveToPocket"; +const STORE_NAME = "pktAPI"; +const DB_VERSION = 1; +const RECENT_SAVES_UPDATE_TIME = 5 * 60 * 1000; // 30 minutes + +/** + * Create a new connection to the database. + */ +function openDatabase() { + return lazy.IndexedDB.open(DB_NAME, DB_VERSION, db => { + db.createObjectStore(STORE_NAME); + }); +} + +/** + * Cache the database connection so that it is shared among multiple operations. + */ +let databasePromise; +function getDatabase() { + if (!databasePromise) { + databasePromise = openDatabase(); + } + return databasePromise; +} + +export var pktApi = (function () { + /** + * Configuration + */ + + // Base url for all api calls + var pocketSiteHost = Services.prefs.getCharPref("extensions.pocket.site"); // getpocket.com + + /** + * + */ + var prefBranch = Services.prefs.getBranch("extensions.pocket.settings."); + + /** + * Helper + */ + + var extend = function (out) { + out = out || {}; + + for (var i = 1; i < arguments.length; i++) { + if (!arguments[i]) { + continue; + } + + for (var key in arguments[i]) { + if (arguments[i].hasOwnProperty(key)) { + out[key] = arguments[i][key]; + } + } + } + return out; + }; + + var parseJSON = function (jsonString) { + try { + var o = JSON.parse(jsonString); + + // Handle non-exception-throwing cases: + // Neither JSON.parse(false) or JSON.parse(1234) throw errors, hence the type-checking, + // but... JSON.parse(null) returns 'null', and typeof null === "object", + // so we must check for that, too. + if (o && typeof o === "object" && o !== null) { + return o; + } + } catch (e) {} + + return undefined; + }; + + /** + * Settings + */ + + /** + * Wrapper for different plattforms to get settings for a given key + * @param {string} key A string containing the name of the key you want to + * retrieve the value of + * @return {string} String containing the value of the key. If the key + * does not exist, null is returned + */ + function getSetting(key) { + // TODO : Move this to sqlite or a local file so it's not editable (and is safer) + // https://developer.mozilla.org/en-US/Add-ons/Overlay_Extensions/XUL_School/Local_Storage + + if (!prefBranch.prefHasUserValue(key)) { + return undefined; + } + + return prefBranch.getStringPref(key); + } + + /** + * Wrapper for different plattforms to set a value for a given key in settings + * @param {string} key A string containing the name of the key you want + * to create/update. + * @param {string} value String containing the value you want to give + * the key you are creating/updating. + */ + function setSetting(key, value) { + // TODO : Move this to sqlite or a local file so it's not editable (and is safer) + // https://developer.mozilla.org/en-US/Add-ons/Overlay_Extensions/XUL_School/Local_Storage + + if (!value) { + prefBranch.clearUserPref(key); + } else { + // We use complexValue as tags can have utf-8 characters in them + prefBranch.setStringPref(key, value); + } + } + + /** + * Auth + */ + + /* + * All cookies from the Pocket domain + * The return format: { cookieName:cookieValue, cookieName:cookieValue, ... } + */ + function getCookiesFromPocket() { + var cookies = {}; + let oa = {}; + if (lazy.gCookieFirstPartyIsolate) { + oa.firstPartyDomain = pocketSiteHost; + } + oa.privateBrowsingId = lazy.PrivateBrowsingUtils.permanentPrivateBrowsing + ? 1 + : 0; + for (let cookie of Services.cookies.getCookiesFromHost( + pocketSiteHost, + oa + )) { + if (cookie.host === pocketSiteHost) { + cookies[cookie.name] = cookie.value; + } + } + return cookies; + } + + /** + * Returns access token or undefined if no logged in user was found + * @return {string | undefined} Access token for logged in user user + */ + function getAccessToken() { + var pocketCookies = getCookiesFromPocket(); + + // If no cookie was found just return undefined + if (typeof pocketCookies.ftv1 === "undefined") { + return undefined; + } + + // Check if a new user logged in in the meantime and clearUserData if so + var sessionId = pocketCookies.fsv1; + var lastSessionId = getSetting("fsv1"); + if (sessionId !== lastSessionId) { + clearUserData(); + setSetting("fsv1", sessionId); + } + + // Return access token + return pocketCookies.ftv1; + } + + /** + * Get the current premium status of the user + * @return {number | undefined} Premium status of user + */ + function getPremiumStatus() { + var premiumStatus = getSetting("premium_status"); + if (typeof premiumStatus === "undefined") { + // Premium status is not in settings try get it from cookie + var pocketCookies = getCookiesFromPocket(); + premiumStatus = pocketCookies.ps; + } + return premiumStatus; + } + + /** + * Helper method to check if a user is premium or not + * @return {Boolean} Boolean if user is premium or not + */ + function isPremiumUser() { + return getPremiumStatus() == 1; + } + + /** + * Returns users logged in status + * @return {Boolean} Users logged in status + */ + function isUserLoggedIn() { + return typeof getAccessToken() !== "undefined"; + } + + /** + * API + */ + + /** + * Helper function for executing api requests. It mainly configures the + * ajax call with default values like type, headers or dataType for an api call. + * This function is for internal usage only. + * @param {Object} options + * Possible keys: + * - {string} path: This should be the Pocket API + * endpoint to call. For example providing the path + * "/get" would result in a call to getpocket.com/v3/get + * - {Object|undefined} data: Gets passed on to the jQuery ajax + * call as data parameter + * - {function(Object data, XMLHttpRequest xhr) | undefined} success: + * A function to be called if the request succeeds. + * - {function(Error errorThrown, XMLHttpRequest xhr) | undefined} error: + * A function to be called if the request fails. + * @return {Boolean} Returns Boolean whether the api call started sucessfully + * + */ + function apiRequest(options, useBFF = false) { + let baseAPIUrl; + let oAuthConsumerKey; + + if (!useBFF) { + baseAPIUrl = `https://${Services.prefs.getCharPref( + "extensions.pocket.api" + )}/v3`; + + oAuthConsumerKey = Services.prefs.getCharPref( + "extensions.pocket.oAuthConsumerKey" + ); + } else { + baseAPIUrl = `https://${lazy.NimbusFeatures.saveToPocket.getVariable( + "bffApi" + )}/desktop/v1`; + + oAuthConsumerKey = lazy.NimbusFeatures.saveToPocket.getVariable( + "oAuthConsumerKeyBff" + ); + } + + if (typeof options === "undefined" || typeof options.path === "undefined") { + return false; + } + + var url = baseAPIUrl + options.path; + var data = options.data || {}; + data.locale_lang = Services.locale.appLocaleAsBCP47; + data.consumer_key = oAuthConsumerKey; + + var request = new XMLHttpRequest(); + + if (!useBFF) { + request.open("POST", url, true); + } else { + request.open("GET", url, true); + } + + request.onreadystatechange = function (e) { + if (request.readyState == 4) { + // "done" is a completed XHR regardless of success/error: + if (options.done) { + options.done(); + } + + if (request.status === 200) { + // There could still be an error if the response is no valid json + // or does not have status = 1 + var response = parseJSON(request.response); + + // BFF doesn't return an appended `status` code in the returned data + if (options.success && response && (response.status == 1 || useBFF)) { + options.success(response, request); + return; + } + } + + // Handle error case + if (options.error) { + // In case the user did revoke the access token or it's not + // valid anymore clear the user data + if (request.status === 401) { + clearUserData(); + } + + // Handle error message + var errorMessage; + if (request.status !== 200) { + errorMessage = + request.getResponseHeader("X-Error") || request.statusText; + errorMessage = JSON.parse('"' + errorMessage + '"'); + } + var error = { message: errorMessage }; + options.error(error, request); + } + } + }; + + // Set headers + request.setRequestHeader( + "Content-Type", + "application/x-www-form-urlencoded; charset=UTF-8" + ); + request.setRequestHeader("X-Accept", " application/json"); + + if (useBFF) { + let cookies = getCookiesFromPocket(); + let serializedCookies = ``; + + for (const key in cookies) { + serializedCookies += `${key}=${cookies[key]}; `; + } + + serializedCookies = serializedCookies.substring( + 0, + serializedCookies.length - 2 + ); + + request.setRequestHeader("Cookie", serializedCookies); + request.setRequestHeader("consumer_key", oAuthConsumerKey); + } + + // Serialize and Fire off the request + var str = []; + for (var p in data) { + if (data.hasOwnProperty(p)) { + str.push(encodeURIComponent(p) + "=" + encodeURIComponent(data[p])); + } + } + + request.send(str.join("&")); + + return true; + } + + /** + * Cleans all settings for the previously logged in user + */ + function clearUserData() { + // Clear stored information + setSetting("premium_status", undefined); + setSetting("latestSince", undefined); + setSetting("tags", undefined); + // An old pref that is no longer used, + // but the user data may still exist on some profiles. + // So best to clean it up just in case. + // Can probably remove this line in the future. + setSetting("usedTags", undefined); + + setSetting("fsv1", undefined); + + _clearRecentSavesCache(); + } + + /** + * Add a new link to Pocket + * @param {string} url URL of the link + * @param {Object | undefined} options Can provide a string-based title, a + * `success` callback and an `error` callback. + * @return {Boolean} Returns Boolean whether the api call started sucessfully + */ + function addLink(url, options) { + var since = getSetting("latestSince"); + var accessToken = getAccessToken(); + + var sendData = { + access_token: accessToken, + url, + since: since ? since : 0, + }; + + if (options.title) { + sendData.title = options.title; + } + + return apiRequest({ + path: "/firefox/save", + data: sendData, + success(data) { + // Update premium status, tags and since + var tags = data.tags; + if (typeof tags !== "undefined" && Array.isArray(tags)) { + // If a tagslist is in the response replace the tags + setSetting("tags", JSON.stringify(data.tags)); + } + + // Update premium status + var premiumStatus = data.premium_status; + if (typeof premiumStatus !== "undefined") { + // If a premium_status is in the response replace the premium_status + setSetting("premium_status", premiumStatus); + } + + // Save since value for further requests + setSetting("latestSince", data.since); + + // Define variant for ho2 + if (data.flags) { + var showHo2 = + Services.locale.appLocaleAsBCP47 === "en-US" + ? data.flags.show_ffx_mobile_prompt + : "control"; + setSetting("test.ho2", showHo2); + } + data.ho2 = getSetting("test.ho2"); + + _expireRecentSavesCache(); + if (options.success) { + options.success.apply(options, Array.apply(null, arguments)); + } + }, + error: options.error, + }); + } + + /** + * Get a preview for saved URL + * @param {string} url URL of the link + * @param {Object | undefined} options Can provide a `success` callback and an `error` callback. + * @return {Boolean} Returns Boolean whether the api call started sucessfully + */ + function getArticleInfo(url, options) { + return apiRequest({ + path: "/getItemPreview", + data: { + access_token: getAccessToken(), + url, + }, + success(data) { + if (options.success) { + options.success.apply(options, Array.apply(null, arguments)); + } + }, + error: options.error, + done: options.done, + }); + } + + /** + * Request a email for mobile apps + * @param {Object | undefined} options Can provide a `success` callback and an `error` callback. + * @return {Boolean} Returns Boolean whether the api call started sucessfully + */ + function getMobileDownload(options) { + return apiRequest({ + path: "/firefox/get-app", + data: { + access_token: getAccessToken(), + }, + success(data) { + if (options.success) { + options.success.apply(options, Array.apply(null, arguments)); + } + }, + error: options.error, + }); + } + + /** + * Delete an item identified by item id from the users list + * @param {string} itemId The id from the item we want to remove + * @param {Object | undefined} options Can provide an actionInfo object with + * further data to send to the API. Can + * have success and error callbacks + * @return {Boolean} Returns Boolean whether the api call started sucessfully + */ + function deleteItem(itemId, options) { + var action = { + action: "delete", + item_id: itemId, + }; + return sendAction(action, options); + } + + /** + * Archive an item identified by item id from the users list + * @param {string} itemId The id from the item we want to archive + * @param {Object | undefined} options Can provide an actionInfo object with + * further data to send to the API. Can + * have success and error callbacks + * @return {Boolean} Returns Boolean whether the api call started sucessfully + */ + function archiveItem(itemId, options) { + var action = { + action: "archive", + item_id: itemId, + }; + return sendAction(action, options); + } + + /** + * General function to send all kinds of actions like adding of links or + * removing of items via the API + * @param {Object} action Action object + * @param {Object | undefined} options Can provide an actionInfo object + * with further data to send to the + * API. Can have success and error + * callbacks + * @return {Boolean} Returns Boolean whether the api call started sucessfully + */ + function sendAction(action, options) { + // Options can have an 'actionInfo' object. This actionInfo object gets + // passed through to the action object that will be send to the API endpoint + if (typeof options.actionInfo !== "undefined") { + action = extend(action, options.actionInfo); + } + return sendActions([action], options); + } + + /** + * General function to send all kinds of actions like adding of links or + * removing of items via the API + * @param {Array} actions Array of action objects + * @param {Object | undefined} options Can have success and error callbacks + * @return {Boolean} Returns Boolean whether the api call started sucessfully + */ + function sendActions(actions, options) { + return apiRequest({ + path: "/send", + data: { + access_token: getAccessToken(), + actions: JSON.stringify(actions), + }, + success: options.success, + error: options.error, + }); + } + + /** + * Handling Tags + */ + + /** + * Add tags to the item identified by the url. Also updates the used tags + * list + * @param {string} itemId The item identifier by item id + * @param {Array} tags Tags adding to the item + * @param {Object | undefined} options Can provide an actionInfo object with + * further data to send to the API. Can + * have success and error callbacks + * @return {Boolean} Returns Boolean whether the api call started sucessfully + */ + function addTagsToItem(itemId, tags, options) { + return addTags({ item_id: itemId }, tags, options); + } + + /** + * Add tags to the item identified by the url. Also updates the used tags + * list + * @param {string} url The item identifier by url + * @param {Array} tags Tags adding to the item + * @param {Object} options Can provide an actionInfo object with further + * data to send to the API. Can have success and error + * callbacks + * @return {Boolean} Returns Boolean whether the api call started sucessfully + */ + function addTagsToURL(url, tags, options) { + return addTags({ url }, tags, options); + } + + /** + * Helper function to execute the add tags api call. Will be used from addTagsToURL + * and addTagsToItem but not exposed outside + * @param {string} actionPart Specific action part to add to action + * @param {Array} tags Tags adding to the item + * @param {Object | undefined} options Can provide an actionInfo object with + * further data to send to the API. Can + * have success and error callbacks + * @return {Boolean} Returns Boolean whether the api call started sucessfully + */ + function addTags(actionPart, tags, options) { + if (tags.length) { + addRecentTags(tags); + } + + // Tags add action + var action = { + action: "tags_add", + tags, + }; + action = extend(action, actionPart); + + // Execute the action + return sendAction(action, options); + } + + /** + * Return all cached tags and used tags. + */ + function getTags() { + var tagsFromSettings = function () { + var tagsJSON = getSetting("tags"); + if (typeof tagsJSON !== "undefined") { + return JSON.parse(tagsJSON); + } + return []; + }; + + return { + tags: tagsFromSettings(), + }; + } + + /** + * Return all recent tags. + */ + function getRecentTags() { + var tagsFromSettings = function () { + var tagsJSON = getSetting("recentTags"); + + if (typeof tagsJSON !== "undefined") { + let parsedTags; + + try { + parsedTags = JSON.parse(tagsJSON); + } catch { + parsedTags = []; + } + + return parsedTags; + } + + return []; + }; + + return { + recentTags: tagsFromSettings(), + }; + } + + /** + * Store recently used tags. + * @param {Array} tags Newly used tags to store + */ + function addRecentTags(tags) { + var newRecentTags = tags || []; + var cachedRecentTags = getRecentTags()?.recentTags; + var mergedRecentTags = []; + + cachedRecentTags.forEach(tag => { + if (!newRecentTags.includes(tag)) { + mergedRecentTags.push(tag); + } + }); + + mergedRecentTags = [...newRecentTags, ...mergedRecentTags]; + + // update recent tags pref to store + setSetting("recentTags", JSON.stringify(mergedRecentTags)); + } + + /** + * Fetch suggested tags for a given item id + * @param {string} itemId Item id of + * @param {Object | undefined} options Can provide an actionInfo object + * with further data to send to the API. + * Can have success and error callbacks + * @return {Boolean} Returns Boolean whether the api call started sucessfully + */ + function getSuggestedTagsForItem(itemId, options) { + return getSuggestedTags({ item_id: itemId }, options); + } + + /** + * Fetch suggested tags for a given URL + * @param {string} url (required) The item identifier by url + * @param {Object} options Can provide an actionInfo object with further + * data to send to the API. Can have success and error + * callbacks + * @return {Boolean} Returns Boolean whether the api call started sucessfully + */ + function getSuggestedTagsForURL(url, options) { + return getSuggestedTags({ url }, options); + } + + /** + * Helper function to get suggested tags + * @return {Boolean} Returns Boolean whether the api call started sucessfully + */ + function getSuggestedTags(data, options) { + data = data || {}; + options = options || {}; + + data.access_token = getAccessToken(); + + return apiRequest({ + path: "/getSuggestedTags", + data, + success: options.success, + error: options.error, + }); + } + + /** + * Helper function to get a user's pocket stories + * @return {Boolean} Returns Boolean whether the api call started sucessfully + */ + function retrieve(data = {}, options = {}) { + const requestData = Object.assign({}, data, { + access_token: getAccessToken(), + }); + + const useBFF = + lazy.NimbusFeatures.saveToPocket.getVariable("bffRecentSaves"); + + return apiRequest( + { + path: useBFF ? `/recent-saves?count=${data.count}` : `/firefox/get`, + data: requestData, + success: options.success, + error: options.error, + }, + useBFF + ); // Use BFF + } + + async function _getRecentSavesCache() { + const db = await getDatabase(); + return db.objectStore(STORE_NAME, "readonly").get("recentSaves"); + } + async function _setRecentSavesCache(data) { + const db = await getDatabase(); + db.objectStore(STORE_NAME, "readwrite").put(data, "recentSaves"); + } + // Clears the cache time, so the next get forces an update. + async function _expireRecentSavesCache() { + const cache = await _getRecentSavesCache(); + _setRecentSavesCache({ + ...cache, + lastUpdated: 0, + }); + } + // Clears the cache, for when a new user logs in. + async function _clearRecentSavesCache() { + const db = await getDatabase(); + db.objectStore(STORE_NAME, "readwrite").delete("recentSaves"); + } + + async function getRecentSavesCache() { + // Get cache + const cache = await _getRecentSavesCache(); + // Check age + if ( + cache?.lastUpdated && + Date.now() - cache.lastUpdated < RECENT_SAVES_UPDATE_TIME + ) { + // Return cache if it's not too old. + return cache.list; + } + return null; + } + + async function getRecentSaves(options = {}) { + pktApi.retrieve( + { count: 4 }, + { + success(data) { + const useBFF = + lazy.NimbusFeatures.saveToPocket.getVariable("bffRecentSaves"); + + // Don't try to parse bad or missing data + if ( + useBFF && + (typeof data !== `object` || typeof data?.data !== `object`) + ) { + return; + } + + try { + let list = useBFF ? [] : data.list; + + if (useBFF) { + // Transform BFF list item schema to existing api schema + data.data.forEach((item, index) => { + list[index] = { + item_id: item.id, + id: item.id, // This can probably be deprecated when the old API is + resolved_url: item.resolvedUrl, + given_url: item.givenUrl, + resolved_title: item.title, + excerpt: item.excerpt, + word_count: item.wordCount, + time_to_read: item.timeToRead, + top_image_url: item.topImageUrl, + }; + }); + } else { + // We want these to show up in the same order as they saved, + // so we need to do some work and sort. + list = Object.values(list) + .map(item => ({ + ...item, + id: parseInt(item.item_id || item.resolved_id, 10), + time_added: parseInt(item.time_added), + })) + .sort((a, b) => b.time_added - a.time_added); + } + + // Cache results + const results = { + lastUpdated: Date.now(), + list, + }; + + _setRecentSavesCache(results); + options.success?.(results.list); + } catch { + // If parsing fails, just leave existing recent saves cache intact + } + }, + error(error) { + options.error?.(error); + }, + } + ); + } + + /** + * Public functions + */ + return { + isUserLoggedIn, + clearUserData, + addLink, + deleteItem, + archiveItem, + addTagsToItem, + addTagsToURL, + getTags, + getRecentTags, + isPremiumUser, + getSuggestedTagsForItem, + getSuggestedTagsForURL, + retrieve, + getRecentSavesCache, + getRecentSaves, + getArticleInfo, + getMobileDownload, + }; +})(); diff --git a/browser/components/pocket/content/pktTelemetry.sys.mjs b/browser/components/pocket/content/pktTelemetry.sys.mjs new file mode 100644 index 0000000000..1591e828a2 --- /dev/null +++ b/browser/components/pocket/content/pktTelemetry.sys.mjs @@ -0,0 +1,74 @@ +/* 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, { + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + pktApi: "chrome://pocket/content/pktApi.sys.mjs", +}); + +const PREF_IMPRESSION_ID = "browser.newtabpage.activity-stream.impressionId"; + +export var pktTelemetry = { + get impressionId() { + if (!this._impressionId) { + this._impressionId = this.getOrCreateImpressionId(); + } + return this._impressionId; + }, + + // Sets or gets the impression id that's use for Pocket impressions. + // The impression id cannot be tied to a client id. + // This is the same impression id used in newtab pocket impressions. + getOrCreateImpressionId() { + let impressionId = Services.prefs.getStringPref(PREF_IMPRESSION_ID, ""); + + if (!impressionId) { + impressionId = String(Services.uuid.generateUUID()); + Services.prefs.setStringPref(PREF_IMPRESSION_ID, impressionId); + } + return impressionId; + }, + + _profileCreationDate() { + return ( + lazy.TelemetryEnvironment.currentEnvironment.profile.resetDate || + lazy.TelemetryEnvironment.currentEnvironment.profile.creationDate + ); + }, + + /** + * Records the provided data and common pocket-button data to Glean, + * then submits it all in a pocket-button ping. + * + * @param eventAction - A string like "click" + * @param eventSource - A string like "save_button" + * @param eventPosition - (optional) A 0-based index. + * If falsey and not 0, is coalesced to undefined. + * @param model - (optional) An identifier for the machine learning model + * used to generate the recommendations like "vec-bestarticle" + */ + submitPocketButtonPing( + eventAction, + eventSource, + eventPosition = undefined, + model = undefined + ) { + eventPosition = eventPosition || eventPosition === 0 ? 0 : undefined; + Glean.pocketButton.impressionId.set(this.impressionId); + Glean.pocketButton.pocketLoggedInStatus.set(lazy.pktApi.isUserLoggedIn()); + Glean.pocketButton.profileCreationDate.set(this._profileCreationDate()); + + Glean.pocketButton.eventAction.set(eventAction); + Glean.pocketButton.eventSource.set(eventSource); + if (eventPosition !== undefined) { + Glean.pocketButton.eventPosition.set(eventPosition); + } + if (model !== undefined) { + Glean.pocketButton.model.set(model); + } + + GleanPings.pocketButton.submit(); + }, +}; diff --git a/browser/components/pocket/content/pktUI.js b/browser/components/pocket/content/pktUI.js new file mode 100644 index 0000000000..60b7e3bcc3 --- /dev/null +++ b/browser/components/pocket/content/pktUI.js @@ -0,0 +1,564 @@ +/* + * LICENSE + * + * POCKET MARKS + * + * Notwithstanding the permitted uses of the Software (as defined below) pursuant to the license set forth below, "Pocket," "Read It Later" and the Pocket icon and logos (collectively, the “Pocket Marks”) are registered and common law trademarks of Read It Later, Inc. This means that, while you have considerable freedom to redistribute and modify the Software, there are tight restrictions on your ability to use the Pocket Marks. This license does not grant you any rights to use the Pocket Marks except as they are embodied in the Software. + * + * --- + * + * SOFTWARE + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/* + * Pocket UI module + * + * Handles interactions with Pocket buttons, panels and menus. + * + */ + +// TODO : Get the toolbar icons from Firefox's build (Nikki needs to give us a red saved icon) +// TODO : [needs clarificaiton from Fx] Firefox's plan was to hide Pocket from context menus until the user logs in. Now that it's an extension I'm wondering if we still need to do this. +// TODO : [needs clarificaiton from Fx] Reader mode (might be a something they need to do since it's in html, need to investigate their code) +// TODO : [needs clarificaiton from Fx] Move prefs within pktApi.s to sqlite or a local file so it's not editable (and is safer) +// TODO : [nice to have] - Immediately save, buffer the actions in a local queue and send (so it works offline, works like our native extensions) + +/* eslint-disable no-shadow */ +/* eslint-env mozilla/browser-window */ + +ChromeUtils.defineESModuleGetters(this, { + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + pktApi: "chrome://pocket/content/pktApi.sys.mjs", + pktTelemetry: "chrome://pocket/content/pktTelemetry.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + ReaderMode: "resource://gre/modules/ReaderMode.sys.mjs", + SaveToPocket: "chrome://pocket/content/SaveToPocket.sys.mjs", +}); + +const POCKET_HOME_PREF = "extensions.pocket.showHome"; + +var pktUI = (function () { + let _titleToSave = ""; + let _urlToSave = ""; + + // Initial sizes are only here to help visual load jank before the panel is ready. + const initialPanelSize = { + signup: { + height: 315, + width: 328, + }, + saved: { + height: 110, + width: 350, + }, + home: { + height: 251, + width: 328, + }, + // This is for non English sizes, this is not for an AB experiment. + home_no_topics: { + height: 86, + width: 328, + }, + }; + + var pocketHomePref; + + function initPrefs() { + pocketHomePref = Services.prefs.getBoolPref(POCKET_HOME_PREF); + } + initPrefs(); + + // -- Communication to API -- // + + /** + * Either save or attempt to log the user in + */ + function tryToSaveCurrentPage() { + tryToSaveUrl(getCurrentUrl(), getCurrentTitle()); + } + + function tryToSaveUrl(url, title) { + // Validate input parameter + if (typeof url !== "undefined" && url.startsWith("about:reader?url=")) { + url = ReaderMode.getOriginalUrl(url); + } + + // If the user is not logged in, show the logged-out state to prompt them to authenticate + if (!pktApi.isUserLoggedIn()) { + showSignUp(); + return; + } + + _titleToSave = title; + _urlToSave = url; + // If the user is logged in, and the url is valid, go ahead and save the current page + if (!pocketHomePref || isValidURL()) { + saveAndShowConfirmation(); + return; + } + showPocketHome(); + } + + // -- Panel UI -- // + + /** + * Show the sign-up panel + */ + function showSignUp() { + getFirefoxAccountSignedInUser(function (userdata) { + showPanel( + "about:pocket-signup?" + + "emailButton=" + + NimbusFeatures.saveToPocket.getVariable("emailButton"), + `signup` + ); + }); + } + + /** + * Show the logged-out state / sign-up panel + */ + function saveAndShowConfirmation() { + getFirefoxAccountSignedInUser(function (userdata) { + showPanel( + "about:pocket-saved?premiumStatus=" + + (pktApi.isPremiumUser() ? "1" : "0") + + "&fxasignedin=" + + (typeof userdata == "object" && userdata !== null ? "1" : "0"), + `saved` + ); + }); + } + + /** + * Show the Pocket home panel state + */ + function showPocketHome() { + const hideRecentSaves = + NimbusFeatures.saveToPocket.getVariable("hideRecentSaves"); + const locale = getUILocale(); + let panel = `home_no_topics`; + if (locale.startsWith("en-")) { + panel = `home`; + } + showPanel(`about:pocket-home?hiderecentsaves=${hideRecentSaves}`, panel); + } + + /** + * Open a generic panel + */ + function showPanel(urlString, panel) { + const locale = getUILocale(); + const options = initialPanelSize[panel]; + + resizePanel({ + width: options.width, + height: options.height, + }); + + const saveToPocketExperiment = ExperimentAPI.getExperimentMetaData({ + featureId: "saveToPocket", + }); + + const saveToPocketRollout = ExperimentAPI.getRolloutMetaData({ + featureId: "saveToPocket", + }); + + const pocketNewtabExperiment = ExperimentAPI.getExperimentMetaData({ + featureId: "pocketNewtab", + }); + + const pocketNewtabRollout = ExperimentAPI.getRolloutMetaData({ + featureId: "pocketNewtab", + }); + + // We want to know if the user is in a Pocket related experiment or rollout, + // but we have 2 Pocket related features, so we prioritize the saveToPocket feature, + // and experiments over rollouts. + const experimentMetaData = + saveToPocketExperiment || + pocketNewtabExperiment || + saveToPocketRollout || + pocketNewtabRollout; + + let utmSource = "firefox_pocket_save_button"; + let utmCampaign = experimentMetaData?.slug; + let utmContent = experimentMetaData?.branch?.slug; + + const url = new URL(urlString); + // A set of params shared across all panels. + url.searchParams.append("utmSource", utmSource); + if (utmCampaign && utmContent) { + url.searchParams.append("utmCampaign", utmCampaign); + url.searchParams.append("utmContent", utmContent); + } + url.searchParams.append("locale", locale); + + // We don't have to hide and show the panel again if it's already shown + // as if the user tries to click again on the toolbar button the overlay + // will close instead of the button will be clicked + var frame = getPanelFrame(); + + // Load the frame + frame.setAttribute("src", url.href); + } + + function onShowSignup() { + // Ensure opening the signup panel clears the icon state from any previous sessions. + SaveToPocket.itemDeleted(); + pktTelemetry.submitPocketButtonPing("click", "save_button"); + } + + async function onShowHome() { + pktTelemetry.submitPocketButtonPing("click", "home_button"); + + if (!NimbusFeatures.saveToPocket.getVariable("hideRecentSaves")) { + let recentSaves = await pktApi.getRecentSavesCache(); + if (recentSaves) { + // We have cache, so we can use those. + pktUIMessaging.sendMessageToPanel("PKT_renderRecentSaves", recentSaves); + } else { + // Let the client know we're loading fresh recs. + pktUIMessaging.sendMessageToPanel( + "PKT_loadingRecentSaves", + recentSaves + ); + // We don't have cache, so fetch fresh stories. + pktApi.getRecentSaves({ + success(data) { + pktUIMessaging.sendMessageToPanel("PKT_renderRecentSaves", data); + }, + error(error) { + pktUIMessaging.sendErrorMessageToPanel( + "PKT_renderRecentSaves", + error + ); + }, + }); + } + } + } + + function onShowSaved() { + var saveLinkMessageId = "PKT_saveLink"; + + // Send error message for invalid url + if (!isValidURL()) { + let errorData = { + localizedKey: "pocket-panel-saved-error-only-links", + }; + pktUIMessaging.sendErrorMessageToPanel(saveLinkMessageId, errorData); + return; + } + + // Check online state + if (!navigator.onLine) { + let errorData = { + localizedKey: "pocket-panel-saved-error-no-internet", + }; + pktUIMessaging.sendErrorMessageToPanel(saveLinkMessageId, errorData); + return; + } + + pktTelemetry.submitPocketButtonPing("click", "save_button"); + + // Add url + var options = { + success(data, request) { + var item = data.item; + var ho2 = data.ho2; + var accountState = data.account_state; + var displayName = data.display_name; + var successResponse = { + status: "success", + accountState, + displayName, + item, + ho2, + }; + pktUIMessaging.sendMessageToPanel(saveLinkMessageId, successResponse); + SaveToPocket.itemSaved(); + + if (!NimbusFeatures.saveToPocket.getVariable("hideRecentSaves")) { + // Articles saved for the first time (by anyone) won't have a resolved_id + if (item?.resolved_id && item?.resolved_id !== "0") { + pktApi.getArticleInfo(item.resolved_url, { + success(data) { + pktUIMessaging.sendMessageToPanel( + "PKT_articleInfoFetched", + data + ); + }, + done() { + pktUIMessaging.sendMessageToPanel( + "PKT_getArticleInfoAttempted" + ); + }, + }); + } else { + pktUIMessaging.sendMessageToPanel("PKT_getArticleInfoAttempted"); + } + } + }, + error(error, request) { + // If user is not authorized show singup page + if (request.status === 401) { + showSignUp(); + return; + } + + // For unknown server errors, use a generic catch-all error message + let errorData = { + localizedKey: "pocket-panel-saved-error-generic", + }; + + // Send error message to panel + pktUIMessaging.sendErrorMessageToPanel(saveLinkMessageId, errorData); + }, + }; + + // Add title if given + if (typeof _titleToSave !== "undefined") { + options.title = _titleToSave; + } + + // Send the link + pktApi.addLink(_urlToSave, options); + } + + /** + * Resize the panel + * options = { + * width: , + * height: , + * } + */ + function resizePanel(options = {}) { + var frame = getPanelFrame(); + + // Set an explicit size, panel will adapt. + frame.style.width = options.width + "px"; + frame.style.height = options.height + "px"; + } + + // -- Browser Navigation -- // + + /** + * Open a new tab with a given url and notify the frame panel that it was opened + */ + + function openTabWithUrl(url, aTriggeringPrincipal, aCsp) { + let recentWindow = Services.wm.getMostRecentWindow("navigator:browser"); + if (!recentWindow) { + console.error("Pocket: No open browser windows to openTabWithUrl"); + return; + } + closePanel(); + + // If the user is in permanent private browsing than this is not an issue, + // since the current window will always share the same cookie jar as the other + // windows. + if ( + !PrivateBrowsingUtils.isWindowPrivate(recentWindow) || + PrivateBrowsingUtils.permanentPrivateBrowsing + ) { + recentWindow.openWebLinkIn(url, "tab", { + triggeringPrincipal: aTriggeringPrincipal, + csp: aCsp, + }); + return; + } + + for (let win of Services.wm.getEnumerator("navigator:browser")) { + if (!PrivateBrowsingUtils.isWindowPrivate(win)) { + win.openWebLinkIn(url, "tab", { + triggeringPrincipal: aTriggeringPrincipal, + csp: aCsp, + }); + return; + } + } + + // If there were no non-private windows opened already. + recentWindow.openWebLinkIn(url, "window", { + triggeringPrincipal: aTriggeringPrincipal, + csp: aCsp, + }); + } + + // Open a new tab with a given url + function onOpenTabWithUrl(data, contentPrincipal, csp) { + try { + urlSecurityCheck( + data.url, + contentPrincipal, + Services.scriptSecurityManager.DISALLOW_INHERIT_PRINCIPAL + ); + } catch (ex) { + return; + } + + // We don't track every click, only clicks with a known source. + if (data.source) { + const { position, source, model } = data; + pktTelemetry.submitPocketButtonPing("click", source, position, model); + } + + var url = data.url; + openTabWithUrl(url, contentPrincipal, csp); + } + + // Open a new tab with a Pocket story url + function onOpenTabWithPocketUrl(data, contentPrincipal, csp) { + try { + urlSecurityCheck( + data.url, + contentPrincipal, + Services.scriptSecurityManager.DISALLOW_INHERIT_PRINCIPAL + ); + } catch (ex) { + return; + } + + const { url, position, model } = data; + // Check to see if we need to and can fire valid telemetry. + if (model && (position || position === 0)) { + pktTelemetry.submitPocketButtonPing( + "click", + "on_save_recs", + position, + model + ); + } + + openTabWithUrl(url, contentPrincipal, csp); + } + + // -- Helper Functions -- // + + function getCurrentUrl() { + return gBrowser.currentURI.spec; + } + + function getCurrentTitle() { + return gBrowser.contentTitle; + } + + function closePanel() { + // The panel frame doesn't exist until the Pocket panel is showing. + // So we ensure it is open before attempting to hide it. + getPanelFrame()?.closest("panel")?.hidePopup(); + } + + var toolbarPanelFrame; + + function setToolbarPanelFrame(frame) { + toolbarPanelFrame = frame; + } + + function getPanelFrame() { + return toolbarPanelFrame; + } + + function isValidURL() { + return ( + typeof _urlToSave !== "undefined" && + (_urlToSave.startsWith("http") || _urlToSave.startsWith("https")) + ); + } + + function getFirefoxAccountSignedInUser(callback) { + fxAccounts + .getSignedInUser() + .then(userData => { + callback(userData); + }) + .then(null, error => { + callback(); + }); + } + + function getUILocale() { + return Services.locale.appLocaleAsBCP47; + } + + /** + * Public functions + */ + return { + setToolbarPanelFrame, + getPanelFrame, + initPrefs, + showPanel, + getUILocale, + + openTabWithUrl, + onOpenTabWithUrl, + onOpenTabWithPocketUrl, + onShowSaved, + onShowSignup, + onShowHome, + + tryToSaveUrl, + tryToSaveCurrentPage, + resizePanel, + closePanel, + }; +})(); + +// -- Communication to Background -- // +var pktUIMessaging = (function () { + /** + * Send a message to the panel's frame + */ + function sendMessageToPanel(messageId, payload) { + var panelFrame = pktUI.getPanelFrame(); + if (!panelFrame) { + console.warn("Pocket panel frame is undefined"); + return; + } + + const aboutPocketActor = + panelFrame?.browsingContext?.currentWindowGlobal?.getActor("AboutPocket"); + + // Send message to panel + aboutPocketActor?.sendAsyncMessage(messageId, payload); + } + + /** + * Helper function to package an error object and send it to the panel + * frame as a message response + */ + function sendErrorMessageToPanel(messageId, error) { + var errorResponse = { status: "error", error }; + sendMessageToPanel(messageId, errorResponse); + } + + /** + * Public + */ + return { + sendMessageToPanel, + sendErrorMessageToPanel, + }; +})(); diff --git a/browser/components/pocket/jar.mn b/browser/components/pocket/jar.mn new file mode 100644 index 0000000000..07ffbb1948 --- /dev/null +++ b/browser/components/pocket/jar.mn @@ -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/. + +browser.jar: +% content pocket %content/pocket/ contentaccessible=yes + content/pocket/pktApi.sys.mjs (content/pktApi.sys.mjs) + content/pocket/pktTelemetry.sys.mjs (content/pktTelemetry.sys.mjs) + content/pocket/pktUI.js (content/pktUI.js) + content/pocket/Pocket.sys.mjs (content/Pocket.sys.mjs) + content/pocket/SaveToPocket.sys.mjs (content/SaveToPocket.sys.mjs) + content/pocket/panels/css (content/panels/css/*) + content/pocket/panels/fonts (content/panels/fonts/*) + content/pocket/panels/img (content/panels/img/*) + content/pocket/panels/home.html (content/panels/home.html) + content/pocket/panels/saved.html (content/panels/saved.html) + content/pocket/panels/signup.html (content/panels/signup.html) + content/pocket/panels/style-guide.html (content/panels/style-guide.html) + content/pocket/panels/js/vendor.bundle.js (content/panels/js/vendor.bundle.js) + content/pocket/panels/js/main.bundle.js (content/panels/js/main.bundle.js) + content/pocket/panels/js/home/entry.js (content/panels/js/home/entry.js) + content/pocket/panels/js/saved/entry.js (content/panels/js/saved/entry.js) + content/pocket/panels/js/signup/entry.js (content/panels/js/signup/entry.js) + content/pocket/panels/js/style-guide/entry.js (content/panels/js/style-guide/entry.js) diff --git a/browser/components/pocket/metrics.yaml b/browser/components/pocket/metrics.yaml new file mode 100644 index 0000000000..a166933e48 --- /dev/null +++ b/browser/components/pocket/metrics.yaml @@ -0,0 +1,144 @@ +# 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/. + +# Adding a new metric? We have docs for that! +# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html + +--- +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 +$tags: + - 'Firefox :: Pocket' + +pocket.button: + impression_id: + type: uuid + description: > + A UUID representing this profile. + This isn't client_id, nor can it be used to link to a client_id. + This also means it should never be sent in a ping with a client_id. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_sensitivity: + - technical + notification_emails: + - chutten@mozilla.com + - kdemtchouk@mozilla.com + - sdowne@mozilla.com + expires: never + send_in_pings: [ pocket-button ] + + pocket_logged_in_status: + type: boolean + description: > + Whether there was a logged-in Pocket account in the Pocket-Firefox + integration at the point in time this action occurred. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_sensitivity: + - technical + notification_emails: + - chutten@mozilla.com + - kdemtchouk@mozilla.com + - sdowne@mozilla.com + expires: never + send_in_pings: [ pocket-button ] + + profile_creation_date: + type: quantity + unit: days_since_jan_1_1970 + description: > + The days since Jan 1, 1970 that the oldest file in the profile dir was + modified. Or created. Or just the day and time of the first thing to ask + for the profile age called in. Or something earlier or later than that. + + You may not want to use this. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_sensitivity: + - technical + notification_emails: + - chutten@mozilla.com + - kdemtchouk@mozilla.com + - sdowne@mozilla.com + expires: never + send_in_pings: [ pocket-button ] + + event_action: + type: string + description: > + The action that was taken, like "click" or... actually, it might only + ever be "click". + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_sensitivity: + - interaction + notification_emails: + - chutten@mozilla.com + - kdemtchouk@mozilla.com + - sdowne@mozilla.com + expires: never + send_in_pings: [ pocket-button ] + + event_source: + type: string + description: > + The source of the taken action, like "save_button", "home_button", + "on_save_recs", or the like. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_sensitivity: + - interaction + notification_emails: + - chutten@mozilla.com + - kdemtchouk@mozilla.com + - sdowne@mozilla.com + expires: never + send_in_pings: [ pocket-button ] + + event_position: + type: quantity + unit: index + description: > + 0-based index of the item on which the action was performed. + Not always provided. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_sensitivity: + - interaction + notification_emails: + - chutten@mozilla.com + - kdemtchouk@mozilla.com + - sdowne@mozilla.com + expires: never + send_in_pings: [ pocket-button ] + + model: + type: string + description: > + A string that identifies the ML model (if any) used to generate on-save + recommendations. Like "doc2vec-incremental-best-article-pubspread". + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_sensitivity: + - interaction + notification_emails: + - chutten@mozilla.com + - kdemtchouk@mozilla.com + - sdowne@mozilla.com + expires: never + send_in_pings: [ pocket-button ] diff --git a/browser/components/pocket/moz.build b/browser/components/pocket/moz.build new file mode 100644 index 0000000000..3a5e2456d2 --- /dev/null +++ b/browser/components/pocket/moz.build @@ -0,0 +1,16 @@ +# -*- 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/. + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Pocket") + +BROWSER_CHROME_MANIFESTS += [ + "test/browser.toml", + "test/unit/browser.toml", + "test/unit/panels/browser.toml", +] + +JAR_MANIFESTS += ["jar.mn"] diff --git a/browser/components/pocket/package-lock.json b/browser/components/pocket/package-lock.json new file mode 100644 index 0000000000..b573263156 --- /dev/null +++ b/browser/components/pocket/package-lock.json @@ -0,0 +1,6537 @@ +{ + "name": "save-to-pocket-ff", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "save-to-pocket-ff", + "version": "1.0.0", + "license": "MPL-2.0", + "dependencies": { + "react": "18.2.0", + "react-dom": "18.2.0" + }, + "devDependencies": { + "@babel/core": "7.18.6", + "@babel/preset-react": "7.18.6", + "babel-loader": "8.2.5", + "chokidar-cli": "3.0.0", + "npm-run-all": "4.1.5", + "sass": "1.53.0", + "webpack": "5.90.1", + "webpack-cli": "4.10.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", + "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", + "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.18.6.tgz", + "integrity": "sha512-cQbWBpxcbbs/IUredIPkHiAGULLV8iwgNRMFzvbhEXISp4f3rUUXE5+TIw6KwUWUR3DwyI6gmBRnmAtYaWehwQ==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.18.6", + "@babel/generator": "^7.18.6", + "@babel/helper-compilation-targets": "^7.18.6", + "@babel/helper-module-transforms": "^7.18.6", + "@babel/helpers": "^7.18.6", + "@babel/parser": "^7.18.6", + "@babel/template": "^7.18.6", + "@babel/traverse": "^7.18.6", + "@babel/types": "^7.18.6", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", + "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.23.6", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", + "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", + "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-validator-option": "^7.23.5", + "browserslist": "^4.22.2", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", + "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.9.tgz", + "integrity": "sha512-87ICKgU5t5SzOT7sBMfCOZQ2rHjRU+Pcb9BoILMYz600W6DkVRLFBPwQ18gwUVvggqXivaUakpnxWQGbpywbBQ==", + "dev": true, + "dependencies": { + "@babel/template": "^7.23.9", + "@babel/traverse": "^7.23.9", + "@babel/types": "^7.23.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", + "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.23.3.tgz", + "integrity": "sha512-EB2MELswq55OHUoRZLGg/zC7QWUKfNLpE57m/S2yr1uEneIgsTgrSzXP3NXEsMkVn76OlaVVnzN+ugObuYGwhg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.23.3.tgz", + "integrity": "sha512-GnvhtVfA2OAtzdX58FJxU19rhoGeQzyVndw3GgtdECQvQFXPEZIOVULHVZGAYmOgmqjXpVpfocAbSjh99V/Fqw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.23.4.tgz", + "integrity": "sha512-5xOpoPguCZCRbo/JeHlloSkTA8Bld1J/E1/kLfD1nsuiW1m8tduTA1ERCgIZokDflX/IBzKcqR3l7VlRgiIfHA==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-jsx": "^7.23.3", + "@babel/types": "^7.23.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.22.5.tgz", + "integrity": "sha512-bDhuzwWMuInwCYeDeMzyi7TaBgRQei6DqxhbyniL7/VG4RSS7HtSL2QbY4eESy1KJqlWt8g3xeEBGPuo+XqC8A==", + "dev": true, + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.23.3.tgz", + "integrity": "sha512-qMFdSS+TUhB7Q/3HVPnEdYJDQIk57jkntAwSuz9xfSE4n+3I+vHYCli3HoHawN1Z3RfCz/y1zXA/JXjG6cVImQ==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.18.6.tgz", + "integrity": "sha512-zXr6atUmyYdiWRVLOZahakYmOBHtWc2WGCkP8PYTgZi0iJXDY2CN180TdrIW4OGOAdLc7TifzDIvtx6izaRIzg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/helper-validator-option": "^7.18.6", + "@babel/plugin-transform-react-display-name": "^7.18.6", + "@babel/plugin-transform-react-jsx": "^7.18.6", + "@babel/plugin-transform-react-jsx-development": "^7.18.6", + "@babel/plugin-transform-react-pure-annotations": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.23.9.tgz", + "integrity": "sha512-+xrD2BWLpvHKNmX2QbpdpsBaWnRxahMwJjO+KZk2JOElj5nSmKezyS1B4u+QbHMTX69t4ukm6hh9lsYQ7GHCKA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.23.5", + "@babel/parser": "^7.23.9", + "@babel/types": "^7.23.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.9.tgz", + "integrity": "sha512-I/4UJ9vs90OkBtY6iiiTORVMyIhJ4kAVmsKo9KFc8UOxMeUfi2hvtIBsET5u9GizXE6/GFSuKCTNfgCswuEjRg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.9", + "@babel/types": "^7.23.9", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.9.tgz", + "integrity": "sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", + "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", + "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@types/eslint": { + "version": "8.56.2", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.2.tgz", + "integrity": "sha512-uQDwm1wFHmbBbCZCqAlq6Do9LYwByNZHWzXppSnay9SuwJ+VRbjkbLABer54kcPnMSlG6Fdiy2yaFXm/z9Z5gw==", + "dev": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.11.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz", + "integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", + "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", + "dev": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", + "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", + "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", + "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", + "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", + "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", + "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/helper-wasm-section": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-opt": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6", + "@webassemblyjs/wast-printer": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", + "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", + "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", + "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", + "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.2.0.tgz", + "integrity": "sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==", + "dev": true, + "peerDependencies": { + "webpack": "4.x.x || 5.x.x", + "webpack-cli": "4.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.5.0.tgz", + "integrity": "sha512-e8tSXZpw2hPl2uMJY6fsMswaok5FdlGNRTktvFk2sD8RjH0hE2+XistawJx1vmKteh4NmGmNUrp+Tb2w+udPcQ==", + "dev": true, + "dependencies": { + "envinfo": "^7.7.3" + }, + "peerDependencies": { + "webpack-cli": "4.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.7.0.tgz", + "integrity": "sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==", + "dev": true, + "peerDependencies": { + "webpack-cli": "4.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-assertions": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", + "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "dev": true, + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.6.tgz", + "integrity": "sha512-j1QzY8iPNPG4o4xmO3ptzpRxTciqD3MgEHtifP/YnJpIo58Xu+ne4BejlbkuaLfXn/nz6HFiw29bLpj2PNMdGg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-loader": { + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.5.tgz", + "integrity": "sha512-OSiFfH89LrEMiWd4pLNqGz4CwJDtbs2ZVc+iGu2HrkRfPxId9F2anQj38IxWpmRfsUY0aBZYi1EFcd3mhtRMLQ==", + "dev": true, + "dependencies": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^2.0.0", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + }, + "engines": { + "node": ">= 8.9" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "webpack": ">=2" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.3.tgz", + "integrity": "sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001580", + "electron-to-chromium": "^1.4.648", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.13" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/call-bind": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.6.tgz", + "integrity": "sha512-Mj50FLHtlsoVfRfnHaZvyrooHcrlceNZdL/QBvJJVd9Ta55qCQK0gs4ss2oZDeV9zFCs6ewzYgVE5yfVmfFpVg==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.3", + "set-function-length": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001585", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001585.tgz", + "integrity": "sha512-yr2BWR1yLXQ8fMpdS/4ZZXpseBgE7o4g41x3a6AJOqZuOi+iE/WdJYAuZ6Y95i4Ohd2Y+9MzIWRR+uGABH4s3Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar-cli": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chokidar-cli/-/chokidar-cli-3.0.0.tgz", + "integrity": "sha512-xVW+Qeh7z15uZRxHOkP93Ux8A0xbPzwK4GaqD8dQOYc34TlkqUhVSS59fK36DOp5WdJlrRzlYSy02Ht99FjZqQ==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.2", + "lodash.debounce": "^4.0.8", + "lodash.throttle": "^4.1.1", + "yargs": "^13.3.0" + }, + "bin": { + "chokidar": "index.js" + }, + "engines": { + "node": ">= 8.10.0" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "dependencies": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/cross-spawn/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.2.tgz", + "integrity": "sha512-SRtsSqsDbgpJBbW3pABMCOt6rQyeM8s8RiyeSN8jYG8sYmt/kGJejbydttUsnDs1tadr19tvhT4ShwMyoqAm4g==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.2", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.4.664", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.664.tgz", + "integrity": "sha512-k9VKKSkOSNPvSckZgDDl/IQx45E1quMjX8QfLzUsAs/zve8AyFDK+ByRynSP/OfEfryiKHpQeMf00z0leLCc3A==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", + "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/envinfo": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.11.1.tgz", + "integrity": "sha512-8PiZgZNIB4q/Lw4AhOvAfB/ityHAd2bli3lESSWmWSzSsl5dKpy5N1d1Rfkd2teq/g9xN90lc6o98DOjMeYHpg==", + "dev": true, + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", + "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.5", + "es-set-tostringtag": "^2.0.1", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.2", + "get-symbol-description": "^1.0.0", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0", + "internal-slot": "^1.0.5", + "is-array-buffer": "^3.0.2", + "is-callable": "^1.2.7", + "is-negative-zero": "^2.0.2", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.12", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", + "safe-regex-test": "^1.0.0", + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", + "typed-array-length": "^1.0.4", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz", + "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==", + "dev": true + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", + "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2", + "has-tostringtag": "^1.0.0", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "bin": { + "flat": "cli.js" + } + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-symbol-description": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", + "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "node_modules/immutable": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz", + "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==", + "dev": true + }, + "node_modules/import-local": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dev": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/internal-slot": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", + "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "dev": true + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true, + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rechoir": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", + "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", + "dev": true, + "dependencies": { + "resolve": "^1.9.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "set-function-name": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.0.tgz", + "integrity": "sha512-ZdQ0Jeb9Ofti4hbt5lX3T2JcAamT9hfzYU1MNB+z/jaEbB6wfFfPIR/zEORmZqobkCCJhSjodobH6WHNmJ97dg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "get-intrinsic": "^1.2.2", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safe-regex-test": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-regex": "^1.1.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sass": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.53.0.tgz", + "integrity": "sha512-zb/oMirbKhUgRQ0/GFz8TSAwRq2IlR29vOUJZOx0l8sV+CkHUfHa4u5nqrG+1VceZp7Jfj59SVW9ogdhTvJDcQ==", + "dev": true, + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/schema-utils": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true + }, + "node_modules/set-function-length": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", + "integrity": "sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.2", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.5.tgz", + "integrity": "sha512-QcgiIWV4WV7qWExbN5llt6frQB/lBven9pqliLXfGPB+K9ZYXxDozp0wLkHS24kWCm+6YXH/f0HhnObZnZOBnQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.4.0.tgz", + "integrity": "sha512-hcjppoJ68fhxA/cjbN4T8N6uCUejN8yFw69ttpqtBeCbF3u13n7mb31NB9jKwGTTWWnt9IbRA/mf1FprYS8wfw==", + "dev": true + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.17", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz", + "integrity": "sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==", + "dev": true + }, + "node_modules/string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "dependencies": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/string.prototype.padend": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.5.tgz", + "integrity": "sha512-DOB27b/2UTTD+4myKUFh+/fXWcu/UDyASIXfg+7VzoCNNGOfWvoyU/x5pvVHr++ztyt/oSYI1BcWBBG/hmlNjA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/terser": { + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.27.0.tgz", + "integrity": "sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", + "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.20", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.26.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.1.tgz", + "integrity": "sha512-RSqu1UEuSlrBhHTWC8O9FnPjOduNs4M7rJ4pRKoEjtx1zUNOPN2sSXHLDX+Y2WPbHIxbvg4JFo2DNAEfPIKWoQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", + "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", + "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "is-typed-array": "^1.1.9" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/watchpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", + "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "dev": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.90.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.90.1.tgz", + "integrity": "sha512-SstPdlAC5IvgFnhiRok8hqJo/+ArAbNv7rhU4fnWGHNVfN59HSQFaxZDSAL3IFG2YmqxuRs+IU33milSxbPlog==", + "dev": true, + "dependencies": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^1.0.5", + "@webassemblyjs/ast": "^1.11.5", + "@webassemblyjs/wasm-edit": "^1.11.5", + "@webassemblyjs/wasm-parser": "^1.11.5", + "acorn": "^8.7.1", + "acorn-import-assertions": "^1.9.0", + "browserslist": "^4.21.10", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.15.0", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.9", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.0", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", + "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", + "dev": true, + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^1.2.0", + "@webpack-cli/info": "^1.5.0", + "@webpack-cli/serve": "^1.7.0", + "colorette": "^2.0.14", + "commander": "^7.0.0", + "cross-spawn": "^7.0.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^2.2.0", + "rechoir": "^0.7.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "4.x.x || 5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "@webpack-cli/migrate": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/webpack-cli/node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/webpack-cli/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/webpack-cli/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/webpack-cli/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/webpack-cli/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "dev": true, + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "dev": true + }, + "node_modules/which-typed-array": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.14.tgz", + "integrity": "sha512-VnXFiIW8yNn9kIHN88xvZ4yOWchftKDsRJ8fEPacX/wl1lOvBrhsJ/OeJCXq7B0AaijRuqgzSKalJoPk+D8MPg==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.6", + "call-bind": "^1.0.5", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true + }, + "node_modules/wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "dev": true, + "dependencies": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + } + }, + "node_modules/yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "dev": true, + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "engines": { + "node": ">=4" + } + } + }, + "dependencies": { + "@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@babel/code-frame": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", + "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "dev": true, + "requires": { + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" + } + }, + "@babel/compat-data": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", + "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "dev": true + }, + "@babel/core": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.18.6.tgz", + "integrity": "sha512-cQbWBpxcbbs/IUredIPkHiAGULLV8iwgNRMFzvbhEXISp4f3rUUXE5+TIw6KwUWUR3DwyI6gmBRnmAtYaWehwQ==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.18.6", + "@babel/generator": "^7.18.6", + "@babel/helper-compilation-targets": "^7.18.6", + "@babel/helper-module-transforms": "^7.18.6", + "@babel/helpers": "^7.18.6", + "@babel/parser": "^7.18.6", + "@babel/template": "^7.18.6", + "@babel/traverse": "^7.18.6", + "@babel/types": "^7.18.6", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + } + }, + "@babel/generator": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", + "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", + "dev": true, + "requires": { + "@babel/types": "^7.23.6", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + } + }, + "@babel/helper-annotate-as-pure": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", + "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", + "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-validator-option": "^7.23.5", + "browserslist": "^4.22.2", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + } + }, + "@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true + }, + "@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "requires": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dev": true, + "requires": { + "@babel/types": "^7.22.15" + } + }, + "@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", + "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "dev": true + }, + "@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "dev": true + }, + "@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "dev": true + }, + "@babel/helpers": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.9.tgz", + "integrity": "sha512-87ICKgU5t5SzOT7sBMfCOZQ2rHjRU+Pcb9BoILMYz600W6DkVRLFBPwQ18gwUVvggqXivaUakpnxWQGbpywbBQ==", + "dev": true, + "requires": { + "@babel/template": "^7.23.9", + "@babel/traverse": "^7.23.9", + "@babel/types": "^7.23.9" + } + }, + "@babel/highlight": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", + "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==", + "dev": true + }, + "@babel/plugin-syntax-jsx": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.23.3.tgz", + "integrity": "sha512-EB2MELswq55OHUoRZLGg/zC7QWUKfNLpE57m/S2yr1uEneIgsTgrSzXP3NXEsMkVn76OlaVVnzN+ugObuYGwhg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-react-display-name": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.23.3.tgz", + "integrity": "sha512-GnvhtVfA2OAtzdX58FJxU19rhoGeQzyVndw3GgtdECQvQFXPEZIOVULHVZGAYmOgmqjXpVpfocAbSjh99V/Fqw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-react-jsx": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.23.4.tgz", + "integrity": "sha512-5xOpoPguCZCRbo/JeHlloSkTA8Bld1J/E1/kLfD1nsuiW1m8tduTA1ERCgIZokDflX/IBzKcqR3l7VlRgiIfHA==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-jsx": "^7.23.3", + "@babel/types": "^7.23.4" + } + }, + "@babel/plugin-transform-react-jsx-development": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.22.5.tgz", + "integrity": "sha512-bDhuzwWMuInwCYeDeMzyi7TaBgRQei6DqxhbyniL7/VG4RSS7HtSL2QbY4eESy1KJqlWt8g3xeEBGPuo+XqC8A==", + "dev": true, + "requires": { + "@babel/plugin-transform-react-jsx": "^7.22.5" + } + }, + "@babel/plugin-transform-react-pure-annotations": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.23.3.tgz", + "integrity": "sha512-qMFdSS+TUhB7Q/3HVPnEdYJDQIk57jkntAwSuz9xfSE4n+3I+vHYCli3HoHawN1Z3RfCz/y1zXA/JXjG6cVImQ==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/preset-react": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.18.6.tgz", + "integrity": "sha512-zXr6atUmyYdiWRVLOZahakYmOBHtWc2WGCkP8PYTgZi0iJXDY2CN180TdrIW4OGOAdLc7TifzDIvtx6izaRIzg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/helper-validator-option": "^7.18.6", + "@babel/plugin-transform-react-display-name": "^7.18.6", + "@babel/plugin-transform-react-jsx": "^7.18.6", + "@babel/plugin-transform-react-jsx-development": "^7.18.6", + "@babel/plugin-transform-react-pure-annotations": "^7.18.6" + } + }, + "@babel/template": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.23.9.tgz", + "integrity": "sha512-+xrD2BWLpvHKNmX2QbpdpsBaWnRxahMwJjO+KZk2JOElj5nSmKezyS1B4u+QbHMTX69t4ukm6hh9lsYQ7GHCKA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.23.5", + "@babel/parser": "^7.23.9", + "@babel/types": "^7.23.9" + } + }, + "@babel/traverse": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.9.tgz", + "integrity": "sha512-I/4UJ9vs90OkBtY6iiiTORVMyIhJ4kAVmsKo9KFc8UOxMeUfi2hvtIBsET5u9GizXE6/GFSuKCTNfgCswuEjRg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.9", + "@babel/types": "^7.23.9", + "debug": "^4.3.1", + "globals": "^11.1.0" + } + }, + "@babel/types": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.9.tgz", + "integrity": "sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==", + "dev": true, + "requires": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + } + }, + "@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true + }, + "@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "requires": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true + }, + "@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true + }, + "@jridgewell/source-map": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", + "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", + "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "@types/eslint": { + "version": "8.56.2", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.2.tgz", + "integrity": "sha512-uQDwm1wFHmbBbCZCqAlq6Do9LYwByNZHWzXppSnay9SuwJ+VRbjkbLABer54kcPnMSlG6Fdiy2yaFXm/z9Z5gw==", + "dev": true, + "requires": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "requires": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "@types/node": { + "version": "20.11.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz", + "integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==", + "dev": true, + "requires": { + "undici-types": "~5.26.4" + } + }, + "@webassemblyjs/ast": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", + "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", + "dev": true, + "requires": { + "@webassemblyjs/helper-numbers": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + } + }, + "@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true + }, + "@webassemblyjs/helper-api-error": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true + }, + "@webassemblyjs/helper-buffer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", + "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==", + "dev": true + }, + "@webassemblyjs/helper-numbers": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", + "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, + "requires": { + "@webassemblyjs/floating-point-hex-parser": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true + }, + "@webassemblyjs/helper-wasm-section": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", + "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6" + } + }, + "@webassemblyjs/ieee754": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", + "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, + "requires": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "@webassemblyjs/leb128": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", + "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, + "requires": { + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/utf8": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true + }, + "@webassemblyjs/wasm-edit": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", + "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/helper-wasm-section": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-opt": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6", + "@webassemblyjs/wast-printer": "1.11.6" + } + }, + "@webassemblyjs/wasm-gen": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", + "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "@webassemblyjs/wasm-opt": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", + "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6" + } + }, + "@webassemblyjs/wasm-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", + "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "@webassemblyjs/wast-printer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", + "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "@webpack-cli/configtest": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.2.0.tgz", + "integrity": "sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==", + "dev": true, + "requires": {} + }, + "@webpack-cli/info": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.5.0.tgz", + "integrity": "sha512-e8tSXZpw2hPl2uMJY6fsMswaok5FdlGNRTktvFk2sD8RjH0hE2+XistawJx1vmKteh4NmGmNUrp+Tb2w+udPcQ==", + "dev": true, + "requires": { + "envinfo": "^7.7.3" + } + }, + "@webpack-cli/serve": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.7.0.tgz", + "integrity": "sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==", + "dev": true, + "requires": {} + }, + "@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true + }, + "acorn-import-assertions": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", + "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "dev": true, + "requires": {} + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "requires": {} + }, + "ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "array-buffer-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "dev": true, + "requires": { + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + } + }, + "arraybuffer.prototype.slice": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "dev": true, + "requires": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", + "is-shared-array-buffer": "^1.0.2" + } + }, + "available-typed-arrays": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.6.tgz", + "integrity": "sha512-j1QzY8iPNPG4o4xmO3ptzpRxTciqD3MgEHtifP/YnJpIo58Xu+ne4BejlbkuaLfXn/nz6HFiw29bLpj2PNMdGg==", + "dev": true + }, + "babel-loader": { + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.5.tgz", + "integrity": "sha512-OSiFfH89LrEMiWd4pLNqGz4CwJDtbs2ZVc+iGu2HrkRfPxId9F2anQj38IxWpmRfsUY0aBZYi1EFcd3mhtRMLQ==", + "dev": true, + "requires": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^2.0.0", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + } + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true + }, + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "browserslist": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.3.tgz", + "integrity": "sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001580", + "electron-to-chromium": "^1.4.648", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.13" + } + }, + "buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "call-bind": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.6.tgz", + "integrity": "sha512-Mj50FLHtlsoVfRfnHaZvyrooHcrlceNZdL/QBvJJVd9Ta55qCQK0gs4ss2oZDeV9zFCs6ewzYgVE5yfVmfFpVg==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.3", + "set-function-length": "^1.2.0" + } + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "caniuse-lite": { + "version": "1.0.30001585", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001585.tgz", + "integrity": "sha512-yr2BWR1yLXQ8fMpdS/4ZZXpseBgE7o4g41x3a6AJOqZuOi+iE/WdJYAuZ6Y95i4Ohd2Y+9MzIWRR+uGABH4s3Q==", + "dev": true + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + } + }, + "chokidar-cli": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chokidar-cli/-/chokidar-cli-3.0.0.tgz", + "integrity": "sha512-xVW+Qeh7z15uZRxHOkP93Ux8A0xbPzwK4GaqD8dQOYc34TlkqUhVSS59fK36DOp5WdJlrRzlYSy02Ht99FjZqQ==", + "dev": true, + "requires": { + "chokidar": "^3.5.2", + "lodash.debounce": "^4.0.8", + "lodash.throttle": "^4.1.1", + "yargs": "^13.3.0" + } + }, + "chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true + }, + "cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "dependencies": { + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true + } + } + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true + }, + "define-data-property": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.2.tgz", + "integrity": "sha512-SRtsSqsDbgpJBbW3pABMCOt6rQyeM8s8RiyeSN8jYG8sYmt/kGJejbydttUsnDs1tadr19tvhT4ShwMyoqAm4g==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.2", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.1" + } + }, + "define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "requires": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + } + }, + "electron-to-chromium": { + "version": "1.4.664", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.664.tgz", + "integrity": "sha512-k9VKKSkOSNPvSckZgDDl/IQx45E1quMjX8QfLzUsAs/zve8AyFDK+ByRynSP/OfEfryiKHpQeMf00z0leLCc3A==", + "dev": true + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true + }, + "enhanced-resolve": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", + "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + } + }, + "envinfo": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.11.1.tgz", + "integrity": "sha512-8PiZgZNIB4q/Lw4AhOvAfB/ityHAd2bli3lESSWmWSzSsl5dKpy5N1d1Rfkd2teq/g9xN90lc6o98DOjMeYHpg==", + "dev": true + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "es-abstract": { + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", + "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", + "dev": true, + "requires": { + "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.5", + "es-set-tostringtag": "^2.0.1", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.2", + "get-symbol-description": "^1.0.0", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0", + "internal-slot": "^1.0.5", + "is-array-buffer": "^3.0.2", + "is-callable": "^1.2.7", + "is-negative-zero": "^2.0.2", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.12", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", + "safe-regex-test": "^1.0.0", + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", + "typed-array-length": "^1.0.4", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.13" + } + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true + }, + "es-module-lexer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz", + "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==", + "dev": true + }, + "es-set-tostringtag": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", + "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "dev": true, + "requires": { + "get-intrinsic": "^1.2.2", + "has-tostringtag": "^1.0.0", + "hasown": "^2.0.0" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true + }, + "eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + }, + "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + } + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + }, + "events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true + }, + "for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "requires": { + "is-callable": "^1.1.3" + } + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true + }, + "function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + } + }, + "functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true + }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + } + }, + "get-symbol-description": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "dev": true, + "requires": { + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" + } + }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dev": true, + "requires": { + "define-properties": "^1.1.3" + } + }, + "gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "requires": { + "get-intrinsic": "^1.1.3" + } + }, + "graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true + }, + "has-property-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", + "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "dev": true, + "requires": { + "get-intrinsic": "^1.2.2" + } + }, + "has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "dev": true + }, + "has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true + }, + "has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "requires": { + "has-symbols": "^1.0.3" + } + }, + "hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, + "requires": { + "function-bind": "^1.1.2" + } + }, + "hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "immutable": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz", + "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==", + "dev": true + }, + "import-local": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dev": true, + "requires": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + } + }, + "internal-slot": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + } + }, + "interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "dev": true + }, + "is-array-buffer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1" + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "requires": { + "has-bigints": "^1.0.1" + } + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, + "is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true + }, + "is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "requires": { + "hasown": "^2.0.0" + } + }, + "is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-negative-zero": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "dev": true + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, + "is-shared-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", + "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2" + } + }, + "is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "requires": { + "has-symbols": "^1.0.2" + } + }, + "is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "dev": true, + "requires": { + "which-typed-array": "^1.1.14" + } + }, + "is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2" + } + }, + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true + }, + "jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true + }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true + }, + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + } + }, + "loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true + }, + "loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true + }, + "lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "dev": true + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true + }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "requires": { + "mime-db": "1.52.0" + } + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node-releases": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "dev": true + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + }, + "dependencies": { + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true + } + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + } + }, + "object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "dev": true + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + }, + "pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + } + }, + "punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true + }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "requires": { + "loose-envify": "^1.1.0" + } + }, + "react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "requires": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + } + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + } + }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "requires": { + "picomatch": "^2.2.1" + } + }, + "rechoir": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", + "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", + "dev": true, + "requires": { + "resolve": "^1.9.0" + } + }, + "regexp.prototype.flags": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "set-function-name": "^2.0.0" + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "requires": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, + "resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "requires": { + "resolve-from": "^5.0.0" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "safe-array-concat": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.0.tgz", + "integrity": "sha512-ZdQ0Jeb9Ofti4hbt5lX3T2JcAamT9hfzYU1MNB+z/jaEbB6wfFfPIR/zEORmZqobkCCJhSjodobH6WHNmJ97dg==", + "dev": true, + "requires": { + "call-bind": "^1.0.5", + "get-intrinsic": "^1.2.2", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + }, + "safe-regex-test": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "dev": true, + "requires": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-regex": "^1.1.4" + } + }, + "sass": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.53.0.tgz", + "integrity": "sha512-zb/oMirbKhUgRQ0/GFz8TSAwRq2IlR29vOUJZOx0l8sV+CkHUfHa4u5nqrG+1VceZp7Jfj59SVW9ogdhTvJDcQ==", + "dev": true, + "requires": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + } + }, + "scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "requires": { + "loose-envify": "^1.1.0" + } + }, + "schema-utils": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + } + }, + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + }, + "serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true + }, + "set-function-length": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", + "integrity": "sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==", + "dev": true, + "requires": { + "define-data-property": "^1.1.2", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.1" + } + }, + "set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dev": true, + "requires": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + } + }, + "shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "requires": { + "kind-of": "^6.0.2" + } + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true + }, + "shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "dev": true + }, + "side-channel": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.5.tgz", + "integrity": "sha512-QcgiIWV4WV7qWExbN5llt6frQB/lBven9pqliLXfGPB+K9ZYXxDozp0wLkHS24kWCm+6YXH/f0HhnObZnZOBnQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true + }, + "source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.4.0.tgz", + "integrity": "sha512-hcjppoJ68fhxA/cjbN4T8N6uCUejN8yFw69ttpqtBeCbF3u13n7mb31NB9jKwGTTWWnt9IbRA/mf1FprYS8wfw==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.17", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz", + "integrity": "sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "string.prototype.padend": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.5.tgz", + "integrity": "sha512-DOB27b/2UTTD+4myKUFh+/fXWcu/UDyASIXfg+7VzoCNNGOfWvoyU/x5pvVHr++ztyt/oSYI1BcWBBG/hmlNjA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "string.prototype.trim": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "string.prototype.trimend": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "string.prototype.trimstart": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true + }, + "tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true + }, + "terser": { + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.27.0.tgz", + "integrity": "sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A==", + "dev": true, + "requires": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + } + }, + "terser-webpack-plugin": { + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", + "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "^0.3.20", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.26.0" + }, + "dependencies": { + "schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "typed-array-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.1.tgz", + "integrity": "sha512-RSqu1UEuSlrBhHTWC8O9FnPjOduNs4M7rJ4pRKoEjtx1zUNOPN2sSXHLDX+Y2WPbHIxbvg4JFo2DNAEfPIKWoQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" + } + }, + "typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + } + }, + "typed-array-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", + "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "dev": true, + "requires": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + } + }, + "typed-array-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", + "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "is-typed-array": "^1.1.9" + } + }, + "unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + } + }, + "undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "requires": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + } + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "watchpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", + "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "dev": true, + "requires": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + } + }, + "webpack": { + "version": "5.90.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.90.1.tgz", + "integrity": "sha512-SstPdlAC5IvgFnhiRok8hqJo/+ArAbNv7rhU4fnWGHNVfN59HSQFaxZDSAL3IFG2YmqxuRs+IU33milSxbPlog==", + "dev": true, + "requires": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^1.0.5", + "@webassemblyjs/ast": "^1.11.5", + "@webassemblyjs/wasm-edit": "^1.11.5", + "@webassemblyjs/wasm-parser": "^1.11.5", + "acorn": "^8.7.1", + "acorn-import-assertions": "^1.9.0", + "browserslist": "^4.21.10", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.15.0", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.9", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.0", + "webpack-sources": "^3.2.3" + }, + "dependencies": { + "schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "webpack-cli": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", + "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", + "dev": true, + "requires": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^1.2.0", + "@webpack-cli/info": "^1.5.0", + "@webpack-cli/serve": "^1.7.0", + "colorette": "^2.0.14", + "commander": "^7.0.0", + "cross-spawn": "^7.0.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^2.2.0", + "rechoir": "^0.7.0", + "webpack-merge": "^5.7.3" + }, + "dependencies": { + "commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true + }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "dev": true, + "requires": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + } + }, + "webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "requires": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + } + }, + "which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "dev": true + }, + "which-typed-array": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.14.tgz", + "integrity": "sha512-VnXFiIW8yNn9kIHN88xvZ4yOWchftKDsRJ8fEPacX/wl1lOvBrhsJ/OeJCXq7B0AaijRuqgzSKalJoPk+D8MPg==", + "dev": true, + "requires": { + "available-typed-arrays": "^1.0.6", + "call-bind": "^1.0.5", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.1" + } + }, + "wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true + }, + "wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + } + }, + "y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "dev": true, + "requires": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + }, + "dependencies": { + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true + } + } + }, + "yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } +} diff --git a/browser/components/pocket/package.json b/browser/components/pocket/package.json new file mode 100644 index 0000000000..c5af55cb9f --- /dev/null +++ b/browser/components/pocket/package.json @@ -0,0 +1,32 @@ +{ + "name": "save-to-pocket-ff", + "version": "1.0.0", + "description": "Task running for Save to Pocket Extension", + "scripts": { + "start": "npm run build && npm run watch", + "build": "npm-run-all build:*", + "build:webpack": "webpack --config webpack.config.js", + "build:sass": "sass content/panels/css/main.scss content/panels/css/main.compiled.css", + "watch": "npm-run-all -p watch:*", + "watch:webpack": "npm run build:webpack -- --env development -w", + "watch:sass": "chokidar \"content/panels/**/*.scss\" -c \"npm run build:sass\"", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Mozilla (https://mozilla.org/)", + "license": "MPL-2.0", + "devDependencies": { + "@babel/core": "7.18.6", + "@babel/preset-react": "7.18.6", + "babel-loader": "8.2.5", + "chokidar-cli": "3.0.0", + "npm-run-all": "4.1.5", + "sass": "1.53.0", + "webpack": "5.90.1", + "webpack-cli": "4.10.0" + }, + "repository": "https://hg.mozilla.org/", + "dependencies": { + "react": "18.2.0", + "react-dom": "18.2.0" + } +} diff --git a/browser/components/pocket/pings.yaml b/browser/components/pocket/pings.yaml new file mode 100644 index 0000000000..22dc954d79 --- /dev/null +++ b/browser/components/pocket/pings.yaml @@ -0,0 +1,22 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +--- +$schema: moz://mozilla.org/schemas/glean/pings/2-0-0 + +pocket-button: + description: | + Reinstrumentation of the Activity Stream "pocket-button" ping. + Submitted when actions are taken around the pocket button. + Does not contain any `client_id`. + Instead uses an `impression_id`. + include_client_id: false + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1857324 + notification_emails: + - chutten@mozilla.com + - kdemtchouk@mozilla.com + - sdowne@mozilla.com diff --git a/browser/components/pocket/test/browser.toml b/browser/components/pocket/test/browser.toml new file mode 100644 index 0000000000..0e815f727e --- /dev/null +++ b/browser/components/pocket/test/browser.toml @@ -0,0 +1,17 @@ +[DEFAULT] +support-files = [ + "head.js", + "test.html", +] + +["browser_pocket_button_icon_state.js"] + +["browser_pocket_context_menu_action.js"] + +["browser_pocket_home_panel.js"] + +["browser_pocket_panel.js"] + +["browser_pocket_panel_closemenu.js"] + +["browser_pocket_ui_check.js"] diff --git a/browser/components/pocket/test/browser_pocket_button_icon_state.js b/browser/components/pocket/test/browser_pocket_button_icon_state.js new file mode 100644 index 0000000000..c2cba8133b --- /dev/null +++ b/browser/components/pocket/test/browser_pocket_button_icon_state.js @@ -0,0 +1,131 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + SaveToPocket: "chrome://pocket/content/SaveToPocket.sys.mjs", +}); + +function test_runner(test) { + let testTask = async () => { + // Before each + const sandbox = sinon.createSandbox(); + + // We're faking logged in tests, so initially we need to fake the logged in state. + sandbox.stub(pktApi, "isUserLoggedIn").callsFake(() => true); + + // Also we cannot actually make remote requests, so make sure we stub any functions + // we need that that make requests to api.getpocket.com. + sandbox.stub(pktApi, "addLink").callsFake(() => true); + + try { + await test({ sandbox }); + } finally { + // After each + sandbox.restore(); + } + }; + + // Copy the name of the test function to identify the test + Object.defineProperty(testTask, "name", { value: test.name }); + add_task(testTask); +} + +async function isPocketPanelShown() { + info("clicking on pocket button in toolbar"); + // The panel is created on the fly, so we can't simply wait for focus + // inside it. + let pocketPanelShowing = BrowserTestUtils.waitForEvent( + document, + "popupshown", + true + ); + return pocketPanelShowing; +} + +async function isPocketPanelHidden() { + let pocketPanelHidden = BrowserTestUtils.waitForEvent( + document, + "popuphidden" + ); + return pocketPanelHidden; +} + +function fakeSavingPage() { + // Because we're not actually logged into a remote Pocket account, + // and because we're not actually saving anything, + // we fake it, instead, by calling the function we care about. + SaveToPocket.itemSaved(); + // This fakes the button from just opened, to also pocketed, + // we currently expect both from a save. + SaveToPocket.updateToolbarNodeState(window); +} + +function checkPanelOpen() { + let pocketButton = document.getElementById("save-to-pocket-button"); + // The Pocket button should be set to open. + is(pocketButton.open, true, "Pocket button is open"); + is(pocketButton.getAttribute("pocketed"), "true", "Pocket item is pocketed"); +} + +function checkPanelClosed() { + let pocketButton = document.getElementById("save-to-pocket-button"); + // Something should have closed the Pocket panel, icon should no longer be red. + is(pocketButton.open, false, "Pocket button is closed"); + is(pocketButton.getAttribute("pocketed"), "", "Pocket item is not pocketed"); +} + +test_runner(async function test_pocketButtonState_changeTabs({ sandbox }) { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/browser/browser/components/pocket/test/test.html" + ); + + let pocketPanelShown = isPocketPanelShown(); + let pocketButton = document.getElementById("save-to-pocket-button"); + pocketButton.click(); + await pocketPanelShown; + fakeSavingPage(); + + // Testing the panel states. + checkPanelOpen(); + + let pocketPanelHidden = isPocketPanelHidden(); + // Mochitests start with an open tab, so use that to trigger a tab change. + await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[0]); + await pocketPanelHidden; + + // Testing the panel states. + checkPanelClosed(); + + BrowserTestUtils.removeTab(tab); +}); + +test_runner(async function test_pocketButtonState_changeLocation({ sandbox }) { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/browser/browser/components/pocket/test/test.html" + ); + + let pocketPanelShown = isPocketPanelShown(); + let pocketButton = document.getElementById("save-to-pocket-button"); + pocketButton.click(); + await pocketPanelShown; + fakeSavingPage(); + + // Testing the panel states. + checkPanelOpen(); + + let pocketPanelHidden = isPocketPanelHidden(); + // Simulate a location change, and check the panel state. + let browser = gBrowser.selectedBrowser; + let loaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.startLoadingURIString(browser, "about:robots"); + await loaded; + await pocketPanelHidden; + + // Testing the panel states. + checkPanelClosed(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/pocket/test/browser_pocket_context_menu_action.js b/browser/components/pocket/test/browser_pocket_context_menu_action.js new file mode 100644 index 0000000000..878d23811e --- /dev/null +++ b/browser/components/pocket/test/browser_pocket_context_menu_action.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +add_task(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/browser/browser/components/pocket/test/test.html" + ); + + info("opening context menu"); + let contextMenu = document.getElementById("contentAreaContextMenu"); + let popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + let popupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "body", + { + type: "contextmenu", + button: 2, + }, + tab.linkedBrowser + ); + await popupShown; + + info("opening pocket panel"); + let contextPocket = contextMenu.querySelector("#context-pocket"); + // The panel is created on the fly, so we can't simply wait for focus + // inside it. + let pocketPanelShown = BrowserTestUtils.waitForEvent( + document, + "popupshown", + true + ); + contextMenu.activateItem(contextPocket); + await pocketPanelShown; + checkElements(true, ["customizationui-widget-panel"]); + + info("closing pocket panel"); + let pocketPanel = document.getElementById("customizationui-widget-panel"); + let pocketPanelHidden = BrowserTestUtils.waitForEvent( + pocketPanel, + "popuphidden" + ); + + pocketPanel.hidePopup(); + await pocketPanelHidden; + checkElements(false, ["customizationui-widget-panel"]); + + contextMenu.hidePopup(); + await popupHidden; + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/pocket/test/browser_pocket_home_panel.js b/browser/components/pocket/test/browser_pocket_home_panel.js new file mode 100644 index 0000000000..d58f3e44c7 --- /dev/null +++ b/browser/components/pocket/test/browser_pocket_home_panel.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +add_task(async function () { + // The recent saves feature makes an external call to api.getpocket.com. + // External calls are not permitted in tests. + // however, we're not testing the content of the panel, + // we're just testing that the right panel is used for certain urls, + // so we can turn recent saves off for this test. + await SpecialPowers.pushPrefEnv({ + set: [["extensions.pocket.refresh.hideRecentSaves.enabled", true]], + }); + // Home panel is used on about: pages, so we use about:robots to test. + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:robots" + ); + + const stub = sinon.stub(pktApi, "isUserLoggedIn").callsFake(() => true); + + info("clicking on pocket button in toolbar"); + let pocketButton = document.getElementById("save-to-pocket-button"); + // The panel is created on the fly, so we can't simply wait for focus + // inside it. + let pocketPanelShowing = BrowserTestUtils.waitForEvent( + document, + "popupshowing", + true + ); + pocketButton.click(); + await pocketPanelShowing; + + let pocketPanel = document.getElementById("customizationui-widget-panel"); + let pocketFrame = pocketPanel.querySelector("browser"); + + await TestUtils.waitForCondition( + () => pocketFrame.src.startsWith("about:pocket-home?"), + "pocket home panel is showing" + ); + + info("closing pocket panel"); + let pocketPanelHidden = BrowserTestUtils.waitForEvent( + pocketPanel, + "popuphidden" + ); + pocketPanel.hidePopup(); + await pocketPanelHidden; + checkElements(false, ["customizationui-widget-panel"]); + + stub.restore(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/pocket/test/browser_pocket_panel.js b/browser/components/pocket/test/browser_pocket_panel.js new file mode 100644 index 0000000000..9961b09c3a --- /dev/null +++ b/browser/components/pocket/test/browser_pocket_panel.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +add_task(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/browser/browser/components/pocket/test/test.html" + ); + + info("clicking on pocket button in toolbar"); + let pocketButton = document.getElementById("save-to-pocket-button"); + // The panel is created on the fly, so we can't simply wait for focus + // inside it. + let pocketPanelShowing = BrowserTestUtils.waitForEvent( + document, + "popupshowing", + true + ); + pocketButton.click(); + await pocketPanelShowing; + + checkElements(true, ["customizationui-widget-panel"]); + let pocketPanel = document.getElementById("customizationui-widget-panel"); + is(pocketPanel.state, "showing", "pocket panel is showing"); + + info("Trigger context menu in a pocket panel element"); + let contextMenu = document.getElementById("contentAreaContextMenu"); + is(contextMenu.state, "closed", "context menu popup is closed"); + let popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + let popupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + + let pocketFrame = pocketPanel.querySelector("browser"); + + const getReadyState = async frame => + SpecialPowers.spawn(frame, [], () => content.document.readyState); + + // Ensure Pocket panel is ready to avoid intermittency. + await TestUtils.waitForCondition( + async () => (await getReadyState(pocketFrame)) == "complete" + ); + + // Ensure that the document layout has been flushed before triggering the mouse event + // (See Bug 1519808 for a rationale). + await pocketFrame.ownerGlobal.promiseDocumentFlushed(() => {}); + await BrowserTestUtils.synthesizeMouseAtCenter( + "body", + { + type: "contextmenu", + button: 2, + }, + pocketFrame + ); + + await popupShown; + is(contextMenu.state, "open", "context menu popup is open"); + const emeLearnMoreContextItem = contextMenu.querySelector( + "#context-media-eme-learnmore" + ); + ok( + BrowserTestUtils.isHidden(emeLearnMoreContextItem), + "Unrelated context menu items should be hidden" + ); + + contextMenu.hidePopup(); + await popupHidden; + + info("closing pocket panel"); + let pocketPanelHidden = BrowserTestUtils.waitForEvent( + pocketPanel, + "popuphidden" + ); + pocketPanel.hidePopup(); + await pocketPanelHidden; + checkElements(false, ["customizationui-widget-panel"]); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/pocket/test/browser_pocket_panel_closemenu.js b/browser/components/pocket/test/browser_pocket_panel_closemenu.js new file mode 100644 index 0000000000..b3a2f2d324 --- /dev/null +++ b/browser/components/pocket/test/browser_pocket_panel_closemenu.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// This is testing the fix in bug 1729847, specifically +// clicking enter while the pocket panel is open should not close the panel. +add_task(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/browser/browser/components/pocket/test/test.html" + ); + + info("clicking on pocket button in toolbar"); + let pocketButton = document.getElementById("save-to-pocket-button"); + // The panel is created on the fly, so we can't simply wait for focus + // inside it. + let pocketPanelShown = BrowserTestUtils.waitForEvent( + document, + "popupshown", + true + ); + pocketButton.click(); + await pocketPanelShown; + + let pocketPanel = document.getElementById("customizationui-widget-panel"); + let pocketFrame = pocketPanel.querySelector("browser"); + + // Ensure that the document layout has been flushed before triggering the focus event + // (See Bug 1519808 for a rationale). + await pocketFrame.ownerGlobal.promiseDocumentFlushed(() => {}); + + // The panelview should have closemenu="none". + // Without closemenu="none", the following sequence of + // frame focus then enter would close the panel, + // but we don't want it to close, we want it to stay open. + let focusEventPromise = BrowserTestUtils.waitForEvent(pocketFrame, "focus"); + pocketFrame.focus(); + await focusEventPromise; + EventUtils.synthesizeKey("VK_RETURN"); + + // Is the Pocket panel still open? + is(pocketPanel.state, "open", "pocket panel is open"); + + // We're done now, we can close the panel. + info("closing pocket panel"); + let pocketPanelHidden = BrowserTestUtils.waitForEvent( + pocketPanel, + "popuphidden" + ); + pocketPanel.hidePopup(); + await pocketPanelHidden; + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/pocket/test/browser_pocket_ui_check.js b/browser/components/pocket/test/browser_pocket_ui_check.js new file mode 100644 index 0000000000..e8b82a0b5c --- /dev/null +++ b/browser/components/pocket/test/browser_pocket_ui_check.js @@ -0,0 +1,85 @@ +"use strict"; + +add_task(async function test_setup() { + let clearValue = Services.prefs.prefHasUserValue("extensions.pocket.enabled"); + let enabledOnStartup = Services.prefs.getBoolPref( + "extensions.pocket.enabled" + ); + registerCleanupFunction(() => { + if (clearValue) { + Services.prefs.clearUserPref("extensions.pocket.enabled"); + } else { + Services.prefs.setBoolPref("extensions.pocket.enabled", enabledOnStartup); + } + }); +}); + +add_task(async function () { + await promisePocketEnabled(); + + // check context menu exists + info("checking content context menu"); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/browser/browser/components/pocket/test/test.html" + ); + + let contextMenu = document.getElementById("contentAreaContextMenu"); + let popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + let popupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + await BrowserTestUtils.synthesizeMouseAtCenter( + "body", + { + type: "contextmenu", + button: 2, + }, + tab.linkedBrowser + ); + await popupShown; + + checkElementsShown(true, ["save-to-pocket-button", "context-pocket"]); + + contextMenu.hidePopup(); + await popupHidden; + popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + popupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + await BrowserTestUtils.synthesizeMouseAtCenter( + "a", + { + type: "contextmenu", + button: 2, + }, + tab.linkedBrowser + ); + await popupShown; + + checkElementsShown(true, ["context-savelinktopocket"]); + contextMenu.hidePopup(); + await popupHidden; + + await promisePocketDisabled(); + + popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + popupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + await BrowserTestUtils.synthesizeMouseAtCenter( + "a", + { + type: "contextmenu", + button: 2, + }, + tab.linkedBrowser + ); + await popupShown; + + checkElementsShown(false, [ + "context-pocket", + "context-savelinktopocket", + "save-to-pocket-button", + ]); + + contextMenu.hidePopup(); + await popupHidden; + BrowserTestUtils.removeTab(tab); + + await promisePocketReset(); +}); diff --git a/browser/components/pocket/test/head.js b/browser/components/pocket/test/head.js new file mode 100644 index 0000000000..c3ce73e42e --- /dev/null +++ b/browser/components/pocket/test/head.js @@ -0,0 +1,85 @@ +// Currently Pocket is disabled in tests. We want these tests to work under +// either case that Pocket is disabled or enabled on startup of the browser, +// and that at the end we're reset to the correct state. +let enabledOnStartup = false; + +ChromeUtils.defineESModuleGetters(this, { + pktApi: "chrome://pocket/content/pktApi.sys.mjs", +}); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +// PocketEnabled/Disabled promises return true if it was already +// Enabled/Disabled, and false if it need to Enable/Disable. +function promisePocketEnabled() { + if ( + Services.prefs.getPrefType("extensions.pocket.enabled") != + Services.prefs.PREF_INVALID && + Services.prefs.getBoolPref("extensions.pocket.enabled") + ) { + info("pocket was already enabled, assuming enabled by default for tests"); + enabledOnStartup = true; + return Promise.resolve(true); + } + info("pocket is not enabled"); + Services.prefs.setBoolPref("extensions.pocket.enabled", true); + return BrowserTestUtils.waitForCondition(() => { + return !!CustomizableUI.getWidget("save-to-pocket-button"); + }); +} + +function promisePocketDisabled() { + if ( + Services.prefs.getPrefType("extensions.pocket.enabled") == + Services.prefs.PREF_INVALID || + !Services.prefs.getBoolPref("extensions.pocket.enabled") + ) { + info("pocket-button already disabled"); + return Promise.resolve(true); + } + info("reset pocket enabled pref"); + // testing/profiles/common/user.js uses user_pref to disable pocket, set + // back to false. + Services.prefs.setBoolPref("extensions.pocket.enabled", false); + return BrowserTestUtils.waitForCondition(() => { + return !CustomizableUI.getWidget("save-to-pocket-button"); + }); +} + +function promisePocketReset() { + if (enabledOnStartup) { + info("reset is enabling pocket addon"); + return promisePocketEnabled(); + } + info("reset is disabling pocket addon"); + return promisePocketDisabled(); +} + +function checkElements(expectPresent, l, win = window) { + for (let id of l) { + let el = + win.document.getElementById(id) || + win.gNavToolbox.palette.querySelector("#" + id); + is( + !!el && !el.hidden, + expectPresent, + "element " + id + (expectPresent ? " is" : " is not") + " present" + ); + } +} + +function checkElementsShown(expectPresent, l, win = window) { + for (let id of l) { + let el = + win.document.getElementById(id) || + win.gNavToolbox.palette.querySelector("#" + id); + let elShown = !!el && window.getComputedStyle(el).display != "none"; + is( + elShown, + expectPresent, + "element " + id + (expectPresent ? " is" : " is not") + " present" + ); + } +} diff --git a/browser/components/pocket/test/test.html b/browser/components/pocket/test/test.html new file mode 100644 index 0000000000..51207f2f97 --- /dev/null +++ b/browser/components/pocket/test/test.html @@ -0,0 +1,12 @@ + + + + + Page Title + + + + + Test link + + diff --git a/browser/components/pocket/test/unit/browser.toml b/browser/components/pocket/test/unit/browser.toml new file mode 100644 index 0000000000..50424f7b22 --- /dev/null +++ b/browser/components/pocket/test/unit/browser.toml @@ -0,0 +1,8 @@ +[DEFAULT] +support-files = ["head.js"] + +["browser_pocket_AboutPocketParent.js"] + +["browser_pocket_pktTelemetry.js"] + +["browser_pocket_pktUI.js"] diff --git a/browser/components/pocket/test/unit/browser_pocket_AboutPocketParent.js b/browser/components/pocket/test/unit/browser_pocket_AboutPocketParent.js new file mode 100644 index 0000000000..5abe3b3db1 --- /dev/null +++ b/browser/components/pocket/test/unit/browser_pocket_AboutPocketParent.js @@ -0,0 +1,372 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +const { AboutPocketParent } = ChromeUtils.importESModule( + "resource:///actors/AboutPocketParent.sys.mjs" +); +const { pktApi } = ChromeUtils.importESModule( + "chrome://pocket/content/pktApi.sys.mjs" +); +let aboutPocketParent; + +function test_runner(test) { + let testTask = async () => { + // Before each + const sandbox = sinon.createSandbox(); + aboutPocketParent = new AboutPocketParent(); + + const manager = { + isClosed: false, + }; + const browsingContext = { + topChromeWindow: { + pktUI: { + onShowSignup: sandbox.spy(), + onShowSaved: sandbox.spy(), + closePanel: sandbox.spy(), + onOpenTabWithUrl: sandbox.spy(), + onOpenTabWithPocketUrl: sandbox.spy(), + resizePanel: sandbox.spy(), + getPanelFrame: () => ({ setAttribute: () => {} }), + }, + }, + embedderElement: { + csp: "csp", + contentPrincipal: "contentPrincipal", + }, + }; + + sandbox.stub(aboutPocketParent, "manager").get(() => manager); + sandbox + .stub(aboutPocketParent, "browsingContext") + .get(() => browsingContext); + + try { + await test({ sandbox }); + } finally { + // After each + sandbox.restore(); + } + }; + + // Copy the name of the test function to identify the test + Object.defineProperty(testTask, "name", { value: test.name }); + add_task(testTask); +} + +test_runner(async function test_AboutPocketParent_sendResponseMessageToPanel({ + sandbox, +}) { + const sendAsyncMessage = sandbox.stub(aboutPocketParent, "sendAsyncMessage"); + + aboutPocketParent.sendResponseMessageToPanel("PKT_testMessage", { + foo: 1, + }); + + const { args } = sendAsyncMessage.firstCall; + + Assert.ok( + sendAsyncMessage.calledOnce, + "Should fire sendAsyncMessage once with sendResponseMessageToPanel" + ); + Assert.deepEqual( + args, + ["PKT_testMessage_response", { foo: 1 }], + "Should fire sendAsyncMessage with proper args from sendResponseMessageToPanel" + ); +}); + +test_runner( + async function test_AboutPocketParent_receiveMessage_PKT_show_signup({ + sandbox, + }) { + await aboutPocketParent.receiveMessage({ + name: "PKT_show_signup", + }); + + const { onShowSignup } = + aboutPocketParent.browsingContext.topChromeWindow.pktUI; + + Assert.ok( + onShowSignup.calledOnce, + "Should fire onShowSignup once with PKT_show_signup" + ); + } +); + +test_runner( + async function test_AboutPocketParent_receiveMessage_PKT_show_saved({ + sandbox, + }) { + await aboutPocketParent.receiveMessage({ + name: "PKT_show_saved", + }); + + const { onShowSaved } = + aboutPocketParent.browsingContext.topChromeWindow.pktUI; + + Assert.ok( + onShowSaved.calledOnce, + "Should fire onShowSaved once with PKT_show_saved" + ); + } +); + +test_runner(async function test_AboutPocketParent_receiveMessage_PKT_close({ + sandbox, +}) { + await aboutPocketParent.receiveMessage({ + name: "PKT_close", + }); + + const { closePanel } = + aboutPocketParent.browsingContext.topChromeWindow.pktUI; + + Assert.ok( + closePanel.calledOnce, + "Should fire closePanel once with PKT_close" + ); +}); + +test_runner( + async function test_AboutPocketParent_receiveMessage_PKT_openTabWithUrl({ + sandbox, + }) { + await aboutPocketParent.receiveMessage({ + name: "PKT_openTabWithUrl", + data: { foo: 1 }, + }); + + const { onOpenTabWithUrl } = + aboutPocketParent.browsingContext.topChromeWindow.pktUI; + const { args } = onOpenTabWithUrl.firstCall; + + Assert.ok( + onOpenTabWithUrl.calledOnce, + "Should fire onOpenTabWithUrl once with PKT_openTabWithUrl" + ); + Assert.deepEqual( + args, + [{ foo: 1 }, "contentPrincipal", "csp"], + "Should fire onOpenTabWithUrl with proper args from PKT_openTabWithUrl" + ); + } +); + +test_runner( + async function test_AboutPocketParent_receiveMessage_PKT_openTabWithPocketUrl({ + sandbox, + }) { + await aboutPocketParent.receiveMessage({ + name: "PKT_openTabWithPocketUrl", + data: { foo: 1 }, + }); + + const { onOpenTabWithPocketUrl } = + aboutPocketParent.browsingContext.topChromeWindow.pktUI; + const { args } = onOpenTabWithPocketUrl.firstCall; + + Assert.ok( + onOpenTabWithPocketUrl.calledOnce, + "Should fire onOpenTabWithPocketUrl once with PKT_openTabWithPocketUrl" + ); + Assert.deepEqual( + args, + [{ foo: 1 }, "contentPrincipal", "csp"], + "Should fire onOpenTabWithPocketUrl with proper args from PKT_openTabWithPocketUrl" + ); + } +); + +test_runner( + async function test_AboutPocketParent_receiveMessage_PKT_resizePanel({ + sandbox, + }) { + const sendResponseMessageToPanel = sandbox.stub( + aboutPocketParent, + "sendResponseMessageToPanel" + ); + await aboutPocketParent.receiveMessage({ + name: "PKT_resizePanel", + data: { foo: 1 }, + }); + + const { resizePanel } = + aboutPocketParent.browsingContext.topChromeWindow.pktUI; + const { args } = resizePanel.firstCall; + + Assert.ok( + resizePanel.calledOnce, + "Should fire resizePanel once with PKT_resizePanel" + ); + Assert.deepEqual( + args, + [{ foo: 1 }], + "Should fire resizePanel with proper args from PKT_resizePanel" + ); + Assert.ok( + sendResponseMessageToPanel.calledOnce, + "Should fire sendResponseMessageToPanel once with PKT_resizePanel" + ); + Assert.deepEqual( + sendResponseMessageToPanel.firstCall.args, + ["PKT_resizePanel"], + "Should fire sendResponseMessageToPanel with proper args from PKT_resizePanel" + ); + } +); + +test_runner(async function test_AboutPocketParent_receiveMessage_PKT_getTags({ + sandbox, +}) { + const sendResponseMessageToPanel = sandbox.stub( + aboutPocketParent, + "sendResponseMessageToPanel" + ); + await aboutPocketParent.receiveMessage({ + name: "PKT_getTags", + }); + Assert.ok( + sendResponseMessageToPanel.calledOnce, + "Should fire sendResponseMessageToPanel once with PKT_getTags" + ); + Assert.deepEqual( + sendResponseMessageToPanel.firstCall.args, + ["PKT_getTags", { tags: [] }], + "Should fire sendResponseMessageToPanel with proper args from PKT_getTags" + ); +}); + +test_runner( + async function test_AboutPocketParent_receiveMessage_PKT_getSuggestedTags({ + sandbox, + }) { + const sendResponseMessageToPanel = sandbox.stub( + aboutPocketParent, + "sendResponseMessageToPanel" + ); + sandbox.stub(pktApi, "getSuggestedTagsForURL").callsFake((url, options) => { + options.success({ suggested_tags: "foo" }); + }); + + await aboutPocketParent.receiveMessage({ + name: "PKT_getSuggestedTags", + data: { url: "https://foo.com" }, + }); + + Assert.ok( + pktApi.getSuggestedTagsForURL.calledOnce, + "Should fire getSuggestedTagsForURL once with PKT_getSuggestedTags" + ); + Assert.equal( + pktApi.getSuggestedTagsForURL.firstCall.args[0], + "https://foo.com", + "Should fire getSuggestedTagsForURL with proper url from PKT_getSuggestedTags" + ); + Assert.ok( + sendResponseMessageToPanel.calledOnce, + "Should fire sendResponseMessageToPanel once with PKT_getSuggestedTags" + ); + Assert.deepEqual( + sendResponseMessageToPanel.firstCall.args, + [ + "PKT_getSuggestedTags", + { + status: "success", + value: { suggestedTags: "foo" }, + }, + ], + "Should fire sendResponseMessageToPanel with proper args from PKT_getSuggestedTags" + ); + } +); + +test_runner(async function test_AboutPocketParent_receiveMessage_PKT_addTags({ + sandbox, +}) { + const sendResponseMessageToPanel = sandbox.stub( + aboutPocketParent, + "sendResponseMessageToPanel" + ); + sandbox.stub(pktApi, "addTagsToURL").callsFake((url, tags, options) => { + options.success(); + }); + + await aboutPocketParent.receiveMessage({ + name: "PKT_addTags", + data: { url: "https://foo.com", tags: "tags" }, + }); + + Assert.ok( + pktApi.addTagsToURL.calledOnce, + "Should fire addTagsToURL once with PKT_addTags" + ); + Assert.equal( + pktApi.addTagsToURL.firstCall.args[0], + "https://foo.com", + "Should fire addTagsToURL with proper url from PKT_addTags" + ); + Assert.equal( + pktApi.addTagsToURL.firstCall.args[1], + "tags", + "Should fire addTagsToURL with proper tags from PKT_addTags" + ); + Assert.ok( + sendResponseMessageToPanel.calledOnce, + "Should fire sendResponseMessageToPanel once with PKT_addTags" + ); + Assert.deepEqual( + sendResponseMessageToPanel.firstCall.args, + [ + "PKT_addTags", + { + status: "success", + }, + ], + "Should fire sendResponseMessageToPanel with proper args from PKT_addTags" + ); +}); + +test_runner( + async function test_AboutPocketParent_receiveMessage_PKT_deleteItem({ + sandbox, + }) { + const sendResponseMessageToPanel = sandbox.stub( + aboutPocketParent, + "sendResponseMessageToPanel" + ); + sandbox.stub(pktApi, "deleteItem").callsFake((itemId, options) => { + options.success(); + }); + + await aboutPocketParent.receiveMessage({ + name: "PKT_deleteItem", + data: { itemId: "itemId" }, + }); + + Assert.ok( + pktApi.deleteItem.calledOnce, + "Should fire deleteItem once with PKT_deleteItem" + ); + Assert.equal( + pktApi.deleteItem.firstCall.args[0], + "itemId", + "Should fire deleteItem with proper itemId from PKT_deleteItem" + ); + Assert.ok( + sendResponseMessageToPanel.calledOnce, + "Should fire sendResponseMessageToPanel once with PKT_deleteItem" + ); + Assert.deepEqual( + sendResponseMessageToPanel.firstCall.args, + [ + "PKT_deleteItem", + { + status: "success", + }, + ], + "Should fire sendResponseMessageToPanel with proper args from PKT_deleteItem" + ); + } +); diff --git a/browser/components/pocket/test/unit/browser_pocket_pktTelemetry.js b/browser/components/pocket/test/unit/browser_pocket_pktTelemetry.js new file mode 100644 index 0000000000..44aa738fac --- /dev/null +++ b/browser/components/pocket/test/unit/browser_pocket_pktTelemetry.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + pktTelemetry: "chrome://pocket/content/pktTelemetry.sys.mjs", +}); + +function test_runner(test) { + let testTask = async () => { + // Before each + const sandbox = sinon.createSandbox(); + try { + await test({ sandbox }); + } finally { + // After each + sandbox.restore(); + } + }; + + // Copy the name of the test function to identify the test + Object.defineProperty(testTask, "name", { value: test.name }); + add_task(testTask); +} + +test_runner(async function test_submitPocketButtonPing({ sandbox }) { + const creationDate = "19640"; + const impressionId = "{422e3da9-c694-4fd2-b676-8ae070156128}"; + sandbox.stub(pktTelemetry, "impressionId").value(impressionId); + sandbox.stub(pktTelemetry, "_profileCreationDate").returns(creationDate); + + const eventAction = "some action like 'click'"; + const eventSource = "some source like 'save_button'"; + + const assertConstantStuff = () => { + Assert.equal( + "{" + Glean.pocketButton.impressionId.testGetValue() + "}", + impressionId + ); + Assert.equal(Glean.pocketButton.pocketLoggedInStatus.testGetValue(), false); + Assert.equal( + Glean.pocketButton.profileCreationDate.testGetValue(), + creationDate + ); + + Assert.equal(Glean.pocketButton.eventAction.testGetValue(), eventAction); + Assert.equal(Glean.pocketButton.eventSource.testGetValue(), eventSource); + }; + + let submitted = false; + GleanPings.pocketButton.testBeforeNextSubmit(() => { + submitted = true; + assertConstantStuff(); + Assert.equal(Glean.pocketButton.eventPosition.testGetValue(), null); + Assert.equal(Glean.pocketButton.model.testGetValue(), null); + }); + + pktTelemetry.submitPocketButtonPing(eventAction, eventSource); + Assert.ok(submitted, "Ping submitted successfully"); + + submitted = false; + GleanPings.pocketButton.testBeforeNextSubmit(() => { + submitted = true; + assertConstantStuff(); + Assert.equal(Glean.pocketButton.eventPosition.testGetValue(), 0); + Assert.equal(Glean.pocketButton.model.testGetValue(), null); + }); + + pktTelemetry.submitPocketButtonPing(eventAction, eventSource, 0, null); + Assert.ok(submitted, "Ping submitted successfully"); + + submitted = false; + GleanPings.pocketButton.testBeforeNextSubmit(() => { + submitted = true; + assertConstantStuff(); + // falsey but not undefined positions will be omitted. + Assert.equal(Glean.pocketButton.eventPosition.testGetValue(), null); + Assert.equal( + Glean.pocketButton.model.testGetValue(), + "some-really-groovy-model" + ); + }); + + pktTelemetry.submitPocketButtonPing( + eventAction, + eventSource, + false, + "some-really-groovy-model" + ); + Assert.ok(submitted, "Ping submitted successfully"); +}); diff --git a/browser/components/pocket/test/unit/browser_pocket_pktUI.js b/browser/components/pocket/test/unit/browser_pocket_pktUI.js new file mode 100644 index 0000000000..69388c213f --- /dev/null +++ b/browser/components/pocket/test/unit/browser_pocket_pktUI.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +function test_runner(test) { + let testTask = async () => { + // Before each + pktUI.initPrefs(); + const sandbox = sinon.createSandbox(); + try { + await test({ sandbox }); + } finally { + // After each + sandbox.restore(); + } + }; + + // Copy the name of the test function to identify the test + Object.defineProperty(testTask, "name", { value: test.name }); + add_task(testTask); +} + +test_runner(async function test_pktUI_showPanel({ sandbox }) { + const testFrame = { + setAttribute: sandbox.stub(), + style: { width: 0, height: 0 }, + }; + pktUI.setToolbarPanelFrame(testFrame); + + pktUI.showPanel("about:pocket-saved", `saved`); + + Assert.deepEqual(testFrame.setAttribute.args[0], [ + "src", + `about:pocket-saved?utmSource=firefox_pocket_save_button&locale=${SpecialPowers.Services.locale.appLocaleAsBCP47}`, + ]); + Assert.deepEqual(testFrame.style, { width: "350px", height: "110px" }); +}); diff --git a/browser/components/pocket/test/unit/head.js b/browser/components/pocket/test/unit/head.js new file mode 100644 index 0000000000..6699eb7a1c --- /dev/null +++ b/browser/components/pocket/test/unit/head.js @@ -0,0 +1,12 @@ +ChromeUtils.defineESModuleGetters(this, { + pktApi: "chrome://pocket/content/pktApi.sys.mjs", +}); +XPCOMUtils.defineLazyScriptGetter( + this, + "pktUI", + "chrome://pocket/content/pktUI.js" +); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); diff --git a/browser/components/pocket/test/unit/panels/browser.toml b/browser/components/pocket/test/unit/panels/browser.toml new file mode 100644 index 0000000000..6e5e3a4018 --- /dev/null +++ b/browser/components/pocket/test/unit/panels/browser.toml @@ -0,0 +1,4 @@ +[DEFAULT] +support-files = ["head.js"] + +["browser_pocket_main.js"] diff --git a/browser/components/pocket/test/unit/panels/browser_pocket_main.js b/browser/components/pocket/test/unit/panels/browser_pocket_main.js new file mode 100644 index 0000000000..c40ad4d5d4 --- /dev/null +++ b/browser/components/pocket/test/unit/panels/browser_pocket_main.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +function test_runner(test) { + let testTask = async () => { + // Before each + const sandbox = sinon.createSandbox(); + try { + await test({ + sandbox, + pktPanelMessaging: testGlobal.window.pktPanelMessaging, + }); + } finally { + // After each + sandbox.restore(); + } + }; + + // Copy the name of the test function to identify the test + Object.defineProperty(testTask, "name", { value: test.name }); + add_task(testTask); +} + +test_runner(async function test_clickHelper({ sandbox, pktPanelMessaging }) { + // Create a button to test the click helper with. + const button = document.createElement("button"); + button.setAttribute("href", "http://example.com"); + + // Setup a stub for the click itself. + sandbox.stub(pktPanelMessaging, "sendMessage"); + + // Create the click helper and trigger the click. + pktPanelMessaging.clickHelper(button, { source: "test-click", position: 2 }); + button.click(); + + Assert.ok( + pktPanelMessaging.sendMessage.calledOnce, + "Should fire sendMessage once with clickHelper click" + ); + Assert.ok( + pktPanelMessaging.sendMessage.calledWith("PKT_openTabWithUrl", { + url: "http://example.com", + source: "test-click", + position: 2, + }), + "Should send expected values to sendMessage with clickHelper click" + ); +}); diff --git a/browser/components/pocket/test/unit/panels/head.js b/browser/components/pocket/test/unit/panels/head.js new file mode 100644 index 0000000000..cc6ba440f1 --- /dev/null +++ b/browser/components/pocket/test/unit/panels/head.js @@ -0,0 +1,24 @@ +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +const testGlobal = { + PKT_PANEL_OVERLAY: class { + create() {} + }, + RPMRemoveMessageListener: () => {}, + RPMAddMessageListener: () => {}, + RPMSendAsyncMessage: () => {}, + window: {}, + self: {}, +}; + +Services.scriptloader.loadSubScript( + "chrome://pocket/content/panels/js/vendor.bundle.js", + testGlobal +); + +Services.scriptloader.loadSubScript( + "chrome://pocket/content/panels/js/main.bundle.js", + testGlobal +); diff --git a/browser/components/pocket/webpack.config.js b/browser/components/pocket/webpack.config.js new file mode 100644 index 0000000000..263a0dba27 --- /dev/null +++ b/browser/components/pocket/webpack.config.js @@ -0,0 +1,41 @@ +/* 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/. */ + +module.exports = { + mode: "production", + entry: { + main: "./content/panels/js/main.mjs", + }, + output: { + filename: "[name].bundle.js", + path: `${__dirname}/content/panels/js/`, + }, + module: { + rules: [ + { + test: /\.jsx$/, + exclude: /node_modules/, + loader: "babel-loader", + options: { + presets: ["@babel/preset-react"], + }, + }, + ], + }, + resolve: { + extensions: [".mjs", ".jsx"], + }, + optimization: { + minimize: false, + splitChunks: { + cacheGroups: { + vendor: { + test: /[\\/]node_modules[\\/](react|react-dom|scheduler|object-assign)[\\/]/, + name: "vendor", + chunks: "all", + }, + }, + }, + }, +}; diff --git a/browser/components/preferences/containers.inc.xhtml b/browser/components/preferences/containers.inc.xhtml new file mode 100644 index 0000000000..7e2a7172dd --- /dev/null +++ b/browser/components/preferences/containers.inc.xhtml @@ -0,0 +1,42 @@ +# 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/. + + + + + + + diff --git a/browser/components/preferences/tests/siteData/service_worker_test.js b/browser/components/preferences/tests/siteData/service_worker_test.js new file mode 100644 index 0000000000..2aba167d18 --- /dev/null +++ b/browser/components/preferences/tests/siteData/service_worker_test.js @@ -0,0 +1 @@ +// empty worker, always succeed! diff --git a/browser/components/preferences/tests/siteData/site_data_test.html b/browser/components/preferences/tests/siteData/site_data_test.html new file mode 100644 index 0000000000..758106b0a5 --- /dev/null +++ b/browser/components/preferences/tests/siteData/site_data_test.html @@ -0,0 +1,29 @@ + + + + + + + + Site Data Test + + + + +

    Site Data Test

    + + + diff --git a/browser/components/preferences/tests/subdialog.xhtml b/browser/components/preferences/tests/subdialog.xhtml new file mode 100644 index 0000000000..3d5402b4e5 --- /dev/null +++ b/browser/components/preferences/tests/subdialog.xhtml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + A sample sub-dialog for testing + + + + + + + diff --git a/browser/components/preferences/tests/subdialog2.xhtml b/browser/components/preferences/tests/subdialog2.xhtml new file mode 100644 index 0000000000..9ae04d5675 --- /dev/null +++ b/browser/components/preferences/tests/subdialog2.xhtml @@ -0,0 +1,29 @@ + + + + + + + + + + + A sample sub-dialog for testing + + + + + + + diff --git a/browser/components/preferences/translations.inc.xhtml b/browser/components/preferences/translations.inc.xhtml new file mode 100644 index 0000000000..5fed03da9b --- /dev/null +++ b/browser/components/preferences/translations.inc.xhtml @@ -0,0 +1,60 @@ +# 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/. + + + + + + +

    + + +
    +
    + +
    +
    +
    + +
    +
    +
    +

    +

    + +
    +
    +
    + + + + diff --git a/browser/components/privatebrowsing/content/aboutPrivateBrowsing.js b/browser/components/privatebrowsing/content/aboutPrivateBrowsing.js new file mode 100644 index 0000000000..0f0e23d81c --- /dev/null +++ b/browser/components/privatebrowsing/content/aboutPrivateBrowsing.js @@ -0,0 +1,406 @@ +/* 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/remote-page */ + +/** + * Determines whether a given value is a fluent id or plain text and adds it to an element + * @param {Array<[HTMLElement, string]>} items An array of [element, value] where value is + * a fluent id starting with "fluent:" or plain text + */ +function translateElements(items) { + items.forEach(([element, value]) => { + // Skip empty text or elements + if (!element || !value) { + return; + } + const fluentId = value.replace(/^fluent:/, ""); + if (fluentId !== value) { + document.l10n.setAttributes(element, fluentId); + } else { + element.textContent = value; + element.removeAttribute("data-l10n-id"); + } + }); +} + +function renderInfo({ + infoEnabled, + infoTitle, + infoTitleEnabled, + infoBody, + infoLinkText, + infoLinkUrl, + infoIcon, +} = {}) { + const container = document.querySelector(".info"); + if (infoEnabled === false) { + container.hidden = true; + return; + } + container.hidden = false; + + const titleEl = document.getElementById("info-title"); + const bodyEl = document.getElementById("info-body"); + const linkEl = document.getElementById("private-browsing-myths"); + + let feltPrivacyEnabled = RPMGetBoolPref( + "browser.privatebrowsing.felt-privacy-v1", + false + ); + + if (infoIcon && !feltPrivacyEnabled) { + container.style.backgroundImage = `url(${infoIcon})`; + } + + if (feltPrivacyEnabled) { + // Record exposure event for Felt Privacy experiment + window.FeltPrivacyExposureTelemetry(); + + infoTitleEnabled = true; + infoTitle = "fluent:about-private-browsing-felt-privacy-v1-info-header"; + infoBody = "fluent:about-private-browsing-felt-privacy-v1-info-body"; + infoLinkText = "fluent:about-private-browsing-felt-privacy-v1-info-link"; + } + + titleEl.hidden = !infoTitleEnabled; + + translateElements([ + [titleEl, infoTitle], + [bodyEl, infoBody], + [linkEl, infoLinkText], + ]); + + if (infoLinkUrl) { + linkEl.setAttribute("href", infoLinkUrl); + } +} + +async function renderPromo({ + messageId = null, + promoEnabled = false, + promoType = "VPN", + promoTitle, + promoTitleEnabled, + promoLinkText, + promoLinkType, + promoSectionStyle, + promoHeader, + promoImageLarge, + promoImageSmall, + promoButton = null, +} = {}) { + const shouldShow = await RPMSendQuery("ShouldShowPromo", { type: promoType }); + const container = document.querySelector(".promo"); + + if (!promoEnabled || !shouldShow) { + container.remove(); + return false; + } + + const titleEl = document.getElementById("private-browsing-promo-text"); + const linkEl = document.getElementById("private-browsing-promo-link"); + const promoHeaderEl = document.getElementById("promo-header"); + const infoContainerEl = document.querySelector(".info"); + const promoImageLargeEl = document.querySelector(".promo-image-large img"); + const promoImageSmallEl = document.querySelector(".promo-image-small img"); + const dismissBtn = document.querySelector("#dismiss-btn"); + + if (promoLinkType === "link") { + linkEl.classList.remove("primary"); + linkEl.classList.add("text-link", "promo-link"); + } + + if (promoButton?.action) { + linkEl.addEventListener("click", async event => { + event.preventDefault(); + + // Record promo click telemetry and set metrics as allow for spotlight + // modal opened on promo click if user is enrolled in an experiment + let isExperiment = window.PrivateBrowsingRecordClick("promo_link"); + const promoButtonData = promoButton?.action?.data; + if ( + promoButton?.action?.type === "SHOW_SPOTLIGHT" && + promoButtonData?.content + ) { + promoButtonData.content.metrics = isExperiment ? "allow" : "block"; + } + + await RPMSendQuery("SpecialMessageActionDispatch", promoButton.action); + }); + } else { + // If the action doesn't exist, remove the promo completely + container.remove(); + return false; + } + + const onDismissBtnClick = () => { + window.ASRouterMessage({ + type: "BLOCK_MESSAGE_BY_ID", + data: { id: messageId }, + }); + window.PrivateBrowsingRecordClick("dismiss_button"); + container.remove(); + }; + + if (dismissBtn && messageId) { + dismissBtn.addEventListener("click", onDismissBtnClick, { once: true }); + } + + if (promoSectionStyle) { + container.classList.add(promoSectionStyle); + + switch (promoSectionStyle) { + case "below-search": + container.remove(); + infoContainerEl?.insertAdjacentElement("beforebegin", container); + break; + case "top": + container.remove(); + document.body.insertAdjacentElement("afterbegin", container); + } + } + + if (promoImageLarge) { + promoImageLargeEl.src = promoImageLarge; + } else { + promoImageLargeEl.parentNode.remove(); + } + + if (promoImageSmall) { + promoImageSmallEl.src = promoImageSmall; + } else { + promoImageSmallEl.parentNode.remove(); + } + + if (!promoTitleEnabled) { + titleEl.remove(); + } + + if (!promoHeader) { + promoHeaderEl.remove(); + } + + translateElements([ + [titleEl, promoTitle], + [linkEl, promoLinkText], + [promoHeaderEl, promoHeader], + ]); + + // Only make promo section visible after adding content + // and translations to prevent layout shifting in page + container.classList.add("promo-visible"); + return true; +} + +/** + * For every PB newtab loaded, a second is pre-rendered in the background. + * We need to guard against invalid impressions by checking visibility state. + * If visible, record. Otherwise, listen for visibility change and record later. + */ +function recordOnceVisible(message) { + const recordImpression = () => { + if (document.visibilityState === "visible") { + window.ASRouterMessage({ + type: "IMPRESSION", + data: message, + }); + // Similar telemetry, but for Nimbus experiments + window.PrivateBrowsingPromoExposureTelemetry(); + document.removeEventListener("visibilitychange", recordImpression); + } + }; + + if (document.visibilityState === "visible") { + window.ASRouterMessage({ + type: "IMPRESSION", + data: message, + }); + // Similar telemetry, but for Nimbus experiments + window.PrivateBrowsingPromoExposureTelemetry(); + } else { + document.addEventListener("visibilitychange", recordImpression); + } +} + +// The PB newtab may be pre-rendered. Once the tab is visible, check to make sure the message wasn't blocked after the initial render. If it was, remove the promo. +function handlePromoOnPreload(message) { + async function removePromoIfBlocked() { + if (document.visibilityState === "visible") { + let blocked = await RPMSendQuery("IsPromoBlocked", message); + if (blocked) { + const container = document.querySelector(".promo"); + container.remove(); + } + } + document.removeEventListener("visibilitychange", removePromoIfBlocked); + } + // Only add the listener to pre-rendered tabs that aren't visible + if (document.visibilityState !== "visible") { + document.addEventListener("visibilitychange", removePromoIfBlocked); + } +} + +async function setupMessageConfig(config = null) { + let message = null; + + if (!config) { + let hideDefault = window.PrivateBrowsingShouldHideDefault(); + try { + let response = await window.ASRouterMessage({ + type: "PBNEWTAB_MESSAGE_REQUEST", + data: { hideDefault: !!hideDefault }, + }); + message = response?.message; + config = message?.content; + config.messageId = message?.id; + } catch (e) {} + } + + renderInfo(config); + let hasRendered = await renderPromo(config); + if (hasRendered && message) { + recordOnceVisible(message); + handlePromoOnPreload(message); + } + // For tests + document.documentElement.setAttribute("PrivateBrowsingRenderComplete", true); +} + +let SHOW_DEVTOOLS_MESSAGE = "ShowDevToolsMessage"; + +function showDevToolsMessage(msg) { + msg.data.content.messageId = "DEVTOOLS_MESSAGE"; + setupMessageConfig(msg?.data?.content); + RPMRemoveMessageListener(SHOW_DEVTOOLS_MESSAGE, showDevToolsMessage); +} + +document.addEventListener("DOMContentLoaded", function () { + // check the url to see if we're rendering a devtools message + if (document.location.toString().includes("debug")) { + RPMAddMessageListener(SHOW_DEVTOOLS_MESSAGE, showDevToolsMessage); + return; + } + if (!RPMIsWindowPrivate()) { + document.documentElement.classList.remove("private"); + document.documentElement.classList.add("normal"); + document + .getElementById("startPrivateBrowsing") + .addEventListener("click", function () { + RPMSendAsyncMessage("OpenPrivateWindow"); + }); + return; + } + + // The default info content is already in the markup, but we need to use JS to + // set up the learn more link, since it's dynamically generated. + const linkEl = document.getElementById("private-browsing-myths"); + linkEl.setAttribute( + "href", + RPMGetFormatURLPref("app.support.baseURL") + "private-browsing-myths" + ); + linkEl.addEventListener("click", () => { + window.PrivateBrowsingRecordClick("info_link"); + }); + + // We don't do this setup until now, because we don't want to record any impressions until we're + // sure we're actually running a private window, not just about:privatebrowsing in a normal window. + setupMessageConfig(); + + // Set up the private search banner. + const privateSearchBanner = document.getElementById("search-banner"); + + RPMSendQuery("ShouldShowSearchBanner", {}).then(engineName => { + if (engineName) { + document.l10n.setAttributes( + document.getElementById("about-private-browsing-search-banner-title"), + "about-private-browsing-search-banner-title", + { engineName } + ); + privateSearchBanner.removeAttribute("hidden"); + document.body.classList.add("showBanner"); + } + + // We set this attribute so that tests know when we are done. + document.documentElement.setAttribute("SearchBannerInitialized", true); + }); + + function hideSearchBanner() { + privateSearchBanner.hidden = true; + document.body.classList.remove("showBanner"); + RPMSendAsyncMessage("SearchBannerDismissed"); + } + + document + .getElementById("search-banner-close-button") + .addEventListener("click", () => { + hideSearchBanner(); + }); + + let openSearchOptions = document.getElementById( + "about-private-browsing-search-banner-description" + ); + let openSearchOptionsEvtHandler = evt => { + if ( + evt.target.id == "open-search-options-link" && + (evt.keyCode == evt.DOM_VK_RETURN || evt.type == "click") + ) { + RPMSendAsyncMessage("OpenSearchPreferences"); + hideSearchBanner(); + } + }; + openSearchOptions.addEventListener("click", openSearchOptionsEvtHandler); + openSearchOptions.addEventListener("keypress", openSearchOptionsEvtHandler); + + // Setup the search hand-off box. + let btn = document.getElementById("search-handoff-button"); + + let editable = document.getElementById("fake-editable"); + let DISABLE_SEARCH_TOPIC = "DisableSearch"; + let SHOW_SEARCH_TOPIC = "ShowSearch"; + let SEARCH_HANDOFF_TOPIC = "SearchHandoff"; + + function showSearch() { + btn.classList.remove("focused"); + btn.classList.remove("disabled"); + RPMRemoveMessageListener(SHOW_SEARCH_TOPIC, showSearch); + } + + function disableSearch() { + btn.classList.add("disabled"); + } + + function handoffSearch(text) { + RPMSendAsyncMessage(SEARCH_HANDOFF_TOPIC, { text }); + RPMAddMessageListener(SHOW_SEARCH_TOPIC, showSearch); + if (text) { + disableSearch(); + } else { + btn.classList.add("focused"); + RPMAddMessageListener(DISABLE_SEARCH_TOPIC, disableSearch); + } + } + btn.addEventListener("focus", function () { + handoffSearch(); + }); + btn.addEventListener("click", function () { + handoffSearch(); + }); + + // Hand-off any text that gets dropped or pasted + editable.addEventListener("drop", function (ev) { + ev.preventDefault(); + let text = ev.dataTransfer.getData("text"); + if (text) { + handoffSearch(text); + } + }); + editable.addEventListener("paste", function (ev) { + ev.preventDefault(); + handoffSearch(ev.clipboardData.getData("Text")); + }); + + // Load contentSearchUI so it sets the search engine icon and name for us. + new window.ContentSearchHandoffUIController(); +}); diff --git a/browser/components/privatebrowsing/content/assets/cookie-banners-begone.svg b/browser/components/privatebrowsing/content/assets/cookie-banners-begone.svg new file mode 100644 index 0000000000..66e47020cd --- /dev/null +++ b/browser/components/privatebrowsing/content/assets/cookie-banners-begone.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/components/privatebrowsing/content/assets/focus-logo.svg b/browser/components/privatebrowsing/content/assets/focus-logo.svg new file mode 100644 index 0000000000..30fa505b2a --- /dev/null +++ b/browser/components/privatebrowsing/content/assets/focus-logo.svg @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/components/privatebrowsing/content/assets/focus-promo.png b/browser/components/privatebrowsing/content/assets/focus-promo.png new file mode 100644 index 0000000000..b7badd3cdd Binary files /dev/null and b/browser/components/privatebrowsing/content/assets/focus-promo.png differ diff --git a/browser/components/privatebrowsing/content/assets/focus-qr-code.svg b/browser/components/privatebrowsing/content/assets/focus-qr-code.svg new file mode 100644 index 0000000000..f182567314 --- /dev/null +++ b/browser/components/privatebrowsing/content/assets/focus-qr-code.svg @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/components/privatebrowsing/content/assets/klar-qr-code.svg b/browser/components/privatebrowsing/content/assets/klar-qr-code.svg new file mode 100644 index 0000000000..2217ca055c --- /dev/null +++ b/browser/components/privatebrowsing/content/assets/klar-qr-code.svg @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/components/privatebrowsing/content/assets/moz-vpn.svg b/browser/components/privatebrowsing/content/assets/moz-vpn.svg new file mode 100644 index 0000000000..038d2952b6 --- /dev/null +++ b/browser/components/privatebrowsing/content/assets/moz-vpn.svg @@ -0,0 +1,4 @@ + + diff --git a/browser/components/privatebrowsing/content/assets/private-promo-asset.svg b/browser/components/privatebrowsing/content/assets/private-promo-asset.svg new file mode 100644 index 0000000000..841384f015 --- /dev/null +++ b/browser/components/privatebrowsing/content/assets/private-promo-asset.svg @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/components/privatebrowsing/content/assets/vpn-logo.svg b/browser/components/privatebrowsing/content/assets/vpn-logo.svg new file mode 100644 index 0000000000..01e7c72d8d --- /dev/null +++ b/browser/components/privatebrowsing/content/assets/vpn-logo.svg @@ -0,0 +1,6 @@ + + + + diff --git a/browser/components/privatebrowsing/jar.mn b/browser/components/privatebrowsing/jar.mn new file mode 100644 index 0000000000..f7f78e615f --- /dev/null +++ b/browser/components/privatebrowsing/jar.mn @@ -0,0 +1,9 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +browser.jar: + content/browser/aboutPrivateBrowsing.css (content/aboutPrivateBrowsing.css) + content/browser/aboutPrivateBrowsing.html (content/aboutPrivateBrowsing.html) + content/browser/aboutPrivateBrowsing.js (content/aboutPrivateBrowsing.js) + content/browser/assets/ (content/assets/*) diff --git a/browser/components/privatebrowsing/metrics.yaml b/browser/components/privatebrowsing/metrics.yaml new file mode 100644 index 0000000000..54f55be40b --- /dev/null +++ b/browser/components/privatebrowsing/metrics.yaml @@ -0,0 +1,50 @@ +# 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/. + +# Adding a new metric? We have docs for that! +# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html + +--- +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 +$tags: + - 'Firefox :: Private Browsing' + +private_browsing.reset_pbm: + confirm_panel: + type: event + description: > + Confirm panel show / hide event. + extra_keys: + action: + type: string + description: Whether the panel was hidden or shown. + reason: + type: string + description: Reason for the action. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1853698 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1853698#c3 + data_sensitivity: + - interaction + notification_emails: + - pbz@mozilla.com + expires: never + reset_action: + type: event + description: > + Dispatched whenever PBM is restarted / reset via the resetPBM feature. + extra_keys: + did_confirm: + type: boolean + description: Whether the user confirmed the reset action via the confirmation dialog. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1853698 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1853698#c3 + data_sensitivity: + - interaction + notification_emails: + - pbz@mozilla.com + expires: never diff --git a/browser/components/privatebrowsing/moz.build b/browser/components/privatebrowsing/moz.build new file mode 100644 index 0000000000..822b875e2a --- /dev/null +++ b/browser/components/privatebrowsing/moz.build @@ -0,0 +1,18 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +BROWSER_CHROME_MANIFESTS += [ + "test/browser/browser.toml", +] + +JAR_MANIFESTS += ["jar.mn"] + +EXTRA_JS_MODULES += [ + "ResetPBMPanel.sys.mjs", +] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Private Browsing") diff --git a/browser/components/privatebrowsing/test/browser/browser.toml b/browser/components/privatebrowsing/test/browser/browser.toml new file mode 100644 index 0000000000..2c37cd8a48 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser.toml @@ -0,0 +1,123 @@ +[DEFAULT] +tags = "openwindow" +support-files = [ + "browser_privatebrowsing_concurrent_page.html", + "browser_privatebrowsing_geoprompt_page.html", + "browser_privatebrowsing_xrprompt_page.html", + "browser_privatebrowsing_localStorage_before_after_page.html", + "browser_privatebrowsing_localStorage_before_after_page2.html", + "browser_privatebrowsing_localStorage_page1.html", + "browser_privatebrowsing_localStorage_page2.html", + "browser_privatebrowsing_placesTitleNoUpdate.html", + "browser_privatebrowsing_protocolhandler_page.html", + "browser_privatebrowsing_windowtitle_page.html", + "head.js", + "title.sjs", + "empty_file.html", + "file_favicon.html", + "file_favicon.png", + "file_favicon.png^headers^", + "file_triggeringprincipal_oa.html", +] + +["browser_oa_private_browsing_window.js"] + +["browser_privatebrowsing_DownloadLastDirWithCPS.js"] + +["browser_privatebrowsing_about.js"] +skip-if = ["verify"] +tags = "trackingprotection" + +["browser_privatebrowsing_aboutSessionRestore.js"] + +["browser_privatebrowsing_about_cookie_banners_promo.js"] + +["browser_privatebrowsing_about_default_pin_promo.js"] +skip-if = [ + "os == 'mac'", + "os == 'linux'", + "os == 'win' && msix", # We don't support pinning in MSIX builds +] + +["browser_privatebrowsing_about_default_promo.js"] + +["browser_privatebrowsing_about_focus_promo.js"] + +["browser_privatebrowsing_about_nimbus.js"] + +["browser_privatebrowsing_about_nimbus_dismiss.js"] + +["browser_privatebrowsing_about_nimbus_impressions.js"] + +["browser_privatebrowsing_about_nimbus_messaging.js"] + +["browser_privatebrowsing_about_search_banner.js"] + +["browser_privatebrowsing_beacon.js"] + +["browser_privatebrowsing_blobUrl.js"] + +["browser_privatebrowsing_cache.js"] + +["browser_privatebrowsing_certexceptionsui.js"] + +["browser_privatebrowsing_cleanup.js"] + +["browser_privatebrowsing_concurrent.js"] +skip-if = ["release_or_beta"] + +["browser_privatebrowsing_context_and_chromeFlags.js"] + +["browser_privatebrowsing_crh.js"] + +["browser_privatebrowsing_downloadLastDir.js"] +skip-if = ["verify"] + +["browser_privatebrowsing_downloadLastDir_c.js"] + +["browser_privatebrowsing_downloadLastDir_toggle.js"] + +["browser_privatebrowsing_favicon.js"] + +["browser_privatebrowsing_history_shift_click.js"] + +["browser_privatebrowsing_last_private_browsing_context_exited.js"] + +["browser_privatebrowsing_lastpbcontextexited.js"] + +["browser_privatebrowsing_localStorage.js"] + +["browser_privatebrowsing_localStorage_before_after.js"] + +["browser_privatebrowsing_newtab_from_popup.js"] + +["browser_privatebrowsing_noSessionRestoreMenuOption.js"] + +["browser_privatebrowsing_nonbrowser.js"] + +["browser_privatebrowsing_opendir.js"] + +["browser_privatebrowsing_placesTitleNoUpdate.js"] + +["browser_privatebrowsing_placestitle.js"] + +["browser_privatebrowsing_protocolhandler.js"] + +["browser_privatebrowsing_rememberprompt.js"] +tags = "geolocation xr" + +["browser_privatebrowsing_resetPBM.js"] + +["browser_privatebrowsing_sidebar.js"] + +["browser_privatebrowsing_theming.js"] + +["browser_privatebrowsing_ui.js"] + +["browser_privatebrowsing_urlbarfocus.js"] + +["browser_privatebrowsing_windowtitle.js"] + +["browser_privatebrowsing_zoom.js"] + +["browser_privatebrowsing_zoomrestore.js"] diff --git a/browser/components/privatebrowsing/test/browser/browser_oa_private_browsing_window.js b/browser/components/privatebrowsing/test/browser/browser_oa_private_browsing_window.js new file mode 100644 index 0000000000..a1b9420171 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_oa_private_browsing_window.js @@ -0,0 +1,64 @@ +"use strict"; + +const PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); +const TEST_PAGE = PATH + "file_triggeringprincipal_oa.html"; +const DUMMY_PAGE = PATH + "empty_file.html"; + +add_task( + async function test_principal_right_click_open_link_in_new_private_win() { + await BrowserTestUtils.withNewTab(TEST_PAGE, async function (browser) { + let promiseNewWindow = BrowserTestUtils.waitForNewWindow({ + url: DUMMY_PAGE, + }); + + // simulate right-click open link in new private window + BrowserTestUtils.waitForEvent(document, "popupshown", false, event => { + document.getElementById("context-openlinkprivate").doCommand(); + event.target.hidePopup(); + return true; + }); + BrowserTestUtils.synthesizeMouseAtCenter( + "#checkPrincipalOA", + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + let privateWin = await promiseNewWindow; + + await SpecialPowers.spawn( + privateWin.gBrowser.selectedBrowser, + [{ DUMMY_PAGE, TEST_PAGE }], + // eslint-disable-next-line no-shadow + async function ({ DUMMY_PAGE, TEST_PAGE }) { + // eslint-disable-line + + let channel = content.docShell.currentDocumentChannel; + is( + channel.URI.spec, + DUMMY_PAGE, + "sanity check to ensure we check principal for right URI" + ); + + let triggeringPrincipal = channel.loadInfo.triggeringPrincipal; + ok( + triggeringPrincipal.isContentPrincipal, + "sanity check to ensure principal is a contentPrincipal" + ); + is( + triggeringPrincipal.spec, + TEST_PAGE, + "test page must be the triggering page" + ); + is( + triggeringPrincipal.originAttributes.privateBrowsingId, + 1, + "must have correct privateBrowsingId" + ); + } + ); + await BrowserTestUtils.closeWindow(privateWin); + }); + } +); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_DownloadLastDirWithCPS.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_DownloadLastDirWithCPS.js new file mode 100644 index 0000000000..eeaeee033c --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_DownloadLastDirWithCPS.js @@ -0,0 +1,445 @@ +/* -*- 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/. */ + +var gTests; +function test() { + waitForExplicitFinish(); + requestLongerTimeout(2); + runTest().catch(ex => ok(false, ex)); +} + +/* + * ================ + * Helper functions + * ================ + */ + +function createWindow(aOptions) { + return new Promise(resolve => whenNewWindowLoaded(aOptions, resolve)); +} + +function getFile(downloadLastDir, aURI) { + return downloadLastDir.getFileAsync(aURI); +} + +function setFile(downloadLastDir, aURI, aValue) { + downloadLastDir.setFile(aURI, aValue); + return new Promise(resolve => executeSoon(resolve)); +} + +function clearHistoryAndWait() { + clearHistory(); + return new Promise(resolve => executeSoon(_ => executeSoon(resolve))); +} + +/* + * =================== + * Function with tests + * =================== + */ + +async function runTest() { + let { FileUtils } = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" + ); + let { DownloadLastDir } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadLastDir.sys.mjs" + ); + + let tmpDir = FileUtils.getDir("TmpD", []); + let dir1 = newDirectory(); + let dir2 = newDirectory(); + let dir3 = newDirectory(); + + let uri1 = Services.io.newURI("http://test1.com/"); + let uri2 = Services.io.newURI("http://test2.com/"); + let uri3 = Services.io.newURI("http://test3.com/"); + let uri4 = Services.io.newURI("http://test4.com/"); + + // cleanup functions registration + registerCleanupFunction(function () { + Services.prefs.clearUserPref("browser.download.lastDir.savePerSite"); + Services.prefs.clearUserPref("browser.download.lastDir"); + [dir1, dir2, dir3].forEach(dir => dir.remove(true)); + win.close(); + pbWin.close(); + }); + + function checkDownloadLastDir(gDownloadLastDir, aLastDir) { + is( + gDownloadLastDir.file.path, + aLastDir.path, + "gDownloadLastDir should point to the expected last directory" + ); + return getFile(gDownloadLastDir, uri1); + } + + function checkDownloadLastDirNull(gDownloadLastDir) { + is(gDownloadLastDir.file, null, "gDownloadLastDir should be null"); + return getFile(gDownloadLastDir, uri1); + } + + /* + * ================================ + * Create a regular and a PB window + * ================================ + */ + + let win = await createWindow({ private: false }); + let pbWin = await createWindow({ private: true }); + + let downloadLastDir = new DownloadLastDir(win); + let pbDownloadLastDir = new DownloadLastDir(pbWin); + + /* + * ================== + * Beginning of tests + * ================== + */ + + is( + typeof downloadLastDir, + "object", + "downloadLastDir should be a valid object" + ); + is(downloadLastDir.file, null, "LastDir pref should be null to start with"); + + // set up last dir + await setFile(downloadLastDir, null, tmpDir); + is( + downloadLastDir.file.path, + tmpDir.path, + "LastDir should point to the tmpDir" + ); + isnot( + downloadLastDir.file, + tmpDir, + "downloadLastDir.file should not be pointing to tmpDir" + ); + + // set uri1 to dir1, all should now return dir1 + // also check that a new object is returned + await setFile(downloadLastDir, uri1, dir1); + is( + downloadLastDir.file.path, + dir1.path, + "downloadLastDir should return dir1" + ); + isnot( + downloadLastDir.file, + dir1, + "downloadLastDir.file should not return dir1" + ); + is( + (await getFile(downloadLastDir, uri1)).path, + dir1.path, + "uri1 should return dir1" + ); // set in CPS + isnot( + await getFile(downloadLastDir, uri1), + dir1, + "getFile on uri1 should not return dir1" + ); + is( + (await getFile(downloadLastDir, uri2)).path, + dir1.path, + "uri2 should return dir1" + ); // fallback + isnot( + await getFile(downloadLastDir, uri2), + dir1, + "getFile on uri2 should not return dir1" + ); + is( + (await getFile(downloadLastDir, uri3)).path, + dir1.path, + "uri3 should return dir1" + ); // fallback + isnot( + await getFile(downloadLastDir, uri3), + dir1, + "getFile on uri3 should not return dir1" + ); + is( + (await getFile(downloadLastDir, uri4)).path, + dir1.path, + "uri4 should return dir1" + ); // fallback + isnot( + await getFile(downloadLastDir, uri4), + dir1, + "getFile on uri4 should not return dir1" + ); + + // set uri2 to dir2, all except uri1 should now return dir2 + await setFile(downloadLastDir, uri2, dir2); + is( + downloadLastDir.file.path, + dir2.path, + "downloadLastDir should point to dir2" + ); + is( + (await getFile(downloadLastDir, uri1)).path, + dir1.path, + "uri1 should return dir1" + ); // set in CPS + is( + (await getFile(downloadLastDir, uri2)).path, + dir2.path, + "uri2 should return dir2" + ); // set in CPS + is( + (await getFile(downloadLastDir, uri3)).path, + dir2.path, + "uri3 should return dir2" + ); // fallback + is( + (await getFile(downloadLastDir, uri4)).path, + dir2.path, + "uri4 should return dir2" + ); // fallback + + // set uri3 to dir3, all except uri1 and uri2 should now return dir3 + await setFile(downloadLastDir, uri3, dir3); + is( + downloadLastDir.file.path, + dir3.path, + "downloadLastDir should point to dir3" + ); + is( + (await getFile(downloadLastDir, uri1)).path, + dir1.path, + "uri1 should return dir1" + ); // set in CPS + is( + (await getFile(downloadLastDir, uri2)).path, + dir2.path, + "uri2 should return dir2" + ); // set in CPS + is( + (await getFile(downloadLastDir, uri3)).path, + dir3.path, + "uri3 should return dir3" + ); // set in CPS + is( + (await getFile(downloadLastDir, uri4)).path, + dir3.path, + "uri4 should return dir4" + ); // fallback + + // set uri1 to dir2, all except uri3 should now return dir2 + await setFile(downloadLastDir, uri1, dir2); + is( + downloadLastDir.file.path, + dir2.path, + "downloadLastDir should point to dir2" + ); + is( + (await getFile(downloadLastDir, uri1)).path, + dir2.path, + "uri1 should return dir2" + ); // set in CPS + is( + (await getFile(downloadLastDir, uri2)).path, + dir2.path, + "uri2 should return dir2" + ); // set in CPS + is( + (await getFile(downloadLastDir, uri3)).path, + dir3.path, + "uri3 should return dir3" + ); // set in CPS + is( + (await getFile(downloadLastDir, uri4)).path, + dir2.path, + "uri4 should return dir2" + ); // fallback + + await clearHistoryAndWait(); + + // check clearHistory removes all data + is(downloadLastDir.file, null, "clearHistory removes all data"); + is(await getFile(downloadLastDir, uri1), null, "uri1 should point to null"); + is(await getFile(downloadLastDir, uri2), null, "uri2 should point to null"); + is(await getFile(downloadLastDir, uri3), null, "uri3 should point to null"); + is(await getFile(downloadLastDir, uri4), null, "uri4 should point to null"); + + await setFile(downloadLastDir, null, tmpDir); + + // check data set outside PB mode is remembered + is( + (await checkDownloadLastDir(pbDownloadLastDir, tmpDir)).path, + tmpDir.path, + "uri1 should return the expected last directory" + ); + is( + (await checkDownloadLastDir(downloadLastDir, tmpDir)).path, + tmpDir.path, + "uri1 should return the expected last directory" + ); + await clearHistoryAndWait(); + + await setFile(downloadLastDir, uri1, dir1); + + // check data set using CPS outside PB mode is remembered + is( + (await checkDownloadLastDir(pbDownloadLastDir, dir1)).path, + dir1.path, + "uri1 should return the expected last directory" + ); + is( + (await checkDownloadLastDir(downloadLastDir, dir1)).path, + dir1.path, + "uri1 should return the expected last directory" + ); + await clearHistoryAndWait(); + + // check data set inside PB mode is forgotten + await setFile(pbDownloadLastDir, null, tmpDir); + + is( + (await checkDownloadLastDir(pbDownloadLastDir, tmpDir)).path, + tmpDir.path, + "uri1 should return the expected last directory" + ); + is( + await checkDownloadLastDirNull(downloadLastDir), + null, + "uri1 should return the expected last directory" + ); + + await clearHistoryAndWait(); + + // check data set using CPS inside PB mode is forgotten + await setFile(pbDownloadLastDir, uri1, dir1); + + is( + (await checkDownloadLastDir(pbDownloadLastDir, dir1)).path, + dir1.path, + "uri1 should return the expected last directory" + ); + is( + await checkDownloadLastDirNull(downloadLastDir), + null, + "uri1 should return the expected last directory" + ); + + // check data set outside PB mode but changed inside is remembered correctly + await setFile(downloadLastDir, uri1, dir1); + await setFile(pbDownloadLastDir, uri1, dir2); + is( + (await checkDownloadLastDir(pbDownloadLastDir, dir2)).path, + dir2.path, + "uri1 should return the expected last directory" + ); + is( + (await checkDownloadLastDir(downloadLastDir, dir1)).path, + dir1.path, + "uri1 should return the expected last directory" + ); + + /* + * ==================== + * Create new PB window + * ==================== + */ + + // check that the last dir store got cleared in a new PB window + pbWin.close(); + // And give it time to close + await new Promise(resolve => executeSoon(resolve)); + + pbWin = await createWindow({ private: true }); + pbDownloadLastDir = new DownloadLastDir(pbWin); + + is( + (await checkDownloadLastDir(pbDownloadLastDir, dir1)).path, + dir1.path, + "uri1 should return the expected last directory" + ); + + await clearHistoryAndWait(); + + // check clearHistory inside PB mode clears data outside PB mode + await setFile(pbDownloadLastDir, uri1, dir2); + + await clearHistoryAndWait(); + + is( + await checkDownloadLastDirNull(downloadLastDir), + null, + "uri1 should return the expected last directory" + ); + is( + await checkDownloadLastDirNull(pbDownloadLastDir), + null, + "uri1 should return the expected last directory" + ); + + // check that disabling CPS works + Services.prefs.setBoolPref("browser.download.lastDir.savePerSite", false); + + await setFile(downloadLastDir, uri1, dir1); + is(downloadLastDir.file.path, dir1.path, "LastDir should be set to dir1"); + is( + (await getFile(downloadLastDir, uri1)).path, + dir1.path, + "uri1 should return dir1" + ); + is( + (await getFile(downloadLastDir, uri2)).path, + dir1.path, + "uri2 should return dir1" + ); + is( + (await getFile(downloadLastDir, uri3)).path, + dir1.path, + "uri3 should return dir1" + ); + is( + (await getFile(downloadLastDir, uri4)).path, + dir1.path, + "uri4 should return dir1" + ); + + downloadLastDir.setFile(uri2, dir2); + is(downloadLastDir.file.path, dir2.path, "LastDir should be set to dir2"); + is( + (await getFile(downloadLastDir, uri1)).path, + dir2.path, + "uri1 should return dir2" + ); + is( + (await getFile(downloadLastDir, uri2)).path, + dir2.path, + "uri2 should return dir2" + ); + is( + (await getFile(downloadLastDir, uri3)).path, + dir2.path, + "uri3 should return dir2" + ); + is( + (await getFile(downloadLastDir, uri4)).path, + dir2.path, + "uri4 should return dir2" + ); + + Services.prefs.clearUserPref("browser.download.lastDir.savePerSite"); + + // check that passing null to setFile clears the stored value + await setFile(downloadLastDir, uri3, dir3); + is( + (await getFile(downloadLastDir, uri3)).path, + dir3.path, + "LastDir should be set to dir3" + ); + await setFile(downloadLastDir, uri3, null); + is(await getFile(downloadLastDir, uri3), null, "uri3 should return null"); + + await clearHistoryAndWait(); + + finish(); +} diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about.js new file mode 100644 index 0000000000..af8bac9727 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about.js @@ -0,0 +1,266 @@ +/* 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/. */ + +ChromeUtils.defineESModuleGetters(this, { + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(this, "UrlbarTestUtils", () => { + const { UrlbarTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/UrlbarTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +/** + * Clicks the given link and checks this opens the given URI in the new tab. + * + * This function does not return to the previous page. + */ +async function testLinkOpensUrl({ win, tab, elementId, expectedUrl }) { + let loadedPromise = BrowserTestUtils.waitForNewTab(win.gBrowser, url => + url.startsWith(expectedUrl) + ); + await SpecialPowers.spawn(tab, [elementId], async function (elemId) { + content.document.getElementById(elemId).click(); + }); + await loadedPromise; + is( + win.gBrowser.selectedBrowser.currentURI.spec, + expectedUrl, + `Clicking ${elementId} opened ${expectedUrl} in the same tab.` + ); +} + +let expectedEngineAlias; +let expectedIconURL; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault", true], + // Enable suggestions in this test. Otherwise, the behaviour of the + // content search box changes. + ["browser.search.suggest.enabled", true], + ], + }); + + const originalPrivateDefault = await Services.search.getDefaultPrivate(); + // We have to use a built-in engine as we are currently hard-coding the aliases. + const privateEngine = await Services.search.getEngineByName("DuckDuckGo"); + await Services.search.setDefaultPrivate( + privateEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + expectedEngineAlias = privateEngine.aliases[0]; + expectedIconURL = privateEngine.getIconURL(); + + registerCleanupFunction(async () => { + await Services.search.setDefaultPrivate( + originalPrivateDefault, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + }); +}); + +/** + * Tests the private-browsing-myths link in "about:privatebrowsing". + */ +add_task(async function test_myths_link() { + Services.prefs.setCharPref("app.support.baseURL", "https://example.com/"); + registerCleanupFunction(function () { + Services.prefs.clearUserPref("app.support.baseURL"); + }); + + let { win, tab } = await openAboutPrivateBrowsing(); + + await testLinkOpensUrl({ + win, + tab, + elementId: "private-browsing-myths", + expectedUrl: "https://example.com/private-browsing-myths", + }); + + await BrowserTestUtils.closeWindow(win); +}); + +function urlBarHasHiddenFocus(win) { + return win.gURLBar.focused && !win.gURLBar.hasAttribute("focused"); +} + +function urlBarHasNormalFocus(win) { + return win.gURLBar.hasAttribute("focused"); +} + +/** + * Tests that we have the correct icon displayed. + */ +add_task(async function test_search_icon() { + let { win, tab } = await openAboutPrivateBrowsing(); + + await SpecialPowers.spawn(tab, [expectedIconURL], async function (iconURL) { + is( + content.document.body.getAttribute("style"), + `--newtab-search-icon: url(${iconURL});`, + "Should have the correct icon URL for the logo" + ); + }); + + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests the search hand-off on character keydown in "about:privatebrowsing". + */ +add_task(async function test_search_handoff_on_keydown() { + let { win, tab } = await openAboutPrivateBrowsing(); + + await SpecialPowers.spawn(tab, [], async function () { + let btn = content.document.getElementById("search-handoff-button"); + btn.click(); + ok(btn.classList.contains("focused"), "in-content search has focus styles"); + }); + ok(urlBarHasHiddenFocus(win), "Urlbar has hidden focus"); + + // Expect two searches, one to enter search mode and then another in search + // mode. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(win); + + await new Promise(r => EventUtils.synthesizeKey("f", {}, win, r)); + await SpecialPowers.spawn(tab, [], async function () { + ok( + content.document + .getElementById("search-handoff-button") + .classList.contains("disabled"), + "in-content search is disabled" + ); + }); + await searchPromise; + ok(urlBarHasNormalFocus(win), "Urlbar has normal focus"); + is(win.gURLBar.value, "f", "url bar has search text"); + + // Close the popup. + await UrlbarTestUtils.promisePopupClose(win); + + // Hitting ESC should reshow the in-content search + await new Promise(r => EventUtils.synthesizeKey("KEY_Escape", {}, win, r)); + await SpecialPowers.spawn(tab, [], async function () { + ok( + !content.document + .getElementById("search-handoff-button") + .classList.contains("disabled"), + "in-content search is not disabled" + ); + }); + + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests the search hand-off on composition start in "about:privatebrowsing". + */ +add_task(async function test_search_handoff_on_composition_start() { + let { win, tab } = await openAboutPrivateBrowsing(); + + await SpecialPowers.spawn(tab, [], async function () { + content.document.getElementById("search-handoff-button").click(); + }); + ok(urlBarHasHiddenFocus(win), "Urlbar has hidden focus"); + await new Promise(r => + EventUtils.synthesizeComposition({ type: "compositionstart" }, win, r) + ); + ok(urlBarHasNormalFocus(win), "Urlbar has normal focus"); + + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests the search hand-off on paste in "about:privatebrowsing". + */ +add_task(async function test_search_handoff_on_paste() { + let { win, tab } = await openAboutPrivateBrowsing(); + + await SpecialPowers.spawn(tab, [], async function () { + content.document.getElementById("search-handoff-button").click(); + }); + ok(urlBarHasHiddenFocus(win), "Urlbar has hidden focus"); + var helper = SpecialPowers.Cc[ + "@mozilla.org/widget/clipboardhelper;1" + ].getService(SpecialPowers.Ci.nsIClipboardHelper); + helper.copyString("words"); + + // Expect two searches, one to enter search mode and then another in search + // mode. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(win); + + await new Promise(r => + EventUtils.synthesizeKey("v", { accelKey: true }, win, r) + ); + + await searchPromise; + + ok(urlBarHasNormalFocus(win), "Urlbar has normal focus"); + is(win.gURLBar.value, "words", "Urlbar has search text"); + + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests that handoff enters search mode when suggestions are disabled. + */ +add_task(async function test_search_handoff_search_mode() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", false]], + }); + + let { win, tab } = await openAboutPrivateBrowsing(); + + await SpecialPowers.spawn(tab, [], async function () { + let btn = content.document.getElementById("search-handoff-button"); + btn.click(); + ok(btn.classList.contains("focused"), "in-content search has focus styles"); + }); + ok(urlBarHasHiddenFocus(win), "Urlbar has hidden focus"); + + // Expect two searches, one to enter search mode and then another in search + // mode. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(win); + + await new Promise(r => EventUtils.synthesizeKey("f", {}, win, r)); + await SpecialPowers.spawn(tab, [], async function () { + ok( + content.document + .getElementById("search-handoff-button") + .classList.contains("disabled"), + "in-content search is disabled" + ); + }); + await searchPromise; + ok(urlBarHasNormalFocus(win), "Urlbar has normal focus"); + await UrlbarTestUtils.assertSearchMode(win, { + engineName: "DuckDuckGo", + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + entry: "handoff", + }); + is(win.gURLBar.value, "f", "url bar has search text"); + + // Close the popup. + await UrlbarTestUtils.exitSearchMode(win); + await UrlbarTestUtils.promisePopupClose(win); + + // Hitting ESC should reshow the in-content search + await new Promise(r => EventUtils.synthesizeKey("KEY_Escape", {}, win, r)); + await SpecialPowers.spawn(tab, [], async function () { + ok( + !content.document + .getElementById("search-handoff-button") + .classList.contains("disabled"), + "in-content search is not disabled" + ); + }); + + await BrowserTestUtils.closeWindow(win); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_aboutSessionRestore.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_aboutSessionRestore.js new file mode 100644 index 0000000000..a838a7e52d --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_aboutSessionRestore.js @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This test checks that the session restore button from about:sessionrestore +// is disabled in private mode +add_task(async function testNoSessionRestoreButton() { + // Opening, then closing, a private window shouldn't create session data. + (await BrowserTestUtils.openNewBrowserWindow({ private: true })).close(); + + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + let tab = BrowserTestUtils.addTab(win.gBrowser, "about:sessionrestore"); + let browser = tab.linkedBrowser; + + await BrowserTestUtils.browserLoaded(browser); + + await SpecialPowers.spawn(browser, [], async function () { + Assert.ok( + content.document.getElementById("errorTryAgain").disabled, + "The Restore about:sessionrestore button should be disabled" + ); + }); + + win.close(); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_cookie_banners_promo.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_cookie_banners_promo.js new file mode 100644 index 0000000000..2f9c87e1e1 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_cookie_banners_promo.js @@ -0,0 +1,107 @@ +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); + +const promoImgSrc = "chrome://browser/content/assets/cookie-banners-begone.svg"; + +async function resetState() { + await Promise.all([ASRouter.resetMessageState(), ASRouter.unblockAll()]); +} + +add_setup(async function setup() { + registerCleanupFunction(resetState); + await resetState(); +}); + +add_task(async function test_cookie_banners_promo_user_set_prefs() { + await resetState(); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.promo.cookiebanners.enabled", true], + // The message's targeting is looking for the following prefs not being 0 + ["cookiebanners.service.mode", 0], + ["cookiebanners.service.mode.privateBrowsing", 0], + ], + }); + await ASRouter.onPrefChange(); + + const { win, tab } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab, [promoImgSrc], async function (imgSrc) { + const promoImage = content.document.querySelector( + ".promo-image-large > img" + ); + Assert.notStrictEqual( + promoImage?.src, + imgSrc, + "Cookie banner reduction promo is not shown" + ); + }); + + await BrowserTestUtils.closeWindow(win); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_cookie_banners_promo() { + await resetState(); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.promo.cookiebanners.enabled", true], + ["cookiebanners.service.mode.privateBrowsing", 1], + ], + }); + await ASRouter.onPrefChange(); + + const sandbox = sinon.createSandbox(); + const expectedUrl = Services.urlFormatter.formatURL( + "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/cookie-banner-reduction" + ); + + const { win, tab } = await openTabAndWaitForRender(); + let triedToOpenTab = new Promise(resolve => { + sandbox.stub(win, "openLinkIn").callsFake((url, where) => { + is(url, expectedUrl, "The link should open the expected URL"); + is( + where, + "tabshifted", + "The link should open the expected URL in a new foreground tab" + ); + resolve(); + }); + }); + + await SpecialPowers.spawn(tab, [promoImgSrc], async function (imgSrc) { + const promoImage = content.document.querySelector( + ".promo-image-large > img" + ); + Assert.strictEqual( + promoImage?.src, + imgSrc, + "Cookie banner reduction promo is shown" + ); + let linkEl = content.document.getElementById("private-browsing-promo-link"); + linkEl.click(); + }); + + await triedToOpenTab; + sandbox.restore(); + + ok(true, "The link was clicked and the new tab opened"); + + let { win: win2, tab: tab2 } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab2, [promoImgSrc], async function (imgSrc) { + const promoImage = content.document.querySelector( + ".promo-image-large > img" + ); + Assert.notStrictEqual( + promoImage?.src, + imgSrc, + "Cookie banner reduction promo is no longer shown after clicking the link" + ); + }); + + await BrowserTestUtils.closeWindow(win2); + await BrowserTestUtils.closeWindow(win); + await SpecialPowers.popPrefEnv(); +}); 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 new file mode 100644 index 0000000000..bc62556b12 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_default_pin_promo.js @@ -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/. */ + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +const sandbox = sinon.createSandbox(); + +add_setup(async function () { + ASRouter.resetMessageState(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.promo.pin.enabled", true]], + }); + await ASRouter.onPrefChange(); + // Stub out the doesAppNeedPin to true so that Pin Promo targeting evaluates true + + sandbox.stub(ShellService, "doesAppNeedPin").withArgs(true).returns(true); + registerCleanupFunction(async () => { + sandbox.restore(); + }); +}); + +add_task(async function test_pin_promo() { + 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, "Pin promo is shown"); + is( + promoHeader.getAttribute("data-l10n-id"), + "about-private-browsing-pin-promo-header", + "Correct default values are shown" + ); + }); + + let { win: win2 } = await openTabAndWaitForRender(); + let { win: win3 } = await openTabAndWaitForRender(); + let { win: win4, tab: tab4 } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab4, [], async function () { + is( + content.document.getElementById(".private-browsing-promo-link"), + null, + "should no longer render the promo after 3 impressions" + ); + }); + + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); + 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_about_default_promo.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_default_promo.js new file mode 100644 index 0000000000..82c6fea011 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_default_promo.js @@ -0,0 +1,224 @@ +/* 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 PromoInfo = { + FOCUS: { enabledPref: "browser.promo.focus.enabled" }, + VPN: { enabledPref: "browser.vpn_promo.enabled" }, + PIN: { enabledPref: "browser.promo.pin.enabled" }, + COOKIE_BANNERS: { enabledPref: "browser.promo.cookiebanners.enabled" }, +}; + +const sandbox = sinon.createSandbox(); + +async function resetState() { + await Promise.all([ + ASRouter.resetMessageState(), + ASRouter.resetGroupsState(), + ASRouter.unblockAll(), + sandbox.restore(), + ]); +} + +add_setup(async function () { + registerCleanupFunction(resetState); + await resetState(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.promo.pin.enabled", false]], + }); + await ASRouter.onPrefChange(); +}); + +add_task(async function test_privatebrowsing_asrouter_messages_state() { + await resetState(); + let pinPromoMessage = ASRouter.state.messages.find( + m => m.id === "PB_NEWTAB_PIN_PROMO" + ); + Assert.ok(pinPromoMessage, "Pin Promo message found"); + + const initialMessages = JSON.parse(JSON.stringify(ASRouter.state.messages)); + + let { win, tab } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab, [], async function () { + const promoContainer = content.document.querySelector(".promo"); + ok(promoContainer, "Focus promo is shown"); + }); + + Assert.equal( + ASRouter.state.messages.filter(m => m.id === "PB_NEWTAB_PIN_PROMO").length, + 0, + "Pin Promo message removed from state when Promotype Pin is disabled" + ); + + for (let msg of initialMessages) { + let shouldPersist = + msg.template !== "pb_newtab" || + Services.prefs.getBoolPref( + PromoInfo[msg.content?.promoType]?.enabledPref, + true + ); + Assert.equal( + !!ASRouter.state.messages.find(m => m.id === msg.id), + shouldPersist, + shouldPersist + ? "Message persists in ASRouter state" + : "Promo message with disabled promoType removed from ASRouter state" + ); + } + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_default_promo() { + await resetState(); + + let { win: win1, tab: tab1 } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab1, [], async function () { + const promoContainer = content.document.querySelector(".promo"); // container which is present if promo is enabled and should show + const promoHeader = content.document.getElementById("promo-header"); + + ok(promoContainer, "Focus promo is shown"); + is( + promoHeader.getAttribute("data-l10n-id"), + "about-private-browsing-focus-promo-header-c", + "Correct default values are shown" + ); + }); + + let { win: win2 } = await openTabAndWaitForRender(); + let { win: win3 } = await openTabAndWaitForRender(); + + let { win: win4, tab: tab4 } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab4, [], async function () { + is( + content.document.querySelector(".promo button"), + null, + "should no longer render the promo after 3 impressions" + ); + }); + + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); + await BrowserTestUtils.closeWindow(win3); + await BrowserTestUtils.closeWindow(win4); +}); + +// Verify that promos are correctly removed if blocked in another tab. +// See handlePromoOnPreload() in aboutPrivateBrowsing.js +add_task(async function test_remove_promo_from_prerendered_tab_if_blocked() { + await resetState(); + + const { win, tab: tab1 } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab1, [], async function () { + // container which is present if promo message is not blocked + const promoContainer = content.document.querySelector(".promo"); + ok(promoContainer, "Focus promo is shown in tab 1"); + }); + + // Open a new background tab (tab 2) while the promo message is unblocked + win.openTrustedLinkIn(win.BROWSER_NEW_TAB_URL, "tabshifted"); + + // Block the promo in tab 1 + await SpecialPowers.spawn(tab1, [], async function () { + content.document.getElementById("dismiss-btn").click(); + await ContentTaskUtils.waitForCondition(() => { + return !content.document.querySelector(".promo"); + }, "The promo container is removed."); + }); + + // Switch to tab 2, invoking the `visibilitychange` handler in + // handlePromoOnPreload() + await BrowserTestUtils.switchTab(win.gBrowser, win.gBrowser.tabs[1]); + + // Verify that the promo has now been removed from tab 2 + await SpecialPowers.spawn( + win.gBrowser.tabs[1].linkedBrowser, + [], + // The timing may be weird in Chaos Mode, so wait for it to be removed + // instead of a single assertion. + async function () { + await ContentTaskUtils.waitForCondition( + () => !content.document.querySelector(".promo"), + "Focus promo is not shown in a new tab after being dismissed in another tab" + ); + } + ); + + await BrowserTestUtils.closeWindow(win); +}); + +// Test that some default content is rendered while waiting for ASRouter to +// return a message. +add_task(async function test_default_content_deferred_message_load() { + await resetState(); + + let messageRequestedPromiseResolver; + const messageRequestedPromise = new Promise(resolve => { + messageRequestedPromiseResolver = resolve; + }); + let messageReadyPromiseResolver; + const messageReadyPromise = new Promise(resolve => { + messageReadyPromiseResolver = resolve; + }); + // Force ASRouter to "hang" until we resolve the promise so we can test what + // happens when there is a delay in loading the message. + const sendMessageStub = sandbox + .stub(ASRouter, "sendPBNewTabMessage") + .callsFake(async (...args) => { + messageRequestedPromiseResolver(); + await messageReadyPromise; + return sendMessageStub.wrappedMethod.apply(ASRouter, args); + }); + + const { win, tab } = await openAboutPrivateBrowsing(); + await messageRequestedPromise; + + await SpecialPowers.spawn(tab, [], async function () { + const promoContainer = content.document.querySelector(".promo"); + ok( + promoContainer && !promoContainer.classList.contains("promo-visible"), + "Focus promo is hidden but not removed" + ); + const infoContainer = content.document.querySelector(".info"); + ok(infoContainer && !infoContainer.hidden, "Info container is shown"); + const infoTitle = content.document.getElementById("info-title"); + ok(infoTitle && infoTitle.hidden, "Info title is hidden"); + const infoBody = content.document.getElementById("info-body"); + ok(infoBody, "Info body is shown"); + is( + infoBody.getAttribute("data-l10n-id"), + "about-private-browsing-info-description-private-window", + "Info body has the correct Fluent id" + ); + await ContentTaskUtils.waitForCondition( + () => infoBody.textContent, + "Info body has been translated" + ); + const infoLink = content.document.getElementById("private-browsing-myths"); + ok(infoLink, "Info link is shown"); + is( + infoLink.getAttribute("data-l10n-id"), + "about-private-browsing-learn-more-link", + "Info link has the correct Fluent id" + ); + await ContentTaskUtils.waitForCondition( + () => infoLink.textContent && infoLink.href, + "Info body has been translated" + ); + }); + + messageReadyPromiseResolver(); + await messageReadyPromise; + + await SpecialPowers.spawn(tab, [], async function () { + await ContentTaskUtils.waitForCondition(() => { + const promoContainer = content.document.querySelector(".promo"); + return promoContainer?.classList.contains("promo-visible"); + }, "The promo container is shown."); + }); + + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_focus_promo.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_focus_promo.js new file mode 100644 index 0000000000..7e5c6540b0 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_focus_promo.js @@ -0,0 +1,89 @@ +const { Region } = ChromeUtils.importESModule( + "resource://gre/modules/Region.sys.mjs" +); +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); + +const initialHomeRegion = Region._home; +const intialCurrentRegion = Region._current; +const initialLocale = Services.locale.appLocaleAsBCP47; + +// Helper to run tests for specific regions +async function setupRegions(home, current) { + Region._setHomeRegion(home || ""); + Region._setCurrentRegion(current || ""); +} + +// Helper to run tests for specific locales +function setLocale(locale) { + Services.locale.availableLocales = [locale]; + Services.locale.requestedLocales = [locale]; +} + +add_task(async function test_focus_promo_in_allowed_region() { + ASRouter.resetMessageState(); + + const allowedRegion = "ES"; // Spain + setupRegions(allowedRegion, allowedRegion); + + const { win, tab } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab, [], async function () { + const promoContainer = content.document.querySelector(".promo"); // container which is present if promo is enabled and should show + + ok(promoContainer, "Focus promo is shown for allowed region"); + }); + + await BrowserTestUtils.closeWindow(win); + setupRegions(initialHomeRegion, intialCurrentRegion); // revert changes to regions +}); + +add_task(async function test_focus_promo_in_disallowed_region() { + ASRouter.resetMessageState(); + + const disallowedRegion = "CN"; // China + setupRegions(disallowedRegion); + + const { win, tab } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab, [], async function () { + const promoContainer = content.document.querySelector(".promo"); // container which is removed if promo is disabled and/or should not show + + ok(!promoContainer, "Focus promo is not shown for disallowed region"); + }); + + await BrowserTestUtils.closeWindow(win); + setupRegions(initialHomeRegion, intialCurrentRegion); // revert changes to regions +}); + +add_task( + async function test_klar_promo_in_certain_regions_with_English_locale() { + const testLocale = "en-US"; // US English + setLocale(testLocale); + + const testRegion = async region => { + setupRegions(region); + ASRouter.resetMessageState(); + const { win, tab } = await openTabAndWaitForRender(); + await SpecialPowers.spawn(tab, [], async function () { + const buttonText = content.document.querySelector( + "#private-browsing-promo-link" + ).textContent; + Assert.equal( + buttonText, + "Download Firefox Klar", + "The promo button text reads 'Download Firefox Klar'" + ); + }); + await BrowserTestUtils.closeWindow(win); + }; + + await testRegion("AT"); // Austria + await testRegion("DE"); // Germany + await testRegion("CH"); // Switzerland + + setupRegions(initialHomeRegion, intialCurrentRegion); // revert changes to regions + setLocale(initialLocale); // revert changes to locale + } +); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus.js new file mode 100644 index 0000000000..489c9c91b2 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus.js @@ -0,0 +1,459 @@ +/* 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/. */ + +requestLongerTimeout(2); + +add_task(async function test_experiment_plain_text() { + const defaultMessageContent = (await PanelTestProvider.getMessages()).find( + m => m.template === "pb_newtab" + ).content; + let doExperimentCleanup = await setupMSExperimentWithMessage({ + id: "PB_NEWTAB_MESSAGING_SYSTEM", + template: "pb_newtab", + content: { + ...defaultMessageContent, + infoTitle: "Hello world", + infoTitleEnabled: true, + infoBody: "This is some text", + infoLinkText: "This is a link", + infoIcon: "chrome://branding/content/about-logo.png", + promoTitle: "Promo title", + promoLinkText: "Promo link", + promoLinkType: "link", + promoButton: { + action: { + type: "OPEN_URL", + data: { + args: "https://example.com", + where: "tabshifted", + }, + }, + }, + }, + // Priority ensures this message is picked over the one in + // OnboardingMessageProvider + priority: 5, + targeting: "true", + }); + + let { win, tab } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab, [], async function () { + const infoContainer = content.document.querySelector(".info"); + const infoTitle = content.document.getElementById("info-title"); + const infoBody = content.document.getElementById("info-body"); + const infoLink = content.document.getElementById("private-browsing-myths"); + const promoText = content.document.getElementById( + "private-browsing-promo-text" + ); + const promoLink = content.document.getElementById( + "private-browsing-promo-link" + ); + + // Check experiment values are rendered + ok(!infoContainer.hidden, ".info container should be visible"); + ok( + infoContainer.style.backgroundImage.includes( + "chrome://branding/content/about-logo.png" + ), + "should render icon" + ); + is(infoTitle.textContent, "Hello world", "should render infoTitle"); + is(infoBody.textContent, "This is some text", "should render infoBody"); + is(infoLink.textContent, "This is a link", "should render infoLink"); + is(promoText.textContent, "Promo title", "should render promoTitle"); + is(promoLink.textContent, "Promo link", "should render promoLinkText"); + }); + + await BrowserTestUtils.closeWindow(win); + await doExperimentCleanup(); +}); + +add_task(async function test_experiment_info_disabled() { + let doExperimentCleanup = await setupMSExperimentWithMessage({ + id: "PB_NEWTAB_MESSAGING_SYSTEM", + template: "pb_newtab", + content: { + infoEnabled: false, + }, + // Priority ensures this message is picked over the one in + // OnboardingMessageProvider + priority: 5, + targeting: "true", + }); + + let { win, tab } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab, [], async function () { + ok( + content.document.querySelector(".info").hidden, + "should hide .info element" + ); + }); + + await BrowserTestUtils.closeWindow(win); + await doExperimentCleanup(); +}); + +add_task(async function test_experiment_promo_disabled() { + let doExperimentCleanup = await setupMSExperimentWithMessage({ + id: "PB_NEWTAB_MESSAGING_SYSTEM", + template: "pb_newtab", + content: { + promoEnabled: false, + }, + // Priority ensures this message is picked over the one in + // OnboardingMessageProvider + priority: 5, + targeting: "true", + }); + + let { win, tab } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab, [], async function () { + is( + content.document.querySelector(".promo"), + undefined, + "should remove .promo element" + ); + }); + + await BrowserTestUtils.closeWindow(win); + await doExperimentCleanup(); +}); + +add_task(async function test_experiment_format_urls() { + const LOCALE = Services.locale.appLocaleAsBCP47; + let doExperimentCleanup = await setupMSExperimentWithMessage({ + id: "PB_NEWTAB_MESSAGING_SYSTEM", + template: "pb_newtab", + content: { + infoEnabled: true, + promoEnabled: true, + infoLinkUrl: "http://foo.mozilla.com/%LOCALE%", + promoButton: { + action: { + data: { + args: "http://bar.mozilla.com/%LOCALE%", + where: "tabshifted", + }, + type: "OPEN_URL", + }, + }, + }, + // Priority ensures this message is picked over the one in + // OnboardingMessageProvider + priority: 5, + targeting: "true", + }); + + let { win, tab } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab, [LOCALE], async function (locale) { + is( + content.document.querySelector(".info a").getAttribute("href"), + "http://foo.mozilla.com/" + locale, + "should format the infoLinkUrl url" + ); + + ok( + content.document.querySelector(".promo button"), + "should render promo button" + ); + }); + + await BrowserTestUtils.closeWindow(win); + await doExperimentCleanup(); +}); + +add_task(async function test_experiment_click_info_telemetry() { + let doExperimentCleanup = await setupMSExperimentWithMessage({ + id: "PB_NEWTAB_MESSAGING_SYSTEM_CLICK_INFO_TELEM", + template: "pb_newtab", + content: { + infoEnabled: true, + infoLinkUrl: "http://example.com", + }, + // Priority ensures this message is picked over the one in + // OnboardingMessageProvider + priority: 5, + targeting: "true", + }); + + // Required for `mach test --verify` + Services.telemetry.clearEvents(); + + let { win, tab } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab, [], () => { + const el = content.document.querySelector(".info a"); + el.click(); + }); + + let event = await waitForTelemetryEvent("aboutprivatebrowsing"); + + ok( + event[2] == "click" && event[3] == "info_link", + "recorded telemetry for info link" + ); + + await BrowserTestUtils.closeWindow(win); + await doExperimentCleanup(); +}); + +add_task(async function test_experiment_click_promo_telemetry() { + let doExperimentCleanup = await setupMSExperimentWithMessage({ + id: `PB_NEWTAB_MESSAGING_SYSTEM_PROMO_TELEM_${Math.random()}`, + template: "pb_newtab", + content: { + promoEnabled: true, + promoLinkType: "link", + promoButton: { + action: { + type: "OPEN_URL", + data: { + args: "https://example.com", + where: "tabshifted", + }, + }, + }, + }, + // Priority ensures this message is picked over the one in + // OnboardingMessageProvider + priority: 5, + targeting: "true", + }); + + let { win, tab } = await openTabAndWaitForRender(); + + Services.telemetry.clearEvents(); + + await SpecialPowers.spawn(tab, [], () => { + is( + content.document + .querySelector(".promo-cta button") + .classList.contains("promo-link"), + true, + "Should have a button styled as a link" + ); + + const el = content.document.querySelector(".promo button"); + el.click(); + }); + + let event = await waitForTelemetryEvent("aboutprivatebrowsing"); + + ok( + event[2] == "click" && event[3] == "promo_link", + "recorded telemetry for promo link" + ); + + await BrowserTestUtils.closeWindow(win); + await doExperimentCleanup(); +}); + +add_task(async function test_experiment_bottom_promo() { + const defaultMessageContent = (await PanelTestProvider.getMessages()).find( + m => m.template === "pb_newtab" + ).content; + + let doExperimentCleanup = await setupMSExperimentWithMessage({ + id: "PB_NEWTAB_MESSAGING_SYSTEM", + template: "pb_newtab", + content: { + ...defaultMessageContent, + promoEnabled: true, + promoLinkType: "button", + promoSectionStyle: "bottom", + promoHeader: "Need more privacy?", + infoTitleEnabled: true, + promoTitleEnabled: false, + promoImageLarge: "", + promoImageSmall: "chrome://browser/content/assets/vpn-logo.svg", + promoButton: { + action: { + data: { + args: "http://bar.example.com/%LOCALE%", + where: "tabshifted", + }, + type: "OPEN_URL", + }, + }, + }, + // Priority ensures this message is picked over the one in + // OnboardingMessageProvider + priority: 5, + targeting: "true", + }); + + let { win, tab } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab, [], async function () { + is( + content.document + .querySelector(".promo-cta button") + .classList.contains("primary"), + true, + "Should have a button CTA" + ); + is( + content.document.querySelector(".promo-image-small img").src, + "chrome://browser/content/assets/vpn-logo.svg", + "Should have logo image" + ); + ok( + content.document.querySelector(".promo.bottom"), + "Should have .bottom for the promo section" + ); + const infoTitle = content.document.getElementById("info-title"); + ok( + infoTitle && !infoTitle.hidden, + "Should render info title if infoTitleEnabled is true" + ); + ok( + !content.document.querySelector("#private-browsing-promo-text"), + "Should not render promo title if promoTitleEnabled is false" + ); + }); + + await BrowserTestUtils.closeWindow(win); + await doExperimentCleanup(); +}); + +add_task(async function test_experiment_below_search_promo() { + const defaultMessageContent = (await PanelTestProvider.getMessages()).find( + m => m.template === "pb_newtab" + ).content; + let doExperimentCleanup = await setupMSExperimentWithMessage({ + id: "PB_NEWTAB_MESSAGING_SYSTEM", + template: "pb_newtab", + content: { + ...defaultMessageContent, + promoEnabled: true, + promoLinkType: "button", + promoSectionStyle: "below-search", + promoHeader: "Need more privacy?", + promoTitle: + "Mozilla VPN. Security, reliability and speed — on every device, anywhere you go.", + promoImageLarge: "chrome://browser/content/assets/moz-vpn.svg", + promoImageSmall: "chrome://browser/content/assets/vpn-logo.svg", + infoTitleEnabled: false, + promoButton: { + action: { + data: { + args: "https://foo.example.com", + where: "tabshifted", + }, + type: "OPEN_URL", + }, + }, + }, + // Priority ensures this message is picked over the one in + // OnboardingMessageProvider + priority: 5, + targeting: "true", + }); + + let { win, tab } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab, [], async function () { + is( + content.document + .querySelector(".promo-cta button") + .classList.contains("primary"), + true, + "Should have a button CTA" + ); + is( + content.document.querySelector(".promo-image-small img").src, + "chrome://browser/content/assets/vpn-logo.svg", + "Should have logo image" + ); + is( + content.document.querySelector(".promo-image-large img").src, + "chrome://browser/content/assets/moz-vpn.svg", + "Should have a product image" + ); + ok( + content.document.querySelector(".promo.below-search"), + "Should have .below-search for the promo section" + ); + ok( + content.document.getElementById("info-title").hidden, + "Should not render info title if infoTitleEnabled is false" + ); + ok( + content.document.querySelector("#private-browsing-promo-text"), + "Should render promo title if promoTitleEnabled is true" + ); + }); + + await BrowserTestUtils.closeWindow(win); + await doExperimentCleanup(); +}); + +add_task(async function test_experiment_top_promo() { + const defaultMessageContent = (await PanelTestProvider.getMessages()).find( + m => m.template === "pb_newtab" + ).content; + let doExperimentCleanup = await setupMSExperimentWithMessage({ + id: `PB_NEWTAB_MESSAGING_SYSTEM_DISMISS_${Math.random()}`, + template: "pb_newtab", + content: { + ...defaultMessageContent, + promoEnabled: true, + promoLinkType: "button", + promoSectionStyle: "top", + promoHeader: "Need more privacy?", + promoTitle: + "Mozilla VPN. Security, reliability and speed — on every device, anywhere you go.", + promoImageLarge: "chrome://browser/content/assets/moz-vpn.svg", + promoImageSmall: "chrome://browser/content/assets/vpn-logo.svg", + infoTitleEnabled: false, + promoButton: { + action: { + data: { + args: "https://foo.example.com", + where: "tabshifted", + }, + type: "OPEN_URL", + }, + }, + }, + // Priority ensures this message is picked over the one in + // OnboardingMessageProvider + priority: 5, + targeting: "true", + }); + + let { win, tab } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab, [], async function () { + is( + content.document.querySelector(".promo-image-small img").src, + "chrome://browser/content/assets/vpn-logo.svg", + "Should have logo image" + ); + is( + content.document.querySelector(".promo-image-large img").src, + "chrome://browser/content/assets/moz-vpn.svg", + "Should have a product image" + ); + ok( + content.document.querySelector(".promo.top"), + "Should have .below-search for the promo section" + ); + ok( + content.document.getElementById("info-title").hidden, + "Should hide info title if infoTitleEnabled is false" + ); + ok( + content.document.querySelector("#private-browsing-promo-text"), + "Should render promo title if promoTitleEnabled is true" + ); + }); + + await BrowserTestUtils.closeWindow(win); + await doExperimentCleanup(); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus_dismiss.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus_dismiss.js new file mode 100644 index 0000000000..bfe5708a5b --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus_dismiss.js @@ -0,0 +1,139 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +add_setup(async function () { + ASRouter.resetMessageState(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.promo.pin.enabled", false]], + }); + await ASRouter.onPrefChange(); +}); + +add_task(async function test_experiment_messaging_system_dismiss() { + const LOCALE = Services.locale.appLocaleAsBCP47; + let doExperimentCleanup = await setupMSExperimentWithMessage({ + id: `PB_NEWTAB_MESSAGING_SYSTEM_${Math.random()}`, + template: "pb_newtab", + content: { + hideDefault: true, + promoEnabled: true, + infoEnabled: true, + infoBody: "fluent:about-private-browsing-info-title", + promoLinkText: "fluent:about-private-browsing-prominent-cta", + infoLinkUrl: "http://foo.example.com/%LOCALE%", + promoLinkType: "link", + promoButton: { + action: { + data: { + args: "http://bar.example.com/%LOCALE%", + where: "tabshifted", + }, + type: "OPEN_URL", + }, + }, + }, + // Priority ensures this message is picked over the one in + // OnboardingMessageProvider + priority: 5, + targeting: "true", + }); + + let { win: win1, tab: tab1 } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab1, [LOCALE], async function (locale) { + content.document.querySelector("#dismiss-btn").click(); + info("button clicked"); + }); + + let telemetryEvent = await waitForTelemetryEvent("aboutprivatebrowsing"); + + ok( + telemetryEvent[2] == "click" && telemetryEvent[3] == "dismiss_button", + "recorded the dismiss button click" + ); + + let { win: win2, tab: tab2 } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab2, [], async function () { + is( + content.document.querySelector(".promo button"), + null, + "should no longer render the experiment message after dismissing" + ); + }); + + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); + await doExperimentCleanup(); +}); + +add_task(async function test_experiment_messaging_show_default_on_dismiss() { + registerCleanupFunction(() => { + ASRouter.resetMessageState(); + }); + let doExperimentCleanup = await setupMSExperimentWithMessage({ + id: `PB_NEWTAB_MESSAGING_SYSTEM_${Math.random()}`, + template: "pb_newtab", + content: { + hideDefault: false, + promoEnabled: true, + infoEnabled: true, + infoBody: "fluent:about-private-browsing-info-title", + promoLinkText: "fluent:about-private-browsing-prominent-cta", + infoLinkUrl: "http://foo.example.com", + promoLinkType: "link", + promoButton: { + action: { + data: { + args: "http://bar.example.com", + where: "tabshifted", + }, + type: "OPEN_URL", + }, + }, + }, + // Priority ensures this message is picked over the one in + // OnboardingMessageProvider + priority: 5, + targeting: "true", + }); + + let { win: win1, tab: tab1 } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab1, [], async function () { + ok( + content.document.querySelector(".promo"), + "should render the promo experiment message" + ); + + content.document.querySelector("#dismiss-btn").click(); + info("button clicked"); + }); + + let telemetryEvent = await waitForTelemetryEvent("aboutprivatebrowsing"); + + ok( + telemetryEvent[2] == "click" && telemetryEvent[3] == "dismiss_button", + "recorded the dismiss button click" + ); + + let { win: win2, tab: tab2 } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab2, [], async function () { + const promoHeader = content.document.getElementById("promo-header"); + ok( + content.document.querySelector(".promo"), + "should render the default promo message after dismissing experiment promo" + ); + is( + promoHeader.getAttribute("data-l10n-id"), + "about-private-browsing-focus-promo-header-c", + "Correct default values are shown" + ); + }); + + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); + await doExperimentCleanup(); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus_impressions.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus_impressions.js new file mode 100644 index 0000000000..ac42caa2dd --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus_impressions.js @@ -0,0 +1,126 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* Tests that use TelemetryTestUtils.assertEvents (at the very least, those with + * `{ process: "content" }`) seem to be super flaky and intermittent-prone when they + * share a file with other telemetry tests, so each one gets its own file. + */ + +add_task(async function test_experiment_messaging_system_impressions() { + registerCleanupFunction(() => { + ASRouter.resetMessageState(); + }); + const LOCALE = Services.locale.appLocaleAsBCP47; + let experimentId = `pb_newtab_${Math.random()}`; + + let doExperimentCleanup = await setupMSExperimentWithMessage({ + id: experimentId, + template: "pb_newtab", + content: { + hideDefault: true, + promoEnabled: true, + infoEnabled: true, + infoBody: "fluent:about-private-browsing-info-title", + promoLinkText: "fluent:about-private-browsing-prominent-cta", + infoLinkUrl: "http://foo.example.com/%LOCALE%", + promoButton: { + action: { + data: { + args: "https://bar.example.com/%LOCALE%", + where: "tabshifted", + }, + type: "OPEN_URL", + }, + }, + }, + frequency: { + lifetime: 2, + }, + // Priority ensures this message is picked over the one in + // OnboardingMessageProvider + priority: 5, + targeting: "true", + }); + + Services.telemetry.clearEvents(); + + let { win: win1, tab: tab1 } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab1, [LOCALE], async function (locale) { + is( + content.document + .querySelector(".promo button") + .classList.contains("primary"), + true, + "should render the promo button as a button" + ); + }); + + let event = await waitForTelemetryEvent("normandy", experimentId); + + ok( + event[1] == "normandy" && + event[2] == "expose" && + event[3] == "nimbus_experiment" && + event[4].includes(experimentId) && + event[5].featureId == "pbNewtab", + "recorded telemetry for expose" + ); + + Services.telemetry.clearEvents(); + + let { win: win2, tab: tab2 } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab2, [LOCALE], async function (locale) { + is( + content.document + .querySelector(".promo button") + .classList.contains("primary"), + true, + "should render the promo button as a button" + ); + }); + + let event2 = await waitForTelemetryEvent("normandy", experimentId); + + ok( + event2[1] == "normandy" && + event2[2] == "expose" && + event2[3] == "nimbus_experiment" && + event2[4].includes(experimentId) && + event2[5].featureId == "pbNewtab", + "recorded telemetry for expose" + ); + + Services.telemetry.clearEvents(); + + let { win: win3, tab: tab3 } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab3, [], async function () { + is( + content.document.querySelector(".promo button"), + null, + "should no longer render the experiment message after 2 impressions" + ); + }); + + // Verify that the telemetry events array does not + // contain an expose event for pbNewtab + info("Should not have promo expose"); + TelemetryTestUtils.assertEvents([], { + category: "normandy", + method: "expose", + object: "nimbus_experiment", + extra_keys: { + featureId: "pbNewtab", + }, + }); + + Services.telemetry.clearEvents(); + + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); + await BrowserTestUtils.closeWindow(win3); + await doExperimentCleanup(); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus_messaging.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus_messaging.js new file mode 100644 index 0000000000..1463cee961 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_nimbus_messaging.js @@ -0,0 +1,247 @@ +/* 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 { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +add_task(async function test_experiment_messaging_system() { + const LOCALE = Services.locale.appLocaleAsBCP47; + let doExperimentCleanup = await setupMSExperimentWithMessage({ + id: "PB_NEWTAB_MESSAGING_SYSTEM", + template: "pb_newtab", + content: { + hideDefault: true, + promoEnabled: true, + infoEnabled: true, + infoBody: "fluent:about-private-browsing-info-title", + promoLinkText: "fluent:about-private-browsing-prominent-cta", + infoLinkUrl: "http://foo.example.com/%LOCALE%", + promoButton: { + action: { + data: { + args: "http://bar.example.com/%LOCALE%", + where: "tabshifted", + }, + type: "OPEN_URL", + }, + }, + }, + // Priority ensures this message is picked over the one in + // OnboardingMessageProvider + priority: 5, + targeting: "true", + }); + + let { win, tab } = await openTabAndWaitForRender(); + + await SpecialPowers.spawn(tab, [LOCALE], async function (locale) { + const infoBody = content.document.getElementById("info-body"); + const promoLink = content.document.getElementById( + "private-browsing-promo-link" + ); + + // Check experiment values are rendered + is( + infoBody.getAttribute("data-l10n-id"), + "about-private-browsing-info-title", + "should render infoBody with fluent" + ); + is( + promoLink.getAttribute("data-l10n-id"), + "about-private-browsing-prominent-cta", + "should render promoLinkText with fluent" + ); + is( + content.document.querySelector(".info a").getAttribute("href"), + "http://foo.example.com/" + locale, + "should format the infoLinkUrl url" + ); + is( + content.document.querySelector(".info a").getAttribute("target"), + "_blank", + "should open info url in new tab" + ); + }); + + await BrowserTestUtils.closeWindow(win); + await doExperimentCleanup(); +}); + +add_task(async function test_experiment_promo_action() { + let doExperimentCleanup = await setupMSExperimentWithMessage({ + id: "PB_NEWTAB_TEST_URL", + template: "pb_newtab", + content: { + hideDefault: true, + promoEnabled: true, + infoEnabled: true, + infoBody: "fluent:about-private-browsing-info-title", + promoLinkText: "fluent:about-private-browsing-prominent-cta", + infoLinkUrl: "http://foo.example.com/%LOCALE%", + promoLinkType: "button", + promoButton: { + action: { + data: { + args: "https://foo.example.com", + where: "tabshifted", + }, + type: "OPEN_URL", + }, + }, + }, + // Priority ensures this message is picked over the one in + // OnboardingMessageProvider + priority: 5, + targeting: "true", + }); + + let { win, tab } = await openTabAndWaitForRender(); + const sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + ASRouter.resetMessageState(); + sandbox.restore(); + BrowserTestUtils.closeWindow(win); + }); + + let windowGlobalParent = + win.gBrowser.selectedBrowser.browsingContext.currentWindowGlobal; + let aboutPrivateBrowsingActor = windowGlobalParent.getActor( + "AboutPrivateBrowsing" + ); + + let specialActionSpy = sandbox.spy( + aboutPrivateBrowsingActor, + "receiveMessage" + ); + + let expectedUrl = "https://foo.example.com"; + + await SpecialPowers.spawn(tab, [], async function () { + ok( + content.document.querySelector(".promo"), + "should render the promo experiment message" + ); + + is( + content.document + .querySelector(".promo button") + .classList.contains("primary"), + true, + "should render the promo button styled as a button" + ); + + content.document.querySelector(".promo button").click(); + info("promo button clicked"); + }); + + Assert.equal( + specialActionSpy.callCount, + 1, + "Should be called by promo action" + ); + + let promoAction = specialActionSpy.firstCall.args[0].data; + + Assert.equal( + promoAction.type, + "OPEN_URL", + "Should be called with promo button action" + ); + + Assert.equal( + promoAction.data.args, + expectedUrl, + "Should be called with right URL" + ); + + await doExperimentCleanup(); +}); + +add_task(async function test_experiment_open_spotlight_action() { + let doExperimentCleanup = await setupMSExperimentWithMessage({ + id: "PB_NEWTAB_TEST_SPOTLIGHT", + template: "pb_newtab", + content: { + hideDefault: true, + promoEnabled: true, + infoEnabled: true, + infoBody: "fluent:about-private-browsing-info-title", + promoLinkText: "fluent:about-private-browsing-prominent-cta", + infoLinkUrl: "http://foo.example.com/", + promoLinkType: "button", + promoButton: { + action: { + type: "SHOW_SPOTLIGHT", + data: { + content: { + template: "multistage", + screens: [ + { + content: { + title: "Test", + subtitle: "Sub Title", + }, + }, + ], + }, + }, + }, + }, + }, + // Priority ensures this message is picked over the one in + // OnboardingMessageProvider + priority: 5, + targeting: "true", + }); + + let { win, tab } = await openTabAndWaitForRender(); + const sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + ASRouter.resetMessageState(); + sandbox.restore(); + BrowserTestUtils.closeWindow(win); + }); + + let windowGlobalParent = + win.gBrowser.selectedBrowser.browsingContext.currentWindowGlobal; + let aboutPrivateBrowsingActor = windowGlobalParent.getActor( + "AboutPrivateBrowsing" + ); + + let specialActionSpy = sandbox.spy( + aboutPrivateBrowsingActor, + "receiveMessage" + ); + + await SpecialPowers.spawn(tab, [], async function () { + ok( + content.document.querySelector(".promo"), + "should render the promo experiment message" + ); + content.document.querySelector(".promo button").click(); + }); + + Assert.equal( + specialActionSpy.callCount, + 1, + "Should be called by promo action" + ); + + let promoAction = specialActionSpy.firstCall.args[0].data; + + Assert.equal( + promoAction.type, + "SHOW_SPOTLIGHT", + "Should be called with promo button spotlight action" + ); + + Assert.equal( + promoAction.data.content.metrics, + "allow", + "Should be called with metrics property set as allow for experiments" + ); + + await doExperimentCleanup(); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_search_banner.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_search_banner.js new file mode 100644 index 0000000000..4d8c2c407a --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_about_search_banner.js @@ -0,0 +1,317 @@ +/* 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 test makes sure that about:privatebrowsing correctly shows the search +// banner. + +const { AboutPrivateBrowsingParent } = ChromeUtils.importESModule( + "resource:///actors/AboutPrivateBrowsingParent.sys.mjs" +); + +const PREF_UI_ENABLED = "browser.search.separatePrivateDefault.ui.enabled"; +const PREF_BANNER_SHOWN = + "browser.search.separatePrivateDefault.ui.banner.shown"; +const PREF_MAX_SEARCH_BANNER_SHOW_COUNT = + "browser.search.separatePrivateDefault.ui.banner.max"; +const MAX_SHOW_COUNT = 5; + +add_setup(async function () { + SpecialPowers.pushPrefEnv({ + set: [ + [PREF_UI_ENABLED, false], + [PREF_BANNER_SHOWN, 0], + [PREF_MAX_SEARCH_BANNER_SHOW_COUNT, MAX_SHOW_COUNT], + ], + }); + + AboutPrivateBrowsingParent.setShownThisSession(false); +}); + +add_task(async function test_not_shown_if_pref_off() { + SpecialPowers.pushPrefEnv({ + set: [ + [PREF_UI_ENABLED, false], + [PREF_MAX_SEARCH_BANNER_SHOW_COUNT, 5], + ], + }); + + const { win, tab } = await openAboutPrivateBrowsing(); + + await SpecialPowers.spawn(tab, [], async function () { + await ContentTaskUtils.waitForCondition( + () => + content.document.documentElement.hasAttribute( + "SearchBannerInitialized" + ), + "Should have initialized" + ); + ok( + content.document.getElementById("search-banner").hasAttribute("hidden"), + "should be hiding the in-content search banner" + ); + }); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_not_shown_if_max_count_0() { + // To avoid having to restart Firefox and slow down tests, we manually reset + // the session pref. + AboutPrivateBrowsingParent.setShownThisSession(false); + + SpecialPowers.pushPrefEnv({ + set: [ + [PREF_UI_ENABLED, true], + [PREF_MAX_SEARCH_BANNER_SHOW_COUNT, 0], + ], + }); + const { win, tab } = await openAboutPrivateBrowsing(); + + await SpecialPowers.spawn(tab, [], async function () { + await ContentTaskUtils.waitForCondition( + () => + content.document.documentElement.hasAttribute( + "SearchBannerInitialized" + ), + "Should have initialized" + ); + ok( + content.document.getElementById("search-banner").hasAttribute("hidden"), + "should be hiding the in-content search banner" + ); + }); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_show_banner_first() { + // To avoid having to restart Firefox and slow down tests, we manually reset + // the session pref. + AboutPrivateBrowsingParent.setShownThisSession(false); + + SpecialPowers.pushPrefEnv({ + set: [ + [PREF_UI_ENABLED, true], + [PREF_MAX_SEARCH_BANNER_SHOW_COUNT, MAX_SHOW_COUNT], + ], + }); + + let prefChanged = TestUtils.waitForPrefChange(PREF_BANNER_SHOWN); + + const { win, tab } = await openAboutPrivateBrowsing(); + + Assert.equal( + await prefChanged, + 1, + "Should have incremented the amount of times shown." + ); + + await SpecialPowers.spawn(tab, [], async function () { + await ContentTaskUtils.waitForCondition( + () => + content.document.documentElement.hasAttribute( + "SearchBannerInitialized" + ), + "Should have initialized" + ); + + ok( + !content.document.getElementById("search-banner").hasAttribute("hidden"), + "should be showing the in-content search banner" + ); + }); + + await BrowserTestUtils.closeWindow(win); + + const { win: win1, tab: tab1 } = await openAboutPrivateBrowsing(); + + await SpecialPowers.spawn(tab1, [], async function () { + await ContentTaskUtils.waitForCondition( + () => + content.document.documentElement.hasAttribute( + "SearchBannerInitialized" + ), + "Should have initialized" + ); + + ok( + content.document.getElementById("search-banner").hasAttribute("hidden"), + "should not be showing the banner in a second window." + ); + }); + + await BrowserTestUtils.closeWindow(win1); + + Assert.equal( + Services.prefs.getIntPref(PREF_BANNER_SHOWN, -1), + 1, + "Should not have changed the preference further" + ); +}); + +add_task(async function test_show_banner_max_times() { + // We've already shown the UI once, so show it a few more times. + for (let i = 1; i < MAX_SHOW_COUNT; i++) { + // To avoid having to restart Firefox and slow down tests, we manually reset + // the session pref. + AboutPrivateBrowsingParent.setShownThisSession(false); + + let prefChanged = TestUtils.waitForPrefChange(PREF_BANNER_SHOWN); + const { win, tab } = await openAboutPrivateBrowsing(); + + Assert.equal( + await prefChanged, + i + 1, + "Should have incremented the amount of times shown." + ); + + await SpecialPowers.spawn(tab, [], async function () { + await ContentTaskUtils.waitForCondition( + () => + content.document.documentElement.hasAttribute( + "SearchBannerInitialized" + ), + "Should have initialized" + ); + + ok( + !content.document + .getElementById("search-banner") + .hasAttribute("hidden"), + "Should be showing the banner again" + ); + }); + + await BrowserTestUtils.closeWindow(win); + } + + // Final time! + + AboutPrivateBrowsingParent.setShownThisSession(false); + + const { win, tab } = await openAboutPrivateBrowsing(); + + await SpecialPowers.spawn(tab, [], async function () { + await ContentTaskUtils.waitForCondition( + () => + content.document.documentElement.hasAttribute( + "SearchBannerInitialized" + ), + "Should have initialized" + ); + + ok( + content.document.getElementById("search-banner").hasAttribute("hidden"), + "should not be showing the banner again" + ); + }); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_show_banner_close_no_more() { + SpecialPowers.pushPrefEnv({ + set: [[PREF_BANNER_SHOWN, 0]], + }); + + AboutPrivateBrowsingParent.setShownThisSession(false); + + const { win, tab } = await openAboutPrivateBrowsing(); + + await SpecialPowers.spawn(tab, [], async function () { + await ContentTaskUtils.waitForCondition( + () => + content.document.documentElement.hasAttribute( + "SearchBannerInitialized" + ), + "Should have initialized" + ); + + ok( + !content.document.getElementById("search-banner").hasAttribute("hidden"), + "should be showing the banner again before closing" + ); + + content.document.getElementById("search-banner-close-button").click(); + + await ContentTaskUtils.waitForCondition( + () => + ContentTaskUtils.isHidden( + content.document.getElementById("search-banner") + ), + "should have closed the in-content search banner after clicking close" + ); + }); + + await BrowserTestUtils.closeWindow(win); + + Assert.equal( + Services.prefs.getIntPref(PREF_BANNER_SHOWN, -1), + MAX_SHOW_COUNT, + "Should have set the shown preference to the maximum" + ); +}); + +add_task(async function test_show_banner_open_preferences_and_no_more() { + SpecialPowers.pushPrefEnv({ + set: [[PREF_BANNER_SHOWN, 0]], + }); + + AboutPrivateBrowsingParent.setShownThisSession(false); + + const { win, tab } = await openAboutPrivateBrowsing(); + + // This is "borrowed" from the preferences test code, as waiting for the + // full preferences to load helps avoid leaking a window. + const finalPaneEvent = Services.prefs.getBoolPref( + "identity.fxaccounts.enabled" + ) + ? "sync-pane-loaded" + : "privacy-pane-loaded"; + let finalPrefPaneLoaded = TestUtils.topicObserved(finalPaneEvent, () => true); + const waitForInitialized = new Promise(resolve => { + tab.addEventListener( + "Initialized", + () => { + tab.contentWindow.addEventListener( + "load", + async function () { + await finalPrefPaneLoaded; + resolve(); + }, + { once: true } + ); + }, + { capture: true, once: true } + ); + }); + + await SpecialPowers.spawn(tab, [], async function () { + await ContentTaskUtils.waitForCondition( + () => + content.document.documentElement.hasAttribute( + "SearchBannerInitialized" + ), + "Should have initialized" + ); + + ok( + !content.document.getElementById("search-banner").hasAttribute("hidden"), + "should be showing the banner again before opening prefs" + ); + + content.document.getElementById("open-search-options-link").click(); + }); + + info("Waiting for preference window load"); + await waitForInitialized; + + await BrowserTestUtils.closeWindow(win); + + Assert.equal( + Services.prefs.getIntPref(PREF_BANNER_SHOWN, -1), + MAX_SHOW_COUNT, + "Should have set the shown preference to the maximum" + ); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_beacon.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_beacon.js new file mode 100644 index 0000000000..034061a91a --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_beacon.js @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const TEST_DOMAIN = "example.com"; +const TEST_TOP = `https://${TEST_DOMAIN}`; +const TEST_URL = `${TEST_TOP}/browser/browser/components/privatebrowsing/test/browser/title.sjs`; + +add_task(async function () { + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + privateWin.gBrowser, + TEST_TOP + ); + + // Create a promise to wait the http response of the beacon request. + let promise = BrowserUtils.promiseObserved( + "http-on-examine-response", + subject => { + let channel = subject.QueryInterface(Ci.nsIHttpChannel); + let url = channel.URI.spec; + + return url == TEST_URL; + } + ); + + // Open a tab and send a beacon. + await SpecialPowers.spawn(tab.linkedBrowser, [TEST_URL], async url => { + content.navigator.sendBeacon(url); + }); + + // Close the entire private window directly. + await BrowserTestUtils.closeWindow(privateWin); + + // Wait the response. + await promise; + + const cookies = Services.cookies.getCookiesFromHost(TEST_DOMAIN, { + privateBrowsingId: 1, + }); + + is(cookies.length, 0, "No cookies after close the private window."); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_blobUrl.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_blobUrl.js new file mode 100644 index 0000000000..f5fd40d4ed --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_blobUrl.js @@ -0,0 +1,69 @@ +"use strict"; + +// Here we want to test that blob URLs are not available between private and +// non-private browsing. + +const BASE_URI = + "http://mochi.test:8888/browser/browser/components/" + + "privatebrowsing/test/browser/empty_file.html"; + +add_task(async function test() { + const loaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + BASE_URI + ); + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, BASE_URI); + await loaded; + + let blobURL; + info("Creating a blob URL..."); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + return Promise.resolve( + content.window.URL.createObjectURL( + new Blob([123], { type: "text/plain" }) + ) + ); + }).then(newURL => { + blobURL = newURL; + }); + + info("Blob URL: " + blobURL); + + info("Creating a private window..."); + + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + let privateTab = privateWin.gBrowser.selectedBrowser; + + const privateTabLoaded = BrowserTestUtils.browserLoaded( + privateTab, + false, + BASE_URI + ); + BrowserTestUtils.startLoadingURIString(privateTab, BASE_URI); + await privateTabLoaded; + + await SpecialPowers.spawn(privateTab, [blobURL], function (url) { + return new Promise(resolve => { + var xhr = new content.window.XMLHttpRequest(); + xhr.onerror = function () { + resolve("SendErrored"); + }; + xhr.onload = function () { + resolve("SendLoaded"); + }; + xhr.open("GET", url); + xhr.send(); + }); + }).then(status => { + is( + status, + "SendErrored", + "Using a blob URI from one user context id in another should not work" + ); + }); + + await BrowserTestUtils.closeWindow(privateWin); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_cache.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_cache.js new file mode 100644 index 0000000000..de6aa1f6ba --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_cache.js @@ -0,0 +1,94 @@ +/* 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/. */ + +// Check about:cache after private browsing +// This test covers MozTrap test 6047 +// bug 880621 + +var tmp = {}; + +function test() { + waitForExplicitFinish(); + + SpecialPowers.pushPrefEnv( + { + set: [["privacy.partition.network_state", false]], + }, + function () { + Sanitizer.sanitize(["cache"], { ignoreTimespan: false }); + + getStorageEntryCount("regular", function (nrEntriesR1) { + is(nrEntriesR1, 0, "Disk cache reports 0KB and has no entries"); + + get_cache_for_private_window(); + }); + } + ); +} + +function getStorageEntryCount(device, goon) { + var storage; + switch (device) { + case "private": + storage = Services.cache2.diskCacheStorage( + Services.loadContextInfo.private + ); + break; + case "regular": + storage = Services.cache2.diskCacheStorage( + Services.loadContextInfo.default + ); + break; + default: + throw new Error(`Unknown device ${device} at getStorageEntryCount`); + } + + var visitor = { + entryCount: 0, + onCacheStorageInfo(aEntryCount, aConsumption) {}, + onCacheEntryInfo(uri) { + var urispec = uri.asciiSpec; + info(device + ":" + urispec + "\n"); + if (urispec.match(/^https:\/\/example.com\//)) { + ++this.entryCount; + } + }, + onCacheEntryVisitCompleted() { + goon(this.entryCount); + }, + }; + + storage.asyncVisitStorage(visitor, true); +} + +function get_cache_for_private_window() { + let win = whenNewWindowLoaded({ private: true }, function () { + executeSoon(function () { + ok(true, "The private window got loaded"); + + let tab = BrowserTestUtils.addTab(win.gBrowser, "https://example.com"); + win.gBrowser.selectedTab = tab; + let newTabBrowser = win.gBrowser.getBrowserForTab(tab); + + BrowserTestUtils.browserLoaded(newTabBrowser).then(function () { + executeSoon(function () { + getStorageEntryCount("private", function (nrEntriesP) { + Assert.greaterOrEqual( + nrEntriesP, + 1, + "Memory cache reports some entries from example.org domain" + ); + + getStorageEntryCount("regular", function (nrEntriesR2) { + is(nrEntriesR2, 0, "Disk cache reports 0KB and has no entries"); + + win.close(); + finish(); + }); + }); + }); + }); + }); + }); +} diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_certexceptionsui.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_certexceptionsui.js new file mode 100644 index 0000000000..9b796613a9 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_certexceptionsui.js @@ -0,0 +1,65 @@ +/* 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 test makes sure that certificate exceptions UI behaves correctly +// in private browsing windows, based on whether it's opened from the prefs +// window or from the SSL error page (see bug 461627). + +function test() { + const EXCEPTIONS_DLG_URL = "chrome://pippki/content/exceptionDialog.xhtml"; + const EXCEPTIONS_DLG_FEATURES = "chrome,centerscreen"; + const INVALID_CERT_LOCATION = "https://nocert.example.com/"; + waitForExplicitFinish(); + + // open a private browsing window + var pbWin = OpenBrowserWindow({ private: true }); + pbWin.addEventListener( + "load", + function () { + doTest(); + }, + { once: true } + ); + + // Test the certificate exceptions dialog + function doTest() { + let params = { + exceptionAdded: false, + location: INVALID_CERT_LOCATION, + prefetchCert: true, + }; + function testCheckbox() { + win.removeEventListener("load", testCheckbox); + Services.obs.addObserver(function onCertUI(aSubject, aTopic, aData) { + Services.obs.removeObserver(onCertUI, "cert-exception-ui-ready"); + ok(win.gCert, "The certificate information should be available now"); + + let checkbox = win.document.getElementById("permanent"); + ok( + checkbox.hasAttribute("disabled"), + "the permanent checkbox should be disabled when handling the private browsing mode" + ); + ok( + !checkbox.hasAttribute("checked"), + "the permanent checkbox should not be checked when handling the private browsing mode" + ); + win.close(); + cleanup(); + }, "cert-exception-ui-ready"); + } + var win = pbWin.openDialog( + EXCEPTIONS_DLG_URL, + "", + EXCEPTIONS_DLG_FEATURES, + params + ); + win.addEventListener("load", testCheckbox); + } + + function cleanup() { + // close the private browsing window + pbWin.close(); + finish(); + } +} diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_cleanup.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_cleanup.js new file mode 100644 index 0000000000..39e41589b4 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_cleanup.js @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const DOMAIN = "http://example.com/"; +const PATH = "browser/browser/components/privatebrowsing/test/browser/"; +const TOP_PAGE = DOMAIN + PATH + "empty_file.html"; + +add_task(async () => { + // Create a private browsing window. + let privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + let privateTab = privateWindow.gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(privateTab, TOP_PAGE); + await BrowserTestUtils.browserLoaded(privateTab); + + let observerExited = { + observe(aSubject, aTopic, aData) { + ok(false, "Notification received!"); + }, + }; + Services.obs.addObserver(observerExited, "last-pb-context-exited"); + + let popup = BrowserTestUtils.waitForNewWindow(); + + await SpecialPowers.spawn(privateTab, [], () => { + content.window.open("empty_file.html", "_blank", "width=300,height=300"); + }); + + popup = await popup; + ok(!!popup, "Popup shown"); + + await BrowserTestUtils.closeWindow(privateWindow); + Services.obs.removeObserver(observerExited, "last-pb-context-exited"); + + let notificationPromise = TestUtils.topicObserved("last-pb-context-exited"); + + popup.close(); + + await notificationPromise; + ok(true, "Notification received!"); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_concurrent.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_concurrent.js new file mode 100644 index 0000000000..0029cdc852 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_concurrent.js @@ -0,0 +1,101 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Test opening two tabs that share a localStorage, but keep one in private mode. +// Ensure that values from one don't leak into the other, and that values from +// earlier private storage sessions aren't visible later. + +// Step 1: create new tab, load a page that sets test=value in non-private storage +// Step 2: create a new tab, load a page that sets test2=value2 in private storage +// Step 3: load a page in the tab from step 1 that checks the value of test2 is value2 and the total count in non-private storage is 1 +// Step 4: load a page in the tab from step 2 that checks the value of test is value and the total count in private storage is 1 + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["dom.ipc.processCount", 1]], + }); +}); + +add_task(async function test() { + let prefix = + "http://mochi.test:8888/browser/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_concurrent_page.html"; + + function getElts(browser) { + return browser.contentTitle.split("|"); + } + + // Step 1 + let non_private_browser = gBrowser.selectedBrowser; + let url = prefix + "?action=set&name=test&value=value&initial=true"; + BrowserTestUtils.startLoadingURIString(non_private_browser, url); + await BrowserTestUtils.browserLoaded(non_private_browser, false, url); + + // Step 2 + let private_window = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + let private_browser = private_window.gBrowser.selectedBrowser; + url = prefix + "?action=set&name=test2&value=value2"; + BrowserTestUtils.startLoadingURIString(private_browser, url); + await BrowserTestUtils.browserLoaded(private_browser, false, url); + + // Step 3 + url = prefix + "?action=get&name=test2"; + BrowserTestUtils.startLoadingURIString(non_private_browser, url); + await BrowserTestUtils.browserLoaded(non_private_browser, false, url); + let elts = await getElts(non_private_browser); + isnot(elts[0], "value2", "public window shouldn't see private storage"); + is(elts[1], "1", "public window should only see public items"); + + // Step 4 + url = prefix + "?action=get&name=test"; + BrowserTestUtils.startLoadingURIString(private_browser, url); + await BrowserTestUtils.browserLoaded(private_browser, false, url); + elts = await getElts(private_browser); + isnot(elts[0], "value", "private window shouldn't see public storage"); + is(elts[1], "1", "private window should only see private items"); + + // Reopen the private window again, without privateBrowsing, which should clear the + // the private storage. + private_window.close(); + private_window = await BrowserTestUtils.openNewBrowserWindow({ + private: false, + }); + private_browser = null; + await new Promise(resolve => Cu.schedulePreciseGC(resolve)); + private_browser = private_window.gBrowser.selectedBrowser; + + url = prefix + "?action=get&name=test2"; + BrowserTestUtils.startLoadingURIString(private_browser, url); + await BrowserTestUtils.browserLoaded(private_browser, false, url); + elts = await getElts(private_browser); + isnot( + elts[0], + "value2", + "public window shouldn't see cleared private storage" + ); + is(elts[1], "1", "public window should only see public items"); + + // Making it private again should clear the storage and it shouldn't + // be able to see the old private storage as well. + private_window.close(); + private_window = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + private_browser = null; + await new Promise(resolve => Cu.schedulePreciseGC(resolve)); + private_browser = private_window.gBrowser.selectedBrowser; + + url = prefix + "?action=set&name=test3&value=value3"; + BrowserTestUtils.startLoadingURIString(private_browser, url); + await BrowserTestUtils.browserLoaded(private_browser, false, url); + elts = await getElts(private_browser); + is(elts[1], "1", "private window should only see new private items"); + + // Cleanup. + url = prefix + "?final=true"; + BrowserTestUtils.startLoadingURIString(non_private_browser, url); + await BrowserTestUtils.browserLoaded(non_private_browser, false, url); + private_window.close(); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_concurrent_page.html b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_concurrent_page.html new file mode 100644 index 0000000000..96d3b74c7c --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_concurrent_page.html @@ -0,0 +1,33 @@ + + + + + + + diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_context_and_chromeFlags.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_context_and_chromeFlags.js new file mode 100644 index 0000000000..66e8dea359 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_context_and_chromeFlags.js @@ -0,0 +1,69 @@ +"use strict"; + +/** + * Given some window in the parent process, ensure that + * the nsIAppWindow has the CHROME_PRIVATE_WINDOW chromeFlag, + * and that the usePrivateBrowsing property is set to true on + * both the window's nsILoadContext, as well as on the initial + * browser's content docShell nsILoadContext. + * + * @param win (nsIDOMWindow) + * An nsIDOMWindow in the parent process. + * @return Promise + */ +function assertWindowIsPrivate(win) { + let winDocShell = win.docShell; + let chromeFlags = winDocShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow).chromeFlags; + + if (!win.gBrowser.selectedBrowser.hasContentOpener) { + Assert.ok( + chromeFlags & Ci.nsIWebBrowserChrome.CHROME_PRIVATE_WINDOW, + "Should have the private window chrome flag" + ); + } + + let loadContext = winDocShell.QueryInterface(Ci.nsILoadContext); + Assert.ok( + loadContext.usePrivateBrowsing, + "The parent window should be using private browsing" + ); + + return SpecialPowers.spawn( + win.gBrowser.selectedBrowser, + [], + async function () { + let contentLoadContext = docShell.QueryInterface(Ci.nsILoadContext); + Assert.ok( + contentLoadContext.usePrivateBrowsing, + "Content docShell should be using private browsing" + ); + } + ); +} + +/** + * Tests that chromeFlags bits and the nsILoadContext.usePrivateBrowsing + * attribute are properly set when opening a new private browsing + * window. + */ +add_task(async function test_context_and_chromeFlags() { + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + await assertWindowIsPrivate(win); + + let browser = win.gBrowser.selectedBrowser; + + let newWinPromise = BrowserTestUtils.waitForNewWindow({ + url: "https://example.com/", + }); + await SpecialPowers.spawn(browser, [], async function () { + content.open("https://example.com", "_blank", "width=100,height=100"); + }); + + let win2 = await newWinPromise; + await assertWindowIsPrivate(win2); + + await BrowserTestUtils.closeWindow(win2); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_crh.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_crh.js new file mode 100644 index 0000000000..ddfb53f1a8 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_crh.js @@ -0,0 +1,48 @@ +/* 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 test makes sure that the Clear Recent History menu item and command +// is disabled inside the private browsing mode. + +add_task(async function test() { + function checkDisableOption(aPrivateMode, aWindow) { + let crhCommand = aWindow.document.getElementById("Tools:Sanitize"); + ok(crhCommand, "The clear recent history command should exist"); + + is( + PrivateBrowsingUtils.isWindowPrivate(aWindow), + aPrivateMode, + "PrivateBrowsingUtils should report the correct per-window private browsing status" + ); + is( + crhCommand.hasAttribute("disabled"), + aPrivateMode, + "Clear Recent History command should be disabled according to the private browsing mode" + ); + } + + let testURI = "http://mochi.test:8888/"; + + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + let privateBrowser = privateWin.gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(privateBrowser, testURI); + await BrowserTestUtils.browserLoaded(privateBrowser); + + info("Test on private window"); + checkDisableOption(true, privateWin); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + let browser = win.gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, testURI); + await BrowserTestUtils.browserLoaded(browser); + + info("Test on public window"); + checkDisableOption(false, win); + + // Cleanup + await BrowserTestUtils.closeWindow(privateWin); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_downloadLastDir.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_downloadLastDir.js new file mode 100644 index 0000000000..dd358bee73 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_downloadLastDir.js @@ -0,0 +1,133 @@ +/* -*- 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/. */ + +function test() { + waitForExplicitFinish(); + + let { FileUtils } = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" + ); + let { DownloadLastDir } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadLastDir.sys.mjs" + ); + let MockFilePicker = SpecialPowers.MockFilePicker; + let launcher = { + source: Services.io.newURI("http://test1.com/file"), + }; + + MockFilePicker.init(window); + MockFilePicker.returnValue = Ci.nsIFilePicker.returnOK; + + let prefs = Services.prefs.getBranch("browser.download."); + let launcherDialog = Cc["@mozilla.org/helperapplauncherdialog;1"].getService( + Ci.nsIHelperAppLauncherDialog + ); + let tmpDir = FileUtils.getDir("TmpD", []); + let dir1 = newDirectory(); + let dir2 = newDirectory(); + let dir3 = newDirectory(); + let file1 = newFileInDirectory(dir1); + let file2 = newFileInDirectory(dir2); + let file3 = newFileInDirectory(dir3); + + // cleanup functions registration + registerCleanupFunction(function () { + Services.prefs.clearUserPref("browser.download.lastDir"); + [dir1, dir2, dir3].forEach(dir => dir.remove(true)); + MockFilePicker.cleanup(); + }); + prefs.setComplexValue("lastDir", Ci.nsIFile, tmpDir); + + function testOnWindow(aPrivate, aCallback) { + whenNewWindowLoaded({ private: aPrivate }, function (win) { + let gDownloadLastDir = new DownloadLastDir(win); + aCallback(win, gDownloadLastDir); + gDownloadLastDir.cleanupPrivateFile(); + }); + } + + function testDownloadDir( + aWin, + gDownloadLastDir, + aFile, + aDisplayDir, + aLastDir, + aGlobalLastDir, + aCallback + ) { + // Check lastDir preference. + is( + prefs.getComplexValue("lastDir", Ci.nsIFile).path, + aDisplayDir.path, + "LastDir should be the expected display dir" + ); + // Check gDownloadLastDir value. + is( + gDownloadLastDir.file.path, + aDisplayDir.path, + "gDownloadLastDir should be the expected display dir" + ); + + MockFilePicker.setFiles([aFile]); + MockFilePicker.displayDirectory = null; + + launcher.saveDestinationAvailable = function (file) { + ok(!!file, "promptForSaveToFile correctly returned a file"); + + // File picker should start with expected display dir. + is( + MockFilePicker.displayDirectory.path, + aDisplayDir.path, + "File picker should start with browser.download.lastDir" + ); + // browser.download.lastDir should be modified on not private windows + is( + prefs.getComplexValue("lastDir", Ci.nsIFile).path, + aLastDir.path, + "LastDir should be the expected last dir" + ); + // gDownloadLastDir should be usable outside of private windows + is( + gDownloadLastDir.file.path, + aGlobalLastDir.path, + "gDownloadLastDir should be the expected global last dir" + ); + + launcher.saveDestinationAvailable = null; + aWin.close(); + aCallback(); + }; + + launcherDialog.promptForSaveToFileAsync(launcher, aWin, "", "", false); + } + + testOnWindow(false, function (win, downloadDir) { + testDownloadDir(win, downloadDir, file1, tmpDir, dir1, dir1, function () { + testOnWindow(true, function (win1, downloadDir1) { + testDownloadDir( + win1, + downloadDir1, + file2, + dir1, + dir1, + dir2, + function () { + testOnWindow(false, function (win2, downloadDir2) { + testDownloadDir( + win2, + downloadDir2, + file3, + dir1, + dir3, + dir3, + finish + ); + }); + } + ); + }); + }); + }); +} diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_downloadLastDir_c.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_downloadLastDir_c.js new file mode 100644 index 0000000000..04e510096a --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_downloadLastDir_c.js @@ -0,0 +1,146 @@ +/* -*- 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/. */ + +function test() { + waitForExplicitFinish(); + + let { FileUtils } = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" + ); + let { DownloadLastDir } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadLastDir.sys.mjs" + ); + let MockFilePicker = SpecialPowers.MockFilePicker; + + MockFilePicker.init(window); + MockFilePicker.returnValue = Ci.nsIFilePicker.returnOK; + + let validateFileNameToRestore = validateFileName; + let prefs = Services.prefs.getBranch("browser.download."); + let tmpDir = FileUtils.getDir("TmpD", []); + let dir1 = newDirectory(); + let dir2 = newDirectory(); + let dir3 = newDirectory(); + let file1 = newFileInDirectory(dir1); + let file2 = newFileInDirectory(dir2); + let file3 = newFileInDirectory(dir3); + + // cleanup function registration + registerCleanupFunction(function () { + Services.prefs.clearUserPref("browser.download.lastDir"); + [dir1, dir2, dir3].forEach(dir => dir.remove(true)); + MockFilePicker.cleanup(); + validateFileName = validateFileNameToRestore; + }); + + // Overwrite validateFileName to validate everything + validateFileName = foo => foo; + + let params = { + fileInfo: new FileInfo( + "test.txt", + "test.txt", + "test", + "txt", + "http://mozilla.org/test.txt" + ), + contentType: "text/plain", + saveMode: SAVEMODE_FILEONLY, + saveAsType: kSaveAsType_Complete, + file: null, + }; + + prefs.setComplexValue("lastDir", Ci.nsIFile, tmpDir); + + function testOnWindow(aPrivate, aCallback) { + whenNewWindowLoaded({ private: aPrivate }, function (win) { + let gDownloadLastDir = new DownloadLastDir(win); + aCallback(win, gDownloadLastDir); + }); + } + + function testDownloadDir( + aWin, + gDownloadLastDir, + aFile, + aDisplayDir, + aLastDir, + aGlobalLastDir, + aCallback + ) { + // Check lastDir preference. + is( + prefs.getComplexValue("lastDir", Ci.nsIFile).path, + aDisplayDir.path, + "LastDir should be the expected display dir" + ); + // Check gDownloadLastDir value. + is( + gDownloadLastDir.file.path, + aDisplayDir.path, + "gDownloadLastDir should be the expected display dir" + ); + + MockFilePicker.setFiles([aFile]); + MockFilePicker.displayDirectory = null; + aWin + .promiseTargetFile(params) + .then(function () { + // File picker should start with expected display dir. + is( + MockFilePicker.displayDirectory.path, + aDisplayDir.path, + "File picker should start with browser.download.lastDir" + ); + // browser.download.lastDir should be modified on not private windows + is( + prefs.getComplexValue("lastDir", Ci.nsIFile).path, + aLastDir.path, + "LastDir should be the expected last dir" + ); + // gDownloadLastDir should be usable outside of private windows + is( + gDownloadLastDir.file.path, + aGlobalLastDir.path, + "gDownloadLastDir should be the expected global last dir" + ); + + gDownloadLastDir.cleanupPrivateFile(); + aWin.close(); + aCallback(); + }) + .catch(function () { + ok(false); + }); + } + + testOnWindow(false, function (win, downloadDir) { + testDownloadDir(win, downloadDir, file1, tmpDir, dir1, dir1, function () { + testOnWindow(true, function (win1, downloadDir1) { + testDownloadDir( + win1, + downloadDir1, + file2, + dir1, + dir1, + dir2, + function () { + testOnWindow(false, function (win2, downloadDir2) { + testDownloadDir( + win2, + downloadDir2, + file3, + dir1, + dir3, + dir3, + finish + ); + }); + } + ); + }); + }); + }); +} diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_downloadLastDir_toggle.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_downloadLastDir_toggle.js new file mode 100644 index 0000000000..4b88bbddf9 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_downloadLastDir_toggle.js @@ -0,0 +1,118 @@ +/** + * Tests how the browser remembers the last download folder + * from download to download, with a particular emphasis + * on how it behaves when private browsing windows open. + */ +add_task(async function test_downloads_last_dir_toggle() { + let tmpDir = FileUtils.getDir("TmpD", []); + let dir1 = newDirectory(); + + registerCleanupFunction(function () { + Services.prefs.clearUserPref("browser.download.lastDir"); + dir1.remove(true); + }); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + let gDownloadLastDir = new DownloadLastDir(win); + is( + typeof gDownloadLastDir, + "object", + "gDownloadLastDir should be a valid object" + ); + is( + gDownloadLastDir.file, + null, + "gDownloadLastDir.file should be null to start with" + ); + + gDownloadLastDir.file = tmpDir; + is( + gDownloadLastDir.file.path, + tmpDir.path, + "LastDir should point to the temporary directory" + ); + isnot( + gDownloadLastDir.file, + tmpDir, + "gDownloadLastDir.file should not be pointing to the tmpDir" + ); + + gDownloadLastDir.file = 1; // not an nsIFile + is(gDownloadLastDir.file, null, "gDownloadLastDir.file should be null"); + + gDownloadLastDir.file = tmpDir; + clearHistory(); + is(gDownloadLastDir.file, null, "gDownloadLastDir.file should be null"); + + gDownloadLastDir.file = tmpDir; + await BrowserTestUtils.closeWindow(win); + + info("Opening the first private window"); + await testHelper({ private: true, expectedDir: tmpDir }); + info("Opening a non-private window"); + await testHelper({ private: false, expectedDir: tmpDir }); + info("Opening a private window and setting download directory"); + await testHelper({ private: true, setDir: dir1, expectedDir: dir1 }); + info("Opening a non-private window and checking download directory"); + await testHelper({ private: false, expectedDir: tmpDir }); + info("Opening private window and clearing history"); + await testHelper({ private: true, clearHistory: true, expectedDir: null }); + info("Opening a non-private window and checking download directory"); + await testHelper({ private: true, expectedDir: null }); +}); + +/** + * Opens a new window and performs some test actions on it based + * on the options object that have been passed in. + * + * @param options (Object) + * An object with the following properties: + * + * clearHistory (bool, optional): + * Whether or not to simulate clearing session history. + * Defaults to false. + * + * setDir (nsIFile, optional): + * An nsIFile for setting the last download directory. + * If not set, the load download directory is not changed. + * + * expectedDir (nsIFile, expectedDir): + * An nsIFile for what we expect the last download directory + * should be. The nsIFile is not compared directly - only + * paths are compared. If expectedDir is not set, then the + * last download directory is expected to be null. + * + * @returns Promise + */ +async function testHelper(options) { + let win = await BrowserTestUtils.openNewBrowserWindow(options); + let gDownloadLastDir = new DownloadLastDir(win); + + if (options.clearHistory) { + clearHistory(); + } + + if (options.setDir) { + gDownloadLastDir.file = options.setDir; + } + + let expectedDir = options.expectedDir; + + if (expectedDir) { + is( + gDownloadLastDir.file.path, + expectedDir.path, + "gDownloadLastDir should point to the expected last directory" + ); + isnot( + gDownloadLastDir.file, + expectedDir, + "gDownloadLastDir.file should not be pointing to the last directory" + ); + } else { + is(gDownloadLastDir.file, null, "gDownloadLastDir should be null"); + } + + gDownloadLastDir.cleanupPrivateFile(); + await BrowserTestUtils.closeWindow(win); +} diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_favicon.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_favicon.js new file mode 100644 index 0000000000..eea0ab07ca --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_favicon.js @@ -0,0 +1,322 @@ +/* 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 test make sure that the favicon of the private browsing is isolated. + +const TEST_SITE = "https://example.com"; +const TEST_CACHE_SITE = "https://test1.example.com"; +const TEST_DIRECTORY = + "/browser/browser/components/privatebrowsing/test/browser/"; + +const TEST_PAGE = TEST_SITE + TEST_DIRECTORY + "file_favicon.html"; +const TEST_CACHE_PAGE = TEST_CACHE_SITE + TEST_DIRECTORY + "file_favicon.html"; +const FAVICON_URI = TEST_SITE + TEST_DIRECTORY + "file_favicon.png"; +const FAVICON_CACHE_URI = TEST_CACHE_SITE + TEST_DIRECTORY + "file_favicon.png"; + +let systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); + +function clearAllImageCaches() { + let tools = SpecialPowers.Cc["@mozilla.org/image/tools;1"].getService( + SpecialPowers.Ci.imgITools + ); + let imageCache = tools.getImgCacheForDocument(window.document); + imageCache.clearCache(true); // true=chrome + imageCache.clearCache(false); // false=content +} + +function clearAllPlacesFavicons() { + let faviconService = Cc["@mozilla.org/browser/favicon-service;1"].getService( + Ci.nsIFaviconService + ); + + return new Promise(resolve => { + let observer = { + observe(aSubject, aTopic, aData) { + if (aTopic === "places-favicons-expired") { + resolve(); + Services.obs.removeObserver(observer, "places-favicons-expired"); + } + }, + }; + + Services.obs.addObserver(observer, "places-favicons-expired"); + faviconService.expireAllFavicons(); + }); +} + +function observeFavicon(aIsPrivate, aExpectedCookie, aPageURI) { + let attr = {}; + + if (aIsPrivate) { + attr.privateBrowsingId = 1; + } + + let expectedPrincipal = Services.scriptSecurityManager.createContentPrincipal( + aPageURI, + attr + ); + + return new Promise(resolve => { + let observer = { + observe(aSubject, aTopic, aData) { + // Make sure that the topic is 'http-on-modify-request'. + if (aTopic === "http-on-modify-request") { + // We check the privateBrowsingId for the originAttributes of the loading + // channel. All requests for the favicon should contain the correct + // privateBrowsingId. There are two requests for a favicon loading, one + // from the Places library and one from the XUL image. The difference + // of them is the loading principal. The Places will use the content + // principal and the XUL image will use the system principal. + + let httpChannel = aSubject.QueryInterface(Ci.nsIHttpChannel); + let reqLoadInfo = httpChannel.loadInfo; + let loadingPrincipal = reqLoadInfo.loadingPrincipal; + + // Make sure this is a favicon request. + if (httpChannel.URI.spec !== FAVICON_URI) { + return; + } + + // Check the privateBrowsingId. + if (aIsPrivate) { + is( + reqLoadInfo.originAttributes.privateBrowsingId, + 1, + "The loadInfo has correct privateBrowsingId" + ); + } else { + is( + reqLoadInfo.originAttributes.privateBrowsingId, + 0, + "The loadInfo has correct privateBrowsingId" + ); + } + + ok( + loadingPrincipal.equals(expectedPrincipal), + "The loadingPrincipal of favicon loading from Places should be the content prinicpal" + ); + + let faviconCookie = httpChannel.getRequestHeader("cookie"); + + is( + faviconCookie, + aExpectedCookie, + "The cookie of the favicon loading is correct." + ); + } else { + ok(false, "Received unexpected topic: ", aTopic); + } + + resolve(); + Services.obs.removeObserver(observer, "http-on-modify-request"); + }, + }; + + Services.obs.addObserver(observer, "http-on-modify-request"); + }); +} + +function waitOnFaviconResponse(aFaviconURL) { + return new Promise(resolve => { + let observer = { + observe(aSubject, aTopic, aData) { + if ( + aTopic === "http-on-examine-response" || + aTopic === "http-on-examine-cached-response" + ) { + let httpChannel = aSubject.QueryInterface(Ci.nsIHttpChannel); + let loadInfo = httpChannel.loadInfo; + + if (httpChannel.URI.spec !== aFaviconURL) { + return; + } + + let result = { + topic: aTopic, + privateBrowsingId: loadInfo.originAttributes.privateBrowsingId, + }; + + resolve(result); + Services.obs.removeObserver(observer, "http-on-examine-response"); + Services.obs.removeObserver( + observer, + "http-on-examine-cached-response" + ); + } + }, + }; + + Services.obs.addObserver(observer, "http-on-examine-response"); + Services.obs.addObserver(observer, "http-on-examine-cached-response"); + }); +} + +function waitOnFaviconLoaded(aFaviconURL) { + return PlacesTestUtils.waitForNotification("favicon-changed", events => + events.some(e => e.faviconUrl == aFaviconURL) + ); +} + +async function assignCookies(aBrowser, aURL, aCookieValue) { + let tabInfo = await openTab(aBrowser, aURL); + + await SpecialPowers.spawn( + tabInfo.browser, + [aCookieValue], + async function (value) { + content.document.cookie = value; + } + ); + + BrowserTestUtils.removeTab(tabInfo.tab); +} + +async function openTab(aBrowser, aURL) { + let tab = BrowserTestUtils.addTab(aBrowser, aURL); + + // Select tab and make sure its browser is focused. + aBrowser.selectedTab = tab; + tab.ownerGlobal.focus(); + + let browser = aBrowser.getBrowserForTab(tab); + await BrowserTestUtils.browserLoaded(browser); + return { tab, browser }; +} + +registerCleanupFunction(async () => { + Services.cookies.removeAll(); + clearAllImageCaches(); + Services.cache2.clear(); + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_favicon_privateBrowsing() { + // Clear all image caches before running the test. + clearAllImageCaches(); + // Clear all favicons in Places. + await clearAllPlacesFavicons(); + + // Create a private browsing window. + let privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + let pageURI = Services.io.newURI(TEST_PAGE); + + // Generate two random cookies for non-private window and private window + // respectively. + let cookies = []; + cookies.push(Math.random().toString()); + cookies.push(Math.random().toString()); + + // Open a tab in private window and add a cookie into it. + await assignCookies(privateWindow.gBrowser, TEST_SITE, cookies[0]); + + // Open a tab in non-private window and add a cookie into it. + await assignCookies(gBrowser, TEST_SITE, cookies[1]); + + // Add the observer earlier in case we don't capture events in time. + let promiseObserveFavicon = observeFavicon(true, cookies[0], pageURI); + + // The page must be bookmarked for favicon requests to go through in PB mode. + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_PAGE, + }); + + // Open a tab for the private window. + let tabInfo = await openTab(privateWindow.gBrowser, TEST_PAGE); + + info("Waiting until favicon requests are all made in private window."); + await promiseObserveFavicon; + + // Close the tab. + BrowserTestUtils.removeTab(tabInfo.tab); + // FIXME: We need to wait for the next event tick here to avoid observing + // the previous tab info in the next step (bug 1446725). + await new Promise(executeSoon); + + // Add the observer earlier in case we don't capture events in time. + promiseObserveFavicon = observeFavicon(false, cookies[1], pageURI); + + // Open a tab for the non-private window. + tabInfo = await openTab(gBrowser, TEST_PAGE); + + info("Waiting until favicon requests are all made in non-private window."); + await promiseObserveFavicon; + + // Close the tab. + BrowserTestUtils.removeTab(tabInfo.tab); + await BrowserTestUtils.closeWindow(privateWindow); +}); + +add_task(async function test_favicon_cache_privateBrowsing() { + // Clear all image caches and network cache before running the test. + clearAllImageCaches(); + + Services.cache2.clear(); + + // Clear all favicons in Places. + await clearAllPlacesFavicons(); + + // Add an observer for making sure the favicon has been loaded and cached. + let promiseFaviconLoaded = waitOnFaviconLoaded(FAVICON_CACHE_URI); + let promiseFaviconResponse = waitOnFaviconResponse(FAVICON_CACHE_URI); + + // Open a tab for the non-private window. + let tabInfoNonPrivate = await openTab(gBrowser, TEST_CACHE_PAGE); + + let response = await promiseFaviconResponse; + + await promiseFaviconLoaded; + + // Check that the favicon response has come from the network and it has the + // correct privateBrowsingId. + is( + response.topic, + "http-on-examine-response", + "The favicon image should be loaded through network." + ); + is( + response.privateBrowsingId, + 0, + "We should observe the network response for the non-private tab." + ); + + // Create a private browsing window. + let privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + // The page must be bookmarked for favicon requests to go through in PB mode. + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_CACHE_PAGE, + }); + + promiseFaviconResponse = waitOnFaviconResponse(FAVICON_CACHE_URI); + + // Open a tab for the private window. + let tabInfoPrivate = await openTab(privateWindow.gBrowser, TEST_CACHE_PAGE); + + // Wait for the favicon response of the private tab. + response = await promiseFaviconResponse; + + // Make sure the favicon is loaded through the network and its privateBrowsingId is correct. + is( + response.topic, + "http-on-examine-response", + "The favicon image should be loaded through the network again." + ); + is( + response.privateBrowsingId, + 1, + "We should observe the network response for the private tab." + ); + + BrowserTestUtils.removeTab(tabInfoPrivate.tab); + BrowserTestUtils.removeTab(tabInfoNonPrivate.tab); + await BrowserTestUtils.closeWindow(privateWindow); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_geoprompt_page.html b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_geoprompt_page.html new file mode 100644 index 0000000000..01ed3f3d2c --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_geoprompt_page.html @@ -0,0 +1,13 @@ + + + + Geolocation invoker + + + + + diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_history_shift_click.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_history_shift_click.js new file mode 100644 index 0000000000..793bcd1a5d --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_history_shift_click.js @@ -0,0 +1,69 @@ +/* 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/. */ + +add_task(async function () { + await testShiftClickOpensNewWindow("back-button"); +}); + +add_task(async function () { + await testShiftClickOpensNewWindow("forward-button"); +}); + +// Create new private browser, open new tab and set history state, then return the window +async function createPrivateWindow() { + const privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await BrowserTestUtils.openNewForegroundTab( + privateWindow.gBrowser, + "http://example.com" + ); + await SpecialPowers.spawn( + privateWindow.gBrowser.selectedBrowser, + [], + async function () { + content.history.pushState({}, "first item", "first-item.html"); + content.history.pushState({}, "second item", "second-item.html"); + content.history.pushState({}, "third item", "third-item.html"); + content.history.back(); + } + ); + await TestUtils.topicObserved("sessionstore-state-write-complete"); + + // Wait for the session data to be flushed before continuing the test + await new Promise(resolve => + SessionStore.getSessionHistory(privateWindow.gBrowser.selectedTab, resolve) + ); + + info("Private window created"); + + return privateWindow; +} + +async function testShiftClickOpensNewWindow(buttonId) { + const privateWindow = await createPrivateWindow(); + + const button = privateWindow.document.getElementById(buttonId); + // Wait for the new private window to be created after click + const newPrivateWindowPromise = BrowserTestUtils.waitForNewWindow(); + + EventUtils.synthesizeMouseAtCenter(button, { shiftKey: true }, privateWindow); + + info("Waiting for new private browser to open"); + + const newPrivateWindow = await newPrivateWindowPromise; + + ok( + PrivateBrowsingUtils.isBrowserPrivate(newPrivateWindow.gBrowser), + "New window is private" + ); + + // Cleanup + await Promise.all([ + BrowserTestUtils.closeWindow(privateWindow), + BrowserTestUtils.closeWindow(newPrivateWindow), + ]); + + info("Closed all windows"); +} diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_last_private_browsing_context_exited.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_last_private_browsing_context_exited.js new file mode 100644 index 0000000000..1fd28d4ca6 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_last_private_browsing_context_exited.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_no_notification_when_pb_autostart() { + let observedLastPBContext = false; + let observerExited = { + observe(aSubject, aTopic, aData) { + observedLastPBContext = true; + }, + }; + Services.obs.addObserver(observerExited, "last-pb-context-exited"); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.privatebrowsing.autostart", true]], + }); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + + let browser = win.gBrowser.selectedTab.linkedBrowser; + ok(browser.browsingContext.usePrivateBrowsing, "should use private browsing"); + + await BrowserTestUtils.closeWindow(win); + + await SpecialPowers.popPrefEnv(); + Services.obs.removeObserver(observerExited, "last-pb-context-exited"); + ok(!observedLastPBContext, "No last-pb-context-exited notification seen"); +}); + +add_task(async function test_notification_when_about_preferences() { + let observedLastPBContext = false; + let observerExited = { + observe(aSubject, aTopic, aData) { + observedLastPBContext = true; + }, + }; + Services.obs.addObserver(observerExited, "last-pb-context-exited"); + + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + let browser = win.gBrowser.selectedTab.linkedBrowser; + ok(browser.browsingContext.usePrivateBrowsing, "should use private browsing"); + ok(browser.browsingContext.isContent, "should be content browsing context"); + + let tab = await BrowserTestUtils.addTab(win.gBrowser, "about:preferences"); + ok( + tab.linkedBrowser.browsingContext.usePrivateBrowsing, + "should use private browsing" + ); + ok( + tab.linkedBrowser.browsingContext.isContent, + "should be content browsing context" + ); + + let tabClose = BrowserTestUtils.waitForTabClosing(win.gBrowser.selectedTab); + BrowserTestUtils.removeTab(win.gBrowser.selectedTab); + await tabClose; + + ok(!observedLastPBContext, "No last-pb-context-exited notification seen"); + + await BrowserTestUtils.closeWindow(win); + + Services.obs.removeObserver(observerExited, "last-pb-context-exited"); + ok(observedLastPBContext, "No last-pb-context-exited notification seen"); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_lastpbcontextexited.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_lastpbcontextexited.js new file mode 100644 index 0000000000..c46417933a --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_lastpbcontextexited.js @@ -0,0 +1,63 @@ +/* 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/. */ + +function test() { + // We need to open a new window for this so that its docshell would get destroyed + // when clearing the PB mode flag. + function runTest(aCloseWindow, aCallback) { + let newWin = OpenBrowserWindow({ private: true }); + SimpleTest.waitForFocus(function () { + let expectedExiting = true; + let expectedExited = false; + let observerExiting = { + observe(aSubject, aTopic, aData) { + is( + aTopic, + "last-pb-context-exiting", + "Correct topic should be dispatched (exiting)" + ); + is(expectedExiting, true, "notification not expected yet (exiting)"); + expectedExited = true; + Services.obs.removeObserver( + observerExiting, + "last-pb-context-exiting" + ); + }, + }; + let observerExited = { + observe(aSubject, aTopic, aData) { + is( + aTopic, + "last-pb-context-exited", + "Correct topic should be dispatched (exited)" + ); + is(expectedExited, true, "notification not expected yet (exited)"); + Services.obs.removeObserver(observerExited, "last-pb-context-exited"); + aCallback(); + }, + }; + Services.obs.addObserver(observerExiting, "last-pb-context-exiting"); + Services.obs.addObserver(observerExited, "last-pb-context-exited"); + expectedExiting = true; + aCloseWindow(newWin); + newWin = null; + SpecialPowers.forceGC(); + }, newWin); + } + + waitForExplicitFinish(); + + runTest( + function (newWin) { + // Simulate pressing the window close button + newWin.document.getElementById("cmd_closeWindow").doCommand(); + }, + function () { + runTest(function (newWin) { + // Simulate closing the last tab + newWin.document.getElementById("cmd_close").doCommand(); + }, finish); + } + ); +} diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage.js new file mode 100644 index 0000000000..7cf07fd601 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage.js @@ -0,0 +1,28 @@ +/* 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/. */ + +add_task(async function test() { + requestLongerTimeout(2); + const page1 = + "http://mochi.test:8888/browser/browser/components/privatebrowsing/test/browser/" + + "browser_privatebrowsing_localStorage_page1.html"; + + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + win.gBrowser.selectedTab = BrowserTestUtils.addTab(win.gBrowser, page1); + let browser = win.gBrowser.selectedBrowser; + await BrowserTestUtils.browserLoaded(browser); + + BrowserTestUtils.startLoadingURIString( + browser, + "http://mochi.test:8888/browser/browser/components/privatebrowsing/test/browser/" + + "browser_privatebrowsing_localStorage_page2.html" + ); + await BrowserTestUtils.browserLoaded(browser); + + is(browser.contentTitle, "2", "localStorage should contain 2 items"); + + // Cleanup + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage_before_after.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage_before_after.js new file mode 100644 index 0000000000..3537236068 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage_before_after.js @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Ensure that a storage instance used by both private and public sessions at different times does not +// allow any data to leak due to cached values. + +// Step 1: Load browser_privatebrowsing_localStorage_before_after_page.html in a private tab, causing a storage +// item to exist. Close the tab. +// Step 2: Load the same page in a non-private tab, ensuring that the storage instance reports only one item +// existing. + +add_task(async function test() { + let prefix = + "http://mochi.test:8888/browser/browser/components/privatebrowsing/test/browser/"; + + // Step 1. + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + let testURL = + prefix + "browser_privatebrowsing_localStorage_before_after_page.html"; + await BrowserTestUtils.openNewForegroundTab(privateWin.gBrowser, testURL); + + is( + privateWin.gBrowser.selectedBrowser.contentTitle, + "1", + "localStorage should contain 1 item" + ); + + // Step 2. + let win = await BrowserTestUtils.openNewBrowserWindow(); + testURL = + prefix + "browser_privatebrowsing_localStorage_before_after_page2.html"; + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, testURL); + + is( + win.gBrowser.selectedBrowser.contentTitle, + "null|0", + "localStorage should contain 0 items" + ); + + // Cleanup + await BrowserTestUtils.closeWindow(privateWin); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage_before_after_page.html b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage_before_after_page.html new file mode 100644 index 0000000000..0fcb3f89be --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage_before_after_page.html @@ -0,0 +1,11 @@ + + + + + + + diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage_before_after_page2.html b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage_before_after_page2.html new file mode 100644 index 0000000000..4eccebdf48 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage_before_after_page2.html @@ -0,0 +1,10 @@ + + + + + + + diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage_page1.html b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage_page1.html new file mode 100644 index 0000000000..ecf5507e0a --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage_page1.html @@ -0,0 +1,10 @@ + + + + + + + diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage_page2.html b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage_page2.html new file mode 100644 index 0000000000..d49c7fea29 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_localStorage_page2.html @@ -0,0 +1,10 @@ + + + + + + + diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_newtab_from_popup.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_newtab_from_popup.js new file mode 100644 index 0000000000..a46c44b0b8 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_newtab_from_popup.js @@ -0,0 +1,71 @@ +/** + * Tests that a popup window in private browsing window opens + * new tab links in the original private browsing window as + * new tabs. + * + * This is a regression test for bug 1202634. + */ + +// We're able to sidestep some quote-escaping issues when +// nesting data URI's by encoding the second data URI in +// base64. +const POPUP_BODY_BASE64 = btoa(` + Now click this + `); +const POPUP_LINK = `data:text/html;charset=utf-8;base64,${POPUP_BODY_BASE64}`; +const WINDOW_BODY = `data:text/html, + + First click this. + `; + +add_task(async function test_private_popup_window_opens_private_tabs() { + // allow top level data: URI navigations, otherwise clicking a data: link fails + await SpecialPowers.pushPrefEnv({ + set: [["security.data_uri.block_toplevel_data_uri_navigations", false]], + }); + let privWin = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + // Sanity check - this browser better be private. + ok( + PrivateBrowsingUtils.isWindowPrivate(privWin), + "Opened a private browsing window." + ); + + // First, open a private browsing window, and load our + // testing page. + let privBrowser = privWin.gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(privBrowser, WINDOW_BODY); + await BrowserTestUtils.browserLoaded(privBrowser); + + // Next, click on the link in the testing page, and ensure + // that a private popup window is opened. + let openedPromise = BrowserTestUtils.waitForNewWindow({ url: POPUP_LINK }); + + await BrowserTestUtils.synthesizeMouseAtCenter("#first", {}, privBrowser); + let popupWin = await openedPromise; + ok( + PrivateBrowsingUtils.isWindowPrivate(popupWin), + "Popup window was private." + ); + + // Now click on the link in the popup, and ensure that a new + // tab is opened in the original private browsing window. + let newTabPromise = BrowserTestUtils.waitForNewTab(privWin.gBrowser); + let popupBrowser = popupWin.gBrowser.selectedBrowser; + await BrowserTestUtils.synthesizeMouseAtCenter("#second", {}, popupBrowser); + let newPrivTab = await newTabPromise; + + // Ensure that the newly created tab's browser is private. + ok( + PrivateBrowsingUtils.isBrowserPrivate(newPrivTab.linkedBrowser), + "Newly opened tab should be private." + ); + + // Clean up + BrowserTestUtils.removeTab(newPrivTab); + await BrowserTestUtils.closeWindow(popupWin); + await BrowserTestUtils.closeWindow(privWin); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_noSessionRestoreMenuOption.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_noSessionRestoreMenuOption.js new file mode 100644 index 0000000000..17ea34d1aa --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_noSessionRestoreMenuOption.js @@ -0,0 +1,29 @@ +"use strict"; + +/** + * Tests that if we open a tab within a private browsing window, and then + * close that private browsing window, that subsequent private browsing + * windows do not allow the command for restoring the last session. + */ +add_task(async function test_no_session_restore_menu_option() { + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + ok(true, "The first private window got loaded"); + BrowserTestUtils.addTab(win.gBrowser, "about:mozilla"); + await BrowserTestUtils.closeWindow(win); + + win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + let srCommand = win.document.getElementById("Browser:RestoreLastSession"); + ok(srCommand, "The Session Restore command should exist"); + is( + PrivateBrowsingUtils.isWindowPrivate(win), + true, + "PrivateBrowsingUtils should report the correct per-window private browsing status" + ); + is( + srCommand.hasAttribute("disabled"), + true, + "The Session Restore command should be disabled in private browsing mode" + ); + + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_nonbrowser.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_nonbrowser.js new file mode 100644 index 0000000000..24586d7464 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_nonbrowser.js @@ -0,0 +1,21 @@ +"use strict"; + +/** + * Tests that we fire the last-pb-context-exited observer notification + * when the last private browsing window closes, even if a chrome window + * was opened from that private browsing window. + */ +add_task(async function () { + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + let chromeWin = win.open( + "chrome://browser/content/places/places.xhtml", + "_blank", + "chrome,extrachrome,menubar,resizable,scrollbars,status,toolbar" + ); + await BrowserTestUtils.waitForEvent(chromeWin, "load"); + let obsPromise = TestUtils.topicObserved("last-pb-context-exited"); + await BrowserTestUtils.closeWindow(win); + await obsPromise; + Assert.ok(true, "Got the last-pb-context-exited notification"); + chromeWin.close(); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_opendir.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_opendir.js new file mode 100644 index 0000000000..146ab82628 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_opendir.js @@ -0,0 +1,175 @@ +/* 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 test makes sure that the last open directory used inside the private +// browsing mode is not remembered after leaving that mode. + +var windowsToClose = []; +function testOnWindow(options, callback) { + var win = OpenBrowserWindow(options); + win.addEventListener( + "load", + function () { + windowsToClose.push(win); + callback(win); + }, + { once: true } + ); +} + +registerCleanupFunction(function () { + windowsToClose.forEach(function (win) { + win.close(); + }); +}); + +function test() { + // initialization + waitForExplicitFinish(); + let dir1 = Services.dirsvc.get("ProfD", Ci.nsIFile); + let dir2 = Services.dirsvc.get("TmpD", Ci.nsIFile); + let file = dir2.clone(); + file.append("pbtest.file"); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600); + + const kPrefName = "browser.open.lastDir"; + + function setupCleanSlate(win) { + win.gLastOpenDirectory.reset(); + Services.prefs.clearUserPref(kPrefName); + } + + setupCleanSlate(window); + + // open one regular and one private window + testOnWindow(undefined, function (nonPrivateWindow) { + setupCleanSlate(nonPrivateWindow); + testOnWindow({ private: true }, function (privateWindow) { + setupCleanSlate(privateWindow); + + // Test 1: general workflow test + + // initial checks + ok( + !nonPrivateWindow.gLastOpenDirectory.path, + "Last open directory path should be initially empty" + ); + nonPrivateWindow.gLastOpenDirectory.path = dir2; + is( + nonPrivateWindow.gLastOpenDirectory.path.path, + dir2.path, + "The path should be successfully set" + ); + nonPrivateWindow.gLastOpenDirectory.path = null; + is( + nonPrivateWindow.gLastOpenDirectory.path.path, + dir2.path, + "The path should be not change when assigning it to null" + ); + nonPrivateWindow.gLastOpenDirectory.path = dir1; + is( + nonPrivateWindow.gLastOpenDirectory.path.path, + dir1.path, + "The path should be successfully outside of the private browsing mode" + ); + + // test the private window + is( + privateWindow.gLastOpenDirectory.path.path, + dir1.path, + "The path should not change when entering the private browsing mode" + ); + privateWindow.gLastOpenDirectory.path = dir2; + is( + privateWindow.gLastOpenDirectory.path.path, + dir2.path, + "The path should successfully change inside the private browsing mode" + ); + + // test the non-private window + is( + nonPrivateWindow.gLastOpenDirectory.path.path, + dir1.path, + "The path should be reset to the same path as before entering the private browsing mode" + ); + + setupCleanSlate(nonPrivateWindow); + setupCleanSlate(privateWindow); + + // Test 2: the user first tries to open a file inside the private browsing mode + + // test the private window + ok( + !privateWindow.gLastOpenDirectory.path, + "No original path should exist inside the private browsing mode" + ); + privateWindow.gLastOpenDirectory.path = dir1; + is( + privateWindow.gLastOpenDirectory.path.path, + dir1.path, + "The path should be successfully set inside the private browsing mode" + ); + // test the non-private window + ok( + !nonPrivateWindow.gLastOpenDirectory.path, + "The path set inside the private browsing mode should not leak when leaving that mode" + ); + + setupCleanSlate(nonPrivateWindow); + setupCleanSlate(privateWindow); + + // Test 3: the last open directory is set from a previous session, it should be used + // in normal mode + + Services.prefs.setComplexValue(kPrefName, Ci.nsIFile, dir1); + is( + nonPrivateWindow.gLastOpenDirectory.path.path, + dir1.path, + "The pref set from last session should take effect outside the private browsing mode" + ); + + setupCleanSlate(nonPrivateWindow); + setupCleanSlate(privateWindow); + + // Test 4: the last open directory is set from a previous session, it should be used + // in private browsing mode mode + + Services.prefs.setComplexValue(kPrefName, Ci.nsIFile, dir1); + // test the private window + is( + privateWindow.gLastOpenDirectory.path.path, + dir1.path, + "The pref set from last session should take effect inside the private browsing mode" + ); + // test the non-private window + is( + nonPrivateWindow.gLastOpenDirectory.path.path, + dir1.path, + "The pref set from last session should remain in effect after leaving the private browsing mode" + ); + + setupCleanSlate(nonPrivateWindow); + setupCleanSlate(privateWindow); + + // Test 5: setting the path to a file shouldn't work + + nonPrivateWindow.gLastOpenDirectory.path = file; + ok( + !nonPrivateWindow.gLastOpenDirectory.path, + "Setting the path to a file shouldn't work when it's originally null" + ); + nonPrivateWindow.gLastOpenDirectory.path = dir1; + nonPrivateWindow.gLastOpenDirectory.path = file; + is( + nonPrivateWindow.gLastOpenDirectory.path.path, + dir1.path, + "Setting the path to a file shouldn't work when it's not originally null" + ); + + // cleanup + file.remove(false); + finish(); + }); + }); +} diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_placesTitleNoUpdate.html b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_placesTitleNoUpdate.html new file mode 100644 index 0000000000..f5bb3212f8 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_placesTitleNoUpdate.html @@ -0,0 +1,8 @@ + + + + Title 1 + + + + diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_placesTitleNoUpdate.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_placesTitleNoUpdate.js new file mode 100644 index 0000000000..855bfe4c41 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_placesTitleNoUpdate.js @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +// Test to make sure that the visited page titles do not get updated inside the +// private browsing mode. +"use strict"; + +add_task(async function test() { + const TEST_URL = + "http://mochi.test:8888/browser/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_placesTitleNoUpdate.html"; + const TITLE_1 = "Title 1"; + const TITLE_2 = "Title 2"; + + await PlacesUtils.history.clear(); + + let promiseTitleChanged = PlacesTestUtils.waitForNotification( + "page-title-changed", + events => events[0].url == TEST_URL + ); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(tab); + }); + info("Wait for a title change notification."); + await promiseTitleChanged; + await BrowserTestUtils.waitForCondition(async function () { + let entry = await PlacesUtils.history.fetch(TEST_URL); + return entry && entry.title == TITLE_1; + }, "The title matches the original title after first visit"); + + promiseTitleChanged = PlacesTestUtils.waitForNotification( + "page-title-changed", + events => events[0].url == TEST_URL + ); + await PlacesTestUtils.addVisits({ uri: TEST_URL, title: TITLE_2 }); + info("Wait for a title change notification."); + await promiseTitleChanged; + await BrowserTestUtils.waitForCondition(async function () { + let entry = await PlacesUtils.history.fetch(TEST_URL); + return entry && entry.title == TITLE_2; + }, "The title matches the original title after updating visit"); + + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + registerCleanupFunction(async () => { + await BrowserTestUtils.closeWindow(privateWin); + }); + await BrowserTestUtils.openNewForegroundTab(privateWin.gBrowser, TEST_URL); + // Wait long enough to be sure history didn't set a title. + await new Promise(resolve => setTimeout(resolve, 1000)); + is( + (await PlacesUtils.history.fetch(TEST_URL)).title, + TITLE_2, + "The title remains the same after visiting in private window" + ); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_placestitle.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_placestitle.js new file mode 100644 index 0000000000..af2c6aeb65 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_placestitle.js @@ -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/. */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +// This test makes sure that the title of existing history entries does not +// change inside a private window. + +add_task(async function test() { + const TEST_URL = + "http://mochi.test:8888/browser/browser/components/" + + "privatebrowsing/test/browser/title.sjs"; + let cm = Services.cookies; + + function cleanup() { + // delete all cookies + cm.removeAll(); + // delete all history items + return PlacesUtils.history.clear(); + } + + await cleanup(); + registerCleanupFunction(cleanup); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + registerCleanupFunction(async () => { + await BrowserTestUtils.closeWindow(win); + }); + + let promiseTitleChanged = PlacesTestUtils.waitForNotification( + "page-title-changed", + events => events[0].url == TEST_URL + ); + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, TEST_URL); + await promiseTitleChanged; + await BrowserTestUtils.waitForCondition(async function () { + let entry = await PlacesUtils.history.fetch(TEST_URL); + return entry && entry.title == "No Cookie"; + }, "The page should be loaded without any cookie for the first time"); + + promiseTitleChanged = PlacesTestUtils.waitForNotification( + "page-title-changed", + events => events[0].url == TEST_URL + ); + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, TEST_URL); + await promiseTitleChanged; + await BrowserTestUtils.waitForCondition(async function () { + let entry = await PlacesUtils.history.fetch(TEST_URL); + return entry && entry.title == "Cookie"; + }, "The page should be loaded with a cookie for the second time"); + + await cleanup(); + + promiseTitleChanged = PlacesTestUtils.waitForNotification( + "page-title-changed", + events => events[0].url == TEST_URL + ); + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, TEST_URL); + await promiseTitleChanged; + await BrowserTestUtils.waitForCondition(async function () { + let entry = await PlacesUtils.history.fetch(TEST_URL); + return entry && entry.title == "No Cookie"; + }, "The page should be loaded without any cookie again"); + + // Reopen the page in a private browser window, it should not notify a title + // change. + let win2 = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + registerCleanupFunction(async () => { + let promisePBExit = TestUtils.topicObserved("last-pb-context-exited"); + await BrowserTestUtils.closeWindow(win2); + await promisePBExit; + }); + + await BrowserTestUtils.openNewForegroundTab(win2.gBrowser, TEST_URL); + // Wait long enough to be sure history didn't set a title. + await new Promise(resolve => setTimeout(resolve, 1000)); + is( + (await PlacesUtils.history.fetch(TEST_URL)).title, + "No Cookie", + "The title remains the same after visiting in private window" + ); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_protocolhandler.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_protocolhandler.js new file mode 100644 index 0000000000..4ab90e2079 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_protocolhandler.js @@ -0,0 +1,71 @@ +/* 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 mozilla/no-arbitrary-setTimeout */ + +// This test makes sure that the web pages can't register protocol handlers +// inside the private browsing mode. + +add_task(async function test() { + let notificationValue = "Protocol Registration: web+testprotocol"; + let testURI = + "https://example.com/browser/" + + "browser/components/privatebrowsing/test/browser/browser_privatebrowsing_protocolhandler_page.html"; + + let doTest = async function (aIsPrivateMode, aWindow) { + let tab = (aWindow.gBrowser.selectedTab = BrowserTestUtils.addTab( + aWindow.gBrowser, + testURI + )); + let notificationBox = aWindow.gBrowser.getNotificationBox(); + let notificationShownPromise; + if (!aIsPrivateMode) { + notificationShownPromise = BrowserTestUtils.waitForEvent( + notificationBox.stack, + "AlertActive", + false, + event => event.target.getAttribute("value") == notificationValue + ); + } + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await notificationShownPromise; + + let promiseFinished = Promise.withResolvers(); + setTimeout(function () { + let notification = + notificationBox.getNotificationWithValue(notificationValue); + + if (aIsPrivateMode) { + // Make sure the notification is correctly displayed without a remember control + ok( + !notification, + "Notification box should not be displayed inside of private browsing mode" + ); + } else { + // Make sure the notification is correctly displayed with a remember control + ok( + notification, + "Notification box should be displaying outside of private browsing mode" + ); + } + + promiseFinished.resolve(); + }, 100); // remember control is added in a setTimeout(0) call + + await promiseFinished.promise; + }; + + // test first when not on private mode + let win = await BrowserTestUtils.openNewBrowserWindow(); + await doTest(false, win); + + // then test when on private mode + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await doTest(true, privateWin); + + // Cleanup + await BrowserTestUtils.closeWindow(win); + await BrowserTestUtils.closeWindow(privateWin); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_protocolhandler_page.html b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_protocolhandler_page.html new file mode 100644 index 0000000000..200fda0d42 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_protocolhandler_page.html @@ -0,0 +1,13 @@ + + + + Protocol registrar page + + + + + diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_rememberprompt.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_rememberprompt.js new file mode 100644 index 0000000000..2090742c91 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_rememberprompt.js @@ -0,0 +1,90 @@ +/* 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 test makes sure that the geolocation prompt does not show a remember +// control inside the private browsing mode. + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["dom.vr.always_support_vr", true]], + }); +}); + +add_task(async function test() { + function checkPrompt(aURL, aName, aPrivateMode, aWindow) { + return (async function () { + aWindow.gBrowser.selectedTab = BrowserTestUtils.addTab( + aWindow.gBrowser, + aURL + ); + await BrowserTestUtils.browserLoaded(aWindow.gBrowser.selectedBrowser); + + let notification = aWindow.PopupNotifications.getNotification(aName); + + // Wait until the notification is available. + while (!notification) { + await new Promise(resolve => { + executeSoon(resolve); + }); + notification = aWindow.PopupNotifications.getNotification(aName); + } + + if (aPrivateMode) { + // Make sure the notification is correctly displayed without a remember control + ok( + !notification.options.checkbox.show, + "Secondary actions should not exist (always/never remember)" + ); + } else { + ok( + notification.options.checkbox.show, + "Secondary actions should exist (always/never remember)" + ); + } + notification.remove(); + + aWindow.gBrowser.removeCurrentTab(); + })(); + } + + function checkPrivateBrowsingRememberPrompt(aURL, aName) { + return (async function () { + let win = await BrowserTestUtils.openNewBrowserWindow(); + let browser = win.gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, aURL); + await BrowserTestUtils.browserLoaded(browser); + + await checkPrompt(aURL, aName, false, win); + + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + let privateBrowser = privateWin.gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(privateBrowser, aURL); + await BrowserTestUtils.browserLoaded(privateBrowser); + + await checkPrompt(aURL, aName, true, privateWin); + + // Cleanup + await BrowserTestUtils.closeWindow(win); + await BrowserTestUtils.closeWindow(privateWin); + })(); + } + + const geoTestPageURL = + "https://example.com/browser/" + + "browser/components/privatebrowsing/test/browser/browser_privatebrowsing_geoprompt_page.html"; + + await checkPrivateBrowsingRememberPrompt(geoTestPageURL, "geolocation"); + + const vrEnabled = Services.prefs.getBoolPref("dom.vr.enabled"); + + if (vrEnabled) { + const xrTestPageURL = + "https://example.com/browser/" + + "browser/components/privatebrowsing/test/browser/browser_privatebrowsing_xrprompt_page.html"; + + await checkPrivateBrowsingRememberPrompt(xrTestPageURL, "xr"); + } +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_resetPBM.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_resetPBM.js new file mode 100644 index 0000000000..a80d818c87 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_resetPBM.js @@ -0,0 +1,824 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SessionStoreTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/SessionStoreTestUtils.sys.mjs" +); + +const PREF_ID_ALWAYS_ASK = + "browser.privatebrowsing.resetPBM.showConfirmationDialog"; + +const SELECTOR_TOOLBAR_BUTTON = "#reset-pbm-toolbar-button"; + +const SELECTOR_PANELVIEW = "panel #reset-pbm-panel"; +const SELECTOR_CONTAINER = "#reset-pbm-panel-container"; +const SELECTOR_PANEL_HEADING = "#reset-pbm-panel-header > description"; +const SELECTOR_PANEL_DESCRIPTION = "#reset-pbm-panel-description"; +const SELECTOR_PANEL_CHECKBOX = "#reset-pbm-panel-checkbox"; +const SELECTOR_PANEL_CONFIRM_BTN = "#reset-pbm-panel-confirm-button"; +const SELECTOR_PANEL_CANCEL_BTN = "#reset-pbm-panel-cancel-button"; + +const SELECTOR_PANEL_COMPLETION_TOAST = "#confirmation-hint"; + +/** + * Wait for the reset pbm confirmation panel to open. May also be called if the + * panel is already open. + * @param {ChromeWindow} win - Chrome window in which the panel is embedded. + * @returns {Promise} - Promise which resolves once the panel has been shown. + * Resolves directly if the panel is already visible. + */ +async function waitForConfirmPanelShow(win) { + // Check for the panel, if it's not present yet wait for it to be inserted. + let panelview = win.document.querySelector(SELECTOR_PANELVIEW); + if (!panelview) { + let navToolbox = win.document.getElementById("navigator-toolbox"); + await BrowserTestUtils.waitForMutationCondition( + navToolbox, + { childList: true, subtree: true }, + () => { + panelview = win.document.querySelector(SELECTOR_PANELVIEW); + return !!panelview; + } + ); + } + + // Panel already visible, we can exit early. + if (BrowserTestUtils.isVisible(panelview)) { + return; + } + + // Wait for panel shown event. + await BrowserTestUtils.waitForEvent(panelview.closest("panel"), "popupshown"); +} + +/** + * Hides the completion toast which is shown after the reset action has been + * completed. + * @param {ChromeWindow} win - Chrome window the toast is shown in. + */ +async function hideCompletionToast(win) { + let promiseHidden = BrowserTestUtils.waitForEvent( + win.ConfirmationHint._panel, + "popuphidden" + ); + + win.ConfirmationHint._panel.hidePopup(); + + await promiseHidden; +} + +/** + * Trigger the reset pbm toolbar button which may open the confirm panel in the + * given window. + * @param {nsIDOMWindow} win - PBM window to trigger the button in. + * @param {boolean} [expectPanelOpen] - After the button action: whether the + * panel is expected to open (true) or remain closed (false). + * @returns {Promise} - Promise which resolves once the button has been + * triggered and, if applicable, the panel has been opened. + */ +async function triggerResetBtn(win, expectPanelOpen = true) { + Assert.ok( + PrivateBrowsingUtils.isWindowPrivate(win), + "Window to open panel is in PBM." + ); + + let shownPromise; + if (expectPanelOpen) { + shownPromise = waitForConfirmPanelShow(win); + } + + await BrowserTestUtils.synthesizeMouseAtCenter( + SELECTOR_TOOLBAR_BUTTON, + {}, + win.browsingContext + ); + + // Promise may not be defined at this point in which case this is a no-op. + await shownPromise; +} + +/** + * Provides a promise that resolves once the reset confirmation panel has been hidden. + * @param nsIDOMWindow win - Chrome window that has the panel. + * @returns {Promise} + */ +function waitForConfirmPanelHidden(win) { + return BrowserTestUtils.waitForEvent( + win.document.querySelector(SELECTOR_PANELVIEW).closest("panel"), + "popuphidden" + ); +} + +/** + * Provides a promise that resolves once the completion toast has been shown. + * @param nsIDOMWindow win - Chrome window that has the panel. + * @returns {Promise} + */ +function waitForCompletionToastShown(win) { + // Init the confirmation hint panel so we can listen for show events. + win.ConfirmationHint._ensurePanel(); + return BrowserTestUtils.waitForEvent( + win.document.querySelector(SELECTOR_PANEL_COMPLETION_TOAST), + "popupshown" + ); +} + +/** + * Wait for private browsing data clearing to be triggered. + * Clearing is not guaranteed to be done at this point. Bug 1846494 will add a + * promise based mechanism and potentially a new triggering method for clearing, + * at which point this helper should be updated. + * @returns {Promise} Promise which resolves when the last-pb-context-exited + * message has been dispatched. + */ +function waitForPBMDataClear() { + return TestUtils.topicObserved("last-pb-context-exited"); +} + +/** + * Test panel visibility. + * @param {nsIDOMWindow} win - Chrome window which is the parent of the panel. + * @param {string} selector - Query selector for the panel. + * @param {boolean} expectVisible - Whether the panel should be visible (true) or invisible or not present (false). + */ +function assertPanelVisibility(win, selector, expectVisible) { + let panelview = win.document.querySelector(selector); + + if (expectVisible) { + Assert.ok(panelview, `Panelview element ${selector} should exist.`); + Assert.ok( + BrowserTestUtils.isVisible(panelview), + `Panelview ${selector} should be visible.` + ); + return; + } + + Assert.ok( + !panelview || !BrowserTestUtils.isVisible(panelview), + `Panelview ${selector} should be invisible or non-existent.` + ); +} + +function transformGleanEvents(events) { + if (!events) { + return []; + } + return events.map(e => ({ ...e.extra })); +} + +function assertTelemetry(expectedConfirmPanel, expectedResetAction, message) { + info(message); + + let confirmPanelEvents = transformGleanEvents( + Glean.privateBrowsingResetPbm.confirmPanel.testGetValue() + ); + Assert.deepEqual( + confirmPanelEvents, + expectedConfirmPanel, + "confirmPanel events should match." + ); + + let resetActionEvents = transformGleanEvents( + Glean.privateBrowsingResetPbm.resetAction.testGetValue() + ); + Assert.deepEqual( + resetActionEvents, + expectedResetAction, + "resetAction events should match." + ); +} + +/** + * Tests that the reset button is only visible in private browsing windows and + * when the feature is enabled. + */ +add_task(async function test_toolbar_button_visibility() { + assertTelemetry([], [], "No glean events initially."); + + for (let isEnabled of [false, true]) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.privatebrowsing.resetPBM.enabled", isEnabled]], + }); + + info( + "Test that the toolbar button is never visible in a normal browsing window." + ); + let toolbarButtonNormalBrowsing = document.querySelector( + SELECTOR_TOOLBAR_BUTTON + ); + Assert.equal( + !!toolbarButtonNormalBrowsing, + isEnabled, + "Normal browsing toolbar button element exists, depending on enabled pref state." + ); + if (toolbarButtonNormalBrowsing) { + Assert.ok( + !BrowserTestUtils.isVisible(toolbarButtonNormalBrowsing), + "Toolbar button is not visible in normal browsing" + ); + } + + info( + "Test that the toolbar button is visible in a private browsing window, depending on enabled pref state." + ); + let privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + let toolbarButtonPrivateBrowsing = privateWindow.document.querySelector( + SELECTOR_TOOLBAR_BUTTON + ); + Assert.equal( + !!toolbarButtonPrivateBrowsing, + isEnabled, + "Private browsing toolbar button element exists, depending on enabled pref state." + ); + if (toolbarButtonPrivateBrowsing) { + Assert.equal( + BrowserTestUtils.isVisible(toolbarButtonPrivateBrowsing), + isEnabled, + "Toolbar button is visible in private browsing if enabled." + ); + } + + await BrowserTestUtils.closeWindow(privateWindow); + + await SpecialPowers.popPrefEnv(); + } + + assertTelemetry([], [], "No glean events after test."); +}); + +/** + * Tests the panel UI, the 'always ask' checkbox and the confirm and cancel + * actions. + */ +add_task(async function test_panel() { + assertTelemetry([], [], "No glean events initially."); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.privatebrowsing.resetPBM.enabled", true]], + }); + + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + // Panel does either not exist (because it's lazy and hasn't been opened yet), + // or it is invisible. + assertPanelVisibility(privateWin, SELECTOR_PANELVIEW, false); + assertPanelVisibility(privateWin, SELECTOR_PANEL_COMPLETION_TOAST, false); + + info("Open the panel."); + await triggerResetBtn(privateWin); + + assertTelemetry( + [{ action: "show", reason: "toolbar-btn" }], + [], + "There should be a panel show event." + ); + + info("Check that all expected elements are present and visible."); + [ + SELECTOR_PANEL_HEADING, + SELECTOR_PANEL_DESCRIPTION, + SELECTOR_PANEL_CHECKBOX, + SELECTOR_PANEL_CONFIRM_BTN, + SELECTOR_PANEL_CANCEL_BTN, + ].forEach(elSelector => { + let el = privateWin.document.querySelector(elSelector); + Assert.ok(el, `Panel element ${elSelector} exists.`); + if (el) { + Assert.ok( + BrowserTestUtils.isVisible(el), + `Panel element ${elSelector} is visible.` + ); + } + }); + + info("Inspect checkbox and pref state."); + let checkbox = privateWin.document.querySelector(SELECTOR_PANEL_CHECKBOX); + Assert.ok( + checkbox.checked, + "The 'always ask' checkbox should be checked initially." + ); + Assert.ok( + Services.prefs.getBoolPref( + "browser.privatebrowsing.resetPBM.showConfirmationDialog" + ), + "The always ask pref should be true." + ); + + info("Accessibility checks"); + let panel = privateWin.document.querySelector(SELECTOR_PANELVIEW); + Assert.equal( + panel.getAttribute("role"), + "document", + "Panel should have role document." + ); + + let container = panel.querySelector(SELECTOR_CONTAINER); + Assert.equal( + container.getAttribute("role"), + "alertdialog", + "Panel container should have role alertdialog." + ); + Assert.equal( + container.getAttribute("aria-labelledby"), + "reset-pbm-panel-header", + "aria-labelledby should point to heading." + ); + + let heading = panel.querySelector(SELECTOR_PANEL_HEADING); + Assert.equal( + heading.getAttribute("role"), + "heading", + "Heading should have role heading." + ); + Assert.equal( + heading.getAttribute("aria-level"), + "2", + "heading should have aria-level 2" + ); + + info("Click the checkbox to uncheck it."); + await BrowserTestUtils.synthesizeMouseAtCenter( + SELECTOR_PANEL_CHECKBOX, + {}, + privateWin.browsingContext + ); + Assert.ok( + !checkbox.checked, + "The 'always ask' checkbox should no longer be checked." + ); + info( + "The pref shouldn't update after clicking the checkbox. It only updates on panel confirm." + ); + Assert.ok( + Services.prefs.getBoolPref( + "browser.privatebrowsing.resetPBM.showConfirmationDialog" + ), + "The always ask pref should still be true." + ); + + info("Close the panel via cancel."); + let promisePanelHidden = waitForConfirmPanelHidden(privateWin); + await BrowserTestUtils.synthesizeMouseAtCenter( + SELECTOR_PANEL_CANCEL_BTN, + {}, + privateWin.browsingContext + ); + await promisePanelHidden; + + assertTelemetry( + [ + { action: "show", reason: "toolbar-btn" }, + { action: "hide", reason: "cancel-btn" }, + ], + [], + "There should be a panel show and a hide event." + ); + + assertPanelVisibility(privateWin, SELECTOR_PANELVIEW, false); + assertPanelVisibility(privateWin, SELECTOR_PANEL_COMPLETION_TOAST, false); + + info("Reopen the panel."); + await triggerResetBtn(privateWin); + + assertTelemetry( + [ + { action: "show", reason: "toolbar-btn" }, + { action: "hide", reason: "cancel-btn" }, + { action: "show", reason: "toolbar-btn" }, + ], + [], + "Should have added a show event." + ); + + assertPanelVisibility(privateWin, SELECTOR_PANELVIEW, true); + assertPanelVisibility(privateWin, SELECTOR_PANEL_COMPLETION_TOAST, false); + Assert.ok( + checkbox.checked, + "The 'always ask' checkbox should be checked again." + ); + Assert.ok( + Services.prefs.getBoolPref(PREF_ID_ALWAYS_ASK), + "The always ask pref should be true." + ); + + info("Test the checkbox on confirm."); + await BrowserTestUtils.synthesizeMouseAtCenter( + SELECTOR_PANEL_CHECKBOX, + {}, + privateWin.browsingContext + ); + Assert.ok( + !checkbox.checked, + "The 'always ask' checkbox should no longer be checked." + ); + + info("Close the panel via confirm."); + let promiseDataCleared = waitForPBMDataClear(); + promisePanelHidden = waitForConfirmPanelHidden(privateWin); + let promiseCompletionToastShown = waitForCompletionToastShown(privateWin); + + await BrowserTestUtils.synthesizeMouseAtCenter( + SELECTOR_PANEL_CONFIRM_BTN, + {}, + privateWin.browsingContext + ); + await promisePanelHidden; + + assertTelemetry( + [ + { action: "show", reason: "toolbar-btn" }, + { action: "hide", reason: "cancel-btn" }, + { action: "show", reason: "toolbar-btn" }, + { action: "hide", reason: "confirm-btn" }, + ], + [{ did_confirm: "true" }], + "Should have added a hide and a reset event." + ); + await promiseCompletionToastShown; + assertPanelVisibility(privateWin, SELECTOR_PANELVIEW, false); + assertPanelVisibility(privateWin, SELECTOR_PANEL_COMPLETION_TOAST, true); + + await promiseDataCleared; + + Assert.ok( + !Services.prefs.getBoolPref(PREF_ID_ALWAYS_ASK), + "The always ask pref should now be false." + ); + + // Need to hide the completion toast, otherwise the next click on the toolbar + // button won't work. + info("Hide the completion toast."); + await hideCompletionToast(privateWin); + + info( + "Simulate a click on the toolbar button. This time the panel should not open - we have unchecked 'always ask'." + ); + promiseDataCleared = waitForPBMDataClear(); + promiseCompletionToastShown = waitForCompletionToastShown(privateWin); + + await triggerResetBtn(privateWin, false); + + info("Waiting for PBM session to end."); + await promiseDataCleared; + info("Data has been cleared."); + + assertPanelVisibility(privateWin, SELECTOR_PANELVIEW, false); + + info("Waiting for the completion toast to show."); + await promiseCompletionToastShown; + + assertPanelVisibility(privateWin, SELECTOR_PANELVIEW, false); + assertPanelVisibility(privateWin, SELECTOR_PANEL_COMPLETION_TOAST, true); + + assertTelemetry( + [ + { action: "show", reason: "toolbar-btn" }, + { action: "hide", reason: "cancel-btn" }, + { action: "show", reason: "toolbar-btn" }, + { action: "hide", reason: "confirm-btn" }, + ], + [{ did_confirm: "true" }, { did_confirm: "false" }], + "Should have added a reset event." + ); + + await BrowserTestUtils.closeWindow(privateWin); + Services.prefs.clearUserPref(PREF_ID_ALWAYS_ASK); + + // Clean up telemetry + Services.fog.testResetFOG(); +}); + +/** + * Tests that the reset action closes all other private browsing windows and + * tabs and triggers private browsing data clearing. + */ +add_task(async function test_reset_action() { + assertTelemetry([], [], "No glean events initially."); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.privatebrowsing.resetPBM.enabled", true]], + }); + + info("Open a few private browsing windows."); + let privateBrowsingWindows = []; + for (let i = 0; i < 3; i += 1) { + privateBrowsingWindows.push( + await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }) + ); + } + + info( + "Open an additional normal browsing window. It should remain open on reset PBM action." + ); + let additionalNormalWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: false, + }); + + info("Use one of the PBM windows to trigger the PBM restart action."); + let [win] = privateBrowsingWindows; + win.focus(); + + Assert.ok( + PrivateBrowsingUtils.isWindowPrivate(win), + "Window for PBM reset trigger is private window." + ); + + info("Load a bunch of tabs in the private window."); + let loadPromises = [ + "https://example.com", + "https://example.org", + "https://example.net", + ].map(async url => { + let tab = BrowserTestUtils.addTab(win.gBrowser, url); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + }); + await Promise.all(loadPromises); + + info("Switch to the last tab."); + win.gBrowser.selectedTab = win.gBrowser.tabs[win.gBrowser.tabs.length - 1]; + Assert.ok( + win.gBrowser.selectedBrowser.currentURI.spec != "about:privatebrowsing", + "The selected tab should not show about:privatebrowsing." + ); + + let windowClosePromises = [ + ...privateBrowsingWindows.filter(w => win != w), + ].map(w => BrowserTestUtils.windowClosed(w)); + + // Create promises for tab close for all tabs in the triggering private browsing window. + let promisesTabsClosed = win.gBrowser.tabs.map(tab => + BrowserTestUtils.waitForTabClosing(tab) + ); + + info("Trigger the restart PBM action"); + + let promiseDataClear = waitForPBMDataClear(); + await ResetPBMPanel._restartPBM(win); + + info( + "Wait for all the windows but the default normal window and the private window which triggered the reset action to be closed." + ); + await Promise.all(windowClosePromises); + + info("Wait for tabs in the trigger private window to close."); + await Promise.all(promisesTabsClosed); + + info("Wait for data to be cleared."); + await promiseDataClear; + + Assert.equal( + win.gBrowser.tabs.length, + 1, + "Should only have 1 tab remaining." + ); + + await BrowserTestUtils.waitForCondition( + () => + win.gBrowser.selectedBrowser.currentURI.spec == "about:privatebrowsing" + ); + Assert.equal( + win.gBrowser.selectedBrowser.currentURI.spec, + "about:privatebrowsing", + "The remaining tab should point to about:privatebrowsing." + ); + + // Close the private window that remained open. + await BrowserTestUtils.closeWindow(win); + await BrowserTestUtils.closeWindow(additionalNormalWindow); + + // We bypass telemetry by calling ResetPBMPanel._restartPBM directly. + assertTelemetry([], [], "No glean events after the test."); +}); + +/** + * Ensure that we don't show the tab close warning when closing multiple tabs + * with the reset PBM action. + */ +add_task(async function test_tab_close_warning_suppressed() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.privatebrowsing.resetPBM.enabled", true], + ["browser.tabs.warnOnClose", true], + ["browser.tabs.warnOnCloseOtherTabs", true], + ], + }); + + info("Open a private browsing window."); + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + info("Open enough tabs so the tab close warning would show."); + let loadPromises = []; + // warnAboutClosingTabs uses this number to determine whether to show the tab + // close warning. + let maxTabsUndo = Services.prefs.getIntPref( + "browser.sessionstore.max_tabs_undo" + ); + for (let i = 0; i < maxTabsUndo + 2; i++) { + let tab = BrowserTestUtils.addTab(win.gBrowser, "about:blank"); + loadPromises.push(BrowserTestUtils.browserLoaded(tab.linkedBrowser)); + } + await Promise.all(loadPromises); + + // Create promises for tab close for all tabs in the triggering private browsing window. + let promisesTabsClosed = win.gBrowser.tabs.map(tab => + BrowserTestUtils.waitForTabClosing(tab) + ); + + info("Trigger the restart PBM action"); + + let promiseDataClear = waitForPBMDataClear(); + await ResetPBMPanel._restartPBM(win); + + info("Wait for tabs in the trigger private window to close."); + await Promise.all(promisesTabsClosed); + + info("Wait for data to be cleared."); + await promiseDataClear; + + Assert.equal( + win.gBrowser.tabs.length, + 1, + "Should only have 1 tab remaining." + ); + + await BrowserTestUtils.waitForCondition( + () => + win.gBrowser.selectedBrowser.currentURI.spec == "about:privatebrowsing" + ); + Assert.equal( + win.gBrowser.selectedBrowser.currentURI.spec, + "about:privatebrowsing", + "The remaining tab should point to about:privatebrowsing." + ); + + // Close the private window that remained open. + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Test that if the browser sidebar is open the reset action closes it. + */ +add_task(async function test_reset_action_closes_sidebar() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.privatebrowsing.resetPBM.enabled", true]], + }); + + info("Open a private browsing window."); + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + info( + "Open the sidebar of both the private browsing window and the normal browsing window." + ); + await SidebarUI.show("viewBookmarksSidebar"); + await win.SidebarUI.show("viewBookmarksSidebar"); + + info("Trigger the restart PBM action"); + await ResetPBMPanel._restartPBM(win); + + Assert.ok( + SidebarUI.isOpen, + "Normal browsing window sidebar should still be open." + ); + Assert.ok( + !win.SidebarUI.isOpen, + "Private browsing sidebar should be closed." + ); + + // Cleanup: Close the sidebar of the normal browsing window. + SidebarUI.hide(); + + // Cleanup: Close the private window that remained open. + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Test that the session store history gets purged by the reset action. + */ +add_task(async function test_reset_action_purges_session_store() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.privatebrowsing.resetPBM.enabled", true]], + }); + + info("Open a private browsing window."); + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + Assert.equal( + SessionStore.getClosedTabCountForWindow(win), + 0, + "Initially there should be no closed tabs recorded for the PBM window in SessionStore." + ); + + info("Load a bunch of tabs in the private window."); + + let tab; + let loadPromises = [ + "https://example.com", + "https://example.org", + "https://example.net", + ].map(async url => { + tab = BrowserTestUtils.addTab(win.gBrowser, url); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + }); + await Promise.all(loadPromises); + + info("Manually close a tab"); + await SessionStoreTestUtils.closeTab(tab); + + Assert.equal( + SessionStore.getClosedTabCountForWindow(win), + 1, + "The manually closed tab should be recorded in SessionStore." + ); + + info("Trigger the restart PBM action"); + await ResetPBMPanel._restartPBM(win); + + Assert.equal( + SessionStore.getClosedTabCountForWindow(win), + 0, + "After triggering the PBM reset action there should be no closed tabs recorded for the PBM window in SessionStore." + ); + + // Cleanup: Close the private window that remained open. + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Test that the the reset action closes all tabs, including pinned and (multi-)selected + * tabs. + */ +add_task(async function test_reset_action_closes_pinned_and_selected_tabs() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.privatebrowsing.resetPBM.enabled", true]], + }); + + info("Open a private browsing window."); + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + info("Load a list of tabs."); + let loadPromises = [ + "https://example.com", + "https://example.org", + "https://example.net", + "about:blank", + ].map(async url => { + let tab = BrowserTestUtils.addTab(win.gBrowser, url); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + return tab; + }); + let tabs = await Promise.all(loadPromises); + + info("Pin a tab"); + win.gBrowser.pinTab(tabs[0]); + + info("Multi-select tabs."); + await BrowserTestUtils.switchTab(win.gBrowser, tabs[2]); + win.gBrowser.addToMultiSelectedTabs(tabs[3]); + + // Create promises for tab close for all tabs in the triggering private browsing window. + let promisesTabsClosed = win.gBrowser.tabs.map(tab => + BrowserTestUtils.waitForTabClosing(tab) + ); + + info("Trigger the restart PBM action"); + await ResetPBMPanel._restartPBM(win); + + info("Wait for all tabs to be closed."); + await promisesTabsClosed; + + Assert.equal( + win.gBrowser.tabs.length, + 1, + "Should only have 1 tab remaining." + ); + + await BrowserTestUtils.waitForCondition( + () => + win.gBrowser.selectedBrowser.currentURI.spec == "about:privatebrowsing" + ); + Assert.equal( + win.gBrowser.selectedBrowser.currentURI.spec, + "about:privatebrowsing", + "The remaining tab should point to about:privatebrowsing." + ); + + // Cleanup: Close the private window/ + 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 new file mode 100644 index 0000000000..58a333bfdb --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_sidebar.js @@ -0,0 +1,88 @@ +/* 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 test makes sure that Sidebars do not migrate across windows with +// different privacy states + +// See Bug 885054: https://bugzilla.mozilla.org/show_bug.cgi?id=885054 + +function test() { + waitForExplicitFinish(); + + // opens a sidebar + function openSidebar(win) { + return win.SidebarUI.show("viewBookmarksSidebar").then(() => win); + } + + let windowCache = []; + function cacheWindow(w) { + windowCache.push(w); + return w; + } + function closeCachedWindows() { + windowCache.forEach(w => w.close()); + } + + // Part 1: NON PRIVATE WINDOW -> PRIVATE WINDOW + openWindow(window, {}, 1) + .then(cacheWindow) + .then(openSidebar) + .then(win => openWindow(win, { private: true })) + .then(cacheWindow) + .then(function ({ document }) { + let sidebarBox = document.getElementById("sidebar-box"); + is( + sidebarBox.hidden, + true, + "Opening a private window from reg window does not open the sidebar" + ); + }) + .then(closeCachedWindows) + // Part 2: NON PRIVATE WINDOW -> NON PRIVATE WINDOW + .then(() => openWindow(window)) + .then(cacheWindow) + .then(openSidebar) + .then(win => openWindow(win)) + .then(cacheWindow) + .then(function ({ document }) { + let sidebarBox = document.getElementById("sidebar-box"); + is( + sidebarBox.hidden, + false, + "Opening a reg window from reg window does open the sidebar" + ); + }) + .then(closeCachedWindows) + // Part 3: PRIVATE WINDOW -> NON PRIVATE WINDOW + .then(() => openWindow(window, { private: true })) + .then(cacheWindow) + .then(openSidebar) + .then(win => openWindow(win)) + .then(cacheWindow) + .then(function ({ document }) { + let sidebarBox = document.getElementById("sidebar-box"); + is( + sidebarBox.hidden, + true, + "Opening a reg window from a private window does not open the sidebar" + ); + }) + .then(closeCachedWindows) + // Part 4: PRIVATE WINDOW -> PRIVATE WINDOW + .then(() => openWindow(window, { private: true })) + .then(cacheWindow) + .then(openSidebar) + .then(win => openWindow(win, { private: true })) + .then(cacheWindow) + .then(function ({ document }) { + let sidebarBox = document.getElementById("sidebar-box"); + is( + sidebarBox.hidden, + false, + "Opening a private window from private window does open the sidebar" + ); + }) + .then(closeCachedWindows) + .then(finish); +} diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_theming.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_theming.js new file mode 100644 index 0000000000..18b398e961 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_theming.js @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This test makes sure that privatebrowsingmode attribute of the window is correctly +// adjusted based on whether the window is a private window. + +var windowsToClose = []; +function testOnWindow(options, callback) { + var win = OpenBrowserWindow(options); + win.addEventListener( + "load", + function () { + windowsToClose.push(win); + executeSoon(() => callback(win)); + }, + { once: true } + ); +} + +registerCleanupFunction(function () { + windowsToClose.forEach(function (win) { + win.close(); + }); +}); + +function test() { + // initialization + waitForExplicitFinish(); + + ok( + !document.documentElement.hasAttribute("privatebrowsingmode"), + "privatebrowsingmode should not be present in normal mode" + ); + + // open a private window + testOnWindow({ private: true }, function (win) { + is( + win.document.documentElement.getAttribute("privatebrowsingmode"), + "temporary", + 'privatebrowsingmode should be "temporary" inside the private browsing mode' + ); + + finish(); + }); +} diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_ui.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_ui.js new file mode 100644 index 0000000000..ab74caeb5e --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_ui.js @@ -0,0 +1,102 @@ +/* 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 test makes sure that the gPrivateBrowsingUI object, the Private Browsing +// menu item and its XUL element work correctly. + +function test() { + // initialization + waitForExplicitFinish(); + SpecialPowers.pushPrefEnv({ + set: [["security.allow_eval_with_system_principal", true]], + }); + let windowsToClose = []; + let testURI = "about:blank"; + let pbMenuItem; + let cmd; + + function doTest(aIsPrivateMode, aWindow, aCallback) { + BrowserTestUtils.browserLoaded(aWindow.gBrowser.selectedBrowser).then( + function () { + ok(aWindow.gPrivateBrowsingUI, "The gPrivateBrowsingUI object exists"); + + pbMenuItem = aWindow.document.getElementById("menu_newPrivateWindow"); + ok(pbMenuItem, "The Private Browsing menu item exists"); + + cmd = aWindow.document.getElementById("Tools:PrivateBrowsing"); + isnot( + cmd, + null, + "XUL command object for the private browsing service exists" + ); + + is( + pbMenuItem.getAttribute("label"), + "New Private Window", + 'The Private Browsing menu item should read "New Private Window"' + ); + is( + PrivateBrowsingUtils.isWindowPrivate(aWindow), + aIsPrivateMode, + "PrivateBrowsingUtils should report the correct per-window private browsing status (privateBrowsing should be " + + aIsPrivateMode + + ")" + ); + + aCallback(); + } + ); + + BrowserTestUtils.startLoadingURIString( + aWindow.gBrowser.selectedBrowser, + testURI + ); + } + + function openPrivateBrowsingModeByUI(aWindow, aCallback) { + Services.obs.addObserver(function observer(aSubject, aTopic, aData) { + aSubject.addEventListener( + "load", + function () { + Services.obs.removeObserver(observer, "domwindowopened"); + windowsToClose.push(aSubject); + aCallback(aSubject); + }, + { once: true } + ); + }, "domwindowopened"); + + cmd = aWindow.document.getElementById("Tools:PrivateBrowsing"); + var func = new Function("", cmd.getAttribute("oncommand")); + func.call(cmd); + } + + function testOnWindow(aOptions, aCallback) { + whenNewWindowLoaded(aOptions, function (aWin) { + windowsToClose.push(aWin); + // execute should only be called when need, like when you are opening + // web pages on the test. If calling executeSoon() is not necesary, then + // call whenNewWindowLoaded() instead of testOnWindow() on your test. + executeSoon(() => aCallback(aWin)); + }); + } + + // this function is called after calling finish() on the test. + registerCleanupFunction(function () { + windowsToClose.forEach(function (aWin) { + aWin.close(); + }); + }); + + // test first when not on private mode + testOnWindow({}, function (aWin) { + doTest(false, aWin, function () { + // then test when on private mode, opening a new private window from the + // user interface. + openPrivateBrowsingModeByUI(aWin, function (aPrivateWin) { + doTest(true, aPrivateWin, finish); + }); + }); + }); +} diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_urlbarfocus.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_urlbarfocus.js new file mode 100644 index 0000000000..cc4c0585de --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_urlbarfocus.js @@ -0,0 +1,44 @@ +/* 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 test makes sure that the URL bar is focused when entering the private window. + +"use strict"; + +function checkUrlbarFocus(win) { + let urlbar = win.gURLBar; + is( + win.document.activeElement, + urlbar.inputField, + "URL Bar should be focused" + ); + is(urlbar.value, "", "URL Bar should be empty"); +} + +function openNewPrivateWindow() { + return new Promise(resolve => { + whenNewWindowLoaded({ private: true }, win => { + executeSoon(() => resolve(win)); + }); + }); +} + +add_task(async function () { + let win = await openNewPrivateWindow(); + checkUrlbarFocus(win); + win.close(); +}); + +add_task(async function () { + AboutNewTab.newTabURL = "about:blank"; + registerCleanupFunction(() => { + AboutNewTab.resetNewTabURL(); + }); + + let win = await openNewPrivateWindow(); + checkUrlbarFocus(win); + win.close(); + + AboutNewTab.resetNewTabURL(); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_windowtitle.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_windowtitle.js new file mode 100644 index 0000000000..de99b19a44 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_windowtitle.js @@ -0,0 +1,140 @@ +/* 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 test makes sure that the window title changes correctly while switching +// from and to private browsing mode. + +"use strict"; + +add_task(async function test() { + const testPageURL = + "http://mochi.test:8888/browser/" + + "browser/components/privatebrowsing/test/browser/browser_privatebrowsing_windowtitle_page.html"; + requestLongerTimeout(2); + + // initialization of expected titles + let test_title = "Test title"; + let app_name = document.title; + + // XXX: Bug 1597849 - Dehardcode titles by fetching them from Fluent + // to compare with the actual values. + const isMacOS = AppConstants.platform == "macosx"; + + let pb_postfix = isMacOS ? ` — Private Browsing` : ` Private Browsing`; + let page_with_title = isMacOS ? test_title : `${test_title} — ${app_name}`; + let page_without_title = app_name; + let about_pb_title = app_name; + let pb_page_with_title = isMacOS + ? `${test_title}${pb_postfix}` + : `${test_title} — ${app_name}${pb_postfix}`; + let pb_page_without_title = `${app_name}${pb_postfix}`; + let pb_about_pb_title = `${app_name}${pb_postfix}`; + + async function testTabTitle(aWindow, url, insidePB, expected_title) { + let tab = await BrowserTestUtils.openNewForegroundTab(aWindow.gBrowser); + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, url); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + await BrowserTestUtils.waitForCondition(() => { + return aWindow.document.title === expected_title; + }, `Window title should be ${expected_title}, got ${aWindow.document.title}`); + + is( + aWindow.document.title, + expected_title, + "The window title for " + + url + + " is correct (" + + (insidePB ? "inside" : "outside") + + " private browsing mode)" + ); + + let win = aWindow.gBrowser.replaceTabWithWindow(tab); + await BrowserTestUtils.waitForEvent(win, "load", false); + + await BrowserTestUtils.waitForCondition(() => { + return win.document.title === expected_title; + }, `Window title should be ${expected_title}, got ${win.document.title}`); + + is( + win.document.title, + expected_title, + "The window title for " + + url + + " detached tab is correct (" + + (insidePB ? "inside" : "outside") + + " private browsing mode)" + ); + + await Promise.all([ + BrowserTestUtils.closeWindow(win), + BrowserTestUtils.closeWindow(aWindow), + ]); + } + + function openWin(isPrivate) { + return BrowserTestUtils.openNewBrowserWindow({ private: isPrivate }); + } + await testTabTitle( + await openWin(false), + "about:blank", + false, + page_without_title + ); + await testTabTitle(await openWin(false), testPageURL, false, page_with_title); + await testTabTitle( + await openWin(false), + "about:privatebrowsing", + false, + about_pb_title + ); + await testTabTitle( + await openWin(true), + "about:blank", + true, + pb_page_without_title + ); + await testTabTitle( + await openWin(true), + testPageURL, + true, + pb_page_with_title + ); + await testTabTitle( + await openWin(true), + "about:privatebrowsing", + true, + pb_about_pb_title + ); + + await SpecialPowers.pushPrefEnv({ + set: [["privacy.exposeContentTitleInWindow.pbm", false]], + }); + await testTabTitle(await openWin(false), testPageURL, false, page_with_title); + await testTabTitle( + await openWin(true), + testPageURL, + true, + pb_page_without_title + ); + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.exposeContentTitleInWindow", false], + ["privacy.exposeContentTitleInWindow.pbm", true], + ], + }); + await testTabTitle( + await openWin(false), + testPageURL, + false, + page_without_title + ); + // The generic preference set to false is intended to override the PBM one + await testTabTitle( + await openWin(true), + testPageURL, + true, + pb_page_without_title + ); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_windowtitle_page.html b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_windowtitle_page.html new file mode 100644 index 0000000000..760bde7d14 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_windowtitle_page.html @@ -0,0 +1,9 @@ + + + + Test title + + + Test page for the window title test + + diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_xrprompt_page.html b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_xrprompt_page.html new file mode 100644 index 0000000000..4330785df2 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_xrprompt_page.html @@ -0,0 +1,11 @@ + + + + XR invoker + + + + + diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_zoom.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_zoom.js new file mode 100644 index 0000000000..048796e7d2 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_zoom.js @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This test makes sure that private browsing turns off doesn't cause zoom +// settings to be reset on tab switch (bug 464962) + +add_task(async function test() { + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + let tabAbout = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "about:mozilla" + ); + let tabMozilla = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "about:mozilla" + ); + + let mozillaZoom = win.ZoomManager.zoom; + + // change the zoom on the mozilla page + win.FullZoom.enlarge(); + // make sure the zoom level has been changed + isnot(win.ZoomManager.zoom, mozillaZoom, "Zoom level can be changed"); + mozillaZoom = win.ZoomManager.zoom; + + // switch to about: tab + await BrowserTestUtils.switchTab(win.gBrowser, tabAbout); + + // switch back to mozilla tab + await BrowserTestUtils.switchTab(win.gBrowser, tabMozilla); + + // make sure the zoom level has not changed + is( + win.ZoomManager.zoom, + mozillaZoom, + "Entering private browsing should not reset the zoom on a tab" + ); + + // cleanup + win.FullZoom.reset(); + BrowserTestUtils.removeTab(tabMozilla); + BrowserTestUtils.removeTab(tabAbout); + + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_zoomrestore.js b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_zoomrestore.js new file mode 100644 index 0000000000..dd0e2e1b64 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_zoomrestore.js @@ -0,0 +1,80 @@ +/* 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 test makes sure that about:privatebrowsing does not appear zoomed in +// if there is already a zoom site pref for about:blank (bug 487656). + +add_task(async function test() { + // initialization + let windowsToClose = []; + let windowsToReset = []; + + function promiseLocationChange() { + return new Promise(resolve => { + Services.obs.addObserver(function onLocationChange(subj, topic, data) { + Services.obs.removeObserver(onLocationChange, topic); + resolve(); + }, "browser-fullZoom:location-change"); + }); + } + + async function promiseTestReady(aIsZoomedWindow, aWindow) { + // Need to wait on two things, the ordering of which is not guaranteed: + // (1) the page load, and (2) FullZoom's update to the new page's zoom + // level. FullZoom broadcasts "browser-fullZoom:location-change" when its + // update is done. (See bug 856366 for details.) + + let browser = aWindow.gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, "about:blank"); + await Promise.all([ + BrowserTestUtils.browserLoaded(browser), + promiseLocationChange(), + ]); + doTest(aIsZoomedWindow, aWindow); + } + + function doTest(aIsZoomedWindow, aWindow) { + if (aIsZoomedWindow) { + is( + aWindow.ZoomManager.zoom, + 1, + "Zoom level for freshly loaded about:blank should be 1" + ); + // change the zoom on the blank page + aWindow.FullZoom.enlarge(); + isnot( + aWindow.ZoomManager.zoom, + 1, + "Zoom level for about:blank should be changed" + ); + return; + } + + // make sure the zoom level is set to 1 + is( + aWindow.ZoomManager.zoom, + 1, + "Zoom level for about:privatebrowsing should be reset" + ); + } + + function testOnWindow(options, callback) { + return BrowserTestUtils.openNewBrowserWindow(options).then(win => { + windowsToClose.push(win); + windowsToReset.push(win); + return win; + }); + } + + await testOnWindow({}).then(win => promiseTestReady(true, win)); + await testOnWindow({ private: true }).then(win => + promiseTestReady(false, win) + ); + + // cleanup + windowsToReset.forEach(win => win.FullZoom.reset()); + await Promise.all( + windowsToClose.map(win => BrowserTestUtils.closeWindow(win)) + ); +}); diff --git a/browser/components/privatebrowsing/test/browser/empty_file.html b/browser/components/privatebrowsing/test/browser/empty_file.html new file mode 100644 index 0000000000..0dc101b533 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/empty_file.html @@ -0,0 +1 @@ + diff --git a/browser/components/privatebrowsing/test/browser/file_favicon.html b/browser/components/privatebrowsing/test/browser/file_favicon.html new file mode 100644 index 0000000000..f294b47758 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/file_favicon.html @@ -0,0 +1,11 @@ + + + + + Favicon Test for originAttributes + + + + Favicon!! + + diff --git a/browser/components/privatebrowsing/test/browser/file_favicon.png b/browser/components/privatebrowsing/test/browser/file_favicon.png new file mode 100644 index 0000000000..5535363c94 Binary files /dev/null and b/browser/components/privatebrowsing/test/browser/file_favicon.png differ diff --git a/browser/components/privatebrowsing/test/browser/file_favicon.png^headers^ b/browser/components/privatebrowsing/test/browser/file_favicon.png^headers^ new file mode 100644 index 0000000000..9e23c73b7f --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/file_favicon.png^headers^ @@ -0,0 +1 @@ +Cache-Control: no-cache diff --git a/browser/components/privatebrowsing/test/browser/file_triggeringprincipal_oa.html b/browser/components/privatebrowsing/test/browser/file_triggeringprincipal_oa.html new file mode 100644 index 0000000000..cd05e833f3 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/file_triggeringprincipal_oa.html @@ -0,0 +1,10 @@ + + + + + Test for Bug 1348801 + + + checkPrincipalOA + + diff --git a/browser/components/privatebrowsing/test/browser/head.js b/browser/components/privatebrowsing/test/browser/head.js new file mode 100644 index 0000000000..f880c10a91 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/head.js @@ -0,0 +1,163 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +ChromeUtils.defineESModuleGetters(this, { + ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs", + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + PanelTestProvider: "resource:///modules/asrouter/PanelTestProvider.sys.mjs", + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +function whenNewWindowLoaded(aOptions, aCallback) { + let win = OpenBrowserWindow(aOptions); + let focused = SimpleTest.promiseFocus(win); + let startupFinished = TestUtils.topicObserved( + "browser-delayed-startup-finished", + subject => subject == win + ).then(() => win); + Promise.all([focused, startupFinished]).then(results => + executeSoon(() => aCallback(results[1])) + ); + + return win; +} + +function openWindow(aParent, aOptions) { + return BrowserWindowTracker.promiseOpenWindow({ + openerWindow: aParent, + ...aOptions, + }); +} + +/** + * Opens a new private window and loads "about:privatebrowsing" there. + */ +async function openAboutPrivateBrowsing() { + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + waitForTabURL: "about:privatebrowsing", + }); + let tab = win.gBrowser.selectedBrowser; + return { win, tab }; +} + +/** + * Wrapper for openAboutPrivateBrowsing that returns after render is complete + */ +async function openTabAndWaitForRender() { + let { win, tab } = await openAboutPrivateBrowsing(); + await SpecialPowers.spawn(tab, [], async function () { + // Wait for render to complete + await ContentTaskUtils.waitForCondition(() => + content.document.documentElement.hasAttribute( + "PrivateBrowsingRenderComplete" + ) + ); + }); + return { win, tab }; +} + +function newDirectory() { + let dir = FileUtils.getDir("TmpD", ["testdir"]); + dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + return dir; +} + +function newFileInDirectory(aDir) { + let file = aDir.clone(); + file.append("testfile"); + file.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_FILE); + return file; +} + +function clearHistory() { + // simulate clearing the private data + Services.obs.notifyObservers(null, "browser:purge-session-history"); +} + +function _initTest() { + // Don't use about:home as the homepage for new windows + Services.prefs.setIntPref("browser.startup.page", 0); + registerCleanupFunction(() => + Services.prefs.clearUserPref("browser.startup.page") + ); +} + +function waitForTelemetryEvent(category, value) { + info("waiting for telemetry event"); + return TestUtils.waitForCondition(() => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).content; + + if (!events) { + return null; + } + events = events.filter(e => e[1] == category); + info(JSON.stringify(events)); + + // Check for experimentId passed as value + // if exists return events only for specific experimentId + if (value) { + events = events.filter(e => e[4].includes(value)); + } + if (events.length) { + return events[0]; + } + return null; + }, "wait and retrieve telemetry event"); +} + +async function setupMSExperimentWithMessage(message) { + Services.telemetry.clearEvents(); + Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ); + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "pbNewtab", + value: message, + }); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.asrouter.providers.messaging-experiments", + '{"id":"messaging-experiments","enabled":true,"type":"remote-experiments","updateCycleInMs":0}', + ], + ], + }); + // Reload the provider + await ASRouter._updateMessageProviders(); + // Wait to load the messages from the messaging-experiments provider + await ASRouter.loadMessagesFromAllProviders(); + + // XXX this only runs at the end of the file, so some of this stuff (eg unblockAll) should be run + // at the bottom of various test functions too. Quite possibly other stuff beside unblockAll too. + registerCleanupFunction(async () => { + // Clear telemetry side effects + Services.telemetry.clearEvents(); + // Make sure the side-effects from dismisses are cleared. + ASRouter.unblockAll(); + // put the disabled providers back + SpecialPowers.popPrefEnv(); + // Reload the provider again at cleanup to remove the experiment message + await ASRouter._updateMessageProviders(); + // Wait to load the messages from the messaging-experiments provider + await ASRouter.loadMessagesFromAllProviders(); + }); + + Assert.ok( + ASRouter.state.messages.find(m => m.id.includes(message.id)), + "Experiment message found in ASRouter state" + ); + + return doExperimentCleanup; +} + +_initTest(); diff --git a/browser/components/privatebrowsing/test/browser/title.sjs b/browser/components/privatebrowsing/test/browser/title.sjs new file mode 100644 index 0000000000..817f124888 --- /dev/null +++ b/browser/components/privatebrowsing/test/browser/title.sjs @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This provides the tests with a page with different titles based on whether +// a cookie is present or not. + +function handleRequest(request, response) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + + var cookie = "name=value"; + var title = "No Cookie"; + if (request.hasHeader("Cookie") && request.getHeader("Cookie") == cookie) { + title = "Cookie"; + } else { + response.setHeader("Set-Cookie", cookie, false); + } + + response.write(""); + response.write(title); + response.write("test page"); +} diff --git a/browser/components/prompts/PromptCollection.sys.mjs b/browser/components/prompts/PromptCollection.sys.mjs new file mode 100644 index 0000000000..b5cb57fe18 --- /dev/null +++ b/browser/components/prompts/PromptCollection.sys.mjs @@ -0,0 +1,193 @@ +/* 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/. */ + +/** + * Implements nsIPromptCollection + * + * @class PromptCollection + */ +export class PromptCollection { + confirmRepost(browsingContext) { + let brandName; + try { + brandName = this.stringBundles.brand.GetStringFromName("brandShortName"); + } catch (exception) { + // That's ok, we'll use a generic version of the prompt + } + + let message; + let resendLabel; + try { + if (brandName) { + message = this.stringBundles.app.formatStringFromName( + "confirmRepostPrompt", + [brandName] + ); + } else { + // Use a generic version of this prompt. + message = this.stringBundles.app.GetStringFromName( + "confirmRepostPrompt" + ); + } + resendLabel = + this.stringBundles.app.GetStringFromName("resendButton.label"); + } catch (exception) { + console.error("Failed to get strings from appstrings.properties"); + return false; + } + + let docViewer = browsingContext?.docShell?.docViewer; + let modalType = docViewer?.isTabModalPromptAllowed + ? Ci.nsIPromptService.MODAL_TYPE_CONTENT + : Ci.nsIPromptService.MODAL_TYPE_WINDOW; + let buttonFlags = + (Ci.nsIPromptService.BUTTON_TITLE_IS_STRING * + Ci.nsIPromptService.BUTTON_POS_0) | + (Ci.nsIPromptService.BUTTON_TITLE_CANCEL * + Ci.nsIPromptService.BUTTON_POS_1); + let buttonPressed = Services.prompt.confirmExBC( + browsingContext, + modalType, + null, + message, + buttonFlags, + resendLabel, + null, + null, + null, + {} + ); + + return buttonPressed === 0; + } + + async asyncBeforeUnloadCheck(browsingContext) { + let title; + let message; + let leaveLabel; + let stayLabel; + + try { + title = this.stringBundles.dom.GetStringFromName("OnBeforeUnloadTitle"); + message = this.stringBundles.dom.GetStringFromName( + "OnBeforeUnloadMessage2" + ); + leaveLabel = this.stringBundles.dom.GetStringFromName( + "OnBeforeUnloadLeaveButton" + ); + stayLabel = this.stringBundles.dom.GetStringFromName( + "OnBeforeUnloadStayButton" + ); + } catch (exception) { + console.error("Failed to get strings from dom.properties"); + return false; + } + + let docViewer = browsingContext?.docShell?.docViewer; + + if ( + (docViewer && !docViewer.isTabModalPromptAllowed) || + !browsingContext.ancestorsAreCurrent + ) { + console.error("Can't prompt from inactive content viewer"); + return true; + } + + let buttonFlags = + Ci.nsIPromptService.BUTTON_POS_0_DEFAULT | + (Ci.nsIPromptService.BUTTON_TITLE_IS_STRING * + Ci.nsIPromptService.BUTTON_POS_0) | + (Ci.nsIPromptService.BUTTON_TITLE_IS_STRING * + Ci.nsIPromptService.BUTTON_POS_1); + + let result = await Services.prompt.asyncConfirmEx( + browsingContext, + Services.prompt.MODAL_TYPE_CONTENT, + title, + message, + buttonFlags, + leaveLabel, + stayLabel, + null, + null, + false, + // Tell the prompt service that this is a permit unload prompt + // so that it can set the appropriate flag on the detail object + // of the events it dispatches. + { inPermitUnload: true } + ); + + return ( + result.QueryInterface(Ci.nsIPropertyBag2).get("buttonNumClicked") == 0 + ); + } + + confirmFolderUpload(browsingContext, directoryName) { + let title; + let message; + let acceptLabel; + + try { + title = this.stringBundles.dom.GetStringFromName( + "FolderUploadPrompt.title" + ); + message = this.stringBundles.dom.formatStringFromName( + "FolderUploadPrompt.message", + [directoryName] + ); + acceptLabel = this.stringBundles.dom.GetStringFromName( + "FolderUploadPrompt.acceptButtonLabel" + ); + } catch (exception) { + console.error("Failed to get strings from dom.properties"); + return false; + } + + let buttonFlags = + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 + + Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1 + + Services.prompt.BUTTON_POS_1_DEFAULT; + + return ( + Services.prompt.confirmExBC( + browsingContext, + Services.prompt.MODAL_TYPE_TAB, + title, + message, + buttonFlags, + acceptLabel, + null, + null, + null, + {} + ) === 0 + ); + } +} + +const BUNDLES = { + dom: "chrome://global/locale/dom/dom.properties", + app: "chrome://global/locale/appstrings.properties", + brand: "chrome://branding/locale/brand.properties", +}; + +PromptCollection.prototype.stringBundles = {}; + +for (const [bundleName, bundleUrl] of Object.entries(BUNDLES)) { + ChromeUtils.defineLazyGetter( + PromptCollection.prototype.stringBundles, + bundleName, + function () { + let bundle = Services.strings.createBundle(bundleUrl); + if (!bundle) { + throw new Error("String bundle for dom not present!"); + } + return bundle; + } + ); +} + +PromptCollection.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIPromptCollection", +]); diff --git a/browser/components/prompts/components.conf b/browser/components/prompts/components.conf new file mode 100644 index 0000000000..c17f8ed2b3 --- /dev/null +++ b/browser/components/prompts/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': '{7913837c-9623-11ea-bb37-0242ac130002}', + 'contract_ids': ['@mozilla.org/embedcomp/prompt-collection;1'], + 'esModule': 'resource:///modules/PromptCollection.sys.mjs', + 'constructor': 'PromptCollection', + }, +] diff --git a/browser/components/prompts/moz.build b/browser/components/prompts/moz.build new file mode 100644 index 0000000000..98d52d5397 --- /dev/null +++ b/browser/components/prompts/moz.build @@ -0,0 +1,14 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +with Files("**"): + BUG_COMPONENT = ("Toolkit", "Content Prompts") + +EXTRA_JS_MODULES += [ + "PromptCollection.sys.mjs", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] diff --git a/browser/components/protections/content/lockwise-card.mjs b/browser/components/protections/content/lockwise-card.mjs new file mode 100644 index 0000000000..cc25d4351b --- /dev/null +++ b/browser/components/protections/content/lockwise-card.mjs @@ -0,0 +1,142 @@ +/* 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/remote-page */ + +const HOW_IT_WORKS_URL_PREF = RPMGetFormatURLPref( + "browser.contentblocking.report.lockwise.how_it_works.url" +); + +export default class LockwiseCard { + constructor(doc) { + this.doc = doc; + } + + /** + * Initializes message listeners/senders. + */ + init() { + const savePasswordsButton = this.doc.getElementById( + "save-passwords-button" + ); + savePasswordsButton.addEventListener( + "click", + this.openAboutLogins.bind(this) + ); + + const managePasswordsButton = this.doc.getElementById( + "manage-passwords-button" + ); + managePasswordsButton.addEventListener( + "click", + this.openAboutLogins.bind(this) + ); + + // Attack link to Firefox Lockwise "How it works" page. + const lockwiseReportLink = this.doc.getElementById("lockwise-how-it-works"); + lockwiseReportLink.addEventListener("click", () => { + this.doc.sendTelemetryEvent("click", "lw_about_link"); + }); + } + + openAboutLogins() { + const lockwiseCard = this.doc.querySelector(".lockwise-card"); + if (lockwiseCard.classList.contains("has-logins")) { + if (lockwiseCard.classList.contains("breached-logins")) { + this.doc.sendTelemetryEvent( + "click", + "lw_open_button", + "manage_breached_passwords" + ); + } else if (lockwiseCard.classList.contains("no-breached-logins")) { + this.doc.sendTelemetryEvent( + "click", + "lw_open_button", + "manage_passwords" + ); + } + } else if (lockwiseCard.classList.contains("no-logins")) { + this.doc.sendTelemetryEvent("click", "lw_open_button", "save_passwords"); + } + RPMSendAsyncMessage("OpenAboutLogins"); + } + + buildContent(data) { + const { numLogins, potentiallyBreachedLogins } = data; + const hasLogins = numLogins > 0; + const title = this.doc.getElementById("lockwise-title"); + const headerContent = this.doc.querySelector( + "#lockwise-header-content span" + ); + const lockwiseCard = this.doc.querySelector(".card.lockwise-card"); + + if (hasLogins) { + lockwiseCard.classList.remove("no-logins"); + lockwiseCard.classList.add("has-logins"); + document.l10n.setAttributes(title, "passwords-title-logged-in"); + document.l10n.setAttributes( + headerContent, + "lockwise-header-content-logged-in" + ); + this.renderContentForLoggedInUser(numLogins, potentiallyBreachedLogins); + } else { + lockwiseCard.classList.remove("has-logins"); + lockwiseCard.classList.add("no-logins"); + document.l10n.setAttributes(title, "lockwise-title"); + document.l10n.setAttributes(headerContent, "passwords-header-content"); + } + + const lockwiseUI = document.querySelector(".card.lockwise-card.loading"); + lockwiseUI.classList.remove("loading"); + } + + /** + * Displays strings indicating stored logins for a user. + * + * @param {number} storedLogins + * The number of browser-stored logins. + * @param {number} potentiallyBreachedLogins + * The number of potentially breached logins. + */ + renderContentForLoggedInUser(storedLogins, potentiallyBreachedLogins) { + const lockwiseScannedText = this.doc.getElementById( + "lockwise-scanned-text" + ); + const lockwiseScannedIcon = this.doc.getElementById( + "lockwise-scanned-icon" + ); + const lockwiseCard = this.doc.querySelector(".card.lockwise-card"); + + if (potentiallyBreachedLogins) { + document.l10n.setAttributes( + lockwiseScannedText, + "lockwise-scanned-text-breached-logins", + { + count: potentiallyBreachedLogins, + } + ); + lockwiseScannedIcon.setAttribute( + "src", + "chrome://browser/skin/protections/breached-password.svg" + ); + lockwiseCard.classList.add("breached-logins"); + } else { + document.l10n.setAttributes( + lockwiseScannedText, + "lockwise-scanned-text-no-breached-logins", + { + count: storedLogins, + } + ); + lockwiseScannedIcon.setAttribute( + "src", + "chrome://browser/skin/protections/resolved-breach.svg" + ); + lockwiseCard.classList.add("no-breached-logins"); + } + + const howItWorksLink = this.doc.getElementById("lockwise-how-it-works"); + howItWorksLink.href = HOW_IT_WORKS_URL_PREF; + } +} diff --git a/browser/components/protections/content/monitor-card.mjs b/browser/components/protections/content/monitor-card.mjs new file mode 100644 index 0000000000..ef693a761e --- /dev/null +++ b/browser/components/protections/content/monitor-card.mjs @@ -0,0 +1,449 @@ +/* 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/remote-page */ + +const MONITOR_URL = RPMGetStringPref( + "browser.contentblocking.report.monitor.url", + "" +); +const MONITOR_SIGN_IN_URL = RPMGetStringPref( + "browser.contentblocking.report.monitor.sign_in_url", + "" +); +const HOW_IT_WORKS_URL_PREF = RPMGetFormatURLPref( + "browser.contentblocking.report.monitor.how_it_works.url" +); +const MONITOR_PREFERENCES_URL = RPMGetFormatURLPref( + "browser.contentblocking.report.monitor.preferences_url" +); +const MONITOR_HOME_PAGE_URL = RPMGetFormatURLPref( + "browser.contentblocking.report.monitor.home_page_url" +); + +export default class MonitorClass { + constructor(doc) { + this.doc = doc; + } + + init() { + // Wait for monitor data and display the card. + this.getMonitorData(); + + let monitorAboutLink = this.doc.getElementById("monitor-link"); + monitorAboutLink.addEventListener("click", () => { + this.doc.sendTelemetryEvent("click", "mtr_about_link"); + }); + + const storedEmailLink = this.doc.getElementById( + "monitor-stored-emails-link" + ); + storedEmailLink.href = MONITOR_PREFERENCES_URL; + storedEmailLink.addEventListener( + "click", + this.onClickMonitorButton.bind(this) + ); + + const knownBreachesLink = this.doc.getElementById( + "monitor-known-breaches-link" + ); + knownBreachesLink.href = MONITOR_HOME_PAGE_URL; + knownBreachesLink.addEventListener( + "click", + this.onClickMonitorButton.bind(this) + ); + + const exposedPasswordsLink = this.doc.getElementById( + "monitor-exposed-passwords-link" + ); + exposedPasswordsLink.href = MONITOR_HOME_PAGE_URL; + exposedPasswordsLink.addEventListener( + "click", + this.onClickMonitorButton.bind(this) + ); + } + + onClickMonitorButton(evt) { + RPMSendAsyncMessage("ClearMonitorCache"); + switch (evt.currentTarget.id) { + case "monitor-partial-breaches-link": + this.doc.sendTelemetryEvent( + "click", + "mtr_report_link", + "resolve_breaches" + ); + break; + case "monitor-breaches-link": + if (evt.currentTarget.classList.contains("no-breaches-resolved")) { + this.doc.sendTelemetryEvent( + "click", + "mtr_report_link", + "manage_breaches" + ); + } else { + this.doc.sendTelemetryEvent( + "click", + "mtr_report_link", + "view_report" + ); + } + break; + case "monitor-stored-emails-link": + this.doc.sendTelemetryEvent( + "click", + "mtr_report_link", + "stored_emails" + ); + break; + case "monitor-known-breaches-link": + const knownBreaches = this.doc.querySelector( + "span[data-type='known-breaches']" + ); + if (knownBreaches.classList.contains("known-resolved-breaches")) { + this.doc.sendTelemetryEvent( + "click", + "mtr_report_link", + "known_resolved_breaches" + ); + } else if ( + knownBreaches.classList.contains("known-unresolved-breaches") + ) { + this.doc.sendTelemetryEvent( + "click", + "mtr_report_link", + "known_unresolved_breaches" + ); + } + break; + case "monitor-exposed-passwords-link": + const exposedPasswords = this.doc.querySelector( + "span[data-type='exposed-passwords']" + ); + if ( + exposedPasswords.classList.contains("passwords-exposed-all-breaches") + ) { + this.doc.sendTelemetryEvent( + "click", + "mtr_report_link", + "exposed_passwords_all_breaches" + ); + } else if ( + exposedPasswords.classList.contains( + "passwords-exposed-unresolved-breaches" + ) + ) { + this.doc.sendTelemetryEvent( + "click", + "mtr_report_link", + "exposed_passwords_unresolved_breaches" + ); + } + break; + } + } + + /** + * Retrieves the monitor data and displays this data in the card. + */ + getMonitorData() { + RPMSendQuery("FetchMonitorData", {}).then(monitorData => { + // Once data for the user is retrieved, display the monitor card. + this.buildContent(monitorData); + + // Show the Monitor card. + const monitorUI = this.doc.querySelector(".card.monitor-card.loading"); + monitorUI.classList.remove("loading"); + }); + } + + buildContent(monitorData) { + const headerContent = this.doc.querySelector( + "#monitor-header-content span" + ); + const monitorCard = this.doc.querySelector(".card.monitor-card"); + if (!monitorData.error) { + monitorCard.classList.add("has-logins"); + this.doc.l10n.setAttributes( + headerContent, + "monitor-header-content-signed-in" + ); + this.renderContentForUserWithAccount(monitorData); + } else { + monitorCard.classList.add("no-logins"); + const signUpForMonitorLink = this.doc.getElementById( + "sign-up-for-monitor-link" + ); + signUpForMonitorLink.href = this.buildMonitorUrl(monitorData.userEmail); + this.doc.l10n.setAttributes(signUpForMonitorLink, "monitor-sign-up-link"); + this.doc.l10n.setAttributes( + headerContent, + "monitor-header-content-no-account" + ); + signUpForMonitorLink.addEventListener("click", () => { + this.doc.sendTelemetryEvent("click", "mtr_signup_button"); + }); + } + } + + /** + * Builds the appropriate URL that takes the user to the Monitor website's + * sign-up/sign-in page. + * + * @param {string | null} email + * Optional. The email used to direct the user to the Monitor website's OAuth + * sign-in flow. If null, then direct user to just the Monitor website. + * + * @returns {string} URL to Monitor website. + */ + buildMonitorUrl(email = null) { + return email + ? `${MONITOR_SIGN_IN_URL}${encodeURIComponent(email)}` + : MONITOR_URL; + } + + renderContentForUserWithAccount(monitorData) { + const { + numBreaches, + numBreachesResolved, + passwords, + passwordsResolved, + monitoredEmails, + } = monitorData; + const monitorCardBody = this.doc.querySelector( + ".card.monitor-card .card-body" + ); + monitorCardBody.classList.remove("hidden"); + + const howItWorksLink = this.doc.getElementById("monitor-link"); + howItWorksLink.href = HOW_IT_WORKS_URL_PREF; + + const storedEmail = this.doc.querySelector( + "span[data-type='stored-emails']" + ); + storedEmail.textContent = monitoredEmails; + const infoMonitoredAddresses = this.doc.getElementById( + "info-monitored-addresses" + ); + this.doc.l10n.setAttributes( + infoMonitoredAddresses, + "info-monitored-emails", + { count: monitoredEmails } + ); + + const knownBreaches = this.doc.querySelector( + "span[data-type='known-breaches']" + ); + const exposedPasswords = this.doc.querySelector( + "span[data-type='exposed-passwords']" + ); + + const infoKnownBreaches = this.doc.getElementById("info-known-breaches"); + const infoExposedPasswords = this.doc.getElementById( + "info-exposed-passwords" + ); + + const breachesWrapper = this.doc.querySelector(".monitor-breaches-wrapper"); + const partialBreachesWrapper = this.doc.querySelector( + ".monitor-partial-breaches-wrapper" + ); + const breachesTitle = this.doc.getElementById("monitor-breaches-title"); + const breachesIcon = this.doc.getElementById("monitor-breaches-icon"); + const breachesDesc = this.doc.getElementById( + "monitor-breaches-description" + ); + const breachesLink = this.doc.getElementById("monitor-breaches-link"); + if (numBreaches) { + if (!numBreachesResolved) { + partialBreachesWrapper.classList.add("hidden"); + knownBreaches.textContent = numBreaches; + knownBreaches.classList.add("known-unresolved-breaches"); + knownBreaches.classList.remove("known-resolved-breaches"); + this.doc.l10n.setAttributes( + infoKnownBreaches, + "info-known-breaches-found", + { count: numBreaches } + ); + exposedPasswords.textContent = passwords; + exposedPasswords.classList.add("passwords-exposed-all-breaches"); + exposedPasswords.classList.remove( + "passwords-exposed-unresolved-breaches" + ); + this.doc.l10n.setAttributes( + infoExposedPasswords, + "info-exposed-passwords-found", + { count: passwords } + ); + + breachesIcon.setAttribute( + "src", + "chrome://browser/skin/protections/new-feature.svg" + ); + this.doc.l10n.setAttributes( + breachesTitle, + "monitor-breaches-unresolved-title" + ); + this.doc.l10n.setAttributes( + breachesDesc, + "monitor-breaches-unresolved-description" + ); + this.doc.l10n.setAttributes( + breachesLink, + "monitor-manage-breaches-link" + ); + breachesLink.classList.add("no-breaches-resolved"); + } else if (numBreaches == numBreachesResolved) { + partialBreachesWrapper.classList.add("hidden"); + knownBreaches.textContent = numBreachesResolved; + knownBreaches.classList.remove("known-unresolved-breaches"); + knownBreaches.classList.add("known-resolved-breaches"); + this.doc.l10n.setAttributes( + infoKnownBreaches, + "info-known-breaches-resolved", + { count: numBreachesResolved } + ); + let unresolvedPasswords = passwords - passwordsResolved; + exposedPasswords.textContent = unresolvedPasswords; + exposedPasswords.classList.remove("passwords-exposed-all-breaches"); + exposedPasswords.classList.add("passwords-exposed-unresolved-breaches"); + this.doc.l10n.setAttributes( + infoExposedPasswords, + "info-exposed-passwords-resolved", + { count: unresolvedPasswords } + ); + + breachesIcon.setAttribute( + "src", + "chrome://browser/skin/protections/resolved-breach.svg" + ); + this.doc.l10n.setAttributes( + breachesTitle, + "monitor-breaches-resolved-title" + ); + this.doc.l10n.setAttributes( + breachesDesc, + "monitor-breaches-resolved-description" + ); + this.doc.l10n.setAttributes(breachesLink, "monitor-view-report-link"); + } else { + breachesWrapper.classList.add("hidden"); + knownBreaches.textContent = numBreachesResolved; + knownBreaches.classList.remove("known-unresolved-breaches"); + knownBreaches.classList.add("known-resolved-breaches"); + this.doc.l10n.setAttributes( + infoKnownBreaches, + "info-known-breaches-resolved", + { count: numBreachesResolved } + ); + let unresolvedPasswords = passwords - passwordsResolved; + exposedPasswords.textContent = unresolvedPasswords; + exposedPasswords.classList.remove("passwords-exposed-all-breaches"); + exposedPasswords.classList.add("passwords-exposed-unresolved-breaches"); + this.doc.l10n.setAttributes( + infoExposedPasswords, + "info-exposed-passwords-resolved", + { count: unresolvedPasswords } + ); + + const partialBreachesTitle = document.getElementById( + "monitor-partial-breaches-title" + ); + this.doc.l10n.setAttributes( + partialBreachesTitle, + "monitor-partial-breaches-title", + { + numBreaches, + numBreachesResolved, + } + ); + + const progressBar = this.doc.querySelector(".progress-bar"); + const partialBreachesMotivationTitle = document.getElementById( + "monitor-partial-breaches-motivation-title" + ); + + let percentageResolved = Math.floor( + (numBreachesResolved / numBreaches) * 100 + ); + progressBar.setAttribute("value", 100 - percentageResolved); + switch (true) { + case percentageResolved > 0 && percentageResolved < 25: + this.doc.l10n.setAttributes( + partialBreachesMotivationTitle, + "monitor-partial-breaches-motivation-title-start" + ); + break; + + case percentageResolved >= 25 && percentageResolved < 75: + this.doc.l10n.setAttributes( + partialBreachesMotivationTitle, + "monitor-partial-breaches-motivation-title-middle" + ); + break; + + case percentageResolved >= 75 && percentageResolved < 100: + this.doc.l10n.setAttributes( + partialBreachesMotivationTitle, + "monitor-partial-breaches-motivation-title-end" + ); + break; + } + + const partialBreachesPercentage = document.getElementById( + "monitor-partial-breaches-percentage" + ); + this.doc.l10n.setAttributes( + partialBreachesPercentage, + "monitor-partial-breaches-percentage", + { percentageResolved } + ); + + const partialBreachesLink = document.getElementById( + "monitor-partial-breaches-link" + ); + partialBreachesLink.setAttribute("href", MONITOR_HOME_PAGE_URL); + partialBreachesLink.addEventListener( + "click", + this.onClickMonitorButton.bind(this) + ); + } + } else { + partialBreachesWrapper.classList.add("hidden"); + knownBreaches.textContent = numBreaches; + knownBreaches.classList.add("known-unresolved-breaches"); + knownBreaches.classList.remove("known-resolved-breaches"); + this.doc.l10n.setAttributes( + infoKnownBreaches, + "info-known-breaches-found", + { count: numBreaches } + ); + exposedPasswords.textContent = passwords; + exposedPasswords.classList.add("passwords-exposed-all-breaches"); + exposedPasswords.classList.remove( + "passwords-exposed-unresolved-breaches" + ); + this.doc.l10n.setAttributes( + infoExposedPasswords, + "info-exposed-passwords-found", + { count: passwords } + ); + + breachesIcon.setAttribute( + "src", + "chrome://browser/skin/protections/resolved-breach.svg" + ); + this.doc.l10n.setAttributes(breachesTitle, "monitor-no-breaches-title"); + this.doc.l10n.setAttributes( + breachesDesc, + "monitor-no-breaches-description" + ); + this.doc.l10n.setAttributes(breachesLink, "monitor-view-report-link"); + } + + breachesLink.setAttribute("href", MONITOR_HOME_PAGE_URL); + breachesLink.addEventListener( + "click", + this.onClickMonitorButton.bind(this) + ); + } +} diff --git a/browser/components/protections/content/protections.css b/browser/components/protections/content/protections.css new file mode 100644 index 0000000000..de0ee0b2a0 --- /dev/null +++ b/browser/components/protections/content/protections.css @@ -0,0 +1,1127 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +:root { + --card-padding: 24px; + --exit-icon-size: 12px; + --exit-icon-position: calc((var(--card-padding) - var(--exit-icon-size)) / 2); + --social-color: #9059FF; + --cookie-color: #0090F4; + --tracker-color: #2AC3A2; + --fingerprinter-color: #FFA436; + --cryptominer-color: #ADADBC; + + /* Highlight colors for trackers */ + --social-highlight-color: #7B4CDB; + --cookie-highlight-color: #0081DB; + --tracker-highlight-color: #23A488; + --fingerprinter-highlight-color: #D37F17; + --cryptominer-highlight-color: #9292A0; + + --tab-highlight: var(--social-color); /* start with social selected */ + --column-width: 16px; + --graph-empty: #CECECF; + --graph-curve: cubic-bezier(.66,.75,.59,.91); + + /* Colors for the loading indicator */ + --protection-report-loader-color-stop: #AEAEAE3D; + --protection-report-loader-gradient-opacity: 0.95; + + --grey-70: #38383D; + --grey-90-a60: rgba(12, 12, 13, 0.6); + + --gear-icon-fill: var(--grey-90-a60); + --hover-grey-link: var(--grey-70); + --feature-banner-color: rgba(0, 0, 0, 0.05); +} + +body { + box-sizing: border-box; +} + +*, *:before, *:after { + box-sizing: inherit; +} + +body[focuseddatatype=social] { + --tab-highlight: var(--social-color); +} + +body[focuseddatatype=cookie] { + --tab-highlight: var(--cookie-color); +} + +body[focuseddatatype=tracker] { + --tab-highlight: var(--tracker-color); +} + +body[focuseddatatype=fingerprinter] { + --tab-highlight: var(--fingerprinter-color); +} + +body[focuseddatatype=cryptominer] { + --tab-highlight: var(--cryptominer-color); +} + +h2 { + font-weight: var(--font-weight-bold); +} + +#report-title { + margin-block-end: 20px; +} + +#report-summary { + color: var(--text-color-deemphasized); + line-height: 26px; + font-size: 16px; + margin: 0; + margin-bottom: 15px; +} + +#report-content { + width: 763px; + margin: 0 auto; + margin-block: 40px 80px; +} + +.card-header .wrapper, +.new-banner .wrapper { + display: grid; + grid-template-columns: repeat(7, 1fr); + align-items: center; +} + +#manage-protections, +.card-header > button, +#save-passwords-button, +#get-proxy-extension-link, +#get-vpn-link, +#vpn-banner-link, +#manage-passwords-button, +#sign-up-for-monitor-link { + grid-area: 1 / 5 / 1 / -1; + margin: 0; + font-size: 0.95em; + cursor: pointer; + padding: 10px; + text-align: center; + line-height: initial; +} + +#vpn-banner-link { + grid-area: 1 / 6 / 1 / -1; +} + +.new-banner .wrapper div:nth-child(1) { + grid-area: 1 / 1 / 1 / 6; + padding-inline-end: 15px; +} + +.lockwise-card.has-logins .wrapper div:nth-child(1) { + grid-area: 1 / 1 / 1 / 6; +} + +.card:not(.has-logins) .wrapper div:nth-child(1), +.etp-card.custom-not-blocking .wrapper div:nth-child(1) { + grid-area: 1 / 1 / 1 / 5; + padding-inline-end: 15px; +} + +.etp-card:not(.custom-not-blocking) .wrapper div:nth-child(1) { + grid-area: 1 / 1 / 1 / 8; +} + +.card.has-logins .wrapper div:nth-child(1) { + grid-area: 1 / 1 / 1 / -1; +} + +.vpn-card.subscribed .wrapper div:nth-child(1) { + padding-inline-end: 29px; + grid-area: 1 / 1 / 1 / 7; +} + +/* We want to hide certain components depending on its state. */ +.no-logins .monitor-scanned-wrapper, +.etp-card.custom-not-blocking .card-body, +.etp-card.custom-not-blocking #protection-settings, +#manage-protections, +.etp-card .icon.dark, +.proxy-card .icon.dark, +.vpn-card .icon.dark, +.vpn-banner .icon.dark, +a.hidden, +.loading .card-body, +.lockwise-card.hidden, +#lockwise-body-content .has-logins.hidden, +#lockwise-body-content .no-logins.hidden, +.lockwise-card.no-logins #lockwise-how-it-works, +.lockwise-card.no-logins .lockwise-scanned-wrapper, +.lockwise-card.no-logins #manage-passwords-button, +.lockwise-card.has-logins #save-passwords-button, +.monitor-card.hidden, +.monitor-card.no-logins .card-body, +.monitor-card.no-logins #monitor-header-content a, +.monitor-card.no-logins .monitor-scanned-text, +.monitor-card.no-logins .icon-small, +.monitor-card.no-logins .monitor-breaches-wrapper, +.monitor-card.no-logins .monitor-partial-breaches-wrapper, +.monitor-card .monitor-breaches-wrapper.hidden, +.monitor-card .monitor-partial-breaches-wrapper.hidden, +.monitor-card.has-logins #sign-up-for-monitor-link, +.loading a, +.loading button, +.loading .wrapper, +.proxy-card.hidden, +.vpn-card.hidden, +.card-body.hidden, +.hidden { + display: none; +} + +.icon { + width: 64px; + height: 64px; + grid-column: 1; + margin: 0 auto; + z-index: 1; +} + +.vpn-card .icon { + width: 56px; + height: 56px; +} + +.new-banner .icon { + width: 50px; + height: 50px; +} + +@media (prefers-color-scheme: dark) { + :root { + --social-highlight-color: #9661FF; + --cookie-highlight-color: #2BA8FF; + --tracker-highlight-color: #39E1BC; + --fingerprinter-highlight-color: #FFB65E; + --cryptominer-highlight-color: #BEBECA; + + --gear-icon-fill: rgba(249, 249, 250, 0.60); + --hover-grey-link: var(--grey-30); + --feature-banner-color: rgba(255, 255, 255, 0.1); + } + + .etp-card .icon.dark, + .proxy-card .icon.dark, + .vpn-card .icon.dark, + .vpn-banner .icon.dark { + display: block; + } + + .etp-card .icon.light, + .proxy-card .icon.light, + .vpn-card .icon.light, + .vpn-banner .icon.light { + display: none; + } +} + +.card { + display: grid; + grid-template-columns: 100%; + grid-template-rows: 20% auto; + padding: 0; + margin-block-end: 25px; + line-height: 1.3em; +} + +.card-header, +.card-body { + display: grid; + grid-template-columns: 1fr 7fr; + padding: var(--card-padding); + grid-gap: var(--card-padding); +} + +.card .card-title { + font-size: inherit; + line-height: 1.25; + margin: 0; + cursor: default; +} + +.card .content { + margin-block: 5px 0; + font-size: .93em; + cursor: default; + color: var(--text-color-deemphasized); +} + +.exit-icon { + position: absolute; + width: var(--exit-icon-size); + height: var(--exit-icon-size); + top: var(--exit-icon-position); + inset-inline-end: var(--exit-icon-position); + background-image: url(chrome://global/skin/icons/close.svg); + background-size: calc(var(--exit-icon-size) - 2px); + background-color: transparent; + background-position: center; + background-repeat: no-repeat; + -moz-context-properties: fill; + fill: currentColor; + + /* Override margin, padding, min-height and min-width set in common-shared.css */ + padding: 0; + margin: 0; + min-width: 1px; + min-height: 1px; +} + +.custom-not-blocking .content { + margin-block-end: 5px; +} + +.custom-not-blocking #manage-protections { + display: initial; +} + +#protection-settings { + -moz-context-properties: fill; + fill: var(--gear-icon-fill); + background: url("chrome://global/skin/icons/settings.svg") no-repeat 0; + cursor: pointer; + width: max-content; + color: var(--text-color-deemphasized); + margin-block: 6px 0; + font-size: 0.8em; + padding-block: 4px; + padding-inline: 24px 4px; +} + +#protection-settings:dir(rtl) { + background-position-x: right; +} + +#protection-settings:hover, +#protection-settings:focus { + text-decoration: underline; + color: var(--hover-grey-link); + fill: var(--hover-grey-link); +} + +#protection-settings:hover:active { + text-decoration: none; + color: var(--in-content-text-color); + fill: var(--in-content-text-color); +} + +#protection-settings:-moz-focusring { + outline: none; +} + +.card .card-body { + border-block-start: 1px solid var(--in-content-border-color); + grid-column: span 2; + grid-row: 2; + position: relative; +} + +.body-wrapper { + grid-column: 2; +} + +#graph-week-summary, +#graph-total-summary { + font-size: 0.8em; +} + +#graph-week-summary { + font-weight: bold; +} + +#graph-wrapper { + width: 100%; + margin-block: 33px 25px; +} + +#graph { + display: grid; + grid: repeat(10, 1fr) max-content / repeat(7, 1fr); + height: 130px; + margin-block-end: 10px; +} + +#private-window-message { + border: 1px solid var(--in-content-border-color); + grid-area: 1 / 2 / 1 / 7; + background-color: var(--in-content-box-background-odd); + padding: 13px 45px; + font-size: 13px; + margin-bottom: 25px; + text-align: center; +} + +#graph:not(.private-window) #private-window-message { + display: none; +} + +/* Graph Bars */ +.graph-bar { + grid-row: 2 / -2; + align-self: flex-end; + width: var(--column-width); + position: relative; + height: 0; + transition: height 500ms var(--graph-curve); +} + +.graph-wrapper-bar { + height: 100%; + width: 100%; + border-radius: 2px; + overflow: hidden; + outline: 1px solid transparent; +} + +.graph-bar:hover { + cursor: pointer; +} + +.graph-bar.empty { + height: 0; + border: 1px var(--graph-empty) solid; + border-radius: 4px; + cursor: default; +} + +.social-bar { + background-color: var(--social-color); +} + +.hover-social .social-bar { + background-color: var(--social-highlight-color); +} + +.cookie-bar { + background-color: var(--cookie-color); +} + +.hover-cookie .cookie-bar { + background-color: var(--cookie-highlight-color); +} + +.tracker-bar { + background-color: var(--tracker-color); +} + +.hover-tracker .tracker-bar { + background-color: var(--tracker-highlight-color); +} + +.fingerprinter-bar { + background-color: var(--fingerprinter-color); +} + +.hover-fingerprinter .fingerprinter-bar { + background-color: var(--fingerprinter-highlight-color); +} + +.cryptominer-bar { + background-color: var(--cryptominer-color); +} + +.hover-cryptominer .cryptominer-bar { + background-color: var(--cryptominer-highlight-color); +} + +.column-label { + margin-block-start: 5px; + font-size: 0.9em; + width: var(--column-width); + grid-row: -1; +} + +.bar-count { + position: absolute; + top: -21px; + font-size: 0.8em; + opacity: 0; + transition: opacity 700ms; + pointer-events: none; +} + +.bar-count.animate { + opacity: 1; +} + +/* Legend */ +#graphLegendDescription { + position: absolute; + opacity: 0; + z-index: -1; +} + +input[type="radio"] { + position: absolute; + inset-inline-start: -100px; +} + +#legend input:focus + label { + outline: solid 1px; + outline-offset: -1px; + outline-color: var(--tab-highlight); +} + +#legend { + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-template-rows: auto auto; + grid-gap: 0; + position: relative; + overflow: hidden; +} + +#highlight { + background: var(--tab-highlight); + position: absolute; + height: 3px; + width: 100%; + align-self: end; + grid-column: 1 / span 1; + grid-row: 1 / 1; +} + +#highlight-hover { + position: absolute; + height: 4px; + width: 100%; + bottom: -1px; + align-self: end; +} + +#legend label { + margin-block-end: -1px; + padding: 15px 0; + padding-inline-end: 5px; + border: 3px solid transparent; + -moz-context-properties: fill; + display: inline-block; +} + +#legend label:-moz-focusring { + outline: none; +} + +.icon-small { + width: 16px; + height: 16px; + display: inline-block; + vertical-align: middle; + -moz-context-properties: fill; + fill: currentColor; + margin-inline-end: 2px; +} + +label span { + margin-inline-start: 1px; + display: inline-block; +} + +label[data-type="social"] .icon-small { + fill: var(--social-color); +} + +label[data-type="cookie"] .icon-small { + fill: var(--cookie-color); +} + +label[data-type="tracker"] .icon-small { + fill: var(--tracker-color); +} + +label[data-type="fingerprinter"] .icon-small { + fill: var(--fingerprinter-color); +} + +label[data-type="cryptominer"] .icon-small { + fill: var(--cryptominer-color); +} + +.hover-social label[for="tab-social"], +.hover-cookie label[for="tab-cookie"], +.hover-tracker label[for="tab-tracker"], +.hover-fingerprinter label[for="tab-fingerprinter"], +.hover-cryptominer label[for="tab-cryptominer"], +label:hover { + cursor: pointer; +} + +.tab-content { + display: none; + padding: 22px 20px 20px; + border-block-start: 1px solid var(--tab-highlight); + background-color: var(--in-content-box-background-odd); + font-size: 0.8em; + line-height: 1.75; +} + +.tab-content .content-title { + font-weight: bold; +} + +.tab-content p { + margin: 0; +} + +.tab-content p:nth-of-type(2) { + color: var(--text-color-deemphasized); +} + +#tab-social:checked ~ #social, +#tab-cookie:checked ~ #cookie, +#tab-tracker:checked ~ #tracker, +#tab-fingerprinter:checked ~ #fingerprinter, +#tab-cryptominer:checked ~ #cryptominer { + display: block; + grid-column: 1/ -1; + grid-row: 2; +} + +input[data-type="social"]:checked ~ #highlight, +.hover-social label[for="tab-social"] ~ #highlight-hover, +label[for="tab-social"]:hover ~ #highlight-hover { + background-color: var(--social-color); + grid-area: social; +} + +input[data-type="cookie"]:checked ~ #highlight, +.hover-cookie label[for="tab-cookie"] ~ #highlight-hover, +label[for="tab-cookie"]:hover ~ #highlight-hover { + background-color: var(--cookie-color); + grid-area: cookie; +} + +input[data-type="tracker"]:checked ~ #highlight, +.hover-tracker label[for="tab-tracker"] ~ #highlight-hover, +label[for="tab-tracker"]:hover ~ #highlight-hover { + background-color: var(--tracker-color); + grid-area: tracker; +} + +input[data-type="fingerprinter"]:checked ~ #highlight, +.hover-fingerprinter label[for="tab-fingerprinter"] ~ #highlight-hover, +label[for="tab-fingerprinter"]:hover ~ #highlight-hover { + background-color: var(--fingerprinter-color); + grid-area: fingerprinter; +} + +input[data-type="cryptominer"]:checked ~ #highlight, +.hover-cryptominer label[for="tab-cryptominer"] ~ #highlight-hover, +label[for="tab-cryptominer"]:hover ~ #highlight-hover { + background-color: var(--cryptominer-color); + grid-area: cryptominer; +} + +#mobile-hanger { + grid-column: 1; + grid-row: 3; +} + +.etp-card { + margin-top: 31px; + grid-template-rows: 20% auto auto; +} + +/* Lockwise Card */ + +#lockwise-body-content > .no-logins, +#lockwise-body-content > .has-logins, +#etp-mobile-content { + display: grid; + font-size: 0.875em; + align-items: center; +} + +#lockwise-body-content > .no-logins, +#etp-mobile-content { + grid: 1fr / 1fr 6fr; +} + +#lockwise-body-content > .has-logins { + grid: 1fr 1fr / minmax(70px, auto) 1fr; + grid-gap: 10px; +} + +.mobile-app-icon { + height: 56px; + width: auto; + -moz-context-properties: fill; + fill: currentColor; +} + +#lockwise-app-links, +#mobile-app-links { + display: block; +} + +.block { + background-color: var(--grey-60); + border-radius: 4px; + text-align: center; + font-size: 1.125em; + font-weight: bold; + color: #fff; + padding: 7px; + line-height: 18px; +} + +#lockwise-body-content .has-logins a { + margin-inline-start: 10px; +} + +.lockwise-scanned-wrapper { + display: grid; + grid-template-columns: 24px auto; + margin-block-start: 24px; + grid-area: 2 / 1 / 2 / 5; + padding-bottom: 1.7em; +} + +#lockwise-scanned-text { + margin-inline-end: 15px; +} + +#lockwise-scanned-icon { + margin-top: 5px; +} + +#manage-passwords-button { + grid-area: 2 / 5 / 2 / 7; + margin-inline-end: 15px; +} + +.vpn-card.subscribed #get-vpn-link { + display: none; +} + +.vpn-card:not(.subscribed) .content.subscribed { + display: none; +} + +.vpn-card.subscribed .content:not(.subscribed) { + display: none; +} + +/* Monitor card */ +.monitor-info-wrapper { + display: grid; + grid: 1fr / 1fr 1fr 1fr; + grid-column-start: 1; + grid-column-end: 7; +} + +.monitor-scanned-wrapper { + margin-block-start: 24px; + font-size: 0.85em; + display: block; +} + +.monitor-breaches-wrapper { + display: grid; + grid-area: 2 / 1 / 2 / 8; + grid: 1fr auto / repeat(7, 1fr); + margin-bottom: 24px; +} + +.monitor-partial-breaches-wrapper { + display: grid; + grid-area: 2 / 1 / 2 / 8; + grid-template-columns: repeat(7, 1fr); + margin-block: 24px; +} + +.monitor-partial-breaches-header { + grid-area: 1 / 1 / 1 / 7; + margin-inline-end: 15px; + margin-block: 6px; +} + +#monitor-partial-breaches-percentage { + font-size: .93em; + cursor: default; + color: var(--text-color-deemphasized); + float: inline-end; +} + +.progress-bar { + grid-area: 2 / 1 / 2 / 7; + margin-inline-start: 15px; + border-radius: 4px; + height: 25px; + box-shadow: 0 0 0 1px rgba(202, 201, 213, 0.5); + border: none; + background: linear-gradient(-45deg, #0250BB 0%, #9059FF 100%); + direction: rtl; +} + +.progress-bar:dir(rtl) { + direction: ltr; + background: linear-gradient(-45deg, #0250BB 0%, #9059FF 100%); +} + +.progress-bar::-moz-progress-bar { + background: #FFFFFF; + border-radius: 0 4px 4px 0; +} + +.monitor-partial-breaches-motivation-text { + grid-template-columns: repeat(7, 1fr); + grid-area: 3 / 1 / 3 / 8; + margin-top: 25px; + display: grid; +} + +.monitor-partial-breaches-motivation-wrapper { + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-area: 2 / 1 / 2 / 8; +} + +#monitor-partial-breaches-motivation-title { + font-weight: 700; + grid-area: 1 / 1 / 1 / 7; + margin-inline-end: 15px; +} + +#monitor-breaches-description, +#monitor-partial-breaches-motivation-desc { + grid-area: 1 / 1 / 1 / 5; + margin-block: auto; + margin-inline-end: 15px; +} + +.monitor-breaches-header { + margin-top: 30px; + grid-area: 1 / 1 / 1 / 8; +} + +.monitor-breaches-description-wrapper { + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-area: 2 / 1 / 2 / 8; +} + +#monitor-partial-breaches-icon, +#monitor-breaches-icon { + vertical-align: middle; + margin-inline-end: 2px; +} + +#monitor-partial-breaches-title { + font-size: 0.93em; +} + +#monitor-breaches-title { + font-weight: 700; +} + +#monitor-breaches-title, +#monitor-partial-breaches-title, +#monitor-partial-breaches-motivation-title { + cursor: default; +} + +.monitor-partial-breaches-link-wrapper, +.monitor-breaches-link-wrapper { + margin-block: auto; + grid-area: 1 / 5 / 1 / 7; + margin-inline: 0 15px; + font-size: 0.95em; + text-align: center; + display: flex; +} + +#monitor-breaches-link, +#monitor-partial-breaches-link { + color: inherit; + outline: none; + text-decoration: none; + width: 157.267px; + padding: 9px; +} + +.lockwise-card #lockwise-header-content > a, +.monitor-card #monitor-header-content > a { + display: block; + margin-block-start: 5px; + width: max-content; +} + +.monitor-card.has-logins #monitor-body-content { + display: grid; + grid: 1fr auto / repeat(7, 1fr); + align-items: center; +} + +.monitor-card .card-body { + padding-top: 0; + border-block-start: none; +} + +.monitor-block { + display: flex; + flex-direction: column; + border-radius: 4px; + text-align: center; + margin-inline-end: 15px; +} + +.monitor-block a { + outline: none; + color: #FFFFFF; + padding: 19px 0; +} + +.monitor-block a:hover { + text-decoration: none; + color: #FFFFFF; +} + +.email { + background: linear-gradient(162.33deg, #AB71FF 0%, #9059FF 100%); + grid-column: 1; +} + +.email:hover { + background: linear-gradient(162.33deg, #7D43D1 0%, #7740E6 100%); +} + +.email:dir(rtl) { + background: linear-gradient(197.67deg, #AB71FF 0%, #9059FF 100%); +} + +.email:dir(rtl):hover { + background: linear-gradient(197.67deg, #7D43D1 0%, #7740E6 100%); +} + +.breaches { + background: linear-gradient(162.33deg, #9059FF 0%, #7542E5 100%); + grid-column: 2; +} + +.breaches:hover { + background: linear-gradient(162.33deg, #7740E6 0%, #4714B7 100%); +} + +.breaches:dir(rtl) { + background: linear-gradient(197.67deg, #9059FF 0%, #7542E5 100%) +} + +.breaches:dir(rtl):hover { + background: linear-gradient(197.67deg, #7740E6 0%, #4714B7 100%) +} + +.passwords { + background: linear-gradient(162.33deg, #7542E5 0%, #592ACB 100%); + grid-column: 3; +} + +.passwords:hover { + background: linear-gradient(162.33deg, #4714B7 0%, #2B009D 100%); +} + +.passwords:dir(rtl) { + background: linear-gradient(197.67deg, #7542E5 0%, #592ACB 100%) +} + +.passwords:dir(rtl):hover { + background: linear-gradient(197.67deg, #4714B7 0%, #2B009D 100%) +} + +.monitor-stat { + display: flex; + font-size: 1.75em; + font-weight: bold; + margin-block-end: 5px; + word-break: break-all; + justify-content: center; + flex-wrap: wrap; +} + +.monitor-icon { + margin-inline-end: 3px; +} + +.icon-med { + width: 24px; + height: 24px; + -moz-context-properties: fill,fill-opacity; + fill: white; + fill-opacity: 0.65; + padding: 5px; + display: inline-block; + vertical-align: middle; +} + +.info-text { + font-size: 0.75em; + line-height: 13px; + margin: 0 10px; + display: inline-block; +} + +.number-of-breaches.block { + font-size: 1.45em; + background-color: var(--orange-50); + padding: 15px; + grid-column: 1 / 2; + width: 70px; + height: 48px; +} + +#manage-protections, +#sign-up-for-monitor-link, +#get-proxy-extension-link, +#get-vpn-link, +#vpn-banner-link, +.monitor-partial-breaches-link-wrapper, +.monitor-breaches-link-wrapper { + background-color: var(--in-content-primary-button-background); + border: 1px solid var(--in-content-primary-button-border-color); + border-radius: 4px; + text-decoration: none; + color: var(--in-content-primary-button-text-color); + font-weight: 600; +} + +#manage-protections:hover, +#sign-up-for-monitor-link:hover, +#get-proxy-extension-link:hover, +#get-vpn-link:hover, +#vpn-banner-link:hover, +#monitor-partial-breaches-link:hover, +#monitor-breaches-link:hover { + background-color: var(--in-content-primary-button-background-hover); + color: var(--in-content-primary-button-text-color-hover); + border-color: var(--in-content-button-border-color-hover); +} + +#manage-protections:hover:active, +#sign-up-for-monitor-link:hover:active, +#get-proxy-extension-link:hover:active, +#get-vpn-link:hover:active, +#vpn-banner-link:hover:active, +#monitor-partial-breaches-link:hover:active, +#monitor-breaches-link:hover:active { + background-color: var(--in-content-primary-button-background-active); + color: var(--in-content-primary-button-text-color-active); + border-color: var(--in-content-button-border-color-active); +} + +#manage-protections:focus-visible, +#sign-up-for-monitor-link:focus-visible, +#get-proxy-extension-link:focus-visible, +#get-vpn-link:focus-visible, +#vpn-banner-link:focus-visible, +#monitor-partial-breaches-link:focus-visible, +.monitor-block > a:focus-visible, +#monitor-breaches-link:focus-visible { + outline: var(--in-content-focus-outline); + outline-offset: var(--in-content-focus-outline-offset); +} + +.monitor-card.loading::after, +.lockwise-card.loading::after { + position: absolute; + height: 110px; + width: 765px; + content: ""; + background-image: linear-gradient(to right, var(--in-content-box-background) 0%, var(--protection-report-loader-color-stop) 30%, var(--in-content-box-background) 40%, var(--in-content-box-background) 100%); + background-repeat: no-repeat; + animation-duration: 2s; + animation-iteration-count: infinite; + animation-name: loading; + animation-timing-function: cubic-bezier(.07,.95,1,1); + background-size: 700px 100%; + opacity: var(--protection-report-loader-gradient-opacity); +} + +.monitor-card.loading:dir(rtl)::after, +.lockwise-card.loading:dir(rtl)::after { + background-image: linear-gradient(to left, var(--in-content-box-background) 0%, var(--protection-report-loader-color-stop) 30%, var(--in-content-box-background) 40%, var(--in-content-box-background) 100%); + animation-name: loading-rtl; +} + +@keyframes loading { + 0% { + background-position-x: -300px; + } + + 100% { + background-position-x: 700px; + opacity: 0.02; + } +} + +@keyframes loading-rtl { + 0% { + background-position-x: right -300px; + } + + 100% { + background-position-x: right 700px; + opacity: 0.02; + } +} + +.new-banner { + width: 100%; + background: var(--feature-banner-color); +} + +.banner-wrapper { + width: 763px; + display: grid; + grid-template-columns: 1fr 7fr; + grid-gap: var(--card-padding); + line-height: 1.3em; + margin: 0 auto; + padding: 12px var(--card-padding); +} + +.new-banner .banner-title { + margin: 0; + line-height: 1.25; + cursor: default; + font-size: inherit; +} + +.new-banner .content { + margin-block: 5px 0; + font-size: 0.88em; + cursor: default; + color: var(--text-color-deemphasized); +} + +.new-banner .exit-icon { + top: auto; + inset-inline-end: 30px; +} + +.vpn-card .title-wrapper { + display: grid; + grid-template-columns: 24px auto; +} + +.vpn-card:not(.subscribed) .card-title { + grid-area: 1 / 1 / 1 / -1; +} + +.vpn-card.subscribed .card-title { + margin-inline-start: 3px; +} + +.vpn-card:not(.subscribed) #check-icon { + display: none; +} diff --git a/browser/components/protections/content/protections.ftl b/browser/components/protections/content/protections.ftl new file mode 100644 index 0000000000..0f5b96e7ff --- /dev/null +++ b/browser/components/protections/content/protections.ftl @@ -0,0 +1,26 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +### This file is not in a locales directory to prevent it from +### being translated as the feature is still in heavy development +### and strings are likely to change often. + +-secure-proxy-brand-name = Firefox Private Network + +proxy-title = Stay safe on public Wi-Fi +proxy-header-content = { -secure-proxy-brand-name } makes wireless hotspots more secure to protect you from hackers. +get-proxy-extension-link = Get the extension + +vpn-title = Take privacy protections beyond the browser +vpn-header-content = Protect your entire device with { -mozilla-vpn-brand-name }. One tap encrypts all traffic and hides your location. +get-vpn-link = Get { -mozilla-vpn-brand-name } + +vpn-title-subscribed = VPN: Subscribed +# Note This text is not being translated, and the
    will need to be removed if or when it does get translated +vpn-header-content-subscribed = Using the { -mozilla-vpn-brand-name } encrypts all your traffic and hides your location — on up to 5 devices. Get the most from your subscription — add it from
    the Google Play Store or Apple App Store. + +vpn-banner-header = Protection that extends beyond the browser +# Note This text is not being translated, and the
    will need to be removed if or when it does get translated +vpn-banner-content = Try { -mozilla-vpn-brand-name } risk-free and see why TechRadar says,
    “its speed, simplicity and low monthly price make it worth a look.” +vpn-banner-link = Get { -mozilla-vpn-brand-name } diff --git a/browser/components/protections/content/protections.html b/browser/components/protections/content/protections.html new file mode 100644 index 0000000000..1374c30fd7 --- /dev/null +++ b/browser/components/protections/content/protections.html @@ -0,0 +1,597 @@ + + + + + + + + + + + + + + + + + + + + + + + +
    +

    +

    +

    +
    +
    + + +
    +
    +

    +

    +
    + +
    +
    +
    +
    +

    +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + +
    +
    +
    +

    +

    + +

    +
    + +
    +

    +

    + +

    +
    +
    +

    +

    + +

    +
    +
    +

    +

    + +

    +
    +
    +
    +
    +
    +
    + +
    + + + + + + + + +
    + + diff --git a/browser/components/protections/content/protections.mjs b/browser/components/protections/content/protections.mjs new file mode 100644 index 0000000000..3204586a2b --- /dev/null +++ b/browser/components/protections/content/protections.mjs @@ -0,0 +1,490 @@ +/* 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/remote-page */ + +import LockwiseCard from "./lockwise-card.mjs"; +import MonitorCard from "./monitor-card.mjs"; +import ProxyCard from "./proxy-card.mjs"; +import VPNCard from "./vpn-card.mjs"; + +let cbCategory = RPMGetStringPref("browser.contentblocking.category"); +document.sendTelemetryEvent = (action, object, value = "") => { + // eslint-disable-next-line no-undef + RPMRecordTelemetryEvent("security.ui.protections", action, object, value, { + category: cbCategory, + }); +}; + +let { protocol, pathname, searchParams } = new URL(document.location); + +let searchParamsChanged = false; +if (searchParams.has("entrypoint")) { + RPMSendAsyncMessage("RecordEntryPoint", { + entrypoint: searchParams.get("entrypoint"), + }); + // Remove this parameter from the URL (after recording above) to make it + // cleaner for bookmarking and switch-to-tab and so that bookmarked values + // don't skew telemetry. + searchParams.delete("entrypoint"); + searchParamsChanged = true; +} + +document.addEventListener("DOMContentLoaded", e => { + if (searchParamsChanged) { + let newURL = protocol + pathname; + let params = searchParams.toString(); + if (params) { + newURL += "?" + params; + } + window.location.replace(newURL); + return; + } + + RPMSendQuery("FetchEntryPoint", {}).then(entrypoint => { + // Send telemetry on arriving on this page + document.sendTelemetryEvent("show", "protection_report", entrypoint); + }); + + // We need to send the close telemetry before unload while we still have a connection to RPM. + window.addEventListener("beforeunload", () => { + document.sendTelemetryEvent("close", "protection_report"); + }); + + let todayInMs = Date.now(); + let weekAgoInMs = todayInMs - 6 * 24 * 60 * 60 * 1000; + + let dataTypes = [ + "cryptominer", + "fingerprinter", + "tracker", + "cookie", + "social", + ]; + + let manageProtectionsLink = document.getElementById("protection-settings"); + let manageProtections = document.getElementById("manage-protections"); + let protectionSettingsEvtHandler = evt => { + if (evt.keyCode == evt.DOM_VK_RETURN || evt.type == "click") { + RPMSendAsyncMessage("OpenContentBlockingPreferences"); + if (evt.target.id == "protection-settings") { + document.sendTelemetryEvent( + "click", + "settings_link", + "header-settings" + ); + } else if (evt.target.id == "manage-protections") { + document.sendTelemetryEvent( + "click", + "settings_link", + "custom-card-settings" + ); + } + } + }; + manageProtectionsLink.addEventListener("click", protectionSettingsEvtHandler); + manageProtectionsLink.addEventListener( + "keypress", + protectionSettingsEvtHandler + ); + manageProtections.addEventListener("click", protectionSettingsEvtHandler); + manageProtections.addEventListener("keypress", protectionSettingsEvtHandler); + + let legend = document.getElementById("legend"); + legend.style.gridTemplateAreas = + "'social cookie tracker fingerprinter cryptominer'"; + + let createGraph = data => { + let graph = document.getElementById("graph"); + let summary = document.getElementById("graph-total-summary"); + let weekSummary = document.getElementById("graph-week-summary"); + + // User is in private mode, show no data on the graph + if (data.isPrivate) { + graph.classList.add("private-window"); + } else { + let earliestDate = data.earliestDate || Date.now(); + document.l10n.setAttributes(summary, "graph-total-tracker-summary", { + count: data.sumEvents, + earliestDate, + }); + } + + // Set a default top size for the height of the graph bars so that small + // numbers don't fill the whole graph. + let largest = 100; + if (largest < data.largest) { + largest = data.largest; + } + let weekCount = 0; + let weekTypeCounts = { + social: 0, + cookie: 0, + tracker: 0, + fingerprinter: 0, + cryptominer: 0, + }; + + // For accessibility clients, we turn the graph into a fake table with annotated text. + // We use WAI-ARIA roles, properties, and states to mark up the table, rows and cells. + // Each day becomes one row in the table. + // Each row contains the day, total, and then one cell for each bar that we display. + // At most, a row can contain seven cells. + // But we need to caclulate the actual number of the most cells in a row to give accurate information. + let maxColumnCount = 0; + let date = new Date(); + for (let i = 0; i <= 6; i++) { + let dateString = date.toISOString().split("T")[0]; + let ariaOwnsString = ""; // Get the row's colummns in order + let currentColumnCount = 0; + let bar = document.createElement("div"); + bar.className = "graph-bar"; + bar.setAttribute("role", "row"); + let innerBar = document.createElement("div"); + innerBar.className = "graph-wrapper-bar"; + if (data[dateString]) { + let content = data[dateString]; + let count = document.createElement("div"); + count.className = "bar-count"; + count.id = "count" + i; + count.setAttribute("role", "cell"); + count.textContent = content.total; + setTimeout(() => { + count.classList.add("animate"); + }, 400); + bar.appendChild(count); + ariaOwnsString = count.id; + currentColumnCount += 1; + let barHeight = (content.total / largest) * 100; + weekCount += content.total; + // Add a short timeout to allow the elements to be added to the dom before triggering an animation. + setTimeout(() => { + bar.style.height = `${barHeight}%`; + }, 20); + for (let type of dataTypes) { + if (content[type]) { + let dataHeight = (content[type] / content.total) * 100; + // Since we are dealing with non-visual content, screen readers need a parent container to get the text + let cellSpan = document.createElement("span"); + cellSpan.id = type + i; + cellSpan.setAttribute("role", "cell"); + let div = document.createElement("div"); + div.className = `${type}-bar inner-bar`; + div.setAttribute("role", "img"); + div.setAttribute("data-type", type); + div.style.height = `${dataHeight}%`; + document.l10n.setAttributes(div, `bar-tooltip-${type}`, { + count: content[type], + percentage: dataHeight, + }); + weekTypeCounts[type] += content[type]; + cellSpan.appendChild(div); + innerBar.appendChild(cellSpan); + ariaOwnsString = ariaOwnsString + " " + cellSpan.id; + currentColumnCount += 1; + } + } + if (currentColumnCount > maxColumnCount) { + // The current row has more than any previous rows + maxColumnCount = currentColumnCount; + } + } else { + // There were no content blocking events on this day. + bar.classList.add("empty"); + } + bar.appendChild(innerBar); + graph.prepend(bar); + + if (data.isPrivate) { + document.l10n.setAttributes( + weekSummary, + "graph-week-summary-private-window" + ); + } else { + document.l10n.setAttributes(weekSummary, "graph-week-summary", { + count: weekCount, + }); + } + + let label = document.createElement("span"); + label.className = "column-label"; + // While the graphs fill up from the right, the days fill up from the left, so match the IDs + label.id = "day" + (6 - i); + label.setAttribute("role", "rowheader"); + if (i == 6) { + document.l10n.setAttributes(label, "graph-today"); + } else { + label.textContent = data.weekdays[(i + 1 + new Date().getDay()) % 7]; + } + graph.append(label); + // Make the day the first column in a row, making it the row header. + bar.setAttribute("aria-owns", "day" + i + " " + ariaOwnsString); + date.setDate(date.getDate() - 1); + } + maxColumnCount += 1; // Add the day column in the fake table + graph.setAttribute("aria-colCount", maxColumnCount); + // Set the total number of each type of tracker on the tabs as well as their + // "Learn More" links + for (let type of dataTypes) { + document.querySelector(`label[data-type=${type}] span`).textContent = + weekTypeCounts[type]; + const learnMoreLink = document.getElementById(`${type}-link`); + learnMoreLink.href = RPMGetFormatURLPref( + `browser.contentblocking.report.${type}.url` + ); + learnMoreLink.addEventListener("click", () => { + document.sendTelemetryEvent("click", "trackers_about_link", type); + }); + } + + let blockingCookies = + RPMGetIntPref("network.cookie.cookieBehavior", 0) != 0; + let cryptominingEnabled = RPMGetBoolPref( + "privacy.trackingprotection.cryptomining.enabled", + false + ); + let fingerprintingEnabled = + RPMGetBoolPref( + "privacy.trackingprotection.fingerprinting.enabled", + false + ) || RPMGetBoolPref("privacy.fingerprintingProtection", false); + let tpEnabled = RPMGetBoolPref("privacy.trackingprotection.enabled", false); + let socialTracking = RPMGetBoolPref( + "privacy.trackingprotection.socialtracking.enabled", + false + ); + let socialCookies = RPMGetBoolPref( + "privacy.socialtracking.block_cookies.enabled", + false + ); + let socialEnabled = + socialCookies && (blockingCookies || (tpEnabled && socialTracking)); + let notBlocking = + !blockingCookies && + !cryptominingEnabled && + !fingerprintingEnabled && + !tpEnabled && + !socialEnabled; + + // User has turned off all blocking, show a different card. + if (notBlocking) { + document.l10n.setAttributes( + document.getElementById("etp-card-content"), + "protection-report-etp-card-content-custom-not-blocking" + ); + document.l10n.setAttributes( + document.querySelector(".etp-card .card-title"), + "etp-card-title-custom-not-blocking" + ); + document.l10n.setAttributes( + document.getElementById("report-summary"), + "protection-report-page-summary" + ); + document.querySelector(".etp-card").classList.add("custom-not-blocking"); + + // Hide the link to settings from the header, so we are not showing two links. + manageProtectionsLink.style.display = "none"; + } else { + // Hide each type of tab if blocking of that type is off. + if (!tpEnabled) { + legend.style.gridTemplateAreas = legend.style.gridTemplateAreas.replace( + "tracker", + "" + ); + let radio = document.getElementById("tab-tracker"); + radio.setAttribute("disabled", true); + document.querySelector("#tab-tracker ~ label").style.display = "none"; + } + if (!socialEnabled) { + legend.style.gridTemplateAreas = legend.style.gridTemplateAreas.replace( + "social", + "" + ); + let radio = document.getElementById("tab-social"); + radio.setAttribute("disabled", true); + document.querySelector("#tab-social ~ label").style.display = "none"; + } + if (!blockingCookies) { + legend.style.gridTemplateAreas = legend.style.gridTemplateAreas.replace( + "cookie", + "" + ); + let radio = document.getElementById("tab-cookie"); + radio.setAttribute("disabled", true); + document.querySelector("#tab-cookie ~ label").style.display = "none"; + } + if (!cryptominingEnabled) { + legend.style.gridTemplateAreas = legend.style.gridTemplateAreas.replace( + "cryptominer", + "" + ); + let radio = document.getElementById("tab-cryptominer"); + radio.setAttribute("disabled", true); + document.querySelector("#tab-cryptominer ~ label").style.display = + "none"; + } + if (!fingerprintingEnabled) { + legend.style.gridTemplateAreas = legend.style.gridTemplateAreas.replace( + "fingerprinter", + "" + ); + let radio = document.getElementById("tab-fingerprinter"); + radio.setAttribute("disabled", true); + document.querySelector("#tab-fingerprinter ~ label").style.display = + "none"; + } + + let firstRadio = document.querySelector("input:enabled"); + // There will be no radio options if we are showing the + firstRadio.checked = true; + document.body.setAttribute("focuseddatatype", firstRadio.dataset.type); + + addListeners(); + } + }; + + let addListeners = () => { + let wrapper = document.querySelector(".body-wrapper"); + let triggerTabClick = ev => { + if (ev.originalTarget.dataset.type) { + document.getElementById(`tab-${ev.target.dataset.type}`).click(); + } + }; + + let triggerTabFocus = ev => { + if (ev.originalTarget.dataset) { + wrapper.classList.add("hover-" + ev.originalTarget.dataset.type); + } + }; + + let triggerTabBlur = ev => { + if (ev.originalTarget.dataset) { + wrapper.classList.remove("hover-" + ev.originalTarget.dataset.type); + } + }; + wrapper.addEventListener("mouseout", triggerTabBlur); + wrapper.addEventListener("mouseover", triggerTabFocus); + wrapper.addEventListener("click", triggerTabClick); + + // Change the class on the body to change the color variable. + let radios = document.querySelectorAll("#legend input"); + for (let radio of radios) { + radio.addEventListener("change", ev => { + document.body.setAttribute("focuseddatatype", ev.target.dataset.type); + }); + radio.addEventListener("focus", ev => { + wrapper.classList.add("hover-" + ev.originalTarget.dataset.type); + document.body.setAttribute("focuseddatatype", ev.target.dataset.type); + }); + radio.addEventListener("blur", ev => { + wrapper.classList.remove("hover-" + ev.originalTarget.dataset.type); + }); + } + }; + + RPMSendQuery("FetchContentBlockingEvents", { + from: weekAgoInMs, + to: todayInMs, + }).then(createGraph); + + let exitIcon = document.querySelector("#mobile-hanger .exit-icon"); + // hide the mobile promotion and keep hidden with a pref. + exitIcon.addEventListener("click", () => { + RPMSetPref("browser.contentblocking.report.show_mobile_app", false); + document.getElementById("mobile-hanger").classList.add("hidden"); + }); + + let androidMobileAppLink = document.getElementById( + "android-mobile-inline-link" + ); + androidMobileAppLink.href = RPMGetStringPref( + "browser.contentblocking.report.mobile-android.url" + ); + androidMobileAppLink.addEventListener("click", () => { + document.sendTelemetryEvent("click", "mobile_app_link", "android"); + }); + let iosMobileAppLink = document.getElementById("ios-mobile-inline-link"); + iosMobileAppLink.href = RPMGetStringPref( + "browser.contentblocking.report.mobile-ios.url" + ); + iosMobileAppLink.addEventListener("click", () => { + document.sendTelemetryEvent("click", "mobile_app_link", "ios"); + }); + + let lockwiseEnabled = RPMGetBoolPref( + "browser.contentblocking.report.lockwise.enabled", + true + ); + + let lockwiseCard; + if (lockwiseEnabled) { + const lockwiseUI = document.querySelector(".lockwise-card"); + lockwiseUI.classList.remove("hidden"); + lockwiseUI.classList.add("loading"); + + lockwiseCard = new LockwiseCard(document); + lockwiseCard.init(); + } + + RPMSendQuery("FetchUserLoginsData", {}).then(data => { + if (lockwiseCard) { + // Once data for the user is retrieved, display the lockwise card. + lockwiseCard.buildContent(data); + } + + if ( + RPMGetBoolPref("browser.contentblocking.report.show_mobile_app") && + !data.mobileDeviceConnected + ) { + document + .getElementById("mobile-hanger") + .classList.toggle("hidden", false); + } + }); + + // For tests + const lockwiseUI = document.querySelector(".lockwise-card"); + lockwiseUI.dataset.enabled = lockwiseEnabled; + + let monitorEnabled = RPMGetBoolPref( + "browser.contentblocking.report.monitor.enabled", + true + ); + if (monitorEnabled) { + // Show the Monitor card. + const monitorUI = document.querySelector(".card.monitor-card.hidden"); + monitorUI.classList.remove("hidden"); + monitorUI.classList.add("loading"); + + const monitorCard = new MonitorCard(document); + monitorCard.init(); + } + + // For tests + const monitorUI = document.querySelector(".monitor-card"); + monitorUI.dataset.enabled = monitorEnabled; + + const proxyEnabled = RPMGetBoolPref( + "browser.contentblocking.report.proxy.enabled", + true + ); + + if (proxyEnabled) { + const proxyCard = new ProxyCard(document); + proxyCard.init(); + } + + // For tests + const proxyUI = document.querySelector(".proxy-card"); + proxyUI.dataset.enabled = proxyEnabled; + + const VPNEnabled = RPMGetBoolPref("browser.vpn_promo.enabled", true); + if (VPNEnabled) { + const vpnCard = new VPNCard(document); + vpnCard.init(); + } + // For tests + const vpnUI = document.querySelector(".vpn-card"); + vpnUI.dataset.enabled = VPNEnabled; +}); diff --git a/browser/components/protections/content/proxy-card.mjs b/browser/components/protections/content/proxy-card.mjs new file mode 100644 index 0000000000..bc6df810b4 --- /dev/null +++ b/browser/components/protections/content/proxy-card.mjs @@ -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 http://mozilla.org/MPL/2.0/. */ + +/* eslint-env mozilla/remote-page */ + +const PROXY_EXTENSION_URL = RPMGetStringPref( + "browser.contentblocking.report.proxy_extension.url", + "" +); + +export default class ProxyCard { + constructor(doc) { + this.doc = doc; + } + + init() { + const proxyExtensionLink = this.doc.getElementById( + "get-proxy-extension-link" + ); + proxyExtensionLink.href = PROXY_EXTENSION_URL; + + // Show the Proxy card + RPMSendQuery("GetShowProxyCard", {}).then(shouldShow => { + const proxyCard = this.doc.querySelector(".proxy-card"); + proxyCard.classList.toggle("hidden", !shouldShow); + }); + } +} diff --git a/browser/components/protections/content/vpn-card.mjs b/browser/components/protections/content/vpn-card.mjs new file mode 100644 index 0000000000..dd2039db2e --- /dev/null +++ b/browser/components/protections/content/vpn-card.mjs @@ -0,0 +1,103 @@ +/* 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/remote-page */ + +export default class VPNCard { + constructor(doc) { + this.doc = doc; + } + + init() { + const vpnLink = this.doc.getElementById("get-vpn-link"); + const vpnBannerLink = this.doc.getElementById("vpn-banner-link"); + vpnLink.href = RPMGetStringPref( + "browser.contentblocking.report.vpn.url", + "" + ); + vpnBannerLink.href = RPMGetStringPref( + "browser.contentblocking.report.vpn-promo.url", + "" + ); + + vpnLink.addEventListener("click", () => { + this.doc.sendTelemetryEvent("click", "vpn_card_link"); + }); + let androidVPNAppLink = document.getElementById( + "vpn-google-playstore-link" + ); + androidVPNAppLink.href = RPMGetStringPref( + "browser.contentblocking.report.vpn-android.url" + ); + androidVPNAppLink.addEventListener("click", () => { + document.sendTelemetryEvent("click", "vpn_app_link_android"); + }); + let iosVPNAppLink = document.getElementById("vpn-app-store-link"); + iosVPNAppLink.href = RPMGetStringPref( + "browser.contentblocking.report.vpn-ios.url" + ); + iosVPNAppLink.addEventListener("click", () => { + document.sendTelemetryEvent("click", "vpn_app_link_ios"); + }); + + const vpnBanner = this.doc.querySelector(".vpn-banner"); + const exitIcon = vpnBanner.querySelector(".exit-icon"); + vpnBannerLink.addEventListener("click", () => { + this.doc.sendTelemetryEvent("click", "vpn_banner_link"); + }); + // User has closed the vpn banner, hide it. + exitIcon.addEventListener("click", () => { + vpnBanner.classList.add("hidden"); + this.doc.sendTelemetryEvent("click", "vpn_banner_close"); + }); + + this.showVPNCard(); + } + + // Show the VPN card if user is located in areas, and on platforms, it serves + async showVPNCard() { + const showVPNBanner = this.showVPNBanner.bind(this); + RPMSendQuery("FetchShowVPNCard", {}).then(shouldShow => { + if (!shouldShow) { + return; + } + const vpnCard = this.doc.querySelector(".vpn-card"); + + // add 'subscribed' class if user is subscribed to vpn + RPMSendQuery("FetchVPNSubStatus", {}).then(async hasVPN => { + if (hasVPN) { + vpnCard.classList.add("subscribed"); + document.l10n.setAttributes( + vpnCard.querySelector(".card-title"), + "vpn-title-subscribed" + ); + + // hide the promo banner if the user is already subscribed to vpn + await RPMSetPref( + "browser.contentblocking.report.hide_vpn_banner", + true + ); + } + + vpnCard.classList.remove("hidden"); + showVPNBanner(); + }); + }); + } + + showVPNBanner() { + if ( + RPMGetBoolPref("browser.contentblocking.report.hide_vpn_banner", false) || + !RPMGetBoolPref("browser.vpn_promo.enabled", false) + ) { + return; + } + + const vpnBanner = this.doc.querySelector(".vpn-banner"); + vpnBanner.classList.remove("hidden"); + this.doc.sendTelemetryEvent("show", "vpn_banner"); + // VPN banner only shows on the first visit, flip a pref so it does not show again. + RPMSetPref("browser.contentblocking.report.hide_vpn_banner", true); + } +} diff --git a/browser/components/protections/jar.mn b/browser/components/protections/jar.mn new file mode 100644 index 0000000000..db8117d2f6 --- /dev/null +++ b/browser/components/protections/jar.mn @@ -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/. + +browser.jar: + content/browser/lockwise-card.mjs (content/lockwise-card.mjs) + content/browser/monitor-card.mjs (content/monitor-card.mjs) + content/browser/protections.css (content/protections.css) + content/browser/protections.html (content/protections.html) + content/browser/protections.mjs (content/protections.mjs) + content/browser/proxy-card.mjs (content/proxy-card.mjs) + content/browser/vpn-card.mjs (content/vpn-card.mjs) diff --git a/browser/components/protections/moz.build b/browser/components/protections/moz.build new file mode 100644 index 0000000000..c79fefcc16 --- /dev/null +++ b/browser/components/protections/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/. + +BROWSER_CHROME_MANIFESTS += ["test/browser/browser.toml"] + +JAR_MANIFESTS += ["jar.mn"] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Protections UI") diff --git a/browser/components/protections/test/browser/browser.toml b/browser/components/protections/test/browser/browser.toml new file mode 100644 index 0000000000..2cea61d997 --- /dev/null +++ b/browser/components/protections/test/browser/browser.toml @@ -0,0 +1,18 @@ +[DEFAULT] +prefs = ["toolkit.telemetry.ipcBatchTimeout=0"] +support-files = [ + "head.js", + "!/browser/base/content/test/protectionsUI/trackingPage.html", +] + +["browser_protections_lockwise.js"] + +["browser_protections_monitor.js"] + +["browser_protections_proxy.js"] + +["browser_protections_report_ui.js"] + +["browser_protections_telemetry.js"] + +["browser_protections_vpn.js"] diff --git a/browser/components/protections/test/browser/browser_protections_lockwise.js b/browser/components/protections/test/browser/browser_protections_lockwise.js new file mode 100644 index 0000000000..72e1795455 --- /dev/null +++ b/browser/components/protections/test/browser/browser_protections_lockwise.js @@ -0,0 +1,290 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +requestLongerTimeout(2); + +const { AboutProtectionsParent } = ChromeUtils.importESModule( + "resource:///actors/AboutProtectionsParent.sys.mjs" +); +const ABOUT_LOGINS_URL = "about:logins"; + +add_task(async function testNoLoginsLockwiseCardUI() { + const tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + const aboutLoginsPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + ABOUT_LOGINS_URL + ); + + info( + "Check that the correct lockwise card content is displayed for non-logged in users." + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition(() => { + const lockwiseCard = content.document.querySelector(".lockwise-card"); + return ContentTaskUtils.isVisible(lockwiseCard); + }, "Lockwise card for user with no logins is visible."); + + const lockwiseHowItWorks = content.document.querySelector( + "#lockwise-how-it-works" + ); + ok( + ContentTaskUtils.isHidden(lockwiseHowItWorks), + "How it works link is hidden" + ); + + const lockwiseHeaderContent = content.document.querySelector( + "#lockwise-header-content span" + ); + await content.document.l10n.translateElements([lockwiseHeaderContent]); + is( + lockwiseHeaderContent.dataset.l10nId, + "passwords-header-content", + "lockwiseHeaderContent contents should match l10n-id attribute set on the element" + ); + + const lockwiseScannedWrapper = content.document.querySelector( + ".lockwise-scanned-wrapper" + ); + ok( + ContentTaskUtils.isHidden(lockwiseScannedWrapper), + "Lockwise scanned wrapper is hidden" + ); + + const managePasswordsButton = content.document.querySelector( + "#manage-passwords-button" + ); + ok( + ContentTaskUtils.isHidden(managePasswordsButton), + "Manage passwords button is hidden" + ); + + const savePasswordsButton = content.document.querySelector( + "#save-passwords-button" + ); + ok( + ContentTaskUtils.isVisible(savePasswordsButton), + "Save passwords button is visible in the header" + ); + info( + "Click on the save passwords button and check that it opens about:logins in a new tab" + ); + savePasswordsButton.click(); + }); + const loginsTab = await aboutLoginsPromise; + info("about:logins was successfully opened in a new tab"); + gBrowser.removeTab(loginsTab); + gBrowser.removeTab(tab); +}); + +add_task(async function testLockwiseCardUIWithLogins() { + const tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + const aboutLoginsPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + ABOUT_LOGINS_URL + ); + + info( + "Add a login and check that lockwise card content for a logged in user is displayed correctly" + ); + await Services.logins.addLoginAsync(TEST_LOGIN1); + await BrowserTestUtils.reloadTab(tab); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition(() => { + const hasLogins = content.document.querySelector(".lockwise-card"); + return ContentTaskUtils.isVisible(hasLogins); + }, "Lockwise card for user with logins is visible"); + + const lockwiseTitle = content.document.querySelector("#lockwise-title"); + await content.document.l10n.translateElements([lockwiseTitle]); + await ContentTaskUtils.waitForCondition( + () => lockwiseTitle.textContent == "Manage your passwords", + "Waiting for Fluent to provide the title translation" + ); + is( + lockwiseTitle.textContent, + "Manage your passwords", + "Correct passwords title is shown" + ); + + const lockwiseHowItWorks = content.document.querySelector( + "#lockwise-how-it-works" + ); + ok( + ContentTaskUtils.isVisible(lockwiseHowItWorks), + "How it works link is visible" + ); + + const lockwiseHeaderContent = content.document.querySelector( + "#lockwise-header-content span" + ); + await content.document.l10n.translateElements([lockwiseHeaderContent]); + is( + lockwiseHeaderContent.dataset.l10nId, + "lockwise-header-content-logged-in", + "lockwiseHeaderContent contents should match l10n-id attribute set on the element" + ); + + const lockwiseScannedWrapper = content.document.querySelector( + ".lockwise-scanned-wrapper" + ); + ok( + ContentTaskUtils.isVisible(lockwiseScannedWrapper), + "Lockwise scanned wrapper is visible" + ); + + const lockwiseScannedText = content.document.querySelector( + "#lockwise-scanned-text" + ); + await content.document.l10n.translateElements([lockwiseScannedText]); + is( + lockwiseScannedText.textContent, + "1 password stored securely.", + "Correct lockwise scanned text is shown" + ); + + const savePasswordsButton = content.document.querySelector( + "#save-passwords-button" + ); + ok( + ContentTaskUtils.isHidden(savePasswordsButton), + "Save passwords button is hidden" + ); + + const managePasswordsButton = content.document.querySelector( + "#manage-passwords-button" + ); + ok( + ContentTaskUtils.isVisible(managePasswordsButton), + "Manage passwords button is visible" + ); + info( + "Click on the manage passwords button and check that it opens about:logins in a new tab" + ); + managePasswordsButton.click(); + }); + const loginsTab = await aboutLoginsPromise; + info("about:logins was successfully opened in a new tab"); + gBrowser.removeTab(loginsTab); + + info( + "Add another login and check that the scanned text about stored logins is updated after reload." + ); + await Services.logins.addLoginAsync(TEST_LOGIN2); + await BrowserTestUtils.reloadTab(tab); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const lockwiseScannedText = content.document.querySelector( + "#lockwise-scanned-text" + ).textContent; + ContentTaskUtils.waitForCondition( + () => + lockwiseScannedText.textContent == + "Your passwords are being stored securely.", + "Correct lockwise scanned text is shown" + ); + }); + + Services.logins.removeLogin(TEST_LOGIN1); + Services.logins.removeLogin(TEST_LOGIN2); + + gBrowser.removeTab(tab); +}); + +add_task(async function testLockwiseCardUIWithBreachedLogins() { + info( + "Add a breached login and test that the lockwise scanned text is displayed correctly" + ); + const tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + await Services.logins.addLoginAsync(TEST_LOGIN1); + + info("Mock monitor data with a breached login to test the Lockwise UI"); + AboutProtectionsParent.setTestOverride( + mockGetLoginDataWithSyncedDevices(false, 1) + ); + await BrowserTestUtils.reloadTab(tab); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const lockwiseScannedText = content.document.querySelector( + "#lockwise-scanned-text" + ); + ok( + ContentTaskUtils.isVisible(lockwiseScannedText), + "Lockwise scanned text is visible" + ); + await ContentTaskUtils.waitForCondition( + () => + lockwiseScannedText.textContent == + "1 password may have been exposed in a data breach." + ); + info("Correct lockwise scanned text is shown"); + }); + + info( + "Mock monitor data with more than one breached logins to test the Lockwise UI" + ); + AboutProtectionsParent.setTestOverride( + mockGetLoginDataWithSyncedDevices(false, 2) + ); + await BrowserTestUtils.reloadTab(tab); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const lockwiseScannedText = content.document.querySelector( + "#lockwise-scanned-text" + ); + ok( + ContentTaskUtils.isVisible(lockwiseScannedText), + "Lockwise scanned text is visible" + ); + await ContentTaskUtils.waitForCondition( + () => + lockwiseScannedText.textContent == + "2 passwords may have been exposed in a data breach." + ); + info("Correct lockwise scanned text is shown"); + }); + + AboutProtectionsParent.setTestOverride(null); + Services.logins.removeLogin(TEST_LOGIN1); + gBrowser.removeTab(tab); +}); + +add_task(async function testLockwiseCardPref() { + const tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + + info("Disable showing the Lockwise card."); + Services.prefs.setBoolPref( + "browser.contentblocking.report.lockwise.enabled", + false + ); + await BrowserTestUtils.reloadTab(tab); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const lockwiseCard = content.document.querySelector(".lockwise-card"); + await ContentTaskUtils.waitForCondition(() => { + return !lockwiseCard["data-enabled"]; + }, "Lockwise card is not enabled."); + + ok(ContentTaskUtils.isHidden(lockwiseCard), "Lockwise card is hidden."); + }); + + // Set the pref back to displaying the card. + Services.prefs.setBoolPref( + "browser.contentblocking.report.lockwise.enabled", + true + ); + gBrowser.removeTab(tab); +}); diff --git a/browser/components/protections/test/browser/browser_protections_monitor.js b/browser/components/protections/test/browser/browser_protections_monitor.js new file mode 100644 index 0000000000..b24d8de55c --- /dev/null +++ b/browser/components/protections/test/browser/browser_protections_monitor.js @@ -0,0 +1,161 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { AboutProtectionsParent } = ChromeUtils.importESModule( + "resource:///actors/AboutProtectionsParent.sys.mjs" +); + +const monitorErrorData = { + error: true, +}; + +const mockMonitorData = { + numBreaches: 11, + numBreachesResolved: 0, +}; + +add_task(async function () { + const tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + + await BrowserTestUtils.reloadTab(tab); + + const monitorCardEnabled = Services.prefs.getBoolPref( + "browser.contentblocking.report.monitor.enabled" + ); + + // Only run monitor card tests if it's enabled. + if (monitorCardEnabled) { + info( + "Check that the correct content is displayed for users with no logins." + ); + await checkNoLoginsContentIsDisplayed(tab, "monitor-sign-up"); + + info( + "Check that the correct content is displayed for users with monitor data." + ); + await Services.logins.addLoginAsync(TEST_LOGIN1); + AboutProtectionsParent.setTestOverride(mockGetMonitorData(mockMonitorData)); + await BrowserTestUtils.reloadTab(tab); + + Assert.ok( + true, + "Error was not thrown for trying to reach the Monitor endpoint, the cache has worked." + ); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition(() => { + const hasLogins = content.document.querySelector( + ".monitor-card.has-logins" + ); + return hasLogins && ContentTaskUtils.isVisible(hasLogins); + }, "Monitor card for user with stored logins is shown."); + + const hasLoginsHeaderContent = content.document.querySelector( + "#monitor-header-content span" + ); + const cardBody = content.document.querySelector( + ".monitor-card .card-body" + ); + + ok( + ContentTaskUtils.isVisible(cardBody), + "Card body is shown for users monitor data." + ); + await ContentTaskUtils.waitForCondition(() => { + return ( + hasLoginsHeaderContent.textContent == + "Firefox Monitor warns you if your info has appeared in a known data breach." + ); + }, "Header content for user with monitor data is correct."); + + info("Make sure correct numbers for monitor stats are displayed."); + const emails = content.document.querySelector( + ".monitor-stat span[data-type='stored-emails']" + ); + const passwords = content.document.querySelector( + ".monitor-stat span[data-type='exposed-passwords']" + ); + const breaches = content.document.querySelector( + ".monitor-stat span[data-type='known-breaches']" + ); + + is(emails.textContent, 1, "1 monitored email is displayed"); + is(passwords.textContent, 8, "8 exposed passwords are displayed"); + is(breaches.textContent, 11, "11 known data breaches are displayed."); + }); + + info( + "Check that correct content is displayed when monitor data contains an error message." + ); + AboutProtectionsParent.setTestOverride( + mockGetMonitorData(monitorErrorData) + ); + await BrowserTestUtils.reloadTab(tab); + await checkNoLoginsContentIsDisplayed(tab); + + info("Disable showing the Monitor card."); + Services.prefs.setBoolPref( + "browser.contentblocking.report.monitor.enabled", + false + ); + await BrowserTestUtils.reloadTab(tab); + } + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition(() => { + const monitorCard = content.document.querySelector(".monitor-card"); + return !monitorCard["data-enabled"]; + }, "Monitor card is not enabled."); + + const monitorCard = content.document.querySelector(".monitor-card"); + ok(ContentTaskUtils.isHidden(monitorCard), "Monitor card is hidden."); + }); + + if (monitorCardEnabled) { + // set the pref back to displaying the card. + Services.prefs.setBoolPref( + "browser.contentblocking.report.monitor.enabled", + true + ); + + // remove logins + Services.logins.removeLogin(TEST_LOGIN1); + + // restore original test functions + AboutProtectionsParent.setTestOverride(null); + } + + await BrowserTestUtils.removeTab(tab); +}); + +async function checkNoLoginsContentIsDisplayed(tab, expectedLinkContent) { + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition(() => { + const noLogins = content.document.querySelector( + ".monitor-card.no-logins" + ); + return noLogins && ContentTaskUtils.isVisible(noLogins); + }, "Monitor card for user with no logins is shown."); + + const noLoginsHeaderContent = content.document.querySelector( + "#monitor-header-content span" + ); + const cardBody = content.document.querySelector(".monitor-card .card-body"); + + ok( + ContentTaskUtils.isHidden(cardBody), + "Card body is hidden for users with no logins." + ); + is( + noLoginsHeaderContent.getAttribute("data-l10n-id"), + "monitor-header-content-no-account", + "Header content for user with no logins is correct" + ); + }); +} diff --git a/browser/components/protections/test/browser/browser_protections_proxy.js b/browser/components/protections/test/browser/browser_protections_proxy.js new file mode 100644 index 0000000000..b5d5e396f5 --- /dev/null +++ b/browser/components/protections/test/browser/browser_protections_proxy.js @@ -0,0 +1,107 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Region: "resource://gre/modules/Region.sys.mjs", +}); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.contentblocking.report.monitor.enabled", false], + ["browser.contentblocking.report.lockwise.enabled", false], + ["browser.vpn_promo.enabled", false], + ], + }); +}); + +add_task(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + + info("Secure Proxy card should be hidden by default"); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition(() => { + const proxyCard = content.document.querySelector(".proxy-card"); + return !proxyCard["data-enabled"]; + }, "Proxy card is not enabled."); + + const proxyCard = content.document.querySelector(".proxy-card"); + ok(ContentTaskUtils.isHidden(proxyCard), "Proxy card is hidden."); + }); + + info("Enable showing the Secure Proxy card"); + Services.prefs.setBoolPref( + "browser.contentblocking.report.proxy.enabled", + true + ); + + info( + "Check that secure proxy card is hidden if user's language is not en-US" + ); + Services.prefs.setCharPref("intl.accept_languages", "en-CA"); + await BrowserTestUtils.reloadTab(tab); + await checkProxyCardVisibility(tab, true); + + info( + "Check that secure proxy card is shown if user's location is in the US." + ); + // Set language back to en-US + Services.prefs.setCharPref("intl.accept_languages", "en-US"); + Region._setHomeRegion("US", false); + await BrowserTestUtils.reloadTab(tab); + await checkProxyCardVisibility(tab, false); + + info( + "Check that secure proxy card is hidden if user's location is not in the US." + ); + Region._setHomeRegion("CA", false); + await BrowserTestUtils.reloadTab(tab); + await checkProxyCardVisibility(tab, true); + + info( + "Check that secure proxy card is hidden if the extension is already installed." + ); + // Make sure we set the region back to "US" + Region._setHomeRegion("US", false); + const id = "secure-proxy@mozilla.com"; + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id } }, + name: "Firefox Proxy", + }, + useAddonManager: "temporary", + }); + await extension.startup(); + await BrowserTestUtils.reloadTab(tab); + await checkProxyCardVisibility(tab, true); + await extension.unload(); + + Services.prefs.setBoolPref( + "browser.contentblocking.report.proxy.enabled", + false + ); + + await BrowserTestUtils.removeTab(tab); +}); + +async function checkProxyCardVisibility(tab, shouldBeHidden) { + await SpecialPowers.spawn( + tab.linkedBrowser, + [{ _shouldBeHidden: shouldBeHidden }], + async function ({ _shouldBeHidden }) { + await ContentTaskUtils.waitForCondition(() => { + const proxyCard = content.document.querySelector(".proxy-card"); + return ContentTaskUtils.isHidden(proxyCard) === _shouldBeHidden; + }); + + const visibilityState = _shouldBeHidden ? "hidden" : "shown"; + ok(true, `Proxy card is ${visibilityState}.`); + } + ); +} diff --git a/browser/components/protections/test/browser/browser_protections_report_ui.js b/browser/components/protections/test/browser/browser_protections_report_ui.js new file mode 100644 index 0000000000..bc099a10a9 --- /dev/null +++ b/browser/components/protections/test/browser/browser_protections_report_ui.js @@ -0,0 +1,1129 @@ +/* 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/. */ + +// Note: This test may cause intermittents if run at exactly midnight. + +const { Sqlite } = ChromeUtils.importESModule( + "resource://gre/modules/Sqlite.sys.mjs" +); +const { AboutProtectionsParent } = ChromeUtils.importESModule( + "resource:///actors/AboutProtectionsParent.sys.mjs" +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "TrackingDBService", + "@mozilla.org/tracking-db-service;1", + "nsITrackingDBService" +); + +ChromeUtils.defineLazyGetter(this, "DB_PATH", function () { + return PathUtils.join(PathUtils.profileDir, "protections.sqlite"); +}); + +const SQL = { + insertCustomTimeEvent: + "INSERT INTO events (type, count, timestamp)" + + "VALUES (:type, :count, date(:timestamp));", + + selectAll: "SELECT * FROM events", +}; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.contentblocking.database.enabled", true], + ["browser.vpn_promo.enabled", false], + ], + }); +}); + +add_task(async function test_graph_display() { + // This creates the schema. + await TrackingDBService.saveEvents(JSON.stringify({})); + let db = await Sqlite.openConnection({ path: DB_PATH }); + + let date = new Date().toISOString(); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.TRACKERS_ID, + count: 1, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.CRYPTOMINERS_ID, + count: 2, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.FINGERPRINTERS_ID, + count: 2, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.TRACKING_COOKIES_ID, + count: 4, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.SOCIAL_ID, + count: 1, + timestamp: date, + }); + + date = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.TRACKERS_ID, + count: 4, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.CRYPTOMINERS_ID, + count: 3, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.FINGERPRINTERS_ID, + count: 2, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.SOCIAL_ID, + count: 1, + timestamp: date, + }); + + date = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.TRACKERS_ID, + count: 4, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.CRYPTOMINERS_ID, + count: 3, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.TRACKING_COOKIES_ID, + count: 1, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.SOCIAL_ID, + count: 1, + timestamp: date, + }); + + date = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.TRACKERS_ID, + count: 3, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.FINGERPRINTERS_ID, + count: 2, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.TRACKING_COOKIES_ID, + count: 1, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.SOCIAL_ID, + count: 1, + timestamp: date, + }); + + date = new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.CRYPTOMINERS_ID, + count: 2, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.FINGERPRINTERS_ID, + count: 2, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.TRACKING_COOKIES_ID, + count: 1, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.SOCIAL_ID, + count: 1, + timestamp: date, + }); + + date = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.TRACKERS_ID, + count: 3, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.CRYPTOMINERS_ID, + count: 3, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.FINGERPRINTERS_ID, + count: 2, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.TRACKING_COOKIES_ID, + count: 8, + timestamp: date, + }); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const DATA_TYPES = [ + "cryptominer", + "fingerprinter", + "tracker", + "cookie", + "social", + ]; + let allBars = null; + await ContentTaskUtils.waitForCondition(() => { + allBars = content.document.querySelectorAll(".graph-bar"); + return allBars.length; + }, "The graph has been built"); + + Assert.equal(allBars.length, 7, "7 bars have been found on the graph"); + + // For accessibility, test if the graph is a table + // and has a correct column count (number of data types + total + day) + Assert.equal( + content.document.getElementById("graph").getAttribute("role"), + "table", + "Graph is an accessible table" + ); + Assert.equal( + content.document.getElementById("graph").getAttribute("aria-colcount"), + DATA_TYPES.length + 2, + "Table has the right number of columns" + ); + Assert.equal( + content.document.getElementById("graph").getAttribute("aria-labelledby"), + "graphLegendDescription", + "Table has an accessible label" + ); + + // today has each type + // yesterday will have no tracking cookies + // 2 days ago will have no fingerprinters + // 3 days ago will have no cryptominers + // 4 days ago will have no trackers + // 5 days ago will have no social (when we add social) + // 6 days ago will be empty + Assert.equal( + allBars[6].querySelectorAll(".inner-bar").length, + DATA_TYPES.length, + "today has all of the data types shown" + ); + Assert.equal( + allBars[6].getAttribute("role"), + "row", + "Today has the correct role" + ); + Assert.equal( + allBars[6].getAttribute("aria-owns"), + "day0 count0 cryptominer0 fingerprinter0 tracker0 cookie0 social0", + "Row has the columns in the right order" + ); + Assert.equal( + allBars[6].querySelector(".tracker-bar").style.height, + "10%", + "trackers take 10%" + ); + Assert.equal( + allBars[6].querySelector(".tracker-bar").parentNode.getAttribute("role"), + "cell", + "Trackers have the correct role" + ); + Assert.equal( + allBars[6].querySelector(".tracker-bar").getAttribute("role"), + "img", + "Tracker bar has the correct image role" + ); + Assert.equal( + allBars[6].querySelector(".tracker-bar").getAttribute("aria-label"), + "1 tracking content (10%)", + "Trackers have the correct accessible text" + ); + Assert.equal( + allBars[6].querySelector(".cryptominer-bar").style.height, + "20%", + "cryptominers take 20%" + ); + Assert.equal( + allBars[6] + .querySelector(".cryptominer-bar") + .parentNode.getAttribute("role"), + "cell", + "Cryptominers have the correct role" + ); + Assert.equal( + allBars[6].querySelector(".cryptominer-bar").getAttribute("role"), + "img", + "Cryptominer bar has the correct image role" + ); + Assert.equal( + allBars[6].querySelector(".cryptominer-bar").getAttribute("aria-label"), + "2 cryptominers (20%)", + "Cryptominers have the correct accessible label" + ); + Assert.equal( + allBars[6].querySelector(".fingerprinter-bar").style.height, + "20%", + "fingerprinters take 20%" + ); + Assert.equal( + allBars[6] + .querySelector(".fingerprinter-bar") + .parentNode.getAttribute("role"), + "cell", + "Fingerprinters have the correct role" + ); + Assert.equal( + allBars[6].querySelector(".fingerprinter-bar").getAttribute("role"), + "img", + "Fingerprinter bar has the correct image role" + ); + Assert.equal( + allBars[6].querySelector(".fingerprinter-bar").getAttribute("aria-label"), + "2 fingerprinters (20%)", + "Fingerprinters have the correct accessible label" + ); + Assert.equal( + allBars[6].querySelector(".cookie-bar").style.height, + "40%", + "cross site tracking cookies take 40%" + ); + Assert.equal( + allBars[6].querySelector(".cookie-bar").parentNode.getAttribute("role"), + "cell", + "cross site tracking cookies have the correct role" + ); + Assert.equal( + allBars[6].querySelector(".cookie-bar").getAttribute("role"), + "img", + "Cross site tracking cookies bar has the correct image role" + ); + Assert.equal( + allBars[6].querySelector(".cookie-bar").getAttribute("aria-label"), + "4 cross-site tracking cookies (40%)", + "cross site tracking cookies have the correct accessible label" + ); + Assert.equal( + allBars[6].querySelector(".social-bar").style.height, + "10%", + "social trackers take 10%" + ); + Assert.equal( + allBars[6].querySelector(".social-bar").parentNode.getAttribute("role"), + "cell", + "social trackers have the correct role" + ); + Assert.equal( + allBars[6].querySelector(".social-bar").getAttribute("role"), + "img", + "social tracker bar has the correct image role" + ); + Assert.equal( + allBars[6].querySelector(".social-bar").getAttribute("aria-label"), + "1 social media tracker (10%)", + "social trackers have the correct accessible text" + ); + + Assert.equal( + allBars[5].querySelectorAll(".inner-bar").length, + DATA_TYPES.length - 1, + "1 day ago is missing one type" + ); + Assert.ok( + !allBars[5].querySelector(".cookie-bar"), + "there is no cross site tracking cookie section 1 day ago." + ); + Assert.equal( + allBars[5].getAttribute("aria-owns"), + "day1 count1 cryptominer1 fingerprinter1 tracker1 social1", + "Row has the columns in the right order" + ); + + Assert.equal( + allBars[4].querySelectorAll(".inner-bar").length, + DATA_TYPES.length - 1, + "2 days ago is missing one type" + ); + Assert.ok( + !allBars[4].querySelector(".fingerprinter-bar"), + "there is no fingerprinter section 1 day ago." + ); + Assert.equal( + allBars[4].getAttribute("aria-owns"), + "day2 count2 cryptominer2 tracker2 cookie2 social2", + "Row has the columns in the right order" + ); + + Assert.equal( + allBars[3].querySelectorAll(".inner-bar").length, + DATA_TYPES.length - 1, + "3 days ago is missing one type" + ); + Assert.ok( + !allBars[3].querySelector(".cryptominer-bar"), + "there is no cryptominer section 1 day ago." + ); + Assert.equal( + allBars[3].getAttribute("aria-owns"), + "day3 count3 fingerprinter3 tracker3 cookie3 social3", + "Row has the columns in the right order" + ); + + Assert.equal( + allBars[2].querySelectorAll(".inner-bar").length, + DATA_TYPES.length - 1, + "4 days ago is missing one type" + ); + Assert.ok( + !allBars[2].querySelector(".tracker-bar"), + "there is no tracker section 1 day ago." + ); + Assert.equal( + allBars[2].getAttribute("aria-owns"), + "day4 count4 cryptominer4 fingerprinter4 cookie4 social4", + "Row has the columns in the right order" + ); + + Assert.equal( + allBars[1].querySelectorAll(".inner-bar").length, + DATA_TYPES.length - 1, + "5 days ago is missing one type" + ); + Assert.ok( + !allBars[1].querySelector(".social-bar"), + "there is no social section 1 day ago." + ); + Assert.equal( + allBars[1].getAttribute("aria-owns"), + "day5 count5 cryptominer5 fingerprinter5 tracker5 cookie5", + "Row has the columns in the right order" + ); + + Assert.equal( + allBars[0].querySelectorAll(".inner-bar").length, + 0, + "6 days ago has no content" + ); + Assert.ok( + allBars[0].classList.contains("empty"), + "6 days ago is an empty bar" + ); + Assert.equal( + allBars[0].getAttribute("aria-owns"), + "day6 ", + "Row has the columns in the right order" + ); + + // Check that each tab has the correct aria-labelledby and aria-describedby + // values. This helps screen readers know what type of tracker the reported + // tab number is referencing. + const socialTab = content.document.getElementById("tab-social"); + Assert.equal( + socialTab.getAttribute("aria-labelledby"), + "socialLabel socialTitle", + "aria-labelledby attribute is socialLabel socialTitle" + ); + Assert.equal( + socialTab.getAttribute("aria-describedby"), + "socialContent", + "aria-describedby attribute is socialContent" + ); + + const cookieTab = content.document.getElementById("tab-cookie"); + Assert.equal( + cookieTab.getAttribute("aria-labelledby"), + "cookieLabel cookieTitle", + "aria-labelledby attribute is cookieLabel cookieTitle" + ); + Assert.equal( + cookieTab.getAttribute("aria-describedby"), + "cookieContent", + "aria-describedby attribute is cookieContent" + ); + + const trackerTab = content.document.getElementById("tab-tracker"); + Assert.equal( + trackerTab.getAttribute("aria-labelledby"), + "trackerLabel trackerTitle", + "aria-labelledby attribute is trackerLabel trackerTitle" + ); + Assert.equal( + trackerTab.getAttribute("aria-describedby"), + "trackerContent", + "aria-describedby attribute is trackerContent" + ); + + const fingerprinterTab = + content.document.getElementById("tab-fingerprinter"); + Assert.equal( + fingerprinterTab.getAttribute("aria-labelledby"), + "fingerprinterLabel fingerprinterTitle", + "aria-labelledby attribute is fingerprinterLabel fingerprinterTitle" + ); + Assert.equal( + fingerprinterTab.getAttribute("aria-describedby"), + "fingerprinterContent", + "aria-describedby attribute is fingerprinterContent" + ); + + const cryptominerTab = content.document.getElementById("tab-cryptominer"); + Assert.equal( + cryptominerTab.getAttribute("aria-labelledby"), + "cryptominerLabel cryptominerTitle", + "aria-labelledby attribute is cryptominerLabel cryptominerTitle" + ); + Assert.equal( + cryptominerTab.getAttribute("aria-describedby"), + "cryptominerContent", + "aria-describedby attribute is cryptominerContent" + ); + }); + + // Use the TrackingDBService API to delete the data. + await TrackingDBService.clearAll(); + // Make sure the data was deleted. + let rows = await db.execute(SQL.selectAll); + is(rows.length, 0, "length is 0"); + await db.close(); + BrowserTestUtils.removeTab(tab); +}); + +// Ensure that the number of suspicious fingerprinter is aggregated into the +// fingerprinter category on about:protection page. +add_task(async function test_suspicious_fingerprinter() { + // This creates the schema. + await TrackingDBService.saveEvents(JSON.stringify({})); + let db = await Sqlite.openConnection({ path: DB_PATH }); + + // Inserting data for today. It won't contain a fingerprinter entry but only + // a suspicious fingerprinter entry. + let date = new Date().toISOString(); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.TRACKERS_ID, + count: 1, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.CRYPTOMINERS_ID, + count: 2, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.SUSPICIOUS_FINGERPRINTERS_ID, + count: 2, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.TRACKING_COOKIES_ID, + count: 4, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.SOCIAL_ID, + count: 1, + timestamp: date, + }); + + // Inserting data for 1 day age. It contains both a fingerprinter entry and + // a suspicious fingerprinter entry. + date = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.TRACKERS_ID, + count: 1, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.CRYPTOMINERS_ID, + count: 2, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.FINGERPRINTERS_ID, + count: 1, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.SUSPICIOUS_FINGERPRINTERS_ID, + count: 1, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.TRACKING_COOKIES_ID, + count: 4, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.SOCIAL_ID, + count: 1, + timestamp: date, + }); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const DATA_TYPES = [ + "cryptominer", + "fingerprinter", + "tracker", + "cookie", + "social", + ]; + let allBars = null; + await ContentTaskUtils.waitForMutationCondition( + content.document.body, + { childList: true, subtree: true }, + () => { + allBars = content.document.querySelectorAll(".graph-bar"); + return !!allBars.length; + } + ); + info("The graph has been built"); + + Assert.equal(allBars.length, 7, "7 bars have been found on the graph"); + + // Verify today's data. The fingerprinter category should take 20%. + Assert.equal( + allBars[6].querySelectorAll(".inner-bar").length, + DATA_TYPES.length, + "today has all of the data types shown" + ); + Assert.equal( + allBars[6].querySelector(".tracker-bar").style.height, + "10%", + "trackers take 10%" + ); + Assert.equal( + allBars[6].querySelector(".cryptominer-bar").style.height, + "20%", + "cryptominers take 20%" + ); + Assert.equal( + allBars[6].querySelector(".fingerprinter-bar").style.height, + "20%", + "fingerprinters take 20%" + ); + Assert.equal( + allBars[6].querySelector(".cookie-bar").style.height, + "40%", + "cross site tracking cookies take 40%" + ); + Assert.equal( + allBars[6].querySelector(".social-bar").style.height, + "10%", + "social trackers take 10%" + ); + + // Verify one day age data. The fingerprinter category should take 20%. + Assert.equal( + allBars[5].querySelectorAll(".inner-bar").length, + DATA_TYPES.length, + "today has all of the data types shown" + ); + Assert.equal( + allBars[5].querySelector(".tracker-bar").style.height, + "10%", + "trackers take 10%" + ); + Assert.equal( + allBars[5].querySelector(".cryptominer-bar").style.height, + "20%", + "cryptominers take 20%" + ); + Assert.equal( + allBars[5].querySelector(".fingerprinter-bar").style.height, + "20%", + "fingerprinters take 20%" + ); + Assert.equal( + allBars[5].querySelector(".cookie-bar").style.height, + "40%", + "cross site tracking cookies take 40%" + ); + Assert.equal( + allBars[5].querySelector(".social-bar").style.height, + "10%", + "social trackers take 10%" + ); + }); + + // Use the TrackingDBService API to delete the data. + await TrackingDBService.clearAll(); + // Make sure the data was deleted. + let rows = await db.execute(SQL.selectAll); + is(rows.length, 0, "length is 0"); + await db.close(); + BrowserTestUtils.removeTab(tab); +}); + +// Ensure that the number of suspicious fingerprinter is displayed even if the +// fingerprinter blocking is disabled. +add_task(async function test_suspicious_fingerprinter_without_fp_blocking() { + // Disable fingerprinter blocking + Services.prefs.setBoolPref( + "privacy.trackingprotection.fingerprinting.enabled", + false + ); + + // This creates the schema. + await TrackingDBService.saveEvents(JSON.stringify({})); + let db = await Sqlite.openConnection({ path: DB_PATH }); + + // Inserting data for today. It won't contain a fingerprinter entry but only + // a suspicious fingerprinter entry. + let date = new Date().toISOString(); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.TRACKERS_ID, + count: 1, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.CRYPTOMINERS_ID, + count: 2, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.SUSPICIOUS_FINGERPRINTERS_ID, + count: 2, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.TRACKING_COOKIES_ID, + count: 4, + timestamp: date, + }); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.SOCIAL_ID, + count: 1, + timestamp: date, + }); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const DATA_TYPES = [ + "cryptominer", + "fingerprinter", + "tracker", + "cookie", + "social", + ]; + let allBars = null; + await ContentTaskUtils.waitForMutationCondition( + content.document.body, + { childList: true, subtree: true }, + () => { + allBars = content.document.querySelectorAll(".graph-bar"); + return !!allBars.length; + } + ); + info("The graph has been built"); + + Assert.equal(allBars.length, 7, "7 bars have been found on the graph"); + + // Verify today's data. The fingerprinter category should take 20%. + Assert.equal( + allBars[6].querySelectorAll(".inner-bar").length, + DATA_TYPES.length, + "today has all of the data types shown" + ); + Assert.equal( + allBars[6].querySelector(".tracker-bar").style.height, + "10%", + "trackers take 10%" + ); + Assert.equal( + allBars[6].querySelector(".cryptominer-bar").style.height, + "20%", + "cryptominers take 20%" + ); + Assert.equal( + allBars[6].querySelector(".fingerprinter-bar").style.height, + "20%", + "fingerprinters take 20%" + ); + Assert.equal( + allBars[6].querySelector(".cookie-bar").style.height, + "40%", + "cross site tracking cookies take 40%" + ); + Assert.equal( + allBars[6].querySelector(".social-bar").style.height, + "10%", + "social trackers take 10%" + ); + }); + + // Use the TrackingDBService API to delete the data. + await TrackingDBService.clearAll(); + // Make sure the data was deleted. + let rows = await db.execute(SQL.selectAll); + is(rows.length, 0, "length is 0"); + await db.close(); + BrowserTestUtils.removeTab(tab); + + Services.prefs.clearUserPref( + "privacy.trackingprotection.fingerprinting.enabled" + ); +}); + +// Ensure that each type of tracker is hidden from the graph if there are no recorded +// trackers of that type and the user has chosen to not block that type. +add_task(async function test_etp_custom_settings() { + Services.prefs.setStringPref("browser.contentblocking.category", "strict"); + Services.prefs.setBoolPref( + "privacy.socialtracking.block_cookies.enabled", + true + ); + // hide cookies from the graph + Services.prefs.setIntPref("network.cookie.cookieBehavior", 0); + let tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition(() => { + let legend = content.document.getElementById("legend"); + return ContentTaskUtils.isVisible(legend); + }, "The legend is visible"); + + let label = content.document.getElementById("cookieLabel"); + Assert.ok(ContentTaskUtils.isHidden(label), "Cookie Label is hidden"); + + label = content.document.getElementById("trackerLabel"); + Assert.ok(ContentTaskUtils.isVisible(label), "Tracker Label is visible"); + label = content.document.getElementById("socialLabel"); + Assert.ok(ContentTaskUtils.isVisible(label), "Social Label is visible"); + label = content.document.getElementById("cryptominerLabel"); + Assert.ok( + ContentTaskUtils.isVisible(label), + "Cryptominer Label is visible" + ); + label = content.document.getElementById("fingerprinterLabel"); + Assert.ok( + ContentTaskUtils.isVisible(label), + "Fingerprinter Label is visible" + ); + }); + BrowserTestUtils.removeTab(tab); + + // hide ad trackers from the graph + Services.prefs.setBoolPref("privacy.trackingprotection.enabled", false); + tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition(() => { + let legend = content.document.getElementById("legend"); + return ContentTaskUtils.isVisible(legend); + }, "The legend is visible"); + + let label = content.document.querySelector("#trackerLabel"); + Assert.ok(ContentTaskUtils.isHidden(label), "Tracker Label is hidden"); + + label = content.document.querySelector("#socialLabel"); + Assert.ok(ContentTaskUtils.isHidden(label), "Social Label is hidden"); + }); + BrowserTestUtils.removeTab(tab); + + // hide social from the graph + Services.prefs.setBoolPref( + "privacy.trackingprotection.socialtracking.enabled", + false + ); + Services.prefs.setBoolPref( + "privacy.socialtracking.block_cookies.enabled", + false + ); + tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition(() => { + let legend = content.document.getElementById("legend"); + return ContentTaskUtils.isVisible(legend); + }, "The legend is visible"); + + let label = content.document.querySelector("#socialLabel"); + Assert.ok(ContentTaskUtils.isHidden(label), "Social Label is hidden"); + }); + BrowserTestUtils.removeTab(tab); + + // hide fingerprinting from the graph + Services.prefs.setBoolPref( + "privacy.trackingprotection.fingerprinting.enabled", + false + ); + Services.prefs.setBoolPref("privacy.fingerprintingProtection", false); + tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition(() => { + let legend = content.document.getElementById("legend"); + return ContentTaskUtils.isVisible(legend); + }, "The legend is visible"); + + let label = content.document.querySelector("#fingerprinterLabel"); + Assert.ok( + ContentTaskUtils.isHidden(label), + "Fingerprinter Label is hidden" + ); + }); + BrowserTestUtils.removeTab(tab); + + // hide cryptomining from the graph + Services.prefs.setBoolPref( + "privacy.trackingprotection.cryptomining.enabled", + false + ); + // Turn fingerprinting on so that all protectionsare not turned off, otherwise we will get a special card. + Services.prefs.setBoolPref( + "privacy.trackingprotection.fingerprinting.enabled", + true + ); + tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition(() => { + let legend = content.document.getElementById("legend"); + return ContentTaskUtils.isVisible(legend); + }, "The legend is visible"); + + let label = content.document.querySelector("#cryptominerLabel"); + Assert.ok(ContentTaskUtils.isHidden(label), "Cryptominer Label is hidden"); + }); + Services.prefs.clearUserPref("browser.contentblocking.category"); + Services.prefs.clearUserPref( + "privacy.trackingprotection.fingerprinting.enabled" + ); + Services.prefs.clearUserPref("privacy.fingerprintingProtection"); + Services.prefs.clearUserPref( + "privacy.trackingprotection.cryptomining.enabled" + ); + Services.prefs.clearUserPref("privacy.trackingprotection.enabled"); + Services.prefs.clearUserPref("network.cookie.cookieBehavior"); + Services.prefs.clearUserPref("privacy.socialtracking.block_cookies.enabled"); + + BrowserTestUtils.removeTab(tab); +}); + +// Ensure that the Custom manage Protections card is shown if the user has all protections turned off. +add_task(async function test_etp_custom_protections_off() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.contentblocking.category", "custom"], + ["network.cookie.cookieBehavior", 0], // not blocking + ["privacy.trackingprotection.cryptomining.enabled", false], // not blocking + ["privacy.trackingprotection.fingerprinting.enabled", false], + ["privacy.trackingprotection.enabled", false], + ["privacy.trackingprotection.socialtracking.enabled", false], + ["privacy.socialtracking.block_cookies.enabled", false], + ], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + + let aboutPreferencesPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:preferences#privacy" + ); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition(() => { + let etpCard = content.document.querySelector(".etp-card"); + return etpCard.classList.contains("custom-not-blocking"); + }, "The custom protections warning card is showing"); + + let manageProtectionsButton = + content.document.getElementById("manage-protections"); + Assert.ok( + ContentTaskUtils.isVisible(manageProtectionsButton), + "Button to manage protections is displayed" + ); + }); + + // Custom protection card should show, even if there would otherwise be data on the graph. + let db = await Sqlite.openConnection({ path: DB_PATH }); + let date = new Date().toISOString(); + await db.execute(SQL.insertCustomTimeEvent, { + type: TrackingDBService.TRACKERS_ID, + count: 1, + timestamp: date, + }); + await BrowserTestUtils.reloadTab(tab); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition(() => { + let etpCard = content.document.querySelector(".etp-card"); + return etpCard.classList.contains("custom-not-blocking"); + }, "The custom protections warning card is showing"); + + let manageProtectionsButton = + content.document.getElementById("manage-protections"); + Assert.ok( + ContentTaskUtils.isVisible(manageProtectionsButton), + "Button to manage protections is displayed" + ); + + manageProtectionsButton.click(); + }); + let aboutPreferencesTab = await aboutPreferencesPromise; + info("about:preferences#privacy was successfully opened in a new tab"); + gBrowser.removeTab(aboutPreferencesTab); + + Services.prefs.setStringPref("browser.contentblocking.category", "standard"); + // Use the TrackingDBService API to delete the data. + await TrackingDBService.clearAll(); + // Make sure the data was deleted. + let rows = await db.execute(SQL.selectAll); + is(rows.length, 0, "length is 0"); + await db.close(); + BrowserTestUtils.removeTab(tab); +}); + +// Ensure that the ETP mobile promotion card is shown when the pref is on and +// there are no mobile devices connected. +add_task(async function test_etp_mobile_promotion_pref_on() { + AboutProtectionsParent.setTestOverride(mockGetLoginDataWithSyncedDevices()); + await SpecialPowers.pushPrefEnv({ + set: [["browser.contentblocking.report.show_mobile_app", true]], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + let mobilePromotion = content.document.getElementById("mobile-hanger"); + Assert.ok( + ContentTaskUtils.isVisible(mobilePromotion), + "Mobile promotions card is displayed when pref is on and there are no synced mobile devices" + ); + + // Card should hide after the X is clicked. + mobilePromotion.querySelector(".exit-icon").click(); + Assert.ok( + ContentTaskUtils.isHidden(mobilePromotion), + "Mobile promotions card is no longer displayed after clicking the X button" + ); + }); + BrowserTestUtils.removeTab(tab); + + // Add a mock mobile device. The promotion should now be hidden. + AboutProtectionsParent.setTestOverride( + mockGetLoginDataWithSyncedDevices(true) + ); + tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + let mobilePromotion = content.document.getElementById("mobile-hanger"); + Assert.ok( + ContentTaskUtils.isHidden(mobilePromotion), + "Mobile promotions card is hidden when pref is on if there are synced mobile devices" + ); + }); + + BrowserTestUtils.removeTab(tab); + AboutProtectionsParent.setTestOverride(null); +}); + +// Test that ETP mobile promotion is not shown when the pref is off, +// even if no mobile devices are synced. +add_task(async function test_etp_mobile_promotion_pref_on() { + AboutProtectionsParent.setTestOverride(mockGetLoginDataWithSyncedDevices()); + await SpecialPowers.pushPrefEnv({ + set: [["browser.contentblocking.report.show_mobile_app", false]], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + let mobilePromotion = content.document.getElementById("mobile-hanger"); + Assert.ok( + ContentTaskUtils.isHidden(mobilePromotion), + "Mobile promotions card is not displayed when pref is off and there are no synced mobile devices" + ); + }); + + BrowserTestUtils.removeTab(tab); + + AboutProtectionsParent.setTestOverride( + mockGetLoginDataWithSyncedDevices(true) + ); + tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + let mobilePromotion = content.document.getElementById("mobile-hanger"); + Assert.ok( + ContentTaskUtils.isHidden(mobilePromotion), + "Mobile promotions card is not displayed when pref is off even if there are synced mobile devices" + ); + }); + BrowserTestUtils.removeTab(tab); + AboutProtectionsParent.setTestOverride(null); +}); + +// Test that clicking on the link to settings in the header properly opens the settings page. +add_task(async function test_settings_links() { + let tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + let aboutPreferencesPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:preferences#privacy" + ); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const protectionSettings = await ContentTaskUtils.waitForCondition(() => { + return content.document.getElementById("protection-settings"); + }, "protection-settings link exists"); + + protectionSettings.click(); + }); + let aboutPreferencesTab = await aboutPreferencesPromise; + info("about:preferences#privacy was successfully opened in a new tab"); + gBrowser.removeTab(aboutPreferencesTab); + gBrowser.removeTab(tab); +}); diff --git a/browser/components/protections/test/browser/browser_protections_telemetry.js b/browser/components/protections/test/browser/browser_protections_telemetry.js new file mode 100644 index 0000000000..2073be23e9 --- /dev/null +++ b/browser/components/protections/test/browser/browser_protections_telemetry.js @@ -0,0 +1,1123 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "TrackingDBService", + "@mozilla.org/tracking-db-service;1", + "nsITrackingDBService" +); + +const { AboutProtectionsParent } = ChromeUtils.importESModule( + "resource:///actors/AboutProtectionsParent.sys.mjs" +); + +const LOG = { + "https://1.example.com": [ + [Ci.nsIWebProgressListener.STATE_BLOCKED_TRACKING_CONTENT, true, 1], + ], + "https://2.example.com": [ + [Ci.nsIWebProgressListener.STATE_BLOCKED_FINGERPRINTING_CONTENT, true, 1], + ], + "https://3.example.com": [ + [Ci.nsIWebProgressListener.STATE_BLOCKED_CRYPTOMINING_CONTENT, true, 2], + ], + "https://4.example.com": [ + [Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_TRACKER, true, 3], + ], + "https://5.example.com": [ + [Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_TRACKER, true, 1], + ], + // Cookie blocked for other reason, then identified as a tracker + "https://6.example.com": [ + [ + Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_ALL | + Ci.nsIWebProgressListener.STATE_LOADED_LEVEL_1_TRACKING_CONTENT, + true, + 4, + ], + ], +}; + +requestLongerTimeout(2); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.vpn_promo.enabled", true], + ["browser.contentblocking.report.vpn_regions", "us,ca,nz,sg,my,gb"], + [ + "browser.vpn_promo.disallowed_regions", + "ae,by,cn,cu,iq,ir,kp,om,ru,sd,sy,tm,tr", + ], + + // Change the endpoints to prevent non-local network connections when landing on the page. + ["browser.contentblocking.report.monitor.url", ""], + ["browser.contentblocking.report.monitor.sign_in_url", ""], + ["browser.contentblocking.report.social.url", ""], + ["browser.contentblocking.report.cookie.url", ""], + ["browser.contentblocking.report.tracker.url", ""], + ["browser.contentblocking.report.fingerprinter.url", ""], + ["browser.contentblocking.report.cryptominer.url", ""], + ["browser.contentblocking.report.mobile-ios.url", ""], + ["browser.contentblocking.report.mobile-android.url", ""], + ["browser.contentblocking.report.monitor.home_page_url", ""], + ["browser.contentblocking.report.monitor.preferences_url", ""], + ["browser.contentblocking.report.vpn.url", ""], + ["browser.contentblocking.report.vpn-promo.url", ""], + ["browser.contentblocking.report.vpn-android.url", ""], + ["browser.contentblocking.report.vpn-ios.url", ""], + ], + }); + + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + registerCleanupFunction(() => { + Services.telemetry.canRecordExtended = oldCanRecord; + // AboutProtectionsParent.setTestOverride(null); + }); +}); + +add_task(async function checkTelemetryLoadEvents() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.contentblocking.database.enabled", false], + ["browser.contentblocking.report.monitor.enabled", false], + ["browser.contentblocking.report.lockwise.enabled", false], + ["browser.contentblocking.report.proxy.enabled", false], + ["browser.vpn_promo.enabled", false], + ], + }); + await addArbitraryTimeout(); + + // Clear everything. + Services.telemetry.clearEvents(); + await TestUtils.waitForCondition(() => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + return !events || !events.length; + }); + + Services.telemetry.setEventRecordingEnabled("security.ui.protections", true); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + + let loadEvents = await TestUtils.waitForCondition(() => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + if (events && events.length) { + events = events.filter( + e => e[1] == "security.ui.protections" && e[2] == "show" + ); + if (events.length == 1) { + return events; + } + } + return null; + }, "recorded telemetry for showing the report"); + + is(loadEvents.length, 1, `recorded telemetry for showing the report`); + await BrowserTestUtils.reloadTab(tab); + loadEvents = await TestUtils.waitForCondition(() => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + if (events && events.length) { + events = events.filter( + e => e[1] == "security.ui.protections" && e[2] == "close" + ); + if (events.length == 1) { + return events; + } + } + return null; + }, "recorded telemetry for closing the report"); + + is(loadEvents.length, 1, `recorded telemetry for closing the report`); + + await BrowserTestUtils.removeTab(tab); +}); + +function waitForTelemetryEventCount(count) { + info("waiting for telemetry event count of " + count); + return TestUtils.waitForCondition(() => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).content; + if (!events) { + return null; + } + // Ignore irrelevant events from other parts of the browser. + events = events.filter(e => e[1] == "security.ui.protections"); + info("got " + (events && events.length) + " events"); + if (events.length == count) { + return events; + } + return null; + }, "waiting for telemetry event count of: " + count); +} + +let addArbitraryTimeout = async () => { + // There's an arbitrary interval of 2 seconds in which the content + // processes sync their event data with the parent process, we wait + // this out to ensure that we clear everything that is left over from + // previous tests and don't receive random events in the middle of our tests. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(c => setTimeout(c, 2000)); +}; + +add_task(async function checkTelemetryClickEvents() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.contentblocking.database.enabled", true], + ["browser.contentblocking.report.monitor.enabled", true], + ["browser.contentblocking.report.lockwise.enabled", true], + ["browser.contentblocking.report.proxy.enabled", true], + ["browser.vpn_promo.enabled", false], + ], + }); + await addArbitraryTimeout(); + + // Clear everything. + Services.telemetry.clearEvents(); + await TestUtils.waitForCondition(() => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + return !events || !events.length; + }); + + Services.telemetry.setEventRecordingEnabled("security.ui.protections", true); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + + // Add user logins. + await Services.logins.addLoginAsync(TEST_LOGIN1); + await BrowserTestUtils.reloadTab(tab); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const managePasswordsButton = await ContentTaskUtils.waitForCondition( + () => { + return content.document.getElementById("manage-passwords-button"); + }, + "Manage passwords button exists" + ); + await ContentTaskUtils.waitForCondition( + () => ContentTaskUtils.isVisible(managePasswordsButton), + "manage passwords button is visible" + ); + managePasswordsButton.click(); + }); + + let events = await waitForTelemetryEventCount(4); + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "lw_open_button" && + e[4] == "manage_passwords" + ); + is( + events.length, + 1, + `recorded telemetry for lw_open_button when there are no breached passwords` + ); + await BrowserTestUtils.removeTab(gBrowser.selectedTab); + + // Add breached logins. + AboutProtectionsParent.setTestOverride( + mockGetLoginDataWithSyncedDevices(false, 4) + ); + await BrowserTestUtils.reloadTab(tab); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const managePasswordsButton = await ContentTaskUtils.waitForCondition( + () => { + return content.document.getElementById("manage-passwords-button"); + }, + "Manage passwords button exists" + ); + await ContentTaskUtils.waitForCondition( + () => ContentTaskUtils.isVisible(managePasswordsButton), + "manage passwords button is visible" + ); + managePasswordsButton.click(); + }); + + events = await waitForTelemetryEventCount(7); + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "lw_open_button" && + e[4] == "manage_breached_passwords" + ); + is( + events.length, + 1, + `recorded telemetry for lw_open_button when there are breached passwords` + ); + AboutProtectionsParent.setTestOverride(null); + Services.logins.removeLogin(TEST_LOGIN1); + await BrowserTestUtils.removeTab(gBrowser.selectedTab); + await BrowserTestUtils.reloadTab(tab); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + // Show all elements, so we can click on them, even though our user is not logged in. + let hidden_elements = content.document.querySelectorAll(".hidden"); + for (let el of hidden_elements) { + el.style.display = "block "; + } + + const savePasswordsButton = await ContentTaskUtils.waitForCondition(() => { + // Opens an extra tab + return content.document.getElementById("save-passwords-button"); + }, "Save Passwords button exists"); + + savePasswordsButton.click(); + }); + + events = await waitForTelemetryEventCount(10); + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "lw_open_button" && + e[4] == "save_passwords" + ); + is( + events.length, + 1, + `recorded telemetry for lw_open_button when there are no stored passwords` + ); + await BrowserTestUtils.removeTab(gBrowser.selectedTab); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const lockwiseAboutLink = await ContentTaskUtils.waitForCondition(() => { + return content.document.getElementById("lockwise-how-it-works"); + }, "lockwiseReportLink exists"); + + lockwiseAboutLink.click(); + }); + + events = await waitForTelemetryEventCount(11); + + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "lw_about_link" + ); + is(events.length, 1, `recorded telemetry for lw_about_link`); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + let monitorAboutLink = await ContentTaskUtils.waitForCondition(() => { + return content.document.getElementById("monitor-link"); + }, "monitorAboutLink exists"); + + monitorAboutLink.click(); + }); + + events = await waitForTelemetryEventCount(12); + + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "mtr_about_link" + ); + is(events.length, 1, `recorded telemetry for mtr_about_link`); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const signUpForMonitorLink = await ContentTaskUtils.waitForCondition(() => { + return content.document.getElementById("sign-up-for-monitor-link"); + }, "signUpForMonitorLink exists"); + + signUpForMonitorLink.click(); + }); + + events = await waitForTelemetryEventCount(13); + + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "mtr_signup_button" + ); + is(events.length, 1, `recorded telemetry for mtr_signup_button`); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const socialLearnMoreLink = await ContentTaskUtils.waitForCondition(() => { + return content.document.getElementById("social-link"); + }, "Learn more link for social tab exists"); + + socialLearnMoreLink.click(); + }); + + events = await waitForTelemetryEventCount(14); + + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "trackers_about_link" && + e[4] == "social" + ); + is(events.length, 1, `recorded telemetry for social trackers_about_link`); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const cookieLearnMoreLink = await ContentTaskUtils.waitForCondition(() => { + return content.document.getElementById("cookie-link"); + }, "Learn more link for cookie tab exists"); + + cookieLearnMoreLink.click(); + }); + + events = await waitForTelemetryEventCount(15); + + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "trackers_about_link" && + e[4] == "cookie" + ); + is(events.length, 1, `recorded telemetry for cookie trackers_about_link`); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const trackerLearnMoreLink = await ContentTaskUtils.waitForCondition(() => { + return content.document.getElementById("tracker-link"); + }, "Learn more link for tracker tab exists"); + + trackerLearnMoreLink.click(); + }); + + events = await waitForTelemetryEventCount(16); + + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "trackers_about_link" && + e[4] == "tracker" + ); + is( + events.length, + 1, + `recorded telemetry for content tracker trackers_about_link` + ); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const fingerprinterLearnMoreLink = await ContentTaskUtils.waitForCondition( + () => { + return content.document.getElementById("fingerprinter-link"); + }, + "Learn more link for fingerprinter tab exists" + ); + + fingerprinterLearnMoreLink.click(); + }); + + events = await waitForTelemetryEventCount(17); + + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "trackers_about_link" && + e[4] == "fingerprinter" + ); + is( + events.length, + 1, + `recorded telemetry for fingerprinter trackers_about_link` + ); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const cryptominerLearnMoreLink = await ContentTaskUtils.waitForCondition( + () => { + return content.document.getElementById("cryptominer-link"); + }, + "Learn more link for cryptominer tab exists" + ); + + cryptominerLearnMoreLink.click(); + }); + + events = await waitForTelemetryEventCount(18); + + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "trackers_about_link" && + e[4] == "cryptominer" + ); + is( + events.length, + 1, + `recorded telemetry for cryptominer trackers_about_link` + ); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const protectionSettings = await ContentTaskUtils.waitForCondition(() => { + return content.document.getElementById("protection-settings"); + }, "protection-settings link exists"); + + protectionSettings.click(); + }); + + events = await waitForTelemetryEventCount(19); + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "settings_link" && + e[4] == "header-settings" + ); + is(events.length, 1, `recorded telemetry for settings_link header-settings`); + await BrowserTestUtils.removeTab(gBrowser.selectedTab); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const customProtectionSettings = await ContentTaskUtils.waitForCondition( + () => { + return content.document.getElementById("manage-protections"); + }, + "manage-protections link exists" + ); + // Show element so we can click on it + customProtectionSettings.style.display = "block"; + + customProtectionSettings.click(); + }); + + events = await waitForTelemetryEventCount(20); + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "settings_link" && + e[4] == "custom-card-settings" + ); + is( + events.length, + 1, + `recorded telemetry for settings_link custom-card-settings` + ); + await BrowserTestUtils.removeTab(gBrowser.selectedTab); + + // Add breached logins and some resolved breaches. + AboutProtectionsParent.setTestOverride( + mockGetMonitorData({ + potentiallyBreachedLogins: 4, + numBreaches: 3, + numBreachesResolved: 1, + }) + ); + await BrowserTestUtils.reloadTab(tab); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const resolveBreachesButton = await ContentTaskUtils.waitForCondition( + () => { + return content.document.getElementById("monitor-partial-breaches-link"); + }, + "Monitor resolve breaches button exists" + ); + + await ContentTaskUtils.waitForCondition( + () => ContentTaskUtils.isVisible(resolveBreachesButton), + "Resolve breaches button is visible" + ); + + resolveBreachesButton.click(); + }); + + events = await waitForTelemetryEventCount(23); + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "mtr_report_link" && + e[4] == "resolve_breaches" + ); + is(events.length, 1, `recorded telemetry for resolve breaches button`); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const monitorKnownBreachesBlock = await ContentTaskUtils.waitForCondition( + () => { + return content.document.getElementById("monitor-known-breaches-link"); + }, + "Monitor card known breaches block exists" + ); + + monitorKnownBreachesBlock.click(); + }); + + events = await waitForTelemetryEventCount(24); + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "mtr_report_link" && + e[4] == "known_resolved_breaches" + ); + is(events.length, 1, `recorded telemetry for monitor known breaches block`); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const monitorExposedPasswordsBlock = + await ContentTaskUtils.waitForCondition(() => { + return content.document.getElementById( + "monitor-exposed-passwords-link" + ); + }, "Monitor card exposed passwords block exists"); + + monitorExposedPasswordsBlock.click(); + }); + + events = await waitForTelemetryEventCount(25); + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "mtr_report_link" && + e[4] == "exposed_passwords_unresolved_breaches" + ); + is( + events.length, + 1, + `recorded telemetry for monitor exposed passwords block` + ); + + // Add breached logins and no resolved breaches. + AboutProtectionsParent.setTestOverride( + mockGetMonitorData({ + potentiallyBreachedLogins: 4, + numBreaches: 3, + numBreachesResolved: 0, + }) + ); + await BrowserTestUtils.reloadTab(tab); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const manageBreachesButton = await ContentTaskUtils.waitForCondition(() => { + return content.document.getElementById("monitor-breaches-link"); + }, "Monitor manage breaches button exists"); + + await ContentTaskUtils.waitForCondition( + () => ContentTaskUtils.isVisible(manageBreachesButton), + "Manage breaches button is visible" + ); + + manageBreachesButton.click(); + }); + + events = await waitForTelemetryEventCount(28); + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "mtr_report_link" && + e[4] == "manage_breaches" + ); + is(events.length, 1, `recorded telemetry for manage breaches button`); + + // All breaches are resolved. + AboutProtectionsParent.setTestOverride( + mockGetMonitorData({ + potentiallyBreachedLogins: 4, + numBreaches: 3, + numBreachesResolved: 3, + }) + ); + await BrowserTestUtils.reloadTab(tab); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const viewReportButton = await ContentTaskUtils.waitForCondition(() => { + return content.document.getElementById("monitor-breaches-link"); + }, "Monitor view report button exists"); + await ContentTaskUtils.waitForCondition( + () => ContentTaskUtils.isVisible(viewReportButton), + "View report button is visible" + ); + + viewReportButton.click(); + }); + + events = await waitForTelemetryEventCount(31); + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "mtr_report_link" && + e[4] == "view_report" + ); + is(events.length, 1, `recorded telemetry for view report button`); + + // No breaches are present. + AboutProtectionsParent.setTestOverride( + mockGetMonitorData({ + potentiallyBreachedLogins: 4, + numBreaches: 0, + numBreachesResolved: 0, + }) + ); + await BrowserTestUtils.reloadTab(tab); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const viewReportButton = await ContentTaskUtils.waitForCondition(() => { + return content.document.getElementById("monitor-breaches-link"); + }, "Monitor view report button exists"); + await ContentTaskUtils.waitForCondition( + () => ContentTaskUtils.isVisible(viewReportButton), + "View report button is visible" + ); + + viewReportButton.click(); + }); + + events = await waitForTelemetryEventCount(34); + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "mtr_report_link" && + e[4] == "view_report" + ); + is(events.length, 2, `recorded telemetry for view report button`); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const monitorEmailBlock = await ContentTaskUtils.waitForCondition(() => { + return content.document.getElementById("monitor-stored-emails-link"); + }, "Monitor card email block exists"); + + monitorEmailBlock.click(); + }); + + events = await waitForTelemetryEventCount(35); + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "mtr_report_link" && + e[4] == "stored_emails" + ); + is(events.length, 1, `recorded telemetry for monitor email block`); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const monitorKnownBreachesBlock = await ContentTaskUtils.waitForCondition( + () => { + return content.document.getElementById("monitor-known-breaches-link"); + }, + "Monitor card known breaches block exists" + ); + + monitorKnownBreachesBlock.click(); + }); + + events = await waitForTelemetryEventCount(36); + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "mtr_report_link" && + e[4] == "known_unresolved_breaches" + ); + is(events.length, 1, `recorded telemetry for monitor known breaches block`); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const monitorExposedPasswordsBlock = + await ContentTaskUtils.waitForCondition(() => { + return content.document.getElementById( + "monitor-exposed-passwords-link" + ); + }, "Monitor card exposed passwords block exists"); + + monitorExposedPasswordsBlock.click(); + }); + + events = await waitForTelemetryEventCount(37); + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "mtr_report_link" && + e[4] == "exposed_passwords_all_breaches" + ); + is( + events.length, + 1, + `recorded telemetry for monitor exposed passwords block` + ); + + // Clean up. + AboutProtectionsParent.setTestOverride(null); + await BrowserTestUtils.removeTab(tab); +}); + +// This tests that telemetry is sent when saveEvents is called. +add_task(async function test_save_telemetry() { + // Clear all scalar telemetry. + Services.telemetry.clearScalars(); + + await TrackingDBService.saveEvents(JSON.stringify(LOG)); + + const scalars = Services.telemetry.getSnapshotForScalars( + "main", + false + ).parent; + is(scalars["contentblocking.trackers_blocked_count"], 6); + + // Use the TrackingDBService API to delete the data. + await TrackingDBService.clearAll(); +}); + +// Test that telemetry is sent if entrypoint param is included, +// and test that it is recorded as default if entrypoint param is not properly included +add_task(async function checkTelemetryLoadEventForEntrypoint() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.contentblocking.database.enabled", false], + ["browser.contentblocking.report.monitor.enabled", false], + ["browser.contentblocking.report.lockwise.enabled", false], + ["browser.contentblocking.report.proxy.enabled", false], + ["browser.vpn_promo.enabled", false], + ], + }); + await addArbitraryTimeout(); + + // Clear everything. + Services.telemetry.clearEvents(); + await TestUtils.waitForCondition(() => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + return !events || !events.length; + }); + + Services.telemetry.setEventRecordingEnabled("security.ui.protections", true); + + info("Typo in 'entrypoint' should not be recorded"); + let tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections?entryPoint=newPage", + gBrowser, + }); + + let loadEvents = await TestUtils.waitForCondition(() => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + if (events && events.length) { + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "show" && + e[4] == "direct" + ); + if (events.length == 1) { + return events; + } + } + return null; + }, "recorded telemetry for showing the report contains default 'direct' entrypoint"); + + is( + loadEvents.length, + 1, + `recorded telemetry for showing the report contains default 'direct' entrypoint` + ); + + await BrowserTestUtils.removeTab(tab); + + tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections?entrypoint=page", + gBrowser, + }); + + loadEvents = await TestUtils.waitForCondition(() => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + if (events && events.length) { + events = events.filter( + e => + e[1] == "security.ui.protections" && e[2] == "show" && e[4] == "page" + ); + if (events.length == 1) { + return events; + } + } + return null; + }, "recorded telemetry for showing the report contains correct entrypoint"); + + is( + loadEvents.length, + 1, + "recorded telemetry for showing the report contains correct entrypoint" + ); + + // Clean up. + await BrowserTestUtils.removeTab(tab); +}); + +// This test is skipping due to failures on try, it passes locally. +// Test that telemetry is sent from the vpn card +add_task(async function checkTelemetryClickEventsVPN() { + if (Services.sysinfo.getProperty("name") != "Windows_NT") { + ok(true, "User is on an unsupported platform, the vpn card will not show"); + return; + } + await addArbitraryTimeout(); + // Clear everything. + Services.telemetry.clearEvents(); + await TestUtils.waitForCondition(() => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + return !events || !events.length; + }); + Services.telemetry.setEventRecordingEnabled("security.ui.protections", true); + + // user is not subscribed to VPN, and is in the us + AboutProtectionsParent.setTestOverride(getVPNOverrides(false, "us")); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.vpn_promo.enabled", true], + [ + "browser.vpn_promo.disallowed_regions", + "ae,by,cn,cu,iq,ir,kp,om,ru,sd,sy,tm,tr", + ], + ["browser.contentblocking.report.vpn_regions", "us,ca,nz,sg,my,gb"], + ["browser.contentblocking.database.enabled", false], + ["browser.contentblocking.report.monitor.enabled", false], + ["browser.contentblocking.report.lockwise.enabled", false], + ["browser.contentblocking.report.proxy.enabled", false], + ["browser.contentblocking.report.hide_vpn_banner", true], + ["browser.contentblocking.report.vpn-android.url", ""], + ["browser.contentblocking.report.vpn-ios.url", ""], + ["browser.contentblocking.report.vpn.url", ""], + ], + }); + Services.locale.availableLocales = ["en-US"]; + Services.locale.requestedLocales = ["en-US"]; + await promiseSetHomeRegion("US"); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + + info("checking for vpn link"); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const getVPNLink = await ContentTaskUtils.waitForCondition(() => { + return content.document.getElementById("get-vpn-link"); + }, "get vpn link exists"); + await ContentTaskUtils.waitForCondition( + () => ContentTaskUtils.isVisible(getVPNLink), + "get vpn link is visible" + ); + EventUtils.sendMouseEvent( + { type: "click", button: 1 }, + getVPNLink, + content + ); + }); + + let events = await waitForTelemetryEventCount(2); + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "vpn_card_link" + ); + is( + events.length, + 1, + `recorded telemetry for vpn_card_link when user is not subscribed` + ); + + // User is subscribed to VPN + AboutProtectionsParent.setTestOverride(getVPNOverrides(true, "us")); + await BrowserTestUtils.reloadTab(tab); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const androidVPNLink = await ContentTaskUtils.waitForCondition(() => { + return content.document.getElementById("vpn-google-playstore-link"); + }, "android vpn link exists"); + await ContentTaskUtils.waitForCondition( + () => ContentTaskUtils.isVisible(androidVPNLink), + "android vpn link is visible" + ); + await ContentTaskUtils.waitForCondition(() => { + return content.document + .querySelector(".vpn-card") + .classList.contains("subscribed"); + }, "subscribed class is added to the vpn card"); + + EventUtils.sendMouseEvent( + { type: "click", button: 1 }, + androidVPNLink, + content + ); + }); + + events = await waitForTelemetryEventCount(5); + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "vpn_app_link_android" + ); + is(events.length, 1, `recorded telemetry for vpn_app_link_android link`); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const iosVPNLink = await ContentTaskUtils.waitForCondition(() => { + return content.document.getElementById("vpn-app-store-link"); + }, "ios vpn link exists"); + await ContentTaskUtils.waitForCondition( + () => ContentTaskUtils.isVisible(iosVPNLink), + "ios vpn link is visible" + ); + await ContentTaskUtils.waitForCondition(() => { + return content.document + .querySelector(".vpn-card") + .classList.contains("subscribed"); + }, "subscribed class is added to the vpn card"); + + EventUtils.sendMouseEvent( + { type: "click", button: 1 }, + iosVPNLink, + content + ); + }); + + events = await waitForTelemetryEventCount(6); + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "vpn_app_link_ios" + ); + is(events.length, 1, `recorded telemetry for vpn_app_link_ios link`); + + // Clean up. + await BrowserTestUtils.removeTab(tab); +}).skip(); + +// This test is skipping due to failures on try, it passes locally. +// Test that telemetry is sent from the vpn banner +add_task(async function checkTelemetryEventsVPNBanner() { + if (Services.sysinfo.getProperty("name") != "Windows_NT") { + ok(true, "User is on an unsupported platform, the vpn card will not show"); + return; + } + AboutProtectionsParent.setTestOverride(getVPNOverrides(false, "us")); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.vpn_promo.enabled", true], + ["browser.contentblocking.report.vpn_regions", "us,ca,nz,sg,my,gb"], + [ + "browser.vpn_promo.disallowed_regions", + "ae,by,cn,cu,iq,ir,kp,om,ru,sd,sy,tm,tr", + ], + ["browser.contentblocking.database.enabled", false], + ["browser.contentblocking.report.monitor.enabled", false], + ["browser.contentblocking.report.lockwise.enabled", false], + ["browser.contentblocking.report.proxy.enabled", false], + ["browser.contentblocking.report.hide_vpn_banner", false], + ["browser.contentblocking.report.vpn-promo.url", ""], + ], + }); + await addArbitraryTimeout(); + + // The VPN banner only shows if the user is in en* + Services.locale.availableLocales = ["en-US"]; + Services.locale.requestedLocales = ["en-US"]; + + // Clear everything. + Services.telemetry.clearEvents(); + await TestUtils.waitForCondition(() => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + return !events || !events.length; + }); + + Services.telemetry.setEventRecordingEnabled("security.ui.protections", true); + // User is not subscribed to VPN + AboutProtectionsParent.setTestOverride(getVPNOverrides(false, "us")); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const bannerVPNLink = await ContentTaskUtils.waitForCondition(() => { + return content.document.getElementById("vpn-banner-link"); + }, "vpn banner link exists"); + await ContentTaskUtils.waitForCondition( + () => ContentTaskUtils.isVisible(bannerVPNLink), + "vpn banner link is visible" + ); + EventUtils.sendMouseEvent( + { type: "click", button: 1 }, + bannerVPNLink, + content + ); + }); + + let events = await waitForTelemetryEventCount(3); + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "vpn_banner_link" + ); + is(events.length, 1, `recorded telemetry for vpn_banner_link`); + + // VPN Banner flips this pref each time it shows, flip back between each instruction. + await SpecialPowers.pushPrefEnv({ + set: [["browser.contentblocking.report.hide_vpn_banner", false]], + }); + + await BrowserTestUtils.reloadTab(tab); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const bannerExitLink = await ContentTaskUtils.waitForCondition(() => { + return content.document.querySelector(".vpn-banner .exit-icon"); + }, "vpn banner exit link exists"); + await ContentTaskUtils.waitForCondition( + () => ContentTaskUtils.isVisible(bannerExitLink), + "vpn banner exit link is visible" + ); + EventUtils.sendMouseEvent( + { type: "click", button: 1 }, + bannerExitLink, + content + ); + }); + + events = await waitForTelemetryEventCount(7); + events = events.filter( + e => + e[1] == "security.ui.protections" && + e[2] == "click" && + e[3] == "vpn_banner_close" + ); + is(events.length, 1, `recorded telemetry for vpn_banner_close`); + + // Clean up. + await BrowserTestUtils.removeTab(tab); +}).skip(); diff --git a/browser/components/protections/test/browser/browser_protections_vpn.js b/browser/components/protections/test/browser/browser_protections_vpn.js new file mode 100644 index 0000000000..2b54982ba9 --- /dev/null +++ b/browser/components/protections/test/browser/browser_protections_vpn.js @@ -0,0 +1,282 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { AboutProtectionsParent } = ChromeUtils.importESModule( + "resource:///actors/AboutProtectionsParent.sys.mjs" +); + +let { Region } = ChromeUtils.importESModule( + "resource://gre/modules/Region.sys.mjs" +); + +const initialHomeRegion = Region._home; +const initialCurrentRegion = Region._current; + +async function checkVPNCardVisibility(tab, shouldBeHidden, subscribed = false) { + await SpecialPowers.spawn( + tab.linkedBrowser, + [{ _shouldBeHidden: shouldBeHidden, _subscribed: subscribed }], + async function ({ _shouldBeHidden, _subscribed }) { + await ContentTaskUtils.waitForCondition(() => { + const vpnCard = content.document.querySelector(".vpn-card"); + const subscribedStateCorrect = + vpnCard.classList.contains("subscribed") == _subscribed; + return ( + ContentTaskUtils.isHidden(vpnCard) === _shouldBeHidden && + subscribedStateCorrect + ); + }); + + const visibilityState = _shouldBeHidden ? "hidden" : "shown"; + ok(true, `VPN card is ${visibilityState}.`); + } + ); +} + +async function checkVPNPromoBannerVisibility(tab, shouldBeHidden) { + await SpecialPowers.spawn( + tab.linkedBrowser, + [{ _shouldBeHidden: shouldBeHidden }], + async function ({ _shouldBeHidden }) { + await ContentTaskUtils.waitForCondition(() => { + const vpnBanner = content.document.querySelector(".vpn-banner"); + return ContentTaskUtils.isHidden(vpnBanner) === _shouldBeHidden; + }); + + const visibilityState = _shouldBeHidden ? "hidden" : "shown"; + ok(true, `VPN banner is ${visibilityState}.`); + } + ); +} + +async function setCurrentRegion(region) { + Region._setCurrentRegion(region); +} + +async function setHomeRegion(region) { + // _setHomeRegion sets a char pref to the value of region. A non-string value will result in an error, so default to an empty string when region is falsey. + Region._setHomeRegion(region || ""); +} + +async function revertRegions() { + setCurrentRegion(initialCurrentRegion); + setHomeRegion(initialHomeRegion); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.contentblocking.report.monitor.enabled", false], + ["browser.contentblocking.report.lockwise.enabled", false], + ["browser.vpn_promo.enabled", true], + ], + }); + AboutProtectionsParent.setTestOverride(getVPNOverrides(false)); + setCurrentRegion("us"); + const avLocales = Services.locale.availableLocales; + + registerCleanupFunction(() => { + Services.locale.availableLocales = avLocales; + }); +}); + +add_task(async function testVPNCardVisibility() { + AboutProtectionsParent.setTestOverride(getVPNOverrides(false)); + await promiseSetHomeRegion("us"); + setCurrentRegion("us"); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + + info("Enable showing the VPN card"); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.vpn_promo.enabled", true], + ["browser.contentblocking.report.vpn_regions", "us,ca,nz,sg,my,gb"], + [ + "browser.vpn_promo.disallowed_regions", + "ae,by,cn,cu,iq,ir,kp,om,ru,sd,sy,tm,tr", + ], + ], + }); + + info( + "Check that vpn card is hidden if neither the user's home nor current location is on the regions list." + ); + AboutProtectionsParent.setTestOverride(getVPNOverrides(false)); + setCurrentRegion("ls"); + await promiseSetHomeRegion("ls"); + await BrowserTestUtils.reloadTab(tab); + await checkVPNCardVisibility(tab, true); + + info( + "Check that vpn card is hidden if user's location is in the list of disallowed regions." + ); + AboutProtectionsParent.setTestOverride(getVPNOverrides(false)); + setCurrentRegion("sy"); + await BrowserTestUtils.reloadTab(tab); + await checkVPNCardVisibility(tab, true); + + info( + "Check that vpn card shows a different version if user has subscribed to Mozilla vpn." + ); + AboutProtectionsParent.setTestOverride(getVPNOverrides(true)); + setCurrentRegion("us"); + await BrowserTestUtils.reloadTab(tab); + await checkVPNCardVisibility(tab, false, true); + + info( + "VPN card should be hidden when vpn not enabled, though all other conditions are true" + ); + await SpecialPowers.pushPrefEnv({ + set: [["browser.vpn_promo.enabled", false]], + }); + await BrowserTestUtils.reloadTab(tab); + await checkVPNCardVisibility(tab, true); + + await BrowserTestUtils.removeTab(tab); + revertRegions(); +}); + +add_task(async function testVPNPromoBanner() { + AboutProtectionsParent.setTestOverride(getVPNOverrides(false)); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + + info("Enable showing the VPN card and banner"); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.vpn_promo.enabled", true], + ["browser.contentblocking.report.vpn_regions", "us,ca,nz,sg,my,gb"], + [ + "browser.vpn_promo.disallowed_regions", + "ae,by,cn,cu,iq,ir,kp,om,ru,sd,sy,tm,tr", + ], + ["browser.contentblocking.report.hide_vpn_banner", false], + ], + }); + + info("Check that vpn banner is shown if user's region is supported"); + setCurrentRegion("us"); + await BrowserTestUtils.reloadTab(tab); + await checkVPNPromoBannerVisibility(tab, false); + + is( + Services.prefs.getBoolPref( + "browser.contentblocking.report.hide_vpn_banner", + false + ), + true, + "After showing the banner once, the pref to hide the VPN banner is flipped" + ); + info("The banner does not show when the pref to hide it is flipped"); + await BrowserTestUtils.reloadTab(tab); + await checkVPNPromoBannerVisibility(tab, true); + + // VPN Banner flips this pref each time it shows, flip back between each instruction. + await SpecialPowers.pushPrefEnv({ + set: [["browser.contentblocking.report.hide_vpn_banner", false]], + }); + + info( + "Check that VPN banner is hidden if user's location is not on the regions list." + ); + AboutProtectionsParent.setTestOverride(getVPNOverrides(false)); + setCurrentRegion("ls"); + await setHomeRegion("ls'"); + await BrowserTestUtils.reloadTab(tab); + await checkVPNPromoBannerVisibility(tab, true); + + info( + "Check that VPN banner is hidden if user's location is in the disallowed regions list." + ); + AboutProtectionsParent.setTestOverride(getVPNOverrides(false)); + setCurrentRegion("sy"); + await BrowserTestUtils.reloadTab(tab); + await checkVPNPromoBannerVisibility(tab, true); + + info( + "VPN banner should be hidden when vpn not enabled, though all other conditions are true" + ); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.vpn_promo.enabled", false], + ["browser.contentblocking.report.hide_vpn_banner", false], + ], + }); + await BrowserTestUtils.reloadTab(tab); + await checkVPNPromoBannerVisibility(tab, true); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.vpn_promo.enabled", true], + ["browser.contentblocking.report.hide_vpn_banner", false], + ], + }); + + info("If user is subscribed to VPN already the promo banner should not show"); + AboutProtectionsParent.setTestOverride(getVPNOverrides(true)); + setCurrentRegion("us"); + await BrowserTestUtils.reloadTab(tab); + await checkVPNPromoBannerVisibility(tab, true); + + await BrowserTestUtils.removeTab(tab); + revertRegions(); +}); + +// Expect the vpn card and banner to not show as we are expressly excluding China. Even when cn is in the supported region pref. +add_task(async function testVPNDoesNotShowChina() { + AboutProtectionsParent.setTestOverride(getVPNOverrides(false)); + setCurrentRegion("us"); + let tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:protections", + gBrowser, + }); + + info("Enable showing the VPN card and banners"); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.vpn_promo.enabled", true], + ["browser.contentblocking.report.vpn_regions", "us,ca,nz,sg,my,gb,cn"], + [ + "browser.vpn_promo.disallowed_regions", + "ae,by,cn,cu,iq,ir,kp,om,ru,sd,sy,tm,tr", + ], + ["browser.contentblocking.report.hide_vpn_banner", false], + ], + }); + + info( + "set home location to China, even though user is currently in the US, expect vpn card to be hidden" + ); + await promiseSetHomeRegion("CN"); + await BrowserTestUtils.reloadTab(tab); + await checkVPNPromoBannerVisibility(tab, true); + await BrowserTestUtils.reloadTab(tab); + await checkVPNCardVisibility(tab, true); + + // VPN Banner flips this pref each time it shows, flip back between each instruction. + await SpecialPowers.pushPrefEnv({ + set: [["browser.contentblocking.report.hide_vpn_banner", false]], + }); + + info("home region is US, but current location is China"); + AboutProtectionsParent.setTestOverride(getVPNOverrides(false)); + await promiseSetHomeRegion("US"); + setCurrentRegion("CN"); + await BrowserTestUtils.reloadTab(tab); + await checkVPNPromoBannerVisibility(tab, true); + await BrowserTestUtils.reloadTab(tab); + await checkVPNCardVisibility(tab, true); + + await BrowserTestUtils.removeTab(tab); + revertRegions(); +}); diff --git a/browser/components/protections/test/browser/head.js b/browser/components/protections/test/browser/head.js new file mode 100644 index 0000000000..9815869ee5 --- /dev/null +++ b/browser/components/protections/test/browser/head.js @@ -0,0 +1,96 @@ +/* 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 no-unused-vars */ + +"use strict"; + +const nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); + +ChromeUtils.defineESModuleGetters(this, { + Region: "resource://gre/modules/Region.sys.mjs", +}); + +const { SearchTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/SearchTestUtils.sys.mjs" +); + +const TEST_LOGIN1 = new nsLoginInfo( + "https://example.com/", + "https://example.com/", + null, + "user1", + "pass1", + "username", + "password" +); + +const TEST_LOGIN2 = new nsLoginInfo( + "https://2.example.com/", + "https://2.example.com/", + null, + "user2", + "pass2", + "username", + "password" +); + +// Used to replace AboutProtectionsHandler.getLoginData in front-end tests. +const mockGetLoginDataWithSyncedDevices = ( + mobileDeviceConnected = false, + potentiallyBreachedLogins = 0 +) => { + return { + getLoginData: () => { + return { + numLogins: Services.logins.countLogins("", "", ""), + potentiallyBreachedLogins, + mobileDeviceConnected, + }; + }, + }; +}; + +// Used to replace AboutProtectionsHandler.getMonitorData in front-end tests. +const mockGetMonitorData = data => { + return { + getMonitorData: () => { + if (data.error) { + return data; + } + + return { + monitoredEmails: 1, + numBreaches: data.numBreaches, + passwords: 8, + numBreachesResolved: data.numBreachesResolved, + passwordsResolved: 1, + error: false, + }; + }, + }; +}; + +registerCleanupFunction(function head_cleanup() { + Services.logins.removeAllUserFacingLogins(); +}); + +// Used to replace AboutProtectionsParent.VPNSubStatus +const getVPNOverrides = (hasSubscription = false) => { + return { + vpnOverrides: () => { + return hasSubscription; + }, + }; +}; + +const promiseSetHomeRegion = async region => { + let promise = SearchTestUtils.promiseSearchNotification("engines-reloaded"); + Region._setHomeRegion(region); + await promise; +}; diff --git a/browser/components/protocolhandler/WebProtocolHandlerRegistrar.sys.mjs b/browser/components/protocolhandler/WebProtocolHandlerRegistrar.sys.mjs new file mode 100644 index 0000000000..927685c83c --- /dev/null +++ b/browser/components/protocolhandler/WebProtocolHandlerRegistrar.sys.mjs @@ -0,0 +1,506 @@ +/* -*- 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/. */ + +const STRING_BUNDLE_URI = "chrome://browser/locale/feeds/subscribe.properties"; + +export function WebProtocolHandlerRegistrar() {} + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + let consoleOptions = { + // tip: set maxLogLevel to "debug" and use lazy.log.debug() to create + // detailed messages during development. See LOG_LEVELS in Console.sys.mjs + // for details. + maxLogLevel: "warning", + maxLogLevelPref: "browser.protocolhandler.loglevel", + prefix: "WebProtocolHandlerRegistrar.sys.mjs", + }; + return new ConsoleAPI(consoleOptions); +}); + +WebProtocolHandlerRegistrar.prototype = { + get stringBundle() { + let sb = Services.strings.createBundle(STRING_BUNDLE_URI); + delete WebProtocolHandlerRegistrar.prototype.stringBundle; + return (WebProtocolHandlerRegistrar.prototype.stringBundle = sb); + }, + + _getFormattedString(key, params) { + return this.stringBundle.formatStringFromName(key, params); + }, + + _getString(key) { + return this.stringBundle.GetStringFromName(key); + }, + + /** + * See nsIWebProtocolHandlerRegistrar + */ + removeProtocolHandler(aProtocol, aURITemplate) { + let eps = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + let handlerInfo = eps.getProtocolHandlerInfo(aProtocol); + let handlers = handlerInfo.possibleApplicationHandlers; + for (let i = 0; i < handlers.length; i++) { + try { + // We only want to test web handlers + let handler = handlers.queryElementAt(i, Ci.nsIWebHandlerApp); + if (handler.uriTemplate == aURITemplate) { + handlers.removeElementAt(i); + let hs = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService + ); + hs.store(handlerInfo); + return; + } + } catch (e) { + /* it wasn't a web handler */ + } + } + }, + + /** + * Determines if a web handler is already registered. + * + * @param {string} aProtocol + * The scheme of the web handler we are checking for. + * @param {string} aURITemplate + * The URI template that the handler uses to handle the protocol. + * @returns {boolean} true if it is already registered, false otherwise. + */ + _protocolHandlerRegistered(aProtocol, aURITemplate) { + let eps = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + let handlerInfo = eps.getProtocolHandlerInfo(aProtocol); + let handlers = handlerInfo.possibleApplicationHandlers; + for (let i = 0; i < handlers.length; i++) { + try { + // We only want to test web handlers + let handler = handlers.queryElementAt(i, Ci.nsIWebHandlerApp); + if (handler.uriTemplate == aURITemplate) { + return true; + } + } catch (e) { + /* it wasn't a web handler */ + lazy.log.debug("No protocolHandler registered, because: " + e.message); + } + } + return false; + }, + + /** + * Private method to return the installHash, which is important for app + * registration on OS level. Without it apps cannot be default/handler apps + * under Windows. Because this value is used to check if its possible to reset + * the default and to actually set it as well, this function puts the + * acquisition of the installHash in one place in the hopes that check and set + * conditions will never deviate. + * + * @returns {string} installHash + */ + _getInstallHash() { + const xreDirProvider = Cc[ + "@mozilla.org/xre/directory-provider;1" + ].getService(Ci.nsIXREDirProvider); + return xreDirProvider.getInstallHash(); + }, + + /** + * Private method to determine if we can set a new OS default for a certain + * protocol. + * + * @param {string} protocol name, e.g. mailto (without ://) + * @returns {boolean} + */ + _canSetOSDefault(protocol) { + // can be toggled off individually if necessary... + if (!lazy.NimbusFeatures.mailto.getVariable("dualPrompt.os")) { + lazy.log.debug("_canSetOSDefault: false: mailto rollout deactivated."); + return false; + } + + // this preferences saves that the user has dismissed the bar before... + if (!Services.prefs.getBoolPref("browser.mailto.prompt.os", true)) { + lazy.log.debug("_canSetOSDefault: false: prompt dismissed before."); + return false; + } + + // an installHash is required for the association with a scheme handler + if ("" == this._getInstallHash()) { + lazy.log.debug("_canSetOSDefault: false: no installation hash."); + return false; + } + + // check if we are already the protocolhandler... + let shellService = Cc[ + "@mozilla.org/browser/shell-service;1" + ].createInstance(Ci.nsIWindowsShellService); + + if (shellService.isDefaultHandlerFor(protocol)) { + lazy.log.debug("_canSetOSDefault: false: is already default handler."); + return false; + } + + return true; + }, + + /** + * Private method to reset the OS default for a certain protocol/uri scheme. + * We basically ignore, that setDefaultExtensionHandlersUserChoice can fail + * when the installHash is wrong or cannot be determined. + * + * @param {string} protocol name, e.g. mailto (without ://) + * @returns {boolean} + */ + _setOSDefault(protocol) { + try { + let defaultAgent = Cc["@mozilla.org/default-agent;1"].createInstance( + Ci.nsIDefaultAgent + ); + defaultAgent.setDefaultExtensionHandlersUserChoice( + this._getInstallHash(), + [protocol, "FirefoxURL"] + ); + return true; + } catch (e) { + // TODO: why could not we just add the installHash and promote the running + // install to be a properly installed one? + lazy.log.debug( + "Could not set Firefox as default application for " + + protocol + + ", because: " + + e.message + ); + } + return false; + }, + + /** + * Private method to set the default uri to handle a certain protocol. This + * automates in a way what a user can do in settings under applications, + * where different 'actions' can be chosen for different 'content types'. + * + * @param {string} protocol + * @param {handler} handler + */ + _setLocalDefault(protocol, handler) { + let eps = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + + let handlerInfo = eps.getProtocolHandlerInfo(protocol); + handlerInfo.preferredAction = Ci.nsIHandlerInfo.useHelperApp; // this is IMPORTANT! + handlerInfo.preferredApplicationHandler = handler; + handlerInfo.alwaysAskBeforeHandling = false; + let hs = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService + ); + hs.store(handlerInfo); + }, + + /** + * Private method to set the default uri to handle a certain protocol. This + * automates in a way what a user can do in settings under applications, + * where different 'actions' can be chosen for different 'content types'. + * + * @param {string} protocol - e.g. 'mailto', so again without :// + * @param {string} name - the protocol associated 'Action' + * @param {string} uri - the uri (compare 'use other...' in the preferences) + * @returns {handler} handler - either the existing one or a newly created + */ + _addLocal(protocol, name, uri) { + let eps = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + + let phi = eps.getProtocolHandlerInfo(protocol); + // not adding duplicates and bail out with the existing entry + for (let h of phi.possibleApplicationHandlers.enumerate()) { + if (h.uriTemplate == uri) { + return h; + } + } + + let handler = Cc["@mozilla.org/uriloader/web-handler-app;1"].createInstance( + Ci.nsIWebHandlerApp + ); + handler.name = name; + handler.uriTemplate = uri; + + let handlerInfo = eps.getProtocolHandlerInfo(protocol); + handlerInfo.possibleApplicationHandlers.appendElement(handler); + + // Since the user has agreed to add a new handler, chances are good + // that the next time they see a handler of this type, they're going + // to want to use it. Reset the handlerInfo to ask before the next + // use. + handlerInfo.alwaysAskBeforeHandling = true; + + let hs = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService + ); + hs.store(handlerInfo); + + return handler; + }, + + /** + * See nsIWebProtocolHandlerRegistrar + */ + registerProtocolHandler( + aProtocol, + aURI, + aTitle, + aDocumentURI, + aBrowserOrWindow + ) { + // first mitigation: check if the API call comes from another domain + aProtocol = (aProtocol || "").toLowerCase(); + if (!aURI || !aDocumentURI) { + return; + } + + let browser = aBrowserOrWindow; // This is the e10s case. + if (aBrowserOrWindow instanceof Ci.nsIDOMWindow) { + // In the non-e10s case, grab the browser off the same-process window. + let rootDocShell = aBrowserOrWindow.docShell.sameTypeRootTreeItem; + browser = rootDocShell.QueryInterface(Ci.nsIDocShell).chromeEventHandler; + } + + let browserWindow = browser.ownerGlobal; + try { + browserWindow.navigator.checkProtocolHandlerAllowed( + aProtocol, + aURI, + aDocumentURI + ); + } catch (ex) { + // We should have already shown the user an error. + return; + } + if (lazy.NimbusFeatures.mailto.getVariable("dualPrompt")) { + if ("mailto" === aProtocol) { + this._askUserToSetMailtoHandler(browser, aProtocol, aURI, aTitle); + return; + } + } + + // If the protocol handler is already registered, just return early. + if (this._protocolHandlerRegistered(aProtocol, aURI.spec)) { + return; + } + + // Now Ask the user and provide the proper callback + let message = this._getFormattedString("addProtocolHandlerMessage", [ + aURI.host, + aProtocol, + ]); + + let notificationIcon = aURI.prePath + "/favicon.ico"; + let notificationValue = "Protocol Registration: " + aProtocol; + let addButton = { + label: this._getString("addProtocolHandlerAddButton"), + accessKey: this._getString("addProtocolHandlerAddButtonAccesskey"), + protocolInfo: { protocol: aProtocol, uri: aURI.spec, name: aTitle }, + + callback(aNotification, aButtonInfo) { + let protocol = aButtonInfo.protocolInfo.protocol; + let name = aButtonInfo.protocolInfo.name; + + let handler = Cc[ + "@mozilla.org/uriloader/web-handler-app;1" + ].createInstance(Ci.nsIWebHandlerApp); + handler.name = name; + handler.uriTemplate = aButtonInfo.protocolInfo.uri; + + let eps = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + let handlerInfo = eps.getProtocolHandlerInfo(protocol); + handlerInfo.possibleApplicationHandlers.appendElement(handler); + + // Since the user has agreed to add a new handler, chances are good + // that the next time they see a handler of this type, they're going + // to want to use it. Reset the handlerInfo to ask before the next + // use. + handlerInfo.alwaysAskBeforeHandling = true; + + let hs = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService + ); + hs.store(handlerInfo); + }, + }; + + let notificationBox = browser.getTabBrowser().getNotificationBox(browser); + + // check if the notification box is already shown + if (notificationBox.getNotificationWithValue(notificationValue)) { + return; + } + + notificationBox.appendNotification( + notificationValue, + { + label: message, + image: notificationIcon, + priority: notificationBox.PRIORITY_INFO_LOW, + }, + [addButton] + ); + }, + + /* + * Special implementation for mailto + * + * @param {string} browser + * @param {string} aProtocol + * @param {string} aURI + * @param {string} aTitle + */ + async _askUserToSetMailtoHandler(browser, aProtocol, aURI, aTitle) { + // shortcut for Localization + let l10n = new Localization([ + "branding/brand.ftl", + "browser/webProtocolHandler.ftl", + ]); + let [ + msg_os_box, + msg_os_yes_confirm, + msg_os_yes, + msg_os_no, + msg_box, + msg_yes_confirm, + msg_yes, + msg_no, + ] = await l10n.formatValues([ + { id: "protocolhandler-mailto-os-handler-notificationbox" }, + { id: "protocolhandler-mailto-os-handler-yes-confirm" }, + { id: "protocolhandler-mailto-os-handler-yes-button" }, + { id: "protocolhandler-mailto-os-handler-no-button" }, + { + id: "protocolhandler-mailto-handler-notificationbox-always", + args: { url: aURI.prePath }, + }, + { + id: "protocolhandler-mailto-handler-yes-confirm", + args: { url: aURI.prePath }, + }, + { id: "protocolhandler-mailto-handler-yes-button" }, + { id: "protocolhandler-mailto-handler-no-button" }, + ]); + + // First prompt: + // Only shown if there is a realistic chance that we can really set the OS + // default and can also be disabled with a preference or experiement + if (this._canSetOSDefault(aProtocol)) { + // Only show if not already set and if we have been properly installed + let notificationId = "OS Protocol Registration: " + aProtocol; + let osDefaultNotificationBox = browser + .getTabBrowser() + .getNotificationBox(browser); + if (!osDefaultNotificationBox.getNotificationWithValue(notificationId)) { + osDefaultNotificationBox.appendNotification( + notificationId, + { + label: msg_os_box, + priority: osDefaultNotificationBox.PRIORITY_INFO_LOW, + }, + [ + { + label: msg_os_yes, + callback: () => { + this._setOSDefault(aProtocol); + Glean.protocolhandlerMailto.promptClicked.set_os_default.add(); + osDefaultNotificationBox.appendNotification( + notificationId, + { + label: msg_os_yes_confirm, + priority: osDefaultNotificationBox.PRIORITY_INFO_LOW, + }, + [] + ); + return false; + }, + }, + { + label: msg_os_no, + callback: () => { + Services.prefs.setBoolPref("browser.mailto.prompt.os", false); + Glean.protocolhandlerMailto.promptClicked.dismiss_os_default.add(); + return false; + }, + }, + ] + ); + + Glean.protocolhandlerMailto.handlerPromptShown.os_default.add(); + } + } + + // Second prompt: + // Only shown if the protocol handler is not already registered + if (!this._protocolHandlerRegistered(aProtocol, aURI.spec)) { + let notificationId = "Protocol Registration: " + aProtocol; + let FxDefaultNotificationBox = browser + .getTabBrowser() + .getNotificationBox(browser); + if (!FxDefaultNotificationBox.getNotificationWithValue(notificationId)) { + FxDefaultNotificationBox.appendNotification( + notificationId, + { + label: msg_box, + priority: FxDefaultNotificationBox.PRIORITY_INFO_LOW, + }, + [ + { + label: msg_yes, + callback: () => { + this._setLocalDefault( + aProtocol, + this._addLocal(aProtocol, aTitle, aURI.spec) + ); + Glean.protocolhandlerMailto.promptClicked.set_local_default.add(); + FxDefaultNotificationBox.appendNotification( + notificationId, + { + label: msg_yes_confirm, + priority: FxDefaultNotificationBox.PRIORITY_INFO_LOW, + }, + [] + ); + return false; + }, + }, + { + label: msg_no, + callback: () => { + Glean.protocolhandlerMailto.promptClicked.dismiss_local_default.add(); + return false; + }, + }, + ] + ); + + Glean.protocolhandlerMailto.handlerPromptShown.fx_default.add(); + } + } + }, + + /** + * See nsISupports + */ + QueryInterface: ChromeUtils.generateQI(["nsIWebProtocolHandlerRegistrar"]), +}; diff --git a/browser/components/protocolhandler/components.conf b/browser/components/protocolhandler/components.conf new file mode 100644 index 0000000000..6fd4f03595 --- /dev/null +++ b/browser/components/protocolhandler/components.conf @@ -0,0 +1,15 @@ +# -*- 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/. + +Classes = [ + { + 'cid': '{efbd7b87-9b15-4684-abf0-dc2679daadb1}', + 'contract_ids': ['@mozilla.org/embeddor.implemented/web-protocol-handler-registrar;1'], + 'esModule': 'resource:///modules/WebProtocolHandlerRegistrar.sys.mjs', + 'constructor': 'WebProtocolHandlerRegistrar', + 'processes': ProcessSelector.MAIN_PROCESS_ONLY, + }, +] diff --git a/browser/components/protocolhandler/metrics.yaml b/browser/components/protocolhandler/metrics.yaml new file mode 100644 index 0000000000..43a2898175 --- /dev/null +++ b/browser/components/protocolhandler/metrics.yaml @@ -0,0 +1,55 @@ +# 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/. + +# Adding a new metric? We have docs for that! + +# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html + +--- +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 +$tags: + - 'Firefox :: General' + +protocolhandler.mailto: + prompt_clicked: + type: labeled_counter + description: > + User clicked on a button to approve setting the current site as default + web mail site. The sum of all counters is the total amount of user + interactions and dismissing the same dialog often could be a sign of + a bug. + bugs: + - https://bugzilla.mozilla.org/1864216 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1864216#c8 + notification_emails: + - install-update@mozilla.com + expires: never + labels: + - set_os_default + - dismiss_os_default + - set_local_default + - dismiss_local_default + send_in_pings: + - active + - metrics + + handler_prompt_shown: + type: labeled_counter + description: > + A website was visited, which called registerProtocolHandler + for mailto:// + bugs: + - https://bugzilla.mozilla.org/1864216 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1864216#c8 + notification_emails: + - install-update@mozilla.com + expires: never + labels: + - os_default + - fx_default + send_in_pings: + - active + - metrics diff --git a/browser/components/protocolhandler/moz.build b/browser/components/protocolhandler/moz.build new file mode 100644 index 0000000000..0bd67ba621 --- /dev/null +++ b/browser/components/protocolhandler/moz.build @@ -0,0 +1,18 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +BROWSER_CHROME_MANIFESTS += ["test/browser/browser.toml"] + +EXTRA_JS_MODULES += [ + "WebProtocolHandlerRegistrar.sys.mjs", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "General") diff --git a/browser/components/protocolhandler/test/browser/browser.toml b/browser/components/protocolhandler/test/browser/browser.toml new file mode 100644 index 0000000000..73a0ad72d2 --- /dev/null +++ b/browser/components/protocolhandler/test/browser/browser.toml @@ -0,0 +1,5 @@ +[DEFAULT] + +["browser_registerProtocolHandler_notification.js"] +support-files = ["browser_registerProtocolHandler_notification.html"] +skip-if = ["verify"] diff --git a/browser/components/protocolhandler/test/browser/browser_registerProtocolHandler_notification.html b/browser/components/protocolhandler/test/browser/browser_registerProtocolHandler_notification.html new file mode 100644 index 0000000000..6ffacd2e85 --- /dev/null +++ b/browser/components/protocolhandler/test/browser/browser_registerProtocolHandler_notification.html @@ -0,0 +1,15 @@ + + + + Protocol registrar page + + + + + + + diff --git a/browser/components/protocolhandler/test/browser/browser_registerProtocolHandler_notification.js b/browser/components/protocolhandler/test/browser/browser_registerProtocolHandler_notification.js new file mode 100644 index 0000000000..e2598ae281 --- /dev/null +++ b/browser/components/protocolhandler/test/browser/browser_registerProtocolHandler_notification.js @@ -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 http://mozilla.org/MPL/2.0/. */ + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); +add_task(async function () { + let notificationValue = "Protocol Registration: web+testprotocol"; + let testURI = TEST_PATH + "browser_registerProtocolHandler_notification.html"; + + BrowserTestUtils.startLoadingURIString( + window.gBrowser.selectedBrowser, + testURI + ); + await TestUtils.waitForCondition( + function () { + // Do not start until the notification is up + let notificationBox = window.gBrowser.getNotificationBox(); + let notification = + notificationBox.getNotificationWithValue(notificationValue); + return notification; + }, + "Still can not get notification after retrying 100 times.", + 100, + 100 + ); + + let notificationBox = window.gBrowser.getNotificationBox(); + let notification = + notificationBox.getNotificationWithValue(notificationValue); + ok(notification, "Notification box should be displayed"); + if (notification == null) { + finish(); + return; + } + is( + notification.getAttribute("type"), + "info", + "We expect this notification to have the type of 'info'." + ); + is( + notification.messageImage.getAttribute("src"), + "chrome://global/skin/icons/info-filled.svg", + "We expect this notification to have an icon." + ); + + let buttons = notification.buttonContainer.getElementsByClassName( + "notification-button" + ); + is(buttons.length, 1, "We expect see one button."); + + let button = buttons[0]; + isnot(button.label, null, "We expect the add button to have a label."); + todo(button.accesskey, "We expect the add button to have a accesskey."); +}); diff --git a/browser/components/protocolhandler/test/test_registerHandler.html b/browser/components/protocolhandler/test/test_registerHandler.html new file mode 100644 index 0000000000..2eb4f7dbdc --- /dev/null +++ b/browser/components/protocolhandler/test/test_registerHandler.html @@ -0,0 +1,88 @@ + + + + + Test for Bug 402788 + + + + +Mozilla Bug 402788 +

    + +
    +
    +
    + + diff --git a/browser/components/reportbrokensite/ReportBrokenSite.sys.mjs b/browser/components/reportbrokensite/ReportBrokenSite.sys.mjs new file mode 100644 index 0000000000..706c013383 --- /dev/null +++ b/browser/components/reportbrokensite/ReportBrokenSite.sys.mjs @@ -0,0 +1,770 @@ +/* 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 */ + +const DEFAULT_NEW_REPORT_ENDPOINT = "https://webcompat.com/issues/new"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs", +}); + +const gDescriptionCheckRE = /\S/; + +class ViewState { + #doc; + #mainView; + #reportSentView; + #formElement; + #reasonOptions; + #randomizeReasons = false; + + currentTabURI; + currentTabWebcompatDetailsPromise; + + constructor(doc) { + this.#doc = doc; + this.#mainView = doc.ownerGlobal.PanelMultiView.getViewNode( + this.#doc, + "report-broken-site-popup-mainView" + ); + this.#reportSentView = doc.ownerGlobal.PanelMultiView.getViewNode( + this.#doc, + "report-broken-site-popup-reportSentView" + ); + this.#formElement = doc.ownerGlobal.PanelMultiView.getViewNode( + this.#doc, + "report-broken-site-panel-form" + ); + ViewState.#cache.set(doc, this); + + this.#reasonOptions = Array.from( + // Skip the first option ("choose reason"), since it always stays at the top + this.reasonInput.querySelectorAll(`option:not(:first-of-type)`) + ); + } + + static #cache = new WeakMap(); + static get(doc) { + return ViewState.#cache.get(doc) ?? new ViewState(doc); + } + + get mainPanelview() { + return this.#mainView; + } + + get reportSentPanelview() { + return this.#reportSentView; + } + + get urlInput() { + return this.#mainView.querySelector("#report-broken-site-popup-url"); + } + + get url() { + return this.urlInput.value; + } + + set url(spec) { + this.urlInput.value = spec; + } + + resetURLToCurrentTab() { + const { currentURI } = this.#doc.ownerGlobal.gBrowser.selectedBrowser; + this.currentTabURI = currentURI; + this.urlInput.value = currentURI.spec; + } + + get descriptionInput() { + return this.#mainView.querySelector( + "#report-broken-site-popup-description" + ); + } + + get description() { + return this.descriptionInput.value; + } + + set description(value) { + this.descriptionInput.value = value; + } + + static REASON_CHOICES_ID_PREFIX = "report-broken-site-popup-reason-"; + + get reasonInput() { + return this.#mainView.querySelector("#report-broken-site-popup-reason"); + } + + get reason() { + const reason = this.reasonInput.selectedOptions[0].id.replace( + ViewState.REASON_CHOICES_ID_PREFIX, + "" + ); + return reason == "choose" ? undefined : reason; + } + + set reason(value) { + this.reasonInput.selectedIndex = this.#mainView.querySelector( + `#${ViewState.REASON_CHOICES_ID_PREFIX}${value}` + ).index; + } + + #randomizeReasonsOrdering() { + // As with QuickActionsLoaderDefault, we use the Normandy + // randomizationId as our PRNG seed to ensure that the same + // user should always get the same sequence. + const seed = [...lazy.ClientEnvironment.randomizationId] + .map(x => x.charCodeAt(0)) + .reduce((sum, a) => sum + a, 0); + + const items = [...this.#reasonOptions]; + this.#shuffleArray(items, seed); + items[0].parentNode.append(...items); + } + + #shuffleArray(array, seed) { + // We use SplitMix as it is reputed to have a strong distribution of values. + const prng = this.#getSplitMix32PRNG(seed); + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(prng() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + } + + // SplitMix32 is a splittable pseudorandom number generator (PRNG). + // License: MIT (https://github.com/attilabuti/SimplexNoise) + #getSplitMix32PRNG(a) { + return () => { + a |= 0; + a = (a + 0x9e3779b9) | 0; + var t = a ^ (a >>> 16); + t = Math.imul(t, 0x21f0aaad); + t = t ^ (t >>> 15); + t = Math.imul(t, 0x735a2d97); + return ((t = t ^ (t >>> 15)) >>> 0) / 4294967296; + }; + } + + #restoreReasonsOrdering() { + this.#reasonOptions[0].parentNode.append(...this.#reasonOptions); + } + + get form() { + return this.#formElement; + } + + reset() { + this.currentTabWebcompatDetailsPromise = undefined; + this.form.reset(); + + this.resetURLToCurrentTab(); + } + + ensureReasonOrderingMatchesPref() { + const randomizeReasons = + this.#doc.ownerGlobal.ReportBrokenSite.randomizeReasons; + if (randomizeReasons != this.#randomizeReasons) { + if (randomizeReasons) { + this.#randomizeReasonsOrdering(); + } else { + this.#restoreReasonsOrdering(); + } + this.#randomizeReasons = randomizeReasons; + } + } + + get isURLValid() { + return this.urlInput.checkValidity(); + } + + get isReasonValid() { + const { reasonEnabled, reasonIsOptional } = + this.#doc.ownerGlobal.ReportBrokenSite; + return ( + !reasonEnabled || reasonIsOptional || this.reasonInput.checkValidity() + ); + } + + get isDescriptionValid() { + const { descriptionIsOptional } = this.#doc.ownerGlobal.ReportBrokenSite; + return ( + descriptionIsOptional || + gDescriptionCheckRE.test(this.descriptionInput.value) + ); + } + + #focusMainViewElement(toFocus) { + const panelview = this.#doc.ownerGlobal.PanelView.forNode(this.#mainView); + panelview.selectedElement = toFocus; + panelview.focusSelectedElement(); + } + + focusFirstInvalidElement() { + if (!this.isURLValid) { + this.#focusMainViewElement(this.urlInput); + } else if (!this.isReasonValid) { + this.#focusMainViewElement(this.reasonInput); + this.reasonInput.showPicker(); + } else if (!this.isDescriptionValid) { + this.#focusMainViewElement(this.descriptionInput); + } + } + + get sendMoreInfoLink() { + return this.#mainView.querySelector( + "#report-broken-site-popup-send-more-info-link" + ); + } + + get reasonLabelRequired() { + return this.#mainView.querySelector( + "#report-broken-site-popup-reason-label" + ); + } + + get reasonLabelOptional() { + return this.#mainView.querySelector( + "#report-broken-site-popup-reason-optional-label" + ); + } + + get descriptionLabelRequired() { + return this.#mainView.querySelector( + "#report-broken-site-popup-description-label" + ); + } + + get descriptionLabelOptional() { + return this.#mainView.querySelector( + "#report-broken-site-popup-description-optional-label" + ); + } + + get sendButton() { + return this.#mainView.querySelector( + "#report-broken-site-popup-send-button" + ); + } + + get cancelButton() { + return this.#mainView.querySelector( + "#report-broken-site-popup-cancel-button" + ); + } + + get mainView() { + return this.#mainView; + } + + get reportSentView() { + return this.#reportSentView; + } + + get okayButton() { + return this.#reportSentView.querySelector( + "#report-broken-site-popup-okay-button" + ); + } +} + +export var ReportBrokenSite = new (class ReportBrokenSite { + #newReportEndpoint = undefined; + + get sendMoreInfoEndpoint() { + return this.#newReportEndpoint || DEFAULT_NEW_REPORT_ENDPOINT; + } + + static WEBCOMPAT_REPORTER_CONFIG = { + src: "desktop-reporter", + utm_campaign: "report-broken-site", + utm_source: "desktop-reporter", + }; + + static DATAREPORTING_PREF = "datareporting.healthreport.uploadEnabled"; + static REPORTER_ENABLED_PREF = "ui.new-webcompat-reporter.enabled"; + + static REASON_PREF = "ui.new-webcompat-reporter.reason-dropdown"; + static REASON_PREF_VALUES = { + 0: "disabled", + 1: "optional", + 2: "required", + }; + static REASON_RANDOMIZED_PREF = + "ui.new-webcompat-reporter.reason-dropdown.randomized"; + static SEND_MORE_INFO_PREF = "ui.new-webcompat-reporter.send-more-info-link"; + static NEW_REPORT_ENDPOINT_PREF = + "ui.new-webcompat-reporter.new-report-endpoint"; + static REPORT_SITE_ISSUE_PREF = "extensions.webcompat-reporter.enabled"; + + static MAIN_PANELVIEW_ID = "report-broken-site-popup-mainView"; + static SENT_PANELVIEW_ID = "report-broken-site-popup-reportSentView"; + + #_enabled = false; + get enabled() { + return this.#_enabled; + } + + #reasonEnabled = false; + #reasonIsOptional = true; + #randomizeReasons = false; + #descriptionIsOptional = true; + #sendMoreInfoEnabled = true; + + get reasonEnabled() { + return this.#reasonEnabled; + } + + get reasonIsOptional() { + return this.#reasonIsOptional; + } + + get randomizeReasons() { + return this.#randomizeReasons; + } + + get descriptionIsOptional() { + return this.#descriptionIsOptional; + } + + constructor() { + for (const [name, [pref, dflt]] of Object.entries({ + dataReportingPref: [ReportBrokenSite.DATAREPORTING_PREF, false], + reasonPref: [ReportBrokenSite.REASON_PREF, 0], + reasonRandomizedPref: [ReportBrokenSite.REASON_RANDOMIZED_PREF, false], + sendMoreInfoPref: [ReportBrokenSite.SEND_MORE_INFO_PREF, false], + newReportEndpointPref: [ + ReportBrokenSite.NEW_REPORT_ENDPOINT_PREF, + DEFAULT_NEW_REPORT_ENDPOINT, + ], + enabledPref: [ReportBrokenSite.REPORTER_ENABLED_PREF, true], + reportSiteIssueEnabledPref: [ + ReportBrokenSite.REPORT_SITE_ISSUE_PREF, + false, + ], + })) { + XPCOMUtils.defineLazyPreferenceGetter( + this, + name, + pref, + dflt, + this.#checkPrefs.bind(this) + ); + } + this.#checkPrefs(); + } + + canReportURI(uri) { + return uri && (uri.schemeIs("http") || uri.schemeIs("https")); + } + + #recordGleanEvent(name, extra) { + Glean.webcompatreporting[name].record(extra); + } + + updateParentMenu(event) { + // We need to make sure that the Report Broken Site menu item + // is disabled and/or hidden depending on the prefs/active tab URL + // when our parent popups are shown, and if their tab's location + // changes while they are open. + const tabbrowser = event.target.ownerGlobal.gBrowser; + this.enableOrDisableMenuitems(tabbrowser.selectedBrowser); + + tabbrowser.addTabsProgressListener(this); + event.target.addEventListener( + "popuphidden", + () => { + tabbrowser.removeTabsProgressListener(this); + }, + { once: true } + ); + } + + init(tabbrowser) { + // Called in browser.js. + const { ownerGlobal } = tabbrowser.selectedBrowser; + const { document } = ownerGlobal; + + const state = ViewState.get(document); + + this.#initMainView(state); + this.#initReportSentView(state); + + for (const id of ["menu_HelpPopup", "appMenu-popup"]) { + document + .getElementById(id) + .addEventListener("popupshown", this.updateParentMenu.bind(this)); + } + + state.mainPanelview.addEventListener("ViewShowing", ({ target }) => { + const { selectedBrowser } = target.ownerGlobal.gBrowser; + let source = "helpMenu"; + switch (target.closest("panelmultiview")?.id) { + case "appMenu-multiView": + source = "hamburgerMenu"; + break; + case "protections-popup-multiView": + source = "ETPShieldIconMenu"; + break; + } + this.#onMainViewShown(source, selectedBrowser); + }); + + // Make sure the URL input is focused when the main view pops up. + state.mainPanelview.addEventListener("ViewShown", () => { + const panelview = ownerGlobal.PanelView.forNode(state.mainPanelview); + panelview.selectedElement = state.urlInput; + panelview.focusSelectedElement(); + }); + + // Make sure the Okay button is focused when the report sent view pops up. + state.reportSentPanelview.addEventListener("ViewShown", () => { + const panelview = ownerGlobal.PanelView.forNode( + state.reportSentPanelview + ); + panelview.selectedElement = state.okayButton; + panelview.focusSelectedElement(); + }); + } + + enableOrDisableMenuitems(selectedbrowser) { + // Ensures that the various Report Broken Site menu items and + // toolbar buttons are enabled/hidden when appropriate (and + // also the Help menu's Report Site Issue item)/ + + const canReportUrl = this.canReportURI(selectedbrowser.currentURI); + + const { document } = selectedbrowser.ownerGlobal; + + const cmd = document.getElementById("cmd_reportBrokenSite"); + if (this.enabled) { + cmd.setAttribute("hidden", "false"); // see bug 805653 + } else { + cmd.setAttribute("hidden", "true"); + } + if (canReportUrl) { + cmd.removeAttribute("disabled"); + } else { + cmd.setAttribute("disabled", "true"); + } + + // Changes to the "hidden" and "disabled" state of the command aren't reliably + // reflected on the main menu unless we open it twice, or do it manually. + // (See bug 1864953). + const mainmenuItem = document.getElementById("help_reportBrokenSite"); + if (mainmenuItem) { + mainmenuItem.hidden = !this.enabled; + mainmenuItem.disabled = !canReportUrl; + } + + // Report Site Issue is our older issue reporter, shown in the Help + // menu on pre-release channels. We should hide it unless we're + // disabled, at which point we should show it when available. + const reportSiteIssue = document.getElementById("help_reportSiteIssue"); + if (reportSiteIssue) { + reportSiteIssue.hidden = this.enabled || !this.reportSiteIssueEnabledPref; + reportSiteIssue.disabled = !canReportUrl; + } + + // "Site not working?" on the protections panel should be hidden when + // Report Broken Site is visible (bug 1868527). + const siteNotWorking = document.getElementById( + "protections-popup-tp-switch-section-footer" + ); + if (siteNotWorking) { + siteNotWorking.hidden = this.enabled; + } + } + + #checkPrefs(whichChanged) { + // No breakage reports can be sent by Glean if it's disabled, so we also + // disable the broken site reporter. We also have our own pref. + this.#_enabled = + Services.policies.isAllowed("feedbackCommands") && + this.dataReportingPref && + this.enabledPref; + + this.#reasonEnabled = this.reasonPref == 1 || this.reasonPref == 2; + this.#reasonIsOptional = this.reasonPref == 1; + if (!whichChanged || whichChanged == ReportBrokenSite.REASON_PREF) { + const setting = ReportBrokenSite.REASON_PREF_VALUES[this.reasonPref]; + this.#recordGleanEvent("reasonDropdown", { setting }); + } + + this.#sendMoreInfoEnabled = this.sendMoreInfoPref; + this.#newReportEndpoint = this.newReportEndpointPref; + + this.#randomizeReasons = this.reasonRandomizedPref; + } + + #initMainView(state) { + state.sendButton.addEventListener("command", () => { + state.form.requestSubmit(); + }); + + state.form.addEventListener("submit", async event => { + event.preventDefault(); + if (!state.form.checkValidity()) { + state.focusFirstInvalidElement(); + return; + } + const multiview = event.target.closest("panelmultiview"); + this.#recordGleanEvent("send"); + await this.#sendReportAsGleanPing(state); + multiview.showSubView("report-broken-site-popup-reportSentView"); + state.reset(); + }); + + state.cancelButton.addEventListener("command", ({ target }) => { + target.ownerGlobal.CustomizableUI.hidePanelForNode(target); + state.reset(); + }); + + state.sendMoreInfoLink.addEventListener("click", async event => { + event.preventDefault(); + const tabbrowser = event.target.ownerGlobal.gBrowser; + this.#recordGleanEvent("sendMoreInfo"); + event.target.ownerGlobal.CustomizableUI.hidePanelForNode(event.target); + await this.#openWebCompatTab(tabbrowser); + state.reset(); + }); + } + + #initReportSentView(state) { + state.okayButton.addEventListener("command", ({ target }) => { + target.ownerGlobal.CustomizableUI.hidePanelForNode(target); + }); + } + + async #onMainViewShown(source, selectedBrowser) { + const { document } = selectedBrowser.ownerGlobal; + + let didReset = false; + const state = ViewState.get(document); + const uri = selectedBrowser.currentURI; + if (!state.isURLValid && !state.isDescriptionValid) { + state.reset(); + didReset = true; + } else if (!state.currentTabURI || !uri.equals(state.currentTabURI)) { + state.reset(); + didReset = true; + } else if (!state.url) { + state.resetURLToCurrentTab(); + } + + const { sendMoreInfoLink } = state; + const { sendMoreInfoEndpoint } = this; + if (sendMoreInfoLink.href !== sendMoreInfoEndpoint) { + sendMoreInfoLink.href = sendMoreInfoEndpoint; + } + sendMoreInfoLink.hidden = !this.#sendMoreInfoEnabled; + + state.reasonInput.hidden = !this.#reasonEnabled; + state.reasonInput.required = this.#reasonEnabled && !this.#reasonIsOptional; + + state.ensureReasonOrderingMatchesPref(); + + state.reasonLabelRequired.hidden = + !this.#reasonEnabled || this.#reasonIsOptional; + state.reasonLabelOptional.hidden = + !this.#reasonEnabled || !this.#reasonIsOptional; + + state.descriptionLabelRequired.hidden = this.#descriptionIsOptional; + state.descriptionLabelOptional.hidden = !this.#descriptionIsOptional; + + this.#recordGleanEvent("opened", { source }); + + if (didReset || !state.currentTabWebcompatDetailsPromise) { + state.currentTabWebcompatDetailsPromise = this.#queryActor( + "GetWebCompatInfo", + undefined, + selectedBrowser + ).catch(err => { + console.error("Report Broken Site: unexpected error", err); + }); + } + } + + async #queryActor(msg, params, browser) { + const actor = + browser.browsingContext.currentWindowGlobal.getActor("ReportBrokenSite"); + return actor.sendQuery(msg, params); + } + + async #loadTab(tabbrowser, url, triggeringPrincipal) { + const tab = tabbrowser.addTab(url, { + inBackground: false, + triggeringPrincipal, + }); + const expectedBrowser = tabbrowser.getBrowserForTab(tab); + return new Promise(resolve => { + const listener = { + onLocationChange(browser, webProgress, request, uri, flags) { + if ( + browser == expectedBrowser && + uri.spec == url && + webProgress.isTopLevel + ) { + resolve(tab); + tabbrowser.removeTabsProgressListener(listener); + } + }, + }; + tabbrowser.addTabsProgressListener(listener); + }); + } + + async #openWebCompatTab(tabbrowser) { + const endpointUrl = this.sendMoreInfoEndpoint; + const principal = Services.scriptSecurityManager.createNullPrincipal({}); + const tab = await this.#loadTab(tabbrowser, endpointUrl, principal); + const { document } = tabbrowser.selectedBrowser.ownerGlobal; + const { description, reason, url, currentTabWebcompatDetailsPromise } = + ViewState.get(document); + + return this.#queryActor( + "SendDataToWebcompatCom", + { + reason, + description, + endpointUrl, + reportUrl: url, + reporterConfig: ReportBrokenSite.WEBCOMPAT_REPORTER_CONFIG, + webcompatInfo: await currentTabWebcompatDetailsPromise, + }, + tab.linkedBrowser + ).catch(err => { + console.error("Report Broken Site: unexpected error", err); + }); + } + + async #sendReportAsGleanPing({ + currentTabWebcompatDetailsPromise, + description, + reason, + url, + }) { + const gBase = Glean.brokenSiteReport; + const gTabInfo = Glean.brokenSiteReportTabInfo; + const gAntitracking = Glean.brokenSiteReportTabInfoAntitracking; + const gFrameworks = Glean.brokenSiteReportTabInfoFrameworks; + const gApp = Glean.brokenSiteReportBrowserInfoApp; + const gGraphics = Glean.brokenSiteReportBrowserInfoGraphics; + const gPrefs = Glean.brokenSiteReportBrowserInfoPrefs; + const gSystem = Glean.brokenSiteReportBrowserInfoSystem; + + if (reason) { + gBase.breakageCategory.set(reason); + } + + gBase.description.set(description); + gBase.url.set(url); + + const details = await currentTabWebcompatDetailsPromise; + + if (!details) { + GleanPings.brokenSiteReport.submit(); + return; + } + + const { + antitracking, + browser, + devicePixelRatio, + frameworks, + languages, + userAgent, + } = details; + + gTabInfo.languages.set(languages); + gTabInfo.useragentString.set(userAgent); + gGraphics.devicePixelRatio.set(devicePixelRatio); + + for (const [name, value] of Object.entries(antitracking)) { + gAntitracking[name].set(value); + } + + for (const [name, value] of Object.entries(frameworks)) { + gFrameworks[name].set(value); + } + + const { app, graphics, locales, platform, prefs, security } = browser; + + gApp.defaultLocales.set(locales); + gApp.defaultUseragentString.set(app.defaultUserAgent); + + const { fissionEnabled, isTablet, memoryMB } = platform; + gApp.fissionEnabled.set(fissionEnabled); + gSystem.isTablet.set(isTablet ?? false); + gSystem.memory.set(memoryMB); + + gPrefs.cookieBehavior.set(prefs["network.cookie.cookieBehavior"]); + gPrefs.forcedAcceleratedLayers.set( + prefs["layers.acceleration.force-enabled"] + ); + gPrefs.globalPrivacyControlEnabled.set( + prefs["privacy.globalprivacycontrol.enabled"] + ); + gPrefs.installtriggerEnabled.set( + prefs["extensions.InstallTrigger.enabled"] + ); + gPrefs.opaqueResponseBlocking.set(prefs["browser.opaqueResponseBlocking"]); + gPrefs.resistFingerprintingEnabled.set( + prefs["privacy.resistFingerprinting"] + ); + gPrefs.softwareWebrender.set(prefs["gfx.webrender.software"]); + + if (security) { + for (const [name, value] of Object.entries(security)) { + if (value?.length) { + Glean.brokenSiteReportBrowserInfoSecurity[name].set(value); + } + } + } + + const { devices, drivers, features, hasTouchScreen, monitors } = graphics; + + gGraphics.devicesJson.set(JSON.stringify(devices)); + gGraphics.driversJson.set(JSON.stringify(drivers)); + gGraphics.featuresJson.set(JSON.stringify(features)); + gGraphics.hasTouchScreen.set(hasTouchScreen); + gGraphics.monitorsJson.set(JSON.stringify(monitors)); + + GleanPings.brokenSiteReport.submit(); + } + + open(event) { + const { target } = event.sourceEvent; + const { selectedBrowser } = target.ownerGlobal.gBrowser; + const { ownerGlobal } = selectedBrowser; + const { document } = ownerGlobal; + + switch (target.id) { + case "appMenu-report-broken-site-button": + ownerGlobal.PanelUI.showSubView( + ReportBrokenSite.MAIN_PANELVIEW_ID, + target + ); + break; + case "protections-popup-report-broken-site-button": + document + .getElementById("protections-popup-multiView") + .showSubView(ReportBrokenSite.MAIN_PANELVIEW_ID); + break; + case "help_reportBrokenSite": + // hide the hamburger menu first, as we overlap with it. + const appMenuPopup = document.getElementById("appMenu-popup"); + appMenuPopup?.hidePopup(); + + ownerGlobal.PanelUI.showSubView( + ReportBrokenSite.MAIN_PANELVIEW_ID, + ownerGlobal.PanelUI.menuButton + ); + break; + } + } +})(); diff --git a/browser/components/reportbrokensite/content/reportBrokenSitePanel.inc.xhtml b/browser/components/reportbrokensite/content/reportBrokenSitePanel.inc.xhtml new file mode 100644 index 0000000000..73132d5f37 --- /dev/null +++ b/browser/components/reportbrokensite/content/reportBrokenSitePanel.inc.xhtml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + +
    + +
    non_ad_carousel
    + + + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_below_the_fold.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_below_the_fold.html new file mode 100644 index 0000000000..737e1e654b --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_below_the_fold.html @@ -0,0 +1,83 @@ + + + + + + + + +
    +
    ad_carousel
    + +
    + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_doubled.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_doubled.html new file mode 100644 index 0000000000..f7b7f948d9 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_doubled.html @@ -0,0 +1,182 @@ + + + + + + + +
    + +
    ad_carousel
    + +
    ad_carousel
    + + +
    non_ad_carousel
    + +
    + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_first_element_non_visible.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_first_element_non_visible.html new file mode 100644 index 0000000000..b5a44b325e --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_first_element_non_visible.html @@ -0,0 +1,85 @@ + + + + + + + +
    + +
    ad_carousel
    + +
    + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_hidden.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_hidden.html new file mode 100644 index 0000000000..cccd714326 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_hidden.html @@ -0,0 +1,87 @@ + + + + + + + +
    +
    ad_carousel with display: none;
    + + +
    ad_carousel with no width;
    + + +
    ad_carousel with no height;
    + + +
    ad_carousel that is far above the page
    + +
    + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_outer_container.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_outer_container.html new file mode 100644 index 0000000000..759bd9f0d9 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_outer_container.html @@ -0,0 +1,83 @@ + + + + + + + +
    + +
    ad_carousel
    + +
    + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_query_parameters.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_query_parameters.html new file mode 100644 index 0000000000..7985fb2c51 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_query_parameters.html @@ -0,0 +1,36 @@ + + + + + + +
    +
    +
    +
    ad_sitelink
    + + +

    Example Result

    +
    +
    +
    + +

    New Releases

    +
    + Cras ac velit sed tellus +
    +
    +
    +
    +
    ad_link
    + +

    Example Result

    +
    +
    +
    +
    + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_text.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_text.html new file mode 100644 index 0000000000..66f056fb25 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_text.html @@ -0,0 +1,112 @@ + + + + + + + +
    + +
    +
    ad_sidebar
    +
    + +
    Mock ad image
    +
    + +

    Buy Example Now

    +
    +

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

    + Buy Now +
    +
    +
    + + + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_visibility.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_visibility.html new file mode 100644 index 0000000000..475ada3a3c --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_visibility.html @@ -0,0 +1,46 @@ + + + + + + + +
    +
    +
    +
    ad_link
    + +
    + Ad Link +
    +
    +
    +
    ad_link
    + Ad Link +
    + +
    +
    ad_link
    +
    + Ad Link +
    +
    +
    +
    ad_link
    + Ad Link +
    + +
    +
    ad_link
    +
    + Ad Link +
    +
    +
    +
    ad_link
    + Ad Link +
    +
    +
    + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes.html new file mode 100644 index 0000000000..7bc1b2745e --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes.html @@ -0,0 +1,10 @@ + + + + + + + + Ad link + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes_href.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes_href.html new file mode 100644 index 0000000000..319485cfae --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes_href.html @@ -0,0 +1,10 @@ + + + + + + + + Ad link + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes_none.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes_none.html new file mode 100644 index 0000000000..a119cf71be --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes_none.html @@ -0,0 +1,10 @@ + + + + + + + + Non-Ad Link + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html new file mode 100644 index 0000000000..d987356d7e --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html @@ -0,0 +1,12 @@ + + + + + + + Page will do a redirect + + + + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html^headers^ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html^headers^ new file mode 100644 index 0000000000..94cde2a288 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html^headers^ @@ -0,0 +1 @@ +Cache-Control: no-cache, must-revalidate diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html new file mode 100644 index 0000000000..1c5c31cb38 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html @@ -0,0 +1,17 @@ + + + + + + + Page will do a redirect without doing it in a top load + + + + + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^ new file mode 100644 index 0000000000..419697b050 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^ @@ -0,0 +1,4 @@ +Cache-Control: no-cache, must-revalidate +Pragma: no-cache +Expires: Fri, 01 Jan 1990 00:00:00 GMT +Content-Type: text/html; charset=ISO-8859-1 diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html new file mode 100644 index 0000000000..7ba3f84f6b --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html @@ -0,0 +1,38 @@ + + + + + + + +
    +
    + +
    +
      +
    • test
    • +
    +
    +
    +
    +
    +
    +
    non_ads_link
    + +

    Example of a non ad

    +
    +
    +
    +
    + + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html^headers^ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html^headers^ new file mode 100644 index 0000000000..62847d0585 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html^headers^ @@ -0,0 +1 @@ +Cache-Control: private, max-age=0 diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html new file mode 100644 index 0000000000..9c4d371691 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html @@ -0,0 +1,39 @@ + + + + + + + +
    +
    + +
    +
    + +
    + Test's + Test 2 +
    +
    + +
    + + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html^headers^ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html^headers^ new file mode 100644 index 0000000000..94cde2a288 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html^headers^ @@ -0,0 +1 @@ +Cache-Control: no-cache, must-revalidate diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html new file mode 100644 index 0000000000..c8a3245446 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html @@ -0,0 +1,12 @@ + + + + + + + Page will do a redirect + + + + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html^headers^ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html^headers^ new file mode 100644 index 0000000000..94cde2a288 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html^headers^ @@ -0,0 +1 @@ +Cache-Control: no-cache, must-revalidate diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_shopping.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_shopping.html new file mode 100644 index 0000000000..faa6c057a4 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_shopping.html @@ -0,0 +1,15 @@ + + + + + + + Document + + + + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorization.html b/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorization.html new file mode 100644 index 0000000000..b9569ba2d6 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorization.html @@ -0,0 +1,45 @@ + + + + + + Document + + + + +
    + + +
    + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationCapProcessedDomains.html b/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationCapProcessedDomains.html new file mode 100644 index 0000000000..63a44b8e77 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationCapProcessedDomains.html @@ -0,0 +1,64 @@ + + + + + + Document + + + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationReporting.html b/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationReporting.html new file mode 100644 index 0000000000..22f763191a --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationReporting.html @@ -0,0 +1,45 @@ + + + + + + Document + + + + +
    + + +
    + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryDomainExtraction.html b/browser/components/search/test/browser/telemetry/searchTelemetryDomainExtraction.html new file mode 100644 index 0000000000..b49e5610ae --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryDomainExtraction.html @@ -0,0 +1,84 @@ + + + + + + Document + + + + + diff --git a/browser/components/search/test/browser/telemetry/searchTelemetrySinglePageApp.html b/browser/components/search/test/browser/telemetry/searchTelemetrySinglePageApp.html new file mode 100644 index 0000000000..7598da694e --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetrySinglePageApp.html @@ -0,0 +1,243 @@ + + + + + Fake SERP + + + + +
    + +
    +
    +
    + + + + diff --git a/browser/components/search/test/browser/telemetry/serp.css b/browser/components/search/test/browser/telemetry/serp.css new file mode 100644 index 0000000000..5b3865da44 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/serp.css @@ -0,0 +1,164 @@ +:root { + --margin-left: 80px; + --subtle: whitesmoke; + --carousel-card-width: 180px; +} + +body { + margin: 0; + padding: 0 0 80px 0; +} + +a:link { + text-decoration: none; +} + +a:visited { + color: blue; +} + +h5[test-label] { + margin-top: 30px; + margin-bottom: 4px; +} + +nav { + border-bottom: 1px solid #ececec; + padding-bottom: 20px; + margin-bottom: 20px; +} + +#searchform { + padding-top: 20px; + margin-bottom: 20px; +} + +nav>div, +#searchform, +.moz-carousel, +.factrow { + display: flex; + align-items: center; +} + +nav>div, +#searchform { + gap: 40px; +} + +nav>div, +#searchform, +#searchresults, +#top { + margin-left: var(--margin-left); +} + +#searchbox { + font-size: 14px; + padding: 10px 20px; + width: 300px; + border-radius: 20px; + border: 2px solid var(--subtle); + height: 20px; +} + +.card-container { + white-space: nowrap; + overflow-x: auto; + overflow-y: hidden; +} + +.card-container>.card { + height: 160px; + border-radius: 3px; + border: 1px solid var(--subtle); + display: inline-block; + box-sizing: border-box; + padding: 10px; +} + +.card-container>.card:not(:last-child) { + margin-right: 10px; +} + +.card-container>.card>a { + display: block; + margin-bottom: 2px; +} + +#searchresults { + width: 900px; + display: grid; + grid-template-columns: 600px 300px; +} + +.moz-carousel, +.factrow { + gap: 10px; +} + +.moz-carousel { + overflow: hidden; +} + +.moz-carousel[narrow], +.moz-carousel-container { + width: calc(var(--carousel-card-width) * 3 + (3 * 10px)); + overflow-x: auto; +} + +.moz-carousel[extra] { + width: calc(var(--carousel-card-width) * 4 + (3 * 10px)); +} + +.moz-carousel>.moz-inner { + border: 1px solid var(--subtle); + border-radius: 10px; + padding: 10px; +} + +.moz-carousel>.moz-carousel-card { + flex: 1 0 var(--carousel-card-width); + border: 1px solid var(--subtle); + font-size: 14px; +} + +.moz-carousel-card .moz-carousel-image { + width: 100%; + height: 120px; + background-color: var(--subtle); + display: flex; + align-items: center; + justify-content: center; +} + +.moz-carousel-card-inner-content { + padding: 10px 20px 20px 20px; +} + +.multi-col { + display: grid; + padding: 10px 20px 20px 20px; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.mock-image { + height: 100px; + background-color: var(--subtle); + display: flex; + align-items: center; + justify-content: center; +} + +/* Some SERPs hide anchors using CSS */ +.hidden { + display: none; +} + +/* Typography */ +h2 { + line-height: 100%; + margin-bottom: 10px; + margin-top: 10px; +} diff --git a/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.html b/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.html new file mode 100644 index 0000000000..8408066897 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.html @@ -0,0 +1,14 @@ + + + + + + + Ad link + Second Ad link + + + + + diff --git a/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.sjs b/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.sjs new file mode 100644 index 0000000000..7a6382d1cb --- /dev/null +++ b/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.sjs @@ -0,0 +1,21 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(request, response) { + const DELAY_MS = 2000; + response.processAsync(); + + response.setHeader("Cache-Control", "no-cache", false); + response.setHeader("Content-Type", "image/png", false); + response.write("Start loading image"); + + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init( + () => { + response.write("Finish loading image"); + response.finish(); + }, + DELAY_MS, + Ci.nsITimer.TYPE_ONE_SHOT + ); +} diff --git a/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads_on_load_event.html b/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads_on_load_event.html new file mode 100644 index 0000000000..517dd30206 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads_on_load_event.html @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.sjs b/browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.sjs new file mode 100644 index 0000000000..1978b4f665 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.sjs @@ -0,0 +1,9 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(req, resp) { + let suffixes = ["foo", "bar"]; + let data = [req.queryString, suffixes.map(s => req.queryString + s)]; + resp.setHeader("Content-Type", "application/json", false); + resp.write(JSON.stringify(data)); +} diff --git a/browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.xml b/browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.xml new file mode 100644 index 0000000000..4a3f6cdf33 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.xml @@ -0,0 +1,6 @@ + + +browser_UsageTelemetry usageTelemetrySearchSuggestions.xml + + + diff --git a/browser/components/search/test/browser/test.html b/browser/components/search/test/browser/test.html new file mode 100644 index 0000000000..a39bece4ff --- /dev/null +++ b/browser/components/search/test/browser/test.html @@ -0,0 +1,8 @@ + + + + + Bug 426329 + + + diff --git a/browser/components/search/test/browser/testEngine.xml b/browser/components/search/test/browser/testEngine.xml new file mode 100644 index 0000000000..9c25993232 --- /dev/null +++ b/browser/components/search/test/browser/testEngine.xml @@ -0,0 +1,12 @@ + + Foo + Foo Search + utf-8 + data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABGklEQVQoz2NgGB6AnZ1dUlJSXl4eSDIyMhLW4Ovr%2B%2Fr168uXL69Zs4YoG%2BLi4i5dusTExMTGxsbNzd3f37937976%2BnpmZmagbHR09J49e5YvX66kpATVEBYW9ubNm2nTphkbG7e2tp44cQLIuHfvXm5urpaWFlDKysqqu7v73LlzECMYIiIiHj58mJCQoKKicvXq1bS0NKBgW1vbjh074uPjgeqAXE1NzSdPnvDz84M0AEUvXLgAsW379u1z5swBen3jxo2zZ892cHB4%2BvQp0KlAfwI1cHJyghQFBwfv2rULokFXV%2FfixYu7d%2B8GGqGgoMDKyrpu3br9%2B%2FcDuXl5eVA%2FAEWBfoWHAdAYoNuAYQ0XAeoUERFhGDYAAPoUaT2dfWJuAAAAAElFTkSuQmCC + + + + http://mochi.test:8888/browser/browser/components/search/test/browser/ + fooalias + diff --git a/browser/components/search/test/browser/testEngine_chromeicon.xml b/browser/components/search/test/browser/testEngine_chromeicon.xml new file mode 100644 index 0000000000..3ce80bcaea --- /dev/null +++ b/browser/components/search/test/browser/testEngine_chromeicon.xml @@ -0,0 +1,12 @@ + + FooChromeIcon + Foo Chrome Icon Search + utf-8 + chrome://browser/skin/info.svg + + + + http://mochi.test:8888/browser/browser/components/search/test/browser/ + foochromeiconalias + diff --git a/browser/components/search/test/browser/testEngine_diacritics.xml b/browser/components/search/test/browser/testEngine_diacritics.xml new file mode 100644 index 0000000000..340893348d --- /dev/null +++ b/browser/components/search/test/browser/testEngine_diacritics.xml @@ -0,0 +1,12 @@ + + Foo ♡ + Engine whose ShortName contains non-BMP Unicode characters + utf-8 + data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABGklEQVQoz2NgGB6AnZ1dUlJSXl4eSDIyMhLW4Ovr%2B%2Fr168uXL69Zs4YoG%2BLi4i5dusTExMTGxsbNzd3f37937976%2BnpmZmagbHR09J49e5YvX66kpATVEBYW9ubNm2nTphkbG7e2tp44cQLIuHfvXm5urpaWFlDKysqqu7v73LlzECMYIiIiHj58mJCQoKKicvXq1bS0NKBgW1vbjh074uPjgeqAXE1NzSdPnvDz84M0AEUvXLgAsW379u1z5swBen3jxo2zZ892cHB4%2BvQp0KlAfwI1cHJyghQFBwfv2rULokFXV%2FfixYu7d%2B8GGqGgoMDKyrpu3br9%2B%2FcDuXl5eVA%2FAEWBfoWHAdAYoNuAYQ0XAeoUERFhGDYAAPoUaT2dfWJuAAAAAElFTkSuQmCC + + + + http://mochi.test:8888/browser/browser/components/search/test/browser/ + diacriticalias + diff --git a/browser/components/search/test/browser/testEngine_dupe.xml b/browser/components/search/test/browser/testEngine_dupe.xml new file mode 100644 index 0000000000..86c4cfadaf --- /dev/null +++ b/browser/components/search/test/browser/testEngine_dupe.xml @@ -0,0 +1,12 @@ + + FooDupe + Second Engine Search + utf-8 + data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABGklEQVQoz2NgGB6AnZ1dUlJSXl4eSDIyMhLW4Ovr%2B%2Fr168uXL69Zs4YoG%2BLi4i5dusTExMTGxsbNzd3f37937976%2BnpmZmagbHR09J49e5YvX66kpATVEBYW9ubNm2nTphkbG7e2tp44cQLIuHfvXm5urpaWFlDKysqqu7v73LlzECMYIiIiHj58mJCQoKKicvXq1bS0NKBgW1vbjh074uPjgeqAXE1NzSdPnvDz84M0AEUvXLgAsW379u1z5swBen3jxo2zZ892cHB4%2BvQp0KlAfwI1cHJyghQFBwfv2rULokFXV%2FfixYu7d%2B8GGqGgoMDKyrpu3br9%2B%2FcDuXl5eVA%2FAEWBfoWHAdAYoNuAYQ0XAeoUERFhGDYAAPoUaT2dfWJuAAAAAElFTkSuQmCC + + + + http://mochi.test:8888/browser/browser/components/search/test/browser/ + secondalias + diff --git a/browser/components/search/test/browser/testEngine_mozsearch.xml b/browser/components/search/test/browser/testEngine_mozsearch.xml new file mode 100644 index 0000000000..2f285feb4c --- /dev/null +++ b/browser/components/search/test/browser/testEngine_mozsearch.xml @@ -0,0 +1,14 @@ + + Foo + Foo Search + utf-8 + data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABGklEQVQoz2NgGB6AnZ1dUlJSXl4eSDIyMhLW4Ovr%2B%2Fr168uXL69Zs4YoG%2BLi4i5dusTExMTGxsbNzd3f37937976%2BnpmZmagbHR09J49e5YvX66kpATVEBYW9ubNm2nTphkbG7e2tp44cQLIuHfvXm5urpaWFlDKysqqu7v73LlzECMYIiIiHj58mJCQoKKicvXq1bS0NKBgW1vbjh074uPjgeqAXE1NzSdPnvDz84M0AEUvXLgAsW379u1z5swBen3jxo2zZ892cHB4%2BvQp0KlAfwI1cHJyghQFBwfv2rULokFXV%2FfixYu7d%2B8GGqGgoMDKyrpu3br9%2B%2FcDuXl5eVA%2FAEWBfoWHAdAYoNuAYQ0XAeoUERFhGDYAAPoUaT2dfWJuAAAAAElFTkSuQmCC + + + + + + + + http://mochi.test:8888/browser/browser/components/search/test/browser/ + diff --git a/browser/components/search/test/browser/test_search.html b/browser/components/search/test/browser/test_search.html new file mode 100644 index 0000000000..010d1fdc82 --- /dev/null +++ b/browser/components/search/test/browser/test_search.html @@ -0,0 +1 @@ +test%20search diff --git a/browser/components/search/test/browser/tooManyEnginesOffered.html b/browser/components/search/test/browser/tooManyEnginesOffered.html new file mode 100644 index 0000000000..64e48d05e9 --- /dev/null +++ b/browser/components/search/test/browser/tooManyEnginesOffered.html @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/browser/components/search/test/browser/trendingSuggestionEngine.sjs b/browser/components/search/test/browser/trendingSuggestionEngine.sjs new file mode 100644 index 0000000000..358d2a6077 --- /dev/null +++ b/browser/components/search/test/browser/trendingSuggestionEngine.sjs @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +let gTimer; + +function handleRequest(req, resp) { + // Parse the query params. If the params aren't in the form "foo=bar", then + // treat the entire query string as a search string. + let params = req.queryString.split("&").reduce((memo, pair) => { + let [key, val] = pair.split("="); + if (!val) { + // This part isn't in the form "foo=bar". Treat it as the search string + // (the "query"). + val = key; + key = "query"; + } + memo[decode(key)] = decode(val); + return memo; + }, {}); + + writeResponse(params, resp); +} + +function writeResponse(params, resp) { + // Echoes back 15 results, query, query0, query1, query2 etc. + let query = params.query || ""; + let suffixes = [...Array(15).keys()].map(s => query + s); + // If we have a query, echo it back (to help test deduplication) + if (query) { + suffixes.unshift(query); + } + let data = [query, suffixes]; + + if (params?.richsuggestions) { + data.push([]); + data.push({ + "google:suggestdetail": data[1].map(() => ({ + a: "Extended title", + dc: "#FFFFFF", + i: "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==", + t: "Title", + })), + }); + } + resp.setHeader("Content-Type", "application/json", false); + + let json = JSON.stringify(data); + let utf8 = String.fromCharCode(...new TextEncoder().encode(json)); + resp.write(utf8); +} + +function decode(str) { + return decodeURIComponent(str.replace(/\+/g, encodeURIComponent(" "))); +} diff --git a/browser/components/search/test/marionette/manifest.toml b/browser/components/search/test/marionette/manifest.toml new file mode 100644 index 0000000000..152442bc5b --- /dev/null +++ b/browser/components/search/test/marionette/manifest.toml @@ -0,0 +1,4 @@ +[DEFAULT] +run-if = ["buildapp == 'browser'"] + +["test_engines_on_restart.py"] diff --git a/browser/components/search/test/marionette/test_engines_on_restart.py b/browser/components/search/test/marionette/test_engines_on_restart.py new file mode 100644 index 0000000000..d7a0634e75 --- /dev/null +++ b/browser/components/search/test/marionette/test_engines_on_restart.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import textwrap + +from marionette_harness.marionette_test import MarionetteTestCase + + +class TestEnginesOnRestart(MarionetteTestCase): + def setUp(self): + super(TestEnginesOnRestart, self).setUp() + self.marionette.enforce_gecko_prefs( + { + "browser.search.log": True, + } + ) + + def get_default_search_engine(self): + """Retrieve the identifier of the default search engine.""" + + script = """\ + let [resolve] = arguments; + let searchService = Components.classes[ + "@mozilla.org/browser/search-service;1"] + .getService(Components.interfaces.nsISearchService); + return searchService.init().then(function () { + resolve(searchService.defaultEngine.identifier); + }); + """ + + with self.marionette.using_context(self.marionette.CONTEXT_CHROME): + return self.marionette.execute_async_script(textwrap.dedent(script)) + + def test_engines(self): + self.assertTrue(self.get_default_search_engine().startswith("google")) + self.marionette.set_pref("intl.locale.requested", "kk_KZ") + self.marionette.restart(clean=False, in_app=True) + self.assertTrue(self.get_default_search_engine().startswith("google")) diff --git a/browser/components/search/test/unit/domain_category_mappings_1a.json b/browser/components/search/test/unit/domain_category_mappings_1a.json new file mode 100644 index 0000000000..51b18e12a7 --- /dev/null +++ b/browser/components/search/test/unit/domain_category_mappings_1a.json @@ -0,0 +1,3 @@ +{ + "Wrq9YDsieAMC3Y2DSY5Rcg==": [1, 100] +} diff --git a/browser/components/search/test/unit/domain_category_mappings_1b.json b/browser/components/search/test/unit/domain_category_mappings_1b.json new file mode 100644 index 0000000000..698ef45f1a --- /dev/null +++ b/browser/components/search/test/unit/domain_category_mappings_1b.json @@ -0,0 +1,3 @@ +{ + "G99y4E1rUMgqSMfk3TjMaQ==": [2, 90] +} diff --git a/browser/components/search/test/unit/domain_category_mappings_2a.json b/browser/components/search/test/unit/domain_category_mappings_2a.json new file mode 100644 index 0000000000..08db2fa8c2 --- /dev/null +++ b/browser/components/search/test/unit/domain_category_mappings_2a.json @@ -0,0 +1,3 @@ +{ + "Wrq9YDsieAMC3Y2DSY5Rcg==": [1, 80] +} diff --git a/browser/components/search/test/unit/domain_category_mappings_2b.json b/browser/components/search/test/unit/domain_category_mappings_2b.json new file mode 100644 index 0000000000..dec2d130c1 --- /dev/null +++ b/browser/components/search/test/unit/domain_category_mappings_2b.json @@ -0,0 +1,3 @@ +{ + "G99y4E1rUMgqSMfk3TjMaQ==": [2, 50, 4, 80] +} diff --git a/browser/components/search/test/unit/test_search_telemetry_categorization_logic.js b/browser/components/search/test/unit/test_search_telemetry_categorization_logic.js new file mode 100644 index 0000000000..947a7aae46 --- /dev/null +++ b/browser/components/search/test/unit/test_search_telemetry_categorization_logic.js @@ -0,0 +1,346 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test ensures we are correctly applying the SERP categorization logic to + * the domains that have been extracted from the SERP. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + SearchSERPCategorization: "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchSERPDomainToCategoriesMap: + "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchSERPTelemetryUtils: "resource:///modules/SearchSERPTelemetry.sys.mjs", +}); + +const TEST_DOMAIN_TO_CATEGORIES_MAP_SIMPLE = { + "byVQ4ej7T7s2xf/cPqgMyw==": [2, 90], + "1TEnSjgNCuobI6olZinMiQ==": [2, 95], + "/Bnju09b9iBPjg7K+5ENIw==": [2, 78, 4, 10], + "Ja6RJq5LQftdl7NQrX1avQ==": [2, 56, 4, 24], + "Jy26Qt99JrUderAcURtQ5A==": [2, 89], + "sZnJyyzY9QcN810Q6jfbvw==": [2, 43], + "QhmteGKeYk0okuB/bXzwRw==": [2, 65], + "CKQZZ1IJjzjjE4LUV8vUSg==": [2, 67], + "FK7mL5E1JaE6VzOiGMmlZg==": [2, 89], + "mzcR/nhDcrs0ed4kTf+ZFg==": [2, 99], +}; + +const TEST_DOMAIN_TO_CATEGORIES_MAP_INCONCLUSIVE = { + "IkOfhoSlHTMIZzWXkYf7fg==": [0, 0], + "PIAHxeaBOeDNY2tvZKqQuw==": [0, 0], + "DKx2mqmFtEvxrHAqpwSevA==": [0, 0], + "DlZKnz9ryYqbxJq9wodzlA==": [0, 0], + "n3NWT4N9JlKX0I7MUtAsYg==": [0, 0], + "A6KyupOlu5zXt8loti90qw==": [0, 0], + "gf5rpseruOaq8nXOSJPG3Q==": [0, 0], + "vlQYOvbcbAp6sMx54OwqCQ==": [0, 0], + "8PcaPATLgmHD9SR0/961Sw==": [0, 0], + "l+hLycEAW2v/OPE/XFpNwQ==": [0, 0], +}; + +const TEST_DOMAIN_TO_CATEGORIES_MAP_UNKNOWN_AND_INCONCLUSIVE = { + "CEA642T3hV+Fdi2PaRH9BQ==": [0, 0], + "cVqopYLASYxcWdDW4F+w2w==": [0, 0], + "X61OdTU20n8pxZ76K2eAHg==": [0, 0], + "/srrOggOAwgaBGCsPdC4bA==": [0, 0], + "onnMGn+MmaCQx3RNLBzGOQ==": [0, 0], +}; + +const TEST_DOMAIN_TO_CATEGORIES_MAP_ALL_TYPES = { + "VSXaqgDKYWrJ/yjsFomUdg==": [3, 90], + "6re74Kk34n2V6VCdLmCD5w==": [3, 88], + "s8gOGIaFnly5hHX7nPncnw==": [3, 90, 6, 2], + "zfRJyKV+2jd1RKNsSHm9pw==": [3, 78, 6, 7], + "zcW+KbRfLRO6Dljf5qnuwQ==": [3, 97], + "Rau9mfbBcIRiRQIliUxkow==": [0, 0], + "4AFhUOmLQ8804doOsI4jBA==": [0, 0], +}; + +const TEST_DOMAIN_TO_CATEGORIES_MAP_TIE = { + "fmEqRSc+pBr9noi0l99nGw==": [1, 50, 2, 50], + "cms8ipz0JQ3WS9o48RtvnQ==": [1, 50, 2, 50], + "y8Haj7Qdmx+k762RaxCPvA==": [1, 50, 2, 50], + "tCbLmi5xJ/OrF8tbRm8PrA==": [1, 50, 2, 50], + "uYNQECmDShqI409HrSTdLQ==": [1, 50, 2, 50], + "D88hdsmzLWIXYhkrDal33w==": [3, 50, 4, 50], + "1mhx0I0B4cEaI91x8zor7Q==": [5, 50, 6, 50], + "dVZYATQixuBHmalCFR9+Lw==": [7, 50, 8, 50], + "pdOFJG49D7hE/+FtsWDihQ==": [9, 50, 10, 50], + "+gl+dBhWE0nx0AM69m2g5w==": [11, 50, 12, 50], +}; + +const TEST_DOMAIN_TO_CATEGORIES_MAP_RANK_PENALIZATION_1 = { + "VSXaqgDKYWrJ/yjsFomUdg==": [1, 45], + "6re74Kk34n2V6VCdLmCD5w==": [2, 45], + "s8gOGIaFnly5hHX7nPncnw==": [3, 45], + "zfRJyKV+2jd1RKNsSHm9pw==": [4, 45], + "zcW+KbRfLRO6Dljf5qnuwQ==": [5, 45], + "Rau9mfbBcIRiRQIliUxkow==": [6, 45], + "4AFhUOmLQ8804doOsI4jBA==": [7, 45], + "YZ3aEL73MR+Cjog0D7A24w==": [8, 45], + "crMclD9rwInEQ30DpZLg+g==": [9, 45], + "/r7oPRoE6LJAE95nuwmu7w==": [10, 45], +}; + +const TEST_DOMAIN_TO_CATEGORIES_MAP_RANK_PENALIZATION_2 = { + "sHWSmFwSYL3snycBZCY8Kg==": [1, 35, 2, 4], + "FZ5zPYh6ByI0KGWKkmpDoA==": [1, 5, 2, 94], +}; + +add_setup(async () => { + Services.prefs.setBoolPref( + "browser.search.serpEventTelemetryCategorization.enabled", + true + ); +}); + +add_task(async function test_categorization_simple() { + SearchSERPDomainToCategoriesMap.overrideMapForTests( + TEST_DOMAIN_TO_CATEGORIES_MAP_SIMPLE + ); + + let domains = new Set([ + "test1.com", + "test2.com", + "test3.com", + "test4.com", + "test5.com", + "test6.com", + "test7.com", + "test8.com", + "test9.com", + "test10.com", + ]); + + let resultsToReport = + SearchSERPCategorization.applyCategorizationLogic(domains); + + Assert.deepEqual( + resultsToReport, + { category: "2", num_domains: 10, num_inconclusive: 0, num_unknown: 0 }, + "Should report the correct values for categorizing the SERP." + ); +}); + +add_task(async function test_categorization_inconclusive() { + SearchSERPDomainToCategoriesMap.overrideMapForTests( + TEST_DOMAIN_TO_CATEGORIES_MAP_INCONCLUSIVE + ); + + let domains = new Set([ + "test11.com", + "test12.com", + "test13.com", + "test14.com", + "test15.com", + "test16.com", + "test17.com", + "test18.com", + "test19.com", + "test20.com", + ]); + + let resultsToReport = + SearchSERPCategorization.applyCategorizationLogic(domains); + + Assert.deepEqual( + resultsToReport, + { + category: SearchSERPTelemetryUtils.CATEGORIZATION.INCONCLUSIVE, + num_domains: 10, + num_inconclusive: 10, + num_unknown: 0, + }, + "Should report the correct values for categorizing the SERP." + ); +}); + +add_task(async function test_categorization_unknown() { + // Reusing TEST_DOMAIN_TO_CATEGORIES_MAP_SIMPLE since none of this task's + // domains will be keys within it. + SearchSERPDomainToCategoriesMap.overrideMapForTests( + TEST_DOMAIN_TO_CATEGORIES_MAP_SIMPLE + ); + + let domains = new Set([ + "test21.com", + "test22.com", + "test23.com", + "test24.com", + "test25.com", + "test26.com", + "test27.com", + "test28.com", + "test29.com", + "test30.com", + ]); + + let resultsToReport = + SearchSERPCategorization.applyCategorizationLogic(domains); + + Assert.deepEqual( + resultsToReport, + { + category: SearchSERPTelemetryUtils.CATEGORIZATION.INCONCLUSIVE, + num_domains: 10, + num_inconclusive: 0, + num_unknown: 10, + }, + "Should report the correct values for categorizing the SERP." + ); +}); + +add_task(async function test_categorization_unknown_and_inconclusive() { + SearchSERPDomainToCategoriesMap.overrideMapForTests( + TEST_DOMAIN_TO_CATEGORIES_MAP_UNKNOWN_AND_INCONCLUSIVE + ); + + let domains = new Set([ + "test31.com", + "test32.com", + "test33.com", + "test34.com", + "test35.com", + "test36.com", + "test37.com", + "test38.com", + "test39.com", + "test40.com", + ]); + + let resultsToReport = + SearchSERPCategorization.applyCategorizationLogic(domains); + + Assert.deepEqual( + resultsToReport, + { + category: SearchSERPTelemetryUtils.CATEGORIZATION.INCONCLUSIVE, + num_domains: 10, + num_inconclusive: 5, + num_unknown: 5, + }, + "Should report the correct values for categorizing the SERP." + ); +}); + +// Tests a mixture of categorized, inconclusive and unknown domains. +add_task(async function test_categorization_all_types() { + SearchSERPDomainToCategoriesMap.overrideMapForTests( + TEST_DOMAIN_TO_CATEGORIES_MAP_ALL_TYPES + ); + + // First 5 domains are categorized, 6th and 7th are inconclusive and the last + // 3 are unknown. + let domains = new Set([ + "test51.com", + "test52.com", + "test53.com", + "test54.com", + "test55.com", + "test56.com", + "test57.com", + "test58.com", + "test59.com", + "test60.com", + ]); + + let resultsToReport = + SearchSERPCategorization.applyCategorizationLogic(domains); + + Assert.deepEqual( + resultsToReport, + { + category: "3", + num_domains: 10, + num_inconclusive: 2, + num_unknown: 3, + }, + "Should report the correct values for categorizing the SERP." + ); +}); + +add_task(async function test_categorization_tie() { + SearchSERPDomainToCategoriesMap.overrideMapForTests( + TEST_DOMAIN_TO_CATEGORIES_MAP_TIE + ); + + let domains = new Set([ + "test41.com", + "test42.com", + "test43.com", + "test44.com", + "test45.com", + "test46.com", + "test47.com", + "test48.com", + "test49.com", + "test50.com", + ]); + + let resultsToReport = + SearchSERPCategorization.applyCategorizationLogic(domains); + + Assert.equal( + [1, 2].includes(resultsToReport.category), + true, + "Category should be one of the 2 categories with the max score." + ); + delete resultsToReport.category; + Assert.deepEqual( + resultsToReport, + { + num_domains: 10, + num_inconclusive: 0, + num_unknown: 0, + }, + "Should report the correct counts for the various domain types." + ); +}); + +add_task(async function test_rank_penalization_equal_scores() { + SearchSERPDomainToCategoriesMap.overrideMapForTests( + TEST_DOMAIN_TO_CATEGORIES_MAP_RANK_PENALIZATION_1 + ); + + let domains = new Set([ + "test51.com", + "test52.com", + "test53.com", + "test54.com", + "test55.com", + "test56.com", + "test57.com", + "test58.com", + "test59.com", + "test60.com", + ]); + + let resultsToReport = + SearchSERPCategorization.applyCategorizationLogic(domains); + + Assert.deepEqual( + resultsToReport, + { category: "1", num_domains: 10, num_inconclusive: 0, num_unknown: 0 }, + "Should report the correct values for categorizing the SERP." + ); +}); + +add_task(async function test_rank_penalization_highest_score_lower_on_page() { + SearchSERPDomainToCategoriesMap.overrideMapForTests( + TEST_DOMAIN_TO_CATEGORIES_MAP_RANK_PENALIZATION_2 + ); + + let domains = new Set(["test61.com", "test62.com"]); + + let resultsToReport = + SearchSERPCategorization.applyCategorizationLogic(domains); + + Assert.deepEqual( + resultsToReport, + { category: "2", num_domains: 2, num_inconclusive: 0, num_unknown: 0 }, + "Should report the correct values for categorizing the SERP." + ); +}); diff --git a/browser/components/search/test/unit/test_search_telemetry_categorization_process_domains.js b/browser/components/search/test/unit/test_search_telemetry_categorization_process_domains.js new file mode 100644 index 0000000000..84acedaa7a --- /dev/null +++ b/browser/components/search/test/unit/test_search_telemetry_categorization_process_domains.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * This test ensures we are correctly processing the domains that have been + * extracted from a SERP. + */ + +ChromeUtils.defineESModuleGetters(this, { + BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs", + SearchSERPCategorization: "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +// Links including the provider name are not extracted. +const PROVIDER = "example"; + +const TESTS = [ + { + title: "Domains matching the provider.", + domains: ["example.com", "www.example.com", "www.foobar.com"], + expected: ["foobar.com"], + }, + { + title: "Second-level domains to a top-level domain.", + domains: [ + "www.foobar.gc.ca", + "www.foobar.gov.uk", + "foobar.co.uk", + "www.foobar.co.il", + ], + expected: ["foobar.gc.ca", "foobar.gov.uk", "foobar.co.uk", "foobar.co.il"], + }, + { + title: "Long subdomain.", + domains: ["ab.cd.ef.gh.foobar.com"], + expected: ["foobar.com"], + }, + { + title: "Same top-level domain.", + domains: ["foobar.com", "www.foobar.com", "abc.def.foobar.com"], + expected: ["foobar.com"], + }, + { + title: "Empty input.", + domains: [""], + expected: [], + }, +]; + +add_setup(async function () { + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "serpEventTelemetry.enabled", + true + ); + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + + "serpEventTelemetryCategorization.enabled", + true + ); + + // Required or else BrowserSearchTelemetry will throw. + sinon.stub(BrowserSearchTelemetry, "shouldRecordSearchCount").returns(true); + await SearchSERPTelemetry.init(); +}); + +add_task(async function test_parsing_extracted_urls() { + for (let i = 0; i < TESTS.length; i++) { + let currentTest = TESTS[i]; + let domains = new Set(currentTest.domains); + + if (currentTest.title) { + info(currentTest.title); + } + let expectedDomains = new Set(currentTest.expected); + let actualDomains = SearchSERPCategorization.processDomains( + domains, + PROVIDER + ); + + Assert.deepEqual( + Array.from(actualDomains), + Array.from(expectedDomains), + "Domains should have been parsed correctly." + ); + } +}); diff --git a/browser/components/search/test/unit/test_search_telemetry_categorization_sync.js b/browser/components/search/test/unit/test_search_telemetry_categorization_sync.js new file mode 100644 index 0000000000..423ee0a81d --- /dev/null +++ b/browser/components/search/test/unit/test_search_telemetry_categorization_sync.js @@ -0,0 +1,423 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the integration of Remote Settings with SERP domain categorization. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + SearchSERPDomainToCategoriesMap: + "resource:///modules/SearchSERPTelemetry.sys.mjs", + TELEMETRY_CATEGORIZATION_KEY: + "resource:///modules/SearchSERPTelemetry.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +async function waitForDomainToCategoriesUpdate() { + return TestUtils.topicObserved("domain-to-categories-map-update-complete"); +} + +async function mockRecordWithCachedAttachment({ id, version, filename }) { + // Get the bytes of the file for the hash and size for attachment metadata. + let data = await IOUtils.readUTF8( + PathUtils.join(do_get_cwd().path, filename) + ); + let buffer = new TextEncoder().encode(data).buffer; + let stream = Cc["@mozilla.org/io/arraybuffer-input-stream;1"].createInstance( + Ci.nsIArrayBufferInputStream + ); + stream.setData(buffer, 0, buffer.byteLength); + + // Generate a hash. + let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(Ci.nsICryptoHash.SHA256); + hasher.updateFromStream(stream, -1); + let hash = hasher.finish(false); + hash = Array.from(hash, (_, i) => + ("0" + hash.charCodeAt(i).toString(16)).slice(-2) + ).join(""); + + let record = { + id, + version, + attachment: { + hash, + location: `main-workspace/search-categorization/${filename}`, + filename, + size: buffer.byteLength, + mimetype: "application/json", + }, + }; + + client.attachments.cacheImpl.set(id, { + record, + blob: new Blob([buffer]), + }); + + return record; +} + +const RECORD_A_ID = Services.uuid.generateUUID().number.slice(1, -1); +const RECORD_B_ID = Services.uuid.generateUUID().number.slice(1, -1); + +const client = RemoteSettings(TELEMETRY_CATEGORIZATION_KEY); +const db = client.db; + +const RECORDS = { + record1a: { + id: RECORD_A_ID, + version: 1, + filename: "domain_category_mappings_1a.json", + }, + record1b: { + id: RECORD_B_ID, + version: 1, + filename: "domain_category_mappings_1b.json", + }, + record2a: { + id: RECORD_A_ID, + version: 2, + filename: "domain_category_mappings_2a.json", + }, + record2b: { + id: RECORD_B_ID, + version: 2, + filename: "domain_category_mappings_2b.json", + }, +}; + +add_setup(async () => { + // Testing with Remote Settings requires a profile. + do_get_profile(); + await db.clear(); +}); + +add_task(async function test_initial_import() { + info("Create record containing domain_category_mappings_1a.json attachment."); + let record1a = await mockRecordWithCachedAttachment(RECORDS.record1a); + await db.create(record1a); + + info("Create record containing domain_category_mappings_1b.json attachment."); + let record1b = await mockRecordWithCachedAttachment(RECORDS.record1b); + await db.create(record1b); + + info("Add data to Remote Settings DB."); + await db.importChanges({}, Date.now()); + + info("Initialize search categorization mappings."); + let promise = waitForDomainToCategoriesUpdate(); + await SearchSERPDomainToCategoriesMap.init(); + await promise; + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.com"), + [{ category: 1, score: 100 }], + "Return value from lookup of example.com should be the same." + ); + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.org"), + [{ category: 2, score: 90 }], + "Return value from lookup of example.org should be the same." + ); + + // Clean up. + await db.clear(); + SearchSERPDomainToCategoriesMap.uninit(); +}); + +add_task(async function test_update_records() { + info("Create record containing domain_category_mappings_1a.json attachment."); + let record1a = await mockRecordWithCachedAttachment(RECORDS.record1a); + await db.create(record1a); + + info("Create record containing domain_category_mappings_1b.json attachment."); + let record1b = await mockRecordWithCachedAttachment(RECORDS.record1b); + await db.create(record1b); + + info("Add data to Remote Settings DB."); + await db.importChanges({}, Date.now()); + + info("Initialize search categorization mappings."); + let promise = waitForDomainToCategoriesUpdate(); + await SearchSERPDomainToCategoriesMap.init(); + await promise; + + info("Send update from Remote Settings with updates to attachments."); + let record2a = await mockRecordWithCachedAttachment(RECORDS.record2a); + let record2b = await mockRecordWithCachedAttachment(RECORDS.record2b); + const payload = { + current: [record2a, record2b], + created: [], + updated: [ + { old: record1a, new: record2a }, + { old: record1b, new: record2b }, + ], + deleted: [], + }; + promise = waitForDomainToCategoriesUpdate(); + await client.emit("sync", { + data: payload, + }); + await promise; + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.com"), + [{ category: 1, score: 80 }], + "Return value from lookup of example.com should have changed." + ); + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.org"), + [ + { category: 2, score: 50 }, + { category: 4, score: 80 }, + ], + "Return value from lookup of example.org should have changed." + ); + + Assert.equal( + SearchSERPDomainToCategoriesMap.version, + 2, + "Version should be correct." + ); + + // Clean up. + await db.clear(); + SearchSERPDomainToCategoriesMap.uninit(); +}); + +add_task(async function test_delayed_initial_import() { + info("Initialize search categorization mappings."); + let observeNoRecordsFound = TestUtils.consoleMessageObserved(msg => { + return ( + typeof msg.wrappedJSObject.arguments?.[0] == "string" && + msg.wrappedJSObject.arguments[0].includes( + "No records found for domain-to-categories map." + ) + ); + }); + info("Initialize without records."); + await SearchSERPDomainToCategoriesMap.init(); + await observeNoRecordsFound; + + Assert.ok(SearchSERPDomainToCategoriesMap.empty, "Map is empty."); + + info("Send update from Remote Settings with updates to attachments."); + let record1a = await mockRecordWithCachedAttachment(RECORDS.record1a); + let record1b = await mockRecordWithCachedAttachment(RECORDS.record1b); + const payload = { + current: [record1a, record1b], + created: [record1a, record1b], + updated: [], + deleted: [], + }; + let promise = waitForDomainToCategoriesUpdate(); + await client.emit("sync", { + data: payload, + }); + await promise; + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.com"), + [{ category: 1, score: 100 }], + "Return value from lookup of example.com should be the same." + ); + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.org"), + [{ category: 2, score: 90 }], + "Return value from lookup of example.org should be the same." + ); + + Assert.equal( + SearchSERPDomainToCategoriesMap.version, + 1, + "Version should be correct." + ); + + // Clean up. + await db.clear(); + SearchSERPDomainToCategoriesMap.uninit(); +}); + +add_task(async function test_remove_record() { + info("Create record containing domain_category_mappings_2a.json attachment."); + let record2a = await mockRecordWithCachedAttachment(RECORDS.record2a); + await db.create(record2a); + + info("Create record containing domain_category_mappings_2b.json attachment."); + let record2b = await mockRecordWithCachedAttachment(RECORDS.record2b); + await db.create(record2b); + + info("Add data to Remote Settings DB."); + await db.importChanges({}, Date.now()); + + info("Initialize search categorization mappings."); + let promise = waitForDomainToCategoriesUpdate(); + await SearchSERPDomainToCategoriesMap.init(); + await promise; + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.com"), + [{ category: 1, score: 80 }], + "Initialized properly." + ); + + info("Send update from Remote Settings with one removed record."); + const payload = { + current: [record2a], + created: [], + updated: [], + deleted: [record2b], + }; + promise = waitForDomainToCategoriesUpdate(); + await client.emit("sync", { + data: payload, + }); + await promise; + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.com"), + [{ category: 1, score: 80 }], + "Return value from lookup of example.com should remain unchanged." + ); + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.org"), + [], + "Return value from lookup of example.org should be empty." + ); + + Assert.equal( + SearchSERPDomainToCategoriesMap.version, + 2, + "Version should be correct." + ); + + // Clean up. + await db.clear(); + SearchSERPDomainToCategoriesMap.uninit(); +}); + +add_task(async function test_different_versions_coexisting() { + info("Create record containing domain_category_mappings_1a.json attachment."); + let record1a = await mockRecordWithCachedAttachment(RECORDS.record1a); + await db.create(record1a); + + info("Create record containing domain_category_mappings_2b.json attachment."); + let record2b = await mockRecordWithCachedAttachment(RECORDS.record2b); + await db.create(record2b); + + info("Add data to Remote Settings DB."); + await db.importChanges({}, Date.now()); + + info("Initialize search categorization mappings."); + let promise = waitForDomainToCategoriesUpdate(); + await SearchSERPDomainToCategoriesMap.init(); + await promise; + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.com"), + [ + { + category: 1, + score: 100, + }, + ], + "Should have a record from an older version." + ); + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.org"), + [ + { category: 2, score: 50 }, + { category: 4, score: 80 }, + ], + "Return value from lookup of example.org should have the most recent value." + ); + + Assert.equal( + SearchSERPDomainToCategoriesMap.version, + 2, + "Version should be the latest." + ); + + // Clean up. + await db.clear(); + SearchSERPDomainToCategoriesMap.uninit(); +}); + +add_task(async function test_download_error() { + info("Create record containing domain_category_mappings_1a.json attachment."); + let record1a = await mockRecordWithCachedAttachment(RECORDS.record1a); + await db.create(record1a); + + info("Add data to Remote Settings DB."); + await db.importChanges({}, Date.now()); + + info("Initialize search categorization mappings."); + let promise = waitForDomainToCategoriesUpdate(); + await SearchSERPDomainToCategoriesMap.init(); + await promise; + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.com"), + [ + { + category: 1, + score: 100, + }, + ], + "Domain should have an entry in the map." + ); + + Assert.equal( + SearchSERPDomainToCategoriesMap.version, + 1, + "Version should be present." + ); + + info("Delete attachment from local cache."); + client.attachments.cacheImpl.delete(RECORD_A_ID); + + const payload = { + current: [record1a], + created: [], + updated: [record1a], + deleted: [], + }; + + info("Sync payload."); + let observeDownloadError = TestUtils.consoleMessageObserved(msg => { + return ( + typeof msg.wrappedJSObject.arguments?.[0] == "string" && + msg.wrappedJSObject.arguments[0].includes("Could not download file:") + ); + }); + await client.emit("sync", { + data: payload, + }); + await observeDownloadError; + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.com"), + [], + "Domain should not exist in store." + ); + + Assert.equal( + SearchSERPDomainToCategoriesMap.version, + null, + "Version should remain null." + ); + + // Clean up. + await db.clear(); + SearchSERPDomainToCategoriesMap.uninit(); +}); diff --git a/browser/components/search/test/unit/test_search_telemetry_compare_urls.js b/browser/components/search/test/unit/test_search_telemetry_compare_urls.js new file mode 100644 index 0000000000..c99c28607a --- /dev/null +++ b/browser/components/search/test/unit/test_search_telemetry_compare_urls.js @@ -0,0 +1,188 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * This test ensures we compare URLs correctly. For more info on the scores, + * please read the function definition. + */ + +ChromeUtils.defineESModuleGetters(this, { + SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs", +}); + +const TESTS = [ + { + title: "No difference", + url1: "https://www.example.org/search?a=b&c=d#hash", + url2: "https://www.example.org/search?a=b&c=d#hash", + expected: Infinity, + }, + { + // Since the ordering is different, a strict equality match is not going + // match. The score will be high, but not Infinity. + title: "Different ordering of query parameters", + url1: "https://www.example.org/search?c=d&a=b#hash", + url2: "https://www.example.org/search?a=b&c=d#hash", + expected: 7, + }, + { + title: "Different protocol", + url1: "http://www.example.org/search", + url2: "https://www.example.org/search", + expected: 0, + }, + { + title: "Different origin", + url1: "https://example.org/search", + url2: "https://www.example.org/search", + expected: 0, + }, + { + title: "Different path", + url1: "https://www.example.org/serp", + url2: "https://www.example.org/search", + expected: 1, + }, + { + title: "Different path, path on", + url1: "https://www.example.org/serp", + url2: "https://www.example.org/search", + options: { + path: true, + }, + expected: 0, + }, + { + title: "Different query parameter keys", + url1: "https://www.example.org/search?a=c", + url2: "https://www.example.org/search?b=c", + expected: 3, + }, + { + title: "Different query parameter keys, paramValues on", + url1: "https://www.example.org/search?a=c", + url2: "https://www.example.org/search?b=c", + options: { + paramValues: true, + }, + // Shouldn't change the score because the option should only nullify + // the result if one of the keys match but has different values. + expected: 3, + }, + { + title: "Some different query parameter keys", + url1: "https://www.example.org/search?a=b&c=d", + url2: "https://www.example.org/search?a=b", + expected: 5, + }, + { + title: "Some different query parameter keys, paramValues on", + url1: "https://www.example.org/search?a=b&c=d", + url2: "https://www.example.org/search?a=b", + options: { + paramValues: true, + }, + // Shouldn't change the score because the option should only trigger + // if the keys match but values differ. + expected: 5, + }, + { + title: "Different query parameter values", + url1: "https://www.example.org/search?a=b", + url2: "https://www.example.org/search?a=c", + expected: 4, + }, + { + title: "Different query parameter values, paramValues on", + url1: "https://www.example.org/search?a=b&c=d", + url2: "https://www.example.org/search?a=b&c=e", + options: { + paramValues: true, + }, + expected: 0, + }, + { + title: "Some different query parameter values", + url1: "https://www.example.org/search?a=b&c=d", + url2: "https://www.example.org/search?a=b&c=e", + expected: 6, + }, + { + title: "Different query parameter values, paramValues on", + url1: "https://www.example.org/search?a=b&c=d", + url2: "https://www.example.org/search?a=b&c=e", + options: { + paramValues: true, + }, + expected: 0, + }, + { + title: "Empty query parameter", + url1: "https://www.example.org/search?a=b&c", + url2: "https://www.example.org/search?c&a=b", + expected: 7, + }, + { + title: "Empty query parameter, paramValues on", + url1: "https://www.example.org/search?a=b&c", + url2: "https://www.example.org/search?c&a=b", + options: { + paramValues: true, + }, + expected: 7, + }, + { + title: "Missing empty query parameter", + url1: "https://www.example.org/search?c&a=b", + url2: "https://www.example.org/search?a=b", + expected: 5, + }, + { + title: "Missing empty query parameter, paramValues on", + url1: "https://www.example.org/search?c&a=b", + url2: "https://www.example.org/search?a=b", + options: { + paramValues: true, + }, + expected: 5, + }, + { + title: "Different empty query parameter", + url1: "https://www.example.org/search?c&a=b", + url2: "https://www.example.org/search?a=b&c=foo", + expected: 6, + }, + { + title: "Different empty query parameter, paramValues on", + url1: "https://www.example.org/search?c&a=b", + url2: "https://www.example.org/search?a=b&c=foo", + options: { + paramValues: true, + }, + expected: 0, + }, +]; + +add_setup(async function () { + await SearchSERPTelemetry.init(); +}); + +add_task(async function test_parsing_extracted_urls() { + for (let test of TESTS) { + info(test.title); + let result = SearchSERPTelemetry.compareUrls( + new URL(test.url1), + new URL(test.url2), + test.options + ); + Assert.equal(result, test.expected, "Equality: url1, url2"); + + // Flip the URLs to ensure order doesn't matter. + result = SearchSERPTelemetry.compareUrls( + new URL(test.url2), + new URL(test.url1), + test.options + ); + Assert.equal(result, test.expected, "Equality: url2, url1"); + } +}); diff --git a/browser/components/search/test/unit/test_search_telemetry_config_validation.js b/browser/components/search/test/unit/test_search_telemetry_config_validation.js new file mode 100644 index 0000000000..8897b1e7c7 --- /dev/null +++ b/browser/components/search/test/unit/test_search_telemetry_config_validation.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + TELEMETRY_SETTINGS_KEY: "resource:///modules/SearchSERPTelemetry.sys.mjs", + JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs", + SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.sys.mjs", +}); + +/** + * Checks to see if a value is an object or not. + * + * @param {*} value + * The value to check. + * @returns {boolean} + */ +function isObject(value) { + return value != null && typeof value == "object" && !Array.isArray(value); +} + +/** + * This function modifies the schema to prevent allowing additional properties + * on objects. This is used to enforce that the schema contains everything that + * we deliver via the search configuration. + * + * These checks are not enabled in-product, as we want to allow older versions + * to keep working if we add new properties for whatever reason. + * + * @param {object} section + * The section to check to see if an additionalProperties flag should be added. + */ +function disallowAdditionalProperties(section) { + // It is generally acceptable for new properties to be added to the + // configuration as older builds will ignore them. + // + // As a result, we only check for new properties on nightly builds, and this + // avoids us having to uplift schema changes. This also helps preserve the + // schemas as documentation of "what was supported in this version". + if (!AppConstants.NIGHTLY_BUILD) { + info("Skipping additional properties validation."); + return; + } + + if (section.type == "object") { + section.additionalProperties = false; + } + for (let value of Object.values(section)) { + if (isObject(value)) { + disallowAdditionalProperties(value); + } + } +} + +add_task(async function test_search_telemetry_validates_to_schema() { + let schema = await IOUtils.readJSON( + PathUtils.join(do_get_cwd().path, "search-telemetry-schema.json") + ); + disallowAdditionalProperties(schema); + + let data = await RemoteSettings(TELEMETRY_SETTINGS_KEY).get(); + + let validator = new JsonSchema.Validator(schema); + + for (let entry of data) { + // Records in Remote Settings contain additional properties independent of + // the schema. Hence, we don't want to validate their presence. + delete entry.schema; + delete entry.id; + delete entry.last_modified; + delete entry.filter_expression; + + let result = validator.validate(entry); + let message = `Should validate ${entry.telemetryId}`; + if (!result.valid) { + message += `:\n${JSON.stringify(result.errors, null, 2)}`; + } + Assert.ok(result.valid, message); + } +}); + +add_task(async function test_search_config_codes_in_search_telemetry() { + let searchTelemetry = await RemoteSettings(TELEMETRY_SETTINGS_KEY).get(); + + let selector = new SearchEngineSelector(() => {}); + let searchConfig = await selector.getEngineConfiguration(); + + const telemetryIdToSearchEngineIdMap = new Map([["duckduckgo", "ddg"]]); + + for (let telemetryEntry of searchTelemetry) { + info(`Checking: ${telemetryEntry.telemetryId}`); + let engine; + for (let entry of searchConfig) { + if (entry.recordType != "engine") { + continue; + } + if ( + entry.identifier == telemetryEntry.telemetryId || + entry.identifier == + telemetryIdToSearchEngineIdMap.get(telemetryEntry.telemetryId) + ) { + engine = entry; + } + } + Assert.ok( + !!engine, + `Should have associated engine data for telemetry id ${telemetryEntry.telemetryId}` + ); + + if (engine.base.partnerCode) { + Assert.ok( + telemetryEntry.taggedCodes.includes(engine.base.partnerCode), + `Should have the base partner code ${engine.base.partnerCode} listed in the search telemetry 'taggedCodes'` + ); + } else { + Assert.equal( + telemetryEntry.telemetryId, + "baidu", + "Should only not have a base partner code for Baidu" + ); + } + + if (engine.variants) { + for (let variant of engine.variants) { + if ("partnerCode" in variant) { + Assert.ok( + telemetryEntry.taggedCodes.includes(variant.partnerCode), + `Should have the partner code ${variant.partnerCode} listed in the search telemetry 'taggedCodes'` + ); + } + } + } + } +}); diff --git a/browser/components/search/test/unit/test_urlTelemetry.js b/browser/components/search/test/unit/test_urlTelemetry.js new file mode 100644 index 0000000000..07f2407015 --- /dev/null +++ b/browser/components/search/test/unit/test_urlTelemetry.js @@ -0,0 +1,306 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +ChromeUtils.defineESModuleGetters(this, { + BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +const TESTS = [ + { + title: "Google search access point", + trackingUrl: + "https://www.google.com/search?q=test&ie=utf-8&oe=utf-8&client=firefox-b-1-ab", + expectedSearchCountEntry: "google:tagged:firefox-b-1-ab", + expectedAdKey: "google:tagged", + adUrls: [ + "https://www.googleadservices.com/aclk=foobar", + "https://www.googleadservices.com/pagead/aclk=foobar", + "https://www.google.com/aclk=foobar", + "https://www.google.com/pagead/aclk=foobar", + ], + nonAdUrls: [ + "https://www.googleadservices.com/?aclk=foobar", + "https://www.googleadservices.com/bar", + "https://www.google.com/image", + ], + }, + { + title: "Google search access point follow-on", + trackingUrl: + "https://www.google.com/search?client=firefox-b-1-ab&ei=EI_VALUE&q=test2&oq=test2&gs_l=GS_L_VALUE", + expectedSearchCountEntry: "google:tagged-follow-on:firefox-b-1-ab", + }, + { + title: "Google organic", + trackingUrl: + "https://www.google.com/search?client=firefox-b-d-invalid&source=hp&ei=EI_VALUE&q=test&oq=test&gs_l=GS_L_VALUE", + expectedSearchCountEntry: "google:organic:other", + expectedAdKey: "google:organic", + adUrls: ["https://www.googleadservices.com/aclk=foobar"], + nonAdUrls: ["https://www.googleadservices.com/?aclk=foobar"], + }, + { + title: "Google organic no code", + trackingUrl: + "https://www.google.com/search?source=hp&ei=EI_VALUE&q=test&oq=test&gs_l=GS_L_VALUE", + expectedSearchCountEntry: "google:organic:none", + expectedAdKey: "google:organic", + adUrls: ["https://www.googleadservices.com/aclk=foobar"], + nonAdUrls: ["https://www.googleadservices.com/?aclk=foobar"], + }, + { + title: "Google organic UK", + trackingUrl: + "https://www.google.co.uk/search?source=hp&ei=EI_VALUE&q=test&oq=test&gs_l=GS_L_VALUE", + expectedSearchCountEntry: "google:organic:none", + }, + { + title: "Bing search access point", + trackingUrl: "https://www.bing.com/search?q=test&pc=MOZI&form=MOZLBR", + expectedSearchCountEntry: "bing:tagged:MOZI", + expectedAdKey: "bing:tagged", + adUrls: [ + "https://www.bing.com/aclick?ld=foo", + "https://www.bing.com/aclk?ld=foo", + ], + nonAdUrls: [ + "https://www.bing.com/fd/ls/ls.gif?IG=foo", + "https://www.bing.com/fd/ls/l?IG=bar", + "https://www.bing.com/aclook?", + "https://www.bing.com/fd/ls/GLinkPingPost.aspx?IG=baz&url=%2Fvideos%2Fsearch%3Fq%3Dfoo", + "https://www.bing.com/fd/ls/GLinkPingPost.aspx?IG=bar&url=https%3A%2F%2Fwww.bing.com%2Faclick", + "https://www.bing.com/fd/ls/GLinkPingPost.aspx?IG=bar&url=https%3A%2F%2Fwww.bing.com%2Faclk", + ], + }, + { + setUp() { + Services.cookies.removeAll(); + Services.cookies.add( + "www.bing.com", + "/", + "SRCHS", + "PC=MOZI", + false, + false, + false, + Date.now() + 1000 * 60 * 60, + {}, + Ci.nsICookie.SAMESITE_NONE, + Ci.nsICookie.SCHEME_HTTPS + ); + }, + tearDown() { + Services.cookies.removeAll(); + }, + title: "Bing search access point follow-on", + trackingUrl: + "https://www.bing.com/search?q=test&qs=n&form=QBRE&sp=-1&pq=&sc=0-0&sk=&cvid=CVID_VALUE", + expectedSearchCountEntry: "bing:tagged-follow-on:MOZI", + }, + { + title: "Bing organic", + trackingUrl: "https://www.bing.com/search?q=test&pc=MOZIfoo&form=MOZLBR", + expectedSearchCountEntry: "bing:organic:other", + expectedAdKey: "bing:organic", + adUrls: ["https://www.bing.com/aclick?ld=foo"], + nonAdUrls: ["https://www.bing.com/fd/ls/ls.gif?IG=foo"], + }, + { + title: "Bing organic no code", + trackingUrl: + "https://www.bing.com/search?q=test&qs=n&form=QBLH&sp=-1&pq=&sc=0-0&sk=&cvid=CVID_VALUE", + expectedSearchCountEntry: "bing:organic:none", + expectedAdKey: "bing:organic", + adUrls: ["https://www.bing.com/aclick?ld=foo"], + nonAdUrls: ["https://www.bing.com/fd/ls/ls.gif?IG=foo"], + }, + { + title: "DuckDuckGo search access point", + trackingUrl: "https://duckduckgo.com/?q=test&t=ffab", + expectedSearchCountEntry: "duckduckgo:tagged:ffab", + expectedAdKey: "duckduckgo:tagged", + adUrls: [ + "https://duckduckgo.com/y.js?ad_provider=foo", + "https://duckduckgo.com/y.js?f=bar&ad_provider=foo", + "https://www.amazon.co.uk/foo?tag=duckduckgo-ffab-uk-32-xk", + ], + nonAdUrls: [ + "https://duckduckgo.com/?q=foo&t=ffab&ia=images&iax=images", + "https://duckduckgo.com/y.js?ifu=foo", + "https://improving.duckduckgo.com/t/bar", + ], + }, + { + title: "DuckDuckGo organic", + trackingUrl: "https://duckduckgo.com/?q=test&t=other&ia=news", + expectedSearchCountEntry: "duckduckgo:organic:other", + expectedAdKey: "duckduckgo:organic", + adUrls: ["https://duckduckgo.com/y.js?ad_provider=foo"], + nonAdUrls: ["https://duckduckgo.com/?q=foo&t=ffab&ia=images&iax=images"], + }, + { + title: "DuckDuckGo expected organic code", + trackingUrl: "https://duckduckgo.com/?q=test&t=h_&ia=news", + expectedSearchCountEntry: "duckduckgo:organic:none", + expectedAdKey: "duckduckgo:organic", + adUrls: ["https://duckduckgo.com/y.js?ad_provider=foo"], + nonAdUrls: ["https://duckduckgo.com/?q=foo&t=ffab&ia=images&iax=images"], + }, + { + title: "DuckDuckGo expected organic code 2", + trackingUrl: "https://duckduckgo.com/?q=test&t=hz&ia=news", + expectedSearchCountEntry: "duckduckgo:organic:none", + expectedAdKey: "duckduckgo:organic", + adUrls: ["https://duckduckgo.com/y.js?ad_provider=foo"], + nonAdUrls: ["https://duckduckgo.com/?q=foo&t=ffab&ia=images&iax=images"], + }, + { + title: "DuckDuckGo organic no code", + trackingUrl: "https://duckduckgo.com/?q=test&ia=news", + expectedSearchCountEntry: "duckduckgo:organic:none", + expectedAdKey: "duckduckgo:organic", + adUrls: ["https://duckduckgo.com/y.js?ad_provider=foo"], + nonAdUrls: ["https://duckduckgo.com/?q=foo&t=ffab&ia=images&iax=images"], + }, + { + title: "Baidu search access point", + trackingUrl: "https://www.baidu.com/baidu?wd=test&tn=monline_7_dg&ie=utf-8", + expectedSearchCountEntry: "baidu:tagged:monline_7_dg", + expectedAdKey: "baidu:tagged", + adUrls: ["https://www.baidu.com/baidu.php?url=encoded"], + nonAdUrls: ["https://www.baidu.com/link?url=encoded"], + }, + { + title: "Baidu search access point follow-on", + trackingUrl: + "https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=1&tn=monline_7_dg&wd=test2&oq=test&rsv_pq=RSV_PQ_VALUE&rsv_t=RSV_T_VALUE&rqlang=cn&rsv_enter=1&rsv_sug3=2&rsv_sug2=0&inputT=227&rsv_sug4=397", + expectedSearchCountEntry: "baidu:tagged-follow-on:monline_7_dg", + }, + { + title: "Baidu organic", + trackingUrl: + "https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=1&rsv_idx=1&ch=&tn=baidu&bar=&wd=test&rn=&oq&rsv_pq=RSV_PQ_VALUE&rsv_t=RSV_T_VALUE&rqlang=cn", + expectedSearchCountEntry: "baidu:organic:other", + }, + { + title: "Baidu organic no code", + trackingUrl: + "https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=1&rsv_idx=1&ch=&bar=&wd=test&rn=&oq&rsv_pq=RSV_PQ_VALUE&rsv_t=RSV_T_VALUE&rqlang=cn", + expectedSearchCountEntry: "baidu:organic:none", + }, + { + title: "Ecosia search access point", + trackingUrl: "https://www.ecosia.org/search?tt=mzl&q=foo", + expectedSearchCountEntry: "ecosia:tagged:mzl", + expectedAdKey: "ecosia:tagged", + adUrls: ["https://www.bing.com/aclick?ld=foo"], + nonAdUrls: [], + }, + { + title: "Ecosia organic", + trackingUrl: "https://www.ecosia.org/search?method=index&q=foo", + expectedSearchCountEntry: "ecosia:organic:none", + expectedAdKey: "ecosia:organic", + adUrls: ["https://www.bing.com/aclick?ld=foo"], + nonAdUrls: [], + }, +]; + +/** + * This function is primarily for testing the Ad URL regexps that are triggered + * when a URL is clicked on. These regexps are also used for the `with_ads` + * probe. However, we test the ad_clicks route as that is easier to hit. + * + * @param {string} serpUrl + * The url to simulate where the page the click came from. + * @param {string} adUrl + * The ad url to simulate being clicked. + * @param {string} [expectedAdKey] + * The expected key to be logged for the scalar. Omit if no scalar should be + * logged. + */ +async function testAdUrlClicked(serpUrl, adUrl, expectedAdKey) { + info(`Testing Ad URL: ${adUrl}`); + let channel = NetUtil.newChannel({ + uri: NetUtil.newURI(adUrl), + triggeringPrincipal: Services.scriptSecurityManager.createContentPrincipal( + NetUtil.newURI(serpUrl), + {} + ), + loadUsingSystemPrincipal: true, + }); + SearchSERPTelemetry._contentHandler.observeActivity( + channel, + Ci.nsIHttpActivityObserver.ACTIVITY_TYPE_HTTP_TRANSACTION, + Ci.nsIHttpActivityObserver.ACTIVITY_SUBTYPE_TRANSACTION_CLOSE + ); + // Since the content handler takes a moment to allow the channel information + // to settle down, wait the same amount of time here. + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + if (!expectedAdKey) { + Assert.ok( + !("browser.search.adclicks.unknown" in scalars), + "Should not have recorded an ad click" + ); + } else { + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.adclicks.unknown", + expectedAdKey, + 1 + ); + } +} + +do_get_profile(); + +add_task(async function setup() { + await SearchSERPTelemetry.init(); + sinon.stub(BrowserSearchTelemetry, "shouldRecordSearchCount").returns(true); + // There is no concept of browsing in unit tests, so assume in tests that we + // are not in private browsing mode. We have browser tests that check when + // private browsing is used. + sinon.stub(PrivateBrowsingUtils, "isBrowserPrivate").returns(false); +}); + +add_task(async function test_parsing_search_urls() { + for (const test of TESTS) { + info(`Running ${test.title}`); + if (test.setUp) { + test.setUp(); + } + SearchSERPTelemetry.updateTrackingStatus( + { + getTabBrowser: () => {}, + }, + test.trackingUrl + ); + let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.content.unknown", + test.expectedSearchCountEntry, + 1 + ); + + if ("adUrls" in test) { + for (const adUrl of test.adUrls) { + await testAdUrlClicked(test.trackingUrl, adUrl, test.expectedAdKey); + } + for (const nonAdUrls of test.nonAdUrls) { + await testAdUrlClicked(test.trackingUrl, nonAdUrls); + } + } + + if (test.tearDown) { + test.tearDown(); + } + } +}); diff --git a/browser/components/search/test/unit/test_urlTelemetry_generic.js b/browser/components/search/test/unit/test_urlTelemetry_generic.js new file mode 100644 index 0000000000..e967002421 --- /dev/null +++ b/browser/components/search/test/unit/test_urlTelemetry_generic.js @@ -0,0 +1,329 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +ChromeUtils.defineESModuleGetters(this, { + BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + 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", +}); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: /^https:\/\/www\.example\.com\/search/, + queryParamNames: ["q"], + codeParamName: "abc", + taggedCodes: ["ff", "tb"], + expectedOrganicCodes: ["baz"], + organicCodes: ["foo"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/www\.example\.com\/ad2/], + shoppingTab: { + regexp: "&site=shop", + }, + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, + { + telemetryId: "example2", + searchPageRegexp: /^https:\/\/www\.example2\.com\/search/, + queryParamNames: ["a", "q"], + codeParamName: "abc", + taggedCodes: ["ff", "tb"], + expectedOrganicCodes: ["baz"], + organicCodes: ["foo"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/www\.example\.com\/ad2/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +const TESTS = [ + { + title: "Tagged search", + trackingUrl: "https://www.example.com/search?q=test&abc=ff", + expectedSearchCountEntry: "example:tagged:ff", + expectedAdKey: "example:tagged", + adUrls: ["https://www.example.com/ad2"], + nonAdUrls: ["https://www.example.com/ad3"], + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + source: "unknown", + }, + }, + { + title: "Tagged search with shopping", + trackingUrl: "https://www.example.com/search?q=test&abc=ff&site=shop", + expectedSearchCountEntry: "example:tagged:ff", + expectedAdKey: "example:tagged", + adUrls: ["https://www.example.com/ad2"], + nonAdUrls: ["https://www.example.com/ad3"], + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + is_shopping_page: "true", + is_private: "false", + shopping_tab_displayed: "false", + source: "unknown", + }, + }, + { + title: "Tagged follow-on", + trackingUrl: "https://www.example.com/search?q=test&abc=tb&a=next", + expectedSearchCountEntry: "example:tagged-follow-on:tb", + expectedAdKey: "example:tagged-follow-on", + adUrls: ["https://www.example.com/ad2"], + nonAdUrls: ["https://www.example.com/ad3"], + impression: { + provider: "example", + tagged: "true", + partner_code: "tb", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + source: "unknown", + }, + }, + { + title: "Organic search matched code", + trackingUrl: "https://www.example.com/search?q=test&abc=foo", + expectedSearchCountEntry: "example:organic:foo", + expectedAdKey: "example:organic", + adUrls: ["https://www.example.com/ad2"], + nonAdUrls: ["https://www.example.com/ad3"], + impression: { + provider: "example", + tagged: "false", + partner_code: "foo", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + source: "unknown", + }, + }, + { + title: "Organic search non-matched code", + trackingUrl: "https://www.example.com/search?q=test&abc=ff123", + expectedSearchCountEntry: "example:organic:other", + expectedAdKey: "example:organic", + adUrls: ["https://www.example.com/ad2"], + nonAdUrls: ["https://www.example.com/ad3"], + impression: { + provider: "example", + tagged: "false", + partner_code: "other", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + source: "unknown", + }, + }, + { + title: "Organic search non-matched code 2", + trackingUrl: "https://www.example.com/search?q=test&abc=foo123", + expectedSearchCountEntry: "example:organic:other", + expectedAdKey: "example:organic", + adUrls: ["https://www.example.com/ad2"], + nonAdUrls: ["https://www.example.com/ad3"], + impression: { + provider: "example", + tagged: "false", + partner_code: "other", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + source: "unknown", + }, + }, + { + title: "Organic search expected organic matched code", + trackingUrl: "https://www.example.com/search?q=test&abc=baz", + expectedSearchCountEntry: "example:organic:none", + expectedAdKey: "example:organic", + adUrls: ["https://www.example.com/ad2"], + nonAdUrls: ["https://www.example.com/ad3"], + impression: { + provider: "example", + tagged: "false", + partner_code: "", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + source: "unknown", + }, + }, + { + title: "Organic search no codes", + trackingUrl: "https://www.example.com/search?q=test", + expectedSearchCountEntry: "example:organic:none", + expectedAdKey: "example:organic", + adUrls: ["https://www.example.com/ad2"], + nonAdUrls: ["https://www.example.com/ad3"], + impression: { + provider: "example", + tagged: "false", + partner_code: "", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + source: "unknown", + }, + }, + { + title: "Different engines using the same adUrl", + trackingUrl: "https://www.example2.com/search?q=test", + expectedSearchCountEntry: "example2:organic:none", + expectedAdKey: "example2:organic", + adUrls: ["https://www.example.com/ad2"], + nonAdUrls: ["https://www.example.com/ad3"], + impression: { + provider: "example2", + tagged: "false", + partner_code: "", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + source: "unknown", + }, + }, +]; + +/** + * This function is primarily for testing the Ad URL regexps that are triggered + * when a URL is clicked on. These regexps are also used for the `withads` + * probe. However, we test the adclicks route as that is easier to hit. + * + * @param {string} serpUrl + * The url to simulate where the page the click came from. + * @param {string} adUrl + * The ad url to simulate being clicked. + * @param {string} [expectedAdKey] + * The expected key to be logged for the scalar. Omit if no scalar should be + * logged. + */ +async function testAdUrlClicked(serpUrl, adUrl, expectedAdKey) { + info(`Testing Ad URL: ${adUrl}`); + let channel = NetUtil.newChannel({ + uri: NetUtil.newURI(adUrl), + triggeringPrincipal: Services.scriptSecurityManager.createContentPrincipal( + NetUtil.newURI(serpUrl), + {} + ), + loadUsingSystemPrincipal: true, + }); + SearchSERPTelemetry._contentHandler.observeActivity( + channel, + Ci.nsIHttpActivityObserver.ACTIVITY_TYPE_HTTP_TRANSACTION, + Ci.nsIHttpActivityObserver.ACTIVITY_SUBTYPE_TRANSACTION_CLOSE + ); + // Since the content handler takes a moment to allow the channel information + // to settle down, wait the same amount of time here. + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + if (!expectedAdKey) { + Assert.ok( + !("browser.search.adclicks.unknown" in scalars), + "Should not have recorded an ad click" + ); + } else { + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.adclicks.unknown", + expectedAdKey, + 1 + ); + } +} + +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); + sinon.stub(BrowserSearchTelemetry, "shouldRecordSearchCount").returns(true); + // There is no concept of browsing in unit tests, so assume in tests that we + // are not in private browsing mode. We have browser tests that check when + // private browsing is used. + sinon.stub(PrivateBrowsingUtils, "isBrowserPrivate").returns(false); +}); + +add_task(async function test_parsing_search_urls() { + for (const test of TESTS) { + info(`Running ${test.title}`); + if (test.setUp) { + test.setUp(); + } + let browser = { + getTabBrowser: () => {}, + }; + SearchSERPTelemetry.updateTrackingStatus(browser, test.trackingUrl); + SearchSERPTelemetry.reportPageImpression( + { + url: test.trackingUrl, + shoppingTabDisplayed: false, + }, + browser + ); + let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.content.unknown", + test.expectedSearchCountEntry, + 1 + ); + + if ("adUrls" in test) { + for (const adUrl of test.adUrls) { + await testAdUrlClicked(test.trackingUrl, adUrl, test.expectedAdKey); + } + for (const nonAdUrls of test.nonAdUrls) { + await testAdUrlClicked(test.trackingUrl, nonAdUrls); + } + } + + let recordedEvents = Glean.serp.impression.testGetValue(); + + Assert.equal( + recordedEvents.length, + 1, + "should only see one impression event" + ); + + // To allow deep equality. + test.impression.impression_id = recordedEvents[0].extra.impression_id; + Assert.deepEqual(recordedEvents[0].extra, test.impression); + + if (test.tearDown) { + test.tearDown(); + } + + // We need to clear Glean events so they don't accumulate for each iteration. + Services.fog.testResetFOG(); + } +}); diff --git a/browser/components/search/test/unit/xpcshell.toml b/browser/components/search/test/unit/xpcshell.toml new file mode 100644 index 0000000000..61cdb83378 --- /dev/null +++ b/browser/components/search/test/unit/xpcshell.toml @@ -0,0 +1,29 @@ +[DEFAULT] +support-files = [ + "../../../../../services/settings/dumps/main/search-config-v2.json", +] +prefs = ["browser.search.log=true"] +skip-if = ["os == 'android'"] # bug 1730213 +firefox-appdir = "browser" + +["test_search_telemetry_categorization_logic.js"] + +["test_search_telemetry_categorization_process_domains.js"] + +["test_search_telemetry_categorization_sync.js"] +prefs = ["browser.search.serpEventTelemetryCategorization.enabled=true"] +support-files = [ + "domain_category_mappings_1a.json", + "domain_category_mappings_1b.json", + "domain_category_mappings_2a.json", + "domain_category_mappings_2b.json", +] + +["test_search_telemetry_compare_urls.js"] + +["test_search_telemetry_config_validation.js"] +support-files = ["../../schema/search-telemetry-schema.json"] + +["test_urlTelemetry.js"] + +["test_urlTelemetry_generic.js"] diff --git a/browser/components/sessionstore/ContentRestore.sys.mjs b/browser/components/sessionstore/ContentRestore.sys.mjs new file mode 100644 index 0000000000..e55772cab3 --- /dev/null +++ b/browser/components/sessionstore/ContentRestore.sys.mjs @@ -0,0 +1,435 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs", + Utils: "resource://gre/modules/sessionstore/Utils.sys.mjs", +}); + +/** + * This module implements the content side of session restoration. The chrome + * side is handled by SessionStore.sys.mjs. The functions in this module are called + * by content-sessionStore.js based on messages received from SessionStore.sys.mjs + * (or, in one case, based on a "load" event). Each tab has its own + * ContentRestore instance, constructed by content-sessionStore.js. + * + * In a typical restore, content-sessionStore.js will call the following based + * on messages and events it receives: + * + * restoreHistory(tabData, loadArguments, callbacks) + * Restores the tab's history and session cookies. + * restoreTabContent(loadArguments, finishCallback) + * Starts loading the data for the current page to restore. + * restoreDocument() + * Restore form and scroll data. + * + * When the page has been loaded from the network, we call finishCallback. It + * should send a message to SessionStore.sys.mjs, which may cause other tabs to be + * restored. + * + * When the page has finished loading, a "load" event will trigger in + * content-sessionStore.js, which will call restoreDocument. At that point, + * form data is restored and the restore is complete. + * + * At any time, SessionStore.sys.mjs can cancel the ongoing restore by sending a + * reset message, which causes resetRestore to be called. At that point it's + * legal to begin another restore. + */ +export function ContentRestore(chromeGlobal) { + let internal = new ContentRestoreInternal(chromeGlobal); + let external = {}; + + let EXPORTED_METHODS = [ + "restoreHistory", + "restoreTabContent", + "restoreDocument", + "resetRestore", + ]; + + for (let method of EXPORTED_METHODS) { + external[method] = internal[method].bind(internal); + } + + return Object.freeze(external); +} + +function ContentRestoreInternal(chromeGlobal) { + this.chromeGlobal = chromeGlobal; + + // The following fields are only valid during certain phases of the restore + // process. + + // The tabData for the restore. Set in restoreHistory and removed in + // restoreTabContent. + this._tabData = null; + + // Contains {entry, scrollPositions, formdata}, where entry is a + // single entry from the tabData.entries array. Set in + // restoreTabContent and removed in restoreDocument. + this._restoringDocument = null; + + // This listener is used to detect reloads on restoring tabs. Set in + // restoreHistory and removed in restoreTabContent. + this._historyListener = null; + + // This listener detects when a pending tab starts loading (when not + // initiated by sessionstore) and when a restoring tab has finished loading + // data from the network. Set in restoreHistory() and restoreTabContent(), + // removed in resetRestore(). + this._progressListener = null; +} + +/** + * The API for the ContentRestore module. Methods listed in EXPORTED_METHODS are + * public. + */ +ContentRestoreInternal.prototype = { + get docShell() { + return this.chromeGlobal.docShell; + }, + + /** + * Starts the process of restoring a tab. The tabData to be restored is passed + * in here and used throughout the restoration. The epoch (which must be + * non-zero) is passed through to all the callbacks. If a load in the tab + * is started while it is pending, the appropriate callbacks are called. + */ + restoreHistory(tabData, loadArguments, callbacks) { + this._tabData = tabData; + + // In case about:blank isn't done yet. + let webNavigation = this.docShell.QueryInterface(Ci.nsIWebNavigation); + webNavigation.stop(Ci.nsIWebNavigation.STOP_ALL); + + // Make sure currentURI is set so that switch-to-tab works before the tab is + // restored. We'll reset this to about:blank when we try to restore the tab + // to ensure that docshell doeesn't get confused. Don't bother doing this if + // we're restoring immediately due to a process switch. It just causes the + // URL bar to be temporarily blank. + let activeIndex = tabData.index - 1; + let activePageData = tabData.entries[activeIndex] || {}; + let uri = activePageData.url || null; + if (uri && !loadArguments) { + webNavigation.setCurrentURIForSessionStore(Services.io.newURI(uri)); + } + + lazy.SessionHistory.restore(this.docShell, tabData); + + // Add a listener to watch for reloads. + let listener = new HistoryListener(this.docShell, () => { + // On reload, restore tab contents. + this.restoreTabContent(null, false, callbacks.onLoadFinished); + }); + + webNavigation.sessionHistory.legacySHistory.addSHistoryListener(listener); + this._historyListener = listener; + + // Make sure to reset the capabilities and attributes in case this tab gets + // reused. + SessionStoreUtils.restoreDocShellCapabilities( + this.docShell, + tabData.disallow + ); + + // Add a progress listener to correctly handle browser.loadURI() + // calls from foreign code. + this._progressListener = new ProgressListener(this.docShell, { + onStartRequest: () => { + // Some code called browser.loadURI() on a pending tab. It's safe to + // assume we don't care about restoring scroll or form data. + this._tabData = null; + + // Listen for the tab to finish loading. + this.restoreTabContentStarted(callbacks.onLoadFinished); + + // Notify the parent. + callbacks.onLoadStarted(); + }, + }); + }, + + /** + * Start loading the current page. When the data has finished loading from the + * network, finishCallback is called. Returns true if the load was successful. + */ + restoreTabContent(loadArguments, isRemotenessUpdate, finishCallback) { + let tabData = this._tabData; + this._tabData = null; + + let webNavigation = this.docShell.QueryInterface(Ci.nsIWebNavigation); + + // Listen for the tab to finish loading. + this.restoreTabContentStarted(finishCallback); + + // Reset the current URI to about:blank. We changed it above for + // switch-to-tab, but now it must go back to the correct value before the + // load happens. Don't bother doing this if we're restoring immediately + // due to a process switch. + if (!isRemotenessUpdate) { + webNavigation.setCurrentURIForSessionStore( + Services.io.newURI("about:blank") + ); + } + + try { + if (loadArguments) { + // If the load was started in another process, and the in-flight channel + // was redirected into this process, resume that load within our process. + // + // NOTE: In this case `isRemotenessUpdate` must be true. + webNavigation.resumeRedirectedLoad( + loadArguments.redirectLoadSwitchId, + loadArguments.redirectHistoryIndex + ); + } else if (tabData.userTypedValue && tabData.userTypedClear) { + // If the user typed a URL into the URL bar and hit enter right before + // we crashed, we want to start loading that page again. A non-zero + // userTypedClear value means that the load had started. + // Load userTypedValue and fix up the URL if it's partial/broken. + let loadURIOptions = { + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP, + }; + webNavigation.fixupAndLoadURIString( + tabData.userTypedValue, + loadURIOptions + ); + } else if (tabData.entries.length) { + // Stash away the data we need for restoreDocument. + this._restoringDocument = { + formdata: tabData.formdata || {}, + scrollPositions: tabData.scroll || {}, + }; + + // In order to work around certain issues in session history, we need to + // force session history to update its internal index and call reload + // instead of gotoIndex. See bug 597315. + let history = webNavigation.sessionHistory.legacySHistory; + history.reloadCurrentEntry(); + } else { + // If there's nothing to restore, we should still blank the page. + let loadURIOptions = { + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_HISTORY, + // Specify an override to force the load to finish in the current + // process, as tests rely on this behaviour for non-fission session + // restore. + remoteTypeOverride: Services.appinfo.remoteType, + }; + webNavigation.loadURI( + Services.io.newURI("about:blank"), + loadURIOptions + ); + } + + return true; + } catch (ex) { + if (ex instanceof Ci.nsIException) { + // Ignore page load errors, but return false to signal that the load never + // happened. + return false; + } + } + return null; + }, + + /** + * To be called after restoreHistory(). Removes all listeners needed for + * pending tabs and makes sure to notify when the tab finished loading. + */ + restoreTabContentStarted(finishCallback) { + // The reload listener is no longer needed. + this._historyListener.uninstall(); + this._historyListener = null; + + // Remove the old progress listener. + this._progressListener.uninstall(); + + // We're about to start a load. This listener will be called when the load + // has finished getting everything from the network. + this._progressListener = new ProgressListener(this.docShell, { + onStopRequest: () => { + // Call resetRestore() to reset the state back to normal. The data + // needed for restoreDocument() (which hasn't happened yet) will + // remain in _restoringDocument. + this.resetRestore(); + + finishCallback(); + }, + }); + }, + + /** + * Finish restoring the tab by filling in form data and setting the scroll + * position. The restore is complete when this function exits. It should be + * called when the "load" event fires for the restoring tab. Returns true + * if we're restoring a document. + */ + restoreDocument() { + if (!this._restoringDocument) { + return; + } + + let { formdata, scrollPositions } = this._restoringDocument; + this._restoringDocument = null; + + let window = this.docShell.domWindow; + + // Restore form data. + lazy.Utils.restoreFrameTreeData(window, formdata, (frame, data) => { + // restore() will return false, and thus abort restoration for the + // current |frame| and its descendants, if |data.url| is given but + // doesn't match the loaded document's URL. + return SessionStoreUtils.restoreFormData(frame.document, data); + }); + + // Restore scroll data. + lazy.Utils.restoreFrameTreeData(window, scrollPositions, (frame, data) => { + if (data.scroll) { + SessionStoreUtils.restoreScrollPosition(frame, data); + } + }); + }, + + /** + * Cancel an ongoing restore. This function can be called any time between + * restoreHistory and restoreDocument. + * + * This function is called externally (if a restore is canceled) and + * internally (when the loads for a restore have finished). In the latter + * case, it's called before restoreDocument, so it cannot clear + * _restoringDocument. + */ + resetRestore() { + this._tabData = null; + + if (this._historyListener) { + this._historyListener.uninstall(); + } + this._historyListener = null; + + if (this._progressListener) { + this._progressListener.uninstall(); + } + this._progressListener = null; + }, +}; + +/* + * This listener detects when a page being restored is reloaded. It triggers a + * callback and cancels the reload. The callback will send a message to + * SessionStore.sys.mjs so that it can restore the content immediately. + */ +function HistoryListener(docShell, callback) { + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + webNavigation.sessionHistory.legacySHistory.addSHistoryListener(this); + + this.webNavigation = webNavigation; + this.callback = callback; +} +HistoryListener.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "nsISHistoryListener", + "nsISupportsWeakReference", + ]), + + uninstall() { + let shistory = this.webNavigation.sessionHistory.legacySHistory; + if (shistory) { + shistory.removeSHistoryListener(this); + } + }, + + OnHistoryGotoIndex() {}, + OnHistoryPurge() {}, + OnHistoryReplaceEntry() {}, + + // This will be called for a pending tab when loadURI(uri) is called where + // the given |uri| only differs in the fragment. + OnHistoryNewEntry(newURI) { + let currentURI = this.webNavigation.currentURI; + + // Ignore new SHistory entries with the same URI as those do not indicate + // a navigation inside a document by changing the #hash part of the URL. + // We usually hit this when purging session history for browsers. + if (currentURI && currentURI.spec == newURI.spec) { + return; + } + + // Reset the tab's URL to what it's actually showing. Without this loadURI() + // would use the current document and change the displayed URL only. + this.webNavigation.setCurrentURIForSessionStore( + Services.io.newURI("about:blank") + ); + + // Kick off a new load so that we navigate away from about:blank to the + // new URL that was passed to loadURI(). The new load will cause a + // STATE_START notification to be sent and the ProgressListener will then + // notify the parent and do the rest. + let loadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP; + let loadURIOptions = { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags, + }; + this.webNavigation.loadURI(newURI, loadURIOptions); + }, + + OnHistoryReload() { + this.callback(); + + // Cancel the load. + return false; + }, +}; + +/** + * This class informs SessionStore.sys.mjs whenever the network requests for a + * restoring page have completely finished. We only restore three tabs + * simultaneously, so this is the signal for SessionStore.sys.mjs to kick off + * another restore (if there are more to do). + * + * The progress listener is also used to be notified when a load not initiated + * by sessionstore starts. Pending tabs will then need to be marked as no + * longer pending. + */ +function ProgressListener(docShell, callbacks) { + let webProgress = docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + webProgress.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW); + + this.webProgress = webProgress; + this.callbacks = callbacks; +} + +ProgressListener.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + + uninstall() { + this.webProgress.removeProgressListener(this); + }, + + onStateChange(webProgress, request, stateFlags, status) { + let { STATE_IS_WINDOW, STATE_STOP, STATE_START } = + Ci.nsIWebProgressListener; + if (!webProgress.isTopLevel || !(stateFlags & STATE_IS_WINDOW)) { + return; + } + + if (stateFlags & STATE_START && this.callbacks.onStartRequest) { + this.callbacks.onStartRequest(); + } + + if (stateFlags & STATE_STOP && this.callbacks.onStopRequest) { + this.callbacks.onStopRequest(); + } + }, +}; diff --git a/browser/components/sessionstore/ContentSessionStore.sys.mjs b/browser/components/sessionstore/ContentSessionStore.sys.mjs new file mode 100644 index 0000000000..44f59cd39d --- /dev/null +++ b/browser/components/sessionstore/ContentSessionStore.sys.mjs @@ -0,0 +1,685 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + clearTimeout, + setTimeoutWithTarget, +} from "resource://gre/modules/Timer.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContentRestore: "resource:///modules/sessionstore/ContentRestore.sys.mjs", + SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs", +}); + +// This pref controls whether or not we send updates to the parent on a timeout +// or not, and should only be used for tests or debugging. +const TIMEOUT_DISABLED_PREF = "browser.sessionstore.debug.no_auto_updates"; + +const PREF_INTERVAL = "browser.sessionstore.interval"; + +const kNoIndex = Number.MAX_SAFE_INTEGER; +const kLastIndex = Number.MAX_SAFE_INTEGER - 1; + +class Handler { + constructor(store) { + this.store = store; + } + + get contentRestore() { + return this.store.contentRestore; + } + + get contentRestoreInitialized() { + return this.store.contentRestoreInitialized; + } + + get mm() { + return this.store.mm; + } + + get messageQueue() { + return this.store.messageQueue; + } +} + +/** + * Listens for and handles content events that we need for the + * session store service to be notified of state changes in content. + */ +class EventListener extends Handler { + constructor(store) { + super(store); + + SessionStoreUtils.addDynamicFrameFilteredListener( + this.mm, + "load", + this, + true + ); + } + + handleEvent(event) { + let { content } = this.mm; + + // Ignore load events from subframes. + if (event.target != content.document) { + return; + } + + if (content.document.documentURI.startsWith("about:reader")) { + if ( + event.type == "load" && + !content.document.body.classList.contains("loaded") + ) { + // Don't restore the scroll position of an about:reader page at this + // point; listen for the custom event dispatched from AboutReader.sys.mjs. + content.addEventListener("AboutReaderContentReady", this); + return; + } + + content.removeEventListener("AboutReaderContentReady", this); + } + + if (this.contentRestoreInitialized) { + // Restore the form data and scroll position. + this.contentRestore.restoreDocument(); + } + } +} + +/** + * Listens for changes to the session history. Whenever the user navigates + * we will collect URLs and everything belonging to session history. + * + * Causes a SessionStore:update message to be sent that contains the current + * session history. + * + * Example: + * {entries: [{url: "about:mozilla", ...}, ...], index: 1} + */ +class SessionHistoryListener extends Handler { + constructor(store) { + super(store); + + this._fromIdx = kNoIndex; + + // By adding the SHistoryListener immediately, we will unfortunately be + // notified of every history entry as the tab is restored. We don't bother + // waiting to add the listener later because these notifications are cheap. + // We will likely only collect once since we are batching collection on + // a delay. + this.mm.docShell + .QueryInterface(Ci.nsIWebNavigation) + .sessionHistory.legacySHistory.addSHistoryListener(this); // OK in non-geckoview + + let webProgress = this.mm.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + + webProgress.addProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT + ); + + // Collect data if we start with a non-empty shistory. + if (!lazy.SessionHistory.isEmpty(this.mm.docShell)) { + this.collect(); + // When a tab is detached from the window, for the new window there is a + // new SessionHistoryListener created. Normally it is empty at this point + // but in a test env. the initial about:blank might have a children in which + // case we fire off a history message here with about:blank in it. If we + // don't do it ASAP then there is going to be a browser swap and the parent + // will be all confused by that message. + this.store.messageQueue.send(); + } + + // Listen for page title changes. + this.mm.addEventListener("DOMTitleChanged", this); + } + + get mm() { + return this.store.mm; + } + + uninit() { + let sessionHistory = this.mm.docShell.QueryInterface( + Ci.nsIWebNavigation + ).sessionHistory; + if (sessionHistory) { + sessionHistory.legacySHistory.removeSHistoryListener(this); // OK in non-geckoview + } + } + + collect() { + // We want to send down a historychange even for full collects in case our + // session history is a partial session history, in which case we don't have + // enough information for a full update. collectFrom(-1) tells the collect + // function to collect all data avaliable in this process. + if (this.mm.docShell) { + this.collectFrom(-1); + } + } + + // History can grow relatively big with the nested elements, so if we don't have to, we + // don't want to send the entire history all the time. For a simple optimization + // we keep track of the smallest index from after any change has occured and we just send + // the elements from that index. If something more complicated happens we just clear it + // and send the entire history. We always send the additional info like the current selected + // index (so for going back and forth between history entries we set the index to kLastIndex + // if nothing else changed send an empty array and the additonal info like the selected index) + collectFrom(idx) { + if (this._fromIdx <= idx) { + // If we already know that we need to update history fromn index N we can ignore any changes + // tha happened with an element with index larger than N. + // Note: initially we use kNoIndex which is MAX_SAFE_INTEGER which means we don't ignore anything + // here, and in case of navigation in the history back and forth we use kLastIndex which ignores + // only the subsequent navigations, but not any new elements added. + return; + } + + this._fromIdx = idx; + this.store.messageQueue.push("historychange", () => { + if (this._fromIdx === kNoIndex) { + return null; + } + + let history = lazy.SessionHistory.collect( + this.mm.docShell, + this._fromIdx + ); + this._fromIdx = kNoIndex; + return history; + }); + } + + handleEvent(event) { + this.collect(); + } + + OnHistoryNewEntry(newURI, oldIndex) { + // Collect the current entry as well, to make sure to collect any changes + // that were made to the entry while the document was active. + this.collectFrom(oldIndex == -1 ? oldIndex : oldIndex - 1); + } + + OnHistoryGotoIndex() { + // We ought to collect the previously current entry as well, see bug 1350567. + this.collectFrom(kLastIndex); + } + + OnHistoryPurge() { + this.collect(); + } + + OnHistoryReload() { + this.collect(); + return true; + } + + OnHistoryReplaceEntry() { + this.collect(); + } + + /** + * @see nsIWebProgressListener.onStateChange + */ + onStateChange(webProgress, request, stateFlags, status) { + // Ignore state changes for subframes because we're only interested in the + // top-document starting or stopping its load. + if (!webProgress.isTopLevel || webProgress.DOMWindow != this.mm.content) { + return; + } + + // onStateChange will be fired when loading the initial about:blank URI for + // a browser, which we don't actually care about. This is particularly for + // the case of unrestored background tabs, where the content has not yet + // been restored: we don't want to accidentally send any updates to the + // parent when the about:blank placeholder page has loaded. + if (!this.mm.docShell.hasLoadedNonBlankURI) { + return; + } + + if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { + this.collect(); + } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + this.collect(); + } + } +} +SessionHistoryListener.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISHistoryListener", + "nsISupportsWeakReference", +]); + +/** + * A message queue that takes collected data and will take care of sending it + * to the chrome process. It allows flushing using synchronous messages and + * takes care of any race conditions that might occur because of that. Changes + * will be batched if they're pushed in quick succession to avoid a message + * flood. + */ +class MessageQueue extends Handler { + constructor(store) { + super(store); + + /** + * A map (string -> lazy fn) holding lazy closures of all queued data + * collection routines. These functions will return data collected from the + * docShell. + */ + this._data = new Map(); + + /** + * The delay (in ms) used to delay sending changes after data has been + * invalidated. + */ + this.BATCH_DELAY_MS = 1000; + + /** + * The minimum idle period (in ms) we need for sending data to chrome process. + */ + this.NEEDED_IDLE_PERIOD_MS = 5; + + /** + * Timeout for waiting an idle period to send data. We will set this from + * the pref "browser.sessionstore.interval". + */ + this._timeoutWaitIdlePeriodMs = null; + + /** + * The current timeout ID, null if there is no queue data. We use timeouts + * to damp a flood of data changes and send lots of changes as one batch. + */ + this._timeout = null; + + /** + * Whether or not sending batched messages on a timer is disabled. This should + * only be used for debugging or testing. If you need to access this value, + * you should probably use the timeoutDisabled getter. + */ + this._timeoutDisabled = false; + + /** + * True if there is already a send pending idle dispatch, set to prevent + * scheduling more than one. If false there may or may not be one scheduled. + */ + this._idleScheduled = false; + + this.timeoutDisabled = Services.prefs.getBoolPref(TIMEOUT_DISABLED_PREF); + this._timeoutWaitIdlePeriodMs = Services.prefs.getIntPref(PREF_INTERVAL); + + Services.prefs.addObserver(TIMEOUT_DISABLED_PREF, this); + Services.prefs.addObserver(PREF_INTERVAL, this); + } + + /** + * True if batched messages are not being fired on a timer. This should only + * ever be true when debugging or during tests. + */ + get timeoutDisabled() { + return this._timeoutDisabled; + } + + /** + * Disables sending batched messages on a timer. Also cancels any pending + * timers. + */ + set timeoutDisabled(val) { + this._timeoutDisabled = val; + + if (val && this._timeout) { + clearTimeout(this._timeout); + this._timeout = null; + } + } + + uninit() { + Services.prefs.removeObserver(TIMEOUT_DISABLED_PREF, this); + Services.prefs.removeObserver(PREF_INTERVAL, this); + this.cleanupTimers(); + } + + /** + * Cleanup pending idle callback and timer. + */ + cleanupTimers() { + this._idleScheduled = false; + if (this._timeout) { + clearTimeout(this._timeout); + this._timeout = null; + } + } + + observe(subject, topic, data) { + if (topic == "nsPref:changed") { + switch (data) { + case TIMEOUT_DISABLED_PREF: + this.timeoutDisabled = Services.prefs.getBoolPref( + TIMEOUT_DISABLED_PREF + ); + break; + case PREF_INTERVAL: + this._timeoutWaitIdlePeriodMs = + Services.prefs.getIntPref(PREF_INTERVAL); + break; + default: + console.error("received unknown message '" + data + "'"); + break; + } + } + } + + /** + * Pushes a given |value| onto the queue. The given |key| represents the type + * of data that is stored and can override data that has been queued before + * but has not been sent to the parent process, yet. + * + * @param key (string) + * A unique identifier specific to the type of data this is passed. + * @param fn (function) + * A function that returns the value that will be sent to the parent + * process. + */ + push(key, fn) { + this._data.set(key, fn); + + if (!this._timeout && !this._timeoutDisabled) { + // Wait a little before sending the message to batch multiple changes. + this._timeout = setTimeoutWithTarget( + () => this.sendWhenIdle(), + this.BATCH_DELAY_MS, + this.mm.tabEventTarget + ); + } + } + + /** + * Sends queued data when the remaining idle time is enough or waiting too + * long; otherwise, request an idle time again. If the |deadline| is not + * given, this function is going to schedule the first request. + * + * @param deadline (object) + * An IdleDeadline object passed by idleDispatch(). + */ + sendWhenIdle(deadline) { + if (!this.mm.content) { + // The frameloader is being torn down. Nothing more to do. + return; + } + + if (deadline) { + if ( + deadline.didTimeout || + deadline.timeRemaining() > this.NEEDED_IDLE_PERIOD_MS + ) { + this.send(); + return; + } + } else if (this._idleScheduled) { + // Bail out if there's a pending run. + return; + } + ChromeUtils.idleDispatch(deadline_ => this.sendWhenIdle(deadline_), { + timeout: this._timeoutWaitIdlePeriodMs, + }); + this._idleScheduled = true; + } + + /** + * Sends queued data to the chrome process. + * + * @param options (object) + * {flushID: 123} to specify that this is a flush + * {isFinal: true} to signal this is the final message sent on unload + */ + send(options = {}) { + // Looks like we have been called off a timeout after the tab has been + // closed. The docShell is gone now and we can just return here as there + // is nothing to do. + if (!this.mm.docShell) { + return; + } + + this.cleanupTimers(); + + let flushID = (options && options.flushID) || 0; + let histID = "FX_SESSION_RESTORE_CONTENT_COLLECT_DATA_MS"; + + let data = {}; + for (let [key, func] of this._data) { + if (key != "isPrivate") { + TelemetryStopwatch.startKeyed(histID, key); + } + + let value = func(); + + if (key != "isPrivate") { + TelemetryStopwatch.finishKeyed(histID, key); + } + + if (value || (key != "storagechange" && key != "historychange")) { + data[key] = value; + } + } + + this._data.clear(); + + try { + // Send all data to the parent process. + this.mm.sendAsyncMessage("SessionStore:update", { + data, + flushID, + isFinal: options.isFinal || false, + epoch: this.store.epoch, + }); + } catch (ex) { + if (ex && ex.result == Cr.NS_ERROR_OUT_OF_MEMORY) { + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_SEND_UPDATE_CAUSED_OOM") + .add(1); + this.mm.sendAsyncMessage("SessionStore:error"); + } + } + } +} + +/** + * Listens for and handles messages sent by the session store service. + */ +const MESSAGES = [ + "SessionStore:restoreHistory", + "SessionStore:restoreTabContent", + "SessionStore:resetRestore", + "SessionStore:flush", + "SessionStore:prepareForProcessChange", +]; + +export class ContentSessionStore { + constructor(mm) { + if (Services.appinfo.sessionHistoryInParent) { + throw new Error("This frame script should not be loaded for SHIP"); + } + + this.mm = mm; + this.messageQueue = new MessageQueue(this); + + this.epoch = 0; + + this.contentRestoreInitialized = false; + + this.handlers = [ + this.messageQueue, + new EventListener(this), + new SessionHistoryListener(this), + ]; + + ChromeUtils.defineLazyGetter(this, "contentRestore", () => { + this.contentRestoreInitialized = true; + return new lazy.ContentRestore(mm); + }); + + MESSAGES.forEach(m => mm.addMessageListener(m, this)); + + mm.addEventListener("unload", this); + } + + receiveMessage({ name, data }) { + // The docShell might be gone. Don't process messages, + // that will just lead to errors anyway. + if (!this.mm.docShell) { + return; + } + + // A fresh tab always starts with epoch=0. The parent has the ability to + // override that to signal a new era in this tab's life. This enables it + // to ignore async messages that were already sent but not yet received + // and would otherwise confuse the internal tab state. + if (data && data.epoch && data.epoch != this.epoch) { + this.epoch = data.epoch; + } + + switch (name) { + case "SessionStore:restoreHistory": + this.restoreHistory(data); + break; + case "SessionStore:restoreTabContent": + this.restoreTabContent(data); + break; + case "SessionStore:resetRestore": + this.contentRestore.resetRestore(); + break; + case "SessionStore:flush": + this.flush(data); + break; + case "SessionStore:prepareForProcessChange": + // During normal in-process navigations, the DocShell would take + // care of automatically persisting layout history state to record + // scroll positions on the nsSHEntry. Unfortunately, process switching + // is not a normal navigation, so for now we do this ourselves. This + // is a workaround until session history state finally lives in the + // parent process. + this.mm.docShell.persistLayoutHistoryState(); + break; + default: + console.error("received unknown message '" + name + "'"); + break; + } + } + + // non-SHIP only + restoreHistory(data) { + let { epoch, tabData, loadArguments, isRemotenessUpdate } = data; + + this.contentRestore.restoreHistory(tabData, loadArguments, { + // Note: The callbacks passed here will only be used when a load starts + // that was not initiated by sessionstore itself. This can happen when + // some code calls browser.loadURI() or browser.reload() on a pending + // browser/tab. + + onLoadStarted: () => { + // Notify the parent that the tab is no longer pending. + this.mm.sendAsyncMessage("SessionStore:restoreTabContentStarted", { + epoch, + }); + }, + + onLoadFinished: () => { + // Tell SessionStore.sys.mjs that it may want to restore some more tabs, + // since it restores a max of MAX_CONCURRENT_TAB_RESTORES at a time. + this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", { + epoch, + }); + }, + }); + + if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT) { + // For non-remote tabs, when restoreHistory finishes, we send a synchronous + // message to SessionStore.sys.mjs so that it can run SSTabRestoring. Users of + // SSTabRestoring seem to get confused if chrome and content are out of + // sync about the state of the restore (particularly regarding + // docShell.currentURI). Using a synchronous message is the easiest way + // to temporarily synchronize them. + // + // For remote tabs, because all nsIWebProgress notifications are sent + // asynchronously using messages, we get the same-order guarantees of the + // message manager, and can use an async message. + this.mm.sendSyncMessage("SessionStore:restoreHistoryComplete", { + epoch, + isRemotenessUpdate, + }); + } else { + this.mm.sendAsyncMessage("SessionStore:restoreHistoryComplete", { + epoch, + isRemotenessUpdate, + }); + } + } + + restoreTabContent({ loadArguments, isRemotenessUpdate, reason }) { + let epoch = this.epoch; + + // We need to pass the value of didStartLoad back to SessionStore.sys.mjs. + let didStartLoad = this.contentRestore.restoreTabContent( + loadArguments, + isRemotenessUpdate, + () => { + // Tell SessionStore.sys.mjs that it may want to restore some more tabs, + // since it restores a max of MAX_CONCURRENT_TAB_RESTORES at a time. + this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", { + epoch, + isRemotenessUpdate, + }); + } + ); + + this.mm.sendAsyncMessage("SessionStore:restoreTabContentStarted", { + epoch, + isRemotenessUpdate, + reason, + }); + + if (!didStartLoad) { + // Pretend that the load succeeded so that event handlers fire correctly. + this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", { + epoch, + isRemotenessUpdate, + }); + } + } + + flush({ id }) { + // Flush the message queue, send the latest updates. + this.messageQueue.send({ flushID: id }); + } + + handleEvent(event) { + if (event.type == "unload") { + this.onUnload(); + } + } + + onUnload() { + // Upon frameLoader destruction, send a final update message to + // the parent and flush all data currently held in the child. + this.messageQueue.send({ isFinal: true }); + + for (let handler of this.handlers) { + if (handler.uninit) { + handler.uninit(); + } + } + + if (this.contentRestoreInitialized) { + // Remove progress listeners. + this.contentRestore.resetRestore(); + } + + // We don't need to take care of any StateChangeNotifier observers as they + // will die with the content script. The same goes for the privacy transition + // observer that will die with the docShell when the tab is closed. + } +} diff --git a/browser/components/sessionstore/GlobalState.sys.mjs b/browser/components/sessionstore/GlobalState.sys.mjs new file mode 100644 index 0000000000..a49fe4650d --- /dev/null +++ b/browser/components/sessionstore/GlobalState.sys.mjs @@ -0,0 +1,88 @@ +/* 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 EXPORTED_METHODS = [ + "getState", + "clear", + "get", + "set", + "delete", + "setFromState", +]; + +/** + * Module that contains global session data. + */ +export function GlobalState() { + let internal = new GlobalStateInternal(); + let external = {}; + for (let method of EXPORTED_METHODS) { + external[method] = internal[method].bind(internal); + } + return Object.freeze(external); +} + +function GlobalStateInternal() { + // Storage for global state. + this.state = {}; +} + +GlobalStateInternal.prototype = { + /** + * Get all value from the global state. + */ + getState() { + return this.state; + }, + + /** + * Clear all currently stored global state. + */ + clear() { + this.state = {}; + }, + + /** + * Retrieve a value from the global state. + * + * @param aKey + * A key the value is stored under. + * @return The value stored at aKey, or an empty string if no value is set. + */ + get(aKey) { + return this.state[aKey] || ""; + }, + + /** + * Set a global value. + * + * @param aKey + * A key to store the value under. + */ + set(aKey, aStringValue) { + this.state[aKey] = aStringValue; + }, + + /** + * Delete a global value. + * + * @param aKey + * A key to delete the value for. + */ + delete(aKey) { + delete this.state[aKey]; + }, + + /** + * Set the current global state from a state object. Any previous global + * state will be removed, even if the new state does not contain a matching + * key. + * + * @param aState + * A state object to extract global state from to be set. + */ + setFromState(aState) { + this.state = (aState && aState.global) || {}; + }, +}; diff --git a/browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs b/browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs new file mode 100644 index 0000000000..4d53b166c0 --- /dev/null +++ b/browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs @@ -0,0 +1,372 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "l10n", () => { + return new Localization(["browser/recentlyClosed.ftl"], true); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "closedTabsFromAllWindowsEnabled", + "browser.sessionstore.closedTabsFromAllWindows" +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "closedTabsFromClosedWindowsEnabled", + "browser.sessionstore.closedTabsFromClosedWindows" +); + +export var RecentlyClosedTabsAndWindowsMenuUtils = { + /** + * Builds up a document fragment of UI items for the recently closed tabs. + * @param aWindow + * The window that the tabs were closed in. + * @param aTagName + * The tag name that will be used when creating the UI items. + * @param aPrefixRestoreAll (defaults to false) + * Whether the 'restore all tabs' item is suffixed or prefixed to the list. + * If suffixed (the default) a separator will be inserted before it. + * @returns A document fragment with UI items for each recently closed tab. + */ + getTabsFragment(aWindow, aTagName, aPrefixRestoreAll = false) { + let doc = aWindow.document; + const isPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate(aWindow); + const fragment = doc.createDocumentFragment(); + let isEmpty = true; + + if ( + lazy.SessionStore.getClosedTabCount({ + sourceWindow: aWindow, + closedTabsFromClosedWindows: false, + }) + ) { + isEmpty = false; + const browserWindows = lazy.closedTabsFromAllWindowsEnabled + ? lazy.SessionStore.getWindows(aWindow) + : [aWindow]; + + for (const win of browserWindows) { + const closedTabs = lazy.SessionStore.getClosedTabDataForWindow(win); + for (let i = 0; i < closedTabs.length; i++) { + createEntry( + aTagName, + false, + i, + closedTabs[i], + doc, + closedTabs[i].title, + fragment + ); + } + } + } + + if ( + !isPrivate && + lazy.closedTabsFromClosedWindowsEnabled && + lazy.SessionStore.getClosedTabCountFromClosedWindows() + ) { + isEmpty = false; + const closedTabs = lazy.SessionStore.getClosedTabDataFromClosedWindows(); + for (let i = 0; i < closedTabs.length; i++) { + createEntry( + aTagName, + false, + i, + closedTabs[i], + doc, + closedTabs[i].title, + fragment + ); + } + } + + if (!isEmpty) { + createRestoreAllEntry( + doc, + fragment, + aPrefixRestoreAll, + false, + aTagName == "menuitem" + ? "recently-closed-menu-reopen-all-tabs" + : "recently-closed-panel-reopen-all-tabs", + aTagName + ); + } + return fragment; + }, + + /** + * Builds up a document fragment of UI items for the recently closed windows. + * @param aWindow + * A window that can be used to create the elements and document fragment. + * @param aTagName + * The tag name that will be used when creating the UI items. + * @param aPrefixRestoreAll (defaults to false) + * Whether the 'restore all windows' item is suffixed or prefixed to the list. + * If suffixed (the default) a separator will be inserted before it. + * @returns A document fragment with UI items for each recently closed window. + */ + getWindowsFragment(aWindow, aTagName, aPrefixRestoreAll = false) { + let closedWindowData = lazy.SessionStore.getClosedWindowData(); + let doc = aWindow.document; + let fragment = doc.createDocumentFragment(); + if (closedWindowData.length) { + for (let i = 0; i < closedWindowData.length; i++) { + const { selected, tabs, title } = closedWindowData[i]; + const selectedTab = tabs[selected - 1]; + if (selectedTab) { + const menuLabel = lazy.l10n.formatValueSync( + "recently-closed-undo-close-window-label", + { tabCount: tabs.length - 1, winTitle: title } + ); + createEntry(aTagName, true, i, selectedTab, doc, menuLabel, fragment); + } + } + + createRestoreAllEntry( + doc, + fragment, + aPrefixRestoreAll, + true, + aTagName == "menuitem" + ? "recently-closed-menu-reopen-all-windows" + : "recently-closed-panel-reopen-all-windows", + aTagName + ); + } + return fragment; + }, + + /** + * Handle a command event to re-open all closed tabs + * @param aEvent + * The command event when the user clicks the restore all menu item + */ + onRestoreAllTabsCommand(aEvent) { + const currentWindow = aEvent.target.ownerGlobal; + const browserWindows = lazy.closedTabsFromAllWindowsEnabled + ? lazy.SessionStore.getWindows(currentWindow) + : [currentWindow]; + for (const sourceWindow of browserWindows) { + let count = lazy.SessionStore.getClosedTabCountForWindow(sourceWindow); + while (--count >= 0) { + lazy.SessionStore.undoCloseTab(sourceWindow, 0, currentWindow); + } + } + if (lazy.closedTabsFromClosedWindowsEnabled) { + for (let tabData of lazy.SessionStore.getClosedTabDataFromClosedWindows()) { + lazy.SessionStore.undoClosedTabFromClosedWindow( + { sourceClosedId: tabData.sourceClosedId }, + tabData.closedId, + currentWindow + ); + } + } + }, + + /** + * Handle a command event to re-open all closed windows + * @param aEvent + * The command event when the user clicks the restore all menu item + */ + onRestoreAllWindowsCommand(aEvent) { + const count = lazy.SessionStore.getClosedWindowCount(); + for (let index = 0; index < count; index++) { + lazy.SessionStore.undoCloseWindow(index); + } + }, + + /** + * Re-open a closed tab and put it to the end of the tab strip. + * Used for a middle click. + * @param aEvent + * The event when the user clicks the menu item + */ + _undoCloseMiddleClick(aEvent) { + if (aEvent.button != 1) { + return; + } + if (aEvent.originalTarget.hasAttribute("source-closed-id")) { + lazy.SessionStore.undoClosedTabFromClosedWindow( + { + sourceClosedId: + aEvent.originalTarget.getAttribute("source-closed-id"), + }, + aEvent.originalTarget.getAttribute("value") + ); + } else { + aEvent.view.undoCloseTab( + aEvent.originalTarget.getAttribute("value"), + aEvent.originalTarget.getAttribute("source-window-id") + ); + } + aEvent.view.gBrowser.moveTabToEnd(); + let ancestorPanel = aEvent.target.closest("panel"); + if (ancestorPanel) { + ancestorPanel.hidePopup(); + } + }, +}; + +/** + * Create a UI entry for a recently closed tab or window. + * @param aTagName + * the tag name that will be used when creating the UI entry + * @param aIsWindowsFragment + * whether or not this entry will represent a closed window + * @param aIndex + * the index of the closed tab + * @param aClosedTab + * the closed tab + * @param aDocument + * a document that can be used to create the entry + * @param aMenuLabel + * the label the created entry will have + * @param aFragment + * the fragment the created entry will be in + */ +function createEntry( + aTagName, + aIsWindowsFragment, + aIndex, + aClosedTab, + aDocument, + aMenuLabel, + aFragment +) { + let element = aDocument.createXULElement(aTagName); + + element.setAttribute("label", aMenuLabel); + if (aClosedTab.image) { + const iconURL = lazy.PlacesUIUtils.getImageURL(aClosedTab.image); + element.setAttribute("image", iconURL); + } + + if (aIsWindowsFragment) { + element.setAttribute("oncommand", `undoCloseWindow("${aIndex}");`); + } else if (typeof aClosedTab.sourceClosedId == "number") { + // sourceClosedId is used to look up the closed window to remove it when the tab is restored + let sourceClosedId = aClosedTab.sourceClosedId; + element.setAttribute("source-closed-id", sourceClosedId); + element.setAttribute("value", aClosedTab.closedId); + element.removeAttribute("oncommand"); + element.addEventListener( + "command", + event => { + lazy.SessionStore.undoClosedTabFromClosedWindow( + { sourceClosedId }, + aClosedTab.closedId + ); + }, + { once: true } + ); + } else { + // sourceWindowId is used to look up the closed tab entry to remove it when it is restored + let sourceWindowId = aClosedTab.sourceWindowId; + element.setAttribute("value", aIndex); + element.setAttribute("source-window-id", sourceWindowId); + element.setAttribute( + "oncommand", + `undoCloseTab(${aIndex}, "${sourceWindowId}");` + ); + } + + if (aTagName == "menuitem") { + element.setAttribute( + "class", + "menuitem-iconic bookmark-item menuitem-with-favicon" + ); + } + + // Set the targetURI attribute so it will be shown in tooltip. + // SessionStore uses one-based indexes, so we need to normalize them. + let tabData; + tabData = aIsWindowsFragment ? aClosedTab : aClosedTab.state; + let activeIndex = (tabData.index || tabData.entries.length) - 1; + if (activeIndex >= 0 && tabData.entries[activeIndex]) { + element.setAttribute("targetURI", tabData.entries[activeIndex].url); + } + + // Windows don't open in new tabs and menuitems dispatch command events on + // middle click, so we only need to manually handle middle clicks for + // toolbarbuttons. + if (!aIsWindowsFragment && aTagName != "menuitem") { + element.addEventListener( + "click", + RecentlyClosedTabsAndWindowsMenuUtils._undoCloseMiddleClick + ); + } + + if (aIndex == 0) { + element.setAttribute( + "key", + "key_undoClose" + (aIsWindowsFragment ? "Window" : "Tab") + ); + } + + aFragment.appendChild(element); +} + +/** + * Create an entry to restore all closed windows or tabs. + * @param aDocument + * a document that can be used to create the entry + * @param aFragment + * the fragment the created entry will be in + * @param aPrefixRestoreAll + * whether the 'restore all windows' item is suffixed or prefixed to the list + * If suffixed a separator will be inserted before it. + * @param aIsWindowsFragment + * whether or not this entry will represent a closed window + * @param aRestoreAllLabel + * which localizable string to use for the entry + * @param aEntryCount + * the number of elements to be restored by this entry + * @param aTagName + * the tag name that will be used when creating the UI entry + */ +function createRestoreAllEntry( + aDocument, + aFragment, + aPrefixRestoreAll, + aIsWindowsFragment, + aRestoreAllLabel, + aTagName +) { + let restoreAllElements = aDocument.createXULElement(aTagName); + restoreAllElements.classList.add("restoreallitem"); + + // We cannot use aDocument.l10n.setAttributes because the menubar label is not + // updated in time and displays a blank string (see Bug 1691553). + restoreAllElements.setAttribute( + "label", + lazy.l10n.formatValueSync(aRestoreAllLabel) + ); + + restoreAllElements.addEventListener( + "command", + aIsWindowsFragment + ? RecentlyClosedTabsAndWindowsMenuUtils.onRestoreAllWindowsCommand + : RecentlyClosedTabsAndWindowsMenuUtils.onRestoreAllTabsCommand + ); + + if (aPrefixRestoreAll) { + aFragment.insertBefore(restoreAllElements, aFragment.firstChild); + } else { + aFragment.appendChild(aDocument.createXULElement("menuseparator")); + aFragment.appendChild(restoreAllElements); + } +} diff --git a/browser/components/sessionstore/RunState.sys.mjs b/browser/components/sessionstore/RunState.sys.mjs new file mode 100644 index 0000000000..94f9a86fcd --- /dev/null +++ b/browser/components/sessionstore/RunState.sys.mjs @@ -0,0 +1,92 @@ +/* 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 STATE_STOPPED = 0; +const STATE_RUNNING = 1; +const STATE_QUITTING = 2; +const STATE_CLOSING = 3; +const STATE_CLOSED = 4; + +// We're initially stopped. +var state = STATE_STOPPED; + +/** + * This module keeps track of SessionStore's current run state. We will + * always start out at STATE_STOPPED. After the session was read from disk and + * the initial browser window has loaded we switch to STATE_RUNNING. On the + * first notice that a browser shutdown was granted we switch to STATE_QUITTING. + */ +export var RunState = Object.freeze({ + // If we're stopped then SessionStore hasn't been initialized yet. As soon + // as the session is read from disk and the initial browser window has loaded + // the run state will change to STATE_RUNNING. + get isStopped() { + return state == STATE_STOPPED; + }, + + // STATE_RUNNING is our default mode of operation that we'll spend most of + // the time in. After the session was read from disk and the first browser + // window has loaded we remain running until the browser quits. + get isRunning() { + return state == STATE_RUNNING; + }, + + // We will enter STATE_QUITTING as soon as we receive notice that a browser + // shutdown was granted. SessionStore will use this information to prevent + // us from collecting partial information while the browser is shutting down + // as well as to allow a last single write to disk and block all writes after + // that. + get isQuitting() { + return state >= STATE_QUITTING; + }, + + // We will enter STATE_CLOSING as soon as SessionStore is uninitialized. + // The SessionFile module will know that a last write will happen in this + // state and it can do some necessary cleanup. + get isClosing() { + return state == STATE_CLOSING; + }, + + // We will enter STATE_CLOSED as soon as SessionFile has written to disk for + // the last time before shutdown and will not accept any further writes. + get isClosed() { + return state == STATE_CLOSED; + }, + + // Switch the run state to STATE_RUNNING. This must be called after the + // session was read from, the initial browser window has loaded and we're + // now ready to restore session data. + setRunning() { + if (this.isStopped) { + state = STATE_RUNNING; + } + }, + + // Switch the run state to STATE_CLOSING. This must be called *before* the + // last SessionFile.write() call so that SessionFile knows we're closing and + // can do some last cleanups and write a proper sessionstore.js file. + setClosing() { + if (this.isQuitting) { + state = STATE_CLOSING; + } + }, + + // Switch the run state to STATE_CLOSED. This must be called by SessionFile + // after the last write to disk was accepted and no further writes will be + // allowed. Any writes after this stage will cause exceptions. + setClosed() { + if (this.isClosing) { + state = STATE_CLOSED; + } + }, + + // Switch the run state to STATE_QUITTING. This should be called once we're + // certain that the browser is going away and before we start collecting the + // final window states to save in the session file. + setQuitting() { + if (this.isRunning) { + state = STATE_QUITTING; + } + }, +}); diff --git a/browser/components/sessionstore/SessionCookies.sys.mjs b/browser/components/sessionstore/SessionCookies.sys.mjs new file mode 100644 index 0000000000..4ab1f9962f --- /dev/null +++ b/browser/components/sessionstore/SessionCookies.sys.mjs @@ -0,0 +1,303 @@ +/* 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, { + PrivacyLevel: "resource://gre/modules/sessionstore/PrivacyLevel.sys.mjs", +}); + +const MAX_EXPIRY = Number.MAX_SAFE_INTEGER; + +/** + * The external API implemented by the SessionCookies module. + */ +export var SessionCookies = Object.freeze({ + collect() { + return SessionCookiesInternal.collect(); + }, + + restore(cookies) { + SessionCookiesInternal.restore(cookies); + }, +}); + +/** + * The internal API. + */ +var SessionCookiesInternal = { + /** + * Stores whether we're initialized, yet. + */ + _initialized: false, + + /** + * Retrieve an array of all stored session cookies. + */ + collect() { + this._ensureInitialized(); + return CookieStore.toArray(); + }, + + /** + * Restores a given list of session cookies. + */ + restore(cookies) { + for (let cookie of cookies) { + let expiry = "expiry" in cookie ? cookie.expiry : MAX_EXPIRY; + let exists = false; + try { + exists = Services.cookies.cookieExists( + cookie.host, + cookie.path || "", + cookie.name || "", + cookie.originAttributes || {} + ); + } catch (ex) { + console.error( + `CookieService::CookieExists failed with error '${ex}' for '${JSON.stringify( + cookie + )}'.` + ); + } + if (!exists) { + try { + Services.cookies.add( + cookie.host, + cookie.path || "", + cookie.name || "", + cookie.value, + !!cookie.secure, + !!cookie.httponly, + /* isSession = */ true, + expiry, + cookie.originAttributes || {}, + cookie.sameSite || Ci.nsICookie.SAMESITE_NONE, + cookie.schemeMap || Ci.nsICookie.SCHEME_HTTPS + ); + } catch (ex) { + console.error( + `CookieService::Add failed with error '${ex}' for cookie ${JSON.stringify( + cookie + )}.` + ); + } + } + } + }, + + /** + * Handles observers notifications that are sent whenever cookies are added, + * changed, or removed. Ensures that the storage is updated accordingly. + */ + observe(subject) { + let notification = subject.QueryInterface(Ci.nsICookieNotification); + + let { + COOKIE_DELETED, + COOKIE_ADDED, + COOKIE_CHANGED, + ALL_COOKIES_CLEARED, + COOKIES_BATCH_DELETED, + } = Ci.nsICookieNotification; + + switch (notification.action) { + case COOKIE_ADDED: + this._addCookie(notification.cookie); + break; + case COOKIE_CHANGED: + this._updateCookie(notification.cookie); + break; + case COOKIE_DELETED: + this._removeCookie(notification.cookie); + break; + case ALL_COOKIES_CLEARED: + CookieStore.clear(); + break; + case COOKIES_BATCH_DELETED: + this._removeCookies(notification.batchDeletedCookies); + break; + default: + throw new Error("Unhandled session-cookie-changed notification."); + } + }, + + /** + * If called for the first time in a session, iterates all cookies in the + * cookies service and puts them into the store if they're session cookies. + */ + _ensureInitialized() { + if (this._initialized) { + return; + } + this._reloadCookies(); + this._initialized = true; + Services.obs.addObserver(this, "session-cookie-changed"); + + // Listen for privacy level changes to reload cookies when needed. + Services.prefs.addObserver("browser.sessionstore.privacy_level", () => { + this._reloadCookies(); + }); + }, + + /** + * Adds a given cookie to the store. + */ + _addCookie(cookie) { + cookie.QueryInterface(Ci.nsICookie); + + // Store only session cookies, obey the privacy level. + if (cookie.isSession && lazy.PrivacyLevel.canSave(cookie.isSecure)) { + CookieStore.add(cookie); + } + }, + + /** + * Updates a given cookie. + */ + _updateCookie(cookie) { + cookie.QueryInterface(Ci.nsICookie); + + // Store only session cookies, obey the privacy level. + if (cookie.isSession && lazy.PrivacyLevel.canSave(cookie.isSecure)) { + CookieStore.add(cookie); + } else { + CookieStore.delete(cookie); + } + }, + + /** + * Removes a given cookie from the store. + */ + _removeCookie(cookie) { + cookie.QueryInterface(Ci.nsICookie); + + if (cookie.isSession) { + CookieStore.delete(cookie); + } + }, + + /** + * Removes a given list of cookies from the store. + */ + _removeCookies(cookies) { + for (let i = 0; i < cookies.length; i++) { + this._removeCookie(cookies.queryElementAt(i, Ci.nsICookie)); + } + }, + + /** + * Iterates all cookies in the cookies service and puts them into the store + * if they're session cookies. Obeys the user's chosen privacy level. + */ + _reloadCookies() { + CookieStore.clear(); + + // Bail out if we're not supposed to store cookies at all. + if (!lazy.PrivacyLevel.canSave(false)) { + return; + } + + for (let cookie of Services.cookies.sessionCookies) { + this._addCookie(cookie); + } + }, +}; + +/** + * The internal storage that keeps track of session cookies. + */ +var CookieStore = { + /** + * The internal map holding all known session cookies. + */ + _entries: new Map(), + + /** + * Stores a given cookie. + * + * @param cookie + * The nsICookie object to add to the storage. + */ + add(cookie) { + let jscookie = { host: cookie.host, value: cookie.value }; + + // Only add properties with non-default values to save a few bytes. + if (cookie.path) { + jscookie.path = cookie.path; + } + + if (cookie.name) { + jscookie.name = cookie.name; + } + + if (cookie.isSecure) { + jscookie.secure = true; + } + + if (cookie.isHttpOnly) { + jscookie.httponly = true; + } + + if (cookie.expiry < MAX_EXPIRY) { + jscookie.expiry = cookie.expiry; + } + + if (cookie.originAttributes) { + jscookie.originAttributes = cookie.originAttributes; + } + + if (cookie.sameSite) { + jscookie.sameSite = cookie.sameSite; + } + + if (cookie.schemeMap) { + jscookie.schemeMap = cookie.schemeMap; + } + + this._entries.set(this._getKeyForCookie(cookie), jscookie); + }, + + /** + * Removes a given cookie. + * + * @param cookie + * The nsICookie object to be removed from storage. + */ + delete(cookie) { + this._entries.delete(this._getKeyForCookie(cookie)); + }, + + /** + * Removes all cookies. + */ + clear() { + this._entries.clear(); + }, + + /** + * Return all cookies as an array. + */ + toArray() { + return [...this._entries.values()]; + }, + + /** + * Returns the key needed to properly store and identify a given cookie. + * A cookie is uniquely identified by the combination of its host, name, + * path, and originAttributes properties. + * + * @param cookie + * The nsICookie object to compute a key for. + * @return string + */ + _getKeyForCookie(cookie) { + return JSON.stringify({ + host: cookie.host, + name: cookie.name, + path: cookie.path, + attr: ChromeUtils.originAttributesToSuffix(cookie.originAttributes), + }); + }, +}; diff --git a/browser/components/sessionstore/SessionFile.sys.mjs b/browser/components/sessionstore/SessionFile.sys.mjs new file mode 100644 index 0000000000..1e5a3bf718 --- /dev/null +++ b/browser/components/sessionstore/SessionFile.sys.mjs @@ -0,0 +1,467 @@ +/* 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/. */ + +/** + * Implementation of all the disk I/O required by the session store. + * This is a private API, meant to be used only by the session store. + * It will change. Do not use it for any other purpose. + * + * Note that this module depends on SessionWriter and that it enqueues its I/O + * requests and never attempts to simultaneously execute two I/O requests on + * the files used by this module from two distinct threads. + * Otherwise, we could encounter bugs, especially under Windows, + * e.g. if a request attempts to write sessionstore.js while + * another attempts to copy that file. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + RunState: "resource:///modules/sessionstore/RunState.sys.mjs", + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + SessionWriter: "resource:///modules/sessionstore/SessionWriter.sys.mjs", +}); + +const PREF_UPGRADE_BACKUP = "browser.sessionstore.upgradeBackup.latestBuildID"; +const PREF_MAX_UPGRADE_BACKUPS = + "browser.sessionstore.upgradeBackup.maxUpgradeBackups"; + +const PREF_MAX_SERIALIZE_BACK = "browser.sessionstore.max_serialize_back"; +const PREF_MAX_SERIALIZE_FWD = "browser.sessionstore.max_serialize_forward"; + +export var SessionFile = { + /** + * Read the contents of the session file, asynchronously. + */ + read() { + return SessionFileInternal.read(); + }, + /** + * Write the contents of the session file, asynchronously. + * @param aData - May get changed on shutdown. + */ + write(aData) { + return SessionFileInternal.write(aData); + }, + /** + * Wipe the contents of the session file, asynchronously. + */ + wipe() { + return SessionFileInternal.wipe(); + }, + + /** + * Return the paths to the files used to store, backup, etc. + * the state of the file. + */ + get Paths() { + return SessionFileInternal.Paths; + }, +}; + +Object.freeze(SessionFile); + +const profileDir = PathUtils.profileDir; + +var SessionFileInternal = { + Paths: Object.freeze({ + // The path to the latest version of sessionstore written during a clean + // shutdown. After startup, it is renamed `cleanBackup`. + clean: PathUtils.join(profileDir, "sessionstore.jsonlz4"), + + // The path at which we store the previous version of `clean`. Updated + // whenever we successfully load from `clean`. + cleanBackup: PathUtils.join( + profileDir, + "sessionstore-backups", + "previous.jsonlz4" + ), + + // The directory containing all sessionstore backups. + backups: PathUtils.join(profileDir, "sessionstore-backups"), + + // The path to the latest version of the sessionstore written + // during runtime. Generally, this file contains more + // privacy-sensitive information than |clean|, and this file is + // therefore removed during clean shutdown. This file is designed to protect + // against crashes / sudden shutdown. + recovery: PathUtils.join( + profileDir, + "sessionstore-backups", + "recovery.jsonlz4" + ), + + // The path to the previous version of the sessionstore written + // during runtime (e.g. 15 seconds before recovery). In case of a + // clean shutdown, this file is removed. Generally, this file + // contains more privacy-sensitive information than |clean|, and + // this file is therefore removed during clean shutdown. This + // file is designed to protect against crashes that are nasty + // enough to corrupt |recovery|. + recoveryBackup: PathUtils.join( + profileDir, + "sessionstore-backups", + "recovery.baklz4" + ), + + // The path to a backup created during an upgrade of Firefox. + // Having this backup protects the user essentially from bugs in + // Firefox or add-ons, especially for users of Nightly. This file + // does not contain any information more sensitive than |clean|. + upgradeBackupPrefix: PathUtils.join( + profileDir, + "sessionstore-backups", + "upgrade.jsonlz4-" + ), + + // The path to the backup of the version of the session store used + // during the latest upgrade of Firefox. During load/recovery, + // this file should be used if both |path|, |backupPath| and + // |latestStartPath| are absent/incorrect. May be "" if no + // upgrade backup has ever been performed. This file does not + // contain any information more sensitive than |clean|. + get upgradeBackup() { + let latestBackupID = SessionFileInternal.latestUpgradeBackupID; + if (!latestBackupID) { + return ""; + } + return this.upgradeBackupPrefix + latestBackupID; + }, + + // The path to a backup created during an upgrade of Firefox. + // Having this backup protects the user essentially from bugs in + // Firefox, especially for users of Nightly. + get nextUpgradeBackup() { + return this.upgradeBackupPrefix + Services.appinfo.platformBuildID; + }, + + /** + * The order in which to search for a valid sessionstore file. + */ + get loadOrder() { + // If `clean` exists and has been written without corruption during + // the latest shutdown, we need to use it. + // + // Otherwise, `recovery` and `recoveryBackup` represent the most + // recent state of the session store. + // + // Finally, if nothing works, fall back to the last known state + // that can be loaded (`cleanBackup`) or, if available, to the + // backup performed during the latest upgrade. + let order = ["clean", "recovery", "recoveryBackup", "cleanBackup"]; + if (SessionFileInternal.latestUpgradeBackupID) { + // We have an upgradeBackup + order.push("upgradeBackup"); + } + return order; + }, + }), + + // Number of attempted calls to `write`. + // Note that we may have _attempts > _successes + _failures, + // if attempts never complete. + // Used for error reporting. + _attempts: 0, + + // Number of successful calls to `write`. + // Used for error reporting. + _successes: 0, + + // Number of failed calls to `write`. + // Used for error reporting. + _failures: 0, + + // `true` once we have initialized SessionWriter. + _initialized: false, + + // A string that will be set to the session file name part that was read from + // disk. It will be available _after_ a session file read() is done. + _readOrigin: null, + + // `true` if the old, uncompressed, file format was used to read from disk, as + // a fallback mechanism. + _usingOldExtension: false, + + // The ID of the latest version of Gecko for which we have an upgrade backup + // or |undefined| if no upgrade backup was ever written. + get latestUpgradeBackupID() { + try { + return Services.prefs.getCharPref(PREF_UPGRADE_BACKUP); + } catch (ex) { + return undefined; + } + }, + + async _readInternal(useOldExtension) { + let result; + let noFilesFound = true; + this._usingOldExtension = useOldExtension; + + // Attempt to load by order of priority from the various backups + for (let key of this.Paths.loadOrder) { + let corrupted = false; + let exists = true; + try { + let path; + let startMs = Date.now(); + + let options = {}; + if (useOldExtension) { + path = this.Paths[key] + .replace("jsonlz4", "js") + .replace("baklz4", "bak"); + } else { + path = this.Paths[key]; + options.decompress = true; + } + let source = await IOUtils.readUTF8(path, options); + let parsed = JSON.parse(source); + + if (parsed._cachedObjs) { + try { + let cacheMap = new Map(parsed._cachedObjs); + for (let win of parsed.windows.concat( + parsed._closedWindows || [] + )) { + for (let tab of win.tabs.concat(win._closedTabs || [])) { + tab.image = cacheMap.get(tab.image) || tab.image; + } + } + } catch (e) { + // This is temporary code to clean up after the backout of bug + // 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); + } + } + + if ( + !lazy.SessionStore.isFormatVersionCompatible( + parsed.version || [ + "sessionrestore", + 0, + ] /* fallback for old versions*/ + ) + ) { + // Skip sessionstore files that we don't understand. + console.error( + "Cannot extract data from Session Restore file ", + path, + ". Wrong format/version: " + JSON.stringify(parsed.version) + "." + ); + continue; + } + result = { + origin: key, + source, + parsed, + useOldExtension, + }; + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_CORRUPT_FILE") + .add(false); + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_READ_FILE_MS") + .add(Date.now() - startMs); + break; + } catch (ex) { + if (DOMException.isInstance(ex) && ex.name == "NotFoundError") { + exists = false; + } 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); + corrupted = true; + } else if (ex instanceof SyntaxError) { + console.error( + "Corrupt session file (invalid JSON found) ", + ex, + ex.stack + ); + // File is corrupted, try next file + corrupted = true; + } + } finally { + if (exists) { + noFilesFound = false; + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_CORRUPT_FILE") + .add(corrupted); + } + } + } + return { result, noFilesFound }; + }, + + // Find the correct session file and read it. + async read() { + // Load session files with lz4 compression. + let { result, noFilesFound } = await this._readInternal(false); + if (!result) { + // No result? Probably because of migration, let's + // load uncompressed session files. + let r = await this._readInternal(true); + result = r.result; + } + + // All files are corrupted if files found but none could deliver a result. + let allCorrupt = !noFilesFound && !result; + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_ALL_FILES_CORRUPT") + .add(allCorrupt); + + if (!result) { + // If everything fails, start with an empty session. + result = { + origin: "empty", + source: "", + parsed: null, + useOldExtension: false, + }; + } + this._readOrigin = result.origin; + + result.noFilesFound = noFilesFound; + + return result; + }, + + // Initialize SessionWriter and return it as a resolved promise. + getWriter() { + if (!this._initialized) { + if (!this._readOrigin) { + return Promise.reject( + "SessionFileInternal.getWriter() called too early! Please read the session file from disk first." + ); + } + + this._initialized = true; + lazy.SessionWriter.init( + this._readOrigin, + this._usingOldExtension, + this.Paths, + { + maxUpgradeBackups: Services.prefs.getIntPref( + PREF_MAX_UPGRADE_BACKUPS, + 3 + ), + maxSerializeBack: Services.prefs.getIntPref( + PREF_MAX_SERIALIZE_BACK, + 10 + ), + maxSerializeForward: Services.prefs.getIntPref( + PREF_MAX_SERIALIZE_FWD, + -1 + ), + } + ); + } + + return Promise.resolve(lazy.SessionWriter); + }, + + write(aData) { + if (lazy.RunState.isClosed) { + return Promise.reject(new Error("SessionFile is closed")); + } + + let isFinalWrite = false; + if (lazy.RunState.isClosing) { + // If shutdown has started, we will want to stop receiving + // write instructions. + isFinalWrite = true; + lazy.RunState.setClosed(); + } + + let performShutdownCleanup = + isFinalWrite && !lazy.SessionStore.willAutoRestore; + + this._attempts++; + let options = { isFinalWrite, performShutdownCleanup }; + let promise = this.getWriter().then(writer => writer.write(aData, options)); + + // Wait until the write is done. + promise = promise.then( + msg => { + // Record how long the write took. + this._recordTelemetry(msg.telemetry); + this._successes++; + if (msg.result.upgradeBackup) { + // We have just completed a backup-on-upgrade, store the information + // in preferences. + Services.prefs.setCharPref( + PREF_UPGRADE_BACKUP, + Services.appinfo.platformBuildID + ); + } + }, + err => { + // Catch and report any errors. + console.error("Could not write session state file ", err, err.stack); + this._failures++; + // By not doing anything special here we ensure that |promise| cannot + // be rejected anymore. The shutdown/cleanup code at the end of the + // function will thus always be executed. + } + ); + + // Ensure that we can write sessionstore.js cleanly before the profile + // becomes unaccessible. + IOUtils.profileBeforeChange.addBlocker( + "SessionFile: Finish writing Session Restore data", + promise, + { + fetchState: () => ({ + options, + attempts: this._attempts, + successes: this._successes, + failures: this._failures, + }), + } + ); + + // This code will always be executed because |promise| can't fail anymore. + // We ensured that by having a reject handler that reports the failure but + // doesn't forward the rejection. + return promise.then(() => { + // Remove the blocker, no matter if writing failed or not. + IOUtils.profileBeforeChange.removeBlocker(promise); + + if (isFinalWrite) { + Services.obs.notifyObservers( + null, + "sessionstore-final-state-write-complete" + ); + } + }); + }, + + async wipe() { + const writer = await this.getWriter(); + await writer.wipe(); + // After a wipe, we need to make sure to re-initialize upon the next read(), + // because the state variables as sent to the writer have changed. + this._initialized = false; + }, + + _recordTelemetry(telemetry) { + for (let id of Object.keys(telemetry)) { + let value = telemetry[id]; + let samples = []; + if (Array.isArray(value)) { + samples.push(...value); + } else { + samples.push(value); + } + let histogram = Services.telemetry.getHistogramById(id); + for (let sample of samples) { + histogram.add(sample); + } + } + }, +}; diff --git a/browser/components/sessionstore/SessionMigration.sys.mjs b/browser/components/sessionstore/SessionMigration.sys.mjs new file mode 100644 index 0000000000..7f2548890d --- /dev/null +++ b/browser/components/sessionstore/SessionMigration.sys.mjs @@ -0,0 +1,92 @@ +/* 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, { + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", +}); + +var SessionMigrationInternal = { + /** + * Convert the original session restore state into a minimal state. It will + * only contain: + * - open windows + * - with tabs + * - with history entries with only title, url, triggeringPrincipal + * - with pinned state + * - with tab group info (hidden + group id) + * - with selected tab info + * - with selected window info + * + * The complete state is then wrapped into the "about:welcomeback" page as + * form field info to be restored when restoring the state. + */ + convertState(aStateObj) { + let state = { + selectedWindow: aStateObj.selectedWindow, + _closedWindows: [], + }; + state.windows = aStateObj.windows.map(function (oldWin) { + var win = { extData: {} }; + win.tabs = oldWin.tabs.map(function (oldTab) { + var tab = {}; + // Keep only titles, urls and triggeringPrincipals for history entries + tab.entries = oldTab.entries.map(function (entry) { + return { + url: entry.url, + triggeringPrincipal_base64: entry.triggeringPrincipal_base64, + title: entry.title, + }; + }); + tab.index = oldTab.index; + tab.hidden = oldTab.hidden; + tab.pinned = oldTab.pinned; + return tab; + }); + win.selected = oldWin.selected; + win._closedTabs = []; + return win; + }); + let url = "about:welcomeback"; + let formdata = { id: { sessionData: state }, url }; + let entry = { + url, + triggeringPrincipal_base64: lazy.E10SUtils.SERIALIZED_SYSTEMPRINCIPAL, + }; + return { windows: [{ tabs: [{ entries: [entry], formdata }] }] }; + }, + /** + * Asynchronously read session restore state (JSON) from a path + */ + readState(aPath) { + return IOUtils.readJSON(aPath, { decompress: true }); + }, + /** + * Asynchronously write session restore state as JSON to a path + */ + writeState(aPath, aState) { + return IOUtils.writeJSON(aPath, aState, { + compress: true, + tmpPath: `${aPath}.tmp`, + }); + }, +}; + +export var SessionMigration = { + /** + * Migrate a limited set of session data from one path to another. + */ + migrate(aFromPath, aToPath) { + return (async function () { + let inState = await SessionMigrationInternal.readState(aFromPath); + let outState = SessionMigrationInternal.convertState(inState); + // Unfortunately, we can't use SessionStore's own SessionFile to + // write out the data because it has a dependency on the profile dir + // being known. When the migration runs, there is no guarantee that + // that's true. + await SessionMigrationInternal.writeState(aToPath, outState); + })(); + }, +}; diff --git a/browser/components/sessionstore/SessionSaver.sys.mjs b/browser/components/sessionstore/SessionSaver.sys.mjs new file mode 100644 index 0000000000..2f08bb2243 --- /dev/null +++ b/browser/components/sessionstore/SessionSaver.sys.mjs @@ -0,0 +1,405 @@ +/* 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 { + cancelIdleCallback, + clearTimeout, + requestIdleCallback, + setTimeout, +} from "resource://gre/modules/Timer.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PrivacyFilter: "resource://gre/modules/sessionstore/PrivacyFilter.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + RunState: "resource:///modules/sessionstore/RunState.sys.mjs", + SessionFile: "resource:///modules/sessionstore/SessionFile.sys.mjs", + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +/* + * Minimal interval between two save operations (in milliseconds). + * + * To save system resources, we generally do not save changes immediately when + * a change is detected. Rather, we wait a little to see if this change is + * followed by other changes, in which case only the last write is necessary. + * This delay is defined by "browser.sessionstore.interval". + * + * Furthermore, when the user is not actively using the computer, webpages + * may still perform changes that require (re)writing to sessionstore, e.g. + * updating Session Cookies or DOM Session Storage, or refreshing, etc. We + * expect that these changes are much less critical to the user and do not + * need to be saved as often. In such cases, we increase the delay to + * "browser.sessionstore.interval.idle". + * + * When the user returns to the computer, if a save is pending, we reschedule + * it to happen soon, with "browser.sessionstore.interval". + */ +const PREF_INTERVAL_ACTIVE = "browser.sessionstore.interval"; +const PREF_INTERVAL_IDLE = "browser.sessionstore.interval.idle"; +const PREF_IDLE_DELAY = "browser.sessionstore.idleDelay"; + +// Notify observers about a given topic with a given subject. +function notify(subject, topic) { + Services.obs.notifyObservers(subject, topic); +} + +// TelemetryStopwatch helper functions. +function stopWatch(method) { + return function (...histograms) { + for (let hist of histograms) { + TelemetryStopwatch[method]("FX_SESSION_RESTORE_" + hist); + } + }; +} + +var stopWatchStart = stopWatch("start"); +var stopWatchFinish = stopWatch("finish"); + +/** + * The external API implemented by the SessionSaver module. + */ +export var SessionSaver = Object.freeze({ + /** + * Immediately saves the current session to disk. + */ + run() { + return SessionSaverInternal.run(); + }, + + /** + * Saves the current session to disk delayed by a given amount of time. Should + * another delayed run be scheduled already, we will ignore the given delay + * and state saving may occur a little earlier. + */ + runDelayed() { + SessionSaverInternal.runDelayed(); + }, + + /** + * Sets the last save time to the current time. This will cause us to wait for + * at least the configured interval when runDelayed() is called next. + */ + updateLastSaveTime() { + SessionSaverInternal.updateLastSaveTime(); + }, + + /** + * Cancels all pending session saves. + */ + cancel() { + SessionSaverInternal.cancel(); + }, +}); + +/** + * The internal API. + */ +var SessionSaverInternal = { + /** + * The timeout ID referencing an active timer for a delayed save. When no + * save is pending, this is null. + */ + _timeoutID: null, + + /** + * The idle callback ID referencing an active idle callback. When no idle + * callback is pending, this is null. + * */ + _idleCallbackID: null, + + /** + * A timestamp that keeps track of when we saved the session last. We will + * this to determine the correct interval between delayed saves to not deceed + * the configured session write interval. + */ + _lastSaveTime: 0, + + /** + * `true` if the user has been idle for at least + * `SessionSaverInternal._intervalWhileIdle` ms. Idleness is computed + * with `nsIUserIdleService`. + */ + _isIdle: false, + + /** + * `true` if the user was idle when we last scheduled a delayed save. + * See `_isIdle` for details on idleness. + */ + _wasIdle: false, + + /** + * Minimal interval between two save operations (in ms), while the user + * is active. + */ + _intervalWhileActive: null, + + /** + * Minimal interval between two save operations (in ms), while the user + * is idle. + */ + _intervalWhileIdle: null, + + /** + * How long before we assume that the user is idle (ms). + */ + _idleDelay: null, + + /** + * Immediately saves the current session to disk. + */ + run() { + return this._saveState(true /* force-update all windows */); + }, + + /** + * Saves the current session to disk delayed by a given amount of time. Should + * another delayed run be scheduled already, we will ignore the given delay + * and state saving may occur a little earlier. + * + * @param delay (optional) + * The minimum delay in milliseconds to wait for until we collect and + * save the current session. + */ + runDelayed(delay = 2000) { + // Bail out if there's a pending run. + if (this._timeoutID) { + return; + } + + // Interval until the next disk operation is allowed. + let interval = this._isIdle + ? this._intervalWhileIdle + : this._intervalWhileActive; + delay = Math.max(this._lastSaveTime + interval - Date.now(), delay, 0); + + // Schedule a state save. + this._wasIdle = this._isIdle; + this._timeoutID = setTimeout(() => { + // Execute _saveStateAsync when we have idle time. + let saveStateAsyncWhenIdle = () => { + this._saveStateAsync(); + }; + + this._idleCallbackID = requestIdleCallback(saveStateAsyncWhenIdle); + }, delay); + }, + + /** + * Sets the last save time to the current time. This will cause us to wait for + * at least the configured interval when runDelayed() is called next. + */ + updateLastSaveTime() { + this._lastSaveTime = Date.now(); + }, + + /** + * Cancels all pending session saves. + */ + cancel() { + clearTimeout(this._timeoutID); + this._timeoutID = null; + cancelIdleCallback(this._idleCallbackID); + this._idleCallbackID = null; + }, + + /** + * Observe idle/ active notifications. + */ + observe(subject, topic, data) { + switch (topic) { + case "idle": + this._isIdle = true; + break; + case "active": + this._isIdle = false; + if (this._timeoutID && this._wasIdle) { + // A state save has been scheduled while we were idle. + // Replace it by an active save. + clearTimeout(this._timeoutID); + this._timeoutID = null; + this.runDelayed(); + } + break; + default: + throw new Error(`Unexpected change value ${topic}`); + } + }, + + /** + * Saves the current session state. Collects data and writes to disk. + * + * @param forceUpdateAllWindows (optional) + * Forces us to recollect data for all windows and will bypass and + * update the corresponding caches. + */ + _saveState(forceUpdateAllWindows = false) { + // Cancel any pending timeouts. + this.cancel(); + + if (lazy.PrivateBrowsingUtils.permanentPrivateBrowsing) { + // Don't save (or even collect) anything in permanent private + // browsing mode + + this.updateLastSaveTime(); + return Promise.resolve(); + } + + stopWatchStart("COLLECT_DATA_MS"); + let state = lazy.SessionStore.getCurrentState(forceUpdateAllWindows); + lazy.PrivacyFilter.filterPrivateWindowsAndTabs(state); + + // Make sure we only write worth saving tabs to disk. + lazy.SessionStore.keepOnlyWorthSavingTabs(state); + + // Make sure that we keep the previous session if we started with a single + // private window and no non-private windows have been opened, yet. + if (state.deferredInitialState) { + state.windows = state.deferredInitialState.windows || []; + delete state.deferredInitialState; + } + + if (AppConstants.platform != "macosx") { + // We want to restore closed windows that are marked with _shouldRestore. + // We're doing this here because we want to control this only when saving + // the file. + while (state._closedWindows.length) { + let i = state._closedWindows.length - 1; + + if (!state._closedWindows[i]._shouldRestore) { + // We only need to go until _shouldRestore + // is falsy since we're going in reverse. + break; + } + + delete state._closedWindows[i]._shouldRestore; + state.windows.unshift(state._closedWindows.pop()); + } + } + + // Clear cookies and storage on clean shutdown. + this._maybeClearCookiesAndStorage(state); + + stopWatchFinish("COLLECT_DATA_MS"); + return this._writeState(state); + }, + + /** + * Purges cookies and DOMSessionStorage data from the session on clean + * shutdown, only if requested by the user's preferences. + */ + _maybeClearCookiesAndStorage(state) { + // Only do this on shutdown. + if (!lazy.RunState.isClosing) { + return; + } + + // Don't clear when restarting. + if ( + Services.prefs.getBoolPref("browser.sessionstore.resume_session_once") + ) { + return; + } + let sanitizeCookies = + Services.prefs.getBoolPref("privacy.sanitize.sanitizeOnShutdown") && + Services.prefs.getBoolPref("privacy.clearOnShutdown.cookies"); + + if (sanitizeCookies) { + // Remove cookies. + delete state.cookies; + + // Remove DOMSessionStorage data. + for (let window of state.windows) { + for (let tab of window.tabs) { + delete tab.storage; + } + } + } + }, + + /** + * Saves the current session state. Collects data asynchronously and calls + * _saveState() to collect data again (with a cache hit rate of hopefully + * 100%) and write to disk afterwards. + */ + _saveStateAsync() { + // Allow scheduling delayed saves again. + this._timeoutID = null; + + // Write to disk. + this._saveState(); + }, + + /** + * Write the given state object to disk. + */ + _writeState(state) { + // We update the time stamp before writing so that we don't write again + // too soon, if saving is requested before the write completes. Without + // this update we may save repeatedly if actions cause a runDelayed + // before writing has completed. See Bug 902280 + this.updateLastSaveTime(); + + // Write (atomically) to a session file, using a tmp file. Once the session + // file is successfully updated, save the time stamp of the last save and + // notify the observers. + return lazy.SessionFile.write(state).then(() => { + this.updateLastSaveTime(); + notify(null, "sessionstore-state-write-complete"); + }, console.error); + }, +}; + +XPCOMUtils.defineLazyPreferenceGetter( + SessionSaverInternal, + "_intervalWhileActive", + PREF_INTERVAL_ACTIVE, + 15000 /* 15 seconds */, + () => { + // Cancel any pending runs and call runDelayed() with + // zero to apply the newly configured interval. + SessionSaverInternal.cancel(); + SessionSaverInternal.runDelayed(0); + } +); + +XPCOMUtils.defineLazyPreferenceGetter( + SessionSaverInternal, + "_intervalWhileIdle", + PREF_INTERVAL_IDLE, + 3600000 /* 1 h */ +); + +XPCOMUtils.defineLazyPreferenceGetter( + SessionSaverInternal, + "_idleDelay", + PREF_IDLE_DELAY, + 180 /* 3 minutes */, + (key, previous, latest) => { + // Update the idle observer for the new `PREF_IDLE_DELAY` value. Here we need + // to re-fetch the service instead of the original one in use; This is for a + // case that the Mock service in the unit test needs to be fetched to + // replace the original one. + var idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService( + Ci.nsIUserIdleService + ); + if (previous != undefined) { + idleService.removeIdleObserver(SessionSaverInternal, previous); + } + if (latest != undefined) { + idleService.addIdleObserver(SessionSaverInternal, latest); + } + } +); + +var idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService( + Ci.nsIUserIdleService +); +idleService.addIdleObserver( + SessionSaverInternal, + SessionSaverInternal._idleDelay +); diff --git a/browser/components/sessionstore/SessionStartup.sys.mjs b/browser/components/sessionstore/SessionStartup.sys.mjs new file mode 100644 index 0000000000..ff3ba55176 --- /dev/null +++ b/browser/components/sessionstore/SessionStartup.sys.mjs @@ -0,0 +1,419 @@ +/* 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/. */ + +/** + * Session Storage and Restoration + * + * Overview + * This service reads user's session file at startup, and makes a determination + * as to whether the session should be restored. It will restore the session + * under the circumstances described below. If the auto-start Private Browsing + * mode is active, however, the session is never restored. + * + * Crash Detection + * The CrashMonitor is used to check if the final session state was successfully + * written at shutdown of the last session. If we did not reach + * 'sessionstore-final-state-write-complete', then it's assumed that the browser + * has previously crashed and we should restore the session. + * + * Forced Restarts + * In the event that a restart is required due to application update or extension + * installation, set the browser.sessionstore.resume_session_once pref to true, + * and the session will be restored the next time the browser starts. + * + * Always Resume + * This service will always resume the session if the integer pref + * browser.startup.page is set to 3. + */ + +/* :::::::: Constants and Helpers ::::::::::::::: */ + +const lazy = {}; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +ChromeUtils.defineESModuleGetters(lazy, { + CrashMonitor: "resource://gre/modules/CrashMonitor.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SessionFile: "resource:///modules/sessionstore/SessionFile.sys.mjs", + StartupPerformance: + "resource:///modules/sessionstore/StartupPerformance.sys.mjs", +}); + +const STATE_RUNNING_STR = "running"; + +const TYPE_NO_SESSION = 0; +const TYPE_RECOVER_SESSION = 1; +const TYPE_RESUME_SESSION = 2; +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; +})(); + +/* :::::::: The Service ::::::::::::::: */ + +export var SessionStartup = { + NO_SESSION: TYPE_NO_SESSION, + RECOVER_SESSION: TYPE_RECOVER_SESSION, + RESUME_SESSION: TYPE_RESUME_SESSION, + DEFER_SESSION: TYPE_DEFER_SESSION, + + // The state to restore at startup. + _initialState: null, + _sessionType: null, + _initialized: false, + + // Stores whether the previous session crashed. + _previousSessionCrashed: null, + + _resumeSessionEnabled: null, + + /* ........ Global Event Handlers .............. */ + + /** + * Initialize the component + */ + init() { + Services.obs.notifyObservers(null, "sessionstore-init-started"); + + if (!AppConstants.DEBUG) { + lazy.StartupPerformance.init(); + } + + // do not need to initialize anything in auto-started private browsing sessions + if (lazy.PrivateBrowsingUtils.permanentPrivateBrowsing) { + this._initialized = true; + gOnceInitializedDeferred.resolve(); + return; + } + + if ( + Services.prefs.getBoolPref( + "browser.sessionstore.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 + // would have been set). Therefore we should clear resume_session_once + // to avoid forcing a resume for a normal startup. + Services.prefs.setBoolPref( + "browser.sessionstore.resume_session_once", + false + ); + } + Services.prefs.setBoolPref( + "browser.sessionstore.resuming_after_os_restart", + false + ); + } + + lazy.SessionFile.read().then( + this._onSessionFileRead.bind(this), + console.error + ); + }, + + // Wrap a string as a nsISupports. + _createSupportsString(data) { + let string = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + string.data = data; + return string; + }, + + /** + * Complete initialization once the Session File has been read. + * + * @param source The Session State string read from disk. + * @param parsed The object obtained by parsing |source| as JSON. + */ + _onSessionFileRead({ source, parsed, noFilesFound }) { + this._initialized = true; + + // Let observers modify the state before it is used + let supportsStateString = this._createSupportsString(source); + Services.obs.notifyObservers( + supportsStateString, + "sessionstore-state-read" + ); + let stateString = supportsStateString.data; + + if (stateString != source) { + // The session has been modified by an add-on, reparse. + 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); + } + } else { + // No need to reparse + this._initialState = parsed; + } + + if (this._initialState == null) { + // No valid session found. + this._sessionType = this.NO_SESSION; + Services.obs.notifyObservers(null, "sessionstore-state-finalized"); + gOnceInitializedDeferred.resolve(); + return; + } + + let initialState = this._initialState; + Services.tm.idleDispatchToMainThread(() => { + let pinnedTabCount = initialState.windows.reduce((winAcc, win) => { + return ( + winAcc + + win.tabs.reduce((tabAcc, tab) => { + return tabAcc + (tab.pinned ? 1 : 0); + }, 0) + ); + }, 0); + Services.telemetry.scalarSetMaximum( + "browser.engagement.max_concurrent_tab_pinned_count", + pinnedTabCount + ); + }, 60000); + + // If this is a normal restore then throw away any previous session. + if (!this.isAutomaticRestoreEnabled() && this._initialState) { + delete this._initialState.lastSessionState; + } + + lazy.CrashMonitor.previousCheckpoints.then(checkpoints => { + if (checkpoints) { + // If the previous session finished writing the final state, we'll + // assume there was no crash. + this._previousSessionCrashed = + !checkpoints["sessionstore-final-state-write-complete"]; + } else if (noFilesFound) { + // If the Crash Monitor could not load a checkpoints file it will + // provide null. This could occur on the first run after updating to + // a version including the Crash Monitor, or if the checkpoints file + // was removed, or on first startup with this profile, or after Firefox Reset. + + // There was no checkpoints file and no sessionstore.js or its backups, + // so we will assume that this was a fresh profile. + this._previousSessionCrashed = false; + } else { + // If this is the first run after an update, sessionstore.js should + // still contain the session.state flag to indicate if the session + // crashed. If it is not present, we will assume this was not the first + // run after update and the checkpoints file was somehow corrupted or + // removed by a crash. + // + // If the session.state flag is present, we will fallback to using it + // for crash detection - If the last write of sessionstore.js had it + // set to "running", we crashed. + let stateFlagPresent = + this._initialState.session && this._initialState.session.state; + + this._previousSessionCrashed = + !stateFlagPresent || + this._initialState.session.state == STATE_RUNNING_STR; + } + + // Report shutdown success via telemetry. Shortcoming here are + // being-killed-by-OS-shutdown-logic, shutdown freezing after + // session restore was written, etc. + Services.telemetry + .getHistogramById("SHUTDOWN_OK") + .add(!this._previousSessionCrashed); + + Services.obs.addObserver(this, "sessionstore-windows-restored", true); + + if (this.sessionType == this.NO_SESSION) { + this._initialState = null; // Reset the state. + } else { + Services.obs.addObserver(this, "browser:purge-session-history", true); + } + + // We're ready. Notify everyone else. + Services.obs.notifyObservers(null, "sessionstore-state-finalized"); + + gOnceInitializedDeferred.resolve(); + }); + }, + + /** + * Handle notifications + */ + observe(subject, topic, data) { + switch (topic) { + case "sessionstore-windows-restored": + Services.obs.removeObserver(this, "sessionstore-windows-restored"); + // Free _initialState after nsSessionStore is done with it. + this._initialState = null; + this._didRestore = true; + break; + case "browser:purge-session-history": + Services.obs.removeObserver(this, "browser:purge-session-history"); + // Reset all state on sanitization. + this._sessionType = this.NO_SESSION; + break; + } + }, + + /* ........ Public API ................*/ + + get onceInitialized() { + return gOnceInitializedDeferred.promise; + }, + + /** + * Get the session state as a jsval + */ + get state() { + return this._initialState; + }, + + /** + * Determines whether automatic session restoration is enabled for this + * launch of the browser. This does not include crash restoration. In + * particular, if session restore is configured to restore only in case of + * crash, this method returns false. + * @returns bool + */ + isAutomaticRestoreEnabled() { + if (this._resumeSessionEnabled === null) { + this._resumeSessionEnabled = + !lazy.PrivateBrowsingUtils.permanentPrivateBrowsing && + (Services.prefs.getBoolPref( + "browser.sessionstore.resume_session_once" + ) || + Services.prefs.getIntPref("browser.startup.page") == + BROWSER_STARTUP_RESUME_SESSION); + } + + return this._resumeSessionEnabled; + }, + + /** + * Determines whether there is a pending session restore. + * @returns bool + */ + willRestore() { + return ( + this.sessionType == this.RECOVER_SESSION || + this.sessionType == this.RESUME_SESSION + ); + }, + + /** + * Determines whether there is a pending session restore and if that will refer + * back to a crash. + * @returns bool + */ + willRestoreAsCrashed() { + return this.sessionType == this.RECOVER_SESSION; + }, + + /** + * Returns a boolean or a promise that resolves to a boolean, indicating + * whether we will restore a session that ends up replacing the homepage. + * True guarantees that we'll restore a session; false means that we + * /probably/ won't do so. + * The browser uses this to avoid unnecessarily loading the homepage when + * restoring a session. + */ + get willOverrideHomepage() { + // If the session file hasn't been read yet and resuming the session isn't + // enabled via prefs, go ahead and load the homepage. We may still replace + // it when recovering from a crash, which we'll only know after reading the + // session file, but waiting for that would delay loading the homepage in + // the non-crash case. + if (!this._initialState && !this.isAutomaticRestoreEnabled()) { + return false; + } + // If we've already restored the session, we won't override again. + if (this._didRestore) { + return false; + } + + return new Promise(resolve => { + this.onceInitialized.then(() => { + // If there are valid windows with not only pinned tabs, signal that we + // will override the default homepage by restoring a session. + resolve( + this.willRestore() && + this._initialState && + this._initialState.windows && + (!this.willRestoreAsCrashed() + ? this._initialState.windows.filter(w => !w._maybeDontRestoreTabs) + : this._initialState.windows + ).some(w => w.tabs.some(t => !t.pinned)) + ); + }); + }); + }, + + /** + * Get the type of pending session store, if any. + */ + get sessionType() { + if (this._sessionType === null) { + let resumeFromCrash = Services.prefs.getBoolPref( + "browser.sessionstore.resume_from_crash" + ); + // Set the startup type. + if (this.isAutomaticRestoreEnabled()) { + this._sessionType = this.RESUME_SESSION; + } else if (this._previousSessionCrashed && resumeFromCrash) { + this._sessionType = this.RECOVER_SESSION; + } else if (this._initialState) { + this._sessionType = this.DEFER_SESSION; + } else { + this._sessionType = this.NO_SESSION; + } + } + + return this._sessionType; + }, + + /** + * Get whether the previous session crashed. + */ + get previousSessionCrashed() { + return this._previousSessionCrashed; + }, + + resetForTest() { + this._resumeSessionEnabled = null; + this._sessionType = null; + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), +}; diff --git a/browser/components/sessionstore/SessionStore.sys.mjs b/browser/components/sessionstore/SessionStore.sys.mjs new file mode 100644 index 0000000000..f269251f54 --- /dev/null +++ b/browser/components/sessionstore/SessionStore.sys.mjs @@ -0,0 +1,7727 @@ +/* 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/. */ + +// Current version of the format used by Session Restore. +const FORMAT_VERSION = 1; + +const PERSIST_SESSIONS = Services.prefs.getBoolPref( + "browser.sessionstore.persist_closed_tabs_between_sessions" +); +const TAB_CUSTOM_VALUES = new WeakMap(); +const TAB_LAZY_STATES = new WeakMap(); +const TAB_STATE_NEEDS_RESTORE = 1; +const TAB_STATE_RESTORING = 2; +const TAB_STATE_FOR_BROWSER = new WeakMap(); +const WINDOW_RESTORE_IDS = new WeakMap(); +const WINDOW_RESTORE_ZINDICES = new WeakMap(); +const WINDOW_SHOWING_PROMISES = new Map(); +const WINDOW_FLUSHING_PROMISES = new Map(); + +// A new window has just been restored. At this stage, tabs are generally +// not restored. +const NOTIFY_SINGLE_WINDOW_RESTORED = "sessionstore-single-window-restored"; +const NOTIFY_WINDOWS_RESTORED = "sessionstore-windows-restored"; +const NOTIFY_BROWSER_STATE_RESTORED = "sessionstore-browser-state-restored"; +const NOTIFY_LAST_SESSION_CLEARED = "sessionstore-last-session-cleared"; +const NOTIFY_RESTORING_ON_STARTUP = "sessionstore-restoring-on-startup"; +const NOTIFY_INITIATING_MANUAL_RESTORE = + "sessionstore-initiating-manual-restore"; +const NOTIFY_CLOSED_OBJECTS_CHANGED = "sessionstore-closed-objects-changed"; + +const NOTIFY_TAB_RESTORED = "sessionstore-debug-tab-restored"; // WARNING: debug-only +const NOTIFY_DOMWINDOWCLOSED_HANDLED = + "sessionstore-debug-domwindowclosed-handled"; // WARNING: debug-only + +const NOTIFY_BROWSER_SHUTDOWN_FLUSH = "sessionstore-browser-shutdown-flush"; + +// Maximum number of tabs to restore simultaneously. Previously controlled by +// the browser.sessionstore.max_concurrent_tabs pref. +const MAX_CONCURRENT_TAB_RESTORES = 3; + +// Minimum amount (in CSS px) by which we allow window edges to be off-screen +// when restoring a window, before we override the saved position to pull the +// window back within the available screen area. +const MIN_SCREEN_EDGE_SLOP = 8; + +// global notifications observed +const OBSERVING = [ + "browser-window-before-show", + "domwindowclosed", + "quit-application-granted", + "browser-lastwindow-close-granted", + "quit-application", + "browser:purge-session-history", + "browser:purge-session-history-for-domain", + "idle-daily", + "clear-origin-attributes-data", + "browsing-context-did-set-embedder", + "browsing-context-discarded", + "browser-shutdown-tabstate-updated", +]; + +// XUL Window properties to (re)store +// Restored in restoreDimensions() +const WINDOW_ATTRIBUTES = ["width", "height", "screenX", "screenY", "sizemode"]; + +const CHROME_FLAGS_MAP = [ + [Ci.nsIWebBrowserChrome.CHROME_TITLEBAR, "titlebar"], + [Ci.nsIWebBrowserChrome.CHROME_WINDOW_CLOSE, "close"], + [Ci.nsIWebBrowserChrome.CHROME_TOOLBAR, "toolbar"], + [Ci.nsIWebBrowserChrome.CHROME_LOCATIONBAR, "location"], + [Ci.nsIWebBrowserChrome.CHROME_PERSONAL_TOOLBAR, "personalbar"], + [Ci.nsIWebBrowserChrome.CHROME_STATUSBAR, "status"], + [Ci.nsIWebBrowserChrome.CHROME_MENUBAR, "menubar"], + [Ci.nsIWebBrowserChrome.CHROME_WINDOW_RESIZE, "resizable"], + [Ci.nsIWebBrowserChrome.CHROME_WINDOW_MINIMIZE, "minimizable"], + [Ci.nsIWebBrowserChrome.CHROME_SCROLLBARS, "", "scrollbars=0"], + [Ci.nsIWebBrowserChrome.CHROME_PRIVATE_WINDOW, "private"], + [Ci.nsIWebBrowserChrome.CHROME_NON_PRIVATE_WINDOW, "non-private"], + // Do not inherit remoteness and fissionness from the previous session. + //[Ci.nsIWebBrowserChrome.CHROME_REMOTE_WINDOW, "remote", "non-remote"], + //[Ci.nsIWebBrowserChrome.CHROME_FISSION_WINDOW, "fission", "non-fission"], + [Ci.nsIWebBrowserChrome.CHROME_WINDOW_LOWERED, "alwayslowered"], + [Ci.nsIWebBrowserChrome.CHROME_WINDOW_RAISED, "alwaysraised"], + // "chrome" and "suppressanimation" are always set. + //[Ci.nsIWebBrowserChrome.CHROME_SUPPRESS_ANIMATION, "suppressanimation"], + [Ci.nsIWebBrowserChrome.CHROME_ALWAYS_ON_TOP, "alwaysontop"], + //[Ci.nsIWebBrowserChrome.CHROME_OPENAS_CHROME, "chrome", "chrome=0"], + [Ci.nsIWebBrowserChrome.CHROME_EXTRA, "extrachrome"], + [Ci.nsIWebBrowserChrome.CHROME_CENTER_SCREEN, "centerscreen"], + [Ci.nsIWebBrowserChrome.CHROME_DEPENDENT, "dependent"], + [Ci.nsIWebBrowserChrome.CHROME_MODAL, "modal"], + [Ci.nsIWebBrowserChrome.CHROME_OPENAS_DIALOG, "dialog", "dialog=0"], +]; + +// Hideable window features to (re)store +// Restored in restoreWindowFeatures() +const WINDOW_HIDEABLE_FEATURES = [ + "menubar", + "toolbar", + "locationbar", + "personalbar", + "statusbar", + "scrollbars", +]; + +const WINDOW_OPEN_FEATURES_MAP = { + locationbar: "location", + statusbar: "status", +}; + +// Messages that will be received via the Frame Message Manager. +const MESSAGES = [ + // The content script sends us data that has been invalidated and needs to + // be saved to disk. + "SessionStore:update", + + // The restoreHistory code has run. This is a good time to run SSTabRestoring. + "SessionStore:restoreHistoryComplete", + + // The load for the restoring tab has begun. We update the URL bar at this + // time; if we did it before, the load would overwrite it. + "SessionStore:restoreTabContentStarted", + + // All network loads for a restoring tab are done, so we should + // consider restoring another tab in the queue. The document has + // been restored, and forms have been filled. We trigger + // SSTabRestored at this time. + "SessionStore:restoreTabContentComplete", + + // The content script encountered an error. + "SessionStore:error", +]; + +// The list of messages we accept from s that have no tab +// assigned, or whose windows have gone away. Those are for example the +// ones that preload about:newtab pages, or from browsers where the window +// has just been closed. +const NOTAB_MESSAGES = new Set([ + // For a description see above. + "SessionStore:update", + + // For a description see above. + "SessionStore:error", +]); + +// The list of messages we accept without an "epoch" parameter. +// See getCurrentEpoch() and friends to find out what an "epoch" is. +const NOEPOCH_MESSAGES = new Set([ + // For a description see above. + "SessionStore:error", +]); + +// The list of messages we want to receive even during the short period after a +// frame has been removed from the DOM and before its frame script has finished +// unloading. +const CLOSED_MESSAGES = new Set([ + // For a description see above. + "SessionStore:update", + + // For a description see above. + "SessionStore:error", +]); + +// These are tab events that we listen to. +const TAB_EVENTS = [ + "TabOpen", + "TabBrowserInserted", + "TabClose", + "TabSelect", + "TabShow", + "TabHide", + "TabPinned", + "TabUnpinned", +]; + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +/** + * When calling restoreTabContent, we can supply a reason why + * the content is being restored. These are those reasons. + */ +const RESTORE_TAB_CONTENT_REASON = { + /** + * SET_STATE: + * We're restoring this tab's content because we're setting + * state inside this browser tab, probably because the user + * has asked us to restore a tab (or window, or entire session). + */ + SET_STATE: 0, + /** + * NAVIGATE_AND_RESTORE: + * We're restoring this tab's content because a navigation caused + * us to do a remoteness-flip. + */ + NAVIGATE_AND_RESTORE: 1, +}; + +// 'browser.startup.page' preference value to resume the previous session. +const BROWSER_STARTUP_RESUME_SESSION = 3; + +// Used by SessionHistoryListener. +const kNoIndex = Number.MAX_SAFE_INTEGER; +const kLastIndex = Number.MAX_SAFE_INTEGER - 1; + +import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"; + +import { TelemetryTimestamps } from "resource://gre/modules/TelemetryTimestamps.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { GlobalState } from "resource:///modules/sessionstore/GlobalState.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetters(lazy, { + gScreenManager: ["@mozilla.org/gfx/screenmanager;1", "nsIScreenManager"], +}); + +ChromeUtils.defineESModuleGetters(lazy, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", + DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs", + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", + HomePage: "resource:///modules/HomePage.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", + TabAttributes: "resource:///modules/sessionstore/TabAttributes.sys.mjs", + TabCrashHandler: "resource:///modules/ContentCrashHandlers.sys.mjs", + TabState: "resource:///modules/sessionstore/TabState.sys.mjs", + TabStateCache: "resource:///modules/sessionstore/TabStateCache.sys.mjs", + TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "blankURI", () => { + return Services.io.newURI("about:blank"); +}); + +/** + * |true| if we are in debug mode, |false| otherwise. + * Debug mode is controlled by preference browser.sessionstore.debug + */ +var gDebuggingEnabled = false; + +/** + * A global value to tell that fingerprinting resistance is enabled or not. + * If it's enabled, the session restore won't restore the window's size and + * size mode. + * This value is controlled by preference privacy.resistFingerprinting. + */ +var gResistFingerprintingEnabled = false; + +/** + * @namespace SessionStore + */ +export var SessionStore = { + get promiseInitialized() { + return SessionStoreInternal.promiseInitialized; + }, + + get promiseAllWindowsRestored() { + return SessionStoreInternal.promiseAllWindowsRestored; + }, + + get canRestoreLastSession() { + return SessionStoreInternal.canRestoreLastSession; + }, + + set canRestoreLastSession(val) { + SessionStoreInternal.canRestoreLastSession = val; + }, + + get lastClosedObjectType() { + return SessionStoreInternal.lastClosedObjectType; + }, + + get lastClosedActions() { + return [...SessionStoreInternal._lastClosedActions]; + }, + + get LAST_ACTION_CLOSED_TAB() { + return SessionStoreInternal._LAST_ACTION_CLOSED_TAB; + }, + + get LAST_ACTION_CLOSED_WINDOW() { + return SessionStoreInternal._LAST_ACTION_CLOSED_WINDOW; + }, + + get willAutoRestore() { + return SessionStoreInternal.willAutoRestore; + }, + + init: function ss_init() { + SessionStoreInternal.init(); + }, + + /** + * Get the collection of all matching windows tracked by SessionStore + * @param {Window|Object} [aWindowOrOptions] Optionally an options object or a window to used to determine if we're filtering for private or non-private windows + * @param {boolean} [aWindowOrOptions.private] Determine if we should filter for private or non-private windows + */ + getWindows(aWindowOrOptions) { + return SessionStoreInternal.getWindows(aWindowOrOptions); + }, + + /** + * Get window a given closed tab belongs to + * @param {integer} aClosedId The closedId of the tab whose window we want to find + * @param {boolean} [aIncludePrivate] Optionally include private windows when searching for the closed tab + */ + getWindowForTabClosedId(aClosedId, aIncludePrivate) { + return SessionStoreInternal.getWindowForTabClosedId( + aClosedId, + aIncludePrivate + ); + }, + + getBrowserState: function ss_getBrowserState() { + return SessionStoreInternal.getBrowserState(); + }, + + setBrowserState: function ss_setBrowserState(aState) { + SessionStoreInternal.setBrowserState(aState); + }, + + getWindowState: function ss_getWindowState(aWindow) { + return SessionStoreInternal.getWindowState(aWindow); + }, + + setWindowState: function ss_setWindowState(aWindow, aState, aOverwrite) { + SessionStoreInternal.setWindowState(aWindow, aState, aOverwrite); + }, + + getTabState: function ss_getTabState(aTab) { + return SessionStoreInternal.getTabState(aTab); + }, + + setTabState: function ss_setTabState(aTab, aState) { + SessionStoreInternal.setTabState(aTab, aState); + }, + + // Return whether a tab is restoring. + isTabRestoring(aTab) { + return TAB_STATE_FOR_BROWSER.has(aTab.linkedBrowser); + }, + + getInternalObjectState(obj) { + return SessionStoreInternal.getInternalObjectState(obj); + }, + + duplicateTab: function ss_duplicateTab( + aWindow, + aTab, + aDelta = 0, + aRestoreImmediately = true, + aOptions = {} + ) { + return SessionStoreInternal.duplicateTab( + aWindow, + aTab, + aDelta, + aRestoreImmediately, + aOptions + ); + }, + + /** + * How many tabs were last closed. If multiple tabs were selected and closed together, + * we'll return that number. Normally the count is 1, or 0 if no tabs have been + * recently closed in this window. + * @returns the number of tabs that were last closed. + */ + getLastClosedTabCount(aWindow) { + return SessionStoreInternal.getLastClosedTabCount(aWindow); + }, + + resetLastClosedTabCount(aWindow) { + SessionStoreInternal.resetLastClosedTabCount(aWindow); + }, + + /** + * Get the number of closed tabs associated with a specific window + * @param {Window} aWindow + */ + getClosedTabCountForWindow: function ss_getClosedTabCountForWindow(aWindow) { + return SessionStoreInternal.getClosedTabCountForWindow(aWindow); + }, + + /** + * Get the number of closed tabs associated with all matching windows + * @param {Window|Object} [aOptions] + * Either a DOMWindow (see aOptions.sourceWindow) or an object with properties + to identify which closed tabs to include in the count. + * @param {Window} aOptions.sourceWindow + A browser window used to identity privateness. + When closedTabsFromAllWindows is false, we only count closed tabs assocated with this window. + * @param {boolean} [aOptions.private = false] + Explicit indicator to constrain tab count to only private or non-private windows, + * @param {boolean} [aOptions.closedTabsFromAllWindows] + Override the value of the closedTabsFromAllWindows preference. + * @param {boolean} [aOptions.closedTabsFromClosedWindows] + Override the value of the closedTabsFromClosedWindows preference. + */ + getClosedTabCount: function ss_getClosedTabCount(aOptions) { + return SessionStoreInternal.getClosedTabCount(aOptions); + }, + + /** + * Get the number of closed tabs from recently closed window + * + * This is normally only relevant in a non-private window context, as we don't + * keep data from closed private windows. + */ + getClosedTabCountFromClosedWindows: + function ss_getClosedTabCountFromClosedWindows() { + return SessionStoreInternal.getClosedTabCountFromClosedWindows(); + }, + + /** + * Get the closed tab data associated with this window + * @param {Window} aWindow + */ + getClosedTabDataForWindow: function ss_getClosedTabDataForWindow(aWindow) { + return SessionStoreInternal.getClosedTabDataForWindow(aWindow); + }, + + /** + * Get the closed tab data associated with all matching windows + * @param {Window|Object} [aOptions] + * Either a DOMWindow (see aOptions.sourceWindow) or an object with properties + to identify which closed tabs to get data from + * @param {Window} aOptions.sourceWindow + A browser window used to identity privateness. + When closedTabsFromAllWindows is false, we only include closed tabs assocated with this window. + * @param {boolean} [aOptions.private = false] + Explicit indicator to constrain tab data to only private or non-private windows, + * @param {boolean} [aOptions.closedTabsFromAllWindows] + Override the value of the closedTabsFromAllWindows preference. + * @param {boolean} [aOptions.closedTabsFromClosedWindows] + Override the value of the closedTabsFromClosedWindows preference. + */ + getClosedTabData: function ss_getClosedTabData(aOptions) { + return SessionStoreInternal.getClosedTabData(aOptions); + }, + + /** + * Get the closed tab data associated with all closed windows + * @returns an un-sorted array of tabData for closed tabs from closed windows + */ + getClosedTabDataFromClosedWindows: + function ss_getClosedTabDataFromClosedWindows() { + return SessionStoreInternal.getClosedTabDataFromClosedWindows(); + }, + + /** + * Re-open a closed tab + * @param {Window|Object} aSource + * Either a DOMWindow or an object with properties to resolve to the window + * the tab was previously open in. + * @param {String} aSource.sourceWindowId + A SessionStore window id used to look up the window where the tab was closed + * @param {number} aSource.sourceClosedId + The closedId used to look up the closed window where the tab was closed + * @param {Integer} [aIndex = 0] + * The index of the tab in the closedTabs array (via SessionStore.getClosedTabData), where 0 is most recent. + * @param {Window} [aTargetWindow = aWindow] Optional window to open the tab into, defaults to current (topWindow). + * @returns a reference to the reopened tab. + */ + undoCloseTab: function ss_undoCloseTab(aSource, aIndex, aTargetWindow) { + return SessionStoreInternal.undoCloseTab(aSource, aIndex, aTargetWindow); + }, + + /** + * Re-open a tab from a closed window, which corresponds to the closedId + * @param {Window|Object} aSource + * Either a DOMWindow or an object with properties to resolve to the window + * the tab was previously open in. + * @param {String} aSource.sourceWindowId + A SessionStore window id used to look up the window where the tab was closed + * @param {number} aSource.sourceClosedId + The closedId used to look up the closed window where the tab was closed + * @param {integer} aClosedId + * The closedId of the tab or window + * @param {Window} [aTargetWindow = aWindow] Optional window to open the tab into, defaults to current (topWindow). + * @returns a reference to the reopened tab. + */ + undoClosedTabFromClosedWindow: function ss_undoClosedTabFromClosedWindow( + aSource, + aClosedId, + aTargetWindow + ) { + return SessionStoreInternal.undoClosedTabFromClosedWindow( + aSource, + aClosedId, + aTargetWindow + ); + }, + + /** + * Forget a closed tab associated with a given window + * Removes the record at the given index so it cannot be un-closed or appear + * in a list of recently-closed tabs + * + * @param {Window|Object} aSource + * Either a DOMWindow or an object with properties to resolve to the window + * the tab was previously open in. + * @param {String} aSource.sourceWindowId + A SessionStore window id used to look up the window where the tab was closed + * @param {number} aSource.sourceClosedId + The closedId used to look up the closed window where the tab was closed + * @param {Integer} [aIndex = 0] + * The index into the window's list of closed tabs + * @throws {InvalidArgumentError} if the window is not tracked by SessionStore, or index is out of bounds + */ + forgetClosedTab: function ss_forgetClosedTab(aSource, aIndex) { + return SessionStoreInternal.forgetClosedTab(aSource, aIndex); + }, + + /** + * Forget a closed tab that corresponds to the closedId + * Removes the record with this closedId so it cannot be un-closed or appear + * in a list of recently-closed tabs + * + * @param {integer} aClosedId + * The closedId of the tab + * @param {Window|Object} aSourceOptions + * Either a DOMWindow or an object with properties to resolve to the window + * the tab was previously open in. + * @param {boolean} [aSourceOptions.includePrivate = true] + If no other means of resolving a source window is given, this flag is used to + constrain a search across all open window's closed tabs. + * @param {String} aSourceOptions.sourceWindowId + A SessionStore window id used to look up the window where the tab was closed + * @param {number} aSourceOptions.sourceClosedId + The closedId used to look up the closed window where the tab was closed + * @throws {InvalidArgumentError} if the closedId doesnt match a closed tab in any window + */ + forgetClosedTabById: function ss_forgetClosedTabById( + aClosedId, + aSourceOptions + ) { + SessionStoreInternal.forgetClosedTabById(aClosedId, aSourceOptions); + }, + + /** + * Forget a closed window. + * Removes the record with this closedId so it cannot be un-closed or appear + * in a list of recently-closed windows + * + * @param {integer} aClosedId + * The closedId of the window + * @throws {InvalidArgumentError} if the closedId doesnt match a closed window + */ + forgetClosedWindowById: function ss_forgetClosedWindowById(aClosedId) { + SessionStoreInternal.forgetClosedWindowById(aClosedId); + }, + + /** + * Look up the object type ("tab" or "window") for a given closedId + * @param {integer} aClosedId + */ + getObjectTypeForClosedId(aClosedId) { + return SessionStoreInternal.getObjectTypeForClosedId(aClosedId); + }, + + /** + * Look up a window tracked by SessionStore by its id + * @param {String} aSessionStoreId + */ + getWindowById: function ss_getWindowById(aSessionStoreId) { + return SessionStoreInternal.getWindowById(aSessionStoreId); + }, + + getClosedWindowCount: function ss_getClosedWindowCount() { + return SessionStoreInternal.getClosedWindowCount(); + }, + + // this should only be used by one caller (currently restoreLastClosedTabOrWindowOrSession in browser.js) + popLastClosedAction: function ss_popLastClosedAction() { + return SessionStoreInternal._lastClosedActions.pop(); + }, + + // for testing purposes + resetLastClosedActions: function ss_resetLastClosedActions() { + SessionStoreInternal._lastClosedActions = []; + }, + + getClosedWindowData: function ss_getClosedWindowData() { + return SessionStoreInternal.getClosedWindowData(); + }, + + maybeDontRestoreTabs(aWindow) { + SessionStoreInternal.maybeDontRestoreTabs(aWindow); + }, + + undoCloseWindow: function ss_undoCloseWindow(aIndex) { + return SessionStoreInternal.undoCloseWindow(aIndex); + }, + + forgetClosedWindow: function ss_forgetClosedWindow(aIndex) { + return SessionStoreInternal.forgetClosedWindow(aIndex); + }, + + getCustomWindowValue(aWindow, aKey) { + return SessionStoreInternal.getCustomWindowValue(aWindow, aKey); + }, + + setCustomWindowValue(aWindow, aKey, aStringValue) { + SessionStoreInternal.setCustomWindowValue(aWindow, aKey, aStringValue); + }, + + deleteCustomWindowValue(aWindow, aKey) { + SessionStoreInternal.deleteCustomWindowValue(aWindow, aKey); + }, + + getCustomTabValue(aTab, aKey) { + return SessionStoreInternal.getCustomTabValue(aTab, aKey); + }, + + setCustomTabValue(aTab, aKey, aStringValue) { + SessionStoreInternal.setCustomTabValue(aTab, aKey, aStringValue); + }, + + deleteCustomTabValue(aTab, aKey) { + SessionStoreInternal.deleteCustomTabValue(aTab, aKey); + }, + + getLazyTabValue(aTab, aKey) { + return SessionStoreInternal.getLazyTabValue(aTab, aKey); + }, + + getCustomGlobalValue(aKey) { + return SessionStoreInternal.getCustomGlobalValue(aKey); + }, + + setCustomGlobalValue(aKey, aStringValue) { + SessionStoreInternal.setCustomGlobalValue(aKey, aStringValue); + }, + + deleteCustomGlobalValue(aKey) { + SessionStoreInternal.deleteCustomGlobalValue(aKey); + }, + + persistTabAttribute: function ss_persistTabAttribute(aName) { + SessionStoreInternal.persistTabAttribute(aName); + }, + + restoreLastSession: function ss_restoreLastSession() { + SessionStoreInternal.restoreLastSession(); + }, + + speculativeConnectOnTabHover(tab) { + SessionStoreInternal.speculativeConnectOnTabHover(tab); + }, + + getCurrentState(aUpdateAll) { + return SessionStoreInternal.getCurrentState(aUpdateAll); + }, + + reviveCrashedTab(aTab) { + return SessionStoreInternal.reviveCrashedTab(aTab); + }, + + reviveAllCrashedTabs() { + return SessionStoreInternal.reviveAllCrashedTabs(); + }, + + updateSessionStoreFromTablistener( + aBrowser, + aBrowsingContext, + aPermanentKey, + aData, + aForStorage + ) { + return SessionStoreInternal.updateSessionStoreFromTablistener( + aBrowser, + aBrowsingContext, + aPermanentKey, + aData, + aForStorage + ); + }, + + getSessionHistory(tab, updatedCallback) { + return SessionStoreInternal.getSessionHistory(tab, updatedCallback); + }, + + /** + * Re-open a tab or window which corresponds to the closedId + * + * @param {integer} aClosedId + * The closedId of the tab or window + * @param {boolean} [aIncludePrivate = true] + * Whether to match the aClosedId to only closed private tabs/windows or non-private + * @param {Window} [aTargetWindow] + * When aClosedId is for a closed tab, which window to re-open the tab into. + * Defaults to current (topWindow). + * + * @returns a tab or window object + */ + undoCloseById(aClosedId, aIncludePrivate, aTargetWindow) { + return SessionStoreInternal.undoCloseById( + aClosedId, + aIncludePrivate, + aTargetWindow + ); + }, + + resetBrowserToLazyState(tab) { + return SessionStoreInternal.resetBrowserToLazyState(tab); + }, + + maybeExitCrashedState(browser) { + SessionStoreInternal.maybeExitCrashedState(browser); + }, + + isBrowserInCrashedSet(browser) { + return SessionStoreInternal.isBrowserInCrashedSet(browser); + }, + + // this is used for testing purposes + resetNextClosedId() { + SessionStoreInternal._nextClosedId = 0; + }, + + /** + * Ensures that session store has registered and started tracking a given window. + * @param window + * Window reference + */ + ensureInitialized(window) { + if (SessionStoreInternal._sessionInitialized && !window.__SSi) { + /* + We need to check that __SSi is not defined on the window so that if + onLoad function is in the middle of executing we don't enter the function + again and try to redeclare the ContentSessionStore script. + */ + SessionStoreInternal.onLoad(window); + } + }, + + getCurrentEpoch(browser) { + return SessionStoreInternal.getCurrentEpoch(browser.permanentKey); + }, + + /** + * Determines whether the passed version number is compatible with + * the current version number of the SessionStore. + * + * @param version The format and version of the file, as an array, e.g. + * ["sessionrestore", 1] + */ + isFormatVersionCompatible(version) { + if (!version) { + return false; + } + if (!Array.isArray(version)) { + // Improper format. + return false; + } + if (version[0] != "sessionrestore") { + // Not a Session Restore file. + return false; + } + let number = Number.parseFloat(version[1]); + if (Number.isNaN(number)) { + return false; + } + return number <= FORMAT_VERSION; + }, + + /** + * Filters out not worth-saving tabs from a given browser state object. + * + * @param aState (object) + * The browser state for which we remove worth-saving tabs. + * The given object will be modified. + */ + keepOnlyWorthSavingTabs(aState) { + let closedWindowShouldRestore = null; + for (let i = aState.windows.length - 1; i >= 0; i--) { + let win = aState.windows[i]; + for (let j = win.tabs.length - 1; j >= 0; j--) { + let tab = win.tabs[j]; + if (!SessionStoreInternal._shouldSaveTab(tab)) { + win.tabs.splice(j, 1); + if (win.selected > j) { + win.selected--; + } + } + } + + // If it's the last window (and no closedWindow that will restore), keep the window state with no tabs. + if ( + !win.tabs.length && + (aState.windows.length > 1 || + closedWindowShouldRestore || + (closedWindowShouldRestore == null && + (closedWindowShouldRestore = aState._closedWindows.some( + w => w._shouldRestore + )))) + ) { + aState.windows.splice(i, 1); + if (aState.selectedWindow > i) { + aState.selectedWindow--; + } + } + } + }, + + /** + * Prepares to change the remoteness of the given browser, by ensuring that + * the local instance of session history is up-to-date. + */ + async prepareToChangeRemoteness(aTab) { + await SessionStoreInternal.prepareToChangeRemoteness(aTab); + }, + + finishTabRemotenessChange(aTab, aSwitchId) { + SessionStoreInternal.finishTabRemotenessChange(aTab, aSwitchId); + }, + + /** + * Clear session store data for a given private browsing window. + * @param {ChromeWindow} win - Open private browsing window to clear data for. + */ + purgeDataForPrivateWindow(win) { + return SessionStoreInternal.purgeDataForPrivateWindow(win); + }, +}; + +// Freeze the SessionStore object. We don't want anyone to modify it. +Object.freeze(SessionStore); + +/** + * @namespace SessionStoreInternal + * + * @description Internal implementations and helpers for the public SessionStore methods + */ +var SessionStoreInternal = { + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + + _globalState: new GlobalState(), + + // A counter to be used to generate a unique ID for each closed tab or window. + _nextClosedId: 0, + + // During the initial restore and setBrowserState calls tracks the number of + // windows yet to be restored + _restoreCount: -1, + + // For each element, records the SHistoryListener. + _browserSHistoryListener: new WeakMap(), + + // Tracks the various listeners that are used throughout the restore. + _restoreListeners: new WeakMap(), + + // Records the promise created in _restoreHistory, which is used to track + // the completion of the first phase of the restore. + _tabStateRestorePromises: new WeakMap(), + + // The history data needed to be restored in the parent. + _tabStateToRestore: new WeakMap(), + + // For each element, records the current epoch. + _browserEpochs: new WeakMap(), + + // Any browsers that fires the oop-browser-crashed event gets stored in + // here - that way we know which browsers to ignore messages from (until + // they get restored). + _crashedBrowsers: new WeakSet(), + + // A map (xul:browser -> FrameLoader) that maps a browser to the last + // associated frameLoader we heard about. + _lastKnownFrameLoader: new WeakMap(), + + // A map (xul:browser -> object) that maps a browser associated with a + // recently closed tab to all its necessary state information we need to + // properly handle final update message. + _closingTabMap: new WeakMap(), + + // A map (xul:browser -> object) that maps a browser associated with a + // recently closed tab due to a window closure to the tab state information + // that is being stored in _closedWindows for that tab. + _tabClosingByWindowMap: new WeakMap(), + + // A set of window data that has the potential to be saved in the _closedWindows + // array for the session. We will remove window data from this set whenever + // forgetClosedWindow is called for the window, or when session history is + // purged, so that we don't accidentally save that data after the flush has + // completed. Closed tabs use a more complicated mechanism for this particular + // problem. When forgetClosedTab is called, the browser is removed from the + // _closingTabMap, so its data is not recorded. In the purge history case, + // the closedTabs array per window is overwritten so that once the flush is + // complete, the tab would only ever add itself to an array that SessionStore + // no longer cares about. Bug 1230636 has been filed to make the tab case + // work more like the window case, which is more explicit, and easier to + // reason about. + _saveableClosedWindowData: new WeakSet(), + + // whether a setBrowserState call is in progress + _browserSetState: false, + + // time in milliseconds when the session was started (saved across sessions), + // defaults to now if no session was restored or timestamp doesn't exist + _sessionStartTime: Date.now(), + + // states for all currently opened windows + _windows: {}, + + // counter for creating unique window IDs + _nextWindowID: 0, + + // states for all recently closed windows + _closedWindows: [], + + // collection of session states yet to be restored + _statesToRestore: {}, + + // counts the number of crashes since the last clean start + _recentCrashes: 0, + + // whether the last window was closed and should be restored + _restoreLastWindow: false, + + // number of tabs currently restoring + _tabsRestoringCount: 0, + + /** + * @typedef {Object} CloseAction + * @property {string} type + * What the close action acted upon. One of either _LAST_ACTION_CLOSED_TAB or + * _LAST_ACTION_CLOSED_WINDOW + * @property {number} closedId + * The unique ID of the item that closed. + */ + + /** + * An in-order stack of close actions for tabs and windows. + * @type {CloseAction[]} + */ + _lastClosedActions: [], + + /** + * Removes an object from the _lastClosedActions list + * + * @param closedAction + * Either _LAST_ACTION_CLOSED_TAB or _LAST_ACTION_CLOSED_WINDOW + * @param {integer} closedId + * The closedId of a tab or window + */ + _removeClosedAction(closedAction, closedId) { + let closedActionIndex = this._lastClosedActions.findIndex( + obj => obj.type == closedAction && obj.closedId == closedId + ); + + if (closedActionIndex > -1) { + this._lastClosedActions.splice(closedActionIndex, 1); + } + }, + + /** + * Add an object to the _lastClosedActions list and truncates the list if needed + * + * @param closedAction + * Either _LAST_ACTION_CLOSED_TAB or _LAST_ACTION_CLOSED_WINDOW + * @param {integer} closedId + * The closedId of a tab or window + */ + _addClosedAction(closedAction, closedId) { + this._lastClosedActions.push({ + type: closedAction, + closedId, + }); + let maxLength = this._max_tabs_undo * this._max_windows_undo; + + if (this._lastClosedActions.length > maxLength) { + this._lastClosedActions = this._lastClosedActions.slice(-maxLength); + } + }, + + _LAST_ACTION_CLOSED_TAB: "tab", + + _LAST_ACTION_CLOSED_WINDOW: "window", + + _log: null, + + // When starting Firefox with a single private window, this is the place + // where we keep the session we actually wanted to restore in case the user + // decides to later open a non-private window as well. + _deferredInitialState: null, + + // Keeps track of whether a notification needs to be sent that closed objects have changed. + _closedObjectsChanged: false, + + // A promise resolved once initialization is complete + _deferredInitialized: (function () { + let deferred = {}; + + deferred.promise = new Promise((resolve, reject) => { + deferred.resolve = resolve; + deferred.reject = reject; + }); + + return deferred; + })(), + + // Whether session has been initialized + _sessionInitialized: false, + + // A promise resolved once all windows are restored. + _deferredAllWindowsRestored: (function () { + let deferred = {}; + + deferred.promise = new Promise((resolve, reject) => { + deferred.resolve = resolve; + deferred.reject = reject; + }); + + return deferred; + })(), + + get promiseAllWindowsRestored() { + return this._deferredAllWindowsRestored.promise; + }, + + // Promise that is resolved when we're ready to initialize + // and restore the session. + _promiseReadyForInitialization: null, + + // Keep busy state counters per window. + _windowBusyStates: new WeakMap(), + + /** + * A promise fulfilled once initialization is complete. + */ + get promiseInitialized() { + return this._deferredInitialized.promise; + }, + + get canRestoreLastSession() { + return LastSession.canRestore; + }, + + set canRestoreLastSession(val) { + // Cheat a bit; only allow false. + if (!val) { + LastSession.clear(); + } + }, + + /** + * Returns a string describing the last closed object, either "tab" or "window". + * + * This was added to support the sessions.restore WebExtensions API. + */ + get lastClosedObjectType() { + if (this._closedWindows.length) { + // Since there are closed windows, we need to check if there's a closed tab + // in one of the currently open windows that was closed after the + // last-closed window. + let tabTimestamps = []; + for (let window of Services.wm.getEnumerator("navigator:browser")) { + let windowState = this._windows[window.__SSi]; + if (windowState && windowState._closedTabs[0]) { + tabTimestamps.push(windowState._closedTabs[0].closedAt); + } + } + if ( + !tabTimestamps.length || + tabTimestamps.sort((a, b) => b - a)[0] < this._closedWindows[0].closedAt + ) { + return this._LAST_ACTION_CLOSED_WINDOW; + } + } + return this._LAST_ACTION_CLOSED_TAB; + }, + + /** + * Returns a boolean that determines whether the session will be automatically + * restored upon the _next_ startup or a restart. + */ + get willAutoRestore() { + return ( + !PrivateBrowsingUtils.permanentPrivateBrowsing && + (Services.prefs.getBoolPref("browser.sessionstore.resume_session_once") || + Services.prefs.getIntPref("browser.startup.page") == + BROWSER_STARTUP_RESUME_SESSION) + ); + }, + + /** + * Initialize the sessionstore service. + */ + init() { + if (this._initialized) { + throw new Error("SessionStore.init() must only be called once!"); + } + + TelemetryTimestamps.add("sessionRestoreInitialized"); + OBSERVING.forEach(function (aTopic) { + Services.obs.addObserver(this, aTopic, true); + }, this); + + this._initPrefs(); + this._initialized = true; + + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_PRIVACY_LEVEL") + .add(Services.prefs.getIntPref("browser.sessionstore.privacy_level")); + }, + + /** + * Initialize the session using the state provided by SessionStartup + */ + initSession() { + TelemetryStopwatch.start("FX_SESSION_RESTORE_STARTUP_INIT_SESSION_MS"); + let state; + let ss = lazy.SessionStartup; + + if (ss.willRestore() || ss.sessionType == ss.DEFER_SESSION) { + state = ss.state; + } + + if (state) { + try { + // If we're doing a DEFERRED session, then we want to pull pinned tabs + // out so they can be restored. + if (ss.sessionType == ss.DEFER_SESSION) { + let [iniState, remainingState] = + this._prepDataForDeferredRestore(state); + // If we have a iniState with windows, that means that we have windows + // with pinned tabs to restore. + if (iniState.windows.length) { + state = iniState; + } else { + state = null; + } + + if (remainingState.windows.length) { + LastSession.setState(remainingState); + } + Services.telemetry.keyedScalarAdd( + "browser.engagement.sessionrestore_interstitial", + "deferred_restore", + 1 + ); + } else { + // Get the last deferred session in case the user still wants to + // restore it + LastSession.setState(state.lastSessionState); + + let restoreAsCrashed = ss.willRestoreAsCrashed(); + if (restoreAsCrashed) { + this._recentCrashes = + ((state.session && state.session.recentCrashes) || 0) + 1; + + // _needsRestorePage will record sessionrestore_interstitial, + // including the specific reason we decided we needed to show + // about:sessionrestore, if that's what we do. + if (this._needsRestorePage(state, this._recentCrashes)) { + // replace the crashed session with a restore-page-only session + let url = "about:sessionrestore"; + let formdata = { id: { sessionData: state }, url }; + let entry = { + url, + triggeringPrincipal_base64: + lazy.E10SUtils.SERIALIZED_SYSTEMPRINCIPAL, + }; + state = { windows: [{ tabs: [{ entries: [entry], formdata }] }] }; + } else if ( + this._hasSingleTabWithURL(state.windows, "about:welcomeback") + ) { + Services.telemetry.keyedScalarAdd( + "browser.engagement.sessionrestore_interstitial", + "shown_only_about_welcomeback", + 1 + ); + // On a single about:welcomeback URL that crashed, replace about:welcomeback + // with about:sessionrestore, to make clear to the user that we crashed. + state.windows[0].tabs[0].entries[0].url = "about:sessionrestore"; + state.windows[0].tabs[0].entries[0].triggeringPrincipal_base64 = + lazy.E10SUtils.SERIALIZED_SYSTEMPRINCIPAL; + } else { + restoreAsCrashed = false; + } + } + + // If we didn't use about:sessionrestore, record that: + if (!restoreAsCrashed) { + Services.telemetry.keyedScalarAdd( + "browser.engagement.sessionrestore_interstitial", + "autorestore", + 1 + ); + + this._removeExplicitlyClosedTabs(state); + } + + // Update the session start time using the restored session state. + this._updateSessionStartTime(state); + + // Make sure that at least the first window doesn't have anything hidden. + delete state.windows[0].hidden; + // Since nothing is hidden in the first window, it cannot be a popup. + delete state.windows[0].isPopup; + // We don't want to minimize and then open a window at startup. + if (state.windows[0].sizemode == "minimized") { + state.windows[0].sizemode = "normal"; + } + + // clear any lastSessionWindowID attributes since those don't matter + // during normal restore + state.windows.forEach(function (aWindow) { + delete aWindow.__lastSessionWindowID; + }); + } + + // clear _maybeDontRestoreTabs because we have restored (or not) + // windows and so they don't matter + 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); + } + } + + // at this point, we've as good as resumed the session, so we can + // clear the resume_session_once flag, if it's set + if ( + !lazy.RunState.isQuitting && + this._prefBranch.getBoolPref("sessionstore.resume_session_once") + ) { + this._prefBranch.setBoolPref("sessionstore.resume_session_once", false); + } + + TelemetryStopwatch.finish("FX_SESSION_RESTORE_STARTUP_INIT_SESSION_MS"); + return state; + }, + + /** + * When initializing session, if we are restoring the last session at startup, + * close open tabs or close windows marked _maybeDontRestoreTabs (if they were closed + * by closing remaining tabs). + * See bug 490136 + */ + _removeExplicitlyClosedTabs(state) { + // Don't restore tabs that has been explicitly closed + for (let i = 0; i < state.windows.length; ) { + const winData = state.windows[i]; + if (winData._maybeDontRestoreTabs) { + if (state.windows.length == 1) { + // it's the last window, we just want to close tabs + let j = 0; + // reset close group (we don't want to append tabs to existing group close). + winData._lastClosedTabGroupCount = -1; + while (winData.tabs.length) { + const tabState = winData.tabs.pop(); + + // Ensure the index is in bounds. + let activeIndex = (tabState.index || tabState.entries.length) - 1; + activeIndex = Math.min(activeIndex, tabState.entries.length - 1); + activeIndex = Math.max(activeIndex, 0); + + let title = ""; + if (activeIndex in tabState.entries) { + title = + tabState.entries[activeIndex].title || + tabState.entries[activeIndex].url; + } + + const tabData = { + state: tabState, + title, + image: tabState.image, + pos: j++, + closedAt: Date.now(), + closedInGroup: true, + }; + if (this._shouldSaveTabState(tabState)) { + this.saveClosedTabData(winData, winData._closedTabs, tabData); + } + } + } else { + // We can remove the window since it doesn't have any + // tabs that we should restore and it's not the only window + if (winData.tabs.some(this._shouldSaveTabState)) { + winData.closedAt = Date.now(); + state._closedWindows.unshift(winData); + } + state.windows.splice(i, 1); + continue; // we don't want to increment the index + } + } + i++; + } + }, + + _initPrefs() { + this._prefBranch = Services.prefs.getBranch("browser."); + + gDebuggingEnabled = this._prefBranch.getBoolPref("sessionstore.debug"); + + Services.prefs.addObserver("browser.sessionstore.debug", () => { + gDebuggingEnabled = this._prefBranch.getBoolPref("sessionstore.debug"); + }); + + this._log = console.createInstance({ + prefix: "SessionStore", + maxLogLevel: gDebuggingEnabled ? "Debug" : "Warn", + }); + + this._max_tabs_undo = this._prefBranch.getIntPref( + "sessionstore.max_tabs_undo" + ); + this._prefBranch.addObserver("sessionstore.max_tabs_undo", this, true); + + this._closedTabsFromAllWindowsEnabled = this._prefBranch.getBoolPref( + "sessionstore.closedTabsFromAllWindows" + ); + this._prefBranch.addObserver( + "sessionstore.closedTabsFromAllWindows", + this, + true + ); + + this._closedTabsFromClosedWindowsEnabled = this._prefBranch.getBoolPref( + "sessionstore.closedTabsFromClosedWindows" + ); + this._prefBranch.addObserver( + "sessionstore.closedTabsFromClosedWindows", + this, + true + ); + + this._max_windows_undo = this._prefBranch.getIntPref( + "sessionstore.max_windows_undo" + ); + this._prefBranch.addObserver("sessionstore.max_windows_undo", this, true); + + this._restore_on_demand = this._prefBranch.getBoolPref( + "sessionstore.restore_on_demand" + ); + this._prefBranch.addObserver("sessionstore.restore_on_demand", this, true); + + gResistFingerprintingEnabled = Services.prefs.getBoolPref( + "privacy.resistFingerprinting" + ); + Services.prefs.addObserver("privacy.resistFingerprinting", this); + + this._shistoryInParent = Services.appinfo.sessionHistoryInParent; + }, + + /** + * Called on application shutdown, after notifications: + * quit-application-granted, quit-application + */ + _uninit: function ssi_uninit() { + if (!this._initialized) { + throw new Error("SessionStore is not initialized."); + } + + // Prepare to close the session file and write the last state. + lazy.RunState.setClosing(); + + // save all data for session resuming + if (this._sessionInitialized) { + lazy.SessionSaver.run(); + } + + // clear out priority queue in case it's still holding refs + TabRestoreQueue.reset(); + + // Make sure to cancel pending saves. + lazy.SessionSaver.cancel(); + }, + + /** + * Handle notifications + */ + observe: function ssi_observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "browser-window-before-show": // catch new windows + this.onBeforeBrowserWindowShown(aSubject); + break; + case "domwindowclosed": // catch closed windows + this.onClose(aSubject).then(() => { + this._notifyOfClosedObjectsChange(); + }); + if (gDebuggingEnabled) { + Services.obs.notifyObservers(null, NOTIFY_DOMWINDOWCLOSED_HANDLED); + } + break; + case "quit-application-granted": + let syncShutdown = aData == "syncShutdown"; + this.onQuitApplicationGranted(syncShutdown); + break; + case "browser-lastwindow-close-granted": + this.onLastWindowCloseGranted(); + break; + case "quit-application": + this.onQuitApplication(aData); + break; + case "browser:purge-session-history": // catch sanitization + this.onPurgeSessionHistory(); + this._notifyOfClosedObjectsChange(); + break; + case "browser:purge-session-history-for-domain": + this.onPurgeDomainData(aData); + this._notifyOfClosedObjectsChange(); + break; + case "nsPref:changed": // catch pref changes + this.onPrefChange(aData); + this._notifyOfClosedObjectsChange(); + break; + case "idle-daily": + this.onIdleDaily(); + this._notifyOfClosedObjectsChange(); + break; + case "clear-origin-attributes-data": + let userContextId = 0; + try { + userContextId = JSON.parse(aData).userContextId; + } catch (e) {} + if (userContextId) { + this._forgetTabsWithUserContextId(userContextId); + } + break; + case "browsing-context-did-set-embedder": + if (Services.appinfo.sessionHistoryInParent) { + if ( + aSubject && + aSubject === aSubject.top && + aSubject.isContent && + aSubject.embedderElement && + aSubject.embedderElement.permanentKey + ) { + let permanentKey = aSubject.embedderElement.permanentKey; + this._browserSHistoryListener.get(permanentKey)?.unregister(); + this.getOrCreateSHistoryListener(permanentKey, aSubject, true); + } + } + break; + case "browsing-context-discarded": + if (Services.appinfo.sessionHistoryInParent) { + let permanentKey = aSubject?.embedderElement?.permanentKey; + if (permanentKey) { + this._browserSHistoryListener.get(permanentKey)?.unregister(); + } + } + break; + case "browser-shutdown-tabstate-updated": + if (Services.appinfo.sessionHistoryInParent) { + // Non-SHIP code calls this when the frame script is unloaded. + this.onFinalTabStateUpdateComplete(aSubject); + } + this._notifyOfClosedObjectsChange(); + break; + } + }, + + getOrCreateSHistoryListener( + permanentKey, + browsingContext, + collectImmediately = false + ) { + class SHistoryListener { + constructor() { + this.QueryInterface = ChromeUtils.generateQI([ + "nsISHistoryListener", + "nsISupportsWeakReference", + ]); + + this._browserId = browsingContext.browserId; + this._fromIndex = kNoIndex; + } + + unregister() { + let bc = BrowsingContext.getCurrentTopByBrowserId(this._browserId); + bc?.sessionHistory?.removeSHistoryListener(this); + SessionStoreInternal._browserSHistoryListener.delete(permanentKey); + } + + collect( + permanentKey, // eslint-disable-line no-shadow + browsingContext, // eslint-disable-line no-shadow + { collectFull = true, writeToCache = false } + ) { + // Don't bother doing anything if we haven't seen any navigations. + if (!collectFull && this._fromIndex === kNoIndex) { + return null; + } + + TelemetryStopwatch.start( + "FX_SESSION_RESTORE_COLLECT_SESSION_HISTORY_MS" + ); + + let fromIndex = collectFull ? -1 : this._fromIndex; + this._fromIndex = kNoIndex; + + let historychange = lazy.SessionHistory.collectFromParent( + browsingContext.currentURI?.spec, + true, // Bug 1704574 + browsingContext.sessionHistory, + fromIndex + ); + + if (writeToCache) { + let win = + browsingContext.embedderElement?.ownerGlobal || + browsingContext.currentWindowGlobal?.browsingContext?.window; + + SessionStoreInternal.onTabStateUpdate(permanentKey, win, { + data: { historychange }, + }); + } + + TelemetryStopwatch.finish( + "FX_SESSION_RESTORE_COLLECT_SESSION_HISTORY_MS" + ); + + return historychange; + } + + collectFrom(index) { + if (this._fromIndex <= index) { + // If we already know that we need to update history from index N we + // can ignore any changes that happened with an element with index + // larger than N. + // + // Note: initially we use kNoIndex which is MAX_SAFE_INTEGER which + // means we don't ignore anything here, and in case of navigation in + // the history back and forth cases we use kLastIndex which ignores + // only the subsequent navigations, but not any new elements added. + return; + } + + let bc = BrowsingContext.getCurrentTopByBrowserId(this._browserId); + if (bc?.embedderElement?.frameLoader) { + this._fromIndex = index; + + // Queue a tab state update on the |browser.sessionstore.interval| + // timer. We'll call this.collect() when we receive the update. + bc.embedderElement.frameLoader.requestSHistoryUpdate(); + } + } + + OnHistoryNewEntry(newURI, oldIndex) { + // We use oldIndex - 1 to collect the current entry as well. This makes + // sure to collect any changes that were made to the entry while the + // document was active. + this.collectFrom(oldIndex == -1 ? oldIndex : oldIndex - 1); + } + OnHistoryGotoIndex() { + this.collectFrom(kLastIndex); + } + OnHistoryPurge() { + this.collectFrom(-1); + } + OnHistoryReload() { + this.collectFrom(-1); + return true; + } + OnHistoryReplaceEntry() { + this.collectFrom(-1); + } + } + + if (!Services.appinfo.sessionHistoryInParent) { + throw new Error("This function should only be used with SHIP"); + } + + 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(); + sessionHistory.addSHistoryListener(listener); + this._browserSHistoryListener.set(permanentKey, listener); + + let isAboutBlank = browsingContext.currentURI?.spec === "about:blank"; + + if (collectImmediately && (!isAboutBlank || sessionHistory.count !== 0)) { + listener.collect(permanentKey, browsingContext, { writeToCache: true }); + } + + return listener; + }, + + onTabStateUpdate(permanentKey, win, update) { + // Ignore messages from elements that have crashed + // and not yet been revived. + if (this._crashedBrowsers.has(permanentKey)) { + return; + } + + lazy.TabState.update(permanentKey, update); + this.saveStateDelayed(win); + + // Handle any updates sent by the child after the tab was closed. This + // might be the final update as sent by the "unload" handler but also + // any async update message that was sent before the child unloaded. + let closedTab = this._closingTabMap.get(permanentKey); + if (closedTab) { + // Update the closed tab's state. This will be reflected in its + // window's list of closed tabs as that refers to the same object. + lazy.TabState.copyFromCache(permanentKey, closedTab.tabData.state); + } + }, + + onFinalTabStateUpdateComplete(browser) { + let permanentKey = browser.permanentKey; + if ( + this._closingTabMap.has(permanentKey) && + !this._crashedBrowsers.has(permanentKey) + ) { + let { winData, closedTabs, tabData } = + this._closingTabMap.get(permanentKey); + + // We expect no further updates. + this._closingTabMap.delete(permanentKey); + + // The tab state no longer needs this reference. + delete tabData.permanentKey; + + // Determine whether the tab state is worth saving. + let shouldSave = this._shouldSaveTabState(tabData.state); + let index = closedTabs.indexOf(tabData); + + if (shouldSave && index == -1) { + // If the tab state is worth saving and we didn't push it onto + // the list of closed tabs when it was closed (because we deemed + // the state not worth saving) then add it to the window's list + // of closed tabs now. + this.saveClosedTabData(winData, closedTabs, tabData); + } else if (!shouldSave && index > -1) { + // Remove from the list of closed tabs. The update messages sent + // after the tab was closed changed enough state so that we no + // longer consider its data interesting enough to keep around. + this.removeClosedTabData(winData, closedTabs, index); + } + } + + // If this the final message we need to resolve all pending flush + // requests for the given browser as they might have been sent too + // late and will never respond. If they have been sent shortly after + // switching a browser's remoteness there isn't too much data to skip. + lazy.TabStateFlusher.resolveAll(browser); + + this._browserSHistoryListener.get(permanentKey)?.unregister(); + this._restoreListeners.get(permanentKey)?.unregister(); + + Services.obs.notifyObservers(browser, NOTIFY_BROWSER_SHUTDOWN_FLUSH); + }, + + updateSessionStoreFromTablistener( + browser, + browsingContext, + permanentKey, + update, + forStorage = false + ) { + permanentKey = browser?.permanentKey ?? permanentKey; + if (!permanentKey) { + return; + } + + // Ignore sessionStore update from previous epochs + if (!this.isCurrentEpoch(permanentKey, update.epoch)) { + return; + } + + if (browsingContext.isReplaced) { + return; + } + + if (Services.appinfo.sessionHistoryInParent) { + let listener = this.getOrCreateSHistoryListener( + permanentKey, + browsingContext + ); + + if (listener) { + let historychange = + // If it is not the scheduled update (tab closed, window closed etc), + // try to store the loading non-web-controlled page opened in _blank + // first. + (forStorage && + lazy.SessionHistory.collectNonWebControlledBlankLoadingSession( + browsingContext + )) || + listener.collect(permanentKey, browsingContext, { + collectFull: !!update.sHistoryNeeded, + writeToCache: false, + }); + + if (historychange) { + update.data.historychange = historychange; + } + } + } + + let win = + browser?.ownerGlobal ?? + browsingContext.currentWindowGlobal?.browsingContext?.window; + + this.onTabStateUpdate(permanentKey, win, update); + }, + + /** + * This method handles incoming messages sent by the session store content + * script via the Frame Message Manager or Parent Process Message Manager, + * and thus enables communication with OOP tabs. + */ + receiveMessage(aMessage) { + if (Services.appinfo.sessionHistoryInParent) { + throw new Error( + `received unexpected message '${aMessage.name}' with ` + + `sessionHistoryInParent enabled` + ); + } + + // If we got here, that means we're dealing with a frame message + // manager message, so the target will be a . + var browser = aMessage.target; + let win = browser.ownerGlobal; + let tab = win ? win.gBrowser.getTabForBrowser(browser) : null; + + // Ensure we receive only specific messages from s that + // have no tab or window assigned, e.g. the ones that preload + // about:newtab pages, or windows that have closed. + if (!tab && !NOTAB_MESSAGES.has(aMessage.name)) { + throw new Error( + `received unexpected message '${aMessage.name}' ` + + `from a browser that has no tab or window` + ); + } + + let data = aMessage.data || {}; + let hasEpoch = data.hasOwnProperty("epoch"); + + // Most messages sent by frame scripts require to pass an epoch. + if (!hasEpoch && !NOEPOCH_MESSAGES.has(aMessage.name)) { + throw new Error(`received message '${aMessage.name}' without an epoch`); + } + + // Ignore messages from previous epochs. + if (hasEpoch && !this.isCurrentEpoch(browser.permanentKey, data.epoch)) { + return; + } + + switch (aMessage.name) { + case "SessionStore:update": + // |browser.frameLoader| might be empty if the browser was already + // destroyed and its tab removed. In that case we still have the last + // frameLoader we know about to compare. + let frameLoader = + browser.frameLoader || + this._lastKnownFrameLoader.get(browser.permanentKey); + + // If the message isn't targeting the latest frameLoader discard it. + if (frameLoader != aMessage.targetFrameLoader) { + return; + } + + this.onTabStateUpdate(browser.permanentKey, browser.ownerGlobal, data); + + // SHIP code will call this when it receives "browser-shutdown-tabstate-updated" + if (data.isFinal) { + if (!Services.appinfo.sessionHistoryInParent) { + this.onFinalTabStateUpdateComplete(browser); + } + } else if (data.flushID) { + // This is an update kicked off by an async flush request. Notify the + // TabStateFlusher so that it can finish the request and notify its + // consumer that's waiting for the flush to be done. + lazy.TabStateFlusher.resolve(browser, data.flushID); + } + + break; + case "SessionStore:restoreHistoryComplete": + this._restoreHistoryComplete(browser, data); + break; + case "SessionStore:restoreTabContentStarted": + this._restoreTabContentStarted(browser, data); + break; + case "SessionStore:restoreTabContentComplete": + this._restoreTabContentComplete(browser, data); + break; + case "SessionStore:error": + lazy.TabStateFlusher.resolveAll( + browser, + false, + "Received error from the content process" + ); + break; + default: + throw new Error(`received unknown message '${aMessage.name}'`); + } + }, + + /* ........ Window Event Handlers .............. */ + + /** + * Implement EventListener for handling various window and tab events + */ + handleEvent: function ssi_handleEvent(aEvent) { + let win = aEvent.currentTarget.ownerGlobal; + let target = aEvent.originalTarget; + switch (aEvent.type) { + case "TabOpen": + this.onTabAdd(win); + break; + case "TabBrowserInserted": + this.onTabBrowserInserted(win, target); + break; + case "TabClose": + // `adoptedBy` will be set if the tab was closed because it is being + // moved to a new window. + if (aEvent.detail.adoptedBy) { + this.onMoveToNewWindow( + target.linkedBrowser, + aEvent.detail.adoptedBy.linkedBrowser + ); + } else if (!aEvent.detail.skipSessionStore) { + // `skipSessionStore` is set by tab close callers to indicate that we + // shouldn't record the closed tab. + this.onTabClose(win, target); + } + this.onTabRemove(win, target); + this._notifyOfClosedObjectsChange(); + break; + case "TabSelect": + this.onTabSelect(win); + break; + case "TabShow": + this.onTabShow(win, target); + break; + case "TabHide": + this.onTabHide(win, target); + break; + case "TabPinned": + case "TabUnpinned": + case "SwapDocShells": + this.saveStateDelayed(win); + break; + case "oop-browser-crashed": + case "oop-browser-buildid-mismatch": + if (aEvent.isTopFrame) { + this.onBrowserCrashed(target); + } + break; + case "XULFrameLoaderCreated": + if ( + target.namespaceURI == XUL_NS && + target.localName == "browser" && + target.frameLoader && + target.permanentKey + ) { + this._lastKnownFrameLoader.set( + target.permanentKey, + target.frameLoader + ); + this.resetEpoch(target.permanentKey, target.frameLoader); + } + break; + default: + throw new Error(`unhandled event ${aEvent.type}?`); + } + this._clearRestoringWindows(); + }, + + /** + * Generate a unique window identifier + * @return string + * A unique string to identify a window + */ + _generateWindowID: function ssi_generateWindowID() { + return "window" + this._nextWindowID++; + }, + + /** + * Registers and tracks a given window. + * + * @param aWindow + * Window reference + */ + onLoad(aWindow) { + // return if window has already been initialized + if (aWindow && aWindow.__SSi && this._windows[aWindow.__SSi]) { + return; + } + + // ignore windows opened while shutting down + if (lazy.RunState.isQuitting) { + return; + } + + // Assign the window a unique identifier we can use to reference + // internal data about the window. + aWindow.__SSi = this._generateWindowID(); + + if (!Services.appinfo.sessionHistoryInParent) { + let mm = aWindow.getGroupMessageManager("browsers"); + MESSAGES.forEach(msg => { + let listenWhenClosed = CLOSED_MESSAGES.has(msg); + mm.addMessageListener(msg, this, listenWhenClosed); + }); + + // Load the frame script after registering listeners. + mm.loadFrameScript( + "chrome://browser/content/content-sessionStore.js", + true, + true + ); + } + + // and create its data object + this._windows[aWindow.__SSi] = { + tabs: [], + selected: 0, + _closedTabs: [], + _lastClosedTabGroupCount: -1, + busy: false, + chromeFlags: aWindow.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow).chromeFlags, + }; + + if (PrivateBrowsingUtils.isWindowPrivate(aWindow)) { + this._windows[aWindow.__SSi].isPrivate = true; + } + if (!this._isWindowLoaded(aWindow)) { + this._windows[aWindow.__SSi]._restoring = true; + } + if (!aWindow.toolbar.visible) { + this._windows[aWindow.__SSi].isPopup = true; + } + + let tabbrowser = aWindow.gBrowser; + + // add tab change listeners to all already existing tabs + for (let i = 0; i < tabbrowser.tabs.length; i++) { + this.onTabBrowserInserted(aWindow, tabbrowser.tabs[i]); + } + // notification of tab add/remove/selection/show/hide + TAB_EVENTS.forEach(function (aEvent) { + tabbrowser.tabContainer.addEventListener(aEvent, this, true); + }, this); + + // Keep track of a browser's latest frameLoader. + aWindow.gBrowser.addEventListener("XULFrameLoaderCreated", this); + }, + + /** + * Initializes a given window. + * + * Windows are registered as soon as they are created but we need to wait for + * the session file to load, and the initial window's delayed startup to + * finish before initializing a window, i.e. restoring data into it. + * + * @param aWindow + * Window reference + * @param aInitialState + * The initial state to be loaded after startup (optional) + */ + initializeWindow(aWindow, aInitialState = null) { + let isPrivateWindow = PrivateBrowsingUtils.isWindowPrivate(aWindow); + + // perform additional initialization when the first window is loading + if (lazy.RunState.isStopped) { + lazy.RunState.setRunning(); + + // restore a crashed session resp. resume the last session if requested + if (aInitialState) { + // Don't write to disk right after startup. Set the last time we wrote + // to disk to NOW() to enforce a full interval before the next write. + lazy.SessionSaver.updateLastSaveTime(); + + if (isPrivateWindow) { + // 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. + this._deferredInitialState = lazy.SessionStartup.state; + + // Nothing to restore now, notify observers things are complete. + Services.obs.notifyObservers(null, NOTIFY_WINDOWS_RESTORED); + Services.obs.notifyObservers( + null, + "sessionstore-one-or-no-tab-restored" + ); + this._deferredAllWindowsRestored.resolve(); + } else { + TelemetryTimestamps.add("sessionRestoreRestoring"); + this._restoreCount = aInitialState.windows + ? aInitialState.windows.length + : 0; + + // global data must be restored before restoreWindow is called so that + // it happens before observers are notified + this._globalState.setFromState(aInitialState); + + // Restore session cookies before loading any tabs. + lazy.SessionCookies.restore(aInitialState.cookies || []); + + let overwrite = this._isCmdLineEmpty(aWindow, aInitialState); + let options = { firstWindow: true, overwriteTabs: overwrite }; + this.restoreWindows(aWindow, aInitialState, options); + } + } else { + // Nothing to restore, notify observers things are complete. + Services.obs.notifyObservers(null, NOTIFY_WINDOWS_RESTORED); + Services.obs.notifyObservers( + null, + "sessionstore-one-or-no-tab-restored" + ); + this._deferredAllWindowsRestored.resolve(); + } + // this window was opened by _openWindowWithState + } else if (!this._isWindowLoaded(aWindow)) { + // We want to restore windows after all windows have opened (since bug + // 1034036), so bail out here. + return; + // The user opened another, non-private window after starting up with + // a single private one. Let's restore the session we actually wanted to + // restore at startup. + } else if ( + this._deferredInitialState && + !isPrivateWindow && + aWindow.toolbar.visible + ) { + // global data must be restored before restoreWindow is called so that + // it happens before observers are notified + this._globalState.setFromState(this._deferredInitialState); + + this._restoreCount = this._deferredInitialState.windows + ? this._deferredInitialState.windows.length + : 0; + this.restoreWindows(aWindow, this._deferredInitialState, { + firstWindow: true, + }); + this._deferredInitialState = null; + } else if ( + this._restoreLastWindow && + aWindow.toolbar.visible && + this._closedWindows.length && + !isPrivateWindow + ) { + // default to the most-recently closed window + // don't use popup windows + let closedWindowState = null; + let closedWindowIndex; + for (let i = 0; i < this._closedWindows.length; i++) { + // Take the first non-popup, point our object at it, and break out. + if (!this._closedWindows[i].isPopup) { + closedWindowState = this._closedWindows[i]; + closedWindowIndex = i; + break; + } + } + + if (closedWindowState) { + let newWindowState; + if ( + AppConstants.platform == "macosx" || + !lazy.SessionStartup.willRestore() + ) { + // We want to split the window up into pinned tabs and unpinned tabs. + // Pinned tabs should be restored. If there are any remaining tabs, + // they should be added back to _closedWindows. + // We'll cheat a little bit and reuse _prepDataForDeferredRestore + // even though it wasn't built exactly for this. + let [appTabsState, normalTabsState] = + this._prepDataForDeferredRestore({ + windows: [closedWindowState], + }); + + // These are our pinned tabs, which we should restore + if (appTabsState.windows.length) { + newWindowState = appTabsState.windows[0]; + delete newWindowState.__lastSessionWindowID; + } + + // In case there were no unpinned tabs, remove the window from _closedWindows + if (!normalTabsState.windows.length) { + this._removeClosedWindow(closedWindowIndex); + // Or update _closedWindows with the modified state + } else { + delete normalTabsState.windows[0].__lastSessionWindowID; + this._closedWindows[closedWindowIndex] = normalTabsState.windows[0]; + } + } else { + // If we're just restoring the window, make sure it gets removed from + // _closedWindows. + this._removeClosedWindow(closedWindowIndex); + newWindowState = closedWindowState; + delete newWindowState.hidden; + } + + if (newWindowState) { + // Ensure that the window state isn't hidden + this._restoreCount = 1; + let state = { windows: [newWindowState] }; + let options = { overwriteTabs: this._isCmdLineEmpty(aWindow, state) }; + this.restoreWindow(aWindow, newWindowState, options); + } + } + // we actually restored the session just now. + this._prefBranch.setBoolPref("sessionstore.resume_session_once", false); + } + if (this._restoreLastWindow && aWindow.toolbar.visible) { + // always reset (if not a popup window) + // we don't want to restore a window directly after, for example, + // undoCloseWindow was executed. + this._restoreLastWindow = false; + } + }, + + /** + * Called right before a new browser window is shown. + * @param aWindow + * Window reference + */ + onBeforeBrowserWindowShown(aWindow) { + // Register the window. + this.onLoad(aWindow); + + // Some are waiting for this window to be shown, which is now, so let's resolve + // the deferred operation. + let deferred = WINDOW_SHOWING_PROMISES.get(aWindow); + if (deferred) { + deferred.resolve(aWindow); + WINDOW_SHOWING_PROMISES.delete(aWindow); + } + + // Just call initializeWindow() directly if we're initialized already. + if (this._sessionInitialized) { + this.initializeWindow(aWindow); + return; + } + + // The very first window that is opened creates a promise that is then + // re-used by all subsequent windows. The promise will be used to tell + // when we're ready for initialization. + if (!this._promiseReadyForInitialization) { + // Wait for the given window's delayed startup to be finished. + let promise = new Promise(resolve => { + Services.obs.addObserver(function obs(subject, topic) { + if (aWindow == subject) { + Services.obs.removeObserver(obs, topic); + resolve(); + } + }, "browser-delayed-startup-finished"); + }); + + // We are ready for initialization as soon as the session file has been + // read from disk and the initial window's delayed startup has finished. + this._promiseReadyForInitialization = Promise.all([ + promise, + lazy.SessionStartup.onceInitialized, + ]); + } + + // We can't call this.onLoad since initialization + // hasn't completed, so we'll wait until it is done. + // Even if additional windows are opened and wait + // for initialization as well, the first opened + // window should execute first, and this.onLoad + // will be called with the initialState. + this._promiseReadyForInitialization + .then(() => { + if (aWindow.closed) { + return; + } + + if (this._sessionInitialized) { + this.initializeWindow(aWindow); + } else { + let initialState = this.initSession(); + this._sessionInitialized = true; + + if (initialState) { + Services.obs.notifyObservers(null, NOTIFY_RESTORING_ON_STARTUP); + } + TelemetryStopwatch.start( + "FX_SESSION_RESTORE_STARTUP_ONLOAD_INITIAL_WINDOW_MS" + ); + this.initializeWindow(aWindow, initialState); + TelemetryStopwatch.finish( + "FX_SESSION_RESTORE_STARTUP_ONLOAD_INITIAL_WINDOW_MS" + ); + + // Let everyone know we're done. + this._deferredInitialized.resolve(); + } + }) + .catch(console.error); + }, + + /** + * On window close... + * - remove event listeners from tabs + * - save all window data + * @param aWindow + * Window reference + * + * @returns a Promise + */ + onClose: function ssi_onClose(aWindow) { + let completionPromise = Promise.resolve(); + // this window was about to be restored - conserve its original data, if any + let isFullyLoaded = this._isWindowLoaded(aWindow); + if (!isFullyLoaded) { + if (!aWindow.__SSi) { + aWindow.__SSi = this._generateWindowID(); + } + + let restoreID = WINDOW_RESTORE_IDS.get(aWindow); + this._windows[aWindow.__SSi] = + this._statesToRestore[restoreID].windows[0]; + delete this._statesToRestore[restoreID]; + WINDOW_RESTORE_IDS.delete(aWindow); + } + + // ignore windows not tracked by SessionStore + if (!aWindow.__SSi || !this._windows[aWindow.__SSi]) { + return completionPromise; + } + + // notify that the session store will stop tracking this window so that + // extensions can store any data about this window in session store before + // that's not possible anymore + let event = aWindow.document.createEvent("Events"); + event.initEvent("SSWindowClosing", true, false); + aWindow.dispatchEvent(event); + + if (this.windowToFocus && this.windowToFocus == aWindow) { + delete this.windowToFocus; + } + + var tabbrowser = aWindow.gBrowser; + + let browsers = Array.from(tabbrowser.browsers); + + TAB_EVENTS.forEach(function (aEvent) { + tabbrowser.tabContainer.removeEventListener(aEvent, this, true); + }, this); + + aWindow.gBrowser.removeEventListener("XULFrameLoaderCreated", this); + + let winData = this._windows[aWindow.__SSi]; + + // Collect window data only when *not* closed during shutdown. + if (lazy.RunState.isRunning) { + // Grab the most recent window data. The tab data will be updated + // once we finish flushing all of the messages from the tabs. + let tabMap = this._collectWindowData(aWindow); + + for (let [tab, tabData] of tabMap) { + let permanentKey = tab.linkedBrowser.permanentKey; + this._tabClosingByWindowMap.set(permanentKey, tabData); + } + + if (isFullyLoaded && !winData.title) { + winData.title = + tabbrowser.selectedBrowser.contentTitle || + tabbrowser.selectedTab.label; + } + + if (AppConstants.platform != "macosx") { + // Until we decide otherwise elsewhere, this window is part of a series + // of closing windows to quit. + winData._shouldRestore = true; + } + + // Store the window's close date to figure out when each individual tab + // was closed. This timestamp should allow re-arranging data based on how + // recently something was closed. + winData.closedAt = Date.now(); + + // we don't want to save the busy state + delete winData.busy; + + // When closing windows one after the other until Firefox quits, we + // will move those closed in series back to the "open windows" bucket + // before writing to disk. If however there is only a single window + // with tabs we deem not worth saving then we might end up with a + // random closed or even a pop-up window re-opened. To prevent that + // we explicitly allow saving an "empty" window state. + let isLastWindow = this.isLastRestorableWindow(); + + // clear this window from the list, since it has definitely been closed. + delete this._windows[aWindow.__SSi]; + + // This window has the potential to be saved in the _closedWindows + // array (maybeSaveClosedWindows gets the final call on that). + this._saveableClosedWindowData.add(winData); + + // Now we have to figure out if this window is worth saving in the _closedWindows + // Object. + // + // We're about to flush the tabs from this window, but it's possible that we + // might never hear back from the content process(es) in time before the user + // chooses to restore the closed window. So we do the following: + // + // 1) Use the tab state cache to determine synchronously if the window is + // worth stashing in _closedWindows. + // 2) Flush the window. + // 3) When the flush is complete, revisit our decision to store the window + // in _closedWindows, and add/remove as necessary. + if (!winData.isPrivate) { + this.maybeSaveClosedWindow(winData, isLastWindow); + } + + completionPromise = lazy.TabStateFlusher.flushWindow(aWindow).then(() => { + // At this point, aWindow is closed! You should probably not try to + // access any DOM elements from aWindow within this callback unless + // you're holding on to them in the closure. + + WINDOW_FLUSHING_PROMISES.delete(aWindow); + + for (let browser of browsers) { + if (this._tabClosingByWindowMap.has(browser.permanentKey)) { + let tabData = this._tabClosingByWindowMap.get(browser.permanentKey); + lazy.TabState.copyFromCache(browser.permanentKey, tabData); + this._tabClosingByWindowMap.delete(browser.permanentKey); + } + } + + // Save non-private windows if they have at + // least one saveable tab or are the last window. + if (!winData.isPrivate) { + this.maybeSaveClosedWindow(winData, isLastWindow, true); + + if (!isLastWindow && winData.closedId > -1) { + this._addClosedAction( + this._LAST_ACTION_CLOSED_WINDOW, + winData.closedId + ); + } + } + + // Update the tabs data now that we've got the most + // recent information. + this.cleanUpWindow(aWindow, winData, browsers); + + // save the state without this window to disk + this.saveStateDelayed(); + }); + + // Here we might override a flush already in flight, but that's fine + // because `completionPromise` will always resolve after the old flush + // resolves. + WINDOW_FLUSHING_PROMISES.set(aWindow, completionPromise); + } else { + this.cleanUpWindow(aWindow, winData, browsers); + } + + for (let i = 0; i < tabbrowser.tabs.length; i++) { + this.onTabRemove(aWindow, tabbrowser.tabs[i], true); + } + + return completionPromise; + }, + + /** + * Clean up the message listeners on a window that has finally + * gone away. Call this once you're sure you don't want to hear + * from any of this windows tabs from here forward. + * + * @param aWindow + * The browser window we're cleaning up. + * @param winData + * The data for the window that we should hold in the + * DyingWindowCache in case anybody is still holding a + * reference to it. + */ + cleanUpWindow(aWindow, winData, browsers) { + // Any leftover TabStateFlusher Promises need to be resolved now, + // since we're about to remove the message listeners. + for (let browser of browsers) { + lazy.TabStateFlusher.resolveAll(browser); + } + + // Cache the window state until it is completely gone. + DyingWindowCache.set(aWindow, winData); + + if (!Services.appinfo.sessionHistoryInParent) { + let mm = aWindow.getGroupMessageManager("browsers"); + MESSAGES.forEach(msg => mm.removeMessageListener(msg, this)); + } + + this._saveableClosedWindowData.delete(winData); + delete aWindow.__SSi; + }, + + /** + * Decides whether or not a closed window should be put into the + * _closedWindows Object. This might be called multiple times per + * window, and will do the right thing of moving the window data + * in or out of _closedWindows if the winData indicates that our + * need for saving it has changed. + * + * @param winData + * The data for the closed window that we might save. + * @param isLastWindow + * Whether or not the window being closed is the last + * browser window. Callers of this function should pass + * in the value of SessionStoreInternal.atLastWindow for + * this argument, and pass in the same value if they happen + * to call this method again asynchronously (for example, after + * a window flush). + */ + maybeSaveClosedWindow(winData, isLastWindow, recordTelemetry = false) { + // Make sure SessionStore is still running, and make sure that we + // haven't chosen to forget this window. + if ( + lazy.RunState.isRunning && + this._saveableClosedWindowData.has(winData) + ) { + // Determine whether the window has any tabs worth saving. + // Note: We currently ignore the possibility of useful _closedTabs here. + // A window with 0 worth-keeping open tabs will not have its state saved, and + // any _closedTabs will be lost. + let hasSaveableTabs = winData.tabs.some(this._shouldSaveTabState); + + // Note that we might already have this window stored in + // _closedWindows from a previous call to this function. + let winIndex = this._closedWindows.indexOf(winData); + let alreadyStored = winIndex != -1; + let shouldStore = hasSaveableTabs || isLastWindow; + + if (shouldStore && !alreadyStored) { + let index = this._closedWindows.findIndex(win => { + return win.closedAt < winData.closedAt; + }); + + // If we found no window closed before our + // window then just append it to the list. + if (index == -1) { + index = this._closedWindows.length; + } + + // About to save the closed window, add a unique ID. + winData.closedId = this._nextClosedId++; + + // Insert winData at the right position. + this._closedWindows.splice(index, 0, winData); + this._capClosedWindows(); + this._closedObjectsChanged = true; + // The first time we close a window, ensure it can be restored from the + // hidden window. + if ( + AppConstants.platform == "macosx" && + this._closedWindows.length == 1 + ) { + // Fake a popupshowing event so shortcuts work: + let window = Services.appShell.hiddenDOMWindow; + let historyMenu = window.document.getElementById("history-menu"); + let evt = new window.CustomEvent("popupshowing", { bubbles: true }); + historyMenu.menupopup.dispatchEvent(evt); + } + } else if (!shouldStore) { + if ( + winData._closedTabs.length && + this._closedTabsFromAllWindowsEnabled + ) { + // we are going to lose closed tabs, so any observers should be notified + this._closedObjectsChanged = true; + } + if (alreadyStored) { + this._removeClosedWindow(winIndex); + return; + } + // we only do this after the TabStateFlusher promise resolves in ssi_onClose + if (recordTelemetry) { + let closedTabsHistogram = Services.telemetry.getHistogramById( + "FX_SESSION_RESTORE_CLOSED_TABS_NOT_SAVED" + ); + closedTabsHistogram.add(winData._closedTabs.length); + } + } + } + }, + + /** + * On quit application granted + */ + onQuitApplicationGranted: function ssi_onQuitApplicationGranted( + syncShutdown = false + ) { + // Collect an initial snapshot of window data before we do the flush. + let index = 0; + for (let window of this._orderedBrowserWindows) { + this._collectWindowData(window); + this._windows[window.__SSi].zIndex = ++index; + } + + // Now add an AsyncShutdown blocker that'll spin the event loop + // until the windows have all been flushed. + + // This progress object will track the state of async window flushing + // and will help us debug things that go wrong with our AsyncShutdown + // blocker. + let progress = { total: -1, current: -1 }; + + // We're going down! Switch state so that we treat closing windows and + // tabs correctly. + lazy.RunState.setQuitting(); + + if (!syncShutdown) { + // We've got some time to shut down, so let's do this properly that there + // will be a complete session available upon next startup. + // To prevent a blocker from taking longer than the DELAY_CRASH_MS limit + // (which will cause a crash) of AsyncShutdown whilst flushing all windows, + // we resolve the Promise blocker once: + // 1. the flush duration exceeds 10 seconds before DELAY_CRASH_MS, or + // 2. 'oop-frameloader-crashed', or + // 3. 'ipc:content-shutdown' is observed. + lazy.AsyncShutdown.quitApplicationGranted.addBlocker( + "SessionStore: flushing all windows", + () => { + // Set up the list of promises that will signal a complete sessionstore + // shutdown: either all data is saved, or we crashed or the message IPC + // channel went away in the meantime. + let promises = [this.flushAllWindowsAsync(progress)]; + + const observeTopic = topic => { + let deferred = Promise.withResolvers(); + const observer = subject => { + // Skip abort on ipc:content-shutdown if not abnormal/crashed + subject.QueryInterface(Ci.nsIPropertyBag2); + if ( + !(topic == "ipc:content-shutdown" && !subject.get("abnormal")) + ) { + deferred.resolve(); + } + }; + const cleanup = () => { + try { + Services.obs.removeObserver(observer, topic); + } catch (ex) { + console.error( + "SessionStore: exception whilst flushing all windows: ", + ex + ); + } + }; + Services.obs.addObserver(observer, topic); + deferred.promise.then(cleanup, cleanup); + return deferred; + }; + + // Build a list of deferred executions that require cleanup once the + // Promise race is won. + // Ensure that the timer fires earlier than the AsyncShutdown crash timer. + let waitTimeMaxMs = Math.max( + 0, + lazy.AsyncShutdown.DELAY_CRASH_MS - 10000 + ); + let defers = [ + this.looseTimer(waitTimeMaxMs), + + // FIXME: We should not be aborting *all* flushes when a single + // content process crashes here. + observeTopic("oop-frameloader-crashed"), + observeTopic("ipc:content-shutdown"), + ]; + // Add these monitors to the list of Promises to start the race. + promises.push(...defers.map(deferred => deferred.promise)); + + return Promise.race(promises).then(() => { + // When a Promise won the race, make sure we clean up the running + // monitors. + defers.forEach(deferred => deferred.reject()); + }); + }, + () => progress + ); + } else { + // We have to shut down NOW, which means we only get to save whatever + // we already had cached. + } + }, + + /** + * An async Task that iterates all open browser windows and flushes + * any outstanding messages from their tabs. This will also close + * all of the currently open windows while we wait for the flushes + * to complete. + * + * @param progress (Object) + * Optional progress object that will be updated as async + * window flushing progresses. flushAllWindowsSync will + * write to the following properties: + * + * total (int): + * The total number of windows to be flushed. + * current (int): + * The current window that we're waiting for a flush on. + * + * @return Promise + */ + async flushAllWindowsAsync(progress = {}) { + let windowPromises = new Map(WINDOW_FLUSHING_PROMISES); + WINDOW_FLUSHING_PROMISES.clear(); + + // We collect flush promises and close each window immediately so that + // the user can't start changing any window state while we're waiting + // for the flushes to finish. + for (let window of this._browserWindows) { + windowPromises.set(window, lazy.TabStateFlusher.flushWindow(window)); + + // We have to wait for these messages to come up from + // each window and each browser. In the meantime, hide + // the windows to improve perceived shutdown speed. + let baseWin = window.docShell.treeOwner.QueryInterface(Ci.nsIBaseWindow); + baseWin.visibility = false; + } + + progress.total = windowPromises.size; + progress.current = 0; + + // We'll iterate through the Promise array, yielding each one, so as to + // provide useful progress information to AsyncShutdown. + for (let [win, promise] of windowPromises) { + await promise; + + // We may have already stopped tracking this window in onClose, which is + // fine as we would've collected window data there as well. + if (win.__SSi && this._windows[win.__SSi]) { + this._collectWindowData(win); + } + + progress.current++; + } + + // We must cache this because _getTopWindow will always + // return null by the time quit-application occurs. + var activeWindow = this._getTopWindow(); + if (activeWindow) { + this.activeWindowSSiCache = activeWindow.__SSi || ""; + } + DirtyWindows.clear(); + }, + + /** + * On last browser window close + */ + onLastWindowCloseGranted: function ssi_onLastWindowCloseGranted() { + // last browser window is quitting. + // remember to restore the last window when another browser window is opened + // do not account for pref(resume_session_once) at this point, as it might be + // set by another observer getting this notice after us + this._restoreLastWindow = true; + }, + + /** + * On quitting application + * @param aData + * String type of quitting + */ + onQuitApplication: function ssi_onQuitApplication(aData) { + if (aData == "restart" || aData == "os-restart") { + if (!PrivateBrowsingUtils.permanentPrivateBrowsing) { + if ( + aData == "os-restart" && + !this._prefBranch.getBoolPref("sessionstore.resume_session_once") + ) { + this._prefBranch.setBoolPref( + "sessionstore.resuming_after_os_restart", + true + ); + } + this._prefBranch.setBoolPref("sessionstore.resume_session_once", true); + } + + // The browser:purge-session-history notification fires after the + // quit-application notification so unregister the + // browser:purge-session-history notification to prevent clearing + // session data on disk on a restart. It is also unnecessary to + // perform any other sanitization processing on a restart as the + // browser is about to exit anyway. + Services.obs.removeObserver(this, "browser:purge-session-history"); + } + + if (aData != "restart") { + // Throw away the previous session on shutdown without notification + LastSession.clear(true); + } + + this._uninit(); + }, + + /** + * Clear session store data for a given private browsing window. + * @param {ChromeWindow} win - Open private browsing window to clear data for. + */ + purgeDataForPrivateWindow(win) { + // No need to clear data if already shutting down. + if (lazy.RunState.isQuitting) { + return; + } + + // Check if we have data for the given window. + let windowData = this._windows[win.__SSi]; + if (!windowData) { + return; + } + + // Clear closed tab data. + if (windowData._closedTabs.length) { + // Remove all of the closed tabs data. + // This also clears out the permenentKey-mapped data for pending state updates + // and removes the tabs from from the _lastClosedActions list + while (windowData._closedTabs.length) { + this.removeClosedTabData(windowData, windowData._closedTabs, 0); + } + // Reset the closed tab list. + windowData._closedTabs = []; + windowData._lastClosedTabGroupCount = -1; + this._closedObjectsChanged = true; + } + }, + + /** + * On purge of session history + */ + onPurgeSessionHistory: function ssi_onPurgeSessionHistory() { + lazy.SessionFile.wipe(); + // If the browser is shutting down, simply return after clearing the + // session data on disk as this notification fires after the + // quit-application notification so the browser is about to exit. + if (lazy.RunState.isQuitting) { + return; + } + LastSession.clear(); + + let openWindows = {}; + // Collect open windows. + for (let window of this._browserWindows) { + openWindows[window.__SSi] = true; + } + + // also clear all data about closed tabs and windows + for (let ix in this._windows) { + if (ix in openWindows) { + if (this._windows[ix]._closedTabs.length) { + this._windows[ix]._closedTabs = []; + this._closedObjectsChanged = true; + } + } else { + delete this._windows[ix]; + } + } + // also clear all data about closed windows + if (this._closedWindows.length) { + this._closedWindows = []; + this._closedObjectsChanged = true; + } + // give the tabbrowsers a chance to clear their histories first + var win = this._getTopWindow(); + if (win) { + win.setTimeout(() => lazy.SessionSaver.run(), 0); + } else if (lazy.RunState.isRunning) { + lazy.SessionSaver.run(); + } + + this._clearRestoringWindows(); + this._saveableClosedWindowData = new WeakSet(); + this._lastClosedActions = []; + }, + + /** + * On purge of domain data + * @param {string} aDomain + * The domain we want to purge data for + */ + onPurgeDomainData: function ssi_onPurgeDomainData(aDomain) { + // does a session history entry contain a url for the given domain? + function containsDomain(aEntry) { + let host; + try { + host = Services.io.newURI(aEntry.url).host; + } catch (e) { + // The given URL probably doesn't have a host. + } + if (host && Services.eTLD.hasRootDomain(host, aDomain)) { + return true; + } + return aEntry.children && aEntry.children.some(containsDomain, this); + } + // remove all closed tabs containing a reference to the given domain + for (let ix in this._windows) { + let closedTabs = this._windows[ix]._closedTabs; + for (let i = closedTabs.length - 1; i >= 0; i--) { + if (closedTabs[i].state.entries.some(containsDomain, this)) { + closedTabs.splice(i, 1); + this._closedObjectsChanged = true; + } + } + } + // remove all open & closed tabs containing a reference to the given + // domain in closed windows + for (let ix = this._closedWindows.length - 1; ix >= 0; ix--) { + let closedTabs = this._closedWindows[ix]._closedTabs; + let openTabs = this._closedWindows[ix].tabs; + let openTabCount = openTabs.length; + for (let i = closedTabs.length - 1; i >= 0; i--) { + if (closedTabs[i].state.entries.some(containsDomain, this)) { + closedTabs.splice(i, 1); + } + } + for (let j = openTabs.length - 1; j >= 0; j--) { + if (openTabs[j].entries.some(containsDomain, this)) { + openTabs.splice(j, 1); + if (this._closedWindows[ix].selected > j) { + this._closedWindows[ix].selected--; + } + } + } + if (!openTabs.length) { + this._closedWindows.splice(ix, 1); + } else if (openTabs.length != openTabCount) { + // Adjust the window's title if we removed an open tab + let selectedTab = openTabs[this._closedWindows[ix].selected - 1]; + // some duplication from restoreHistory - make sure we get the correct title + let activeIndex = (selectedTab.index || selectedTab.entries.length) - 1; + if (activeIndex >= selectedTab.entries.length) { + activeIndex = selectedTab.entries.length - 1; + } + this._closedWindows[ix].title = selectedTab.entries[activeIndex].title; + } + } + + if (lazy.RunState.isRunning) { + lazy.SessionSaver.run(); + } + + this._clearRestoringWindows(); + }, + + /** + * On preference change + * @param aData + * String preference changed + */ + onPrefChange: function ssi_onPrefChange(aData) { + switch (aData) { + // if the user decreases the max number of closed tabs they want + // preserved update our internal states to match that max + case "sessionstore.max_tabs_undo": + this._max_tabs_undo = this._prefBranch.getIntPref( + "sessionstore.max_tabs_undo" + ); + for (let ix in this._windows) { + if (this._windows[ix]._closedTabs.length > this._max_tabs_undo) { + this._windows[ix]._closedTabs.splice( + this._max_tabs_undo, + this._windows[ix]._closedTabs.length + ); + this._closedObjectsChanged = true; + } + } + break; + case "sessionstore.max_windows_undo": + this._max_windows_undo = this._prefBranch.getIntPref( + "sessionstore.max_windows_undo" + ); + this._capClosedWindows(); + break; + case "privacy.resistFingerprinting": + gResistFingerprintingEnabled = Services.prefs.getBoolPref( + "privacy.resistFingerprinting" + ); + break; + case "sessionstore.restore_on_demand": + this._restore_on_demand = this._prefBranch.getBoolPref( + "sessionstore.restore_on_demand" + ); + break; + case "sessionstore.closedTabsFromAllWindows": + this._closedTabsFromAllWindowsEnabled = this._prefBranch.getBoolPref( + "sessionstore.closedTabsFromAllWindows" + ); + this._closedObjectsChanged = true; + break; + case "sessionstore.closedTabsFromClosedWindows": + this._closedTabsFromClosedWindowsEnabled = this._prefBranch.getBoolPref( + "sessionstore.closedTabsFromClosedWindows" + ); + this._closedObjectsChanged = true; + break; + } + }, + + /** + * save state when new tab is added + * @param aWindow + * Window reference + */ + onTabAdd: function ssi_onTabAdd(aWindow) { + this.saveStateDelayed(aWindow); + }, + + /** + * set up listeners for a new tab + * @param aWindow + * Window reference + * @param aTab + * Tab reference + */ + onTabBrowserInserted: function ssi_onTabBrowserInserted(aWindow, aTab) { + let browser = aTab.linkedBrowser; + browser.addEventListener("SwapDocShells", this); + browser.addEventListener("oop-browser-crashed", this); + browser.addEventListener("oop-browser-buildid-mismatch", this); + + if (browser.frameLoader) { + this._lastKnownFrameLoader.set(browser.permanentKey, browser.frameLoader); + } + + // Only restore if browser has been lazy. + if ( + TAB_LAZY_STATES.has(aTab) && + !TAB_STATE_FOR_BROWSER.has(browser) && + lazy.TabStateCache.get(browser.permanentKey) + ) { + let tabState = lazy.TabState.clone(aTab, TAB_CUSTOM_VALUES.get(aTab)); + this.restoreTab(aTab, tabState); + } + + // The browser has been inserted now, so lazy data is no longer relevant. + TAB_LAZY_STATES.delete(aTab); + }, + + /** + * remove listeners for a tab + * @param aWindow + * Window reference + * @param aTab + * Tab reference + * @param aNoNotification + * bool Do not save state if we're updating an existing tab + */ + onTabRemove: function ssi_onTabRemove(aWindow, aTab, aNoNotification) { + this.cleanUpRemovedBrowser(aTab); + + if (!aNoNotification) { + this.saveStateDelayed(aWindow); + } + }, + + /** + * When a tab closes, collect its properties + * @param aWindow + * Window reference + * @param aTab + * Tab reference + */ + onTabClose: function ssi_onTabClose(aWindow, aTab) { + // don't update our internal state if we don't have to + if (this._max_tabs_undo == 0) { + return; + } + + // Get the latest data for this tab (generally, from the cache) + let tabState = lazy.TabState.collect(aTab, TAB_CUSTOM_VALUES.get(aTab)); + + // Store closed-tab data for undo. + this.maybeSaveClosedTab(aWindow, aTab, tabState); + }, + + /** + * Flush and copy tab state when moving a tab to a new window. + * @param aFromBrowser + * Browser reference. + * @param aToBrowser + * Browser reference. + */ + onMoveToNewWindow(aFromBrowser, aToBrowser) { + lazy.TabStateFlusher.flush(aFromBrowser).then(() => { + let tabState = lazy.TabStateCache.get(aFromBrowser.permanentKey); + lazy.TabStateCache.update(aToBrowser.permanentKey, tabState); + }); + }, + + /** + * Save a closed tab if needed. + * @param aWindow + * Window reference. + * @param aTab + * Tab reference. + * @param tabState + * Tab state. + */ + maybeSaveClosedTab(aWindow, aTab, tabState) { + // Don't save private tabs + let isPrivateWindow = PrivateBrowsingUtils.isWindowPrivate(aWindow); + if (!isPrivateWindow && tabState.isPrivate) { + return; + } + if (aTab == aWindow.FirefoxViewHandler.tab) { + return; + } + + let permanentKey = aTab.linkedBrowser.permanentKey; + + let tabData = { + permanentKey, + state: tabState, + title: aTab.label, + image: aWindow.gBrowser.getIcon(aTab), + pos: aTab._tPos, + closedAt: Date.now(), + closedInGroup: aTab._closedInGroup, + sourceWindowId: aWindow.__SSi, + }; + + let winData = this._windows[aWindow.__SSi]; + let closedTabs = winData._closedTabs; + + // Determine whether the tab contains any information worth saving. Note + // that there might be pending state changes queued in the child that + // didn't reach the parent yet. If a tab is emptied before closing then we + // might still remove it from the list of closed tabs later. + if (this._shouldSaveTabState(tabState)) { + // Save the tab state, for now. We might push a valid tab out + // of the list but those cases should be extremely rare and + // do probably never occur when using the browser normally. + // (Tests or add-ons might do weird things though.) + this.saveClosedTabData(winData, closedTabs, tabData); + } + + // Remember the closed tab to properly handle any last updates included in + // the final "update" message sent by the frame script's unload handler. + this._closingTabMap.set(permanentKey, { + winData, + closedTabs, + tabData, + }); + }, + + /** + * Remove listeners which were added when browser was inserted and reset restoring state. + * Also re-instate lazy data and basically revert tab to its lazy browser state. + * @param aTab + * Tab reference + */ + resetBrowserToLazyState(aTab) { + const gBrowser = aTab.ownerGlobal.gBrowser; + let browser = aTab.linkedBrowser; + // Browser is already lazy so don't do anything. + if (!browser.isConnected) { + return; + } + + this.cleanUpRemovedBrowser(aTab); + + aTab.setAttribute("pending", "true"); + + this._lastKnownFrameLoader.delete(browser.permanentKey); + this._crashedBrowsers.delete(browser.permanentKey); + aTab.removeAttribute("crashed"); + gBrowser.tabContainer.updateTabIndicatorAttr(aTab); + + let { userTypedValue = null, userTypedClear = 0 } = browser; + let hasStartedLoad = browser.didStartLoadSinceLastUserTyping(); + + let cacheState = lazy.TabStateCache.get(browser.permanentKey); + + // Cache the browser userTypedValue either if there is no cache state + // at all (e.g. if it was already discarded before we got to cache its state) + // or it may have been created but not including a userTypedValue (e.g. + // for a private tab we will cache `isPrivate: true` as soon as the tab + // is opened). + // + // But only if: + // + // - if there is no cache state yet (which is unfortunately required + // for tabs discarded immediately after creation by extensions, see + // Bug 1422588). + // + // - or the user typed value was already being loaded (otherwise the lazy + // tab will not be restored with the expected url once activated again, + // see Bug 1724205). + let shouldUpdateCacheState = + userTypedValue && + (!cacheState || (hasStartedLoad && !cacheState.userTypedValue)); + + if (shouldUpdateCacheState) { + // Discard was likely called before state can be cached. Update + // the persistent tab state cache with browser information so a + // restore will be successful. This information is necessary for + // restoreTabContent in ContentRestore.sys.mjs to work properly. + lazy.TabStateCache.update(browser.permanentKey, { + userTypedValue, + userTypedClear: 1, + }); + } + + TAB_LAZY_STATES.set(aTab, { + url: browser.currentURI.spec, + title: aTab.label, + userTypedValue, + userTypedClear, + }); + }, + + /** + * Check if we are dealing with a crashed browser. If so, then the corresponding + * crashed tab was revived by navigating to a different page. Remove the browser + * from the list of crashed browsers to stop ignoring its messages. + * @param aBrowser + * Browser reference + */ + maybeExitCrashedState(aBrowser) { + let uri = aBrowser.documentURI; + if (uri?.spec?.startsWith("about:tabcrashed")) { + this._crashedBrowsers.delete(aBrowser.permanentKey); + } + }, + + /** + * A debugging-only function to check if a browser is in _crashedBrowsers. + * @param aBrowser + * Browser reference + */ + isBrowserInCrashedSet(aBrowser) { + if (gDebuggingEnabled) { + return this._crashedBrowsers.has(aBrowser.permanentKey); + } + throw new Error( + "SessionStore.isBrowserInCrashedSet() should only be called in debug mode!" + ); + }, + + /** + * When a tab is removed or suspended, remove listeners and reset restoring state. + * @param aBrowser + * Browser reference + */ + cleanUpRemovedBrowser(aTab) { + let browser = aTab.linkedBrowser; + + browser.removeEventListener("SwapDocShells", this); + browser.removeEventListener("oop-browser-crashed", this); + browser.removeEventListener("oop-browser-buildid-mismatch", this); + + // If this tab was in the middle of restoring or still needs to be restored, + // we need to reset that state. If the tab was restoring, we will attempt to + // restore the next tab. + let previousState = TAB_STATE_FOR_BROWSER.get(browser); + if (previousState) { + this._resetTabRestoringState(aTab); + if (previousState == TAB_STATE_RESTORING) { + this.restoreNextTab(); + } + } + }, + + /** + * Insert a given |tabData| object into the list of |closedTabs|. We will + * determine the right insertion point based on the .closedAt properties of + * all tabs already in the list. The list will be truncated to contain a + * maximum of |this._max_tabs_undo| entries. + * + * @param winData (object) + * The data of the window. + * @param tabData (object) + * The tabData to be inserted. + * @param closedTabs (array) + * The list of closed tabs for a window. + */ + saveClosedTabData(winData, closedTabs, tabData, saveAction = true) { + // Find the index of the first tab in the list + // of closed tabs that was closed before our tab. + let index = closedTabs.findIndex(tab => { + return tab.closedAt < tabData.closedAt; + }); + + // If we found no tab closed before our + // tab then just append it to the list. + if (index == -1) { + index = closedTabs.length; + } + + // About to save the closed tab, add a unique ID. + tabData.closedId = this._nextClosedId++; + + // Insert tabData at the right position. + closedTabs.splice(index, 0, tabData); + this._closedObjectsChanged = true; + + if (tabData.closedInGroup) { + if (winData._lastClosedTabGroupCount < this._max_tabs_undo) { + if (winData._lastClosedTabGroupCount < 0) { + winData._lastClosedTabGroupCount = 1; + } else { + winData._lastClosedTabGroupCount++; + } + } + } else { + winData._lastClosedTabGroupCount = -1; + } + + if (saveAction) { + this._addClosedAction(this._LAST_ACTION_CLOSED_TAB, tabData.closedId); + } + + // Truncate the list of closed tabs, if needed. + if (closedTabs.length > this._max_tabs_undo) { + closedTabs.splice(this._max_tabs_undo, closedTabs.length); + } + }, + + /** + * Remove the closed tab data at |index| from the list of |closedTabs|. If + * the tab's final message is still pending we will simply discard it when + * it arrives so that the tab doesn't reappear in the list. + * + * @param winData (object) + * The data of the window. + * @param index (uint) + * The index of the tab to remove. + * @param closedTabs (array) + * The list of closed tabs for a window. + */ + removeClosedTabData(winData, closedTabs, index) { + // Remove the given index from the list. + let [closedTab] = closedTabs.splice(index, 1); + this._closedObjectsChanged = true; + + // If the tab is part of the last closed group, + // we need to deduct the tab from the count. + if (index < winData._lastClosedTabGroupCount) { + winData._lastClosedTabGroupCount--; + } + + // If the closed tab's state still has a .permanentKey property then we + // haven't seen its final update message yet. Remove it from the map of + // closed tabs so that we will simply discard its last messages and will + // not add it back to the list of closed tabs again. + if (closedTab.permanentKey) { + this._closingTabMap.delete(closedTab.permanentKey); + this._tabClosingByWindowMap.delete(closedTab.permanentKey); + delete closedTab.permanentKey; + } + + this._removeClosedAction(this._LAST_ACTION_CLOSED_TAB, closedTab.closedId); + + return closedTab; + }, + + /** + * When a tab is selected, save session data + * @param aWindow + * Window reference + */ + onTabSelect: function ssi_onTabSelect(aWindow) { + if (lazy.RunState.isRunning) { + this._windows[aWindow.__SSi].selected = + aWindow.gBrowser.tabContainer.selectedIndex; + + let tab = aWindow.gBrowser.selectedTab; + let browser = tab.linkedBrowser; + + if (TAB_STATE_FOR_BROWSER.get(browser) == TAB_STATE_NEEDS_RESTORE) { + // If BROWSER_STATE is still available for the browser and it is + // If __SS_restoreState is still on the browser and it is + // TAB_STATE_NEEDS_RESTORE, then we haven't restored this tab yet. + // + // It's possible that this tab was recently revived, and that + // we've deferred showing the tab crashed page for it (if the + // tab crashed in the background). If so, we need to re-enter + // the crashed state, since we'll be showing the tab crashed + // page. + if (lazy.TabCrashHandler.willShowCrashedTab(browser)) { + this.enterCrashedState(browser); + } else { + this.restoreTabContent(tab); + } + } + } + }, + + onTabShow: function ssi_onTabShow(aWindow, aTab) { + // If the tab hasn't been restored yet, move it into the right bucket + if ( + TAB_STATE_FOR_BROWSER.get(aTab.linkedBrowser) == TAB_STATE_NEEDS_RESTORE + ) { + TabRestoreQueue.hiddenToVisible(aTab); + + // let's kick off tab restoration again to ensure this tab gets restored + // with "restore_hidden_tabs" == false (now that it has become visible) + this.restoreNextTab(); + } + + // Default delay of 2 seconds gives enough time to catch multiple TabShow + // events. This used to be due to changing groups in 'tab groups'. We + // might be able to get rid of this now? + this.saveStateDelayed(aWindow); + }, + + onTabHide: function ssi_onTabHide(aWindow, aTab) { + // If the tab hasn't been restored yet, move it into the right bucket + if ( + TAB_STATE_FOR_BROWSER.get(aTab.linkedBrowser) == TAB_STATE_NEEDS_RESTORE + ) { + TabRestoreQueue.visibleToHidden(aTab); + } + + // Default delay of 2 seconds gives enough time to catch multiple TabHide + // events. This used to be due to changing groups in 'tab groups'. We + // might be able to get rid of this now? + this.saveStateDelayed(aWindow); + }, + + /** + * Handler for the event that is fired when a crashes. + * + * @param aWindow + * The window that the crashed browser belongs to. + * @param aBrowser + * The that is now in the crashed state. + */ + onBrowserCrashed(aBrowser) { + this.enterCrashedState(aBrowser); + // The browser crashed so we might never receive flush responses. + // Resolve all pending flush requests for the crashed browser. + lazy.TabStateFlusher.resolveAll(aBrowser); + }, + + /** + * Called when a browser is showing or is about to show the tab + * crashed page. This method causes SessionStore to ignore the + * tab until it's restored. + * + * @param browser + * The that is about to show the crashed page. + */ + enterCrashedState(browser) { + this._crashedBrowsers.add(browser.permanentKey); + + let win = browser.ownerGlobal; + + // If we hadn't yet restored, or were still in the midst of + // restoring this browser at the time of the crash, we need + // to reset its state so that we can try to restore it again + // when the user revives the tab from the crash. + if (TAB_STATE_FOR_BROWSER.has(browser)) { + let tab = win.gBrowser.getTabForBrowser(browser); + if (tab) { + this._resetLocalTabRestoringState(tab); + } + } + }, + + // Clean up data that has been closed a long time ago. + // Do not reschedule a save. This will wait for the next regular + // save. + onIdleDaily() { + // Remove old closed windows + this._cleanupOldData([this._closedWindows]); + + // Remove closed tabs of closed windows + this._cleanupOldData( + this._closedWindows.map(winData => winData._closedTabs) + ); + + // Remove closed tabs of open windows + this._cleanupOldData( + Object.keys(this._windows).map(key => this._windows[key]._closedTabs) + ); + + this._notifyOfClosedObjectsChange(); + }, + + // Remove "old" data from an array + _cleanupOldData(targets) { + const TIME_TO_LIVE = this._prefBranch.getIntPref( + "sessionstore.cleanup.forget_closed_after" + ); + const now = Date.now(); + + for (let array of targets) { + for (let i = array.length - 1; i >= 0; --i) { + let data = array[i]; + // Make sure that we have a timestamp to tell us when the target + // has been closed. If we don't have a timestamp, default to a + // safe timestamp: just now. + data.closedAt = data.closedAt || now; + if (now - data.closedAt > TIME_TO_LIVE) { + array.splice(i, 1); + this._closedObjectsChanged = true; + } + } + } + }, + + /* ........ nsISessionStore API .............. */ + + getBrowserState: function ssi_getBrowserState() { + let state = this.getCurrentState(); + + // Don't include the last session state in getBrowserState(). + delete state.lastSessionState; + + // Don't include any deferred initial state. + delete state.deferredInitialState; + + return JSON.stringify(state); + }, + + setBrowserState: function ssi_setBrowserState(aState) { + this._handleClosedWindows(); + + try { + var state = JSON.parse(aState); + } catch (ex) { + /* invalid state object - don't restore anything */ + } + if (!state) { + throw Components.Exception( + "Invalid state string: not JSON", + Cr.NS_ERROR_INVALID_ARG + ); + } + if (!state.windows) { + throw Components.Exception("No windows", Cr.NS_ERROR_INVALID_ARG); + } + + this._browserSetState = true; + + // Make sure the priority queue is emptied out + this._resetRestoringState(); + + var window = this._getTopWindow(); + if (!window) { + this._restoreCount = 1; + this._openWindowWithState(state); + return; + } + + // close all other browser windows + for (let otherWin of this._browserWindows) { + if (otherWin != window) { + otherWin.close(); + this.onClose(otherWin); + } + } + + // make sure closed window data isn't kept + if (this._closedWindows.length) { + this._closedWindows = []; + this._closedObjectsChanged = true; + } + + // determine how many windows are meant to be restored + this._restoreCount = state.windows ? state.windows.length : 0; + + // global data must be restored before restoreWindow is called so that + // it happens before observers are notified + this._globalState.setFromState(state); + + // Restore session cookies. + lazy.SessionCookies.restore(state.cookies || []); + + // restore to the given state + this.restoreWindows(window, state, { overwriteTabs: true }); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + getWindowState: function ssi_getWindowState(aWindow) { + if ("__SSi" in aWindow) { + return Cu.cloneInto(this._getWindowState(aWindow), {}); + } + + if (DyingWindowCache.has(aWindow)) { + let data = DyingWindowCache.get(aWindow); + return Cu.cloneInto({ windows: [data] }, {}); + } + + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + }, + + setWindowState: function ssi_setWindowState(aWindow, aState, aOverwrite) { + if (!aWindow.__SSi) { + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + + this.restoreWindows(aWindow, aState, { overwriteTabs: aOverwrite }); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + getTabState: function ssi_getTabState(aTab) { + if (!aTab || !aTab.ownerGlobal) { + throw Components.Exception("Need a valid tab", Cr.NS_ERROR_INVALID_ARG); + } + if (!aTab.ownerGlobal.__SSi) { + throw Components.Exception( + "Default view is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let tabState = lazy.TabState.collect(aTab, TAB_CUSTOM_VALUES.get(aTab)); + + return JSON.stringify(tabState); + }, + + setTabState(aTab, aState) { + // Remove the tab state from the cache. + // Note that we cannot simply replace the contents of the cache + // as |aState| can be an incomplete state that will be completed + // by |restoreTabs|. + let tabState = aState; + if (typeof tabState == "string") { + tabState = JSON.parse(aState); + } + if (!tabState) { + throw Components.Exception( + "Invalid state string: not JSON", + Cr.NS_ERROR_INVALID_ARG + ); + } + if (typeof tabState != "object") { + throw Components.Exception("Not an object", Cr.NS_ERROR_INVALID_ARG); + } + if (!("entries" in tabState)) { + throw Components.Exception( + "Invalid state object: no entries", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let window = aTab.ownerGlobal; + if (!window || !("__SSi" in window)) { + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (TAB_STATE_FOR_BROWSER.has(aTab.linkedBrowser)) { + this._resetTabRestoringState(aTab); + } + + this._ensureNoNullsInTabDataList( + window.gBrowser.tabs, + this._windows[window.__SSi].tabs, + aTab._tPos + ); + this.restoreTab(aTab, tabState); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + getInternalObjectState(obj) { + if (obj.__SSi) { + return this._windows[obj.__SSi]; + } + return obj.loadURI + ? TAB_STATE_FOR_BROWSER.get(obj) + : TAB_CUSTOM_VALUES.get(obj); + }, + + getObjectTypeForClosedId(aClosedId) { + // check if matches a window first + if (this.getClosedWindowDataByClosedId(aClosedId)) { + return this._LAST_ACTION_CLOSED_WINDOW; + } + return this._LAST_ACTION_CLOSED_TAB; + }, + + getClosedWindowDataByClosedId: function ssi_getClosedWindowDataByClosedId( + aClosedId + ) { + return this._closedWindows.find( + closedData => closedData.closedId == aClosedId + ); + }, + + getWindowById: function ssi_getWindowById(aSessionStoreId) { + let resultWindow; + for (let window of this._browserWindows) { + if (window.__SSi === aSessionStoreId) { + resultWindow = window; + break; + } + } + return resultWindow; + }, + + duplicateTab: function ssi_duplicateTab( + aWindow, + aTab, + aDelta = 0, + aRestoreImmediately = true, + { inBackground, index } = {} + ) { + if (!aTab || !aTab.ownerGlobal) { + throw Components.Exception("Need a valid tab", Cr.NS_ERROR_INVALID_ARG); + } + if (!aTab.ownerGlobal.__SSi) { + throw Components.Exception( + "Default view is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + if (!aWindow.gBrowser) { + throw Components.Exception( + "Invalid window object: no gBrowser", + Cr.NS_ERROR_INVALID_ARG + ); + } + + // Create a new tab. + let userContextId = aTab.getAttribute("usercontextid"); + + let tabOptions = { + userContextId, + index, + ...(aTab == aWindow.gBrowser.selectedTab + ? { relatedToCurrent: true, ownerTab: aTab } + : {}), + skipLoad: true, + preferredRemoteType: aTab.linkedBrowser.remoteType, + }; + let newTab = aWindow.gBrowser.addTrustedTab(null, tabOptions); + + // Start the throbber to pretend we're doing something while actually + // waiting for data from the frame script. This throbber is disabled + // if the URI is a local about: URI. + let uriObj = aTab.linkedBrowser.currentURI; + if (!uriObj || (uriObj && !uriObj.schemeIs("about"))) { + newTab.setAttribute("busy", "true"); + } + + // Hack to ensure that the about:home, about:newtab, and about:welcome + // favicon is loaded instantaneously, to avoid flickering and improve + // perceived performance. + aWindow.gBrowser.setDefaultIcon(newTab, uriObj); + + // Collect state before flushing. + let tabState = lazy.TabState.collect(aTab, TAB_CUSTOM_VALUES.get(aTab)); + + // Flush to get the latest tab state to duplicate. + let browser = aTab.linkedBrowser; + lazy.TabStateFlusher.flush(browser).then(() => { + // The new tab might have been closed in the meantime. + if (newTab.closing || !newTab.linkedBrowser) { + return; + } + + let window = newTab.ownerGlobal; + + // The tab or its window might be gone. + if (!window || !window.__SSi) { + return; + } + + // Update state with flushed data. We can't use TabState.clone() here as + // the tab to duplicate may have already been closed. In that case we + // only have access to the . + let options = { includePrivateData: true }; + lazy.TabState.copyFromCache(browser.permanentKey, tabState, options); + + tabState.index += aDelta; + tabState.index = Math.max( + 1, + Math.min(tabState.index, tabState.entries.length) + ); + tabState.pinned = false; + + if (inBackground === false) { + aWindow.gBrowser.selectedTab = newTab; + } + + // Restore the state into the new tab. + this.restoreTab(newTab, tabState, { + restoreImmediately: aRestoreImmediately, + }); + }); + + return newTab; + }, + + getWindows(aWindowOrOptions) { + let isPrivate; + if (!aWindowOrOptions) { + aWindowOrOptions = this._getTopWindow(); + } + if (aWindowOrOptions instanceof Ci.nsIDOMWindow) { + isPrivate = PrivateBrowsingUtils.isBrowserPrivate(aWindowOrOptions); + } else { + isPrivate = Boolean(aWindowOrOptions.private); + } + + const browserWindows = Array.from(this._browserWindows).filter(win => { + return PrivateBrowsingUtils.isBrowserPrivate(win) === isPrivate; + }); + return browserWindows; + }, + + getWindowForTabClosedId(aClosedId, aIncludePrivate) { + // check non-private windows first, and then only check private windows if + // aIncludePrivate was true + const privateValues = aIncludePrivate ? [false, true] : [false]; + for (let privateness of privateValues) { + for (let window of this.getWindows({ private: privateness })) { + const windowState = this._windows[window.__SSi]; + if (!windowState._closedTabs?.length) { + continue; + } + if (windowState._closedTabs.find(tab => tab.closedId === aClosedId)) { + return window; + } + } + } + return undefined; + }, + + getLastClosedTabCount(aWindow) { + if ("__SSi" in aWindow) { + return Math.min( + Math.max(this._windows[aWindow.__SSi]._lastClosedTabGroupCount, 1), + this.getClosedTabCountForWindow(aWindow) + ); + } + + throw (Components.returnCode = Cr.NS_ERROR_INVALID_ARG); + }, + + resetLastClosedTabCount(aWindow) { + if ("__SSi" in aWindow) { + this._windows[aWindow.__SSi]._lastClosedTabGroupCount = -1; + } else { + throw (Components.returnCode = Cr.NS_ERROR_INVALID_ARG); + } + }, + + getClosedTabCountForWindow: function ssi_getClosedTabCountForWindow(aWindow) { + if ("__SSi" in aWindow) { + return this._windows[aWindow.__SSi]._closedTabs.length; + } + + if (!DyingWindowCache.has(aWindow)) { + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + + return DyingWindowCache.get(aWindow)._closedTabs.length; + }, + + _prepareClosedTabOptions(aOptions = {}) { + const sourceOptions = Object.assign( + { + closedTabsFromAllWindows: this._closedTabsFromAllWindowsEnabled, + closedTabsFromClosedWindows: this._closedTabsFromClosedWindowsEnabled, + sourceWindow: null, + }, + aOptions instanceof Ci.nsIDOMWindow + ? { sourceWindow: aOptions } + : aOptions + ); + if (!sourceOptions.sourceWindow) { + sourceOptions.sourceWindow = this._getTopWindow(sourceOptions.private); + } + if (!sourceOptions.hasOwnProperty("private")) { + sourceOptions.private = PrivateBrowsingUtils.isWindowPrivate( + sourceOptions.sourceWindow + ); + } + return sourceOptions; + }, + + getClosedTabCount(aOptions) { + const sourceOptions = this._prepareClosedTabOptions(aOptions); + let tabCount = 0; + + if (sourceOptions.closedTabsFromAllWindows) { + tabCount += this.getWindows({ private: sourceOptions.private }) + .map(win => this.getClosedTabCountForWindow(win)) + .reduce((total, count) => total + count, 0); + } else { + tabCount += this.getClosedTabCountForWindow(sourceOptions.sourceWindow); + } + + if (!sourceOptions.private && sourceOptions.closedTabsFromClosedWindows) { + tabCount += this.getClosedTabCountFromClosedWindows(); + } + return tabCount; + }, + + getClosedTabCountFromClosedWindows: + function ssi_getClosedTabCountFromClosedWindows() { + const tabCount = this._closedWindows + .map(winData => winData._closedTabs.length) + .reduce((total, count) => total + count, 0); + return tabCount; + }, + + getClosedTabDataForWindow: function ssi_getClosedTabDataForWindow(aWindow) { + // We need to enable wrapping reflectors in order to allow the cloning of + // objects containing FormDatas, which could be stored by + // form-associated custom elements. + let options = { wrapReflectors: true }; + if ("__SSi" in aWindow) { + return Cu.cloneInto( + this._windows[aWindow.__SSi]._closedTabs, + {}, + options + ); + } + + if (!DyingWindowCache.has(aWindow)) { + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let data = DyingWindowCache.get(aWindow); + return Cu.cloneInto(data._closedTabs, {}, options); + }, + + getClosedTabData: function ssi_getClosedTabData(aOptions) { + const sourceOptions = this._prepareClosedTabOptions(aOptions); + const closedTabData = []; + if (sourceOptions.closedTabsFromAllWindows) { + for (let win of this.getWindows({ private: sourceOptions.private })) { + closedTabData.push(...this.getClosedTabDataForWindow(win)); + } + } else { + closedTabData.push( + ...this.getClosedTabDataForWindow(sourceOptions.sourceWindow) + ); + } + return closedTabData; + }, + + getClosedTabDataFromClosedWindows: + function ssi_getClosedTabDataFromClosedWindows() { + const closedTabData = []; + for (let winData of this._closedWindows) { + const sourceClosedId = winData.closedId; + const closedTabs = Cu.cloneInto(winData._closedTabs, {}); + // Add a property pointing back to the closed window source + for (let tabData of closedTabs) { + tabData.sourceClosedId = sourceClosedId; + } + closedTabData.push(...closedTabs); + } + // sorting is left to the caller + return closedTabData; + }, + + undoCloseTab: function ssi_undoCloseTab(aSource, aIndex, aTargetWindow) { + const sourceWinData = this._resolveClosedDataSource(aSource); + const isPrivateSource = Boolean(sourceWinData.isPrivate); + if (aTargetWindow && !aTargetWindow.__SSi) { + throw Components.Exception( + "Target window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } else if (!aTargetWindow) { + aTargetWindow = this._getTopWindow(isPrivateSource); + } + if ( + isPrivateSource !== PrivateBrowsingUtils.isWindowPrivate(aTargetWindow) + ) { + throw Components.Exception( + "Target window doesn't have the same privateness as the source window", + Cr.NS_ERROR_INVALID_ARG + ); + } + + // default to the most-recently closed tab + aIndex = aIndex || 0; + if (!(aIndex in sourceWinData._closedTabs)) { + throw Components.Exception( + "Invalid index: not in the closed tabs", + Cr.NS_ERROR_INVALID_ARG + ); + } + + // fetch the data of closed tab, while removing it from the array + let { state, pos } = this.removeClosedTabData( + sourceWinData, + sourceWinData._closedTabs, + aIndex + ); + + // Predict the remote type to use for the load to avoid unnecessary process + // switches. + let preferredRemoteType = lazy.E10SUtils.DEFAULT_REMOTE_TYPE; + let url; + if (state.entries?.length) { + let activeIndex = (state.index || state.entries.length) - 1; + activeIndex = Math.min(activeIndex, state.entries.length - 1); + activeIndex = Math.max(activeIndex, 0); + url = state.entries[activeIndex].url; + } + if (url) { + preferredRemoteType = this.getPreferredRemoteType( + url, + aTargetWindow, + state.userContextId + ); + } + + // create a new tab + let tabbrowser = aTargetWindow.gBrowser; + let tab = (tabbrowser.selectedTab = tabbrowser.addTrustedTab(null, { + // Append the tab if we're opening into a different window, + index: aSource == aTargetWindow ? pos : Infinity, + pinned: state.pinned, + userContextId: state.userContextId, + skipLoad: true, + preferredRemoteType, + })); + + // restore tab content + this.restoreTab(tab, state); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + + return tab; + }, + + undoClosedTabFromClosedWindow: function ssi_undoClosedTabFromClosedWindow( + aSource, + aClosedId, + aTargetWindow + ) { + const sourceWinData = this._resolveClosedDataSource(aSource); + const closedIndex = sourceWinData._closedTabs.findIndex( + tabData => tabData.closedId == aClosedId + ); + if (closedIndex >= 0) { + return this.undoCloseTab(aSource, closedIndex, aTargetWindow); + } + throw Components.Exception( + "Invalid closedId: not in the closed tabs", + Cr.NS_ERROR_INVALID_ARG + ); + }, + + getPreferredRemoteType(url, aWindow, userContextId) { + return lazy.E10SUtils.getRemoteTypeForURI( + url, + aWindow.gMultiProcessBrowser, + aWindow.gFissionBrowser, + lazy.E10SUtils.DEFAULT_REMOTE_TYPE, + null, + lazy.E10SUtils.predictOriginAttributes({ + window: aWindow, + userContextId, + }) + ); + }, + + _resolveClosedDataSource(aSource) { + let winData; + if (aSource instanceof Ci.nsIDOMWindow) { + if (!aSource.__SSi) { + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + winData = this._windows[aSource.__SSi]; + } else if (typeof aSource.sourceClosedId == "number") { + /* eslint-disable-next-line no-shadow */ + winData = this._closedWindows.find( + closedData => closedData.closedId == aSource.sourceClosedId + ); + if (!winData) { + throw Components.Exception( + "No such closed window", + Cr.NS_ERROR_INVALID_ARG + ); + } + } else if (typeof aSource.sourceWindowId == "string") { + winData = this._windows[aSource.sourceWindowId]; + if (!winData) { + throw Components.Exception( + "No such source window", + Cr.NS_ERROR_INVALID_ARG + ); + } + } else { + throw Components.Exception( + "Invalid source object", + Cr.NS_ERROR_INVALID_ARG + ); + } + return winData; + }, + + forgetClosedTab: function ssi_forgetClosedTab(aSource, aIndex) { + const winData = this._resolveClosedDataSource(aSource); + // default to the most-recently closed tab + aIndex = aIndex || 0; + if (!(aIndex in winData._closedTabs)) { + throw Components.Exception( + "Invalid index: not in the closed tabs", + Cr.NS_ERROR_INVALID_ARG + ); + } + + // remove closed tab from the array + this.removeClosedTabData(winData, winData._closedTabs, aIndex); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + forgetClosedWindowById(aClosedId) { + // We don't keep any record for closed private windows so privateness is not relevant here + let closedIndex = this._closedWindows.findIndex( + windowState => windowState.closedId == aClosedId + ); + if (closedIndex < 0) { + throw Components.Exception( + "Invalid closedId: not in the closed windows", + Cr.NS_ERROR_INVALID_ARG + ); + } + this.forgetClosedWindow(closedIndex); + }, + + forgetClosedTabById(aClosedId, aSourceOptions = {}) { + let sourceWindowsData; + let searchPrivateWindows = aSourceOptions.includePrivate ?? true; + if ( + aSourceOptions instanceof Ci.nsIDOMWindow || + "sourceWindowId" in aSourceOptions || + "sourceClosedId" in aSourceOptions + ) { + sourceWindowsData = [this._resolveClosedDataSource(aSourceOptions)]; + } else { + // Get the windows we'll look for the closed tab in, filtering out private + // windows if necessary + let browserWindows = Array.from(this._browserWindows); + sourceWindowsData = []; + for (let win of browserWindows) { + if ( + !searchPrivateWindows && + PrivateBrowsingUtils.isBrowserPrivate(win) + ) { + continue; + } + sourceWindowsData.push(this._windows[win.__SSi]); + } + } + + // See if the aCloseId matches a closed tab in any window data + for (let winData of sourceWindowsData) { + let closedIndex = winData._closedTabs.findIndex( + tabData => tabData.closedId == aClosedId + ); + if (closedIndex >= 0) { + // remove closed tab from the array + this.removeClosedTabData(winData, winData._closedTabs, closedIndex); + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + return; + } + } + throw Components.Exception( + "Invalid closedId: not found in the closed tabs of any window", + Cr.NS_ERROR_INVALID_ARG + ); + }, + + getClosedWindowCount: function ssi_getClosedWindowCount() { + return this._closedWindows.length; + }, + + getClosedWindowData: function ssi_getClosedWindowData() { + return Cu.cloneInto(this._closedWindows, {}); + }, + + maybeDontRestoreTabs(aWindow) { + // Don't restore the tabs if we restore the session at startup + this._windows[aWindow.__SSi]._maybeDontRestoreTabs = true; + }, + + isLastRestorableWindow() { + return ( + Object.values(this._windows).filter(winData => !winData.isPrivate) + .length == 1 && + !this._closedWindows.some(win => win._shouldRestore || false) + ); + }, + + undoCloseWindow: function ssi_undoCloseWindow(aIndex) { + if (!(aIndex in this._closedWindows)) { + throw Components.Exception( + "Invalid index: not in the closed windows", + Cr.NS_ERROR_INVALID_ARG + ); + } + // reopen the window + let state = { windows: this._removeClosedWindow(aIndex) }; + delete state.windows[0].closedAt; // Window is now open. + + let window = this._openWindowWithState(state); + this.windowToFocus = window; + WINDOW_SHOWING_PROMISES.get(window).promise.then(win => + this.restoreWindows(win, state, { overwriteTabs: true }) + ); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + + return window; + }, + + forgetClosedWindow: function ssi_forgetClosedWindow(aIndex) { + // default to the most-recently closed window + aIndex = aIndex || 0; + if (!(aIndex in this._closedWindows)) { + throw Components.Exception( + "Invalid index: not in the closed windows", + Cr.NS_ERROR_INVALID_ARG + ); + } + + // remove closed window from the array + let winData = this._closedWindows[aIndex]; + this._removeClosedWindow(aIndex); + this._saveableClosedWindowData.delete(winData); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + getCustomWindowValue(aWindow, aKey) { + if ("__SSi" in aWindow) { + let data = this._windows[aWindow.__SSi].extData || {}; + return data[aKey] || ""; + } + + if (DyingWindowCache.has(aWindow)) { + let data = DyingWindowCache.get(aWindow).extData || {}; + return data[aKey] || ""; + } + + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + }, + + setCustomWindowValue(aWindow, aKey, aStringValue) { + if (typeof aStringValue != "string") { + throw new TypeError("setCustomWindowValue only accepts string values"); + } + + if (!("__SSi" in aWindow)) { + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + if (!this._windows[aWindow.__SSi].extData) { + this._windows[aWindow.__SSi].extData = {}; + } + this._windows[aWindow.__SSi].extData[aKey] = aStringValue; + this.saveStateDelayed(aWindow); + }, + + deleteCustomWindowValue(aWindow, aKey) { + if ( + aWindow.__SSi && + this._windows[aWindow.__SSi].extData && + this._windows[aWindow.__SSi].extData[aKey] + ) { + delete this._windows[aWindow.__SSi].extData[aKey]; + } + this.saveStateDelayed(aWindow); + }, + + getCustomTabValue(aTab, aKey) { + return (TAB_CUSTOM_VALUES.get(aTab) || {})[aKey] || ""; + }, + + setCustomTabValue(aTab, aKey, aStringValue) { + if (typeof aStringValue != "string") { + throw new TypeError("setCustomTabValue only accepts string values"); + } + + // If the tab hasn't been restored, then set the data there, otherwise we + // could lose newly added data. + if (!TAB_CUSTOM_VALUES.has(aTab)) { + TAB_CUSTOM_VALUES.set(aTab, {}); + } + + TAB_CUSTOM_VALUES.get(aTab)[aKey] = aStringValue; + this.saveStateDelayed(aTab.ownerGlobal); + }, + + deleteCustomTabValue(aTab, aKey) { + let state = TAB_CUSTOM_VALUES.get(aTab); + if (state && aKey in state) { + delete state[aKey]; + this.saveStateDelayed(aTab.ownerGlobal); + } + }, + + /** + * Retrieves data specific to lazy-browser tabs. If tab is not lazy, + * will return undefined. + * + * @param aTab (xul:tab) + * The tabbrowser-tab the data is for. + * @param aKey (string) + * The key which maps to the desired data. + */ + getLazyTabValue(aTab, aKey) { + return (TAB_LAZY_STATES.get(aTab) || {})[aKey]; + }, + + getCustomGlobalValue(aKey) { + return this._globalState.get(aKey); + }, + + setCustomGlobalValue(aKey, aStringValue) { + if (typeof aStringValue != "string") { + throw new TypeError("setCustomGlobalValue only accepts string values"); + } + + this._globalState.set(aKey, aStringValue); + this.saveStateDelayed(); + }, + + deleteCustomGlobalValue(aKey) { + this._globalState.delete(aKey); + this.saveStateDelayed(); + }, + + persistTabAttribute: function ssi_persistTabAttribute(aName) { + if (lazy.TabAttributes.persist(aName)) { + this.saveStateDelayed(); + } + }, + + /** + * Undoes the closing of a tab or window which corresponds + * to the closedId passed in. + * + * @param {integer} aClosedId + * The closedId of the tab or window + * @param {boolean} [aIncludePrivate = true] + * Whether to restore private tabs or windows. Defaults to true + * @param {Window} [aTargetWindow] + * When aClosedId is for a closed tab, which window to re-open the tab into. + * Defaults to current (topWindow). + * + * @returns a tab or window object + */ + undoCloseById(aClosedId, aIncludePrivate = true, aTargetWindow) { + // Check if we are re-opening a window first. + for (let i = 0, l = this._closedWindows.length; i < l; i++) { + if (this._closedWindows[i].closedId == aClosedId) { + return this.undoCloseWindow(i); + } + } + + // See if the aCloseId matches a tab in an open window + // Check for a tab. + for (let sourceWindow of Services.wm.getEnumerator("navigator:browser")) { + if ( + !aIncludePrivate && + PrivateBrowsingUtils.isWindowPrivate(sourceWindow) + ) { + continue; + } + let windowState = this._windows[sourceWindow.__SSi]; + if (windowState) { + for (let j = 0, l = windowState._closedTabs.length; j < l; j++) { + if (windowState._closedTabs[j].closedId == aClosedId) { + return this.undoCloseTab(sourceWindow, j, aTargetWindow); + } + } + } + } + + // Neither a tab nor a window was found, return undefined and let the caller decide what to do about it. + return undefined; + }, + + /** + * Updates the label and icon for a using the data from + * tabData. + * + * @param tab + * The to update. + * @param tabData (optional) + * The tabData to use to update the tab. If the argument is + * not supplied, the data will be retrieved from the cache. + */ + updateTabLabelAndIcon(tab, tabData = null) { + if (tab.hasAttribute("customizemode")) { + return; + } + + let browser = tab.linkedBrowser; + let win = browser.ownerGlobal; + + if (!tabData) { + tabData = lazy.TabState.collect(tab, TAB_CUSTOM_VALUES.get(tab)); + if (!tabData) { + throw new Error("tabData not found for given tab"); + } + } + + let activePageData = tabData.entries[tabData.index - 1] || null; + + // If the page has a title, set it. + if (activePageData) { + if (activePageData.title && activePageData.title != activePageData.url) { + win.gBrowser.setInitialTabTitle(tab, activePageData.title, { + isContentTitle: true, + }); + } else { + win.gBrowser.setInitialTabTitle(tab, activePageData.url); + } + } + + // Restore the tab icon. + if ("image" in tabData) { + // We know that about:blank is safe to load in any remote type. Since + // SessionStore is triggered with about:blank, there must be a process + // flip. We will ignore the first about:blank load to prevent resetting the + // favicon that we have set earlier to avoid flickering and improve + // perceived performance. + if ( + !activePageData || + (activePageData && activePageData.url != "about:blank") + ) { + win.gBrowser.setIcon( + tab, + tabData.image, + undefined, + tabData.iconLoadingPrincipal + ); + } + lazy.TabStateCache.update(browser.permanentKey, { + image: null, + iconLoadingPrincipal: null, + }); + } + }, + + // This method deletes all the closedTabs matching userContextId. + _forgetTabsWithUserContextId(userContextId) { + for (let window of Services.wm.getEnumerator("navigator:browser")) { + let windowState = this._windows[window.__SSi]; + if (windowState) { + // In order to remove the tabs in the correct order, we store the + // indexes, into an array, then we revert the array and remove closed + // data from the last one going backward. + let indexes = []; + windowState._closedTabs.forEach((closedTab, index) => { + if (closedTab.state.userContextId == userContextId) { + indexes.push(index); + } + }); + + for (let index of indexes.reverse()) { + this.removeClosedTabData(windowState, windowState._closedTabs, index); + } + } + } + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + /** + * Restores the session state stored in LastSession. This will attempt + * to merge data into the current session. If a window was opened at startup + * with pinned tab(s), then the remaining data from the previous session for + * that window will be opened into that window. Otherwise new windows will + * be opened. + */ + restoreLastSession: function ssi_restoreLastSession() { + // Use the public getter since it also checks PB mode + if (!this.canRestoreLastSession) { + throw Components.Exception("Last session can not be restored"); + } + + Services.obs.notifyObservers(null, NOTIFY_INITIATING_MANUAL_RESTORE); + + // First collect each window with its id... + let windows = {}; + for (let window of this._browserWindows) { + if (window.__SS_lastSessionWindowID) { + windows[window.__SS_lastSessionWindowID] = window; + } + } + + let lastSessionState = LastSession.getState(); + + // This shouldn't ever be the case... + if (!lastSessionState.windows.length) { + throw Components.Exception( + "lastSessionState has no windows", + Cr.NS_ERROR_UNEXPECTED + ); + } + + // We're technically doing a restore, so set things up so we send the + // notification when we're done. We want to send "sessionstore-browser-state-restored". + this._restoreCount = lastSessionState.windows.length; + this._browserSetState = true; + + // We want to re-use the last opened window instead of opening a new one in + // the case where it's "empty" and not associated with a window in the session. + // We will do more processing via _prepWindowToRestoreInto if we need to use + // the lastWindow. + let lastWindow = this._getTopWindow(); + let canUseLastWindow = lastWindow && !lastWindow.__SS_lastSessionWindowID; + + // global data must be restored before restoreWindow is called so that + // it happens before observers are notified + this._globalState.setFromState(lastSessionState); + + let openWindows = []; + let windowsToOpen = []; + + // Restore session cookies. + lazy.SessionCookies.restore(lastSessionState.cookies || []); + + // Restore into windows or open new ones as needed. + for (let i = 0; i < lastSessionState.windows.length; i++) { + let winState = lastSessionState.windows[i]; + let lastSessionWindowID = winState.__lastSessionWindowID; + // delete lastSessionWindowID so we don't add that to the window again + delete winState.__lastSessionWindowID; + + // See if we can use an open window. First try one that is associated with + // the state we're trying to restore and then fallback to the last selected + // window. + let windowToUse = windows[lastSessionWindowID]; + if (!windowToUse && canUseLastWindow) { + windowToUse = lastWindow; + canUseLastWindow = false; + } + + let [canUseWindow, canOverwriteTabs] = + this._prepWindowToRestoreInto(windowToUse); + + // If there's a window already open that we can restore into, use that + if (canUseWindow) { + if (!PERSIST_SESSIONS) { + // Since we're not overwriting existing tabs, we want to merge _closedTabs, + // putting existing ones first. Then make sure we're respecting the max pref. + if (winState._closedTabs && winState._closedTabs.length) { + let curWinState = this._windows[windowToUse.__SSi]; + curWinState._closedTabs = curWinState._closedTabs.concat( + winState._closedTabs + ); + curWinState._closedTabs.splice( + this._max_tabs_undo, + curWinState._closedTabs.length + ); + } + } + // We don't restore window right away, just store its data. + // Later, these windows will be restored with newly opened windows. + this._updateWindowRestoreState(windowToUse, { + windows: [winState], + options: { overwriteTabs: canOverwriteTabs }, + }); + openWindows.push(windowToUse); + } else { + windowsToOpen.push(winState); + } + } + + // Actually restore windows in reversed z-order. + this._openWindows({ windows: windowsToOpen }).then(openedWindows => + this._restoreWindowsInReversedZOrder(openWindows.concat(openedWindows)) + ); + + // Merge closed windows from this session with ones from last session + if (lastSessionState._closedWindows) { + // reset window closedIds and any references to them from closed tabs + for (let closedWindow of lastSessionState._closedWindows) { + closedWindow.closedId = this._nextClosedId++; + if (closedWindow._closedTabs?.length) { + this._resetClosedTabIds( + closedWindow._closedTabs, + closedWindow.closedId + ); + } + } + this._closedWindows = this._closedWindows.concat( + lastSessionState._closedWindows + ); + this._capClosedWindows(); + this._closedObjectsChanged = true; + } + + lazy.DevToolsShim.restoreDevToolsSession(lastSessionState); + + // Set data that persists between sessions + this._recentCrashes = + (lastSessionState.session && lastSessionState.session.recentCrashes) || 0; + + // Update the session start time using the restored session state. + this._updateSessionStartTime(lastSessionState); + + LastSession.clear(); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + /** + * Revive a crashed tab and restore its state from before it crashed. + * + * @param aTab + * A linked to a crashed browser. This is a no-op if the + * browser hasn't actually crashed, or is not associated with a tab. + * This function will also throw if the browser happens to be remote. + */ + reviveCrashedTab(aTab) { + if (!aTab) { + throw new Error( + "SessionStore.reviveCrashedTab expected a tab, but got null." + ); + } + + const gBrowser = aTab.ownerGlobal.gBrowser; + let browser = aTab.linkedBrowser; + if (!this._crashedBrowsers.has(browser.permanentKey)) { + return; + } + + // Sanity check - the browser to be revived should not be remote + // at this point. + if (browser.isRemoteBrowser) { + throw new Error( + "SessionStore.reviveCrashedTab: " + + "Somehow a crashed browser is still remote." + ); + } + + // We put the browser at about:blank in case the user is + // restoring tabs on demand. This way, the user won't see + // a flash of the about:tabcrashed page after selecting + // the revived tab. + aTab.removeAttribute("crashed"); + gBrowser.tabContainer.updateTabIndicatorAttr(aTab); + + browser.loadURI(lazy.blankURI, { + triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({ + userContextId: aTab.userContextId, + }), + remoteTypeOverride: lazy.E10SUtils.NOT_REMOTE, + }); + + let data = lazy.TabState.collect(aTab, TAB_CUSTOM_VALUES.get(aTab)); + this.restoreTab(aTab, data, { + forceOnDemand: true, + }); + }, + + /** + * Revive all crashed tabs and reset the crashed tabs count to 0. + */ + reviveAllCrashedTabs() { + for (let window of Services.wm.getEnumerator("navigator:browser")) { + for (let tab of window.gBrowser.tabs) { + this.reviveCrashedTab(tab); + } + } + }, + + /** + * Retrieves the latest session history information for a tab. The cached data + * is returned immediately, but a callback may be provided that supplies + * up-to-date data when or if it is available. The callback is passed a single + * argument with data in the same format as the return value. + * + * @param tab tab to retrieve the session history for + * @param updatedCallback function to call with updated data as the single argument + * @returns a object containing 'index' specifying the current index, and an + * array 'entries' containing an object for each history item. + */ + getSessionHistory(tab, updatedCallback) { + if (updatedCallback) { + lazy.TabStateFlusher.flush(tab.linkedBrowser).then(() => { + let sessionHistory = this.getSessionHistory(tab); + if (sessionHistory) { + updatedCallback(sessionHistory); + } + }); + } + + // Don't continue if the tab was closed before TabStateFlusher.flush resolves. + if (tab.linkedBrowser) { + let tabState = lazy.TabState.collect(tab, TAB_CUSTOM_VALUES.get(tab)); + return { index: tabState.index - 1, entries: tabState.entries }; + } + return null; + }, + + /** + * See if aWindow is usable for use when restoring a previous session via + * restoreLastSession. If usable, prepare it for use. + * + * @param aWindow + * the window to inspect & prepare + * @returns [canUseWindow, canOverwriteTabs] + * canUseWindow: can the window be used to restore into + * canOverwriteTabs: all of the current tabs are home pages and we + * can overwrite them + */ + _prepWindowToRestoreInto: function ssi_prepWindowToRestoreInto(aWindow) { + if (!aWindow) { + return [false, false]; + } + + // We might be able to overwrite the existing tabs instead of just adding + // the previous session's tabs to the end. This will be set if possible. + let canOverwriteTabs = false; + + // Look at the open tabs in comparison to home pages. If all the tabs are + // home pages then we'll end up overwriting all of them. Otherwise we'll + // just close the tabs that match home pages. Tabs with the about:blank + // URI will always be overwritten. + let homePages = ["about:blank"]; + let removableTabs = []; + let tabbrowser = aWindow.gBrowser; + let startupPref = this._prefBranch.getIntPref("startup.page"); + if (startupPref == 1) { + homePages = homePages.concat(lazy.HomePage.get(aWindow).split("|")); + } + + for (let i = tabbrowser._numPinnedTabs; i < tabbrowser.tabs.length; i++) { + let tab = tabbrowser.tabs[i]; + if (homePages.includes(tab.linkedBrowser.currentURI.spec)) { + removableTabs.push(tab); + } + } + + if ( + tabbrowser.tabs.length > tabbrowser.visibleTabs.length && + tabbrowser.visibleTabs.length === removableTabs.length + ) { + // If all the visible tabs are also removable and the selected tab is hidden or removeable, we will later remove + // all "removable" tabs causing the browser to automatically close because the only tab left is hidden. + // To prevent the browser from automatically closing, we will leave one other visible tab open. + removableTabs.shift(); + } + + if (tabbrowser.tabs.length == removableTabs.length) { + canOverwriteTabs = true; + } else { + // If we're not overwriting all of the tabs, then close the home tabs. + for (let i = removableTabs.length - 1; i >= 0; i--) { + tabbrowser.removeTab(removableTabs.pop(), { animate: false }); + } + } + + return [true, canOverwriteTabs]; + }, + + /* ........ Saving Functionality .............. */ + + /** + * Store window dimensions, visibility, sidebar + * @param aWindow + * Window reference + */ + _updateWindowFeatures: function ssi_updateWindowFeatures(aWindow) { + var winData = this._windows[aWindow.__SSi]; + + WINDOW_ATTRIBUTES.forEach(function (aAttr) { + winData[aAttr] = this._getWindowDimension(aWindow, aAttr); + }, this); + + if (winData.sizemode != "minimized") { + winData.sizemodeBeforeMinimized = winData.sizemode; + } + + var hidden = WINDOW_HIDEABLE_FEATURES.filter(function (aItem) { + return aWindow[aItem] && !aWindow[aItem].visible; + }); + if (hidden.length) { + winData.hidden = hidden.join(","); + } else if (winData.hidden) { + delete winData.hidden; + } + + 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 workspaceID = aWindow.getWorkspaceID(); + if (workspaceID) { + winData.workspaceID = workspaceID; + } + }, + + /** + * gather session data as object + * @param aUpdateAll + * Bool update all windows + * @returns object + */ + getCurrentState(aUpdateAll) { + this._handleClosedWindows().then(() => { + this._notifyOfClosedObjectsChange(); + }); + + var activeWindow = this._getTopWindow(); + + TelemetryStopwatch.start("FX_SESSION_RESTORE_COLLECT_ALL_WINDOWS_DATA_MS"); + if (lazy.RunState.isRunning) { + // update the data for all windows with activities since the last save operation. + let index = 0; + for (let window of this._orderedBrowserWindows) { + if (!this._isWindowLoaded(window)) { + // window data is still in _statesToRestore + continue; + } + if (aUpdateAll || DirtyWindows.has(window) || window == activeWindow) { + this._collectWindowData(window); + } else { + // always update the window features (whose change alone never triggers a save operation) + this._updateWindowFeatures(window); + } + this._windows[window.__SSi].zIndex = ++index; + } + DirtyWindows.clear(); + } + TelemetryStopwatch.finish("FX_SESSION_RESTORE_COLLECT_ALL_WINDOWS_DATA_MS"); + + // An array that at the end will hold all current window data. + var total = []; + // The ids of all windows contained in 'total' in the same order. + var ids = []; + // The number of window that are _not_ popups. + var nonPopupCount = 0; + var ix; + + // collect the data for all windows + for (ix in this._windows) { + if (this._windows[ix]._restoring) { + // window data is still in _statesToRestore + continue; + } + total.push(this._windows[ix]); + ids.push(ix); + if (!this._windows[ix].isPopup) { + nonPopupCount++; + } + } + + // collect the data for all windows yet to be restored + for (ix in this._statesToRestore) { + for (let winData of this._statesToRestore[ix].windows) { + total.push(winData); + if (!winData.isPopup) { + nonPopupCount++; + } + } + } + + // shallow copy this._closedWindows to preserve current state + let lastClosedWindowsCopy = this._closedWindows.slice(); + + if (AppConstants.platform != "macosx") { + // If no non-popup browser window remains open, return the state of the last + // closed window(s). We only want to do this when we're actually "ending" + // the session. + // XXXzpao We should do this for _restoreLastWindow == true, but that has + // its own check for popups. c.f. bug 597619 + if ( + nonPopupCount == 0 && + !!lastClosedWindowsCopy.length && + lazy.RunState.isQuitting + ) { + // prepend the last non-popup browser window, so that if the user loads more tabs + // at startup we don't accidentally add them to a popup window + do { + total.unshift(lastClosedWindowsCopy.shift()); + } while (total[0].isPopup && lastClosedWindowsCopy.length); + } + } + + if (activeWindow) { + this.activeWindowSSiCache = activeWindow.__SSi || ""; + } + ix = ids.indexOf(this.activeWindowSSiCache); + // We don't want to restore focus to a minimized window or a window which had all its + // tabs stripped out (doesn't exist). + if (ix != -1 && total[ix] && total[ix].sizemode == "minimized") { + ix = -1; + } + + let session = { + lastUpdate: Date.now(), + startTime: this._sessionStartTime, + recentCrashes: this._recentCrashes, + }; + + let state = { + version: ["sessionrestore", FORMAT_VERSION], + windows: total, + selectedWindow: ix + 1, + _closedWindows: lastClosedWindowsCopy, + session, + global: this._globalState.getState(), + }; + + // Collect and store session cookies. + state.cookies = lazy.SessionCookies.collect(); + + lazy.DevToolsShim.saveDevToolsSession(state); + + // Persist the last session if we deferred restoring it + if (LastSession.canRestore) { + state.lastSessionState = LastSession.getState(); + } + + // If we were called by the SessionSaver and started with only a private + // window we want to pass the deferred initial state to not lose the + // previous session. + if (this._deferredInitialState) { + state.deferredInitialState = this._deferredInitialState; + } + + return state; + }, + + /** + * serialize session data for a window + * @param aWindow + * Window reference + * @returns string + */ + _getWindowState: function ssi_getWindowState(aWindow) { + if (!this._isWindowLoaded(aWindow)) { + return this._statesToRestore[WINDOW_RESTORE_IDS.get(aWindow)]; + } + + if (lazy.RunState.isRunning) { + this._collectWindowData(aWindow); + } + + return { windows: [this._windows[aWindow.__SSi]] }; + }, + + /** + * Gathers data about a window and its tabs, and updates its + * entry in this._windows. + * + * @param aWindow + * Window references. + * @returns a Map mapping the browser tabs from aWindow to the tab + * entry that was put into the window data in this._windows. + */ + _collectWindowData: function ssi_collectWindowData(aWindow) { + let tabMap = new Map(); + + if (!this._isWindowLoaded(aWindow)) { + return tabMap; + } + + let tabbrowser = aWindow.gBrowser; + let tabs = tabbrowser.tabs; + let winData = this._windows[aWindow.__SSi]; + let tabsData = (winData.tabs = []); + + // update the internal state data for this window + for (let tab of tabs) { + if (tab == aWindow.FirefoxViewHandler.tab) { + continue; + } + let tabData = lazy.TabState.collect(tab, TAB_CUSTOM_VALUES.get(tab)); + tabMap.set(tab, tabData); + tabsData.push(tabData); + } + + let selectedIndex = tabbrowser.tabbox.selectedIndex + 1; + // We don't store the Firefox View tab in Session Store, so if it was the last selected "tab" when + // a window is closed, point to the first item in the tab strip instead (it will never be the Firefox View tab, + // since it's only inserted into the tab strip after it's selected). + if (aWindow.FirefoxViewHandler.tab?.selected) { + selectedIndex = 1; + winData.title = tabbrowser.tabs[0].label; + } + winData.selected = selectedIndex; + + this._updateWindowFeatures(aWindow); + + // Make sure we keep __SS_lastSessionWindowID around for cases like entering + // or leaving PB mode. + if (aWindow.__SS_lastSessionWindowID) { + this._windows[aWindow.__SSi].__lastSessionWindowID = + aWindow.__SS_lastSessionWindowID; + } + + DirtyWindows.remove(aWindow); + return tabMap; + }, + + /* ........ Restoring Functionality .............. */ + + /** + * Open windows with data + * + * @param root + * Windows data + * @returns a promise resolved when all windows have been opened + */ + _openWindows(root) { + let windowsOpened = []; + for (let winData of root.windows) { + if (!winData || !winData.tabs || !winData.tabs[0]) { + this._restoreCount--; + continue; + } + windowsOpened.push(this._openWindowWithState({ windows: [winData] })); + } + let windowOpenedPromises = []; + for (const openedWindow of windowsOpened) { + let deferred = WINDOW_SHOWING_PROMISES.get(openedWindow); + windowOpenedPromises.push(deferred.promise); + } + return Promise.all(windowOpenedPromises); + }, + + /** reset closedId's from previous sessions to ensure these IDs are unique + * @param tabData + * an array of data to be restored + * @param {String} windowId + * The SessionStore id for the window these tabs should be associated with + * @returns the updated tabData array + */ + _resetClosedTabIds(tabData, windowId) { + for (let entry of tabData) { + entry.closedId = this._nextClosedId++; + entry.sourceWindowId = windowId; + } + return tabData; + }, + /** + * restore features to a single window + * @param aWindow + * Window reference to the window to use for restoration + * @param winData + * JS object + * @param aOptions.overwriteTabs + * to overwrite existing tabs w/ new ones + * @param aOptions.firstWindow + * if this is the first non-private window we're + * restoring in this session, that might open an + * external link as well + */ + restoreWindow: function ssi_restoreWindow(aWindow, winData, aOptions = {}) { + let overwriteTabs = aOptions && aOptions.overwriteTabs; + let firstWindow = aOptions && aOptions.firstWindow; + + // initialize window if necessary + if (aWindow && (!aWindow.__SSi || !this._windows[aWindow.__SSi])) { + this.onLoad(aWindow); + } + + TelemetryStopwatch.start("FX_SESSION_RESTORE_RESTORE_WINDOW_MS"); + + // We're not returning from this before we end up calling restoreTabs + // for this window, so make sure we send the SSWindowStateBusy event. + this._sendWindowRestoringNotification(aWindow); + this._setWindowStateBusy(aWindow); + + if (winData.workspaceID) { + aWindow.moveToWorkspace(winData.workspaceID); + } + + if (!winData.tabs) { + winData.tabs = []; + // don't restore a single blank tab when we've had an external + // URL passed in for loading at startup (cf. bug 357419) + } else if ( + firstWindow && + !overwriteTabs && + winData.tabs.length == 1 && + (!winData.tabs[0].entries || !winData.tabs[0].entries.length) + ) { + winData.tabs = []; + } + + // See SessionStoreInternal.restoreTabs for a description of what + // selectTab represents. + let selectTab = 0; + if (overwriteTabs) { + selectTab = parseInt(winData.selected || 1, 10); + selectTab = Math.max(selectTab, 1); + selectTab = Math.min(selectTab, winData.tabs.length); + } + + let tabbrowser = aWindow.gBrowser; + + // disable smooth scrolling while adding, moving, removing and selecting tabs + let arrowScrollbox = tabbrowser.tabContainer.arrowScrollbox; + let smoothScroll = arrowScrollbox.smoothScroll; + arrowScrollbox.smoothScroll = false; + + // We need to keep track of the initially open tabs so that they + // can be moved to the end of the restored tabs. + let initialTabs; + if (!overwriteTabs && firstWindow) { + initialTabs = Array.from(tabbrowser.tabs); + } + + // Get rid of tabs that aren't needed anymore. + if (overwriteTabs) { + for (let i = tabbrowser.browsers.length - 1; i >= 0; i--) { + if (!tabbrowser.tabs[i].selected) { + tabbrowser.removeTab(tabbrowser.tabs[i]); + } + } + } + + let restoreTabsLazily = + this._prefBranch.getBoolPref("sessionstore.restore_tabs_lazily") && + this._restore_on_demand; + + if (winData.tabs.length) { + var tabs = tabbrowser.createTabsForSessionRestore( + restoreTabsLazily, + selectTab, + winData.tabs + ); + } + + // Move the originally open tabs to the end. + if (initialTabs) { + let endPosition = tabbrowser.tabs.length - 1; + for (let i = 0; i < initialTabs.length; i++) { + tabbrowser.unpinTab(initialTabs[i]); + tabbrowser.moveTabTo(initialTabs[i], endPosition); + } + } + + // We want to correlate the window with data from the last session, so + // assign another id if we have one. Otherwise clear so we don't do + // anything with it. + delete aWindow.__SS_lastSessionWindowID; + if (winData.__lastSessionWindowID) { + aWindow.__SS_lastSessionWindowID = winData.__lastSessionWindowID; + } + + if (overwriteTabs) { + delete this._windows[aWindow.__SSi].extData; + } + + // Restore cookies from legacy sessions, i.e. before bug 912717. + lazy.SessionCookies.restore(winData.cookies || []); + + if (winData.extData) { + if (!this._windows[aWindow.__SSi].extData) { + this._windows[aWindow.__SSi].extData = {}; + } + for (var key in winData.extData) { + this._windows[aWindow.__SSi].extData[key] = winData.extData[key]; + } + } + + let newClosedTabsData; + if (winData._closedTabs) { + newClosedTabsData = winData._closedTabs; + this._resetClosedTabIds(newClosedTabsData, aWindow.__SSi); + } else { + newClosedTabsData = []; + } + + let newLastClosedTabGroupCount = winData._lastClosedTabGroupCount || -1; + + if (overwriteTabs || firstWindow) { + // Overwrite existing closed tabs data when overwriteTabs=true + // or we're the first window to be restored. + this._windows[aWindow.__SSi]._closedTabs = newClosedTabsData; + } else if (this._max_tabs_undo > 0) { + // We preserve tabs between sessions so we just want to filter out any previously open tabs that + // were added to the _closedTabs list prior to restoreLastSession + if (PERSIST_SESSIONS) { + newClosedTabsData = this._windows[aWindow.__SSi]._closedTabs.filter( + tab => !tab.removeAfterRestore + ); + } else { + newClosedTabsData = newClosedTabsData.concat( + this._windows[aWindow.__SSi]._closedTabs + ); + } + + // ... and make sure that we don't exceed the max number of closed tabs + // we can restore. + this._windows[aWindow.__SSi]._closedTabs = newClosedTabsData.slice( + 0, + this._max_tabs_undo + ); + } + // Because newClosedTabsData are put in first, we need to + // copy also the _lastClosedTabGroupCount. + this._windows[aWindow.__SSi]._lastClosedTabGroupCount = + newLastClosedTabGroupCount; + + if (!this._isWindowLoaded(aWindow)) { + // from now on, the data will come from the actual window + delete this._statesToRestore[WINDOW_RESTORE_IDS.get(aWindow)]; + WINDOW_RESTORE_IDS.delete(aWindow); + delete this._windows[aWindow.__SSi]._restoring; + } + + // Restore tabs, if any. + if (winData.tabs.length) { + this.restoreTabs(aWindow, tabs, winData.tabs, selectTab); + } + + // set smoothScroll back to the original value + arrowScrollbox.smoothScroll = smoothScroll; + + TelemetryStopwatch.finish("FX_SESSION_RESTORE_RESTORE_WINDOW_MS"); + + this._setWindowStateReady(aWindow); + + this._sendWindowRestoredNotification(aWindow); + + Services.obs.notifyObservers(aWindow, NOTIFY_SINGLE_WINDOW_RESTORED); + + this._sendRestoreCompletedNotifications(); + }, + + /** + * Prepare connection to host beforehand. + * + * @param tab + * Tab we are loading from. + * @param url + * URL of a host. + * @returns a flag indicates whether a connection has been made + */ + prepareConnectionToHost(tab, url) { + if (url && !url.startsWith("about:")) { + let principal = Services.scriptSecurityManager.createNullPrincipal({ + userContextId: tab.userContextId, + }); + let sc = Services.io.QueryInterface(Ci.nsISpeculativeConnect); + let uri = Services.io.newURI(url); + try { + sc.speculativeConnect(uri, principal, null, false); + return true; + } catch (error) { + // Can't setup speculative connection for this url. + console.error(error); + return false; + } + } + return false; + }, + + /** + * Make a connection to a host when users hover mouse on a tab. + * This will also set a flag in the tab to prevent us from speculatively + * connecting a second time. + * + * @param tab + * a tab to speculatively connect on mouse hover. + */ + speculativeConnectOnTabHover(tab) { + let tabState = TAB_LAZY_STATES.get(tab); + if (tabState && !tabState.connectionPrepared) { + let url = this.getLazyTabValue(tab, "url"); + let prepared = this.prepareConnectionToHost(tab, url); + // This is used to test if a connection has been made beforehand. + if (gDebuggingEnabled) { + tab.__test_connection_prepared = prepared; + tab.__test_connection_url = url; + } + // A flag indicate that we've prepared a connection for this tab and + // if is called again, we shouldn't prepare another connection. + tabState.connectionPrepared = true; + } + }, + + /** + * This function will restore window features and then retore window data. + * + * @param windows + * ordered array of windows to restore + */ + _restoreWindowsFeaturesAndTabs(windows) { + // First, we restore window features, so that when users start interacting + // with a window, we don't steal the window focus. + for (let window of windows) { + let state = this._statesToRestore[WINDOW_RESTORE_IDS.get(window)]; + this.restoreWindowFeatures(window, state.windows[0]); + } + + // Then we restore data into windows. + for (let window of windows) { + let state = this._statesToRestore[WINDOW_RESTORE_IDS.get(window)]; + this.restoreWindow( + window, + state.windows[0], + state.options || { overwriteTabs: true } + ); + WINDOW_RESTORE_ZINDICES.delete(window); + } + }, + + /** + * This function will restore window in reversed z-index, so that users will + * be presented with most recently used window first. + * + * @param windows + * unordered array of windows to restore + */ + _restoreWindowsInReversedZOrder(windows) { + windows.sort( + (a, b) => + (WINDOW_RESTORE_ZINDICES.get(a) || 0) - + (WINDOW_RESTORE_ZINDICES.get(b) || 0) + ); + + this.windowToFocus = windows[0]; + this._restoreWindowsFeaturesAndTabs(windows); + }, + + /** + * Restore multiple windows using the provided state. + * @param aWindow + * Window reference to the first window to use for restoration. + * Additionally required windows will be opened. + * @param aState + * JS object or JSON string + * @param aOptions.overwriteTabs + * to overwrite existing tabs w/ new ones + * @param aOptions.firstWindow + * if this is the first non-private window we're + * restoring in this session, that might open an + * external link as well + */ + restoreWindows: function ssi_restoreWindows(aWindow, aState, aOptions = {}) { + // initialize window if necessary + if (aWindow && (!aWindow.__SSi || !this._windows[aWindow.__SSi])) { + this.onLoad(aWindow); + } + + let root; + try { + root = typeof aState == "string" ? JSON.parse(aState) : aState; + } catch (ex) { + // invalid state object - don't restore anything + this._log.error(ex); + this._sendRestoreCompletedNotifications(); + return; + } + + // Restore closed windows if any. + if (root._closedWindows) { + this._closedWindows = root._closedWindows; + // reset window closedIds and any references to them from closed tabs + for (let closedWindow of this._closedWindows) { + closedWindow.closedId = this._nextClosedId++; + if (closedWindow._closedTabs?.length) { + this._resetClosedTabIds( + closedWindow._closedTabs, + closedWindow.closedId + ); + } + } + this._closedObjectsChanged = true; + } + + // We're done here if there are no windows. + if (!root.windows || !root.windows.length) { + this._sendRestoreCompletedNotifications(); + return; + } + + let firstWindowData = root.windows.splice(0, 1); + // Store the restore state and restore option of the current window, + // so that the window can be restored in reversed z-order. + this._updateWindowRestoreState(aWindow, { + windows: firstWindowData, + options: aOptions, + }); + + // Begin the restoration: First open all windows in creation order. After all + // windows have opened, we restore states to windows in reversed z-order. + this._openWindows(root).then(windows => { + // We want to add current window to opened window, so that this window will be + // restored in reversed z-order. (We add the window to first position, in case + // no z-indices are found, that window will be restored first.) + windows.unshift(aWindow); + + this._restoreWindowsInReversedZOrder(windows); + }); + + lazy.DevToolsShim.restoreDevToolsSession(aState); + }, + + /** + * Manage history restoration for a window + * @param aWindow + * Window to restore the tabs into + * @param aTabs + * Array of tab references + * @param aTabData + * Array of tab data + * @param aSelectTab + * Index of the tab to select. This is a 1-based index where "1" + * indicates the first tab should be selected, and "0" indicates that + * the currently selected tab will not be changed. + */ + restoreTabs(aWindow, aTabs, aTabData, aSelectTab) { + var tabbrowser = aWindow.gBrowser; + + let numTabsToRestore = aTabs.length; + let numTabsInWindow = tabbrowser.tabs.length; + let tabsDataArray = this._windows[aWindow.__SSi].tabs; + + // Update the window state in case we shut down without being notified. + // Individual tab states will be taken care of by restoreTab() below. + if (numTabsInWindow == numTabsToRestore) { + // Remove all previous tab data. + tabsDataArray.length = 0; + } else { + // Remove all previous tab data except tabs that should not be overriden. + tabsDataArray.splice(numTabsInWindow - numTabsToRestore); + } + + // Remove items from aTabData if there is no corresponding tab: + if (numTabsInWindow < tabsDataArray.length) { + tabsDataArray.length = numTabsInWindow; + } + + // Ensure the tab data array has items for each of the tabs + this._ensureNoNullsInTabDataList( + tabbrowser.tabs, + tabsDataArray, + numTabsInWindow - 1 + ); + + if (aSelectTab > 0 && aSelectTab <= aTabs.length) { + // Update the window state in case we shut down without being notified. + this._windows[aWindow.__SSi].selected = aSelectTab; + } + + // If we restore the selected tab, make sure it goes first. + let selectedIndex = aTabs.indexOf(tabbrowser.selectedTab); + if (selectedIndex > -1) { + this.restoreTab(tabbrowser.selectedTab, aTabData[selectedIndex]); + } + + // Restore all tabs. + for (let t = 0; t < aTabs.length; t++) { + if (t != selectedIndex) { + this.restoreTab(aTabs[t], aTabData[t]); + } + } + }, + + // In case we didn't collect/receive data for any tabs yet we'll have to + // fill the array with at least empty tabData objects until |_tPos| or + // we'll end up with |null| entries. + _ensureNoNullsInTabDataList(tabElements, tabDataList, changedTabPos) { + let initialDataListLength = tabDataList.length; + if (changedTabPos < initialDataListLength) { + return; + } + // Add items to the end. + while (tabDataList.length < changedTabPos) { + let existingTabEl = tabElements[tabDataList.length]; + tabDataList.push({ + entries: [], + lastAccessed: existingTabEl.lastAccessed, + }); + } + // Ensure the pre-existing items are non-null. + for (let i = 0; i < initialDataListLength; i++) { + if (!tabDataList[i]) { + let existingTabEl = tabElements[i]; + tabDataList[i] = { + entries: [], + lastAccessed: existingTabEl.lastAccessed, + }; + } + } + }, + + // Restores the given tab state for a given tab. + restoreTab(tab, tabData, options = {}) { + let browser = tab.linkedBrowser; + + if (TAB_STATE_FOR_BROWSER.has(browser)) { + console.error("Must reset tab before calling restoreTab."); + return; + } + + let loadArguments = options.loadArguments; + let window = tab.ownerGlobal; + let tabbrowser = window.gBrowser; + let forceOnDemand = options.forceOnDemand; + let isRemotenessUpdate = options.isRemotenessUpdate; + + let willRestoreImmediately = + options.restoreImmediately || tabbrowser.selectedBrowser == browser; + + let isBrowserInserted = browser.isConnected; + + // Increase the busy state counter before modifying the tab. + this._setWindowStateBusy(window); + + // It's important to set the window state to dirty so that + // we collect their data for the first time when saving state. + DirtyWindows.add(window); + + if (!tab.hasOwnProperty("_tPos")) { + throw new Error( + "Shouldn't be trying to restore a tab that has no position" + ); + } + // Update the tab state in case we shut down without being notified. + this._windows[window.__SSi].tabs[tab._tPos] = tabData; + + // Prepare the tab so that it can be properly restored. We'll also attach + // a copy of the tab's data in case we close it before it's been restored. + // Anything that dispatches an event to external consumers must happen at + // the end of this method, to make sure that the tab/browser object is in a + // reliable and consistent state. + + if (tabData.lastAccessed) { + tab.updateLastAccessed(tabData.lastAccessed); + } + + if ("attributes" in tabData) { + // Ensure that we persist tab attributes restored from previous sessions. + Object.keys(tabData.attributes).forEach(a => + lazy.TabAttributes.persist(a) + ); + } + + if (!tabData.entries) { + tabData.entries = []; + } + if (tabData.extData) { + TAB_CUSTOM_VALUES.set(tab, Cu.cloneInto(tabData.extData, {})); + } else { + TAB_CUSTOM_VALUES.delete(tab); + } + + // Tab is now open. + delete tabData.closedAt; + + // Ensure the index is in bounds. + let activeIndex = (tabData.index || tabData.entries.length) - 1; + activeIndex = Math.min(activeIndex, tabData.entries.length - 1); + activeIndex = Math.max(activeIndex, 0); + + // Save the index in case we updated it above. + tabData.index = activeIndex + 1; + + tab.setAttribute("pending", "true"); + + // If we're restoring this tab, it certainly shouldn't be in + // the ignored set anymore. + this._crashedBrowsers.delete(browser.permanentKey); + + // If we're in the midst of performing a process flip, then we must + // have initiated a navigation. This means that these userTyped* + // values are now out of date. + if ( + options.restoreContentReason == + RESTORE_TAB_CONTENT_REASON.NAVIGATE_AND_RESTORE + ) { + delete tabData.userTypedValue; + delete tabData.userTypedClear; + } + + // Update the persistent tab state cache with |tabData| information. + lazy.TabStateCache.update(browser.permanentKey, { + // NOTE: Copy the entries array shallowly, so as to not screw with the + // original tabData's history when getting history updates. + history: { entries: [...tabData.entries], index: tabData.index }, + scroll: tabData.scroll || null, + storage: tabData.storage || null, + formdata: tabData.formdata || null, + disallow: tabData.disallow || null, + userContextId: tabData.userContextId || 0, + + // This information is only needed until the tab has finished restoring. + // When that's done it will be removed from the cache and we always + // collect it in TabState._collectBaseTabData(). + image: tabData.image || "", + iconLoadingPrincipal: tabData.iconLoadingPrincipal || null, + searchMode: tabData.searchMode || null, + userTypedValue: tabData.userTypedValue || "", + userTypedClear: tabData.userTypedClear || 0, + }); + + // Restore tab attributes. + if ("attributes" in tabData) { + lazy.TabAttributes.set(tab, tabData.attributes); + } + + if (isBrowserInserted) { + // Start a new epoch to discard all frame script messages relating to a + // previous epoch. All async messages that are still on their way to chrome + // will be ignored and don't override any tab data set when restoring. + let epoch = this.startNextEpoch(browser.permanentKey); + + // Ensure that the tab will get properly restored in the event the tab + // crashes while restoring. But don't set this on lazy browsers as + // restoreTab will get called again when the browser is instantiated. + TAB_STATE_FOR_BROWSER.set(browser, TAB_STATE_NEEDS_RESTORE); + + this._sendRestoreHistory(browser, { + tabData, + epoch, + loadArguments, + isRemotenessUpdate, + }); + + // This could cause us to ignore MAX_CONCURRENT_TAB_RESTORES a bit, but + // it ensures each window will have its selected tab loaded. + if (willRestoreImmediately) { + this.restoreTabContent(tab, options); + } else if (!forceOnDemand) { + TabRestoreQueue.add(tab); + // Check if a tab is in queue and will be restored + // after the currently loading tabs. If so, prepare + // a connection to host to speed up page loading. + if (TabRestoreQueue.willRestoreSoon(tab)) { + if (activeIndex in tabData.entries) { + let url = tabData.entries[activeIndex].url; + let prepared = this.prepareConnectionToHost(tab, url); + if (gDebuggingEnabled) { + tab.__test_connection_prepared = prepared; + tab.__test_connection_url = url; + } + } + } + this.restoreNextTab(); + } + } else { + // TAB_LAZY_STATES holds data for lazy-browser tabs to proxy for + // data unobtainable from the unbound browser. This only applies to lazy + // browsers and will be removed once the browser is inserted in the document. + // This must preceed `updateTabLabelAndIcon` call for required data to be present. + let url = "about:blank"; + let title = ""; + + if (activeIndex in tabData.entries) { + url = tabData.entries[activeIndex].url; + title = tabData.entries[activeIndex].title || url; + } + TAB_LAZY_STATES.set(tab, { + url, + title, + userTypedValue: tabData.userTypedValue || "", + userTypedClear: tabData.userTypedClear || 0, + }); + } + + // Most of tabData has been restored, now continue with restoring + // attributes that may trigger external events. + + if (tabData.pinned) { + tabbrowser.pinTab(tab); + } else { + tabbrowser.unpinTab(tab); + } + + if (tabData.hidden) { + tabbrowser.hideTab(tab); + } else { + tabbrowser.showTab(tab); + } + + if (!!tabData.muted != browser.audioMuted) { + tab.toggleMuteAudio(tabData.muteReason); + } + + if (tab.hasAttribute("customizemode")) { + window.gCustomizeMode.setTab(tab); + } + + // Update tab label and icon to show something + // while we wait for the messages to be processed. + this.updateTabLabelAndIcon(tab, tabData); + + // Decrease the busy state counter after we're done. + this._setWindowStateReady(window); + }, + + /** + * Kicks off restoring the given tab. + * + * @param aTab + * the tab to restore + * @param aOptions + * optional arguments used when performing process switch during load + */ + restoreTabContent(aTab, aOptions = {}) { + let loadArguments = aOptions.loadArguments; + if (aTab.hasAttribute("customizemode") && !loadArguments) { + return; + } + + let browser = aTab.linkedBrowser; + let window = aTab.ownerGlobal; + let tabbrowser = window.gBrowser; + let tabData = lazy.TabState.clone(aTab, TAB_CUSTOM_VALUES.get(aTab)); + let activeIndex = tabData.index - 1; + let activePageData = tabData.entries[activeIndex] || null; + let uri = activePageData ? activePageData.url || null : null; + + this.markTabAsRestoring(aTab); + + let isRemotenessUpdate = aOptions.isRemotenessUpdate; + let explicitlyUpdateRemoteness = !Services.appinfo.sessionHistoryInParent; + // If we aren't already updating the browser's remoteness, check if it's + // necessary. + if (explicitlyUpdateRemoteness && !isRemotenessUpdate) { + isRemotenessUpdate = tabbrowser.updateBrowserRemotenessByURL( + browser, + uri + ); + + if (isRemotenessUpdate) { + // We updated the remoteness, so we need to send the history down again. + // + // Start a new epoch to discard all frame script messages relating to a + // previous epoch. All async messages that are still on their way to chrome + // will be ignored and don't override any tab data set when restoring. + let epoch = this.startNextEpoch(browser.permanentKey); + + this._sendRestoreHistory(browser, { + tabData, + epoch, + loadArguments, + isRemotenessUpdate, + }); + } + } + + this._sendRestoreTabContent(browser, { + loadArguments, + isRemotenessUpdate, + reason: + aOptions.restoreContentReason || RESTORE_TAB_CONTENT_REASON.SET_STATE, + }); + + // Focus the tab's content area, unless the restore is for a new tab URL or + // was triggered by a DocumentChannel process switch. + if ( + aTab.selected && + !window.isBlankPageURL(uri) && + !aOptions.isRemotenessUpdate + ) { + browser.focus(); + } + }, + + /** + * Marks a given pending tab as restoring. + * + * @param aTab + * the pending tab to mark as restoring + */ + markTabAsRestoring(aTab) { + let browser = aTab.linkedBrowser; + if (TAB_STATE_FOR_BROWSER.get(browser) != TAB_STATE_NEEDS_RESTORE) { + throw new Error("Given tab is not pending."); + } + + // Make sure that this tab is removed from the priority queue. + TabRestoreQueue.remove(aTab); + + // Increase our internal count. + this._tabsRestoringCount++; + + // Set this tab's state to restoring + TAB_STATE_FOR_BROWSER.set(browser, TAB_STATE_RESTORING); + aTab.removeAttribute("pending"); + }, + + /** + * This _attempts_ to restore the next available tab. If the restore fails, + * then we will attempt the next one. + * There are conditions where this won't do anything: + * if we're in the process of quitting + * if there are no tabs to restore + * if we have already reached the limit for number of tabs to restore + */ + restoreNextTab: function ssi_restoreNextTab() { + // If we call in here while quitting, we don't actually want to do anything + if (lazy.RunState.isQuitting) { + return; + } + + // Don't exceed the maximum number of concurrent tab restores. + if (this._tabsRestoringCount >= MAX_CONCURRENT_TAB_RESTORES) { + return; + } + + let tab = TabRestoreQueue.shift(); + if (tab) { + this.restoreTabContent(tab); + } + }, + + /** + * Restore visibility and dimension features to a window + * @param aWindow + * Window reference + * @param aWinData + * Object containing session data for the window + */ + restoreWindowFeatures: function ssi_restoreWindowFeatures(aWindow, aWinData) { + var hidden = aWinData.hidden ? aWinData.hidden.split(",") : []; + WINDOW_HIDEABLE_FEATURES.forEach(function (aItem) { + aWindow[aItem].visible = !hidden.includes(aItem); + }); + + if (aWinData.isPopup) { + this._windows[aWindow.__SSi].isPopup = true; + if (aWindow.gURLBar) { + aWindow.gURLBar.readOnly = true; + } + } else { + delete this._windows[aWindow.__SSi].isPopup; + if (aWindow.gURLBar) { + aWindow.gURLBar.readOnly = false; + } + } + + aWindow.setTimeout(() => { + this.restoreDimensions( + aWindow, + +(aWinData.width || 0), + +(aWinData.height || 0), + "screenX" in aWinData ? +aWinData.screenX : NaN, + "screenY" in aWinData ? +aWinData.screenY : NaN, + aWinData.sizemode || "", + aWinData.sizemodeBeforeMinimized || "", + aWinData.sidebar || "" + ); + }, 0); + }, + + /** + * Restore a window's dimensions + * @param aWidth + * Window width in desktop pixels + * @param aHeight + * Window height in desktop pixels + * @param aLeft + * Window left in desktop pixels + * @param aTop + * Window top in desktop pixels + * @param aSizeMode + * 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, + aWidth, + aHeight, + aLeft, + aTop, + aSizeMode, + aSizeModeBeforeMinimized, + aSidebar + ) { + var win = aWindow; + var _this = this; + function win_(aName) { + return _this._getWindowDimension(win, aName); + } + + const dwu = win.windowUtils; + // find available space on the screen where this window is being placed + let screen = lazy.gScreenManager.screenForRect( + aLeft, + aTop, + aWidth, + aHeight + ); + if (screen) { + let screenLeft = {}, + screenTop = {}, + screenWidth = {}, + screenHeight = {}; + screen.GetAvailRectDisplayPix( + screenLeft, + screenTop, + screenWidth, + screenHeight + ); + + // We store aLeft / aTop (screenX/Y) in desktop pixels, see + // _getWindowDimension. + screenLeft = screenLeft.value; + screenTop = screenTop.value; + screenWidth = screenWidth.value; + screenHeight = screenHeight.value; + + let screenBottom = screenTop + screenHeight; + let screenRight = screenLeft + screenWidth; + + // NOTE: contentsScaleFactor is the desktopToDeviceScale of the screen. + // Naming could be more consistent here. + let cssToDesktopScale = + screen.defaultCSSScaleFactor / screen.contentsScaleFactor; + + let winSlopX = win.screenEdgeSlopX * cssToDesktopScale; + let winSlopY = win.screenEdgeSlopY * cssToDesktopScale; + + let minSlop = MIN_SCREEN_EDGE_SLOP * cssToDesktopScale; + let slopX = Math.max(minSlop, winSlopX); + let slopY = Math.max(minSlop, winSlopY); + + // Pull the window within the screen's bounds (allowing a little slop + // for windows that may be deliberately placed with their border off-screen + // as when Win10 "snaps" a window to the left/right edge -- bug 1276516). + // First, ensure the left edge is large enough... + if (aLeft < screenLeft - slopX) { + aLeft = screenLeft - winSlopX; + } + // Then check the resulting right edge, and reduce it if necessary. + let right = aLeft + aWidth * cssToDesktopScale; + if (right > screenRight + slopX) { + right = screenRight + winSlopX; + // See if we can move the left edge leftwards to maintain width. + if (aLeft > screenLeft) { + aLeft = Math.max( + right - aWidth * cssToDesktopScale, + screenLeft - winSlopX + ); + } + } + // Finally, update aWidth to account for the adjusted left and right + // edges, and convert it back to CSS pixels on the target screen. + aWidth = (right - aLeft) / cssToDesktopScale; + + // And do the same in the vertical dimension. + if (aTop < screenTop - slopY) { + aTop = screenTop - winSlopY; + } + let bottom = aTop + aHeight * cssToDesktopScale; + if (bottom > screenBottom + slopY) { + bottom = screenBottom + winSlopY; + if (aTop > screenTop) { + aTop = Math.max( + bottom - aHeight * cssToDesktopScale, + screenTop - winSlopY + ); + } + } + aHeight = (bottom - aTop) / cssToDesktopScale; + } + + // Suppress animations. + dwu.suppressAnimation(true); + + // We want to make sure users will get their animations back in case an exception is thrown. + try { + // only modify those aspects which aren't correct yet + if ( + !isNaN(aLeft) && + !isNaN(aTop) && + (aLeft != win_("screenX") || aTop != win_("screenY")) + ) { + // moveTo uses CSS pixels relative to aWindow, while aLeft and aRight + // are on desktop pixels, undo the conversion we do in + // _getWindowDimension. + let desktopToCssScale = + aWindow.desktopToDeviceScale / aWindow.devicePixelRatio; + aWindow.moveTo(aLeft * desktopToCssScale, aTop * desktopToCssScale); + } + if ( + aWidth && + aHeight && + (aWidth != win_("width") || aHeight != win_("height")) && + !gResistFingerprintingEnabled + ) { + // Don't resize the window if it's currently maximized and we would + // maximize it again shortly after. + if (aSizeMode != "maximized" || win_("sizemode") != "maximized") { + aWindow.resizeTo(aWidth, aHeight); + } + } + this._windows[aWindow.__SSi].sizemodeBeforeMinimized = + aSizeModeBeforeMinimized; + if ( + aSizeMode && + win_("sizemode") != aSizeMode && + !gResistFingerprintingEnabled + ) { + switch (aSizeMode) { + case "maximized": + aWindow.maximize(); + break; + case "minimized": + if (aSizeModeBeforeMinimized == "maximized") { + aWindow.maximize(); + } + aWindow.minimize(); + break; + case "normal": + aWindow.restore(); + 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) { + this.windowToFocus.focus(); + } + } finally { + // Enable animations. + dwu.suppressAnimation(false); + } + }, + + /* ........ Disk Access .............. */ + + /** + * Save the current session state to disk, after a delay. + * + * @param aWindow (optional) + * Will mark the given window as dirty so that we will recollect its + * data before we start writing. + */ + saveStateDelayed(aWindow = null) { + if (aWindow) { + DirtyWindows.add(aWindow); + } + + lazy.SessionSaver.runDelayed(); + }, + + /* ........ Auxiliary Functions .............. */ + + /** + * Remove a closed window from the list of closed windows and indicate that + * the change should be notified. + * + * @param index + * The index of the window in this._closedWindows. + * + * @returns Array of closed windows. + */ + _removeClosedWindow(index) { + // remove all of the closed tabs from the _lastClosedActions list + // before removing the window from it + for (let closedTab of this._closedWindows[index]._closedTabs) { + this._removeClosedAction( + this._LAST_ACTION_CLOSED_TAB, + closedTab.closedId + ); + } + this._removeClosedAction( + this._LAST_ACTION_CLOSED_WINDOW, + this._closedWindows[index].closedId + ); + let windows = this._closedWindows.splice(index, 1); + this._closedObjectsChanged = true; + return windows; + }, + + /** + * Notifies observers that the list of closed tabs and/or windows has changed. + * Waits a tick to allow SessionStorage a chance to register the change. + */ + _notifyOfClosedObjectsChange() { + if (!this._closedObjectsChanged) { + return; + } + this._closedObjectsChanged = false; + lazy.setTimeout(() => { + Services.obs.notifyObservers(null, NOTIFY_CLOSED_OBJECTS_CHANGED); + }, 0); + }, + + /** + * Update the session start time and send a telemetry measurement + * for the number of days elapsed since the session was started. + * + * @param state + * The session state. + */ + _updateSessionStartTime: function ssi_updateSessionStartTime(state) { + // Attempt to load the session start time from the session state + if (state.session && state.session.startTime) { + this._sessionStartTime = state.session.startTime; + } + }, + + /** + * Iterator that yields all currently opened browser windows. + * (Might miss the most recent one.) + * This list is in focus order, but may include minimized windows + * before non-minimized windows. + */ + _browserWindows: { + *[Symbol.iterator]() { + for (let window of lazy.BrowserWindowTracker.orderedWindows) { + if (window.__SSi && !window.closed) { + yield window; + } + } + }, + }, + + /** + * Iterator that yields all currently opened browser windows, + * with minimized windows last. + * (Might miss the most recent window.) + */ + _orderedBrowserWindows: { + *[Symbol.iterator]() { + let windows = lazy.BrowserWindowTracker.orderedWindows; + windows.sort((a, b) => { + if ( + a.windowState == a.STATE_MINIMIZED && + b.windowState != b.STATE_MINIMIZED + ) { + return 1; + } + if ( + a.windowState != a.STATE_MINIMIZED && + b.windowState == b.STATE_MINIMIZED + ) { + return -1; + } + return 0; + }); + for (let window of windows) { + if (window.__SSi && !window.closed) { + yield window; + } + } + }, + }, + + /** + * Returns most recent window + * @param {boolean} [isPrivate] + * Optional boolean to get only non-private or private windows + * When omitted, we'll return whatever the top-most window is regardless of privateness + * @returns Window reference + */ + _getTopWindow: function ssi_getTopWindow(isPrivate) { + const options = { allowPopups: true }; + if (typeof isPrivate !== "undefined") { + options.private = isPrivate; + } + return lazy.BrowserWindowTracker.getTopWindow(options); + }, + + /** + * Calls onClose for windows that are determined to be closed but aren't + * destroyed yet, which would otherwise cause getBrowserState and + * setBrowserState to treat them as open windows. + */ + _handleClosedWindows: function ssi_handleClosedWindows() { + let promises = []; + for (let window of Services.wm.getEnumerator("navigator:browser")) { + if (window.closed) { + promises.push(this.onClose(window)); + } + } + return Promise.all(promises); + }, + + /** + * Store a restore state of a window to this._statesToRestore. The window + * will be given an id that can be used to get the restore state from + * this._statesToRestore. + * + * @param window + * a reference to a window that has a state to restore + * @param state + * an object containing session data + */ + _updateWindowRestoreState(window, state) { + // Store z-index, so that windows can be restored in reversed z-order. + if ("zIndex" in state.windows[0]) { + WINDOW_RESTORE_ZINDICES.set(window, state.windows[0].zIndex); + } + do { + var ID = "window" + Math.random(); + } while (ID in this._statesToRestore); + WINDOW_RESTORE_IDS.set(window, ID); + this._statesToRestore[ID] = state; + }, + + /** + * open a new browser window for a given session state + * called when restoring a multi-window session + * @param aState + * Object containing session data + */ + _openWindowWithState: function ssi_openWindowWithState(aState) { + var argString = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + argString.data = ""; + + // Build feature string + let features; + let winState = aState.windows[0]; + if (winState.chromeFlags) { + features = ["chrome", "suppressanimation"]; + let chromeFlags = winState.chromeFlags; + const allFlags = Ci.nsIWebBrowserChrome.CHROME_ALL; + const hasAll = (chromeFlags & allFlags) == allFlags; + if (hasAll) { + features.push("all"); + } + for (let [flag, onValue, offValue] of CHROME_FLAGS_MAP) { + if (hasAll && allFlags & flag) { + continue; + } + let value = chromeFlags & flag ? onValue : offValue; + if (value) { + features.push(value); + } + } + } else { + // |chromeFlags| is not found. Fallbacks to the old method. + features = ["chrome", "dialog=no", "suppressanimation"]; + let hidden = winState.hidden?.split(",") || []; + if (!hidden.length) { + features.push("all"); + } else { + features.push("resizable"); + WINDOW_HIDEABLE_FEATURES.forEach(aFeature => { + if (!hidden.includes(aFeature)) { + features.push(WINDOW_OPEN_FEATURES_MAP[aFeature] || aFeature); + } + }); + } + } + WINDOW_ATTRIBUTES.forEach(aFeature => { + // Use !isNaN as an easy way to ignore sizemode and check for numbers + if (aFeature in winState && !isNaN(winState[aFeature])) { + features.push(aFeature + "=" + winState[aFeature]); + } + }); + + if (winState.isPrivate) { + features.push("private"); + } + + var window = Services.ww.openWindow( + null, + AppConstants.BROWSER_CHROME_URL, + "_blank", + features.join(","), + argString + ); + + this._updateWindowRestoreState(window, aState); + WINDOW_SHOWING_PROMISES.set(window, Promise.withResolvers()); + + return window; + }, + + /** + * whether the user wants to load any other page at startup + * (except the homepage) - needed for determining whether to overwrite the current tabs + * C.f.: nsBrowserContentHandler's defaultArgs implementation. + * @returns bool + */ + _isCmdLineEmpty: function ssi_isCmdLineEmpty(aWindow, aState) { + var pinnedOnly = + aState.windows && + aState.windows.every(win => win.tabs.every(tab => tab.pinned)); + + let hasFirstArgument = aWindow.arguments && aWindow.arguments[0]; + if (!pinnedOnly) { + let defaultArgs = Cc["@mozilla.org/browser/clh;1"].getService( + Ci.nsIBrowserHandler + ).defaultArgs; + if ( + aWindow.arguments && + aWindow.arguments[0] && + aWindow.arguments[0] == defaultArgs + ) { + hasFirstArgument = false; + } + } + + return !hasFirstArgument; + }, + + /** + * on popup windows, the AppWindow's attributes seem not to be set correctly + * we use thus JSDOMWindow attributes for sizemode and normal window attributes + * (and hope for reasonable values when maximized/minimized - since then + * outerWidth/outerHeight aren't the dimensions of the restored window) + * @param aWindow + * Window reference + * @param aAttribute + * String sizemode | width | height | other window attribute + * @returns string + */ + _getWindowDimension: function ssi_getWindowDimension(aWindow, aAttribute) { + if (aAttribute == "sizemode") { + switch (aWindow.windowState) { + case aWindow.STATE_FULLSCREEN: + case aWindow.STATE_MAXIMIZED: + return "maximized"; + case aWindow.STATE_MINIMIZED: + return "minimized"; + default: + return "normal"; + } + } + + // We want to persist the size / position in normal state, so that + // we can restore to them even if the window is currently maximized + // or minimized. However, attributes on window object only reflect + // the current state of the window, so when it isn't in the normal + // sizemode, their values aren't what we want the window to restore + // to. In that case, try to read from the attributes of the root + // element first instead. + if (aWindow.windowState != aWindow.STATE_NORMAL) { + let docElem = aWindow.document.documentElement; + let attr = parseInt(docElem.getAttribute(aAttribute), 10); + if (attr) { + if (aAttribute != "width" && aAttribute != "height") { + return attr; + } + // Width and height attribute report the inner size, but we want + // to store the outer size, so add the difference. + let appWin = aWindow.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow); + let diff = + aAttribute == "width" + ? appWin.outerToInnerWidthDifferenceInCSSPixels + : appWin.outerToInnerHeightDifferenceInCSSPixels; + return attr + diff; + } + } + + switch (aAttribute) { + case "width": + return aWindow.outerWidth; + case "height": + return aWindow.outerHeight; + case "screenX": + case "screenY": + // We use desktop pixels rather than CSS pixels to store window + // positions, see bug 1247335. This allows proper multi-monitor + // positioning in mixed-DPI situations. + // screenX/Y are in CSS pixels for the current window, so, convert them + // to desktop pixels. + return ( + (aWindow[aAttribute] * aWindow.devicePixelRatio) / + aWindow.desktopToDeviceScale + ); + default: + return aAttribute in aWindow ? aWindow[aAttribute] : ""; + } + }, + + /** + * @param aState is a session state + * @param aRecentCrashes is the number of consecutive crashes + * @returns whether a restore page will be needed for the session state + */ + _needsRestorePage: function ssi_needsRestorePage(aState, aRecentCrashes) { + const SIX_HOURS_IN_MS = 6 * 60 * 60 * 1000; + + // don't display the page when there's nothing to restore + let winData = aState.windows || null; + if (!winData || !winData.length) { + return false; + } + + // don't wrap a single about:sessionrestore page + if ( + this._hasSingleTabWithURL(winData, "about:sessionrestore") || + this._hasSingleTabWithURL(winData, "about:welcomeback") + ) { + return false; + } + + // don't automatically restore in Safe Mode + if (Services.appinfo.inSafeMode) { + return true; + } + + let max_resumed_crashes = this._prefBranch.getIntPref( + "sessionstore.max_resumed_crashes" + ); + let sessionAge = + aState.session && + aState.session.lastUpdate && + Date.now() - aState.session.lastUpdate; + + let decision = + max_resumed_crashes != -1 && + (aRecentCrashes > max_resumed_crashes || + (sessionAge && sessionAge >= SIX_HOURS_IN_MS)); + if (decision) { + let key; + if (aRecentCrashes > max_resumed_crashes) { + if (sessionAge && sessionAge >= SIX_HOURS_IN_MS) { + key = "shown_many_crashes_old_session"; + } else { + key = "shown_many_crashes"; + } + } else { + key = "shown_old_session"; + } + Services.telemetry.keyedScalarAdd( + "browser.engagement.sessionrestore_interstitial", + key, + 1 + ); + } + return decision; + }, + + /** + * @param aWinData is the set of windows in session state + * @param aURL is the single URL we're looking for + * @returns whether the window data contains only the single URL passed + */ + _hasSingleTabWithURL(aWinData, aURL) { + if ( + aWinData && + aWinData.length == 1 && + aWinData[0].tabs && + aWinData[0].tabs.length == 1 && + aWinData[0].tabs[0].entries && + aWinData[0].tabs[0].entries.length == 1 + ) { + return aURL == aWinData[0].tabs[0].entries[0].url; + } + return false; + }, + + /** + * Determine if the tab state we're passed is something we should save. This + * is used when closing a tab or closing a window with a single tab + * + * @param aTabState + * The current tab state + * @returns boolean + */ + _shouldSaveTabState: function ssi_shouldSaveTabState(aTabState) { + // If the tab has only a transient about: history entry, no other + // session history, and no userTypedValue, then we don't actually want to + // store this tab's data. + const entryUrl = aTabState.entries[0]?.url; + return ( + entryUrl && + !( + aTabState.entries.length == 1 && + (entryUrl == "about:blank" || + entryUrl == "about:home" || + entryUrl == "about:newtab" || + entryUrl == "about:privatebrowsing") && + !aTabState.userTypedValue + ) + ); + }, + + /** + * Determine if the tab state we're passed is something we should keep to be + * reopened at session restore. This is used when we are saving the current + * session state to disk. This method is very similar to _shouldSaveTabState, + * however, "about:blank" and "about:newtab" tabs will still be saved to disk. + * + * @param aTabState + * The current tab state + * @returns boolean + */ + _shouldSaveTab: function ssi_shouldSaveTab(aTabState) { + // If the tab has one of the following transient about: history entry, no + // userTypedValue, and no customizemode attribute, then we don't actually + // want to write this tab's data to disk. + return ( + aTabState.userTypedValue || + (aTabState.attributes && aTabState.attributes.customizemode == "true") || + (aTabState.entries.length && + aTabState.entries[0].url != "about:privatebrowsing") + ); + }, + + /** + * This is going to take a state as provided at startup (via + * SessionStartup.state) and split it into 2 parts. The first part + * (defaultState) will be a state that should still be restored at startup, + * while the second part (state) is a state that should be saved for later. + * defaultState will be comprised of windows with only pinned tabs, extracted + * from a clone of startupState. It will also contain window position information. + * + * defaultState will be restored at startup. state will be passed into + * LastSession and will be kept in case the user explicitly wants + * to restore the previous session (publicly exposed as restoreLastSession). + * + * @param state + * The startupState, presumably from SessionStartup.state + * @returns [defaultState, state] + */ + _prepDataForDeferredRestore: function ssi_prepDataForDeferredRestore( + startupState + ) { + // Make sure that we don't modify the global state as provided by + // SessionStartup.state. + let state = Cu.cloneInto(startupState, {}); + let hasPinnedTabs = false; + let defaultState = { windows: [], selectedWindow: 1 }; + state.selectedWindow = state.selectedWindow || 1; + + // Look at each window, remove pinned tabs, adjust selectedindex, + // remove window if necessary. + for (let wIndex = 0; wIndex < state.windows.length; ) { + let window = state.windows[wIndex]; + window.selected = window.selected || 1; + // We're going to put the state of the window into this object, but for closedTabs + // we want to preserve the original closedTabs since that will be saved as the lastSessionState + let newWindowState = { + tabs: [], + }; + if (PERSIST_SESSIONS) { + newWindowState._closedTabs = Cu.cloneInto(window._closedTabs, {}); + } + for (let tIndex = 0; tIndex < window.tabs.length; ) { + if (window.tabs[tIndex].pinned) { + // Adjust window.selected + if (tIndex + 1 < window.selected) { + window.selected -= 1; + } else if (tIndex + 1 == window.selected) { + newWindowState.selected = newWindowState.tabs.length + 1; + } + // + 1 because the tab isn't actually in the array yet + + // Now add the pinned tab to our window + newWindowState.tabs = newWindowState.tabs.concat( + window.tabs.splice(tIndex, 1) + ); + // We don't want to increment tIndex here. + continue; + } else if (!window.tabs[tIndex].hidden && PERSIST_SESSIONS) { + // Add any previously open tabs that aren't pinned or hidden to the recently closed tabs list + // which we want to persist between sessions; if the session is manually restored, they will + // be filtered out of the closed tabs list (due to removeAfterRestore property) and reopened + // per expected session restore behavior. + + let tabState = window.tabs[tIndex]; + + // Ensure the index is in bounds. + let activeIndex = tabState.index; + activeIndex = Math.min(activeIndex, tabState.entries.length - 1); + activeIndex = Math.max(activeIndex, 0); + + if (activeIndex in tabState.entries) { + let title = + tabState.entries[activeIndex].title || + tabState.entries[activeIndex].url; + + let tabData = { + state: tabState, + title, + image: tabState.image, + pos: tIndex, + closedAt: Date.now(), + closedInGroup: false, + removeAfterRestore: true, + }; + + if (this._shouldSaveTabState(tabState)) { + this.saveClosedTabData( + window, + newWindowState._closedTabs, + tabData, + false + ); + } + } + } + tIndex++; + } + + hasPinnedTabs ||= !!newWindowState.tabs.length; + + // Only transfer over window attributes for pinned tabs, which has + // already been extracted into newWindowState.tabs. + if (newWindowState.tabs.length) { + WINDOW_ATTRIBUTES.forEach(function (attr) { + if (attr in window) { + newWindowState[attr] = window[attr]; + delete window[attr]; + } + }); + // We're just copying position data into the window for pinned tabs. + // Not copying over: + // - extData + // - isPopup + // - hidden + + // Assign a unique ID to correlate the window to be opened with the + // remaining data + window.__lastSessionWindowID = newWindowState.__lastSessionWindowID = + "" + Date.now() + Math.random(); + } + + // If this newWindowState contains pinned tabs (stored in tabs) or + // closed tabs, add it to the defaultState so they're available immediately. + if ( + newWindowState.tabs.length || + (PERSIST_SESSIONS && newWindowState._closedTabs.length) + ) { + defaultState.windows.push(newWindowState); + // Remove the window from the state if it doesn't have any tabs + if (!window.tabs.length) { + if (wIndex + 1 <= state.selectedWindow) { + state.selectedWindow -= 1; + } else if (wIndex + 1 == state.selectedWindow) { + defaultState.selectedIndex = defaultState.windows.length + 1; + } + + state.windows.splice(wIndex, 1); + // We don't want to increment wIndex here. + continue; + } + } + wIndex++; + } + + if (hasPinnedTabs) { + // Move cookies over from so that they're restored right away and pinned tabs will load correctly. + defaultState.cookies = state.cookies; + delete state.cookies; + } + // we return state here rather than startupState so as to avoid duplicating + // pinned tabs that we add to the defaultState (when a user restores a session) + return [defaultState, state]; + }, + + _sendRestoreCompletedNotifications: + function ssi_sendRestoreCompletedNotifications() { + // not all windows restored, yet + if (this._restoreCount > 1) { + this._restoreCount--; + this._log.warn( + `waiting on ${this._restoreCount} windows to be restored before sending restore complete notifications.` + ); + return; + } + + // observers were already notified + if (this._restoreCount == -1) { + return; + } + + // This was the last window restored at startup, notify observers. + if (!this._browserSetState) { + Services.obs.notifyObservers(null, NOTIFY_WINDOWS_RESTORED); + this._deferredAllWindowsRestored.resolve(); + } else { + // _browserSetState is used only by tests, and it uses an alternate + // notification in order not to retrigger startup observers that + // are listening for NOTIFY_WINDOWS_RESTORED. + Services.obs.notifyObservers(null, NOTIFY_BROWSER_STATE_RESTORED); + } + + this._browserSetState = false; + this._restoreCount = -1; + }, + + /** + * Set the given window's busy state + * @param aWindow the window + * @param aValue the window's busy state + */ + _setWindowStateBusyValue: function ssi_changeWindowStateBusyValue( + aWindow, + aValue + ) { + this._windows[aWindow.__SSi].busy = aValue; + + // Keep the to-be-restored state in sync because that is returned by + // getWindowState() as long as the window isn't loaded, yet. + if (!this._isWindowLoaded(aWindow)) { + let stateToRestore = + this._statesToRestore[WINDOW_RESTORE_IDS.get(aWindow)].windows[0]; + stateToRestore.busy = aValue; + } + }, + + /** + * Set the given window's state to 'not busy'. + * @param aWindow the window + */ + _setWindowStateReady: function ssi_setWindowStateReady(aWindow) { + let newCount = (this._windowBusyStates.get(aWindow) || 0) - 1; + if (newCount < 0) { + throw new Error("Invalid window busy state (less than zero)."); + } + this._windowBusyStates.set(aWindow, newCount); + + if (newCount == 0) { + this._setWindowStateBusyValue(aWindow, false); + this._sendWindowStateReadyEvent(aWindow); + } + }, + + /** + * Set the given window's state to 'busy'. + * @param aWindow the window + */ + _setWindowStateBusy: function ssi_setWindowStateBusy(aWindow) { + let newCount = (this._windowBusyStates.get(aWindow) || 0) + 1; + this._windowBusyStates.set(aWindow, newCount); + + if (newCount == 1) { + this._setWindowStateBusyValue(aWindow, true); + this._sendWindowStateBusyEvent(aWindow); + } + }, + + /** + * Dispatch an SSWindowStateReady event for the given window. + * @param aWindow the window + */ + _sendWindowStateReadyEvent: function ssi_sendWindowStateReadyEvent(aWindow) { + let event = aWindow.document.createEvent("Events"); + event.initEvent("SSWindowStateReady", true, false); + aWindow.dispatchEvent(event); + }, + + /** + * Dispatch an SSWindowStateBusy event for the given window. + * @param aWindow the window + */ + _sendWindowStateBusyEvent: function ssi_sendWindowStateBusyEvent(aWindow) { + let event = aWindow.document.createEvent("Events"); + event.initEvent("SSWindowStateBusy", true, false); + aWindow.dispatchEvent(event); + }, + + /** + * Dispatch the SSWindowRestoring event for the given window. + * @param aWindow + * The window which is going to be restored + */ + _sendWindowRestoringNotification(aWindow) { + let event = aWindow.document.createEvent("Events"); + event.initEvent("SSWindowRestoring", true, false); + aWindow.dispatchEvent(event); + }, + + /** + * Dispatch the SSWindowRestored event for the given window. + * @param aWindow + * The window which has been restored + */ + _sendWindowRestoredNotification(aWindow) { + let event = aWindow.document.createEvent("Events"); + event.initEvent("SSWindowRestored", true, false); + aWindow.dispatchEvent(event); + }, + + /** + * Dispatch the SSTabRestored event for the given tab. + * @param aTab + * The tab which has been restored + * @param aIsRemotenessUpdate + * True if this tab was restored due to flip from running from + * out-of-main-process to in-main-process or vice-versa. + */ + _sendTabRestoredNotification(aTab, aIsRemotenessUpdate) { + let event = aTab.ownerDocument.createEvent("CustomEvent"); + event.initCustomEvent("SSTabRestored", true, false, { + isRemotenessUpdate: aIsRemotenessUpdate, + }); + aTab.dispatchEvent(event); + }, + + /** + * @param aWindow + * Window reference + * @returns whether this window's data is still cached in _statesToRestore + * because it's not fully loaded yet + */ + _isWindowLoaded: function ssi_isWindowLoaded(aWindow) { + return !WINDOW_RESTORE_IDS.has(aWindow); + }, + + /** + * Resize this._closedWindows to the value of the pref, except in the case + * where we don't have any non-popup windows on Windows and Linux. Then we must + * resize such that we have at least one non-popup window. + */ + _capClosedWindows: function ssi_capClosedWindows() { + if (this._closedWindows.length <= this._max_windows_undo) { + return; + } + let spliceTo = this._max_windows_undo; + if (AppConstants.platform != "macosx") { + let normalWindowIndex = 0; + // try to find a non-popup window in this._closedWindows + while ( + normalWindowIndex < this._closedWindows.length && + !!this._closedWindows[normalWindowIndex].isPopup + ) { + normalWindowIndex++; + } + if (normalWindowIndex >= this._max_windows_undo) { + spliceTo = normalWindowIndex + 1; + } + } + if (spliceTo < this._closedWindows.length) { + this._closedWindows.splice(spliceTo, this._closedWindows.length); + this._closedObjectsChanged = true; + } + }, + + /** + * Clears the set of windows that are "resurrected" before writing to disk to + * make closing windows one after the other until shutdown work as expected. + * + * This function should only be called when we are sure that there has been + * a user action that indicates the browser is actively being used and all + * windows that have been closed before are not part of a series of closing + * windows. + */ + _clearRestoringWindows: function ssi_clearRestoringWindows() { + for (let i = 0; i < this._closedWindows.length; i++) { + delete this._closedWindows[i]._shouldRestore; + } + }, + + /** + * Reset state to prepare for a new session state to be restored. + */ + _resetRestoringState: function ssi_initRestoringState() { + TabRestoreQueue.reset(); + this._tabsRestoringCount = 0; + }, + + /** + * Reset the restoring state for a particular tab. This will be called when + * removing a tab or when a tab needs to be reset (it's being overwritten). + * + * @param aTab + * The tab that will be "reset" + */ + _resetLocalTabRestoringState(aTab) { + let browser = aTab.linkedBrowser; + + // Keep the tab's previous state for later in this method + let previousState = TAB_STATE_FOR_BROWSER.get(browser); + + if (!previousState) { + console.error("Given tab is not restoring."); + return; + } + + // The browser is no longer in any sort of restoring state. + TAB_STATE_FOR_BROWSER.delete(browser); + + if (Services.appinfo.sessionHistoryInParent) { + this._restoreListeners.get(browser.permanentKey)?.unregister(); + browser.browsingContext.clearRestoreState(); + } + + aTab.removeAttribute("pending"); + + if (previousState == TAB_STATE_RESTORING) { + if (this._tabsRestoringCount) { + this._tabsRestoringCount--; + } + } else if (previousState == TAB_STATE_NEEDS_RESTORE) { + // Make sure that the tab is removed from the list of tabs to restore. + // Again, this is normally done in restoreTabContent, but that isn't being called + // for this tab. + TabRestoreQueue.remove(aTab); + } + }, + + _resetTabRestoringState(tab) { + let browser = tab.linkedBrowser; + + if (!TAB_STATE_FOR_BROWSER.has(browser)) { + console.error("Given tab is not restoring."); + return; + } + + if (!Services.appinfo.sessionHistoryInParent) { + browser.messageManager.sendAsyncMessage("SessionStore:resetRestore", {}); + } + this._resetLocalTabRestoringState(tab); + }, + + /** + * Each fresh tab starts out with epoch=0. This function can be used to + * start a next epoch by incrementing the current value. It will enables us + * to ignore stale messages sent from previous epochs. The function returns + * the new epoch ID for the given |browser|. + */ + startNextEpoch(permanentKey) { + let next = this.getCurrentEpoch(permanentKey) + 1; + this._browserEpochs.set(permanentKey, next); + return next; + }, + + /** + * Returns the current epoch for the given . If we haven't assigned + * a new epoch this will default to zero for new tabs. + */ + getCurrentEpoch(permanentKey) { + return this._browserEpochs.get(permanentKey) || 0; + }, + + /** + * Each time a element is restored, we increment its "epoch". To + * check if a message from content-sessionStore.js is out of date, we can + * compare the epoch received with the message to the element's + * epoch. This function does that, and returns true if |epoch| is up-to-date + * with respect to |browser|. + */ + isCurrentEpoch(permanentKey, epoch) { + return this.getCurrentEpoch(permanentKey) == epoch; + }, + + /** + * Resets the epoch for a given . We need to this every time we + * receive a hint that a new docShell has been loaded into the browser as + * the frame script starts out with epoch=0. + */ + resetEpoch(permanentKey, frameLoader = null) { + this._browserEpochs.delete(permanentKey); + if (frameLoader) { + frameLoader.requestEpochUpdate(0); + } + }, + + /** + * Countdown for a given duration, skipping beats if the computer is too busy, + * sleeping or otherwise unavailable. + * + * @param {number} delay An approximate delay to wait in milliseconds (rounded + * up to the closest second). + * + * @return Promise + */ + looseTimer(delay) { + let DELAY_BEAT = 1000; + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + let beats = Math.ceil(delay / DELAY_BEAT); + let deferred = Promise.withResolvers(); + timer.initWithCallback( + function () { + if (beats <= 0) { + deferred.resolve(); + } + --beats; + }, + DELAY_BEAT, + Ci.nsITimer.TYPE_REPEATING_PRECISE_CAN_SKIP + ); + // Ensure that the timer is both canceled once we are done with it + // and not garbage-collected until then. + deferred.promise.then( + () => timer.cancel(), + () => timer.cancel() + ); + 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(); + + const listener = { + unregister(reject = true) { + if (reject) { + deferred.reject(); + } + + SessionStoreInternal._restoreListeners.delete(browser.permanentKey); + + try { + browser.removeProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_STATE_WINDOW + ); + } catch {} // May have already gotten rid of the browser's webProgress. + }, + + onStateChange(webProgress, request, stateFlags, status) { + if ( + webProgress.isTopLevel && + stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW && + stateFlags & Ci.nsIWebProgressListener.STATE_STOP + ) { + // FIXME: We sometimes see spurious STATE_STOP events for about:blank + // loads, so we have to account for that here. + let aboutBlankOK = !expectedURL || expectedURL === "about:blank"; + let url = request.QueryInterface(Ci.nsIChannel).originalURI.spec; + if (url !== "about:blank" || aboutBlankOK) { + this.unregister(false); + deferred.resolve(); + } + } + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + }; + + this._restoreListeners.get(browser.permanentKey)?.unregister(); + this._restoreListeners.set(browser.permanentKey, listener); + + browser.addProgressListener( + listener, + Ci.nsIWebProgress.NOTIFY_STATE_WINDOW + ); + + return deferred.promise; + }, + + _listenForNavigations(browser, callbacks) { + const listener = { + unregister() { + browser.browsingContext?.sessionHistory?.removeSHistoryListener(this); + + try { + browser.removeProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_STATE_WINDOW + ); + } catch {} // May have already gotten rid of the browser's webProgress. + + SessionStoreInternal._restoreListeners.delete(browser.permanentKey); + }, + + OnHistoryReload() { + this.unregister(); + return callbacks.onHistoryReload(); + }, + + // TODO(kashav): ContentRestore.sys.mjs handles OnHistoryNewEntry + // separately, so we should eventually support that here as well. + OnHistoryNewEntry() {}, + OnHistoryGotoIndex() {}, + OnHistoryPurge() {}, + OnHistoryReplaceEntry() {}, + + onStateChange(webProgress, request, stateFlags, status) { + if ( + webProgress.isTopLevel && + stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW && + stateFlags & Ci.nsIWebProgressListener.STATE_START + ) { + this.unregister(); + callbacks.onStartRequest(); + } + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsISHistoryListener", + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + }; + + this._restoreListeners.get(browser.permanentKey)?.unregister(); + this._restoreListeners.set(browser.permanentKey, listener); + + browser.browsingContext?.sessionHistory?.addSHistoryListener(listener); + + browser.addProgressListener( + listener, + Ci.nsIWebProgress.NOTIFY_STATE_WINDOW + ); + }, + + /** + * This mirrors ContentRestore.restoreHistory() for parent process session + * history restores. + */ + _restoreHistory(browser, data) { + if (!Services.appinfo.sessionHistoryInParent) { + throw new Error("This function should only be used with SHIP"); + } + + this._tabStateToRestore.set(browser.permanentKey, data); + + // In case about:blank isn't done yet. + // XXX(kashav): Does this actually accomplish anything? Can we remove? + browser.stop(); + + lazy.SessionHistory.restoreFromParent( + browser.browsingContext.sessionHistory, + data.tabData + ); + + let url = data.tabData?.entries[data.tabData.index - 1]?.url; + let disallow = data.tabData?.disallow; + + let promise = SessionStoreUtils.restoreDocShellState( + browser.browsingContext, + url, + disallow + ); + this._tabStateRestorePromises.set(browser.permanentKey, promise); + + const onResolve = () => { + if (TAB_STATE_FOR_BROWSER.get(browser) !== TAB_STATE_RESTORING) { + this._listenForNavigations(browser, { + // The history entry was reloaded before we began restoring tab + // content, just proceed as we would normally. + onHistoryReload: () => { + this._restoreTabContent(browser); + return false; + }, + + // Some foreign code, like an extension, loaded a new URI on the + // browser. We no longer want to restore saved tab data, but may + // still have browser state that needs to be restored. + onStartRequest: () => { + this._tabStateToRestore.delete(browser.permanentKey); + this._restoreTabContent(browser); + }, + }); + } + + this._tabStateRestorePromises.delete(browser.permanentKey); + + this._restoreHistoryComplete(browser, data); + }; + + promise.then(onResolve).catch(() => {}); + }, + + /** + * Either load the saved typed value or restore the active history entry. + * If neither is possible, just load an empty document. + */ + _restoreTabEntry(browser, tabData) { + let haveUserTypedValue = tabData.userTypedValue && tabData.userTypedClear; + // First take care of the common case where we load the history entry. + if (!haveUserTypedValue && tabData.entries.length) { + return SessionStoreUtils.initializeRestore( + browser.browsingContext, + this.buildRestoreData(tabData.formdata, tabData.scroll) + ); + } + // Here, we need to load user data or about:blank instead. + // As it's user-typed (or blank), it gets system triggering principal: + let triggeringPrincipal = + Services.scriptSecurityManager.getSystemPrincipal(); + // Bypass all the fixup goop for about:blank: + if (!haveUserTypedValue) { + let blankPromise = this._waitForStateStop(browser, "about:blank"); + browser.browsingContext.loadURI(lazy.blankURI, { + triggeringPrincipal, + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_HISTORY, + }); + return blankPromise; + } + + // We have a user typed value, load that with fixup: + let loadPromise = this._waitForStateStop(browser, tabData.userTypedValue); + browser.browsingContext.fixupAndLoadURIString(tabData.userTypedValue, { + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP, + triggeringPrincipal, + }); + + return loadPromise; + }, + + /** + * This mirrors ContentRestore.restoreTabContent() for parent process session + * history restores. + */ + _restoreTabContent(browser, options = {}) { + if (!Services.appinfo.sessionHistoryInParent) { + throw new Error("This function should only be used with SHIP"); + } + + this._restoreListeners.get(browser.permanentKey)?.unregister(); + + this._restoreTabContentStarted(browser, options); + + let state = this._tabStateToRestore.get(browser.permanentKey); + this._tabStateToRestore.delete(browser.permanentKey); + + let promises = [this._tabStateRestorePromises.get(browser.permanentKey)]; + + if (state) { + promises.push(this._restoreTabEntry(browser, state.tabData)); + } else { + // The browser started another load, so we decided to not restore + // saved tab data. We should still wait for that new load to finish + // before proceeding. + promises.push(this._waitForStateStop(browser)); + } + + Promise.allSettled(promises).then(() => { + this._restoreTabContentComplete(browser, options); + }); + }, + + _sendRestoreTabContent(browser, options) { + if (Services.appinfo.sessionHistoryInParent) { + this._restoreTabContent(browser, options); + } else { + browser.messageManager.sendAsyncMessage( + "SessionStore:restoreTabContent", + options + ); + } + }, + + _restoreHistoryComplete(browser, data) { + let win = browser.ownerGlobal; + let tab = win?.gBrowser.getTabForBrowser(browser); + if (!tab) { + return; + } + + // Notify the tabbrowser that the tab chrome has been restored. + let tabData = lazy.TabState.collect(tab, TAB_CUSTOM_VALUES.get(tab)); + + // Update tab label and icon again after the tab history was updated. + this.updateTabLabelAndIcon(tab, tabData); + + let event = win.document.createEvent("Events"); + event.initEvent("SSTabRestoring", true, false); + tab.dispatchEvent(event); + }, + + _restoreTabContentStarted(browser, data) { + let win = browser.ownerGlobal; + let tab = win?.gBrowser.getTabForBrowser(browser); + if (!tab) { + return; + } + + let initiatedBySessionStore = + TAB_STATE_FOR_BROWSER.get(browser) != TAB_STATE_NEEDS_RESTORE; + let isNavigateAndRestore = + data.reason == RESTORE_TAB_CONTENT_REASON.NAVIGATE_AND_RESTORE; + + // We need to be careful when restoring the urlbar's search mode because + // we race a call to gURLBar.setURI due to the location change. setURI + // will exit search mode and set gURLBar.value to the restored URL, + // clobbering any search mode and userTypedValue we restore here. If + // this is a typical restore -- restoring on startup or restoring a + // closed tab for example -- then we need to restore search mode after + // that setURI call, and so we wait until restoreTabContentComplete, at + // which point setURI will have been called. If this is not a typical + // restore -- it was not initiated by session store or it's due to a + // remoteness change -- then we do not want to restore search mode at + // all, and so we remove it from the tab state cache. In particular, if + // the restore is due to a remoteness change, then the user is loading a + // new URL and the current search mode should not be carried over to it. + let cacheState = lazy.TabStateCache.get(browser.permanentKey); + if (cacheState.searchMode) { + if (!initiatedBySessionStore || isNavigateAndRestore) { + lazy.TabStateCache.update(browser.permanentKey, { + searchMode: null, + userTypedValue: null, + }); + } + return; + } + + if (!initiatedBySessionStore) { + // If a load not initiated by sessionstore was started in a + // previously pending tab. Mark the tab as no longer pending. + this.markTabAsRestoring(tab); + } else if (!isNavigateAndRestore) { + // If the user was typing into the URL bar when we crashed, but hadn't hit + // enter yet, then we just need to write that value to the URL bar without + // loading anything. This must happen after the load, as the load will clear + // userTypedValue. + // + // Note that we only want to do that if we're restoring state for reasons + // _other_ than a navigateAndRestore remoteness-flip, as such a flip + // implies that the user was navigating. + let tabData = lazy.TabState.collect(tab, TAB_CUSTOM_VALUES.get(tab)); + if ( + tabData.userTypedValue && + !tabData.userTypedClear && + !browser.userTypedValue + ) { + browser.userTypedValue = tabData.userTypedValue; + if (tab.selected) { + win.gURLBar.setURI(); + } + } + + // Remove state we don't need any longer. + lazy.TabStateCache.update(browser.permanentKey, { + userTypedValue: null, + userTypedClear: null, + }); + } + }, + + _restoreTabContentComplete(browser, data) { + let win = browser.ownerGlobal; + let tab = browser.ownerGlobal?.gBrowser.getTabForBrowser(browser); + if (!tab) { + return; + } + // Restore search mode and its search string in userTypedValue, if + // appropriate. + let cacheState = lazy.TabStateCache.get(browser.permanentKey); + if (cacheState.searchMode) { + win.gURLBar.setSearchMode(cacheState.searchMode, browser); + browser.userTypedValue = cacheState.userTypedValue; + if (tab.selected) { + win.gURLBar.setURI(); + } + lazy.TabStateCache.update(browser.permanentKey, { + searchMode: null, + userTypedValue: null, + }); + } + + // This callback is used exclusively by tests that want to + // monitor the progress of network loads. + if (gDebuggingEnabled) { + Services.obs.notifyObservers(browser, NOTIFY_TAB_RESTORED); + } + + SessionStoreInternal._resetLocalTabRestoringState(tab); + SessionStoreInternal.restoreNextTab(); + + this._sendTabRestoredNotification(tab, data.isRemotenessUpdate); + + Services.obs.notifyObservers(null, "sessionstore-one-or-no-tab-restored"); + }, + + /** + * Send the "SessionStore:restoreHistory" message to content, triggering a + * content restore. This method is intended to be used internally by + * SessionStore, as it also ensures that permissions are avaliable in the + * content process before triggering the history restore in the content + * process. + * + * @param browser The browser to transmit the permissions for + * @param options The options data to send to content. + */ + _sendRestoreHistory(browser, options) { + if (options.tabData.storage) { + SessionStoreUtils.restoreSessionStorageFromParent( + browser.browsingContext, + options.tabData.storage + ); + delete options.tabData.storage; + } + + if (Services.appinfo.sessionHistoryInParent) { + this._restoreHistory(browser, options); + } else { + browser.messageManager.sendAsyncMessage( + "SessionStore:restoreHistory", + options + ); + } + + if (browser && browser.frameLoader) { + browser.frameLoader.requestEpochUpdate(options.epoch); + } + }, + + // Flush out session history state so that it can be used to restore the state + // into a new process in `finishTabRemotenessChange`. + // + // NOTE: This codepath is temporary while the Fission Session History rewrite + // is in process, and will be removed & replaced once that rewrite is + // complete. (bug 1645062) + async prepareToChangeRemoteness(aBrowser) { + aBrowser.messageManager.sendAsyncMessage( + "SessionStore:prepareForProcessChange" + ); + await lazy.TabStateFlusher.flush(aBrowser); + }, + + // Handle finishing the remoteness change for a tab by restoring session + // history state into it, and resuming the ongoing network load. + // + // NOTE: This codepath is temporary while the Fission Session History rewrite + // is in process, and will be removed & replaced once that rewrite is + // complete. (bug 1645062) + finishTabRemotenessChange(aTab, aSwitchId) { + let window = aTab.ownerGlobal; + if (!window || !window.__SSi || window.closed) { + return; + } + + let tabState = lazy.TabState.clone(aTab, TAB_CUSTOM_VALUES.get(aTab)); + let options = { + restoreImmediately: true, + restoreContentReason: RESTORE_TAB_CONTENT_REASON.NAVIGATE_AND_RESTORE, + isRemotenessUpdate: true, + loadArguments: { + redirectLoadSwitchId: aSwitchId, + // As we're resuming a load which has been redirected from another + // process, record the history index which is currently being requested. + // It has to be offset by 1 to get back to native history indices from + // SessionStore history indicies. + redirectHistoryIndex: tabState.requestedIndex - 1, + }, + }; + + // Need to reset restoring tabs. + if (TAB_STATE_FOR_BROWSER.has(aTab.linkedBrowser)) { + this._resetLocalTabRestoringState(aTab); + } + + // Restore the state into the tab. + this.restoreTab(aTab, tabState, options); + }, +}; + +/** + * Priority queue that keeps track of a list of tabs to restore and returns + * the tab we should restore next, based on priority rules. We decide between + * pinned, visible and hidden tabs in that and FIFO order. Hidden tabs are only + * restored with restore_hidden_tabs=true. + */ +var TabRestoreQueue = { + // The separate buckets used to store tabs. + tabs: { priority: [], visible: [], hidden: [] }, + + // Preferences used by the TabRestoreQueue to determine which tabs + // are restored automatically and which tabs will be on-demand. + prefs: { + // Lazy getter that returns whether tabs are restored on demand. + get restoreOnDemand() { + let updateValue = () => { + let value = Services.prefs.getBoolPref(PREF); + let definition = { value, configurable: true }; + Object.defineProperty(this, "restoreOnDemand", definition); + return value; + }; + + const PREF = "browser.sessionstore.restore_on_demand"; + Services.prefs.addObserver(PREF, updateValue); + return updateValue(); + }, + + // Lazy getter that returns whether pinned tabs are restored on demand. + get restorePinnedTabsOnDemand() { + let updateValue = () => { + let value = Services.prefs.getBoolPref(PREF); + let definition = { value, configurable: true }; + Object.defineProperty(this, "restorePinnedTabsOnDemand", definition); + return value; + }; + + const PREF = "browser.sessionstore.restore_pinned_tabs_on_demand"; + Services.prefs.addObserver(PREF, updateValue); + return updateValue(); + }, + + // Lazy getter that returns whether we should restore hidden tabs. + get restoreHiddenTabs() { + let updateValue = () => { + let value = Services.prefs.getBoolPref(PREF); + let definition = { value, configurable: true }; + Object.defineProperty(this, "restoreHiddenTabs", definition); + return value; + }; + + const PREF = "browser.sessionstore.restore_hidden_tabs"; + Services.prefs.addObserver(PREF, updateValue); + return updateValue(); + }, + }, + + // Resets the queue and removes all tabs. + reset() { + this.tabs = { priority: [], visible: [], hidden: [] }; + }, + + // Adds a tab to the queue and determines its priority bucket. + add(tab) { + let { priority, hidden, visible } = this.tabs; + + if (tab.pinned) { + priority.push(tab); + } else if (tab.hidden) { + hidden.push(tab); + } else { + visible.push(tab); + } + }, + + // Removes a given tab from the queue, if it's in there. + remove(tab) { + let { priority, hidden, visible } = this.tabs; + + // We'll always check priority first since we don't + // have an indicator if a tab will be there or not. + let set = priority; + let index = set.indexOf(tab); + + if (index == -1) { + set = tab.hidden ? hidden : visible; + index = set.indexOf(tab); + } + + if (index > -1) { + set.splice(index, 1); + } + }, + + // Returns and removes the tab with the highest priority. + shift() { + let set; + let { priority, hidden, visible } = this.tabs; + + let { restoreOnDemand, restorePinnedTabsOnDemand } = this.prefs; + let restorePinned = !(restoreOnDemand && restorePinnedTabsOnDemand); + if (restorePinned && priority.length) { + set = priority; + } else if (!restoreOnDemand) { + if (visible.length) { + set = visible; + } else if (this.prefs.restoreHiddenTabs && hidden.length) { + set = hidden; + } + } + + return set && set.shift(); + }, + + // Moves a given tab from the 'hidden' to the 'visible' bucket. + hiddenToVisible(tab) { + let { hidden, visible } = this.tabs; + let index = hidden.indexOf(tab); + + if (index > -1) { + hidden.splice(index, 1); + visible.push(tab); + } + }, + + // Moves a given tab from the 'visible' to the 'hidden' bucket. + visibleToHidden(tab) { + let { visible, hidden } = this.tabs; + let index = visible.indexOf(tab); + + if (index > -1) { + visible.splice(index, 1); + hidden.push(tab); + } + }, + + /** + * Returns true if the passed tab is in one of the sets that we're + * restoring content in automatically. + * + * @param tab () + * The tab to check + * @returns bool + */ + willRestoreSoon(tab) { + let { priority, hidden, visible } = this.tabs; + let { restoreOnDemand, restorePinnedTabsOnDemand, restoreHiddenTabs } = + this.prefs; + let restorePinned = !(restoreOnDemand && restorePinnedTabsOnDemand); + let candidateSet = []; + + if (restorePinned && priority.length) { + candidateSet.push(...priority); + } + + if (!restoreOnDemand) { + if (visible.length) { + candidateSet.push(...visible); + } + + if (restoreHiddenTabs && hidden.length) { + candidateSet.push(...hidden); + } + } + + return candidateSet.indexOf(tab) > -1; + }, +}; + +// A map storing a closed window's state data until it goes aways (is GC'ed). +// This ensures that API clients can still read (but not write) states of +// windows they still hold a reference to but we don't. +var DyingWindowCache = { + _data: new WeakMap(), + + has(window) { + return this._data.has(window); + }, + + get(window) { + return this._data.get(window); + }, + + set(window, data) { + this._data.set(window, data); + }, + + remove(window) { + this._data.delete(window); + }, +}; + +// A weak set of dirty windows. We use it to determine which windows we need to +// recollect data for when getCurrentState() is called. +var DirtyWindows = { + _data: new WeakMap(), + + has(window) { + return this._data.has(window); + }, + + add(window) { + return this._data.set(window, true); + }, + + remove(window) { + this._data.delete(window); + }, + + clear(window) { + this._data = new WeakMap(); + }, +}; + +// The state from the previous session (after restoring pinned tabs). This +// state is persisted and passed through to the next session during an app +// restart to make the third party add-on warning not trash the deferred +// session +var LastSession = { + _state: null, + + get canRestore() { + return !!this._state; + }, + + getState() { + return this._state; + }, + + setState(state) { + this._state = state; + }, + + clear(silent = false) { + if (this._state) { + this._state = null; + if (!silent) { + Services.obs.notifyObservers(null, NOTIFY_LAST_SESSION_CLEARED); + } + } + }, +}; + +// Exposed for tests +export const _LastSession = LastSession; diff --git a/browser/components/sessionstore/SessionWriter.sys.mjs b/browser/components/sessionstore/SessionWriter.sys.mjs new file mode 100644 index 0000000000..37f565e4af --- /dev/null +++ b/browser/components/sessionstore/SessionWriter.sys.mjs @@ -0,0 +1,396 @@ +/* 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/. */ + +/** + * We just started (we haven't written anything to disk yet) from + * `Paths.clean`. The backup directory may not exist. + */ +const STATE_CLEAN = "clean"; +/** + * We know that `Paths.recovery` is good, either because we just read + * it (we haven't written anything to disk yet) or because have + * already written once to `Paths.recovery` during this session. + * `Paths.clean` is absent or invalid. The backup directory exists. + */ +const STATE_RECOVERY = "recovery"; +/** + * We just started from `Paths.upgradeBackup` (we haven't written + * anything to disk yet). Both `Paths.clean`, `Paths.recovery` and + * `Paths.recoveryBackup` are absent or invalid. The backup directory + * exists. + */ +const STATE_UPGRADE_BACKUP = "upgradeBackup"; +/** + * We just started without a valid session store file (we haven't + * written anything to disk yet). The backup directory may not exist. + */ +const STATE_EMPTY = "empty"; + +var sessionFileIOMutex = Promise.resolve(); +// Ensure that we don't do concurrent I/O on the same file. +// Example usage: +// const unlock = await lockIOWithMutex(); +// try { +// ... (Do I/O work here.) +// } finally { unlock(); } +function lockIOWithMutex() { + // Return a Promise that resolves when the mutex is free. + return new Promise(unlock => { + // Overwrite the mutex variable with a chained-on, new Promise. The Promise + // we returned to the caller can be called to resolve that new Promise + // and unlock the mutex. + sessionFileIOMutex = sessionFileIOMutex.then(() => { + return new Promise(unlock); + }); + }); +} + +/** + * Interface dedicated to handling I/O for Session Store. + */ +export const SessionWriter = { + init(origin, useOldExtension, paths, prefs = {}) { + return SessionWriterInternal.init(origin, useOldExtension, paths, prefs); + }, + + /** + * Write the contents of the session file. + * @param state - May get changed on shutdown. + */ + async write(state, options = {}) { + const unlock = await lockIOWithMutex(); + try { + return await SessionWriterInternal.write(state, options); + } finally { + unlock(); + } + }, + + async wipe() { + const unlock = await lockIOWithMutex(); + try { + return await SessionWriterInternal.wipe(); + } finally { + unlock(); + } + }, +}; + +const SessionWriterInternal = { + // Path to the files used by the SessionWriter + Paths: null, + + /** + * The current state of the session file, as one of the following strings: + * - "empty" if we have started without any sessionstore; + * - one of "clean", "recovery", "recoveryBackup", "cleanBackup", + * "upgradeBackup" if we have started by loading the corresponding file. + */ + state: null, + + /** + * A flag that indicates we loaded a session file with the deprecated .js extension. + */ + useOldExtension: false, + + /** + * Number of old upgrade backups that are being kept + */ + maxUpgradeBackups: null, + + /** + * Initialize (or reinitialize) the writer. + * + * @param {string} origin Which of sessionstore.js or its backups + * was used. One of the `STATE_*` constants defined above. + * @param {boolean} a flag indicate whether we loaded a session file with ext .js + * @param {object} paths The paths at which to find the various files. + * @param {object} prefs The preferences the writer needs to know. + */ + init(origin, useOldExtension, paths, prefs) { + if (!(origin in paths || origin == STATE_EMPTY)) { + throw new TypeError("Invalid origin: " + origin); + } + + // Check that all required preference values were passed. + for (let pref of [ + "maxUpgradeBackups", + "maxSerializeBack", + "maxSerializeForward", + ]) { + if (!prefs.hasOwnProperty(pref)) { + throw new TypeError(`Missing preference value for ${pref}`); + } + } + + this.useOldExtension = useOldExtension; + this.state = origin; + this.Paths = paths; + this.maxUpgradeBackups = prefs.maxUpgradeBackups; + this.maxSerializeBack = prefs.maxSerializeBack; + this.maxSerializeForward = prefs.maxSerializeForward; + this.upgradeBackupNeeded = paths.nextUpgradeBackup != paths.upgradeBackup; + return { result: true }; + }, + + /** + * Write the session to disk. + * Write the session to disk, performing any necessary backup + * along the way. + * + * @param {object} state The state to write to disk. + * @param {object} options + * - performShutdownCleanup If |true|, we should + * perform shutdown-time cleanup to ensure that private data + * is not left lying around; + * - isFinalWrite If |true|, write to Paths.clean instead of + * Paths.recovery + */ + async write(state, options) { + let exn; + let telemetry = {}; + + // Cap the number of backward and forward shistory entries on shutdown. + if (options.isFinalWrite) { + for (let window of state.windows) { + for (let tab of window.tabs) { + let lower = 0; + let upper = tab.entries.length; + + if (this.maxSerializeBack > -1) { + lower = Math.max(lower, tab.index - this.maxSerializeBack - 1); + } + if (this.maxSerializeForward > -1) { + upper = Math.min(upper, tab.index + this.maxSerializeForward); + } + + tab.entries = tab.entries.slice(lower, upper); + tab.index -= lower; + } + } + } + + try { + if (this.state == STATE_CLEAN || this.state == STATE_EMPTY) { + // The backups directory may not exist yet. In all other cases, + // we have either already read from or already written to this + // directory, so we are satisfied that it exists. + await IOUtils.makeDirectory(this.Paths.backups); + } + + if (this.state == STATE_CLEAN) { + // Move $Path.clean out of the way, to avoid any ambiguity as + // to which file is more recent. + if (!this.useOldExtension) { + await IOUtils.move(this.Paths.clean, this.Paths.cleanBackup); + } else { + // Since we are migrating from .js to .jsonlz4, + // we need to compress the deprecated $Path.clean + // and write it to $Path.cleanBackup. + let oldCleanPath = this.Paths.clean.replace("jsonlz4", "js"); + let d = await IOUtils.read(oldCleanPath); + await IOUtils.write(this.Paths.cleanBackup, d, { compress: true }); + } + } + + let startWriteMs = Date.now(); + let fileStat; + + if (options.isFinalWrite) { + // We are shutting down. At this stage, we know that + // $Paths.clean is either absent or corrupted. If it was + // originally present and valid, it has been moved to + // $Paths.cleanBackup a long time ago. We can therefore write + // with the guarantees that we erase no important data. + await IOUtils.writeJSON(this.Paths.clean, state, { + tmpPath: this.Paths.clean + ".tmp", + compress: true, + }); + fileStat = await IOUtils.stat(this.Paths.clean); + } else if (this.state == STATE_RECOVERY) { + // At this stage, either $Paths.recovery was written >= 15 + // seconds ago during this session or we have just started + // from $Paths.recovery left from the previous session. Either + // way, $Paths.recovery is good. We can move $Path.backup to + // $Path.recoveryBackup without erasing a good file with a bad + // file. + await IOUtils.writeJSON(this.Paths.recovery, state, { + tmpPath: this.Paths.recovery + ".tmp", + backupFile: this.Paths.recoveryBackup, + compress: true, + }); + fileStat = await IOUtils.stat(this.Paths.recovery); + } else { + // In other cases, either $Path.recovery is not necessary, or + // it doesn't exist or it has been corrupted. Regardless, + // don't backup $Path.recovery. + await IOUtils.writeJSON(this.Paths.recovery, state, { + tmpPath: this.Paths.recovery + ".tmp", + compress: true, + }); + fileStat = await IOUtils.stat(this.Paths.recovery); + } + + telemetry.FX_SESSION_RESTORE_WRITE_FILE_MS = Date.now() - startWriteMs; + telemetry.FX_SESSION_RESTORE_FILE_SIZE_BYTES = fileStat.size; + } catch (ex) { + // Don't throw immediately + exn = exn || ex; + } + + // If necessary, perform an upgrade backup + let upgradeBackupComplete = false; + if ( + this.upgradeBackupNeeded && + (this.state == STATE_CLEAN || this.state == STATE_UPGRADE_BACKUP) + ) { + try { + // If we loaded from `clean`, the file has since then been renamed to `cleanBackup`. + let path = + this.state == STATE_CLEAN + ? this.Paths.cleanBackup + : this.Paths.upgradeBackup; + await IOUtils.copy(path, this.Paths.nextUpgradeBackup); + this.upgradeBackupNeeded = false; + upgradeBackupComplete = true; + } catch (ex) { + // Don't throw immediately + exn = exn || ex; + } + + // Find all backups + let backups = []; + + try { + let children = await IOUtils.getChildren(this.Paths.backups); + backups = children.filter(path => + path.startsWith(this.Paths.upgradeBackupPrefix) + ); + } catch (ex) { + // Don't throw immediately + exn = exn || ex; + } + + // If too many backups exist, delete them + if (backups.length > this.maxUpgradeBackups) { + // Use alphanumerical sort since dates are in YYYYMMDDHHMMSS format + backups.sort(); + // remove backup file if it is among the first (n-maxUpgradeBackups) files + for (let i = 0; i < backups.length - this.maxUpgradeBackups; i++) { + try { + await IOUtils.remove(backups[i]); + } catch (ex) { + exn = exn || ex; + } + } + } + } + + if (options.performShutdownCleanup && !exn) { + // During shutdown, if auto-restore is disabled, we need to + // remove possibly sensitive data that has been stored purely + // for crash recovery. Note that this slightly decreases our + // ability to recover from OS-level/hardware-level issue. + + // If an exception was raised, we assume that we still need + // these files. + await IOUtils.remove(this.Paths.recoveryBackup); + await IOUtils.remove(this.Paths.recovery); + } + + this.state = STATE_RECOVERY; + + if (exn) { + throw exn; + } + + return { + result: { + upgradeBackup: upgradeBackupComplete, + }, + telemetry, + }; + }, + + /** + * Wipes all files holding session data from disk. + */ + async wipe() { + // Don't stop immediately in case of error. + let exn = null; + + // Erase main session state file + try { + await IOUtils.remove(this.Paths.clean); + // Remove old extension ones. + let oldCleanPath = this.Paths.clean.replace("jsonlz4", "js"); + await IOUtils.remove(oldCleanPath, { + ignoreAbsent: true, + }); + } catch (ex) { + // Don't stop immediately. + exn = exn || ex; + } + + // Wipe the Session Restore directory + try { + await IOUtils.remove(this.Paths.backups, { recursive: true }); + } catch (ex) { + exn = exn || ex; + } + + // Wipe legacy Session Restore files from the profile directory + try { + await this._wipeFromDir(PathUtils.profileDir, "sessionstore.bak"); + } catch (ex) { + exn = exn || ex; + } + + this.state = STATE_EMPTY; + if (exn) { + throw exn; + } + + return { result: true }; + }, + + /** + * Wipe a number of files from a directory. + * + * @param {string} path The directory. + * @param {string} prefix Remove files whose + * name starts with the prefix. + */ + async _wipeFromDir(path, prefix) { + // Sanity check + if (!prefix) { + throw new TypeError("Must supply prefix"); + } + + let exn = null; + + let children = await IOUtils.getChildren(path, { + ignoreAbsent: true, + }); + for (let entryPath of children) { + if (!PathUtils.filename(entryPath).startsWith(prefix)) { + continue; + } + try { + let { type } = await IOUtils.stat(entryPath); + if (type == "directory") { + continue; + } + await IOUtils.remove(entryPath); + } catch (ex) { + // Don't stop immediately + exn = exn || ex; + } + } + + if (exn) { + throw exn; + } + }, +}; diff --git a/browser/components/sessionstore/StartupPerformance.sys.mjs b/browser/components/sessionstore/StartupPerformance.sys.mjs new file mode 100644 index 0000000000..a13333d9d1 --- /dev/null +++ b/browser/components/sessionstore/StartupPerformance.sys.mjs @@ -0,0 +1,242 @@ +/* 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, { + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +const COLLECT_RESULTS_AFTER_MS = 10000; + +const OBSERVED_TOPICS = [ + "sessionstore-restoring-on-startup", + "sessionstore-initiating-manual-restore", +]; + +export var StartupPerformance = { + /** + * Once we have finished restoring initial tabs, we broadcast on this topic. + */ + RESTORED_TOPIC: "sessionstore-finished-restoring-initial-tabs", + + // Instant at which we have started restoration (notification "sessionstore-restoring-on-startup") + _startTimeStamp: null, + + // Latest instant at which we have finished restoring a tab (DOM event "SSTabRestored") + _latestRestoredTimeStamp: null, + + // A promise resolved once we have finished restoring all the startup tabs. + _promiseFinished: null, + + // Function `resolve()` for `_promiseFinished`. + _resolveFinished: null, + + // A timer + _deadlineTimer: null, + + // `true` once the timer has fired + _hasFired: false, + + // `true` once we are restored + _isRestored: false, + + // Statistics on the session we need to restore. + _totalNumberOfEagerTabs: 0, + _totalNumberOfTabs: 0, + _totalNumberOfWindows: 0, + + init() { + for (let topic of OBSERVED_TOPICS) { + Services.obs.addObserver(this, topic); + } + }, + + /** + * Return the timestamp at which we finished restoring the latest tab. + * + * This information is not really interesting until we have finished restoring + * tabs. + */ + get latestRestoredTimeStamp() { + return this._latestRestoredTimeStamp; + }, + + /** + * `true` once we have finished restoring startup tabs. + */ + get isRestored() { + return this._isRestored; + }, + + // Called when restoration starts. + // Record the start timestamp, setup the timer and `this._promiseFinished`. + // Behavior is unspecified if there was already an ongoing measure. + _onRestorationStarts(isAutoRestore) { + ChromeUtils.addProfilerMarker("_onRestorationStarts"); + this._latestRestoredTimeStamp = this._startTimeStamp = Date.now(); + this._totalNumberOfEagerTabs = 0; + this._totalNumberOfTabs = 0; + this._totalNumberOfWindows = 0; + + // While we may restore several sessions in a single run of the browser, + // that's a very unusual case, and not really worth measuring, so let's + // stop listening for further restorations. + + for (let topic of OBSERVED_TOPICS) { + Services.obs.removeObserver(this, topic); + } + + Services.obs.addObserver(this, "sessionstore-single-window-restored"); + this._promiseFinished = new Promise(resolve => { + this._resolveFinished = resolve; + }); + this._promiseFinished.then(() => { + try { + this._isRestored = true; + Services.obs.notifyObservers(null, this.RESTORED_TOPIC); + + if (this._latestRestoredTimeStamp == this._startTimeStamp) { + // Apparently, we haven't restored any tab. + return; + } + + // Once we are done restoring tabs, update Telemetry. + let histogramName = isAutoRestore + ? "FX_SESSION_RESTORE_AUTO_RESTORE_DURATION_UNTIL_EAGER_TABS_RESTORED_MS" + : "FX_SESSION_RESTORE_MANUAL_RESTORE_DURATION_UNTIL_EAGER_TABS_RESTORED_MS"; + let histogram = Services.telemetry.getHistogramById(histogramName); + let delta = this._latestRestoredTimeStamp - this._startTimeStamp; + histogram.add(delta); + + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_NUMBER_OF_EAGER_TABS_RESTORED") + .add(this._totalNumberOfEagerTabs); + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_NUMBER_OF_TABS_RESTORED") + .add(this._totalNumberOfTabs); + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_NUMBER_OF_WINDOWS_RESTORED") + .add(this._totalNumberOfWindows); + + // Reset + this._startTimeStamp = null; + } catch (ex) { + console.error("StartupPerformance: error after resolving promise", ex); + } + }); + }, + + _startTimer() { + if (this._hasFired) { + return; + } + if (this._deadlineTimer) { + lazy.clearTimeout(this._deadlineTimer); + } + this._deadlineTimer = lazy.setTimeout(() => { + try { + this._resolveFinished(); + } catch (ex) { + console.error("StartupPerformance: Error in timeout handler", ex); + } finally { + // Clean up. + this._deadlineTimer = null; + this._hasFired = true; + this._resolveFinished = null; + Services.obs.removeObserver( + this, + "sessionstore-single-window-restored" + ); + } + }, COLLECT_RESULTS_AFTER_MS); + }, + + observe(subject, topic, details) { + try { + switch (topic) { + case "sessionstore-restoring-on-startup": + this._onRestorationStarts(true); + break; + case "sessionstore-initiating-manual-restore": + this._onRestorationStarts(false); + break; + case "sessionstore-single-window-restored": + { + // Session Restore has just opened a window with (initially empty) tabs. + // Some of these tabs will be restored eagerly, while others will be + // restored on demand. The process becomes usable only when all windows + // have finished restored their eager tabs. + // + // While it would be possible to track the restoration of each tab + // from within SessionRestore to determine exactly when the process + // becomes usable, experience shows that this is too invasive. Rather, + // we employ the following heuristic: + // - we maintain a timer of `COLLECT_RESULTS_AFTER_MS` that we expect + // will be triggered only once all tabs have been restored; + // - whenever we restore a new window (hence a bunch of eager tabs), + // we postpone the timer to ensure that the new eager tabs have + // `COLLECT_RESULTS_AFTER_MS` to be restored; + // - whenever a tab is restored, we update + // `this._latestRestoredTimeStamp`; + // - after `COLLECT_RESULTS_AFTER_MS`, we collect the final version + // of `this._latestRestoredTimeStamp`, and use it to determine the + // entire duration of the collection. + // + // Note that this heuristic may be inaccurate if a user clicks + // immediately on a restore-on-demand tab before the end of + // `COLLECT_RESULTS_AFTER_MS`. We assume that this will not + // affect too much the results. + // + // Reset the delay, to give the tabs a little (more) time to restore. + this._startTimer(); + + this._totalNumberOfWindows += 1; + + // Observe the restoration of all tabs. We assume that all tabs of this + // window will have been restored before `COLLECT_RESULTS_AFTER_MS`. + // The last call to `observer` will let us determine how long it took + // to reach that point. + let win = subject; + + let observer = event => { + // We don't care about tab restorations that are due to + // a browser flipping from out-of-main-process to in-main-process + // or vice-versa. We only care about restorations that are due + // to the user switching to a lazily restored tab, or for tabs + // that are restoring eagerly. + if (!event.detail.isRemotenessUpdate) { + ChromeUtils.addProfilerMarker("SSTabRestored"); + this._latestRestoredTimeStamp = Date.now(); + this._totalNumberOfEagerTabs += 1; + } + }; + win.gBrowser.tabContainer.addEventListener( + "SSTabRestored", + observer + ); + this._totalNumberOfTabs += win.gBrowser.tabContainer.itemCount; + + // Once we have finished collecting the results, clean up the observers. + this._promiseFinished.then(() => { + if (!win.gBrowser.tabContainer) { + // May be undefined during shutdown and/or some tests. + return; + } + win.gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + observer + ); + }); + } + break; + default: + throw new Error(`Unexpected topic ${topic}`); + } + } catch (ex) { + console.error("StartupPerformance error", ex, ex.stack); + throw ex; + } + }, +}; diff --git a/browser/components/sessionstore/TabAttributes.sys.mjs b/browser/components/sessionstore/TabAttributes.sys.mjs new file mode 100644 index 0000000000..1c7f54b6ab --- /dev/null +++ b/browser/components/sessionstore/TabAttributes.sys.mjs @@ -0,0 +1,72 @@ +/* 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/. */ + +// We never want to directly read or write these attributes. +// 'image' should not be accessed directly but handled by using the +// gBrowser.getIcon()/setIcon() methods. +// 'muted' should not be accessed directly but handled by using the +// tab.linkedBrowser.audioMuted/toggleMuteAudio methods. +// 'pending' is used internal by sessionstore and managed accordingly. +const ATTRIBUTES_TO_SKIP = new Set([ + "image", + "muted", + "pending", + "skipbackgroundnotify", +]); + +// A set of tab attributes to persist. We will read a given list of tab +// attributes when collecting tab data and will re-set those attributes when +// the given tab data is restored to a new tab. +export var TabAttributes = Object.freeze({ + persist(name) { + return TabAttributesInternal.persist(name); + }, + + get(tab) { + return TabAttributesInternal.get(tab); + }, + + set(tab, data = {}) { + TabAttributesInternal.set(tab, data); + }, +}); + +var TabAttributesInternal = { + _attrs: new Set(), + + persist(name) { + if (this._attrs.has(name) || ATTRIBUTES_TO_SKIP.has(name)) { + return false; + } + + this._attrs.add(name); + return true; + }, + + get(tab) { + let data = {}; + + for (let name of this._attrs) { + if (tab.hasAttribute(name)) { + data[name] = tab.getAttribute(name); + } + } + + return data; + }, + + set(tab, data = {}) { + // Clear attributes. + for (let name of this._attrs) { + tab.removeAttribute(name); + } + + // Set attributes. + for (let [name, value] of Object.entries(data)) { + if (!ATTRIBUTES_TO_SKIP.has(name)) { + tab.setAttribute(name, value); + } + } + }, +}; diff --git a/browser/components/sessionstore/TabState.sys.mjs b/browser/components/sessionstore/TabState.sys.mjs new file mode 100644 index 0000000000..26f5671c84 --- /dev/null +++ b/browser/components/sessionstore/TabState.sys.mjs @@ -0,0 +1,204 @@ +/* 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, { + PrivacyFilter: "resource://gre/modules/sessionstore/PrivacyFilter.sys.mjs", + TabAttributes: "resource:///modules/sessionstore/TabAttributes.sys.mjs", + TabStateCache: "resource:///modules/sessionstore/TabStateCache.sys.mjs", +}); + +/** + * Module that contains tab state collection methods. + */ +export var TabState = Object.freeze({ + update(permanentKey, data) { + TabStateInternal.update(permanentKey, data); + }, + + collect(tab, extData) { + return TabStateInternal.collect(tab, extData); + }, + + clone(tab, extData) { + return TabStateInternal.clone(tab, extData); + }, + + copyFromCache(permanentKey, tabData, options) { + TabStateInternal.copyFromCache(permanentKey, tabData, options); + }, +}); + +var TabStateInternal = { + /** + * Processes a data update sent by the content script. + */ + update(permanentKey, { data }) { + lazy.TabStateCache.update(permanentKey, data); + }, + + /** + * Collect data related to a single tab, synchronously. + * + * @param tab + * tabbrowser tab + * @param [extData] + * optional dictionary object, containing custom tab values. + * + * @returns {TabData} An object with the data for this tab. If the + * tab has not been invalidated since the last call to + * collect(aTab), the same object is returned. + */ + collect(tab, extData) { + return this._collectBaseTabData(tab, { extData }); + }, + + /** + * Collect data related to a single tab, including private data. + * Use with caution. + * + * @param tab + * tabbrowser tab + * @param [extData] + * optional dictionary object, containing custom tab values. + * + * @returns {object} An object with the data for this tab. This data is never + * cached, it will always be read from the tab and thus be + * up-to-date. + */ + clone(tab, extData) { + return this._collectBaseTabData(tab, { extData, includePrivateData: true }); + }, + + /** + * Collects basic tab data for a given tab. + * + * @param tab + * tabbrowser tab + * @param options (object) + * {extData: object} optional dictionary object, containing custom tab values + * {includePrivateData: true} to always include private data + * + * @returns {object} An object with the basic data for this tab. + */ + _collectBaseTabData(tab, options) { + let tabData = { entries: [], lastAccessed: tab.lastAccessed }; + let browser = tab.linkedBrowser; + + if (tab.pinned) { + tabData.pinned = true; + } + + tabData.hidden = tab.hidden; + + if (browser.audioMuted) { + tabData.muted = true; + tabData.muteReason = tab.muteReason; + } + + tabData.searchMode = tab.ownerGlobal.gURLBar.getSearchMode(browser, true); + + tabData.userContextId = tab.userContextId || 0; + + // Save tab attributes. + tabData.attributes = lazy.TabAttributes.get(tab); + + if (options.extData) { + tabData.extData = options.extData; + } + + // Copy data from the tab state cache only if the tab has fully finished + // restoring. We don't want to overwrite data contained in __SS_data. + this.copyFromCache(browser.permanentKey, tabData, options); + + // After copyFromCache() was called we check for properties that are kept + // in the cache only while the tab is pending or restoring. Once that + // happened those properties will be removed from the cache and will + // be read from the tab/browser every time we collect data. + + // Store the tab icon. + if (!("image" in tabData)) { + let tabbrowser = tab.ownerGlobal.gBrowser; + tabData.image = tabbrowser.getIcon(tab); + } + + // If there is a userTypedValue set, then either the user has typed something + // in the URL bar, or a new tab was opened with a URI to load. + // If so, we also track whether we were still in the process of loading something. + if (!("userTypedValue" in tabData) && browser.userTypedValue) { + tabData.userTypedValue = browser.userTypedValue; + // We always used to keep track of the loading state as an integer, where + // '0' indicated the user had typed since the last load (or no load was + // ongoing), and any positive value indicated we had started a load since + // the last time the user typed in the URL bar. Mimic this to keep the + // session store representation in sync, even though we now represent this + // more explicitly: + tabData.userTypedClear = browser.didStartLoadSinceLastUserTyping() + ? 1 + : 0; + } + + return tabData; + }, + + /** + * Copy data for the given |browser| from the cache to |tabData|. + * + * @param permanentKey (object) + * The browser belonging to the given |tabData| object. + * @param tabData (object) + * The tab data belonging to the given |tab|. + * @param options (object) + * {includePrivateData: true} to always include private data + */ + copyFromCache(permanentKey, tabData, options = {}) { + let data = lazy.TabStateCache.get(permanentKey); + if (!data) { + return; + } + + // The caller may explicitly request to omit privacy checks. + let includePrivateData = options && options.includePrivateData; + + for (let key of Object.keys(data)) { + let value = data[key]; + + // Filter sensitive data according to the current privacy level. + if (!includePrivateData) { + if (key === "storage") { + value = lazy.PrivacyFilter.filterSessionStorageData(value); + } else if (key === "formdata") { + value = lazy.PrivacyFilter.filterFormData(value); + } + } + + if (key === "history") { + // Make a shallow copy of the entries array. We (currently) don't update + // entries in place, so we don't have to worry about performing a deep + // copy. + tabData.entries = [...value.entries]; + + if (value.hasOwnProperty("index")) { + tabData.index = value.index; + } + + if (value.hasOwnProperty("requestedIndex")) { + tabData.requestedIndex = value.requestedIndex; + } + } else if (!value && (key == "scroll" || key == "formdata")) { + // [Bug 1554512] + + // If scroll or formdata null it indicates that the update to + // be performed is to remove them, and not copy a null + // value. Scroll will be null when the position is at the top + // of the document, formdata will be null when there is only + // default data. + delete tabData[key]; + } else { + tabData[key] = value; + } + } + }, +}; diff --git a/browser/components/sessionstore/TabStateCache.sys.mjs b/browser/components/sessionstore/TabStateCache.sys.mjs new file mode 100644 index 0000000000..81524c4d69 --- /dev/null +++ b/browser/components/sessionstore/TabStateCache.sys.mjs @@ -0,0 +1,171 @@ +/* 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 cache for tabs data. + * + * This cache implements a weak map from tabs (as XUL elements) + * to tab data (as objects). + * + * Note that we should never cache private data, as: + * - that data is used very seldom by SessionStore; + * - caching private data in addition to public data is memory consuming. + */ +export var TabStateCache = Object.freeze({ + /** + * Retrieves cached data for a given |tab| or associated |browser|. + * + * @param permanentKey (object) + * The tab or browser to retrieve cached data for. + * @return (object) + * The cached data stored for the given |tab| + * or associated |browser|. + */ + get(permanentKey) { + return TabStateCacheInternal.get(permanentKey); + }, + + /** + * Updates cached data for a given |tab| or associated |browser|. + * + * @param permanentKey (object) + * The tab or browser belonging to the given tab data. + * @param newData (object) + * The new data to be stored for the given |tab| + * or associated |browser|. + */ + update(permanentKey, newData) { + TabStateCacheInternal.update(permanentKey, newData); + }, +}); + +var TabStateCacheInternal = { + _data: new WeakMap(), + + /** + * Retrieves cached data for a given |tab| or associated |browser|. + * + * @param permanentKey (object) + * The tab or browser to retrieve cached data for. + * @return (object) + * The cached data stored for the given |tab| + * or associated |browser|. + */ + get(permanentKey) { + return this._data.get(permanentKey); + }, + + /** + * Helper function used by update (see below). For message size + * optimization sometimes we don't update the whole session storage + * only the values that have been changed. + * + * @param data (object) + * The cached data where we want to update the changes. + * @param change (object) + * The actual changed values per domain. + */ + updatePartialStorageChange(data, change) { + if (!data.storage) { + data.storage = {}; + } + + let storage = data.storage; + for (let domain of Object.keys(change)) { + if (!change[domain]) { + // We were sent null in place of the change object, which means + // we should delete session storage entirely for this domain. + delete storage[domain]; + } else { + for (let key of Object.keys(change[domain])) { + let value = change[domain][key]; + if (value === null) { + if (storage[domain] && storage[domain][key]) { + delete storage[domain][key]; + } + } else { + if (!storage[domain]) { + storage[domain] = {}; + } + storage[domain][key] = value; + } + } + } + } + }, + + /** + * Helper function used by update (see below). For message size + * optimization sometimes we don't update the whole browser history + * only the current index and the tail of the history from a certain + * index (specified by change.fromIdx) + * + * @param data (object) + * The cached data where we want to update the changes. + * @param change (object) + * Object containing the tail of the history array, and + * some additional metadata. + */ + updatePartialHistoryChange(data, change) { + const kLastIndex = Number.MAX_SAFE_INTEGER - 1; + + if (!data.history) { + data.history = { entries: [] }; + } + + let history = data.history; + let toIdx = history.entries.length; + if ("toIdx" in change) { + toIdx = Math.min(toIdx, change.toIdx + 1); + } + + for (let key of Object.keys(change)) { + if (key == "entries") { + if (change.fromIdx != kLastIndex) { + let start = change.fromIdx + 1; + history.entries.splice.apply( + history.entries, + [start, toIdx - start].concat(change.entries) + ); + } + } else if (key != "fromIdx" && key != "toIdx") { + history[key] = change[key]; + } + } + }, + + /** + * Updates cached data for a given |tab| or associated |browser|. + * + * @param permanentKey (object) + * The tab or browser belonging to the given tab data. + * @param newData (object) + * The new data to be stored for the given |tab| + * or associated |browser|. + */ + update(permanentKey, newData) { + let data = this._data.get(permanentKey) || {}; + + for (let key of Object.keys(newData)) { + if (key == "storagechange") { + this.updatePartialStorageChange(data, newData.storagechange); + continue; + } + + if (key == "historychange") { + this.updatePartialHistoryChange(data, newData.historychange); + continue; + } + + let value = newData[key]; + if (value === null) { + delete data[key]; + } else { + data[key] = value; + } + } + + this._data.set(permanentKey, data); + }, +}; diff --git a/browser/components/sessionstore/TabStateFlusher.sys.mjs b/browser/components/sessionstore/TabStateFlusher.sys.mjs new file mode 100644 index 0000000000..e391abc970 --- /dev/null +++ b/browser/components/sessionstore/TabStateFlusher.sys.mjs @@ -0,0 +1,234 @@ +/* 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, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +/** + * A module that enables async flushes. Updates from frame scripts are + * throttled to be sent only once per second. If an action wants a tab's latest + * state without waiting for a second then it can request an async flush and + * wait until the frame scripts reported back. At this point the parent has the + * latest data and the action can continue. + */ +export var TabStateFlusher = Object.freeze({ + /** + * Requests an async flush for the given browser. Returns a promise that will + * resolve when we heard back from the content process and the parent has + * all the latest data. + */ + flush(browser) { + return TabStateFlusherInternal.flush(browser); + }, + + /** + * Requests an async flush for all browsers of a given window. Returns a Promise + * that will resolve when we've heard back from all browsers. + */ + flushWindow(window) { + return TabStateFlusherInternal.flushWindow(window); + }, + + /** + * Resolves the flush request with the given flush ID. + * + * @param browser () + * The browser for which the flush is being resolved. + * @param flushID (int) + * The ID of the flush that was sent to the browser. + * @param success (bool, optional) + * Whether or not the flush succeeded. + * @param message (string, optional) + * An error message that will be sent to the Console in the + * event that a flush failed. + */ + resolve(browser, flushID, success = true, message = "") { + TabStateFlusherInternal.resolve(browser, flushID, success, message); + }, + + /** + * Resolves all active flush requests for a given browser. This should be + * used when the content process crashed or the final update message was + * seen. In those cases we can't guarantee to ever hear back from the frame + * script so we just resolve all requests instead of discarding them. + * + * @param browser () + * The browser for which all flushes are being resolved. + * @param success (bool, optional) + * Whether or not the flushes succeeded. + * @param message (string, optional) + * An error message that will be sent to the Console in the + * event that the flushes failed. + */ + resolveAll(browser, success = true, message = "") { + TabStateFlusherInternal.resolveAll(browser, success, message); + }, +}); + +var TabStateFlusherInternal = { + // Stores the last request ID. + _lastRequestID: 0, + + // A map storing all active requests per browser. A request is a + // triple of a map containing all flush requests, a promise that + // resolve when a request for a browser is canceled, and the + // function to call to cancel a reqeust. + _requests: new WeakMap(), + + initEntry(entry) { + entry.perBrowserRequests = new Map(); + entry.cancelPromise = new Promise(resolve => { + entry.cancel = resolve; + }).then(result => { + TabStateFlusherInternal.initEntry(entry); + return result; + }); + + return entry; + }, + + /** + * Requests an async flush for the given browser. Returns a promise that will + * resolve when we heard back from the content process and the parent has + * all the latest data. + */ + flush(browser) { + let id = ++this._lastRequestID; + let nativePromise = Promise.resolve(); + if (browser && browser.frameLoader) { + /* + Request native listener to flush the tabState. + Resolves when flush is complete. + */ + nativePromise = browser.frameLoader.requestTabStateFlush(); + } + + if (!Services.appinfo.sessionHistoryInParent) { + /* + In the event that we have to trigger a process switch and thus change + browser remoteness, session store needs to register and track the new + browser window loaded and to have message manager listener registered + ** before ** TabStateFlusher send "SessionStore:flush" message. This fixes + the race where we send the message before the message listener is + registered for it. + */ + lazy.SessionStore.ensureInitialized(browser.ownerGlobal); + + let mm = browser.messageManager; + mm.sendAsyncMessage("SessionStore:flush", { + id, + epoch: lazy.SessionStore.getCurrentEpoch(browser), + }); + } + + // Retrieve active requests for given browser. + let permanentKey = browser.permanentKey; + let request = this._requests.get(permanentKey); + if (!request) { + // If we don't have any requests for this browser, create a new + // entry for browser. + request = this.initEntry({}); + this._requests.set(permanentKey, request); + } + + // Non-SHIP flushes resolve this after the "SessionStore:update" message. We + // don't use that message for SHIP, so it's fine to resolve the request + // immediately after the native promise resolves, since SessionStore will + // have processed all updates from this browser by that point. + let requestPromise = Promise.resolve(); + if (!Services.appinfo.sessionHistoryInParent) { + requestPromise = new Promise(resolve => { + // Store resolve() so that we can resolve the promise later. + request.perBrowserRequests.set(id, resolve); + }); + } + + return Promise.race([ + nativePromise.then(_ => requestPromise), + request.cancelPromise, + ]); + }, + + /** + * Requests an async flush for all non-lazy browsers of a given window. + * Returns a Promise that will resolve when we've heard back from all browsers. + */ + flushWindow(window) { + let promises = []; + for (let browser of window.gBrowser.browsers) { + if (window.gBrowser.getTabForBrowser(browser).linkedPanel) { + promises.push(this.flush(browser)); + } + } + return Promise.all(promises); + }, + + /** + * Resolves the flush request with the given flush ID. + * + * @param browser () + * The browser for which the flush is being resolved. + * @param flushID (int) + * The ID of the flush that was sent to the browser. + * @param success (bool, optional) + * Whether or not the flush succeeded. + * @param message (string, optional) + * An error message that will be sent to the Console in the + * event that a flush failed. + */ + resolve(browser, flushID, success = true, message = "") { + // Nothing to do if there are no pending flushes for the given browser. + if (!this._requests.has(browser.permanentKey)) { + return; + } + + // Retrieve active requests for given browser. + let { perBrowserRequests } = this._requests.get(browser.permanentKey); + if (!perBrowserRequests.has(flushID)) { + return; + } + + if (!success) { + console.error("Failed to flush browser: ", message); + } + + // Resolve the request with the given id. + let resolve = perBrowserRequests.get(flushID); + perBrowserRequests.delete(flushID); + resolve(success); + }, + + /** + * Resolves all active flush requests for a given browser. This should be + * used when the content process crashed or the final update message was + * seen. In those cases we can't guarantee to ever hear back from the frame + * script so we just resolve all requests instead of discarding them. + * + * @param browser () + * The browser for which all flushes are being resolved. + * @param success (bool, optional) + * Whether or not the flushes succeeded. + * @param message (string, optional) + * An error message that will be sent to the Console in the + * event that the flushes failed. + */ + resolveAll(browser, success = true, message = "") { + // Nothing to do if there are no pending flushes for the given browser. + if (!this._requests.has(browser.permanentKey)) { + return; + } + + // Retrieve the cancel function for a given browser. + let { cancel } = this._requests.get(browser.permanentKey); + + if (!success) { + console.error("Failed to flush browser: ", message); + } + + // Resolve all requests. + cancel(success); + }, +}; diff --git a/browser/components/sessionstore/content/aboutSessionRestore.js b/browser/components/sessionstore/content/aboutSessionRestore.js new file mode 100644 index 0000000000..51bed7c51b --- /dev/null +++ b/browser/components/sessionstore/content/aboutSessionRestore.js @@ -0,0 +1,447 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +ChromeUtils.defineESModuleGetters(this, { + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +var gStateObject; +var gTreeData; +var gTreeInitialized = false; + +// Page initialization + +window.onload = function () { + let toggleTabs = document.getElementById("tabsToggle"); + if (toggleTabs) { + let tabList = document.getElementById("tabList"); + + let toggleHiddenTabs = () => { + toggleTabs.classList.toggle("tabs-hidden"); + tabList.hidden = toggleTabs.classList.contains("tabs-hidden"); + initTreeView(); + }; + toggleTabs.onclick = toggleHiddenTabs; + } + + // wire up click handlers for the radio buttons if they exist. + for (let radioId of ["radioRestoreAll", "radioRestoreChoose"]) { + let button = document.getElementById(radioId); + if (button) { + button.addEventListener("click", updateTabListVisibility); + } + } + + var tabListTree = document.getElementById("tabList"); + tabListTree.addEventListener("click", onListClick); + tabListTree.addEventListener("keydown", onListKeyDown); + + var errorCancelButton = document.getElementById("errorCancel"); + // aboutSessionRestore.js is included aboutSessionRestore.xhtml + // and aboutWelcomeBack.xhtml, but the latter does not have an + // errorCancel button. + if (errorCancelButton) { + errorCancelButton.addEventListener("command", startNewSession); + } + + var errorTryAgainButton = document.getElementById("errorTryAgain"); + errorTryAgainButton.addEventListener("command", restoreSession); + + // the crashed session state is kept inside a textbox so that SessionStore picks it up + // (for when the tab is closed or the session crashes right again) + var sessionData = document.getElementById("sessionData"); + if (!sessionData.value) { + errorTryAgainButton.disabled = true; + return; + } + + gStateObject = JSON.parse(sessionData.value); + + // make sure the data is tracked to be restored in case of a subsequent crash + var event = document.createEvent("UIEvents"); + event.initUIEvent("input", true, true, window, 0); + sessionData.dispatchEvent(event); + + initTreeView(); + + errorTryAgainButton.focus({ focusVisible: false }); +}; + +function isTreeViewVisible() { + return !document.getElementById("tabList").hidden; +} + +async function initTreeView() { + if (gTreeInitialized || !isTreeViewVisible()) { + return; + } + + var tabList = document.getElementById("tabList"); + let l10nIds = []; + for ( + let labelIndex = 0; + labelIndex < gStateObject.windows.length; + labelIndex++ + ) { + l10nIds.push({ + id: "restore-page-window-label", + args: { windowNumber: labelIndex + 1 }, + }); + } + let winLabels = await document.l10n.formatValues(l10nIds); + gTreeData = []; + gStateObject.windows.forEach(function (aWinData, aIx) { + var winState = { + label: winLabels[aIx], + open: true, + checked: true, + ix: aIx, + }; + winState.tabs = aWinData.tabs.map(function (aTabData) { + var entry = aTabData.entries[aTabData.index - 1] || { + url: "about:blank", + }; + // don't initiate a connection just to fetch a favicon (see bug 462863) + return { + label: entry.title || entry.url, + checked: true, + src: PlacesUIUtils.getImageURL(aTabData.image), + parent: winState, + }; + }); + gTreeData.push(winState); + for (let tab of winState.tabs) { + gTreeData.push(tab); + } + }, this); + + tabList.view = treeView; + tabList.view.selection.select(0); + gTreeInitialized = true; +} + +// User actions +function updateTabListVisibility() { + document.getElementById("tabList").hidden = + !document.getElementById("radioRestoreChoose").checked; + initTreeView(); +} + +function restoreSession() { + Services.obs.notifyObservers(null, "sessionstore-initiating-manual-restore"); + document.getElementById("errorTryAgain").disabled = true; + + if (isTreeViewVisible()) { + if (!gTreeData.some(aItem => aItem.checked)) { + // This should only be possible when we have no "cancel" button, and thus + // the "Restore session" button always remains enabled. In that case and + // when nothing is selected, we just want a new session. + startNewSession(); + return; + } + + // remove all unselected tabs from the state before restoring it + var ix = gStateObject.windows.length - 1; + for (var t = gTreeData.length - 1; t >= 0; t--) { + if (treeView.isContainer(t)) { + if (gTreeData[t].checked === 0) { + // this window will be restored partially + gStateObject.windows[ix].tabs = gStateObject.windows[ix].tabs.filter( + (aTabData, aIx) => gTreeData[t].tabs[aIx].checked + ); + } else if (!gTreeData[t].checked) { + // this window won't be restored at all + gStateObject.windows.splice(ix, 1); + } + ix--; + } + } + } + var stateString = JSON.stringify(gStateObject); + + var top = getBrowserWindow(); + + // if there's only this page open, reuse the window for restoring the session + if (top.gBrowser.tabs.length == 1) { + SessionStore.setWindowState(top, stateString, true); + return; + } + + // restore the session into a new window and close the current tab + var newWindow = top.openDialog( + top.location, + "_blank", + "chrome,dialog=no,all" + ); + + Services.obs.addObserver(function observe(win, topic) { + if (win != newWindow) { + return; + } + + Services.obs.removeObserver(observe, topic); + SessionStore.setWindowState(newWindow, stateString, true); + + let tabbrowser = top.gBrowser; + let browser = window.docShell.chromeEventHandler; + let tab = tabbrowser.getTabForBrowser(browser); + tabbrowser.removeTab(tab); + }, "browser-delayed-startup-finished"); +} + +function startNewSession() { + if (Services.prefs.getIntPref("browser.startup.page") == 0) { + getBrowserWindow().gBrowser.loadURI(Services.io.newURI("about:blank"), { + triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal( + {} + ), + }); + } else { + getBrowserWindow().BrowserHome(); + } +} + +function onListClick(aEvent) { + // don't react to right-clicks + if (aEvent.button == 2) { + return; + } + + var cell = treeView.treeBox.getCellAt(aEvent.clientX, aEvent.clientY); + if (cell.col) { + // Restore this specific tab in the same window for middle/double/accel clicking + // on a tab's title. + let accelKey = + AppConstants.platform == "macosx" ? aEvent.metaKey : aEvent.ctrlKey; + if ( + (aEvent.button == 1 || + (aEvent.button == 0 && aEvent.detail == 2) || + accelKey) && + cell.col.id == "title" && + !treeView.isContainer(cell.row) + ) { + restoreSingleTab(cell.row, aEvent.shiftKey); + aEvent.stopPropagation(); + } else if (cell.col.id == "restore") { + toggleRowChecked(cell.row); + } + } +} + +function onListKeyDown(aEvent) { + switch (aEvent.keyCode) { + case KeyEvent.DOM_VK_SPACE: + toggleRowChecked(document.getElementById("tabList").currentIndex); + // Prevent page from scrolling on the space key. + aEvent.preventDefault(); + break; + case KeyEvent.DOM_VK_RETURN: + var ix = document.getElementById("tabList").currentIndex; + if (aEvent.ctrlKey && !treeView.isContainer(ix)) { + restoreSingleTab(ix, aEvent.shiftKey); + } + break; + } +} + +// Helper functions + +function getBrowserWindow() { + return window.browsingContext.topChromeWindow; +} + +function toggleRowChecked(aIx) { + function isChecked(aItem) { + return aItem.checked; + } + + var item = gTreeData[aIx]; + item.checked = !item.checked; + treeView.treeBox.invalidateRow(aIx); + + if (treeView.isContainer(aIx)) { + // (un)check all tabs of this window as well + for (let tab of item.tabs) { + tab.checked = item.checked; + treeView.treeBox.invalidateRow(gTreeData.indexOf(tab)); + } + } else { + // Update the window's checkmark as well (0 means "partially checked"). + let state = false; + if (item.parent.tabs.every(isChecked)) { + state = true; + } else if (item.parent.tabs.some(isChecked)) { + state = 0; + } + item.parent.checked = state; + + treeView.treeBox.invalidateRow(gTreeData.indexOf(item.parent)); + } + + // we only disable the button when there's no cancel button. + if (document.getElementById("errorCancel")) { + document.getElementById("errorTryAgain").disabled = + !gTreeData.some(isChecked); + } +} + +function restoreSingleTab(aIx, aShifted) { + var tabbrowser = getBrowserWindow().gBrowser; + var newTab = tabbrowser.addWebTab(); + var item = gTreeData[aIx]; + + var tabState = + gStateObject.windows[item.parent.ix].tabs[ + aIx - gTreeData.indexOf(item.parent) - 1 + ]; + // ensure tab would be visible on the tabstrip. + tabState.hidden = false; + SessionStore.setTabState(newTab, JSON.stringify(tabState)); + + // respect the preference as to whether to select the tab (the Shift key inverses) + if ( + Services.prefs.getBoolPref("browser.tabs.loadInBackground") != !aShifted + ) { + tabbrowser.selectedTab = newTab; + } +} + +// Tree controller + +var treeView = { + treeBox: null, + selection: null, + + get rowCount() { + return gTreeData.length; + }, + setTree(treeBox) { + this.treeBox = treeBox; + }, + getCellText(idx, column) { + return gTreeData[idx].label; + }, + isContainer(idx) { + return "open" in gTreeData[idx]; + }, + getCellValue(idx, column) { + return gTreeData[idx].checked; + }, + isContainerOpen(idx) { + return gTreeData[idx].open; + }, + isContainerEmpty(idx) { + return false; + }, + isSeparator(idx) { + return false; + }, + isSorted() { + return false; + }, + isEditable(idx, column) { + return false; + }, + canDrop(idx, orientation, dt) { + return false; + }, + getLevel(idx) { + return this.isContainer(idx) ? 0 : 1; + }, + + getParentIndex(idx) { + if (!this.isContainer(idx)) { + for (var t = idx - 1; t >= 0; t--) { + if (this.isContainer(t)) { + return t; + } + } + } + return -1; + }, + + hasNextSibling(idx, after) { + var thisLevel = this.getLevel(idx); + for (var t = after + 1; t < gTreeData.length; t++) { + if (this.getLevel(t) <= thisLevel) { + return this.getLevel(t) == thisLevel; + } + } + return false; + }, + + toggleOpenState(idx) { + if (!this.isContainer(idx)) { + return; + } + var item = gTreeData[idx]; + if (item.open) { + // remove this window's tab rows from the view + var thisLevel = this.getLevel(idx); + /* eslint-disable no-empty */ + for ( + var t = idx + 1; + t < gTreeData.length && this.getLevel(t) > thisLevel; + t++ + ) {} + /* eslint-disable no-empty */ + var deletecount = t - idx - 1; + gTreeData.splice(idx + 1, deletecount); + this.treeBox.rowCountChanged(idx + 1, -deletecount); + } else { + // add this window's tab rows to the view + var toinsert = gTreeData[idx].tabs; + for (var i = 0; i < toinsert.length; i++) { + gTreeData.splice(idx + i + 1, 0, toinsert[i]); + } + this.treeBox.rowCountChanged(idx + 1, toinsert.length); + } + item.open = !item.open; + this.treeBox.invalidateRow(idx); + }, + + getCellProperties(idx, column) { + if ( + column.id == "restore" && + this.isContainer(idx) && + gTreeData[idx].checked === 0 + ) { + return "partial"; + } + if (column.id == "title") { + return this.getImageSrc(idx, column) ? "icon" : "noicon"; + } + + return ""; + }, + + getRowProperties(idx) { + var winState = gTreeData[idx].parent || gTreeData[idx]; + if (winState.ix % 2 != 0) { + return "alternate"; + } + + return ""; + }, + + getImageSrc(idx, column) { + if (column.id == "title") { + return gTreeData[idx].src || null; + } + return null; + }, + + cycleHeader(column) {}, + cycleCell(idx, column) {}, + selectionChanged() {}, + getColumnProperties(column) { + return ""; + }, +}; diff --git a/browser/components/sessionstore/content/aboutSessionRestore.xhtml b/browser/components/sessionstore/content/aboutSessionRestore.xhtml new file mode 100644 index 0000000000..05538be5d9 --- /dev/null +++ b/browser/components/sessionstore/content/aboutSessionRestore.xhtml @@ -0,0 +1,69 @@ + + + + %htmlDTD; +]> + + + + + + + + + + + + + diff --git a/browser/components/sessionstore/test/browser_1284886_suspend_tab.html b/browser/components/sessionstore/test/browser_1284886_suspend_tab.html new file mode 100644 index 0000000000..ec3edbffdc --- /dev/null +++ b/browser/components/sessionstore/test/browser_1284886_suspend_tab.html @@ -0,0 +1,12 @@ + + + + + +TEST PAGE + + diff --git a/browser/components/sessionstore/test/browser_1284886_suspend_tab.js b/browser/components/sessionstore/test/browser_1284886_suspend_tab.js new file mode 100644 index 0000000000..3c6a2c1f2c --- /dev/null +++ b/browser/components/sessionstore/test/browser_1284886_suspend_tab.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.require_user_interaction_for_beforeunload", false]], + }); + + let url = "about:robots"; + let tab0 = gBrowser.tabs[0]; + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + const staleAttributes = [ + "activemedia-blocked", + "busy", + "pendingicon", + "progress", + "soundplaying", + ]; + for (let attr of staleAttributes) { + tab0.toggleAttribute(attr, true); + } + gBrowser.discardBrowser(tab0); + ok(!tab0.linkedPanel, "tab0 is suspended"); + for (let attr of staleAttributes) { + ok( + !tab0.hasAttribute(attr), + `discarding browser removes "${attr}" tab attribute` + ); + } + + await BrowserTestUtils.switchTab(gBrowser, tab0); + ok(tab0.linkedPanel, "selecting tab unsuspends it"); + + // Test that active tab is not able to be suspended. + gBrowser.discardBrowser(tab0); + ok(tab0.linkedPanel, "active tab is not able to be suspended"); + + // Test that tab that is closing is not able to be suspended. + gBrowser._beginRemoveTab(tab1); + gBrowser.discardBrowser(tab1); + + ok(tab1.linkedPanel, "cannot suspend a tab that is closing"); + + gBrowser._endRemoveTab(tab1); + + // Open tab containing a page which has a beforeunload handler which shows a prompt. + url = + "http://example.com/browser/browser/components/sessionstore/test/browser_1284886_suspend_tab.html"; + tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await BrowserTestUtils.switchTab(gBrowser, tab0); + + // Test that tab with beforeunload handler which would show a prompt cannot be suspended. + gBrowser.discardBrowser(tab1); + ok( + tab1.linkedPanel, + "cannot suspend a tab with beforeunload handler which would show a prompt" + ); + + // Test that tab with beforeunload handler which would show a prompt will be suspended if forced. + gBrowser.discardBrowser(tab1, true); + ok( + !tab1.linkedPanel, + "force suspending a tab with beforeunload handler which would show a prompt" + ); + + BrowserTestUtils.removeTab(tab1); + + // Open tab containing a page which has a beforeunload handler which does not show a prompt. + url = + "http://example.com/browser/browser/components/sessionstore/test/browser_1284886_suspend_tab_2.html"; + tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await BrowserTestUtils.switchTab(gBrowser, tab0); + + // Test that tab with beforeunload handler which would not show a prompt can be suspended. + gBrowser.discardBrowser(tab1); + ok( + !tab1.linkedPanel, + "can suspend a tab with beforeunload handler which would not show a prompt" + ); + + BrowserTestUtils.removeTab(tab1); + + // Test that non-remote tab is not able to be suspended. + url = "about:robots"; + tab1 = BrowserTestUtils.addTab(gBrowser, url, { forceNotRemote: true }); + await promiseBrowserLoaded(tab1.linkedBrowser, true, url); + await BrowserTestUtils.switchTab(gBrowser, tab1); + await BrowserTestUtils.switchTab(gBrowser, tab0); + + gBrowser.discardBrowser(tab1); + ok(tab1.linkedPanel, "cannot suspend a remote tab"); + + BrowserTestUtils.removeTab(tab1); +}); diff --git a/browser/components/sessionstore/test/browser_1284886_suspend_tab_2.html b/browser/components/sessionstore/test/browser_1284886_suspend_tab_2.html new file mode 100644 index 0000000000..5c42913635 --- /dev/null +++ b/browser/components/sessionstore/test/browser_1284886_suspend_tab_2.html @@ -0,0 +1,11 @@ + + + + + +TEST PAGE + + diff --git a/browser/components/sessionstore/test/browser_1446343-windowsize.js b/browser/components/sessionstore/test/browser_1446343-windowsize.js new file mode 100644 index 0000000000..97f664a460 --- /dev/null +++ b/browser/components/sessionstore/test/browser_1446343-windowsize.js @@ -0,0 +1,39 @@ +add_task(async function test() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + + async function changeSizeMode(mode) { + let promise = BrowserTestUtils.waitForEvent(win, "sizemodechange"); + win[mode](); + await promise; + } + if (win.windowState != win.STATE_NORMAL) { + await changeSizeMode("restore"); + } + + const { outerWidth, outerHeight, screenX, screenY } = win; + function checkCurrentState(sizemode) { + let state = ss.getWindowState(win); + let winState = state.windows[0]; + let msgSuffix = ` should match on ${sizemode} mode`; + is(winState.width, outerWidth, "width" + msgSuffix); + is(winState.height, outerHeight, "height" + msgSuffix); + // The position attributes seem to be affected on macOS when the + // window gets maximized, so skip checking them for now. + if (AppConstants.platform != "macosx" || sizemode == "normal") { + is(winState.screenX, screenX, "screenX" + msgSuffix); + is(winState.screenY, screenY, "screenY" + msgSuffix); + } + is(winState.sizemode, sizemode, "sizemode should match"); + } + + checkCurrentState("normal"); + + await changeSizeMode("maximize"); + checkCurrentState("maximized"); + + await changeSizeMode("minimize"); + checkCurrentState("minimized"); + + // Clean up. + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/sessionstore/test/browser_248970_b_perwindowpb.js b/browser/components/sessionstore/test/browser_248970_b_perwindowpb.js new file mode 100644 index 0000000000..d62a701ea3 --- /dev/null +++ b/browser/components/sessionstore/test/browser_248970_b_perwindowpb.js @@ -0,0 +1,199 @@ +/* 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/. */ + +function test() { + /** Test (B) for Bug 248970 **/ + waitForExplicitFinish(); + + let windowsToClose = []; + let file = Services.dirsvc.get("TmpD", Ci.nsIFile); + let filePath = file.path; + let fieldList = { + "//input[@name='input']": Date.now().toString(16), + "//input[@name='spaced 1']": Math.random().toString(), + "//input[3]": "three", + "//input[@type='checkbox']": true, + "//input[@name='uncheck']": false, + "//input[@type='radio'][1]": false, + "//input[@type='radio'][2]": true, + "//input[@type='radio'][3]": false, + "//select": 2, + "//select[@multiple]": [1, 3], + "//textarea[1]": "", + "//textarea[2]": "Some text... " + Math.random(), + "//textarea[3]": "Some more text\n" + new Date(), + "//input[@type='file']": filePath, + }; + + registerCleanupFunction(async function () { + for (let win of windowsToClose) { + await BrowserTestUtils.closeWindow(win); + } + }); + + function checkNoThrow(aLambda) { + try { + return aLambda() || true; + } catch (ex) {} + return false; + } + + function getElementByXPath(aTab, aQuery) { + let doc = aTab.linkedBrowser.contentDocument; + let xptype = doc.defaultView.XPathResult.FIRST_ORDERED_NODE_TYPE; + return doc.evaluate(aQuery, doc, null, xptype, null).singleNodeValue; + } + + function setFormValue(aTab, aQuery, aValue) { + let node = getElementByXPath(aTab, aQuery); + if (typeof aValue == "string") { + node.value = aValue; + } else if (typeof aValue == "boolean") { + node.checked = aValue; + } else if (typeof aValue == "number") { + node.selectedIndex = aValue; + } else { + Array.prototype.forEach.call( + node.options, + (aOpt, aIx) => (aOpt.selected = aValue.indexOf(aIx) > -1) + ); + } + } + + function compareFormValue(aTab, aQuery, aValue) { + let node = getElementByXPath(aTab, aQuery); + if (!node) { + return false; + } + if (ChromeUtils.getClassName(node) === "HTMLInputElement") { + return ( + aValue == + (node.type == "checkbox" || node.type == "radio" + ? node.checked + : node.value) + ); + } + if (ChromeUtils.getClassName(node) === "HTMLTextAreaElement") { + return aValue == node.value; + } + if (!node.multiple) { + return aValue == node.selectedIndex; + } + return Array.prototype.every.call( + node.options, + (aOpt, aIx) => aValue.indexOf(aIx) > -1 == aOpt.selected + ); + } + + /** + * Test (B) : Session data restoration between windows + */ + + let rootDir = getRootDirectory(gTestPath); + const testURL = rootDir + "browser_248970_b_sample.html"; + const testURL2 = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_248970_b_sample.html"; + + whenNewWindowLoaded({ private: false }, function (aWin) { + windowsToClose.push(aWin); + + // get closed tab count + let count = ss.getClosedTabCountForWindow(aWin); + let max_tabs_undo = Services.prefs.getIntPref( + "browser.sessionstore.max_tabs_undo" + ); + ok( + 0 <= count && count <= max_tabs_undo, + "getClosedTabCountForWindow should return zero or at most max_tabs_undo" + ); + + // setup a state for tab (A) so we can check later that is restored + let value = "Value " + Math.random(); + let state = { entries: [{ url: testURL }], extData: { key: value } }; + + // public session, add new tab: (A) + let tab_A = BrowserTestUtils.addTab(aWin.gBrowser, testURL); + ss.setTabState(tab_A, JSON.stringify(state)); + promiseBrowserLoaded(tab_A.linkedBrowser).then(() => { + // make sure that the next closed tab will increase getClosedTabCountForWindow + Services.prefs.setIntPref( + "browser.sessionstore.max_tabs_undo", + max_tabs_undo + 1 + ); + + // populate tab_A with form data + for (let i in fieldList) { + setFormValue(tab_A, i, fieldList[i]); + } + + // public session, close tab: (A) + aWin.gBrowser.removeTab(tab_A); + + // verify that closedTabCount increased + Assert.greater( + ss.getClosedTabCountForWindow(aWin), + count, + "getClosedTabCountForWindow has increased after closing a tab" + ); + + // verify tab: (A), in undo list + let tab_A_restored = checkNoThrow(() => ss.undoCloseTab(aWin, 0)); + ok(tab_A_restored, "a tab is in undo list"); + promiseTabRestored(tab_A_restored).then(() => { + is( + testURL, + tab_A_restored.linkedBrowser.currentURI.spec, + "it's the same tab that we expect" + ); + aWin.gBrowser.removeTab(tab_A_restored); + + whenNewWindowLoaded({ private: true }, function (win) { + windowsToClose.push(win); + + // setup a state for tab (B) so we can check that its duplicated + // properly + let key1 = "key1"; + let value1 = "Value " + Math.random(); + let state1 = { + entries: [{ url: testURL2 }], + extData: { key1: value1 }, + }; + + let tab_B = BrowserTestUtils.addTab(win.gBrowser, testURL2); + promiseTabState(tab_B, state1).then(() => { + // populate tab: (B) with different form data + for (let item in fieldList) { + setFormValue(tab_B, item, fieldList[item]); + } + + // duplicate tab: (B) + let tab_C = win.gBrowser.duplicateTab(tab_B); + promiseTabRestored(tab_C).then(() => { + // verify the correctness of the duplicated tab + is( + ss.getCustomTabValue(tab_C, key1), + value1, + "tab successfully duplicated - correct state" + ); + + for (let item in fieldList) { + ok( + compareFormValue(tab_C, item, fieldList[item]), + 'The value for "' + item + '" was correctly duplicated' + ); + } + + // private browsing session, close tab: (C) and (B) + win.gBrowser.removeTab(tab_C); + win.gBrowser.removeTab(tab_B); + + finish(); + }); + }); + }); + }); + }); + }); +} diff --git a/browser/components/sessionstore/test/browser_248970_b_sample.html b/browser/components/sessionstore/test/browser_248970_b_sample.html new file mode 100644 index 0000000000..76c3ae1aa0 --- /dev/null +++ b/browser/components/sessionstore/test/browser_248970_b_sample.html @@ -0,0 +1,37 @@ + + +Test for bug 248970 + +

    Text Fields

    + + + + +

    Checkboxes and Radio buttons

    + Check 1 + Check 2 +

    + Radio 1 + Radio 2 + Radio 3 + +

    Selects

    + + + +

    Text Areas

    + + + + +

    File Selector

    + diff --git a/browser/components/sessionstore/test/browser_339445.js b/browser/components/sessionstore/test/browser_339445.js new file mode 100644 index 0000000000..e7c7ffa5cb --- /dev/null +++ b/browser/components/sessionstore/test/browser_339445.js @@ -0,0 +1,39 @@ +/* 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/. */ + +add_task(async function test() { + /** Test for Bug 339445 **/ + + let testURL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_339445_sample.html"; + + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + await promiseBrowserLoaded(tab.linkedBrowser, true, testURL); + + await SpecialPowers.spawn(tab.linkedBrowser, [], function () { + let doc = content.document; + is( + doc.getElementById("storageTestItem").textContent, + "PENDING", + "sessionStorage value has been set" + ); + }); + + let tab2 = gBrowser.duplicateTab(tab); + await promiseTabRestored(tab2); + + await ContentTask.spawn(tab2.linkedBrowser, null, function () { + let doc2 = content.document; + is( + doc2.getElementById("storageTestItem").textContent, + "SUCCESS", + "sessionStorage value has been duplicated" + ); + }); + + // clean up + BrowserTestUtils.removeTab(tab2); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_339445_sample.html b/browser/components/sessionstore/test/browser_339445_sample.html new file mode 100644 index 0000000000..ff5b4acd9f --- /dev/null +++ b/browser/components/sessionstore/test/browser_339445_sample.html @@ -0,0 +1,18 @@ + + +Test for bug 339445 + +storageTestItem = FAIL + + + + diff --git a/browser/components/sessionstore/test/browser_345898.js b/browser/components/sessionstore/test/browser_345898.js new file mode 100644 index 0000000000..7e08702222 --- /dev/null +++ b/browser/components/sessionstore/test/browser_345898.js @@ -0,0 +1,69 @@ +/* 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/. */ + +function test() { + /** Test for Bug 345898 **/ + + // all of the following calls with illegal arguments should throw NS_ERROR_ILLEGAL_VALUE + Assert.throws( + () => ss.getWindowState({}), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for getWindowState throws" + ); + Assert.throws( + () => ss.setWindowState({}, "", false), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for setWindowState throws" + ); + Assert.throws( + () => ss.getTabState({}), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid tab for getTabState throws" + ); + Assert.throws( + () => ss.setTabState({}, "{}"), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid tab state for setTabState throws" + ); + Assert.throws( + () => ss.setTabState({}, JSON.stringify({ entries: [] })), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid tab for setTabState throws" + ); + Assert.throws( + () => ss.duplicateTab({}, {}), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid tab for duplicateTab throws" + ); + Assert.throws( + () => ss.duplicateTab({}, gBrowser.selectedTab), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for duplicateTab throws" + ); + Assert.throws( + () => ss.getClosedTabDataForWindow({}), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for getClosedTabData throws" + ); + Assert.throws( + () => ss.undoCloseTab({}, 0), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for undoCloseTab throws" + ); + Assert.throws( + () => ss.undoCloseTab(window, -1), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid index for undoCloseTab throws" + ); + Assert.throws( + () => ss.getCustomWindowValue({}, ""), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for getCustomWindowValue throws" + ); + Assert.throws( + () => ss.setCustomWindowValue({}, "", ""), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for setCustomWindowValue throws" + ); +} diff --git a/browser/components/sessionstore/test/browser_350525.js b/browser/components/sessionstore/test/browser_350525.js new file mode 100644 index 0000000000..ca577bf093 --- /dev/null +++ b/browser/components/sessionstore/test/browser_350525.js @@ -0,0 +1,136 @@ +"use strict"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["dom.ipc.processCount", 1]], + }); +}); + +add_task(async function () { + /** Test for Bug 350525 **/ + + function test(aLambda) { + try { + return aLambda() || true; + } catch (ex) {} + return false; + } + + /** + * setCustomWindowValue, et al. + */ + let key = "Unique name: " + Date.now(); + let value = "Unique value: " + Math.random(); + + // test adding + ok( + test(() => ss.setCustomWindowValue(window, key, value)), + "set a window value" + ); + + // test retrieving + is( + ss.getCustomWindowValue(window, key), + value, + "stored window value matches original" + ); + + // test deleting + ok( + test(() => ss.deleteCustomWindowValue(window, key)), + "delete the window value" + ); + + // value should not exist post-delete + is(ss.getCustomWindowValue(window, key), "", "window value was deleted"); + + // test deleting a non-existent value + ok( + test(() => ss.deleteCustomWindowValue(window, key)), + "delete non-existent window value" + ); + + /** + * setCustomTabValue, et al. + */ + key = "Unique name: " + Math.random(); + value = "Unique value: " + Date.now(); + let tab = BrowserTestUtils.addTab(gBrowser); + tab.linkedBrowser.stop(); + + // test adding + ok( + test(() => ss.setCustomTabValue(tab, key, value)), + "store a tab value" + ); + + // test retrieving + is(ss.getCustomTabValue(tab, key), value, "stored tab value match original"); + + // test deleting + ok( + test(() => ss.deleteCustomTabValue(tab, key)), + "delete the tab value" + ); + + // value should not exist post-delete + is(ss.getCustomTabValue(tab, key), "", "tab value was deleted"); + + // test deleting a non-existent value + ok( + test(() => ss.deleteCustomTabValue(tab, key)), + "delete non-existent tab value" + ); + + // clean up + await promiseRemoveTabAndSessionState(tab); + + /** + * getClosedTabCountForWindow, undoCloseTab + */ + + // get closed tab count + let count = ss.getClosedTabCountForWindow(window); + let max_tabs_undo = Services.prefs.getIntPref( + "browser.sessionstore.max_tabs_undo" + ); + ok( + 0 <= count && count <= max_tabs_undo, + "getClosedTabCountForWindow returns zero or at most max_tabs_undo" + ); + + // create a new tab + let testURL = "about:mozilla"; + tab = BrowserTestUtils.addTab(gBrowser, testURL); + await promiseBrowserLoaded(tab.linkedBrowser); + + // make sure that the next closed tab will increase getClosedTabCountForWindow + Services.prefs.setIntPref( + "browser.sessionstore.max_tabs_undo", + max_tabs_undo + 1 + ); + registerCleanupFunction(() => + Services.prefs.clearUserPref("browser.sessionstore.max_tabs_undo") + ); + + // remove tab + await promiseRemoveTabAndSessionState(tab); + + // getClosedTabCountForWindow + let newcount = ss.getClosedTabCountForWindow(window); + Assert.greater( + newcount, + count, + "after closing a tab, getClosedTabCountForWindow has been incremented" + ); + + // undoCloseTab + tab = test(() => ss.undoCloseTab(window, 0)); + ok(tab, "undoCloseTab doesn't throw"); + + await promiseTabRestored(tab); + is(tab.linkedBrowser.currentURI.spec, testURL, "correct tab was reopened"); + + // clean up + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_354894_perwindowpb.js b/browser/components/sessionstore/test/browser_354894_perwindowpb.js new file mode 100644 index 0000000000..90368536dc --- /dev/null +++ b/browser/components/sessionstore/test/browser_354894_perwindowpb.js @@ -0,0 +1,489 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Checks that restoring the last browser window in session is actually + * working. + * + * @see https://bugzilla.mozilla.org/show_bug.cgi?id=354894 + * @note It is implicitly tested that restoring the last window works when + * non-browser windows are around. The "Run Tests" window as well as the main + * browser window (wherein the test code gets executed) won't be considered + * browser windows. To achiveve this said main browser window has its windowtype + * attribute modified so that it's not considered a browser window any longer. + * This is crucial, because otherwise there would be two browser windows around, + * said main test window and the one opened by the tests, and hence the new + * logic wouldn't be executed at all. + * @note Mac only tests the new notifications, as restoring the last window is + * not enabled on that platform (platform shim; the application is kept running + * although there are no windows left) + * @note There is a difference when closing a browser window with + * BrowserTryToCloseWindow() as opposed to close(). The former will make + * nsSessionStore restore a window next time it gets a chance and will post + * notifications. The latter won't. + */ + +// The rejection "BrowserWindowTracker.getTopWindow(...) is null" is left +// unhandled in some cases. This bug should be fixed, but for the moment this +// file allows a class of rejections. +// +// NOTE: Allowing a whole class of rejections should be avoided. Normally you +// should use "expectUncaughtRejection" to flag individual failures. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/getTopWindow/); + +// Some urls that might be opened in tabs and/or popups +// Do not use about:blank: +// That one is reserved for special purposes in the tests +const TEST_URLS = ["about:mozilla", "about:buildconfig"]; + +// Number of -request notifications to except +// remember to adjust when adding new tests +const NOTIFICATIONS_EXPECTED = 6; + +// Window features of popup windows +const POPUP_FEATURES = "toolbar=no,resizable=no,status=no"; + +// Window features of browser windows +const CHROME_FEATURES = "chrome,all,dialog=no"; + +const IS_MAC = navigator.platform.match(/Mac/); + +/** + * Returns an Object with two properties: + * open (int): + * A count of how many non-closed navigator:browser windows there are. + * winstates (int): + * A count of how many windows there are in the SessionStore state. + */ +function getBrowserWindowsCount() { + let open = 0; + for (let win of Services.wm.getEnumerator("navigator:browser")) { + if (!win.closed) { + ++open; + } + } + + let winstates = JSON.parse(ss.getBrowserState()).windows.length; + + return { open, winstates }; +} + +add_setup(async function () { + // Make sure we've only got one browser window to start with + let { open, winstates } = getBrowserWindowsCount(); + is(open, 1, "Should only be one open window"); + is(winstates, 1, "Should only be one window state in SessionStore"); + + // This test takes some time to run, and it could timeout randomly. + // So we require a longer timeout. See bug 528219. + requestLongerTimeout(3); + + // Make the main test window not count as a browser window any longer + let oldWinType = document.documentElement.getAttribute("windowtype"); + document.documentElement.setAttribute("windowtype", "navigator:testrunner"); + + registerCleanupFunction(() => { + document.documentElement.setAttribute("windowtype", oldWinType); + }); +}); + +/** + * Sets up one of our tests by setting the right preferences, and + * then opening up a browser window preloaded with some tabs. + * + * @param options (Object) + * An object that can contain the following properties: + * + * private: + * Whether or not the opened window should be private. + * + * denyFirst: + * Whether or not the first window that attempts to close + * via closeWindowForRestoration should be denied. + * + * @param testFunction (Function*) + * A generator function that yields Promises to be run + * once the test has been set up. + * + * @returns Promise + * Resolves once the test has been cleaned up. + */ +let setupTest = async function (options, testFunction) { + await pushPrefs( + ["browser.startup.page", 3], + ["browser.tabs.warnOnClose", false] + ); + // SessionStartup caches pref values, but as this test tries to simulate a + // startup scenario, we'll reset them here. + SessionStartup.resetForTest(); + + // Observe these, and also use to count the number of hits + let observing = { + "browser-lastwindow-close-requested": 0, + "browser-lastwindow-close-granted": 0, + }; + + /** + * Helper: Will observe and handle the notifications for us + */ + let hitCount = 0; + function observer(aCancel, aTopic, aData) { + // count so that we later may compare + observing[aTopic]++; + + // handle some tests + if (options.denyFirst && ++hitCount == 1) { + aCancel.QueryInterface(Ci.nsISupportsPRBool).data = true; + } + } + + for (let o in observing) { + Services.obs.addObserver(observer, o); + } + + let newWin = await promiseNewWindowLoaded({ + private: options.private || false, + }); + + await injectTestTabs(newWin); + + await testFunction(newWin, observing); + + let count = getBrowserWindowsCount(); + is(count.open, 0, "Got right number of open windows"); + is(count.winstates, 1, "Got right number of stored window states"); + + for (let o in observing) { + Services.obs.removeObserver(observer, o); + } + + await popPrefs(); + // Act like nothing ever happened. + SessionStartup.resetForTest(); +}; + +/** + * Loads a TEST_URLS into a browser window. + * + * @param win (Window) + * The browser window to load the tabs in + */ +function injectTestTabs(win) { + let promises = TEST_URLS.map(url => + BrowserTestUtils.addTab(win.gBrowser, url) + ).map(tab => BrowserTestUtils.browserLoaded(tab.linkedBrowser)); + return Promise.all(promises); +} + +/** + * Attempts to close a window via BrowserTryToCloseWindow so that + * we get the browser-lastwindow-close-requested and + * browser-lastwindow-close-granted observer notifications. + * + * @param win (Window) + * The window to try to close + * @returns Promise + * Resolves to true if the window closed, or false if the window + * was denied the ability to close. + */ +function closeWindowForRestoration(win) { + return new Promise(resolve => { + let closePromise = BrowserTestUtils.windowClosed(win); + win.BrowserTryToCloseWindow(); + if (!win.closed) { + resolve(false); + return; + } + + closePromise.then(() => { + resolve(true); + }); + }); +} + +/** + * Normal in-session restore + * + * @note: Non-Mac only + * + * Should do the following: + * 1. Open a new browser window + * 2. Add some tabs + * 3. Close that window + * 4. Opening another window + * 5. Checks that state is restored + */ +add_task(async function test_open_close_normal() { + if (IS_MAC) { + return; + } + + await setupTest({ denyFirst: true }, async function (newWin, obs) { + let closed = await closeWindowForRestoration(newWin); + ok(!closed, "First close request should have been denied"); + + closed = await closeWindowForRestoration(newWin); + ok(closed, "Second close request should be accepted"); + + newWin = await promiseNewWindowLoaded(); + is( + newWin.gBrowser.browsers.length, + TEST_URLS.length + 2, + "Restored window in-session with otherpopup windows around" + ); + + // Note that this will not result in the the browser-lastwindow-close + // notifications firing for this other newWin. + await BrowserTestUtils.closeWindow(newWin); + + // setupTest gave us a window which was denied for closing once, and then + // closed. + is( + obs["browser-lastwindow-close-requested"], + 2, + "Got expected browser-lastwindow-close-requested notifications" + ); + is( + obs["browser-lastwindow-close-granted"], + 1, + "Got expected browser-lastwindow-close-granted notifications" + ); + }); +}); + +/** + * PrivateBrowsing in-session restore + * + * @note: Non-Mac only + * + * Should do the following: + * 1. Open a new browser window A + * 2. Add some tabs + * 3. Close the window A as the last window + * 4. Open a private browsing window B + * 5. Make sure that B didn't restore the tabs from A + * 6. Close private browsing window B + * 7. Open a new window C + * 8. Make sure that new window C has restored tabs from A + */ +add_task(async function test_open_close_private_browsing() { + if (IS_MAC) { + return; + } + + await setupTest({}, async function (newWin, obs) { + let closed = await closeWindowForRestoration(newWin); + ok(closed, "Should be able to close the window"); + + newWin = await promiseNewWindowLoaded({ private: true }); + is( + newWin.gBrowser.browsers.length, + 1, + "Did not restore in private browsing mode" + ); + + closed = await closeWindowForRestoration(newWin); + ok(closed, "Should be able to close the window"); + + newWin = await promiseNewWindowLoaded(); + is( + newWin.gBrowser.browsers.length, + TEST_URLS.length + 2, + "Restored tabs in a new non-private window" + ); + + // Note that this will not result in the the browser-lastwindow-close + // notifications firing for this other newWin. + await BrowserTestUtils.closeWindow(newWin); + + // We closed two windows with closeWindowForRestoration, and both + // should have been successful. + is( + obs["browser-lastwindow-close-requested"], + 2, + "Got expected browser-lastwindow-close-requested notifications" + ); + is( + obs["browser-lastwindow-close-granted"], + 2, + "Got expected browser-lastwindow-close-granted notifications" + ); + }); +}); + +/** + * Open some popup window to check it isn't restored. Instead nothing at all + * should be restored + * + * @note: Non-Mac only + * + * Should do the following: + * 1. Open a popup + * 2. Add another tab to the popup (so that it gets stored) and close it again + * 3. Open a window + * 4. Check that nothing at all is restored + * 5. Open two browser windows and close them again + * 6. undoCloseWindow() one + * 7. Open another browser window + * 8. Check that nothing at all is restored + */ +add_task(async function test_open_close_only_popup() { + if (IS_MAC) { + return; + } + + await setupTest({}, async function (newWin, obs) { + // We actually don't care about the initial window in this test. + await BrowserTestUtils.closeWindow(newWin); + + // This will cause nsSessionStore to restore a window the next time it + // gets a chance. + let popupPromise = BrowserTestUtils.waitForNewWindow(); + openDialog(location, "popup", POPUP_FEATURES, TEST_URLS[1]); + let popup = await popupPromise; + + is( + popup.gBrowser.browsers.length, + 1, + "Did not restore the popup window (1)" + ); + + let closed = await closeWindowForRestoration(popup); + ok(closed, "Should be able to close the window"); + + popupPromise = BrowserTestUtils.waitForNewWindow(); + openDialog(location, "popup", POPUP_FEATURES, TEST_URLS[1]); + popup = await popupPromise; + + BrowserTestUtils.addTab(popup.gBrowser, TEST_URLS[0]); + is( + popup.gBrowser.browsers.length, + 2, + "Did not restore to the popup window (2)" + ); + + await BrowserTestUtils.closeWindow(popup); + + newWin = await promiseNewWindowLoaded(); + isnot( + newWin.gBrowser.browsers.length, + 2, + "Did not restore the popup window" + ); + is( + TEST_URLS.indexOf(newWin.gBrowser.browsers[0].currentURI.spec), + -1, + "Did not restore the popup window (2)" + ); + await BrowserTestUtils.closeWindow(newWin); + + // We closed one popup window with closeWindowForRestoration, and popup + // windows should never fire the browser-lastwindow notifications. + is( + obs["browser-lastwindow-close-requested"], + 0, + "Got expected browser-lastwindow-close-requested notifications" + ); + is( + obs["browser-lastwindow-close-granted"], + 0, + "Got expected browser-lastwindow-close-granted notifications" + ); + }); +}); + +/** + * Open some windows and do undoCloseWindow. This should prevent any + * restoring later in the test + * + * @note: Non-Mac only + * + * Should do the following: + * 1. Open two browser windows and close them again + * 2. undoCloseWindow() one + * 3. Open another browser window + * 4. Make sure nothing at all is restored + */ +add_task(async function test_open_close_restore_from_popup() { + if (IS_MAC) { + return; + } + + await setupTest({}, async function (newWin, obs) { + let newWin2 = await promiseNewWindowLoaded(); + await injectTestTabs(newWin2); + + let closed = await closeWindowForRestoration(newWin); + ok(closed, "Should be able to close the window"); + closed = await closeWindowForRestoration(newWin2); + ok(closed, "Should be able to close the window"); + + let counts = getBrowserWindowsCount(); + is(counts.open, 0, "Got right number of open windows"); + is(counts.winstates, 1, "Got right number of window states"); + + newWin = undoCloseWindow(0); + await BrowserTestUtils.waitForEvent(newWin, "load"); + + // Make sure we wait until this window is restored. + await BrowserTestUtils.waitForEvent( + newWin.gBrowser.tabContainer, + "SSTabRestored" + ); + + newWin2 = await promiseNewWindowLoaded(); + + is( + TEST_URLS.indexOf(newWin2.gBrowser.browsers[0].currentURI.spec), + -1, + "Did not restore, as undoCloseWindow() was last called (2)" + ); + + counts = getBrowserWindowsCount(); + is(counts.open, 2, "Got right number of open windows"); + is(counts.winstates, 3, "Got right number of window states"); + + await BrowserTestUtils.closeWindow(newWin); + await BrowserTestUtils.closeWindow(newWin2); + + counts = getBrowserWindowsCount(); + is(counts.open, 0, "Got right number of open windows"); + is(counts.winstates, 1, "Got right number of window states"); + }); +}); + +/** + * Test if closing can be denied on Mac. + * @note: Mac only + */ +add_task(async function test_mac_notifications() { + if (!IS_MAC) { + return; + } + + await setupTest({ denyFirst: true }, async function (newWin, obs) { + let closed = await closeWindowForRestoration(newWin); + ok(!closed, "First close attempt should be denied"); + closed = await closeWindowForRestoration(newWin); + ok(closed, "Second close attempt should be granted"); + + // We tried closing once, and got denied. Then we tried again and + // succeeded. That means 2 close requests, and 1 close granted. + is( + obs["browser-lastwindow-close-requested"], + 2, + "Got expected browser-lastwindow-close-requested notifications" + ); + is( + obs["browser-lastwindow-close-granted"], + 1, + "Got expected browser-lastwindow-close-granted notifications" + ); + }); +}); diff --git a/browser/components/sessionstore/test/browser_367052.js b/browser/components/sessionstore/test/browser_367052.js new file mode 100644 index 0000000000..4137945729 --- /dev/null +++ b/browser/components/sessionstore/test/browser_367052.js @@ -0,0 +1,52 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function () { + // make sure that the next closed tab will increase getClosedTabCountForWindow + let max_tabs_undo = Services.prefs.getIntPref( + "browser.sessionstore.max_tabs_undo" + ); + Services.prefs.setIntPref( + "browser.sessionstore.max_tabs_undo", + max_tabs_undo + 1 + ); + registerCleanupFunction(() => + Services.prefs.clearUserPref("browser.sessionstore.max_tabs_undo") + ); + + forgetClosedTabs(window); + + // restore a blank tab + let tab = BrowserTestUtils.addTab(gBrowser, "about:mozilla"); + await promiseBrowserLoaded(tab.linkedBrowser); + + let count = await promiseSHistoryCount(tab.linkedBrowser); + Assert.greaterOrEqual( + count, + 1, + "the new tab does have at least one history entry" + ); + + await promiseTabState(tab, { entries: [] }); + + // We may have a different sessionHistory object if the tab + // switched from non-remote to remote. + count = await promiseSHistoryCount(tab.linkedBrowser); + is(count, 0, "the tab was restored without any history whatsoever"); + + await promiseRemoveTabAndSessionState(tab); + is( + ss.getClosedTabCountForWindow(window), + 0, + "The closed blank tab wasn't added to Recently Closed Tabs" + ); +}); + +function promiseSHistoryCount(browser) { + return SpecialPowers.spawn(browser, [], async function () { + return docShell.QueryInterface(Ci.nsIWebNavigation).sessionHistory.count; + }); +} diff --git a/browser/components/sessionstore/test/browser_393716.js b/browser/components/sessionstore/test/browser_393716.js new file mode 100644 index 0000000000..383c25e385 --- /dev/null +++ b/browser/components/sessionstore/test/browser_393716.js @@ -0,0 +1,103 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = "about:config"; + +add_setup(async function () { + // Make sure that the field of which we restore the state is visible on load. + await SpecialPowers.pushPrefEnv({ + set: [["browser.aboutConfig.showWarning", false]], + }); +}); + +/** + * Bug 393716 - Basic tests for getTabState(), setTabState(), and duplicateTab(). + */ +add_task(async function test_set_tabstate() { + let key = "Unique key: " + Date.now(); + let value = "Unique value: " + Math.random(); + + // create a new tab + let tab = BrowserTestUtils.addTab(gBrowser, URL); + ss.setCustomTabValue(tab, key, value); + await promiseBrowserLoaded(tab.linkedBrowser); + + // get the tab's state + await TabStateFlusher.flush(tab.linkedBrowser); + let state = ss.getTabState(tab); + ok(state, "get the tab's state"); + + // verify the tab state's integrity + state = JSON.parse(state); + ok( + state instanceof Object && + state.entries instanceof Array && + !!state.entries.length, + "state object seems valid" + ); + ok( + state.entries.length == 1 && state.entries[0].url == URL, + "Got the expected state object (test URL)" + ); + ok( + state.extData && state.extData[key] == value, + "Got the expected state object (test manually set tab value)" + ); + + // clean up + gBrowser.removeTab(tab); +}); + +add_task(async function test_set_tabstate_and_duplicate() { + let key2 = "key2"; + let value2 = "Value " + Math.random(); + let value3 = "Another value: " + Date.now(); + let state = { + entries: [{ url: URL, triggeringPrincipal_base64 }], + extData: { key2: value2 }, + }; + + // create a new tab + let tab = BrowserTestUtils.addTab(gBrowser); + // set the tab's state + ss.setTabState(tab, JSON.stringify(state)); + await promiseBrowserLoaded(tab.linkedBrowser); + + // verify the correctness of the restored tab + ok( + ss.getCustomTabValue(tab, key2) == value2 && + tab.linkedBrowser.currentURI.spec == URL, + "the tab's state was correctly restored" + ); + + // add text data + await setPropertyOfFormField( + tab.linkedBrowser, + "#about-config-search", + "value", + value3 + ); + + // duplicate the tab + let tab2 = ss.duplicateTab(window, tab); + await promiseTabRestored(tab2); + + // verify the correctness of the duplicated tab + ok( + ss.getCustomTabValue(tab2, key2) == value2 && + tab2.linkedBrowser.currentURI.spec == URL, + "correctly duplicated the tab's state" + ); + let textbox = await getPropertyOfFormField( + tab2.linkedBrowser, + "#about-config-search", + "value" + ); + is(textbox, value3, "also duplicated text data"); + + // clean up + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_394759_basic.js b/browser/components/sessionstore/test/browser_394759_basic.js new file mode 100644 index 0000000000..62d5c40e17 --- /dev/null +++ b/browser/components/sessionstore/test/browser_394759_basic.js @@ -0,0 +1,123 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const TEST_URL = + "data:text/html;charset=utf-8," + + ""; + +/** + * This test ensures that closing a window is a reversible action. We will + * close the the window, restore it and check that all data has been restored. + * This includes window-specific data as well as form data for tabs. + */ +function test() { + waitForExplicitFinish(); + + let uniqueKey = "bug 394759"; + let uniqueValue = "unik" + Date.now(); + let uniqueText = "pi != " + Math.random(); + + // Clear the list of closed windows. + forgetClosedWindows(); + + provideWindow(function onTestURLLoaded(newWin) { + BrowserTestUtils.addTab(newWin.gBrowser).linkedBrowser.stop(); + + // Mark the window with some unique data to be restored later on. + ss.setCustomWindowValue(newWin, uniqueKey, uniqueValue); + let [txt] = newWin.content.document.querySelectorAll("#txt"); + txt.value = uniqueText; + + let browser = newWin.gBrowser.selectedBrowser; + + setPropertyOfFormField(browser, "#chk", "checked", true).then(() => { + BrowserTestUtils.closeWindow(newWin).then(() => { + is( + ss.getClosedWindowCount(), + 1, + "The closed window was added to Recently Closed Windows" + ); + + let data = SessionStore.getClosedWindowData(); + + // Verify that non JSON serialized data is the same as JSON serialized data. + is( + JSON.stringify(data), + ss.getClosedWindowData(), + "Non-serialized data is the same as serialized data" + ); + + ok( + data[0].title == TEST_URL && + JSON.stringify(data[0]).indexOf(uniqueText) > -1, + "The closed window data was stored correctly" + ); + + // Reopen the closed window and ensure its integrity. + let newWin2 = ss.undoCloseWindow(0); + + ok( + newWin2.isChromeWindow, + "undoCloseWindow actually returned a window" + ); + is( + ss.getClosedWindowCount(), + 0, + "The reopened window was removed from Recently Closed Windows" + ); + + // SSTabRestored will fire more than once, so we need to make sure we count them. + let restoredTabs = 0; + let expectedTabs = data[0].tabs.length; + newWin2.addEventListener( + "SSTabRestored", + function sstabrestoredListener(aEvent) { + ++restoredTabs; + info("Restored tab " + restoredTabs + "/" + expectedTabs); + if (restoredTabs < expectedTabs) { + return; + } + + is(restoredTabs, expectedTabs, "Correct number of tabs restored"); + newWin2.removeEventListener( + "SSTabRestored", + sstabrestoredListener, + true + ); + + is( + newWin2.gBrowser.tabs.length, + 2, + "The window correctly restored 2 tabs" + ); + is( + newWin2.gBrowser.currentURI.spec, + TEST_URL, + "The window correctly restored the URL" + ); + + let chk; + [txt, chk] = + newWin2.content.document.querySelectorAll("#txt, #chk"); + ok( + txt.value == uniqueText && chk.checked, + "The window correctly restored the form" + ); + is( + ss.getCustomWindowValue(newWin2, uniqueKey), + uniqueValue, + "The window correctly restored the data associated with it" + ); + + // Clean up. + BrowserTestUtils.closeWindow(newWin2).then(finish); + }, + true + ); + }); + }); + }, TEST_URL); +} diff --git a/browser/components/sessionstore/test/browser_394759_behavior.js b/browser/components/sessionstore/test/browser_394759_behavior.js new file mode 100644 index 0000000000..ee4b121e84 --- /dev/null +++ b/browser/components/sessionstore/test/browser_394759_behavior.js @@ -0,0 +1,91 @@ +/** + * Test helper function that opens a series of windows, closes them + * and then checks the closed window data from SessionStore against + * expected results. + * + * @param windowsToOpen (Array) + * An array of Objects, where each object must define a single + * property "isPopup" for whether or not the opened window should + * be a popup. + * @param expectedResults (Array) + * An Object with two properies: mac and other, where each points + * at yet another Object, with the following properties: + * + * popup (int): + * The number of popup windows we expect to be in the closed window + * data. + * normal (int): + * The number of normal windows we expect to be in the closed window + * data. + * @returns Promise + */ +function testWindows(windowsToOpen, expectedResults) { + return (async function () { + let num = 0; + for (let winData of windowsToOpen) { + let features = "chrome,dialog=no," + (winData.isPopup ? "all=no" : "all"); + let url = "http://example.com/?window=" + num; + num = num + 1; + + let openWindowPromise = BrowserTestUtils.waitForNewWindow({ url }); + openDialog(AppConstants.BROWSER_CHROME_URL, "", features, url); + let win = await openWindowPromise; + await BrowserTestUtils.closeWindow(win); + } + + let closedWindowData = ss.getClosedWindowData(); + let numPopups = closedWindowData.filter(function (el, i, arr) { + return el.isPopup; + }).length; + let numNormal = ss.getClosedWindowCount() - numPopups; + // #ifdef doesn't work in browser-chrome tests, so do a simple regex on platform + let oResults = navigator.platform.match(/Mac/) + ? expectedResults.mac + : expectedResults.other; + is( + numPopups, + oResults.popup, + "There were " + oResults.popup + " popup windows to reopen" + ); + is( + numNormal, + oResults.normal, + "There were " + oResults.normal + " normal windows to repoen" + ); + })(); +} + +add_task(async function test_closed_window_states() { + // This test takes quite some time, and timeouts frequently, so we require + // more time to run. + // See Bug 518970. + requestLongerTimeout(2); + + let windowsToOpen = [ + { isPopup: false }, + { isPopup: false }, + { isPopup: true }, + { isPopup: true }, + { isPopup: true }, + ]; + let expectedResults = { + mac: { popup: 3, normal: 0 }, + other: { popup: 3, normal: 1 }, + }; + + await testWindows(windowsToOpen, expectedResults); + + let windowsToOpen2 = [ + { isPopup: false }, + { isPopup: false }, + { isPopup: false }, + { isPopup: false }, + { isPopup: false }, + ]; + let expectedResults2 = { + mac: { popup: 0, normal: 3 }, + other: { popup: 0, normal: 3 }, + }; + + await testWindows(windowsToOpen2, expectedResults2); +}); diff --git a/browser/components/sessionstore/test/browser_394759_perwindowpb.js b/browser/components/sessionstore/test/browser_394759_perwindowpb.js new file mode 100644 index 0000000000..b79527d4c7 --- /dev/null +++ b/browser/components/sessionstore/test/browser_394759_perwindowpb.js @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const TESTS = [ + { url: "about:config", key: "bug 394759 Non-PB", value: "uniq" + r() }, + { url: "about:mozilla", key: "bug 394759 PB", value: "uniq" + r() }, +]; + +function promiseTestOpenCloseWindow(aIsPrivate, aTest) { + return (async function () { + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: aIsPrivate, + }); + BrowserTestUtils.startLoadingURIString( + win.gBrowser.selectedBrowser, + aTest.url + ); + await promiseBrowserLoaded(win.gBrowser.selectedBrowser, true, aTest.url); + // Mark the window with some unique data to be restored later on. + ss.setCustomWindowValue(win, aTest.key, aTest.value); + await TabStateFlusher.flushWindow(win); + // Close. + await BrowserTestUtils.closeWindow(win); + })(); +} + +function promiseTestOnWindow(aIsPrivate, aValue) { + return (async function () { + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: aIsPrivate, + }); + await TabStateFlusher.flushWindow(win); + let data = ss.getClosedWindowData()[0]; + is( + ss.getClosedWindowCount(), + 1, + "Check that the closed window count hasn't changed" + ); + Assert.greater( + JSON.stringify(data).indexOf(aValue), + -1, + "Check the closed window data was stored correctly" + ); + registerCleanupFunction(() => BrowserTestUtils.closeWindow(win)); + })(); +} + +add_setup(async function () { + forgetClosedWindows(); + forgetClosedTabs(window); +}); + +add_task(async function main() { + await promiseTestOpenCloseWindow(false, TESTS[0]); + await promiseTestOpenCloseWindow(true, TESTS[1]); + await promiseTestOnWindow(false, TESTS[0].value); + await promiseTestOnWindow(true, TESTS[0].value); +}); diff --git a/browser/components/sessionstore/test/browser_394759_purge.js b/browser/components/sessionstore/test/browser_394759_purge.js new file mode 100644 index 0000000000..e5218c9936 --- /dev/null +++ b/browser/components/sessionstore/test/browser_394759_purge.js @@ -0,0 +1,247 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +let { ForgetAboutSite } = ChromeUtils.importESModule( + "resource://gre/modules/ForgetAboutSite.sys.mjs" +); + +function promiseClearHistory() { + return new Promise(resolve => { + let observer = { + observe(aSubject, aTopic, aData) { + Services.obs.removeObserver( + this, + "browser:purge-session-history-for-domain" + ); + resolve(); + }, + }; + Services.obs.addObserver( + observer, + "browser:purge-session-history-for-domain" + ); + }); +} + +add_task(async function () { + // utility functions + function countClosedTabsByTitle(aClosedTabList, aTitle) { + return aClosedTabList.filter(aData => aData.title == aTitle).length; + } + + function countOpenTabsByTitle(aOpenTabList, aTitle) { + return aOpenTabList.filter(aData => + aData.entries.some(aEntry => aEntry.title == aTitle) + ).length; + } + + // backup old state + let oldState = ss.getBrowserState(); + let oldState_wins = JSON.parse(oldState).windows.length; + if (oldState_wins != 1) { + ok( + false, + "oldState in test_purge has " + oldState_wins + " windows instead of 1" + ); + } + + // create a new state for testing + const REMEMBER = Date.now(), + FORGET = Math.random(); + let testState = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.com/", triggeringPrincipal_base64 }, + ], + }, + ], + selected: 1, + }, + ], + _closedWindows: [ + // _closedWindows[0] + { + tabs: [ + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: REMEMBER, + }, + ], + }, + { + entries: [ + { + url: "http://mozilla.org/", + triggeringPrincipal_base64, + title: FORGET, + }, + ], + }, + ], + selected: 2, + title: "mozilla.org", + _closedTabs: [], + }, + // _closedWindows[1] + { + tabs: [ + { + entries: [ + { + url: "http://mozilla.org/", + triggeringPrincipal_base64, + title: FORGET, + }, + ], + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: REMEMBER, + }, + ], + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: REMEMBER, + }, + ], + }, + { + entries: [ + { + url: "http://mozilla.org/", + triggeringPrincipal_base64, + title: FORGET, + }, + ], + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: REMEMBER, + }, + ], + }, + ], + selected: 5, + _closedTabs: [], + }, + // _closedWindows[2] + { + tabs: [ + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: REMEMBER, + }, + ], + }, + ], + selected: 1, + _closedTabs: [ + { + state: { + entries: [ + { + url: "http://mozilla.org/", + triggeringPrincipal_base64, + title: FORGET, + }, + { + url: "http://mozilla.org/again", + triggeringPrincipal_base64, + title: "doesn't matter", + }, + ], + }, + pos: 1, + title: FORGET, + }, + { + state: { + entries: [ + { + url: "http://example.com", + triggeringPrincipal_base64, + title: REMEMBER, + }, + ], + }, + title: REMEMBER, + }, + ], + }, + ], + }; + + // set browser to test state + ss.setBrowserState(JSON.stringify(testState)); + + // purge domain & check that we purged correctly for closed windows + let clearHistoryPromise = promiseClearHistory(); + await ForgetAboutSite.removeDataFromDomain("mozilla.org"); + await clearHistoryPromise; + + let closedWindowData = ss.getClosedWindowData(); + + // First set of tests for _closedWindows[0] - tests basics + let win = closedWindowData[0]; + is(win.tabs.length, 1, "1 tab was removed"); + is(countOpenTabsByTitle(win.tabs, FORGET), 0, "The correct tab was removed"); + is( + countOpenTabsByTitle(win.tabs, REMEMBER), + 1, + "The correct tab was remembered" + ); + is(win.selected, 1, "Selected tab has changed"); + is(win.title, REMEMBER, "The window title was correctly updated"); + + // Test more complicated case + win = closedWindowData[1]; + is(win.tabs.length, 3, "2 tabs were removed"); + is( + countOpenTabsByTitle(win.tabs, FORGET), + 0, + "The correct tabs were removed" + ); + is( + countOpenTabsByTitle(win.tabs, REMEMBER), + 3, + "The correct tabs were remembered" + ); + is(win.selected, 3, "Selected tab has changed"); + is(win.title, REMEMBER, "The window title was correctly updated"); + + // Tests handling of _closedTabs + win = closedWindowData[2]; + is( + countClosedTabsByTitle(win._closedTabs, REMEMBER), + 1, + "The correct number of tabs were removed, and the correct ones" + ); + is( + countClosedTabsByTitle(win._closedTabs, FORGET), + 0, + "All tabs to be forgotten were indeed removed" + ); + + // restore pre-test state + ss.setBrowserState(oldState); +}); diff --git a/browser/components/sessionstore/test/browser_423132.js b/browser/components/sessionstore/test/browser_423132.js new file mode 100644 index 0000000000..3a0113d4d0 --- /dev/null +++ b/browser/components/sessionstore/test/browser_423132.js @@ -0,0 +1,52 @@ +"use strict"; + +/** + * Tests that cookies are stored and restored correctly + * by sessionstore (bug 423132). + */ +add_task(async function () { + const testURL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_423132_sample.html"; + + Services.cookies.removeAll(); + // make sure that sessionstore.js can be forced to be created by setting + // the interval pref to 0 + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.interval", 0]], + }); + + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + + // get the sessionstore state for the window + let state = ss.getBrowserState(); + + // verify our cookie got set during pageload + let i = 0; + for (var cookie of Services.cookies.cookies) { + i++; + } + Assert.equal(i, 1, "expected one cookie"); + + // remove the cookie + Services.cookies.removeAll(); + + // restore the window state + await setBrowserState(state); + + // at this point, the cookie should be restored... + for (var cookie2 of Services.cookies.cookies) { + if (cookie.name == cookie2.name) { + break; + } + } + is(cookie.name, cookie2.name, "cookie name successfully restored"); + is(cookie.value, cookie2.value, "cookie value successfully restored"); + is(cookie.path, cookie2.path, "cookie path successfully restored"); + + // clean up + Services.cookies.removeAll(); + BrowserTestUtils.removeTab(gBrowser.tabs[1]); +}); diff --git a/browser/components/sessionstore/test/browser_423132_sample.html b/browser/components/sessionstore/test/browser_423132_sample.html new file mode 100644 index 0000000000..6ff7e7aa3e --- /dev/null +++ b/browser/components/sessionstore/test/browser_423132_sample.html @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/browser/components/sessionstore/test/browser_447951.js b/browser/components/sessionstore/test/browser_447951.js new file mode 100644 index 0000000000..aa08f59bbe --- /dev/null +++ b/browser/components/sessionstore/test/browser_447951.js @@ -0,0 +1,84 @@ +/* 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/. */ + +function test() { + /** Test for Bug 447951 **/ + + waitForExplicitFinish(); + const baseURL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_447951_sample.html#"; + + // Make sure the functionality added in bug 943339 doesn't affect the results + Services.prefs.setIntPref("browser.sessionstore.max_serialize_back", -1); + Services.prefs.setIntPref("browser.sessionstore.max_serialize_forward", -1); + registerCleanupFunction(function () { + Services.prefs.clearUserPref("browser.sessionstore.max_serialize_back"); + Services.prefs.clearUserPref("browser.sessionstore.max_serialize_forward"); + }); + + let tab = BrowserTestUtils.addTab(gBrowser); + promiseBrowserLoaded(tab.linkedBrowser).then(() => { + let tabState = { entries: [] }; + let max_entries = Services.prefs.getIntPref( + "browser.sessionhistory.max_entries" + ); + for (let i = 0; i < max_entries; i++) { + tabState.entries.push({ url: baseURL + i, triggeringPrincipal_base64 }); + } + + promiseTabState(tab, tabState) + .then(() => { + return TabStateFlusher.flush(tab.linkedBrowser); + }) + .then(() => { + tabState = JSON.parse(ss.getTabState(tab)); + is( + tabState.entries.length, + max_entries, + "session history filled to the limit" + ); + is(tabState.entries[0].url, baseURL + 0, "... but not more"); + + // visit yet another anchor (appending it to session history) + SpecialPowers.spawn(tab.linkedBrowser, [], function () { + content.window.document.querySelector("a").click(); + }).then(flushAndCheck); + + function flushAndCheck() { + TabStateFlusher.flush(tab.linkedBrowser).then(check); + } + + function check() { + tabState = JSON.parse(ss.getTabState(tab)); + if (tab.linkedBrowser.currentURI.spec != baseURL + "end") { + // It may take a few passes through the event loop before we + // get the right URL. + executeSoon(flushAndCheck); + return; + } + + is( + tab.linkedBrowser.currentURI.spec, + baseURL + "end", + "the new anchor was loaded" + ); + is( + tabState.entries[tabState.entries.length - 1].url, + baseURL + "end", + "... and ignored" + ); + is( + tabState.entries[0].url, + baseURL + 1, + "... and the first item was removed" + ); + + // clean up + gBrowser.removeTab(tab); + finish(); + } + }); + }); +} diff --git a/browser/components/sessionstore/test/browser_447951_sample.html b/browser/components/sessionstore/test/browser_447951_sample.html new file mode 100644 index 0000000000..00282f25ef --- /dev/null +++ b/browser/components/sessionstore/test/browser_447951_sample.html @@ -0,0 +1,5 @@ + + +Testcase for bug 447951 + +click me diff --git a/browser/components/sessionstore/test/browser_454908.js b/browser/components/sessionstore/test/browser_454908.js new file mode 100644 index 0000000000..415930c32d --- /dev/null +++ b/browser/components/sessionstore/test/browser_454908.js @@ -0,0 +1,65 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +if (gFissionBrowser) { + addCoopTask( + "browser_454908_sample.html", + test_dont_save_passwords, + HTTPSROOT + ); +} +addNonCoopTask("browser_454908_sample.html", test_dont_save_passwords, ROOT); +addNonCoopTask( + "browser_454908_sample.html", + test_dont_save_passwords, + HTTPROOT +); +addNonCoopTask( + "browser_454908_sample.html", + test_dont_save_passwords, + HTTPSROOT +); + +const PASS = "pwd-" + Math.random(); + +/** + * Bug 454908 - Don't save/restore values of password fields. + */ +async function test_dont_save_passwords(aURL) { + // Make sure we do save form data. + Services.prefs.clearUserPref("browser.sessionstore.privacy_level"); + + // Add a tab with a password field. + let tab = BrowserTestUtils.addTab(gBrowser, aURL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Fill in some values. + let usernameValue = "User " + Math.random(); + await setPropertyOfFormField(browser, "#username", "value", usernameValue); + await setPropertyOfFormField(browser, "#passwd", "value", PASS); + + // Close and restore the tab. + await promiseRemoveTabAndSessionState(tab); + tab = ss.undoCloseTab(window, 0); + browser = tab.linkedBrowser; + await promiseTabRestored(tab); + + // Check that password fields aren't saved/restored. + let username = await getPropertyOfFormField(browser, "#username", "value"); + is(username, usernameValue, "username was saved/restored"); + let passwd = await getPropertyOfFormField(browser, "#passwd", "value"); + is(passwd, "", "password wasn't saved/restored"); + + // Write to disk and read our file. + await forceSaveState(); + await promiseForEachSessionRestoreFile((state, key) => + // Ensure that we have not saved our password. + ok(!state.includes(PASS), "password has not been written to file " + key) + ); + + // Cleanup. + gBrowser.removeTab(tab); +} diff --git a/browser/components/sessionstore/test/browser_454908_sample.html b/browser/components/sessionstore/test/browser_454908_sample.html new file mode 100644 index 0000000000..02f40bf20b --- /dev/null +++ b/browser/components/sessionstore/test/browser_454908_sample.html @@ -0,0 +1,8 @@ + +Test for bug 454908 + +

    Dummy Login

    +
    +

    Username: +

    Password: +

    diff --git a/browser/components/sessionstore/test/browser_456342.js b/browser/components/sessionstore/test/browser_456342.js new file mode 100644 index 0000000000..e7f1c96e34 --- /dev/null +++ b/browser/components/sessionstore/test/browser_456342.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +addCoopTask( + "browser_456342_sample.xhtml", + test_restore_nonstandard_input_values, + HTTPSROOT +); +addNonCoopTask( + "browser_456342_sample.xhtml", + test_restore_nonstandard_input_values, + ROOT +); +addNonCoopTask( + "browser_456342_sample.xhtml", + test_restore_nonstandard_input_values, + HTTPROOT +); +addNonCoopTask( + "browser_456342_sample.xhtml", + test_restore_nonstandard_input_values, + HTTPSROOT +); + +const EXPECTED_IDS = new Set(["searchTerm"]); + +const EXPECTED_XPATHS = new Set([ + "/xhtml:html/xhtml:body/xhtml:form/xhtml:p[2]/xhtml:input", + "/xhtml:html/xhtml:body/xhtml:form/xhtml:p[3]/xhtml:input[@name='fill-in']", + "/xhtml:html/xhtml:body/xhtml:form/xhtml:p[4]/xhtml:input[@name='mistyped']", + "/xhtml:html/xhtml:body/xhtml:form/xhtml:p[5]/xhtml:textarea[@name='textarea_pass']", +]); + +/** + * Bug 456342 - Restore values from non-standard input field types. + */ +async function test_restore_nonstandard_input_values(aURL) { + // Add tab with various non-standard input field types. + let tab = BrowserTestUtils.addTab(gBrowser, aURL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Fill in form values. + let expectedValue = Math.random(); + + await SpecialPowers.spawn(browser, [expectedValue], valueChild => { + for (let elem of content.document.forms[0].elements) { + elem.value = valueChild; + let event = elem.ownerDocument.createEvent("UIEvents"); + event.initUIEvent("input", true, true, elem.ownerGlobal, 0); + elem.dispatchEvent(event); + } + }); + + // Remove tab and check collected form data. + await promiseRemoveTabAndSessionState(tab); + let undoItems = ss.getClosedTabDataForWindow(window); + let savedFormData = undoItems[0].state.formdata; + + let foundIds = 0; + for (let id of Object.keys(savedFormData.id)) { + ok(EXPECTED_IDS.has(id), `Check saved ID "${id}" was expected`); + is( + savedFormData.id[id], + "" + expectedValue, + `Check saved value for #${id}` + ); + foundIds++; + } + + let foundXpaths = 0; + for (let exp of Object.keys(savedFormData.xpath)) { + ok(EXPECTED_XPATHS.has(exp), `Check saved xpath "${exp}" was expected`); + is( + savedFormData.xpath[exp], + "" + expectedValue, + `Check saved value for ${exp}` + ); + foundXpaths++; + } + + is(foundIds, EXPECTED_IDS.size, "Check number of fields saved by ID"); + is( + foundXpaths, + EXPECTED_XPATHS.size, + "Check number of fields saved by xpath" + ); +} diff --git a/browser/components/sessionstore/test/browser_456342_sample.xhtml b/browser/components/sessionstore/test/browser_456342_sample.xhtml new file mode 100644 index 0000000000..ea8704d17d --- /dev/null +++ b/browser/components/sessionstore/test/browser_456342_sample.xhtml @@ -0,0 +1,46 @@ + + + +Test for bug 456342 + + +
    +

    Non-standard <input>s

    +

    Search

    +

    Image Search:

    +

    Autocomplete:

    +

    Mistyped:

    +

    Invalid attr: + + + +

    File Selector

    + + diff --git a/browser/components/sessionstore/test/browser_frame_history.js b/browser/components/sessionstore/test/browser_frame_history.js new file mode 100644 index 0000000000..1db32e74ab --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history.js @@ -0,0 +1,230 @@ +/* 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 mozilla/no-arbitrary-setTimeout */ + +/** + Ensure that frameset history works properly when restoring a tab, + provided that the frameset is static. + */ + +// Loading a toplevel frameset +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.navigation.requireUserInteraction", false]], + }); + + let testURL = + getRootDirectory(gTestPath) + "browser_frame_history_index.html"; + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + gBrowser.selectedTab = tab; + + info("Opening a page with three frames, 4 loads should take place"); + await waitForLoadsInBrowser(tab.linkedBrowser, 4); + + let browser_b = + tab.linkedBrowser.contentDocument.getElementsByTagName("frame")[1]; + let document_b = browser_b.contentDocument; + let links = document_b.getElementsByTagName("a"); + + // We're going to click on the first link, so listen for another load event + info("Clicking on link 1, 1 load should take place"); + let promise = waitForLoadsInBrowser(tab.linkedBrowser, 1); + EventUtils.sendMouseEvent( + { type: "click" }, + links[0], + browser_b.contentWindow + ); + await promise; + + info("Clicking on link 2, 1 load should take place"); + promise = waitForLoadsInBrowser(tab.linkedBrowser, 1); + EventUtils.sendMouseEvent( + { type: "click" }, + links[1], + browser_b.contentWindow + ); + await promise; + + info("Close then un-close page, 4 loads should take place"); + await promiseRemoveTabAndSessionState(tab); + let newTab = ss.undoCloseTab(window, 0); + await waitForLoadsInBrowser(newTab.linkedBrowser, 4); + + info("Go back in time, 1 load should take place"); + gBrowser.goBack(); + await waitForLoadsInBrowser(newTab.linkedBrowser, 1); + + let expectedURLEnds = ["a.html", "b.html", "c1.html"]; + let frames = + newTab.linkedBrowser.contentDocument.getElementsByTagName("frame"); + for (let i = 0; i < frames.length; i++) { + is( + frames[i].contentDocument.location.href, + getRootDirectory(gTestPath) + + "browser_frame_history_" + + expectedURLEnds[i], + "frame " + i + " has the right url" + ); + } + gBrowser.removeTab(newTab); +}); + +// Loading the frameset inside an iframe +add_task(async function () { + let testURL = + getRootDirectory(gTestPath) + "browser_frame_history_index2.html"; + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + gBrowser.selectedTab = tab; + + info( + "iframe: Opening a page with an iframe containing three frames, 5 loads should take place" + ); + await waitForLoadsInBrowser(tab.linkedBrowser, 5); + + let browser_b = tab.linkedBrowser.contentDocument + .getElementById("iframe") + .contentDocument.getElementsByTagName("frame")[1]; + let document_b = browser_b.contentDocument; + let links = document_b.getElementsByTagName("a"); + + // We're going to click on the first link, so listen for another load event + info("iframe: Clicking on link 1, 1 load should take place"); + let promise = waitForLoadsInBrowser(tab.linkedBrowser, 1); + EventUtils.sendMouseEvent( + { type: "click" }, + links[0], + browser_b.contentWindow + ); + await promise; + + info("iframe: Clicking on link 2, 1 load should take place"); + promise = waitForLoadsInBrowser(tab.linkedBrowser, 1); + EventUtils.sendMouseEvent( + { type: "click" }, + links[1], + browser_b.contentWindow + ); + await promise; + + info("iframe: Close then un-close page, 5 loads should take place"); + await promiseRemoveTabAndSessionState(tab); + let newTab = ss.undoCloseTab(window, 0); + await waitForLoadsInBrowser(newTab.linkedBrowser, 5); + + info("iframe: Go back in time, 1 load should take place"); + gBrowser.goBack(); + await waitForLoadsInBrowser(newTab.linkedBrowser, 1); + + let expectedURLEnds = ["a.html", "b.html", "c1.html"]; + let frames = newTab.linkedBrowser.contentDocument + .getElementById("iframe") + .contentDocument.getElementsByTagName("frame"); + for (let i = 0; i < frames.length; i++) { + is( + frames[i].contentDocument.location.href, + getRootDirectory(gTestPath) + + "browser_frame_history_" + + expectedURLEnds[i], + "frame " + i + " has the right url" + ); + } + gBrowser.removeTab(newTab); +}); + +// Now, test that we don't record history if the iframe is added dynamically +add_task(async function () { + // Start with an empty history + let blankState = JSON.stringify({ + windows: [ + { + tabs: [ + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + ], + _closedTabs: [], + }, + ], + _closedWindows: [], + }); + await setBrowserState(blankState); + + let testURL = + getRootDirectory(gTestPath) + "browser_frame_history_index_blank.html"; + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + gBrowser.selectedTab = tab; + await waitForLoadsInBrowser(tab.linkedBrowser, 1); + + info( + "dynamic: Opening a page with an iframe containing three frames, 4 dynamic loads should take place" + ); + let doc = tab.linkedBrowser.contentDocument; + let iframe = doc.createElement("iframe"); + iframe.id = "iframe"; + iframe.src = "browser_frame_history_index.html"; + doc.body.appendChild(iframe); + await waitForLoadsInBrowser(tab.linkedBrowser, 4); + + let browser_b = tab.linkedBrowser.contentDocument + .getElementById("iframe") + .contentDocument.getElementsByTagName("frame")[1]; + let document_b = browser_b.contentDocument; + let links = document_b.getElementsByTagName("a"); + + // We're going to click on the first link, so listen for another load event + info("dynamic: Clicking on link 1, 1 load should take place"); + let promise = waitForLoadsInBrowser(tab.linkedBrowser, 1); + EventUtils.sendMouseEvent( + { type: "click" }, + links[0], + browser_b.contentWindow + ); + await promise; + + info("dynamic: Clicking on link 2, 1 load should take place"); + promise = waitForLoadsInBrowser(tab.linkedBrowser, 1); + EventUtils.sendMouseEvent( + { type: "click" }, + links[1], + browser_b.contentWindow + ); + await promise; + + info("Check in the state that we have not stored this history"); + let state = ss.getBrowserState(); + info(JSON.stringify(JSON.parse(state), null, "\t")); + is( + state.indexOf("c1.html"), + -1, + "History entry was not stored in the session state" + ); + gBrowser.removeTab(tab); +}); + +// helper functions +function waitForLoadsInBrowser(aBrowser, aLoadCount) { + return new Promise(resolve => { + let loadCount = 0; + aBrowser.addEventListener( + "load", + function listener(aEvent) { + if (++loadCount < aLoadCount) { + info( + "Got " + loadCount + " loads, waiting until we have " + aLoadCount + ); + return; + } + + aBrowser.removeEventListener("load", listener, true); + resolve(); + }, + true + ); + }); +} + +function timeout(delay, task) { + return new Promise((resolve, reject) => { + setTimeout(() => resolve(true), delay); + task.then(() => resolve(false), reject); + }); +} diff --git a/browser/components/sessionstore/test/browser_frame_history_a.html b/browser/components/sessionstore/test/browser_frame_history_a.html new file mode 100644 index 0000000000..8e7b35d7a1 --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_a.html @@ -0,0 +1,5 @@ + + + I'm A! + + diff --git a/browser/components/sessionstore/test/browser_frame_history_b.html b/browser/components/sessionstore/test/browser_frame_history_b.html new file mode 100644 index 0000000000..38b43da211 --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_b.html @@ -0,0 +1,10 @@ + + + I'm B!
    + click me first
    + then click me
    + Close this tab.
    + Restore this tab.
    + Click back.
    + + diff --git a/browser/components/sessionstore/test/browser_frame_history_c.html b/browser/components/sessionstore/test/browser_frame_history_c.html new file mode 100644 index 0000000000..0efd7d9026 --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_c.html @@ -0,0 +1,5 @@ + + + I'm C! + + diff --git a/browser/components/sessionstore/test/browser_frame_history_c1.html b/browser/components/sessionstore/test/browser_frame_history_c1.html new file mode 100644 index 0000000000..b55c1d45a9 --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_c1.html @@ -0,0 +1,5 @@ + + + I'm C1! + + diff --git a/browser/components/sessionstore/test/browser_frame_history_c2.html b/browser/components/sessionstore/test/browser_frame_history_c2.html new file mode 100644 index 0000000000..aec504141b --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_c2.html @@ -0,0 +1,5 @@ + + + I'm C2! + + diff --git a/browser/components/sessionstore/test/browser_frame_history_index.html b/browser/components/sessionstore/test/browser_frame_history_index.html new file mode 100644 index 0000000000..04a44555ab --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_index.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/browser/components/sessionstore/test/browser_frame_history_index2.html b/browser/components/sessionstore/test/browser_frame_history_index2.html new file mode 100644 index 0000000000..d465abef62 --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_index2.html @@ -0,0 +1,3 @@ + + + + diff --git a/browser/components/sessionstore/test/browser_global_store.js b/browser/components/sessionstore/test/browser_global_store.js new file mode 100644 index 0000000000..99aa672180 --- /dev/null +++ b/browser/components/sessionstore/test/browser_global_store.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests the API for saving global session data. +add_task(async function () { + const key1 = "Unique name 1: " + Date.now(); + const key2 = "Unique name 2: " + Date.now(); + const value1 = "Unique value 1: " + Math.random(); + const value2 = "Unique value 2: " + Math.random(); + + let global = {}; + global[key1] = value1; + + const testState = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + ], + }, + ], + global, + }; + + function testRestoredState() { + is( + ss.getCustomGlobalValue(key1), + value1, + "restored state has global value" + ); + } + + function testGlobalStore() { + is(ss.getCustomGlobalValue(key2), "", "global value initially not set"); + + ss.setCustomGlobalValue(key2, value1); + is(ss.getCustomGlobalValue(key2), value1, "retreived value matches stored"); + + ss.setCustomGlobalValue(key2, value2); + is( + ss.getCustomGlobalValue(key2), + value2, + "previously stored value was overwritten" + ); + + ss.deleteCustomGlobalValue(key2); + is(ss.getCustomGlobalValue(key2), "", "global value was deleted"); + } + + await promiseBrowserState(testState); + testRestoredState(); + testGlobalStore(); +}); diff --git a/browser/components/sessionstore/test/browser_history_persist.js b/browser/components/sessionstore/test/browser_history_persist.js new file mode 100644 index 0000000000..f6749b02e3 --- /dev/null +++ b/browser/components/sessionstore/test/browser_history_persist.js @@ -0,0 +1,163 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Ensure that history entries that should not be persisted are restored in the + * same state. + */ +add_task(async function check_history_not_persisted() { + // Create an about:blank tab + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Retrieve the tab state. + await TabStateFlusher.flush(browser); + let state = JSON.parse(ss.getTabState(tab)); + ok(!state.entries[0].persist, "Should have collected the persistence state"); + BrowserTestUtils.removeTab(tab); + browser = null; + + // Open a new tab to restore into. + tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + browser = tab.linkedBrowser; + await promiseTabState(tab, state); + + if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(browser, [], function () { + let sessionHistory = + docShell.browsingContext.childSessionHistory.legacySHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + }); + } else { + let sessionHistory = browser.browsingContext.sessionHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + } + + // Load a new URL into the tab, it should replace the about:blank history entry + BrowserTestUtils.startLoadingURIString(browser, "about:robots"); + await promiseBrowserLoaded(browser, false, "about:robots"); + if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(browser, [], function () { + let sessionHistory = + docShell.browsingContext.childSessionHistory.legacySHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:robots", + "Should be the right URL" + ); + }); + } else { + let sessionHistory = browser.browsingContext.sessionHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:robots", + "Should be the right URL" + ); + } + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); + +/** + * Check that entries default to being persisted when the attribute doesn't + * exist + */ +add_task(async function check_history_default_persisted() { + // Create an about:blank tab + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Retrieve the tab state. + await TabStateFlusher.flush(browser); + let state = JSON.parse(ss.getTabState(tab)); + delete state.entries[0].persist; + BrowserTestUtils.removeTab(tab); + browser = null; + + // Open a new tab to restore into. + tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + browser = tab.linkedBrowser; + await promiseTabState(tab, state); + if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(browser, [], function () { + let sessionHistory = + docShell.browsingContext.childSessionHistory.legacySHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + }); + } else { + let sessionHistory = browser.browsingContext.sessionHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + } + + // Load a new URL into the tab, it should replace the about:blank history entry + BrowserTestUtils.startLoadingURIString(browser, "about:robots"); + await promiseBrowserLoaded(browser, false, "about:robots"); + if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(browser, [], function () { + let sessionHistory = + docShell.browsingContext.childSessionHistory.legacySHistory; + + is(sessionHistory.count, 2, "Should be two history entries"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + is( + sessionHistory.getEntryAtIndex(1).URI.spec, + "about:robots", + "Should be the right URL" + ); + }); + } else { + let sessionHistory = browser.browsingContext.sessionHistory; + + is(sessionHistory.count, 2, "Should be two history entries"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + is( + sessionHistory.getEntryAtIndex(1).URI.spec, + "about:robots", + "Should be the right URL" + ); + } + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_ignore_updates_crashed_tabs.js b/browser/components/sessionstore/test/browser_ignore_updates_crashed_tabs.js new file mode 100644 index 0000000000..b70a3aa392 --- /dev/null +++ b/browser/components/sessionstore/test/browser_ignore_updates_crashed_tabs.js @@ -0,0 +1,108 @@ +// This test checks that browsers are removed from the SessionStore's +// crashed browser set at a correct time, so that it can stop ignoring update +// events coming from those browsers. + +/** + * Open a tab, crash it, navigate it to a remote uri, and check that it + * is removed from a crashed set. + */ +add_task(async function test_update_crashed_tab_after_navigate_to_remote() { + let tab = BrowserTestUtils.addTab(gBrowser, "https://example.com/"); + let browser = tab.linkedBrowser; + gBrowser.selectedTab = tab; + await promiseBrowserLoaded(browser); + ok( + !SessionStore.isBrowserInCrashedSet(browser), + "browser is not in the crashed set" + ); + + await BrowserTestUtils.crashFrame(browser); + ok( + SessionStore.isBrowserInCrashedSet(browser), + "browser is in the crashed set" + ); + + BrowserTestUtils.startLoadingURIString(browser, "https://example.org/"); + await BrowserTestUtils.browserLoaded(browser, false, "https://example.org/"); + ok( + !SessionStore.isBrowserInCrashedSet(browser), + "browser is not in the crashed set" + ); + ok( + !tab.hasAttribute("crashed"), + "Tab shouldn't be marked as crashed anymore." + ); + gBrowser.removeTab(tab); +}); + +/** + * Open a tab, crash it, navigate it to a non-remote uri, and check that it + * is removed from a crashed set. + */ +add_task(async function test_update_crashed_tab_after_navigate_to_non_remote() { + let tab = BrowserTestUtils.addTab(gBrowser, "https://example.com/"); + let browser = tab.linkedBrowser; + gBrowser.selectedTab = tab; + await promiseBrowserLoaded(browser); + ok( + !SessionStore.isBrowserInCrashedSet(browser), + "browser is not in the crashed set" + ); + + await BrowserTestUtils.crashFrame(browser); + ok( + SessionStore.isBrowserInCrashedSet(browser), + "browser is in the crashed set" + ); + + BrowserTestUtils.startLoadingURIString(browser, "about:mozilla"); + await BrowserTestUtils.browserLoaded(browser, false, "about:mozilla"); + ok( + !SessionStore.isBrowserInCrashedSet(browser), + "browser is not in the crashed set" + ); + ok( + !gBrowser.selectedTab.hasAttribute("crashed"), + "Tab shouldn't be marked as crashed anymore." + ); + gBrowser.removeTab(tab); +}); + +/** + * Open a tab, crash it, restore it from history, and check that it + * is removed from a crashed set. + */ +add_task(async function test_update_crashed_tab_after_session_restore() { + let tab = BrowserTestUtils.addTab(gBrowser, "https://example.com/"); + let browser = tab.linkedBrowser; + gBrowser.selectedTab = tab; + await promiseBrowserLoaded(browser); + ok( + !SessionStore.isBrowserInCrashedSet(browser), + "browser is not in the crashed set" + ); + + await BrowserTestUtils.crashFrame(browser); + ok( + SessionStore.isBrowserInCrashedSet(browser), + "browser is in the crashed set" + ); + + let tabRestoredPromise = promiseTabRestored(tab); + // Click restoreTab button + await SpecialPowers.spawn(browser, [], () => { + let button = content.document.getElementById("restoreTab"); + button.click(); + }); + await tabRestoredPromise; + ok( + !tab.hasAttribute("crashed"), + "Tab shouldn't be marked as crashed anymore." + ); + ok( + !SessionStore.isBrowserInCrashedSet(browser), + "browser is not in the crashed set" + ); + + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_label_and_icon.js b/browser/components/sessionstore/test/browser_label_and_icon.js new file mode 100644 index 0000000000..6938c3b01e --- /dev/null +++ b/browser/components/sessionstore/test/browser_label_and_icon.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Ensure that a pending tab has label and icon correctly set. + */ +add_task(async function test_label_and_icon() { + // Make sure that tabs are restored on demand as otherwise the tab will start + // loading immediately and we can't check its icon and label. + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.restore_on_demand", true]], + }); + + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, "about:robots"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + // Because there is debounce logic in FaviconLoader.sys.mjs to reduce the + // favicon loads, we have to wait some time before checking that icon was + // stored properly. + await BrowserTestUtils.waitForCondition( + () => { + return gBrowser.getIcon(tab) != null; + }, + "wait for favicon load to finish", + 100, + 5 + ); + + // Retrieve the tab state. + await TabStateFlusher.flush(browser); + let state = ss.getTabState(tab); + BrowserTestUtils.removeTab(tab); + browser = null; + + // Open a new tab to restore into. + tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + ss.setTabState(tab, state); + await promiseTabRestoring(tab); + + // Check that label and icon are set for the restoring tab. + is( + gBrowser.getIcon(tab), + "chrome://browser/content/robot.ico", + "icon is set" + ); + is(tab.label, "Gort! Klaatu barada nikto!", "label is set"); + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_movePendingTabToNewWindow.js b/browser/components/sessionstore/test/browser_movePendingTabToNewWindow.js new file mode 100644 index 0000000000..3a013132be --- /dev/null +++ b/browser/components/sessionstore/test/browser_movePendingTabToNewWindow.js @@ -0,0 +1,124 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This tests the behaviour of moving pending tabs to a new window. These + * pending tabs have yet to be restored and should be restored upon opening + * in the new window. This test covers moving a single pending tab at once + * as well as multiple tabs at the same time (using tab multiselection). + */ +add_task(async function test_movePendingTabToNewWindow() { + const TEST_URIS = [ + "http://www.example.com/1", + "http://www.example.com/2", + "http://www.example.com/3", + "http://www.example.com/4", + ]; + + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.restore_on_demand", true]], + }); + + let state = { + windows: [ + { + tabs: [ + { entries: [{ url: TEST_URIS[0], triggeringPrincipal_base64 }] }, + { entries: [{ url: TEST_URIS[1], triggeringPrincipal_base64 }] }, + { entries: [{ url: TEST_URIS[2], triggeringPrincipal_base64 }] }, + { entries: [{ url: TEST_URIS[3], triggeringPrincipal_base64 }] }, + ], + selected: 4, + }, + ], + }; + + await promiseBrowserState(state); + + is( + gBrowser.visibleTabs.length, + 4, + "Three tabs are visible to start the test" + ); + + let tabToSelect = gBrowser.visibleTabs[1]; + ok(tabToSelect.hasAttribute("pending"), "Tab should be pending"); + + gBrowser.addRangeToMultiSelectedTabs(gBrowser.selectedTab, tabToSelect); + ok(!gBrowser.visibleTabs[0].multiselected, "First tab not multiselected"); + ok(gBrowser.visibleTabs[1].multiselected, "Second tab multiselected"); + ok(gBrowser.visibleTabs[2].multiselected, "Third tab multiselected"); + ok(gBrowser.visibleTabs[3].multiselected, "Fourth tab multiselected"); + + let promiseNewWindow = BrowserTestUtils.waitForNewWindow(); + gBrowser.replaceTabsWithWindow(tabToSelect); + + info("Waiting for new window"); + let newWindow = await promiseNewWindow; + isnot(newWindow, gBrowser.ownerGlobal, "Tab moved to new window"); + + let newWindowTabs = newWindow.gBrowser.visibleTabs; + await TestUtils.waitForCondition(() => { + return ( + newWindowTabs.length == 3 && + newWindowTabs[0].linkedBrowser.currentURI.spec == TEST_URIS[1] && + newWindowTabs[1].linkedBrowser.currentURI.spec == TEST_URIS[2] && + newWindowTabs[2].linkedBrowser.currentURI.spec == TEST_URIS[3] + ); + }, "Wait for all three tabs to move to new window and load"); + + is(newWindowTabs.length, 3, "Three tabs should be in new window"); + is( + newWindowTabs[0].linkedBrowser.currentURI.spec, + TEST_URIS[1], + "Second tab moved" + ); + is( + newWindowTabs[1].linkedBrowser.currentURI.spec, + TEST_URIS[2], + "Third tab moved" + ); + is( + newWindowTabs[2].linkedBrowser.currentURI.spec, + TEST_URIS[3], + "Fourth tab moved" + ); + + ok( + newWindowTabs[0].hasAttribute("pending"), + "First tab in new window should still be pending" + ); + ok( + newWindowTabs[1].hasAttribute("pending"), + "Second tab in new window should still be pending" + ); + newWindow.gBrowser.clearMultiSelectedTabs(); + ok( + newWindowTabs.every(t => !t.multiselected), + "No multiselection should be present" + ); + + promiseNewWindow = BrowserTestUtils.waitForNewWindow(); + newWindow.gBrowser.replaceTabsWithWindow(newWindowTabs[0]); + + info("Waiting for second new window"); + let secondNewWindow = await promiseNewWindow; + await TestUtils.waitForCondition( + () => + secondNewWindow.gBrowser.selectedBrowser.currentURI.spec == TEST_URIS[1], + "Wait until the URI is updated" + ); + is( + secondNewWindow.gBrowser.visibleTabs.length, + 1, + "Only one tab in second new window" + ); + is( + secondNewWindow.gBrowser.selectedBrowser.currentURI.spec, + TEST_URIS[1], + "First tab moved" + ); + + await BrowserTestUtils.closeWindow(secondNewWindow); + await BrowserTestUtils.closeWindow(newWindow); +}); diff --git a/browser/components/sessionstore/test/browser_multiple_navigateAndRestore.js b/browser/components/sessionstore/test/browser_multiple_navigateAndRestore.js new file mode 100644 index 0000000000..e2ee1b9e39 --- /dev/null +++ b/browser/components/sessionstore/test/browser_multiple_navigateAndRestore.js @@ -0,0 +1,45 @@ +"use strict"; + +const PAGE_1 = + "data:text/html,A%20regular,%20everyday,%20normal%20page."; +const PAGE_2 = + "data:text/html,Another%20regular,%20everyday,%20normal%20page."; + +add_task(async function () { + // Load an empty, non-remote tab at about:blank... + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank", { + forceNotRemote: true, + }); + gBrowser.selectedTab = tab; + let browser = gBrowser.selectedBrowser; + ok(!browser.isRemoteBrowser, "Ensure browser is not remote"); + // Load a remote page, and then another remote page immediately + // after. + BrowserTestUtils.startLoadingURIString(browser, PAGE_1); + browser.stop(); + BrowserTestUtils.startLoadingURIString(browser, PAGE_2); + await BrowserTestUtils.browserLoaded(browser, false, PAGE_2); + + ok(browser.isRemoteBrowser, "Should have switched remoteness"); + await TabStateFlusher.flush(browser); + let state = JSON.parse(ss.getTabState(tab)); + let entries = state.entries; + is(entries.length, 1, "There should only be one entry"); + is(entries[0].url, PAGE_2, "Should have PAGE_2 as the sole history entry"); + is( + browser.currentURI.spec, + PAGE_2, + "Should have PAGE_2 as the browser currentURI" + ); + + await SpecialPowers.spawn(browser, [PAGE_2], async function (expectedURL) { + docShell.QueryInterface(Ci.nsIWebNavigation); + Assert.equal( + docShell.currentURI.spec, + expectedURL, + "Content should have PAGE_2 as the browser currentURI" + ); + }); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_multiple_select_after_load.js b/browser/components/sessionstore/test/browser_multiple_select_after_load.js new file mode 100644 index 0000000000..dcb896e435 --- /dev/null +++ b/browser/components/sessionstore/test/browser_multiple_select_after_load.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = `data:text/html;charset=utf-8, +`; + +const VALUES = ["1", "3"]; + +// Tests that a document that changes a element and select some + // options. + await setPropertyOfFormField(tab.linkedBrowser, "select", "multiple", true); + + for (let v of VALUES) { + await setPropertyOfFormField( + tab.linkedBrowser, + `option[value="${v}"]`, + "selected", + true + ); + } + + // Remove the tab. + await promiseRemoveTabAndSessionState(tab); + + // Verify state of the closed tab. + let tabData = ss.getClosedTabDataForWindow(window); + Assert.deepEqual( + tabData[0].state.formdata.id.select, + VALUES, + "Collected correct formdata" + ); + + // Restore the close tab. + tab = ss.undoCloseTab(window, 0); + await promiseTabRestored(tab); + ok(true, "Didn't crash!"); + + // Cleanup. + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_newtab_userTypedValue.js b/browser/components/sessionstore/test/browser_newtab_userTypedValue.js new file mode 100644 index 0000000000..755a1f2859 --- /dev/null +++ b/browser/components/sessionstore/test/browser_newtab_userTypedValue.js @@ -0,0 +1,92 @@ +"use strict"; + +requestLongerTimeout(4); + +/** + * Test that when restoring an 'initial page' with session restore, it + * produces an empty URL bar, rather than leaving its URL explicitly + * there as a 'user typed value'. + */ +add_task(async function () { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:logo"); + let tabOpenedAndSwitchedTo = BrowserTestUtils.switchTab( + win.gBrowser, + () => {} + ); + + // This opens about:newtab: + win.BrowserOpenTab(); + let tab = await tabOpenedAndSwitchedTo; + is(win.gURLBar.value, "", "URL bar should be empty"); + is(tab.linkedBrowser.userTypedValue, null, "userTypedValue should be null"); + let state = JSON.parse(SessionStore.getTabState(tab)); + ok( + !state.userTypedValue, + "userTypedValue should be undefined on the tab's state" + ); + tab = null; + + await BrowserTestUtils.closeWindow(win); + + ok(SessionStore.getClosedWindowCount(), "Should have a closed window"); + + await forceSaveState(); + + win = SessionStore.undoCloseWindow(0); + await TestUtils.topicObserved( + "sessionstore-single-window-restored", + subject => subject == win + ); + // Don't wait for load here because it's about:newtab and we may have swapped in + // a preloaded browser. + await TabStateFlusher.flush(win.gBrowser.selectedBrowser); + + is(win.gURLBar.value, "", "URL bar should be empty"); + tab = win.gBrowser.selectedTab; + is(tab.linkedBrowser.userTypedValue, null, "userTypedValue should be null"); + state = JSON.parse(SessionStore.getTabState(tab)); + ok( + !state.userTypedValue, + "userTypedValue should be undefined on the tab's state" + ); + + BrowserTestUtils.removeTab(tab); + + for (let url of gInitialPages) { + if (url == BROWSER_NEW_TAB_URL) { + continue; // We tested about:newtab using BrowserOpenTab() above. + } + info("Testing " + url + " - " + new Date()); + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, url); + await BrowserTestUtils.closeWindow(win); + + ok(SessionStore.getClosedWindowCount(), "Should have a closed window"); + + await forceSaveState(); + + win = SessionStore.undoCloseWindow(0); + await TestUtils.topicObserved( + "sessionstore-single-window-restored", + subject => subject == win + ); + await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await TabStateFlusher.flush(win.gBrowser.selectedBrowser); + + is(win.gURLBar.value, "", "URL bar should be empty"); + tab = win.gBrowser.selectedTab; + is(tab.linkedBrowser.userTypedValue, null, "userTypedValue should be null"); + state = JSON.parse(SessionStore.getTabState(tab)); + ok( + !state.userTypedValue, + "userTypedValue should be undefined on the tab's state" + ); + + info("Removing tab - " + new Date()); + BrowserTestUtils.removeTab(tab); + info("Finished removing tab - " + new Date()); + } + info("Removing window - " + new Date()); + await BrowserTestUtils.closeWindow(win); + info("Finished removing window - " + new Date()); +}); diff --git a/browser/components/sessionstore/test/browser_not_collect_when_idle.js b/browser/components/sessionstore/test/browser_not_collect_when_idle.js new file mode 100644 index 0000000000..c4a49ab7b7 --- /dev/null +++ b/browser/components/sessionstore/test/browser_not_collect_when_idle.js @@ -0,0 +1,118 @@ +/** Test for Bug 1305950 **/ + +const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +// The mock idle service. +var idleService = { + _observers: new Set(), + _activity: { + addCalls: [], + removeCalls: [], + observerFires: [], + }, + + _reset() { + this._observers.clear(); + this._activity.addCalls = []; + this._activity.removeCalls = []; + this._activity.observerFires = []; + }, + + _fireObservers(state) { + for (let observer of this._observers.values()) { + observer.observe(observer, state, null); + this._activity.observerFires.push(state); + } + }, + + QueryInterface: ChromeUtils.generateQI(["nsIUserIdleService"]), + idleTime: 19999, + + addIdleObserver(observer, time) { + this._observers.add(observer); + this._activity.addCalls.push(time); + }, + + removeIdleObserver(observer, time) { + this._observers.delete(observer); + this._activity.removeCalls.push(time); + }, +}; + +add_task(async function testIntervalChanges() { + const PREF_SS_INTERVAL = 2000; + + // We speed up the interval between session saves to ensure that the test + // runs quickly. + Services.prefs.setIntPref("browser.sessionstore.interval", PREF_SS_INTERVAL); + + // Increase `idleDelay` to 1 day to update the pre-registered idle observer + // in "real" idle service to avoid possible interference, especially for the + // CI server environment. + Services.prefs.setIntPref("browser.sessionstore.idleDelay", 86400); + + // Mock an idle service. + let fakeIdleService = MockRegistrar.register( + "@mozilla.org/widget/useridleservice;1", + idleService + ); + idleService._reset(); + + registerCleanupFunction(function () { + Services.prefs.clearUserPref("browser.sessionstore.interval"); + MockRegistrar.unregister(fakeIdleService); + }); + + // Hook idle/active observer to mock idle service by changing pref `idleDelay` + // to a whatever value, which will not be used. + Services.prefs.setIntPref("browser.sessionstore.idleDelay", 5000); + + // Wait a `sessionstore-state-write-complete` event from any previous + // scheduled state write. This is needed since the `_lastSaveTime` in + // runDelayed() should be set at least once, or the `_isIdle` flag will not + // become effective. + info("Waiting for sessionstore-state-write-complete notification"); + await TestUtils.topicObserved("sessionstore-state-write-complete"); + + info( + "Got the sessionstore-state-write-complete notification, now testing idle mode" + ); + + // Enter the "idle mode" (raise the `_isIdle` flag) by firing idle + // observer of mock idle service. + idleService._fireObservers("idle"); + + // Cancel any possible state save, which is not related with this test to + // avoid interference. + SessionSaver.cancel(); + + let p1 = promiseSaveState(); + + // Schedule a state write, which is expeced to be postponed after about + // `browser.sessionstore.interval.idle` ms, since the idle flag was just set. + SessionSaver.runDelayed(0); + + // We expect `p1` hits the timeout. + await Assert.rejects( + p1, + /Save state timeout/, + "[Test 1A] No state write during idle." + ); + + // Test again for better reliability. Same, we expect following promise hits + // the timeout. + await Assert.rejects( + promiseSaveState(), + /Save state timeout/, + "[Test 1B] Again: No state write during idle." + ); + + // Back to the active mode. + info("Start to test active mode..."); + idleService._fireObservers("active"); + + info("[Test 2] Waiting for sessionstore-state-write-complete during active"); + await TestUtils.topicObserved("sessionstore-state-write-complete"); +}); diff --git a/browser/components/sessionstore/test/browser_old_favicon.js b/browser/components/sessionstore/test/browser_old_favicon.js new file mode 100644 index 0000000000..fc416e81f6 --- /dev/null +++ b/browser/components/sessionstore/test/browser_old_favicon.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Ensure that we can restore old style favicon and principals. + */ +add_task(async function test_label_and_icon() { + // Make sure that tabs are restored on demand as otherwise the tab will start + // loading immediately and override the icon. + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.restore_on_demand", true]], + }); + + // Create a new tab. + let tab = BrowserTestUtils.addTab( + gBrowser, + "http://www.example.com/browser/browser/components/sessionstore/test/empty.html" + ); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + let contentPrincipal = browser.contentPrincipal; + let serializedPrincipal = E10SUtils.serializePrincipal(contentPrincipal); + + // Retrieve the tab state. + await TabStateFlusher.flush(browser); + let state = JSON.parse(ss.getTabState(tab)); + state.image = "http://www.example.com/favicon.ico"; + state.iconLoadingPrincipal = serializedPrincipal; + + BrowserTestUtils.removeTab(tab); + + // Open a new tab to restore into. + tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + ss.setTabState(tab, state); + await promiseTabRestoring(tab); + + // Check that label and icon are set for the restoring tab. + is( + gBrowser.getIcon(tab), + "http://www.example.com/favicon.ico", + "icon is set" + ); + is( + tab.getAttribute("image"), + "http://www.example.com/favicon.ico", + "tab image is set" + ); + is( + tab.getAttribute("iconloadingprincipal"), + serializedPrincipal, + "tab image loading principal is set" + ); + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_page_title.js b/browser/components/sessionstore/test/browser_page_title.js new file mode 100644 index 0000000000..9f84e67a94 --- /dev/null +++ b/browser/components/sessionstore/test/browser_page_title.js @@ -0,0 +1,54 @@ +"use strict"; + +const URL = "data:text/html,initial title"; + +add_task(async function () { + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + await promiseBrowserLoaded(tab.linkedBrowser); + + // Remove the tab. + await promiseRemoveTabAndSessionState(tab); + + // Check the title. + let [ + { + state: { entries }, + }, + ] = ss.getClosedTabDataForWindow(window); + is(entries[0].title, "initial title", "correct title"); +}); + +add_task(async function () { + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Flush to ensure we collected the initial title. + await TabStateFlusher.flush(browser); + + // Set a new title. + await SpecialPowers.spawn(browser, [], async function () { + return new Promise(resolve => { + docShell.chromeEventHandler.addEventListener( + "DOMTitleChanged", + () => resolve(), + { once: true } + ); + + content.document.title = "new title"; + }); + }); + + // Remove the tab. + await promiseRemoveTabAndSessionState(tab); + + // Check the title. + let [ + { + state: { entries }, + }, + ] = ss.getClosedTabDataForWindow(window); + is(entries[0].title, "new title", "correct title"); +}); diff --git a/browser/components/sessionstore/test/browser_parentProcessRestoreHash.js b/browser/components/sessionstore/test/browser_parentProcessRestoreHash.js new file mode 100644 index 0000000000..442914d580 --- /dev/null +++ b/browser/components/sessionstore/test/browser_parentProcessRestoreHash.js @@ -0,0 +1,115 @@ +"use strict"; + +const SELFCHROMEURL = + "chrome://mochitests/content/browser/browser/" + + "components/sessionstore/test/browser_parentProcessRestoreHash.js"; + +const Cm = Components.manager; + +const TESTCLASSID = "78742c04-3630-448c-9be3-6c5070f062de"; + +const TESTURL = "about:testpageforsessionrestore#foo"; + +let TestAboutPage = { + QueryInterface: ChromeUtils.generateQI(["nsIAboutModule"]), + getURIFlags(aURI) { + // No CAN_ or MUST_LOAD_IN_CHILD means this loads in the parent: + return ( + Ci.nsIAboutModule.ALLOW_SCRIPT | + Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT | + Ci.nsIAboutModule.HIDE_FROM_ABOUTABOUT + ); + }, + + newChannel(aURI, aLoadInfo) { + // about: page inception! + let newURI = Services.io.newURI(SELFCHROMEURL); + let channel = Services.io.newChannelFromURIWithLoadInfo(newURI, aLoadInfo); + channel.originalURI = aURI; + return channel; + }, + + createInstance(iid) { + return this.QueryInterface(iid); + }, + + register() { + Cm.QueryInterface(Ci.nsIComponentRegistrar).registerFactory( + Components.ID(TESTCLASSID), + "Only here for a test", + "@mozilla.org/network/protocol/about;1?what=testpageforsessionrestore", + this + ); + }, + + unregister() { + Cm.QueryInterface(Ci.nsIComponentRegistrar).unregisterFactory( + Components.ID(TESTCLASSID), + this + ); + }, +}; + +/** + * Test that switching from a remote to a parent process browser + * correctly clears the userTypedValue + */ +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.skip_about_page_has_csp_assert", true]], + }); + + TestAboutPage.register(); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/", + true, + true + ); + ok(tab.linkedBrowser.isRemoteBrowser, "Browser should be remote"); + + let resolveLocationChangePromise; + let locationChangePromise = new Promise( + r => (resolveLocationChangePromise = r) + ); + let wpl = { + onStateChange(listener, request, state, status) { + let location = request.QueryInterface(Ci.nsIChannel).originalURI; + // Ignore about:blank loads. + let docStop = + Ci.nsIWebProgressListener.STATE_STOP | + Ci.nsIWebProgressListener.STATE_IS_NETWORK; + if (location.spec == "about:blank" || (state & docStop) != docStop) { + return; + } + is(location.spec, TESTURL, "Got the expected URL"); + resolveLocationChangePromise(); + }, + }; + gBrowser.addProgressListener(wpl); + + gURLBar.value = TESTURL; + gURLBar.select(); + EventUtils.sendKey("return"); + + await locationChangePromise; + + ok(!tab.linkedBrowser.isRemoteBrowser, "Browser should no longer be remote"); + + is(gURLBar.value, TESTURL, "URL bar visible value should be correct."); + is(gURLBar.untrimmedValue, TESTURL, "URL bar value should be correct."); + is( + gURLBar.getAttribute("pageproxystate"), + "valid", + "URL bar is in valid page proxy state" + ); + + ok( + !tab.linkedBrowser.userTypedValue, + "No userTypedValue should be on the browser." + ); + + BrowserTestUtils.removeTab(tab); + gBrowser.removeProgressListener(wpl); + TestAboutPage.unregister(); +}); diff --git a/browser/components/sessionstore/test/browser_pending_tabs.js b/browser/components/sessionstore/test/browser_pending_tabs.js new file mode 100644 index 0000000000..279d2efcf9 --- /dev/null +++ b/browser/components/sessionstore/test/browser_pending_tabs.js @@ -0,0 +1,38 @@ +"use strict"; + +const TAB_STATE = { + entries: [ + { url: "about:mozilla", triggeringPrincipal_base64 }, + { url: "about:robots", triggeringPrincipal_base64 }, + ], + index: 1, +}; + +add_task(async function () { + // Create a background tab. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // The tab shouldn't be restored right away. + Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", true); + + // Prepare the tab state. + let promise = promiseTabRestoring(tab); + ss.setTabState(tab, JSON.stringify(TAB_STATE)); + ok(tab.hasAttribute("pending"), "tab is pending"); + await promise; + + // Flush to ensure the parent has all data. + await TabStateFlusher.flush(browser); + + // Check that the shistory index is the one we restored. + let tabState = TabState.collect(tab, ss.getInternalObjectState(tab)); + is(tabState.index, TAB_STATE.index, "correct shistory index"); + + // Check we don't collect userTypedValue when we shouldn't. + ok(!tabState.userTypedValue, "tab didn't have a userTypedValue"); + + // Cleanup. + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_pinned_tabs.js b/browser/components/sessionstore/test/browser_pinned_tabs.js new file mode 100644 index 0000000000..7a51da7ccc --- /dev/null +++ b/browser/components/sessionstore/test/browser_pinned_tabs.js @@ -0,0 +1,324 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const REMOTE_URL = "https://www.example.com/"; +const ABOUT_ROBOTS_URL = "about:robots"; +const NO_TITLE_URL = "data:text/plain,foo"; + +const BACKUP_STATE = SessionStore.getBrowserState(); +registerCleanupFunction(() => promiseBrowserState(BACKUP_STATE)); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.sessionstore.restore_on_demand", true], + ["browser.sessionstore.restore_tabs_lazily", true], + ], + }); +}); + +/** + * When implementing batch insertion of tabs as part of session restore, + * we started reversing the insertion order of pinned tabs (bug 1607441). + * This test checks we don't regress that again. + */ +add_task(async function test_pinned_tabs_order() { + // we expect 3 pinned tabs plus the selected tab get content restored. + let allTabsRestored = promiseSessionStoreLoads(4); + await promiseBrowserState({ + windows: [ + { + selected: 4, // SessionStore uses 1-based indexing. + tabs: [ + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: ABOUT_ROBOTS_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: NO_TITLE_URL, triggeringPrincipal_base64 }], + }, + { entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }] }, + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + ], + }, + ], + }); + await allTabsRestored; + let [tab1, tab2, tab3, tab4, tab5] = gBrowser.tabs; + ok(tab1.pinned, "First tab is pinned"); + ok(tab2.pinned, "Second tab is pinned"); + ok(tab3.pinned, "Third tab is pinned"); + ok(!tab4.pinned, "Fourth tab is not pinned"); + ok(!tab5.pinned, "Fifth tab is not pinned"); + + ok(tab4.selected, "Fourth tab is selected"); + is( + tab1.linkedBrowser.currentURI.spec, + REMOTE_URL, + "First tab has matching URL" + ); + is( + tab2.linkedBrowser.currentURI.spec, + ABOUT_ROBOTS_URL, + "Second tab has matching URL" + ); + is( + tab3.linkedBrowser.currentURI.spec, + NO_TITLE_URL, + "Third tab has matching URL" + ); + // Clean up for the next task. + await promiseBrowserState(BACKUP_STATE); +}); + +/** + * When fixing the previous regression, pinned tabs started disappearing out + * of sessions with selected pinned tabs. This test checks that case. + */ +add_task(async function test_selected_pinned_tab_dataloss() { + // we expect 3 pinned tabs (one of which is selected) get content restored. + let allTabsRestored = promiseSessionStoreLoads(3); + await promiseBrowserState({ + windows: [ + { + selected: 1, // SessionStore uses 1-based indexing. + tabs: [ + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: ABOUT_ROBOTS_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: NO_TITLE_URL, triggeringPrincipal_base64 }], + }, + { entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }] }, + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + ], + }, + ], + }); + await allTabsRestored; + let [tab1, tab2, tab3, tab4, tab5] = gBrowser.tabs; + ok(tab5, "Should have 5 tabs"); + ok(tab1.pinned, "First tab is pinned"); + ok(tab2.pinned, "Second tab is pinned"); + ok(tab3.pinned, "Third tab is pinned"); + ok(tab4 && !tab4.pinned, "Fourth tab is not pinned"); + ok(tab5 && !tab5.pinned, "Fifth tab is not pinned"); + + ok(tab1 && tab1.selected, "First (pinned) tab is selected"); + is( + tab1.linkedBrowser.currentURI.spec, + REMOTE_URL, + "First tab has matching URL" + ); + is( + tab2.linkedBrowser.currentURI.spec, + ABOUT_ROBOTS_URL, + "Second tab has matching URL" + ); + is( + tab3.linkedBrowser.currentURI.spec, + NO_TITLE_URL, + "Third tab has matching URL" + ); + // Clean up for the next task. + await promiseBrowserState(BACKUP_STATE); +}); + +/** + * While we're here, it seems useful to have a test for mixed pinned and + * unpinned tabs in session store state, as well as userContextId. + */ +add_task(async function test_mixed_pinned_unpinned() { + // we expect 3 pinned tabs plus the selected tab get content restored. + let allTabsRestored = promiseSessionStoreLoads(4); + await promiseBrowserState({ + windows: [ + { + selected: 4, // SessionStore uses 1-based indexing. + tabs: [ + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }] }, + { + pinned: true, + entries: [{ url: ABOUT_ROBOTS_URL, triggeringPrincipal_base64 }], + }, + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + { + pinned: true, + entries: [{ url: NO_TITLE_URL, triggeringPrincipal_base64 }], + }, + ], + }, + ], + }); + await allTabsRestored; + let [tab1, tab2, tab3, tab4, tab5] = gBrowser.tabs; + ok(tab1.pinned, "First tab is pinned"); + ok(tab2.pinned, "Second tab is pinned"); + ok(tab3.pinned, "Third tab is pinned"); + ok(!tab4.pinned, "Fourth tab is not pinned"); + ok(!tab5.pinned, "Fifth tab is not pinned"); + + // This is confusing to read - the 4th entry in the session data is + // selected. But the 5th entry is pinned, so it moves to the start of the + // tabstrip, so when we fetch `gBrowser.tabs`, the 4th entry in the list + // is actually the 5th tab. + ok(tab5.selected, "Fifth tab is selected"); + is( + tab1.linkedBrowser.currentURI.spec, + REMOTE_URL, + "First tab has matching URL" + ); + is( + tab2.linkedBrowser.currentURI.spec, + ABOUT_ROBOTS_URL, + "Second tab has matching URL" + ); + is( + tab3.linkedBrowser.currentURI.spec, + NO_TITLE_URL, + "Third tab has matching URL" + ); + // Clean up for the next task. + await promiseBrowserState(BACKUP_STATE); +}); + +/** + * After session restore, if we crash an unpinned tab, we noticed pinned tabs + * created in the same process would lose all data (Bug 1624511). This test + * checks that case. + */ +add_task(async function test_pinned_tab_dataloss() { + // We do not run if there are no crash reporters to avoid + // problems with the intentional crash. + if (!AppConstants.MOZ_CRASHREPORTER) { + return; + } + // If we end up increasing the process count limit in future, + // we want to ensure that we don't stop testing this case + // of pinned tab data loss. + if (SpecialPowers.getIntPref("dom.ipc.processCount") > 8) { + ok( + false, + "Process count is greater than 8, update the number of pinned tabs in test." + ); + } + + // We expect 17 pinned tabs plus the selected tab get content restored. + // Given that the default process count is currently 8, we need this + // number of pinned tabs to reproduce the data loss. If this changes, + // please add more pinned tabs. + let allTabsRestored = promiseSessionStoreLoads(18); + await promiseBrowserState({ + windows: [ + { + selected: 18, // SessionStore uses 1-based indexing. + tabs: [ + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }] }, + ], + }, + ], + }); + await allTabsRestored; + + let tabs = gBrowser.tabs; + await BrowserTestUtils.crashFrame(tabs[17].linkedBrowser); + + await TestUtils.topicObserved("sessionstore-state-write-complete"); + + for (let i = 0; i < tabs.length; i++) { + let tab = tabs[i]; + is( + tab.linkedBrowser.currentURI.spec, + REMOTE_URL, + `Tab ${i + 1} should have matching URL` + ); + } + + // Clean up for the next task. + await promiseBrowserState(BACKUP_STATE); +}); diff --git a/browser/components/sessionstore/test/browser_privatetabs.js b/browser/components/sessionstore/test/browser_privatetabs.js new file mode 100644 index 0000000000..237d9ff036 --- /dev/null +++ b/browser/components/sessionstore/test/browser_privatetabs.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(function cleanup() { + info("Forgetting closed tabs"); + forgetClosedTabs(window); +}); + +add_task(async function test_restore_pbm() { + // Clear the list of closed windows. + forgetClosedWindows(); + + // Create a new window to attach our frame script to. + let win = await promiseNewWindowLoaded({ private: true }); + + // Create a new tab in the new window that will load the frame script. + let tab = BrowserTestUtils.addTab(win.gBrowser, "about:mozilla"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Check that we consider the tab as private. + let state = JSON.parse(ss.getTabState(tab)); + ok(state.isPrivate, "tab considered private"); + + // Ensure that closed tabs in a private windows can be restored. + await SessionStoreTestUtils.closeTab(tab); + is(ss.getClosedTabCountForWindow(win), 1, "there is a single tab to restore"); + + // Ensure that closed private windows can never be restored. + await BrowserTestUtils.closeWindow(win); + is(ss.getClosedWindowCount(), 0, "no windows to restore"); +}); + +/** + * Tests the purgeDataForPrivateWindow SessionStore method. + */ +add_task(async function test_purge_pbm() { + info("Clear the list of closed windows."); + forgetClosedWindows(); + + info("Create a new window to attach our frame script to."); + let win = await promiseNewWindowLoaded({ private: true }); + + info("Create a new tab in the new window that will load the frame script."); + let tab = BrowserTestUtils.addTab(win.gBrowser, "about:mozilla"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + info("Ensure that closed tabs in a private windows can be restored."); + await SessionStoreTestUtils.closeTab(tab); + is(ss.getClosedTabCountForWindow(win), 1, "there is a single tab to restore"); + + info("Call purgeDataForPrivateWindow"); + ss.purgeDataForPrivateWindow(win); + + is(ss.getClosedTabCountForWindow(win), 0, "there is no tab to restore"); + + // Cleanup + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/sessionstore/test/browser_purge_shistory.js b/browser/components/sessionstore/test/browser_purge_shistory.js new file mode 100644 index 0000000000..2078bb46ed --- /dev/null +++ b/browser/components/sessionstore/test/browser_purge_shistory.js @@ -0,0 +1,65 @@ +"use strict"; + +/** + * This test checks that pending tabs are treated like fully loaded tabs when + * purging session history. Just like for fully loaded tabs we want to remove + * every but the current shistory entry. + */ + +const TAB_STATE = { + entries: [ + { url: "about:mozilla", triggeringPrincipal_base64 }, + { url: "about:robots", triggeringPrincipal_base64 }, + ], + index: 1, +}; + +function checkTabContents(browser) { + return SpecialPowers.spawn(browser, [], async function () { + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + let history = webNavigation.sessionHistory; + Assert.ok( + history && + history.count == 1 && + content.document.documentURI == "about:mozilla", + "expected tab contents found" + ); + }); +} + +add_task(async function () { + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + await promiseTabState(tab, TAB_STATE); + + // Create another new tab. + let tab2 = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser2 = tab2.linkedBrowser; + await promiseBrowserLoaded(browser2); + + // The tab shouldn't be restored right away. + Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", true); + + // Prepare the tab state. + let promise = promiseTabRestoring(tab2); + ss.setTabState(tab2, JSON.stringify(TAB_STATE)); + ok(tab2.hasAttribute("pending"), "tab is pending"); + await promise; + + // Purge session history. + Services.obs.notifyObservers(null, "browser:purge-session-history"); + await checkTabContents(browser); + ok(tab2.hasAttribute("pending"), "tab is still pending"); + + // Kick off tab restoration. + gBrowser.selectedTab = tab2; + await promiseTabRestored(tab2); + await checkTabContents(browser2); + ok(!tab2.hasAttribute("pending"), "tab is not pending anymore"); + + // Cleanup. + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_remoteness_flip_on_restore.js b/browser/components/sessionstore/test/browser_remoteness_flip_on_restore.js new file mode 100644 index 0000000000..8674664ede --- /dev/null +++ b/browser/components/sessionstore/test/browser_remoteness_flip_on_restore.js @@ -0,0 +1,310 @@ +"use strict"; + +/** + * This set of tests checks that the remoteness is properly + * set for each browser in a window when that window has + * session state loaded into it. + */ + +/** + * Takes a SessionStore window state object for a single + * window, sets the selected tab for it, and then returns + * the object to be passed to SessionStore.setWindowState. + * + * @param state (object) + * The state to prepare to be sent to a window. This is + * state should just be for a single window. + * @param selected (int) + * The 1-based index of the selected tab. Note that + * If this is 0, then the selected tab will not change + * from what's already selected in the window that we're + * sending state to. + * @returns (object) + * The JSON encoded string to call + * SessionStore.setWindowState with. + */ +function prepareState(state, selected) { + // We'll create a copy so that we don't accidentally + // modify the caller's selected property. + let copy = {}; + Object.assign(copy, state); + copy.selected = selected; + + return { + windows: [copy], + }; +} + +const SIMPLE_STATE = { + tabs: [ + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + }, + ], + title: "", + _closedTabs: [], +}; + +const PINNED_STATE = { + tabs: [ + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + pinned: true, + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + pinned: true, + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + }, + ], + title: "", + _closedTabs: [], +}; + +/** + * This is where most of the action is happening. This function takes + * an Array of "test scenario" Objects and runs them. For each scenario, a + * window is opened, put into some state, and then a new state is + * loaded into that window. We then check to make sure that the + * right things have happened in that window wrt remoteness flips. + * + * The schema for a testing scenario Object is as follows: + * + * initialRemoteness: + * an Array that represents the starting window. Each bool + * in the Array represents the window tabs in order. A "true" + * indicates that that tab should be remote. "false" if the tab + * should be non-remote. + * + * initialSelectedTab: + * The 1-based index of the tab that we want to select for the + * restored window. This is 1-based to avoid confusion with the + * selectedTab property described down below, though you probably + * want to set this to be greater than 0, since the initial window + * needs to have a defined initial selected tab. Because of this, + * the test will throw if initialSelectedTab is 0. + * + * stateToRestore: + * A JS Object for the state to send down to the window. + * + * selectedTab: + * The 1-based index of the tab that we want to select for the + * restored window. Leave this at 0 if you don't want to change + * the selection from the initial window state. + * + * expectedRemoteness: + * an Array that represents the window that we end up with after + * restoring state. Each bool in the Array represents the window + * tabs in order. A "true" indicates that the tab be remote, and + * a "false" indicates that the tab should be "non-remote". We + * need this Array in order to test pinned tabs which will also + * be loaded by default, and therefore should end up remote. + * + */ +async function runScenarios(scenarios) { + for (let [scenarioIndex, scenario] of scenarios.entries()) { + info("Running scenario " + scenarioIndex); + Assert.ok( + scenario.initialSelectedTab > 0, + "You must define an initially selected tab" + ); + + // First, we need to create the initial conditions, so we + // open a new window to put into our starting state... + let win = await BrowserTestUtils.openNewBrowserWindow(); + let tabbrowser = win.gBrowser; + Assert.ok( + tabbrowser.selectedBrowser.isRemoteBrowser, + "The initial browser should be remote." + ); + // Now put the window into the expected initial state. + for (let i = 0; i < scenario.initialRemoteness.length; ++i) { + let tab; + if (i > 0) { + // The window starts with one tab, so we need to create + // any of the additional ones required by this test. + info("Opening a new tab"); + tab = await BrowserTestUtils.openNewForegroundTab(tabbrowser); + } else { + info("Using the selected tab"); + tab = tabbrowser.selectedTab; + } + let browser = tab.linkedBrowser; + let remotenessState = scenario.initialRemoteness[i] + ? E10SUtils.DEFAULT_REMOTE_TYPE + : E10SUtils.NOT_REMOTE; + tabbrowser.updateBrowserRemoteness(browser, { + remoteType: remotenessState, + }); + } + + // And select the requested tab. + let tabToSelect = tabbrowser.tabs[scenario.initialSelectedTab - 1]; + if (tabbrowser.selectedTab != tabToSelect) { + await BrowserTestUtils.switchTab(tabbrowser, tabToSelect); + } + + // Okay, time to test! + let state = prepareState(scenario.stateToRestore, scenario.selectedTab); + + await setWindowState(win, state, true); + + for (let i = 0; i < scenario.expectedRemoteness.length; ++i) { + let expectedRemoteness = scenario.expectedRemoteness[i]; + let tab = tabbrowser.tabs[i]; + + Assert.equal( + tab.linkedBrowser.isRemoteBrowser, + expectedRemoteness, + "Should have gotten the expected remoteness " + + `for the tab at index ${i}` + ); + } + + await BrowserTestUtils.closeWindow(win); + } +} + +/** + * Tests that if we restore state to browser windows with + * a variety of initial remoteness states. For this particular + * set of tests, we assume that tabs are restoring on demand. + */ +add_task(async function () { + // This test opens and closes windows, which might bog down + // a debug build long enough to time out the test, so we + // extend the tolerance on timeouts. + requestLongerTimeout(5); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.restore_on_demand", true]], + }); + + const TEST_SCENARIOS = [ + // Only one tab in the new window, and it's remote. This + // is the common case, since this is how restoration occurs + // when the restored window is being opened. + { + initialRemoteness: [true], + initialSelectedTab: 1, + stateToRestore: SIMPLE_STATE, + selectedTab: 3, + // All tabs should now be remote. + expectedRemoteness: [true, true, true], + }, + + // A single remote tab, and this is the one that's going + // to be selected once state is restored. + { + initialRemoteness: [true], + initialSelectedTab: 1, + stateToRestore: SIMPLE_STATE, + selectedTab: 1, + // All tabs should now be remote. + expectedRemoteness: [true, true, true], + }, + + // A single remote tab which starts selected. We set the + // selectedTab to 0 which is equivalent to "don't change + // the tab selection in the window". + { + initialRemoteness: [true], + initialSelectedTab: 1, + stateToRestore: SIMPLE_STATE, + selectedTab: 0, + // All tabs should now be remote. + expectedRemoteness: [true, true, true], + }, + + // An initially remote tab, but we're going to load + // some pinned tabs now, and the pinned tabs should load + // right away. + { + initialRemoteness: [true], + initialSelectedTab: 1, + stateToRestore: PINNED_STATE, + selectedTab: 3, + // Both pinned tabs and the selected tabs should all + // end up being remote. + expectedRemoteness: [true, true, true], + }, + + // A single non-remote tab. + { + initialRemoteness: [false], + initialSelectedTab: 1, + stateToRestore: SIMPLE_STATE, + selectedTab: 2, + // All tabs should now be remote. + expectedRemoteness: [true, true, true], + }, + + // A mixture of remote and non-remote tabs. + { + initialRemoteness: [true, false, true], + initialSelectedTab: 1, + stateToRestore: SIMPLE_STATE, + selectedTab: 3, + // All tabs should now be remote. + expectedRemoteness: [true, true, true], + }, + + // An initially non-remote tab, but we're going to load + // some pinned tabs now, and the pinned tabs should load + // right away. + { + initialRemoteness: [false], + initialSelectedTab: 1, + stateToRestore: PINNED_STATE, + selectedTab: 3, + // All tabs should now be remote. + expectedRemoteness: [true, true, true], + }, + ]; + + await runScenarios(TEST_SCENARIOS); +}); diff --git a/browser/components/sessionstore/test/browser_reopen_all_windows.js b/browser/components/sessionstore/test/browser_reopen_all_windows.js new file mode 100644 index 0000000000..532e689f50 --- /dev/null +++ b/browser/components/sessionstore/test/browser_reopen_all_windows.js @@ -0,0 +1,146 @@ +"use strict"; + +const PATH = "browser/browser/components/sessionstore/test/empty.html"; +var URLS_WIN1 = [ + "https://example.com/" + PATH, + "https://example.org/" + PATH, + "http://test1.mochi.test:8888/" + PATH, + "http://test1.example.com/" + PATH, +]; +var EXPECTED_URLS_WIN1 = ["about:blank", ...URLS_WIN1]; + +var URLS_WIN2 = [ + "http://sub1.test1.mochi.test:8888/" + PATH, + "http://sub2.xn--lt-uia.mochi.test:8888/" + PATH, + "http://test2.mochi.test:8888/" + PATH, + "http://sub1.test2.example.org/" + PATH, + "http://sub2.test1.example.org/" + PATH, + "http://test2.example.com/" + PATH, +]; +var EXPECTED_URLS_WIN2 = ["about:blank", ...URLS_WIN2]; + +requestLongerTimeout(4); + +function allTabsRestored(win, expectedUrls) { + return new Promise(resolve => { + let tabsRestored = 0; + function handler(event) { + let spec = event.target.linkedBrowser.currentURI.spec; + if (expectedUrls.includes(spec)) { + tabsRestored++; + } + info(`Got SSTabRestored for ${spec}, tabsRestored=${tabsRestored}`); + if (tabsRestored === expectedUrls.length) { + win.gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + handler, + true + ); + resolve(); + } + } + win.gBrowser.tabContainer.addEventListener("SSTabRestored", handler, true); + }); +} + +async function windowAndTabsRestored(win, expectedUrls) { + await TestUtils.topicObserved( + "browser-window-before-show", + subject => subject === win + ); + return allTabsRestored(win, expectedUrls); +} + +add_task(async () => { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set the pref to true so we know exactly how many tabs should be restoring at + // any given time. This guarantees that a finishing load won't start another. + ["browser.sessionstore.restore_on_demand", false], + ], + }); + + forgetClosedWindows(); + is(SessionStore.getClosedWindowCount(), 0, "starting with no closed windows"); + + // Open window 1, with different tabs + let win1 = await BrowserTestUtils.openNewBrowserWindow(); + for (let url of URLS_WIN1) { + await BrowserTestUtils.openNewForegroundTab(win1.gBrowser, url); + } + + // Open window 2, with different tabs + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + for (let url of URLS_WIN2) { + await BrowserTestUtils.openNewForegroundTab(win2.gBrowser, url); + } + + await TabStateFlusher.flushWindow(win1); + await TabStateFlusher.flushWindow(win2); + + // Close both windows + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); + await forceSaveState(); + + // Verify both windows were accounted for by session store + is( + ss.getClosedWindowCount(), + 2, + "The closed windows was added to Recently Closed Windows" + ); + + // We previously used to manually navigate the Library menu to click the + // "Reopen all Windows" button, but that reopens all windows at once without + // returning a reference to each window. Since we need to attach listeners to + // these windows *before* they start restoring tabs, we now manually call + // undoCloseWindow() here, which has the same effect, but also gives us the + // window references. + info("Reopening windows"); + let restoredWindows = []; + while (SessionStore.getClosedWindowCount() > 0) { + restoredWindows.unshift(undoCloseWindow()); + } + is(restoredWindows.length, 2, "Reopened correct number of windows"); + + let win1Restored = windowAndTabsRestored( + restoredWindows[0], + EXPECTED_URLS_WIN1 + ); + let win2Restored = windowAndTabsRestored( + restoredWindows[1], + EXPECTED_URLS_WIN2 + ); + + info("About to wait for tabs to be restored"); + await Promise.all([win1Restored, win2Restored]); + + is( + restoredWindows[0].gBrowser.tabs.length, + EXPECTED_URLS_WIN1.length, + "All tabs restored" + ); + is( + restoredWindows[1].gBrowser.tabs.length, + EXPECTED_URLS_WIN2.length, + "All tabs restored" + ); + + // Verify that tabs opened as expected + Assert.deepEqual( + restoredWindows[0].gBrowser.tabs.map( + tab => tab.linkedBrowser.currentURI.spec + ), + EXPECTED_URLS_WIN1 + ); + Assert.deepEqual( + restoredWindows[1].gBrowser.tabs.map( + tab => tab.linkedBrowser.currentURI.spec + ), + EXPECTED_URLS_WIN2 + ); + + info("About to close windows"); + await BrowserTestUtils.closeWindow(restoredWindows[0]); + await BrowserTestUtils.closeWindow(restoredWindows[1]); +}); diff --git a/browser/components/sessionstore/test/browser_replace_load.js b/browser/components/sessionstore/test/browser_replace_load.js new file mode 100644 index 0000000000..21bec044a9 --- /dev/null +++ b/browser/components/sessionstore/test/browser_replace_load.js @@ -0,0 +1,56 @@ +"use strict"; + +const STATE = { + entries: [{ url: "about:robots" }, { url: "about:mozilla" }], + selected: 2, +}; + +/** + * Bug 1100223. Calling browser.loadURI() while a tab is loading causes + * sessionstore to override the desired target URL. This test ensures that + * calling loadURI() on a pending tab causes the tab to no longer be marked + * as pending and correctly finish the instructed load while keeping the + * restored history around. + */ +add_task(async function () { + await testSwitchToTab("about:mozilla#fooobar", { + ignoreFragment: "whenComparingAndReplace", + }); + await testSwitchToTab("about:mozilla?foo=bar", { replaceQueryString: true }); +}); + +var testSwitchToTab = async function (url, options) { + // Create a background tab. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // The tab shouldn't be restored right away. + Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", true); + + // Prepare the tab state. + let promise = promiseTabRestoring(tab); + ss.setTabState(tab, JSON.stringify(STATE)); + ok(tab.hasAttribute("pending"), "tab is pending"); + await promise; + + options.triggeringPrincipal = + Services.scriptSecurityManager.getSystemPrincipal(); + + // Switch-to-tab with a similar URI. + switchToTabHavingURI(url, false, options); + + // Tab should now restore + await promiseTabRestored(tab); + is(browser.currentURI.spec, url, "correct URL loaded"); + + // Check that we didn't lose any history entries. + await SpecialPowers.spawn(browser, [], async function () { + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + let history = webNavigation.sessionHistory; + Assert.equal(history && history.count, 3, "three history entries"); + }); + + // Cleanup. + gBrowser.removeTab(tab); +}; diff --git a/browser/components/sessionstore/test/browser_restoreLastActionCorrectOrder.js b/browser/components/sessionstore/test/browser_restoreLastActionCorrectOrder.js new file mode 100644 index 0000000000..967dd7d7e7 --- /dev/null +++ b/browser/components/sessionstore/test/browser_restoreLastActionCorrectOrder.js @@ -0,0 +1,101 @@ +"use strict"; + +const { _LastSession, _lastClosedActions } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionStore.sys.mjs" +); + +/** + * Tests that the _lastClosedAction list is truncated correctly + * by removing oldest actions in SessionStore._addClosedAction + */ +add_task(async function test_undo_last_action_correct_order() { + SpecialPowers.pushPrefEnv({ + set: [ + ["browser.sessionstore.max_tabs_undo", 3], + ["browser.sessionstore.max_windows_undo", 1], + ], + }); + + gBrowser.removeAllTabsBut(gBrowser.tabs[0]); + await TabStateFlusher.flushWindow(window); + + forgetClosedTabs(window); + + const state = { + windows: [ + { + tabs: [ + { + entries: [ + { + title: "example.org", + url: "https://example.org/", + triggeringPrincipal_base64, + }, + ], + }, + { + entries: [ + { + title: "example.com", + url: "https://example.com/", + triggeringPrincipal_base64, + }, + ], + }, + { + entries: [ + { + title: "mozilla", + url: "https://www.mozilla.org/", + triggeringPrincipal_base64, + }, + ], + }, + { + entries: [ + { + title: "mozilla privacy policy", + url: "https://www.mozilla.org/privacy", + triggeringPrincipal_base64, + }, + ], + }, + ], + selected: 3, + }, + ], + }; + + _LastSession.setState(state); + SessionStore.resetLastClosedActions(); + + let sessionRestored = promiseSessionStoreLoads(4 /* total restored tabs */); + restoreLastClosedTabOrWindowOrSession(); + await sessionRestored; + + Assert.equal(window.gBrowser.tabs.length, 4, "4 tabs have been restored"); + + BrowserTestUtils.removeTab(window.gBrowser.tabs[3]); + BrowserTestUtils.removeTab(window.gBrowser.tabs[2]); + Assert.equal(window.gBrowser.tabs.length, 2, "Window has one open tab"); + + // open and close a window + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + Assert.equal(win2.gBrowser.tabs.length, 1, "Second window has one open tab"); + BrowserTestUtils.startLoadingURIString( + win2.gBrowser.selectedBrowser, + "https://example.com/" + ); + await BrowserTestUtils.browserLoaded(win2.gBrowser.selectedBrowser); + await BrowserTestUtils.closeWindow(win2); + + // close one tab and reopen it + BrowserTestUtils.removeTab(window.gBrowser.tabs[1]); + Assert.equal(window.gBrowser.tabs.length, 1, "Window has one open tabs"); + restoreLastClosedTabOrWindowOrSession(); + Assert.equal(window.gBrowser.tabs.length, 2, "Window now has two open tabs"); + + await SpecialPowers.popPrefEnv(); + gBrowser.removeAllTabsBut(gBrowser.tabs[0]); +}); diff --git a/browser/components/sessionstore/test/browser_restoreLastClosedTabOrWindowOrSession.js b/browser/components/sessionstore/test/browser_restoreLastClosedTabOrWindowOrSession.js new file mode 100644 index 0000000000..cc340c4617 --- /dev/null +++ b/browser/components/sessionstore/test/browser_restoreLastClosedTabOrWindowOrSession.js @@ -0,0 +1,284 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { _LastSession, _lastClosedActions } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionStore.sys.mjs" +); + +async function testLastClosedActionsEntries() { + SessionStore.resetLastClosedActions(); + + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + BrowserTestUtils.startLoadingURIString( + win2.gBrowser.selectedBrowser, + "https://www.mozilla.org/" + ); + + await BrowserTestUtils.browserLoaded(win2.gBrowser.selectedBrowser); + await openAndCloseTab(win2, "https://example.org/"); + + Assert.equal( + SessionStore.lastClosedActions.length, + 1, + `1 closed action has been recorded` + ); + + await BrowserTestUtils.closeWindow(win2); + + Assert.equal( + SessionStore.lastClosedActions.length, + 2, + `2 closed actions have been recorded` + ); +} + +add_setup(() => { + forgetClosedTabs(window); + registerCleanupFunction(() => { + forgetClosedTabs(window); + }); + + // needed for verify tests so that forgetting tabs isn't recorded + SessionStore.resetLastClosedActions(); +}); + +/** + * Tests that if the user invokes restoreLastClosedTabOrWindowOrSession it will + * result in either the last session will be restored, if possible, the last + * tab (or multiple selected tabs) that was closed is reopened, or the last + * window that is closed is reopened. + */ +add_task(async function test_undo_last_action() { + const state = { + windows: [ + { + tabs: [ + { + entries: [ + { + title: "example.org", + url: "https://example.org/", + triggeringPrincipal_base64, + }, + ], + }, + { + entries: [ + { + title: "example.com", + url: "https://example.com/", + triggeringPrincipal_base64, + }, + ], + }, + { + entries: [ + { + title: "mozilla", + url: "https://www.mozilla.org/", + triggeringPrincipal_base64, + }, + ], + }, + ], + selected: 3, + }, + ], + }; + + _LastSession.setState(state); + + let sessionRestored = promiseSessionStoreLoads(3 /* total restored tabs */); + restoreLastClosedTabOrWindowOrSession(); + await sessionRestored; + Assert.equal( + window.gBrowser.tabs.length, + 3, + "Window has three tabs after session is restored" + ); + + // open and close a window, then reopen it + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + Assert.equal(win2.gBrowser.tabs.length, 1, "Second window has one open tab"); + BrowserTestUtils.startLoadingURIString( + win2.gBrowser.selectedBrowser, + "https://example.com/" + ); + await BrowserTestUtils.browserLoaded(win2.gBrowser.selectedBrowser); + await BrowserTestUtils.closeWindow(win2); + let restoredWinPromise = BrowserTestUtils.waitForNewWindow({ + url: "https://example.com/", + }); + restoreLastClosedTabOrWindowOrSession(); + let restoredWin = await restoredWinPromise; + Assert.equal( + restoredWin.gBrowser.tabs.length, + 1, + "First tab in the second window has been reopened" + ); + await BrowserTestUtils.closeWindow(restoredWin); + SessionStore.forgetClosedWindow(restoredWin.index); + restoreLastClosedTabOrWindowOrSession(); + + // close one tab and reopen it + BrowserTestUtils.removeTab(window.gBrowser.tabs[2]); + Assert.equal(window.gBrowser.tabs.length, 2, "Window has two open tabs"); + restoreLastClosedTabOrWindowOrSession(); + Assert.equal( + window.gBrowser.tabs.length, + 3, + "Window now has three open tabs" + ); + + // select 2 tabs and close both via the 'close 2 tabs' context menu option + let tab2 = window.gBrowser.tabs[1]; + let tab3 = window.gBrowser.tabs[2]; + await triggerClickOn(tab2, { ctrlKey: true }); + Assert.equal(tab2.multiselected, true); + Assert.equal(tab3.multiselected, true); + + let menu = await openTabMenuFor(tab3); + let menuItemCloseTab = document.getElementById("context_closeTab"); + let tab2Closing = BrowserTestUtils.waitForTabClosing(tab2); + let tab3Closing = BrowserTestUtils.waitForTabClosing(tab3); + menu.activateItem(menuItemCloseTab); + await tab2Closing; + await tab3Closing; + Assert.equal(window.gBrowser.tabs[0].selected, true); + await TestUtils.waitForCondition(() => window.gBrowser.tabs.length == 1); + Assert.equal( + window.gBrowser.tabs.length, + 1, + "Window now has one open tab after closing two multi-selected tabs" + ); + + // ensure both tabs are reopened with a single click + restoreLastClosedTabOrWindowOrSession(); + Assert.equal( + window.gBrowser.tabs.length, + 3, + "Window now has three open tabs after reopening closed tabs" + ); + + // close one tab and forget it - it should not be reopened + BrowserTestUtils.removeTab(window.gBrowser.tabs[2]); + Assert.equal(window.gBrowser.tabs.length, 2, "Window has two open tabs"); + SessionStore.forgetClosedTab(window, 0); + restoreLastClosedTabOrWindowOrSession(); + Assert.equal( + window.gBrowser.tabs.length, + 2, + "Window still has two open tabs" + ); + + gBrowser.removeAllTabsBut(gBrowser.tabs[0]); +}); + +add_task(async function test_forget_closed_window() { + await testLastClosedActionsEntries(); + SessionStore.forgetClosedWindow(); + + // both the forgotten window and its closed tab has been removed from the list + Assert.ok( + !SessionStore.lastClosedActions.length, + `0 closed actions have been recorded` + ); +}); + +add_task(async function test_user_clears_history() { + await testLastClosedActionsEntries(); + Services.obs.notifyObservers(null, "browser:purge-session-history"); + + // both the forgotten window and its closed tab has been removed from the list + Assert.ok( + !SessionStore.lastClosedActions.length, + `0 closed actions have been recorded` + ); +}); + +/** + * It the browser has restarted and the closed actions list is empty, we + * should fallback to re-opening the last closed tab if one exists. + */ +add_task(async function test_reopen_last_tab_if_no_closed_actions() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:blank", + }, + async browser => { + const TEST_URL = "https://example.com/"; + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + let update = BrowserTestUtils.waitForSessionStoreUpdate(tab); + BrowserTestUtils.removeTab(tab); + await update; + + SessionStore.resetLastClosedActions(); + + let promiseTab = BrowserTestUtils.waitForNewTab(gBrowser, TEST_URL); + restoreLastClosedTabOrWindowOrSession(); + let newTab = await promiseTab; + Assert.equal(newTab.linkedBrowser.currentURI.spec, TEST_URL); + } + ); +}); + +/** + * It the browser has restarted and the closed actions list is empty, and + * no closed tabs exist for the window, we should fallback to re-opening + * the last session if one exists. + */ +add_task(async function test_reopen_last_session_if_no_closed_actions() { + gBrowser.removeAllTabsBut(gBrowser.tabs[0]); + await TabStateFlusher.flushWindow(window); + + forgetClosedTabs(window); + + const state = { + windows: [ + { + tabs: [ + { + entries: [ + { + title: "example.org", + url: "https://example.org/", + triggeringPrincipal_base64, + }, + ], + }, + { + entries: [ + { + title: "example.com", + url: "https://example.com/", + triggeringPrincipal_base64, + }, + ], + }, + { + entries: [ + { + title: "mozilla", + url: "https://www.mozilla.org/", + triggeringPrincipal_base64, + }, + ], + }, + ], + selected: 3, + }, + ], + }; + + _LastSession.setState(state); + SessionStore.resetLastClosedActions(); + + let sessionRestored = promiseSessionStoreLoads(3 /* total restored tabs */); + restoreLastClosedTabOrWindowOrSession(); + await sessionRestored; + Assert.equal(gBrowser.tabs.length, 4, "Got the expected number of tabs"); + gBrowser.removeAllTabsBut(gBrowser.tabs[0]); +}); diff --git a/browser/components/sessionstore/test/browser_restoreTabContainer.js b/browser/components/sessionstore/test/browser_restoreTabContainer.js new file mode 100644 index 0000000000..a38dca386e --- /dev/null +++ b/browser/components/sessionstore/test/browser_restoreTabContainer.js @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +add_task(async function () { + const testUserContextId = 2; + const testCases = [ + { + url: `${TEST_PATH}empty.html`, + crossOriginIsolated: false, + }, + { + url: `${TEST_PATH}coop_coep.html`, + crossOriginIsolated: true, + }, + ]; + + for (const testCase of testCases) { + let tab = BrowserTestUtils.addTab(gBrowser, testCase.url, { + userContextId: testUserContextId, + }); + await promiseBrowserLoaded(tab.linkedBrowser); + + is( + tab.userContextId, + testUserContextId, + `The tab was opened with the expected userContextId` + ); + + await SpecialPowers.spawn( + tab.linkedBrowser, + [testCase.crossOriginIsolated], + async expectedCrossOriginIsolated => { + is( + content.window.crossOriginIsolated, + expectedCrossOriginIsolated, + `The tab was opened in the expected crossOriginIsolated environment` + ); + } + ); + + let sessionPromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + BrowserTestUtils.removeTab(tab); + await sessionPromise; + + let restoredTab = SessionStore.undoCloseTab(window, 0); + + // TODO: also check that `promiseTabRestored` is fulfilled. This currently + // doesn't happen correctly in some cases, as the content restore is aborted + // when the process switch occurs to load a cross-origin-isolated document + // into a different process. + await promiseBrowserLoaded(restoredTab.linkedBrowser); + + is( + restoredTab.userContextId, + testUserContextId, + `The tab was restored with the expected userContextId` + ); + + await SpecialPowers.spawn( + restoredTab.linkedBrowser, + [testCase.crossOriginIsolated], + async expectedCrossOriginIsolated => { + is( + content.window.crossOriginIsolated, + expectedCrossOriginIsolated, + `The tab was restored in the expected crossOriginIsolated environment` + ); + } + ); + + BrowserTestUtils.removeTab(restoredTab); + } +}); diff --git a/browser/components/sessionstore/test/browser_restore_container_tabs_oa.js b/browser/components/sessionstore/test/browser_restore_container_tabs_oa.js new file mode 100644 index 0000000000..74edcef6e7 --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_container_tabs_oa.js @@ -0,0 +1,249 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { UrlbarProviderOpenTabs } = ChromeUtils.importESModule( + "resource:///modules/UrlbarProviderOpenTabs.sys.mjs" +); + +const PATH = "browser/browser/components/sessionstore/test/empty.html"; + +/* import-globals-from ../../../base/content/test/tabs/helper_origin_attrs_testing.js */ +loadTestSubscript( + "../../../base/content/test/tabs/helper_origin_attrs_testing.js" +); + +var TEST_CASES = [ + "https://example.com/" + PATH, + "https://example.org/" + PATH, + "about:preferences", + "about:config", +]; + +var remoteTypes; + +var xulFrameLoaderCreatedCounter = {}; + +function handleEventLocal(aEvent) { + if (aEvent.type != "XULFrameLoaderCreated") { + return; + } + // Ignore element in about:preferences and any other special pages + if ("gBrowser" in aEvent.target.ownerGlobal) { + xulFrameLoaderCreatedCounter.numCalledSoFar++; + } +} + +var NUM_DIFF_TAB_MODES = NUM_USER_CONTEXTS + 1; /** regular tab */ + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set the pref to true so we know exactly how many tabs should be restoring at + // any given time. This guarantees that a finishing load won't start another. + ["browser.sessionstore.restore_on_demand", true], + // Don't restore tabs lazily. + ["browser.sessionstore.restore_tabs_lazily", true], + // don't preload tabs so we don't have extra XULFrameLoaderCreated events + // firing + ["browser.newtab.preload", false], + ], + }); + + requestLongerTimeout(7); +}); + +function setupRemoteTypes() { + if (gFissionBrowser) { + remoteTypes = [ + "webIsolated=https://example.com", + "webIsolated=https://example.com^userContextId=1", + "webIsolated=https://example.com^userContextId=2", + "webIsolated=https://example.com^userContextId=3", + "webIsolated=https://example.org", + "webIsolated=https://example.org^userContextId=1", + "webIsolated=https://example.org^userContextId=2", + "webIsolated=https://example.org^userContextId=3", + ]; + } else { + remoteTypes = Array( + NUM_DIFF_TAB_MODES * 2 /** 2 is the number of non parent uris */ + ).fill("web"); + } + remoteTypes.push(...Array(NUM_DIFF_TAB_MODES * 2).fill(null)); // remote types for about: pages + + forgetClosedWindows(); + is(SessionStore.getClosedWindowCount(), 0, "starting with no closed windows"); +} +/* + * 1. Open several tabs in different containers and in regular tabs + [page1, page2, page3] [ [(page1 - work) (page1 - home)] [(page2 - work) (page2 - home)] ] + * 2. Close the window + * 3. Restore session, window will have the following tabs + * [initial blank page, page1, page1-work, page1-home, page2, page2-work, page2-home] + * 4. Verify correct remote types and that XULFrameLoaderCreated gets fired correct number of times + */ +add_task(async function testRestore() { + setupRemoteTypes(); + let newWin = await promiseNewWindowLoaded(); + var regularPages = []; + var containerPages = {}; + // Go through all the test cases and open same set of urls in regular tabs and in container tabs + for (const uri of TEST_CASES) { + // Open a url in a regular tab + let regularPage = await openURIInRegularTab(uri, newWin); + regularPages.push(regularPage); + + // Open the same url in different user contexts + for ( + var user_context_id = 1; + user_context_id <= NUM_USER_CONTEXTS; + user_context_id++ + ) { + let containerPage = await openURIInContainer( + uri, + newWin, + user_context_id + ); + containerPages[uri] = containerPage; + } + } + await TabStateFlusher.flushWindow(newWin); + + // Close the window + await BrowserTestUtils.closeWindow(newWin); + await forceSaveState(); + + is( + SessionStore.getClosedWindowCount(), + 1, + "Should have restore data for the closed window" + ); + + Assert.equal( + 0, + (await UrlbarProviderOpenTabs.getDatabaseRegisteredOpenTabsForTests()) + .length, + "No registered open pages should be left" + ); + + // Now restore the window + newWin = SessionStore.undoCloseWindow(0); + + // Make sure to wait for the window to be restored. + await Promise.all([ + BrowserTestUtils.waitForEvent(newWin, "SSWindowStateReady"), + ]); + await BrowserTestUtils.waitForEvent( + newWin.gBrowser.tabContainer, + "SSTabRestored" + ); + + var nonblank_pages_len = + TEST_CASES.length + NUM_USER_CONTEXTS * TEST_CASES.length; + is( + newWin.gBrowser.tabs.length, + nonblank_pages_len + 1 /* initial page */, + "Correct number of tabs restored" + ); + + // Now we have pages opened in the following manner + // [blank page, page1, page1-work, page1-home, page2, page2-work, page2-home] + + info(`Number of tabs restored: ${newWin.gBrowser.tabs.length}`); + var currRemoteType, expectedRemoteType; + let loaded; + for (var tab_idx = 1; tab_idx < nonblank_pages_len; ) { + info(`Accessing regular tab at index ${tab_idx}`); + var test_page_data = regularPages.shift(); + let regular_tab = newWin.gBrowser.tabs[tab_idx]; + let regular_browser = regular_tab.linkedBrowser; + + // I would have used browserLoaded but for about:config it doesn't work + let ready = BrowserTestUtils.waitForCondition(async () => { + // Catch an error because the browser might change remoteness in between + // calls, so we will just wait for the document to finish loadig. + return SpecialPowers.spawn(regular_browser, [], () => { + return content.document.readyState == "complete"; + }).catch(console.error); + }); + newWin.gBrowser.selectedTab = regular_tab; + await TabStateFlusher.flush(regular_browser); + await ready; + + currRemoteType = regular_browser.remoteType; + expectedRemoteType = remoteTypes.shift(); + is( + currRemoteType, + expectedRemoteType, + `correct remote type for regular tab with uri ${test_page_data.uri}` + ); + + let page_uri = regular_browser.currentURI.spec; + info(`Current uri = ${page_uri}`); + + // Iterate over container pages, starting after the regular page and ending before the next regular page + var userContextId = 1; + for ( + var container_tab_idx = tab_idx + 1; + container_tab_idx < tab_idx + 1 + NUM_USER_CONTEXTS; + container_tab_idx++, userContextId++ + ) { + info(`Accessing container tab at index ${container_tab_idx}`); + let container_tab = newWin.gBrowser.tabs[container_tab_idx]; + + initXulFrameLoaderCreatedCounter(xulFrameLoaderCreatedCounter); + container_tab.ownerGlobal.gBrowser.addEventListener( + "XULFrameLoaderCreated", + handleEventLocal + ); + + loaded = BrowserTestUtils.browserLoaded( + container_tab.linkedBrowser, + false, + test_page_data.uri + ); + + newWin.gBrowser.selectedTab = container_tab; + await TabStateFlusher.flush(container_tab.linkedBrowser); + await loaded; + let uri = container_tab.linkedBrowser.currentURI.spec; + + // Verify XULFrameLoaderCreated was fired once + is( + xulFrameLoaderCreatedCounter.numCalledSoFar, + 1, + `XULFrameLoaderCreated was fired once, when restoring ${uri} in container ${userContextId} ` + ); + container_tab.ownerGlobal.gBrowser.removeEventListener( + "XULFrameLoaderCreated", + handleEventLocal + ); + + // Verify correct remote type for container tab + currRemoteType = container_tab.linkedBrowser.remoteType; + expectedRemoteType = remoteTypes.shift(); + info( + `Remote type for container tab ${userContextId} is ${currRemoteType}` + ); + is( + currRemoteType, + expectedRemoteType, + "correct remote type for container tab" + ); + } + // Advance to the next regular page in our tabs list + tab_idx = container_tab_idx; + } + + await BrowserTestUtils.closeWindow(newWin); + + Assert.equal( + 0, + (await UrlbarProviderOpenTabs.getDatabaseRegisteredOpenTabsForTests()) + .length, + "No registered open pages should be left" + ); +}); diff --git a/browser/components/sessionstore/test/browser_restore_cookies_noOriginAttributes.js b/browser/components/sessionstore/test/browser_restore_cookies_noOriginAttributes.js new file mode 100644 index 0000000000..bdfdf7fbe3 --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_cookies_noOriginAttributes.js @@ -0,0 +1,191 @@ +/* + * Bug 1267910 - The regression test case for session cookies. + */ + +"use strict"; + +const TEST_HOST = "www.example.com"; +const COOKIE = { + name: "test1", + value: "yes1", + path: "/browser/browser/components/sessionstore/test/", +}; +const SESSION_DATA = JSON.stringify({ + version: ["sessionrestore", 1], + windows: [ + { + tabs: [ + { + entries: [], + lastAccessed: 1463893009797, + hidden: false, + attributes: {}, + image: null, + }, + { + entries: [ + { + url: "http://www.example.com/browser/browser/components/sessionstore/test/browser_1267910_page.html", + triggeringPrincipal_base64, + charset: "UTF-8", + ID: 0, + docshellID: 2, + originalURI: + "http://www.example.com/browser/browser/components/sessionstore/test/browser_1267910_page.html", + docIdentifier: 0, + persist: true, + }, + ], + lastAccessed: 1463893009321, + hidden: false, + attributes: {}, + userContextId: 0, + index: 1, + image: "http://www.example.com/favicon.ico", + }, + ], + selected: 1, + _closedTabs: [], + busy: false, + width: 1024, + height: 768, + screenX: 4, + screenY: 23, + sizemode: "normal", + cookies: [ + { + host: "www.example.com", + value: "yes1", + path: "/browser/browser/components/sessionstore/test/", + name: "test1", + }, + ], + }, + ], + selectedWindow: 1, + _closedWindows: [], + session: { + lastUpdate: 1463893009801, + startTime: 1463893007134, + recentCrashes: 0, + }, + global: {}, +}); + +const SESSION_DATA_OA = JSON.stringify({ + version: ["sessionrestore", 1], + windows: [ + { + tabs: [ + { + entries: [], + lastAccessed: 1463893009797, + hidden: false, + attributes: {}, + image: null, + }, + { + entries: [ + { + url: "http://www.example.com/browser/browser/components/sessionstore/test/browser_1267910_page.html", + triggeringPrincipal_base64, + charset: "UTF-8", + ID: 0, + docshellID: 2, + originalURI: + "http://www.example.com/browser/browser/components/sessionstore/test/browser_1267910_page.html", + docIdentifier: 0, + persist: true, + }, + ], + lastAccessed: 1463893009321, + hidden: false, + attributes: {}, + userContextId: 0, + index: 1, + image: "http://www.example.com/favicon.ico", + }, + ], + selected: 1, + _closedTabs: [], + busy: false, + width: 1024, + height: 768, + screenX: 4, + screenY: 23, + sizemode: "normal", + cookies: [ + { + host: "www.example.com", + value: "yes1", + path: "/browser/browser/components/sessionstore/test/", + name: "test1", + originAttributes: { + addonId: "", + inIsolatedMozBrowser: false, + userContextId: 0, + }, + }, + ], + }, + ], + selectedWindow: 1, + _closedWindows: [], + session: { + lastUpdate: 1463893009801, + startTime: 1463893007134, + recentCrashes: 0, + }, + global: {}, +}); + +add_task(async function run_test() { + // Wait until initialization is complete. + await SessionStore.promiseInitialized; + + // Clear cookies. + Services.cookies.removeAll(); + + // Open a new window. + let win = await promiseNewWindowLoaded(); + + // Restore window with session cookies that have no originAttributes. + await setWindowState(win, SESSION_DATA, true); + + let cookieCount = 0; + for (var cookie of Services.cookies.getCookiesFromHost(TEST_HOST, {})) { + cookieCount++; + } + + // Check that the cookie is restored successfully. + is(cookieCount, 1, "expected one cookie"); + is(cookie.name, COOKIE.name, "cookie name successfully restored"); + is(cookie.value, COOKIE.value, "cookie value successfully restored"); + is(cookie.path, COOKIE.path, "cookie path successfully restored"); + + // Clear cookies. + Services.cookies.removeAll(); + + // In real usage, the event loop would get to spin between setWindowState + // uses. Without a spin, we can defer handling the STATE_STOP that + // removes the progress listener until after the mozbrowser has been + // destroyed, causing a window leak. + await new Promise(resolve => win.setTimeout(resolve, 0)); + + // Restore window with session cookies that have originAttributes within. + await setWindowState(win, SESSION_DATA_OA, true); + + cookieCount = 0; + for (cookie of Services.cookies.getCookiesFromHost(TEST_HOST, {})) { + cookieCount++; + } + + // Check that the cookie is restored successfully. + is(cookieCount, 1, "expected one cookie"); + is(cookie.name, COOKIE.name, "cookie name successfully restored"); + is(cookie.value, COOKIE.value, "cookie value successfully restored"); + is(cookie.path, COOKIE.path, "cookie path successfully restored"); + + // Close our window. + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/sessionstore/test/browser_restore_pageProxyState.js b/browser/components/sessionstore/test/browser_restore_pageProxyState.js new file mode 100644 index 0000000000..f98237c7e8 --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_pageProxyState.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const BACKUP_STATE = SessionStore.getBrowserState(); +registerCleanupFunction(() => promiseBrowserState(BACKUP_STATE)); + +// The pageproxystate of the restored tab controls whether the identity +// information in the URL bar will display correctly. See bug 1766951 for more +// context. +async function test_pageProxyState(url1, url2) { + info(`urls: "${url1}", "${url2}"`); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.sessionstore.restore_on_demand", true], + ["browser.sessionstore.restore_tabs_lazily", true], + ], + }); + + await promiseBrowserState({ + windows: [ + { + tabs: [ + { + entries: [ + { + url: url1, + triggeringPrincipal_base64, + }, + ], + }, + { + entries: [ + { + url: url2, + triggeringPrincipal_base64, + }, + ], + }, + ], + selected: 1, + }, + ], + }); + + // The first tab isn't lazy and should be initialized. + ok(gBrowser.tabs[0].linkedPanel, "first tab is not lazy"); + is(gBrowser.selectedTab, gBrowser.tabs[0], "first tab is selected"); + is(gBrowser.userTypedValue, null, "no user typed value"); + is( + gURLBar.getAttribute("pageproxystate"), + "valid", + "has valid page proxy state" + ); + + // The second tab is lazy until selected. + ok(!gBrowser.tabs[1].linkedPanel, "second tab should be lazy"); + gBrowser.selectedTab = gBrowser.tabs[1]; + await promiseTabRestored(gBrowser.tabs[1]); + is(gBrowser.userTypedValue, null, "no user typed value"); + is( + gURLBar.getAttribute("pageproxystate"), + "valid", + "has valid page proxy state" + ); +} + +add_task(async function test_system() { + await test_pageProxyState("about:support", "about:addons"); +}); + +add_task(async function test_http() { + await test_pageProxyState( + "https://example.com/document-builder.sjs?html=tab1", + "https://example.com/document-builder.sjs?html=tab2" + ); +}); diff --git a/browser/components/sessionstore/test/browser_restore_private_tab_os.js b/browser/components/sessionstore/test/browser_restore_private_tab_os.js new file mode 100644 index 0000000000..3041fe6f39 --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_private_tab_os.js @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const TEST_URI = + "https://example.com/" + + "browser/browser/components/sessionstore/test/empty.html"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set the pref to true so we know exactly how many tabs should be restoring at + // any given time. This guarantees that a finishing load won't start another. + ["browser.sessionstore.restore_on_demand", true], + // Don't restore tabs lazily. + ["browser.sessionstore.restore_tabs_lazily", true], + // don't preload tabs so we don't have extra XULFrameLoaderCreated events + // firing + ["browser.newtab.preload", false], + ], + }); +}); + +add_task(async function testRestore() { + // Clear the list of closed windows. + forgetClosedWindows(); + + // Create a new private window + let win = await promiseNewWindowLoaded({ private: true }); + + // Create a new private tab + let tab = BrowserTestUtils.addTab(win.gBrowser, TEST_URI); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + await TabStateFlusher.flush(browser); + + // Ensure that closed tabs in a private windows can be restored. + win.gBrowser.removeTab(tab); + is(ss.getClosedTabCountForWindow(win), 1, "there is a single tab to restore"); + + tab = SessionStore.undoCloseTab(win, 0); + info(`Undo close tab`); + browser = tab.linkedBrowser; + await promiseTabRestored(tab); + info(`Private tab restored`); + + let expectedRemoteType = gFissionBrowser + ? "webIsolated=https://example.com^privateBrowsingId=1" + : "web"; + is(browser.remoteType, expectedRemoteType, "correct remote type"); + + await BrowserTestUtils.closeWindow(win); + + // Cleanup + info("Forgetting closed tabs"); + forgetClosedTabs(window); +}); diff --git a/browser/components/sessionstore/test/browser_restore_redirect.js b/browser/components/sessionstore/test/browser_restore_redirect.js new file mode 100644 index 0000000000..206b783191 --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_redirect.js @@ -0,0 +1,72 @@ +"use strict"; + +const BASE = "http://example.com/browser/browser/components/sessionstore/test/"; +const TARGET = BASE + "restore_redirect_target.html"; + +/** + * Ensure that a http redirect leaves a working tab. + */ +add_task(async function check_http_redirect() { + let state = { + entries: [ + { url: BASE + "restore_redirect_http.html", triggeringPrincipal_base64 }, + ], + }; + + // Open a new tab to restore into. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseTabState(tab, state); + + info("Restored tab"); + + await TabStateFlusher.flush(browser); + let data = TabState.collect(tab); + is(data.entries.length, 1, "Should be one entry in session history"); + is(data.entries[0].url, TARGET, "Should be the right session history entry"); + + ok( + !ss.getInternalObjectState(browser), + "Temporary restore data should have been cleared" + ); + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); + +/** + * Ensure that a js redirect leaves a working tab. + */ +add_task(async function check_js_redirect() { + let state = { + entries: [ + { url: BASE + "restore_redirect_js.html", triggeringPrincipal_base64 }, + ], + }; + + // Open a new tab to restore into. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + let loadPromise = BrowserTestUtils.browserLoaded(browser, true, url => + url.endsWith("restore_redirect_target.html") + ); + + await promiseTabState(tab, state); + + info("Restored tab"); + + await loadPromise; + + await TabStateFlusher.flush(browser); + let data = TabState.collect(tab); + is(data.entries.length, 1, "Should be one entry in session history"); + is(data.entries[0].url, TARGET, "Should be the right session history entry"); + + ok( + !ss.getInternalObjectState(browser), + "Temporary restore data should have been cleared" + ); + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_restore_reversed_z_order.js b/browser/components/sessionstore/test/browser_restore_reversed_z_order.js new file mode 100644 index 0000000000..69745148a4 --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_reversed_z_order.js @@ -0,0 +1,128 @@ +"use strict"; + +const PRIMARY_WINDOW = window; + +let gTestURLsMap = new Map([ + ["about:about", null], + ["about:license", null], + ["about:robots", null], + ["about:mozilla", null], +]); +let gBrowserState; + +add_setup(async function () { + let windows = []; + let count = 0; + for (let url of gTestURLsMap.keys()) { + let window = !count + ? PRIMARY_WINDOW + : await BrowserTestUtils.openNewBrowserWindow(); + let browserLoaded = BrowserTestUtils.browserLoaded( + window.gBrowser.selectedBrowser + ); + BrowserTestUtils.startLoadingURIString( + window.gBrowser.selectedBrowser, + url + ); + await browserLoaded; + // Capture the title. + gTestURLsMap.set(url, window.gBrowser.selectedTab.label); + // Minimize the before-last window, to have a different window feature added + // to the test. + if (count == gTestURLsMap.size - 1) { + let activated = BrowserTestUtils.waitForEvent( + windows[count - 1], + "activate" + ); + window.minimize(); + await activated; + } + windows.push(window); + ++count; + } + + // Wait until we get the lastest history from all windows. + await Promise.all(windows.map(window => TabStateFlusher.flushWindow(window))); + + gBrowserState = ss.getBrowserState(); + + await promiseAllButPrimaryWindowClosed(); +}); + +add_task(async function test_z_indices_are_saved_correctly() { + let state = JSON.parse(gBrowserState); + Assert.equal( + state.windows.length, + gTestURLsMap.size, + "Correct number of windows saved" + ); + + // Check if we saved state in correct order of creation. + let idx = 0; + for (let url of gTestURLsMap.keys()) { + Assert.equal( + state.windows[idx].tabs[0].entries[0].url, + url, + `Window #${idx} is stored in correct creation order` + ); + ++idx; + } + + // Check if we saved a valid zIndex (no null, no undefined or no 0). + for (let window of state.windows) { + Assert.ok(window.zIndex, "A valid zIndex is stored"); + } + + Assert.equal( + state.windows[0].zIndex, + 3, + "Window #1 should have the correct z-index" + ); + Assert.equal( + state.windows[1].zIndex, + 2, + "Window #2 should have correct z-index" + ); + Assert.equal( + state.windows[2].zIndex, + 1, + "Window #3 should be the topmost window" + ); + Assert.equal( + state.windows[3].zIndex, + 4, + "Minimized window should be the last window to restore" + ); +}); + +add_task(async function test_windows_are_restored_in_reversed_z_order() { + await promiseBrowserState(gBrowserState); + + let indexedTabLabels = [...gTestURLsMap.values()]; + let tabsRestoredLabels = BrowserWindowTracker.orderedWindows.map( + window => window.gBrowser.selectedTab.label + ); + + Assert.equal( + tabsRestoredLabels[0], + indexedTabLabels[2], + "First restored tab should be last used tab" + ); + Assert.equal( + tabsRestoredLabels[1], + indexedTabLabels[1], + "Second restored tab is correct" + ); + Assert.equal( + tabsRestoredLabels[2], + indexedTabLabels[0], + "Third restored tab is correct" + ); + Assert.equal( + tabsRestoredLabels[3], + indexedTabLabels[3], + "Last restored tab should be a minimized window" + ); + + await promiseAllButPrimaryWindowClosed(); +}); diff --git a/browser/components/sessionstore/test/browser_restore_srcdoc.js b/browser/components/sessionstore/test/browser_restore_srcdoc.js new file mode 100644 index 0000000000..9e670100ea --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_srcdoc.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function makeURL(srcdocValue) { + return `data:text/html;charset=utf-8," + + "clickme" + + "clickme"; + + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Check that we have a single shistory entry. + await TabStateFlusher.flush(browser); + let { entries } = JSON.parse(ss.getTabState(tab)); + is(entries.length, 1, "there is one shistory entry"); + is(entries[0].children.length, 1, "the entry has one child"); + + // Navigate the subframe. + await SpecialPowers.spawn(browser, [], async function () { + content.document.querySelector("#a1").click(); + }); + await promiseBrowserLoaded( + browser, + false /* wait for subframe load only */, + "http://example.com/1" + ); + + // Check shistory. + await TabStateFlusher.flush(browser); + ({ entries } = JSON.parse(ss.getTabState(tab))); + is(entries.length, 2, "there now are two shistory entries"); + is(entries[1].children.length, 1, "the second entry has one child"); + + // Go back in history. + let goneBack = promiseBrowserLoaded( + browser, + false /* wait for subframe load only */, + "http://example.com/" + ); + info("About to go back in history"); + browser.goBack(); + await goneBack; + + // Navigate the subframe again. + let eventPromise = BrowserTestUtils.waitForContentEvent( + browser, + "hashchange", + true + ); + await SpecialPowers.spawn(browser, [], async function () { + content.document.querySelector("#a2").click(); + }); + await eventPromise; + + // Check shistory. + await TabStateFlusher.flush(browser); + ({ entries } = JSON.parse(ss.getTabState(tab))); + is(entries.length, 2, "there now are two shistory entries"); + is(entries[1].children.length, 1, "the second entry has one child"); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +/** + * Ensure that navigating from an about page invalidates shistory. + */ +add_task(async function test_about_page_navigate() { + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Check that we have a single shistory entry. + await TabStateFlusher.flush(browser); + let { entries } = JSON.parse(ss.getTabState(tab)); + is(entries.length, 1, "there is one shistory entry"); + is(entries[0].url, "about:blank", "url is correct"); + + // Verify that the title is also recorded. + is(entries[0].title, "about:blank", "title is correct"); + + BrowserTestUtils.startLoadingURIString(browser, "about:robots"); + await promiseBrowserLoaded(browser); + + // Check that we have changed the history entry. + await TabStateFlusher.flush(browser); + ({ entries } = JSON.parse(ss.getTabState(tab))); + is(entries.length, 1, "there is one shistory entry"); + is(entries[0].url, "about:robots", "url is correct"); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +/** + * Ensure that history.pushState and history.replaceState invalidate shistory. + */ +add_task(async function test_pushstate_replacestate() { + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com/1"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Check that we have a single shistory entry. + await TabStateFlusher.flush(browser); + let { entries } = JSON.parse(ss.getTabState(tab)); + is(entries.length, 1, "there is one shistory entry"); + is(entries[0].url, "http://example.com/1", "url is correct"); + + await SpecialPowers.spawn(browser, [], async function () { + content.window.history.pushState({}, "", "test-entry/"); + }); + + // Check that we have added the history entry. + await TabStateFlusher.flush(browser); + ({ entries } = JSON.parse(ss.getTabState(tab))); + is(entries.length, 2, "there is another shistory entry"); + is(entries[1].url, "http://example.com/test-entry/", "url is correct"); + + await SpecialPowers.spawn(browser, [], async function () { + content.window.history.replaceState({}, "", "test-entry2/"); + }); + + // Check that we have modified the history entry. + await TabStateFlusher.flush(browser); + ({ entries } = JSON.parse(ss.getTabState(tab))); + is(entries.length, 2, "there is still two shistory entries"); + is( + entries[1].url, + "http://example.com/test-entry/test-entry2/", + "url is correct" + ); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +/** + * Ensure that slow loading subframes will invalidate shistory. + */ +add_task(async function test_slow_subframe_load() { + const SLOW_URL = + "http://mochi.test:8888/browser/browser/components/" + + "sessionstore/test/browser_sessionHistory_slow.sjs"; + + const URL = + "data:text/html;charset=utf-8," + + "" + + "" + + ""; + + // Add a new tab with a slow loading subframe + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + await TabStateFlusher.flush(browser); + let { entries } = JSON.parse(ss.getTabState(tab)); + + // Check the number of children. + is(entries.length, 1, "there is one root entry ..."); + is(entries[0].children.length, 1, "... with one child entries"); + + // Check URLs. + ok(entries[0].url.startsWith("data:text/html"), "correct root url"); + is(entries[0].children[0].url, SLOW_URL, "correct url for subframe"); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +/** + * Ensure that document wireframes can be persisted when they're enabled. + */ +add_task(async function test_wireframes() { + // Wireframes only works when Fission and SHIP are enabled. + if ( + !Services.appinfo.fissionAutostart || + !Services.appinfo.sessionHistoryInParent + ) { + ok(true, "Skipping test_wireframes when Fission or SHIP is not enabled."); + return; + } + + await SpecialPowers.pushPrefEnv({ + set: [["browser.history.collectWireframes", true]], + }); + + let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + await TabStateFlusher.flush(browser); + let { entries } = JSON.parse(ss.getTabState(tab)); + + // Check the number of children. + is(entries.length, 1, "there is one shistory entry"); + + // Check for the wireframe + ok(entries[0].wireframe, "A wireframe was captured and serialized."); + ok( + entries[0].wireframe.rects.length, + "Several wireframe rects were captured." + ); + + // Cleanup. + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_sessionHistory_slow.sjs b/browser/components/sessionstore/test/browser_sessionHistory_slow.sjs new file mode 100644 index 0000000000..abb1dee829 --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionHistory_slow.sjs @@ -0,0 +1,22 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const DELAY_MS = "2000"; + +let timer; + +function handleRequest(req, resp) { + resp.processAsync(); + resp.setHeader("Cache-Control", "no-cache", false); + resp.setHeader("Content-Type", "text/html;charset=utf-8", false); + + timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init( + () => { + resp.write("hi"); + resp.finish(); + }, + DELAY_MS, + Ci.nsITimer.TYPE_ONE_SHOT + ); +} diff --git a/browser/components/sessionstore/test/browser_sessionStorage.html b/browser/components/sessionstore/test/browser_sessionStorage.html new file mode 100644 index 0000000000..f3664ebc9b --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionStorage.html @@ -0,0 +1,28 @@ + + + + + browser_sessionStorage.html + + + + + + + diff --git a/browser/components/sessionstore/test/browser_sessionStorage.js b/browser/components/sessionstore/test/browser_sessionStorage.js new file mode 100644 index 0000000000..c1d6f898da --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionStorage.js @@ -0,0 +1,298 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const RAND = Math.random(); +const URL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_sessionStorage.html" + + "?" + + RAND; + +const HAS_FIRST_PARTY_DOMAIN = [ + Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN, +].includes(Services.prefs.getIntPref("network.cookie.cookieBehavior")); +const OUTER_ORIGIN = "http://mochi.test:8888"; +const FIRST_PARTY_DOMAIN = escape("(http,mochi.test)"); +const INNER_ORIGIN = HAS_FIRST_PARTY_DOMAIN + ? `http://example.com^partitionKey=${FIRST_PARTY_DOMAIN}` + : "http://example.com"; +const SECURE_INNER_ORIGIN = HAS_FIRST_PARTY_DOMAIN + ? `https://example.com^partitionKey=${FIRST_PARTY_DOMAIN}` + : "https://example.com"; + +const OUTER_VALUE = "outer-value-" + RAND; +const INNER_VALUE = "inner-value-" + RAND; + +/** + * This test ensures that setting, modifying and restoring sessionStorage data + * works as expected. + */ +add_task(async function session_storage() { + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Flush to make sure chrome received all data. + await TabStateFlusher.flush(browser); + + let { storage } = JSON.parse(ss.getTabState(tab)); + is( + storage[INNER_ORIGIN].test, + INNER_VALUE, + "sessionStorage data for example.com has been serialized correctly" + ); + is( + storage[OUTER_ORIGIN].test, + OUTER_VALUE, + "sessionStorage data for mochi.test has been serialized correctly" + ); + + // Ensure that modifying sessionStore values works for the inner frame only. + await modifySessionStorage(browser, { test: "modified1" }, { frameIndex: 0 }); + await TabStateFlusher.flush(browser); + + ({ storage } = JSON.parse(ss.getTabState(tab))); + is( + storage[INNER_ORIGIN].test, + "modified1", + "sessionStorage data for example.com has been serialized correctly" + ); + is( + storage[OUTER_ORIGIN].test, + OUTER_VALUE, + "sessionStorage data for mochi.test has been serialized correctly" + ); + + // Ensure that modifying sessionStore values works for both frames. + await modifySessionStorage(browser, { test: "modified" }); + await modifySessionStorage(browser, { test: "modified2" }, { frameIndex: 0 }); + await TabStateFlusher.flush(browser); + + ({ storage } = JSON.parse(ss.getTabState(tab))); + is( + storage[INNER_ORIGIN].test, + "modified2", + "sessionStorage data for example.com has been serialized correctly" + ); + is( + storage[OUTER_ORIGIN].test, + "modified", + "sessionStorage data for mochi.test has been serialized correctly" + ); + + // Test that duplicating a tab works. + let tab2 = gBrowser.duplicateTab(tab); + let browser2 = tab2.linkedBrowser; + await promiseTabRestored(tab2); + + // Flush to make sure chrome received all data. + await TabStateFlusher.flush(browser2); + + ({ storage } = JSON.parse(ss.getTabState(tab2))); + is( + storage[INNER_ORIGIN].test, + "modified2", + "sessionStorage data for example.com has been duplicated correctly" + ); + is( + storage[OUTER_ORIGIN].test, + "modified", + "sessionStorage data for mochi.test has been duplicated correctly" + ); + + // Ensure that the content script retains restored data + // (by e.g. duplicateTab) and sends it along with new data. + await modifySessionStorage(browser2, { test: "modified3" }); + await TabStateFlusher.flush(browser2); + + ({ storage } = JSON.parse(ss.getTabState(tab2))); + is( + storage[INNER_ORIGIN].test, + "modified2", + "sessionStorage data for example.com has been duplicated correctly" + ); + is( + storage[OUTER_ORIGIN].test, + "modified3", + "sessionStorage data for mochi.test has been duplicated correctly" + ); + + // Check that loading a new URL discards data. + BrowserTestUtils.startLoadingURIString(browser2, "http://mochi.test:8888/"); + await promiseBrowserLoaded(browser2); + await TabStateFlusher.flush(browser2); + + ({ storage } = JSON.parse(ss.getTabState(tab2))); + is( + storage[OUTER_ORIGIN].test, + "modified3", + "navigating retains correct storage data" + ); + + is( + storage[INNER_ORIGIN].test, + "modified2", + "sessionStorage data for example.com wasn't discarded after top-level same-site navigation" + ); + + // Test that clearing the data in the first tab works properly within + // the subframe + await modifySessionStorage(browser, {}, { frameIndex: 0 }); + await TabStateFlusher.flush(browser); + ({ storage } = JSON.parse(ss.getTabState(tab))); + is( + storage[INNER_ORIGIN], + undefined, + "sessionStorage data for example.com has been cleared correctly" + ); + + // Test that clearing the data in the first tab works properly within + // the top-level frame + await modifySessionStorage(browser, {}); + await TabStateFlusher.flush(browser); + ({ storage } = JSON.parse(ss.getTabState(tab))); + ok( + storage === null || storage === undefined, + "sessionStorage data for the entire tab has been cleared correctly" + ); + + // Clean up. + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); + +/** + * This test ensures that purging domain data also purges data from the + * sessionStorage data collected for tabs. + */ +add_task(async function purge_domain() { + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Purge data for "mochi.test". + await purgeDomainData(browser, "mochi.test"); + + // Flush to make sure chrome received all data. + await TabStateFlusher.flush(browser); + + let { storage } = JSON.parse(ss.getTabState(tab)); + ok( + !storage[OUTER_ORIGIN], + "sessionStorage data for mochi.test has been purged" + ); + is( + storage[INNER_ORIGIN].test, + INNER_VALUE, + "sessionStorage data for example.com has been preserved" + ); + + BrowserTestUtils.removeTab(tab); +}); + +/** + * This test ensures that collecting sessionStorage data respects the privacy + * levels as set by the user. + */ +add_task(async function respect_privacy_level() { + let tab = BrowserTestUtils.addTab(gBrowser, URL + "&secure"); + await promiseBrowserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + await promiseRemoveTabAndSessionState(tab); + + let [ + { + state: { storage }, + }, + ] = ss.getClosedTabDataForWindow(window); + is( + storage[OUTER_ORIGIN].test, + OUTER_VALUE, + "http sessionStorage data has been saved" + ); + is( + storage[SECURE_INNER_ORIGIN].test, + INNER_VALUE, + "https sessionStorage data has been saved" + ); + + // Disable saving data for encrypted sites. + Services.prefs.setIntPref("browser.sessionstore.privacy_level", 1); + + tab = BrowserTestUtils.addTab(gBrowser, URL + "&secure"); + await promiseBrowserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + await promiseRemoveTabAndSessionState(tab); + + [ + { + state: { storage }, + }, + ] = ss.getClosedTabDataForWindow(window); + is( + storage[OUTER_ORIGIN].test, + OUTER_VALUE, + "http sessionStorage data has been saved" + ); + ok( + !storage[SECURE_INNER_ORIGIN], + "https sessionStorage data has *not* been saved" + ); + + // Disable saving data for any site. + Services.prefs.setIntPref("browser.sessionstore.privacy_level", 2); + + // Check that duplicating a tab copies all private data. + tab = BrowserTestUtils.addTab(gBrowser, URL + "&secure"); + await promiseBrowserLoaded(tab.linkedBrowser); + let tab2 = gBrowser.duplicateTab(tab); + await promiseTabRestored(tab2); + await promiseRemoveTabAndSessionState(tab); + + // With privacy_level=2 the |tab| shouldn't have any sessionStorage data. + [ + { + state: { storage }, + }, + ] = ss.getClosedTabDataForWindow(window); + ok(!storage, "sessionStorage data has *not* been saved"); + + // Remove all closed tabs before continuing with the next test. + // As Date.now() isn't monotonic we might sometimes check + // the wrong closedTabData entry. + forgetClosedTabs(window); + + // Restore the default privacy level and close the duplicated tab. + Services.prefs.clearUserPref("browser.sessionstore.privacy_level"); + await promiseRemoveTabAndSessionState(tab2); + + // With privacy_level=0 the duplicated |tab2| should persist all data. + [ + { + state: { storage }, + }, + ] = ss.getClosedTabDataForWindow(window); + is( + storage[OUTER_ORIGIN].test, + OUTER_VALUE, + "http sessionStorage data has been saved" + ); + is( + storage[SECURE_INNER_ORIGIN].test, + INNER_VALUE, + "https sessionStorage data has been saved" + ); +}); + +function purgeDomainData(browser, domain) { + return new Promise(resolve => { + Services.clearData.deleteDataFromHost( + domain, + true, + Services.clearData.CLEAR_SESSION_HISTORY, + resolve + ); + }); +} diff --git a/browser/components/sessionstore/test/browser_sessionStorage_size.js b/browser/components/sessionstore/test/browser_sessionStorage_size.js new file mode 100644 index 0000000000..1045482817 --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionStorage_size.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const RAND = Math.random(); +const URL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_sessionStorage.html" + + "?" + + RAND; + +const OUTER_VALUE = "outer-value-" + RAND; + +// Lower the size limit for DOM Storage content. Check that DOM Storage +// is not updated, but that other things remain updated. +add_task(async function test_large_content() { + Services.prefs.setIntPref("browser.sessionstore.dom_storage_limit", 5); + + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Flush to make sure chrome received all data. + await TabStateFlusher.flush(browser); + + let state = JSON.parse(ss.getTabState(tab)); + info(JSON.stringify(state, null, "\t")); + Assert.equal(state.storage, null, "We have no storage for the tab"); + Assert.equal(state.entries[0].title, OUTER_VALUE); + BrowserTestUtils.removeTab(tab); + + Services.prefs.clearUserPref("browser.sessionstore.dom_storage_limit"); +}); diff --git a/browser/components/sessionstore/test/browser_sessionStoreContainer.js b/browser/components/sessionstore/test/browser_sessionStoreContainer.js new file mode 100644 index 0000000000..86833dea82 --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionStoreContainer.js @@ -0,0 +1,166 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function () { + for (let i = 0; i < 3; ++i) { + let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com/", { + userContextId: i, + }); + let browser = tab.linkedBrowser; + + await promiseBrowserLoaded(browser); + + let tab2 = gBrowser.duplicateTab(tab); + Assert.equal(tab2.getAttribute("usercontextid"), i); + let browser2 = tab2.linkedBrowser; + await promiseTabRestored(tab2); + + await SpecialPowers.spawn( + browser2, + [{ expectedId: i }], + async function (args) { + let loadContext = docShell.QueryInterface(Ci.nsILoadContext); + Assert.equal( + loadContext.originAttributes.userContextId, + args.expectedId, + "The docShell has the correct userContextId" + ); + } + ); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); + } +}); + +add_task(async function () { + let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com/", { + userContextId: 1, + }); + let browser = tab.linkedBrowser; + + await promiseBrowserLoaded(browser); + + gBrowser.selectedTab = tab; + + let tab2 = gBrowser.duplicateTab(tab); + let browser2 = tab2.linkedBrowser; + await promiseTabRestored(tab2); + + await SpecialPowers.spawn( + browser2, + [{ expectedId: 1 }], + async function (args) { + Assert.equal( + docShell.getOriginAttributes().userContextId, + args.expectedId, + "The docShell has the correct userContextId" + ); + } + ); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); + +add_task(async function () { + let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com/", { + userContextId: 1, + }); + let browser = tab.linkedBrowser; + + await promiseBrowserLoaded(browser); + + gBrowser.removeTab(tab); + + let tab2 = ss.undoCloseTab(window, 0); + Assert.equal(tab2.getAttribute("usercontextid"), 1); + await promiseTabRestored(tab2); + await SpecialPowers.spawn( + tab2.linkedBrowser, + [{ expectedId: 1 }], + async function (args) { + Assert.equal( + docShell.getOriginAttributes().userContextId, + args.expectedId, + "The docShell has the correct userContextId" + ); + } + ); + + BrowserTestUtils.removeTab(tab2); +}); + +// Opens "uri" in a new tab with the provided userContextId and focuses it. +// Returns the newly opened tab. +async function openTabInUserContext(userContextId) { + // Open the tab in the correct userContextId. + let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com", { + userContextId, + }); + + // Select tab and make sure its browser is focused. + gBrowser.selectedTab = tab; + tab.ownerGlobal.focus(); + + let browser = gBrowser.getBrowserForTab(tab); + await BrowserTestUtils.browserLoaded(browser); + return { tab, browser }; +} + +function waitForNewCookie() { + return new Promise(resolve => { + Services.obs.addObserver(function observer(subj, topic) { + let notification = subj.QueryInterface(Ci.nsICookieNotification); + if (notification.action == Ci.nsICookieNotification.COOKIE_ADDED) { + Services.obs.removeObserver(observer, topic); + resolve(); + } + }, "session-cookie-changed"); + }); +} + +add_task(async function test() { + const USER_CONTEXTS = ["default", "personal", "work"]; + + // Make sure userContext is enabled. + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + + Services.cookies.removeAll(); + + for (let userContextId of Object.keys(USER_CONTEXTS)) { + // Load the page in 3 different contexts and set a cookie + // which should only be visible in that context. + let cookie = USER_CONTEXTS[userContextId]; + + // Open our tab in the given user context. + let { tab, browser } = await openTabInUserContext(userContextId); + + await Promise.all([ + waitForNewCookie(), + SpecialPowers.spawn( + browser, + [cookie], + passedCookie => (content.document.cookie = passedCookie) + ), + ]); + + // Ensure the tab's session history is up-to-date. + await TabStateFlusher.flush(browser); + + // Remove the tab. + gBrowser.removeTab(tab); + } + + let state = JSON.parse(SessionStore.getBrowserState()); + is( + state.cookies.length, + USER_CONTEXTS.length, + "session restore should have each container's cookie" + ); +}); diff --git a/browser/components/sessionstore/test/browser_should_restore_tab.js b/browser/components/sessionstore/test/browser_should_restore_tab.js new file mode 100644 index 0000000000..ab9513083a --- /dev/null +++ b/browser/components/sessionstore/test/browser_should_restore_tab.js @@ -0,0 +1,139 @@ +const NOTIFY_CLOSED_OBJECTS_CHANGED = "sessionstore-closed-objects-changed"; + +add_setup(async () => { + registerCleanupFunction(async () => { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + }); +}); + +async function check_tab_close_notification(openedTab, expectNotification) { + let tabUrl = openedTab.linkedBrowser.currentURI.spec; + let win = openedTab.ownerGlobal; + let initialTabCount = SessionStore.getClosedTabCountForWindow(win); + + let tabClosed = BrowserTestUtils.waitForTabClosing(openedTab); + let notified = false; + function topicObserver(_, topic) { + notified = true; + } + Services.obs.addObserver(topicObserver, NOTIFY_CLOSED_OBJECTS_CHANGED); + + BrowserTestUtils.removeTab(openedTab); + await tabClosed; + // SessionStore does a setTimeout(notify, 0) to notifyObservers when it handles TabClose + // We need to wait long enough to be confident the observer would have been notified + // if it was going to be. + let ticks = 0; + await TestUtils.waitForCondition(() => { + return ++ticks > 1; + }); + + Services.obs.removeObserver(topicObserver, NOTIFY_CLOSED_OBJECTS_CHANGED); + if (expectNotification) { + Assert.ok( + notified, + `Expected ${NOTIFY_CLOSED_OBJECTS_CHANGED} when the ${tabUrl} tab closed` + ); + Assert.equal( + SessionStore.getClosedTabCountForWindow(win), + initialTabCount + 1, + "Expected closed tab count to have incremented" + ); + } else { + Assert.ok( + !notified, + `Expected no ${NOTIFY_CLOSED_OBJECTS_CHANGED} when the ${tabUrl} tab closed` + ); + Assert.equal( + SessionStore.getClosedTabCountForWindow(win), + initialTabCount, + "Expected closed tab count to have not changed" + ); + } +} + +add_task(async function test_control_case() { + let win = window; + let tabOpenedAndSwitchedTo = BrowserTestUtils.switchTab( + win.gBrowser, + () => {} + ); + let tab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "https://example.com/" + ); + await tabOpenedAndSwitchedTo; + await check_tab_close_notification(tab, true); +}); + +add_task(async function test_about_new_tab() { + let win = window; + let tabOpenedAndSwitchedTo = BrowserTestUtils.switchTab( + win.gBrowser, + () => {} + ); + // This opens about:newtab: + win.BrowserOpenTab(); + let tab = await tabOpenedAndSwitchedTo; + await check_tab_close_notification(tab, false); +}); + +add_task(async function test_about_home() { + let win = window; + let tabOpenedAndSwitchedTo = BrowserTestUtils.switchTab( + win.gBrowser, + () => {} + ); + let tab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "about:home" + ); + await tabOpenedAndSwitchedTo; + await check_tab_close_notification(tab, false); +}); + +add_task(async function test_navigated_about_home() { + let win = window; + let tabOpenedAndSwitchedTo = BrowserTestUtils.switchTab( + win.gBrowser, + () => {} + ); + let tab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "https://example.com/" + ); + await tabOpenedAndSwitchedTo; + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, "about:home"); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + // even if we end up on an ignorable URL, + // if there's meaningful history, we should save this tab + await check_tab_close_notification(tab, true); +}); + +add_task(async function test_about_blank() { + let win = window; + let tabOpenedAndSwitchedTo = BrowserTestUtils.switchTab( + win.gBrowser, + () => {} + ); + let tab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "about:blank" + ); + await tabOpenedAndSwitchedTo; + await check_tab_close_notification(tab, false); +}); + +add_task(async function test_about_privatebrowsing() { + let win = window; + let tabOpenedAndSwitchedTo = BrowserTestUtils.switchTab( + win.gBrowser, + () => {} + ); + let tab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "about:privatebrowsing" + ); + await tabOpenedAndSwitchedTo; + await check_tab_close_notification(tab, false); +}); diff --git a/browser/components/sessionstore/test/browser_sizemodeBeforeMinimized.js b/browser/components/sessionstore/test/browser_sizemodeBeforeMinimized.js new file mode 100644 index 0000000000..a832e71bcf --- /dev/null +++ b/browser/components/sessionstore/test/browser_sizemodeBeforeMinimized.js @@ -0,0 +1,44 @@ +add_task(async function test() { + // Test for bugfix 384278. Confirms that sizemodeBeforeMinimized is set properly when window state is saved. + let win = await BrowserTestUtils.openNewBrowserWindow(); + + async function changeSizeMode(mode) { + let promise = BrowserTestUtils.waitForEvent(win, "sizemodechange"); + win[mode](); + await promise; + } + + function checkCurrentState(sizemodeBeforeMinimized) { + let state = ss.getWindowState(win); + let winState = state.windows[0]; + is( + winState.sizemodeBeforeMinimized, + sizemodeBeforeMinimized, + "sizemodeBeforeMinimized should match" + ); + } + + // Note: Uses ss.getWindowState(win); as a more time efficient alternative to forceSaveState(); (causing timeouts). + // Simulates FF restart. + + if (win.windowState != win.STATE_NORMAL) { + await changeSizeMode("restore"); + } + ss.getWindowState(win); + await changeSizeMode("minimize"); + checkCurrentState("normal"); + + // Need to create new window or test will timeout on linux. + await BrowserTestUtils.closeWindow(win); + win = await BrowserTestUtils.openNewBrowserWindow(); + + if (win.windowState != win.STATE_MAXIMIZED) { + await changeSizeMode("maximize"); + } + ss.getWindowState(win); + await changeSizeMode("minimize"); + checkCurrentState("maximized"); + + // Clean up. + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/sessionstore/test/browser_speculative_connect.html b/browser/components/sessionstore/test/browser_speculative_connect.html new file mode 100644 index 0000000000..a0fb88e0a6 --- /dev/null +++ b/browser/components/sessionstore/test/browser_speculative_connect.html @@ -0,0 +1,8 @@ + +
    + Dummy html page to test speculative connect +
    + + Hello Speculative Connect + + diff --git a/browser/components/sessionstore/test/browser_speculative_connect.js b/browser/components/sessionstore/test/browser_speculative_connect.js new file mode 100644 index 0000000000..bece5e7baa --- /dev/null +++ b/browser/components/sessionstore/test/browser_speculative_connect.js @@ -0,0 +1,145 @@ +const TEST_URLS = [ + "about:buildconfig", + "http://mochi.test:8888/browser/browser/components/sessionstore/test/browser_speculative_connect.html", + "", +]; + +/** + * This will open tabs in browser. This will also make the last tab + * inserted to be the selected tab. + */ +async function openTabs(win) { + for (let i = 0; i < TEST_URLS.length; ++i) { + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, TEST_URLS[i]); + } +} + +add_task(async function speculative_connect_restore_on_demand() { + Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", true); + is( + Services.prefs.getBoolPref("browser.sessionstore.restore_on_demand"), + true, + "We're restoring on demand" + ); + forgetClosedWindows(); + + // Open a new window and populate with tabs. + let win = await promiseNewWindowLoaded(); + await openTabs(win); + + // Close the window. + await BrowserTestUtils.closeWindow(win); + + // Reopen a window. + let newWin = undoCloseWindow(0); + // Make sure we wait until this window is restored. + await promiseWindowRestored(newWin); + + let tabs = newWin.gBrowser.tabs; + is(tabs.length, TEST_URLS.length + 1, "Restored right number of tabs"); + + let e = new MouseEvent("mouseover"); + + // First tab should be ignored, since it's the default blank tab when we open a new window. + + // Trigger a mouse enter on second tab. + tabs[1].dispatchEvent(e); + ok( + !tabs[1].__test_connection_prepared, + "Second tab doesn't have a connection prepared" + ); + is(tabs[1].__test_connection_url, TEST_URLS[0], "Second tab has correct url"); + ok( + SessionStore.getLazyTabValue(tabs[1], "connectionPrepared"), + "Second tab should have connectionPrepared flag after hovered" + ); + + // Trigger a mouse enter on third tab. + tabs[2].dispatchEvent(e); + ok(tabs[2].__test_connection_prepared, "Third tab has a connection prepared"); + is(tabs[2].__test_connection_url, TEST_URLS[1], "Third tab has correct url"); + ok( + SessionStore.getLazyTabValue(tabs[2], "connectionPrepared"), + "Third tab should have connectionPrepared flag after hovered" + ); + + // Last tab is the previously selected tab. + tabs[3].dispatchEvent(e); + is( + SessionStore.getLazyTabValue(tabs[3], "connectionPrepared"), + undefined, + "Previous selected tab shouldn't have connectionPrepared flag" + ); + is( + tabs[3].__test_connection_prepared, + undefined, + "Previous selected tab should not have a connection prepared" + ); + is( + tabs[3].__test_connection_url, + undefined, + "Previous selected tab should not have a connection prepared" + ); + + await BrowserTestUtils.closeWindow(newWin); +}); + +add_task(async function speculative_connect_restore_automatically() { + Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", false); + is( + Services.prefs.getBoolPref("browser.sessionstore.restore_on_demand"), + false, + "We're restoring automatically" + ); + forgetClosedWindows(); + + // Open a new window and populate with tabs. + let win = await promiseNewWindowLoaded(); + await openTabs(win); + + // Close the window. + await BrowserTestUtils.closeWindow(win); + + // Reopen a window. + let newWin = undoCloseWindow(0); + // Make sure we wait until this window is restored. + await promiseWindowRestored(newWin); + + let tabs = newWin.gBrowser.tabs; + is(tabs.length, TEST_URLS.length + 1, "Restored right number of tabs"); + + // First tab is ignored, since it's the default tab open when we open new window + + // Second tab. + ok( + !tabs[1].__test_connection_prepared, + "Second tab doesn't have a connection prepared" + ); + is( + tabs[1].__test_connection_url, + TEST_URLS[0], + "Second tab has correct host url" + ); + + // Third tab. + ok(tabs[2].__test_connection_prepared, "Third tab has a connection prepared"); + is( + tabs[2].__test_connection_url, + TEST_URLS[1], + "Third tab has correct host url" + ); + + // Last tab is the previously selected tab. + is( + tabs[3].__test_connection_prepared, + undefined, + "Selected tab should not have a connection prepared" + ); + is( + tabs[3].__test_connection_url, + undefined, + "Selected tab should not have a connection prepared" + ); + + await BrowserTestUtils.closeWindow(newWin); +}); diff --git a/browser/components/sessionstore/test/browser_swapDocShells.js b/browser/components/sessionstore/test/browser_swapDocShells.js new file mode 100644 index 0000000000..047a36c510 --- /dev/null +++ b/browser/components/sessionstore/test/browser_swapDocShells.js @@ -0,0 +1,40 @@ +"use strict"; + +add_task(async function () { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:mozilla" + )); + await promiseBrowserLoaded(gBrowser.selectedBrowser); + + let win = gBrowser.replaceTabWithWindow(tab); + await promiseDelayedStartupFinished(win); + await promiseBrowserHasURL(win.gBrowser.browsers[0], "about:mozilla"); + + win.duplicateTabIn(win.gBrowser.selectedTab, "tab"); + await promiseTabRestored(win.gBrowser.tabs[1]); + + let browser = win.gBrowser.browsers[1]; + is(browser.currentURI.spec, "about:mozilla", "tab was duplicated"); + + await BrowserTestUtils.closeWindow(win); +}); + +function promiseDelayedStartupFinished(win) { + return new Promise(resolve => { + whenDelayedStartupFinished(win, resolve); + }); +} + +function promiseBrowserHasURL(browser, url) { + let promise = Promise.resolve(); + + if ( + browser.contentDocument.readyState === "complete" && + browser.currentURI.spec === url + ) { + return promise; + } + + return promise.then(() => promiseBrowserHasURL(browser, url)); +} diff --git a/browser/components/sessionstore/test/browser_switch_remoteness.js b/browser/components/sessionstore/test/browser_switch_remoteness.js new file mode 100644 index 0000000000..927162d830 --- /dev/null +++ b/browser/components/sessionstore/test/browser_switch_remoteness.js @@ -0,0 +1,57 @@ +"use strict"; + +const URL = "http://example.com/browser_switch_remoteness_"; + +function countHistoryEntries(browser, expected) { + return SpecialPowers.spawn(browser, [{ expected }], async function (args) { + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + let history = webNavigation.sessionHistory; + Assert.equal( + history && history.count, + args.expected, + "correct number of shistory entries" + ); + }); +} + +add_task(async function () { + // Open a new window. + let win = await promiseNewWindowLoaded(); + + // Add a new tab. + let tab = BrowserTestUtils.addTab(win.gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + ok(browser.isRemoteBrowser, "browser is remote"); + + // Get the maximum number of preceding entries to save. + const MAX_BACK = Services.prefs.getIntPref( + "browser.sessionstore.max_serialize_back" + ); + Assert.greater( + MAX_BACK, + -1, + "check that the default has a value that caps data" + ); + + // Load more pages than we would save to disk on a clean shutdown. + for (let i = 0; i < MAX_BACK + 2; i++) { + BrowserTestUtils.startLoadingURIString(browser, URL + i); + await promiseBrowserLoaded(browser); + ok(browser.isRemoteBrowser, "browser is still remote"); + } + + // Check we have the right number of shistory entries. + await countHistoryEntries(browser, MAX_BACK + 2); + + // Load a non-remote page. + BrowserTestUtils.startLoadingURIString(browser, "about:robots"); + await promiseBrowserLoaded(browser); + ok(!browser.isRemoteBrowser, "browser is not remote anymore"); + + // Check that we didn't lose any shistory entries. + await countHistoryEntries(browser, MAX_BACK + 3); + + // Cleanup. + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/sessionstore/test/browser_tab_label_during_restore.js b/browser/components/sessionstore/test/browser_tab_label_during_restore.js new file mode 100644 index 0000000000..7108990818 --- /dev/null +++ b/browser/components/sessionstore/test/browser_tab_label_during_restore.js @@ -0,0 +1,182 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test that we don't do unnecessary tab label changes while restoring a tab. + */ + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.sessionstore.restore_on_demand", true], + ["browser.sessionstore.restore_tabs_lazily", true], + ], + }); + + const BACKUP_STATE = SessionStore.getBrowserState(); + const REMOTE_URL = "http://www.example.com/"; + const ABOUT_ROBOTS_URI = "about:robots"; + const ABOUT_ROBOTS_TITLE = "Gort! Klaatu barada nikto!"; + const NO_TITLE_URL = "data:text/plain,foo"; + const EMPTY_TAB_TITLE = gBrowser.tabContainer.emptyTabTitle; + + function observeLabelChanges(tab, expectedLabels) { + let seenLabels = [tab.label]; + function TabAttrModifiedListener(event) { + if (event.detail.changed.some(attr => attr == "label")) { + seenLabels.push(tab.label); + } + } + tab.addEventListener("TabAttrModified", TabAttrModifiedListener); + return async () => { + await BrowserTestUtils.waitForCondition( + () => seenLabels.length == expectedLabels.length, + "saw " + seenLabels.length + " TabAttrModified events" + ); + tab.removeEventListener("TabAttrModified", TabAttrModifiedListener); + is( + JSON.stringify(seenLabels), + JSON.stringify(expectedLabels || []), + "observed tab label changes" + ); + }; + } + + info("setting test browser state"); + let browserLoadedPromise = BrowserTestUtils.firstBrowserLoaded(window, false); + await promiseBrowserState({ + windows: [ + { + tabs: [ + { entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }] }, + { entries: [{ url: ABOUT_ROBOTS_URI, triggeringPrincipal_base64 }] }, + { entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }] }, + { entries: [{ url: NO_TITLE_URL, triggeringPrincipal_base64 }] }, + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + ], + }, + ], + }); + let [tab1, tab2, tab3, tab4, tab5] = gBrowser.tabs; + is(gBrowser.selectedTab, tab1, "first tab is selected"); + + await browserLoadedPromise; + const REMOTE_TITLE = tab1.linkedBrowser.contentTitle; + is( + tab1.linkedBrowser.currentURI.spec, + REMOTE_URL, + "correct URL loaded in first tab" + ); + is(typeof REMOTE_TITLE, "string", "content title is a string"); + isnot(REMOTE_TITLE.length, 0, "content title isn't empty"); + isnot(REMOTE_TITLE, REMOTE_URL, "content title is different from the URL"); + is(tab1.label, REMOTE_TITLE, "first tab displays content title"); + ok( + document.title.startsWith(REMOTE_TITLE), + "title bar displays content title" + ); + ok(tab2.hasAttribute("pending"), "second tab is pending"); + ok(tab3.hasAttribute("pending"), "third tab is pending"); + ok(tab4.hasAttribute("pending"), "fourth tab is pending"); + is(tab5.label, EMPTY_TAB_TITLE, "fifth tab dislpays empty tab title"); + + info("selecting the second tab"); + // The fix for bug 1364127 caused about: pages' initial tab titles to show + // their about: URIs until their actual page titles are known, e.g. + // "about:addons" -> "Add-ons Manager". This is bug 1371896. Previously, + // about: pages' initial tab titles were blank until the page title was known. + let finishObservingLabelChanges = observeLabelChanges(tab2, [ + ABOUT_ROBOTS_URI, + ABOUT_ROBOTS_TITLE, + ]); + browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab2.linkedBrowser, + false, + ABOUT_ROBOTS_URI + ); + gBrowser.selectedTab = tab2; + await browserLoadedPromise; + ok(!tab2.hasAttribute("pending"), "second tab isn't pending anymore"); + await finishObservingLabelChanges(); + ok( + document.title.startsWith(ABOUT_ROBOTS_TITLE), + "title bar displays content title" + ); + + info("selecting the third tab"); + finishObservingLabelChanges = observeLabelChanges(tab3, [ + "example.com/", + REMOTE_TITLE, + ]); + browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab3.linkedBrowser, + false, + REMOTE_URL + ); + gBrowser.selectedTab = tab3; + await browserLoadedPromise; + ok(!tab3.hasAttribute("pending"), "third tab isn't pending anymore"); + await finishObservingLabelChanges(); + ok( + document.title.startsWith(REMOTE_TITLE), + "title bar displays content title" + ); + + info("selecting the fourth tab"); + finishObservingLabelChanges = observeLabelChanges(tab4, [NO_TITLE_URL]); + browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab4.linkedBrowser, + false, + NO_TITLE_URL + ); + gBrowser.selectedTab = tab4; + await browserLoadedPromise; + ok(!tab4.hasAttribute("pending"), "fourth tab isn't pending anymore"); + await finishObservingLabelChanges(); + is( + document.title, + document.getElementById("bundle_brand").getString("brandFullName"), + "title bar doesn't display content title since page doesn't have one" + ); + + info("restoring the modified browser state"); + gBrowser.selectedTab = tab3; + await TabStateFlusher.flushWindow(window); + await promiseBrowserState(SessionStore.getBrowserState()); + [tab1, tab2, tab3, tab4, tab5] = gBrowser.tabs; + is(tab3, gBrowser.selectedTab, "third tab is selected after restoring"); + ok( + document.title.startsWith(REMOTE_TITLE), + "title bar displays content title" + ); + ok(tab1.hasAttribute("pending"), "first tab is pending after restoring"); + ok(tab2.hasAttribute("pending"), "second tab is pending after restoring"); + is(tab2.label, ABOUT_ROBOTS_TITLE, "second tab displays content title"); + ok(!tab3.hasAttribute("pending"), "third tab is not pending after restoring"); + is( + tab3.label, + REMOTE_TITLE, + "third tab displays content title in pending state" + ); + ok(tab4.hasAttribute("pending"), "fourth tab is pending after restoring"); + is(tab4.label, NO_TITLE_URL, "fourth tab displays URL"); + is(tab5.label, EMPTY_TAB_TITLE, "fifth tab still displays empty tab title"); + + info("selecting the first tab"); + finishObservingLabelChanges = observeLabelChanges(tab1, [REMOTE_TITLE]); + let tabContentRestored = TestUtils.topicObserved( + "sessionstore-debug-tab-restored" + ); + gBrowser.selectedTab = tab1; + ok( + document.title.startsWith(REMOTE_TITLE), + "title bar displays content title" + ); + await tabContentRestored; + ok(!tab1.hasAttribute("pending"), "first tab isn't pending anymore"); + await finishObservingLabelChanges(); + + await promiseBrowserState(BACKUP_STATE); +}); diff --git a/browser/components/sessionstore/test/browser_tabicon_after_bg_tab_crash.js b/browser/components/sessionstore/test/browser_tabicon_after_bg_tab_crash.js new file mode 100644 index 0000000000..8a26806985 --- /dev/null +++ b/browser/components/sessionstore/test/browser_tabicon_after_bg_tab_crash.js @@ -0,0 +1,52 @@ +"use strict"; + +const FAVICON = + "data:image/gif;base64,R0lGODlhCwALAIAAAAAA3pn/ZiH5BAEAAAEALAAAAAALAAsAAAIUhA+hkcuO4lmNVindo7qyrIXiGBYAOw=="; +const PAGE_URL = `data:text/html, + + + + + + Favicon! + +`; + +/** + * Tests that if a background tab crashes that it doesn't + * lose the favicon in the tab. + */ +add_task(async function test_tabicon_after_bg_tab_crash() { + let originalTab = gBrowser.selectedTab; + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: PAGE_URL, + }, + async function (browser) { + // Because there is debounce logic in FaviconLoader.sys.mjs to reduce the + // favicon loads, we have to wait some time before checking that icon was + // stored properly. + await BrowserTestUtils.waitForCondition( + () => { + return gBrowser.getIcon() != null; + }, + "wait for favicon load to finish", + 100, + 5 + ); + Assert.equal(browser.mIconURL, FAVICON, "Favicon is correctly set."); + await BrowserTestUtils.switchTab(gBrowser, originalTab); + await BrowserTestUtils.crashFrame( + browser, + false /* shouldShowTabCrashPage */ + ); + Assert.equal( + browser.mIconURL, + FAVICON, + "Favicon is still set after crash." + ); + } + ); +}); diff --git a/browser/components/sessionstore/test/browser_tabs_in_urlbar.js b/browser/components/sessionstore/test/browser_tabs_in_urlbar.js new file mode 100644 index 0000000000..b82ba24a2a --- /dev/null +++ b/browser/components/sessionstore/test/browser_tabs_in_urlbar.js @@ -0,0 +1,151 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests that tabs which aren't displayed yet (i.e. need to be reloaded) are + * still displayed in the address bar results. + */ + +const RESTRICT_TOKEN_OPENPAGE = "%"; + +const { PlacesTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PlacesTestUtils.sys.mjs" +); + +const { UrlbarProviderOpenTabs } = ChromeUtils.importESModule( + "resource:///modules/UrlbarProviderOpenTabs.sys.mjs" +); + +const { UrlbarTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/UrlbarTestUtils.sys.mjs" +); + +var stateBackup = ss.getBrowserState(); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set the pref to true so we know exactly how many tabs should be restoring at + // any given time. This guarantees that a finishing load won't start another. + ["browser.sessionstore.restore_on_demand", true], + // Don't restore tabs lazily. + ["browser.sessionstore.restore_tabs_lazily", false], + ], + }); + + registerCleanupFunction(() => { + ss.setBrowserState(stateBackup); + }); + + info("Waiting for the Places DB to be initialized"); + await PlacesUtils.promiseLargeCacheDBConnection(); + await UrlbarProviderOpenTabs.promiseDBPopulated; +}); + +add_task(async function test_unrestored_tabs_listed() { + const state = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.org/#1", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.org/#2", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.org/#3", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.org/#4", triggeringPrincipal_base64 }, + ], + }, + ], + selected: 1, + }, + ], + }; + + const tabsForEnsure = new Set(); + state.windows[0].tabs.forEach(function (tab) { + tabsForEnsure.add(tab.entries[0].url); + }); + + let tabsRestoring = 0; + let tabsRestored = 0; + + await new Promise(resolve => { + function handleEvent(aEvent) { + if (aEvent.type == "SSTabRestoring") { + tabsRestoring++; + } else { + tabsRestored++; + } + + if (tabsRestoring < state.windows[0].tabs.length || tabsRestored < 1) { + return; + } + + gBrowser.tabContainer.removeEventListener( + "SSTabRestoring", + handleEvent, + true + ); + gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + handleEvent, + true + ); + executeSoon(resolve); + } + + // currentURI is set before SSTabRestoring is fired, so we can sucessfully check + // after that has fired for all tabs. Since 1 tab will be restored though, we + // also need to wait for 1 SSTabRestored since currentURI will be set, unset, then set. + gBrowser.tabContainer.addEventListener("SSTabRestoring", handleEvent, true); + gBrowser.tabContainer.addEventListener("SSTabRestored", handleEvent, true); + ss.setBrowserState(JSON.stringify(state)); + }); + + // Ensure any database statements started by UrlbarProviderOpenTabs are + // complete before continuing. + await PlacesTestUtils.promiseAsyncUpdates(); + + // Remove the current tab from tabsForEnsure, because switch to tab doesn't + // suggest it. + tabsForEnsure.delete(gBrowser.currentURI.spec); + info("Searching open pages."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: RESTRICT_TOKEN_OPENPAGE, + }); + const total = UrlbarTestUtils.getResultCount(window); + info(`Found ${total} matches`); + + // Check to see the expected uris and titles match up (in any order) + for (let i = 0; i < total; i++) { + const result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (result.heuristic) { + info("Skip heuristic match"); + continue; + } + const url = result.url; + Assert.ok( + tabsForEnsure.has(url), + `Should have the found result '${url}' in the expected list of entries` + ); + // Remove the found entry from expected results. + tabsForEnsure.delete(url); + } + // Make sure there is no reported open page that is not open. + Assert.equal(tabsForEnsure.size, 0, "Should have found all the tabs"); +}); diff --git a/browser/components/sessionstore/test/browser_undoCloseById.js b/browser/components/sessionstore/test/browser_undoCloseById.js new file mode 100644 index 0000000000..f1c49ae22c --- /dev/null +++ b/browser/components/sessionstore/test/browser_undoCloseById.js @@ -0,0 +1,185 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +/** + * This test is for the undoCloseById function. + */ + +async function openWindow(url) { + let win = await promiseNewWindowLoaded(); + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY; + BrowserTestUtils.startLoadingURIString(win.gBrowser.selectedBrowser, url, { + flags, + }); + await promiseBrowserLoaded(win.gBrowser.selectedBrowser, true, url); + return win; +} + +async function closeWindow(win) { + await BrowserTestUtils.closeWindow(win); + // Wait 20 ms to allow SessionStorage a chance to register the closed window. + await new Promise(resolve => setTimeout(resolve, 20)); +} + +function getLastClosedTabData(win) { + const closedTabs = SessionStore.getClosedTabData(win); + return closedTabs[closedTabs.length - 1]; +} + +add_task(async function test_undoCloseById() { + // Clear the lists of closed windows and tabs. + forgetClosedWindows(); + for (const win of SessionStore.getWindows()) { + while (SessionStore.getClosedTabCountForWindow(win)) { + SessionStore.forgetClosedTab(win, 0); + } + } + SessionStore.resetNextClosedId(); + + // Open a new window. + let win = await openWindow("about:robots"); + + // Open and close a tab. + await openAndCloseTab(win, "about:mozilla"); + is( + SessionStore.lastClosedObjectType, + "tab", + "The last closed object is a tab" + ); + + // Record the first closedId created. + is(1, SessionStore.getClosedTabCount(), "We have 1 closed tab"); + let initialClosedId = SessionStore.getClosedTabDataForWindow(win)[0].closedId; + + // Open and close another window. + let win2 = await openWindow("about:mozilla"); + await closeWindow(win2); // closedId == initialClosedId + 1 + is( + SessionStore.lastClosedObjectType, + "window", + "The last closed object is a window" + ); + + // Open and close another tab in the first window. + await openAndCloseTab(win, "about:robots"); // closedId == initialClosedId + 2 + is( + SessionStore.lastClosedObjectType, + "tab", + "The last closed object is a tab" + ); + + // Undo closing the second tab. + let tab = SessionStore.undoCloseById(initialClosedId + 2); + await promiseBrowserLoaded(tab.linkedBrowser); + is( + tab.linkedBrowser.currentURI.spec, + "about:robots", + "The expected tab was re-opened" + ); + + let notTab = SessionStore.undoCloseById(initialClosedId + 2); + is(notTab, undefined, "Re-opened tab cannot be unClosed again by closedId"); + + // Now the last closed object should be a window again. + is( + SessionStore.lastClosedObjectType, + "window", + "The last closed object is a window" + ); + + // Undo closing the first tab. + let tab2 = SessionStore.undoCloseById(initialClosedId); + await promiseBrowserLoaded(tab2.linkedBrowser); + is( + tab2.linkedBrowser.currentURI.spec, + "about:mozilla", + "The expected tab was re-opened" + ); + + // Close the two tabs we re-opened. + await promiseRemoveTabAndSessionState(tab); // closedId == initialClosedId + 3 + is( + SessionStore.lastClosedObjectType, + "tab", + "The last closed object is a tab" + ); + await promiseRemoveTabAndSessionState(tab2); // closedId == initialClosedId + 4 + is( + SessionStore.lastClosedObjectType, + "tab", + "The last closed object is a tab" + ); + + // Open another new window. + let win3 = await openWindow("about:mozilla"); + + // Close both windows. + await closeWindow(win); // closedId == initialClosedId + 5 + is( + SessionStore.lastClosedObjectType, + "window", + "The last closed object is a window" + ); + await closeWindow(win3); // closedId == initialClosedId + 6 + is( + SessionStore.lastClosedObjectType, + "window", + "The last closed object is a window" + ); + + // Undo closing the second window. + win = SessionStore.undoCloseById(initialClosedId + 6); + await BrowserTestUtils.waitForEvent(win, "load"); + + // Make sure we wait until this window is restored. + await BrowserTestUtils.waitForEvent( + win.gBrowser.tabContainer, + "SSTabRestored" + ); + + is( + win.gBrowser.selectedBrowser.currentURI.spec, + "about:mozilla", + "The expected window was re-opened" + ); + + let notWin = SessionStore.undoCloseById(initialClosedId + 6); + is( + notWin, + undefined, + "Re-opened window cannot be unClosed again by closedId" + ); + + // Close the window again. + await closeWindow(win); + is( + SessionStore.lastClosedObjectType, + "window", + "The last closed object is a window" + ); + + // Undo closing the first window. + win = SessionStore.undoCloseById(initialClosedId + 5); + + await BrowserTestUtils.waitForEvent(win, "load"); + + // Make sure we wait until this window is restored. + await BrowserTestUtils.waitForEvent( + win.gBrowser.tabContainer, + "SSTabRestored" + ); + + is( + win.gBrowser.selectedBrowser.currentURI.spec, + "about:robots", + "The expected window was re-opened" + ); + + // Close the window again. + await closeWindow(win); + is( + SessionStore.lastClosedObjectType, + "window", + "The last closed object is a window" + ); +}); diff --git a/browser/components/sessionstore/test/browser_undoCloseById_targetWindow.js b/browser/components/sessionstore/test/browser_undoCloseById_targetWindow.js new file mode 100644 index 0000000000..62e3da89ea --- /dev/null +++ b/browser/components/sessionstore/test/browser_undoCloseById_targetWindow.js @@ -0,0 +1,93 @@ +"use strict"; + +/** + * This test verifies SessionStore.undoCloseById behavior when passed the targetWindow argument + */ + +async function openWindow(url) { + let win = await promiseNewWindowLoaded(); + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY; + BrowserTestUtils.startLoadingURIString(win.gBrowser.selectedBrowser, url, { + flags, + }); + await promiseBrowserLoaded(win.gBrowser.selectedBrowser, true, url); + return win; +} + +async function closeWindow(win) { + TestUtils.waitForTick(); + let sessionStoreUpdated = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + await BrowserTestUtils.closeWindow(win); + await sessionStoreUpdated; +} + +function forgetTabsAndWindows() { + // Clear the lists of closed windows and tabs. + forgetClosedWindows(); + while (SessionStore.getClosedTabCount(window)) { + SessionStore.forgetClosedTab(window, 0); + } +} + +add_task(async function test_undoCloseById_with_targetWindow() { + forgetTabsAndWindows(); + // Test that a tab closed in (currently open) window B, will correctly be opened in target window A. + // And that the closed record should be correctly removed from window B + const winA = window; + // Open a new window. + const winB = await openWindow("about:robots"); + await SimpleTest.promiseFocus(winB); + // Open and close a tab in the 2nd window + await openAndCloseTab(winB, "about:mozilla"); + is( + SessionStore.lastClosedObjectType, + "tab", + "The last closed object is a tab" + ); + // Record the first closedId created. + const closedId = SessionStore.getClosedTabData(winB)[0].closedId; + let tabRestored = BrowserTestUtils.waitForNewTab( + winA.gBrowser, + "about:mozilla" + ); + + // Restore the tab into the first window, not the window it was closed in + SessionStore.undoCloseById(closedId, undefined, winA); + await tabRestored; + is(winA.gBrowser.selectedBrowser.currentURI.spec, "about:mozilla"); + + // Verify the closed tab data is removed from the source window + is( + SessionStore.getClosedTabData(winB).length, + 0, + "Record removed from the source window's closed tab data" + ); + + BrowserTestUtils.removeTab(winA.gBrowser.selectedTab); + await closeWindow(winB); +}); + +add_task(async function test_undoCloseById_with_nonExistent_targetWindow() { + // Test that restoring a tab to a non-existent targetWindow throws + forgetTabsAndWindows(); + await openAndCloseTab(window, "about:mozilla"); + is( + SessionStore.lastClosedObjectType, + "tab", + "The last closed object is a tab" + ); + // Record the first closedId created. + const closedId = SessionStore.getClosedTabData(window)[0].closedId; + + // get a reference to a window that will be closed + const newWin = await BrowserTestUtils.openNewBrowserWindow(); + await SimpleTest.promiseFocus(newWin); + await BrowserTestUtils.closeWindow(newWin); + + // Expect an exception trying to restore a tab to a non-existent window + Assert.throws(() => { + SessionStore.undoCloseById(closedId, undefined, newWin); + }, /NS_ERROR_ILLEGAL_VALUE/); +}); diff --git a/browser/components/sessionstore/test/browser_unrestored_crashedTabs.js b/browser/components/sessionstore/test/browser_unrestored_crashedTabs.js new file mode 100644 index 0000000000..51e54af12a --- /dev/null +++ b/browser/components/sessionstore/test/browser_unrestored_crashedTabs.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if we have tabs that are still in the "click to + * restore" state, that if their browsers crash, that we don't + * show the crashed state for those tabs (since selecting them + * should restore them anyway). + */ + +const PREF = "browser.sessionstore.restore_on_demand"; +const PAGE = + "data:text/html,A%20regular,%20everyday,%20normal%20page."; + +add_task(async function test() { + await pushPrefs([PREF, true]); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: PAGE, + }, + async function (browser) { + await TabStateFlusher.flush(browser); + + // We'll create a second "pending" tab. This is the one we'll + // ensure doesn't go to about:tabcrashed. We start it non-remote + // since this is how SessionStore creates all browsers before + // they are restored. + let unrestoredTab = BrowserTestUtils.addTab(gBrowser, "about:blank", { + skipAnimation: true, + forceNotRemote: true, + }); + + let state = { + entries: [{ url: PAGE, triggeringPrincipal_base64 }], + }; + + ss.setTabState(unrestoredTab, JSON.stringify(state)); + + ok(!unrestoredTab.hasAttribute("crashed"), "tab is not crashed"); + ok(unrestoredTab.hasAttribute("pending"), "tab is pending"); + + // Now crash the selected browser. + await BrowserTestUtils.crashFrame(browser); + + ok(!unrestoredTab.hasAttribute("crashed"), "tab is still not crashed"); + ok(unrestoredTab.hasAttribute("pending"), "tab is still pending"); + + // Selecting the tab should now restore it. + gBrowser.selectedTab = unrestoredTab; + await promiseTabRestored(unrestoredTab); + + ok(!unrestoredTab.hasAttribute("crashed"), "tab is still not crashed"); + ok(!unrestoredTab.hasAttribute("pending"), "tab is no longer pending"); + + // The original tab should still be crashed + let originalTab = gBrowser.getTabForBrowser(browser); + ok(originalTab.hasAttribute("crashed"), "original tab is crashed"); + ok(!originalTab.isRemoteBrowser, "Should not be remote"); + + // We'd better be able to restore it still. + gBrowser.selectedTab = originalTab; + SessionStore.reviveCrashedTab(originalTab); + await promiseTabRestored(originalTab); + + // Clean up. + BrowserTestUtils.removeTab(unrestoredTab); + } + ); +}); diff --git a/browser/components/sessionstore/test/browser_upgrade_backup.js b/browser/components/sessionstore/test/browser_upgrade_backup.js new file mode 100644 index 0000000000..fa0e34d421 --- /dev/null +++ b/browser/components/sessionstore/test/browser_upgrade_backup.js @@ -0,0 +1,158 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +const Paths = SessionFile.Paths; +const PREF_UPGRADE = "browser.sessionstore.upgradeBackup.latestBuildID"; +const PREF_MAX_UPGRADE_BACKUPS = + "browser.sessionstore.upgradeBackup.maxUpgradeBackups"; + +/** + * Prepares tests by retrieving the current platform's build ID, clearing the + * build where the last backup was created and creating arbitrary JSON data + * for a new backup. + */ +function prepareTest() { + let result = {}; + + result.buildID = Services.appinfo.platformBuildID; + Services.prefs.setCharPref(PREF_UPGRADE, ""); + result.contents = { + "browser_upgrade_backup.js": Math.random(), + }; + + return result; +} + +/** + * Retrieves all upgrade backups and returns them in an array. + */ +async function getUpgradeBackups() { + let children = await IOUtils.getChildren(Paths.backups); + + return children.filter(path => path.startsWith(Paths.upgradeBackupPrefix)); +} + +add_setup(async function () { + // Wait until initialization is complete + await SessionStore.promiseInitialized; +}); + +add_task(async function test_upgrade_backup() { + let test = prepareTest(); + info("Let's check if we create an upgrade backup"); + await SessionFile.wipe(); + await IOUtils.writeJSON(Paths.clean, test.contents, { + compress: true, + }); + info("Call `SessionFile.read()` to set state to 'clean'"); + await SessionFile.read(); + await SessionFile.write(""); // First call to write() triggers the backup + + Assert.equal( + Services.prefs.getCharPref(PREF_UPGRADE), + test.buildID, + "upgrade backup should be set" + ); + + Assert.ok( + await IOUtils.exists(Paths.upgradeBackup), + "upgrade backup file has been created" + ); + + let data = await IOUtils.readJSON(Paths.upgradeBackup, { decompress: true }); + Assert.deepEqual( + test.contents, + data, + "upgrade backup contains the expected contents" + ); + + info("Let's check that we don't overwrite this upgrade backup"); + let newContents = { + "something else entirely": Math.random(), + }; + await IOUtils.writeJSON(Paths.clean, newContents, { + compress: true, + }); + await SessionFile.write(""); // Next call to write() shouldn't trigger the backup + data = await IOUtils.readJSON(Paths.upgradeBackup, { decompress: true }); + Assert.deepEqual(test.contents, data, "upgrade backup hasn't changed"); +}); + +add_task(async function test_upgrade_backup_removal() { + let test = prepareTest(); + let maxUpgradeBackups = Preferences.get(PREF_MAX_UPGRADE_BACKUPS, 3); + info("Let's see if we remove backups if there are too many"); + await SessionFile.wipe(); + await IOUtils.writeJSON(Paths.clean, test.contents, { + compress: true, + }); + info("Call `SessionFile.read()` to set state to 'clean'"); + await SessionFile.read(); + + // create dummy backups + await IOUtils.writeUTF8(Paths.upgradeBackupPrefix + "20080101010101", "", { + compress: true, + }); + await IOUtils.writeUTF8(Paths.upgradeBackupPrefix + "20090101010101", "", { + compress: true, + }); + await IOUtils.writeUTF8(Paths.upgradeBackupPrefix + "20100101010101", "", { + compress: true, + }); + await IOUtils.writeUTF8(Paths.upgradeBackupPrefix + "20110101010101", "", { + compress: true, + }); + await IOUtils.writeUTF8(Paths.upgradeBackupPrefix + "20120101010101", "", { + compress: true, + }); + await IOUtils.writeUTF8(Paths.upgradeBackupPrefix + "20130101010101", "", { + compress: true, + }); + + // get currently existing backups + let backups = await getUpgradeBackups(); + + info("Write the session to disk and perform a backup"); + await SessionFile.write(""); // First call to write() triggers the backup and the cleanup + + // a new backup should have been created (and still exist) + is( + Services.prefs.getCharPref(PREF_UPGRADE), + test.buildID, + "upgrade backup should be set" + ); + Assert.ok( + await IOUtils.exists(Paths.upgradeBackup), + "upgrade backup file has been created" + ); + + // get currently existing backups and check their count + let newBackups = await getUpgradeBackups(); + is( + newBackups.length, + maxUpgradeBackups, + "expected number of backups are present after removing old backups" + ); + + // find all backups that were created during the last call to `SessionFile.write("");` + // ie, filter out all the backups that have already been present before the call + newBackups = newBackups.filter(function (backup) { + return !backups.includes(backup); + }); + + // check that exactly one new backup was created + is(newBackups.length, 1, "one new backup was created that was not removed"); + + await SessionFile.write(""); // Second call to write() should not trigger anything + + backups = await getUpgradeBackups(); + is( + backups.length, + maxUpgradeBackups, + "second call to SessionFile.write() didn't create or remove more backups" + ); +}); diff --git a/browser/components/sessionstore/test/browser_urlbarSearchMode.js b/browser/components/sessionstore/test/browser_urlbarSearchMode.js new file mode 100644 index 0000000000..052fcf355c --- /dev/null +++ b/browser/components/sessionstore/test/browser_urlbarSearchMode.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test makes sure that the urlbar's search mode is correctly preserved. + */ + +ChromeUtils.defineESModuleGetters(this, { + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(this, "UrlbarTestUtils", () => { + const { UrlbarTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/UrlbarTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +add_task(async function test() { + // Open the urlbar view and enter search mode. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }); + + // The search mode should be in the tab state. + let state = JSON.parse(ss.getTabState(gBrowser.selectedTab)); + Assert.ok( + "searchMode" in state, + "state.searchMode is present after entering search mode" + ); + Assert.deepEqual( + state.searchMode, + { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + entry: "oneoff", + isPreview: false, + }, + "state.searchMode is correct" + ); + + // Exit search mode. + await UrlbarTestUtils.exitSearchMode(window); + + // The search mode should not be in the tab state. + let newState = JSON.parse(ss.getTabState(gBrowser.selectedTab)); + Assert.ok( + !newState.searchMode, + "state.searchMode is not present after exiting search mode" + ); + + await UrlbarTestUtils.promisePopupClose(window); +}); diff --git a/browser/components/sessionstore/test/browser_userTyped_restored_after_discard.js b/browser/components/sessionstore/test/browser_userTyped_restored_after_discard.js new file mode 100644 index 0000000000..171197a743 --- /dev/null +++ b/browser/components/sessionstore/test/browser_userTyped_restored_after_discard.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function testDiscardWithNotLoadedUserTypedValue() { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com" + ); + + // Make sure we flushed the state at least once (otherwise the fix + // for Bug 1422588 would make SessionStore.resetBrowserToLazyState + // to still store the user typed value into the tab state cache + // even when the user typed value was not yet being loading when + // the tab got discarded). + await TabStateFlusher.flush(tab1.linkedBrowser); + + tab1.linkedBrowser.userTypedValue = "mockUserTypedValue"; + + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:robots" + ); + + let waitForTabDiscarded = BrowserTestUtils.waitForEvent( + tab1, + "TabBrowserDiscarded" + ); + gBrowser.discardBrowser(tab1); + await waitForTabDiscarded; + + const promiseTabLoaded = BrowserTestUtils.browserLoaded( + tab1.linkedBrowser, + false, + "https://example.com/" + ); + await BrowserTestUtils.switchTab(gBrowser, tab1); + info("Wait for the restored tab to load https://example.com"); + await promiseTabLoaded; + is( + tab1.linkedBrowser.currentURI.spec, + "https://example.com/", + "Restored discarded tab has loaded the expected url" + ); + + BrowserTestUtils.removeTab(tab2); + BrowserTestUtils.removeTab(tab1); +}); diff --git a/browser/components/sessionstore/test/browser_windowRestore_perwindowpb.js b/browser/components/sessionstore/test/browser_windowRestore_perwindowpb.js new file mode 100644 index 0000000000..73568cb348 --- /dev/null +++ b/browser/components/sessionstore/test/browser_windowRestore_perwindowpb.js @@ -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/. */ + +// This test checks that closed private windows can't be restored + +function test() { + waitForExplicitFinish(); + + // Purging the list of closed windows + forgetClosedWindows(); + + // Load a private window, then close it + // and verify it doesn't get remembered for restoring + whenNewWindowLoaded({ private: true }, function (win) { + info("The private window got loaded"); + win.addEventListener( + "SSWindowClosing", + function () { + executeSoon(function () { + is( + ss.getClosedWindowCount(), + 0, + "The private window should not have been stored" + ); + }); + }, + { once: true } + ); + BrowserTestUtils.closeWindow(win).then(finish); + }); +} diff --git a/browser/components/sessionstore/test/browser_windowStateContainer.js b/browser/components/sessionstore/test/browser_windowStateContainer.js new file mode 100644 index 0000000000..f0d6f42d39 --- /dev/null +++ b/browser/components/sessionstore/test/browser_windowStateContainer.js @@ -0,0 +1,176 @@ +"use strict"; + +requestLongerTimeout(2); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["dom.ipc.processCount", 1]], + }); +}); + +function promiseTabsRestored(win, nExpected) { + return new Promise(resolve => { + let nReceived = 0; + function handler(event) { + if (++nReceived === nExpected) { + win.gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + handler, + true + ); + resolve(); + } + } + win.gBrowser.tabContainer.addEventListener("SSTabRestored", handler, true); + }); +} + +add_task(async function () { + let win = await BrowserTestUtils.openNewBrowserWindow(); + + // Create 4 tabs with different userContextId. + for (let userContextId = 1; userContextId < 5; userContextId++) { + let tab = BrowserTestUtils.addTab(win.gBrowser, "http://example.com/", { + userContextId, + }); + await promiseBrowserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + } + + // Move the default tab of window to the end. + // We want the 1st tab to have non-default userContextId, so later when we + // restore into win2 we can test restore into an existing tab with different + // userContextId. + win.gBrowser.moveTabTo(win.gBrowser.tabs[0], win.gBrowser.tabs.length - 1); + + let winState = ss.getWindowState(win); + + for (let i = 0; i < 4; i++) { + Assert.equal( + winState.windows[0].tabs[i].userContextId, + i + 1, + "1st Window: tabs[" + i + "].userContextId should exist." + ); + } + + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + + // Create tabs with different userContextId, but this time we create them with + // fewer tabs and with different order with win. + for (let userContextId = 3; userContextId > 0; userContextId--) { + let tab = BrowserTestUtils.addTab(win2.gBrowser, "http://example.com/", { + userContextId, + }); + await promiseBrowserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + } + + let tabsRestored = promiseTabsRestored(win2, 5); + await setWindowState(win2, winState, true); + await tabsRestored; + + for (let i = 0; i < 4; i++) { + let browser = win2.gBrowser.tabs[i].linkedBrowser; + await ContentTask.spawn( + browser, + { expectedId: i + 1 }, + async function (args) { + Assert.equal( + docShell.getOriginAttributes().userContextId, + args.expectedId, + "The docShell has the correct userContextId" + ); + + Assert.equal( + content.document.nodePrincipal.originAttributes.userContextId, + args.expectedId, + "The document has the correct userContextId" + ); + } + ); + } + + // Test the last tab, which doesn't have userContextId. + let browser = win2.gBrowser.tabs[4].linkedBrowser; + await SpecialPowers.spawn( + browser, + [{ expectedId: 0 }], + async function (args) { + Assert.equal( + docShell.getOriginAttributes().userContextId, + args.expectedId, + "The docShell has the correct userContextId" + ); + + Assert.equal( + content.document.nodePrincipal.originAttributes.userContextId, + args.expectedId, + "The document has the correct userContextId" + ); + } + ); + + await BrowserTestUtils.closeWindow(win); + await BrowserTestUtils.closeWindow(win2); +}); + +add_task(async function () { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await TabStateFlusher.flush(win.gBrowser.selectedBrowser); + + let tab = BrowserTestUtils.addTab(win.gBrowser, "http://example.com/", { + userContextId: 1, + }); + await promiseBrowserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + + // win should have 1 default tab, and 1 container tab. + Assert.equal(win.gBrowser.tabs.length, 2, "win should have 2 tabs"); + + let winState = ss.getWindowState(win); + + for (let i = 0; i < 2; i++) { + Assert.equal( + winState.windows[0].tabs[i].userContextId, + i, + "1st Window: tabs[" + i + "].userContextId should be " + i + ); + } + + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + + let tab2 = BrowserTestUtils.addTab(win2.gBrowser, "http://example.com/", { + userContextId: 1, + }); + await promiseBrowserLoaded(tab2.linkedBrowser); + await TabStateFlusher.flush(tab2.linkedBrowser); + + // Move the first normal tab to end, so the first tab of win2 will be a + // container tab. + win2.gBrowser.moveTabTo(win2.gBrowser.tabs[0], win2.gBrowser.tabs.length - 1); + await TabStateFlusher.flush(win2.gBrowser.tabs[0].linkedBrowser); + + let tabsRestored = promiseTabsRestored(win2, 2); + await setWindowState(win2, winState, true); + await tabsRestored; + + for (let i = 0; i < 2; i++) { + let browser = win2.gBrowser.tabs[i].linkedBrowser; + await ContentTask.spawn(browser, { expectedId: i }, async function (args) { + Assert.equal( + docShell.getOriginAttributes().userContextId, + args.expectedId, + "The docShell has the correct userContextId" + ); + + Assert.equal( + content.document.nodePrincipal.originAttributes.userContextId, + args.expectedId, + "The document has the correct userContextId" + ); + }); + } + + await BrowserTestUtils.closeWindow(win); + await BrowserTestUtils.closeWindow(win2); +}); diff --git a/browser/components/sessionstore/test/coopHeaderCommon.sjs b/browser/components/sessionstore/test/coopHeaderCommon.sjs new file mode 100644 index 0000000000..be4607b2c2 --- /dev/null +++ b/browser/components/sessionstore/test/coopHeaderCommon.sjs @@ -0,0 +1,32 @@ +function handleRequest(request, response) { + let { NetUtil } = ChromeUtils.importESModule( + "resource://gre/modules/NetUtil.sys.mjs" + ); + let query = new URLSearchParams(request.queryString); + + response.setHeader("Cross-Origin-Opener-Policy", "same-origin", false); + response.setHeader("Cross-Origin-Embedder-Policy", "require-corp", false); + + var fileRoot = query.get("fileRoot"); + + // Get the desired file + var file; + getObjectState("SERVER_ROOT", function (serverRoot) { + file = serverRoot.getFile(fileRoot); + }); + + // Set up the file streams to read in the file as UTF-8 + let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + + fstream.init(file, -1, 0, 0); + + // Read the file + let available = fstream.available(); + let data = + available > 0 ? NetUtil.readInputStreamToString(fstream, available) : ""; + fstream.close(); + + response.write(data); +} diff --git a/browser/components/sessionstore/test/coop_coep.html b/browser/components/sessionstore/test/coop_coep.html new file mode 100644 index 0000000000..9fe6f7a03e --- /dev/null +++ b/browser/components/sessionstore/test/coop_coep.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/browser/components/sessionstore/test/coop_coep.html^headers^ b/browser/components/sessionstore/test/coop_coep.html^headers^ new file mode 100644 index 0000000000..5f8621ef83 --- /dev/null +++ b/browser/components/sessionstore/test/coop_coep.html^headers^ @@ -0,0 +1,2 @@ +Cross-Origin-Embedder-Policy: require-corp +Cross-Origin-Opener-Policy: same-origin diff --git a/browser/components/sessionstore/test/empty.html b/browser/components/sessionstore/test/empty.html new file mode 100644 index 0000000000..ba0056bc32 --- /dev/null +++ b/browser/components/sessionstore/test/empty.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/browser/components/sessionstore/test/file_async_duplicate_tab.html b/browser/components/sessionstore/test/file_async_duplicate_tab.html new file mode 100644 index 0000000000..17f1c080ea --- /dev/null +++ b/browser/components/sessionstore/test/file_async_duplicate_tab.html @@ -0,0 +1 @@ +clickme diff --git a/browser/components/sessionstore/test/file_async_flushes.html b/browser/components/sessionstore/test/file_async_flushes.html new file mode 100644 index 0000000000..17f1c080ea --- /dev/null +++ b/browser/components/sessionstore/test/file_async_flushes.html @@ -0,0 +1 @@ +clickme diff --git a/browser/components/sessionstore/test/file_formdata_password.html b/browser/components/sessionstore/test/file_formdata_password.html new file mode 100644 index 0000000000..0f072c31e1 --- /dev/null +++ b/browser/components/sessionstore/test/file_formdata_password.html @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + diff --git a/browser/components/sessionstore/test/file_sessionHistory_hashchange.html b/browser/components/sessionstore/test/file_sessionHistory_hashchange.html new file mode 100644 index 0000000000..4b64fc180a --- /dev/null +++ b/browser/components/sessionstore/test/file_sessionHistory_hashchange.html @@ -0,0 +1 @@ +clickme diff --git a/browser/components/sessionstore/test/head.js b/browser/components/sessionstore/test/head.js new file mode 100644 index 0000000000..d475fa86a1 --- /dev/null +++ b/browser/components/sessionstore/test/head.js @@ -0,0 +1,690 @@ +/* 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 triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL; + +const TAB_STATE_NEEDS_RESTORE = 1; +const TAB_STATE_RESTORING = 2; + +const ROOT = getRootDirectory(gTestPath); +const HTTPROOT = ROOT.replace( + "chrome://mochitests/content/", + "http://example.com/" +); +const HTTPSROOT = ROOT.replace( + "chrome://mochitests/content/", + "https://example.com/" +); + +const { SessionSaver } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionSaver.sys.mjs" +); +const { SessionFile } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" +); +const { TabState } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/TabState.sys.mjs" +); +const { TabStateFlusher } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/TabStateFlusher.sys.mjs" +); +const { SessionStoreTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/SessionStoreTestUtils.sys.mjs" +); + +const ss = SessionStore; +SessionStoreTestUtils.init(this, window); + +// Some tests here assume that all restored tabs are loaded without waiting for +// the user to bring them to the foreground. We ensure this by resetting the +// related preference (see the "firefox.js" defaults file for details). +Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", false); +registerCleanupFunction(function () { + Services.prefs.clearUserPref("browser.sessionstore.restore_on_demand"); +}); + +// Obtain access to internals +Services.prefs.setBoolPref("browser.sessionstore.debug", true); +registerCleanupFunction(function () { + Services.prefs.clearUserPref("browser.sessionstore.debug"); +}); + +// This kicks off the search service used on about:home and allows the +// session restore tests to be run standalone without triggering errors. +Cc["@mozilla.org/browser/clh;1"].getService(Ci.nsIBrowserHandler).defaultArgs; + +function provideWindow(aCallback, aURL, aFeatures) { + function callbackSoon(aWindow) { + executeSoon(function executeCallbackSoon() { + aCallback(aWindow); + }); + } + + let win = openDialog( + AppConstants.BROWSER_CHROME_URL, + "", + aFeatures || "chrome,all,dialog=no", + aURL || "about:blank" + ); + whenWindowLoaded(win, function onWindowLoaded(aWin) { + if (!aURL) { + info("Loaded a blank window."); + callbackSoon(aWin); + return; + } + + aWin.gBrowser.selectedBrowser.addEventListener( + "load", + function () { + callbackSoon(aWin); + }, + { capture: true, once: true } + ); + }); +} + +// This assumes that tests will at least have some state/entries +function waitForBrowserState(aState, aSetStateCallback) { + return SessionStoreTestUtils.waitForBrowserState(aState, aSetStateCallback); +} + +function promiseBrowserState(aState) { + return SessionStoreTestUtils.promiseBrowserState(aState); +} + +function promiseTabState(tab, state) { + if (typeof state != "string") { + state = JSON.stringify(state); + } + + let promise = promiseTabRestored(tab); + ss.setTabState(tab, state); + return promise; +} + +function promiseWindowRestoring(win) { + return new Promise(resolve => + win.addEventListener("SSWindowRestoring", resolve, { once: true }) + ); +} + +function promiseWindowRestored(win) { + return new Promise(resolve => + win.addEventListener("SSWindowRestored", resolve, { once: true }) + ); +} + +async function setBrowserState(state, win = window) { + ss.setBrowserState(typeof state != "string" ? JSON.stringify(state) : state); + await promiseWindowRestored(win); +} + +async function setWindowState(win, state, overwrite = false) { + ss.setWindowState( + win, + typeof state != "string" ? JSON.stringify(state) : state, + overwrite + ); + await promiseWindowRestored(win); +} + +function waitForTopic(aTopic, aTimeout, aCallback) { + let observing = false; + function removeObserver() { + if (!observing) { + return; + } + Services.obs.removeObserver(observer, aTopic); + observing = false; + } + + let timeout = setTimeout(function () { + removeObserver(); + aCallback(false); + }, aTimeout); + + function observer(subject, topic, data) { + removeObserver(); + timeout = clearTimeout(timeout); + executeSoon(() => aCallback(true)); + } + + registerCleanupFunction(function () { + removeObserver(); + if (timeout) { + clearTimeout(timeout); + } + }); + + observing = true; + Services.obs.addObserver(observer, aTopic); +} + +/** + * Wait until session restore has finished collecting its data and is + * has written that data ("sessionstore-state-write-complete"). + * + * @param {function} aCallback If sessionstore-state-write-complete is sent + * within buffering interval + 100 ms, the callback is passed |true|, + * otherwise, it is passed |false|. + */ +function waitForSaveState(aCallback) { + let timeout = + 100 + Services.prefs.getIntPref("browser.sessionstore.interval"); + return waitForTopic("sessionstore-state-write-complete", timeout, aCallback); +} +function promiseSaveState() { + return new Promise((resolve, reject) => { + waitForSaveState(isSuccessful => { + if (!isSuccessful) { + reject(new Error("Save state timeout")); + } else { + resolve(); + } + }); + }); +} +function forceSaveState() { + return SessionSaver.run(); +} + +function promiseRecoveryFileContents() { + let promise = forceSaveState(); + return promise.then(function () { + return IOUtils.readUTF8(SessionFile.Paths.recovery, { + decompress: true, + }); + }); +} + +var promiseForEachSessionRestoreFile = async function (cb) { + for (let key of SessionFile.Paths.loadOrder) { + let data = ""; + try { + data = await IOUtils.readUTF8(SessionFile.Paths[key], { + decompress: true, + }); + } catch (ex) { + // Ignore missing files + if (!(DOMException.isInstance(ex) && ex.name == "NotFoundError")) { + throw ex; + } + } + cb(data, key); + } +}; + +function promiseBrowserLoaded( + aBrowser, + ignoreSubFrames = true, + wantLoad = null +) { + return BrowserTestUtils.browserLoaded(aBrowser, !ignoreSubFrames, wantLoad); +} + +function whenWindowLoaded(aWindow, aCallback) { + aWindow.addEventListener( + "load", + function () { + executeSoon(function executeWhenWindowLoaded() { + aCallback(aWindow); + }); + }, + { once: true } + ); +} +function promiseWindowLoaded(aWindow) { + return new Promise(resolve => whenWindowLoaded(aWindow, resolve)); +} + +var gUniqueCounter = 0; +function r() { + return Date.now() + "-" + ++gUniqueCounter; +} + +function* BrowserWindowIterator() { + for (let currentWindow of Services.wm.getEnumerator("navigator:browser")) { + if (!currentWindow.closed) { + yield currentWindow; + } + } +} + +var gWebProgressListener = { + _callback: null, + + setCallback(aCallback) { + if (!this._callback) { + window.gBrowser.addTabsProgressListener(this); + } + this._callback = aCallback; + }, + + unsetCallback() { + if (this._callback) { + this._callback = null; + window.gBrowser.removeTabsProgressListener(this); + } + }, + + onStateChange(aBrowser, aWebProgress, aRequest, aStateFlags, aStatus) { + if ( + aStateFlags & Ci.nsIWebProgressListener.STATE_STOP && + aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK && + aStateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW + ) { + this._callback(aBrowser); + } + }, +}; + +registerCleanupFunction(function () { + gWebProgressListener.unsetCallback(); +}); + +var gProgressListener = { + _callback: null, + + setCallback(callback) { + Services.obs.addObserver(this, "sessionstore-debug-tab-restored"); + this._callback = callback; + }, + + unsetCallback() { + if (this._callback) { + this._callback = null; + Services.obs.removeObserver(this, "sessionstore-debug-tab-restored"); + } + }, + + observe(browser, topic, data) { + gProgressListener.onRestored(browser); + }, + + onRestored(browser) { + if (ss.getInternalObjectState(browser) == TAB_STATE_RESTORING) { + let args = [browser].concat(gProgressListener._countTabs()); + gProgressListener._callback.apply(gProgressListener, args); + } + }, + + _countTabs() { + let needsRestore = 0, + isRestoring = 0, + wasRestored = 0; + + for (let win of BrowserWindowIterator()) { + for (let i = 0; i < win.gBrowser.tabs.length; i++) { + let browser = win.gBrowser.tabs[i].linkedBrowser; + let state = ss.getInternalObjectState(browser); + if (browser.isConnected && !state) { + wasRestored++; + } else if (state == TAB_STATE_RESTORING) { + isRestoring++; + } else if (state == TAB_STATE_NEEDS_RESTORE || !browser.isConnected) { + needsRestore++; + } + } + } + return [needsRestore, isRestoring, wasRestored]; + }, +}; + +registerCleanupFunction(function () { + gProgressListener.unsetCallback(); +}); + +// Close all but our primary window. +function promiseAllButPrimaryWindowClosed() { + let windows = []; + for (let win of BrowserWindowIterator()) { + if (win != window) { + windows.push(win); + } + } + + return Promise.all(windows.map(BrowserTestUtils.closeWindow)); +} + +// Forget all closed windows. +function forgetClosedWindows() { + while (ss.getClosedWindowCount() > 0) { + ss.forgetClosedWindow(0); + } +} + +// Forget all closed tabs for a window +function forgetClosedTabs(win) { + while (ss.getClosedTabCountForWindow(win) > 0) { + ss.forgetClosedTab(win, 0); + } +} + +/** + * When opening a new window it is not sufficient to wait for its load event. + * We need to use whenDelayedStartupFinshed() here as the browser window's + * delayedStartup() routine is executed one tick after the window's load event + * has been dispatched. browser-delayed-startup-finished might be deferred even + * further if parts of the window's initialization process take more time than + * expected (e.g. reading a big session state from disk). + */ +function whenNewWindowLoaded(aOptions, aCallback) { + let features = ""; + let url = "about:blank"; + + if ((aOptions && aOptions.private) || false) { + features = ",private"; + url = "about:privatebrowsing"; + } + + let win = openDialog( + AppConstants.BROWSER_CHROME_URL, + "", + "chrome,all,dialog=no" + features, + url + ); + let delayedStartup = promiseDelayedStartupFinished(win); + + let browserLoaded = new Promise(resolve => { + if (url == "about:blank") { + resolve(); + return; + } + + win.addEventListener( + "load", + function () { + let browser = win.gBrowser.selectedBrowser; + promiseBrowserLoaded(browser).then(resolve); + }, + { once: true } + ); + }); + + Promise.all([delayedStartup, browserLoaded]).then(() => aCallback(win)); +} +function promiseNewWindowLoaded(aOptions) { + return new Promise(resolve => whenNewWindowLoaded(aOptions, resolve)); +} + +/** + * This waits for the browser-delayed-startup-finished notification of a given + * window. It indicates that the windows has loaded completely and is ready to + * be used for testing. + */ +function whenDelayedStartupFinished(aWindow, aCallback) { + Services.obs.addObserver(function observer(aSubject, aTopic) { + if (aWindow == aSubject) { + Services.obs.removeObserver(observer, aTopic); + executeSoon(aCallback); + } + }, "browser-delayed-startup-finished"); +} +function promiseDelayedStartupFinished(aWindow) { + return new Promise(resolve => whenDelayedStartupFinished(aWindow, resolve)); +} + +function promiseTabRestored(tab) { + return BrowserTestUtils.waitForEvent(tab, "SSTabRestored"); +} + +function promiseTabRestoring(tab) { + return BrowserTestUtils.waitForEvent(tab, "SSTabRestoring"); +} + +// Removes the given tab immediately and returns a promise that resolves when +// all pending status updates (messages) of the closing tab have been received. +function promiseRemoveTabAndSessionState(tab) { + let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + BrowserTestUtils.removeTab(tab); + return sessionUpdatePromise; +} + +// Write DOMSessionStorage data to the given browser. +function modifySessionStorage(browser, storageData, storageOptions = {}) { + let browsingContext = browser.browsingContext; + if (storageOptions && "frameIndex" in storageOptions) { + browsingContext = browsingContext.children[storageOptions.frameIndex]; + } + + return SpecialPowers.spawn( + browsingContext, + [[storageData, storageOptions]], + async function ([data, options]) { + let frame = content; + let keys = new Set(Object.keys(data)); + let isClearing = !keys.size; + let storage = frame.sessionStorage; + + return new Promise(resolve => { + docShell.chromeEventHandler.addEventListener( + "MozSessionStorageChanged", + function onStorageChanged(event) { + if (event.storageArea == storage) { + keys.delete(event.key); + } + + if (keys.size == 0) { + docShell.chromeEventHandler.removeEventListener( + "MozSessionStorageChanged", + onStorageChanged, + true + ); + resolve(); + } + }, + true + ); + + if (isClearing) { + storage.clear(); + } else { + for (let key of keys) { + frame.sessionStorage[key] = data[key]; + } + } + }); + } + ); +} + +function pushPrefs(...aPrefs) { + return SpecialPowers.pushPrefEnv({ set: aPrefs }); +} + +function popPrefs() { + return SpecialPowers.popPrefEnv(); +} + +function setScrollPosition(bc, x, y) { + return SpecialPowers.spawn(bc, [x, y], (childX, childY) => { + return new Promise(resolve => { + content.addEventListener( + "mozvisualscroll", + function onScroll(event) { + if (content.document.ownerGlobal.visualViewport == event.target) { + content.removeEventListener("mozvisualscroll", onScroll, { + mozSystemGroup: true, + }); + resolve(); + } + }, + { mozSystemGroup: true } + ); + content.scrollTo(childX, childY); + }); + }); +} + +async function checkScroll(tab, expected, msg) { + let browser = tab.linkedBrowser; + await TabStateFlusher.flush(browser); + + let scroll = JSON.parse(ss.getTabState(tab)).scroll || null; + is(JSON.stringify(scroll), JSON.stringify(expected), msg); +} + +function whenDomWindowClosedHandled(aCallback) { + Services.obs.addObserver(function observer(aSubject, aTopic) { + Services.obs.removeObserver(observer, aTopic); + aCallback(); + }, "sessionstore-debug-domwindowclosed-handled"); +} + +function getPropertyOfFormField(browserContext, selector, propName) { + return SpecialPowers.spawn( + browserContext, + [selector, propName], + (selectorChild, propNameChild) => { + return content.document.querySelector(selectorChild)[propNameChild]; + } + ); +} + +function setPropertyOfFormField(browserContext, selector, propName, newValue) { + return SpecialPowers.spawn( + browserContext, + [selector, propName, newValue], + (selectorChild, propNameChild, newValueChild) => { + let node = content.document.querySelector(selectorChild); + node[propNameChild] = newValueChild; + + let event = node.ownerDocument.createEvent("UIEvents"); + event.initUIEvent("input", true, true, node.ownerGlobal, 0); + node.dispatchEvent(event); + } + ); +} + +function promiseOnHistoryReplaceEntry(browser) { + if (SpecialPowers.Services.appinfo.sessionHistoryInParent) { + return new Promise(resolve => { + let sessionHistory = browser.browsingContext?.sessionHistory; + if (sessionHistory) { + var historyListener = { + OnHistoryNewEntry() {}, + OnHistoryGotoIndex() {}, + OnHistoryPurge() {}, + OnHistoryReload() { + return true; + }, + + OnHistoryReplaceEntry() { + resolve(); + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsISHistoryListener", + "nsISupportsWeakReference", + ]), + }; + + sessionHistory.addSHistoryListener(historyListener); + } + }); + } + + return SpecialPowers.spawn(browser, [], () => { + return new Promise(resolve => { + var historyListener = { + OnHistoryNewEntry() {}, + OnHistoryGotoIndex() {}, + OnHistoryPurge() {}, + OnHistoryReload() { + return true; + }, + + OnHistoryReplaceEntry() { + resolve(); + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsISHistoryListener", + "nsISupportsWeakReference", + ]), + }; + + var { sessionHistory } = this.docShell.QueryInterface( + Ci.nsIWebNavigation + ); + if (sessionHistory) { + sessionHistory.legacySHistory.addSHistoryListener(historyListener); + } + }); + }); +} + +function loadTestSubscript(filePath) { + Services.scriptloader.loadSubScript(new URL(filePath, gTestPath).href, this); +} + +function addCoopTask(aFile, aTest, aUrlRoot) { + async function taskToBeAdded() { + info(`File ${aFile} has COOP headers enabled`); + let filePath = `browser/browser/components/sessionstore/test/${aFile}`; + let url = aUrlRoot + `coopHeaderCommon.sjs?fileRoot=${filePath}`; + await aTest(url); + } + Object.defineProperty(taskToBeAdded, "name", { value: aTest.name }); + add_task(taskToBeAdded); +} + +function addNonCoopTask(aFile, aTest, aUrlRoot) { + async function taskToBeAdded() { + await aTest(aUrlRoot + aFile); + } + Object.defineProperty(taskToBeAdded, "name", { value: aTest.name }); + add_task(taskToBeAdded); +} + +function openAndCloseTab(window, url) { + return SessionStoreTestUtils.openAndCloseTab(window, url); +} + +/** + * This is regrettable, but when `promiseBrowserState` resolves, we're still + * midway through loading the tabs. To avoid race conditions in URLs for tabs + * being available, wait for all the loads to finish: + */ +function promiseSessionStoreLoads(numberOfLoads) { + let loadsSeen = 0; + return new Promise(resolve => { + Services.obs.addObserver(function obs(browser) { + loadsSeen++; + if (loadsSeen == numberOfLoads) { + resolve(); + } + // The typeof check is here to avoid one test messing with everything else by + // keeping the observer indefinitely. + if (typeof info == "undefined" || loadsSeen >= numberOfLoads) { + Services.obs.removeObserver(obs, "sessionstore-debug-tab-restored"); + } + info("Saw load for " + browser.currentURI.spec); + }, "sessionstore-debug-tab-restored"); + }); +} + +function triggerClickOn(target, options) { + let promise = BrowserTestUtils.waitForEvent(target, "click"); + if (AppConstants.platform == "macosx") { + options.metaKey = options.ctrlKey; + delete options.ctrlKey; + } + EventUtils.synthesizeMouseAtCenter(target, options); + return promise; +} + +async function openTabMenuFor(tab) { + let tabMenu = tab.ownerDocument.getElementById("tabContextMenu"); + + let tabMenuShown = BrowserTestUtils.waitForEvent(tabMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + tab, + { type: "contextmenu" }, + tab.ownerGlobal + ); + await tabMenuShown; + + return tabMenu; +} diff --git a/browser/components/sessionstore/test/marionette/manifest.toml b/browser/components/sessionstore/test/marionette/manifest.toml new file mode 100644 index 0000000000..6b62bea84e --- /dev/null +++ b/browser/components/sessionstore/test/marionette/manifest.toml @@ -0,0 +1,19 @@ +[DEFAULT] +tags = "local" + +["test_persist_closed_tabs_restore_manually.py"] + +["test_restore_loading_tab.py"] + +["test_restore_manually.py"] + +["test_restore_manually_with_pinned_tabs.py"] + +["test_restore_windows_after_close_last_tabs.py"] +skip-if = ["os == 'mac'"] + +["test_restore_windows_after_restart_and_quit.py"] + +["test_restore_windows_after_windows_shutdown.py"] +run-if = ["os == 'win'"] +skip-if = ["win11_2009"] # Bug 1727691 diff --git a/browser/components/sessionstore/test/marionette/session_store_test_case.py b/browser/components/sessionstore/test/marionette/session_store_test_case.py new file mode 100644 index 0000000000..3bcbcd3f56 --- /dev/null +++ b/browser/components/sessionstore/test/marionette/session_store_test_case.py @@ -0,0 +1,432 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from urllib.parse import quote + +from marionette_driver import Wait, errors +from marionette_driver.keys import Keys +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +# Each list element represents a window of tabs loaded at +# some testing URL +DEFAULT_WINDOWS = set( + [ + # Window 1. Note the comma after the inline call - + # this is Python's way of declaring a 1 item tuple. + (inline("""Lorem
    """),), + # Window 2 + ( + inline("""ipsum"""), + inline("""dolor"""), + ), + # Window 3 + ( + inline("""sit"""), + inline("""amet"""), + ), + ] +) + + +class SessionStoreTestCase(WindowManagerMixin, MarionetteTestCase): + def setUp( + self, + startup_page=1, + include_private=True, + restore_on_demand=False, + no_auto_updates=True, + win_register_restart=False, + test_windows=DEFAULT_WINDOWS, + ): + super(SessionStoreTestCase, self).setUp() + self.marionette.set_context("chrome") + + platform = self.marionette.session_capabilities["platformName"] + self.accelKey = Keys.META if platform == "mac" else Keys.CONTROL + + self.test_windows = test_windows + + self.private_windows = set( + [ + ( + inline("""consectetur"""), + inline("""ipsum"""), + ), + ( + inline("""adipiscing"""), + inline("""consectetur"""), + ), + ] + ) + + self.marionette.enforce_gecko_prefs( + { + # Set browser restore previous session pref, + # depending on what the test requires. + "browser.startup.page": startup_page, + # Make the content load right away instead of waiting for + # the user to click on the background tabs + "browser.sessionstore.restore_on_demand": restore_on_demand, + # Avoid race conditions by having the content process never + # send us session updates unless the parent has explicitly asked + # for them via the TabStateFlusher. + "browser.sessionstore.debug.no_auto_updates": no_auto_updates, + # Whether to enable the register application restart mechanism. + "toolkit.winRegisterApplicationRestart": win_register_restart, + } + ) + + self.all_windows = self.test_windows.copy() + self.open_windows(self.test_windows) + + if include_private: + self.all_windows.update(self.private_windows) + self.open_windows(self.private_windows, is_private=True) + + def tearDown(self): + try: + # Create a fresh profile for subsequent tests. + self.marionette.restart(in_app=False, clean=True) + finally: + super(SessionStoreTestCase, self).tearDown() + + def open_windows(self, window_sets, is_private=False): + """Open a set of windows with tabs pointing at some URLs. + + @param window_sets (list) + A set of URL tuples. Each tuple within window_sets + represents a window, and each URL in the URL + tuples represents what will be loaded in a tab. + + Note that if is_private is False, then the first + URL tuple will be opened in the current window, and + subequent tuples will be opened in new windows. + + Example: + + set( + (self.marionette.absolute_url('layout/mozilla_1.html'), + self.marionette.absolute_url('layout/mozilla_2.html')), + + (self.marionette.absolute_url('layout/mozilla_3.html'), + self.marionette.absolute_url('layout/mozilla_4.html')), + ) + + This would take the currently open window, and load + mozilla_1.html and mozilla_2.html in new tabs. It would + then open a new, second window, and load tabs at + mozilla_3.html and mozilla_4.html. + @param is_private (boolean, optional) + Whether or not any new windows should be a private browsing + windows. + """ + if is_private: + win = self.open_window(private=True) + self.marionette.switch_to_window(win) + else: + win = self.marionette.current_chrome_window_handle + + for index, urls in enumerate(window_sets): + if index > 0: + win = self.open_window(private=is_private) + self.marionette.switch_to_window(win) + self.open_tabs(win, urls) + + def open_tabs(self, win, urls): + """Open a set of URLs inside a window in new tabs. + + @param win (browser window) + The browser window to load the tabs in. + @param urls (tuple) + A tuple of URLs to load in this window. The + first URL will be loaded in the currently selected + browser tab. Subsequent URLs will be loaded in + new tabs. + """ + # If there are any remaining URLs for this window, + # open some new tabs and navigate to them. + with self.marionette.using_context("content"): + if isinstance(urls, str): + self.marionette.navigate(urls) + else: + for index, url in enumerate(urls): + if index > 0: + tab = self.open_tab() + self.marionette.switch_to_window(tab) + self.marionette.navigate(url) + + def wait_for_windows(self, expected_windows, message, timeout=5): + current_windows = None + + def check(_): + nonlocal current_windows + current_windows = self.convert_open_windows_to_set() + return current_windows == expected_windows + + try: + wait = Wait(self.marionette, timeout=timeout, interval=0.1) + wait.until(check, message=message) + except errors.TimeoutException as e: + # Update the message to include the most recent list of windows + message = ( + f"{e.message}. Expected {expected_windows}, got {current_windows}." + ) + raise errors.TimeoutException(message) + + def get_urls_for_window(self, win): + orig_handle = self.marionette.current_chrome_window_handle + + try: + with self.marionette.using_context("chrome"): + self.marionette.switch_to_window(win) + return self.marionette.execute_script( + """ + return gBrowser.tabs.map(tab => { + return tab.linkedBrowser.currentURI.spec; + }); + """ + ) + finally: + self.marionette.switch_to_window(orig_handle) + + def convert_open_windows_to_set(self): + # There's no guarantee that Marionette will return us an + # iterator for the opened windows that will match the + # order within our window list. Instead, we'll convert + # the list of URLs within each open window to a set of + # tuples that will allow us to do a direct comparison + # while allowing the windows to be in any order. + opened_windows = set() + for win in self.marionette.chrome_window_handles: + urls = tuple(self.get_urls_for_window(win)) + opened_windows.add(urls) + + return opened_windows + + def _close_tab_shortcut(self): + self.marionette.actions.sequence("key", "keyboard_id").key_down( + self.accelKey + ).key_down("w").key_up("w").key_up(self.accelKey).perform() + + def close_all_tabs_and_restart(self): + self.close_all_tabs() + self.marionette.quit(callback=self._close_tab_shortcut) + self.marionette.start_session() + + def simulate_os_shutdown(self): + """Simulate an OS shutdown. + + :raises: Exception: if not supported on the current platform + :raises: WindowsError: if a Windows API call failed + """ + if self.marionette.session_capabilities["platformName"] != "windows": + raise Exception("Unsupported platform for simulate_os_shutdown") + + self._shutdown_with_windows_restart_manager(self.marionette.process_id) + + def _shutdown_with_windows_restart_manager(self, pid): + """Shut down a process using the Windows Restart Manager. + + When Windows shuts down, it uses a protocol including the + WM_QUERYENDSESSION and WM_ENDSESSION messages to give + applications a chance to shut down safely. The best way to + simulate this is via the Restart Manager, which allows a process + (such as an installer) to use the same mechanism to shut down + any other processes which are using registered resources. + + This function starts a Restart Manager session, registers the + process as a resource, and shuts down the process. + + :param pid: The process id (int) of the process to shutdown + + :raises: WindowsError: if a Windows API call fails + """ + import ctypes + from ctypes import POINTER, WINFUNCTYPE, Structure, WinError, pointer, windll + from ctypes.wintypes import BOOL, DWORD, HANDLE, LPCWSTR, UINT, ULONG, WCHAR + + # set up Windows SDK types + OpenProcess = windll.kernel32.OpenProcess + OpenProcess.restype = HANDLE + OpenProcess.argtypes = [ + DWORD, # dwDesiredAccess + BOOL, # bInheritHandle + DWORD, + ] # dwProcessId + PROCESS_QUERY_INFORMATION = 0x0400 + + class FILETIME(Structure): + _fields_ = [("dwLowDateTime", DWORD), ("dwHighDateTime", DWORD)] + + LPFILETIME = POINTER(FILETIME) + + GetProcessTimes = windll.kernel32.GetProcessTimes + GetProcessTimes.restype = BOOL + GetProcessTimes.argtypes = [ + HANDLE, # hProcess + LPFILETIME, # lpCreationTime + LPFILETIME, # lpExitTime + LPFILETIME, # lpKernelTime + LPFILETIME, + ] # lpUserTime + + ERROR_SUCCESS = 0 + + class RM_UNIQUE_PROCESS(Structure): + _fields_ = [("dwProcessId", DWORD), ("ProcessStartTime", FILETIME)] + + RmStartSession = windll.rstrtmgr.RmStartSession + RmStartSession.restype = DWORD + RmStartSession.argtypes = [ + POINTER(DWORD), # pSessionHandle + DWORD, # dwSessionFlags + POINTER(WCHAR), + ] # strSessionKey + + class GUID(ctypes.Structure): + _fields_ = [ + ("Data1", ctypes.c_ulong), + ("Data2", ctypes.c_ushort), + ("Data3", ctypes.c_ushort), + ("Data4", ctypes.c_ubyte * 8), + ] + + CCH_RM_SESSION_KEY = ctypes.sizeof(GUID) * 2 + + RmRegisterResources = windll.rstrtmgr.RmRegisterResources + RmRegisterResources.restype = DWORD + RmRegisterResources.argtypes = [ + DWORD, # dwSessionHandle + UINT, # nFiles + POINTER(LPCWSTR), # rgsFilenames + UINT, # nApplications + POINTER(RM_UNIQUE_PROCESS), # rgApplications + UINT, # nServices + POINTER(LPCWSTR), + ] # rgsServiceNames + + RM_WRITE_STATUS_CALLBACK = WINFUNCTYPE(None, UINT) + RmShutdown = windll.rstrtmgr.RmShutdown + RmShutdown.restype = DWORD + RmShutdown.argtypes = [ + DWORD, # dwSessionHandle + ULONG, # lActionFlags + RM_WRITE_STATUS_CALLBACK, + ] # fnStatus + + RmEndSession = windll.rstrtmgr.RmEndSession + RmEndSession.restype = DWORD + RmEndSession.argtypes = [DWORD] # dwSessionHandle + + # Get the info needed to uniquely identify the process + hProc = OpenProcess(PROCESS_QUERY_INFORMATION, False, pid) + if not hProc: + raise WinError() + + creationTime = FILETIME() + exitTime = FILETIME() + kernelTime = FILETIME() + userTime = FILETIME() + if not GetProcessTimes( + hProc, + pointer(creationTime), + pointer(exitTime), + pointer(kernelTime), + pointer(userTime), + ): + raise WinError() + + # Start the Restart Manager Session + dwSessionHandle = DWORD() + sessionKeyType = WCHAR * (CCH_RM_SESSION_KEY + 1) + sessionKey = sessionKeyType() + if RmStartSession(pointer(dwSessionHandle), 0, sessionKey) != ERROR_SUCCESS: + raise WinError() + + try: + UProcs_count = 1 + UProcsArrayType = RM_UNIQUE_PROCESS * UProcs_count + UProcs = UProcsArrayType(RM_UNIQUE_PROCESS(pid, creationTime)) + + # Register the process as a resource + if ( + RmRegisterResources( + dwSessionHandle, 0, None, UProcs_count, UProcs, 0, None + ) + != ERROR_SUCCESS + ): + raise WinError() + + # Shut down all processes using registered resources + if ( + RmShutdown( + dwSessionHandle, 0, ctypes.cast(None, RM_WRITE_STATUS_CALLBACK) + ) + != ERROR_SUCCESS + ): + raise WinError() + + finally: + RmEndSession(dwSessionHandle) + + def windows_shutdown_with_variety(self, restart_by_os, expect_restore): + """Test restoring windows after Windows shutdown. + + Opens a set of windows, both standard and private, with + some number of tabs in them. Once the tabs have loaded, shuts down + the browser with the Windows Restart Manager and restarts the browser. + + This specifically exercises the Windows synchronous shutdown mechanism, + which terminates the process in response to the Restart Manager's + WM_ENDSESSION message. + + If restart_by_os is True, the -os-restarted arg is passed when restarting, + simulating being automatically restarted by the Restart Manager. + + If expect_restore is True, this ensures that the standard tabs have been + restored, and that the private ones have not. Otherwise it ensures that + no tabs and windows have been restored. + """ + current_windows_set = self.convert_open_windows_to_set() + self.assertEqual( + current_windows_set, + self.all_windows, + msg="Not all requested windows have been opened. Expected {}, got {}.".format( + self.all_windows, current_windows_set + ), + ) + + self.marionette.quit(callback=lambda: self.simulate_os_shutdown()) + + saved_args = self.marionette.instance.app_args + try: + if restart_by_os: + self.marionette.instance.app_args = ["-os-restarted"] + + self.marionette.start_session() + self.marionette.set_context("chrome") + finally: + self.marionette.instance.app_args = saved_args + + if expect_restore: + self.wait_for_windows( + self.test_windows, + "Non private browsing windows should have been restored", + ) + else: + self.assertEqual( + len(self.marionette.chrome_window_handles), + 1, + msg="Windows from last session shouldn`t have been restored.", + ) + self.assertEqual( + len(self.marionette.window_handles), + 1, + msg="Tabs from last session shouldn`t have been restored.", + ) diff --git a/browser/components/sessionstore/test/marionette/test_persist_closed_tabs_restore_manually.py b/browser/components/sessionstore/test/marionette/test_persist_closed_tabs_restore_manually.py new file mode 100644 index 0000000000..9aa2a3871f --- /dev/null +++ b/browser/components/sessionstore/test/marionette/test_persist_closed_tabs_restore_manually.py @@ -0,0 +1,225 @@ +# 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 os +import sys + +# add this directory to the path +sys.path.append(os.path.dirname(__file__)) + +from marionette_driver import Wait, errors +from session_store_test_case import SessionStoreTestCase + + +def inline(title): + return "data:text/html;charset=utf-8,{}".format( + title + ) + + +class TestSessionRestoreClosedTabs(SessionStoreTestCase): + """ + Test that closed tabs persist between sessions and + that any previously open tabs are added to the recently + closed tab list. When a previous session is restored, + an open tab is restored and removed from the closed tabs list. + + If additional tabs are opened (and closed) before a previous + session is restored, those should be merged with the restored open + and closed tabs, preserving their state. + """ + + def setUp(self): + super(TestSessionRestoreClosedTabs, self).setUp( + startup_page=1, + include_private=False, + restore_on_demand=True, + test_windows=set( + [ + # Window 1 + ( + inline("lorem ipsom"), + inline("dolor"), + ), + ] + ), + ) + + def test_restore(self): + self.marionette.execute_script( + """ + Services.prefs.setBoolPref("browser.sessionstore.persist_closed_tabs_between_sessions", true); + """ + ) + + self.wait_for_windows( + self.all_windows, "Not all requested windows have been opened" + ) + # Close the second tab leaving the first tab open + self.marionette.execute_async_script( + """ + let resolve = arguments[0]; + let tab = gBrowser.tabs[1]; + gBrowser.removeTab(tab); + let { TabStateFlusher } = ChromeUtils.importESModule("resource:///modules/sessionstore/TabStateFlusher.sys.mjs"); + TabStateFlusher.flush(tab).then(resolve); + """ + ) + + self.marionette.quit() + self.marionette.start_session() + self.marionette.set_context("chrome") + + self.assertEqual( + self.marionette.execute_script( + """ + let { SessionStore } = ChromeUtils.importESModule("resource:///modules/sessionstore/SessionStore.sys.mjs"); + let state = JSON.parse(SessionStore.getBrowserState()); + return state.windows[0]._closedTabs.length; + """ + ), + 2, + msg="Should have 2 closed tabs after restart.", + ) + + self.assertEqual( + self.marionette.execute_script( + """ + let { SessionStore } = ChromeUtils.importESModule("resource:///modules/sessionstore/SessionStore.sys.mjs"); + let state = JSON.parse(SessionStore.getBrowserState()); + return state.windows[0]._closedTabs[0].removeAfterRestore; + """ + ), + True, + msg="Previously open tab that was added to closedTabs should have removeAfterRestore property.", + ) + + # open two new tabs, the second one will be closed + win = self.marionette.current_chrome_window_handle + self.open_tabs(win, (inline("sit"), inline("amet"))) + + self.assertEqual( + self.marionette.execute_script( + """ + return gBrowser.tabs[0].label + """ + ), + "sit", + msg="First open tab should now be sit", + ) + + self.assertEqual( + self.marionette.execute_script( + """ + return gBrowser.tabs[1].label + """ + ), + "amet", + msg="Second open tab should be amet", + ) + + self.assertEqual( + self.marionette.execute_script( + """ + return gBrowser.tabs.length + """ + ), + 2, + msg="should have 2 tabs open", + ) + + self.marionette.execute_async_script( + """ + let resolve = arguments[0]; + let tab = gBrowser.tabs[1]; + gBrowser.removeTab(tab); + let { TabStateFlusher } = ChromeUtils.importESModule("resource:///modules/sessionstore/TabStateFlusher.sys.mjs"); + TabStateFlusher.flush(tab).then(resolve); + """ + ) + + self.wait_for_tabcount(1, "Waiting for 1 tabs") + + # restore the previous session + self.assertEqual( + self.marionette.execute_script( + """ + const lazy = {}; + ChromeUtils.defineESModuleGetters(lazy, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + }); + function observeClosedTabsChange() { + return new Promise(resolve => { + function observe(subject, topic, data) { + if (topic == "sessionstore-closed-objects-changed") { + Services.obs.removeObserver(this, "sessionstore-closed-objects-changed"); + resolve('observed closed objects changed'); + }; + } + Services.obs.addObserver(observe, "sessionstore-closed-objects-changed"); + }); + }; + + async function checkForClosedTabs() { + let closedTabsObserver = observeClosedTabsChange(); + lazy.SessionStore.restoreLastSession(); + await closedTabsObserver; + let state = JSON.parse(lazy.SessionStore.getBrowserState()); + return state.windows[0]._closedTabs.length; + } + return checkForClosedTabs(); + """ + ), + 2, + msg="Should have 2 closed tab after restoring session.", + ) + + self.wait_for_tabcount(2, "Waiting for 2 tabs") + + self.assertEqual( + self.marionette.execute_script( + """ + return gBrowser.tabs[0].label + """ + ), + "sit", + msg="Newly opened tab should still exist", + ) + + self.assertEqual( + self.marionette.execute_script( + """ + return gBrowser.tabs[1].label + """ + ), + "lorem ipsom", + msg="The open tab from the previous session should be restored", + ) + + # temporary until we remove this pref + self.marionette.execute_script( + """ + Services.prefs.clearUserPref("browser.sessionstore.persist_closed_tabs_between_sessions"); + """ + ) + + def wait_for_tabcount(self, expected_tabcount, message, timeout=5): + current_tabcount = None + + def check(_): + nonlocal current_tabcount + current_tabcount = self.marionette.execute_script( + "return gBrowser.tabs.length;" + ) + return current_tabcount == expected_tabcount + + try: + wait = Wait(self.marionette, timeout=timeout, interval=0.1) + wait.until(check, message=message) + except errors.TimeoutException as e: + # Update the message to include the most recent list of windows + message = ( + f"{e.message}. Expected {expected_tabcount}, got {current_tabcount}." + ) + raise errors.TimeoutException(message) diff --git a/browser/components/sessionstore/test/marionette/test_restore_loading_tab.py b/browser/components/sessionstore/test/marionette/test_restore_loading_tab.py new file mode 100644 index 0000000000..f053081b02 --- /dev/null +++ b/browser/components/sessionstore/test/marionette/test_restore_loading_tab.py @@ -0,0 +1,69 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from urllib.parse import quote + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestRestoreLoadingPage(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestRestoreLoadingPage, self).setUp() + self.delayed_page = self.marionette.absolute_url("slow") + + def do_test(self, html, is_restoring_expected): + self.marionette.navigate(inline(html.format(self.delayed_page))) + link = self.marionette.find_element("id", "link") + link.click() + + self.marionette.restart(in_app=True) + + with self.marionette.using_context("chrome"): + urls = self.marionette.execute_script( + "return gBrowser.tabs.map(t => t.linkedBrowser.currentURI.spec);" + ) + + if is_restoring_expected: + self.assertEqual( + len(urls), + 2, + msg="The tab opened should be restored", + ) + self.assertEqual( + urls[1], + self.delayed_page, + msg="The tab restored is correct", + ) + else: + self.assertEqual( + len(urls), + 1, + msg="The tab opened should not be restored", + ) + + self.close_all_tabs() + + def test_target_blank(self): + self.do_test("click", True) + + def test_target_other(self): + self.do_test("click", False) + + def test_by_script(self): + self.do_test( + """ + click + + """, + False, + ) diff --git a/browser/components/sessionstore/test/marionette/test_restore_manually.py b/browser/components/sessionstore/test/marionette/test_restore_manually.py new file mode 100644 index 0000000000..e3c0a83607 --- /dev/null +++ b/browser/components/sessionstore/test/marionette/test_restore_manually.py @@ -0,0 +1,144 @@ +# 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 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,{}".format( + title + ) + + +class TestSessionRestoreManually(SessionStoreTestCase): + """ + Test that window attributes for each window are restored + correctly (with manual session restore) in a new session. + """ + + def setUp(self): + super(TestSessionRestoreManually, self).setUp( + startup_page=1, + include_private=False, + restore_on_demand=True, + test_windows=set( + [ + # Window 1 + ( + inline("lorem ipsom"), + inline("dolor"), + ), + # Window 2 + (inline("sit"),), + ] + ), + ) + + def test_restore(self): + self.marionette.execute_script( + """ + Services.prefs.setBoolPref("browser.sessionstore.persist_closed_tabs_between_sessions", true); + """ + ) + + self.wait_for_windows( + self.all_windows, "Not all requested windows have been opened" + ) + + self.assertEqual( + len(self.marionette.chrome_window_handles), + 2, + msg="Should have 3 windows open.", + ) + self.marionette.execute_async_script( + """ + function getAllBrowserWindows() { + return Array.from(Services.wm.getEnumerator("navigator:browser")); + } + function promiseResize(value, win) { + let deferred = Promise.withResolvers(); + let id; + function listener() { + win.clearTimeout(id); + if (win.innerWidth <= value) { + id = win.setTimeout(() => { + win.removeEventListener("resize", listener); + deferred.resolve() + }, 100); + } + } + if (win.innerWidth > value) { + win.addEventListener("resize", listener); + win.resizeTo(value, value); + } else { + deferred.resolve() + } + return deferred.promise; + } + + let resolve = arguments[0]; + let windows = getAllBrowserWindows(); + let value = 500; + promiseResize(value, windows[1]).then(resolve); + """ + ) + + self.marionette.quit() + self.marionette.start_session() + self.marionette.set_context("chrome") + + # restore the previous session + self.marionette.execute_script( + """ + const lazy = {}; + ChromeUtils.defineESModuleGetters(lazy, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + }); + function observeClosedObjectsChange() { + return new Promise(resolve => { + function observe(subject, topic, data) { + if (topic == "sessionstore-closed-objects-changed") { + Services.obs.removeObserver(this, "sessionstore-closed-objects-changed"); + resolve('observed closed objects changed'); + }; + } + Services.obs.addObserver(observe, "sessionstore-closed-objects-changed"); + }); + }; + + async function checkForWindowHeight() { + let closedWindowsObserver = observeClosedObjectsChange(); + lazy.SessionStore.restoreLastSession(); + await closedWindowsObserver; + } + checkForWindowHeight(); + """ + ) + + self.assertEqual( + len(self.marionette.chrome_window_handles), + 2, + msg="Windows from last session have 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[1]["height"] + """ + ), + 500, + "Second window has been restored to the correct height.", + ) diff --git a/browser/components/sessionstore/test/marionette/test_restore_manually_with_pinned_tabs.py b/browser/components/sessionstore/test/marionette/test_restore_manually_with_pinned_tabs.py new file mode 100644 index 0000000000..fa00c25a4c --- /dev/null +++ b/browser/components/sessionstore/test/marionette/test_restore_manually_with_pinned_tabs.py @@ -0,0 +1,108 @@ +# 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 os +import sys +from urllib.parse import quote + +# add this directory to the path +sys.path.append(os.path.dirname(__file__)) + +from marionette_driver import Wait, errors +from session_store_test_case import SessionStoreTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestSessionRestoreWithPinnedTabs(SessionStoreTestCase): + def setUp(self): + super(TestSessionRestoreWithPinnedTabs, self).setUp( + startup_page=1, + include_private=False, + restore_on_demand=True, + test_windows=set( + [ + # Window 1 + ( + inline("""ipsum"""), + inline("""dolor"""), + inline("""amet"""), + ), + ] + ), + ) + + def test_no_restore_with_quit(self): + self.wait_for_windows( + self.all_windows, "Not all requested windows have been opened" + ) + # add pinned tab in first window. + self.marionette.execute_async_script( + """ + let resolve = arguments[0]; + gBrowser.pinTab(gBrowser.tabs[0]); + let { TabStateFlusher } = ChromeUtils.importESModule("resource:///modules/sessionstore/TabStateFlusher.sys.mjs"); + TabStateFlusher.flush(gBrowser.tabs[0]).then(resolve); + """ + ) + + self.marionette.quit() + self.marionette.start_session() + self.marionette.set_context("chrome") + + self.assertEqual( + self.marionette.execute_script("return gBrowser.tabs.length"), + 2, + msg="Should have 2 tabs.", + ) + + self.assertEqual( + self.marionette.execute_script( + "return gBrowser.tabs.filter(t => t.pinned).length" + ), + 1, + msg="Pinned tab should have been restored.", + ) + + self.marionette.execute_script( + """ + SessionStore.restoreLastSession(); + """ + ) + self.wait_for_tabcount(3, "Waiting for 3 tabs") + + self.assertEqual( + self.marionette.execute_script("return gBrowser.tabs.length"), + 3, + msg="Should have 2 tabs.", + ) + self.assertEqual( + self.marionette.execute_script( + "return gBrowser.tabs.filter(t => t.pinned).length" + ), + 1, + msg="Should still have 1 pinned tab", + ) + + def wait_for_tabcount(self, expected_tabcount, message, timeout=5): + current_tabcount = None + + def check(_): + nonlocal current_tabcount + current_tabcount = self.marionette.execute_script( + "return gBrowser.tabs.length;" + ) + return current_tabcount == expected_tabcount + + try: + wait = Wait(self.marionette, timeout=timeout, interval=0.1) + wait.until(check, message=message) + except errors.TimeoutException as e: + # Update the message to include the most recent list of windows + message = ( + f"{e.message}. Expected {expected_tabcount}, got {current_tabcount}." + ) + raise errors.TimeoutException(message) diff --git a/browser/components/sessionstore/test/marionette/test_restore_windows_after_close_last_tabs.py b/browser/components/sessionstore/test/marionette/test_restore_windows_after_close_last_tabs.py new file mode 100644 index 0000000000..2022d8fb87 --- /dev/null +++ b/browser/components/sessionstore/test/marionette/test_restore_windows_after_close_last_tabs.py @@ -0,0 +1,59 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# add this directory to the path +import os +import sys + +sys.path.append(os.path.dirname(__file__)) + +from session_store_test_case import SessionStoreTestCase + + +class TestSessionStoreEnabledAllWindows(SessionStoreTestCase): + def setUp(self, include_private=True): + """Setup for the test, enabling session restore. + + :param include_private: Whether to open private windows. + """ + super(TestSessionStoreEnabledAllWindows, self).setUp( + include_private=include_private, startup_page=3 + ) + + def test_with_variety(self): + """Test opening and restoring both standard and private windows. + + Opens a set of windows, both standard and private, with + some number of tabs in them. Once the tabs have loaded, restarts + the browser, and then ensures that the standard tabs have been + restored, and that the private ones have not. + """ + self.wait_for_windows( + self.all_windows, "Not all requested windows have been opened" + ) + + self.marionette.quit() + self.marionette.start_session() + + self.wait_for_windows( + self.test_windows, "Non private browsing windows should have been restored" + ) + + def test_close_tabs(self): + self.wait_for_windows( + self.all_windows, "Not all requested windows have been opened" + ) + + self.close_all_tabs_and_restart() + + self.assertEqual( + len(self.marionette.chrome_window_handles), + 1, + msg="Windows from last session shouldn`t have been restored.", + ) + self.assertEqual( + len(self.marionette.window_handles), + 1, + msg="Tabs from last session shouldn`t have been restored.", + ) diff --git a/browser/components/sessionstore/test/marionette/test_restore_windows_after_restart_and_quit.py b/browser/components/sessionstore/test/marionette/test_restore_windows_after_restart_and_quit.py new file mode 100644 index 0000000000..3dd9dc1bf3 --- /dev/null +++ b/browser/components/sessionstore/test/marionette/test_restore_windows_after_restart_and_quit.py @@ -0,0 +1,88 @@ +# 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/. + +# add this directory to the path +import os +import sys + +sys.path.append(os.path.dirname(__file__)) + +from session_store_test_case import SessionStoreTestCase + + +def inline(title): + return "data:text/html;charset=utf-8,{}".format( + title + ) + + +class TestSessionStoreEnabledAllWindows(SessionStoreTestCase): + def setUp(self, include_private=True): + """Setup for the test, enabling session restore. + + :param include_private: Whether to open private windows. + """ + super(TestSessionStoreEnabledAllWindows, self).setUp( + include_private=include_private, startup_page=3 + ) + + def test_with_variety(self): + """Test opening and restoring both standard and private windows. + + Opens a set of windows, both standard and private, with + some number of tabs in them. Once the tabs have loaded, restarts + the browser, and then ensures that the standard tabs have been + restored, and that the private ones have not. + """ + self.wait_for_windows( + self.all_windows, "Not all requested windows have been opened" + ) + + self.marionette.quit() + self.marionette.start_session() + self.marionette.set_context("chrome") + + self.wait_for_windows( + self.test_windows, "Non private browsing windows should have been restored" + ) + + +class TestSessionStoreEnabledNoPrivateWindows(TestSessionStoreEnabledAllWindows): + def setUp(self): + super(TestSessionStoreEnabledNoPrivateWindows, self).setUp( + include_private=False + ) + + +class TestSessionStoreDisabled(SessionStoreTestCase): + def test_no_restore_with_quit(self): + self.wait_for_windows( + self.all_windows, "Not all requested windows have been opened" + ) + + self.marionette.quit() + self.marionette.start_session() + self.marionette.set_context("chrome") + + self.assertEqual( + len(self.marionette.chrome_window_handles), + 1, + msg="Windows from last session shouldn`t have been restored.", + ) + self.assertEqual( + len(self.marionette.window_handles), + 1, + msg="Tabs from last session shouldn`t have been restored.", + ) + + def test_restore_with_restart(self): + self.wait_for_windows( + self.all_windows, "Not all requested windows have been opened" + ) + + self.marionette.restart(in_app=True) + + self.wait_for_windows( + self.test_windows, "Non private browsing windows should have been restored" + ) diff --git a/browser/components/sessionstore/test/marionette/test_restore_windows_after_windows_shutdown.py b/browser/components/sessionstore/test/marionette/test_restore_windows_after_windows_shutdown.py new file mode 100644 index 0000000000..21eec455bb --- /dev/null +++ b/browser/components/sessionstore/test/marionette/test_restore_windows_after_windows_shutdown.py @@ -0,0 +1,66 @@ +# 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/. + +# add this directory to the path +import os +import sys + +sys.path.append(os.path.dirname(__file__)) + +from session_store_test_case import SessionStoreTestCase + +# We test the following combinations with simulated Windows shutdown: +# - Start page = restore session (expect restore in all cases) +# - RAR (toolkit.winRegisterApplicationRestart) disabled +# - RAR enabled, restarted manually +# +# - Start page = home +# - RAR disabled (no restore) +# - RAR enabled: +# - restarted by OS (restore) +# - restarted manually (no restore) + + +class TestWindowsShutdown(SessionStoreTestCase): + def setUp(self): + super(TestWindowsShutdown, self).setUp(startup_page=3, no_auto_updates=False) + + def test_with_variety(self): + """Test session restore selected by user.""" + self.windows_shutdown_with_variety(restart_by_os=False, expect_restore=True) + + +class TestWindowsShutdownRegisterRestart(SessionStoreTestCase): + def setUp(self): + super(TestWindowsShutdownRegisterRestart, self).setUp( + startup_page=3, no_auto_updates=False, win_register_restart=True + ) + + def test_manual_restart(self): + """Test that restore tabs works in case of register restart failure.""" + self.windows_shutdown_with_variety(restart_by_os=False, expect_restore=True) + + +class TestWindowsShutdownNormal(SessionStoreTestCase): + def setUp(self): + super(TestWindowsShutdownNormal, self).setUp(no_auto_updates=False) + + def test_with_variety(self): + """Test that windows are not restored on a normal restart.""" + self.windows_shutdown_with_variety(restart_by_os=False, expect_restore=False) + + +class TestWindowsShutdownForcedSessionRestore(SessionStoreTestCase): + def setUp(self): + super(TestWindowsShutdownForcedSessionRestore, self).setUp( + no_auto_updates=False, win_register_restart=True + ) + + def test_os_restart(self): + """Test that register application restart restores the session.""" + self.windows_shutdown_with_variety(restart_by_os=True, expect_restore=True) + + def test_manual_restart(self): + """Test that OS shutdown is ignored on manual start.""" + self.windows_shutdown_with_variety(restart_by_os=False, expect_restore=False) diff --git a/browser/components/sessionstore/test/restore_redirect_http.html b/browser/components/sessionstore/test/restore_redirect_http.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/browser/components/sessionstore/test/restore_redirect_http.html^headers^ b/browser/components/sessionstore/test/restore_redirect_http.html^headers^ new file mode 100644 index 0000000000..533bda36f3 --- /dev/null +++ b/browser/components/sessionstore/test/restore_redirect_http.html^headers^ @@ -0,0 +1,2 @@ +HTTP 302 Moved Temporarily +Location: restore_redirect_target.html diff --git a/browser/components/sessionstore/test/restore_redirect_js.html b/browser/components/sessionstore/test/restore_redirect_js.html new file mode 100644 index 0000000000..f0130847b6 --- /dev/null +++ b/browser/components/sessionstore/test/restore_redirect_js.html @@ -0,0 +1,10 @@ + + + + + + + diff --git a/browser/components/sessionstore/test/restore_redirect_target.html b/browser/components/sessionstore/test/restore_redirect_target.html new file mode 100644 index 0000000000..813af05508 --- /dev/null +++ b/browser/components/sessionstore/test/restore_redirect_target.html @@ -0,0 +1,8 @@ + + + + +Test page + +Test page + diff --git a/browser/components/sessionstore/test/unit/data/sessionCheckpoints_all.json b/browser/components/sessionstore/test/unit/data/sessionCheckpoints_all.json new file mode 100644 index 0000000000..e02c421c3b --- /dev/null +++ b/browser/components/sessionstore/test/unit/data/sessionCheckpoints_all.json @@ -0,0 +1,11 @@ +{ + "profile-after-change": true, + "final-ui-startup": true, + "sessionstore-windows-restored": true, + "quit-application-granted": true, + "quit-application": true, + "sessionstore-final-state-write-complete": true, + "profile-change-net-teardown": true, + "profile-change-teardown": true, + "profile-before-change": true +} diff --git a/browser/components/sessionstore/test/unit/data/sessionstore_invalid.js b/browser/components/sessionstore/test/unit/data/sessionstore_invalid.js new file mode 100644 index 0000000000..a8c3ff2ff9 --- /dev/null +++ b/browser/components/sessionstore/test/unit/data/sessionstore_invalid.js @@ -0,0 +1,3 @@ +{ + "windows": // invalid json +} diff --git a/browser/components/sessionstore/test/unit/data/sessionstore_valid.js b/browser/components/sessionstore/test/unit/data/sessionstore_valid.js new file mode 100644 index 0000000000..f9511f29f6 --- /dev/null +++ b/browser/components/sessionstore/test/unit/data/sessionstore_valid.js @@ -0,0 +1,3 @@ +{ + "windows": [] +} \ No newline at end of file diff --git a/browser/components/sessionstore/test/unit/head.js b/browser/components/sessionstore/test/unit/head.js new file mode 100644 index 0000000000..b342a886fb --- /dev/null +++ b/browser/components/sessionstore/test/unit/head.js @@ -0,0 +1,36 @@ +ChromeUtils.defineESModuleGetters(this, { + SessionStartup: "resource:///modules/sessionstore/SessionStartup.sys.mjs", +}); + +// Call a function once initialization of SessionStartup is complete +function afterSessionStartupInitialization(cb) { + info("Waiting for session startup initialization"); + let observer = function () { + try { + info("Session startup initialization observed"); + Services.obs.removeObserver(observer, "sessionstore-state-finalized"); + cb(); + } catch (ex) { + do_throw(ex); + } + }; + Services.obs.addObserver(observer, "sessionstore-state-finalized"); + + // We need the Crash Monitor initialized for sessionstartup to run + // successfully. + const { CrashMonitor } = ChromeUtils.importESModule( + "resource://gre/modules/CrashMonitor.sys.mjs" + ); + CrashMonitor.init(); + + // Start sessionstartup initialization. + SessionStartup.init(); +} + +// Compress the source file using lz4 and put the result to destination file. +// After that, source file is deleted. +async function writeCompressedFile(source, destination) { + let s = await IOUtils.read(source); + await IOUtils.write(destination, s, { compress: true }); + await IOUtils.remove(source); +} diff --git a/browser/components/sessionstore/test/unit/test_backup_once.js b/browser/components/sessionstore/test/unit/test_backup_once.js new file mode 100644 index 0000000000..db566491d5 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_backup_once.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SessionWriter } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionWriter.sys.mjs" +); + +// Make sure that we have a profile before initializing SessionFile. +const profd = do_get_profile(); +const { SessionFile } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" +); +const Paths = SessionFile.Paths; + +// We need a XULAppInfo to initialize SessionFile +const { updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" +); +updateAppInfo({ + name: "SessionRestoreTest", + ID: "{230de50e-4cd1-11dc-8314-0800200c9a66}", + version: "1", + platformVersion: "", +}); + +add_setup(async function () { + let source = do_get_file("data/sessionstore_valid.js"); + source.copyTo(profd, "sessionstore.js"); + await writeCompressedFile(Paths.clean.replace("jsonlz4", "js"), Paths.clean); + + // Finish initialization of SessionFile + await SessionFile.read(); +}); + +function promise_check_exist(path, shouldExist) { + return (async function () { + info( + "Ensuring that " + path + (shouldExist ? " exists" : " does not exist") + ); + if ((await IOUtils.exists(path)) != shouldExist) { + throw new Error( + "File" + path + " should " + (shouldExist ? "exist" : "not exist") + ); + } + })(); +} + +function promise_check_contents(path, expect) { + return (async function () { + info("Checking whether " + path + " has the right contents"); + let actual = await IOUtils.readJSON(path, { + decompress: true, + }); + Assert.deepEqual( + actual, + expect, + `File ${path} contains the expected data.` + ); + })(); +} + +function generateFileContents(id) { + let url = `http://example.com/test_backup_once#${id}_${Math.random()}`; + return { windows: [{ tabs: [{ entries: [{ url }], index: 1 }] }] }; +} + +// Write to the store, and check that it creates: +// - $Path.recovery with the new data +// - $Path.nextUpgradeBackup with the old data +add_task(async function test_first_write_backup() { + let initial_content = generateFileContents("initial"); + let new_content = generateFileContents("test_1"); + + info("Before the first write, none of the files should exist"); + await promise_check_exist(Paths.backups, false); + + await IOUtils.makeDirectory(Paths.backups); + await IOUtils.writeJSON(Paths.clean, initial_content, { + compress: true, + }); + await SessionFile.write(new_content); + + info("After first write, a few files should have been created"); + await promise_check_exist(Paths.backups, true); + await promise_check_exist(Paths.clean, false); + await promise_check_exist(Paths.cleanBackup, true); + await promise_check_exist(Paths.recovery, true); + await promise_check_exist(Paths.recoveryBackup, false); + await promise_check_exist(Paths.nextUpgradeBackup, true); + + await promise_check_contents(Paths.recovery, new_content); + await promise_check_contents(Paths.nextUpgradeBackup, initial_content); +}); + +// Write to the store again, and check that +// - $Path.clean is not written +// - $Path.recovery contains the new data +// - $Path.recoveryBackup contains the previous data +add_task(async function test_second_write_no_backup() { + let new_content = generateFileContents("test_2"); + let previous_backup_content = await IOUtils.readJSON(Paths.recovery, { + decompress: true, + }); + + await IOUtils.remove(Paths.cleanBackup); + + await SessionFile.write(new_content); + + await promise_check_exist(Paths.backups, true); + await promise_check_exist(Paths.clean, false); + await promise_check_exist(Paths.cleanBackup, false); + await promise_check_exist(Paths.recovery, true); + await promise_check_exist(Paths.nextUpgradeBackup, true); + + await promise_check_contents(Paths.recovery, new_content); + await promise_check_contents(Paths.recoveryBackup, previous_backup_content); +}); + +// Make sure that we create $Paths.clean and remove $Paths.recovery* +// upon shutdown +add_task(async function test_shutdown() { + let output = generateFileContents("test_3"); + + await IOUtils.writeUTF8(Paths.recovery, "I should disappear"); + await IOUtils.writeUTF8(Paths.recoveryBackup, "I should also disappear"); + + await SessionWriter.write(output, { + isFinalWrite: true, + performShutdownCleanup: true, + }); + + Assert.ok(!(await IOUtils.exists(Paths.recovery))); + Assert.ok(!(await IOUtils.exists(Paths.recoveryBackup))); + await promise_check_contents(Paths.clean, output); +}); diff --git a/browser/components/sessionstore/test/unit/test_final_write_cleanup.js b/browser/components/sessionstore/test/unit/test_final_write_cleanup.js new file mode 100644 index 0000000000..b3fbd6b206 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_final_write_cleanup.js @@ -0,0 +1,116 @@ +"use strict"; + +/** + * This test ensures that we correctly clean up the session state when + * writing with isFinalWrite, which is used on shutdown. It tests that each + * tab's shistory is capped to a maximum number of preceding and succeeding + * entries. + */ + +const { SessionWriter } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionWriter.sys.mjs" +); + +// Make sure that we have a profile before initializing SessionFile. +do_get_profile(); +const { + SessionFile: { Paths }, +} = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" +); + +const MAX_ENTRIES = 9; +const URL = "http://example.com/#"; + +async function prepareWithLimit(back, fwd) { + SessionWriter.init("empty", false, Paths, { + maxSerializeBack: back, + maxSerializeForward: fwd, + maxUpgradeBackups: 3, + }); + await SessionWriter.wipe(); +} + +add_setup(async function () { + registerCleanupFunction(() => SessionWriter.wipe()); +}); + +function createSessionState(index) { + // Generate the tab state entries and set the one-based + // tab-state index to the middle session history entry. + let tabState = { entries: [], index }; + for (let i = 0; i < MAX_ENTRIES; i++) { + tabState.entries.push({ url: URL + i }); + } + + return { windows: [{ tabs: [tabState] }] }; +} + +async function writeAndParse(state, path, options = {}) { + // We clone here because `write` can change the data passed. + let data = structuredClone(state); + await SessionWriter.write(data, options); + return IOUtils.readJSON(path, { decompress: true }); +} + +add_task(async function test_shistory_cap_none() { + let state = createSessionState(5); + + // Don't limit the number of shistory entries. + await prepareWithLimit(-1, -1); + + // Check that no caps are applied. + let diskState = await writeAndParse(state, Paths.clean, { + isFinalWrite: true, + }); + Assert.deepEqual(state, diskState, "no cap applied"); +}); + +add_task(async function test_shistory_cap_middle() { + let state = createSessionState(5); + await prepareWithLimit(2, 3); + + // Cap is only applied on clean shutdown. + let diskState = await writeAndParse(state, Paths.recovery); + Assert.deepEqual(state, diskState, "no cap applied"); + + // Check that the right number of shistory entries was discarded + // and the shistory index updated accordingly. + diskState = await writeAndParse(state, Paths.clean, { isFinalWrite: true }); + let tabState = state.windows[0].tabs[0]; + tabState.entries = tabState.entries.slice(2, 8); + tabState.index = 3; + Assert.deepEqual(state, diskState, "cap applied"); +}); + +add_task(async function test_shistory_cap_lower_bound() { + let state = createSessionState(1); + await prepareWithLimit(5, 5); + + // Cap is only applied on clean shutdown. + let diskState = await writeAndParse(state, Paths.recovery); + Assert.deepEqual(state, diskState, "no cap applied"); + + // Check that the right number of shistory entries was discarded. + diskState = await writeAndParse(state, Paths.clean, { isFinalWrite: true }); + let tabState = state.windows[0].tabs[0]; + tabState.entries = tabState.entries.slice(0, 6); + Assert.deepEqual(state, diskState, "cap applied"); +}); + +add_task(async function test_shistory_cap_upper_bound() { + let state = createSessionState(MAX_ENTRIES); + await prepareWithLimit(5, 5); + + // Cap is only applied on clean shutdown. + let diskState = await writeAndParse(state, Paths.recovery); + Assert.deepEqual(state, diskState, "no cap applied"); + + // Check that the right number of shistory entries was discarded + // and the shistory index updated accordingly. + diskState = await writeAndParse(state, Paths.clean, { isFinalWrite: true }); + let tabState = state.windows[0].tabs[0]; + tabState.entries = tabState.entries.slice(3); + tabState.index = 6; + Assert.deepEqual(state, diskState, "cap applied"); +}); diff --git a/browser/components/sessionstore/test/unit/test_histogram_corrupt_files.js b/browser/components/sessionstore/test/unit/test_histogram_corrupt_files.js new file mode 100644 index 0000000000..2c469ed3b4 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_histogram_corrupt_files.js @@ -0,0 +1,117 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * The primary purpose of this test is to ensure that + * the sessionstore component records information about + * corrupted backup files into a histogram. + */ + +"use strict"; + +const Telemetry = Services.telemetry; +const HistogramId = "FX_SESSION_RESTORE_ALL_FILES_CORRUPT"; + +// Prepare the session file. +do_get_profile(); +const { SessionFile } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" +); + +/** + * A utility function for resetting the histogram and the contents + * of the backup directory. This will also compress the file using lz4 compression. + */ +function promise_reset_session(backups = {}) { + return (async function () { + // Reset the histogram. + Telemetry.getHistogramById(HistogramId).clear(); + + // Reset the contents of the backups directory + await IOUtils.makeDirectory(SessionFile.Paths.backups); + let basePath = do_get_cwd().path; + for (let key of SessionFile.Paths.loadOrder) { + if (backups.hasOwnProperty(key)) { + let path = backups[key]; + const fullPath = PathUtils.join(basePath, ...path); + let s = await IOUtils.read(fullPath); + await IOUtils.write(SessionFile.Paths[key], s, { + compress: true, + }); + } else { + await IOUtils.remove(SessionFile.Paths[key]); + } + } + })(); +} + +/** + * In order to use FX_SESSION_RESTORE_ALL_FILES_CORRUPT histogram + * it has to be registered in "toolkit/components/telemetry/Histograms.json". + * This test ensures that the histogram is registered and empty. + */ +add_task(async function test_ensure_histogram_exists_and_empty() { + let s = Telemetry.getHistogramById(HistogramId).snapshot(); + Assert.equal(s.sum, 0, "Initially, the sum of probes is 0"); +}); + +/** + * Makes sure that the histogram is negatively updated when no + * backup files are present. + */ +add_task(async function test_no_files_exist() { + // No session files are available to SessionFile. + await promise_reset_session(); + + await SessionFile.read(); + // Checking if the histogram is updated negatively + let h = Telemetry.getHistogramById(HistogramId); + let s = h.snapshot(); + Assert.equal(s.values[0], 1, "One probe for the 'false' bucket."); + Assert.equal(s.values[1], 0, "No probes in the 'true' bucket."); +}); + +/** + * Makes sure that the histogram is negatively updated when at least one + * backup file is not corrupted. + */ +add_task(async function test_one_file_valid() { + // Corrupting some backup files. + let invalidSession = ["data", "sessionstore_invalid.js"]; + let validSession = ["data", "sessionstore_valid.js"]; + await promise_reset_session({ + clean: invalidSession, + cleanBackup: validSession, + recovery: invalidSession, + recoveryBackup: invalidSession, + }); + + await SessionFile.read(); + // Checking if the histogram is updated negatively. + let h = Telemetry.getHistogramById(HistogramId); + let s = h.snapshot(); + Assert.equal(s.values[0], 1, "One probe for the 'false' bucket."); + Assert.equal(s.values[1], 0, "No probes in the 'true' bucket."); +}); + +/** + * Makes sure that the histogram is positively updated when all + * backup files are corrupted. + */ +add_task(async function test_all_files_corrupt() { + // Corrupting all backup files. + let invalidSession = ["data", "sessionstore_invalid.js"]; + await promise_reset_session({ + clean: invalidSession, + cleanBackup: invalidSession, + recovery: invalidSession, + recoveryBackup: invalidSession, + }); + + await SessionFile.read(); + // Checking if the histogram is positively updated. + let h = Telemetry.getHistogramById(HistogramId); + let s = h.snapshot(); + Assert.equal(s.values[1], 1, "One probe for the 'true' bucket."); + Assert.equal(s.values[0], 0, "No probes in the 'false' bucket."); +}); diff --git a/browser/components/sessionstore/test/unit/test_migration_lz4compression.js b/browser/components/sessionstore/test/unit/test_migration_lz4compression.js new file mode 100644 index 0000000000..4d9b700d8b --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_migration_lz4compression.js @@ -0,0 +1,151 @@ +"use strict"; + +const { SessionWriter } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionWriter.sys.mjs" +); + +// Make sure that we have a profile before initializing SessionFile. +const profd = do_get_profile(); +const { SessionFile } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" +); +const Paths = SessionFile.Paths; + +// We need a XULAppInfo to initialize SessionFile +const { updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" +); +updateAppInfo({ + name: "SessionRestoreTest", + ID: "{230de50e-4cd1-11dc-8314-0800200c9a66}", + version: "1", + platformVersion: "", +}); + +function promise_check_exist(path, shouldExist) { + return (async function () { + info( + "Ensuring that " + path + (shouldExist ? " exists" : " does not exist") + ); + if ((await IOUtils.exists(path)) != shouldExist) { + throw new Error( + "File " + path + " should " + (shouldExist ? "exist" : "not exist") + ); + } + })(); +} + +function promise_check_contents(path, expect) { + return (async function () { + info("Checking whether " + path + " has the right contents"); + let actual = await IOUtils.readJSON(path, { + decompress: true, + }); + Assert.deepEqual( + actual, + expect, + `File ${path} contains the expected data.` + ); + })(); +} + +// Check whether the migration from .js to .jslz4 is correct. +add_task(async function test_migration() { + let source = do_get_file("data/sessionstore_valid.js"); + source.copyTo(profd, "sessionstore.js"); + + // Read the content of the session store file. + let parsed = await IOUtils.readJSON(Paths.clean.replace("jsonlz4", "js")); + + // Read the session file with .js extension. + let result = await SessionFile.read(); + + // Check whether the result is what we wanted. + equal(result.origin, "clean"); + equal(result.useOldExtension, true); + Assert.deepEqual( + result.parsed, + parsed, + "result.parsed contains expected data" + ); + + // Initiate a write to ensure we write the compressed version. + await SessionFile.write(parsed); + await promise_check_exist(Paths.backups, true); + await promise_check_exist(Paths.clean, false); + await promise_check_exist(Paths.cleanBackup, true); + await promise_check_exist(Paths.recovery, true); + await promise_check_exist(Paths.recoveryBackup, false); + await promise_check_exist(Paths.nextUpgradeBackup, true); + // The deprecated $Path.clean should exist. + await promise_check_exist(Paths.clean.replace("jsonlz4", "js"), true); + + await promise_check_contents(Paths.recovery, parsed); +}); + +add_task(async function test_startup_with_compressed_clean() { + let state = { windows: [] }; + + // Mare sure we have an empty profile dir. + await SessionFile.wipe(); + + // Populate session files to profile dir. + await IOUtils.writeJSON(Paths.clean, state, { + compress: true, + }); + await IOUtils.makeDirectory(Paths.backups); + await IOUtils.writeJSON(Paths.cleanBackup, state, { + compress: true, + }); + + // Initiate a read. + let result = await SessionFile.read(); + + // Make sure we read correct session file and its content. + equal(result.origin, "clean"); + equal(result.useOldExtension, false); + Assert.deepEqual( + state, + result.parsed, + "result.parsed contains expected data" + ); +}); + +add_task(async function test_empty_profile_dir() { + // Make sure that we have empty profile dir. + await SessionFile.wipe(); + await promise_check_exist(Paths.backups, false); + await promise_check_exist(Paths.clean, false); + await promise_check_exist(Paths.cleanBackup, false); + await promise_check_exist(Paths.recovery, false); + await promise_check_exist(Paths.recoveryBackup, false); + await promise_check_exist(Paths.nextUpgradeBackup, false); + await promise_check_exist(Paths.backups.replace("jsonlz4", "js"), false); + await promise_check_exist(Paths.clean.replace("jsonlz4", "js"), false); + await promise_check_exist(Paths.cleanBackup.replace("lz4", ""), false); + await promise_check_exist(Paths.recovery.replace("jsonlz4", "js"), false); + await promise_check_exist( + Paths.recoveryBackup.replace("jsonlz4", "js"), + false + ); + await promise_check_exist( + Paths.nextUpgradeBackup.replace("jsonlz4", "js"), + false + ); + + // Initiate a read and make sure that we are in empty state. + let result = await SessionFile.read(); + equal(result.origin, "empty"); + equal(result.noFilesFound, true); + + // Create a state to store. + let state = { windows: [] }; + await SessionWriter.write(state, { isFinalWrite: true }); + + // Check session files are created, but not deprecated ones. + await promise_check_exist(Paths.clean, true); + await promise_check_exist(Paths.clean.replace("jsonlz4", "js"), false); + + // Check session file' content is correct. + await promise_check_contents(Paths.clean, state); +}); diff --git a/browser/components/sessionstore/test/unit/test_startup_invalid_session.js b/browser/components/sessionstore/test/unit/test_startup_invalid_session.js new file mode 100644 index 0000000000..50960b1d43 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_startup_invalid_session.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function run_test() { + let profd = do_get_profile(); + var SessionFile = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" + ).SessionFile; + + let sourceSession = do_get_file("data/sessionstore_invalid.js"); + sourceSession.copyTo(profd, "sessionstore.js"); + + let sourceCheckpoints = do_get_file("data/sessionCheckpoints_all.json"); + sourceCheckpoints.copyTo(profd, "sessionCheckpoints.json"); + + // Compress sessionstore.js to sessionstore.jsonlz4 + // and remove sessionstore.js + let oldExtSessionFile = SessionFile.Paths.clean.replace("jsonlz4", "js"); + writeCompressedFile(oldExtSessionFile, SessionFile.Paths.clean).then(() => { + afterSessionStartupInitialization(function cb() { + Assert.equal(SessionStartup.sessionType, SessionStartup.NO_SESSION); + do_test_finished(); + }); + }); + + do_test_pending(); +} diff --git a/browser/components/sessionstore/test/unit/test_startup_nosession_async.js b/browser/components/sessionstore/test/unit/test_startup_nosession_async.js new file mode 100644 index 0000000000..259c393e63 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_startup_nosession_async.js @@ -0,0 +1,19 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test SessionStartup.sessionType in the following scenario: +// - no sessionstore.js; +// - the session store has been loaded, so no need to go +// through the synchronous fallback + +function run_test() { + // Initialize the profile (the session startup uses it) + do_get_profile(); + + do_test_pending(); + + afterSessionStartupInitialization(function cb() { + Assert.equal(SessionStartup.sessionType, SessionStartup.NO_SESSION); + do_test_finished(); + }); +} diff --git a/browser/components/sessionstore/test/unit/test_startup_session_async.js b/browser/components/sessionstore/test/unit/test_startup_session_async.js new file mode 100644 index 0000000000..a61c9fe422 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_startup_session_async.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test SessionStartup.sessionType in the following scenario: +// - valid sessionstore.js; +// - valid sessionCheckpoints.json with all checkpoints; +// - the session store has been loaded + +function run_test() { + let profd = do_get_profile(); + var SessionFile = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" + ).SessionFile; + + let sourceSession = do_get_file("data/sessionstore_valid.js"); + sourceSession.copyTo(profd, "sessionstore.js"); + + let sourceCheckpoints = do_get_file("data/sessionCheckpoints_all.json"); + sourceCheckpoints.copyTo(profd, "sessionCheckpoints.json"); + + // Compress sessionstore.js to sessionstore.jsonlz4 + // and remove sessionstore.js + let oldExtSessionFile = SessionFile.Paths.clean.replace("jsonlz4", "js"); + writeCompressedFile(oldExtSessionFile, SessionFile.Paths.clean).then(() => { + afterSessionStartupInitialization(function cb() { + Assert.equal(SessionStartup.sessionType, SessionStartup.DEFER_SESSION); + do_test_finished(); + }); + }); + + do_test_pending(); +} diff --git a/browser/components/sessionstore/test/unit/xpcshell.toml b/browser/components/sessionstore/test/unit/xpcshell.toml new file mode 100644 index 0000000000..6a53151b2b --- /dev/null +++ b/browser/components/sessionstore/test/unit/xpcshell.toml @@ -0,0 +1,28 @@ +[DEFAULT] +head = "head.js" +tags = "condprof" +firefox-appdir = "browser" +skip-if = ["os == 'android'"] # bug 1730213 +support-files = [ + "data/sessionCheckpoints_all.json", + "data/sessionstore_invalid.js", + "data/sessionstore_valid.js", +] + +["test_backup_once.js"] +skip-if = ["condprof"] # 1769154 + +["test_final_write_cleanup.js"] + +["test_histogram_corrupt_files.js"] + +["test_migration_lz4compression.js"] +skip-if = ["condprof"] # 1769154 + +["test_startup_invalid_session.js"] +skip-if = ["condprof"] # 1769154 + +["test_startup_nosession_async.js"] +skip-if = ["condprof"] # 1769154 + +["test_startup_session_async.js"] diff --git a/browser/components/sessionstore/triage.json b/browser/components/sessionstore/triage.json new file mode 100644 index 0000000000..3681397100 --- /dev/null +++ b/browser/components/sessionstore/triage.json @@ -0,0 +1,68 @@ +{ + "triagers": { + "Dão Gottwald": { + "bzmail": "dao+bmo@mozilla.com" + }, + "Sam Foster": { + "bzmail": "sfoster@mozilla.com" + }, + "Sarah Clements": { + "bzmail": "sclements@mozilla.com" + } + }, + "duty-start-dates": { + "2023-10-05": "Dão Gottwald", + "2023-10-12": "Sarah Clements", + "2023-10-19": "Sam Foster", + "2023-10-26": "Dão Gottwald", + "2023-11-02": "Sarah Clements", + "2023-11-09": "Sam Foster", + "2023-11-16": "Dão Gottwald", + "2023-11-23": "Sarah Clements", + "2023-11-30": "Sam Foster", + "2023-12-07": "Sarah Clements", + "2023-12-14": "Sam Foster", + "2023-12-21": "Sarah Clements", + "2023-12-28": "Sam Foster", + "2024-01-04": "Sarah Clements", + "2024-01-11": "Sam Foster", + "2024-01-18": "Sarah Clements", + "2024-01-25": "Sam Foster", + "2024-02-03": "Sarah Clements", + "2024-02-10": "Sam Foster", + "2024-02-17": "Sarah Clements", + "2024-02-24": "Sam Foster", + "2024-03-01": "Sarah Clements", + "2024-03-08": "Sam Foster", + "2024-03-15": "Sarah Clements", + "2024-03-22": "Sam Foster", + "2024-03-29": "Sarah Clements", + "2024-04-06": "Sam Foster", + "2024-04-13": "Sarah Clements", + "2024-04-20": "Sam Foster", + "2024-04-27": "Sarah Clements", + "2024-05-04": "Sam Foster", + "2024-05-11": "Sarah Clements", + "2024-05-18": "Sam Foster", + "2024-05-25": "Sarah Clements", + "2024-06-02": "Sam Foster", + "2024-06-09": "Sarah Clements", + "2024-06-16": "Sam Foster", + "2024-06-23": "Sarah Clements", + "2024-06-30": "Sam Foster", + "2024-07-07": "Sarah Clements", + "2024-07-14": "Sam Foster", + "2024-07-21": "Sarah Clements", + "2024-07-28": "Sam Foster", + "2024-08-05": "Sarah Clements", + "2024-08-12": "Sam Foster", + "2024-08-19": "Sarah Clements", + "2024-08-26": "Sam Foster", + "2024-09-03": "Sarah Clements", + "2024-09-10": "Sam Foster", + "2024-09-17": "Sarah Clements", + "2024-09-24": "Sam Foster", + "2024-10-01": "Sarah Clements", + "2024-10-08": "Sam Foster" + } +} diff --git a/browser/components/shell/HeadlessShell.sys.mjs b/browser/components/shell/HeadlessShell.sys.mjs new file mode 100644 index 0000000000..c87a7a6d56 --- /dev/null +++ b/browser/components/shell/HeadlessShell.sys.mjs @@ -0,0 +1,262 @@ +/* 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 { E10SUtils } from "resource://gre/modules/E10SUtils.sys.mjs"; +import { HiddenFrame } from "resource://gre/modules/HiddenFrame.sys.mjs"; + +// Refrences to the progress listeners to keep them from being gc'ed +// before they are called. +const progressListeners = new Set(); + +export class ScreenshotParent extends JSWindowActorParent { + getDimensions(params) { + return this.sendQuery("GetDimensions", params); + } +} + +ChromeUtils.registerWindowActor("Screenshot", { + parent: { + esModuleURI: "resource:///modules/HeadlessShell.sys.mjs", + }, + child: { + esModuleURI: "resource:///modules/ScreenshotChild.sys.mjs", + }, +}); + +function loadContentWindow(browser, url) { + let uri; + try { + uri = Services.io.newURI(url); + } catch (e) { + let msg = `Invalid URL passed to loadContentWindow(): ${url}`; + console.error(msg); + return Promise.reject(new Error(msg)); + } + + const principal = Services.scriptSecurityManager.getSystemPrincipal(); + return new Promise((resolve, reject) => { + let oa = E10SUtils.predictOriginAttributes({ + browser, + }); + let loadURIOptions = { + triggeringPrincipal: principal, + remoteType: E10SUtils.getRemoteTypeForURI( + url, + true, + false, + E10SUtils.DEFAULT_REMOTE_TYPE, + null, + oa + ), + }; + browser.loadURI(uri, loadURIOptions); + let { webProgress } = browser; + + let progressListener = { + onLocationChange(progress, request, location, flags) { + // Ignore inner-frame events + if (!progress.isTopLevel) { + return; + } + // Ignore events that don't change the document + if (flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) { + return; + } + // Ignore the initial about:blank, unless about:blank is requested + if (location.spec == "about:blank" && uri.spec != "about:blank") { + return; + } + + progressListeners.delete(progressListener); + webProgress.removeProgressListener(progressListener); + resolve(); + }, + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + }; + progressListeners.add(progressListener); + webProgress.addProgressListener( + progressListener, + Ci.nsIWebProgress.NOTIFY_LOCATION + ); + }); +} + +async function takeScreenshot( + fullWidth, + fullHeight, + contentWidth, + contentHeight, + path, + url +) { + let frame; + try { + frame = new HiddenFrame(); + let windowlessBrowser = await frame.get(); + + let doc = windowlessBrowser.document; + let browser = doc.createXULElement("browser"); + browser.setAttribute("remote", "true"); + browser.setAttribute("type", "content"); + browser.setAttribute( + "style", + `width: ${contentWidth}px; min-width: ${contentWidth}px; height: ${contentHeight}px; min-height: ${contentHeight}px;` + ); + browser.setAttribute("maychangeremoteness", "true"); + doc.documentElement.appendChild(browser); + + await loadContentWindow(browser, url); + + let actor = + browser.browsingContext.currentWindowGlobal.getActor("Screenshot"); + let dimensions = await actor.getDimensions(); + + let canvas = doc.createElementNS( + "http://www.w3.org/1999/xhtml", + "html:canvas" + ); + let context = canvas.getContext("2d"); + let width = dimensions.innerWidth; + let height = dimensions.innerHeight; + if (fullWidth) { + width += dimensions.scrollMaxX - dimensions.scrollMinX; + } + if (fullHeight) { + height += dimensions.scrollMaxY - dimensions.scrollMinY; + } + canvas.width = width; + canvas.height = height; + let rect = new DOMRect(0, 0, width, height); + + let snapshot = + await browser.browsingContext.currentWindowGlobal.drawSnapshot( + rect, + 1, + "rgb(255, 255, 255)" + ); + context.drawImage(snapshot, 0, 0); + + snapshot.close(); + + let blob = await new Promise(resolve => canvas.toBlob(resolve)); + + let reader = await new Promise(resolve => { + let fr = new FileReader(); + fr.onloadend = () => resolve(fr); + fr.readAsArrayBuffer(blob); + }); + + await IOUtils.write(path, new Uint8Array(reader.result)); + dump("Screenshot saved to: " + path + "\n"); + } catch (e) { + dump("Failure taking screenshot: " + e + "\n"); + } finally { + if (frame) { + frame.destroy(); + } + } +} + +export let HeadlessShell = { + async handleCmdLineArgs(cmdLine, URLlist) { + try { + // Don't quit even though we don't create a window + Services.startup.enterLastWindowClosingSurvivalArea(); + + // Default options + let fullWidth = true; + let fullHeight = true; + // Most common screen resolution of Firefox users + let contentWidth = 1366; + let contentHeight = 768; + + // Parse `window-size` + try { + var dimensionsStr = cmdLine.handleFlagWithParam("window-size", true); + } catch (e) { + dump("expected format: --window-size width[,height]\n"); + return; + } + if (dimensionsStr) { + let success; + let dimensions = dimensionsStr.split(",", 2); + if (dimensions.length == 1) { + success = dimensions[0] > 0; + if (success) { + fullWidth = false; + fullHeight = true; + contentWidth = dimensions[0]; + } + } else { + success = dimensions[0] > 0 && dimensions[1] > 0; + if (success) { + fullWidth = false; + fullHeight = false; + contentWidth = dimensions[0]; + contentHeight = dimensions[1]; + } + } + + if (!success) { + dump("expected format: --window-size width[,height]\n"); + return; + } + } + + let urlOrFileToSave = null; + try { + urlOrFileToSave = cmdLine.handleFlagWithParam("screenshot", true); + } catch (e) { + // We know that the flag exists so we only get here if there was no parameter. + cmdLine.handleFlag("screenshot", true); // Remove `screenshot` + } + + // Assume that the remaining arguments that do not start + // with a hyphen are URLs + for (let i = 0; i < cmdLine.length; ++i) { + const argument = cmdLine.getArgument(i); + if (argument.startsWith("-")) { + dump(`Warning: unrecognized command line flag ${argument}\n`); + // To emulate the pre-nsICommandLine behavior, we ignore + // the argument after an unrecognized flag. + ++i; + } else { + URLlist.push(argument); + } + } + + let path = null; + if (urlOrFileToSave && !URLlist.length) { + // URL was specified next to "-screenshot" + // Example: -screenshot https://www.example.com -attach-console + URLlist.push(urlOrFileToSave); + } else { + path = urlOrFileToSave; + } + + if (!path) { + path = PathUtils.join(cmdLine.workingDirectory.path, "screenshot.png"); + } + + if (URLlist.length == 1) { + await takeScreenshot( + fullWidth, + fullHeight, + contentWidth, + contentHeight, + path, + URLlist[0] + ); + } else { + dump("expected exactly one URL when using `screenshot`\n"); + } + } finally { + Services.startup.exitLastWindowClosingSurvivalArea(); + Services.startup.quit(Ci.nsIAppStartup.eForceQuit); + } + }, +}; diff --git a/browser/components/shell/ScreenshotChild.sys.mjs b/browser/components/shell/ScreenshotChild.sys.mjs new file mode 100644 index 0000000000..994cb1a27e --- /dev/null +++ b/browser/components/shell/ScreenshotChild.sys.mjs @@ -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/. */ + +export class ScreenshotChild extends JSWindowActorChild { + receiveMessage(message) { + if (message.name == "GetDimensions") { + return this.getDimensions(); + } + return null; + } + + async getDimensions() { + if (this.document.readyState != "complete") { + await new Promise(resolve => + this.contentWindow.addEventListener("load", resolve, { once: true }) + ); + } + + let { contentWindow } = this; + + return { + innerWidth: contentWindow.innerWidth, + innerHeight: contentWindow.innerHeight, + scrollMinX: contentWindow.scrollMinX, + scrollMaxX: contentWindow.scrollMaxX, + scrollMinY: contentWindow.scrollMinY, + scrollMaxY: contentWindow.scrollMaxY, + }; + } +} diff --git a/browser/components/shell/ShellService.sys.mjs b/browser/components/shell/ShellService.sys.mjs new file mode 100644 index 0000000000..c4af0be7de --- /dev/null +++ b/browser/components/shell/ShellService.sys.mjs @@ -0,0 +1,499 @@ +/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "XreDirProvider", + "@mozilla.org/xre/directory-provider;1", + "nsIXREDirProvider" +); + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + let consoleOptions = { + // tip: set maxLogLevel to "debug" and use log.debug() to create detailed + // messages during development. See LOG_LEVELS in Console.sys.mjs for details. + maxLogLevel: "error", + maxLogLevelPref: "browser.shell.loglevel", + prefix: "ShellService", + }; + return new ConsoleAPI(consoleOptions); +}); + +/** + * Internal functionality to save and restore the docShell.allow* properties. + */ +let ShellServiceInternal = { + /** + * Used to determine whether or not to offer "Set as desktop background" + * functionality. Even if shell service is available it is not + * guaranteed that it is able to set the background for every desktop + * which is especially true for Linux with its many different desktop + * environments. + */ + get canSetDesktopBackground() { + if (AppConstants.platform == "win" || AppConstants.platform == "macosx") { + return true; + } + + if (AppConstants.platform == "linux") { + if (this.shellService) { + let linuxShellService = this.shellService.QueryInterface( + Ci.nsIGNOMEShellService + ); + return linuxShellService.canSetDesktopBackground; + } + } + + return false; + }, + + /** + * Used to determine whether or not to show a "Set Default Browser" + * query dialog. This attribute is true if the application is starting + * up and "browser.shell.checkDefaultBrowser" is true, otherwise it + * is false. + */ + _checkedThisSession: false, + get shouldCheckDefaultBrowser() { + // If we've already checked, the browser has been started and this is a + // new window open, and we don't want to check again. + if (this._checkedThisSession) { + return false; + } + + if (!Services.prefs.getBoolPref("browser.shell.checkDefaultBrowser")) { + return false; + } + + return true; + }, + + set shouldCheckDefaultBrowser(shouldCheck) { + Services.prefs.setBoolPref( + "browser.shell.checkDefaultBrowser", + !!shouldCheck + ); + }, + + isDefaultBrowser(startupCheck, forAllTypes) { + // If this is the first browser window, maintain internal state that we've + // checked this session (so that subsequent window opens don't show the + // default browser dialog). + if (startupCheck) { + this._checkedThisSession = true; + } + if (this.shellService) { + return this.shellService.isDefaultBrowser(forAllTypes); + } + return false; + }, + + /* + * Check if UserChoice is impossible. + * + * Separated for easy stubbing in tests. + * + * @return string telemetry result like "Err*", or null if UserChoice + * is possible. + */ + _userChoiceImpossibleTelemetryResult() { + if (!ShellService.checkAllProgIDsExist()) { + return "ErrProgID"; + } + if (!ShellService.checkBrowserUserChoiceHashes()) { + return "ErrHash"; + } + return null; + }, + + /* + * Accommodate `setDefaultPDFHandlerOnlyReplaceBrowsers` feature. + * @return true if Firefox should set itself as default PDF handler, false + * otherwise. + */ + _shouldSetDefaultPDFHandler() { + if ( + !lazy.NimbusFeatures.shellService.getVariable( + "setDefaultPDFHandlerOnlyReplaceBrowsers" + ) + ) { + return true; + } + + const handler = this.getDefaultPDFHandler(); + if (handler === null) { + // We only get an exception when something went really wrong. Fail + // safely: don't set Firefox as default PDF handler. + lazy.log.warn( + "Could not determine default PDF handler: not setting Firefox as " + + "default PDF handler!" + ); + return false; + } + + if (!handler.registered) { + lazy.log.debug( + "Current default PDF handler has no registered association; " + + "should set as default PDF handler." + ); + return true; + } + + if (handler.knownBrowser) { + lazy.log.debug( + "Current default PDF handler progID matches known browser; should " + + "set as default PDF handler." + ); + return true; + } + + lazy.log.debug( + "Current default PDF handler progID does not match known browser " + + "prefix; should not set as default PDF handler." + ); + return false; + }, + + getDefaultPDFHandler() { + const knownBrowserPrefixes = [ + "AppXq0fevzme2pys62n3e0fbqa7peapykr8v", // Edge before Blink, per https://stackoverflow.com/a/32724723. + "AppXd4nrz8ff68srnhf9t5a8sbjyar1cr723", // Another pre-Blink Edge identifier. See Bug 1858729. + "Brave", // For "BraveFile". + "Chrome", // For "ChromeHTML". + "Firefox", // For "FirefoxHTML-*" or "FirefoxPDF-*". Need to take from other installations of Firefox! + "IE", // Best guess. + "MSEdge", // For "MSEdgePDF". Edgium. + "Opera", // For "OperaStable", presumably varying with channel. + "Yandex", // For "YandexPDF.IHKFKZEIOKEMR6BGF62QXCRIKM", presumably varying with installation. + ]; + + let currentProgID = ""; + try { + // Returns the empty string when no association is registered, in + // which case the prefix matching will fail and we'll set Firefox as + // the default PDF handler. + currentProgID = this.queryCurrentDefaultHandlerFor(".pdf"); + } catch (e) { + // We only get an exception when something went really wrong. Fail + // safely: don't set Firefox as default PDF handler. + lazy.log.warn("Failed to queryCurrentDefaultHandlerFor:"); + return null; + } + + if (currentProgID == "") { + return { registered: false, knownBrowser: false }; + } + + const knownBrowserPrefix = knownBrowserPrefixes.find(it => + currentProgID.startsWith(it) + ); + + if (knownBrowserPrefix) { + lazy.log.debug(`Found known browser prefix: ${knownBrowserPrefix}`); + } + + return { + registered: true, + knownBrowser: !!knownBrowserPrefix, + }; + }, + + /* + * Set the default browser through the UserChoice registry keys on Windows. + * + * NOTE: This does NOT open the System Settings app for manual selection + * in case of failure. If that is desired, catch the exception and call + * setDefaultBrowser(). + * + * @return Promise, resolves when successful, rejects with Error on failure. + */ + async setAsDefaultUserChoice() { + if (AppConstants.platform != "win") { + throw new Error("Windows-only"); + } + + lazy.log.info("Setting Firefox as default using UserChoice"); + + let telemetryResult = "ErrOther"; + + try { + telemetryResult = + this._userChoiceImpossibleTelemetryResult() ?? "ErrOther"; + if (telemetryResult == "ErrProgID") { + throw new Error("checkAllProgIDsExist() failed"); + } + if (telemetryResult == "ErrHash") { + throw new Error("checkBrowserUserChoiceHashes() failed"); + } + + const aumi = lazy.XreDirProvider.getInstallHash(); + + telemetryResult = "ErrLaunchExe"; + const extraFileExtensions = []; + if ( + lazy.NimbusFeatures.shellService.getVariable("setDefaultPDFHandler") + ) { + if (this._shouldSetDefaultPDFHandler()) { + lazy.log.info("Setting Firefox as default PDF handler"); + extraFileExtensions.push(".pdf", "FirefoxPDF"); + } else { + lazy.log.info("Not setting Firefox as default PDF handler"); + } + } + try { + await this.defaultAgent.setDefaultBrowserUserChoiceAsync( + aumi, + extraFileExtensions + ); + } catch (err) { + telemetryResult = "ErrOther"; + this._handleWDBAResult(err.result || Cr.NS_ERROR_FAILURE); + } + telemetryResult = "Success"; + } catch (ex) { + if (ex instanceof WDBAError) { + telemetryResult = ex.telemetryResult; + } + + throw ex; + } finally { + try { + const histogram = Services.telemetry.getHistogramById( + "BROWSER_SET_DEFAULT_USER_CHOICE_RESULT" + ); + histogram.add(telemetryResult); + } catch (ex) {} + } + }, + + async setAsDefaultPDFHandlerUserChoice() { + if (AppConstants.platform != "win") { + throw new Error("Windows-only"); + } + + let telemetryResult = "ErrOther"; + + try { + const aumi = lazy.XreDirProvider.getInstallHash(); + try { + this.defaultAgent.setDefaultExtensionHandlersUserChoice(aumi, [ + ".pdf", + "FirefoxPDF", + ]); + } catch (err) { + telemetryResult = "ErrOther"; + this._handleWDBAResult(err.result || Cr.NS_ERROR_FAILURE); + } + telemetryResult = "Success"; + } catch (ex) { + if (ex instanceof WDBAError) { + telemetryResult = ex.telemetryResult; + } + + throw ex; + } finally { + try { + const histogram = Services.telemetry.getHistogramById( + "BROWSER_SET_DEFAULT_PDF_HANDLER_USER_CHOICE_RESULT" + ); + histogram.add(telemetryResult); + } catch (ex) {} + } + }, + + // override nsIShellService.setDefaultBrowser() on the ShellService proxy. + async setDefaultBrowser(forAllUsers) { + // On Windows, our best chance is to set UserChoice, so try that first. + if ( + AppConstants.platform == "win" && + lazy.NimbusFeatures.shellService.getVariable( + "setDefaultBrowserUserChoice" + ) + ) { + try { + await this.setAsDefaultUserChoice(); + return; + } catch (err) { + lazy.log.warn( + "Error thrown during setAsDefaultUserChoice. Full exception:", + err + ); + + // intentionally fall through to setting via the non-user choice pathway on error + } + } + + this.shellService.setDefaultBrowser(forAllUsers); + }, + + async setAsDefault() { + let setAsDefaultError = false; + try { + await ShellService.setDefaultBrowser(false); + } catch (ex) { + setAsDefaultError = true; + console.error(ex); + } + // Here BROWSER_IS_USER_DEFAULT and BROWSER_SET_USER_DEFAULT_ERROR appear + // to be inverse of each other, but that is only because this function is + // called when the browser is set as the default. During startup we record + // the BROWSER_IS_USER_DEFAULT value without recording BROWSER_SET_USER_DEFAULT_ERROR. + Services.telemetry + .getHistogramById("BROWSER_IS_USER_DEFAULT") + .add(!setAsDefaultError); + Services.telemetry + .getHistogramById("BROWSER_SET_DEFAULT_ERROR") + .add(setAsDefaultError); + }, + + setAsDefaultPDFHandler(onlyIfKnownBrowser = false) { + if (onlyIfKnownBrowser && !this.getDefaultPDFHandler().knownBrowser) { + return; + } + + if (AppConstants.platform == "win") { + this.setAsDefaultPDFHandlerUserChoice(); + } + }, + + /** + * Determine if we're the default handler for the given file extension (like + * ".pdf") or protocol (like "https"). Windows-only for now. + * + * @returns {boolean} true if we are the default handler, false otherwise. + */ + isDefaultHandlerFor(aFileExtensionOrProtocol) { + if (AppConstants.platform == "win") { + return this.shellService + .QueryInterface(Ci.nsIWindowsShellService) + .isDefaultHandlerFor(aFileExtensionOrProtocol); + } + return false; + }, + + /** + * Checks if Firefox app can and isn't pinned to OS "taskbar." + * + * @throws if not called from main process. + */ + async doesAppNeedPin(privateBrowsing = false) { + if ( + Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT + ) { + throw new Components.Exception( + "Can't determine pinned from child process", + Cr.NS_ERROR_NOT_AVAILABLE + ); + } + + // Pretend pinning is not needed/supported if remotely disabled. + if (lazy.NimbusFeatures.shellService.getVariable("disablePin")) { + return false; + } + + // Currently this only works on certain Windows versions. + try { + // First check if we can even pin the app where an exception means no. + await this.shellService + .QueryInterface(Ci.nsIWindowsShellService) + .checkPinCurrentAppToTaskbarAsync(privateBrowsing); + let winTaskbar = Cc["@mozilla.org/windows-taskbar;1"].getService( + Ci.nsIWinTaskbar + ); + + // Then check if we're already pinned. + return !(await this.shellService.isCurrentAppPinnedToTaskbarAsync( + privateBrowsing + ? winTaskbar.defaultPrivateGroupId + : winTaskbar.defaultGroupId + )); + } catch (ex) {} + + // Next check mac pinning to dock. + try { + // Accessing this.macDockSupport will ensure we're actually running + // on Mac (it's possible to be on Linux in this block). + const isInDock = this.macDockSupport.isAppInDock; + // We can't pin Private Browsing mode on Mac, only a shortcut to the vanilla app + return privateBrowsing ? false : !isInDock; + } catch (ex) {} + return false; + }, + + /** + * Pin Firefox app to the OS "taskbar." + */ + async pinToTaskbar(privateBrowsing = false) { + if (await this.doesAppNeedPin(privateBrowsing)) { + try { + if (AppConstants.platform == "win") { + await this.shellService.pinCurrentAppToTaskbarAsync(privateBrowsing); + } else if (AppConstants.platform == "macosx") { + this.macDockSupport.ensureAppIsPinnedToDock(); + } + } catch (ex) { + console.error(ex); + } + } + }, + + _handleWDBAResult(exitCode) { + if (exitCode != Cr.NS_OK) { + const telemetryResult = + new Map([ + [Cr.NS_ERROR_WDBA_NO_PROGID, "ErrExeProgID"], + [Cr.NS_ERROR_WDBA_HASH_CHECK, "ErrExeHash"], + [Cr.NS_ERROR_WDBA_REJECTED, "ErrExeRejected"], + [Cr.NS_ERROR_WDBA_BUILD, "ErrBuild"], + ]).get(exitCode) ?? "ErrExeOther"; + + throw new WDBAError(exitCode, telemetryResult); + } + }, +}; + +XPCOMUtils.defineLazyServiceGetters(ShellServiceInternal, { + defaultAgent: ["@mozilla.org/default-agent;1", "nsIDefaultAgent"], + shellService: ["@mozilla.org/browser/shell-service;1", "nsIShellService"], + macDockSupport: ["@mozilla.org/widget/macdocksupport;1", "nsIMacDockSupport"], +}); + +/** + * The external API exported by this module. + */ +export var ShellService = new Proxy(ShellServiceInternal, { + get(target, name) { + if (name in target) { + return target[name]; + } + if (target.shellService) { + return target.shellService[name]; + } + Services.console.logStringMessage( + `${name} not found in ShellService: ${target.shellService}` + ); + return undefined; + }, +}); + +class WDBAError extends Error { + constructor(exitCode, telemetryResult) { + super(`WDBA nonzero exit code ${exitCode}: ${telemetryResult}`); + + this.exitCode = exitCode; + this.telemetryResult = telemetryResult; + } +} diff --git a/browser/components/shell/WindowsDefaultBrowser.cpp b/browser/components/shell/WindowsDefaultBrowser.cpp new file mode 100644 index 0000000000..4e73e9d022 --- /dev/null +++ b/browser/components/shell/WindowsDefaultBrowser.cpp @@ -0,0 +1,205 @@ +/* -*- 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 "WindowsDefaultBrowser.h" + +#include "city.h" +#include "mozilla/ArrayUtils.h" +#include "mozilla/UniquePtr.h" +#include "mozilla/WindowsVersion.h" +#include "mozilla/WinHeaderOnlyUtils.h" + +// This must be before any other includes that might include shlobj.h +#define INITGUID +#include + +#include +#include +#include +#include +#include + +#define APP_REG_NAME_BASE L"Firefox-" + +static bool IsWindowsLogonConnected() { + WCHAR userName[UNLEN + 1]; + DWORD size = mozilla::ArrayLength(userName); + if (!GetUserNameW(userName, &size)) { + return false; + } + + LPUSER_INFO_24 info; + if (NetUserGetInfo(nullptr, userName, 24, (LPBYTE*)&info) != NERR_Success) { + return false; + } + bool connected = info->usri24_internet_identity; + NetApiBufferFree(info); + + return connected; +} + +static bool SettingsAppBelievesConnected() { + const wchar_t* keyPath = L"SOFTWARE\\Microsoft\\Windows\\Shell\\Associations"; + const wchar_t* valueName = L"IsConnectedAtLogon"; + + uint32_t value = 0; + DWORD size = sizeof(uint32_t); + LSTATUS ls = RegGetValueW(HKEY_CURRENT_USER, keyPath, valueName, RRF_RT_ANY, + nullptr, &value, &size); + if (ls != ERROR_SUCCESS) { + return false; + } + + return !!value; +} + +bool GetAppRegName(mozilla::UniquePtr& aAppRegName) { + mozilla::UniquePtr appDirStr; + bool success = GetInstallDirectory(appDirStr); + if (!success) { + return success; + } + + uint64_t hash = CityHash64(reinterpret_cast(appDirStr.get()), + wcslen(appDirStr.get()) * sizeof(wchar_t)); + + const wchar_t* format = L"%s%I64X"; + int bufferSize = _scwprintf(format, APP_REG_NAME_BASE, hash); + ++bufferSize; // Extra byte for terminating null + aAppRegName = mozilla::MakeUnique(bufferSize); + + _snwprintf_s(aAppRegName.get(), bufferSize, _TRUNCATE, format, + APP_REG_NAME_BASE, hash); + + return success; +} + +bool LaunchControlPanelDefaultPrograms() { + // Build the path control.exe path safely + WCHAR controlEXEPath[MAX_PATH + 1] = {'\0'}; + if (!GetSystemDirectoryW(controlEXEPath, MAX_PATH)) { + return false; + } + LPCWSTR controlEXE = L"control.exe"; + if (wcslen(controlEXEPath) + wcslen(controlEXE) >= MAX_PATH) { + return false; + } + if (!PathAppendW(controlEXEPath, controlEXE)) { + return false; + } + + const wchar_t* paramFormat = + L"control.exe /name Microsoft.DefaultPrograms " + L"/page pageDefaultProgram\\pageAdvancedSettings?pszAppName=%s"; + mozilla::UniquePtr appRegName; + GetAppRegName(appRegName); + int bufferSize = _scwprintf(paramFormat, appRegName.get()); + ++bufferSize; // Extra byte for terminating null + mozilla::UniquePtr params = + mozilla::MakeUnique(bufferSize); + _snwprintf_s(params.get(), bufferSize, _TRUNCATE, paramFormat, + appRegName.get()); + + STARTUPINFOW si = {sizeof(si), 0}; + si.dwFlags = STARTF_USESHOWWINDOW; + si.wShowWindow = SW_SHOWDEFAULT; + PROCESS_INFORMATION pi = {0}; + if (!CreateProcessW(controlEXEPath, params.get(), nullptr, nullptr, FALSE, 0, + nullptr, nullptr, &si, &pi)) { + return false; + } + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + + return true; +} + +static bool IsAppRegistered(HKEY rootKey, const wchar_t* appRegName) { + const wchar_t* keyPath = L"Software\\RegisteredApplications"; + + DWORD size = sizeof(uint32_t); + LSTATUS ls = RegGetValueW(rootKey, keyPath, appRegName, RRF_RT_ANY, nullptr, + nullptr, &size); + return ls == ERROR_SUCCESS; +} + +static bool LaunchMsSettingsProtocol() { + mozilla::UniquePtr params = nullptr; + if (mozilla::HasPackageIdentity()) { + mozilla::UniquePtr packageFamilyName = + mozilla::GetPackageFamilyName(); + if (packageFamilyName) { + const wchar_t* paramFormat = + L"ms-settings:defaultapps?registeredAUMID=%s!App"; + int bufferSize = _scwprintf(paramFormat, packageFamilyName.get()); + ++bufferSize; // Extra byte for terminating null + params = mozilla::MakeUnique(bufferSize); + _snwprintf_s(params.get(), bufferSize, _TRUNCATE, paramFormat, + packageFamilyName.get()); + } + } + if (!params) { + mozilla::UniquePtr appRegName; + GetAppRegName(appRegName); + const wchar_t* paramFormat = + IsAppRegistered(HKEY_CURRENT_USER, appRegName.get()) || + !IsAppRegistered(HKEY_LOCAL_MACHINE, appRegName.get()) + ? L"ms-settings:defaultapps?registeredAppUser=%s" + : L"ms-settings:defaultapps?registeredAppMachine=%s"; + int bufferSize = _scwprintf(paramFormat, appRegName.get()); + ++bufferSize; // Extra byte for terminating null + params = mozilla::MakeUnique(bufferSize); + _snwprintf_s(params.get(), bufferSize, _TRUNCATE, paramFormat, + appRegName.get()); + } + + SHELLEXECUTEINFOW seinfo = {sizeof(seinfo)}; + seinfo.lpFile = params.get(); + seinfo.nShow = SW_SHOWNORMAL; + return ShellExecuteExW(&seinfo); +} + +bool LaunchModernSettingsDialogDefaultApps() { + if (mozilla::IsWin11OrLater()) { + return LaunchMsSettingsProtocol(); + } + + if (!mozilla::IsWindows10BuildOrLater(14965) && !IsWindowsLogonConnected() && + SettingsAppBelievesConnected()) { + // Use the classic Control Panel to work around a bug of older + // builds of Windows 10. + return LaunchControlPanelDefaultPrograms(); + } + + IApplicationActivationManager* pActivator; + HRESULT hr = CoCreateInstance( + CLSID_ApplicationActivationManager, nullptr, CLSCTX_INPROC, + IID_IApplicationActivationManager, (void**)&pActivator); + + if (SUCCEEDED(hr)) { + DWORD pid; + hr = pActivator->ActivateApplication( + L"windows.immersivecontrolpanel_cw5n1h2txyewy" + L"!microsoft.windows.immersivecontrolpanel", + L"page=SettingsPageAppsDefaults", AO_NONE, &pid); + if (SUCCEEDED(hr)) { + // Do not check error because we could at least open + // the "Default apps" setting. + pActivator->ActivateApplication( + L"windows.immersivecontrolpanel_cw5n1h2txyewy" + L"!microsoft.windows.immersivecontrolpanel", + L"page=SettingsPageAppsDefaults" + L"&target=SystemSettings_DefaultApps_Browser", + AO_NONE, &pid); + } + pActivator->Release(); + return SUCCEEDED(hr); + } + return true; +} diff --git a/browser/components/shell/WindowsDefaultBrowser.h b/browser/components/shell/WindowsDefaultBrowser.h new file mode 100644 index 0000000000..3e081aad38 --- /dev/null +++ b/browser/components/shell/WindowsDefaultBrowser.h @@ -0,0 +1,20 @@ +/* -*- 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. + */ + +#ifndef windowsdefaultbrowser_h____ +#define windowsdefaultbrowser_h____ + +#include "mozilla/UniquePtr.h" + +bool GetAppRegName(mozilla::UniquePtr& aAppRegName); +bool LaunchControlPanelDefaultPrograms(); +bool LaunchModernSettingsDialogDefaultApps(); + +#endif // windowsdefaultbrowser_h____ diff --git a/browser/components/shell/WindowsUserChoice.cpp b/browser/components/shell/WindowsUserChoice.cpp new file mode 100644 index 0000000000..baa3e7286e --- /dev/null +++ b/browser/components/shell/WindowsUserChoice.cpp @@ -0,0 +1,474 @@ +/* -*- 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/. */ + +/* + * Generate and check the UserChoice Hash, which protects file and protocol + * associations on Windows 10. + * + * NOTE: This is also used in the WDBA, so it avoids XUL and XPCOM. + * + * References: + * - PS-SFTA by Danysys + * - based on a PureBasic version by LMongrain + * + * - AssocHashGen by "halfmeasuresdisabled", see bug 1225660 and + * + * - SetUserFTA changelog + * + */ + +#include +#include // for GetPackageFamilyName +#include // for ConvertSidToStringSidW +#include // for CryptoAPI base64 +#include // for CNG MD5 +#include // for NT_SUCCESS() + +#include "nsDebug.h" +#include "mozilla/ArrayUtils.h" +#include "mozilla/UniquePtr.h" +#include "nsWindowsHelpers.h" + +#include "WindowsUserChoice.h" + +using namespace mozilla; + +UniquePtr GetCurrentUserStringSid() { + HANDLE rawProcessToken; + if (!::OpenProcessToken(::GetCurrentProcess(), TOKEN_QUERY, + &rawProcessToken)) { + return nullptr; + } + nsAutoHandle processToken(rawProcessToken); + + DWORD userSize = 0; + if (!(!::GetTokenInformation(processToken.get(), TokenUser, nullptr, 0, + &userSize) && + GetLastError() == ERROR_INSUFFICIENT_BUFFER)) { + return nullptr; + } + + auto userBytes = MakeUnique(userSize); + if (!::GetTokenInformation(processToken.get(), TokenUser, userBytes.get(), + userSize, &userSize)) { + return nullptr; + } + + wchar_t* rawSid = nullptr; + if (!::ConvertSidToStringSidW( + reinterpret_cast(userBytes.get())->User.Sid, &rawSid)) { + return nullptr; + } + UniquePtr sid(rawSid); + + // Copy instead of passing UniquePtr back to + // the caller. + int sidLen = ::lstrlenW(sid.get()) + 1; + auto outSid = MakeUnique(sidLen); + memcpy(outSid.get(), sid.get(), sidLen * sizeof(wchar_t)); + + return outSid; +} + +/* + * Create the string which becomes the input to the UserChoice hash. + * + * @see GenerateUserChoiceHash() for parameters. + * + * @return The formatted string, nullptr on failure. + * + * NOTE: This uses the format as of Windows 10 20H2 (latest as of this writing), + * used at least since 1803. + * There was at least one older version, not currently supported: On Win10 RTM + * (build 10240, aka 1507) the hash function is the same, but the timestamp and + * User Experience string aren't included; instead (for protocols) the string + * ends with the exe path. The changelog of SetUserFTA suggests the algorithm + * changed in 1703, so there may be two versions: before 1703, and 1703 to now. + */ +static UniquePtr FormatUserChoiceString(const wchar_t* aExt, + const wchar_t* aUserSid, + const wchar_t* aProgId, + SYSTEMTIME aTimestamp) { + aTimestamp.wSecond = 0; + aTimestamp.wMilliseconds = 0; + + FILETIME fileTime = {0}; + if (!::SystemTimeToFileTime(&aTimestamp, &fileTime)) { + return nullptr; + } + + // This string is built into Windows as part of the UserChoice hash algorithm. + // It might vary across Windows SKUs (e.g. Windows 10 vs. Windows Server), or + // across builds of the same SKU, but this is the only currently known + // version. There isn't any known way of deriving it, so we assume this + // constant value. If we are wrong, we will not be able to generate correct + // UserChoice hashes. + const wchar_t* userExperience = + L"User Choice set via Windows User Experience " + L"{D18B6DD5-6124-4341-9318-804003BAFA0B}"; + + const wchar_t* userChoiceFmt = + L"%s%s%s" + L"%08lx" + L"%08lx" + L"%s"; + int userChoiceLen = _scwprintf(userChoiceFmt, aExt, aUserSid, aProgId, + fileTime.dwHighDateTime, + fileTime.dwLowDateTime, userExperience); + userChoiceLen += 1; // _scwprintf does not include the terminator + + auto userChoice = MakeUnique(userChoiceLen); + _snwprintf_s(userChoice.get(), userChoiceLen, _TRUNCATE, userChoiceFmt, aExt, + aUserSid, aProgId, fileTime.dwHighDateTime, + fileTime.dwLowDateTime, userExperience); + + ::CharLowerW(userChoice.get()); + + return userChoice; +} + +// @return The MD5 hash of the input, nullptr on failure. +static UniquePtr CNG_MD5(const unsigned char* bytes, ULONG bytesLen) { + constexpr ULONG MD5_BYTES = 16; + constexpr ULONG MD5_DWORDS = MD5_BYTES / sizeof(DWORD); + UniquePtr hash; + + BCRYPT_ALG_HANDLE hAlg = nullptr; + if (NT_SUCCESS(::BCryptOpenAlgorithmProvider(&hAlg, BCRYPT_MD5_ALGORITHM, + nullptr, 0))) { + BCRYPT_HASH_HANDLE hHash = nullptr; + // As of Windows 7 the hash handle will manage its own object buffer when + // pbHashObject is nullptr and cbHashObject is 0. + if (NT_SUCCESS( + ::BCryptCreateHash(hAlg, &hHash, nullptr, 0, nullptr, 0, 0))) { + // BCryptHashData promises not to modify pbInput. + if (NT_SUCCESS(::BCryptHashData(hHash, const_cast(bytes), + bytesLen, 0))) { + hash = MakeUnique(MD5_DWORDS); + if (!NT_SUCCESS(::BCryptFinishHash( + hHash, reinterpret_cast(hash.get()), + MD5_DWORDS * sizeof(DWORD), 0))) { + hash.reset(); + } + } + ::BCryptDestroyHash(hHash); + } + ::BCryptCloseAlgorithmProvider(hAlg, 0); + } + + return hash; +} + +// @return The input bytes encoded as base64, nullptr on failure. +static UniquePtr CryptoAPI_Base64Encode(const unsigned char* bytes, + DWORD bytesLen) { + DWORD base64Len = 0; + if (!::CryptBinaryToStringW(bytes, bytesLen, + CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, + nullptr, &base64Len)) { + return nullptr; + } + auto base64 = MakeUnique(base64Len); + if (!::CryptBinaryToStringW(bytes, bytesLen, + CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, + base64.get(), &base64Len)) { + return nullptr; + } + + return base64; +} + +static inline DWORD WordSwap(DWORD v) { return (v >> 16) | (v << 16); } + +/* + * Generate the UserChoice Hash. + * + * This implementation is based on the references listed above. + * It is organized to show the logic as clearly as possible, but at some + * point the reasoning is just "this is how it works". + * + * @param inputString A null-terminated string to hash. + * + * @return The base64-encoded hash, or nullptr on failure. + */ +static UniquePtr HashString(const wchar_t* inputString) { + auto inputBytes = reinterpret_cast(inputString); + int inputByteCount = (::lstrlenW(inputString) + 1) * sizeof(wchar_t); + + constexpr size_t DWORDS_PER_BLOCK = 2; + constexpr size_t BLOCK_SIZE = sizeof(DWORD) * DWORDS_PER_BLOCK; + // Incomplete blocks are ignored. + int blockCount = inputByteCount / BLOCK_SIZE; + + if (blockCount == 0) { + return nullptr; + } + + // Compute an MD5 hash. md5[0] and md5[1] will be used as constant multipliers + // in the scramble below. + auto md5 = CNG_MD5(inputBytes, inputByteCount); + if (!md5) { + return nullptr; + } + + // The following loop effectively computes two checksums, scrambled like a + // hash after every DWORD is added. + + // Constant multipliers for the scramble, one set for each DWORD in a block. + const DWORD C0s[DWORDS_PER_BLOCK][5] = { + {md5[0] | 1, 0xCF98B111uL, 0x87085B9FuL, 0x12CEB96DuL, 0x257E1D83uL}, + {md5[1] | 1, 0xA27416F5uL, 0xD38396FFuL, 0x7C932B89uL, 0xBFA49F69uL}}; + const DWORD C1s[DWORDS_PER_BLOCK][5] = { + {md5[0] | 1, 0xEF0569FBuL, 0x689B6B9FuL, 0x79F8A395uL, 0xC3EFEA97uL}, + {md5[1] | 1, 0xC31713DBuL, 0xDDCD1F0FuL, 0x59C3AF2DuL, 0x35BD1EC9uL}}; + + // The checksums. + DWORD h0 = 0; + DWORD h1 = 0; + // Accumulated total of the checksum after each DWORD. + DWORD h0Acc = 0; + DWORD h1Acc = 0; + + for (int i = 0; i < blockCount; ++i) { + for (size_t j = 0; j < DWORDS_PER_BLOCK; ++j) { + const DWORD* C0 = C0s[j]; + const DWORD* C1 = C1s[j]; + + DWORD input; + memcpy(&input, &inputBytes[(i * DWORDS_PER_BLOCK + j) * sizeof(DWORD)], + sizeof(DWORD)); + + h0 += input; + // Scramble 0 + h0 *= C0[0]; + h0 = WordSwap(h0) * C0[1]; + h0 = WordSwap(h0) * C0[2]; + h0 = WordSwap(h0) * C0[3]; + h0 = WordSwap(h0) * C0[4]; + h0Acc += h0; + + h1 += input; + // Scramble 1 + h1 = WordSwap(h1) * C1[1] + h1 * C1[0]; + h1 = (h1 >> 16) * C1[2] + h1 * C1[3]; + h1 = WordSwap(h1) * C1[4] + h1; + h1Acc += h1; + } + } + + DWORD hash[2] = {h0 ^ h1, h0Acc ^ h1Acc}; + + return CryptoAPI_Base64Encode(reinterpret_cast(hash), + sizeof(hash)); +} + +UniquePtr GetAssociationKeyPath(const wchar_t* aExt) { + const wchar_t* keyPathFmt; + if (aExt[0] == L'.') { + keyPathFmt = + L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\%s"; + } else { + keyPathFmt = + L"SOFTWARE\\Microsoft\\Windows\\Shell\\Associations\\" + L"UrlAssociations\\%s"; + } + + int keyPathLen = _scwprintf(keyPathFmt, aExt); + keyPathLen += 1; // _scwprintf does not include the terminator + + auto keyPath = MakeUnique(keyPathLen); + _snwprintf_s(keyPath.get(), keyPathLen, _TRUNCATE, keyPathFmt, aExt); + + return keyPath; +} + +UniquePtr GenerateUserChoiceHash(const wchar_t* aExt, + const wchar_t* aUserSid, + const wchar_t* aProgId, + SYSTEMTIME aTimestamp) { + auto userChoice = FormatUserChoiceString(aExt, aUserSid, aProgId, aTimestamp); + if (!userChoice) { + return nullptr; + } + return HashString(userChoice.get()); +} + +/* + * NOTE: The passed-in current user SID is used here, instead of getting the SID + * for the owner of the key. We are assuming that this key in HKCU is owned by + * the current user, since we want to replace that key ourselves. If the key is + * owned by someone else, then this check will fail; this is ok because we would + * likely not want to replace that other user's key anyway. + */ +CheckUserChoiceHashResult CheckUserChoiceHash(const wchar_t* aExt, + const wchar_t* aUserSid) { + auto keyPath = GetAssociationKeyPath(aExt); + if (!keyPath) { + return CheckUserChoiceHashResult::ERR_OTHER; + } + + HKEY rawAssocKey; + if (::RegOpenKeyExW(HKEY_CURRENT_USER, keyPath.get(), 0, KEY_READ, + &rawAssocKey) != ERROR_SUCCESS) { + return CheckUserChoiceHashResult::ERR_OTHER; + } + nsAutoRegKey assocKey(rawAssocKey); + + FILETIME lastWriteFileTime; + { + HKEY rawUserChoiceKey; + if (::RegOpenKeyExW(assocKey.get(), L"UserChoice", 0, KEY_READ, + &rawUserChoiceKey) != ERROR_SUCCESS) { + return CheckUserChoiceHashResult::ERR_OTHER; + } + nsAutoRegKey userChoiceKey(rawUserChoiceKey); + + if (::RegQueryInfoKeyW(userChoiceKey.get(), nullptr, nullptr, nullptr, + nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, + nullptr, &lastWriteFileTime) != ERROR_SUCCESS) { + return CheckUserChoiceHashResult::ERR_OTHER; + } + } + + SYSTEMTIME lastWriteSystemTime; + if (!::FileTimeToSystemTime(&lastWriteFileTime, &lastWriteSystemTime)) { + return CheckUserChoiceHashResult::ERR_OTHER; + } + + // Read ProgId + DWORD dataSizeBytes = 0; + if (::RegGetValueW(assocKey.get(), L"UserChoice", L"ProgId", RRF_RT_REG_SZ, + nullptr, nullptr, &dataSizeBytes) != ERROR_SUCCESS) { + return CheckUserChoiceHashResult::ERR_OTHER; + } + // +1 in case dataSizeBytes was odd, +1 to ensure termination + DWORD dataSizeChars = (dataSizeBytes / sizeof(wchar_t)) + 2; + UniquePtr progId(new wchar_t[dataSizeChars]()); + if (::RegGetValueW(assocKey.get(), L"UserChoice", L"ProgId", RRF_RT_REG_SZ, + nullptr, progId.get(), &dataSizeBytes) != ERROR_SUCCESS) { + return CheckUserChoiceHashResult::ERR_OTHER; + } + + // Read Hash + dataSizeBytes = 0; + if (::RegGetValueW(assocKey.get(), L"UserChoice", L"Hash", RRF_RT_REG_SZ, + nullptr, nullptr, &dataSizeBytes) != ERROR_SUCCESS) { + return CheckUserChoiceHashResult::ERR_OTHER; + } + dataSizeChars = (dataSizeBytes / sizeof(wchar_t)) + 2; + UniquePtr storedHash(new wchar_t[dataSizeChars]()); + if (::RegGetValueW(assocKey.get(), L"UserChoice", L"Hash", RRF_RT_REG_SZ, + nullptr, storedHash.get(), + &dataSizeBytes) != ERROR_SUCCESS) { + return CheckUserChoiceHashResult::ERR_OTHER; + } + + auto computedHash = + GenerateUserChoiceHash(aExt, aUserSid, progId.get(), lastWriteSystemTime); + if (!computedHash) { + return CheckUserChoiceHashResult::ERR_OTHER; + } + + if (::CompareStringOrdinal(computedHash.get(), -1, storedHash.get(), -1, + FALSE) != CSTR_EQUAL) { + return CheckUserChoiceHashResult::ERR_MISMATCH; + } + + return CheckUserChoiceHashResult::OK_V1; +} + +bool CheckBrowserUserChoiceHashes() { + auto userSid = GetCurrentUserStringSid(); + if (!userSid) { + return false; + } + + const wchar_t* exts[] = {L"https", L"http", L".html", L".htm"}; + + for (size_t i = 0; i < ArrayLength(exts); ++i) { + switch (CheckUserChoiceHash(exts[i], userSid.get())) { + case CheckUserChoiceHashResult::OK_V1: + break; + case CheckUserChoiceHashResult::ERR_MISMATCH: + case CheckUserChoiceHashResult::ERR_OTHER: + return false; + } + } + + return true; +} + +UniquePtr FormatProgID(const wchar_t* aProgIDBase, + const wchar_t* aAumi) { + const wchar_t* progIDFmt = L"%s-%s"; + int progIDLen = _scwprintf(progIDFmt, aProgIDBase, aAumi); + progIDLen += 1; // _scwprintf does not include the terminator + + auto progID = MakeUnique(progIDLen); + _snwprintf_s(progID.get(), progIDLen, _TRUNCATE, progIDFmt, aProgIDBase, + aAumi); + + return progID; +} + +bool CheckProgIDExists(const wchar_t* aProgID) { + HKEY key; + if (::RegOpenKeyExW(HKEY_CLASSES_ROOT, aProgID, 0, KEY_READ, &key) != + ERROR_SUCCESS) { + return false; + } + ::RegCloseKey(key); + return true; +} + +nsresult GetMsixProgId(const wchar_t* assoc, UniquePtr& aProgId) { + // Retrieve the registry path to the package from registry path: + // clang-format off + // HKEY_CLASSES_ROOT\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages\[Package Full Name]\App\Capabilities\[FileAssociations | URLAssociations]\[File | URL] + // clang-format on + + UINT32 pfnLen = 0; + LONG rv = GetCurrentPackageFullName(&pfnLen, nullptr); + NS_ENSURE_TRUE(rv != APPMODEL_ERROR_NO_PACKAGE, NS_ERROR_FAILURE); + + auto pfn = mozilla::MakeUnique(pfnLen); + rv = GetCurrentPackageFullName(&pfnLen, pfn.get()); + NS_ENSURE_TRUE(rv == ERROR_SUCCESS, NS_ERROR_FAILURE); + + const wchar_t* assocSuffix; + if (assoc[0] == L'.') { + // File association. + assocSuffix = LR"(App\Capabilities\FileAssociations)"; + } else { + // URL association. + assocSuffix = LR"(App\Capabilities\URLAssociations)"; + } + + const wchar_t* assocPathFmt = + LR"(Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages\%s\%s)"; + int assocPathLen = _scwprintf(assocPathFmt, pfn.get(), assocSuffix); + assocPathLen += 1; // _scwprintf does not include the terminator + + auto assocPath = MakeUnique(assocPathLen); + _snwprintf_s(assocPath.get(), assocPathLen, _TRUNCATE, assocPathFmt, + pfn.get(), assocSuffix); + + LSTATUS ls; + + // Retrieve the package association's ProgID, always in the form `AppX[32 hash + // characters]`. + const size_t appxProgIdLen = 37; + auto progId = MakeUnique(appxProgIdLen); + DWORD progIdLen = appxProgIdLen * sizeof(wchar_t); + ls = ::RegGetValueW(HKEY_CLASSES_ROOT, assocPath.get(), assoc, RRF_RT_REG_SZ, + nullptr, (LPBYTE)progId.get(), &progIdLen); + if (ls != ERROR_SUCCESS) { + return NS_ERROR_WDBA_NO_PROGID; + } + + aProgId.swap(progId); + + return NS_OK; +} diff --git a/browser/components/shell/WindowsUserChoice.h b/browser/components/shell/WindowsUserChoice.h new file mode 100644 index 0000000000..ae83e093e4 --- /dev/null +++ b/browser/components/shell/WindowsUserChoice.h @@ -0,0 +1,118 @@ +/* -*- 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_WINDOWSUSERCHOICE_H__ +#define SHELL_WINDOWSUSERCHOICE_H__ + +#include + +#include "ErrorList.h" // for nsresult +#include "mozilla/UniquePtr.h" +#include "nsString.h" + +/* + * Check the UserChoice Hashes for https, http, .html, .htm + * + * This should be checked before attempting to set a new default browser via + * the UserChoice key, to confirm our understanding of the existing hash. + * If an incorrect hash is written, Windows will prompt the user to choose a + * new default (or, in recent versions, it will just reset the default to Edge). + * + * Assuming that the existing hash value is correct (since Windows is fairly + * diligent about replacing bad keys), if we can recompute it from scratch, + * then we should be able to compute a correct hash for our new UserChoice key. + * + * @return true if we matched all the hashes, false otherwise. + */ +bool CheckBrowserUserChoiceHashes(); + +/* + * Result from CheckUserChoiceHash() + * + * NOTE: Currently the only positive result is OK_V1 , but the enum + * could be extended to indicate different versions of the hash. + */ +enum class CheckUserChoiceHashResult { + OK_V1, // Matched the current version of the hash (as of Win10 20H2). + ERR_MISMATCH, // The hash did not match. + ERR_OTHER, // Error reading or generating the hash. +}; + +/* + * Generate a UserChoice Hash, compare it with the one that is stored. + * + * See comments on CheckBrowserUserChoiceHashes(), which calls this to check + * each of the browser associations. + * + * @param aExt File extension or protocol association to check + * @param aUserSid String SID of the current user + * + * @return Result of the check, see CheckUserChoiceHashResult + */ +CheckUserChoiceHashResult CheckUserChoiceHash(const wchar_t* aExt, + const wchar_t* aUserSid); + +/* + * Get the registry path for the given association, file extension or protocol. + * + * @return The path, or nullptr on failure. + */ +mozilla::UniquePtr GetAssociationKeyPath(const wchar_t* aExt); + +/* + * Get the current user's SID + * + * @return String SID for the user of the current process, nullptr on failure. + */ +mozilla::UniquePtr GetCurrentUserStringSid(); + +/* + * Generate the UserChoice Hash + * + * @param aExt file extension or protocol being registered + * @param aUserSid string SID of the current user + * @param aProgId ProgId to associate with aExt + * @param aTimestamp approximate write time of the UserChoice key (within + * the same minute) + * + * @return UserChoice Hash, nullptr on failure. + */ +mozilla::UniquePtr GenerateUserChoiceHash(const wchar_t* aExt, + const wchar_t* aUserSid, + const wchar_t* aProgId, + SYSTEMTIME aTimestamp); + +/* + * Build a ProgID from a base and AUMI + * + * @param aProgIDBase A base, such as FirefoxHTML or FirefoxURL + * @param aAumi The AUMI of the installation + * + * @return Formatted ProgID. + */ +mozilla::UniquePtr FormatProgID(const wchar_t* aProgIDBase, + const wchar_t* aAumi); + +/* + * Check that the given ProgID exists in HKCR + * + * @return true if it could be opened for reading, false otherwise. + */ +bool CheckProgIDExists(const wchar_t* aProgID); + +/* + * Get the ProgID registered by Windows for the given association. + * + * The MSIX `AppManifest.xml` declares supported protocols and file + * type associations. Upon installation, Windows generates + * corresponding ProgIDs for them, of the form `AppX*`. This function + * retrieves those generated ProgIDs (from the Windows registry). + * + * @return ProgID. + */ +nsresult GetMsixProgId(const wchar_t* assoc, + mozilla::UniquePtr& aProgId); + +#endif // SHELL_WINDOWSUSERCHOICE_H__ diff --git a/browser/components/shell/content/setDesktopBackground.js b/browser/components/shell/content/setDesktopBackground.js new file mode 100644 index 0000000000..7448a3e076 --- /dev/null +++ b/browser/components/shell/content/setDesktopBackground.js @@ -0,0 +1,249 @@ +/* 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-globals-from /browser/base/content/utilityOverlay.js */ + +var gSetBackground = { + _position: AppConstants.platform == "macosx" ? "STRETCH" : "", + _backgroundColor: AppConstants.platform != "macosx" ? 0 : undefined, + _screenWidth: 0, + _screenHeight: 0, + _image: null, + _canvas: null, + _imageName: null, + + get _shell() { + return Cc["@mozilla.org/browser/shell-service;1"].getService( + Ci.nsIShellService + ); + }, + + load() { + this._canvas = document.getElementById("screen"); + this._screenWidth = screen.width; + this._screenHeight = screen.height; + // Cap ratio to 4 so the dialog width doesn't get ridiculous. Highest + // regular screens seem to be 32:9 (3.56) according to Wikipedia. + let screenRatio = Math.min(this._screenWidth / this._screenHeight, 4); + this._canvas.width = this._canvas.height * screenRatio; + document.getElementById("preview-unavailable").style.width = + this._canvas.width + "px"; + + if (AppConstants.platform == "macosx") { + document + .getElementById("SetDesktopBackgroundDialog") + .getButton("accept").hidden = true; + } else { + let multiMonitors = false; + if (AppConstants.platform == "linux") { + // getMonitors only ever returns the primary monitor on Linux, so just + // always show the option + multiMonitors = true; + } else { + const gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo); + const monitors = gfxInfo.getMonitors(); + multiMonitors = monitors.length > 1; + } + + if (!multiMonitors) { + // Hide span option on single monitor systems. + document.getElementById("spanPosition").hidden = true; + } + } + + document.addEventListener("dialogaccept", function () { + gSetBackground.setDesktopBackground(); + }); + // make sure that the correct dimensions will be used + setTimeout( + function (self) { + self.init(window.arguments[0], window.arguments[1]); + }, + 0, + this + ); + }, + + init(aImage, aImageName) { + this._image = aImage; + this._imageName = aImageName; + + // set the size of the coordinate space + this._canvas.width = this._canvas.clientWidth; + this._canvas.height = this._canvas.clientHeight; + + var ctx = this._canvas.getContext("2d"); + ctx.scale( + this._canvas.clientWidth / this._screenWidth, + this._canvas.clientHeight / this._screenHeight + ); + + if (AppConstants.platform != "macosx") { + this._initColor(); + } else { + // Make sure to reset the button state in case the user has already + // set an image as their desktop background. + var setDesktopBackground = document.getElementById( + "setDesktopBackground" + ); + setDesktopBackground.hidden = false; + var bundle = document.getElementById("backgroundBundle"); + setDesktopBackground.label = bundle.getString("DesktopBackgroundSet"); + setDesktopBackground.disabled = false; + + document.getElementById("showDesktopPreferences").hidden = true; + } + this.updatePosition(); + }, + + setDesktopBackground() { + if (AppConstants.platform != "macosx") { + Services.xulStore.persist( + document.getElementById("menuPosition"), + "value" + ); + this._shell.desktopBackgroundColor = this._hexStringToLong( + this._backgroundColor + ); + } else { + Services.obs.addObserver(this, "shell:desktop-background-changed"); + + var bundle = document.getElementById("backgroundBundle"); + var setDesktopBackground = document.getElementById( + "setDesktopBackground" + ); + setDesktopBackground.disabled = true; + setDesktopBackground.label = bundle.getString( + "DesktopBackgroundDownloading" + ); + } + this._shell.setDesktopBackground( + this._image, + Ci.nsIShellService["BACKGROUND_" + this._position], + this._imageName + ); + }, + + updatePosition() { + var ctx = this._canvas.getContext("2d"); + ctx.clearRect(0, 0, this._screenWidth, this._screenHeight); + document.getElementById("preview-unavailable").hidden = true; + + if (AppConstants.platform != "macosx") { + this._position = document.getElementById("menuPosition").value; + } + + switch (this._position) { + case "TILE": + ctx.save(); + ctx.fillStyle = ctx.createPattern(this._image, "repeat"); + ctx.fillRect(0, 0, this._screenWidth, this._screenHeight); + ctx.restore(); + break; + case "STRETCH": + ctx.drawImage(this._image, 0, 0, this._screenWidth, this._screenHeight); + break; + case "CENTER": { + let x = (this._screenWidth - this._image.naturalWidth) / 2; + let y = (this._screenHeight - this._image.naturalHeight) / 2; + ctx.drawImage(this._image, x, y); + break; + } + case "FILL": { + // Try maxing width first, overflow height. + let widthRatio = this._screenWidth / this._image.naturalWidth; + let width = this._image.naturalWidth * widthRatio; + let height = this._image.naturalHeight * widthRatio; + if (height < this._screenHeight) { + // Height less than screen, max height and overflow width. + let heightRatio = this._screenHeight / this._image.naturalHeight; + width = this._image.naturalWidth * heightRatio; + height = this._image.naturalHeight * heightRatio; + } + let x = (this._screenWidth - width) / 2; + let y = (this._screenHeight - height) / 2; + ctx.drawImage(this._image, x, y, width, height); + break; + } + case "FIT": { + // Try maxing width first, top and bottom borders. + let widthRatio = this._screenWidth / this._image.naturalWidth; + let width = this._image.naturalWidth * widthRatio; + let height = this._image.naturalHeight * widthRatio; + let x = 0; + let y = (this._screenHeight - height) / 2; + if (height > this._screenHeight) { + // Height overflow, maximise height, side borders. + let heightRatio = this._screenHeight / this._image.naturalHeight; + width = this._image.naturalWidth * heightRatio; + height = this._image.naturalHeight * heightRatio; + x = (this._screenWidth - width) / 2; + y = 0; + } + ctx.drawImage(this._image, x, y, width, height); + break; + } + case "SPAN": { + document.getElementById("preview-unavailable").hidden = false; + ctx.fillStyle = "#222"; + ctx.fillRect(0, 0, this._screenWidth, this._screenHeight); + ctx.stroke(); + } + } + }, +}; + +if (AppConstants.platform != "macosx") { + gSetBackground._initColor = function () { + var color = this._shell.desktopBackgroundColor; + + const rMask = 4294901760; + const gMask = 65280; + const bMask = 255; + var r = (color & rMask) >> 16; + var g = (color & gMask) >> 8; + var b = color & bMask; + this.updateColor(this._rgbToHex(r, g, b)); + + var colorpicker = document.getElementById("desktopColor"); + colorpicker.value = this._backgroundColor; + }; + + gSetBackground.updateColor = function (aColor) { + this._backgroundColor = aColor; + this._canvas.style.backgroundColor = aColor; + }; + + // Converts a color string in the format "#RRGGBB" to an integer. + gSetBackground._hexStringToLong = function (aString) { + return ( + (parseInt(aString.substring(1, 3), 16) << 16) | + (parseInt(aString.substring(3, 5), 16) << 8) | + parseInt(aString.substring(5, 7), 16) + ); + }; + + gSetBackground._rgbToHex = function (aR, aG, aB) { + return ( + "#" + + [aR, aG, aB] + .map(aInt => aInt.toString(16).replace(/^(.)$/, "0$1")) + .join("") + .toUpperCase() + ); + }; +} else { + gSetBackground.observe = function (aSubject, aTopic, aData) { + if (aTopic == "shell:desktop-background-changed") { + document.getElementById("setDesktopBackground").hidden = true; + document.getElementById("showDesktopPreferences").hidden = false; + + Services.obs.removeObserver(this, "shell:desktop-background-changed"); + } + }; + + gSetBackground.showDesktopPrefs = function () { + this._shell.QueryInterface(Ci.nsIMacShellService).showDesktopPreferences(); + }; +} diff --git a/browser/components/shell/content/setDesktopBackground.xhtml b/browser/components/shell/content/setDesktopBackground.xhtml new file mode 100644 index 0000000000..57db807ac1 --- /dev/null +++ b/browser/components/shell/content/setDesktopBackground.xhtml @@ -0,0 +1,91 @@ + + +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + + + + + + + + + + + + +#ifdef XP_MACOSX +#include ../../../base/content/macWindow.inc.xhtml +#else + + + + + + + +
    +
    + + diff --git a/browser/components/shopping/content/unanalyzed.css b/browser/components/shopping/content/unanalyzed.css new file mode 100644 index 0000000000..d2e2b0b4b0 --- /dev/null +++ b/browser/components/shopping/content/unanalyzed.css @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +@import url("chrome://global/skin/in-content/common.css"); + +#unanalyzed-product-wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +#unanalyzed-product-icon { + max-width: 264px; + max-height: 290px; + width: 100%; + content: url("chrome://browser/content/shopping/assets/unanalyzedLight.avif"); + + @media (prefers-color-scheme: dark) { + content: url("chrome://browser/content/shopping/assets/unanalyzedDark.avif"); + } +} + +#unanalyzed-product-message-content { + display: flex; + flex-direction: column; + line-height: 1.5; + + > h2 { + font-size: inherit; + } + + > p { + margin-block: 0.25rem; + } +} + +#unanalyzed-product-analysis-button { + width: 100%; +} diff --git a/browser/components/shopping/content/unanalyzed.mjs b/browser/components/shopping/content/unanalyzed.mjs new file mode 100644 index 0000000000..0be85b65e4 --- /dev/null +++ b/browser/components/shopping/content/unanalyzed.mjs @@ -0,0 +1,61 @@ +/* -*- 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 { 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://browser/content/shopping/shopping-card.mjs"; + +class UnanalyzedProductCard extends MozLitElement { + static properties = { + productURL: { type: String, reflect: true }, + }; + + static get queries() { + return { + analysisButtonEl: "#unanalyzed-product-analysis-button", + }; + } + + onClickAnalysisButton() { + this.dispatchEvent( + new CustomEvent("NewAnalysisRequested", { + bubbles: true, + composed: true, + }) + ); + Glean.shopping.surfaceAnalyzeReviewsNoneAvailableClicked.record(); + } + + render() { + return html` + + +
    + +
    +

    +

    +
    + +
    +
    + `; + } +} + +customElements.define("unanalyzed-product-card", UnanalyzedProductCard); diff --git a/browser/components/shopping/jar.mn b/browser/components/shopping/jar.mn new file mode 100644 index 0000000000..25fe1b1c0e --- /dev/null +++ b/browser/components/shopping/jar.mn @@ -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/. + +browser.jar: + content/browser/shopping/onboarding.mjs (content/onboarding.mjs) + content/browser/shopping/shopping.html (content/shopping.html) + content/browser/shopping/shopping-container.css (content/shopping-container.css) + content/browser/shopping/shopping-page.css (content/shopping-page.css) + content/browser/shopping/shopping-sidebar.js (content/shopping-sidebar.js) + content/browser/shopping/shopping-message-bar.css (content/shopping-message-bar.css) + content/browser/shopping/shopping-message-bar.mjs (content/shopping-message-bar.mjs) + content/browser/shopping/highlights.mjs (content/highlights.mjs) + content/browser/shopping/highlight-item.css (content/highlight-item.css) + content/browser/shopping/highlight-item.mjs (content/highlight-item.mjs) + content/browser/shopping/shopping-card.css (content/shopping-card.css) + content/browser/shopping/shopping-card.mjs (content/shopping-card.mjs) + content/browser/shopping/letter-grade.css (content/letter-grade.css) + content/browser/shopping/letter-grade.mjs (content/letter-grade.mjs) + content/browser/shopping/settings.mjs (content/settings.mjs) + content/browser/shopping/settings.css (content/settings.css) + content/browser/shopping/shopping-container.mjs (content/shopping-container.mjs) + content/browser/shopping/adjusted-rating.mjs (content/adjusted-rating.mjs) + content/browser/shopping/reliability.mjs (content/reliability.mjs) + content/browser/shopping/analysis-explainer.css (content/analysis-explainer.css) + content/browser/shopping/analysis-explainer.mjs (content/analysis-explainer.mjs) + content/browser/shopping/unanalyzed.css (content/unanalyzed.css) + content/browser/shopping/unanalyzed.mjs (content/unanalyzed.mjs) + content/browser/shopping/recommended-ad.css (content/recommended-ad.css) + content/browser/shopping/recommended-ad.mjs (content/recommended-ad.mjs) + content/browser/shopping/assets/ (content/assets/*) diff --git a/browser/components/shopping/metrics.yaml b/browser/components/shopping/metrics.yaml new file mode 100644 index 0000000000..b1869e859a --- /dev/null +++ b/browser/components/shopping/metrics.yaml @@ -0,0 +1,738 @@ +# 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/. + +# Adding a new metric? We have docs for that! +# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html + +--- +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 +$tags: + - "Firefox :: Shopping" + +shopping.settings: + nimbus_disabled_shopping: + type: boolean + lifetime: application + description: | + Indicates if Nimbus has disabled the use the shopping component. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1845822 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1845822 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - metrics + telemetry_mirror: SHOPPING_NIMBUS_DISABLED + + component_opted_out: + type: boolean + lifetime: application + description: | + Indicates if the user has opted out of using the shopping component. + Set during shopping component init and updated when changed in browser. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1845822 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1845822 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - metrics + telemetry_mirror: SHOPPING_COMPONENT_OPTED_OUT + + has_onboarded: + type: boolean + lifetime: application + description: | + Indicates if the user has completed the Shopping product Onboarding + experience. Set during shopping component init and updated when changed + in browser. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1845822 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1845822 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - metrics + telemetry_mirror: SHOPPING_HAS_ONBOARDED + + disabled_ads: + type: boolean + lifetime: application + description: | + Indicates if the user has manually disabled ads. Set during shopping + component init and updated when changed in browser. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1858540 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1858540 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - metrics + telemetry_mirror: SHOPPING_DISABLED_ADS + + auto_open_user_disabled: + type: boolean + lifetime: application + description: | + Indicates if the user has manually disabled the auto open sidebar feature. + Set during shopping component init and updated when changed in browser. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1879119 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1879119 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - metrics + telemetry_mirror: SHOPPING_AUTO_OPEN_USER_DISABLED + +shopping: + surface_displayed: + type: event + description: | + The Shopping product Sidebar was displayed. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1849236 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848870#c2 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + extra_keys: + side_bar_state: + type: string + description: | + Which of the possible configurations of the sidebar was displayed. + + surface_reanalyze_clicked: + type: event + description: | + The user clicked to REanalyze reviews in the shopping side bar. This + metric does not contain any information about the product the user is + viewing or any displayed trusted deals. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848870 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848870 + data_sensitivity: + - interaction + expires: 134 + send_in_pings: + - events + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + + surface_show_quality_explainer_clicked: + type: event + description: | + The user clicked to see the explanation of Review Quality in the + shopping component. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1849382 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1849382 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + extra_keys: + action: + description: > + Whether the button was used to expand or collapse the quality + explainer card. + Possible values are `expanded` and `collapsed`. + type: string + + surface_settings_expand_clicked: + type: event + description: | + The user opened the settings menu of the shopping component. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1849382 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1849382 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + extra_keys: + action: + description: > + Whether the button was used to expand or collapse the settings card. + Possible values are `expanded` and `collapsed`. + type: string + + surface_closed: + type: event + description: | + The user opened the settings menu of the shopping component. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1849240 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1841892 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + extra_keys: + source: + description: > + The source of the close event. For example, whether the shopping + sidebar was closed with the close button or the icon in the + address bar. + type: string + + address_bar_icon_clicked: + type: event + description: | + The Shopping product Address Bar Icon was clicked by the user. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1849239 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1841892 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + extra_keys: + action: + description: > + Whether the icon was used to open or close the Shopping sidebar. + type: string + + surface_show_more_reviews_button_clicked: + type: event + description: | + The user clicked to expand the recent reviews to see more. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1849241 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1849241 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + send_in_pings: + - events + extra_keys: + action: + description: > + Whether the button was used to expand or collapse the more reviews + card. + type: string + + surface_show_terms_clicked: + type: event + description: | + The user clicked to view the Terms of Service. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1849899 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1841892 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + + surface_show_privacy_policy_clicked: + type: event + description: | + The user clicked to view the Privacy Policy. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1849899 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1841892 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + + surface_not_now_clicked: + type: event + description: | + The user clicked 'Not Now' to dismiss the dialog. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1849899 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1841892 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + + surface_opt_in_clicked: + type: event + description: | + The user clicked the "Yes, try it" element to use the Shopping product's + functionality. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1849899 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1841892 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + + surface_onboarding_displayed: + type: event + description: | + The Shopping Side bar displayed the onboarding experience. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1849899 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1841892 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + extra_keys: + configuration: + description: > + Which version of the onboarding experience the user was shown. + type: string + + surface_no_review_reliability_available: + type: event + description: | + Review reliability was not available for display in the shopping side + bar. This metric does not contain any information about the product + the user is viewing. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1849243 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1841892#c6 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + + surface_analyze_reviews_none_available_clicked: + type: event + description: | + The user clicked to analyze reviews in the case the reliability rating + was not available for display in the shopping side bar. This metric + does not contain any information about the product the user is viewing. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1849244 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1841892 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + + surface_learn_more_clicked: + type: event + description: | + The user clicked the 'Learn More' link in the Shopping onboarding + experience. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1851820 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1851820#c2 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + + surface_show_quality_explainer_url_clicked: + type: event + description: | + The user clicked to see the explanation of Review Quality in the + shopping component. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1849382 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848870 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + + address_bar_icon_displayed: + type: event + description: | + The Shopping product Address Bar Icon was displayed to the user. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1851036 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1841892 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + + surface_reactivated_button_clicked: + type: event + description: | + The user clicked the reactivated product button. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1851675 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1851675#c4 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + + surface_stale_analysis_shown: + type: event + description: | + The user was shown the dialogue box indicating that analysis of a product + was stale. No information about the product is included. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1854223 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1854223 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + + product_page_visits: + type: counter + description: | + Counts number of visits to a supported retailer product page + while enrolled in either the control or treatment branches + of the shopping experiment. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848160 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848160 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - metrics + telemetry_mirror: SHOPPING_PRODUCT_PAGE_VISITS + + surface_powered_by_fakespot_link_clicked: + type: event + description: | + The user clicked the "Fakespot by Mozilla" link in the shopping side + bar. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1853785 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1853785 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + + address_bar_feature_callout_displayed: + type: event + description: | + The user was shown the feature callout for the Shopping component. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1854376 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1854376 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + extra_keys: + configuration: + description: > + Message id for the version of the feature callout shown. + type: string + + surface_ads_clicked: + type: event + description: | + An ad shown in the sidebar was clicked. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1855812 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1855812 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + + surface_ads_impression: + type: event + description: | + An ad was shown and visible in the sidebar for 1.5 seconds. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1855810 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1855810 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + + surface_ads_placement: + type: event + description: | + An ad unit was fetched successfully. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1872872 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1872872 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + + surface_no_ads_available: + type: event + description: | + On a supported product page, the review checker showed analysis, and + review checker ads were enabled, but when we tried to fetch an ad from + the ad server, no ad was available. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1855811 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1855811 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + + ads_exposure: + type: event + description: | + On a supported product page, the review checker showed analysis, and + the ads exposure pref was enabled, or review checker ads were enabled, + and when we tried to fetch an ad from the ad server, an ad was available. + Does not indicate whether the ad was actually shown. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1858470 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1858470 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + + surface_ads_setting_toggled: + type: event + description: | + The user clicked the settings toggle to enable or disable ads in the + sidebar settings component. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1858540 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1858540 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + extra_keys: + action: + description: > + Whether the toggle was used to enable or disable ads. Possible values + are `enabled` and `disabled`. + type: string + + surface_opt_out_button_clicked: + type: event + description: | + The user clicked the button in the settings panel to turn off the shopping experience. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1869413 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1869413 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + + surface_auto_open_setting_toggled: + type: event + description: | + The user clicked the settings toggle to enable or disable auto-open in the + sidebar settings component. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1879125 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1879125 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + extra_keys: + action: + description: > + Whether the toggle was used to enable or disable auto-open. Possible values + are `enabled` and `disabled`. + type: string + + surface_no_thanks_button_clicked: + type: event + description: | + The user clicks the 'No thanks' button when asked if they want to + disable auto-open behavior. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1879127 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1879127 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + + surface_yes_keep_closed_button_clicked: + type: event + description: | + The user clicks the 'Yes, keep closed' button when asked if they want to + disable auto-open behavior. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1879127 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1879127 + data_sensitivity: + - interaction + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events diff --git a/browser/components/shopping/moz.build b/browser/components/shopping/moz.build new file mode 100644 index 0000000000..1db819a631 --- /dev/null +++ b/browser/components/shopping/moz.build @@ -0,0 +1,21 @@ +# -*- 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/. + +JAR_MANIFESTS += ["jar.mn"] + +FINAL_TARGET_FILES.actors += [ + "ShoppingSidebarChild.sys.mjs", + "ShoppingSidebarParent.sys.mjs", +] + +EXTRA_JS_MODULES += [ + "ShoppingUtils.sys.mjs", +] + +BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.toml"] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Shopping") diff --git a/browser/components/shopping/tests/browser/browser.toml b/browser/components/shopping/tests/browser/browser.toml new file mode 100644 index 0000000000..d93abec789 --- /dev/null +++ b/browser/components/shopping/tests/browser/browser.toml @@ -0,0 +1,79 @@ +[DEFAULT] +support-files = [ + "head.js", + "!/toolkit/components/shopping/test/mockapis/server_helper.js", + "!/toolkit/components/shopping/test/mockapis/analysis_status.sjs", + "!/toolkit/components/shopping/test/mockapis/analysis.sjs", + "!/toolkit/components/shopping/test/mockapis/analyze.sjs", + "!/toolkit/components/shopping/test/mockapis/attribution.sjs", + "!/toolkit/components/shopping/test/mockapis/recommendations.sjs", + "!/toolkit/components/shopping/test/mockapis/reporting.sjs", +] + +prefs = [ + "browser.shopping.experience2023.enabled=true", + "browser.shopping.experience2023.optedIn=1", + "browser.shopping.experience2023.ads.enabled=true", + "browser.shopping.experience2023.ads.userEnabled=true", + "browser.shopping.experience2023.autoOpen.enabled=false", + "browser.shopping.experience2023.autoOpen.userEnabled=true", + "toolkit.shopping.environment=test", + "toolkit.shopping.ohttpRelayURL=https://example.com/relay", # These URLs don't actually host a relay or gateway config, but are needed to stop us making outside network connections. + "toolkit.shopping.ohttpConfigURL=https://example.com/ohttp-config", + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features=false", # Disable the fakespot feature callouts to avoid interference. Individual tests that need them can re-enable them as needed. +] + +["browser_adjusted_rating.js"] + +["browser_ads_exposure_telemetry.js"] + +["browser_analysis_explainer.js"] + +["browser_auto_open.js"] + +["browser_exposure_telemetry.js"] + +["browser_inprogress_analysis.js"] + +["browser_keep_close_message_bar.js"] + +["browser_network_offline.js"] + +["browser_not_enough_reviews.js"] + +["browser_page_not_supported.js"] + +["browser_private_mode.js"] + +["browser_recommended_ad_test.js"] + +["browser_review_highlights.js"] + +["browser_settings_telemetry.js"] + +["browser_shopping_card.js"] + +["browser_shopping_container.js"] + +["browser_shopping_message_triggers.js"] + +["browser_shopping_onboarding.js"] + +["browser_shopping_settings.js"] + +["browser_shopping_sidebar.js"] + +["browser_shopping_survey.js"] + +["browser_shopping_urlbar.js"] + +["browser_stale_product.js"] + +["browser_ui_telemetry.js"] +skip-if = [ + "os == 'linux' && os_version == '18.04'" +] + +["browser_unanalyzed_product.js"] + +["browser_unavailable_product.js"] diff --git a/browser/components/shopping/tests/browser/browser_adjusted_rating.js b/browser/components/shopping/tests/browser/browser_adjusted_rating.js new file mode 100644 index 0000000000..b0d2da41d5 --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_adjusted_rating.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_adjusted_rating() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn( + browser, + [MOCK_ANALYZED_PRODUCT_RESPONSE], + async mockData => { + let rating = mockData.adjusted_rating; + + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + + let adjustedRating = shoppingContainer.adjustedRatingEl; + await adjustedRating.updateComplete; + + let mozFiveStar = adjustedRating.ratingEl; + ok(mozFiveStar, "The moz-five-star element exists"); + + is( + mozFiveStar.rating, + rating, + `The moz-five-star rating is ${rating}` + ); + is( + adjustedRating.rating, + rating, + `The adjusted rating "rating" is ${rating}` + ); + + rating = 2.55; + adjustedRating.rating = rating; + + await adjustedRating.updateComplete; + + is( + mozFiveStar.rating, + rating, + `The moz-five-star rating is now ${rating}` + ); + is( + adjustedRating.rating, + rating, + `The adjusted rating "rating" is now ${rating}` + ); + + rating = 0; + adjustedRating.rating = rating; + + await adjustedRating.updateComplete; + + is( + adjustedRating.rating, + rating, + `The adjusted rating "rating" is now ${rating}` + ); + + is( + mozFiveStar.rating, + 0.5, + `When the rating is 0, the star rating displays 0.5 stars.` + ); + + rating = null; + adjustedRating.rating = rating; + + await adjustedRating.updateComplete; + + is( + adjustedRating.rating, + rating, + `The adjusted rating "rating" is now ${rating}` + ); + + ok( + ContentTaskUtils.isHidden(adjustedRating), + "adjusted rating should not be visible" + ); + + rating = 3; + adjustedRating.rating = rating; + + await adjustedRating.updateComplete; + mozFiveStar = adjustedRating.ratingEl; + ok( + ContentTaskUtils.isVisible(adjustedRating), + "adjusted rating should be visible" + ); + is( + mozFiveStar.rating, + rating, + `The moz-five-star rating is now ${rating}` + ); + is( + adjustedRating.rating, + rating, + `The adjusted rating "rating" is now ${rating}` + ); + } + ); + } + ); +}); diff --git a/browser/components/shopping/tests/browser/browser_ads_exposure_telemetry.js b/browser/components/shopping/tests/browser/browser_ads_exposure_telemetry.js new file mode 100644 index 0000000000..5358667716 --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_ads_exposure_telemetry.js @@ -0,0 +1,213 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const PRODUCT_PAGE = "https://example.com/product/B09TJGHL5F"; + +const ADS_JSON = `[{ + "name": "Test product name ftw", + "url": ${PRODUCT_PAGE}, + "image_url": "https://i.fakespot.io/b6vx27xf3rgwr1a597q6qd3rutp6", + "price": "249.99", + "currency": "USD", + "grade": "A", + "adjusted_rating": 4.6, + "analysis_url": "https://www.fakespot.com/product/test-product", + "sponsored": true, + "aid": "a2VlcCBvbiByb2NraW4gdGhlIGZyZWUgd2ViIQ==", +}]`; + +// Verifies that, if the ads server returns an ad, but we have disabled +// ads exposure, no Glean telemetry is recorded. +add_task(async function test_ads_exposure_disabled_not_recorded() { + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.ads.enabled", false], + ["browser.shopping.experience2023.ads.exposure", false], + ], + }); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn( + browser, + [PRODUCT_PAGE, ADS_JSON], + async (prodPage, adResponse) => { + const { ShoppingProduct } = ChromeUtils.importESModule( + "chrome://global/content/shopping/ShoppingProduct.mjs" + ); + const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" + ); + + let productURI = Services.io.newURI(prodPage); + let product = new ShoppingProduct(productURI); + let productRequestAdsStub = sinon.stub( + product, + "requestRecommendations" + ); + productRequestAdsStub.resolves(adResponse); + + let actor = content.windowGlobalChild.getActor("ShoppingSidebar"); + actor.productURI = productURI; + actor.product = product; + + actor.requestRecommendations(productURI); + + Assert.ok( + productRequestAdsStub.notCalled, + "product.requestRecommendations should not have been called if ads and ads exposure were disabled" + ); + } + ); + } + ); + + await Services.fog.testFlushAllChildren(); + + var events = Glean.shopping.adsExposure.testGetValue(); + Assert.equal(events, null, "Ads exposure should not have been recorded"); + await SpecialPowers.popPrefEnv(); +}); + +// Verifies that, if the ads server returns nothing, and ads exposure is +// enabled, no Glean telemetry is recorded. +add_task(async function test_ads_exposure_enabled_no_ad_not_recorded() { + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.ads.enabled", true], + ["browser.shopping.experience2023.ads.exposure", true], + ], + }); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn(browser, [PRODUCT_PAGE], async prodPage => { + const { ShoppingProduct } = ChromeUtils.importESModule( + "chrome://global/content/shopping/ShoppingProduct.mjs" + ); + const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" + ); + + let productURI = Services.io.newURI(prodPage); + let product = new ShoppingProduct(productURI); + let productRequestAdsStub = sinon.stub( + product, + "requestRecommendations" + ); + productRequestAdsStub.resolves([]); + + let actor = content.windowGlobalChild.getActor("ShoppingSidebar"); + actor.productURI = productURI; + actor.product = product; + + actor.requestRecommendations(productURI); + + Assert.ok( + productRequestAdsStub.called, + "product.requestRecommendations should have been called" + ); + }); + } + ); + + await Services.fog.testFlushAllChildren(); + + var events = Glean.shopping.adsExposure.testGetValue(); + Assert.equal( + events, + null, + "Ads exposure should not have been recorded if ads exposure was enabled but no ads were returned" + ); + await SpecialPowers.popPrefEnv(); +}); + +// Verifies that, if ads are disabled but ads exposure is enabled, ads will +// be fetched, and if an ad is returned, the Glean probe will be recorded. +add_task(async function test_ads_exposure_enabled_with_ad_recorded() { + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.ads.enabled", false], + ["browser.shopping.experience2023.ads.exposure", true], + ], + }); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn( + browser, + [PRODUCT_PAGE, ADS_JSON], + async (prodPage, adResponse) => { + const { ShoppingProduct } = ChromeUtils.importESModule( + "chrome://global/content/shopping/ShoppingProduct.mjs" + ); + const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" + ); + + let productURI = Services.io.newURI(prodPage); + let product = new ShoppingProduct(productURI); + let productRequestAdsStub = sinon.stub( + product, + "requestRecommendations" + ); + productRequestAdsStub.resolves(adResponse); + + let actor = content.windowGlobalChild.getActor("ShoppingSidebar"); + actor.productURI = productURI; + actor.product = product; + + actor.requestRecommendations(productURI); + + Assert.ok( + productRequestAdsStub.called, + "product.requestRecommendations should have been called if ads exposure is enabled, even if ads are not" + ); + } + ); + } + ); + + await Services.fog.testFlushAllChildren(); + + const events = Glean.shopping.adsExposure.testGetValue(); + Assert.equal( + events.length, + 1, + "Ads exposure should have been recorded if ads exposure was enabled and ads were returned" + ); + Assert.equal( + events[0].category, + "shopping", + "Glean event should have category 'shopping'" + ); + Assert.equal( + events[0].name, + "ads_exposure", + "Glean event should have name 'ads_exposure'" + ); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/shopping/tests/browser/browser_analysis_explainer.js b/browser/components/shopping/tests/browser/browser_analysis_explainer.js new file mode 100644 index 0000000000..cb73a80709 --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_analysis_explainer.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the analysis explainer SUMO link is rendered with the expected + * UTM parameters. + */ +add_task(async function test_analysis_explainer_sumo_link_utm() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn( + browser, + [MOCK_ANALYZED_PRODUCT_RESPONSE], + async mockData => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + + let card = + shoppingContainer.analysisExplainerEl.shadowRoot.querySelector( + "shopping-card" + ); + + let href = card.querySelector("a").href; + let qs = new URL(href).searchParams; + is(qs.get("as"), "u"); + is(qs.get("utm_source"), "inproduct"); + is(qs.get("utm_campaign"), "learn-more"); + is(qs.get("utm_term"), "core-sidebar"); + } + ); + } + ); +}); diff --git a/browser/components/shopping/tests/browser/browser_auto_open.js b/browser/components/shopping/tests/browser/browser_auto_open.js new file mode 100644 index 0000000000..69c37316c5 --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_auto_open.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ShoppingUtils: "resource:///modules/ShoppingUtils.sys.mjs", +}); + +const ACTIVE_PREF = "browser.shopping.experience2023.active"; +const AUTO_OPEN_ENABLED_PREF = + "browser.shopping.experience2023.autoOpen.enabled"; +const AUTO_OPEN_USER_ENABLED_PREF = + "browser.shopping.experience2023.autoOpen.userEnabled"; +const PRODUCT_PAGE = "https://example.com/product/B09TJGHL5F"; +const productURI = Services.io.newURI(PRODUCT_PAGE); + +async function trigger_auto_open_flow(expectedActivePrefValue) { + // Set the active pref to false, which triggers ShoppingUtils.onActiveUpdate. + Services.prefs.setBoolPref(ACTIVE_PREF, false); + + // Call onLocationChange with a product URL, triggering auto-open to flip the + // active pref back to true, if the auto-open conditions are satisfied. + ShoppingUtils.onLocationChange(productURI, 0); + + // Wait a turn for the change to propagate... + await TestUtils.waitForTick(); + + // Finally, assert the active pref has the expected state. + Assert.equal( + expectedActivePrefValue, + Services.prefs.getBoolPref(ACTIVE_PREF, false) + ); +} + +add_task(async function test_auto_open() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.optedIn", 1], + ["browser.shopping.experience2023.autoOpen.enabled", true], + ["browser.shopping.experience2023.autoOpen.userEnabled", true], + ], + }); + + await trigger_auto_open_flow(true); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_auto_open_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.optedIn", 1], + ["browser.shopping.experience2023.autoOpen.enabled", false], + ["browser.shopping.experience2023.autoOpen.userEnabled", true], + ], + }); + + await trigger_auto_open_flow(false); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_auto_open_user_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.optedIn", 1], + ["browser.shopping.experience2023.autoOpen.enabled", true], + ["browser.shopping.experience2023.autoOpen.userEnabled", false], + ], + }); + + await trigger_auto_open_flow(false); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_auto_open_not_opted_in() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.optedIn", 0], + ["browser.shopping.experience2023.autoOpen.enabled", true], + ["browser.shopping.experience2023.autoOpen.userEnabled", true], + ], + }); + + await trigger_auto_open_flow(false); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/shopping/tests/browser/browser_exposure_telemetry.js b/browser/components/shopping/tests/browser/browser_exposure_telemetry.js new file mode 100644 index 0000000000..51334ce722 --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_exposure_telemetry.js @@ -0,0 +1,123 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ShoppingUtils: "resource:///modules/ShoppingUtils.sys.mjs", +}); + +// Tests in this file simulate exposure detection without actually loading the +// product pages. Instead, we call the `ShoppingUtils.maybeRecordExposure` +// method, passing in flags and URLs to simulate onLocationChange events. +// Bug 1853401 captures followup work to add integration tests. + +const PRODUCT_PAGE = Services.io.newURI( + "https://example.com/product/B09TJGHL5F" +); +const WALMART_PAGE = Services.io.newURI( + "https://www.walmart.com/ip/Utz-Cheese-Balls-23-Oz/15543964" +); +const WALMART_OTHER_PAGE = Services.io.newURI( + "https://www.walmart.com/ip/Utz-Gluten-Free-Cheese-Balls-23-0-OZ/10898644" +); + +async function setup(pref) { + await SpecialPowers.pushPrefEnv({ + set: [[`browser.shopping.experience2023.${pref}`, true]], + }); + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); +} + +async function teardown(pref) { + await SpecialPowers.popPrefEnv(); + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + // Clear out the normally short-lived pushState navigation cache in between + // runs, to avoid accidentally deduping when we shouldn't. + ShoppingUtils.lastWalmartURI = null; +} + +async function runTest({ aLocationURI, aFlags, expected }) { + async function _run() { + Assert.equal(undefined, Glean.shopping.productPageVisits.testGetValue()); + ShoppingUtils.onLocationChange(aLocationURI, aFlags); + await Services.fog.testFlushAllChildren(); + Assert.equal(expected, Glean.shopping.productPageVisits.testGetValue()); + } + + await setup("enabled"); + await _run(); + await teardown("enabled"); + + await setup("control"); + await _run(); + await teardown("control"); +} + +add_task(async function test_shopping_exposure_new_page() { + await runTest({ + aLocationURI: PRODUCT_PAGE, + aFlags: 0, + expected: 1, + }); +}); + +add_task(async function test_shopping_exposure_reload_page() { + await runTest({ + aLocationURI: PRODUCT_PAGE, + aFlags: Ci.nsIWebProgressListener.LOCATION_CHANGE_RELOAD, + expected: 1, + }); +}); + +add_task(async function test_shopping_exposure_session_restore_page() { + await runTest({ + aLocationURI: PRODUCT_PAGE, + aFlags: Ci.nsIWebProgressListener.LOCATION_CHANGE_SESSION_STORE, + expected: 1, + }); +}); + +add_task(async function test_shopping_exposure_ignore_same_page() { + await runTest({ + aLocationURI: PRODUCT_PAGE, + aFlags: Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT, + expected: undefined, + }); +}); + +add_task(async function test_shopping_exposure_count_same_page_pushstate() { + await runTest({ + aLocationURI: WALMART_PAGE, + aFlags: Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT, + expected: 1, + }); +}); + +add_task(async function test_shopping_exposure_ignore_pushstate_repeats() { + async function _run() { + let aFlags = Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT; + Assert.equal(undefined, Glean.shopping.productPageVisits.testGetValue()); + + // Slightly different setup here: simulate deduping by setting the first + // walmart page's URL as the `ShoppingUtils.lastWalmartURI`, then fire the + // pushState for the first page, then twice for a second page. This seems + // to be roughly the observed behavior when navigating between walmart + // product pages. + ShoppingUtils.lastWalmartURI = WALMART_PAGE; + ShoppingUtils.onLocationChange(WALMART_PAGE, aFlags); + ShoppingUtils.onLocationChange(WALMART_OTHER_PAGE, aFlags); + ShoppingUtils.onLocationChange(WALMART_OTHER_PAGE, aFlags); + await Services.fog.testFlushAllChildren(); + Assert.equal(1, Glean.shopping.productPageVisits.testGetValue()); + } + await setup("enabled"); + await _run(); + await teardown("enabled"); + await setup("control"); + await _run(); + await teardown("control"); +}); diff --git a/browser/components/shopping/tests/browser/browser_inprogress_analysis.js b/browser/components/shopping/tests/browser/browser_inprogress_analysis.js new file mode 100644 index 0000000000..d2d1ddeb8c --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_inprogress_analysis.js @@ -0,0 +1,151 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the correct shopping-message-bar component appears after requesting analysis for an unanalyzed product. + */ +add_task(async function test_in_progress_analysis_unanalyzed() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn( + browser, + [MOCK_UNANALYZED_PRODUCT_RESPONSE], + async mockData => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + + let unanalyzedProduct = shoppingContainer.unanalyzedProductEl; + let analysisButton = unanalyzedProduct.analysisButtonEl; + + let messageBarVisiblePromise = ContentTaskUtils.waitForCondition( + () => { + return ( + !!shoppingContainer.shoppingMessageBarEl && + ContentTaskUtils.isVisible( + shoppingContainer.shoppingMessageBarEl + ) + ); + }, + "Waiting for shopping-message-bar to be visible" + ); + + analysisButton.click(); + await shoppingContainer.updateComplete; + + // Mock the response from analysis status being "pending" + shoppingContainer.isAnalysisInProgress = true; + // Add data back, as it was unset as due to the lack of mock APIs. + // TODO: Support for the mocks will be added in Bug 1853474. + shoppingContainer.data = Cu.cloneInto(mockData, content); + + await messageBarVisiblePromise; + await shoppingContainer.updateComplete; + + is( + shoppingContainer.shoppingMessageBarEl?.getAttribute("type"), + "analysis-in-progress", + "shopping-message-bar type should be correct" + ); + } + ); + } + ); +}); + +/** + * Tests that the correct shopping-message-bar component appears after re-requesting analysis for a stale product, + * and that component shows progress percentage. + */ +add_task(async function test_in_progress_analysis_stale() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn( + browser, + [MOCK_STALE_PRODUCT_RESPONSE], + async mockData => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + + let staleMessageBar = shoppingContainer.shoppingMessageBarEl; + is(staleMessageBar?.type, "stale", "Got stale message-bar"); + + let analysisButton = staleMessageBar.reAnalysisButtonEl; + + let messageBarVisiblePromise = ContentTaskUtils.waitForCondition( + () => { + return ( + !!shoppingContainer.shoppingMessageBarEl && + ContentTaskUtils.isVisible( + shoppingContainer.shoppingMessageBarEl + ) + ); + }, + "Waiting for shopping-message-bar to be visible" + ); + + analysisButton.click(); + await shoppingContainer.updateComplete; + + // Mock the response from analysis status being "pending" + shoppingContainer.isAnalysisInProgress = true; + // Mock the analysis status response with progress. + shoppingContainer.analysisProgress = 50; + // Add data back, as it was unset as due to the lack of mock APIs. + // TODO: Support for the mocks will be added in Bug 1853474. + shoppingContainer.data = Cu.cloneInto(mockData, content); + + await messageBarVisiblePromise; + await shoppingContainer.updateComplete; + + let shoppingMessageBarEl = shoppingContainer.shoppingMessageBarEl; + is( + shoppingMessageBarEl?.getAttribute("type"), + "reanalysis-in-progress", + "shopping-message-bar type should be correct" + ); + is( + shoppingMessageBarEl?.getAttribute("progress"), + "50", + "shopping-message-bar should have progress" + ); + + let messageBarEl = + shoppingMessageBarEl?.shadowRoot.querySelector("message-bar"); + is( + messageBarEl?.getAttribute("style"), + "--analysis-progress-pcent: 50%;", + "message-bar should have progress set as a CSS variable" + ); + + let messageBarContainerEl = + shoppingMessageBarEl?.shadowRoot.querySelector( + "#message-bar-container" + ); + is( + messageBarContainerEl.querySelector("#header")?.dataset.l10nArgs, + `{"percentage":50}`, + "message-bar-container header should have progress set as a l10n arg" + ); + } + ); + } + ); +}); diff --git a/browser/components/shopping/tests/browser/browser_keep_close_message_bar.js b/browser/components/shopping/tests/browser/browser_keep_close_message_bar.js new file mode 100644 index 0000000000..c4d5f5f81a --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_keep_close_message_bar.js @@ -0,0 +1,530 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const PRODUCT_PAGE = "https://example.com/product/B09TJGHL5F"; + +const SIDEBAR_CLOSED_COUNT_PREF = + "browser.shopping.experience2023.sidebarClosedCount"; +const SHOW_KEEP_SIDEBAR_CLOSED_MESSAGE_PREF = + "browser.shopping.experience2023.showKeepSidebarClosedMessage"; +const SHOPPING_SIDEBAR_ACTIVE_PREF = "browser.shopping.experience2023.active"; +const SIDEBAR_AUTO_OPEN_ENABLED_PREF = + "browser.shopping.experience2023.autoOpen.enabled"; +const SIDEBAR_AUTO_OPEN_USER_ENABLED_PREF = + "browser.shopping.experience2023.autoOpen.userEnabled"; +const SHOPPING_OPTED_IN_PREF = "browser.shopping.experience2023.optedIn"; + +add_task( + async function test_keep_close_message_bar_no_longer_shows_after_3_appearences() { + await SpecialPowers.pushPrefEnv({ + set: [ + [SHOPPING_SIDEBAR_ACTIVE_PREF, true], + [SHOW_KEEP_SIDEBAR_CLOSED_MESSAGE_PREF, true], + [SHOPPING_OPTED_IN_PREF, 1], + [SIDEBAR_CLOSED_COUNT_PREF, 3], + [SIDEBAR_AUTO_OPEN_ENABLED_PREF, true], + [SIDEBAR_AUTO_OPEN_USER_ENABLED_PREF, true], + ], + }); + + await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async browser => { + let shoppingButton = document.getElementById("shopping-sidebar-button"); + let browserPanel = gBrowser.getPanel(browser); + + let sidebar = browserPanel.querySelector("shopping-sidebar"); + + function waitForSidebarOpen() { + return BrowserTestUtils.waitForMutationCondition( + shoppingButton, + { + attributeFilter: ["shoppingsidebaropen"], + }, + () => shoppingButton.getAttribute("shoppingsidebaropen") === "true" + ); + } + + function waitForSidebarClosed() { + return BrowserTestUtils.waitForMutationCondition( + shoppingButton, + { + attributeFilter: ["shoppingsidebaropen"], + }, + () => shoppingButton.getAttribute("shoppingsidebaropen") === "false" + ); + } + + function assertKeepClosedMessageBarVisible() { + return SpecialPowers.spawn( + sidebar.querySelector("browser"), + [], + async () => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + await shoppingContainer.updateComplete; + + await ContentTaskUtils.waitForCondition(() => { + return ( + !!shoppingContainer.keepClosedMessageBarEl && + ContentTaskUtils.isVisible( + shoppingContainer.keepClosedMessageBarEl + ) + ); + }, "Waiting for keep message bar to be visible"); + + await shoppingContainer.keepClosedMessageBarEl.updateComplete; + + Assert.ok( + shoppingContainer.showingKeepClosedMessage, + "We are showing the keep closed message bar" + ); + } + ); + } + + function assertKeepClosedMessageBarNotShowing() { + return SpecialPowers.spawn( + sidebar.querySelector("browser"), + [], + async () => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + await shoppingContainer.updateComplete; + + await ContentTaskUtils.waitForCondition(() => { + return ( + !shoppingContainer.keepClosedMessageBarEl || + ContentTaskUtils.isHidden( + shoppingContainer.keepClosedMessageBarEl + ) + ); + }, "Waiting for keep message bar to be visible"); + + Assert.ok( + !shoppingContainer.showingKeepClosedMessage, + "We are not showing the keep closed message bar" + ); + } + ); + } + + function clickSidebarCloseButton() { + return SpecialPowers.spawn( + sidebar.querySelector("browser"), + [], + async () => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + await shoppingContainer.updateComplete; + + shoppingContainer.closeButtonEl.click(); + } + ); + } + + await promiseSidebarUpdated(sidebar, PRODUCT_PAGE); + + await waitForSidebarOpen(); + ok( + BrowserTestUtils.isVisible(sidebar), + "Shopping sidebar should be open" + ); + + // Close sidebar + shoppingButton.click(); + + await waitForSidebarClosed(); + ok( + BrowserTestUtils.isHidden(sidebar), + "Shopping sidebar should be closed" + ); + + // Open sidebar + shoppingButton.click(); + + await waitForSidebarOpen(); + ok( + BrowserTestUtils.isVisible(sidebar), + "Shopping sidebar should be open" + ); + + // Try closing sidebar. Keep closed message bar will show + await clickSidebarCloseButton(); + + await TestUtils.waitForTick(); + await assertKeepClosedMessageBarVisible(); + + await clickSidebarCloseButton(); + + await waitForSidebarClosed(); + ok( + BrowserTestUtils.isHidden(sidebar), + "Shopping sidebar should be closed" + ); + + // Open sidebar + shoppingButton.click(); + + await waitForSidebarOpen(); + ok( + BrowserTestUtils.isVisible(sidebar), + "Shopping sidebar should be open" + ); + + // Try closing sidebar. Keep closed message bar will show + shoppingButton.click(); + + await TestUtils.waitForTick(); + await assertKeepClosedMessageBarVisible(); + + shoppingButton.click(); + + await waitForSidebarClosed(); + ok( + BrowserTestUtils.isHidden(sidebar), + "Shopping sidebar should be closed" + ); + + // Open sidebar + shoppingButton.click(); + + await waitForSidebarOpen(); + ok( + BrowserTestUtils.isVisible(sidebar), + "Shopping sidebar should be open" + ); + + // Try closing sidebar. Keep closed message bar will show + shoppingButton.click(); + + await TestUtils.waitForTick(); + await assertKeepClosedMessageBarVisible(); + + shoppingButton.click(); + + await waitForSidebarClosed(); + ok( + BrowserTestUtils.isHidden(sidebar), + "Shopping sidebar should be closed" + ); + + // Open sidebar + shoppingButton.click(); + + await waitForSidebarOpen(); + ok( + BrowserTestUtils.isVisible(sidebar), + "Shopping sidebar should be open" + ); + await assertKeepClosedMessageBarNotShowing(); + + // Close sidebar. Keep closed message bar no longer shows + await clickSidebarCloseButton(); + + await waitForSidebarClosed(); + ok( + BrowserTestUtils.isHidden(sidebar), + "Shopping sidebar should be closed" + ); + }); + } +); + +add_task(async function test_keep_close_message_bar_no_thanks() { + await SpecialPowers.pushPrefEnv({ + set: [ + [SHOPPING_SIDEBAR_ACTIVE_PREF, true], + [SHOW_KEEP_SIDEBAR_CLOSED_MESSAGE_PREF, true], + [SHOPPING_OPTED_IN_PREF, 1], + [SIDEBAR_CLOSED_COUNT_PREF, 5], + [SIDEBAR_AUTO_OPEN_ENABLED_PREF, true], + [SIDEBAR_AUTO_OPEN_USER_ENABLED_PREF, true], + ], + }); + + await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async browser => { + let shoppingButton = document.getElementById("shopping-sidebar-button"); + let browserPanel = gBrowser.getPanel(browser); + + let sidebar = browserPanel.querySelector("shopping-sidebar"); + + function waitForSidebarOpen() { + return BrowserTestUtils.waitForMutationCondition( + shoppingButton, + { + attributeFilter: ["shoppingsidebaropen"], + }, + () => shoppingButton.getAttribute("shoppingsidebaropen") === "true" + ); + } + + function waitForSidebarClosed() { + return BrowserTestUtils.waitForMutationCondition( + shoppingButton, + { + attributeFilter: ["shoppingsidebaropen"], + }, + () => shoppingButton.getAttribute("shoppingsidebaropen") === "false" + ); + } + + function assertKeepClosedMessageBarVisible() { + return SpecialPowers.spawn( + sidebar.querySelector("browser"), + [], + async () => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + await shoppingContainer.updateComplete; + + await ContentTaskUtils.waitForCondition(() => { + return ( + !!shoppingContainer.keepClosedMessageBarEl && + ContentTaskUtils.isVisible( + shoppingContainer.keepClosedMessageBarEl + ) + ); + }, "Waiting for keep message bar to be visible"); + + await shoppingContainer.keepClosedMessageBarEl.updateComplete; + + Assert.ok( + shoppingContainer.showingKeepClosedMessage, + "We are showing the keep closed message bar" + ); + } + ); + } + + function assertKeepClosedMessageBarNotShowing() { + return SpecialPowers.spawn( + sidebar.querySelector("browser"), + [], + async () => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + await shoppingContainer.updateComplete; + + await ContentTaskUtils.waitForCondition(() => { + return ( + !shoppingContainer.keepClosedMessageBarEl || + ContentTaskUtils.isHidden( + shoppingContainer.keepClosedMessageBarEl + ) + ); + }, "Waiting for keep message bar to be visible"); + + Assert.ok( + !shoppingContainer.showingKeepClosedMessage, + "We are not showing the keep closed message bar" + ); + } + ); + } + + function clickNoThanksButton() { + return SpecialPowers.spawn( + sidebar.querySelector("browser"), + [], + async () => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + await shoppingContainer.updateComplete; + + let keepClosedMessageBar = shoppingContainer.keepClosedMessageBarEl; + await keepClosedMessageBar.updateComplete; + + keepClosedMessageBar.noThanksButtonEl.click(); + } + ); + } + + await promiseSidebarUpdated(sidebar, PRODUCT_PAGE); + + await waitForSidebarOpen(); + ok(BrowserTestUtils.isVisible(sidebar), "Shopping sidebar should be open"); + + // Try closing sidebar. Keep closed message bar will show + shoppingButton.click(); + + await TestUtils.waitForTick(); + await assertKeepClosedMessageBarVisible(); + + await clickNoThanksButton(); + + await waitForSidebarClosed(); + ok(BrowserTestUtils.isHidden(sidebar), "Shopping sidebar should be closed"); + + // Open sidebar + shoppingButton.click(); + + await waitForSidebarOpen(); + ok(BrowserTestUtils.isVisible(sidebar), "Shopping sidebar should be open"); + await assertKeepClosedMessageBarNotShowing(); + + // Close sidebar. Keep closed message no longer shows + shoppingButton.click(); + + await waitForSidebarClosed(); + ok(BrowserTestUtils.isHidden(sidebar), "Shopping sidebar should be closed"); + }); +}); + +add_task(async function test_keep_close_message_bar_yes_keep_closed() { + await SpecialPowers.pushPrefEnv({ + set: [ + [SHOPPING_SIDEBAR_ACTIVE_PREF, true], + [SHOW_KEEP_SIDEBAR_CLOSED_MESSAGE_PREF, true], + [SHOPPING_OPTED_IN_PREF, 1], + [SIDEBAR_CLOSED_COUNT_PREF, 5], + [SIDEBAR_AUTO_OPEN_ENABLED_PREF, true], + [SIDEBAR_AUTO_OPEN_USER_ENABLED_PREF, true], + ], + }); + + await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async browser => { + let shoppingButton = document.getElementById("shopping-sidebar-button"); + let browserPanel = gBrowser.getPanel(browser); + + let sidebar = browserPanel.querySelector("shopping-sidebar"); + + function waitForSidebarOpen() { + return BrowserTestUtils.waitForMutationCondition( + shoppingButton, + { + attributeFilter: ["shoppingsidebaropen"], + }, + () => shoppingButton.getAttribute("shoppingsidebaropen") === "true" + ); + } + + function waitForSidebarClosed() { + return BrowserTestUtils.waitForMutationCondition( + shoppingButton, + { + attributeFilter: ["shoppingsidebaropen"], + }, + () => shoppingButton.getAttribute("shoppingsidebaropen") === "false" + ); + } + + function assertKeepClosedMessageBarVisible() { + return SpecialPowers.spawn( + sidebar.querySelector("browser"), + [], + async () => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + await shoppingContainer.updateComplete; + + await ContentTaskUtils.waitForCondition(() => { + return ( + !!shoppingContainer.keepClosedMessageBarEl && + ContentTaskUtils.isVisible( + shoppingContainer.keepClosedMessageBarEl + ) + ); + }, "Waiting for keep message bar to be visible"); + + await shoppingContainer.keepClosedMessageBarEl.updateComplete; + + Assert.ok( + shoppingContainer.showingKeepClosedMessage, + "We are showing the keep closed message bar" + ); + } + ); + } + + function assertKeepClosedMessageBarNotShowing() { + return SpecialPowers.spawn( + sidebar.querySelector("browser"), + [], + async () => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + await shoppingContainer.updateComplete; + + await ContentTaskUtils.waitForCondition(() => { + return ( + !shoppingContainer.keepClosedMessageBarEl || + ContentTaskUtils.isHidden( + shoppingContainer.keepClosedMessageBarEl + ) + ); + }, "Waiting for keep message bar to be visible"); + + Assert.ok( + !shoppingContainer.showingKeepClosedMessage, + "We are not showing the keep closed message bar" + ); + } + ); + } + + function clickYesKeepClosedButton() { + return SpecialPowers.spawn( + sidebar.querySelector("browser"), + [], + async () => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + await shoppingContainer.updateComplete; + + let keepClosedMessageBar = shoppingContainer.keepClosedMessageBarEl; + await keepClosedMessageBar.updateComplete; + + keepClosedMessageBar.yesKeepClosedButtonEl.click(); + } + ); + } + + await promiseSidebarUpdated(sidebar, PRODUCT_PAGE); + + await waitForSidebarOpen(); + ok(BrowserTestUtils.isVisible(sidebar), "Shopping sidebar should be open"); + + // Try closing sidebar. Keep closed message bar will show + shoppingButton.click(); + + await TestUtils.waitForTick(); + await assertKeepClosedMessageBarVisible(); + + await clickYesKeepClosedButton(); + + await waitForSidebarClosed(); + ok(BrowserTestUtils.isHidden(sidebar), "Shopping sidebar should be closed"); + + // Open sidebar + shoppingButton.click(); + + await waitForSidebarOpen(); + ok(BrowserTestUtils.isVisible(sidebar), "Shopping sidebar should be open"); + await assertKeepClosedMessageBarNotShowing(); + + // Close sidebar. Keep closed message no longer shows + shoppingButton.click(); + + await waitForSidebarClosed(); + ok(BrowserTestUtils.isHidden(sidebar), "Shopping sidebar should be closed"); + }); +}); diff --git a/browser/components/shopping/tests/browser/browser_network_offline.js b/browser/components/shopping/tests/browser/browser_network_offline.js new file mode 100644 index 0000000000..d833d551d8 --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_network_offline.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_setup() { + let originalIoOffline = Services.io.offline; + Services.io.offline = true; + + registerCleanupFunction(() => { + Services.io.offline = originalIoOffline; + }); +}); + +/** + * Tests that only the loading state appears when there is no network connection. + */ +add_task(async function test_offline_warning() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + let shoppingContainer = await getAnalysisDetails(browser, null); + + ok(shoppingContainer.isOffline, "Offline status detected"); + ok(shoppingContainer.loadingEl, "Render loading state"); + verifyAnalysisDetailsHidden(shoppingContainer); + verifyFooterHidden(shoppingContainer); + } + ); +}); diff --git a/browser/components/shopping/tests/browser/browser_not_enough_reviews.js b/browser/components/shopping/tests/browser/browser_not_enough_reviews.js new file mode 100644 index 0000000000..d979156c1e --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_not_enough_reviews.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the unanalyzed card is shown when not_enough_reviews is not present. + +add_task(async function test_show_unanalyzed_on_initial_load() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + let shoppingContainer = await getAnalysisDetails( + browser, + MOCK_UNANALYZED_PRODUCT_RESPONSE + ); + ok( + shoppingContainer.unanalyzedProductEl, + "Got unanalyzed card on first try" + ); + + verifyAnalysisDetailsHidden(shoppingContainer); + verifyFooterVisible(shoppingContainer); + } + ); +}); + +// Tests that the not enough reviews card is shown when not_enough_reviews is true. + +add_task(async function test_show_not_enough_reviews() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + + async browser => { + await SpecialPowers.spawn( + browser, + [MOCK_NOT_ENOUGH_REVIEWS_PRODUCT_RESPONSE], + async mockData => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + + shoppingContainer.data = Cu.cloneInto(mockData, content); + + let messageBarVisiblePromise = ContentTaskUtils.waitForCondition( + () => { + return ( + !!shoppingContainer.shoppingMessageBarEl && + ContentTaskUtils.isVisible( + shoppingContainer.shoppingMessageBarEl + ) + ); + }, + "Waiting for shopping-message-bar to be visible" + ); + + await messageBarVisiblePromise; + await shoppingContainer.updateComplete; + + ok( + shoppingContainer.shoppingMessageBarEl, + "Got shopping-message-bar element" + ); + + is( + shoppingContainer.shoppingMessageBarEl?.getAttribute("type"), + "not-enough-reviews", + "shopping-message-bar type should be correct" + ); + } + ); + } + ); +}); diff --git a/browser/components/shopping/tests/browser/browser_page_not_supported.js b/browser/components/shopping/tests/browser/browser_page_not_supported.js new file mode 100644 index 0000000000..c14fd697da --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_page_not_supported.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the correct shopping-message-bar component appears if a page is not supported. + * Only footer should be visible. + */ +add_task(async function test_page_not_supported() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + let shoppingContainer = await getAnalysisDetails( + browser, + MOCK_PAGE_NOT_SUPPORTED_RESPONSE + ); + + ok( + shoppingContainer.shoppingMessageBarEl, + "Got shopping-message-bar element" + ); + is( + shoppingContainer.shoppingMessageBarType, + "page-not-supported", + "shopping-message-bar type should be correct" + ); + + verifyAnalysisDetailsHidden(shoppingContainer); + verifyFooterVisible(shoppingContainer); + } + ); +}); diff --git a/browser/components/shopping/tests/browser/browser_private_mode.js b/browser/components/shopping/tests/browser/browser_private_mode.js new file mode 100644 index 0000000000..16d7ee733b --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_private_mode.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// This test verifies that the shopping sidebar is not initialized if the +// user visits a shopping product page while in private browsing mode. + +add_task(async function test_private_window_disabled() { + let privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + let browser = privateWindow.gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString( + browser, + "https://example.com/product/B09TJGHL5F" + ); + await BrowserTestUtils.browserLoaded(browser); + + let shoppingButton = privateWindow.document.getElementById( + "shopping-sidebar-button" + ); + ok( + BrowserTestUtils.isHidden(shoppingButton), + "Shopping Button should not be visible on a product page" + ); + + ok( + !privateWindow.document.querySelector("shopping-sidebar"), + "Shopping sidebar does not exist" + ); + + await BrowserTestUtils.closeWindow(privateWindow); +}); diff --git a/browser/components/shopping/tests/browser/browser_recommended_ad_test.js b/browser/components/shopping/tests/browser/browser_recommended_ad_test.js new file mode 100644 index 0000000000..159bd0514e --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_recommended_ad_test.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_ads_requested_after_enabled() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.ads.enabled", true], + ["browser.shopping.experience2023.ads.userEnabled", false], + ["browser.shopping.experience2023.autoOpen.enabled", true], + ["toolkit.shopping.ohttpRelayURL", ""], + ["toolkit.shopping.ohttpConfigURL", ""], + ], + }); + await BrowserTestUtils.withNewTab( + { + url: PRODUCT_TEST_URL, + gBrowser, + }, + async browser => { + let sidebar = gBrowser + .getPanel(browser) + .querySelector("shopping-sidebar"); + Assert.ok(sidebar, "Sidebar should exist"); + Assert.ok( + BrowserTestUtils.isVisible(sidebar), + "Sidebar should be visible." + ); + info("Waiting for sidebar to update."); + await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL); + + await SpecialPowers.spawn( + sidebar.querySelector("browser"), + [], + async () => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + await shoppingContainer.updateComplete; + + Assert.ok( + !shoppingContainer.recommendedAdEl, + "Recommended card should not exist" + ); + + let shoppingSettings = shoppingContainer.settingsEl; + await shoppingSettings.updateComplete; + + let recommendationsToggle = shoppingSettings.recommendationsToggleEl; + recommendationsToggle.click(); + + await ContentTaskUtils.waitForCondition(() => { + return shoppingContainer.recommendedAdEl; + }); + + await shoppingContainer.updateComplete; + + let recommendedCard = shoppingContainer.recommendedAdEl; + await recommendedCard.updateComplete; + Assert.ok(recommendedCard, "Recommended card should exist"); + Assert.ok( + ContentTaskUtils.isVisible(recommendedCard), + "Recommended card is visible" + ); + } + ); + } + ); +}); diff --git a/browser/components/shopping/tests/browser/browser_review_highlights.js b/browser/components/shopping/tests/browser/browser_review_highlights.js new file mode 100644 index 0000000000..f4f3467a80 --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_review_highlights.js @@ -0,0 +1,194 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +async function verifyHighlights( + browser, + data, + productUrl /* optional, set to override */, + expectedHighlightTypes, + expectedLang +) { + return SpecialPowers.spawn( + browser, + [{ data, productUrl, expectedHighlightTypes, expectedLang }], + async args => { + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(args.data, content); + if (args.productUrl) { + shoppingContainer.productUrl = args.productUrl; + } + await shoppingContainer.updateComplete; + + let reviewHighlights = shoppingContainer.highlightsEl; + ok(reviewHighlights, "Got review-highlights"); + await reviewHighlights.updateComplete; + + let highlightsList = reviewHighlights.reviewHighlightsListEl; + await highlightsList.updateComplete; + + is( + highlightsList.children.length, + args.expectedHighlightTypes.length, + "review-highlights should have the right number of highlight-items" + ); + + // Verify number of reviews for each available highlight + for (let key of args.expectedHighlightTypes) { + let highlightEl = highlightsList.querySelector( + `#${content.CSS.escape(key)}` + ); + + ok(highlightEl, "highlight-item for " + key + " exists"); + is( + highlightEl.lang, + args.expectedLang, + `highlight-item should have lang set to ${args.expectedLang}` + ); + + let actualNumberOfReviews = highlightEl.shadowRoot.querySelector( + ".highlight-details-list" + ).children.length; + let expectedNumberOfReviews = Object.values( + args.data.highlights[key] + ).flat().length; + + is( + actualNumberOfReviews, + expectedNumberOfReviews, + "There should be equal number of reviews displayed for " + key + ); + } + } + ); +} + +/** + * Tests that the review highlights custom components are visible on the page + * if there is valid data. + */ +add_task(async function test_review_highlights() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + let data = MOCK_ANALYZED_PRODUCT_RESPONSE; + let expectedHighlightTypes = [ + "price", + "quality", + "competitiveness", + "packaging/appearance", + ]; + + info("Testing with default en highlights"); + await verifyHighlights( + browser, + data, + undefined, + expectedHighlightTypes, + "en" + ); + + info("Testing with www.amazon.fr"); + await verifyHighlights( + browser, + data, + "https://www.amazon.fr", + expectedHighlightTypes, + "fr" + ); + + info("Testing with www.amazon.de"); + await verifyHighlights( + browser, + data, + "https://www.amazon.de", + expectedHighlightTypes, + "de" + ); + } + ); +}); + +/** + * Tests that entire highlights components is still hidden if we receive falsy data. + */ +add_task(async function test_review_highlights_no_highlights() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn( + browser, + [MOCK_ANALYZED_PRODUCT_RESPONSE], + async mockData => { + mockData.highlights = null; + + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + + let reviewHighlights = shoppingContainer.highlightsEl; + ok(reviewHighlights, "Got review-highlights"); + await reviewHighlights.updateComplete; + + ok( + ContentTaskUtils.isHidden(reviewHighlights), + "review-highlights should not be visible" + ); + + let highlightsList = reviewHighlights?.reviewHighlightsListEl; + ok(!highlightsList, "review-highlights-list should not be visible"); + } + ); + } + ); +}); + +/** + * Tests that we do not show an invalid highlight type and properly filter data. + */ +add_task(async function test_review_highlights_invalid_type() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + const invalidHighlightData = structuredClone( + MOCK_ANALYZED_PRODUCT_RESPONSE + ); + invalidHighlightData.highlights = MOCK_INVALID_KEY_OBJ; + await SpecialPowers.spawn( + browser, + [invalidHighlightData], + async mockData => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + + let reviewHighlights = shoppingContainer.highlightsEl; + ok(reviewHighlights, "Got review-highlights"); + await reviewHighlights.updateComplete; + + ok( + ContentTaskUtils.isHidden(reviewHighlights), + "review-highlights should not be visible" + ); + } + ); + } + ); +}); diff --git a/browser/components/shopping/tests/browser/browser_settings_telemetry.js b/browser/components/shopping/tests/browser/browser_settings_telemetry.js new file mode 100644 index 0000000000..803630f73d --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_settings_telemetry.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the settings component is rendered as expected. + */ +add_task(async function test_shopping_settings() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["toolkit.telemetry.testing.overridePreRelease", true], + ["browser.shopping.experience2023.optedIn", 0], + ], + }); + + let opt_in_status = Services.prefs.getIntPref( + "browser.shopping.experience2023.optedIn", + undefined + ); + // Values that match how we're defining the metrics + let component_opted_out = opt_in_status === 2; + let onboarded_status = opt_in_status > 0; + + Assert.equal( + component_opted_out, + Glean.shoppingSettings.componentOptedOut.testGetValue(), + "Component Opted Out metric should correctly reflect the preference value" + ); + Assert.equal( + onboarded_status, + Glean.shoppingSettings.hasOnboarded.testGetValue(), + "Has Onboarded metric should correctly reflect the preference value" + ); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_shopping_setting_update() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["toolkit.telemetry.testing.overridePreRelease", true], + ["browser.shopping.experience2023.optedIn", 2], + ], + }); + + Assert.equal( + true, + Glean.shoppingSettings.componentOptedOut.testGetValue(), + "Component Opted Out metric should return True as we've set the value of the preference" + ); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_shopping_settings_ads_enabled() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.shopping.experience2023.optedIn", 1]], + }); + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn( + browser, + [MOCK_ANALYZED_PRODUCT_RESPONSE], + async mockData => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + + shoppingContainer.data = Cu.cloneInto(mockData, content); + shoppingContainer.adsEnabled = true; + await shoppingContainer.updateComplete; + + let shoppingSettings = shoppingContainer.settingsEl; + ok(shoppingSettings, "Got the shopping-settings element"); + + let optOutButton = shoppingSettings.optOutButtonEl; + ok(optOutButton, "There should be an opt-out button"); + + optOutButton.click(); + } + ); + } + ); + + await Services.fog.testFlushAllChildren(); + var optOutClickedEvents = + Glean.shopping.surfaceOptOutButtonClicked.testGetValue(); + + Assert.equal(optOutClickedEvents.length, 1); + Assert.equal(optOutClickedEvents[0].category, "shopping"); + Assert.equal(optOutClickedEvents[0].name, "surface_opt_out_button_clicked"); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/shopping/tests/browser/browser_shopping_card.js b/browser/components/shopping/tests/browser/browser_shopping_card.js new file mode 100644 index 0000000000..ebe35f1cc0 --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_shopping_card.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the chevron button's accessible name and state. + */ +add_task(async function test_chevron_button_markup() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn( + browser, + [MOCK_UNANALYZED_PRODUCT_RESPONSE], + async mockData => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + + let shoppingSettings = content.document + .querySelector("shopping-container") + .shadowRoot.querySelector("shopping-settings"); + let shoppingCard = + shoppingSettings.shadowRoot.querySelector("shopping-card"); + let detailsEl = shoppingCard.shadowRoot.querySelector("details"); + + // Need to wait for different async events to complete on the lit component: + await ContentTaskUtils.waitForCondition(() => + detailsEl.querySelector(".chevron-icon") + ); + + let chevronButton = detailsEl.querySelector(".chevron-icon"); + + is( + chevronButton.getAttribute("aria-labelledby"), + "header", + "The chevron button is has an accessible name" + ); + } + ); + } + ); +}); diff --git a/browser/components/shopping/tests/browser/browser_shopping_container.js b/browser/components/shopping/tests/browser/browser_shopping_container.js new file mode 100644 index 0000000000..533f40f33e --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_shopping_container.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_close_button() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + // Call SpecialPowers.spawn to make RPMSetPref available on the content window. + await SpecialPowers.spawn( + browser, + [MOCK_ANALYZED_PRODUCT_RESPONSE], + async () => { + let { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" + ); + + let xrayWindow = ChromeUtils.waiveXrays(content); + let setPrefSpy = sinon.spy(xrayWindow, "RPMSetPref"); + + let closeButton = content.document + .querySelector("shopping-container") + .shadowRoot.querySelector("#close-button"); + closeButton.click(); + + ok( + setPrefSpy.calledOnceWith( + "browser.shopping.experience2023.active", + false + ) + ); + setPrefSpy.restore(); + } + ); + } + ); +}); diff --git a/browser/components/shopping/tests/browser/browser_shopping_message_triggers.js b/browser/components/shopping/tests/browser/browser_shopping_message_triggers.js new file mode 100644 index 0000000000..47e4e2a1a7 --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_shopping_message_triggers.js @@ -0,0 +1,315 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ShoppingUtils: "resource:///modules/ShoppingUtils.sys.mjs", +}); + +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); + +const { FeatureCalloutMessages } = ChromeUtils.importESModule( + "resource:///modules/asrouter/FeatureCalloutMessages.sys.mjs" +); + +const OPTED_IN_PREF = "browser.shopping.experience2023.optedIn"; +const ACTIVE_PREF = "browser.shopping.experience2023.active"; +const CFR_ENABLED_PREF = + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features"; + +const CONTENT_PAGE = "https://example.com"; +const PRODUCT_PAGE = "https://example.com/product/B09TJGHL5F"; + +add_setup(async function setup() { + // disable auto-activation to prevent interference with the tests + ShoppingUtils.handledAutoActivate = true; + // clean up all the prefs/states modified by this test + registerCleanupFunction(() => { + ShoppingUtils.handledAutoActivate = false; + }); +}); + +/** Test that the correct callouts show for opted-in users */ +add_task(async function test_fakespot_callouts_opted_in_flow() { + // Re-enable feature callouts for this test. This has to be done in each task + // because they're disabled in browser.ini. + await SpecialPowers.pushPrefEnv({ set: [[CFR_ENABLED_PREF, true]] }); + let sandbox = sinon.createSandbox(); + let routeCFRMessageStub = sandbox + .stub(ASRouter, "routeCFRMessage") + .withArgs( + sinon.match.any, + sinon.match.any, + sinon.match({ id: "shoppingProductPageWithSidebarClosed" }) + ); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders( + ASRouter.state.providers.filter(p => p.id === "onboarding") + ); + + // Reset opt-in but make the sidebar active so it appears on PDP. + await SpecialPowers.pushPrefEnv({ + set: [ + [ACTIVE_PREF, true], + [OPTED_IN_PREF, 0], + ], + }); + + // Visit a product page and wait for the sidebar to open. + let pdpTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + PRODUCT_PAGE + ); + let pdpBrowser = pdpTab.linkedBrowser; + let pdpBrowserPanel = gBrowser.getPanel(pdpBrowser); + let isSidebarVisible = () => { + let sidebar = pdpBrowserPanel.querySelector("shopping-sidebar"); + return sidebar && BrowserTestUtils.isVisible(sidebar); + }; + await BrowserTestUtils.waitForMutationCondition( + pdpBrowserPanel, + { childList: true, attributeFilter: ["hidden"] }, + isSidebarVisible + ); + ok(isSidebarVisible(), "Shopping sidebar should be open on a product page"); + + // Visiting the PDP should not cause shoppingProductPageWithSidebarClosed to + // fire in this case, because the sidebar is active. + ok( + routeCFRMessageStub.neverCalledWithMatch( + sinon.match.any, + sinon.match.any, + sinon.match({ id: "shoppingProductPageWithSidebarClosed" }) + ), + "shoppingProductPageWithSidebarClosed should not fire when sidebar is active" + ); + + // Now opt in... + let prefChanged = TestUtils.waitForPrefChange( + OPTED_IN_PREF, + value => value === 1 + ); + await SpecialPowers.pushPrefEnv({ set: [[OPTED_IN_PREF, 1]] }); + await prefChanged; + + // Close the sidebar by deactivating the global toggle, and wait for the + // shoppingProductPageWithSidebarClosed trigger to fire. + let shoppingProductPageWithSidebarClosedMsg = new Promise(resolve => { + routeCFRMessageStub.callsFake((message, browser, trigger) => { + if ( + trigger.id === "shoppingProductPageWithSidebarClosed" && + trigger.context.isSidebarClosing + ) { + resolve(message?.id); + } + }); + }); + await SpecialPowers.pushPrefEnv({ set: [[ACTIVE_PREF, false]] }); + // Assert that the message is the one we expect. + is( + await shoppingProductPageWithSidebarClosedMsg, + "FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT", + "Should route the expected message: FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT" + ); + BrowserTestUtils.removeTab(pdpTab); + + // Now, having seen the on-closed callout, we should expect to see the on-PDP + // callout on the next PDP visit, provided it's been at least 24 hours. + // + // Of course we can't really do that in an automated test, so we'll override + // the message impression date to simulate that. + // + // But first, try opening a PDP so we can test that it _doesn't_ fire if less + // than 24hrs has passed. + + // Visit a product page and wait for routeCFRMessage to fire, expecting the + // message to be null due to targeting. + shoppingProductPageWithSidebarClosedMsg = new Promise(resolve => { + routeCFRMessageStub.callsFake((message, browser, trigger) => { + if ( + trigger.id === "shoppingProductPageWithSidebarClosed" && + !trigger.context.isSidebarClosing + ) { + resolve(message?.id); + } + }); + }); + pdpTab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PRODUCT_PAGE); + // Assert that the on-PDP message is not matched, due to targeting. + isnot( + await shoppingProductPageWithSidebarClosedMsg, + "FAKESPOT_CALLOUT_PDP_OPTED_IN_DEFAULT", + "Should not route the on-PDP message because the on-close message was seen recently" + ); + BrowserTestUtils.removeTab(pdpTab); + + // Now override the state so it looks like we closed the sidebar 25 hours ago. + let lastClosedDate = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago + await ASRouter.setState(state => { + const messageImpressions = { ...state.messageImpressions }; + messageImpressions.FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT = [ + lastClosedDate, + ]; + ASRouter._storage.set("messageImpressions", messageImpressions); + return { messageImpressions }; + }); + + // And open a new PDP, expecting the on-PDP message to be routed. + shoppingProductPageWithSidebarClosedMsg = new Promise(resolve => { + routeCFRMessageStub.callsFake((message, browser, trigger) => { + if ( + trigger.id === "shoppingProductPageWithSidebarClosed" && + !trigger.context.isSidebarClosing + ) { + resolve(message?.id); + } + }); + }); + pdpTab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PRODUCT_PAGE); + // Assert that the on-PDP message is now matched, due to targeting. + is( + await shoppingProductPageWithSidebarClosedMsg, + "FAKESPOT_CALLOUT_PDP_OPTED_IN_DEFAULT", + "Should route the on-PDP message" + ); + BrowserTestUtils.removeTab(pdpTab); + + // Clean up. + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); + await ASRouter.setState(state => { + const messageImpressions = { ...state.messageImpressions }; + delete messageImpressions.FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT; + ASRouter._storage.set("messageImpressions", messageImpressions); + return { messageImpressions }; + }); + sandbox.restore(); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders( + ASRouter.state.providers.filter(p => p.id === "onboarding") + ); +}); + +/** Test that the correct callouts show for not-opted-in users */ +add_task(async function test_fakespot_callouts_not_opted_in_flow() { + await SpecialPowers.pushPrefEnv({ set: [[CFR_ENABLED_PREF, true]] }); + let sandbox = sinon.createSandbox(); + let routeCFRMessageStub = sandbox + .stub(ASRouter, "routeCFRMessage") + .withArgs( + sinon.match.any, + sinon.match.any, + sinon.match({ id: "shoppingProductPageWithSidebarClosed" }) + ); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders( + ASRouter.state.providers.filter(p => p.id === "onboarding") + ); + + // Reset opt-in but make the sidebar active so it appears on PDP. + await SpecialPowers.pushPrefEnv({ + set: [ + [ACTIVE_PREF, true], + [OPTED_IN_PREF, 0], + ], + }); + + // Visit a product page and wait for the sidebar to open. + let pdpTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + PRODUCT_PAGE + ); + let pdpBrowser = pdpTab.linkedBrowser; + let pdpBrowserPanel = gBrowser.getPanel(pdpBrowser); + let isSidebarVisible = () => { + let sidebar = pdpBrowserPanel.querySelector("shopping-sidebar"); + return sidebar && BrowserTestUtils.isVisible(sidebar); + }; + await BrowserTestUtils.waitForMutationCondition( + pdpBrowserPanel, + { childList: true, attributeFilter: ["hidden"] }, + isSidebarVisible + ); + ok(isSidebarVisible(), "Shopping sidebar should be open on a product page"); + + // Close the sidebar by deactivating the global toggle, and wait for the + // shoppingProductPageWithSidebarClosed trigger to fire. + let shoppingProductPageWithSidebarClosedMsg = new Promise(resolve => { + routeCFRMessageStub.callsFake((message, browser, trigger) => { + if ( + trigger.id === "shoppingProductPageWithSidebarClosed" && + trigger.context.isSidebarClosing + ) { + resolve(message?.id); + } + }); + }); + await SpecialPowers.pushPrefEnv({ set: [[ACTIVE_PREF, false]] }); + // Assert that the message is the one we expect. + is( + await shoppingProductPageWithSidebarClosedMsg, + "FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT", + "Should route the expected message: FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT" + ); + BrowserTestUtils.removeTab(pdpTab); + + // Unlike the opted-in flow, at this point we should not expect to see any + // more callouts, because the flow ends after the on-closed callout. So we can + // test that FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT's targeting excludes us + // even if it's been 25 hours since the sidebar was closed. + + // As with the opted-in flow, override the state so it looks like we closed + // the sidebar 25 hours ago. + let lastClosedDate = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago + await ASRouter.setState(state => { + const messageImpressions = { ...state.messageImpressions }; + messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT = [ + lastClosedDate, + ]; + ASRouter._storage.set("messageImpressions", messageImpressions); + return { messageImpressions }; + }); + + // Visit a product page and wait for routeCFRMessage to fire, expecting the + // message to be null due to targeting. + shoppingProductPageWithSidebarClosedMsg = new Promise(resolve => { + routeCFRMessageStub.callsFake((message, browser, trigger) => { + if ( + trigger.id === "shoppingProductPageWithSidebarClosed" && + !trigger.context.isSidebarClosing + ) { + resolve(message?.id); + } + }); + }); + pdpTab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PRODUCT_PAGE); + // Assert that the on-PDP message is not matched, due to targeting. + isnot( + await shoppingProductPageWithSidebarClosedMsg, + "FAKESPOT_CALLOUT_PDP_OPTED_IN_DEFAULT", + "Should not route the on-PDP message because the user is not opted in" + ); + BrowserTestUtils.removeTab(pdpTab); + + // Clean up. We don't need to verify that the frequency caps work, since + // that's a generic ASRouter feature. + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); + await ASRouter.setState(state => { + const messageImpressions = { ...state.messageImpressions }; + delete messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT; + ASRouter._storage.set("messageImpressions", messageImpressions); + return { messageImpressions }; + }); + sandbox.restore(); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders( + ASRouter.state.providers.filter(p => p.id === "onboarding") + ); +}); diff --git a/browser/components/shopping/tests/browser/browser_shopping_onboarding.js b/browser/components/shopping/tests/browser/browser_shopping_onboarding.js new file mode 100644 index 0000000000..725ce6d8c2 --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_shopping_onboarding.js @@ -0,0 +1,661 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ShoppingUtils: "resource:///modules/ShoppingUtils.sys.mjs", +}); + +const { SpecialMessageActions } = ChromeUtils.importESModule( + "resource://messaging-system/lib/SpecialMessageActions.sys.mjs" +); + +/** + * Toggle prefs involved in automatically activating the sidebar on PDPs if the + * user has not opted in. Onboarding should only try to auto-activate the + * sidebar for non-opted-in users once per session at most, no more than once + * per day, and no more than two times total. + * + * @param {object} states An object containing pref states to set. Leave a + * property undefined to ignore it. + * @param {boolean} [states.active] Global sidebar toggle + * @param {number} [states.optedIn] 2: opted out, 1: opted in, 0: not opted in + * @param {number} [states.lastAutoActivate] Last auto activate date in seconds + * @param {number} [states.autoActivateCount] Number of auto-activations (max 2) + * @param {boolean} [states.handledAutoActivate] True if the sidebar handled its + * auto-activation logic this session, preventing further auto-activations + */ +function setOnboardingPrefs(states = {}) { + if (Object.hasOwn(states, "handledAutoActivate")) { + ShoppingUtils.handledAutoActivate = !!states.handledAutoActivate; + } + + if (Object.hasOwn(states, "lastAutoActivate")) { + Services.prefs.setIntPref( + "browser.shopping.experience2023.lastAutoActivate", + states.lastAutoActivate + ); + } + + if (Object.hasOwn(states, "autoActivateCount")) { + Services.prefs.setIntPref( + "browser.shopping.experience2023.autoActivateCount", + states.autoActivateCount + ); + } + + if (Object.hasOwn(states, "optedIn")) { + Services.prefs.setIntPref( + "browser.shopping.experience2023.optedIn", + states.optedIn + ); + } + + if (Object.hasOwn(states, "active")) { + Services.prefs.setBoolPref( + "browser.shopping.experience2023.active", + states.active + ); + } + + if (Object.hasOwn(states, "telemetryEnabled")) { + Services.prefs.setBoolPref( + "browser.newtabpage.activity-stream.telemetry", + states.telemetryEnabled + ); + } +} + +add_setup(async function setup() { + // Block on testFlushAllChildren to ensure Glean is initialized before + // running tests. + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + // Set all the prefs/states modified by this test to default values. + registerCleanupFunction(() => + setOnboardingPrefs({ + active: true, + optedIn: 1, + lastAutoActivate: 0, + autoActivateCount: 0, + handledAutoActivate: false, + telementryEnabled: false, + }) + ); +}); + +/** + * Test to check onboarding message container is rendered + * when user is not opted-in + */ +add_task(async function test_showOnboarding_notOptedIn() { + // OptedIn pref Value is 0 when a user hasn't opted-in + setOnboardingPrefs({ active: false, optedIn: 0, telemetryEnabled: true }); + + Services.fog.testResetFOG(); + await Services.fog.testFlushAllChildren(); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + // Get the actor to update the product URL, since no content will render without one + let actor = + gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getExistingActor( + "ShoppingSidebar" + ); + actor.updateProductURL("https://example.com/product/B09TJGHL5F"); + + await SpecialPowers.spawn(browser, [], async () => { + let shoppingContainer = await ContentTaskUtils.waitForCondition( + () => content.document.querySelector("shopping-container"), + "shopping-container" + ); + + let containerElem = + shoppingContainer.shadowRoot.getElementById("shopping-container"); + let messageSlot = containerElem.getElementsByTagName("slot"); + + // Check multi-stage-message-slot used to show opt-In message is + // rendered inside shopping container when user optedIn pref value is 0 + ok(messageSlot.length, `message slot element exists`); + is( + messageSlot[0].name, + "multi-stage-message-slot", + "multi-stage-message-slot showing opt-in message rendered" + ); + + ok( + !content.document.getElementById("multi-stage-message-root").hidden, + "message is shown" + ); + }); + } + ); + + if (!AppConstants.platform != "linux") { + await Services.fog.testFlushAllChildren(); + const events = Glean.shopping.surfaceOnboardingDisplayed.testGetValue(); + + if (events) { + Assert.greater(events.length, 0); + Assert.equal(events[0].category, "shopping"); + Assert.equal(events[0].name, "surface_onboarding_displayed"); + } else { + info("Failed to get Glean value due to unknown bug. See bug 1862389."); + } + } +}); + +/** + * Test to check onboarding message is not shown for opted-in users + */ +add_task(async function test_hideOnboarding_optedIn() { + // OptedIn pref value is 1 for opted-in users + setOnboardingPrefs({ active: false, optedIn: 1 }); + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + // Get the actor to update the product URL, since no content will render without one + let actor = + gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getExistingActor( + "ShoppingSidebar" + ); + actor.updateProductURL("https://example.com/product/B09TJGHL5F"); + + await SpecialPowers.spawn(browser, [], async () => { + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector("shopping-container"), + "shopping-container" + ); + + ok( + content.document.getElementById("multi-stage-message-root").hidden, + "message is hidden" + ); + }); + } + ); +}); + +/** + * Test to check onboarding message does not show when selecting "not now" + * + * Also confirms a Glean event was triggered. + */ +add_task(async function test_hideOnboarding_onClose() { + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + // OptedIn pref value is 0 when a user has not opted-in + setOnboardingPrefs({ active: false, optedIn: 0, telemetryEnabled: true }); + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + // Get the actor to update the product URL, since no content will render without one + let actor = + gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getExistingActor( + "ShoppingSidebar" + ); + actor.updateProductURL("https://example.com/product/B09TJGHL5F"); + + await SpecialPowers.spawn(browser, [], async () => { + let shoppingContainer = await ContentTaskUtils.waitForCondition( + () => content.document.querySelector("shopping-container"), + "shopping-container" + ); + // "Not now" button + let notNowButton = await ContentTaskUtils.waitForCondition(() => + shoppingContainer.querySelector(".additional-cta") + ); + + notNowButton.click(); + + // Does not render shopping container onboarding message + ok( + !shoppingContainer.length, + "Shopping container element does not exist" + ); + }); + } + ); + + await Services.fog.testFlushAllChildren(); + let events = Glean.shopping.surfaceNotNowClicked.testGetValue(); + + await BrowserTestUtils.waitForCondition(() => { + let _events = Glean.shopping.surfaceNotNowClicked.testGetValue(); + return _events?.length > 0; + }); + + Assert.greater(events.length, 0); + Assert.equal(events[0].category, "shopping"); + Assert.equal(events[0].name, "surface_not_now_clicked"); +}); + +/** + * Test to check behavior when selecting 'Yes, try it to opt in to the + * shopping experience. + * + * Also tests if a Glean event was correctly recorded. + */ +add_task(async function test_onOptIn() { + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + setOnboardingPrefs({ active: false, optedIn: 0, telemetryEnabled: true }); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn(browser, [], async () => { + await ContentTaskUtils.waitForMutationCondition( + content.document, + { childList: true, subtree: true }, + () => !!content.document.querySelector("shopping-container .primary") + ); + + // "Yes, try it" button + let primary = content.document.querySelector( + "shopping-container .primary" + ); + primary.click(); + }); + } + ); + + await Services.fog.testFlushAllChildren(); + let events = Glean.shopping.surfaceOptInClicked.testGetValue(); + + await BrowserTestUtils.waitForCondition(() => { + let _events = Glean.shopping.surfaceOptInClicked.testGetValue(); + return _events?.length > 0; + }); + + Assert.greater(events.length, 0); + Assert.equal(events[0].category, "shopping"); + Assert.equal(events[0].name, "surface_opt_in_clicked"); +}); + +/** + * Helper function to click the links in the Link Paragraph. + */ +async function linkParagraphClickLinks() { + const sandbox = sinon.createSandbox(); + + let handleActionStub = sandbox + .stub(SpecialMessageActions, "handleAction") + .withArgs(sandbox.match({ type: "OPEN_URL" })); + + let handleActionStubCalled = new Promise(resolve => + handleActionStub.callsFake(resolve) + ); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn(browser, [], async () => { + await ContentTaskUtils.waitForMutationCondition( + content.document, + { childList: true, subtree: true }, + // Can safely assume that if one of the link exists, they both do. + () => + !!content.document.querySelector( + ".legal-paragraph a[value='terms_of_use']" + ) + ); + + let termsOfUse = content.document.querySelector( + "shopping-container .legal-paragraph a[value='terms_of_use']" + ); + termsOfUse.click(); + }); + } + ); + + await handleActionStubCalled; + + handleActionStub.resetHistory(); + + handleActionStubCalled = new Promise(resolve => + handleActionStub.callsFake(resolve) + ); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn(browser, [], async () => { + await ContentTaskUtils.waitForMutationCondition( + content.document, + { childList: true, subtree: true }, + // Can safely assume that if one of the link exists, they both do. + () => + !!content.document.querySelector( + ".legal-paragraph a[value='terms_of_use']" + ) + ); + let privacyPolicy = content.document.querySelector( + "shopping-container .legal-paragraph a[value='privacy_policy']" + ); + privacyPolicy.click(); + }); + } + ); + await handleActionStubCalled; + + handleActionStub.resetHistory(); + + handleActionStubCalled = new Promise(resolve => + handleActionStub.callsFake(resolve) + ); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn(browser, [], async () => { + await ContentTaskUtils.waitForMutationCondition( + content.document, + { childList: true, subtree: true }, + () => content.document.querySelector(".link-paragraph a") + ); + let learnMore = content.document.querySelector( + "shopping-container .link-paragraph a[value='learn_more']" + ); + // Learn More link button. + learnMore.click(); + }); + } + ); + await handleActionStubCalled; + + sandbox.restore(); +} + +/** + * Test to check behavior when selecting links in the link-paragraph + * to opt in to the + * shopping experience. + * + * Also tests if a Glean event was correctly recorded. + */ +add_task(async function test_linkParagraph() { + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + setOnboardingPrefs({ active: false, optedIn: 0, telemetryEnabled: true }); + + await linkParagraphClickLinks(); + + await Services.fog.testFlushAllChildren(); + let privacyEvents = + Glean.shopping.surfaceShowPrivacyPolicyClicked.testGetValue(); + + Assert.greater(privacyEvents.length, 0); + Assert.equal(privacyEvents[0].category, "shopping"); + Assert.equal(privacyEvents[0].name, "surface_show_privacy_policy_clicked"); + + let tosEvents = Glean.shopping.surfaceShowTermsClicked.testGetValue(); + + Assert.greater(tosEvents.length, 0); + Assert.equal(tosEvents[0].category, "shopping"); + Assert.equal(tosEvents[0].name, "surface_show_terms_clicked"); + + let learnMoreEvents = Glean.shopping.surfaceLearnMoreClicked.testGetValue(); + + Assert.greater(learnMoreEvents.length, 0); + Assert.equal(learnMoreEvents[0].category, "shopping"); + Assert.equal(learnMoreEvents[0].name, "surface_learn_more_clicked"); +}); + +add_task(async function test_onboarding_auto_activate_opt_in() { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", + true, + ], + ], + }); + // Opt out of the feature + setOnboardingPrefs({ + active: false, + optedIn: 0, + lastAutoActivate: 0, + autoActivateCount: 0, + handledAutoActivate: false, + }); + ShoppingUtils.handleAutoActivateOnProduct(); + + // User is not opted-in, and auto-activate has not happened yet. So it should + // be enabled now. + ok( + Services.prefs.getBoolPref("browser.shopping.experience2023.active"), + "Global toggle should be activated to open the sidebar on PDPs" + ); + + // Now opt in, deactivate the global toggle, and reset the targeting prefs. + // The sidebar should no longer open on PDPs, since the user is opted in and + // the global toggle is off. + + setOnboardingPrefs({ + active: false, + optedIn: 1, + lastAutoActivate: 0, + autoActivateCount: 1, + handledAutoActivate: false, + }); + ShoppingUtils.handleAutoActivateOnProduct(); + + ok( + !Services.prefs.getBoolPref("browser.shopping.experience2023.active"), + "Global toggle should not activate again since user is opted in" + ); +}); + +add_task(async function test_onboarding_auto_activate_not_now() { + // Opt of the feature so it auto-activates once. + setOnboardingPrefs({ + active: false, + optedIn: 0, + lastAutoActivate: 0, + autoActivateCount: 0, + handledAutoActivate: false, + }); + ShoppingUtils.handleAutoActivateOnProduct(); + + ok( + Services.prefs.getBoolPref("browser.shopping.experience2023.active"), + "Global toggle should be activated to open the sidebar on PDPs" + ); + + // After auto-activating once, we should not auto-activate again in this + // session. So when we click "Not now", it should deactivate the global + // toggle, closing all sidebars, and sidebars should not open again on PDPs. + // Test that handledAutoActivate was set automatically by the previous + // auto-activate, and that it prevents the toggle from activating again. + setOnboardingPrefs({ active: false }); + ShoppingUtils.handleAutoActivateOnProduct(); + + ok( + !Services.prefs.getBoolPref("browser.shopping.experience2023.active"), + "Global toggle should not activate again this session" + ); + + // There are 3 conditions for auto-activating the sidebar before opt-in: + // 1. The sidebar has not already been automatically set to `active` twice. + // 2. It's been at least 24 hours since the user last saw the sidebar because + // of this auto-activation behavior. + // 3. This method has not already been called (handledAutoActivate is false) + // Let's test each of these conditions, in isolation. + + // Reset the auto-activate count to 0, and set the last auto-activate to never + // opened. Leave the handledAutoActivate flag set to true, so we can + // test that the sidebar auto-activate is still blocked if we already + // auto-activated previously this session. + setOnboardingPrefs({ + active: false, + optedIn: 0, + lastAutoActivate: 0, + autoActivateCount: 0, + handledAutoActivate: true, + }); + ShoppingUtils.handleAutoActivateOnProduct(); + + ok( + !Services.prefs.getBoolPref("browser.shopping.experience2023.active"), + "Shopping sidebar should not auto-activate if auto-activated previously this session" + ); + + // Now test that sidebar auto-activate is blocked if the last auto-activate + // was less than 24 hours ago. + setOnboardingPrefs({ + active: false, + optedIn: 0, + lastAutoActivate: Date.now() / 1000, + autoActivateCount: 1, + handledAutoActivate: false, + }); + ShoppingUtils.handleAutoActivateOnProduct(); + + ok( + !Services.prefs.getBoolPref("browser.shopping.experience2023.active"), + "Shopping sidebar should not auto-activate if last auto-activation was less than 24 hours ago" + ); + + // Test that auto-activate is blocked if the sidebar has been auto-activated + // twice already. + setOnboardingPrefs({ + active: false, + optedIn: 0, + lastAutoActivate: 0, + autoActivateCount: 2, + handledAutoActivate: false, + }); + ShoppingUtils.handleAutoActivateOnProduct(); + + ok( + !Services.prefs.getBoolPref("browser.shopping.experience2023.active"), + "Shopping sidebar should not auto-activate if it has already been auto-activated twice" + ); + + // Now test that auto-activate is unblocked if all 3 conditions are met. + setOnboardingPrefs({ + active: false, + optedIn: 0, + lastAutoActivate: Date.now() / 1000 - 2 * 24 * 60 * 60, // 2 days ago + autoActivateCount: 1, + handledAutoActivate: false, + }); + ShoppingUtils.handleAutoActivateOnProduct(); + + ok( + Services.prefs.getBoolPref("browser.shopping.experience2023.active"), + "Shopping sidebar should auto-activate a second time if all conditions are met" + ); +}); + +/** + * Test to check onboarding message is not shown for user + * after a user opt-out and opt back in after seeing survey + */ + +add_task(async function test_hideOnboarding_OptIn_AfterSurveySeen() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.optedIn", 0], + ["browser.shopping.experience2023.survey.enabled", true], + ["browser.shopping.experience2023.survey.hasSeen", true], + ["browser.shopping.experience2023.survey.pdpVisits", 5], + ], + }); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + let actor = + gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getExistingActor( + "ShoppingSidebar" + ); + actor.updateProductURL("https://example.com/product/B09TJGHL5F"); + + await SpecialPowers.spawn(browser, [], async () => { + let shoppingContainer = await ContentTaskUtils.waitForCondition( + () => content.document.querySelector("shopping-container"), + "shopping-container" + ); + + ok( + !content.document.getElementById("multi-stage-message-root").hidden, + "opt-in message is shown" + ); + + const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" + ); + + let optedInPrefChanged = TestUtils.waitForPrefChange( + "browser.shopping.experience2023.optedIn", + value => value === 1 + ); + await SpecialPowers.pushPrefEnv({ + set: [["browser.shopping.experience2023.optedIn", 1]], + }); + await optedInPrefChanged; + await shoppingContainer.wrappedJSObject.updateComplete; + + ok( + content.document.getElementById("multi-stage-message-root").hidden, + "opt-in message is hidden" + ); + await SpecialPowers.popPrefEnv(); + }); + } + ); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_deactivate_sidebar_if_user_turns_off_cfr() { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", + false, + ], + ], + }); + // Opt out of the feature + setOnboardingPrefs({ + active: false, + optedIn: 0, + lastAutoActivate: 0, + autoActivateCount: 0, + handledAutoActivate: false, + }); + ShoppingUtils.handleAutoActivateOnProduct(); + + ok( + !Services.prefs.getBoolPref("browser.shopping.experience2023.active"), + "Shopping sidebar should not auto-activate if Recommended features is turned off" + ); +}); diff --git a/browser/components/shopping/tests/browser/browser_shopping_settings.js b/browser/components/shopping/tests/browser/browser_shopping_settings.js new file mode 100644 index 0000000000..2508be05c7 --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_shopping_settings.js @@ -0,0 +1,642 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the fakespot link has the expected url and utm parameters. + */ +add_task(async function test_shopping_settings_fakespot_learn_more() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn( + browser, + [MOCK_ANALYZED_PRODUCT_RESPONSE], + async mockData => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + + let href = shoppingContainer.settingsEl.fakespotLearnMoreLinkEl.href; + let url = new URL(href); + is(url.pathname, "/our-mission"); + is(url.origin, "https://www.fakespot.com"); + + let qs = url.searchParams; + is(qs.get("utm_source"), "review-checker"); + is(qs.get("utm_campaign"), "fakespot-by-mozilla"); + is(qs.get("utm_medium"), "inproduct"); + is(qs.get("utm_term"), "core-sidebar"); + } + ); + } + ); +}); + +/** + * Tests that the ads link has the expected utm parameters. + */ +add_task(async function test_shopping_settings_ads_learn_more() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.shopping.experience2023.ads.enabled", true]], + }); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn( + browser, + [MOCK_ANALYZED_PRODUCT_RESPONSE], + async mockData => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + + let href = shoppingContainer.settingsEl.adsLearnMoreLinkEl.href; + let qs = new URL(href).searchParams; + + is(qs.get("utm_campaign"), "learn-more"); + is(qs.get("utm_medium"), "inproduct"); + is(qs.get("utm_term"), "core-sidebar"); + } + ); + } + ); +}); + +/** + * Tests that the settings component is rendered as expected when + * `browser.shopping.experience2023.ads.enabled` is true. + */ +add_task(async function test_shopping_settings_ads_enabled() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.shopping.experience2023.ads.enabled", true]], + }); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn( + browser, + [MOCK_ANALYZED_PRODUCT_RESPONSE], + async mockData => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + + shoppingContainer.data = Cu.cloneInto(mockData, content); + // Note (Bug 1876878): Until we have proper mocks of data passed from ShoppingSidebarChild, + // hardcode `adsEnabled` to be passed to settings.mjs so that we can test + // toggle for ad visibility. + shoppingContainer.adsEnabled = true; + await shoppingContainer.updateComplete; + + let shoppingSettings = shoppingContainer.settingsEl; + ok(shoppingSettings, "Got the shopping-settings element"); + + let adsToggle = shoppingSettings.recommendationsToggleEl; + ok(adsToggle, "There should be an ads toggle"); + + let optOutButton = shoppingSettings.optOutButtonEl; + ok(optOutButton, "There should be an opt-out button"); + } + ); + } + ); + + await SpecialPowers.popPrefEnv(); +}); + +/** + * Tests that the settings component is rendered as expected when + * `browser.shopping.experience2023.ads.enabled` is false. + */ +add_task(async function test_shopping_settings_ads_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.shopping.experience2023.ads.enabled", false]], + }); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + let shoppingSettings = await getSettingsDetails( + browser, + MOCK_POPULATED_DATA + ); + ok(shoppingSettings.settingsEl, "Got the shopping-settings element"); + + let adsToggle = shoppingSettings.recommendationsToggleEl; + ok(!adsToggle, "There should be no ads toggle"); + + let optOutButton = shoppingSettings.optOutButtonEl; + ok(optOutButton, "There should be an opt-out button"); + } + ); + + await SpecialPowers.popPrefEnv(); +}); + +/** + * Tests that the shopping-settings ads toggle and ad render correctly, even with + * multiple tabs. If `browser.shopping.experience2023.ads.userEnabled` + * is false in one tab, it should be false for all other tabs with the shopping sidebar open. + */ +add_task(async function test_settings_toggle_ad_and_multiple_tabs() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.ads.enabled", true], + ["browser.shopping.experience2023.ads.userEnabled", true], + ], + }); + + // Tab 1 - ad is visible at first and then toggle is selected to set ads.userEnabled to false. + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:shoppingsidebar" + ); + let browser1 = tab1.linkedBrowser; + + let mockArgs = { + mockData: MOCK_ANALYZED_PRODUCT_RESPONSE, + mockRecommendationData: MOCK_RECOMMENDED_ADS_RESPONSE, + }; + await SpecialPowers.spawn(browser1, [mockArgs], async args => { + const { mockData, mockRecommendationData } = args; + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + + let adVisiblePromise = ContentTaskUtils.waitForCondition(() => { + return ( + shoppingContainer.recommendedAdEl && + ContentTaskUtils.isVisible(shoppingContainer.recommendedAdEl) + ); + }, "Waiting for recommended-ad to be visible"); + + shoppingContainer.data = Cu.cloneInto(mockData, content); + shoppingContainer.recommendationData = Cu.cloneInto( + mockRecommendationData, + content + ); + // Note (Bug 1876878): Until we have proper mocks of data passed from ShoppingSidebarChild, + // hardcode `adsEnabled` and `adsEnabledByUser` so that we can test ad visibility. + shoppingContainer.adsEnabled = true; + shoppingContainer.adsEnabledByUser = true; + + await shoppingContainer.updateComplete; + await adVisiblePromise; + + let adEl = shoppingContainer.recommendedAdEl; + await adEl.updateComplete; + is( + adEl.priceEl.textContent, + "$" + mockRecommendationData[0].price, + "Price is shown correctly" + ); + is( + adEl.linkEl.title, + mockRecommendationData[0].name, + "Title in link is shown correctly" + ); + is( + adEl.linkEl.href, + mockRecommendationData[0].url, + "URL for link is correct" + ); + is( + adEl.ratingEl.rating, + mockRecommendationData[0].adjusted_rating, + "MozFiveStar rating is shown correctly" + ); + is( + adEl.letterGradeEl.letter, + mockRecommendationData[0].grade, + "LetterGrade letter is shown correctly" + ); + + let shoppingSettings = shoppingContainer.settingsEl; + ok(shoppingSettings, "Got the shopping-settings element"); + + let adsToggle = shoppingSettings.recommendationsToggleEl; + ok(adsToggle, "There should be a toggle"); + ok(adsToggle.hasAttribute("pressed"), "Toggle should have enabled state"); + + ok( + SpecialPowers.getBoolPref( + "browser.shopping.experience2023.ads.userEnabled" + ), + "ads userEnabled pref should be true" + ); + + let adRemovedPromise = ContentTaskUtils.waitForCondition(() => { + return !shoppingContainer.recommendedAdEl; + }, "Waiting for recommended-ad to be removed"); + + adsToggle.click(); + + await adRemovedPromise; + + ok(!adsToggle.hasAttribute("pressed"), "Toggle should have disabled state"); + ok( + !SpecialPowers.getBoolPref( + "browser.shopping.experience2023.ads.userEnabled" + ), + "ads userEnabled pref should be false" + ); + }); + + // Tab 2 - ads.userEnabled should still be false and ad should not be visible. + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:shoppingsidebar" + ); + let browser2 = tab2.linkedBrowser; + + await SpecialPowers.spawn(browser2, [mockArgs], async args => { + const { mockData, mockRecommendationData } = args; + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + + shoppingContainer.data = Cu.cloneInto(mockData, content); + shoppingContainer.recommendationData = Cu.cloneInto( + mockRecommendationData, + content + ); + // Note (Bug 1876878): Until we have proper mocks of data passed from ShoppingSidebarChild, + // hardcode `adsEnabled` so that we can test ad visibility. + shoppingContainer.adsEnabled = true; + + await shoppingContainer.updateComplete; + + ok( + !shoppingContainer.recommendedAdEl, + "There should be no ads in the new tab" + ); + ok( + !SpecialPowers.getBoolPref( + "browser.shopping.experience2023.ads.userEnabled" + ), + "ads userEnabled pref should be false" + ); + }); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); +}); + +/** + * Tests that the settings component is rendered as expected when + * `browser.shopping.experience2023.autoOpen.enabled` is false. + */ +add_task(async function test_shopping_settings_experiment_auto_open_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.shopping.experience2023.autoOpen.enabled", false]], + }); + + await BrowserTestUtils.withNewTab( + { + url: PRODUCT_TEST_URL, + gBrowser, + }, + async browser => { + let sidebar = gBrowser + .getPanel(browser) + .querySelector("shopping-sidebar"); + await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL); + + await SpecialPowers.spawn( + sidebar.querySelector("browser"), + [MOCK_ANALYZED_PRODUCT_RESPONSE], + async mockData => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + + let shoppingSettings = shoppingContainer.settingsEl; + ok(shoppingSettings, "Got the shopping-settings element"); + ok( + !shoppingSettings.wrapperEl.className.includes( + "shopping-settings-auto-open-ui-enabled" + ), + "Settings card should not have a special classname with autoOpen pref disabled" + ); + is( + shoppingSettings.shoppingCardEl?.type, + "accordion", + "shopping-card type should be accordion" + ); + + /* Verify control treatment UI */ + ok( + !shoppingSettings.autoOpenToggleEl, + "There should be no auto-open toggle" + ); + ok( + !shoppingSettings.autoOpenToggleDescriptionEl, + "There should be no description for the auto-open toggle" + ); + ok(!shoppingSettings.dividerEl, "There should be no divider"); + ok( + !shoppingSettings.sidebarEnabledStateEl, + "There should be no message about the sidebar active state" + ); + + ok( + shoppingSettings.optOutButtonEl, + "There should be an opt-out button" + ); + } + ); + } + ); + + await SpecialPowers.popPrefEnv(); +}); + +/** + * Tests that the settings component is rendered as expected when + * `browser.shopping.experience2023.autoOpen.enabled` is true and + * `browser.shopping.experience2023.ads.enabled is true`. + */ +add_task( + async function test_shopping_settings_experiment_auto_open_enabled_with_ads() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.autoOpen.enabled", true], + ["browser.shopping.experience2023.autoOpen.userEnabled", true], + ["browser.shopping.experience2023.ads.enabled", true], + ], + }); + + await BrowserTestUtils.withNewTab( + { + url: PRODUCT_TEST_URL, + gBrowser, + }, + async browser => { + let sidebar = gBrowser + .getPanel(browser) + .querySelector("shopping-sidebar"); + await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL); + + await SpecialPowers.spawn( + sidebar.querySelector("browser"), + [MOCK_ANALYZED_PRODUCT_RESPONSE], + async mockData => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + + await shoppingContainer.updateComplete; + + let shoppingSettings = shoppingContainer.settingsEl; + ok(shoppingSettings, "Got the shopping-settings element"); + ok( + shoppingSettings.wrapperEl.className.includes( + "shopping-settings-auto-open-ui-enabled" + ), + "Settings card should have a special classname with autoOpen pref enabled" + ); + is( + shoppingSettings.shoppingCardEl?.type, + "", + "shopping-card type should be default" + ); + + ok( + shoppingSettings.recommendationsToggleEl, + "There should be an ads toggle" + ); + + /* Verify auto-open experiment UI */ + ok( + shoppingSettings.autoOpenToggleEl, + "There should be an auto-open toggle" + ); + ok( + shoppingSettings.autoOpenToggleDescriptionEl, + "There should be a description for the auto-open toggle" + ); + ok(shoppingSettings.dividerEl, "There should be a divider"); + ok( + shoppingSettings.sidebarEnabledStateEl, + "There should be a message about the sidebar active state" + ); + + ok( + shoppingSettings.optOutButtonEl, + "There should be an opt-out button" + ); + } + ); + } + ); + + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); + } +); + +/** + * Tests that the settings component is rendered as expected when + * `browser.shopping.experience2023.autoOpen.enabled` is true and + * `browser.shopping.experience2023.ads.enabled is false`. + */ +add_task( + async function test_shopping_settings_experiment_auto_open_enabled_no_ads() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.autoOpen.enabled", true], + ["browser.shopping.experience2023.autoOpen.userEnabled", true], + ["browser.shopping.experience2023.ads.enabled", false], + ], + }); + + await BrowserTestUtils.withNewTab( + { + url: PRODUCT_TEST_URL, + gBrowser, + }, + async browser => { + let sidebar = gBrowser + .getPanel(browser) + .querySelector("shopping-sidebar"); + await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL); + + await SpecialPowers.spawn( + sidebar.querySelector("browser"), + [MOCK_ANALYZED_PRODUCT_RESPONSE], + async mockData => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + + await shoppingContainer.updateComplete; + + let shoppingSettings = shoppingContainer.settingsEl; + ok(shoppingSettings, "Got the shopping-settings element"); + ok( + shoppingSettings.wrapperEl.className.includes( + "shopping-settings-auto-open-ui-enabled" + ), + "Settings card should have a special classname with autoOpen pref enabled" + ); + is( + shoppingSettings.shoppingCardEl?.type, + "", + "shopping-card type should be default" + ); + + ok( + !shoppingSettings.recommendationsToggleEl, + "There should be no ads toggle" + ); + + /* Verify auto-open experiment UI */ + ok( + shoppingSettings.autoOpenToggleEl, + "There should be an auto-open toggle" + ); + ok( + shoppingSettings.autoOpenToggleDescriptionEl, + "There should be a description for the auto-open toggle" + ); + ok(shoppingSettings.dividerEl, "There should be a divider"); + ok( + shoppingSettings.sidebarEnabledStateEl, + "There should be a message about the sidebar active state" + ); + + ok( + shoppingSettings.optOutButtonEl, + "There should be an opt-out button" + ); + } + ); + } + ); + + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); + } +); + +/** + * Tests that auto-open toggle state and autoOpen.userEnabled pref update correctly. + */ +add_task(async function test_settings_auto_open_toggle() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.autoOpen.enabled", true], + ["browser.shopping.experience2023.autoOpen.userEnabled", true], + ], + }); + + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + PRODUCT_TEST_URL + ); + let browser = tab1.linkedBrowser; + + let mockArgs = { + mockData: MOCK_ANALYZED_PRODUCT_RESPONSE, + }; + + let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar"); + await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL); + + await SpecialPowers.spawn( + sidebar.querySelector("browser"), + [mockArgs], + async args => { + const { mockData } = args; + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + + let shoppingSettings = shoppingContainer.settingsEl; + ok(shoppingSettings, "Got the shopping-settings element"); + + let autoOpenToggle = shoppingSettings.autoOpenToggleEl; + ok(autoOpenToggle, "There should be an auto-open toggle"); + ok( + autoOpenToggle.hasAttribute("pressed"), + "Toggle should have enabled state" + ); + + let toggleStateChangePromise = ContentTaskUtils.waitForCondition(() => { + return !autoOpenToggle.hasAttribute("pressed"); + }, "Waiting for auto-open toggle state to be disabled"); + + autoOpenToggle.click(); + + await toggleStateChangePromise; + + ok( + !SpecialPowers.getBoolPref( + "browser.shopping.experience2023.autoOpen.userEnabled" + ), + "autoOpen.userEnabled pref should be false" + ); + ok( + SpecialPowers.getBoolPref( + "browser.shopping.experience2023.autoOpen.enabled" + ), + "autoOpen.enabled pref should still be true" + ); + ok( + !SpecialPowers.getBoolPref("browser.shopping.experience2023.active"), + "Sidebar active pref should be false after pressing auto-open toggle to close the sidebar" + ); + + // Now try updating the pref directly to see if toggle will change state immediately + await SpecialPowers.popPrefEnv(); + toggleStateChangePromise = ContentTaskUtils.waitForCondition(() => { + return autoOpenToggle.hasAttribute("pressed"); + }, "Waiting for auto-open toggle to be enabled"); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.autoOpen.userEnabled", true], + ["browser.shopping.experience2023.active", true], + ], + }); + + await toggleStateChangePromise; + } + ); + + BrowserTestUtils.removeTab(tab1); + + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/shopping/tests/browser/browser_shopping_sidebar.js b/browser/components/shopping/tests/browser/browser_shopping_sidebar.js new file mode 100644 index 0000000000..31cbc6d732 --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_shopping_sidebar.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const SHOPPING_SIDEBAR_WIDTH_PREF = + "browser.shopping.experience2023.sidebarWidth"; + +add_task(async function test_sidebar_opens_correct_size() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["toolkit.shopping.ohttpRelayURL", ""], + ["toolkit.shopping.ohttpConfigURL", ""], + ["browser.shopping.experience2023.active", true], + [SHOPPING_SIDEBAR_WIDTH_PREF, 0], + ], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: PRODUCT_TEST_URL, + }); + + let browserPanel = gBrowser.getPanel(tab.linkedBrowser); + let sidebar = browserPanel.querySelector("shopping-sidebar"); + + await TestUtils.waitForCondition(() => sidebar.scrollWidth === 320); + + is(sidebar.scrollWidth, 320, "Shopping sidebar should default to 320px"); + + let prefChangedPromise = TestUtils.waitForPrefChange( + SHOPPING_SIDEBAR_WIDTH_PREF + ); + sidebar.style.width = "345px"; + await TestUtils.waitForCondition(() => sidebar.scrollWidth === 345); + await prefChangedPromise; + + let shoppingButton = document.getElementById("shopping-sidebar-button"); + shoppingButton.click(); + await BrowserTestUtils.waitForMutationCondition( + shoppingButton, + { + attributeFilter: ["shoppingsidebaropen"], + }, + () => shoppingButton.getAttribute("shoppingsidebaropen") == "false" + ); + + shoppingButton.click(); + await BrowserTestUtils.waitForMutationCondition( + shoppingButton, + { + attributeFilter: ["shoppingsidebaropen"], + }, + () => shoppingButton.getAttribute("shoppingsidebaropen") == "true" + ); + + await TestUtils.waitForCondition(() => sidebar.scrollWidth === 345); + + is( + sidebar.scrollWidth, + 345, + "Shopping sidebar should open to previous set width of 345" + ); + + gBrowser.removeTab(tab); +}); diff --git a/browser/components/shopping/tests/browser/browser_shopping_survey.js b/browser/components/shopping/tests/browser/browser_shopping_survey.js new file mode 100644 index 0000000000..aebe6e9dcf --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_shopping_survey.js @@ -0,0 +1,337 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const currentTime = Date.now() / 1000; +const time25HrsAgo = currentTime - 25 * 60 * 60; +const time1HrAgo = currentTime - 1 * 60 * 60; + +add_task(async function test_setup() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn(browser, [], async () => { + let childActor = content.windowGlobalChild.getExistingActor( + "AboutWelcomeShopping" + ); + childActor.resetChildStates(); + }); + } + ); +}); + +/** + * Test to check survey renders when show survey conditions are met + */ +add_task(async function test_showSurvey_Enabled() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.optedIn", 1], + ["browser.shopping.experience2023.survey.enabled", true], + ["browser.shopping.experience2023.survey.hasSeen", false], + ["browser.shopping.experience2023.survey.pdpVisits", 5], + ["browser.shopping.experience2023.survey.optedInTime", time25HrsAgo], + ], + }); + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn( + browser, + [MOCK_ANALYZED_PRODUCT_RESPONSE], + async mockData => { + const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" + ); + let surveyPrefChanged = TestUtils.waitForPrefChange( + "browser.shopping.experience2023.survey.hasSeen" + ); + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + + // Manually send data update event, as it isn't set due to the lack of mock APIs. + // TODO: Support for the mocks will be added in Bug 1853474. + let mockObj = { + data: mockData, + productUrl: "https://example.com/product/1234", + }; + let evt = new content.CustomEvent("Update", { + bubbles: true, + detail: Cu.cloneInto(mockObj, content), + }); + content.document.dispatchEvent(evt); + + await shoppingContainer.updateComplete; + await surveyPrefChanged; + + let childActor = content.windowGlobalChild.getExistingActor( + "AboutWelcomeShopping" + ); + + ok(childActor.surveyEnabled, "Survey is Enabled"); + + let surveyScreen = await ContentTaskUtils.waitForCondition( + () => + content.document.querySelector( + "shopping-container .screen.SHOPPING_MICROSURVEY_SCREEN_1" + ), + "survey-screen" + ); + + ok(surveyScreen, "Survey screen is rendered"); + + ok( + childActor.showMicroSurvey, + "Show Survey targeting conditions met" + ); + Assert.strictEqual( + content.document + .getElementById("steps") + .getAttribute("data-l10n-id"), + "shopping-onboarding-welcome-steps-indicator-label", + "Steps indicator has appropriate fluent ID" + ); + ok( + !content.document.getElementById("multi-stage-message-root").hidden, + "Survey Message container is shown" + ); + ok( + content.document.querySelector(".dismiss-button"), + "Dismiss button is shown" + ); + + let survey_seen_status = Services.prefs.getBoolPref( + "browser.shopping.experience2023.survey.hasSeen", + false + ); + ok(survey_seen_status, "Survey pref state is updated"); + childActor.resetChildStates(); + } + ); + } + ); + await SpecialPowers.popPrefEnv(); +}); + +/** + * Test to check survey is hidden when survey enabled pref is false + */ +add_task(async function test_showSurvey_Disabled() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.optedIn", 1], + ["browser.shopping.experience2023.survey.enabled", false], + ["browser.shopping.experience2023.survey.hasSeen", false], + ["browser.shopping.experience2023.survey.pdpVisits", 5], + ["browser.shopping.experience2023.survey.optedInTime", time25HrsAgo], + ], + }); + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn( + browser, + [MOCK_ANALYZED_PRODUCT_RESPONSE], + async mockData => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + + // Manually send data update event, as it isn't set due to the lack of mock APIs. + // TODO: Support for the mocks will be added in Bug 1853474. + let mockObj = { + data: mockData, + productUrl: "https://example.com/product/1234", + }; + let evt = new content.CustomEvent("Update", { + bubbles: true, + detail: Cu.cloneInto(mockObj, content), + }); + content.document.dispatchEvent(evt); + + await shoppingContainer.updateComplete; + + let childActor = content.windowGlobalChild.getExistingActor( + "AboutWelcomeShopping" + ); + + ok(!childActor.surveyEnabled, "Survey is disabled"); + + let surveyScreen = content.document.querySelector( + "shopping-container .screen.SHOPPING_MICROSURVEY_SCREEN_1" + ); + + ok(!surveyScreen, "Survey screen is not rendered"); + ok( + !childActor.showMicroSurvey, + "Show Survey targeting conditions are not met" + ); + ok( + content.document.getElementById("multi-stage-message-root").hidden, + "Survey Message container is hidden" + ); + + childActor.resetChildStates(); + } + ); + } + ); + await SpecialPowers.popPrefEnv(); +}); + +/** + * Test to check survey display logic respects 24 hours after Opt-in rule + */ +add_task(async function test_24_hr_since_optin_rule() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.optedIn", 1], + ["browser.shopping.experience2023.survey.enabled", true], + ["browser.shopping.experience2023.survey.hasSeen", false], + ["browser.shopping.experience2023.survey.pdpVisits", 5], + ["browser.shopping.experience2023.survey.optedInTime", time1HrAgo], + ], + }); + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn( + browser, + [MOCK_ANALYZED_PRODUCT_RESPONSE], + async mockData => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + + let childActor = content.windowGlobalChild.getExistingActor( + "AboutWelcomeShopping" + ); + + let surveyScreen = content.document.querySelector( + "shopping-container .screen.SHOPPING_MICROSURVEY_SCREEN_1" + ); + + ok(!surveyScreen, "Survey screen is not rendered"); + ok( + !childActor.showMicroSurvey, + "Show Survey 24 hours after opt in conditions are not met" + ); + ok( + content.document.getElementById("multi-stage-message-root").hidden, + "Survey Message container is hidden" + ); + + childActor.resetChildStates(); + } + ); + } + ); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_confirmation_screen() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.optedIn", 1], + ["browser.shopping.experience2023.survey.enabled", true], + ["browser.shopping.experience2023.survey.hasSeen", false], + ["browser.shopping.experience2023.survey.pdpVisits", 5], + ["browser.shopping.experience2023.survey.optedInTime", time25HrsAgo], + ], + }); + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn( + browser, + [MOCK_ANALYZED_PRODUCT_RESPONSE], + async mockData => { + async function clickVisibleElement(selector) { + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector(selector), + `waiting for selector ${selector}`, + 200, // interval + 100 // maxTries + ); + content.document.querySelector(selector).click(); + } + + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + + // Manually send data update event, as it isn't set due to the lack of mock APIs. + // TODO: Support for the mocks will be added in Bug 1853474. + let mockObj = { + data: mockData, + productUrl: "https://example.com/product/1234", + }; + let evt = new content.CustomEvent("Update", { + bubbles: true, + detail: Cu.cloneInto(mockObj, content), + }); + content.document.dispatchEvent(evt); + + await shoppingContainer.updateComplete; + + let surveyScreen1 = await ContentTaskUtils.waitForCondition( + () => + content.document.querySelector( + "shopping-container .screen.SHOPPING_MICROSURVEY_SCREEN_1" + ), + "survey-screen" + ); + + ok(surveyScreen1, "Survey screen 1 is rendered"); + clickVisibleElement("#radio-1"); + clickVisibleElement("button.primary"); + + let surveyScreen2 = await ContentTaskUtils.waitForCondition( + () => + content.document.querySelector( + "shopping-container .screen.SHOPPING_MICROSURVEY_SCREEN_2" + ), + "survey-screen" + ); + ok(surveyScreen2, "Survey screen 2 is rendered"); + clickVisibleElement("#radio-1"); + clickVisibleElement("button.primary"); + + let confirmationScreen = await ContentTaskUtils.waitForCondition( + () => content.document.querySelector("shopping-message-bar"), + "survey-screen" + ); + + ok(confirmationScreen, "Survey confirmation screen is rendered"); + } + ); + } + ); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/shopping/tests/browser/browser_shopping_urlbar.js b/browser/components/shopping/tests/browser/browser_shopping_urlbar.js new file mode 100644 index 0000000000..9eb396e846 --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_shopping_urlbar.js @@ -0,0 +1,427 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const CONTENT_PAGE = "https://example.com"; +const PRODUCT_PAGE = "https://example.com/product/B09TJGHL5F"; + +add_task(async function test_button_hidden() { + await BrowserTestUtils.withNewTab(CONTENT_PAGE, async function (browser) { + let shoppingButton = document.getElementById("shopping-sidebar-button"); + ok( + BrowserTestUtils.isHidden(shoppingButton), + "Shopping Button should be hidden on a content page" + ); + }); +}); + +add_task(async function test_button_shown() { + await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async function (browser) { + let shoppingButton = document.getElementById("shopping-sidebar-button"); + ok( + BrowserTestUtils.isVisible(shoppingButton), + "Shopping Button should be visible on a product page" + ); + }); +}); + +// Button is hidden on navigation to a content page +add_task(async function test_button_changes_with_location() { + await BrowserTestUtils.withNewTab(CONTENT_PAGE, async function (browser) { + let shoppingButton = document.getElementById("shopping-sidebar-button"); + ok( + BrowserTestUtils.isHidden(shoppingButton), + "Shopping Button should be hidden on a content page" + ); + BrowserTestUtils.startLoadingURIString(browser, PRODUCT_PAGE); + await BrowserTestUtils.browserLoaded(browser); + ok( + BrowserTestUtils.isVisible(shoppingButton), + "Shopping Button should be visible on a product page" + ); + BrowserTestUtils.startLoadingURIString(browser, CONTENT_PAGE); + await BrowserTestUtils.browserLoaded(browser); + ok( + BrowserTestUtils.isHidden(shoppingButton), + "Shopping Button should be hidden on a content page" + ); + }); +}); + +add_task(async function test_button_active() { + Services.prefs.setBoolPref("browser.shopping.experience2023.active", true); + + await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async function (browser) { + let shoppingButton = document.getElementById("shopping-sidebar-button"); + Assert.equal( + shoppingButton.getAttribute("shoppingsidebaropen"), + "true", + "Shopping Button should be active when sidebar is open" + ); + }); +}); + +add_task(async function test_button_inactive() { + Services.prefs.setBoolPref("browser.shopping.experience2023.active", false); + + await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async function (browser) { + let shoppingButton = document.getElementById("shopping-sidebar-button"); + Assert.equal( + shoppingButton.getAttribute("shoppingsidebaropen"), + "false", + "Shopping Button should be inactive when sidebar is closed" + ); + }); +}); + +// Switching Tabs shows and hides the button +add_task(async function test_button_changes_with_tabswitch() { + Services.prefs.setBoolPref("browser.shopping.experience2023.active", true); + + let shoppingButton = document.getElementById("shopping-sidebar-button"); + + let productTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: PRODUCT_PAGE, + }); + let contentTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: CONTENT_PAGE, + }); + + await BrowserTestUtils.switchTab(gBrowser, productTab); + ok( + BrowserTestUtils.isVisible(shoppingButton), + "Shopping Button should be visible on a product page" + ); + + await BrowserTestUtils.switchTab(gBrowser, contentTab); + ok( + BrowserTestUtils.isHidden(shoppingButton), + "Shopping Button should be hidden on a content page" + ); + + await BrowserTestUtils.removeTab(productTab); + await BrowserTestUtils.removeTab(contentTab); +}); + +add_task(async function test_button_toggles_sidebars() { + Services.prefs.setBoolPref("browser.shopping.experience2023.active", false); + + await BrowserTestUtils.withNewTab(CONTENT_PAGE, async function (browser) { + let shoppingButton = document.getElementById("shopping-sidebar-button"); + let browserPanel = gBrowser.getPanel(browser); + + BrowserTestUtils.startLoadingURIString(browser, PRODUCT_PAGE); + await BrowserTestUtils.browserLoaded(browser); + + let sidebar = browserPanel.querySelector("shopping-sidebar"); + + is(sidebar, null, "Shopping sidebar should be closed"); + + // open + shoppingButton.click(); + await BrowserTestUtils.waitForMutationCondition( + shoppingButton, + { + attributeFilter: ["shoppingsidebaropen"], + }, + () => shoppingButton.getAttribute("shoppingsidebaropen") == "true" + ); + + sidebar = browserPanel.querySelector("shopping-sidebar"); + ok(BrowserTestUtils.isVisible(sidebar), "Shopping sidebar should be open"); + + // close + shoppingButton.click(); + await BrowserTestUtils.waitForMutationCondition( + shoppingButton, + { + attributeFilter: ["shoppingsidebaropen"], + }, + () => shoppingButton.getAttribute("shoppingsidebaropen") == "false" + ); + + ok(BrowserTestUtils.isHidden(sidebar), "Shopping sidebar should be closed"); + }); +}); + +// Button changes all Windows +add_task(async function test_button_toggles_all_windows() { + Services.prefs.setBoolPref("browser.shopping.experience2023.active", false); + + let shoppingButton = document.getElementById("shopping-sidebar-button"); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PRODUCT_PAGE); + + let browserPanelA = gBrowser.getPanel(gBrowser.selectedBrowser); + let sidebarA = browserPanelA.querySelector("shopping-sidebar"); + + let newWindow = await BrowserTestUtils.openNewBrowserWindow(); + + BrowserTestUtils.startLoadingURIString( + newWindow.gBrowser.selectedBrowser, + PRODUCT_PAGE + ); + await BrowserTestUtils.browserLoaded(newWindow.gBrowser.selectedBrowser); + + let browserPanelB = newWindow.gBrowser.getPanel( + newWindow.gBrowser.selectedBrowser + ); + let sidebarB = browserPanelB.querySelector("shopping-sidebar"); + + is( + sidebarA, + null, + "Shopping sidebar should not exist yet for new tab in current window" + ); + is(sidebarB, null, "Shopping sidebar closed in new window"); + + // open + shoppingButton.click(); + await BrowserTestUtils.waitForMutationCondition( + shoppingButton, + { + attributeFilter: ["shoppingsidebaropen"], + }, + () => shoppingButton.getAttribute("shoppingsidebaropen") == "true" + ); + sidebarA = browserPanelA.querySelector("shopping-sidebar"); + ok( + BrowserTestUtils.isVisible(sidebarA), + "Shopping sidebar should be open in current window" + ); + sidebarB = browserPanelB.querySelector("shopping-sidebar"); + ok( + BrowserTestUtils.isVisible(sidebarB), + "Shopping sidebar should be open in new window" + ); + + // close + shoppingButton.click(); + await BrowserTestUtils.waitForMutationCondition( + shoppingButton, + { + attributeFilter: ["shoppingsidebaropen"], + }, + () => shoppingButton.getAttribute("shoppingsidebaropen") == "false" + ); + + ok( + BrowserTestUtils.isHidden(sidebarA), + "Shopping sidebar should be closed in current window" + ); + ok( + BrowserTestUtils.isHidden(sidebarB), + "Shopping sidebar should be closed in new window" + ); + + BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.closeWindow(newWindow); +}); + +add_task(async function test_button_right_click_doesnt_affect_sidebars() { + Services.prefs.setBoolPref("browser.shopping.experience2023.active", false); + + await BrowserTestUtils.withNewTab(CONTENT_PAGE, async function (browser) { + let shoppingButton = document.getElementById("shopping-sidebar-button"); + let browserPanel = gBrowser.getPanel(browser); + + BrowserTestUtils.startLoadingURIString(browser, PRODUCT_PAGE); + await BrowserTestUtils.browserLoaded(browser); + + let sidebar = browserPanel.querySelector("shopping-sidebar"); + + is(sidebar, null, "Shopping sidebar should be closed"); + EventUtils.synthesizeMouseAtCenter(shoppingButton, { button: 1 }); + // Wait a tick. + await new Promise(executeSoon); + sidebar = browserPanel.querySelector("shopping-sidebar"); + is(sidebar, null, "Shopping sidebar should still be closed"); + }); +}); + +add_task(async function test_button_deals_with_tabswitches() { + Services.prefs.setBoolPref("browser.shopping.experience2023.active", true); + + await BrowserTestUtils.withNewTab(CONTENT_PAGE, async function (browser) { + let shoppingButton = document.getElementById("shopping-sidebar-button"); + + ok( + BrowserTestUtils.isHidden(shoppingButton), + "The shopping button is hidden on a non product page" + ); + + let newProductTab = BrowserTestUtils.addTab(gBrowser, PRODUCT_PAGE); + let newProductBrowser = newProductTab.linkedBrowser; + await BrowserTestUtils.browserLoaded( + newProductBrowser, + false, + PRODUCT_PAGE + ); + + ok( + BrowserTestUtils.isHidden(shoppingButton), + "The shopping button is still hidden after opening a background product tab" + ); + + let shoppingButtonVisiblePromise = + BrowserTestUtils.waitForMutationCondition( + shoppingButton, + { attributes: true, attributeFilter: ["hidden"] }, + () => !shoppingButton.hidden + ); + await BrowserTestUtils.switchTab(gBrowser, newProductTab); + await shoppingButtonVisiblePromise; + + ok( + BrowserTestUtils.isVisible(shoppingButton), + "The shopping button is now visible" + ); + + let newProductTab2 = BrowserTestUtils.addTab(gBrowser, PRODUCT_PAGE); + let newProductBrowser2 = newProductTab2.linkedBrowser; + await BrowserTestUtils.browserLoaded( + newProductBrowser2, + false, + PRODUCT_PAGE + ); + + ok( + BrowserTestUtils.isVisible(shoppingButton), + "The shopping button is still visible after opening background product tab" + ); + + shoppingButtonVisiblePromise = BrowserTestUtils.waitForMutationCondition( + shoppingButton, + { attributes: true, attributeFilter: ["hidden"] }, + () => !shoppingButton.hidden + ); + await BrowserTestUtils.switchTab(gBrowser, newProductTab2); + await shoppingButtonVisiblePromise; + + ok( + BrowserTestUtils.isVisible(shoppingButton), + "The shopping button is still visible" + ); + + BrowserTestUtils.removeTab(newProductTab2); + + BrowserTestUtils.removeTab(newProductTab); + }); +}); + +add_task(async function test_button_deals_with_tabswitches_post_optout() { + Services.prefs.setBoolPref("browser.shopping.experience2023.active", true); + + await BrowserTestUtils.withNewTab(CONTENT_PAGE, async function (browser) { + let shoppingButton = document.getElementById("shopping-sidebar-button"); + + ok( + BrowserTestUtils.isHidden(shoppingButton), + "The shopping button is hidden on a non product page" + ); + + let newProductTab = BrowserTestUtils.addTab(gBrowser, PRODUCT_PAGE); + let newProductBrowser = newProductTab.linkedBrowser; + await BrowserTestUtils.browserLoaded( + newProductBrowser, + false, + PRODUCT_PAGE + ); + + ok( + BrowserTestUtils.isHidden(shoppingButton), + "The shopping button is still hidden after opening a background product tab" + ); + + let shoppingButtonVisiblePromise = + BrowserTestUtils.waitForMutationCondition( + shoppingButton, + { attributes: true, attributeFilter: ["hidden"] }, + () => !shoppingButton.hidden + ); + await BrowserTestUtils.switchTab(gBrowser, newProductTab); + await shoppingButtonVisiblePromise; + + ok( + BrowserTestUtils.isVisible(shoppingButton), + "The shopping button is now visible" + ); + + let newProductTab2 = BrowserTestUtils.addTab(gBrowser, PRODUCT_PAGE); + let newProductBrowser2 = newProductTab2.linkedBrowser; + await BrowserTestUtils.browserLoaded( + newProductBrowser2, + false, + PRODUCT_PAGE + ); + + ok( + BrowserTestUtils.isVisible(shoppingButton), + "The shopping button is still visible after opening background product tab" + ); + + shoppingButtonVisiblePromise = BrowserTestUtils.waitForMutationCondition( + shoppingButton, + { attributes: true, attributeFilter: ["hidden"] }, + () => !shoppingButton.hidden + ); + await BrowserTestUtils.switchTab(gBrowser, newProductTab2); + await shoppingButtonVisiblePromise; + + ok( + BrowserTestUtils.isVisible(shoppingButton), + "The shopping button is still visible" + ); + + // Simulate opt-out + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.active", false], + ["browser.shopping.experience2023.optedIn", 2], + ], + }); + + ok( + BrowserTestUtils.isVisible(shoppingButton), + "The shopping button is still visible after opting out." + ); + Assert.equal( + shoppingButton.getAttribute("shoppingsidebaropen"), + "false", + "Button not marked as open." + ); + + // Switch to non-product tab. + await BrowserTestUtils.switchTab( + gBrowser, + gBrowser.getTabForBrowser(browser) + ); + ok( + BrowserTestUtils.isHidden(shoppingButton), + "The shopping button is hidden on non-product page." + ); + Assert.equal( + shoppingButton.getAttribute("shoppingsidebaropen"), + "false", + "Button not marked as open." + ); + // Switch to non-product tab. + await BrowserTestUtils.switchTab(gBrowser, newProductTab); + ok( + BrowserTestUtils.isVisible(shoppingButton), + "The shopping button is still visible on a different product tab after opting out." + ); + Assert.equal( + shoppingButton.getAttribute("shoppingsidebaropen"), + "false", + "Button not marked as open." + ); + + BrowserTestUtils.removeTab(newProductTab2); + + BrowserTestUtils.removeTab(newProductTab); + }); +}); diff --git a/browser/components/shopping/tests/browser/browser_stale_product.js b/browser/components/shopping/tests/browser/browser_stale_product.js new file mode 100644 index 0000000000..45bed46a6b --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_stale_product.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the correct shopping-message-bar component appears if a product analysis is stale. + * Other analysis details should be visible. + */ +add_task(async function test_stale_product() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + let shoppingContainer = await getAnalysisDetails( + browser, + MOCK_STALE_PRODUCT_RESPONSE + ); + + ok( + shoppingContainer.shoppingMessageBarEl, + "Got shopping-message-bar element" + ); + is( + shoppingContainer.shoppingMessageBarType, + "stale", + "shopping-message-bar type should be correct" + ); + + verifyAnalysisDetailsVisible(shoppingContainer); + verifyFooterVisible(shoppingContainer); + } + ); +}); diff --git a/browser/components/shopping/tests/browser/browser_ui_telemetry.js b/browser/components/shopping/tests/browser/browser_ui_telemetry.js new file mode 100644 index 0000000000..b97aca1963 --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_ui_telemetry.js @@ -0,0 +1,762 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const CONTENT_PAGE = "https://example.com"; +const PRODUCT_PAGE = "https://example.com/product/B09TJGHL5F"; + +function assertEventMatches(gleanEvent, requiredValues) { + let limitedEvent = Object.assign({}, gleanEvent); + for (let k of Object.keys(limitedEvent)) { + if (!requiredValues.hasOwnProperty(k)) { + delete limitedEvent[k]; + } + } + return Assert.deepEqual(limitedEvent, requiredValues); +} + +add_task(async function test_shopping_reanalysis_event() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.shopping.experience2023.active", true]], + }); + + // testFlushAllChildren() is necessary to deal with the event being + // recorded in content, but calling testGetValue() in parent. + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await clickReAnalyzeLink(browser, MOCK_STALE_PRODUCT_RESPONSE); + } + ); + + await Services.fog.testFlushAllChildren(); + var staleAnalysisEvents = + Glean.shopping.surfaceStaleAnalysisShown.testGetValue(); + + assertEventMatches(staleAnalysisEvents[0], { + category: "shopping", + name: "surface_stale_analysis_shown", + }); + + var reanalysisRequestedEvents = + Glean.shopping.surfaceReanalyzeClicked.testGetValue(); + + assertEventMatches(reanalysisRequestedEvents[0], { + category: "shopping", + name: "surface_reanalyze_clicked", + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_reactivated_product_button_click() { + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await clickProductAvailableLink(browser, MOCK_STALE_PRODUCT_RESPONSE); + } + ); + + await Services.fog.testFlushAllChildren(); + + var reanalysisEvents = + Glean.shopping.surfaceReactivatedButtonClicked.testGetValue(); + assertEventMatches(reanalysisEvents[0], { + category: "shopping", + name: "surface_reactivated_button_clicked", + }); +}); + +add_task(async function test_no_reliability_available_request_click() { + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await clickCheckReviewQualityButton( + browser, + MOCK_UNANALYZED_PRODUCT_RESPONSE + ); + } + ); + + await Services.fog.testFlushAllChildren(); + var requestEvents = + Glean.shopping.surfaceAnalyzeReviewsNoneAvailableClicked.testGetValue(); + + assertEventMatches(requestEvents[0], { + category: "shopping", + name: "surface_analyze_reviews_none_available_clicked", + }); +}); + +add_task(async function test_shopping_sidebar_displayed() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.shopping.experience2023.active", true]], + }); + + Services.fog.testResetFOG(); + + await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async function (browser) { + let shoppingButton = document.getElementById("shopping-sidebar-button"); + await BrowserTestUtils.waitForMutationCondition( + shoppingButton, + { + attributeFilter: ["shoppingsidebaropen"], + }, + () => shoppingButton.getAttribute("shoppingsidebaropen") == "true" + ); + let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar"); + Assert.ok( + BrowserTestUtils.isVisible(sidebar), + "Sidebar should be visible." + ); + + // open a new tab onto a page where sidebar is not visible. + let contentTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: CONTENT_PAGE, + }); + + // change the focused tab a few times to ensure we don't increment on tab + // switch. + await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[0]); + await BrowserTestUtils.switchTab(gBrowser, contentTab); + await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[0]); + + BrowserTestUtils.removeTab(contentTab); + }); + + await Services.fog.testFlushAllChildren(); + + var displayedEvents = Glean.shopping.surfaceDisplayed.testGetValue(); + Assert.equal(1, displayedEvents.length); + assertEventMatches(displayedEvents[0], { + category: "shopping", + name: "surface_displayed", + }); + + var addressBarIconDisplayedEvents = + Glean.shopping.addressBarIconDisplayed.testGetValue(); + assertEventMatches(addressBarIconDisplayedEvents[0], { + category: "shopping", + name: "address_bar_icon_displayed", + }); + + // reset FOG and check a page that should NOT have these events + Services.fog.testResetFOG(); + + await BrowserTestUtils.withNewTab(CONTENT_PAGE, async function (browser) { + let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar"); + + Assert.equal(sidebar, null); + }); + + var emptyDisplayedEvents = Glean.shopping.surfaceDisplayed.testGetValue(); + var emptyAddressBarIconDisplayedEvents = + Glean.shopping.addressBarIconDisplayed.testGetValue(); + + Assert.equal(emptyDisplayedEvents, null); + Assert.equal(emptyAddressBarIconDisplayedEvents, null); + + // Open a product page in a background tab, verify telemetry is not recorded. + let backgroundTab = await BrowserTestUtils.addTab(gBrowser, PRODUCT_PAGE); + await Services.fog.testFlushAllChildren(); + let tabSwitchEvents = Glean.shopping.surfaceDisplayed.testGetValue(); + Assert.equal(tabSwitchEvents, null); + Services.fog.testResetFOG(); + + // Next, switch tabs to the backgrounded product tab and verify telemetry is + // recorded. + await BrowserTestUtils.switchTab(gBrowser, backgroundTab); + await Services.fog.testFlushAllChildren(); + tabSwitchEvents = Glean.shopping.surfaceDisplayed.testGetValue(); + Assert.equal(1, tabSwitchEvents.length); + assertEventMatches(tabSwitchEvents[0], { + category: "shopping", + name: "surface_displayed", + }); + Services.fog.testResetFOG(); + + // Finally, switch tabs again and verify telemetry is not recorded for the + // background tab after it has been foregrounded once. + await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[0]); + await BrowserTestUtils.switchTab(gBrowser, backgroundTab); + await Services.fog.testFlushAllChildren(); + tabSwitchEvents = Glean.shopping.surfaceDisplayed.testGetValue(); + Assert.equal(tabSwitchEvents, null); + Services.fog.testResetFOG(); + BrowserTestUtils.removeTab(backgroundTab); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_shopping_card_clicks() { + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await clickShowMoreButton(browser, MOCK_ANALYZED_PRODUCT_RESPONSE); + } + ); + + await Services.fog.testFlushAllChildren(); + var learnMoreButtonEvents = + Glean.shopping.surfaceShowMoreReviewsButtonClicked.testGetValue(); + + assertEventMatches(learnMoreButtonEvents[0], { + category: "shopping", + name: "surface_show_more_reviews_button_clicked", + }); +}); + +add_task(async function test_close_telemetry_recorded() { + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await clickCloseButton(browser, MOCK_ANALYZED_PRODUCT_RESPONSE); + } + ); + + await Services.fog.testFlushAllChildren(); + + var closeEvents = Glean.shopping.surfaceClosed.testGetValue(); + assertEventMatches(closeEvents[0], { + category: "shopping", + name: "surface_closed", + extra: { source: "closeButton" }, + }); + + // Ensure that the sidebar is open so we confirm the icon click closes it. + await SpecialPowers.pushPrefEnv({ + set: [["browser.shopping.experience2023.active", true]], + }); + + await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async function (browser) { + let shoppingButton = document.getElementById("shopping-sidebar-button"); + shoppingButton.click(); + }); + + await Services.fog.testFlushAllChildren(); + var urlBarIconEvents = Glean.shopping.addressBarIconClicked.testGetValue(); + assertEventMatches(urlBarIconEvents[0], { + category: "shopping", + name: "address_bar_icon_clicked", + extra: { action: "closed" }, + }); + + var closeSurfaceEvents = Glean.shopping.surfaceClosed.testGetValue(); + assertEventMatches(closeSurfaceEvents[0], { + category: "shopping", + name: "surface_closed", + extra: { source: "closeButton" }, + }); + + assertEventMatches(closeSurfaceEvents[1], { + category: "shopping", + name: "surface_closed", + extra: { source: "addressBarIcon" }, + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_powered_by_fakespot_link() { + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await clickPoweredByFakespotLink(browser, MOCK_ANALYZED_PRODUCT_RESPONSE); + } + ); + + await Services.fog.testFlushAllChildren(); + + let fakespotLinkEvents = + Glean.shopping.surfacePoweredByFakespotLinkClicked.testGetValue(); + assertEventMatches(fakespotLinkEvents[0], { + category: "shopping", + name: "surface_powered_by_fakespot_link_clicked", + }); +}); + +add_task(async function test_review_quality_explainer_link() { + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await clickReviewQualityExplainerLink( + browser, + MOCK_ANALYZED_PRODUCT_RESPONSE + ); + } + ); + + await Services.fog.testFlushAllChildren(); + + let qualityExplainerEvents = + Glean.shopping.surfaceShowQualityExplainerUrlClicked.testGetValue(); + assertEventMatches(qualityExplainerEvents[0], { + category: "shopping", + name: "surface_show_quality_explainer_url_clicked", + }); +}); + +// Start with ads user enabled, then disable them, and verify telemetry. +add_task(async function test_ads_disable_button_click() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.adsEnabled", true], + ["browser.shopping.experience2023.ads.userEnabled", true], + ], + }); + + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + let mockArgs = { + mockData: MOCK_ANALYZED_PRODUCT_RESPONSE, + mockRecommendationData: MOCK_RECOMMENDED_ADS_RESPONSE, + }; + + await clickAdsToggle(browser, mockArgs); + + await Services.fog.testFlushAllChildren(); + + // Verify the ads state was changed to disabled. + let toggledEvents = + Glean.shopping.surfaceAdsSettingToggled.testGetValue(); + assertEventMatches(toggledEvents[0], { + category: "shopping", + name: "surface_ads_setting_toggled", + extra: { action: "disabled" }, + }); + + // Verify the ads disabled state is set to true. + Assert.equal( + Glean.shoppingSettings.disabledAds.testGetValue(), + true, + "Ads should be marked as disabled" + ); + } + ); +}); + +// Start with ads user disabled, then enable them, and verify telemetry. +add_task(async function test_ads_enable_button_click() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.adsEnabled", true], + ["browser.shopping.experience2023.ads.userEnabled", false], + ], + }); + + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + let mockArgs = { + mockData: MOCK_ANALYZED_PRODUCT_RESPONSE, + mockRecommendationData: MOCK_RECOMMENDED_ADS_RESPONSE, + }; + + await clickAdsToggle(browser, mockArgs); + + await Services.fog.testFlushAllChildren(); + + // Verify the ads state was changed to enabled. + let toggledEvents = + Glean.shopping.surfaceAdsSettingToggled.testGetValue(); + assertEventMatches(toggledEvents[0], { + category: "shopping", + name: "surface_ads_setting_toggled", + extra: { action: "enabled" }, + }); + + // Verify the ads disabled state is set to false. + Assert.equal( + Glean.shoppingSettings.disabledAds.testGetValue(), + false, + "Ads should be marked as enabled" + ); + } + ); +}); + +add_task(async function test_auto_open_settings_toggle() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.autoOpen.enabled", true], + ["browser.shopping.experience2023.autoOpen.userEnabled", true], + ], + }); + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + let mockData = MOCK_ANALYZED_PRODUCT_RESPONSE; + await clickAutoOpenToggle(browser, mockData); + await Services.fog.testFlushAllChildren(); + let toggledEvents = + Glean.shopping.surfaceAutoOpenSettingToggled.testGetValue(); + assertEventMatches(toggledEvents[0], { + category: "shopping", + name: "surface_auto_open_setting_toggled", + extra: { action: "disabled" }, + }); + + Services.fog.testResetFOG(); + + // Toggle back in the other direction. + await clickAutoOpenToggle(browser, mockData); + await Services.fog.testFlushAllChildren(); + toggledEvents = + Glean.shopping.surfaceAutoOpenSettingToggled.testGetValue(); + assertEventMatches(toggledEvents[0], { + category: "shopping", + name: "surface_auto_open_setting_toggled", + extra: { action: "enabled" }, + }); + } + ); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_auto_open_no_thanks_button_click() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.autoOpen.enabled", true], + ["browser.shopping.experience2023.autoOpen.userEnabled", true], + ["browser.shopping.experience2023.showKeepSidebarClosedMessage", true], + ], + }); + + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + let mockArgs = { + mockData: MOCK_ANALYZED_PRODUCT_RESPONSE, + }; + + await clickNoThanksButton(browser, mockArgs); + + await Services.fog.testFlushAllChildren(); + + let noThanksButtonEvents = + Glean.shopping.surfaceNoThanksButtonClicked.testGetValue(); + + assertEventMatches(noThanksButtonEvents[0], { + category: "shopping", + name: "surface_no_thanks_button_clicked", + }); + } + ); +}); + +add_task(async function test_auto_open_yes_keep_closed_button() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.autoOpen.enabled", true], + ["browser.shopping.experience2023.autoOpen.userEnabled", true], + ["browser.shopping.experience2023.showKeepSidebarClosedMessage", true], + ], + }); + + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + let mockArgs = { + mockData: MOCK_ANALYZED_PRODUCT_RESPONSE, + }; + + await clickYesKeepClosedButton(browser, mockArgs); + + await Services.fog.testFlushAllChildren(); + + let yesKeepClosedButtonEvents = + Glean.shopping.surfaceYesKeepClosedButtonClicked.testGetValue(); + + assertEventMatches(yesKeepClosedButtonEvents[0], { + category: "shopping", + name: "surface_yes_keep_closed_button_clicked", + }); + } + ); +}); + +add_task(async function test_auto_open_user_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.shopping.experience2023.autoOpen.enabled", true], + ["browser.shopping.experience2023.autoOpen.userEnabled", true], + ], + }); + + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + Services.prefs.setBoolPref( + "browser.shopping.experience2023.autoOpen.userEnabled", + false + ); + + await Services.fog.testFlushAllChildren(); + + Assert.equal( + Glean.shoppingSettings.autoOpenUserDisabled.testGetValue(), + true, + "Auto open should be marked as disabled" + ); +}); + +function clickAdsToggle(browser, data) { + return SpecialPowers.spawn(browser, [data], async args => { + const { mockData, mockRecommendationData } = args; + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + shoppingContainer.recommendationData = Cu.cloneInto( + mockRecommendationData, + content + ); + + await shoppingContainer.updateComplete; + + let shoppingSettings = shoppingContainer.settingsEl; + let toggle = shoppingSettings.recommendationsToggleEl; + toggle.click(); + + await shoppingContainer.updateComplete; + }); +} + +function clickAutoOpenToggle(browser, data) { + return SpecialPowers.spawn(browser, [data], async mockData => { + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + + await shoppingContainer.updateComplete; + + let shoppingSettings = shoppingContainer.settingsEl; + let toggle = shoppingSettings.autoOpenToggleEl; + toggle.click(); + + await shoppingContainer.updateComplete; + }); +} + +function clickReAnalyzeLink(browser, data) { + return SpecialPowers.spawn(browser, [data], async mockData => { + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + + let shoppingMessageBar = shoppingContainer.shoppingMessageBarEl; + await shoppingMessageBar.updateComplete; + + await shoppingMessageBar.onClickAnalysisButton(); + + return "clicked"; + }); +} + +function clickCloseButton(browser, data) { + return SpecialPowers.spawn(browser, [data], async mockData => { + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + + let closeButton = + shoppingContainer.shadowRoot.querySelector("#close-button"); + await closeButton.updateComplete; + + closeButton.click(); + }); +} + +function clickProductAvailableLink(browser, data) { + return SpecialPowers.spawn(browser, [data], async mockData => { + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + + let shoppingMessageBar = shoppingContainer.shoppingMessageBarEl; + await shoppingMessageBar.updateComplete; + + // calling onClickProductAvailable will fail quietly in cases where this is + // not possible to call, so assure it exists first. + Assert.notEqual(shoppingMessageBar, null); + await shoppingMessageBar.onClickProductAvailable(); + }); +} + +function clickShowMoreButton(browser, data) { + return SpecialPowers.spawn(browser, [data], async mockData => { + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + + let highlights = shoppingContainer.highlightsEl; + let card = highlights.shadowRoot.querySelector("shopping-card"); + let button = card.shadowRoot.querySelector("article footer button"); + + button.click(); + }); +} + +function clickCheckReviewQualityButton(browser, data) { + return SpecialPowers.spawn(browser, [data], async mockData => { + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + + let button = shoppingContainer.unanalyzedProductEl.shadowRoot + .querySelector("shopping-card") + .querySelector("button"); + + button.click(); + }); +} + +function clickPoweredByFakespotLink(browser, data) { + return SpecialPowers.spawn(browser, [data], async mockData => { + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + + let settingsEl = shoppingContainer.settingsEl; + await settingsEl.updateComplete; + let fakespotLink = settingsEl.fakespotLearnMoreLinkEl; + + // Prevent link navigation for test. + fakespotLink.href = undefined; + await fakespotLink.updateComplete; + + fakespotLink.click(); + }); +} + +function clickReviewQualityExplainerLink(browser, data) { + return SpecialPowers.spawn(browser, [data], async mockData => { + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + + let analysisExplainerEl = shoppingContainer.analysisExplainerEl; + await analysisExplainerEl.updateComplete; + let reviewQualityLink = analysisExplainerEl.reviewQualityExplainerLink; + + // Prevent link navigation for test. + reviewQualityLink.href = undefined; + await reviewQualityLink.updateComplete; + + reviewQualityLink.click(); + }); +} + +function clickNoThanksButton(browser, data) { + return SpecialPowers.spawn(browser, [data], async mockData => { + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + // Force the "keep closed" to appear + shoppingContainer.showingKeepClosedMessage = true; + await shoppingContainer.updateComplete; + + let shoppingMessageBar = shoppingContainer.keepClosedMessageBarEl; + await shoppingMessageBar.updateComplete; + + let button = shoppingMessageBar.noThanksButtonEl; + button.click(); + }); +} + +function clickYesKeepClosedButton(browser, data) { + return SpecialPowers.spawn(browser, [data], async mockData => { + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + // Force the "keep closed" to appear + shoppingContainer.showingKeepClosedMessage = true; + await shoppingContainer.updateComplete; + + let shoppingMessageBar = shoppingContainer.keepClosedMessageBarEl; + await shoppingMessageBar.updateComplete; + + let button = shoppingMessageBar.yesKeepClosedButtonEl; + button.click(); + }); +} diff --git a/browser/components/shopping/tests/browser/browser_unanalyzed_product.js b/browser/components/shopping/tests/browser/browser_unanalyzed_product.js new file mode 100644 index 0000000000..a28611cdbf --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_unanalyzed_product.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the unanalyzed product card appears if a product has no analysis yet. + * Settings should be the only other component that is visible. + */ +add_task(async function test_unanalyzed_product() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + let shoppingContainer = await getAnalysisDetails( + browser, + MOCK_UNANALYZED_PRODUCT_RESPONSE + ); + + ok( + shoppingContainer.unanalyzedProductEl, + "Got the unanalyzed-product-card element" + ); + + verifyAnalysisDetailsHidden(shoppingContainer); + verifyFooterVisible(shoppingContainer); + } + ); +}); + +/** + * Tests that the unanalyzed product card is hidden if a product already has an up-to-date analysis. + * Other analysis details should be visible. + */ +add_task(async function test_analyzed_product() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + let shoppingContainer = await getAnalysisDetails( + browser, + MOCK_ANALYZED_PRODUCT_RESPONSE + ); + + ok( + !shoppingContainer.unanalyzedProductEl, + "unanalyzed-product-card should not be visible" + ); + + verifyAnalysisDetailsVisible(shoppingContainer); + verifyFooterVisible(shoppingContainer); + } + ); +}); + +/** + * Tests that the unanalyzed product card appears if a product has no grade, + * even if a product id is available. + * Settings should be the only other component that is visible. + */ +add_task(async function test_ungraded_product() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + let shoppingContainer = await getAnalysisDetails( + browser, + MOCK_UNGRADED_PRODUCT_RESPONSE + ); + + ok( + shoppingContainer.unanalyzedProductEl, + "Got the unanalyzed-product-card element" + ); + + verifyAnalysisDetailsHidden(shoppingContainer); + verifyFooterVisible(shoppingContainer); + } + ); +}); diff --git a/browser/components/shopping/tests/browser/browser_unavailable_product.js b/browser/components/shopping/tests/browser/browser_unavailable_product.js new file mode 100644 index 0000000000..96f82ae296 --- /dev/null +++ b/browser/components/shopping/tests/browser/browser_unavailable_product.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the correct shopping-message-bar component appears if a product was marked as unavailable. + */ +add_task(async function test_unavailable_product() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn( + browser, + [MOCK_UNAVAILABLE_PRODUCT_RESPONSE], + async mockData => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + + let productNotAvailableMessageBar = + shoppingContainer.shoppingMessageBarEl; + + ok(productNotAvailableMessageBar, "Got shopping-message-bar element"); + is( + productNotAvailableMessageBar?.getAttribute("type"), + "product-not-available", + "shopping-message-bar type should be correct" + ); + + let productAvailableBtn = + productNotAvailableMessageBar?.productAvailableBtnEl; + + ok(productAvailableBtn, "Got report product available button"); + + let thanksForReportMessageBarVisible = + ContentTaskUtils.waitForCondition(() => { + return ( + !!shoppingContainer.shoppingMessageBarEl && + ContentTaskUtils.isVisible( + shoppingContainer.shoppingMessageBarEl + ) + ); + }, "Waiting for shopping-message-bar to be visible"); + + productAvailableBtn.click(); + + await thanksForReportMessageBarVisible; + + is( + shoppingContainer.shoppingMessageBarEl?.getAttribute("type"), + "thanks-for-reporting", + "shopping-message-bar type should be correct" + ); + } + ); + } + ); +}); + +/** + * Tests that the correct shopping-message-bar component appears if a product marked as unavailable + * was reported to be back in stock by another user. + */ +add_task(async function test_unavailable_product_reported() { + await BrowserTestUtils.withNewTab( + { + url: "about:shoppingsidebar", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn( + browser, + [MOCK_UNAVAILABLE_PRODUCT_REPORTED_RESPONSE], + async mockData => { + let shoppingContainer = + content.document.querySelector( + "shopping-container" + ).wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + + ok( + shoppingContainer.shoppingMessageBarEl, + "Got shopping-message-bar element" + ); + is( + shoppingContainer.shoppingMessageBarEl?.getAttribute("type"), + "product-not-available-reported", + "shopping-message-bar type should be correct" + ); + } + ); + } + ); +}); diff --git a/browser/components/shopping/tests/browser/head.js b/browser/components/shopping/tests/browser/head.js new file mode 100644 index 0000000000..49367fd58b --- /dev/null +++ b/browser/components/shopping/tests/browser/head.js @@ -0,0 +1,225 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/toolkit/components/shopping/test/browser/head.js", + this +); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +const MOCK_UNPOPULATED_DATA = { + adjusted_rating: null, + grade: null, + highlights: null, +}; + +const MOCK_POPULATED_DATA = { + adjusted_rating: 5, + grade: "B", + highlights: { + price: { + positive: ["This watch is great and the price was even better."], + negative: [], + neutral: [], + }, + quality: { + positive: [ + "Other than that, I am very impressed with the watch and it’s capabilities.", + "This watch performs above expectations in every way with the exception of the heart rate monitor.", + ], + negative: [ + "Battery life is no better than the 3 even with the solar gimmick, probably worse.", + ], + neutral: [ + "I have small wrists and still went with the 6X and glad I did.", + "I can deal with the looks, as Im now retired.", + ], + }, + competitiveness: { + positive: [ + "Bought this to replace my vivoactive 3.", + "I like that this watch has so many features, especially those that monitor health like SP02, respiration, sleep, HRV status, stress, and heart rate.", + ], + negative: [ + "I do not use it for sleep or heartrate monitoring so not sure how accurate they are.", + ], + neutral: [ + "I've avoided getting a smartwatch for so long due to short battery life on most of them.", + ], + }, + "packaging/appearance": { + positive: ["Great cardboard box."], + negative: [], + neutral: [], + }, + shipping: { + positive: [], + negative: [], + neutral: [], + }, + }, +}; + +const MOCK_INVALID_KEY_OBJ = { + invalidHighlight: { + negative: ["This is an invalid highlight and should not be visible"], + }, + shipping: { + positive: [], + negative: [], + neutral: [], + }, +}; + +const MOCK_UNANALYZED_PRODUCT_RESPONSE = { + ...MOCK_UNPOPULATED_DATA, + product_id: null, + needs_analysis: true, +}; + +const MOCK_STALE_PRODUCT_RESPONSE = { + ...MOCK_POPULATED_DATA, + product_id: "ABCD123", + grade: "A", + needs_analysis: true, +}; + +const MOCK_UNGRADED_PRODUCT_RESPONSE = { + ...MOCK_UNPOPULATED_DATA, + product_id: "ABCD123", + needs_analysis: true, +}; + +const MOCK_NOT_ENOUGH_REVIEWS_PRODUCT_RESPONSE = { + ...MOCK_UNPOPULATED_DATA, + product_id: "ABCD123", + needs_analysis: false, + not_enough_reviews: true, +}; + +const MOCK_ANALYZED_PRODUCT_RESPONSE = { + ...MOCK_POPULATED_DATA, + product_id: "ABCD123", + needs_analysis: false, +}; + +const MOCK_UNAVAILABLE_PRODUCT_RESPONSE = { + ...MOCK_POPULATED_DATA, + product_id: "ABCD123", + deleted_product: true, +}; + +const MOCK_UNAVAILABLE_PRODUCT_REPORTED_RESPONSE = { + ...MOCK_UNAVAILABLE_PRODUCT_RESPONSE, + deleted_product_reported: true, +}; + +const MOCK_PAGE_NOT_SUPPORTED_RESPONSE = { + ...MOCK_UNPOPULATED_DATA, + page_not_supported: true, +}; + +const MOCK_RECOMMENDED_ADS_RESPONSE = [ + { + name: "VIVO Electric 60 x 24 inch Stand Up Desk | Black Table Top, Black Frame, Height Adjustable Standing Workstation with Memory Preset Controller (DESK-KIT-1B6B)", + url: "www.example.com", + price: "249.99", + currency: "USD", + grade: "A", + adjusted_rating: 4.6, + sponsored: true, + image_blob: new Blob(new Uint8Array(), { type: "image/jpeg" }), + }, +]; + +function verifyAnalysisDetailsVisible(shoppingContainer) { + ok( + shoppingContainer.reviewReliabilityEl, + "review-reliability should be visible" + ); + ok(shoppingContainer.adjustedRatingEl, "adjusted-rating should be visible"); + ok(shoppingContainer.highlightsEl, "review-highlights should be visible"); +} + +function verifyAnalysisDetailsHidden(shoppingContainer) { + ok( + !shoppingContainer.reviewReliabilityEl, + "review-reliability should not be visible" + ); + ok( + !shoppingContainer.adjustedRatingEl, + "adjusted-rating should not be visible" + ); + ok( + !shoppingContainer.highlightsEl, + "review-highlights should not be visible" + ); +} + +function verifyFooterVisible(shoppingContainer) { + ok(shoppingContainer.settingsEl, "Got the shopping-settings element"); + ok( + shoppingContainer.analysisExplainerEl, + "Got the analysis-explainer element" + ); +} + +function verifyFooterHidden(shoppingContainer) { + ok(!shoppingContainer.settingsEl, "Do not render shopping-settings element"); + ok( + !shoppingContainer.analysisExplainerEl, + "Do not render the analysis-explainer element" + ); +} + +function getAnalysisDetails(browser, data) { + return SpecialPowers.spawn(browser, [data], async mockData => { + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + let returnState = {}; + for (let el of [ + "unanalyzedProductEl", + "reviewReliabilityEl", + "analysisExplainerEl", + "adjustedRatingEl", + "highlightsEl", + "settingsEl", + "shoppingMessageBarEl", + "loadingEl", + ]) { + returnState[el] = + !!shoppingContainer[el] && + ContentTaskUtils.isVisible(shoppingContainer[el]); + } + returnState.shoppingMessageBarType = + shoppingContainer.shoppingMessageBarEl?.getAttribute("type"); + returnState.isOffline = shoppingContainer.isOffline; + return returnState; + }); +} + +function getSettingsDetails(browser, data) { + return SpecialPowers.spawn(browser, [data], async mockData => { + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + shoppingContainer.data = Cu.cloneInto(mockData, content); + await shoppingContainer.updateComplete; + let shoppingSettings = shoppingContainer.settingsEl; + await shoppingSettings.updateComplete; + let returnState = { + settingsEl: + !!shoppingSettings && ContentTaskUtils.isVisible(shoppingSettings), + }; + for (let el of ["recommendationsToggleEl", "optOutButtonEl"]) { + returnState[el] = + !!shoppingSettings[el] && + ContentTaskUtils.isVisible(shoppingSettings[el]); + } + return returnState; + }); +} diff --git a/browser/components/storybook/.storybook/addon-component-status/StatusIndicator.mjs b/browser/components/storybook/.storybook/addon-component-status/StatusIndicator.mjs new file mode 100644 index 0000000000..05d72146cb --- /dev/null +++ b/browser/components/storybook/.storybook/addon-component-status/StatusIndicator.mjs @@ -0,0 +1,114 @@ +/* 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 no-unused-vars +import React from "react"; +import { useParameter } from "@storybook/api"; +import { + // eslint-disable-next-line no-unused-vars + Badge, + // eslint-disable-next-line no-unused-vars + WithTooltip, + // eslint-disable-next-line no-unused-vars + TooltipMessage, + // eslint-disable-next-line no-unused-vars + IconButton, +} from "@storybook/components"; +import { TOOL_ID, STATUS_PARAM_KEY } from "./constants.mjs"; + +const VALID_STATUS_MAP = { + stable: { + label: "Stable", + badgeType: "positive", + description: + "This component is widely used in Firefox, in both the chrome and in-content pages.", + }, + "in-development": { + label: "In Development", + badgeType: "warning", + description: + "This component is in active development and starting to be used in Firefox. It may not yet be usable in both the chrome and in-content pages.", + }, + unstable: { + label: "Unstable", + badgeType: "negative", + description: + "This component is still in the early stages of development and may not be ready for general use in Firefox.", + }, +}; + +/** + * Displays a badge with the components status in the Storybook toolbar. + * + * Statuses are set via story parameters. + * We support either passing `status: "statusType"` for using defaults or + * `status: { + type: "stable" | "in-development" | "unstable", + description: "Your description here" + links: [ + { + title: "Link title", + href: "www.example.com", + }, + ], + }` + * when we want to customize the description or add links. + */ +export const StatusIndicator = () => { + let componentStatus = useParameter(STATUS_PARAM_KEY, null); + let statusData = VALID_STATUS_MAP[componentStatus?.type ?? componentStatus]; + + if (!componentStatus || !statusData) { + return ""; + } + + // The tooltip message is added/removed from the DOM when visibility changes. + // We need to update the aira-describedby button relationship accordingly. + let onVisibilityChange = isVisible => { + let button = document.getElementById("statusButton"); + if (isVisible) { + button.setAttribute("aria-describedby", "statusMessage"); + } else { + button.removeAttribute("aria-describedby"); + } + }; + + let description = componentStatus.description || statusData.description; + let links = componentStatus.links || []; + + return ( + ( +
    + +
    + )} + > + + + {statusData.label} + + +
    + ); +}; diff --git a/browser/components/storybook/.storybook/addon-component-status/constants.mjs b/browser/components/storybook/.storybook/addon-component-status/constants.mjs new file mode 100644 index 0000000000..84dc1983ac --- /dev/null +++ b/browser/components/storybook/.storybook/addon-component-status/constants.mjs @@ -0,0 +1,7 @@ +/* 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/. */ + +export const ADDON_ID = "addon-component-status"; +export const TOOL_ID = `${ADDON_ID}/statusIndicator`; +export const STATUS_PARAM_KEY = "status"; diff --git a/browser/components/storybook/.storybook/addon-component-status/index.js b/browser/components/storybook/.storybook/addon-component-status/index.js new file mode 100644 index 0000000000..7f923e2de1 --- /dev/null +++ b/browser/components/storybook/.storybook/addon-component-status/index.js @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* eslint-env node */ + +/** + * This file hooks our addon into Storybook. Having a root-level file like this + * is a Storybook requirement. It handles registering the addon without any + * additional user configuration. + */ + +function config(entry = []) { + return [...entry, require.resolve("./preset/preview.mjs")]; +} + +function managerEntries(entry = []) { + return [...entry, require.resolve("./preset/manager.mjs")]; +} + +module.exports = { + managerEntries, + config, +}; diff --git a/browser/components/storybook/.storybook/addon-component-status/preset/manager.mjs b/browser/components/storybook/.storybook/addon-component-status/preset/manager.mjs new file mode 100644 index 0000000000..4aa611b156 --- /dev/null +++ b/browser/components/storybook/.storybook/addon-component-status/preset/manager.mjs @@ -0,0 +1,19 @@ +/* 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 handles registering the Storybook addon */ + +// eslint-disable-next-line no-unused-vars +import React from "react"; +import { addons, types } from "@storybook/addons"; +import { ADDON_ID, TOOL_ID } from "../constants.mjs"; +import { StatusIndicator } from "../StatusIndicator.mjs"; + +addons.register(ADDON_ID, () => { + addons.add(TOOL_ID, { + type: types.TOOL, + title: "Pseudo Localization", + render: StatusIndicator, + }); +}); diff --git a/browser/components/storybook/.storybook/addon-component-status/preset/preview.mjs b/browser/components/storybook/.storybook/addon-component-status/preset/preview.mjs new file mode 100644 index 0000000000..57f1378af6 --- /dev/null +++ b/browser/components/storybook/.storybook/addon-component-status/preset/preview.mjs @@ -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/. */ + +export const globalTypes = { + componentStatus: { + name: "Component status", + description: + "Provides a visual indicator of the component's readiness status.", + defaultValue: "default", + }, +}; diff --git a/browser/components/storybook/.storybook/addon-fluent/FluentPanel.mjs b/browser/components/storybook/.storybook/addon-fluent/FluentPanel.mjs new file mode 100644 index 0000000000..692ff73737 --- /dev/null +++ b/browser/components/storybook/.storybook/addon-fluent/FluentPanel.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 React from "react"; +import { addons } from "@storybook/addons"; +// eslint-disable-next-line no-unused-vars +import { AddonPanel } from "@storybook/components"; +import { FLUENT_CHANGED, FLUENT_SET_STRINGS } from "./constants.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "./fluent-panel.css"; + +export class FluentPanel extends React.Component { + constructor(props) { + super(props); + this.channel = addons.getChannel(); + this.state = { + name: null, + strings: [], + }; + } + + componentDidMount() { + const { api } = this.props; + api.on(FLUENT_CHANGED, this.handleFluentChanged); + } + + componentWillUnmount() { + const { api } = this.props; + api.off(FLUENT_CHANGED, this.handleFluentChanged); + } + + handleFluentChanged = strings => { + let storyData = this.props.api.getCurrentStoryData(); + let fileName = `${storyData.component}.ftl`; + this.setState(state => ({ ...state, strings, fileName })); + }; + + onInput = e => { + this.setState(state => { + let strings = []; + for (let [key, value] of state.strings) { + if (key == e.target.name) { + let stringValue = e.target.value; + if (stringValue.startsWith(".")) { + stringValue = "\n" + stringValue; + } + strings.push([key, stringValue]); + } else { + strings.push([key, value]); + } + } + let stringified = strings + .map(([key, value]) => `${key} = ${value}`) + .join("\n"); + this.channel.emit(FLUENT_SET_STRINGS, stringified); + const { fluentStrings } = this.props.api.getGlobals(); + this.props.api.updateGlobals({ + fluentStrings: { ...fluentStrings, [state.fileName]: strings }, + }); + return { ...state, strings }; + }); + }; + + render() { + const { api, active } = this.props; + const { strings } = this.state; + if (strings.length === 0) { + return ( + +
    +
    + This story is not configured to use Fluent. +
    +
    +
    + ); + } + + return ( + +
    + + + + + + + + + {strings.map(([identifier, value]) => ( + + + + + ))} + +
    + Identifier + + String +
    + {identifier} + + +
    +
    +
    + ); + } +} diff --git a/browser/components/storybook/.storybook/addon-fluent/PseudoLocalizationButton.mjs b/browser/components/storybook/.storybook/addon-fluent/PseudoLocalizationButton.mjs new file mode 100644 index 0000000000..d60112d224 --- /dev/null +++ b/browser/components/storybook/.storybook/addon-fluent/PseudoLocalizationButton.mjs @@ -0,0 +1,55 @@ +/* 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 no-unused-vars +import React from "react"; +import { useGlobals } from "@storybook/api"; +import { + // eslint-disable-next-line no-unused-vars + Icons, + // eslint-disable-next-line no-unused-vars + IconButton, + // eslint-disable-next-line no-unused-vars + WithTooltip, + // eslint-disable-next-line no-unused-vars + TooltipLinkList, +} from "@storybook/components"; +import { TOOL_ID, STRATEGY_DEFAULT, PSEUDO_STRATEGIES } from "./constants.mjs"; + +// React component for a button + tooltip that gets added to the Storybook toolbar. +export const PseudoLocalizationButton = () => { + const [{ pseudoStrategy = STRATEGY_DEFAULT }, updateGlobals] = useGlobals(); + + const updatePseudoStrategy = strategy => { + updateGlobals({ pseudoStrategy: strategy }); + }; + + const getTooltipLinks = ({ onHide }) => { + return PSEUDO_STRATEGIES.map(strategy => ({ + id: strategy, + title: strategy.charAt(0).toUpperCase() + strategy.slice(1), + onClick: () => { + updatePseudoStrategy(strategy); + onHide(); + }, + active: pseudoStrategy === strategy, + })); + }; + + return ( + } + > + + + + + ); +}; diff --git a/browser/components/storybook/.storybook/addon-fluent/constants.mjs b/browser/components/storybook/.storybook/addon-fluent/constants.mjs new file mode 100644 index 0000000000..3f00b2972a --- /dev/null +++ b/browser/components/storybook/.storybook/addon-fluent/constants.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/. */ + +export const ADDON_ID = "addon-fluent"; +export const PANEL_ID = `${ADDON_ID}/fluentPanel`; +export const TOOL_ID = `${ADDON_ID}/toolbarButton`; + +export const STRATEGY_DEFAULT = "default"; +export const STRATEGY_ACCENTED = "accented"; +export const STRATEGY_BIDI = "bidi"; + +export const PSEUDO_STRATEGIES = [ + STRATEGY_DEFAULT, + STRATEGY_ACCENTED, + STRATEGY_BIDI, +]; + +export const DIRECTIONS = { + ltr: "ltr", + rtl: "rtl", +}; + +export const DIRECTION_BY_STRATEGY = { + [STRATEGY_DEFAULT]: DIRECTIONS.ltr, + [STRATEGY_ACCENTED]: DIRECTIONS.ltr, + [STRATEGY_BIDI]: DIRECTIONS.rtl, +}; + +export const UPDATE_STRATEGY_EVENT = "update-strategy"; +export const FLUENT_SET_STRINGS = "fluent-set-strings"; +export const FLUENT_CHANGED = "fluent-changed"; diff --git a/browser/components/storybook/.storybook/addon-fluent/fluent-panel.css b/browser/components/storybook/.storybook/addon-fluent/fluent-panel.css new file mode 100644 index 0000000000..75f4562820 --- /dev/null +++ b/browser/components/storybook/.storybook/addon-fluent/fluent-panel.css @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +.addon-panel-body { + box-sizing: border-box; +} + +.addon-panel-message { + background: #FFF5CF; + color: #333333; + padding: 10px 15px; + line-height: 20px; + box-shadow: rgba(0,0,0,.1) 0 -1px 0 0 inset; + font-size: 13px; +} + +.addon-panel-table { + border-collapse: collapse; + border-spacing: 0; + color: #333333; + font-size: 13px; + line-height: 20px; + text-align: left; + width: 100%; + margin: 0; +} + +.addon-panel-table-head { + color: rgba(51,51,51,0.75); +} + +.addon-panel-table-head th { + padding: 10px 15px; + border: none; + vertical-align: top; +} + +.addon-panel-table-head th:first-of-type, .addon-panel-table-body td:first-of-type { + width: 25%; + padding-left: 20px; +} + +.addon-panel-table-head th:last-of-type, .addon-panel-table-body td:last-of-type { + padding-right: 20px; +} + +.addon-panel-table-body { + border-radius: 4px; +} + +.addon-panel-table-body tr { + overflow: hidden; + border-top: 1px solid #e6e6e6; +} + +.addon-panel-table-body td { + padding: 10px 15px; + font-weight: bold; +} + +.addon-panel-table-body label { + display: flex; +} + +.addon-panel-table-body textarea { + height: fit-content; + appearance: none; + border: none; + box-sizing: inherit; + display: block; + margin: 0; + background-color: rgb(255, 255, 255); + padding: 6px 10px; + color: #333333; + box-shadow: rgba(0,0,0,.1) 0 0 0 1px inset; + border-radius: 4px; + line-height: 20px; + flex: 1; + text-align: left; + overflow: visible; + max-height: 400px; +} diff --git a/browser/components/storybook/.storybook/addon-fluent/index.js b/browser/components/storybook/.storybook/addon-fluent/index.js new file mode 100644 index 0000000000..7f923e2de1 --- /dev/null +++ b/browser/components/storybook/.storybook/addon-fluent/index.js @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* eslint-env node */ + +/** + * This file hooks our addon into Storybook. Having a root-level file like this + * is a Storybook requirement. It handles registering the addon without any + * additional user configuration. + */ + +function config(entry = []) { + return [...entry, require.resolve("./preset/preview.mjs")]; +} + +function managerEntries(entry = []) { + return [...entry, require.resolve("./preset/manager.mjs")]; +} + +module.exports = { + managerEntries, + config, +}; diff --git a/browser/components/storybook/.storybook/addon-fluent/preset/manager.mjs b/browser/components/storybook/.storybook/addon-fluent/preset/manager.mjs new file mode 100644 index 0000000000..0f7ff9299b --- /dev/null +++ b/browser/components/storybook/.storybook/addon-fluent/preset/manager.mjs @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** This file handles registering the Storybook addon */ + +// eslint-disable-next-line no-unused-vars +import React from "react"; +import { addons, types } from "@storybook/addons"; +import { ADDON_ID, PANEL_ID, TOOL_ID } from "../constants.mjs"; +import { PseudoLocalizationButton } from "../PseudoLocalizationButton.mjs"; +// eslint-disable-next-line no-unused-vars +import { FluentPanel } from "../FluentPanel.mjs"; + +// Register the addon. +addons.register(ADDON_ID, api => { + // Register the tool. + addons.add(TOOL_ID, { + type: types.TOOL, + title: "Pseudo Localization", + // Toolbar button doesn't show on the "Docs" tab. + match: ({ viewMode }) => !!(viewMode && viewMode.match(/^story$/)), + render: PseudoLocalizationButton, + }); + + addons.add(PANEL_ID, { + title: "Fluent", + //👇 Sets the type of UI element in Storybook + type: types.PANEL, + render: ({ active, key }) => ( + + ), + }); +}); diff --git a/browser/components/storybook/.storybook/addon-fluent/preset/preview.mjs b/browser/components/storybook/.storybook/addon-fluent/preset/preview.mjs new file mode 100644 index 0000000000..cf4f135d40 --- /dev/null +++ b/browser/components/storybook/.storybook/addon-fluent/preset/preview.mjs @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This file provides global decorators for the Storybook addon. In theory we + * could combine multiple decorators, but for now we only need one. + */ + +import { + withPseudoLocalization, + withFluentStrings, +} from "../withPseudoLocalization.mjs"; + +export const decorators = [withPseudoLocalization, withFluentStrings]; +export const globalTypes = { + pseudoStrategy: { + name: "Pseudo l10n strategy", + description: "Provides text variants for testing different locales.", + defaultValue: "default", + }, + fluentStrings: { + name: "Fluent string map for components", + description: "Mapping of component to fluent strings.", + defaultValue: {}, + }, +}; diff --git a/browser/components/storybook/.storybook/addon-fluent/withPseudoLocalization.mjs b/browser/components/storybook/.storybook/addon-fluent/withPseudoLocalization.mjs new file mode 100644 index 0000000000..9d6c62af38 --- /dev/null +++ b/browser/components/storybook/.storybook/addon-fluent/withPseudoLocalization.mjs @@ -0,0 +1,89 @@ +/* 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 { useEffect, useGlobals, addons } from "@storybook/addons"; +import { + DIRECTIONS, + DIRECTION_BY_STRATEGY, + UPDATE_STRATEGY_EVENT, + FLUENT_CHANGED, +} from "./constants.mjs"; +import { provideFluent } from "../fluent-utils.mjs"; + +/** + * withPseudoLocalization is a Storybook decorator that handles emitting an + * event to update translations when a new pseudo localization strategy is + * applied. It also handles setting a "dir" attribute on the root element in the + * Storybook iframe. + * + * @param {Function} StoryFn - Provided by Storybook, used to render the story. + * @param {Object} context - Provided by Storybook, data about the story. + * @returns {Function} StoryFn with a modified "dir" attr set. + */ +export const withPseudoLocalization = (StoryFn, context) => { + const [{ pseudoStrategy }] = useGlobals(); + const direction = DIRECTION_BY_STRATEGY[pseudoStrategy] || DIRECTIONS.ltr; + const isInDocs = context.viewMode === "docs"; + const channel = addons.getChannel(); + + useEffect(() => { + if (pseudoStrategy) { + channel.emit(UPDATE_STRATEGY_EVENT, pseudoStrategy); + } + }, [pseudoStrategy]); + + useEffect(() => { + if (isInDocs) { + document.documentElement.setAttribute("dir", DIRECTIONS.ltr); + let storyElements = document.querySelectorAll(".docs-story"); + storyElements.forEach(element => element.setAttribute("dir", direction)); + } else { + document.documentElement.setAttribute("dir", direction); + } + }, [direction, isInDocs]); + + return StoryFn(); +}; + +/** + * withFluentStrings is a Storybook decorator that handles emitting an + * event to update the Fluent strings shown in the Fluent panel. + * + * @param {Function} StoryFn - Provided by Storybook, used to render the story. + * @param {Object} context - Provided by Storybook, data about the story. + * @returns {Function} StoryFn unmodified. + */ +export const withFluentStrings = (StoryFn, context) => { + const [{ fluentStrings }, updateGlobals] = useGlobals(); + const channel = addons.getChannel(); + + const fileName = context.component + ".ftl"; + let strings = []; + + if (context.parameters?.fluent && fileName) { + if (fluentStrings.hasOwnProperty(fileName)) { + strings = fluentStrings[fileName]; + } else { + let resource = provideFluent(context.parameters.fluent, fileName); + for (let message of resource.body) { + strings.push([ + message.id, + [ + message.value, + ...Object.entries(message.attributes).map( + ([key, value]) => ` .${key} = ${value}` + ), + ].join("\n"), + ]); + } + updateGlobals({ + fluentStrings: { ...fluentStrings, [fileName]: strings }, + }); + } + } + + channel.emit(FLUENT_CHANGED, strings); + + return StoryFn(); +}; diff --git a/browser/components/storybook/.storybook/chrome-styles-loader.js b/browser/components/storybook/.storybook/chrome-styles-loader.js new file mode 100644 index 0000000000..4a66128320 --- /dev/null +++ b/browser/components/storybook/.storybook/chrome-styles-loader.js @@ -0,0 +1,145 @@ +/* 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 node */ + +/** + * This file contains a webpack loader which rewrites JS source files to use CSS + * imports when running in Storybook. This allows JS files loaded in Storybook to use + * chrome:// URIs when loading external stylesheets without having to worry + * about Storybook being able to find and detect changes to the files. + * + * This loader allows Lit-based custom element code like this to work with + * Storybook: + * + * render() { + * return html` + * + * ... + * `; + * } + * + * By rewriting the source to this: + * + * import moztoggleStyles from "toolkit/content/widgets/moz-toggle/moz-toggle.css"; + * ... + * render() { + * return html` + * + * ... + * `; + * } + * + * It works similarly for vanilla JS custom elements that utilize template + * strings. The following code: + * + * static get markup() { + * return` + * + * `; + * } + * + * Gets rewritten to: + * + * import migrationwizardStyles from "browser/themes/shared/migration/migration-wizard.css"; + * ... + * static get markup() { + * return` + * + * `; + * } + */ + +const path = require("path"); +const projectRoot = path.join(process.cwd(), "../../.."); +const rewriteChromeUri = require("./chrome-uri-utils.js"); + +/** + * Return an array of the unique chrome:// CSS URIs referenced in this file. + * + * @param {string} source - The source file to scan. + * @returns {string[]} Unique list of chrome:// CSS URIs + */ +function getReferencedChromeUris(source) { + const chromeRegex = /chrome:\/\/.*?\.css/g; + const matches = new Set(); + for (let match of source.matchAll(chromeRegex)) { + // Add the full URI to the set of matches. + matches.add(match[0]); + } + return [...matches]; +} + +/** + * Replace references to chrome:// URIs with the relative path on disk from the + * project root. + * + * @this {WebpackLoader} https://webpack.js.org/api/loaders/ + * @param {string} source - The source file to update. + * @returns {string} The updated source. + */ +async function rewriteChromeUris(source) { + const chromeUriToLocalPath = new Map(); + // We're going to rewrite the chrome:// URIs, find all referenced URIs. + let chromeDependencies = getReferencedChromeUris(source); + for (let chromeUri of chromeDependencies) { + let localRelativePath = rewriteChromeUri(chromeUri); + if (localRelativePath) { + localRelativePath = localRelativePath.replaceAll("\\", "/"); + // Store the mapping to a local path for this chrome URI. + chromeUriToLocalPath.set(chromeUri, localRelativePath); + // Tell webpack the file being handled depends on the referenced file. + this.addMissingDependency(path.join(projectRoot, localRelativePath)); + } + } + // Rewrite the source file with mapped chrome:// URIs. + let rewrittenSource = source; + for (let [chromeUri, localPath] of chromeUriToLocalPath.entries()) { + // Generate an import friendly variable name for the default export from + // the CSS file e.g. __chrome_styles_loader__moztoggleStyles. + let cssImport = `__chrome_styles_loader__${path + .basename(localPath, ".css") + .replaceAll("-", "")}Styles`; + + // MozTextLabel is a special case for now since we don't use a template. + if ( + this.resourcePath.endsWith("/moz-label.mjs") || + this.resourcePath.endsWith(".js") + ) { + rewrittenSource = rewrittenSource.replaceAll(`"${chromeUri}"`, cssImport); + } else { + rewrittenSource = rewrittenSource.replaceAll( + chromeUri, + `\$\{${cssImport}\}` + ); + } + + // Add a CSS import statement as the first line in the file. + rewrittenSource = + `import ${cssImport} from "${localPath}";\n` + rewrittenSource; + } + return rewrittenSource; +} + +/** + * The WebpackLoader export. Runs async since apparently that's preferred. + * + * @param {string} source - The source to rewrite. + * @param {Map} sourceMap - Source map data, unused. + * @param {Object} meta - Metadata, unused. + */ +module.exports = async function chromeUriLoader(source) { + // Get a callback to tell webpack when we're done. + const callback = this.async(); + // Rewrite the source async since that appears to be preferred (and will be + // necessary once we support rewriting CSS/SVG/etc). + const newSource = await rewriteChromeUris.call(this, source); + // Give webpack the rewritten content. + callback(null, newSource); +}; diff --git a/browser/components/storybook/.storybook/chrome-uri-utils.js b/browser/components/storybook/.storybook/chrome-uri-utils.js new file mode 100644 index 0000000000..514d397961 --- /dev/null +++ b/browser/components/storybook/.storybook/chrome-uri-utils.js @@ -0,0 +1,30 @@ +/* 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 node */ + +const [prefixMap, aliasMap, sourceMap] = require("./chrome-map.js"); + +function rewriteChromeUri(uri) { + if (uri in aliasMap) { + return rewriteChromeUri(aliasMap[uri]); + } + for (let [prefix, [bundlePath]] of Object.entries(prefixMap)) { + if (uri.startsWith(prefix)) { + if (!bundlePath.endsWith("/")) { + bundlePath += "/"; + } + let relativePath = uri.slice(prefix.length); + let objdirPath = bundlePath + relativePath; + for (let [_objdirPath, [filePath]] of Object.entries(sourceMap)) { + if (_objdirPath == objdirPath) { + // We're just hoping this is the actual path =\ + return filePath; + } + } + } + } + return ""; +} + +module.exports = rewriteChromeUri; diff --git a/browser/components/storybook/.storybook/fluent-utils.mjs b/browser/components/storybook/.storybook/fluent-utils.mjs new file mode 100644 index 0000000000..52a3721820 --- /dev/null +++ b/browser/components/storybook/.storybook/fluent-utils.mjs @@ -0,0 +1,122 @@ +/* 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 { DOMLocalization } from "@fluent/dom"; +import { FluentBundle, FluentResource } from "@fluent/bundle"; +import { addons } from "@storybook/addons"; +import { PSEUDO_STRATEGY_TRANSFORMS } from "./l10n-pseudo.mjs"; +import { + FLUENT_SET_STRINGS, + UPDATE_STRATEGY_EVENT, + STRATEGY_DEFAULT, + PSEUDO_STRATEGIES, +} from "./addon-fluent/constants.mjs"; + +let loadedResources = new Map(); +let currentStrategy; +let storybookBundle = new FluentBundle("en-US", { + transform(str) { + if (currentStrategy in PSEUDO_STRATEGY_TRANSFORMS) { + return PSEUDO_STRATEGY_TRANSFORMS[currentStrategy](str); + } + return str; + }, +}); + +// Listen for update events from addon-fluent. +const channel = addons.getChannel(); +channel.on(UPDATE_STRATEGY_EVENT, updatePseudoStrategy); +channel.on(FLUENT_SET_STRINGS, ftlContents => { + let resource = new FluentResource(ftlContents); + for (let message of resource.body) { + let existingMessage = storybookBundle.getMessage(message.id); + existingMessage.value = message.value; + existingMessage.attributes = message.attributes; + } + document.l10n.translateRoots(); +}); + +/** + * Updates "currentStrategy" when the selected pseudo localization strategy + * changes, which in turn changes the transform used by the Fluent bundle. + * + * @param {string} strategy + * Pseudo localization strategy. Can be "default", "accented", or "bidi". + */ +function updatePseudoStrategy(strategy = STRATEGY_DEFAULT) { + if (strategy !== currentStrategy && PSEUDO_STRATEGIES.includes(strategy)) { + currentStrategy = strategy; + document.l10n.translateRoots(); + } +} + +export function connectFluent() { + document.l10n = new DOMLocalization([], generateBundles); + document.l10n.connectRoot(document.documentElement); + document.l10n.translateRoots(); +} + +function* generateBundles() { + yield* [storybookBundle]; +} + +export async function insertFTLIfNeeded(fileName) { + if (loadedResources.has(fileName)) { + return; + } + + // This should be browser, locales-preview or toolkit. + let [root, ...rest] = fileName.split("/"); + let ftlContents; + + // TODO(mstriemer): These seem like they could be combined but I don't want + // to fight with webpack anymore. + if (root == "toolkit") { + // eslint-disable-next-line no-unsanitized/method + let imported = await import( + /* webpackInclude: /.*[\/\\].*\.ftl$/ */ + `toolkit/locales/en-US/${fileName}` + ); + ftlContents = imported.default; + } else if (root == "browser") { + // eslint-disable-next-line no-unsanitized/method + let imported = await import( + /* webpackInclude: /.*[\/\\].*\.ftl$/ */ + `browser/locales/en-US/${fileName}` + ); + ftlContents = imported.default; + } else if (root == "locales-preview") { + // eslint-disable-next-line no-unsanitized/method + let imported = await import( + /* webpackInclude: /\.ftl$/ */ + `browser/locales-preview/${rest}` + ); + ftlContents = imported.default; + } else if (root == "branding") { + // eslint-disable-next-line no-unsanitized/method + let imported = await import( + /* webpackInclude: /\.ftl$/ */ + `browser/branding/nightly/locales/en-US/${rest}` + ); + ftlContents = imported.default; + } + + if (loadedResources.has(fileName)) { + // Seems possible we've attempted to load this twice before the first call + // resolves, so once the first load is complete we can abandon the others. + return; + } + + provideFluent(ftlContents, fileName); +} + +export function provideFluent(ftlContents, fileName) { + let ftlResource = new FluentResource(ftlContents); + storybookBundle.addResource(ftlResource); + if (fileName) { + loadedResources.set(fileName, ftlResource); + } + document.l10n.translateRoots(); + return ftlResource; +} diff --git a/browser/components/storybook/.storybook/l10n-pseudo.mjs b/browser/components/storybook/.storybook/l10n-pseudo.mjs new file mode 100644 index 0000000000..c92be262e9 --- /dev/null +++ b/browser/components/storybook/.storybook/l10n-pseudo.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/. */ + +// Stolen from https://github.com/firefox-devtools/profiler/blob/52a7531662a08d96dc8bd03d25adcdb4c9653b92/src/utils/l10n-pseudo.js +// Which was stolen from https://hg.mozilla.org/mozilla-central/file/a1f74e8c8fb72390d22054d6b00c28b1a32f6c43/intl/l10n/L10nRegistry.jsm#l425 + +/** + * Pseudolocalizations + * + * PSEUDO_STRATEGIES is a dict of strategies to be used to modify a + * context in order to create pseudolocalizations. These can be used by + * developers to test the localizability of their code without having to + * actually speak a foreign language. + * + * Currently, the following pseudolocales are supported: + * + * accented - Ȧȧƈƈḗḗƞŧḗḗḓ Ḗḗƞɠŀīīşħ + * + * In Accented English all Latin letters are replaced by accented + * Unicode counterparts which don't impair the readability of the content. + * This allows developers to quickly test if any given string is being + * correctly displayed in its 'translated' form. Additionally, simple + * heuristics are used to make certain words longer to better simulate the + * experience of international users. + * + * bidi - ɥsıʅƃuƎ ıpıԐ + * + * Bidi English is a fake RTL locale. All words are surrounded by + * Unicode formatting marks forcing the RTL directionality of characters. + * In addition, to make the reversed text easier to read, individual + * letters are flipped. + * + * Note: The name above is hardcoded to be RTL in case code editors have + * trouble with the RLO and PDF Unicode marks. In reality, it should be + * surrounded by those marks as well. + * + * See https://bugzil.la/1450781 for more information. + * + * In this implementation we use code points instead of inline unicode characters + * because the encoding of JSM files mangles them otherwise. + */ + +const ACCENTED_MAP = { + // ȦƁƇḒḖƑƓĦĪĴĶĿḾȠǾƤɊŘŞŦŬṼẆẊẎẐ + // prettier-ignore + "caps": [550, 385, 391, 7698, 7702, 401, 403, 294, 298, 308, 310, 319, 7742, 544, 510, 420, 586, 344, 350, 358, 364, 7804, 7814, 7818, 7822, 7824], + // ȧƀƈḓḗƒɠħīĵķŀḿƞǿƥɋřşŧŭṽẇẋẏẑ + // prettier-ignore + "small": [551, 384, 392, 7699, 7703, 402, 608, 295, 299, 309, 311, 320, 7743, 414, 511, 421, 587, 345, 351, 359, 365, 7805, 7815, 7819, 7823, 7825], +}; + +const FLIPPED_MAP = { + // ∀ԐↃᗡƎℲ⅁HIſӼ⅂WNOԀÒᴚS⊥∩ɅMX⅄Z + // prettier-ignore + "caps": [8704, 1296, 8579, 5601, 398, 8498, 8513, 72, 73, 383, 1276, 8514, 87, 78, 79, 1280, 210, 7450, 83, 8869, 8745, 581, 77, 88, 8516, 90], + // ɐqɔpǝɟƃɥıɾʞʅɯuodbɹsʇnʌʍxʎz + // prettier-ignore + "small": [592, 113, 596, 112, 477, 607, 387, 613, 305, 638, 670, 645, 623, 117, 111, 100, 98, 633, 115, 647, 110, 652, 653, 120, 654, 122], +}; + +function transformString( + map = FLIPPED_MAP, + elongate = false, + prefix = "", + postfix = "", + msg +) { + // Exclude access-keys and other single-char messages + if (msg.length === 1) { + return msg; + } + // XML entities (‪) and XML tags. + const reExcluded = /(&[#\w]+;|<\s*.+?\s*>)/; + + const parts = msg.split(reExcluded); + const modified = parts.map(part => { + if (reExcluded.test(part)) { + return part; + } + return ( + prefix + + part.replace(/[a-z]/gi, ch => { + const cc = ch.charCodeAt(0); + if (cc >= 97 && cc <= 122) { + const newChar = String.fromCodePoint(map.small[cc - 97]); + // duplicate "a", "e", "o" and "u" to emulate ~30% longer text + if ( + elongate && + (cc === 97 || cc === 101 || cc === 111 || cc === 117) + ) { + return newChar + newChar; + } + return newChar; + } + if (cc >= 65 && cc <= 90) { + return String.fromCodePoint(map.caps[cc - 65]); + } + return ch; + }) + + postfix + ); + }); + return modified.join(""); +} + +export const PSEUDO_STRATEGY_TRANSFORMS = { + accented: transformString.bind(null, ACCENTED_MAP, true, "", ""), + bidi: transformString.bind(null, FLIPPED_MAP, false, "\u202e", "\u202c"), +}; diff --git a/browser/components/storybook/.storybook/main.js b/browser/components/storybook/.storybook/main.js new file mode 100644 index 0000000000..3e42f778a4 --- /dev/null +++ b/browser/components/storybook/.storybook/main.js @@ -0,0 +1,153 @@ +/* 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 node */ + +const path = require("path"); +const webpack = require("webpack"); +const rewriteChromeUri = require("./chrome-uri-utils.js"); + +const projectRoot = path.resolve(__dirname, "../../../../"); + +module.exports = { + // The ordering for this stories array affects the order that they are displayed in Storybook + stories: [ + // Docs section + "../**/README.*.stories.md", + // UI Widgets section + `${projectRoot}/toolkit/content/widgets/**/*.stories.@(js|jsx|mjs|ts|tsx|md)`, + // about:logins components stories + `${projectRoot}/browser/components/aboutlogins/content/components/**/*.stories.mjs`, + // Everything else + "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx|md)", + // Design system files + `${projectRoot}/toolkit/themes/shared/design-system/**/*.stories.@(js|jsx|mjs|ts|tsx|md)`, + ], + addons: [ + "@storybook/addon-links", + { + name: "@storybook/addon-essentials", + options: { + backgrounds: false, + measure: false, + outline: false, + }, + }, + "@storybook/addon-a11y", + path.resolve(__dirname, "addon-fluent"), + path.resolve(__dirname, "addon-component-status"), + ], + framework: "@storybook/web-components", + webpackFinal: async (config, { configType }) => { + // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION' + // You can change the configuration based on that. + // 'PRODUCTION' is used when building the static version of storybook. + + // Make whatever fine-grained changes you need + config.resolve.alias.browser = `${projectRoot}/browser`; + config.resolve.alias.toolkit = `${projectRoot}/toolkit`; + config.resolve.alias[ + "toolkit-widgets" + ] = `${projectRoot}/toolkit/content/widgets/`; + config.resolve.alias[ + "lit.all.mjs" + ] = `${projectRoot}/toolkit/content/widgets/vendor/lit.all.mjs`; + // @mdx-js/react@1.x.x versions don't get hoisted to the root node_modules + // folder due to the versions of React it accepts as a peer dependency. That + // means we have to go one level deeper and look in the node_modules of + // @storybook/addon-docs, which depends on @mdx-js/react. + config.resolve.alias["@storybook/addon-docs"] = + "browser/components/storybook/node_modules/@storybook/addon-docs"; + config.resolve.alias["@mdx-js/react"] = + "@storybook/addon-docs/node_modules/@mdx-js/react"; + + // The @storybook/web-components project uses lit-html. Redirect it to our + // bundled version. + config.resolve.alias["lit-html/directive-helpers.js"] = "lit.all.mjs"; + config.resolve.alias["lit-html"] = "lit.all.mjs"; + + config.plugins.push( + // Rewrite chrome:// URI imports to file system paths. + new webpack.NormalModuleReplacementPlugin(/^chrome:\/\//, resource => { + resource.request = rewriteChromeUri(resource.request); + }) + ); + + config.module.rules.push({ + test: /\.ftl$/, + type: "asset/source", + }); + + config.module.rules.push({ + test: /\.m?js$/, + exclude: /.storybook/, + use: [{ loader: path.resolve(__dirname, "./chrome-styles-loader.js") }], + }); + + // Replace the default CSS rule with a rule to emit a separate CSS file and + // export the URL. This allows us to rewrite the source to use CSS imports + // via the chrome-styles-loader. + let cssFileTest = /\.css$/.toString(); + let cssRuleIndex = config.module.rules.findIndex( + rule => rule.test.toString() === cssFileTest + ); + config.module.rules[cssRuleIndex] = { + test: /\.css$/, + exclude: [/.storybook/, /node_modules/], + type: "asset/resource", + generator: { + filename: "[name].[contenthash].css", + }, + }; + + // We're adding a rule for files matching this pattern in order to support + // writing docs only stories in plain markdown. + const MD_STORY_REGEX = /(stories|story)\.md$/; + + // Find the existing rule for MDX stories. + let mdxStoryTest = /(stories|story)\.mdx$/.toString(); + let mdxRule = config.module.rules.find( + rule => rule.test.toString() === mdxStoryTest + ); + + // Use a custom Webpack loader to transform our markdown stories into MDX, + // then run our new MDX through the same loaders that Storybook usually uses + // for MDX files. This is how we get a docs page from plain markdown. + config.module.rules.push({ + test: MD_STORY_REGEX, + use: [ + ...mdxRule.use, + { loader: path.resolve(__dirname, "./markdown-story-loader.js") }, + ], + }); + + // Find the existing rule for markdown files. + let markdownTest = /\.md$/.toString(); + let markdownRuleIndex = config.module.rules.findIndex( + rule => rule.test.toString() === markdownTest + ); + let markdownRule = config.module.rules[markdownRuleIndex]; + + // Modify the existing markdown rule so it doesn't process .stories.md + // files, but still treats any other markdown files as asset/source. + config.module.rules[markdownRuleIndex] = { + ...markdownRule, + exclude: MD_STORY_REGEX, + }; + + config.optimization = { + splitChunks: false, + runtimeChunk: false, + sideEffects: false, + usedExports: false, + concatenateModules: false, + minimizer: [], + }; + + // Return the altered config + return config; + }, + core: { + builder: "webpack5", + }, +}; diff --git a/browser/components/storybook/.storybook/markdown-story-loader.js b/browser/components/storybook/.storybook/markdown-story-loader.js new file mode 100644 index 0000000000..b11036af74 --- /dev/null +++ b/browser/components/storybook/.storybook/markdown-story-loader.js @@ -0,0 +1,149 @@ +/* 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 node */ + +/** + * This file contains a Webpack loader that takes markdown as its source and + * outputs a docs only MDX Storybook story. This enables us to write docs only + * pages in plain markdown by specifying a `.stories.md` extension. + * + * For more context on docs only stories, see: + * https://storybook.js.org/docs/web-components/writing-docs/mdx#documentation-only-mdx + * + * The MDX generated by the loader will then get run through the same loaders + * Storybook usually uses to transform MDX files. + */ + +const path = require("path"); +const fs = require("fs"); + +const projectRoot = path.resolve(__dirname, "../../../../"); + +/** + * Takes a file path and returns a string to use as the story title, capitalized + * and split into multiple words. The file name gets transformed into the story + * name, which will be visible in the Storybook sidebar. For example, either: + * + * /stories/hello-world.stories.md or /stories/helloWorld.md + * + * will result in a story named "Hello World". + * + * @param {string} filePath - path of the file being processed. + * @returns {string} The title of the story. + */ +function getStoryTitle(filePath) { + let fileName = path.basename(filePath, ".stories.md"); + if (fileName != "README") { + try { + let relatedFilePath = path.resolve( + "../../../", + filePath.replace(".md", ".mjs") + ); + let relatedFile = fs.readFileSync(relatedFilePath).toString(); + let relatedTitle = relatedFile.match(/title: "(.*)"/)[1]; + if (relatedTitle) { + return relatedTitle + "/README"; + } + } catch {} + } + return separateWords(fileName); +} + +/** + * Splits a string into multiple capitalized words e.g. hello-world, helloWorld, + * and hello.world all become "Hello World." + * @param {string} str - String in any case. + * @returns {string} The string split into multiple words. + */ +function separateWords(str) { + return ( + str + .match(/[A-Z]?[a-z0-9]+/g) + ?.map(text => text[0].toUpperCase() + text.substring(1)) + .join(" ") || str + ); +} + +/** + * Enables rendering code in our markdown docs by parsing the source for + * annotated code blocks and replacing them with Storybook's Canvas component. + * @param {string} source - Stringified markdown source code. + * @returns {string} Source with code blocks replaced by Canvas components. + */ +function parseStoriesFromMarkdown(source) { + let storiesRegex = /```(?:js|html) story\n(?[\s\S]*?)```/g; + // $code comes from the capture group in the regex above. It consists + // of any code in between backticks and gets run when used in a Canvas component. + return source.replace( + storiesRegex, + "\n$" + ); +} + +/** + * The WebpackLoader export. Takes markdown as its source and returns a docs + * only MDX story. Falls back to filing stories under "Docs" for everything + * outside of `toolkit/content/widgets`. + * + * @param {string} source - The markdown source to rewrite to MDX. + */ +module.exports = function markdownStoryLoader(source) { + // Currently we sort docs only stories under "Docs" by default. + let storyPath = "Docs"; + + // `this.resourcePath` is the path of the file being processed. + let relativePath = path + .relative(projectRoot, this.resourcePath) + .replaceAll(path.sep, "/"); + let componentName; + + if (relativePath.includes("toolkit/content/widgets")) { + let storyNameRegex = /(?<=\/widgets\/)(?.*?)(?=\/)/g; + componentName = storyNameRegex.exec(relativePath)?.groups?.name; + if (componentName) { + // Get the common name for a component e.g. Toggle for moz-toggle + storyPath = + "UI Widgets/" + separateWords(componentName).replace(/^Moz/g, ""); + } + } + + let storyTitle = getStoryTitle(relativePath); + let title = storyTitle.includes("/") + ? storyTitle + : `${storyPath}/${storyTitle}`; + + let componentStories; + if (componentName) { + componentStories = this.resourcePath + .replace("README", componentName) + .replace(".md", ".mjs"); + try { + fs.statSync(componentStories); + componentStories = "./" + path.basename(componentStories); + } catch { + componentStories = null; + } + } + + // Unfortunately the indentation/spacing here seems to be important for the + // MDX parser to know what to do in the next step of the Webpack process. + let mdxSource = ` +import { Meta, Description, Canvas, Story } from "@storybook/addon-docs"; +${componentStories ? `import * as Stories from "${componentStories}";` : ""} + + + +${parseStoriesFromMarkdown(source)}`; + + return mdxSource; +}; diff --git a/browser/components/storybook/.storybook/preview-head.html b/browser/components/storybook/.storybook/preview-head.html new file mode 100644 index 0000000000..c2f9e8d1a2 --- /dev/null +++ b/browser/components/storybook/.storybook/preview-head.html @@ -0,0 +1,208 @@ + + + + + + diff --git a/browser/components/storybook/.storybook/preview.mjs b/browser/components/storybook/.storybook/preview.mjs new file mode 100644 index 0000000000..acff43b7c0 --- /dev/null +++ b/browser/components/storybook/.storybook/preview.mjs @@ -0,0 +1,108 @@ +/* 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 { css, html } from "lit.all.mjs"; +import { MozLitElement } from "toolkit/content/widgets/lit-utils.mjs"; +import { setCustomElementsManifest } from "@storybook/web-components"; +import customElementsManifest from "../custom-elements.json"; +import { insertFTLIfNeeded, connectFluent } from "./fluent-utils.mjs"; + +// Base Fluent set up. +connectFluent(); + +// Any fluent imports should go through MozXULElement.insertFTLIfNeeded. +window.MozXULElement = { + insertFTLIfNeeded, +}; + +// Used to set prefs in unprivileged contexts. +window.RPMSetPref = () => { + /* NOOP */ +}; +window.RPMGetFormatURLPref = () => { + /* NOOP */ +}; + +/** + * Wrapper component used to decorate all of our stories by providing access to + * `in-content/common.css` without leaking styles that conflict Storybook's CSS. + * + * More information on decorators can be found at: + * https://storybook.js.org/docs/web-components/writing-stories/decorators + * + * @property {Function} story + * Storybook uses this internally to render stories. We call `story` in our + * render function so that the story contents have the same shadow root as + * `with-common-styles` and styles from `in-content/common` get applied. + * @property {Object} context + * Another Storybook provided property containing additional data stories use + * to render. If we don't make this a reactive property Lit seems to optimize + * away any re-rendering of components inside `with-common-styles`. + */ +class WithCommonStyles extends MozLitElement { + static styles = css` + :host { + display: block; + height: 100%; + padding: 1rem; + box-sizing: border-box; + } + + :host, + :root { + font: message-box; + font-size: var(--font-size-root); + appearance: none; + background-color: var(--color-canvas); + color: var(--text-color); + -moz-box-layout: flex; + } + + :host { + font-size: var(--font-size-root); + } + `; + + static properties = { + story: { type: Function }, + context: { type: Object }, + }; + + connectedCallback() { + super.connectedCallback(); + this.classList.add("anonymous-content-host"); + } + + storyContent() { + if (this.story) { + return this.story(); + } + return html` `; + } + + render() { + return html` + + ${this.storyContent()} + `; + } +} +customElements.define("with-common-styles", WithCommonStyles); + +// Wrap all stories in `with-common-styles`. +export const decorators = [ + (story, context) => + html` + + `, +]; + +// Enable props tables documentation. +setCustomElementsManifest(customElementsManifest); diff --git a/browser/components/storybook/custom-elements-manifest.config.mjs b/browser/components/storybook/custom-elements-manifest.config.mjs new file mode 100644 index 0000000000..d49f646816 --- /dev/null +++ b/browser/components/storybook/custom-elements-manifest.config.mjs @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Custom element manifest analyzer plugin to remove static and private + * properties from custom-elements.json that we don't want to document in our + * Storybook props tables. + */ +function removePrivateAndStaticFields() { + return { + packageLinkPhase({ customElementsManifest }) { + customElementsManifest?.modules?.forEach(m => { + m?.declarations?.forEach(declaration => { + if (declaration.members != null) { + declaration.members = declaration.members.filter(member => { + return ( + !member.kind === "field" || + (!member.static && !member.name.startsWith("#")) + ); + }); + } + }); + }); + }, + }; +} + +/** + * Custom element manifest config. Controls how we parse directories for custom + * elements to populate custom-elements.json, which is used by Storybook to + * generate docs. + */ +const config = { + globs: ["../../../toolkit/content/widgets/**/*.mjs"], + exclude: [ + "../../../toolkit/content/widgets/**/*.stories.mjs", + "../../../toolkit/content/widgets/vendor/**", + "../../../toolkit/content/widgets/lit-utils.mjs", + ], + outdir: ".", + litelement: true, + plugins: [removePrivateAndStaticFields()], +}; + +export default config; diff --git a/browser/components/storybook/docs/README.lit-guide.stories.md b/browser/components/storybook/docs/README.lit-guide.stories.md new file mode 100644 index 0000000000..577b17f416 --- /dev/null +++ b/browser/components/storybook/docs/README.lit-guide.stories.md @@ -0,0 +1,157 @@ +# Lit + +## Background + +[Lit](https://lit.dev) is a small library for creating web components that is maintained by Google. It aims to improve the experience of authoring web components by eliminating boilerplate and providing more declarative syntax and re-rendering optimizations, and should feel familiar to developers who have experience working with popular component-based front end frameworks. + +Mozilla developers began experimenting with using Lit to build a handful of new web components in 2021. The developer experience and productivity benefits were noticeable enough that the team tasked with building out a library of new [reusable widgets](./README.reusable-widgets.stories.md) vendored Lit to make it available in `mozilla-central` in late 2022. Lit can now be used for creating new web components anywhere in the codebase. + +## Using Lit + +Lit has comprehensive documentation on their website that should be consulted alongside this document when building new Lit-based custom elements: [https://lit.dev/docs/](https://lit.dev/docs/) + +While Lit was initially introduced to assist with the work of the Reusable Components team it can also be used for creating both reusable and domain-specific UI widgets throughout `mozilla-central`. Some examples of custom elements that have been created using Lit so far include [moz-toggle](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/moz-toggle/moz-toggle.mjs), [moz-button-group](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/moz-button-group/moz-button-group.mjs), and the Credential Management team's [login-timeline](https://searchfox.org/mozilla-central/source/browser/components/aboutlogins/content/components/login-timeline.mjs) component. + +### When to use Lit + +Lit may be a particularly good choice if you're building a highly reactive element that needs to respond efficiently to state changes. Lit's declarative [templates](https://lit.dev/docs/templates/overview/) and [reactive properties](https://lit.dev/docs/components/properties/) can take care of a lot of the work of figuring out which parts of the UI should update in response to specific changes. + +Because Lit components are ultimately just web components, you may also want to use it just because of some of the syntax it provides, like allowing you to write your template code next to your JavaScript code, providing for binding event listeners and properties in your templates, and automatically creating an open `shadowRoot`. + +### When not to use Lit + +Lit cannot be used in cases where you want to [extend a built-in element](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#customized_built-in_elements). Lit can only be used for creating [autonomous custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#autonomous_custom_elements), i.e. elements that extend `HTMLElement`. + +## Writing components with Lit + +All of the standard features of the Lit library - with the exception of decorators - are available for use in `mozilla-central`, but there are some special considerations and specific files you should be aware of when using Lit for Firefox code. + +### Using external stylesheets + +Using external stylesheets is the preferred way to style your Lit-based components in `mozilla-central`, despite the fact that the the Lit documentation [explicitly recommends against](https://lit.dev/docs/components/styles/#external-stylesheet) this approach. The caveats they list are not particularly relevant to our use cases, and we have implemented platform level workarounds to ensure external styles will not cause a flash-of-unstyled-content. Using external stylesheets makes it so that CSS changes can be detected by our automated linting and review tools, and helps provide greater visibility to Mozilla's `desktop-theme-reviewers` group. + +### The `lit.all.mjs` vendor file + +A somewhat customized, vendored version of Lit is available at [toolkit/content/widgets/vendor/lit.all.mjs](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/vendor/lit.all.mjs). The version of Lit in `mozilla-central` has a number of patches applied to disable minification, source maps, and certain warning messages, as well as patches to replace usage of `innerHTML` with `DOMParser` and to slightly modify the behavior of the `styleMap` directive. More specifics on these patches, as well as information on how to update `lit.all.mjs`, can be found [here](https://searchfox.org/mozilla-central/source/toolkit/content/vendor/lit). + +Because our vendored version of Lit bundles the contents of a few different Lit source files into a single file, imports that would normally come from different files are pulled directly from `lit.all.mjs`. For example, imports that look like this when using the Lit npm package: + +```js +// Standard npm package. +import { LitElement } from "lit"; +import { classMap } from "lit/directives/class-map.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +``` + +Would look like this in `mozilla-central`: + +```js +// All imports come from a single file (relative path also works). +import { LitElement, classMap, ifDefined } from "chrome://global/content/vendor/lit.all.mjs"; +``` + +### `MozLitElement` and `lit-utils.mjs` + +[MozLitElement](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/lit-utils.mjs#84) is an extension of the `LitElement` class that has added functionality to make it more tailored to Mozilla developers' needs. In almost all cases `MozLitElement` should be used as the base class for your new Lit-based custom elements in place of `LitElement`. + +It can be imported from `lit-utils.js` and used as follows: + +```js +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +class MyCustomElement extends MozLitElement { + ... +} +``` + +`MozLitElement` differs from `LitElement` in a few important ways: + +#### 1. It provides automatic Fluent support for the shadow DOM + +When working with Fluent in the shadow DOM an element's `shadowRoot` must be connected before Fluent can be used. `MozLitElement` handles this by extending `LitElement`'s `connectedCallback` to [call](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/lit-utils.mjs#84) `document.l10n.connectRoot` if needed. `MozLitElement` also automatically calls `document.l10n.translateFragment` on the renderRoot anytime an element updates. The net result of these modifications is that you can use Fluent in your Lit based components just like you would in any other markup in `mozilla-central`. + +#### 2. It implements support for Lit's `@query` and `@queryAll` decorators + +The Lit library includes `@query` and `@queryAll` [decorators](https://lit.dev/docs/components/shadow-dom/#@query-@queryall-and-@queryasync-decorators) that provide an easy way of finding elements within the internal component DOM. These do not work in `mozilla-central` as we do not have support for JavaScript decorators. Instead, `MozLitElement` provides equivalent [DOM querying functionality](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/lit-utils.mjs#87-99) via defining a static `queries` property on the subclass. For example the following Lit code that queries the component's DOM for certain selectors and assigns the results to different class properties: + +```ts +import { LitElement, html } from "lit"; +import { query } from "lit/decorators/query.js"; + +class MyCustomElement extends LitElement { + @query("#title"); + _title; + + @queryAll("p"); + _paragraphs; + + render() { + return html` +

    The title

    +

    Some other paragraph.

    + `; + } +} +``` + +Is equivalent to this in `mozilla-central`: + +```js +import { html } from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +class MyCustomElement extends MozLitElement { + static queries = { + _title: "#title", // equivalent to @query + _paragraphs: { all: "p" }, // equivalent to @queryAll + }; + + render() { + return html` +

    The title

    +

    Some other paragraph.

    + `; + } +} +``` + +#### 3. It adds a `dispatchOnUpdateComplete` method + +The `dispatchOnUpdateComplete` method provides an easy way to communicate to test code or other element consumers that a reactive property change has taken effect. It leverages Lit's [updateComplete](https://lit.dev/docs/components/lifecycle/#updatecomplete) promise to emit an event after all updates have been applied and the component's DOM is ready to be queried. It has the potential to be particularly useful when you need to query the DOM in test code, for example: + +```js +// my-custom-element.mjs +class MyCustomElement extends MozLitElement { + static properties = { + clicked: { type: Boolean }, + }; + + async handleClick() { + if (!this.clicked) { + this.clicked = true; + } + this.dispatchOnUpdateComplete(new CustomEvent("button-clicked")); + } + + render() { + return html` +

    The button was ${this.clicked ? "clicked" : "not clicked"}

    + + `; + } +} +``` + +```js +// test_my_custom_element.mjs +add_task(async function testButtonClicked() { + let { button, message } = this.convenientHelperToGetElements(); + is(message.textContent.trim(), "The button was not clicked"); + + let clicked = BrowserTestUtils.waitForEvent(button, "button-clicked"); + synthesizeMouseAtCenter(button, {}); + await clicked; + + is(message.textContent.trim(), "The button was clicked"); +}); +``` diff --git a/browser/components/storybook/docs/README.other-widgets.stories.md b/browser/components/storybook/docs/README.other-widgets.stories.md new file mode 100644 index 0000000000..b2614d88b6 --- /dev/null +++ b/browser/components/storybook/docs/README.other-widgets.stories.md @@ -0,0 +1,84 @@ +# Other types of UI Widgets + +In addition to the [new reusable UI widgets](https://firefox-source-docs.mozilla.org/browser/components/storybook/docs/README.reusable-widgets.stories.html) there are existing elements that serve a similar role. +These older elements are broken down into two groups: Mozilla Custom Elements and User Agent (UA) Widgets. +Additionally, we also have domain-specific widgets that are similar to the reusable widgets but are created to serve a specific need and may or may not adhere to the Design Systems specifications. + +## Older custom elements in `toolkit/widgets` + +There are existing UI widgets in `toolkit/content/widgets/` that belong to one of two groups: Mozilla Custom Elements or User Agent (UA) Widgets. +These [existing custom elements](https://searchfox.org/mozilla-central/rev/cde3d4a8d228491e8b7f1bd94c63bbe039850696/toolkit/content/customElements.js#792-809,847-866) are loaded into all privileged main process documents automatically. +You can determine if a custom element belongs to the existing UI widgets category by either [viewing the array](https://searchfox.org/mozilla-central/rev/cde3d4a8d228491e8b7f1bd94c63bbe039850696/toolkit/content/customElements.js#792-809,847-866) or by viewing the [files in toolkit/content/widgets](https://searchfox.org/mozilla-central/source/toolkit/content/widgets). +Additionally, these older custom elements are a mix of XUL and HTML elements. + + +### Mozilla Custom Elements + +Unlike newer reusable UI widgets, the older Mozilla Custom Elements do not have a dedicated directory. +For example `arrowscrollbox.js` is an older single file custom element versus `moz-button-group/moz-button-group.mjs` which exemplifies the structure followed by newer custom elements. + +### User Agent (UA) widgets + +User agent (UA) widgets are like custom elements but run in per-origin UA widget scope instead of the chrome or content scope. +There are a much smaller number of these widgets compared to the Mozilla Custom Elements: +- [datetimebox.js](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/datetimebox.js) +- [marquee.js](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/marquee.js) +- [textrecognition.js](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/textrecognition.js) +- [videocontrols.js](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/videocontrols.js) + +Please refer to the existing [UA widgets documentation](https://firefox-source-docs.mozilla.org/toolkit/content/toolkit_widgets/ua_widget.html) for more details. + +### 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 ` +``` + +Or use `window.ensureCustomElements("")` 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 new file mode 100644 index 0000000000..f26c18a2b0 --- /dev/null +++ b/browser/components/storybook/docs/README.reusable-widgets.stories.md @@ -0,0 +1,152 @@ +# Reusable UI widgets + +## Background + +Different Firefox surfaces make use of similar UI elements such as cards, menus, +toggles, and message bars. A group of designers and developers have started +working together to create standardized versions of these elements in the form +of new web components. The intention is for these components to encapsulate our +design system, ensure accessibility and usability across the application, and +reduce the maintenance burden associated with supporting multiple different +implementations of the same UI patterns. + +Many of these components are being built using the [Lit +library](https://lit.dev/) to take advantage of its templating syntax and +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. + +## Designing new reusable widgets + +Widgets that live at the global level, "UI Widgets", should be created in collaboration with the Design System team. +This ensures consistency with the rest of the elements in the Design System and the existing UI elements. +Otherwise, you should consult with your team and the appropriate designer to create domain-specific UI widgets. +Ideally, these domain widgets should be consistent with the rest of the UI patterns established in Firefox. + +### Does an existing widget cover the use case you need? + +Before creating a new reusable widget, make sure there isn't a widget you could use already. +When designing a new reusable widget, ensure it is designed for all users. +Here are some questions you can use to help include all users: how will people perceive, operate, and understand this widget? Will the widget use standards proven technology. +[Please refer to the "General Considerations" section of the Mozilla Accessibility Release Guidelines document](https://wiki.mozilla.org/Accessibility/Guidelines#General_Considerations) for more details to ensure your widget adheres to accessibility standards. + +### Supporting widget use in different processes + +A newly designed widget may need to work in the parent process, the content process, or both depending on your use case. +[See the Process Model document for more information about these different processes](https://firefox-source-docs.mozilla.org/dom/ipc/process_model.html). +You will likely be using your widget in a privileged process (such as the parent or privileged content) with access to `Services`, `XPCOMUtils`, and other globals. +Storybook and other web content do not have access to these privileged globals, so you will need to write workarounds for `Services`, `XPCOMUtils`, chrome URIs for CSS files and assets, etc. +[Check out moz-support-link.mjs and moz-support-link.stories.mjs for an example of a widget being used in the parent/chrome and needing to handle `XPCOMUtils` in Storybook](https://searchfox.org/mozilla-central/search?q=moz-support-link&path=&case=false®exp=false). +[See moz-toggle.mjs for handling chrome URIs for CSS in Storybook](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/moz-toggle/moz-toggle.mjs). +[See moz-label.mjs for an example of handling `Services` in Storybook](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/moz-label/moz-label.mjs). + +### Autonomous or Customized built-in Custom Elements + +There are two types of custom elements, autonomous elements that extend `HTMLElement` and customized built-in elements that extend basic HTML elements. +If you use autonomous elements, you can use Shadow DOM and/or the Lit library. +[Lit does not support customized built-in custom elements](https://github.com/lit/lit-element/issues/879). + +In some cases, you may want to provide some functionality on top of a built-in HTML element, [like how `moz-support-link` prepares the `href` value for anchor elements](https://searchfox.org/mozilla-central/rev/3563da061ca2b32f7f77f5f68088dbf9b5332a9f/toolkit/content/widgets/moz-support-link/moz-support-link.mjs#83-89). +In other cases, you may want to focus on creating markup and reacting to changes on the element. +This is where Lit can be useful for declaritively defining the markup and reacting to changes when attributes are updated. + +### How will developers use your widget? + +What does the interface to your widget look like? +Do you expect developers to use reactive attributes or [slots](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_templates_and_slots#adding_flexibility_with_slots)? +If there are many ways to accomplish the same end result, this could result in future confusion and increase the maintainance cost. + +You should write stories for your widget to demonstrate how it can be used. +These stories can be used as guides for new use cases that may appear in the future. +This can also help draw the line for the responsibilities of your widget. + +## Adding new design system components + +We have a `./mach addwidget` scaffold command to make it easier to create new +reusable components and hook them up to Storybook. Currently this command can +only be used to add a new Lit based web component to `toolkit/content/widgets`. +In the future we may expand it to support options for creating components +without using Lit and for adding components to different directories. +See [Bug 1803677](https://bugzilla.mozilla.org/show_bug.cgi?id=1803677) for more details on these future use cases. + +To create a new component, you run: + +```sh +# Component names should be in kebab-case and contain at least 1 -. +./mach addwidget component-name +``` + +The scaffold command will generate the following files: + +```sh +└── toolkit + └── content + ├── tests + │ └── widgets + │ └── test_component_name.html # chrome test + └── widgets + └── component-name # new folder for component code + ├── component-name.css # component specific CSS + ├── component-name.mjs # Lit based component + └── component-name.stories.mjs # component stories +``` + +It will also make modifications to `toolkit/content/jar.mn` to add `chrome://` +URLs for the new files, and to `toolkit/content/tests/widgets/chrome.ini` to +enable running the newly added test. + +After running the scaffold command you can start Storybook and you will see +placeholder content that has been generated for your component. You can then +start altering the generated files and see your changes reflected in Storybook. + +### Known `browser_all_files_referenced.js` issue + +Unfortunately for now [the +browser_all_files_referenced.js test](https://searchfox.org/mozilla-central/source/browser/base/content/test/static/browser_all_files_referenced.js) +will fail unless your new component is immediately used somewhere outside +of Storybook. We have plans to fix this issue, [see Bug 1806002 for more details](https://bugzilla.mozilla.org/show_bug.cgi?id=1806002), but for now you can get around it +by updating [this array](https://searchfox.org/mozilla-central/rev/5c922d8b93b43c18bf65539bfc72a30f84989003/browser/base/content/test/static/browser_all_files_referenced.js#113) to include your new chrome filepath. + +### Using new design system components + +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 +`script` tag with `type="module"`: + +```html + +``` + +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). + +## Common pitfalls + +If you're trying to use a reusable widget but nothing is appearing on the +page it may be due to one of the following issues: + +- Omitting the `type="module"` in your `script` tag. +- Wrong file path for the `src` of your imported module. +- Widget is not declared or incorrectly declared in the correct `jar.mn` file. +- Not specifying the `html:` namespace when using a custom HTML element in an + `xhtml` file. For example the tag should look something like this: + + ```html + + ``` +- Adding a `script` tag to an `inc.xhtml` file. For example when using a new + component in the privacy section of `about:preferences` the `script` tag needs + to be added to `preferences.xhtml` rather than to `privacy.inc.xhtml`. +- Trying to extend a built-in HTML element in Lit. [Because Webkit never + implemented support for customized built-ins, Lit doesn't support it either.](https://github.com/lit/lit-element/issues/879#issuecomment-1061892879) + That means if you want to do something like: + + ```js + customElements.define("cool-button", CoolButton, { extends: "button" }); + ``` + + you will need to make a vanilla custom element, you cannot use Lit. + [For an example of extending an HTML element, see `moz-support-link`](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/moz-support-link/moz-support-link.mjs). diff --git a/browser/components/storybook/docs/README.storybook.stories.md b/browser/components/storybook/docs/README.storybook.stories.md new file mode 100644 index 0000000000..bb0fcdd1a2 --- /dev/null +++ b/browser/components/storybook/docs/README.storybook.stories.md @@ -0,0 +1,148 @@ +# Storybook for Firefox + +[Storybook](https://storybook.js.org/) is an interactive tool that creates a +playground for UI components. We use Storybook to document our design system, +reusable components, and any specific components you might want to test with +dummy data. [Take a look at our Storybook +instance!](https://firefoxux.github.io/firefox-desktop-components/?path=/story/docs-reusable-widgets--page) + +## Background + +Storybook lists components that can be reused, and helps document +what common elements we have. It can also list implementation specific +components, but they should be added to the "Domain-Specific UI Widgets" section. + +Changes to files directly referenced from Storybook (so basically non-chrome:// +paths) should automatically reflect changes in the opened browser. If you make a +change to a `chrome://` referenced file then you'll need to do a hard refresh +(Cmd+Shift+R/Ctrl+Shift+R) to notice the changes. If you're on Windows you may +need to `./mach build faster` to have the `chrome://` URL show the latest version. + +## Running Storybook + +Installing the npm dependencies and running the `storybook` npm script should be +enough to get Storybook running. This can be done via `./mach storybook` +commands, or with your personal npm/node that happens to be compatible. + +### Running with mach commands + +This is the recommended approach for installing dependencies and running +Storybook locally. + +To install dependencies, start the Storybook server, and launch the Storybook +site in a local build of Firefox, just run: + +```sh +# This uses npm ci under the hood to install the package-lock.json exactly. +./mach storybook +``` + +This single command will first install any missing dependencies then start the +local Storybook server. It will also start your local browser and point it to +`http://localhost:5703` while enabling certain preferences to ensure components +display as expected (specifically `svg.context-properties.content.enabled` and +`layout.css.light-dark.enabled`). + +It's necessary to use your local build to test in Storybook since `chrome://` +URLs are currently being pulled from the running browser, so any changes to +common-shared.css for example will come from your build. + +The Storybook server will continue running and will watch for component file +changes. + +#### Alternative mach commands + +Although running `./mach storybook` is the most convenient way to interact with +Storybook locally it is also possible to run separate commands to start the +Storybook server and run your local build with the necessary prefs. + +If you only want to start the Storybook server - for example in cases where you +already have a local build running - you can pass a `--no-open` flag to `./mach +storybook`: + +```sh +# Start the storybook server without launching a local Firefox build. +./mach storybook --no-open +``` + +If you just want to spin up a local build of Firefox with the required prefs +enabled you can use the `launch` subcommand: + +```sh +# In another terminal: +./mach storybook launch +``` + +This will run your local browser and point it at `http://localhost:5703`. + +Alternatively, you can simply navigate to `http://localhost:5703/` or run: + +```sh +# In another terminal: +./mach run http://localhost:5703/ +``` + +although with this option certain prefs won't be enabled, so what's displayed in +Storybook may not exactly reflect how components will look when used in Firefox. + +### Personal npm + +You can use your own `npm` to install and run Storybook. Compatibility is up +to you to sort out. + +```sh +cd browser/components/storybook +npm ci # Install the package-lock.json exactly so lockfileVersion won't change. +npm run storybook +``` + +## Updating Storybook dependencies + +On occasion you may need to update or add a npm dependency for Storybook. +This can be done using the version of `npm` packaged with `mach`: + +```sh +# Install a dev dependency from within the storybook directory. +cd browser/components/storybook && ../../../mach npm i -D your-package +``` + +## Adding new stories + +Storybook is currently configured to search for story files (any file with a +`.stories.(js|mjs|md)` extension) in `toolkit/content/widgets` and +`browser/components/storybook/stories`. + +Stories in `toolkit/content/widgets` are used to document design system +components, also known as UI widgets. +As long as you used `./mach addwidget` correctly, there is no additional setup needed to view your newly created story in Storybook. + +Stories in `browser/components/storybook/stories` are used for non-design system components, also called domain-specific UI widgets. +The easiest way to use Storybook for non-design system element is to use `./mach addstory new-component "Your Project"`. +You can also use `./mach addstory new-component "Your Project" --path browser/components/new-component.mjs` where `--path` is the path to your new components' source. +[See the Credential Management/Timeline widget for an example.](https://searchfox.org/mozilla-central/rev/2c11f18f89056a806c299a9d06bfa808718c2e84/browser/components/storybook/stories/login-timeline.stories.mjs#11) + +If you want to colocate your story with the code it is documenting you will need +to add to the `stories` array in the `.storybook/main.js` [configuration +file](https://searchfox.org/mozilla-central/source/browser/components/storybook/.storybook/main.js) +so that Storybook knows where to look for your files. + +The Storybook docs site has a [good +overview](https://storybook.js.org/docs/web-components/get-started/whats-a-story) +of what's involved in writing a new story. For convenience you can use the [Lit +library](https://lit.dev/) to define the template code for your story, but this +is not a requirement. + +### UI Widgets versus Domain-Specific UI Widgets + +Widgets that are part of [our design system](https://acorn.firefox.com/latest/acorn.html) and intended to be used across the Mozilla suite of products live under the "UI Widgets" category in Storybook and under `toolkit/content/widgets/` in Firefox. +These global widgets are denoted in code by the `moz-` prefix in their name. +For example, the name `moz-support-link` informs us that this widget is design system compliant and can be used anywhere in Firefox. + +Storybook can also be used to help document and prototype widgets that are specific to a part of the codebase and not intended for more global use. +Stories for these types of widgets live under the "Domain-Specific UI Widgets" category, while the code can live in any appropriate folder in `mozilla-central`. +[See the Credential Management folder as an example of a domain specific folder](https://firefoxux.github.io/firefox-desktop-components/?path=/docs/domain-specific-ui-widgets-credential-management-timeline--empty-timeline) and [see the login-timeline.stories.mjs for how to make a domain specific folder in Storybook](https://searchfox.org/mozilla-central/source/browser/components/storybook/stories/login-timeline.stories.mjs). +[To add a non-team specific widget to the "Domain-specific UI Widgets" section, see the migration-wizard.stories.mjs file](https://searchfox.org/mozilla-central/source/browser/components/storybook/stories/migration-wizard.stories.mjs). + +Creating and documenting domain specific UI widgets allows other teams to be aware of and take inspiration from existing UI patterns. +With these widgets, **there is no guarantee that the element will work for your domain.** +If you need to use a domain-specific widget outside of its intended domain, it may be worth discussing how to convert this domain specific widget into a global UI widget. diff --git a/browser/components/storybook/docs/README.typography.stories.md b/browser/components/storybook/docs/README.typography.stories.md new file mode 100644 index 0000000000..4b85d59ef8 --- /dev/null +++ b/browser/components/storybook/docs/README.typography.stories.md @@ -0,0 +1,389 @@ +# Typography +## Scale +[In-content pages and the browser chrome](https://acorn.firefox.com/latest/resources/browser-anatomy/desktop-ZaxCgqkt) follow different type scales due to the chrome relying on operating systems' font sizing, while in-content pages follow the type scale set by the design system. + +We set `font: message-box` at the root of `common-shared.css` and `global.css` stylesheets so that both in-content and the chrome can have access to operating system font families. + +We also don't specify line height units and rely on the default. + +### In-content + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameHTML class/tag or CSS tokenPreviewFont sizeFont weight
    Heading XLargeh1,
    .heading-xlarge
    + ```html story +

    The quick brown fox jumps over the lazy dog

    + ``` +
    + 1.6rem (24px) + + 600 +
    Heading Largeh2,
    .heading-large
    + ```html story +

    The quick brown fox jumps over the lazy dog

    + ``` +
    + 1.467rem (22px) + + 600 +
    Heading Mediumh3,
    .heading-medium
    + ```html story +

    The quick brown fox jumps over the lazy dog

    + ``` +
    + 1.133rem (17px) + + 600 +
    Root (body)--font-size-root set at the :root of common-shared.css + ```html story +

    The quick brown fox jumps over the lazy dog

    + ``` +
    + 15px (1rem) + + normal +
    Body Small--font-size-small + ```html story +

    The quick brown fox jumps over the lazy dog

    + ``` +
    + 0.867rem (13px) + + normal +
    + +### Chrome + +The chrome solely relies on `font` declarations (it also relies on `font: menu` for panels) so that it can inherit the operating system font family **and** sizing in order for it to feel like it is part of the user's operating system. Keep in mind that font sizes and families vary between macOS, Windows, and Linux. Moreover, you will only see a difference between `font: message-box` and `font: menu` font sizes on macOS. + +Note that there currently isn't a hierarchy of multiple headings on the chrome since every panel and modal that opens from it relies only on an `h1` for its title; so today, we just bold the existing fonts in order to create headings. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameClassPreviewFont keywordFont weight
    Menu Headingh1 + ```html story +

    The quick brown fox jumps over the lazy dog

    + ``` +
    + menu + + 600 +
    MenuApplied directly to panel classes in panel.css and panelUI-shared.css + ```html story +

    The quick brown fox jumps over the lazy dog

    + ``` +
    + menu + + normal +
    Headingh1 + ```html story +

    The quick brown fox jumps over the lazy dog

    + ``` +
    + message-box + + 600 +
    Root (body)message-box set at the :root of global.css + ```html story +

    The quick brown fox jumps over the lazy dog

    + ``` +
    + message-box + + normal +
    + +## Design tokens +Type setting relies on design tokens for font size and font weight. + +#### Font size + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Base tokenIn-content valueChrome value
    + --font-size-xxlarge + + 1.6rem + + unset +
    + --font-size-xlarge + + 1.467rem + + unset +
    + --font-size-large + + 1.133rem + + unset +
    + --font-size-root + + 15px + + unset +
    + --font-size-small + + 0.867rem + + unset +
    + + +#### Font weight + + + + + + + + + + + + + + + + + + + + +
    Base tokenIn-content valueChrome value
    + --font-weight-default + + normal + + normal +
    + --font-weight-bold + + 600 + + 600 +
    + +## Helpers +### text-and-typography.css + +The text and typography stylesheet found in `toolkit/themes/shared/design-system/text-and-typography.css` contains type setting declarations, and text and typography helper classes: + +- It applies the design system's type scale by default, therefore it styles the `root` and headings automatically. +- It comes with helper classes for contexts where designers may visually prefer an `h1` to start at the "medium" heading size instead of "large" (e.g. Shopping sidebar). It also contains text related helpers for truncating and deemphasizing text. + +You should rely on typography helper classes and the defaults set by the design system. + +This file is imported into `common-shared.css` and `global-shared.css` so that both in-content pages and the chrome receive their respective typography scale treatments, and have access to helper classes. + +#### Heading +##### XLarge (h1) +###### In-content +```html story +

    Firefox View

    +``` + +###### Chrome +```html story +

    Close window and quit Firefox?

    +``` + +###### Chrome menus +```html story +

    Edit bookmark

    +``` + +```css story +h1, +.heading-xlarge { + font-weight: var(--font-weight-bold); + font-size: var(--font-size-xxlarge); +} +``` + +*Reminder: There's no hierarchy of headings on the chrome. So here's just in-content's preview:* + +##### Large (h2) +```html story +

    Recent browsing

    +``` + +```css story +h2, +.heading-large { + font-weight: var(--font-weight-bold); + font-size: var(--font-size-xlarge); +} +``` + +##### Medium (h3) +```html story +

    Tabs from other devices

    +``` + +```css story +h3, +.heading-medium { + font-weight: var(--font-weight-bold); + font-size: var(--font-size-large); +} +``` + +#### Text +##### De-emphasized + +```html story + Get your passwords on your other devices. +``` + +```css story +.text-deemphasized { + font-size: var(--font-size-small); + color: var(--text-color-deemphasized); +} +``` + +##### Truncated ellipsis + +```html story +
    A really long piece of text a really long piece of text a really long piece of text a really long piece of text a really long piece of text a really long piece of text.
    +``` + +```css story +.text-truncated-ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +``` + +`.text-truncated-ellipsis` can be applied to `display: block` or `display: inline-block` elements. + +For `display: flex` or `display: grid` elements, you'll need to wrap its contents with an element with the `.text-truncated-ellipsis` class instead. + +Example: + +```html +
    + A really long string of text that needs truncation. +
    +``` diff --git a/browser/components/storybook/docs/README.xul-and-html.stories.md b/browser/components/storybook/docs/README.xul-and-html.stories.md new file mode 100644 index 0000000000..8d2d4cb7bf --- /dev/null +++ b/browser/components/storybook/docs/README.xul-and-html.stories.md @@ -0,0 +1,67 @@ +# XUL and HTML + +This document gives a quick overview of XUL and HTML, especially as it pertains to desktop front-end developers. +As we migrate away from XUL elements to HTML elements where possible, it is important to note the differences between these two technologies. +Additionally it is helpful to know how to use both where needed, as some elements will still need to use XUL. + +## What is XUL + +XUL is an XML-based language for building cross-platform user interfaces and applications, so all the features of XML are available in XUL as well. +This is in contrast to HTML which is intended for developing web pages. +Because of this XUL is oriented towards application artifacts such as windows, scrollbars, and menus instead of pages, headings, links, etc. +These XUL elements are provided to an HTML page without the page having any control over them. + +XUL was and is used to create various UI elements, for example: +- Input controls such as textboxes and checkboxes +- Toolbars with buttons or other content +- Menus on a menu bar or pop up menus +- Tabbed dialogs +- Trees for hierarchical or tabular information + +XUL is a Mozilla-specific technology with many similarities but also many differences to HTML. +These include a different box model (although it is now synthesized in the HTML box model) and the ability to be backed by C++ code. + +## What requires XUL + +While many of the existing XUL elements that make up the browser can be migrated to HTML, there are some elements that require XUL. +These elements tend to be fundamental to the browser such as windows, popups, panels, etc. +Elements that need to emulate OS-specific styling also tend to be XUL elements. +While there are parts of these elements that must be XUL, that does not mean that the component must be entirely implmented in XUL. +For example, we require that a `panel` can be drawn outside of a window's bounds, but then we can have HTML inside of that `panel` element. + +The following is not an exhaustive list of elements that require XUL: +- Browser Window + - https://searchfox.org/mozilla-central/source/xpfe/appshell/nsIXULBrowserWindow.idl +- Popups + - https://searchfox.org/mozilla-central/source/dom/webidl/XULPopupElement.webidl + - https://searchfox.org/mozilla-central/source/layout/xul/nsMenuPopupFrame.cpp + - https://searchfox.org/mozilla-central/source/toolkit/content/widgets/autocomplete-popup.js + - https://searchfox.org/mozilla-central/source/toolkit/content/widgets/menupopup.js +- Panels + - https://searchfox.org/mozilla-central/source/toolkit/content/widgets/panel.js + +## When to use HTML or XUL + +Now that HTML is powerful enough for us to create almost an entire application with it, the need for the features of XUL has diminished. +We now prefer to write HTML components over XUL components since that model is better understood by the web and front-end community. +This also allows us to gain new features of the web in the UI that we write without backporting them to XUL. + +There are some cases where XUL may still be required for non-standard functionality. +Some XUL elements have more functions over similar HTML elements, such as the XUL `` element compared to the HTML `", + expected: { + urlbar: "data:text/html,", + autocomplete: "data:text/html,", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "data:,123\n4 5\n6", + expected: { + urlbar: "data:,123 4 5 6", + autocomplete: "data:,123 4 5 6", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "data:text/html;base64,123\n4 5\n6", + expected: { + urlbar: "data:text/html;base64,1234 56", + autocomplete: "data:text/html;base64,123456", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "http://example.com\n", + expected: { + urlbar: "http://example.com", + autocomplete: "http://example.com/", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "http://example.com\r", + expected: { + urlbar: "http://example.com", + autocomplete: "http://example.com/", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "http://ex\ra\nmp\r\nle.com\r\n", + expected: { + urlbar: "http://example.com", + autocomplete: "http://example.com/", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "http://example.com/titled", + expected: { + urlbar: "http://example.com/titled", + autocomplete: "example title", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "127.0.0.1\r", + expected: { + urlbar: "127.0.0.1", + autocomplete: "http://127.0.0.1/", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "\r\n\r\n\r\n\r\n\r\n", + expected: { + urlbar: "", + autocomplete: "", + type: UrlbarUtils.RESULT_TYPE.SEARCH, + }, + }, +]; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // There are cases that URLBar loses focus before assertion of this test. + // In that case, this test will be failed since the result is closed + // before it. We use this pref so that keep the result even if lose focus. + ["ui.popup.disable_autohide", true], + ], + }); + + await PlacesUtils.history.clear(); + await PlacesTestUtils.addVisits({ + uri: "http://example.com/titled", + title: "example title", + }); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + SpecialPowers.clipboardCopyString(""); + }); +}); + +add_task(async function test_paste_onto_urlbar() { + for (const { input, expected } of TEST_DATA) { + gURLBar.value = ""; + gURLBar.focus(); + + await paste(input); + await assertResult(expected); + + await UrlbarTestUtils.promisePopupClose(window); + } +}); + +add_task(async function test_paste_after_opening_autocomplete_panel() { + for (const { input, expected } of TEST_DATA) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + + await paste(input); + await assertResult(expected); + + await UrlbarTestUtils.promisePopupClose(window); + } +}); + +async function assertResult(expected) { + Assert.equal(gURLBar.value, expected.urlbar, "Pasted value is correct"); + + const result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.title, + expected.autocomplete, + "Title of autocomplete is correct" + ); + Assert.equal(result.type, expected.type, "Type of autocomplete is correct"); + + if (gURLBar.value) { + Assert.ok(gURLBar.hasAttribute("usertyping")); + Assert.ok(BrowserTestUtils.isVisible(gURLBar.goButton)); + } else { + Assert.ok(!gURLBar.hasAttribute("usertyping")); + Assert.ok(BrowserTestUtils.isHidden(gURLBar.goButton)); + } +} + +async function paste(input) { + await SimpleTest.promiseClipboardChange(input.replace(/\r\n?/g, "\n"), () => { + clipboardHelper.copyString(input); + }); + + document.commandDispatcher + .getControllerForCommand("cmd_paste") + .doCommand("cmd_paste"); +} diff --git a/browser/components/urlbar/tests/browser/browser_paste_then_focus.js b/browser/components/urlbar/tests/browser/browser_paste_then_focus.js new file mode 100644 index 0000000000..23d603fd80 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_paste_then_focus.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the urlbar value when focusing after pasting value. + +const TEST_DATA = [ + { + input: "this is a\ntest", + expected: "this is a test", + }, + { + input: "http:\n//\nexample.\ncom", + expected: "http://example.com", + }, + { + input: "javasc\nript:\nalert(1)", + expected: "alert(1)", + }, + { + input: "javascript:alert(1)", + expected: "alert(1)", + }, + { + input: "test", + expected: "test", + }, +]; + +add_task(async function test_paste_then_focus() { + for (const testData of TEST_DATA) { + gURLBar.value = ""; + gURLBar.focus(); + + EventUtils.synthesizeKey("x"); + gURLBar.select(); + + await paste(testData.input); + + gURLBar.blur(); + gURLBar.focus(); + + Assert.equal( + gURLBar.value, + testData.expected, + "Value on urlbar is correct" + ); + } +}); + +async function paste(input) { + await SimpleTest.promiseClipboardChange(input, () => { + clipboardHelper.copyString(input); + }); + + document.commandDispatcher + .getControllerForCommand("cmd_paste") + .doCommand("cmd_paste"); +} diff --git a/browser/components/urlbar/tests/browser/browser_paste_then_switch_tab.js b/browser/components/urlbar/tests/browser/browser_paste_then_switch_tab.js new file mode 100644 index 0000000000..09d94f79e7 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_paste_then_switch_tab.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the urlbar value when switching tab after pasting value. + +const TEST_DATA = [ + { + input: "this is a\ntest", + expected: "this is a test", + }, + { + input: "https:\n//\nexample.\ncom", + expected: UrlbarTestUtils.trimURL("https://example.com"), + }, + { + input: "http:\n//\nexample.\ncom", + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + expected: UrlbarTestUtils.trimURL("http://example.com"), + }, + { + input: "javasc\nript:\nalert(1)", + expected: "alert(1)", + }, + { + input: "javascript:alert(1)", + expected: "alert(1)", + }, + { + // Has U+3000 IDEOGRAPHIC SPACE. + input: "Mozilla Firefox", + expected: "Mozilla Firefox", + }, + { + input: "test", + expected: "test", + }, +]; + +add_task(async function test_paste_then_switch_tab() { + for (const testData of TEST_DATA) { + gURLBar.focus(); + gURLBar.select(); + + await paste(testData.input); + + // Switch to a new tab. + const originalTab = gBrowser.selectedTab; + const newTab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await BrowserTestUtils.waitForCondition(() => !gURLBar.value); + + // Switch back to original tab. + gBrowser.selectedTab = originalTab; + + Assert.equal( + gURLBar.value, + testData.expected, + "Value on urlbar is correct" + ); + + BrowserTestUtils.removeTab(newTab); + } +}); + +async function paste(input) { + await SimpleTest.promiseClipboardChange(input, () => { + clipboardHelper.copyString(input); + }); + + document.commandDispatcher + .getControllerForCommand("cmd_paste") + .doCommand("cmd_paste"); +} diff --git a/browser/components/urlbar/tests/browser/browser_percent_encoded.js b/browser/components/urlbar/tests/browser/browser_percent_encoded.js new file mode 100644 index 0000000000..c334c03a09 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_percent_encoded.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that searching history works for both encoded or decoded strings. + +add_task(async function test() { + const decoded = "日本"; + const TEST_URL = TEST_BASE_URL + "?" + decoded; + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); + + // Visit url in a new tab, going through normal urlbar workflow. + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser); + let promise = PlacesTestUtils.waitForNotification("page-visited", visits => { + Assert.equal( + visits.length, + 1, + "Was notified for the right number of visits." + ); + let { url, transitionType } = visits[0]; + return ( + url == encodeURI(TEST_URL) && + transitionType == PlacesUtils.history.TRANSITIONS.TYPED + ); + }); + gURLBar.focus(); + gURLBar.value = TEST_URL; + info("Visiting url"); + EventUtils.synthesizeKey("KEY_Enter"); + await promise; + gBrowser.removeCurrentTab({ skipPermitUnload: true }); + + info("Search for the decoded string."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: decoded, + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "Check number of results" + ); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, encodeURI(TEST_URL), "Check result url"); + + info("Search for the encoded string."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: encodeURIComponent(decoded), + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "Check number of results" + ); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, encodeURI(TEST_URL), "Check result url"); +}); diff --git a/browser/components/urlbar/tests/browser/browser_placeholder.js b/browser/components/urlbar/tests/browser/browser_placeholder.js new file mode 100644 index 0000000000..e096c6fdf6 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_placeholder.js @@ -0,0 +1,412 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test ensures the placeholder is set correctly for different search + * engines. + */ + +"use strict"; + +var originalEngine, extraEngine, extraPrivateEngine, expectedString; +var tabs = []; + +var noEngineString; + +add_setup(async function () { + originalEngine = await Services.search.getDefault(); + [noEngineString, expectedString] = ( + await document.l10n.formatMessages([ + { id: "urlbar-placeholder" }, + { + id: "urlbar-placeholder-with-name", + args: { name: originalEngine.name }, + }, + ]) + ).map(msg => msg.attributes[0].value); + + let rootUrl = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://mochi.test:8888/" + ); + await SearchTestUtils.installSearchExtension({ + name: "extraEngine", + search_url: "https://mochi.test:8888/", + suggest_url: `${rootUrl}/searchSuggestionEngine.sjs`, + }); + extraEngine = Services.search.getEngineByName("extraEngine"); + await SearchTestUtils.installSearchExtension({ + name: "extraPrivateEngine", + search_url: "https://mochi.test:8888/", + suggest_url: `${rootUrl}/searchSuggestionEngine.sjs`, + }); + extraPrivateEngine = Services.search.getEngineByName("extraPrivateEngine"); + + // Force display of a tab with a URL bar, to clear out any possible placeholder + // initialization listeners that happen on startup. + let urlTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + BrowserTestUtils.removeTab(urlTab); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.ui.enabled", true], + ["browser.search.separatePrivateDefault", false], + ["browser.urlbar.suggest.quickactions", false], + ], + }); + + registerCleanupFunction(async () => { + await Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } + }); +}); + +add_task(async function test_change_default_engine_updates_placeholder() { + tabs.push(await BrowserTestUtils.openNewForegroundTab(gBrowser)); + + await Services.search.setDefault( + extraEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + await TestUtils.waitForCondition( + () => gURLBar.placeholder == noEngineString, + "The placeholder should match the default placeholder for non-built-in engines." + ); + Assert.equal(gURLBar.placeholder, noEngineString); + + await Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + await TestUtils.waitForCondition( + () => gURLBar.placeholder == expectedString, + "The placeholder should include the engine name for built-in engines." + ); + Assert.equal(gURLBar.placeholder, expectedString); +}); + +add_task(async function test_delayed_update_placeholder() { + // We remove the change of engine listener here as that is set so that + // if the engine is changed by the user then the placeholder is always updated + // straight away. As we want to test the delay update here, we remove the + // listener and call the placeholder update manually with the delay flag. + Services.obs.removeObserver(BrowserSearch, "browser-search-engine-modified"); + + // Since we can't easily test for startup changes, we'll at least test the delay + // of update for the placeholder works. + let urlTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + tabs.push(urlTab); + + // Open a tab with a blank URL bar. + let blankTab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + tabs.push(blankTab); + + await Services.search.setDefault( + extraEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + // Pretend we've "initialized". + BrowserSearch._updateURLBarPlaceholder(extraEngine.name, false, true); + + Assert.equal( + gURLBar.placeholder, + expectedString, + "Placeholder should be unchanged." + ); + + // Now switch to a tab with something in the URL Bar. + await BrowserTestUtils.switchTab(gBrowser, urlTab); + + await TestUtils.waitForCondition( + () => gURLBar.placeholder == noEngineString, + "The placeholder should have updated in the background." + ); + + // Do it the other way to check both named engine and fallback code paths. + await BrowserTestUtils.switchTab(gBrowser, blankTab); + + await Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + BrowserSearch._updateURLBarPlaceholder(originalEngine.name, false, true); + + Assert.equal( + gURLBar.placeholder, + noEngineString, + "Placeholder should be unchanged." + ); + Assert.deepEqual( + document.l10n.getAttributes(gURLBar.inputField), + { id: "urlbar-placeholder", args: null }, + "Placeholder data should be unchanged." + ); + + await BrowserTestUtils.switchTab(gBrowser, urlTab); + + await TestUtils.waitForCondition( + () => gURLBar.placeholder == expectedString, + "The placeholder should include the engine name for built-in engines." + ); + + // Now check when we have a URL displayed, the placeholder is updated straight away. + BrowserSearch._updateURLBarPlaceholder(extraEngine.name, false); + + await TestUtils.waitForCondition( + () => gURLBar.placeholder == noEngineString, + "The placeholder should go back to the default" + ); + Assert.equal( + gURLBar.placeholder, + noEngineString, + "Placeholder should be the default." + ); + + Services.obs.addObserver(BrowserSearch, "browser-search-engine-modified"); +}); + +add_task(async function test_private_window_no_separate_engine() { + const win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + await Services.search.setDefault( + extraEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + await TestUtils.waitForCondition( + () => win.gURLBar.placeholder == noEngineString, + "The placeholder should match the default placeholder for non-built-in engines." + ); + Assert.equal(win.gURLBar.placeholder, noEngineString); + + await Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + await TestUtils.waitForCondition( + () => win.gURLBar.placeholder == expectedString, + "The placeholder should include the engine name for built-in engines." + ); + Assert.equal(win.gURLBar.placeholder, expectedString); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_private_window_separate_engine() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.separatePrivateDefault", true]], + }); + const originalPrivateEngine = await Services.search.getDefaultPrivate(); + registerCleanupFunction(async () => { + await Services.search.setDefaultPrivate( + originalPrivateEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + }); + + const win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + // Keep the normal default as a different string to the private, so that we + // can be sure we're testing the right thing. + await Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.setDefaultPrivate( + extraPrivateEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + await TestUtils.waitForCondition( + () => win.gURLBar.placeholder == noEngineString, + "The placeholder should match the default placeholder for non-built-in engines." + ); + Assert.equal(win.gURLBar.placeholder, noEngineString); + + await Services.search.setDefault( + extraEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.setDefaultPrivate( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + await TestUtils.waitForCondition( + () => win.gURLBar.placeholder == expectedString, + "The placeholder should include the engine name for built-in engines." + ); + Assert.equal(win.gURLBar.placeholder, expectedString); + + await BrowserTestUtils.closeWindow(win); + + // Verify that the placeholder for private windows is updated even when no + // private window is visible (https://bugzilla.mozilla.org/1792816). + await Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.setDefaultPrivate( + extraPrivateEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + const win2 = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + Assert.equal(win2.gURLBar.placeholder, noEngineString); + await BrowserTestUtils.closeWindow(win2); + + // And ensure this doesn't affect the placeholder for non private windows. + tabs.push(await BrowserTestUtils.openNewForegroundTab(gBrowser)); + Assert.equal(win.gURLBar.placeholder, expectedString); +}); + +add_task(async function test_search_mode_engine_web() { + // Add our test engine to WEB_ENGINE_NAMES so that it's recognized as a web + // engine. + SearchUtils.GENERAL_SEARCH_ENGINE_IDS.add( + extraEngine.wrappedJSObject._extensionID + ); + + await doSearchModeTest( + { + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + engineName: extraEngine.name, + }, + { + id: "urlbar-placeholder-search-mode-web-2", + args: { name: extraEngine.name }, + } + ); + + SearchUtils.GENERAL_SEARCH_ENGINE_IDS.delete( + extraEngine.wrappedJSObject._extensionID + ); +}); + +add_task(async function test_search_mode_engine_other() { + await doSearchModeTest( + { engineName: extraEngine.name }, + { + id: "urlbar-placeholder-search-mode-other-engine", + args: { name: extraEngine.name }, + } + ); +}); + +add_task(async function test_search_mode_bookmarks() { + await doSearchModeTest( + { source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS }, + { id: "urlbar-placeholder-search-mode-other-bookmarks", args: null } + ); +}); + +add_task(async function test_search_mode_tabs() { + await doSearchModeTest( + { source: UrlbarUtils.RESULT_SOURCE.TABS }, + { id: "urlbar-placeholder-search-mode-other-tabs", args: null } + ); +}); + +add_task(async function test_search_mode_history() { + await doSearchModeTest( + { source: UrlbarUtils.RESULT_SOURCE.HISTORY }, + { id: "urlbar-placeholder-search-mode-other-history", args: null } + ); +}); + +add_task(async function test_change_default_engine_updates_placeholder() { + tabs.push(await BrowserTestUtils.openNewForegroundTab(gBrowser)); + + info(`Set engine to ${extraEngine.name}`); + await Services.search.setDefault( + extraEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await TestUtils.waitForCondition( + () => gURLBar.placeholder == noEngineString, + "The placeholder should match the default placeholder for non-built-in engines." + ); + Assert.equal(gURLBar.placeholder, noEngineString); + + info(`Set engine to ${originalEngine.name}`); + await Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await TestUtils.waitForCondition( + () => gURLBar.placeholder == expectedString, + "The placeholder should include the engine name for built-in engines." + ); + + // Simulate the placeholder not having changed due to the delayed update + // on startup. + BrowserSearch._setURLBarPlaceholder(""); + await TestUtils.waitForCondition( + () => gURLBar.placeholder == noEngineString, + "The placeholder should have been reset." + ); + + info("Show search engine removal info bar"); + await BrowserSearch.removalOfSearchEngineNotificationBox( + extraEngine.name, + originalEngine.name + ); + const notificationBox = gNotificationBox.getNotificationWithValue( + "search-engine-removal" + ); + Assert.ok(notificationBox, "Search engine removal should be shown."); + + await TestUtils.waitForCondition( + () => gURLBar.placeholder == expectedString, + "The placeholder should include the engine name for built-in engines." + ); + + Assert.equal(gURLBar.placeholder, expectedString); + + notificationBox.close(); +}); + +/** + * Opens the view, clicks a one-off button to enter search mode, and asserts + * that the placeholder is corrrect. + * + * @param {object} expectedSearchMode + * The expected search mode object for the one-off. + * @param {object} expectedPlaceholderL10n + * The expected l10n object for the one-off. + */ +async function doSearchModeTest(expectedSearchMode, expectedPlaceholderL10n) { + // Click the urlbar to open the top-sites view. + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + + // Enter search mode. + await UrlbarTestUtils.enterSearchMode(window, expectedSearchMode); + + // Check the placeholder. + Assert.deepEqual( + document.l10n.getAttributes(gURLBar.inputField), + expectedPlaceholderL10n, + "Placeholder has expected l10n" + ); + + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); +} diff --git a/browser/components/urlbar/tests/browser/browser_populateAfterPushState.js b/browser/components/urlbar/tests/browser/browser_populateAfterPushState.js new file mode 100644 index 0000000000..96c43326a9 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_populateAfterPushState.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* When a user clears the URL bar, and then the page pushes state, we should + * re-fill the URL bar so it doesn't remain empty indefinitely. See bug 1441039. + * For normal loads, this happens automatically because a non-same-document state + * change takes place. + */ +add_task(async function () { + await BrowserTestUtils.withNewTab( + TEST_BASE_URL + "dummy_page.html", + async function (browser) { + gURLBar.value = ""; + + let locationChangePromise = BrowserTestUtils.waitForLocationChange( + gBrowser, + TEST_BASE_URL + "dummy_page2.html" + ); + await SpecialPowers.spawn(browser, [], function () { + content.history.pushState({}, "Page 2", "dummy_page2.html"); + }); + await locationChangePromise; + is( + gURLBar.value, + UrlbarTestUtils.trimURL(TEST_BASE_URL + "dummy_page2.html"), + "Should have updated the URL bar." + ); + } + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_primary_selection_safe_on_new_tab.js b/browser/components/urlbar/tests/browser/browser_primary_selection_safe_on_new_tab.js new file mode 100644 index 0000000000..2f8e871bfe --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_primary_selection_safe_on_new_tab.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Verify that the primary selection is unaffected by opening a new tab. + * + * The steps here follow STR for regression + * https://bugzilla.mozilla.org/show_bug.cgi?id=1457355. + */ + +"use strict"; + +let tabs = []; +let supportsPrimary = Services.clipboard.isClipboardTypeSupported( + Services.clipboard.kSelectionClipboard +); +const NON_EMPTY_URL = "data:text/html,Hello"; +const TEXT_FOR_PRIMARY = "Text for PRIMARY selection"; + +add_task(async function () { + tabs.push( + await BrowserTestUtils.openNewForegroundTab(gBrowser, NON_EMPTY_URL) + ); + + // Bug 1457355 reproduced only when the url had a non-empty selection. + gURLBar.select(); + Assert.equal(gURLBar.selectionStart, 0); + Assert.equal(gURLBar.selectionEnd, gURLBar.value.length); + + if (supportsPrimary) { + clipboardHelper.copyStringToClipboard( + TEXT_FOR_PRIMARY, + Services.clipboard.kSelectionClipboard + ); + } + + tabs.push( + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: () => { + // Simulate tab open from user input such as keyboard shortcut or new + // tab button. + let userInput = window.windowUtils.setHandlingUserInput(true); + try { + BrowserOpenTab(); + } finally { + userInput.destruct(); + } + }, + waitForLoad: false, + }) + ); + + if (!supportsPrimary) { + info("Primary selection not supported. Skipping assertion."); + return; + } + + let primaryAsText = SpecialPowers.getClipboardData( + "text/plain", + SpecialPowers.Ci.nsIClipboard.kSelectionClipboard + ); + Assert.equal(primaryAsText, TEXT_FOR_PRIMARY); +}); + +registerCleanupFunction(() => { + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } +}); diff --git a/browser/components/urlbar/tests/browser/browser_privateBrowsingWindowChange.js b/browser/components/urlbar/tests/browser/browser_privateBrowsingWindowChange.js new file mode 100644 index 0000000000..eeeda93687 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_privateBrowsingWindowChange.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test that when opening a private browsing window and typing in it before + * about:privatebrowsing loads, we don't clear the URL bar. + */ +add_task(async function () { + let urlbarTestValue = "Mary had a little lamb"; + let win = OpenBrowserWindow({ private: true }); + registerCleanupFunction(() => BrowserTestUtils.closeWindow(win)); + await BrowserTestUtils.waitForEvent(win, "load"); + let promise = new Promise(resolve => { + let wpl = { + onLocationChange(aWebProgress, aRequest, aLocation) { + if (aLocation && aLocation.spec == "about:privatebrowsing") { + win.gBrowser.removeProgressListener(wpl); + resolve(); + } + }, + }; + win.gBrowser.addProgressListener(wpl); + }); + Assert.notEqual( + win.gBrowser.selectedBrowser.currentURI.spec, + "about:privatebrowsing", + "Check privatebrowsing page has not been loaded yet" + ); + info("Search in urlbar"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: urlbarTestValue, + fireInputEvent: true, + }); + info("waiting for about:privatebrowsing load"); + await promise; + + let urlbar = win.gURLBar; + is( + urlbar.value, + urlbarTestValue, + "URL bar value should be the same once about:privatebrowsing has loaded" + ); + is( + win.gBrowser.selectedBrowser.userTypedValue, + urlbarTestValue, + "User typed value should be the same once about:privatebrowsing has loaded" + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_queryContextCache.js b/browser/components/urlbar/tests/browser/browser_queryContextCache.js new file mode 100644 index 0000000000..88409e253d --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_queryContextCache.js @@ -0,0 +1,490 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests the view's QueryContextCache. When the view opens and a context is +// cached for the search, the view should *synchronously* open and update. + +"use strict"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + UrlbarProviderTopSites: "resource:///modules/UrlbarProviderTopSites.sys.mjs", +}); + +const TEST_URLS = []; +const TEST_URLS_COUNT = 5; +const TOP_SITES_VISIT_COUNT = 5; +const SEARCH_STRING = "example"; + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + // Clear history and bookmarks to make sure the URLs we add below are truly + // the top sites. If any existing history or bookmarks were the top sites, + // which is likely but not guaranteed, one or more "newtab-top-sites-changed" + // notifications will be sent, potentially interfering with the rest of the + // test. Waiting for Places updates to finish and then an extra tick should be + // enough to make sure no more notfications occur. + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesTestUtils.promiseAsyncUpdates(); + await TestUtils.waitForTick(); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); + + // Add some URLs to populate both history and top sites. Each URL needs to + // match `SEARCH_STRING`. + for (let i = 0; i < TEST_URLS_COUNT; i++) { + let url = `https://${i}.example.com/${SEARCH_STRING}`; + TEST_URLS.unshift(url); + // Each URL needs to be added several times to boost its frecency enough to + // qualify as a top site. + for (let j = 0; j < TOP_SITES_VISIT_COUNT; j++) { + await PlacesTestUtils.addVisits(url); + } + } + await updateTopSitesAndAwaitChanged(TEST_URLS_COUNT); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function search() { + await withNewBrowserWindow(async win => { + // Do a search and then close the view. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: SEARCH_STRING, + }); + await UrlbarTestUtils.promisePopupClose(win); + + // Open the view. It should open synchronously and the cached search context + // should be used. + await openViewAndAssertCached({ + win, + searchString: SEARCH_STRING, + cached: true, + }); + }); +}); + +add_task(async function topSites_simple() { + await withNewBrowserWindow(async win => { + // Open the view to show top sites and then close it. + await openViewAndAssertCached({ win, cached: false }); + + // Open the view again. It should open synchronously and the cached + // top-sites context should be used. + await openViewAndAssertCached({ win, cached: true }); + }); +}); + +add_task(async function topSites_nonEmptySearch() { + await withNewBrowserWindow(async win => { + // Open the view to show top sites and then close it. + await openViewAndAssertCached({ win, cached: false }); + + // Do a search, close the view, and revert the input. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "test", + }); + await UrlbarTestUtils.promisePopupClose(win); + win.gURLBar.handleRevert(); + + // Open the view. It should open synchronously and the cached top-sites + // context should be used. + await openViewAndAssertCached({ win, cached: true }); + }); +}); + +add_task(async function topSites_otherEmptySearch() { + await withNewBrowserWindow(async win => { + // Open the view to show top sites and then close it. + await openViewAndAssertCached({ win, cached: false }); + + // Enter search mode with an empty search string (by pressing accel+K), + // starting a new search. The view should *not* open synchronously and the + // cached top-sites context should not be used. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(win); + EventUtils.synthesizeKey("k", { accelKey: true }, win); + Assert.ok(!win.gURLBar.view.isOpen, "View is not open"); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(win, { + engineName: Services.search.defaultEngine.name, + isGeneralPurposeEngine: true, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + isPreview: false, + entry: "shortcut", + }); + + // Close the view and revert the input. + await UrlbarTestUtils.promisePopupClose(win); + win.gURLBar.handleRevert(); + await UrlbarTestUtils.assertSearchMode(win, null); + + // Open the view. It should open synchronously and the cached top-sites + // context should be used. + await openViewAndAssertCached({ win, cached: true }); + }); +}); + +add_task(async function topSites_changed() { + await withNewBrowserWindow(async win => { + // Open the view to show top sites and then close it. + await openViewAndAssertCached({ win, cached: false }); + + // Change the top sites by adding visits to a new URL. + let newURL = "https://changed.example.com/"; + for (let j = 0; j < TOP_SITES_VISIT_COUNT; j++) { + await PlacesTestUtils.addVisits(newURL); + } + await updateTopSitesAndAwaitChanged(TEST_URLS_COUNT + 1); + + // Open the view. It should *not* open synchronously and the cached + // top-sites context should not be used. + await openViewAndAssertCached({ win, cached: false }); + + // Open the view again. It should open synchronously and the new cached + // top-sites context with the new URL should be used. + await openViewAndAssertCached({ + win, + cached: true, + urls: [newURL, ...TEST_URLS], + // The new URL is sometimes at the end of the list of top sites instead of + // the start, so ignore the order of the results. + ignoreOrder: true, + }); + + // Remove the new URL. The top sites will update themselves automatically, + // so we only need to wait for newtab-top-sites-changed. + info("Removing new URL and awaiting newtab-top-sites-changed"); + let changedPromise = TestUtils.topicObserved("newtab-top-sites-changed"); + await PlacesUtils.history.remove([newURL]); + await changedPromise; + + // Open the view. It should *not* open synchronously and the cached + // top-sites context should not be used. + await openViewAndAssertCached({ win, cached: false }); + + // Open the view again. It should open synchronously and the new cached + // top-sites context with the new URL should be used. + await openViewAndAssertCached({ win, cached: true }); + }); +}); + +add_task(async function topSites_nonTopSitesResults() { + await withNewBrowserWindow(async win => { + // Open the view to show top sites and then close it. + await openViewAndAssertCached({ win, cached: false }); + + // Add a provider that returns a result with a suggested index of zero so + // that the first result in the view is not from the top-sites provider. + let suggestedIndexURL = "https://example.com/suggested-index-0"; + let provider = new UrlbarTestUtils.TestProvider({ + priority: lazy.UrlbarProviderTopSites.PRIORITY, + results: [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + url: suggestedIndexURL, + } + ), + { suggestedIndex: 0 } + ), + ], + }); + UrlbarProvidersManager.registerProvider(provider); + + // Open the view. It should open synchronously and the cached top-sites + // context should be used. The suggested-index result should not be + // immediately present in the view since it's not in the cached context. + await openViewAndAssertCached({ win, cached: true, keepOpen: true }); + + // After the search has finished, the suggested-index result should be in + // the first row. The search's context should become the newly cached + // top-sites context and it should include the suggested-index result. + Assert.equal( + UrlbarTestUtils.getResultCount(win), + TEST_URLS.length + 1, + "Should be one more result after search finishes" + ); + let details = await UrlbarTestUtils.getDetailsOfResultAt(win, 0); + Assert.equal( + details.url, + suggestedIndexURL, + "First result after search finishes should be the suggested index result" + ); + + // At this point, the search's context should have become the newly cached + // top-sites context and it should include the suggested-index result. + + await UrlbarTestUtils.promisePopupClose(win); + + // Open the view again. It should open synchronously and the new cached + // top-sites context with the suggested-index URL should be used. + await openViewAndAssertCached({ + win, + cached: true, + urls: [suggestedIndexURL, ...TEST_URLS], + }); + + UrlbarProvidersManager.unregisterProvider(provider); + }); +}); + +add_task(async function topSites_disabled_1() { + await withNewBrowserWindow(async win => { + // Open the view to show top sites and then close it. + await openViewAndAssertCached({ win, cached: false }); + + // Disable `browser.urlbar.suggest.topsites`. + UrlbarPrefs.set("suggest.topsites", false); + + // Open the view. It should *not* open synchronously and the cached + // top-sites context should not be used. + await openViewAndAssertCached({ + win, + cached: false, + cachedAfterOpen: false, + }); + + // Clear the pref, open the view to show top sites, and close it. + UrlbarPrefs.clear("suggest.topsites"); + await openViewAndAssertCached({ win, cached: false }); + + // Open the view. It should open synchronously and the cached top-sites + // context should be used. + await openViewAndAssertCached({ win, cached: true }); + }); +}); + +add_task(async function topSites_disabled_2() { + await withNewBrowserWindow(async win => { + // Open the view to show top sites and then close it. + await openViewAndAssertCached({ win, cached: false }); + + // Disable `browser.newtabpage.activity-stream.feeds.system.topsites`. + Services.prefs.setBoolPref( + "browser.newtabpage.activity-stream.feeds.system.topsites", + false + ); + + // Open the view. It should *not* open synchronously and the cached + // top-sites context should not be used. + await openViewAndAssertCached({ + win, + cached: false, + cachedAfterOpen: false, + }); + + // Clear the pref, open the view to show top sites, and close it. + Services.prefs.clearUserPref( + "browser.newtabpage.activity-stream.feeds.system.topsites" + ); + await openViewAndAssertCached({ win, cached: false }); + + // Open the view. It should open synchronously and the cached top-sites + // context should be used. + await openViewAndAssertCached({ win, cached: true }); + }); +}); + +add_task(async function evict() { + await withNewBrowserWindow(async win => { + let cache = win.gURLBar.view.queryContextCache; + Assert.equal( + typeof cache.size, + "number", + "Sanity check: queryContextCache.size is a number" + ); + + // Open the view to show top sites and then close it. + await openViewAndAssertCached({ win, cached: false }); + + // Do `cache.size` + 1 searches. + for (let i = 0; i < cache.size + 1; i++) { + let searchString = "test" + i; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: searchString, + }); + await UrlbarTestUtils.promisePopupClose(win); + Assert.ok( + cache.get(searchString), + "Cache includes search string: " + searchString + ); + } + + // The first search string should have been evicted from the cache, but the + // one after that should still be cached. + Assert.ok(!cache.get("test0"), "test0 has been evicted from the cache"); + Assert.ok(cache.get("test1"), "Cache includes test1"); + + // Revert the input and open the view to show the top sites. It should open + // synchronously and the cached top-sites context should be used. + win.gURLBar.handleRevert(); + Assert.equal(win.gURLBar.value, "", "Input is empty after reverting"); + await openViewAndAssertCached({ win, cached: true }); + }); +}); + +/** + * Opens the view and checks that it is or is not synchronously opened and + * populated as specified. + * + * @param {object} options + * Options object. + * @param {window} options.win + * The window to open the view in. + * @param {boolean} options.cached + * Whether a query context is expected to already be cached for the search + * that's performed when the view opens. If true, then the view should + * synchronously open and populate using the cached context. If false, then + * the view should asynchronously open once the first results are fetched. + * @param {boolean} [options.cachedAfterOpen] + * Whether the context is expected to be cached after the view opens and the + * query finishes. + * @param {string} [options.searchString] + * The search string for which the context should or should not be cached. If + * falsey, then the relevant context is assumed to be the top-sites context. + * @param {Array} [options.urls] + * Array of URLs that are expected to be shown in the view. + * @param {boolean} [options.ignoreOrder] + * Whether to treat `urls` as an unordered set instead of an array. When true, + * the order of results is ignored. + * @param {boolean} [options.keepOpen] + * Whether to keep the view open when the function returns. + */ +async function openViewAndAssertCached({ + win, + cached, + cachedAfterOpen = true, + searchString = "", + urls = TEST_URLS, + ignoreOrder = false, + keepOpen = false, +}) { + let cache = win.gURLBar.view.queryContextCache; + let getContext = () => + searchString ? cache.get(searchString) : cache.topSitesContext; + + let cachedContext = getContext(); + Assert.equal( + !!cachedContext, + cached, + "Context is present or not in cache as expected for search string: " + + JSON.stringify(searchString) + ); + // Our payload schema validator allows for explicit undefined properties, + // thus we must transform them for stringify. + Assert.deepEqual( + cachedContext, + JSON.parse(JSON.stringify(cachedContext, (k, v) => v ?? null)), + "The query context should be made of serializable properties" + ); + + // Open the view by performing the accel+L command. + await SimpleTest.promiseFocus(win); + win.document.getElementById("Browser:OpenLocation").doCommand(); + + Assert.equal( + win.gURLBar.view.isOpen, + cached, + "View is open or not as expected" + ); + + if (!cached && cachedAfterOpen) { + // Wait for the search to finish and the context to be cached since callers + // generally expect it. + await TestUtils.waitForCondition( + getContext, + "Waiting for context to be cached for search string: " + + JSON.stringify(searchString) + ); + } else if (cached) { + // The view is expected to open synchronously. Check the results. We don't + // do this in the `!cached` case, when the view is expected to open + // asynchronously, because there are plenty of other tests for that. Here we + // want to make sure results are correct before the new search finishes in + // order to avoid any flicker. + let startIndex = 0; + let resultCount = urls.length; + if (searchString) { + // Plus heuristic + startIndex++; + resultCount++; + } + + // In all the checks below, check the rows container directly instead of + // relying on `UrlbarTestUtils` functions that wait for the search to + // finish. Here we're specifically checking cached results that should be + // used before the search finishes. + let rows = UrlbarTestUtils.getResultsContainer(win).children; + Assert.equal(rows.length, resultCount, "View has expected row count"); + + // Check the search heuristic row. + if (searchString) { + let result = rows[0].result; + Assert.ok(result.heuristic, "First row should be a heuristic"); + Assert.equal( + result.payload.query, + searchString, + "First row's query should be the search string" + ); + } + + // Check the URL rows. + let actualURLs = []; + let urlRows = Array.from(rows).slice(startIndex); + for (let row of urlRows) { + actualURLs.push(row.result.payload.url); + } + if (ignoreOrder) { + urls.sort(); + actualURLs.sort(); + } + Assert.deepEqual(actualURLs, urls, "View should contain the expected URLs"); + } + + // Now wait for the search to finish before returning. We await + // `lastQueryContextPromise` instead of the promise returned from + // `UrlbarTestUtils.promiseSearchComplete()` because the latter assumes the + // view will open, which isn't the case for every task here. + await win.gURLBar.lastQueryContextPromise; + if (!keepOpen) { + await UrlbarTestUtils.promisePopupClose(win); + } +} + +/** + * Updates the top sites and waits for the "newtab-top-sites-changed" + * notification. Note that this notification is not sent if the sites don't + * actually change. In that case, use only `updateTopSites()` instead. + * + * @param {number} expectedCount + * The new expected number of top sites. + */ +async function updateTopSitesAndAwaitChanged(expectedCount) { + info("Updating top sites and awaiting newtab-top-sites-changed"); + let changedPromise = TestUtils.topicObserved("newtab-top-sites-changed").then( + () => info("Observed newtab-top-sites-changed") + ); + await updateTopSites(sites => sites?.length == expectedCount); + await changedPromise; +} + +async function withNewBrowserWindow(callback) { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await callback(win); + await BrowserTestUtils.closeWindow(win); +} diff --git a/browser/components/urlbar/tests/browser/browser_quickactions.js b/browser/components/urlbar/tests/browser/browser_quickactions.js new file mode 100644 index 0000000000..ccf045d9e8 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_quickactions.js @@ -0,0 +1,737 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test QuickActions. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.sys.mjs", + UpdateService: "resource://gre/modules/UpdateService.sys.mjs", + UrlbarProviderQuickActions: + "resource:///modules/UrlbarProviderQuickActions.sys.mjs", +}); + +const DUMMY_PAGE = + "http://example.com/browser/browser/base/content/test/general/dummy_page.html"; + +let testActionCalled = 0; + +add_setup(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.quickactions.enabled", true], + ["browser.urlbar.suggest.quickactions", true], + ["browser.urlbar.shortcuts.quickactions", true], + ], + }); + + UrlbarProviderQuickActions.addAction("testaction", { + commands: ["testaction"], + label: "quickactions-downloads2", + onPick: () => testActionCalled++, + }); + + registerCleanupFunction(() => { + UrlbarProviderQuickActions.removeAction("testaction"); + }); +}); + +add_task(async function basic() { + info("The action isnt shown when not matched"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "nomatch", + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "We did no match anything" + ); + + info("A prefix of the command matches"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "testact", + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "We matched the action" + ); + + info("The callback of the action is fired when selected"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, 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); +}); + +add_task(async function match_in_phrase() { + UrlbarProviderQuickActions.addAction("newtestaction", { + commands: ["matchingstring"], + label: "quickactions-downloads2", + }); + + info("The action is matched when at end of input"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + 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 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); + } +}); + +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", + 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/" + ); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + const viewSourceTab = await onLoad; + + info("Do view source action on the view-source page"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "viewsource", + }); + + Assert.equal( + await hasQuickActions(window), + false, + "Result for quick actions is hidden" + ); + + // Clean up. + BrowserTestUtils.removeTab(viewSourceTab); + BrowserTestUtils.removeTab(tab); +}); + +async function doAlertDialogTest({ input, dialogContentURI }) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: input, + }); + + const onDialog = BrowserTestUtils.promiseAlertDialog(null, null, { + isSubDialog: true, + callback: win => { + Assert.equal(win.location.href, dialogContentURI, "The dialog is opened"); + EventUtils.synthesizeKey("KEY_Escape", {}, win); + }, + }); + + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + + await onDialog; +} + +add_task(async function test_refresh() { + await doAlertDialogTest({ + input: "refresh", + dialogContentURI: "chrome://global/content/resetProfile.xhtml", + }); +}); + +add_task(async function test_clear() { + let useOldClearHistoryDialog = Services.prefs.getBoolPref( + "privacy.sanitize.useOldClearHistoryDialog" + ); + let dialogURL = useOldClearHistoryDialog + ? "chrome://browser/content/sanitize.xhtml" + : "chrome://browser/content/sanitize_v2.xhtml"; + await doAlertDialogTest({ + input: "clear", + dialogContentURI: dialogURL, + }); +}); + +async function doUpdateActionTest(isActiveExpected, description) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "update", + }); + + if (isActiveExpected) { + await assertActionButtonStatus("update", isActiveExpected, description); + } else { + Assert.equal(await hasQuickActions(window), false, description); + } +} + +add_task(async function test_update() { + if (!AppConstants.MOZ_UPDATER) { + await doUpdateActionTest( + false, + "Should be disabled since not AppConstants.MOZ_UPDATER" + ); + return; + } + + const sandbox = sinon.createSandbox(); + try { + sandbox + .stub(UpdateService.prototype, "currentState") + .get(() => Ci.nsIApplicationUpdateService.STATE_IDLE); + await doUpdateActionTest( + false, + "Should be disabled since current update state is not pending" + ); + sandbox + .stub(UpdateService.prototype, "currentState") + .get(() => Ci.nsIApplicationUpdateService.STATE_PENDING); + await doUpdateActionTest( + true, + "Should be enabled since current update state is pending" + ); + } finally { + sandbox.restore(); + } +}); + +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({ + set: [["browser.urlbar.quickactions.showInZeroPrefix", false]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: " ", + }); + Assert.equal( + await hasQuickActions(window), + false, + "Result for quick actions is not shown" + ); + await SpecialPowers.popPrefEnv(); + + info("Test with quickactions.showInZeroPrefix pref is true"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.quickactions.showInZeroPrefix", true]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + const countForEmpty = window.document.querySelectorAll( + ".urlbarView-quickaction-button" + ).length; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: " ", + }); + const countForWhitespace = window.document.querySelectorAll( + ".urlbarView-quickaction-button" + ).length; + Assert.equal( + countForEmpty, + countForWhitespace, + "Count of quick actions of empty and whitespace are same" + ); + await SpecialPowers.popPrefEnv(); +}); + +async function clickQuickActionOneoffButton() { + const oneOffButton = await TestUtils.waitForCondition(() => + window.document.getElementById("urlbar-engine-one-off-item-actions") + ); + Assert.ok(oneOffButton, "One off button is available when preffed on"); + + EventUtils.synthesizeMouseAtCenter(oneOffButton, {}, window); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.ACTIONS, + entry: "oneoff", + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_quickactions_devtools.js b/browser/components/urlbar/tests/browser/browser_quickactions_devtools.js new file mode 100644 index 0000000000..1e1e92fb31 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_quickactions_devtools.js @@ -0,0 +1,176 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests QuickActions related to DevTools. + */ + +"use strict"; + +requestLongerTimeout(2); + +ChromeUtils.defineESModuleGetters(this, { + DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs", +}); + +add_setup(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.quickactions.enabled", true], + ["browser.urlbar.suggest.quickactions", true], + ["browser.urlbar.shortcuts.quickactions", true], + ], + }); +}); + +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); +}; + +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_inspector() { + const testData = [ + { + description: "Test for 'about:' page", + page: "about:home", + isDevToolsUser: true, + actionVisible: true, + actionEnabled: true, + }, + { + description: "Test for another 'about:' page", + page: "about:about", + isDevToolsUser: true, + actionVisible: true, + actionEnabled: true, + }, + { + description: "Test for another devtools-toolbox page", + page: "about:devtools-toolbox", + isDevToolsUser: true, + actionVisible: true, + actionEnabled: false, + }, + { + description: "Test for web content", + page: "https://example.com", + isDevToolsUser: true, + actionVisible: true, + actionEnabled: true, + }, + { + description: "Test for disabled DevTools", + page: "https://example.com", + prefs: [["devtools.policy.disabled", true]], + isDevToolsUser: true, + actionVisible: true, + actionEnabled: false, + }, + { + description: "Test for not DevTools user", + page: "https://example.com", + isDevToolsUser: false, + actionVisible: true, + actionEnabled: false, + }, + { + description: "Test for fully disabled", + page: "https://example.com", + prefs: [["devtools.policy.disabled", true]], + isDevToolsUser: false, + actionVisible: false, + }, + ]; + + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + for (const { + description, + page, + prefs = [], + isDevToolsUser, + actionEnabled, + actionVisible, + } of testData) { + info(description); + + info("Set preferences"); + await SpecialPowers.pushPrefEnv({ + set: [...prefs, ["devtools.selfxss.count", isDevToolsUser ? 5 : 0]], + }); + + info("Check the button status"); + const onLoad = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, page); + await onLoad; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "inspector", + }); + + if (actionVisible && actionEnabled) { + await assertActionButtonStatus( + "inspect", + true, + "The status of action button is correct" + ); + } else { + Assert.equal( + await hasQuickActions(window), + false, + "Result for quick actions is not shown since the inspector tool is disabled" + ); + } + + await SpecialPowers.popPrefEnv(); + + if (!actionVisible || !actionEnabled) { + continue; + } + + info("Do inspect action"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + await BrowserTestUtils.waitForCondition( + () => DevToolsShim.hasToolboxForTab(gBrowser.selectedTab), + "Wait for opening inspector for current selected tab" + ); + const toolbox = DevToolsShim.getToolboxForTab(gBrowser.selectedTab); + await BrowserTestUtils.waitForCondition( + () => toolbox.getPanel("inspector"), + "Wait until the inspector is ready" + ); + + info("Do inspect action again in the same page during opening inspector"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "inspector", + }); + Assert.equal( + await hasQuickActions(window), + false, + "Result for quick actions is not shown since the inspector is already opening" + ); + + info( + "Select another tool to check whether the inspector will be selected in next test even if the previous tool is not inspector" + ); + await toolbox.selectTool("options"); + await toolbox.destroy(); + } + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_quickactions_screenshot.js b/browser/components/urlbar/tests/browser/browser_quickactions_screenshot.js new file mode 100644 index 0000000000..c81442f0f5 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_quickactions_screenshot.js @@ -0,0 +1,170 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * QuickActions tests that touch screenshot functionality. + */ + +"use strict"; + +requestLongerTimeout(3); + +const DUMMY_PAGE = + "https://example.com/browser/browser/base/content/test/general/dummy_page.html"; + +async function isScreenshotInitialized() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + return screenshotsChild?.overlay?.initialized; + }); +} + +async function clickQuickActionOneoffButton() { + const oneOffButton = await TestUtils.waitForCondition(() => + window.document.getElementById("urlbar-engine-one-off-item-actions") + ); + Assert.ok(oneOffButton, "One off button is available when preffed on"); + + EventUtils.synthesizeMouseAtCenter(oneOffButton, {}, window); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.ACTIONS, + entry: "oneoff", + }); +} + +add_setup(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.quickactions.enabled", true], + ["browser.urlbar.suggest.quickactions", true], + ["browser.urlbar.shortcuts.quickactions", true], + ], + }); +}); + +add_task(async function test_screenshot() { + await SpecialPowers.pushPrefEnv({ + set: [["screenshots.browser.component.enabled", true]], + }); + + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, DUMMY_PAGE); + await BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + DUMMY_PAGE + ); + + info("The action is matched when at end of input"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + 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"); + + info("Trigger the screenshot mode"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, 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" + ); + + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Escape"); + }); +}); + +add_task(async function search_mode_on_webpage() { + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com" + ); + + info("Show result by click"); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}, window); + }); + 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"); + + info("Show result again"); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}, window); + }); + 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); + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_quickactions_tab_refocus.js b/browser/components/urlbar/tests/browser/browser_quickactions_tab_refocus.js new file mode 100644 index 0000000000..abac861931 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_quickactions_tab_refocus.js @@ -0,0 +1,194 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests for QuickActions that re-focus tab.. + */ + +"use strict"; + +requestLongerTimeout(3); + +add_setup(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.quickactions.enabled", true], + ["browser.urlbar.suggest.quickactions", true], + ["browser.urlbar.shortcuts.quickactions", true], + ], + }); +}); + +let isSelected = async selector => + SpecialPowers.spawn(gBrowser.selectedBrowser, [selector], arg => { + return ContentTaskUtils.waitForCondition(() => + content.document.querySelector(arg)?.hasAttribute("selected") + ); + }); + +add_task(async function test_about_pages() { + const testData = [ + { + firstInput: "downloads", + uri: "about:downloads", + }, + { + firstInput: "logins", + uri: "about:logins", + }, + { + firstInput: "settings", + uri: "about:preferences", + }, + { + firstInput: "add-ons", + uri: "about:addons", + component: "button[name=discover]", + }, + { + firstInput: "extensions", + uri: "about:addons", + component: "button[name=extension]", + }, + { + firstInput: "plugins", + uri: "about:addons", + component: "button[name=plugin]", + }, + { + firstInput: "themes", + uri: "about:addons", + component: "button[name=theme]", + }, + { + firstLoad: "about:preferences#home", + secondInput: "settings", + uri: "about:preferences#home", + }, + ]; + + for (const { + firstInput, + firstLoad, + secondInput, + uri, + component, + } of testData) { + info("Setup initial state"); + let firstTab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + let onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + uri + ); + if (firstLoad) { + info("Load initial URI"); + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, uri); + } else { + info("Open about page by quick action"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: firstInput, + }); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + } + await onLoad; + + if (component) { + info("Check whether the component is in the page"); + Assert.ok(await isSelected(component), "There is expected component"); + } + + info("Do the second quick action in second tab"); + let secondTab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: secondInput || firstInput, + }); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + Assert.equal( + gBrowser.selectedTab, + firstTab, + "Switched to the tab that is opening the about page" + ); + Assert.equal( + gBrowser.selectedBrowser.currentURI.spec, + uri, + "URI is not changed" + ); + Assert.equal(gBrowser.tabs.length, 3, "Not opened a new tab"); + + if (component) { + info("Check whether the component is still in the page"); + Assert.ok(await isSelected(component), "There is expected component"); + } + + BrowserTestUtils.removeTab(secondTab); + BrowserTestUtils.removeTab(firstTab); + } +}); + +add_task(async function test_about_addons_pages() { + let testData = [ + { + cmd: "add-ons", + testFun: async () => isSelected("button[name=discover]"), + }, + { + cmd: "plugins", + testFun: async () => isSelected("button[name=plugin]"), + }, + { + cmd: "extensions", + testFun: async () => isSelected("button[name=extension]"), + }, + { + cmd: "themes", + testFun: async () => isSelected("button[name=theme]"), + }, + ]; + + info("Pick all actions related about:addons"); + let originalTab = gBrowser.selectedTab; + for (const { cmd, testFun } of testData) { + await BrowserTestUtils.openNewForegroundTab(gBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: cmd, + }); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + Assert.ok(await testFun(), "The page content is correct"); + } + Assert.equal( + gBrowser.tabs.length, + testData.length + 1, + "Tab length is correct" + ); + + info("Pick all again"); + for (const { cmd, testFun } of testData) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: cmd, + }); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + await BrowserTestUtils.waitForCondition(() => testFun()); + Assert.ok(true, "The tab correspondent action is selected"); + } + Assert.equal( + gBrowser.tabs.length, + testData.length + 1, + "Tab length is not changed" + ); + + for (const tab of gBrowser.tabs) { + if (tab !== originalTab) { + BrowserTestUtils.removeTab(tab); + } + } +}); diff --git a/browser/components/urlbar/tests/browser/browser_raceWithTabs.js b/browser/components/urlbar/tests/browser/browser_raceWithTabs.js new file mode 100644 index 0000000000..17560ea101 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_raceWithTabs.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URL = `${TEST_BASE_URL}dummy_page.html`; + +async function addBookmark(bookmark) { + info("Creating bookmark and keyword"); + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: bookmark.url, + title: bookmark.title, + }); + if (bookmark.keyword) { + await PlacesUtils.keywords.insert({ + keyword: bookmark.keyword, + url: bookmark.url, + }); + } + + registerCleanupFunction(async function () { + if (bookmark.keyword) { + await PlacesUtils.keywords.remove(bookmark.keyword); + } + await PlacesUtils.bookmarks.remove(bm); + }); +} + +/** + * Check that if the user hits enter and ctrl-t at the same time, we open the + * URL in the right tab. + */ +add_task(async function hitEnterLoadInRightTab() { + await addBookmark({ + title: "Test for keyword bookmark and URL", + url: TEST_URL, + keyword: "urlbarkeyword", + }); + + info("Opening a tab"); + let oldTabOpenPromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ); + BrowserOpenTab(); + let oldTab = (await oldTabOpenPromise).target; + let oldTabLoadedPromise = BrowserTestUtils.browserLoaded( + oldTab.linkedBrowser, + false, + TEST_URL + ).then(() => info("Old tab loaded")); + + info("Filling URL bar, sending and opening a tab"); + let tabOpenPromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ); + gURLBar.value = "urlbarkeyword"; + gURLBar.focus(); + gURLBar.select(); + EventUtils.sendKey("return"); + + info("Immediately open a second tab"); + BrowserOpenTab(); + let newTab = (await tabOpenPromise).target; + + info("Created new tab; waiting for tabs to load"); + let newTabLoadedPromise = BrowserTestUtils.browserLoaded( + newTab.linkedBrowser, + false, + "about:newtab" + ).then(() => info("New tab loaded")); + // If one of the tabs loads the wrong page, this will timeout, and that + // indicates we regressed this bug fix. + await Promise.all([newTabLoadedPromise, oldTabLoadedPromise]); + // These are not particularly useful, but the test must contain some checks. + is( + newTab.linkedBrowser.currentURI.spec, + "about:newtab", + "New tab loaded about:newtab" + ); + is(oldTab.linkedBrowser.currentURI.spec, TEST_URL, "Old tab loaded URL"); + + info("Closing tabs"); + BrowserTestUtils.removeTab(newTab); + BrowserTestUtils.removeTab(oldTab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_recentsearches.js b/browser/components/urlbar/tests/browser/browser_recentsearches.js new file mode 100644 index 0000000000..e0ba5f684f --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_recentsearches.js @@ -0,0 +1,138 @@ +/* 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 CONFIG_DEFAULT = [ + { + webExtension: { id: "basic@search.mozilla.org" }, + appliesTo: [{ included: { everywhere: true } }], + default: "yes", + }, +]; + +const TOP_SITES = [ + "https://example-1.com/", + "https://example-2.com/", + "https://example-3.com/", +]; + +SearchTestUtils.init(this); + +add_setup(async () => { + // Use engines in test directory + let searchExtensions = getChromeDir(getResolvedURI(gTestPath)); + searchExtensions.append("search-engines"); + await SearchTestUtils.useMochitestEngines(searchExtensions); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.suggest.recentsearches", true], + ["browser.urlbar.recentsearches.featureGate", true], + // Disable UrlbarProviderSearchTips + [ + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", + false, + ], + ], + }); + + SearchTestUtils.useMockIdleService(); + await SearchTestUtils.updateRemoteSettingsConfig(CONFIG_DEFAULT); + Services.telemetry.clearScalars(); + + registerCleanupFunction(async () => { + let settingsWritten = SearchTestUtils.promiseSearchNotification( + "write-settings-to-disk-complete" + ); + await SearchTestUtils.updateRemoteSettingsConfig(); + await settingsWritten; + await UrlbarTestUtils.formHistory.clear(); + }); +}); + +add_task(async () => { + let tab = await BrowserTestUtils.openNewForegroundTab( + window.gBrowser, + "data:text/html," + ); + + info("Perform a search that will be added to search history."); + let browserLoaded = BrowserTestUtils.browserLoaded( + window.gBrowser.selectedBrowser + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "Bob Vylan", + }); + + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Enter", {}, window); + }); + await browserLoaded; + + info("Now check that is shown in search history."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "Previous search shown" + ); + let { result } = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal(result.providerName, "RecentSearches"); + + info("Selecting the recent search should be indicated in telemetry."); + browserLoaded = BrowserTestUtils.browserLoaded( + window.gBrowser.selectedBrowser + ); + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + }); + await browserLoaded; + + let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.picked.recent_search", + 0, + 1 + ); + await BrowserTestUtils.removeTab(tab); +}); + +// Ensure that top sites are shown above recent searches, even if trending +// suggestions are disabled. +add_task(async () => { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.trending", false], + ["browser.urlbar.suggest.topsites", true], + ["browser.newtabpage.activity-stream.default.sites", TOP_SITES.join(",")], + ], + }); + await updateTopSites(sites => sites && sites.length); + + let tab = await BrowserTestUtils.openNewForegroundTab( + window.gBrowser, + "data:text/html," + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + + let count = UrlbarTestUtils.getResultCount(window); + let { result } = await UrlbarTestUtils.getDetailsOfResultAt( + window, + count - 1 + ); + Assert.equal(result.providerName, "RecentSearches"); + + await BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_redirect_error.js b/browser/components/urlbar/tests/browser/browser_redirect_error.js new file mode 100644 index 0000000000..ae8dec3da6 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_redirect_error.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const REDIRECT_FROM = `${TEST_BASE_URL}redirect_error.sjs`; + +const REDIRECT_TO = "https://www.bank1.com/"; // Bad-cert host. + +function isRedirectedURISpec(aURISpec) { + return isRedirectedURI(Services.io.newURI(aURISpec)); +} + +function isRedirectedURI(aURI) { + // Compare only their before-hash portion. + return Services.io.newURI(REDIRECT_TO).equalsExceptRef(aURI); +} + +/* + Test. + +1. Load redirect_bug623155.sjs#BG in a background tab. + +2. The redirected URI is , which displayes a cert + error page. + +3. Switch the tab to foreground. + +4. Check the URLbar's value, expecting + +5. Load redirect_bug623155.sjs#FG in the foreground tab. + +6. The redirected URI is . And this is also + a cert-error page. + +7. Check the URLbar's value, expecting + +8. End. + + */ + +var gNewTab; + +function test() { + waitForExplicitFinish(); + + // Load a URI in the background. + gNewTab = BrowserTestUtils.addTab(gBrowser, REDIRECT_FROM + "#BG"); + gBrowser + .getBrowserForTab(gNewTab) + .webProgress.addProgressListener( + gWebProgressListener, + Ci.nsIWebProgress.NOTIFY_LOCATION + ); +} + +var gWebProgressListener = { + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + + // --------------------------------------------------------------------------- + // NOTIFY_LOCATION mode should work fine without these methods. + // + // onStateChange: function() {}, + // onStatusChange: function() {}, + // onProgressChange: function() {}, + // onSecurityChange: function() {}, + // ---------------------------------------------------------------------------- + + onLocationChange(aWebProgress, aRequest, aLocation, aFlags) { + if (!aRequest) { + // This is bug 673752, or maybe initial "about:blank". + return; + } + + ok(gNewTab, "There is a new tab."); + ok( + isRedirectedURI(aLocation), + "onLocationChange catches only redirected URI." + ); + + if (aLocation.ref == "BG") { + // This is background tab's request. + isnot(gNewTab, gBrowser.selectedTab, "This is a background tab."); + } else if (aLocation.ref == "FG") { + // This is foreground tab's request. + is(gNewTab, gBrowser.selectedTab, "This is a foreground tab."); + } else { + // We shonuld not reach here. + ok(false, "This URI hash is not expected:" + aLocation.ref); + } + + let isSelectedTab = gNewTab.selected; + setTimeout(delayed, 0, isSelectedTab); + }, +}; + +function delayed(aIsSelectedTab) { + // Switch tab and confirm URL bar. + if (!aIsSelectedTab) { + gBrowser.selectedTab = gNewTab; + } + + let currentURI = gBrowser.selectedBrowser.currentURI.spec; + ok( + isRedirectedURISpec(currentURI), + "The content area is redirected. aIsSelectedTab:" + aIsSelectedTab + ); + is( + gURLBar.value, + UrlbarTestUtils.trimURL(currentURI), + "The URL bar shows the content URI. aIsSelectedTab:" + aIsSelectedTab + ); + + if (!aIsSelectedTab) { + // If this was a background request, go on a foreground request. + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + REDIRECT_FROM + "#FG" + ); + } else { + // Othrewise, nothing to do remains. + finish(); + } +} + +/* Cleanup */ +registerCleanupFunction(function () { + if (gNewTab) { + gBrowser + .getBrowserForTab(gNewTab) + .webProgress.removeProgressListener(gWebProgressListener); + + gBrowser.removeTab(gNewTab); + } + gNewTab = null; +}); diff --git a/browser/components/urlbar/tests/browser/browser_remoteness_switch.js b/browser/components/urlbar/tests/browser/browser_remoteness_switch.js new file mode 100644 index 0000000000..d4d64f81cb --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_remoteness_switch.js @@ -0,0 +1,56 @@ +"use strict"; + +/** + * Verify that when loading and going back/forward through history between URLs + * loaded in the content process, and URLs loaded in the parent process, we + * don't set the URL for the tab to about:blank inbetween the loads. + */ +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.navigation.requireUserInteraction", false]], + }); + let url = "http://www.example.com/foo.html"; + await BrowserTestUtils.withNewTab( + { gBrowser, url }, + async function (browser) { + let wpl = { + onLocationChange(unused, unused2, location) { + if (location.schemeIs("about")) { + is( + location.spec, + "about:config", + "Only about: location change should be for about:preferences" + ); + } else { + is( + location.spec, + url, + "Only non-about: location change should be for the http URL we're dealing with." + ); + } + }, + }; + gBrowser.addProgressListener(wpl); + + let didLoad = BrowserTestUtils.browserLoaded( + browser, + null, + function (loadedURL) { + return loadedURL == "about:config"; + } + ); + BrowserTestUtils.startLoadingURIString(browser, "about:config"); + await didLoad; + + gBrowser.goBack(); + await BrowserTestUtils.browserLoaded(browser, null, function (loadedURL) { + return url == loadedURL; + }); + gBrowser.goForward(); + await BrowserTestUtils.browserLoaded(browser, null, function (loadedURL) { + return loadedURL == "about:config"; + }); + gBrowser.removeProgressListener(wpl); + } + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_remotetab.js b/browser/components/urlbar/tests/browser/browser_remotetab.js new file mode 100644 index 0000000000..1fde855dbd --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_remotetab.js @@ -0,0 +1,111 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This tests checks that the remote tab result is displayed and can be + * selected. + */ + +"use strict"; + +const { SyncedTabs } = ChromeUtils.importESModule( + "resource://services-sync/SyncedTabs.sys.mjs" +); + +const TEST_URL = `${TEST_BASE_URL}dummy_page.html`; + +const REMOTE_TAB = { + id: "7cqCr77ptzX3", + type: "client", + lastModified: 1492201200, + name: "zcarter's Nightly on MacBook-Pro-25", + clientType: "desktop", + tabs: [ + { + type: "tab", + title: "Test Remote", + url: TEST_URL, + icon: UrlbarUtils.ICON.DEFAULT, + client: "7cqCr77ptzX3", + lastUsed: Math.floor(Date.now() / 1000), + }, + ], +}; + +add_setup(async function () { + sandbox = sinon.createSandbox(); + + let originalSyncedTabsInternal = SyncedTabs._internal; + SyncedTabs._internal = { + isConfiguredToSyncTabs: true, + hasSyncedThisSession: true, + getTabClients() { + return Promise.resolve([]); + }, + syncTabs() { + return Promise.resolve(); + }, + }; + + // Tell the Sync XPCOM service it is initialized. + let weaveXPCService = Cc["@mozilla.org/weave/service;1"].getService( + Ci.nsISupports + ).wrappedJSObject; + let oldWeaveServiceReady = weaveXPCService.ready; + weaveXPCService.ready = true; + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.autoFill", false], + ["services.sync.username", "fake"], + ["services.sync.syncedTabs.showRemoteTabs", true], + ], + }); + + sandbox + .stub(SyncedTabs._internal, "getTabClients") + .callsFake(() => Promise.resolve(Cu.cloneInto([REMOTE_TAB], {}))); + + registerCleanupFunction(async () => { + sandbox.restore(); + weaveXPCService.ready = oldWeaveServiceReady; + SyncedTabs._internal = originalSyncedTabsInternal; + }); +}); + +add_task(async function test_remotetab_opens() { + await BrowserTestUtils.withNewTab( + { url: "about:robots", gBrowser }, + async function () { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "Test Remote", + }); + + // There should be two items in the pop-up, the first is the default search + // suggestion, the second is the remote tab. + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.REMOTE_TAB, + "Should be the remote tab entry" + ); + + // The URL is going to open in the current tab as it is currently about:blank + let promiseTabLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await promiseTabLoaded; + + Assert.equal( + gBrowser.selectedTab.linkedBrowser.currentURI.spec, + TEST_URL, + "correct URL loaded" + ); + } + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_removeUnsafeProtocolsFromURLBarPaste.js b/browser/components/urlbar/tests/browser/browser_removeUnsafeProtocolsFromURLBarPaste.js new file mode 100644 index 0000000000..4dfbc5c01b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_removeUnsafeProtocolsFromURLBarPaste.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Ensures that pasting unsafe protocols in the urlbar have the protocol + * correctly stripped. + */ + +var pairs = [ + ["javascript:", ""], + ["javascript:1+1", "1+1"], + ["javascript:document.domain", "document.domain"], + [ + " \u0001\u0002\u0003\u0004\u0005\u0006\u0007\u0008\u0009javascript:document.domain", + "document.domain", + ], + ["java\nscript:foo", "foo"], + ["java\tscript:foo", "foo"], + ["http://\nexample.com", "http://example.com"], + ["http://\nexample.com\n", "http://example.com"], + ["data:text/html,hi", "data:text/html,hi"], + ["javaScript:foopy", "foopy"], + ["javaScript:javaScript:alert('hi')", "alert('hi')"], + // Nested things get confusing because some things don't parse as URIs: + ["javascript:javascript:alert('hi!')", "alert('hi!')"], + [ + "data:data:text/html,hi", + "data:data:text/html,hi", + ], + ["javascript:data:javascript:alert('hi!')", "data:javascript:alert('hi!')"], + [ + "javascript:data:text/html,javascript:alert('hi!')", + "data:text/html,javascript:alert('hi!')", + ], + [ + "data:data:text/html,javascript:alert('hi!')", + "data:data:text/html,javascript:alert('hi!')", + ], +]; + +let supportsNullBytes = AppConstants.platform == "macosx"; +// Note that \u000d (\r) is missing here; we test it separately because it +// makes the test sad on Windows. +let nonsense = + "\u000a\u000b\u000c\u000e\u000f\u0010\u0011\u0012\u0013\u0014javascript:foo"; +if (supportsNullBytes) { + nonsense = "\u0000" + nonsense; +} +pairs.push([nonsense, "foo"]); + +let supportsReturnWithoutNewline = + AppConstants.platform != "win" && AppConstants.platform != "linux"; +if (supportsReturnWithoutNewline) { + pairs.push(["java\rscript:foo", "foo"]); +} + +async function paste(input) { + try { + await SimpleTest.promiseClipboardChange( + aData => { + // This test checks how "\r" is treated. Therefore, we cannot specify + // string here and instead, we need to compare strictly with this + // function. + return aData === input; + }, + () => { + clipboardHelper.copyString(input); + } + ); + } catch (ex) { + Assert.ok(false, "Failed to copy string '" + input + "' to clipboard"); + } + + document.commandDispatcher + .getControllerForCommand("cmd_paste") + .doCommand("cmd_paste"); +} + +add_task(async function test_stripUnsafeProtocolPaste() { + for (let [inputValue, expectedURL] of pairs) { + gURLBar.value = ""; + gURLBar.focus(); + await paste(inputValue); + + Assert.equal( + gURLBar.value, + expectedURL, + `entering ${inputValue} strips relevant bits.` + ); + + await new Promise(resolve => setTimeout(resolve, 0)); + } +}); diff --git a/browser/components/urlbar/tests/browser/browser_remove_match.js b/browser/components/urlbar/tests/browser/browser_remove_match.js new file mode 100644 index 0000000000..b9e97044e4 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_remove_match.js @@ -0,0 +1,218 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +ChromeUtils.defineESModuleGetters(this, { + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", +}); + +add_setup(async function () { + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + let engine = Services.search.getEngineByName("Example"); + await Services.search.moveEngine(engine, 0); +}); + +add_task(async function test_remove_history() { + const TEST_URL = "http://remove.me/from_urlbar/"; + await PlacesTestUtils.addVisits(TEST_URL); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); + + let promiseVisitRemoved = PlacesTestUtils.waitForNotification( + "page-removed", + events => events[0].url === TEST_URL + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "from_urlbar", + }); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, TEST_URL, "Found the expected result"); + + let expectedResultCount = UrlbarTestUtils.getResultCount(window) - 1; + + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), 1); + EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true }); + + const removeEvents = await promiseVisitRemoved; + Assert.ok( + removeEvents[0].isRemovedFromStore, + "isRemovedFromStore should be true" + ); + + await TestUtils.waitForCondition( + () => UrlbarTestUtils.getResultCount(window) == expectedResultCount, + "Waiting for the result to disappear" + ); + + for (let i = 0; i < expectedResultCount; i++) { + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.notEqual( + details.url, + TEST_URL, + "Should not find the test URL in the remaining results" + ); + } + + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_task(async function test_remove_form_history() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.maxHistoricalSearchSuggestions", 1], + ], + }); + + let formHistoryValue = "foobar"; + await UrlbarTestUtils.formHistory.add([formHistoryValue]); + + let formHistory = ( + await UrlbarTestUtils.formHistory.search({ + value: formHistoryValue, + }) + ).map(entry => entry.value); + Assert.deepEqual( + formHistory, + [formHistoryValue], + "Should find form history after adding it" + ); + + let promiseRemoved = UrlbarTestUtils.formHistory.promiseChanged("remove"); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + + let index = 1; + let count = UrlbarTestUtils.getResultCount(window); + for (; index < count; index++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.source == UrlbarUtils.RESULT_SOURCE.HISTORY + ) { + break; + } + } + Assert.ok(index < count, "Result found"); + + EventUtils.synthesizeKey("KEY_Tab", { repeat: index }); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), index); + EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true }); + await promiseRemoved; + + await TestUtils.waitForCondition( + () => UrlbarTestUtils.getResultCount(window) == count - 1, + "Waiting for the result to disappear" + ); + + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.ok( + result.type != UrlbarUtils.RESULT_TYPE.SEARCH || + result.source != UrlbarUtils.RESULT_SOURCE.HISTORY, + "Should not find the form history result in the remaining results" + ); + } + + await UrlbarTestUtils.promisePopupClose(window); + + formHistory = ( + await UrlbarTestUtils.formHistory.search({ + value: formHistoryValue, + }) + ).map(entry => entry.value); + Assert.deepEqual( + formHistory, + [], + "Should not find form history after removing it" + ); + + await SpecialPowers.popPrefEnv(); +}); + +// We shouldn't be able to remove a bookmark item. +add_task(async function test_remove_bookmark_doesnt() { + const TEST_URL = "http://dont.remove.me/from_urlbar/"; + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "test", + url: TEST_URL, + }); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "from_urlbar", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, TEST_URL, "Found the expected result"); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), 1); + EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true }); + + // We don't have an easy way of determining if the event was process or not, + // so let any event queues clear before testing. + await new Promise(resolve => setTimeout(resolve, 0)); + await PlacesTestUtils.promiseAsyncUpdates(); + + Assert.ok( + await PlacesUtils.bookmarks.fetch({ url: TEST_URL }), + "Should still have the URL bookmarked." + ); +}); + +add_task(async function test_searchMode_removeRestyledHistory() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.maxHistoricalSearchSuggestions", 1], + ], + }); + + let query = "ciao"; + let url = `https://example.com/?q=${query}bar`; + await PlacesTestUtils.addVisits(url); + + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: query, + }); + await UrlbarTestUtils.enterSearchMode(window); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.SEARCH); + Assert.equal(result.source, UrlbarUtils.RESULT_SOURCE.HISTORY); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), 1); + EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true }); + await TestUtils.waitForCondition( + async () => !(await PlacesTestUtils.isPageInDB(url)), + "Wait for url to be removed from history" + ); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "Urlbar result should be removed" + ); + + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + }); + await PlacesUtils.history.clear(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_restoreEmptyInput.js b/browser/components/urlbar/tests/browser/browser_restoreEmptyInput.js new file mode 100644 index 0000000000..096d8e2134 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_restoreEmptyInput.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// When the input is empty and the view is opened, keying down through the +// results and then out of the results should restore the empty input. + +"use strict"; + +add_task(async function test() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); + + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits("http://example.com/"); + } + // Update Top Sites to make sure the last Top Site is a URL. Otherwise, it + // would be a search shortcut and thus would not fill the Urlbar when + // selected. + await updateTopSites(sites => { + return ( + sites && + sites[sites.length - 1] && + sites[sites.length - 1].url == "http://example.com/" + ); + }); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + fireInputEvent: true, + }); + + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + -1, + "Nothing selected" + ); + + let resultCount = UrlbarTestUtils.getResultCount(window); + Assert.greater(resultCount, 0, "At least one result"); + + for (let i = 0; i < resultCount; i++) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + resultCount - 1, + "Last result selected" + ); + Assert.notEqual(gURLBar.value, "", "Input should not be empty"); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + -1, + "Nothing selected" + ); + Assert.equal(gURLBar.value, "", "Input should be empty"); +}); diff --git a/browser/components/urlbar/tests/browser/browser_resultSpan.js b/browser/components/urlbar/tests/browser/browser_resultSpan.js new file mode 100644 index 0000000000..9b17fb71f5 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_resultSpan.js @@ -0,0 +1,254 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that displaying results with resultSpan > 1 limits other results in +// the view. + +const TEST_RESULTS = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/1" } + ), + makeTipResult(), +]; + +const MAX_RESULTS = UrlbarPrefs.get("maxRichResults"); +const TIP_SPAN = UrlbarUtils.getSpanForResult({ + type: UrlbarUtils.RESULT_TYPE.TIP, +}); + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); +}); + +// A restricting provider with one tip result and many history results. +add_task(async function oneTip() { + let results = Array.from(TEST_RESULTS); + for (let i = TEST_RESULTS.length; i < MAX_RESULTS; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: `http://mozilla.org/${i}` } + ) + ); + } + + let expectedResults = Array.from(results).slice( + 0, + MAX_RESULTS - TIP_SPAN + 1 + ); + + let provider = new UrlbarTestUtils.TestProvider({ results, priority: 1 }); + UrlbarProvidersManager.registerProvider(provider); + + let context = await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + + checkResults(context.results, expectedResults); + + UrlbarProvidersManager.unregisterProvider(provider); + gURLBar.view.close(); +}); + +// A restricting provider with three tip results and many history results. +add_task(async function threeTips() { + let results = Array.from(TEST_RESULTS); + for (let i = 1; i < 3; i++) { + results.push(makeTipResult()); + } + for (let i = 2; i < 15; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: `http://mozilla.org/${i}` } + ) + ); + } + + let expectedResults = Array.from(results).slice( + 0, + MAX_RESULTS - 3 * (TIP_SPAN - 1) + ); + + let provider = new UrlbarTestUtils.TestProvider({ results, priority: 1 }); + UrlbarProvidersManager.registerProvider(provider); + + let context = await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + + checkResults(context.results, expectedResults); + + UrlbarProvidersManager.unregisterProvider(provider); + gURLBar.view.close(); +}); + +// A non-restricting provider with one tip result and many history results. +add_task(async function oneTip_nonRestricting() { + let results = Array.from(TEST_RESULTS); + for (let i = 2; i < 15; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: `http://mozilla.org/${i}` } + ) + ); + } + + let expectedResults = Array.from(results); + + // UrlbarProviderHeuristicFallback's heuristic search result + expectedResults.unshift({ + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + payload: { + engine: Services.search.defaultEngine.name, + query: "test", + }, + }); + + expectedResults = expectedResults.slice(0, MAX_RESULTS - TIP_SPAN + 1); + + let provider = new UrlbarTestUtils.TestProvider({ results }); + UrlbarProvidersManager.registerProvider(provider); + + let context = await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + + checkResults(context.results, expectedResults); + + UrlbarProvidersManager.unregisterProvider(provider); + gURLBar.view.close(); +}); + +// A non-restricting provider with three tip results and many history results. +add_task(async function threeTips_nonRestricting() { + let results = Array.from(TEST_RESULTS); + for (let i = 1; i < 3; i++) { + results.push(makeTipResult()); + } + for (let i = 2; i < 15; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: `http://mozilla.org/${i}` } + ) + ); + } + + let expectedResults = Array.from(results); + + // UrlbarProviderHeuristicFallback's heuristic search result + expectedResults.unshift({ + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + payload: { + engine: Services.search.defaultEngine.name, + query: "test", + }, + }); + + expectedResults = expectedResults.slice(0, MAX_RESULTS - 3 * (TIP_SPAN - 1)); + + let provider = new UrlbarTestUtils.TestProvider({ results }); + UrlbarProvidersManager.registerProvider(provider); + + let context = await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + + checkResults(context.results, expectedResults); + + UrlbarProvidersManager.unregisterProvider(provider); + gURLBar.view.close(); +}); + +add_task(async function customValue() { + let results = []; + for (let i = 0; i < 15; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: `http://mozilla.org/${i}` } + ) + ); + } + + results[1].resultSpan = 5; + + let expectedResults = Array.from(results); + expectedResults = expectedResults.slice(0, 6); + + let provider = new UrlbarTestUtils.TestProvider({ results }); + UrlbarProvidersManager.registerProvider(provider); + + let context = await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + + checkResults(context.results, expectedResults); + + UrlbarProvidersManager.unregisterProvider(provider); + gURLBar.view.close(); +}); + +function checkResults(actual, expected) { + Assert.equal(actual.length, expected.length, "Number of results"); + for (let i = 0; i < expected.length; i++) { + info(`Checking results at index ${i}`); + let actualResult = collectExpectedProperties(actual[i], expected[i]); + Assert.deepEqual(actualResult, expected[i], "Actual vs. expected result"); + } +} + +function collectExpectedProperties(actualObj, expectedObj) { + let newActualObj = {}; + for (let name in expectedObj) { + if (typeof expectedObj[name] == "object") { + newActualObj[name] = collectExpectedProperties( + actualObj[name], + expectedObj[name] + ); + } else { + newActualObj[name] = expectedObj[name]; + } + } + return newActualObj; +} + +function makeTipResult() { + return new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + helpUrl: "http://example.com/", + type: "test", + titleL10n: { id: "urlbar-search-tips-confirm" }, + buttons: [ + { + url: "http://example.com/", + l10n: { id: "urlbar-search-tips-confirm" }, + }, + ], + } + ); +} diff --git a/browser/components/urlbar/tests/browser/browser_result_menu.js b/browser/components/urlbar/tests/browser/browser_result_menu.js new file mode 100644 index 0000000000..ccbe247598 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_result_menu.js @@ -0,0 +1,260 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_history() { + const TEST_URL = "https://remove.me/from_urlbar/"; + await PlacesTestUtils.addVisits(TEST_URL); + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); + + const resultIndex = 1; + let result; + let startQuery = async () => { + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "from_urlbar", + }); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + Assert.equal(result.url, TEST_URL, "Found the expected result"); + gURLBar.view.selectedRowIndex = resultIndex; + }; + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.resultMenu.keyboardAccessible", false]], + }); + await startQuery(); + EventUtils.synthesizeKey("KEY_Tab"); + isnot( + UrlbarTestUtils.getSelectedElement(window), + UrlbarTestUtils.getButtonForResultIndex(window, "menu", resultIndex), + "Tab key skips over menu button with resultMenu.keyboardAccessible pref set to false" + ); + info( + "Checking that the mouse can still activate the menu button with resultMenu.keyboardAccessible = false" + ); + await UrlbarTestUtils.openResultMenu(window, { + byMouse: true, + resultIndex, + }); + gURLBar.view.resultMenu.hidePopup(); + await SpecialPowers.popPrefEnv(); + await startQuery(); + EventUtils.synthesizeKey("KEY_Tab"); + is( + UrlbarTestUtils.getSelectedElement(window), + UrlbarTestUtils.getButtonForResultIndex(window, "menu", resultIndex), + "Tab key doesn't skip over menu button with resultMenu.keyboardAccessible pref reset to true" + ); + + info("Checking that Space activates the menu button"); + await startQuery(); + await UrlbarTestUtils.openResultMenu(window, { + activationKey: " ", + }); + gURLBar.view.resultMenu.hidePopup(); + + info("Selecting Learn more item from the result menu"); + let tabOpenPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "awesome-bar-result-menu" + ); + await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "L"); + info("Waiting for Learn more link to open in a new tab"); + await tabOpenPromise; + gBrowser.removeCurrentTab(); + + info("Restarting query in order to remove history entry via the menu"); + await startQuery(); + let promiseVisitRemoved = PlacesTestUtils.waitForNotification( + "page-removed", + events => events[0].url === TEST_URL + ); + let expectedResultCount = UrlbarTestUtils.getResultCount(window) - 1; + + await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "R"); + const removeEvents = await promiseVisitRemoved; + Assert.ok( + removeEvents[0].isRemovedFromStore, + "isRemovedFromStore should be true" + ); + await TestUtils.waitForCondition( + () => UrlbarTestUtils.getResultCount(window) == expectedResultCount, + "Waiting for the result to disappear" + ); + for (let i = 0; i < expectedResultCount; i++) { + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.notEqual( + details.url, + TEST_URL, + "Should not find the test URL in the remaining results" + ); + } + + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_task(async function test_remove_search_history() { + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + let engine = Services.search.getEngineByName("Example"); + await Services.search.moveEngine(engine, 0); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.maxHistoricalSearchSuggestions", 1], + ], + }); + + let formHistoryValue = "foobar"; + await UrlbarTestUtils.formHistory.add([formHistoryValue]); + + let formHistory = ( + await UrlbarTestUtils.formHistory.search({ + value: formHistoryValue, + }) + ).map(entry => entry.value); + Assert.deepEqual( + formHistory, + [formHistoryValue], + "Should find form history after adding it" + ); + + let promiseRemoved = UrlbarTestUtils.formHistory.promiseChanged("remove"); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + + let resultIndex = 1; + let count = UrlbarTestUtils.getResultCount(window); + for (; resultIndex < count; resultIndex++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + resultIndex + ); + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.source == UrlbarUtils.RESULT_SOURCE.HISTORY + ) { + break; + } + } + Assert.ok(resultIndex < count, "Result found"); + + await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "R", { + resultIndex, + }); + await promiseRemoved; + + await TestUtils.waitForCondition( + () => UrlbarTestUtils.getResultCount(window) == count - 1, + "Waiting for the result to disappear" + ); + + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.ok( + result.type != UrlbarUtils.RESULT_TYPE.SEARCH || + result.source != UrlbarUtils.RESULT_SOURCE.HISTORY, + "Should not find the form history result in the remaining results" + ); + } + + await UrlbarTestUtils.promisePopupClose(window); + + formHistory = ( + await UrlbarTestUtils.formHistory.search({ + value: formHistoryValue, + }) + ).map(entry => entry.value); + Assert.deepEqual( + formHistory, + [], + "Should not find form history after removing it" + ); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function firefoxSuggest() { + const url = "https://example.com/hey-there"; + const helpUrl = "https://example.com/help"; + let provider = new UrlbarTestUtils.TestProvider({ + priority: Infinity, + results: [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + url, + isBlockable: true, + blockL10n: { id: "urlbar-result-menu-dismiss-firefox-suggest" }, + helpUrl, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + } + ), + ], + }); + + // Implement the provider's `onEngagement()` so it removes the result. + let onEngagementCallCount = 0; + provider.onEngagement = (state, queryContext, details, controller) => { + onEngagementCallCount++; + controller.removeResult(details.result); + }; + + UrlbarProvidersManager.registerProvider(provider); + + async function openResults() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "There should be one result" + ); + + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + Assert.equal( + row.result.payload.url, + url, + "The result should be in the first row" + ); + } + + await openResults(); + let tabOpenPromise = BrowserTestUtils.waitForNewTab(gBrowser, helpUrl); + await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "L", { + resultIndex: 0, + }); + info("Waiting for help URL to load in a new tab"); + await tabOpenPromise; + gBrowser.removeCurrentTab(); + + await openResults(); + await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "D", { + resultIndex: 0, + }); + + Assert.greater( + onEngagementCallCount, + 0, + "onEngagement() should have been called" + ); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 0, + "There should be no results after blocking" + ); + + await UrlbarTestUtils.promisePopupClose(window); + UrlbarProvidersManager.unregisterProvider(provider); +}); diff --git a/browser/components/urlbar/tests/browser/browser_result_menu_general.js b/browser/components/urlbar/tests/browser/browser_result_menu_general.js new file mode 100644 index 0000000000..ece48de20a --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_result_menu_general.js @@ -0,0 +1,416 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// General tests for the result menu that aren't related to specific result +// types. + +"use strict"; + +const MAX_RESULTS = UrlbarPrefs.get("maxRichResults"); +const RESULT_URL = "https://example.com/test"; +const RESULT_HELP_URL = "https://example.com/help"; + +add_setup(async function () { + // Add enough results to fill up the view. + await PlacesUtils.history.clear(); + for (let i = 0; i < MAX_RESULTS; i++) { + await PlacesTestUtils.addVisits("https://example.com/" + i); + } + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +// Sets `helpUrl` on a result payload and makes sure the result menu ends up +// with a help command. +add_task(async function help() { + let provider = registerTestProvider(1); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "example", + window, + }); + + await assertIsTestResult(1); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + let menuButton = result.element.row._buttons.get("menu"); + Assert.ok(menuButton, "Sanity check: menu button should exist"); + + let menuitem = await UrlbarTestUtils.openResultMenuAndGetItem({ + window, + command: "help", + resultIndex: 1, + openByMouse: true, + }); + Assert.ok(menuitem, "Help menu item should exist"); + + let l10nAttrs = document.l10n.getAttributes(menuitem); + Assert.deepEqual( + l10nAttrs, + { id: "urlbar-result-menu-tip-get-help", args: null }, + "The l10n ID attribute was correctly set" + ); + + // The result menu needs to be closed before calling + // `openResultMenuAndClickItem()` below; otherwise it will wait on a + // `popupshown` event that will never come. + gURLBar.view.resultMenu.hidePopup(true); + + // We assume clicking "help" will load a page in a new tab. + let loadPromise = BrowserTestUtils.waitForNewTab(gBrowser); + + await UrlbarTestUtils.openResultMenuAndClickItem(window, "help", { + resultIndex: 1, + openByMouse: true, + }); + + info("Waiting for load"); + await loadPromise; + await TestUtils.waitForTick(); + Assert.equal( + gBrowser.currentURI.spec, + RESULT_HELP_URL, + "The load URL should be the help URL" + ); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + + UrlbarProvidersManager.unregisterProvider(provider); +}); + +// (SHIFT+)TABs through a result with a menu button. The result is the second +// result and has other results after it. +add_task(async function keyboardSelection_secondResult() { + let provider = registerTestProvider(1); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "example", + window, + }); + + // Sanity-check initial state. + Assert.equal( + UrlbarTestUtils.getResultCount(window), + MAX_RESULTS, + "There should be MAX_RESULTS results in the view" + ); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + 0, + "The heuristic result should be selected" + ); + await assertIsTestResult(1); + + info("Arrow down to the main part of the result."); + EventUtils.synthesizeKey("KEY_ArrowDown"); + assertMainPartSelected(1); + + info("TAB to the button."); + EventUtils.synthesizeKey("KEY_Tab"); + assertButtonSelected(2); + + info("TAB to the next (third) result."); + EventUtils.synthesizeKey("KEY_Tab"); + assertOtherResultSelected(3, "next result"); + + info("SHIFT+TAB to the menu button."); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + assertButtonSelected(2); + + info("SHIFT+TAB to the main part of the result."); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + assertMainPartSelected(1); + + info("Arrow up to the previous (first) result."); + EventUtils.synthesizeKey("KEY_ArrowUp"); + assertOtherResultSelected(0, "previous result"); + + await UrlbarTestUtils.promisePopupClose(window); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +// (SHIFT+)TABs through a result with a help button. The result is the +// last result. +add_task(async function keyboardSelection_lastResult() { + let provider = registerTestProvider(MAX_RESULTS - 1); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "example", + window, + }); + + // Sanity-check initial state. + Assert.equal( + UrlbarTestUtils.getResultCount(window), + MAX_RESULTS, + "There should be MAX_RESULTS results in the view" + ); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + 0, + "The heuristic result should be selected" + ); + await assertIsTestResult(MAX_RESULTS - 1); + + let numSelectable = MAX_RESULTS * 2 - 2; + + // Arrow down to the main part of the result. + EventUtils.synthesizeKey("KEY_ArrowDown", { repeat: MAX_RESULTS - 1 }); + assertMainPartSelected(numSelectable - 1); + + // TAB to the menu button. + EventUtils.synthesizeKey("KEY_Tab"); + assertButtonSelected(numSelectable); + + // Arrow down to the first one-off. If this test is running alone, the + // one-offs will rebuild themselves when the view is opened above, and they + // may not be visible yet. Wait for the first one to become visible before + // trying to select it. + await TestUtils.waitForCondition(() => { + return ( + gURLBar.view.oneOffSearchButtons.buttons.firstElementChild && + BrowserTestUtils.isVisible( + gURLBar.view.oneOffSearchButtons.buttons.firstElementChild + ) + ); + }, "Waiting for first one-off to become visible."); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + await TestUtils.waitForCondition(() => { + return gURLBar.view.oneOffSearchButtons.selectedButton; + }, "Waiting for one-off to become selected."); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + -1, + "No results should be selected." + ); + + // SHIFT+TAB to the menu button. + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + assertButtonSelected(numSelectable); + + // SHIFT+TAB to the main part of the result. + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + assertMainPartSelected(numSelectable - 1); + + // Arrow up to the previous result. + EventUtils.synthesizeKey("KEY_ArrowUp"); + assertOtherResultSelected(numSelectable - 3, "previous result"); + + await UrlbarTestUtils.promisePopupClose(window); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +// Picks the main part of the test result with the keyboard. +add_task(async function pick_mainPart_keyboard() { + await doPickTest({ pickHelp: false, useKeyboard: true }); +}); + +// Picks the help command with the keyboard. +add_task(async function pick_help_keyboard() { + await doPickTest({ pickHelp: true, useKeyboard: true }); +}); + +// Picks the main part of the test result with the mouse. +add_task(async function pick_mainPart_mouse() { + await doPickTest({ pickHelp: false, useKeyboard: false }); +}); + +// Picks the help command with the mouse. +add_task(async function pick_help_mouse() { + await doPickTest({ pickHelp: true, useKeyboard: false }); +}); + +async function doPickTest({ pickHelp, useKeyboard }) { + await BrowserTestUtils.withNewTab("about:blank", async () => { + let index = 1; + let provider = registerTestProvider(index); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "example", + window, + }); + + // Sanity-check initial state. + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + 0, + "The heuristic result should be selected" + ); + await assertIsTestResult(index); + + if (useKeyboard) { + // Arrow down to the result. + EventUtils.synthesizeKey("KEY_ArrowDown", { repeat: index }); + assertMainPartSelected(index * 2 - 1); + } + + // Pick the result. The appropriate URL should load. + let loadPromise = pickHelp + ? BrowserTestUtils.waitForNewTab(gBrowser) + : BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await Promise.all([ + loadPromise, + UrlbarTestUtils.promisePopupClose(window, async () => { + if (pickHelp) { + await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "h", { + openByMouse: !useKeyboard, + resultIndex: index, + }); + } else if (useKeyboard) { + EventUtils.synthesizeKey("KEY_Enter"); + } else { + let result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + index + ); + EventUtils.synthesizeMouseAtCenter(result.element.row._content, {}); + } + }), + ]); + Assert.equal( + gBrowser.selectedBrowser.currentURI.spec, + pickHelp ? RESULT_HELP_URL : RESULT_URL, + "Expected URL should have loaded" + ); + + if (pickHelp) { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + UrlbarProvidersManager.unregisterProvider(provider); + + // Avoid showing adaptive history autofill. + await PlacesTestUtils.clearInputHistory(); + }); +} + +/** + * Registers a provider that creates a result with a help URL. + * + * @param {number} suggestedIndex + * The result's suggestedIndex. + * @returns {UrlbarProvider} + * The new provider. + */ +function registerTestProvider(suggestedIndex) { + let results = [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + url: RESULT_URL, + helpUrl: RESULT_HELP_URL, + helpL10n: { + id: "urlbar-result-menu-tip-get-help", + }, + } + ), + { suggestedIndex } + ), + ]; + let provider = new UrlbarTestUtils.TestProvider({ results }); + UrlbarProvidersManager.registerProvider(provider); + return provider; +} + +/** + * Asserts that the result at the given index is our test result with a menu + * button. + * + * @param {number} index + * The expected index of the test result. + */ +async function assertIsTestResult(index) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.URL, + "The second result should be a URL" + ); + Assert.equal( + result.url, + RESULT_URL, + "The result's URL should be the expected URL" + ); + + let { row } = result.element; + Assert.ok(row._buttons.get("menu"), "The result should have a menu button"); + Assert.ok(row._content.id, "Row-inner has an ID"); + Assert.equal( + row.getAttribute("role"), + "presentation", + "Row should have role=presentation" + ); + Assert.equal( + row._content.getAttribute("role"), + "option", + "Row-inner should have role=option" + ); +} + +/** + * Asserts that a particular element is selected. + * + * @param {number} expectedSelectedElementIndex + * The expected selected element index. + * @param {string} expectedClassName + * A class name of the expected selected element. + * @param {string} msg + * A string to include in the assertion message. + */ +function assertSelection(expectedSelectedElementIndex, expectedClassName, msg) { + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + expectedSelectedElementIndex, + "Expected selected element index: " + msg + ); + Assert.ok( + UrlbarTestUtils.getSelectedElement(window).classList.contains( + expectedClassName + ), + `Expected selected element: ${msg} (${ + UrlbarTestUtils.getSelectedElement(window).classList + } == ${expectedClassName})` + ); +} + +/** + * Asserts that the main part of our test result is selected. + * + * @param {number} expectedSelectedElementIndex + * The expected selected element index. + */ +function assertMainPartSelected(expectedSelectedElementIndex) { + assertSelection( + expectedSelectedElementIndex, + "urlbarView-row-inner", + "main part of test result" + ); +} + +/** + * Asserts that the menu button is selected. + * + * @param {number} expectedSelectedElementIndex + * The expected selected element index. + */ +function assertButtonSelected(expectedSelectedElementIndex) { + assertSelection( + expectedSelectedElementIndex, + "urlbarView-button-menu", + "menu button" + ); +} + +/** + * Asserts that a result other than our test result is selected. + * + * @param {number} expectedSelectedElementIndex + * The expected selected element index. + * @param {string} msg + * A string to include in the assertion message. + */ +function assertOtherResultSelected(expectedSelectedElementIndex, msg) { + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + expectedSelectedElementIndex, + "Expected other selected element index: " + msg + ); +} diff --git a/browser/components/urlbar/tests/browser/browser_result_onSelection.js b/browser/components/urlbar/tests/browser/browser_result_onSelection.js new file mode 100644 index 0000000000..2a5f8c3760 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_result_onSelection.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test() { + let results = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { + url: "http://mozilla.org/1", + helpUrl: "http://example.com/", + isBlockable: true, + blockL10n: { id: "urlbar-result-menu-remove-from-history" }, + } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { + url: "http://mozilla.org/2", + helpUrl: "http://example.com/", + isBlockable: true, + blockL10n: { id: "urlbar-result-menu-remove-from-history" }, + } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + helpUrl: "http://example.com/", + type: "test", + titleL10n: { id: "urlbar-search-tips-confirm" }, + buttons: [ + { + url: "http://example.com/", + l10n: { id: "urlbar-search-tips-confirm" }, + }, + ], + } + ), + ]; + + results[0].heuristic = true; + + let selectionCount = 0; + let provider = new UrlbarTestUtils.TestProvider({ + results, + priority: 1, + onSelection: (result, element) => { + selectionCount++; + }, + }); + UrlbarProvidersManager.registerProvider(provider); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + + EventUtils.synthesizeKey("KEY_Tab", { + repeat: 5, + }); + EventUtils.synthesizeKey("KEY_ArrowDown"); + ok( + UrlbarTestUtils.getOneOffSearchButtons(window).selectedButton, + "a one off button is selected" + ); + + Assert.equal(selectionCount, 6, "Number of elements selected in the view."); + UrlbarProvidersManager.unregisterProvider(provider); +}); diff --git a/browser/components/urlbar/tests/browser/browser_results_format_displayValue.js b/browser/components/urlbar/tests/browser/browser_results_format_displayValue.js new file mode 100644 index 0000000000..d0ec3d3818 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_results_format_displayValue.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_receive_punycode_result() { + let url = "https://www.اختبار.اختبار.org:5000/"; + + // eslint-disable-next-line jsdoc/require-jsdoc + class ResultWithHighlightsProvider extends UrlbarTestUtils.TestProvider { + startQuery(context, addCallback) { + let result = Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + ...UrlbarResult.payloadAndSimpleHighlights(context.tokens, { + url: [url, UrlbarUtils.HIGHLIGHT.TYPED], + }) + ), + { suggestedIndex: 0 } + ); + addCallback(this, result); + } + + getViewUpdate(result, idsByName) { + return {}; + } + } + let provider = new ResultWithHighlightsProvider(); + + registerCleanupFunction(async () => { + UrlbarProvidersManager.unregisterProvider(provider); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + gURLBar.handleRevert(); + }); + UrlbarProvidersManager.registerProvider(provider); + + info("Open the result popup"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "org", + window, + fireInputEvent: true, + }); + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + is(row.result.type, UrlbarUtils.RESULT_TYPE.URL, "row.result.type"); + is( + row.result.payload.displayUrl, + "اختبار.اختبار.org:5000", + "Result is trimmed and formatted correctly." + ); + is( + row.result.payload.title, + "www.اختبار.اختبار.org:5000", + "Result is trimmed and formatted correctly." + ); + + let firstRow = document.querySelector(".urlbarView-row"); + let firstRowUrl = firstRow.querySelector(".urlbarView-url"); + + is( + firstRowUrl.innerHTML.charAt(0), + "\u200e", + "UrlbarView row url contains LRM" + ); + // Tests if highlights are correct after inserting lrm symbol + is( + firstRowUrl.querySelector("strong")?.innerText, + "org", + "Correct part of url is highlighted" + ); + is( + firstRow.querySelector(".urlbarView-title strong")?.innerText, + "org", + "Correct part of title is highlighted" + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_retainedResultsOnFocus.js b/browser/components/urlbar/tests/browser/browser_retainedResultsOnFocus.js new file mode 100644 index 0000000000..3cc26a5757 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_retainedResultsOnFocus.js @@ -0,0 +1,438 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests retained results. +// When there is a pending search (user typed a search string and blurred +// without picking a result), on focus we should the search results again. + +async function checkPanelStatePersists(win, isOpen) { + // Check for popup events, we should not see any of them because the urlbar + // popup state should not change. This also ensures we don't cause flickering + // open/close actions. + function handler(event) { + Assert.ok(false, `Received unexpected event ${event.type}`); + } + win.gURLBar.addEventListener("popupshowing", handler); + win.gURLBar.addEventListener("popuphiding", handler); + // Because the panel opening may not be immediate, we must wait a bit. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 300)); + win.gURLBar.removeEventListener("popupshowing", handler); + win.gURLBar.removeEventListener("popuphiding", handler); + Assert.equal( + isOpen, + win.gURLBar.view.isOpen, + `check urlbar remains ${isOpen ? "open" : "closed"}` + ); +} + +async function checkOpensOnFocus(win, state) { + Assert.ok(!win.gURLBar.view.isOpen, "check urlbar panel is not open"); + win.gURLBar.blur(); + + info("Check the keyboard shortcut."); + await UrlbarTestUtils.promisePopupOpen(win, () => { + win.document.getElementById("Browser:OpenLocation").doCommand(); + }); + + await UrlbarTestUtils.promiseSearchComplete(win); + Assert.equal(state.selectionStart, win.gURLBar.selectionStart); + Assert.equal(state.selectionEnd, win.gURLBar.selectionEnd); + + await UrlbarTestUtils.promisePopupClose(win, () => { + win.gURLBar.blur(); + }); + info("Focus with the mouse."); + await UrlbarTestUtils.promisePopupOpen(win, () => { + EventUtils.synthesizeMouseAtCenter(win.gURLBar.inputField, {}, win); + }); + + await UrlbarTestUtils.promiseSearchComplete(win); + Assert.equal(state.selectionStart, win.gURLBar.selectionStart); + Assert.equal(state.selectionEnd, win.gURLBar.selectionEnd); +} + +async function checkDoesNotOpenOnFocus(win) { + Assert.ok(!win.gURLBar.view.isOpen, "check urlbar panel is not open"); + win.gURLBar.blur(); + + info("Check the keyboard shortcut."); + let promiseState = checkPanelStatePersists(win, false); + win.document.getElementById("Browser:OpenLocation").doCommand(); + await promiseState; + win.gURLBar.blur(); + info("Focus with the mouse."); + promiseState = checkPanelStatePersists(win, false); + EventUtils.synthesizeMouseAtCenter(win.gURLBar.inputField, {}, win); + await promiseState; +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill", true]], + }); + // Add some history for the empty panel and autofill. + await PlacesTestUtils.addVisits([ + { + uri: "https://example.com/", + transition: PlacesUtils.history.TRANSITIONS.TYPED, + }, + { + uri: "https://example.com/foo/", + transition: PlacesUtils.history.TRANSITIONS.TYPED, + }, + ]); + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); +}); + +async function test_window(win) { + for (let url of ["about:newtab", "about:home", "https://example.com/"]) { + // withNewTab may hang on preloaded pages, thus instead of waiting for load + // we just wait for the expected currentURI value. + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url, waitForLoad: false }, + async browser => { + await TestUtils.waitForCondition( + () => win.gBrowser.currentURI.spec == url, + "Ensure we're on the expected page" + ); + + // In one case use a value that triggers autofill. + let autofill = url == "https://example.com/"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: autofill ? "ex" : "foo", + fireInputEvent: true, + }); + let { value, selectionStart, selectionEnd } = win.gURLBar; + if (!autofill) { + selectionStart = 0; + } + info("expected " + value + " " + selectionStart + " " + selectionEnd); + await UrlbarTestUtils.promisePopupClose(win, () => { + win.gURLBar.blur(); + }); + + info("The panel should open when there's a search string"); + await checkOpensOnFocus(win, { value, selectionStart, selectionEnd }); + await UrlbarTestUtils.promisePopupClose(win, () => { + win.gURLBar.blur(); + }); + } + ); + } +} + +add_task(async function test_normalWindow() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await test_window(win); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_privateWindow() { + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await test_window(privateWin); + await BrowserTestUtils.closeWindow(privateWin); +}); + +add_task(async function test_tabSwitch() { + info("Check that switching tabs reopens the view."); + let win = await BrowserTestUtils.openNewBrowserWindow(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "ex", + fireInputEvent: true, + }); + let { value, selectionStart, selectionEnd } = win.gURLBar; + Assert.equal(value, "example.com/", "Check autofill value"); + Assert.ok( + selectionStart > 0 && selectionEnd > selectionStart, + "Check autofill selection" + ); + + Assert.ok(win.gURLBar.focused, "The urlbar should be focused"); + let tab1 = win.gBrowser.selectedTab; + + async function check_autofill() { + // The urlbar code waits for both TabSelect and the focus change, thus + // we can't just wait for search completion here, we have to poll for a + // value. + await TestUtils.waitForCondition( + () => win.gURLBar.value == "example.com/", + "wait for autofill value" + ); + // Ensure stable results. + await UrlbarTestUtils.promiseSearchComplete(win); + Assert.equal(selectionStart, win.gURLBar.selectionStart); + Assert.equal(selectionEnd, win.gURLBar.selectionEnd); + } + + info("Open a new tab with the same search"); + let tab2 = await BrowserTestUtils.openNewForegroundTab(win.gBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "ex", + fireInputEvent: true, + }); + + info("Switch across tabs"); + for (let tab of win.gBrowser.tabs) { + await UrlbarTestUtils.promisePopupOpen(win, async () => { + await BrowserTestUtils.switchTab(win.gBrowser, tab); + }); + await check_autofill(); + } + + info("Close tab and check the view is open."); + await UrlbarTestUtils.promisePopupOpen(win, () => { + BrowserTestUtils.removeTab(tab2); + }); + await check_autofill(); + + info("Open a new tab with a different search"); + tab2 = await BrowserTestUtils.openNewForegroundTab(win.gBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "xam", + fireInputEvent: true, + }); + + info("Switch to the first tab and check the panel remains open"); + let promiseState = checkPanelStatePersists(win, true); + await BrowserTestUtils.switchTab(win.gBrowser, tab1); + await promiseState; + await UrlbarTestUtils.promiseSearchComplete(win); + await check_autofill(); + + info("Switch to the second tab and check the panel remains open"); + promiseState = checkPanelStatePersists(win, true); + await BrowserTestUtils.switchTab(win.gBrowser, tab2); + await promiseState; + await UrlbarTestUtils.promiseSearchComplete(win); + Assert.equal(win.gURLBar.value, "xam", "check value"); + Assert.equal(win.gURLBar.selectionStart, 3); + Assert.equal(win.gURLBar.selectionEnd, 3); + + info("autofill in tab2, switch to tab1, then back to tab2 with the mouse"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "e", + fireInputEvent: true, + }); + // Adjust selection start, we are using a different search string. + await BrowserTestUtils.switchTab(win.gBrowser, tab1); + await UrlbarTestUtils.promiseSearchComplete(win); + await check_autofill(); + tab2.click(); + selectionStart = 1; + await check_autofill(); + + info("Check we don't rerun a search if the shortcut is used on an open view"); + EventUtils.synthesizeKey("KEY_Backspace", {}, win); + await UrlbarTestUtils.promiseSearchComplete(win); + Assert.ok(win.gURLBar.view.isOpen, "The view should be open"); + Assert.equal(win.gURLBar.value, "e", "The value should be the typed one"); + win.document.getElementById("Browser:OpenLocation").doCommand(); + // A search should not run here, so there's nothing to wait for. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 300)); + Assert.ok(win.gURLBar.view.isOpen, "The view should be open"); + Assert.equal(win.gURLBar.value, "e", "The value should not change"); + + info( + "Tab switch from an empty search tab with unfocused urlbar to a tab with a search string and a focused urlbar" + ); + win.gURLBar.value = ""; + win.gURLBar.blur(); + await UrlbarTestUtils.promisePopupOpen(win, async () => { + await BrowserTestUtils.switchTab(win.gBrowser, tab1); + }); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_tabSwitch_pageproxystate() { + info("Switching tabs on valid pageproxystate doesn't reopen."); + + info("Adding some visits for the empty panel"); + await PlacesTestUtils.addVisits([ + "https://example.com/", + "https://example.org/", + ]); + registerCleanupFunction(PlacesUtils.history.clear); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + BrowserTestUtils.startLoadingURIString( + win.gBrowser.selectedBrowser, + "about:robots" + ); + let tab1 = win.gBrowser.selectedTab; + + info("Open a new tab and the empty search"); + let tab2 = await BrowserTestUtils.openNewForegroundTab(win.gBrowser); + await UrlbarTestUtils.promisePopupOpen(win, async () => { + win.gURLBar.focus(); + // On Linux and Mac down moves caret to the end of the text unless it's + // there already. + win.gURLBar.selectionStart = win.gURLBar.selectionEnd = + win.gURLBar.value.length; + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + }); + await UrlbarTestUtils.promiseSearchComplete(win); + let result = await UrlbarTestUtils.getDetailsOfResultAt(win, 0); + Assert.notEqual(result.url, "about:robots"); + + info("Switch to the first tab and start searching with DOWN"); + await UrlbarTestUtils.promisePopupClose(win, async () => { + await BrowserTestUtils.switchTab(win.gBrowser, tab1); + }); + await checkPanelStatePersists(win, false); + await UrlbarTestUtils.promisePopupOpen(win, async () => { + win.gURLBar.focus(); + // On Linux and Mac down moves caret to the end of the text unless it's + // there already. + win.gURLBar.selectionStart = win.gURLBar.selectionEnd = + win.gURLBar.value.length; + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + }); + await UrlbarTestUtils.promiseSearchComplete(win); + + info("Switcihng to the second tab should not reopen the search"); + await UrlbarTestUtils.promisePopupClose(win, async () => { + await BrowserTestUtils.switchTab(win.gBrowser, tab2); + }); + await checkPanelStatePersists(win, false); + + info("Switching to the first tab should not reopen the search"); + let promiseState = await checkPanelStatePersists(win, false); + await BrowserTestUtils.switchTab(win.gBrowser, tab1); + await promiseState; + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_tabSwitch_emptySearch() { + info("Switching between empty-search tabs should not reopen the view."); + let win = await BrowserTestUtils.openNewBrowserWindow(); + + info("Open the empty search"); + let tab1 = win.gBrowser.selectedTab; + await UrlbarTestUtils.promisePopupOpen(win, async () => { + win.gURLBar.focus(); + // On Linux and Mac down moves caret to the end of the text unless it's + // there already. + win.gURLBar.selectionStart = win.gURLBar.selectionEnd = + win.gURLBar.value.length; + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + }); + await UrlbarTestUtils.promiseSearchComplete(win); + + info("Open a new tab and the empty search"); + let tab2 = await BrowserTestUtils.openNewForegroundTab(win.gBrowser); + await UrlbarTestUtils.promisePopupOpen(win, async () => { + win.gURLBar.focus(); + // On Linux and Mac down moves caret to the end of the text unless it's + // there already. + win.gURLBar.selectionStart = win.gURLBar.selectionEnd = + win.gURLBar.value.length; + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + }); + await UrlbarTestUtils.promiseSearchComplete(win); + + info("Switching to the first tab should not reopen the view"); + await UrlbarTestUtils.promisePopupClose(win, async () => { + await BrowserTestUtils.switchTab(win.gBrowser, tab1); + }); + await checkPanelStatePersists(win, false); + + info("Switching to the second tab should not reopen the view"); + let promiseState = await checkPanelStatePersists(win, false); + await BrowserTestUtils.switchTab(win.gBrowser, tab2); + await promiseState; + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_pageproxystate_valid() { + info("Focusing on valid pageproxystate should not reopen the view."); + let win = await BrowserTestUtils.openNewBrowserWindow(); + + info("Search for a full url and confirm it with Enter"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "about:robots", + fireInputEvent: true, + }); + let loadedPromise = BrowserTestUtils.browserLoaded( + win.gBrowser.selectedBrowser + ); + EventUtils.synthesizeKey("KEY_Enter", {}, win); + await loadedPromise; + + Assert.ok(!win.gURLBar.focused, "The urlbar should not be focused"); + info("Focus the urlbar"); + win.document.getElementById("Browser:OpenLocation").doCommand(); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_allowAutofill() { + info("Check we respect allowAutofill from the last search"); + let win = await BrowserTestUtils.openNewBrowserWindow(); + + await UrlbarTestUtils.promisePopupOpen(win, async () => { + await selectAndPaste("e", win); + }); + Assert.equal(win.gURLBar.value, "e", "Should not autofill"); + let context = await win.gURLBar.lastQueryContextPromise; + Assert.equal(context.allowAutofill, false, "Check initial allowAutofill"); + await UrlbarTestUtils.promisePopupClose(win); + + await UrlbarTestUtils.promisePopupOpen(win, async () => { + win.document.getElementById("Browser:OpenLocation").doCommand(); + }); + await UrlbarTestUtils.promiseSearchComplete(win); + Assert.equal(win.gURLBar.value, "e", "Should not autofill"); + context = await win.gURLBar.lastQueryContextPromise; + Assert.equal(context.allowAutofill, false, "Check reopened allowAutofill"); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_clicks_after_autofill() { + info( + "Check that clickin on an autofilled input field doesn't requery, causing loss of the caret position" + ); + let win = await BrowserTestUtils.openNewBrowserWindow(); + info("autofill in tab2, switch to tab1, then back to tab2 with the mouse"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "e", + fireInputEvent: true, + }); + Assert.equal(win.gURLBar.value, "example.com/", "Should have autofilled"); + + // Check single click. + let input = win.gURLBar.inputField; + EventUtils.synthesizeMouse(input, 30, 10, {}, win); + // Wait a bit, in case of a mistake this would run a query, otherwise not. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 300)); + Assert.ok(win.gURLBar.selectionStart < win.gURLBar.value.length); + Assert.equal(win.gURLBar.selectionStart, win.gURLBar.selectionEnd); + + // Check double click. + EventUtils.synthesizeMouse(input, 30, 10, { clickCount: 2 }, win); + // Wait a bit, in case of a mistake this would run a query, otherwise not. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 300)); + Assert.ok(win.gURLBar.selectionStart < win.gURLBar.value.length); + Assert.ok(win.gURLBar.selectionEnd > win.gURLBar.selectionStart); + + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/urlbar/tests/browser/browser_revert.js b/browser/components/urlbar/tests/browser/browser_revert.js new file mode 100644 index 0000000000..b68ad0ff91 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_revert.js @@ -0,0 +1,33 @@ +// Test reverting the urlbar value with ESC after a tab switch. + +add_task(async function () { + registerCleanupFunction(PlacesUtils.history.clear); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "http://example.com", + }, + async function (browser) { + let originalValue = gURLBar.value; + let tab = gBrowser.selectedTab; + info("Put a typed value."); + gBrowser.userTypedValue = "foobar"; + info("Switch tabs."); + gBrowser.selectedTab = gBrowser.tabs[0]; + gBrowser.selectedTab = tab; + Assert.equal( + gURLBar.value, + "foobar", + "location bar displays typed value" + ); + + gURLBar.focus(); + EventUtils.synthesizeKey("KEY_Escape"); + Assert.equal( + gURLBar.value, + originalValue, + "ESC reverted the location bar value" + ); + } + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchFunction.js b/browser/components/urlbar/tests/browser/browser_searchFunction.js new file mode 100644 index 0000000000..0a272f9f01 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchFunction.js @@ -0,0 +1,278 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test checks the urlbar.search() function. + +"use strict"; + +const ALIAS = "@enginealias"; +let aliasEngine; + +add_setup(async function () { + // Run this in a new tab, to ensure all the locationchange notifications have + // fired. + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await SearchTestUtils.installSearchExtension({ + keyword: ALIAS, + }); + aliasEngine = Services.search.getEngineByName("Example"); + + registerCleanupFunction(async function () { + BrowserTestUtils.removeTab(tab); + gURLBar.handleRevert(); + }); +}); + +// Calls search() with a normal, non-"@engine" search-string argument. +add_task(async function basic() { + gURLBar.blur(); + gURLBar.search("basic"); + ok(gURLBar.hasAttribute("focused"), "url bar is focused"); + await assertUrlbarValue("basic"); + + assertOneOffButtonsVisible(true); + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// Calls search() with an invalid "@engine" search engine alias so that the +// one-off search buttons are disabled. +add_task(async function searchEngineAlias() { + gURLBar.blur(); + await UrlbarTestUtils.promisePopupOpen(window, () => + gURLBar.search("@example") + ); + ok(gURLBar.hasAttribute("focused"), "url bar is focused"); + UrlbarTestUtils.assertSearchMode(window, null); + await assertUrlbarValue("@example"); + + assertOneOffButtonsVisible(false); + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); + + // Open the popup again (by doing another search) to make sure the one-off + // buttons are shown -- i.e., that we didn't accidentally break them. + await UrlbarTestUtils.promisePopupOpen(window, () => + gURLBar.search("not an engine alias") + ); + await assertUrlbarValue("not an engine alias"); + assertOneOffButtonsVisible(true); + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +add_task(async function searchRestriction() { + gURLBar.blur(); + await UrlbarTestUtils.promisePopupOpen(window, () => + gURLBar.search(UrlbarTokenizer.RESTRICT.SEARCH) + ); + ok(gURLBar.hasAttribute("focused"), "url bar is focused"); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: UrlbarSearchUtils.getDefaultEngine().name, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + // Entry is "other" because we didn't pass searchModeEntry to search(). + entry: "other", + }); + assertOneOffButtonsVisible(true); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_task(async function historyRestriction() { + gURLBar.blur(); + await UrlbarTestUtils.promisePopupOpen(window, () => + gURLBar.search(UrlbarTokenizer.RESTRICT.HISTORY) + ); + ok(gURLBar.hasAttribute("focused"), "url bar is focused"); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + entry: "other", + }); + assertOneOffButtonsVisible(true); + Assert.ok(!gURLBar.value, "The Urlbar has no value."); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_task(async function historyRestrictionWithString() { + gURLBar.blur(); + // The leading and trailing spaces are intentional to verify that search() + // preserves them. + let searchString = " foo bar "; + await UrlbarTestUtils.promisePopupOpen(window, () => + gURLBar.search(`${UrlbarTokenizer.RESTRICT.HISTORY} ${searchString}`) + ); + ok(gURLBar.hasAttribute("focused"), "url bar is focused"); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + entry: "other", + }); + // We don't use assertUrlbarValue here since we expect to open a local search + // mode. In those modes, we don't show a heuristic search result, which + // assertUrlbarValue checks for. + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal( + gURLBar.value, + searchString, + "The Urlbar value should be the search string." + ); + assertOneOffButtonsVisible(true); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_task(async function tagRestriction() { + gURLBar.blur(); + await UrlbarTestUtils.promisePopupOpen(window, () => + gURLBar.search(UrlbarTokenizer.RESTRICT.TAG) + ); + ok(gURLBar.hasAttribute("focused"), "url bar is focused"); + // Since tags are not a supported search mode, we should just insert the tag + // restriction token and not enter search mode. + await UrlbarTestUtils.assertSearchMode(window, null); + await assertUrlbarValue(`${UrlbarTokenizer.RESTRICT.TAG} `); + assertOneOffButtonsVisible(true); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Calls search() twice with the same value. The popup should reopen. +add_task(async function searchTwice() { + gURLBar.blur(); + await UrlbarTestUtils.promisePopupOpen(window, () => gURLBar.search("test")); + ok(gURLBar.hasAttribute("focused"), "url bar is focused"); + await assertUrlbarValue("test"); + assertOneOffButtonsVisible(true); + await UrlbarTestUtils.promisePopupClose(window); + + await UrlbarTestUtils.promisePopupOpen(window, () => gURLBar.search("test")); + ok(gURLBar.hasAttribute("focused"), "url bar is focused"); + await assertUrlbarValue("test"); + assertOneOffButtonsVisible(true); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Calls search() during an IME composition. +add_task(async function searchIME() { + // First run a search. + gURLBar.blur(); + await UrlbarTestUtils.promisePopupOpen(window, () => gURLBar.search("test")); + ok(gURLBar.hasAttribute("focused"), "url bar is focused"); + await assertUrlbarValue("test"); + // Start composition. + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeComposition({ type: "compositionstart" }) + ); + + gURLBar.search("test"); + // Unfortunately there's no other way to check we don't open the view than to + // wait for it. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1000)); + ok(!UrlbarTestUtils.isPopupOpen(window), "The panel should still be closed"); + + await UrlbarTestUtils.promisePopupOpen(window, () => + EventUtils.synthesizeComposition({ type: "compositioncommitasis" }) + ); + + assertOneOffButtonsVisible(true); + + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Calls search() with an engine alias. +add_task(async function searchWithAlias() { + await UrlbarTestUtils.promisePopupOpen(window, async () => + gURLBar.search(`${ALIAS} test`, { + searchEngine: aliasEngine, + searchModeEntry: "topsites_urlbar", + }) + ); + Assert.ok(gURLBar.hasAttribute("focused"), "Urlbar is focused"); + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: aliasEngine.name, + entry: "topsites_urlbar", + }); + await assertUrlbarValue("test"); + assertOneOffButtonsVisible(true); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Calls search() and passes in a search engine without including a restriction +// token or engine alias in the search string. Simulates pasting into the newtab +// handoff field with search suggestions disabled. +add_task(async function searchEngineWithNoToken() { + await UrlbarTestUtils.promisePopupOpen(window, async () => + gURLBar.search("no-alias", { + searchEngine: aliasEngine, + searchModeEntry: "handoff", + }) + ); + + Assert.ok(gURLBar.hasAttribute("focused"), "Urlbar is focused"); + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: aliasEngine.name, + entry: "handoff", + }); + await assertUrlbarValue("no-alias"); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +/** + * Asserts that the one-off search buttons are or aren't visible. + * + * @param {boolean} visible + * True if they should be visible, false if not. + */ +function assertOneOffButtonsVisible(visible) { + Assert.equal( + UrlbarTestUtils.getOneOffSearchButtonsVisible(window), + visible, + "Should show or not the one-off search buttons" + ); +} + +/** + * Asserts that the urlbar's input value is the given value. Also asserts that + * the first (heuristic) result in the popup is a search suggestion whose search + * query is the given value. + * + * @param {string} value + * The urlbar's expected value. + */ +async function assertUrlbarValue(value) { + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + + Assert.equal(gURLBar.value, value); + Assert.greater( + UrlbarTestUtils.getResultCount(window), + 0, + "Should have at least one result" + ); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Should have type search for the first result" + ); + // Strip search restriction token from value. + if (value[0] == UrlbarTokenizer.RESTRICT.SEARCH) { + value = value.substring(1).trim(); + } + Assert.equal( + result.searchParams.query, + value, + "Should have the correct query for the first result" + ); +} diff --git a/browser/components/urlbar/tests/browser/browser_searchHistoryLimit.js b/browser/components/urlbar/tests/browser/browser_searchHistoryLimit.js new file mode 100644 index 0000000000..6fcde0882b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchHistoryLimit.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test checks that search values longer than + * SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH are not added to + * search history. + */ + +"use strict"; + +const { SearchSuggestionController } = ChromeUtils.importESModule( + "resource://gre/modules/SearchSuggestionController.sys.mjs" +); + +let gEngine; + +add_setup(async function () { + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + gEngine = Services.search.getEngineByName("Example"); + await UrlbarTestUtils.formHistory.clear(); + + registerCleanupFunction(async function () { + await UrlbarTestUtils.formHistory.clear(); + }); +}); + +add_task(async function sanityCheckShortString() { + const shortString = new Array( + SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH + ) + .fill("a") + .join(""); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: shortString, + }); + let url = gEngine.getSubmission(shortString).uri.spec; + let loadPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + url + ); + let addPromise = UrlbarTestUtils.formHistory.promiseChanged("add"); + EventUtils.synthesizeKey("VK_RETURN"); + await Promise.all([loadPromise, addPromise]); + + let formHistory = ( + await UrlbarTestUtils.formHistory.search({ source: gEngine.name }) + ).map(entry => entry.value); + Assert.deepEqual( + formHistory, + [shortString], + "Should find form history after adding it" + ); + + await UrlbarTestUtils.formHistory.clear(); +}); + +add_task(async function urlbar_checkLongString() { + const longString = new Array( + SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH + 1 + ) + .fill("a") + .join(""); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: longString, + }); + let url = gEngine.getSubmission(longString).uri.spec; + let loadPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + url + ); + EventUtils.synthesizeKey("VK_RETURN"); + await loadPromise; + // There's nothing we can wait for, since addition should not be happening. + /* eslint-disable mozilla/no-arbitrary-setTimeout */ + await new Promise(resolve => setTimeout(resolve, 500)); + let formHistory = ( + await UrlbarTestUtils.formHistory.search({ source: gEngine.name }) + ).map(entry => entry.value); + Assert.deepEqual(formHistory, [], "Should not find form history"); + + await UrlbarTestUtils.formHistory.clear(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_alias_replacement.js b/browser/components/urlbar/tests/browser/browser_searchMode_alias_replacement.js new file mode 100644 index 0000000000..9f4558e6c9 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_alias_replacement.js @@ -0,0 +1,274 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that user-defined aliases are replaced by the search mode indicator. + */ + +const ALIAS = "testalias"; +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; + +// We make sure that aliases and search terms are correctly recognized when they +// are separated by each of these different types of spaces and combinations of +// spaces. U+3000 is the ideographic space in CJK and is commonly used by CJK +// speakers. +const TEST_SPACES = [" ", "\u3000", " \u3000", "\u3000 "]; + +let defaultEngine, aliasEngine; + +add_setup(async function () { + defaultEngine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + setAsDefault: true, + }); + defaultEngine.alias = "@default"; + await SearchTestUtils.installSearchExtension({ + keyword: ALIAS, + }); + aliasEngine = Services.search.getEngineByName("Example"); +}); + +// An incomplete alias should not be replaced. +add_task(async function incompleteAlias() { + // Check that a non-fully typed alias is not replaced. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS.slice(0, -1), + }); + await UrlbarTestUtils.assertSearchMode(window, null); + + // Type a space just to make sure it's not replaced. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey(" "); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal( + gURLBar.value, + ALIAS.slice(0, -1) + " ", + "The typed value should be unchanged except for the space." + ); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// A complete alias without a trailing space should not be replaced. +add_task(async function noTrailingSpace() { + let value = ALIAS; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + }); + await UrlbarTestUtils.assertSearchMode(window, null); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// A complete typed alias without a trailing space should not be replaced. +add_task(async function noTrailingSpace_typed() { + // Start by searching for the alias minus its last char. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS.slice(0, -1), + }); + await UrlbarTestUtils.assertSearchMode(window, null); + + // Now type the last char. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey(ALIAS.slice(-1)); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal( + gURLBar.value, + ALIAS, + "The typed value should be the full alias." + ); + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// A complete alias with a trailing space should be replaced. +add_task(async function trailingSpace() { + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS + spaces, + }); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: aliasEngine.name, + entry: "typed", + }); + Assert.ok(!gURLBar.value, "The urlbar value should be cleared."); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); + } +}); + +// A complete alias should be replaced after typing a space. +add_task(async function trailingSpace_typed() { + for (let spaces of TEST_SPACES) { + if (spaces.length != 1) { + continue; + } + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS, + }); + await UrlbarTestUtils.assertSearchMode(window, null); + + // We need to wait for two searches: The first enters search mode, the second + // does the search in search mode. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey(spaces); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: aliasEngine.name, + entry: "typed", + }); + Assert.ok(!gURLBar.value, "The urlbar value should be cleared."); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); + } +}); + +// A complete alias with a trailing space should be replaced, and the query +// after the trailing space should be the new value of the input. +add_task(async function trailingSpace_query() { + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS + spaces + "query", + }); + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: aliasEngine.name, + entry: "typed", + }); + Assert.equal( + gURLBar.value, + "query", + "The urlbar value should be the query." + ); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); + } +}); + +add_task(async function () { + info("Test search mode when typing an alias after selecting one-off button"); + + info("Open the result popup"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + + info("Select one of one-off button"); + const oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + await TestUtils.waitForCondition( + () => !oneOffs._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + ok(oneOffs.selectedButton, "There is a selected one-off button"); + const selectedEngine = oneOffs.selectedButton.engine; + await UrlbarTestUtils.assertSearchMode(window, { + engineName: selectedEngine.name, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + entry: "oneoff", + isPreview: true, + }); + + info("Type a search engine alias and query"); + const inputString = "@default query"; + inputString.split("").forEach(c => EventUtils.synthesizeKey(c)); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal( + gURLBar.value, + inputString, + "Alias and query is inputed correctly to the urlbar" + ); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: selectedEngine.name, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + entry: "oneoff", + }); + + // When starting typing, as the search mode is confirmed, the one-off + // selection is removed. + ok(!oneOffs.selectedButton, "There is no any selected one-off button"); + + // Clean up + gURLBar.value = ""; + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_task(async function () { + info( + "Test search mode after removing current search mode when multiple aliases are written" + ); + + info("Open the result popup with multiple aliases"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@default testalias @default", + }); + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: defaultEngine.name, + entry: "typed", + }); + Assert.equal( + gURLBar.value, + "testalias @default", + "The value on the urlbar is correct" + ); + + info("Exit search mode by clicking"); + const indicator = gURLBar.querySelector("#urlbar-search-mode-indicator"); + EventUtils.synthesizeMouseAtCenter(indicator, { type: "mouseover" }, window); + const closeButton = gURLBar.querySelector( + "#urlbar-search-mode-indicator-close" + ); + const searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeMouseAtCenter(closeButton, {}, window); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: aliasEngine.name, + entry: "typed", + }); + Assert.equal(gURLBar.value, "@default", "The value on the urlbar is correct"); + + // Clean up + gURLBar.value = ""; + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +/** + * Returns an array of code points in the given string. Each code point is + * returned as a hexidecimal string. + * + * @param {string} str + * The code points of this string will be returned. + * @returns {Array} + * Array of code points in the string, where each is a hexidecimal string. + */ +function codePoints(str) { + return str.split("").map(s => s.charCodeAt(0).toString(16)); +} diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_autofill.js b/browser/components/urlbar/tests/browser/browser_searchMode_autofill.js new file mode 100644 index 0000000000..96c9b7212f --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_autofill.js @@ -0,0 +1,133 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that autofill is cleared if a remote search mode is entered but still + * works for local search modes. + */ + +"use strict"; + +add_setup(async function () { + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([{ uri: "http://example.com/" }]); + } + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + let defaultEngine = Services.search.getEngineByName("Example"); + await Services.search.moveEngine(defaultEngine, 0); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +// Tests that autofill is cleared when entering a remote search mode and that +// autofill doesn't happen when in that mode. +add_task(async function remote() { + info("Sanity check: we autofill in a normal search."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ex", + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill, "We're autofilling."); + Assert.equal( + gURLBar.value, + "example.com/", + "Urlbar contains the autofilled URL." + ); + info("Enter remote search mode and check autofill is cleared."); + await UrlbarTestUtils.enterSearchMode(window); + Assert.equal(gURLBar.value, "ex", "Urlbar contains the typed string."); + + info("Continue typing and check that we're not autofilling."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exa", + fireInputEvent: true, + }); + + details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(!details.autofill, "We're not autofilling."); + Assert.equal(gURLBar.value, "exa", "Urlbar contains the typed string."); + + info("Exit remote search mode and check that we now autofill."); + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill, "We're autofilling."); + Assert.equal( + gURLBar.value, + "example.com/", + "Urlbar contains the typed string." + ); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); +}); + +// Tests that autofill works as normal when entering and when in a local search +// mode. +add_task(async function local() { + info("Sanity check: we autofill in a normal search."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ex", + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill, "We're autofilling."); + Assert.equal( + gURLBar.value, + "example.com/", + "Urlbar contains the autofilled URL." + ); + info("Enter local search mode and check autofill is preserved."); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }); + Assert.equal( + gURLBar.value, + "example.com/", + "Urlbar contains the autofilled URL." + ); + + info("Continue typing and check that we're autofilling."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exa", + fireInputEvent: true, + }); + + details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill, "We're autofilling."); + Assert.equal( + gURLBar.value, + "example.com/", + "Urlbar contains the autofilled URL." + ); + + info("Exit local search mode and check that nothing has changed."); + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill, "We're autofilling."); + Assert.equal( + gURLBar.value, + "example.com/", + "Urlbar contains the typed string." + ); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_clickLink.js b/browser/components/urlbar/tests/browser/browser_searchMode_clickLink.js new file mode 100644 index 0000000000..d037c77bbb --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_clickLink.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that search mode is exited after clicking a link and loading a page in + * the current tab. + */ + +"use strict"; + +const LINK_PAGE_URL = + "http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/dummy_page.html"; + +// Opens a new tab containing a link, enters search mode, and clicks the link. +// Uses a variety of search strings and link hrefs in order to hit different +// branches in setURI. Search mode should be exited in all cases, and the href +// in the link should be opened. +add_task(async function clickLink() { + for (let test of [ + // searchString, href to use in the link + [LINK_PAGE_URL, LINK_PAGE_URL], + [LINK_PAGE_URL, "http://www.example.com/"], + ["test", LINK_PAGE_URL], + ["test", "http://www.example.com/"], + [null, LINK_PAGE_URL], + [null, "http://www.example.com/"], + ]) { + await doClickLinkTest(...test); + } +}); + +async function doClickLinkTest(searchString, href) { + info( + "doClickLinkTest with args: " + + JSON.stringify({ + searchString, + href, + }) + ); + + await BrowserTestUtils.withNewTab(LINK_PAGE_URL, async () => { + if (searchString) { + // Do a search with the search string. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + Assert.ok( + gBrowser.selectedBrowser.userTypedValue, + "userTypedValue should be defined" + ); + } else { + // Open top sites. + await UrlbarTestUtils.promisePopupOpen(window, () => { + document.getElementById("Browser:OpenLocation").doCommand(); + }); + Assert.strictEqual( + gBrowser.selectedBrowser.userTypedValue, + null, + "userTypedValue should be null" + ); + } + + // Enter search mode and then close the popup so we can click the link in + // the page. + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + + // Add a link to the page and click it. + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await ContentTask.spawn(gBrowser.selectedBrowser, href, async cHref => { + let link = this.content.document.createElement("a"); + link.textContent = "Click me"; + link.href = cHref; + this.content.document.body.append(link); + link.click(); + }); + await loadPromise; + Assert.equal( + gBrowser.currentURI.spec, + href, + "Should have loaded the href URL" + ); + + await UrlbarTestUtils.assertSearchMode(window, null); + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_engineRemoval.js b/browser/components/urlbar/tests/browser/browser_searchMode_engineRemoval.js new file mode 100644 index 0000000000..f5eab77789 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_engineRemoval.js @@ -0,0 +1,109 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that we exit search mode when the search mode engine is removed. + */ + +"use strict"; + +// Tests that we exit search mode in the active tab when the search mode engine +// is removed. +add_task(async function activeTab() { + let extension = await SearchTestUtils.installSearchExtension( + {}, + { skipUnload: true } + ); + let engine = Services.search.getEngineByName("Example"); + await Services.search.moveEngine(engine, 0); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ex", + }); + await UrlbarTestUtils.enterSearchMode(window); + // Sanity check: we are in the correct search mode. + await UrlbarTestUtils.assertSearchMode(window, { + engineName: engine.name, + entry: "oneoff", + }); + await extension.unload(); + // Check that we are no longer in search mode. + await UrlbarTestUtils.assertSearchMode(window, null); +}); + +// Tests that we exit search mode in a background tab when the search mode +// engine is removed. +add_task(async function backgroundTab() { + let extension = await SearchTestUtils.installSearchExtension( + {}, + { skipUnload: true } + ); + let engine = Services.search.getEngineByName("Example"); + await Services.search.moveEngine(engine, 0); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ex", + }); + await UrlbarTestUtils.enterSearchMode(window); + let tab1 = gBrowser.selectedTab; + let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + // Sanity check: tab1 is still in search mode. + await BrowserTestUtils.switchTab(gBrowser, tab1); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: engine.name, + entry: "oneoff", + }); + + // Switch back to tab2 so tab1 is in the background when the engine is + // removed. + await BrowserTestUtils.switchTab(gBrowser, tab2); + // tab2 shouldn't be in search mode. + await UrlbarTestUtils.assertSearchMode(window, null); + await extension.unload(); + + // tab1 should have exited search mode. + await BrowserTestUtils.switchTab(gBrowser, tab1); + await UrlbarTestUtils.assertSearchMode(window, null); + BrowserTestUtils.removeTab(tab2); +}); + +// Tests that we exit search mode in a background window when the search mode +// engine is removed. +add_task(async function backgroundWindow() { + let extension = await SearchTestUtils.installSearchExtension( + {}, + { skipUnload: true } + ); + let engine = Services.search.getEngineByName("Example"); + await Services.search.moveEngine(engine, 0); + + let win1 = window; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win1, + value: "ex", + }); + await UrlbarTestUtils.enterSearchMode(win1); + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + + // Sanity check: win1 is still in search mode. + win1.focus(); + await UrlbarTestUtils.assertSearchMode(win1, { + engineName: engine.name, + entry: "oneoff", + }); + + // Switch back to win2 so win1 is in the background when the engine is + // removed. + win2.focus(); + // win2 shouldn't be in search mode. + await UrlbarTestUtils.assertSearchMode(win2, null); + await extension.unload(); + + // win1 should not be in search mode. + win1.focus(); + await UrlbarTestUtils.assertSearchMode(win1, null); + await BrowserTestUtils.closeWindow(win2); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_excludeResults.js b/browser/components/urlbar/tests/browser/browser_searchMode_excludeResults.js new file mode 100644 index 0000000000..0e9471280e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_excludeResults.js @@ -0,0 +1,217 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that results with hostnames other than the search mode engine are not + * shown. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", +}); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", false], + ["browser.urlbar.autoFill", false], + // Special prefs for remote tabs. + ["services.sync.username", "fake"], + ["services.sync.syncedTabs.showRemoteTabs", true], + ], + }); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + // Note that the result domain is subdomain.example.ca. We still expect to + // match with example.com results because we ignore subdomains and the public + // suffix in this check. + await SearchTestUtils.installSearchExtension( + { + search_url: "https://subdomain.example.ca/", + }, + { setAsDefault: true } + ); + let engine = Services.search.getEngineByName("Example"); + await Services.search.moveEngine(engine, 0); + + const REMOTE_TAB = { + id: "7cqCr77ptzX3", + type: "client", + lastModified: 1492201200, + name: "Nightly on MacBook-Pro", + clientType: "desktop", + tabs: [ + { + type: "tab", + title: "Test Remote", + url: "https://example.com", + icon: UrlbarUtils.ICON.DEFAULT, + client: "7cqCr77ptzX3", + lastUsed: Math.floor(Date.now() / 1000), + }, + { + type: "tab", + title: "Test Remote 2", + url: "https://example-2.com", + icon: UrlbarUtils.ICON.DEFAULT, + client: "7cqCr77ptzX3", + lastUsed: Math.floor(Date.now() / 1000), + }, + ], + }; + + const sandbox = sinon.createSandbox(); + + let originalSyncedTabsInternal = SyncedTabs._internal; + SyncedTabs._internal = { + isConfiguredToSyncTabs: true, + hasSyncedThisSession: true, + getTabClients() { + return Promise.resolve([]); + }, + syncTabs() { + return Promise.resolve(); + }, + }; + + // Tell the Sync XPCOM service it is initialized. + let weaveXPCService = Cc["@mozilla.org/weave/service;1"].getService( + Ci.nsISupports + ).wrappedJSObject; + let oldWeaveServiceReady = weaveXPCService.ready; + weaveXPCService.ready = true; + + sandbox + .stub(SyncedTabs._internal, "getTabClients") + .callsFake(() => Promise.resolve(Cu.cloneInto([REMOTE_TAB], {}))); + + // Reset internal cache in UrlbarProviderRemoteTabs. + Services.obs.notifyObservers(null, "weave:engine:sync:finish", "tabs"); + + registerCleanupFunction(async function () { + sandbox.restore(); + weaveXPCService.ready = oldWeaveServiceReady; + SyncedTabs._internal = originalSyncedTabsInternal; + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function basic() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "example", + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 3, + "We have three results" + ); + let firstResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + firstResult.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "The first result is the heuristic search result." + ); + let secondResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + secondResult.type, + UrlbarUtils.RESULT_TYPE.REMOTE_TAB, + "The second result is a remote tab." + ); + let thirdResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + thirdResult.type, + UrlbarUtils.RESULT_TYPE.REMOTE_TAB, + "The third result is a remote tab." + ); + + await UrlbarTestUtils.enterSearchMode(window); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "We have two results. The second remote tab result is excluded despite matching the search string." + ); + firstResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + firstResult.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "The first result is the heuristic search result." + ); + secondResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + secondResult.type, + UrlbarUtils.RESULT_TYPE.REMOTE_TAB, + "The second result is a remote tab." + ); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// For engines with an invalid TLD, we filter on the entire domain. +add_task(async function malformedEngine() { + await SearchTestUtils.installSearchExtension({ + name: "TestMalformed", + search_url: "https://example.foobar/", + }); + let badEngine = Services.search.getEngineByName("TestMalformed"); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "example", + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 4, + "We have four results" + ); + let firstResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + firstResult.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "The first result is the heuristic search result." + ); + let secondResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + secondResult.type, + UrlbarUtils.RESULT_TYPE.DYNAMIC, + "The second result is the tab-to-search onboarding result for our malformed engine." + ); + let thirdResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 2); + Assert.equal( + thirdResult.type, + UrlbarUtils.RESULT_TYPE.REMOTE_TAB, + "The third result is a remote tab." + ); + let fourthResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 3); + Assert.equal( + fourthResult.type, + UrlbarUtils.RESULT_TYPE.REMOTE_TAB, + "The fourth result is a remote tab." + ); + + await UrlbarTestUtils.enterSearchMode(window, { + engineName: badEngine.name, + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "We only have one result." + ); + firstResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(firstResult.heuristic, "The first result is heuristic."); + Assert.equal( + firstResult.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "The first result is the heuristic search result." + ); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_heuristic.js b/browser/components/urlbar/tests/browser/browser_searchMode_heuristic.js new file mode 100644 index 0000000000..c979e86235 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_heuristic.js @@ -0,0 +1,219 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests heuristic results in search mode. + */ + +"use strict"; + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + // Add a new mock default engine so we don't hit the network. + await SearchTestUtils.installSearchExtension( + { name: "Test" }, + { setAsDefault: true } + ); + + // Add one bookmark we'll use below. + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "https://example.com/bookmark", + }); + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +// Enters search mode with no results. +add_task(async function noResults() { + // Do a search that doesn't match our bookmark and enter bookmark search mode. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "doesn't match anything", + }); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 0, + "Zero results since no bookmark matches" + ); + + // Press enter. Nothing should happen. + let promise = waitForLoadStartOrTimeout(); + EventUtils.synthesizeKey("KEY_Enter"); + await Assert.rejects(promise, /timed out/, "Nothing should have loaded"); + + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Enters a local search mode (bookmarks) with a matching result. No heuristic +// should be present. +add_task(async function localNoHeuristic() { + // Do a search that matches our bookmark and enter bookmarks search mode. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "bookmark", + }); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "There should be one result" + ); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.source, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + "Result source should be BOOKMARKS" + ); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.URL, + "Result type should be URL" + ); + Assert.equal( + result.url, + "https://example.com/bookmark", + "Result URL is our bookmark URL" + ); + Assert.ok(!result.heuristic, "Result should not be heuristic"); + + // Press enter. Nothing should happen. + let promise = waitForLoadStartOrTimeout(); + EventUtils.synthesizeKey("KEY_Enter"); + await Assert.rejects(promise, /timed out/, "Nothing should have loaded"); + + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Enters a local search mode (bookmarks) with a matching autofill result. The +// result should be the heuristic. +add_task(async function localAutofill() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + // Do a search that autofills our bookmark's origin and enter bookmarks + // search mode. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "example", + }); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "There should be two results" + ); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.source, + UrlbarUtils.RESULT_SOURCE.HISTORY, + "Result source should be HISTORY" + ); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.URL, + "Result type should be URL" + ); + Assert.equal( + result.url, + "https://example.com/", + "Result URL is our bookmark's origin" + ); + Assert.ok(result.heuristic, "Result should be heuristic"); + Assert.ok(result.autofill, "Result should be autofill"); + + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + result.source, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + "Result source should be BOOKMARKS" + ); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.URL, + "Result type should be URL" + ); + Assert.equal( + result.url, + "https://example.com/bookmark", + "Result URL is our bookmark URL" + ); + + // Press enter. Our bookmark's origin should be loaded. + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + Assert.equal( + gBrowser.currentURI.spec, + "https://example.com/", + "Bookmark's origin should have loaded" + ); + }); +}); + +// Enters a remote engine search mode. There should be a heuristic. +add_task(async function remote() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + // Do a search and enter search mode with our test engine. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "remote", + }); + await UrlbarTestUtils.enterSearchMode(window, { + engineName: "Test", + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "There should be one result" + ); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.source, + UrlbarUtils.RESULT_SOURCE.SEARCH, + "Result source should be SEARCH" + ); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Result type should be SEARCH" + ); + Assert.ok(result.searchParams, "searchParams should be present"); + Assert.equal( + result.searchParams.engine, + "Test", + "searchParams.engine should be our test engine" + ); + Assert.equal( + result.searchParams.query, + "remote", + "searchParams.query should be our query" + ); + Assert.ok(result.heuristic, "Result should be heuristic"); + + // Press enter. The engine's SERP should load. + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + Assert.equal( + gBrowser.currentURI.spec, + "https://example.com/?q=remote", + "Engine's SERP should have loaded" + ); + }); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_indicator.js b/browser/components/urlbar/tests/browser/browser_searchMode_indicator.js new file mode 100644 index 0000000000..707a4ea38e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_indicator.js @@ -0,0 +1,377 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests interactions with the search mode indicator. See browser_oneOffs.js for + * more coverage. + */ + +const TEST_QUERY = "test string"; +const SUGGESTIONS_ENGINE_NAME = "searchSuggestionEngine.xml"; + +// These need to have different domains because otherwise new tab and/or +// activity stream collapses them. +const TOP_SITES_URLS = [ + "http://top-site-0.com/", + "http://top-site-1.com/", + "http://top-site-2.com/", +]; + +let suggestionsEngine; +let defaultEngine; + +add_setup(async function () { + suggestionsEngine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + SUGGESTIONS_ENGINE_NAME, + }); + + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + defaultEngine = Services.search.getEngineByName("Example"); + await Services.search.moveEngine(suggestionsEngine, 0); + + // Set our top sites. + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.default.sites", + TOP_SITES_URLS.join(","), + ], + ], + }); + await updateTopSites(sites => + ObjectUtils.deepEqual( + sites.map(s => s.url), + TOP_SITES_URLS + ) + ); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.ui.enabled", false], + ["browser.urlbar.suggest.quickactions", false], + ], + }); +}); + +async function verifySearchModeResultsAdded(window) { + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 3, + "There should be three results." + ); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.searchParams.engine, + suggestionsEngine.name, + "The first result should be a search result for our suggestion engine." + ); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + result.searchParams.suggestion, + `${TEST_QUERY}foo`, + "The second result should be a suggestion result." + ); + Assert.equal( + result.searchParams.engine, + suggestionsEngine.name, + "The second result should be a search result for our suggestion engine." + ); +} + +async function verifySearchModeResultsRemoved(window) { + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "There should only be one result." + ); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.searchParams.engine, + defaultEngine.name, + "The first result should be a search result for our default engine." + ); +} + +async function verifyTopSitesResultsAdded(window) { + Assert.equal( + UrlbarTestUtils.getResultCount(window), + TOP_SITES_URLS.length, + "Expected number of top sites results" + ); + for (let i = 0; i < TOP_SITES_URLS; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.equal( + result.url, + TOP_SITES_URLS[i], + `Expected top sites result URL at index ${i}` + ); + } +} + +// Tests that the indicator is removed when backspacing at the beginning of +// the search string. +add_task(async function backspace() { + // View open, with string. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + await UrlbarTestUtils.enterSearchMode(window); + await verifySearchModeResultsAdded(window); + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + await verifySearchModeResultsRemoved(window); + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Urlbar view is open."); + + // View open, no string (i.e., top sites). + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await UrlbarTestUtils.enterSearchMode(window); + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Urlbar view is open."); + await verifyTopSitesResultsAdded(window); + await UrlbarTestUtils.promisePopupClose(window); + + // View closed, with string. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + await UrlbarTestUtils.enterSearchMode(window); + await verifySearchModeResultsAdded(window); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + await verifySearchModeResultsRemoved(window); + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Urlbar view is now open."); + + // View closed, no string (i.e., top sites). + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await UrlbarTestUtils.enterSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Urlbar view is open."); + await verifyTopSitesResultsAdded(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_task(async function escapeOnInitialPage() { + info("Tests the indicator's interaction with the ESC key"); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + await UrlbarTestUtils.enterSearchMode(window); + await verifySearchModeResultsAdded(window); + + EventUtils.synthesizeKey("KEY_Escape"); + Assert.ok(!UrlbarTestUtils.isPopupOpen(window, "UrlbarView is closed.")); + Assert.equal(gURLBar.value, TEST_QUERY, "Urlbar value hasn't changed."); + + let oneOffs = + UrlbarTestUtils.getOneOffSearchButtons(window).getSelectableButtons(true); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: oneOffs[0].engine.name, + entry: "oneoff", + }); + + EventUtils.synthesizeKey("KEY_Escape"); + Assert.ok(!UrlbarTestUtils.isPopupOpen(window, "UrlbarView is closed.")); + Assert.ok(!gURLBar.value, "Urlbar value is empty."); + await UrlbarTestUtils.assertSearchMode(window, null); +}); + +add_task(async function escapeOnBrowsingPage() { + info("Tests the indicator's interaction with the ESC key on browsing page"); + + await BrowserTestUtils.withNewTab("http://example.com", async browser => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + await UrlbarTestUtils.enterSearchMode(window); + await verifySearchModeResultsAdded(window); + + EventUtils.synthesizeKey("KEY_Escape"); + Assert.ok(!UrlbarTestUtils.isPopupOpen(window, "UrlbarView is closed.")); + Assert.equal(gURLBar.value, TEST_QUERY, "Urlbar value hasn't changed."); + + const oneOffs = + UrlbarTestUtils.getOneOffSearchButtons(window).getSelectableButtons(true); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: oneOffs[0].engine.name, + entry: "oneoff", + }); + + EventUtils.synthesizeKey("KEY_Escape"); + Assert.ok(!UrlbarTestUtils.isPopupOpen(window, "UrlbarView is closed.")); + Assert.equal( + gURLBar.value, + UrlbarTestUtils.trimURL("http://example.com"), + "Urlbar value indicates the browsing page." + ); + await UrlbarTestUtils.assertSearchMode(window, null); + }); +}); + +// Tests that the indicator is removed when its close button is clicked. +add_task(async function click_close() { + // Clicking close with the view open, with string. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + await UrlbarTestUtils.enterSearchMode(window); + await verifySearchModeResultsAdded(window); + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await verifySearchModeResultsRemoved(window); + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Urlbar view is open."); + await UrlbarTestUtils.promisePopupClose(window); + + // Clicking close with the view open, no string (i.e., top sites). + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await UrlbarTestUtils.enterSearchMode(window); + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Urlbar view is open."); + await verifyTopSitesResultsAdded(window); + await UrlbarTestUtils.promisePopupClose(window); + + // Clicking close with the view closed, with string. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + await UrlbarTestUtils.enterSearchMode(window); + await verifySearchModeResultsAdded(window); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.exitSearchMode(window, { + clickClose: true, + waitForSearch: false, + }); + Assert.ok(!UrlbarTestUtils.isPopupOpen(window), "Urlbar view is closed."); + + // Clicking close with the view closed, no string (i.e., top sites). + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await UrlbarTestUtils.enterSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.exitSearchMode(window, { + clickClose: true, + waitForSearch: false, + }); + Assert.ok(!UrlbarTestUtils.isPopupOpen(window), "Urlbar view is closed."); +}); + +// Tests that Accel+K enters search mode with the default engine. Also tests +// that Accel+K highlights the typed search string. +add_task(async function keyboard_shortcut() { + const query = "test query"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: query, + }); + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal( + gURLBar.selectionStart, + gURLBar.selectionEnd, + "The search string is not highlighted." + ); + EventUtils.synthesizeKey("k", { accelKey: true }); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + engineName: defaultEngine.name, + entry: "shortcut", + }); + Assert.equal(gURLBar.value, query, "The search string was not cleared."); + Assert.equal(gURLBar.selectionStart, 0); + Assert.equal( + gURLBar.selectionEnd, + query.length, + "The search string is highlighted." + ); + await UrlbarTestUtils.exitSearchMode(window, { + clickClose: true, + waitForSearch: false, + }); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); +}); + +// Tests that the Tools:Search menu item enters search mode with the default +// engine. Also tests that Tools:Search highlights the typed search string. +add_task(async function menubar_item() { + const query = "test query 2"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: query, + }); + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal( + gURLBar.selectionStart, + gURLBar.selectionEnd, + "The search string is not highlighted." + ); + let command = window.document.getElementById("Tools:Search"); + command.doCommand(); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + engineName: defaultEngine.name, + entry: "shortcut", + }); + Assert.equal(gURLBar.value, query, "The search string was not cleared."); + Assert.equal(gURLBar.selectionStart, 0); + Assert.equal( + gURLBar.selectionEnd, + query.length, + "The search string is highlighted." + ); + await UrlbarTestUtils.exitSearchMode(window, { + clickClose: true, + waitForSearch: false, + }); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); +}); + +// Tests that entering search mode invalidates pageproxystate and that +// pageproxystate remains invalid after exiting search mode. +add_task(async function invalidate_pageproxystate() { + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + Assert.equal(gURLBar.getAttribute("pageproxystate"), "valid"); + await UrlbarTestUtils.enterSearchMode(window); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "invalid", + "Entering search mode should clear pageproxystate." + ); + Assert.equal(gURLBar.value, "", "Urlbar value should be cleared."); + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "invalid", + "Pageproxystate should still be invalid after exiting search mode." + ); + }); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_indicator_clickthrough.js b/browser/components/urlbar/tests/browser/browser_searchMode_indicator_clickthrough.js new file mode 100644 index 0000000000..214448ee61 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_indicator_clickthrough.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check clicking on the search mode indicator when the urlbar is not focused puts + * focus in the urlbar. + */ + +add_task(async function test() { + // Avoid remote connections. + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.suggest.enabled", false]], + }); + + await BrowserTestUtils.withNewTab("about:robots", async browser => { + // View open, with string. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + const indicator = document.getElementById("urlbar-search-mode-indicator"); + Assert.ok(!BrowserTestUtils.isVisible(indicator)); + const indicatorCloseButton = document.getElementById( + "urlbar-search-mode-indicator-close" + ); + Assert.ok(!BrowserTestUtils.isVisible(indicatorCloseButton)); + const labelBox = document.getElementById("urlbar-label-box"); + Assert.ok(!BrowserTestUtils.isVisible(labelBox)); + + await UrlbarTestUtils.enterSearchMode(window); + Assert.ok(BrowserTestUtils.isVisible(indicator)); + Assert.ok(BrowserTestUtils.isVisible(indicatorCloseButton)); + Assert.ok(!BrowserTestUtils.isVisible(labelBox)); + + info("Blur the urlbar"); + gURLBar.blur(); + Assert.ok(BrowserTestUtils.isVisible(indicator)); + Assert.ok(BrowserTestUtils.isVisible(indicatorCloseButton)); + Assert.ok(!BrowserTestUtils.isVisible(labelBox)); + Assert.notEqual( + document.activeElement, + gURLBar.inputField, + "URL Bar should not be focused" + ); + + info("Focus the urlbar clicking on the indicator"); + // We intentionally turn off a11y_checks for the following click, because + // it is send to send a focus on the URL Bar with the mouse, while other + // ways to focus it are accessible for users of assistive technology and + // keyboards, thus this test can be excluded from the accessibility tests. + AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false }); + EventUtils.synthesizeMouseAtCenter(indicator, {}); + AccessibilityUtils.resetEnv(); + Assert.ok(BrowserTestUtils.isVisible(indicator)); + Assert.ok(BrowserTestUtils.isVisible(indicatorCloseButton)); + Assert.ok(!BrowserTestUtils.isVisible(labelBox)); + Assert.equal( + document.activeElement, + gURLBar.inputField, + "URL Bar should be focused" + ); + + info("Leave search mode clicking on the close button"); + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + Assert.ok(!BrowserTestUtils.isVisible(indicator)); + Assert.ok(!BrowserTestUtils.isVisible(indicatorCloseButton)); + Assert.ok(!BrowserTestUtils.isVisible(labelBox)); + }); + + await BrowserTestUtils.withNewTab("about:robots", async browser => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + const indicator = document.getElementById("urlbar-search-mode-indicator"); + Assert.ok(!BrowserTestUtils.isVisible(indicator)); + const indicatorCloseButton = document.getElementById( + "urlbar-search-mode-indicator-close" + ); + Assert.ok(!BrowserTestUtils.isVisible(indicatorCloseButton)); + + await UrlbarTestUtils.enterSearchMode(window); + Assert.ok(BrowserTestUtils.isVisible(indicator)); + Assert.ok(BrowserTestUtils.isVisible(indicatorCloseButton)); + + info("Blur the urlbar"); + gURLBar.blur(); + Assert.ok(BrowserTestUtils.isVisible(indicator)); + Assert.ok(BrowserTestUtils.isVisible(indicatorCloseButton)); + Assert.notEqual( + document.activeElement, + gURLBar.inputField, + "URL Bar should not be focused" + ); + + info("Leave search mode clicking on the close button while unfocussing"); + await UrlbarTestUtils.exitSearchMode(window, { + clickClose: true, + waitForSearch: false, + }); + Assert.ok(!BrowserTestUtils.isVisible(indicator)); + Assert.ok(!BrowserTestUtils.isVisible(indicatorCloseButton)); + }); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_localOneOffs_actionText.js b/browser/components/urlbar/tests/browser/browser_searchMode_localOneOffs_actionText.js new file mode 100644 index 0000000000..2068d4c1d5 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_localOneOffs_actionText.js @@ -0,0 +1,459 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests action text shown on heuristic and search suggestions when keyboard + * navigating local one-off buttons. + */ + +"use strict"; + +const DEFAULT_ENGINE_NAME = "Test"; +const SUGGESTIONS_ENGINE_NAME = "searchSuggestionEngine.xml"; + +let engine; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.suggest.quickactions", false], + ["browser.urlbar.shortcuts.quickactions", false], + ], + }); + engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + SUGGESTIONS_ENGINE_NAME, + setAsDefault: true, + }); + await Services.search.moveEngine(engine, 0); + + await PlacesUtils.history.clear(); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function localOneOff() { + info("Type some text, select a local one-off, check heuristic action"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "query", + }); + Assert.ok(UrlbarTestUtils.getResultCount(window) > 1, "Sanity check results"); + + info("Alt UP to select the last local one-off."); + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "the heuristic result should be selected" + ); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + let oneOffButtons = UrlbarTestUtils.getOneOffSearchButtons(window); + Assert.equal( + oneOffButtons.selectedButton.source, + UrlbarUtils.RESULT_SOURCE.HISTORY, + "A local one-off button should be selected" + ); + Assert.ok( + BrowserTestUtils.isVisible(result.element.action), + "The heuristic action should be visible" + ); + let [actionHistory, actionBookmarks] = await document.l10n.formatValues([ + { id: "urlbar-result-action-search-history" }, + { id: "urlbar-result-action-search-bookmarks" }, + ]); + Assert.equal( + result.displayed.action, + actionHistory, + "Check the heuristic action" + ); + Assert.equal( + result.image, + "chrome://browser/skin/history.svg", + "Check the heuristic icon" + ); + + info("Move to an engine one-off and check heuristic action"); + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + Assert.ok( + oneOffButtons.selectedButton.engine, + "A one-off search button should be selected" + ); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok( + BrowserTestUtils.isVisible(result.element.action), + "The heuristic action should be visible" + ); + Assert.ok( + result.displayed.action.includes(oneOffButtons.selectedButton.engine.name), + "Check the heuristic action" + ); + Assert.equal( + result.image, + oneOffButtons.selectedButton.engine.getIconURL(), + "Check the heuristic icon" + ); + + info("Move again to a local one-off, deselect and reselect the heuristic"); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + oneOffButtons.selectedButton.source, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + "A local one-off button should be selected" + ); + Assert.equal( + result.displayed.action, + actionBookmarks, + "Check the heuristic action" + ); + Assert.equal( + result.image, + "chrome://browser/skin/bookmark.svg", + "Check the heuristic icon" + ); + + info( + "Select the next result, then reselect the heuristic, check it's a search with the default engine" + ); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "the heuristic result should be selected" + ); + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + "the heuristic result should not be selected" + ); + EventUtils.synthesizeKey("KEY_ArrowUp", {}); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "the heuristic result should be selected" + ); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.SEARCH); + Assert.equal(result.searchParams.engine, engine.name); + Assert.ok( + result.displayed.action.includes(engine.name), + "Check the heuristic action" + ); + Assert.equal( + result.image, + "chrome://global/skin/icons/search-glass.svg", + "Check the heuristic icon" + ); +}); + +add_task(async function localOneOff_withVisit() { + info("Type a url, select a local one-off, check heuristic action"); + await PlacesUtils.history.clear(); + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits("https://mozilla.org/"); + await PlacesTestUtils.addVisits("https://other.mozilla.org/"); + } + const searchString = "mozilla.org"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + }); + Assert.ok(UrlbarTestUtils.getResultCount(window) > 1, "Sanity check results"); + let oneOffButtons = UrlbarTestUtils.getOneOffSearchButtons(window); + + let [actionHistory, actionTabs, actionBookmarks] = + await document.l10n.formatValues([ + { id: "urlbar-result-action-search-history" }, + { id: "urlbar-result-action-search-tabs" }, + { id: "urlbar-result-action-search-bookmarks" }, + ]); + + info("Alt UP to select the history one-off."); + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "the heuristic result should be selected" + ); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + oneOffButtons.selectedButton.source, + UrlbarUtils.RESULT_SOURCE.HISTORY, + "The history one-off button should be selected" + ); + Assert.ok( + BrowserTestUtils.isVisible(result.element.action), + "The heuristic action should be visible" + ); + Assert.equal( + result.displayed.action, + actionHistory, + "Check the heuristic action" + ); + Assert.equal( + result.image, + "chrome://browser/skin/history.svg", + "Check the heuristic icon" + ); + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + Assert.equal( + row.querySelector(".urlbarView-title").textContent, + searchString, + "Check that the result title has been replaced with the search string." + ); + + info("Alt UP to select the tabs one-off."); + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + oneOffButtons.selectedButton.source, + UrlbarUtils.RESULT_SOURCE.TABS, + "The tabs one-off button should be selected" + ); + Assert.ok( + BrowserTestUtils.isVisible(result.element.action), + "The heuristic action should be visible" + ); + Assert.equal( + result.displayed.action, + actionTabs, + "Check the heuristic action" + ); + Assert.equal( + result.image, + "chrome://browser/skin/tab.svg", + "Check the heuristic icon" + ); + + info("Alt UP to select the bookmarks one-off."); + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + oneOffButtons.selectedButton.source, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + "The bookmarks one-off button should be selected" + ); + Assert.ok( + BrowserTestUtils.isVisible(result.element.action), + "The heuristic action should be visible" + ); + Assert.equal( + result.displayed.action, + actionBookmarks, + "Check the heuristic action" + ); + Assert.equal( + result.image, + "chrome://browser/skin/bookmark.svg", + "Check the heuristic icon" + ); + + info( + "Select the next result, then reselect the heuristic, check it's a visit" + ); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "the heuristic result should be selected" + ); + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + "the heuristic result should not be selected" + ); + EventUtils.synthesizeKey("KEY_ArrowUp", {}); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "the heuristic result should be selected" + ); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + oneOffButtons.selectedButton, + null, + "No one-off button should be selected" + ); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.URL); + Assert.equal( + result.displayed.url, + result.result.payload.displayUrl, + "Check the heuristic action" + ); + Assert.notEqual( + result.image, + "chrome://browser/skin/history.svg", + "Check the heuristic icon" + ); + row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + Assert.equal( + row.querySelector(".urlbarView-title").textContent, + result.result.payload.title || `https://${searchString}`, + "Check that the result title has been restored to the fixed-up URI." + ); + + await PlacesUtils.history.clear(); +}); + +add_task(async function localOneOff_suggestion() { + info("Type some text, select the first suggestion, then a local one-off"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "query", + }); + let count = UrlbarTestUtils.getResultCount(window); + Assert.ok(count > 1, "Sanity check results"); + let result = null; + let suggestionIndex = -1; + for (let i = 1; i < count; ++i) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + let index = await UrlbarTestUtils.getSelectedRowIndex(window); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.searchParams.suggestion + ) { + suggestionIndex = i; + break; + } + } + Assert.ok( + result.searchParams.suggestion, + "Should have selected a search suggestion" + ); + Assert.ok( + result.displayed.action.includes(result.searchParams.engine), + "Check the search suggestion action" + ); + + info("Alt UP to select the last local one-off."); + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + Assert.equal( + await UrlbarTestUtils.getSelectedRowIndex(window), + suggestionIndex, + "the suggestion should still be selected" + ); + + let [actionHistory] = await document.l10n.formatValues([ + { id: "urlbar-result-action-search-history" }, + ]); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, suggestionIndex); + Assert.equal( + result.displayed.action, + actionHistory, + "Check the search suggestion action changed to local one-off" + ); + // Like in the normal engine one-offs case, we don't replace the favicon. + Assert.equal( + result.image, + "chrome://global/skin/icons/search-glass.svg", + "Check the suggestion icon" + ); + + info("DOWN to select the next suggestion"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + suggestionIndex + 1 + ); + Assert.ok( + result.searchParams.suggestion, + "Should have selected a search suggestion" + ); + Assert.ok( + result.displayed.action.includes(result.searchParams.engine), + "Check the search suggestion action" + ); + Assert.equal( + result.image, + "chrome://global/skin/icons/search-glass.svg", + "Check the suggestion icon" + ); + + info("UP back to the previous suggestion"); + EventUtils.synthesizeKey("KEY_ArrowUp"); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, suggestionIndex); + Assert.ok( + result.displayed.action.includes(result.searchParams.engine), + "Check the search suggestion action" + ); + Assert.equal( + result.image, + "chrome://global/skin/icons/search-glass.svg", + "Check the suggestion icon" + ); +}); + +add_task(async function localOneOff_shortcut() { + info("Select a search shortcut, then a local one-off"); + + await PlacesUtils.history.clear(); + // Enough vists to get this site into Top Sites. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits("http://example.com/"); + } + + await updateTopSites( + sites => sites && sites[0] && sites[0].searchTopSite, + true + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + let count = UrlbarTestUtils.getResultCount(window); + Assert.ok(count > 1, "Sanity check results"); + let result = null; + let shortcutIndex = -1; + for (let i = 0; i < count; ++i) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + let index = await UrlbarTestUtils.getSelectedRowIndex(window); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.searchParams.keyword + ) { + shortcutIndex = i; + break; + } + } + Assert.ok(result.searchParams.keyword, "Should have selected a shortcut"); + let shortcutEngine = result.searchParams.engine; + + info("Alt UP to select the last local one-off."); + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + Assert.equal( + await UrlbarTestUtils.getSelectedRowIndex(window), + shortcutIndex, + "the shortcut should still be selected" + ); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, shortcutIndex); + Assert.equal( + result.displayed.action, + "", + "Check the shortcut action is empty" + ); + Assert.equal( + result.searchParams.engine, + shortcutEngine, + "Check the shortcut engine" + ); + Assert.ok( + result.displayed.title.includes(shortcutEngine), + "Check the shortcut title" + ); + Assert.notEqual( + result.image, + "chrome://global/skin/icons/search-glass.svg", + "Check the icon was not replaced" + ); + + await UrlbarTestUtils.exitSearchMode(window); + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_newWindow.js b/browser/components/urlbar/tests/browser/browser_searchMode_newWindow.js new file mode 100644 index 0000000000..e5a3eb848a --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_newWindow.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests immediately entering search mode in a new window and then exiting it. +// No errors should be thrown and search mode should be exited successfully. + +"use strict"; + +add_task(async function escape() { + await doTest(win => + EventUtils.synthesizeKey("KEY_Escape", { repeat: 2 }, win) + ); +}); + +add_task(async function backspace() { + await doTest(win => EventUtils.synthesizeKey("KEY_Backspace", {}, win)); +}); + +async function doTest(exitSearchMode) { + let win = await BrowserTestUtils.openNewBrowserWindow(); + + // Press accel+K to enter search mode. + await UrlbarTestUtils.promisePopupOpen(win, () => + EventUtils.synthesizeKey("k", { accelKey: true }, win) + ); + await UrlbarTestUtils.assertSearchMode(win, { + engineName: Services.search.defaultEngine.name, + isGeneralPurposeEngine: true, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + isPreview: false, + entry: "shortcut", + }); + + // Exit search mode. + await exitSearchMode(win); + await UrlbarTestUtils.assertSearchMode(win, null); + + await UrlbarTestUtils.promisePopupClose(win); + await BrowserTestUtils.closeWindow(win); +} diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_no_results.js b/browser/components/urlbar/tests/browser/browser_searchMode_no_results.js new file mode 100644 index 0000000000..9ecc5573fc --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_no_results.js @@ -0,0 +1,290 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests entering search mode and there are no results in the view. + */ + +"use strict"; + +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; + +add_setup(async function () { + // In order to open the view without any results, we need to be in search mode + // with an empty search string so that no heuristic result is shown, and the + // empty search must yield zero additional results. We'll enter search mode + // using the bookmarks one-off, and first we'll delete all bookmarks so that + // there are no results. + await PlacesUtils.bookmarks.eraseEverything(); + + // Also clear history so that using the alias of our test engine doesn't + // inadvertently return any history results due to bug 1658646. + await PlacesUtils.history.clear(); + + // Add a top site so we're guaranteed the view has at least one result to + // show initially with an empty search. Otherwise the view won't even open. + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.default.sites", + "http://example.com/", + ], + ], + }); + await updateTopSites(sites => sites.length); +}); + +// Basic test for entering search mode with no results. +add_task(async function basic() { + await withNewWindow(async win => { + // Do an empty search. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "", + }); + + // Initially there should be at least the top site we added above. + Assert.greater( + UrlbarTestUtils.getResultCount(win), + 0, + "Top sites should be present initially" + ); + Assert.ok( + !win.gURLBar.panel.hasAttribute("noresults"), + "Panel has results, therefore should not have noresults attribute" + ); + + // Enter search mode by clicking the bookmarks one-off. + await UrlbarTestUtils.enterSearchMode(win, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(win), + 0, + "Zero results since no bookmarks exist" + ); + Assert.equal( + win.gURLBar.panel.getAttribute("noresults"), + "true", + "Panel has no results, therefore should have noresults attribute" + ); + + // Exit search mode by backspacing. The top sites should be shown again. + await UrlbarTestUtils.exitSearchMode(win, { backspace: true }); + Assert.greater( + UrlbarTestUtils.getResultCount(win), + 0, + "Top sites should be present again" + ); + Assert.ok( + !win.gURLBar.panel.hasAttribute("noresults"), + "noresults attribute should be absent again" + ); + + await UrlbarTestUtils.promisePopupClose(win); + }); +}); + +// When the urlbar is in search mode, has no results, and is not focused, +// focusing it should auto-open the view. +add_task(async function autoOpen() { + await withNewWindow(async win => { + // Do an empty search. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "", + }); + + // Initially there should be at least the top site we added above. + Assert.greater( + UrlbarTestUtils.getResultCount(win), + 0, + "Top sites should be present initially" + ); + Assert.ok( + !win.gURLBar.panel.hasAttribute("noresults"), + "Panel has results, therefore should not have noresults attribute" + ); + + // Enter search mode by clicking the bookmarks one-off. + await UrlbarTestUtils.enterSearchMode(win, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + Assert.equal( + UrlbarTestUtils.getResultCount(win), + 0, + "Zero results since no bookmarks exist" + ); + Assert.equal( + win.gURLBar.panel.getAttribute("noresults"), + "true", + "Panel has no results, therefore should have noresults attribute" + ); + + // Blur the urlbar. + win.gURLBar.blur(); + await UrlbarTestUtils.assertSearchMode(win, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + + // Click the urlbar. + await UrlbarTestUtils.promisePopupOpen(win, () => { + EventUtils.synthesizeMouseAtCenter(win.gURLBar.inputField, {}, win); + }); + Assert.equal( + UrlbarTestUtils.getResultCount(win), + 0, + "Still zero results since no bookmarks exist" + ); + Assert.equal( + win.gURLBar.panel.getAttribute("noresults"), + "true", + "Panel still has no results, therefore should have noresults attribute" + ); + + // Exit search mode by backspacing. The top sites should be shown again. + await UrlbarTestUtils.exitSearchMode(win, { backspace: true }); + Assert.greater( + UrlbarTestUtils.getResultCount(win), + 0, + "Top sites should be present again" + ); + Assert.ok( + !win.gURLBar.panel.hasAttribute("noresults"), + "noresults attribute should be absent again" + ); + + await UrlbarTestUtils.promisePopupClose(win); + }); +}); + +// When the urlbar is in search mode, the user backspaces over the final char +// (but remains in search mode), and there are no results, the view should +// remain open. +add_task(async function backspaceRemainOpen() { + await withNewWindow(async win => { + // Do a one-char search. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "x", + }); + Assert.greater( + UrlbarTestUtils.getResultCount(win), + 0, + "At least the heuristic result should be shown" + ); + Assert.ok( + !win.gURLBar.panel.hasAttribute("noresults"), + "Panel has results, therefore should not have noresults attribute" + ); + + // Enter search mode by clicking the bookmarks one-off. + await UrlbarTestUtils.enterSearchMode(win, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + + // The heursitic should not be shown since we don't show it in local search + // modes. + Assert.equal( + UrlbarTestUtils.getResultCount(win), + 0, + "No results should be present" + ); + Assert.ok( + win.gURLBar.panel.hasAttribute("noresults"), + "Panel has no results, therefore should have noresults attribute" + ); + + // Backspace. The search string will now be empty. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(win); + EventUtils.synthesizeKey("KEY_Backspace", {}, win); + await searchPromise; + Assert.ok(UrlbarTestUtils.isPopupOpen(win), "View remains open"); + await UrlbarTestUtils.assertSearchMode(win, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + Assert.equal( + UrlbarTestUtils.getResultCount(win), + 0, + "Zero results since no bookmarks exist" + ); + Assert.equal( + win.gURLBar.panel.getAttribute("noresults"), + "true", + "Panel has no results, therefore should have noresults attribute" + ); + + // Exit search mode by backspacing. The top sites should be shown. + await UrlbarTestUtils.exitSearchMode(win, { backspace: true }); + Assert.greater( + UrlbarTestUtils.getResultCount(win), + 0, + "Top sites should be present again" + ); + Assert.ok( + !win.gURLBar.panel.hasAttribute("noresults"), + "noresults attribute should be absent again" + ); + + await UrlbarTestUtils.promisePopupClose(win); + }); +}); + +// Types a search alias and then a space to enter search mode, with no results. +// The one-offs should be shown. +add_task(async function spaceToEnterSearchMode() { + let engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + }); + engine.alias = "@test"; + + await withNewWindow(async win => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: engine.alias, + }); + + // We need to wait for two searches: The first enters search mode, the + // second does the search in search mode. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(win); + EventUtils.synthesizeKey(" ", {}, win); + await searchPromise; + + Assert.equal(UrlbarTestUtils.getResultCount(win), 0, "Zero results"); + Assert.equal( + win.gURLBar.panel.getAttribute("noresults"), + "true", + "Panel has no results, therefore should have noresults attribute" + ); + await UrlbarTestUtils.assertSearchMode(win, { + engineName: engine.name, + entry: "typed", + }); + this.Assert.equal( + UrlbarTestUtils.getOneOffSearchButtonsVisible(window), + true, + "One-offs are visible" + ); + + await UrlbarTestUtils.exitSearchMode(win, { backspace: true }); + await UrlbarTestUtils.promisePopupClose(win); + }); +}); + +/** + * Opens a new window, waits for it to load, calls a callback, and closes the + * window. We use a new window in each task so that the view starts with a + * blank slate each time. + * + * @param {Function} callback + * Will be called as: callback(newWindow) + */ +async function withNewWindow(callback) { + // Start in a new window so we have a blank slate. + let win = await BrowserTestUtils.openNewBrowserWindow(); + await callback(win); + await BrowserTestUtils.closeWindow(win); +} diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_oneOffButton.js b/browser/components/urlbar/tests/browser/browser_searchMode_oneOffButton.js new file mode 100644 index 0000000000..1ba0d3283b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_oneOffButton.js @@ -0,0 +1,108 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests one-off search button behavior with search mode. + */ + +const TEST_ENGINE_NAME = "test engine"; + +add_setup(async function () { + await SearchTestUtils.installSearchExtension({ + name: TEST_ENGINE_NAME, + keyword: "@test", + }); +}); + +add_task(async function test() { + info("Test no one-off buttons are selected when entering search mode"); + + info("Open the result popup"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + + info("Select one of one-off button"); + const oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + await TestUtils.waitForCondition( + () => !oneOffs._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + ok(oneOffs.selectedButton, "There is a selected one-off button"); + + info("Enter search mode"); + await UrlbarTestUtils.enterSearchMode(window, { + engineName: TEST_ENGINE_NAME, + }); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "oneoff", + }); + ok(!oneOffs.selectedButton, "There is no selected one-off button"); +}); + +add_task(async function () { + info( + "Test the status of the selected one-off button when exiting search mode with backspace" + ); + + info("Open the result popup"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + + info("Select one of one-off button"); + const oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + await TestUtils.waitForCondition( + () => !oneOffs._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + ok(oneOffs.selectedButton, "There is a selected one-off button"); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: oneOffs.selectedButton.engine.name, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + entry: "oneoff", + isPreview: true, + }); + + info("Exit from search mode"); + await UrlbarTestUtils.exitSearchMode(window); + ok(!oneOffs.selectedButton, "There is no any selected one-off button"); +}); + +add_task(async function () { + info( + "Test the status of the selected one-off button when exiting search mode with clicking close button" + ); + + info("Open the result popup"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + + info("Select one of one-off button"); + const oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + await TestUtils.waitForCondition( + () => !oneOffs._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + ok(oneOffs.selectedButton, "There is a selected one-off button"); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: oneOffs.selectedButton.engine.name, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + entry: "oneoff", + isPreview: true, + }); + + info("Exit from search mode"); + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + ok(!oneOffs.selectedButton, "There is no any selected one-off button"); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_pickResult.js b/browser/components/urlbar/tests/browser/browser_searchMode_pickResult.js new file mode 100644 index 0000000000..ac45b3e5c7 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_pickResult.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that search mode is exited after picking a result. + */ + +"use strict"; + +const BOOKMARK_URL = "http://www.example.com/browser_searchMode_pickResult.js"; + +add_setup(async function () { + // Add a bookmark so we can enter bookmarks search mode and pick it. + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: BOOKMARK_URL, + }); + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +// Opens a new tab, enters search mode, does a search for our test bookmark, and +// picks it. Uses a variety of initial URLs and search strings in order to hit +// different branches in setURI. Search mode should be exited in all cases. +add_task(async function pickResult() { + for (let test of [ + // initialURL, searchString + ["about:blank", BOOKMARK_URL], + ["about:blank", new URL(BOOKMARK_URL).origin], + ["about:blank", new URL(BOOKMARK_URL).pathname], + [BOOKMARK_URL, BOOKMARK_URL], + [BOOKMARK_URL, new URL(BOOKMARK_URL).origin], + [BOOKMARK_URL, new URL(BOOKMARK_URL).pathname], + ]) { + await doPickResultTest(...test); + } +}); + +async function doPickResultTest(initialURL, searchString) { + info( + "doPickResultTest with args: " + + JSON.stringify({ + initialURL, + searchString, + }) + ); + + await BrowserTestUtils.withNewTab(initialURL, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + + // Arrow down to the bookmark result. + let firstResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + if (!firstResult.heuristic) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + let foundResult = false; + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (result.url == BOOKMARK_URL) { + foundResult = true; + break; + } + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + Assert.ok(foundResult, "The bookmark result should have been found"); + + // Press enter. + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + Assert.equal( + gBrowser.currentURI.spec, + BOOKMARK_URL, + "Should have loaded the bookmarked URL" + ); + + await UrlbarTestUtils.assertSearchMode(window, null); + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_preview.js b/browser/components/urlbar/tests/browser/browser_searchMode_preview.js new file mode 100644 index 0000000000..19df744663 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_preview.js @@ -0,0 +1,489 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests search mode preview. + */ + +"use strict"; + +const TEST_ENGINE_NAME = "Test"; + +add_setup(async function () { + await SearchTestUtils.installSearchExtension({ + name: TEST_ENGINE_NAME, + keyword: "@test", + }); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +/** + * @param {Node} button + * A one-off button. + * @param {boolean} [isPreview] + * Whether the expected search mode should be a preview. Defaults to true. + * @returns {object} + * The search mode object expected when that one-off is selected. + */ +function getExpectedSearchMode(button, isPreview = true) { + let expectedSearchMode = { + entry: "oneoff", + isPreview, + }; + if (button.engine) { + expectedSearchMode.engineName = button.engine.name; + let engine = Services.search.getEngineByName(button.engine.name); + if (engine.isGeneralPurposeEngine) { + expectedSearchMode.source = UrlbarUtils.RESULT_SOURCE.SEARCH; + } + } else { + expectedSearchMode.source = button.source; + } + + return expectedSearchMode; +} + +// Tests that cycling through token alias engines enters search mode preview. +add_task(async function tokenAlias() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + }); + + let result; + while (gURLBar.searchMode?.engineName != TEST_ENGINE_NAME) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + let index = UrlbarTestUtils.getSelectedRowIndex(window); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + let expectedSearchMode = { + engineName: result.searchParams.engine, + isPreview: true, + entry: "keywordoffer", + }; + let engine = Services.search.getEngineByName(result.searchParams.engine); + if (engine.isGeneralPurposeEngine) { + expectedSearchMode.source = UrlbarUtils.RESULT_SOURCE.SEARCH; + } + await UrlbarTestUtils.assertSearchMode(window, expectedSearchMode); + } + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + // Test that we are in confirmed search mode. + await UrlbarTestUtils.assertSearchMode(window, { + engineName: result.searchParams.engine, + entry: "keywordoffer", + }); + await UrlbarTestUtils.exitSearchMode(window); +}); + +// Tests that starting to type a query exits search mode preview in favour of +// full search mode. +add_task(async function startTyping() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + }); + while (gURLBar.searchMode?.engineName != TEST_ENGINE_NAME) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + isPreview: true, + entry: "keywordoffer", + }); + + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("M"); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "keywordoffer", + }); + await UrlbarTestUtils.exitSearchMode(window); +}); + +// Tests that highlighting a search shortcut Top Site enters search mode +// preview. +add_task(async function topSites() { + // Enable search shortcut Top Sites. + await PlacesUtils.history.clear(); + await updateTopSites( + sites => sites && sites[0] && sites[0].searchTopSite, + true + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + fireInputEvent: true, + }); + + // We previously verified that the first Top Site is a search shortcut. + EventUtils.synthesizeKey("KEY_ArrowDown"); + let searchTopSite = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: searchTopSite.searchParams.engine, + isPreview: true, + entry: "topsites_urlbar", + }); + await UrlbarTestUtils.exitSearchMode(window); +}); + +// Tests that search mode preview is exited when the view is closed. +add_task(async function closeView() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + }); + + while (gURLBar.searchMode?.engineName != TEST_ENGINE_NAME) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + isPreview: true, + entry: "keywordoffer", + }); + + // We should close search mode when closing the view. + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + await UrlbarTestUtils.assertSearchMode(window, null); + + // Check search mode isn't re-entered when re-opening the view. + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await UrlbarTestUtils.assertSearchMode(window, null); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Tests that search more preview is exited when the user switches tabs. +add_task(async function tabSwitch() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + }); + + while (gURLBar.searchMode?.engineName != TEST_ENGINE_NAME) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + isPreview: true, + entry: "keywordoffer", + }); + + // Open a new tab then switch back to the original tab. + let tab1 = gBrowser.selectedTab; + let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await BrowserTestUtils.switchTab(gBrowser, tab1); + + await UrlbarTestUtils.assertSearchMode(window, null); + BrowserTestUtils.removeTab(tab2); +}); + +// Tests that search mode is previewed when the user down arrows through the +// one-offs. +add_task(async function oneOff_downArrow() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + let oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + await TestUtils.waitForCondition( + () => !oneOffs._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + let resultCount = UrlbarTestUtils.getResultCount(window); + + // Key down through all results. + for (let i = 0; i < resultCount; i++) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + + // Key down again. The first one-off should be selected. + EventUtils.synthesizeKey("KEY_ArrowDown"); + + // Check for the one-off's search mode previews. + while (oneOffs.selectedButton != oneOffs.settingsButton) { + await UrlbarTestUtils.assertSearchMode( + window, + getExpectedSearchMode(oneOffs.selectedButton) + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + + // Check that selecting the search settings button leaves search mode preview. + Assert.equal( + oneOffs.selectedButton, + oneOffs.settingsButton, + "The settings button is selected." + ); + await UrlbarTestUtils.assertSearchMode(window, null); + + // Closing the view should also exit search mode preview. + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.assertSearchMode(window, null); +}); + +// Tests that search mode is previewed when the user Alt+down arrows through the +// one-offs. This subtest also highlights a keywordoffer result (the first Top +// Site) before Alt+Arrowing to the one-offs. This checks that the search mode +// previews from keywordoffer results are overwritten by selected one-offs. +add_task(async function oneOff_alt_downArrow() { + // Add some visits to a URL so we have multiple Top Sites. + await PlacesUtils.history.clear(); + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits("https://example.com/"); + } + await updateTopSites( + sites => + sites && + sites[0]?.searchTopSite && + sites[1]?.url == "https://example.com/", + true + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + let oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + await TestUtils.waitForCondition( + () => !oneOffs._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + + // Key down to the first result and check that it enters search mode preview. + EventUtils.synthesizeKey("KEY_ArrowDown"); + let searchTopSite = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: searchTopSite.searchParams.engine, + isPreview: true, + entry: "topsites_urlbar", + }); + + // Alt+down. The first one-off should be selected. + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + // Check for the one-offs' search mode previews. + while (oneOffs.selectedButton) { + await UrlbarTestUtils.assertSearchMode( + window, + getExpectedSearchMode(oneOffs.selectedButton) + ); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + } + + // Now key down without a modifier. We should move to the second result and + // have no search mode preview. + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + "The second result is selected." + ); + await UrlbarTestUtils.assertSearchMode(window, null); + + // Arrow back up to the keywordoffer result and check for search mode preview. + EventUtils.synthesizeKey("KEY_ArrowUp"); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: searchTopSite.searchParams.engine, + isPreview: true, + entry: "topsites_urlbar", + }); + + await PlacesUtils.history.clear(); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.assertSearchMode(window, null); +}); + +// Tests that search mode is previewed when the user is in full search mode +// and down arrows through the one-offs. +add_task(async function fullSearchMode_oneOff_downArrow() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + let oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + await TestUtils.waitForCondition( + () => !oneOffs._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + let oneOffButtons = oneOffs.getSelectableButtons(true); + + await UrlbarTestUtils.enterSearchMode(window); + let expectedSearchMode = getExpectedSearchMode(oneOffButtons[0], false); + // Sanity check: we are in the correct search mode. + await UrlbarTestUtils.assertSearchMode(window, expectedSearchMode); + + // Key down through all results. + let resultCount = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < resultCount; i++) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + // If the result is a shortcut, it will enter preview mode. + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + await UrlbarTestUtils.assertSearchMode( + window, + Object.assign(expectedSearchMode, { + isPreview: !!result.searchParams.keyword, + }) + ); + } + + // Key down again. The first one-off should be selected. + EventUtils.synthesizeKey("KEY_ArrowDown"); + // Check that we show the correct preview as we cycle through the one-offs. + while (oneOffs.selectedButton != oneOffs.settingsButton) { + await UrlbarTestUtils.assertSearchMode( + window, + getExpectedSearchMode(oneOffs.selectedButton, true) + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + + // We should still be in the same search mode after cycling through all the + // one-offs. + await UrlbarTestUtils.assertSearchMode(window, expectedSearchMode); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Tests that search mode is previewed when the user is in full search mode +// and alt+down arrows through the one-offs. This subtest also checks that +// exiting full search mode while alt+arrowing through the one-offs enters +// search mode preview for subsequent one-offs. +add_task(async function fullSearchMode_oneOff_alt_downArrow() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + let oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + let oneOffButtons = oneOffs.getSelectableButtons(true); + await TestUtils.waitForCondition( + () => !oneOffs._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + + await UrlbarTestUtils.enterSearchMode(window); + let expectedSearchMode = getExpectedSearchMode(oneOffButtons[0], false); + await UrlbarTestUtils.assertSearchMode(window, expectedSearchMode); + + // Key down to the first result. + EventUtils.synthesizeKey("KEY_ArrowDown"); + + // Alt+down. The first one-off should be selected. + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + // Cycle through the first half of the one-offs and verify that search mode + // preview is entered. + Assert.greater( + oneOffButtons.length, + 1, + "Sanity check: We should have at least two one-offs." + ); + for (let i = 1; i < oneOffButtons.length / 2; i++) { + await UrlbarTestUtils.assertSearchMode( + window, + getExpectedSearchMode(oneOffs.selectedButton, true) + ); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + } + // Now click out of search mode. + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + // Now check for the remaining one-offs' search mode previews. + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + while (oneOffs.selectedButton) { + await UrlbarTestUtils.assertSearchMode( + window, + getExpectedSearchMode(oneOffs.selectedButton, true) + ); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + } + + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.assertSearchMode(window, null); +}); + +// Tests that the original search mode is preserved when going through some +// one-off buttons and then back down in the results list. +add_task(async function fullSearchMode_oneOff_restore_on_down() { + info("Add a few visits to top sites"); + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([ + "https://1.example.com/", + "https://2.example.com/", + "https://3.example.com/", + ]); + } + await updateTopSites(sites => sites?.length > 2, false); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + let oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + let oneOffButtons = oneOffs.getSelectableButtons(true); + await TestUtils.waitForCondition( + () => !oneOffs._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }); + let expectedSearchMode = getExpectedSearchMode( + oneOffButtons.find(b => b.source == UrlbarUtils.RESULT_SOURCE.HISTORY), + false + ); + await UrlbarTestUtils.assertSearchMode(window, expectedSearchMode); + info("Down to the first result"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + await UrlbarTestUtils.assertSearchMode(window, expectedSearchMode); + info("Alt+down to the first one-off."); + Assert.greater( + oneOffButtons.length, + 1, + "Sanity check: We should have at least two one-offs." + ); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + await UrlbarTestUtils.assertSearchMode( + window, + getExpectedSearchMode(oneOffs.selectedButton, true) + ); + info("Go again down through the list of results"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + await UrlbarTestUtils.assertSearchMode(window, expectedSearchMode); + + // Now do a similar test without initial search mode. + info("Exit search mode."); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + info("Down to the first result"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + await UrlbarTestUtils.assertSearchMode(window, null); + info("select a one-off to start preview"); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + await UrlbarTestUtils.assertSearchMode( + window, + getExpectedSearchMode(oneOffs.selectedButton, true) + ); + info("Go again through the list of results"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + await UrlbarTestUtils.assertSearchMode(window, null); + + await UrlbarTestUtils.promisePopupClose(window); + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_sessionStore.js b/browser/components/urlbar/tests/browser/browser_searchMode_sessionStore.js new file mode 100644 index 0000000000..ef3fabe636 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_sessionStore.js @@ -0,0 +1,332 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests search mode and session store. Also tests that search mode is + * duplicated when duplicating tabs, since tab duplication is handled by session + * store. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs", +}); + +// This test takes a long time on the OS X 10.14 machines, so request a longer +// timeout. See bug 1671045. This may also fix a different failure on Linux in +// bug 1671087, but it's not clear. Regardless, a longer timeout won't hurt. +requestLongerTimeout(5); + +const SEARCH_STRING = "test browser_sessionStore.js"; +const URL = "http://example.com/"; + +// A URL in gInitialPages. We test this separately since SessionStore sometimes +// takes different paths for these URLs. +const INITIAL_URL = "about:newtab"; + +// The following tasks make sure non-null search mode is restored. + +add_task(async function initialPageOnRestore() { + await doTest({ + urls: [INITIAL_URL], + searchModeTabIndex: 0, + exitSearchMode: false, + switchTabsAfterEnteringSearchMode: false, + }); +}); + +add_task(async function switchToInitialPage() { + await doTest({ + urls: [URL, INITIAL_URL], + searchModeTabIndex: 1, + exitSearchMode: false, + switchTabsAfterEnteringSearchMode: true, + }); +}); + +add_task(async function nonInitialPageOnRestore() { + await doTest({ + urls: [URL], + searchModeTabIndex: 0, + exitSearchMode: false, + switchTabsAfterEnteringSearchMode: false, + }); +}); + +add_task(async function switchToNonInitialPage() { + await doTest({ + urls: [INITIAL_URL, URL], + searchModeTabIndex: 1, + exitSearchMode: false, + switchTabsAfterEnteringSearchMode: true, + }); +}); + +// The following tasks enter and then exit search mode to make sure that no +// search mode is restored. + +add_task(async function initialPageOnRestore_exit() { + await doTest({ + urls: [INITIAL_URL], + searchModeTabIndex: 0, + exitSearchMode: true, + switchTabsAfterEnteringSearchMode: false, + }); +}); + +add_task(async function switchToInitialPage_exit() { + await doTest({ + urls: [URL, INITIAL_URL], + searchModeTabIndex: 1, + exitSearchMode: true, + switchTabsAfterEnteringSearchMode: true, + }); +}); + +add_task(async function nonInitialPageOnRestore_exit() { + await doTest({ + urls: [URL], + searchModeTabIndex: 0, + exitSearchMode: true, + switchTabsAfterEnteringSearchMode: false, + }); +}); + +add_task(async function switchToNonInitialPage_exit() { + await doTest({ + urls: [INITIAL_URL, URL], + searchModeTabIndex: 1, + exitSearchMode: true, + switchTabsAfterEnteringSearchMode: true, + }); +}); + +/** + * The main test function. Opens some URLs in a new window, enters search mode + * in one of the tabs, closes the window, restores it, and makes sure that + * search mode is restored properly. + * + * @param {object} options + * Options object + * @param {Array} options.urls + * Array of string URLs to open. + * @param {number} options.searchModeTabIndex + * The index of the tab in which to enter search mode. + * @param {boolean} options.exitSearchMode + * If true, search mode will be immediately exited after entering it. Use + * this to make sure search mode is *not* restored after it's exited. + * @param {boolean} options.switchTabsAfterEnteringSearchMode + * If true, we'll switch to a tab other than the one that search mode was + * entered in before closing the window. `urls` should contain more than one + * URL in this case. + */ +async function doTest({ + urls, + searchModeTabIndex, + exitSearchMode, + switchTabsAfterEnteringSearchMode, +}) { + let searchModeURL = urls[searchModeTabIndex]; + let otherTabIndex = (searchModeTabIndex + 1) % urls.length; + let otherURL = urls[otherTabIndex]; + + await withNewWindow(urls, async win => { + if (win.gBrowser.selectedTab != win.gBrowser.tabs[searchModeTabIndex]) { + await BrowserTestUtils.switchTab( + win.gBrowser, + win.gBrowser.tabs[searchModeTabIndex] + ); + } + + Assert.equal( + win.gBrowser.currentURI.spec, + searchModeURL, + `Sanity check: Tab at index ${searchModeTabIndex} is correct` + ); + Assert.equal( + searchModeURL == INITIAL_URL, + win.gInitialPages.includes(win.gBrowser.currentURI.spec), + `Sanity check: ${searchModeURL} is or is not in gInitialPages as expected` + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: SEARCH_STRING, + fireInputEvent: true, + }); + await UrlbarTestUtils.enterSearchMode(win, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + + if (exitSearchMode) { + await UrlbarTestUtils.exitSearchMode(win); + } + + // Make sure session store is updated. + await TabStateFlusher.flush(win.gBrowser.selectedBrowser); + + if (switchTabsAfterEnteringSearchMode) { + await BrowserTestUtils.switchTab( + win.gBrowser, + win.gBrowser.tabs[otherTabIndex] + ); + } + }); + + let restoredURL = switchTabsAfterEnteringSearchMode + ? otherURL + : searchModeURL; + + let win = await restoreWindow(restoredURL); + + Assert.equal( + win.gBrowser.currentURI.spec, + restoredURL, + "Sanity check: Initially selected tab in restored window is correct" + ); + + if (switchTabsAfterEnteringSearchMode) { + // Switch back to the tab with search mode. + await BrowserTestUtils.switchTab( + win.gBrowser, + win.gBrowser.tabs[searchModeTabIndex] + ); + } + + if (exitSearchMode) { + // If we exited search mode, it should be null. + await new Promise(r => win.setTimeout(r, 500)); + await UrlbarTestUtils.assertSearchMode(win, null); + } else { + // If we didn't exit search mode, it should be restored. + await TestUtils.waitForCondition( + () => win.gURLBar.searchMode, + "Waiting for search mode to be restored" + ); + await UrlbarTestUtils.assertSearchMode(win, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + Assert.equal( + win.gURLBar.value, + SEARCH_STRING, + "Search string should be restored" + ); + } + + await BrowserTestUtils.closeWindow(win); +} + +async function openTabMenuFor(tab) { + let tabMenu = tab.ownerDocument.getElementById("tabContextMenu"); + + let tabMenuShown = BrowserTestUtils.waitForEvent(tabMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + tab, + { type: "contextmenu" }, + tab.ownerGlobal + ); + await tabMenuShown; + + return tabMenu; +} + +// Tests that search mode is duplicated when duplicating tabs. Note that tab +// duplication is handled by session store. +add_task(async function duplicateTabs() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.net/" + ); + gBrowser.selectedTab = tab; + // Enter search mode with a search string in the current tab. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: SEARCH_STRING, + fireInputEvent: true, + }); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + + // Now duplicate the current tab using the context menu item. + const menu = await openTabMenuFor(gBrowser.selectedTab); + let tabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + gBrowser.currentURI.spec + ); + menu.activateItem(document.getElementById("context_duplicateTab")); + let newTab = await tabPromise; + Assert.equal( + gBrowser.selectedTab, + newTab, + "Sanity check: The duplicated tab is now the selected tab" + ); + + // Wait for search mode, then check it and the input value. + await TestUtils.waitForCondition( + () => gURLBar.searchMode, + "Waiting for search mode to be duplicated/restored" + ); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + Assert.equal( + gURLBar.value, + SEARCH_STRING, + "Search string should be duplicated/restored" + ); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(newTab); + gURLBar.handleRevert(); +}); + +/** + * Opens a new browser window with the given URLs, calls a callback, and then + * closes the window. + * + * @param {Array} urls + * Array of string URLs to open. + * @param {Function} callback + * The callback. + */ +async function withNewWindow(urls, callback) { + let win = await BrowserTestUtils.openNewBrowserWindow(); + for (let url of urls) { + await BrowserTestUtils.openNewForegroundTab({ + url, + gBrowser: win.gBrowser, + waitForLoad: url != "about:newtab", + }); + if (url == "about:newtab") { + await TestUtils.waitForCondition( + () => win.gBrowser.currentURI.spec == "about:newtab", + "Waiting for about:newtab" + ); + } + } + BrowserTestUtils.removeTab(win.gBrowser.tabs[0]); + await callback(win); + await BrowserTestUtils.closeWindow(win); +} + +/** + * Uses SessionStore to reopen the last closed window. + * + * @param {string} expectedRestoredURL + * The URL you expect will be restored in the selected browser. + */ +async function restoreWindow(expectedRestoredURL) { + let winPromise = BrowserTestUtils.waitForNewWindow(); + let win = SessionStore.undoCloseWindow(0); + await winPromise; + await TestUtils.waitForCondition( + () => win.gBrowser.currentURI.spec == expectedRestoredURL, + "Waiting for restored selected browser to have expected URI" + ); + return win; +} diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_setURI.js b/browser/components/urlbar/tests/browser/browser_searchMode_setURI.js new file mode 100644 index 0000000000..46f0a84256 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_setURI.js @@ -0,0 +1,119 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that search mode remains active or is exited when setURI is called, + * depending on the situation. + */ + +"use strict"; + +// Opens a new tab, does a search, enters search mode, and then manually calls +// setURI. Uses a variety of initial URLs, search strings, and setURI arguments +// in order to hit different branches in setURI. Search mode should remain +// active or be exited as appropriate. +add_task(async function setURI() { + for (let test of [ + // initialURL, searchString, url, expectSearchMode + + ["about:blank", "", null, true], + ["about:blank", "", "about:blank", true], + ["about:blank", "", "http://www.example.com/", true], + + ["about:blank", "about:blank", null, false], + ["about:blank", "about:blank", "about:blank", false], + ["about:blank", "about:blank", "http://www.example.com/", false], + + ["about:blank", "http://www.example.com/", null, true], + ["about:blank", "http://www.example.com/", "about:blank", true], + ["about:blank", "http://www.example.com/", "http://www.example.com/", true], + + ["about:blank", "not a URL", null, true], + ["about:blank", "not a URL", "about:blank", true], + ["about:blank", "not a URL", "http://www.example.com/", true], + + ["http://www.example.com/", "", null, true], + ["http://www.example.com/", "", "about:blank", true], + ["http://www.example.com/", "", "http://www.example.com/", true], + + ["http://www.example.com/", "about:blank", null, false], + ["http://www.example.com/", "about:blank", "about:blank", false], + [ + "http://www.example.com/", + "about:blank", + "http://www.example.com/", + false, + ], + + ["http://www.example.com/", "http://www.example.com/", null, true], + ["http://www.example.com/", "http://www.example.com/", "about:blank", true], + [ + "http://www.example.com/", + "http://www.example.com/", + "http://www.example.com/", + true, + ], + + ["http://www.example.com/", "not a URL", null, true], + ["http://www.example.com/", "not a URL", "about:blank", true], + ["http://www.example.com/", "not a URL", "http://www.example.com/", true], + ]) { + await doSetURITest(...test); + } +}); + +async function doSetURITest(initialURL, searchString, url, expectSearchMode) { + info( + "doSetURITest with args: " + + JSON.stringify({ + initialURL, + searchString, + url, + expectSearchMode, + }) + ); + + await BrowserTestUtils.withNewTab(initialURL, async () => { + if (searchString) { + // Do a search with the search string. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + } else { + // Open top sites. + await UrlbarTestUtils.promisePopupOpen(window, () => { + document.getElementById("Browser:OpenLocation").doCommand(); + }); + } + + // Enter search mode and close the view. + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + await UrlbarTestUtils.promisePopupClose(window); + Assert.strictEqual( + gBrowser.selectedBrowser.userTypedValue, + searchString, + `userTypedValue should be ${searchString}` + ); + + // Call setURI. + let uri = url ? Services.io.newURI(url) : null; + gURLBar.setURI(uri); + + await UrlbarTestUtils.assertSearchMode( + window, + !expectSearchMode + ? null + : { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + } + ); + + gURLBar.handleRevert(); + await UrlbarTestUtils.assertSearchMode(window, null); + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_suggestions.js b/browser/components/urlbar/tests/browser/browser_searchMode_suggestions.js new file mode 100644 index 0000000000..6e9b3c1031 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_suggestions.js @@ -0,0 +1,581 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests search suggestions in search mode. + */ + +const DEFAULT_ENGINE_NAME = "Test"; +const SUGGESTIONS_ENGINE_NAME = "searchSuggestionEngine.xml"; +const MANY_SUGGESTIONS_ENGINE_NAME = "searchSuggestionEngineMany.xml"; +const MAX_RESULT_COUNT = UrlbarPrefs.get("maxRichResults"); + +let suggestionsEngine; +let expectedFormHistoryResults = []; + +add_setup(async function () { + suggestionsEngine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + SUGGESTIONS_ENGINE_NAME, + }); + + await SearchTestUtils.installSearchExtension( + { + name: DEFAULT_ENGINE_NAME, + keyword: "@test", + }, + { setAsDefault: true } + ); + await Services.search.moveEngine(suggestionsEngine, 0); + + async function cleanup() { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + } + await cleanup(); + registerCleanupFunction(cleanup); + + // Add some form history for our test engine. + for (let i = 0; i < MAX_RESULT_COUNT; i++) { + let value = `hello formHistory ${i}`; + await UrlbarTestUtils.formHistory.add([ + { value, source: suggestionsEngine.name }, + ]); + expectedFormHistoryResults.push({ + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + searchParams: { + suggestion: value, + engine: suggestionsEngine.name, + }, + }); + } + + // Add other form history. + await UrlbarTestUtils.formHistory.add([ + { value: "hello formHistory global" }, + { value: "hello formHistory other", source: "other engine" }, + ]); + + registerCleanupFunction(async () => { + await UrlbarTestUtils.formHistory.clear(); + }); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.ui.enabled", false], + ["browser.urlbar.suggest.quickactions", false], + ["browser.urlbar.suggest.trending", false], + ["browser.urlbar.suggest.recentsearches", false], + ], + }); +}); + +add_task(async function emptySearch() { + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.update2.emptySearchBehavior", 2]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await UrlbarTestUtils.enterSearchMode(window); + Assert.equal(gURLBar.value, "", "Urlbar value should be cleared."); + // For the empty search case, we expect to get the form history relative to + // the picked engine and no heuristic. + await checkResults(expectedFormHistoryResults); + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); + }); +}); + +add_task(async function emptySearch_withRestyledHistory() { + // URLs with the same host as the search engine. + await PlacesTestUtils.addVisits([ + `http://mochi.test/`, + `http://mochi.test/redirect`, + // Should not be returned because it's a redirect target. + { + uri: `http://mochi.test/target`, + transition: PlacesUtils.history.TRANSITIONS.REDIRECT_TEMPORARY, + referrer: `http://mochi.test/redirect`, + }, + // Can be restyled and dupes form history. + "http://mochi.test:8888/?terms=hello+formHistory+0", + // Can be restyled but does not dupe form history. + "http://mochi.test:8888/?terms=ciao", + ]); + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.update2.emptySearchBehavior", 2]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await UrlbarTestUtils.enterSearchMode(window); + Assert.equal(gURLBar.value, "", "Urlbar value should be cleared."); + // For the empty search case, we expect to get the form history relative to + // the picked engine, history without redirects, and no heuristic. + await checkResults([ + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + searchParams: { + suggestion: "ciao", + engine: suggestionsEngine.name, + }, + }, + ...expectedFormHistoryResults.slice(0, MAX_RESULT_COUNT - 3), + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: `http://mochi.test/`, + }, + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: `http://mochi.test/redirect`, + }, + ]); + + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function emptySearch_withRestyledHistory_noSearchHistory() { + // URLs with the same host as the search engine. + await PlacesTestUtils.addVisits([ + `http://mochi.test/`, + `http://mochi.test/redirect`, + // Can be restyled but does not dupe form history. + "http://mochi.test:8888/?terms=ciao", + ]); + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.update2.emptySearchBehavior", 2], + ["browser.urlbar.maxHistoricalSearchSuggestions", 0], + ], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await UrlbarTestUtils.enterSearchMode(window); + Assert.equal(gURLBar.value, "", "Urlbar value should be cleared."); + // maxHistoricalSearchSuggestions == 0, so form history should not be + // present. + await checkResults([ + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: `http://mochi.test/redirect`, + }, + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: `http://mochi.test/`, + }, + ]); + + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function emptySearch_behavior() { + // URLs with the same host as the search engine. + await PlacesTestUtils.addVisits([`http://mochi.test/`]); + + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.update2.emptySearchBehavior", 0]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await UrlbarTestUtils.enterSearchMode(window); + Assert.equal(gURLBar.value, "", "Urlbar value should be cleared."); + // For the empty search case, we expect to get the form history relative to + // the picked engine, history without redirects, and no heuristic. + await checkResults([]); + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + + // We should still show history for empty searches when not in search mode. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: " ", + }); + await checkResults([ + { + heuristic: true, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query: " ", + engine: DEFAULT_ENGINE_NAME, + }, + }, + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: `http://mochi.test/`, + }, + ]); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); + }); + + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.update2.emptySearchBehavior", 1]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await UrlbarTestUtils.enterSearchMode(window); + Assert.equal(gURLBar.value, "", "Urlbar value should be cleared."); + // For the empty search case, we expect to get the form history relative to + // the picked engine, history without redirects, and no heuristic. + await checkResults([...expectedFormHistoryResults]); + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function emptySearch_local() { + await PlacesTestUtils.addVisits([`http://mochi.test/`]); + + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.update2.emptySearchBehavior", 0]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }); + Assert.equal(gURLBar.value, "", "Urlbar value should be cleared."); + // Even when emptySearchBehavior is 0, we still show the user's most frecent + // history for an empty search. + await checkResults([ + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: `http://mochi.test/`, + }, + ]); + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function nonEmptySearch() { + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + let query = "hello"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: query, + }); + await UrlbarTestUtils.enterSearchMode(window); + Assert.equal(gURLBar.value, query, "Urlbar value should be set."); + // We expect to get the heuristic and all the suggestions. + await checkResults([ + { + heuristic: true, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query, + engine: suggestionsEngine.name, + }, + }, + ...expectedFormHistoryResults.slice(0, MAX_RESULT_COUNT - 3), + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query, + suggestion: `${query}foo`, + engine: suggestionsEngine.name, + }, + }, + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query, + suggestion: `${query}bar`, + engine: suggestionsEngine.name, + }, + }, + ]); + + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +add_task(async function nonEmptySearch_nonMatching() { + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + let query = "ciao"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: query, + }); + await UrlbarTestUtils.enterSearchMode(window); + Assert.equal(gURLBar.value, query, "Urlbar value should be set."); + // We expect to get the heuristic and the remote suggestions since the local + // ones don't match. + await checkResults([ + { + heuristic: true, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query, + engine: suggestionsEngine.name, + }, + }, + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query, + suggestion: `${query}foo`, + engine: suggestionsEngine.name, + }, + }, + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query, + suggestion: `${query}bar`, + engine: suggestionsEngine.name, + }, + }, + ]); + + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +add_task(async function nonEmptySearch_withHistory() { + let manySuggestionsEngine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + MANY_SUGGESTIONS_ENGINE_NAME, + }); + // URLs with the same host as the search engine. + let query = "ciao"; + await PlacesTestUtils.addVisits([ + `http://mochi.test/${query}`, + `http://mochi.test/${query}1`, + // Should not be returned because it has a different host, even if it + // matches the host in the path. + `http://example.com/mochi.test/${query}`, + ]); + + function makeSuggestionResult(suffix) { + return { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query, + suggestion: `${query}${suffix}`, + engine: manySuggestionsEngine.name, + }, + }; + } + + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: query, + }); + await UrlbarTestUtils.enterSearchMode(window, { + engineName: manySuggestionsEngine.name, + }); + Assert.equal(gURLBar.value, query, "Urlbar value should be set."); + + await checkResults([ + { + heuristic: true, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query, + engine: manySuggestionsEngine.name, + }, + }, + makeSuggestionResult("foo"), + makeSuggestionResult("bar"), + makeSuggestionResult("1"), + makeSuggestionResult("2"), + makeSuggestionResult("3"), + makeSuggestionResult("4"), + makeSuggestionResult("5"), + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: `http://mochi.test/${query}1`, + }, + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: `http://mochi.test/${query}`, + }, + ]); + + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + + info("Test again with history before suggestions"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchSuggestionsFirst", false]], + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: query, + }); + await UrlbarTestUtils.enterSearchMode(window, { + engineName: manySuggestionsEngine.name, + }); + Assert.equal(gURLBar.value, query, "Urlbar value should be set."); + + await checkResults([ + { + heuristic: true, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query, + engine: manySuggestionsEngine.name, + }, + }, + makeSuggestionResult("foo"), + makeSuggestionResult("bar"), + makeSuggestionResult("1"), + makeSuggestionResult("2"), + makeSuggestionResult("3"), + makeSuggestionResult("4"), + makeSuggestionResult("5"), + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: `http://mochi.test/${query}1`, + }, + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: `http://mochi.test/${query}`, + }, + ]); + + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function nonEmptySearch_url() { + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + let query = "http://www.example.com/"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: query, + }); + await UrlbarTestUtils.enterSearchMode(window); + + // The heuristic result for a search that's a valid URL should be a search + // result, not a URL result. + await checkResults([ + { + heuristic: true, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query, + engine: suggestionsEngine.name, + }, + }, + ]); + + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +async function checkResults(expectedResults) { + Assert.equal( + UrlbarTestUtils.getResultCount(window), + expectedResults.length, + "Check results count." + ); + for (let i = 0; i < expectedResults.length; ++i) { + info(`Checking result at index ${i}`); + let expected = expectedResults[i]; + let actual = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + + // Check each property defined in the expected result against the property + // in the actual result. + for (let key of Object.keys(expected)) { + // For searchParams, remove undefined properties in the actual result so + // that the expected result doesn't need to include them. + if (key == "searchParams") { + let actualSearchParams = actual.searchParams; + for (let spKey of Object.keys(actualSearchParams)) { + if (actualSearchParams[spKey] === undefined) { + delete actualSearchParams[spKey]; + } + } + } + Assert.deepEqual( + actual[key], + expected[key], + `${key} should match at result index ${i}.` + ); + } + } +} diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_switchTabs.js b/browser/components/urlbar/tests/browser/browser_searchMode_switchTabs.js new file mode 100644 index 0000000000..db278ad9ba --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_switchTabs.js @@ -0,0 +1,317 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that search mode is stored per tab and restored when switching tabs. + */ + +"use strict"; + +// Enters search mode using the one-off buttons. +add_task(async function switchTabs() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); + + // Open three tabs. We'll enter search mode in tabs 0 and 2. + let tabs = []; + for (let i = 0; i < 3; i++) { + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "http://example.com/" + i, + }); + tabs.push(tab); + } + + // Switch to tab 0. + await BrowserTestUtils.switchTab(gBrowser, tabs[0]); + + // Do a search and enter search mode. Pass fireInputEvent so that + // userTypedValue is set and restored when we switch back to this tab. This + // isn't really necessary but it simulates the user's typing, and it also + // means that we'll start a search when we switch back to this tab. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + fireInputEvent: true, + }); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + + // Switch to tab 1. Search mode should be exited. + await BrowserTestUtils.switchTab(gBrowser, tabs[1]); + await UrlbarTestUtils.assertSearchMode(window, null); + + // Switch back to tab 0. We should do a search (for "test") and re-enter + // search mode. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + await BrowserTestUtils.switchTab(gBrowser, tabs[0]); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + Assert.equal( + gURLBar.value, + "test", + "Value should remain the search string after switching back" + ); + + // Switch to tab 2. Search mode should be exited. + await BrowserTestUtils.switchTab(gBrowser, tabs[2]); + await UrlbarTestUtils.assertSearchMode(window, null); + + // Do another search (in tab 2) and enter search mode. Use a different source + // from tab 0 just to use something different. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test tab 2", + fireInputEvent: true, + }); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.TABS, + }); + + // Switch back to tab 0. We should do a search and still be in search mode. + searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + await BrowserTestUtils.switchTab(gBrowser, tabs[0]); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + Assert.equal( + gURLBar.value, + "test", + "Value should remain the search string after switching back" + ); + + // Switch to tab 1. Search mode should be exited. + await BrowserTestUtils.switchTab(gBrowser, tabs[1]); + await UrlbarTestUtils.assertSearchMode(window, null); + + // Switch back to tab 2. We should do a search and re-enter search mode. + searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + await BrowserTestUtils.switchTab(gBrowser, tabs[2]); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.TABS, + entry: "oneoff", + }); + Assert.equal( + gURLBar.value, + "test tab 2", + "Value should remain the search string after switching back" + ); + + // Exit search mode. + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + + // Switch to tab 0. We should do a search and re-enter search mode. + searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + await BrowserTestUtils.switchTab(gBrowser, tabs[0]); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + Assert.equal( + gURLBar.value, + "test", + "Value should remain the search string after switching back" + ); + + // Switch back to tab 2. We should do a search but search mode should be + // inactive. + searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + await BrowserTestUtils.switchTab(gBrowser, tabs[2]); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal( + gURLBar.value, + "test tab 2", + "Value should remain the search string after switching back" + ); + + // Switch back to tab 0. We should do a search and re-enter search mode. + searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + await BrowserTestUtils.switchTab(gBrowser, tabs[0]); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + Assert.equal( + gURLBar.value, + "test", + "Value should remain the search string after switching back" + ); + + // Exit search mode. + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + + // Switch back to tab 2. We should do a search but search mode should be + // inactive. + searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + await BrowserTestUtils.switchTab(gBrowser, tabs[2]); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal( + gURLBar.value, + "test tab 2", + "Value should remain the search string after switching back" + ); + + // Switch back to tab 0. We should do a search but search mode should be + // inactive. + searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + await BrowserTestUtils.switchTab(gBrowser, tabs[0]); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal( + gURLBar.value, + "test", + "Value should remain the search string after switching back" + ); + + await UrlbarTestUtils.promisePopupClose(window); + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } +}); + +// Start loading a SERP from search mode then immediately switch to a new tab so +// the SERP finishes loading in the background. Switch back to the SERP tab and +// observe that we don't re-enter search mode despite having an entry for that +// tab in UrlbarInput._searchModesByBrowser. See bug 1675926. +// +// This subtest intermittently does not test bug 1675926 (NB: this does not mean +// it is an intermittent failure). The false-positive occurs if the SERP page +// finishes loading before we switch tabs. We include this subtest so we have +// one covering real-world behaviour. A subtest that is guaranteed to test this +// behaviour that does not simulate real world behaviour is included below. +add_task(async function slow_load() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", false]], + }); + const engineName = "Test"; + let extension = await SearchTestUtils.installSearchExtension( + { + name: engineName, + }, + { skipUnload: true } + ); + + const originalTab = gBrowser.selectedTab; + const newTab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + fireInputEvent: true, + }); + await UrlbarTestUtils.enterSearchMode(window, { engineName }); + + const loadPromise = BrowserTestUtils.browserLoaded(newTab.linkedBrowser); + // Select the search mode heuristic to load the example.com SERP. + EventUtils.synthesizeKey("KEY_Enter"); + // Switch away from the tab before we let it load. + await BrowserTestUtils.switchTab(gBrowser, originalTab); + await loadPromise; + + // Switch back to the search mode tab and confirm we don't restore search + // mode. + await BrowserTestUtils.switchTab(gBrowser, newTab); + await UrlbarTestUtils.assertSearchMode(window, null); + + BrowserTestUtils.removeTab(newTab); + await SpecialPowers.popPrefEnv(); + await extension.unload(); +}); + +// Tests the same behaviour as slow_load, but in a more reliable way using +// non-real-world behaviour. +add_task(async function slow_load_guaranteed() { + const engineName = "Test"; + let extension = await SearchTestUtils.installSearchExtension( + { + name: engineName, + }, + { skipUnload: true } + ); + + const backgroundTab = BrowserTestUtils.addTab(gBrowser); + + // Simulate a tab that was in search mode, loaded a SERP, then was switched + // away from before setURI was called. + backgroundTab.ownerGlobal.gURLBar.searchMode = { engineName }; + let loadPromise = BrowserTestUtils.browserLoaded(backgroundTab.linkedBrowser); + BrowserTestUtils.startLoadingURIString( + backgroundTab.linkedBrowser, + "http://example.com/?search=test" + ); + await loadPromise; + + // Switch to the background mode tab and confirm we don't restore search mode. + await BrowserTestUtils.switchTab(gBrowser, backgroundTab); + await UrlbarTestUtils.assertSearchMode(window, null); + + BrowserTestUtils.removeTab(backgroundTab); + await extension.unload(); +}); + +// Enters search mode by typing a restriction char with no search string. +// Search mode and the search string should be restored after switching back to +// the tab. +add_task(async function userTypedValue_empty() { + await doUserTypedValueTest(""); +}); + +// Enters search mode by typing a restriction char followed by a search string. +// Search mode and the search string should be restored after switching back to +// the tab. +add_task(async function userTypedValue_nonEmpty() { + await doUserTypedValueTest("foo bar"); +}); + +/** + * Enters search mode by typing a restriction char followed by a search string, + * opens a new tab and immediately closes it so we switch back to the search + * mode tab, and checks the search mode state and input value. + * + * @param {string} searchString + * The search string to enter search mode with. + */ +async function doUserTypedValueTest(searchString) { + let value = `${UrlbarTokenizer.RESTRICT.BOOKMARK} ${searchString}`; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + fireInputEvent: true, + }); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "typed", + }); + Assert.equal( + gURLBar.value, + searchString, + "Sanity check: Value is the search string" + ); + + let tab = await BrowserTestUtils.openNewForegroundTab({ gBrowser }); + BrowserTestUtils.removeTab(tab); + + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "typed", + }); + Assert.equal( + gURLBar.value, + searchString, + "Value should remain the search string after switching back" + ); + + gURLBar.handleRevert(); +} diff --git a/browser/components/urlbar/tests/browser/browser_searchSettings.js b/browser/components/urlbar/tests/browser/browser_searchSettings.js new file mode 100644 index 0000000000..2cded38c99 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchSettings.js @@ -0,0 +1,30 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:blank" }, + async function () { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "a", + }); + + // Since the current tab is blank the preferences pane will load there + let loaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await UrlbarTestUtils.promisePopupClose(window, () => { + let button = document.getElementById("urlbar-anon-search-settings"); + EventUtils.synthesizeMouseAtCenter(button, {}); + }); + await loaded; + + is( + gBrowser.selectedBrowser.currentURI.spec, + "about:preferences#search", + "Should have loaded the right page" + ); + } + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchSingleWordNotification.js b/browser/components/urlbar/tests/browser/browser_searchSingleWordNotification.js new file mode 100644 index 0000000000..36a065d58e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchSingleWordNotification.js @@ -0,0 +1,372 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +let gDNSResolved = false; +add_setup(async function () { + registerCleanupFunction(function () { + Services.prefs.clearUserPref("browser.fixup.domainwhitelist.localhost"); + }); +}); + +function promiseNotification(aBrowser, value, expected, input) { + return new Promise(resolve => { + let notificationBox = aBrowser.getNotificationBox(aBrowser.selectedBrowser); + if (expected) { + info("Waiting for " + value + " notification"); + resolve( + BrowserTestUtils.waitForNotificationInNotificationBox( + notificationBox, + value + ) + ); + } else { + setTimeout(() => { + is( + notificationBox.getNotificationWithValue(value), + null, + `We are expecting to not get a notification for ${input}` + ); + resolve(); + }, 1000); + } + }); +} + +async function runURLBarSearchTest({ + valueToOpen, + enterSearchMode, + expectSearch, + expectNotification, + expectDNSResolve, + aWindow = window, +}) { + gDNSResolved = false; + // Test both directly setting a value and pressing enter, or setting the + // value through input events, like the user would do. + const setValueFns = [ + value => { + aWindow.gURLBar.value = value; + if (enterSearchMode) { + // Ensure to open the panel. + UrlbarTestUtils.fireInputEvent(aWindow); + } + }, + value => { + return UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: aWindow, + value, + }); + }, + ]; + + for (let i = 0; i < setValueFns.length; ++i) { + await setValueFns[i](valueToOpen); + let topic = "uri-fixup-check-dns"; + let observer = (aSubject, aTopicInner, aData) => { + if (aTopicInner == topic) { + gDNSResolved = true; + } + }; + Services.obs.addObserver(observer, topic); + + if (enterSearchMode) { + if (!expectSearch) { + throw new Error("Must execute a search in search mode"); + } + await UrlbarTestUtils.enterSearchMode(aWindow); + } + + let expectedURI; + if (!expectSearch) { + expectedURI = "http://" + valueToOpen + "/"; + } else { + expectedURI = (await Services.search.getDefault()).getSubmission( + valueToOpen, + null, + "keyword" + ).uri.spec; + } + aWindow.gURLBar.focus(); + let docLoadPromise = BrowserTestUtils.waitForDocLoadAndStopIt( + expectedURI, + aWindow.gBrowser.selectedBrowser + ); + EventUtils.synthesizeKey("VK_RETURN", {}, aWindow); + + if (!enterSearchMode) { + await promiseNotification( + aWindow.gBrowser, + "keyword-uri-fixup", + expectNotification, + valueToOpen + ); + } + await docLoadPromise; + + if (expectNotification) { + let notificationBox = aWindow.gBrowser.getNotificationBox( + aWindow.gBrowser.selectedBrowser + ); + let notification = + notificationBox.getNotificationWithValue("keyword-uri-fixup"); + // Confirm the notification only on the last loop. + if (i == setValueFns.length - 1) { + docLoadPromise = BrowserTestUtils.waitForDocLoadAndStopIt( + "http://" + valueToOpen + "/", + aWindow.gBrowser.selectedBrowser + ); + notification.buttonContainer.querySelector("button").click(); + await docLoadPromise; + } else { + notificationBox.currentNotification.close(); + } + } + + Services.obs.removeObserver(observer, topic); + Assert.equal( + gDNSResolved, + expectDNSResolve, + `Should${expectDNSResolve ? "" : " not"} DNS resolve "${valueToOpen}"` + ); + } +} + +add_task(async function test_navigate_full_domain() { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:blank" + )); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await runURLBarSearchTest({ + valueToOpen: "www.singlewordtest.org", + expectSearch: false, + expectNotification: false, + expectDNSResolve: false, + }); + gBrowser.removeTab(tab); +}); + +add_task(async function test_navigate_decimal_ip() { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:blank" + )); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await runURLBarSearchTest({ + valueToOpen: "1234", + expectSearch: true, + expectNotification: false, + expectDNSResolve: false, // Possible IP in numeric format. + }); + gBrowser.removeTab(tab); +}); + +add_task(async function test_navigate_decimal_ip_with_path() { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:blank" + )); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await runURLBarSearchTest({ + valueToOpen: "1234/12", + expectSearch: true, + expectNotification: false, + expectDNSResolve: false, + }); + gBrowser.removeTab(tab); +}); + +add_task(async function test_navigate_large_number() { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:blank" + )); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await runURLBarSearchTest({ + valueToOpen: "123456789012345", + expectSearch: true, + expectNotification: false, + expectDNSResolve: false, // Possible IP in numeric format. + }); + gBrowser.removeTab(tab); +}); + +add_task(async function test_navigate_small_hex_number() { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:blank" + )); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await runURLBarSearchTest({ + valueToOpen: "0x1f00ffff", + expectSearch: true, + expectNotification: false, + expectDNSResolve: false, // Possible IP in numeric format. + }); + gBrowser.removeTab(tab); +}); + +add_task(async function test_navigate_large_hex_number() { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:blank" + )); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await runURLBarSearchTest({ + valueToOpen: "0x7f0000017f000001", + expectSearch: true, + expectNotification: false, + expectDNSResolve: false, // Possible IP in numeric format. + }); + gBrowser.removeTab(tab); +}); + +function get_test_function_for_localhost_with_hostname( + hostName, + isPrivate = false +) { + return async function test_navigate_single_host() { + info(`Test ${hostName}${isPrivate ? " in Private Browsing mode" : ""}`); + const pref = "browser.fixup.domainwhitelist.localhost"; + let win; + if (isPrivate) { + let promiseWin = BrowserTestUtils.waitForNewWindow(); + win = OpenBrowserWindow({ private: true }); + await promiseWin; + await SimpleTest.promiseFocus(win); + } else { + win = window; + } + + // Remove the domain from the whitelist + Services.prefs.setBoolPref(pref, false); + + // The notification should not appear because the default value of + // browser.urlbar.dnsResolveSingleWordsAfterSearch is 0 + await BrowserTestUtils.withNewTab( + { + gBrowser: win.gBrowser, + url: "about:blank", + }, + browser => + runURLBarSearchTest({ + valueToOpen: hostName, + expectSearch: true, + expectNotification: false, + expectDNSResolve: false, + aWindow: win, + }) + ); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.dnsResolveSingleWordsAfterSearch", 1]], + }); + + // The notification should appear, unless we are in private browsing mode. + await BrowserTestUtils.withNewTab( + { + gBrowser: win.gBrowser, + url: "about:blank", + }, + browser => + runURLBarSearchTest({ + valueToOpen: hostName, + expectSearch: true, + expectNotification: true, + expectDNSResolve: true, + aWindow: win, + }) + ); + + // check pref value + let prefValue = Services.prefs.getBoolPref(pref); + is(prefValue, !isPrivate, "Pref should have the correct state."); + + // Now try again with the pref set. + // In a private window, the notification should appear again. + await BrowserTestUtils.withNewTab( + { + gBrowser: win.gBrowser, + url: "about:blank", + }, + browser => + runURLBarSearchTest({ + valueToOpen: hostName, + expectSearch: isPrivate, + expectNotification: isPrivate, + expectDNSResolve: isPrivate, + aWindow: win, + }) + ); + + if (isPrivate) { + info("Waiting for private window to close"); + await BrowserTestUtils.closeWindow(win); + await SimpleTest.promiseFocus(window); + } + + await SpecialPowers.popPrefEnv(); + }; +} + +add_task(get_test_function_for_localhost_with_hostname("localhost")); +add_task(get_test_function_for_localhost_with_hostname("localhost.")); +add_task(get_test_function_for_localhost_with_hostname("localhost", true)); + +add_task(async function test_dnsResolveSingleWordsAfterSearch() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.dnsResolveSingleWordsAfterSearch", 0], + ["browser.fixup.domainwhitelist.localhost", false], + ], + }); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:blank", + }, + browser => + runURLBarSearchTest({ + valueToOpen: "localhost", + expectSearch: true, + expectNotification: false, + expectDNSResolve: false, + }) + ); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_navigate_invalid_url() { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:blank" + )); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await runURLBarSearchTest({ + valueToOpen: "mozilla is awesome", + expectSearch: true, + expectNotification: false, + expectDNSResolve: false, + }); + gBrowser.removeTab(tab); +}); + +add_task(async function test_search_mode() { + info("When in search mode we should never query the DNS"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.suggest.enabled", false]], + }); + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:blank" + )); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await runURLBarSearchTest({ + enterSearchMode: true, + valueToOpen: "mozilla", + expectSearch: true, + expectNotification: false, + expectDNSResolve: false, + }); + gBrowser.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchSuggestions.js b/browser/components/urlbar/tests/browser/browser_searchSuggestions.js new file mode 100644 index 0000000000..8a226a3c4c --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchSuggestions.js @@ -0,0 +1,341 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This tests checks that search suggestions can be acted upon correctly + * e.g. selection with modifiers, copying text. + */ + +"use strict"; + +const SUGGEST_URLBAR_PREF = "browser.urlbar.suggest.searches"; +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; + +const MAX_CHARS_PREF = "browser.urlbar.maxCharsForSearchSuggestions"; + +// Must run first. +add_task(async function prepare() { + let suggestionsEnabled = Services.prefs.getBoolPref(SUGGEST_URLBAR_PREF); + Services.prefs.setBoolPref(SUGGEST_URLBAR_PREF, true); + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + setAsDefault: true, + }); + await UrlbarTestUtils.formHistory.clear(); + registerCleanupFunction(async function () { + Services.prefs.setBoolPref(SUGGEST_URLBAR_PREF, suggestionsEnabled); + + // Clicking suggestions causes visits to search results pages, so clear that + // history now. + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + }); +}); + +add_task(async function clickSuggestion() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + let [idx, suggestion, engineName] = await getFirstSuggestion(); + Assert.equal( + engineName, + "browser_searchSuggestionEngine searchSuggestionEngine.xml", + "Expected suggestion engine" + ); + + let uri = (await Services.search.getDefault()).getSubmission(suggestion).uri; + let loadPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + uri.spec + ); + let element = await UrlbarTestUtils.waitForAutocompleteResultAt(window, idx); + EventUtils.synthesizeMouseAtCenter(element, {}, window); + await loadPromise; + + let formHistory = ( + await UrlbarTestUtils.formHistory.search({ source: engineName }) + ).map(entry => entry.value); + Assert.deepEqual( + formHistory, + ["foofoo"], + "Should find form history after adding it" + ); + + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); +}); + +async function testPressEnterOnSuggestion( + expectedUrl = null, + keyModifiers = {} +) { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + let [idx, suggestion, engineName] = await getFirstSuggestion(); + Assert.equal( + engineName, + "browser_searchSuggestionEngine searchSuggestionEngine.xml", + "Expected suggestion engine" + ); + + let hasExpectedUrl = !!expectedUrl; + if (!expectedUrl) { + expectedUrl = (await Services.search.getDefault()).getSubmission(suggestion) + .uri.spec; + } + + let promiseLoad = BrowserTestUtils.waitForDocLoadAndStopIt( + expectedUrl, + gBrowser.selectedBrowser + ); + + let promiseFormHistory; + if (!hasExpectedUrl) { + promiseFormHistory = UrlbarTestUtils.formHistory.promiseChanged("add"); + } + + for (let i = 0; i < idx; ++i) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + EventUtils.synthesizeKey("KEY_Enter", keyModifiers); + + await promiseLoad; + + if (!hasExpectedUrl) { + await promiseFormHistory; + let formHistory = ( + await UrlbarTestUtils.formHistory.search({ source: engineName }) + ).map(entry => entry.value); + Assert.deepEqual( + formHistory, + ["foofoo"], + "Should find form history after adding it" + ); + } + + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); +} + +add_task(async function plainEnterOnSuggestion() { + await testPressEnterOnSuggestion(); +}); + +add_task(async function ctrlEnterOnSuggestion() { + await testPressEnterOnSuggestion("https://www.foofoo.com/", { + ctrlKey: true, + }); +}); + +add_task(async function copySuggestionText() { + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + let [idx, suggestion] = await getFirstSuggestion(); + for (let i = 0; i < idx; ++i) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + gURLBar.select(); + await SimpleTest.promiseClipboardChange(suggestion, () => { + goDoCommand("cmd_copy"); + }); +}); + +add_task(async function typeMaxChars() { + gURLBar.focus(); + + let maxChars = 10; + await SpecialPowers.pushPrefEnv({ + set: [[MAX_CHARS_PREF, maxChars]], + }); + + // Make a string as long as maxChars and type it. + let value = ""; + for (let i = 0; i < maxChars; i++) { + value += String.fromCharCode("a".charCodeAt(0) + i); + } + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + }); + + // Suggestions should be fetched since we allow them when typing, and the + // value so far isn't longer than maxChars anyway. + await assertSuggestions([value + "foo", value + "bar"]); + + // Now type some additional chars. Suggestions should still be fetched since + // we allow them when typing. + for (let i = 0; i < 3; i++) { + let char = String.fromCharCode("z".charCodeAt(0) - i); + value += char; + EventUtils.synthesizeKey(char); + await assertSuggestions([value + "foo", value + "bar"]); + } + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function pasteMaxChars() { + gURLBar.focus(); + + let maxChars = 10; + await SpecialPowers.pushPrefEnv({ + set: [[MAX_CHARS_PREF, maxChars]], + }); + + // Make a string as long as maxChars and paste it. + let value = ""; + for (let i = 0; i < maxChars; i++) { + value += String.fromCharCode("a".charCodeAt(0) + i); + } + await selectAndPaste(value); + + // Suggestions should be fetched since the pasted string is not longer than + // maxChars. + await assertSuggestions([value + "foo", value + "bar"]); + + // Now type some additional chars. Suggestions should still be fetched since + // we allow them when typing. + for (let i = 0; i < 3; i++) { + let char = String.fromCharCode("z".charCodeAt(0) - i); + value += char; + EventUtils.synthesizeKey(char); + await assertSuggestions([value + "foo", value + "bar"]); + } + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function pasteMoreThanMaxChars() { + gURLBar.focus(); + + let maxChars = 10; + await SpecialPowers.pushPrefEnv({ + set: [[MAX_CHARS_PREF, maxChars]], + }); + + // Make a string longer than maxChars and paste it. + let value = ""; + for (let i = 0; i < 2 * maxChars; i++) { + value += String.fromCharCode("a".charCodeAt(0) + i); + } + await selectAndPaste(value); + + // Suggestions should not be fetched since the value was pasted and it was + // longer than maxChars. + await assertSuggestions([]); + + // Now type some additional chars. Suggestions should now be fetched since we + // allow them when typing. + for (let i = 0; i < 3; i++) { + let char = String.fromCharCode("z".charCodeAt(0) - i); + value += char; + EventUtils.synthesizeKey(char); + await assertSuggestions([value + "foo", value + "bar"]); + } + + // Paste again. The string is longer than maxChars, so suggestions should not + // be fetched. + await selectAndPaste(value); + await assertSuggestions([]); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function heuristicAddsFormHistory() { + await UrlbarTestUtils.formHistory.clear(); + let formHistory = (await UrlbarTestUtils.formHistory.search()).map( + entry => entry.value + ); + Assert.deepEqual(formHistory, [], "Form history should be empty initially"); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(result.heuristic); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.SEARCH); + Assert.equal(result.searchParams.query, "foo"); + + let uri = (await Services.search.getDefault()).getSubmission("foo").uri; + let loadPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + uri.spec + ); + let formHistoryPromise = UrlbarTestUtils.formHistory.promiseChanged("add"); + let element = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + EventUtils.synthesizeMouseAtCenter(element, {}, window); + await loadPromise; + + await formHistoryPromise; + formHistory = ( + await UrlbarTestUtils.formHistory.search({ + source: result.searchParams.engine, + }) + ).map(entry => entry.value); + Assert.deepEqual( + formHistory, + ["foo"], + "Should find form history after adding it" + ); + + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); +}); + +async function getFirstSuggestion() { + let results = await getSuggestionResults(); + if (!results.length) { + return [-1, null, null]; + } + let result = results[0]; + return [ + result.index, + result.searchParams.suggestion, + result.searchParams.engine, + ]; +} + +async function getSuggestionResults() { + await UrlbarTestUtils.promiseSearchComplete(window); + + let results = []; + let matchCount = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < matchCount; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.searchParams.suggestion + ) { + result.index = i; + results.push(result); + } + } + return results; +} + +async function assertSuggestions(expectedSuggestions) { + let results = await getSuggestionResults(); + let actualSuggestions = results.map(r => r.searchParams.suggestion); + Assert.deepEqual( + actualSuggestions, + expectedSuggestions, + "Expected suggestions" + ); +} diff --git a/browser/components/urlbar/tests/browser/browser_searchTelemetry.js b/browser/components/urlbar/tests/browser/browser_searchTelemetry.js new file mode 100644 index 0000000000..61ddff4c2d --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchTelemetry.js @@ -0,0 +1,220 @@ +"use strict"; + +const SUGGEST_URLBAR_PREF = "browser.urlbar.suggest.searches"; +const MAX_FORM_HISTORY_PREF = "browser.urlbar.maxHistoricalSearchSuggestions"; +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; + +// Must run first. +add_task(async function prepare() { + await SpecialPowers.pushPrefEnv({ + set: [ + [SUGGEST_URLBAR_PREF, true], + [MAX_FORM_HISTORY_PREF, 2], + ], + }); + + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + setAsDefault: true, + }); + + registerCleanupFunction(async function () { + // Clicking urlbar results causes visits to their associated pages, so clear + // that history now. + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + }); + + // Move the mouse away from the urlbar one-offs so that a one-off engine is + // not inadvertently selected. + await EventUtils.promiseNativeMouseEvent({ + type: "mousemove", + target: window.document.documentElement, + offsetX: 0, + offsetY: 0, + }); +}); + +add_task(async function heuristicResultMouse() { + await compareCounts(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "heuristicResult", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Should be of type search" + ); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + let element = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + EventUtils.synthesizeMouseAtCenter(element, {}); + await loadPromise; + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); + }); +}); + +add_task(async function heuristicResultKeyboard() { + await compareCounts(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "heuristicResult", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Should be of type search" + ); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.sendKey("return"); + await loadPromise; + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); + }); +}); + +add_task(async function searchSuggestionMouse() { + await compareCounts(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "searchSuggestion", + }); + let idx = await getFirstSuggestionIndex(); + Assert.greaterOrEqual(idx, 0, "there should be a first suggestion"); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + let element = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + idx + ); + EventUtils.synthesizeMouseAtCenter(element, {}); + await loadPromise; + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); + }); +}); + +add_task(async function searchSuggestionKeyboard() { + await compareCounts(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "searchSuggestion", + }); + let idx = await getFirstSuggestionIndex(); + Assert.greaterOrEqual(idx, 0, "there should be a first suggestion"); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + while (idx--) { + EventUtils.sendKey("down"); + } + EventUtils.sendKey("return"); + await loadPromise; + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); + }); +}); + +add_task(async function formHistoryMouse() { + await compareCounts(async function () { + await UrlbarTestUtils.formHistory.add(["foofoo", "foobar"]); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + let index = await getFirstSuggestionIndex(); + Assert.greaterOrEqual(index, 0, "there should be a first suggestion"); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.SEARCH); + Assert.equal(result.source, UrlbarUtils.RESULT_SOURCE.HISTORY); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + let element = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + index + ); + EventUtils.synthesizeMouseAtCenter(element, {}); + await loadPromise; + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); + }); +}); + +add_task(async function formHistoryKeyboard() { + await compareCounts(async function () { + await UrlbarTestUtils.formHistory.add(["foofoo", "foobar"]); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + let index = await getFirstSuggestionIndex(); + Assert.greaterOrEqual(index, 0, "there should be a first suggestion"); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.SEARCH); + Assert.equal(result.source, UrlbarUtils.RESULT_SOURCE.HISTORY); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + while (index--) { + EventUtils.sendKey("down"); + } + EventUtils.sendKey("return"); + await loadPromise; + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); + }); +}); + +/** + * This does three things: gets current telemetry/FHR counts, calls + * clickCallback, gets telemetry/FHR counts again to compare them to the old + * counts. + * + * @param {Function} clickCallback Use this to open the urlbar popup and choose + * and click a result. + */ +async function compareCounts(clickCallback) { + // Search events triggered by clicks (not the Return key in the urlbar) are + // recorded in three places: + // * Telemetry histogram named "SEARCH_COUNTS" + // * FHR + + let engine = await Services.search.getDefault(); + + let histogramKey = `other-${engine.name}.urlbar`; + let histogram = Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS"); + histogram.clear(); + + gURLBar.focus(); + await clickCallback(); + + TelemetryTestUtils.assertKeyedHistogramSum(histogram, histogramKey, 1); +} + +/** + * Returns the index of the first search suggestion in the urlbar results. + * + * @returns {number} An index, or -1 if there are no search suggestions. + */ +async function getFirstSuggestionIndex() { + const matchCount = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < matchCount; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.searchParams.suggestion + ) { + return i; + } + } + return -1; +} diff --git a/browser/components/urlbar/tests/browser/browser_search_bookmarks_from_bookmarks_menu.js b/browser/components/urlbar/tests/browser/browser_search_bookmarks_from_bookmarks_menu.js new file mode 100644 index 0000000000..499399db3a --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_search_bookmarks_from_bookmarks_menu.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function searchBookmarksFromBooksmarksMenu() { + // Add Button to toolbar + CustomizableUI.addWidgetToArea( + "bookmarks-menu-button", + CustomizableUI.AREA_NAVBAR, + 0 + ); + let bookmarksMenuButton = document.getElementById("bookmarks-menu-button"); + ok(bookmarksMenuButton, "Bookmarks Menu Button added"); + + // Open Bookmarks-Menu-Popup + let bookmarksMenuPopup = document.getElementById("BMB_bookmarksPopup"); + let PopupShownPromise = BrowserTestUtils.waitForEvent( + bookmarksMenuPopup, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(bookmarksMenuButton, { + type: "mousedown", + }); + await PopupShownPromise; + ok(true, "Bookmarks Menu Popup shown"); + + // Click on 'Search Bookmarks' + let searchBookmarksButton = document.getElementById("BMB_searchBookmarks"); + ok( + BrowserTestUtils.isVisible( + searchBookmarksButton, + "'Search Bookmarks Button' is visible." + ) + ); + EventUtils.synthesizeMouseAtCenter(searchBookmarksButton, {}); + + await new Promise(resolve => { + window.gURLBar.controller.addQueryListener({ + onViewOpen() { + window.gURLBar.controller.removeQueryListener(this); + resolve(); + }, + }); + }); + + // Verify URLBar is in search mode with correct restriction + is( + gURLBar.searchMode?.source, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + "Addressbar in correct mode." + ); + + resetCUIAndReinitUrlbarInput(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_search_continuation.js b/browser/components/urlbar/tests/browser/browser_search_continuation.js new file mode 100644 index 0000000000..8a24d57856 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_search_continuation.js @@ -0,0 +1,113 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests how trending and recent searches work together. + */ + +const CONFIG_DEFAULT = [ + { + webExtension: { id: "basic@search.mozilla.org" }, + urls: { + trending: { + fullPath: + "https://example.com/browser/browser/components/search/test/browser/trendingSuggestionEngine.sjs", + query: "", + }, + }, + appliesTo: [{ included: { everywhere: true } }], + default: "yes", + }, +]; + +add_setup(async () => { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.suggest.trending", true], + ["browser.urlbar.maxRichResults", 3], + ["browser.urlbar.trending.featureGate", true], + ["browser.urlbar.trending.requireSearchMode", false], + ["browser.urlbar.suggest.recentsearches", true], + ["browser.urlbar.recentsearches.featureGate", true], + [ + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", + false, + ], + ], + }); + + await UrlbarTestUtils.formHistory.clear(); + await SearchTestUtils.setupTestEngines("search-engines", CONFIG_DEFAULT); + + registerCleanupFunction(async () => { + await UrlbarTestUtils.formHistory.clear(); + }); +}); + +add_task(async function test_trending_results() { + await check_results([ + "SearchSuggestions", + "SearchSuggestions", + "SearchSuggestions", + ]); + await doSearch("Testing 1"); + await check_results([ + "RecentSearches", + "SearchSuggestions", + "SearchSuggestions", + ]); + await doSearch("Testing 2"); + await check_results([ + "RecentSearches", + "RecentSearches", + "SearchSuggestions", + ]); + await doSearch("Testing 3"); + await check_results(["RecentSearches", "RecentSearches", "RecentSearches"]); +}); + +async function check_results(results) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + waitForFocus: SimpleTest.waitForFocus, + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + results.length, + "We matched the expected number of results" + ); + + for (let i = 0; i < results.length; i++) { + let { result } = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.equal(result.providerName, results[i]); + } + + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Escape"); + }); +} + +async function doSearch(search) { + info("Perform a search that will be added to search history."); + let tab = await BrowserTestUtils.openNewForegroundTab( + window.gBrowser, + "data:text/html," + ); + let browserLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: search, + waitForFocus: SimpleTest.waitForFocus, + }); + + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Enter", {}, window); + }); + await browserLoaded; + + await BrowserTestUtils.removeTab(tab); +} diff --git a/browser/components/urlbar/tests/browser/browser_search_history_from_history_panel.js b/browser/components/urlbar/tests/browser/browser_search_history_from_history_panel.js new file mode 100644 index 0000000000..a61f9a6eed --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_search_history_from_history_panel.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { CustomizableUITestUtils } = ChromeUtils.importESModule( + "resource://testing-common/CustomizableUITestUtils.sys.mjs" +); +let gCUITestUtils = new CustomizableUITestUtils(window); + +add_task(async function searchHistoryFromHistoryPanel() { + // Add Button to toolbar + CustomizableUI.addWidgetToArea( + "history-panelmenu", + CustomizableUI.AREA_NAVBAR, + 0 + ); + registerCleanupFunction(() => { + resetCUIAndReinitUrlbarInput(); + }); + + let historyButton = document.getElementById("history-panelmenu"); + ok(historyButton, "History button appears in Panel Menu"); + + historyButton.click(); + + let historyPanel = document.getElementById("PanelUI-history"); + let promise = BrowserTestUtils.waitForEvent(historyPanel, "ViewShown"); + await promise; + ok(historyPanel.getAttribute("visible"), "History Panel is in view"); + + // Click on 'Search Bookmarks' + let searchHistoryButton = document.getElementById("appMenuSearchHistory"); + ok( + BrowserTestUtils.isVisible( + searchHistoryButton, + "'Search History Button' is visible." + ) + ); + EventUtils.synthesizeMouseAtCenter(searchHistoryButton, {}); + + await new Promise(resolve => { + window.gURLBar.controller.addQueryListener({ + onViewOpen() { + window.gURLBar.controller.removeQueryListener(this); + resolve(); + }, + }); + }); + + // Verify URLBar is in search mode with correct restriction + is( + gURLBar.searchMode?.source, + UrlbarUtils.RESULT_SOURCE.HISTORY, + "Addressbar in correct mode." + ); + gURLBar.searchMode = null; + gURLBar.blur(); +}); + +add_task(async function searchHistoryFromAppMenuHistoryButton() { + // Open main menu and click on 'History' button + await gCUITestUtils.openMainMenu(); + let historyButton = document.getElementById("appMenu-history-button"); + historyButton.click(); + + let historyPanel = document.getElementById("PanelUI-history"); + let promise = BrowserTestUtils.waitForEvent(historyPanel, "ViewShown"); + await promise; + ok(historyPanel.getAttribute("visible"), "History Panel is in view"); + + // Click on 'Search Bookmarks' + let searchHistoryButton = document.getElementById("appMenuSearchHistory"); + ok( + BrowserTestUtils.isVisible( + searchHistoryButton, + "'Search History Button' is visible." + ) + ); + EventUtils.synthesizeMouseAtCenter(searchHistoryButton, {}); + + await new Promise(resolve => { + window.gURLBar.controller.addQueryListener({ + onViewOpen() { + window.gURLBar.controller.removeQueryListener(this); + resolve(); + }, + }); + }); + + // Verify URLBar is in search mode with correct restriction + is( + gURLBar.searchMode?.source, + UrlbarUtils.RESULT_SOURCE.HISTORY, + "Addressbar in correct mode." + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_selectStaleResults.js b/browser/components/urlbar/tests/browser/browser_selectStaleResults.js new file mode 100644 index 0000000000..c381478712 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_selectStaleResults.js @@ -0,0 +1,329 @@ +/* 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 test makes sure that arrowing down and up through the view's results +// works correctly with regard to stale results. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarView: "resource:///modules/UrlbarView.sys.mjs", +}); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); + + // We'll later replace this, so ensure it's restored. + let originalRemoveStaleRowsTimeout = UrlbarView.removeStaleRowsTimeout; + registerCleanupFunction(() => { + UrlbarView.removeStaleRowsTimeout = originalRemoveStaleRowsTimeout; + }); +}); + +// This tests the case where queryContext.results.length < the number of rows in +// the view, i.e., the view contains stale rows. +add_task(async function viewContainsStaleRows() { + // Set the remove-stale-rows timer to a very large value, so there's no + // possibility it interferes with this test. + UrlbarView.removeStaleRowsTimeout = 10000; + + // For the test stability we need a slow provider that ensures the search + // doesn't complete too fast. + let slowProvider = new UrlbarTestUtils.TestProvider({ + results: [], + name: "emptySlowProvider", + addTimeout: 1000, + }); + UrlbarProvidersManager.registerProvider(slowProvider); + registerCleanupFunction(() => { + UrlbarProvidersManager.unregisterProvider(slowProvider); + }); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + let maxResults = UrlbarPrefs.get("maxRichResults"); + let halfResults = Math.floor(maxResults / 2); + + // Add enough visits to pages with "xx" in the title to fill up half the view. + for (let i = 0; i < halfResults; i++) { + await PlacesTestUtils.addVisits({ + uri: "http://mochi.test:8888/" + i, + title: "xx" + i, + }); + } + + // Add enough visits to pages with "x" in the title to fill up the entire + // view. + for (let i = 0; i < maxResults; i++) { + await PlacesTestUtils.addVisits({ + uri: "http://example.com/" + i, + title: "x" + i, + }); + } + + gURLBar.focus(); + + // Search for "x" and wait for the search to finish. All the "x" results + // added above should be in the view. (Actually one fewer will be in the + // view due to the heuristic result, but that's not important.) + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "x", + fireInputEvent: true, + }); + + // Below we'll do a search for "xx". Get the row that will show the last + // result in that search, and await for it to be updated. + Assert.ok( + !UrlbarTestUtils.getRowAt(window, halfResults).hasAttribute("stale"), + "Should not be stale" + ); + + let lastMatchingResultUpdatedPromise = TestUtils.waitForCondition(() => { + let row = UrlbarTestUtils.getRowAt(window, halfResults); + console.log(row.result.title); + return row.result.title.startsWith("xx"); + }, "Wait for the result to be updated"); + + // Type another "x" so that we search for "xx", but don't wait for the search + // to finish. Instead, wait for the row to be updated. + EventUtils.synthesizeKey("x"); + await lastMatchingResultUpdatedPromise; + + // Now arrow down. The search, which is still ongoing, will now stop and the + // view won't be updated anymore. + EventUtils.synthesizeKey("KEY_ArrowDown"); + + // Wait for the search to stop. + info("Waiting for the search to stop... "); + await gURLBar.lastQueryContextPromise; + + // Check stale status of results. + Assert.ok( + !UrlbarTestUtils.getRowAt(window, halfResults).hasAttribute("stale"), + "Should not be stale" + ); + Assert.ok( + UrlbarTestUtils.getRowAt(window, halfResults + 1).hasAttribute("stale"), + "Should be stale" + ); + + // The query context for the last search ("xx") should contain only + // halfResults + 1 results (+ 1 for the heuristic). + Assert.ok(gURLBar.controller._lastQueryContextWrapper); + let { queryContext } = gURLBar.controller._lastQueryContextWrapper; + Assert.ok(queryContext); + Assert.equal(queryContext.results.length, halfResults + 1); + + // But there should be maxResults visible rows in the view. + let items = Array.from( + UrlbarTestUtils.getResultsContainer(window).children + ).filter(r => BrowserTestUtils.isVisible(r)); + Assert.equal(items.length, maxResults); + + // Arrow down through all the results. After arrowing down from the last "xx" + // result, the stale "x" results should be selected. We should *not* enter + // the one-off search buttons at that point. + for (let i = 1; i < maxResults; i++) { + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), i); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.equal(result.element.row.result.rowIndex, i); + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + + // Now the first one-off should be selected. + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), -1); + Assert.equal(gURLBar.view.oneOffSearchButtons.selectedButtonIndex, 0); + + // Arrow back up through all the results. + for (let i = maxResults - 1; i >= 0; i--) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), i); + } + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); + UrlbarProvidersManager.unregisterProvider(slowProvider); +}); + +// This tests the case where, before the search finishes, stale results have +// been removed and replaced with non-stale results. +add_task(async function staleReplacedWithFresh() { + // For this test, we need one set of results that's added quickly and another + // set that's added after a delay. We do an initial search and wait for both + // sets to be added. Then we do another search, but this time only wait for + // the fast results to be added, and then we arrow down to stop the search + // before the delayed results are added. The order in which things should + // happen after the second search goes like this: + // + // (1) second search + // (2) fast results are added + // (3) remove-stale-rows timer fires and removes stale rows (the rows from + // the delayed set of results from the first search) + // (4) we arrow down to stop the search + // + // We use history for the fast results and a slow search engine for the + // delayed results. + // + // NB: If this test ends up failing, it may be because the remove-stale-rows + // timer fires before the history results are added. i.e., steps 2 and 3 + // above happen out of order. If that happens, try increasing it. + UrlbarView.removeStaleRowsTimeout = 1000; + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + // Enable search suggestions, and add an engine that returns suggestions on a + // delay. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", true]], + }); + let engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "searchSuggestionEngineSlow.xml", + }); + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.moveEngine(engine, 0); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + let maxResults = UrlbarPrefs.get("maxRichResults"); + + // Add enough visits to pages with "test" in the title to fill up the entire + // view. + for (let i = 0; i < maxResults; i++) { + await PlacesTestUtils.addVisits({ + uri: "http://example.com/" + i, + title: "test" + i, + }); + } + + gURLBar.focus(); + + // Search for "tes" and wait for the search to finish. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "tes", + fireInputEvent: true, + }); + + // Sanity check the results. They should be: + // + // tes -- Search with searchSuggestionEngineSlow [heuristic] + // tesfoo [search suggestion] + // tesbar [search suggestion] + // test9 [history] + // test8 [history] + // test7 [history] + // test6 [history] + // test5 [history] + // test4 [history] + // test3 [history] + let count = UrlbarTestUtils.getResultCount(window); + Assert.equal(count, maxResults); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(result.heuristic); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.ok(result.searchParams); + Assert.equal(result.searchParams.suggestion, "tesfoo"); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 2); + Assert.ok(result.searchParams); + Assert.equal(result.searchParams.suggestion, "tesbar"); + for (let i = 3; i < maxResults; i++) { + result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.URL); + Assert.equal(result.title, "test" + (maxResults - i + 2)); + } + + // Below we'll do a search for "test" *but* not wait for the two search + // suggestion results to be added. We'll only wait for the history results to + // be added. To determine when the history results are added, use a mutation + // listener on the node containing the rows, and wait until the title of the + // next-to-last row is "test2". At that point, the results should be: + // + // test -- Search with searchSuggestionEngineSlow + // test9 + // test8 + // test7 + // test6 + // test5 + // test4 + // test3 + // test2 + // test1 + let mutationPromise = new Promise(resolve => { + let observer = new MutationObserver(mutations => { + let row = UrlbarTestUtils.getRowAt(window, maxResults - 2); + if (row && row._elements.get("title").textContent == "test2") { + observer.disconnect(); + resolve(); + } + }); + observer.observe(UrlbarTestUtils.getResultsContainer(window), { + subtree: true, + characterData: true, + childList: true, + attributes: true, + }); + }); + + // Now type a "t" so that we search for "test", but only wait for history + // results to be added, as described above. + EventUtils.synthesizeKey("t"); + info("Waiting for the 'test2' row... "); + await mutationPromise; + + // Now arrow down. The search, which is still ongoing, will now stop and the + // view won't be updated anymore. + EventUtils.synthesizeKey("KEY_ArrowDown"); + + // Wait for the search to stop. + info("Waiting for the search to stop... "); + await gURLBar.lastQueryContextPromise; + + // Sanity check the results. They should be as described above. + count = UrlbarTestUtils.getResultCount(window); + Assert.equal(count, maxResults); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(result.heuristic); + Assert.equal(result.element.row.result.rowIndex, 0); + for (let i = 1; i < maxResults; i++) { + result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.URL); + Assert.equal(result.title, "test" + (maxResults - i)); + Assert.equal(result.element.row.result.rowIndex, i); + } + + // Arrow down through all the results. After arrowing down from "test3", we + // should continue on to "test2". We should *not* enter the one-off search + // buttons at that point. + for (let i = 1; i < maxResults; i++) { + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), i); + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + + // Now the first one-off should be selected. + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), -1); + Assert.equal(gURLBar.view.oneOffSearchButtons.selectedButtonIndex, 0); + + // Arrow back up through all the results. + for (let i = maxResults - 1; i >= 0; i--) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), i); + } + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); + await SpecialPowers.popPrefEnv(); + await Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_selectionKeyNavigation.js b/browser/components/urlbar/tests/browser/browser_selectionKeyNavigation.js new file mode 100644 index 0000000000..89ba179833 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_selectionKeyNavigation.js @@ -0,0 +1,200 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test makes sure that the up/down and page-up/down properly adjust the +// selection. See also browser_caret_navigation.js and +// browser_urlbar_tabKeyBehavior.js. + +"use strict"; + +const MAX_RESULTS = UrlbarPrefs.get("maxRichResults"); + +add_setup(async function () { + for (let i = 0; i < MAX_RESULTS; i++) { + await PlacesTestUtils.addVisits("http://example.com/" + i); + } + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function downKey() { + for (const ctrlKey of [false, true]) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "The heuristic autofill result should be selected initially" + ); + for (let i = 1; i < MAX_RESULTS; i++) { + EventUtils.synthesizeKey("KEY_ArrowDown", { ctrlKey }); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), i); + } + EventUtils.synthesizeKey("KEY_ArrowDown", { ctrlKey }); + let oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + Assert.ok(oneOffs.selectedButton, "A one-off should now be selected"); + while (oneOffs.selectedButton) { + EventUtils.synthesizeKey("KEY_ArrowDown", { ctrlKey }); + } + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "The heuristic autofill result should be selected again" + ); + } +}); + +add_task(async function upKey() { + for (const ctrlKey of [false, true]) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "The heuristic autofill result should be selected initially" + ); + EventUtils.synthesizeKey("KEY_ArrowUp", { ctrlKey }); + let oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + Assert.ok(oneOffs.selectedButton, "A one-off should now be selected"); + while (oneOffs.selectedButton) { + EventUtils.synthesizeKey("KEY_ArrowUp", { ctrlKey }); + } + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + MAX_RESULTS - 1, + "The last result should be selected" + ); + for (let i = 1; i < MAX_RESULTS; i++) { + EventUtils.synthesizeKey("KEY_ArrowUp", { ctrlKey }); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + MAX_RESULTS - i - 1 + ); + } + } +}); + +add_task(async function pageDownKey() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "The heuristic autofill result should be selected initially" + ); + let pageCount = Math.ceil((MAX_RESULTS - 1) / UrlbarUtils.PAGE_UP_DOWN_DELTA); + for (let i = 0; i < pageCount; i++) { + EventUtils.synthesizeKey("KEY_PageDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + Math.min((i + 1) * UrlbarUtils.PAGE_UP_DOWN_DELTA, MAX_RESULTS - 1) + ); + } + EventUtils.synthesizeKey("KEY_PageDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "Page down at end should wrap around to first result" + ); +}); + +add_task(async function pageUpKey() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "The heuristic autofill result should be selected initially" + ); + EventUtils.synthesizeKey("KEY_PageUp"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + MAX_RESULTS - 1, + "Page up at start should wrap around to last result" + ); + let pageCount = Math.ceil((MAX_RESULTS - 1) / UrlbarUtils.PAGE_UP_DOWN_DELTA); + for (let i = 0; i < pageCount; i++) { + EventUtils.synthesizeKey("KEY_PageUp"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + Math.max(MAX_RESULTS - 1 - (i + 1) * UrlbarUtils.PAGE_UP_DOWN_DELTA, 0) + ); + } +}); + +add_task(async function pageDownKeyShowsView() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + await UrlbarTestUtils.promisePopupClose(window); + EventUtils.synthesizeKey("KEY_PageDown"); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.ok(UrlbarTestUtils.isPopupOpen(window)); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), 0); +}); + +add_task(async function pageUpKeyShowsView() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + await UrlbarTestUtils.promisePopupClose(window); + EventUtils.synthesizeKey("KEY_PageUp"); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.ok(UrlbarTestUtils.isPopupOpen(window)); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), 0); +}); + +add_task(async function pageDownKeyWithCtrlKey() { + const previousTab = gBrowser.selectedTab; + const currentTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_PageDown", { ctrlKey: true }); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal(gBrowser.selectedTab, previousTab); + BrowserTestUtils.removeTab(currentTab); +}); + +add_task(async function pageUpKeyWithCtrlKey() { + const previousTab = gBrowser.selectedTab; + const currentTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_PageUp", { ctrlKey: true }); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal(gBrowser.selectedTab, previousTab); + BrowserTestUtils.removeTab(currentTab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_separatePrivateDefault.js b/browser/components/urlbar/tests/browser/browser_separatePrivateDefault.js new file mode 100644 index 0000000000..8cdc0e746b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_separatePrivateDefault.js @@ -0,0 +1,223 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the 'Search in a Private Window' result of the address bar. +// Tests here don't have a different private engine, for that see +// browser_separatePrivateDefault_differentPrivateEngine.js + +const serverInfo = { + scheme: "http", + host: "localhost", + port: 20709, // Must be identical to what is in searchSuggestionEngine2.xml +}; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.ui.enabled", true], + ["browser.search.separatePrivateDefault.urlbarResult.enabled", true], + ["browser.search.separatePrivateDefault", true], + ["browser.urlbar.suggest.searches", true], + ], + }); + + // Add some history for the empty panel. + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/", + transition: PlacesUtils.history.TRANSITIONS.TYPED, + }, + ]); + + // Add a search suggestion engine and move it to the front so that it appears + // as the first one-off. + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "searchSuggestionEngine.xml", + setAsDefault: true, + setAsDefaultPrivate: true, + }); + + // Add another engine in the first one-off position. + let engine2 = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "POSTSearchEngine.xml", + }); + await Services.search.moveEngine(engine2, 0); + + // Add an engine with an alias. + await SearchTestUtils.installSearchExtension({ + name: "MozSearch", + keyword: "alias", + }); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +async function AssertNoPrivateResult(win) { + let count = await UrlbarTestUtils.getResultCount(win); + Assert.ok(count > 0, "Sanity check result count"); + for (let i = 0; i < count; ++i) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(win, i); + Assert.ok( + result.type != UrlbarUtils.RESULT_TYPE.SEARCH || + !result.searchParams.inPrivateWindow, + "Check this result is not a 'Search in a Private Window' one" + ); + } +} + +async function AssertPrivateResult(win, engine, isPrivateEngine) { + let count = await UrlbarTestUtils.getResultCount(win); + Assert.ok(count > 1, "Sanity check result count"); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Check result type" + ); + Assert.ok(result.searchParams.inPrivateWindow, "Check inPrivateWindow"); + Assert.equal( + result.searchParams.isPrivateEngine, + isPrivateEngine, + "Check isPrivateEngine" + ); + Assert.equal( + result.searchParams.engine, + engine.name, + "Check the search engine" + ); + return result; +} + +add_task(async function test_nonsearch() { + info( + "Test that 'Search in a Private Window' does not appear with non-search results" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exa", + }); + await AssertNoPrivateResult(window); +}); + +add_task(async function test_search() { + info( + "Test that 'Search in a Private Window' appears with only search results" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "unique198273982173", + }); + await AssertPrivateResult(window, await Services.search.getDefault(), false); +}); + +add_task(async function test_search_urlbar_result_disabled() { + info("Test that 'Search in a Private Window' does not appear when disabled"); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.urlbarResult.enabled", false], + ], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "unique198273982173", + }); + await AssertNoPrivateResult(window); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_search_disabled_suggestions() { + info( + "Test that 'Search in a Private Window' appears if suggestions are disabled" + ); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", false]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "unique198273982173", + }); + await AssertPrivateResult(window, await Services.search.getDefault(), false); + await SpecialPowers.popPrefEnv(); +}); + +// TODO: (Bug 1658620) Write a new subtest for this behaviour with the update2 +// pref on. +// add_task(async function test_oneoff_selected_keyboard() { +// info( +// "Test that 'Search in a Private Window' with keyboard opens the selected one-off engine if there's no private engine" +// ); +// await SpecialPowers.pushPrefEnv({ +// set: [ +// ["browser.urlbar.update2", false], +// ["browser.urlbar.update2.oneOffsRefresh", false], +// ], +// }); +// await UrlbarTestUtils.promiseAutocompleteResultPopup({ +// window, +// value: "unique198273982173", +// }); +// await AssertPrivateResult(window, await Services.search.getDefault(), false); +// // Select the 'Search in a Private Window' result, alt down to select the +// // first one-off button, Enter. It should open a pb window, but using the +// // selected one-off engine. +// let promiseWindow = BrowserTestUtils.waitForNewWindow({ +// url: +// "http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/print_postdata.sjs", +// }); +// // Select the private result. +// EventUtils.synthesizeKey("KEY_ArrowDown"); +// // Select the first one-off button. +// EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); +// EventUtils.synthesizeKey("VK_RETURN"); +// let win = await promiseWindow; +// Assert.ok( +// PrivateBrowsingUtils.isWindowPrivate(win), +// "Should open a private window" +// ); +// await BrowserTestUtils.closeWindow(win); +// await SpecialPowers.popPrefEnv(); +// }); + +// TODO: (Bug 1658620) Write a new subtest for this behaviour with the update2 +// pref on. +// add_task(async function test_oneoff_selected_mouse() { +// info( +// "Test that 'Search in a Private Window' with mouse opens the selected one-off engine if there's no private engine" +// ); +// await SpecialPowers.pushPrefEnv({ +// set: [ +// ["browser.urlbar.update2", false], +// ["browser.urlbar.update2.oneOffsRefresh", false], +// ], +// }); +// await UrlbarTestUtils.promiseAutocompleteResultPopup({ +// window, +// value: "unique198273982173", +// }); +// await AssertPrivateResult(window, await Services.search.getDefault(), false); +// // Select the 'Search in a Private Window' result, alt down to select the +// // first one-off button, Enter. It should open a pb window, but using the +// // selected one-off engine. +// let promiseWindow = BrowserTestUtils.waitForNewWindow({ +// url: +// "http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/print_postdata.sjs", +// }); +// // Select the private result. +// EventUtils.synthesizeKey("KEY_ArrowDown"); +// // Select the first one-off button. +// EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); +// // Click on the result. +// let element = UrlbarTestUtils.getSelectedRow(window); +// EventUtils.synthesizeMouseAtCenter(element, {}); +// let win = await promiseWindow; +// Assert.ok( +// PrivateBrowsingUtils.isWindowPrivate(win), +// "Should open a private window" +// ); +// await BrowserTestUtils.closeWindow(win); +// await SpecialPowers.popPrefEnv(); +// }); diff --git a/browser/components/urlbar/tests/browser/browser_separatePrivateDefault_differentEngine.js b/browser/components/urlbar/tests/browser/browser_separatePrivateDefault_differentEngine.js new file mode 100644 index 0000000000..58a60d68a9 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_separatePrivateDefault_differentEngine.js @@ -0,0 +1,354 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the 'Search in a Private Window' result of the address bar. + +const serverInfo = { + scheme: "http", + host: "localhost", + port: 20709, // Must be identical to what is in searchSuggestionEngine2.xml +}; + +let gAliasEngine; +let gPrivateEngine; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.ui.enabled", true], + ["browser.search.separatePrivateDefault.urlbarResult.enabled", true], + ["browser.search.separatePrivateDefault", true], + ["browser.urlbar.suggest.searches", true], + ], + }); + + // Add some history for the empty panel. + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/", + transition: PlacesUtils.history.TRANSITIONS.TYPED, + }, + ]); + + // Add a search suggestion engine and move it to the front so that it appears + // as the first one-off. + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "searchSuggestionEngine.xml", + setAsDefault: true, + }); + gPrivateEngine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "searchSuggestionEngine2.xml", + setAsDefaultPrivate: true, + }); + + // Add another engine in the first one-off position. + let engine2 = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "POSTSearchEngine.xml", + }); + await Services.search.moveEngine(engine2, 0); + + // Add an engine with an alias. + await SearchTestUtils.installSearchExtension({ + name: "MozSearch", + keyword: "alias", + }); + gAliasEngine = Services.search.getEngineByName("MozSearch"); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +async function AssertNoPrivateResult(win) { + let count = await UrlbarTestUtils.getResultCount(win); + for (let i = 0; i < count; ++i) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(win, i); + Assert.ok( + result.type != UrlbarUtils.RESULT_TYPE.SEARCH || + !result.searchParams.inPrivateWindow, + "Check this result is not a 'Search in a Private Window' one" + ); + } +} + +async function AssertPrivateResult(win, engine, isPrivateEngine) { + let count = await UrlbarTestUtils.getResultCount(win); + Assert.ok(count > 1, "Sanity check result count"); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Check result type" + ); + Assert.ok(result.searchParams.inPrivateWindow, "Check inPrivateWindow"); + Assert.equal( + result.searchParams.isPrivateEngine, + isPrivateEngine, + "Check isPrivateEngine" + ); + Assert.equal( + result.searchParams.engine, + engine.name, + "Check the search engine" + ); + return result; +} + +// Tests from here on have a different default private engine. + +add_task(async function test_search_private_engine() { + info( + "Test that 'Search in a Private Window' reports a separate private engine" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "unique198273982173", + }); + await AssertPrivateResult(window, gPrivateEngine, true); +}); + +add_task(async function test_privateWindow() { + info( + "Test that 'Search in a Private Window' does not appear in a private window" + ); + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: privateWin, + value: "unique198273982173", + }); + await AssertNoPrivateResult(privateWin); + await BrowserTestUtils.closeWindow(privateWin); +}); + +add_task(async function test_permanentPB() { + info( + "Test that 'Search in a Private Window' does not appear in Permanent Private Browsing" + ); + await SpecialPowers.pushPrefEnv({ + set: [["browser.privatebrowsing.autostart", true]], + }); + let win = await BrowserTestUtils.openNewBrowserWindow(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "unique198273982173", + }); + await AssertNoPrivateResult(win); + await BrowserTestUtils.closeWindow(win); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_openPBWindow() { + info( + "Test that 'Search in a Private Window' opens the search in a new Private Window" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "unique198273982173", + }); + await AssertPrivateResult( + window, + await Services.search.getDefaultPrivate(), + true + ); + + await withHttpServer(serverInfo, async () => { + let promiseWindow = BrowserTestUtils.waitForNewWindow({ + url: "http://localhost:20709/?terms=unique198273982173", + maybeErrorPage: true, + }); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("VK_RETURN"); + let win = await promiseWindow; + Assert.ok( + PrivateBrowsingUtils.isWindowPrivate(win), + "Should open a private window" + ); + await BrowserTestUtils.closeWindow(win); + }); +}); + +// TODO: (Bug 1658620) Write a new subtest for this behaviour with the update2 +// pref on. +// add_task(async function test_oneoff_selected_with_private_engine_mouse() { +// info( +// "Test that 'Search in a Private Window' opens the private engine even if a one-off is selected" +// ); +// await SpecialPowers.pushPrefEnv({ +// set: [ +// ["browser.urlbar.update2", false], +// ["browser.urlbar.update2.oneOffsRefresh", false], +// ], +// }); +// await UrlbarTestUtils.promiseAutocompleteResultPopup({ +// window, +// value: "unique198273982173", +// }); +// await AssertPrivateResult( +// window, +// await Services.search.getDefaultPrivate(), +// true +// ); + +// await withHttpServer(serverInfo, async () => { +// // Select the 'Search in a Private Window' result, alt down to select the +// // first one-off button, Click on the result. It should open a pb window using +// // the private search engine, because it has been set. +// let promiseWindow = BrowserTestUtils.waitForNewWindow({ +// url: "http://localhost:20709/?terms=unique198273982173", +// maybeErrorPage: true, +// }); +// // Select the private result. +// EventUtils.synthesizeKey("KEY_ArrowDown"); +// // Select the first one-off button. +// EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); +// // Click on the result. +// let element = UrlbarTestUtils.getSelectedRow(window); +// EventUtils.synthesizeMouseAtCenter(element, {}); +// let win = await promiseWindow; +// Assert.ok( +// PrivateBrowsingUtils.isWindowPrivate(win), +// "Should open a private window" +// ); +// await BrowserTestUtils.closeWindow(win); +// }); +// await SpecialPowers.popPrefEnv(); +// }); + +// TODO: (Bug 1658620) Write a new subtest for this behaviour with the update2 +// pref on. +// add_task(async function test_oneoff_selected_with_private_engine_keyboard() { +// info( +// "Test that 'Search in a Private Window' opens the private engine even if a one-off is selected" +// ); +// await SpecialPowers.pushPrefEnv({ +// set: [ +// ["browser.urlbar.update2", false], +// ["browser.urlbar.update2.oneOffsRefresh", false], +// ], +// }); +// await UrlbarTestUtils.promiseAutocompleteResultPopup({ +// window, +// value: "unique198273982173", +// }); +// await AssertPrivateResult( +// window, +// await Services.search.getDefaultPrivate(), +// true +// ); + +// await withHttpServer(serverInfo, async () => { +// // Select the 'Search in a Private Window' result, alt down to select the +// // first one-off button, Enter. It should open a pb window, but using the +// // selected one-off engine. +// let promiseWindow = BrowserTestUtils.waitForNewWindow({ +// url: "http://localhost:20709/?terms=unique198273982173", +// maybeErrorPage: true, +// }); +// // Select the private result. +// EventUtils.synthesizeKey("KEY_ArrowDown"); +// // Select the first one-off button. +// EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); +// EventUtils.synthesizeKey("VK_RETURN"); +// let win = await promiseWindow; +// Assert.ok( +// PrivateBrowsingUtils.isWindowPrivate(win), +// "Should open a private window" +// ); +// await BrowserTestUtils.closeWindow(win); +// }); +// await SpecialPowers.popPrefEnv(); +// }); + +add_task(async function test_alias_no_query() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.update2.emptySearchBehavior", 2]], + }); + info( + "Test that 'Search in a Private Window' doesn't appear if an alias is typed with no query" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "alias ", + }); + // Wait for the second new search that starts when search mode is entered. + await UrlbarTestUtils.promiseSearchComplete(window); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: gAliasEngine.name, + entry: "typed", + }); + await AssertNoPrivateResult(window); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_alias_query() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.update2.emptySearchBehavior", 2]], + }); + info( + "Test that 'Search in a Private Window' appears when an alias is typed with a query" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "alias something", + }); + // Wait for the second new search that starts when search mode is entered. + await UrlbarTestUtils.promiseSearchComplete(window); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: "MozSearch", + entry: "typed", + }); + await AssertPrivateResult(window, gAliasEngine, true); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_restrict() { + info( + "Test that 'Search in a Private Window' doesn's appear for just the restriction token" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: UrlbarTokenizer.RESTRICT.SEARCH, + }); + await AssertNoPrivateResult(window); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: UrlbarTokenizer.RESTRICT.SEARCH + " ", + }); + await AssertNoPrivateResult(window); + await UrlbarTestUtils.exitSearchMode(window); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: " " + UrlbarTokenizer.RESTRICT.SEARCH, + }); + await AssertNoPrivateResult(window); +}); + +add_task(async function test_restrict_search() { + info( + "Test that 'Search in a Private Window' has the right string with the restriction token" + ); + let engine = await Services.search.getDefaultPrivate(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: UrlbarTokenizer.RESTRICT.SEARCH + "test", + }); + let result = await AssertPrivateResult(window, engine, true); + Assert.equal(result.searchParams.query, "test"); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test" + UrlbarTokenizer.RESTRICT.SEARCH, + }); + result = await AssertPrivateResult(window, engine, true); + Assert.equal(result.searchParams.query, "test"); +}); diff --git a/browser/components/urlbar/tests/browser/browser_shortcuts_add_search_engine.js b/browser/components/urlbar/tests/browser/browser_shortcuts_add_search_engine.js new file mode 100644 index 0000000000..92eebf1997 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_shortcuts_add_search_engine.js @@ -0,0 +1,243 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test adding engines through search shortcut buttons. +// A more complete coverage of the detection of engines is available in +// browser_add_search_engine.js + +const { PromptTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromptTestUtils.sys.mjs" +); +const BASE_URL = + "http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", false]], + }); + // Ensure initial state. + UrlbarTestUtils.getOneOffSearchButtons(window).invalidateCache(); +}); + +add_task(async function shortcuts_none() { + info("Checks the shortcuts with a page that doesn't offer any engines."); + let url = "http://mochi.test:8888/"; + await BrowserTestUtils.withNewTab(url, async () => { + let shortcutButtons = UrlbarTestUtils.getOneOffSearchButtons(window); + let rebuildPromise = BrowserTestUtils.waitForEvent( + shortcutButtons, + "rebuild" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await rebuildPromise; + + Assert.ok( + !Array.from(shortcutButtons.buttons.children).some(b => + b.classList.contains("searchbar-engine-one-off-add-engine") + ), + "Check there's no buttons to add engines" + ); + }); +}); + +add_task(async function test_shortcuts() { + await do_test_shortcuts(button => { + info("Click on button"); + EventUtils.synthesizeMouseAtCenter(button, {}); + }); + await do_test_shortcuts(button => { + info("Enter on button"); + let shortcuts = UrlbarTestUtils.getOneOffSearchButtons(window); + while (shortcuts.selectedButton != button) { + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + } + EventUtils.synthesizeKey("KEY_Enter"); + }); +}); + +/** + * Test add engine shortcuts. + * + * @param {Function} activateTask a function receiveing the shortcut button to + * activate as argument. The scope of this function is to activate the + * shortcut button. + */ +async function do_test_shortcuts(activateTask) { + info("Checks the shortcuts with a page that offers two engines."); + let url = getRootDirectory(gTestPath) + "add_search_engine_two.html"; + await BrowserTestUtils.withNewTab(url, async () => { + let shortcutButtons = UrlbarTestUtils.getOneOffSearchButtons(window); + let rebuildPromise = BrowserTestUtils.waitForEvent( + shortcutButtons, + "rebuild" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await rebuildPromise; + + let addEngineButtons = Array.from(shortcutButtons.buttons.children).filter( + b => b.classList.contains("searchbar-engine-one-off-add-engine") + ); + Assert.equal( + addEngineButtons.length, + 2, + "Check there's two buttons to add engines" + ); + + for (let button of addEngineButtons) { + Assert.ok(BrowserTestUtils.isVisible(button)); + Assert.ok(button.hasAttribute("image")); + await document.l10n.translateElements([button]); + Assert.ok( + button.getAttribute("tooltiptext").includes("add_search_engine_") + ); + Assert.ok( + button.getAttribute("engine-name").startsWith("add_search_engine_") + ); + Assert.ok( + button.classList.contains("searchbar-engine-one-off-add-engine") + ); + } + + info("Activate the first button"); + rebuildPromise = BrowserTestUtils.waitForEvent(shortcutButtons, "rebuild"); + let enginePromise = promiseEngine("engine-added", "add_search_engine_0"); + await activateTask(addEngineButtons[0]); + info("await engine install"); + let engine = await enginePromise; + info("await rebuild"); + await rebuildPromise; + + Assert.ok( + UrlbarTestUtils.isPopupOpen(window), + "Urlbar view is still open." + ); + + addEngineButtons = Array.from(shortcutButtons.buttons.children).filter(b => + b.classList.contains("searchbar-engine-one-off-add-engine") + ); + Assert.equal( + addEngineButtons.length, + 1, + "Check there's one button to add engines" + ); + Assert.equal( + addEngineButtons[0].getAttribute("engine-name"), + "add_search_engine_1" + ); + let installedEngineButton = addEngineButtons[0].previousElementSibling; + Assert.equal(installedEngineButton.engine.name, "add_search_engine_0"); + + info("Remove the added engine"); + rebuildPromise = BrowserTestUtils.waitForEvent(shortcutButtons, "rebuild"); + await Services.search.removeEngine(engine); + await rebuildPromise; + Assert.equal( + Array.from(shortcutButtons.buttons.children).filter(b => + b.classList.contains("searchbar-engine-one-off-add-engine") + ).length, + 2, + "Check there's two buttons to add engines" + ); + await UrlbarTestUtils.promisePopupClose(window); + + info("Switch to a new tab and check the buttons are not persisted"); + await BrowserTestUtils.withNewTab("about:robots", async () => { + rebuildPromise = BrowserTestUtils.waitForEvent( + shortcutButtons, + "rebuild" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await rebuildPromise; + Assert.ok( + !Array.from(shortcutButtons.buttons.children).some(b => + b.classList.contains("searchbar-engine-one-off-add-engine") + ), + "Check there's no option to add engines" + ); + }); + }); +} + +add_task(async function shortcuts_many() { + info("Checks the shortcuts with a page that offers many engines."); + let url = getRootDirectory(gTestPath) + "add_search_engine_many.html"; + await BrowserTestUtils.withNewTab(url, async () => { + let shortcutButtons = UrlbarTestUtils.getOneOffSearchButtons(window); + let rebuildPromise = BrowserTestUtils.waitForEvent( + shortcutButtons, + "rebuild" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await rebuildPromise; + + let addEngineButtons = Array.from(shortcutButtons.buttons.children).filter( + b => b.classList.contains("searchbar-engine-one-off-add-engine") + ); + Assert.equal( + addEngineButtons.length, + gURLBar.addSearchEngineHelper.maxInlineEngines, + "Check there's a maximum of `maxInlineEngines` buttons to add engines" + ); + }); +}); + +function promiseEngine(expectedData, expectedEngineName) { + info(`Waiting for engine ${expectedData}`); + return TestUtils.topicObserved( + "browser-search-engine-modified", + (engine, data) => { + info(`Got engine ${engine.wrappedJSObject.name} ${data}`); + return ( + expectedData == data && + expectedEngineName == engine.wrappedJSObject.name + ); + } + ).then(([engine, data]) => engine); +} + +add_task(async function shortcuts_without_other_engines() { + info("Checks the shortcuts without other engines."); + + info("Remove search engines except default"); + const defaultEngine = Services.search.defaultEngine; + const engines = await Services.search.getVisibleEngines(); + for (const engine of engines) { + if (defaultEngine.name !== engine.name) { + await Services.search.removeEngine(engine); + } + } + + info("Remove local engines"); + for (const { pref } of UrlbarUtils.LOCAL_SEARCH_MODES) { + await SpecialPowers.pushPrefEnv({ + set: [[`browser.urlbar.${pref}`, false]], + }); + } + + const url = getRootDirectory(gTestPath) + "add_search_engine_many.html"; + await BrowserTestUtils.withNewTab(url, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + + const shortcutButtons = UrlbarTestUtils.getOneOffSearchButtons(window); + Assert.ok(shortcutButtons.container.hidden, "It should be hidden"); + }); + + Services.search.restoreDefaultEngines(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_slow_heuristic.js b/browser/components/urlbar/tests/browser/browser_slow_heuristic.js new file mode 100644 index 0000000000..22b71d87b6 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_slow_heuristic.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that slow heuristic results are still waited for on selection. + +"use strict"; + +add_task(async function test_slow_heuristic() { + // Must be between CHUNK_RESULTS_DELAY_MS and DEFERRING_TIMEOUT_MS + let timeout = 150; + Assert.greater(timeout, UrlbarProvidersManager.CHUNK_RESULTS_DELAY_MS); + Assert.greater(UrlbarEventBufferer.DEFERRING_TIMEOUT_MS, timeout); + + // First, add a provider that adds a heuristic result on a delay. + let heuristicResult = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { url: "https://example.com/" } + ); + heuristicResult.heuristic = true; + let heuristicProvider = new UrlbarTestUtils.TestProvider({ + results: [heuristicResult], + name: "heuristicProvider", + priority: Infinity, + addTimeout: timeout, + }); + UrlbarProvidersManager.registerProvider(heuristicProvider); + registerCleanupFunction(() => { + UrlbarProvidersManager.unregisterProvider(heuristicProvider); + }); + + // Do a search without waiting for a result. + const win = await BrowserTestUtils.openNewBrowserWindow(); + let promiseLoaded = BrowserTestUtils.browserLoaded( + win.gBrowser.selectedBrowser + ); + + win.gURLBar.focus(); + EventUtils.sendString("test", win); + EventUtils.synthesizeKey("KEY_Enter", {}, win); + await promiseLoaded; + + await UrlbarTestUtils.promisePopupClose(win); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_fast_heuristic() { + let longTimeoutMs = 1000000; + let originalHeuristicTimeout = UrlbarProvidersManager.CHUNK_RESULTS_DELAY_MS; + UrlbarProvidersManager.CHUNK_RESULTS_DELAY_MS = longTimeoutMs; + registerCleanupFunction(() => { + UrlbarProvidersManager.CHUNK_RESULTS_DELAY_MS = originalHeuristicTimeout; + }); + + // Add a fast heuristic provider. + let heuristicResult = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { url: "https://example.com/" } + ); + heuristicResult.heuristic = true; + let heuristicProvider = new UrlbarTestUtils.TestProvider({ + results: [heuristicResult], + name: "heuristicProvider", + priority: Infinity, + }); + UrlbarProvidersManager.registerProvider(heuristicProvider); + registerCleanupFunction(() => { + UrlbarProvidersManager.unregisterProvider(heuristicProvider); + }); + + // Do a search. + const win = await BrowserTestUtils.openNewBrowserWindow(); + + let startTime = Cu.now(); + Assert.greater( + longTimeoutMs, + Cu.now() - startTime, + "Heuristic result is returned faster than CHUNK_RESULTS_DELAY_MS" + ); + + await UrlbarTestUtils.promisePopupClose(win); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/urlbar/tests/browser/browser_speculative_connect.js b/browser/components/urlbar/tests/browser/browser_speculative_connect.js new file mode 100644 index 0000000000..dc1b4a4c11 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_speculative_connect.js @@ -0,0 +1,199 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// This test ensures that we setup a speculative network connection to +// the site in various cases: +// 1. search engine if it's the first result +// 2. mousedown event before the http request happens(in mouseup). + +const TEST_ENGINE_BASENAME = "searchSuggestionEngine2.xml"; + +const serverInfo = { + scheme: "http", + host: "localhost", + port: 20709, // Must be identical to what is in searchSuggestionEngine2.xml +}; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.autoFill", true], + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.speculativeConnect.enabled", true], + // In mochitest this number is 0 by default but we have to turn it on. + ["network.http.speculative-parallel-limit", 6], + // The http server is using IPv4, so it's better to disable IPv6 to avoid + // weird networking problem. + ["network.dns.disableIPv6", true], + ], + }); + + // Ensure we start from a clean situation. + await PlacesUtils.history.clear(); + + await PlacesTestUtils.addVisits([ + { + uri: `${serverInfo.scheme}://${serverInfo.host}:${serverInfo.port}`, + title: "test visit for speculative connection", + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + ]); + + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + setAsDefault: true, + }); + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function search_test() { + // We speculative connect to the search engine only if suggestions are enabled. + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.suggest.enabled", true]], + }); + await withHttpServer(serverInfo, async server => { + let connectionNumber = server.connectionNumber; + info("Searching for 'foo'"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + fireInputEvent: true, + }); + // Check if the first result is with type "searchengine" + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + details.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "The first result is a search" + ); + await UrlbarTestUtils.promiseSpeculativeConnections( + server, + connectionNumber + 1 + ); + }); +}); + +add_task(async function popup_mousedown_test() { + // Disable search suggestions and autofill, to avoid other speculative + // connections. + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.suggest.enabled", false], + ["browser.urlbar.autoFill", false], + ], + }); + await withHttpServer(serverInfo, async server => { + let connectionNumber = server.connectionNumber; + let searchString = "ocal"; + let completeValue = `${serverInfo.scheme}://${serverInfo.host}:${serverInfo.port}/`; + info(`Searching for '${searchString}'`); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + let listitem = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + details.url, + completeValue, + "The second item has the url we visited." + ); + + info("Clicking on the second result"); + EventUtils.synthesizeMouseAtCenter(listitem, { type: "mousedown" }, window); + Assert.equal( + UrlbarTestUtils.getSelectedRow(window), + listitem, + "The second item is selected" + ); + await UrlbarTestUtils.promiseSpeculativeConnections( + server, + connectionNumber + 1 + ); + }); +}); + +add_task(async function test_autofill() { + // Disable search suggestions but enable autofill. + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.suggest.enabled", false], + ["browser.urlbar.autoFill", true], + ], + }); + await withHttpServer(serverInfo, async server => { + let connectionNumber = server.connectionNumber; + let searchString = serverInfo.host; + info(`Searching for '${searchString}'`); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + let completeValue = `${serverInfo.scheme}://${serverInfo.host}:${serverInfo.port}/`; + Assert.equal(details.url, completeValue, `Autofilled value is as expected`); + await UrlbarTestUtils.promiseSpeculativeConnections( + server, + connectionNumber + 1 + ); + }); +}); + +add_task(async function test_autofill_privateContext() { + info("Autofill in private context."); + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + registerCleanupFunction(async () => { + let promisePBExit = TestUtils.topicObserved("last-pb-context-exited"); + await BrowserTestUtils.closeWindow(privateWin); + await promisePBExit; + }); + await withHttpServer(serverInfo, async server => { + let connectionNumber = server.connectionNumber; + let searchString = serverInfo.host; + info(`Searching for '${searchString}'`); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: privateWin, + value: searchString, + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(privateWin, 0); + let completeValue = `${serverInfo.scheme}://${serverInfo.host}:${serverInfo.port}/`; + Assert.equal(details.url, completeValue, `Autofilled value is as expected`); + await UrlbarTestUtils.promiseSpeculativeConnections( + server, + connectionNumber + ); + }); +}); + +add_task(async function test_no_heuristic_result() { + info("Don't speculative connect on results addition if there's no heuristic"); + await withHttpServer(serverInfo, async server => { + let connectionNumber = server.connectionNumber; + info(`Searching for the empty string`); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + fireInputEvent: true, + }); + Assert.greater(UrlbarTestUtils.getResultCount(window), 0, "Has results"); + let result = await UrlbarTestUtils.getSelectedRow(window); + Assert.strictEqual(result, null, `Should have no selection`); + await UrlbarTestUtils.promiseSpeculativeConnections( + server, + connectionNumber + ); + }); +}); diff --git a/browser/components/urlbar/tests/browser/browser_speculative_connect_not_with_client_cert.js b/browser/components/urlbar/tests/browser/browser_speculative_connect_not_with_client_cert.js new file mode 100644 index 0000000000..62aec6f67a --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_speculative_connect_not_with_client_cert.js @@ -0,0 +1,230 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Tests that we don't speculatively connect when user certificates are installed + +const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +const certOverrideService = Cc[ + "@mozilla.org/security/certoverride;1" +].getService(Ci.nsICertOverrideService); + +const host = "localhost"; +let uri; +let handshakeDone = false; +let expectingChooseCertificate = false; +let chooseCertificateCalled = false; + +const clientAuthDialogService = { + chooseCertificate(hostname, certArray, loadContext, callback) { + ok( + expectingChooseCertificate, + `${ + expectingChooseCertificate ? "" : "not " + }expecting chooseCertificate to be called` + ); + is( + certArray.length, + 1, + "should have only one client certificate available" + ); + ok( + !chooseCertificateCalled, + "chooseCertificate should only be called once" + ); + chooseCertificateCalled = true; + callback.certificateChosen(certArray[0], false); + }, + + QueryInterface: ChromeUtils.generateQI(["nsIClientAuthDialogService"]), +}; + +/** + * A helper class to use with nsITLSServerConnectionInfo.setSecurityObserver. + * Implements nsITLSServerSecurityObserver and simulates an extremely + * rudimentary HTTP server that expects an HTTP/1.1 GET request and responds + * with a 200 OK. + */ +class SecurityObserver { + constructor(input, output) { + this.input = input; + this.output = output; + } + + onHandshakeDone(socket, status) { + info("TLS handshake done"); + handshakeDone = true; + + let output = this.output; + this.input.asyncWait( + { + onInputStreamReady(readyInput) { + try { + let request = NetUtil.readInputStreamToString( + readyInput, + readyInput.available() + ); + ok( + request.startsWith("GET /") && request.includes("HTTP/1.1"), + "expecting an HTTP/1.1 GET request" + ); + let response = + "HTTP/1.1 200 OK\r\nContent-Type:text/plain\r\n" + + "Connection:Close\r\nContent-Length:2\r\n\r\nOK"; + output.write(response, response.length); + } catch (e) { + console.log(e.message); + // This will fail when we close the speculative connection. + } + }, + }, + 0, + 0, + Services.tm.currentThread + ); + } +} + +function startServer(cert) { + let tlsServer = Cc["@mozilla.org/network/tls-server-socket;1"].createInstance( + Ci.nsITLSServerSocket + ); + tlsServer.init(-1, true, -1); + tlsServer.serverCert = cert; + + let securityObservers = []; + + let listener = { + onSocketAccepted(socket, transport) { + info("Accepted TLS client connection"); + let connectionInfo = transport.securityCallbacks.getInterface( + Ci.nsITLSServerConnectionInfo + ); + let input = transport.openInputStream(0, 0, 0); + let output = transport.openOutputStream(0, 0, 0); + connectionInfo.setSecurityObserver(new SecurityObserver(input, output)); + }, + + onStopListening() { + info("onStopListening"); + for (let securityObserver of securityObservers) { + securityObserver.input.close(); + securityObserver.output.close(); + } + }, + }; + + tlsServer.setSessionTickets(false); + tlsServer.setRequestClientCertificate(Ci.nsITLSServerSocket.REQUEST_ALWAYS); + + tlsServer.asyncListen(listener); + + return tlsServer; +} + +let server; + +function getTestServerCertificate() { + const certDB = Cc["@mozilla.org/security/x509certdb;1"].getService( + Ci.nsIX509CertDB + ); + for (const cert of certDB.getCerts()) { + if (cert.commonName == "Mochitest client") { + return cert; + } + } + return null; +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.autoFill", true], + // Turn off search suggestion so we won't speculative connect to the search engine. + ["browser.search.suggest.enabled", false], + ["browser.urlbar.speculativeConnect.enabled", true], + // In mochitest this number is 0 by default but we have to turn it on. + ["network.http.speculative-parallel-limit", 6], + // The http server is using IPv4, so it's better to disable IPv6 to avoid weird + // networking problem. + ["network.dns.disableIPv6", true], + ["security.default_personal_cert", "Ask Every Time"], + ], + }); + + let clientAuthDialogServiceCID = MockRegistrar.register( + "@mozilla.org/security/ClientAuthDialogService;1", + clientAuthDialogService + ); + + let cert = getTestServerCertificate(); + server = startServer(cert); + uri = `https://${host}:${server.port}/`; + info(`running tls server at ${uri}`); + await PlacesTestUtils.addVisits([ + { + uri, + title: "test visit for speculative connection", + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + ]); + + certOverrideService.rememberValidityOverride( + "localhost", + server.port, + {}, + cert, + true + ); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + MockRegistrar.unregister(clientAuthDialogServiceCID); + certOverrideService.clearValidityOverride("localhost", server.port, {}); + }); +}); + +add_task( + async function popup_mousedown_no_client_cert_dialog_until_navigate_test() { + // To not trigger autofill, search keyword starts from the second character. + let searchString = host.substr(1, 4); + let completeValue = uri; + info(`Searching for '${searchString}'`); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + let listitem = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + info(`The url of the second item is ${details.url}`); + is(details.url, completeValue, "The second item has the url we visited."); + + expectingChooseCertificate = false; + EventUtils.synthesizeMouseAtCenter(listitem, { type: "mousedown" }, window); + is( + UrlbarTestUtils.getSelectedRow(window), + listitem, + "The second item is selected" + ); + + // We shouldn't have triggered a speculative connection, because a client + // certificate is installed. + SimpleTest.requestFlakyTimeout("Wait for UI"); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Now mouseup, expect that we choose a client certificate, and expect that + // we successfully load a page. + expectingChooseCertificate = true; + EventUtils.synthesizeMouseAtCenter(listitem, { type: "mouseup" }, window); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + ok(chooseCertificateCalled, "chooseCertificate must have been called"); + server.close(); + } +); diff --git a/browser/components/urlbar/tests/browser/browser_stop.js b/browser/components/urlbar/tests/browser/browser_stop.js new file mode 100644 index 0000000000..285071a3ff --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_stop.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This tests ensures the urlbar reflects the correct value if a page load is + * stopped immediately after loading. + */ + +"use strict"; + +const goodURL = "http://mochi.test:8888/"; +const badURL = "http://mochi.test:8888/whatever.html"; + +add_task(async function () { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, goodURL); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + is( + gURLBar.value, + BrowserUIUtils.trimURL(goodURL), + "location bar reflects loaded page" + ); + + await typeAndSubmitAndStop(badURL); + is( + gURLBar.value, + BrowserUIUtils.trimURL(goodURL), + "location bar reflects loaded page after stop()" + ); + gBrowser.removeCurrentTab(); + + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + is(gURLBar.value, "", "location bar is empty"); + + await typeAndSubmitAndStop(badURL); + is( + gURLBar.value, + BrowserUIUtils.trimURL(badURL), + "location bar reflects stopped page in an empty tab" + ); + gBrowser.removeCurrentTab(); +}); + +async function typeAndSubmitAndStop(url) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: url, + fireInputEvent: true, + }); + + let docLoadPromise = BrowserTestUtils.waitForDocLoadAndStopIt( + url, + gBrowser.selectedBrowser + ); + + // When the load is stopped, tabbrowser calls gURLBar.setURI and then calls + // onStateChange on its progress listeners. So to properly wait until the + // urlbar value has been updated, add our own progress listener here. + let progressPromise = new Promise(resolve => { + let listener = { + onStateChange(browser, webProgress, request, stateFlags, status) { + if ( + webProgress.isTopLevel && + stateFlags & Ci.nsIWebProgressListener.STATE_STOP + ) { + gBrowser.removeTabsProgressListener(listener); + resolve(); + } + }, + }; + gBrowser.addTabsProgressListener(listener); + }); + + gURLBar.handleCommand(); + await Promise.all([docLoadPromise, progressPromise]); +} diff --git a/browser/components/urlbar/tests/browser/browser_stopSearchOnSelection.js b/browser/components/urlbar/tests/browser/browser_stopSearchOnSelection.js new file mode 100644 index 0000000000..0a1ef1b057 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_stopSearchOnSelection.js @@ -0,0 +1,113 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This tests that when a search is stopped due to the user selecting a result, + * the view doesn't update after that. + */ + +"use strict"; + +const TEST_ENGINE_BASENAME = "searchSuggestionEngineSlow.xml"; + +// This should match the `timeout` query param used in the suggestions URL in +// the test engine. +const TEST_ENGINE_SUGGESTIONS_TIMEOUT = 3000; + +// The number of suggestions returned by the test engine. +const TEST_ENGINE_NUM_EXPECTED_RESULTS = 2; + +add_setup(async function () { + await PlacesUtils.history.clear(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", true]], + }); + // Add a test search engine that returns suggestions on a delay. + let engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + setAsDefault: true, + }); + await Services.search.moveEngine(engine, 0); + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function mainTest() { + // Open a tab that will match the search string below so that we're guaranteed + // to have more than one result (the heuristic result) so that we can change + // the selected result. We open a tab instead of adding a page in history + // because open tabs are kept in a memory SQLite table, so open-tab results + // are more likely than history results to be fetched before our slow search + // suggestions. This is important when the test runs on slow debug builds on + // slow machines. + await BrowserTestUtils.withNewTab("http://example.com/", async () => { + await BrowserTestUtils.withNewTab("about:blank", async () => { + // Do an initial search. There should be 4 results: heuristic, open tab, + // and the two suggestions. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "amp", + }); + await TestUtils.waitForCondition(() => { + return ( + UrlbarTestUtils.getResultCount(window) == + 2 + TEST_ENGINE_NUM_EXPECTED_RESULTS + ); + }); + + // Type a character to start a new search. The new search should still + // match the open tab so that the open-tab result appears again. + EventUtils.synthesizeKey("l"); + + // There should be 2 results immediately: heuristic and open tab. + await TestUtils.waitForCondition(() => { + return UrlbarTestUtils.getResultCount(window) == 2; + }); + + // Before the search completes, change the selected result. Pressing only + // the down arrow key ends up selecting the first one-off on Linux debug + // builds on the infrastructure for some reason, so arrow back up to + // select the heuristic result again. The important thing is to change + // the selection. It doesn't matter which result ends up selected. + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_ArrowUp"); + + // Wait for the new search to complete. It should be canceled due to the + // selection change, but it should still complete. + await UrlbarTestUtils.promiseSearchComplete(window); + + // To make absolutely sure the suggestions don't appear after the search + // completes, wait a bit. + await new Promise(r => + setTimeout(r, 1 + TEST_ENGINE_SUGGESTIONS_TIMEOUT) + ); + + // The heuristic result should reflect the new search, "ampl". + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Should have the correct result type" + ); + Assert.equal( + result.searchParams.query, + "ampl", + "Should have the correct query" + ); + + // None of the other results should be "ampl" suggestions, i.e., amplfoo + // and amplbar should not be in the results. + let count = UrlbarTestUtils.getResultCount(window); + for (let i = 1; i < count; i++) { + result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.ok( + result.type != UrlbarUtils.RESULT_TYPE.SEARCH || + !["amplfoo", "amplbar"].includes(result.searchParams.suggestion), + "Suggestions should not contain the typed l char" + ); + } + }); + }); +}); diff --git a/browser/components/urlbar/tests/browser/browser_stop_pending.js b/browser/components/urlbar/tests/browser/browser_stop_pending.js new file mode 100644 index 0000000000..50f5dfdeec --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_stop_pending.js @@ -0,0 +1,459 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +const SLOW_PAGE = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://www.example.com" + ) + "slow-page.sjs"; +const SLOW_PAGE2 = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://mochi.test:8888" + ) + "slow-page.sjs?faster"; + +/** + * Check that if we: + * 1) have a loaded page + * 2) load a separate URL + * 3) before the URL for step 2 has finished loading, load a third URL + * we don't revert to the URL from (1). + */ +add_task(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com", + true, + true + ); + + let initialValue = gURLBar.untrimmedValue; + let expectedURLBarChange = SLOW_PAGE; + let sawChange = false; + let handler = () => { + isnot( + gURLBar.untrimmedValue, + initialValue, + "Should not revert URL bar value!" + ); + if (gURLBar.getAttribute("pageproxystate") == "valid") { + sawChange = true; + is( + gURLBar.untrimmedValue, + expectedURLBarChange, + "Should set expected URL bar value!" + ); + } + }; + + let obs = new MutationObserver(handler); + + obs.observe(gURLBar.textbox, { attributes: true }); + gURLBar.value = SLOW_PAGE; + gURLBar.handleCommand(); + + // If this ever starts going intermittent, we've broken this. + await new Promise(resolve => setTimeout(resolve, 200)); + expectedURLBarChange = SLOW_PAGE2; + let pageLoadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + gURLBar.value = expectedURLBarChange; + gURLBar.handleCommand(); + is( + gURLBar.untrimmedValue, + expectedURLBarChange, + "Should not have changed URL bar value synchronously." + ); + await pageLoadPromise; + ok( + sawChange, + "The URL bar change handler should have been called by the time the page was loaded" + ); + obs.disconnect(); + obs = null; + BrowserTestUtils.removeTab(tab); +}); + +/** + * Check that if we: + * 1) middle-click a link to a separate page whose server doesn't respond + * 2) we switch to that tab and stop the request + * + * The URL bar continues to contain the URL of the page we wanted to visit. + */ +add_task(async function () { + let socket = Cc["@mozilla.org/network/server-socket;1"].createInstance( + Ci.nsIServerSocket + ); + socket.init(-1, true, -1); + const PORT = socket.port; + registerCleanupFunction(() => { + socket.close(); + }); + + const BASE_PAGE = TEST_BASE_URL + "dummy_page.html"; + const SLOW_HOST = `https://localhost:${PORT}/`; + info("Using URLs: " + SLOW_HOST); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, BASE_PAGE); + info("opened tab"); + await SpecialPowers.spawn(tab.linkedBrowser, [SLOW_HOST], URL => { + let link = content.document.createElement("a"); + link.href = URL; + link.textContent = "click me to open a slow page"; + link.id = "clickme"; + content.document.body.appendChild(link); + }); + info("added link"); + let newTabPromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ); + // Middle click the link: + await BrowserTestUtils.synthesizeMouseAtCenter( + "#clickme", + { button: 1 }, + tab.linkedBrowser + ); + // get new tab, switch to it + let newTab = (await newTabPromise).target; + await BrowserTestUtils.switchTab(gBrowser, newTab); + is(gURLBar.untrimmedValue, SLOW_HOST, "Should have slow page in URL bar"); + let browserStoppedPromise = BrowserTestUtils.browserStopped( + newTab.linkedBrowser, + null, + true + ); + BrowserStop(); + await browserStoppedPromise; + + is( + gURLBar.untrimmedValue, + SLOW_HOST, + "Should still have slow page in URL bar after stop" + ); + BrowserTestUtils.removeTab(newTab); + BrowserTestUtils.removeTab(tab); +}); +/** + * Check that if we: + * 1) middle-click a link to a separate page whose server doesn't respond + * 2) we alter the URL on that page to some other server that doesn't respond + * 3) we stop the request + * + * The URL bar continues to contain the second URL. + */ +add_task(async function () { + let socket = Cc["@mozilla.org/network/server-socket;1"].createInstance( + Ci.nsIServerSocket + ); + socket.init(-1, true, -1); + const PORT1 = socket.port; + let socket2 = Cc["@mozilla.org/network/server-socket;1"].createInstance( + Ci.nsIServerSocket + ); + socket2.init(-1, true, -1); + const PORT2 = socket2.port; + registerCleanupFunction(() => { + socket.close(); + socket2.close(); + }); + + const BASE_PAGE = TEST_BASE_URL + "dummy_page.html"; + const SLOW_HOST1 = `https://localhost:${PORT1}/`; + const SLOW_HOST2 = `https://localhost:${PORT2}/`; + info("Using URLs: " + SLOW_HOST1 + " and " + SLOW_HOST2); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, BASE_PAGE); + info("opened tab"); + await SpecialPowers.spawn(tab.linkedBrowser, [SLOW_HOST1], URL => { + let link = content.document.createElement("a"); + link.href = URL; + link.textContent = "click me to open a slow page"; + link.id = "clickme"; + content.document.body.appendChild(link); + }); + info("added link"); + let newTabPromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ); + // Middle click the link: + await BrowserTestUtils.synthesizeMouseAtCenter( + "#clickme", + { button: 1 }, + tab.linkedBrowser + ); + // get new tab, switch to it + let newTab = (await newTabPromise).target; + await BrowserTestUtils.switchTab(gBrowser, newTab); + is(gURLBar.untrimmedValue, SLOW_HOST1, "Should have slow page in URL bar"); + let browserStoppedPromise = BrowserTestUtils.browserStopped( + newTab.linkedBrowser, + null, + true + ); + gURLBar.value = SLOW_HOST2; + gURLBar.handleCommand(); + await browserStoppedPromise; + + is( + gURLBar.untrimmedValue, + SLOW_HOST2, + "Should have second slow page in URL bar" + ); + browserStoppedPromise = BrowserTestUtils.browserStopped( + newTab.linkedBrowser, + null, + true + ); + BrowserStop(); + await browserStoppedPromise; + + is( + gURLBar.untrimmedValue, + SLOW_HOST2, + "Should still have second slow page in URL bar after stop" + ); + BrowserTestUtils.removeTab(newTab); + BrowserTestUtils.removeTab(tab); +}); + +/** + * 1) Try to load page 0 and wait for it to finish loading. + * 2) Try to load page 1 and wait for it to finish loading. + * 3) Try to load SLOW_PAGE, and then before it finishes loading, navigate back. + * - We should be taken to page 0. + */ +add_task(async function testCorrectUrlBarAfterGoingBackDuringAnotherLoad() { + // Load example.org + let page0 = "http://example.org/"; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + page0, + true, + true + ); + + // Load example.com in the same browser + let page1 = "http://example.com/"; + let loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, page1); + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, page1); + await loaded; + + let initialValue = gURLBar.untrimmedValue; + let expectedURLBarChange = SLOW_PAGE; + let sawChange = false; + let goneBack = false; + let handler = () => { + if (!goneBack) { + isnot( + gURLBar.untrimmedValue, + initialValue, + `Should not revert URL bar value to ${initialValue}` + ); + } + + if (gURLBar.getAttribute("pageproxystate") == "valid") { + sawChange = true; + is( + gURLBar.untrimmedValue, + expectedURLBarChange, + `Should set expected URL bar value - ${expectedURLBarChange}` + ); + } + }; + + let obs = new MutationObserver(handler); + + obs.observe(gURLBar.textbox, { attributes: true }); + // Set the value of url bar to SLOW_PAGE + gURLBar.value = SLOW_PAGE; + gURLBar.handleCommand(); + + // Copied from the first test case: + // If this ever starts going intermittent, we've broken this. + await new Promise(resolve => setTimeout(resolve, 200)); + + expectedURLBarChange = page0; + let pageLoadPromise = BrowserTestUtils.browserStopped( + tab.linkedBrowser, + page0 + ); + + // Wait until we can go back + await TestUtils.waitForCondition(() => tab.linkedBrowser.canGoBack); + ok(tab.linkedBrowser.canGoBack, "can go back"); + + // Navigate back from SLOW_PAGE. We should be taken to page 0 now. + tab.linkedBrowser.goBack(); + goneBack = true; + is( + gURLBar.untrimmedValue, + SLOW_PAGE, + "Should not have changed URL bar value synchronously." + ); + // Wait until page 0 have finished loading. + await pageLoadPromise; + is( + gURLBar.untrimmedValue, + page0, + "Should not have changed URL bar value synchronously." + ); + ok( + sawChange, + "The URL bar change handler should have been called by the time the page was loaded" + ); + obs.disconnect(); + obs = null; + BrowserTestUtils.removeTab(tab); +}); + +/** + * 1) Try to load page 1 and wait for it to finish loading. + * 2) Start loading SLOW_PAGE (it won't finish loading) + * 3) Reload the page. We should have loaded page 1 now. + */ +add_task(async function testCorrectUrlBarAfterReloadingDuringSlowPageLoad() { + // Load page 1 - example.com + let page1 = "http://example.com/"; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + page1, + true, + true + ); + + let initialValue = gURLBar.untrimmedValue; + let expectedURLBarChange = SLOW_PAGE; + let sawChange = false; + let hasReloaded = false; + let handler = () => { + if (!hasReloaded) { + isnot( + gURLBar.untrimmedValue, + initialValue, + "Should not revert URL bar value!" + ); + } + if (gURLBar.getAttribute("pageproxystate") == "valid") { + sawChange = true; + is( + gURLBar.untrimmedValue, + expectedURLBarChange, + "Should set expected URL bar value!" + ); + } + }; + + let obs = new MutationObserver(handler); + + obs.observe(gURLBar.textbox, { attributes: true }); + // Start loading SLOW_PAGE + gURLBar.value = SLOW_PAGE; + gURLBar.handleCommand(); + + // Copied from the first test: If this ever starts going intermittent, + // we've broken this. + await new Promise(resolve => setTimeout(resolve, 200)); + + expectedURLBarChange = page1; + let pageLoadPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + page1 + ); + // Reload the page + tab.linkedBrowser.reload(); + hasReloaded = true; + is( + gURLBar.untrimmedValue, + SLOW_PAGE, + "Should not have changed URL bar value synchronously." + ); + // Wait for page1 to be loaded due to a reload while the slow page was still loading + await pageLoadPromise; + ok( + sawChange, + "The URL bar change handler should have been called by the time the page was loaded" + ); + obs.disconnect(); + obs = null; + BrowserTestUtils.removeTab(tab); +}); + +/** + * 1) Try to load example.com and wait for it to finish loading. + * 2) Start loading SLOW_PAGE and then stop the load before the load completes + * 3) Check that example.com has been loaded as a result of stopping SLOW_PAGE + * load. + */ +add_task(async function testCorrectUrlBarAfterStoppingTheLoad() { + // Load page 1 + let page1 = "http://example.com/"; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + page1, + true, + true + ); + + let initialValue = gURLBar.untrimmedValue; + let expectedURLBarChange = SLOW_PAGE; + let sawChange = false; + let hasStopped = false; + let handler = () => { + if (!hasStopped) { + isnot( + gURLBar.untrimmedValue, + initialValue, + "Should not revert URL bar value!" + ); + } + if (gURLBar.getAttribute("pageproxystate") == "valid") { + sawChange = true; + is( + gURLBar.untrimmedValue, + expectedURLBarChange, + "Should set expected URL bar value!" + ); + } + }; + + let obs = new MutationObserver(handler); + + obs.observe(gURLBar.textbox, { attributes: true }); + // Start loading SLOW_PAGE + gURLBar.value = SLOW_PAGE; + gURLBar.handleCommand(); + + // Copied from the first test case: + // If this ever starts going intermittent, we've broken this. + await new Promise(resolve => setTimeout(resolve, 200)); + + // We expect page 1 to be loaded after the SLOW_PAGE load is stopped. + expectedURLBarChange = page1; + let pageLoadPromise = BrowserTestUtils.browserStopped( + tab.linkedBrowser, + SLOW_PAGE, + true + ); + // Stop the SLOW_PAGE load + tab.linkedBrowser.stop(); + hasStopped = true; + is( + gURLBar.untrimmedValue, + SLOW_PAGE, + "Should not have changed URL bar value synchronously." + ); + // Wait for SLOW_PAGE load to stop + await pageLoadPromise; + + ok( + sawChange, + "The URL bar change handler should have been called by the time the page was loaded" + ); + obs.disconnect(); + obs = null; + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_strip_on_share.js b/browser/components/urlbar/tests/browser/browser_strip_on_share.js new file mode 100644 index 0000000000..508106ccdc --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_strip_on_share.js @@ -0,0 +1,197 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let listService; + +// Tests for the strip on share functionality of the urlbar. + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.query_stripping.strip_list", "stripParam"], + ["privacy.query_stripping.enabled", false], + ], + }); + + // Get the list service so we can wait for it to be fully initialized before running tests. + listService = Cc["@mozilla.org/query-stripping-list-service;1"].getService( + Ci.nsIURLQueryStrippingListService + ); + + await listService.testWaitForInit(); +}); + +// Selection is not a valid URI, menu item should be hidden +add_task(async function testInvalidURI() { + await testMenuItemDisabled( + "https://www.example.com/?stripParam=1234", + true, + true + ); +}); + +// Pref is not enabled, menu item should be hidden +add_task(async function testPrefDisabled() { + await testMenuItemDisabled( + "https://www.example.com/?stripParam=1234", + false, + false + ); +}); + +// Menu item should be visible, the whole url is copied without a selection, url should be stripped. +add_task(async function testQueryParamIsStripped() { + let originalUrl = "https://www.example.com/?stripParam=1234"; + let shortenedUrl = "https://www.example.com/"; + await testMenuItemEnabled({ + selectWholeUrl: false, + validUrl: originalUrl, + strippedUrl: shortenedUrl, + useTestList: false, + }); +}); + +// Menu item should be visible, selecting the whole url, url should be stripped. +add_task(async function testQueryParamIsStrippedSelectURL() { + let originalUrl = "https://www.example.com/?stripParam=1234"; + let shortenedUrl = "https://www.example.com/"; + await testMenuItemEnabled({ + selectWholeUrl: true, + validUrl: originalUrl, + strippedUrl: shortenedUrl, + useTestList: false, + }); +}); + +// Menu item should be visible, selecting the whole url, url should be the same. +add_task(async function testURLIsCopiedWithNoParams() { + let originalUrl = "https://www.example.com/"; + let shortenedUrl = "https://www.example.com/"; + await testMenuItemEnabled({ + selectWholeUrl: true, + validUrl: originalUrl, + strippedUrl: shortenedUrl, + useTestList: false, + }); +}); + +// Testing site specific parameter stripping +add_task(async function testQueryParamIsStrippedForSiteSpecific() { + let originalUrl = "https://www.example.com/?test_2=1234"; + let shortenedUrl = "https://www.example.com/"; + await testMenuItemEnabled({ + selectWholeUrl: true, + validUrl: originalUrl, + strippedUrl: shortenedUrl, + useTestList: true, + }); +}); + +// Ensuring site specific parameters are not stripped for other sites +add_task(async function testQueryParamIsNotStrippedForWrongSiteSpecific() { + let originalUrl = "https://www.example.com/?test_3=1234"; + let shortenedUrl = "https://www.example.com/?test_3=1234"; + 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 + * + * @param {string} url - The url to be loaded + * @param {boolean} prefEnabled - Whether privacy.query_stripping.strip_on_share.enabled should be enabled for the test + * @param {boolean} selection - True: The whole url will be selected, false: Only part of the url will be selected + */ +async function testMenuItemDisabled(url, prefEnabled, selection) { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.query_stripping.strip_on_share.enabled", prefEnabled]], + }); + await BrowserTestUtils.withNewTab(url, async function (browser) { + gURLBar.focus(); + if (selection) { + //select only part of the url + gURLBar.selectionStart = url.indexOf("example"); + gURLBar.selectionEnd = url.indexOf("4"); + } + let menuitem = await promiseContextualMenuitem("strip-on-share"); + Assert.ok( + !BrowserTestUtils.isVisible(menuitem), + "Menu item is not visible" + ); + let hidePromise = BrowserTestUtils.waitForEvent( + menuitem.parentElement, + "popuphidden" + ); + menuitem.parentElement.hidePopup(); + await hidePromise; + }); +} + +/** + * Opens a new tab, opens the url bar context menu and checks that the strip-on-share menu item is visible. + * Checks that the stripped version of the url is copied to the clipboard. + * + * @param {object} options - method options + * @param {boolean} options.selectWholeUrl - Whether the whole url should be selected + * @param {string} options.validUrl - The original url before the stripping occurs + * @param {string} options.strippedUrl - The expected url after stripping occurs + * @param {boolean} options.useTestList - Whether the StripOnShare or Test list should be used + */ +async function testMenuItemEnabled({ + selectWholeUrl, + validUrl, + strippedUrl, + useTestList, +}) { + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.query_stripping.strip_on_share.enabled", true], + ["privacy.query_stripping.strip_on_share.enableTestMode", useTestList], + ], + }); + + if (useTestList) { + let testJson = { + global: { + queryParams: ["utm_ad"], + topLevelSites: ["*"], + }, + example: { + queryParams: ["test_2", "test_1"], + topLevelSites: ["www.example.com"], + }, + exampleNet: { + queryParams: ["test_3", "test_4"], + topLevelSites: ["www.example.net"], + }, + }; + + await listService.testSetList(testJson); + } + + await BrowserTestUtils.withNewTab(validUrl, async function (browser) { + gURLBar.focus(); + if (selectWholeUrl) { + gURLBar.select(); + } + let menuitem = await promiseContextualMenuitem("strip-on-share"); + Assert.ok(BrowserTestUtils.isVisible(menuitem), "Menu item is visible"); + let hidePromise = BrowserTestUtils.waitForEvent( + menuitem.parentElement, + "popuphidden" + ); + // Make sure the clean copy of the link will be copied to the clipboard + await SimpleTest.promiseClipboardChange(strippedUrl, () => { + menuitem.closest("menupopup").activateItem(menuitem); + }); + await hidePromise; + }); + + await SpecialPowers.popPrefEnv(); +} diff --git a/browser/components/urlbar/tests/browser/browser_strip_on_share_telemetry.js b/browser/components/urlbar/tests/browser/browser_strip_on_share_telemetry.js new file mode 100644 index 0000000000..48a8b6c729 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_strip_on_share_telemetry.js @@ -0,0 +1,98 @@ +"use strict"; + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +let listService; + +const STRIP_ON_SHARE_PARAMS_REMOVED = "STRIP_ON_SHARE_PARAMS_REMOVED"; +const STRIP_ON_SHARE_LENGTH_DECREASE = "STRIP_ON_SHARE_LENGTH_DECREASE"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.query_stripping.strip_on_share.enabled", true], + ["privacy.query_stripping.enabled", false], + ], + }); + + // Get the list service so we can wait for it to be fully initialized before running tests. + listService = Cc["@mozilla.org/query-stripping-list-service;1"].getService( + Ci.nsIURLQueryStrippingListService + ); + + await listService.testWaitForInit(); +}); + +// Checking telemetry for single query params being stripped +add_task(async function testSingleQueryParam() { + let originalURI = "https://www.example.com/?utm_source=1"; + let strippedURI = "https://www.example.com/"; + + // Calculating length difference between URLs to check correct telemetry label + let lengthDiff = originalURI.length - strippedURI.length; + + let paramHistogram = TelemetryTestUtils.getAndClearHistogram( + STRIP_ON_SHARE_PARAMS_REMOVED + ); + let lengthHistogram = TelemetryTestUtils.getAndClearHistogram( + STRIP_ON_SHARE_LENGTH_DECREASE + ); + + await testStripOnShare(originalURI, strippedURI); + + // The "1" Label is being checked as 1 Query Param is being stripped + TelemetryTestUtils.assertHistogram(paramHistogram, 1, 1); + TelemetryTestUtils.assertHistogram(lengthHistogram, lengthDiff, 1); + + await testStripOnShare(originalURI, strippedURI); + + TelemetryTestUtils.assertHistogram(paramHistogram, 1, 2); + TelemetryTestUtils.assertHistogram(lengthHistogram, lengthDiff, 2); +}); + +// Checking telemetry for mutliple query params being stripped +add_task(async function testMultiQueryParams() { + let originalURI = "https://www.example.com/?utm_source=1&utm_ad=1&utm_id=1"; + let strippedURI = "https://www.example.com/"; + + // Calculating length difference between URLs to check correct telemetry label + let lengthDiff = originalURI.length - strippedURI.length; + + let paramHistogram = TelemetryTestUtils.getAndClearHistogram( + STRIP_ON_SHARE_PARAMS_REMOVED + ); + let lengthHistogram = TelemetryTestUtils.getAndClearHistogram( + STRIP_ON_SHARE_LENGTH_DECREASE + ); + + await testStripOnShare(originalURI, strippedURI); + + // The "3" Label is being checked as 3 Query Params are being stripped + TelemetryTestUtils.assertHistogram(paramHistogram, 3, 1); + TelemetryTestUtils.assertHistogram(lengthHistogram, lengthDiff, 1); + + await testStripOnShare(originalURI, strippedURI); + + TelemetryTestUtils.assertHistogram(paramHistogram, 3, 2); + TelemetryTestUtils.assertHistogram(lengthHistogram, lengthDiff, 2); +}); + +async function testStripOnShare(validUrl, strippedUrl) { + await BrowserTestUtils.withNewTab(validUrl, async function (browser) { + gURLBar.focus(); + gURLBar.select(); + let menuitem = await promiseContextualMenuitem("strip-on-share"); + Assert.ok(BrowserTestUtils.isVisible(menuitem), "Menu item is visible"); + let hidePromise = BrowserTestUtils.waitForEvent( + menuitem.parentElement, + "popuphidden" + ); + // Make sure the clean copy of the link will be copied to the clipboard + await SimpleTest.promiseClipboardChange(strippedUrl, () => { + menuitem.closest("menupopup").activateItem(menuitem); + }); + await hidePromise; + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_suggestedIndex.js b/browser/components/urlbar/tests/browser/browser_suggestedIndex.js new file mode 100644 index 0000000000..563202036a --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_suggestedIndex.js @@ -0,0 +1,120 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that results with a suggestedIndex property end up in the expected +// position. + +add_task(async function suggestedIndex() { + let result1 = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/1" } + ); + result1.suggestedIndex = 2; + let result2 = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/2" } + ); + result2.suggestedIndex = 6; + + let provider = new UrlbarTestUtils.TestProvider({ + results: [result1, result2], + }); + UrlbarProvidersManager.registerProvider(provider); + async function clean() { + UrlbarProvidersManager.unregisterProvider(provider); + await PlacesUtils.history.clear(); + } + registerCleanupFunction(clean); + + let urls = []; + let maxResults = UrlbarPrefs.get("maxRichResults"); + // Add more results, so that the sum of these results plus the above ones, + // will be greater than maxResults. + for (let i = 0; i < maxResults; ++i) { + urls.push("http://example.com/foo" + i); + } + await PlacesTestUtils.addVisits(urls); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + maxResults, + `There should be ${maxResults} results in the view.` + ); + + urls.reverse(); + urls.unshift( + (await Services.search.getDefault()).getSubmission("foo").uri.spec + ); + urls.splice(result1.suggestedIndex, 0, result1.payload.url); + urls.splice(result2.suggestedIndex, 0, result2.payload.url); + urls = urls.slice(0, maxResults); + + let expected = []; + for (let i = 0; i < maxResults; ++i) { + let url = (await UrlbarTestUtils.getDetailsOfResultAt(window, i)).url; + expected.push(url); + } + // Check all the results. + Assert.deepEqual(expected, urls); + + await clean(); + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_task(async function suggestedIndex_append() { + // When suggestedIndex is greater than the number of results the result is + // appended. + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/append/" } + ); + result.suggestedIndex = 4; + + let provider = new UrlbarTestUtils.TestProvider({ results: [result] }); + UrlbarProvidersManager.registerProvider(provider); + async function clean() { + UrlbarProvidersManager.unregisterProvider(provider); + await PlacesUtils.history.clear(); + } + registerCleanupFunction(clean); + + await PlacesTestUtils.addVisits("http://example.com/bar"); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "bar", + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 3, + `There should be 3 results in the view.` + ); + + let urls = [ + (await Services.search.getDefault()).getSubmission("bar").uri.spec, + "http://example.com/bar", + "http://mozilla.org/append/", + ]; + + let expected = []; + for (let i = 0; i < 3; ++i) { + let url = (await UrlbarTestUtils.getDetailsOfResultAt(window, i)).url; + expected.push(url); + } + // Check all the results. + Assert.deepEqual(expected, urls); + + await clean(); + await UrlbarTestUtils.promisePopupClose(window); +}); diff --git a/browser/components/urlbar/tests/browser/browser_suppressFocusBorder.js b/browser/components/urlbar/tests/browser/browser_suppressFocusBorder.js new file mode 100644 index 0000000000..769c1790a9 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_suppressFocusBorder.js @@ -0,0 +1,391 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that the suppress-focus-border attribute is applied to the Urlbar + * correctly. Its purpose is to hide the focus border after the panel is closed. + * It also ensures we don't flash the border at the user after they click the + * Urlbar but before we decide we're opening the view. + */ + +let TEST_RESULT = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/" } +); + +/** + * A test provider that awaits a promise before returning results. + */ +class AwaitPromiseProvider extends UrlbarTestUtils.TestProvider { + /** + * @param {object} args + * The constructor arguments for UrlbarTestUtils.TestProvider. + * @param {Promise} promise + * The promise that will be awaited before returning results. + */ + constructor(args, promise) { + super(args); + this._promise = promise; + } + + async startQuery(context, add) { + await this._promise; + for (let result of this.results) { + add(this, result); + } + } +} + +add_setup(async function () { + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + registerCleanupFunction(function () { + SpecialPowers.clipboardCopyString(""); + }); +}); + +add_task(async function afterMousedown_topSites() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + win.gURLBar.blur(); + + await withAwaitProvider( + { results: [TEST_RESULT], priority: Infinity }, + getSuppressFocusPromise(win), + async () => { + Assert.ok( + !win.gURLBar.hasAttribute("suppress-focus-border"), + "Sanity check: the Urlbar does not have the supress-focus-border attribute." + ); + + await UrlbarTestUtils.promisePopupOpen(win, () => { + if (win.gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(win.gURLBar.inputField, {}, win); + }); + + let result = await UrlbarTestUtils.waitForAutocompleteResultAt(win, 0); + Assert.ok( + result, + "The provider returned a result after waiting for the suppress-focus-border attribute." + ); + + await UrlbarTestUtils.promisePopupClose(win); + Assert.ok( + !gURLBar.hasAttribute("suppress-focus-border"), + "The Urlbar no longer has the supress-focus-border attribute after close." + ); + } + ); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function openLocation_topSites() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + + await withAwaitProvider( + { results: [TEST_RESULT], priority: Infinity }, + getSuppressFocusPromise(win), + async () => { + Assert.ok( + !win.gURLBar.hasAttribute("suppress-focus-border"), + "Sanity check: the Urlbar does not have the supress-focus-border attribute." + ); + + await UrlbarTestUtils.promisePopupOpen(win, () => { + EventUtils.synthesizeKey("l", { accelKey: true }, win); + }); + + let result = await UrlbarTestUtils.waitForAutocompleteResultAt(win, 0); + Assert.ok( + result, + "The provider returned a result after waiting for the suppress-focus-border attribute." + ); + + await UrlbarTestUtils.promisePopupClose(win); + Assert.ok( + !win.gURLBar.hasAttribute("suppress-focus-border"), + "The Urlbar no longer has the supress-focus-border attribute after close." + ); + } + ); + + await BrowserTestUtils.closeWindow(win); +}); + +// Tests that the address bar loses the suppress-focus-border attribute if no +// results are returned by a query. This simulates the user disabling Top Sites +// then clicking the address bar. +add_task(async function afterMousedown_noTopSites() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + + await withAwaitProvider( + // Note that the provider returns no results. + { results: [], priority: Infinity }, + getSuppressFocusPromise(win), + async () => { + Assert.ok( + !win.gURLBar.hasAttribute("suppress-focus-border"), + "Sanity check: the Urlbar does not have the supress-focus-border attribute." + ); + + EventUtils.synthesizeMouseAtCenter(win.gURLBar.inputField, {}, win); + // Because the panel opening may not be immediate, we must wait a bit. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + Assert.ok(!UrlbarTestUtils.isPopupOpen(win), "The popup is not open."); + + Assert.ok( + !win.gURLBar.hasAttribute("suppress-focus-border"), + "The Urlbar no longer has the supress-focus-border attribute." + ); + } + ); + + await BrowserTestUtils.closeWindow(win); +}); + +// Tests that we show the focus border when new tabs are opened. +add_task(async function newTab() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + + // Tabs opened with withNewTab don't focus the Urlbar, so we have to open one + // manually. + let tab = await openAboutNewTab(win); + await BrowserTestUtils.waitForCondition( + () => win.gURLBar.hasAttribute("focused"), + "Waiting for the Urlbar to become focused." + ); + Assert.ok( + !win.gURLBar.hasAttribute( + "suppress-focus-border", + "The Urlbar does not have the suppress-focus-border attribute." + ) + ); + + BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.closeWindow(win); +}); + +// Tests that we show the focus border when a new tab is opened and the address +// bar panel is already open. +add_task(async function newTab_alreadyOpen() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + + await withAwaitProvider( + { results: [TEST_RESULT], priority: Infinity }, + getSuppressFocusPromise(win), + async () => { + await UrlbarTestUtils.promisePopupOpen(win, () => { + EventUtils.synthesizeKey("l", { accelKey: true }, win); + }); + + let tab = await openAboutNewTab(win); + await BrowserTestUtils.waitForCondition( + () => !UrlbarTestUtils.isPopupOpen(win), + "Waiting for the Urlbar panel to close." + ); + Assert.ok( + !win.gURLBar.hasAttribute( + "suppress-focus-border", + "The Urlbar does not have the suppress-focus-border attribute." + ) + ); + BrowserTestUtils.removeTab(tab); + } + ); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function searchTip() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + + info("Set a pref to show a search tip button."); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.searchTips.test.ignoreShowLimits", true]], + }); + + info("Open new tab."); + const tab = await openAboutNewTab(win); + + info("Click the tip button."); + const result = await UrlbarTestUtils.getDetailsOfResultAt(win, 0); + const button = result.element.row._buttons.get("0"); + await UrlbarTestUtils.promisePopupClose(win, () => { + EventUtils.synthesizeMouseAtCenter(button, {}, win); + }); + + Assert.ok( + !win.gURLBar.hasAttribute( + "suppress-focus-border", + "The Urlbar does not have the suppress-focus-border attribute." + ) + ); + + BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.closeWindow(win); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function interactionOnNewTab() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + + info("Open about:newtab in new tab"); + const tab = await openAboutNewTab(win); + await BrowserTestUtils.waitForCondition( + () => win.gBrowser.selectedTab === tab + ); + + await testInteractionsOnAboutNewTab(win); + + BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function interactionOnNewTabInPrivateWindow() { + const win = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + waitForTabURL: "about:privatebrowsing", + }); + await testInteractionsOnAboutNewTab(win); + await BrowserTestUtils.closeWindow(win); + await SimpleTest.promiseFocus(window); +}); + +add_task(async function clickOnEdgeOfURLBar() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + win.gURLBar.blur(); + + Assert.ok( + !win.gURLBar.hasAttribute("suppress-focus-border"), + "URLBar does not have suppress-focus-border attribute" + ); + + const onHiddenFocusRemoved = BrowserTestUtils.waitForCondition( + () => !win.gURLBar._hideFocus + ); + + const container = win.document.getElementById("urlbar-input-container"); + container.click(); + + await onHiddenFocusRemoved; + Assert.ok( + win.gURLBar.hasAttribute("suppress-focus-border"), + "suppress-focus-border is set from the beginning" + ); + + await UrlbarTestUtils.promisePopupClose(win.window); + await BrowserTestUtils.closeWindow(win); +}); + +async function testInteractionsOnAboutNewTab(win) { + info("Test for clicking on URLBar while showing about:newtab"); + await testInteractionFeature(() => { + info("Click on URLBar"); + EventUtils.synthesizeMouseAtCenter(win.gURLBar.inputField, {}, win); + }, win); + + info("Test for typing on .fake-editable while showing about:newtab"); + await testInteractionFeature(() => { + info("Type a character on .fake-editable"); + EventUtils.synthesizeKey("v", {}, win); + }, win); + Assert.equal(win.gURLBar.value, "v", "URLBar value is correct"); + + info("Test for typing on .fake-editable while showing about:newtab"); + await testInteractionFeature(() => { + info("Paste some words on .fake-editable"); + SpecialPowers.clipboardCopyString("paste test"); + win.document.commandDispatcher + .getControllerForCommand("cmd_paste") + .doCommand("cmd_paste"); + SpecialPowers.clipboardCopyString(""); + }, win); + Assert.equal(win.gURLBar.value, "paste test", "URLBar value is correct"); +} + +async function testInteractionFeature(interaction, win) { + info("Focus on URLBar"); + win.gURLBar.value = ""; + win.gURLBar.focus(); + Assert.ok( + !win.gURLBar.hasAttribute("suppress-focus-border"), + "URLBar does not have suppress-focus-border attribute" + ); + + info("Click on search-handoff-button in newtab page"); + await ContentTask.spawn(win.gBrowser.selectedBrowser, null, async () => { + await ContentTaskUtils.waitForCondition(() => + content.document.querySelector(".search-handoff-button") + ); + content.document.querySelector(".search-handoff-button").click(); + }); + + await BrowserTestUtils.waitForCondition( + () => win.gURLBar._hideFocus, + "Wait until _hideFocus will be true" + ); + + const onHiddenFocusRemoved = BrowserTestUtils.waitForCondition( + () => !win.gURLBar._hideFocus + ); + + await interaction(); + + await onHiddenFocusRemoved; + Assert.ok( + win.gURLBar.hasAttribute("suppress-focus-border"), + "suppress-focus-border is set from the beginning" + ); + + const result = await UrlbarTestUtils.waitForAutocompleteResultAt(win, 0); + Assert.ok(result, "The provider returned a result"); + await UrlbarTestUtils.promisePopupClose(win); +} + +function getSuppressFocusPromise(win = window) { + return new Promise(resolve => { + let observer = new MutationObserver(() => { + if ( + win.gURLBar.hasAttribute("suppress-focus-border") && + !UrlbarTestUtils.isPopupOpen(win) + ) { + resolve(); + observer.disconnect(); + } + }); + observer.observe(win.gURLBar.textbox, { + attributes: true, + attributeFilter: ["suppress-focus-border"], + }); + }); +} + +async function withAwaitProvider(args, promise, callback) { + let provider = new AwaitPromiseProvider(args, promise); + UrlbarProvidersManager.registerProvider(provider); + try { + await callback(); + } catch (ex) { + console.error(ex); + } finally { + UrlbarProvidersManager.unregisterProvider(provider); + } +} + +async function openAboutNewTab(win = window) { + // We have to listen for the new tab using this brute force method. + // about:newtab is preloaded in the background. When about:newtab is opened, + // the cached version is shown. Since the page is already loaded, + // waitForNewTab does not detect it. It also doesn't fire the TabOpen event. + const tabCount = win.gBrowser.tabs.length; + EventUtils.synthesizeKey("t", { accelKey: true }, win); + await TestUtils.waitForCondition( + () => win.gBrowser.tabs.length === tabCount + 1, + "Waiting for background about:newtab to open." + ); + return win.gBrowser.tabs[win.gBrowser.tabs.length - 1]; +} diff --git a/browser/components/urlbar/tests/browser/browser_switchTab_closesUrlbarPopup.js b/browser/components/urlbar/tests/browser/browser_switchTab_closesUrlbarPopup.js new file mode 100644 index 0000000000..a9b0eb7b1a --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_switchTab_closesUrlbarPopup.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * Checks that switching tabs closes the urlbar popup. + */ + +"use strict"; + +add_task(async function () { + let tab1 = BrowserTestUtils.addTab(gBrowser); + let tab2 = BrowserTestUtils.addTab(gBrowser); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + }); + + // Add a couple of dummy entries to ensure the history popup will open. + await PlacesTestUtils.addVisits([ + { uri: makeURI("http://example.com/foo") }, + { uri: makeURI("http://example.com/foo/bar") }, + ]); + + // When urlbar in a new tab is focused, and a tab switch occurs, + // the urlbar popup should be closed + await BrowserTestUtils.switchTab(gBrowser, tab2); + gURLBar.focus(); // focus the urlbar in the tab we will switch to + await BrowserTestUtils.switchTab(gBrowser, tab1); + // Now open the popup. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + // Check that the popup closes when we switch tab. + await UrlbarTestUtils.promisePopupClose(window, () => { + return BrowserTestUtils.switchTab(gBrowser, tab2); + }); + Assert.ok(true, "Popup was successfully closed"); +}); diff --git a/browser/components/urlbar/tests/browser/browser_switchTab_currentTab.js b/browser/components/urlbar/tests/browser/browser_switchTab_currentTab.js new file mode 100644 index 0000000000..eccee800e3 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_switchTab_currentTab.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test ensures that switch to tab still works when the URI contains an + * encoded part. + */ + +"use strict"; + +add_task(async function test_switchTab_currentTab() { + registerCleanupFunction(PlacesUtils.history.clear); + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:robots#1" }, + async () => { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:robots#2" }, + async () => { + let context = await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "robot", + }); + Assert.ok( + context.results.some( + result => + result.type == UrlbarUtils.RESULT_TYPE.TAB_SWITCH && + result.payload.url == "about:robots#1" + ) + ); + Assert.ok( + !context.results.some( + result => + result.type == UrlbarUtils.RESULT_TYPE.TAB_SWITCH && + result.payload.url == "about:robots#2" + ) + ); + } + ); + } + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_switchTab_decodeuri.js b/browser/components/urlbar/tests/browser/browser_switchTab_decodeuri.js new file mode 100644 index 0000000000..fe23eceaf9 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_switchTab_decodeuri.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test ensures that switch to tab still works when the URI contains an + * encoded part. + */ + +"use strict"; + +const TEST_URL = `${TEST_BASE_URL}dummy_page.html#test%7C1`; + +add_task(async function test_switchtab_decodeuri() { + info("Opening first tab"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + info("Opening and selecting second tab"); + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser); + + info("Wait for autocomplete"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "dummy_page", + }); + + info("Select autocomplete popup entry"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + let result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + UrlbarTestUtils.getSelectedRowIndex(window) + ); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.TAB_SWITCH); + + info("switch-to-tab"); + let tabSelectPromise = BrowserTestUtils.waitForEvent( + window, + "TabSelect", + false + ); + EventUtils.synthesizeKey("KEY_Enter"); + await tabSelectPromise; + + Assert.equal( + gBrowser.selectedTab, + tab, + "Should have switched to the right tab" + ); + + gBrowser.removeCurrentTab(); + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_switchTab_inputHistory.js b/browser/components/urlbar/tests/browser/browser_switchTab_inputHistory.js new file mode 100644 index 0000000000..0da3161d0e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_switchTab_inputHistory.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This tests ensures that the urlbar adaptive behavior updates + * when using switch to tab in the address bar dropdown. + */ + +"use strict"; + +add_setup(async function () { + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function test_adaptive_with_search_term_and_switch_tab() { + await PlacesUtils.history.clear(); + let urls = [ + "https://example.com/", + "https://example.com/#cat", + "https://example.com/#cake", + "https://example.com/#car", + ]; + + info(`Load tabs in same order as urls`); + let tabs = []; + for (let url of urls) { + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, url, false, true); + gBrowser.loadTabs([url], { + inBackground: true, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + + let tab = await tabPromise; + tabs.push(tab); + } + + info(`Switch to tab 0`); + await BrowserTestUtils.switchTab(gBrowser, tabs[0]); + + info("Wait for autocomplete"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ca", + }); + + let result1 = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.notEqual(result1.url, urls[1], `${urls[1]} url should not be first`); + + info(`Scroll down to select the ${urls[1]} entry using keyboard`); + let result2 = await UrlbarTestUtils.getDetailsOfResultAt( + window, + UrlbarTestUtils.getSelectedRowIndex(window) + ); + + while (result2.url != urls[1]) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + result2 = await UrlbarTestUtils.getDetailsOfResultAt( + window, + UrlbarTestUtils.getSelectedRowIndex(window) + ); + } + + Assert.equal( + result2.type, + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + "Selected entry should be tab switch" + ); + Assert.equal(result2.url, urls[1]); + + info("Visiting tab 1"); + EventUtils.synthesizeKey("KEY_Enter"); + Assert.equal(gBrowser.selectedTab, tabs[1], "Should have switched to tab 1"); + + info("Switch back to tab 0"); + await BrowserTestUtils.switchTab(gBrowser, tabs[0]); + + info("Wait for autocomplete"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ca", + }); + + let result3 = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result3.url, urls[1], `${urls[1]} url should be first`); + + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } +}); diff --git a/browser/components/urlbar/tests/browser/browser_switchTab_override.js b/browser/components/urlbar/tests/browser/browser_switchTab_override.js new file mode 100644 index 0000000000..66426a154b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_switchTab_override.js @@ -0,0 +1,100 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This test ensures that overriding switch-to-tab correctly loads the page + * rather than switching to it. + */ + +"use strict"; + +const TEST_URL = `${TEST_BASE_URL}dummy_page.html`; + +add_task(async function test_switchtab_override() { + info("Opening first tab"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + info("Opening and selecting second tab"); + let secondTab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + registerCleanupFunction(() => { + try { + gBrowser.removeTab(tab); + gBrowser.removeTab(secondTab); + } catch (ex) { + /* tabs may have already been closed in case of failure */ + } + }); + + info("Wait for autocomplete"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "dummy_page", + }); + + info("Select second autocomplete popup entry"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + let result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + UrlbarTestUtils.getSelectedRowIndex(window) + ); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.TAB_SWITCH); + + // Check to see if the switchtab label is visible and + // all other labels are hidden + const allLabels = document.getElementById("urlbar-label-box").children; + for (let label of allLabels) { + if (label.id == "urlbar-label-switchtab") { + Assert.ok(BrowserTestUtils.isVisible(label)); + } else { + Assert.ok(BrowserTestUtils.isHidden(label)); + } + } + + info("Override switch-to-tab"); + let deferred = Promise.withResolvers(); + // In case of failure this would switch tab. + let onTabSelect = event => { + deferred.reject(new Error("Should have overridden switch to tab")); + }; + gBrowser.tabContainer.addEventListener("TabSelect", onTabSelect); + registerCleanupFunction(() => { + gBrowser.tabContainer.removeEventListener("TabSelect", onTabSelect); + }); + // Otherwise it would load the page. + BrowserTestUtils.browserLoaded(secondTab.linkedBrowser).then( + deferred.resolve + ); + + EventUtils.synthesizeKey("KEY_Shift", { type: "keydown" }); + + // Checks that all labels are hidden when Shift is held down on the SwitchToTab result + for (let label of allLabels) { + Assert.ok(BrowserTestUtils.isHidden(label)); + } + + registerCleanupFunction(() => { + // Avoid confusing next tests by leaving a pending keydown. + EventUtils.synthesizeKey("KEY_Shift", { type: "keyup" }); + }); + + let attribute = "action-override"; + Assert.ok( + gURLBar.view.panel.hasAttribute(attribute), + "We should be overriding" + ); + + EventUtils.synthesizeKey("KEY_Enter"); + info(`gURLBar.value = ${gURLBar.value}`); + await deferred.promise; + + // Blurring the urlbar should have cleared the override. + Assert.ok( + !gURLBar.view.panel.hasAttribute(attribute), + "We should not be overriding anymore" + ); + + await PlacesUtils.history.clear(); + gBrowser.removeTab(tab); + gBrowser.removeTab(secondTab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_switchToTabHavingURI_aOpenParams.js b/browser/components/urlbar/tests/browser/browser_switchToTabHavingURI_aOpenParams.js new file mode 100644 index 0000000000..1a0d2eef70 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_switchToTabHavingURI_aOpenParams.js @@ -0,0 +1,217 @@ +/* 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/. */ + +add_task(async function test_ignoreFragment() { + let tabRefAboutHome = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:home#1" + ); + await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:mozilla"); + let numTabsAtStart = gBrowser.tabs.length; + + switchTab("about:home#1", true); + switchTab("about:mozilla", true); + + let hashChangePromise = ContentTask.spawn( + tabRefAboutHome.linkedBrowser, + [], + async function () { + await ContentTaskUtils.waitForEvent(this, "hashchange", true); + } + ); + switchTab("about:home#2", true, { + ignoreFragment: "whenComparingAndReplace", + }); + is( + tabRefAboutHome, + gBrowser.selectedTab, + "The same about:home tab should be switched to" + ); + await hashChangePromise; + is(gBrowser.currentURI.ref, "2", "The ref should be updated to the new ref"); + switchTab("about:mozilla", true); + switchTab("about:home#3", true, { ignoreFragment: "whenComparing" }); + is( + tabRefAboutHome, + gBrowser.selectedTab, + "The same about:home tab should be switched to" + ); + is( + gBrowser.currentURI.ref, + "2", + "The ref should be unchanged since the fragment is only ignored when comparing" + ); + switchTab("about:mozilla", true); + switchTab("about:home#1", false); + isnot( + tabRefAboutHome, + gBrowser.selectedTab, + "Selected tab should not be initial about:blank tab" + ); + is( + gBrowser.tabs.length, + numTabsAtStart + 1, + "Should have one new tab opened" + ); + switchTab("about:mozilla", true); + switchTab("about:home", true, { ignoreFragment: "whenComparingAndReplace" }); + await BrowserTestUtils.waitForCondition(function () { + return tabRefAboutHome.linkedBrowser.currentURI.spec == "about:home"; + }); + is( + tabRefAboutHome.linkedBrowser.currentURI.spec, + "about:home", + "about:home shouldn't have hash" + ); + switchTab("about:about", false, { + ignoreFragment: "whenComparingAndReplace", + }); + cleanupTestTabs(); +}); + +add_task(async function test_ignoreQueryString() { + let tabRefAboutHome = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:home?hello=firefox" + ); + await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:mozilla"); + + switchTab("about:home?hello=firefox", true); + switchTab("about:home?hello=firefoxos", false); + // Remove the last opened tab to test ignoreQueryString option. + gBrowser.removeCurrentTab(); + switchTab("about:home?hello=firefoxos", true, { ignoreQueryString: true }); + is( + tabRefAboutHome, + gBrowser.selectedTab, + "Selected tab should be the initial about:home tab" + ); + is( + gBrowser.currentURI.spec, + "about:home?hello=firefox", + "The spec should NOT be updated to the new query string" + ); + cleanupTestTabs(); +}); + +add_task(async function test_replaceQueryString() { + let tabRefAboutHome = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:home?hello=firefox" + ); + await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:mozilla"); + + switchTab("about:home", false); + switchTab("about:home?hello=firefox", true); + switchTab("about:home?hello=firefoxos", false); + // Remove the last opened tab to test replaceQueryString option. + gBrowser.removeCurrentTab(); + switchTab("about:home?hello=firefoxos", true, { replaceQueryString: true }); + is( + tabRefAboutHome, + gBrowser.selectedTab, + "Selected tab should be the initial about:home tab" + ); + // Wait for the tab to load the new URI spec. + await BrowserTestUtils.browserLoaded(tabRefAboutHome.linkedBrowser); + is( + gBrowser.currentURI.spec, + "about:home?hello=firefoxos", + "The spec should be updated to the new spec" + ); + cleanupTestTabs(); +}); + +add_task(async function test_replaceQueryStringAndFragment() { + let tabRefAboutHome = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:home?hello=firefox#aaa" + ); + let tabRefAboutMozilla = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla?hello=firefoxos#aaa" + ); + + switchTab("about:home", false); + gBrowser.removeCurrentTab(); + switchTab("about:home?hello=firefox#aaa", true); + is( + tabRefAboutHome, + gBrowser.selectedTab, + "Selected tab should be the initial about:home tab" + ); + switchTab("about:mozilla?hello=firefox#bbb", true, { + replaceQueryString: true, + ignoreFragment: "whenComparingAndReplace", + }); + is( + tabRefAboutMozilla, + gBrowser.selectedTab, + "Selected tab should be the initial about:mozilla tab" + ); + switchTab("about:home?hello=firefoxos#bbb", true, { + ignoreQueryString: true, + ignoreFragment: "whenComparingAndReplace", + }); + is( + tabRefAboutHome, + gBrowser.selectedTab, + "Selected tab should be the initial about:home tab" + ); + cleanupTestTabs(); +}); + +add_task(async function test_ignoreQueryStringIgnoresFragment() { + let tabRefAboutHome = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:home?hello=firefox#aaa" + ); + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla?hello=firefoxos#aaa" + ); + + switchTab("about:home?hello=firefox#bbb", false, { ignoreQueryString: true }); + gBrowser.removeCurrentTab(); + switchTab("about:home?hello=firefoxos#aaa", true, { + ignoreQueryString: true, + }); + is( + tabRefAboutHome, + gBrowser.selectedTab, + "Selected tab should be the initial about:home tab" + ); + cleanupTestTabs(); +}); + +// Begin helpers + +function cleanupTestTabs() { + while (gBrowser.tabs.length > 1) { + gBrowser.removeCurrentTab(); + } +} + +function switchTab(aURI, aShouldFindExistingTab, aOpenParams = {}) { + // Build the description before switchToTabHavingURI deletes the object properties. + let msg = + `Should switch to existing ${aURI} tab if one existed, ` + + `${ + aOpenParams.ignoreFragment ? "ignoring" : "including" + } fragment portion, `; + if (aOpenParams.replaceQueryString) { + msg += "replacing"; + } else if (aOpenParams.ignoreQueryString) { + msg += "ignoring"; + } else { + msg += "including"; + } + msg += " query string."; + aOpenParams.triggeringPrincipal = + Services.scriptSecurityManager.getSystemPrincipal(); + let tabFound = switchToTabHavingURI(aURI, true, aOpenParams); + is(tabFound, aShouldFindExistingTab, msg); +} + +registerCleanupFunction(cleanupTestTabs); diff --git a/browser/components/urlbar/tests/browser/browser_switchToTab_chiclet.js b/browser/components/urlbar/tests/browser/browser_switchToTab_chiclet.js new file mode 100644 index 0000000000..ee887c6796 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_switchToTab_chiclet.js @@ -0,0 +1,122 @@ +/* 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/. */ + +/** + * Test for chiclet upon switching tab mode. + */ + +"use strict"; + +const TEST_URL = `${TEST_BASE_URL}dummy_page.html`; + +add_task(async function test_with_oneoff_button() { + info("Loading test page into first tab"); + let promiseLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + TEST_URL + ); + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, TEST_URL); + await promiseLoad; + + info("Opening a new tab"); + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + info("Wait for autocomplete"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + + info("Enter Tabs mode"); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.TABS, + }); + + info("Select first popup entry"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "dummy", + }); + EventUtils.synthesizeKey("KEY_ArrowDown"); + const result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + UrlbarTestUtils.getSelectedRowIndex(window) + ); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.TAB_SWITCH); + + info("Enter escape key"); + EventUtils.synthesizeKey("KEY_Escape"); + + info("Check label visibility"); + const searchModeTitle = document.getElementById( + "urlbar-search-mode-indicator-title" + ); + const switchTabLabel = document.getElementById("urlbar-label-switchtab"); + await BrowserTestUtils.waitForCondition( + () => + BrowserTestUtils.isVisible(searchModeTitle) && + searchModeTitle.textContent === "Tabs", + "Waiting until the search mode title will be visible" + ); + await BrowserTestUtils.waitForCondition( + () => BrowserTestUtils.isHidden(switchTabLabel), + "Waiting until the switch tab label will be hidden" + ); + + await PlacesUtils.history.clear(); + gBrowser.removeTab(tab); +}); + +add_task(async function test_with_keytype() { + info("Loading test page into first tab"); + let promiseLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + TEST_URL + ); + BrowserTestUtils.startLoadingURIString(gBrowser, TEST_URL); + await promiseLoad; + + info("Opening a new tab"); + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + info("Enter Tabs mode with keytype"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "%", + }); + + info("Select second popup entry"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "dummy", + }); + EventUtils.synthesizeKey("KEY_ArrowDown"); + const result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + UrlbarTestUtils.getSelectedRowIndex(window) + ); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.TAB_SWITCH); + + info("Enter escape key"); + EventUtils.synthesizeKey("KEY_Escape"); + + info("Check label visibility"); + const searchModeTitle = document.getElementById( + "urlbar-search-mode-indicator-title" + ); + const switchTabLabel = document.getElementById("urlbar-label-switchtab"); + await BrowserTestUtils.waitForCondition( + () => BrowserTestUtils.isHidden(searchModeTitle), + "Waiting until the search mode title will be hidden" + ); + await BrowserTestUtils.waitForCondition( + () => BrowserTestUtils.isVisible(switchTabLabel), + "Waiting until the switch tab label will be visible" + ); + + await PlacesUtils.history.clear(); + gBrowser.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_switchToTab_closed_tab.js b/browser/components/urlbar/tests/browser/browser_switchToTab_closed_tab.js new file mode 100644 index 0000000000..85b428db61 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_switchToTab_closed_tab.js @@ -0,0 +1,90 @@ +/* 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 tests that the "switch to tab" result in the urlbar + * will still load the relevant URL if the tab being referred + * to does not exist. + */ + +"use strict"; + +const { UrlbarProviderOpenTabs } = ChromeUtils.importESModule( + "resource:///modules/UrlbarProviderOpenTabs.sys.mjs" +); + +async function openPagesCount() { + let conn = await PlacesUtils.promiseLargeCacheDBConnection(); + let res = await conn.executeCached( + "SELECT COUNT(*) AS count FROM moz_openpages_temp;" + ); + return res[0].getResultByName("count"); +} + +add_task(async function test_switchToTab_tab_closed() { + let testURL = + "https://example.org/browser/browser/components/urlbar/tests/browser/dummy_page.html"; + + // Open a blank tab to start the test from. + let testTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.org" + ); + + // Check how many currently open pages are registered + let pagesCount = await openPagesCount(); + + // Register an open tab that does not exist, this simulates a tab being + // opened but not properly unregistered. + await UrlbarProviderOpenTabs.registerOpenTab( + testURL, + gBrowser.contentPrincipal.userContextId, + false + ); + + Assert.equal( + await openPagesCount(), + pagesCount + 1, + "We registered a new open page" + ); + + let tabOpenPromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen", + false + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: testURL, + }); + + // The first result is the heuristic, the second will be the switch to tab. + let element = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + EventUtils.synthesizeMouseAtCenter(element, {}, window); + + await tabOpenPromise; + await BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + testURL + ); + + Assert.equal( + gBrowser.selectedBrowser.currentURI.spec, + testURL, + "We opened a new tab with the URL" + ); + + gBrowser.removeTab(gBrowser.selectedTab); + + Assert.equal( + await openPagesCount(), + pagesCount, + "We unregistered the orphaned open tab" + ); + + gBrowser.removeTab(testTab); + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_switchToTab_closes_newtab.js b/browser/components/urlbar/tests/browser/browser_switchToTab_closes_newtab.js new file mode 100644 index 0000000000..5031491d7e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_switchToTab_closes_newtab.js @@ -0,0 +1,63 @@ +/* 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 tests that switch to tab from a blank tab switches and then closes + * the blank tab. + */ + +"use strict"; + +add_task(async function test_switchToTab_closes() { + let testURL = + "http://example.org/browser/browser/components/urlbar/tests/browser/dummy_page.html"; + + // Open the base tab + let baseTab = await BrowserTestUtils.openNewForegroundTab(gBrowser, testURL); + + if (baseTab.linkedBrowser.currentURI.spec == "about:blank") { + return; + } + + // Open a blank tab to start the test from. + let testTab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + // Functions for TabClose and TabSelect + let tabClosePromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabClose", + false, + event => { + return event.originalTarget == testTab; + } + ); + let tabSelectPromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabSelect", + false, + event => { + return event.originalTarget == baseTab; + } + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "dummy", + }); + + // The first result is the heuristic, the second will be the switch to tab. + let element = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + EventUtils.synthesizeMouseAtCenter(element, {}, window); + + await Promise.all([tabSelectPromise, tabClosePromise]); + + // Confirm that the selected tab is now the base tab + Assert.equal( + gBrowser.selectedTab, + baseTab, + "Should have switched to the correct tab" + ); + + gBrowser.removeTab(baseTab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_switchToTab_fullUrl_repeatedKeydown.js b/browser/components/urlbar/tests/browser/browser_switchToTab_fullUrl_repeatedKeydown.js new file mode 100644 index 0000000000..8f80ac5841 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_switchToTab_fullUrl_repeatedKeydown.js @@ -0,0 +1,60 @@ +/* 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 tests that typing a url and picking a switch to tab actually switches + * to the right tab. Also tests repeated keydown/keyup events don't confuse + * override. + */ + +"use strict"; + +add_task(async function test_switchToTab_url() { + const TEST_URL = "https://example.org/browser/"; + + let baseTab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + let testTab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + // Functions for TabClose and TabSelect + let tabClosePromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabClose", + false, + event => event.target == testTab + ); + let tabSelectPromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabSelect", + false, + event => event.target == baseTab + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_URL, + fireInputEvent: true, + }); + // The first result is the heuristic, the second will be the switch to tab. + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + + // Simulate a long press, on some platforms (Windows) it can generate multiple + // keydown events. + EventUtils.synthesizeKey("VK_SHIFT", { type: "keydown", repeat: 3 }); + EventUtils.synthesizeKey("VK_SHIFT", { type: "keyup" }); + + // Pick the switch to tab result. + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + + await Promise.all([tabSelectPromise, tabClosePromise]); + + // Confirm that the selected tab is now the base tab + Assert.equal( + gBrowser.selectedTab, + baseTab, + "Should have switched to the correct tab" + ); + + gBrowser.removeTab(baseTab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_tabKeyBehavior.js b/browser/components/urlbar/tests/browser/browser_tabKeyBehavior.js new file mode 100644 index 0000000000..32e842d43e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_tabKeyBehavior.js @@ -0,0 +1,378 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test makes sure that the tab key properly adjusts the selection or moves +// through toolbar items, depending on the urlbar state. +// When the view is open, tab should go through results if the urlbar was +// focused with the mouse, or has a typed string. + +"use strict"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); + + for (let i = 0; i < UrlbarPrefs.get("maxRichResults"); i++) { + await PlacesTestUtils.addVisits("http://example.com/" + i); + } + + registerCleanupFunction(PlacesUtils.history.clear); + + CustomizableUI.addWidgetToArea("home-button", "nav-bar", 0); + CustomizableUI.addWidgetToArea("sidebar-button", "nav-bar"); + registerCleanupFunction(() => { + CustomizableUI.removeWidgetFromArea("home-button"); + CustomizableUI.removeWidgetFromArea("sidebar-button"); + }); +}); + +add_task(async function tabWithSearchString() { + info("Tab with a search string"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + await expectTabThroughResults(); + info("Reverse Tab with a search string"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + await expectTabThroughResults({ reverse: true }); +}); + +add_task(async function tabNoSearchString() { + info("Tab without a search string"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + fireInputEvent: true, + }); + await expectTabThroughToolbar(); + info("Reverse Tab without a search string"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + fireInputEvent: true, + }); + await expectTabThroughToolbar({ reverse: true }); +}); + +add_task(async function tabAfterBlur() { + info("Tab after closing the view"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + await UrlbarTestUtils.promisePopupClose(window); + await expectTabThroughToolbar(); +}); + +add_task(async function tabNoSearchStringMouseFocus() { + info("Tab in a new blank tab after mouse focus"); + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + await expectTabThroughResults(); + }); + info("Tab in a loaded tab after mouse focus"); + await BrowserTestUtils.withNewTab("example.com", async () => { + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + await expectTabThroughResults(); + }); +}); + +add_task(async function tabNoSearchStringKeyboardFocus() { + info("Tab in a new blank tab after keyboard focus"); + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeKey("l", { accelKey: true }); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + await expectTabThroughToolbar(); + }); + info("Tab in a loaded tab after keyboard focus"); + await BrowserTestUtils.withNewTab("example.com", async () => { + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeKey("l", { accelKey: true }); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + await expectTabThroughToolbar(); + }); +}); + +add_task(async function tabRetainedResultMouseFocus() { + info("Tab after retained results with mouse focus"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + await expectTabThroughResults(); +}); + +add_task(async function tabRetainedResultsKeyboardFocus() { + info("Tab after retained results with keyboard focus"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeKey("l", { accelKey: true }); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + await expectTabThroughResults(); +}); + +add_task(async function tabRetainedResults() { + info("Tab with a search string after mouse focus."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + await UrlbarTestUtils.promisePopupClose(window); + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + await expectTabThroughResults(); +}); + +add_task(async function tabSearchModePreview() { + info( + "Tab past a search mode preview keywordoffer after focusing with the keyboard." + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + fireInputEvent: true, + }); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeKey("l", { accelKey: true }); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok( + result.searchParams.keyword, + "The first result is a keyword offer." + ); + + // Sanity check: the Urlbar value is cleared when keywordoffer results are + // selected. + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.ok(!gURLBar.value, "The Urlbar should have no value."); + EventUtils.synthesizeKey("KEY_ArrowUp"); + + await expectTabThroughResults(); + + await UrlbarTestUtils.promisePopupClose(window, async () => { + gURLBar.blur(); + // Verify that blur closes search mode preview. + await UrlbarTestUtils.assertSearchMode(window, null); + }); +}); + +add_task(async function tabTabToSearch() { + info("Tab past a tab-to-search result after focusing with the keyboard."); + await SearchTestUtils.installSearchExtension(); + + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits(["https://example.com/"]); + } + + // Search for a tab-to-search result. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + }); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeKey("l", { accelKey: true }); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + let tabToSearchResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + tabToSearchResult.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + + await expectTabThroughResults(); + + await UrlbarTestUtils.promisePopupClose(window, async () => { + gURLBar.blur(); + await UrlbarTestUtils.assertSearchMode(window, null); + }); + await PlacesUtils.history.clear(); +}); + +add_task(async function tabNoSearchStringSearchMode() { + info( + "Tab through the toolbar when refocusing a Urlbar in search mode with the keyboard." + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + fireInputEvent: true, + }); + // Enter history search mode to avoid hitting the network. + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeKey("l", { accelKey: true }); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + + await expectTabThroughToolbar(); + + // We have to reopen the view to exit search mode. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + fireInputEvent: true, + }); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_task(async function tabOnTopSites() { + info("Tab through the toolbar when focusing the Address Bar on top sites."); + for (let val of [true, false]) { + info(`Test with keyboard_navigation set to "${val}"`); + await SpecialPowers.pushPrefEnv({ + set: [["browser.toolbars.keyboard_navigation", val]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + fireInputEvent: true, + }); + Assert.ok( + UrlbarTestUtils.getResultCount(window) > 0, + "There should be some results" + ); + Assert.deepEqual( + UrlbarTestUtils.getSelectedElement(window), + null, + "There should be no selection" + ); + + await expectTabThroughToolbar(); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); + } +}); + +async function expectTabThroughResults(options = { reverse: false }) { + let resultCount = UrlbarTestUtils.getResultCount(window); + Assert.ok(resultCount > 0, "There should be results"); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + + let initiallySelectedIndex = result.heuristic ? 0 : -1; + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + initiallySelectedIndex, + "Check the initial selection." + ); + + for (let i = initiallySelectedIndex + 1; i < resultCount; i++) { + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: options.reverse }); + if ( + UrlbarTestUtils.getButtonForResultIndex( + window, + "menu", + UrlbarTestUtils.getSelectedRowIndex(window) + ) + ) { + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: options.reverse }); + } + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + options.reverse ? resultCount - i : i + ); + } + + EventUtils.synthesizeKey("KEY_Tab"); + + if (!options.reverse) { + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + initiallySelectedIndex, + "Should be back at the initial selection." + ); + } + + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); +} + +async function expectTabThroughToolbar(options = { reverse: false }) { + if (gURLBar.getAttribute("pageproxystate") == "valid") { + Assert.equal(document.activeElement, gURLBar.inputField); + EventUtils.synthesizeKey("KEY_Tab"); + Assert.notEqual(document.activeElement, gURLBar.inputField); + } else { + let focusPromise = waitForFocusOnNextFocusableElement(options.reverse); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: options.reverse }); + await focusPromise; + } + Assert.ok(!gURLBar.view.isOpen, "The urlbar view should be closed."); +} + +async function waitForFocusOnNextFocusableElement(reverse = false) { + if ( + !Services.prefs.getBoolPref("browser.toolbars.keyboard_navigation", true) + ) { + return BrowserTestUtils.waitForCondition( + () => document.activeElement == gBrowser.selectedBrowser + ); + } + let urlbar = document.getElementById("urlbar-container"); + let nextFocusableElement = reverse + ? urlbar.previousElementSibling + : urlbar.nextElementSibling; + while ( + nextFocusableElement && + (!nextFocusableElement.classList.contains("toolbarbutton-1") || + nextFocusableElement.hasAttribute("hidden") || + nextFocusableElement.hasAttribute("disabled") || + BrowserTestUtils.isHidden(nextFocusableElement)) + ) { + nextFocusableElement = reverse + ? nextFocusableElement.previousElementSibling + : nextFocusableElement.nextElementSibling; + } + info( + `Next focusable element: ${nextFocusableElement.localName}.#${nextFocusableElement.id}` + ); + + Assert.ok( + nextFocusableElement.classList.contains("toolbarbutton-1"), + "We should have a reference to the next focusable element after the Urlbar." + ); + + return BrowserTestUtils.waitForCondition( + () => nextFocusableElement.tabIndex == -1 + ); +} diff --git a/browser/components/urlbar/tests/browser/browser_tabMatchesInAwesomebar.js b/browser/components/urlbar/tests/browser/browser_tabMatchesInAwesomebar.js new file mode 100644 index 0000000000..354cd3a802 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_tabMatchesInAwesomebar.js @@ -0,0 +1,224 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim:set ts=2 sw=2 sts=2 et: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Tests for ensuring that the tab switch results correctly match what is + * currently available. + */ + +requestLongerTimeout(2); + +const TEST_URL_BASES = [ + `${TEST_BASE_URL}dummy_page.html#tabmatch`, + `${TEST_BASE_URL}moz.png#tabmatch`, +]; + +const RESTRICT_TOKEN_OPENPAGE = "%"; + +var gTabCounter = 0; + +add_task(async function step_1() { + info("Running step 1"); + let maxResults = Services.prefs.getIntPref("browser.urlbar.maxRichResults"); + let promises = []; + for (let i = 0; i < maxResults - 1; i++) { + let tab = BrowserTestUtils.addTab(gBrowser); + promises.push(loadTab(tab, TEST_URL_BASES[0] + ++gTabCounter)); + } + + await Promise.all(promises); + await ensure_opentabs_match_db(); +}); + +add_task(async function step_2() { + info("Running step 2"); + gBrowser.selectTabAtIndex(1); + gBrowser.removeCurrentTab(); + gBrowser.selectTabAtIndex(1); + gBrowser.removeCurrentTab(); + gBrowser.selectTabAtIndex(0); + + let promises = []; + for (let i = 1; i < gBrowser.tabs.length; i++) { + promises.push(loadTab(gBrowser.tabs[i], TEST_URL_BASES[1] + ++gTabCounter)); + } + + await Promise.all(promises); + await ensure_opentabs_match_db(); +}); + +add_task(async function step_3() { + info("Running step 3"); + let promises = []; + for (let i = 1; i < gBrowser.tabs.length; i++) { + promises.push(loadTab(gBrowser.tabs[i], TEST_URL_BASES[0] + gTabCounter)); + } + + await Promise.all(promises); + await ensure_opentabs_match_db(); +}); + +add_task(async function step_4() { + info("Running step 4 - ensure we don't register subframes as open pages"); + let tab = BrowserTestUtils.addTab( + gBrowser, + 'data:text/html,' + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + let iframe_loaded = ContentTaskUtils.waitForEvent( + content.document, + "load", + true + ); + content.document.querySelector("iframe").src = "http://test2.example.org/"; + await iframe_loaded; + }); + + await ensure_opentabs_match_db(); +}); + +add_task(async function step_5() { + info("Running step 5 - remove tab immediately"); + let tab = BrowserTestUtils.addTab(gBrowser, "about:logo"); + BrowserTestUtils.removeTab(tab); + await ensure_opentabs_match_db(); +}); + +add_task(async function step_6() { + info( + "Running step 6 - check swapBrowsersAndCloseOther preserves registered switch-to-tab result" + ); + let tabToKeep = BrowserTestUtils.addTab(gBrowser); + let tab = BrowserTestUtils.addTab(gBrowser, "about:mozilla"); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + gBrowser.updateBrowserRemoteness(tabToKeep.linkedBrowser, { + remoteType: tab.linkedBrowser.isRemoteBrowser + ? E10SUtils.DEFAULT_REMOTE_TYPE + : E10SUtils.NOT_REMOTE, + }); + gBrowser.swapBrowsersAndCloseOther(tabToKeep, tab); + + await ensure_opentabs_match_db(); + + BrowserTestUtils.removeTab(tabToKeep); + + await ensure_opentabs_match_db(); +}); + +add_task(async function step_7() { + info("Running step 7 - close all tabs"); + + Services.prefs.clearUserPref("browser.sessionstore.restore_on_demand"); + + BrowserTestUtils.addTab(gBrowser, "about:blank", { skipAnimation: true }); + while (gBrowser.tabs.length > 1) { + info("Removing tab: " + gBrowser.tabs[0].linkedBrowser.currentURI.spec); + gBrowser.selectTabAtIndex(0); + gBrowser.removeCurrentTab(); + } + + await ensure_opentabs_match_db(); +}); + +add_task(async function cleanup() { + info("Cleaning up"); + + await PlacesUtils.history.clear(); +}); + +function loadTab(tab, url) { + // Because adding visits is async, we will not be notified immediately. + let loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + let visited = new Promise(resolve => { + Services.obs.addObserver(function observer(aSubject, aTopic, aData) { + if (url != aSubject.QueryInterface(Ci.nsIURI).spec) { + return; + } + Services.obs.removeObserver(observer, aTopic); + resolve(); + }, "uri-visit-saved"); + }); + + info("Loading page: " + url); + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, url); + return Promise.all([loaded, visited]); +} + +function ensure_opentabs_match_db() { + let tabs = {}; + + for (let browserWin of Services.wm.getEnumerator("navigator:browser")) { + // skip closed-but-not-destroyed windows + if (browserWin.closed) { + continue; + } + + for (let i = 0; i < browserWin.gBrowser.tabs.length; i++) { + let browser = browserWin.gBrowser.getBrowserAtIndex(i); + let url = browser.currentURI.spec; + if (browserWin.isBlankPageURL(url)) { + continue; + } + if (!(url in tabs)) { + tabs[url] = 1; + } else { + tabs[url]++; + } + } + } + + return checkAutocompleteResults(tabs); +} + +async function checkAutocompleteResults(expected) { + info("Searching open pages."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: RESTRICT_TOKEN_OPENPAGE, + }); + + let resultCount = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < resultCount; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (result.heuristic) { + info("Skip heuristic match"); + continue; + } + + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + "Should have a tab switch result" + ); + + let url = result.url; + + info(`Search for ${url} in open tabs.`); + let inExpected = url in expected; + Assert.ok( + inExpected, + `${url} was found in autocomplete, was ${ + inExpected ? "" : "not " + } expected` + ); + // Remove the found entry from expected results. + delete expected[url]; + } + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); + + // Make sure there is no reported open page that is not open. + for (let entry in expected) { + Assert.ok(!entry, `Should have been found in autocomplete`); + } +} diff --git a/browser/components/urlbar/tests/browser/browser_tabMatchesInAwesomebar_perwindowpb.js b/browser/components/urlbar/tests/browser/browser_tabMatchesInAwesomebar_perwindowpb.js new file mode 100644 index 0000000000..9aac30e6b6 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_tabMatchesInAwesomebar_perwindowpb.js @@ -0,0 +1,174 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test ensures that we don't switch between tabs from normal window to + * private browsing window or opposite. + */ + +const TEST_URL = `${TEST_BASE_URL}dummy_page.html`; + +add_task(async function () { + let normalWindow = await BrowserTestUtils.openNewBrowserWindow(); + let privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await runTest(normalWindow, privateWindow, false); + await BrowserTestUtils.closeWindow(normalWindow); + await BrowserTestUtils.closeWindow(privateWindow); + + normalWindow = await BrowserTestUtils.openNewBrowserWindow(); + privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await runTest(privateWindow, normalWindow, false); + await BrowserTestUtils.closeWindow(normalWindow); + await BrowserTestUtils.closeWindow(privateWindow); + + privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await runTest(privateWindow, privateWindow, true); + await BrowserTestUtils.closeWindow(privateWindow); + + normalWindow = await BrowserTestUtils.openNewBrowserWindow(); + await runTest(normalWindow, normalWindow, true); + await BrowserTestUtils.closeWindow(normalWindow); +}); + +async function runTest(aSourceWindow, aDestWindow, aExpectSwitch) { + BrowserTestUtils.addTab(aSourceWindow.gBrowser, TEST_URL, { + userContextId: 1, + }); + await BrowserTestUtils.openNewForegroundTab(aSourceWindow.gBrowser, TEST_URL); + let testTab = await BrowserTestUtils.openNewForegroundTab( + aDestWindow.gBrowser + ); + + info("waiting for focus on the window"); + await SimpleTest.promiseFocus(aDestWindow); + info("got focus on the window"); + + // Select the testTab + aDestWindow.gBrowser.selectedTab = testTab; + + // Ensure that this tab has no history entries + let sessionHistoryCount = await new Promise(resolve => { + SessionStore.getSessionHistory( + gBrowser.selectedTab, + function (sessionHistory) { + resolve(sessionHistory.entries.length); + } + ); + }); + + Assert.less( + sessionHistoryCount, + 2, + `The test tab has 1 or fewer history entries. sessionHistoryCount=${sessionHistoryCount}` + ); + // Ensure that this tab is on about:blank + is( + testTab.linkedBrowser.currentURI.spec, + "about:blank", + "The test tab is on about:blank" + ); + // Ensure that this tab's document has no child nodes + await SpecialPowers.spawn(testTab.linkedBrowser, [], async function () { + ok( + !content.document.body.hasChildNodes(), + "The test tab has no child nodes" + ); + }); + ok( + !testTab.hasAttribute("busy"), + "The test tab doesn't have the busy attribute" + ); + + // Wait for the Awesomebar popup to appear. + let searchString = TEST_URL; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: aDestWindow, + value: searchString, + }); + + info(`awesomebar popup appeared. aExpectSwitch: ${aExpectSwitch}`); + // Make sure the last match is selected. + while ( + UrlbarTestUtils.getSelectedRowIndex(aDestWindow) < + UrlbarTestUtils.getResultCount(aDestWindow) - 1 + ) { + info("handling key navigation for DOM_VK_DOWN key"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, aDestWindow); + } + + let awaitTabSwitch; + if (aExpectSwitch) { + awaitTabSwitch = BrowserTestUtils.waitForTabClosing(testTab); + } + + // Execute the selected action. + EventUtils.synthesizeKey("KEY_Enter", {}, aDestWindow); + info("sent Enter command to the controller"); + + if (aExpectSwitch) { + // If we expect a tab switch then the current tab + // will be closed and we switch to the other tab. + await awaitTabSwitch; + } else { + // If we don't expect a tab switch then wait for the tab to load. + await BrowserTestUtils.browserLoaded(testTab.linkedBrowser); + } +} + +// Ensure that if the same page is opened in a non-private and a private window, +// the address bar in the non-private window doesn't show the private tab. +add_task(async function same_url_both_windows() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + let tab = await BrowserTestUtils.openNewForegroundTab(win.gBrowser, TEST_URL); + + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await BrowserTestUtils.openNewForegroundTab(privateWin.gBrowser, TEST_URL); + + // The current tab is not suggested, so open and focus another tab. + await BrowserTestUtils.openNewForegroundTab(win.gBrowser); + + // Check the switch-tab is not shown twice (one per window). + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "dummy_page", + }); + Assert.equal(2, UrlbarTestUtils.getResultCount(win), "Check results count"); + let result = await UrlbarTestUtils.getDetailsOfResultAt(win, 0); + Assert.ok(result.heuristic, "First result is heuristic"); + result = await UrlbarTestUtils.getDetailsOfResultAt(win, 1); + Assert.equal( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + result.type, + "Second result is tab switch" + ); + + // Now close the non-private tab, and check there's no switch-tab entry in + // the non-private window. + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "dummy_page", + }); + Assert.equal(2, UrlbarTestUtils.getResultCount(win), "Check results count"); + result = await UrlbarTestUtils.getDetailsOfResultAt(win, 0); + Assert.ok(result.heuristic, "First result is heuristic"); + result = await UrlbarTestUtils.getDetailsOfResultAt(win, 1); + Assert.notEqual( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + result.type, + "Second result is not tab switch" + ); + + await BrowserTestUtils.closeWindow(privateWin); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/urlbar/tests/browser/browser_tabToSearch.js b/browser/components/urlbar/tests/browser/browser_tabToSearch.js new file mode 100644 index 0000000000..a336980583 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_tabToSearch.js @@ -0,0 +1,647 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests tab-to-search results. See also + * browser/components/urlbar/tests/unit/test_providerTabToSearch.js. + */ + +"use strict"; + +const TEST_ENGINE_NAME = "Test"; +const TEST_ENGINE_DOMAIN = "example.com"; + +const DYNAMIC_RESULT_TYPE = "onboardTabToSearch"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderTabToSearch: + "resource:///modules/UrlbarProviderTabToSearch.sys.mjs", +}); + +add_setup(async function () { + await PlacesUtils.history.clear(); + await SpecialPowers.pushPrefEnv({ + set: [ + // Disable onboarding results for general tests. They are enabled in tests + // that specifically address onboarding. + ["browser.urlbar.tabToSearch.onboard.interactionsLeft", 0], + ], + }); + + await SearchTestUtils.installSearchExtension({ + name: TEST_ENGINE_NAME, + search_url: `https://${TEST_ENGINE_DOMAIN}/`, + }); + + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits([`https://${TEST_ENGINE_DOMAIN}/`]); + } + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +// Tests that tab-to-search results preview search mode when highlighted. These +// results are worth testing separately since they do not set the +// payload.keyword parameter. +add_task(async function basic() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + let autofillResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(autofillResult.autofill); + Assert.equal( + autofillResult.url, + `https://${TEST_ENGINE_DOMAIN}/`, + "The autofilled URL matches the engine domain." + ); + + let tabToSearchResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + tabToSearchResult.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + Assert.equal( + tabToSearchResult.payload.engine, + TEST_ENGINE_NAME, + "The tab-to-search result is for the correct engine." + ); + let tabToSearchDetails = await UrlbarTestUtils.getDetailsOfResultAt( + window, + 1 + ); + let [actionTabToSearch] = await document.l10n.formatValues([ + { + id: Services.search.getEngineByName( + tabToSearchDetails.searchParams.engine + ).isGeneralPurposeEngine + ? "urlbar-result-action-tabtosearch-web" + : "urlbar-result-action-tabtosearch-other-engine", + args: { engine: tabToSearchDetails.searchParams.engine }, + }, + ]); + Assert.equal( + tabToSearchDetails.displayed.title, + `Search with ${tabToSearchDetails.searchParams.engine}`, + "The result's title is set correctly." + ); + Assert.equal( + tabToSearchDetails.displayed.action, + actionTabToSearch, + "The correct action text is displayed in the tab-to-search result." + ); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + "Sanity check: The second result is selected." + ); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch", + isPreview: true, + }); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); +}); + +// Tests that we do not set aria-activedescendant after tabbing to a +// tab-to-search result when the pref +// browser.urlbar.accessibility.tabToSearch.announceResults is true. If that +// pref is true, the result was already announced while the user was typing. +add_task(async function activedescendant_tab() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.accessibility.tabToSearch.announceResults", true]], + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "There should be two results." + ); + let tabToSearchRow = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + 1 + ); + Assert.equal( + tabToSearchRow.result.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + + EventUtils.synthesizeKey("KEY_Tab"); + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch", + isPreview: true, + }); + let aadID = gURLBar.inputField.getAttribute("aria-activedescendant"); + Assert.equal(aadID, null, "aria-activedescendant was not set."); + + // Cycle through all the results then return to the tab-to-search result. It + // should be announced. + EventUtils.synthesizeKey("KEY_Tab"); + aadID = gURLBar.inputField.getAttribute("aria-activedescendant"); + let firstRow = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + Assert.equal( + aadID, + firstRow._content.id, + "aria-activedescendant was set to the row after the tab-to-search result." + ); + EventUtils.synthesizeKey("KEY_Tab"); + aadID = gURLBar.inputField.getAttribute("aria-activedescendant"); + Assert.equal( + aadID, + tabToSearchRow._content.id, + "aria-activedescendant was set to the tab-to-search result." + ); + + // Now close and reopen the view, then do another search that yields a + // tab-to-search result. aria-activedescendant should not be set when it is + // selected. + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + tabToSearchRow = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + Assert.equal( + tabToSearchRow.result.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + + EventUtils.synthesizeKey("KEY_Tab"); + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch", + isPreview: true, + }); + aadID = gURLBar.inputField.getAttribute("aria-activedescendant"); + Assert.equal(aadID, null, "aria-activedescendant was not set."); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + await SpecialPowers.popPrefEnv(); +}); + +// Tests that we set aria-activedescendant after accessing a tab-to-search +// result with the arrow keys. +add_task(async function activedescendant_arrow() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + let tabToSearchRow = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + 1 + ); + Assert.equal( + tabToSearchRow.result.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch", + isPreview: true, + }); + let aadID = gURLBar.inputField.getAttribute("aria-activedescendant"); + Assert.equal( + aadID, + tabToSearchRow._content.id, + "aria-activedescendant was set to the tab-to-search result." + ); + + // Move selection away from the tab-to-search result then return. It should + // be announced. + EventUtils.synthesizeKey("KEY_ArrowDown"); + aadID = gURLBar.inputField.getAttribute("aria-activedescendant"); + Assert.equal( + aadID, + UrlbarTestUtils.getOneOffSearchButtons(window).selectedButton.id, + "aria-activedescendant was moved to the first one-off." + ); + EventUtils.synthesizeKey("KEY_ArrowUp"); + aadID = gURLBar.inputField.getAttribute("aria-activedescendant"); + Assert.equal( + aadID, + tabToSearchRow._content.id, + "aria-activedescendant was set to the tab-to-search result." + ); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); +}); + +add_task(async function tab_key_race() { + // Mac Debug tinderboxes are just too slow and fail intermittently + // even if the EventBufferer timeout is set to an high value. + if (AppConstants.platform == "macosx" && AppConstants.DEBUG) { + return; + } + info( + "Test typing a letter followed shortly by down arrow consistently selects a tab-to-search result" + ); + Assert.equal(gURLBar.value, "", "Sanity check urlbar is empty"); + let promiseQueryStarted = new Promise(resolve => { + /** + * A no-op test provider. + * We use this to wait for the query to start, because otherwise TAB will + * move to the next widget since the panel is closed and there's no running + * query. This means waiting for the UrlbarProvidersManager to at least + * evaluate the isActive status of providers. + * In the future we should try to reduce this latency, to defer user events + * even more efficiently. + */ + class ListeningTestProvider extends UrlbarProvider { + constructor() { + super(); + } + get name() { + return "ListeningTestProvider"; + } + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + isActive(context) { + executeSoon(resolve); + return false; + } + isRestricting(context) { + return false; + } + async startQuery(context, addCallback) { + // Nothing to do. + } + } + let provider = new ListeningTestProvider(); + UrlbarProvidersManager.registerProvider(provider); + registerCleanupFunction(async function () { + UrlbarProvidersManager.unregisterProvider(provider); + }); + }); + gURLBar.focus(); + info("Type the beginning of the search string to get tab-to-search"); + EventUtils.synthesizeKey(TEST_ENGINE_DOMAIN.slice(0, 1)); + info("Awaiting for the query to start"); + await promiseQueryStarted; + EventUtils.synthesizeKey("KEY_ArrowDown"); + await UrlbarTestUtils.promiseSearchComplete(window); + await TestUtils.waitForCondition( + () => UrlbarTestUtils.getSelectedRowIndex(window) == 1, + "Wait for down arrow key to be handled" + ); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch", + isPreview: true, + }); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); +}); + +// Test that large-style onboarding results appear and have the correct +// properties. +add_task(async function onboard() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 3]], + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + let autofillResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(autofillResult.autofill); + Assert.equal( + autofillResult.url, + `https://${TEST_ENGINE_DOMAIN}/`, + "The autofilled URL matches the engine domain." + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + "Sanity check: The second result is selected." + ); + + // Now check the properties of the onboarding result. + let onboardingElement = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + 1 + ); + Assert.equal( + onboardingElement.result.payload.dynamicType, + DYNAMIC_RESULT_TYPE, + "The tab-to-search result is an onboarding result." + ); + Assert.equal( + onboardingElement.result.resultSpan, + 2, + "The correct resultSpan was set." + ); + Assert.ok( + onboardingElement + .querySelector(".urlbarView-row-inner") + .hasAttribute("selected"), + "The onboarding element set the selected attribute." + ); + + let [titleOnboarding, actionOnboarding, descriptionOnboarding] = + await document.l10n.formatValues([ + { + id: "urlbar-result-action-search-w-engine", + args: { + engine: onboardingElement.result.payload.engine, + }, + }, + { + id: Services.search.getEngineByName( + onboardingElement.result.payload.engine + ).isGeneralPurposeEngine + ? "urlbar-result-action-tabtosearch-web" + : "urlbar-result-action-tabtosearch-other-engine", + args: { engine: onboardingElement.result.payload.engine }, + }, + { + id: "urlbar-tabtosearch-onboard", + }, + ]); + let onboardingDetails = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + onboardingDetails.displayed.title, + titleOnboarding, + "The correct title was set." + ); + Assert.equal( + onboardingDetails.displayed.action, + actionOnboarding, + "The correct action text was set." + ); + Assert.equal( + onboardingDetails.element.row.querySelector( + ".urlbarView-dynamic-onboardTabToSearch-description" + ).textContent, + descriptionOnboarding, + "The correct description was set." + ); + Assert.ok( + BrowserTestUtils.isVisible( + onboardingDetails.element.row.querySelector(".urlbarView-title-separator") + ), + "The title separator should be visible." + ); + + // Check that the onboarding result enters search mode. + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch_onboard", + isPreview: true, + }); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch_onboard", + }); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + UrlbarPrefs.set("tabToSearch.onboard.interactionsLeft", 3); + delete UrlbarProviderTabToSearch.onboardingInteractionAtTime; + await SpecialPowers.popPrefEnv(); +}); + +// Tests that we show the onboarding result until the user interacts with it +// `browser.urlbar.tabToSearch.onboard.interactionsLeft` times. +add_task(async function onboard_limit() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 3]], + }); + + Assert.equal( + UrlbarPrefs.get("tabToSearch.onboard.interactionsLeft"), + 3, + "Sanity check: interactionsLeft is 3." + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + let onboardingResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + onboardingResult.payload.dynamicType, + DYNAMIC_RESULT_TYPE, + "The second result is an onboarding result." + ); + await EventUtils.synthesizeKey("KEY_ArrowDown"); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch_onboard", + isPreview: true, + }); + Assert.equal(UrlbarPrefs.get("tabToSearch.onboard.interactionsLeft"), 2); + await UrlbarTestUtils.exitSearchMode(window); + + // We don't increment the counter if we showed the onboarding result less than + // 5 minutes ago. + for (let i = 0; i < 5; i++) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + onboardingResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + onboardingResult.payload.dynamicType, + DYNAMIC_RESULT_TYPE, + "The second result is an onboarding result." + ); + await EventUtils.synthesizeKey("KEY_ArrowDown"); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch_onboard", + isPreview: true, + }); + Assert.equal( + UrlbarPrefs.get("tabToSearch.onboard.interactionsLeft"), + 2, + "We shouldn't decrement interactionsLeft if an onboarding result was just shown." + ); + await UrlbarTestUtils.exitSearchMode(window); + } + + // If the user doesn't interact with the result, we don't increment the + // counter. + for (let i = 0; i < 5; i++) { + delete UrlbarProviderTabToSearch.onboardingInteractionAtTime; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + let tabToSearchResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + tabToSearchResult.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + Assert.equal( + tabToSearchResult.type, + UrlbarUtils.RESULT_TYPE.DYNAMIC, + "The tab-to-search result is an onboarding result." + ); + Assert.equal( + UrlbarPrefs.get("tabToSearch.onboard.interactionsLeft"), + 2, + "We shouldn't decrement interactionsLeft if the user doesn't interact with onboarding." + ); + } + + // Test that we increment the counter if the user interacts with the result + // and it's been 5+ minutes since they last interacted with it. + for (let i = 1; i >= 0; i--) { + delete UrlbarProviderTabToSearch.onboardingInteractionAtTime; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + let tabToSearchResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + tabToSearchResult.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + Assert.equal( + onboardingResult.payload.dynamicType, + DYNAMIC_RESULT_TYPE, + "The second result is an onboarding result." + ); + await EventUtils.synthesizeKey("KEY_ArrowDown"); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch_onboard", + isPreview: true, + }); + Assert.equal( + UrlbarPrefs.get("tabToSearch.onboard.interactionsLeft"), + i, + "We decremented interactionsLeft." + ); + await UrlbarTestUtils.exitSearchMode(window); + } + + delete UrlbarProviderTabToSearch.onboardingInteractionAtTime; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + let tabToSearchResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + tabToSearchResult.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + Assert.notEqual( + tabToSearchResult.type, + UrlbarUtils.RESULT_TYPE.DYNAMIC, + "Now that interactionsLeft is 0, we don't show onboarding results." + ); + + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + UrlbarPrefs.set("tabToSearch.onboard.interactionsLeft", 3); + delete UrlbarProviderTabToSearch.onboardingInteractionAtTime; + await SpecialPowers.popPrefEnv(); +}); + +// Tests that we show at most one onboarding result at a time. See +// tests/unit/test_providerTabToSearch.js:multipleEnginesForHostname for a test +// that ensures only one normal tab-to-search result is shown in this scenario. +add_task(async function onboard_multipleEnginesForHostname() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 3]], + }); + + let extension = await SearchTestUtils.installSearchExtension( + { + name: `${TEST_ENGINE_NAME}Maps`, + search_url: `https://${TEST_ENGINE_DOMAIN}/maps/`, + }, + { skipUnload: true } + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "Only two results are shown." + ); + let firstResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0) + ).result; + Assert.notEqual( + firstResult.providerName, + "TabToSearch", + "The first result is not from TabToSearch." + ); + let secondResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + secondResult.providerName, + "TabToSearch", + "The second result is from TabToSearch." + ); + Assert.equal( + secondResult.type, + UrlbarUtils.RESULT_TYPE.DYNAMIC, + "The tab-to-search result is the only onboarding result." + ); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + await extension.unload(); + UrlbarPrefs.set("tabToSearch.onboard.interactionsLeft", 3); + delete UrlbarProviderTabToSearch.onboardingInteractionAtTime; + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_textruns.js b/browser/components/urlbar/tests/browser/browser_textruns.js new file mode 100644 index 0000000000..ed7a61e6b0 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_textruns.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test ensures that we limit textruns in case of very long urls or titles. + */ + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", true]], + }); + await SearchTestUtils.installSearchExtension( + { name: "Test" }, + { setAsDefault: true } + ); + + let lotsOfSpaces = "%20".repeat(300); + await PlacesTestUtils.addVisits({ + uri: `https://textruns.mozilla.org/${lotsOfSpaces}/test/`, + title: `A long ${lotsOfSpaces} title`, + }); + await UrlbarTestUtils.formHistory.add([ + { value: `A long ${lotsOfSpaces} textruns suggestion` }, + ]); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "textruns", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.searchParams.engine, "Test", "Sanity check engine"); + Assert.equal( + result.displayed.title.length, + UrlbarUtils.MAX_TEXT_LENGTH, + "Result title should be limited" + ); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 2); + Assert.equal( + result.displayed.title.length, + UrlbarUtils.MAX_TEXT_LENGTH, + "Result title should be limited" + ); + Assert.equal( + result.displayed.url.length, + UrlbarUtils.MAX_TEXT_LENGTH, + "Result url should be limited" + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_tokenAlias.js b/browser/components/urlbar/tests/browser/browser_tokenAlias.js new file mode 100644 index 0000000000..d215c2536f --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_tokenAlias.js @@ -0,0 +1,861 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test checks "@" search engine aliases ("token aliases") in the urlbar. + +"use strict"; + +const TEST_ALIAS_ENGINE_NAME = "Test"; +const ALIAS = "@test"; +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; + +// We make sure that aliases and search terms are correctly recognized when they +// are separated by each of these different types of spaces and combinations of +// spaces. U+3000 is the ideographic space in CJK and is commonly used by CJK +// speakers. +const TEST_SPACES = [" ", "\u3000", " \u3000", "\u3000 "]; + +// Allow more time for Mac machines so they don't time out in verify mode. See +// bug 1673062. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(5); +} + +add_setup(async function () { + // Add a default engine with suggestions, to avoid hitting the network when + // fetching them. + let defaultEngine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + setAsDefault: true, + }); + defaultEngine.alias = "@default"; + await SearchTestUtils.installSearchExtension({ + name: TEST_ALIAS_ENGINE_NAME, + keyword: ALIAS, + }); + + // Search results aren't shown in quantumbar unless search suggestions are + // enabled. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", true]], + }); +}); + +// Simple test that tries different variations of an alias, without reverting +// the urlbar value in between. +add_task(async function testNoRevert() { + await doSimpleTest(false); +}); + +// Simple test that tries different variations of an alias, reverting the urlbar +// value in between. +add_task(async function testRevert() { + await doSimpleTest(true); +}); + +async function doSimpleTest(revertBetweenSteps) { + // When autofill is enabled, searching for "@tes" will autofill to "@test", + // which gets in the way of this test task, so temporarily disable it. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill", false]], + }); + + // "@tes" -- not an alias, no search mode + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS.substr(0, ALIAS.length - 1), + }); + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal( + gURLBar.value, + ALIAS.substr(0, ALIAS.length - 1), + "value should be alias substring" + ); + + if (revertBetweenSteps) { + gURLBar.handleRevert(); + gURLBar.blur(); + } + + // "@test" -- alias but no trailing space, no search mode + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS, + }); + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal(gURLBar.value, ALIAS, "value should be alias"); + + if (revertBetweenSteps) { + gURLBar.handleRevert(); + gURLBar.blur(); + } + + // "@test " -- alias with trailing space, search mode + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS + spaces, + }); + // Wait for the second new search that starts when search mode is entered. + await UrlbarTestUtils.promiseSearchComplete(window); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ALIAS_ENGINE_NAME, + entry: "typed", + }); + Assert.equal(gURLBar.value, "", "value should be empty"); + await UrlbarTestUtils.exitSearchMode(window); + + if (revertBetweenSteps) { + gURLBar.handleRevert(); + gURLBar.blur(); + } + } + + // "@test foo" -- alias, search mode + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS + spaces + "foo", + }); + // Wait for the second new search that starts when search mode is entered. + await UrlbarTestUtils.promiseSearchComplete(window); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ALIAS_ENGINE_NAME, + entry: "typed", + }); + Assert.equal(gURLBar.value, "foo", "value should be query"); + await UrlbarTestUtils.exitSearchMode(window); + + if (revertBetweenSteps) { + gURLBar.handleRevert(); + gURLBar.blur(); + } + } + + // "@test " -- alias with trailing space, search mode + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS + spaces, + }); + // Wait for the second new search that starts when search mode is entered. + await UrlbarTestUtils.promiseSearchComplete(window); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ALIAS_ENGINE_NAME, + entry: "typed", + }); + Assert.equal(gURLBar.value, "", "value should be empty"); + await UrlbarTestUtils.exitSearchMode(window); + + if (revertBetweenSteps) { + gURLBar.handleRevert(); + gURLBar.blur(); + } + } + + // "@test" -- alias but no trailing space, no highlight + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS, + }); + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal(gURLBar.value, ALIAS, "value should be alias"); + + if (revertBetweenSteps) { + gURLBar.handleRevert(); + gURLBar.blur(); + } + + // "@tes" -- not an alias, no highlight + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS.substr(0, ALIAS.length - 1), + }); + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal( + gURLBar.value, + ALIAS.substr(0, ALIAS.length - 1), + "value should be alias substring" + ); + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); + + await SpecialPowers.popPrefEnv(); +} + +// An alias should be recognized even when there are spaces before it, and +// search mode should be entered. +add_task(async function spacesBeforeAlias() { + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: spaces + ALIAS + spaces, + }); + // Wait for the second new search that starts when search mode is entered. + await UrlbarTestUtils.promiseSearchComplete(window); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ALIAS_ENGINE_NAME, + entry: "typed", + }); + Assert.equal(gURLBar.value, "", "value should be empty"); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); + } +}); + +// An alias in the middle of a string should not be recognized and search mode +// should not be entered. +add_task(async function charsBeforeAlias() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "not an alias " + ALIAS + " ", + }); + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal( + gURLBar.value, + "not an alias " + ALIAS + " ", + "value should be unchanged" + ); + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// While already in search mode, an alias should not be recognized. +add_task(async function alreadyInSearchMode() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS + " ", + }); + + // Search mode source should still be bookmarks. + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + Assert.equal(gURLBar.value, ALIAS + " ", "value should be unchanged"); + + // Exit search mode, but first remove the value in the input. Since the value + // is "alias ", we'd actually immediately re-enter search mode otherwise. + gURLBar.value = ""; + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// Types a space while typing an alias to ensure we stop autofilling. +add_task(async function spaceWhileTypingAlias() { + for (let spaces of TEST_SPACES) { + if (spaces.length != 1) { + continue; + } + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + + let value = ALIAS.substring(0, ALIAS.length - 1); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + selectionStart: value.length, + selectionEnd: value.length, + }); + Assert.equal(gURLBar.value, ALIAS + " ", "Alias should be autofilled"); + + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey(spaces); + await searchPromise; + + Assert.equal( + gURLBar.value, + value + spaces, + "Alias should not be autofilled" + ); + await UrlbarTestUtils.assertSearchMode(window, null); + + await UrlbarTestUtils.promisePopupClose(window); + } +}); + +// Aliases are case insensitive. Make sure that searching with an alias using a +// weird case still causes the alias to be recognized and search mode entered. +add_task(async function aliasCase() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@TeSt ", + }); + // Wait for the second new search that starts when search mode is entered. + await UrlbarTestUtils.promiseSearchComplete(window); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ALIAS_ENGINE_NAME, + entry: "typed", + }); + Assert.equal(gURLBar.value, "", "value should be empty"); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// Same as previous but with a query. +add_task(async function aliasCase_query() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@tEsT query", + }); + // Wait for the second new search that starts when search mode is entered. + await UrlbarTestUtils.promiseSearchComplete(window); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ALIAS_ENGINE_NAME, + entry: "typed", + }); + Assert.equal(gURLBar.value, "query", "value should be query"); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// Selecting a non-heuristic (non-first) search engine result with an alias and +// empty query should put the alias in the urlbar and highlight it. +// Also checks that internal aliases appear with the "@" keyword. +add_task(async function nonHeuristicAliases() { + // Get the list of token alias engines (those with aliases that start with + // "@"). + let tokenEngines = []; + for (let engine of await Services.search.getEngines()) { + let aliases = []; + if (engine.alias) { + aliases.push(engine.alias); + } + aliases.push(...engine.aliases); + let tokenAliases = aliases.filter(a => a.startsWith("@")); + if (tokenAliases.length) { + tokenEngines.push({ engine, tokenAliases }); + } + } + if (!tokenEngines.length) { + Assert.ok(true, "No token alias engines, skipping task."); + return; + } + info( + "Got token alias engines: " + tokenEngines.map(({ engine }) => engine.name) + ); + + // Populate the results with the list of token alias engines by searching for + // "@". + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + }); + await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + tokenEngines.length - 1 + ); + // Key down to select each result in turn. The urlbar should preview search + // mode for each engine. + for (let { tokenAliases } of tokenEngines) { + let alias = tokenAliases[0]; + let engineName = (await UrlbarSearchUtils.engineForAlias(alias)).name; + EventUtils.synthesizeKey("KEY_ArrowDown"); + let expectedSearchMode = { + engineName, + entry: "keywordoffer", + isPreview: true, + }; + if (Services.search.getEngineByName(engineName).isGeneralPurposeEngine) { + expectedSearchMode.source = UrlbarUtils.RESULT_SOURCE.SEARCH; + } + await UrlbarTestUtils.assertSearchMode(window, expectedSearchMode); + Assert.ok(!gURLBar.value, "The Urlbar should be empty."); + } + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// Clicking on an @ alias offer (an @ alias with an empty search string) in the +// view should enter search mode. +add_task(async function clickAndFillAlias() { + // Do a search for "@" to show all the @ aliases. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + }); + + // Find our test engine in the results. It's probably last, but for + // robustness don't assume it is. + let testEngineItem; + for (let i = 0; !testEngineItem; i++) { + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.equal( + details.displayed.title, + `Search with ${details.searchParams.engine}`, + "The result's title is set correctly." + ); + Assert.ok(!details.action, "The result should have no action text."); + if (details.searchParams && details.searchParams.keyword == ALIAS) { + testEngineItem = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + i + ); + } + } + + // Click it. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeMouseAtCenter(testEngineItem, {}); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: testEngineItem.result.payload.engine, + entry: "keywordoffer", + }); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// Pressing enter on an @ alias offer (an @ alias with an empty search string) +// in the view should enter search mode. +add_task(async function enterAndFillAlias() { + // Do a search for "@" to show all the @ aliases. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + }); + + // Find our test engine in the results. It's probably last, but for + // robustness don't assume it is. + let details; + let index = 0; + for (; ; index++) { + details = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + if (details.searchParams && details.searchParams.keyword == ALIAS) { + index++; + break; + } + } + + // Key down to it and press enter. + EventUtils.synthesizeKey("KEY_ArrowDown", { repeat: index }); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: details.searchParams.engine, + entry: "keywordoffer", + }); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// Pressing Enter on an @ alias autofill should enter search mode. +add_task(async function enterAutofillsAlias() { + for (let value of [ALIAS.substring(0, ALIAS.length - 1), ALIAS]) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + selectionStart: value.length, + selectionEnd: value.length, + }); + + // Press Enter. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ALIAS_ENGINE_NAME, + entry: "keywordoffer", + }); + + await UrlbarTestUtils.exitSearchMode(window); + } + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// Pressing Right on an @ alias autofill should enter search mode. +add_task(async function rightEntersSearchMode() { + for (let value of [ALIAS.substring(0, ALIAS.length - 1), ALIAS]) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + selectionStart: value.length, + selectionEnd: value.length, + }); + + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_ArrowRight"); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ALIAS_ENGINE_NAME, + entry: "typed", + }); + Assert.equal(gURLBar.value, "", "value should be empty"); + await UrlbarTestUtils.exitSearchMode(window); + } + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// Pressing Tab when an @ alias is autofilled should enter search mode preview. +add_task(async function rightEntersSearchMode() { + for (let value of [ALIAS.substring(0, ALIAS.length - 1), ALIAS]) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + selectionStart: value.length, + selectionEnd: value.length, + }); + + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + -1, + "There is no selected result." + ); + + EventUtils.synthesizeKey("KEY_Tab"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "The first result is selected." + ); + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ALIAS_ENGINE_NAME, + entry: "keywordoffer", + isPreview: true, + }); + Assert.equal(gURLBar.value, "", "value should be empty"); + + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ALIAS_ENGINE_NAME, + entry: "keywordoffer", + isPreview: false, + }); + await UrlbarTestUtils.exitSearchMode(window); + } + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +/** + * This test checks that if an engine is marked as hidden then + * it should not appear in the popup when using the "@" token alias in the search bar. + */ +add_task(async function hiddenEngine() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + fireInputEvent: true, + }); + + const defaultEngine = await Services.search.getDefault(); + + let foundDefaultEngineInPopup = false; + + // Checks that the default engine appears in the urlbar's popup. + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (defaultEngine.name == details.searchParams.engine) { + foundDefaultEngineInPopup = true; + break; + } + } + Assert.ok(foundDefaultEngineInPopup, "Default engine appears in the popup."); + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); + + // Checks that a hidden default engine (i.e. an engine removed from + // a user's search settings) does not appear in the urlbar's popup. + defaultEngine.hidden = true; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + fireInputEvent: true, + }); + foundDefaultEngineInPopup = false; + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (defaultEngine.name == details.searchParams.engine) { + foundDefaultEngineInPopup = true; + break; + } + } + Assert.ok( + !foundDefaultEngineInPopup, + "Hidden default engine does not appear in the popup" + ); + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); + + defaultEngine.hidden = false; +}); + +/** + * This test checks that if an engines alias is not prefixed with + * @ it still appears in the popup when using the "@" token + * alias in the search bar. + */ +add_task(async function nonPrefixedKeyword() { + let name = "Custom"; + let alias = "customkeyword"; + let extension = await SearchTestUtils.installSearchExtension( + { + name, + keyword: alias, + }, + { skipUnload: true } + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + }); + + let foundEngineInPopup = false; + + // Checks that the default engine appears in the urlbar's popup. + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (details.searchParams.engine === name) { + foundEngineInPopup = true; + break; + } + } + Assert.ok(foundEngineInPopup, "Custom engine appears in the popup."); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@" + alias, + }); + + let keywordOfferResult = await UrlbarTestUtils.getDetailsOfResultAt( + window, + 0 + ); + + Assert.equal( + keywordOfferResult.searchParams.engine, + name, + "The first result should be a keyword search result with the correct engine." + ); + + await extension.unload(); +}); + +// Tests that we show all engines with a token alias that match the search +// string. +add_task(async function multipleMatchingEngines() { + let extension = await SearchTestUtils.installSearchExtension( + { + name: "TestFoo", + keyword: `${ALIAS}foo`, + }, + { skipUnload: true } + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@te", + fireInputEvent: true, + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "Two results are shown." + ); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + -1, + "Neither result is selected." + ); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(result.autofill, "The first result is autofilling."); + Assert.equal( + result.searchParams.keyword, + ALIAS, + "The autofilled engine is shown first." + ); + + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + result.searchParams.keyword, + `${ALIAS}foo`, + "The other engine is shown second." + ); + + EventUtils.synthesizeKey("KEY_Tab"); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), 0); + Assert.equal(gURLBar.value, "", "Urlbar should be empty."); + EventUtils.synthesizeKey("KEY_Tab"); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), 1); + Assert.equal(gURLBar.value, "", "Urlbar should be empty."); + EventUtils.synthesizeKey("KEY_Tab"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + -1, + "Tabbing all the way through the matching engines should return to the input." + ); + Assert.equal( + gURLBar.value, + "@te", + "Urlbar should contain the search string." + ); + + await extension.unload(); +}); + +// Tests that UrlbarProviderTokenAliasEngines is disabled in search mode. +add_task(async function doNotShowInSearchMode() { + // Do a search for "@" to show all the @ aliases. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + }); + + // Find our test engine in the results. It's probably last, but for + // robustness don't assume it is. + let testEngineItem; + for (let i = 0; !testEngineItem; i++) { + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (details.searchParams && details.searchParams.keyword == ALIAS) { + testEngineItem = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + i + ); + } + } + + Assert.equal( + testEngineItem.result.payload.keyword, + ALIAS, + "Sanity check: we found our engine." + ); + + // Click it. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeMouseAtCenter(testEngineItem, {}); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, { + engineName: testEngineItem.result.payload.engine, + entry: "keywordoffer", + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + fireInputEvent: true, + }); + + let resultCount = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < resultCount; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.ok( + !result.searchParams.keyword, + `Result at index ${i} is not a keywordoffer.` + ); + } +}); + +async function assertFirstResultIsAlias(isAlias, expectedAlias) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Should have the correct type" + ); + + if (isAlias) { + Assert.equal( + result.searchParams.keyword, + expectedAlias, + "Payload keyword should be the alias" + ); + } else { + Assert.notEqual( + result.searchParams.keyword, + expectedAlias, + "Payload keyword should be absent or not the alias" + ); + } +} + +function assertHighlighted(highlighted, expectedAlias) { + let selection = gURLBar.editor.selectionController.getSelection( + Ci.nsISelectionController.SELECTION_FIND + ); + Assert.ok(selection); + if (!highlighted) { + Assert.equal(selection.rangeCount, 0); + return; + } + Assert.equal(selection.rangeCount, 1); + let index = gURLBar.value.indexOf(expectedAlias); + Assert.ok( + index >= 0, + `gURLBar.value="${gURLBar.value}" expectedAlias="${expectedAlias}"` + ); + let range = selection.getRangeAt(0); + Assert.ok(range); + Assert.equal(range.startOffset, index); + Assert.equal(range.endOffset, index + expectedAlias.length); +} + +/** + * Returns an array of code points in the given string. Each code point is + * returned as a hexidecimal string. + * + * @param {string} str + * The code points of this string will be returned. + * @returns {Array} + * Array of code points in the string, where each is a hexidecimal string. + */ +function codePoints(str) { + return str.split("").map(s => s.charCodeAt(0).toString(16)); +} diff --git a/browser/components/urlbar/tests/browser/browser_top_sites.js b/browser/components/urlbar/tests/browser/browser_top_sites.js new file mode 100644 index 0000000000..a473216ab1 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_top_sites.js @@ -0,0 +1,478 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", +}); + +const EN_US_TOPSITES = + "https://www.youtube.com/,https://www.facebook.com/,https://www.amazon.com/,https://www.reddit.com/,https://www.wikipedia.org/,https://twitter.com/"; + +async function addTestVisits() { + // Add some visits to a URL. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits("http://example.com/"); + } + + // Wait for example.com to be listed first. + await updateTopSites(sites => { + return sites && sites[0] && sites[0].url == "http://example.com/"; + }); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "https://www.youtube.com/", + title: "YouTube", + }); +} + +async function checkDoesNotOpenOnFocus(win = window) { + // The view should not open when the input is focused programmatically. + win.gURLBar.blur(); + win.gURLBar.focus(); + Assert.ok(!win.gURLBar.view.isOpen, "check urlbar panel is not open"); + win.gURLBar.blur(); + + // Check the keyboard shortcut. + win.document.getElementById("Browser:OpenLocation").doCommand(); + // Because the panel opening may not be immediate, we must wait a bit. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + Assert.ok(!win.gURLBar.view.isOpen, "check urlbar panel is not open"); + win.gURLBar.blur(); + + // Focus with the mouse. + EventUtils.synthesizeMouseAtCenter(win.gURLBar.inputField, {}, win); + // Because the panel opening may not be immediate, we must wait a bit. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + Assert.ok(!win.gURLBar.view.isOpen, "check urlbar panel is not open"); + win.gURLBar.blur(); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.topsites", true], + ["browser.urlbar.suggest.quickactions", false], + ["browser.newtabpage.activity-stream.default.sites", EN_US_TOPSITES], + ], + }); + + await updateTopSites( + sites => sites && sites.length == EN_US_TOPSITES.split(",").length + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(tab); + }); +}); + +add_task(async function topSitesShown() { + let sites = AboutNewTab.getTopSites(); + + for (let prefVal of [true, false]) { + // This test should work regardless of whether Top Sites are enabled on + // about:newtab. + await SpecialPowers.pushPrefEnv({ + set: [["browser.newtabpage.activity-stream.feeds.topsites", prefVal]], + }); + // We don't expect this to change, but we run updateTopSites just in case + // feeds.topsites were to have an effect on the composition of Top Sites. + await updateTopSites(siteList => siteList.length == 6); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + Assert.ok(gURLBar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + sites.length, + "The number of results should be the same as the number of Top Sites (6)." + ); + + for (let i = 0; i < sites.length; i++) { + let site = sites[i]; + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (site.searchTopSite) { + Assert.equal( + result.searchParams.keyword, + site.label, + "The search Top Site should have an alias." + ); + continue; + } + + Assert.equal( + site.url, + result.url, + "The Top Site URL and the result URL shoud match." + ); + Assert.equal( + site.label || site.title || site.hostname, + result.title, + "The Top Site title and the result title shoud match." + ); + } + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); + // This pops updateTopSites changes. + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); + } +}); + +add_task(async function selectSearchTopSite() { + await updateTopSites( + sites => sites && sites[0] && sites[0].searchTopSite, + true + ); + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + + let amazonSearch = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + 0 + ); + + Assert.equal( + amazonSearch.result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "First result should have SEARCH type." + ); + + Assert.equal( + amazonSearch.result.payload.keyword, + "@amazon", + "First result should have the Amazon keyword." + ); + + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeMouseAtCenter(amazonSearch, {}); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, { + engineName: amazonSearch.result.payload.engine, + entry: "topsites_urlbar", + }); + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); +}); + +add_task(async function topSitesBookmarksAndTabs() { + await addTestVisits(); + let sites = AboutNewTab.getTopSites(); + Assert.equal( + sites.length, + 7, + "The test suite browser should have 7 Top Sites." + ); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + Assert.ok(gURLBar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(window); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 7, + "The number of results should be the same as the number of Top Sites (7)." + ); + + let exampleResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + exampleResult.url, + "http://example.com/", + "The example.com Top Site should be the first result." + ); + Assert.equal( + exampleResult.source, + UrlbarUtils.RESULT_SOURCE.TABS, + "The example.com Top Site should appear in the view as an open tab result." + ); + + let youtubeResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + youtubeResult.url, + "https://www.youtube.com/", + "The YouTube Top Site should be the second result." + ); + Assert.equal( + youtubeResult.source, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + "The YouTube Top Site should appear in the view as a bookmark result." + ); + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task(async function topSitesKeywordNavigationPageproxystate() { + await addTestVisits(); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "valid", + "Sanity check initial state" + ); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + Assert.ok(gURLBar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(window); + + let count = UrlbarTestUtils.getResultCount(window); + Assert.equal(count, 7, "The number of results should be the expected one."); + + for (let i = 0; i < count; ++i) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "invalid", + "Moving through results" + ); + } + for (let i = 0; i < count; ++i) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "invalid", + "Moving through results" + ); + } + + // Double ESC should restore state. + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Escape"); + }); + EventUtils.synthesizeKey("KEY_Escape"); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "valid", + "Double ESC should restore state" + ); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task(async function topSitesPinned() { + await addTestVisits(); + let info = { url: "http://example.com/" }; + NewTabUtils.pinnedLinks.pin(info, 0); + + await updateTopSites(sites => sites && sites[0] && sites[0].isPinned); + + let sites = AboutNewTab.getTopSites(); + Assert.equal( + sites.length, + 7, + "The test suite browser should have 7 Top Sites." + ); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + Assert.ok(gURLBar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(window); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 7, + "The number of results should be the same as the number of Top Sites (7)." + ); + + let exampleResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + exampleResult.url, + "http://example.com/", + "The example.com Top Site should be the first result." + ); + + Assert.equal( + exampleResult.source, + UrlbarUtils.RESULT_SOURCE.TABS, + "The example.com Top Site should be an open tab result." + ); + + Assert.ok( + exampleResult.element.row.hasAttribute("pinned"), + "The example.com Top Site should have the pinned property." + ); + + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); + NewTabUtils.pinnedLinks.unpin(info); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task(async function topSitesBookmarksAndTabsDisabled() { + await addTestVisits(); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.openpage", false], + ["browser.urlbar.suggest.bookmark", false], + ], + }); + + let sites = AboutNewTab.getTopSites(); + Assert.equal( + sites.length, + 7, + "The test suite browser should have 7 Top Sites." + ); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + Assert.ok(gURLBar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(window); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 7, + "The number of results should be the same as the number of Top Sites (7)." + ); + + let exampleResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + exampleResult.url, + "http://example.com/", + "The example.com Top Site should be the second result." + ); + Assert.equal( + exampleResult.source, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + "The example.com Top Site should appear as a normal result even though it's open in a tab." + ); + + let youtubeResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + youtubeResult.url, + "https://www.youtube.com/", + "The YouTube Top Site should be the third result." + ); + Assert.equal( + youtubeResult.source, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + "The YouTube Top Site should appear as a normal result even though it's bookmarked." + ); + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function topSitesDisabled() { + // Disable Top Sites feed. + await SpecialPowers.pushPrefEnv({ + set: [["browser.newtabpage.activity-stream.feeds.system.topsites", false]], + }); + await checkDoesNotOpenOnFocus(); + await SpecialPowers.popPrefEnv(); + + // Top Sites should also not be shown when Urlbar Top Sites are disabled. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.topsites", false]], + }); + await checkDoesNotOpenOnFocus(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function topSitesNumber() { + // Add some visits + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([ + "http://example-a.com/", + "http://example-b.com/", + "http://example-c.com/", + "http://example-d.com/", + "http://example-e.com/", + ]); + } + + // Wait for the expected number of Top sites. + await updateTopSites(sites => sites && sites.length == 8); + Assert.equal( + AboutNewTab.getTopSites().length, + 8, + "The test suite browser should have 8 Top Sites." + ); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + Assert.ok(gURLBar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 8, + "The number of results should be the default (8)." + ); + await UrlbarTestUtils.promisePopupClose(window); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.newtabpage.activity-stream.topSitesRows", 2]], + }); + // Wait for the expected number of Top sites. + await updateTopSites(sites => sites && sites.length == 11); + Assert.equal( + AboutNewTab.getTopSites().length, + 11, + "The test suite browser should have 11 Top Sites." + ); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + Assert.ok(gURLBar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(window); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 10, + "The number of results should be maxRichResults (10)." + ); + await UrlbarTestUtils.promisePopupClose(window); + + await SpecialPowers.popPrefEnv(); + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_top_sites_private.js b/browser/components/urlbar/tests/browser/browser_top_sites_private.js new file mode 100644 index 0000000000..c52239a800 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_top_sites_private.js @@ -0,0 +1,171 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", +}); + +const EN_US_TOPSITES = + "https://www.youtube.com/,https://www.facebook.com/,https://www.amazon.com/,https://www.reddit.com/,https://www.wikipedia.org/,https://twitter.com/"; + +async function addTestVisits() { + // Add some visits to a URL. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits("http://example.com/"); + } + + // Wait for example.com to be listed first. + await updateTopSites(sites => { + return sites && sites[0] && sites[0].url == "http://example.com/"; + }); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "https://www.youtube.com/", + title: "YouTube", + }); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.topsites", true], + ["browser.urlbar.suggest.quickactions", false], + ["browser.newtabpage.activity-stream.default.sites", EN_US_TOPSITES], + ], + }); + + await updateTopSites( + sites => sites && sites.length == EN_US_TOPSITES.split(",").length + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(tab); + }); +}); + +add_task(async function topSitesPrivateWindow() { + // Top Sites should also be shown in private windows. + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await addTestVisits(); + let sites = AboutNewTab.getTopSites(); + Assert.equal( + sites.length, + 7, + "The test suite browser should have 7 Top Sites." + ); + let urlbar = privateWin.gURLBar; + await UrlbarTestUtils.promisePopupOpen(privateWin, () => { + if (urlbar.getAttribute("pageproxystate") == "invalid") { + urlbar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(urlbar.inputField, {}, privateWin); + }); + Assert.ok(urlbar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(privateWin); + + Assert.equal( + UrlbarTestUtils.getResultCount(privateWin), + 7, + "The number of results should be the same as the number of Top Sites (7)." + ); + + // Top sites should also be shown in a private window if the search string + // gets cleared. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: privateWin, + value: "example", + }); + urlbar.select(); + EventUtils.synthesizeKey("KEY_Backspace", {}, privateWin); + Assert.ok(urlbar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(privateWin); + Assert.equal( + UrlbarTestUtils.getResultCount(privateWin), + 7, + "The number of results should be the same as the number of Top Sites (7)." + ); + + await BrowserTestUtils.closeWindow(privateWin); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); +}); + +add_task(async function topSitesTabSwitch() { + // Add some visits + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits(["http://example.com/"]); + } + + // Switch to the originating tab, to check for switch to the current tab. + gBrowser.selectedTab = gBrowser.tabs[0]; + + // Wait for the expected number of Top sites. + await updateTopSites(sites => sites?.length == 7); + Assert.equal( + AboutNewTab.getTopSites().length, + 7, + "The test suite browser should have 7 Top Sites." + ); + + async function checkResults(win, expectedResultType) { + let resultCount = UrlbarTestUtils.getResultCount(win); + let result; + for (let i = 0; i < resultCount; ++i) { + result = await UrlbarTestUtils.getDetailsOfResultAt(win, i); + if (result.url == "http://example.com/") { + break; + } + } + Assert.equal( + result.type, + expectedResultType, + `Should provide a result of type ${expectedResultType}.` + ); + } + + info("Test in a non-private window"); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + Assert.ok(gURLBar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(window); + await checkResults(window, UrlbarUtils.RESULT_TYPE.TAB_SWITCH); + await UrlbarTestUtils.promisePopupClose(window); + + info("Test in a private window, switch to tab should not be offered"); + // Top Sites should also be shown in private windows. + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + let urlbar = privateWin.gURLBar; + await UrlbarTestUtils.promisePopupOpen(privateWin, () => { + if (urlbar.getAttribute("pageproxystate") == "invalid") { + urlbar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(urlbar.inputField, {}, privateWin); + }); + + Assert.ok(urlbar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(privateWin); + await checkResults(privateWin, UrlbarUtils.RESULT_TYPE.URL); + await UrlbarTestUtils.promisePopupClose(privateWin); + await BrowserTestUtils.closeWindow(privateWin); + + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_typed_value.js b/browser/components/urlbar/tests/browser/browser_typed_value.js new file mode 100644 index 0000000000..01a957b5df --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_typed_value.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test ensures that the urlbar is restored to the typed value on blur. + +"use strict"; + +add_setup(async function () { + registerCleanupFunction(async function () { + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + gURLBar.handleRevert(); + await PlacesUtils.history.clear(); + }); + Services.prefs.setBoolPref("browser.urlbar.autoFill", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + + await PlacesTestUtils.addVisits([ + "http://example.com/", + "http://example.com/foo", + ]); +}); + +add_task(async function test_autofill() { + let typed = "ex"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: typed, + fireInputEvent: true, + }); + Assert.equal(gURLBar.value, "example.com/", "autofilled value as expected"); + Assert.equal(gURLBar.selectionStart, typed.length); + Assert.equal(gURLBar.selectionEnd, gURLBar.value.length); + + gURLBar.blur(); + Assert.equal(gURLBar.value, typed, "Value should have been restored"); + gURLBar.focus(); + Assert.equal(gURLBar.value, typed, "Value should not have changed"); + Assert.equal(gURLBar.selectionStart, typed.length); + Assert.equal(gURLBar.selectionEnd, typed.length); +}); + +add_task(async function test_complete_selection() { + let typed = "ex"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: typed, + fireInputEvent: true, + }); + Assert.equal(gURLBar.value, "example.com/", "autofilled value as expected"); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "Should have the correct number of matches" + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + gURLBar.value, + UrlbarTestUtils.trimURL("http://example.com/foo"), + "Value should have been completed" + ); + + gURLBar.blur(); + Assert.equal(gURLBar.value, typed, "Value should have been restored"); + gURLBar.focus(); + Assert.equal(gURLBar.value, typed, "Value should not have changed"); + Assert.equal(gURLBar.selectionStart, typed.length); + Assert.equal(gURLBar.selectionEnd, typed.length); +}); diff --git a/browser/components/urlbar/tests/browser/browser_unitConversion.js b/browser/components/urlbar/tests/browser/browser_unitConversion.js new file mode 100644 index 0000000000..566300b7d4 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_unitConversion.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests unit conversion on browser. + */ + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.unitConversion.enabled", true]], + }); + + registerCleanupFunction(function () { + SpecialPowers.clipboardCopyString(""); + }); +}); + +add_task(async function test_selectByMouse() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + + // Clear clipboard content. + SpecialPowers.clipboardCopyString(""); + + const row = await doUnitConversion(win); + + info("Check if the result is copied to clipboard when selecting by mouse"); + EventUtils.synthesizeMouseAtCenter( + row.querySelector(".urlbarView-no-wrap"), + {}, + win + ); + assertClipboard(); + + await UrlbarTestUtils.promisePopupClose(win); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_selectByKey() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + + // Clear clipboard content. + SpecialPowers.clipboardCopyString(""); + + await doUnitConversion(win); + + // As gURLBar might lost focus, + // give focus again in order to enable key event on the result. + win.gURLBar.focus(); + + info("Check if the result is copied to clipboard when selecting by key"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + EventUtils.synthesizeKey("KEY_Enter", {}, win); + assertClipboard(); + + await UrlbarTestUtils.promisePopupClose(win); + await BrowserTestUtils.closeWindow(win); +}); + +function assertClipboard() { + Assert.equal( + SpecialPowers.getClipboardData("text/plain"), + "100 cm", + "The result of conversion is copied to clipboard" + ); +} + +async function doUnitConversion(win) { + info("Do unit conversion then wait the result"); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "1m to cm", + waitForFocus: SimpleTest.waitForFocus, + }); + + const row = await UrlbarTestUtils.waitForAutocompleteResultAt(win, 1); + + Assert.ok(row.querySelector(".urlbarView-favicon"), "The icon is displayed"); + Assert.equal( + row.querySelector(".urlbarView-dynamic-unitConversion-output").textContent, + "100 cm", + "The unit is converted" + ); + + return row; +} diff --git a/browser/components/urlbar/tests/browser/browser_updateForDomainCompletion.js b/browser/components/urlbar/tests/browser/browser_updateForDomainCompletion.js new file mode 100644 index 0000000000..ee49f9d477 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_updateForDomainCompletion.js @@ -0,0 +1,22 @@ +"use strict"; + +/** + * Disable keyword.enabled (so no keyword search), and check that when + * you type in "example" and hit enter, the browser shows an error page. + */ +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["keyword.enabled", false]], + }); + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:blank" }, + async function (browser) { + gURLBar.value = "example"; + gURLBar.select(); + const loadPromise = BrowserTestUtils.waitForErrorPage(browser); + EventUtils.sendKey("return"); + await loadPromise; + ok(true, "error page is loaded correctly"); + } + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_url_formatted_correctly_on_load.js b/browser/components/urlbar/tests/browser/browser_url_formatted_correctly_on_load.js new file mode 100644 index 0000000000..fe923b4ebf --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_url_formatted_correctly_on_load.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + XPCShellContentUtils: + "resource://testing-common/XPCShellContentUtils.sys.mjs", +}); + +let PUNYCODE_PAGE = "xn--31b1c3b9b.com"; +// eslint-disable-next-line @microsoft/sdl/no-insecure-url +let DECODED_PAGE = "http://योगा.com/"; + +function startServer() { + XPCShellContentUtils.ensureInitialized(this); + let server = XPCShellContentUtils.createHttpServer({ + hosts: [PUNYCODE_PAGE], + }); + server.registerPathHandler("/", (request, response) => { + response.write("A page without icon"); + }); +} + +add_task(async function test_url_formatted_correctly_on_page_load() { + SpecialPowers.pushPrefEnv({ set: [["browser.urlbar.trimURLs", false]] }); + startServer(); + + let onValueChangeCalledAtLeastOnce = false; + let onValueChanged = _ => { + is(gURLBar.value, DECODED_PAGE, "Value is decoded."); + onValueChangeCalledAtLeastOnce = true; + }; + + gURLBar.inputField.addEventListener("ValueChange", onValueChanged); + registerCleanupFunction(() => { + gURLBar.inputField.removeEventListener("ValueChange", onValueChanged); + }); + + BrowserTestUtils.startLoadingURIString(gBrowser, PUNYCODE_PAGE); + // Check that whenever the value of the urlbar is changed, the correct + // decoded punycode url is used. + await BrowserTestUtils.browserLoaded(gBrowser, false, null, true); + + ok( + onValueChangeCalledAtLeastOnce, + "OnValueChanged of UrlbarInput was called at least once." + ); + // Check that the final value is decoded punycode as well. + is(gURLBar.value, DECODED_PAGE, "Final Urlbar value is correct"); + + // Cleanup. + SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_annotation.js b/browser/components/urlbar/tests/browser/browser_urlbar_annotation.js new file mode 100644 index 0000000000..d737fb3561 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_annotation.js @@ -0,0 +1,333 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether a visit information is annotated correctly when picking a result. + +if (AppConstants.platform === "macosx") { + requestLongerTimeout(2); +} + +const FRECENCY = { + ORGANIC: 2000, + SPONSORED: -1, + BOOKMARKED: 2075, + SEARCHED: 100, +}; + +const { + VISIT_SOURCE_ORGANIC, + VISIT_SOURCE_SPONSORED, + VISIT_SOURCE_BOOKMARKED, + VISIT_SOURCE_SEARCHED, +} = PlacesUtils.history; + +/** + * To be used before checking database contents when they depend on a visit + * being added to History. + * + * @param {string} href the page to await notifications for. + */ +async function waitForVisitNotification(href) { + await PlacesTestUtils.waitForNotification("page-visited", events => + events.some(e => e.url === href) + ); +} + +async function assertDatabase({ targetURL, expected }) { + const frecency = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: targetURL } + ); + Assert.equal(frecency, expected.frecency, "Frecency is correct"); + + const placesId = await PlacesTestUtils.getDatabaseValue("moz_places", "id", { + url: targetURL, + }); + const expectedTriggeringPlaceId = expected.triggerURL + ? await PlacesTestUtils.getDatabaseValue("moz_places", "id", { + url: expected.triggerURL, + }) + : null; + const db = await PlacesUtils.promiseDBConnection(); + const rows = await db.execute( + "SELECT source, triggeringPlaceId FROM moz_historyvisits WHERE place_id = :place_id AND source = :source", + { + place_id: placesId, + source: expected.source, + } + ); + Assert.equal(rows.length, 1); + Assert.equal( + rows[0].getResultByName("triggeringPlaceId"), + expectedTriggeringPlaceId, + `The triggeringPlaceId in database is correct for ${targetURL}` + ); +} + +function registerProvider(payload) { + const provider = new UrlbarTestUtils.TestProvider({ + results: [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...UrlbarResult.payloadAndSimpleHighlights([], { + ...payload, + }) + ), + ], + priority: Infinity, + }); + UrlbarProvidersManager.registerProvider(provider); + return provider; +} + +async function pickResult({ input, payloadURL, redirectTo }) { + const destinationURL = redirectTo || payloadURL; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: input, + fireInputEvent: true, + }); + + const result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal(result.url, payloadURL); + UrlbarTestUtils.setSelectedRowIndex(window, 0); + + info("Show result and wait for loading"); + const onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + destinationURL + ); + EventUtils.synthesizeKey("KEY_Enter"); + await onLoad; +} + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function basic() { + const testData = [ + { + description: "Sponsored result", + input: "exa", + payload: { + url: "https://example.com/", + isSponsored: true, + }, + expected: { + source: VISIT_SOURCE_SPONSORED, + frecency: FRECENCY.SPONSORED, + }, + }, + { + description: "Bookmarked result", + input: "exa", + payload: { + url: "https://example.com/", + }, + bookmarks: [ + { + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: Services.io.newURI("https://example.com/"), + title: "test bookmark", + }, + ], + expected: { + source: VISIT_SOURCE_BOOKMARKED, + frecency: FRECENCY.BOOKMARKED, + }, + }, + { + description: "Sponsored and bookmarked result", + input: "exa", + payload: { + url: "https://example.com/", + isSponsored: true, + }, + bookmarks: [ + { + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: Services.io.newURI("https://example.com/"), + title: "test bookmark", + }, + ], + expected: { + source: VISIT_SOURCE_SPONSORED, + frecency: FRECENCY.BOOKMARKED, + }, + }, + { + description: "Organic result", + input: "exa", + payload: { + url: "https://example.com/", + }, + expected: { + source: VISIT_SOURCE_ORGANIC, + frecency: FRECENCY.ORGANIC, + }, + }, + ]; + + for (const { description, input, payload, bookmarks, expected } of testData) { + info(description); + const provider = registerProvider(payload); + + for (const bookmark of bookmarks || []) { + await PlacesUtils.bookmarks.insert(bookmark); + } + + await BrowserTestUtils.withNewTab("about:blank", async () => { + info("Pick result"); + let promiseVisited = waitForVisitNotification(payload.url); + await pickResult({ input, payloadURL: payload.url }); + await promiseVisited; + info("Check database"); + await assertDatabase({ targetURL: payload.url, expected }); + }); + + UrlbarProvidersManager.unregisterProvider(provider); + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + } +}); + +add_task(async function redirection() { + const redirectTo = "https://example.com/"; + const payload = { + url: "https://example.com/browser/browser/components/urlbar/tests/browser/redirect_to.sjs?/", + isSponsored: true, + }; + const input = "exa"; + const provider = registerProvider(payload); + + await BrowserTestUtils.withNewTab("about:home", async () => { + info("Pick result"); + let promises = [ + waitForVisitNotification(payload.url), + waitForVisitNotification(redirectTo), + ]; + await pickResult({ input, payloadURL: payload.url, redirectTo }); + await Promise.all(promises); + + info("Check database"); + await assertDatabase({ + targetURL: payload.url, + expected: { + source: VISIT_SOURCE_SPONSORED, + frecency: FRECENCY.SPONSORED, + }, + }); + await assertDatabase({ + targetURL: redirectTo, + expected: { + source: VISIT_SOURCE_SPONSORED, + triggerURL: payload.url, + frecency: FRECENCY.SPONSORED, + }, + }); + }); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +add_task(async function search() { + const originalDefaultEngine = await Services.search.getDefault(); + await SearchTestUtils.installSearchExtension({ + name: "test engine", + keyword: "@test", + }); + + const testData = [ + { + description: "Searched result", + input: "@test abc", + resultURL: "https://example.com/?q=abc", + expected: { + source: VISIT_SOURCE_SEARCHED, + frecency: FRECENCY.SEARCHED, + }, + }, + { + description: "Searched bookmarked result", + input: "@test abc", + resultURL: "https://example.com/?q=abc", + bookmarks: [ + { + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: Services.io.newURI("https://example.com/?q=abc"), + title: "test bookmark", + }, + ], + expected: { + source: VISIT_SOURCE_BOOKMARKED, + frecency: FRECENCY.BOOKMARKED, + }, + }, + ]; + + for (const { + description, + input, + resultURL, + bookmarks, + expected, + } of testData) { + info(description); + await BrowserTestUtils.withNewTab("about:blank", async () => { + for (const bookmark of bookmarks || []) { + await PlacesUtils.bookmarks.insert(bookmark); + } + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: input, + }); + const onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + resultURL + ); + let promiseVisited = waitForVisitNotification(resultURL); + EventUtils.synthesizeKey("KEY_Enter"); + await onLoad; + await promiseVisited; + await assertDatabase({ targetURL: resultURL, expected }); + + // Open another URL to check whther the source is not inherited. + const payload = { url: "https://example.com/" }; + const provider = registerProvider(payload); + promiseVisited = waitForVisitNotification(payload.url); + await pickResult({ input, payloadURL: payload.url }); + await promiseVisited; + await assertDatabase({ + targetURL: payload.url, + expected: { + source: VISIT_SOURCE_ORGANIC, + frecency: FRECENCY.ORGANIC, + }, + }); + UrlbarProvidersManager.unregisterProvider(provider); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + }); + } + + await Services.search.setDefault( + originalDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_selection.js b/browser/components/urlbar/tests/browser/browser_urlbar_selection.js new file mode 100644 index 0000000000..233f61e4eb --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_selection.js @@ -0,0 +1,307 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const exampleSearch = "f oo bar"; +const exampleUrl = "https://example.com/1"; + +function click(target) { + let promise = BrowserTestUtils.waitForEvent(target, "click"); + EventUtils.synthesizeMouseAtCenter(target, {}, target.ownerGlobal); + return promise; +} + +function openContextMenu(target) { + let popupShownPromise = BrowserTestUtils.waitForEvent( + target.ownerGlobal, + "contextmenu" + ); + + EventUtils.synthesizeMouseAtCenter( + target, + { + type: "contextmenu", + button: 2, + }, + target.ownerGlobal + ); + return popupShownPromise; +} + +function drag(target, fromX, fromY, toX, toY) { + let promise = BrowserTestUtils.waitForEvent(target, "mouseup"); + EventUtils.synthesizeMouse( + target, + fromX, + fromY, + { type: "mousemove" }, + target.ownerGlobal + ); + EventUtils.synthesizeMouse( + target, + fromX, + fromY, + { type: "mousedown" }, + target.ownerGlobal + ); + EventUtils.synthesizeMouse( + target, + toX, + toY, + { type: "mousemove" }, + target.ownerGlobal + ); + EventUtils.synthesizeMouse( + target, + toX, + toY, + { type: "mouseup" }, + target.ownerGlobal + ); + return promise; +} + +function resetPrimarySelection(val = "") { + if ( + Services.clipboard.isClipboardTypeSupported( + Services.clipboard.kSelectionClipboard + ) + ) { + // Reset the clipboard. + clipboardHelper.copyStringToClipboard( + val, + Services.clipboard.kSelectionClipboard + ); + } +} + +function checkPrimarySelection(expectedVal = "") { + if ( + Services.clipboard.isClipboardTypeSupported( + Services.clipboard.kSelectionClipboard + ) + ) { + let primaryAsText = SpecialPowers.getClipboardData( + "text/plain", + SpecialPowers.Ci.nsIClipboard.kSelectionClipboard + ); + Assert.equal(primaryAsText, expectedVal); + } +} + +add_setup(async function () { + // On macOS, we must "warm up" the Urlbar to get the first test to pass. + gURLBar.value = ""; + await click(gURLBar.inputField); + gURLBar.blur(); +}); + +add_task(async function leftClickSelectsAll() { + resetPrimarySelection(); + gURLBar.value = exampleSearch; + await click(gURLBar.inputField); + Assert.equal( + gURLBar.selectionStart, + 0, + "The entire search term should be selected." + ); + Assert.equal( + gURLBar.selectionEnd, + exampleSearch.length, + "The entire search term should be selected." + ); + gURLBar.blur(); + checkPrimarySelection(); +}); + +add_task(async function leftClickSelectsUrl() { + resetPrimarySelection(); + gURLBar.value = exampleUrl; + await click(gURLBar.inputField); + Assert.equal(gURLBar.selectionStart, 0, "The entire url should be selected."); + Assert.equal( + gURLBar.selectionEnd, + UrlbarTestUtils.trimURL(exampleUrl).length, + "The entire url should be selected." + ); + gURLBar.blur(); + checkPrimarySelection(); +}); + +add_task(async function rightClickSelectsAll() { + gURLBar.inputField.focus(); + gURLBar.value = exampleUrl; + + // Remove the selection so the focus() call above doesn't influence the test. + gURLBar.selectionStart = gURLBar.selectionEnd = 0; + + resetPrimarySelection(); + + await openContextMenu(gURLBar.inputField); + + Assert.equal(gURLBar.selectionStart, 0, "The entire URL should be selected."); + Assert.equal( + gURLBar.selectionEnd, + UrlbarTestUtils.trimURL(exampleUrl).length, + "The entire URL should be selected." + ); + + checkPrimarySelection(); + + let contextMenu = gURLBar.querySelector("moz-input-box").menupopup; + + // While the context menu is open, test the "Select All" button. + let contextMenuItem = contextMenu.firstElementChild; + while ( + contextMenuItem.nextElementSibling && + contextMenuItem.getAttribute("cmd") != "cmd_selectAll" + ) { + contextMenuItem = contextMenuItem.nextElementSibling; + } + Assert.ok( + contextMenuItem, + "The context menu should have the select all menu item." + ); + + let controller = document.commandDispatcher.getControllerForCommand( + contextMenuItem.getAttribute("cmd") + ); + let enabled = controller.isCommandEnabled( + contextMenuItem.getAttribute("cmd") + ); + Assert.ok(enabled, "The context menu select all item should be enabled."); + + await click(contextMenuItem); + Assert.equal( + gURLBar.selectionStart, + 0, + "The entire URL should be selected after clicking selectAll button." + ); + Assert.equal( + gURLBar.selectionEnd, + UrlbarTestUtils.trimURL(exampleUrl).length, + "The entire URL should be selected after clicking selectAll button." + ); + + gURLBar.querySelector("moz-input-box").menupopup.hidePopup(); + gURLBar.blur(); + checkPrimarySelection(gURLBar._untrimmedValue); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function contextMenuDoesNotCancelSelection() { + gURLBar.inputField.focus(); + gURLBar.value = exampleUrl; + + gURLBar.selectionStart = 3; + gURLBar.selectionEnd = 7; + + resetPrimarySelection(); + + await openContextMenu(gURLBar.inputField); + + Assert.equal( + gURLBar.selectionStart, + 3, + "The selection should not have changed." + ); + Assert.equal( + gURLBar.selectionEnd, + 7, + "The selection should not have changed." + ); + + gURLBar.querySelector("moz-input-box").menupopup.hidePopup(); + gURLBar.blur(); + checkPrimarySelection(); +}); + +add_task(async function dragSelect() { + resetPrimarySelection(); + gURLBar.value = exampleSearch.repeat(10); + // Drags from an artibrary offset of 30 to test for bug 1562145: that the + // selection does not start at the beginning. + await drag(gURLBar.inputField, 30, 0, 60, 0); + Assert.greater( + gURLBar.selectionStart, + 0, + "Selection should not start at the beginning of the string." + ); + + let selectedVal = gURLBar.value.substring( + gURLBar.selectionStart, + gURLBar.selectionEnd + ); + gURLBar.blur(); + checkPrimarySelection(selectedVal); +}); + +/** + * Testing for bug 1571018: that the entire Urlbar isn't selected when the + * Urlbar is dragged following a selectsAll event then a blur. + */ +add_task(async function dragAfterSelectAll() { + resetPrimarySelection(); + gURLBar.value = exampleSearch.repeat(10); + await click(gURLBar.inputField); + Assert.equal( + gURLBar.selectionStart, + 0, + "The entire search term should be selected." + ); + Assert.equal( + gURLBar.selectionEnd, + exampleSearch.repeat(10).length, + "The entire search term should be selected." + ); + + gURLBar.blur(); + checkPrimarySelection(); + + // The offset of 30 is arbitrary. + await drag(gURLBar.inputField, 30, 0, 60, 0); + + Assert.notEqual( + gURLBar.selectionStart, + 0, + "Only part of the search term should be selected." + ); + Assert.notEqual( + gURLBar.selectionEnd, + exampleSearch.repeat(10).length, + "Only part of the search term should be selected." + ); + + checkPrimarySelection( + gURLBar.value.substring(gURLBar.selectionStart, gURLBar.selectionEnd) + ); +}); + +/** + * Testing for bug 1571018: that the entire Urlbar is selected when the Urlbar + * is refocused following a partial text selection then a blur. + */ +add_task(async function selectAllAfterDrag() { + gURLBar.value = exampleSearch; + + gURLBar.selectionStart = 3; + gURLBar.selectionEnd = 7; + + gURLBar.blur(); + + await click(gURLBar.inputField); + + Assert.equal( + gURLBar.selectionStart, + 0, + "The entire search term should be selected." + ); + Assert.equal( + gURLBar.selectionEnd, + exampleSearch.length, + "The entire search term should be selected." + ); + + gURLBar.blur(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry.js new file mode 100644 index 0000000000..679beb5752 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry.js @@ -0,0 +1,1218 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests urlbar telemetry with search related actions. + */ + +"use strict"; + +const SCALAR_URLBAR = "browser.engagement.navigation.urlbar"; +const SCALAR_SEARCHMODE = "browser.engagement.navigation.urlbar_searchmode"; + +// The preference to enable suggestions in the urlbar. +const SUGGEST_URLBAR_PREF = "browser.urlbar.suggest.searches"; + +ChromeUtils.defineESModuleGetters(this, { + SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs", +}); + +function searchInAwesomebar(value, win = window) { + return UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + waitForFocus, + value, + fireInputEvent: true, + }); +} + +/** + * Click one of the entries in the urlbar suggestion popup. + * + * @param {string} resultTitle + * The title of the result to click on. + * @param {number} button [optional] + * which button to click. + * @returns {number} + * The index of the result that was clicked, or -1 if not found. + */ +async function clickURLBarSuggestion(resultTitle, button = 1) { + await UrlbarTestUtils.promiseSearchComplete(window); + + const count = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < count; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (result.displayed.title == resultTitle) { + // This entry is the search suggestion we're looking for. + let element = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + i + ); + if (button == 1) { + EventUtils.synthesizeMouseAtCenter(element, {}); + } else if (button == 2) { + EventUtils.synthesizeMouseAtCenter(element, { + type: "mousedown", + button: 2, + }); + } + return i; + } + } + return -1; +} + +/** + * Create an engine to generate search suggestions and add it as default + * for this test. + * + * @param {Function} taskFn + * The function to run with the new search engine as default. + */ +async function withNewSearchEngine(taskFn) { + let suggestionEngine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "urlbarTelemetrySearchSuggestions.xml", + }); + let previousEngine = await Services.search.getDefault(); + await Services.search.setDefault( + suggestionEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + try { + await taskFn(suggestionEngine); + } finally { + await Services.search.setDefault( + previousEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.removeEngine(suggestionEngine); + } +} + +add_setup(async function () { + await SearchTestUtils.installSearchExtension( + { + name: "MozSearch", + keyword: "mozalias", + search_url: "https://example.com/", + }, + { setAsDefault: true } + ); + + // Make it the first one-off engine. + let engine = Services.search.getEngineByName("MozSearch"); + await Services.search.moveEngine(engine, 0); + + // Enable search suggestions in the urlbar. + let suggestionsEnabled = Services.prefs.getBoolPref(SUGGEST_URLBAR_PREF); + Services.prefs.setBoolPref(SUGGEST_URLBAR_PREF, true); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + // Enable event recording for the events tested here. + Services.telemetry.setEventRecordingEnabled("navigation", true); + + // Clear history so that history added by previous tests doesn't mess up this + // test when it selects results in the urlbar. + await PlacesUtils.history.clear(); + + // Clear historical search suggestions to avoid interference from previous + // tests. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxHistoricalSearchSuggestions", 0]], + }); + + // This test assumes that general results are shown before suggestions. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchSuggestionsFirst", false]], + }); + + // Make sure to restore the engine once we're done. + registerCleanupFunction(async function () { + Services.telemetry.canRecordExtended = oldCanRecord; + Services.prefs.setBoolPref(SUGGEST_URLBAR_PREF, suggestionsEnabled); + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + Services.telemetry.setEventRecordingEnabled("navigation", false); + }); +}); + +add_task(async function test_simpleQuery() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Simulate entering a simple search."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("simple query"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_URLBAR, + "search_enter", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_URLBAR]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // SEARCH_COUNTS should be incremented, but only the urlbar source since an + // internal @search keyword was not used. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + 1 + ); + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.alias", + undefined + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + [ + "navigation", + "search", + "urlbar", + "enter", + { engine: "other-MozSearch" }, + ], + ], + { category: "navigation", method: "search" } + ); + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter, + 1 + ); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_searchMode_enter() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Enter search mode using an alias and a query."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("mozalias query"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_SEARCHMODE, + "search_enter", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_SEARCHMODE]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + [ + "navigation", + "search", + "urlbar_searchmode", + "enter", + { engine: "other-MozSearch" }, + ], + ], + { category: "navigation", method: "search" } + ); + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter, + 1 + ); + + BrowserTestUtils.removeTab(tab); +}); + +// Performs a search using the first result, a one-off button, and the Return +// (Enter) key. +add_task(async function test_oneOff_enter() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Perform a one-off search using the first engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("query"); + + info("Pressing Alt+Down to take us to the first one-off engine."); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + let engine = + UrlbarTestUtils.getOneOffSearchButtons(window).selectedButton.engine; + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: engine.name, + entry: "oneoff", + }); + + // Now that we're in search mode, execute the search. + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_SEARCHMODE, + "search_enter", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_SEARCHMODE]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // SEARCH_COUNTS should be incremented, but only the urlbar-searchmode source + // since aliases aren't counted separately in search mode. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar-searchmode", + 1 + ); + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.alias", + undefined + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + [ + "navigation", + "search", + "urlbar_searchmode", + "enter", + { engine: "other-MozSearch" }, + ], + ], + { category: "navigation", method: "search" } + ); + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter, + 1 + ); + + BrowserTestUtils.removeTab(tab); +}); + +// Performs a search using the second result, a one-off button, and the Return +// (Enter) key. This only tests the FX_URLBAR_SELECTED_RESULT_METHOD histogram +// since test_oneOff_enter covers everything else. +add_task(async function test_oneOff_enterSelection() { + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + await withNewSearchEngine(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxHistoricalSearchSuggestions", 1]], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("query"); + + info( + "Select the second result, press Alt+Down to take us to the first one-off engine." + ); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + let engine = + UrlbarTestUtils.getOneOffSearchButtons(window).selectedButton.engine; + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: engine.name, + entry: "oneoff", + }); + + // Now that we're in search mode, execute the search. + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection, + 1 + ); + + await SpecialPowers.popPrefEnv(); + BrowserTestUtils.removeTab(tab); + }); +}); + +// Performs a search using a click on a one-off button. This only tests the +// FX_URLBAR_SELECTED_RESULT_METHOD histogram since test_oneOff_enter covers +// everything else. +add_task(async function test_oneOff_click() { + Services.telemetry.clearScalars(); + + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("query"); + + info("Click the first one-off button."); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + let oneOffButton = + UrlbarTestUtils.getOneOffSearchButtons(window).getSelectableButtons( + false + )[0]; + oneOffButton.click(); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: oneOffButton.engine.name, + entry: "oneoff", + }); + + // Now that we're in search mode, execute the search. + let element = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + Assert.ok(element, "Found result after entering search mode."); + EventUtils.synthesizeMouseAtCenter(element, {}); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.click, + 1 + ); + + BrowserTestUtils.removeTab(tab); +}); + +// Clicks the first suggestion offered by the test search engine. +add_task(async function test_suggestion_click() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + await UrlbarTestUtils.formHistory.clear(); + + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + await withNewSearchEngine(async function (engine) { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("query"); + info("Clicking the urlbar suggestion."); + await clickURLBarSuggestion("queryfoo"); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_URLBAR, + "search_suggestion", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_URLBAR]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // SEARCH_COUNTS should be incremented. + let searchEngineId = "other-" + engine.name; + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + searchEngineId + ".urlbar", + 1 + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + [ + "navigation", + "search", + "urlbar", + "suggestion", + { engine: searchEngineId }, + ], + ], + { category: "navigation", method: "search" } + ); + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.click, + 1 + ); + + BrowserTestUtils.removeTab(tab); + }); +}); + +// Selects and presses the Return (Enter) key on the first suggestion offered by +// the test search engine. This only tests the FX_URLBAR_SELECTED_RESULT_METHOD +// histogram since test_suggestion_click covers everything else. +add_task(async function test_suggestion_arrowEnterSelection() { + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + await withNewSearchEngine(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("query"); + info("Select the second result and press Return."); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection, + 1 + ); + + BrowserTestUtils.removeTab(tab); + }); +}); + +// Selects through tab and presses the Return (Enter) key on the first +// suggestion offered by the test search engine. +add_task(async function test_suggestion_tabEnterSelection() { + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + await withNewSearchEngine(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("query"); + info("Select the second result and press Return."); + EventUtils.synthesizeKey("KEY_Tab"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.tabEnterSelection, + 1 + ); + + BrowserTestUtils.removeTab(tab); + }); +}); + +// Selects through code and presses the Return (Enter) key on the first +// suggestion offered by the test search engine. +add_task(async function test_suggestion_enterSelection() { + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + await withNewSearchEngine(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("query"); + info("Select the second result and press Return."); + UrlbarTestUtils.setSelectedRowIndex(window, 1); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enterSelection, + 1 + ); + + BrowserTestUtils.removeTab(tab); + }); +}); + +// Clicks the first suggestion offered by the test search engine when in search +// mode. +add_task(async function test_searchmode_suggestion_click() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + await withNewSearchEngine(async function (engine) { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + await searchInAwesomebar("query"); + await UrlbarTestUtils.enterSearchMode(window, { + engineName: engine.name, + }); + info("Clicking the urlbar suggestion."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await clickURLBarSuggestion("queryfoo"); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_SEARCHMODE, + "search_suggestion", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_SEARCHMODE]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // SEARCH_COUNTS should be incremented. + let searchEngineId = "other-" + engine.name; + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + searchEngineId + ".urlbar-searchmode", + 1 + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + [ + "navigation", + "search", + "urlbar_searchmode", + "suggestion", + { engine: searchEngineId }, + ], + ], + { category: "navigation", method: "search" } + ); + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.click, + 1 + ); + + BrowserTestUtils.removeTab(tab); + }); +}); + +// Selects and presses the Return (Enter) key on the first suggestion offered by +// the test search engine in search mode. This only tests the +// FX_URLBAR_SELECTED_RESULT_METHOD histogram since +// test_searchmode_suggestion_click covers everything else. +add_task(async function test_searchmode_suggestion_arrowEnterSelection() { + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + await withNewSearchEngine(async function (engine) { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("query"); + await UrlbarTestUtils.enterSearchMode(window, { + engineName: engine.name, + }); + info("Select the second result and press Return."); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection, + 1 + ); + BrowserTestUtils.removeTab(tab); + }); +}); + +// Selects through tab and presses the Return (Enter) key on the first +// suggestion offered by the test search engine in search mode. +add_task(async function test_suggestion_tabEnterSelection() { + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + await withNewSearchEngine(async function (engine) { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("query"); + await UrlbarTestUtils.enterSearchMode(window, { + engineName: engine.name, + }); + info("Select the second result and press Return."); + EventUtils.synthesizeKey("KEY_Tab"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.tabEnterSelection, + 1 + ); + + BrowserTestUtils.removeTab(tab); + }); +}); + +// Selects through code and presses the Return (Enter) key on the first +// suggestion offered by the test search engine in search mode. +add_task(async function test_suggestion_enterSelection() { + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + await withNewSearchEngine(async function (engine) { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("query"); + await UrlbarTestUtils.enterSearchMode(window, { + engineName: engine.name, + }); + info("Select the second result and press Return."); + UrlbarTestUtils.setSelectedRowIndex(window, 1); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enterSelection, + 1 + ); + + BrowserTestUtils.removeTab(tab); + }); +}); + +// Clicks a form history result. +add_task(async function test_formHistory_click() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + await UrlbarTestUtils.formHistory.clear(); + await UrlbarTestUtils.formHistory.add(["foobar"]); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxHistoricalSearchSuggestions", 1]], + }); + + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + await withNewSearchEngine(async engine => { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. There should be form history."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("foo"); + info("Clicking the form history."); + await clickURLBarSuggestion("foobar"); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_URLBAR, + "search_formhistory", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_URLBAR]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // SEARCH_COUNTS should be incremented. + let searchEngineId = "other-" + engine.name; + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + searchEngineId + ".urlbar", + 1 + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + [ + "navigation", + "search", + "urlbar", + "formhistory", + { engine: searchEngineId }, + ], + ], + { category: "navigation", method: "search" } + ); + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.click, + 1 + ); + + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); + await SpecialPowers.popPrefEnv(); + }); +}); + +// Selects and presses the Return (Enter) key on a form history result. This +// only tests the FX_URLBAR_SELECTED_RESULT_METHOD histogram since +// test_formHistory_click covers everything else. +add_task(async function test_formHistory_arrowEnterSelection() { + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + await UrlbarTestUtils.formHistory.clear(); + await UrlbarTestUtils.formHistory.add(["foobar"]); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxHistoricalSearchSuggestions", 1]], + }); + + await withNewSearchEngine(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. There should be form history."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("foo"); + info("Select the form history result and press Return."); + while (gURLBar.untrimmedValue != "foobar") { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection, + 1 + ); + + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); + await SpecialPowers.popPrefEnv(); + }); +}); + +// Selects through tab and presses the Return (Enter) key on a form history +// result. +add_task(async function test_formHistory_tabEnterSelection() { + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + await UrlbarTestUtils.formHistory.clear(); + await UrlbarTestUtils.formHistory.add(["foobar"]); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxHistoricalSearchSuggestions", 1]], + }); + + await withNewSearchEngine(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. There should be form history."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("foo"); + info("Select the form history result and press Return."); + while (gURLBar.untrimmedValue != "foobar") { + EventUtils.synthesizeKey("KEY_Tab"); + } + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.tabEnterSelection, + 1 + ); + + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); + await SpecialPowers.popPrefEnv(); + }); +}); + +// Selects through code and presses the Return (Enter) key on a form history +// result. +add_task(async function test_formHistory_enterSelection() { + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + await UrlbarTestUtils.formHistory.clear(); + await UrlbarTestUtils.formHistory.add(["foobar"]); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxHistoricalSearchSuggestions", 1]], + }); + + await withNewSearchEngine(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. There should be form history."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("foo"); + info("Select the second result and press Return."); + let index = 1; + while (gURLBar.untrimmedValue != "foobar") { + UrlbarTestUtils.setSelectedRowIndex(window, index++); + } + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enterSelection, + 1 + ); + + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); + await SpecialPowers.popPrefEnv(); + }); +}); + +add_task(async function test_privateWindow() { + // This test assumes the showSearchTerms feature is not enabled, + // as multiple searches are made one after another, relying on + // urlbar as the keyed scalar SAP, not urlbar_persisted. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", false]], + }); + + // Override the search telemetry search provider info to + // count in-content SEARCH_COUNTs telemetry for our test engine. + SearchSERPTelemetry.overrideSearchTelemetryForTests([ + { + telemetryId: "example", + searchPageRegexp: "^https://example\\.com/", + queryParamNames: ["q"], + }, + ]); + + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + // First, do a bunch of searches in a private window. + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + info("Search in a private window and the pref does not exist"); + let p = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await searchInAwesomebar("query", win); + EventUtils.synthesizeKey("KEY_Enter", undefined, win); + await p; + + // SEARCH_COUNTS should be incremented. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + 1 + ); + let scalars = TelemetryTestUtils.getProcessScalars("parent", true); + console.log(scalars); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.content.urlbar", + "example:organic:none", + 1 + ); + + info("Search again in a private window after setting the pref to true"); + Services.prefs.setBoolPref("browser.engagement.search_counts.pbm", true); + p = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await searchInAwesomebar("another query", win); + EventUtils.synthesizeKey("KEY_Enter", undefined, win); + await p; + + // SEARCH_COUNTS should *not* be incremented. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + 1 + ); + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.content.urlbar", + "example:organic:none", + 1 + ); + + info("Search again in a private window after setting the pref to false"); + Services.prefs.setBoolPref("browser.engagement.search_counts.pbm", false); + p = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await searchInAwesomebar("another query", win); + EventUtils.synthesizeKey("KEY_Enter", undefined, win); + await p; + + // SEARCH_COUNTS should be incremented. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + 2 + ); + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.content.urlbar", + "example:organic:none", + 2 + ); + + info("Search again in a private window after clearing the pref"); + Services.prefs.clearUserPref("browser.engagement.search_counts.pbm"); + p = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await searchInAwesomebar("another query", win); + EventUtils.synthesizeKey("KEY_Enter", undefined, win); + await p; + + // SEARCH_COUNTS should be incremented. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + 3 + ); + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.content.urlbar", + "example:organic:none", + 3 + ); + + await BrowserTestUtils.closeWindow(win); + + // Now, do a bunch of searches in a non-private window. Telemetry should + // always be recorded regardless of the pref's value. + win = await BrowserTestUtils.openNewBrowserWindow(); + + info("Search in a non-private window and the pref does not exist"); + p = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await searchInAwesomebar("query", win); + EventUtils.synthesizeKey("KEY_Enter", undefined, win); + await p; + + // SEARCH_COUNTS should be incremented. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + 4 + ); + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.content.urlbar", + "example:organic:none", + 4 + ); + + info("Search again in a non-private window after setting the pref to true"); + Services.prefs.setBoolPref("browser.engagement.search_counts.pbm", true); + p = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await searchInAwesomebar("another query", win); + EventUtils.synthesizeKey("KEY_Enter", undefined, win); + await p; + + // SEARCH_COUNTS should be incremented. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + 5 + ); + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.content.urlbar", + "example:organic:none", + 5 + ); + + info("Search again in a non-private window after setting the pref to false"); + Services.prefs.setBoolPref("browser.engagement.search_counts.pbm", false); + p = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await searchInAwesomebar("another query", win); + EventUtils.synthesizeKey("KEY_Enter", undefined, win); + await p; + + // SEARCH_COUNTS should be incremented. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + 6 + ); + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.content.urlbar", + "example:organic:none", + 6 + ); + + info("Search again in a non-private window after clearing the pref"); + Services.prefs.clearUserPref("browser.engagement.search_counts.pbm"); + p = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await searchInAwesomebar("another query", win); + EventUtils.synthesizeKey("KEY_Enter", undefined, win); + await p; + + // SEARCH_COUNTS should be incremented. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + 7 + ); + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.content.urlbar", + "example:organic:none", + 7 + ); + + await BrowserTestUtils.closeWindow(win); + + // Reset the search provider info. + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + await UrlbarTestUtils.formHistory.clear(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_autofill.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_autofill.js new file mode 100644 index 0000000000..9abd990700 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_autofill.js @@ -0,0 +1,684 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests urlbar autofill telemetry. + */ + +"use strict"; + +const SCALAR_URLBAR = "browser.engagement.navigation.urlbar"; + +function assertSearchTelemetryEmpty(search_hist) { + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + Assert.ok( + !(SCALAR_URLBAR in scalars), + `Should not have recorded ${SCALAR_URLBAR}` + ); + + // SEARCH_COUNTS should not contain any engine counts at all. The keys in this + // histogram are search engine telemetry identifiers. + Assert.deepEqual( + Object.keys(search_hist.snapshot()), + [], + "SEARCH_COUNTS is empty" + ); + + // Also check events. + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ); + events = (events.parent || []).filter( + e => e[1] == "navigation" && e[2] == "search" + ); + Assert.deepEqual( + events, + [], + "Should not have recorded any navigation search events" + ); +} + +function snapshotHistograms() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + return { + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + search_hist: TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultMethodHist, method, 1); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + `urlbar.picked.${type}`, + index, + 1 + ); +} + +/** + * Performs a search and picks the first result. + * + * @param {string} searchString + * The search string. Assumed to trigger an autofill result + * @param {string} autofilledValue + * The input's expected value after autofill occurs. + * @param {string} unpickResult + * Optional: If true, do not pick any result. Default value is false. + * @param {string} urlToSelect + * Optional: If want to select result except autofill, pass the URL. + */ +async function triggerAutofillAndPickResult( + searchString, + autofilledValue, + unpickResult = false, + urlToSelect = null +) { + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill, "Result is autofill"); + Assert.equal(gURLBar.value, autofilledValue, "gURLBar.value"); + Assert.equal(gURLBar.selectionStart, searchString.length, "selectionStart"); + Assert.equal(gURLBar.selectionEnd, autofilledValue.length, "selectionEnd"); + + if (urlToSelect) { + for (let row = 0; row < UrlbarTestUtils.getResultCount(window); row++) { + const result = await UrlbarTestUtils.getDetailsOfResultAt(window, row); + if (result.url === urlToSelect) { + UrlbarTestUtils.setSelectedRowIndex(window, row); + break; + } + } + } + + if (unpickResult) { + // Close popup without any action. + await UrlbarTestUtils.promisePopupClose(window); + return; + } + + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + + let url; + if (urlToSelect) { + url = urlToSelect; + } else { + url = autofilledValue.includes(":") + ? autofilledValue + : "http://" + autofilledValue; + } + Assert.equal(gBrowser.currentURI.spec, url, "Loaded URL is correct"); + }); +} + +function createOtherAutofillProvider(searchString, autofilledValue) { + return new UrlbarTestUtils.TestProvider({ + priority: Infinity, + type: UrlbarUtils.PROVIDER_TYPE.HEURISTIC, + results: [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + title: "Test", + url: "http://example.com/", + } + ), + { + heuristic: true, + autofill: { + value: autofilledValue, + selectionStart: searchString.length, + selectionEnd: autofilledValue.length, + // Leave out `type` to trigger "other" + }, + } + ), + ], + }); +} + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + await PlacesTestUtils.clearInputHistory(); + + // Enable local telemetry recording for the duration of the tests. + const originalCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + // Make sure autofill is tested without upgrading pages to https + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.https_first_schemeless", false]], + }); + + registerCleanupFunction(async () => { + Services.telemetry.canRecordExtended = originalCanRecord; + await PlacesTestUtils.clearInputHistory(); + await PlacesUtils.history.clear(); + }); +}); + +// Checks adaptive history, origin, and URL autofill. +add_task(async function history() { + const testData = [ + { + useAdaptiveHistory: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "ex", + autofilled: "example.com/", + expected: "autofill_origin", + }, + { + useAdaptiveHistory: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exa", + autofilled: "example.com/test", + expected: "autofill_adaptive", + }, + { + useAdaptiveHistory: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exam", + autofilled: "example.com/test", + expected: "autofill_adaptive", + }, + { + useAdaptiveHistory: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "example.com", + autofilled: "example.com/test", + expected: "autofill_adaptive", + }, + { + useAdaptiveHistory: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "example.com/", + autofilled: "example.com/test", + expected: "autofill_adaptive", + }, + { + useAdaptiveHistory: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "example.com/test", + autofilled: "example.com/test", + expected: "autofill_adaptive", + }, + { + useAdaptiveHistory: true, + visitHistory: ["http://example.com/test", "http://example.org/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "example.org", + autofilled: "example.org/", + expected: "autofill_origin", + }, + { + useAdaptiveHistory: true, + visitHistory: ["http://example.com/test", "http://example.com/test/url"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "example.com/test/", + autofilled: "example.com/test/", + expected: "autofill_url", + }, + { + useAdaptiveHistory: true, + visitHistory: [{ uri: "http://example.com/test" }], + inputHistory: [ + { uri: "http://example.com/test", input: "http://example.com/test" }, + ], + userInput: "http://example.com/test", + autofilled: "http://example.com/test", + expected: "autofill_adaptive", + }, + { + useAdaptiveHistory: false, + visitHistory: [{ uri: "http://example.com/test" }], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "example", + autofilled: "example.com/", + expected: "autofill_origin", + }, + { + useAdaptiveHistory: false, + visitHistory: [{ uri: "http://example.com/test" }], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "example.com/te", + autofilled: "example.com/test", + expected: "autofill_url", + }, + ]; + + for (const { + useAdaptiveHistory, + visitHistory, + inputHistory, + userInput, + autofilled, + expected, + } of testData) { + const histograms = snapshotHistograms(); + + await PlacesTestUtils.addVisits(visitHistory); + for (const { uri, input } of inputHistory) { + await UrlbarUtils.addToInputHistory(uri, input); + } + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + UrlbarPrefs.set("autoFill.adaptiveHistory.enabled", useAdaptiveHistory); + + await triggerAutofillAndPickResult(userInput, autofilled); + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + expected, + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter + ); + + UrlbarPrefs.clear("autoFill.adaptiveHistory.enabled"); + await PlacesTestUtils.clearInputHistory(); + await PlacesUtils.history.clear(); + } +}); + +// Checks about-page autofill (e.g., "about:about"). +add_task(async function about() { + let histograms = snapshotHistograms(); + await triggerAutofillAndPickResult("about:abou", "about:about"); + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "autofill_about", + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter + ); + + await PlacesUtils.history.clear(); +}); + +// Checks the "other" fallback, which shouldn't normally happen. +add_task(async function other() { + let searchString = "exam"; + let autofilledValue = "example.com/"; + let provider = createOtherAutofillProvider(searchString, autofilledValue); + UrlbarProvidersManager.registerProvider(provider); + + let histograms = snapshotHistograms(); + await triggerAutofillAndPickResult(searchString, autofilledValue); + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "autofill_other", + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter + ); + + await PlacesUtils.history.clear(); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +// Checks impression telemetry. +add_task(async function impression() { + const testData = [ + { + description: "Adaptive history autofill and pick it", + useAdaptiveHistory: true, + visitHistory: ["http://example.com/first", "http://example.com/second"], + inputHistory: [{ uri: "http://example.com/first", input: "exa" }], + userInput: "exa", + autofilled: "example.com/first", + expected: "autofill_adaptive", + }, + { + description: "Adaptive history autofill but pick another result", + useAdaptiveHistory: true, + visitHistory: ["http://example.com/first", "http://example.com/second"], + inputHistory: [{ uri: "http://example.com/first", input: "exa" }], + userInput: "exa", + urlToSelect: "http://example.com/second", + autofilled: "example.com/first", + expected: "autofill_adaptive", + }, + { + description: "Adaptive history autofill but not pick any result", + unpickResult: true, + useAdaptiveHistory: true, + visitHistory: ["http://example.com/first", "http://example.com/second"], + inputHistory: [{ uri: "http://example.com/first", input: "exa" }], + userInput: "exa", + autofilled: "example.com/first", + }, + { + description: "Origin autofill and pick it", + visitHistory: ["http://example.com/first", "http://example.com/second"], + userInput: "exa", + autofilled: "example.com/", + expected: "autofill_origin", + }, + { + description: "Origin autofill but pick another result", + visitHistory: ["http://example.com/first", "http://example.com/second"], + userInput: "exa", + urlToSelect: "http://example.com/second", + autofilled: "example.com/", + expected: "autofill_origin", + }, + { + description: "Origin autofill but not pick any result", + unpickResult: true, + visitHistory: ["http://example.com/first", "http://example.com/second"], + userInput: "exa", + autofilled: "example.com/", + }, + { + description: "URL autofill and pick it", + visitHistory: ["http://example.com/first", "http://example.com/second"], + userInput: "example.com/", + autofilled: "example.com/", + expected: "autofill_url", + }, + { + description: "URL autofill but pick another result", + visitHistory: ["http://example.com/first", "http://example.com/second"], + userInput: "example.com/", + urlToSelect: "http://example.com/second", + autofilled: "example.com/", + expected: "autofill_url", + }, + { + description: "URL autofill but not pick any result", + unpickResult: true, + visitHistory: ["http://example.com/first", "http://example.com/second"], + userInput: "example.com/", + autofilled: "example.com/", + }, + { + description: "about page autofill and pick it", + userInput: "about:a", + autofilled: "about:about", + expected: "autofill_about", + }, + { + description: "about page autofill but pick another result", + userInput: "about:a", + urlToSelect: "about:addons", + autofilled: "about:about", + expected: "autofill_about", + }, + { + description: "about page autofill but not pick any result", + unpickResult: true, + userInput: "about:a", + autofilled: "about:about", + }, + { + description: "Other provider's autofill and pick it", + useOtherProvider: true, + userInput: "example", + autofilled: "example.com/", + expected: "autofill_other", + }, + { + description: "Other provider's autofill but not pick any result", + unpickResult: true, + useOtherProvider: true, + userInput: "example", + autofilled: "example.com/", + }, + ]; + + for (const { + description, + useAdaptiveHistory = false, + useOtherProvider = false, + unpickResult = false, + visitHistory, + inputHistory, + userInput, + select, + autofilled, + expected, + } of testData) { + info(description); + + UrlbarPrefs.set("autoFill.adaptiveHistory.enabled", useAdaptiveHistory); + let otherProvider; + if (useOtherProvider) { + otherProvider = createOtherAutofillProvider(userInput, autofilled); + UrlbarProvidersManager.registerProvider(otherProvider); + } + + if (visitHistory) { + await PlacesTestUtils.addVisits(visitHistory); + } + if (inputHistory) { + for (const { uri, input } of inputHistory) { + await UrlbarUtils.addToInputHistory(uri, input); + } + } + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await triggerAutofillAndPickResult( + userInput, + autofilled, + unpickResult, + select + ); + + const scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + if (unpickResult) { + TelemetryTestUtils.assertScalarUnset( + scalars, + "urlbar.impression.autofill_adaptive" + ); + TelemetryTestUtils.assertScalarUnset( + scalars, + "urlbar.impression.autofill_origin" + ); + TelemetryTestUtils.assertScalarUnset( + scalars, + "urlbar.impression.autofill_url" + ); + TelemetryTestUtils.assertScalarUnset( + scalars, + "urlbar.impression.autofill_about" + ); + } else { + TelemetryTestUtils.assertScalar( + scalars, + `urlbar.impression.${expected}`, + 1 + ); + } + + UrlbarPrefs.clear("autoFill.adaptiveHistory.enabled"); + + if (otherProvider) { + UrlbarProvidersManager.unregisterProvider(otherProvider); + } + + await PlacesTestUtils.clearInputHistory(); + await PlacesUtils.history.clear(); + } +}); + +// Checks autofill deletion telemetry. +add_task(async function deletion() { + await PlacesTestUtils.addVisits(["http://example.com/"]); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("Delete autofilled value by DELETE key"); + await doDeletionTest({ + firstSearchString: "exa", + firstAutofilledValue: "example.com/", + trigger: () => { + EventUtils.synthesizeKey("KEY_Delete"); + Assert.equal(gURLBar.value, "exa"); + }, + expectedScalar: 1, + }); + + info("Delete autofilled value by BACKSPACE key"); + await doDeletionTest({ + firstSearchString: "exa", + firstAutofilledValue: "example.com/", + trigger: () => { + EventUtils.synthesizeKey("KEY_Backspace"); + Assert.equal(gURLBar.value, "exa"); + }, + expectedScalar: 1, + }); + + info("Delete autofilled value twice"); + await doDeletionTest({ + firstSearchString: "exa", + firstAutofilledValue: "example.com/", + trigger: () => { + // Delete autofilled string. + EventUtils.synthesizeKey("KEY_Delete"); + Assert.equal(gURLBar.value, "exa"); + + // Re-autofilling. + EventUtils.synthesizeKey("m"); + Assert.equal(gURLBar.value, "example.com/"); + Assert.equal(gURLBar.selectionStart, "exam".length); + Assert.equal(gURLBar.selectionEnd, "example.com/".length); + + // Delete autofilled string again. + EventUtils.synthesizeKey("KEY_Backspace"); + Assert.equal(gURLBar.value, "exam"); + }, + expectedScalar: 2, + }); + + info("Delete one char after unselecting autofilled string"); + await doDeletionTest({ + firstSearchString: "exa", + firstAutofilledValue: "example.com/", + trigger: () => { + // Cancel selection. + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + Assert.equal(gURLBar.selectionStart, "example.com/".length); + Assert.equal(gURLBar.selectionEnd, "example.com/".length); + + EventUtils.synthesizeKey("KEY_Backspace"); + Assert.equal(gURLBar.value, "example.com"); + }, + expectedScalar: 0, + }); + + info("Delete autofilled value after unselecting autofilled string"); + await doDeletionTest({ + firstSearchString: "exa", + firstAutofilledValue: "example.com/", + trigger: () => { + // Cancel selection. + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + Assert.equal(gURLBar.selectionStart, "example.com/".length); + Assert.equal(gURLBar.selectionEnd, "example.com/".length); + + // Delete autofilled string one by one. + for (let i = 0; i < "mple.com/".length; i++) { + EventUtils.synthesizeKey("KEY_Backspace"); + } + Assert.equal(gURLBar.value, "exa"); + }, + expectedScalar: 0, + }); + + info( + "Delete autofilled value after unselecting autofilled string then selecting them manually again" + ); + await doDeletionTest({ + firstSearchString: "exa", + firstAutofilledValue: "example.com/", + trigger: () => { + // Cancel selection. + const previousSelectionStart = gURLBar.selectionStart; + const previousSelectionEnd = gURLBar.selectionEnd; + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + Assert.equal(gURLBar.selectionStart, "example.com/".length); + Assert.equal(gURLBar.selectionEnd, "example.com/".length); + + // Select same range again. + gURLBar.selectionStart = previousSelectionStart; + gURLBar.selectionEnd = previousSelectionEnd; + + EventUtils.synthesizeKey("KEY_Backspace"); + Assert.equal(gURLBar.value, "exa"); + }, + expectedScalar: 1, + }); + + await PlacesUtils.history.clear(); +}); + +async function doDeletionTest({ + firstSearchString, + firstAutofilledValue, + trigger, + expectedScalar, +}) { + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: firstSearchString, + fireInputEvent: true, + }); + const details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill, "Result is autofill"); + Assert.equal(gURLBar.value, firstAutofilledValue, "gURLBar.value"); + Assert.equal( + gURLBar.selectionStart, + firstSearchString.length, + "selectionStart" + ); + Assert.equal( + gURLBar.selectionEnd, + firstAutofilledValue.length, + "selectionEnd" + ); + + await trigger(); + + const scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + if (expectedScalar) { + TelemetryTestUtils.assertScalar( + scalars, + "urlbar.autofill_deletion", + expectedScalar + ); + } else { + TelemetryTestUtils.assertScalarUnset(scalars, "urlbar.autofill_deletion"); + } + + await UrlbarTestUtils.promisePopupClose(window); + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_dynamic.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_dynamic.js new file mode 100644 index 0000000000..d4f4e77d57 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_dynamic.js @@ -0,0 +1,136 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests urlbar telemetry for dynamic results. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", + UrlbarView: "resource:///modules/UrlbarView.sys.mjs", +}); + +const DYNAMIC_TYPE_NAME = "test"; + +/** + * A test URLBar provider. + */ +class TestProvider extends UrlbarTestUtils.TestProvider { + constructor() { + super({ + priority: Infinity, + results: [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.DYNAMIC, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + dynamicType: DYNAMIC_TYPE_NAME, + } + ), + { heuristic: true } + ), + ], + }); + } + + getViewUpdate(result, idsByName) { + return { + title: { + textContent: "This is a dynamic result.", + }, + button: { + textContent: "Click Me", + }, + }; + } +} + +add_task(async function test() { + // Add a dynamic result type. + UrlbarResult.addDynamicResultType(DYNAMIC_TYPE_NAME); + UrlbarView.addDynamicViewTemplate(DYNAMIC_TYPE_NAME, { + stylesheet: + getRootDirectory(gTestPath) + "urlbarTelemetryUrlbarDynamic.css", + children: [ + { + name: "title", + tag: "span", + }, + { + name: "buttonSpacer", + tag: "span", + }, + { + name: "button", + tag: "span", + attributes: { + role: "button", + }, + }, + ], + }); + registerCleanupFunction(() => { + UrlbarView.removeDynamicViewTemplate(DYNAMIC_TYPE_NAME); + UrlbarResult.removeDynamicResultType(DYNAMIC_TYPE_NAME); + }); + + // Register a provider that returns the dynamic result type. + let provider = new TestProvider(); + UrlbarProvidersManager.registerProvider(provider); + registerCleanupFunction(() => { + UrlbarProvidersManager.unregisterProvider(provider); + }); + + const histograms = snapshotHistograms(); + + // Do a search to show the dynamic result. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "test", + fireInputEvent: true, + }); + + // Press enter on the result's button. It will be preselected since the + // result is the heuristic. + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Enter") + ); + + assertTelemetryResults( + histograms, + "dynamic", + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter + ); + + // Clean up for subsequent tests. + gURLBar.handleRevert(); +}); + +function snapshotHistograms() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + return { + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultMethodHist, method, 1); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + `urlbar.picked.${type}`, + index, + 1 + ); +} diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_extension.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_extension.js new file mode 100644 index 0000000000..28eae06a6f --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_extension.js @@ -0,0 +1,155 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests urlbar telemetry with extension actions. + */ + +"use strict"; + +const SCALAR_URLBAR = "browser.engagement.navigation.urlbar"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +function assertSearchTelemetryEmpty(search_hist) { + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + Assert.ok( + !(SCALAR_URLBAR in scalars), + `Should not have recorded ${SCALAR_URLBAR}` + ); + + // Make sure SEARCH_COUNTS contains identical values. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + undefined + ); + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.alias", + undefined + ); + + // Also check events. + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ); + events = (events.parent || []).filter( + e => e[1] == "navigation" && e[2] == "search" + ); + Assert.deepEqual( + events, + [], + "Should not have recorded any navigation search events" + ); +} + +function snapshotHistograms() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + return { + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + search_hist: TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultMethodHist, method, 1); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + `urlbar.picked.${type}`, + index, + 1 + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Disable search suggestions in the urlbar. + ["browser.urlbar.suggest.searches", false], + // Clear historical search suggestions to avoid interference from previous + // tests. + ["browser.urlbar.maxHistoricalSearchSuggestions", 0], + // Turn autofill off. + ["browser.urlbar.autoFill", false], + ], + }); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + // Enable event recording for the events tested here. + Services.telemetry.setEventRecordingEnabled("navigation", true); + + // Clear history so that history added by previous tests doesn't mess up this + // test when it selects results in the urlbar. + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + // Make sure to restore the engine once we're done. + registerCleanupFunction(async function () { + Services.telemetry.canRecordExtended = oldCanRecord; + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + Services.telemetry.setEventRecordingEnabled("navigation", false); + }); +}); + +add_task(async function test_extension() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + omnibox: { + keyword: "omniboxtest", + }, + + background() { + /* global browser */ + browser.omnibox.setDefaultSuggestion({ + description: "doit", + }); + // Just do nothing for this test. + browser.omnibox.onInputEntered.addListener(() => {}); + browser.omnibox.onInputChanged.addListener((text, suggest) => { + suggest([]); + }); + }, + }, + }); + + await extension.startup(); + + const histograms = snapshotHistograms(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "omniboxtest ", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_Enter"); + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "extension", + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter + ); + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_handoff.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_handoff.js new file mode 100644 index 0000000000..6a0f84fbd0 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_handoff.js @@ -0,0 +1,182 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SearchSERPTelemetry } = ChromeUtils.importESModule( + "resource:///modules/SearchSERPTelemetry.sys.mjs" +); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.com\/browser\/browser\/components\/search\/test\/browser\/searchTelemetry(?:Ad)?.html/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + }, +]; + +function getPageUrl(useAdPage = false) { + let page = useAdPage ? "searchTelemetryAd.html" : "searchTelemetry.html"; + return `https://example.com/browser/browser/components/search/test/browser/${page}`; +} + +// sharedData messages are only passed to the child on idle. Therefore +// we wait for a few idles to try and ensure the messages have been able +// to be passed across and handled. +async function waitForIdle() { + for (let i = 0; i < 10; i++) { + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + } +} + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar", + true, + ], + ], + }); + + await SearchTestUtils.installSearchExtension( + { + search_url: getPageUrl(true), + search_url_get_params: "s={searchTerms}&abc=ff", + suggest_url: + "https://example.com/browser/browser/components/search/test/browser/searchSuggestionEngine.sjs", + suggest_url_get_params: "query={searchTerms}", + }, + { setAsDefault: true } + ); + + const oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + Services.telemetry.setEventRecordingEnabled("navigation", true); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + + Services.telemetry.canRecordExtended = oldCanRecord; + Services.telemetry.setEventRecordingEnabled("navigation", false); + + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + }); +}); + +add_task(async function test_search() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + const histogram = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + info("Load about:newtab in new window"); + const newtab = "about:newtab"; + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, newtab); + await BrowserTestUtils.browserStopped(tab.linkedBrowser, newtab); + + info("Focus on search input in newtab content"); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const searchInput = content.document.querySelector(".fake-editable"); + searchInput.click(); + }); + + info("Search and wait the result"); + const onLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("q"); + EventUtils.synthesizeKey("VK_RETURN"); + await onLoaded; + + info("Check the telemetries"); + await assertHandoffResult(histogram); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_search_private_mode() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + const histogram = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + info("Open private window"); + let privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + let tab = privateWindow.gBrowser.selectedTab; + + info("Focus on search input in newtab content"); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const searchInput = content.document.querySelector(".fake-editable"); + searchInput.click(); + }); + + info("Search and wait the result"); + const onLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("q", {}, privateWindow); + EventUtils.synthesizeKey("VK_RETURN", {}, privateWindow); + await onLoaded; + + info("Check the telemetries"); + await assertHandoffResult(histogram); + + await BrowserTestUtils.closeWindow(privateWindow); +}); + +async function assertHandoffResult(histogram) { + await assertScalars([ + ["browser.engagement.navigation.urlbar_handoff", "search_enter", 1], + ["browser.search.content.urlbar_handoff", "example:tagged:ff", 1], + ]); + await assertHistogram(histogram, [["other-Example.urlbar-handoff", 1]]); + TelemetryTestUtils.assertEvents( + [ + [ + "navigation", + "search", + "urlbar_handoff", + "enter", + { engine: "other-Example" }, + ], + ], + { category: "navigation", method: "search" } + ); +} + +async function assertHistogram(histogram, expectedResults) { + await TestUtils.waitForCondition(() => { + const snapshot = histogram.snapshot(); + return expectedResults.every(([key]) => key in snapshot); + }, "Wait until the histogram has expected keys"); + + for (const [key, value] of expectedResults) { + TelemetryTestUtils.assertKeyedHistogramSum(histogram, key, value); + } +} + +async function assertScalars(expectedResults) { + await TestUtils.waitForCondition(() => { + const scalars = TelemetryTestUtils.getProcessScalars("parent", true); + return expectedResults.every(([scalarName]) => scalarName in scalars); + }, "Wait until the scalars have expected keyed scalars"); + + const scalars = TelemetryTestUtils.getProcessScalars("parent", true); + + for (const [scalarName, key, value] of expectedResults) { + TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, key, value); + } +} diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_persisted.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_persisted.js new file mode 100644 index 0000000000..629e39855c --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_persisted.js @@ -0,0 +1,270 @@ +/* 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 tests browser.engagement.navigation.urlbar_persisted and the + * event navigation.search.urlbar_persisted + */ + +"use strict"; + +const { SearchSERPTelemetry } = ChromeUtils.importESModule( + "resource:///modules/SearchSERPTelemetry.sys.mjs" +); + +const SCALAR_URLBAR_PERSISTED = + "browser.engagement.navigation.urlbar_persisted"; + +const SEARCH_STRING = "chocolate"; + +let testEngine; +add_setup(async () => { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", true]], + }); + + await SearchTestUtils.installSearchExtension( + { + name: "MozSearch", + search_url: "https://www.example.com/", + search_url_get_params: "q={searchTerms}&pc=fake_code", + }, + { setAsDefault: true } + ); + + testEngine = Services.search.getEngineByName("MozSearch"); + + // Enable event recording for the events. + Services.telemetry.setEventRecordingEnabled("navigation", true); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + Services.telemetry.setEventRecordingEnabled("navigation", false); + }); +}); + +async function searchForString(searchString, tab) { + info(`Search for string: ${searchString}.`); + let [expectedSearchUrl] = UrlbarUtils.getSearchQueryUrl( + testEngine, + searchString + ); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + expectedSearchUrl + ); + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: searchString, + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_Enter"); + await browserLoadedPromise; + info("Finished loading search."); + return expectedSearchUrl; +} + +async function gotoUrl(url, tab) { + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + url + ); + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, url); + await browserLoadedPromise; + info(`Loaded page: ${url}`); +} + +async function goBack(browser) { + let pageShowPromise = BrowserTestUtils.waitForContentEvent( + browser, + "pageshow" + ); + browser.goBack(); + await pageShowPromise; + info("Go back a page."); +} + +async function goForward(browser) { + let pageShowPromise = BrowserTestUtils.waitForContentEvent( + browser, + "pageshow" + ); + browser.goForward(); + await pageShowPromise; + info("Go forward a page."); +} + +function assertScalarSearchEnter(number) { + let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_URLBAR_PERSISTED, + "search_enter", + number + ); +} + +function assertScalarDoesNotExist(scalar) { + let scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + Assert.ok(!(scalar in scalars), scalar + " must not be recorded."); +} + +function assertTelemetryEvents() { + TelemetryTestUtils.assertEvents( + [ + [ + "navigation", + "search", + "urlbar", + "enter", + { engine: "other-MozSearch" }, + ], + [ + "navigation", + "search", + "urlbar_persisted", + "enter", + { engine: "other-MozSearch" }, + ], + ], + { + category: "navigation", + method: "search", + } + ); +} + +// A user making a search after making a search should result +// in the telemetry being recorded. +add_task(async function search_after_search() { + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await searchForString(SEARCH_STRING, tab); + + // Scalar should not exist from a blank page, only when a search + // is conducted from a default SERP. + await assertScalarDoesNotExist(SCALAR_URLBAR_PERSISTED); + + // After the first search, we should expect the SAP to change + // because the search term should show up on the SERP. + await searchForString(SEARCH_STRING, tab); + assertScalarSearchEnter(1); + + // Check search counts. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar-persisted", + 1 + ); + + // Check events. + assertTelemetryEvents(); + + BrowserTestUtils.removeTab(tab); +}); + +// A user going to a tab that contains a SERP should +// trigger the telemetry when conducting a search. +add_task(async function switch_to_tab_and_search() { + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + const tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await searchForString(SEARCH_STRING, tab1); + + const tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await gotoUrl("https://www.example.com/some-place", tab2); + + await BrowserTestUtils.switchTab(gBrowser, tab1); + await searchForString(SEARCH_STRING, tab1); + assertScalarSearchEnter(1); + + // Check search count. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar-persisted", + 1 + ); + + // Check events. + assertTelemetryEvents(); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); + +// When a user reverts the Urlbar after the search terms persist, +// conducting another search should still be registered as a +// urlbar-persisted SAP. +add_task(async function handle_revert() { + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await searchForString(SEARCH_STRING, tab); + + gURLBar.handleRevert(); + await searchForString(SEARCH_STRING, tab); + + assertScalarSearchEnter(1); + + // Check search count. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar-persisted", + 1 + ); + + // Check events. + assertTelemetryEvents(); + + BrowserTestUtils.removeTab(tab); +}); + +// A user going back and forth in history should trigger +// urlbar-persisted telemetry when returning to a SERP +// and conducting a search. +add_task(async function back_and_forth() { + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + // Create three pages in history: a page, a SERP, and a page. + await gotoUrl("https://www.example.com/some-place", tab); + await searchForString(SEARCH_STRING, tab); + await gotoUrl("https://www.example.com/another-page", tab); + + // Go back to the SERP by using both back and forward. + await goBack(tab.linkedBrowser); + await goBack(tab.linkedBrowser); + await goForward(tab.linkedBrowser); + await assertScalarDoesNotExist(SCALAR_URLBAR_PERSISTED); + + // Then do a search. + await searchForString(SEARCH_STRING, tab); + assertScalarSearchEnter(1); + + // Check search count. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar-persisted", + 1 + ); + + // Check events. + assertTelemetryEvents(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_places.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_places.js new file mode 100644 index 0000000000..671ff9320b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_places.js @@ -0,0 +1,321 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests urlbar telemetry with places related actions (e.g. history/ + * bookmark selection). + */ + +"use strict"; + +const SCALAR_URLBAR = "browser.engagement.navigation.urlbar"; + +const TEST_URL = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://mochi.test:8888" +); + +ChromeUtils.defineESModuleGetters(this, { + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +function searchInAwesomebar(value, win = window) { + return UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + waitForFocus, + value, + fireInputEvent: true, + }); +} + +function assertSearchTelemetryEmpty(search_hist) { + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + Assert.ok( + !(SCALAR_URLBAR in scalars), + `Should not have recorded ${SCALAR_URLBAR}` + ); + + // Make sure SEARCH_COUNTS contains identical values. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + undefined + ); + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.alias", + undefined + ); + + // Also check events. + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ); + events = (events.parent || []).filter( + e => e[1] == "navigation" && e[2] == "search" + ); + Assert.deepEqual( + events, + [], + "Should not have recorded any navigation search events" + ); +} + +function snapshotHistograms() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + return { + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + search_hist: TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultMethodHist, method, 1); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + `urlbar.picked.${type}`, + index, + 1 + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Disable search suggestions in the urlbar. + ["browser.urlbar.suggest.searches", false], + // Clear historical search suggestions to avoid interference from previous + // tests. + ["browser.urlbar.maxHistoricalSearchSuggestions", 0], + // Turn autofill off. + ["browser.urlbar.autoFill", false], + ], + }); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + // Enable event recording for the events tested here. + Services.telemetry.setEventRecordingEnabled("navigation", true); + + // Clear history so that history added by previous tests doesn't mess up this + // test when it selects results in the urlbar. + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + await PlacesUtils.keywords.insert({ + keyword: "get", + url: TEST_URL + "?q=%s", + }); + + // Make sure to restore the engine once we're done. + registerCleanupFunction(async function () { + await PlacesUtils.keywords.remove("get"); + Services.telemetry.canRecordExtended = oldCanRecord; + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + Services.telemetry.setEventRecordingEnabled("navigation", false); + }); +}); + +add_task(async function test_history() { + const histograms = snapshotHistograms(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com", + title: "example", + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + ]); + + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("example"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "history", + 1, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection + ); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_history_adaptive() { + const histograms = snapshotHistograms(); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("example"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "history_adaptive", + 1, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection + ); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_bookmark_without_history() { + await PlacesUtils.history.clear(); + + const histograms = snapshotHistograms(); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + let bm = await PlacesUtils.bookmarks.insert({ + url: "http://example.com", + title: "example", + parentGuid: PlacesUtils.bookmarks.menuGuid, + }); + + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("example"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "bookmark", + 1, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection + ); + + await PlacesUtils.bookmarks.remove(bm); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_bookmark_with_history() { + const histograms = snapshotHistograms(); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + let bm = await PlacesUtils.bookmarks.insert({ + url: "http://example.com", + title: "example", + parentGuid: PlacesUtils.bookmarks.menuGuid, + }); + + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("example"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "bookmark_adaptive", + 1, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection + ); + + await PlacesUtils.bookmarks.remove(bm); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_keyword() { + const histograms = snapshotHistograms(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("get example"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "keyword", + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter + ); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_switchtab() { + const histograms = snapshotHistograms(); + + let homeTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:buildconfig" + ); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + + let p = BrowserTestUtils.waitForEvent(gBrowser, "TabSwitchDone"); + await searchInAwesomebar("about:buildconfig"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "switchtab", + 1, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection + ); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(homeTab); +}); + +add_task(async function test_visitURL() { + const histograms = snapshotHistograms(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("http://example.com/a/"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "visiturl", + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter + ); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_quickactions.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_quickactions.js new file mode 100644 index 0000000000..b29807900b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_quickactions.js @@ -0,0 +1,133 @@ +/* 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_urlbar_telemetry_remotetab.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_remotetab.js new file mode 100644 index 0000000000..ffa3158f2b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_remotetab.js @@ -0,0 +1,185 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests urlbar telemetry with remote tab action. + */ + +"use strict"; + +const SCALAR_URLBAR = "browser.engagement.navigation.urlbar"; + +ChromeUtils.defineESModuleGetters(this, { + SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +function assertSearchTelemetryEmpty(search_hist) { + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + Assert.ok( + !(SCALAR_URLBAR in scalars), + `Should not have recorded ${SCALAR_URLBAR}` + ); + + // Make sure SEARCH_COUNTS contains identical values. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + undefined + ); + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.alias", + undefined + ); + + // Also check events. + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ); + events = (events.parent || []).filter( + e => e[1] == "navigation" && e[2] == "search" + ); + Assert.deepEqual( + events, + [], + "Should not have recorded any navigation search events" + ); +} + +function snapshotHistograms() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + return { + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + search_hist: TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultMethodHist, method, 1); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + `urlbar.picked.${type}`, + index, + 1 + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Disable search suggestions in the urlbar. + ["browser.urlbar.suggest.searches", false], + // Clear historical search suggestions to avoid interference from previous + // tests. + ["browser.urlbar.maxHistoricalSearchSuggestions", 0], + // Turn autofill off. + ["browser.urlbar.autoFill", false], + // Special prefs for remote tabs. + ["services.sync.username", "fake"], + ["services.sync.syncedTabs.showRemoteTabs", true], + ], + }); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + // Enable event recording for the events tested here. + Services.telemetry.setEventRecordingEnabled("navigation", true); + + // Clear history so that history added by previous tests doesn't mess up this + // test when it selects results in the urlbar. + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + const REMOTE_TAB = { + id: "7cqCr77ptzX3", + type: "client", + lastModified: 1492201200, + name: "zcarter's Nightly on MacBook-Pro-25", + clientType: "desktop", + tabs: [ + { + type: "tab", + title: "Test Remote", + url: "http://example.com", + icon: UrlbarUtils.ICON.DEFAULT, + client: "7cqCr77ptzX3", + lastUsed: Math.floor(Date.now() / 1000), + }, + ], + }; + + const sandbox = sinon.createSandbox(); + + let originalSyncedTabsInternal = SyncedTabs._internal; + SyncedTabs._internal = { + isConfiguredToSyncTabs: true, + hasSyncedThisSession: true, + getTabClients() { + return Promise.resolve([]); + }, + syncTabs() { + return Promise.resolve(); + }, + }; + + // Tell the Sync XPCOM service it is initialized. + let weaveXPCService = Cc["@mozilla.org/weave/service;1"].getService( + Ci.nsISupports + ).wrappedJSObject; + let oldWeaveServiceReady = weaveXPCService.ready; + weaveXPCService.ready = true; + + sandbox + .stub(SyncedTabs._internal, "getTabClients") + .callsFake(() => Promise.resolve(Cu.cloneInto([REMOTE_TAB], {}))); + + // Make sure to restore the engine once we're done. + registerCleanupFunction(async function () { + sandbox.restore(); + weaveXPCService.ready = oldWeaveServiceReady; + SyncedTabs._internal = originalSyncedTabsInternal; + Services.telemetry.canRecordExtended = oldCanRecord; + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + Services.telemetry.setEventRecordingEnabled("navigation", false); + }); +}); + +add_task(async function test_remotetab() { + const histograms = snapshotHistograms(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "example", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "remotetab", + 1, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection + ); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_searchmode.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_searchmode.js new file mode 100644 index 0000000000..7830102cf6 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_searchmode.js @@ -0,0 +1,592 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests the urlbar.searchmode.* scalars telemetry with search mode + * related actions. + */ + +"use strict"; + +const ENTRY_SCALAR_PREFIX = "urlbar.searchmode."; +const PICKED_SCALAR_PREFIX = "urlbar.picked.searchmode."; +const ENGINE_ALIAS = "alias"; +const TEST_QUERY = "test"; +let engineName; +let engineDomain; + +// The preference to enable suggestions. +const SUGGEST_PREF = "browser.search.suggest.enabled"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderTabToSearch: + "resource:///modules/UrlbarProviderTabToSearch.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "TouchBarHelper", + "@mozilla.org/widget/touchbarhelper;1", + "nsITouchBarHelper" +); + +/** + * Asserts that search mode telemetry was recorded correctly. Checks both the + * urlbar.searchmode.* and urlbar.searchmode_picked.* probes. + * + * @param {string} entry + * A search mode entry point. + * @param {string} engineOrSource + * An engine name or a search mode source. + * @param {number} [resultIndex] + * The index of the result picked while in search mode. Only pass this + * parameter if a result is picked. + */ +function assertSearchModeScalars(entry, engineOrSource, resultIndex = -1) { + // Check if the urlbar.searchmode.entry scalar contains the expected value. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + ENTRY_SCALAR_PREFIX + entry, + engineOrSource, + 1 + ); + + for (let e of UrlbarUtils.SEARCH_MODE_ENTRY) { + if (e == entry) { + Assert.equal( + Object.keys(scalars[ENTRY_SCALAR_PREFIX + entry]).length, + 1, + `This search must only increment one entry in the correct scalar: ${e}` + ); + } else { + Assert.ok( + !scalars[ENTRY_SCALAR_PREFIX + e], + `No other urlbar.searchmode scalars should be recorded. Checking ${e}` + ); + } + } + + if (resultIndex >= 0) { + TelemetryTestUtils.assertKeyedScalar( + scalars, + PICKED_SCALAR_PREFIX + entry, + resultIndex, + 1 + ); + } + + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Disable tab-to-search onboarding results for general tests. They are + // enabled in tests that specifically address onboarding. + ["browser.urlbar.tabToSearch.onboard.interactionsLeft", 0], + ], + }); + + // Create an engine to generate search suggestions and add it as default + // for this test. + let suggestionEngine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "urlbarTelemetrySearchSuggestions.xml", + setAsDefault: true, + }); + suggestionEngine.alias = ENGINE_ALIAS; + engineDomain = suggestionEngine.searchUrlDomain; + engineName = suggestionEngine.name; + + // And the first one-off engine. + await Services.search.moveEngine(suggestionEngine, 0); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + // Clear history so that history added by previous tests doesn't mess up this + // test when it selects results in the urlbar. + await PlacesUtils.history.clear(); + + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + // Clear historical search suggestions to avoid interference from previous + // tests. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxHistoricalSearchSuggestions", 0]], + }); + + // Make sure to restore the engine once we're done. + registerCleanupFunction(async function () { + Services.telemetry.canRecordExtended = oldCanRecord; + await PlacesUtils.history.clear(); + Services.telemetry.setEventRecordingEnabled("navigation", false); + }); +}); + +// Clicks the first one off. +add_task(async function test_oneoff_remote() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + // Enters search mode by clicking a one-off. + await UrlbarTestUtils.enterSearchMode(window); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + assertSearchModeScalars("oneoff", "other", 0); + + BrowserTestUtils.removeTab(tab); +}); + +// Clicks the history one off. +add_task(async function test_oneoff_local() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + // Enters search mode by clicking a one-off. + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + assertSearchModeScalars("oneoff", "history", 0); + + BrowserTestUtils.removeTab(tab); +}); + +// Checks that the Amazon search mode name is collapsed to "Amazon". +add_task(async function test_oneoff_amazon() { + // Disable suggestions to avoid hitting Amazon servers. + await SpecialPowers.pushPrefEnv({ + set: [[SUGGEST_PREF, false]], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + // Enters search mode by clicking a one-off. + await UrlbarTestUtils.enterSearchMode(window, { + engineName: "Amazon.com", + }); + assertSearchModeScalars("oneoff", "Amazon"); + await UrlbarTestUtils.exitSearchMode(window); + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// Checks that the Wikipedia search mode name is collapsed to "Wikipedia". +add_task(async function test_oneoff_wikipedia() { + // Disable suggestions to avoid hitting Wikipedia servers. + await SpecialPowers.pushPrefEnv({ + set: [[SUGGEST_PREF, false]], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + // Enters search mode by clicking a one-off. + await UrlbarTestUtils.enterSearchMode(window, { + engineName: "Wikipedia (en)", + }); + assertSearchModeScalars("oneoff", "Wikipedia"); + await UrlbarTestUtils.exitSearchMode(window); + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// Enters search mode by pressing the keyboard shortcut. +add_task(async function test_shortcut() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + // Enter search mode by pressing the keyboard shortcut. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("k", { accelKey: true }); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + entry: "shortcut", + }); + assertSearchModeScalars("shortcut", "other"); + + BrowserTestUtils.removeTab(tab); +}); + +// Enters search mode by selecting a Top Site from the Urlbar. +add_task(async function test_topsites_urlbar() { + // Disable suggestions to avoid hitting Amazon servers. + await SpecialPowers.pushPrefEnv({ + set: [[SUGGEST_PREF, false]], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + // Enter search mode by selecting a Top Site from the Urlbar. + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + + let amazonSearch = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + 0 + ); + Assert.equal( + amazonSearch.result.payload.keyword, + "@amazon", + "First result should have the Amazon keyword." + ); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeMouseAtCenter(amazonSearch, {}); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: amazonSearch.result.payload.engine, + entry: "topsites_urlbar", + }); + assertSearchModeScalars("topsites_urlbar", "Amazon"); + await UrlbarTestUtils.exitSearchMode(window); + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// Enters search mode by selecting a keyword offer result. +add_task(async function test_keywordoffer() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + // Do a search for "@" + our test alias. It should autofill with a trailing + // space, and the heuristic result should be an autofill result with a keyword + // offer. + let alias = "@" + ENGINE_ALIAS; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: alias, + }); + let keywordOfferResult = await UrlbarTestUtils.getDetailsOfResultAt( + window, + 0 + ); + Assert.equal( + keywordOfferResult.searchParams.keyword, + alias, + "The first result should be a keyword search result with the correct alias." + ); + + // Pick the keyword offer result. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName, + entry: "keywordoffer", + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + assertSearchModeScalars("keywordoffer", "other", 0); + + BrowserTestUtils.removeTab(tab); +}); + +// Enters search mode by typing an alias. +add_task(async function test_typed() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + // Enter search mode by selecting a keywordoffer result. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: `${ENGINE_ALIAS} `, + }); + + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey(" "); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName, + entry: "typed", + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + assertSearchModeScalars("typed", "other", 0); + + BrowserTestUtils.removeTab(tab); +}); + +// Enters search mode by calling the same function called by the Search +// Bookmarks menu item in Library > Bookmarks. +add_task(async function test_bookmarkmenu() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + PlacesCommandHook.searchBookmarks(); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "bookmarkmenu", + }); + assertSearchModeScalars("bookmarkmenu", "bookmarks"); + BrowserTestUtils.removeTab(tab); +}); + +// Enters search mode by calling the same function called from a History +// menu. +add_task(async function test_historymenu() { + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + PlacesCommandHook.searchHistory(); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + entry: "historymenu", + }); + assertSearchModeScalars("historymenu", "history"); +}); + +// Enters search mode by calling the same function called by the Search Tabs +// menu item in the tab overflow menu. +add_task(async function test_tabmenu() { + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + gTabsPanel.searchTabs(); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.TABS, + entry: "tabmenu", + }); + assertSearchModeScalars("tabmenu", "tabs"); +}); + +// Enters search mode by performing a search handoff on about:privatebrowsing. +// Note that handoff-to-search-mode only occurs when suggestions are disabled +// in the Urlbar. +// NOTE: We don't test handoff on about:home. Running mochitests on about:home +// is quite difficult. This subtest verifies that `handoff` is a valid scalar +// suffix and that a call to UrlbarInput.handoff(value, searchEngine) records +// values in the urlbar.searchmode.handoff scalar. PlacesFeed.test.js verfies that +// about:home handoff makes that exact call. +add_task(async function test_handoff_pbm() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", false]], + }); + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + waitForTabURL: "about:privatebrowsing", + }); + let tab = win.gBrowser.selectedBrowser; + + await SpecialPowers.spawn(tab, [], async function () { + let btn = content.document.getElementById("search-handoff-button"); + btn.click(); + }); + + let searchPromise = UrlbarTestUtils.promiseSearchComplete(win); + await new Promise(r => EventUtils.synthesizeKey("f", {}, win, r)); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(win, { + engineName, + entry: "handoff", + }); + assertSearchModeScalars("handoff", "other"); + + await UrlbarTestUtils.exitSearchMode(win); + await UrlbarTestUtils.promisePopupClose(win); + await BrowserTestUtils.closeWindow(win); + await SpecialPowers.popPrefEnv(); +}); + +// Enters search mode by tapping a search shortcut on the Touch Bar. +add_task(async function test_touchbar() { + if (AppConstants.platform != "macosx") { + return; + } + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + // We have to fake the tap on the Touch Bar since mochitests have no way of + // interacting with the Touch Bar. + TouchBarHelper.insertRestrictionInUrlbar(UrlbarTokenizer.RESTRICT.HISTORY); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + entry: "touchbar", + }); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + assertSearchModeScalars("touchbar", "history", 0); + BrowserTestUtils.removeTab(tab); +}); + +// Enters search mode by selecting a tab-to-search result. +// Tests that tab-to-search results preview search mode when highlighted. These +// results are worth testing separately since they do not set the +// payload.keyword parameter. +add_task(async function test_tabtosearch() { + await SpecialPowers.pushPrefEnv({ + set: [ + // Do not show the onboarding result for this subtest. + ["browser.urlbar.tabToSearch.onboard.interactionsLeft", 0], + ], + }); + await PlacesTestUtils.addVisits([`http://${engineDomain}/`]); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: engineDomain.slice(0, 4), + }); + let tabToSearchResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + tabToSearchResult.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + Assert.equal( + tabToSearchResult.payload.engine, + engineName, + "The tab-to-search result is for the correct engine." + ); + await UrlbarTestUtils.assertSearchMode(window, null); + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + "Sanity check: The second result is selected." + ); + // Pick the tab-to-search result. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName, + entry: "tabtosearch", + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + assertSearchModeScalars("tabtosearch", "other", 0); + + BrowserTestUtils.removeTab(tab); + + await PlacesUtils.history.clear(); + await SpecialPowers.popPrefEnv(); +}); + +// Enters search mode by selecting a tab-to-search onboarding result. +add_task(async function test_tabtosearch_onboard() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 3]], + }); + await PlacesTestUtils.addVisits([`http://${engineDomain}/`]); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: engineDomain.slice(0, 4), + fireInputEvent: true, + }); + let tabToSearchResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + tabToSearchResult.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + Assert.equal( + tabToSearchResult.payload.engine, + engineName, + "The tab-to-search result is for the correct engine." + ); + Assert.equal( + tabToSearchResult.payload.dynamicType, + "onboardTabToSearch", + "The tab-to-search result is an onboarding result." + ); + await UrlbarTestUtils.assertSearchMode(window, null); + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + "Sanity check: The second result is selected." + ); + // Pick the tab-to-search onboarding result. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName, + entry: "tabtosearch_onboard", + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + assertSearchModeScalars("tabtosearch_onboard", "other", 0); + + UrlbarPrefs.set("tabToSearch.onboard.interactionsLeft", 3); + delete UrlbarProviderTabToSearch.onboardingInteractionAtTime; + + BrowserTestUtils.removeTab(tab); + await PlacesUtils.history.clear(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tabtosearch.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tabtosearch.js new file mode 100644 index 0000000000..318b29ad19 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tabtosearch.js @@ -0,0 +1,418 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests telemetry for tabtosearch results. + * NB: This file does not test the search mode `entry` field for tab-to-search + * results. That is tested in browser_UsageTelemetry_urlbar_searchmode.js. + */ + +"use strict"; + +const ENGINE_NAME = "MozSearch"; +const ENGINE_DOMAIN = "example.com"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderTabToSearch: + "resource:///modules/UrlbarProviderTabToSearch.sys.mjs", +}); + +function snapshotHistograms() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + return { + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + search_hist: TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultMethodHist, method, 1); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + `urlbar.picked.${type}`, + index, + 1 + ); +} + +/** + * Checks to see if the second result in the Urlbar is a tab-to-search result + * with the correct engine. + * + * @param {string} engineName + * The expected engine name. + * @param {boolean} [isOnboarding] + * If true, expects the tab-to-search result to be an onbarding result. + */ +async function checkForTabToSearchResult(engineName, isOnboarding) { + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Popup should be open."); + let tabToSearchResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + tabToSearchResult.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + Assert.equal( + tabToSearchResult.payload.engine, + engineName, + "The tab-to-search result is for the first engine." + ); + if (isOnboarding) { + Assert.equal( + tabToSearchResult.payload.dynamicType, + "onboardTabToSearch", + "The tab-to-search result is an onboarding result." + ); + } else { + Assert.ok( + !tabToSearchResult.payload.dynamicType, + "The tab-to-search result should not be an onboarding result." + ); + } +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 0]], + }); + + await SearchTestUtils.installSearchExtension({ + name: ENGINE_NAME, + search_url: `https://${ENGINE_DOMAIN}/`, + }); + + // Reset the enginesShown sets in case a previous test showed a tab-to-search + // result but did not end its engagement. + UrlbarProviderTabToSearch.enginesShown.regular.clear(); + UrlbarProviderTabToSearch.enginesShown.onboarding.clear(); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + registerCleanupFunction(async () => { + Services.telemetry.canRecordExtended = oldCanRecord; + }); +}); + +add_task(async function test() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + const histograms = snapshotHistograms(); + + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits([`https://${ENGINE_DOMAIN}/`]); + } + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + + let tabToSearchResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + tabToSearchResult.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + Assert.equal( + tabToSearchResult.payload.engine, + ENGINE_NAME, + "The tab-to-search result is for the correct engine." + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + "Sanity check: The second result is selected." + ); + + // Select the tab-to-search result. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: ENGINE_NAME, + entry: "tabtosearch", + }); + + assertTelemetryResults( + histograms, + "tabtosearch", + 1, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection + ); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); + await PlacesUtils.history.clear(); + }); + + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); +}); + +add_task(async function impressions() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 0]], + }); + await impressions_test(false); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function onboarding_impressions() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 3]], + }); + await impressions_test(true); + await SpecialPowers.popPrefEnv(); + delete UrlbarProviderTabToSearch.onboardingInteractionAtTime; +}); + +async function impressions_test(isOnboarding) { + await BrowserTestUtils.withNewTab("about:blank", async browser => { + const firstEngineHost = "example"; + let extension = await SearchTestUtils.installSearchExtension( + { + name: `${ENGINE_NAME}2`, + search_url: `https://${firstEngineHost}-2.com/`, + }, + { skipUnload: true } + ); + + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits([`https://${firstEngineHost}-2.com`]); + await PlacesTestUtils.addVisits([`https://${ENGINE_DOMAIN}/`]); + } + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + // First do multiple searches for substrings of firstEngineHost. The view + // should show the same tab-to-search onboarding result the entire time, so + // we should not continue to increment urlbar.tips. + for (let i = 1; i < firstEngineHost.length; i++) { + info( + `Search for "${firstEngineHost.slice( + 0, + i + )}". Only record one impression for this sequence.` + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: firstEngineHost.slice(0, i), + fireInputEvent: true, + }); + await checkForTabToSearchResult(ENGINE_NAME, isOnboarding); + } + + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + let scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + isOnboarding ? "tabtosearch_onboard-shown" : "tabtosearch-shown", + 1 + ); + TelemetryTestUtils.assertKeyedScalar( + scalars, + isOnboarding + ? "urlbar.tabtosearch.impressions_onboarding" + : "urlbar.tabtosearch.impressions", + // "other" is recorded as the engine name because we're not using a built-in engine. + "other", + 1 + ); + + info("Type through autofill to second engine hostname. Record impression."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: firstEngineHost, + fireInputEvent: true, + }); + await checkForTabToSearchResult(ENGINE_NAME, isOnboarding); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: `${firstEngineHost}-`, + fireInputEvent: true, + }); + await checkForTabToSearchResult(`${ENGINE_NAME}2`, isOnboarding); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + // Since the user typed past the autofill for the first engine, we showed a + // different onboarding result and now we increment + // tabtosearch_onboard-shown. + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + isOnboarding ? "tabtosearch_onboard-shown" : "tabtosearch-shown", + 3 + ); + TelemetryTestUtils.assertKeyedScalar( + scalars, + isOnboarding + ? "urlbar.tabtosearch.impressions_onboarding" + : "urlbar.tabtosearch.impressions", + "other", + 3 + ); + + info("Make a typo and return to autofill. Do not record impression."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: `${firstEngineHost}-`, + fireInputEvent: true, + }); + await checkForTabToSearchResult(`${ENGINE_NAME}2`, isOnboarding); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: `${firstEngineHost}-3`, + fireInputEvent: true, + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "We are not showing a tab-to-search result." + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: `${firstEngineHost}-2`, + fireInputEvent: true, + }); + await checkForTabToSearchResult(`${ENGINE_NAME}2`, isOnboarding); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + isOnboarding ? "tabtosearch_onboard-shown" : "tabtosearch-shown", + 4 + ); + TelemetryTestUtils.assertKeyedScalar( + scalars, + isOnboarding + ? "urlbar.tabtosearch.impressions_onboarding" + : "urlbar.tabtosearch.impressions", + "other", + 4 + ); + + info( + "Cancel then restart autofill. Continue to show the tab-to-search result." + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: `${firstEngineHost}-2`, + fireInputEvent: true, + }); + await checkForTabToSearchResult(`${ENGINE_NAME}2`, isOnboarding); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Backspace"); + await searchPromise; + await checkForTabToSearchResult(`${ENGINE_NAME}2`, isOnboarding); + searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + // Type the "." from `example-2.com`. + EventUtils.synthesizeKey("."); + await searchPromise; + await checkForTabToSearchResult(`${ENGINE_NAME}2`, isOnboarding); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + isOnboarding ? "tabtosearch_onboard-shown" : "tabtosearch-shown", + 5 + ); + TelemetryTestUtils.assertKeyedScalar( + scalars, + isOnboarding + ? "urlbar.tabtosearch.impressions_onboarding" + : "urlbar.tabtosearch.impressions", + // "other" is recorded as the engine name because we're not using a built-in engine. + "other", + 5 + ); + + // See javadoc for UrlbarProviderTabToSearch.onEngagement for discussion + // about retained results. + info("Reopen the result set with retained results. Record impression."); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await checkForTabToSearchResult(`${ENGINE_NAME}2`, isOnboarding); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + isOnboarding ? "tabtosearch_onboard-shown" : "tabtosearch-shown", + 6 + ); + TelemetryTestUtils.assertKeyedScalar( + scalars, + isOnboarding + ? "urlbar.tabtosearch.impressions_onboarding" + : "urlbar.tabtosearch.impressions", + "other", + 6 + ); + + info( + "Open a result page and then autofill engine host. Record impression." + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: firstEngineHost, + fireInputEvent: true, + }); + await checkForTabToSearchResult(ENGINE_NAME, isOnboarding); + // Press enter on the heuristic result so we visit example.com without + // doing an additional search. + let loadPromise = BrowserTestUtils.browserLoaded(browser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + // Click the Urlbar and type to simulate what a user would actually do. If + // we use promiseAutocompleteResultPopup, no query would be made between + // this one and the previous tab-to-search query. Thus + // `onboardingEnginesShown` would not be cleared. This would not happen + // in real-world usage. + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey(firstEngineHost.slice(0, 4)); + await searchPromise; + await checkForTabToSearchResult(ENGINE_NAME, isOnboarding); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + // We clear the scalar this time. + scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + isOnboarding ? "tabtosearch_onboard-shown" : "tabtosearch-shown", + 8 + ); + TelemetryTestUtils.assertKeyedScalar( + scalars, + isOnboarding + ? "urlbar.tabtosearch.impressions_onboarding" + : "urlbar.tabtosearch.impressions", + "other", + 8 + ); + + await PlacesUtils.history.clear(); + await extension.unload(); + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tip.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tip.js new file mode 100644 index 0000000000..345b063441 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tip.js @@ -0,0 +1,130 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests urlbar telemetry for tip results. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProvider: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +function snapshotHistograms() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + return { + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + search_hist: TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultMethodHist, method, 1); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + `urlbar.picked.${type}`, + index, + 1 + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Disable search suggestions in the urlbar. + ["browser.urlbar.suggest.searches", false], + // Turn autofill off. + ["browser.urlbar.autoFill", false], + ], + }); + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test() { + // Add a restricting provider that returns a preselected heuristic tip result. + let provider = new TipProvider([ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + helpUrl: "https://example.com/", + type: "test", + titleL10n: { id: "urlbar-search-tips-confirm" }, + buttons: [ + { + url: "https://example.com/", + l10n: { id: "urlbar-search-tips-confirm" }, + }, + ], + } + ), + { heuristic: true } + ), + ]); + UrlbarProvidersManager.registerProvider(provider); + + const histograms = snapshotHistograms(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + // Show the view and press enter to select the tip. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "test", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_Enter"); + + assertTelemetryResults( + histograms, + "tip", + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter + ); + + UrlbarProvidersManager.unregisterProvider(provider); + BrowserTestUtils.removeTab(tab); +}); + +/** + * A test URLBar provider. + */ +class TipProvider extends UrlbarProvider { + constructor(results) { + super(); + this.results = results; + } + get name() { + return "TestProviderTip"; + } + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + isActive(context) { + return true; + } + getPriority(context) { + return 1; + } + async startQuery(context, addCallback) { + context.preselected = true; + for (const result of this.results) { + addCallback(this, result); + } + } +} diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_topsite.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_topsite.js new file mode 100644 index 0000000000..c4e44bf778 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_topsite.js @@ -0,0 +1,133 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests urlbar telemetry for topsite results. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +const EN_US_TOPSITES = + "https://www.youtube.com/,https://www.facebook.com/,https://www.amazon.com/,https://www.reddit.com/,https://www.wikipedia.org/,https://twitter.com/"; + +function snapshotHistograms() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + return { + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + search_hist: TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultMethodHist, method, 1); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + `urlbar.picked.${type}`, + index, + 1 + ); +} + +/** + * Updates the Top Sites feed. + * + * @param {Function} condition + * A callback that returns true after Top Sites are successfully updated. + * @param {boolean} searchShortcuts + * True if Top Sites search shortcuts should be enabled. + */ +async function updateTopSites(condition, searchShortcuts = false) { + // Toggle the pref to clear the feed cache and force an update. + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.discoverystream.endpointSpocsClear", + "", + ], + ["browser.newtabpage.activity-stream.feeds.system.topsites", false], + ["browser.newtabpage.activity-stream.feeds.system.topsites", true], + [ + "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts", + searchShortcuts, + ], + ], + }); + + // Wait for the feed to be updated. + await TestUtils.waitForCondition(() => { + let sites = AboutNewTab.getTopSites(); + return condition(sites); + }, "Waiting for top sites to be updated"); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.topsites", true], + ["browser.newtabpage.activity-stream.default.sites", EN_US_TOPSITES], + ["browser.urlbar.suggest.quickactions", false], + ], + }); + await updateTopSites( + sites => sites && sites.length == EN_US_TOPSITES.split(",").length + ); +}); + +add_task(async function test() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + let sites = AboutNewTab.getTopSites(); + Assert.equal( + sites.length, + 6, + "The test suite browser should have 6 Top Sites." + ); + + const histograms = snapshotHistograms(); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + sites.length, + "The number of results should be the same as the number of Top Sites (6)." + ); + // Select the first resultm and confirm it. + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "The first result should be selected" + ); + + let loadPromise = BrowserTestUtils.waitForDocLoadAndStopIt( + result.url, + gBrowser.selectedBrowser + ); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + + assertTelemetryResults( + histograms, + "topsite", + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection + ); + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); + }); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_zeroPrefix.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_zeroPrefix.js new file mode 100644 index 0000000000..9c3e63ae12 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_zeroPrefix.js @@ -0,0 +1,266 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests urlbar telemetry related to the zero-prefix view, i.e., when + * the search string is empty. + */ + +"use strict"; + +const HISTOGRAM_DWELL_TIME = "FX_URLBAR_ZERO_PREFIX_DWELL_TIME_MS"; +const SCALARS = { + ABANDONMENT: "urlbar.zeroprefix.abandonment", + ENGAGEMENT: "urlbar.zeroprefix.engagement", + EXPOSURE: "urlbar.zeroprefix.exposure", +}; + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + Services.telemetry.clearScalars(); + + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + await updateTopSitesAndAwaitChanged(); +}); + +// zero prefix engagement +add_task(async function engagement() { + let dwellHistogram = + TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_DWELL_TIME); + + await BrowserTestUtils.withNewTab("about:blank", async () => { + await showZeroPrefix(); + checkScalars({ + [SCALARS.EXPOSURE]: 1, + }); + checkAndClearHistogram(dwellHistogram, false); + + info("Finding row with result type URL"); + let foundURLRow = false; + let count = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < count && !foundURLRow; i++) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + let index = UrlbarTestUtils.getSelectedRowIndex(window); + Assert.equal(index, i, "The expected row index should be selected"); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + info(`Checked row at index ${i}, result type is: ${details.type}`); + if (details.type == UrlbarUtils.RESULT_TYPE.URL) { + foundURLRow = true; + } + } + Assert.ok(foundURLRow, "Should have found a row with result type URL"); + + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + }); + + checkScalars({ + [SCALARS.ENGAGEMENT]: 1, + }); + checkAndClearHistogram(dwellHistogram, true); +}); + +// zero prefix abandonment +add_task(async function abandonment() { + let dwellHistogram = + TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_DWELL_TIME); + + // Open and close the view twice. The second time the view will used a cached + // query context and that shouldn't interfere with telemetry. + for (let i = 0; i < 2; i++) { + await showZeroPrefix(); + checkScalars({ + [SCALARS.EXPOSURE]: 1, + }); + checkAndClearHistogram(dwellHistogram, false); + + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + checkScalars({ + [SCALARS.ABANDONMENT]: 1, + }); + dwellHistogram = checkAndClearHistogram(dwellHistogram, true); + } +}); + +// Shows the zero-prefix view, does some searches, then shows it again by doing +// a search for an empty string. +add_task(async function searches() { + let dwellHistogram = + TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_DWELL_TIME); + + info("Show zero prefix"); + await showZeroPrefix(); + checkScalars({ + [SCALARS.EXPOSURE]: 1, + }); + checkAndClearHistogram(dwellHistogram, false); + + info("Search for 't'"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "t", + }); + checkScalars({}); + dwellHistogram = checkAndClearHistogram(dwellHistogram, true); + + info("Search for 'te'"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "te", + }); + checkScalars({}); + checkAndClearHistogram(dwellHistogram, false); + + info("Search for 't'"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "t", + }); + checkScalars({}); + checkAndClearHistogram(dwellHistogram, false); + + info("Search for ''"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + checkScalars({ + [SCALARS.EXPOSURE]: 1, + }); + checkAndClearHistogram(dwellHistogram, false); + + info("Blur urlbar and close view"); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + checkScalars({ + [SCALARS.ABANDONMENT]: 1, + }); + checkAndClearHistogram(dwellHistogram, true); +}); + +// A zero prefix engagement should not be recorded when the view isn't showing +// zero prefix. +add_task(async function notZeroPrefix_engagement() { + let dwellHistogram = + TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_DWELL_TIME); + + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + }); + + checkScalars({}); + checkAndClearHistogram(dwellHistogram, false); +}); + +// A zero prefix abandonment should not be recorded when the view isn't showing +// zero prefix. +add_task(async function notZeroPrefix_abandonment() { + let dwellHistogram = + TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_DWELL_TIME); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + + checkScalars({}); + checkAndClearHistogram(dwellHistogram, false); +}); + +function checkScalars(expected) { + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + for (let scalar of Object.values(SCALARS)) { + if (expected.hasOwnProperty(scalar)) { + TelemetryTestUtils.assertScalar(scalars, scalar, expected[scalar]); + } else { + Assert.ok( + !scalars.hasOwnProperty(scalar), + "Scalar should not be recorded: " + scalar + ); + } + } +} + +function checkAndClearHistogram(histogram, expected) { + if (expected) { + Assert.deepEqual( + Object.values(histogram.snapshot().values).filter(v => v > 0), + [1], + "Dwell histogram should be updated" + ); + } else { + Assert.strictEqual( + histogram.snapshot().sum, + 0, + "Dwell histogram should not be updated" + ); + } + + return TelemetryTestUtils.getAndClearHistogram(histogram.name()); +} + +async function showZeroPrefix() { + let { promise, cleanup } = waitForQueryFinished(); + await SimpleTest.promiseFocus(window); + await UrlbarTestUtils.promisePopupOpen(window, () => + document.getElementById("Browser:OpenLocation").doCommand() + ); + await promise; + cleanup(); + + Assert.greater( + UrlbarTestUtils.getResultCount(window), + 0, + "There should be at least one row in the zero prefix view" + ); +} + +/** + * Returns a promise that's resolved on the next `onQueryFinished()`. It's + * important to wait for `onQueryFinished()` because that's when the view checks + * whether it's showing zero prefix. + * + * @returns {object} + * An object with the following properties: + * {Promise} promise + * Resolved when `onQueryFinished()` is called. + * {Function} cleanup + * This should be called to remove the listener. + */ +function waitForQueryFinished() { + let deferred = Promise.withResolvers(); + let listener = { + onQueryFinished: () => deferred.resolve(), + }; + gURLBar.controller.addQueryListener(listener); + + return { + promise: deferred.promise, + cleanup() { + gURLBar.controller.removeQueryListener(listener); + }, + }; +} + +async function updateTopSitesAndAwaitChanged() { + let url = "http://mochi.test:8888/topsite"; + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits(url); + } + + info("Updating top sites and awaiting newtab-top-sites-changed"); + let changedPromise = TestUtils.topicObserved("newtab-top-sites-changed").then( + () => info("Observed newtab-top-sites-changed") + ); + await updateTopSites(sites => sites?.length); + await changedPromise; +} diff --git a/browser/components/urlbar/tests/browser/browser_userTypedValue.js b/browser/components/urlbar/tests/browser/browser_userTypedValue.js new file mode 100644 index 0000000000..14749c6e82 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_userTypedValue.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function test() { + const URI = TEST_BASE_URL + "file_userTypedValue.html"; + window.browserDOMWindow.openURI( + makeURI(URI), + null, + Ci.nsIBrowserDOMWindow.OPEN_NEWTAB, + Ci.nsIBrowserDOMWindow.OPEN_EXTERNAL, + Services.scriptSecurityManager.getSystemPrincipal() + ); + + is(gBrowser.userTypedValue, URI, "userTypedValue matches test URI"); + is( + gURLBar.value, + UrlbarTestUtils.trimURL(URI), + "location bar value matches test URI" + ); + + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser); + gBrowser.removeCurrentTab({ skipPermitUnload: true }); + is( + gBrowser.userTypedValue, + URI, + "userTypedValue matches test URI after switching tabs" + ); + is( + gURLBar.value, + UrlbarTestUtils.trimURL(URI), + "location bar value matches test URI after switching tabs" + ); + + waitForExplicitFinish(); + BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => { + is( + gBrowser.userTypedValue, + null, + "userTypedValue is null as the page has loaded" + ); + is( + gURLBar.value, + UrlbarTestUtils.trimURL(URI), + "location bar value matches test URI as the page has loaded" + ); + + gBrowser.removeCurrentTab({ skipPermitUnload: true }); + finish(); + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_valueOnTabSwitch.js b/browser/components/urlbar/tests/browser/browser_valueOnTabSwitch.js new file mode 100644 index 0000000000..ba249adb3b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_valueOnTabSwitch.js @@ -0,0 +1,166 @@ +/* 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 tests for the correct URL being displayed in the URL bar after switching + * tabs which are in different states (e.g. deleted, partially deleted). + */ + +"use strict"; + +const TEST_URL = `${TEST_BASE_URL}dummy_page.html`; + +add_task(async function () { + // autofill may conflict with the test scope, by filling missing parts of + // the url due to autoOpen. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill", false]], + }); + + let charsToDelete, + deletedURLTab, + fullURLTab, + partialURLTab, + testPartialURL, + testURL; + + charsToDelete = 5; + deletedURLTab = BrowserTestUtils.addTab(gBrowser); + fullURLTab = BrowserTestUtils.addTab(gBrowser); + partialURLTab = BrowserTestUtils.addTab(gBrowser); + testURL = TEST_URL; + + let loaded1 = BrowserTestUtils.browserLoaded( + deletedURLTab.linkedBrowser, + false, + testURL + ); + let loaded2 = BrowserTestUtils.browserLoaded( + fullURLTab.linkedBrowser, + false, + testURL + ); + let loaded3 = BrowserTestUtils.browserLoaded( + partialURLTab.linkedBrowser, + false, + testURL + ); + BrowserTestUtils.startLoadingURIString(deletedURLTab.linkedBrowser, testURL); + BrowserTestUtils.startLoadingURIString(fullURLTab.linkedBrowser, testURL); + BrowserTestUtils.startLoadingURIString(partialURLTab.linkedBrowser, testURL); + await Promise.all([loaded1, loaded2, loaded3]); + + testURL = BrowserUIUtils.trimURL(testURL); + testPartialURL = testURL.substr(0, testURL.length - charsToDelete); + + function cleanUp() { + gBrowser.removeTab(fullURLTab); + gBrowser.removeTab(partialURLTab); + gBrowser.removeTab(deletedURLTab); + } + + async function cycleTabs() { + await BrowserTestUtils.switchTab(gBrowser, fullURLTab); + is( + gURLBar.value, + testURL, + "gURLBar.value should be testURL after switching back to fullURLTab" + ); + + await BrowserTestUtils.switchTab(gBrowser, partialURLTab); + is( + gURLBar.value, + testPartialURL, + "gURLBar.value should be testPartialURL after switching back to partialURLTab" + ); + await BrowserTestUtils.switchTab(gBrowser, deletedURLTab); + is( + gURLBar.value, + testURL, + "gURLBar.value should be testURL after switching back to deletedURLTab" + ); + + await BrowserTestUtils.switchTab(gBrowser, fullURLTab); + is( + gURLBar.value, + testURL, + "gURLBar.value should be testURL after switching back to fullURLTab" + ); + } + + function urlbarBackspace(removeAll) { + return new Promise((resolve, reject) => { + gBrowser.selectedBrowser.focus(); + gURLBar.addEventListener( + "input", + function () { + resolve(); + }, + { once: true } + ); + gURLBar.focus(); + if (removeAll) { + gURLBar.select(); + } else { + gURLBar.selectionStart = gURLBar.selectionEnd = gURLBar.value.length; + } + EventUtils.synthesizeKey("KEY_Backspace"); + }); + } + + async function prepareDeletedURLTab() { + await BrowserTestUtils.switchTab(gBrowser, deletedURLTab); + is( + gURLBar.value, + testURL, + "gURLBar.value should be testURL after initial switch to deletedURLTab" + ); + + // simulate the user removing the whole url from the location bar + await urlbarBackspace(true); + is(gURLBar.value, "", 'gURLBar.value should be "" (just set)'); + } + + async function prepareFullURLTab() { + await BrowserTestUtils.switchTab(gBrowser, fullURLTab); + is( + gURLBar.value, + testURL, + "gURLBar.value should be testURL after initial switch to fullURLTab" + ); + } + + async function preparePartialURLTab() { + await BrowserTestUtils.switchTab(gBrowser, partialURLTab); + is( + gURLBar.value, + testURL, + "gURLBar.value should be testURL after initial switch to partialURLTab" + ); + + // simulate the user removing part of the url from the location bar + let deleted = 0; + while (deleted < charsToDelete) { + await urlbarBackspace(false); + deleted++; + } + + is( + gURLBar.value, + testPartialURL, + "gURLBar.value should be testPartialURL (just set)" + ); + } + + // prepare the three tabs required by this test + + // First tab + await prepareFullURLTab(); + await preparePartialURLTab(); + await prepareDeletedURLTab(); + + // now cycle the tabs and make sure everything looks good + await cycleTabs(); + cleanUp(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_view_emptyResultSet.js b/browser/components/urlbar/tests/browser/browser_view_emptyResultSet.js new file mode 100644 index 0000000000..f7a2721093 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_view_emptyResultSet.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the view results are cleared and the view is closed, when an empty +// result set arrives after a non-empty one. + +add_task(async function () { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + Assert.ok( + UrlbarTestUtils.getResultCount(window) > 0, + `There should be some results in the view.` + ); + Assert.ok(gURLBar.view.isOpen, `The view should be open.`); + + // Register an high priority empty result provider. + let provider = new UrlbarTestUtils.TestProvider({ + results: [], + priority: 999, + }); + UrlbarProvidersManager.registerProvider(provider); + registerCleanupFunction(async function () { + UrlbarProvidersManager.unregisterProvider(provider); + await PlacesUtils.history.clear(); + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + Assert.ok( + UrlbarTestUtils.getResultCount(window) == 0, + `There should be no results in the view.` + ); + Assert.ok(!gURLBar.view.isOpen, `The view should have been closed.`); +}); diff --git a/browser/components/urlbar/tests/browser/browser_view_removedSelectedElement.js b/browser/components/urlbar/tests/browser/browser_view_removedSelectedElement.js new file mode 100644 index 0000000000..532f9e10a2 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_view_removedSelectedElement.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that if the selectedElement is removed from the DOM, the view still +// sets a selection on the next received results. + +add_task(async function () { + let view = gURLBar.view; + // We need a heuristic provider that the Muxer will prefer over other + // heuristics and that will return results after the first onQueryResults. + // Luckily TEST providers come first in the heuristic group! + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { url: "https://example.com/1", title: "example" } + ); + result.heuristic = true; + // To ensure the selectedElement is removed, we use this special property that + // asks the view to generate new content for the row. + result.testForceNewContent = true; + + let receivedResults = false; + let firstSelectedElement; + let delayResultsPromise = new Promise(resolve => { + gURLBar.controller.addQueryListener({ + async onQueryResults(queryContext) { + Assert.ok(!receivedResults, "Should execute only once"); + gURLBar.controller.removeQueryListener(this); + receivedResults = true; + // Store the corrent selection. + firstSelectedElement = view.selectedElement; + Assert.ok(firstSelectedElement, "There should be a selected element"); + Assert.ok( + view.selectedResult.heuristic, + "Selected result should be a heuristic" + ); + Assert.notEqual( + result, + view.selectedResult, + "Should not immediately select our result" + ); + resolve(); + }, + }); + }); + + let delayedHeuristicProvider = new UrlbarTestUtils.TestProvider({ + delayResultsPromise, + results: [result], + type: UrlbarUtils.PROVIDER_TYPE.HEURISTIC, + }); + UrlbarProvidersManager.registerProvider(delayedHeuristicProvider); + registerCleanupFunction(async function () { + UrlbarProvidersManager.unregisterProvider(delayedHeuristicProvider); + await UrlbarTestUtils.promisePopupClose(window); + gURLBar.handleRevert(); + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exa", + }); + Assert.ok(receivedResults, "Results observer was invoked"); + Assert.ok( + UrlbarTestUtils.getResultCount(window) > 0, + `There should be some results in the view.` + ); + Assert.ok(view.isOpen, `The view should be open.`); + Assert.ok(view.selectedElement.isConnected, "selectedElement is connected"); + Assert.equal(view.selectedElementIndex, 0, "selectedElementIndex is correct"); + Assert.deepEqual( + view.getResultFromElement(view.selectedElement), + result, + "result is the expected one" + ); + Assert.notEqual( + view.selectedElement, + firstSelectedElement, + "Selected element should have changed" + ); + Assert.ok( + !firstSelectedElement.isConnected, + "Previous selected element should be disconnected" + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_view_resultDisplay.js b/browser/components/urlbar/tests/browser/browser_view_resultDisplay.js new file mode 100644 index 0000000000..c4053eaed7 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_view_resultDisplay.js @@ -0,0 +1,354 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that a result has the various elements displayed in the URL bar as + * we expect them to be. + */ + +add_setup(async function () { + await PlacesUtils.history.clear(); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + Services.prefs.clearUserPref("browser.urlbar.trimURLs"); + }); +}); + +async function testResult(input, expected, index = 1) { + const ESCAPED_URL = encodeURI(input.url); + + await PlacesUtils.history.clear(); + if (index > 0) { + await PlacesTestUtils.addVisits({ + uri: input.url, + title: input.title, + }); + } + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: input.query, + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + Assert.equal(result.url, ESCAPED_URL, "Should have the correct url to load"); + Assert.equal( + result.displayed.url, + expected.displayedUrl, + "Should have the correct displayed url" + ); + Assert.equal( + result.displayed.title, + input.title, + "Should have the expected title" + ); + Assert.equal( + result.displayed.typeIcon, + "none", + "Should not have a type icon" + ); + if (index > 0) { + Assert.equal( + result.image, + `page-icon:${ESCAPED_URL}`, + "Should have the correct favicon" + ); + } + + assertDisplayedHighlights( + "title", + result.element.title, + expected.highlightedTitle + ); + + assertDisplayedHighlights("url", result.element.url, expected.highlightedUrl); +} + +function assertDisplayedHighlights(elementName, element, expectedResults) { + Assert.equal( + element.childNodes.length, + expectedResults.length, + `Should have the correct number of child nodes for ${elementName}` + ); + + for (let i = 0; i < element.childNodes.length; i++) { + let child = element.childNodes[i]; + Assert.equal( + child.textContent, + expectedResults[i][0], + `Should have the correct text for the ${i} part of the ${elementName}` + ); + Assert.equal( + child.nodeName, + expectedResults[i][1] ? "strong" : "#text", + `Should have the correct text/strong status for the ${i} part of the ${elementName}` + ); + } +} + +add_task(async function test_url_result() { + await testResult( + { + query: "\u6e2C\u8a66", + title: "The \u6e2C\u8a66 URL", + url: "https://example.com/\u6e2C\u8a66test", + }, + { + displayedUrl: "example.com/\u6e2C\u8a66test", + highlightedTitle: [ + ["The ", false], + ["\u6e2C\u8a66", true], + [" URL", false], + ], + highlightedUrl: [ + ["example.com/", false], + ["\u6e2C\u8a66", true], + ["test", false], + ], + } + ); +}); + +add_task(async function test_url_result_no_path() { + await testResult( + { + query: "ample", + title: "The Title", + url: "https://example.com/", + }, + { + displayedUrl: "example.com", + highlightedTitle: [["The Title", false]], + highlightedUrl: [ + ["ex", false], + ["ample", true], + [".com", false], + ], + } + ); +}); + +add_task(async function test_url_result_www() { + await testResult( + { + query: "ample", + title: "The Title", + url: "https://www.example.com/", + }, + { + displayedUrl: "example.com", + highlightedTitle: [["The Title", false]], + highlightedUrl: [ + ["ex", false], + ["ample", true], + [".com", false], + ], + } + ); +}); + +add_task(async function test_url_result_no_trimming() { + Services.prefs.setBoolPref("browser.urlbar.trimURLs", false); + + await testResult( + { + query: "\u6e2C\u8a66", + title: "The \u6e2C\u8a66 URL", + url: "http://example.com/\u6e2C\u8a66test", + }, + { + displayedUrl: "http://example.com/\u6e2C\u8a66test", + highlightedTitle: [ + ["The ", false], + ["\u6e2C\u8a66", true], + [" URL", false], + ], + highlightedUrl: [ + ["http://example.com/", false], + ["\u6e2C\u8a66", true], + ["test", false], + ], + } + ); + + Services.prefs.clearUserPref("browser.urlbar.trimURLs"); +}); + +add_task(async function test_case_insensitive_highlights_1() { + await testResult( + { + query: "exam", + title: "The examPLE URL EXAMple", + url: "https://example.com/ExAm", + }, + { + displayedUrl: "example.com/ExAm", + highlightedTitle: [ + ["The ", false], + ["exam", true], + ["PLE URL ", false], + ["EXAM", true], + ["ple", false], + ], + highlightedUrl: [ + ["exam", true], + ["ple.com/", false], + ["ExAm", true], + ], + } + ); +}); + +add_task(async function test_case_insensitive_highlights_2() { + await testResult( + { + query: "EXAM", + title: "The examPLE URL EXAMple", + url: "https://example.com/ExAm", + }, + { + displayedUrl: "example.com/ExAm", + highlightedTitle: [ + ["The ", false], + ["exam", true], + ["PLE URL ", false], + ["EXAM", true], + ["ple", false], + ], + highlightedUrl: [ + ["exam", true], + ["ple.com/", false], + ["ExAm", true], + ], + } + ); +}); + +add_task(async function test_case_insensitive_highlights_3() { + await testResult( + { + query: "eXaM", + title: "The examPLE URL EXAMple", + url: "https://example.com/ExAm", + }, + { + displayedUrl: "example.com/ExAm", + highlightedTitle: [ + ["The ", false], + ["exam", true], + ["PLE URL ", false], + ["EXAM", true], + ["ple", false], + ], + highlightedUrl: [ + ["exam", true], + ["ple.com/", false], + ["ExAm", true], + ], + } + ); +}); + +add_task(async function test_case_insensitive_highlights_4() { + await testResult( + { + query: "ExAm", + title: "The examPLE URL EXAMple", + url: "https://example.com/ExAm", + }, + { + displayedUrl: "example.com/ExAm", + highlightedTitle: [ + ["The ", false], + ["exam", true], + ["PLE URL ", false], + ["EXAM", true], + ["ple", false], + ], + highlightedUrl: [ + ["exam", true], + ["ple.com/", false], + ["ExAm", true], + ], + } + ); +}); + +add_task(async function test_case_insensitive_highlights_5() { + await testResult( + { + query: "exam foo", + title: "The examPLE URL foo EXAMple FOO", + url: "https://example.com/ExAm/fOo", + }, + { + displayedUrl: "example.com/ExAm/fOo", + highlightedTitle: [ + ["The ", false], + ["exam", true], + ["PLE URL ", false], + ["foo", true], + [" ", false], + ["EXAM", true], + ["ple ", false], + ["FOO", true], + ], + highlightedUrl: [ + ["exam", true], + ["ple.com/", false], + ["ExAm", true], + ["/", false], + ["fOo", true], + ], + } + ); +}); + +add_task(async function test_case_insensitive_highlights_6() { + await testResult( + { + query: "EXAM FOO", + title: "The examPLE URL foo EXAMple FOO", + url: "https://example.com/ExAm/fOo", + }, + { + displayedUrl: "example.com/ExAm/fOo", + highlightedTitle: [ + ["The ", false], + ["exam", true], + ["PLE URL ", false], + ["foo", true], + [" ", false], + ["EXAM", true], + ["ple ", false], + ["FOO", true], + ], + highlightedUrl: [ + ["exam", true], + ["ple.com/", false], + ["ExAm", true], + ["/", false], + ["fOo", true], + ], + } + ); +}); + +add_task(async function test_no_highlight_fallback_heuristic_url() { + info("Test unvisited heuristic (fallback provider)"); + await testResult( + { + query: "nonexisting.com", + title: "http://nonexisting.com/", + url: "http://nonexisting.com/", + }, + { + displayedUrl: "", // URL heuristic only has title. + highlightedTitle: [["http://nonexisting.com/", false]], + highlightedUrl: [], + }, + 0 // Test the heuristic result. + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_view_resultTypes_display.js b/browser/components/urlbar/tests/browser/browser_view_resultTypes_display.js new file mode 100644 index 0000000000..c9bd4750f8 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_view_resultTypes_display.js @@ -0,0 +1,317 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SyncedTabs } = ChromeUtils.importESModule( + "resource://services-sync/SyncedTabs.sys.mjs" +); + +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; + +function assertElementsDisplayed(details, expected) { + Assert.equal( + details.type, + expected.type, + "Should be displaying a row of the correct type" + ); + Assert.equal( + details.title, + expected.title, + "Should be displaying the correct title" + ); + let separatorVisible = + window.getComputedStyle(details.element.separator).display != "none" && + window.getComputedStyle(details.element.separator).visibility != "collapse"; + Assert.equal( + expected.separator, + separatorVisible, + `Should${expected.separator ? " " : " not "}be displaying a separator` + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", false], + // Disable search suggestions in the urlbar. + ["browser.urlbar.suggest.searches", false], + // Clear historical search suggestions to avoid interference from previous + // tests. + ["browser.urlbar.maxHistoricalSearchSuggestions", 0], + // Turn autofill off. + ["browser.urlbar.autoFill", false], + ], + }); + + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + setAsDefault: true, + }); + + // Move the mouse away from the results panel, because hovering a result may + // change its aspect (e.g. by showing a " - search with Engine" suffix). + await EventUtils.promiseNativeMouseEvent({ + type: "mousemove", + target: gURLBar.inputField, + offsetX: 0, + offsetY: 0, + }); +}); + +add_task(async function test_tab_switch_result() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + + await BrowserTestUtils.withNewTab({ gBrowser }, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "about:mozilla", + fireInputEvent: true, + }); + + const details = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + + assertElementsDisplayed(details, { + separator: true, + title: "about:mozilla", + type: UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + }); + }); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_search_result() { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", true); + + await BrowserTestUtils.withNewTab({ gBrowser }, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + fireInputEvent: true, + }); + + let index = await UrlbarTestUtils.promiseSuggestionsPresent(window); + const details = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + + // We'll initially display no separator. + assertElementsDisplayed(details, { + separator: false, + title: "foofoo", + type: UrlbarUtils.RESULT_TYPE.SEARCH, + }); + + // Down to select the first search suggestion. + for (let i = index; i > 0; --i) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + + // We should now be displaying one. + assertElementsDisplayed(details, { + separator: true, + title: "foofoo", + type: UrlbarUtils.RESULT_TYPE.SEARCH, + }); + }); + + await PlacesUtils.history.clear(); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); +}); + +add_task(async function test_url_result() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com", + title: "example", + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + ]); + + await BrowserTestUtils.withNewTab({ gBrowser }, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "example", + fireInputEvent: true, + }); + + const details = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + + assertElementsDisplayed(details, { + separator: true, + title: "example", + type: UrlbarUtils.RESULT_TYPE.URL, + }); + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function test_keyword_result() { + const TEST_URL = `${TEST_BASE_URL}print_postdata.sjs`; + + await PlacesUtils.keywords.insert({ + keyword: "get", + url: TEST_URL + "?q=%s", + }); + + await BrowserTestUtils.withNewTab({ gBrowser }, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "get ", + fireInputEvent: true, + }); + + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + + // Because only the keyword is typed, we show the bookmark url. + assertElementsDisplayed(details, { + separator: true, + title: TEST_URL.substring("https://".length) + "?q=", + type: UrlbarUtils.RESULT_TYPE.KEYWORD, + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "get test", + fireInputEvent: true, + }); + + details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + + assertElementsDisplayed(details, { + separator: false, + title: "example.com: test", + type: UrlbarUtils.RESULT_TYPE.KEYWORD, + }); + }); +}); + +add_task(async function test_omnibox_result() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + omnibox: { + keyword: "omniboxtest", + }, + + background() { + /* global browser */ + browser.omnibox.setDefaultSuggestion({ + description: "doit", + }); + // Just do nothing for this test. + browser.omnibox.onInputEntered.addListener(() => {}); + browser.omnibox.onInputChanged.addListener((text, suggest) => { + suggest([]); + }); + }, + }, + }); + + await extension.startup(); + + await BrowserTestUtils.withNewTab({ gBrowser }, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "omniboxtest ", + fireInputEvent: true, + }); + + const details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + + assertElementsDisplayed(details, { + separator: true, + title: "Generated extension", + type: UrlbarUtils.RESULT_TYPE.OMNIBOX, + }); + }); + + await extension.unload(); +}); + +add_task(async function test_remote_tab_result() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["services.sync.username", "fake"], + ["services.sync.syncedTabs.showRemoteTabs", true], + ], + }); + // Clear history so that history added by previous tests doesn't mess up this + // test when it selects results in the urlbar. + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + const REMOTE_TAB = { + id: "7cqCr77ptzX3", + type: "client", + lastModified: 1492201200, + name: "zcarter's Nightly on MacBook-Pro-25", + clientType: "desktop", + tabs: [ + { + type: "tab", + title: "Test Remote", + url: "http://example.com", + icon: UrlbarUtils.ICON.DEFAULT, + client: "7cqCr77ptzX3", + lastUsed: Math.floor(Date.now() / 1000), + }, + ], + }; + + const sandbox = sinon.createSandbox(); + + let originalSyncedTabsInternal = SyncedTabs._internal; + SyncedTabs._internal = { + isConfiguredToSyncTabs: true, + hasSyncedThisSession: true, + getTabClients() { + return Promise.resolve([]); + }, + syncTabs() { + return Promise.resolve(); + }, + }; + + // Tell the Sync XPCOM service it is initialized. + let weaveXPCService = Cc["@mozilla.org/weave/service;1"].getService( + Ci.nsISupports + ).wrappedJSObject; + let oldWeaveServiceReady = weaveXPCService.ready; + weaveXPCService.ready = true; + + sandbox + .stub(SyncedTabs._internal, "getTabClients") + .callsFake(() => Promise.resolve(Cu.cloneInto([REMOTE_TAB], {}))); + + // Reset internal cache in UrlbarProviderRemoteTabs. + Services.obs.notifyObservers(null, "weave:engine:sync:finish", "tabs"); + + registerCleanupFunction(async function () { + sandbox.restore(); + weaveXPCService.ready = oldWeaveServiceReady; + SyncedTabs._internal = originalSyncedTabsInternal; + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + Services.telemetry.setEventRecordingEnabled("navigation", false); + }); + + await BrowserTestUtils.withNewTab({ gBrowser }, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "example", + fireInputEvent: true, + }); + + const details = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + + assertElementsDisplayed(details, { + separator: true, + title: "Test Remote", + type: UrlbarUtils.RESULT_TYPE.REMOTE_TAB, + }); + }); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_view_selectionByMouse.js b/browser/components/urlbar/tests/browser/browser_view_selectionByMouse.js new file mode 100644 index 0000000000..fc617220b6 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_view_selectionByMouse.js @@ -0,0 +1,567 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test selection on result view by mouse. + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderQuickActions: + "resource:///modules/UrlbarProviderQuickActions.sys.mjs", +}); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.quickactions.enabled", true], + ["browser.urlbar.suggest.quickactions", true], + ["browser.urlbar.shortcuts.quickactions", true], + ], + }); + + UrlbarTestUtils.disableResultMenuAutohide(window); + + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + UrlbarProviderQuickActions.addAction("test-addons", { + commands: ["test-addons"], + label: "quickactions-addons", + onPick: () => + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "about:about" + ), + }); + UrlbarProviderQuickActions.addAction("test-downloads", { + commands: ["test-downloads"], + label: "quickactions-downloads2", + onPick: () => + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "about:downloads" + ), + }); + + registerCleanupFunction(function () { + UrlbarProviderQuickActions.removeAction("test-addons"); + UrlbarProviderQuickActions.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]", + 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]", + mouseup: "body", + expected: false, + }, + ]; + + for (const { description, mousedown, mouseup, expected } of testData) { + info(description); + + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + let [downElement, upElement] = await waitForElements([ + mousedown, + mouseup, + ]); + + EventUtils.synthesizeMouseAtCenter(downElement, { + type: "mousedown", + }); + Assert.ok( + downElement.hasAttribute("selected"), + "Mousedown element should be selected after mousedown" + ); + + if (upElement.tagName === "html:body") { + // We intentionally turn off this a11y check, because the following + // click is sent to test the selection behavior using an alternative way + // of the urlbar dismissal, where other ways are accessible, therefore + // this test can be ignored. + AccessibilityUtils.setEnv({ + mustHaveAccessibleRule: false, + }); + } + EventUtils.synthesizeMouseAtCenter(upElement, { type: "mouseup" }); + AccessibilityUtils.resetEnv(); + Assert.ok( + !downElement.hasAttribute("selected"), + "Mousedown element should not be selected after mouseup" + ); + Assert.ok( + !upElement.hasAttribute("selected"), + "Mouseup element should not be selected after mouseup" + ); + + if (expected) { + await BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + expected + ); + Assert.ok(true, "Expected page is opened"); + } + }); + } +}); + +add_task(async function outOfBrowser() { + const testData = [ + { + description: "Normal result to out of browser", + mousedown: ".urlbarView-row:nth-child(1) > .urlbarView-row-inner", + }, + { + description: "Normal result to out of result", + mousedown: ".urlbarView-row:nth-child(1) > .urlbarView-row-inner", + expected: false, + }, + { + description: "Quick action button to out of browser", + mousedown: ".urlbarView-quickaction-button[data-key=test-addons]", + }, + ]; + + for (const { description, mousedown } of testData) { + info(description); + + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + let [downElement] = await waitForElements([mousedown]); + + EventUtils.synthesizeMouseAtCenter(downElement, { + type: "mousedown", + }); + Assert.ok( + downElement.hasAttribute("selected"), + "Mousedown element should be selected after mousedown" + ); + + // Mouseup at out of browser. + EventUtils.synthesizeMouse(document.documentElement, -1, -1, { + type: "mouseup", + }); + + Assert.ok( + !downElement.hasAttribute("selected"), + "Mousedown element should not be selected after mouseup" + ); + }); + } +}); + +add_task(async function withSelectionByKeyboard() { + const testData = [ + { + description: "Select normal result, then click on out of result", + mousedown: "body", + mouseup: "body", + expected: { + selectedElementByKey: + "#urlbar-results .urlbarView-row > .urlbarView-row-inner[selected]", + selectedElementAfterMouseDown: + "#urlbar-results .urlbarView-row > .urlbarView-row-inner[selected]", + actionedPage: false, + }, + }, + { + description: "Select quick action button, then click on out of result", + arrowDown: 1, + mousedown: "body", + mouseup: "body", + expected: { + selectedElementByKey: + "#urlbar-results .urlbarView-quickaction-button[selected]", + selectedElementAfterMouseDown: + "#urlbar-results .urlbarView-quickaction-button[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]", + expected: { + selectedElementByKey: + "#urlbar-results .urlbarView-row > .urlbarView-row-inner[selected]", + selectedElementAfterMouseDown: + ".urlbarView-quickaction-button[data-key=test-downloads]", + actionedPage: "about:downloads", + }, + }, + ]; + + for (const { + description, + arrowDown, + mousedown, + mouseup, + expected, + } of testData) { + info(description); + + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + let [downElement, upElement] = await waitForElements([ + mousedown, + mouseup, + ]); + + if (arrowDown) { + EventUtils.synthesizeKey( + "KEY_ArrowDown", + { repeat: arrowDown }, + window + ); + } + + let [selectedElementByKey] = await waitForElements([ + expected.selectedElementByKey, + ]); + Assert.ok( + selectedElementByKey.hasAttribute("selected"), + "selectedElementByKey should be selected after arrow down" + ); + + EventUtils.synthesizeMouseAtCenter(downElement, { + type: "mousedown", + }); + + if ( + expected.selectedElementByKey !== expected.selectedElementAfterMouseDown + ) { + let [selectedElementAfterMouseDown] = await waitForElements([ + expected.selectedElementAfterMouseDown, + ]); + Assert.ok( + selectedElementAfterMouseDown.hasAttribute("selected"), + "selectedElementAfterMouseDown should be selected after mousedown" + ); + Assert.ok( + !selectedElementByKey.hasAttribute("selected"), + "selectedElementByKey should not be selected after mousedown" + ); + } + + if (upElement.tagName === "html:body") { + // We intentionally turn off this a11y check, because the following + // click is sent to test the selection behavior using an alternative way + // of the urlbar dismissal, where other ways are accessible, therefore + // this test can be ignored. + AccessibilityUtils.setEnv({ + mustHaveAccessibleRule: false, + }); + } + EventUtils.synthesizeMouseAtCenter(upElement, { + type: "mouseup", + }); + AccessibilityUtils.resetEnv(); + + if (expected.actionedPage) { + Assert.ok( + !selectedElementByKey.hasAttribute("selected"), + "selectedElementByKey should not be selected after page starts load" + ); + await BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + expected.actionedPage + ); + Assert.ok(true, "Expected page is opened"); + } else { + Assert.ok( + selectedElementByKey.hasAttribute("selected"), + "selectedElementByKey should remain selected" + ); + } + }); + } +}); + +add_task(async function withDnsFirstForSingleWordsPref() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.fixup.dns_first_for_single_words", true]], + }); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "https://example.org/", + title: "example", + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "ex", + window, + }); + + const details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + const target = details.element.action; + EventUtils.synthesizeMouseAtCenter(target, { type: "mousedown" }); + const onLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + "https://example.org/" + ); + EventUtils.synthesizeMouseAtCenter(target, { type: "mouseup" }); + await onLoaded; + Assert.ok(true, "Expected page is opened"); + + await PlacesUtils.bookmarks.eraseEverything(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function buttons() { + let initialTabUrl = "https://example.com/initial"; + let mainResultUrl = "https://example.com/main"; + let mainResultHelpUrl = "https://example.com/help"; + let otherResultUrl = "https://example.com/other"; + + let searchString = "test"; + + // Add a provider with two results: The first has buttons and the second has a + // URL that should or shouldn't become the input's value when the block button + // in the first result is clicked, depending on the test. + let provider = new UrlbarTestUtils.TestProvider({ + priority: Infinity, + results: [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + url: mainResultUrl, + helpUrl: mainResultHelpUrl, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + url: otherResultUrl, + } + ), + ], + }); + + UrlbarProvidersManager.registerProvider(provider); + + let assertResultMenuOpen = () => { + Assert.equal( + gURLBar.view.resultMenu.state, + "showing", + "Result menu is showing" + ); + EventUtils.synthesizeKey("KEY_Escape"); + }; + + let testData = [ + { + description: "Menu button to menu button", + mousedown: ".urlbarView-row:nth-child(1) .urlbarView-button-menu", + afterMouseupCallback: assertResultMenuOpen, + expected: { + mousedownSelected: false, + topSites: { + pageProxyState: "valid", + value: initialTabUrl, + }, + searchString: { + pageProxyState: "invalid", + value: searchString, + }, + }, + }, + { + description: "Row-inner to menu button", + mousedown: ".urlbarView-row:nth-child(1) > .urlbarView-row-inner", + mouseup: ".urlbarView-row:nth-child(1) .urlbarView-button-menu", + afterMouseupCallback: assertResultMenuOpen, + expected: { + mousedownSelected: true, + topSites: { + pageProxyState: "valid", + value: initialTabUrl, + }, + searchString: { + pageProxyState: "invalid", + value: searchString, + }, + }, + }, + { + description: "Menu button to row-inner", + mousedown: ".urlbarView-row:nth-child(1) .urlbarView-button-menu", + mouseup: ".urlbarView-row:nth-child(1) > .urlbarView-row-inner", + expected: { + mousedownSelected: false, + url: mainResultUrl, + newTab: false, + }, + }, + ]; + + for (let showTopSites of [true, false]) { + for (let { + description, + mousedown, + mouseup, + expected, + afterMouseupCallback = null, + } of testData) { + info(`Running test with showTopSites = ${showTopSites}: ${description}`); + mouseup ||= mousedown; + + await BrowserTestUtils.withNewTab(initialTabUrl, async () => { + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "valid", + "Sanity check: pageproxystate should be valid initially" + ); + Assert.equal( + gURLBar.value, + UrlbarTestUtils.trimURL(initialTabUrl), + "Sanity check: input.value should be the initial URL initially" + ); + + if (showTopSites) { + // Open the view and show top sites by performing the accel+L command. + await SimpleTest.promiseFocus(window); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + document.getElementById("Browser:OpenLocation").doCommand(); + await searchPromise; + } else { + // Do a search. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + }); + } + + let [downElement, upElement] = await waitForElements([ + mousedown, + mouseup, + ]); + + // Mousedown and check the selection. + EventUtils.synthesizeMouseAtCenter(downElement, { + type: "mousedown", + }); + if (expected.mousedownSelected) { + Assert.ok( + downElement.hasAttribute("selected"), + "Mousedown element should be selected after mousedown" + ); + } else { + Assert.ok( + !downElement.hasAttribute("selected"), + "Mousedown element should not be selected after mousedown" + ); + } + + let loadPromise; + if (expected.url) { + loadPromise = expected.newTab + ? BrowserTestUtils.waitForNewTab(gBrowser, expected.url) + : BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + null, + expected.url + ); + } + + // Mouseup and check the selection. + EventUtils.synthesizeMouseAtCenter(upElement, { type: "mouseup" }); + Assert.ok( + !downElement.hasAttribute("selected"), + "Mousedown element should not be selected after mouseup" + ); + Assert.ok( + !upElement.hasAttribute("selected"), + "Mouseup element should not be selected after mouseup" + ); + + // If we expect a URL to load, we're done since the view will close and + // the input value will be set to the URL. + if (loadPromise) { + info("Waiting for URL to load: " + expected.url); + let tab = await loadPromise; + Assert.ok(true, "Expected URL loaded"); + if (expected.newTab) { + BrowserTestUtils.removeTab(tab); + } + return; + } + + if (afterMouseupCallback) { + await afterMouseupCallback(); + } + + let state = showTopSites ? expected.topSites : expected.searchString; + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + state.pageProxyState, + "pageproxystate should be as expected" + ); + Assert.equal( + gURLBar.value, + UrlbarTestUtils.trimURL(state.value), + "input.value should be as expected" + ); + }); + } + } + + UrlbarProvidersManager.unregisterProvider(provider); +}); + +async function waitForElements(selectors) { + let elements; + await BrowserTestUtils.waitForCondition(() => { + elements = selectors.map(s => document.querySelector(s)); + return elements.every(e => e && BrowserTestUtils.isVisible(e)); + }, "Waiting for elements to become visible: " + JSON.stringify(selectors)); + return elements; +} diff --git a/browser/components/urlbar/tests/browser/browser_waitForLoadStartOrTimeout.js b/browser/components/urlbar/tests/browser/browser_waitForLoadStartOrTimeout.js new file mode 100644 index 0000000000..0fc6f0739f --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_waitForLoadStartOrTimeout.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the waitForLoadStartOrTimeout test helper function in head.js. + */ + +"use strict"; + +add_task(async function load() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + let url = "https://example.com/"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: url, + }); + + let loadPromise = waitForLoadStartOrTimeout(); + EventUtils.synthesizeKey("KEY_Enter"); + let uri = await loadPromise; + info("Page should have loaded before timeout"); + + Assert.equal(uri.spec, url, "example.com should have loaded"); + }); +}); + +add_task(async function timeout() { + await Assert.rejects( + waitForLoadStartOrTimeout(), + /timed out/, + "Should have timed out" + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_whereToOpen.js b/browser/components/urlbar/tests/browser/browser_whereToOpen.js new file mode 100644 index 0000000000..339a20d90e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_whereToOpen.js @@ -0,0 +1,192 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const NON_EMPTY_TAB = "example.com/non-empty"; +const EMPTY_TAB = "about:blank"; +const META_KEY = AppConstants.platform == "macosx" ? "metaKey" : "ctrlKey"; +const ENTER = new KeyboardEvent("keydown", {}); +const ALT_ENTER = new KeyboardEvent("keydown", { altKey: true }); +const ALTGR_ENTER = new KeyboardEvent("keydown", { modifierAltGraph: true }); +const CLICK = new MouseEvent("click", { button: 0 }); +const META_CLICK = new MouseEvent("click", { button: 0, [META_KEY]: true }); +const MIDDLE_CLICK = new MouseEvent("click", { button: 1 }); + +let old_openintab = Preferences.get("browser.urlbar.openintab"); +registerCleanupFunction(async function () { + Preferences.set("browser.urlbar.openintab", old_openintab); +}); + +add_task(async function openInTab() { + // Open a non-empty tab. + let tab = (gBrowser.selectedTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + NON_EMPTY_TAB + )); + + for (let test of [ + { + pref: false, + event: ALT_ENTER, + desc: "Alt+Enter, non-empty tab, default prefs", + }, + { + pref: false, + event: ALTGR_ENTER, + desc: "AltGr+Enter, non-empty tab, default prefs", + }, + { + pref: false, + event: META_CLICK, + desc: "Meta+click, non-empty tab, default prefs", + }, + { + pref: false, + event: MIDDLE_CLICK, + desc: "Middle click, non-empty tab, default prefs", + }, + { pref: true, event: ENTER, desc: "Enter, non-empty tab, openInTab" }, + { + pref: true, + event: CLICK, + desc: "Normal click, non-empty tab, openInTab", + }, + ]) { + info(test.desc); + + Preferences.set("browser.urlbar.openintab", test.pref); + let where = gURLBar._whereToOpen(test.event); + is(where, "tab", "URL would be loaded in a new tab"); + } + + // Clean up. + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function keepEmptyTab() { + // Open an empty tab. + let tab = (gBrowser.selectedTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + EMPTY_TAB + )); + + for (let test of [ + { + pref: false, + event: META_CLICK, + desc: "Meta+click, empty tab, default prefs", + }, + { + pref: false, + event: MIDDLE_CLICK, + desc: "Middle click, empty tab, default prefs", + }, + ]) { + info(test.desc); + + Preferences.set("browser.urlbar.openintab", test.pref); + let where = gURLBar._whereToOpen(test.event); + is(where, "tab", "URL would be loaded in a new tab"); + } + + // Clean up. + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function reuseEmptyTab() { + // Open an empty tab. + let tab = (gBrowser.selectedTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + EMPTY_TAB + )); + + for (let test of [ + { + pref: false, + event: ALT_ENTER, + desc: "Alt+Enter, empty tab, default prefs", + }, + { + pref: false, + event: ALTGR_ENTER, + desc: "AltGr+Enter, empty tab, default prefs", + }, + { pref: true, event: ENTER, desc: "Enter, empty tab, openInTab" }, + { pref: true, event: CLICK, desc: "Normal click, empty tab, openInTab" }, + ]) { + info(test.desc); + Preferences.set("browser.urlbar.openintab", test.pref); + let where = gURLBar._whereToOpen(test.event); + is(where, "current", "New URL would reuse the current empty tab"); + } + + // Clean up. + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function openInCurrentTab() { + for (let test of [ + { + pref: false, + url: NON_EMPTY_TAB, + event: ENTER, + desc: "Enter, non-empty tab, default prefs", + }, + { + pref: false, + url: NON_EMPTY_TAB, + event: CLICK, + desc: "Normal click, non-empty tab, default prefs", + }, + { + pref: false, + url: EMPTY_TAB, + event: ENTER, + desc: "Enter, empty tab, default prefs", + }, + { + pref: false, + url: EMPTY_TAB, + event: CLICK, + desc: "Normal click, empty tab, default prefs", + }, + { + pref: true, + url: NON_EMPTY_TAB, + event: ALT_ENTER, + desc: "Alt+Enter, non-empty tab, openInTab", + }, + { + pref: true, + url: NON_EMPTY_TAB, + event: ALTGR_ENTER, + desc: "AltGr+Enter, non-empty tab, openInTab", + }, + { + pref: true, + url: NON_EMPTY_TAB, + event: META_CLICK, + desc: "Meta+click, non-empty tab, openInTab", + }, + { + pref: true, + url: NON_EMPTY_TAB, + event: MIDDLE_CLICK, + desc: "Middle click, non-empty tab, openInTab", + }, + ]) { + info(test.desc); + + // Open a new tab. + let tab = (gBrowser.selectedTab = + await BrowserTestUtils.openNewForegroundTab(gBrowser, test.url)); + + Preferences.set("browser.urlbar.openintab", test.pref); + let where = gURLBar._whereToOpen(test.event); + is(where, "current", "URL would open in the current tab"); + + // Clean up. + BrowserTestUtils.removeTab(tab); + } +}); diff --git a/browser/components/urlbar/tests/browser/dummy_page.html b/browser/components/urlbar/tests/browser/dummy_page.html new file mode 100644 index 0000000000..1a87e28408 --- /dev/null +++ b/browser/components/urlbar/tests/browser/dummy_page.html @@ -0,0 +1,9 @@ + + +Dummy test page + + + +

    Dummy test page

    + + diff --git a/browser/components/urlbar/tests/browser/dynamicResult0.css b/browser/components/urlbar/tests/browser/dynamicResult0.css new file mode 100644 index 0000000000..328127b594 --- /dev/null +++ b/browser/components/urlbar/tests/browser/dynamicResult0.css @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +#urlbar { + --testDynamicResult0: ok0; +} + +.urlbarView-row[dynamicType=test] > .urlbarView-row-inner { + display: flex; + align-items: center; + min-height: 32px; + width: 100%; +} + +.urlbarView-dynamic-test-buttonBox { + display: flex; + align-items: center; + min-height: 32px; +} + +.urlbarView-dynamic-test-text { + flex-grow: 1; + flex-shrink: 1; + padding: 10px; +} + +.urlbarView-dynamic-test-selectable, +.urlbarView-dynamic-test-button1, +.urlbarView-dynamic-test-button2 { + min-height: 16px; + padding: 8px; + border: none; + border-radius: 2px; + font-size: 0.93em; + color: inherit; + background-color: var(--urlbarView-button-background); + min-width: 8.75em; + text-align: center; + flex-basis: initial; + flex-shrink: 0; + margin-inline-end: 10px; +} + +.urlbarView-dynamic-test-selectable[selected], +.urlbarView-dynamic-test-button1[selected], +.urlbarView-dynamic-test-button2[selected] { + color: white; + background-color: var(--urlbarView-primary-button-background); + box-shadow: 0 0 0 1px #0a84ff inset, 0 0 0 1px #0a84ff, 0 0 0 4px rgba(10, 132, 255, 0.3); +} diff --git a/browser/components/urlbar/tests/browser/dynamicResult1.css b/browser/components/urlbar/tests/browser/dynamicResult1.css new file mode 100644 index 0000000000..ae43fd3f9a --- /dev/null +++ b/browser/components/urlbar/tests/browser/dynamicResult1.css @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +#urlbar { + --testDynamicResult1: ok1; +} + +.urlbarView-row[dynamicType=test] > .urlbarView-row-inner { + display: flex; + align-items: center; + min-height: 32px; + width: 100%; +} + +.urlbarView-dynamic-test-buttonBox { + display: flex; + align-items: center; + min-height: 32px; +} + +.urlbarView-dynamic-test-text { + flex-grow: 1; + flex-shrink: 1; + padding: 10px; +} + +.urlbarView-dynamic-test-selectable, +.urlbarView-dynamic-test-button1, +.urlbarView-dynamic-test-button2 { + min-height: 16px; + padding: 8px; + border: none; + border-radius: 2px; + font-size: 0.93em; + color: inherit; + background-color: var(--urlbarView-button-background); + min-width: 8.75em; + text-align: center; + flex-basis: initial; + flex-shrink: 0; + margin-inline-end: 10px; +} + +.urlbarView-dynamic-test-selectable[selected], +.urlbarView-dynamic-test-button1[selected], +.urlbarView-dynamic-test-button2[selected] { + color: white; + background-color: var(--urlbarView-primary-button-background); + box-shadow: 0 0 0 1px #0a84ff inset, 0 0 0 1px #0a84ff, 0 0 0 4px rgba(10, 132, 255, 0.3); +} diff --git a/browser/components/urlbar/tests/browser/file_blank_but_not_blank.html b/browser/components/urlbar/tests/browser/file_blank_but_not_blank.html new file mode 100644 index 0000000000..1f5fea8dcf --- /dev/null +++ b/browser/components/urlbar/tests/browser/file_blank_but_not_blank.html @@ -0,0 +1,2 @@ + +Click me diff --git a/browser/components/urlbar/tests/browser/file_copying_home.html b/browser/components/urlbar/tests/browser/file_copying_home.html new file mode 100644 index 0000000000..7aaafc26af --- /dev/null +++ b/browser/components/urlbar/tests/browser/file_copying_home.html @@ -0,0 +1 @@ +wait-a-bit.sjs diff --git a/browser/components/urlbar/tests/browser/file_urlbar_edit_dos.html b/browser/components/urlbar/tests/browser/file_urlbar_edit_dos.html new file mode 100644 index 0000000000..e02242f6a1 --- /dev/null +++ b/browser/components/urlbar/tests/browser/file_urlbar_edit_dos.html @@ -0,0 +1,18 @@ + + + +Try editing the URL bar + + + + + + diff --git a/browser/components/urlbar/tests/browser/file_userTypedValue.html b/browser/components/urlbar/tests/browser/file_userTypedValue.html new file mode 100644 index 0000000000..a787b70898 --- /dev/null +++ b/browser/components/urlbar/tests/browser/file_userTypedValue.html @@ -0,0 +1 @@ +bug562649 diff --git a/browser/components/urlbar/tests/browser/head-common.js b/browser/components/urlbar/tests/browser/head-common.js new file mode 100644 index 0000000000..2119d33123 --- /dev/null +++ b/browser/components/urlbar/tests/browser/head-common.js @@ -0,0 +1,153 @@ +ChromeUtils.defineESModuleGetters(this, { + HttpServer: "resource://testing-common/httpd.sys.mjs", + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + Preferences: "resource://gre/modules/Preferences.sys.mjs", + UrlbarProvider: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(this, "TEST_BASE_URL", () => + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ) +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "clipboardHelper", + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper" +); + +ChromeUtils.defineLazyGetter(this, "UrlbarTestUtils", () => { + const { UrlbarTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/UrlbarTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +ChromeUtils.defineLazyGetter(this, "SearchTestUtils", () => { + const { SearchTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/SearchTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +/** + * Initializes an HTTP Server, and runs a task with it. + * + * @param {object} details {scheme, host, port} + * @param {Function} taskFn The task to run, gets the server as argument. + */ +async function withHttpServer( + details = { scheme: "http", host: "localhost", port: -1 }, + taskFn +) { + let server = new HttpServer(); + let url = `${details.scheme}://${details.host}:${details.port}`; + try { + info(`starting HTTP Server for ${url}`); + try { + server.start(details.port); + details.port = server.identity.primaryPort; + server.identity.setPrimary(details.scheme, details.host, details.port); + } catch (ex) { + throw new Error("We can't launch our http server successfully. " + ex); + } + Assert.ok( + server.identity.has(details.scheme, details.host, details.port), + `${url} is listening.` + ); + try { + await taskFn(server); + } catch (ex) { + throw new Error("Exception in the task function " + ex); + } + } finally { + server.identity.remove(details.scheme, details.host, details.port); + try { + await new Promise(resolve => server.stop(resolve)); + } catch (ex) {} + server = null; + } +} + +/** + * Updates the Top Sites feed. + * + * @param {Function} condition + * A callback that returns true after Top Sites are successfully updated. + * @param {boolean} searchShortcuts + * True if Top Sites search shortcuts should be enabled. + */ +async function updateTopSites(condition, searchShortcuts = false) { + // Toggle the pref to clear the feed cache and force an update. + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.discoverystream.endpointSpocsClear", + "", + ], + ["browser.newtabpage.activity-stream.feeds.system.topsites", false], + ["browser.newtabpage.activity-stream.feeds.system.topsites", true], + [ + "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts", + searchShortcuts, + ], + ], + }); + + // Wait for the feed to be updated. + await TestUtils.waitForCondition(() => { + let sites = AboutNewTab.getTopSites(); + return condition(sites); + }, "Waiting for top sites to be updated"); +} + +/** + * Asserts a search term is in the url bar and state values are + * what they should be. + * + * @param {string} searchString + * String that should be matched in the url bar. + * @param {object | null} options + * Options for the assertions. + * @param {Window | null} options.window + * Window to use for tests. + * @param {string | null} options.pageProxyState + * The pageproxystate that should be expected. Defaults to "valid". + * @param {string | null} options.userTypedValue + * The userTypedValue that should be expected. Defaults to null. + */ +function assertSearchStringIsInUrlbar( + searchString, + { win = window, pageProxyState = "valid", userTypedValue = null } = {} +) { + Assert.equal( + win.gURLBar.value, + searchString, + `Search string should be the urlbar value.` + ); + Assert.equal( + win.gBrowser.selectedBrowser.searchTerms, + searchString, + `Search terms should match.` + ); + Assert.equal( + win.gBrowser.userTypedValue, + userTypedValue, + "userTypedValue should match." + ); + Assert.equal( + win.gURLBar.getAttribute("pageproxystate"), + pageProxyState, + "Pageproxystate should match." + ); +} diff --git a/browser/components/urlbar/tests/browser/head.js b/browser/components/urlbar/tests/browser/head.js new file mode 100644 index 0000000000..a81e8e4811 --- /dev/null +++ b/browser/components/urlbar/tests/browser/head.js @@ -0,0 +1,248 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs", + ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs", + PromptTestUtils: "resource://testing-common/PromptTestUtils.sys.mjs", + ResetProfile: "resource://gre/modules/ResetProfile.sys.mjs", + SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + UrlbarController: "resource:///modules/UrlbarController.sys.mjs", + UrlbarEventBufferer: "resource:///modules/UrlbarEventBufferer.sys.mjs", + UrlbarQueryContext: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarView: "resource:///modules/UrlbarView.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(this, "PlacesFrecencyRecalculator", () => { + return Cc["@mozilla.org/places/frecency-recalculator;1"].getService( + Ci.nsIObserver + ).wrappedJSObject; +}); + +let sandbox; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/browser/head-common.js", + this +); + +registerCleanupFunction(async () => { + // Ensure the Urlbar popup is always closed at the end of a test, to save having + // to do it within each test. + await UrlbarTestUtils.promisePopupClose(window); +}); + +async function selectAndPaste(str, win = window) { + await SimpleTest.promiseClipboardChange(str, () => { + clipboardHelper.copyString(str); + }); + win.gURLBar.select(); + win.document.commandDispatcher + .getControllerForCommand("cmd_paste") + .doCommand("cmd_paste"); +} + +/** + * Waits for a load starting in any browser or a timeout, whichever comes first. + * + * @param {window} win + * The top-level browser window to listen in. + * @param {number} timeoutMs + * The timeout in ms. + * @returns {Promise} resolved to the loading uri in case of load, rejected in + * case of timeout. + */ +function waitForLoadStartOrTimeout(win = window, timeoutMs = 1000) { + let listener; + let timeout; + return Promise.race([ + new Promise(resolve => { + listener = { + onStateChange(browser, webprogress, request, flags, status) { + if (flags & Ci.nsIWebProgressListener.STATE_START) { + resolve(request.QueryInterface(Ci.nsIChannel).URI); + } + }, + }; + win.gBrowser.addTabsProgressListener(listener); + }), + new Promise((resolve, reject) => { + timeout = win.setTimeout(() => reject("timed out"), timeoutMs); + }), + ]).finally(() => { + win.gBrowser.removeTabsProgressListener(listener); + win.clearTimeout(timeout); + }); +} + +/** + * Opens the url bar context menu by synthesizing a click. + * Returns a menu item that is specified by an id. + * + * @param {string} anonid - Identifier of a menu item of the url bar context menu. + * @returns {string} - The element that has the corresponding identifier. + */ +async function promiseContextualMenuitem(anonid) { + let textBox = gURLBar.querySelector("moz-input-box"); + let cxmenu = textBox.menupopup; + let cxmenuPromise = BrowserTestUtils.waitForEvent(cxmenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, { + type: "contextmenu", + button: 2, + }); + await cxmenuPromise; + return textBox.getMenuItem(anonid); +} + +/** + * Puts all CustomizableUI widgetry back to their default locations, and + * then fires the `aftercustomization` toolbox event so that UrlbarInput + * knows to reinitialize itself. + * + * @param {window} [win=window] + * The top-level browser window to fire the `aftercustomization` event in. + */ +function resetCUIAndReinitUrlbarInput(win = window) { + CustomizableUI.reset(); + CustomizableUI.dispatchToolboxEvent("aftercustomization", {}, win); +} + +/** + * This function does the following: + * + * 1. Starts a search with `searchString` but doesn't wait for it to complete. + * 2. Compares the input value to `valueBefore`. If anything is autofilled at + * this point, it will be due to the placeholder. + * 3. Waits for the search to complete. + * 4. Compares the input value to `valueAfter`. If anything is autofilled at + * this point, it will be due to the autofill result fetched by the search. + * 5. Compares the placeholder to `placeholderAfter`. + * + * @param {object} options + * The options object. + * @param {string} options.searchString + * The search string. + * @param {string} options.valueBefore + * The expected input value before the search completes. + * @param {string} options.valueAfter + * The expected input value after the search completes. + * @param {string} options.placeholderAfter + * The expected placeholder value after the search completes. + * @returns {Promise} + */ +async function search({ + searchString, + valueBefore, + valueAfter, + placeholderAfter, +}) { + info( + "Searching: " + + JSON.stringify({ + searchString, + valueBefore, + valueAfter, + placeholderAfter, + }) + ); + + await SimpleTest.promiseFocus(window); + gURLBar.inputField.focus(); + + // 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.inputField.setSelectionRange( + searchString.length, + searchString.length + ); + + // Placeholder autofill is done on input, so fire an input event. We can't use + // `promiseAutocompleteResultPopup()` or other helpers that wait for the + // search to complete because we are specifically checking placeholder + // 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), + "gURLBar.value before the search completes" + ); + Assert.equal( + gURLBar.selectionStart, + searchString.length - selectionOffset, + "gURLBar.selectionStart before the search completes" + ); + Assert.equal( + gURLBar.selectionEnd, + valueBefore.length - selectionOffset, + "gURLBar.selectionEnd before the search completes" + ); + + // Wait for the search to complete. + info("Waiting for the search to complete"); + await UrlbarTestUtils.promiseSearchComplete(window); + + // Check the final value after the results arrived. + Assert.equal( + gURLBar.value, + UrlbarTestUtils.trimURL(valueAfter), + "gURLBar.value after the search completes" + ); + Assert.equal( + gURLBar.selectionStart, + searchString.length - selectionOffset, + "gURLBar.selectionStart after the search completes" + ); + Assert.equal( + gURLBar.selectionEnd, + valueAfter.length - selectionOffset, + "gURLBar.selectionEnd after the search completes" + ); + + // Check the placeholder. + if (placeholderAfter) { + Assert.ok( + gURLBar._autofillPlaceholder, + "gURLBar._autofillPlaceholder exists after the search completes" + ); + Assert.strictEqual( + gURLBar._autofillPlaceholder.value, + UrlbarTestUtils.trimURL(placeholderAfter), + "gURLBar._autofillPlaceholder.value after the search completes" + ); + } else { + Assert.strictEqual( + gURLBar._autofillPlaceholder, + null, + "gURLBar._autofillPlaceholder does not exist after the search completes" + ); + } + + // Check the first result. + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + !!details.autofill, + !!placeholderAfter, + "First result is an autofill result iff a placeholder is expected" + ); +} diff --git a/browser/components/urlbar/tests/browser/mixed_active.html b/browser/components/urlbar/tests/browser/mixed_active.html new file mode 100644 index 0000000000..4ce8e78dc4 --- /dev/null +++ b/browser/components/urlbar/tests/browser/mixed_active.html @@ -0,0 +1,14 @@ + + + + + + + Mixed Active Content test + + + + + diff --git a/browser/components/urlbar/tests/browser/moz.png b/browser/components/urlbar/tests/browser/moz.png new file mode 100644 index 0000000000..769c636340 Binary files /dev/null and b/browser/components/urlbar/tests/browser/moz.png differ diff --git a/browser/components/urlbar/tests/browser/print_postdata.sjs b/browser/components/urlbar/tests/browser/print_postdata.sjs new file mode 100644 index 0000000000..5884a1d598 --- /dev/null +++ b/browser/components/urlbar/tests/browser/print_postdata.sjs @@ -0,0 +1,25 @@ +const CC = Components.Constructor; +const BinaryInputStream = CC( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + +function handleRequest(request, response) { + response.setHeader("Content-Type", "text/plain", false); + if (request.method == "GET") { + response.write(request.queryString); + } else { + let body = new BinaryInputStream(request.bodyInputStream); + + let avail; + let bytes = []; + + while ((avail = body.available()) > 0) { + Array.prototype.push.apply(bytes, body.readByteArray(avail)); + } + + let data = String.fromCharCode.apply(null, bytes); + response.bodyOutputStream.write(data, data.length); + } +} diff --git a/browser/components/urlbar/tests/browser/redirect_error.sjs b/browser/components/urlbar/tests/browser/redirect_error.sjs new file mode 100644 index 0000000000..a3937b0e7a --- /dev/null +++ b/browser/components/urlbar/tests/browser/redirect_error.sjs @@ -0,0 +1,16 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +const REDIRECT_TO = "https://www.bank1.com/"; // Bad-cert host. + +function handleRequest(aRequest, aResponse) { + // Set HTTP Status + aResponse.setStatusLine(aRequest.httpVersion, 301, "Moved Permanently"); + + // Set redirect URI, mirroring the hash value. + let hash = /\#.+/.test(aRequest.path) + ? "#" + aRequest.path.split("#")[1] + : ""; + aResponse.setHeader("Location", REDIRECT_TO + hash); +} diff --git a/browser/components/urlbar/tests/browser/redirect_to.sjs b/browser/components/urlbar/tests/browser/redirect_to.sjs new file mode 100644 index 0000000000..b52ebdc63e --- /dev/null +++ b/browser/components/urlbar/tests/browser/redirect_to.sjs @@ -0,0 +1,9 @@ +"use strict"; + +function handleRequest(request, response) { + // redirect_to.sjs?ctxmenu-image.png + // redirects to : ctxmenu-image.png + const redirectUrl = request.queryString; + response.setStatusLine(request.httpVersion, "302", "Found"); + response.setHeader("Location", redirectUrl, false); +} diff --git a/browser/components/urlbar/tests/browser/search-engines/basic/manifest.json b/browser/components/urlbar/tests/browser/search-engines/basic/manifest.json new file mode 100644 index 0000000000..d66c1ed3d8 --- /dev/null +++ b/browser/components/urlbar/tests/browser/search-engines/basic/manifest.json @@ -0,0 +1,20 @@ +{ + "name": "basic", + "manifest_version": 2, + "version": "1.0", + "description": "basic", + "browser_specific_settings": { + "gecko": { + "id": "basic@search.mozilla.org" + } + }, + "hidden": true, + "chrome_settings_overrides": { + "search_provider": { + "name": "basic", + "keyword": "@basic", + "search_url": "https://example.com/?search={searchTerms}&foo=1", + "suggest_url": "https://example.com/browser/browser/components/search/test/browser/trendingSuggestionEngine.sjs?richsuggestions=true&query={searchTerms}" + } + } +} diff --git a/browser/components/urlbar/tests/browser/searchSuggestionEngine.sjs b/browser/components/urlbar/tests/browser/searchSuggestionEngine.sjs new file mode 100644 index 0000000000..145392fcf2 --- /dev/null +++ b/browser/components/urlbar/tests/browser/searchSuggestionEngine.sjs @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +let gTimer; + +function handleRequest(req, resp) { + // Parse the query params. If the params aren't in the form "foo=bar", then + // treat the entire query string as a search string. + let params = req.queryString.split("&").reduce((memo, pair) => { + let [key, val] = pair.split("="); + if (!val) { + // This part isn't in the form "foo=bar". Treat it as the search string + // (the "query"). + val = key; + key = "query"; + } + memo[decode(key)] = decode(val); + return memo; + }, {}); + + let timeout = parseInt(params.timeout); + if (timeout) { + // Write the response after a timeout. + resp.processAsync(); + gTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + gTimer.init( + () => { + writeResponse(params, resp); + resp.finish(); + }, + timeout, + Ci.nsITimer.TYPE_ONE_SHOT + ); + return; + } + + writeResponse(params, resp); +} + +function writeResponse(params, resp) { + // Echo back the search string with "foo" and "bar" appended. + let suffixes = ["foo", "bar"]; + if (params.count) { + // Add more suffixes. + let serial = 0; + while (suffixes.length < params.count) { + suffixes.push(++serial); + } + } + let data = [params.query, suffixes.map(s => params.query + s)]; + resp.setHeader("Content-Type", "application/json", false); + resp.write(JSON.stringify(data)); +} + +function decode(str) { + return decodeURIComponent(str.replace(/\+/g, encodeURIComponent(" "))); +} diff --git a/browser/components/urlbar/tests/browser/searchSuggestionEngine.xml b/browser/components/urlbar/tests/browser/searchSuggestionEngine.xml new file mode 100644 index 0000000000..142c91849c --- /dev/null +++ b/browser/components/urlbar/tests/browser/searchSuggestionEngine.xml @@ -0,0 +1,11 @@ + + + + +browser_searchSuggestionEngine searchSuggestionEngine.xml + + + + + diff --git a/browser/components/urlbar/tests/browser/searchSuggestionEngine2.xml b/browser/components/urlbar/tests/browser/searchSuggestionEngine2.xml new file mode 100644 index 0000000000..565aaf2bc0 --- /dev/null +++ b/browser/components/urlbar/tests/browser/searchSuggestionEngine2.xml @@ -0,0 +1,13 @@ + + + + +browser_searchSuggestionEngine2 searchSuggestionEngine2.xml + + + + + + + diff --git a/browser/components/urlbar/tests/browser/searchSuggestionEngineMany.xml b/browser/components/urlbar/tests/browser/searchSuggestionEngineMany.xml new file mode 100644 index 0000000000..7e77e32029 --- /dev/null +++ b/browser/components/urlbar/tests/browser/searchSuggestionEngineMany.xml @@ -0,0 +1,11 @@ + + + + +browser_searchSuggestionEngineMany searchSuggestionEngineMany.xml + + + + + diff --git a/browser/components/urlbar/tests/browser/searchSuggestionEngineSlow.xml b/browser/components/urlbar/tests/browser/searchSuggestionEngineSlow.xml new file mode 100644 index 0000000000..e7214e65cc --- /dev/null +++ b/browser/components/urlbar/tests/browser/searchSuggestionEngineSlow.xml @@ -0,0 +1,11 @@ + + + + +searchSuggestionEngineSlow.xml + + + + + diff --git a/browser/components/urlbar/tests/browser/slow-page.sjs b/browser/components/urlbar/tests/browser/slow-page.sjs new file mode 100644 index 0000000000..ce9a759744 --- /dev/null +++ b/browser/components/urlbar/tests/browser/slow-page.sjs @@ -0,0 +1,23 @@ +"use strict"; + +let timer; + +const DELAY_MS = 5000; +function handleRequest(request, response) { + if (request.queryString.endsWith("faster")) { + response.setHeader("Content-Type", "text/html", false); + response.write("Not so slow!"); + return; + } + response.processAsync(); + timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init( + () => { + response.setHeader("Content-Type", "text/html", false); + response.write("This is a slow loading page."); + response.finish(); + }, + DELAY_MS, + Ci.nsITimer.TYPE_ONE_SHOT + ); +} diff --git a/browser/components/urlbar/tests/browser/urlbarTelemetrySearchSuggestions.sjs b/browser/components/urlbar/tests/browser/urlbarTelemetrySearchSuggestions.sjs new file mode 100644 index 0000000000..1978b4f665 --- /dev/null +++ b/browser/components/urlbar/tests/browser/urlbarTelemetrySearchSuggestions.sjs @@ -0,0 +1,9 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(req, resp) { + let suffixes = ["foo", "bar"]; + let data = [req.queryString, suffixes.map(s => req.queryString + s)]; + resp.setHeader("Content-Type", "application/json", false); + resp.write(JSON.stringify(data)); +} diff --git a/browser/components/urlbar/tests/browser/urlbarTelemetrySearchSuggestions.xml b/browser/components/urlbar/tests/browser/urlbarTelemetrySearchSuggestions.xml new file mode 100644 index 0000000000..8ed4fef6f1 --- /dev/null +++ b/browser/components/urlbar/tests/browser/urlbarTelemetrySearchSuggestions.xml @@ -0,0 +1,6 @@ + + +browser_urlbar_telemetry urlbarTelemetrySearchSuggestions.xml + + + diff --git a/browser/components/urlbar/tests/browser/urlbarTelemetryUrlbarDynamic.css b/browser/components/urlbar/tests/browser/urlbarTelemetryUrlbarDynamic.css new file mode 100644 index 0000000000..e81052522f --- /dev/null +++ b/browser/components/urlbar/tests/browser/urlbarTelemetryUrlbarDynamic.css @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +.urlbarView-row[dynamicType=test] > .urlbarView-row-inner { + display: flex; + align-items: center; + min-height: 32px; + width: 100%; +} + +.urlbarView-dynamic-test-button { + min-height: 16px; + padding: 8px; + border: none; + border-radius: 2px; + font-size: 0.93em; + color: inherit; + background-color: var(--urlbarView-button-background); + min-width: 8.75em; + text-align: center; + flex-basis: initial; + flex-shrink: 0; +} + +.urlbarView-dynamic-test-button[selected] { + color: white; + background-color: var(--urlbarView-primary-button-background); + box-shadow: 0 0 0 1px #0a84ff inset, 0 0 0 1px #0a84ff, 0 0 0 4px rgba(10, 132, 255, 0.3); +} + +.urlbarView-dynamic-test-button:hover { + color: white; + background-color: var(--urlbarView-primary-button-background-hover); +} + +.urlbarView-dynamic-test-button:active { + color: white; + background-color: var(--urlbarView-primary-button-background-active); +} + +.urlbarView-dynamic-test-buttonSpacer { + flex-basis: 48px; + flex-grow: 1; + flex-shrink: 1; +} diff --git a/browser/components/urlbar/tests/browser/wait-a-bit.sjs b/browser/components/urlbar/tests/browser/wait-a-bit.sjs new file mode 100644 index 0000000000..52a6ae2c22 --- /dev/null +++ b/browser/components/urlbar/tests/browser/wait-a-bit.sjs @@ -0,0 +1,11 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +async function handleRequest(request, response) { + response.processAsync(); + + const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init(response.finish, 3000, Ci.nsITimer.TYPE_ONE_SHOT); +} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser.toml b/browser/components/urlbar/tests/engagementTelemetry/browser/browser.toml new file mode 100644 index 0000000000..68a7881399 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser.toml @@ -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/. + +[DEFAULT] +support-files = [ + "head.js", + "head-search_engine_default_id.js", + "head-exposure.js", + "head-groups.js", + "head-interaction.js", + "head-n_chars_n_words.js", + "head-sap.js", + "head-search_mode.js", + "../../browser-tips/head.js", +] +prefs = ["browser.bookmarks.testing.skipDefaultBookmarksImport=true"] + +["browser_glean_telemetry_abandonment_groups.js"] + +["browser_glean_telemetry_abandonment_interaction.js"] + +["browser_glean_telemetry_abandonment_interaction_persisted_search_terms_disabled.js"] + +["browser_glean_telemetry_abandonment_interaction_persisted_search_terms_enabled.js"] + +["browser_glean_telemetry_abandonment_n_chars_n_words.js"] + +["browser_glean_telemetry_abandonment_sap.js"] + +["browser_glean_telemetry_abandonment_search_engine_default_id.js"] + +["browser_glean_telemetry_abandonment_search_mode.js"] + +["browser_glean_telemetry_abandonment_tips.js"] + +["browser_glean_telemetry_engagement_edge_cases.js"] + +["browser_glean_telemetry_engagement_groups.js"] + +["browser_glean_telemetry_engagement_interaction.js"] + +["browser_glean_telemetry_engagement_interaction_persisted_search_terms_disabled.js"] + +["browser_glean_telemetry_engagement_interaction_persisted_search_terms_enabled.js"] + +["browser_glean_telemetry_engagement_n_chars_n_words.js"] + +["browser_glean_telemetry_engagement_sap.js"] + +["browser_glean_telemetry_engagement_search_engine_default_id.js"] + +["browser_glean_telemetry_engagement_search_mode.js"] + +["browser_glean_telemetry_engagement_selected_result.js"] +support-files = ["../../../../search/test/browser/trendingSuggestionEngine.sjs"] +skip-if = ["verify"] # Bug 1852375 - MerinoTestUtils.initWeather() doesn't play well with pushPrefEnv() + +["browser_glean_telemetry_engagement_tips.js"] + +["browser_glean_telemetry_engagement_type.js"] + +["browser_glean_telemetry_exposure.js"] + +["browser_glean_telemetry_exposure_edge_cases.js"] + +["browser_glean_telemetry_impression_groups.js"] + +["browser_glean_telemetry_impression_interaction.js"] + +["browser_glean_telemetry_impression_interaction_persisted_search_terms_disabled.js"] + +["browser_glean_telemetry_impression_interaction_persisted_search_terms_enabled.js"] + +["browser_glean_telemetry_impression_n_chars_n_words.js"] + +["browser_glean_telemetry_impression_preferences.js"] + +["browser_glean_telemetry_impression_sap.js"] + +["browser_glean_telemetry_impression_search_engine_default_id.js"] + +["browser_glean_telemetry_impression_search_mode.js"] + +["browser_glean_telemetry_impression_timing.js"] + +["browser_glean_telemetry_record_preferences.js"] 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 new file mode 100644 index 0000000000..ce69d30517 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_groups.js @@ -0,0 +1,235 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of abandonment telemetry. +// - groups +// - results +// - n_results + +// This test has many subtests and can time out in verify mode. +requestLongerTimeout(5); + +add_setup(async function () { + await initGroupTest(); +}); + +add_task(async function heuristics() { + await doHeuristicsTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { groups: "heuristic", results: "search_engine" }, + ]), + }); +}); + +add_task(async function adaptive_history() { + await doAdaptiveHistoryTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,adaptive_history", + results: "search_engine,history", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function search_history() { + await doSearchHistoryTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,search_history,search_history", + results: "search_engine,search_history,search_history", + n_results: 3, + }, + ]), + }); +}); + +add_task(async function recent_search() { + await doRecentSearchTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "recent_search,suggested_index", + results: "recent_search,action", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function search_suggest() { + await doSearchSuggestTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,search_suggest,search_suggest", + results: "search_engine,search_suggest,search_suggest", + n_results: 3, + }, + ]), + }); + + await doTailSearchSuggestTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,search_suggest", + results: "search_engine,search_suggest", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function top_pick() { + await doTopPickTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,top_pick,search_suggest,search_suggest", + results: + "search_engine,merino_top_picks,search_suggest,search_suggest", + n_results: 4, + }, + ]), + }); +}); + +add_task(async function top_site() { + await doTopSiteTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "top_site,suggested_index", + results: "top_site,action", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function clipboard() { + await doClipboardTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "general,suggested_index", + results: "clipboard,action", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function remote_tab() { + await doRemoteTabTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,remote_tab", + results: "search_engine,remote_tab", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function addon() { + await doAddonTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "addon", + results: "addon", + n_results: 1, + }, + ]), + }); +}); + +add_task(async function general() { + await doGeneralBookmarkTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,suggested_index,general", + results: "search_engine,action,bookmark", + n_results: 3, + }, + ]), + }); + + await doGeneralHistoryTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,general", + results: "search_engine,history", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function suggest() { + await doSuggestTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,suggest", + results: UrlbarPrefs.get("quickSuggestRustEnabled") + ? "search_engine,rust_adm_nonsponsored" + : "search_engine,rs_adm_nonsponsored", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function about_page() { + await doAboutPageTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,about_page,about_page", + results: "search_engine,history,history", + n_results: 3, + }, + ]), + }); +}); + +add_task(async function suggested_index() { + await doSuggestedIndexTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,suggested_index", + results: "search_engine,unit", + n_results: 2, + }, + ]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction.js new file mode 100644 index 0000000000..73820be059 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of abandonment telemetry. +// - interaction + +add_setup(async function () { + await initInteractionTest(); +}); + +add_task(async function topsites() { + await doTopsitesTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ interaction: "topsites" }]), + }); +}); + +add_task(async function typed() { + await doTypedTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ interaction: "typed" }]), + }); + + await doTypedWithResultsPopupTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ interaction: "typed" }]), + }); +}); + +add_task(async function pasted() { + await doPastedTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ interaction: "pasted" }]), + }); + + await doPastedWithResultsPopupTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ interaction: "pasted" }]), + }); +}); + +add_task(async function topsite_search() { + await doTopsitesSearchTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([{ interaction: "topsite_search" }]), + }); +}); + +add_task(async function returned_restarted_refined() { + await doReturnedRestartedRefinedTest({ + trigger: () => doBlur(), + assert: expected => + assertAbandonmentTelemetry([ + { interaction: "typed" }, + { interaction: expected }, + ]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction_persisted_search_terms_disabled.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction_persisted_search_terms_disabled.js new file mode 100644 index 0000000000..68799544b0 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction_persisted_search_terms_disabled.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test abandonment telemetry with persisted search terms disabled. + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + await initInteractionTest(); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", false]], + }); +}); + +add_task(async function persisted_search_terms() { + await doPersistedSearchTermsTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ interaction: "typed" }]), + }); +}); + +add_task(async function persisted_search_terms_restarted_refined() { + await doPersistedSearchTermsRestartedRefinedTest({ + enabled: false, + trigger: () => doBlur(), + assert: expected => assertAbandonmentTelemetry([{ interaction: expected }]), + }); +}); + +add_task( + async function persisted_search_terms_restarted_refined_via_abandonment() { + await doPersistedSearchTermsRestartedRefinedViaAbandonmentTest({ + enabled: false, + trigger: () => doBlur(), + assert: expected => + assertAbandonmentTelemetry([ + { interaction: "typed" }, + { interaction: expected }, + ]), + }); + } +); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction_persisted_search_terms_enabled.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction_persisted_search_terms_enabled.js new file mode 100644 index 0000000000..f0a217805f --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction_persisted_search_terms_enabled.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test abandonment telemetry with persisted search terms enabled. + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + await initInteractionTest(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.showSearchTerms.featureGate", true], + ["browser.urlbar.showSearchTerms.enabled", true], + ["browser.search.widget.inNavBar", false], + ], + }); +}); + +add_task(async function persisted_search_terms() { + await doPersistedSearchTermsTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([{ interaction: "persisted_search_terms" }]), + }); +}); + +add_task(async function persisted_search_terms_restarted_refined() { + await doPersistedSearchTermsRestartedRefinedTest({ + enabled: true, + trigger: () => doBlur(), + assert: expected => assertAbandonmentTelemetry([{ interaction: expected }]), + }); +}); + +add_task( + async function persisted_search_terms_restarted_refined_via_abandonment() { + await doPersistedSearchTermsRestartedRefinedViaAbandonmentTest({ + enabled: true, + trigger: () => doBlur(), + assert: expected => + assertAbandonmentTelemetry([ + { interaction: "persisted_search_terms_restarted" }, + { interaction: expected }, + ]), + }); + } +); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_n_chars_n_words.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_n_chars_n_words.js new file mode 100644 index 0000000000..7427db8cbf --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_n_chars_n_words.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of abandonment telemetry. +// - n_chars +// - n_words + +add_setup(async function () { + await initNCharsAndNWordsTest(); +}); + +add_task(async function n_chars() { + await doNCharsTest({ + trigger: () => doBlur(), + assert: nChars => assertAbandonmentTelemetry([{ n_chars: nChars }]), + }); + + await doNCharsWithOverMaxTextLengthCharsTest({ + trigger: () => doBlur(), + assert: nChars => assertAbandonmentTelemetry([{ n_chars: nChars }]), + }); +}); + +add_task(async function n_words() { + await doNWordsTest({ + trigger: () => doBlur(), + assert: nWords => assertAbandonmentTelemetry([{ n_words: nWords }]), + }); + + await doNWordsWithOverMaxTextLengthCharsTest({ + trigger: () => doBlur(), + assert: nWords => assertAbandonmentTelemetry([{ n_words: nWords }]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_sap.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_sap.js new file mode 100644 index 0000000000..3d0af65379 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_sap.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of abandonment telemetry. +// - sap + +add_setup(async function () { + await initSapTest(); +}); + +add_task(async function urlbar_newtab() { + await doUrlbarNewTabTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ sap: "urlbar_newtab" }]), + }); +}); + +add_task(async function urlbar() { + await doUrlbarTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ sap: "urlbar" }]), + }); +}); + +add_task(async function handoff() { + await doHandoffTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ sap: "handoff" }]), + }); +}); + +add_task(async function urlbar_addonpage() { + await doUrlbarAddonpageTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ sap: "urlbar_addonpage" }]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_search_engine_default_id.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_search_engine_default_id.js new file mode 100644 index 0000000000..d64b540d25 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_search_engine_default_id.js @@ -0,0 +1,19 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of abandonment telemetry. +// - search_engine_default_id + +add_setup(async function () { + await initSearchEngineDefaultIdTest(); +}); + +add_task(async function basic() { + await doSearchEngineDefaultIdTest({ + trigger: () => doBlur(), + assert: engineId => + assertAbandonmentTelemetry([{ search_engine_default_id: engineId }]), + }); +}); 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 new file mode 100644 index 0000000000..7edcc47a30 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_search_mode.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of abandonment telemetry. +// - search_mode + +add_setup(async function () { + await initSearchModeTest(); +}); + +add_task(async function not_search_mode() { + await doNotSearchModeTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ search_mode: "" }]), + }); +}); + +add_task(async function search_engine() { + await doSearchEngineTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([{ search_mode: "search_engine" }]), + }); +}); + +add_task(async function bookmarks() { + await doBookmarksTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ search_mode: "bookmarks" }]), + }); +}); + +add_task(async function history() { + await doHistoryTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ search_mode: "history" }]), + }); +}); + +add_task(async function tabs() { + await doTabTest({ + trigger: () => doBlur(), + 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_abandonment_tips.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_tips.js new file mode 100644 index 0000000000..71087d03d0 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_tips.js @@ -0,0 +1,99 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for abandonment telemetry for tips using Glean. + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/browser-tips/head.js", + this +); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.searchTips.test.ignoreShowLimits", true], + ["browser.urlbar.showSearchTerms.featureGate", true], + ], + }); + const engine = await SearchTestUtils.promiseNewSearchEngine({ + url: "chrome://mochitests/content/browser/browser/components/urlbar/tests/browser/searchSuggestionEngine.xml", + }); + const originalDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.moveEngine(engine, 0); + + registerCleanupFunction(async function () { + await SpecialPowers.popPrefEnv(); + await Services.search.setDefault( + originalDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + resetSearchTipsProvider(); + }); +}); + +add_task(async function tip_persist() { + await doTest(async browser => { + await showPersistSearchTip("test"); + gURLBar.focus(); + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); + + assertAbandonmentTelemetry([{ results: "tip_persist" }]); + }); +}); + +add_task(async function mouse_down_with_tip() { + await doTest(async browser => { + await showPersistSearchTip("test"); + await UrlbarTestUtils.promisePopupClose(window, () => { + // We intentionally turn off this a11y check, because the following click + // is sent to test the telemetry behavior using an alternative way of the + // urlbar dismissal, where other ways are accessible, therefore this test + // can be ignored. + AccessibilityUtils.setEnv({ + mustHaveAccessibleRule: false, + }); + EventUtils.synthesizeMouseAtCenter(browser, {}); + AccessibilityUtils.resetEnv(); + }); + + assertAbandonmentTelemetry([{ results: "tip_persist" }]); + }); +}); + +add_task(async function mouse_down_without_tip() { + await doTest(async browser => { + // We intentionally turn off this a11y check, because the following click + // is sent to test the telemetry behavior using an alternative way of the + // urlbar dismissal, where other ways are accessible, therefore this test + // can be ignored. + AccessibilityUtils.setEnv({ + mustHaveAccessibleRule: false, + }); + EventUtils.synthesizeMouseAtCenter(browser, {}); + AccessibilityUtils.resetEnv(); + + assertAbandonmentTelemetry([]); + }); +}); + +async function showPersistSearchTip(word) { + await openPopup(word); + await doEnter(); + await BrowserTestUtils.waitForCondition(async () => { + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + const detail = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (detail.result.payload?.type === "searchTip_persist") { + return true; + } + } + return false; + }); +} 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 new file mode 100644 index 0000000000..fcac924879 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_edge_cases.js @@ -0,0 +1,221 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test edge cases for engagement. + +add_setup(async function () { + await setup(); +}); + +/** + * UrlbarProvider that does not add any result. + */ +class NoResponseTestProvider extends UrlbarTestUtils.TestProvider { + constructor() { + super({ name: "TestProviderNoResponse ", results: [] }); + this.#deferred = Promise.withResolvers(); + } + + get type() { + return UrlbarUtils.PROVIDER_TYPE.HEURISTIC; + } + + async startQuery(context, addCallback) { + await this.#deferred.promise; + } + + done() { + this.#deferred.resolve(); + } + + #deferred = null; +} +const noResponseProvider = new NoResponseTestProvider(); + +/** + * UrlbarProvider that adds a heuristic result immediately as usual. + */ +class AnotherHeuristicProvider extends UrlbarTestUtils.TestProvider { + constructor({ results }) { + super({ name: "TestProviderAnotherHeuristic ", results }); + this.#deferred = Promise.withResolvers(); + } + + get type() { + return UrlbarUtils.PROVIDER_TYPE.HEURISTIC; + } + + async startQuery(context, addCallback) { + for (const result of this.results) { + addCallback(this, result); + } + + this.#deferred.resolve(context); + } + + onQueryStarted() { + return this.#deferred.promise; + } + + #deferred = null; +} +const anotherHeuristicProvider = new AnotherHeuristicProvider({ + results: [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { url: "https://example.com/immediate" } + ), + { heuristic: true } + ), + ], +}); + +add_task(async function engagement_before_showing_results() { + await SpecialPowers.pushPrefEnv({ + // Avoid showing search tip. + set: [["browser.urlbar.tipShownCount.searchTip_onboard", 999]], + }); + + // Increase chunk delays to delay the call to notifyResults. + let originalChunkTimeout = UrlbarProvidersManager.CHUNK_RESULTS_DELAY_MS; + UrlbarProvidersManager.CHUNK_RESULTS_DELAY_MS = 1000000; + + // Add a provider that waits forever in startQuery() to avoid fireing + // heuristicProviderTimer. + UrlbarProvidersManager.registerProvider(noResponseProvider); + + // Add a provider that add a result immediately as usual. + UrlbarProvidersManager.registerProvider(anotherHeuristicProvider); + + const cleanup = () => { + UrlbarProvidersManager.unregisterProvider(noResponseProvider); + UrlbarProvidersManager.unregisterProvider(anotherHeuristicProvider); + UrlbarProvidersManager.CHUNK_RESULTS_DELAY_MS = originalChunkTimeout; + }; + registerCleanupFunction(cleanup); + + await doTest(async browser => { + // Try to show the results. + await UrlbarTestUtils.inputIntoURLBar(window, "exam"); + + // Wait until starting the query and filling expected results. + const context = await anotherHeuristicProvider.onQueryStarted(); + const query = UrlbarProvidersManager.queries.get(context); + await BrowserTestUtils.waitForCondition( + () => + query.unsortedResults.some( + r => r.providerName === "HeuristicFallback" + ) && + query.unsortedResults.some( + r => r.providerName === anotherHeuristicProvider.name + ) + ); + + // Type Enter key before showing any results. + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "input_field", + selected_result_subtype: "", + provider: undefined, + results: "", + groups: "", + }, + ]); + + // Clear the pending query. + noResponseProvider.done(); + }); + + cleanup(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function engagement_after_closing_results() { + const TRIGGERS = [ + () => EventUtils.synthesizeKey("KEY_Escape"), + () => { + // We intentionally turn off this a11y check, because the following click + // is sent to test the telemetry behavior using an alternative way of the + // urlbar dismissal, where other ways are accessible (and tested above), + // therefore this test can be ignored. + AccessibilityUtils.setEnv({ + mustHaveAccessibleRule: false, + }); + EventUtils.synthesizeMouseAtCenter( + document.getElementById("customizableui-special-spring2"), + {} + ); + AccessibilityUtils.resetEnv(); + }, + ]; + + for (const trigger of TRIGGERS) { + await doTest(async browser => { + await openPopup("test"); + await UrlbarTestUtils.promisePopupClose(window, () => { + trigger(); + }); + Assert.equal( + gURLBar.value, + "test", + "The inputted text remains even if closing the results" + ); + // The tested trigger should not record abandonment event. + assertAbandonmentTelemetry([]); + + // Endgagement. + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "input_field", + selected_result_subtype: "", + provider: undefined, + results: "", + groups: "", + }, + ]); + }); + } +}); + +add_task(async function enter_to_reload_current_url() { + await doTest(async browser => { + // Open a URL once. + await openPopup("https://example.com"); + await doEnter(); + + // Focus the urlbar. + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + 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(); + + assertEngagementTelemetry([ + { + selected_result: "url", + selected_result_subtype: "", + provider: "HeuristicFallback", + results: "url", + groups: "heuristic", + }, + { + selected_result: "input_field", + selected_result_subtype: "", + provider: undefined, + results: "action", + groups: "suggested_index", + }, + ]); + }); +}); 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 new file mode 100644 index 0000000000..8779487960 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_groups.js @@ -0,0 +1,292 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of engagement telemetry. +// - groups +// - results +// - n_results + +// This test has many subtests and can time out in verify mode. +requestLongerTimeout(5); + +add_setup(async function () { + await initGroupTest(); +}); + +add_task(async function heuristics() { + await doHeuristicsTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { groups: "heuristic", results: "search_engine" }, + ]), + }); +}); + +add_task(async function adaptive_history() { + await doAdaptiveHistoryTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,adaptive_history", + results: "search_engine,history", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function search_history() { + await doSearchHistoryTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,search_history,search_history", + results: "search_engine,search_history,search_history", + n_results: 3, + }, + ]), + }); +}); + +add_task(async function recent_search() { + await doRecentSearchTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "recent_search,suggested_index", + results: "recent_search,action", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function search_suggest() { + await doSearchSuggestTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,search_suggest,search_suggest", + results: "search_engine,search_suggest,search_suggest", + n_results: 3, + }, + ]), + }); + + await doTailSearchSuggestTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,search_suggest", + results: "search_engine,search_suggest", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function top_pick() { + await doTopPickTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,top_pick,search_suggest,search_suggest", + results: + "search_engine,merino_top_picks,search_suggest,search_suggest", + n_results: 4, + }, + ]), + }); +}); + +add_task(async function top_site() { + await doTopSiteTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "top_site,suggested_index", + results: "top_site,action", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function clipboard() { + await doClipboardTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "general,suggested_index", + results: "clipboard,action", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function remote_tab() { + await doRemoteTabTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,remote_tab", + results: "search_engine,remote_tab", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function addon() { + await doAddonTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "addon", + results: "addon", + n_results: 1, + }, + ]), + }); +}); + +add_task(async function general() { + await doGeneralBookmarkTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,suggested_index,general", + results: "search_engine,action,bookmark", + n_results: 3, + }, + ]), + }); + + await doGeneralHistoryTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,general", + results: "search_engine,history", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function suggest() { + await doSuggestTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,suggest", + results: UrlbarPrefs.get("quickSuggestRustEnabled") + ? "search_engine,rust_adm_nonsponsored" + : "search_engine,rs_adm_nonsponsored", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function about_page() { + await doAboutPageTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,about_page,about_page", + results: "search_engine,history,history", + n_results: 3, + }, + ]), + }); +}); + +add_task(async function suggested_index() { + await doSuggestedIndexTest({ + trigger: () => + SimpleTest.promiseClipboardChange("100 cm", () => { + EventUtils.synthesizeKey("KEY_Enter"); + }), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,suggested_index", + results: "search_engine,unit", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function always_empty_if_drop_go() { + const expected = [ + { + engagement_type: "drop_go", + groups: "", + results: "", + n_results: 0, + }, + ]; + + await doTest(async browser => { + await doDropAndGo("example.com"); + + assertEngagementTelemetry(expected); + }); + + await doTest(async browser => { + // Open the results view once. + await showResultByArrowDown(); + await UrlbarTestUtils.promisePopupClose(window); + + await doDropAndGo("example.com"); + + assertEngagementTelemetry(expected); + }); +}); + +add_task(async function always_empty_if_paste_go() { + const expected = [ + { + engagement_type: "paste_go", + groups: "", + results: "", + n_results: 0, + }, + ]; + + await doTest(async browser => { + await doPasteAndGo("example.com"); + + assertEngagementTelemetry(expected); + }); + + await doTest(async browser => { + // Open the results view once. + await showResultByArrowDown(); + await UrlbarTestUtils.promisePopupClose(window); + + await doPasteAndGo("example.com"); + + assertEngagementTelemetry(expected); + }); +}); 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 new file mode 100644 index 0000000000..f4880d2205 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of engagement telemetry. +// - interaction + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + await initInteractionTest(); +}); + +add_task(async function topsites() { + await doTopsitesTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ interaction: "topsites" }]), + }); +}); + +add_task(async function typed() { + await doTypedTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ interaction: "typed" }]), + }); + + await doTypedWithResultsPopupTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ interaction: "typed" }]), + }); +}); + +add_task(async function dropped() { + await doTest(async browser => { + await doDropAndGo("example.com"); + + assertEngagementTelemetry([{ interaction: "dropped" }]); + }); + + await doTest(async browser => { + await showResultByArrowDown(); + await doDropAndGo("example.com"); + + assertEngagementTelemetry([{ interaction: "dropped" }]); + }); +}); + +add_task(async function pasted() { + await doPastedTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ interaction: "pasted" }]), + }); + + await doPastedWithResultsPopupTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ interaction: "pasted" }]), + }); + + await doTest(async browser => { + await doPasteAndGo("www.example.com"); + + assertEngagementTelemetry([{ interaction: "pasted" }]); + }); + + await doTest(async browser => { + await showResultByArrowDown(); + await doPasteAndGo("www.example.com"); + + assertEngagementTelemetry([{ interaction: "pasted" }]); + }); +}); + +add_task(async function topsite_search() { + await doTopsitesSearchTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([{ interaction: "topsite_search" }]), + }); +}); + +add_task(async function returned_restarted_refined() { + await doReturnedRestartedRefinedTest({ + trigger: () => doEnter(), + assert: expected => assertEngagementTelemetry([{ interaction: expected }]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction_persisted_search_terms_disabled.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction_persisted_search_terms_disabled.js new file mode 100644 index 0000000000..1bdb4f0b61 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction_persisted_search_terms_disabled.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test engagement telemetry with persisted search terms disabled. + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/engagementTelemetry/browser/head-interaction.js", + this +); + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + await initInteractionTest(); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", false]], + }); +}); + +add_task(async function persisted_search_terms() { + await doPersistedSearchTermsTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { interaction: "typed" }, + { interaction: "typed" }, + ]), + }); +}); + +add_task(async function persisted_search_terms_restarted_refined() { + await doPersistedSearchTermsRestartedRefinedTest({ + enabled: false, + trigger: () => doEnter(), + assert: expected => + assertEngagementTelemetry([ + { interaction: "typed" }, + { interaction: expected }, + ]), + }); +}); + +add_task( + async function persisted_search_terms_restarted_refined_via_abandonment() { + await doPersistedSearchTermsRestartedRefinedViaAbandonmentTest({ + enabled: false, + trigger: () => doEnter(), + assert: expected => + assertEngagementTelemetry([ + { interaction: "typed" }, + { interaction: expected }, + ]), + }); + } +); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction_persisted_search_terms_enabled.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction_persisted_search_terms_enabled.js new file mode 100644 index 0000000000..33a01fdd22 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction_persisted_search_terms_enabled.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test engagement telemetry with persisted search terms enabled. + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + await initInteractionTest(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.showSearchTerms.featureGate", true], + ["browser.urlbar.showSearchTerms.enabled", true], + ["browser.search.widget.inNavBar", false], + ], + }); +}); + +add_task(async function persisted_search_terms() { + await doPersistedSearchTermsTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { interaction: "typed" }, + { interaction: "persisted_search_terms" }, + ]), + }); +}); + +add_task(async function persisted_search_terms_restarted_refined() { + await doPersistedSearchTermsRestartedRefinedTest({ + enabled: true, + trigger: () => doEnter(), + assert: expected => + assertEngagementTelemetry([ + { interaction: "typed" }, + { interaction: expected }, + ]), + }); +}); + +add_task( + async function persisted_search_terms_restarted_refined_via_abandonment() { + await doPersistedSearchTermsRestartedRefinedViaAbandonmentTest({ + enabled: true, + trigger: () => doEnter(), + assert: expected => + assertEngagementTelemetry([ + { interaction: "typed" }, + { interaction: expected }, + ]), + }); + } +); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_n_chars_n_words.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_n_chars_n_words.js new file mode 100644 index 0000000000..498ffd9532 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_n_chars_n_words.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of engagement telemetry. +// - n_chars +// - n_words + +add_setup(async function () { + await initNCharsAndNWordsTest(); +}); + +add_task(async function n_chars() { + await doNCharsTest({ + trigger: () => doEnter(), + assert: nChars => assertEngagementTelemetry([{ n_chars: nChars }]), + }); + + await doNCharsWithOverMaxTextLengthCharsTest({ + trigger: () => doEnter(), + assert: nChars => assertEngagementTelemetry([{ n_chars: nChars }]), + }); +}); + +add_task(async function n_words() { + await doNWordsTest({ + trigger: () => doEnter(), + assert: nWords => assertEngagementTelemetry([{ n_words: nWords }]), + }); + + await doNWordsWithOverMaxTextLengthCharsTest({ + trigger: () => doEnter(), + assert: nWords => assertEngagementTelemetry([{ n_words: nWords }]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_sap.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_sap.js new file mode 100644 index 0000000000..d361d70229 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_sap.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of engagement telemetry. +// - sap + +add_setup(async function () { + await initSapTest(); +}); + +add_task(async function urlbar() { + await doUrlbarTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([{ sap: "urlbar_newtab" }, { sap: "urlbar" }]), + }); +}); + +add_task(async function handoff() { + await doHandoffTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ sap: "handoff" }]), + }); +}); + +add_task(async function urlbar_addonpage() { + await doUrlbarAddonpageTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ sap: "urlbar_addonpage" }]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_search_engine_default_id.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_search_engine_default_id.js new file mode 100644 index 0000000000..60331ff53b --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_search_engine_default_id.js @@ -0,0 +1,19 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of engagement telemetry. +// - search_engine_default_id + +add_setup(async function () { + await initSearchEngineDefaultIdTest(); +}); + +add_task(async function basic() { + await doSearchEngineDefaultIdTest({ + trigger: () => doEnter(), + assert: engineId => + assertEngagementTelemetry([{ search_engine_default_id: engineId }]), + }); +}); 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 new file mode 100644 index 0000000000..013bef1904 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_search_mode.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of engagement telemetry. +// - search_mode + +add_setup(async function () { + await initSearchModeTest(); +}); + +add_task(async function not_search_mode() { + await doNotSearchModeTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ search_mode: "" }]), + }); +}); + +add_task(async function search_engine() { + await doSearchEngineTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ search_mode: "search_engine" }]), + }); +}); + +add_task(async function bookmarks() { + await doBookmarksTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ search_mode: "bookmarks" }]), + }); +}); + +add_task(async function history() { + await doHistoryTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ search_mode: "history" }]), + }); +}); + +add_task(async function tabs() { + await doTabTest({ + trigger: async () => { + const currentTab = gBrowser.selectedTab; + EventUtils.synthesizeKey("KEY_Enter"); + await BrowserTestUtils.waitForCondition( + () => gBrowser.selectedTab !== currentTab + ); + }, + 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 new file mode 100644 index 0000000000..6a3422d939 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_selected_result.js @@ -0,0 +1,974 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of engagement telemetry. +// - selected_result +// - selected_result_subtype +// - selected_position +// - provider +// - results + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderClipboard: + "resource:///modules/UrlbarProviderClipboard.sys.mjs", +}); + +// This test has many subtests and can time out in verify mode. +requestLongerTimeout(5); + +add_setup(async function () { + await setup(); +}); + +add_task(async function selected_result_autofill_about() { + await doTest(async browser => { + await openPopup("about:about"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "autofill_about", + selected_result_subtype: "", + selected_position: 1, + provider: "Autofill", + results: "autofill_about", + }, + ]); + }); +}); + +add_task(async function selected_result_autofill_adaptive() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill.adaptiveHistory.enabled", true]], + }); + + await doTest(async browser => { + await PlacesTestUtils.addVisits("https://example.com/test"); + await UrlbarUtils.addToInputHistory("https://example.com/test", "exa"); + await openPopup("exa"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "autofill_adaptive", + selected_result_subtype: "", + selected_position: 1, + provider: "Autofill", + results: "autofill_adaptive", + }, + ]); + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_autofill_origin() { + await doTest(async browser => { + await PlacesTestUtils.addVisits("https://example.com/test"); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await openPopup("exa"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "autofill_origin", + selected_result_subtype: "", + selected_position: 1, + provider: "Autofill", + results: "autofill_origin,history", + }, + ]); + }); +}); + +add_task(async function selected_result_autofill_url() { + await doTest(async browser => { + await PlacesTestUtils.addVisits("https://example.com/test"); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + await openPopup("https://example.com/test"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "autofill_url", + selected_result_subtype: "", + selected_position: 1, + provider: "Autofill", + results: "autofill_url", + }, + ]); + }); +}); + +add_task(async function selected_result_bookmark() { + await doTest(async browser => { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "https://example.com/bookmark", + title: "bookmark", + }); + + await openPopup("bookmark"); + await selectRowByURL("https://example.com/bookmark"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "bookmark", + selected_result_subtype: "", + selected_position: 3, + provider: "Places", + results: "search_engine,action,bookmark", + }, + ]); + }); +}); + +add_task(async function selected_result_history() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill", false]], + }); + + await doTest(async browser => { + await PlacesTestUtils.addVisits("https://example.com/test"); + + await openPopup("example"); + await selectRowByURL("https://example.com/test"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "history", + selected_result_subtype: "", + selected_position: 2, + provider: "Places", + results: "search_engine,history", + }, + ]); + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_keyword() { + await doTest(async browser => { + await PlacesUtils.keywords.insert({ + keyword: "keyword", + url: "https://example.com/?q=%s", + }); + + await openPopup("keyword test"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "keyword", + selected_result_subtype: "", + selected_position: 1, + provider: "BookmarkKeywords", + results: "keyword", + }, + ]); + + await PlacesUtils.keywords.remove("keyword"); + }); +}); + +add_task(async function selected_result_search_engine() { + await doTest(async browser => { + await openPopup("x"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "search_engine", + selected_result_subtype: "", + selected_position: 1, + provider: "HeuristicFallback", + results: "search_engine", + }, + ]); + }); +}); + +add_task(async function selected_result_search_suggest() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.maxHistoricalSearchSuggestions", 2], + ], + }); + + await doTest(async browser => { + await openPopup("foo"); + await selectRowByURL("http://mochi.test:8888/?terms=foofoo"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "search_suggest", + selected_result_subtype: "", + selected_position: 2, + provider: "SearchSuggestions", + results: "search_engine,search_suggest,search_suggest", + }, + ]); + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_search_history() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.maxHistoricalSearchSuggestions", 2], + ], + }); + + await doTest(async browser => { + await UrlbarTestUtils.formHistory.add(["foofoo", "foobar"]); + + await openPopup("foo"); + await selectRowByURL("http://mochi.test:8888/?terms=foofoo"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "search_history", + selected_result_subtype: "", + selected_position: 3, + provider: "SearchSuggestions", + results: "search_engine,search_history,search_history", + }, + ]); + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_url() { + await doTest(async browser => { + await openPopup("https://example.com/"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "url", + selected_result_subtype: "", + selected_position: 1, + provider: "HeuristicFallback", + results: "url", + }, + ]); + }); +}); + +add_task(async function selected_result_action() { + await doTest(async browser => { + 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() { + const tab = BrowserTestUtils.addTab(gBrowser, "https://example.com/"); + + await doTest(async browser => { + await openPopup("example"); + await selectRowByProvider("Places"); + EventUtils.synthesizeKey("KEY_Enter"); + await BrowserTestUtils.waitForCondition(() => gBrowser.selectedTab === tab); + + assertEngagementTelemetry([ + { + selected_result: "tab", + selected_result_subtype: "", + selected_position: 4, + provider: "Places", + results: "search_engine,search_suggest,search_suggest,tab", + }, + ]); + }); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function selected_result_remote_tab() { + const remoteTab = await loadRemoteTab("https://example.com"); + + await doTest(async browser => { + await openPopup("example"); + await selectRowByProvider("RemoteTabs"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "remote_tab", + selected_result_subtype: "", + selected_position: 2, + provider: "RemoteTabs", + results: "search_engine,remote_tab", + }, + ]); + }); + + await remoteTab.unload(); +}); + +add_task(async function selected_result_addon() { + const addon = loadOmniboxAddon({ keyword: "omni" }); + await addon.startup(); + + await doTest(async browser => { + await openPopup("omni test"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "addon", + selected_result_subtype: "", + selected_position: 1, + provider: "Omnibox", + results: "addon", + }, + ]); + }); + + await addon.unload(); +}); + +add_task(async function selected_result_tab_to_search() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 0]], + }); + + await SearchTestUtils.installSearchExtension({ + name: "mozengine", + search_url: "https://mozengine/", + }); + + await doTest(async browser => { + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits(["https://mozengine/"]); + } + + await openPopup("moze"); + await selectRowByProvider("TabToSearch"); + const onComplete = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await onComplete; + + assertEngagementTelemetry([ + { + selected_result: "tab_to_search", + selected_result_subtype: "", + selected_position: 2, + provider: "TabToSearch", + results: "search_engine,tab_to_search,history", + }, + ]); + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_top_site() { + await doTest(async browser => { + await addTopSites("https://example.com/"); + await showResultByArrowDown(); + await selectRowByURL("https://example.com/"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "top_site", + selected_result_subtype: "", + selected_position: 1, + provider: "UrlbarProviderTopSites", + results: "top_site,action", + }, + ]); + }); +}); + +add_task(async function selected_result_calc() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.calculator", true]], + }); + + await doTest(async browser => { + await openPopup("8*8"); + await selectRowByProvider("calculator"); + await SimpleTest.promiseClipboardChange("64", () => { + EventUtils.synthesizeKey("KEY_Enter"); + }); + + assertEngagementTelemetry([ + { + selected_result: "calc", + selected_result_subtype: "", + selected_position: 2, + provider: "calculator", + results: "search_engine,calc", + }, + ]); + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_clipboard() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.clipboard.featureGate", true], + ["browser.urlbar.suggest.clipboard", true], + ], + }); + SpecialPowers.clipboardCopyString( + "https://example.com/selected_result_clipboard" + ); + + await doTest(async browser => { + await openPopup(""); + await selectRowByProvider("UrlbarProviderClipboard"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "clipboard", + selected_result_subtype: "", + selected_position: 1, + provider: "UrlbarProviderClipboard", + results: "clipboard,action", + }, + ]); + }); + + SpecialPowers.clipboardCopyString(""); + UrlbarProviderClipboard.setPreviousClipboardValue(""); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_unit() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.unitConversion.enabled", true]], + }); + + await doTest(async browser => { + await openPopup("1m to cm"); + await selectRowByProvider("UnitConversion"); + await SimpleTest.promiseClipboardChange("100 cm", () => { + EventUtils.synthesizeKey("KEY_Enter"); + }); + + assertEngagementTelemetry([ + { + selected_result: "unit", + selected_result_subtype: "", + selected_position: 2, + provider: "UnitConversion", + results: "search_engine,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 browser => { + 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]], + }); + + await doTest(async browser => { + await openPopup("sponsored"); + await selectRowByURL("https://example.com/sponsored"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "rs_adm_sponsored", + selected_result_subtype: "", + selected_position: 2, + provider: "UrlbarProviderQuickSuggest", + results: "search_engine,rs_adm_sponsored", + }, + ]); + }); + + await cleanupQuickSuggest(); +}); + +add_task(async function selected_result_rs_adm_nonsponsored() { + const cleanupQuickSuggest = await ensureQuickSuggestInit({ + prefs: [["quicksuggest.rustEnabled", false]], + }); + + await doTest(async browser => { + await openPopup("nonsponsored"); + await selectRowByURL("https://example.com/nonsponsored"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "rs_adm_nonsponsored", + selected_result_subtype: "", + selected_position: 2, + provider: "UrlbarProviderQuickSuggest", + results: "search_engine,rs_adm_nonsponsored", + }, + ]); + }); + + await cleanupQuickSuggest(); +}); + +add_task(async function selected_result_input_field() { + const expected = [ + { + selected_result: "input_field", + selected_result_subtype: "", + selected_position: 0, + provider: null, + results: "", + }, + ]; + + await doTest(async browser => { + await doDropAndGo("example.com"); + + assertEngagementTelemetry(expected); + }); + + await doTest(async browser => { + await doPasteAndGo("example.com"); + + assertEngagementTelemetry(expected); + }); +}); + +add_task(async function selected_result_weather() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.quickactions.enabled", false]], + }); + + const cleanupQuickSuggest = await ensureQuickSuggestInit(); + await MerinoTestUtils.initWeather(); + + let provider = UrlbarPrefs.get("quickSuggestRustEnabled") + ? "UrlbarProviderQuickSuggest" + : "Weather"; + await doTest(async browser => { + await openPopup(MerinoTestUtils.WEATHER_KEYWORD); + await selectRowByProvider(provider); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "weather", + selected_result_subtype: "", + selected_position: 2, + provider, + results: "search_engine,weather", + }, + ]); + }); + + await cleanupQuickSuggest(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_navigational() { + const cleanupQuickSuggest = await ensureQuickSuggestInit({ + merinoSuggestions: [ + { + title: "Navigational suggestion", + url: "https://example.com/navigational-suggestion", + provider: "top_picks", + is_sponsored: false, + score: 0.25, + block_id: 0, + is_top_pick: true, + }, + ], + }); + + await doTest(async browser => { + await openPopup("only match the Merino suggestion"); + await selectRowByProvider("UrlbarProviderQuickSuggest"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "merino_top_picks", + selected_result_subtype: "", + selected_position: 2, + provider: "UrlbarProviderQuickSuggest", + results: "search_engine,merino_top_picks", + }, + ]); + }); + + await cleanupQuickSuggest(); +}); + +add_task(async function selected_result_dynamic_wikipedia() { + const cleanupQuickSuggest = await ensureQuickSuggestInit({ + merinoSuggestions: [ + { + block_id: 1, + 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", + }, + ], + }); + + await doTest(async browser => { + await openPopup("only match the Merino suggestion"); + await selectRowByProvider("UrlbarProviderQuickSuggest"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "merino_wikipedia", + selected_result_subtype: "", + selected_position: 2, + provider: "UrlbarProviderQuickSuggest", + results: "search_engine,merino_wikipedia", + }, + ]); + }); + + await cleanupQuickSuggest(); +}); + +add_task(async function selected_result_search_shortcut_button() { + await doTest(async browser => { + const oneOffSearchButtons = UrlbarTestUtils.getOneOffSearchButtons(window); + await openPopup("x"); + Assert.ok(!oneOffSearchButtons.selectedButton); + + // Select oneoff button added for test in setup(). + for (;;) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + if (!oneOffSearchButtons.selectedButton) { + continue; + } + + if ( + oneOffSearchButtons.selectedButton.engine.name.includes( + "searchSuggestionEngine.xml" + ) + ) { + break; + } + } + + // Search immediately. + await doEnter({ shiftKey: true }); + + assertEngagementTelemetry([ + { + selected_result: "search_shortcut_button", + selected_result_subtype: "", + selected_position: 0, + provider: null, + results: "search_engine", + }, + ]); + }); +}); + +add_task(async function selected_result_trending() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.trending.featureGate", true], + ["browser.urlbar.trending.requireSearchMode", false], + ["browser.urlbar.trending.maxResultsNoSearchMode", 1], + ["browser.urlbar.weather.featureGate", false], + ], + }); + + let defaultEngine = await Services.search.getDefault(); + let extension = await SearchTestUtils.installSearchExtension( + { + name: "mozengine", + search_url: "https://example.org/", + }, + { setAsDefault: true, skipUnload: true } + ); + + SearchTestUtils.useMockIdleService(); + await SearchTestUtils.updateRemoteSettingsConfig([ + { + webExtension: { id: "mozengine@tests.mozilla.org" }, + urls: { + trending: { + fullPath: + "https://example.com/browser/browser/components/search/test/browser/trendingSuggestionEngine.sjs", + query: "", + }, + }, + appliesTo: [{ included: { everywhere: true } }], + default: "yes", + }, + ]); + + await doTest(async browser => { + await openPopup(""); + await selectRowByProvider("SearchSuggestions"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "trending_search", + selected_result_subtype: "", + selected_position: 1, + provider: "SearchSuggestions", + results: "trending_search", + }, + ]); + }); + + await extension.unload(); + await Services.search.setDefault( + defaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + let settingsWritten = SearchTestUtils.promiseSearchNotification( + "write-settings-to-disk-complete" + ); + await SearchTestUtils.updateRemoteSettingsConfig(); + await settingsWritten; + await PlacesUtils.history.clear(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_trending_rich() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.richSuggestions.featureGate", true], + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.trending.featureGate", true], + ["browser.urlbar.trending.requireSearchMode", false], + ["browser.urlbar.trending.maxResultsNoSearchMode", 1], + ["browser.urlbar.weather.featureGate", false], + ], + }); + + let defaultEngine = await Services.search.getDefault(); + let extension = await SearchTestUtils.installSearchExtension( + { + name: "mozengine", + search_url: "https://example.org/", + }, + { setAsDefault: true, skipUnload: true } + ); + + SearchTestUtils.useMockIdleService(); + await SearchTestUtils.updateRemoteSettingsConfig([ + { + webExtension: { id: "mozengine@tests.mozilla.org" }, + urls: { + trending: { + fullPath: + "https://example.com/browser/browser/components/search/test/browser/trendingSuggestionEngine.sjs?richsuggestions=true", + query: "", + }, + }, + appliesTo: [{ included: { everywhere: true } }], + default: "yes", + }, + ]); + + await doTest(async browser => { + await openPopup(""); + await selectRowByProvider("SearchSuggestions"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "trending_search_rich", + selected_result_subtype: "", + selected_position: 1, + provider: "SearchSuggestions", + results: "trending_search_rich", + }, + ]); + }); + + await extension.unload(); + await Services.search.setDefault( + defaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + let settingsWritten = SearchTestUtils.promiseSearchNotification( + "write-settings-to-disk-complete" + ); + await SearchTestUtils.updateRemoteSettingsConfig(); + await settingsWritten; + await PlacesUtils.history.clear(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_addons() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.addons.featureGate", true], + ["browser.urlbar.suggest.searches", false], + ], + }); + + const cleanupQuickSuggest = await ensureQuickSuggestInit({ + merinoSuggestions: [ + { + provider: "amo", + icon: "https://example.com/good-addon.svg", + url: "https://example.com/good-addon", + title: "Good Addon", + description: "This is a good addon", + custom_details: { + amo: { + rating: "4.8", + number_of_ratings: "1234567", + guid: "good@addon", + }, + }, + is_top_pick: true, + }, + ], + }); + + await doTest(async browser => { + await openPopup("only match the Merino suggestion"); + await selectRowByProvider("UrlbarProviderQuickSuggest"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "merino_amo", + selected_result_subtype: "", + selected_position: 2, + provider: "UrlbarProviderQuickSuggest", + results: "search_engine,merino_amo", + }, + ]); + }); + + await cleanupQuickSuggest(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_rust_adm_sponsored() { + const cleanupQuickSuggest = await ensureQuickSuggestInit({ + prefs: [["quicksuggest.rustEnabled", true]], + }); + + await doTest(async browser => { + await openPopup("sponsored"); + await selectRowByURL("https://example.com/sponsored"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "rust_adm_sponsored", + selected_result_subtype: "", + selected_position: 2, + provider: "UrlbarProviderQuickSuggest", + results: "search_engine,rust_adm_sponsored", + }, + ]); + }); + + await cleanupQuickSuggest(); +}); + +add_task(async function selected_result_rust_adm_nonsponsored() { + const cleanupQuickSuggest = await ensureQuickSuggestInit({ + prefs: [["quicksuggest.rustEnabled", true]], + }); + + await doTest(async browser => { + await openPopup("nonsponsored"); + await selectRowByURL("https://example.com/nonsponsored"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "rust_adm_nonsponsored", + selected_result_subtype: "", + selected_position: 2, + provider: "UrlbarProviderQuickSuggest", + results: "search_engine,rust_adm_nonsponsored", + }, + ]); + }); + + await cleanupQuickSuggest(); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_tips.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_tips.js new file mode 100644 index 0000000000..2b38631747 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_tips.js @@ -0,0 +1,173 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for engagement telemetry for tips using Glean. + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/browser-tips/head.js", + this +); + +add_setup(async function () { + makeProfileResettable(); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.quickactions.enabled", false]], + }); + + registerCleanupFunction(async function () { + await SpecialPowers.popPrefEnv(); + }); +}); + +add_task(async function selected_result_tip() { + const testData = [ + { + type: "searchTip_onboard", + expected: "tip_onboard", + }, + { + type: "searchTip_persist", + expected: "tip_persist", + }, + { + type: "searchTip_redirect", + expected: "tip_redirect", + }, + { + type: "test", + expected: "tip_unknown", + }, + ]; + + for (const { type, expected } of testData) { + const deferred = Promise.withResolvers(); + const provider = new UrlbarTestUtils.TestProvider({ + results: [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + type, + helpUrl: "https://example.com/", + titleL10n: { id: "urlbar-search-tips-confirm" }, + buttons: [ + { + url: "https://example.com/", + l10n: { id: "urlbar-search-tips-confirm" }, + }, + ], + } + ), + ], + priority: 1, + onEngagement: () => { + deferred.resolve(); + }, + }); + UrlbarProvidersManager.registerProvider(provider); + + await doTest(async browser => { + await openPopup("example"); + await selectRowByType(type); + EventUtils.synthesizeKey("VK_RETURN"); + await deferred.promise; + + assertEngagementTelemetry([ + { + selected_result: expected, + results: expected, + }, + ]); + }); + + UrlbarProvidersManager.unregisterProvider(provider); + } +}); + +add_task(async function selected_result_intervention_clear() { + let useOldClearHistoryDialog = Services.prefs.getBoolPref( + "privacy.sanitize.useOldClearHistoryDialog" + ); + let dialogURL = useOldClearHistoryDialog + ? "chrome://browser/content/sanitize.xhtml" + : "chrome://browser/content/sanitize_v2.xhtml"; + await doInterventionTest( + SEARCH_STRINGS.CLEAR, + "intervention_clear", + dialogURL, + [ + { + selected_result: "intervention_clear", + results: "search_engine,intervention_clear", + }, + ] + ); +}); + +add_task(async function selected_result_intervention_refresh() { + await doInterventionTest( + SEARCH_STRINGS.REFRESH, + "intervention_refresh", + "chrome://global/content/resetProfile.xhtml", + [ + { + selected_result: "intervention_refresh", + results: "search_engine,intervention_refresh", + }, + ] + ); +}); + +add_task(async function selected_result_intervention_update() { + // Updates are disabled for MSIX packages, this test is irrelevant for them. + if ( + AppConstants.platform === "win" && + Services.sysinfo.getProperty("hasWinPackageId") + ) { + return; + } + await UpdateUtils.setAppUpdateAutoEnabled(false); + await initUpdate({ queryString: "&noUpdates=1" }); + UrlbarProviderInterventions.checkForBrowserUpdate(true); + await processUpdateSteps([ + { + panelId: "checkingForUpdates", + checkActiveUpdate: null, + continueFile: CONTINUE_CHECK, + }, + { + panelId: "noUpdatesFound", + checkActiveUpdate: null, + continueFile: null, + }, + ]); + + await doInterventionTest( + SEARCH_STRINGS.UPDATE, + "intervention_update_refresh", + "chrome://global/content/resetProfile.xhtml", + [ + { + selected_result: "intervention_update", + results: "search_engine,intervention_update", + }, + ] + ); +}); + +async function doInterventionTest(keyword, type, dialog, expectedTelemetry) { + await doTest(async browser => { + await openPopup(keyword); + await selectRowByType(type); + const onDialog = BrowserTestUtils.promiseAlertDialog("cancel", dialog, { + isSubDialog: true, + }); + EventUtils.synthesizeKey("VK_RETURN"); + await onDialog; + + assertEngagementTelemetry(expectedTelemetry); + }); +} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_type.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_type.js new file mode 100644 index 0000000000..5972dd331d --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_type.js @@ -0,0 +1,118 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of engagement telemetry. +// - engagement_type + +// This test has many subtests and can time out in verify mode. +requestLongerTimeout(5); + +add_setup(async function () { + await setup(); +}); + +add_task(async function engagement_type_click() { + await doTest(async browser => { + await openPopup("x"); + await doClick(); + + assertEngagementTelemetry([{ engagement_type: "click" }]); + }); +}); + +add_task(async function engagement_type_enter() { + await doTest(async browser => { + await openPopup("x"); + await doEnter(); + + assertEngagementTelemetry([{ engagement_type: "enter" }]); + }); +}); + +add_task(async function engagement_type_go_button() { + await doTest(async browser => { + await openPopup("x"); + EventUtils.synthesizeMouseAtCenter(gURLBar.goButton, {}); + + assertEngagementTelemetry([{ engagement_type: "go_button" }]); + }); +}); + +add_task(async function engagement_type_drop_go() { + await doTest(async browser => { + await doDropAndGo("example.com"); + + assertEngagementTelemetry([{ engagement_type: "drop_go" }]); + }); +}); + +add_task(async function engagement_type_paste_go() { + await doTest(async browser => { + await doPasteAndGo("www.example.com"); + + assertEngagementTelemetry([{ engagement_type: "paste_go" }]); + }); +}); + +add_task(async function engagement_type_dismiss() { + const cleanupQuickSuggest = await ensureQuickSuggestInit(); + + await doTest(async browser => { + await openPopup("sponsored"); + + const originalResultCount = UrlbarTestUtils.getResultCount(window); + await selectRowByURL("https://example.com/sponsored"); + UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "D"); + await BrowserTestUtils.waitForCondition( + () => originalResultCount != UrlbarTestUtils.getResultCount(window) + ); + + assertEngagementTelemetry([{ engagement_type: "dismiss" }]); + + // The view should stay open after dismissing the result. Now pick the + // heuristic result. Another "click" engagement event should be recorded. + Assert.ok( + gURLBar.view.isOpen, + "View should remain open after dismissing result" + ); + await doClick(); + assertEngagementTelemetry([ + { engagement_type: "dismiss" }, + { engagement_type: "click", interaction: "typed" }, + ]); + }); + + await doTest(async browser => { + await openPopup("sponsored"); + + const originalResultCount = UrlbarTestUtils.getResultCount(window); + await selectRowByURL("https://example.com/sponsored"); + EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true }); + await BrowserTestUtils.waitForCondition( + () => originalResultCount != UrlbarTestUtils.getResultCount(window) + ); + + assertEngagementTelemetry([{ engagement_type: "dismiss" }]); + }); + + await cleanupQuickSuggest(); +}); + +add_task(async function engagement_type_help() { + const cleanupQuickSuggest = await ensureQuickSuggestInit(); + + await doTest(async browser => { + await openPopup("sponsored"); + await selectRowByURL("https://example.com/sponsored"); + const onTabOpened = BrowserTestUtils.waitForNewTab(gBrowser); + UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "L"); + const tab = await onTabOpened; + BrowserTestUtils.removeTab(tab); + + assertEngagementTelemetry([{ engagement_type: "help" }]); + }); + + await cleanupQuickSuggest(); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_exposure.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_exposure.js new file mode 100644 index 0000000000..07e8b9b360 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_exposure.js @@ -0,0 +1,136 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const SPONSORED_QUERY = "sponsored"; +const NONSPONSORED_QUERY = "nonsponsored"; + +// test for exposure events +add_setup(async function () { + await initExposureTest(); +}); + +add_task(async function exposureSponsoredOnEngagement() { + await doExposureTest({ + prefs: [ + ["browser.urlbar.exposureResults", suggestResultType("adm_sponsored")], + ["browser.urlbar.showExposureResults", true], + ], + query: SPONSORED_QUERY, + trigger: () => doClick(), + assert: () => + assertExposureTelemetry([ + { results: suggestResultType("adm_sponsored") }, + ]), + }); +}); + +add_task(async function exposureSponsoredOnAbandonment() { + await doExposureTest({ + prefs: [ + ["browser.urlbar.exposureResults", suggestResultType("adm_sponsored")], + ["browser.urlbar.showExposureResults", true], + ], + query: SPONSORED_QUERY, + trigger: () => doBlur(), + assert: () => + assertExposureTelemetry([ + { results: suggestResultType("adm_sponsored") }, + ]), + }); +}); + +add_task(async function exposureFilter() { + await doExposureTest({ + prefs: [ + ["browser.urlbar.exposureResults", suggestResultType("adm_sponsored")], + ["browser.urlbar.showExposureResults", false], + ], + query: SPONSORED_QUERY, + select: async () => { + // assert that the urlbar has no results + Assert.equal( + await getResultByType(suggestResultType("adm_sponsored")), + null + ); + }, + trigger: () => doBlur(), + assert: () => + assertExposureTelemetry([ + { results: suggestResultType("adm_sponsored") }, + ]), + }); +}); + +add_task(async function innerQueryExposure() { + await doExposureTest({ + prefs: [ + ["browser.urlbar.exposureResults", suggestResultType("adm_sponsored")], + ["browser.urlbar.showExposureResults", true], + ], + query: NONSPONSORED_QUERY, + select: () => {}, + trigger: async () => { + // delete the old query + gURLBar.select(); + EventUtils.synthesizeKey("KEY_Backspace"); + await openPopup(SPONSORED_QUERY); + await defaultSelect(SPONSORED_QUERY); + await doClick(); + }, + assert: () => + assertExposureTelemetry([ + { results: suggestResultType("adm_sponsored") }, + ]), + }); +}); + +add_task(async function innerQueryInvertedExposure() { + await doExposureTest({ + prefs: [ + ["browser.urlbar.exposureResults", suggestResultType("adm_sponsored")], + ["browser.urlbar.showExposureResults", true], + ], + query: SPONSORED_QUERY, + select: () => {}, + trigger: async () => { + // delete the old query + gURLBar.select(); + EventUtils.synthesizeKey("KEY_Backspace"); + await openPopup(NONSPONSORED_QUERY); + await defaultSelect(SPONSORED_QUERY); + await doClick(); + }, + assert: () => + assertExposureTelemetry([ + { results: suggestResultType("adm_sponsored") }, + ]), + }); +}); + +add_task(async function multipleProviders() { + await doExposureTest({ + prefs: [ + [ + "browser.urlbar.exposureResults", + [ + suggestResultType("adm_sponsored"), + suggestResultType("adm_nonsponsored"), + ].join(","), + ], + ["browser.urlbar.showExposureResults", true], + ], + query: NONSPONSORED_QUERY, + trigger: () => doClick(), + assert: () => + assertExposureTelemetry([ + { results: suggestResultType("adm_nonsponsored") }, + ]), + }); +}); + +function suggestResultType(typeWithoutSource) { + let source = UrlbarPrefs.get("quickSuggestRustEnabled") ? "rust" : "rs"; + return `${source}_${typeWithoutSource}`; +} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_exposure_edge_cases.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_exposure_edge_cases.js new file mode 100644 index 0000000000..d28352b417 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_exposure_edge_cases.js @@ -0,0 +1,539 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests edge cases related to the exposure event and view updates. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarView: "resource:///modules/UrlbarView.sys.mjs", +}); + +const MAX_RESULT_COUNT = 10; + +let gProvider; + +add_setup(async function () { + await initExposureTest(); + + await SpecialPowers.pushPrefEnv({ + set: [ + // Make absolutely sure the panel stays open during the test. There are + // spurious blurs on WebRender TV tests as the test starts that cause the + // panel to close and the query to be canceled, resulting in intermittent + // failures without this. + ["ui.popup.disable_autohide", true], + + // Set maxRichResults for sanity. + ["browser.urlbar.maxRichResults", MAX_RESULT_COUNT], + ], + }); + + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + gProvider = new TestProvider(); + UrlbarProvidersManager.registerProvider(gProvider); + + // Increase the timeout of the stale-rows timer so it doesn't interfere with + // this test, which specifically tests what happens before the timer fires. + let originalRemoveStaleRowsTimeout = UrlbarView.removeStaleRowsTimeout; + UrlbarView.removeStaleRowsTimeout = 30000; + + registerCleanupFunction(() => { + UrlbarView.removeStaleRowsTimeout = originalRemoveStaleRowsTimeout; + UrlbarProvidersManager.unregisterProvider(gProvider); + }); +}); + +// Does one query that fills up the view with search suggestions, starts a +// second query that returns a history result, and cancels it before it can +// finish but after the view is updated. Regardless of `showExposureResults`, +// the history result should not trigger an exposure since it never had a chance +// to be visible in the view. +add_task(async function noExposure() { + for (let showExposureResults of [true, false]) { + await do_noExposure(showExposureResults); + } +}); + +async function do_noExposure(showExposureResults) { + info("Starting do_noExposure: " + JSON.stringify({ showExposureResults })); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.exposureResults", "history"], + ["browser.urlbar.showExposureResults", showExposureResults], + ], + }); + + // Make the provider return enough search suggestions to fill the view. + gProvider.results = []; + for (let i = 0; i < MAX_RESULT_COUNT; i++) { + gProvider.results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + suggestion: "suggestion " + i, + engine: Services.search.defaultEngine.name, + } + ) + ); + } + + // Do the first query to fill the view with search suggestions. + info("Doing first query"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test 1", + }); + + // Now make the provider return a history result and bookmark. If + // `showExposureResults` is true, the history result will be added to the view + // but it should be hidden since the view is already full. If it's false, it + // shouldn't be added at all. The bookmark will always be added, which will + // tell us when the view has been updated either way. (It also will be hidden + // since the view is already full.) + let historyUrl = "https://example.com/history"; + let bookmarkUrl = "https://example.com/bookmark"; + gProvider.results = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: historyUrl } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + { url: bookmarkUrl } + ), + ]; + + // When the provider's `startQuery()` is called, let it add its results but + // don't let it return. That will cause the view to be updated with the new + // results but prevent it from showing hidden rows since the query won't + // finish. + let queryResolver = Promise.withResolvers(); + gProvider.finishQueryPromise = queryResolver.promise; + + // Observe when the view appends the bookmark row. This will tell us when the + // view has been updated with the provider's new results. The bookmark row + // will be hidden since the view is already full with search suggestions. + let lastRowPromise = promiseLastRowAppended( + row => row.result.payload.url == bookmarkUrl + ); + + // Now start the second query but don't await it. + info("Starting second query"); + let queryPromise = UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test 2", + reopenOnBlur: false, + }); + + // Wait for the view to be updated. + info("Waiting for last row"); + let lastRow = await lastRowPromise; + info("Done waiting for last row"); + + Assert.ok( + BrowserTestUtils.isHidden(lastRow), + "The new bookmark row should be hidden since the view is full" + ); + + // Make sure the view is full of visible rows as expected, plus the one or two + // hidden rows for the history and/or bookmark results. + let rows = UrlbarTestUtils.getResultsContainer(window); + let expectedCount = MAX_RESULT_COUNT + 1; + if (showExposureResults) { + expectedCount++; + } + Assert.equal( + rows.children.length, + expectedCount, + "The view has the expected number of rows" + ); + + // Check the visible rows. + for (let i = 0; i < MAX_RESULT_COUNT; i++) { + let row = rows.children[i]; + Assert.ok(BrowserTestUtils.isVisible(row), `rows[${i}] should be visible`); + Assert.ok( + row.result.type == UrlbarUtils.RESULT_TYPE.SEARCH, + `rows[${i}].result.type should be SEARCH` + ); + // The heuristic won't have a suggestion so skip it. + if (i > 0) { + Assert.ok( + row.result.payload.suggestion, + `rows[${i}] should have a suggestion` + ); + } + } + + // Check the hidden history and/or bookmark rows. + let expected = [ + { source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, url: bookmarkUrl }, + ]; + if (showExposureResults) { + expected.unshift({ + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: historyUrl, + }); + } + for (let i = 0; i < expected.length; i++) { + let { source, url } = expected[i]; + let row = rows.children[MAX_RESULT_COUNT + i]; + Assert.ok(row, `rows[${i}] should exist`); + Assert.ok(BrowserTestUtils.isHidden(row), `rows[${i}] should be hidden`); + Assert.equal( + row.result.type, + UrlbarUtils.RESULT_TYPE.URL, + `rows[${i}].result.type should be URL` + ); + Assert.equal( + row.result.source, + source, + `rows[${i}].result.source should be as expected` + ); + Assert.equal( + row.result.payload.url, + url, + `rows[${i}] URL should be as expected` + ); + } + + // Close the view. Blur the urlbar to end the session. + info("Closing view and blurring"); + await UrlbarTestUtils.promisePopupClose(window); + gURLBar.blur(); + + // No exposure should have been recorded since the history result was never + // visible. + assertExposureTelemetry([]); + + // Clean up. + queryResolver.resolve(); + await queryPromise; + await SpecialPowers.popPrefEnv(); + Services.fog.testResetFOG(); +} + +// Does one query that underfills the view and then a second query that returns +// a search suggestion. The search suggestion should be appended and trigger an +// exposure. When `showExposureResults` is true, it should also be shown. After +// the view is updated, it shouldn't matter whether or not the second query is +// canceled. +add_task(async function exposure_append() { + for (let showExposureResults of [true, false]) { + for (let cancelSecondQuery of [true, false]) { + await do_exposure_append({ + showExposureResults, + cancelSecondQuery, + }); + } + } +}); + +async function do_exposure_append({ showExposureResults, cancelSecondQuery }) { + info( + "Starting do_exposure_append: " + + JSON.stringify({ showExposureResults, cancelSecondQuery }) + ); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.exposureResults", "search_suggest"], + ["browser.urlbar.showExposureResults", showExposureResults], + ], + }); + + // Make the provider return no results at first. + gProvider.results = []; + + // Do the first query to open the view. + info("Doing first query"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test 1", + }); + + // Now make the provider return a search suggestion and a bookmark. If + // `showExposureResults` is true, the suggestion should be added to the view + // and be visible immediately. If it's false, it shouldn't be added at + // all. The bookmark will always be added, which will tell us when the view + // has been updated either way. + let newSuggestion = "new suggestion"; + let bookmarkUrl = "https://example.com/bookmark"; + gProvider.results = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + suggestion: newSuggestion, + engine: Services.search.defaultEngine.name, + } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + { url: bookmarkUrl } + ), + ]; + + // When the provider's `startQuery()` is called, let it add its results but + // don't let it return. That will cause the view to be updated with the new + // results but let us test the specific case where the query doesn't finish. + let queryResolver = Promise.withResolvers(); + gProvider.finishQueryPromise = queryResolver.promise; + + // Observe when the view appends the bookmark row. This will tell us when the + // view has been updated with the provider's new results. + let lastRowPromise = promiseLastRowAppended( + row => row.result.payload.url == bookmarkUrl + ); + + // Now start the second query but don't await it. + info("Starting second query"); + let queryPromise = UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test 2", + reopenOnBlur: false, + }); + + // Wait for the view to be updated. + info("Waiting for last row"); + let lastRow = await lastRowPromise; + info("Done waiting for last row"); + + Assert.ok( + BrowserTestUtils.isVisible(lastRow), + "The new bookmark row should be visible since the view is not full" + ); + + // Check the new suggestion row. + let rows = UrlbarTestUtils.getResultsContainer(window); + let newSuggestionRow = [...rows.children].find( + r => r.result.payload.suggestion == newSuggestion + ); + if (showExposureResults) { + Assert.ok( + newSuggestionRow, + "The new suggestion row should have been added" + ); + Assert.ok( + BrowserTestUtils.isVisible(newSuggestionRow), + "The new suggestion row should be visible" + ); + } else { + Assert.ok( + !newSuggestionRow, + "The new suggestion row should not have been added" + ); + } + + if (!cancelSecondQuery) { + // Finish the query. + queryResolver.resolve(); + await queryPromise; + } + + // Close the view. Blur the urlbar to end the session. + info("Closing view and blurring"); + await UrlbarTestUtils.promisePopupClose(window); + gURLBar.blur(); + + // If `showExposureResults` is true, the new search suggestion should have + // been shown; if it's false, it would have been shown. Either way, it should + // have triggered an exposure. + assertExposureTelemetry([{ results: "search_suggest" }]); + + // Clean up. + queryResolver.resolve(); + await queryPromise; + await SpecialPowers.popPrefEnv(); + Services.fog.testResetFOG(); +} + +// Does one query that returns a search suggestion and then a second query that +// returns a new search suggestion. The new search suggestion can replace the +// old one, so it should trigger an exposure. When `showExposureResults` is +// true, it should actually replace it. After the view is updated, it shouldn't +// matter whether or not the second query is canceled. +add_task(async function exposure_replace() { + for (let showExposureResults of [true, false]) { + for (let cancelSecondQuery of [true, false]) { + await do_exposure_replace({ showExposureResults, cancelSecondQuery }); + } + } +}); + +async function do_exposure_replace({ showExposureResults, cancelSecondQuery }) { + info( + "Starting do_exposure_replace: " + + JSON.stringify({ showExposureResults, cancelSecondQuery }) + ); + + // Make the provider return a search suggestion. + gProvider.results = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + suggestion: "suggestion", + engine: Services.search.defaultEngine.name, + } + ), + ]; + + // Do the first query to show the suggestion. + info("Doing first query"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test 1", + }); + + // Set exposure results to search suggestions and hide them. We can't do this + // before now because that would hide the search suggestions in the first + // query, and here we're specifically testing the case where a new row + // replaces an old row, which is only allowed for rows of the same type. + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.exposureResults", "search_suggest"], + ["browser.urlbar.showExposureResults", showExposureResults], + ], + }); + + // Now make the provider return another search suggestion and a bookmark. If + // `showExposureResults` is true, the new suggestion should replace the old + // one in the view and be visible immediately. If it's false, it shouldn't be + // added at all. The bookmark will always be added, which will tell us when + // the view has been updated either way. + let newSuggestion = "new suggestion"; + let bookmarkUrl = "https://example.com/bookmark"; + gProvider.results = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + suggestion: newSuggestion, + engine: Services.search.defaultEngine.name, + } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + { url: bookmarkUrl } + ), + ]; + + // When the provider's `startQuery()` is called, let it add its results but + // don't let it return. That will cause the view to be updated with the new + // results but let us test the specific case where the query doesn't finish. + let queryResolver = Promise.withResolvers(); + gProvider.finishQueryPromise = queryResolver.promise; + + // Observe when the view appends the bookmark row. This will tell us when the + // view has been updated with the provider's new results. + let lastRowPromise = promiseLastRowAppended( + row => row.result.payload.url == bookmarkUrl + ); + + // Now start the second query but don't await it. + info("Starting second query"); + let queryPromise = UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test 2", + reopenOnBlur: false, + }); + + // Wait for the view to be updated. + info("Waiting for last row"); + let lastRow = await lastRowPromise; + info("Done waiting for last row"); + + Assert.ok( + BrowserTestUtils.isVisible(lastRow), + "The new bookmark row should be visible since the view is not full" + ); + + // Check the new suggestion row. + let rows = UrlbarTestUtils.getResultsContainer(window); + let newSuggestionRow = [...rows.children].find( + r => r.result.payload.suggestion == newSuggestion + ); + if (showExposureResults) { + Assert.ok( + newSuggestionRow, + "The new suggestion row should have replaced the old one" + ); + Assert.ok( + BrowserTestUtils.isVisible(newSuggestionRow), + "The new suggestion row should be visible" + ); + } else { + Assert.ok( + !newSuggestionRow, + "The new suggestion row should not have been added" + ); + } + + if (!cancelSecondQuery) { + // Finish the query. + queryResolver.resolve(); + await queryPromise; + } + + // Close the view. Blur the urlbar to end the session. + info("Closing view and blurring"); + await UrlbarTestUtils.promisePopupClose(window); + gURLBar.blur(); + + // If `showExposureResults` is true, the new search suggestion should have + // been shown; if it's false, it would have been shown. Either way, it should + // have triggered an exposure. + assertExposureTelemetry([{ results: "search_suggest" }]); + + // Clean up. + queryResolver.resolve(); + await queryPromise; + await SpecialPowers.popPrefEnv(); + Services.fog.testResetFOG(); +} + +/** + * A test provider that doesn't finish startQuery() until `finishQueryPromise` + * is resolved. + */ +class TestProvider extends UrlbarTestUtils.TestProvider { + finishQueryPromise = null; + + async startQuery(context, addCallback) { + for (let result of this.results) { + addCallback(this, result); + } + await this.finishQueryPromise; + } +} + +function promiseLastRowAppended(predicate) { + return new Promise(resolve => { + let rows = UrlbarTestUtils.getResultsContainer(window); + let observer = new MutationObserver(mutations => { + let lastRow = rows.children[rows.children.length - 1]; + info( + "Observed mutation, lastRow.result is: " + + JSON.stringify(lastRow.result) + ); + if (predicate(lastRow)) { + observer.disconnect(); + resolve(lastRow); + } + }); + observer.observe(rows, { childList: true }); + }); +} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_groups.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_groups.js new file mode 100644 index 0000000000..354876e512 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_groups.js @@ -0,0 +1,258 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of impression telemetry. +// - groups +// - results +// - n_results + +// This test has many subtests and can time out in verify mode. +requestLongerTimeout(5); + +add_setup(async function () { + await initGroupTest(); + // Increase the pausing time to ensure to ready for all suggestions. + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.urlbar.searchEngagementTelemetry.pauseImpressionIntervalMs", + 500, + ], + ], + }); +}); + +add_task(async function heuristics() { + await doHeuristicsTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { reason: "pause", groups: "heuristic", results: "search_engine" }, + ]), + }); +}); + +add_task(async function adaptive_history() { + await doAdaptiveHistoryTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "heuristic,adaptive_history", + results: "search_engine,history", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function search_history() { + await doSearchHistoryTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "heuristic,search_history,search_history", + results: "search_engine,search_history,search_history", + n_results: 3, + }, + ]), + }); +}); + +add_task(async function recent_search() { + await doRecentSearchTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "recent_search,suggested_index", + results: "recent_search,action", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function search_suggest() { + await doSearchSuggestTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "heuristic,search_suggest,search_suggest", + results: "search_engine,search_suggest,search_suggest", + n_results: 3, + }, + ]), + }); + + await doTailSearchSuggestTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "heuristic,search_suggest", + results: "search_engine,search_suggest", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function top_pick() { + await doTopPickTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "heuristic,top_pick,search_suggest,search_suggest", + results: + "search_engine,merino_top_picks,search_suggest,search_suggest", + n_results: 4, + }, + ]), + }); +}); + +add_task(async function top_site() { + await doTopSiteTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "top_site,suggested_index", + results: "top_site,action", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function clipboard() { + await doClipboardTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "general,suggested_index", + results: "clipboard,action", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function remote_tab() { + await doRemoteTabTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "heuristic,remote_tab", + results: "search_engine,remote_tab", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function addon() { + await doAddonTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "addon", + results: "addon", + n_results: 1, + }, + ]), + }); +}); + +add_task(async function general() { + await doGeneralBookmarkTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "heuristic,suggested_index,general", + results: "search_engine,action,bookmark", + n_results: 3, + }, + ]), + }); + + await doGeneralHistoryTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "heuristic,general", + results: "search_engine,history", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function suggest() { + await doSuggestTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + groups: "heuristic,suggest", + results: UrlbarPrefs.get("quickSuggestRustEnabled") + ? "search_engine,rust_adm_nonsponsored" + : "search_engine,rs_adm_nonsponsored", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function about_page() { + await doAboutPageTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "heuristic,about_page,about_page", + results: "search_engine,history,history", + n_results: 3, + }, + ]), + }); +}); + +add_task(async function suggested_index() { + await doSuggestedIndexTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "heuristic,suggested_index", + results: "search_engine,unit", + n_results: 2, + }, + ]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction.js new file mode 100644 index 0000000000..a16b55cac6 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of impression telemetry. +// - interaction + +add_setup(async function () { + await initInteractionTest(); +}); + +add_task(async function topsites() { + await doTopsitesTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", interaction: "topsites" }]), + }); +}); + +add_task(async function typed() { + await doTypedTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", interaction: "typed" }]), + }); + + await doTypedWithResultsPopupTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", interaction: "typed" }]), + }); +}); + +add_task(async function pasted() { + await doPastedTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", interaction: "pasted" }]), + }); + + await doPastedWithResultsPopupTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", interaction: "pasted" }]), + }); +}); + +add_task(async function topsite_search() { + await doTopsitesSearchTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { reason: "pause", interaction: "topsite_search" }, + ]), + }); +}); + +add_task(async function returned_restarted_refined() { + await doReturnedRestartedRefinedTest({ + trigger: () => waitForPauseImpression(), + assert: expected => + assertImpressionTelemetry([ + { reason: "pause" }, + { reason: "pause", interaction: expected }, + ]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction_persisted_search_terms_disabled.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction_persisted_search_terms_disabled.js new file mode 100644 index 0000000000..af7134b3a0 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction_persisted_search_terms_disabled.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test impression telemetry with persisted search terms disabled. + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + await initInteractionTest(); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", false]], + }); +}); + +add_task(async function persisted_search_terms() { + await doPersistedSearchTermsTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { reason: "pause" }, + { reason: "pause", interaction: "typed" }, + ]), + }); +}); + +add_task(async function persisted_search_terms_restarted_refined() { + await doPersistedSearchTermsRestartedRefinedTest({ + enabled: false, + trigger: () => waitForPauseImpression(), + assert: expected => + assertImpressionTelemetry([ + { reason: "pause" }, + { reason: "pause", interaction: expected }, + ]), + }); +}); + +add_task( + async function persisted_search_terms_restarted_refined_via_abandonment() { + await doPersistedSearchTermsRestartedRefinedViaAbandonmentTest({ + enabled: false, + trigger: () => waitForPauseImpression(), + assert: expected => + assertImpressionTelemetry([ + { reason: "pause" }, + { reason: "pause" }, + { reason: "pause", interaction: expected }, + ]), + }); + } +); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction_persisted_search_terms_enabled.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction_persisted_search_terms_enabled.js new file mode 100644 index 0000000000..a29ff98b78 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction_persisted_search_terms_enabled.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test impression telemetry with persisted search terms enabled. + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + await initInteractionTest(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.showSearchTerms.featureGate", true], + ["browser.urlbar.showSearchTerms.enabled", true], + ["browser.search.widget.inNavBar", false], + ], + }); +}); + +add_task(async function interaction_persisted_search_terms() { + await doPersistedSearchTermsTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { reason: "pause" }, + { reason: "pause", interaction: "persisted_search_terms" }, + ]), + }); +}); + +add_task(async function interaction_persisted_search_terms_restarted_refined() { + await doPersistedSearchTermsRestartedRefinedTest({ + enabled: true, + trigger: () => waitForPauseImpression(), + assert: expected => + assertImpressionTelemetry([ + { reason: "pause" }, + { reason: "pause", interaction: expected }, + ]), + }); +}); + +add_task( + async function interaction_persisted_search_terms_restarted_refined_via_abandonment() { + await doPersistedSearchTermsRestartedRefinedViaAbandonmentTest({ + enabled: true, + trigger: () => waitForPauseImpression(), + assert: expected => + assertImpressionTelemetry([ + { reason: "pause" }, + { reason: "pause" }, + { reason: "pause", interaction: expected }, + ]), + }); + } +); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_n_chars_n_words.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_n_chars_n_words.js new file mode 100644 index 0000000000..528cc318e0 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_n_chars_n_words.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of impression telemetry. +// - n_chars +// - n_words + +add_setup(async function () { + await initNCharsAndNWordsTest(); +}); + +add_task(async function n_chars() { + await doNCharsTest({ + trigger: () => waitForPauseImpression(), + assert: nChars => + assertImpressionTelemetry([{ reason: "pause", n_chars: nChars }]), + }); + + await doNCharsWithOverMaxTextLengthCharsTest({ + trigger: () => waitForPauseImpression(), + assert: nChars => + assertImpressionTelemetry([{ reason: "pause", n_chars: nChars }]), + }); +}); + +add_task(async function n_words() { + await doNWordsTest({ + trigger: () => waitForPauseImpression(), + assert: nWords => + assertImpressionTelemetry([{ reason: "pause", n_words: nWords }]), + }); + + await doNWordsWithOverMaxTextLengthCharsTest({ + trigger: () => waitForPauseImpression(), + assert: nWords => + assertImpressionTelemetry([{ reason: "pause", n_words: nWords }]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_preferences.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_preferences.js new file mode 100644 index 0000000000..344e238e24 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_preferences.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the impression telemetry behavior with its preferences. + +add_setup(async function () { + await setup(); +}); + +add_task(async function pauseImpressionIntervalMs() { + const additionalInterval = 1000; + const originalInterval = UrlbarPrefs.get( + "searchEngagementTelemetry.pauseImpressionIntervalMs" + ); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.urlbar.searchEngagementTelemetry.pauseImpressionIntervalMs", + originalInterval + additionalInterval, + ], + ], + }); + + await doTest(async browser => { + await openPopup("https://example.com"); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, originalInterval)); + await Services.fog.testFlushAllChildren(); + assertImpressionTelemetry([]); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, additionalInterval)); + await Services.fog.testFlushAllChildren(); + assertImpressionTelemetry([{ sap: "urlbar_newtab" }]); + }); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_sap.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_sap.js new file mode 100644 index 0000000000..482b906024 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_sap.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of impression telemetry. +// - sap + +add_setup(async function () { + await initSapTest(); +}); + +add_task(async function urlbar() { + await doUrlbarTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { reason: "pause", sap: "urlbar_newtab" }, + { reason: "pause", sap: "urlbar" }, + ]), + }); +}); + +add_task(async function handoff() { + await doHandoffTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", sap: "handoff" }]), + }); +}); + +add_task(async function urlbar_addonpage() { + await doUrlbarAddonpageTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", sap: "urlbar_addonpage" }]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_search_engine_default_id.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_search_engine_default_id.js new file mode 100644 index 0000000000..c5bd983d7f --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_search_engine_default_id.js @@ -0,0 +1,28 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of impression telemetry. +// - search_engine_default_id + +add_setup(async function () { + await initSearchEngineDefaultIdTest(); + // Increase the pausing time to ensure to ready for all suggestions. + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.urlbar.searchEngagementTelemetry.pauseImpressionIntervalMs", + 500, + ], + ], + }); +}); + +add_task(async function basic() { + await doSearchEngineDefaultIdTest({ + trigger: () => waitForPauseImpression(), + assert: engineId => + assertImpressionTelemetry([{ search_engine_default_id: engineId }]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_search_mode.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_search_mode.js new file mode 100644 index 0000000000..727afa3cef --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_search_mode.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of impression telemetry. +// - search_mode + +add_setup(async function () { + await initSearchModeTest(); + // Increase the pausing time to ensure entering search mode. + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.urlbar.searchEngagementTelemetry.pauseImpressionIntervalMs", + 1000, + ], + ], + }); +}); + +add_task(async function not_search_mode() { + await doNotSearchModeTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", search_mode: "" }]), + }); +}); + +add_task(async function search_engine() { + await doSearchEngineTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { reason: "pause", search_mode: "search_engine" }, + ]), + }); +}); + +add_task(async function bookmarks() { + await doBookmarksTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { reason: "pause", search_mode: "bookmarks" }, + ]), + }); +}); + +add_task(async function history() { + await doHistoryTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", search_mode: "history" }]), + }); +}); + +add_task(async function tabs() { + await doTabTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", search_mode: "tabs" }]), + }); +}); + +add_task(async function actions() { + await doActionsTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", search_mode: "actions" }]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_timing.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_timing.js new file mode 100644 index 0000000000..31f64996f3 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_timing.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the taking timing for the impression telemetry. + +add_setup(async function () { + await setup(); +}); + +add_task(async function cancelImpressionTimerByEngagementEvent() { + const additionalInterval = 1000; + const originalInterval = UrlbarPrefs.get( + "searchEngagementTelemetry.pauseImpressionIntervalMs" + ); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.urlbar.searchEngagementTelemetry.pauseImpressionIntervalMs", + originalInterval + additionalInterval, + ], + ], + }); + + for (const trigger of [doEnter, doBlur]) { + await doTest(async browser => { + await openPopup("https://example.com"); + await trigger(); + + // Check whether the impression timer was canceled. + await new Promise(r => + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(r, originalInterval + additionalInterval) + ); + assertImpressionTelemetry([]); + }); + } + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function cancelInpressionTimerByType() { + const originalInterval = UrlbarPrefs.get( + "searchEngagementTelemetry.pauseImpressionIntervalMs" + ); + + await doTest(async browser => { + await openPopup("x"); + await new Promise(r => + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(r, originalInterval / 10) + ); + assertImpressionTelemetry([]); + + EventUtils.synthesizeKey(" "); + EventUtils.synthesizeKey("z"); + await UrlbarTestUtils.promiseSearchComplete(window); + assertImpressionTelemetry([]); + await waitForPauseImpression(); + + assertImpressionTelemetry([{ n_chars: 3 }]); + }); +}); + +add_task(async function oneImpressionInOneSession() { + await doTest(async browser => { + await openPopup("x"); + await waitForPauseImpression(); + + // Sanity check. + assertImpressionTelemetry([{ n_chars: 1 }]); + + // Add a keyword to start new query. + EventUtils.synthesizeKey(" "); + EventUtils.synthesizeKey("z"); + await UrlbarTestUtils.promiseSearchComplete(window); + await waitForPauseImpression(); + + // No more taking impression telemetry. + assertImpressionTelemetry([{ n_chars: 1 }]); + + // Finish the current session. + await doEnter(); + + // Should take pause impression since new session started. + await openPopup("x z y"); + await waitForPauseImpression(); + assertImpressionTelemetry([{ n_chars: 1 }, { n_chars: 5 }]); + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_record_preferences.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_record_preferences.js new file mode 100644 index 0000000000..88adc2fc11 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_record_preferences.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for preference telemetry. + +add_setup(async function () { + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + // Create a new window in order to initialize TelemetryEvent of + // UrlbarController. + const win = await BrowserTestUtils.openNewBrowserWindow(); + registerCleanupFunction(async function () { + await BrowserTestUtils.closeWindow(win); + }); +}); + +add_task(async function prefMaxRichResults() { + Assert.equal( + Glean.urlbar.prefMaxResults.testGetValue(), + UrlbarPrefs.get("maxRichResults"), + "Record prefMaxResults when UrlbarController is initialized" + ); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxRichResults", 0]], + }); + Assert.equal( + Glean.urlbar.prefMaxResults.testGetValue(), + UrlbarPrefs.get("maxRichResults"), + "Record prefMaxResults when the maxRichResults pref is updated" + ); +}); + +add_task(async function boolPref() { + const testData = [ + { + green: "prefSuggestDataCollection", + pref: "quicksuggest.dataCollection.enabled", + }, + { + green: "prefSuggestNonsponsored", + pref: "suggest.quicksuggest.nonsponsored", + }, + { + green: "prefSuggestSponsored", + pref: "suggest.quicksuggest.sponsored", + }, + { + green: "prefSuggestTopsites", + pref: "suggest.topsites", + }, + ]; + + for (const { green, pref } of testData) { + Assert.equal( + Glean.urlbar[green].testGetValue(), + UrlbarPrefs.get(pref), + `Record ${green} when UrlbarController is initialized` + ); + + await SpecialPowers.pushPrefEnv({ + set: [[`browser.urlbar.${pref}`, !UrlbarPrefs.get(pref)]], + }); + + Assert.equal( + Glean.urlbar[green].testGetValue(), + UrlbarPrefs.get(pref), + `Record ${green} when the ${pref} pref is updated` + ); + } +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/head-exposure.js b/browser/components/urlbar/tests/engagementTelemetry/browser/head-exposure.js new file mode 100644 index 0000000000..f0723be701 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/head-exposure.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from head.js */ + +async function doExposureTest({ + prefs, + query, + trigger, + assert, + select = defaultSelect, +}) { + const cleanupQuickSuggest = await ensureQuickSuggestInit(); + await SpecialPowers.pushPrefEnv({ + set: prefs, + }); + + await doTest(async () => { + await openPopup(query); + await select(query); + + await trigger(); + await assert(); + }); + + await SpecialPowers.popPrefEnv(); + await cleanupQuickSuggest(); +} + +async function defaultSelect(query) { + await selectRowByURL(`https://example.com/${query}`); +} + +async function getResultByType(provider) { + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + const detail = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + const telemetryType = UrlbarUtils.searchEngagementTelemetryType( + detail.result + ); + if (telemetryType === provider) { + return detail.result; + } + } + return null; +} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/head-groups.js b/browser/components/urlbar/tests/engagementTelemetry/browser/head-groups.js new file mode 100644 index 0000000000..e86c664b46 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/head-groups.js @@ -0,0 +1,339 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from head.js */ + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderClipboard: + "resource:///modules/UrlbarProviderClipboard.sys.mjs", +}); + +async function doHeuristicsTest({ trigger, assert }) { + await doTest(async browser => { + await openPopup("x"); + + await trigger(); + await assert(); + }); +} + +async function doAdaptiveHistoryTest({ trigger, assert }) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill", false]], + }); + + await doTest(async browser => { + await PlacesTestUtils.addVisits(["https://example.com/test"]); + await UrlbarUtils.addToInputHistory("https://example.com/test", "examp"); + + await openPopup("exa"); + await selectRowByURL("https://example.com/test"); + + await trigger(); + await assert(); + }); + + await SpecialPowers.popPrefEnv(); +} + +async function doSearchHistoryTest({ trigger, assert }) { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.maxHistoricalSearchSuggestions", 2], + ], + }); + + await doTest(async browser => { + await UrlbarTestUtils.formHistory.add(["foofoo", "foobar"]); + + await openPopup("foo"); + await selectRowByURL("http://mochi.test:8888/?terms=foofoo"); + + await trigger(); + await assert(); + }); + + await SpecialPowers.popPrefEnv(); +} + +async function doRecentSearchTest({ trigger, assert }) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.recentsearches.featureGate", true]], + }); + + await doTest(async browser => { + await UrlbarTestUtils.formHistory.add([ + { value: "foofoo", source: Services.search.defaultEngine.name }, + ]); + + await openPopup(""); + await selectRowByURL("http://mochi.test:8888/?terms=foofoo"); + + await trigger(); + await assert(); + }); + + await SpecialPowers.popPrefEnv(); +} + +async function doSearchSuggestTest({ trigger, assert }) { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.maxHistoricalSearchSuggestions", 2], + ], + }); + + await doTest(async browser => { + await openPopup("foo"); + await selectRowByURL("http://mochi.test:8888/?terms=foofoo"); + + await trigger(); + await assert(); + }); + + await SpecialPowers.popPrefEnv(); +} + +async function doTailSearchSuggestTest({ trigger, assert }) { + const cleanup = await _useTailSuggestionsEngine(); + + await doTest(async browser => { + await openPopup("hello"); + await selectRowByProvider("SearchSuggestions"); + + await trigger(); + await assert(); + }); + + await cleanup(); +} + +async function doTopPickTest({ trigger, assert }) { + const cleanupQuickSuggest = await ensureQuickSuggestInit({ + merinoSuggestions: [ + { + title: "Navigational suggestion", + url: "https://example.com/navigational-suggestion", + provider: "top_picks", + is_sponsored: false, + score: 0.25, + block_id: 0, + is_top_pick: true, + }, + ], + }); + + await doTest(async browser => { + await openPopup("navigational"); + await selectRowByURL("https://example.com/navigational-suggestion"); + + await trigger(); + await assert(); + }); + + await cleanupQuickSuggest(); +} + +async function doTopSiteTest({ trigger, assert }) { + await doTest(async browser => { + await addTopSites("https://example.com/"); + + await showResultByArrowDown(); + await selectRowByURL("https://example.com/"); + + await trigger(); + await assert(); + }); +} + +async function doClipboardTest({ trigger, assert }) { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.clipboard.featureGate", true], + ["browser.urlbar.suggest.clipboard", true], + ], + }); + SpecialPowers.clipboardCopyString("https://example.com/clipboard"); + await doTest(async browser => { + await showResultByArrowDown(); + await selectRowByURL("https://example.com/clipboard"); + + await trigger(); + await assert(); + }); + SpecialPowers.clipboardCopyString(""); + UrlbarProviderClipboard.setPreviousClipboardValue(""); + await SpecialPowers.popPrefEnv(); +} + +async function doRemoteTabTest({ trigger, assert }) { + const remoteTab = await loadRemoteTab("https://example.com"); + + await doTest(async browser => { + await openPopup("example"); + await selectRowByProvider("RemoteTabs"); + + await trigger(); + await assert(); + }); + + await remoteTab.unload(); +} + +async function doAddonTest({ trigger, assert }) { + const addon = loadOmniboxAddon({ keyword: "omni" }); + await addon.startup(); + + await doTest(async browser => { + await openPopup("omni test"); + + await trigger(); + await assert(); + }); + + await addon.unload(); +} + +async function doGeneralBookmarkTest({ trigger, assert }) { + await doTest(async browser => { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "https://example.com/bookmark", + title: "bookmark", + }); + + await openPopup("bookmark"); + await selectRowByURL("https://example.com/bookmark"); + + await trigger(); + await assert(); + }); +} + +async function doGeneralHistoryTest({ trigger, assert }) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill", false]], + }); + + await doTest(async browser => { + await PlacesTestUtils.addVisits("https://example.com/test"); + + await openPopup("example"); + await selectRowByURL("https://example.com/test"); + + await trigger(); + await assert(); + }); + + await SpecialPowers.popPrefEnv(); +} + +async function doSuggestTest({ trigger, assert }) { + const cleanupQuickSuggest = await ensureQuickSuggestInit(); + + await doTest(async browser => { + await openPopup("nonsponsored"); + await selectRowByURL("https://example.com/nonsponsored"); + + await trigger(); + await assert(); + }); + + await cleanupQuickSuggest(); +} + +async function doAboutPageTest({ trigger, assert }) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxRichResults", 3]], + }); + + await doTest(async browser => { + await openPopup("about:"); + await selectRowByURL("about:robots"); + + await trigger(); + await assert(); + }); + + await SpecialPowers.popPrefEnv(); +} + +async function doSuggestedIndexTest({ trigger, assert }) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.unitConversion.enabled", true]], + }); + + await doTest(async browser => { + await openPopup("1m to cm"); + await selectRowByProvider("UnitConversion"); + + await trigger(); + await assert(); + }); + + await SpecialPowers.popPrefEnv(); +} + +/** + * Creates a search engine that returns tail suggestions and sets it as the + * default engine. + * + * @returns {Function} + * A cleanup function that will revert the default search engine and stop http + * server. + */ +async function _useTailSuggestionsEngine() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.suggest.enabled", true], + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.richSuggestions.tail", true], + ], + }); + + const engineName = "TailSuggestions"; + const httpServer = new HttpServer(); + httpServer.start(-1); + httpServer.registerPathHandler("/suggest", (req, resp) => { + const params = new URLSearchParams(req.queryString); + const searchStr = params.get("q"); + const suggestions = [ + searchStr, + [searchStr + "-tail"], + [], + { + "google:suggestdetail": [{ t: "-tail", mp: "… " }], + }, + ]; + resp.setHeader("Content-Type", "application/json", false); + resp.write(JSON.stringify(suggestions)); + }); + + await SearchTestUtils.installSearchExtension({ + name: engineName, + search_url: `http://localhost:${httpServer.identity.primaryPort}/search`, + suggest_url: `http://localhost:${httpServer.identity.primaryPort}/suggest`, + suggest_url_get_params: "?q={searchTerms}", + search_form: `http://localhost:${httpServer.identity.primaryPort}/search?q={searchTerms}`, + }); + + const tailEngine = Services.search.getEngineByName(engineName); + const originalEngine = await Services.search.getDefault(); + Services.search.setDefault( + tailEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + return async () => { + Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + httpServer.stop(() => {}); + await SpecialPowers.popPrefEnv(); + }; +} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/head-interaction.js b/browser/components/urlbar/tests/engagementTelemetry/browser/head-interaction.js new file mode 100644 index 0000000000..244e27d272 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/head-interaction.js @@ -0,0 +1,340 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from head.js */ + +ChromeUtils.defineESModuleGetters(this, { + CUSTOM_SEARCH_SHORTCUTS: + "resource://activity-stream/lib/SearchShortcuts.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", + SEARCH_SHORTCUTS: "resource://activity-stream/lib/SearchShortcuts.sys.mjs", + SearchService: "resource://gre/modules/SearchService.sys.mjs", +}); + +async function doTopsitesTest({ trigger, assert }) { + await doTest(async browser => { + await addTopSites("https://example.com/"); + + await showResultByArrowDown(); + await selectRowByURL("https://example.com/"); + + await trigger(); + await assert(); + }); +} + +async function doTopsitesSearchTest({ trigger, assert }) { + await doTest(async browser => { + let extension = await SearchTestUtils.installSearchExtension( + { + name: "MozSearch", + keyword: "@test", + search_url: "https://example.com/", + search_url_get_params: "q={searchTerms}", + }, + { skipUnload: true } + ); + + // Fresh profiles come with an empty set of pinned websites (pref doesn't + // exist). Search shortcut topsites make this test more complicated because + // the feature pins a new website on startup. Behaviour can vary when running + // with --verify so it's more predictable to clear pins entirely. + Services.prefs.clearUserPref("browser.newtabpage.pinned"); + NewTabUtils.pinnedLinks.resetCache(); + + let entry = { + keyword: "@test", + shortURL: "example", + url: "https://example.com/", + }; + + // The array is used to identify sites that should be converted to + // a Top Site. + let searchShortcuts = JSON.parse(JSON.stringify(SEARCH_SHORTCUTS)); + SEARCH_SHORTCUTS.push(entry); + + // TopSitesFeed takes a list of app provided engines and determine if the + // engine containing an alias that matches a keyword inside of this array. + // If so, the list of search shortcuts in the store will be updated. + let customSearchShortcuts = JSON.parse( + JSON.stringify(CUSTOM_SEARCH_SHORTCUTS) + ); + CUSTOM_SEARCH_SHORTCUTS.push(entry); + + // TopSitesFeed only allows app provided engines to be included as + // search shortcuts. + // eslint-disable-next-line mozilla/valid-lazy + let sandbox = lazy.sinon.createSandbox(); + sandbox + .stub(SearchService.prototype, "getAppProvidedEngines") + .resolves([{ aliases: ["@test"] }]); + + let siteToPin = { + url: "https://example.com", + label: "@test", + searchTopSite: true, + }; + NewTabUtils.pinnedLinks.pin(siteToPin, 0); + + await updateTopSites(sites => { + return sites && sites[0] && sites[0].url == "https://example.com"; + }, true); + + BrowserTestUtils.startLoadingURIString(browser, "about:newtab"); + await BrowserTestUtils.browserStopped(browser, "about:newtab"); + + await BrowserTestUtils.synthesizeMouseAtCenter( + ".search-shortcut .top-site-button", + {}, + gBrowser.selectedBrowser + ); + + EventUtils.synthesizeKey("x"); + await UrlbarTestUtils.promiseSearchComplete(window); + + await trigger(); + await assert(); + + // Clean up. + NewTabUtils.pinnedLinks.unpin(siteToPin); + SEARCH_SHORTCUTS.pop(); + CUSTOM_SEARCH_SHORTCUTS.pop(); + // Sanity check to ensure we're leaving the shortcuts in their default state. + Assert.deepEqual( + searchShortcuts, + SEARCH_SHORTCUTS, + "SEARCH_SHORTCUTS values" + ); + Assert.deepEqual( + customSearchShortcuts, + CUSTOM_SEARCH_SHORTCUTS, + "CUSTOM_SEARCH_SHORTCUTS values" + ); + sandbox.restore(); + Services.prefs.clearUserPref("browser.newtabpage.pinned"); + NewTabUtils.pinnedLinks.resetCache(); + await extension.unload(); + }); +} + +async function doTypedTest({ trigger, assert }) { + await doTest(async browser => { + await openPopup("x"); + + await trigger(); + await assert(); + }); +} + +async function doTypedWithResultsPopupTest({ trigger, assert }) { + await doTest(async browser => { + await showResultByArrowDown(); + EventUtils.synthesizeKey("x"); + await UrlbarTestUtils.promiseSearchComplete(window); + + await trigger(); + await assert(); + }); +} + +async function doPastedTest({ trigger, assert }) { + await doTest(async browser => { + await doPaste("www.example.com"); + + await trigger(); + await assert(); + }); +} + +async function doPastedWithResultsPopupTest({ trigger, assert }) { + await doTest(async browser => { + await showResultByArrowDown(); + await doPaste("x"); + + await trigger(); + await assert(); + }); +} + +async function doReturnedRestartedRefinedTest({ trigger, assert }) { + const testData = [ + { + firstInput: "x", + // Just move the focus to the URL bar after blur. + secondInput: null, + expected: "returned", + }, + { + firstInput: "x", + secondInput: "x", + expected: "returned", + }, + { + firstInput: "x", + secondInput: "y", + expected: "restarted", + }, + { + firstInput: "x", + secondInput: "x y", + expected: "refined", + }, + { + firstInput: "x y", + secondInput: "x", + expected: "refined", + }, + ]; + + for (const { firstInput, secondInput, expected } of testData) { + await doTest(async browser => { + await openPopup(firstInput); + await waitForPauseImpression(); + await doBlur(); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + document.getElementById("Browser:OpenLocation").doCommand(); + }); + if (secondInput) { + for (let i = 0; i < secondInput.length; i++) { + EventUtils.synthesizeKey(secondInput.charAt(i)); + } + } + await UrlbarTestUtils.promiseSearchComplete(window); + + await trigger(); + await assert(expected); + }); + } +} + +async function doPersistedSearchTermsTest({ trigger, assert }) { + await doTest(async browser => { + await openPopup("x"); + await waitForPauseImpression(); + await doEnter(); + + await openPopup("x"); + + await trigger(); + await assert(); + }); +} + +async function doPersistedSearchTermsRestartedRefinedTest({ + enabled, + trigger, + assert, +}) { + const testData = [ + { + firstInput: "x", + // Just move the focus to the URL bar after engagement. + secondInput: null, + expected: enabled ? "persisted_search_terms" : "topsites", + }, + { + firstInput: "x", + secondInput: "x", + expected: enabled ? "persisted_search_terms" : "typed", + }, + { + firstInput: "x", + secondInput: "y", + expected: enabled ? "persisted_search_terms_restarted" : "typed", + }, + { + firstInput: "x", + secondInput: "x y", + expected: enabled ? "persisted_search_terms_refined" : "typed", + }, + { + firstInput: "x y", + secondInput: "x", + expected: enabled ? "persisted_search_terms_refined" : "typed", + }, + ]; + + for (const { firstInput, secondInput, expected } of testData) { + await doTest(async browser => { + await openPopup(firstInput); + await waitForPauseImpression(); + await doEnter(); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeKey("l", { accelKey: true }); + }); + if (secondInput) { + for (let i = 0; i < secondInput.length; i++) { + EventUtils.synthesizeKey(secondInput.charAt(i)); + } + } + await UrlbarTestUtils.promiseSearchComplete(window); + + await trigger(); + await assert(expected); + }); + } +} + +async function doPersistedSearchTermsRestartedRefinedViaAbandonmentTest({ + enabled, + trigger, + assert, +}) { + const testData = [ + { + firstInput: "x", + // Just move the focus to the URL bar after blur. + secondInput: null, + expected: enabled ? "persisted_search_terms" : "returned", + }, + { + firstInput: "x", + secondInput: "x", + expected: enabled ? "persisted_search_terms" : "returned", + }, + { + firstInput: "x", + secondInput: "y", + expected: enabled ? "persisted_search_terms_restarted" : "restarted", + }, + { + firstInput: "x", + secondInput: "x y", + expected: enabled ? "persisted_search_terms_refined" : "refined", + }, + { + firstInput: "x y", + secondInput: "x", + expected: enabled ? "persisted_search_terms_refined" : "refined", + }, + ]; + + for (const { firstInput, secondInput, expected } of testData) { + await doTest(async browser => { + await openPopup("any search"); + await waitForPauseImpression(); + await doEnter(); + + await openPopup(firstInput); + await waitForPauseImpression(); + await doBlur(); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeKey("l", { accelKey: true }); + }); + if (secondInput) { + for (let i = 0; i < secondInput.length; i++) { + EventUtils.synthesizeKey(secondInput.charAt(i)); + } + } + await UrlbarTestUtils.promiseSearchComplete(window); + + await trigger(); + await assert(expected); + }); + } +} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/head-n_chars_n_words.js b/browser/components/urlbar/tests/engagementTelemetry/browser/head-n_chars_n_words.js new file mode 100644 index 0000000000..6d4c61c7f0 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/head-n_chars_n_words.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from head.js */ + +async function doNCharsTest({ trigger, assert }) { + for (const input of ["x", "xx", "xx x", "xx x "]) { + await doTest(async browser => { + await openPopup(input); + + await trigger(); + await assert(input.length); + }); + } +} + +async function doNCharsWithOverMaxTextLengthCharsTest({ trigger, assert }) { + await doTest(async browser => { + let input = ""; + for (let i = 0; i < UrlbarUtils.MAX_TEXT_LENGTH * 2; i++) { + input += "x"; + } + await openPopup(input); + + await trigger(); + await assert(UrlbarUtils.MAX_TEXT_LENGTH * 2); + }); +} + +async function doNWordsTest({ trigger, assert }) { + for (const input of ["x", "xx", "xx x", "xx x "]) { + await doTest(async browser => { + await openPopup(input); + + await trigger(); + const splits = input.trim().split(" "); + await assert(splits.length); + }); + } +} + +async function doNWordsWithOverMaxTextLengthCharsTest({ trigger, assert }) { + await doTest(async browser => { + const word = "1234 "; + let input = ""; + while (input.length < UrlbarUtils.MAX_TEXT_LENGTH * 2) { + input += word; + } + await openPopup(input); + + await trigger(); + await assert(UrlbarUtils.MAX_TEXT_LENGTH / word.length); + }); +} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/head-sap.js b/browser/components/urlbar/tests/engagementTelemetry/browser/head-sap.js new file mode 100644 index 0000000000..ef95873813 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/head-sap.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from head.js */ + +async function doUrlbarNewTabTest({ trigger, assert }) { + await doTest(async browser => { + await openPopup("x"); + + await trigger(); + await assert(); + }); +} + +async function doUrlbarTest({ trigger, assert }) { + await doTest(async browser => { + await openPopup("x"); + await waitForPauseImpression(); + await doEnter(); + await openPopup("y"); + + await trigger(); + await assert(); + }); +} + +async function doHandoffTest({ trigger, assert }) { + await doTest(async browser => { + BrowserTestUtils.startLoadingURIString(browser, "about:newtab"); + await BrowserTestUtils.browserStopped(browser, "about:newtab"); + await SpecialPowers.spawn(browser, [], function () { + const searchInput = content.document.querySelector(".fake-editable"); + searchInput.click(); + }); + EventUtils.synthesizeKey("x"); + await UrlbarTestUtils.promiseSearchComplete(window); + + await trigger(); + await assert(); + }); +} + +async function doUrlbarAddonpageTest({ trigger, assert }) { + const extensionData = { + files: { + "page.html": "hello", + }, + }; + const extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + const extensionURL = `moz-extension://${extension.uuid}/page.html`; + + await doTest(async browser => { + const onLoad = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.startLoadingURIString(browser, extensionURL); + await onLoad; + await openPopup("x"); + + await trigger(); + await assert(); + }); + + await extension.unload(); +} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/head-search_engine_default_id.js b/browser/components/urlbar/tests/engagementTelemetry/browser/head-search_engine_default_id.js new file mode 100644 index 0000000000..c0af764e7f --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/head-search_engine_default_id.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from head.js */ + +async function doSearchEngineDefaultIdTest({ trigger, assert }) { + await doTest(async browser => { + info("Test with current engine"); + const defaultEngine = await Services.search.getDefault(); + + await openPopup("x"); + await trigger(); + await assert(defaultEngine.telemetryId); + }); + + await doTest(async browser => { + info("Test with new engine"); + const defaultEngine = await Services.search.getDefault(); + const newEngineName = "NewDummyEngine"; + await SearchTestUtils.installSearchExtension({ + name: newEngineName, + search_url: "https://example.com/", + search_url_get_params: "q={searchTerms}", + }); + const newEngine = await Services.search.getEngineByName(newEngineName); + Assert.notEqual(defaultEngine.telemetryId, newEngine.telemetryId); + await Services.search.setDefault( + newEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + await openPopup("x"); + await trigger(); + await assert(newEngine.telemetryId); + + await Services.search.setDefault( + defaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + }); +} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/head-search_mode.js b/browser/components/urlbar/tests/engagementTelemetry/browser/head-search_mode.js new file mode 100644 index 0000000000..5c877da05f --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/head-search_mode.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from head.js */ + +async function doNotSearchModeTest({ trigger, assert }) { + await doTest(async browser => { + await openPopup("x"); + + await trigger(); + await assert(); + }); +} + +async function doSearchEngineTest({ trigger, assert }) { + await doTest(async browser => { + await openPopup("x"); + await UrlbarTestUtils.enterSearchMode(window); + + await trigger(); + await assert(); + }); +} + +async function doBookmarksTest({ trigger, assert }) { + await doTest(async browser => { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "https://example.com/bookmark", + title: "bookmark", + }); + await openPopup("bookmark"); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + await selectRowByURL("https://example.com/bookmark"); + + await trigger(); + await assert(); + }); +} + +async function doHistoryTest({ trigger, assert }) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill", false]], + }); + + await doTest(async browser => { + await PlacesTestUtils.addVisits("https://example.com/test"); + await openPopup("example"); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }); + await selectRowByURL("https://example.com/test"); + + await trigger(); + await assert(); + }); + + await SpecialPowers.popPrefEnv(); +} + +async function doTabTest({ trigger, assert }) { + const tab = BrowserTestUtils.addTab(gBrowser, "https://example.com/"); + + await doTest(async browser => { + await openPopup("example"); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.TABS, + }); + await selectRowByProvider("Places"); + + await trigger(); + await assert(); + }); + + BrowserTestUtils.removeTab(tab); +} + +async function doActionsTest({ trigger, assert }) { + await doTest(async browser => { + 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 new file mode 100644 index 0000000000..367387b0e8 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/head.js @@ -0,0 +1,473 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/browser/head-common.js", + this +); + +ChromeUtils.defineESModuleGetters(this, { + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", +}); + +const lazy = {}; + +ChromeUtils.defineLazyGetter(lazy, "QuickSuggestTestUtils", () => { + const { QuickSuggestTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/QuickSuggestTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +ChromeUtils.defineLazyGetter(this, "MerinoTestUtils", () => { + const { MerinoTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/MerinoTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +ChromeUtils.defineESModuleGetters(lazy, { + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(this, "PlacesFrecencyRecalculator", () => { + return Cc["@mozilla.org/places/frecency-recalculator;1"].getService( + Ci.nsIObserver + ).wrappedJSObject; +}); + +async function addTopSites(url) { + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits(url); + } + await updateTopSites(sites => { + return sites && sites[0] && sites[0].url == url; + }); +} + +function assertAbandonmentTelemetry(expectedExtraList) { + _assertGleanTelemetry("abandonment", expectedExtraList); +} + +function assertEngagementTelemetry(expectedExtraList) { + _assertGleanTelemetry("engagement", expectedExtraList); +} + +function assertImpressionTelemetry(expectedExtraList) { + _assertGleanTelemetry("impression", expectedExtraList); +} + +function assertExposureTelemetry(expectedExtraList) { + _assertGleanTelemetry("exposure", expectedExtraList); +} + +function _assertGleanTelemetry(telemetryName, expectedExtraList) { + const telemetries = Glean.urlbar[telemetryName].testGetValue() ?? []; + info( + "Asserting Glean telemetry is correct, actual events are: " + + JSON.stringify(telemetries) + ); + Assert.equal( + telemetries.length, + expectedExtraList.length, + "Telemetry event length matches expected event length." + ); + + for (let i = 0; i < telemetries.length; i++) { + const telemetry = telemetries[i]; + Assert.equal(telemetry.category, "urlbar"); + Assert.equal(telemetry.name, telemetryName); + + const expectedExtra = expectedExtraList[i]; + for (const key of Object.keys(expectedExtra)) { + Assert.equal( + telemetry.extra[key], + expectedExtra[key], + `${key} is correct` + ); + } + } +} + +async function ensureQuickSuggestInit({ ...args } = {}) { + return lazy.QuickSuggestTestUtils.ensureQuickSuggestInit({ + ...args, + remoteSettingsRecords: [ + { + type: "data", + attachment: [ + { + id: 1, + url: "https://example.com/sponsored", + title: "Sponsored suggestion", + keywords: ["sponsored"], + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "TestAdvertiser", + iab_category: "22 - Shopping", + icon: "1234", + }, + { + id: 2, + url: `https://example.com/nonsponsored`, + title: "Non-sponsored suggestion", + keywords: ["nonsponsored"], + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "Wikipedia", + iab_category: "5 - Education", + icon: "1234", + }, + ], + }, + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ], + }); +} + +async function doBlur() { + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); +} + +async function doClick() { + const selected = UrlbarTestUtils.getSelectedRow(window); + const onLoad = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeMouseAtCenter(selected, {}); + await onLoad; +} + +async function doClickSubButton(selector) { + const selected = UrlbarTestUtils.getSelectedElement(window); + const button = selected.closest(".urlbarView-row").querySelector(selector); + EventUtils.synthesizeMouseAtCenter(button, {}); +} + +async function doDropAndGo(data) { + const onLoad = BrowserTestUtils.browserLoaded(browser); + EventUtils.synthesizeDrop( + document.getElementById("back-button"), + gURLBar.inputField, + [[{ type: "text/plain", data }]], + "copy", + window + ); + await onLoad; +} + +async function doEnter(modifier = {}) { + const onLoad = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeKey("KEY_Enter", modifier); + await onLoad; +} + +async function doPaste(data) { + await SimpleTest.promiseClipboardChange(data, () => { + clipboardHelper.copyString(data); + }); + + gURLBar.focus(); + gURLBar.select(); + document.commandDispatcher + .getControllerForCommand("cmd_paste") + .doCommand("cmd_paste"); + await UrlbarTestUtils.promiseSearchComplete(window); +} + +async function doPasteAndGo(data) { + await SimpleTest.promiseClipboardChange(data, () => { + clipboardHelper.copyString(data); + }); + const inputBox = gURLBar.querySelector("moz-input-box"); + const contextMenu = inputBox.menupopup; + const onPopup = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, { + type: "contextmenu", + button: 2, + }); + await onPopup; + const onLoad = BrowserTestUtils.browserLoaded(browser); + const menuitem = inputBox.getMenuItem("paste-and-go"); + contextMenu.activateItem(menuitem); + await onLoad; +} + +async function doTest(testFn) { + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + // Enable recording telemetry for impression, as it is disabled by default. + Services.fog.setMetricsFeatureConfig( + JSON.stringify({ + "urlbar.impression": true, + }) + ); + + gURLBar.controller.engagementEvent.reset(); + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesTestUtils.clearHistoryVisits(); + await PlacesTestUtils.clearInputHistory(); + await UrlbarTestUtils.formHistory.clear(window); + await QuickSuggest.blockedSuggestions.clear(); + await QuickSuggest.blockedSuggestions._test_readyPromise; + await updateTopSites(() => true); + + try { + await BrowserTestUtils.withNewTab(gBrowser, testFn); + } finally { + Services.fog.setMetricsFeatureConfig("{}"); + } +} + +async function initGroupTest() { + /* import-globals-from head-groups.js */ + Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/engagementTelemetry/browser/head-groups.js", + this + ); + await setup(); +} + +async function initInteractionTest() { + /* import-globals-from head-interaction.js */ + Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/engagementTelemetry/browser/head-interaction.js", + this + ); + await setup(); +} + +async function initNCharsAndNWordsTest() { + /* import-globals-from head-n_chars_n_words.js */ + Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/engagementTelemetry/browser/head-n_chars_n_words.js", + this + ); + await setup(); +} + +async function initSapTest() { + /* import-globals-from head-sap.js */ + Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/engagementTelemetry/browser/head-sap.js", + this + ); + await setup(); +} + +async function initSearchEngineDefaultIdTest() { + /* import-globals-from head-search_engine_default_id.js */ + Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/engagementTelemetry/browser/head-search_engine_default_id.js", + this + ); + await setup(); +} + +async function initSearchModeTest() { + /* import-globals-from head-search_mode.js */ + Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/engagementTelemetry/browser/head-search_mode.js", + this + ); + await setup(); +} + +async function initExposureTest() { + /* import-globals-from head-exposure.js */ + Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/engagementTelemetry/browser/head-exposure.js", + this + ); + await setup(); +} + +function loadOmniboxAddon({ keyword }) { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + omnibox: { + keyword, + }, + }, + background() { + /* global browser */ + browser.omnibox.setDefaultSuggestion({ + description: "doit", + }); + browser.omnibox.onInputEntered.addListener(() => { + browser.tabs.update({ url: "https://example.com/" }); + }); + browser.omnibox.onInputChanged.addListener((text, suggest) => { + suggest([]); + }); + }, + }); +} + +async function loadRemoteTab(url) { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", false], + ["browser.urlbar.maxHistoricalSearchSuggestions", 0], + ["browser.urlbar.autoFill", false], + ["services.sync.username", "fake"], + ["services.sync.syncedTabs.showRemoteTabs", true], + ], + }); + + const REMOTE_TAB = { + id: "test", + type: "client", + lastModified: 1492201200, + name: "test", + clientType: "desktop", + tabs: [ + { + type: "tab", + title: "tesrt", + url, + icon: UrlbarUtils.ICON.DEFAULT, + client: "test", + lastUsed: Math.floor(Date.now() / 1000), + }, + ], + }; + + const sandbox = lazy.sinon.createSandbox(); + // eslint-disable-next-line no-undef + const syncedTabs = SyncedTabs; + const originalSyncedTabsInternal = syncedTabs._internal; + syncedTabs._internal = { + isConfiguredToSyncTabs: true, + hasSyncedThisSession: true, + getTabClients() { + return Promise.resolve([]); + }, + syncTabs() { + return Promise.resolve(); + }, + }; + const weaveXPCService = Cc["@mozilla.org/weave/service;1"].getService( + Ci.nsISupports + ).wrappedJSObject; + const oldWeaveServiceReady = weaveXPCService.ready; + weaveXPCService.ready = true; + sandbox + .stub(syncedTabs._internal, "getTabClients") + .callsFake(() => Promise.resolve(Cu.cloneInto([REMOTE_TAB], {}))); + + return { + async unload() { + sandbox.restore(); + weaveXPCService.ready = oldWeaveServiceReady; + syncedTabs._internal = originalSyncedTabsInternal; + // Reset internal cache in UrlbarProviderRemoteTabs. + Services.obs.notifyObservers(null, "weave:engine:sync:finish", "tabs"); + await SpecialPowers.popPrefEnv(); + }, + }; +} + +async function openPopup(input) { + await UrlbarTestUtils.promisePopupOpen(window, async () => { + await UrlbarTestUtils.inputIntoURLBar(window, input); + }); + await UrlbarTestUtils.promiseSearchComplete(window); +} + +async function selectRowByURL(url) { + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + const detail = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (detail.url === url) { + UrlbarTestUtils.setSelectedRowIndex(window, i); + return; + } + } +} + +async function selectRowByProvider(provider) { + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + const detail = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (detail.result.providerName === provider) { + UrlbarTestUtils.setSelectedRowIndex(window, i); + break; + } + } +} + +async function selectRowByType(type) { + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + const detail = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (detail.result.payload.type === type) { + UrlbarTestUtils.setSelectedRowIndex(window, i); + return; + } + } +} + +async function setup() { + await SpecialPowers.pushPrefEnv({ + 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.searchEngagementTelemetry.pauseImpressionIntervalMs", + 100, + ], + ], + }); + + const engine = await SearchTestUtils.promiseNewSearchEngine({ + url: "chrome://mochitests/content/browser/browser/components/urlbar/tests/browser/searchSuggestionEngine.xml", + }); + const originalDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.moveEngine(engine, 0); + + registerCleanupFunction(async function () { + await SpecialPowers.popPrefEnv(); + await Services.search.setDefault( + originalDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + }); +} + +async function setupNimbus(variables) { + return lazy.UrlbarTestUtils.initNimbusFeature(variables); +} + +async function showResultByArrowDown() { + gURLBar.value = ""; + gURLBar.select(); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeKey("KEY_ArrowDown"); + }); + await UrlbarTestUtils.promiseSearchComplete(window); +} + +async function waitForPauseImpression() { + await new Promise(r => + setTimeout( + r, + UrlbarPrefs.get("searchEngagementTelemetry.pauseImpressionIntervalMs") + ) + ); + await Services.fog.testFlushAllChildren(); +} diff --git a/browser/components/urlbar/tests/quicksuggest/MerinoTestUtils.sys.mjs b/browser/components/urlbar/tests/quicksuggest/MerinoTestUtils.sys.mjs new file mode 100644 index 0000000000..6cda9bb9a7 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/MerinoTestUtils.sys.mjs @@ -0,0 +1,809 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", +}); + +import { HttpServer } from "resource://testing-common/httpd.sys.mjs"; + +// The following properties and methods are copied from the test scope to the +// test utils object so they can be easily accessed. Be careful about assuming a +// particular property will be defined because depending on the scope -- browser +// test or xpcshell test -- some may not be. +const TEST_SCOPE_PROPERTIES = [ + "Assert", + "EventUtils", + "info", + "registerCleanupFunction", +]; + +const SEARCH_PARAMS = { + CLIENT_VARIANTS: "client_variants", + PROVIDERS: "providers", + QUERY: "q", + SEQUENCE_NUMBER: "seq", + SESSION_ID: "sid", +}; + +const REQUIRED_SEARCH_PARAMS = [ + SEARCH_PARAMS.QUERY, + SEARCH_PARAMS.SEQUENCE_NUMBER, + SEARCH_PARAMS.SESSION_ID, +]; + +// We set the client timeout to a large value to avoid intermittent failures in +// CI, especially TV tests, where the Merino fetch unexpectedly doesn't finish +// before the default timeout. +const CLIENT_TIMEOUT_MS = 2000; + +const HISTOGRAM_LATENCY = "FX_URLBAR_MERINO_LATENCY_MS"; +const HISTOGRAM_RESPONSE = "FX_URLBAR_MERINO_RESPONSE"; + +// Maps from string labels of the `FX_URLBAR_MERINO_RESPONSE` histogram to their +// numeric values. +const RESPONSE_HISTOGRAM_VALUES = { + success: 0, + timeout: 1, + network_error: 2, + http_error: 3, + no_suggestion: 4, +}; + +const WEATHER_KEYWORD = "weather"; + +const WEATHER_RS_DATA = { + keywords: [WEATHER_KEYWORD], + min_keyword_length: 3, + score: "0.29", +}; + +const WEATHER_SUGGESTION = { + title: "Weather for San Francisco", + url: "https://example.com/weather", + provider: "accuweather", + is_sponsored: false, + score: 0.2, + icon: null, + city_name: "San Francisco", + current_conditions: { + url: "https://example.com/weather-current-conditions", + summary: "Mostly cloudy", + icon_id: 6, + temperature: { c: 15.5, f: 60.0 }, + }, + forecast: { + url: "https://example.com/weather-forecast", + summary: "Pleasant Saturday", + high: { c: 21.1, f: 70.0 }, + low: { c: 13.9, f: 57.0 }, + }, +}; + +// We set the weather suggestion fetch interval to an absurdly large value so it +// absolutely will not fire during tests. +const WEATHER_FETCH_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours + +const GEOLOCATION_DATA = { + provider: "geolocation", + title: "", + url: "https://merino.services.mozilla.com/", + is_sponsored: false, + score: 0, + custom_details: { + geolocation: { + country: "Japan", + region: "Kanagawa", + city: "Yokohama", + }, + }, +}; + +/** + * Test utils for Merino. + */ +class _MerinoTestUtils { + /** + * Initializes the utils. + * + * @param {object} scope + * The global JS scope where tests are being run. This allows the instance + * to access test helpers like `Assert` that are available in the scope. + */ + init(scope) { + if (!scope) { + throw new Error("MerinoTestUtils.init() must be called with a scope"); + } + + this.#initDepth++; + scope.info?.("MerinoTestUtils init: Depth is now " + this.#initDepth); + + for (let p of TEST_SCOPE_PROPERTIES) { + this[p] = scope[p]; + } + // If you add other properties to `this`, null them in `uninit()`. + + if (!this.#server) { + this.#server = new MockMerinoServer(scope); + } + lazy.UrlbarPrefs.set("merino.timeoutMs", CLIENT_TIMEOUT_MS); + scope.registerCleanupFunction?.(() => { + scope.info?.("MerinoTestUtils cleanup function"); + this.uninit(); + }); + } + + /** + * Uninitializes the utils. If they were created with a test scope that + * defines `registerCleanupFunction()`, you don't need to call this yourself + * because it will automatically be called as a cleanup function. Otherwise + * you'll need to call this. + */ + uninit() { + this.#initDepth--; + this.info?.("MerinoTestUtils uninit: Depth is now " + this.#initDepth); + + if (this.#initDepth) { + this.info?.("MerinoTestUtils uninit: Bailing because depth > 0"); + return; + } + this.info?.("MerinoTestUtils uninit: Now uninitializing"); + + for (let p of TEST_SCOPE_PROPERTIES) { + this[p] = null; + } + this.#server.uninit(); + this.#server = null; + lazy.UrlbarPrefs.clear("merino.timeoutMs"); + } + + /** + * @returns {object} + * The names of URL search params. + */ + get SEARCH_PARAMS() { + return SEARCH_PARAMS; + } + + /** + * @returns {object} + * Mock geolocation data. + */ + get GEOLOCATION() { + return { ...GEOLOCATION_DATA.custom_details.geolocation }; + } + + /** + * @returns {string} + * The weather keyword in `WEATHER_RS_DATA`. Can be used as a search string + * to match the weather suggestion. + */ + get WEATHER_KEYWORD() { + return WEATHER_KEYWORD; + } + + /** + * @returns {object} + * Default remote settings data that sets up `WEATHER_KEYWORD` as the + * keyword for the weather suggestion. + */ + get WEATHER_RS_DATA() { + return { ...WEATHER_RS_DATA }; + } + + /** + * @returns {object} + * A mock weather suggestion. + */ + get WEATHER_SUGGESTION() { + return WEATHER_SUGGESTION; + } + + /** + * @returns {MockMerinoServer} + * The mock Merino server. The server isn't started until its `start()` + * method is called. + */ + get server() { + return this.#server; + } + + /** + * Clears the Merino-related histograms and returns them. + * + * @param {object} options + * Options + * @param {string} options.extraLatency + * The name of another latency histogram you expect to be updated. + * @param {string} options.extraResponse + * The name of another response histogram you expect to be updated. + * @returns {object} + * An object of histograms: `{ latency, response }` + * `latency` and `response` are both arrays of Histogram objects. + */ + getAndClearHistograms({ + extraLatency = undefined, + extraResponse = undefined, + } = {}) { + let histograms = { + latency: [ + lazy.TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_LATENCY), + ], + response: [ + lazy.TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_RESPONSE), + ], + }; + if (extraLatency) { + histograms.latency.push( + lazy.TelemetryTestUtils.getAndClearHistogram(extraLatency) + ); + } + if (extraResponse) { + histograms.response.push( + lazy.TelemetryTestUtils.getAndClearHistogram(extraResponse) + ); + } + return histograms; + } + + /** + * Asserts the Merino-related histograms are updated as expected. Clears the + * histograms before returning. + * + * @param {object} options + * Options object + * @param {MerinoClient} options.client + * The relevant `MerinoClient` instance. This is used to check the latency + * stopwatch. + * @param {object} options.histograms + * The histograms object returned from `getAndClearHistograms()`. + * @param {string} options.response + * The expected string label for the `response` histogram. If the histogram + * should not be recorded, pass null. + * @param {boolean} options.latencyRecorded + * Whether the latency histogram is expected to contain a value. + * @param {boolean} options.latencyStopwatchRunning + * Whether the latency stopwatch is expected to be running. + */ + checkAndClearHistograms({ + client, + histograms, + response, + latencyRecorded, + latencyStopwatchRunning = false, + }) { + // Check the response histograms. + if (response) { + this.Assert.ok( + RESPONSE_HISTOGRAM_VALUES.hasOwnProperty(response), + "Sanity check: Expected response is valid: " + response + ); + for (let histogram of histograms.response) { + lazy.TelemetryTestUtils.assertHistogram( + histogram, + RESPONSE_HISTOGRAM_VALUES[response], + 1 + ); + } + } else { + for (let histogram of histograms.response) { + this.Assert.strictEqual( + histogram.snapshot().sum, + 0, + "Response histogram not updated: " + histogram.name() + ); + } + } + + // Check the latency histograms. + if (latencyRecorded) { + // There should be a single value across all buckets. + for (let histogram of histograms.latency) { + this.Assert.deepEqual( + Object.values(histogram.snapshot().values).filter(v => v > 0), + [1], + "Latency histogram updated: " + histogram.name() + ); + } + } else { + for (let histogram of histograms.latency) { + this.Assert.strictEqual( + histogram.snapshot().sum, + 0, + "Latency histogram not updated: " + histogram.name() + ); + } + } + + // Check the latency stopwatch. + if (!client) { + this.Assert.ok( + !latencyStopwatchRunning, + "Client is null, latency stopwatch should not be expected to be running" + ); + } else { + this.Assert.equal( + TelemetryStopwatch.running( + HISTOGRAM_LATENCY, + client._test_latencyStopwatchInstance + ), + latencyStopwatchRunning, + "Latency stopwatch running as expected" + ); + } + + // Clear histograms. + for (let histogramArray of Object.values(histograms)) { + for (let histogram of histogramArray) { + histogram.clear(); + } + } + } + + /** + * Initializes the quick suggest weather feature and mock Merino server. + */ + async initWeather() { + this.info("MockMerinoServer initializing weather, starting server"); + await this.server.start(); + this.info("MockMerinoServer initializing weather, server now started"); + this.server.response.body.suggestions = [WEATHER_SUGGESTION]; + + lazy.QuickSuggest.weather._test_fetchIntervalMs = WEATHER_FETCH_INTERVAL_MS; + + // Enabling weather will trigger a fetch. Wait for it to finish so the + // suggestion is ready when this function returns. + this.info("MockMerinoServer initializing weather, waiting for fetch"); + let fetchPromise = lazy.QuickSuggest.weather.waitForFetches(); + lazy.UrlbarPrefs.set("weather.featureGate", true); + lazy.UrlbarPrefs.set("suggest.weather", true); + await fetchPromise; + this.info("MockMerinoServer initializing weather, got fetch"); + + this.Assert.equal( + lazy.QuickSuggest.weather._test_pendingFetchCount, + 0, + "No pending fetches after awaiting initial fetch" + ); + + this.registerCleanupFunction?.(async () => { + lazy.UrlbarPrefs.clear("weather.featureGate"); + lazy.UrlbarPrefs.clear("suggest.weather"); + lazy.QuickSuggest.weather._test_fetchIntervalMs = -1; + }); + } + + /** + * Initializes the mock Merino geolocation server. + */ + async initGeolocation() { + await this.server.start(); + this.server.response.body.suggestions = [GEOLOCATION_DATA]; + } + + #initDepth = 0; + #server = null; +} + +/** + * A mock Merino server with useful helper methods. + */ +class MockMerinoServer { + /** + * Until `start()` is called the server isn't started and `this.url` is null. + * + * @param {object} scope + * The global JS scope where tests are being run. This allows the instance + * to access test helpers like `Assert` that are available in the scope. + */ + constructor(scope) { + scope.info?.("MockMerinoServer constructor"); + + for (let p of TEST_SCOPE_PROPERTIES) { + this[p] = scope[p]; + } + + let path = "/merino"; + this.#httpServer = new HttpServer(); + this.#httpServer.registerPathHandler(path, (req, resp) => + this.#handleRequest(req, resp) + ); + this.#baseURL = new URL("http://localhost/"); + this.#baseURL.pathname = path; + + this.reset(); + } + + /** + * Uninitializes the server. + */ + uninit() { + this.info?.("MockMerinoServer uninit"); + for (let p of TEST_SCOPE_PROPERTIES) { + this[p] = null; + } + } + + /** + * @returns {nsIHttpServer} + * The underlying HTTP server. + */ + get httpServer() { + return this.#httpServer; + } + + /** + * @returns {URL} + * The server's endpoint URL or null if the server isn't running. + */ + get url() { + return this.#url; + } + + /** + * @returns {Array} + * Array of received nsIHttpRequest objects. Requests are continually + * collected, and the list can be cleared with `reset()`. + */ + get requests() { + return this.#requests; + } + + /** + * @returns {object} + * An object that describes the response that the server will return. Can be + * modified or set to a different object to change the response. Can be + * reset to the default reponse by calling `reset()`. For details see + * `makeDefaultResponse()` and `#handleRequest()`. In summary: + * + * { + * status, + * contentType, + * delay, + * body: { + * request_id, + * suggestions, + * }, + * } + */ + get response() { + return this.#response; + } + set response(value) { + this.#response = value; + } + + /** + * Starts the server and sets `this.url`. If the server was created with a + * test scope that defines `registerCleanupFunction()`, you don't need to call + * `stop()` yourself because it will automatically be called as a cleanup + * function. Otherwise you'll need to call `stop()`. + */ + async start() { + if (this.#url) { + return; + } + + this.info("MockMerinoServer starting"); + + this.#httpServer.start(-1); + this.#url = new URL(this.#baseURL); + this.#url.port = this.#httpServer.identity.primaryPort; + + this._originalEndpointURL = lazy.UrlbarPrefs.get("merino.endpointURL"); + lazy.UrlbarPrefs.set("merino.endpointURL", this.#url.toString()); + + this.registerCleanupFunction?.(() => this.stop()); + + // Wait for the server to actually start serving. In TV tests, where the + // server is created over and over again, sometimes it doesn't seem to be + // ready after being recreated even after `#httpServer.start()` is called. + this.info("MockMerinoServer waiting to start serving..."); + this.reset(); + let suggestion; + while (!suggestion) { + let response = await fetch(this.#url); + let body = await response?.json(); + suggestion = body?.suggestions?.[0]; + } + this.reset(); + this.info("MockMerinoServer is now serving"); + } + + /** + * Stops the server and cleans up other state. + */ + async stop() { + if (!this.#url) { + return; + } + + // `uninit()` may have already been called by this point and removed + // `this.info()`, so don't assume it's defined. + this.info?.("MockMerinoServer stopping"); + + // Cancel delayed-response timers and resolve their promises. Otherwise, if + // a test awaits this method before finishing, it will hang until the timers + // fire and allow the server to send the responses. + this.#cancelDelayedResponses(); + + await this.#httpServer.stop(); + this.#url = null; + lazy.UrlbarPrefs.set("merino.endpointURL", this._originalEndpointURL); + + this.info?.("MockMerinoServer is now stopped"); + } + + /** + * Returns a new object that describes the default response the server will + * return. + * + * @returns {object} + */ + makeDefaultResponse() { + return { + status: 200, + contentType: "application/json", + body: { + request_id: "request_id", + suggestions: [ + { + provider: "adm", + full_keyword: "amp", + title: "Amp Suggestion", + url: "http://example.com/amp", + icon: null, + impression_url: "http://example.com/amp-impression", + click_url: "http://example.com/amp-click", + block_id: 1, + advertiser: "Amp", + iab_category: "22 - Shopping", + is_sponsored: true, + score: 1, + }, + ], + }, + }; + } + + /** + * Clears the received requests and sets the response to the default. + */ + reset() { + this.#requests = []; + this.response = this.makeDefaultResponse(); + this.#cancelDelayedResponses(); + } + + /** + * Asserts a given list of requests has been received. Clears the list of + * received requests before returning. + * + * @param {Array} expected + * The expected requests. Each item should be an object: `{ params }` + */ + checkAndClearRequests(expected) { + let actual = this.requests.map(req => { + let params = new URLSearchParams(req.queryString); + return { params: Object.fromEntries(params) }; + }); + + this.info("Checking requests"); + this.info("actual: " + JSON.stringify(actual)); + this.info("expect: " + JSON.stringify(expected)); + + // Check the request count. + this.Assert.equal(actual.length, expected.length, "Expected request count"); + if (actual.length != expected.length) { + return; + } + + // Check each request. + for (let i = 0; i < actual.length; i++) { + let a = actual[i]; + let e = expected[i]; + this.info("Checking requests at index " + i); + this.info("actual: " + JSON.stringify(a)); + this.info("expect: " + JSON.stringify(e)); + + // Check required search params. + for (let p of REQUIRED_SEARCH_PARAMS) { + this.Assert.ok( + a.params.hasOwnProperty(p), + "Required param is present in actual request: " + p + ); + if (p != SEARCH_PARAMS.SESSION_ID) { + this.Assert.ok( + e.params.hasOwnProperty(p), + "Required param is present in expected request: " + p + ); + } + } + + // If the expected request doesn't include a session ID, then: + if (!e.params.hasOwnProperty(SEARCH_PARAMS.SESSION_ID)) { + if (e.params[SEARCH_PARAMS.SEQUENCE_NUMBER] == 0 || i == 0) { + // If its sequence number is zero, then copy the actual request's + // sequence number to the expected request. As a convenience, do the + // same if this is the first request. + e.params[SEARCH_PARAMS.SESSION_ID] = + a.params[SEARCH_PARAMS.SESSION_ID]; + } else { + // Otherwise this is not the first request in the session and + // therefore the session ID should be the same as the ID in the + // previous expected request. + e.params[SEARCH_PARAMS.SESSION_ID] = + expected[i - 1].params[SEARCH_PARAMS.SESSION_ID]; + } + } + + this.Assert.deepEqual(a, e, "Expected request at index " + i); + + let actualSessionID = a.params[SEARCH_PARAMS.SESSION_ID]; + this.Assert.ok(actualSessionID, "Session ID exists"); + this.Assert.ok( + /^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(actualSessionID), + "Session ID is a UUID" + ); + } + + this.#requests = []; + } + + /** + * Temporarily creates the conditions for a network error. Any Merino fetches + * that occur during the callback will fail with a network error. + * + * @param {Function} callback + * Callback function. + */ + async withNetworkError(callback) { + // Set the endpoint to a valid, unreachable URL. + let originalURL = lazy.UrlbarPrefs.get("merino.endpointURL"); + lazy.UrlbarPrefs.set( + "merino.endpointURL", + "http://localhost/valid-but-unreachable-url" + ); + + // Set the timeout high enough that the network error exception will happen + // first. On Mac and Linux the fetch naturally times out fairly quickly but + // on Windows it seems to take 5s, so set our artificial timeout to 10s. + let originalTimeout = lazy.UrlbarPrefs.get("merino.timeoutMs"); + lazy.UrlbarPrefs.set("merino.timeoutMs", 10000); + + await callback(); + + lazy.UrlbarPrefs.set("merino.endpointURL", originalURL); + lazy.UrlbarPrefs.set("merino.timeoutMs", originalTimeout); + } + + /** + * Returns a promise that will resolve when the next request is received. + * + * @returns {Promise} + */ + waitForNextRequest() { + if (!this.#nextRequestDeferred) { + this.#nextRequestDeferred = Promise.withResolvers(); + } + return this.#nextRequestDeferred.promise; + } + + /** + * nsIHttpServer request handler. + * + * @param {nsIHttpRequest} httpRequest + * Request. + * @param {nsIHttpResponse} httpResponse + * Response. + */ + #handleRequest(httpRequest, httpResponse) { + this.info( + "MockMerinoServer received request with query string: " + + JSON.stringify(httpRequest.queryString) + ); + this.info( + "MockMerinoServer replying with response: " + + JSON.stringify(this.response) + ); + + // Add the request to the list of received requests. + this.#requests.push(httpRequest); + + // Resolve promises waiting on the next request. + this.#nextRequestDeferred?.resolve(); + this.#nextRequestDeferred = null; + + // Now set up and finish the response. + httpResponse.processAsync(); + + let { response } = this; + + let finishResponse = () => { + let status = response.status || 200; + httpResponse.setStatusLine("", status, status); + + let contentType = response.contentType || "application/json"; + httpResponse.setHeader("Content-Type", contentType, false); + + if (typeof response.body == "string") { + httpResponse.write(response.body); + } else if (response.body) { + httpResponse.write(JSON.stringify(response.body)); + } + + httpResponse.finish(); + }; + + if (typeof response.delay != "number") { + finishResponse(); + return; + } + + // Set up a timer to wait until the delay elapses. Since we called + // `httpResponse.processAsync()`, we need to be careful to always finish the + // response, even if the timer is canceled. Otherwise the server will hang + // when we try to stop it at the end of the test. When an `nsITimer` is + // canceled, its callback is *not* called. Therefore we set up a race + // between the timer's callback and a deferred promise. If the timer is + // canceled, resolving the deferred promise will resolve the race, and the + // response can then be finished. + + let delayedResponseID = this.#nextDelayedResponseID++; + this.info( + "MockMerinoServer delaying response: " + + JSON.stringify({ delayedResponseID, delay: response.delay }) + ); + + let deferred = Promise.withResolvers(); + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + let record = { timer, resolve: deferred.resolve }; + this.#delayedResponseRecords.add(record); + + // Don't await this promise. + Promise.race([ + deferred.promise, + new Promise(resolve => { + timer.initWithCallback( + resolve, + response.delay, + Ci.nsITimer.TYPE_ONE_SHOT + ); + }), + ]).then(() => { + this.info( + "MockMerinoServer done delaying response: " + + JSON.stringify({ delayedResponseID }) + ); + deferred.resolve(); + this.#delayedResponseRecords.delete(record); + finishResponse(); + }); + } + + /** + * Cancels the timers for delayed responses and resolves their promises. + */ + #cancelDelayedResponses() { + for (let { timer, resolve } of this.#delayedResponseRecords) { + timer.cancel(); + resolve(); + } + this.#delayedResponseRecords.clear(); + } + + #httpServer = null; + #url = null; + #baseURL = null; + #response = null; + #requests = []; + #nextRequestDeferred = null; + #nextDelayedResponseID = 0; + #delayedResponseRecords = new Set(); +} + +export var MerinoTestUtils = new _MerinoTestUtils(); diff --git a/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs b/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs new file mode 100644 index 0000000000..2ba9dce8be --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs @@ -0,0 +1,915 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* eslint-disable mozilla/valid-lazy */ +/* eslint-disable jsdoc/require-param */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + RemoteSettingsConfig: "resource://gre/modules/RustRemoteSettings.sys.mjs", + RemoteSettingsServer: + "resource://testing-common/RemoteSettingsServer.sys.mjs", + SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", + SuggestBackendRust: + "resource:///modules/urlbar/private/SuggestBackendRust.sys.mjs", + Suggestion: "resource://gre/modules/RustSuggest.sys.mjs", + SuggestionProvider: "resource://gre/modules/RustSuggest.sys.mjs", + SuggestStore: "resource://gre/modules/RustSuggest.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderQuickSuggest: + "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +let gTestScope; + +// Test utils singletons need special handling. Since they are uninitialized in +// cleanup functions, they must be re-initialized on each new test. That does +// not happen automatically inside system modules like this one because system +// module lifetimes are the app's lifetime, unlike individual browser chrome and +// xpcshell tests. +Object.defineProperty(lazy, "UrlbarTestUtils", { + get: () => { + if (!lazy._UrlbarTestUtils) { + const { UrlbarTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/UrlbarTestUtils.sys.mjs" + ); + module.init(gTestScope); + gTestScope.registerCleanupFunction(() => { + // Make sure the utils are re-initialized during the next test. + lazy._UrlbarTestUtils = null; + }); + lazy._UrlbarTestUtils = module; + } + return lazy._UrlbarTestUtils; + }, +}); + +// Test utils singletons need special handling. Since they are uninitialized in +// cleanup functions, they must be re-initialized on each new test. That does +// not happen automatically inside system modules like this one because system +// module lifetimes are the app's lifetime, unlike individual browser chrome and +// xpcshell tests. +Object.defineProperty(lazy, "MerinoTestUtils", { + get: () => { + if (!lazy._MerinoTestUtils) { + const { MerinoTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/MerinoTestUtils.sys.mjs" + ); + module.init(gTestScope); + gTestScope.registerCleanupFunction(() => { + // Make sure the utils are re-initialized during the next test. + lazy._MerinoTestUtils = null; + }); + lazy._MerinoTestUtils = module; + } + return lazy._MerinoTestUtils; + }, +}); + +// TODO bug 1881409: Previously this was an empty object, but the Rust backend +// seems to persist old config after ingesting an empty config object. +const DEFAULT_CONFIG = { + // Zero means there is no cap, the same as if this wasn't specified at all. + show_less_frequently_cap: 0, +}; + +// The following properties and methods are copied from the test scope to the +// test utils object so they can be easily accessed. Be careful about assuming a +// particular property will be defined because depending on the scope -- browser +// test or xpcshell test -- some may not be. +const TEST_SCOPE_PROPERTIES = [ + "Assert", + "EventUtils", + "info", + "registerCleanupFunction", +]; + +/** + * Test utils for quick suggest. + */ +class _QuickSuggestTestUtils { + /** + * Initializes the utils. + * + * @param {object} scope + * The global JS scope where tests are being run. This allows the instance + * to access test helpers like `Assert` that are available in the scope. + */ + init(scope) { + if (!scope) { + throw new Error("QuickSuggestTestUtils() must be called with a scope"); + } + gTestScope = scope; + for (let p of TEST_SCOPE_PROPERTIES) { + this[p] = scope[p]; + } + // If you add other properties to `this`, null them in `uninit()`. + + Services.telemetry.clearScalars(); + + scope.registerCleanupFunction?.(() => this.uninit()); + } + + /** + * Uninitializes the utils. If they were created with a test scope that + * defines `registerCleanupFunction()`, you don't need to call this yourself + * because it will automatically be called as a cleanup function. Otherwise + * you'll need to call this. + */ + uninit() { + gTestScope = null; + for (let p of TEST_SCOPE_PROPERTIES) { + this[p] = null; + } + Services.telemetry.clearScalars(); + } + + get DEFAULT_CONFIG() { + // Return a clone so callers can modify it. + return Cu.cloneInto(DEFAULT_CONFIG, this); + } + + /** + * Sets up local remote settings and Merino servers, registers test + * suggestions, and initializes Suggest. + * + * @param {object} options + * Options object + * @param {Array} options.remoteSettingsRecords + * Array of remote settings records. Each item in this array should be a + * realistic remote settings record with some exceptions, e.g., + * `record.attachment`, if defined, should be the attachment itself and not + * its metadata. For details see `RemoteSettingsServer.addRecords()`. + * @param {Array} options.merinoSuggestions + * Array of Merino suggestion objects. If given, this function will start + * the mock Merino server and set `quicksuggest.dataCollection.enabled` to + * true so that `UrlbarProviderQuickSuggest` will fetch suggestions from it. + * Otherwise Merino will not serve suggestions, but you can still set up + * Merino without using this function by using `MerinoTestUtils` directly. + * @param {object} options.config + * The Suggest configuration object. This should not be the full remote + * settings record; only pass the object that should be set to the nested + * `configuration` object inside the record. + * @param {Array} options.prefs + * An array of Suggest-related prefs to set. This is useful because setting + * some prefs, like feature gates, can cause Suggest to sync from remote + * settings; this function will set them, wait for sync to finish, and clear + * them when the cleanup function is called. Each item in this array should + * itself be a two-element array `[prefName, prefValue]` similar to the + * `set` array passed to `SpecialPowers.pushPrefEnv()`, except here pref + * names are relative to `browser.urlbar`. + * @returns {Function} + * An async cleanup function. This function is automatically registered as a + * cleanup function, so you only need to call it if your test needs to clean + * up Suggest before it ends, for example if you have a small number of + * tasks that need Suggest and it's not enabled throughout your test. The + * cleanup function is idempotent so there's no harm in calling it more than + * once. Be sure to `await` it. + */ + async ensureQuickSuggestInit({ + remoteSettingsRecords = [], + merinoSuggestions = null, + config = DEFAULT_CONFIG, + prefs = [], + } = {}) { + prefs.push(["quicksuggest.enabled", true]); + + // Set up the local remote settings server. + this.#log( + "ensureQuickSuggestInit", + "Started, preparing remote settings server" + ); + if (!this.#remoteSettingsServer) { + this.#remoteSettingsServer = new lazy.RemoteSettingsServer(); + } + await this.#remoteSettingsServer.setRecords({ + collection: "quicksuggest", + records: [ + ...remoteSettingsRecords, + { type: "configuration", configuration: config }, + ], + }); + this.#log("ensureQuickSuggestInit", "Starting remote settings server"); + await this.#remoteSettingsServer.start(); + this.#log("ensureQuickSuggestInit", "Remote settings server started"); + + // Get the cached `RemoteSettings` client used by the JS backend and tell it + // to ignore signatures and to always force sync. Otherwise it won't sync if + // the previous sync was recent enough, which is incompatible with testing. + let rs = lazy.RemoteSettings("quicksuggest"); + let { get, verifySignature } = rs; + rs.verifySignature = false; + rs.get = opts => get.call(rs, { forceSync: true, ...opts }); + this.#restoreRemoteSettings = () => { + rs.verifySignature = verifySignature; + rs.get = get; + }; + + // Finally, init Suggest and set prefs. Do this after setting up remote + // settings because the current backend will immediately try to sync. + this.#log( + "ensureQuickSuggestInit", + "Calling QuickSuggest.init() and setting prefs" + ); + lazy.QuickSuggest.init(); + for (let [name, value] of prefs) { + lazy.UrlbarPrefs.set(name, value); + } + + // Tell the Rust backend to use the local remote setting server. + await lazy.QuickSuggest.rustBackend._test_setRemoteSettingsConfig( + new lazy.RemoteSettingsConfig({ + collectionName: "quicksuggest", + bucketName: "main", + serverUrl: this.#remoteSettingsServer.url.toString(), + }) + ); + + // Wait for the current backend to finish syncing. + await this.forceSync(); + + // Set up Merino. This can happen any time relative to Suggest init. + if (merinoSuggestions) { + this.#log("ensureQuickSuggestInit", "Setting up Merino server"); + await lazy.MerinoTestUtils.server.start(); + lazy.MerinoTestUtils.server.response.body.suggestions = merinoSuggestions; + lazy.UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true); + this.#log("ensureQuickSuggestInit", "Done setting up Merino server"); + } + + let cleanupCalled = false; + let cleanup = async () => { + if (!cleanupCalled) { + cleanupCalled = true; + await this.#uninitQuickSuggest(prefs, !!merinoSuggestions); + } + }; + this.registerCleanupFunction?.(cleanup); + + this.#log("ensureQuickSuggestInit", "Done"); + return cleanup; + } + + async #uninitQuickSuggest(prefs, clearDataCollectionEnabled) { + this.#log("#uninitQuickSuggest", "Started"); + + // Reset prefs, which can cause the current backend to start syncing. Wait + // for it to finish. + for (let [name] of prefs) { + lazy.UrlbarPrefs.clear(name); + } + await this.forceSync(); + + this.#log("#uninitQuickSuggest", "Stopping remote settings server"); + await this.#remoteSettingsServer.stop(); + this.#restoreRemoteSettings(); + + if (clearDataCollectionEnabled) { + lazy.UrlbarPrefs.clear("quicksuggest.dataCollection.enabled"); + } + + this.#log("#uninitQuickSuggest", "Done"); + } + + /** + * Removes all records from the local remote settings server and adds a new + * batch of records. + * + * @param {Array} records + * Array of remote settings records. See `ensureQuickSuggestInit()`. + * @param {object} options + * Options object. + * @param {boolean} options.forceSync + * Whether to force Suggest to sync after updating the records. + */ + async setRemoteSettingsRecords(records, { forceSync = true } = {}) { + this.#log("setRemoteSettingsRecords", "Started"); + await this.#remoteSettingsServer.setRecords({ + collection: "quicksuggest", + records, + }); + if (forceSync) { + this.#log("setRemoteSettingsRecords", "Forcing sync"); + await this.forceSync(); + } + this.#log("setRemoteSettingsRecords", "Done"); + } + + /** + * Sets the quick suggest configuration. You should call this again with + * `DEFAULT_CONFIG` before your test finishes. See also `withConfig()`. + * + * @param {object} config + * The quick suggest configuration object. This should not be the full + * remote settings record; only pass the object that should be set to the + * `configuration` nested object inside the record. + */ + async setConfig(config) { + this.#log("setConfig", "Started"); + let type = "configuration"; + this.#remoteSettingsServer.removeRecords({ type }); + await this.#remoteSettingsServer.addRecords({ + collection: "quicksuggest", + records: [{ type, configuration: config }], + }); + this.#log("setConfig", "Forcing sync"); + await this.forceSync(); + this.#log("setConfig", "Done"); + } + + /** + * Forces Suggest to sync with remote settings. This can be used to ensure + * Suggest has finished all sync activity. + */ + async forceSync() { + this.#log("forceSync", "Started"); + if (lazy.QuickSuggest.rustBackend.isEnabled) { + this.#log("forceSync", "Syncing Rust backend"); + await lazy.QuickSuggest.rustBackend._test_ingest(); + this.#log("forceSync", "Done syncing Rust backend"); + } + if (lazy.QuickSuggest.jsBackend.isEnabled) { + this.#log("forceSync", "Syncing JS backend"); + await lazy.QuickSuggest.jsBackend._test_syncAll(); + this.#log("forceSync", "Done syncing JS backend"); + } + this.#log("forceSync", "Done"); + } + + /** + * Sets the quick suggest configuration, calls your callback, and restores the + * previous configuration. + * + * @param {object} options + * The options object. + * @param {object} options.config + * The configuration that should be used with the callback + * @param {Function} options.callback + * Will be called with the configuration applied + * + * @see {@link setConfig} + */ + async withConfig({ config, callback }) { + let original = lazy.QuickSuggest.jsBackend.config; + await this.setConfig(config); + await callback(); + await this.setConfig(original); + } + + /** + * Returns an AMP (sponsored) suggestion suitable for storing in a remote + * settings attachment. + * + * @returns {object} + * An AMP suggestion for storing in remote settings. + */ + ampRemoteSettings({ + keywords = ["amp"], + url = "http://example.com/amp", + title = "Amp Suggestion", + score = 0.3, + }) { + return { + keywords, + url, + title, + score, + id: 1, + click_url: "http://example.com/amp-click", + impression_url: "http://example.com/amp-impression", + advertiser: "Amp", + iab_category: "22 - Shopping", + icon: "1234", + }; + } + + /** + * Returns a Wikipedia (non-sponsored) suggestion suitable for storing in a + * remote settings attachment. + * + * @returns {object} + * A Wikipedia suggestion for storing in remote settings. + */ + wikipediaRemoteSettings({ + keywords = ["wikipedia"], + url = "http://example.com/wikipedia", + title = "Wikipedia Suggestion", + score = 0.2, + }) { + return { + keywords, + url, + title, + score, + id: 2, + click_url: "http://example.com/wikipedia-click", + impression_url: "http://example.com/wikipedia-impression", + advertiser: "Wikipedia", + iab_category: "5 - Education", + icon: "1234", + }; + } + + /** + * Returns an AMO (addons) suggestion suitable for storing in a remote + * settings attachment. + * + * @returns {object} + * An AMO suggestion for storing in remote settings. + */ + amoRemoteSettings({ + keywords = ["amo"], + url = "http://example.com/amo", + title = "Amo Suggestion", + score = 0.2, + }) { + return { + keywords, + url, + title, + score, + guid: "amo-suggestion@example.com", + icon: "https://example.com/addon.svg", + rating: "4.7", + description: "Addon with score", + number_of_ratings: 1256, + }; + } + + /** + * Sets the Firefox Suggest scenario and waits for prefs to be updated. + * + * @param {string} scenario + * Pass falsey to reset the scenario to the default. + */ + async setScenario(scenario) { + // If we try to set the scenario before a previous update has finished, + // `updateFirefoxSuggestScenario` will bail, so wait. + await this.waitForScenarioUpdated(); + await lazy.UrlbarPrefs.updateFirefoxSuggestScenario({ scenario }); + } + + /** + * Waits for any prior scenario update to finish. + */ + async waitForScenarioUpdated() { + await lazy.TestUtils.waitForCondition( + () => !lazy.UrlbarPrefs.updatingFirefoxSuggestScenario, + "Waiting for updatingFirefoxSuggestScenario to be false" + ); + } + + /** + * Asserts a result is a quick suggest result. + * + * @param {object} [options] + * The options object. + * @param {string} options.url + * The expected URL. At least one of `url` and `originalUrl` must be given. + * @param {string} options.originalUrl + * The expected original URL (the URL with an unreplaced timestamp + * template). At least one of `url` and `originalUrl` must be given. + * @param {object} options.window + * The window that should be used for this assertion + * @param {number} [options.index] + * The expected index of the quick suggest result. Pass -1 to use the index + * of the last result. + * @param {boolean} [options.isSponsored] + * Whether the result is expected to be sponsored. + * @param {boolean} [options.isBestMatch] + * Whether the result is expected to be a best match. + * @returns {result} + * The quick suggest result. + */ + async assertIsQuickSuggest({ + url, + originalUrl, + window, + index = -1, + isSponsored = true, + isBestMatch = false, + } = {}) { + this.Assert.ok( + url || originalUrl, + "At least one of url and originalUrl is specified" + ); + + if (index < 0) { + let resultCount = lazy.UrlbarTestUtils.getResultCount(window); + if (isBestMatch) { + index = 1; + this.Assert.greater( + resultCount, + 1, + "Sanity check: Result count should be > 1" + ); + } else { + index = resultCount - 1; + this.Assert.greater( + resultCount, + 0, + "Sanity check: Result count should be > 0" + ); + } + } + + let details = await lazy.UrlbarTestUtils.getDetailsOfResultAt( + window, + index + ); + let { result } = details; + + this.#log( + "assertIsQuickSuggest", + `Checking actual result at index ${index}: ` + JSON.stringify(result) + ); + + this.Assert.equal( + result.providerName, + "UrlbarProviderQuickSuggest", + "Result provider name is UrlbarProviderQuickSuggest" + ); + this.Assert.equal(details.type, lazy.UrlbarUtils.RESULT_TYPE.URL); + this.Assert.equal(details.isSponsored, isSponsored, "Result isSponsored"); + if (url) { + this.Assert.equal(details.url, url, "Result URL"); + } + if (originalUrl) { + this.Assert.equal( + result.payload.originalUrl, + originalUrl, + "Result original URL" + ); + } + + this.Assert.equal(!!result.isBestMatch, isBestMatch, "Result isBestMatch"); + + let { row } = details.element; + + let sponsoredElement = row._elements.get("description"); + if (isSponsored || isBestMatch) { + this.Assert.ok(sponsoredElement, "Result sponsored label element exists"); + this.Assert.equal( + sponsoredElement.textContent, + isSponsored ? "Sponsored" : "", + "Result sponsored label" + ); + } else { + this.Assert.ok( + !sponsoredElement, + "Result sponsored label element should not exist" + ); + } + + this.Assert.equal( + result.payload.helpUrl, + lazy.QuickSuggest.HELP_URL, + "Result helpURL" + ); + + this.Assert.ok( + row._buttons.get("menu"), + "The menu button should be present" + ); + + return details; + } + + /** + * Asserts a result is not a quick suggest result. + * + * @param {object} window + * The window that should be used for this assertion + * @param {number} index + * The index of the result. + */ + async assertIsNotQuickSuggest(window, index) { + let details = await lazy.UrlbarTestUtils.getDetailsOfResultAt( + window, + index + ); + this.Assert.notEqual( + details.result.providerName, + "UrlbarProviderQuickSuggest", + `Result at index ${index} is not provided by UrlbarProviderQuickSuggest` + ); + } + + /** + * Asserts that none of the results are quick suggest results. + * + * @param {object} window + * The window that should be used for this assertion + */ + async assertNoQuickSuggestResults(window) { + for (let i = 0; i < lazy.UrlbarTestUtils.getResultCount(window); i++) { + await this.assertIsNotQuickSuggest(window, i); + } + } + + /** + * Checks the values of all the quick suggest telemetry keyed scalars and, + * if provided, other non-quick-suggest keyed scalars. Scalar values are all + * assumed to be 1. + * + * @param {object} expectedKeysByScalarName + * Maps scalar names to keys that are expected to be recorded. The value for + * each key is assumed to be 1. If you expect a scalar to be incremented, + * include it in this object; otherwise, don't include it. + */ + assertScalars(expectedKeysByScalarName) { + let scalars = lazy.TelemetryTestUtils.getProcessScalars( + "parent", + true, + true + ); + + // Check all quick suggest scalars. + expectedKeysByScalarName = { ...expectedKeysByScalarName }; + for (let scalarName of Object.values( + lazy.UrlbarProviderQuickSuggest.TELEMETRY_SCALARS + )) { + if (scalarName in expectedKeysByScalarName) { + lazy.TelemetryTestUtils.assertKeyedScalar( + scalars, + scalarName, + expectedKeysByScalarName[scalarName], + 1 + ); + delete expectedKeysByScalarName[scalarName]; + } else { + this.Assert.ok( + !(scalarName in scalars), + "Scalar should not be present: " + scalarName + ); + } + } + + // Check any other remaining scalars that were passed in. + for (let [scalarName, key] of Object.entries(expectedKeysByScalarName)) { + lazy.TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, key, 1); + } + } + + /** + * Checks quick suggest telemetry events. This is the same as + * `TelemetryTestUtils.assertEvents()` except it filters in only quick suggest + * events by default. If you are expecting events that are not in the quick + * suggest category, use `TelemetryTestUtils.assertEvents()` directly or pass + * in a filter override for `category`. + * + * @param {Array} expectedEvents + * List of expected telemetry events. + * @param {object} filterOverrides + * Extra properties to set in the filter object. + * @param {object} options + * The options object to pass to `TelemetryTestUtils.assertEvents()`. + */ + assertEvents(expectedEvents, filterOverrides = {}, options = undefined) { + lazy.TelemetryTestUtils.assertEvents( + expectedEvents, + { + category: lazy.QuickSuggest.TELEMETRY_EVENT_CATEGORY, + ...filterOverrides, + }, + options + ); + } + + /** + * Asserts that URLs in a result's payload have the timestamp template + * substring replaced with real timestamps. + * + * @param {UrlbarResult} result The results to check + * @param {object} urls + * An object that contains the expected payload properties with template + * substrings. For example: + * ```js + * { + * url: "http://example.com/foo-%YYYYMMDDHH%", + * sponsoredClickUrl: "http://example.com/bar-%YYYYMMDDHH%", + * } + * ``` + */ + assertTimestampsReplaced(result, urls) { + let { TIMESTAMP_TEMPLATE, TIMESTAMP_LENGTH } = lazy.QuickSuggest; + + // Parse the timestamp strings from each payload property and save them in + // `urls[key].timestamp`. + urls = { ...urls }; + for (let [key, url] of Object.entries(urls)) { + let index = url.indexOf(TIMESTAMP_TEMPLATE); + this.Assert.ok( + index >= 0, + `Timestamp template ${TIMESTAMP_TEMPLATE} is in URL ${url} for key ${key}` + ); + let value = result.payload[key]; + this.Assert.ok(value, "Key is in result payload: " + key); + let timestamp = value.substring(index, index + TIMESTAMP_LENGTH); + + // Set `urls[key]` to an object that's helpful in the logged info message + // below. + urls[key] = { url, value, timestamp }; + } + + this.#log( + "assertTimestampsReplaced", + "Parsed timestamps: " + JSON.stringify(urls) + ); + + // Make a set of unique timestamp strings. There should only be one. + let { timestamp } = Object.values(urls)[0]; + this.Assert.deepEqual( + [...new Set(Object.values(urls).map(o => o.timestamp))], + [timestamp], + "There's only one unique timestamp string" + ); + + // Parse the parts of the timestamp string. + let year = timestamp.slice(0, -6); + let month = timestamp.slice(-6, -4); + let day = timestamp.slice(-4, -2); + let hour = timestamp.slice(-2); + let date = new Date(year, month - 1, day, hour); + + // The timestamp should be no more than two hours in the past. Typically it + // will be the same as the current hour, but since its resolution is in + // terms of hours and it's possible the test may have crossed over into a + // new hour as it was running, allow for the previous hour. + this.Assert.less( + Date.now() - 2 * 60 * 60 * 1000, + date.getTime(), + "Timestamp is within the past two hours" + ); + } + + /** + * Calls a callback while enrolled in a mock Nimbus experiment. The experiment + * is automatically unenrolled and cleaned up after the callback returns. + * + * @param {object} options + * Options for the mock experiment. + * @param {Function} options.callback + * The callback to call while enrolled in the mock experiment. + * @param {object} options.options + * See {@link enrollExperiment}. + */ + async withExperiment({ callback, ...options }) { + let doExperimentCleanup = await this.enrollExperiment(options); + await callback(); + await doExperimentCleanup(); + } + + /** + * Enrolls in a mock Nimbus experiment. + * + * @param {object} options + * Options for the mock experiment. + * @param {object} [options.valueOverrides] + * Values for feature variables. + * @returns {Promise} + * The experiment cleanup function (async). + */ + async enrollExperiment({ valueOverrides = {} }) { + this.#log("enrollExperiment", "Awaiting ExperimentAPI.ready"); + await lazy.ExperimentAPI.ready(); + + // Wait for any prior scenario updates to finish. If updates are ongoing, + // UrlbarPrefs will ignore the Nimbus update when the experiment is + // installed. This shouldn't be a problem in practice because in reality + // scenario updates are triggered only on app startup and Nimbus + // enrollments, but tests can trigger lots of updates back to back. + await this.waitForScenarioUpdated(); + + let doExperimentCleanup = + await lazy.ExperimentFakes.enrollWithFeatureConfig({ + enabled: true, + featureId: "urlbar", + value: valueOverrides, + }); + + // Wait for the pref updates triggered by the experiment enrollment. + this.#log( + "enrollExperiment", + "Awaiting update after enrolling in experiment" + ); + await this.waitForScenarioUpdated(); + + return async () => { + this.#log("enrollExperiment.cleanup", "Awaiting experiment cleanup"); + await doExperimentCleanup(); + + // The same pref updates will be triggered by unenrollment, so wait for + // them again. + this.#log( + "enrollExperiment.cleanup", + "Awaiting update after unenrolling in experiment" + ); + await this.waitForScenarioUpdated(); + }; + } + + /** + * Sets the app's locales, calls your callback, and resets locales. + * + * @param {Array} locales + * An array of locale strings. The entire array will be set as the available + * locales, and the first locale in the array will be set as the requested + * locale. + * @param {Function} callback + * The callback to be called with the {@link locales} set. This function can + * be async. + */ + async withLocales(locales, callback) { + let promiseChanges = async desiredLocales => { + this.#log( + "withLocales", + "Changing locales from " + + JSON.stringify(Services.locale.requestedLocales) + + " to " + + JSON.stringify(desiredLocales) + ); + + if (desiredLocales[0] == Services.locale.requestedLocales[0]) { + // Nothing happens when the locale doesn't actually change. + return; + } + + this.#log("withLocales", "Waiting for intl:requested-locales-changed"); + await lazy.TestUtils.topicObserved("intl:requested-locales-changed"); + this.#log("withLocales", "Observed intl:requested-locales-changed"); + + // Wait for the search service to reload engines. Otherwise tests can fail + // in strange ways due to internal search service state during shutdown. + // It won't always reload engines but it's hard to tell in advance when it + // won't, so also set a timeout. + this.#log("withLocales", "Waiting for TOPIC_SEARCH_SERVICE"); + await Promise.race([ + lazy.TestUtils.topicObserved( + lazy.SearchUtils.TOPIC_SEARCH_SERVICE, + (subject, data) => { + this.#log( + "withLocales", + "Observed TOPIC_SEARCH_SERVICE with data: " + data + ); + return data == "engines-reloaded"; + } + ), + new Promise(resolve => { + lazy.setTimeout(() => { + this.#log( + "withLocales", + "Timed out waiting for TOPIC_SEARCH_SERVICE" + ); + resolve(); + }, 2000); + }), + ]); + + this.#log("withLocales", "Done waiting for locale changes"); + }; + + let available = Services.locale.availableLocales; + let requested = Services.locale.requestedLocales; + + let newRequested = locales.slice(0, 1); + let promise = promiseChanges(newRequested); + Services.locale.availableLocales = locales; + Services.locale.requestedLocales = newRequested; + await promise; + + this.Assert.equal( + Services.locale.appLocaleAsBCP47, + locales[0], + "App locale is now " + locales[0] + ); + + await callback(); + + promise = promiseChanges(requested); + Services.locale.availableLocales = available; + Services.locale.requestedLocales = requested; + await promise; + } + + #log(fnName, msg) { + this.info?.(`QuickSuggestTestUtils.${fnName} ${msg}`); + } + + #remoteSettingsServer; + #restoreRemoteSettings; +} + +export var QuickSuggestTestUtils = new _QuickSuggestTestUtils(); diff --git a/browser/components/urlbar/tests/quicksuggest/RemoteSettingsServer.sys.mjs b/browser/components/urlbar/tests/quicksuggest/RemoteSettingsServer.sys.mjs new file mode 100644 index 0000000000..32b42198c3 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/RemoteSettingsServer.sys.mjs @@ -0,0 +1,619 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* eslint-disable jsdoc/require-param-description */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + HttpError: "resource://testing-common/httpd.sys.mjs", + HttpServer: "resource://testing-common/httpd.sys.mjs", + HTTP_404: "resource://testing-common/httpd.sys.mjs", + Log: "resource://gre/modules/Log.sys.mjs", +}); + +const SERVER_PREF = "services.settings.server"; + +/** + * A remote settings server. Tested with the desktop and Rust remote settings + * clients. + */ +export class RemoteSettingsServer { + /** + * The server must be started by calling `start()`. + * + * @param {object} options + * @param {number} options.logLevel + * A `Log.Level` value from `Log.sys.mjs`. `Log.Level.Info` logs basic info + * on requests and responses like paths and status codes. Pass + * `Log.Level.Debug` to log more info like headers, response bodies, and + * added and removed records. + */ + constructor({ logLevel = lazy.Log.Level.Info } = {}) { + this.#log = lazy.Log.repository.getLogger("RemoteSettingsServer"); + this.#log.level = logLevel; + + // Use `DumpAppender` instead of `ConsoleAppender`. The xpcshell and browser + // test harnesses buffer console messages and log them later, which makes it + // really hard to debug problems. `DumpAppender` logs to stdout, which the + // harnesses log immediately. + this.#log.addAppender( + new lazy.Log.DumpAppender(new lazy.Log.BasicFormatter()) + ); + } + + /** + * @returns {URL} + * The server's URL. Null when the server is stopped. + */ + get url() { + return this.#url; + } + + /** + * Starts the server and sets the `services.settings.server` pref to its + * URL. The server's `url` property will be non-null on return. + */ + async start() { + this.#log.info("Starting"); + + if (this.#url) { + this.#log.info("Already started at " + this.#url); + return; + } + + if (!this.#server) { + this.#server = new lazy.HttpServer(); + this.#server.registerPrefixHandler("/", this); + } + this.#server.start(-1); + + this.#url = new URL("http://localhost/v1"); + this.#url.port = this.#server.identity.primaryPort; + + this.#originalServerPrefValue = Services.prefs.getCharPref( + SERVER_PREF, + null + ); + Services.prefs.setCharPref(SERVER_PREF, this.#url.toString()); + + this.#log.info("Server is now started at " + this.#url); + } + + /** + * Stops the server and clears the `services.settings.server` pref. The + * server's `url` property will be null on return. + */ + async stop() { + this.#log.info("Stopping"); + + if (!this.#url) { + this.#log.info("Already stopped"); + return; + } + + await this.#server.stop(); + this.#url = null; + + if (this.#originalServerPrefValue === null) { + Services.prefs.clearUserPref(SERVER_PREF); + } else { + Services.prefs.setCharPref(SERVER_PREF, this.#originalServerPrefValue); + } + + this.#log.info("Server is now stopped"); + } + + /** + * Adds remote settings records to the server. Records may have attachments; + * see the param doc below. + * + * @param {object} options + * @param {string} options.bucket + * @param {string} options.collection + * @param {Array} options.records + * Each object in this array should be a realistic remote settings record + * with the following exceptions: + * + * - `record.id` will be generated if it's undefined. + * - `record.last_modified` will be set to the `#lastModified` property of + * the server if it's undefined. + * - `record.attachment`, if defined, should be the attachment itself and + * not its metadata. The server will automatically create some dummy + * metadata. Currently the only supported attachment type is plain + * JSON'able objects that the server will convert to JSON in responses. + */ + async addRecords({ bucket = "main", collection = "test", records }) { + this.#log.debug( + "Adding records: " + + JSON.stringify({ bucket, collection, records }, null, 2) + ); + + this.#lastModified++; + + let key = this.#recordsKey(bucket, collection); + let allRecords = this.#records.get(key); + if (!allRecords) { + allRecords = []; + this.#records.set(key, allRecords); + } + + for (let record of records) { + let copy = { ...record }; + if (!copy.hasOwnProperty("id")) { + copy.id = String(this.#nextRecordId++); + } + if (!copy.hasOwnProperty("last_modified")) { + copy.last_modified = this.#lastModified; + } + if (copy.attachment) { + await this.#addAttachment({ bucket, collection, record: copy }); + } + allRecords.push(copy); + } + + this.#log.debug( + "Done adding records. All records are now: " + + JSON.stringify([...this.#records.entries()], null, 2) + ); + } + + /** + * Marks records as deleted. Deleted records will still be returned in + * responses, but they'll have a `deleted = true` property. Their attachments + * will be deleted immediately, however. + * + * @param {object} filter + * If null, all records will be marked as deleted. Otherwise only records + * that match the filter will be marked as deleted. For a given record, each + * value in the filter object will be compared to the value with the same + * key in the record. If all values are the same, the record will be + * removed. Examples: + * + * To remove remove records whose `type` key has the value "data": + * `{ type: "data" } + * + * To remove remove records whose `type` key has the value "data" and whose + * `last_modified` key has the value 1234: + * `{ type: "data", last_modified: 1234 } + */ + removeRecords(filter = null) { + this.#log.debug("Removing records: " + JSON.stringify({ filter })); + + this.#lastModified++; + + for (let [recordsKey, records] of this.#records.entries()) { + for (let record of records) { + if ( + !filter || + Object.entries(filter).every( + ([filterKey, filterValue]) => + record.hasOwnProperty(filterKey) && + record[filterKey] == filterValue + ) + ) { + if (record.attachment) { + let attachmentKey = `${recordsKey}/${record.attachment.filename}`; + this.#attachments.delete(attachmentKey); + } + record.deleted = true; + record.last_modified = this.#lastModified; + } + } + } + + this.#log.debug( + "Done removing records. All records are now: " + + JSON.stringify([...this.#records.entries()], null, 2) + ); + } + + /** + * Removes all existing records and adds the given records to the server. + * + * @param {object} options + * @param {string} options.bucket + * @param {string} options.collection + * @param {Array} options.records + * See `addRecords()`. + */ + async setRecords({ bucket = "main", collection = "test", records }) { + this.#log.debug("Setting records"); + + this.removeRecords(); + await this.addRecords({ bucket, collection, records }); + + this.#log.debug("Done setting records"); + } + + /** + * `nsIHttpRequestHandler` callback from the backing server. Handles a + * request. + * + * @param {nsIHttpRequest} request + * @param {nsIHttpResponse} response + */ + handle(request, response) { + this.#logRequest(request); + + // Get the route that matches the request path. + let { match, route } = this.#getRoute(request.path) || {}; + if (!route) { + this.#prepareError({ request, response, error: lazy.HTTP_404 }); + return; + } + + let respInfo = route.response(match, request, response); + if (respInfo instanceof lazy.HttpError) { + this.#prepareError({ request, response, error: respInfo }); + } else { + this.#prepareResponse({ ...respInfo, request, response }); + } + } + + /** + * @returns {Array} + * The routes handled by the server. Each item in this array is an object + * with the following properties that describes one or more paths and the + * response that should be sent when a request is made on those paths: + * + * {string} spec + * A path spec. This is required unless `specs` is defined. To determine + * which route should be used for a given request, the server will check + * each route's spec(s) until it finds the first that matches the + * request's path. A spec is just a path whose components can be variables + * that start with "$". When a spec with variables matches a request path, + * the `match` object passed to the route's `response` function will map + * from variable names to the corresponding components in the path. + * {Array} specs + * An array of path spec strings. Use this instead of `spec` if the route + * handles more than one. + * {function} response + * A function that will be called when the route matches a request. It is + * called as: `response(match, request, response)` + * + * {object} match + * An object mapping variable names in the spec to their matched + * components in the path. See `#match()` for details. + * {nsIHttpRequest} request + * {nsIHttpResponse} response + * + * The function must return one of the following: + * + * {object} + * An object that describes the response with the following properties: + * {object} body + * A plain JSON'able object. The server will convert this to JSON and + * set it to the response body. + * {HttpError} + * An `HttpError` instance defined in `httpd.sys.mjs`. + */ + get #routes() { + return [ + { + spec: "/v1", + response: () => ({ + body: { + capabilities: { + attachments: { + base_url: this.#url.toString(), + }, + }, + }, + }), + }, + + { + spec: "/v1/buckets/monitor/collections/changes/changeset", + response: () => ({ + body: { + timestamp: this.#lastModified, + changes: [ + { + last_modified: this.#lastModified, + }, + ], + }, + }), + }, + + { + spec: "/v1/buckets/$bucket/collections/$collection/changeset", + response: ({ bucket, collection }) => { + let records = this.#getRecords(bucket, collection); + return !records + ? lazy.HTTP_404 + : { + body: { + metadata: null, + timestamp: this.#lastModified, + changes: records, + }, + }; + }, + }, + + { + spec: "/v1/buckets/$bucket/collections/$collection/records", + response: ({ bucket, collection }) => { + let records = this.#getRecords(bucket, collection); + return !records + ? lazy.HTTP_404 + : { + body: { + data: records, + }, + }; + }, + }, + + { + specs: [ + // The Rust remote settings client doesn't include "v1" in attachment + // URLs, but the JS client does. + "/attachments/$bucket/$collection/$filename", + "/v1/attachments/$bucket/$collection/$filename", + ], + response: ({ bucket, collection, filename }) => { + return { + body: this.#getAttachment(bucket, collection, filename), + }; + }, + }, + ]; + } + + /** + * @returns {object} + * Default response headers. + */ + get #responseHeaders() { + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Expose-Headers": + "Retry-After, Content-Length, Alert, Backoff", + Server: "waitress", + Etag: `"${this.#lastModified}"`, + }; + } + + /** + * Returns the route that matches a request path. + * + * @param {string} path + * A request path. + * @returns {object} + * If no route matches the path, returns an empty object. Otherwise returns + * an object with the following properties: + * + * {object} match + * An object describing the matched variables in the route spec. See + * `#match()` for details. + * {object} route + * The matched route. See `#routes` for details. + */ + #getRoute(path) { + for (let route of this.#routes) { + let specs = route.specs || [route.spec]; + for (let spec of specs) { + let match = this.#match(path, spec); + if (match) { + return { match, route }; + } + } + } + return {}; + } + + /** + * Matches a request path to a route spec. + * + * @param {string} path + * A request path. + * @param {string} spec + * A route spec. See `#routes` for details. + * @returns {object|null} + * If the spec doesn't match the path, returns null. Otherwise returns an + * object mapping variable names in the spec to their matched components in + * the path. Example: + * + * path : "/main/myfeature/foo" + * spec : "/$bucket/$collection/foo" + * returns: `{ bucket: "main", collection: "myfeature" }` + */ + #match(path, spec) { + let pathParts = path.split("/"); + let specParts = spec.split("/"); + + if (pathParts.length != specParts.length) { + // If the path has only one more part than the spec and its last part is + // empty, then the path ends in a trailing slash but the spec does not. + // Consider that a match. Otherwise return null for no match. + if ( + pathParts[pathParts.length - 1] || + pathParts.length != specParts.length + 1 + ) { + return null; + } + pathParts.pop(); + } + + let match = {}; + for (let i = 0; i < pathParts.length; i++) { + let pathPart = pathParts[i]; + let specPart = specParts[i]; + if (specPart.startsWith("$")) { + match[specPart.substring(1)] = pathPart; + } else if (pathPart != specPart) { + return null; + } + } + + return match; + } + + #getRecords(bucket, collection) { + return this.#records.get(this.#recordsKey(bucket, collection)); + } + + #recordsKey(bucket, collection) { + return `${bucket}/${collection}`; + } + + /** + * Registers an attachment for a record. + * + * @param {object} options + * @param {string} options.bucket + * @param {string} options.collection + * @param {object} options.record + * The record should have an `attachment` property as described in + * `addRecords()`. + */ + async #addAttachment({ bucket, collection, record }) { + let { attachment } = record; + let filename = record.id; + + this.#attachments.set( + this.#attachmentsKey(bucket, collection, filename), + attachment + ); + + let encoder = new TextEncoder(); + let bytes = encoder.encode(JSON.stringify(attachment)); + + let hashBuffer = await crypto.subtle.digest("SHA-256", bytes); + let hashBytes = new Uint8Array(hashBuffer); + let toHex = b => b.toString(16).padStart(2, "0"); + let hash = Array.from(hashBytes, toHex).join(""); + + // Replace `record.attachment` with appropriate metadata in order to conform + // with the remote settings API. + record.attachment = { + hash, + filename, + mimetype: "application/json; charset=UTF-8", + size: bytes.length, + location: `attachments/${bucket}/${collection}/${filename}`, + }; + } + + #attachmentsKey(bucket, collection, filename) { + return `${bucket}/${collection}/${filename}`; + } + + #getAttachment(bucket, collection, filename) { + return this.#attachments.get( + this.#attachmentsKey(bucket, collection, filename) + ); + } + + /** + * Prepares an HTTP response. + * + * @param {object} options + * @param {nsIHttpRequest} options.request + * @param {nsIHttpResponse} options.response + * @param {object|null} options.body + * Currently only JSON'able objects are supported. They will be converted to + * JSON in the response. + * @param {integer} options.status + * @param {string} options.statusText + */ + #prepareResponse({ + request, + response, + body = null, + status = 200, + statusText = "OK", + }) { + let headers = { ...this.#responseHeaders }; + if (body) { + headers["Content-Type"] = "application/json; charset=UTF-8"; + } + + this.#logResponse({ request, status, statusText, headers, body }); + + for (let [name, value] of Object.entries(headers)) { + response.setHeader(name, value, false); + } + if (body) { + response.write(JSON.stringify(body)); + } + response.setStatusLine(request.httpVersion, status, statusText); + } + + /** + * Prepares an HTTP error response. + * + * @param {object} options + * @param {nsIHttpRequest} options.request + * @param {nsIHttpResponse} options.response + * @param {HttpError} options.error + * An `HttpError` instance defined in `httpd.sys.mjs`. + */ + #prepareError({ request, response, error }) { + this.#prepareResponse({ + request, + response, + status: error.code, + statusText: error.description, + }); + } + + /** + * Logs a request. + * + * @param {nsIHttpRequest} request + */ + #logRequest(request) { + let pathAndQuery = request.path; + if (request.queryString) { + pathAndQuery += "?" + request.queryString; + } + this.#log.info( + `< HTTP ${request.httpVersion} ${request.method} ${pathAndQuery}` + ); + for (let name of request.headers) { + this.#log.debug(`${name}: ${request.getHeader(name.toString())}`); + } + } + + /** + * Logs a response. + * + * @param {object} options + * @param {nsIHttpRequest} options.request + * The associated request. + * @param {integer} options.status + * The HTTP status code of the response. + * @param {string} options.statusText + * The description of the status code. + * @param {object} options.headers + * An object mapping from response header names to values. + * @param {object} options.body + * The response body, if any. + */ + #logResponse({ request, status, statusText, headers, body }) { + this.#log.info(`> ${status} ${request.path}`); + for (let [name, value] of Object.entries(headers)) { + this.#log.debug(`${name}: ${value}`); + } + if (body) { + this.#log.debug("Response body: " + JSON.stringify(body, null, 2)); + } + } + + // records key (see `#recordsKey()`) -> array of record objects + #records = new Map(); + + // attachments key (see `#attachmentsKey()`) -> attachment object + #attachments = new Map(); + + #log; + #server; + #originalServerPrefValue; + #url = null; + #lastModified = 1368273600000; + #nextRecordId = 1; +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser.toml b/browser/components/urlbar/tests/quicksuggest/browser/browser.toml new file mode 100644 index 0000000000..a77d26c2a6 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser.toml @@ -0,0 +1,68 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +[DEFAULT] +support-files = [ + "head.js", + "searchSuggestionEngine.xml", + "searchSuggestionEngine.sjs", + "subdialog.xhtml", +] +prefs = ["browser.bookmarks.testing.skipDefaultBookmarksImport=true"] + +["browser_quicksuggest.js"] + +["browser_quicksuggest_addons.js"] + +["browser_quicksuggest_block.js"] + +["browser_quicksuggest_configuration.js"] + +["browser_quicksuggest_indexes.js"] + +["browser_quicksuggest_mdn.js"] + +["browser_quicksuggest_merinoSessions.js"] + +["browser_quicksuggest_onboardingDialog.js"] +skip-if = ["os == 'linux' && bits == 64"] # Bug 1773830 + +["browser_quicksuggest_pocket.js"] +tags = "search-telemetry" + +["browser_quicksuggest_yelp.js"] + +["browser_telemetry_dynamicWikipedia.js"] +tags = "search-telemetry" +skip-if = ["true"] # Bug 1880214 + +["browser_telemetry_gleanEmptyStrings.js"] +tags = "search-telemetry" +skip-if = ["true"] # Bug 1880214 + +["browser_telemetry_impressionEdgeCases.js"] +tags = "search-telemetry" +skip-if = ["true"] # Bug 1880214 + +["browser_telemetry_navigationalSuggestions.js"] +tags = "search-telemetry" +skip-if = ["true"] # Bug 1880214 + +["browser_telemetry_nonsponsored.js"] +tags = "search-telemetry" +skip-if = ["true"] # Bug 1880214 + +["browser_telemetry_other.js"] +tags = "search-telemetry" +skip-if = ["true"] # Bug 1880214 + +["browser_telemetry_sponsored.js"] +tags = "search-telemetry" +skip-if = ["true"] # Bug 1880214 + +["browser_telemetry_weather.js"] +tags = "search-telemetry" +skip-if = ["true"] # Bug 1880214 + +["browser_weather.js"] diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js new file mode 100644 index 0000000000..130afe8c53 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js @@ -0,0 +1,166 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests browser quick suggestions. + */ + +const TEST_URL = "http://example.com/quicksuggest"; + +const REMOTE_SETTINGS_RESULTS = [ + { + id: 1, + url: `${TEST_URL}?q=frabbits`, + title: "frabbits", + keywords: ["fra", "frab"], + click_url: "http://click.reporting.test.com/", + impression_url: "http://impression.reporting.test.com/", + advertiser: "TestAdvertiser", + iab_category: "22 - Shopping", + icon: "1234", + }, + { + id: 2, + url: `${TEST_URL}?q=nonsponsored`, + title: "Non-Sponsored", + keywords: ["nonspon"], + click_url: "http://click.reporting.test.com/nonsponsored", + impression_url: "http://impression.reporting.test.com/nonsponsored", + advertiser: "Wikipedia", + iab_category: "5 - Education", + icon: "1234", + }, +]; + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await UrlbarTestUtils.formHistory.clear(); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + }); +}); + +// Tests a sponsored result and keyword highlighting. +add_tasks_with_rust(async function sponsored() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "fra", + }); + await QuickSuggestTestUtils.assertIsQuickSuggest({ + window, + index: 1, + isSponsored: true, + url: `${TEST_URL}?q=frabbits`, + }); + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + Assert.equal( + row.querySelector(".urlbarView-title").firstChild.textContent, + "fra", + "The part of the keyword that matches users input is not bold." + ); + Assert.equal( + row.querySelector(".urlbarView-title > strong").textContent, + "b", + "The auto completed section of the keyword is bolded." + ); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Tests a non-sponsored result. +add_tasks_with_rust(async function nonSponsored() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "nonspon", + }); + await QuickSuggestTestUtils.assertIsQuickSuggest({ + window, + index: 1, + isSponsored: false, + url: `${TEST_URL}?q=nonsponsored`, + }); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Tests sponsored priority feature. +add_tasks_with_rust(async function sponsoredPriority() { + const cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature({ + quickSuggestSponsoredPriority: true, + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "fra", + }); + await QuickSuggestTestUtils.assertIsQuickSuggest({ + window, + index: 1, + isSponsored: true, + isBestMatch: true, + url: `${TEST_URL}?q=frabbits`, + }); + + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + Assert.equal( + row.querySelector(".urlbarView-title").firstChild.textContent, + "fra", + "The part of the keyword that matches users input is not bold." + ); + Assert.equal( + row.querySelector(".urlbarView-title > strong").textContent, + "b", + "The auto completed section of the keyword is bolded." + ); + + // Group label. + let before = window.getComputedStyle(row, "::before"); + Assert.equal(before.content, "attr(label)", "::before.content is enabled"); + Assert.equal( + row.getAttribute("label"), + "Top pick", + "Row has 'Top pick' group label" + ); + + await UrlbarTestUtils.promisePopupClose(window); + await cleanUpNimbus(); +}); + +// Tests sponsored priority feature does not affect to non-sponsored suggestion. +add_tasks_with_rust( + async function sponsoredPriorityButNotSponsoredSuggestion() { + const cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature({ + quickSuggestSponsoredPriority: true, + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "nonspon", + }); + await QuickSuggestTestUtils.assertIsQuickSuggest({ + window, + index: 1, + isSponsored: false, + url: `${TEST_URL}?q=nonsponsored`, + }); + + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + let before = window.getComputedStyle(row, "::before"); + Assert.equal(before.content, "attr(label)", "::before.content is enabled"); + Assert.equal( + row.getAttribute("label"), + "Firefox Suggest", + "Row has general group label for quick suggest" + ); + + await UrlbarTestUtils.promisePopupClose(window); + await cleanUpNimbus(); + } +); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_addons.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_addons.js new file mode 100644 index 0000000000..b09345aa54 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_addons.js @@ -0,0 +1,443 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for addon suggestions. + +// The expected index of the addon suggestion. +const EXPECTED_RESULT_INDEX = 1; + +// Allow more time for TV runs. +requestLongerTimeout(5); + +// TODO: Firefox no longer uses `rating` and `number_of_ratings` but they are +// still present in Merino and RS suggestions, so they are included here for +// greater accuracy. We should remove them from Merino, RS, and tests. +const TEST_MERINO_SUGGESTIONS = [ + { + provider: "amo", + icon: "https://example.com/first.svg", + url: "https://example.com/first-addon", + title: "First Addon", + description: "This is a first addon", + custom_details: { + amo: { + rating: "5", + number_of_ratings: "1234567", + guid: "first@addon", + }, + }, + is_top_pick: true, + }, + { + provider: "amo", + icon: "https://example.com/second.png", + url: "https://example.com/second-addon", + title: "Second Addon", + description: "This is a second addon", + custom_details: { + amo: { + rating: "4.5", + number_of_ratings: "123", + guid: "second@addon", + }, + }, + is_sponsored: true, + is_top_pick: false, + }, + { + provider: "amo", + icon: "https://example.com/third.svg", + url: "https://example.com/third-addon", + title: "Third Addon", + description: "This is a third addon", + custom_details: { + amo: { + rating: "0", + number_of_ratings: "0", + guid: "third@addon", + }, + }, + is_top_pick: false, + }, + { + provider: "amo", + icon: "https://example.com/fourth.svg", + url: "https://example.com/fourth-addon", + title: "Fourth Addon", + description: "This is a fourth addon", + custom_details: { + amo: { + rating: "4", + number_of_ratings: "4", + guid: "fourth@addon", + }, + }, + }, +]; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Disable search suggestions so we don't hit the network. + ["browser.search.suggest.enabled", false], + ], + }); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + merinoSuggestions: TEST_MERINO_SUGGESTIONS, + }); +}); + +add_task(async function basic() { + for (const merinoSuggestion of TEST_MERINO_SUGGESTIONS) { + MerinoTestUtils.server.response.body.suggestions = [merinoSuggestion]; + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "only match the Merino suggestion", + }); + Assert.equal(UrlbarTestUtils.getResultCount(window), 2); + + const { element, result } = await UrlbarTestUtils.getDetailsOfResultAt( + window, + 1 + ); + const row = element.row; + const icon = row.querySelector(".urlbarView-favicon"); + Assert.equal(icon.src, merinoSuggestion.icon); + const url = row.querySelector(".urlbarView-url"); + const expectedUrl = makeExpectedUrl(merinoSuggestion.url); + const displayUrl = expectedUrl.replace(/^https:\/\//, ""); + Assert.equal(url.textContent, displayUrl); + const title = row.querySelector(".urlbarView-title"); + Assert.equal(title.textContent, merinoSuggestion.title); + const description = row.querySelector(".urlbarView-row-body-description"); + Assert.equal(description.textContent, merinoSuggestion.description); + const bottom = row.querySelector(".urlbarView-row-body-bottom"); + Assert.equal(bottom.textContent, "Recommended"); + Assert.ok( + BrowserTestUtils.isVisible( + row.querySelector(".urlbarView-title-separator") + ), + "The title separator should be visible" + ); + + Assert.equal(result.suggestedIndex, 1); + + const onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + expectedUrl + ); + EventUtils.synthesizeMouseAtCenter(row, {}); + await onLoad; + Assert.ok(true, "Expected page is loaded"); + + await PlacesUtils.history.clear(); + } +}); + +add_task(async function disable() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.addons.featureGate", false]], + }); + + // Restore AdmWikipedia suggestions. + MerinoTestUtils.server.reset(); + // Add one Addon suggestion that is higher score than AdmWikipedia. + MerinoTestUtils.server.response.body.suggestions.push( + Object.assign({}, TEST_MERINO_SUGGESTIONS[0], { score: 2 }) + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "only match the Merino suggestion", + }); + Assert.equal(UrlbarTestUtils.getResultCount(window), 2); + + const { result } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.payload.telemetryType, "adm_sponsored"); + + MerinoTestUtils.server.response.body.suggestions = TEST_MERINO_SUGGESTIONS; + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function resultMenu_showLessFrequently() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.addons.showLessFrequentlyCount", 0]], + }); + + await QuickSuggestTestUtils.setConfig({ + show_less_frequently_cap: 3, + }); + + await doShowLessFrequently({ + input: "aaa b", + expected: { + isSuggestionShown: true, + isMenuItemShown: true, + }, + }); + Assert.equal(UrlbarPrefs.get("addons.showLessFrequentlyCount"), 1); + + await doShowLessFrequently({ + input: "aaa b", + expected: { + isSuggestionShown: true, + isMenuItemShown: true, + }, + }); + Assert.equal(UrlbarPrefs.get("addons.showLessFrequentlyCount"), 2); + + // The cap will be reached this time. Keep the view open so we can make sure + // the command has been removed from the menu before it closes. + await doShowLessFrequently({ + keepViewOpen: true, + input: "aaa b", + expected: { + isSuggestionShown: true, + isMenuItemShown: true, + }, + }); + Assert.equal(UrlbarPrefs.get("addons.showLessFrequentlyCount"), 3); + + // Make sure the command has been removed. + let menuitem = await UrlbarTestUtils.openResultMenuAndGetItem({ + window, + command: "show_less_frequently", + resultIndex: EXPECTED_RESULT_INDEX, + openByMouse: true, + }); + Assert.ok(!menuitem, "Menuitem should be absent before closing the view"); + gURLBar.view.resultMenu.hidePopup(true); + await UrlbarTestUtils.promisePopupClose(window); + + await doShowLessFrequently({ + input: "aaa b", + expected: { + // The suggestion should not display since addons.showLessFrequentlyCount + // is 3 and the substring (" b") after the first word ("aaa") is 2 chars + // long. + isSuggestionShown: false, + }, + }); + + await doShowLessFrequently({ + input: "aaa bb", + expected: { + // The suggestion should display, but item should not shown since the + // addons.showLessFrequentlyCount reached to addonsShowLessFrequentlyCap + // already. + isSuggestionShown: true, + isMenuItemShown: false, + }, + }); + + await QuickSuggestTestUtils.setConfig(QuickSuggestTestUtils.DEFAULT_CONFIG); + await SpecialPowers.popPrefEnv(); +}); + +// Tests the "Not interested" result menu dismissal command. +add_task(async function resultMenu_notInterested() { + await doDismissTest("not_interested", true); +}); + +// Tests the "Not relevant" result menu dismissal command. +add_task(async function notRelevant() { + await doDismissTest("not_relevant", false); +}); + +// Tests the row/group label. +add_task(async function rowLabel() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "only match the Merino suggestion", + }); + Assert.equal(UrlbarTestUtils.getResultCount(window), 2); + + const { element } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + const row = element.row; + Assert.equal(row.getAttribute("label"), "Firefox extension"); + + await UrlbarTestUtils.promisePopupClose(window); +}); + +async function doShowLessFrequently({ input, expected, keepViewOpen = false }) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: input, + }); + + if (!expected.isSuggestionShown) { + Assert.ok( + !(await getAddonResultDetails()), + "Addons suggestion should be absent" + ); + return; + } + + const details = await getAddonResultDetails(); + Assert.ok( + details, + `Addons suggestion should be present at expected index after ${input} search` + ); + + // Click the command. + try { + await UrlbarTestUtils.openResultMenuAndClickItem( + window, + "show_less_frequently", + { + resultIndex: EXPECTED_RESULT_INDEX, + } + ); + Assert.ok(expected.isMenuItemShown); + Assert.ok( + gURLBar.view.isOpen, + "The view should remain open clicking the command" + ); + Assert.ok( + details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should have feedback acknowledgment after clicking command" + ); + } catch (e) { + Assert.ok(!expected.isMenuItemShown); + Assert.ok( + !details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should not have feedback acknowledgment after clicking command" + ); + Assert.equal( + e.message, + "Menu item not found for command: show_less_frequently" + ); + } + + if (!keepViewOpen) { + await UrlbarTestUtils.promisePopupClose(window); + } +} + +async function doDismissTest(command, allDismissed) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "123", + }); + + const resultCount = UrlbarTestUtils.getResultCount(window); + let details = await getAddonResultDetails(); + Assert.ok(details, "Addons suggestion should be present"); + + // Sanity check. + Assert.ok(UrlbarPrefs.get("suggest.addons")); + + // Click the command. + await UrlbarTestUtils.openResultMenuAndClickItem( + window, + ["[data-l10n-id=firefox-suggest-command-dont-show-this]", command], + { resultIndex: EXPECTED_RESULT_INDEX, openByMouse: true } + ); + + Assert.equal( + UrlbarPrefs.get("suggest.addons"), + !allDismissed, + "suggest.addons should be true iff all suggestions weren't dismissed" + ); + Assert.equal( + await QuickSuggest.blockedSuggestions.has( + details.result.payload.originalUrl + ), + !allDismissed, + "Suggestion URL should be blocked iff all suggestions weren't dismissed" + ); + + // The row should be a tip now. + Assert.ok(gURLBar.view.isOpen, "The view should remain open after dismissal"); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + resultCount, + "The result count should not haved changed after dismissal" + ); + details = await UrlbarTestUtils.getDetailsOfResultAt( + window, + EXPECTED_RESULT_INDEX + ); + Assert.equal( + details.type, + UrlbarUtils.RESULT_TYPE.TIP, + "Row should be a tip after dismissal" + ); + Assert.equal( + details.result.payload.type, + "dismissalAcknowledgment", + "Tip type should be dismissalAcknowledgment" + ); + Assert.ok( + !details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should not have feedback acknowledgment after dismissal" + ); + + // Check tip title. + let title = details.element.row.querySelector(".urlbarView-title"); + let titleL10nId = title.dataset.l10nId; + if (allDismissed) { + Assert.equal(titleL10nId, "firefox-suggest-dismissal-acknowledgment-all"); + } else { + Assert.equal(titleL10nId, "firefox-suggest-dismissal-acknowledgment-one"); + } + + // Get the dismissal acknowledgment's "Got it" button and click it. + let gotItButton = UrlbarTestUtils.getButtonForResultIndex( + window, + 0, + EXPECTED_RESULT_INDEX + ); + Assert.ok(gotItButton, "Row should have a 'Got it' button"); + EventUtils.synthesizeMouseAtCenter(gotItButton, {}, window); + + // The view should remain open and the tip row should be gone. + Assert.ok( + gURLBar.view.isOpen, + "The view should remain open clicking the 'Got it' button" + ); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + resultCount - 1, + "The result count should be one less after clicking 'Got it' button" + ); + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.ok( + details.type != UrlbarUtils.RESULT_TYPE.TIP && + !isAddonResult(details.result), + "Tip result and addon result should not be present" + ); + } + + await UrlbarTestUtils.promisePopupClose(window); + + UrlbarPrefs.clear("suggest.addons"); + await QuickSuggest.blockedSuggestions.clear(); +} + +function makeExpectedUrl(originalUrl) { + let url = new URL(originalUrl); + url.searchParams.set("utm_medium", "firefox-desktop"); + url.searchParams.set("utm_source", "firefox-suggest"); + return url.href; +} + +async function getAddonResultDetails() { + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + const details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (isAddonResult(details.result)) { + return details; + } + } + return null; +} + +function isAddonResult(result) { + return ["AddonSuggestions", "amo"].includes(result.payload.provider); +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_block.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_block.js new file mode 100644 index 0000000000..c400cf72f6 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_block.js @@ -0,0 +1,252 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests quick suggest dismissals ("blocks"). + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + CONTEXTUAL_SERVICES_PING_TYPES: + "resource:///modules/PartnerLinkAttribution.sys.mjs", +}); + +const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest; +const { TIMESTAMP_TEMPLATE } = QuickSuggest; + +// Include the timestamp template in the suggestion URLs so we can make sure +// their original URLs with the unreplaced templates are blocked and not their +// URLs with timestamps. +const REMOTE_SETTINGS_RESULTS = [ + { + id: 1, + url: `https://example.com/sponsored?t=${TIMESTAMP_TEMPLATE}`, + title: "Sponsored suggestion", + keywords: ["sponsored"], + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "TestAdvertiser", + iab_category: "22 - Shopping", + icon: "1234", + }, + { + id: 2, + url: `https://example.com/nonsponsored?t=${TIMESTAMP_TEMPLATE}`, + title: "Non-sponsored suggestion", + keywords: ["nonsponsored"], + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "Wikipedia", + iab_category: "5 - Education", + icon: "1234", + }, +]; + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await UrlbarTestUtils.formHistory.clear(); + + await QuickSuggest.blockedSuggestions._test_readyPromise; + await QuickSuggest.blockedSuggestions.clear(); + + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + }); +}); + +// Picks the dismiss command in the result menu. +add_tasks_with_rust(async function basic() { + await doBasicBlockTest({ + block: async () => { + await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "D", { + resultIndex: 1, + }); + }, + }); +}); + +// Uses the key shortcut to block a suggestion. +add_tasks_with_rust(async function basic_keyShortcut() { + await doBasicBlockTest({ + block: () => { + // Arrow down once to select the row. + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true }); + }, + }); +}); + +async function doBasicBlockTest({ block }) { + for (let result of REMOTE_SETTINGS_RESULTS) { + info("Doing basic block test with result: " + JSON.stringify({ result })); + await doOneBasicBlockTest({ result, block }); + } +} + +async function doOneBasicBlockTest({ result, block }) { + let index = 2; + let suggested_index = -1; + let suggested_index_relative_to_group = true; + let match_type = "firefox-suggest"; + let isSponsored = result.iab_category != "5 - Education"; + let expectedBlockId = + UrlbarPrefs.get("quicksuggest.rustEnabled") && !isSponsored + ? null + : result.id; + + let pingsSubmitted = 0; + GleanPings.quickSuggest.testBeforeNextSubmit(() => { + pingsSubmitted++; + // First ping's an impression. + Assert.equal( + Glean.quickSuggest.pingType.testGetValue(), + CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION + ); + Assert.equal(Glean.quickSuggest.matchType.testGetValue(), match_type); + Assert.equal(Glean.quickSuggest.blockId.testGetValue(), expectedBlockId); + Assert.equal(Glean.quickSuggest.isClicked.testGetValue(), false); + Assert.equal(Glean.quickSuggest.position.testGetValue(), index); + Assert.equal( + Glean.quickSuggest.suggestedIndex.testGetValue(), + suggested_index + ); + Assert.equal( + Glean.quickSuggest.suggestedIndexRelativeToGroup.testGetValue(), + suggested_index_relative_to_group + ); + Assert.equal(Glean.quickSuggest.position.testGetValue(), index); + GleanPings.quickSuggest.testBeforeNextSubmit(() => { + pingsSubmitted++; + // Second ping's a block. + Assert.equal( + Glean.quickSuggest.pingType.testGetValue(), + CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK + ); + Assert.equal(Glean.quickSuggest.matchType.testGetValue(), match_type); + Assert.equal(Glean.quickSuggest.blockId.testGetValue(), expectedBlockId); + Assert.equal( + Glean.quickSuggest.iabCategory.testGetValue(), + result.iab_category + ); + Assert.equal(Glean.quickSuggest.position.testGetValue(), index); + }); + }); + + // Do a search that triggers the suggestion. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: result.keywords[0], + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "Two rows are present after searching (heuristic + suggestion)" + ); + + await QuickSuggestTestUtils.assertIsQuickSuggest({ + window, + isSponsored, + originalUrl: result.url, + }); + + // Block the suggestion. + await block(); + + // The row should have been removed. + Assert.ok( + UrlbarTestUtils.isPopupOpen(window), + "View remains open after blocking result" + ); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "Only one row after blocking suggestion" + ); + await QuickSuggestTestUtils.assertNoQuickSuggestResults(window); + + // The URL should be blocked. + Assert.ok( + await QuickSuggest.blockedSuggestions.has(result.url), + "Suggestion is blocked" + ); + + // Check Glean. + Assert.equal(pingsSubmitted, 2, "Both Glean pings submitted."); + + // Check telemetry scalars. + let scalars = {}; + if (isSponsored) { + scalars[TELEMETRY_SCALARS.IMPRESSION_SPONSORED] = index; + scalars[TELEMETRY_SCALARS.BLOCK_SPONSORED] = index; + } else { + scalars[TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED] = index; + scalars[TELEMETRY_SCALARS.BLOCK_NONSPONSORED] = index; + } + QuickSuggestTestUtils.assertScalars(scalars); + + // Check the engagement event. + QuickSuggestTestUtils.assertEvents([ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "block", + extra: { + match_type, + position: String(index), + suggestion_type: isSponsored ? "sponsored" : "nonsponsored", + }, + }, + ]); + + await UrlbarTestUtils.promisePopupClose(window); + await QuickSuggest.blockedSuggestions.clear(); +} + +// Blocks multiple suggestions one after the other. +add_tasks_with_rust(async function blockMultiple() { + for (let i = 0; i < REMOTE_SETTINGS_RESULTS.length; i++) { + // Do a search that triggers the i'th suggestion. + let { keywords, url } = REMOTE_SETTINGS_RESULTS[i]; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: keywords[0], + }); + await QuickSuggestTestUtils.assertIsQuickSuggest({ + window, + originalUrl: url, + isSponsored: keywords[0] == "sponsored", + }); + + // Block it. + await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "D", { + resultIndex: 1, + }); + Assert.ok( + await QuickSuggest.blockedSuggestions.has(url), + "Suggestion is blocked after picking block button" + ); + + // Make sure all previous suggestions remain blocked and no other + // suggestions are blocked yet. + for (let j = 0; j < REMOTE_SETTINGS_RESULTS.length; j++) { + Assert.equal( + await QuickSuggest.blockedSuggestions.has( + REMOTE_SETTINGS_RESULTS[j].url + ), + j <= i, + `Suggestion at index ${j} is blocked or not as expected` + ); + } + } + + await UrlbarTestUtils.promisePopupClose(window); + await QuickSuggest.blockedSuggestions.clear(); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_configuration.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_configuration.js new file mode 100644 index 0000000000..d9a4345898 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_configuration.js @@ -0,0 +1,2099 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests QuickSuggest configurations. + */ + +ChromeUtils.defineESModuleGetters(this, { + EnterprisePolicyTesting: + "resource://testing-common/EnterprisePolicyTesting.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +// We use this pref in enterprise preference policy tests. We specifically use a +// pref that's sticky and exposed in the UI to make sure it can be set properly. +const POLICY_PREF = "suggest.quicksuggest.nonsponsored"; + +let gDefaultBranch = Services.prefs.getDefaultBranch("browser.urlbar."); +let gUserBranch = Services.prefs.getBranch("browser.urlbar."); + +add_setup(async function () { + await QuickSuggestTestUtils.ensureQuickSuggestInit(); +}); + +// Makes sure `QuickSuggest._updateFeatureState()` is called when the +// `browser.urlbar.quicksuggest.enabled` pref is changed. +add_task(async function test_updateFeatureState_pref() { + Assert.ok( + UrlbarPrefs.get("quicksuggest.enabled"), + "Sanity check: quicksuggest.enabled is true by default" + ); + + let sandbox = sinon.createSandbox(); + let spy = sandbox.spy(QuickSuggest, "_updateFeatureState"); + + UrlbarPrefs.set("quicksuggest.enabled", false); + Assert.equal( + spy.callCount, + 1, + "_updateFeatureState called once after changing pref" + ); + + UrlbarPrefs.clear("quicksuggest.enabled"); + Assert.equal( + spy.callCount, + 2, + "_updateFeatureState called again after clearing pref" + ); + + sandbox.restore(); +}); + +// Makes sure `QuickSuggest._updateFeatureState()` is called when a Nimbus +// experiment is installed and uninstalled. +add_task(async function test_updateFeatureState_experiment() { + let sandbox = sinon.createSandbox(); + let spy = sandbox.spy(QuickSuggest, "_updateFeatureState"); + + await QuickSuggestTestUtils.withExperiment({ + callback: () => { + Assert.equal( + spy.callCount, + 1, + "_updateFeatureState called once after installing experiment" + ); + }, + }); + + Assert.equal( + spy.callCount, + 2, + "_updateFeatureState called again after uninstalling experiment" + ); + + sandbox.restore(); +}); + +add_task(async function test_indexes() { + await QuickSuggestTestUtils.withExperiment({ + valueOverrides: { + quickSuggestNonSponsoredIndex: 99, + quickSuggestSponsoredIndex: -1337, + }, + callback: () => { + Assert.equal( + UrlbarPrefs.get("quickSuggestNonSponsoredIndex"), + 99, + "quickSuggestNonSponsoredIndex" + ); + Assert.equal( + UrlbarPrefs.get("quickSuggestSponsoredIndex"), + -1337, + "quickSuggestSponsoredIndex" + ); + }, + }); +}); + +add_task(async function test_merino() { + await QuickSuggestTestUtils.withExperiment({ + valueOverrides: { + merinoEndpointURL: "http://example.com/test_merino_config", + merinoClientVariants: "test-client-variants", + merinoProviders: "test-providers", + }, + callback: () => { + Assert.equal( + UrlbarPrefs.get("merinoEndpointURL"), + "http://example.com/test_merino_config", + "merinoEndpointURL" + ); + Assert.equal( + UrlbarPrefs.get("merinoClientVariants"), + "test-client-variants", + "merinoClientVariants" + ); + Assert.equal( + UrlbarPrefs.get("merinoProviders"), + "test-providers", + "merinoProviders" + ); + }, + }); +}); + +add_task(async function test_scenario_online() { + await doBasicScenarioTest("online", { + urlbarPrefs: { + // prefs + "quicksuggest.scenario": "online", + "quicksuggest.enabled": true, + "quicksuggest.dataCollection.enabled": false, + "quicksuggest.shouldShowOnboardingDialog": true, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + + // Nimbus variables + quickSuggestScenario: "online", + quickSuggestEnabled: true, + quickSuggestShouldShowOnboardingDialog: true, + }, + defaults: [ + { + name: "browser.urlbar.quicksuggest.enabled", + value: true, + }, + { + name: "browser.urlbar.quicksuggest.dataCollection.enabled", + value: false, + }, + { + name: "browser.urlbar.quicksuggest.shouldShowOnboardingDialog", + value: true, + }, + { + name: "browser.urlbar.suggest.quicksuggest.nonsponsored", + value: true, + }, + { + name: "browser.urlbar.suggest.quicksuggest.sponsored", + value: true, + }, + ], + }); +}); + +add_task(async function test_scenario_offline() { + await doBasicScenarioTest("offline", { + urlbarPrefs: { + // prefs + "quicksuggest.scenario": "offline", + "quicksuggest.enabled": true, + "quicksuggest.dataCollection.enabled": false, + "quicksuggest.shouldShowOnboardingDialog": false, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + + // Nimbus variables + quickSuggestScenario: "offline", + quickSuggestEnabled: true, + quickSuggestShouldShowOnboardingDialog: false, + }, + defaults: [ + { + name: "browser.urlbar.quicksuggest.enabled", + value: true, + }, + { + name: "browser.urlbar.quicksuggest.dataCollection.enabled", + value: false, + }, + { + name: "browser.urlbar.quicksuggest.shouldShowOnboardingDialog", + value: false, + }, + { + name: "browser.urlbar.suggest.quicksuggest.nonsponsored", + value: true, + }, + { + name: "browser.urlbar.suggest.quicksuggest.sponsored", + value: true, + }, + ], + }); +}); + +add_task(async function test_scenario_history() { + await doBasicScenarioTest("history", { + urlbarPrefs: { + // prefs + "quicksuggest.scenario": "history", + "quicksuggest.enabled": false, + + // Nimbus variables + quickSuggestScenario: "history", + quickSuggestEnabled: false, + }, + defaults: [ + { + name: "browser.urlbar.quicksuggest.enabled", + value: false, + }, + ], + }); +}); + +async function doBasicScenarioTest(scenario, expectedPrefs) { + await QuickSuggestTestUtils.withExperiment({ + valueOverrides: { + quickSuggestScenario: scenario, + }, + callback: () => { + // Pref updates should always settle down by the time enrollment is done. + Assert.ok( + !UrlbarPrefs.updatingFirefoxSuggestPrefs, + "updatingFirefoxSuggestPrefs is false" + ); + + assertScenarioPrefs(expectedPrefs); + }, + }); + + // Similarly, pref updates should always settle down by the time unenrollment + // is done. + Assert.ok( + !UrlbarPrefs.updatingFirefoxSuggestPrefs, + "updatingFirefoxSuggestPrefs is false" + ); + + assertDefaultScenarioPrefs(); +} + +function assertScenarioPrefs({ urlbarPrefs, defaults }) { + for (let [name, value] of Object.entries(urlbarPrefs)) { + Assert.equal(UrlbarPrefs.get(name), value, `UrlbarPrefs.get("${name}")`); + } + + let prefs = Services.prefs.getDefaultBranch(""); + for (let { name, getter, value } of defaults) { + Assert.equal( + prefs[getter || "getBoolPref"](name), + value, + `Default branch pref: ${name}` + ); + } +} + +function assertDefaultScenarioPrefs() { + assertScenarioPrefs({ + urlbarPrefs: { + "quicksuggest.scenario": "offline", + "quicksuggest.enabled": true, + "quicksuggest.dataCollection.enabled": false, + "quicksuggest.shouldShowOnboardingDialog": false, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + + // No Nimbus variables since they're only available when an experiment is + // installed. + }, + defaults: [ + { + name: "browser.urlbar.quicksuggest.enabled", + value: true, + }, + { + name: "browser.urlbar.quicksuggest.dataCollection.enabled", + value: false, + }, + { + name: "browser.urlbar.quicksuggest.shouldShowOnboardingDialog", + value: false, + }, + { + name: "browser.urlbar.suggest.quicksuggest.nonsponsored", + value: true, + }, + { + name: "browser.urlbar.suggest.quicksuggest.sponsored", + value: true, + }, + ], + }); +} + +function clearOnboardingPrefs() { + UrlbarPrefs.clear("suggest.quicksuggest.nonsponsored"); + UrlbarPrefs.clear("suggest.quicksuggest.sponsored"); + UrlbarPrefs.clear("quicksuggest.dataCollection.enabled"); + UrlbarPrefs.clear("quicksuggest.shouldShowOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.showedOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.seenRestarts"); +} + +// The following tasks test Nimbus enrollments + +// Initial state: +// * History (quick suggest feature disabled) +// +// Enrollment: +// * History +// +// Expected: +// * All history prefs set on the default branch +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.history, + }, + valueOverrides: { + quickSuggestScenario: "history", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.history, + }, + }); +}); + +// Initial state: +// * History (quick suggest feature disabled) +// +// Enrollment: +// * Offline +// +// Expected: +// * All offline prefs set on the default branch +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.history, + }, + valueOverrides: { + quickSuggestScenario: "offline", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + }, + }); +}); + +// Initial state: +// * History (quick suggest feature disabled) +// +// Enrollment: +// * Online +// +// Expected: +// * All online prefs set on the default branch +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.history, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + }, + }); +}); + +// The following tasks test OFFLINE TO OFFLINE + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * User did not override any defaults +// +// Enrollment: +// * Offline +// +// Expected: +// * All offline prefs set on the default branch +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + }, + valueOverrides: { + quickSuggestScenario: "offline", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Enrollment: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain off +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "offline", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user left on +// * Data collection: user left off +// +// Enrollment: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain on +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "offline", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Enrollment: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain off +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "offline", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user turned on +// +// Enrollment: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain on +// * Data collection: remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "offline", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user turned off +// * Data collection: user turned on +// +// Enrollment: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain off +// * Data collection: remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "offline", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// The following tasks test OFFLINE TO ONLINE + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * User did not override any defaults +// +// Enrollment: +// * Online +// +// Expected: +// * All online prefs set on the default branch +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain off +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user left on +// * Data collection: user left off +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain on +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain off +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user turned on +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain on +// * Data collection: remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user turned off +// * Data collection: user turned on +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain off +// * Data collection: remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user left on +// * Data collection: user turned on +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain on +// * Data collection: remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user turned off +// * Data collection: user turned on +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain off +// * Data collection: remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// The following tasks test ONLINE TO ONLINE + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * User did not override any defaults +// +// Enrollment: +// * Online +// +// Expected: +// * All online prefs set on the default branch +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain off +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user left on +// * Data collection: user left off +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain on +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain off +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user turned on +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain on +// * Data collection: remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user turned off +// * Data collection: user turned on +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain off +// * Data collection: remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user left on +// * Data collection: user turned on +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain on +// * Data collection: remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user turned off +// * Data collection: user turned on +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain off +// * Data collection: remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// The following tasks test scenarios in conjunction with individual Nimbus +// variables + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * User did not override any defaults +// +// Enrollment: +// * Offline +// * Sponsored suggestions individually forced on +// +// Expected: +// * Sponsored suggestions: on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + }, + valueOverrides: { + quickSuggestScenario: "offline", + quickSuggestSponsoredEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + "suggest.quicksuggest.sponsored": true, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Sponsored suggestions: user turned off +// +// Enrollment: +// * Offline +// * Sponsored suggestions individually forced on +// +// Expected: +// * Sponsored suggestions: remain off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "offline", + quickSuggestSponsoredEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + "suggest.quicksuggest.sponsored": true, + }, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * User did not override any defaults +// +// Enrollment: +// * Offline +// * Data collection individually forced on +// +// Expected: +// * Data collection: on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + }, + valueOverrides: { + quickSuggestScenario: "offline", + quickSuggestDataCollectionEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Data collection: user turned off (it's off by default, so this simulates +// when the user toggled it on and then back off) +// +// Enrollment: +// * Offline +// * Data collection individually forced on +// +// Expected: +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "offline", + quickSuggestDataCollectionEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + "quicksuggest.dataCollection.enabled": true, + }, + userBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * User did not override any defaults +// +// Enrollment: +// * Online +// * Sponsored suggestions individually forced off +// +// Expected: +// * Sponsored suggestions: off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + }, + valueOverrides: { + quickSuggestScenario: "online", + quickSuggestSponsoredEnabled: false, + }, + expectedPrefs: { + defaultBranch: { + ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * Sponsored suggestions: user turned on (they're on by default, so this +// simulates when the user toggled them off and then back on) +// +// Enrollment: +// * Online +// * Sponsored suggestions individually forced off +// +// Expected: +// * Sponsored suggestions: remain on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + quickSuggestSponsoredEnabled: false, + }, + expectedPrefs: { + defaultBranch: { + ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + "suggest.quicksuggest.sponsored": false, + }, + userBranch: { + "suggest.quicksuggest.sponsored": true, + }, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * User did not override any defaults +// +// Enrollment: +// * Online +// * Data collection individually forced on +// +// Expected: +// * Data collection: on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + }, + valueOverrides: { + quickSuggestScenario: "online", + quickSuggestDataCollectionEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * Data collection: user turned off (it's off by default, so this simulates +// when the user toggled it on and then back off) +// +// Enrollment: +// * Online +// * Data collection individually forced on +// +// Expected: +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + quickSuggestDataCollectionEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + "quicksuggest.dataCollection.enabled": true, + }, + userBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + }, + }); +}); + +// The following tasks test individual Nimbus variables without scenarios + +// Initial state: +// * Suggestions on by default and user left them on +// +// 1. First enrollment: +// * Suggestions forced off +// +// Expected: +// * Suggestions off +// +// 2. User turns on suggestions +// 3. Second enrollment: +// * Suggestions forced off again +// +// Expected: +// * Suggestions remain on +add_task(async function () { + await checkEnrollments([ + { + initialPrefsToSet: { + defaultBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + valueOverrides: { + quickSuggestNonSponsoredEnabled: false, + quickSuggestSponsoredEnabled: false, + }, + expectedPrefs: { + defaultBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }, + { + initialPrefsToSet: { + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + valueOverrides: { + quickSuggestNonSponsoredEnabled: false, + quickSuggestSponsoredEnabled: false, + }, + expectedPrefs: { + defaultBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + }, + ]); +}); + +// Initial state: +// * Suggestions on by default but user turned them off +// +// Enrollment: +// * Suggestions forced on +// +// Expected: +// * Suggestions remain off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + valueOverrides: { + quickSuggestNonSponsoredEnabled: true, + quickSuggestSponsoredEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Suggestions off by default and user left them off +// +// 1. First enrollment: +// * Suggestions forced on +// +// Expected: +// * Suggestions on +// +// 2. User turns off suggestions +// 3. Second enrollment: +// * Suggestions forced on again +// +// Expected: +// * Suggestions remain off +add_task(async function () { + await checkEnrollments([ + { + initialPrefsToSet: { + defaultBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + valueOverrides: { + quickSuggestNonSponsoredEnabled: true, + quickSuggestSponsoredEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + }, + { + initialPrefsToSet: { + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + valueOverrides: { + quickSuggestNonSponsoredEnabled: true, + quickSuggestSponsoredEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }, + ]); +}); + +// Initial state: +// * Suggestions off by default but user turned them on +// +// Enrollment: +// * Suggestions forced off +// +// Expected: +// * Suggestions remain on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + valueOverrides: { + quickSuggestNonSponsoredEnabled: false, + quickSuggestSponsoredEnabled: false, + }, + expectedPrefs: { + defaultBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + }); +}); + +// Initial state: +// * Data collection on by default and user left them on +// +// 1. First enrollment: +// * Data collection forced off +// +// Expected: +// * Data collection off +// +// 2. User turns on data collection +// 3. Second enrollment: +// * Data collection forced off again +// +// Expected: +// * Data collection remains on +add_task(async function () { + await checkEnrollments( + [ + { + initialPrefsToSet: { + defaultBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestDataCollectionEnabled: false, + }, + expectedPrefs: { + defaultBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + }, + }, + ], + [ + { + initialPrefsToSet: { + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestDataCollectionEnabled: false, + }, + expectedPrefs: { + defaultBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + }, + ] + ); +}); + +// Initial state: +// * Data collection on by default but user turned it off +// +// Enrollment: +// * Data collection forced on +// +// Expected: +// * Data collection remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + userBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + }, + valueOverrides: { + quickSuggestDataCollectionEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + userBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + }, + }); +}); + +// Initial state: +// * Data collection off by default and user left it off +// +// 1. First enrollment: +// * Data collection forced on +// +// Expected: +// * Data collection on +// +// 2. User turns off data collection +// 3. Second enrollment: +// * Data collection forced on again +// +// Expected: +// * Data collection remains off +add_task(async function () { + await checkEnrollments( + [ + { + initialPrefsToSet: { + defaultBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + }, + valueOverrides: { + quickSuggestDataCollectionEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + }, + ], + [ + { + initialPrefsToSet: { + userBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + }, + valueOverrides: { + quickSuggestDataCollectionEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + userBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + }, + }, + ] + ); +}); + +// Initial state: +// * Data collection off by default but user turned it on +// +// Enrollment: +// * Data collection forced off +// +// Expected: +// * Data collection remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestDataCollectionEnabled: false, + }, + expectedPrefs: { + defaultBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +/** + * Tests one or more enrollments. Sets an initial set of prefs on the default + * and/or user branches, enrolls in a mock Nimbus experiment, checks expected + * pref values, unenrolls, and finally checks prefs again. + * + * The given `options` value may be an object as described below or an array of + * such objects, one per enrollment. + * + * @param {object} options + * Function options. + * @param {object} options.initialPrefsToSet + * An object: { userBranch, defaultBranch } + * `userBranch` and `defaultBranch` are objects that map pref names (relative + * to `browser.urlbar`) to values. These prefs will be set on the appropriate + * branch before enrollment. Both `userBranch` and `defaultBranch` are + * optional. + * @param {object} options.valueOverrides + * The `valueOverrides` object passed to the mock experiment. It should map + * Nimbus variable names to values. + * @param {object} options.expectedPrefs + * Preferences that should be set after enrollment. It has the same shape as + * `options.initialPrefsToSet`. + */ +async function checkEnrollments(options) { + info("Testing: " + JSON.stringify(options)); + + let enrollments; + if (Array.isArray(options)) { + enrollments = options; + } else { + enrollments = [options]; + } + + // Do each enrollment. + for (let i = 0; i < enrollments.length; i++) { + info( + `Starting setup for enrollment ${i}: ` + JSON.stringify(enrollments[i]) + ); + + let { initialPrefsToSet, valueOverrides, expectedPrefs } = enrollments[i]; + + // Set initial prefs. + UrlbarPrefs._updatingFirefoxSuggestScenario = true; + let { defaultBranch: initialDefaultBranch, userBranch: initialUserBranch } = + initialPrefsToSet; + initialDefaultBranch = initialDefaultBranch || {}; + initialUserBranch = initialUserBranch || {}; + for (let name of Object.keys(initialDefaultBranch)) { + // Clear user-branch values on the default prefs so the defaults aren't + // masked. + gUserBranch.clearUserPref(name); + } + for (let [branch, prefs] of [ + [gDefaultBranch, initialDefaultBranch], + [gUserBranch, initialUserBranch], + ]) { + for (let [name, value] of Object.entries(prefs)) { + branch.setBoolPref(name, value); + } + } + UrlbarPrefs._updatingFirefoxSuggestScenario = false; + + let { + defaultBranch: expectedDefaultBranch, + userBranch: expectedUserBranch, + } = expectedPrefs; + expectedDefaultBranch = expectedDefaultBranch || {}; + expectedUserBranch = expectedUserBranch || {}; + + // Install the experiment. + info(`Installing experiment for enrollment ${i}`); + await QuickSuggestTestUtils.withExperiment({ + valueOverrides, + callback: () => { + info(`Installed experiment for enrollment ${i}, now checking prefs`); + + // Check expected pref values. Store expected effective values as we go + // so we can check them afterward. For a given pref, the expected + // effective value is the user value, or if there's not a user value, + // the default value. + let expectedEffectivePrefs = {}; + for (let [branch, prefs, branchType] of [ + [gDefaultBranch, expectedDefaultBranch, "default"], + [gUserBranch, expectedUserBranch, "user"], + ]) { + for (let [name, value] of Object.entries(prefs)) { + expectedEffectivePrefs[name] = value; + Assert.equal( + branch.getBoolPref(name), + value, + `Pref ${name} on ${branchType} branch` + ); + if (branch == gUserBranch) { + Assert.ok( + gUserBranch.prefHasUserValue(name), + `Pref ${name} is on user branch` + ); + } + } + } + for (let name of Object.keys(initialDefaultBranch)) { + if (!expectedUserBranch.hasOwnProperty(name)) { + Assert.ok( + !gUserBranch.prefHasUserValue(name), + `Pref ${name} is not on user branch` + ); + } + } + for (let [name, value] of Object.entries(expectedEffectivePrefs)) { + Assert.equal( + UrlbarPrefs.get(name), + value, + `Pref ${name} effective value` + ); + } + + info(`Uninstalling experiment for enrollment ${i}`); + }, + }); + + info(`Uninstalled experiment for enrollment ${i}, now checking prefs`); + + // Check expected effective values after unenrollment. The expected + // effective value for a pref at this point is the value on the user branch, + // or if there's not a user value, the original value on the default branch + // before enrollment. This assumes the default values reflect the offline + // scenario (the case for the U.S. region). + let effectivePrefs = Object.assign( + {}, + UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline + ); + for (let [name, value] of Object.entries(expectedUserBranch)) { + effectivePrefs[name] = value; + } + for (let [name, value] of Object.entries(effectivePrefs)) { + Assert.equal( + UrlbarPrefs.get(name), + value, + `Pref ${name} effective value after unenrolling` + ); + } + + // Clean up. + UrlbarPrefs._updatingFirefoxSuggestScenario = true; + for (let name of Object.keys(expectedUserBranch)) { + UrlbarPrefs.clear(name); + } + UrlbarPrefs._updatingFirefoxSuggestScenario = false; + } +} + +// The following tasks test enterprise preference policies + +// Preference policy test for the following: +// * Status: locked +// * Value: false +add_task(async function () { + await doPolicyTest({ + prefPolicy: { + Status: "locked", + Value: false, + }, + expectedDefault: false, + expectedUser: undefined, + expectedLocked: true, + }); +}); + +// Preference policy test for the following: +// * Status: locked +// * Value: true +add_task(async function () { + await doPolicyTest({ + prefPolicy: { + Status: "locked", + Value: true, + }, + expectedDefault: true, + expectedUser: undefined, + expectedLocked: true, + }); +}); + +// Preference policy test for the following: +// * Status: default +// * Value: false +add_task(async function () { + await doPolicyTest({ + prefPolicy: { + Status: "default", + Value: false, + }, + expectedDefault: false, + expectedUser: undefined, + expectedLocked: false, + }); +}); + +// Preference policy test for the following: +// * Status: default +// * Value: true +add_task(async function () { + await doPolicyTest({ + prefPolicy: { + Status: "default", + Value: true, + }, + expectedDefault: true, + expectedUser: undefined, + expectedLocked: false, + }); +}); + +// Preference policy test for the following: +// * Status: user +// * Value: false +add_task(async function () { + await doPolicyTest({ + prefPolicy: { + Status: "user", + Value: false, + }, + expectedDefault: true, + expectedUser: false, + expectedLocked: false, + }); +}); + +// Preference policy test for the following: +// * Status: user +// * Value: true +add_task(async function () { + await doPolicyTest({ + prefPolicy: { + Status: "user", + Value: true, + }, + expectedDefault: true, + // Because the pref is sticky, it's true on the user branch even though it's + // also true on the default branch. Sticky prefs retain their user-branch + // values even when they're the same as their default-branch values. + expectedUser: true, + expectedLocked: false, + }); +}); + +/** + * This tests an enterprise preference policy with one of the quick suggest + * sticky prefs (defined by `POLICY_PREF`). Pref policies should apply to the + * quick suggest sticky prefs just as they do to non-sticky prefs. + * + * @param {object} options + * Options object. + * @param {object} options.prefPolicy + * An object `{ Status, Value }` that will be included in the policy. + * @param {boolean} options.expectedDefault + * The expected default-branch pref value after setting the policy. + * @param {boolean} options.expectedUser + * The expected user-branch pref value after setting the policy or undefined + * if the pref should not exist on the user branch. + * @param {boolean} options.expectedLocked + * Whether the pref is expected to be locked after setting the policy. + */ +async function doPolicyTest({ + prefPolicy, + expectedDefault, + expectedUser, + expectedLocked, +}) { + info( + "Starting pref policy test: " + + JSON.stringify({ + prefPolicy, + expectedDefault, + expectedUser, + expectedLocked, + }) + ); + + let pref = POLICY_PREF; + + // Check initial state. + Assert.ok( + gDefaultBranch.getBoolPref(pref), + `${pref} is initially true on default branch (assuming en-US)` + ); + Assert.ok( + !gUserBranch.prefHasUserValue(pref), + `${pref} does not have initial user value` + ); + + // Set up the policy. + await EnterprisePolicyTesting.setupPolicyEngineWithJson({ + policies: { + Preferences: { + [`browser.urlbar.${pref}`]: prefPolicy, + }, + }, + }); + Assert.equal( + Services.policies.status, + Ci.nsIEnterprisePolicies.ACTIVE, + "Policy engine is active" + ); + + // Check the default branch. + Assert.equal( + gDefaultBranch.getBoolPref(pref), + expectedDefault, + `${pref} has expected default-branch value after setting policy` + ); + + // Check the user branch. + Assert.equal( + gUserBranch.prefHasUserValue(pref), + expectedUser !== undefined, + `${pref} is on user branch as expected after setting policy` + ); + if (expectedUser !== undefined) { + Assert.equal( + gUserBranch.getBoolPref(pref), + expectedUser, + `${pref} has expected user-branch value after setting policy` + ); + } + + // Check the locked state. + Assert.equal( + gDefaultBranch.prefIsLocked(pref), + expectedLocked, + `${pref} is locked as expected after setting policy` + ); + + // Clean up. + await EnterprisePolicyTesting.setupPolicyEngineWithJson(""); + Assert.equal( + Services.policies.status, + Ci.nsIEnterprisePolicies.INACTIVE, + "Policy engine is inactive" + ); + + gDefaultBranch.unlockPref(pref); + gUserBranch.clearUserPref(pref); + await QuickSuggestTestUtils.setScenario(null); + + Assert.ok( + !gDefaultBranch.prefIsLocked(pref), + `${pref} is not locked after cleanup` + ); + Assert.ok( + gDefaultBranch.getBoolPref(pref), + `${pref} is true on default branch after cleanup (assuming en-US)` + ); + Assert.ok( + !gUserBranch.prefHasUserValue(pref), + `${pref} does not have user value after cleanup` + ); +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_indexes.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_indexes.js new file mode 100644 index 0000000000..713df1ec02 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_indexes.js @@ -0,0 +1,410 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests the configurable indexes of sponsored and non-sponsored ("Firefox +// Suggest") quick suggest results. + +"use strict"; + +const SUGGESTIONS_FIRST_PREF = "browser.urlbar.showSearchSuggestionsFirst"; +const SUGGESTIONS_PREF = "browser.urlbar.suggest.searches"; + +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; +const MAX_RESULTS = UrlbarPrefs.get("maxRichResults"); + +const SPONSORED_INDEX_PREF = "browser.urlbar.quicksuggest.sponsoredIndex"; +const NON_SPONSORED_INDEX_PREF = + "browser.urlbar.quicksuggest.nonSponsoredIndex"; + +const SPONSORED_SEARCH_STRING = "frabbits"; +const NON_SPONSORED_SEARCH_STRING = "nonspon"; + +const TEST_URL = "http://example.com/quicksuggest"; + +const REMOTE_SETTINGS_RESULTS = [ + QuickSuggestTestUtils.ampRemoteSettings({ + keywords: [SPONSORED_SEARCH_STRING], + }), + QuickSuggestTestUtils.wikipediaRemoteSettings({ + keywords: [NON_SPONSORED_SEARCH_STRING], + }), +]; + +// Trying to avoid timeouts. +requestLongerTimeout(3); + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await UrlbarTestUtils.formHistory.clear(); + + // Add a mock engine so we don't hit the network. + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + }); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +// Tests with history only +add_task(async function noSuggestions() { + await doTestPermutations(({ withHistory, generalIndex }) => ({ + expectedResultCount: withHistory ? MAX_RESULTS : 2, + expectedIndex: generalIndex == 0 || !withHistory ? 1 : MAX_RESULTS - 1, + })); +}); + +// Tests with suggestions followed by history +add_task(async function suggestionsFirst() { + await SpecialPowers.pushPrefEnv({ + set: [[SUGGESTIONS_FIRST_PREF, true]], + }); + await withSuggestions(async () => { + await doTestPermutations(({ withHistory, generalIndex }) => ({ + expectedResultCount: withHistory ? MAX_RESULTS : 4, + expectedIndex: generalIndex == 0 || !withHistory ? 3 : MAX_RESULTS - 1, + })); + }); + await SpecialPowers.popPrefEnv(); +}); + +// Tests with history followed by suggestions +add_task(async function suggestionsLast() { + await SpecialPowers.pushPrefEnv({ + set: [[SUGGESTIONS_FIRST_PREF, false]], + }); + await withSuggestions(async () => { + await doTestPermutations(({ withHistory, generalIndex }) => ({ + expectedResultCount: withHistory ? MAX_RESULTS : 4, + expectedIndex: generalIndex == 0 || !withHistory ? 1 : MAX_RESULTS - 3, + })); + }); + await SpecialPowers.popPrefEnv(); +}); + +// Tests with history only plus a suggestedIndex result with a resultSpan +add_task(async function otherSuggestedIndex_noSuggestions() { + await doSuggestedIndexTest([ + // heuristic + { heuristic: true }, + // TestProvider result + { suggestedIndex: 1, resultSpan: 2 }, + // history + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + // quick suggest + { + type: UrlbarUtils.RESULT_TYPE.URL, + providerName: UrlbarProviderQuickSuggest.name, + }, + ]); +}); + +// Tests with suggestions followed by history plus a suggestedIndex result with +// a resultSpan +add_task(async function otherSuggestedIndex_suggestionsFirst() { + await SpecialPowers.pushPrefEnv({ + set: [[SUGGESTIONS_FIRST_PREF, true]], + }); + await withSuggestions(async () => { + await doSuggestedIndexTest([ + // heuristic + { heuristic: true }, + // TestProvider result + { suggestedIndex: 1, resultSpan: 2 }, + // search suggestions + { + type: UrlbarUtils.RESULT_TYPE.SEARCH, + payload: { suggestion: SPONSORED_SEARCH_STRING + "foo" }, + }, + { + type: UrlbarUtils.RESULT_TYPE.SEARCH, + payload: { suggestion: SPONSORED_SEARCH_STRING + "bar" }, + }, + // history + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + // quick suggest + { + type: UrlbarUtils.RESULT_TYPE.URL, + providerName: UrlbarProviderQuickSuggest.name, + }, + ]); + }); + await SpecialPowers.popPrefEnv(); +}); + +// Tests with history followed by suggestions plus a suggestedIndex result with +// a resultSpan +add_task(async function otherSuggestedIndex_suggestionsLast() { + await SpecialPowers.pushPrefEnv({ + set: [[SUGGESTIONS_FIRST_PREF, false]], + }); + await withSuggestions(async () => { + await doSuggestedIndexTest([ + // heuristic + { heuristic: true }, + // TestProvider result + { suggestedIndex: 1, resultSpan: 2 }, + // history + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + // quick suggest + { + type: UrlbarUtils.RESULT_TYPE.URL, + providerName: UrlbarProviderQuickSuggest.name, + }, + // search suggestions + { + type: UrlbarUtils.RESULT_TYPE.SEARCH, + payload: { suggestion: SPONSORED_SEARCH_STRING + "foo" }, + }, + { + type: UrlbarUtils.RESULT_TYPE.SEARCH, + payload: { suggestion: SPONSORED_SEARCH_STRING + "bar" }, + }, + ]); + }); + await SpecialPowers.popPrefEnv(); +}); + +/** + * A test provider that returns one result with a suggestedIndex and resultSpan. + */ +class TestProvider extends UrlbarTestUtils.TestProvider { + constructor() { + super({ + results: [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { url: "http://example.com/test" } + ), + { + suggestedIndex: 1, + resultSpan: 2, + } + ), + ], + }); + } +} + +/** + * Does a round of test permutations. + * + * @param {Function} callback + * For each permutation, this will be called with the arguments of `doTest()`, + * and it should return an object with the appropriate values of + * `expectedResultCount` and `expectedIndex`. + */ +async function doTestPermutations(callback) { + for (let isSponsored of [true, false]) { + for (let withHistory of [true, false]) { + for (let generalIndex of [0, -1]) { + let opts = { + isSponsored, + withHistory, + generalIndex, + }; + await doTest(Object.assign(opts, callback(opts))); + } + } + } +} + +/** + * Does one test run. + * + * @param {object} options + * Options for the test. + * @param {boolean} options.isSponsored + * True to use a sponsored result, false to use a non-sponsored result. + * @param {boolean} options.withHistory + * True to run with a bunch of history, false to run with no history. + * @param {number} options.generalIndex + * The value to set as the relevant index pref, i.e., the index within the + * general group of the quick suggest result. + * @param {number} options.expectedResultCount + * The expected total result count for sanity checking. + * @param {number} options.expectedIndex + * The expected index of the quick suggest result in the whole results list. + */ +async function doTest({ + isSponsored, + withHistory, + generalIndex, + expectedResultCount, + expectedIndex, +}) { + info( + "Running test with options: " + + JSON.stringify({ + isSponsored, + withHistory, + generalIndex, + expectedResultCount, + expectedIndex, + }) + ); + + // Set the index pref. + let indexPref = isSponsored ? SPONSORED_INDEX_PREF : NON_SPONSORED_INDEX_PREF; + await SpecialPowers.pushPrefEnv({ + set: [[indexPref, generalIndex]], + }); + + // Add history. + if (withHistory) { + await addHistory(); + } + + // Do a search. + let value = isSponsored + ? SPONSORED_SEARCH_STRING + : NON_SPONSORED_SEARCH_STRING; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + }); + + // Check the result count and quick suggest result. + Assert.equal( + UrlbarTestUtils.getResultCount(window), + expectedResultCount, + "Expected result count" + ); + await QuickSuggestTestUtils.assertIsQuickSuggest({ + window, + isSponsored, + index: expectedIndex, + url: isSponsored + ? REMOTE_SETTINGS_RESULTS[0].url + : REMOTE_SETTINGS_RESULTS[1].url, + }); + + await UrlbarTestUtils.promisePopupClose(window); + await PlacesUtils.history.clear(); + await SpecialPowers.popPrefEnv(); +} + +/** + * Adds history that matches the sponsored and non-sponsored search strings. + */ +async function addHistory() { + for (let i = 0; i < MAX_RESULTS; i++) { + await PlacesTestUtils.addVisits([ + "http://example.com/" + SPONSORED_SEARCH_STRING + i, + "http://example.com/" + NON_SPONSORED_SEARCH_STRING + i, + ]); + } +} + +/** + * Adds a search engine that provides suggestions, calls your callback, and then + * removes the engine. + * + * @param {Function} callback + * Your callback function. + */ +async function withSuggestions(callback) { + await SpecialPowers.pushPrefEnv({ + set: [[SUGGESTIONS_PREF, true]], + }); + let engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + }); + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + try { + await callback(engine); + } finally { + await Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.removeEngine(engine); + await SpecialPowers.popPrefEnv(); + } +} + +/** + * Registers a test provider that returns a result with a suggestedIndex and + * resultSpan and asserts the given expected results match the actual results. + * + * @param {Array} expectedProps + * See `checkResults()`. + */ +async function doSuggestedIndexTest(expectedProps) { + await addHistory(); + let provider = new TestProvider(); + UrlbarProvidersManager.registerProvider(provider); + + let context = await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: SPONSORED_SEARCH_STRING, + }); + checkResults(context.results, expectedProps); + await UrlbarTestUtils.promisePopupClose(window); + + UrlbarProvidersManager.unregisterProvider(provider); + await PlacesUtils.history.clear(); +} + +/** + * Asserts the given actual and expected results match. + * + * @param {Array} actualResults + * Array of actual results. + * @param {Array} expectedProps + * Array of expected result-like objects. Only the properties defined in each + * of these objects are compared against the corresponding actual result. + */ +function checkResults(actualResults, expectedProps) { + Assert.equal( + actualResults.length, + expectedProps.length, + "Expected result count" + ); + + let actualProps = actualResults.map((actual, i) => { + if (expectedProps.length <= i) { + return actual; + } + let props = {}; + let expected = expectedProps[i]; + for (let [key, expectedValue] of Object.entries(expected)) { + if (key != "payload") { + props[key] = actual[key]; + } else { + props.payload = {}; + for (let pkey of Object.keys(expectedValue)) { + props.payload[pkey] = actual.payload[pkey]; + } + } + } + return props; + }); + Assert.deepEqual(actualProps, expectedProps); +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_mdn.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_mdn.js new file mode 100644 index 0000000000..b7da7533c4 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_mdn.js @@ -0,0 +1,230 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for mdn suggestions. + +const REMOTE_SETTINGS_DATA = [ + { + type: "mdn-suggestions", + attachment: [ + { + url: "https://example.com/array-filter", + title: "Array.prototype.filter()", + description: + "The filter() method creates a shallow copy of a portion of a given array, filtered down to just the elements from the given array that pass the test implemented by the provided function.", + keywords: ["array"], + score: 0.24, + }, + ], + }, +]; + +add_setup(async function () { + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: REMOTE_SETTINGS_DATA, + }); +}); + +add_tasks_with_rust(async function basic() { + const suggestion = REMOTE_SETTINGS_DATA[0].attachment[0]; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: suggestion.keywords[0], + }); + Assert.equal(UrlbarTestUtils.getResultCount(window), 2); + + const { element, result } = await UrlbarTestUtils.getDetailsOfResultAt( + window, + 1 + ); + Assert.equal( + result.providerName, + UrlbarProviderQuickSuggest.name, + "The result should be from the expected provider" + ); + Assert.equal( + result.payload.provider, + UrlbarPrefs.get("quickSuggestRustEnabled") ? "Mdn" : "MDNSuggestions" + ); + + const onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + result.payload.url + ); + EventUtils.synthesizeMouseAtCenter(element.row, {}); + await onLoad; + Assert.ok(true, "Expected page is loaded"); + + await PlacesUtils.history.clear(); +}); + +// Tests the row/group label. +add_tasks_with_rust(async function rowLabel() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: REMOTE_SETTINGS_DATA[0].attachment[0].keywords[0], + }); + Assert.equal(UrlbarTestUtils.getResultCount(window), 2); + + const { element } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + const row = element.row; + Assert.equal(row.getAttribute("label"), "Recommended resource"); + + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_tasks_with_rust(async function disable() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.mdn.featureGate", false]], + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "array", + }); + Assert.equal(UrlbarTestUtils.getResultCount(window), 1); + + const { result } = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal(result.providerName, "HeuristicFallback"); + + await SpecialPowers.popPrefEnv(); + await QuickSuggestTestUtils.forceSync(); +}); + +// Tests the "Not interested" result menu dismissal command. +add_tasks_with_rust(async function resultMenu_notInterested() { + await doDismissTest("not_interested"); + + Assert.equal(UrlbarPrefs.get("suggest.mdn"), false); + const exists = await QuickSuggest.blockedSuggestions.has( + REMOTE_SETTINGS_DATA[0].attachment[0].url + ); + Assert.ok(!exists); + + // Re-enable suggestions and wait until MDNSuggestions syncs them from + // remote settings again. + UrlbarPrefs.set("suggest.mdn", true); + await QuickSuggestTestUtils.forceSync(); +}); + +// Tests the "Not relevant" result menu dismissal command. +add_tasks_with_rust(async function notRelevant() { + await doDismissTest("not_relevant"); + + Assert.equal(UrlbarPrefs.get("suggest.mdn"), true); + const exists = await QuickSuggest.blockedSuggestions.has( + REMOTE_SETTINGS_DATA[0].attachment[0].url + ); + Assert.ok(exists); + + await QuickSuggest.blockedSuggestions.clear(); +}); + +async function doDismissTest(command) { + const keyword = REMOTE_SETTINGS_DATA[0].attachment[0].keywords[0]; + // Do a search. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: keyword, + }); + + // Check the result. + const resultCount = 2; + Assert.equal( + UrlbarTestUtils.getResultCount(window), + resultCount, + "There should be two results" + ); + + const resultIndex = 1; + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + Assert.equal( + details.result.providerName, + UrlbarProviderQuickSuggest.name, + "The result should be from the expected provider" + ); + Assert.equal( + details.result.payload.telemetryType, + "mdn", + "The result should be a MDN result" + ); + + // Click the command. + await UrlbarTestUtils.openResultMenuAndClickItem( + window, + ["[data-l10n-id=firefox-suggest-command-dont-show-mdn]", command], + { resultIndex, openByMouse: true } + ); + + // The row should be a tip now. + Assert.ok(gURLBar.view.isOpen, "The view should remain open after dismissal"); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + resultCount, + "The result count should not haved changed after dismissal" + ); + details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + Assert.equal( + details.type, + UrlbarUtils.RESULT_TYPE.TIP, + "Row should be a tip after dismissal" + ); + Assert.equal( + details.result.payload.type, + "dismissalAcknowledgment", + "Tip type should be dismissalAcknowledgment" + ); + Assert.ok( + !details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should not have feedback acknowledgment after dismissal" + ); + + // Get the dismissal acknowledgment's "Got it" button and click it. + const gotItButton = UrlbarTestUtils.getButtonForResultIndex( + window, + 0, + resultIndex + ); + Assert.ok(gotItButton, "Row should have a 'Got it' button"); + EventUtils.synthesizeMouseAtCenter(gotItButton, {}, window); + + // The view should remain open and the tip row should be gone. + Assert.ok( + gURLBar.view.isOpen, + "The view should remain open clicking the 'Got it' button" + ); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + resultCount - 1, + "The result count should be one less after clicking 'Got it' button" + ); + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.ok( + details.type != UrlbarUtils.RESULT_TYPE.TIP && + details.result.payload.telemetryType !== "mdn", + "Tip result and suggestion should not be present" + ); + } + + gURLBar.handleRevert(); + + // Do the search again. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: keyword, + }); + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.ok( + details.type != UrlbarUtils.RESULT_TYPE.TIP && + details.result.payload.telemetryType !== "mdn", + "Tip result and suggestion should not be present" + ); + } + + await UrlbarTestUtils.promisePopupClose(window); +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_merinoSessions.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_merinoSessions.js new file mode 100644 index 0000000000..eab63f4c9e --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_merinoSessions.js @@ -0,0 +1,138 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// End-to-end browser smoke test for Merino sessions. More comprehensive tests +// are in test_quicksuggest_merinoSessions.js. This test essentially makes sure +// engagements occur as expected when interacting with the urlbar. If you need +// to add tests that do not depend on a new definition of "engagement", consider +// adding them to test_quicksuggest_merinoSessions.js instead. + +"use strict"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.quicksuggest.dataCollection.enabled", true]], + }); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await UrlbarTestUtils.formHistory.clear(); + + // Install a mock default engine so we don't hit the network. + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + await MerinoTestUtils.server.start(); +}); + +// In a single engagement, all requests should use the same session ID and the +// sequence number should be incremented. +add_task(async function singleEngagement() { + for (let i = 0; i < 3; i++) { + let searchString = "search" + i; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString, + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: i, + }, + }, + ]); + } + + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); +}); + +// In a single engagement, all requests should use the same session ID and the +// sequence number should be incremented. This task closes the panel between +// searches but keeps the input focused, so the engagement should not end. +add_task(async function singleEngagement_panelClosed() { + for (let i = 0; i < 3; i++) { + let searchString = "search" + i; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString, + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: i, + }, + }, + ]); + + EventUtils.synthesizeKey("KEY_Escape"); + Assert.ok(!UrlbarTestUtils.isPopupOpen(window), "Panel is closed"); + Assert.ok(gURLBar.focused, "Input remains focused"); + } + + // End the engagement to reset the session for the next test. + gURLBar.blur(); +}); + +// New engagements should not use the same session ID as previous engagements +// and the sequence number should be reset. This task completes each engagement +// successfully. +add_task(async function manyEngagements_engagement() { + for (let i = 0; i < 3; i++) { + // Open a new tab since we'll load the mock default search engine page. + await BrowserTestUtils.withNewTab("about:blank", async () => { + let searchString = "search" + i; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString, + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + }, + }, + ]); + + // Press enter on the heuristic result to load the search engine page and + // complete the engagement. + let loadPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + }); + } +}); + +// New engagements should not use the same session ID as previous engagements +// and the sequence number should be reset. This task abandons each engagement. +add_task(async function manyEngagements_abandonment() { + for (let i = 0; i < 3; i++) { + let searchString = "search" + i; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString, + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + }, + }, + ]); + + // Blur the urlbar to abandon the engagement. + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + } +}); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_onboardingDialog.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_onboardingDialog.js new file mode 100644 index 0000000000..6256a5aec2 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_onboardingDialog.js @@ -0,0 +1,1569 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests the buttons in the onboarding dialog for quick suggest/Firefox Suggest. + */ + +ChromeUtils.defineESModuleGetters(this, { + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", +}); + +const OTHER_DIALOG_URI = getRootDirectory(gTestPath) + "subdialog.xhtml"; + +// Default-branch pref values in the offline scenario. +const OFFLINE_DEFAULT_PREFS = { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": false, +}; + +let gDefaultBranch = Services.prefs.getDefaultBranch("browser.urlbar."); +let gUserBranch = Services.prefs.getBranch("browser.urlbar."); + +// Allow more time for Mac and Linux machines so they don't time out in verify mode. +if (AppConstants.platform === "macosx") { + requestLongerTimeout(4); +} else if (AppConstants.platform === "linux") { + requestLongerTimeout(2); +} + +// Whether the tab key can move the focus. On macOS with full keyboard access +// disabled (which is default), this will be false. See `canTabMoveFocus`. +let gCanTabMoveFocus; +add_setup(async function () { + gCanTabMoveFocus = await canTabMoveFocus(); + + // Ensure the test remote settings server is set up. This test doesn't trigger + // any suggestions but it enables Suggest, which will attempt to sync from + // remote settings. + await QuickSuggestTestUtils.ensureQuickSuggestInit(); +}); + +// When the user has already enabled the data-collection pref, the dialog should +// not appear. +add_task(async function dataCollectionAlreadyEnabled() { + setDialogPrereqPrefs(); + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true); + + info("Calling maybeShowOnboardingDialog"); + let showed = await QuickSuggest.maybeShowOnboardingDialog(); + Assert.ok(!showed, "The dialog was not shown"); + + UrlbarPrefs.clear("quicksuggest.dataCollection.enabled"); +}); + +// When the current tab is about:welcome, the dialog should not appear. +add_task(async function aboutWelcome() { + setDialogPrereqPrefs(); + await BrowserTestUtils.withNewTab("about:welcome", async () => { + info("Calling maybeShowOnboardingDialog"); + let showed = await QuickSuggest.maybeShowOnboardingDialog(); + Assert.ok(!showed, "The dialog was not shown"); + }); +}); + +// The Escape key should dismiss the dialog without opting in. This task tests +// when Escape is pressed while the focus is inside the dialog. +add_task(async function escKey_focusInsideDialog() { + await doDialogTest({ + callback: async () => { + const { maybeShowPromise } = await showOnboardingDialog({ + skipIntroduction: true, + }); + + const tabCount = gBrowser.tabs.length; + Assert.ok( + document.activeElement.classList.contains("dialogFrame"), + "dialogFrame is focused in the browser window" + ); + + info("Close the dialog"); + EventUtils.synthesizeKey("KEY_Escape"); + + await maybeShowPromise; + + Assert.equal( + gBrowser.currentURI.spec, + "about:blank", + "Nothing loaded in the current tab" + ); + Assert.equal(gBrowser.tabs.length, tabCount, "No news tabs were opened"); + }, + onboardingDialogVersion: JSON.stringify({ version: 1 }), + onboardingDialogChoice: "dismiss_2", + expectedUserBranchPrefs: { + "quicksuggest.dataCollection.enabled": false, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "disabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: "dismiss_2", + }, + ], + }); +}); + +// The Escape key should dismiss the dialog without opting in. This task tests +// when Escape is pressed while the focus is outside the dialog. +add_task(async function escKey_focusOutsideDialog() { + await doDialogTest({ + callback: async () => { + const { maybeShowPromise } = await showOnboardingDialog({ + skipIntroduction: true, + }); + + document.documentElement.focus(); + Assert.ok( + !document.activeElement.classList.contains("dialogFrame"), + "dialogFrame is not focused in the browser window" + ); + + info("Close the dialog"); + EventUtils.synthesizeKey("KEY_Escape"); + + await maybeShowPromise; + }, + onboardingDialogVersion: JSON.stringify({ version: 1 }), + onboardingDialogChoice: "dismiss_2", + expectedUserBranchPrefs: { + "quicksuggest.dataCollection.enabled": false, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "disabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: "dismiss_2", + }, + ], + }); +}); + +// The Escape key should dismiss the dialog without opting in when another +// dialog is queued and shown before the onboarding. This task dismisses the +// other dialog by pressing the Escape key. +add_task(async function escKey_queued_esc() { + await doQueuedEscKeyTest("KEY_Escape"); +}); + +// The Escape key should dismiss the dialog without opting in when another +// dialog is queued and shown before the onboarding. This task dismisses the +// other dialog by pressing the Enter key. +add_task(async function escKey_queued_enter() { + await doQueuedEscKeyTest("KEY_Enter"); +}); + +async function doQueuedEscKeyTest(otherDialogKey) { + await doDialogTest({ + callback: async () => { + // Create promises that will resolve when each dialog is opened. + let uris = [OTHER_DIALOG_URI, QuickSuggest.ONBOARDING_URI]; + let [otherOpenedPromise, onboardingOpenedPromise] = uris.map(uri => + TestUtils.topicObserved( + "subdialog-loaded", + contentWin => contentWin.document.documentURI == uri + ).then(async ([contentWin]) => { + if (contentWin.document.readyState != "complete") { + await BrowserTestUtils.waitForEvent(contentWin, "load"); + } + }) + ); + + info("Queuing dialogs for opening"); + let otherClosedPromise = gDialogBox.open(OTHER_DIALOG_URI); + let onboardingClosedPromise = QuickSuggest.maybeShowOnboardingDialog(); + + info("Waiting for the other dialog to open"); + await otherOpenedPromise; + + info(`Pressing ${otherDialogKey} and waiting for other dialog to close`); + EventUtils.synthesizeKey(otherDialogKey); + await otherClosedPromise; + + info("Waiting for the onboarding dialog to open"); + await onboardingOpenedPromise; + + info("Pressing Escape and waiting for onboarding dialog to close"); + EventUtils.synthesizeKey("KEY_Escape"); + await onboardingClosedPromise; + }, + onboardingDialogVersion: JSON.stringify({ version: 1 }), + onboardingDialogChoice: "dismiss_1", + expectedUserBranchPrefs: { + "quicksuggest.dataCollection.enabled": false, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "disabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: "dismiss_1", + }, + ], + }); +} + +// Tests `dismissed_other` by closing the dialog programmatically. +add_task(async function dismissed_other_on_introduction() { + await doDialogTest({ + callback: async () => { + const { maybeShowPromise } = await showOnboardingDialog(); + gDialogBox._dialog.close(); + await maybeShowPromise; + }, + onboardingDialogVersion: JSON.stringify({ version: 1 }), + onboardingDialogChoice: "dismiss_1", + expectedUserBranchPrefs: { + "quicksuggest.dataCollection.enabled": false, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "disabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: "dismiss_1", + }, + ], + }); +}); + +// The default is to wait for no browser restarts to show the onboarding dialog +// on the first restart. This tests that we can override it by configuring the +// `showOnboardingDialogOnNthRestart` +add_task(async function nimbus_override_wait_after_n_restarts() { + UrlbarPrefs.clear("quicksuggest.shouldShowOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.showedOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.seenRestarts", 0); + + await QuickSuggestTestUtils.withExperiment({ + valueOverrides: { + quickSuggestScenario: "online", + // Wait for 1 browser restart + quickSuggestShowOnboardingDialogAfterNRestarts: 1, + }, + callback: async () => { + let prefPromise = TestUtils.waitForPrefChange( + "browser.urlbar.quicksuggest.showedOnboardingDialog", + value => value === true + ).then(() => info("Saw pref change")); + + // Simulate 2 restarts. this function is only called by BrowserGlue + // on startup, the first restart would be where MR1 was shown then + // we will show onboarding the 2nd restart after that. + info("Simulating first restart"); + await QuickSuggest.maybeShowOnboardingDialog(); + + info("Simulating second restart"); + const dialogPromise = BrowserTestUtils.promiseAlertDialogOpen( + null, + QuickSuggest.ONBOARDING_URI, + { isSubDialog: true } + ); + const maybeShowPromise = QuickSuggest.maybeShowOnboardingDialog(); + const win = await dialogPromise; + if (win.document.readyState != "complete") { + await BrowserTestUtils.waitForEvent(win, "load"); + } + // Close dialog. + EventUtils.synthesizeKey("KEY_Escape"); + + info("Waiting for maybeShowPromise and pref change"); + await Promise.all([maybeShowPromise, prefPromise]); + }, + }); +}); + +add_task(async function nimbus_skip_onboarding_dialog() { + UrlbarPrefs.clear("quicksuggest.shouldShowOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.showedOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.seenRestarts", 0); + + await QuickSuggestTestUtils.withExperiment({ + valueOverrides: { + quickSuggestScenario: "online", + quickSuggestShouldShowOnboardingDialog: false, + }, + callback: async () => { + // Simulate 3 restarts. + for (let i = 0; i < 3; i++) { + info(`Simulating restart ${i + 1}`); + await QuickSuggest.maybeShowOnboardingDialog(); + } + Assert.ok( + !Services.prefs.getBoolPref( + "browser.urlbar.quicksuggest.showedOnboardingDialog", + false + ), + "The showed onboarding dialog pref should not be set" + ); + }, + }); +}); + +const LOGO_TYPE = { + FIREFOX: 1, + MAGGLASS: 2, + ANIMATION_MAGGLASS: 3, +}; + +const VARIATION_TEST_DATA = [ + { + name: "A", + introductionSection: { + logoType: LOGO_TYPE.ANIMATION_MAGGLASS, + l10n: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-1", + }, + visibility: { + "#onboardingLearnMoreOnIntroduction": false, + ".description-section": false, + ".pager": true, + }, + defaultFocusOrder: [ + "onboardingNext", + "onboardingClose", + "onboardingDialog", + "onboardingNext", + ], + actions: ["onboardingClose", "onboardingNext"], + }, + mainSection: { + logoType: LOGO_TYPE.MAGGLASS, + l10n: { + "main-title": "firefox-suggest-onboarding-main-title-1", + "main-description": "firefox-suggest-onboarding-main-description-1", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-1", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-1", + }, + visibility: { + "#main-privacy-first": false, + ".description-section #onboardingLearnMore": false, + ".accept #onboardingLearnMore": true, + ".pager": true, + }, + defaultFocusOrder: [ + "onboardingNext", + "onboardingAccept", + "onboardingLearnMore", + "onboardingReject", + "onboardingSkipLink", + "onboardingDialog", + "onboardingAccept", + ], + acceptFocusOrder: [ + "onboardingAccept", + "onboardingLearnMore", + "onboardingSubmit", + "onboardingSkipLink", + "onboardingDialog", + "onboardingAccept", + ], + rejectFocusOrder: [ + "onboardingReject", + "onboardingSubmit", + "onboardingSkipLink", + "onboardingDialog", + "onboardingLearnMore", + "onboardingReject", + ], + actions: [ + "onboardingAccept", + "onboardingReject", + "onboardingSkipLink", + "onboardingLearnMore", + ], + }, + }, + { + // We don't need to test the focus order and actions because the layout of + // variation B-H is as same as A. + name: "B", + introductionSection: { + logoType: LOGO_TYPE.ANIMATION_MAGGLASS, + l10n: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-2", + }, + visibility: { + "#onboardingLearnMoreOnIntroduction": false, + ".description-section": false, + ".pager": true, + }, + }, + mainSection: { + logoType: LOGO_TYPE.MAGGLASS, + l10n: { + "main-title": "firefox-suggest-onboarding-main-title-2", + "main-description": "firefox-suggest-onboarding-main-description-2", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-1", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-1", + }, + visibility: { + "#main-privacy-first": false, + ".description-section #onboardingLearnMore": false, + ".accept #onboardingLearnMore": true, + ".pager": true, + }, + }, + }, + { + name: "C", + introductionSection: { + logoType: LOGO_TYPE.FIREFOX, + l10n: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-3", + }, + visibility: { + "#onboardingLearnMoreOnIntroduction": false, + ".description-section": false, + ".pager": true, + }, + }, + mainSection: { + logoType: LOGO_TYPE.FIREFOX, + l10n: { + "main-title": "firefox-suggest-onboarding-main-title-3", + "main-description": "firefox-suggest-onboarding-main-description-3", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-1", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-1", + }, + visibility: { + "#main-privacy-first": false, + ".description-section #onboardingLearnMore": false, + ".accept #onboardingLearnMore": true, + ".pager": true, + }, + }, + }, + { + name: "D", + introductionSection: { + logoType: LOGO_TYPE.ANIMATION_MAGGLASS, + l10n: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-4", + }, + visibility: { + "#onboardingLearnMoreOnIntroduction": false, + ".description-section": false, + ".pager": true, + }, + }, + mainSection: { + logoType: LOGO_TYPE.MAGGLASS, + l10n: { + "main-title": "firefox-suggest-onboarding-main-title-4", + "main-description": "firefox-suggest-onboarding-main-description-4", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-2", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-2", + }, + visibility: { + "#main-privacy-first": false, + ".description-section #onboardingLearnMore": false, + ".accept #onboardingLearnMore": true, + ".pager": true, + }, + }, + }, + { + name: "E", + introductionSection: { + logoType: LOGO_TYPE.FIREFOX, + l10n: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-5", + }, + visibility: { + "#onboardingLearnMoreOnIntroduction": false, + ".description-section": false, + ".pager": true, + }, + }, + mainSection: { + logoType: LOGO_TYPE.FIREFOX, + l10n: { + "main-title": "firefox-suggest-onboarding-main-title-5", + "main-description": "firefox-suggest-onboarding-main-description-5", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-2", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-2", + }, + visibility: { + "#main-privacy-first": false, + ".description-section #onboardingLearnMore": false, + ".accept #onboardingLearnMore": true, + ".pager": true, + }, + }, + }, + { + name: "F", + introductionSection: { + logoType: LOGO_TYPE.ANIMATION_MAGGLASS, + l10n: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-2", + "introduction-title": "firefox-suggest-onboarding-introduction-title-6", + }, + visibility: { + "#onboardingLearnMoreOnIntroduction": false, + ".description-section": false, + ".pager": true, + }, + }, + mainSection: { + logoType: LOGO_TYPE.MAGGLASS, + l10n: { + "main-title": "firefox-suggest-onboarding-main-title-6", + "main-description": "firefox-suggest-onboarding-main-description-6", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-2", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-2", + }, + visibility: { + "#main-privacy-first": false, + ".description-section #onboardingLearnMore": false, + ".accept #onboardingLearnMore": true, + ".pager": true, + }, + }, + }, + { + name: "G", + introductionSection: { + logoType: LOGO_TYPE.ANIMATION_MAGGLASS, + l10n: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-7", + }, + visibility: { + "#onboardingLearnMoreOnIntroduction": false, + ".description-section": false, + ".pager": true, + }, + }, + mainSection: { + logoType: LOGO_TYPE.MAGGLASS, + l10n: { + "main-title": "firefox-suggest-onboarding-main-title-7", + "main-description": "firefox-suggest-onboarding-main-description-7", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-2", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-2", + }, + visibility: { + "#main-privacy-first": true, + ".description-section #onboardingLearnMore": false, + ".accept #onboardingLearnMore": true, + ".pager": true, + }, + }, + }, + { + name: "H", + introductionSection: { + logoType: LOGO_TYPE.FIREFOX, + l10n: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-2", + }, + visibility: { + "#onboardingLearnMoreOnIntroduction": false, + ".description-section": false, + ".pager": true, + }, + }, + mainSection: { + logoType: LOGO_TYPE.FIREFOX, + l10n: { + "main-title": "firefox-suggest-onboarding-main-title-8", + "main-description": "firefox-suggest-onboarding-main-description-8", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-1", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-1", + }, + visibility: { + "#main-privacy-first": false, + ".description-section #onboardingLearnMore": false, + ".accept #onboardingLearnMore": true, + ".pager": true, + }, + }, + }, + { + name: "100-A", + introductionSection: { + logoType: LOGO_TYPE.FIREFOX, + l10n: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-3", + "introduction-title": "firefox-suggest-onboarding-main-title-9", + }, + visibility: { + "#onboardingLearnMoreOnIntroduction": true, + ".description-section": true, + ".pager": true, + }, + defaultFocusOrder: [ + "onboardingNext", + "onboardingLearnMoreOnIntroduction", + "onboardingClose", + "onboardingDialog", + "onboardingNext", + ], + actions: [ + "onboardingClose", + "onboardingNext", + "onboardingLearnMoreOnIntroduction", + ], + }, + mainSection: { + logoType: LOGO_TYPE.FIREFOX, + l10n: { + "main-title": "firefox-suggest-onboarding-main-title-9", + "main-description": "firefox-suggest-onboarding-main-description-9", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label-2", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-3", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label-2", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-3", + }, + visibility: { + "#main-privacy-first": true, + ".description-section #onboardingLearnMore": true, + ".accept #onboardingLearnMore": false, + ".pager": false, + }, + defaultFocusOrder: [ + "onboardingNext", + "onboardingLearnMore", + "onboardingAccept", + "onboardingReject", + "onboardingSkipLink", + "onboardingDialog", + "onboardingLearnMore", + ], + acceptFocusOrder: [ + "onboardingAccept", + "onboardingSubmit", + "onboardingSkipLink", + "onboardingDialog", + "onboardingLearnMore", + "onboardingAccept", + ], + rejectFocusOrder: [ + "onboardingReject", + "onboardingSubmit", + "onboardingSkipLink", + "onboardingDialog", + "onboardingLearnMore", + "onboardingReject", + ], + actions: [ + "onboardingAccept", + "onboardingReject", + "onboardingSkipLink", + "onboardingLearnMore", + ], + }, + }, + { + name: "100-B", + mainSection: { + logoType: LOGO_TYPE.FIREFOX, + l10n: { + "main-title": "firefox-suggest-onboarding-main-title-9", + "main-description": "firefox-suggest-onboarding-main-description-9", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label-2", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-3", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label-2", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-3", + }, + visibility: { + "#main-privacy-first": true, + ".description-section #onboardingLearnMore": true, + ".accept #onboardingLearnMore": false, + ".pager": false, + }, + // Layout of 100-B is same as 100-A, but since there is no the introduction + // pane, only the default focus order on the main pane is a bit diffrence. + defaultFocusOrder: [ + "onboardingLearnMore", + "onboardingAccept", + "onboardingReject", + "onboardingSkipLink", + "onboardingDialog", + "onboardingLearnMore", + ], + }, + }, +]; + +/** + * This test checks for differences due to variations in logo type, l10n text, + * element visibility, order of focus, actions, etc. The designation is on + * VARIATION_TEST_DATA. The items that can be specified are below. + * + * name: Specify the variation name. + * + * The following items are specified for each section. + * (introductionSection, mainSection). + * + * logoType: + * Specify the expected logo type. Please refer to LOGO_TYPE about the type. + * + * l10n: + * Specify the expected l10n id applied to elements. + * + * visibility: + * Specify the expected visibility of elements. The way to specify the element + * is using selector. + * + * defaultFocusOrder: + * Specify the expected focus order right after the section is appeared. The + * way to specify the element is using id. + * + * acceptFocusOrder: + * Specify the expected focus order after selecting accept option. + * + * rejectFocusOrder: + * Specify the expected focus order after selecting reject option. + * + * actions: + * Specify the action we want to verify such as clicking the close button. The + * available actions are below. + * - onboardingClose: + * Action of the close button “x” by mouse/keyboard. + * - onboardingNext: + * Action of the next button that transits from the introduction section to + * the main section by mouse/keyboard. + * - onboardingAccept: + * Action of the submit button by mouse/keyboard after selecting accept + * option by mouse/keyboard. + * - onboardingReject: + * Action of the submit button by mouse/keyboard after selecting reject + * option by mouse/keyboard. + * - onboardingSkipLink: + * Action of the skip link by mouse/keyboard. + * - onboardingLearnMore: + * Action of the learn more link by mouse/keyboard. + * - onboardingLearnMoreOnIntroduction: + * Action of the learn more link on the introduction section by + * mouse/keyboard. + */ +add_task(async function variation_test() { + for (const variation of VARIATION_TEST_DATA) { + info(`Test for variation [${variation.name}]`); + + info("Do layout test"); + await doLayoutTest(variation); + + for (const action of variation.introductionSection?.actions || []) { + info( + `${action} test on the introduction section for variation [${variation.name}]` + ); + await this[action](variation); + } + + for (const action of variation.mainSection?.actions || []) { + info( + `${action} test on the main section for variation [${variation.name}]` + ); + await this[action](variation, !!variation.introductionSection); + } + } +}); + +async function doLayoutTest(variation) { + UrlbarPrefs.clear("quicksuggest.shouldShowOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.showedOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.seenRestarts", 0); + + await QuickSuggestTestUtils.withExperiment({ + valueOverrides: { + quickSuggestScenario: "online", + quickSuggestOnboardingDialogVariation: variation.name, + }, + callback: async () => { + info("Calling showOnboardingDialog"); + const { win, maybeShowPromise } = await showOnboardingDialog(); + + const introductionSection = win.document.getElementById( + "introduction-section" + ); + const mainSection = win.document.getElementById("main-section"); + + if (variation.introductionSection) { + info("Check the section visibility"); + Assert.ok(BrowserTestUtils.isVisible(introductionSection)); + Assert.ok(BrowserTestUtils.isHidden(mainSection)); + + info("Check the introduction section"); + await assertSection(introductionSection, variation.introductionSection); + + info("Transition to the main section"); + win.document.getElementById("onboardingNext").click(); + await BrowserTestUtils.waitForCondition( + () => + BrowserTestUtils.isHidden(introductionSection) && + BrowserTestUtils.isVisible(mainSection) + ); + } else { + info("Check the section visibility"); + Assert.ok(BrowserTestUtils.isHidden(introductionSection)); + Assert.ok(BrowserTestUtils.isVisible(mainSection)); + } + + info("Check the main section"); + await assertSection(mainSection, variation.mainSection); + + info("Close the dialog"); + EventUtils.synthesizeKey("KEY_Escape", {}, win); + await maybeShowPromise; + }, + }); +} + +async function assertSection(sectionElement, expectedSection) { + info("Check the logo"); + assertLogo(sectionElement, expectedSection.logoType); + + info("Check the l10n"); + assertL10N(sectionElement, expectedSection.l10n); + + info("Check the visibility"); + assertVisibility(sectionElement, expectedSection.visibility); + + if (!gCanTabMoveFocus) { + Assert.ok(true, "Tab key can't move focus, skipping test for focus order"); + return; + } + + if (expectedSection.defaultFocusOrder) { + info("Check the default focus order"); + assertFocusOrder(sectionElement, expectedSection.defaultFocusOrder); + } + + if (expectedSection.acceptFocusOrder) { + info("Check the focus order after selecting accept option"); + sectionElement.querySelector("#onboardingAccept").focus(); + EventUtils.synthesizeKey("VK_SPACE", {}, sectionElement.ownerGlobal); + assertFocusOrder(sectionElement, expectedSection.acceptFocusOrder); + } + + if (expectedSection.rejectFocusOrder) { + info("Check the focus order after selecting reject option"); + sectionElement.querySelector("#onboardingReject").focus(); + EventUtils.synthesizeKey("VK_SPACE", {}, sectionElement.ownerGlobal); + assertFocusOrder(sectionElement, expectedSection.rejectFocusOrder); + } +} + +function assertLogo(sectionElement, expectedLogoType) { + let expectedLogoImage; + switch (expectedLogoType) { + case LOGO_TYPE.FIREFOX: { + expectedLogoImage = 'url("chrome://branding/content/about-logo.svg")'; + break; + } + case LOGO_TYPE.MAGGLASS: { + expectedLogoImage = + 'url("chrome://browser/content/urlbar/quicksuggestOnboarding_magglass.svg")'; + break; + } + case LOGO_TYPE.ANIMATION_MAGGLASS: { + const mediaQuery = sectionElement.ownerGlobal.matchMedia( + "(prefers-reduced-motion: no-preference)" + ); + expectedLogoImage = mediaQuery.matches + ? 'url("chrome://browser/content/urlbar/quicksuggestOnboarding_magglass_animation.svg")' + : 'url("chrome://browser/content/urlbar/quicksuggestOnboarding_magglass.svg")'; + break; + } + default: { + Assert.ok(false, `Unexpected image type ${expectedLogoType}`); + break; + } + } + + const logo = sectionElement.querySelector(".logo"); + Assert.ok(BrowserTestUtils.isVisible(logo)); + const logoImage = + sectionElement.ownerGlobal.getComputedStyle(logo).backgroundImage; + Assert.equal(logoImage, expectedLogoImage); +} + +function assertL10N(sectionElement, expectedL10N) { + for (const [id, l10n] of Object.entries(expectedL10N)) { + const element = sectionElement.querySelector("#" + id); + Assert.equal(element.getAttribute("data-l10n-id"), l10n); + } +} + +function assertVisibility(sectionElement, expectedVisibility) { + for (const [selector, visibility] of Object.entries(expectedVisibility)) { + const element = sectionElement.querySelector(selector); + if (visibility) { + Assert.ok(BrowserTestUtils.isVisible(element)); + } else { + if (!element) { + Assert.ok(true); + return; + } + Assert.ok(BrowserTestUtils.isHidden(element)); + } + } +} + +function assertFocusOrder(sectionElement, expectedFocusOrder) { + const win = sectionElement.ownerGlobal; + + // Check initial active element. + Assert.equal(win.document.activeElement.id, expectedFocusOrder[0]); + + for (const next of expectedFocusOrder.slice(1)) { + EventUtils.synthesizeKey("KEY_Tab", {}, win); + Assert.equal(win.document.activeElement.id, next); + } +} + +async function onboardingClose(variation) { + await doActionTest({ + callback: async (win, userAction, maybeShowPromise) => { + info("Check the status of the close button"); + const closeButton = win.document.getElementById("onboardingClose"); + Assert.ok(BrowserTestUtils.isVisible(closeButton)); + Assert.equal(closeButton.getAttribute("title"), "Close"); + + info("Commit the close button"); + userAction(closeButton); + + info("Waiting for maybeShowOnboardingDialog to finish"); + await maybeShowPromise; + }, + variation, + onboardingDialogVersion: JSON.stringify({ + version: 1, + variation: variation.name.toLowerCase(), + }), + onboardingDialogChoice: "close_1", + expectedUserBranchPrefs: { + "quicksuggest.dataCollection.enabled": false, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "disabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: "close_1", + }, + ], + }); +} + +async function onboardingNext(variation) { + await doActionTest({ + callback: async (win, userAction, maybeShowPromise) => { + info("Check the status of the next button"); + const nextButton = win.document.getElementById("onboardingNext"); + Assert.ok(BrowserTestUtils.isVisible(nextButton)); + + info("Commit the next button"); + userAction(nextButton); + + const introductionSection = win.document.getElementById( + "introduction-section" + ); + const mainSection = win.document.getElementById("main-section"); + await BrowserTestUtils.waitForCondition( + () => + BrowserTestUtils.isHidden(introductionSection) && + BrowserTestUtils.isVisible(mainSection), + "Wait for the transition" + ); + + info("Exit"); + EventUtils.synthesizeKey("KEY_Escape", {}, win); + + info("Waiting for maybeShowOnboardingDialog to finish"); + await maybeShowPromise; + }, + variation, + onboardingDialogVersion: JSON.stringify({ + version: 1, + variation: variation.name.toLowerCase(), + }), + onboardingDialogChoice: "dismiss_2", + expectedUserBranchPrefs: { + "quicksuggest.dataCollection.enabled": false, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "disabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: "dismiss_2", + }, + ], + }); +} + +async function onboardingAccept(variation, skipIntroduction) { + await doActionTest({ + callback: async (win, userAction, maybeShowPromise) => { + info("Check the status of the accept option and submit button"); + const acceptOption = win.document.getElementById("onboardingAccept"); + const submitButton = win.document.getElementById("onboardingSubmit"); + Assert.ok(acceptOption); + Assert.ok(submitButton.disabled); + + info("Select the accept option"); + userAction(acceptOption); + + info("Commit the submit button"); + Assert.ok(!submitButton.disabled); + userAction(submitButton); + + info("Waiting for maybeShowOnboardingDialog to finish"); + await maybeShowPromise; + }, + variation, + skipIntroduction, + onboardingDialogVersion: JSON.stringify({ + version: 1, + variation: variation.name.toLowerCase(), + }), + onboardingDialogChoice: "accept_2", + expectedUserBranchPrefs: { + "quicksuggest.onboardingDialogVersion": JSON.stringify({ version: 1 }), + "quicksuggest.dataCollection.enabled": true, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "enabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: "accept_2", + }, + ], + }); +} + +async function onboardingReject(variation, skipIntroduction) { + await doActionTest({ + callback: async (win, userAction, maybeShowPromise) => { + info("Check the status of the reject option and submit button"); + const rejectOption = win.document.getElementById("onboardingReject"); + const submitButton = win.document.getElementById("onboardingSubmit"); + Assert.ok(rejectOption); + Assert.ok(submitButton.disabled); + + info("Select the reject option"); + userAction(rejectOption); + + info("Commit the submit button"); + Assert.ok(!submitButton.disabled); + userAction(submitButton); + + info("Waiting for maybeShowOnboardingDialog to finish"); + await maybeShowPromise; + }, + variation, + skipIntroduction, + onboardingDialogVersion: JSON.stringify({ + version: 1, + variation: variation.name.toLowerCase(), + }), + onboardingDialogChoice: "reject_2", + expectedUserBranchPrefs: { + "quicksuggest.dataCollection.enabled": false, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "disabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: "reject_2", + }, + ], + }); +} + +async function onboardingSkipLink(variation, skipIntroduction) { + await doActionTest({ + callback: async (win, userAction, maybeShowPromise) => { + info("Check the status of the skip link"); + const skipLink = win.document.getElementById("onboardingSkipLink"); + Assert.ok(BrowserTestUtils.isVisible(skipLink)); + + info("Commit the skip link"); + const tabCount = gBrowser.tabs.length; + userAction(skipLink); + + info("Waiting for maybeShowOnboardingDialog to finish"); + await maybeShowPromise; + + info("Check the current tab status"); + Assert.equal( + gBrowser.currentURI.spec, + "about:blank", + "Nothing loaded in the current tab" + ); + Assert.equal(gBrowser.tabs.length, tabCount, "No news tabs were opened"); + }, + variation, + skipIntroduction, + onboardingDialogVersion: JSON.stringify({ + version: 1, + variation: variation.name.toLowerCase(), + }), + onboardingDialogChoice: "not_now_2", + expectedUserBranchPrefs: { + "quicksuggest.dataCollection.enabled": false, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "disabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: "not_now_2", + }, + ], + }); +} + +async function onboardingLearnMore(variation, skipIntroduction) { + await doLearnMoreTest( + variation, + skipIntroduction, + "onboardingLearnMore", + "learn_more_2" + ); +} + +async function onboardingLearnMoreOnIntroduction(variation, skipIntroduction) { + await doLearnMoreTest( + variation, + skipIntroduction, + "onboardingLearnMoreOnIntroduction", + "learn_more_1" + ); +} + +async function doLearnMoreTest(variation, skipIntroduction, target, telemetry) { + await doActionTest({ + callback: async (win, userAction, maybeShowPromise) => { + info("Check the status of the learn more link"); + const learnMoreLink = win.document.getElementById(target); + Assert.ok(BrowserTestUtils.isVisible(learnMoreLink)); + + info("Commit the learn more link"); + const loadPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + QuickSuggest.HELP_URL + ).then(tab => { + info("Saw new tab"); + return tab; + }); + userAction(learnMoreLink); + + info("Waiting for maybeShowOnboardingDialog to finish"); + await maybeShowPromise; + + info("Waiting for new tab"); + let tab = await loadPromise; + + info("Check the current tab status"); + Assert.equal(gBrowser.selectedTab, tab, "Current tab is the new tab"); + Assert.equal( + gBrowser.currentURI.spec, + QuickSuggest.HELP_URL, + "Current tab is the support page" + ); + BrowserTestUtils.removeTab(tab); + }, + variation, + skipIntroduction, + onboardingDialogChoice: telemetry, + onboardingDialogVersion: JSON.stringify({ + version: 1, + variation: variation.name.toLowerCase(), + }), + expectedUserBranchPrefs: { + "quicksuggest.dataCollection.enabled": false, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "disabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: telemetry, + }, + ], + }); +} + +async function doActionTest({ + variation, + skipIntroduction, + callback, + onboardingDialogVersion, + onboardingDialogChoice, + expectedUserBranchPrefs, + telemetryEvents, +}) { + const userClick = target => { + info("Click on the target"); + target.click(); + }; + const userEnter = target => { + target.focus(); + if (target.type === "radio") { + info("Space on the target"); + EventUtils.synthesizeKey("VK_SPACE", {}, target.ownerGlobal); + } else { + info("Enter on the target"); + EventUtils.synthesizeKey("KEY_Enter", {}, target.ownerGlobal); + } + }; + + for (const userAction of [userClick, userEnter]) { + UrlbarPrefs.clear("quicksuggest.shouldShowOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.showedOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.seenRestarts", 0); + + await QuickSuggestTestUtils.withExperiment({ + valueOverrides: { + quickSuggestScenario: "online", + quickSuggestOnboardingDialogVariation: variation.name, + }, + callback: async () => { + await doDialogTest({ + callback: async () => { + info("Calling showOnboardingDialog"); + const { win, maybeShowPromise } = await showOnboardingDialog({ + skipIntroduction, + }); + + await callback(win, userAction, maybeShowPromise); + }, + onboardingDialogVersion, + onboardingDialogChoice, + expectedUserBranchPrefs, + telemetryEvents, + }); + }, + }); + } +} + +async function doDialogTest({ + callback, + onboardingDialogVersion, + onboardingDialogChoice, + telemetryEvents, + expectedUserBranchPrefs, +}) { + setDialogPrereqPrefs(); + + // Set initial prefs on the default branch. + let initialDefaultBranch = OFFLINE_DEFAULT_PREFS; + let originalDefaultBranch = {}; + for (let [name, value] of Object.entries(initialDefaultBranch)) { + originalDefaultBranch = gDefaultBranch.getBoolPref(name); + gDefaultBranch.setBoolPref(name, value); + gUserBranch.clearUserPref(name); + } + + // Setting the prefs just now triggered telemetry events, so clear them + // before calling the callback. + Services.telemetry.clearEvents(); + + // Call the callback, which should trigger the dialog and interact with it. + await BrowserTestUtils.withNewTab("about:blank", async () => { + await callback(); + }); + + // Now check all pref values on the default and user branches. + for (let [name, value] of Object.entries(initialDefaultBranch)) { + Assert.equal( + gDefaultBranch.getBoolPref(name), + value, + "Default-branch value for pref did not change after modal: " + name + ); + + let effectiveValue; + if (name in expectedUserBranchPrefs) { + effectiveValue = expectedUserBranchPrefs[name]; + Assert.equal( + gUserBranch.getBoolPref(name), + effectiveValue, + "User-branch value for pref has expected value: " + name + ); + } else { + effectiveValue = value; + Assert.ok( + !gUserBranch.prefHasUserValue(name), + "User-branch value for pref does not exist: " + name + ); + } + + // For good measure, check the value returned by UrlbarPrefs. + Assert.equal( + UrlbarPrefs.get(name), + effectiveValue, + "Effective value for pref is correct: " + name + ); + } + + Assert.equal( + UrlbarPrefs.get("quicksuggest.onboardingDialogVersion"), + onboardingDialogVersion, + "onboardingDialogVersion" + ); + Assert.equal( + UrlbarPrefs.get("quicksuggest.onboardingDialogChoice"), + onboardingDialogChoice, + "onboardingDialogChoice" + ); + Assert.equal( + TelemetryEnvironment.currentEnvironment.settings.userPrefs[ + "browser.urlbar.quicksuggest.onboardingDialogChoice" + ], + onboardingDialogChoice, + "onboardingDialogChoice is correct in TelemetryEnvironment" + ); + + QuickSuggestTestUtils.assertEvents(telemetryEvents); + + Assert.ok( + UrlbarPrefs.get("quicksuggest.showedOnboardingDialog"), + "quicksuggest.showedOnboardingDialog is true after showing dialog" + ); + + // Clean up. + for (let [name, value] of Object.entries(originalDefaultBranch)) { + gDefaultBranch.setBoolPref(name, value); + } + for (let name of Object.keys(expectedUserBranchPrefs)) { + gUserBranch.clearUserPref(name); + } +} + +/** + * Show onbaording dialog. + * + * @param {object} [options] + * The object options. + * @param {boolean} [options.skipIntroduction] + * If true, return dialog with skipping the introduction section. + * @returns {{ window, maybeShowPromise: Promise }} + * win: window object of the dialog. + * maybeShowPromise: Promise of QuickSuggest.maybeShowOnboardingDialog(). + */ +async function showOnboardingDialog({ skipIntroduction } = {}) { + const dialogPromise = BrowserTestUtils.promiseAlertDialogOpen( + null, + QuickSuggest.ONBOARDING_URI, + { isSubDialog: true } + ); + + const maybeShowPromise = QuickSuggest.maybeShowOnboardingDialog(); + + const win = await dialogPromise; + if (win.document.readyState != "complete") { + await BrowserTestUtils.waitForEvent(win, "load"); + } + + // Wait until all listers on onboarding dialog are ready. + await window._quicksuggestOnboardingReady; + + if (!skipIntroduction) { + return { win, maybeShowPromise }; + } + + // Trigger the transition by pressing Enter on the Next button. + EventUtils.synthesizeKey("KEY_Enter"); + + const introductionSection = win.document.getElementById( + "introduction-section" + ); + const mainSection = win.document.getElementById("main-section"); + + await BrowserTestUtils.waitForCondition( + () => + BrowserTestUtils.isHidden(introductionSection) && + BrowserTestUtils.isVisible(mainSection) + ); + + return { win, maybeShowPromise }; +} + +/** + * Sets all the required prefs for showing the onboarding dialog except for the + * prefs that are set when the dialog is accepted. + */ +function setDialogPrereqPrefs() { + UrlbarPrefs.set("quicksuggest.shouldShowOnboardingDialog", true); + UrlbarPrefs.set("quicksuggest.showedOnboardingDialog", false); +} + +/** + * This is a real hacky way of determining whether the tab key can move focus. + * Windows and Linux both support it but macOS does not unless full keyboard + * access is enabled, so practically this is only useful on macOS. Gecko seems + * to know whether full keyboard access is enabled because it affects focus in + * Firefox and some code in nsXULElement.cpp and other places mention it, but + * there doesn't seem to be a way to access that information from JS. There is + * `Services.focus.elementIsFocusable`, but it returns true regardless of + * whether full access is enabled. + * + * So what we do here is open the dialog and synthesize a tab key. If the focus + * doesn't change, then we assume moving the focus via the tab key is not + * supported. + * + * Why not just always skip the focus tasks on Mac? Because individual + * developers (like the one writing this comment) may be running macOS with full + * keyboard access enabled and want to actually run the tasks on their machines. + * + * @returns {boolean} + */ +async function canTabMoveFocus() { + if (AppConstants.platform != "macosx") { + return true; + } + + let canMove = false; + await doDialogTest({ + callback: async () => { + const { win, maybeShowPromise } = await showOnboardingDialog({ + skipIntroduction: true, + }); + + let doc = win.document; + doc.getElementById("onboardingAccept").focus(); + EventUtils.synthesizeKey("KEY_Tab"); + + // Whether or not the focus can move to the link. + canMove = doc.activeElement.id === "onboardingLearnMore"; + + EventUtils.synthesizeKey("KEY_Escape"); + await maybeShowPromise; + }, + onboardingDialogVersion: JSON.stringify({ version: 1 }), + onboardingDialogChoice: "dismiss_2", + expectedUserBranchPrefs: { + "quicksuggest.dataCollection.enabled": false, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "disabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: "dismiss_2", + }, + ], + }); + + return canMove; +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_pocket.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_pocket.js new file mode 100644 index 0000000000..0064b6a297 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_pocket.js @@ -0,0 +1,435 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Browser tests for Pocket suggestions. +// +// TODO: Make this work with Rust enabled. Right now, running this test with +// Rust hits the following error on ingest, which prevents ingest from finishing +// successfully: +// +// 0:03.17 INFO Console message: [JavaScript Error: "1698289045697 urlbar ERROR QuickSuggest.SuggestBackendRust :: Ingest error: Error executing SQL: FOREIGN KEY constraint failed" {file: "resource://gre/modules/Log.sys.mjs" line: 722}] + +// The expected index of the Pocket suggestion. +const EXPECTED_RESULT_INDEX = 1; + +const REMOTE_SETTINGS_DATA = [ + { + type: "pocket-suggestions", + attachment: [ + { + url: "https://example.com/pocket-suggestion", + title: "Pocket Suggestion", + description: "Pocket description", + lowConfidenceKeywords: ["pocket suggestion"], + highConfidenceKeywords: ["high"], + score: 0.25, + }, + ], + }, +]; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Disable search suggestions so we don't hit the network. + ["browser.search.suggest.enabled", false], + ], + }); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: REMOTE_SETTINGS_DATA, + prefs: [ + ["suggest.quicksuggest.nonsponsored", true], + ["pocket.featureGate", true], + ], + }); +}); + +add_task(async function basic() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + // Do a search. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "pocket suggestion", + }); + + // Check the result. + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "There should be two results" + ); + + let { element, result } = await UrlbarTestUtils.getDetailsOfResultAt( + window, + 1 + ); + Assert.equal( + result.providerName, + UrlbarProviderQuickSuggest.name, + "The result should be from the expected provider" + ); + Assert.equal( + result.payload.telemetryType, + "pocket", + "The result should be a Pocket result" + ); + + // Click it. + const onLoad = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeMouseAtCenter(element.row, {}); + await onLoad; + // Append utm parameters. + let url = new URL(REMOTE_SETTINGS_DATA[0].attachment[0].url); + url.searchParams.set("utm_medium", "firefox-desktop"); + url.searchParams.set("utm_source", "firefox-suggest"); + url.searchParams.set( + "utm_campaign", + "pocket-collections-in-the-address-bar" + ); + url.searchParams.set("utm_content", "treatment"); + + Assert.equal(gBrowser.currentURI.spec, url.href, "Expected page loaded"); + }); +}); + +// Tests the "Show less frequently" command. +add_task(async function resultMenu_showLessFrequently() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.pocket.featureGate", true], + ["browser.urlbar.pocket.showLessFrequentlyCount", 0], + ], + }); + await QuickSuggestTestUtils.forceSync(); + + await QuickSuggestTestUtils.setConfig({ + show_less_frequently_cap: 3, + }); + + await doShowLessFrequently({ + input: "pocket s", + expected: { + isSuggestionShown: true, + isMenuItemShown: true, + }, + }); + Assert.equal(UrlbarPrefs.get("pocket.showLessFrequentlyCount"), 1); + + await doShowLessFrequently({ + input: "pocket s", + expected: { + isSuggestionShown: true, + isMenuItemShown: true, + }, + }); + Assert.equal(UrlbarPrefs.get("pocket.showLessFrequentlyCount"), 2); + + // The cap will be reached this time. Keep the view open so we can make sure + // the command has been removed from the menu before it closes. + await doShowLessFrequently({ + keepViewOpen: true, + input: "pocket s", + expected: { + isSuggestionShown: true, + isMenuItemShown: true, + }, + }); + Assert.equal(UrlbarPrefs.get("pocket.showLessFrequentlyCount"), 3); + + // Make sure the command has been removed. + let menuitem = await UrlbarTestUtils.openResultMenuAndGetItem({ + window, + command: "show_less_frequently", + resultIndex: EXPECTED_RESULT_INDEX, + openByMouse: true, + }); + Assert.ok(!menuitem, "Menuitem should be absent before closing the view"); + gURLBar.view.resultMenu.hidePopup(true); + await UrlbarTestUtils.promisePopupClose(window); + + await doShowLessFrequently({ + input: "pocket s", + expected: { + isSuggestionShown: false, + }, + }); + + await doShowLessFrequently({ + input: "pocket su", + expected: { + isSuggestionShown: true, + isMenuItemShown: false, + }, + }); + + await QuickSuggestTestUtils.setConfig(QuickSuggestTestUtils.DEFAULT_CONFIG); + await SpecialPowers.popPrefEnv(); + await QuickSuggestTestUtils.forceSync(); +}); + +async function doShowLessFrequently({ input, expected, keepViewOpen = false }) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: input, + }); + + if (!expected.isSuggestionShown) { + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + const details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.notEqual( + details.result.payload.telemetryType, + "pocket", + `Pocket suggestion should be absent (checking index ${i})` + ); + } + + return; + } + + const details = await UrlbarTestUtils.getDetailsOfResultAt( + window, + EXPECTED_RESULT_INDEX + ); + Assert.equal( + details.result.payload.telemetryType, + "pocket", + `Pocket suggestion should be present at expected index after ${input} search` + ); + + // Click the command. + try { + await UrlbarTestUtils.openResultMenuAndClickItem( + window, + "show_less_frequently", + { + resultIndex: EXPECTED_RESULT_INDEX, + } + ); + Assert.ok(expected.isMenuItemShown); + Assert.ok( + gURLBar.view.isOpen, + "The view should remain open clicking the command" + ); + Assert.ok( + details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should have feedback acknowledgment after clicking command" + ); + } catch (e) { + Assert.ok(!expected.isMenuItemShown); + Assert.ok( + !details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should not have feedback acknowledgment after clicking command" + ); + Assert.equal( + e.message, + "Menu item not found for command: show_less_frequently" + ); + } + + if (!keepViewOpen) { + await UrlbarTestUtils.promisePopupClose(window); + } +} + +// Tests the "Not interested" result menu dismissal command. +add_task(async function resultMenu_notInterested() { + await doDismissTest("not_interested"); + + // Re-enable suggestions and wait until PocketSuggestions syncs them from + // remote settings again. + UrlbarPrefs.set("suggest.pocket", true); + await QuickSuggestTestUtils.forceSync(); +}); + +// Tests the "Not relevant" result menu dismissal command. +add_task(async function notRelevant() { + await doDismissTest("not_relevant"); +}); + +async function doDismissTest(command) { + // Do a search. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "pocket suggestion", + }); + + // Check the result. + let resultCount = 2; + Assert.equal( + UrlbarTestUtils.getResultCount(window), + resultCount, + "There should be two results" + ); + + let { result } = await UrlbarTestUtils.getDetailsOfResultAt( + window, + EXPECTED_RESULT_INDEX + ); + Assert.equal( + result.providerName, + UrlbarProviderQuickSuggest.name, + "The result should be from the expected provider" + ); + Assert.equal( + result.payload.telemetryType, + "pocket", + "The result should be a Pocket result" + ); + + // Click the command. + await UrlbarTestUtils.openResultMenuAndClickItem( + window, + ["[data-l10n-id=firefox-suggest-command-dont-show-this]", command], + { resultIndex: EXPECTED_RESULT_INDEX, openByMouse: true } + ); + + // The row should be a tip now. + Assert.ok(gURLBar.view.isOpen, "The view should remain open after dismissal"); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + resultCount, + "The result count should not haved changed after dismissal" + ); + let details = await UrlbarTestUtils.getDetailsOfResultAt( + window, + EXPECTED_RESULT_INDEX + ); + Assert.equal( + details.type, + UrlbarUtils.RESULT_TYPE.TIP, + "Row should be a tip after dismissal" + ); + Assert.equal( + details.result.payload.type, + "dismissalAcknowledgment", + "Tip type should be dismissalAcknowledgment" + ); + Assert.ok( + !details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should not have feedback acknowledgment after dismissal" + ); + + // Get the dismissal acknowledgment's "Got it" button and click it. + let gotItButton = UrlbarTestUtils.getButtonForResultIndex( + window, + 0, + EXPECTED_RESULT_INDEX + ); + Assert.ok(gotItButton, "Row should have a 'Got it' button"); + EventUtils.synthesizeMouseAtCenter(gotItButton, {}, window); + + // The view should remain open and the tip row should be gone. + Assert.ok( + gURLBar.view.isOpen, + "The view should remain open clicking the 'Got it' button" + ); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + resultCount - 1, + "The result count should be one less after clicking 'Got it' button" + ); + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.ok( + details.type != UrlbarUtils.RESULT_TYPE.TIP && + details.result.payload.telemetryType !== "pocket", + "Tip result and suggestion should not be present" + ); + } + + gURLBar.handleRevert(); + + // Do the search again. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "pocket suggestion", + }); + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.ok( + details.type != UrlbarUtils.RESULT_TYPE.TIP && + details.result.payload.telemetryType !== "pocket", + "Tip result and suggestion should not be present" + ); + } + + await UrlbarTestUtils.promisePopupClose(window); + await QuickSuggest.blockedSuggestions.clear(); +} + +// Tests row labels. +add_task(async function rowLabel() { + const testCases = [ + // high confidence keyword best match + { + searchString: "high", + expected: "Recommended reads", + }, + // low confidence keyword non-best match + { + searchString: "pocket suggestion", + expected: "Firefox Suggest", + }, + ]; + + for (const { searchString, expected } of testCases) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + }); + Assert.equal(UrlbarTestUtils.getResultCount(window), 2); + + const { element } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + const row = element.row; + Assert.equal(row.getAttribute("label"), expected); + } +}); + +// Tests visibility of "Show less frequently" menu. +add_task(async function showLessFrequentlyMenuVisibility() { + const testCases = [ + // high confidence keyword best match + { + searchString: "high", + expected: false, + }, + // low confidence keyword non-best match + { + searchString: "pocket suggestion", + expected: true, + }, + ]; + + for (const { searchString, expected } of testCases) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + }); + Assert.equal(UrlbarTestUtils.getResultCount(window), 2); + + const details = await UrlbarTestUtils.getDetailsOfResultAt( + window, + EXPECTED_RESULT_INDEX + ); + Assert.equal( + details.result.payload.telemetryType, + "pocket", + "Pocket suggestion should be present at expected index" + ); + + const menuitem = await UrlbarTestUtils.openResultMenuAndGetItem({ + resultIndex: EXPECTED_RESULT_INDEX, + openByMouse: true, + command: "show_less_frequently", + window, + }); + Assert.equal(!!menuitem, expected); + + gURLBar.view.resultMenu.hidePopup(true); + } + + await UrlbarTestUtils.promisePopupClose(window); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_yelp.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_yelp.js new file mode 100644 index 0000000000..b7c2bdc25c --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_yelp.js @@ -0,0 +1,429 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for Yelp suggestions. + +const REMOTE_SETTINGS_RECORDS = [ + { + type: "yelp-suggestions", + attachment: { + subjects: ["ramen"], + preModifiers: ["best"], + postModifiers: ["delivery"], + locationSigns: [{ keyword: "in", needLocation: true }], + yelpModifiers: [], + icon: "1234", + score: 0.5, + }, + }, +]; + +add_setup(async function () { + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: REMOTE_SETTINGS_RECORDS, + prefs: [ + ["quicksuggest.rustEnabled", true], + ["suggest.quicksuggest.sponsored", true], + ["suggest.yelp", true], + ["yelp.featureGate", true], + ], + }); +}); + +add_task(async function basic() { + for (let topPick of [true, false]) { + info("Setting yelpPriority: " + topPick); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.quicksuggest.yelpPriority", topPick]], + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "RaMeN iN tOkYo", + }); + + Assert.equal(UrlbarTestUtils.getResultCount(window), 2); + + const details = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + const { result } = details; + Assert.equal( + result.providerName, + UrlbarProviderQuickSuggest.name, + "The result should be from the expected provider" + ); + Assert.equal(result.payload.provider, "Yelp"); + Assert.equal( + result.payload.url, + "https://www.yelp.com/search?find_desc=RaMeN&find_loc=tOkYo&utm_medium=partner&utm_source=mozilla" + ); + Assert.equal(result.payload.title, "RaMeN iN tOkYo"); + + const { row } = details.element; + const bottom = row.querySelector(".urlbarView-row-body-bottom"); + Assert.ok(bottom, "Bottom text element should exist"); + Assert.ok( + BrowserTestUtils.isVisible(bottom), + "Bottom text element should be visible" + ); + Assert.equal( + bottom.textContent, + "Yelp · Sponsored", + "Bottom text is correct" + ); + + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); + } +}); + +// Tests the "Show less frequently" result menu command. +add_task(async function resultMenu_show_less_frequently() { + info("Test for no yelpMinKeywordLength and no yelpShowLessFrequentlyCap"); + await doShowLessFrequently({ + minKeywordLength: 0, + frequentlyCap: 0, + testData: [ + { + input: "best ra", + expected: { + hasSuggestion: true, + hasShowLessItem: true, + }, + }, + { + input: "best ra", + expected: { + hasSuggestion: false, + }, + }, + { + input: "best ram", + expected: { + hasSuggestion: true, + hasShowLessItem: true, + }, + }, + { + input: "best ram", + expected: { + hasSuggestion: false, + }, + }, + { + input: "best rame", + expected: { + hasSuggestion: true, + hasShowLessItem: true, + }, + }, + { + input: "best rame", + expected: { + hasSuggestion: false, + }, + }, + ], + }); + + info("Test whether yelpShowLessFrequentlyCap can work"); + await doShowLessFrequently({ + minKeywordLength: 0, + frequentlyCap: 2, + testData: [ + { + input: "best ra", + expected: { + hasSuggestion: true, + hasShowLessItem: true, + }, + }, + { + input: "best ram", + expected: { + hasSuggestion: true, + hasShowLessItem: true, + }, + }, + { + input: "best ram", + expected: { + hasSuggestion: false, + }, + }, + { + input: "best rame", + expected: { + hasSuggestion: true, + hasShowLessItem: false, + }, + }, + { + input: "best ramen", + expected: { + hasSuggestion: true, + hasShowLessItem: false, + }, + }, + ], + }); + + info( + "Test whether local yelp.minKeywordLength pref can override nimbus variable yelpMinKeywordLength" + ); + await doShowLessFrequently({ + minKeywordLength: 8, + frequentlyCap: 0, + testData: [ + { + input: "best ra", + expected: { + hasSuggestion: false, + }, + }, + { + input: "best ram", + expected: { + hasSuggestion: true, + hasShowLessItem: true, + }, + }, + { + input: "best rame", + expected: { + hasSuggestion: true, + hasShowLessItem: true, + }, + }, + { + input: "best rame", + expected: { + hasSuggestion: false, + }, + }, + ], + }); +}); + +async function doShowLessFrequently({ + minKeywordLength, + frequentlyCap, + testData, +}) { + UrlbarPrefs.clear("yelp.showLessFrequentlyCount"); + UrlbarPrefs.clear("yelp.minKeywordLength"); + + let cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature({ + yelpMinKeywordLength: minKeywordLength, + yelpShowLessFrequentlyCap: frequentlyCap, + }); + + for (let { input, expected } of testData) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: input, + }); + + if (expected.hasSuggestion) { + let resultIndex = 1; + let details = await UrlbarTestUtils.getDetailsOfResultAt( + window, + resultIndex + ); + Assert.equal(details.result.payload.provider, "Yelp"); + + if (expected.hasShowLessItem) { + // Click the command. + let previousShowLessFrequentlyCount = UrlbarPrefs.get( + "yelp.showLessFrequentlyCount" + ); + await UrlbarTestUtils.openResultMenuAndClickItem( + window, + "show_less_frequently", + { resultIndex, openByMouse: true } + ); + + Assert.equal( + UrlbarPrefs.get("yelp.showLessFrequentlyCount"), + previousShowLessFrequentlyCount + 1 + ); + Assert.equal( + UrlbarPrefs.get("yelp.minKeywordLength"), + input.length + 1 + ); + } else { + let menuitem = await UrlbarTestUtils.openResultMenuAndGetItem({ + window, + command: "show_less_frequently", + resultIndex: 1, + openByMouse: true, + }); + Assert.ok(!menuitem); + } + } else { + // Yelp suggestion should not be shown. + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.notEqual(details.result.payload.provider, "Yelp"); + } + } + + await UrlbarTestUtils.promisePopupClose(window); + } + + await cleanUpNimbus(); + UrlbarPrefs.clear("yelp.showLessFrequentlyCount"); + UrlbarPrefs.clear("yelp.minKeywordLength"); +} + +// Tests the "Not relevant" result menu dismissal command. +add_task(async function resultMenu_not_relevant() { + await doDismiss({ + menu: "not_relevant", + assert: resuilt => { + Assert.ok( + QuickSuggest.blockedSuggestions.has(resuilt.payload.url), + "The URL should be register as blocked" + ); + }, + }); + + await QuickSuggest.blockedSuggestions.clear(); +}); + +// Tests the "Not interested" result menu dismissal command. +add_task(async function resultMenu_not_interested() { + await doDismiss({ + menu: "not_interested", + assert: () => { + Assert.ok(!UrlbarPrefs.get("suggest.yelp")); + }, + }); + + UrlbarPrefs.clear("suggest.yelp"); +}); + +async function doDismiss({ menu, assert }) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ramen", + }); + + let resultCount = UrlbarTestUtils.getResultCount(window); + let resultIndex = 1; + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + Assert.equal(details.result.payload.provider, "Yelp"); + let result = details.result; + + // Click the command. + await UrlbarTestUtils.openResultMenuAndClickItem( + window, + ["[data-l10n-id=firefox-suggest-command-dont-show-this]", menu], + { + resultIndex, + openByMouse: true, + } + ); + + // The row should be a tip now. + Assert.ok(gURLBar.view.isOpen, "The view should remain open after dismissal"); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + resultCount, + "The result count should not haved changed after dismissal" + ); + details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + Assert.equal( + details.type, + UrlbarUtils.RESULT_TYPE.TIP, + "Row should be a tip after dismissal" + ); + Assert.equal( + details.result.payload.type, + "dismissalAcknowledgment", + "Tip type should be dismissalAcknowledgment" + ); + Assert.ok( + !details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should not have feedback acknowledgment after dismissal" + ); + + // Get the dismissal acknowledgment's "Got it" button and click it. + let gotItButton = UrlbarTestUtils.getButtonForResultIndex( + window, + "0", + resultIndex + ); + Assert.ok(gotItButton, "Row should have a 'Got it' button"); + EventUtils.synthesizeMouseAtCenter(gotItButton, {}, window); + + // The view should remain open and the tip row should be gone. + Assert.ok( + gURLBar.view.isOpen, + "The view should remain open clicking the 'Got it' button" + ); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + resultCount - 1, + "The result count should be one less after clicking 'Got it' button" + ); + + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.ok( + details.type != UrlbarUtils.RESULT_TYPE.TIP && + details.result.payload.provider !== "Yelp", + "Tip result and Yelp result should not be present" + ); + } + + assert(result); + + await UrlbarTestUtils.promisePopupClose(window); + + // Check that the result should not be shown anymore. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ramen", + }); + + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.ok( + details.result.payload.provider !== "Yelp", + "Yelp result should not be present" + ); + } + + await UrlbarTestUtils.promisePopupClose(window); +} + +// Tests the row/group label. +add_task(async function rowLabel() { + let tests = [ + { topPick: true, label: "Local recommendations" }, + { topPick: false, label: "Firefox Suggest" }, + ]; + + for (let { topPick, label } of tests) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.yelp.priority", topPick]], + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ramen", + }); + Assert.equal(UrlbarTestUtils.getResultCount(window), 2); + + const { element } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + const row = element.row; + Assert.equal(row.getAttribute("label"), label); + + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); + } +}); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_dynamicWikipedia.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_dynamicWikipedia.js new file mode 100644 index 0000000000..001c54458c --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_dynamicWikipedia.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests primary telemetry for dynamic Wikipedia suggestions. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + CONTEXTUAL_SERVICES_PING_TYPES: + "resource:///modules/PartnerLinkAttribution.sys.mjs", +}); + +const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest; + +const MERINO_SUGGESTION = { + block_id: 1, + 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", +}; + +const suggestion_type = "dynamic-wikipedia"; +const match_type = "firefox-suggest"; +const index = 1; +const position = index + 1; + +add_setup(async function () { + await setUpTelemetryTest({ + merinoSuggestions: [MERINO_SUGGESTION], + }); +}); + +add_task(async function () { + await doTelemetryTest({ + index, + suggestion: MERINO_SUGGESTION, + // impression-only + impressionOnly: { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_DYNAMIC_WIKIPEDIA]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "impression_only", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + }, + // click + click: { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_DYNAMIC_WIKIPEDIA]: position, + [TELEMETRY_SCALARS.CLICK_DYNAMIC_WIKIPEDIA]: position, + "urlbar.picked.dynamic_wikipedia": index.toString(), + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "click", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + }, + commands: [ + // dismiss + { + command: "dismiss", + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_DYNAMIC_WIKIPEDIA]: position, + [TELEMETRY_SCALARS.BLOCK_DYNAMIC_WIKIPEDIA]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "block", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + }, + // 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_gleanEmptyStrings.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_gleanEmptyStrings.js new file mode 100644 index 0000000000..00cbe6c4e1 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_gleanEmptyStrings.js @@ -0,0 +1,221 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests that Glean handles empty request IDs properly. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + CONTEXTUAL_SERVICES_PING_TYPES: + "resource:///modules/PartnerLinkAttribution.sys.mjs", +}); + +const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest; + +const MERINO_RESULT = { + block_id: 1, + url: "https://example.com/sponsored", + title: "Sponsored suggestion", + keywords: ["sponsored"], + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "testadvertiser", + iab_category: "22 - Shopping", + provider: "adm", + is_sponsored: true, +}; + +const suggestion_type = "sponsored"; +const index = 1; +const position = index + 1; + +// Trying to avoid timeouts in TV mode. +requestLongerTimeout(3); + +add_setup(async function () { + await setUpTelemetryTest({ + merinoSuggestions: [MERINO_RESULT], + }); + MerinoTestUtils.server.response.body.request_id = ""; +}); + +// sponsored +add_task(async function sponsored() { + let match_type = "firefox-suggest"; + let source = "merino"; + + let improve_suggest_experience_checked = true; + + await doTelemetryTest({ + index, + suggestion: MERINO_RESULT, + // impression-only + impressionOnly: { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "impression_only", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + ping: { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + source, + match_type, + position, + suggested_index: -1, + suggested_index_relative_to_group: true, + improve_suggest_experience_checked, + is_clicked: false, + block_id: MERINO_RESULT.block_id, + advertiser: MERINO_RESULT.advertiser, + request_id: "", + }, + }, + }, + // click + click: { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position, + [TELEMETRY_SCALARS.CLICK_SPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "click", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + source, + match_type, + position, + suggested_index: -1, + suggested_index_relative_to_group: true, + improve_suggest_experience_checked, + is_clicked: true, + block_id: MERINO_RESULT.block_id, + advertiser: MERINO_RESULT.advertiser, + request_id: "", + }, + }, + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION, + payload: { + source, + match_type, + position, + suggested_index: -1, + suggested_index_relative_to_group: true, + improve_suggest_experience_checked, + block_id: MERINO_RESULT.block_id, + advertiser: MERINO_RESULT.advertiser, + request_id: "", + }, + }, + ], + }, + commands: [ + // dismiss + { + command: "dismiss", + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position, + [TELEMETRY_SCALARS.BLOCK_SPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "block", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + source, + match_type, + position, + suggested_index: -1, + suggested_index_relative_to_group: true, + improve_suggest_experience_checked, + is_clicked: false, + block_id: MERINO_RESULT.block_id, + advertiser: MERINO_RESULT.advertiser, + request_id: "", + }, + }, + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK, + payload: { + source, + match_type, + position, + suggested_index: -1, + suggested_index_relative_to_group: true, + improve_suggest_experience_checked, + block_id: MERINO_RESULT.block_id, + advertiser: MERINO_RESULT.advertiser, + iab_category: MERINO_RESULT.iab_category, + request_id: "", + }, + }, + ], + }, + // help + { + command: "help", + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position, + [TELEMETRY_SCALARS.HELP_SPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "help", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + source, + match_type, + position, + suggested_index: -1, + suggested_index_relative_to_group: true, + improve_suggest_experience_checked, + is_clicked: false, + block_id: MERINO_RESULT.block_id, + advertiser: MERINO_RESULT.advertiser, + request_id: "", + }, + }, + ], + }, + ], + }); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_impressionEdgeCases.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_impressionEdgeCases.js new file mode 100644 index 0000000000..8682f1f53a --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_impressionEdgeCases.js @@ -0,0 +1,482 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests abandonment and edge cases related to impressions. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + CONTEXTUAL_SERVICES_PING_TYPES: + "resource:///modules/PartnerLinkAttribution.sys.mjs", + UrlbarView: "resource:///modules/UrlbarView.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest; + +const REMOTE_SETTINGS_RESULTS = [ + { + id: 1, + url: "https://example.com/sponsored", + title: "Sponsored suggestion", + keywords: ["sponsored"], + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "testadvertiser", + }, + { + id: 2, + url: "https://example.com/nonsponsored", + title: "Non-sponsored suggestion", + keywords: ["nonsponsored"], + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "testadvertiser", + iab_category: "5 - Education", + }, +]; + +const SPONSORED_RESULT = REMOTE_SETTINGS_RESULTS[0]; + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await UrlbarTestUtils.formHistory.clear(); + + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + // Add a mock engine so we don't hit the network. + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + }); +}); + +// Makes sure impression telemetry is not recorded when the urlbar engagement is +// abandoned. +add_task(async function abandonment() { + Services.telemetry.clearEvents(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "sponsored", + fireInputEvent: true, + }); + await QuickSuggestTestUtils.assertIsQuickSuggest({ + window, + url: SPONSORED_RESULT.url, + }); + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); + QuickSuggestTestUtils.assertScalars({}); + QuickSuggestTestUtils.assertEvents([]); +}); + +// Makes sure impression telemetry is not recorded when a quick suggest result +// is not present. +add_task(async function noQuickSuggestResult() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + Services.telemetry.clearEvents(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "noImpression_noQuickSuggestResult", + fireInputEvent: true, + }); + await QuickSuggestTestUtils.assertNoQuickSuggestResults(window); + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Enter"); + }); + QuickSuggestTestUtils.assertScalars({}); + QuickSuggestTestUtils.assertEvents([]); + }); + await PlacesUtils.history.clear(); +}); + +// When a quick suggest result is added to the view but hidden during the view +// update, impression telemetry should not be recorded for it. +add_task(async function hiddenRow() { + Services.telemetry.clearEvents(); + + // Increase the timeout of the remove-stale-rows timer so that it doesn't + // interfere with this task. + let originalRemoveStaleRowsTimeout = UrlbarView.removeStaleRowsTimeout; + UrlbarView.removeStaleRowsTimeout = 30000; + registerCleanupFunction(() => { + UrlbarView.removeStaleRowsTimeout = originalRemoveStaleRowsTimeout; + }); + + // Set up a test provider that doesn't add any results until we resolve its + // `finishQueryPromise`. For the first search below, it will add many search + // suggestions. + let maxCount = UrlbarPrefs.get("maxRichResults"); + let results = []; + for (let i = 0; i < maxCount; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + engine: "Example", + suggestion: "suggestion " + i, + lowerCaseSuggestion: "suggestion " + i, + query: "test", + } + ) + ); + } + let provider = new DelayingTestProvider({ results }); + UrlbarProvidersManager.registerProvider(provider); + + // Open a new tab since we'll load a page below. + let tab = await BrowserTestUtils.openNewForegroundTab({ gBrowser }); + + // Do a normal search and allow the test provider to finish. + provider.finishQueryPromise = Promise.resolve(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + fireInputEvent: true, + }); + + // Sanity check the rows. After the heuristic, the remaining rows should be + // the search results added by the test provider. + Assert.equal( + UrlbarTestUtils.getResultCount(window), + maxCount, + "Row count after first search" + ); + for (let i = 1; i < maxCount; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Expected result type at index " + i + ); + Assert.equal( + result.source, + UrlbarUtils.RESULT_SOURCE.SEARCH, + "Expected result source at index " + i + ); + } + + // Now set up a second search that triggers a quick suggest result. Add a + // mutation listener to the view so we can tell when the quick suggest row is + // added. + let mutationPromise = new Promise(resolve => { + let observer = new MutationObserver(mutations => { + let rows = UrlbarTestUtils.getResultsContainer(window).children; + for (let row of rows) { + if (row.result.providerName == "UrlbarProviderQuickSuggest") { + observer.disconnect(); + resolve(row); + return; + } + } + }); + observer.observe(UrlbarTestUtils.getResultsContainer(window), { + childList: true, + }); + }); + + // Set the test provider's `finishQueryPromise` to a promise that doesn't + // resolve. That will prevent the search from completing, which will prevent + // the view from removing stale rows and showing the quick suggest row. + let resolveQuery; + provider.finishQueryPromise = new Promise( + resolve => (resolveQuery = resolve) + ); + + // Start the second search but don't wait for it to finish. + gURLBar.focus(); + let queryPromise = UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: REMOTE_SETTINGS_RESULTS[0].keywords[0], + fireInputEvent: true, + }); + + // Wait for the quick suggest row to be added to the view. It should be hidden + // because (a) quick suggest results have a `suggestedIndex`, and rows with + // suggested indexes can't replace rows without suggested indexes, and (b) the + // view already contains the maximum number of rows due to the first search. + // It should remain hidden until the search completes or the remove-stale-rows + // timer fires. Next, we'll hit enter, which will cancel the search and close + // the view, so the row should never appear. + let quickSuggestRow = await mutationPromise; + Assert.ok( + BrowserTestUtils.isHidden(quickSuggestRow), + "Quick suggest row is hidden" + ); + + // Hit enter to pick the heuristic search result. This will cancel the search + // and notify the quick suggest provider that an engagement occurred. + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Enter"); + }); + await loadPromise; + + // Resolve the test provider's promise finally. + resolveQuery(); + await queryPromise; + + // The quick suggest provider added a result but it wasn't visible in the + // view. No impression telemetry should be recorded for it. + QuickSuggestTestUtils.assertScalars({}); + QuickSuggestTestUtils.assertEvents([]); + + BrowserTestUtils.removeTab(tab); + UrlbarProvidersManager.unregisterProvider(provider); + UrlbarView.removeStaleRowsTimeout = originalRemoveStaleRowsTimeout; +}); + +// When a quick suggest result has not been added to the view, impression +// telemetry should not be recorded for it even if it's the result most recently +// returned by the provider. +add_task(async function notAddedToView() { + Services.telemetry.clearEvents(); + + // Open a new tab since we'll load a page. + await BrowserTestUtils.withNewTab("about:blank", async () => { + // Do an initial search that doesn't match any suggestions to make sure + // there aren't any quick suggest results in the view to start. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "this doesn't match anything", + fireInputEvent: true, + }); + await QuickSuggestTestUtils.assertNoQuickSuggestResults(window); + await UrlbarTestUtils.promisePopupClose(window); + + // Now do a search for a suggestion and hit enter after the provider adds it + // but before it appears in the view. + await doEngagementWithoutAddingResultToView( + REMOTE_SETTINGS_RESULTS[0].keywords[0] + ); + + // The quick suggest provider added a result but it wasn't visible in the + // view, and no other quick suggest results were visible in the view. No + // impression telemetry should be recorded. + QuickSuggestTestUtils.assertScalars({}); + QuickSuggestTestUtils.assertEvents([]); + }); +}); + +// When a quick suggest result is visible in the view, impression telemetry +// should be recorded for it even if it's not the result most recently returned +// by the provider. +add_task(async function previousResultStillVisible() { + Services.telemetry.clearEvents(); + + // Open a new tab since we'll load a page. + await BrowserTestUtils.withNewTab("about:blank", async () => { + // Do a search for the first suggestion. + let firstSuggestion = REMOTE_SETTINGS_RESULTS[0]; + let index = 1; + + let pingSubmitted = false; + GleanPings.quickSuggest.testBeforeNextSubmit(() => { + pingSubmitted = true; + Assert.equal( + Glean.quickSuggest.pingType.testGetValue(), + CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION + ); + Assert.equal( + Glean.quickSuggest.improveSuggestExperience.testGetValue(), + false + ); + Assert.equal( + Glean.quickSuggest.blockId.testGetValue(), + firstSuggestion.id + ); + Assert.equal(Glean.quickSuggest.isClicked.testGetValue(), false); + Assert.equal( + Glean.quickSuggest.matchType.testGetValue(), + "firefox-suggest" + ); + Assert.equal(Glean.quickSuggest.position.testGetValue(), index + 1); + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: firstSuggestion.keywords[0], + fireInputEvent: true, + }); + + await QuickSuggestTestUtils.assertIsQuickSuggest({ + window, + index, + url: firstSuggestion.url, + }); + + // Without closing the view, do a second search for the second suggestion + // and hit enter after the provider adds it but before it appears in the + // view. + await doEngagementWithoutAddingResultToView( + REMOTE_SETTINGS_RESULTS[1].keywords[0], + index + ); + + // An impression for the first suggestion should be recorded since it's + // still visible in the view, not the second suggestion. + QuickSuggestTestUtils.assertScalars({ + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: index + 1, + }); + QuickSuggestTestUtils.assertEvents([ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "impression_only", + extra: { + match_type: "firefox-suggest", + position: String(index + 1), + suggestion_type: "sponsored", + }, + }, + ]); + Assert.ok(pingSubmitted, "Glean ping was submitted"); + }); +}); + +/** + * Does a search that causes the quick suggest provider to return a result + * without adding it to the view and then hits enter to load a SERP and create + * an engagement. + * + * @param {string} searchString + * The search string. + * @param {number} previousResultIndex + * If the view is already open and showing a quick suggest result, pass its + * index here. Otherwise pass -1. + */ +async function doEngagementWithoutAddingResultToView( + searchString, + previousResultIndex = -1 +) { + // Set the timeout of the chunk timer to a really high value so that it will + // not fire. The view updates when the timer fires, which we specifically want + // to avoid here. + let originalChunkTimeout = UrlbarProvidersManager.CHUNK_RESULTS_DELAY_MS; + UrlbarProvidersManager.CHUNK_RESULTS_DELAY_MS = 30000; + const cleanup = () => { + UrlbarProvidersManager.CHUNK_RESULTS_DELAY_MS = originalChunkTimeout; + }; + registerCleanupFunction(cleanup); + + // Stub `UrlbarProviderQuickSuggest.getPriority()` to return Infinity. + let sandbox = sinon.createSandbox(); + let getPriorityStub = sandbox.stub(UrlbarProviderQuickSuggest, "getPriority"); + getPriorityStub.returns(Infinity); + + // Spy on `UrlbarProviderQuickSuggest.onEngagement()`. + let onEngagementSpy = sandbox.spy(UrlbarProviderQuickSuggest, "onEngagement"); + + let sandboxCleanup = () => { + getPriorityStub?.restore(); + getPriorityStub = null; + sandbox?.restore(); + sandbox = null; + }; + registerCleanupFunction(sandboxCleanup); + + // In addition to setting the chunk timeout to a large value above, in order + // to prevent the view from updating there also needs to be a heuristic + // provider that takes a long time to add results. Set one up that doesn't add + // any results until we resolve its `finishQueryPromise`. Set its priority to + // Infinity too so that only it and the quick suggest provider will be active. + let provider = new DelayingTestProvider({ + results: [], + priority: Infinity, + type: UrlbarUtils.PROVIDER_TYPE.HEURISTIC, + }); + UrlbarProvidersManager.registerProvider(provider); + + let resolveQuery; + provider.finishQueryPromise = new Promise(r => (resolveQuery = r)); + + // Add a query listener so we can grab the query context. + let context; + let queryListener = { + onQueryStarted: c => (context = c), + }; + gURLBar.controller.addQueryListener(queryListener); + + // Do a search but don't wait for it to finish. + gURLBar.focus(); + UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + + // Wait for the quick suggest provider to add its result to `context.unsortedResults`. + let result = await TestUtils.waitForCondition(() => { + let query = UrlbarProvidersManager.queries.get(context); + return query?.unsortedResults.find( + r => r.providerName == "UrlbarProviderQuickSuggest" + ); + }, "Waiting for quick suggest result to be added to context.unsortedResults"); + + gURLBar.controller.removeQueryListener(queryListener); + + // The view should not have updated, so the result's `rowIndex` should still + // have its initial value of -1. + Assert.equal(result.rowIndex, -1, "result.rowIndex is still -1"); + + // If there's a result from the previous query, assert it's still in the + // view. Otherwise assume that the view should be closed. These are mostly + // sanity checks because they should only fail if the telemetry assertions + // below also fail. + if (previousResultIndex >= 0) { + let rows = gURLBar.view.panel.querySelector(".urlbarView-results"); + Assert.equal( + rows.children[previousResultIndex].result.providerName, + "UrlbarProviderQuickSuggest", + "Result already in view is a quick suggest" + ); + } else { + Assert.ok(!gURLBar.view.isOpen, "View is closed"); + } + + // Hit enter to load a SERP for the search string. This should notify the + // quick suggest provider that an engagement occurred. + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Enter"); + }); + await loadPromise; + + let engagementCalls = onEngagementSpy.getCalls().filter(call => { + let state = call.args[0]; + return state == "engagement"; + }); + Assert.equal(engagementCalls.length, 1, "One engagement occurred"); + + // Clean up. + resolveQuery(); + UrlbarProvidersManager.unregisterProvider(provider); + cleanup(); + sandboxCleanup(); +} + +/** + * A test provider that doesn't finish `startQuery()` until `finishQueryPromise` + * is resolved. + */ +class DelayingTestProvider extends UrlbarTestUtils.TestProvider { + finishQueryPromise = null; + async startQuery(context, addCallback) { + for (let result of this.results) { + addCallback(this, result); + } + await this.finishQueryPromise; + } +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_navigationalSuggestions.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_navigationalSuggestions.js new file mode 100644 index 0000000000..4762095795 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_navigationalSuggestions.js @@ -0,0 +1,346 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests primary telemetry for navigational suggestions, a.k.a. + * navigational top picks. + */ + +"use strict"; + +const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest; + +const MERINO_SUGGESTION = { + title: "Navigational suggestion", + url: "https://example.com/navigational-suggestion", + provider: "top_picks", + is_sponsored: false, + score: 0.25, + block_id: 0, + is_top_pick: true, +}; + +const suggestion_type = "navigational"; +const index = 1; +const position = index + 1; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Disable tab-to-search since like best match it's also shown with + // `suggestedIndex` = 1. + ["browser.urlbar.suggest.engines", false], + ], + }); + + await setUpTelemetryTest({ + merinoSuggestions: [MERINO_SUGGESTION], + }); +}); + +// Clicks the heuristic when a nav suggestion is not matched +add_task(async function notMatched_clickHeuristic() { + await doTest({ + suggestion: null, + shouldBeShown: false, + pickRowIndex: 0, + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NAV_NOTMATCHED]: "search_engine", + [TELEMETRY_SCALARS.CLICK_NAV_NOTMATCHED]: "search_engine", + }, + events: [], + }); +}); + +// Clicks a non-heuristic row when a nav suggestion is not matched +add_task(async function notMatched_clickOther() { + await PlacesTestUtils.addVisits("http://mochi.test:8888/example"); + await doTest({ + suggestion: null, + shouldBeShown: false, + pickRowIndex: 1, + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NAV_NOTMATCHED]: "search_engine", + }, + events: [], + }); +}); + +// Clicks the heuristic when a nav suggestion is shown +add_task(async function shown_clickHeuristic() { + await doTest({ + suggestion: MERINO_SUGGESTION, + shouldBeShown: true, + pickRowIndex: 0, + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NAV_SHOWN]: "search_engine", + [TELEMETRY_SCALARS.CLICK_NAV_SHOWN_HEURISTIC]: "search_engine", + }, + events: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "impression_only", + extra: { + suggestion_type, + match_type: "best-match", + position: position.toString(), + source: "merino", + }, + }, + ], + }); +}); + +// Clicks the nav suggestion +add_task(async function shown_clickNavSuggestion() { + await doTest({ + suggestion: MERINO_SUGGESTION, + shouldBeShown: true, + pickRowIndex: index, + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NAV_SHOWN]: "search_engine", + [TELEMETRY_SCALARS.CLICK_NAV_SHOWN_NAV]: "search_engine", + "urlbar.picked.navigational": "1", + }, + events: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "click", + extra: { + suggestion_type, + match_type: "best-match", + position: position.toString(), + source: "merino", + }, + }, + ], + }); +}); + +// Clicks a non-heuristic non-nav-suggestion row when the nav suggestion is +// shown +add_task(async function shown_clickOther() { + await PlacesTestUtils.addVisits("http://mochi.test:8888/example"); + await doTest({ + suggestion: MERINO_SUGGESTION, + shouldBeShown: true, + pickRowIndex: 2, + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NAV_SHOWN]: "search_engine", + }, + events: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "impression_only", + extra: { + suggestion_type, + match_type: "best-match", + position: position.toString(), + source: "merino", + }, + }, + ], + }); +}); + +// Clicks the heuristic when it dupes the nav suggestion +add_task(async function duped_clickHeuristic() { + // Add enough visits to example.com so it autofills. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits("https://example.com/"); + } + + // Set the nav suggestion's URL to the same URL, example.com. + let suggestion = { + ...MERINO_SUGGESTION, + url: "https://example.com/", + }; + + await doTest({ + suggestion, + shouldBeShown: false, + pickRowIndex: 0, + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NAV_SUPERCEDED]: "autofill_origin", + [TELEMETRY_SCALARS.CLICK_NAV_SUPERCEDED]: "autofill_origin", + }, + events: [], + }); +}); + +// Clicks a non-heuristic row when the heuristic dupes the nav suggestion +add_task(async function duped_clickOther() { + // Add enough visits to example.com so it autofills. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits("https://example.com/"); + } + + // Set the nav suggestion's URL to the same URL, example.com. + let suggestion = { + ...MERINO_SUGGESTION, + url: "https://example.com/", + }; + + // Add a visit to another URL so it appears in the search below. + await PlacesTestUtils.addVisits("https://example.com/some-other-url"); + + await doTest({ + suggestion, + shouldBeShown: false, + pickRowIndex: 1, + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NAV_SUPERCEDED]: "autofill_origin", + }, + events: [], + }); +}); + +// Telemetry specific to nav suggestions should not be recorded when the +// `recordNavigationalSuggestionTelemetry` Nimbus variable is false. +add_task(async function recordNavigationalSuggestionTelemetry_false() { + await doTest({ + valueOverrides: { + recordNavigationalSuggestionTelemetry: false, + }, + suggestion: MERINO_SUGGESTION, + shouldBeShown: true, + pickRowIndex: index, + scalars: {}, + events: [ + // The legacy engagement event should still be recorded as it is for all + // quick suggest results. + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "click", + extra: { + suggestion_type, + match_type: "best-match", + position: position.toString(), + source: "merino", + }, + }, + ], + }); +}); + +// Telemetry specific to nav suggestions should not be recorded when the +// `recordNavigationalSuggestionTelemetry` Nimbus variable is left out. +add_task(async function recordNavigationalSuggestionTelemetry_undefined() { + await doTest({ + valueOverrides: {}, + suggestion: MERINO_SUGGESTION, + shouldBeShown: true, + pickRowIndex: index, + scalars: {}, + events: [ + // The legacy engagement event should still be recorded as it is for all + // quick suggest results. + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "click", + extra: { + suggestion_type, + match_type: "best-match", + position: position.toString(), + source: "merino", + }, + }, + ], + }); +}); + +/** + * Does the following: + * + * 1. Sets up a Merino nav suggestion + * 2. Enrolls in a Nimbus experiment with the specified variables + * 3. Does a search + * 4. Makes sure the nav suggestion is or isn't shown as expected + * 5. Clicks a specified row + * 6. Makes sure the expected telemetry is recorded + * + * @param {object} options + * Options object + * @param {object} options.suggestion + * The nav suggestion or null if Merino shouldn't serve one. + * @param {boolean} options.shouldBeShown + * Whether the nav suggestion is expected to be shown. + * @param {number} options.pickRowIndex + * The index of the row to pick. + * @param {object} options.scalars + * An object that specifies the nav suggest keyed scalars that are expected to + * be recorded. + * @param {Array} options.events + * An object that specifies the legacy engagement events that are expected to + * be recorded. + * @param {object} options.valueOverrides + * The Nimbus variables to use. + */ +async function doTest({ + suggestion, + shouldBeShown, + pickRowIndex, + scalars, + events, + valueOverrides = { + recordNavigationalSuggestionTelemetry: true, + }, +}) { + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + MerinoTestUtils.server.response.body.suggestions = suggestion + ? [suggestion] + : []; + + Services.telemetry.clearEvents(); + + await QuickSuggestTestUtils.withExperiment({ + valueOverrides, + callback: async () => { + await BrowserTestUtils.withNewTab("about:blank", async () => { + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "example", + fireInputEvent: true, + }); + + if (shouldBeShown) { + await QuickSuggestTestUtils.assertIsQuickSuggest({ + window, + index, + url: suggestion.url, + isBestMatch: true, + isSponsored: false, + }); + } else { + await QuickSuggestTestUtils.assertNoQuickSuggestResults(window); + } + + let loadPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + if (pickRowIndex > 0) { + info("Arrowing down to row index " + pickRowIndex); + EventUtils.synthesizeKey("KEY_ArrowDown", { repeat: pickRowIndex }); + } + info("Pressing Enter and waiting for page load"); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + }); + }, + }); + + info("Checking scalars"); + QuickSuggestTestUtils.assertScalars(scalars); + + info("Checking events"); + QuickSuggestTestUtils.assertEvents(events); + + await PlacesUtils.history.clear(); + MerinoTestUtils.server.response.body.suggestions = [MERINO_SUGGESTION]; +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_nonsponsored.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_nonsponsored.js new file mode 100644 index 0000000000..9a1aa06c02 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_nonsponsored.js @@ -0,0 +1,236 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests primary telemetry for nonsponsored suggestions. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + CONTEXTUAL_SERVICES_PING_TYPES: + "resource:///modules/PartnerLinkAttribution.sys.mjs", +}); + +const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest; + +const REMOTE_SETTINGS_RESULT = { + id: 1, + url: "https://example.com/nonsponsored", + title: "Non-sponsored suggestion", + keywords: ["nonsponsored"], + advertiser: "Wikipedia", + iab_category: "5 - Education", + icon: "1234", +}; + +const suggestion_type = "nonsponsored"; +const index = 1; +const position = index + 1; + +// Trying to avoid timeouts in TV mode. +requestLongerTimeout(3); + +add_setup(async function () { + await setUpTelemetryTest({ + remoteSettingsRecords: [ + { + type: "data", + attachment: [REMOTE_SETTINGS_RESULT], + }, + ], + }); +}); + +add_tasks_with_rust(async function nonsponsored() { + let match_type = "firefox-suggest"; + let advertiser = REMOTE_SETTINGS_RESULT.advertiser.toLowerCase(); + let reporting_url = undefined; + let source = UrlbarPrefs.get("quicksuggest.rustEnabled") + ? "rust" + : "remote-settings"; + let block_id = source == "rust" ? undefined : REMOTE_SETTINGS_RESULT.id; + + // Make sure `improve_suggest_experience_checked` is recorded correctly + // depending on the value of the related pref. + for (let improve_suggest_experience_checked of [false, true]) { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.urlbar.quicksuggest.dataCollection.enabled", + improve_suggest_experience_checked, + ], + ], + }); + await doTelemetryTest({ + index, + suggestion: REMOTE_SETTINGS_RESULT, + // impression-only + impressionOnly: { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "impression_only", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + ping: { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + source, + match_type, + position, + block_id, + advertiser, + reporting_url, + suggested_index: -1, + suggested_index_relative_to_group: true, + improve_suggest_experience_checked, + is_clicked: false, + }, + }, + }, + // click + click: { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED]: position, + [TELEMETRY_SCALARS.CLICK_NONSPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "click", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + source, + match_type, + position, + block_id, + advertiser, + reporting_url, + suggested_index: -1, + suggested_index_relative_to_group: true, + improve_suggest_experience_checked, + is_clicked: true, + }, + }, + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION, + payload: { + source, + match_type, + position, + block_id, + advertiser, + reporting_url, + suggested_index: -1, + suggested_index_relative_to_group: true, + improve_suggest_experience_checked, + }, + }, + ], + }, + commands: [ + // dismiss + { + command: "dismiss", + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED]: position, + [TELEMETRY_SCALARS.BLOCK_NONSPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "block", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + source, + match_type, + position, + block_id, + advertiser, + reporting_url, + suggested_index: -1, + suggested_index_relative_to_group: true, + improve_suggest_experience_checked, + is_clicked: false, + }, + }, + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK, + payload: { + source, + match_type, + position, + block_id, + advertiser, + suggested_index: -1, + suggested_index_relative_to_group: true, + improve_suggest_experience_checked, + iab_category: REMOTE_SETTINGS_RESULT.iab_category, + }, + }, + ], + }, + // help + { + command: "help", + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED]: position, + [TELEMETRY_SCALARS.HELP_NONSPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "help", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + source, + match_type, + position, + block_id, + advertiser, + reporting_url, + suggested_index: -1, + suggested_index_relative_to_group: true, + improve_suggest_experience_checked, + is_clicked: false, + }, + }, + ], + }, + ], + }); + await SpecialPowers.popPrefEnv(); + } +}); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_other.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_other.js new file mode 100644 index 0000000000..d40c70107e --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_other.js @@ -0,0 +1,298 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests ancillary quick suggest telemetry, i.e., telemetry that's not + * strongly related to showing suggestions in the urlbar. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", +}); + +const REMOTE_SETTINGS_RESULTS = [ + { + id: 1, + url: "https://example.com/sponsored", + title: "Sponsored suggestion", + keywords: ["sponsored"], + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "testadvertiser", + }, +]; + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await UrlbarTestUtils.formHistory.clear(); + + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + // Add a mock engine so we don't hit the network. + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + }); +}); + +// Tests telemetry recorded when toggling the +// `suggest.quicksuggest.nonsponsored` pref: +// * contextservices.quicksuggest enable_toggled event telemetry +// * TelemetryEnvironment +add_task(async function enableToggled() { + Services.telemetry.clearEvents(); + + // Toggle the suggest.quicksuggest.nonsponsored pref twice. We should get two + // events. + let enabled = UrlbarPrefs.get("suggest.quicksuggest.nonsponsored"); + for (let i = 0; i < 2; i++) { + enabled = !enabled; + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", enabled); + QuickSuggestTestUtils.assertEvents([ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "enable_toggled", + object: enabled ? "enabled" : "disabled", + }, + ]); + Assert.equal( + TelemetryEnvironment.currentEnvironment.settings.userPrefs[ + "browser.urlbar.suggest.quicksuggest.nonsponsored" + ], + enabled, + "suggest.quicksuggest.nonsponsored is correct in TelemetryEnvironment" + ); + } + + // Set the main quicksuggest.enabled pref to false and toggle the + // suggest.quicksuggest.nonsponsored pref again. We shouldn't get any events. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.quicksuggest.enabled", false]], + }); + enabled = !enabled; + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", enabled); + QuickSuggestTestUtils.assertEvents([]); + await SpecialPowers.popPrefEnv(); + + // Set the pref back to what it was at the start of the task. + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", !enabled); +}); + +// Tests telemetry recorded when toggling the `suggest.quicksuggest.sponsored` +// pref: +// * contextservices.quicksuggest enable_toggled event telemetry +// * TelemetryEnvironment +add_task(async function sponsoredToggled() { + Services.telemetry.clearEvents(); + + // Toggle the suggest.quicksuggest.sponsored pref twice. We should get two + // events. + let enabled = UrlbarPrefs.get("suggest.quicksuggest.sponsored"); + for (let i = 0; i < 2; i++) { + enabled = !enabled; + UrlbarPrefs.set("suggest.quicksuggest.sponsored", enabled); + QuickSuggestTestUtils.assertEvents([ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "sponsored_toggled", + object: enabled ? "enabled" : "disabled", + }, + ]); + Assert.equal( + TelemetryEnvironment.currentEnvironment.settings.userPrefs[ + "browser.urlbar.suggest.quicksuggest.sponsored" + ], + enabled, + "suggest.quicksuggest.sponsored is correct in TelemetryEnvironment" + ); + } + + // Set the main quicksuggest.enabled pref to false and toggle the + // suggest.quicksuggest.sponsored pref again. We shouldn't get any events. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.quicksuggest.enabled", false]], + }); + enabled = !enabled; + UrlbarPrefs.set("suggest.quicksuggest.sponsored", enabled); + QuickSuggestTestUtils.assertEvents([]); + await SpecialPowers.popPrefEnv(); + + // Set the pref back to what it was at the start of the task. + UrlbarPrefs.set("suggest.quicksuggest.sponsored", !enabled); +}); + +// Tests telemetry recorded when toggling the +// `quicksuggest.dataCollection.enabled` pref: +// * contextservices.quicksuggest data_collect_toggled event telemetry +// * TelemetryEnvironment +add_task(async function dataCollectionToggled() { + Services.telemetry.clearEvents(); + + // Toggle the quicksuggest.dataCollection.enabled pref twice. We should get + // two events. + let enabled = UrlbarPrefs.get("quicksuggest.dataCollection.enabled"); + for (let i = 0; i < 2; i++) { + enabled = !enabled; + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", enabled); + QuickSuggestTestUtils.assertEvents([ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: enabled ? "enabled" : "disabled", + }, + ]); + Assert.equal( + TelemetryEnvironment.currentEnvironment.settings.userPrefs[ + "browser.urlbar.quicksuggest.dataCollection.enabled" + ], + enabled, + "quicksuggest.dataCollection.enabled is correct in TelemetryEnvironment" + ); + } + + // Set the main quicksuggest.enabled pref to false and toggle the data + // collection pref again. We shouldn't get any events. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.quicksuggest.enabled", false]], + }); + enabled = !enabled; + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", enabled); + QuickSuggestTestUtils.assertEvents([]); + await SpecialPowers.popPrefEnv(); + + // Set the pref back to what it was at the start of the task. + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", !enabled); +}); + +// Simulates the race on startup between telemetry environment initialization +// and the initial update of the Suggest scenario. After startup is done, +// telemetry environment should record the correct values for startup prefs. +add_task(async function telemetryEnvironmentOnStartup() { + await QuickSuggestTestUtils.setScenario(null); + + // Restart telemetry environment so we know it's watching its default set of + // prefs. + await TelemetryEnvironment.testCleanRestart().onInitialized(); + + // Get the prefs that UrlbarPrefs sets when the Suggest scenario is updated on + // startup. They're the union of the prefs exposed in the UI and the prefs + // that are set on the default branch per scenario. + let prefs = [ + ...new Set([ + ...Object.values(UrlbarPrefs.FIREFOX_SUGGEST_UI_PREFS_BY_VARIABLE), + ...Object.values(UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS) + .map(valuesByPrefName => Object.keys(valuesByPrefName)) + .flat(), + ]), + ]; + + // Not all of the prefs are recorded in telemetry environment. Filter in the + // ones that are. + prefs = prefs.filter( + p => + `browser.urlbar.${p}` in + TelemetryEnvironment.currentEnvironment.settings.userPrefs + ); + + info("Got startup prefs: " + JSON.stringify(prefs)); + + // Sanity check the expected prefs. This isn't strictly necessary since we + // programmatically get the prefs above, but it's an extra layer of defense, + // for example in case we accidentally filtered out some expected prefs above. + // If this fails, you might have added a startup pref but didn't update this + // array here. + Assert.deepEqual( + prefs.sort(), + [ + "quicksuggest.dataCollection.enabled", + "suggest.quicksuggest.nonsponsored", + "suggest.quicksuggest.sponsored", + ], + "Expected startup prefs" + ); + + // Make sure the prefs don't have user values that would mask the default + // values. + for (let p of prefs) { + UrlbarPrefs.clear(p); + } + + // Build a map of default values. + let defaultValues = Object.fromEntries( + prefs.map(p => [p, UrlbarPrefs.get(p)]) + ); + + // Now simulate startup. Restart telemetry environment but don't wait for it + // to finish before calling `updateFirefoxSuggestScenario()`. This simulates + // startup where telemetry environment's initialization races the intial + // update of the Suggest scenario. + let environmentInitPromise = + TelemetryEnvironment.testCleanRestart().onInitialized(); + + // Update the scenario and force the startup prefs to take on values that are + // the inverse of what they are now. + await UrlbarPrefs.updateFirefoxSuggestScenario({ + isStartup: true, + scenario: "online", + defaultPrefs: { + online: Object.fromEntries( + Object.entries(defaultValues).map(([p, value]) => [p, !value]) + ), + }, + }); + + // At this point telemetry environment should be done initializing since + // `updateFirefoxSuggestScenario()` waits for it, but await our promise now. + await environmentInitPromise; + + // TelemetryEnvironment should have cached the new values. + for (let [p, value] of Object.entries(defaultValues)) { + let expected = !value; + Assert.strictEqual( + TelemetryEnvironment.currentEnvironment.settings.userPrefs[ + `browser.urlbar.${p}` + ], + expected, + `Check 1: ${p} is ${expected} in TelemetryEnvironment` + ); + } + + // Simulate another startup and set all prefs back to their original default + // values. + environmentInitPromise = + TelemetryEnvironment.testCleanRestart().onInitialized(); + + await UrlbarPrefs.updateFirefoxSuggestScenario({ + isStartup: true, + scenario: "online", + defaultPrefs: { + online: defaultValues, + }, + }); + + await environmentInitPromise; + + // TelemetryEnvironment should have cached the new (original) values. + for (let [p, value] of Object.entries(defaultValues)) { + let expected = value; + Assert.strictEqual( + TelemetryEnvironment.currentEnvironment.settings.userPrefs[ + `browser.urlbar.${p}` + ], + expected, + `Check 2: ${p} is ${expected} in TelemetryEnvironment` + ); + } + + await TelemetryEnvironment.testCleanRestart().onInitialized(); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_sponsored.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_sponsored.js new file mode 100644 index 0000000000..7c477e8af7 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_sponsored.js @@ -0,0 +1,408 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests primary telemetry for sponsored suggestions. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + CONTEXTUAL_SERVICES_PING_TYPES: + "resource:///modules/PartnerLinkAttribution.sys.mjs", +}); + +const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest; + +const REMOTE_SETTINGS_RESULT = { + id: 1, + url: "https://example.com/sponsored", + title: "Sponsored suggestion", + keywords: ["sponsored"], + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "testadvertiser", + iab_category: "22 - Shopping", + icon: "1234", +}; + +const suggestion_type = "sponsored"; +const index = 1; +const position = index + 1; + +// Trying to avoid timeouts in TV mode. +requestLongerTimeout(3); + +add_setup(async function () { + await setUpTelemetryTest({ + remoteSettingsRecords: [ + { + type: "data", + attachment: [REMOTE_SETTINGS_RESULT], + }, + ], + }); +}); + +// sponsored +add_tasks_with_rust(async function sponsored() { + let match_type = "firefox-suggest"; + let source = UrlbarPrefs.get("quicksuggest.rustEnabled") + ? "rust" + : "remote-settings"; + + // Make sure `improve_suggest_experience_checked` is recorded correctly + // depending on the value of the related pref. + for (let improve_suggest_experience_checked of [false, true]) { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.urlbar.quicksuggest.dataCollection.enabled", + improve_suggest_experience_checked, + ], + ], + }); + await doTelemetryTest({ + index, + suggestion: REMOTE_SETTINGS_RESULT, + // impression-only + impressionOnly: { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "impression_only", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + ping: { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + source, + match_type, + position, + suggested_index: -1, + suggested_index_relative_to_group: true, + improve_suggest_experience_checked, + is_clicked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + }, + // click + click: { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position, + [TELEMETRY_SCALARS.CLICK_SPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "click", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + source, + match_type, + position, + suggested_index: -1, + suggested_index_relative_to_group: true, + improve_suggest_experience_checked, + is_clicked: true, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION, + payload: { + source, + match_type, + position, + suggested_index: -1, + suggested_index_relative_to_group: true, + improve_suggest_experience_checked, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + ], + }, + commands: [ + // dismiss + { + command: "dismiss", + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position, + [TELEMETRY_SCALARS.BLOCK_SPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "block", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + source, + match_type, + position, + suggested_index: -1, + suggested_index_relative_to_group: true, + improve_suggest_experience_checked, + is_clicked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK, + payload: { + source, + match_type, + position, + suggested_index: -1, + suggested_index_relative_to_group: true, + improve_suggest_experience_checked, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + iab_category: REMOTE_SETTINGS_RESULT.iab_category, + }, + }, + ], + }, + // help + { + command: "help", + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position, + [TELEMETRY_SCALARS.HELP_SPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "help", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + source, + match_type, + position, + suggested_index: -1, + suggested_index_relative_to_group: true, + improve_suggest_experience_checked, + is_clicked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + ], + }, + ], + }); + await SpecialPowers.popPrefEnv(); + } +}); + +// higher-placement sponsored, a.k.a sponsored priority, sponsored best match +add_tasks_with_rust(async function sponsoredBestMatch() { + let match_type = "best-match"; + let source = UrlbarPrefs.get("quicksuggest.rustEnabled") + ? "rust" + : "remote-settings"; + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.quicksuggest.sponsoredPriority", true]], + }); + await doTelemetryTest({ + index, + suggestion: REMOTE_SETTINGS_RESULT, + // impression-only + impressionOnly: { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "impression_only", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + ping: { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + source, + match_type, + position, + suggested_index: 1, + suggested_index_relative_to_group: false, + is_clicked: false, + improve_suggest_experience_checked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + }, + // click + click: { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position, + [TELEMETRY_SCALARS.CLICK_SPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "click", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + source, + match_type, + position, + suggested_index: 1, + suggested_index_relative_to_group: false, + is_clicked: true, + improve_suggest_experience_checked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION, + payload: { + source, + match_type, + position, + suggested_index: 1, + suggested_index_relative_to_group: false, + improve_suggest_experience_checked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + ], + }, + commands: [ + // dismiss + { + command: "dismiss", + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position, + [TELEMETRY_SCALARS.BLOCK_SPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "block", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + source, + match_type, + position, + suggested_index: 1, + suggested_index_relative_to_group: false, + is_clicked: false, + improve_suggest_experience_checked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK, + payload: { + source, + match_type, + position, + suggested_index: 1, + suggested_index_relative_to_group: false, + improve_suggest_experience_checked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + iab_category: REMOTE_SETTINGS_RESULT.iab_category, + }, + }, + ], + }, + // help + { + command: "help", + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position, + [TELEMETRY_SCALARS.HELP_SPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "help", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + source, + match_type, + position, + suggested_index: 1, + suggested_index_relative_to_group: false, + is_clicked: false, + improve_suggest_experience_checked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + ], + }, + ], + }); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_weather.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_weather.js new file mode 100644 index 0000000000..e87c64740f --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_weather.js @@ -0,0 +1,158 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests primary telemetry for weather suggestions. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderWeather: "resource:///modules/UrlbarProviderWeather.sys.mjs", +}); + +const suggestion_type = "weather"; +const match_type = "firefox-suggest"; +const index = 1; +const position = index + 1; + +const { TELEMETRY_SCALARS: WEATHER_SCALARS } = UrlbarProviderWeather; +const { WEATHER_SUGGESTION: suggestion, WEATHER_RS_DATA } = MerinoTestUtils; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Make sure quick actions are disabled because showing them in the top + // sites view interferes with this test. + ["browser.urlbar.suggest.quickactions", false], + ], + }); + + await setUpTelemetryTest({ + remoteSettingsRecords: [ + { + type: "weather", + weather: WEATHER_RS_DATA, + }, + ], + }); + await MerinoTestUtils.initWeather(); + await updateTopSitesAndAwaitChanged(); +}); + +add_task(async function () { + await doTelemetryTest({ + index, + suggestion, + providerName: UrlbarProviderWeather.name, + showSuggestion: async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: MerinoTestUtils.WEATHER_KEYWORD, + }); + }, + teardown: async () => { + // Picking the block button sets this pref to false and disables weather + // suggestions. We need to flip it back to true and wait for the + // suggestion to be fetched again before continuing to the next selectable + // test. The view also also stay open, so close it afterward. + if (!UrlbarPrefs.get("suggest.weather")) { + await UrlbarTestUtils.promisePopupClose(window); + gURLBar.handleRevert(); + let fetchPromise = QuickSuggest.weather.waitForFetches(); + UrlbarPrefs.clear("suggest.weather"); + await fetchPromise; + + // Wait for keywords to be re-synced from remote settings. + await QuickSuggestTestUtils.forceSync(); + } + }, + // impression-only + impressionOnly: { + scalars: { + [WEATHER_SCALARS.IMPRESSION]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "impression_only", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + }, + // click + click: { + scalars: { + [WEATHER_SCALARS.IMPRESSION]: position, + [WEATHER_SCALARS.CLICK]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "click", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + }, + commands: [ + // not relevant + { + command: [ + "[data-l10n-id=firefox-suggest-command-dont-show-this]", + "not_relevant", + ], + scalars: { + [WEATHER_SCALARS.IMPRESSION]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "other", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + }, + // 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(), + }, + }, + }, + ], + }); +}); + +async function updateTopSitesAndAwaitChanged() { + let url = "http://mochi.test:8888/topsite"; + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits(url); + } + + info("Updating top sites and awaiting newtab-top-sites-changed"); + let changedPromise = TestUtils.topicObserved("newtab-top-sites-changed").then( + () => info("Observed newtab-top-sites-changed") + ); + await updateTopSites(sites => sites?.length); + await changedPromise; +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_weather.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_weather.js new file mode 100644 index 0000000000..1c3f0e62e7 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_weather.js @@ -0,0 +1,426 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Browser test for the weather suggestion. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderWeather: "resource:///modules/UrlbarProviderWeather.sys.mjs", +}); + +add_setup(async function () { + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ], + }); + await MerinoTestUtils.initWeather(); +}); + +// Basic checks of the row DOM. +add_tasks_with_rust(async function dom() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: MerinoTestUtils.WEATHER_KEYWORD, + }); + + let resultIndex = 1; + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + assertIsWeatherResult(details.result, true); + let { row } = details.element; + + Assert.ok( + BrowserTestUtils.isVisible( + row.querySelector(".urlbarView-title-separator") + ), + "The title separator should be visible" + ); + + await UrlbarTestUtils.promisePopupClose(window); +}); + +// This test ensures the browser navigates to the weather webpage after +// the weather result is selected. +add_tasks_with_rust(async function test_weather_result_selection() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: MerinoTestUtils.WEATHER_KEYWORD, + }); + + info(`Select the weather result`); + EventUtils.synthesizeKey("KEY_ArrowDown"); + + info(`Navigate to the weather url`); + EventUtils.synthesizeKey("KEY_Enter"); + + await browserLoadedPromise; + + Assert.equal( + gBrowser.currentURI.spec, + "https://example.com/weather", + "Assert the page navigated to the weather webpage after selecting the weather result." + ); + + BrowserTestUtils.removeTab(tab); + await PlacesUtils.history.clear(); +}); + +// Does a search, clicks the "Show less frequently" result menu command, and +// repeats both steps until the min keyword length cap is reached. +add_tasks_with_rust(async function showLessFrequentlyCapReached_manySearches() { + // Set up a min keyword length and cap. + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "weather", + weather: { + keywords: ["weather"], + min_keyword_length: 3, + }, + }, + { + type: "configuration", + configuration: { + show_less_frequently_cap: 1, + }, + }, + ]); + + // Trigger the suggestion. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "wea", + }); + + let resultIndex = 1; + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + info("Weather suggestion should be present after 'wea' search"); + assertIsWeatherResult(details.result, true); + + // Click the command. + let command = "show_less_frequently"; + await UrlbarTestUtils.openResultMenuAndClickItem(window, command, { + resultIndex, + }); + + Assert.ok( + gURLBar.view.isOpen, + "The view should remain open clicking the command" + ); + Assert.ok( + details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should have feedback acknowledgment after clicking command" + ); + Assert.equal( + UrlbarPrefs.get("weather.minKeywordLength"), + 4, + "weather.minKeywordLength should be incremented once" + ); + + // Do the same search again. The suggestion should not appear. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "wea", + }); + + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + info(`Weather suggestion should be absent (checking index ${i})`); + assertIsWeatherResult(details.result, false); + } + + // Do a search using one more character. The suggestion should appear. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "weat", + }); + + details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + info("Weather suggestion should be present after 'weat' search"); + assertIsWeatherResult(details.result, true); + Assert.ok( + !details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should not have feedback acknowledgment after 'weat' search" + ); + + // Since the cap has been reached, the command should no longer appear in the + // result menu. + await UrlbarTestUtils.openResultMenu(window, { resultIndex }); + let menuitem = gURLBar.view.resultMenu.querySelector( + `menuitem[data-command=${command}]` + ); + Assert.ok(!menuitem, "Menuitem should be absent"); + gURLBar.view.resultMenu.hidePopup(true); + + await UrlbarTestUtils.promisePopupClose(window); + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ]); + UrlbarPrefs.clear("weather.minKeywordLength"); +}); + +// Repeatedly clicks the "Show less frequently" result menu command after doing +// a single search until the min keyword length cap is reached. +add_tasks_with_rust(async function showLessFrequentlyCapReached_oneSearch() { + // Set up a min keyword length and cap. + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "weather", + weather: { + keywords: ["weather"], + min_keyword_length: 3, + }, + }, + { + type: "configuration", + configuration: { + show_less_frequently_cap: 3, + }, + }, + ]); + + // Trigger the suggestion. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "wea", + }); + + let resultIndex = 1; + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + info("Weather suggestion should be present after 'wea' search"); + assertIsWeatherResult(details.result, true); + + let command = "show_less_frequently"; + + for (let i = 0; i < 3; i++) { + await UrlbarTestUtils.openResultMenuAndClickItem(window, command, { + resultIndex, + openByMouse: true, + }); + + Assert.ok( + gURLBar.view.isOpen, + "The view should remain open clicking the command" + ); + Assert.ok( + details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should have feedback acknowledgment after clicking command" + ); + Assert.equal( + UrlbarPrefs.get("weather.minKeywordLength"), + 4 + i, + "weather.minKeywordLength should be incremented once" + ); + } + + let menuitem = await UrlbarTestUtils.openResultMenuAndGetItem({ + window, + command, + resultIndex, + }); + Assert.ok( + !menuitem, + "The menuitem should not exist after the cap is reached" + ); + + gURLBar.view.resultMenu.hidePopup(true); + await UrlbarTestUtils.promisePopupClose(window); + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ]); + UrlbarPrefs.clear("weather.minKeywordLength"); +}); + +// Tests the "Not interested" result menu dismissal command. +add_tasks_with_rust(async function notInterested() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: MerinoTestUtils.WEATHER_KEYWORD, + }); + await doDismissTest("not_interested"); +}); + +// Tests the "Not relevant" result menu dismissal command. +add_tasks_with_rust(async function notRelevant() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: MerinoTestUtils.WEATHER_KEYWORD, + }); + await doDismissTest("not_relevant"); +}); + +async function doDismissTest(command) { + let resultCount = UrlbarTestUtils.getResultCount(window); + + let resultIndex = 1; + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + assertIsWeatherResult(details.result, true); + + // Click the command. + await UrlbarTestUtils.openResultMenuAndClickItem( + window, + ["[data-l10n-id=firefox-suggest-command-dont-show-this]", command], + { resultIndex, openByMouse: true } + ); + + Assert.ok( + !UrlbarPrefs.get("suggest.weather"), + "suggest.weather pref should be set to false after dismissal" + ); + + // The row should be a tip now. + Assert.ok(gURLBar.view.isOpen, "The view should remain open after dismissal"); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + resultCount, + "The result count should not haved changed after dismissal" + ); + details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + Assert.equal( + details.type, + UrlbarUtils.RESULT_TYPE.TIP, + "Row should be a tip after dismissal" + ); + Assert.equal( + details.result.payload.type, + "dismissalAcknowledgment", + "Tip type should be dismissalAcknowledgment" + ); + Assert.ok( + !details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should not have feedback acknowledgment after dismissal" + ); + + // Get the dismissal acknowledgment's "Got it" button and click it. + let gotItButton = UrlbarTestUtils.getButtonForResultIndex( + window, + "0", + resultIndex + ); + Assert.ok(gotItButton, "Row should have a 'Got it' button"); + EventUtils.synthesizeMouseAtCenter(gotItButton, {}, window); + + // The view should remain open and the tip row should be gone. + Assert.ok( + gURLBar.view.isOpen, + "The view should remain open clicking the 'Got it' button" + ); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + resultCount - 1, + "The result count should be one less after clicking 'Got it' button" + ); + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.notEqual( + details.type, + UrlbarUtils.RESULT_TYPE.TIP, + "Tip result should not be present" + ); + info("Weather result should not be present"); + assertIsWeatherResult(details.result, false); + } + + await UrlbarTestUtils.promisePopupClose(window); + + // Enable the weather suggestion again and wait for it to be fetched. + let fetchPromise = QuickSuggest.weather.waitForFetches(); + UrlbarPrefs.clear("suggest.weather"); + info("Waiting for weather fetch after re-enabling the suggestion"); + await fetchPromise; + info("Got weather fetch"); + + // Wait for keywords to be re-synced from remote settings. + await QuickSuggestTestUtils.forceSync(); +} + +// Tests the "Report inaccurate location" result menu command immediately +// followed by a dismissal command to make sure other commands still work +// properly while the urlbar session remains ongoing. +add_tasks_with_rust(async function inaccurateLocationAndDismissal() { + await doSessionOngoingCommandTest("inaccurate_location"); +}); + +// Tests the "Show less frequently" result menu command immediately followed by +// a dismissal command to make sure other commands still work properly while the +// urlbar session remains ongoing. +add_tasks_with_rust(async function showLessFrequentlyAndDismissal() { + await doSessionOngoingCommandTest("show_less_frequently"); + UrlbarPrefs.clear("weather.minKeywordLength"); +}); + +async function doSessionOngoingCommandTest(command) { + // Trigger the suggestion. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: MerinoTestUtils.WEATHER_KEYWORD, + }); + + let resultIndex = 1; + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + info("Weather suggestion should be present after search"); + assertIsWeatherResult(details.result, true); + + // Click the command. + await UrlbarTestUtils.openResultMenuAndClickItem(window, command, { + resultIndex, + }); + + Assert.ok( + gURLBar.view.isOpen, + "The view should remain open clicking the command" + ); + Assert.ok( + details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should have feedback acknowledgment after clicking command" + ); + + info("Doing dismissal"); + await doDismissTest("not_interested"); +} + +function assertIsWeatherResult(result, isWeatherResult) { + let provider = UrlbarPrefs.get("quickSuggestRustEnabled") + ? UrlbarProviderQuickSuggest + : UrlbarProviderWeather; + if (isWeatherResult) { + Assert.equal( + result.providerName, + provider.name, + "Result should be from a weather provider" + ); + Assert.equal( + UrlbarUtils.searchEngagementTelemetryType(result), + "weather", + "Result telemetry type should be 'weather'" + ); + } else { + Assert.notEqual( + result.providerName, + provider.name, + "Result should not be from a weather provider" + ); + Assert.notEqual( + UrlbarUtils.searchEngagementTelemetryType(result), + "weather", + "Result telemetry type should not be 'weather'" + ); + } +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/head.js b/browser/components/urlbar/tests/quicksuggest/browser/head.js new file mode 100644 index 0000000000..7d62a44d45 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/head.js @@ -0,0 +1,693 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let sandbox; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/browser/head-common.js", + this +); + +ChromeUtils.defineESModuleGetters(this, { + CONTEXTUAL_SERVICES_PING_TYPES: + "resource:///modules/PartnerLinkAttribution.jsm", + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + UrlbarProviderQuickSuggest: + "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(this, "QuickSuggestTestUtils", () => { + const { QuickSuggestTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/QuickSuggestTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +ChromeUtils.defineLazyGetter(this, "MerinoTestUtils", () => { + const { MerinoTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/MerinoTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +ChromeUtils.defineLazyGetter(this, "PlacesFrecencyRecalculator", () => { + return Cc["@mozilla.org/places/frecency-recalculator;1"].getService( + Ci.nsIObserver + ).wrappedJSObject; +}); + +registerCleanupFunction(async () => { + // Ensure the popup is always closed at the end of each test to avoid + // interfering with the next test. + await UrlbarTestUtils.promisePopupClose(window); +}); + +/** + * Updates the Top Sites feed. + * + * @param {Function} condition + * A callback that returns true after Top Sites are successfully updated. + * @param {boolean} searchShortcuts + * True if Top Sites search shortcuts should be enabled. + */ +async function updateTopSites(condition, searchShortcuts = false) { + // Toggle the pref to clear the feed cache and force an update. + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.discoverystream.endpointSpocsClear", + "", + ], + ["browser.newtabpage.activity-stream.feeds.system.topsites", false], + ["browser.newtabpage.activity-stream.feeds.system.topsites", true], + [ + "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts", + searchShortcuts, + ], + ], + }); + + // Wait for the feed to be updated. + await TestUtils.waitForCondition(() => { + let sites = AboutNewTab.getTopSites(); + return condition(sites); + }, "Waiting for top sites to be updated"); +} + +/** + * Call this in your setup task if you use `doTelemetryTest()`. + * + * @param {object} options + * Options + * @param {Array} options.remoteSettingsRecords + * See `QuickSuggestTestUtils.ensureQuickSuggestInit()`. + * @param {Array} options.merinoSuggestions + * See `QuickSuggestTestUtils.ensureQuickSuggestInit()`. + * @param {Array} options.config + * See `QuickSuggestTestUtils.ensureQuickSuggestInit()`. + */ +async function setUpTelemetryTest({ + remoteSettingsRecords, + merinoSuggestions = null, + config = QuickSuggestTestUtils.DEFAULT_CONFIG, +}) { + await SpecialPowers.pushPrefEnv({ + set: [ + // Switch-to-tab results can sometimes appear after the test clicks a help + // button and closes the new tab, which interferes with the expected + // indexes of quick suggest results, so disable them. + ["browser.urlbar.suggest.openpage", false], + // Disable the persisted-search-terms search tip because it can interfere. + ["browser.urlbar.tipShownCount.searchTip_persist", 999], + ], + }); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await UrlbarTestUtils.formHistory.clear(); + + // Add a mock engine so we don't hit the network. + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords, + merinoSuggestions, + config, + }); +} + +/** + * Main entry point for testing primary telemetry for quick suggest suggestions: + * impressions, clicks, helps, and blocks. This can be used to declaratively + * test all primary telemetry for any suggestion type. + * + * @param {object} options + * Options + * @param {number} options.index + * The expected index of the suggestion in the results list. + * @param {object} options.suggestion + * The suggestion being tested. + * @param {object} options.impressionOnly + * An object describing the expected impression-only telemetry, i.e., + * telemetry recorded when an impression occurs but not a click. It must have + * the following properties: + * {object} scalars + * An object that maps expected scalar names to values. + * {object} event + * The expected recorded event. + * {object} ping + * The expected recorded custom telemetry ping. If no ping is expected, + * leave this undefined or pass null. + * @param {object} options.click + * An object describing the expected click telemetry. It must have the same + * properties as `impressionOnly` except `ping` must be `pings` (plural), an + * array of expected pings. + * @param {Array} options.commands + * Each element in this array is an object that describes the expected + * telemetry for a result menu command. Each object must have the following + * properties: + * {string|Array} command + * A command name or array; this is passed directly to + * `UrlbarTestUtils.openResultMenuAndClickItem()` as the `commandOrArray` + * arg, so see its documentation for details. + * {object} scalars + * An object that maps expected scalar names to values. + * {object} event + * The expected recorded event. + * {Array} pings + * A list of expected recorded custom telemetry pings. If no pings are + * expected, pass an empty array. + * @param {string} options.providerName + * The name of the provider that is expected to create the UrlbarResult for + * the suggestion. + * @param {Function} options.teardown + * If given, this function will be called after each selectable test. If + * picking an element causes side effects that need to be cleaned up before + * starting the next selectable test, they can be cleaned up here. + * @param {Function} options.showSuggestion + * This function should open the view and show the suggestion. + */ +async function doTelemetryTest({ + index, + suggestion, + impressionOnly, + click, + commands, + providerName = UrlbarProviderQuickSuggest.name, + teardown = null, + showSuggestion = () => + UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + // If the suggestion object is a remote settings result, it will have a + // `keywords` property. Otherwise the suggestion object must be a Merino + // suggestion, and the search string doesn't matter in that case because + // the mock Merino server will be set up to return suggestions regardless. + value: suggestion.keywords?.[0] || "test", + fireInputEvent: true, + }), +}) { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + await doImpressionOnlyTest({ + index, + suggestion, + providerName, + showSuggestion, + expected: impressionOnly, + }); + + await doClickTest({ + suggestion, + providerName, + showSuggestion, + index, + expected: click, + }); + + for (let command of commands) { + await doCommandTest({ + suggestion, + providerName, + showSuggestion, + index, + commandOrArray: command.command, + expected: command, + }); + + if (teardown) { + info("Calling teardown"); + await teardown(); + info("Finished teardown"); + } + } +} + +/** + * Helper for `doTelemetryTest()` that does an impression-only test. + * + * @param {object} options + * Options + * @param {number} options.index + * The expected index of the suggestion in the results list. + * @param {object} options.suggestion + * The suggestion being tested. + * @param {string} options.providerName + * The name of the provider that is expected to create the UrlbarResult for + * the suggestion. + * @param {object} options.expected + * An object describing the expected impression-only telemetry. It must have + * the following properties: + * {object} scalars + * An object that maps expected scalar names to values. + * {object} event + * The expected recorded event. + * {object} ping + * The expected recorded custom telemetry ping. If no ping is expected, + * leave this undefined or pass null. + * @param {Function} options.showSuggestion + * This function should open the view and show the suggestion. + */ +async function doImpressionOnlyTest({ + index, + suggestion, + providerName, + expected, + showSuggestion, +}) { + info("Starting impression-only test"); + + Services.telemetry.clearEvents(); + + let expectedPings = expected.ping ? [expected.ping] : []; + let gleanPingCount = watchGleanPings(expectedPings); + + info("Showing suggestion"); + await showSuggestion(); + + // Get the suggestion row. + let row = await validateSuggestionRow(index, suggestion, providerName); + if (!row) { + Assert.ok( + false, + "Couldn't get suggestion row, stopping impression-only test" + ); + return; + } + + // We need to get a different selectable row so we can pick it to trigger + // impression-only telemetry. For simplicity we'll look for a row that will + // load a URL when picked. We'll also verify no other rows are from the + // expected provider. + let otherRow; + let rowCount = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < rowCount; i++) { + if (i != index) { + let r = await UrlbarTestUtils.waitForAutocompleteResultAt(window, i); + Assert.notEqual( + r.result.providerName, + providerName, + "No other row should be from expected provider: index = " + i + ); + if ( + !otherRow && + (r.result.payload.url || + (r.result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + (r.result.payload.query || r.result.payload.suggestion))) && + r.hasAttribute("row-selectable") + ) { + otherRow = r; + } + } + } + if (!otherRow) { + Assert.ok( + false, + "Couldn't get a different selectable row with a URL, stopping impression-only test" + ); + return; + } + + // Pick the different row. Assumptions: + // * The middle of the row is selectable + // * Picking the row will load a page + info("Clicking different row and waiting for view to close"); + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeMouseAtCenter(otherRow, {}) + ); + + info("Waiting for page to load after clicking different row"); + await loadPromise; + + // Check telemetry. + info("Checking scalars. Expected: " + JSON.stringify(expected.scalars)); + QuickSuggestTestUtils.assertScalars(expected.scalars); + + info("Checking events. Expected: " + JSON.stringify([expected.event])); + QuickSuggestTestUtils.assertEvents([expected.event]); + + Assert.equal( + expectedPings.length, + gleanPingCount.value, + "Submitted one Glean ping per expected ping" + ); + + // Clean up. + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + + info("Finished impression-only test"); +} + +/** + * Helper for `doTelemetryTest()` that clicks a suggestion's row and checks + * telemetry. + * + * @param {object} options + * Options + * @param {number} options.index + * The expected index of the suggestion in the results list. + * @param {object} options.suggestion + * The suggestion being tested. + * @param {string} options.providerName + * The name of the provider that is expected to create the UrlbarResult for + * the suggestion. + * @param {object} options.expected + * An object describing the telemetry that's expected to be recorded when the + * selectable element is picked. It must have the following properties: + * {object} scalars + * An object that maps expected scalar names to values. + * {object} event + * The expected recorded event. + * {Array} pings + * A list of expected recorded custom telemetry pings. If no pings are + * expected, leave this undefined or pass an empty array. + * @param {Function} options.showSuggestion + * This function should open the view and show the suggestion. + */ +async function doClickTest({ + index, + suggestion, + providerName, + expected, + showSuggestion, +}) { + info("Starting click test"); + + Services.telemetry.clearEvents(); + + let expectedPings = expected.pings ?? []; + let gleanPingCount = watchGleanPings(expectedPings); + + info("Showing suggestion"); + await showSuggestion(); + + let row = await validateSuggestionRow(index, suggestion, providerName); + if (!row) { + Assert.ok(false, "Couldn't get suggestion row, stopping click test"); + return; + } + + // We assume clicking the row will load a page in the current browser. + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + info("Clicking row"); + EventUtils.synthesizeMouseAtCenter(row, {}); + + info("Waiting for load"); + await loadPromise; + await TestUtils.waitForTick(); + + info("Checking scalars. Expected: " + JSON.stringify(expected.scalars)); + QuickSuggestTestUtils.assertScalars(expected.scalars); + + info("Checking events. Expected: " + JSON.stringify([expected.event])); + QuickSuggestTestUtils.assertEvents([expected.event]); + + Assert.equal( + expectedPings.length, + gleanPingCount.value, + "Submitted one Glean ping per expected ping" + ); + + await PlacesUtils.history.clear(); + + info("Finished click test"); +} + +/** + * Helper for `doTelemetryTest()` that clicks a result menu command for a + * suggestion and checks telemetry. + * + * @param {object} options + * Options + * @param {number} options.index + * The expected index of the suggestion in the results list. + * @param {object} options.suggestion + * The suggestion being tested. + * @param {string} options.providerName + * The name of the provider that is expected to create the UrlbarResult for + * the suggestion. + * @param {string|Array} options.commandOrArray + * A command name or array; this is passed directly to + * `UrlbarTestUtils.openResultMenuAndClickItem()` as the `commandOrArray` arg, + * so see its documentation for details. + * @param {object} options.expected + * An object describing the telemetry that's expected to be recorded when the + * selectable element is picked. It must have the following properties: + * {object} scalars + * An object that maps expected scalar names to values. + * {object} event + * The expected recorded event. + * {Array} pings + * A list of expected recorded custom telemetry pings. If no pings are + * expected, leave this undefined or pass an empty array. + * @param {Function} options.showSuggestion + * This function should open the view and show the suggestion. + */ +async function doCommandTest({ + index, + suggestion, + providerName, + commandOrArray, + expected, + showSuggestion, +}) { + info("Starting command test: " + JSON.stringify({ commandOrArray })); + + Services.telemetry.clearEvents(); + + let expectedPings = expected.pings ?? []; + let gleanPingCount = watchGleanPings(expectedPings); + + info("Showing suggestion"); + await showSuggestion(); + + let row = await validateSuggestionRow(index, suggestion, providerName); + if (!row) { + Assert.ok(false, "Couldn't get suggestion row, stopping click test"); + return; + } + + let command = + typeof commandOrArray == "string" + ? commandOrArray + : commandOrArray[commandOrArray.length - 1]; + + let loadPromise; + if (command == "help") { + // We assume clicking "help" will load a page in a new tab. + loadPromise = BrowserTestUtils.waitForNewTab(gBrowser); + } + + info("Clicking command"); + await UrlbarTestUtils.openResultMenuAndClickItem(window, commandOrArray, { + resultIndex: index, + openByMouse: true, + }); + + if (loadPromise) { + info("Waiting for load"); + await loadPromise; + await TestUtils.waitForTick(); + if (command == "help") { + info("Closing help tab"); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + } + + info("Checking scalars. Expected: " + JSON.stringify(expected.scalars)); + QuickSuggestTestUtils.assertScalars(expected.scalars); + + info("Checking events. Expected: " + JSON.stringify([expected.event])); + QuickSuggestTestUtils.assertEvents([expected.event]); + + Assert.equal( + expectedPings.length, + gleanPingCount.value, + "Submitted one Glean ping per expected ping" + ); + + if (command == "dismiss") { + await QuickSuggest.blockedSuggestions.clear(); + } + await PlacesUtils.history.clear(); + + info("Finished command test: " + JSON.stringify({ commandOrArray })); +} + +/** + * Gets a row in the view, which is assumed to be open, and asserts that it's a + * particular quick suggest row. If it is, the row is returned. If it's not, + * null is returned. + * + * @param {number} index + * The expected index of the quick suggest row. + * @param {object} suggestion + * The expected suggestion. + * @param {string} providerName + * The name of the provider that is expected to create the UrlbarResult for + * the suggestion. + * @returns {Element} + * If the row is the expected suggestion, the row element is returned. + * Otherwise null is returned. + */ +async function validateSuggestionRow(index, suggestion, providerName) { + let rowCount = UrlbarTestUtils.getResultCount(window); + Assert.less( + index, + rowCount, + "Expected suggestion row index should be < row count" + ); + if (rowCount <= index) { + return null; + } + + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, index); + Assert.equal( + row.result.providerName, + providerName, + "Expected suggestion row should be from expected provider" + ); + Assert.equal( + row.result.payload.url, + suggestion.url, + "The suggestion row should represent the expected suggestion" + ); + if ( + row.result.providerName != providerName || + row.result.payload.url != suggestion.url + ) { + return null; + } + + return row; +} + +function watchGleanPings(pings) { + let countObject = { value: 0 }; + + let checkPing = (ping, next) => { + countObject.value++; + _assertGleanPing(ping); + if (next) { + GleanPings.quickSuggest.testBeforeNextSubmit(next); + } + }; + + // Build the chain of `testBeforeNextSubmit`s backwards. + let next = undefined; + pings + .slice() + .reverse() + .forEach(ping => { + next = checkPing.bind(null, ping, next); + }); + if (next) { + GleanPings.quickSuggest.testBeforeNextSubmit(next); + } + + return countObject; +} + +function _assertGleanPing(ping) { + Assert.equal(Glean.quickSuggest.pingType.testGetValue(), ping.type); + const keymap = { + // present in all pings + source: Glean.quickSuggest.source, + match_type: Glean.quickSuggest.matchType, + position: Glean.quickSuggest.position, + suggested_index: Glean.quickSuggest.suggestedIndex, + suggested_index_relative_to_group: + Glean.quickSuggest.suggestedIndexRelativeToGroup, + improve_suggest_experience_checked: + Glean.quickSuggest.improveSuggestExperience, + block_id: Glean.quickSuggest.blockId, + advertiser: Glean.quickSuggest.advertiser, + request_id: Glean.quickSuggest.requestId, + context_id: Glean.quickSuggest.contextId, + // impression and click pings + reporting_url: Glean.quickSuggest.reportingUrl, + // impression ping + is_clicked: Glean.quickSuggest.isClicked, + // block/dismiss ping + iab_category: Glean.quickSuggest.iabCategory, + }; + for (let [key, value] of Object.entries(ping.payload)) { + Assert.ok(key in keymap, `A Glean metric exists for field ${key}`); + + // Merino results may contain empty strings, but Glean will represent these + // as nulls. + if (value === "") { + value = null; + } + + Assert.equal( + keymap[key].testGetValue(), + value ?? null, + `Glean metric field ${key} should be the expected value` + ); + } +} + +/** + * Adds two tasks: One with the Rust backend disabled and one with it enabled. + * The names of the task functions will be the name of the passed-in task + * function appended with "_rustDisabled" and "_rustEnabled" respectively. Call + * with the usual `add_task()` arguments. + * + * @param {...any} args + * The usual `add_task()` arguments. + */ +function add_tasks_with_rust(...args) { + let taskFnIndex = args.findIndex(a => typeof a == "function"); + let taskFn = args[taskFnIndex]; + + for (let rustEnabled of [false, true]) { + let newTaskFn = async (...taskFnArgs) => { + info("add_tasks_with_rust: Setting rustEnabled: " + rustEnabled); + UrlbarPrefs.set("quicksuggest.rustEnabled", rustEnabled); + info("add_tasks_with_rust: Done setting rustEnabled: " + rustEnabled); + + // The current backend may now start syncing, so wait for it to finish. + info("add_tasks_with_rust: Forcing sync"); + await QuickSuggestTestUtils.forceSync(); + info("add_tasks_with_rust: Done forcing sync"); + + let rv; + try { + info( + "add_tasks_with_rust: Calling original task function: " + taskFn.name + ); + rv = await taskFn(...taskFnArgs); + } finally { + info( + "add_tasks_with_rust: Done calling original task function: " + + taskFn.name + ); + info("add_tasks_with_rust: Clearing rustEnabled"); + UrlbarPrefs.clear("quicksuggest.rustEnabled"); + info("add_tasks_with_rust: Done clearing rustEnabled"); + + // The current backend may now start syncing, so wait for it to finish. + info("add_tasks_with_rust: Forcing sync"); + await QuickSuggestTestUtils.forceSync(); + info("add_tasks_with_rust: Done forcing sync"); + } + return rv; + }; + + Object.defineProperty(newTaskFn, "name", { + value: taskFn.name + (rustEnabled ? "_rustEnabled" : "_rustDisabled"), + }); + let addTaskArgs = [...args]; + addTaskArgs[taskFnIndex] = newTaskFn; + add_task(...addTaskArgs); + } +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.sjs b/browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.sjs new file mode 100644 index 0000000000..145392fcf2 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.sjs @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +let gTimer; + +function handleRequest(req, resp) { + // Parse the query params. If the params aren't in the form "foo=bar", then + // treat the entire query string as a search string. + let params = req.queryString.split("&").reduce((memo, pair) => { + let [key, val] = pair.split("="); + if (!val) { + // This part isn't in the form "foo=bar". Treat it as the search string + // (the "query"). + val = key; + key = "query"; + } + memo[decode(key)] = decode(val); + return memo; + }, {}); + + let timeout = parseInt(params.timeout); + if (timeout) { + // Write the response after a timeout. + resp.processAsync(); + gTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + gTimer.init( + () => { + writeResponse(params, resp); + resp.finish(); + }, + timeout, + Ci.nsITimer.TYPE_ONE_SHOT + ); + return; + } + + writeResponse(params, resp); +} + +function writeResponse(params, resp) { + // Echo back the search string with "foo" and "bar" appended. + let suffixes = ["foo", "bar"]; + if (params.count) { + // Add more suffixes. + let serial = 0; + while (suffixes.length < params.count) { + suffixes.push(++serial); + } + } + let data = [params.query, suffixes.map(s => params.query + s)]; + resp.setHeader("Content-Type", "application/json", false); + resp.write(JSON.stringify(data)); +} + +function decode(str) { + return decodeURIComponent(str.replace(/\+/g, encodeURIComponent(" "))); +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.xml b/browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.xml new file mode 100644 index 0000000000..142c91849c --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.xml @@ -0,0 +1,11 @@ + + + + +browser_searchSuggestionEngine searchSuggestionEngine.xml + + + + + diff --git a/browser/components/urlbar/tests/quicksuggest/browser/subdialog.xhtml b/browser/components/urlbar/tests/quicksuggest/browser/subdialog.xhtml new file mode 100644 index 0000000000..67303f19ac --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/subdialog.xhtml @@ -0,0 +1,14 @@ + + + + + + + + + A sample sub-dialog for testing + + diff --git a/browser/components/urlbar/tests/quicksuggest/unit/head.js b/browser/components/urlbar/tests/quicksuggest/unit/head.js new file mode 100644 index 0000000000..c468e4526f --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/head.js @@ -0,0 +1,911 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* import-globals-from ../../unit/head.js */ +/* eslint-disable jsdoc/require-param */ + +ChromeUtils.defineESModuleGetters(this, { + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + UrlbarProviderAutofill: "resource:///modules/UrlbarProviderAutofill.sys.mjs", + UrlbarProviderQuickSuggest: + "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs", +}); + +add_setup(async function setUpQuickSuggestXpcshellTest() { + // Initializing TelemetryEnvironment in an xpcshell environment requires + // jumping through a bunch of hoops. Suggest's use of TelemetryEnvironment is + // tested in browser tests, and there's no other necessary reason to wait for + // TelemetryEnvironment initialization in xpcshell tests, so just skip it. + UrlbarPrefs._testSkipTelemetryEnvironmentInit = true; +}); + +/** + * Adds two tasks: One with the Rust backend disabled and one with it enabled. + * The names of the task functions will be the name of the passed-in task + * function appended with "_rustDisabled" and "_rustEnabled". If the passed-in + * task doesn't have a name, "anonymousTask" will be used. Call this with the + * usual `add_task()` arguments. + */ +function add_tasks_with_rust(...args) { + let taskFnIndex = args.findIndex(a => typeof a == "function"); + let taskFn = args[taskFnIndex]; + + for (let rustEnabled of [false, true]) { + let newTaskFn = async (...taskFnArgs) => { + info("add_tasks_with_rust: Setting rustEnabled: " + rustEnabled); + UrlbarPrefs.set("quicksuggest.rustEnabled", rustEnabled); + info("add_tasks_with_rust: Done setting rustEnabled: " + rustEnabled); + + // The current backend may now start syncing, so wait for it to finish. + info("add_tasks_with_rust: Forcing sync"); + await QuickSuggestTestUtils.forceSync(); + info("add_tasks_with_rust: Done forcing sync"); + + let rv; + try { + info( + "add_tasks_with_rust: Calling original task function: " + taskFn.name + ); + rv = await taskFn(...taskFnArgs); + } catch (e) { + // Clearly report any unusual errors to make them easier to spot and to + // make the flow of the test clearer. The harness throws NS_ERROR_ABORT + // when a normal assertion fails, so don't report that. + if (e.result != Cr.NS_ERROR_ABORT) { + Assert.ok( + false, + "add_tasks_with_rust: The original task function threw an error: " + + e + ); + } + throw e; + } finally { + info( + "add_tasks_with_rust: Done calling original task function: " + + taskFn.name + ); + info("add_tasks_with_rust: Clearing rustEnabled"); + UrlbarPrefs.clear("quicksuggest.rustEnabled"); + info("add_tasks_with_rust: Done clearing rustEnabled"); + + // The current backend may now start syncing, so wait for it to finish. + info("add_tasks_with_rust: Forcing sync"); + await QuickSuggestTestUtils.forceSync(); + info("add_tasks_with_rust: Done forcing sync"); + } + return rv; + }; + + Object.defineProperty(newTaskFn, "name", { + value: + (taskFn.name || "anonymousTask") + + (rustEnabled ? "_rustEnabled" : "_rustDisabled"), + }); + let addTaskArgs = [...args]; + addTaskArgs[taskFnIndex] = newTaskFn; + add_task(...addTaskArgs); + } +} + +/** + * Returns an expected Wikipedia (non-sponsored) result that can be passed to + * `check_results()` regardless of whether the Rust backend is enabled. + * + * @returns {object} + * An object that can be passed to `check_results()`. + */ +function makeWikipediaResult({ + source, + provider, + keyword = "wikipedia", + title = "Wikipedia Suggestion", + url = "http://example.com/wikipedia", + originalUrl = "http://example.com/wikipedia", + icon = null, + iconBlob = new Blob([new Uint8Array([])]), + impressionUrl = "http://example.com/wikipedia-impression", + clickUrl = "http://example.com/wikipedia-click", + blockId = 1, + advertiser = "Wikipedia", + iabCategory = "5 - Education", + suggestedIndex = -1, + isSuggestedIndexRelativeToGroup = true, +}) { + let result = { + suggestedIndex, + isSuggestedIndexRelativeToGroup, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + title, + url, + originalUrl, + displayUrl: url.replace(/^https:\/\//, ""), + isSponsored: false, + qsSuggestion: keyword, + sponsoredAdvertiser: "Wikipedia", + sponsoredIabCategory: "5 - Education", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + telemetryType: "adm_nonsponsored", + }, + }; + + if (UrlbarPrefs.get("quickSuggestRustEnabled")) { + result.payload.source = source || "rust"; + result.payload.provider = provider || "Wikipedia"; + result.payload.iconBlob = iconBlob; + } else { + result.payload.source = source || "remote-settings"; + result.payload.provider = provider || "AdmWikipedia"; + result.payload.icon = icon; + result.payload.sponsoredImpressionUrl = impressionUrl; + result.payload.sponsoredClickUrl = clickUrl; + result.payload.sponsoredBlockId = blockId; + result.payload.sponsoredAdvertiser = advertiser; + result.payload.sponsoredIabCategory = iabCategory; + } + + return result; +} + +/** + * Returns an expected AMP (sponsored) result that can be passed to + * `check_results()` regardless of whether the Rust backend is enabled. + * + * @returns {object} + * An object that can be passed to `check_results()`. + */ +function makeAmpResult({ + source, + provider, + keyword = "amp", + title = "Amp Suggestion", + url = "http://example.com/amp", + originalUrl = "http://example.com/amp", + icon = null, + iconBlob = new Blob([new Uint8Array([])]), + impressionUrl = "http://example.com/amp-impression", + clickUrl = "http://example.com/amp-click", + blockId = 1, + advertiser = "Amp", + iabCategory = "22 - Shopping", + suggestedIndex = -1, + isSuggestedIndexRelativeToGroup = true, + requestId = undefined, +} = {}) { + let result = { + suggestedIndex, + isSuggestedIndexRelativeToGroup, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + title, + url, + originalUrl, + requestId, + displayUrl: url.replace(/^https:\/\//, ""), + isSponsored: true, + qsSuggestion: keyword, + sponsoredImpressionUrl: impressionUrl, + sponsoredClickUrl: clickUrl, + sponsoredBlockId: blockId, + sponsoredAdvertiser: advertiser, + sponsoredIabCategory: iabCategory, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + telemetryType: "adm_sponsored", + descriptionL10n: { id: "urlbar-result-action-sponsored" }, + }, + }; + + if (UrlbarPrefs.get("quickSuggestRustEnabled")) { + result.payload.source = source || "rust"; + result.payload.provider = provider || "Amp"; + if (result.payload.source == "rust") { + result.payload.iconBlob = iconBlob; + } else { + result.payload.icon = icon; + } + } else { + result.payload.source = source || "remote-settings"; + result.payload.provider = provider || "AdmWikipedia"; + result.payload.icon = icon; + } + + return result; +} + +/** + * Returns an expected MDN result that can be passed to `check_results()` + * regardless of whether the Rust backend is enabled. + * + * @returns {object} + * An object that can be passed to `check_results()`. + */ +function makeMdnResult({ url, title, description }) { + let finalUrl = new URL(url); + finalUrl.searchParams.set("utm_medium", "firefox-desktop"); + finalUrl.searchParams.set("utm_source", "firefox-suggest"); + finalUrl.searchParams.set( + "utm_campaign", + "firefox-mdn-web-docs-suggestion-experiment" + ); + finalUrl.searchParams.set("utm_content", "treatment"); + + let result = { + isBestMatch: true, + suggestedIndex: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.OTHER_NETWORK, + heuristic: false, + payload: { + telemetryType: "mdn", + title, + url: finalUrl.href, + originalUrl: url, + displayUrl: finalUrl.href.replace(/^https:\/\//, ""), + description, + icon: "chrome://global/skin/icons/mdn.svg", + shouldShowUrl: true, + bottomTextL10n: { id: "firefox-suggest-mdn-bottom-text" }, + }, + }; + + if (UrlbarPrefs.get("quickSuggestRustEnabled")) { + result.payload.source = "rust"; + result.payload.provider = "Mdn"; + } else { + result.payload.source = "remote-settings"; + result.payload.provider = "MDNSuggestions"; + } + + return result; +} + +/** + * Returns an expected AMO (addons) result that can be passed to + * `check_results()` regardless of whether the Rust backend is enabled. + * + * @returns {object} + * An object that can be passed to `check_results()`. + */ +function makeAmoResult({ + source, + provider, + title = "Amo Suggestion", + description = "Amo description", + url = "http://example.com/amo", + originalUrl = "http://example.com/amo", + icon = null, + setUtmParams = true, +}) { + if (setUtmParams) { + url = new URL(url); + url.searchParams.set("utm_medium", "firefox-desktop"); + url.searchParams.set("utm_source", "firefox-suggest"); + url = url.href; + } + + let result = { + isBestMatch: true, + suggestedIndex: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + source, + provider, + title, + description, + url, + originalUrl, + icon, + displayUrl: url.replace(/^https:\/\//, ""), + shouldShowUrl: true, + bottomTextL10n: { id: "firefox-suggest-addons-recommended" }, + helpUrl: QuickSuggest.HELP_URL, + telemetryType: "amo", + }, + }; + + if (UrlbarPrefs.get("quickSuggestRustEnabled")) { + result.payload.source = source || "rust"; + result.payload.provider = provider || "Amo"; + } else { + result.payload.source = source || "remote-settings"; + result.payload.provider = provider || "AddonSuggestions"; + } + + return result; +} + +/** + * Returns an expected weather result that can be passed to `check_results()` + * regardless of whether the Rust backend is enabled. + * + * @returns {object} + * An object that can be passed to `check_results()`. + */ +function makeWeatherResult({ + source, + provider, + telemetryType = undefined, + temperatureUnit = undefined, +} = {}) { + if (!temperatureUnit) { + temperatureUnit = + Services.locale.regionalPrefsLocales[0] == "en-US" ? "f" : "c"; + } + + let result = { + type: UrlbarUtils.RESULT_TYPE.DYNAMIC, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + suggestedIndex: 1, + payload: { + temperatureUnit, + url: MerinoTestUtils.WEATHER_SUGGESTION.url, + iconId: "6", + helpUrl: QuickSuggest.HELP_URL, + requestId: MerinoTestUtils.server.response.body.request_id, + source: "merino", + provider: "accuweather", + dynamicType: "weather", + city: MerinoTestUtils.WEATHER_SUGGESTION.city_name, + temperature: + MerinoTestUtils.WEATHER_SUGGESTION.current_conditions.temperature[ + temperatureUnit + ], + currentConditions: + MerinoTestUtils.WEATHER_SUGGESTION.current_conditions.summary, + forecast: MerinoTestUtils.WEATHER_SUGGESTION.forecast.summary, + high: MerinoTestUtils.WEATHER_SUGGESTION.forecast.high[temperatureUnit], + low: MerinoTestUtils.WEATHER_SUGGESTION.forecast.low[temperatureUnit], + shouldNavigate: true, + }, + }; + + if (UrlbarPrefs.get("quickSuggestRustEnabled")) { + result.payload.source = source || "rust"; + result.payload.provider = provider || "Weather"; + if (telemetryType !== null) { + result.payload.telemetryType = telemetryType || "weather"; + } + } else { + result.payload.source = source || "merino"; + result.payload.provider = provider || "accuweather"; + } + + return result; +} + +/** + * Tests quick suggest prefs migrations. + * + * @param {object} options + * The options object. + * @param {object} options.testOverrides + * An object that modifies how migration is performed. It has the following + * properties, and all are optional: + * + * {number} migrationVersion + * Migration will stop at this version, so for example you can test + * migration only up to version 1 even when the current actual version is + * larger than 1. + * {object} defaultPrefs + * An object that maps pref names (relative to `browser.urlbar`) to + * default-branch values. These should be the default prefs for the given + * `migrationVersion` and will be set as defaults before migration occurs. + * + * @param {string} options.scenario + * The scenario to set at the time migration occurs. + * @param {object} options.expectedPrefs + * The expected prefs after migration: `{ defaultBranch, userBranch }` + * Pref names should be relative to `browser.urlbar`. + * @param {object} [options.initialUserBranch] + * Prefs to set on the user branch before migration ocurs. Use these to + * simulate user actions like disabling prefs or opting in or out of the + * online modal. Pref names should be relative to `browser.urlbar`. + */ +async function doMigrateTest({ + testOverrides, + scenario, + expectedPrefs, + initialUserBranch = {}, +}) { + info( + "Testing migration: " + + JSON.stringify({ + testOverrides, + initialUserBranch, + scenario, + expectedPrefs, + }) + ); + + function setPref(branch, name, value) { + switch (typeof value) { + case "boolean": + branch.setBoolPref(name, value); + break; + case "number": + branch.setIntPref(name, value); + break; + case "string": + branch.setCharPref(name, value); + break; + default: + Assert.ok( + false, + `Pref type not handled for setPref: ${name} = ${value}` + ); + break; + } + } + + function getPref(branch, name) { + let type = typeof UrlbarPrefs.get(name); + switch (type) { + case "boolean": + return branch.getBoolPref(name); + case "number": + return branch.getIntPref(name); + case "string": + return branch.getCharPref(name); + default: + Assert.ok(false, `Pref type not handled for getPref: ${name} ${type}`); + break; + } + return null; + } + + let defaultBranch = Services.prefs.getDefaultBranch("browser.urlbar."); + let userBranch = Services.prefs.getBranch("browser.urlbar."); + + // Set initial prefs. `initialDefaultBranch` are firefox.js values, i.e., + // defaults immediately after startup and before any scenario update and + // migration happens. + UrlbarPrefs._updatingFirefoxSuggestScenario = true; + UrlbarPrefs.clear("quicksuggest.migrationVersion"); + let initialDefaultBranch = { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": false, + }; + for (let name of Object.keys(initialDefaultBranch)) { + userBranch.clearUserPref(name); + } + for (let [branch, prefs] of [ + [defaultBranch, initialDefaultBranch], + [userBranch, initialUserBranch], + ]) { + for (let [name, value] of Object.entries(prefs)) { + if (value !== undefined) { + setPref(branch, name, value); + } + } + } + UrlbarPrefs._updatingFirefoxSuggestScenario = false; + + // Update the scenario and check prefs twice. The first time the migration + // should happen, and the second time the migration should not happen and + // all the prefs should stay the same. + for (let i = 0; i < 2; i++) { + info(`Calling updateFirefoxSuggestScenario, i=${i}`); + + // Do the scenario update and set `isStartup` to simulate startup. + await UrlbarPrefs.updateFirefoxSuggestScenario({ + ...testOverrides, + scenario, + isStartup: true, + }); + + // Check expected pref values. Store expected effective values as we go so + // we can check them afterward. For a given pref, the expected effective + // value is the user value, or if there's not a user value, the default + // value. + let expectedEffectivePrefs = {}; + let { + defaultBranch: expectedDefaultBranch, + userBranch: expectedUserBranch, + } = expectedPrefs; + expectedDefaultBranch = expectedDefaultBranch || {}; + expectedUserBranch = expectedUserBranch || {}; + for (let [branch, prefs, branchType] of [ + [defaultBranch, expectedDefaultBranch, "default"], + [userBranch, expectedUserBranch, "user"], + ]) { + let entries = Object.entries(prefs); + if (!entries.length) { + continue; + } + + info( + `Checking expected prefs on ${branchType} branch after updating scenario` + ); + for (let [name, value] of entries) { + expectedEffectivePrefs[name] = value; + if (branch == userBranch) { + Assert.ok( + userBranch.prefHasUserValue(name), + `Pref ${name} is on user branch` + ); + } + Assert.equal( + getPref(branch, name), + value, + `Pref ${name} value on ${branchType} branch` + ); + } + } + + info( + `Making sure prefs on the default branch without expected user-branch values are not on the user branch` + ); + for (let name of Object.keys(initialDefaultBranch)) { + if (!expectedUserBranch.hasOwnProperty(name)) { + Assert.ok( + !userBranch.prefHasUserValue(name), + `Pref ${name} is not on user branch` + ); + } + } + + info(`Checking expected effective prefs`); + for (let [name, value] of Object.entries(expectedEffectivePrefs)) { + Assert.equal( + UrlbarPrefs.get(name), + value, + `Pref ${name} effective value` + ); + } + + let currentVersion = + testOverrides?.migrationVersion === undefined + ? UrlbarPrefs.FIREFOX_SUGGEST_MIGRATION_VERSION + : testOverrides.migrationVersion; + Assert.equal( + UrlbarPrefs.get("quicksuggest.migrationVersion"), + currentVersion, + "quicksuggest.migrationVersion is correct after migration" + ); + } + + // Clean up. + UrlbarPrefs._updatingFirefoxSuggestScenario = true; + UrlbarPrefs.clear("quicksuggest.migrationVersion"); + let userBranchNames = [ + ...Object.keys(initialUserBranch), + ...Object.keys(expectedPrefs.userBranch || {}), + ]; + for (let name of userBranchNames) { + userBranch.clearUserPref(name); + } + UrlbarPrefs._updatingFirefoxSuggestScenario = false; +} + +/** + * Does some "show less frequently" tests where the cap is set in remote + * settings and Nimbus. See `doOneShowLessFrequentlyTest()`. This function + * assumes the matching behavior implemented by the given `BaseFeature` is based + * on matching prefixes of the given keyword starting at the first word. It + * also assumes the `BaseFeature` provides suggestions in remote settings. + * + * @param {object} options + * Options object. + * @param {BaseFeature} options.feature + * The feature that provides the suggestion matched by the searches. + * @param {*} options.expectedResult + * The expected result that should be matched, for searches that are expected + * to match a result. Can also be a function; it's passed the current search + * string and it should return the expected result. + * @param {string} options.showLessFrequentlyCountPref + * The name of the pref that stores the "show less frequently" count being + * tested. + * @param {string} options.nimbusCapVariable + * The name of the Nimbus variable that stores the "show less frequently" cap + * being tested. + * @param {object} options.keyword + * The primary keyword to use during the test. + * @param {number} options.keywordBaseIndex + * The index in `keyword` to base substring checks around. This function will + * test substrings starting at the beginning of keyword and ending at the + * following indexes: one index before `keywordBaseIndex`, + * `keywordBaseIndex`, `keywordBaseIndex` + 1, `keywordBaseIndex` + 2, and + * `keywordBaseIndex` + 3. + */ +async function doShowLessFrequentlyTests({ + feature, + expectedResult, + showLessFrequentlyCountPref, + nimbusCapVariable, + keyword, + keywordBaseIndex = keyword.indexOf(" "), +}) { + // Do some sanity checks on the keyword. Any checks that fail are errors in + // the test. + if (keywordBaseIndex <= 0) { + throw new Error( + "keywordBaseIndex must be > 0, but it's " + keywordBaseIndex + ); + } + if (keyword.length < keywordBaseIndex + 3) { + throw new Error( + "keyword must have at least two chars after keywordBaseIndex" + ); + } + + let tests = [ + { + showLessFrequentlyCount: 0, + canShowLessFrequently: true, + newSearches: { + [keyword.substring(0, keywordBaseIndex - 1)]: false, + [keyword.substring(0, keywordBaseIndex)]: true, + [keyword.substring(0, keywordBaseIndex + 1)]: true, + [keyword.substring(0, keywordBaseIndex + 2)]: true, + [keyword.substring(0, keywordBaseIndex + 3)]: true, + }, + }, + { + showLessFrequentlyCount: 1, + canShowLessFrequently: true, + newSearches: { + [keyword.substring(0, keywordBaseIndex)]: false, + }, + }, + { + showLessFrequentlyCount: 2, + canShowLessFrequently: true, + newSearches: { + [keyword.substring(0, keywordBaseIndex + 1)]: false, + }, + }, + { + showLessFrequentlyCount: 3, + canShowLessFrequently: false, + newSearches: { + [keyword.substring(0, keywordBaseIndex + 2)]: false, + }, + }, + { + showLessFrequentlyCount: 3, + canShowLessFrequently: false, + newSearches: {}, + }, + ]; + + info("Testing 'show less frequently' with cap in remote settings"); + await doOneShowLessFrequentlyTest({ + tests, + feature, + expectedResult, + showLessFrequentlyCountPref, + rs: { + show_less_frequently_cap: 3, + }, + }); + + // Nimbus should override remote settings. + info("Testing 'show less frequently' with cap in Nimbus and remote settings"); + await doOneShowLessFrequentlyTest({ + tests, + feature, + expectedResult, + showLessFrequentlyCountPref, + rs: { + show_less_frequently_cap: 10, + }, + nimbus: { + [nimbusCapVariable]: 3, + }, + }); +} + +/** + * Does a group of searches, increments the "show less frequently" count, and + * repeats until all groups are done. The cap can be set by remote settings + * config and/or Nimbus. + * + * @param {object} options + * Options object. + * @param {BaseFeature} options.feature + * The feature that provides the suggestion matched by the searches. + * @param {*} options.expectedResult + * The expected result that should be matched, for searches that are expected + * to match a result. Can also be a function; it's passed the current search + * string and it should return the expected result. + * @param {string} options.showLessFrequentlyCountPref + * The name of the pref that stores the "show less frequently" count being + * tested. + * @param {object} options.tests + * An array where each item describes a group of new searches to perform and + * expected state. Each item should look like this: + * `{ showLessFrequentlyCount, canShowLessFrequently, newSearches }` + * + * {number} showLessFrequentlyCount + * The expected value of `showLessFrequentlyCount` before the group of + * searches is performed. + * {boolean} canShowLessFrequently + * The expected value of `canShowLessFrequently` before the group of + * searches is performed. + * {object} newSearches + * An object that maps each search string to a boolean that indicates + * whether the first remote settings suggestion should be triggered by the + * search string. Searches are cumulative: The intended use is to pass a + * large initial group of searches in the first search group, and then each + * following `newSearches` is a diff against the previous. + * @param {object} options.rs + * The remote settings config to set. + * @param {object} options.nimbus + * The Nimbus variables to set. + */ +async function doOneShowLessFrequentlyTest({ + feature, + expectedResult, + showLessFrequentlyCountPref, + tests, + rs = {}, + nimbus = {}, +}) { + // Disable Merino so we trigger only remote settings suggestions. The + // `BaseFeature` is expected to add remote settings suggestions using keywords + // start starting with the first word in each full keyword, but the mock + // Merino server will always return whatever suggestion it's told to return + // regardless of the search string. That means Merino will return a suggestion + // for a keyword that's smaller than the first full word. + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", false); + + // Set Nimbus variables and RS config. + let cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature(nimbus); + await QuickSuggestTestUtils.withConfig({ + config: rs, + callback: async () => { + let cumulativeSearches = {}; + + for (let { + showLessFrequentlyCount, + canShowLessFrequently, + newSearches, + } of tests) { + info( + "Starting subtest: " + + JSON.stringify({ + showLessFrequentlyCount, + canShowLessFrequently, + newSearches, + }) + ); + + Assert.equal( + feature.showLessFrequentlyCount, + showLessFrequentlyCount, + "showLessFrequentlyCount should be correct initially" + ); + Assert.equal( + UrlbarPrefs.get(showLessFrequentlyCountPref), + showLessFrequentlyCount, + "Pref should be correct initially" + ); + Assert.equal( + feature.canShowLessFrequently, + canShowLessFrequently, + "canShowLessFrequently should be correct initially" + ); + + // Merge the current `newSearches` object into the cumulative object. + cumulativeSearches = { + ...cumulativeSearches, + ...newSearches, + }; + + for (let [searchString, isExpected] of Object.entries( + cumulativeSearches + )) { + info("Doing search: " + JSON.stringify({ searchString, isExpected })); + + let results = []; + if (isExpected) { + results.push( + typeof expectedResult == "function" + ? expectedResult(searchString) + : expectedResult + ); + } + + await check_results({ + context: createContext(searchString, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: results, + }); + } + + feature.incrementShowLessFrequentlyCount(); + } + }, + }); + + await cleanUpNimbus(); + UrlbarPrefs.clear(showLessFrequentlyCountPref); + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true); +} + +/** + * Queries the Rust component directly and checks the returned suggestions. The + * point is to make sure the Rust backend passes the correct providers to the + * Rust component depending on the types of enabled suggestions. Assuming the + * Rust component isn't buggy, it should return suggestions only for the + * passed-in providers. + * + * @param {object} options + * Options object + * @param {string} options.searchString + * The search string. + * @param {Array} options.tests + * Array of test objects: `{ prefs, expectedUrls }` + * + * For each object, the given prefs are set, the Rust component is queried + * using the given search string, and the URLs of the returned suggestions are + * compared to the given expected URLs (order doesn't matter). + * + * {object} prefs + * An object mapping pref names (relative to `browser.urlbar`) to values. + * These prefs will be set before querying and should be used to enable or + * disable particular types of suggestions. + * {Array} expectedUrls + * An array of the URLs of the suggestions that are expected to be returned. + * The order doesn't matter. + */ +async function doRustProvidersTests({ searchString, tests }) { + UrlbarPrefs.set("quicksuggest.rustEnabled", true); + + for (let { prefs, expectedUrls } of tests) { + info( + "Starting Rust providers test: " + JSON.stringify({ prefs, expectedUrls }) + ); + + info("Setting prefs and forcing sync"); + for (let [name, value] of Object.entries(prefs)) { + UrlbarPrefs.set(name, value); + } + await QuickSuggestTestUtils.forceSync(); + + info("Querying with search string: " + JSON.stringify(searchString)); + let suggestions = await QuickSuggest.rustBackend.query(searchString); + info("Got suggestions: " + JSON.stringify(suggestions)); + + Assert.deepEqual( + suggestions.map(s => s.url).sort(), + expectedUrls.sort(), + "query() should return the expected suggestions (by URL)" + ); + + info("Clearing prefs and forcing sync"); + for (let name of Object.keys(prefs)) { + UrlbarPrefs.clear(name); + } + await QuickSuggestTestUtils.forceSync(); + } + + info("Clearing rustEnabled pref and forcing sync"); + UrlbarPrefs.clear("quicksuggest.rustEnabled"); + await QuickSuggestTestUtils.forceSync(); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient.js b/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient.js new file mode 100644 index 0000000000..cd45cb11a7 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient.js @@ -0,0 +1,647 @@ +/* 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/. */ + +// Test for MerinoClient. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs", + MerinoClient: "resource:///modules/MerinoClient.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", +}); + +// Set the `merino.timeoutMs` pref to a large value so that the client will not +// inadvertently time out during fetches. This is especially important on CI and +// when running this test in verify mode. Tasks that specifically test timeouts +// may need to set a more reasonable value for their duration. +const TEST_TIMEOUT_MS = 30000; + +// The expected suggestion objects returned from `MerinoClient.fetch()`. +const EXPECTED_MERINO_SUGGESTIONS = []; + +const { SEARCH_PARAMS } = MerinoClient; + +let gClient; + +add_setup(async function init() { + UrlbarPrefs.set("merino.timeoutMs", TEST_TIMEOUT_MS); + registerCleanupFunction(() => { + UrlbarPrefs.clear("merino.timeoutMs"); + }); + + gClient = new MerinoClient(); + await MerinoTestUtils.server.start(); + + for (let suggestion of MerinoTestUtils.server.response.body.suggestions) { + EXPECTED_MERINO_SUGGESTIONS.push({ + ...suggestion, + request_id: MerinoTestUtils.server.response.body.request_id, + source: "merino", + }); + } +}); + +// Checks client names. +add_task(async function name() { + Assert.equal( + gClient.name, + "anonymous", + "gClient name is 'anonymous' since it wasn't given a name" + ); + + let client = new MerinoClient("New client"); + Assert.equal(client.name, "New client", "newClient name is correct"); +}); + +// Does a successful fetch. +add_task(async function success() { + let histograms = MerinoTestUtils.getAndClearHistograms(); + + await fetchAndCheckSuggestions({ + expected: EXPECTED_MERINO_SUGGESTIONS, + }); + + Assert.equal( + gClient.lastFetchStatus, + "success", + "The request successfully finished" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); +}); + +// Does a successful fetch that doesn't return any suggestions. +add_task(async function noSuggestions() { + let { suggestions } = MerinoTestUtils.server.response.body; + MerinoTestUtils.server.response.body.suggestions = []; + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + await fetchAndCheckSuggestions({ + expected: [], + }); + + Assert.equal( + gClient.lastFetchStatus, + "no_suggestion", + "The request successfully finished without suggestions" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "no_suggestion", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.response.body.suggestions = suggestions; +}); + +// Checks a response that's valid but also has some unexpected properties. +add_task(async function unexpectedResponseProperties() { + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response.body.unexpectedString = "some value"; + MerinoTestUtils.server.response.body.unexpectedArray = ["a", "b", "c"]; + MerinoTestUtils.server.response.body.unexpectedObject = { foo: "bar" }; + + await fetchAndCheckSuggestions({ + expected: EXPECTED_MERINO_SUGGESTIONS, + }); + + Assert.equal( + gClient.lastFetchStatus, + "success", + "The request successfully finished" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); +}); + +// Checks some responses with unexpected response bodies. +add_task(async function unexpectedResponseBody() { + let histograms = MerinoTestUtils.getAndClearHistograms(); + + let responses = [ + { body: {} }, + { body: { bogus: [] } }, + { body: { suggestions: {} } }, + { body: { suggestions: [] } }, + { body: "" }, + { body: "bogus", contentType: "text/html" }, + ]; + + for (let r of responses) { + info("Testing response: " + JSON.stringify(r)); + + MerinoTestUtils.server.response = r; + await fetchAndCheckSuggestions({ expected: [] }); + + Assert.equal( + gClient.lastFetchStatus, + "no_suggestion", + "The request successfully finished without suggestions" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "no_suggestion", + latencyRecorded: true, + client: gClient, + }); + } + + MerinoTestUtils.server.reset(); +}); + +// Tests with a network error. +add_task(async function networkError() { + let histograms = MerinoTestUtils.getAndClearHistograms(); + + // This promise will be resolved when the client processes the network error. + let responsePromise = gClient.waitForNextResponse(); + + await MerinoTestUtils.server.withNetworkError(async () => { + await fetchAndCheckSuggestions({ expected: [] }); + }); + + // The client should have nulled out the timeout timer before `fetch()` + // returned. + Assert.strictEqual( + gClient._test_timeoutTimer, + null, + "timeoutTimer does not exist after fetch finished" + ); + + // Wait for the client to process the network error. + await responsePromise; + + Assert.equal( + gClient.lastFetchStatus, + "network_error", + "The request failed with a network error" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "network_error", + latencyRecorded: false, + client: gClient, + }); +}); + +// Tests with an HTTP error. +add_task(async function httpError() { + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response = { status: 500 }; + await fetchAndCheckSuggestions({ expected: [] }); + + Assert.equal( + gClient.lastFetchStatus, + "http_error", + "The request failed with an HTTP error" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "http_error", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); +}); + +// Tests a client timeout. +add_task(async function clientTimeout() { + await doClientTimeoutTest({ + prefTimeoutMs: 200, + responseDelayMs: 400, + }); +}); + +// Tests a client timeout followed by an HTTP error. Only the timeout should be +// recorded. +add_task(async function clientTimeoutFollowedByHTTPError() { + MerinoTestUtils.server.response = { status: 500 }; + await doClientTimeoutTest({ + prefTimeoutMs: 200, + responseDelayMs: 400, + expectedResponseStatus: 500, + }); +}); + +// Tests a client timeout when a timeout value is passed to `fetch()`, which +// should override the value in the `merino.timeoutMs` pref. +add_task(async function timeoutPassedToFetch() { + // Set up a timeline like this: + // + // 1ms: The timeout passed to `fetch()` elapses + // 400ms: Merino returns a response + // 30000ms: The timeout in the pref elapses + // + // The expected behavior is that the 1ms timeout is hit, the request fails + // with a timeout, and Merino later returns a response. If the 1ms timeout is + // not hit, then Merino will return a response before the 30000ms timeout + // elapses and the request will complete successfully. + + await doClientTimeoutTest({ + prefTimeoutMs: 30000, + responseDelayMs: 400, + fetchArgs: { query: "search", timeoutMs: 1 }, + }); +}); + +async function doClientTimeoutTest({ + prefTimeoutMs, + responseDelayMs, + fetchArgs = { query: "search" }, + expectedResponseStatus = 200, +} = {}) { + let histograms = MerinoTestUtils.getAndClearHistograms(); + + let originalPrefTimeoutMs = UrlbarPrefs.get("merino.timeoutMs"); + UrlbarPrefs.set("merino.timeoutMs", prefTimeoutMs); + + // Make the server return a delayed response so the client times out waiting + // for it. + MerinoTestUtils.server.response.delay = responseDelayMs; + + let responsePromise = gClient.waitForNextResponse(); + await fetchAndCheckSuggestions({ args: fetchArgs, expected: [] }); + + Assert.equal(gClient.lastFetchStatus, "timeout", "The request timed out"); + + // The client should have nulled out the timeout timer. + Assert.strictEqual( + gClient._test_timeoutTimer, + null, + "timeoutTimer does not exist after fetch finished" + ); + + // The fetch controller should still exist because the fetch should remain + // ongoing. + Assert.ok( + gClient._test_fetchController, + "fetchController still exists after fetch finished" + ); + Assert.ok( + !gClient._test_fetchController.signal.aborted, + "fetchController is not aborted" + ); + + // The latency histogram should not be updated since the response has not been + // received. + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "timeout", + latencyRecorded: false, + latencyStopwatchRunning: true, + client: gClient, + }); + + // Wait for the client to receive the response. + let httpResponse = await responsePromise; + Assert.ok(httpResponse, "Response was received"); + Assert.equal(httpResponse.status, expectedResponseStatus, "Response status"); + + // The client should have nulled out the fetch controller. + Assert.ok(!gClient._test_fetchController, "fetchController no longer exists"); + + // The `checkAndClearHistograms()` call above cleared the histograms. After + // that, nothing else should have been recorded for the response. + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: null, + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + UrlbarPrefs.set("merino.timeoutMs", originalPrefTimeoutMs); +} + +// By design, when a fetch times out, the client allows it to finish so we can +// record its latency. But when a second fetch starts before the first finishes, +// the client should abort the first so that there is at most one fetch at a +// time. +add_task(async function newFetchAbortsPrevious() { + let histograms = MerinoTestUtils.getAndClearHistograms(); + + // Make the server return a very delayed response so that it would time out + // and we can start a second fetch that will abort the first fetch. + MerinoTestUtils.server.response.delay = + 100 * UrlbarPrefs.get("merino.timeoutMs"); + + // Do the first fetch. + await fetchAndCheckSuggestions({ expected: [] }); + + // At this point, the timeout timer has fired, causing our `fetch()` call to + // return. However, the client's internal fetch should still be ongoing. + + Assert.equal(gClient.lastFetchStatus, "timeout", "The request timed out"); + + // The client should have nulled out the timeout timer. + Assert.strictEqual( + gClient._test_timeoutTimer, + null, + "timeoutTimer does not exist after first fetch finished" + ); + + // The fetch controller should still exist because the fetch should remain + // ongoing. + Assert.ok( + gClient._test_fetchController, + "fetchController still exists after first fetch finished" + ); + Assert.ok( + !gClient._test_fetchController.signal.aborted, + "fetchController is not aborted" + ); + + // The latency histogram should not be updated since the fetch is still + // ongoing. + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "timeout", + latencyRecorded: false, + latencyStopwatchRunning: true, + client: gClient, + }); + + // Do the second fetch. This time don't delay the response. + delete MerinoTestUtils.server.response.delay; + await fetchAndCheckSuggestions({ + expected: EXPECTED_MERINO_SUGGESTIONS, + }); + + Assert.equal( + gClient.lastFetchStatus, + "success", + "The request finished successfully" + ); + + // The fetch was successful, so the client should have nulled out both + // properties. + Assert.ok( + !gClient._test_fetchController, + "fetchController does not exist after second fetch finished" + ); + Assert.strictEqual( + gClient._test_timeoutTimer, + null, + "timeoutTimer does not exist after second fetch finished" + ); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); +}); + +// The client should not include the `clientVariants` and `providers` search +// params when they are not set. +add_task(async function clientVariants_providers_notSet() { + UrlbarPrefs.set("merino.clientVariants", ""); + UrlbarPrefs.set("merino.providers", ""); + + await fetchAndCheckSuggestions({ + expected: EXPECTED_MERINO_SUGGESTIONS, + }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: "search", + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + }, + }, + ]); + + UrlbarPrefs.clear("merino.clientVariants"); + UrlbarPrefs.clear("merino.providers"); +}); + +// The client should include the `clientVariants` and `providers` search params +// when they are set using preferences. +add_task(async function clientVariants_providers_preferences() { + UrlbarPrefs.set("merino.clientVariants", "green"); + UrlbarPrefs.set("merino.providers", "pink"); + + await fetchAndCheckSuggestions({ + expected: EXPECTED_MERINO_SUGGESTIONS, + }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: "search", + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + [SEARCH_PARAMS.CLIENT_VARIANTS]: "green", + [SEARCH_PARAMS.PROVIDERS]: "pink", + }, + }, + ]); + + UrlbarPrefs.clear("merino.clientVariants"); + UrlbarPrefs.clear("merino.providers"); +}); + +// The client should include the `providers` search param when it's set by +// passing in the `providers` argument to `fetch()`. The argument should +// override the pref. This tests a single provider. +add_task(async function providers_arg_single() { + UrlbarPrefs.set("merino.providers", "prefShouldNotBeUsed"); + + await fetchAndCheckSuggestions({ + args: { query: "search", providers: ["argShouldBeUsed"] }, + expected: EXPECTED_MERINO_SUGGESTIONS, + }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: "search", + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + [SEARCH_PARAMS.PROVIDERS]: "argShouldBeUsed", + }, + }, + ]); + + UrlbarPrefs.clear("merino.providers"); +}); + +// The client should include the `providers` search param when it's set by +// passing in the `providers` argument to `fetch()`. The argument should +// override the pref. This tests multiple providers. +add_task(async function providers_arg_many() { + UrlbarPrefs.set("merino.providers", "prefShouldNotBeUsed"); + + await fetchAndCheckSuggestions({ + args: { query: "search", providers: ["one", "two", "three"] }, + expected: EXPECTED_MERINO_SUGGESTIONS, + }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: "search", + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + [SEARCH_PARAMS.PROVIDERS]: "one,two,three", + }, + }, + ]); + + UrlbarPrefs.clear("merino.providers"); +}); + +// The client should include the `providers` search param when it's set by +// passing in the `providers` argument to `fetch()` even when it's an empty +// array. The argument should override the pref. +add_task(async function providers_arg_empty() { + UrlbarPrefs.set("merino.providers", "prefShouldNotBeUsed"); + + await fetchAndCheckSuggestions({ + args: { query: "search", providers: [] }, + expected: EXPECTED_MERINO_SUGGESTIONS, + }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: "search", + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + [SEARCH_PARAMS.PROVIDERS]: "", + }, + }, + ]); + + UrlbarPrefs.clear("merino.providers"); +}); + +// Passes invalid `providers` arguments to `fetch()`. +add_task(async function providers_arg_invalid() { + let providersValues = ["", "nonempty", {}]; + + for (let providers of providersValues) { + info("Calling fetch() with providers: " + JSON.stringify(providers)); + + // `Assert.throws()` doesn't seem to work with async functions... + let error; + try { + await gClient.fetch({ providers, query: "search" }); + } catch (e) { + error = e; + } + Assert.ok(error, "fetch() threw an error"); + Assert.equal( + error.message, + "providers must be an array if given", + "Expected error was thrown" + ); + } +}); + +// Tests setting the endpoint URL and query parameters via Nimbus. +add_task(async function nimbus() { + // Clear the endpoint pref so we know the URL is not being fetched from it. + let originalEndpointURL = UrlbarPrefs.get("merino.endpointURL"); + UrlbarPrefs.set("merino.endpointURL", ""); + + await UrlbarTestUtils.initNimbusFeature(); + + // First, with the endpoint pref set to an empty string, make sure no Merino + // suggestion are returned. + await fetchAndCheckSuggestions({ expected: [] }); + + // Now install an experiment that sets the endpoint and other Merino-related + // variables. Make sure a suggestion is returned and the request includes the + // correct query params. + + // `param`: The param name in the request URL + // `value`: The value to use for the param + // `variable`: The name of the Nimbus variable corresponding to the param + let expectedParams = [ + { + param: SEARCH_PARAMS.CLIENT_VARIANTS, + value: "test-client-variants", + variable: "merinoClientVariants", + }, + { + param: SEARCH_PARAMS.PROVIDERS, + value: "test-providers", + variable: "merinoProviders", + }, + ]; + + // Set up the Nimbus variable values to create the experiment with. + let experimentValues = { + merinoEndpointURL: MerinoTestUtils.server.url.toString(), + }; + for (let { variable, value } of expectedParams) { + experimentValues[variable] = value; + } + + await withExperiment(experimentValues, async () => { + await fetchAndCheckSuggestions({ expected: EXPECTED_MERINO_SUGGESTIONS }); + + let params = { + [SEARCH_PARAMS.QUERY]: "search", + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + }; + for (let { param, value } of expectedParams) { + params[param] = value; + } + MerinoTestUtils.server.checkAndClearRequests([{ params }]); + }); + + UrlbarPrefs.set("merino.endpointURL", originalEndpointURL); +}); + +async function fetchAndCheckSuggestions({ + expected, + args = { + query: "search", + }, +}) { + let actual = await gClient.fetch(args); + Assert.deepEqual(actual, expected, "Expected suggestions"); + gClient.resetSession(); +} + +async function withExperiment(values, callback) { + let { enrollmentPromise, doExperimentCleanup } = + ExperimentFakes.enrollmentHelper( + ExperimentFakes.recipe("mock-experiment", { + active: true, + branches: [ + { + slug: "treatment", + features: [ + { + featureId: NimbusFeatures.urlbar.featureId, + value: { + enabled: true, + ...values, + }, + }, + ], + }, + ], + }) + ); + await enrollmentPromise; + await callback(); + await doExperimentCleanup(); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient_sessions.js b/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient_sessions.js new file mode 100644 index 0000000000..b8d62062c0 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient_sessions.js @@ -0,0 +1,402 @@ +/* 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/. */ + +// Test for MerinoClient sessions. + +"use strict"; + +const { MerinoClient } = ChromeUtils.importESModule( + "resource:///modules/MerinoClient.sys.mjs" +); + +const { SEARCH_PARAMS } = MerinoClient; + +let gClient; + +add_setup(async () => { + gClient = new MerinoClient(); + await MerinoTestUtils.server.start(); +}); + +// In a single session, all requests should use the same session ID and the +// sequence number should be incremented. +add_task(async function singleSession() { + for (let i = 0; i < 3; i++) { + let query = "search" + i; + await gClient.fetch({ query }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: i, + }, + }, + ]); + } + + gClient.resetSession(); +}); + +// Different sessions should use different session IDs and the sequence number +// should be reset. +add_task(async function manySessions() { + for (let i = 0; i < 3; i++) { + let query = "search" + i; + await gClient.fetch({ query }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + }, + }, + ]); + + gClient.resetSession(); + } +}); + +// Tests two consecutive fetches: +// +// 1. Start a fetch +// 2. Wait for the mock Merino server to receive the request +// 3. Start a second fetch before the client receives the response +// +// The first fetch will be canceled by the second but the sequence number in the +// second fetch should still be incremented. +add_task(async function twoFetches_wait() { + for (let i = 0; i < 3; i++) { + // Send the first response after a delay to make sure the client will not + // receive it before we start the second fetch. + MerinoTestUtils.server.response.delay = UrlbarPrefs.get("merino.timeoutMs"); + + // Start the first fetch but don't wait for it to finish. + let requestPromise = MerinoTestUtils.server.waitForNextRequest(); + let query1 = "search" + i; + gClient.fetch({ query: query1 }); + + // Wait until the first request is received before starting the second + // fetch, which will cancel the first. The response doesn't need to be + // delayed, so remove it to make the test run faster. + await requestPromise; + delete MerinoTestUtils.server.response.delay; + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + // The sequence number should have been incremented for each fetch. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query1, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i, + }, + }, + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + } + + gClient.resetSession(); +}); + +// Tests two consecutive fetches: +// +// 1. Start a fetch +// 2. Immediately start a second fetch +// +// The first fetch will be canceled by the second but the sequence number in the +// second fetch should still be incremented. +add_task(async function twoFetches_immediate() { + for (let i = 0; i < 3; i++) { + // Send the first response after a delay to make sure the client will not + // receive it before we start the second fetch. + MerinoTestUtils.server.response.delay = + 100 * UrlbarPrefs.get("merino.timeoutMs"); + + // Start the first fetch but don't wait for it to finish. + let query1 = "search" + i; + gClient.fetch({ query: query1 }); + + // Immediately do a second fetch that cancels the first. The response + // doesn't need to be delayed, so remove it to make the test run faster. + delete MerinoTestUtils.server.response.delay; + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + // The sequence number should have been incremented for each fetch, but the + // first won't have reached the server since it was immediately canceled. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + } + + gClient.resetSession(); +}); + +// When a network error occurs, the sequence number should still be incremented. +add_task(async function networkError() { + for (let i = 0; i < 3; i++) { + // Do a fetch that fails with a network error. + let query1 = "search" + i; + await MerinoTestUtils.server.withNetworkError(async () => { + await gClient.fetch({ query: query1 }); + }); + + Assert.equal( + gClient.lastFetchStatus, + "network_error", + "The request failed with a network error" + ); + + // Do another fetch that successfully finishes. + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + Assert.equal( + gClient.lastFetchStatus, + "success", + "The request completed successfully" + ); + + // Only the second request should have been received but the sequence number + // should have been incremented for each. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + } + + gClient.resetSession(); +}); + +// When the server returns a response with an HTTP error, the sequence number +// should be incremented. +add_task(async function httpError() { + for (let i = 0; i < 3; i++) { + // Do a fetch that fails with an HTTP error. + MerinoTestUtils.server.response.status = 500; + let query1 = "search" + i; + await gClient.fetch({ query: query1 }); + + Assert.equal( + gClient.lastFetchStatus, + "http_error", + "The last request failed with a network error" + ); + + // Do another fetch that successfully finishes. + MerinoTestUtils.server.response.status = 200; + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + Assert.equal( + gClient.lastFetchStatus, + "success", + "The last request completed successfully" + ); + + // Both requests should have been received and the sequence number should + // have been incremented for each. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query1, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i, + }, + }, + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + + MerinoTestUtils.server.reset(); + } + + gClient.resetSession(); +}); + +// When the client times out waiting for a response but later receives it and no +// other fetch happens in the meantime, the sequence number should be +// incremented. +add_task(async function clientTimeout_wait() { + for (let i = 0; i < 3; i++) { + // Do a fetch that causes the client to time out. + MerinoTestUtils.server.response.delay = + 2 * UrlbarPrefs.get("merino.timeoutMs"); + let responsePromise = gClient.waitForNextResponse(); + let query1 = "search" + i; + await gClient.fetch({ query: query1 }); + + Assert.equal( + gClient.lastFetchStatus, + "timeout", + "The last request failed with a client timeout" + ); + + // Wait for the client to receive the response. + await responsePromise; + + // Do another fetch that successfully finishes. + delete MerinoTestUtils.server.response.delay; + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + Assert.equal( + gClient.lastFetchStatus, + "success", + "The last request completed successfully" + ); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query1, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i, + }, + }, + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + } + + gClient.resetSession(); +}); + +// When the client times out waiting for a response and a second fetch starts +// before the response is received, the first fetch should be canceled but the +// sequence number should still be incremented. +add_task(async function clientTimeout_canceled() { + for (let i = 0; i < 3; i++) { + // Do a fetch that causes the client to time out. + MerinoTestUtils.server.response.delay = + 2 * UrlbarPrefs.get("merino.timeoutMs"); + let query1 = "search" + i; + await gClient.fetch({ query: query1 }); + + Assert.equal( + gClient.lastFetchStatus, + "timeout", + "The last request failed with a client timeout" + ); + + // Do another fetch that successfully finishes. + delete MerinoTestUtils.server.response.delay; + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + Assert.equal( + gClient.lastFetchStatus, + "success", + "The last request completed successfully" + ); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query1, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i, + }, + }, + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + } + + gClient.resetSession(); +}); + +// When the session times out, the next fetch should use a new session ID and +// the sequence number should be reset. +add_task(async function sessionTimeout() { + // Set the session timeout to something reasonable to test. + let originalTimeoutMs = gClient.sessionTimeoutMs; + gClient.sessionTimeoutMs = 500; + + // Do a fetch. + let query1 = "search"; + await gClient.fetch({ query: query1 }); + + // Wait for the session to time out. + await gClient.waitForNextSessionReset(); + + Assert.strictEqual( + gClient.sessionID, + null, + "sessionID is null after session timeout" + ); + Assert.strictEqual( + gClient.sequenceNumber, + 0, + "sequenceNumber is zero after session timeout" + ); + Assert.strictEqual( + gClient._test_sessionTimer, + null, + "sessionTimer is null after session timeout" + ); + + // Do another fetch. + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + // The second request's sequence number should be zero due to the session + // timeout. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query1, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + }, + }, + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + }, + }, + ]); + + Assert.ok( + gClient.sessionID, + "sessionID is non-null after first request in a new session" + ); + Assert.equal( + gClient.sequenceNumber, + 1, + "sequenceNumber is one after first request in a new session" + ); + Assert.ok( + gClient._test_sessionTimer, + "sessionTimer is non-null after first request in a new session" + ); + + gClient.sessionTimeoutMs = originalTimeoutMs; + gClient.resetSession(); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest.js new file mode 100644 index 0000000000..e4c145aabb --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest.js @@ -0,0 +1,1661 @@ +/* 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/. */ + +// Basic tests for the quick suggest provider using the remote settings source. +// See also test_quicksuggest_merino.js. + +"use strict"; + +const TELEMETRY_REMOTE_SETTINGS_LATENCY = + "FX_URLBAR_QUICK_SUGGEST_REMOTE_SETTINGS_LATENCY_MS"; + +const SPONSORED_SEARCH_STRING = "amp"; +const NONSPONSORED_SEARCH_STRING = "wikipedia"; +const SPONSORED_AND_NONSPONSORED_SEARCH_STRING = "sponsored and non-sponsored"; + +const HTTP_SEARCH_STRING = "http prefix"; +const HTTPS_SEARCH_STRING = "https prefix"; +const PREFIX_SUGGESTIONS_STRIPPED_URL = "example.com/prefix-test"; + +const { TIMESTAMP_TEMPLATE, TIMESTAMP_LENGTH } = QuickSuggest; +const TIMESTAMP_SEARCH_STRING = "timestamp"; +const TIMESTAMP_SUGGESTION_URL = `http://example.com/timestamp-${TIMESTAMP_TEMPLATE}`; +const TIMESTAMP_SUGGESTION_CLICK_URL = `http://click.reporting.test.com/timestamp-${TIMESTAMP_TEMPLATE}-foo`; + +const REMOTE_SETTINGS_RESULTS = [ + QuickSuggestTestUtils.ampRemoteSettings({ + keywords: [ + SPONSORED_SEARCH_STRING, + SPONSORED_AND_NONSPONSORED_SEARCH_STRING, + ], + }), + QuickSuggestTestUtils.wikipediaRemoteSettings({ + keywords: [ + NONSPONSORED_SEARCH_STRING, + SPONSORED_AND_NONSPONSORED_SEARCH_STRING, + ], + }), + { + id: 3, + url: "http://" + PREFIX_SUGGESTIONS_STRIPPED_URL, + title: "HTTP Suggestion", + keywords: [HTTP_SEARCH_STRING], + click_url: "http://example.com/http-click", + impression_url: "http://example.com/http-impression", + advertiser: "HttpAdvertiser", + iab_category: "22 - Shopping", + icon: "1234", + }, + { + id: 4, + url: "https://" + PREFIX_SUGGESTIONS_STRIPPED_URL, + title: "https suggestion", + keywords: [HTTPS_SEARCH_STRING], + click_url: "http://click.reporting.test.com/prefix", + impression_url: "http://impression.reporting.test.com/prefix", + advertiser: "TestAdvertiserPrefix", + iab_category: "22 - Shopping", + icon: "1234", + }, + { + id: 5, + url: TIMESTAMP_SUGGESTION_URL, + title: "Timestamp suggestion", + keywords: [TIMESTAMP_SEARCH_STRING], + click_url: TIMESTAMP_SUGGESTION_CLICK_URL, + impression_url: "http://impression.reporting.test.com/timestamp", + advertiser: "TestAdvertiserTimestamp", + iab_category: "22 - Shopping", + icon: "1234", + }, +]; + +function expectedNonSponsoredResult() { + return makeWikipediaResult({ + blockId: 2, + }); +} + +function expectedSponsoredResult() { + return makeAmpResult(); +} + +function expectedSponsoredPriorityResult() { + return { + ...expectedSponsoredResult(), + isBestMatch: true, + suggestedIndex: 1, + isSuggestedIndexRelativeToGroup: false, + }; +} + +function expectedHttpResult() { + let suggestion = REMOTE_SETTINGS_RESULTS[2]; + return makeAmpResult({ + keyword: HTTP_SEARCH_STRING, + title: suggestion.title, + url: suggestion.url, + originalUrl: suggestion.url, + impressionUrl: suggestion.impression_url, + clickUrl: suggestion.click_url, + blockId: suggestion.id, + advertiser: suggestion.advertiser, + }); +} + +function expectedHttpsResult() { + let suggestion = REMOTE_SETTINGS_RESULTS[3]; + return makeAmpResult({ + keyword: HTTPS_SEARCH_STRING, + title: suggestion.title, + url: suggestion.url, + originalUrl: suggestion.url, + impressionUrl: suggestion.impression_url, + clickUrl: suggestion.click_url, + blockId: suggestion.id, + advertiser: suggestion.advertiser, + }); +} + +add_setup(async function init() { + // Install a default test engine. + let engine = await addTestSuggestionsEngine(); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + const testDataTypeResults = [ + Object.assign({}, REMOTE_SETTINGS_RESULTS[0], { title: "test-data-type" }), + ]; + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + { + type: "test-data-type", + attachment: testDataTypeResults, + }, + ], + }); +}); + +add_task(async function telemetryType_sponsored() { + Assert.equal( + QuickSuggest.getFeature("AdmWikipedia").getSuggestionTelemetryType({ + is_sponsored: true, + }), + "adm_sponsored", + "Telemetry type should be 'adm_sponsored'" + ); +}); + +add_task(async function telemetryType_nonsponsored() { + Assert.equal( + QuickSuggest.getFeature("AdmWikipedia").getSuggestionTelemetryType({ + is_sponsored: false, + }), + "adm_nonsponsored", + "Telemetry type should be 'adm_nonsponsored'" + ); + Assert.equal( + QuickSuggest.getFeature("AdmWikipedia").getSuggestionTelemetryType({}), + "adm_nonsponsored", + "Telemetry type should be 'adm_nonsponsored' if `is_sponsored` not defined" + ); +}); + +// Tests with only non-sponsored suggestions enabled with a matching search +// string. +add_tasks_with_rust(async function nonsponsoredOnly_match() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(NONSPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedNonSponsoredResult()], + }); + + // The title should include the full keyword and em dash, and the part of the + // title that the search string does not match should be highlighted. + let result = context.results[0]; + Assert.equal( + result.title, + `${NONSPONSORED_SEARCH_STRING} — Wikipedia Suggestion`, + "result.title should be correct" + ); + Assert.deepEqual( + result.titleHighlights, + [], + "result.titleHighlights should be correct" + ); +}); + +// Tests with only non-sponsored suggestions enabled with a non-matching search +// string. +add_tasks_with_rust(async function nonsponsoredOnly_noMatch() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ context, matches: [] }); +}); + +// Tests with only sponsored suggestions enabled with a matching search string. +add_tasks_with_rust(async function sponsoredOnly_sponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedSponsoredResult()], + }); + + // The title should include the full keyword and em dash, and the part of the + // title that the search string does not match should be highlighted. + let result = context.results[0]; + Assert.equal( + result.title, + `${SPONSORED_SEARCH_STRING} — Amp Suggestion`, + "result.title should be correct" + ); + Assert.deepEqual( + result.titleHighlights, + [], + "result.titleHighlights should be correct" + ); +}); + +// Tests with only sponsored suggestions enabled with a non-matching search +// string. +add_tasks_with_rust(async function sponsoredOnly_nonsponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(NONSPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ context, matches: [] }); +}); + +// Tests with both sponsored and non-sponsored suggestions enabled with a +// search string that matches the sponsored suggestion. +add_tasks_with_rust(async function both_sponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedSponsoredResult()], + }); +}); + +// Tests with both sponsored and non-sponsored suggestions enabled with a +// search string that matches the non-sponsored suggestion. +add_tasks_with_rust(async function both_nonsponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(NONSPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedNonSponsoredResult()], + }); +}); + +// Tests with both sponsored and non-sponsored suggestions enabled with a +// search string that doesn't match either suggestion. +add_tasks_with_rust(async function both_noMatch() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext("this doesn't match anything", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ context, matches: [] }); +}); + +// Tests with both the main and sponsored prefs disabled with a search string +// that matches the sponsored suggestion. +add_tasks_with_rust(async function neither_sponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ context, matches: [] }); +}); + +// Tests with both the main and sponsored prefs disabled with a search string +// that matches the non-sponsored suggestion. +add_tasks_with_rust(async function neither_nonsponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + let context = createContext(NONSPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ context, matches: [] }); +}); + +// Search string matching should be case insensitive and ignore leading spaces. +add_tasks_with_rust(async function caseInsensitiveAndLeadingSpaces() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(" " + SPONSORED_SEARCH_STRING.toUpperCase(), { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedSponsoredResult()], + }); +}); + +// The provider should not be active for search strings that are empty or +// contain only spaces. +add_tasks_with_rust(async function emptySearchStringsAndSpaces() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + let searchStrings = ["", " ", " ", " "]; + for (let str of searchStrings) { + let msg = JSON.stringify(str) + ` (length = ${str.length})`; + info("Testing search string: " + msg); + + let context = createContext(str, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + Assert.ok( + !UrlbarProviderQuickSuggest.isActive(context), + "Provider should not be active for search string: " + msg + ); + } +}); + +// Results should be returned even when `browser.search.suggest.enabled` is +// false. +add_tasks_with_rust(async function browser_search_suggest_enabled() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("browser.search.suggest.enabled", false); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedSponsoredResult()], + }); + + UrlbarPrefs.clear("browser.search.suggest.enabled"); +}); + +// Results should be returned even when `browser.urlbar.suggest.searches` is +// false. +add_tasks_with_rust(async function browser_search_suggest_enabled() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("suggest.searches", false); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedSponsoredResult()], + }); + + UrlbarPrefs.clear("suggest.searches"); +}); + +// Neither sponsored nor non-sponsored results should appear in private contexts +// even when suggestions in private windows are enabled. +add_tasks_with_rust(async function privateContext() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + for (let privateSuggestionsEnabled of [true, false]) { + UrlbarPrefs.set( + "browser.search.suggest.enabled.private", + privateSuggestionsEnabled + ); + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: true, + }); + await check_results({ + context, + matches: [], + }); + } + + UrlbarPrefs.clear("browser.search.suggest.enabled.private"); +}); + +// When search suggestions come before general results and the only general +// result is a quick suggest result, it should come last. +add_tasks_with_rust(async function suggestionsBeforeGeneral_only() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("browser.search.suggest.enabled", true); + UrlbarPrefs.set("suggest.searches", true); + UrlbarPrefs.set("showSearchSuggestionsFirst", true); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(SPONSORED_SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: SPONSORED_SEARCH_STRING, + engineName: Services.search.defaultEngine.name, + }), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " foo", + engineName: Services.search.defaultEngine.name, + }), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " bar", + engineName: Services.search.defaultEngine.name, + }), + expectedSponsoredResult(), + ], + }); + + UrlbarPrefs.clear("browser.search.suggest.enabled"); + UrlbarPrefs.clear("suggest.searches"); + UrlbarPrefs.clear("showSearchSuggestionsFirst"); +}); + +// When search suggestions come before general results and there are other +// general results besides quick suggest, the quick suggest result should come +// last. +add_tasks_with_rust(async function suggestionsBeforeGeneral_others() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("browser.search.suggest.enabled", true); + UrlbarPrefs.set("suggest.searches", true); + UrlbarPrefs.set("showSearchSuggestionsFirst", true); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(SPONSORED_SEARCH_STRING, { isPrivate: false }); + + // Add some history that will match our query below. + let maxResults = UrlbarPrefs.get("maxRichResults"); + let historyResults = []; + for (let i = 0; i < maxResults; i++) { + let url = "http://example.com/" + SPONSORED_SEARCH_STRING + i; + historyResults.push( + makeVisitResult(context, { + uri: url, + title: "test visit for " + url, + }) + ); + await PlacesTestUtils.addVisits(url); + } + historyResults = historyResults.reverse().slice(0, historyResults.length - 4); + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: SPONSORED_SEARCH_STRING, + engineName: Services.search.defaultEngine.name, + }), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " foo", + engineName: Services.search.defaultEngine.name, + }), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " bar", + engineName: Services.search.defaultEngine.name, + }), + ...historyResults, + expectedSponsoredResult(), + ], + }); + + UrlbarPrefs.clear("browser.search.suggest.enabled"); + UrlbarPrefs.clear("suggest.searches"); + UrlbarPrefs.clear("showSearchSuggestionsFirst"); + await PlacesUtils.history.clear(); +}); + +// When general results come before search suggestions and the only general +// result is a quick suggest result, it should come before suggestions. +add_tasks_with_rust(async function generalBeforeSuggestions_only() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("browser.search.suggest.enabled", true); + UrlbarPrefs.set("suggest.searches", true); + UrlbarPrefs.set("showSearchSuggestionsFirst", false); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(SPONSORED_SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: SPONSORED_SEARCH_STRING, + engineName: Services.search.defaultEngine.name, + }), + expectedSponsoredResult(), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " foo", + engineName: Services.search.defaultEngine.name, + }), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " bar", + engineName: Services.search.defaultEngine.name, + }), + ], + }); + + UrlbarPrefs.clear("browser.search.suggest.enabled"); + UrlbarPrefs.clear("suggest.searches"); + UrlbarPrefs.clear("showSearchSuggestionsFirst"); +}); + +// When general results come before search suggestions and there are other +// general results besides quick suggest, the quick suggest result should be the +// last general result. +add_tasks_with_rust(async function generalBeforeSuggestions_others() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("browser.search.suggest.enabled", true); + UrlbarPrefs.set("suggest.searches", true); + UrlbarPrefs.set("showSearchSuggestionsFirst", false); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(SPONSORED_SEARCH_STRING, { isPrivate: false }); + + // Add some history that will match our query below. + let maxResults = UrlbarPrefs.get("maxRichResults"); + let historyResults = []; + for (let i = 0; i < maxResults; i++) { + let url = "http://example.com/" + SPONSORED_SEARCH_STRING + i; + historyResults.push( + makeVisitResult(context, { + uri: url, + title: "test visit for " + url, + }) + ); + await PlacesTestUtils.addVisits(url); + } + historyResults = historyResults.reverse().slice(0, historyResults.length - 4); + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: SPONSORED_SEARCH_STRING, + engineName: Services.search.defaultEngine.name, + }), + ...historyResults, + expectedSponsoredResult(), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " foo", + engineName: Services.search.defaultEngine.name, + }), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " bar", + engineName: Services.search.defaultEngine.name, + }), + ], + }); + + UrlbarPrefs.clear("browser.search.suggest.enabled"); + UrlbarPrefs.clear("suggest.searches"); + UrlbarPrefs.clear("showSearchSuggestionsFirst"); + await PlacesUtils.history.clear(); +}); + +add_tasks_with_rust(async function dedupeAgainstURL_samePrefix() { + await doDedupeAgainstURLTest({ + searchString: HTTP_SEARCH_STRING, + expectedQuickSuggestResult: expectedHttpResult(), + otherPrefix: "http://", + expectOther: false, + }); +}); + +add_tasks_with_rust(async function dedupeAgainstURL_higherPrefix() { + await doDedupeAgainstURLTest({ + searchString: HTTPS_SEARCH_STRING, + expectedQuickSuggestResult: expectedHttpsResult(), + otherPrefix: "http://", + expectOther: false, + }); +}); + +add_tasks_with_rust(async function dedupeAgainstURL_lowerPrefix() { + await doDedupeAgainstURLTest({ + searchString: HTTP_SEARCH_STRING, + expectedQuickSuggestResult: expectedHttpResult(), + otherPrefix: "https://", + expectOther: true, + }); +}); + +/** + * Tests how the muxer dedupes URL results against quick suggest results. + * Depending on prefix rank, quick suggest results should be preferred over + * other URL results with the same stripped URL: Other results should be + * discarded when their prefix rank is lower than the prefix rank of the quick + * suggest. They should not be discarded when their prefix rank is higher, and + * in that case both results should be included. + * + * This function adds a visit to the URL formed by the given `otherPrefix` and + * `PREFIX_SUGGESTIONS_STRIPPED_URL`. The visit's title will be set to the given + * `searchString` so that both the visit and the quick suggest will match it. + * + * @param {object} options + * Options object. + * @param {string} options.searchString + * The search string that should trigger one of the mock prefix-test quick + * suggest results. + * @param {object} options.expectedQuickSuggestResult + * The expected quick suggest result. + * @param {string} options.otherPrefix + * The visit will be created with a URL with this prefix, e.g., "http://". + * @param {boolean} options.expectOther + * Whether the visit result should appear in the final results. + */ +async function doDedupeAgainstURLTest({ + searchString, + expectedQuickSuggestResult, + otherPrefix, + expectOther, +}) { + // Disable search suggestions. + UrlbarPrefs.set("suggest.searches", false); + + // Add a visit that will match our query below. + let otherURL = otherPrefix + PREFIX_SUGGESTIONS_STRIPPED_URL; + await PlacesTestUtils.addVisits({ uri: otherURL, title: searchString }); + + // First, do a search with quick suggest disabled to make sure the search + // string matches the visit. + info("Doing first query"); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + let context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: searchString, + engineName: Services.search.defaultEngine.name, + }), + makeVisitResult(context, { + uri: otherURL, + title: searchString, + }), + ], + }); + + // Now do another search with quick suggest enabled. + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + context = createContext(searchString, { isPrivate: false }); + + let expectedResults = [ + makeSearchResult(context, { + heuristic: true, + query: searchString, + engineName: Services.search.defaultEngine.name, + }), + ]; + if (expectOther) { + expectedResults.push( + makeVisitResult(context, { + uri: otherURL, + title: searchString, + }) + ); + } + expectedResults.push(expectedQuickSuggestResult); + + info("Doing second query"); + await check_results({ context, matches: expectedResults }); + + UrlbarPrefs.clear("suggest.quicksuggest.nonsponsored"); + UrlbarPrefs.clear("suggest.quicksuggest.sponsored"); + await QuickSuggestTestUtils.forceSync(); + + UrlbarPrefs.clear("suggest.searches"); + await PlacesUtils.history.clear(); +} + +// Tests the remote settings latency histogram. +add_task( + { + // Not supported by the Rust backend. + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function latencyTelemetry() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + let histogram = Services.telemetry.getHistogramById( + TELEMETRY_REMOTE_SETTINGS_LATENCY + ); + histogram.clear(); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedSponsoredResult()], + }); + + // In the latency histogram, there should be a single value across all + // buckets. + Assert.deepEqual( + Object.values(histogram.snapshot().values).filter(v => v > 0), + [1], + "Latency histogram updated after search" + ); + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_REMOTE_SETTINGS_LATENCY, context), + "Stopwatch not running after search" + ); + } +); + +// Tests setup and teardown of the remote settings client depending on whether +// quick suggest is enabled. +add_task( + { + // Not supported by the Rust backend. + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function setupAndTeardown() { + Assert.ok( + QuickSuggest.jsBackend.isEnabled, + "Remote settings backend is enabled initially" + ); + + // Disable the suggest prefs so the settings client starts out torn down. + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + Assert.ok( + !QuickSuggest.jsBackend.rs, + "Settings client is null after disabling suggest prefs" + ); + Assert.ok( + QuickSuggest.jsBackend.isEnabled, + "Remote settings backend remains enabled" + ); + + // Setting one of the suggest prefs should cause the client to be set up. We + // assume all previous tasks left `quicksuggest.enabled` true (from the init + // task). + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + Assert.ok( + QuickSuggest.jsBackend.rs, + "Settings client is non-null after enabling suggest.quicksuggest.nonsponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + Assert.ok( + !QuickSuggest.jsBackend.rs, + "Settings client is null after disabling suggest.quicksuggest.nonsponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + Assert.ok( + QuickSuggest.jsBackend.rs, + "Settings client is non-null after enabling suggest.quicksuggest.sponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + Assert.ok( + QuickSuggest.jsBackend.rs, + "Settings client remains non-null after enabling suggest.quicksuggest.nonsponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + Assert.ok( + QuickSuggest.jsBackend.rs, + "Settings client remains non-null after disabling suggest.quicksuggest.nonsponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + Assert.ok( + !QuickSuggest.jsBackend.rs, + "Settings client is null after disabling suggest.quicksuggest.sponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + Assert.ok( + QuickSuggest.jsBackend.rs, + "Settings client is non-null after enabling suggest.quicksuggest.nonsponsored" + ); + + UrlbarPrefs.set("quicksuggest.enabled", false); + Assert.ok( + !QuickSuggest.jsBackend.rs, + "Settings client is null after disabling quicksuggest.enabled" + ); + + UrlbarPrefs.set("quicksuggest.enabled", true); + Assert.ok( + QuickSuggest.jsBackend.rs, + "Settings client is non-null after re-enabling quicksuggest.enabled" + ); + Assert.ok( + QuickSuggest.jsBackend.isEnabled, + "Remote settings backend is enabled after re-enabling quicksuggest.enabled" + ); + + UrlbarPrefs.set("quicksuggest.rustEnabled", true); + Assert.ok( + !QuickSuggest.jsBackend.rs, + "Settings client is null after enabling the Rust backend" + ); + Assert.ok( + !QuickSuggest.jsBackend.isEnabled, + "Remote settings backend is disabled after enabling the Rust backend" + ); + + UrlbarPrefs.clear("quicksuggest.rustEnabled"); + Assert.ok( + QuickSuggest.jsBackend.rs, + "Settings client is non-null after disabling the Rust backend" + ); + Assert.ok( + QuickSuggest.jsBackend.isEnabled, + "Remote settings backend is enabled after disabling the Rust backend" + ); + + // Leave the prefs in the same state as when the task started. + UrlbarPrefs.clear("suggest.quicksuggest.nonsponsored"); + UrlbarPrefs.clear("suggest.quicksuggest.sponsored"); + UrlbarPrefs.set("quicksuggest.enabled", true); + Assert.ok( + !QuickSuggest.jsBackend.rs, + "Settings client remains null at end of task" + ); + } +); + +// Timestamp templates in URLs should be replaced with real timestamps. +add_tasks_with_rust(async function timestamps() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + // Do a search. + let context = createContext(TIMESTAMP_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + let controller = UrlbarTestUtils.newMockController(); + await controller.startQuery(context); + + // Should be one quick suggest result. + Assert.equal(context.results.length, 1, "One result returned"); + let result = context.results[0]; + + QuickSuggestTestUtils.assertTimestampsReplaced(result, { + url: TIMESTAMP_SUGGESTION_URL, + sponsoredClickUrl: TIMESTAMP_SUGGESTION_CLICK_URL, + }); +}); + +// Real quick suggest URLs include a timestamp template that +// UrlbarProviderQuickSuggest fills in when it fetches suggestions. When the +// user picks a quick suggest, its URL with its particular timestamp is added to +// history. If the user triggers the quick suggest again later, its new +// timestamp may be different from the one in the user's history. In that case, +// the two URLs should be treated as dupes and only the quick suggest should be +// shown, not the URL from history. +add_tasks_with_rust(async function dedupeAgainstURL_timestamps() { + // Disable search suggestions. + UrlbarPrefs.set("suggest.searches", false); + + // Add a visit that will match the query below and dupe the quick suggest. + let dupeURL = TIMESTAMP_SUGGESTION_URL.replace( + TIMESTAMP_TEMPLATE, + "2013051113" + ); + + // Add other visits that will match the query and almost dupe the quick + // suggest but not quite because they have invalid timestamps. + let badTimestamps = [ + // not numeric digits + "x".repeat(TIMESTAMP_LENGTH), + // too few digits + "5".repeat(TIMESTAMP_LENGTH - 1), + // empty string, too few digits + "", + ]; + let badTimestampURLs = badTimestamps.map(str => + TIMESTAMP_SUGGESTION_URL.replace(TIMESTAMP_TEMPLATE, str) + ); + + await PlacesTestUtils.addVisits( + [dupeURL, ...badTimestampURLs].map(uri => ({ + uri, + title: TIMESTAMP_SEARCH_STRING, + })) + ); + + // First, do a search with quick suggest disabled to make sure the search + // string matches all the other URLs. + info("Doing first query"); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + let context = createContext(TIMESTAMP_SEARCH_STRING, { isPrivate: false }); + + let expectedHeuristic = makeSearchResult(context, { + heuristic: true, + query: TIMESTAMP_SEARCH_STRING, + engineName: Services.search.defaultEngine.name, + }); + let expectedDupeResult = makeVisitResult(context, { + uri: dupeURL, + title: TIMESTAMP_SEARCH_STRING, + }); + let expectedBadTimestampResults = [...badTimestampURLs].reverse().map(uri => + makeVisitResult(context, { + uri, + title: TIMESTAMP_SEARCH_STRING, + }) + ); + + await check_results({ + context, + matches: [ + expectedHeuristic, + ...expectedBadTimestampResults, + expectedDupeResult, + ], + }); + + // Now do another search with quick suggest enabled. + info("Doing second query"); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + context = createContext(TIMESTAMP_SEARCH_STRING, { isPrivate: false }); + + let expectedQuickSuggest = makeAmpResult({ + originalUrl: TIMESTAMP_SUGGESTION_URL, + keyword: TIMESTAMP_SEARCH_STRING, + title: "Timestamp suggestion", + impressionUrl: "http://impression.reporting.test.com/timestamp", + blockId: 5, + advertiser: "TestAdvertiserTimestamp", + iabCategory: "22 - Shopping", + }); + + let expectedResults = [ + expectedHeuristic, + ...expectedBadTimestampResults, + expectedQuickSuggest, + ]; + + let controller = UrlbarTestUtils.newMockController(); + await controller.startQuery(context); + info("Actual results: " + JSON.stringify(context.results)); + + Assert.equal( + context.results.length, + expectedResults.length, + "Found the expected number of results" + ); + + function getPayload(result, keysToIgnore = []) { + let payload = {}; + for (let [key, value] of Object.entries(result.payload)) { + if (value !== undefined && !keysToIgnore.includes(key)) { + payload[key] = value; + } + } + return payload; + } + + // Check actual vs. expected result properties. + for (let i = 0; i < expectedResults.length; i++) { + let actual = context.results[i]; + let expected = expectedResults[i]; + info( + `Comparing results at index ${i}:` + + " actual=" + + JSON.stringify(actual) + + " expected=" + + JSON.stringify(expected) + ); + Assert.equal( + actual.type, + expected.type, + `result.type at result index ${i}` + ); + Assert.equal( + actual.source, + expected.source, + `result.source at result index ${i}` + ); + Assert.equal( + actual.heuristic, + expected.heuristic, + `result.heuristic at result index ${i}` + ); + + // Check payloads except for the last result, which should be the quick + // suggest. + if (i != expectedResults.length - 1) { + Assert.deepEqual( + getPayload(context.results[i]), + getPayload(expectedResults[i]), + "Payload at index " + i + ); + } + } + + // Check the quick suggest's payload excluding the timestamp-related + // properties. + let actualQuickSuggest = context.results[context.results.length - 1]; + let timestampKeys = [ + "displayUrl", + "sponsoredClickUrl", + "url", + "urlTimestampIndex", + ]; + Assert.deepEqual( + getPayload(actualQuickSuggest, timestampKeys), + getPayload(expectedQuickSuggest, timestampKeys), + "Quick suggest payload excluding timestamp-related keys" + ); + + // Now check the timestamps in the payload. + QuickSuggestTestUtils.assertTimestampsReplaced(actualQuickSuggest, { + url: TIMESTAMP_SUGGESTION_URL, + sponsoredClickUrl: TIMESTAMP_SUGGESTION_CLICK_URL, + }); + + // Clean up. + UrlbarPrefs.clear("suggest.quicksuggest.nonsponsored"); + UrlbarPrefs.clear("suggest.quicksuggest.sponsored"); + await QuickSuggestTestUtils.forceSync(); + + UrlbarPrefs.clear("suggest.searches"); + await PlacesUtils.history.clear(); +}); + +// Tests the API for blocking suggestions and the backing pref. +add_task(async function blockedSuggestionsAPI() { + // Start with no blocked suggestions. + await QuickSuggest.blockedSuggestions.clear(); + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + 0, + "blockedSuggestions._test_digests is empty" + ); + Assert.equal( + UrlbarPrefs.get("quicksuggest.blockedDigests"), + "", + "quicksuggest.blockedDigests is an empty string" + ); + + // Make some URLs. + let urls = []; + for (let i = 0; i < 3; i++) { + urls.push("http://example.com/" + i); + } + + // Block each URL in turn and make sure previously blocked URLs are still + // blocked and the remaining URLs are not blocked. + for (let i = 0; i < urls.length; i++) { + await QuickSuggest.blockedSuggestions.add(urls[i]); + for (let j = 0; j < urls.length; j++) { + Assert.equal( + await QuickSuggest.blockedSuggestions.has(urls[j]), + j <= i, + `Suggestion at index ${j} is blocked or not as expected` + ); + } + } + + // Make sure all URLs are blocked for good measure. + for (let url of urls) { + Assert.ok( + await QuickSuggest.blockedSuggestions.has(url), + `Suggestion is blocked: ${url}` + ); + } + + // Check `blockedSuggestions._test_digests` and `quicksuggest.blockedDigests`. + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + urls.length, + "blockedSuggestions._test_digests has correct size" + ); + let array = JSON.parse(UrlbarPrefs.get("quicksuggest.blockedDigests")); + Assert.ok(Array.isArray(array), "Parsed value of pref is an array"); + Assert.equal(array.length, urls.length, "Array has correct length"); + + // Write some junk to `quicksuggest.blockedDigests`. + // `blockedSuggestions._test_digests` should not be changed and all previously + // blocked URLs should remain blocked. + UrlbarPrefs.set("quicksuggest.blockedDigests", "not a json array"); + await QuickSuggest.blockedSuggestions._test_readyPromise; + for (let url of urls) { + Assert.ok( + await QuickSuggest.blockedSuggestions.has(url), + `Suggestion remains blocked: ${url}` + ); + } + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + urls.length, + "blockedSuggestions._test_digests still has correct size" + ); + + // Block a new URL. All URLs should remain blocked and the pref should be + // updated. + let newURL = "http://example.com/new-block"; + await QuickSuggest.blockedSuggestions.add(newURL); + urls.push(newURL); + for (let url of urls) { + Assert.ok( + await QuickSuggest.blockedSuggestions.has(url), + `Suggestion is blocked: ${url}` + ); + } + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + urls.length, + "blockedSuggestions._test_digests has correct size" + ); + array = JSON.parse(UrlbarPrefs.get("quicksuggest.blockedDigests")); + Assert.ok(Array.isArray(array), "Parsed value of pref is an array"); + Assert.equal(array.length, urls.length, "Array has correct length"); + + // Add a new URL digest directly to the JSON'ed array in the pref. + newURL = "http://example.com/direct-to-pref"; + urls.push(newURL); + array = JSON.parse(UrlbarPrefs.get("quicksuggest.blockedDigests")); + array.push(await QuickSuggest.blockedSuggestions._test_getDigest(newURL)); + UrlbarPrefs.set("quicksuggest.blockedDigests", JSON.stringify(array)); + await QuickSuggest.blockedSuggestions._test_readyPromise; + + // All URLs should remain blocked and the new URL should be blocked. + for (let url of urls) { + Assert.ok( + await QuickSuggest.blockedSuggestions.has(url), + `Suggestion is blocked: ${url}` + ); + } + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + urls.length, + "blockedSuggestions._test_digests has correct size" + ); + + // Clear the pref. All URLs should be unblocked. + UrlbarPrefs.clear("quicksuggest.blockedDigests"); + await QuickSuggest.blockedSuggestions._test_readyPromise; + for (let url of urls) { + Assert.ok( + !(await QuickSuggest.blockedSuggestions.has(url)), + `Suggestion is no longer blocked: ${url}` + ); + } + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + 0, + "blockedSuggestions._test_digests is now empty" + ); + + // Block all the URLs again and test `blockedSuggestions.clear()`. + for (let url of urls) { + await QuickSuggest.blockedSuggestions.add(url); + } + for (let url of urls) { + Assert.ok( + await QuickSuggest.blockedSuggestions.has(url), + `Suggestion is blocked: ${url}` + ); + } + await QuickSuggest.blockedSuggestions.clear(); + for (let url of urls) { + Assert.ok( + !(await QuickSuggest.blockedSuggestions.has(url)), + `Suggestion is no longer blocked: ${url}` + ); + } + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + 0, + "blockedSuggestions._test_digests is now empty" + ); +}); + +// Tests blocking real `UrlbarResult`s. +add_tasks_with_rust(async function block() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + let tests = [ + // [suggestion, expected result] + [REMOTE_SETTINGS_RESULTS[0], expectedSponsoredResult()], + [REMOTE_SETTINGS_RESULTS[1], expectedNonSponsoredResult()], + [REMOTE_SETTINGS_RESULTS[2], expectedHttpResult()], + [REMOTE_SETTINGS_RESULTS[3], expectedHttpsResult()], + ]; + + for (let [suggestion, expectedResult] of tests) { + info("Testing suggestion: " + JSON.stringify(suggestion)); + + // Do a search to get a real `UrlbarResult` created for the suggestion. + let context = createContext(suggestion.keywords[0], { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedResult], + }); + + // Block it. + await QuickSuggest.blockedSuggestions.add(context.results[0].payload.url); + + // Do another search. The result shouldn't be added. + await check_results({ + context: createContext(suggestion.keywords[0], { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + await QuickSuggest.blockedSuggestions.clear(); + } +}); + +// Tests blocking a real `UrlbarResult` whose URL has a timestamp template. +add_tasks_with_rust(async function block_timestamp() { + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + await QuickSuggestTestUtils.forceSync(); + + // Do a search. + let context = createContext(TIMESTAMP_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + let controller = UrlbarTestUtils.newMockController(); + await controller.startQuery(context); + + // Should be one quick suggest result. + Assert.equal(context.results.length, 1, "One result returned"); + let result = context.results[0]; + + QuickSuggestTestUtils.assertTimestampsReplaced(result, { + url: TIMESTAMP_SUGGESTION_URL, + sponsoredClickUrl: TIMESTAMP_SUGGESTION_CLICK_URL, + }); + + Assert.ok(result.payload.originalUrl, "The actual result has an originalUrl"); + Assert.equal( + result.payload.originalUrl, + REMOTE_SETTINGS_RESULTS[4].url, + "The actual result's originalUrl should be the raw suggestion URL with a timestamp template" + ); + + // Block the result. + await QuickSuggest.blockedSuggestions.add(result.payload.originalUrl); + + // Do another search. The result shouldn't be added. + await check_results({ + context: createContext(TIMESTAMP_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + await QuickSuggest.blockedSuggestions.clear(); +}); + +// Makes sure remote settings data is fetched using the correct `type` based on +// the value of the `quickSuggestRemoteSettingsDataType` Nimbus variable. +add_task( + { + // Not supported by the Rust backend. + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function remoteSettingsDataType() { + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + await QuickSuggestTestUtils.forceSync(); + + for (let dataType of [undefined, "test-data-type"]) { + // Set up a mock Nimbus rollout with the data type. + let value = {}; + if (dataType) { + value.quickSuggestRemoteSettingsDataType = dataType; + } + let cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature(value); + + // Make the result for test data type. + let expected = expectedSponsoredResult(); + if (dataType) { + expected = JSON.parse(JSON.stringify(expected)); + expected.payload.title = dataType; + } + + // Re-sync. + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expected], + }); + + await cleanUpNimbus(); + } + } +); + +add_tasks_with_rust(async function sponsoredPriority_normal() { + await doSponsoredPriorityTest({ + searchWord: SPONSORED_SEARCH_STRING, + remoteSettingsData: [REMOTE_SETTINGS_RESULTS[0]], + expectedMatches: [expectedSponsoredPriorityResult()], + }); +}); + +add_tasks_with_rust(async function sponsoredPriority_nonsponsoredSuggestion() { + // Not affect to except sponsored suggestion. + await doSponsoredPriorityTest({ + searchWord: NONSPONSORED_SEARCH_STRING, + remoteSettingsData: [REMOTE_SETTINGS_RESULTS[1]], + expectedMatches: [expectedNonSponsoredResult()], + }); +}); + +add_tasks_with_rust(async function sponsoredPriority_sponsoredIndex() { + await doSponsoredPriorityTest({ + nimbusSettings: { quickSuggestSponsoredIndex: 2 }, + searchWord: SPONSORED_SEARCH_STRING, + remoteSettingsData: [REMOTE_SETTINGS_RESULTS[0]], + expectedMatches: [expectedSponsoredPriorityResult()], + }); +}); + +add_tasks_with_rust(async function sponsoredPriority_position() { + await doSponsoredPriorityTest({ + nimbusSettings: { quickSuggestAllowPositionInSuggestions: true }, + searchWord: SPONSORED_SEARCH_STRING, + remoteSettingsData: [ + Object.assign({}, REMOTE_SETTINGS_RESULTS[0], { position: 2 }), + ], + expectedMatches: [expectedSponsoredPriorityResult()], + }); +}); + +async function doSponsoredPriorityTest({ + remoteSettingsConfig = {}, + nimbusSettings = {}, + searchWord, + remoteSettingsData, + expectedMatches, +}) { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature({ + ...nimbusSettings, + quickSuggestSponsoredPriority: true, + }); + + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "data", + attachment: remoteSettingsData, + }, + ]); + await QuickSuggestTestUtils.setConfig(remoteSettingsConfig); + + await check_results({ + context: createContext(searchWord, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: expectedMatches, + }); + + await cleanUpNimbusEnable(); +} + +// When a Suggest best match and a tab-to-search (TTS) are shown in the same +// search, both will have a `suggestedIndex` value of 1. The TTS should appear +// first. +add_tasks_with_rust(async function tabToSearch() { + // We'll use a sponsored priority result as the best match result. Different + // types of Suggest results can appear as best matches, and they all should + // have the same behavior. + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + Services.prefs.setBoolPref( + "browser.urlbar.quicksuggest.sponsoredPriority", + true + ); + + // Disable tab-to-search onboarding results so we get a regular TTS result, + // which we can test a little more easily with `makeSearchResult()`. + UrlbarPrefs.set("tabToSearch.onboard.interactionsLeft", 0); + + // Disable search suggestions so we don't need to expect them below. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + // Install a test engine. The main part of its domain name needs to match the + // best match result too so we can trigger both its TTS and the best match. + let engineURL = `https://foo.${SPONSORED_SEARCH_STRING}.com/`; + let extension = await SearchTestUtils.installSearchExtension( + { + name: "Test", + search_url: engineURL, + }, + { skipUnload: true } + ); + let engine = Services.search.getEngineByName("Test"); + + // Also need to add a visit to trigger TTS. + await PlacesTestUtils.addVisits(engineURL); + + let context = createContext(SPONSORED_SEARCH_STRING, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + // search heuristic + makeSearchResult(context, { + engineName: Services.search.defaultEngine.name, + engineIconUri: Services.search.defaultEngine.getIconURL(), + heuristic: true, + }), + // tab to search + makeSearchResult(context, { + engineName: engine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost(engine.searchUrlDomain), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + satisfiesAutofillThreshold: true, + }), + // Suggest best match + expectedSponsoredPriorityResult(), + // visit + makeVisitResult(context, { + uri: engineURL, + title: `test visit for ${engineURL}`, + }), + ], + }); + + await cleanupPlaces(); + await extension.unload(); + + UrlbarPrefs.clear("tabToSearch.onboard.interactionsLeft"); + Services.prefs.clearUserPref("browser.search.suggest.enabled"); + Services.prefs.clearUserPref("browser.urlbar.quicksuggest.sponsoredPriority"); +}); + +// `suggestion.position` should be ignored when the suggestion is a best match. +add_tasks_with_rust(async function position() { + // We'll use a sponsored priority result as the best match result. Different + // types of Suggest results can appear as best matches, and they all should + // have the same behavior. + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + Services.prefs.setBoolPref( + "browser.urlbar.quicksuggest.sponsoredPriority", + true + ); + + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + // Set the remote settings data with a suggestion containing a position. + UrlbarPrefs.set("quicksuggest.allowPositionInSuggestions", true); + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "data", + attachment: [ + { + ...REMOTE_SETTINGS_RESULTS[0], + position: 9, + }, + ], + }, + ]); + + let context = createContext(SPONSORED_SEARCH_STRING, { + isPrivate: false, + }); + + // Add some visits to fill up the view. + let maxResultCount = UrlbarPrefs.get("maxRichResults"); + let visitResults = []; + for (let i = 0; i < maxResultCount; i++) { + let url = `http://example.com/${SPONSORED_SEARCH_STRING}-${i}`; + await PlacesTestUtils.addVisits(url); + visitResults.unshift( + makeVisitResult(context, { + uri: url, + title: `test visit for ${url}`, + }) + ); + } + + // Do a search. + await check_results({ + context, + matches: [ + // search heuristic + makeSearchResult(context, { + engineName: Services.search.defaultEngine.name, + engineIconUri: Services.search.defaultEngine.getIconURL(), + heuristic: true, + }), + // best match whose backing suggestion has a `position` + expectedSponsoredPriorityResult(), + // visits + ...visitResults.slice(0, maxResultCount - 2), + ], + }); + + await cleanupPlaces(); + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ]); + + UrlbarPrefs.clear("quicksuggest.allowPositionInSuggestions"); + Services.prefs.clearUserPref("browser.search.suggest.enabled"); + Services.prefs.clearUserPref("browser.urlbar.quicksuggest.sponsoredPriority"); +}); + +// The `Amp` and `Wikipedia` Rust providers should be passed to the Rust +// component when querying depending on whether sponsored and non-sponsored +// suggestions are enabled. +add_task(async function rustProviders() { + await doRustProvidersTests({ + searchString: SPONSORED_AND_NONSPONSORED_SEARCH_STRING, + tests: [ + { + prefs: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + expectedUrls: [ + "http://example.com/amp", + "http://example.com/wikipedia", + ], + }, + { + prefs: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": false, + }, + expectedUrls: ["http://example.com/wikipedia"], + }, + { + prefs: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": true, + }, + expectedUrls: ["http://example.com/amp"], + }, + { + prefs: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + expectedUrls: [], + }, + ], + }); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_addons.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_addons.js new file mode 100644 index 0000000000..c17f3f1655 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_addons.js @@ -0,0 +1,558 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Tests addon quick suggest results. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs", +}); + +// TODO: Firefox no longer uses `rating` and `number_of_ratings` but they are +// still present in Merino and RS suggestions, so they are included here for +// greater accuracy. We should remove them from Merino, RS, and tests. +const MERINO_SUGGESTIONS = [ + { + provider: "amo", + icon: "icon", + url: "https://example.com/merino-addon", + title: "title", + description: "description", + is_top_pick: true, + custom_details: { + amo: { + rating: "5", + number_of_ratings: "1234567", + guid: "test@addon", + }, + }, + }, +]; + +const REMOTE_SETTINGS_RESULTS = [ + { + type: "amo-suggestions", + attachment: [ + { + url: "https://example.com/first-addon", + guid: "first@addon", + icon: "https://example.com/first-addon.svg", + title: "First Addon", + rating: "4.7", + keywords: ["first", "1st", "two words", "a b c"], + description: "Description for the First Addon", + number_of_ratings: 1256, + score: 0.25, + }, + { + url: "https://example.com/second-addon", + guid: "second@addon", + icon: "https://example.com/second-addon.svg", + title: "Second Addon", + rating: "1.7", + keywords: ["second", "2nd"], + description: "Description for the Second Addon", + number_of_ratings: 256, + score: 0.25, + }, + { + url: "https://example.com/third-addon", + guid: "third@addon", + icon: "https://example.com/third-addon.svg", + title: "Third Addon", + rating: "3.7", + keywords: ["third", "3rd"], + description: "Description for the Third Addon", + number_of_ratings: 3, + score: 0.25, + }, + { + url: "https://example.com/fourth-addon?utm_medium=aaa&utm_source=bbb", + guid: "fourth@addon", + icon: "https://example.com/fourth-addon.svg", + title: "Fourth Addon", + rating: "4.7", + keywords: ["fourth", "4th"], + description: "Description for the Fourth Addon", + number_of_ratings: 4, + score: 0.25, + }, + ], + }, +]; + +add_setup(async function init() { + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: REMOTE_SETTINGS_RESULTS, + merinoSuggestions: MERINO_SUGGESTIONS, + prefs: [["suggest.quicksuggest.nonsponsored", true]], + }); +}); + +add_task(async function telemetryType() { + Assert.equal( + QuickSuggest.getFeature("AddonSuggestions").getSuggestionTelemetryType({}), + "amo", + "Telemetry type should be 'amo'" + ); +}); + +// When quick suggest prefs are disabled, addon suggestions should be disabled. +add_tasks_with_rust(async function quickSuggestPrefsDisabled() { + let prefs = ["quicksuggest.enabled", "suggest.quicksuggest.nonsponsored"]; + for (let pref of prefs) { + // Before disabling the pref, first make sure the suggestion is added. + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + suggestion: MERINO_SUGGESTIONS[0], + source: "merino", + }), + ], + }); + + // Now disable the pref. + UrlbarPrefs.set(pref, false); + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + UrlbarPrefs.set(pref, true); + await QuickSuggestTestUtils.forceSync(); + } +}); + +// When addon suggestions specific preference is disabled, addon suggestions +// should not be added. +add_tasks_with_rust(async function addonSuggestionsSpecificPrefDisabled() { + const prefs = ["suggest.addons", "addons.featureGate"]; + for (const pref of prefs) { + // First make sure the suggestion is added. + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + suggestion: MERINO_SUGGESTIONS[0], + source: "merino", + }), + ], + }); + + // Now disable the pref. + UrlbarPrefs.set(pref, false); + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + // Revert. + UrlbarPrefs.clear(pref); + await QuickSuggestTestUtils.forceSync(); + } +}); + +// Check wheather the addon suggestions will be shown by the setup of Nimbus +// variable. +add_tasks_with_rust(async function nimbus() { + // Disable the fature gate. + UrlbarPrefs.set("addons.featureGate", false); + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + // Enable by Nimbus. + const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature({ + addonsFeatureGate: true, + }); + await QuickSuggestTestUtils.forceSync(); + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + suggestion: MERINO_SUGGESTIONS[0], + source: "merino", + }), + ], + }); + await cleanUpNimbusEnable(); + + // Enable locally. + UrlbarPrefs.set("addons.featureGate", true); + await QuickSuggestTestUtils.forceSync(); + + // Disable by Nimbus. + const cleanUpNimbusDisable = await UrlbarTestUtils.initNimbusFeature({ + addonsFeatureGate: false, + }); + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + await cleanUpNimbusDisable(); + + // Revert. + UrlbarPrefs.clear("addons.featureGate"); + await QuickSuggestTestUtils.forceSync(); +}); + +add_tasks_with_rust(async function hideIfAlreadyInstalled() { + // Show suggestion. + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + suggestion: MERINO_SUGGESTIONS[0], + source: "merino", + }), + ], + }); + + // Install an addon for the suggestion. + const xpi = ExtensionTestCommon.generateXPI({ + manifest: { + browser_specific_settings: { + gecko: { id: "test@addon" }, + }, + }, + }); + const addon = await AddonManager.installTemporaryAddon(xpi); + + // Show suggestion for the addon installed. + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + await addon.uninstall(); + xpi.remove(false); +}); + +add_tasks_with_rust(async function remoteSettings() { + const testCases = [ + { + input: "f", + expected: null, + }, + { + input: "fi", + expected: null, + }, + { + input: "fir", + expected: null, + }, + { + input: "firs", + expected: null, + }, + { + input: "first", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "1st", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "t", + expected: null, + }, + { + input: "tw", + expected: null, + }, + { + input: "two", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "two ", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "two w", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "two wo", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "two wor", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "two word", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "two words", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "a", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "a ", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "a b", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "a b ", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "a b c", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "second", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[1], + source: "remote-settings", + }), + }, + { + input: "2nd", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[1], + source: "remote-settings", + }), + }, + { + input: "third", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[2], + source: "remote-settings", + }), + }, + { + input: "3rd", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[2], + source: "remote-settings", + }), + }, + { + input: "fourth", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[3], + source: "remote-settings", + setUtmParams: false, + }), + }, + { + input: "FoUrTh", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[3], + source: "remote-settings", + setUtmParams: false, + }), + }, + ]; + + // Disable Merino so we trigger only remote settings suggestions. + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", false); + + for (let { input, expected } of testCases) { + await check_results({ + context: createContext(input, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: expected ? [expected] : [], + }); + } + + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true); +}); + +add_task(async function merinoIsTopPick() { + const suggestion = JSON.parse(JSON.stringify(MERINO_SUGGESTIONS[0])); + + // is_top_pick is specified as false. + suggestion.is_top_pick = false; + MerinoTestUtils.server.response.body.suggestions = [suggestion]; + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + suggestion, + source: "merino", + }), + ], + }); + + // is_top_pick is undefined. + delete suggestion.is_top_pick; + MerinoTestUtils.server.response.body.suggestions = [suggestion]; + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + suggestion, + source: "merino", + }), + ], + }); +}); + +// Tests the "show less frequently" behavior. +add_tasks_with_rust(async function showLessFrequently() { + await doShowLessFrequentlyTests({ + feature: QuickSuggest.getFeature("AddonSuggestions"), + showLessFrequentlyCountPref: "addons.showLessFrequentlyCount", + nimbusCapVariable: "addonsShowLessFrequentlyCap", + expectedResult: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + keyword: "two words", + }); +}); + +// The `Amo` Rust provider should be passed to the Rust component when querying +// depending on whether addon suggestions are enabled. +add_task(async function rustProviders() { + await doRustProvidersTests({ + searchString: "first", + tests: [ + { + prefs: { + "suggest.addons": true, + }, + expectedUrls: ["https://example.com/first-addon"], + }, + { + prefs: { + "suggest.addons": false, + }, + expectedUrls: [], + }, + ], + }); + + UrlbarPrefs.clear("suggest.addons"); + await QuickSuggestTestUtils.forceSync(); +}); + +function makeExpectedResult({ suggestion, source, setUtmParams = true }) { + if ( + source == "remote-settings" && + UrlbarPrefs.get("quicksuggest.rustEnabled") + ) { + source = "rust"; + } + + let provider; + switch (source) { + case "remote-settings": + provider = "AddonSuggestions"; + break; + case "rust": + provider = "Amo"; + break; + case "merino": + provider = "amo"; + break; + } + + return makeAmoResult({ + source, + provider, + setUtmParams, + title: suggestion.title, + description: suggestion.description, + url: suggestion.url, + originalUrl: suggestion.url, + icon: suggestion.icon, + }); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js new file mode 100644 index 0000000000..a9f339c324 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js @@ -0,0 +1,103 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Tests dynamic Wikipedia quick suggest results. + +"use strict"; + +const MERINO_SUGGESTIONS = [ + { + title: "title", + url: "url", + is_sponsored: false, + score: 0.23, + description: "description", + icon: "icon", + full_keyword: "full_keyword", + advertiser: "dynamic-wikipedia", + block_id: 0, + impression_url: "impression_url", + click_url: "click_url", + provider: "wikipedia", + }, +]; + +add_setup(async function init() { + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + merinoSuggestions: MERINO_SUGGESTIONS, + prefs: [["suggest.quicksuggest.nonsponsored", true]], + }); +}); + +// When non-sponsored suggestions are disabled, dynamic Wikipedia suggestions +// should be disabled. +add_task(async function nonsponsoredDisabled() { + // Disable sponsored suggestions. Dynamic Wikipedia suggestions are + // non-sponsored, so doing this should not prevent them from being enabled. + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + // First make sure the suggestion is added when non-sponsored suggestions are + // enabled. + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [makeExpectedResult()], + }); + + // Now disable them. + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.clear("suggest.quicksuggest.sponsored"); +}); + +add_task(async function mixedCaseQuery() { + await check_results({ + context: createContext("TeSt", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [makeExpectedResult()], + }); +}); + +function makeExpectedResult() { + return { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + suggestedIndex: -1, + payload: { + telemetryType: "wikipedia", + title: "title", + url: "url", + displayUrl: "url", + isSponsored: false, + icon: "icon", + 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", + }, + }, + }; +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js new file mode 100644 index 0000000000..1c00cb5320 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js @@ -0,0 +1,3907 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Tests impression frequency capping for quick suggest results. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +const REMOTE_SETTINGS_RESULTS = [ + { + id: 1, + url: "http://example.com/sponsored", + title: "Sponsored suggestion", + keywords: ["sponsored"], + click_url: "http://example.com/click", + impression_url: "http://example.com/impression", + advertiser: "TestAdvertiser", + iab_category: "22 - Shopping", + }, + { + id: 2, + url: "http://example.com/nonsponsored", + title: "Non-sponsored suggestion", + keywords: ["nonsponsored"], + click_url: "http://example.com/click", + impression_url: "http://example.com/impression", + advertiser: "TestAdvertiser", + iab_category: "5 - Education", + }, +]; + +const EXPECTED_SPONSORED_URLBAR_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_sponsored", + url: "http://example.com/sponsored", + originalUrl: "http://example.com/sponsored", + displayUrl: "http://example.com/sponsored", + title: "Sponsored suggestion", + qsSuggestion: "sponsored", + icon: null, + isSponsored: true, + sponsoredImpressionUrl: "http://example.com/impression", + sponsoredClickUrl: "http://example.com/click", + sponsoredBlockId: 1, + sponsoredAdvertiser: "TestAdvertiser", + sponsoredIabCategory: "22 - Shopping", + descriptionL10n: { id: "urlbar-result-action-sponsored" }, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + source: "remote-settings", + provider: "AdmWikipedia", + }, +}; + +const EXPECTED_NONSPONSORED_URLBAR_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_nonsponsored", + url: "http://example.com/nonsponsored", + originalUrl: "http://example.com/nonsponsored", + displayUrl: "http://example.com/nonsponsored", + title: "Non-sponsored suggestion", + qsSuggestion: "nonsponsored", + icon: null, + isSponsored: false, + sponsoredImpressionUrl: "http://example.com/impression", + sponsoredClickUrl: "http://example.com/click", + sponsoredBlockId: 2, + sponsoredAdvertiser: "TestAdvertiser", + sponsoredIabCategory: "5 - Education", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + source: "remote-settings", + provider: "AdmWikipedia", + }, +}; + +let gSandbox; +let gDateNowStub; +let gStartupDateMsStub; + +add_setup(async () => { + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + prefs: [ + ["quicksuggest.impressionCaps.sponsoredEnabled", true], + ["quicksuggest.impressionCaps.nonSponsoredEnabled", true], + ["suggest.quicksuggest.nonsponsored", true], + ["suggest.quicksuggest.sponsored", true], + ], + }); + + // Set up a sinon stub for the `Date.now()` implementation inside of + // UrlbarProviderQuickSuggest. This lets us test searches performed at + // specific times. See `doTimedCallbacks()` for more info. + gSandbox = sinon.createSandbox(); + gDateNowStub = gSandbox.stub( + Cu.getGlobalForObject(UrlbarProviderQuickSuggest).Date, + "now" + ); + + // Set up a sinon stub for `UrlbarProviderQuickSuggest._getStartupDateMs()` to + // let the test override the startup date. + gStartupDateMsStub = gSandbox.stub( + QuickSuggest.impressionCaps, + "_getStartupDateMs" + ); + gStartupDateMsStub.returns(0); +}); + +// Tests a single interval. +add_task(async function oneInterval() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 1 }], + }, + }, + }, + callback: async () => { + await doTimedSearches("sponsored", { + 0: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "3", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + 1: { + results: [[]], + }, + 2: { + results: [[]], + }, + 3: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "1", + startDate: "3000", + impressionDate: "3000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + 4: { + results: [[]], + }, + 5: { + results: [[]], + }, + }); + }, + }); +}); + +// Tests multiple intervals. +add_task(async function multipleIntervals() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [ + { interval_s: 1, max_count: 1 }, + { interval_s: 5, max_count: 3 }, + { interval_s: 10, max_count: 5 }, + ], + }, + }, + }, + callback: async () => { + await doTimedSearches("sponsored", { + // 0s: 1 new impression; 1 impression total + 0: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 1s: 1 new impression; 2 impressions total + 1: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 2s: 1 new impression; 3 impressions total + 2: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 5, max_count: 3 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 3s: no new impressions; 3 impressions total + 3: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 4s: no new impressions; 3 impressions total + 4: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "4000", + intervalSeconds: "1", + maxCount: "1", + startDate: "3000", + impressionDate: "2000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 5s: 1 new impression; 4 impressions total + 5: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "1", + maxCount: "1", + startDate: "4000", + impressionDate: "2000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 5, max_count: 3 + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "5000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "5000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 6s: 1 new impression; 5 impressions total + 6: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "6000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "5000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "6000", + intervalSeconds: "1", + maxCount: "1", + startDate: "6000", + impressionDate: "6000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 10, max_count: 5 + { + object: "hit", + extra: { + eventDate: "6000", + intervalSeconds: "10", + maxCount: "5", + startDate: "0", + impressionDate: "6000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 7s: no new impressions; 5 impressions total + 7: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "7000", + intervalSeconds: "1", + maxCount: "1", + startDate: "6000", + impressionDate: "6000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 8s: no new impressions; 5 impressions total + 8: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "8000", + intervalSeconds: "1", + maxCount: "1", + startDate: "7000", + impressionDate: "6000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 9s: no new impressions; 5 impressions total + 9: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "9000", + intervalSeconds: "1", + maxCount: "1", + startDate: "8000", + impressionDate: "6000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 10s: 1 new impression; 6 impressions total + 10: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "1", + startDate: "9000", + impressionDate: "6000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 5, max_count: 3 + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "5", + maxCount: "3", + startDate: "5000", + impressionDate: "6000", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 10, max_count: 5 + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "10", + maxCount: "5", + startDate: "0", + impressionDate: "6000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "1", + startDate: "10000", + impressionDate: "10000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 11s: 1 new impression; 7 impressions total + 11: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "11000", + intervalSeconds: "1", + maxCount: "1", + startDate: "10000", + impressionDate: "10000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "11000", + intervalSeconds: "1", + maxCount: "1", + startDate: "11000", + impressionDate: "11000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 12s: 1 new impression; 8 impressions total + 12: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "12000", + intervalSeconds: "1", + maxCount: "1", + startDate: "11000", + impressionDate: "11000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "12000", + intervalSeconds: "1", + maxCount: "1", + startDate: "12000", + impressionDate: "12000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 5, max_count: 3 + { + object: "hit", + extra: { + eventDate: "12000", + intervalSeconds: "5", + maxCount: "3", + startDate: "10000", + impressionDate: "12000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 13s: no new impressions; 8 impressions total + 13: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "13000", + intervalSeconds: "1", + maxCount: "1", + startDate: "12000", + impressionDate: "12000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 14s: no new impressions; 8 impressions total + 14: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "14000", + intervalSeconds: "1", + maxCount: "1", + startDate: "13000", + impressionDate: "12000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 15s: 1 new impression; 9 impressions total + 15: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "15000", + intervalSeconds: "1", + maxCount: "1", + startDate: "14000", + impressionDate: "12000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 5, max_count: 3 + { + object: "reset", + extra: { + eventDate: "15000", + intervalSeconds: "5", + maxCount: "3", + startDate: "10000", + impressionDate: "12000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "15000", + intervalSeconds: "1", + maxCount: "1", + startDate: "15000", + impressionDate: "15000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 16s: 1 new impression; 10 impressions total + 16: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "16000", + intervalSeconds: "1", + maxCount: "1", + startDate: "15000", + impressionDate: "15000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "16000", + intervalSeconds: "1", + maxCount: "1", + startDate: "16000", + impressionDate: "16000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 10, max_count: 5 + { + object: "hit", + extra: { + eventDate: "16000", + intervalSeconds: "10", + maxCount: "5", + startDate: "10000", + impressionDate: "16000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 17s: no new impressions; 10 impressions total + 17: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "17000", + intervalSeconds: "1", + maxCount: "1", + startDate: "16000", + impressionDate: "16000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 18s: no new impressions; 10 impressions total + 18: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "18000", + intervalSeconds: "1", + maxCount: "1", + startDate: "17000", + impressionDate: "16000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 19s: no new impressions; 10 impressions total + 19: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "19000", + intervalSeconds: "1", + maxCount: "1", + startDate: "18000", + impressionDate: "16000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 20s: 1 new impression; 11 impressions total + 20: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "20000", + intervalSeconds: "1", + maxCount: "1", + startDate: "19000", + impressionDate: "16000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 5, max_count: 3 + { + object: "reset", + extra: { + eventDate: "20000", + intervalSeconds: "5", + maxCount: "3", + startDate: "15000", + impressionDate: "16000", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 10, max_count: 5 + { + object: "reset", + extra: { + eventDate: "20000", + intervalSeconds: "10", + maxCount: "5", + startDate: "10000", + impressionDate: "16000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "20000", + intervalSeconds: "1", + maxCount: "1", + startDate: "20000", + impressionDate: "20000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + }); + }, + }); +}); + +// Tests a lifetime cap. +add_task(async function lifetime() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 3, + }, + }, + }, + callback: async () => { + await doTimedSearches("sponsored", { + 0: { + results: [ + [EXPECTED_SPONSORED_URLBAR_RESULT], + [EXPECTED_SPONSORED_URLBAR_RESULT], + [EXPECTED_SPONSORED_URLBAR_RESULT], + [], + ], + telemetry: { + events: [ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + 1: { + results: [[]], + }, + }); + }, + }); +}); + +// Tests one interval and a lifetime cap together. +add_task(async function intervalAndLifetime() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 3, + custom: [{ interval_s: 1, max_count: 1 }], + }, + }, + }, + callback: async () => { + await doTimedSearches("sponsored", { + // 0s: 1 new impression; 1 impression total + 0: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 1s: 1 new impression; 2 impressions total + 1: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 2s: 1 new impression; 3 impressions total + 2: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: Infinity, max_count: 3 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + 3: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + }); + }, + }); +}); + +// Tests multiple intervals and a lifetime cap together. +add_task(async function multipleIntervalsAndLifetime() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 4, + custom: [ + { interval_s: 1, max_count: 1 }, + { interval_s: 5, max_count: 3 }, + ], + }, + }, + }, + callback: async () => { + await doTimedSearches("sponsored", { + // 0s: 1 new impression; 1 impression total + 0: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 1s: 1 new impression; 2 impressions total + 1: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 2s: 1 new impression; 3 impressions total + 2: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 5, max_count: 3 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 3s: no new impressions; 3 impressions total + 3: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 4s: no new impressions; 3 impressions total + 4: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "4000", + intervalSeconds: "1", + maxCount: "1", + startDate: "3000", + impressionDate: "2000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 5s: 1 new impression; 4 impressions total + 5: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "1", + maxCount: "1", + startDate: "4000", + impressionDate: "2000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 5, max_count: 3 + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "5000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "5000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: Infinity, max_count: 4 + { + object: "hit", + extra: { + eventDate: "5000", + intervalSeconds: "Infinity", + maxCount: "4", + startDate: "0", + impressionDate: "5000", + count: "4", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 6s: no new impressions; 4 impressions total + 6: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "6000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "5000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 7s: no new impressions; 4 impressions total + 7: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "7000", + intervalSeconds: "1", + maxCount: "1", + startDate: "6000", + impressionDate: "5000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + }); + }, + }); +}); + +// Smoke test for non-sponsored caps. Most tasks use sponsored results and caps, +// but sponsored and non-sponsored should behave the same since they use the +// same code paths. +add_task(async function nonsponsored() { + await doTest({ + config: { + impression_caps: { + nonsponsored: { + lifetime: 4, + custom: [ + { interval_s: 1, max_count: 1 }, + { interval_s: 5, max_count: 3 }, + ], + }, + }, + }, + callback: async () => { + await doTimedSearches("nonsponsored", { + // 0s: 1 new impression; 1 impression total + 0: { + results: [[EXPECTED_NONSPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 1s: 1 new impression; 2 impressions total + 1: { + results: [[EXPECTED_NONSPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 2s: 1 new impression; 3 impressions total + 2: { + results: [[EXPECTED_NONSPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 5, max_count: 3 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 3s: no new impressions; 3 impressions total + 3: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 4s: no new impressions; 3 impressions total + 4: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "4000", + intervalSeconds: "1", + maxCount: "1", + startDate: "3000", + impressionDate: "2000", + count: "0", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 5s: 1 new impression; 4 impressions total + 5: { + results: [[EXPECTED_NONSPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "1", + maxCount: "1", + startDate: "4000", + impressionDate: "2000", + count: "0", + type: "nonsponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 5, max_count: 3 + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "nonsponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "5000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "5000", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + // hit: interval_s: Infinity, max_count: 4 + { + object: "hit", + extra: { + eventDate: "5000", + intervalSeconds: "Infinity", + maxCount: "4", + startDate: "0", + impressionDate: "5000", + count: "4", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 6s: no new impressions; 4 impressions total + 6: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "6000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "5000", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 7s: no new impressions; 4 impressions total + 7: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "7000", + intervalSeconds: "1", + maxCount: "1", + startDate: "6000", + impressionDate: "5000", + count: "0", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + }); + }, + }); +}); + +// Smoke test for sponsored and non-sponsored caps together. Most tasks use only +// sponsored results and caps, but sponsored and non-sponsored should behave the +// same since they use the same code paths. +add_task(async function sponsoredAndNonsponsored() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 2, + }, + nonsponsored: { + lifetime: 3, + }, + }, + }, + callback: async () => { + // 1st searches + await checkSearch({ + name: "sponsored 1", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "nonsponsored 1", + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + await checkTelemetryEvents([]); + + // 2nd searches + await checkSearch({ + name: "sponsored 2", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "nonsponsored 2", + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "2", + startDate: "0", + impressionDate: "0", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + + // 3rd searches + await checkSearch({ + name: "sponsored 3", + searchString: "sponsored", + expectedResults: [], + }); + await checkSearch({ + name: "nonsponsored 3", + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "nonsponsored", + eventCount: "1", + }, + }, + ]); + + // 4th searches + await checkSearch({ + name: "sponsored 4", + searchString: "sponsored", + expectedResults: [], + }); + await checkSearch({ + name: "nonsponsored 4", + searchString: "nonsponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + }); +}); + +// Tests with an empty config to make sure results are not capped. +add_task(async function emptyConfig() { + await doTest({ + config: {}, + callback: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "sponsored " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "nonsponsored " + i, + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([]); + }, + }); +}); + +// Tests with sponsored caps disabled. Non-sponsored should still be capped. +add_task(async function sponsoredCapsDisabled() { + UrlbarPrefs.set("quicksuggest.impressionCaps.sponsoredEnabled", false); + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 0, + }, + nonsponsored: { + lifetime: 3, + }, + }, + }, + callback: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "sponsored " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "nonsponsored " + i, + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "nonsponsored", + eventCount: "1", + }, + }, + ]); + + await checkSearch({ + name: "sponsored additional", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "nonsponsored additional", + searchString: "nonsponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + }); + UrlbarPrefs.set("quicksuggest.impressionCaps.sponsoredEnabled", true); +}); + +// Tests with non-sponsored caps disabled. Sponsored should still be capped. +add_task(async function nonsponsoredCapsDisabled() { + UrlbarPrefs.set("quicksuggest.impressionCaps.nonSponsoredEnabled", false); + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 3, + }, + nonsponsored: { + lifetime: 0, + }, + }, + }, + callback: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "sponsored " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "nonsponsored " + i, + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + + await checkSearch({ + name: "sponsored additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkSearch({ + name: "nonsponsored additional", + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + await checkTelemetryEvents([]); + }, + }); + UrlbarPrefs.set("quicksuggest.impressionCaps.nonSponsoredEnabled", true); +}); + +// Tests a config change: 1 interval -> same interval with lower cap, with the +// old cap already reached +add_task(async function configChange_sameIntervalLowerCap_1() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "0s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "3", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 1 }], + }, + }, + }); + }, + 1: async () => { + await checkSearch({ + name: "1s", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + 3: async () => { + await checkSearch({ + name: "3s 0", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "3s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "1", + startDate: "3000", + impressionDate: "3000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: 1 interval -> same interval with lower cap, with the +// old cap not reached +add_task(async function configChange_sameIntervalLowerCap_2() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 1 }], + }, + }, + }); + }, + 1: async () => { + await checkSearch({ + name: "1s", + searchString: "sponsored", + expectedResults: [], + }); + }, + 3: async () => { + await checkSearch({ + name: "3s 0", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "3s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "1", + startDate: "3000", + impressionDate: "3000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: 1 interval -> same interval with higher cap +add_task(async function configChange_sameIntervalHigherCap() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "0s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "3", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 5 }], + }, + }, + }); + }, + 1: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "1s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "1s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "3", + maxCount: "5", + startDate: "0", + impressionDate: "1000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + 3: async () => { + for (let i = 0; i < 5; i++) { + await checkSearch({ + name: "3s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "3s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "5", + startDate: "0", + impressionDate: "1000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "5", + startDate: "3000", + impressionDate: "3000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: 1 interval -> 2 new intervals with higher timeouts. +// Impression counts for the old interval should contribute to the new +// intervals. +add_task(async function configChange_1IntervalTo2NewIntervalsHigher() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "3", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + custom: [ + { interval_s: 5, max_count: 3 }, + { interval_s: 10, max_count: 5 }, + ], + }, + }, + }); + }, + 3: async () => { + await checkSearch({ + name: "3s", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + 4: async () => { + await checkSearch({ + name: "4s", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + 5: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "5s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "5s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "5000", + intervalSeconds: "10", + maxCount: "5", + startDate: "0", + impressionDate: "5000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: 2 intervals -> 1 new interval with higher timeout. +// Impression counts for the old intervals should contribute to the new +// interval. +add_task(async function configChange_2IntervalsTo1NewIntervalHigher() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [ + { interval_s: 2, max_count: 2 }, + { interval_s: 4, max_count: 4 }, + ], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "2", + maxCount: "2", + startDate: "0", + impressionDate: "0", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + 2: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "2s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "2000", + intervalSeconds: "2", + maxCount: "2", + startDate: "0", + impressionDate: "0", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "2", + maxCount: "2", + startDate: "2000", + impressionDate: "2000", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "4", + maxCount: "4", + startDate: "0", + impressionDate: "2000", + count: "4", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + custom: [{ interval_s: 6, max_count: 5 }], + }, + }, + }); + }, + 4: async () => { + await checkSearch({ + name: "4s 0", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "4s 1", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "4000", + intervalSeconds: "6", + maxCount: "5", + startDate: "0", + impressionDate: "4000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + 5: async () => { + await checkSearch({ + name: "5s", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + 6: async () => { + for (let i = 0; i < 5; i++) { + await checkSearch({ + name: "6s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "6s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "6000", + intervalSeconds: "6", + maxCount: "5", + startDate: "0", + impressionDate: "4000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "6000", + intervalSeconds: "6", + maxCount: "5", + startDate: "6000", + impressionDate: "6000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: 1 interval -> 1 new interval with lower timeout. +// Impression counts for the old interval should not contribute to the new +// interval since the new interval has a lower timeout. +add_task(async function configChange_1IntervalTo1NewIntervalLower() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 5, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }); + }, + 1: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "3s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "3s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "3", + maxCount: "3", + startDate: "0", + impressionDate: "1000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: 1 interval -> lifetime. +// Impression counts for the old interval should contribute to the new lifetime +// cap. +add_task(async function configChange_1IntervalToLifetime() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "3", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + lifetime: 3, + }, + }, + }); + }, + 3: async () => { + await checkSearch({ + name: "3s", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + }); + }, + }); +}); + +// Tests a config change: lifetime cap -> higher lifetime cap +add_task(async function configChange_lifetimeCapHigher() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 3, + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "0s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + lifetime: 5, + }, + }, + }); + }, + 1: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "1s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "1s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "Infinity", + maxCount: "5", + startDate: "0", + impressionDate: "1000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: lifetime cap -> lower lifetime cap +add_task(async function configChange_lifetimeCapLower() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 3, + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "0s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + lifetime: 1, + }, + }, + }); + }, + 1: async () => { + await checkSearch({ + name: "1s", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + }); + }, + }); +}); + +// Makes sure stats are serialized to and from the pref correctly. +add_task(async function prefSync() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 5, + custom: [ + { interval_s: 3, max_count: 2 }, + { interval_s: 5, max_count: 4 }, + ], + }, + }, + }, + callback: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + + let json = UrlbarPrefs.get("quicksuggest.impressionCaps.stats"); + Assert.ok(json, "JSON is non-empty"); + Assert.deepEqual( + JSON.parse(json), + { + sponsored: [ + { + intervalSeconds: 3, + count: 2, + maxCount: 2, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: 5, + count: 2, + maxCount: 4, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: null, + count: 2, + maxCount: 5, + startDateMs: 0, + impressionDateMs: 0, + }, + ], + }, + "JSON is correct" + ); + + QuickSuggest.impressionCaps._test_reloadStats(); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + { + sponsored: [ + { + intervalSeconds: 3, + count: 2, + maxCount: 2, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: 5, + count: 2, + maxCount: 4, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: Infinity, + count: 2, + maxCount: 5, + startDateMs: 0, + impressionDateMs: 0, + }, + ], + }, + "Impression stats were properly restored from the pref" + ); + }, + }); +}); + +// Tests direct changes to the stats pref. +add_task(async function prefDirectlyChanged() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 5, + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }, + callback: async () => { + let expectedStats = { + sponsored: [ + { + intervalSeconds: 3, + count: 0, + maxCount: 3, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: Infinity, + count: 0, + maxCount: 5, + startDateMs: 0, + impressionDateMs: 0, + }, + ], + }; + + UrlbarPrefs.set("quicksuggest.impressionCaps.stats", "bogus"); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + expectedStats, + "Expected stats for 'bogus'" + ); + + UrlbarPrefs.set("quicksuggest.impressionCaps.stats", JSON.stringify({})); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + expectedStats, + "Expected stats for {}" + ); + + UrlbarPrefs.set( + "quicksuggest.impressionCaps.stats", + JSON.stringify({ sponsored: "bogus" }) + ); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + expectedStats, + "Expected stats for { sponsored: 'bogus' }" + ); + + UrlbarPrefs.set( + "quicksuggest.impressionCaps.stats", + JSON.stringify({ + sponsored: [ + { + intervalSeconds: 3, + count: 0, + maxCount: 3, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: "bogus", + count: 0, + maxCount: 99, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: Infinity, + count: 0, + maxCount: 5, + startDateMs: 0, + impressionDateMs: 0, + }, + ], + }) + ); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + expectedStats, + "Expected stats with intervalSeconds: 'bogus'" + ); + + UrlbarPrefs.set( + "quicksuggest.impressionCaps.stats", + JSON.stringify({ + sponsored: [ + { + intervalSeconds: 3, + count: 0, + maxCount: 123, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: Infinity, + count: 0, + maxCount: 456, + startDateMs: 0, + impressionDateMs: 0, + }, + ], + }) + ); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + expectedStats, + "Expected stats with `maxCount` values different from caps" + ); + + let stats = { + sponsored: [ + { + intervalSeconds: 3, + count: 1, + maxCount: 3, + startDateMs: 99, + impressionDateMs: 99, + }, + { + intervalSeconds: Infinity, + count: 7, + maxCount: 5, + startDateMs: 1337, + impressionDateMs: 1337, + }, + ], + }; + UrlbarPrefs.set( + "quicksuggest.impressionCaps.stats", + JSON.stringify(stats) + ); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + stats, + "Expected stats with valid JSON" + ); + }, + }); +}); + +// Tests multiple interval periods where the cap is not hit. Telemetry should be +// recorded for these periods. +add_task(async function intervalsElapsedButCapNotHit() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 1, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + // 1s + 1: async () => { + await checkSearch({ + name: "1s", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + }, + // 10s + 10: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + let expectedEvents = [ + // 1s: reset with count = 0 + { + object: "reset", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + // 2-10s: reset with count = 1, eventCount = 9 + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "3", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "9", + }, + }, + ]; + await checkTelemetryEvents(expectedEvents); + }, + }); + }, + }); +}); + +// Simulates reset events across a restart with the following: +// +// S S R +// >----|----|----|----|----|----|----|----|----|----| +// 0s 1 2 3 4 5 6 7 8 9 10 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 1 +// 3. Startup at 4.5s +// 4. Reset triggered at 10s +// +// Expected: +// At 10s: 6 batched resets for periods starting at 4s +add_task(async function restart_1() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 1, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(4500); + await doTimedCallbacks({ + // 10s: 6 batched resets for periods starting at 4s + 10: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "1", + startDate: "4000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "6", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Simulates reset events across a restart with the following: +// +// S S R +// >----|----|----|----|----|----|----|----|----|----| +// 0s 1 2 3 4 5 6 7 8 9 10 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 1 +// 3. Startup at 5s +// 4. Reset triggered at 10s +// +// Expected: +// At 10s: 5 batched resets for periods starting at 5s +add_task(async function restart_2() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 1, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(5000); + await doTimedCallbacks({ + // 10s: 5 batched resets for periods starting at 5s + 10: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "5", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Simulates reset events across a restart with the following: +// +// S S R +// >----|----|----|----|----|----|----|----|----|----| +// 0s 1 2 3 4 5 6 7 8 9 10 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 1 +// 3. Startup at 5.5s +// 4. Reset triggered at 10s +// +// Expected: +// At 10s: 5 batched resets for periods starting at 5s +add_task(async function restart_3() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 1, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(5500); + await doTimedCallbacks({ + // 10s: 5 batched resets for periods starting at 5s + 10: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "5", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Simulates reset events across a restart with the following: +// +// S S RR RR +// >---------|---------| +// 0s 10 20 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 10 +// 3. Startup at 5s +// 4. Resets triggered at 9s, 10s, 19s, 20s +// +// Expected: +// At 10s: 1 reset for period starting at 0s +// At 20s: 1 reset for period starting at 10s +add_task(async function restart_4() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 10, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(5000); + await doTimedCallbacks({ + // 9s: no resets + 9: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([]); + }, + // 10s: 1 reset for period starting at 0s + 10: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "10", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + // 19s: no resets + 19: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([]); + }, + // 20s: 1 reset for period starting at 10s + 20: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "20000", + intervalSeconds: "10", + maxCount: "1", + startDate: "10000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Simulates reset events across a restart with the following: +// +// S S R +// >---------|---------| +// 0s 10 20 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 10 +// 3. Startup at 5s +// 4. Reset triggered at 20s +// +// Expected: +// At 20s: 2 batched resets for periods starting at 0s +add_task(async function restart_5() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 10, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(5000); + await doTimedCallbacks({ + // 20s: 2 batches resets for periods starting at 0s + 20: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "20000", + intervalSeconds: "10", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "2", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Simulates reset events across a restart with the following: +// +// S S RR RR +// >---------|---------|---------| +// 0s 10 20 30 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 10 +// 3. Startup at 15s +// 4. Resets triggered at 19s, 20s, 29s, 30s +// +// Expected: +// At 20s: 1 reset for period starting at 10s +// At 30s: 1 reset for period starting at 20s +add_task(async function restart_6() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 10, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(15000); + await doTimedCallbacks({ + // 19s: no resets + 19: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([]); + }, + // 20s: 1 reset for period starting at 10s + 20: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "20000", + intervalSeconds: "10", + maxCount: "1", + startDate: "10000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + // 29s: no resets + 29: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([]); + }, + // 30s: 1 reset for period starting at 20s + 30: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "30000", + intervalSeconds: "10", + maxCount: "1", + startDate: "20000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Simulates reset events across a restart with the following: +// +// S S R +// >---------|---------|---------| +// 0s 10 20 30 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 10 +// 3. Startup at 15s +// 4. Reset triggered at 30s +// +// Expected: +// At 30s: 2 batched resets for periods starting at 10s +add_task(async function restart_7() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 10, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(15000); + await doTimedCallbacks({ + // 30s: 2 batched resets for periods starting at 10s + 30: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "30000", + intervalSeconds: "10", + maxCount: "1", + startDate: "10000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "2", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Tests reset telemetry recorded on shutdown. +add_task(async function shutdown() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 1, max_count: 1 }], + }, + }, + }, + callback: async () => { + // Make `Date.now()` return 10s. Since the cap's `interval_s` is 1s and + // before this `Date.now()` returned 0s, 10 reset events should be + // recorded on shutdown. + gDateNowStub.returns(10000); + + // Simulate shutdown. + Services.prefs.setBoolPref("toolkit.asyncshutdown.testing", true); + AsyncShutdown.profileChangeTeardown._trigger(); + + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "10", + }, + }, + ]); + + gDateNowStub.returns(0); + Services.prefs.clearUserPref("toolkit.asyncshutdown.testing"); + }, + }); +}); + +// Tests the reset interval in realtime. +add_task(async function resetInterval() { + // Remove the test stubs so we can test in realtime. + gDateNowStub.restore(); + gStartupDateMsStub.restore(); + + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 0.1, max_count: 1 }], + }, + }, + }, + callback: async () => { + // Restart the reset interval now with a 1s period. Since the cap's + // `interval_s` is 0.1s, at least 10 reset events should be recorded the + // first time the reset interval fires. The exact number depends on timing + // and the machine running the test: how many 0.1s intervals elapse + // between when the config is set to when the reset interval fires. For + // that reason, we allow some leeway when checking `eventCount` below to + // avoid intermittent failures. + QuickSuggest.impressionCaps._test_setCountersResetInterval(1000); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 1100)); + + // Restore the reset interval to its default. + QuickSuggest.impressionCaps._test_setCountersResetInterval(); + + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: /^[0-9]+$/, + intervalSeconds: "0.1", + maxCount: "1", + startDate: /^[0-9]+$/, + impressionDate: "0", + count: "0", + type: "sponsored", + // See comment above on allowing leeway for `eventCount`. + eventCount: str => { + info(`Checking 'eventCount': ${str}`); + let count = parseInt(str); + return 10 <= count && count < 20; + }, + }, + }, + ]); + }, + }); + + // Recreate the test stubs. + gDateNowStub = gSandbox.stub( + Cu.getGlobalForObject(UrlbarProviderQuickSuggest).Date, + "now" + ); + gStartupDateMsStub = gSandbox.stub( + QuickSuggest.impressionCaps, + "_getStartupDateMs" + ); + gStartupDateMsStub.returns(0); +}); + +/** + * Main test helper. Sets up state, calls your callback, and resets state. + * + * @param {object} options + * Options object. + * @param {object} options.config + * The quick suggest config to use during the test. + * @param {Function} options.callback + * The callback that will be run with the {@link config} + */ +async function doTest({ config, callback }) { + Services.telemetry.clearEvents(); + + // Make `Date.now()` return 0 to start with. It's necessary to do this before + // calling `withConfig()` because when a new config is set, the provider + // validates its impression stats, whose `startDateMs` values depend on + // `Date.now()`. + gDateNowStub.returns(0); + + info(`Clearing stats and setting config`); + UrlbarPrefs.clear("quicksuggest.impressionCaps.stats"); + QuickSuggest.impressionCaps._test_reloadStats(); + await QuickSuggestTestUtils.withConfig({ config, callback }); +} + +/** + * Does a series of timed searches and checks their results and telemetry. This + * function relies on `doTimedCallbacks()`, so it may be helpful to look at it + * too. + * + * @param {string} searchString + * The query that should be timed + * @param {object} expectedBySecond + * An object that maps from seconds to objects that describe the searches to + * perform, their expected results, and the expected telemetry. For a given + * entry `S -> E` in this object, searches are performed S seconds after this + * function is called. `E` is an object that looks like this: + * + * { results, telemetry } + * + * {array} results + * An array of arrays. A search is performed for each sub-array in + * `results`, and the contents of the sub-array are the expected results + * for that search. + * {object} telemetry + * An object like this: { events } + * {array} events + * An array of expected telemetry events after all searches are done. + * Telemetry events are cleared after checking these. If not present, + * then it will be asserted that no events were recorded. + * + * Example: + * + * { + * 0: { + * results: [[R1], []], + * telemetry: { + * events: [ + * someExpectedEvent, + * ], + * }, + * } + * 1: { + * results: [[]], + * }, + * } + * + * 0 seconds after `doTimedSearches()` is called, two searches are + * performed. The first one is expected to return a single result R1, and + * the second search is expected to return no results. After the searches + * are done, one telemetry event is expected to be recorded. + * + * 1 second after `doTimedSearches()` is called, one search is performed. + * It's expected to return no results, and no telemetry is expected to be + * recorded. + */ +async function doTimedSearches(searchString, expectedBySecond) { + await doTimedCallbacks( + Object.entries(expectedBySecond).reduce( + (memo, [second, { results, telemetry }]) => { + memo[second] = async () => { + for (let i = 0; i < results.length; i++) { + let expectedResults = results[i]; + await checkSearch({ + searchString, + expectedResults, + name: `${second}s search ${i + 1} of ${results.length}`, + }); + } + let { events } = telemetry || {}; + await checkTelemetryEvents(events || []); + }; + return memo; + }, + {} + ) + ); +} + +/** + * Takes a series a callbacks and times at which they should be called, and + * calls them accordingly. This function is specifically designed for + * UrlbarProviderQuickSuggest and its impression capping implementation because + * it works by stubbing `Date.now()` within UrlbarProviderQuickSuggest. The + * callbacks are not actually called at the given times but instead `Date.now()` + * is stubbed so that UrlbarProviderQuickSuggest will think they are being + * called at the given times. + * + * A more general implementation of this helper function that isn't tailored to + * UrlbarProviderQuickSuggest is commented out below, and unfortunately it + * doesn't work properly on macOS. + * + * @param {object} callbacksBySecond + * An object that maps from seconds to callback functions. For a given entry + * `S -> F` in this object, the callback F is called S seconds after + * `doTimedCallbacks()` is called. + */ +async function doTimedCallbacks(callbacksBySecond) { + let entries = Object.entries(callbacksBySecond).sort(([t1], [t2]) => t1 - t2); + for (let [timeoutSeconds, callback] of entries) { + gDateNowStub.returns(1000 * timeoutSeconds); + await callback(); + } +} + +/* +// This is the original implementation of `doTimedCallbacks()`, left here for +// reference or in case the macOS problem described below is fixed. Instead of +// stubbing `Date.now()` within UrlbarProviderQuickSuggest, it starts parallel +// timers so that the callbacks are actually called at appropriate times. This +// version of `doTimedCallbacks()` is therefore more generally useful, but it +// has the drawback that your test has to run in real time. e.g., if one of your +// callbacks needs to run 10s from now, the test must actually wait 10s. +// +// Unfortunately macOS seems to have some kind of limit of ~33 total 1-second +// timers during any xpcshell test -- not 33 simultaneous timers but 33 total +// timers. After that, timers fire randomly and with huge timeout periods that +// are often exactly 10s greater than the specified period, as if some 10s +// timeout internal to macOS is being hit. This problem does not seem to happen +// when running the full browser, only during xpcshell tests. In fact the +// problem can be reproduced in an xpcshell test that simply creates an interval +// timer whose period is 1s (e.g., using `setInterval()` from Timer.sys.mjs). +// After ~33 ticks, the timer's period jumps to ~10s. +async function doTimedCallbacks(callbacksBySecond) { + await Promise.all( + Object.entries(callbacksBySecond).map( + ([timeoutSeconds, callback]) => new Promise( + resolve => setTimeout( + () => callback().then(resolve), + 1000 * parseInt(timeoutSeconds) + ) + ) + ) + ); +} +*/ + +/** + * Does a search, triggers an engagement, and checks the results. + * + * @param {object} options + * Options object. + * @param {string} options.name + * This value is the name of the search and will be logged in messages to make + * debugging easier. + * @param {string} options.searchString + * The query that should be searched. + * @param {Array} options.expectedResults + * The results that are expected from the search. + */ +async function checkSearch({ name, searchString, expectedResults }) { + info(`Preparing search "${name}" with search string "${searchString}"`); + let context = createContext(searchString, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + info(`Doing search: ${name}`); + await check_results({ + context, + matches: expectedResults, + }); + info(`Finished search: ${name}`); + + // Impression stats are updated only on engagement, so force one now. + // `selIndex` doesn't really matter but since we're not trying to simulate a + // click on the suggestion, pass in -1 to ensure we don't record a click. + if (UrlbarProviderQuickSuggest._resultFromLastQuery) { + UrlbarProviderQuickSuggest._resultFromLastQuery.isVisible = true; + } + const controller = UrlbarTestUtils.newMockController({ + input: { + isPrivate: true, + onFirstResult() { + return false; + }, + getSearchSource() { + return "dummy-search-source"; + }, + window: { + location: { + href: AppConstants.BROWSER_CHROME_URL, + }, + }, + }, + }); + controller.setView({ + get visibleResults() { + return context.results; + }, + controller: { + removeResult() {}, + }, + }); + UrlbarProviderQuickSuggest.onEngagement( + "engagement", + context, + { + selIndex: -1, + }, + controller + ); +} + +async function checkTelemetryEvents(expectedEvents) { + QuickSuggestTestUtils.assertEvents( + expectedEvents.map(event => ({ + ...event, + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "impression_cap", + })), + // Filter in only impression_cap events. + { method: "impression_cap" } + ); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_mdn.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_mdn.js new file mode 100644 index 0000000000..e9bccba649 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_mdn.js @@ -0,0 +1,190 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Tests MDN quick suggest results. + +"use strict"; + +const REMOTE_SETTINGS_DATA = [ + { + type: "mdn-suggestions", + attachment: [ + { + url: "https://example.com/array-filter", + title: "Array.prototype.filter()", + description: + "The filter() method creates a shallow copy of a portion of a given array, filtered down to just the elements from the given array that pass the test implemented by the provided function.", + keywords: ["array filter"], + score: 0.24, + }, + { + url: "https://example.com/input", + title: ": The Input (Form Input) element", + description: + "The HTML element is used to create interactive controls for web-based forms in order to accept data from the user; a wide variety of types of input data and control widgets are available, depending on the device and user agent. The element is one of the most powerful and complex in all of HTML due to the sheer number of combinations of input types and attributes.", + keywords: ["input"], + score: 0.24, + }, + { + url: "https://example.com/grid", + title: "CSS Grid Layout", + description: + "CSS Grid Layout excels at dividing a page into major regions or defining the relationship in terms of size, position, and layer, between parts of a control built from HTML primitives.", + keywords: ["grid"], + score: 0.24, + }, + ], + }, +]; + +add_setup(async function init() { + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: REMOTE_SETTINGS_DATA, + prefs: [ + ["suggest.quicksuggest.nonsponsored", true], + ["suggest.quicksuggest.sponsored", false], + ], + }); +}); + +add_tasks_with_rust(async function basic() { + for (const suggestion of REMOTE_SETTINGS_DATA[0].attachment) { + const fullKeyword = suggestion.keywords[0]; + const firstWord = fullKeyword.split(" ")[0]; + for (let i = 1; i < fullKeyword.length; i++) { + const keyword = fullKeyword.substring(0, i); + const shouldMatch = i >= firstWord.length; + const matches = shouldMatch ? [makeMdnResult(suggestion)] : []; + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches, + }); + } + + await check_results({ + context: createContext(fullKeyword + " ", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: + UrlbarPrefs.get("quickSuggestRustEnabled") && !fullKeyword.includes(" ") + ? [makeMdnResult(suggestion)] + : [], + }); + } +}); + +// Check wheather the MDN suggestions will be hidden by the pref. +add_tasks_with_rust(async function disableByLocalPref() { + const suggestion = REMOTE_SETTINGS_DATA[0].attachment[0]; + const keyword = suggestion.keywords[0]; + + const prefs = [ + "suggest.mdn", + "quicksuggest.enabled", + "suggest.quicksuggest.nonsponsored", + ]; + + for (const pref of prefs) { + // First make sure the suggestion is added. + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [makeMdnResult(suggestion)], + }); + + // Now disable them. + UrlbarPrefs.set(pref, false); + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + // Revert. + UrlbarPrefs.set(pref, true); + await QuickSuggestTestUtils.forceSync(); + } +}); + +// Check wheather the MDN suggestions will be shown by the setup of Nimbus +// variable. +add_tasks_with_rust(async function nimbus() { + const defaultPrefs = Services.prefs.getDefaultBranch("browser.urlbar."); + + const suggestion = REMOTE_SETTINGS_DATA[0].attachment[0]; + const keyword = suggestion.keywords[0]; + + // Disable the fature gate. + defaultPrefs.setBoolPref("mdn.featureGate", false); + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + // Enable by Nimbus. + const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature( + { mdnFeatureGate: true }, + "urlbar", + "config" + ); + await QuickSuggestTestUtils.forceSync(); + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [makeMdnResult(suggestion)], + }); + await cleanUpNimbusEnable(); + + // Enable locally. + defaultPrefs.setBoolPref("mdn.featureGate", true); + await QuickSuggestTestUtils.forceSync(); + + // Disable by Nimbus. + const cleanUpNimbusDisable = await UrlbarTestUtils.initNimbusFeature( + { mdnFeatureGate: false }, + "urlbar", + "config" + ); + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + await cleanUpNimbusDisable(); + + // Revert. + defaultPrefs.setBoolPref("mdn.featureGate", true); + await QuickSuggestTestUtils.forceSync(); +}); + +add_tasks_with_rust(async function mixedCaseQuery() { + const suggestion = REMOTE_SETTINGS_DATA[0].attachment[1]; + const keyword = "InPuT"; + + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [makeMdnResult(suggestion)], + }); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js new file mode 100644 index 0000000000..64f4991236 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js @@ -0,0 +1,574 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Tests Merino integration with UrlbarProviderQuickSuggest. + +"use strict"; + +// relative to `browser.urlbar` +const PREF_DATA_COLLECTION_ENABLED = "quicksuggest.dataCollection.enabled"; + +const SEARCH_STRING = "frab"; + +const { DEFAULT_SUGGESTION_SCORE } = UrlbarProviderQuickSuggest; + +const REMOTE_SETTINGS_RESULTS = [ + QuickSuggestTestUtils.ampRemoteSettings({ + keywords: [SEARCH_STRING], + }), +]; + +const EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT = makeAmpResult({ + keyword: SEARCH_STRING, +}); + +const EXPECTED_MERINO_URLBAR_RESULT = makeAmpResult({ + source: "merino", + provider: "adm", + requestId: "request_id", +}); + +// `UrlbarProviderQuickSuggest.#merino` is lazily created on the first Merino +// fetch, so it's easiest to create `gClient` lazily too. +ChromeUtils.defineLazyGetter( + this, + "gClient", + () => UrlbarProviderQuickSuggest._test_merino +); + +add_setup(async () => { + await MerinoTestUtils.server.start(); + + // Set up the remote settings client with the test data. + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + prefs: [ + ["suggest.quicksuggest.nonsponsored", true], + ["suggest.quicksuggest.sponsored", true], + ], + }); + + Assert.equal( + typeof DEFAULT_SUGGESTION_SCORE, + "number", + "Sanity check: DEFAULT_SUGGESTION_SCORE is defined" + ); +}); + +// Tests with the Merino endpoint URL set to an empty string, which disables +// fetching from Merino. +add_task(async function merinoDisabled() { + let mockEndpointUrl = UrlbarPrefs.get("merino.endpointURL"); + UrlbarPrefs.set("merino.endpointURL", ""); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + // Clear the remote settings suggestions so that if Merino is actually queried + // -- which would be a bug -- we don't accidentally mask the Merino suggestion + // by also matching an RS suggestion with the same or higher score. + await QuickSuggestTestUtils.setRemoteSettingsRecords([]); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: null, + latencyRecorded: false, + client: UrlbarProviderQuickSuggest._test_merino, + }); + + UrlbarPrefs.set("merino.endpointURL", mockEndpointUrl); + + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ]); +}); + +// Tests with Merino enabled but with data collection disabled. Results should +// not be fetched from Merino in that case. +add_task(async function dataCollectionDisabled() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, false); + + // Clear the remote settings suggestions so that if Merino is actually queried + // -- which would be a bug -- we don't accidentally mask the Merino suggestion + // by also matching an RS suggestion with the same or higher score. + await QuickSuggestTestUtils.setRemoteSettingsRecords([]); + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ]); +}); + +// When the Merino suggestion has a higher score than the remote settings +// suggestion, the Merino suggestion should be used. +add_task(async function higherScore() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response.body.suggestions[0].score = + 2 * DEFAULT_SUGGESTION_SCORE; + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_MERINO_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// When the Merino suggestion has a lower score than the remote settings +// suggestion, the remote settings suggestion should be used. +add_task(async function lowerScore() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response.body.suggestions[0].score = + DEFAULT_SUGGESTION_SCORE / 2; + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// When the Merino and remote settings suggestions have the same score, the +// remote settings suggestion should be used. +add_task(async function sameScore() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response.body.suggestions[0].score = + DEFAULT_SUGGESTION_SCORE; + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// When the Merino suggestion does not include a score, the remote settings +// suggestion should be used. +add_task(async function noMerinoScore() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + Assert.equal( + typeof MerinoTestUtils.server.response.body.suggestions[0].score, + "number", + "Sanity check: First suggestion has a score" + ); + delete MerinoTestUtils.server.response.body.suggestions[0].score; + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// When remote settings doesn't return a suggestion but Merino does, the Merino +// suggestion should be used. +add_task(async function noSuggestion_remoteSettings() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + let context = createContext("this doesn't match remote settings", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_MERINO_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// When Merino doesn't return a suggestion but remote settings does, the remote +// settings suggestion should be used. +add_task(async function noSuggestion_merino() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response.body.suggestions = []; + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "no_suggestion", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// When Merino returns multiple suggestions, the one with the largest score +// should be used. +add_task(async function multipleMerinoSuggestions() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response.body.suggestions = [ + { + provider: "adm", + full_keyword: "multipleMerinoSuggestions 0 full_keyword", + title: "multipleMerinoSuggestions 0 title", + url: "multipleMerinoSuggestions 0 url", + icon: "multipleMerinoSuggestions 0 icon", + impression_url: "multipleMerinoSuggestions 0 impression_url", + click_url: "multipleMerinoSuggestions 0 click_url", + block_id: 0, + advertiser: "multipleMerinoSuggestions 0 advertiser", + iab_category: "22 - Shopping", + is_sponsored: true, + score: 0.1, + }, + { + provider: "adm", + full_keyword: "multipleMerinoSuggestions 1 full_keyword", + title: "multipleMerinoSuggestions 1 title", + url: "multipleMerinoSuggestions 1 url", + icon: "multipleMerinoSuggestions 1 icon", + impression_url: "multipleMerinoSuggestions 1 impression_url", + click_url: "multipleMerinoSuggestions 1 click_url", + block_id: 1, + advertiser: "multipleMerinoSuggestions 1 advertiser", + iab_category: "22 - Shopping", + is_sponsored: true, + score: 1, + }, + { + provider: "adm", + full_keyword: "multipleMerinoSuggestions 2 full_keyword", + title: "multipleMerinoSuggestions 2 title", + url: "multipleMerinoSuggestions 2 url", + icon: "multipleMerinoSuggestions 2 icon", + impression_url: "multipleMerinoSuggestions 2 impression_url", + click_url: "multipleMerinoSuggestions 2 click_url", + block_id: 2, + advertiser: "multipleMerinoSuggestions 2 advertiser", + iab_category: "22 - Shopping", + is_sponsored: true, + score: 0.2, + }, + ]; + + let context = createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeAmpResult({ + keyword: "multipleMerinoSuggestions 1 full_keyword", + title: "multipleMerinoSuggestions 1 title", + url: "multipleMerinoSuggestions 1 url", + originalUrl: "multipleMerinoSuggestions 1 url", + icon: "multipleMerinoSuggestions 1 icon", + impressionUrl: "multipleMerinoSuggestions 1 impression_url", + clickUrl: "multipleMerinoSuggestions 1 click_url", + blockId: 1, + advertiser: "multipleMerinoSuggestions 1 advertiser", + requestId: "request_id", + source: "merino", + provider: "adm", + }), + ], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// Timestamp templates in URLs should be replaced with real timestamps. +add_task(async function timestamps() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + // Set up the Merino response with template URLs. + let suggestion = MerinoTestUtils.server.response.body.suggestions[0]; + let { TIMESTAMP_TEMPLATE } = QuickSuggest; + + suggestion.url = `http://example.com/time-${TIMESTAMP_TEMPLATE}`; + suggestion.click_url = `http://example.com/time-${TIMESTAMP_TEMPLATE}-foo`; + + // Do a search. + let context = createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + let controller = UrlbarTestUtils.newMockController({ + input: { + isPrivate: context.isPrivate, + onFirstResult() { + return false; + }, + getSearchSource() { + return "dummy-search-source"; + }, + window: { + location: { + href: AppConstants.BROWSER_CHROME_URL, + }, + }, + }, + }); + await controller.startQuery(context); + + // Should be one quick suggest result. + Assert.equal(context.results.length, 1, "One result returned"); + let result = context.results[0]; + + QuickSuggestTestUtils.assertTimestampsReplaced(result, { + url: suggestion.click_url, + sponsoredClickUrl: suggestion.click_url, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// When both suggestion types are disabled but data collection is enabled, we +// should still send requests to Merino, and the requests should include an +// empty `providers` to tell Merino not to fetch any suggestions. +add_task(async function suggestedDisabled_dataCollectionEnabled() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + let context = createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + // Check that the request is received and includes an empty `providers`. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: "test", + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + [MerinoTestUtils.SEARCH_PARAMS.PROVIDERS]: "", + }, + }, + ]); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + gClient.resetSession(); +}); + +// Test whether the blocking for Merino results works. +add_task(async function block() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + // Make sure the Merino suggestions have different URLs from the remote + // settings suggestion. + let { suggestions } = MerinoTestUtils.server.response.body; + for (let i = 0; i < suggestions.length; i++) { + let suggestion = suggestions[i]; + suggestion.url = "https://example.com/merino-url-" + i; + await QuickSuggest.blockedSuggestions.add(suggestion.url); + } + + const context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + await QuickSuggest.blockedSuggestions.clear(); + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// Tests a Merino suggestion that is a top pick/best match. +add_task(async function bestMatch() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + // Set up a suggestion with `is_top_pick` and an unknown provider so that + // UrlbarProviderQuickSuggest will make a default result for it. + MerinoTestUtils.server.response.body.suggestions = [ + { + is_top_pick: true, + provider: "some_top_pick_provider", + full_keyword: "full_keyword", + title: "title", + url: "url", + icon: null, + score: 1, + }, + ]; + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [ + { + isBestMatch: true, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "some_top_pick_provider", + title: "title", + 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", + }, + displayUrl: "url", + source: "merino", + provider: "some_top_pick_provider", + }, + }, + ], + }); + + // This isn't necessary since `check_results()` checks `isBestMatch`, but + // check it here explicitly for good measure. + Assert.ok(context.results[0].isBestMatch, "Result is a best match"); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js new file mode 100644 index 0000000000..61b1b9186f --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js @@ -0,0 +1,173 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Tests Merino session integration with UrlbarProviderQuickSuggest. + +"use strict"; + +// `UrlbarProviderQuickSuggest.#merino` is lazily created on the first Merino +// fetch, so it's easiest to create `gClient` lazily too. +ChromeUtils.defineLazyGetter( + this, + "gClient", + () => UrlbarProviderQuickSuggest._test_merino +); + +add_setup(async () => { + await MerinoTestUtils.server.start(); + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + prefs: [ + ["suggest.quicksuggest.sponsored", true], + ["quicksuggest.dataCollection.enabled", true], + ], + }); +}); + +// In a single engagement, all requests should use the same session ID and the +// sequence number should be incremented. +add_task(async function singleEngagement() { + let controller = UrlbarTestUtils.newMockController(); + + for (let i = 0; i < 3; i++) { + let searchString = "search" + i; + await controller.startQuery( + createContext(searchString, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }) + ); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString, + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: i, + }, + }, + ]); + } + + // End the engagement to reset the session for the next test. + endEngagement({ controller }); +}); + +// New engagements should not use the same session ID as previous engagements +// and the sequence number should be reset. This task completes each engagement +// successfully. +add_task(async function manyEngagements_engagement() { + await doManyEngagementsTest("engagement"); +}); + +// New engagements should not use the same session ID as previous engagements +// and the sequence number should be reset. This task abandons each engagement. +add_task(async function manyEngagements_abandonment() { + await doManyEngagementsTest("abandonment"); +}); + +async function doManyEngagementsTest(state) { + let controller = UrlbarTestUtils.newMockController(); + + for (let i = 0; i < 3; i++) { + let searchString = "search" + i; + let context = createContext(searchString, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await controller.startQuery(context); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString, + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + }, + }, + ]); + + endEngagement({ context, state, controller }); + } +} + +// When a search is canceled after the request is sent and before the Merino +// response is received, the sequence number should still be incremented. +add_task(async function canceledQueries() { + let controller = UrlbarTestUtils.newMockController(); + + for (let i = 0; i < 3; i++) { + // Send the first response after a delay to make sure the client will not + // receive it before we start the second fetch. + MerinoTestUtils.server.response.delay = UrlbarPrefs.get("merino.timeoutMs"); + + // Start the first search. + let requestPromise = MerinoTestUtils.server.waitForNextRequest(); + let searchString1 = "search" + i; + controller.startQuery( + createContext(searchString1, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }) + ); + + // Wait until the first request is received before starting the second + // search. If we started the second search immediately, the first would be + // canceled before the provider is even called due to the urlbar's 50ms + // delay (see `browser.urlbar.delay`) so the sequence number would not be + // incremented for it. Here we want to test the case where the first search + // is canceled after the request is sent and the number is incremented. + await requestPromise; + delete MerinoTestUtils.server.response.delay; + + // Now do a second search that cancels the first. + let searchString2 = searchString1 + "again"; + await controller.startQuery( + createContext(searchString2, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }) + ); + + // The sequence number should have been incremented for each search. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString1, + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i, + }, + }, + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString2, + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + } + + // End the engagement to reset the session for the next test. + endEngagement({ controller }); +}); + +function endEngagement({ controller, context = null, state = "engagement" }) { + UrlbarProviderQuickSuggest.onEngagement( + state, + context || + createContext("endEngagement", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + { selIndex: -1 }, + controller + ); + + Assert.strictEqual( + gClient.sessionID, + null, + "sessionID is null after engagement" + ); + Assert.strictEqual( + gClient._test_sessionTimer, + null, + "sessionTimer is null after engagement" + ); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v1.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v1.js new file mode 100644 index 0000000000..851757b11b --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v1.js @@ -0,0 +1,490 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Tests quick suggest prefs migration from unversioned prefs to version 1. + +"use strict"; + +// Expected version 1 default-branch prefs +const DEFAULT_PREFS = { + history: { + "quicksuggest.enabled": false, + }, + offline: { + "quicksuggest.enabled": true, + "quicksuggest.dataCollection.enabled": false, + "quicksuggest.shouldShowOnboardingDialog": false, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + online: { + "quicksuggest.enabled": true, + "quicksuggest.dataCollection.enabled": false, + "quicksuggest.shouldShowOnboardingDialog": true, + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, +}; + +// Migration will use these values to migrate only up to version 1 instead of +// the current version. +const TEST_OVERRIDES = { + migrationVersion: 1, + defaultPrefs: DEFAULT_PREFS, +}; + +add_setup(async () => { + await UrlbarTestUtils.initNimbusFeature(); +}); + +// The following tasks test OFFLINE TO OFFLINE + +// Migrating from: +// * Offline (suggestions on by default) +// * User did not override any defaults +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + }, + }); +}); + +// Migrating from: +// * Offline (suggestions on by default) +// * Main suggestions pref: user left on +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: remain off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Offline (suggestions on by default) +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user left on (but ignored since main was off) +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Offline (suggestions on by default) +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + "suggest.quicksuggest.sponsored": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// The following tasks test OFFLINE TO ONLINE + +// Migrating from: +// * Offline (suggestions on by default) +// * User did not override any defaults +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + }, + }); +}); + +// Migrating from: +// * Offline (suggestions on by default) +// * Main suggestions pref: user left on +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Offline (suggestions on by default) +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user left on (but ignored since main was off) +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Offline (suggestions on by default) +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + "suggest.quicksuggest.sponsored": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// The following tasks test ONLINE TO OFFLINE + +// Migrating from: +// * Online (suggestions off by default) +// * User did not override any defaults +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on (since main pref had default value) +// * Sponsored suggestions: on (since main & sponsored prefs had default values) +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + }, + }); +}); + +// Migrating from: +// * Online (suggestions off by default) +// * Main suggestions pref: user left off +// * Sponsored suggestions: user turned on (but ignored since main was off) +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: on (see below) +// * Data collection: off +// +// It's unfortunate that sponsored suggestions are ultimately on since before +// the migration no suggestions were shown to the user. There's nothing we can +// do about it, aside from forcing off suggestions in more cases than we want. +// The reason is that at the time of migration we can't tell that the previous +// scenario was online -- or more precisely that it wasn't history. If we knew +// it wasn't history, then we'd know to turn sponsored off; if we knew it *was* +// history, then we'd know to turn sponsored -- and non-sponsored -- on, since +// the scenario at the time of migration is offline, where suggestions should be +// enabled by default. +// +// This is the reason we now record `quicksuggest.scenario` on the user branch +// and not the default branch as we previously did. +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": true, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": true, + }, + }, + }); +}); + +// Migrating from: +// * Online (suggestions off by default) +// * Main suggestions pref: user turned on +// * Sponsored suggestions: user left off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain off +// * Data collection: off (since scenario is offline) +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": true, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + }, + }, + }); +}); + +// Migrating from: +// * Online (suggestions off by default) +// * Main suggestions pref: user turned on +// * Sponsored suggestions: user turned on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain on +// * Data collection: off (since scenario is offline) +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": true, + "suggest.quicksuggest.sponsored": true, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + }); +}); + +// The following tasks test ONLINE TO ONLINE + +// Migrating from: +// * Online (suggestions off by default) +// * User did not override any defaults +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + }, + }); +}); + +// Migrating from: +// * Online (suggestions off by default) +// * Main suggestions pref: user left off +// * Sponsored suggestions: user turned on (but ignored since main was off) +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + }, + }); +}); + +// Migrating from: +// * Online (suggestions off by default) +// * Main suggestions pref: user turned on +// * Sponsored suggestions: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain off +// * Data collection: ON (since user effectively opted in by turning on +// suggestions) +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Online (suggestions off by default) +// * Main suggestions pref: user turned on +// * Sponsored suggestions: user turned on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain on +// * Data collection: ON (since user effectively opted in by turning on +// suggestions) +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": true, + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v2.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v2.js new file mode 100644 index 0000000000..991e8c66f9 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v2.js @@ -0,0 +1,1355 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Tests quick suggest prefs migration to version 2. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", +}); + +// Expected version 2 default-branch prefs +const DEFAULT_PREFS = { + history: { + "quicksuggest.enabled": false, + }, + offline: { + "quicksuggest.enabled": true, + "quicksuggest.dataCollection.enabled": false, + "quicksuggest.shouldShowOnboardingDialog": false, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + online: { + "quicksuggest.enabled": true, + "quicksuggest.dataCollection.enabled": false, + "quicksuggest.shouldShowOnboardingDialog": true, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, +}; + +// Migration will use these values to migrate only up to version 1 instead of +// the current version. +// Currently undefined because version 2 is the current migration version and we +// want migration to use its actual values, not overrides. When version 3 is +// added, set this to an object like the one in test_quicksuggest_migrate_v1.js. +const TEST_OVERRIDES = undefined; + +add_setup(async () => { + await UrlbarTestUtils.initNimbusFeature(); +}); + +// The following tasks test OFFLINE UNVERSIONED to OFFLINE + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user left on +// * Sponsored suggestions: user left on +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user left on (but ignored since main was off) +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user left on +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + "suggest.quicksuggest.sponsored": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// The following tasks test OFFLINE UNVERSIONED to ONLINE + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user left on +// * Sponsored suggestions: user left on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user left on (but ignored since main was off) +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user left on +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + "suggest.quicksuggest.sponsored": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// The following tasks test ONLINE UNVERSIONED to ONLINE when the user was NOT +// SHOWN THE MODAL (e.g., because they didn't restart) + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: no +// * User enrolled in online where suggestions were disabled by default, did not +// turn on either type of suggestion, was not shown the modal (e.g., because +// they didn't restart), and upgraded to v2 +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await withOnlineExperiment(async () => { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + }, + }); + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: no +// * User enrolled in online where suggestions were disabled by default, turned +// on main suggestions pref and left off sponsored suggestions, was not shown +// the modal (e.g., because they didn't restart), and upgraded to v2 +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: on (since they opted in by checking the main checkbox +// while in online) +add_task(async function () { + await withOnlineExperiment(async () => { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + }, + }, + }); + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: no +// * User enrolled in online where suggestions were disabled by default, left +// off main suggestions pref and turned on sponsored suggestions, was not +// shown the modal (e.g., because they didn't restart), and upgraded to v2 +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await withOnlineExperiment(async () => { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": true, + }, + }, + }); + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: no +// * User enrolled in online where suggestions were disabled by default, turned +// on main suggestions pref and sponsored suggestions, was not shown the +// modal (e.g., because they didn't restart), and upgraded to v2 +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on (since they opted in by checking the main checkbox +// while in online) +add_task(async function () { + await withOnlineExperiment(async () => { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": true, + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + }); + }); +}); + +// The following tasks test ONLINE UNVERSIONED to ONLINE when the user WAS SHOWN +// THE MODAL + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: yes +// * The following end up with same prefs and are covered by this task: +// 1. User did not opt in and left off both the main suggestions pref and +// sponsored suggestions +// 2. User opted in but then later turned off both the main suggestions pref +// and sponsored suggestions +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.showedOnboardingDialog": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": false, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: yes +// * The following end up with same prefs and are covered by this task: +// 1. User did not opt in but then later turned on the main suggestions pref +// 2. User opted in but then later turned off sponsored suggestions +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: yes +// * The following end up with same prefs and are covered by this task: +// 1. User did not opt in but then later turned on sponsored suggestions +// 2. User opted in but then later turned off the main suggestions pref +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": false, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: yes +// * The following end up with same prefs and are covered by this task: +// 1. User did not opt in but then later turned on both the main suggestions +// pref and sponsored suggestions +// 2. User opted in and left on both the main suggestions pref and sponsored +// suggestions +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest": true, + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// The following tasks test OFFLINE VERSION 1 to OFFLINE + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user turned on +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + "suggest.quicksuggest.sponsored": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// The following tasks test OFFLINE VERSION 1 to ONLINE + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + "suggest.quicksuggest.sponsored": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// The following tasks test ONLINE VERSION 1 to ONLINE when the user was NOT +// SHOWN THE MODAL (e.g., because they didn't restart) + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user left off +// * Sponsored suggestions: user left off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user left off +// * Sponsored suggestions: user left off +// * Data collection: user turned on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user left off +// * Sponsored suggestions: user turned on +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user left off +// * Sponsored suggestions: user turned on +// * Data collection: user turned on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user turned on +// * Sponsored suggestions: user left off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "suggest.quicksuggest.nonsponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user turned on +// * Sponsored suggestions: user left off +// * Data collection: user turned on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "suggest.quicksuggest.nonsponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user turned on +// * Sponsored suggestions: user turned on +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user turned on +// * Sponsored suggestions: user turned on +// * Data collection: user turned on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// The following tasks test ONLINE VERSION 1 to ONLINE when the user WAS SHOWN +// THE MODAL WHILE PREFS WERE UNVERSIONED + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were unversioned +// * User opted in: no +// * Non-sponsored suggestions: user left off +// * Sponsored suggestions: user left off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were unversioned +// * User opted in: no +// * Non-sponsored suggestions: user turned on +// * Sponsored suggestions: user left off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest.nonsponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were unversioned +// * User opted in: yes +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user left on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were unversioned +// * User opted in: yes +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user left on +// * Data collection: user left on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were unversioned +// * User opted in: yes +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user turned off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": false, + }, + }, + }); +}); + +// The following tasks test ONLINE VERSION 1 to ONLINE when the user WAS SHOWN +// THE MODAL WHILE PREFS WERE VERSION 1 + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were version 1 +// * User opted in: no +// * Non-sponsored suggestions: user left off +// * Sponsored suggestions: user left off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + "quicksuggest.onboardingDialogChoice": "not_now_link", + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were version 1 +// * User opted in: yes +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user left on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + "quicksuggest.onboardingDialogChoice": "accept", + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +async function withOnlineExperiment(callback) { + let { enrollmentPromise, doExperimentCleanup } = + ExperimentFakes.enrollmentHelper( + ExperimentFakes.recipe("firefox-suggest-offline-vs-online", { + active: true, + branches: [ + { + slug: "treatment", + features: [ + { + featureId: NimbusFeatures.urlbar.featureId, + value: { + enabled: true, + }, + }, + ], + }, + ], + }) + ); + await enrollmentPromise; + await callback(); + await doExperimentCleanup(); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_nonUniqueKeywords.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_nonUniqueKeywords.js new file mode 100644 index 0000000000..8ac7b85ba2 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_nonUniqueKeywords.js @@ -0,0 +1,285 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Tests non-unique keywords, i.e., keywords used by multiple suggestions. + +"use strict"; + +const { DEFAULT_SUGGESTION_SCORE } = UrlbarProviderQuickSuggest; + +// For each of these objects, the test creates a quick suggest result (the kind +// stored in the remote settings data, not a urlbar result), the corresponding +// expected quick suggest suggestion, and the corresponding expected urlbar +// result. The test assumes results and suggestions are returned in the order +// listed here. +let SUGGESTIONS_DATA = [ + { + keywords: ["aaa"], + isSponsored: true, + score: DEFAULT_SUGGESTION_SCORE, + }, + { + keywords: ["aaa", "bbb"], + isSponsored: false, + score: 2 * DEFAULT_SUGGESTION_SCORE, + }, + { + keywords: ["bbb"], + isSponsored: true, + score: 4 * DEFAULT_SUGGESTION_SCORE, + }, + { + keywords: ["bbb"], + isSponsored: false, + score: 3 * DEFAULT_SUGGESTION_SCORE, + }, + { + keywords: ["ccc"], + isSponsored: true, + score: DEFAULT_SUGGESTION_SCORE, + }, +]; + +// Test cases. In this object, keywords map to subtest cases. For each keyword, +// the test calls `query(keyword)` and checks that the indexes (relative to +// `SUGGESTIONS_DATA`) of the returned quick suggest results are the ones in +// `expectedIndexes`. Then the test does a series of urlbar searches using the +// keyword as the search string, one search per object in `searches`. Sponsored +// and non-sponsored urlbar results are enabled as defined by `sponsored` and +// `nonsponsored`. `expectedIndex` is the expected index (relative to +// `SUGGESTIONS_DATA`) of the returned urlbar result. +let TESTS = { + aaa: { + // 0: sponsored + // 1: nonsponsored, score = 2x + expectedIndexes: [0, 1], + searches: [ + { + sponsored: true, + nonsponsored: true, + expectedIndex: 1, + }, + { + sponsored: false, + nonsponsored: true, + expectedIndex: 1, + }, + { + sponsored: true, + nonsponsored: false, + expectedIndex: 0, + }, + { + sponsored: false, + nonsponsored: false, + expectedIndex: undefined, + }, + ], + }, + bbb: { + // 1: nonsponsored, score = 2x + // 2: sponsored, score = 4x, + // 3: nonsponsored, score = 3x + expectedIndexes: [1, 2, 3], + searches: [ + { + sponsored: true, + nonsponsored: true, + expectedIndex: 2, + }, + { + sponsored: false, + nonsponsored: true, + expectedIndex: 3, + }, + { + sponsored: true, + nonsponsored: false, + expectedIndex: 2, + }, + { + sponsored: false, + nonsponsored: false, + expectedIndex: undefined, + }, + ], + }, + ccc: { + // 4: sponsored + expectedIndexes: [4], + searches: [ + { + sponsored: true, + nonsponsored: true, + expectedIndex: 4, + }, + { + sponsored: false, + nonsponsored: true, + expectedIndex: undefined, + }, + { + sponsored: true, + nonsponsored: false, + expectedIndex: 4, + }, + { + sponsored: false, + nonsponsored: false, + expectedIndex: undefined, + }, + ], + }, +}; + +add_task(async function () { + // Create results and suggestions based on `SUGGESTIONS_DATA`. + let qsResults = []; + let qsSuggestions = []; + let urlbarResults = []; + for (let i = 0; i < SUGGESTIONS_DATA.length; i++) { + let { keywords, isSponsored, score } = SUGGESTIONS_DATA[i]; + + // quick suggest result + let qsResult = { + keywords, + score, + id: i, + url: "http://example.com/" + i, + title: "Title " + i, + click_url: "http://example.com/click", + impression_url: "http://example.com/impression", + advertiser: "TestAdvertiser", + iab_category: isSponsored ? "22 - Shopping" : "5 - Education", + }; + qsResults.push(qsResult); + + // expected quick suggest suggestion + let qsSuggestion = { + ...qsResult, + score, + block_id: qsResult.id, + is_sponsored: isSponsored, + source: "remote-settings", + icon: null, + position: undefined, + provider: "AdmWikipedia", + }; + delete qsSuggestion.keywords; + delete qsSuggestion.id; + qsSuggestions.push(qsSuggestion); + + // expected urlbar result + urlbarResults.push({ + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + isSponsored, + telemetryType: isSponsored ? "adm_sponsored" : "adm_nonsponsored", + sponsoredBlockId: qsResult.id, + url: qsResult.url, + originalUrl: qsResult.url, + displayUrl: qsResult.url, + title: qsResult.title, + sponsoredClickUrl: qsResult.click_url, + sponsoredImpressionUrl: qsResult.impression_url, + sponsoredAdvertiser: qsResult.advertiser, + sponsoredIabCategory: qsResult.iab_category, + icon: null, + descriptionL10n: isSponsored + ? { id: "urlbar-result-action-sponsored" } + : undefined, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + source: "remote-settings", + provider: "AdmWikipedia", + }, + }); + } + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "data", + attachment: qsResults, + }, + ], + prefs: [ + ["suggest.quicksuggest.sponsored", true], + ["suggest.quicksuggest.nonsponsored", true], + ], + }); + + // Run a test for each keyword. + for (let [keyword, test] of Object.entries(TESTS)) { + info("Running subtest " + JSON.stringify({ keyword, test })); + + let { expectedIndexes, searches } = test; + + // Call `query()`. + Assert.deepEqual( + await QuickSuggest.jsBackend.query(keyword), + expectedIndexes.map(i => ({ + ...qsSuggestions[i], + full_keyword: keyword, + })), + `query() for keyword ${keyword}` + ); + + // Now do a urlbar search for the keyword with all possible combinations of + // sponsored and non-sponsored suggestions enabled and disabled. + for (let sponsored of [true, false]) { + for (let nonsponsored of [true, false]) { + // Find the matching `searches` object. + let search = searches.find( + s => s.sponsored == sponsored && s.nonsponsored == nonsponsored + ); + Assert.ok( + search, + "Sanity check: Search test case specified for " + + JSON.stringify({ keyword, sponsored, nonsponsored }) + ); + + info( + "Running urlbar search subtest " + + JSON.stringify({ keyword, expectedIndexes, search }) + ); + + UrlbarPrefs.set("suggest.quicksuggest.sponsored", sponsored); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", nonsponsored); + await QuickSuggestTestUtils.forceSync(); + + // Set up the search and do it. + let context = createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + + let matches = []; + if (search.expectedIndex !== undefined) { + matches.push({ + ...urlbarResults[search.expectedIndex], + payload: { + ...urlbarResults[search.expectedIndex].payload, + qsSuggestion: keyword, + }, + }); + } + + await check_results({ context, matches }); + } + } + + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + await QuickSuggestTestUtils.forceSync(); + } +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_offlineDefault.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_offlineDefault.js new file mode 100644 index 0000000000..c01792e321 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_offlineDefault.js @@ -0,0 +1,127 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Tests `UrlbarPrefs.updateFirefoxSuggestScenario` in isolation under the +// assumption that the offline scenario should be enabled by default for US en. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Region: "resource://gre/modules/Region.sys.mjs", +}); + +// All the prefs that `updateFirefoxSuggestScenario` sets along with the +// expected default-branch values when offline is enabled and when it's not +// enabled. +const PREFS = [ + { + name: "browser.urlbar.quicksuggest.enabled", + get: "getBoolPref", + set: "setBoolPref", + expectedOfflineValue: true, + expectedOtherValue: false, + }, + { + name: "browser.urlbar.quicksuggest.shouldShowOnboardingDialog", + get: "getBoolPref", + set: "setBoolPref", + expectedOfflineValue: false, + expectedOtherValue: true, + }, + { + name: "browser.urlbar.suggest.quicksuggest.nonsponsored", + get: "getBoolPref", + set: "setBoolPref", + expectedOfflineValue: true, + expectedOtherValue: false, + }, + { + name: "browser.urlbar.suggest.quicksuggest.sponsored", + get: "getBoolPref", + set: "setBoolPref", + expectedOfflineValue: true, + expectedOtherValue: false, + }, +]; + +add_setup(async () => { + await UrlbarTestUtils.initNimbusFeature(); +}); + +add_task(async function test() { + let tests = [ + { locale: "en-US", home: "US", expectedOfflineDefault: true }, + { locale: "en-US", home: "CA", expectedOfflineDefault: false }, + { locale: "en-CA", home: "US", expectedOfflineDefault: true }, + { locale: "en-CA", home: "CA", expectedOfflineDefault: false }, + { locale: "en-GB", home: "US", expectedOfflineDefault: true }, + { locale: "en-GB", home: "GB", expectedOfflineDefault: false }, + { locale: "de", home: "US", expectedOfflineDefault: false }, + { locale: "de", home: "DE", expectedOfflineDefault: false }, + ]; + for (let { locale, home, expectedOfflineDefault } of tests) { + await doTest({ locale, home, expectedOfflineDefault }); + } +}); + +/** + * Sets the app's locale and region, calls + * `UrlbarPrefs.updateFirefoxSuggestScenario`, and asserts that the pref values + * are correct. + * + * @param {object} options + * Options object. + * @param {string} options.locale + * The locale to simulate. + * @param {string} options.home + * The "home" region to simulate. + * @param {boolean} options.expectedOfflineDefault + * The expected value of whether offline should be enabled by default given + * the locale and region. + */ +async function doTest({ locale, home, expectedOfflineDefault }) { + // Setup: Clear any user values and save original default-branch values. + for (let pref of PREFS) { + Services.prefs.clearUserPref(pref.name); + pref.originalDefault = Services.prefs + .getDefaultBranch(pref.name) + [pref.get](""); + } + + // Set the region and locale, call the function, check the pref values. + Region._setHomeRegion(home, false); + await QuickSuggestTestUtils.withLocales([locale], async () => { + await UrlbarPrefs.updateFirefoxSuggestScenario(); + for (let { name, get, expectedOfflineValue, expectedOtherValue } of PREFS) { + let expectedValue = expectedOfflineDefault + ? expectedOfflineValue + : expectedOtherValue; + + // Check the default-branch value. + Assert.strictEqual( + Services.prefs.getDefaultBranch(name)[get](""), + expectedValue, + `Default pref value for ${name}, locale ${locale}, home ${home}` + ); + + // For good measure, also check the return value of `UrlbarPrefs.get` + // since we use it everywhere. The value should be the same as the + // default-branch value. + UrlbarPrefs.get( + name.replace("browser.urlbar.", ""), + expectedValue, + `UrlbarPrefs.get() value for ${name}, locale ${locale}, home ${home}` + ); + } + }); + + // Teardown: Restore original default-branch values for the next task. + for (let { name, originalDefault, set } of PREFS) { + if (originalDefault === undefined) { + Services.prefs.deleteBranch(name); + } else { + Services.prefs.getDefaultBranch(name)[set]("", originalDefault); + } + } +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_pocket.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_pocket.js new file mode 100644 index 0000000000..29133a8579 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_pocket.js @@ -0,0 +1,531 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Tests Pocket quick suggest results. + +"use strict"; + +const LOW_KEYWORD = "low one two"; +const HIGH_KEYWORD = "high three"; + +const REMOTE_SETTINGS_DATA = [ + { + type: "pocket-suggestions", + attachment: [ + { + url: "https://example.com/pocket-0", + title: "Pocket Suggestion 0", + description: "Pocket description 0", + lowConfidenceKeywords: [LOW_KEYWORD, "how to low"], + highConfidenceKeywords: [HIGH_KEYWORD], + score: 0.25, + }, + { + url: "https://example.com/pocket-1", + title: "Pocket Suggestion 1", + description: "Pocket description 1", + lowConfidenceKeywords: ["other low"], + highConfidenceKeywords: ["another high"], + score: 0.25, + }, + ], + }, +]; + +add_setup(async () => { + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: REMOTE_SETTINGS_DATA, + prefs: [ + ["suggest.quicksuggest.nonsponsored", true], + ["pocket.featureGate", true], + ], + }); +}); + +add_task(async function telemetryType() { + Assert.equal( + QuickSuggest.getFeature("PocketSuggestions").getSuggestionTelemetryType({}), + "pocket", + "Telemetry type should be 'pocket'" + ); +}); + +// When non-sponsored suggestions are disabled, Pocket suggestions should be +// disabled. +add_tasks_with_rust(async function nonsponsoredDisabled() { + // Disable sponsored suggestions. Pocket suggestions are non-sponsored, so + // doing this should not prevent them from being enabled. + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + // First make sure the suggestion is added when non-sponsored suggestions are + // enabled. + await check_results({ + context: createContext(LOW_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [makeExpectedResult({ searchString: LOW_KEYWORD })], + }); + + // Now disable them. + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + await check_results({ + context: createContext(LOW_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.clear("suggest.quicksuggest.sponsored"); + await QuickSuggestTestUtils.forceSync(); +}); + +// When Pocket-specific preferences are disabled, suggestions should not be +// added. +add_tasks_with_rust(async function pocketSpecificPrefsDisabled() { + const prefs = ["suggest.pocket", "pocket.featureGate"]; + for (const pref of prefs) { + // First make sure the suggestion is added. + await check_results({ + context: createContext(LOW_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [makeExpectedResult({ searchString: LOW_KEYWORD })], + }); + + // Now disable the pref. + UrlbarPrefs.set(pref, false); + await check_results({ + context: createContext(LOW_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + // Revert. + UrlbarPrefs.set(pref, true); + await QuickSuggestTestUtils.forceSync(); + } +}); + +// Check wheather the Pocket suggestions will be shown by the setup of Nimbus +// variable. +add_tasks_with_rust(async function nimbus() { + // Disable the fature gate. + UrlbarPrefs.set("pocket.featureGate", false); + await check_results({ + context: createContext(LOW_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + // Enable by Nimbus. + const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature({ + pocketFeatureGate: true, + }); + await QuickSuggestTestUtils.forceSync(); + await check_results({ + context: createContext(LOW_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [makeExpectedResult({ searchString: LOW_KEYWORD })], + }); + await cleanUpNimbusEnable(); + + // Enable locally. + UrlbarPrefs.set("pocket.featureGate", true); + await QuickSuggestTestUtils.forceSync(); + + // Disable by Nimbus. + const cleanUpNimbusDisable = await UrlbarTestUtils.initNimbusFeature({ + pocketFeatureGate: false, + }); + await check_results({ + context: createContext(LOW_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + await cleanUpNimbusDisable(); + + // Revert. + UrlbarPrefs.set("pocket.featureGate", true); + await QuickSuggestTestUtils.forceSync(); +}); + +// The suggestion should be shown as a top pick when a high-confidence keyword +// is matched. +add_tasks_with_rust(async function topPick() { + await check_results({ + context: createContext(HIGH_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ searchString: HIGH_KEYWORD, isTopPick: true }), + ], + }); +}); + +// Low-confidence keywords should do prefix matching starting at the first word. +add_tasks_with_rust(async function lowPrefixes() { + // search string -> should match + let tests = { + l: false, + lo: false, + low: true, + "low ": true, + "low o": true, + "low on": true, + "low one": true, + "low one ": true, + "low one t": true, + "low one tw": true, + "low one two": true, + "low one two ": false, + }; + for (let [searchString, shouldMatch] of Object.entries(tests)) { + info("Doing search: " + JSON.stringify({ searchString, shouldMatch })); + await check_results({ + context: createContext(searchString, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: shouldMatch + ? [makeExpectedResult({ searchString, fullKeyword: LOW_KEYWORD })] + : [], + }); + } +}); + +// Low-confidence keywords that start with "how to" should do prefix matching +// starting at "how to" instead of the first word. +// +// Note: The Rust implementation doesn't support this. +add_task( + { + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function lowPrefixes_howTo() { + // search string -> should match + let tests = { + h: false, + ho: false, + how: false, + "how ": false, + "how t": false, + "how to": true, + "how to ": true, + "how to l": true, + "how to lo": true, + "how to low": true, + }; + for (let [searchString, shouldMatch] of Object.entries(tests)) { + info("Doing search: " + JSON.stringify({ searchString, shouldMatch })); + await check_results({ + context: createContext(searchString, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: shouldMatch + ? [makeExpectedResult({ searchString, fullKeyword: "how to low" })] + : [], + }); + } + } +); + +// High-confidence keywords should not do prefix matching at all. +add_tasks_with_rust(async function highPrefixes() { + // search string -> should match + let tests = { + h: false, + hi: false, + hig: false, + high: false, + "high ": false, + "high t": false, + "high th": false, + "high thr": false, + "high thre": false, + "high three": true, + "high three ": false, + }; + for (let [searchString, shouldMatch] of Object.entries(tests)) { + info("Doing search: " + JSON.stringify({ searchString, shouldMatch })); + await check_results({ + context: createContext(searchString, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: shouldMatch + ? [ + makeExpectedResult({ + searchString, + fullKeyword: HIGH_KEYWORD, + isTopPick: true, + }), + ] + : [], + }); + } +}); + +// Keyword matching should be case insenstive. +add_tasks_with_rust(async function uppercase() { + await check_results({ + context: createContext(LOW_KEYWORD.toUpperCase(), { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + searchString: LOW_KEYWORD.toUpperCase(), + fullKeyword: LOW_KEYWORD, + }), + ], + }); + await check_results({ + context: createContext(HIGH_KEYWORD.toUpperCase(), { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + searchString: HIGH_KEYWORD.toUpperCase(), + fullKeyword: HIGH_KEYWORD, + isTopPick: true, + }), + ], + }); +}); + +// Tests the "Not relevant" command: a dismissed suggestion shouldn't be added. +add_tasks_with_rust(async function notRelevant() { + let result = makeExpectedResult({ searchString: LOW_KEYWORD }); + + info("Triggering the 'Not relevant' command"); + QuickSuggest.getFeature("PocketSuggestions").handleCommand( + { + controller: { removeResult() {} }, + }, + result, + "not_relevant" + ); + await QuickSuggest.blockedSuggestions._test_readyPromise; + + Assert.ok( + await QuickSuggest.blockedSuggestions.has(result.payload.originalUrl), + "The result's URL should be blocked" + ); + + info("Doing search for blocked suggestion"); + await check_results({ + context: createContext(LOW_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + info("Doing search for blocked suggestion using high-confidence keyword"); + await check_results({ + context: createContext(HIGH_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + info("Doing search for a suggestion that wasn't blocked"); + await check_results({ + context: createContext("other low", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + searchString: "other low", + suggestion: REMOTE_SETTINGS_DATA[0].attachment[1], + }), + ], + }); + + info("Clearing blocked suggestions"); + await QuickSuggest.blockedSuggestions.clear(); + + info("Doing search for unblocked suggestion"); + await check_results({ + context: createContext(LOW_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [result], + }); +}); + +// Tests the "Not interested" command: all Pocket suggestions should be disabled +// and not added anymore. +add_tasks_with_rust(async function notInterested() { + let result = makeExpectedResult({ searchString: LOW_KEYWORD }); + + info("Triggering the 'Not interested' command"); + QuickSuggest.getFeature("PocketSuggestions").handleCommand( + { + controller: { removeResult() {} }, + }, + result, + "not_interested" + ); + + Assert.ok( + !UrlbarPrefs.get("suggest.pocket"), + "Pocket suggestions should be disabled" + ); + + info("Doing search for the suggestion the command was used on"); + await check_results({ + context: createContext(LOW_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + info("Doing search for another Pocket suggestion"); + await check_results({ + context: createContext("other low", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + UrlbarPrefs.clear("suggest.pocket"); + await QuickSuggestTestUtils.forceSync(); +}); + +// Tests the "show less frequently" behavior. +add_tasks_with_rust(async function showLessFrequently() { + await doShowLessFrequentlyTests({ + feature: QuickSuggest.getFeature("PocketSuggestions"), + showLessFrequentlyCountPref: "pocket.showLessFrequentlyCount", + nimbusCapVariable: "pocketShowLessFrequentlyCap", + expectedResult: searchString => + makeExpectedResult({ searchString, fullKeyword: LOW_KEYWORD }), + keyword: LOW_KEYWORD, + }); +}); + +// The `Pocket` Rust provider should be passed to the Rust component when +// querying depending on whether Pocket suggestions are enabled. +add_task(async function rustProviders() { + // TODO bug 1874074: The Rust component fetches Pocket suggestions when the + // AMO provider is specified regardless of whether the Pocket provider is + // specified. AMO suggestions are enabled by default, so disable them first so + // that the Rust backend does not pass in the AMO provider. + UrlbarPrefs.set("suggest.addons", false); + + await doRustProvidersTests({ + searchString: LOW_KEYWORD, + tests: [ + { + prefs: { + "suggest.pocket": true, + }, + expectedUrls: ["https://example.com/pocket-0"], + }, + { + prefs: { + "suggest.pocket": false, + }, + expectedUrls: [], + }, + ], + }); + + UrlbarPrefs.clear("suggest.addons"); + UrlbarPrefs.clear("suggest.pocket"); + await QuickSuggestTestUtils.forceSync(); +}); + +function makeExpectedResult({ + searchString, + fullKeyword = searchString, + suggestion = REMOTE_SETTINGS_DATA[0].attachment[0], + source = "remote-settings", + isTopPick = false, +} = {}) { + if ( + source == "remote-settings" && + UrlbarPrefs.get("quicksuggest.rustEnabled") + ) { + source = "rust"; + } + + let provider; + let keywordSubstringNotTyped = fullKeyword.substring(searchString.length); + let description = suggestion.description; + switch (source) { + case "remote-settings": + provider = "PocketSuggestions"; + break; + case "rust": + provider = "Pocket"; + // Rust suggestions currently do not include full keyword or description. + keywordSubstringNotTyped = ""; + description = suggestion.title; + break; + case "merino": + provider = "pocket"; + break; + } + + let url = new URL(suggestion.url); + url.searchParams.set("utm_medium", "firefox-desktop"); + url.searchParams.set("utm_source", "firefox-suggest"); + url.searchParams.set("utm_campaign", "pocket-collections-in-the-address-bar"); + url.searchParams.set("utm_content", "treatment"); + + return { + isBestMatch: isTopPick, + suggestedIndex: isTopPick ? 1 : -1, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.OTHER_NETWORK, + heuristic: false, + payload: { + source, + provider, + telemetryType: "pocket", + title: suggestion.title, + url: url.href, + displayUrl: url.href.replace(/^https:\/\//, ""), + originalUrl: suggestion.url, + description: isTopPick ? description : "", + icon: isTopPick + ? "chrome://global/skin/icons/pocket.svg" + : "chrome://global/skin/icons/pocket-favicon.ico", + helpUrl: QuickSuggest.HELP_URL, + shouldShowUrl: true, + bottomTextL10n: { + id: "firefox-suggest-pocket-bottom-text", + args: { + keywordSubstringTyped: searchString, + keywordSubstringNotTyped, + }, + }, + }, + }; +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_positionInSuggestions.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_positionInSuggestions.js new file mode 100644 index 0000000000..d1845a9b22 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_positionInSuggestions.js @@ -0,0 +1,487 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests for quick suggest result position specified in suggestions. + */ + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderHeuristicFallback: + "resource:///modules/UrlbarProviderHeuristicFallback.sys.mjs", + UrlbarProviderPlaces: "resource:///modules/UrlbarProviderPlaces.sys.mjs", + UrlbarProviderTabToSearch: + "resource:///modules/UrlbarProviderTabToSearch.sys.mjs", +}); + +const SPONSORED_SECOND_POSITION_RESULT = { + id: 1, + url: "http://example.com/?q=sponsored-second", + title: "sponsored second", + keywords: ["s-s"], + click_url: "http://click.reporting.test.com/", + impression_url: "http://impression.reporting.test.com/", + advertiser: "TestAdvertiser", + iab_category: "22 - Shopping", + position: 1, +}; +const SPONSORED_NORMAL_POSITION_RESULT = { + id: 2, + url: "http://example.com/?q=sponsored-normal", + title: "sponsored normal", + keywords: ["s-n"], + click_url: "http://click.reporting.test.com/", + impression_url: "http://impression.reporting.test.com/", + advertiser: "TestAdvertiser", + iab_category: "22 - Shopping", +}; +const NONSPONSORED_SECOND_POSITION_RESULT = { + id: 3, + url: "http://example.com/?q=nonsponsored-second", + title: "nonsponsored second", + keywords: ["n-s"], + click_url: "http://click.reporting.test.com/nonsponsored", + impression_url: "http://impression.reporting.test.com/nonsponsored", + advertiser: "TestAdvertiserNonSponsored", + iab_category: "5 - Education", + position: 1, +}; +const NONSPONSORED_NORMAL_POSITION_RESULT = { + id: 4, + url: "http://example.com/?q=nonsponsored-normal", + title: "nonsponsored normal", + keywords: ["n-n"], + click_url: "http://click.reporting.test.com/nonsponsored", + impression_url: "http://impression.reporting.test.com/nonsponsored", + advertiser: "TestAdvertiserNonSponsored", + iab_category: "5 - Education", +}; +const FIRST_POSITION_RESULT = { + id: 5, + url: "http://example.com/?q=first-position", + title: "first position suggest", + keywords: ["first-position"], + click_url: "http://click.reporting.test.com/first-position", + impression_url: "http://impression.reporting.test.com/first-position", + advertiser: "TestAdvertiserFirstPositionQuickSuggest", + iab_category: "22 - Shopping", + position: 0, +}; +const SECOND_POSITION_RESULT = { + id: 6, + url: "http://example.com/?q=second-position", + title: "second position suggest", + keywords: ["second-position"], + click_url: "http://click.reporting.test.com/second-position", + impression_url: "http://impression.reporting.test.com/second-position", + advertiser: "TestAdvertiserSecondPositionQuickSuggest", + iab_category: "22 - Shopping", + position: 1, +}; +const THIRD_POSITION_RESULT = { + id: 7, + url: "http://example.com/?q=third-position", + title: "third position suggest", + keywords: ["third-position"], + click_url: "http://click.reporting.test.com/third-position", + impression_url: "http://impression.reporting.test.com/third-position", + advertiser: "TestAdvertiserThirdPositionQuickSuggest", + iab_category: "22 - Shopping", + position: 2, +}; + +const TABTOSEARCH_ENGINE_DOMAIN_FOR_FIRST_POSITION_TEST = + "first-position.example.com"; +const TABTOSEARCH_ENGINE_DOMAIN_FOR_SECOND_POSITION_TEST = + "second-position.example.com"; + +const SECOND_POSITION_INTERVENTION_RESULT = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/a" } +); +SECOND_POSITION_INTERVENTION_RESULT.suggestedIndex = 1; +const SECOND_POSITION_INTERVENTION_RESULT_PROVIDER = + new UrlbarTestUtils.TestProvider({ + results: [SECOND_POSITION_INTERVENTION_RESULT], + priority: 0, + name: "second_position_intervention_provider", + }); + +const EXPECTED_GENERAL_HEURISTIC_RESULT = { + providerName: UrlbarProviderHeuristicFallback.name, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: true, +}; + +const EXPECTED_GENERAL_PLACES_RESULT = { + providerName: UrlbarProviderPlaces.name, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + heuristic: false, +}; + +const EXPECTED_GENERAL_TABTOSEARCH_RESULT = { + providerName: UrlbarProviderTabToSearch.name, + type: UrlbarUtils.RESULT_TYPE.DYNAMIC, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, +}; + +const EXPECTED_GENERAL_INTERVENTION_RESULT = { + providerName: SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + heuristic: false, +}; + +function createExpectedQuickSuggestResult(suggest) { + let isSponsored = suggest.iab_category !== "5 - Education"; + return { + providerName: UrlbarProviderQuickSuggest.name, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: isSponsored ? "adm_sponsored" : "adm_nonsponsored", + qsSuggestion: suggest.keywords[0], + title: suggest.title, + url: suggest.url, + originalUrl: suggest.url, + icon: null, + sponsoredImpressionUrl: suggest.impression_url, + sponsoredClickUrl: suggest.click_url, + sponsoredBlockId: suggest.id, + sponsoredAdvertiser: suggest.advertiser, + sponsoredIabCategory: suggest.iab_category, + isSponsored, + descriptionL10n: isSponsored + ? { id: "urlbar-result-action-sponsored" } + : undefined, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + displayUrl: suggest.url, + source: "remote-settings", + provider: "AdmWikipedia", + }, + }; +} + +const TEST_CASES = [ + { + description: "Test for second placable sponsored suggest", + input: SPONSORED_SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + createExpectedQuickSuggestResult(SPONSORED_SECOND_POSITION_RESULT), + EXPECTED_GENERAL_PLACES_RESULT, + ], + }, + { + description: "Test for normal sponsored suggest", + input: SPONSORED_NORMAL_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + EXPECTED_GENERAL_PLACES_RESULT, + createExpectedQuickSuggestResult(SPONSORED_NORMAL_POSITION_RESULT), + ], + }, + { + description: "Test for second placable nonsponsored suggest", + input: NONSPONSORED_SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + createExpectedQuickSuggestResult(NONSPONSORED_SECOND_POSITION_RESULT), + EXPECTED_GENERAL_PLACES_RESULT, + ], + }, + { + description: "Test for normal nonsponsored suggest", + input: NONSPONSORED_NORMAL_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + EXPECTED_GENERAL_PLACES_RESULT, + createExpectedQuickSuggestResult(NONSPONSORED_NORMAL_POSITION_RESULT), + ], + }, + { + description: + "Test for second placable sponsored suggest but secondPosition pref is disabled", + input: SPONSORED_SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": false, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + EXPECTED_GENERAL_PLACES_RESULT, + createExpectedQuickSuggestResult(SPONSORED_SECOND_POSITION_RESULT), + ], + }, + { + description: "Test the results with multi providers having same index", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderQuickSuggest.name, + UrlbarProviderTabToSearch.name, + SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name, + ], + expected: [ + EXPECTED_GENERAL_TABTOSEARCH_RESULT, + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + EXPECTED_GENERAL_INTERVENTION_RESULT, + ], + }, + { + description: "Test the results with tab-to-search", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderTabToSearch.name, + UrlbarProviderQuickSuggest.name, + ], + expected: [ + EXPECTED_GENERAL_TABTOSEARCH_RESULT, + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + ], + }, + { + description: "Test the results with another intervention", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderQuickSuggest.name, + SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name, + ], + expected: [ + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + EXPECTED_GENERAL_INTERVENTION_RESULT, + ], + }, + { + description: "Test the results with heuristic and tab-to-search", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderTabToSearch.name, + UrlbarProviderQuickSuggest.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + EXPECTED_GENERAL_TABTOSEARCH_RESULT, + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + ], + }, + { + description: "Test the results with heuristic tab-to-search and places", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderTabToSearch.name, + UrlbarProviderQuickSuggest.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + EXPECTED_GENERAL_TABTOSEARCH_RESULT, + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + EXPECTED_GENERAL_PLACES_RESULT, + ], + }, + { + description: "Test the results with heuristic and another intervention", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + EXPECTED_GENERAL_INTERVENTION_RESULT, + ], + }, + { + description: + "Test the results with heuristic, another intervention and places", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + EXPECTED_GENERAL_INTERVENTION_RESULT, + EXPECTED_GENERAL_PLACES_RESULT, + ], + }, + { + description: "Test for 0 indexed quick suggest", + input: FIRST_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderTabToSearch.name, + UrlbarProviderQuickSuggest.name, + ], + expected: [ + createExpectedQuickSuggestResult(FIRST_POSITION_RESULT), + EXPECTED_GENERAL_TABTOSEARCH_RESULT, + ], + }, + { + description: "Test for 2 indexed quick suggest", + input: THIRD_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderQuickSuggest.name, + SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name, + ], + expected: [ + EXPECTED_GENERAL_INTERVENTION_RESULT, + createExpectedQuickSuggestResult(THIRD_POSITION_RESULT), + ], + }, +]; + +add_setup(async function () { + // Setup for quick suggest result. + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "data", + attachment: [ + SPONSORED_SECOND_POSITION_RESULT, + SPONSORED_NORMAL_POSITION_RESULT, + NONSPONSORED_SECOND_POSITION_RESULT, + NONSPONSORED_NORMAL_POSITION_RESULT, + FIRST_POSITION_RESULT, + SECOND_POSITION_RESULT, + THIRD_POSITION_RESULT, + ], + }, + ], + prefs: [ + ["suggest.quicksuggest.sponsored", true], + ["suggest.quicksuggest.nonsponsored", true], + ], + }); + + // Setup for places result. + await PlacesUtils.history.clear(); + await PlacesTestUtils.addVisits([ + "http://example.com/" + SPONSORED_SECOND_POSITION_RESULT.keywords[0], + "http://example.com/" + SPONSORED_NORMAL_POSITION_RESULT.keywords[0], + "http://example.com/" + NONSPONSORED_SECOND_POSITION_RESULT.keywords[0], + "http://example.com/" + NONSPONSORED_NORMAL_POSITION_RESULT.keywords[0], + "http://example.com/" + SECOND_POSITION_RESULT.keywords[0], + ]); + + // Setup for tab-to-search result. + await SearchTestUtils.installSearchExtension({ + name: "first", + search_url: `https://${TABTOSEARCH_ENGINE_DOMAIN_FOR_FIRST_POSITION_TEST}/`, + }); + await SearchTestUtils.installSearchExtension({ + name: "second", + search_url: `https://${TABTOSEARCH_ENGINE_DOMAIN_FOR_SECOND_POSITION_TEST}/`, + }); + + /// Setup for another intervention result. + UrlbarProvidersManager.registerProvider( + SECOND_POSITION_INTERVENTION_RESULT_PROVIDER + ); +}); + +add_task(async function basic() { + for (const { description, input, prefs, providers, expected } of TEST_CASES) { + info(description); + + for (let name in prefs) { + UrlbarPrefs.set(name, prefs[name]); + } + + const context = createContext(input, { + providers, + isPrivate: false, + }); + await check_results({ + context, + matches: expected, + }); + + for (let name in prefs) { + UrlbarPrefs.clear(name); + } + } +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_scoreMap.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_scoreMap.js new file mode 100644 index 0000000000..224dd6cb22 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_scoreMap.js @@ -0,0 +1,670 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Tests the `quickSuggestScoreMap` Nimbus variable that assigns scores to +// specified types of quick suggest suggestions. The scores in the map should +// override the scores in the individual suggestion objects so that experiments +// can fully control the relative ranking of suggestions. + +"use strict"; + +const { DEFAULT_SUGGESTION_SCORE } = UrlbarProviderQuickSuggest; + +const REMOTE_SETTINGS_RECORDS = [ + { + type: "data", + attachment: [ + // sponsored without score + QuickSuggestTestUtils.ampRemoteSettings({ + score: undefined, + keywords: [ + "sponsored without score", + "sponsored without score, nonsponsored without score", + "sponsored without score, nonsponsored with score", + "sponsored without score, addon without score", + ], + url: "https://example.com/sponsored-without-score", + title: "Sponsored without score", + }), + // sponsored with score + QuickSuggestTestUtils.ampRemoteSettings({ + score: 2 * DEFAULT_SUGGESTION_SCORE, + keywords: [ + "sponsored with score", + "sponsored with score, nonsponsored without score", + "sponsored with score, nonsponsored with score", + "sponsored with score, addon with score", + ], + url: "https://example.com/sponsored-with-score", + title: "Sponsored with score", + }), + // nonsponsored without score + QuickSuggestTestUtils.wikipediaRemoteSettings({ + score: undefined, + keywords: [ + "nonsponsored without score", + "sponsored without score, nonsponsored without score", + "sponsored with score, nonsponsored without score", + ], + url: "https://example.com/nonsponsored-without-score", + title: "Nonsponsored without score", + }), + // nonsponsored with score + QuickSuggestTestUtils.wikipediaRemoteSettings({ + score: 2 * DEFAULT_SUGGESTION_SCORE, + keywords: [ + "nonsponsored with score", + "sponsored without score, nonsponsored with score", + "sponsored with score, nonsponsored with score", + ], + url: "https://example.com/nonsponsored-with-score", + title: "Nonsponsored with score", + }), + ], + }, + { + type: "amo-suggestions", + attachment: [ + // addon with score + QuickSuggestTestUtils.amoRemoteSettings({ + score: 2 * DEFAULT_SUGGESTION_SCORE, + keywords: [ + "addon with score", + "sponsored with score, addon with score", + ], + url: "https://example.com/addon-with-score", + title: "Addon with score", + }), + ], + }, +]; + +const ADM_RECORD = REMOTE_SETTINGS_RECORDS[0]; +const SPONSORED_WITHOUT_SCORE = ADM_RECORD.attachment[0]; +const SPONSORED_WITH_SCORE = ADM_RECORD.attachment[1]; +const NONSPONSORED_WITHOUT_SCORE = ADM_RECORD.attachment[2]; +const NONSPONSORED_WITH_SCORE = ADM_RECORD.attachment[3]; + +const ADDON_RECORD = REMOTE_SETTINGS_RECORDS[1]; +const ADDON_WITH_SCORE = ADDON_RECORD.attachment[0]; + +const MERINO_SPONSORED_SUGGESTION = { + provider: "adm", + score: DEFAULT_SUGGESTION_SCORE, + iab_category: "22 - Shopping", + is_sponsored: true, + keywords: ["test"], + full_keyword: "test", + block_id: 1, + url: "https://example.com/merino-sponsored", + title: "Merino sponsored", + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "TestAdvertiser", + icon: "1234", +}; + +const MERINO_ADDON_SUGGESTION = { + provider: "amo", + score: DEFAULT_SUGGESTION_SCORE, + keywords: ["test"], + icon: "https://example.com/addon.svg", + url: "https://example.com/merino-addon", + title: "Merino addon", + description: "Merino addon", + custom_details: { + amo: { + guid: "merino-addon@example.com", + rating: "4.7", + number_of_ratings: "1256", + }, + }, +}; + +const MERINO_UNKNOWN_SUGGESTION = { + provider: "some_unknown_provider", + score: DEFAULT_SUGGESTION_SCORE, + keywords: ["test"], + url: "https://example.com/merino-unknown", + title: "Merino unknown", +}; + +add_setup(async function init() { + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: REMOTE_SETTINGS_RECORDS, + merinoSuggestions: [], + prefs: [ + ["suggest.quicksuggest.sponsored", true], + ["suggest.quicksuggest.nonsponsored", true], + ], + }); +}); + +add_task(async function sponsoredWithout_nonsponsoredWithout_sponsoredWins() { + let keyword = "sponsored without score, nonsponsored without score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_sponsored: score, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedAdmResult({ + keyword, + suggestion: SPONSORED_WITHOUT_SCORE, + }), + }); +}); + +add_task( + async function sponsoredWithout_nonsponsoredWithout_nonsponsoredWins() { + let keyword = "sponsored without score, nonsponsored without score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_nonsponsored: score, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedWikipediaResult({ + keyword, + suggestion: NONSPONSORED_WITHOUT_SCORE, + }), + }); + } +); + +add_task( + async function sponsoredWithout_nonsponsoredWithout_sponsoredWins_both() { + let keyword = "sponsored without score, nonsponsored without score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_sponsored: score, + adm_nonsponsored: score / 2, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedAdmResult({ + keyword, + suggestion: SPONSORED_WITHOUT_SCORE, + }), + }); + } +); + +add_task( + async function sponsoredWithout_nonsponsoredWithout_nonsponsoredWins_both() { + let keyword = "sponsored without score, nonsponsored without score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_nonsponsored: score, + adm_sponsored: score / 2, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedWikipediaResult({ + keyword, + suggestion: NONSPONSORED_WITHOUT_SCORE, + }), + }); + } +); + +add_task(async function sponsoredWith_nonsponsoredWith_sponsoredWins() { + let keyword = "sponsored with score, nonsponsored with score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_sponsored: score, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedAdmResult({ + keyword, + suggestion: SPONSORED_WITH_SCORE, + }), + }); +}); + +add_task(async function sponsoredWith_nonsponsoredWith_nonsponsoredWins() { + let keyword = "sponsored with score, nonsponsored with score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_nonsponsored: score, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedWikipediaResult({ + keyword, + suggestion: NONSPONSORED_WITH_SCORE, + }), + }); +}); + +add_task(async function sponsoredWith_nonsponsoredWith_sponsoredWins_both() { + let keyword = "sponsored with score, nonsponsored with score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_sponsored: score, + adm_nonsponsored: score / 2, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedAdmResult({ + keyword, + suggestion: SPONSORED_WITH_SCORE, + }), + }); +}); + +add_task(async function sponsoredWith_nonsponsoredWith_nonsponsoredWins_both() { + let keyword = "sponsored with score, nonsponsored with score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_nonsponsored: score, + adm_sponsored: score / 2, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedWikipediaResult({ + keyword, + suggestion: NONSPONSORED_WITH_SCORE, + }), + }); +}); + +add_task(async function sponsoredWithout_addonWithout_sponsoredWins() { + let keyword = "sponsored without score, addon without score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_sponsored: score, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedAdmResult({ + keyword, + suggestion: SPONSORED_WITHOUT_SCORE, + }), + }); +}); + +add_task(async function sponsoredWithout_addonWithout_sponsoredWins_both() { + let keyword = "sponsored without score, addon without score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_sponsored: score, + amo: score / 2, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedAdmResult({ + keyword, + suggestion: SPONSORED_WITHOUT_SCORE, + }), + }); +}); + +add_task(async function sponsoredWith_addonWith_sponsoredWins() { + let keyword = "sponsored with score, addon with score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_sponsored: score, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedAdmResult({ + keyword, + suggestion: SPONSORED_WITH_SCORE, + }), + }); +}); + +add_task(async function sponsoredWith_addonWith_addonWins() { + let keyword = "sponsored with score, addon with score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + amo: score, + }, + expectedFeatureName: "AddonSuggestions", + expectedScore: score, + expectedResult: makeExpectedAddonResult({ + suggestion: ADDON_WITH_SCORE, + }), + }); +}); + +add_task(async function sponsoredWith_addonWith_sponsoredWins_both() { + let keyword = "sponsored with score, addon with score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_sponsored: score, + amo: score / 2, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedAdmResult({ + keyword, + suggestion: SPONSORED_WITH_SCORE, + }), + }); +}); + +add_task(async function sponsoredWith_addonWith_addonWins_both() { + let keyword = "sponsored with score, addon with score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + amo: score, + adm_sponsored: score / 2, + }, + expectedFeatureName: "AddonSuggestions", + expectedScore: score, + expectedResult: makeExpectedAddonResult({ + suggestion: ADDON_WITH_SCORE, + }), + }); +}); + +add_task(async function merino_sponsored_addon_sponsoredWins() { + await QuickSuggestTestUtils.setRemoteSettingsRecords([]); + + MerinoTestUtils.server.response.body.suggestions = [ + MERINO_SPONSORED_SUGGESTION, + MERINO_ADDON_SUGGESTION, + ]; + + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword: "test", + scoreMap: { + adm_sponsored: score, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedAdmResult({ + keyword: "test", + suggestion: MERINO_SPONSORED_SUGGESTION, + source: "merino", + provider: "adm", + requestId: MerinoTestUtils.server.response.body.request_id, + }), + }); + + await QuickSuggestTestUtils.setRemoteSettingsRecords(REMOTE_SETTINGS_RECORDS); +}); + +add_task(async function merino_sponsored_addon_addonWins() { + await QuickSuggestTestUtils.setRemoteSettingsRecords([]); + + MerinoTestUtils.server.response.body.suggestions = [ + MERINO_SPONSORED_SUGGESTION, + MERINO_ADDON_SUGGESTION, + ]; + + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword: "test", + scoreMap: { + amo: score, + }, + expectedFeatureName: "AddonSuggestions", + expectedScore: score, + expectedResult: makeExpectedAddonResult({ + suggestion: MERINO_ADDON_SUGGESTION, + source: "merino", + provider: "amo", + requestId: MerinoTestUtils.server.response.body.request_id, + }), + }); + + await QuickSuggestTestUtils.setRemoteSettingsRecords(REMOTE_SETTINGS_RECORDS); +}); + +add_task(async function merino_sponsored_unknown_sponsoredWins() { + await QuickSuggestTestUtils.setRemoteSettingsRecords([]); + + MerinoTestUtils.server.response.body.suggestions = [ + MERINO_SPONSORED_SUGGESTION, + MERINO_UNKNOWN_SUGGESTION, + ]; + + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword: "test", + scoreMap: { + adm_sponsored: score, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedAdmResult({ + keyword: "test", + suggestion: MERINO_SPONSORED_SUGGESTION, + source: "merino", + provider: "adm", + requestId: MerinoTestUtils.server.response.body.request_id, + }), + }); + + await QuickSuggestTestUtils.setRemoteSettingsRecords(REMOTE_SETTINGS_RECORDS); +}); + +add_task(async function merino_sponsored_unknown_unknownWins() { + await QuickSuggestTestUtils.setRemoteSettingsRecords([]); + + MerinoTestUtils.server.response.body.suggestions = [ + MERINO_SPONSORED_SUGGESTION, + MERINO_UNKNOWN_SUGGESTION, + ]; + + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword: "test", + scoreMap: { + [MERINO_UNKNOWN_SUGGESTION.provider]: score, + }, + expectedFeatureName: null, + expectedScore: score, + expectedResult: makeExpectedDefaultResult({ + suggestion: MERINO_UNKNOWN_SUGGESTION, + }), + }); + + await QuickSuggestTestUtils.setRemoteSettingsRecords(REMOTE_SETTINGS_RECORDS); +}); + +add_task(async function stringValue() { + let keyword = "sponsored with score, nonsponsored with score"; + await doTest({ + keyword, + scoreMap: { + adm_sponsored: "123.456", + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: 123.456, + expectedResult: makeExpectedAdmResult({ + keyword, + suggestion: SPONSORED_WITH_SCORE, + }), + }); +}); + +/** + * Sets up Nimbus with a `quickSuggestScoreMap` variable value, does a search, + * and makes sure the expected result is shown and the expected score is set on + * the suggestion. + * + * @param {object} options + * Options object. + * @param {string} options.keyword + * The search string. This should be equal to a keyword from one or more + * suggestions. + * @param {object} options.scoreMap + * The value to set for the `quickSuggestScoreMap` variable. + * @param {string} options.expectedFeatureName + * The name of the `BaseFeature` instance that is expected to create the + * `UrlbarResult` that's shown. If the suggestion is intentionally from an + * unknown Merino provider and therefore the quick suggest provider is + * expected to create a default result for it, set this to null. + * @param {UrlbarResultstring} options.expectedResult + * The `UrlbarResult` that's expected to be shown. + * @param {number} options.expectedScore + * The final `score` value that's expected to be defined on the suggestion + * object. + */ +async function doTest({ + keyword, + scoreMap, + expectedFeatureName, + expectedResult, + expectedScore, +}) { + let cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature({ + quickSuggestScoreMap: scoreMap, + }); + + // Stub the expected feature's `makeResult()` so we can see the value of the + // passed-in suggestion's score. If the suggestion's type is in the score map, + // the provider will set its score before calling `makeResult()`. + let actualScore; + let sandbox; + if (expectedFeatureName) { + sandbox = sinon.createSandbox(); + let feature = QuickSuggest.getFeature(expectedFeatureName); + let stub = sandbox + .stub(feature, "makeResult") + .callsFake((queryContext, suggestion, searchString) => { + actualScore = suggestion.score; + return stub.wrappedMethod.call( + feature, + queryContext, + suggestion, + searchString + ); + }); + } + + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [expectedResult], + }); + + if (expectedFeatureName) { + Assert.equal( + actualScore, + expectedScore, + "Suggestion score should be set correctly" + ); + sandbox.restore(); + } + + await cleanUpNimbus(); +} + +function makeExpectedAdmResult({ + suggestion, + keyword, + source, + provider, + requestId, +}) { + return makeAmpResult({ + keyword, + source, + provider, + requestId, + title: suggestion.title, + url: suggestion.url, + originalUrl: suggestion.url, + impressionUrl: suggestion.impression_url, + clickUrl: suggestion.click_url, + blockId: suggestion.id, + advertiser: suggestion.advertiser, + icon: suggestion.icon, + }); +} + +function makeExpectedWikipediaResult({ suggestion, keyword, source }) { + return makeWikipediaResult({ + keyword, + source, + title: suggestion.title, + url: suggestion.url, + originalUrl: suggestion.url, + impressionUrl: suggestion.impression_url, + clickUrl: suggestion.click_url, + blockId: suggestion.id, + }); +} + +function makeExpectedAddonResult({ suggestion, source, provider }) { + return makeAmoResult({ + source, + provider, + title: suggestion.title, + description: suggestion.description, + url: suggestion.url, + originalUrl: suggestion.url, + icon: suggestion.icon, + }); +} + +function makeExpectedDefaultResult({ suggestion }) { + return { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + source: "merino", + provider: suggestion.provider, + telemetryType: suggestion.provider, + isSponsored: suggestion.is_sponsored, + title: suggestion.title, + url: suggestion.url, + displayUrl: suggestion.url.replace(/^https:\/\//, ""), + icon: suggestion.icon, + descriptionL10n: suggestion.is_sponsored + ? { 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", + }, + }, + }; +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js new file mode 100644 index 0000000000..1b8da54920 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js @@ -0,0 +1,192 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Tests top pick quick suggest results. "Top picks" refers to two different +// concepts: +// +// (1) Any type of suggestion from Merino can have a boolean property called +// `is_top_pick`. When true, Firefox should show the suggestion using the +// "best match" UI treatment (labeled "top pick" in the UI) that makes a +// result's row larger than usual and sets `suggestedIndex` to 1. +// (2) There is a Merino provider called "top_picks" that returns a specific +// type of suggestion called "navigational suggestions". These suggestions +// also have `is_top_pick` set to true. +// +// This file tests aspects of both concepts. + +"use strict"; + +const SUGGESTION_SEARCH_STRING = "example"; +const SUGGESTION_URL = "http://example.com/"; +const SUGGESTION_URL_WWW = "http://www.example.com/"; +const SUGGESTION_URL_DISPLAY = "http://example.com"; + +const MERINO_SUGGESTIONS = [ + { + is_top_pick: true, + provider: "top_picks", + url: SUGGESTION_URL, + title: "title", + icon: "icon", + is_sponsored: false, + score: 1, + }, +]; + +add_setup(async function init() { + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + merinoSuggestions: MERINO_SUGGESTIONS, + prefs: [["suggest.quicksuggest.nonsponsored", true]], + }); +}); + +// When non-sponsored suggestions are disabled, navigational suggestions should +// be disabled. +add_task(async function nonsponsoredDisabled() { + // Disable sponsored suggestions. Navigational suggestions are non-sponsored, + // so doing this should not prevent them from being enabled. + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + // First make sure the suggestion is added when non-sponsored suggestions are + // enabled. + await check_results({ + context: createContext(SUGGESTION_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + isBestMatch: true, + suggestedIndex: 1, + }), + ], + }); + + // Now disable them. + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + await check_results({ + context: createContext(SUGGESTION_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.clear("suggest.quicksuggest.sponsored"); +}); + +// Test that bestMatch navigational suggestion results are not shown when there +// is a heuristic result for the same domain. +add_task(async function heuristicDeduplication() { + let expectedNavSuggestResult = makeExpectedResult({ + isBestMatch: true, + suggestedIndex: 1, + dupedHeuristic: false, + }); + + let scenarios = [ + [SUGGESTION_URL, false], + [SUGGESTION_URL_WWW, false], + ["http://exampledomain.com/", true], + ]; + + // Stub `UrlbarProviderQuickSuggest.startQuery()` so we can collect the + // results it adds for each query. + let addedResults = []; + let sandbox = sinon.createSandbox(); + let startQueryStub = sandbox.stub(UrlbarProviderQuickSuggest, "startQuery"); + startQueryStub.callsFake((queryContext, add) => { + let fakeAdd = (provider, result) => { + addedResults.push(result); + add(provider, result); + }; + return startQueryStub.wrappedMethod.call( + UrlbarProviderQuickSuggest, + queryContext, + fakeAdd + ); + }); + + for (let [url, expectBestMatch] of scenarios) { + await PlacesTestUtils.addVisits(url); + + // Do a search and check the results. + let context = createContext(SUGGESTION_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderAutofill.name], + isPrivate: false, + }); + const EXPECTED_AUTOFILL_RESULT = makeVisitResult(context, { + uri: url, + title: `test visit for ${url}`, + heuristic: true, + }); + await check_results({ + context, + matches: expectBestMatch + ? [EXPECTED_AUTOFILL_RESULT, expectedNavSuggestResult] + : [EXPECTED_AUTOFILL_RESULT], + }); + + // Regardless of whether it was shown, one result should have been added and + // its `payload.dupedHeuristic` should be set properly. + Assert.equal( + addedResults.length, + 1, + "The provider should have added one result" + ); + Assert.equal( + !addedResults[0].payload.dupedHeuristic, + expectBestMatch, + "dupedHeuristic should be the opposite of expectBestMatch" + ); + addedResults = []; + + await PlacesUtils.history.clear(); + } + + sandbox.restore(); +}); + +function makeExpectedResult({ + isBestMatch, + suggestedIndex, + dupedHeuristic, + telemetryType = "top_picks", +}) { + let result = { + isBestMatch, + suggestedIndex, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + dupedHeuristic, + telemetryType, + title: "title", + url: SUGGESTION_URL, + displayUrl: SUGGESTION_URL_DISPLAY, + icon: "icon", + isSponsored: false, + 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", + }, + }, + }; + if (typeof dupedHeuristic == "boolean") { + result.payload.dupedHeuristic = dupedHeuristic; + } + return result; +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_yelp.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_yelp.js new file mode 100644 index 0000000000..aa9c700f1c --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_yelp.js @@ -0,0 +1,842 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Tests Yelp suggestions. + +"use strict"; + +const { GEOLOCATION } = MerinoTestUtils; + +const REMOTE_SETTINGS_RECORDS = [ + { + type: "yelp-suggestions", + attachment: { + subjects: ["ramen", "ab", "alongerkeyword"], + preModifiers: ["best"], + postModifiers: ["delivery"], + locationSigns: [ + { keyword: "in", needLocation: true }, + { keyword: "nearby", needLocation: false }, + ], + yelpModifiers: [], + icon: "1234", + score: 0.5, + }, + }, +]; + +add_setup(async function () { + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: REMOTE_SETTINGS_RECORDS, + prefs: [ + ["quicksuggest.rustEnabled", true], + ["suggest.quicksuggest.sponsored", true], + ["suggest.yelp", true], + ["yelp.featureGate", true], + ["yelp.minKeywordLength", 5], + ], + }); + + await MerinoTestUtils.initGeolocation(); +}); + +add_task(async function basic() { + const TEST_DATA = [ + { + description: "Basic", + query: "best ramen delivery in tokyo", + expected: { + url: "https://www.yelp.com/search?find_desc=best+ramen+delivery&find_loc=tokyo", + title: "best ramen delivery in tokyo", + }, + }, + { + description: "With upper case", + query: "BeSt RaMeN dElIvErY iN tOkYo", + expected: { + url: "https://www.yelp.com/search?find_desc=BeSt+RaMeN+dElIvErY&find_loc=tOkYo", + title: "BeSt RaMeN dElIvErY iN tOkYo", + }, + }, + { + description: "No specific location with location-sign", + query: "ramen in", + expected: { + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=ramen", + displayUrl: + "yelp.com/search?find_desc=ramen&find_loc=Yokohama,+Kanagawa", + title: "ramen in Yokohama, Kanagawa", + }, + }, + { + description: "No specific location with location-modifier", + query: "ramen nearby", + expected: { + url: "https://www.yelp.com/search?find_desc=ramen+nearby&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=ramen+nearby", + displayUrl: + "yelp.com/search?find_desc=ramen+nearby&find_loc=Yokohama,+Kanagawa", + title: "ramen nearby in Yokohama, Kanagawa", + }, + }, + { + description: "Query too short, no subject exact match: ra", + query: "ra", + expected: null, + }, + { + description: "Query too short, no subject not exact match: ram", + query: "ram", + expected: null, + }, + { + description: "Query too short, no subject exact match: rame", + query: "rame", + expected: null, + }, + { + description: + "Query length == minKeywordLength, subject exact match: ramen", + query: "ramen", + expected: { + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=ramen", + displayUrl: + "yelp.com/search?find_desc=ramen&find_loc=Yokohama,+Kanagawa", + title: "ramen in Yokohama, Kanagawa", + }, + }, + { + description: "Pre-modifier only", + query: "best", + expected: null, + }, + { + description: "Pre-modifier only with trailing space", + query: "best ", + expected: null, + }, + { + description: "Pre-modifier, subject too short", + query: "best r", + expected: null, + }, + { + description: "Pre-modifier, query long enough, subject long enough", + query: "best ra", + expected: { + url: "https://www.yelp.com/search?find_desc=best+ramen&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=best+ramen", + displayUrl: + "yelp.com/search?find_desc=best+ramen&find_loc=Yokohama,+Kanagawa", + title: "best ramen in Yokohama, Kanagawa", + }, + }, + { + description: "Subject exact match with length < minKeywordLength", + query: "ab", + expected: { + url: "https://www.yelp.com/search?find_desc=ab&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=ab", + displayUrl: "yelp.com/search?find_desc=ab&find_loc=Yokohama,+Kanagawa", + title: "ab in Yokohama, Kanagawa", + }, + }, + { + description: + "Subject exact match with length < minKeywordLength, showLessFrequentlyCount non-zero", + query: "ab", + showLessFrequentlyCount: 1, + expected: null, + }, + { + description: + "Subject exact match with length == minKeywordLength, showLessFrequentlyCount non-zero", + query: "ramen", + showLessFrequentlyCount: 1, + expected: { + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=ramen", + displayUrl: + "yelp.com/search?find_desc=ramen&find_loc=Yokohama,+Kanagawa", + title: "ramen in Yokohama, Kanagawa", + }, + }, + { + description: "Query too short: alon", + query: "alon", + expected: null, + }, + { + description: "Query length == minKeywordLength, subject not exact match", + query: "along", + expected: { + url: "https://www.yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=alongerkeyword", + displayUrl: + "yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama,+Kanagawa", + title: "alongerkeyword in Yokohama, Kanagawa", + }, + }, + { + description: + "Query length == minKeywordLength, subject not exact match, showLessFrequentlyCount non-zero", + query: "along", + showLessFrequentlyCount: 1, + expected: { + url: "https://www.yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=alongerkeyword", + displayUrl: + "yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama,+Kanagawa", + title: "alongerkeyword in Yokohama, Kanagawa", + }, + }, + { + description: + "Query length == minKeywordLength + showLessFrequentlyCount, subject not exact match", + query: "alonge", + showLessFrequentlyCount: 1, + expected: { + url: "https://www.yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=alongerkeyword", + displayUrl: + "yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama,+Kanagawa", + title: "alongerkeyword in Yokohama, Kanagawa", + }, + }, + { + description: + "Query length < minKeywordLength + showLessFrequentlyCount, subject not exact match", + query: "alonge", + showLessFrequentlyCount: 2, + expected: { + url: "https://www.yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=alongerkeyword", + displayUrl: + "yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama,+Kanagawa", + title: "alongerkeyword in Yokohama, Kanagawa", + }, + }, + { + description: + "Query length == minKeywordLength + showLessFrequentlyCount, subject not exact match", + query: "alonger", + showLessFrequentlyCount: 2, + expected: { + url: "https://www.yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=alongerkeyword", + displayUrl: + "yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama,+Kanagawa", + title: "alongerkeyword in Yokohama, Kanagawa", + }, + }, + ]; + + for (let { + description, + query, + showLessFrequentlyCount, + expected, + } of TEST_DATA) { + info( + "Doing basic subtest: " + + JSON.stringify({ + description, + query, + showLessFrequentlyCount, + expected, + }) + ); + + if (typeof showLessFrequentlyCount == "number") { + UrlbarPrefs.set("yelp.showLessFrequentlyCount", showLessFrequentlyCount); + } + + await check_results({ + context: createContext(query, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: expected ? [makeExpectedResult(expected)] : [], + }); + + UrlbarPrefs.clear("yelp.showLessFrequentlyCount"); + } +}); + +add_task(async function telemetryType() { + Assert.equal( + QuickSuggest.getFeature("YelpSuggestions").getSuggestionTelemetryType({}), + "yelp", + "Telemetry type should be 'yelp'" + ); +}); + +// When sponsored suggestions are disabled, Yelp suggestions should be +// disabled. +add_task(async function sponsoredDisabled() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + + // First make sure the suggestion is added when non-sponsored + // suggestions are enabled, if the rust is enabled. + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + }), + ], + }); + + // Now disable the pref. + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + Assert.ok( + !QuickSuggest.getFeature("YelpSuggestions").isEnabled, + "Yelp should be disabled" + ); + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.clear("suggest.quicksuggest.nonsponsored"); + await QuickSuggestTestUtils.forceSync(); + + // Make sure Yelp is enabled again. + Assert.ok( + QuickSuggest.getFeature("YelpSuggestions").isEnabled, + "Yelp should be re-enabled" + ); + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + }), + ], + }); +}); + +// When Yelp-specific preferences are disabled, suggestions should not be +// added. +add_task(async function yelpSpecificPrefsDisabled() { + const prefs = ["suggest.yelp", "yelp.featureGate"]; + for (const pref of prefs) { + // First make sure the suggestion is added, if the rust is enabled. + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + }), + ], + }); + + // Now disable the pref. + UrlbarPrefs.set(pref, false); + Assert.ok( + !QuickSuggest.getFeature("YelpSuggestions").isEnabled, + "Yelp should be disabled" + ); + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + // Revert. + UrlbarPrefs.set(pref, true); + await QuickSuggestTestUtils.forceSync(); + + // Make sure Yelp is enabled again. + Assert.ok( + QuickSuggest.getFeature("YelpSuggestions").isEnabled, + "Yelp should be re-enabled" + ); + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + }), + ], + }); + } +}); + +// Check wheather the Yelp suggestions will be shown by the setup of Nimbus +// variable. +add_task(async function featureGate() { + // Disable the fature gate. + UrlbarPrefs.set("yelp.featureGate", false); + await check_results({ + context: createContext("ramem in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + // Enable by Nimbus. + const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature({ + yelpFeatureGate: true, + }); + await QuickSuggestTestUtils.forceSync(); + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + }), + ], + }); + await cleanUpNimbusEnable(); + + // Enable locally. + UrlbarPrefs.set("yelp.featureGate", true); + await QuickSuggestTestUtils.forceSync(); + + // Disable by Nimbus. + const cleanUpNimbusDisable = await UrlbarTestUtils.initNimbusFeature({ + yelpFeatureGate: false, + }); + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + await cleanUpNimbusDisable(); + + // Revert. + UrlbarPrefs.set("yelp.featureGate", true); + await QuickSuggestTestUtils.forceSync(); +}); + +// Check wheather the Yelp suggestions will be shown as top_pick by the Nimbus +// variable. +add_task(async function yelpSuggestPriority() { + // Enable by Nimbus. + const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature({ + yelpSuggestPriority: true, + }); + await QuickSuggestTestUtils.forceSync(); + + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + isTopPick: true, + }), + ], + }); + + await cleanUpNimbusEnable(); + await QuickSuggestTestUtils.forceSync(); + + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + isTopPick: false, + }), + ], + }); +}); + +// Tests the `yelpSuggestNonPriorityIndex` Nimbus variable, which controls the +// group-relative suggestedIndex. The default Yelp suggestedIndex is 0, unlike +// most other Suggest suggestion types, which use -1. +add_task(async function nimbusSuggestedIndex() { + const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature({ + yelpSuggestNonPriorityIndex: -1, + }); + await QuickSuggestTestUtils.forceSync(); + + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + isTopPick: false, + suggestedIndex: -1, + }), + ], + }); + + await cleanUpNimbusEnable(); + await QuickSuggestTestUtils.forceSync(); + + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + isTopPick: false, + suggestedIndex: 0, + }), + ], + }); +}); + +// Tests the "Not relevant" command: a dismissed suggestion shouldn't be added. +add_task(async function notRelevant() { + let result = makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + }); + + info("Triggering the 'Not relevant' command"); + QuickSuggest.getFeature("YelpSuggestions").handleCommand( + { + controller: { removeResult() {} }, + }, + result, + "not_relevant" + ); + await QuickSuggest.blockedSuggestions._test_readyPromise; + + Assert.ok( + await QuickSuggest.blockedSuggestions.has(result.payload.originalUrl), + "The result's URL should be blocked" + ); + + info("Doing search for blocked suggestion"); + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + info("Doing search for a suggestion that wasn't blocked"); + await check_results({ + context: createContext("alongerkeyword in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=alongerkeyword&find_loc=tokyo", + title: "alongerkeyword in tokyo", + }), + ], + }); + + info("Clearing blocked suggestions"); + await QuickSuggest.blockedSuggestions.clear(); + + info("Doing search for unblocked suggestion"); + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [result], + }); +}); + +// Tests the "Not interested" command: all Yelp suggestions should be disabled +// and not added anymore. +add_task(async function notInterested() { + let result = makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + }); + + info("Triggering the 'Not interested' command"); + QuickSuggest.getFeature("YelpSuggestions").handleCommand( + { + controller: { removeResult() {} }, + }, + result, + "not_interested" + ); + + Assert.ok( + !UrlbarPrefs.get("suggest.yelp"), + "Yelp suggestions should be disabled" + ); + + info("Doing search for the suggestion the command was used on"); + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + info("Doing search for another Yelp suggestion"); + await check_results({ + context: createContext("alongerkeyword in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + UrlbarPrefs.clear("suggest.yelp"); + await QuickSuggestTestUtils.forceSync(); +}); + +// Tests the "show less frequently" behavior. +add_task(async function showLessFrequently() { + UrlbarPrefs.set("yelp.showLessFrequentlyCount", 0); + UrlbarPrefs.set("yelp.minKeywordLength", 0); + let cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature({ + yelpMinKeywordLength: 0, + yelpShowLessFrequentlyCap: 3, + }); + + let location = `${GEOLOCATION.city}, ${GEOLOCATION.region}`; + + let originalUrl = new URL("https://www.yelp.com/search"); + originalUrl.searchParams.set("find_desc", "best ramen"); + + let url = new URL(originalUrl); + url.searchParams.set("find_loc", location); + + let result = makeExpectedResult({ + url: url.toString(), + originalUrl: originalUrl.toString(), + title: `best ramen in ${location}`, + }); + + const testData = [ + { + input: "best ra", + before: { + canShowLessFrequently: true, + showLessFrequentlyCount: 0, + minKeywordLength: 0, + }, + after: { + canShowLessFrequently: true, + showLessFrequentlyCount: 1, + minKeywordLength: 8, + }, + }, + { + input: "best ram", + before: { + canShowLessFrequently: true, + showLessFrequentlyCount: 1, + minKeywordLength: 8, + }, + after: { + canShowLessFrequently: true, + showLessFrequentlyCount: 2, + minKeywordLength: 9, + }, + }, + { + input: "best rame", + before: { + canShowLessFrequently: true, + showLessFrequentlyCount: 2, + minKeywordLength: 9, + }, + after: { + canShowLessFrequently: false, + showLessFrequentlyCount: 3, + minKeywordLength: 10, + }, + }, + { + input: "best ramen", + before: { + canShowLessFrequently: false, + showLessFrequentlyCount: 3, + minKeywordLength: 10, + }, + after: { + canShowLessFrequently: false, + showLessFrequentlyCount: 3, + minKeywordLength: 11, + }, + }, + ]; + + for (let { input, before, after } of testData) { + let feature = QuickSuggest.getFeature("YelpSuggestions"); + + await check_results({ + context: createContext(input, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [result], + }); + + Assert.equal( + UrlbarPrefs.get("yelp.minKeywordLength"), + before.minKeywordLength + ); + Assert.equal(feature.canShowLessFrequently, before.canShowLessFrequently); + Assert.equal( + feature.showLessFrequentlyCount, + before.showLessFrequentlyCount + ); + + feature.handleCommand( + { + acknowledgeFeedback: () => {}, + invalidateResultMenuCommands: () => {}, + }, + result, + "show_less_frequently", + input + ); + + Assert.equal( + UrlbarPrefs.get("yelp.minKeywordLength"), + after.minKeywordLength + ); + Assert.equal(feature.canShowLessFrequently, after.canShowLessFrequently); + Assert.equal( + feature.showLessFrequentlyCount, + after.showLessFrequentlyCount + ); + + await check_results({ + context: createContext(input, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + } + + await cleanUpNimbus(); + UrlbarPrefs.clear("yelp.showLessFrequentlyCount"); + UrlbarPrefs.clear("yelp.minKeywordLength"); +}); + +// The `Yelp` Rust provider should be passed to the Rust component when +// querying depending on whether Yelp suggestions are enabled. +add_task(async function rustProviders() { + await doRustProvidersTests({ + searchString: "ramen in tokyo", + tests: [ + { + prefs: { + "suggest.yelp": true, + }, + expectedUrls: [ + "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + ], + }, + { + prefs: { + "suggest.yelp": false, + }, + expectedUrls: [], + }, + ], + }); + + UrlbarPrefs.clear("suggest.yelp"); + await QuickSuggestTestUtils.forceSync(); +}); + +function makeExpectedResult({ + url, + title, + isTopPick = false, + // The default Yelp suggestedIndex is 0, unlike most other Suggest suggestion + // types, which use -1. + suggestedIndex = 0, + isSuggestedIndexRelativeToGroup = true, + originalUrl = undefined, + displayUrl = undefined, +}) { + const utmParameters = "&utm_medium=partner&utm_source=mozilla"; + + originalUrl ??= url; + + displayUrl = + (displayUrl ?? + url + .replace(/^https:\/\/www[.]/, "") + .replace("%20", " ") + .replace("%2C", ",")) + utmParameters; + + url += utmParameters; + + if (isTopPick) { + suggestedIndex = 1; + isSuggestedIndexRelativeToGroup = false; + } + + return { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + isBestMatch: !!isTopPick, + suggestedIndex, + isSuggestedIndexRelativeToGroup, + heuristic: false, + payload: { + source: "rust", + provider: "Yelp", + telemetryType: "yelp", + shouldShowUrl: true, + bottomTextL10n: { id: "firefox-suggest-yelp-bottom-text" }, + url, + originalUrl, + title, + displayUrl, + icon: null, + }, + }; +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_rust_ingest.js b/browser/components/urlbar/tests/quicksuggest/unit/test_rust_ingest.js new file mode 100644 index 0000000000..e6ec61bcd4 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_rust_ingest.js @@ -0,0 +1,244 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Tests ingest in the Rust backend. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +// These consts are copied from the update timer manager test. See +// `initUpdateTimerManager()`. +const PREF_APP_UPDATE_TIMERMINIMUMDELAY = "app.update.timerMinimumDelay"; +const PREF_APP_UPDATE_TIMERFIRSTINTERVAL = "app.update.timerFirstInterval"; +const MAIN_TIMER_INTERVAL = 1000; // milliseconds +const CATEGORY_UPDATE_TIMER = "update-timer"; + +const REMOTE_SETTINGS_SUGGESTION = { + id: 1, + url: "http://example.com/amp", + title: "AMP Suggestion", + keywords: ["amp"], + click_url: "http://example.com/amp-click", + impression_url: "http://example.com/amp-impression", + advertiser: "Amp", + iab_category: "22 - Shopping", + icon: "1234", +}; + +add_setup(async function () { + initUpdateTimerManager(); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "data", + attachment: [REMOTE_SETTINGS_SUGGESTION], + }, + ], + prefs: [ + ["suggest.quicksuggest.sponsored", true], + ["quicksuggest.rustEnabled", false], + ], + }); +}); + +// IMPORTANT: This task must run first! +// +// This simulates the first time the Rust backend is enabled in a profile. The +// backend should perform ingestion immediately. +add_task(async function firstRun() { + Assert.ok( + !UrlbarPrefs.get("quicksuggest.rustEnabled"), + "rustEnabled pref is initially false (this task must run first!)" + ); + Assert.strictEqual( + QuickSuggest.rustBackend.isEnabled, + false, + "Rust backend is initially disabled (this task must run first!)" + ); + Assert.ok( + !QuickSuggest.rustBackend.ingestPromise, + "No ingest has been performed yet (this task must run first!)" + ); + + info("Enabling the Rust backend"); + UrlbarPrefs.set("quicksuggest.rustEnabled", true); + Assert.ok(QuickSuggest.rustBackend.isEnabled, "Rust backend is now enabled"); + + // An ingest should start. + let { ingestPromise } = await waitForIngestStart(null); + + info("Awaiting ingest promise"); + await ingestPromise; + info("Done awaiting ingest promise"); + + await checkSuggestions(); + + // Disable and re-enable the backend. No new ingestion should start + // immediately since this isn't the first time the backend has been enabled. + UrlbarPrefs.set("quicksuggest.rustEnabled", false); + UrlbarPrefs.set("quicksuggest.rustEnabled", true); + await assertNoNewIngestStarted(ingestPromise); + + await checkSuggestions(); + + UrlbarPrefs.set("quicksuggest.rustEnabled", false); +}); + +// Ingestion should be performed according to the defined interval. +add_task(async function interval() { + let { ingestPromise } = QuickSuggest.rustBackend; + Assert.ok( + ingestPromise, + "Sanity check: An ingest has already been performed" + ); + Assert.ok( + !UrlbarPrefs.get("quicksuggest.rustEnabled"), + "Sanity check: Rust backend is initially disabled" + ); + + // Set a small interval and enable the backend. No new ingestion should start + // immediately since this isn't the first time the backend has been enabled. + let intervalSecs = 1; + UrlbarPrefs.set("quicksuggest.rustIngestIntervalSeconds", intervalSecs); + UrlbarPrefs.set("quicksuggest.rustEnabled", true); + await assertNoNewIngestStarted(ingestPromise); + + // Wait for a few ingests to happen. + for (let i = 0; i < 3; i++) { + info("Preparing for ingest at index " + i); + + // Set a new suggestion so we can make sure ingest really happened. + let suggestion = { + ...REMOTE_SETTINGS_SUGGESTION, + url: REMOTE_SETTINGS_SUGGESTION.url + "/" + i, + }; + await QuickSuggestTestUtils.setRemoteSettingsRecords( + [ + { + type: "data", + attachment: [suggestion], + }, + ], + // Don't force sync since the whole point here is to make sure the backend + // ingests on its own! + { forceSync: false } + ); + + // Wait for ingest to start and finish. + info("Waiting for ingest to start at index " + i); + ({ ingestPromise } = await waitForIngestStart(ingestPromise)); + info("Waiting for ingest to finish at index " + i); + await ingestPromise; + await checkSuggestions([suggestion]); + } + + // In the loop above, there was one additional async call after awaiting the + // ingest promise, to `checkSuggestions()`. It's possible, though unlikely, + // that call took so long that another ingest has started. To be sure, wait + // for one final ingest to start before continuing. + ({ ingestPromise } = await waitForIngestStart(ingestPromise)); + + // Now immediately disable the backend. New ingests should not start, but the + // final one will still be ongoing. + info("Disabling the backend"); + UrlbarPrefs.set("quicksuggest.rustEnabled", false); + + info("Awaiting final ingest promise"); + await ingestPromise; + + // Wait a few seconds. + let waitSecs = 3 * intervalSecs; + info(`Waiting ${waitSecs}s...`); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 1000 * waitSecs)); + + // No new ingests should have started. + Assert.equal( + QuickSuggest.rustBackend.ingestPromise, + ingestPromise, + "No new ingest started after disabling the backend" + ); + + UrlbarPrefs.clear("quicksuggest.rustIngestIntervalSeconds"); +}); + +async function waitForIngestStart(oldIngestPromise) { + let newIngestPromise; + await TestUtils.waitForCondition(() => { + let { ingestPromise } = QuickSuggest.rustBackend; + if ( + (oldIngestPromise && ingestPromise != oldIngestPromise) || + (!oldIngestPromise && ingestPromise) + ) { + newIngestPromise = ingestPromise; + return true; + } + return false; + }, "Waiting for a new ingest to start"); + + Assert.equal( + QuickSuggest.rustBackend.ingestPromise, + newIngestPromise, + "Sanity check: ingestPromise hasn't changed since waitForCondition returned" + ); + + // A bare promise can't be returned because it will cause the awaiting caller + // to await that promise! We're simply trying to return the promise, which the + // caller can later await. + return { ingestPromise: newIngestPromise }; +} + +async function assertNoNewIngestStarted(oldIngestPromise) { + for (let i = 0; i < 3; i++) { + await TestUtils.waitForTick(); + } + Assert.equal( + QuickSuggest.rustBackend.ingestPromise, + oldIngestPromise, + "No new ingest started" + ); +} + +async function checkSuggestions(expected = [REMOTE_SETTINGS_SUGGESTION]) { + let actual = await QuickSuggest.rustBackend.query("amp"); + Assert.deepEqual( + actual.map(s => s.url), + expected.map(s => s.url), + "Backend should be serving the expected suggestions" + ); +} + +/** + * Sets up the update timer manager for testing: makes it fire more often, + * removes all existing timers, and initializes it for testing. The body of this + * function is copied from: + * https://searchfox.org/mozilla-central/source/toolkit/components/timermanager/tests/unit/consumerNotifications.js + */ +function initUpdateTimerManager() { + // Set the timer to fire every second + Services.prefs.setIntPref( + PREF_APP_UPDATE_TIMERMINIMUMDELAY, + MAIN_TIMER_INTERVAL / 1000 + ); + Services.prefs.setIntPref( + PREF_APP_UPDATE_TIMERFIRSTINTERVAL, + MAIN_TIMER_INTERVAL + ); + + // Remove existing update timers to prevent them from being notified + for (let { data: entry } of Services.catMan.enumerateCategory( + CATEGORY_UPDATE_TIMER + )) { + Services.catMan.deleteCategoryEntry(CATEGORY_UPDATE_TIMER, entry, false); + } + + Cc["@mozilla.org/updates/timer-manager;1"] + .getService(Ci.nsIUpdateTimerManager) + .QueryInterface(Ci.nsIObserver) + .observe(null, "utm-test-init", ""); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_suggestionsMap.js b/browser/components/urlbar/tests/quicksuggest/unit/test_suggestionsMap.js new file mode 100644 index 0000000000..f50fe32dd3 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_suggestionsMap.js @@ -0,0 +1,293 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Tests `SuggestionsMap`. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs", + SuggestionsMap: "resource:///modules/urlbar/private/SuggestBackendJs.sys.mjs", +}); + +// This overrides `SuggestionsMap.chunkSize`. Testing the actual value can make +// the test run too long. This is OK because the correctness of the chunking +// behavior doesn't depend on the chunk size. +const TEST_CHUNK_SIZE = 100; + +add_setup(async () => { + // Sanity check the actual `chunkSize` value. + Assert.equal( + typeof SuggestionsMap.chunkSize, + "number", + "Sanity check: chunkSize is a number" + ); + Assert.greater(SuggestionsMap.chunkSize, 0, "Sanity check: chunkSize > 0"); + + // Set our test value. + SuggestionsMap.chunkSize = TEST_CHUNK_SIZE; +}); + +// Tests many suggestions with one keyword each. +add_task(async function chunking_singleKeyword() { + let suggestionCounts = [ + 1 * SuggestionsMap.chunkSize - 1, + 1 * SuggestionsMap.chunkSize, + 1 * SuggestionsMap.chunkSize + 1, + 2 * SuggestionsMap.chunkSize - 1, + 2 * SuggestionsMap.chunkSize, + 2 * SuggestionsMap.chunkSize + 1, + 3 * SuggestionsMap.chunkSize - 1, + 3 * SuggestionsMap.chunkSize, + 3 * SuggestionsMap.chunkSize + 1, + ]; + for (let count of suggestionCounts) { + await doChunkingTest(count, 1); + } +}); + +// Tests a small number of suggestions with many keywords each. +add_task(async function chunking_manyKeywords() { + let keywordCounts = [ + 1 * SuggestionsMap.chunkSize - 1, + 1 * SuggestionsMap.chunkSize, + 1 * SuggestionsMap.chunkSize + 1, + 2 * SuggestionsMap.chunkSize - 1, + 2 * SuggestionsMap.chunkSize, + 2 * SuggestionsMap.chunkSize + 1, + 3 * SuggestionsMap.chunkSize - 1, + 3 * SuggestionsMap.chunkSize, + 3 * SuggestionsMap.chunkSize + 1, + ]; + for (let suggestionCount = 1; suggestionCount <= 3; suggestionCount++) { + for (let keywordCount of keywordCounts) { + await doChunkingTest(suggestionCount, keywordCount); + } + } +}); + +async function doChunkingTest(suggestionCount, keywordCountPerSuggestion) { + info( + "Running chunking test: " + + JSON.stringify({ suggestionCount, keywordCountPerSuggestion }) + ); + + // Create `suggestionCount` suggestions, each with `keywordCountPerSuggestion` + // keywords. + let suggestions = []; + for (let i = 0; i < suggestionCount; i++) { + let keywords = []; + for (let k = 0; k < keywordCountPerSuggestion; k++) { + keywords.push(`keyword-${i}-${k}`); + } + suggestions.push({ + keywords, + id: i, + url: "http://example.com/" + i, + title: "Suggestion " + i, + click_url: "http://example.com/click", + impression_url: "http://example.com/impression", + advertiser: "TestAdvertiser", + iab_category: "22 - Shopping", + }); + } + + // Add the suggestions. + let map = new SuggestionsMap(); + await map.add(suggestions); + + // Make sure all keyword-suggestion pairs have been added. + for (let i = 0; i < suggestionCount; i++) { + for (let k = 0; k < keywordCountPerSuggestion; k++) { + let keyword = `keyword-${i}-${k}`; + + // Check the map. Logging all assertions takes a ton of time and makes the + // test run much longer than it otherwise would, especially if `chunkSize` + // is large, so only log failing assertions. + let actualSuggestions = map.get(keyword); + if (!ObjectUtils.deepEqual(actualSuggestions, [suggestions[i]])) { + Assert.deepEqual( + actualSuggestions, + [suggestions[i]], + `Suggestion ${i} is present for keyword ${keyword}` + ); + } + } + } +} + +add_task(async function duplicateKeywords() { + let suggestions = [ + { + title: "suggestion 0", + keywords: ["a", "a", "a", "b", "b", "c"], + }, + { + title: "suggestion 1", + keywords: ["b", "c", "d"], + }, + { + title: "suggestion 2", + keywords: ["c", "d", "e"], + }, + { + title: "suggestion 3", + keywords: ["f", "f"], + }, + ]; + + let expectedIndexesByKeyword = { + a: [0], + b: [0, 1], + c: [0, 1, 2], + d: [1, 2], + e: [2], + f: [3], + }; + + let map = new SuggestionsMap(); + await map.add(suggestions); + + for (let [keyword, indexes] of Object.entries(expectedIndexesByKeyword)) { + Assert.deepEqual( + map.get(keyword), + indexes.map(i => suggestions[i]), + "get() with keyword: " + keyword + ); + } +}); + +add_task(async function mapKeywords() { + let suggestions = [ + { + title: "suggestion 0", + keywords: ["a", "a", "a", "b", "b", "c"], + }, + { + title: "suggestion 1", + keywords: ["b", "c", "d"], + }, + { + title: "suggestion 2", + keywords: ["c", "d", "e"], + }, + { + title: "suggestion 3", + keywords: ["f", "f"], + }, + ]; + + let expectedIndexesByKeyword = { + a: [], + b: [], + c: [], + d: [], + e: [], + f: [], + ax: [0], + bx: [0, 1], + cx: [0, 1, 2], + dx: [1, 2], + ex: [2], + fx: [3], + fy: [3], + fz: [3], + }; + + let map = new SuggestionsMap(); + await map.add(suggestions, { + mapKeyword: keyword => { + if (keyword == "f") { + return [keyword + "x", keyword + "y", keyword + "z"]; + } + return [keyword + "x"]; + }, + }); + + for (let [keyword, indexes] of Object.entries(expectedIndexesByKeyword)) { + Assert.deepEqual( + map.get(keyword), + indexes.map(i => suggestions[i]), + "get() with keyword: " + keyword + ); + } +}); + +// Tests `keywordsProperty`. +add_task(async function keywordsProperty() { + let suggestion = { + title: "suggestion", + keywords: ["should be ignored"], + foo: ["hello"], + }; + + let map = new SuggestionsMap(); + await map.add([suggestion], { + keywordsProperty: "foo", + }); + + Assert.deepEqual( + map.get("hello"), + [suggestion], + "Keyword in `foo` should match" + ); + Assert.deepEqual( + map.get("should be ignored"), + [], + "Keyword in `keywords` should not match" + ); +}); + +// Tests `MAP_KEYWORD_PREFIXES_STARTING_AT_FIRST_WORD`. +add_task(async function prefixesStartingAtFirstWord() { + let suggestion = { + title: "suggestion", + keywords: ["one two three", "four five six"], + }; + + // keyword passed to `get()` -> should match + let tests = { + o: false, + on: false, + one: true, + "one ": true, + "one t": true, + "one tw": true, + "one two": true, + "one two ": true, + "one two t": true, + "one two th": true, + "one two thr": true, + "one two thre": true, + "one two three": true, + "one two three ": false, + f: false, + fo: false, + fou: false, + four: true, + "four ": true, + "four f": true, + "four fi": true, + "four fiv": true, + "four five": true, + "four five ": true, + "four five s": true, + "four five si": true, + "four five six": true, + "four five six ": false, + }; + + let map = new SuggestionsMap(); + await map.add([suggestion], { + mapKeyword: SuggestionsMap.MAP_KEYWORD_PREFIXES_STARTING_AT_FIRST_WORD, + }); + + for (let [keyword, shouldMatch] of Object.entries(tests)) { + Assert.deepEqual( + map.get(keyword), + shouldMatch ? [suggestion] : [], + "get() with keyword: " + keyword + ); + } +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_weather.js b/browser/components/urlbar/tests/quicksuggest/unit/test_weather.js new file mode 100644 index 0000000000..28801904a1 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_weather.js @@ -0,0 +1,1402 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Tests the quick suggest weather feature. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs", + UrlbarProviderWeather: "resource:///modules/UrlbarProviderWeather.sys.mjs", +}); + +const HISTOGRAM_LATENCY = "FX_URLBAR_MERINO_LATENCY_WEATHER_MS"; +const HISTOGRAM_RESPONSE = "FX_URLBAR_MERINO_RESPONSE_WEATHER"; + +const { WEATHER_RS_DATA, WEATHER_SUGGESTION } = MerinoTestUtils; + +add_setup(async () => { + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + prefs: [["suggest.quicksuggest.nonsponsored", true]], + remoteSettingsRecords: [ + { + type: "weather", + weather: WEATHER_RS_DATA, + }, + ], + }); + + await MerinoTestUtils.initWeather(); + + // Give this a small value so it doesn't delay the test too long. Choose a + // value that's unlikely to be used anywhere else in the test so that when + // `lastFetchTimeMs` is expected to be `fetchDelayAfterComingOnlineMs`, we can + // be sure the value actually came from `fetchDelayAfterComingOnlineMs`. + QuickSuggest.weather._test_fetchDelayAfterComingOnlineMs = 53; +}); + +// The feature should be properly uninitialized when it's disabled and then +// re-initialized when it's re-enabled. This task disables the feature using the +// feature gate pref. +add_tasks_with_rust(async function disableAndEnable_featureGate() { + await doBasicDisableAndEnableTest("weather.featureGate"); +}); + +// The feature should be properly uninitialized when it's disabled and then +// re-initialized when it's re-enabled. This task disables the feature using the +// suggest pref. +add_tasks_with_rust(async function disableAndEnable_suggestPref() { + await doBasicDisableAndEnableTest("suggest.weather"); +}); + +async function doBasicDisableAndEnableTest(pref) { + // Sanity check initial state. + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + // Disable the feature. It should be immediately uninitialized. + UrlbarPrefs.set(pref, false); + assertDisabled({ + message: "After disabling", + pendingFetchCount: 0, + }); + + // No suggestion should be returned for a search. + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + let histograms = MerinoTestUtils.getAndClearHistograms({ + extraLatency: HISTOGRAM_LATENCY, + extraResponse: HISTOGRAM_RESPONSE, + }); + + // Re-enable the feature. It should be immediately initialized and a fetch + // should start. + info("Re-enable the feature"); + let fetchPromise = QuickSuggest.weather.waitForFetches(); + UrlbarPrefs.set(pref, true); + assertEnabled({ + message: "Immediately after re-enabling", + hasSuggestion: false, + pendingFetchCount: 1, + }); + + await fetchPromise; + assertEnabled({ + message: "After awaiting fetch", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + Assert.equal( + QuickSuggest.weather._test_merino.lastFetchStatus, + "success", + "The request successfully finished" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: QuickSuggest.weather._test_merino, + }); + + // Wait for keywords to be re-synced from remote settings. + await QuickSuggestTestUtils.forceSync(); + + // The suggestion should be returned for a search. + context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [makeWeatherResult()], + }); +} + +// This task is only appropriate for the JS backend, not Rust, since fetching is +// always active with Rust. +add_task( + { + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function keywordsNotDefined() { + // Sanity check initial state. + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + // Set RS data without any keywords. Fetching should immediately stop. + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "weather", + weather: {}, + }, + ]); + assertDisabled({ + message: "After setting RS data without keywords", + pendingFetchCount: 0, + }); + + // No suggestion should be returned for a search. + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + // Set keywords. Fetching should immediately start. + info("Setting keywords"); + let fetchPromise = QuickSuggest.weather.waitForFetches(); + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ]); + assertEnabled({ + message: "Immediately after setting keywords", + hasSuggestion: false, + pendingFetchCount: 1, + }); + + await fetchPromise; + assertEnabled({ + message: "After awaiting fetch", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + Assert.equal( + QuickSuggest.weather._test_merino.lastFetchStatus, + "success", + "The request successfully finished" + ); + + // The suggestion should be returned for a search. + context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [makeWeatherResult()], + }); + } +); + +// Disables and re-enables the feature without waiting for any intermediate +// fetches to complete, using the following steps: +// +// 1. Disable +// 2. Enable +// 3. Disable again +// +// At this point, the fetch from step 2 will remain ongoing but once it finishes +// it should be discarded since the feature is disabled. +add_tasks_with_rust(async function disableAndEnable_immediate1() { + // Sanity check initial state. + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + // Disable the feature. It should be immediately uninitialized. + UrlbarPrefs.set("weather.featureGate", false); + assertDisabled({ + message: "After disabling", + pendingFetchCount: 0, + }); + + // Re-enable the feature. It should be immediately initialized and a fetch + // should start. + let fetchPromise = QuickSuggest.weather.waitForFetches(); + UrlbarPrefs.set("weather.featureGate", true); + assertEnabled({ + message: "Immediately after re-enabling", + hasSuggestion: false, + pendingFetchCount: 1, + }); + + // Disable it again. The fetch will remain ongoing since pending fetches + // aren't stopped when the feature is disabled. + UrlbarPrefs.set("weather.featureGate", false); + assertDisabled({ + message: "After disabling again", + pendingFetchCount: 1, + }); + + // Wait for the fetch to finish. + await fetchPromise; + + // The fetched suggestion should be discarded and the feature should remain + // uninitialized. + assertDisabled({ + message: "After awaiting fetch", + pendingFetchCount: 0, + }); + + // Clean up by re-enabling the feature for the remaining tasks. + fetchPromise = QuickSuggest.weather.waitForFetches(); + UrlbarPrefs.set("weather.featureGate", true); + await fetchPromise; + + // Wait for keywords to be re-synced from remote settings. + await QuickSuggestTestUtils.forceSync(); + + assertEnabled({ + message: "On cleanup", + hasSuggestion: true, + pendingFetchCount: 0, + }); +}); + +// Disables and re-enables the feature without waiting for any intermediate +// fetches to complete, using the following steps: +// +// 1. Disable +// 2. Enable +// 3. Disable again +// 4. Enable again +// +// At this point, the fetches from steps 2 and 4 will remain ongoing. The fetch +// from step 2 should be discarded. +add_tasks_with_rust(async function disableAndEnable_immediate2() { + // Sanity check initial state. + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + // Disable the feature. It should be immediately uninitialized. + UrlbarPrefs.set("weather.featureGate", false); + assertDisabled({ + message: "After disabling", + pendingFetchCount: 0, + }); + + // Re-enable the feature. It should be immediately initialized and a fetch + // should start. + UrlbarPrefs.set("weather.featureGate", true); + assertEnabled({ + message: "Immediately after re-enabling", + hasSuggestion: false, + pendingFetchCount: 1, + }); + + // Disable it again. The fetch will remain ongoing since pending fetches + // aren't stopped when the feature is disabled. + UrlbarPrefs.set("weather.featureGate", false); + assertDisabled({ + message: "After disabling again", + pendingFetchCount: 1, + }); + + // Re-enable it. A new fetch should start, so now there will be two pending + // fetches. + let fetchPromise = QuickSuggest.weather.waitForFetches(); + UrlbarPrefs.set("weather.featureGate", true); + assertEnabled({ + message: "Immediately after re-enabling again", + hasSuggestion: false, + pendingFetchCount: 2, + }); + + // Wait for both fetches to finish. + await fetchPromise; + assertEnabled({ + message: "Immediately after re-enabling again", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + // Wait for keywords to be re-synced from remote settings. + await QuickSuggestTestUtils.forceSync(); +}); + +// A fetch that doesn't return a suggestion should cause the last-fetched +// suggestion to be discarded. +add_tasks_with_rust(async function noSuggestion() { + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + let histograms = MerinoTestUtils.getAndClearHistograms({ + extraLatency: HISTOGRAM_LATENCY, + extraResponse: HISTOGRAM_RESPONSE, + }); + + let { suggestions } = MerinoTestUtils.server.response.body; + MerinoTestUtils.server.response.body.suggestions = []; + + await QuickSuggest.weather._test_fetch(); + + assertEnabled({ + message: "After fetch", + hasSuggestion: false, + pendingFetchCount: 0, + }); + Assert.equal( + QuickSuggest.weather._test_merino.lastFetchStatus, + "no_suggestion", + "The request successfully finished without suggestions" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "no_suggestion", + latencyRecorded: true, + client: QuickSuggest.weather._test_merino, + }); + + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + MerinoTestUtils.server.response.body.suggestions = suggestions; + + // Clean up by forcing another fetch so the suggestion is non-null for the + // remaining tasks. + await QuickSuggest.weather._test_fetch(); + assertEnabled({ + message: "On cleanup", + hasSuggestion: true, + pendingFetchCount: 0, + }); +}); + +// A network error should cause the last-fetched suggestion to be discarded. +add_tasks_with_rust(async function networkError() { + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + let histograms = MerinoTestUtils.getAndClearHistograms({ + extraLatency: HISTOGRAM_LATENCY, + extraResponse: HISTOGRAM_RESPONSE, + }); + + // Set the weather fetch timeout high enough that the network error exception + // will happen first. See `MerinoTestUtils.withNetworkError()`. + QuickSuggest.weather._test_setTimeoutMs(10000); + + await MerinoTestUtils.server.withNetworkError(async () => { + await QuickSuggest.weather._test_fetch(); + }); + + QuickSuggest.weather._test_setTimeoutMs(-1); + + assertEnabled({ + message: "After fetch", + hasSuggestion: false, + pendingFetchCount: 0, + }); + Assert.equal( + QuickSuggest.weather._test_merino.lastFetchStatus, + "network_error", + "The request failed with a network error" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "network_error", + latencyRecorded: false, + client: QuickSuggest.weather._test_merino, + }); + + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + // Clean up by forcing another fetch so the suggestion is non-null for the + // remaining tasks. + await QuickSuggest.weather._test_fetch(); + assertEnabled({ + message: "On cleanup", + hasSuggestion: true, + pendingFetchCount: 0, + }); +}); + +// An HTTP error should cause the last-fetched suggestion to be discarded. +add_tasks_with_rust(async function httpError() { + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + let histograms = MerinoTestUtils.getAndClearHistograms({ + extraLatency: HISTOGRAM_LATENCY, + extraResponse: HISTOGRAM_RESPONSE, + }); + + MerinoTestUtils.server.response = { status: 500 }; + await QuickSuggest.weather._test_fetch(); + + assertEnabled({ + message: "After fetch", + hasSuggestion: false, + pendingFetchCount: 0, + }); + Assert.equal( + QuickSuggest.weather._test_merino.lastFetchStatus, + "http_error", + "The request failed with an HTTP error" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "http_error", + latencyRecorded: true, + client: QuickSuggest.weather._test_merino, + }); + + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + // Clean up by forcing another fetch so the suggestion is non-null for the + // remaining tasks. + MerinoTestUtils.server.reset(); + MerinoTestUtils.server.response.body.suggestions = [WEATHER_SUGGESTION]; + await QuickSuggest.weather._test_fetch(); + assertEnabled({ + message: "On cleanup", + hasSuggestion: true, + pendingFetchCount: 0, + }); +}); + +// A fetch that doesn't return a suggestion due to a client timeout should cause +// the last-fetched suggestion to be discarded. +add_tasks_with_rust(async function clientTimeout() { + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + let histograms = MerinoTestUtils.getAndClearHistograms({ + extraLatency: HISTOGRAM_LATENCY, + extraResponse: HISTOGRAM_RESPONSE, + }); + + // Make the server return a delayed response so the Merino client times out + // waiting for it. + MerinoTestUtils.server.response.delay = 400; + + // Make the client time out immediately. + QuickSuggest.weather._test_setTimeoutMs(1); + + // Set up a promise that will be resolved when the client finally receives the + // response. + let responsePromise = QuickSuggest.weather._test_merino.waitForNextResponse(); + + await QuickSuggest.weather._test_fetch(); + + assertEnabled({ + message: "After fetch", + hasSuggestion: false, + pendingFetchCount: 0, + }); + Assert.equal( + QuickSuggest.weather._test_merino.lastFetchStatus, + "timeout", + "The request timed out" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "timeout", + latencyRecorded: false, + latencyStopwatchRunning: true, + client: QuickSuggest.weather._test_merino, + }); + + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + // Await the response. + await responsePromise; + + // The `checkAndClearHistograms()` call above cleared the histograms. After + // that, nothing else should have been recorded for the response. + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: null, + latencyRecorded: true, + client: QuickSuggest.weather._test_merino, + }); + + QuickSuggest.weather._test_setTimeoutMs(-1); + delete MerinoTestUtils.server.response.delay; + + // Clean up by forcing another fetch so the suggestion is non-null for the + // remaining tasks. + await QuickSuggest.weather._test_fetch(); + assertEnabled({ + message: "On cleanup", + hasSuggestion: true, + pendingFetchCount: 0, + }); +}); + +// Locale task for when this test runs on an en-US OS. +add_tasks_with_rust(async function locale_enUS() { + await doLocaleTest({ + shouldRunTask: osLocale => osLocale == "en-US", + osUnit: "f", + unitsByLocale: { + "en-US": "f", + // When the app's locale is set to any en-* locale, F will be used because + // `regionalPrefsLocales` will prefer the en-US OS locale. + "en-CA": "f", + "en-GB": "f", + de: "c", + }, + }); +}); + +// Locale task for when this test runs on a non-US English OS. +add_tasks_with_rust(async function locale_nonUSEnglish() { + await doLocaleTest({ + shouldRunTask: osLocale => osLocale.startsWith("en") && osLocale != "en-US", + osUnit: "c", + unitsByLocale: { + // When the app's locale is set to en-US, C will be used because + // `regionalPrefsLocales` will prefer the non-US English OS locale. + "en-US": "c", + "en-CA": "c", + "en-GB": "c", + de: "c", + }, + }); +}); + +// Locale task for when this test runs on a non-English OS. +add_tasks_with_rust(async function locale_nonEnglish() { + await doLocaleTest({ + shouldRunTask: osLocale => !osLocale.startsWith("en"), + osUnit: "c", + unitsByLocale: { + "en-US": "f", + "en-CA": "c", + "en-GB": "c", + de: "c", + }, + }); +}); + +/** + * Testing locales is tricky due to the weather feature's use of + * `Services.locale.regionalPrefsLocales`. By default `regionalPrefsLocales` + * prefers the OS locale if its language is the same as the app locale's + * language; otherwise it prefers the app locale. For example, assuming the OS + * locale is en-CA, then if the app locale is en-US it will prefer en-CA since + * both are English, but if the app locale is de it will prefer de. If the pref + * `intl.regional_prefs.use_os_locales` is set, then the OS locale is always + * preferred. + * + * This function tests a given set of locales with and without + * `intl.regional_prefs.use_os_locales` set. + * + * @param {object} options + * Options + * @param {Function} options.shouldRunTask + * Called with the OS locale. Should return true if the function should run. + * Use this to skip tasks that don't target a desired OS locale. + * @param {string} options.osUnit + * The expected "c" or "f" unit for the OS locale. + * @param {object} options.unitsByLocale + * The expected "c" or "f" unit when the app's locale is set to particular + * locales. This should be an object that maps locales to expected units. For + * each locale in the object, the app's locale is set to that locale and the + * actual unit is expected to be the unit in the object. + */ +async function doLocaleTest({ shouldRunTask, osUnit, unitsByLocale }) { + Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", true); + let osLocale = Services.locale.regionalPrefsLocales[0]; + Services.prefs.clearUserPref("intl.regional_prefs.use_os_locales"); + + if (!shouldRunTask(osLocale)) { + info("Skipping task, should not run for this OS locale"); + return; + } + + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + // Sanity check initial locale info. + Assert.equal( + Services.locale.appLocaleAsBCP47, + "en-US", + "Initial app locale should be en-US" + ); + Assert.ok( + !Services.prefs.getBoolPref("intl.regional_prefs.use_os_locales"), + "intl.regional_prefs.use_os_locales should be false initially" + ); + + // Check locales. + for (let [locale, temperatureUnit] of Object.entries(unitsByLocale)) { + await QuickSuggestTestUtils.withLocales([locale], async () => { + info("Checking locale: " + locale); + await check_results({ + context: createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [ + UrlbarProviderQuickSuggest.name, + UrlbarProviderWeather.name, + ], + isPrivate: false, + }), + matches: [makeWeatherResult({ temperatureUnit })], + }); + + info( + "Checking locale with intl.regional_prefs.use_os_locales: " + locale + ); + Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", true); + await check_results({ + context: createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [ + UrlbarProviderQuickSuggest.name, + UrlbarProviderWeather.name, + ], + isPrivate: false, + }), + matches: [makeWeatherResult({ temperatureUnit: osUnit })], + }); + Services.prefs.clearUserPref("intl.regional_prefs.use_os_locales"); + }); + } +} + +// Blocks a result and makes sure the weather pref is disabled. +add_tasks_with_rust(async function block() { + // Sanity check initial state. + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + Assert.ok( + UrlbarPrefs.get("suggest.weather"), + "Sanity check: suggest.weather is true initially" + ); + + // Do a search so we can get an actual result. + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [makeWeatherResult()], + }); + + // Block the result. + const controller = UrlbarTestUtils.newMockController(); + controller.setView({ + get visibleResults() { + return context.results; + }, + controller: { + removeResult() {}, + }, + }); + let result = context.results[0]; + let provider = UrlbarProvidersManager.getProvider(result.providerName); + Assert.ok(provider, "Sanity check: Result provider found"); + provider.onEngagement( + "engagement", + context, + { + result, + selType: "dismiss", + selIndex: context.results[0].rowIndex, + }, + controller + ); + Assert.ok( + !UrlbarPrefs.get("suggest.weather"), + "suggest.weather is false after blocking the result" + ); + + // Do a second search. Nothing should be returned. + context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + // Re-enable the pref and clean up. + let fetchPromise = QuickSuggest.weather.waitForFetches(); + UrlbarPrefs.set("suggest.weather", true); + await fetchPromise; + + // Wait for keywords to be re-synced from remote settings. + await QuickSuggestTestUtils.forceSync(); + + assertEnabled({ + message: "On cleanup", + hasSuggestion: true, + pendingFetchCount: 0, + }); +}); + +// Simulates wake 100ms before the start of the next fetch period. A new fetch +// should not start. +add_tasks_with_rust(async function wakeBeforeNextFetchPeriod() { + await doWakeTest({ + sleepIntervalMs: QuickSuggest.weather._test_fetchIntervalMs - 100, + shouldFetchOnWake: false, + fetchTimerMsOnWake: 100, + }); +}); + +// Simulates wake 100ms after the start of the next fetch period. A new fetch +// should start. +add_tasks_with_rust(async function wakeAfterNextFetchPeriod() { + await doWakeTest({ + sleepIntervalMs: QuickSuggest.weather._test_fetchIntervalMs + 100, + shouldFetchOnWake: true, + }); +}); + +// Simulates wake after many fetch periods + 100ms. A new fetch should start. +add_tasks_with_rust(async function wakeAfterManyFetchPeriods() { + await doWakeTest({ + sleepIntervalMs: 100 * QuickSuggest.weather._test_fetchIntervalMs + 100, + shouldFetchOnWake: true, + }); +}); + +async function doWakeTest({ + sleepIntervalMs, + shouldFetchOnWake, + fetchTimerMsOnWake, +}) { + // Make `Date.now()` return a value under our control, doesn't matter what it + // is. This is the time the first fetch period will start. + let nowOnStart = 100; + let sandbox = sinon.createSandbox(); + let dateNowStub = sandbox.stub( + Cu.getGlobalForObject(QuickSuggest.weather).Date, + "now" + ); + dateNowStub.returns(nowOnStart); + + // Start the first fetch period. + info("Starting first fetch period"); + await QuickSuggest.weather._test_fetch(); + Assert.equal( + QuickSuggest.weather._test_lastFetchTimeMs, + nowOnStart, + "Last fetch time should be updated after fetch" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchIntervalMs, + "Timer period should be full fetch interval" + ); + + let timer = QuickSuggest.weather._test_fetchTimer; + + // Advance the clock and simulate wake. + info("Sending wake notification"); + let nowOnWake = nowOnStart + sleepIntervalMs; + dateNowStub.returns(nowOnWake); + QuickSuggest.weather.observe(null, "wake_notification", ""); + + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "After wake, next fetch should not have immediately started" + ); + Assert.equal( + QuickSuggest.weather._test_lastFetchTimeMs, + nowOnStart, + "After wake, last fetch time should be unchanged" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "After wake, the timer should exist (be non-zero)" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + timer, + "After wake, a new timer should have been created" + ); + + if (shouldFetchOnWake) { + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchDelayAfterComingOnlineMs, + "After wake, timer period should be fetchDelayAfterComingOnlineMs" + ); + } else { + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + fetchTimerMsOnWake, + "After wake, timer period should be the remaining interval" + ); + } + + // Wait for the fetch. If the wake didn't trigger it, then the caller should + // have passed in a `sleepIntervalMs` that will make it start soon. + info("Waiting for fetch after wake"); + await QuickSuggest.weather.waitForFetches(); + + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchIntervalMs, + "After post-wake fetch, timer period should remain full fetch interval" + ); + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "After post-wake fetch, no more fetches should be pending" + ); + + dateNowStub.restore(); +} + +// When network:link-status-changed is observed and the suggestion is non-null, +// a fetch should not start. +add_tasks_with_rust(async function networkLinkStatusChanged_nonNull() { + // See nsINetworkLinkService for possible data values. + await doOnlineTestWithSuggestion({ + topic: "network:link-status-changed", + dataValues: [ + "down", + "up", + "changed", + "unknown", + "this is not a valid data value", + ], + }); +}); + +// When network:offline-status-changed is observed and the suggestion is +// non-null, a fetch should not start. +add_tasks_with_rust(async function networkOfflineStatusChanged_nonNull() { + // See nsIIOService for possible data values. + await doOnlineTestWithSuggestion({ + topic: "network:offline-status-changed", + dataValues: ["offline", "online", "this is not a valid data value"], + }); +}); + +// When captive-portal-login-success is observed and the suggestion is non-null, +// a fetch should not start. +add_tasks_with_rust(async function captivePortalLoginSuccess_nonNull() { + // See nsIIOService for possible data values. + await doOnlineTestWithSuggestion({ + topic: "captive-portal-login-success", + dataValues: [""], + }); +}); + +async function doOnlineTestWithSuggestion({ topic, dataValues }) { + info("Starting fetch period"); + await QuickSuggest.weather._test_fetch(); + Assert.ok( + QuickSuggest.weather.suggestion, + "Suggestion should have been fetched" + ); + + let timer = QuickSuggest.weather._test_fetchTimer; + + for (let data of dataValues) { + info("Sending notification: " + JSON.stringify({ topic, data })); + QuickSuggest.weather.observe(null, topic, data); + + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "Fetch should not have started" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimer, + timer, + "Timer should not have been recreated" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchIntervalMs, + "Timer period should be the full fetch interval" + ); + } +} + +// When network:link-status-changed is observed and the suggestion is null, a +// fetch should start unless the data indicates the status is offline. +add_tasks_with_rust(async function networkLinkStatusChanged_null() { + // See nsINetworkLinkService for possible data values. + await doOnlineTestWithNullSuggestion({ + topic: "network:link-status-changed", + offlineData: "down", + otherDataValues: [ + "up", + "changed", + "unknown", + "this is not a valid data value", + ], + }); +}); + +// When network:offline-status-changed is observed and the suggestion is null, a +// fetch should start unless the data indicates the status is offline. +add_tasks_with_rust(async function networkOfflineStatusChanged_null() { + // See nsIIOService for possible data values. + await doOnlineTestWithNullSuggestion({ + topic: "network:offline-status-changed", + offlineData: "offline", + otherDataValues: ["online", "this is not a valid data value"], + }); +}); + +// When captive-portal-login-success is observed and the suggestion is null, a +// fetch should start. +add_tasks_with_rust(async function captivePortalLoginSuccess_null() { + // See nsIIOService for possible data values. + await doOnlineTestWithNullSuggestion({ + topic: "captive-portal-login-success", + otherDataValues: [""], + }); +}); + +async function doOnlineTestWithNullSuggestion({ + topic, + otherDataValues, + offlineData = "", +}) { + QuickSuggest.weather._test_setSuggestionToNull(); + Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null"); + + let timer = QuickSuggest.weather._test_fetchTimer; + + // First, send the notification with the offline data value. Nothing should + // happen. + if (offlineData) { + info("Sending notification: " + JSON.stringify({ topic, offlineData })); + QuickSuggest.weather.observe(null, topic, offlineData); + + Assert.ok( + !QuickSuggest.weather.suggestion, + "Suggestion should remain null" + ); + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "Fetch should not have started" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimer, + timer, + "Timer should not have been recreated" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchIntervalMs, + "Timer period should be the full fetch interval" + ); + } + + // Now send it with all other data values. Fetches should be triggered. + for (let data of otherDataValues) { + QuickSuggest.weather._test_setSuggestionToNull(); + Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null"); + + info("Sending notification: " + JSON.stringify({ topic, data })); + QuickSuggest.weather.observe(null, topic, data); + + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "Fetch should not have started yet" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "Timer should exist" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + timer, + "A new timer should have been created" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchDelayAfterComingOnlineMs, + "Timer ms should be fetchDelayAfterComingOnlineMs" + ); + + timer = QuickSuggest.weather._test_fetchTimer; + + info("Waiting for fetch after notification"); + await QuickSuggest.weather.waitForFetches(); + + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "Fetch should not be pending" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "Timer should exist" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + timer, + "A new timer should have been created" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchIntervalMs, + "Timer period should be full fetch interval" + ); + + timer = QuickSuggest.weather._test_fetchTimer; + } +} + +// When many online notifications are received at once, only one fetch should +// start. +add_tasks_with_rust(async function manyOnlineNotifications() { + await doManyNotificationsTest([ + ["network:link-status-changed", "changed"], + ["network:link-status-changed", "up"], + ["network:offline-status-changed", "online"], + ]); +}); + +// When wake and online notifications are received at once, only one fetch +// should start. +add_tasks_with_rust(async function wakeAndOnlineNotifications() { + await doManyNotificationsTest([ + ["wake_notification", ""], + ["network:link-status-changed", "changed"], + ["network:link-status-changed", "up"], + ["network:offline-status-changed", "online"], + ]); +}); + +async function doManyNotificationsTest(notifications) { + // Make `Date.now()` return a value under our control, doesn't matter what it + // is. This is the time the first fetch period will start. + let nowOnStart = 100; + let sandbox = sinon.createSandbox(); + let dateNowStub = sandbox.stub( + Cu.getGlobalForObject(QuickSuggest.weather).Date, + "now" + ); + dateNowStub.returns(nowOnStart); + + // Start a first fetch period so that after we send the notifications below + // the last fetch time will be in the past. + info("Starting first fetch period"); + await QuickSuggest.weather._test_fetch(); + Assert.equal( + QuickSuggest.weather._test_lastFetchTimeMs, + nowOnStart, + "Last fetch time should be updated after fetch" + ); + + // Now advance the clock by many fetch intervals. + let nowOnWake = nowOnStart + 100 * QuickSuggest.weather._test_fetchIntervalMs; + dateNowStub.returns(nowOnWake); + + // Set the suggestion to null so online notifications will trigger a fetch. + QuickSuggest.weather._test_setSuggestionToNull(); + Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null"); + + // Clear the server's list of received requests. + MerinoTestUtils.server.reset(); + MerinoTestUtils.server.response.body.suggestions = [ + MerinoTestUtils.WEATHER_SUGGESTION, + ]; + + // Send the notifications. + for (let [topic, data] of notifications) { + info("Sending notification: " + JSON.stringify({ topic, data })); + QuickSuggest.weather.observe(null, topic, data); + } + + info("Waiting for fetch after notifications"); + await QuickSuggest.weather.waitForFetches(); + + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "Fetch should not be pending" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "Timer should exist" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchIntervalMs, + "Timer period should be full fetch interval" + ); + + Assert.equal( + MerinoTestUtils.server.requests.length, + 1, + "Merino should have received only one request" + ); + + dateNowStub.restore(); +} + +// Fetching when a VPN is detected should set the suggestion to null, and +// turning off the VPN should trigger a re-fetch. +add_tasks_with_rust(async function vpn() { + // Register a mock object that implements nsINetworkLinkService. + let mockLinkService = { + isLinkUp: true, + linkStatusKnown: true, + linkType: Ci.nsINetworkLinkService.LINK_TYPE_WIFI, + networkID: "abcd", + dnsSuffixList: [], + platformDNSIndications: Ci.nsINetworkLinkService.NONE_DETECTED, + QueryInterface: ChromeUtils.generateQI(["nsINetworkLinkService"]), + }; + let networkLinkServiceCID = MockRegistrar.register( + "@mozilla.org/network/network-link-service;1", + mockLinkService + ); + QuickSuggest.weather._test_linkService = mockLinkService; + + // At this point no VPN is detected, so a fetch should complete successfully. + await QuickSuggest.weather._test_fetch(); + Assert.ok(QuickSuggest.weather.suggestion, "Suggestion should exist"); + + // Modify the mock link service to indicate a VPN is detected. + mockLinkService.platformDNSIndications = + Ci.nsINetworkLinkService.VPN_DETECTED; + + // Now a fetch should set the suggestion to null. + await QuickSuggest.weather._test_fetch(); + Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null"); + + // Set `weather.ignoreVPN` and fetch again. It should complete successfully. + UrlbarPrefs.set("weather.ignoreVPN", true); + await QuickSuggest.weather._test_fetch(); + Assert.ok(QuickSuggest.weather.suggestion, "Suggestion should be fetched"); + + // Clear the pref and fetch again. It should set the suggestion back to null. + UrlbarPrefs.clear("weather.ignoreVPN"); + await QuickSuggest.weather._test_fetch(); + Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null"); + + // Simulate the link status changing. Since the mock link service still + // indicates a VPN is detected, the suggestion should remain null. + let fetchPromise = QuickSuggest.weather.waitForFetches(); + QuickSuggest.weather.observe(null, "network:link-status-changed", "changed"); + await fetchPromise; + Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should remain null"); + + // Modify the mock link service to indicate a VPN is no longer detected. + mockLinkService.platformDNSIndications = + Ci.nsINetworkLinkService.NONE_DETECTED; + + // Simulate the link status changing again. The suggestion should be fetched. + fetchPromise = QuickSuggest.weather.waitForFetches(); + QuickSuggest.weather.observe(null, "network:link-status-changed", "changed"); + await fetchPromise; + Assert.ok(QuickSuggest.weather.suggestion, "Suggestion should be fetched"); + + MockRegistrar.unregister(networkLinkServiceCID); + delete QuickSuggest.weather._test_linkService; +}); + +// When a Nimbus experiment is installed, it should override the remote settings +// weather record. +add_tasks_with_rust(async function nimbusOverride() { + // Sanity check initial state. + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + let defaultResult = makeWeatherResult(); + + // Verify a search works as expected with the default remote settings weather + // record (which was added in the init task). + await check_results({ + context: createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [defaultResult], + }); + + // Install an experiment with a different keyword and min length. + let nimbusCleanup = await UrlbarTestUtils.initNimbusFeature({ + weatherKeywords: ["nimbusoverride"], + weatherKeywordsMinimumLength: "nimbus".length, + }); + + // The usual default keyword shouldn't match. + await check_results({ + context: createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [], + }); + + // The new keyword from Nimbus should match. Since keywords are defined in + // Nimbus, the result will be served from UrlbarProviderWeather and its source + // will be "merino", not "rust", even when Rust is enabled. + let merinoResult = makeWeatherResult({ + source: "merino", + provider: "accuweather", + telemetryType: null, + }); + await check_results({ + context: createContext("nimbusoverride", { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [merinoResult], + }); + await check_results({ + context: createContext("nimbus", { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [merinoResult], + }); + + // Uninstall the experiment. + await nimbusCleanup(); + + // The usual default keyword should match again. + await check_results({ + context: createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [defaultResult], + }); + + // The keywords from Nimbus shouldn't match anymore. + await check_results({ + context: createContext("nimbusoverride", { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [], + }); + await check_results({ + context: createContext("nimbus", { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [], + }); +}); + +function assertEnabled({ message, hasSuggestion, pendingFetchCount }) { + info("Asserting feature is enabled"); + if (message) { + info(message); + } + + Assert.equal( + !!QuickSuggest.weather.suggestion, + hasSuggestion, + "Suggestion is null or non-null as expected" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "Fetch timer is non-zero" + ); + Assert.ok(QuickSuggest.weather._test_merino, "Merino client is non-null"); + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + pendingFetchCount, + "Expected pending fetch count" + ); +} + +function assertDisabled({ message, pendingFetchCount }) { + info("Asserting feature is disabled"); + if (message) { + info(message); + } + + Assert.strictEqual( + QuickSuggest.weather.suggestion, + null, + "Suggestion is null" + ); + Assert.strictEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "Fetch timer is zero" + ); + Assert.strictEqual( + QuickSuggest.weather._test_merino, + null, + "Merino client is null" + ); + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + pendingFetchCount, + "Expected pending fetch count" + ); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_weather_keywords.js b/browser/components/urlbar/tests/quicksuggest/unit/test_weather_keywords.js new file mode 100644 index 0000000000..efa5922c3e --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_weather_keywords.js @@ -0,0 +1,1503 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Tests the keywords behavior of quick suggest weather. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderWeather: "resource:///modules/UrlbarProviderWeather.sys.mjs", +}); + +const { WEATHER_RS_DATA } = MerinoTestUtils; + +add_setup(async () => { + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "weather", + weather: WEATHER_RS_DATA, + }, + ], + prefs: [["suggest.quicksuggest.nonsponsored", true]], + }); + await MerinoTestUtils.initWeather(); +}); + +// * Settings data: none +// * Nimbus values: none +// * Min keyword length pref: none +// * Expected: no suggestion +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "No data", + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: false, + }, + }); +}); + +// * Settings data: empty +// * Nimbus values: none +// * Min keyword length pref: none +// * Expected: no suggestion +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Empty settings", + settingsData: {}, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: false, + }, + }); +}); + +// * Settings data: keywords only +// * Nimbus values: none +// * Min keyword length pref: none +// * Expected: full keywords only +// +// JS backend only. The Rust component expects settings data to contain +// min_keyword_length. +add_task( + { + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function () { + await doKeywordsTest({ + desc: "Settings only, keywords only", + settingsData: { + keywords: ["weather", "forecast"], + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: true, + }, + }); + } +); + +// * Settings data: keywords and min keyword length = 0 +// * Nimbus values: none +// * Min keyword length pref: none +// * Expected: full keywords only +// +// JS backend only. The Rust component doesn't treat minKeywordLength == 0 as a +// special case. +add_task( + { + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function () { + await doKeywordsTest({ + desc: "Settings only, min keyword length = 0", + settingsData: { + keywords: ["weather", "forecast"], + min_keyword_length: 0, + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: true, + }, + }); + } +); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: none +// * Min keyword length pref: none +// * Expected: use settings data +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings only, min keyword length > 0", + settingsData: { + keywords: ["weather", "forecast"], + min_keyword_length: 3, + }, + tests: { + "": false, + w: false, + we: false, + wea: true, + weat: true, + weath: true, + weathe: true, + weather: true, + f: false, + fo: false, + for: true, + fore: true, + forec: true, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length = 0 +// * Nimbus values: none +// * Min keyword length pref: 6 +// * Expected: use settings keywords and min keyword length pref +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings only, min keyword length = 0, pref exists", + settingsData: { + keywords: ["weather", "forecast"], + min_keyword_length: 0, + }, + minKeywordLength: 6, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: true, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: none +// * Min keyword length pref: 6 +// * Expected: use settings keywords and min keyword length pref +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings only, min keyword length > 0, pref exists", + settingsData: { + keywords: ["weather", "forecast"], + min_keyword_length: 3, + }, + minKeywordLength: 6, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: true, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: empty +// * Nimbus values: empty +// * Min keyword length pref: none +// * Expected: no suggestion +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings: empty; Nimbus: empty", + settingsData: {}, + nimbusValues: {}, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: false, + }, + }); +}); + +// * Settings data: keywords only +// * Nimbus values: keywords only +// * Min keyword length pref: none +// * Expected: full keywords in Nimbus +// +// JS backend only. The Rust component expects settings data to contain +// min_keyword_length. +add_task( + { + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function () { + await doKeywordsTest({ + desc: "Settings: keywords; Nimbus: keywords", + settingsData: { + keywords: ["weather"], + }, + nimbusValues: { + weatherKeywords: ["forecast"], + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: true, + }, + }); + } +); + +// * Settings data: keywords and min keyword length = 0 +// * Nimbus values: keywords only +// * Min keyword length pref: none +// * Expected: full keywords in Nimbus +// +// JS backend only. The Rust component doesn't treat minKeywordLength == 0 as a +// special case. +add_task( + { + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function () { + await doKeywordsTest({ + desc: "Settings: keywords, min keyword length = 0; Nimbus: keywords", + settingsData: { + keywords: ["weather"], + min_keyword_length: 0, + }, + nimbusValues: { + weatherKeywords: ["forecast"], + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: true, + }, + }); + } +); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: keywords only +// * Min keyword length pref: none +// * Expected: Nimbus keywords with settings min keyword length. +// Even when Rust is enabled, UrlbarProviderWeather should serve the +// suggestion since the keywords come from Nimbus. +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords", + settingsData: { + keywords: ["weather"], + min_keyword_length: 3, + }, + nimbusValues: { + weatherKeywords: ["forecast"], + }, + alwaysExpectMerinoResult: true, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: true, + fore: true, + forec: true, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: keywords and min keyword length = 0 +// * Min keyword length pref: none +// * Expected: Nimbus keywords with settings min keyword length. +// Even when Rust is enabled, UrlbarProviderWeather should serve the +// suggestion since the keywords come from Nimbus. +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords, min keyword length = 0", + settingsData: { + keywords: ["weather"], + min_keyword_length: 3, + }, + nimbusValues: { + weatherKeywords: ["forecast"], + weatherKeywordsMinimumLength: 0, + }, + alwaysExpectMerinoResult: true, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: true, + fore: true, + forec: true, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: keywords and min keyword length > 0 +// * Min keyword length pref: none +// * Expected: use Nimbus values. +// Even when Rust is enabled, UrlbarProviderWeather should serve the +// suggestion since the keywords come from Nimbus. +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords, min keyword length > 0", + settingsData: { + keywords: ["weather"], + min_keyword_length: 3, + }, + nimbusValues: { + weatherKeywords: ["forecast"], + weatherKeywordsMinimumLength: 4, + }, + alwaysExpectMerinoResult: true, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: true, + forec: true, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: keywords and min keyword length = 0 +// * Min keyword length pref: exists +// * Expected: Nimbus keywords with min keyword length pref. +// Even when Rust is enabled, UrlbarProviderWeather should serve the +// suggestion since the keywords come from Nimbus. +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords, min keyword length = 0; pref exists", + settingsData: { + keywords: ["weather"], + min_keyword_length: 3, + }, + nimbusValues: { + weatherKeywords: ["forecast"], + weatherKeywordsMinimumLength: 0, + }, + minKeywordLength: 6, + alwaysExpectMerinoResult: true, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: keywords and min keyword length > 0 +// * Min keyword length pref: exists +// * Expected: Nimbus keywords with min keyword length pref +// Even when Rust is enabled, UrlbarProviderWeather should serve the +// suggestion since the keywords come from Nimbus. +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords, min keyword length > 0; pref exists", + settingsData: { + keywords: ["weather", "forecast"], + min_keyword_length: 3, + }, + nimbusValues: { + weatherKeywords: ["forecast"], + weatherKeywordsMinimumLength: 4, + }, + minKeywordLength: 6, + alwaysExpectMerinoResult: true, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: none +// * Nimbus values: keywords only +// * Min keyword length pref: none +// * Expected: full keywords +// +// TODO bug 1879209: This doesn't work with the Rust backend because if +// min_keyword_length isn't specified on ingest, the Rust database will retain +// the last known good min_keyword_length, which interferes with this task. +add_task( + { + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function () { + await doKeywordsTest({ + desc: "Settings: none; Nimbus: keywords", + nimbusValues: { + weatherKeywords: ["weather", "forecast"], + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: true, + }, + }); + } +); + +// * Settings data: none +// * Nimbus values: keywords and min keyword length = 0 +// * Min keyword length pref: none +// * Expected: full keywords +// +// TODO bug 1879209: This doesn't work with the Rust backend because if +// min_keyword_length isn't specified on ingest, the Rust database will retain +// the last known good min_keyword_length, which interferes with this task. +add_task( + { + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function () { + await doKeywordsTest({ + desc: "Settings: none; Nimbus: keywords, min keyword length = 0", + nimbusValues: { + weatherKeywords: ["weather", "forecast"], + weatherKeywordsMinimumLength: 0, + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: true, + }, + }); + } +); + +// * Settings data: none +// * Nimbus values: keywords and min keyword length > 0 +// * Min keyword length pref: none +// * Expected: use Nimbus values +// Even when Rust is enabled, UrlbarProviderWeather should serve the +// suggestion since the keywords come from Nimbus. +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings: none; Nimbus: keywords, min keyword length > 0", + nimbusValues: { + weatherKeywords: ["weather", "forecast"], + weatherKeywordsMinimumLength: 3, + }, + alwaysExpectMerinoResult: true, + tests: { + "": false, + w: false, + we: false, + wea: true, + weat: true, + weath: true, + weathe: true, + weather: true, + f: false, + fo: false, + for: true, + fore: true, + forec: true, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: none +// * Nimbus values: keywords and min keyword length > 0 +// * Min keyword length pref: exists +// * Expected: use Nimbus keywords and min keyword length pref +// Even when Rust is enabled, UrlbarProviderWeather should serve the +// suggestion since the keywords come from Nimbus. +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings: none; Nimbus: keywords, min keyword length > 0; pref exists", + nimbusValues: { + weatherKeywords: ["weather", "forecast"], + weatherKeywordsMinimumLength: 3, + }, + minKeywordLength: 6, + alwaysExpectMerinoResult: true, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: true, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// When `weatherKeywords` is non-null and `weatherKeywordsMinimumLength` is +// larger than the length of all keywords, the suggestion should not be +// triggered. +// Even when Rust is enabled, UrlbarProviderWeather should serve the +// suggestion since the keywords come from Nimbus. +add_tasks_with_rust(async function minLength_large() { + await doKeywordsTest({ + desc: "Large min length", + nimbusValues: { + weatherKeywords: ["weather", "forecast"], + weatherKeywordsMinimumLength: 999, + }, + alwaysExpectMerinoResult: true, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: false, + }, + }); +}); + +// Leading and trailing spaces should be ignored. +add_tasks_with_rust(async function leadingAndTrailingSpaces() { + await doKeywordsTest({ + settingsData: { + keywords: ["weather"], + min_keyword_length: 3, + }, + tests: { + " wea": true, + " wea": true, + "wea ": true, + "wea ": true, + " wea ": true, + " weat": true, + " weat": true, + "weat ": true, + "weat ": true, + " weat ": true, + }, + }); +}); + +add_tasks_with_rust(async function caseInsensitive() { + await doKeywordsTest({ + desc: "Case insensitive", + settingsData: { + keywords: ["weather"], + min_keyword_length: 3, + }, + tests: { + wea: true, + WEA: true, + Wea: true, + WeA: true, + WEATHER: true, + Weather: true, + WeAtHeR: true, + }, + }); +}); + +async function doKeywordsTest({ + desc, + tests, + nimbusValues = null, + settingsData = null, + minKeywordLength = undefined, + alwaysExpectMerinoResult = false, +}) { + info("Doing keywords test: " + desc); + info(JSON.stringify({ nimbusValues, settingsData, minKeywordLength })); + + // If the JS backend is enabled, a suggestion hasn't already been fetched, and + // the data contains keywords, a fetch will start. Wait for it to finish later + // below. + let fetchPromise; + if ( + !QuickSuggest.weather.suggestion && + !UrlbarPrefs.get("quickSuggestRustEnabled") && + (nimbusValues?.weatherKeywords || settingsData?.keywords) + ) { + fetchPromise = QuickSuggest.weather.waitForFetches(); + } + + let nimbusCleanup; + if (nimbusValues) { + nimbusCleanup = await UrlbarTestUtils.initNimbusFeature(nimbusValues); + } + + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "weather", + weather: settingsData, + }, + ]); + + if (minKeywordLength) { + UrlbarPrefs.set("weather.minKeywordLength", minKeywordLength); + } + + if (fetchPromise) { + info("Waiting for fetch"); + assertFetchingStarted({ pendingFetchCount: 1 }); + await fetchPromise; + info("Got fetch"); + } + + let expectedResult = makeWeatherResult( + !alwaysExpectMerinoResult + ? undefined + : { source: "merino", provider: "accuweather", telemetryType: null } + ); + + for (let [searchString, expected] of Object.entries(tests)) { + info( + "Doing keywords test search: " + + JSON.stringify({ + searchString, + expected, + }) + ); + + await check_results({ + context: createContext(searchString, { + providers: [ + UrlbarProviderQuickSuggest.name, + UrlbarProviderWeather.name, + ], + isPrivate: false, + }), + matches: expected ? [expectedResult] : [], + }); + } + + await nimbusCleanup?.(); + + fetchPromise = null; + if (!QuickSuggest.weather.suggestion) { + fetchPromise = QuickSuggest.weather.waitForFetches(); + } + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ]); + + UrlbarPrefs.clear("weather.minKeywordLength"); + await fetchPromise; +} + +// When a sponsored quick suggest result matches the same keyword as the weather +// result, the weather result should be shown and the quick suggest result +// should not be shown. +add_tasks_with_rust(async function matchingQuickSuggest_sponsored() { + await doMatchingQuickSuggestTest("suggest.quicksuggest.sponsored", true); +}); + +// When a non-sponsored quick suggest result matches the same keyword as the +// weather result, the weather result should be shown and the quick suggest +// result should not be shown. +add_tasks_with_rust(async function matchingQuickSuggest_nonsponsored() { + await doMatchingQuickSuggestTest("suggest.quicksuggest.nonsponsored", false); +}); + +async function doMatchingQuickSuggestTest(pref, isSponsored) { + let keyword = "test"; + + let attachment = isSponsored + ? { + id: 1, + url: "http://example.com/amp", + title: "AMP Suggestion", + keywords: [keyword], + click_url: "http://example.com/amp-click", + impression_url: "http://example.com/amp-impression", + advertiser: "Amp", + iab_category: "22 - Shopping", + icon: "1234", + } + : { + id: 2, + url: "http://example.com/wikipedia", + title: "Wikipedia Suggestion", + keywords: [keyword], + click_url: "http://example.com/wikipedia-click", + impression_url: "http://example.com/wikipedia-impression", + advertiser: "Wikipedia", + iab_category: "5 - Education", + icon: "1234", + }; + + // Add a remote settings result to quick suggest. + let oldPrefValue = UrlbarPrefs.get(pref); + UrlbarPrefs.set(pref, true); + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "data", + attachment: [attachment], + }, + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ]); + + // First do a search to verify the quick suggest result matches the keyword. + let payload; + if (!UrlbarPrefs.get("quickSuggestRustEnabled")) { + payload = { + source: "remote-settings", + provider: "AdmWikipedia", + sponsoredImpressionUrl: attachment.impression_url, + sponsoredClickUrl: attachment.click_url, + sponsoredBlockId: attachment.id, + }; + } else { + payload = { + source: "rust", + provider: isSponsored ? "Amp" : "Wikipedia", + }; + if (isSponsored) { + payload.sponsoredImpressionUrl = attachment.impression_url; + payload.sponsoredClickUrl = attachment.click_url; + payload.sponsoredBlockId = attachment.id; + } + } + + info("Doing first search for quick suggest result"); + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [ + { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + ...payload, + telemetryType: isSponsored ? "adm_sponsored" : "adm_nonsponsored", + qsSuggestion: keyword, + title: attachment.title, + url: attachment.url, + displayUrl: attachment.url.replace(/[/]$/, ""), + originalUrl: attachment.url, + icon: null, + sponsoredAdvertiser: attachment.advertiser, + sponsoredIabCategory: attachment.iab_category, + isSponsored, + descriptionL10n: isSponsored + ? { id: "urlbar-result-action-sponsored" } + : undefined, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + }, + }, + ], + }); + + // Set up the keyword for the weather suggestion and do a second search to + // verify only the weather result matches. + info("Doing second search for weather suggestion"); + let cleanup = await UrlbarTestUtils.initNimbusFeature({ + weatherKeywords: [keyword], + weatherKeywordsMinimumLength: 1, + }); + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }), + // The result should always come from Merino. + matches: [ + makeWeatherResult({ + source: "merino", + provider: "accuweather", + telemetryType: null, + }), + ], + }); + await cleanup(); + + UrlbarPrefs.set(pref, oldPrefValue); +} + +add_tasks_with_rust(async function () { + await doIncrementTest({ + desc: "Settings only without cap", + setup: { + settingsData: { + weather: { + keywords: ["forecast", "wind"], + min_keyword_length: 3, + }, + }, + }, + tests: [ + { + minKeywordLength: 3, + canIncrement: true, + searches: { + fo: false, + for: true, + fore: true, + forec: true, + wi: false, + win: true, + wind: true, + }, + }, + { + minKeywordLength: 4, + canIncrement: true, + searches: { + fo: false, + for: false, + fore: true, + forec: true, + wi: false, + win: false, + wind: true, + }, + }, + { + minKeywordLength: 5, + canIncrement: true, + searches: { + fo: false, + for: false, + fore: false, + forec: true, + wi: false, + win: false, + wind: false, + }, + }, + ], + }); +}); + +add_tasks_with_rust(async function () { + await doIncrementTest({ + desc: "Settings only with cap", + setup: { + settingsData: { + weather: { + keywords: ["forecast", "wind"], + min_keyword_length: 3, + }, + configuration: { + show_less_frequently_cap: 3, + }, + }, + }, + tests: [ + { + minKeywordLength: 3, + canIncrement: true, + searches: { + fo: false, + for: true, + fore: true, + forec: true, + foreca: true, + forecas: true, + wi: false, + win: true, + wind: true, + }, + }, + { + minKeywordLength: 4, + canIncrement: true, + searches: { + fo: false, + for: false, + fore: true, + forec: true, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: true, + }, + }, + { + minKeywordLength: 5, + canIncrement: true, + searches: { + fo: false, + for: false, + fore: false, + forec: true, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + { + minKeywordLength: 6, + canIncrement: false, + searches: { + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + { + minKeywordLength: 6, + canIncrement: false, + searches: { + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + ], + }); +}); + +add_tasks_with_rust(async function () { + await doIncrementTest({ + desc: "Settings and Nimbus without cap", + setup: { + settingsData: { + weather: { + keywords: ["weather"], + min_keyword_length: 5, + }, + }, + nimbusValues: { + weatherKeywords: ["forecast", "wind"], + weatherKeywordsMinimumLength: 3, + }, + }, + // The suggestion should be served by UrlbarProviderWeather and therefore + // be from Merino. + alwaysExpectMerinoResult: true, + tests: [ + { + minKeywordLength: 3, + canIncrement: true, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: true, + fore: true, + forec: true, + wi: false, + win: true, + wind: true, + }, + }, + { + minKeywordLength: 4, + canIncrement: true, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: false, + fore: true, + forec: true, + wi: false, + win: false, + wind: true, + }, + }, + { + minKeywordLength: 5, + canIncrement: true, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: false, + fore: false, + forec: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + ], + }); +}); + +add_task(async function () { + await doIncrementTest({ + desc: "Settings and Nimbus with cap in Nimbus", + setup: { + settingsData: { + weather: { + keywords: ["weather"], + min_keyword_length: 5, + }, + }, + nimbusValues: { + weatherKeywords: ["forecast", "wind"], + weatherKeywordsMinimumLength: 3, + weatherKeywordsMinimumLengthCap: 6, + }, + }, + // The suggestion should be served by UrlbarProviderWeather and therefore + // be from Merino. + alwaysExpectMerinoResult: true, + tests: [ + { + minKeywordLength: 3, + canIncrement: true, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: true, + fore: true, + forec: true, + foreca: true, + forecas: true, + wi: false, + win: true, + wind: true, + }, + }, + { + minKeywordLength: 4, + canIncrement: true, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: false, + fore: true, + forec: true, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: true, + }, + }, + { + minKeywordLength: 5, + canIncrement: true, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: false, + fore: false, + forec: true, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + { + minKeywordLength: 6, + canIncrement: false, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + { + minKeywordLength: 6, + canIncrement: false, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + ], + }); +}); + +async function doIncrementTest({ + desc, + setup, + tests, + alwaysExpectMerinoResult = false, +}) { + info("Doing increment test: " + desc); + info(JSON.stringify({ setup })); + + let { nimbusValues, settingsData } = setup; + + // If the JS backend is enabled, a suggestion hasn't already been fetched, and + // the data contains keywords, a fetch will start. Wait for it to finish later + // below. + let fetchPromise; + if ( + !QuickSuggest.weather.suggestion && + !UrlbarPrefs.get("quickSuggestRustEnabled") && + (nimbusValues?.weatherKeywords || settingsData?.weather?.keywords) + ) { + fetchPromise = QuickSuggest.weather.waitForFetches(); + } + + let nimbusCleanup; + if (nimbusValues) { + nimbusCleanup = await UrlbarTestUtils.initNimbusFeature(nimbusValues); + } + + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "weather", + weather: settingsData?.weather, + }, + { + type: "configuration", + configuration: settingsData?.configuration, + }, + ]); + + if (fetchPromise) { + info("Waiting for fetch"); + assertFetchingStarted({ pendingFetchCount: 1 }); + await fetchPromise; + info("Got fetch"); + } + + let expectedResult = makeWeatherResult( + !alwaysExpectMerinoResult + ? undefined + : { source: "merino", provider: "accuweather", telemetryType: null } + ); + + for (let { minKeywordLength, canIncrement, searches } of tests) { + info( + "Doing increment test case: " + + JSON.stringify({ + minKeywordLength, + canIncrement, + }) + ); + + Assert.equal( + QuickSuggest.weather.minKeywordLength, + minKeywordLength, + "minKeywordLength should be correct" + ); + Assert.equal( + QuickSuggest.weather.canIncrementMinKeywordLength, + canIncrement, + "canIncrement should be correct" + ); + + for (let [searchString, expected] of Object.entries(searches)) { + await check_results({ + context: createContext(searchString, { + providers: [ + UrlbarProviderQuickSuggest.name, + UrlbarProviderWeather.name, + ], + isPrivate: false, + }), + matches: expected ? [expectedResult] : [], + }); + } + + QuickSuggest.weather.incrementMinKeywordLength(); + info( + "Incremented min keyword length, new value is: " + + QuickSuggest.weather.minKeywordLength + ); + } + + await nimbusCleanup?.(); + + fetchPromise = null; + if (!QuickSuggest.weather.suggestion) { + fetchPromise = QuickSuggest.weather.waitForFetches(); + } + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ]); + UrlbarPrefs.clear("weather.minKeywordLength"); + await fetchPromise; +} + +function assertFetchingStarted() { + info("Asserting fetching has started"); + + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "Fetch timer is non-zero" + ); + Assert.ok(QuickSuggest.weather._test_merino, "Merino client is non-null"); + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 1, + "Expected pending fetch count" + ); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/xpcshell.toml b/browser/components/urlbar/tests/quicksuggest/unit/xpcshell.toml new file mode 100644 index 0000000000..ceab478795 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/xpcshell.toml @@ -0,0 +1,51 @@ +[DEFAULT] +skip-if = ["os == 'android'"] # bug 1730213 +head = "../../unit/head.js head.js" +firefox-appdir = "browser" + +["test_merinoClient.js"] + +["test_merinoClient_sessions.js"] + +["test_quicksuggest.js"] + +["test_quicksuggest_addons.js"] + +["test_quicksuggest_dynamicWikipedia.js"] + +["test_quicksuggest_impressionCaps.js"] +skip-if = ["true"] # Bug 1880214 + +["test_quicksuggest_mdn.js"] + +["test_quicksuggest_merino.js"] + +["test_quicksuggest_merinoSessions.js"] + +["test_quicksuggest_migrate_v1.js"] + +["test_quicksuggest_migrate_v2.js"] + +["test_quicksuggest_nonUniqueKeywords.js"] +skip-if = ["true"] # Bug 1880214 + +["test_quicksuggest_offlineDefault.js"] + +["test_quicksuggest_pocket.js"] + +["test_quicksuggest_positionInSuggestions.js"] +skip-if = ["true"] # Bug 1880214 + +["test_quicksuggest_scoreMap.js"] + +["test_quicksuggest_topPicks.js"] + +["test_quicksuggest_yelp.js"] + +["test_rust_ingest.js"] + +["test_suggestionsMap.js"] + +["test_weather.js"] + +["test_weather_keywords.js"] diff --git a/browser/components/urlbar/tests/unit/data/engine.xml b/browser/components/urlbar/tests/unit/data/engine.xml new file mode 100644 index 0000000000..61d776655f --- /dev/null +++ b/browser/components/urlbar/tests/unit/data/engine.xml @@ -0,0 +1,10 @@ + + +engine.xml +A test search engine +UTF-8 + + + +http://www.example.com/ + diff --git a/browser/components/urlbar/tests/unit/head.js b/browser/components/urlbar/tests/unit/head.js new file mode 100644 index 0000000000..6f78608c94 --- /dev/null +++ b/browser/components/urlbar/tests/unit/head.js @@ -0,0 +1,1173 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +var { UrlbarMuxer, UrlbarProvider, UrlbarQueryContext, UrlbarUtils } = + ChromeUtils.importESModule("resource:///modules/UrlbarUtils.sys.mjs"); + +ChromeUtils.defineESModuleGetters(this, { + AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs", + HttpServer: "resource://testing-common/httpd.sys.mjs", + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", + UrlbarController: "resource:///modules/UrlbarController.sys.mjs", + UrlbarInput: "resource:///modules/UrlbarInput.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderOpenTabs: "resource:///modules/UrlbarProviderOpenTabs.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(this, "QuickSuggestTestUtils", () => { + const { QuickSuggestTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/QuickSuggestTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +ChromeUtils.defineLazyGetter(this, "MerinoTestUtils", () => { + const { MerinoTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/MerinoTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +ChromeUtils.defineLazyGetter(this, "UrlbarTestUtils", () => { + const { UrlbarTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/UrlbarTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +ChromeUtils.defineLazyGetter(this, "PlacesFrecencyRecalculator", () => { + return Cc["@mozilla.org/places/frecency-recalculator;1"].getService( + Ci.nsIObserver + ).wrappedJSObject; +}); + +SearchTestUtils.init(this); +AddonTestUtils.init(this, false); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +const SUGGESTIONS_ENGINE_NAME = "Suggestions"; +const TAIL_SUGGESTIONS_ENGINE_NAME = "Tail Suggestions"; + +/** + * Gets the database connection. If the Places connection is invalid it will + * try to create a new connection. + * + * @param [optional] aForceNewConnection + * Forces creation of a new connection to the database. When a + * connection is asyncClosed it cannot anymore schedule async statements, + * though connectionReady will keep returning true (Bug 726990). + * + * @returns The database connection or null if unable to get one. + */ +var gDBConn; +function DBConn(aForceNewConnection) { + if (!aForceNewConnection) { + let db = PlacesUtils.history.DBConnection; + if (db.connectionReady) { + return db; + } + } + + // If the Places database connection has been closed, create a new connection. + if (!gDBConn || aForceNewConnection) { + let file = Services.dirsvc.get("ProfD", Ci.nsIFile); + file.append("places.sqlite"); + let dbConn = (gDBConn = Services.storage.openDatabase(file)); + + TestUtils.topicObserved("profile-before-change").then(() => + dbConn.asyncClose() + ); + } + + return gDBConn.connectionReady ? gDBConn : null; +} + +/** + * @param {string} searchString The search string to insert into the context. + * @param {object} properties Overrides for the default values. + * @returns {UrlbarQueryContext} Creates a dummy query context with pre-filled + * required options. + */ +function createContext(searchString = "foo", properties = {}) { + info(`Creating new queryContext with searchString: ${searchString}`); + let context = new UrlbarQueryContext( + Object.assign( + { + allowAutofill: UrlbarPrefs.get("autoFill"), + isPrivate: true, + maxResults: UrlbarPrefs.get("maxRichResults"), + searchString, + }, + properties + ) + ); + UrlbarTokenizer.tokenize(context); + return context; +} + +/** + * Waits for the given notification from the supplied controller. + * + * @param {UrlbarController} controller The controller to wait for a response from. + * @param {string} notification The name of the notification to wait for. + * @param {boolean} expected Wether the notification is expected. + * @returns {Promise} A promise that is resolved with the arguments supplied to + * the notification. + */ +function promiseControllerNotification( + controller, + notification, + expected = true +) { + return new Promise((resolve, reject) => { + let proxifiedObserver = new Proxy( + {}, + { + get: (target, name) => { + if (name == notification) { + return (...args) => { + controller.removeQueryListener(proxifiedObserver); + if (expected) { + resolve(args); + } else { + reject(); + } + }; + } + return () => false; + }, + } + ); + controller.addQueryListener(proxifiedObserver); + }); +} + +/** + * A basic test provider, returning all the provided matches. + */ +class TestProvider extends UrlbarTestUtils.TestProvider { + isActive(context) { + Assert.ok(context, "context is passed-in"); + return true; + } + getPriority(context) { + Assert.ok(context, "context is passed-in"); + return 0; + } + async startQuery(context, add) { + Assert.ok(context, "context is passed-in"); + Assert.equal(typeof add, "function", "add is a callback"); + this._context = context; + for (const result of this.results) { + add(this, result); + } + } + cancelQuery(context) { + // If the query was created but didn't run, this._context will be undefined. + if (this._context) { + Assert.equal(this._context, context, "cancelQuery: context is the same"); + } + this._onCancel?.(); + } +} + +function convertToUtf8(str) { + return String.fromCharCode(...new TextEncoder().encode(str)); +} + +/** + * Helper function to clear the existing providers and register a basic provider + * that returns only the results given. + * + * @param {Array} results The results for the provider to return. + * @param {Function} [onCancel] Optional, called when the query provider + * receives a cancel instruction. + * @param {UrlbarUtils.PROVIDER_TYPE} type The provider type. + * @param {string} [name] Optional, use as the provider name. + * If none, a default name is chosen. + * @returns {UrlbarProvider} The provider + */ +function registerBasicTestProvider(results = [], onCancel, type, name) { + let provider = new TestProvider({ results, onCancel, type, name }); + UrlbarProvidersManager.registerProvider(provider); + registerCleanupFunction(() => + UrlbarProvidersManager.unregisterProvider(provider) + ); + return provider; +} + +// Creates an HTTP server for the test. +function makeTestServer(port = -1) { + let httpServer = new HttpServer(); + httpServer.start(port); + registerCleanupFunction(() => httpServer.stop(() => {})); + return httpServer; +} + +/** + * Sets up a search engine that provides some suggestions by appending strings + * onto the search query. + * + * @param {Function} suggestionsFn + * A function that returns an array of suggestion strings given a + * search string. If not given, a default function is used. + * @param {object} options + * Options for the check. + * @param {string} [options.name] + * The name of the engine to install. + * @returns {nsISearchEngine} The new engine. + */ +async function addTestSuggestionsEngine( + suggestionsFn = null, + { name = SUGGESTIONS_ENGINE_NAME } = {} +) { + // This port number should match the number in engine-suggestions.xml. + let server = makeTestServer(); + server.registerPathHandler("/suggest", (req, resp) => { + let params = new URLSearchParams(req.queryString); + let searchStr = params.get("q"); + let suggestions = suggestionsFn + ? suggestionsFn(searchStr) + : [searchStr].concat(["foo", "bar"].map(s => searchStr + " " + s)); + let data = [searchStr, suggestions]; + resp.setHeader("Content-Type", "application/json", false); + resp.write(JSON.stringify(data)); + }); + await SearchTestUtils.installSearchExtension({ + name, + search_url: `http://localhost:${server.identity.primaryPort}/search`, + suggest_url: `http://localhost:${server.identity.primaryPort}/suggest`, + suggest_url_get_params: "?q={searchTerms}", + // test_search_suggestions_aliases.js uses the search form. + search_form: `http://localhost:${server.identity.primaryPort}/search?q={searchTerms}`, + }); + let engine = Services.search.getEngineByName(name); + return engine; +} + +/** + * Sets up a search engine that provides some tail suggestions by creating an + * array that mimics Google's tail suggestion responses. + * + * @param {Function} suggestionsFn + * A function that returns an array that mimics Google's tail suggestion + * responses. See bug 1626897. + * NOTE: Consumers specifying suggestionsFn must include searchStr as a + * part of the array returned by suggestionsFn. + * @returns {nsISearchEngine} The new engine. + */ +async function addTestTailSuggestionsEngine(suggestionsFn = null) { + // This port number should match the number in engine-tail-suggestions.xml. + let server = makeTestServer(); + server.registerPathHandler("/suggest", (req, resp) => { + let params = new URLSearchParams(req.queryString); + let searchStr = params.get("q"); + let suggestions = suggestionsFn + ? suggestionsFn(searchStr) + : [ + "what time is it in t", + ["what is the time today texas"].concat( + ["toronto", "tunisia"].map(s => searchStr + s.slice(1)) + ), + [], + { + "google:irrelevantparameter": [], + "google:suggestdetail": [{}].concat( + ["toronto", "tunisia"].map(s => ({ + mp: "… ", + t: s, + })) + ), + }, + ]; + let data = suggestions; + let jsonString = JSON.stringify(data); + // This script must be evaluated as UTF-8 for this to write out the bytes of + // the string in UTF-8. If it's evaluated as Latin-1, the written bytes + // will be the result of UTF-8-encoding the result-string *twice*, which + // will break the "… " match prefixes. + let stringOfUtf8Bytes = convertToUtf8(jsonString); + resp.setHeader("Content-Type", "application/json", false); + resp.write(stringOfUtf8Bytes); + }); + await SearchTestUtils.installSearchExtension({ + name: TAIL_SUGGESTIONS_ENGINE_NAME, + search_url: `http://localhost:${server.identity.primaryPort}/search`, + suggest_url: `http://localhost:${server.identity.primaryPort}/suggest`, + suggest_url_get_params: "?q={searchTerms}", + }); + let engine = Services.search.getEngineByName("Tail Suggestions"); + return engine; +} + +/** + * Creates a function that can be provided to the new engine + * utility function to mimic a search engine that returns + * rich suggestions. + * + * @param {string} searchStr + * The string being searched for. + * + * @returns {object} + * A JSON object mimicing the data format returned by + * a search engine. + */ +function defaultRichSuggestionsFn(searchStr) { + let suffixes = ["toronto", "tunisia", "tacoma", "taipei"]; + return [ + "what time is it in t", + suffixes.map(s => searchStr + s.slice(1)), + [], + { + "google:irrelevantparameter": [], + "google:suggestdetail": suffixes.map((suffix, i) => { + // Set every other suggestion as a rich suggestion so we can + // test how they are handled and ordered when interleaved. + if (i % 2) { + return {}; + } + return { + a: "description", + dc: "#FFFFFF", + i: "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==", + t: "Title", + }; + }), + }, + ]; +} + +async function addOpenPages(uri, count = 1, userContextId = 0) { + for (let i = 0; i < count; i++) { + await UrlbarProviderOpenTabs.registerOpenTab( + uri.spec, + userContextId, + false + ); + } +} + +async function removeOpenPages(aUri, aCount = 1, aUserContextId = 0) { + for (let i = 0; i < aCount; i++) { + await UrlbarProviderOpenTabs.unregisterOpenTab( + aUri.spec, + aUserContextId, + false + ); + } +} + +/** + * Helper for tests that generate search results but aren't interested in + * suggestions, such as autofill tests. Installs a test engine and disables + * suggestions. + */ +function testEngine_setup() { + add_setup(async () => { + await cleanupPlaces(); + let engine = await addTestSuggestionsEngine(); + let oldDefaultEngine = await Services.search.getDefault(); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref( + "browser.search.separatePrivateDefault.ui.enabled" + ); + Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + }); + + Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + Services.prefs.setBoolPref( + "browser.search.separatePrivateDefault.ui.enabled", + false + ); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + }); +} + +async function cleanupPlaces() { + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +} + +/** + * Creates a UrlbarResult for a bookmark result. + * + * @param {UrlbarQueryContext} queryContext + * The context that this result will be displayed in. + * @param {object} options + * Options for the result. + * @param {string} options.title + * The page title. + * @param {string} options.uri + * The page URI. + * @param {string} [options.iconUri] + * A URI for the page's icon. + * @param {Array} [options.tags] + * An array of string tags. Defaults to an empty array. + * @param {boolean} [options.heuristic] + * True if this is a heuristic result. Defaults to false. + * @param {number} [options.source] + * Where the results should be sourced from. See {@link UrlbarUtils.RESULT_SOURCE}. + * @returns {UrlbarResult} + */ +function makeBookmarkResult( + queryContext, + { + title, + uri, + iconUri, + tags = [], + heuristic = false, + source = UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + } +) { + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + source, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + url: [uri, UrlbarUtils.HIGHLIGHT.TYPED], + // Check against undefined so consumers can pass in the empty string. + icon: [typeof iconUri != "undefined" ? iconUri : `page-icon:${uri}`], + title: [title, UrlbarUtils.HIGHLIGHT.TYPED], + tags: [tags, UrlbarUtils.HIGHLIGHT.TYPED], + isBlockable: + source == UrlbarUtils.RESULT_SOURCE.HISTORY ? true : undefined, + blockL10n: + source == UrlbarUtils.RESULT_SOURCE.HISTORY + ? { id: "urlbar-result-menu-remove-from-history" } + : undefined, + helpUrl: + source == UrlbarUtils.RESULT_SOURCE.HISTORY + ? Services.urlFormatter.formatURLPref("app.support.baseURL") + + "awesome-bar-result-menu" + : undefined, + }) + ); + + result.heuristic = heuristic; + return result; +} + +/** + * Creates a UrlbarResult for a form history result. + * + * @param {UrlbarQueryContext} queryContext + * The context that this result will be displayed in. + * @param {object} options + * Options for the result. + * @param {string} options.suggestion + * The form history suggestion. + * @param {string} options.engineName + * The name of the engine that will do the search when the result is picked. + * @returns {UrlbarResult} + */ +function makeFormHistoryResult(queryContext, { suggestion, engineName }) { + return new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.HISTORY, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + engine: engineName, + suggestion: [suggestion, UrlbarUtils.HIGHLIGHT.SUGGESTED], + lowerCaseSuggestion: suggestion.toLocaleLowerCase(), + isBlockable: true, + blockL10n: { id: "urlbar-result-menu-remove-from-history" }, + helpUrl: + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "awesome-bar-result-menu", + }) + ); +} + +/** + * Creates a UrlbarResult for an omnibox extension result. For more information, + * see the documentation for omnibox.SuggestResult: + * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/omnibox/SuggestResult + * + * @param {UrlbarQueryContext} queryContext + * The context that this result will be displayed in. + * @param {object} options + * Options for the result. + * @param {string} options.content + * The string displayed when the result is highlighted. + * @param {string} options.description + * The string displayed in the address bar dropdown. + * @param {string} options.keyword + * The keyword associated with the extension returning the result. + * @param {boolean} [options.heuristic] + * True if this is a heuristic result. Defaults to false. + * @returns {UrlbarResult} + */ +function makeOmniboxResult( + queryContext, + { content, description, keyword, heuristic = false } +) { + let payload = { + title: [description, UrlbarUtils.HIGHLIGHT.TYPED], + content: [content, UrlbarUtils.HIGHLIGHT.TYPED], + keyword: [keyword, UrlbarUtils.HIGHLIGHT.TYPED], + icon: [UrlbarUtils.ICON.EXTENSION], + }; + if (!heuristic) { + payload.blockL10n = { id: "urlbar-result-menu-dismiss-firefox-suggest" }; + } + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.OMNIBOX, + UrlbarUtils.RESULT_SOURCE.ADDON, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, payload) + ); + result.heuristic = heuristic; + + return result; +} + +/** + * Creates a UrlbarResult for an switch-to-tab result. + * + * @param {UrlbarQueryContext} queryContext + * The context that this result will be displayed in. + * @param {object} options + * Options for the result. + * @param {string} options.uri + * The page URI. + * @param {string} [options.title] + * The page title. + * @param {string} [options.iconUri] + * A URI for the page icon. + * @param {number} [options.userContextId] + * A id of the userContext in which the tab is located. + * @returns {UrlbarResult} + */ +function makeTabSwitchResult( + queryContext, + { uri, title, iconUri, userContextId } +) { + return new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + url: [uri, UrlbarUtils.HIGHLIGHT.TYPED], + title: [title, UrlbarUtils.HIGHLIGHT.TYPED], + // Check against undefined so consumers can pass in the empty string. + icon: typeof iconUri != "undefined" ? iconUri : `page-icon:${uri}`, + userContextId: [userContextId || 0], + }) + ); +} + +/** + * Creates a UrlbarResult for a keyword search result. + * + * @param {UrlbarQueryContext} queryContext + * The context that this result will be displayed in. + * @param {object} options + * Options for the result. + * @param {string} options.uri + * The page URI. + * @param {string} options.keyword + * The page's search keyword. + * @param {string} [options.title] + * The title for the bookmarked keyword page. + * @param {string} [options.iconUri] + * A URI for the engine's icon. + * @param {string} [options.postData] + * The search POST data. + * @param {boolean} [options.heuristic] + * True if this is a heuristic result. Defaults to false. + * @returns {UrlbarResult} + */ +function makeKeywordSearchResult( + queryContext, + { uri, keyword, title, iconUri, postData, heuristic = false } +) { + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.KEYWORD, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + title: [title ? title : uri, UrlbarUtils.HIGHLIGHT.TYPED], + url: [uri, UrlbarUtils.HIGHLIGHT.TYPED], + keyword: [keyword, UrlbarUtils.HIGHLIGHT.TYPED], + input: [queryContext.searchString, UrlbarUtils.HIGHLIGHT.TYPED], + postData: postData || null, + icon: typeof iconUri != "undefined" ? iconUri : `page-icon:${uri}`, + }) + ); + + if (heuristic) { + result.heuristic = heuristic; + } + return result; +} + +/** + * Creates a UrlbarResult for a remote tab result. + * + * @param {UrlbarQueryContext} queryContext + * The context that this result will be displayed in. + * @param {object} options + * Options for the result. + * @param {string} options.uri + * The page URI. + * @param {string} options.device + * The name of the device that the remote tab comes from. + * @param {string} [options.title] + * The page title. + * @param {number} [options.lastUsed] + * The last time the remote tab was visited, in epoch seconds. Defaults + * to 0. + * @param {string} [options.iconUri] + * A URI for the page's icon. + * @returns {UrlbarResult} + */ +function makeRemoteTabResult( + queryContext, + { uri, device, title, iconUri, lastUsed = 0 } +) { + let payload = { + url: [uri, UrlbarUtils.HIGHLIGHT.TYPED], + device: [device, UrlbarUtils.HIGHLIGHT.TYPED], + // Check against undefined so consumers can pass in the empty string. + icon: typeof iconUri != "undefined" ? iconUri : `page-icon:${uri}`, + lastUsed: lastUsed * 1000, + }; + + // Check against undefined so consumers can pass in the empty string. + if (typeof title != "undefined") { + payload.title = [title, UrlbarUtils.HIGHLIGHT.TYPED]; + } else { + payload.title = [uri, UrlbarUtils.HIGHLIGHT.TYPED]; + } + + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.REMOTE_TAB, + UrlbarUtils.RESULT_SOURCE.TABS, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, payload) + ); + + return result; +} + +/** + * Creates a UrlbarResult for a search result. + * + * @param {UrlbarQueryContext} queryContext + * The context that this result will be displayed in. + * @param {object} options + * Options for the result. + * @param {string} [options.suggestion] + * The suggestion offered by the search engine. + * @param {string} [options.tailPrefix] + * The characters placed at the end of a Google "tail" suggestion. See + * {@link https://firefox-source-docs.mozilla.org/browser/urlbar/nontechnical-overview.html#search-suggestions} + * @param {*} [options.tail] + * The details of the URL bar tail + * @param {number} [options.tailOffsetIndex] + * The index of the first character in the tail suggestion that should be + * @param {string} [options.engineName] + * The name of the engine providing the suggestion. Leave blank if there + * is no suggestion. + * @param {string} [options.uri] + * The URI that the search result will navigate to. + * @param {string} [options.query] + * The query that started the search. This overrides + * `queryContext.searchString`. This is useful when the query that will show + * up in the result object will be different from what was typed. For example, + * if a leading restriction token will be used. + * @param {string} [options.alias] + * The alias for the search engine, if the search is an alias search. + * @param {string} [options.engineIconUri] + * A URI for the engine's icon. + * @param {boolean} [options.heuristic] + * True if this is a heuristic result. Defaults to false. + * @param {boolean} [options.providesSearchMode] + * Whether search mode is entered when this result is selected. + * @param {string} [options.providerName] + * The name of the provider offering this result. The test suite will not + * check which provider offered a result unless this option is specified. + * @param {boolean} [options.inPrivateWindow] + * If the window to test is a private window. + * @param {boolean} [options.isPrivateEngine] + * If the engine is a private engine. + * @param {number} [options.type] + * The type of the search result. Defaults to UrlbarUtils.RESULT_TYPE.SEARCH. + * @param {number} [options.source] + * The source of the search result. Defaults to UrlbarUtils.RESULT_SOURCE.SEARCH. + * @param {boolean} [options.satisfiesAutofillThreshold] + * If this search should appear in the autofill section of the box + * @param {boolean} [options.trending] + * If the search result is a trending result. `Defaults to false`. + * @param {boolean} [options.isRichSuggestion] + * If the search result is a rich result. `Defaults to false`. + * @returns {UrlbarResult} + */ +function makeSearchResult( + queryContext, + { + suggestion, + tailPrefix, + tail, + tailOffsetIndex, + engineName, + alias, + uri, + query, + engineIconUri, + providesSearchMode, + providerName, + inPrivateWindow, + isPrivateEngine, + heuristic = false, + trending = false, + isRichSuggestion = false, + type = UrlbarUtils.RESULT_TYPE.SEARCH, + source = UrlbarUtils.RESULT_SOURCE.SEARCH, + satisfiesAutofillThreshold = false, + } +) { + // Tail suggestion common cases, handled here to reduce verbosity in tests. + if (tail) { + if (!tailPrefix && !isRichSuggestion) { + tailPrefix = "… "; + } + if (!tailOffsetIndex) { + tailOffsetIndex = suggestion.indexOf(tail); + } + } + + let payload = { + engine: [engineName, UrlbarUtils.HIGHLIGHT.TYPED], + suggestion: [suggestion, UrlbarUtils.HIGHLIGHT.SUGGESTED], + tailPrefix, + tail: [tail, UrlbarUtils.HIGHLIGHT.SUGGESTED], + tailOffsetIndex, + keyword: [ + alias, + providesSearchMode + ? UrlbarUtils.HIGHLIGHT.TYPED + : UrlbarUtils.HIGHLIGHT.NONE, + ], + // Check against undefined so consumers can pass in the empty string. + query: [ + typeof query != "undefined" ? query : queryContext.trimmedSearchString, + UrlbarUtils.HIGHLIGHT.TYPED, + ], + icon: engineIconUri, + providesSearchMode, + inPrivateWindow, + isPrivateEngine, + }; + + // Passing even an undefined URL in the payload creates a potentially-unwanted + // displayUrl parameter, so we add it only if specified. + if (uri) { + payload.url = uri; + } + if (providerName == "TabToSearch") { + payload.satisfiesAutofillThreshold = satisfiesAutofillThreshold; + if (payload.url.startsWith("www.")) { + payload.url = payload.url.substring(4); + } + payload.isGeneralPurposeEngine = false; + } + + let result = new UrlbarResult( + type, + source, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, payload) + ); + + if (typeof suggestion == "string") { + result.payload.lowerCaseSuggestion = + result.payload.suggestion.toLocaleLowerCase(); + result.payload.trending = trending; + result.isRichSuggestion = isRichSuggestion; + } + + if (isRichSuggestion) { + result.payload.icon = + "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; + result.payload.description = "description"; + } + + if (providerName) { + result.providerName = providerName; + } + + result.heuristic = heuristic; + return result; +} + +/** + * Creates a UrlbarResult for a history result. + * + * @param {UrlbarQueryContext} queryContext + * The context that this result will be displayed in. + * @param {object} options Options for the result. + * @param {string} options.title + * The page title. + * @param {string} [options.fallbackTitle] + * The provider has capability to use the actual page title though, + * when the provider can’t get the page title, use this value as the fallback. + * @param {string} options.uri + * The page URI. + * @param {Array} [options.tags] + * An array of string tags. Defaults to an empty array. + * @param {string} [options.iconUri] + * A URI for the page's icon. + * @param {boolean} [options.heuristic] + * True if this is a heuristic result. Defaults to false. + * @param {string} options.providerName + * The name of the provider offering this result. The test suite will not + * check which provider offered a result unless this option is specified. + * @param {number} [options.source] + * The source of the result + * @returns {UrlbarResult} + */ +function makeVisitResult( + queryContext, + { + title, + fallbackTitle, + uri, + iconUri, + providerName, + tags = [], + heuristic = false, + source = UrlbarUtils.RESULT_SOURCE.HISTORY, + } +) { + let payload = { + url: [uri, UrlbarUtils.HIGHLIGHT.TYPED], + }; + + if (title) { + payload.title = [title, UrlbarUtils.HIGHLIGHT.TYPED]; + } + + if (fallbackTitle) { + payload.fallbackTitle = [fallbackTitle, UrlbarUtils.HIGHLIGHT.TYPED]; + } + + if ( + !heuristic && + providerName != "AboutPages" && + providerName != "PreloadedSites" && + source == UrlbarUtils.RESULT_SOURCE.HISTORY + ) { + payload.isBlockable = true; + payload.blockL10n = { id: "urlbar-result-menu-remove-from-history" }; + payload.helpUrl = + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "awesome-bar-result-menu"; + } + + if (iconUri) { + payload.icon = iconUri; + } else if ( + iconUri === undefined && + source != UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL + ) { + payload.icon = `page-icon:${uri}`; + } + + if (!heuristic && tags) { + payload.tags = [tags, UrlbarUtils.HIGHLIGHT.TYPED]; + } + + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + source, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, payload) + ); + + if (providerName) { + result.providerName = providerName; + } + + result.heuristic = heuristic; + return result; +} + +/** + * Checks that the results returned by a UrlbarController match those in + * the param `matches`. + * + * @param {object} options Options for the check. + * @param {UrlbarQueryContext} options.context + * The context for this query. + * @param {string} [options.incompleteSearch] + * A search will be fired for this string and then be immediately canceled by + * the query in `context`. + * @param {string} [options.autofilled] + * The autofilled value in the first result. + * @param {string} [options.completed] + * The value that would be filled if the autofill result was confirmed. + * Has no effect if `autofilled` is not specified. + * @param {Array} options.matches + * An array of UrlbarResults. + */ +async function check_results({ + context, + incompleteSearch, + autofilled, + completed, + matches = [], +} = {}) { + if (!context) { + return; + } + + // At this point frecency could still be updating due to latest pages + // updates. + // This is not a problem in real life, but autocomplete tests should + // return reliable resultsets, thus we have to wait. + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + const controller = UrlbarTestUtils.newMockController({ + input: { + isPrivate: context.isPrivate, + onFirstResult() { + return false; + }, + getSearchSource() { + return "dummy-search-source"; + }, + window: { + location: { + href: AppConstants.BROWSER_CHROME_URL, + }, + }, + }, + }); + controller.setView({ + get visibleResults() { + return context.results; + }, + controller: { + removeResult() {}, + }, + }); + + if (incompleteSearch) { + let incompleteContext = createContext(incompleteSearch, { + isPrivate: context.isPrivate, + }); + controller.startQuery(incompleteContext); + } + await controller.startQuery(context); + + if (autofilled) { + Assert.ok(context.results[0], "There is a first result."); + Assert.ok( + context.results[0].autofill, + "The first result is an autofill result" + ); + Assert.equal( + context.results[0].autofill.value, + autofilled, + "The correct value was autofilled." + ); + if (completed) { + Assert.equal( + context.results[0].payload.url, + completed, + "The completed autofill value is correct." + ); + } + } + if (context.results.length != matches.length) { + info("Actual results: " + JSON.stringify(context.results)); + } + Assert.equal( + context.results.length, + matches.length, + "Found the expected number of results." + ); + + function getPayload(result) { + let payload = {}; + for (let [key, value] of Object.entries(result.payload)) { + if (value !== undefined) { + payload[key] = value; + } + } + return payload; + } + + for (let i = 0; i < matches.length; i++) { + let actual = context.results[i]; + let expected = matches[i]; + info( + `Comparing results at index ${i}:` + + " actual=" + + JSON.stringify(actual) + + " expected=" + + JSON.stringify(expected) + ); + Assert.equal( + actual.type, + expected.type, + `result.type at result index ${i}` + ); + Assert.equal( + actual.source, + expected.source, + `result.source at result index ${i}` + ); + Assert.equal( + actual.heuristic, + expected.heuristic, + `result.heuristic at result index ${i}` + ); + Assert.equal( + !!actual.isBestMatch, + !!expected.isBestMatch, + `result.isBestMatch at result index ${i}` + ); + if (expected.providerName) { + Assert.equal( + actual.providerName, + expected.providerName, + `result.providerName at result index ${i}` + ); + } + if (expected.hasOwnProperty("suggestedIndex")) { + Assert.equal( + actual.suggestedIndex, + expected.suggestedIndex, + `result.suggestedIndex at result index ${i}` + ); + } + if (expected.hasOwnProperty("isSuggestedIndexRelativeToGroup")) { + Assert.equal( + !!actual.isSuggestedIndexRelativeToGroup, + expected.isSuggestedIndexRelativeToGroup, + `result.isSuggestedIndexRelativeToGroup at result index ${i}` + ); + } + + if (expected.payload) { + Assert.deepEqual( + getPayload(actual), + getPayload(expected), + `result.payload at result index ${i}` + ); + } + } +} + +/** + * Returns the frecency of an origin. + * + * @param {string} prefix + * The origin's prefix, e.g., "http://". + * @param {string} aHost + * The origin's host. + * @returns {number} The origin's frecency. + */ +async function getOriginFrecency(prefix, aHost) { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute( + ` + SELECT frecency + FROM moz_origins + WHERE prefix = :prefix AND host = :host + `, + { prefix, host: aHost } + ); + Assert.equal(rows.length, 1); + return rows[0].getResultByIndex(0); +} + +/** + * Returns the origin frecency stats. + * + * @returns {object} + * An object { count, sum, squares }. + */ +async function getOriginFrecencyStats() { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute(` + SELECT + IFNULL((SELECT value FROM moz_meta WHERE key = 'origin_frecency_count'), 0), + IFNULL((SELECT value FROM moz_meta WHERE key = 'origin_frecency_sum'), 0), + IFNULL((SELECT value FROM moz_meta WHERE key = 'origin_frecency_sum_of_squares'), 0) + `); + let count = rows[0].getResultByIndex(0); + let sum = rows[0].getResultByIndex(1); + let squares = rows[0].getResultByIndex(2); + return { count, sum, squares }; +} + +/** + * Returns the origin autofill frecency threshold. + * + * @returns {number} + * The threshold. + */ +async function getOriginAutofillThreshold() { + let { count, sum, squares } = await getOriginFrecencyStats(); + if (!count) { + return 0; + } + if (count == 1) { + return sum; + } + let stddevMultiplier = UrlbarPrefs.get("autoFill.stddevMultiplier"); + return ( + sum / count + + stddevMultiplier * Math.sqrt((squares - (sum * sum) / count) / count) + ); +} + +/** + * Checks that origins appear in a given order in the database. + * + * @param {string} host The "fixed" host, without "www." + * @param {Array} prefixOrder The prefixes (scheme + www.) sorted appropriately. + */ +async function checkOriginsOrder(host, prefixOrder) { + await PlacesUtils.withConnectionWrapper("checkOriginsOrder", async db => { + let prefixes = ( + await db.execute( + `SELECT prefix || iif(instr(host, "www.") = 1, "www.", "") + FROM moz_origins + WHERE host = :host OR host = "www." || :host + ORDER BY ROWID ASC + `, + { host } + ) + ).map(r => r.getResultByIndex(0)); + Assert.deepEqual(prefixes, prefixOrder); + }); +} diff --git a/browser/components/urlbar/tests/unit/test_000_frecency.js b/browser/components/urlbar/tests/unit/test_000_frecency.js new file mode 100644 index 0000000000..cef110963f --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_000_frecency.js @@ -0,0 +1,245 @@ +/* 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/. */ + +/* + +Autocomplete Frecency Tests + +- add a visit for each score permutation +- search +- test number of matches +- test each item's location in results + +*/ + +testEngine_setup(); + +try { + var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].getService( + Ci.nsINavHistoryService + ); +} catch (ex) { + do_throw("Could not get services\n"); +} + +var bucketPrefs = [ + ["firstBucketCutoff", "firstBucketWeight"], + ["secondBucketCutoff", "secondBucketWeight"], + ["thirdBucketCutoff", "thirdBucketWeight"], + ["fourthBucketCutoff", "fourthBucketWeight"], + [null, "defaultBucketWeight"], +]; + +var bonusPrefs = { + embedVisitBonus: PlacesUtils.history.TRANSITION_EMBED, + framedLinkVisitBonus: PlacesUtils.history.TRANSITION_FRAMED_LINK, + linkVisitBonus: PlacesUtils.history.TRANSITION_LINK, + typedVisitBonus: PlacesUtils.history.TRANSITION_TYPED, + bookmarkVisitBonus: PlacesUtils.history.TRANSITION_BOOKMARK, + downloadVisitBonus: PlacesUtils.history.TRANSITION_DOWNLOAD, + permRedirectVisitBonus: PlacesUtils.history.TRANSITION_REDIRECT_PERMANENT, + tempRedirectVisitBonus: PlacesUtils.history.TRANSITION_REDIRECT_TEMPORARY, + reloadVisitBonus: PlacesUtils.history.TRANSITION_RELOAD, +}; + +// create test data +var searchTerm = "frecency"; +var results = []; +var now = Date.now(); +var prefPrefix = "places.frecency."; + +async function task_initializeBucket(bucket) { + let [cutoffName, weightName] = bucket; + // get pref values + let weight = Services.prefs.getIntPref(prefPrefix + weightName, 0); + let cutoff = Services.prefs.getIntPref(prefPrefix + cutoffName, 0); + if (cutoff < 1) { + return; + } + + // generate a date within the cutoff period + let dateInPeriod = (now - (cutoff - 1) * 86400 * 1000) * 1000; + + for (let [bonusName, visitType] of Object.entries(bonusPrefs)) { + let frecency = -1; + let calculatedURI = null; + let matchTitle = ""; + let bonusValue = Services.prefs.getIntPref(prefPrefix + bonusName); + // unvisited (only for first cutoff date bucket) + if ( + bonusName == "unvisitedBookmarkBonus" || + bonusName == "unvisitedTypedBonus" + ) { + if (cutoffName == "firstBucketCutoff") { + let points = Math.ceil((bonusValue / parseFloat(100.0)) * weight); + let visitCount = 1; // bonusName == "unvisitedBookmarkBonus" ? 1 : 0; + frecency = Math.ceil(visitCount * points); + calculatedURI = Services.io.newURI( + "http://" + + searchTerm + + ".com/" + + bonusName + + ":" + + bonusValue + + "/cutoff:" + + cutoff + + "/weight:" + + weight + + "/frecency:" + + frecency + ); + if (bonusName == "unvisitedBookmarkBonus") { + matchTitle = searchTerm + "UnvisitedBookmark"; + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: calculatedURI, + title: matchTitle, + }); + } else { + matchTitle = searchTerm + "UnvisitedTyped"; + await PlacesTestUtils.addVisits({ + uri: calculatedURI, + title: matchTitle, + transition: visitType, + visitDate: now, + }); + histsvc.markPageAsTyped(calculatedURI); + } + } + } else { + // visited + // visited bookmarks get the visited bookmark bonus twice + if (visitType == Ci.nsINavHistoryService.TRANSITION_BOOKMARK) { + bonusValue = bonusValue * 2; + } + + let points = Math.ceil( + (1 * ((bonusValue / parseFloat(100.0)).toFixed(6) * weight)) / 1 + ); + if (!points) { + if ( + visitType == Ci.nsINavHistoryService.TRANSITION_EMBED || + visitType == Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK || + visitType == Ci.nsINavHistoryService.TRANSITION_DOWNLOAD || + visitType == Ci.nsINavHistoryService.TRANSITION_RELOAD || + bonusName == "defaultVisitBonus" + ) { + frecency = 0; + } else { + frecency = -1; + } + } else { + frecency = points; + } + calculatedURI = Services.io.newURI( + "http://" + + searchTerm + + ".com/" + + bonusName + + ":" + + bonusValue + + "/cutoff:" + + cutoff + + "/weight:" + + weight + + "/frecency:" + + frecency + ); + if (visitType == Ci.nsINavHistoryService.TRANSITION_BOOKMARK) { + matchTitle = searchTerm + "Bookmarked"; + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: calculatedURI, + title: matchTitle, + }); + } else { + matchTitle = calculatedURI.spec.substr( + calculatedURI.spec.lastIndexOf("/") + 1 + ); + } + await PlacesTestUtils.addVisits({ + uri: calculatedURI, + transition: visitType, + visitDate: dateInPeriod, + }); + } + + if (calculatedURI && frecency) { + results.push([calculatedURI, frecency, matchTitle]); + await PlacesTestUtils.addVisits({ + uri: calculatedURI, + title: matchTitle, + transition: visitType, + visitDate: dateInPeriod, + }); + } + } +} + +add_task(async function test_frecency() { + // Disable autoFill for this test. + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + // always search in history + bookmarks, no matter what the default is + Services.prefs.setBoolPref("browser.urlbar.suggest.history", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmarks", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.history"); + Services.prefs.clearUserPref("browser.urlbar.suggest.bookmarks"); + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + for (let bucket of bucketPrefs) { + await task_initializeBucket(bucket); + } + + // Sort results by frecency. Break ties by alphabetical URL. + results.sort((a, b) => { + let frecencyDiff = b[1] - a[1]; + if (frecencyDiff == 0) { + return a[0].spec.localeCompare(b[0].spec); + } + return frecencyDiff; + }); + + // Make sure there's enough results returned + Services.prefs.setIntPref( + "browser.urlbar.maxRichResults", + // +1 for the heuristic search result. + results.length + 1 + ); + + await PlacesTestUtils.promiseAsyncUpdates(); + let context = createContext(searchTerm, { isPrivate: false }); + let urlbarResults = []; + for (let result of results) { + let url = result[0].spec; + if (url.toLowerCase().includes("bookmark")) { + urlbarResults.push( + makeBookmarkResult(context, { + uri: url, + title: result[2], + }) + ); + } else { + urlbarResults.push( + makeVisitResult(context, { + uri: url, + title: result[2], + }) + ); + } + } + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...urlbarResults, + ], + }); +}); diff --git a/browser/components/urlbar/tests/unit/test_UrlbarController_integration.js b/browser/components/urlbar/tests/unit/test_UrlbarController_integration.js new file mode 100644 index 0000000000..220af80e06 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarController_integration.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests test the UrlbarController in association with the model. + */ + +"use strict"; + +const TEST_URL = "http://example.com"; +const match = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: TEST_URL } +); +let controller; + +add_setup(async function () { + controller = UrlbarTestUtils.newMockController(); +}); + +add_task(async function test_basic_search() { + let provider = registerBasicTestProvider([match]); + const context = createContext(TEST_URL, { providers: [provider.name] }); + + let startedPromise = promiseControllerNotification( + controller, + "onQueryStarted" + ); + let resultsPromise = promiseControllerNotification( + controller, + "onQueryResults" + ); + + controller.startQuery(context); + + let params = await startedPromise; + + Assert.equal(params[0], context); + + params = await resultsPromise; + + Assert.deepEqual( + params[0].results, + [match], + "Should have the expected match" + ); +}); + +add_task(async function test_cancel_search() { + let providerCanceledDeferred = Promise.withResolvers(); + let provider = registerBasicTestProvider( + [match], + providerCanceledDeferred.resolve + ); + const context = createContext(TEST_URL, { providers: [provider.name] }); + + let startedPromise = promiseControllerNotification( + controller, + "onQueryStarted" + ); + let cancelPromise = promiseControllerNotification( + controller, + "onQueryCancelled" + ); + + let delayResultsPromise = new Promise(resolve => { + controller.addQueryListener({ + async onQueryResults(queryContext) { + controller.removeQueryListener(this); + controller.cancelQuery(queryContext); + resolve(); + }, + }); + }); + + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { url: "https://example.com/1", title: "example" } + ); + + // We are awaiting for asynchronous work on initialization. + // For this test, we need the query objects to be created. We ensure this by + // using a delayed Provider. We wait for onQueryResults, then cancel the + // query. By that time the query objects are created. Then we unblock the + // delayed provider. + let delayedProvider = new UrlbarTestUtils.TestProvider({ + delayResultsPromise, + results: [result], + type: UrlbarUtils.PROVIDER_TYPE.PROFILE, + }); + + UrlbarProvidersManager.registerProvider(delayedProvider); + + controller.startQuery(context); + + let params = await startedPromise; + Assert.equal(params[0], context); + + info("Should have notified the provider the query is canceled"); + await providerCanceledDeferred.promise; + + params = await cancelPromise; + UrlbarProvidersManager.unregisterProvider(delayedProvider); +}); diff --git a/browser/components/urlbar/tests/unit/test_UrlbarController_telemetry.js b/browser/components/urlbar/tests/unit/test_UrlbarController_telemetry.js new file mode 100644 index 0000000000..d344c4f8e1 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarController_telemetry.js @@ -0,0 +1,253 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests unit test the functionality of UrlbarController by stubbing out the + * model and providing stubs to be called. + */ + +"use strict"; + +const TEST_URL = "http://example.com"; +const MATCH = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: TEST_URL } +); +const TELEMETRY_1ST_RESULT = "PLACES_AUTOCOMPLETE_1ST_RESULT_TIME_MS"; +const TELEMETRY_6_FIRST_RESULTS = "PLACES_AUTOCOMPLETE_6_FIRST_RESULTS_TIME_MS"; + +let controller; +let firstHistogram; +let sixthHistogram; + +/** + * A delayed test provider, allowing the query to be delayed for an amount of time. + */ +class DelayedProvider extends TestProvider { + async startQuery(context, add) { + Assert.ok(context, "context is passed-in"); + Assert.equal(typeof add, "function", "add is a callback"); + this._add = add; + await new Promise(resolve => { + this._resultsAdded = resolve; + }); + } + async addResults(matches, finish = true) { + // startQuery may have not been invoked yet, so wait for it + await TestUtils.waitForCondition( + () => !!this._add, + "Waiting for the _add callback" + ); + for (const match of matches) { + this._add(this, match); + } + if (finish) { + this._add = null; + this._resultsAdded(); + } + } +} + +/** + * Returns the number of reports sent recorded within the histogram results. + * + * @param {object} results a snapshot of histogram results to check. + * @returns {number} The count of reports recorded in the histogram. + */ +function getHistogramReportsCount(results) { + let sum = 0; + for (let [, value] of Object.entries(results.values)) { + sum += value; + } + return sum; +} + +add_setup(function () { + controller = UrlbarTestUtils.newMockController(); + + firstHistogram = Services.telemetry.getHistogramById(TELEMETRY_1ST_RESULT); + sixthHistogram = Services.telemetry.getHistogramById( + TELEMETRY_6_FIRST_RESULTS + ); +}); + +add_task(async function test_n_autocomplete_cancel() { + firstHistogram.clear(); + sixthHistogram.clear(); + + let provider = new TestProvider({ + results: [], + }); + UrlbarProvidersManager.registerProvider(provider); + const context = createContext(TEST_URL, { providers: [provider.name] }); + + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_1ST_RESULT, context), + "Should not have started first result stopwatch" + ); + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_6_FIRST_RESULTS, context), + "Should not have started first 6 results stopwatch" + ); + + let startQueryPromise = controller.startQuery(context); + + Assert.ok( + TelemetryStopwatch.running(TELEMETRY_1ST_RESULT, context), + "Should have started first result stopwatch" + ); + Assert.ok( + TelemetryStopwatch.running(TELEMETRY_6_FIRST_RESULTS, context), + "Should have started first 6 results stopwatch" + ); + + controller.cancelQuery(context); + await startQueryPromise; + + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_1ST_RESULT, context), + "Should have canceled first result stopwatch" + ); + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_6_FIRST_RESULTS, context), + "Should have canceled first 6 results stopwatch" + ); + + let results = firstHistogram.snapshot(); + Assert.equal( + results.sum, + 0, + "Should not have recorded any times (first result)" + ); + results = sixthHistogram.snapshot(); + Assert.equal( + results.sum, + 0, + "Should not have recorded any times (first 6 results)" + ); +}); + +add_task(async function test_n_autocomplete_results() { + firstHistogram.clear(); + sixthHistogram.clear(); + + let provider = new DelayedProvider(); + UrlbarProvidersManager.registerProvider(provider); + const context = createContext(TEST_URL, { providers: [provider.name] }); + + let resultsPromise = promiseControllerNotification( + controller, + "onQueryResults" + ); + + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_1ST_RESULT, context), + "Should not have started first result stopwatch" + ); + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_6_FIRST_RESULTS, context), + "Should not have started first 6 results stopwatch" + ); + + controller.startQuery(context); + + Assert.ok( + TelemetryStopwatch.running(TELEMETRY_1ST_RESULT, context), + "Should have started first result stopwatch" + ); + Assert.ok( + TelemetryStopwatch.running(TELEMETRY_6_FIRST_RESULTS, context), + "Should have started first 6 results stopwatch" + ); + + await provider.addResults([MATCH], false); + await resultsPromise; + + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_1ST_RESULT, context), + "Should have stopped the first stopwatch" + ); + Assert.ok( + TelemetryStopwatch.running(TELEMETRY_6_FIRST_RESULTS, context), + "Should have kept the first 6 results stopwatch running" + ); + + let firstResults = firstHistogram.snapshot(); + let first6Results = sixthHistogram.snapshot(); + Assert.equal( + getHistogramReportsCount(firstResults), + 1, + "Should have recorded one time for the first result" + ); + Assert.equal( + getHistogramReportsCount(first6Results), + 0, + "Should not have recorded any times (first 6 results)" + ); + + // Now add 5 more results, so that the first 6 results is triggered. + for (let i = 0; i < 5; i++) { + resultsPromise = promiseControllerNotification( + controller, + "onQueryResults" + ); + await provider.addResults( + [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: TEST_URL + "/" + i } + ), + ], + false + ); + await resultsPromise; + } + + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_1ST_RESULT, context), + "Should have stopped the first stopwatch" + ); + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_6_FIRST_RESULTS, context), + "Should have stopped the first 6 results stopwatch" + ); + + let updatedResults = firstHistogram.snapshot(); + let updated6Results = sixthHistogram.snapshot(); + Assert.deepEqual( + updatedResults, + firstResults, + "Should not have changed the histogram for the first result" + ); + Assert.equal( + getHistogramReportsCount(updated6Results), + 1, + "Should have recorded one time for the first 6 results" + ); + + // Add one more, to check neither are updated. + resultsPromise = promiseControllerNotification(controller, "onQueryResults"); + await provider.addResults([ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: TEST_URL + "/6" } + ), + ]); + await resultsPromise; + + let secondUpdateResults = firstHistogram.snapshot(); + let secondUpdate6Results = sixthHistogram.snapshot(); + Assert.deepEqual( + secondUpdateResults, + firstResults, + "Should not have changed the histogram for the first result" + ); + Assert.equal( + getHistogramReportsCount(secondUpdate6Results), + 1, + "Should not have changed the histogram for the first 6 results" + ); +}); diff --git a/browser/components/urlbar/tests/unit/test_UrlbarController_unit.js b/browser/components/urlbar/tests/unit/test_UrlbarController_unit.js new file mode 100644 index 0000000000..31a0b48227 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarController_unit.js @@ -0,0 +1,389 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests unit test the functionality of UrlbarController by stubbing out the + * model and providing stubs to be called. + */ + +"use strict"; + +// A fake ProvidersManager. +let fPM; +let sandbox; +let generalListener; +let controller; + +/** + * Asserts that the query context has the expected values. + * + * @param {UrlbarQueryContext} context The query context. + * @param {object} expectedValues The expected values for the UrlbarQueryContext. + */ +function assertContextMatches(context, expectedValues) { + Assert.ok( + context instanceof UrlbarQueryContext, + "Should be a UrlbarQueryContext" + ); + + for (let [key, value] of Object.entries(expectedValues)) { + Assert.equal( + context[key], + value, + `Should have the expected value for ${key} in the UrlbarQueryContext` + ); + } +} + +add_setup(function () { + sandbox = sinon.createSandbox(); + + fPM = { + startQuery: sandbox.stub(), + cancelQuery: sandbox.stub(), + }; + + generalListener = { + onQueryStarted: sandbox.stub(), + onQueryResults: sandbox.stub(), + onQueryCancelled: sandbox.stub(), + }; + + controller = UrlbarTestUtils.newMockController({ + manager: fPM, + }); + controller.addQueryListener(generalListener); +}); + +add_task(function test_constructor_throws() { + Assert.throws( + () => new UrlbarController(), + /Missing options: input/, + "Should throw if the input was not supplied" + ); + Assert.throws( + () => new UrlbarController({ input: {} }), + /input is missing 'window' property/, + "Should throw if the input is not a UrlbarInput" + ); + Assert.throws( + () => new UrlbarController({ input: { window: {} } }), + /input.window should be an actual browser window/, + "Should throw if the input.window is not a window" + ); + Assert.throws( + () => + new UrlbarController({ + input: { + window: { + location: "about:fake", + }, + }, + }), + /input.window should be an actual browser window/, + "Should throw if the input.window is not an object" + ); + Assert.throws( + () => + new UrlbarController({ + input: { + window: { + location: { + href: "about:fake", + }, + }, + }, + }), + /input.window should be an actual browser window/, + "Should throw if the input.window does not have the correct location" + ); + Assert.throws( + () => + new UrlbarController({ + input: { + window: { + location: { + href: AppConstants.BROWSER_CHROME_URL, + }, + }, + }, + }), + /input.isPrivate must be set/, + "Should throw if input.isPrivate is not set" + ); + + new UrlbarController({ + input: { + isPrivate: false, + window: { + location: { + href: AppConstants.BROWSER_CHROME_URL, + }, + }, + }, + }); + Assert.ok(true, "Correct call should not throw"); +}); + +add_task(function test_add_and_remove_listeners() { + Assert.throws( + () => controller.addQueryListener(null), + /Expected listener to be an object/, + "Should throw for a null listener" + ); + Assert.throws( + () => controller.addQueryListener(123), + /Expected listener to be an object/, + "Should throw for a non-object listener" + ); + + const listener = {}; + + controller.addQueryListener(listener); + + Assert.ok( + controller._listeners.has(listener), + "Should have added the listener to the list." + ); + + // Adding a non-existent listener shouldn't throw. + controller.removeQueryListener(123); + + controller.removeQueryListener(listener); + + Assert.ok( + !controller._listeners.has(listener), + "Should have removed the listener from the list" + ); + + sandbox.resetHistory(); +}); + +add_task(function test__notify() { + const listener1 = { + onFake: sandbox.stub().callsFake(() => { + throw new Error("fake error"); + }), + }; + const listener2 = { + onFake: sandbox.stub(), + }; + + controller.addQueryListener(listener1); + controller.addQueryListener(listener2); + + const param = "1234"; + + controller.notify("onFake", param); + + Assert.equal( + listener1.onFake.callCount, + 1, + "Should have called the first listener method." + ); + Assert.deepEqual( + listener1.onFake.args[0], + [param], + "Should have called the first listener with the correct argument" + ); + Assert.equal( + listener2.onFake.callCount, + 1, + "Should have called the second listener method." + ); + Assert.deepEqual( + listener2.onFake.args[0], + [param], + "Should have called the first listener with the correct argument" + ); + + controller.removeQueryListener(listener2); + controller.removeQueryListener(listener1); + + // This should succeed without errors. + controller.notify("onNewFake"); + + sandbox.resetHistory(); +}); + +add_task(function test_handle_query_starts_search() { + const context = createContext(); + controller.startQuery(context); + + Assert.equal( + fPM.startQuery.callCount, + 1, + "Should have called startQuery once" + ); + Assert.equal( + fPM.startQuery.args[0].length, + 2, + "Should have called startQuery with two arguments" + ); + + assertContextMatches(fPM.startQuery.args[0][0], {}); + Assert.equal( + fPM.startQuery.args[0][1], + controller, + "Should have passed the controller as the second argument" + ); + + Assert.equal( + generalListener.onQueryStarted.callCount, + 1, + "Should have called onQueryStarted for the listener" + ); + Assert.deepEqual( + generalListener.onQueryStarted.args[0], + [context], + "Should have called onQueryStarted with the context" + ); + + sandbox.resetHistory(); +}); + +add_task(async function test_handle_query_starts_search_sets_allowAutofill() { + let originalValue = Services.prefs.getBoolPref("browser.urlbar.autoFill"); + Services.prefs.setBoolPref("browser.urlbar.autoFill", !originalValue); + + await controller.startQuery(createContext()); + + Assert.equal( + fPM.startQuery.callCount, + 1, + "Should have called startQuery once" + ); + Assert.equal( + fPM.startQuery.args[0].length, + 2, + "Should have called startQuery with two arguments" + ); + + assertContextMatches(fPM.startQuery.args[0][0], { + allowAutofill: !originalValue, + }); + Assert.equal( + fPM.startQuery.args[0][1], + controller, + "Should have passed the controller as the second argument" + ); + + sandbox.resetHistory(); + + Services.prefs.clearUserPref("browser.urlbar.autoFill"); +}); + +add_task(function test_cancel_query() { + const context = createContext(); + controller.startQuery(context); + + controller.cancelQuery(); + + Assert.equal( + fPM.cancelQuery.callCount, + 1, + "Should have called cancelQuery once" + ); + Assert.equal( + fPM.cancelQuery.args[0].length, + 1, + "Should have called cancelQuery with one argument" + ); + + Assert.equal( + generalListener.onQueryCancelled.callCount, + 1, + "Should have called onQueryCancelled for the listener" + ); + Assert.deepEqual( + generalListener.onQueryCancelled.args[0], + [context], + "Should have called onQueryCancelled with the context" + ); + + sandbox.resetHistory(); +}); + +add_task(function test_receiveResults() { + const context = createContext(); + context.results = []; + controller.receiveResults(context); + + Assert.equal( + generalListener.onQueryResults.callCount, + 1, + "Should have called onQueryResults for the listener" + ); + Assert.deepEqual( + generalListener.onQueryResults.args[0], + [context], + "Should have called onQueryResults with the context" + ); + + sandbox.resetHistory(); +}); + +add_task(async function test_notifications_order() { + // Clear any pending notifications. + const context = createContext(); + await controller.startQuery(context); + + // Check that when multiple queries are executed, the notifications arrive + // in the proper order. + let collectingListener = new Proxy( + {}, + { + _notifications: [], + get(target, name) { + if (name == "notifications") { + return this._notifications; + } + return () => { + this._notifications.push(name); + }; + }, + } + ); + controller.addQueryListener(collectingListener); + controller.startQuery(context); + Assert.deepEqual( + ["onQueryStarted"], + collectingListener.notifications, + "Check onQueryStarted is fired synchronously" + ); + controller.startQuery(context); + Assert.deepEqual( + ["onQueryStarted", "onQueryCancelled", "onQueryFinished", "onQueryStarted"], + collectingListener.notifications, + "Check order of notifications" + ); + controller.cancelQuery(); + Assert.deepEqual( + [ + "onQueryStarted", + "onQueryCancelled", + "onQueryFinished", + "onQueryStarted", + "onQueryCancelled", + "onQueryFinished", + ], + collectingListener.notifications, + "Check order of notifications" + ); + await controller.startQuery(context); + controller.cancelQuery(); + Assert.deepEqual( + [ + "onQueryStarted", + "onQueryCancelled", + "onQueryFinished", + "onQueryStarted", + "onQueryCancelled", + "onQueryFinished", + "onQueryStarted", + "onQueryFinished", + ], + collectingListener.notifications, + "Check order of notifications" + ); +}); diff --git a/browser/components/urlbar/tests/unit/test_UrlbarPrefs.js b/browser/components/urlbar/tests/unit/test_UrlbarPrefs.js new file mode 100644 index 0000000000..d30739f03e --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarPrefs.js @@ -0,0 +1,447 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(function test() { + Assert.throws( + () => UrlbarPrefs.get("browser.migration.version"), + /Trying to access an unknown pref/, + "Should throw when passing an untracked pref" + ); + + Assert.throws( + () => UrlbarPrefs.set("browser.migration.version", 100), + /Trying to access an unknown pref/, + "Should throw when passing an untracked pref" + ); + Assert.throws( + () => UrlbarPrefs.set("maxRichResults", "10"), + /Invalid value/, + "Should throw when passing an invalid value type" + ); + + Assert.deepEqual(UrlbarPrefs.get("formatting.enabled"), true); + UrlbarPrefs.set("formatting.enabled", false); + Assert.deepEqual(UrlbarPrefs.get("formatting.enabled"), false); + + Assert.deepEqual(UrlbarPrefs.get("maxRichResults"), 10); + UrlbarPrefs.set("maxRichResults", 6); + Assert.deepEqual(UrlbarPrefs.get("maxRichResults"), 6); + + Assert.deepEqual(UrlbarPrefs.get("autoFill.stddevMultiplier"), 0.0); + UrlbarPrefs.set("autoFill.stddevMultiplier", 0.01); + // Due to rounding errors, floats are slightly imprecise, so we can't + // directly compare what we set to what we retrieve. + Assert.deepEqual( + parseFloat(UrlbarPrefs.get("autoFill.stddevMultiplier").toFixed(2)), + 0.01 + ); +}); + +// Tests UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: true }). +add_task(function makeResultGroups_true() { + Assert.deepEqual( + UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: true }), + { + children: [ + // heuristic + { + maxResultCount: 1, + children: [ + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_EXTENSION }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_SEARCH_TIP }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_OMNIBOX }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_ENGINE_ALIAS }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_BOOKMARK_KEYWORD }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_AUTOFILL }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TOKEN_ALIAS_ENGINE }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_HISTORY_URL }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK }, + ], + }, + // extensions using the omnibox API + { + group: UrlbarUtils.RESULT_GROUP.OMNIBOX, + }, + // main group + { + flexChildren: true, + children: [ + // suggestions + { + flex: 2, + children: [ + { + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + { + flex: 99, + group: UrlbarUtils.RESULT_GROUP.RECENT_SEARCH, + }, + { + flex: 4, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + group: UrlbarUtils.RESULT_GROUP.TAIL_SUGGESTION, + }, + ], + }, + // general + { + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + flex: 1, + children: [ + { + availableSpan: 3, + group: UrlbarUtils.RESULT_GROUP.INPUT_HISTORY, + }, + { + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_TAB, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.ABOUT_PAGES, + }, + ], + }, + { + group: UrlbarUtils.RESULT_GROUP.INPUT_HISTORY, + }, + ], + }, + ], + }, + ], + } + ); +}); + +// Tests UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: false }). +add_task(function makeResultGroups_false() { + Assert.deepEqual( + UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: false }), + + { + children: [ + // heuristic + { + maxResultCount: 1, + children: [ + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_EXTENSION }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_SEARCH_TIP }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_OMNIBOX }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_ENGINE_ALIAS }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_BOOKMARK_KEYWORD }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_AUTOFILL }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TOKEN_ALIAS_ENGINE }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_HISTORY_URL }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK }, + ], + }, + // extensions using the omnibox API + { + group: UrlbarUtils.RESULT_GROUP.OMNIBOX, + }, + // main group + { + flexChildren: true, + children: [ + // general + { + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + flex: 2, + children: [ + { + availableSpan: 3, + group: UrlbarUtils.RESULT_GROUP.INPUT_HISTORY, + }, + { + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_TAB, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.ABOUT_PAGES, + }, + ], + }, + { + group: UrlbarUtils.RESULT_GROUP.INPUT_HISTORY, + }, + ], + }, + // suggestions + { + flex: 1, + children: [ + { + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + { + flex: 99, + group: UrlbarUtils.RESULT_GROUP.RECENT_SEARCH, + }, + { + flex: 4, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + group: UrlbarUtils.RESULT_GROUP.TAIL_SUGGESTION, + }, + ], + }, + ], + }, + ], + } + ); +}); + +// Tests interaction between showSearchSuggestionsFirst and resultGroups. +add_task(function showSearchSuggestionsFirst_resultGroups() { + // Check initial values. + Assert.equal( + UrlbarPrefs.get("showSearchSuggestionsFirst"), + true, + "showSearchSuggestionsFirst is true initially" + ); + Assert.deepEqual( + UrlbarPrefs.resultGroups, + UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: true }), + "resultGroups is the same as the groups for which howSearchSuggestionsFirst is true" + ); + + // Set showSearchSuggestionsFirst = false. + UrlbarPrefs.set("showSearchSuggestionsFirst", false); + Assert.deepEqual( + UrlbarPrefs.resultGroups, + UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: false }), + "resultGroups is updated after setting showSearchSuggestionsFirst = false" + ); + + // Set showSearchSuggestionsFirst = true. + UrlbarPrefs.set("showSearchSuggestionsFirst", true); + Assert.deepEqual( + UrlbarPrefs.resultGroups, + UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: true }), + "resultGroups is updated after setting showSearchSuggestionsFirst = true" + ); + + // Set showSearchSuggestionsFirst = false again so we can clear it next. + UrlbarPrefs.set("showSearchSuggestionsFirst", false); + Assert.deepEqual( + UrlbarPrefs.resultGroups, + UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: false }), + "resultGroups is updated after setting showSearchSuggestionsFirst = false" + ); + + // Clear showSearchSuggestionsFirst. + Services.prefs.clearUserPref("browser.urlbar.showSearchSuggestionsFirst"); + Assert.deepEqual( + UrlbarPrefs.resultGroups, + UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: true }), + "resultGroups is updated immediately after clearing showSearchSuggestionsFirst" + ); + Assert.equal( + UrlbarPrefs.get("showSearchSuggestionsFirst"), + true, + "showSearchSuggestionsFirst defaults to true after clearing it" + ); + Assert.deepEqual( + UrlbarPrefs.resultGroups, + UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: true }), + "resultGroups remains correct after getting showSearchSuggestionsFirst" + ); +}); + +// Tests UrlbarPrefs.initializeShowSearchSuggestionsFirstPref() and the +// interaction between matchGroups, showSearchSuggestionsFirst, and +// resultGroups. It's a little complex, but the flow is: +// +// 1. The old matchGroups pref has some value +// 2. UrlbarPrefs.initializeShowSearchSuggestionsFirstPref() is called to +// translate matchGroups into the newer showSearchSuggestionsFirst pref +// 3. The update to showSearchSuggestionsFirst causes the new resultGroups +// pref to be set +add_task(function initializeShowSearchSuggestionsFirstPref() { + // Each value in `tests`: [matchGroups, expectedShowSearchSuggestionsFirst] + let tests = [ + ["suggestion:4,general:Infinity", true], + ["suggestion:4,general:5", true], + ["suggestion:1,general:5,suggestion:Infinity", true], + ["suggestion:Infinity", true], + ["suggestion:4", true], + + ["foo:1,suggestion:4,general:Infinity", true], + ["foo:2,suggestion:4,general:5", true], + ["foo:3,suggestion:1,general:5,suggestion:Infinity", true], + ["foo:4,suggestion:Infinity", true], + ["foo:5,suggestion:4", true], + + ["general:5,suggestion:Infinity", false], + ["general:5,suggestion:4", false], + ["general:1,suggestion:4,general:Infinity", false], + ["general:Infinity", false], + ["general:5", false], + + ["foo:1,general:5,suggestion:Infinity", false], + ["foo:2,general:5,suggestion:4", false], + ["foo:3,general:1,suggestion:4,general:Infinity", false], + ["foo:4,general:Infinity", false], + ["foo:5,general:5", false], + + ["", true], + ["bogus groups", true], + ]; + + for (let [matchGroups, expectedValue] of tests) { + info("Running test: " + JSON.stringify({ matchGroups, expectedValue })); + Services.prefs.clearUserPref("browser.urlbar.showSearchSuggestionsFirst"); + + // Set matchGroups. + Services.prefs.setCharPref("browser.urlbar.matchGroups", matchGroups); + + // Call initializeShowSearchSuggestionsFirstPref. + UrlbarPrefs.initializeShowSearchSuggestionsFirstPref(); + + // Both showSearchSuggestionsFirst and resultGroups should be updated. + Assert.equal( + Services.prefs.getBoolPref("browser.urlbar.showSearchSuggestionsFirst"), + expectedValue, + "showSearchSuggestionsFirst has the expected value" + ); + Assert.deepEqual( + UrlbarPrefs.resultGroups, + UrlbarPrefs.makeResultGroups({ + showSearchSuggestionsFirst: expectedValue, + }), + "resultGroups should be updated with the appropriate default" + ); + } + + Services.prefs.clearUserPref("browser.urlbar.matchGroups"); +}); + +// Tests whether observer.onNimbusChanged works. +add_task(async function onNimbusChanged() { + Services.prefs.setBoolPref( + "browser.urlbar.autoFill.adaptiveHistory.enabled", + false + ); + + // Add an observer that throws an Error and an observer that does not define + // anything to check whether the other observers can get notifications. + UrlbarPrefs.addObserver({ + onPrefChanged(pref) { + throw new Error("From onPrefChanged"); + }, + onNimbusChanged(pref) { + throw new Error("From onNimbusChanged"); + }, + }); + UrlbarPrefs.addObserver({}); + + const observer = { + onPrefChanged(pref) { + this.prefChangedList.push(pref); + }, + onNimbusChanged(pref) { + this.nimbusChangedList.push(pref); + }, + }; + observer.prefChangedList = []; + observer.nimbusChangedList = []; + UrlbarPrefs.addObserver(observer); + + const doCleanup = await UrlbarTestUtils.initNimbusFeature({ + autoFillAdaptiveHistoryEnabled: true, + }); + Assert.equal(observer.prefChangedList.length, 0); + Assert.ok( + observer.nimbusChangedList.includes("autoFillAdaptiveHistoryEnabled") + ); + doCleanup(); +}); + +// Tests whether observer.onPrefChanged works. +add_task(async function onPrefChanged() { + const doCleanup = await UrlbarTestUtils.initNimbusFeature({ + autoFillAdaptiveHistoryEnabled: false, + }); + Services.prefs.setBoolPref( + "browser.urlbar.autoFill.adaptiveHistory.enabled", + false + ); + + // Add an observer that throws an Error and an observer that does not define + // anything to check whether the other observers can get notifications. + UrlbarPrefs.addObserver({ + onPrefChanged(pref) { + throw new Error("From onPrefChanged"); + }, + onNimbusChanged(pref) { + throw new Error("From onNimbusChanged"); + }, + }); + UrlbarPrefs.addObserver({}); + + const deferred = Promise.withResolvers(); + const observer = { + onPrefChanged(pref) { + this.prefChangedList.push(pref); + deferred.resolve(); + }, + onNimbusChanged(pref) { + this.nimbusChangedList.push(pref); + deferred.resolve(); + }, + }; + observer.prefChangedList = []; + observer.nimbusChangedList = []; + UrlbarPrefs.addObserver(observer); + + Services.prefs.setBoolPref( + "browser.urlbar.autoFill.adaptiveHistory.enabled", + true + ); + await deferred.promise; + Assert.equal(observer.prefChangedList.length, 1); + Assert.equal(observer.prefChangedList[0], "autoFill.adaptiveHistory.enabled"); + Assert.equal(observer.nimbusChangedList.length, 0); + + Services.prefs.clearUserPref( + "browser.urlbar.autoFill.adaptiveHistory.enabled" + ); + doCleanup(); +}); diff --git a/browser/components/urlbar/tests/unit/test_UrlbarQueryContext.js b/browser/components/urlbar/tests/unit/test_UrlbarQueryContext.js new file mode 100644 index 0000000000..e30e2fa0eb --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarQueryContext.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(function test_constructor() { + Assert.throws( + () => new UrlbarQueryContext(), + /Missing or empty allowAutofill provided to UrlbarQueryContext/, + "Should throw with no arguments" + ); + + Assert.throws( + () => + new UrlbarQueryContext({ + allowAutofill: true, + isPrivate: false, + searchString: "foo", + }), + /Missing or empty maxResults provided to UrlbarQueryContext/, + "Should throw with a missing maxResults parameter" + ); + + Assert.throws( + () => + new UrlbarQueryContext({ + allowAutofill: true, + maxResults: 1, + searchString: "foo", + }), + /Missing or empty isPrivate provided to UrlbarQueryContext/, + "Should throw with a missing isPrivate parameter" + ); + + Assert.throws( + () => + new UrlbarQueryContext({ + isPrivate: false, + maxResults: 1, + searchString: "foo", + }), + /Missing or empty allowAutofill provided to UrlbarQueryContext/, + "Should throw with a missing allowAutofill parameter" + ); + + let qc = new UrlbarQueryContext({ + allowAutofill: false, + isPrivate: true, + maxResults: 1, + searchString: "foo", + }); + + Assert.strictEqual( + qc.allowAutofill, + false, + "Should have saved the correct value for allowAutofill" + ); + Assert.strictEqual( + qc.isPrivate, + true, + "Should have saved the correct value for isPrivate" + ); + Assert.equal( + qc.maxResults, + 1, + "Should have saved the correct value for maxResults" + ); + Assert.equal( + qc.searchString, + "foo", + "Should have saved the correct value for searchString" + ); +}); diff --git a/browser/components/urlbar/tests/unit/test_UrlbarQueryContext_restrictSource.js b/browser/components/urlbar/tests/unit/test_UrlbarQueryContext_restrictSource.js new file mode 100644 index 0000000000..3867668c1a --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarQueryContext_restrictSource.js @@ -0,0 +1,113 @@ +/* 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/. */ + +/** + * Test for restrictions set through UrlbarQueryContext.sources. + */ + +testEngine_setup(); + +add_task(async function test_restrictions() { + await PlacesTestUtils.addVisits([ + { uri: "http://history.com/", title: "match" }, + ]); + await PlacesUtils.bookmarks.insert({ + url: "http://bookmark.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "match", + }); + await UrlbarProviderOpenTabs.registerOpenTab( + "http://openpagematch.com/", + 0, + false + ); + + info("Bookmark restrict"); + let results = await get_results({ + sources: [UrlbarUtils.RESULT_SOURCE.BOOKMARKS], + searchString: "match", + }); + // Skip the heuristic result. + Assert.deepEqual( + results.filter(r => !r.heuristic).map(r => r.payload.url), + ["http://bookmark.com/"] + ); + + info("History restrict"); + results = await get_results({ + sources: [UrlbarUtils.RESULT_SOURCE.HISTORY], + searchString: "match", + }); + // Skip the heuristic result. + Assert.deepEqual( + results.filter(r => !r.heuristic).map(r => r.payload.url), + ["http://history.com/"] + ); + + info("tabs restrict"); + results = await get_results({ + sources: [UrlbarUtils.RESULT_SOURCE.TABS], + searchString: "match", + }); + // Skip the heuristic result. + Assert.deepEqual( + results.filter(r => !r.heuristic).map(r => r.payload.url), + ["http://openpagematch.com/"] + ); + + info("search restrict"); + results = await get_results({ + sources: [UrlbarUtils.RESULT_SOURCE.SEARCH], + searchString: "match", + }); + Assert.ok( + !results.some(r => r.payload.engine != SUGGESTIONS_ENGINE_NAME), + "All the results should be search results" + ); + + info("search restrict should ignore restriction token"); + results = await get_results({ + sources: [UrlbarUtils.RESULT_SOURCE.SEARCH], + searchString: `${UrlbarTokenizer.RESTRICT.BOOKMARKS} match`, + }); + Assert.ok( + !results.some(r => r.payload.engine != SUGGESTIONS_ENGINE_NAME), + "All the results should be search results" + ); + Assert.equal( + results[0].payload.query, + `${UrlbarTokenizer.RESTRICT.BOOKMARKS} match`, + "The restriction token should be ignored and not stripped" + ); + + info("search restrict with other engine"); + results = await get_results({ + sources: [UrlbarUtils.RESULT_SOURCE.SEARCH], + searchString: "match", + engineName: "Test", + }); + Assert.ok( + !results.some(r => r.payload.engine != "Test"), + "All the results should be search results from the Test engine" + ); +}); + +async function get_results(test) { + let controller = UrlbarTestUtils.newMockController(); + let options = { + allowAutofill: false, + isPrivate: false, + maxResults: 10, + sources: test.sources, + }; + if (test.engineName) { + options.searchMode = { + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + engineName: test.engineName, + }; + } + let queryContext = createContext(test.searchString, options); + await controller.startQuery(queryContext); + return queryContext.results; +} diff --git a/browser/components/urlbar/tests/unit/test_UrlbarSearchUtils.js b/browser/components/urlbar/tests/unit/test_UrlbarSearchUtils.js new file mode 100644 index 0000000000..fe33228007 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarSearchUtils.js @@ -0,0 +1,462 @@ +/* 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 { UrlbarSearchUtils } = ChromeUtils.importESModule( + "resource:///modules/UrlbarSearchUtils.sys.mjs" +); + +let baconEngineExtension; + +add_task(async function () { + await UrlbarSearchUtils.init(); + // Tell the search service we are running in the US. This also has the + // desired side-effect of preventing our geoip lookup. + Services.prefs.setCharPref("browser.search.region", "US"); + + Services.search.restoreDefaultEngines(); + Services.search.resetToAppDefaultEngine(); +}); + +add_task(async function search_engine_match() { + let engine = await Services.search.getDefault(); + let domain = engine.searchUrlDomain; + let token = domain.substr(0, 1); + let matchedEngine = ( + await UrlbarSearchUtils.enginesForDomainPrefix(token) + )[0]; + Assert.equal(matchedEngine, engine); +}); + +add_task(async function no_match() { + Assert.equal( + 0, + (await UrlbarSearchUtils.enginesForDomainPrefix("test")).length + ); +}); + +add_task(async function hide_search_engine_nomatch() { + let engine = await Services.search.getDefault(); + let domain = engine.searchUrlDomain; + let token = domain.substr(0, 1); + let promiseTopic = promiseSearchTopic("engine-changed"); + await Promise.all([Services.search.removeEngine(engine), promiseTopic]); + Assert.ok(engine.hidden); + let matchedEngines = await UrlbarSearchUtils.enginesForDomainPrefix(token); + Assert.ok( + !matchedEngines.length || matchedEngines[0].searchUrlDomain != domain + ); + engine.hidden = false; + await TestUtils.waitForCondition( + async () => (await UrlbarSearchUtils.enginesForDomainPrefix(token)).length + ); + let matchedEngine2 = ( + await UrlbarSearchUtils.enginesForDomainPrefix(token) + )[0]; + Assert.ok(matchedEngine2); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); +}); + +add_task(async function onlyEnabled_option_nomatch() { + let engine = await Services.search.getDefault(); + let domain = engine.searchUrlDomain; + let token = domain.substr(0, 1); + engine.hideOneOffButton = true; + let matchedEngines = await UrlbarSearchUtils.enginesForDomainPrefix(token, { + onlyEnabled: true, + }); + Assert.notEqual(matchedEngines[0].searchUrlDomain, domain); + engine.hideOneOffButton = false; + matchedEngines = await UrlbarSearchUtils.enginesForDomainPrefix(token, { + onlyEnabled: true, + }); + Assert.equal(matchedEngines[0].searchUrlDomain, domain); +}); + +add_task(async function add_search_engine_match() { + Assert.equal( + 0, + (await UrlbarSearchUtils.enginesForDomainPrefix("bacon")).length + ); + baconEngineExtension = await SearchTestUtils.installSearchExtension( + { + name: "bacon", + keyword: "pork", + search_url: "https://www.bacon.moz/", + }, + { skipUnload: true } + ); + let matchedEngine = ( + await UrlbarSearchUtils.enginesForDomainPrefix("bacon") + )[0]; + Assert.ok(matchedEngine); + Assert.equal(matchedEngine.searchForm, "https://www.bacon.moz"); + Assert.equal(matchedEngine.name, "bacon"); + Assert.equal(matchedEngine.getIconURL(), null); + info("also type part of the public suffix"); + matchedEngine = ( + await UrlbarSearchUtils.enginesForDomainPrefix("bacon.m") + )[0]; + Assert.ok(matchedEngine); + Assert.equal(matchedEngine.searchForm, "https://www.bacon.moz"); + Assert.equal(matchedEngine.name, "bacon"); + Assert.equal(matchedEngine.getIconURL(), null); +}); + +add_task(async function match_multiple_search_engines() { + Assert.equal( + 0, + (await UrlbarSearchUtils.enginesForDomainPrefix("baseball")).length + ); + await SearchTestUtils.installSearchExtension({ + name: "baseball", + search_url: "https://www.baseball.moz/", + }); + let matchedEngines = await UrlbarSearchUtils.enginesForDomainPrefix("ba"); + Assert.equal( + matchedEngines.length, + 2, + "enginesForDomainPrefix returned two engines." + ); + Assert.equal(matchedEngines[0].searchForm, "https://www.bacon.moz"); + Assert.equal(matchedEngines[0].name, "bacon"); + Assert.equal(matchedEngines[1].searchForm, "https://www.baseball.moz"); + Assert.equal(matchedEngines[1].name, "baseball"); +}); + +add_task(async function test_aliased_search_engine_match() { + Assert.equal(null, await UrlbarSearchUtils.engineForAlias("sober")); + // Lower case + let matchedEngine = await UrlbarSearchUtils.engineForAlias("pork"); + Assert.ok(matchedEngine); + Assert.equal(matchedEngine.name, "bacon"); + Assert.ok(matchedEngine.aliases.includes("pork")); + Assert.equal(matchedEngine.getIconURL(), null); + // Upper case + matchedEngine = await UrlbarSearchUtils.engineForAlias("PORK"); + Assert.ok(matchedEngine); + Assert.equal(matchedEngine.name, "bacon"); + Assert.ok(matchedEngine.aliases.includes("pork")); + Assert.equal(matchedEngine.getIconURL(), null); + // Cap case + matchedEngine = await UrlbarSearchUtils.engineForAlias("Pork"); + Assert.ok(matchedEngine); + Assert.equal(matchedEngine.name, "bacon"); + Assert.ok(matchedEngine.aliases.includes("pork")); + Assert.equal(matchedEngine.getIconURL(), null); +}); + +add_task(async function test_aliased_search_engine_match_upper_case_alias() { + Assert.equal( + 0, + (await UrlbarSearchUtils.enginesForDomainPrefix("patch")).length + ); + await SearchTestUtils.installSearchExtension({ + name: "patch", + keyword: "PR", + search_url: "https://www.patch.moz/", + }); + // lower case + let matchedEngine = await UrlbarSearchUtils.engineForAlias("pr"); + Assert.ok(matchedEngine); + Assert.equal(matchedEngine.name, "patch"); + Assert.ok(matchedEngine.aliases.includes("PR")); + Assert.equal(matchedEngine.getIconURL(), null); + // Upper case + matchedEngine = await UrlbarSearchUtils.engineForAlias("PR"); + Assert.ok(matchedEngine); + Assert.equal(matchedEngine.name, "patch"); + Assert.ok(matchedEngine.aliases.includes("PR")); + Assert.equal(matchedEngine.getIconURL(), null); + // Cap case + matchedEngine = await UrlbarSearchUtils.engineForAlias("Pr"); + Assert.ok(matchedEngine); + Assert.equal(matchedEngine.name, "patch"); + Assert.ok(matchedEngine.aliases.includes("PR")); + Assert.equal(matchedEngine.getIconURL(), null); +}); + +add_task(async function remove_search_engine_nomatch() { + let promiseTopic = promiseSearchTopic("engine-removed"); + await Promise.all([baconEngineExtension.unload(), promiseTopic]); + Assert.equal( + 0, + (await UrlbarSearchUtils.enginesForDomainPrefix("bacon")).length + ); +}); + +add_task(async function test_builtin_aliased_search_engine_match() { + let engine = await UrlbarSearchUtils.engineForAlias("@google"); + Assert.ok(engine); + Assert.equal(engine.name, "Google"); + let promiseTopic = promiseSearchTopic("engine-changed"); + await Promise.all([Services.search.removeEngine(engine), promiseTopic]); + let matchedEngine = await UrlbarSearchUtils.engineForAlias("@google"); + Assert.ok(!matchedEngine); + engine.hidden = false; + await TestUtils.waitForCondition(() => + UrlbarSearchUtils.engineForAlias("@google") + ); + engine = await UrlbarSearchUtils.engineForAlias("@google"); + Assert.ok(engine); +}); + +add_task(async function test_serps_are_equivalent() { + info("Subset URL has extraneous parameters."); + let url1 = "https://example.com/search?q=test&type=images"; + let url2 = "https://example.com/search?q=test"; + Assert.ok(!UrlbarSearchUtils.serpsAreEquivalent(url1, url2)); + info("Superset URL has extraneous parameters."); + Assert.ok(UrlbarSearchUtils.serpsAreEquivalent(url2, url1)); + + info("Same keys, different values."); + url1 = "https://example.com/search?q=test&type=images"; + url2 = "https://example.com/search?q=test123&type=maps"; + Assert.ok(!UrlbarSearchUtils.serpsAreEquivalent(url1, url2)); + Assert.ok(!UrlbarSearchUtils.serpsAreEquivalent(url2, url1)); + + info("Subset matching isn't strict (URL is subset of itself)."); + Assert.ok(UrlbarSearchUtils.serpsAreEquivalent(url1, url1)); + + info("Origin and pathname are ignored."); + url1 = "https://example.com/search?q=test"; + url2 = "https://example-1.com/maps?q=test"; + Assert.ok(UrlbarSearchUtils.serpsAreEquivalent(url1, url2)); + Assert.ok(UrlbarSearchUtils.serpsAreEquivalent(url2, url1)); + + info("Params can be optionally ignored"); + url1 = "https://example.com/search?q=test&abc=123&foo=bar"; + url2 = "https://example.com/search?q=test"; + Assert.ok(!UrlbarSearchUtils.serpsAreEquivalent(url1, url2)); + Assert.ok(UrlbarSearchUtils.serpsAreEquivalent(url1, url2, ["abc", "foo"])); +}); + +add_task(async function test_get_root_domain_from_engine() { + let extension = await SearchTestUtils.installSearchExtension( + { + name: "TestEngine2", + search_url: "https://example.com/", + }, + { skipUnload: true } + ); + let engine = Services.search.getEngineByName("TestEngine2"); + Assert.equal(UrlbarSearchUtils.getRootDomainFromEngine(engine), "example"); + await extension.unload(); + + extension = await SearchTestUtils.installSearchExtension( + { + name: "TestEngine", + search_url: "https://www.subdomain.othersubdomain.example.com", + }, + { skipUnload: true } + ); + engine = Services.search.getEngineByName("TestEngine"); + Assert.equal(UrlbarSearchUtils.getRootDomainFromEngine(engine), "example"); + await extension.unload(); + + // We let engines with URL ending in .test through even though its not a valid + // TLD. + extension = await SearchTestUtils.installSearchExtension( + { + name: "TestMalformed", + search_url: "https://mochi.test/", + search_url_get_params: "search={searchTerms}", + }, + { skipUnload: true } + ); + engine = Services.search.getEngineByName("TestMalformed"); + Assert.equal(UrlbarSearchUtils.getRootDomainFromEngine(engine), "mochi"); + await extension.unload(); + + // We return the domain for engines with a malformed URL. + extension = await SearchTestUtils.installSearchExtension( + { + name: "TestMalformed", + search_url: "https://subdomain.foobar/", + search_url_get_params: "search={searchTerms}", + }, + { skipUnload: true } + ); + engine = Services.search.getEngineByName("TestMalformed"); + Assert.equal( + UrlbarSearchUtils.getRootDomainFromEngine(engine), + "subdomain.foobar" + ); + await extension.unload(); +}); + +// Tests getSearchTermIfDefaultSerpUri() by using a variety of +// input strings and nsIURI's. +// Should not throw an error if the consumer passes an input +// that when accessed, could cause an error. +add_task(async function get_search_term_if_default_serp_uri() { + let testCases = [ + { + url: null, + skipUriTest: true, + }, + { + url: "", + skipUriTest: true, + }, + { + url: "about:blank", + }, + { + url: "about:home", + }, + { + url: "about:newtab", + }, + { + url: "not://a/supported/protocol", + }, + { + url: "view-source:http://www.example.com/", + }, + { + // Not a default engine. + url: "http://mochi.test:8888/?q=chocolate&pc=sample_code", + }, + { + // Not the correct protocol. + url: "http://example.com/?q=chocolate&pc=sample_code", + }, + { + // Not the same query param values. + url: "https://example.com/?q=chocolate&pc=sample_code2", + }, + { + // Not the same query param values. + url: "https://example.com/?q=chocolate&pc=sample_code&pc2=sample_code_2", + }, + { + url: "https://example.com/?q=chocolate&pc=sample_code", + expectedString: "chocolate", + }, + { + url: "https://example.com/?q=chocolate+cakes&pc=sample_code", + expectedString: "chocolate cakes", + }, + ]; + + // Create a specific engine so that the tests are matched + // exactly against the query params used. + let extension = await SearchTestUtils.installSearchExtension( + { + name: "TestEngine", + search_url: "https://example.com/", + search_url_get_params: "?q={searchTerms}&pc=sample_code", + }, + { skipUnload: true } + ); + let engine = Services.search.getEngineByName("TestEngine"); + let originalDefaultEngine = Services.search.defaultEngine; + Services.search.defaultEngine = engine; + + for (let testCase of testCases) { + let expectedString = testCase.expectedString ?? ""; + Assert.equal( + UrlbarSearchUtils.getSearchTermIfDefaultSerpUri(testCase.url), + expectedString, + `Should return ${ + expectedString == "" ? "an empty string" : "a matching search string" + }` + ); + // Convert the string into a nsIURI and then + // try the test case with it. + if (!testCase.skipUriTest) { + Assert.equal( + UrlbarSearchUtils.getSearchTermIfDefaultSerpUri( + Services.io.newURI(testCase.url) + ), + expectedString, + `Should return ${ + expectedString == "" ? "an empty string" : "a matching search string" + }` + ); + } + } + + Services.search.defaultEngine = originalDefaultEngine; + await extension.unload(); +}); + +add_task(async function matchAllDomainLevels() { + let baseHostname = "matchalldomainlevels"; + Assert.equal( + (await UrlbarSearchUtils.enginesForDomainPrefix(baseHostname)).length, + 0, + `Sanity check: No engines initially match ${baseHostname}` + ); + + // Install engines with the following domains. When we match engines below, + // perfectly matching domains should come before partially matching domains. + let baseDomain = `${baseHostname}.com`; + let perfectDomains = [baseDomain, `www.${baseDomain}`]; + let partialDomains = [`foo.${baseDomain}`, `foo.bar.${baseDomain}`]; + + // Install engines with partially matching domains first so that the test + // isn't incidentally passing because engines are installed in the order it + // ultimately expects them in. Wait for each engine to finish installing + // before starting the next one to avoid intermittent out-of-order failures. + let extensions = []; + for (let list of [partialDomains, perfectDomains]) { + for (let domain of list) { + let ext = await SearchTestUtils.installSearchExtension( + { + name: domain, + search_url: `https://${domain}/`, + }, + { skipUnload: true } + ); + extensions.push(ext); + } + } + + // Perfect matches come before partial matches. + let expectedDomains = [...perfectDomains, ...partialDomains]; + + // Do searches for the following strings. Each should match all the engines + // installed above. + let searchStrings = [baseHostname, baseHostname + "."]; + for (let searchString of searchStrings) { + info(`Searching for "${searchString}"`); + let engines = await UrlbarSearchUtils.enginesForDomainPrefix(searchString, { + matchAllDomainLevels: true, + }); + let engineData = engines.map(e => ({ + name: e.name, + searchForm: e.searchForm, + })); + info("Matching engines: " + JSON.stringify(engineData)); + + Assert.equal( + engines.length, + expectedDomains.length, + "Expected number of matching engines" + ); + Assert.deepEqual( + engineData.map(d => d.name), + expectedDomains, + "Expected matching engine names/domains in the expected order" + ); + } + + await Promise.all(extensions.map(e => e.unload())); +}); + +function promiseSearchTopic(expectedVerb) { + return new Promise(resolve => { + Services.obs.addObserver(function observe(subject, topic, verb) { + info("browser-search-engine-modified: " + verb); + if (verb == expectedVerb) { + Services.obs.removeObserver(observe, "browser-search-engine-modified"); + resolve(); + } + }, "browser-search-engine-modified"); + }); +} diff --git a/browser/components/urlbar/tests/unit/test_UrlbarUtils_addToUrlbarHistory.js b/browser/components/urlbar/tests/unit/test_UrlbarUtils_addToUrlbarHistory.js new file mode 100644 index 0000000000..dc668e69ea --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarUtils_addToUrlbarHistory.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests unit test the functionality of the functions in UrlbarUtils. + * Some functions are bigger, and split out into sepearate test_UrlbarUtils_* files. + */ + +"use strict"; + +const { PrivateBrowsingUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PrivateBrowsingUtils.sys.mjs" +); +const { PlacesUIUtils } = ChromeUtils.importESModule( + "resource:///modules/PlacesUIUtils.sys.mjs" +); + +let sandbox; + +add_setup(function () { + sandbox = sinon.createSandbox(); +}); + +add_task(function test_addToUrlbarHistory() { + sandbox.stub(PlacesUIUtils, "markPageAsTyped"); + sandbox.stub(PrivateBrowsingUtils, "isWindowPrivate").returns(false); + + UrlbarUtils.addToUrlbarHistory("http://example.com"); + Assert.ok( + PlacesUIUtils.markPageAsTyped.calledOnce, + "Should have marked a simple URL as typed." + ); + PlacesUIUtils.markPageAsTyped.resetHistory(); + + UrlbarUtils.addToUrlbarHistory(); + Assert.ok( + PlacesUIUtils.markPageAsTyped.notCalled, + "Should not have attempted to mark a null URL as typed." + ); + PlacesUIUtils.markPageAsTyped.resetHistory(); + + UrlbarUtils.addToUrlbarHistory("http://exam ple.com"); + Assert.ok( + PlacesUIUtils.markPageAsTyped.notCalled, + "Should not have marked a URL containing a space as typed." + ); + PlacesUIUtils.markPageAsTyped.resetHistory(); + + UrlbarUtils.addToUrlbarHistory("http://exam\x01ple.com"); + Assert.ok( + PlacesUIUtils.markPageAsTyped.notCalled, + "Should not have marked a URL containing a control character as typed." + ); + PlacesUIUtils.markPageAsTyped.resetHistory(); + + PrivateBrowsingUtils.isWindowPrivate.returns(true); + UrlbarUtils.addToUrlbarHistory("http://example.com"); + Assert.ok( + PlacesUIUtils.markPageAsTyped.notCalled, + "Should not have marked a URL provided by a private browsing page as typed." + ); + PlacesUIUtils.markPageAsTyped.resetHistory(); +}); diff --git a/browser/components/urlbar/tests/unit/test_UrlbarUtils_copySnakeKeysToCamel.js b/browser/components/urlbar/tests/unit/test_UrlbarUtils_copySnakeKeysToCamel.js new file mode 100644 index 0000000000..4b5352bc2a --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarUtils_copySnakeKeysToCamel.js @@ -0,0 +1,226 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests `UrlbarUtils.copySnakeKeysToCamel()`. + +"use strict"; + +add_task(async function noSnakes() { + Assert.deepEqual( + UrlbarUtils.copySnakeKeysToCamel({ + foo: "foo key", + bar: "bar key", + }), + { + foo: "foo key", + bar: "bar key", + } + ); +}); + +add_task(async function oneSnake() { + Assert.deepEqual( + UrlbarUtils.copySnakeKeysToCamel({ + foo: "foo key", + snake_key: "snake key", + bar: "bar key", + }), + { + foo: "foo key", + snake_key: "snake key", + bar: "bar key", + snakeKey: "snake key", + } + ); +}); + +add_task(async function manySnakeKeys() { + Assert.deepEqual( + UrlbarUtils.copySnakeKeysToCamel({ + foo: "foo key", + snake_one: "snake key 1", + bar: "bar key", + and_snake_two_also: "snake key 2", + snake_key_3: "snake key 3", + snake_key_4_too: "snake key 4", + }), + { + foo: "foo key", + snake_one: "snake key 1", + bar: "bar key", + and_snake_two_also: "snake key 2", + snake_key_3: "snake key 3", + snake_key_4_too: "snake key 4", + snakeOne: "snake key 1", + andSnakeTwoAlso: "snake key 2", + snakeKey3: "snake key 3", + snakeKey4Too: "snake key 4", + } + ); +}); + +add_task(async function singleChars() { + Assert.deepEqual( + UrlbarUtils.copySnakeKeysToCamel({ + a: "a key", + b_c: "b_c key", + d_e_f: "d_e_f key", + g_h_i_j: "g_h_i_j key", + }), + { + a: "a key", + b_c: "b_c key", + d_e_f: "d_e_f key", + g_h_i_j: "g_h_i_j key", + bC: "b_c key", + dEF: "d_e_f key", + gHIJ: "g_h_i_j key", + } + ); +}); + +add_task(async function numbers() { + Assert.deepEqual( + UrlbarUtils.copySnakeKeysToCamel({ + snake_1: "snake 1 key", + snake_2_too: "snake 2 key", + "3_snakes": "snake 3 key", + }), + { + snake_1: "snake 1 key", + snake_2_too: "snake 2 key", + "3_snakes": "snake 3 key", + snake1: "snake 1 key", + snake2Too: "snake 2 key", + "3Snakes": "snake 3 key", + } + ); +}); + +add_task(async function leadingUnderscores() { + Assert.deepEqual( + UrlbarUtils.copySnakeKeysToCamel({ + _foo: "foo key", + __bar: "bar key", + _snake_with_leading: "snake key 1", + __snake_with_two_leading: "snake key 2", + }), + { + _foo: "foo key", + __bar: "bar key", + _snake_with_leading: "snake key 1", + __snake_with_two_leading: "snake key 2", + _snakeWithLeading: "snake key 1", + __snakeWithTwoLeading: "snake key 2", + } + ); +}); + +add_task(async function trailingUnderscores() { + Assert.deepEqual( + UrlbarUtils.copySnakeKeysToCamel({ + foo_: "foo key", + bar__: "bar key", + snake_with_trailing_: "snake key 1", + snake_with_two_trailing__: "snake key 2", + }), + { + foo_: "foo key", + bar__: "bar key", + snake_with_trailing_: "snake key 1", + snake_with_two_trailing__: "snake key 2", + snakeWithTrailing_: "snake key 1", + snakeWithTwoTrailing__: "snake key 2", + } + ); +}); + +add_task(async function leadingAndTrailingUnderscores() { + Assert.deepEqual( + UrlbarUtils.copySnakeKeysToCamel({ + _foo_: "foo key", + _extra_long_snake_: "snake key", + }), + { + _foo_: "foo key", + _extra_long_snake_: "snake key", + _extraLongSnake_: "snake key", + } + ); +}); + +add_task(async function consecutiveUnderscores() { + Assert.deepEqual( + UrlbarUtils.copySnakeKeysToCamel({ weird__snake: "snake key" }), + { + weird__snake: "snake key", + weird_Snake: "snake key", + } + ); +}); + +add_task(async function nested() { + let obj = UrlbarUtils.copySnakeKeysToCamel({ + foo: "foo key", + nested: { + bar: "bar key", + baz: { + snake_in_baz: "snake_in_baz key", + }, + snake_in_nested: { + snake_in_snake_in_nested: "snake_in_snake_in_nested key", + }, + }, + snake_key: { + snake_in_snake_key: "snake_in_snake_key key", + }, + }); + + Assert.equal(obj.foo, "foo key"); + Assert.equal(obj.nested.bar, "bar key"); + Assert.deepEqual(obj.nested.baz, { + snake_in_baz: "snake_in_baz key", + snakeInBaz: "snake_in_baz key", + }); + Assert.deepEqual(obj.nested.snake_in_nested, { + snake_in_snake_in_nested: "snake_in_snake_in_nested key", + snakeInSnakeInNested: "snake_in_snake_in_nested key", + }); + Assert.equal(obj.nested.snake_in_nested, obj.nested.snakeInNested); + Assert.deepEqual(obj.snake_key, { + snake_in_snake_key: "snake_in_snake_key key", + snakeInSnakeKey: "snake_in_snake_key key", + }); + Assert.equal(obj.snake_key, obj.snakeKey); +}); + +add_task(async function noOverwrite_ok() { + Assert.deepEqual( + UrlbarUtils.copySnakeKeysToCamel( + { + foo: "foo key", + snake_key: "snake key", + }, + false + ), + { + foo: "foo key", + snake_key: "snake key", + snakeKey: "snake key", + } + ); +}); + +add_task(async function noOverwrite_throws() { + Assert.throws( + () => + UrlbarUtils.copySnakeKeysToCamel( + { + snake_key: "snake key", + snakeKey: "snake key", + }, + false + ), + /Can't copy snake_case key/ + ); +}); diff --git a/browser/components/urlbar/tests/unit/test_UrlbarUtils_getShortcutOrURIAndPostData.js b/browser/components/urlbar/tests/unit/test_UrlbarUtils_getShortcutOrURIAndPostData.js new file mode 100644 index 0000000000..034005b0fa --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarUtils_getShortcutOrURIAndPostData.js @@ -0,0 +1,249 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests unit test the functionality of UrlbarController by stubbing out the + * model and providing stubs to be called. + */ + +"use strict"; + +function getPostDataString(aIS) { + if (!aIS) { + return null; + } + + let sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( + Ci.nsIScriptableInputStream + ); + sis.init(aIS); + let dataLines = sis.read(aIS.available()).split("\n"); + + // only want the last line + return dataLines[dataLines.length - 1]; +} + +function keywordResult(aURL, aPostData, aIsUnsafe) { + this.url = aURL; + this.postData = aPostData; + this.isUnsafe = aIsUnsafe; +} + +function keyWordData() {} +keyWordData.prototype = { + init(aKeyWord, aURL, aPostData, aSearchWord) { + this.keyword = aKeyWord; + this.uri = Services.io.newURI(aURL); + this.postData = aPostData; + this.searchWord = aSearchWord; + + this.method = this.postData ? "POST" : "GET"; + }, +}; + +function bmKeywordData(aKeyWord, aURL, aPostData, aSearchWord) { + this.init(aKeyWord, aURL, aPostData, aSearchWord); +} +bmKeywordData.prototype = new keyWordData(); + +function searchKeywordData(aKeyWord, aURL, aPostData, aSearchWord) { + this.init(aKeyWord, aURL, aPostData, aSearchWord); +} +searchKeywordData.prototype = new keyWordData(); + +var testData = [ + [ + new bmKeywordData("bmget", "https://bmget/search=%s", null, "foo"), + new keywordResult("https://bmget/search=foo", null), + ], + + [ + new bmKeywordData("bmpost", "https://bmpost/", "search=%s", "foo2"), + new keywordResult("https://bmpost/", "search=foo2"), + ], + + [ + new bmKeywordData( + "bmpostget", + "https://bmpostget/search1=%s", + "search2=%s", + "foo3" + ), + new keywordResult("https://bmpostget/search1=foo3", "search2=foo3"), + ], + + [ + new bmKeywordData("bmget-nosearch", "https://bmget-nosearch/", null, ""), + new keywordResult("https://bmget-nosearch/", null), + ], + + [ + new searchKeywordData( + "searchget", + "https://searchget/?search={searchTerms}", + null, + "foo4" + ), + new keywordResult("https://searchget/?search=foo4", null, true), + ], + + [ + new searchKeywordData( + "searchpost", + "https://searchpost/", + "search={searchTerms}", + "foo5" + ), + new keywordResult("https://searchpost/", "search=foo5", true), + ], + + [ + new searchKeywordData( + "searchpostget", + "https://searchpostget/?search1={searchTerms}", + "search2={searchTerms}", + "foo6" + ), + new keywordResult( + "https://searchpostget/?search1=foo6", + "search2=foo6", + true + ), + ], + + // Bookmark keywords that don't take parameters should not be activated if a + // parameter is passed (bug 420328). + [ + new bmKeywordData("bmget-noparam", "https://bmget-noparam/", null, "foo7"), + new keywordResult(null, null, true), + ], + [ + new bmKeywordData( + "bmpost-noparam", + "https://bmpost-noparam/", + "not_a=param", + "foo8" + ), + new keywordResult(null, null, true), + ], + + // Test escaping (%s = escaped, %S = raw) + // UTF-8 default + [ + new bmKeywordData( + "bmget-escaping", + "https://bmget/?esc=%s&raw=%S", + null, + "fo\xE9" + ), + new keywordResult("https://bmget/?esc=fo%C3%A9&raw=fo\xE9", null), + ], + // Explicitly-defined ISO-8859-1 + [ + new bmKeywordData( + "bmget-escaping2", + "https://bmget/?esc=%s&raw=%S&mozcharset=ISO-8859-1", + null, + "fo\xE9" + ), + new keywordResult("https://bmget/?esc=fo%E9&raw=fo\xE9", null), + ], + + // Bug 359809: Test escaping +, /, and @ + // UTF-8 default + [ + new bmKeywordData( + "bmget-escaping", + "https://bmget/?esc=%s&raw=%S", + null, + "+/@" + ), + new keywordResult("https://bmget/?esc=%2B%2F%40&raw=+/@", null), + ], + // Explicitly-defined ISO-8859-1 + [ + new bmKeywordData( + "bmget-escaping2", + "https://bmget/?esc=%s&raw=%S&mozcharset=ISO-8859-1", + null, + "+/@" + ), + new keywordResult("https://bmget/?esc=%2B%2F%40&raw=+/@", null), + ], + + // Test using a non-bmKeywordData object, to test the behavior of + // getShortcutOrURIAndPostData for non-keywords (setupKeywords only adds keywords for + // bmKeywordData objects) + [{ keyword: "https://gavinsharp.com" }, new keywordResult(null, null, true)], +]; + +add_task(async function test_getshortcutoruri() { + await setupKeywords(); + + for (let item of testData) { + let [data, result] = item; + + let query = data.keyword; + if (data.searchWord) { + query += " " + data.searchWord; + } + let returnedData = await UrlbarUtils.getShortcutOrURIAndPostData(query); + // null result.url means we should expect the same query we sent in + let expected = result.url || query; + Assert.equal( + returnedData.url, + expected, + "got correct URL for " + data.keyword + ); + Assert.equal( + getPostDataString(returnedData.postData), + result.postData, + "got correct postData for " + data.keyword + ); + Assert.equal( + returnedData.mayInheritPrincipal, + !result.isUnsafe, + "got correct mayInheritPrincipal for " + data.keyword + ); + } + + await cleanupKeywords(); +}); + +var folder = null; + +async function setupKeywords() { + folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "keyword-test", + }); + for (let item of testData) { + let data = item[0]; + if (data instanceof bmKeywordData) { + await PlacesUtils.bookmarks.insert({ + url: data.uri, + parentGuid: folder.guid, + }); + await PlacesUtils.keywords.insert({ + keyword: data.keyword, + url: data.uri.spec, + postData: data.postData, + }); + } + + if (data instanceof searchKeywordData) { + await SearchTestUtils.installSearchExtension({ + name: data.keyword, + keyword: data.keyword, + search_url: data.uri.spec, + search_url_get_params: "", + search_url_post_params: data.postData, + }); + } + } +} + +async function cleanupKeywords() { + await PlacesUtils.bookmarks.remove(folder); +} diff --git a/browser/components/urlbar/tests/unit/test_UrlbarUtils_getTokenMatches.js b/browser/components/urlbar/tests/unit/test_UrlbarUtils_getTokenMatches.js new file mode 100644 index 0000000000..bae6ffc879 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarUtils_getTokenMatches.js @@ -0,0 +1,294 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests UrlbarUtils.getTokenMatches. + */ + +"use strict"; + +add_task(function test() { + const tests = [ + { + tokens: ["mozilla", "is", "i"], + phrase: "mozilla is for the Open Web", + expected: [ + [0, 7], + [8, 2], + ], + }, + { + tokens: ["mozilla", "is", "i"], + phrase: "MOZILLA IS for the Open Web", + expected: [ + [0, 7], + [8, 2], + ], + }, + { + tokens: ["mozilla", "is", "i"], + phrase: "MoZiLlA Is for the Open Web", + expected: [ + [0, 7], + [8, 2], + ], + }, + { + tokens: ["MOZILLA", "IS", "I"], + phrase: "mozilla is for the Open Web", + expected: [ + [0, 7], + [8, 2], + ], + }, + { + tokens: ["MoZiLlA", "Is", "I"], + phrase: "mozilla is for the Open Web", + expected: [ + [0, 7], + [8, 2], + ], + }, + { + tokens: ["mo", "b"], + phrase: "mozilla is for the Open Web", + expected: [ + [0, 2], + [26, 1], + ], + }, + { + tokens: ["mo", "b"], + phrase: "MOZILLA is for the OPEN WEB", + expected: [ + [0, 2], + [26, 1], + ], + }, + { + tokens: ["MO", "B"], + phrase: "mozilla is for the Open Web", + expected: [ + [0, 2], + [26, 1], + ], + }, + { + tokens: ["mo", ""], + phrase: "mozilla is for the Open Web", + expected: [[0, 2]], + }, + { + tokens: ["mozilla"], + phrase: "mozilla", + expected: [[0, 7]], + }, + { + tokens: ["mozilla"], + phrase: "MOZILLA", + expected: [[0, 7]], + }, + { + tokens: ["mozilla"], + phrase: "MoZiLlA", + expected: [[0, 7]], + }, + { + tokens: ["mozilla"], + phrase: "mOzIlLa", + expected: [[0, 7]], + }, + { + tokens: ["MOZILLA"], + phrase: "mozilla", + expected: [[0, 7]], + }, + { + tokens: ["MoZiLlA"], + phrase: "mozilla", + expected: [[0, 7]], + }, + { + tokens: ["mOzIlLa"], + phrase: "mozilla", + expected: [[0, 7]], + }, + { + tokens: ["\u9996"], + phrase: "Test \u9996\u9875 Test", + expected: [[5, 1]], + }, + { + tokens: ["mo", "zilla"], + phrase: "mozilla", + expected: [[0, 7]], + }, + { + tokens: ["mo", "zilla"], + phrase: "MOZILLA", + expected: [[0, 7]], + }, + { + tokens: ["mo", "zilla"], + phrase: "MoZiLlA", + expected: [[0, 7]], + }, + { + tokens: ["mo", "zilla"], + phrase: "mOzIlLa", + expected: [[0, 7]], + }, + { + tokens: ["MO", "ZILLA"], + phrase: "mozilla", + expected: [[0, 7]], + }, + { + tokens: ["Mo", "Zilla"], + phrase: "mozilla", + expected: [[0, 7]], + }, + { + tokens: ["moz", "zilla"], + phrase: "mozilla", + expected: [[0, 7]], + }, + { + tokens: [""], // Should never happen in practice. + phrase: "mozilla", + expected: [], + }, + { + tokens: ["mo", "om"], + phrase: "mozilla mozzarella momo", + expected: [ + [0, 2], + [8, 2], + [19, 4], + ], + }, + { + tokens: ["mo", "om"], + phrase: "MOZILLA MOZZARELLA MOMO", + expected: [ + [0, 2], + [8, 2], + [19, 4], + ], + }, + { + tokens: ["MO", "OM"], + phrase: "mozilla mozzarella momo", + expected: [ + [0, 2], + [8, 2], + [19, 4], + ], + }, + { + tokens: ["resume"], + phrase: "résumé", + expected: [[0, 6]], + }, + { + // This test should succeed even in a Spanish locale where N and Ñ are + // considered distinct letters. + tokens: ["jalapeno"], + phrase: "jalapeño", + expected: [[0, 8]], + }, + ]; + for (let { tokens, phrase, expected } of tests) { + tokens = tokens.map(t => ({ + value: t, + lowerCaseValue: t.toLocaleLowerCase(), + })); + Assert.deepEqual( + UrlbarUtils.getTokenMatches(tokens, phrase, UrlbarUtils.HIGHLIGHT.TYPED), + expected, + `Match "${tokens.map(t => t.value).join(", ")}" on "${phrase}"` + ); + } +}); + +/** + * Tests suggestion highlighting. Note that suggestions are only highlighted if + * the matching token is at the beginning of a word in the matched string. + */ +add_task(function testSuggestions() { + const tests = [ + { + tokens: ["mozilla", "is", "i"], + phrase: "mozilla is for the Open Web", + expected: [ + [7, 1], + [10, 17], + ], + }, + { + tokens: ["\u9996"], + phrase: "Test \u9996\u9875 Test", + expected: [ + [0, 5], + [6, 6], + ], + }, + { + tokens: ["mo", "zilla"], + phrase: "mOzIlLa", + expected: [[2, 5]], + }, + { + tokens: ["MO", "ZILLA"], + phrase: "mozilla", + expected: [[2, 5]], + }, + { + tokens: [""], // Should never happen in practice. + phrase: "mozilla", + expected: [[0, 7]], + }, + { + tokens: ["mo", "om", "la"], + phrase: "mozilla mozzarella momo", + expected: [ + [2, 6], + [10, 9], + [21, 2], + ], + }, + { + tokens: ["mo", "om", "la"], + phrase: "MOZILLA MOZZARELLA MOMO", + expected: [ + [2, 6], + [10, 9], + [21, 2], + ], + }, + { + tokens: ["MO", "OM", "LA"], + phrase: "mozilla mozzarella momo", + expected: [ + [2, 6], + [10, 9], + [21, 2], + ], + }, + ]; + for (let { tokens, phrase, expected } of tests) { + tokens = tokens.map(t => ({ + value: t, + lowerCaseValue: t.toLocaleLowerCase(), + })); + Assert.deepEqual( + UrlbarUtils.getTokenMatches( + tokens, + phrase, + UrlbarUtils.HIGHLIGHT.SUGGESTED + ), + expected, + `Match "${tokens.map(t => t.value).join(", ")}" on "${phrase}"` + ); + } +}); diff --git a/browser/components/urlbar/tests/unit/test_UrlbarUtils_skippableTimer.js b/browser/components/urlbar/tests/unit/test_UrlbarUtils_skippableTimer.js new file mode 100644 index 0000000000..7400d507af --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarUtils_skippableTimer.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests UrlbarUtils.SkippableTimer + */ + +"use strict"; + +let { SkippableTimer } = ChromeUtils.importESModule( + "resource:///modules/UrlbarUtils.sys.mjs" +); +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +add_task(async function test_basic() { + let invoked = 0; + let deferred = Promise.withResolvers(); + let timer = new SkippableTimer({ + name: "test 1", + callback: () => { + invoked++; + deferred.resolve(); + }, + time: 50, + }); + Assert.equal(timer.name, "test 1", "Timer should have the correct name"); + Assert.ok(!timer.done, "Should not be done"); + Assert.equal(invoked, 0, "Should not have invoked the callback yet"); + await deferred.promise; + Assert.ok(timer.done, "Should be done"); + Assert.equal(invoked, 1, "Should have invoked the callback"); +}); + +add_task(async function test_fire() { + let longTimeMs = 1000; + let invoked = 0; + let deferred = Promise.withResolvers(); + let timer = new SkippableTimer({ + name: "test 1", + callback: () => { + invoked++; + deferred.resolve(); + }, + time: longTimeMs, + }); + let start = Cu.now(); + Assert.equal(timer.name, "test 1", "Timer should have the correct name"); + Assert.ok(!timer.done, "Should not be done"); + Assert.equal(invoked, 0, "Should not have invoked the callback yet"); + // Call fire() many times to also verify the callback is invoked just once. + timer.fire(); + timer.fire(); + timer.fire(); + Assert.ok(timer.done, "Should be done"); + await deferred.promise; + Assert.greater(longTimeMs, Cu.now() - start, "Should have resolved earlier"); + Assert.equal(invoked, 1, "Should have invoked the callback"); +}); + +add_task(async function test_cancel() { + let timeMs = 50; + let invoked = 0; + let deferred = Promise.withResolvers(); + let timer = new SkippableTimer({ + name: "test 1", + callback: () => { + invoked++; + deferred.resolve(); + }, + time: timeMs, + }); + let start = Cu.now(); + Assert.equal(timer.name, "test 1", "Timer should have the correct name"); + Assert.ok(!timer.done, "Should not be done"); + Assert.equal(invoked, 0, "Should not have invoked the callback yet"); + // Calling cancel many times shouldn't rise any error. + timer.cancel(); + timer.cancel(); + Assert.ok(timer.done, "Should be done"); + await Promise.race([ + deferred.promise, + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + new Promise(r => setTimeout(r, timeMs * 4)), + ]); + Assert.greater(Cu.now() - start, timeMs, "Should not have resolved earlier"); + Assert.equal(invoked, 0, "Should not have invoked the callback"); +}); diff --git a/browser/components/urlbar/tests/unit/test_UrlbarUtils_unEscapeURIForUI.js b/browser/components/urlbar/tests/unit/test_UrlbarUtils_unEscapeURIForUI.js new file mode 100644 index 0000000000..6efc6711c6 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarUtils_unEscapeURIForUI.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test for unEscapeURIForUI function in UrlbarUtils. + */ + +"use strict"; + +const TEST_DATA = [ + { + description: "Test for characters including percent encoded chars", + input: "A%E3%81%82%F0%A0%AE%B7%21", + expected: "Aあ𠮷!", + testMessage: "Unescape given characters correctly", + }, + { + description: "Test for characters over the limit", + input: "A%E3%81%82%F0%A0%AE%B7%21".repeat( + Math.ceil(UrlbarUtils.MAX_TEXT_LENGTH / 25) + ), + expected: "A%E3%81%82%F0%A0%AE%B7%21".repeat( + Math.ceil(UrlbarUtils.MAX_TEXT_LENGTH / 25) + ), + testMessage: "Return given characters as it is because of over the limit", + }, +]; + +add_task(function () { + for (const { description, input, expected, testMessage } of TEST_DATA) { + info(description); + + const result = UrlbarUtils.unEscapeURIForUI(input); + Assert.equal(result, expected, testMessage); + } +}); diff --git a/browser/components/urlbar/tests/unit/test_about_urls.js b/browser/components/urlbar/tests/unit/test_about_urls.js new file mode 100644 index 0000000000..277ddb8ee1 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_about_urls.js @@ -0,0 +1,176 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { AboutPagesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/AboutPagesUtils.sys.mjs" +); + +testEngine_setup(); + +// "about:ab" should match "about:about" +add_task(async function aboutAb() { + let context = createContext("about:ab", { isPrivate: false }); + await check_results({ + context, + autofilled: "about:about", + completed: "about:about", + matches: [ + makeVisitResult(context, { + uri: "about:about", + title: "about:about", + heuristic: true, + }), + ], + }); +}); + +// "about:Ab" should match "about:about" +add_task(async function aboutAb() { + let context = createContext("about:Ab", { isPrivate: false }); + await check_results({ + context, + autofilled: "about:About", + completed: "about:about", + matches: [ + makeVisitResult(context, { + uri: "about:about", + title: "about:about", + heuristic: true, + }), + ], + }); +}); + +// "about:about" should match "about:about" +add_task(async function aboutAbout() { + let context = createContext("about:about", { isPrivate: false }); + await check_results({ + context, + autofilled: "about:about", + completed: "about:about", + matches: [ + makeVisitResult(context, { + uri: "about:about", + title: "about:about", + heuristic: true, + }), + ], + }); +}); + +// "about:a" should complete to "about:about" and also match "about:addons" +add_task(async function aboutAboutAndAboutAddons() { + let context = createContext("about:a", { isPrivate: false }); + await check_results({ + context, + search: "about:a", + autofilled: "about:about", + completed: "about:about", + matches: [ + makeVisitResult(context, { + uri: "about:about", + title: "about:about", + heuristic: true, + }), + makeVisitResult(context, { + uri: "about:addons", + title: "about:addons", + tags: null, + providerName: "AboutPages", + }), + ], + }); +}); + +// "about:" by itself matches a list of about: pages and nothing else +add_task(async function aboutColonMatchesOnlyAboutPages() { + // We generate 9 about: page results because there are 10 results total, + // and the first result is the heuristic result. + function getFirst9AboutPages() { + const aboutPageNames = AboutPagesUtils.visibleAboutUrls.slice(0, 9); + const aboutPageResults = aboutPageNames.map(aboutPageName => { + return makeVisitResult(context, { + uri: aboutPageName, + title: aboutPageName, + tags: null, + providerName: "AboutPages", + }); + }); + return aboutPageResults; + } + + let context = createContext("about:", { isPrivate: false }); + await check_results({ + context, + search: "about:", + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + providerName: "HeuristicFallback", + heuristic: true, + }), + ...getFirst9AboutPages(), + ], + }); +}); + +// Results for about: pages do not match webpage titles from the user's history +add_task(async function aboutResultsDoNotMatchTitlesInHistory() { + await PlacesTestUtils.addVisits([ + { + uri: Services.io.newURI("http://example.com/guide/config/"), + title: "Guide to config in Firefox", + }, + ]); + + let context = createContext("about:config", { isPrivate: false }); + await check_results({ + context, + search: "about:config", + matches: [ + makeVisitResult(context, { + uri: "about:config", + title: "about:config", + heuristic: true, + providerName: "Autofill", + }), + ], + }); +}); + +// Tests that about: pages are shown after general results. +add_task(async function after_general() { + await PlacesTestUtils.addVisits([ + { + uri: Services.io.newURI("http://example.com/guide/aboutaddons/"), + title: "Guide to about:addons in Firefox", + }, + ]); + + let context = createContext("about:a", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: "about:about", + title: "about:about", + heuristic: true, + providerName: "Autofill", + }), + makeVisitResult(context, { + uri: "http://example.com/guide/aboutaddons/", + title: "Guide to about:addons in Firefox", + }), + makeVisitResult(context, { + uri: "about:addons", + title: "about:addons", + tags: null, + providerName: "AboutPages", + }), + ], + }); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_autofill_adaptiveHistory.js b/browser/components/urlbar/tests/unit/test_autofill_adaptiveHistory.js new file mode 100644 index 0000000000..5b0c496aa9 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_adaptiveHistory.js @@ -0,0 +1,1443 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Test for adaptive history autofill. + +testEngine_setup(); + +registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); +}); +Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + +const TEST_DATA = [ + { + description: "Basic behavior for adaptive history autofill", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exa", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "URL that has www", + pref: true, + visitHistory: ["http://www.example.com/test"], + inputHistory: [{ uri: "http://www.example.com/test", input: "exa" }], + userInput: "exa", + expected: { + autofilled: "example.com/test", + completed: "http://www.example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://www.example.com/test", + title: "test visit for http://www.example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "User's input starts with www", + pref: true, + visitHistory: ["http://www.example.com/test"], + inputHistory: [{ uri: "http://www.example.com/test", input: "www.exa" }], + userInput: "www.exa", + expected: { + autofilled: "www.example.com/test", + completed: "http://www.example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://www.example.com/test", + title: "test visit for http://www.example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "Case differences for user's input are ignored", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "EXA" }], + userInput: "eXA", + expected: { + autofilled: "eXAmple.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: + "Case differences for user's input that starts with www are ignored", + pref: true, + visitHistory: ["http://www.example.com/test"], + inputHistory: [{ uri: "http://www.example.com/test", input: "www.exa" }], + userInput: "WWW.exa", + expected: { + autofilled: "WWW.example.com/test", + completed: "http://www.example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://www.example.com/test", + title: "test visit for http://www.example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "Mutiple case difference input history", + pref: true, + visitHistory: ["http://example.com/yes", "http://example.com/no"], + inputHistory: [ + { uri: "http://example.com/yes", input: "exa" }, + { uri: "http://example.com/yes", input: "EXA" }, + { uri: "http://example.com/yes", input: "EXa" }, + { uri: "http://example.com/yes", input: "eXa" }, + { uri: "http://example.com/yes", input: "eXA" }, + { uri: "http://example.com/no", input: "exa" }, + { uri: "http://example.com/no", input: "exa" }, + { uri: "http://example.com/no", input: "exa" }, + ], + userInput: "exa", + expected: { + autofilled: "example.com/yes", + completed: "http://example.com/yes", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/yes", + title: "test visit for http://example.com/yes", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/no", + title: "test visit for http://example.com/no", + }), + ], + }, + }, + { + description: "Multiple input history count", + pref: true, + visitHistory: ["http://example.com/few", "http://example.com/many"], + inputHistory: [ + { uri: "http://example.com/many", input: "exa" }, + { uri: "http://example.com/few", input: "exa" }, + { uri: "http://example.com/many", input: "examp" }, + ], + userInput: "exa", + expected: { + autofilled: "example.com/many", + completed: "http://example.com/many", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/many", + title: "test visit for http://example.com/many", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/few", + title: "test visit for http://example.com/few", + }), + ], + }, + }, + { + description: "Multiple input history count with same input", + pref: true, + visitHistory: ["http://example.com/few", "http://example.com/many"], + inputHistory: [ + { uri: "http://example.com/many", input: "exa" }, + { uri: "http://example.com/few", input: "exa" }, + { uri: "http://example.com/many", input: "exa" }, + ], + userInput: "exa", + expected: { + autofilled: "example.com/many", + completed: "http://example.com/many", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/many", + title: "test visit for http://example.com/many", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/few", + title: "test visit for http://example.com/few", + }), + ], + }, + }, + { + description: + "Multiple input history count with same input but different frecency", + pref: true, + visitHistory: [ + "http://example.com/few", + "http://example.com/many", + "http://example.com/many", + ], + inputHistory: [ + { uri: "http://example.com/many", input: "exa" }, + { uri: "http://example.com/few", input: "exa" }, + ], + userInput: "exa", + expected: { + autofilled: "example.com/many", + completed: "http://example.com/many", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/many", + title: "test visit for http://example.com/many", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/few", + title: "test visit for http://example.com/few", + }), + ], + }, + }, + { + description: "User input is shorter than the input history", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "e", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.com/"), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "User input is longer than the input history", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "example", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: + "User input starts with input history and includes path of the url", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "example.com/te", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "User input starts with input history and but another url", + pref: true, + visitHistory: ["http://example.com/test", "http://example.org/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "example.o", + expected: { + autofilled: "example.org/", + completed: "http://example.org/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.org/", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.org/"), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.org/test", + title: "test visit for http://example.org/test", + }), + ], + }, + }, + { + description: "User input does not start with input history", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "notmatch" }], + userInput: "exa", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.com/"), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: + "User input does not start with input history, but it includes as part of URL", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "test", + expected: { + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "User input does not start with visited URL", + pref: true, + visitHistory: ["http://mozilla.com/test"], + inputHistory: [{ uri: "http://mozilla.com/test", input: "exa" }], + userInput: "exa", + expected: { + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://mozilla.com/test", + title: "test visit for http://mozilla.com/test", + }), + ], + }, + }, + { + description: "Visited page is bookmarked", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + bookmarks: [{ uri: "http://example.com/test", title: "test bookmark" }], + userInput: "exa", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test bookmark", + heuristic: true, + }), + ], + }, + }, + { + description: "Visit history and no bookamrk with HISTORY source", + pref: true, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exa", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "Visit history and no bookamrk with BOOKMARK source", + pref: true, + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exa", + expected: { + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + ], + }, + }, + { + description: "Bookmarked visit history with HISTORY source", + pref: true, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + visitHistory: ["http://example.com/test", "http://example.com/bookmarked"], + bookmarks: [ + { uri: "http://example.com/bookmarked", title: "test bookmark" }, + ], + inputHistory: [ + { + uri: "http://example.com/test", + input: "exa", + }, + { + uri: "http://example.com/bookmarked", + input: "exa", + }, + ], + userInput: "exa", + expected: { + autofilled: "example.com/bookmarked", + completed: "http://example.com/bookmarked", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/bookmarked", + title: "test bookmark", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "Bookmarked visit history with BOOKMARK source", + pref: true, + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + visitHistory: ["http://example.com/test", "http://example.com/bookmarked"], + bookmarks: [ + { uri: "http://example.com/bookmarked", title: "test bookmark" }, + ], + inputHistory: [ + { + uri: "http://example.com/test", + input: "exa", + }, + { + uri: "http://example.com/bookmarked", + input: "exa", + }, + ], + userInput: "exa", + expected: { + autofilled: "example.com/bookmarked", + completed: "http://example.com/bookmarked", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/bookmarked", + title: "test bookmark", + heuristic: true, + }), + ], + }, + }, + { + description: "No visit history with HISTORY source", + pref: true, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exa", + expected: { + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + ], + }, + }, + { + description: "No visit history with BOOKMARK source", + pref: true, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + bookmarks: [{ uri: "http://example.com/bookmarked", title: "test" }], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exa", + expected: { + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + ], + }, + }, + { + description: "Match with path expression", + pref: true, + visitHistory: [ + "http://example.com/testMany", + "http://example.com/testMany", + "http://example.com/test", + ], + inputHistory: [{ uri: "http://example.com/test", input: "example.com/te" }], + userInput: "example.com/te", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/testMany", + title: "test visit for http://example.com/testMany", + }), + ], + }, + }, + { + description: + "Prefixed URL for input history and the same string for user input", + pref: true, + visitHistory: [ + "http://example.com/testMany", + "http://example.com/testMany", + "http://example.com/test", + ], + inputHistory: [ + { uri: "http://example.com/test", input: "http://example.com/test" }, + ], + userInput: "http://example.com/test", + expected: { + autofilled: "http://example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/testMany", + title: "test visit for http://example.com/testMany", + }), + ], + }, + }, + { + description: + "Prefixed URL for input history and URL expression for user input", + pref: true, + visitHistory: [ + "http://example.com/testMany", + "http://example.com/testMany", + "http://example.com/test", + ], + inputHistory: [ + { uri: "http://example.com/test", input: "http://example.com/te" }, + ], + userInput: "http://example.com/te", + expected: { + autofilled: "http://example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/testMany", + title: "test visit for http://example.com/testMany", + }), + ], + }, + }, + { + description: + "Prefixed URL for input history and path expression for user input", + pref: true, + visitHistory: [ + "http://example.com/testMany", + "http://example.com/testMany", + "http://example.com/test", + ], + inputHistory: [ + { uri: "http://example.com/test", input: "http://example.com/te" }, + ], + userInput: "example.com/te", + expected: { + autofilled: "example.com/testMany", + completed: "http://example.com/testMany", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/testMany", + title: "test visit for http://example.com/testMany", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "Prefixed URL for input history and 'http' for user input", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "http" }], + userInput: "http", + expected: { + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "Prefixed URL for input history and 'http:' for user input", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "http:" }], + userInput: "http:", + expected: { + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "Prefixed URL for input history and 'http:/' for user input", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "http:/" }], + userInput: "http:/", + expected: { + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "Prefixed URL for input history and 'http://' for user input", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "http://" }], + userInput: "http://", + expected: { + autofilled: "http://example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "Prefixed URL for input history and 'http://e' for user input", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "http://e" }], + userInput: "http://e", + expected: { + autofilled: "http://example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: + "Prefixed URL with www omitted for input history and 'http://e' for user input", + pref: true, + visitHistory: ["http://www.example.com/test"], + inputHistory: [{ uri: "http://www.example.com/test", input: "http://e" }], + userInput: "http://e", + expected: { + autofilled: "http://example.com/test", + completed: "http://www.example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://www.example.com/test", + title: "test visit for http://www.example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: + "Those that match with fixed URL take precedence over those that match prefixed URL", + pref: true, + visitHistory: ["http://http.example.com/test", "http://example.com/test"], + inputHistory: [ + { uri: "http://http.example.com/test", input: "http" }, + { uri: "http://example.com/test", input: "http://example.com/test" }, + ], + userInput: "http", + expected: { + autofilled: "http.example.com/test", + completed: "http://http.example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://http.example.com/test", + title: "test visit for http://http.example.com/test", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "Input history is totally different string from the URL", + pref: true, + visitHistory: [ + "http://example.com/testMany", + "http://example.com/testMany", + "http://example.com/test", + ], + inputHistory: [ + { uri: "http://example.com/test", input: "totally-different-string" }, + ], + userInput: "totally", + expected: { + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: + "Input history is totally different string from the URL and there is a visit history whose URL starts with the input", + pref: true, + visitHistory: ["http://example.com/test", "http://totally.example.com"], + inputHistory: [ + { uri: "http://example.com/test", input: "totally-different-string" }, + ], + userInput: "totally", + expected: { + autofilled: "totally.example.com/", + completed: "http://totally.example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://totally.example.com/", + title: "test visit for http://totally.example.com/", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "Use count threshold is as same as use count of input history", + pref: true, + useCountThreshold: 1 * 0.9 + 1, + visitHistory: ["http://example.com/test"], + inputHistory: [ + { uri: "http://example.com/test", input: "exa" }, + { uri: "http://example.com/test", input: "exa" }, + ], + userInput: "exa", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "Use count threshold is less than use count of input history", + pref: true, + useCountThreshold: 3, + visitHistory: ["http://example.com/test"], + inputHistory: [ + { uri: "http://example.com/test", input: "exa" }, + { uri: "http://example.com/test", input: "exa" }, + { uri: "http://example.com/test", input: "exa" }, + { uri: "http://example.com/test", input: "exa" }, + ], + userInput: "exa", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "Use count threshold is more than use count of input history", + pref: true, + useCountThreshold: 10, + visitHistory: ["http://example.com/test"], + inputHistory: [ + { uri: "http://example.com/test", input: "exa" }, + { uri: "http://example.com/test", input: "exa" }, + { uri: "http://example.com/test", input: "exa" }, + { uri: "http://example.com/test", input: "exa" }, + { uri: "http://example.com/test", input: "exa" }, + ], + userInput: "exa", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.com/"), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "minCharsThreshold pref equals to the user input length", + pref: true, + minCharsThreshold: 3, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exa", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "minCharsThreshold pref is smaller than the user input length", + pref: true, + minCharsThreshold: 2, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exa", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "minCharsThreshold pref is larger than the user input length", + pref: true, + minCharsThreshold: 4, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exa", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.com/"), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: + "Prioritize path component with case-sensitive and that is visited", + pref: true, + visitHistory: [ + "http://example.com/TEST", + "http://example.com/TEST", + "http://example.com/test", + ], + inputHistory: [ + { uri: "http://example.com/TEST", input: "example.com/test" }, + { uri: "http://example.com/test", input: "example.com/test" }, + ], + userInput: "example.com/test", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/TEST", + title: "test visit for http://example.com/TEST", + }), + ], + }, + }, + { + description: + "Prioritize path component with case-sensitive but no visited data", + pref: true, + visitHistory: ["http://example.com/TEST"], + inputHistory: [ + { uri: "http://example.com/TEST", input: "example.com/test" }, + ], + userInput: "example.com/test", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.com/test"), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/TEST", + title: "test visit for http://example.com/TEST", + }), + ], + }, + }, + { + description: + "With history and bookmarks sources, foreign_count == 0, frecency <= 0: No adaptive history autofill", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + frecency: 0, + userInput: "exa", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.com/"), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: + "With history source, visit_count == 0, foreign_count != 0: No adaptive history autofill", + pref: true, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + bookmarks: [{ uri: "http://example.com/test", title: "test bookmark" }], + userInput: "exa", + expected: { + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + ], + }, + }, + { + description: + "With history source, visit_count > 0, foreign_count != 0, frecency <= 20: No adaptive history autofill", + pref: true, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + bookmarks: [{ uri: "http://example.com/test", title: "test bookmark" }], + frecency: 0, + userInput: "exa", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.com/"), + heuristic: true, + }), + ], + }, + }, + { + description: + "With history source, visit_count > 0, foreign_count == 0, frecency <= 20: No adaptive history autofill", + pref: true, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + frecency: 0, + userInput: "exa", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.com/"), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "Empty input string", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "" }], + userInput: "exa", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.com/"), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "Turn the pref off", + pref: false, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exa", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.com/"), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, +]; + +add_task(async function inputTest() { + for (const { + description, + pref, + minCharsThreshold, + useCountThreshold, + source, + visitHistory, + inputHistory, + bookmarks, + frecency, + userInput, + expected, + } of TEST_DATA) { + info(description); + + UrlbarPrefs.set("autoFill.adaptiveHistory.enabled", pref); + + if (!isNaN(minCharsThreshold)) { + UrlbarPrefs.set( + "autoFill.adaptiveHistory.minCharsThreshold", + minCharsThreshold + ); + } + + if (!isNaN(useCountThreshold)) { + UrlbarPrefs.set( + "autoFill.adaptiveHistory.useCountThreshold", + useCountThreshold + ); + } + + if (visitHistory && visitHistory.length) { + await PlacesTestUtils.addVisits(visitHistory); + } + for (const { uri, input } of inputHistory) { + await UrlbarUtils.addToInputHistory(uri, input); + } + for (const bookmark of bookmarks || []) { + await PlacesTestUtils.addBookmarkWithDetails(bookmark); + } + + if (typeof frecency == "number") { + await PlacesUtils.withConnectionWrapper("test::setFrecency", db => + db.execute( + `UPDATE moz_places SET frecency = :frecency WHERE url = :url`, + { + frecency, + url: visitHistory[0], + } + ) + ); + } + + const sources = source + ? [source] + : [ + UrlbarUtils.RESULT_SOURCE.HISTORY, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + ]; + + const context = createContext(userInput, { + sources, + isPrivate: false, + }); + + await check_results({ + context, + autofilled: expected.autofilled, + completed: expected.completed, + hasAutofillTitle: expected.hasAutofillTitle, + matches: expected.results.map(f => f(context)), + }); + + await cleanupPlaces(); + UrlbarPrefs.clear("autoFill.adaptiveHistory.enabled"); + UrlbarPrefs.clear("autoFill.adaptiveHistory.minCharsThreshold"); + UrlbarPrefs.clear("autoFill.adaptiveHistory.useCountThreshold"); + } +}); + +add_task(async function urlCase() { + UrlbarPrefs.set("autoFill.adaptiveHistory.enabled", true); + + const testVisitFixed = "example.com/ABC/DEF"; + const testVisitURL = `http://${testVisitFixed}`; + const testInput = "example"; + await PlacesTestUtils.addVisits([testVisitURL]); + await UrlbarUtils.addToInputHistory(testVisitURL, testInput); + + const userInput = "example.COM/abc/def"; + for (let i = 1; i <= userInput.length; i++) { + const currentUserInput = userInput.substring(0, i); + const context = createContext(currentUserInput, { isPrivate: false }); + + if (currentUserInput.length < testInput.length) { + // Autofill with host. + await check_results({ + context, + autofilled: "example.com/", + completed: "http://example.com/", + matches: [ + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.com/"), + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }); + } else if (currentUserInput.length !== testVisitFixed.length) { + // Autofill using input history. + const autofilled = currentUserInput + testVisitFixed.substring(i); + await check_results({ + context, + autofilled, + completed: "http://example.com/ABC/DEF", + matches: [ + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + heuristic: true, + }), + ], + }); + } else { + // Autofill using user's input. + await check_results({ + context, + autofilled: "example.COM/abc/def", + completed: "http://example.com/abc/def", + matches: [ + makeVisitResult(context, { + uri: "http://example.com/abc/def", + fallbackTitle: UrlbarTestUtils.trimURL( + "http://example.com/abc/def" + ), + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }); + } + } + + await cleanupPlaces(); + UrlbarPrefs.clear("autoFill.adaptiveHistory.enabled"); +}); + +add_task(async function decayTest() { + UrlbarPrefs.set("autoFill.adaptiveHistory.enabled", true); + + await PlacesTestUtils.addVisits(["http://example.com/test"]); + await UrlbarUtils.addToInputHistory("http://example.com/test", "exa"); + + const initContext = createContext("exa", { isPrivate: false }); + await check_results({ + context: initContext, + autofilled: "example.com/test", + completed: "http://example.com/test", + matches: [ + makeVisitResult(initContext, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }); + + // The decay rate for a day is 0.975, as defined in PlacesFrecencyRecalculator + // Therefore, after 30 days, as use_count will be 0.975^30 = 0.468, we set the + // useCountThreshold 0.47 to not take the input history passed 30 days. + UrlbarPrefs.set("autoFill.adaptiveHistory.useCountThreshold", 0.47); + + // Make 29 days later. + for (let i = 0; i < 29; i++) { + await Cc["@mozilla.org/places/frecency-recalculator;1"] + .getService(Ci.nsIObserver) + .wrappedJSObject.decay(); + } + const midContext = createContext("exa", { isPrivate: false }); + await check_results({ + context: midContext, + autofilled: "example.com/test", + completed: "http://example.com/test", + matches: [ + makeVisitResult(midContext, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }); + + // Total 30 days later. + await Cc["@mozilla.org/places/frecency-recalculator;1"] + .getService(Ci.nsIObserver) + .wrappedJSObject.decay(); + const context = createContext("exa", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/", + completed: "http://example.com/", + matches: [ + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.com"), + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }); + + await cleanupPlaces(); + UrlbarPrefs.clear("autoFill.adaptiveHistory.enabled"); + UrlbarPrefs.clear("autoFill.adaptiveHistory.useCountThreshold"); +}); diff --git a/browser/components/urlbar/tests/unit/test_autofill_bookmarked.js b/browser/components/urlbar/tests/unit/test_autofill_bookmarked.js new file mode 100644 index 0000000000..2c6b874dbb --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_bookmarked.js @@ -0,0 +1,151 @@ +/* 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 is a specific autofill test to ensure we pick the correct bookmarked +// state of an origin. Regardless of the order of origins, we should always pick +// the correct bookmarked status. + +add_task(async function () { + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + }); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + + let host = "example.com"; + // Add a bookmark to the http version, but ensure the https version has an + // higher frecency. + let bookmark = await PlacesUtils.bookmarks.insert({ + url: `http://${host}`, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits(`https://${host}`); + } + // ensure both fall below the threshold. + for (let i = 0; i < 15; i++) { + await PlacesTestUtils.addVisits(`https://not-${host}`); + } + + async function check_autofill() { + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + let threshold = await getOriginAutofillThreshold(); + let httpOriginFrecency = await getOriginFrecency("http://", host); + Assert.less( + httpOriginFrecency, + threshold, + "Http origin frecency should be below the threshold" + ); + let httpsOriginFrecency = await getOriginFrecency("https://", host); + Assert.less( + httpsOriginFrecency, + threshold, + "Https origin frecency should be below the threshold" + ); + Assert.less( + httpOriginFrecency, + httpsOriginFrecency, + "Http origin frecency should be below the https origin frecency" + ); + + // The http version should be filled because it's bookmarked, but with the + // https prefix that is more frecent. + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + autofilled: `${host}/`, + completed: `https://${host}/`, + matches: [ + makeVisitResult(context, { + uri: `https://${host}/`, + title: `test visit for https://${host}/`, + heuristic: true, + }), + makeVisitResult(context, { + uri: `https://not-${host}/`, + title: `test visit for https://not-${host}/`, + }), + ], + }); + } + + await check_autofill(); + + // Now remove the bookmark, ensure to remove the orphans, then reinsert the + // bookmark; thus we physically invert the order of the rows in the table. + await checkOriginsOrder(host, ["http://", "https://"]); + await PlacesUtils.bookmarks.remove(bookmark); + await PlacesUtils.withConnectionWrapper("removeOrphans", async db => { + db.execute(`DELETE FROM moz_places WHERE url = :url`, { + url: `http://${host}/`, + }); + db.execute( + `DELETE FROM moz_origins WHERE prefix = "http://" AND host = :host`, + { host } + ); + }); + bookmark = await PlacesUtils.bookmarks.insert({ + url: `http://${host}`, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + + await checkOriginsOrder(host, ["https://", "http://"]); + + await check_autofill(); + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.remove(bookmark); +}); + +add_task(async function test_www() { + // Add a bookmark to the www version + let host = "example.com"; + await PlacesUtils.bookmarks.insert({ + url: `http://www.${host}`, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + + info("search for start of www."); + let context = createContext("w", { isPrivate: false }); + await check_results({ + context, + autofilled: `www.${host}/`, + completed: `http://www.${host}/`, + matches: [ + makeVisitResult(context, { + uri: `http://www.${host}/`, + fallbackTitle: UrlbarTestUtils.trimURL(`http://www.${host}`), + heuristic: true, + }), + ], + }); + info("search for full www."); + context = createContext("www.", { isPrivate: false }); + await check_results({ + context, + autofilled: `www.${host}/`, + completed: `http://www.${host}/`, + matches: [ + makeVisitResult(context, { + uri: `http://www.${host}/`, + fallbackTitle: UrlbarTestUtils.trimURL(`http://www.${host}`), + heuristic: true, + }), + ], + }); + info("search for host without www."); + context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + autofilled: `${host}/`, + completed: `http://www.${host}/`, + matches: [ + makeVisitResult(context, { + uri: `http://www.${host}/`, + fallbackTitle: UrlbarTestUtils.trimURL(`http://www.${host}`), + heuristic: true, + }), + ], + }); +}); diff --git a/browser/components/urlbar/tests/unit/test_autofill_do_not_trim.js b/browser/components/urlbar/tests/unit/test_autofill_do_not_trim.js new file mode 100644 index 0000000000..37e2a8bbcb --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_do_not_trim.js @@ -0,0 +1,140 @@ +/* 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/. */ + +// We should not autofill when the search string contains spaces. + +testEngine_setup(); + +add_setup(async () => { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/link/"), + }); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + await cleanupPlaces(); + }); +}); + +add_task(async function test_not_autofill_ws_1() { + info("Do not autofill whitespaced entry 1"); + let context = createContext("mozilla.org ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/", + fallbackTitle: "http://mozilla.org/", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/link/", + title: "test visit for http://mozilla.org/link/", + }), + ], + }); +}); + +add_task(async function test_not_autofill_ws_2() { + info("Do not autofill whitespaced entry 2"); + let context = createContext("mozilla.org/ ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/", + fallbackTitle: "http://mozilla.org/", + iconUri: "page-icon:http://mozilla.org/", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/link/", + title: "test visit for http://mozilla.org/link/", + }), + ], + }); +}); + +add_task(async function test_not_autofill_ws_3() { + info("Do not autofill whitespaced entry 3"); + let context = createContext("mozilla.org/link ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/link", + fallbackTitle: "http://mozilla.org/link", + iconUri: "page-icon:http://mozilla.org/", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/link/", + title: "test visit for http://mozilla.org/link/", + }), + ], + }); +}); + +add_task(async function test_not_autofill_ws_4() { + info( + "Do not autofill whitespaced entry 4, but UrlbarProviderPlaces provides heuristic result" + ); + let context = createContext("mozilla.org/link/ ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/link/", + title: "test visit for http://mozilla.org/link/", + iconUri: "page-icon:http://mozilla.org/link/", + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + heuristic: true, + }), + ], + }); +}); + +add_task(async function test_not_autofill_ws_5() { + info("Do not autofill whitespaced entry 5"); + let context = createContext("moz illa ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + // query is made explict so makeSearchResult doesn't trim it. + query: "moz illa ", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/link/", + title: "test visit for http://mozilla.org/link/", + }), + ], + }); +}); + +add_task(async function test_not_autofill_ws_6() { + info("Do not autofill whitespaced entry 6"); + let context = createContext(" mozilla", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + // query is made explict so makeSearchResult doesn't trim it. + query: " mozilla", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/link/", + title: "test visit for http://mozilla.org/link/", + }), + ], + }); +}); diff --git a/browser/components/urlbar/tests/unit/test_autofill_functional.js b/browser/components/urlbar/tests/unit/test_autofill_functional.js new file mode 100644 index 0000000000..ad8d567a30 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_functional.js @@ -0,0 +1,147 @@ +/* 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/. */ + +// Functional tests for inline autocomplete + +add_setup(async function () { + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + }); + + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); +}); + +add_task(async function test_urls_order() { + info("Add urls, check for correct order"); + let places = [ + { uri: Services.io.newURI("http://visit1.mozilla.org") }, + { uri: Services.io.newURI("http://visit2.mozilla.org") }, + ]; + await PlacesTestUtils.addVisits(places); + let context = createContext("vis", { isPrivate: false }); + await check_results({ + context, + autofilled: "visit2.mozilla.org/", + completed: "http://visit2.mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://visit2.mozilla.org/", + title: "test visit for http://visit2.mozilla.org/", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://visit1.mozilla.org/", + title: "test visit for http://visit1.mozilla.org/", + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_bookmark_first() { + info("With a bookmark and history, the query result should be the bookmark"); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: Services.io.newURI("http://bookmark1.mozilla.org/"), + }); + await PlacesTestUtils.addVisits( + Services.io.newURI("http://bookmark1.mozilla.org/foo") + ); + let context = createContext("bookmark", { isPrivate: false }); + await check_results({ + context, + autofilled: "bookmark1.mozilla.org/", + completed: "http://bookmark1.mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://bookmark1.mozilla.org/", + title: "A bookmark", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://bookmark1.mozilla.org/foo", + title: "test visit for http://bookmark1.mozilla.org/foo", + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_complete_querystring() { + info("Check to make sure we autocomplete after ?"); + await PlacesTestUtils.addVisits( + Services.io.newURI("http://smokey.mozilla.org/foo?bacon=delicious") + ); + let context = createContext("smokey.mozilla.org/foo?", { isPrivate: false }); + await check_results({ + context, + autofilled: "smokey.mozilla.org/foo?bacon=delicious", + completed: "http://smokey.mozilla.org/foo?bacon=delicious", + matches: [ + makeVisitResult(context, { + uri: "http://smokey.mozilla.org/foo?bacon=delicious", + title: "test visit for http://smokey.mozilla.org/foo?bacon=delicious", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_complete_fragment() { + info("Check to make sure we autocomplete after #"); + await PlacesTestUtils.addVisits( + Services.io.newURI("http://smokey.mozilla.org/foo?bacon=delicious#bar") + ); + let context = createContext("smokey.mozilla.org/foo?bacon=delicious#bar", { + isPrivate: false, + }); + await check_results({ + context, + autofilled: "smokey.mozilla.org/foo?bacon=delicious#bar", + completed: "http://smokey.mozilla.org/foo?bacon=delicious#bar", + matches: [ + makeVisitResult(context, { + uri: "http://smokey.mozilla.org/foo?bacon=delicious#bar", + title: + "test visit for http://smokey.mozilla.org/foo?bacon=delicious#bar", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_prefix_autofill() { + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/test/"), + }); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://moz.org/test/"), + }); + + info("Should still autofill after a search is cancelled immediately"); + let context = createContext("mozi", { isPrivate: false }); + await check_results({ + context, + incompleteSearch: "moz", + autofilled: "mozilla.org/", + completed: "http://mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/", + fallbackTitle: UrlbarTestUtils.trimURL("http://mozilla.org"), + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/test/", + title: "test visit for http://mozilla.org/test/", + providerName: "Places", + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_autofill_origins.js b/browser/components/urlbar/tests/unit/test_autofill_origins.js new file mode 100644 index 0000000000..33e462a8af --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_origins.js @@ -0,0 +1,1041 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const HEURISTIC_FALLBACK_PROVIDERNAME = "HeuristicFallback"; + +const origin = "example.com"; + +async function cleanup() { + let suggestPrefs = ["history", "bookmark", "openpage"]; + for (let type of suggestPrefs) { + Services.prefs.clearUserPref("browser.urlbar.suggest." + type); + } + await cleanupPlaces(); +} + +testEngine_setup(); + +registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); +}); +Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + +// "example.com/" should match http://example.com/. i.e., the search string +// should be treated as if it didn't have the trailing slash. +add_task(async function trailingSlash() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/", + }, + ]); + + let context = createContext(`${origin}/`, { isPrivate: false }); + await check_results({ + context, + autofilled: `${origin}/`, + completed: `http://${origin}/`, + matches: [ + makeVisitResult(context, { + uri: `http://${origin}/`, + title: `test visit for http://${origin}/`, + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "example.com/" should match http://www.example.com/. i.e., the search string +// should be treated as if it didn't have the trailing slash. +add_task(async function trailingSlashWWW() { + await PlacesTestUtils.addVisits([ + { + uri: "http://www.example.com/", + }, + ]); + let context = createContext(`${origin}/`, { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/", + completed: "http://www.example.com/", + matches: [ + makeVisitResult(context, { + uri: `http://www.${origin}/`, + title: `test visit for http://www.${origin}/`, + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "ex" should match http://example.com:8888/, and the port should be completed. +add_task(async function port() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com:8888/", + }, + ]); + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com:8888/", + completed: "http://example.com:8888/", + matches: [ + makeVisitResult(context, { + uri: `http://${origin}:8888/`, + title: `test visit for http://${origin}:8888/`, + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "example.com:8" should match http://example.com:8888/, and the port should +// be completed. +add_task(async function portPartial() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com:8888/", + }, + ]); + let context = createContext(`${origin}:8`, { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com:8888/", + completed: "http://example.com:8888/", + matches: [ + makeVisitResult(context, { + uri: `http://${origin}:8888/`, + title: `test visit for http://${origin}:8888/`, + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "EXaM" should match http://example.com/ and the case of the search string +// should be preserved in the autofilled value. +add_task(async function preserveCase() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/", + }, + ]); + let context = createContext("EXaM", { isPrivate: false }); + await check_results({ + context, + autofilled: "EXaMple.com/", + completed: "http://example.com/", + matches: [ + makeVisitResult(context, { + uri: `http://${origin}/`, + title: `test visit for http://${origin}/`, + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "EXaM" should match http://example.com:8888/, the port should be completed, +// and the case of the search string should be preserved in the autofilled +// value. +add_task(async function preserveCasePort() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com:8888/", + }, + ]); + let context = createContext("EXaM", { isPrivate: false }); + await check_results({ + context, + autofilled: "EXaMple.com:8888/", + completed: "http://example.com:8888/", + matches: [ + makeVisitResult(context, { + uri: `http://${origin}:8888/`, + title: `test visit for http://${origin}:8888/`, + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "example.com:89" should *not* match http://example.com:8888/. +add_task(async function portNoMatch1() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com:8888/", + }, + ]); + let context = createContext(`${origin}:89`, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${origin}:89/`, + fallbackTitle: `http://${origin}:89/`, + iconUri: "", + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// "example.com:9" should *not* match http://example.com:8888/. +add_task(async function portNoMatch2() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com:8888/", + }, + ]); + let context = createContext(`${origin}:9`, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${origin}:9/`, + fallbackTitle: `http://${origin}:9/`, + iconUri: "", + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// "example/" should *not* match http://example.com/. +add_task(async function trailingSlash_2() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/", + }, + ]); + let context = createContext("example/", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://example/", + fallbackTitle: "http://example/", + iconUri: "page-icon:http://example/", + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// multi.dotted.domain, search up to dot. +add_task(async function multidotted() { + await PlacesTestUtils.addVisits([ + { + uri: "http://www.example.co.jp:8888/", + }, + ]); + let context = createContext("www.example.co.", { isPrivate: false }); + await check_results({ + context, + autofilled: "www.example.co.jp:8888/", + completed: "http://www.example.co.jp:8888/", + matches: [ + makeVisitResult(context, { + uri: "http://www.example.co.jp:8888/", + title: "test visit for http://www.example.co.jp:8888/", + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +add_task(async function test_ip() { + // IP addresses have complicated rules around whether they show + // HeuristicFallback's backup search result. Flip this pref to disable that + // backup search and simplify ths subtest. + Services.prefs.setBoolPref("keyword.enabled", false); + for (let str of [ + "192.168.1.1/", + "255.255.255.255:8080/", + "[2001:db8::1428:57ab]/", + "[::c0a8:5909]/", + "[::1]/", + ]) { + info("testing " + str); + await PlacesTestUtils.addVisits("http://" + str); + for (let i = 1; i < str.length; ++i) { + let context = createContext(str.substring(0, i), { isPrivate: false }); + await check_results({ + context, + autofilled: str, + completed: "http://" + str, + matches: [ + makeVisitResult(context, { + uri: "http://" + str, + title: `test visit for http://${str}`, + heuristic: true, + }), + ], + }); + } + await cleanup(); + } + Services.prefs.clearUserPref("keyword.enabled"); +}); + +// host starting with large number. +add_task(async function large_number_host() { + await PlacesTestUtils.addVisits([ + { + uri: "http://12345example.it:8888/", + }, + ]); + let context = createContext("1234", { isPrivate: false }); + await check_results({ + context, + autofilled: "12345example.it:8888/", + completed: "http://12345example.it:8888/", + matches: [ + makeVisitResult(context, { + uri: "http://12345example.it:8888/", + title: "test visit for http://12345example.it:8888/", + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// When determining which origins should be autofilled, all the origins sharing +// a host should be added together to get their combined frecency -- i.e., +// prefixes should be collapsed. And then from that list, the origin with the +// highest frecency should be chosen. +add_task(async function groupByHost() { + // Add some visits to the same host, example.com. Add one http and two https + // so that https has a higher frecency and is therefore the origin that should + // be autofilled. Also add another origin that has a higher frecency than + // both so that alone, neither http nor https would be autofilled, but added + // together they should be. + await PlacesTestUtils.addVisits([ + { uri: "http://example.com/" }, + + { uri: "https://example.com/" }, + { uri: "https://example.com/" }, + + { uri: "https://mozilla.org/" }, + { uri: "https://mozilla.org/" }, + { uri: "https://mozilla.org/" }, + { uri: "https://mozilla.org/" }, + ]); + + let httpFrec = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: "http://example.com/" } + ); + let httpsFrec = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: "https://example.com/" } + ); + let otherFrec = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: "https://mozilla.org/" } + ); + Assert.less(httpFrec, httpsFrec, "Sanity check"); + Assert.less(httpsFrec, otherFrec, "Sanity check"); + + // Make sure the frecencies of the three origins are as expected in relation + // to the threshold. + let threshold = await getOriginAutofillThreshold(); + Assert.less(httpFrec, threshold, "http origin should be < threshold"); + Assert.less(httpsFrec, threshold, "https origin should be < threshold"); + Assert.ok(threshold <= otherFrec, "Other origin should cross threshold"); + + Assert.ok( + threshold <= httpFrec + httpsFrec, + "http and https origin added together should cross threshold" + ); + + // The https origin should be autofilled. + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/", + completed: "https://example.com/", + matches: [ + makeVisitResult(context, { + uri: "https://example.com/", + title: "test visit for https://example.com/", + heuristic: true, + }), + ], + }); + + await cleanup(); +}); + +// This is the same as the previous (groupByHost), but it changes the standard +// deviation multiplier by setting the corresponding pref. This makes sure that +// the pref is respected. +add_task(async function groupByHostNonDefaultStddevMultiplier() { + let stddevMultiplier = 1.5; + Services.prefs.setCharPref( + "browser.urlbar.autoFill.stddevMultiplier", + Number(stddevMultiplier).toFixed(1) + ); + + await PlacesTestUtils.addVisits([ + { uri: "http://example.com/" }, + { uri: "http://example.com/" }, + + { uri: "https://example.com/" }, + { uri: "https://example.com/" }, + { uri: "https://example.com/" }, + + { uri: "https://foo.com/" }, + { uri: "https://foo.com/" }, + { uri: "https://foo.com/" }, + + { uri: "https://mozilla.org/" }, + { uri: "https://mozilla.org/" }, + { uri: "https://mozilla.org/" }, + { uri: "https://mozilla.org/" }, + { uri: "https://mozilla.org/" }, + ]); + + let httpFrec = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { + url: "http://example.com/", + } + ); + let httpsFrec = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { + url: "https://example.com/", + } + ); + let otherFrec = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { + url: "https://mozilla.org/", + } + ); + Assert.less(httpFrec, httpsFrec, "Sanity check"); + Assert.less(httpsFrec, otherFrec, "Sanity check"); + + // Make sure the frecencies of the three origins are as expected in relation + // to the threshold. + let threshold = await getOriginAutofillThreshold(); + Assert.less(httpFrec, threshold, "http origin should be < threshold"); + Assert.less(httpsFrec, threshold, "https origin should be < threshold"); + Assert.ok(threshold <= otherFrec, "Other origin should cross threshold"); + + Assert.ok( + threshold <= httpFrec + httpsFrec, + "http and https origin added together should cross threshold" + ); + + // The https origin should be autofilled. + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/", + completed: "https://example.com/", + matches: [ + makeVisitResult(context, { + uri: "https://example.com/", + title: "test visit for https://example.com/", + heuristic: true, + }), + ], + }); + + Services.prefs.clearUserPref("browser.urlbar.autoFill.stddevMultiplier"); + + await cleanup(); +}); + +// This is similar to suggestHistoryFalse_bookmark_0 in test_autofill_tasks.js, +// but it adds unbookmarked visits for multiple URLs with the same origin. +add_task(async function suggestHistoryFalse_bookmark_multiple() { + // Force only bookmarked pages to be suggested and therefore only bookmarked + // pages to be completed. + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + + let search = "ex"; + let baseURL = "http://example.com/"; + let bookmarkedURL = baseURL + "bookmarked"; + + // Add visits for three different URLs all sharing the same origin, and then + // bookmark the second one. After that, the origin should be autofilled. The + // reason for adding unbookmarked visits before and after adding the + // bookmarked visit is to make sure our aggregate SQL query for determining + // whether an origin is bookmarked is correct. + + await PlacesTestUtils.addVisits([ + { + uri: baseURL + "other1", + }, + ]); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + heuristic: true, + }), + ], + }); + + await PlacesTestUtils.addVisits([ + { + uri: bookmarkedURL, + }, + ]); + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + heuristic: true, + }), + ], + }); + + await PlacesTestUtils.addVisits([ + { + uri: baseURL + "other2", + }, + ]); + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + heuristic: true, + }), + ], + }); + + // Now bookmark the second URL. It should be suggested and completed. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: bookmarkedURL, + }); + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/", + completed: baseURL, + matches: [ + makeVisitResult(context, { + uri: baseURL, + fallbackTitle: UrlbarTestUtils.trimURL(baseURL), + heuristic: true, + }), + makeBookmarkResult(context, { + uri: bookmarkedURL, + title: "A bookmark", + }), + ], + }); + + await cleanup(); +}); + +// This is similar to suggestHistoryFalse_bookmark_prefix_0 in +// autofill_test_autofill_originsAndQueries.js, but it adds unbookmarked visits +// for multiple URLs with the same origin. +add_task(async function suggestHistoryFalse_bookmark_prefix_multiple() { + // Force only bookmarked pages to be suggested and therefore only bookmarked + // pages to be completed. + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + + let search = "http://ex"; + let baseURL = "http://example.com/"; + let bookmarkedURL = baseURL + "bookmarked"; + + // Add visits for three different URLs all sharing the same origin, and then + // bookmark the second one. After that, the origin should be autofilled. The + // reason for adding unbookmarked visits before and after adding the + // bookmarked visit is to make sure our aggregate SQL query for determining + // whether an origin is bookmarked is correct. + + await PlacesTestUtils.addVisits([ + { + uri: baseURL + "other1", + }, + ]); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `${search}/`, + fallbackTitle: `${search}/`, + iconUri: "", + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + + await PlacesTestUtils.addVisits([ + { + uri: bookmarkedURL, + }, + ]); + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `${search}/`, + fallbackTitle: `${search}/`, + iconUri: "", + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + + await PlacesTestUtils.addVisits([ + { + uri: baseURL + "other2", + }, + ]); + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `${search}/`, + fallbackTitle: `${search}/`, + iconUri: "", + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + + // Now bookmark the second URL. It should be suggested and completed. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: bookmarkedURL, + }); + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: "http://example.com/", + completed: baseURL, + matches: [ + makeVisitResult(context, { + uri: baseURL, + fallbackTitle: UrlbarTestUtils.trimURL(baseURL), + heuristic: true, + }), + makeBookmarkResult(context, { + uri: bookmarkedURL, + title: "A bookmark", + }), + ], + }); + + await cleanup(); +}); + +// When the autofilled URL is `example.com/`, a visit for `example.com/?` should +// not be included in the results since it dupes the autofill result. +add_task(async function searchParams() { + await PlacesTestUtils.addVisits([ + "http://example.com/", + "http://example.com/?", + "http://example.com/?foo", + ]); + + // First, do a search with autofill disabled to make sure the visits were + // properly added. `example.com/?foo` has the highest frecency because it was + // added last; `example.com/?` has the next highest. `example.com/` dupes + // `example.com/?`, so it should not appear. + UrlbarPrefs.set("autoFill", false); + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://example.com/?foo", + title: "test visit for http://example.com/?foo", + }), + makeVisitResult(context, { + uri: "http://example.com/?", + title: "test visit for http://example.com/?", + }), + ], + }); + + // Now do a search with autofill enabled. This time `example.com/` will be + // autofilled, and since `example.com/?` dupes it, `example.com/?` should not + // appear. + UrlbarPrefs.clear("autoFill"); + context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/", + completed: "http://example.com/", + matches: [ + makeVisitResult(context, { + uri: "http://example.com/", + title: "test visit for http://example.com/", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://example.com/?foo", + title: "test visit for http://example.com/?foo", + }), + ], + }); + + await cleanup(); +}); + +// When the autofilled URL is `example.com/`, a visit for `example.com/?` should +// not be included in the results since it dupes the autofill result. (Same as +// the previous task but with https URLs instead of http. There shouldn't be any +// substantive difference.) +add_task(async function searchParams_https() { + await PlacesTestUtils.addVisits([ + "https://example.com/", + "https://example.com/?", + "https://example.com/?foo", + ]); + + // First, do a search with autofill disabled to make sure the visits were + // properly added. `example.com/?foo` has the highest frecency because it was + // added last; `example.com/?` has the next highest. `example.com/` dupes + // `example.com/?`, so it should not appear. + UrlbarPrefs.set("autoFill", false); + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://example.com/?foo", + title: "test visit for https://example.com/?foo", + }), + makeVisitResult(context, { + uri: "https://example.com/?", + title: "test visit for https://example.com/?", + }), + ], + }); + + // Now do a search with autofill enabled. This time `example.com/` will be + // autofilled, and since `example.com/?` dupes it, `example.com/?` should not + // appear. + UrlbarPrefs.clear("autoFill"); + context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/", + completed: "https://example.com/", + matches: [ + makeVisitResult(context, { + uri: "https://example.com/", + title: "test visit for https://example.com/", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://example.com/?foo", + title: "test visit for https://example.com/?foo", + }), + ], + }); + + await cleanup(); +}); + +// Checks an origin that looks like a prefix: a scheme with no dots + a port. +add_task(async function originLooksLikePrefix() { + let hostAndPort = "localhost:8888"; + let address = `http://${hostAndPort}/`; + await PlacesTestUtils.addVisits([{ uri: address }]); + + // addTestSuggestionsEngine adds a search engine + // with localhost as a server, so we have to disable the + // TTS result or else it will show up as a second result + // when searching l to localhost + UrlbarPrefs.set("suggest.engines", false); + + for (let search of ["lo", "localhost", "localhost:", "localhost:8888"]) { + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: hostAndPort + "/", + completed: address, + matches: [ + makeVisitResult(context, { + uri: address, + title: `test visit for http://${hostAndPort}/`, + heuristic: true, + }), + ], + }); + } + await cleanup(); +}); + +// Checks an origin whose prefix is "about:". +add_task(async function about() { + const testData = [ + { + uri: "about:config", + input: "conf", + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + context => + makeBookmarkResult(context, { + uri: "about:config", + title: "A bookmark", + }), + ], + }, + { + uri: "about:blank", + input: "about:blan", + results: [ + context => + makeVisitResult(context, { + uri: "about:blan", + fallbackTitle: "about:blan", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + context => + makeBookmarkResult(context, { + uri: "about:blank", + title: "A bookmark", + }), + ], + }, + ]; + + for (const { uri, input, results } of testData) { + await PlacesTestUtils.addBookmarkWithDetails({ uri }); + + const context = createContext(input, { isPrivate: false }); + await check_results({ + context, + matches: results.map(f => f(context)), + }); + await cleanup(); + } +}); + +// Checks an origin whose prefix is "place:". +add_task(async function place() { + const testData = [ + { + uri: "place:transition=7&sort=4", + input: "tran", + }, + { + uri: "place:transition=7&sort=4", + input: "place:tran", + }, + ]; + + for (const { uri, input } of testData) { + await PlacesTestUtils.addBookmarkWithDetails({ uri }); + + const context = createContext(input, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + ], + }); + await cleanup(); + } +}); + +add_task(async function nullTitle() { + await doTitleTest({ + visits: [ + { + uri: "http://example.com/", + // Set title of visits data to an empty string causes + // the title to be null in the database. + title: "", + frecency: 100, + }, + { + uri: "https://www.example.com/", + title: "high frecency", + frecency: 50, + }, + { + uri: "http://www.example.com/", + title: "low frecency", + frecency: 1, + }, + ], + input: "example.com", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + matches: context => [ + makeVisitResult(context, { + uri: "http://example.com/", + title: "high frecency", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://www.example.com/", + title: "high frecency", + }), + ], + }, + }); +}); + +add_task(async function domainTitle() { + await doTitleTest({ + visits: [ + { + uri: "http://example.com/", + title: "example.com", + frecency: 100, + }, + { + uri: "https://www.example.com/", + title: "", + frecency: 50, + }, + { + uri: "http://www.example.com/", + title: "lowest frecency but has title", + frecency: 1, + }, + ], + input: "example.com", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + matches: context => [ + makeVisitResult(context, { + uri: "http://example.com/", + title: "lowest frecency but has title", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://www.example.com/", + title: "www.example.com", + }), + ], + }, + }); +}); + +add_task(async function exactMatchedTitle() { + await doTitleTest({ + visits: [ + { + uri: "http://example.com/", + title: "exact match", + frecency: 50, + }, + { + uri: "https://www.example.com/", + title: "high frecency uri", + frecency: 100, + }, + ], + input: "http://example.com/", + expected: { + autofilled: "http://example.com/", + completed: "http://example.com/", + matches: context => [ + makeVisitResult(context, { + uri: "http://example.com/", + title: "exact match", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://www.example.com/", + title: "high frecency uri", + }), + ], + }, + }); +}); + +async function doTitleTest({ visits, input, expected }) { + await PlacesTestUtils.addVisits(visits); + for (const { uri, frecency } of visits) { + // Prepare data. + await PlacesUtils.withConnectionWrapper("test::doTitleTest", async db => { + await db.execute( + `UPDATE moz_places SET frecency = :frecency, recalc_frecency=0 WHERE url = :url`, + { + frecency, + url: uri, + } + ); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + }); + } + + const context = createContext(input, { isPrivate: false }); + await check_results({ + context, + autofilled: expected.autofilled, + completed: expected.completed, + matches: expected.matches(context), + }); + + await cleanup(); +} diff --git a/browser/components/urlbar/tests/unit/test_autofill_originsAndQueries.js b/browser/components/urlbar/tests/unit/test_autofill_originsAndQueries.js new file mode 100644 index 0000000000..05e3a230f1 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_originsAndQueries.js @@ -0,0 +1,2471 @@ +/* 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 HEURISTIC_FALLBACK_PROVIDERNAME = "HeuristicFallback"; +const PLACES_PROVIDERNAME = "Places"; + +/** + * Helpful reminder of the `autofilled` and `completed` properties in the + * object passed to check_results: + * autofilled: expected input.value after autofill + * completed: expected input.value after autofill and enter is pressed + * + * `completed` is the URL that the controller sets to input.value, and the URL + * that will ultimately be loaded when you press enter. + */ + +async function cleanup() { + let suggestPrefs = ["history", "bookmark", "openpage"]; + for (let type of suggestPrefs) { + Services.prefs.clearUserPref("browser.urlbar.suggest." + type); + } + await cleanupPlaces(); +} + +testEngine_setup(); + +registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); +}); +Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + +let path; +let search; +let searchCase; +let visitTitle; +let url; +const host = "example.com"; +let origins; + +function add_autofill_task(callback) { + let func = async () => { + info(`Running subtest with origins disabled: ${callback.name}`); + origins = false; + path = "/foo"; + search = "example.com/f"; + searchCase = "EXAMPLE.COM/f"; + visitTitle = (protocol, sub) => + `test visit for ${protocol}://${sub}example.com/foo`; + url = host + path; + await callback(); + + info(`Running subtest with origins enabled: ${callback.name}`); + origins = true; + path = "/"; + search = "ex"; + searchCase = "EX"; + visitTitle = (protocol, sub) => + `test visit for ${protocol}://${sub}example.com/`; + url = host + path; + await callback(); + }; + Object.defineProperty(func, "name", { value: callback.name }); + add_task(func); +} + +// "ex" should match http://example.com/. +add_autofill_task(async function basic() { + await PlacesTestUtils.addVisits([ + { + uri: "http://" + url, + }, + ]); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "EX" should match http://example.com/. +add_autofill_task(async function basicCase() { + await PlacesTestUtils.addVisits([ + { + uri: "http://" + url, + }, + ]); + let context = createContext(searchCase, { isPrivate: false }); + await check_results({ + context, + autofilled: searchCase + url.substr(searchCase.length), + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "ex" should match http://www.example.com/. +add_autofill_task(async function noWWWShouldMatchWWW() { + await PlacesTestUtils.addVisits([ + { + uri: "http://www." + url, + }, + ]); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://www." + url, + matches: [ + makeVisitResult(context, { + uri: "http://www." + url, + title: visitTitle("http", "www."), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "EX" should match http://www.example.com/. +add_autofill_task(async function noWWWShouldMatchWWWCase() { + await PlacesTestUtils.addVisits([ + { + uri: "http://www." + url, + }, + ]); + let context = createContext(searchCase, { isPrivate: false }); + await check_results({ + context, + autofilled: searchCase + url.substr(searchCase.length), + completed: "http://www." + url, + matches: [ + makeVisitResult(context, { + uri: "http://www." + url, + title: visitTitle("http", "www."), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "www.ex" should *not* match http://example.com/. +add_autofill_task(async function wwwShouldNotMatchNoWWW() { + await PlacesTestUtils.addVisits([ + { + uri: "http://" + url, + }, + ]); + let context = createContext("www." + search, { isPrivate: false }); + if (origins) { + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://www." + search + "/", + fallbackTitle: "http://www." + search + "/", + displayUrl: "http://www." + search, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } else { + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://www." + search, + fallbackTitle: "http://www." + search, + iconUri: `page-icon:http://www.${host}/`, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } + await cleanup(); +}); + +// "http://ex" should match http://example.com/. +add_autofill_task(async function prefix() { + await PlacesTestUtils.addVisits([ + { + uri: "http://" + url, + }, + ]); + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "http://" + url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "HTTP://EX" should match http://example.com/. +add_autofill_task(async function prefixCase() { + await PlacesTestUtils.addVisits([ + { + uri: "http://" + url, + }, + ]); + let context = createContext("HTTP://" + searchCase, { isPrivate: false }); + await check_results({ + context, + autofilled: "HTTP://" + searchCase + url.substr(searchCase.length), + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "http://ex" should match http://www.example.com/. +add_autofill_task(async function prefixNoWWWShouldMatchWWW() { + await PlacesTestUtils.addVisits([ + { + uri: "http://www." + url, + }, + ]); + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "http://" + url, + completed: "http://www." + url, + matches: [ + makeVisitResult(context, { + uri: "http://www." + url, + title: visitTitle("http", "www."), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "HTTP://EX" should match http://www.example.com/. +add_autofill_task(async function prefixNoWWWShouldMatchWWWCase() { + await PlacesTestUtils.addVisits([ + { + uri: "http://www." + url, + }, + ]); + let context = createContext("HTTP://" + searchCase, { isPrivate: false }); + await check_results({ + context, + autofilled: "HTTP://" + searchCase + url.substr(searchCase.length), + completed: "http://www." + url, + matches: [ + makeVisitResult(context, { + uri: "http://www." + url, + title: visitTitle("http", "www."), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "http://www.ex" should *not* match http://example.com/. +add_autofill_task(async function prefixWWWShouldNotMatchNoWWW() { + await PlacesTestUtils.addVisits([ + { + uri: "http://" + url, + }, + ]); + let context = createContext("http://www." + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://www.${search}/` : `http://www.${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://www.${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// "http://ex" should *not* match https://example.com/. +add_autofill_task(async function httpPrefixShouldNotMatchHTTPS() { + await PlacesTestUtils.addVisits([ + { + uri: "https://" + url, + }, + ]); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "https://" + url, + title: "test visit for https://" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// "ex" should match https://example.com/. +add_autofill_task(async function httpsBasic() { + await PlacesTestUtils.addVisits([ + { + uri: "https://" + url, + }, + ]); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "https://" + url, + matches: [ + makeVisitResult(context, { + uri: "https://" + url, + title: visitTitle("https", ""), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "ex" should match https://www.example.com/. +add_autofill_task(async function httpsNoWWWShouldMatchWWW() { + await PlacesTestUtils.addVisits([ + { + uri: "https://www." + url, + }, + ]); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "https://www." + url, + matches: [ + makeVisitResult(context, { + uri: "https://www." + url, + title: visitTitle("https", "www."), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "www.ex" should *not* match https://example.com/. +add_autofill_task(async function httpsWWWShouldNotMatchNoWWW() { + await PlacesTestUtils.addVisits([ + { + uri: "https://" + url, + }, + ]); + let context = createContext("www." + search, { isPrivate: false }); + if (origins) { + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://www." + search + "/", + fallbackTitle: "http://www." + search + "/", + displayUrl: "http://www." + search, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } else { + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://www." + search, + fallbackTitle: "http://www." + search, + iconUri: `page-icon:http://www.${host}/`, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } + await cleanup(); +}); + +// "https://ex" should match https://example.com/. +add_autofill_task(async function httpsPrefix() { + await PlacesTestUtils.addVisits([ + { + uri: "https://" + url, + }, + ]); + let context = createContext("https://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "https://" + url, + completed: "https://" + url, + matches: [ + makeVisitResult(context, { + uri: "https://" + url, + title: visitTitle("https", ""), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "https://ex" should match https://www.example.com/. +add_autofill_task(async function httpsPrefixNoWWWShouldMatchWWW() { + await PlacesTestUtils.addVisits([ + { + uri: "https://www." + url, + }, + ]); + let context = createContext("https://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "https://" + url, + completed: "https://www." + url, + matches: [ + makeVisitResult(context, { + uri: "https://www." + url, + title: visitTitle("https", "www."), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "https://www.ex" should *not* match https://example.com/. +add_autofill_task(async function httpsPrefixWWWShouldNotMatchNoWWW() { + await PlacesTestUtils.addVisits([ + { + uri: "https://" + url, + }, + ]); + let context = createContext("https://www." + search, { isPrivate: false }); + let prefixedUrl = origins + ? `https://www.${search}/` + : `https://www.${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:https://www.${host}/`, + providerame: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// "https://ex" should *not* match http://example.com/. +add_autofill_task(async function httpsPrefixShouldNotMatchHTTP() { + await PlacesTestUtils.addVisits([ + { + uri: "http://" + url, + }, + ]); + let context = createContext("https://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `https://${search}/` : `https://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:https://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://" + url, + title: "test visit for http://" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// "https://ex" should *not* match http://example.com/, even if the latter is +// more frecent and both could be autofilled. +add_autofill_task(async function httpsPrefixShouldNotMatchMoreFrecentHTTP() { + await PlacesTestUtils.addVisits([ + { + uri: "http://" + url, + transition: PlacesUtils.history.TRANSITIONS.TYPED, + }, + { + uri: "http://" + url, + }, + { + uri: "https://" + url, + transition: PlacesUtils.history.TRANSITIONS.TYPED, + }, + { + uri: "http://otherpage", + }, + ]); + let context = createContext("https://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "https://" + url, + completed: "https://" + url, + matches: [ + makeVisitResult(context, { + uri: "https://" + url, + title: visitTitle("https", ""), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// Autofill should respond to frecency changes. +add_autofill_task(async function frecency() { + // Start with an http visit. It should be completed. + await PlacesTestUtils.addVisits([ + { + uri: "http://" + url, + }, + ]); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + + // Add two https visits. https should now be completed. + for (let i = 0; i < 2; i++) { + await PlacesTestUtils.addVisits([{ uri: "https://" + url }]); + } + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "https://" + url, + matches: [ + makeVisitResult(context, { + uri: "https://" + url, + title: visitTitle("https", ""), + heuristic: true, + }), + ], + }); + + // Add two more http visits, three total. http should now be completed + // again. + for (let i = 0; i < 2; i++) { + await PlacesTestUtils.addVisits([{ uri: "http://" + url }]); + } + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://" + url, + title: "test visit for https://" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + + // Add four www https visits. www https should now be completed. + for (let i = 0; i < 4; i++) { + await PlacesTestUtils.addVisits([{ uri: "https://www." + url }]); + } + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "https://www." + url, + matches: [ + makeVisitResult(context, { + uri: "https://www." + url, + title: visitTitle("https", "www."), + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://" + url, + title: "test visit for https://" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + + // Remove the www https page. + await PlacesUtils.history.remove(["https://www." + url]); + + // http should now be completed again. + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://" + url, + title: "test visit for https://" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + + // Remove the http page. + await PlacesUtils.history.remove(["http://" + url]); + + // https should now be completed again. + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "https://" + url, + matches: [ + makeVisitResult(context, { + uri: "https://" + url, + title: visitTitle("https", ""), + heuristic: true, + }), + ], + }); + + // Add a visit with a different host so that "ex" doesn't autofill it. + // https://example.com/ should still have a higher frecency though, so it + // should still be autofilled. + await PlacesTestUtils.addVisits([{ uri: "https://not-" + url }]); + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "https://" + url, + matches: [ + makeVisitResult(context, { + uri: "https://" + url, + title: visitTitle("https", ""), + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://not-" + url, + title: "test visit for https://not-" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + + // Now add 10 more visits to the different host so that the frecency of + // https://example.com/ falls below the autofill threshold. It should not + // be autofilled now. + for (let i = 0; i < 10; i++) { + await PlacesTestUtils.addVisits([{ uri: "https://not-" + url }]); + } + + // In the `origins` case, the failure to make an autofill match means + // HeuristicFallback should not create a heuristic result. In the + // `!origins` case, autofill should still happen since there's no threshold + // comparison. + context = createContext(search, { isPrivate: false }); + if (origins) { + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "https://not-" + url, + title: "test visit for https://not-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "https://" + url, + title: "test visit for https://" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + } else { + await check_results({ + context, + autofilled: url, + completed: "https://" + url, + matches: [ + makeVisitResult(context, { + uri: "https://" + url, + title: visitTitle("https", ""), + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://not-" + url, + title: "test visit for https://not-" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + } + + // Remove the visits to the different host. + await PlacesUtils.history.remove(["https://not-" + url]); + + // https should be completed again. + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "https://" + url, + matches: [ + makeVisitResult(context, { + uri: "https://" + url, + title: visitTitle("https", ""), + heuristic: true, + }), + ], + }); + + // Remove the https visits. + await PlacesUtils.history.remove(["https://" + url]); + + // Now nothing should be completed. + context = createContext(search, { isPrivate: false }); + if (origins) { + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } else { + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://" + search, + fallbackTitle: "http://" + search, + iconUri: `page-icon:http://${host}/`, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } + + await cleanup(); +}); + +// Bookmarked places should always be autofilled, even when they don't meet +// the threshold. +add_autofill_task(async function bookmarkBelowThreshold() { + // Add some visits to a URL so that the origin autofill threshold is large. + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits([ + { + uri: "http://not-" + url, + }, + ]); + } + + // Now bookmark another URL. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + // Make sure the bookmarked origin and place frecencies are below the + // threshold so that the origin/URL otherwise would not be autofilled. + let placeFrecency = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: "http://" + url } + ); + let originFrecency = await getOriginFrecency("http://", host); + let threshold = await getOriginAutofillThreshold(); + Assert.ok( + placeFrecency < threshold, + `Place frecency should be below the threshold: ` + + `placeFrecency=${placeFrecency} threshold=${threshold}` + ); + Assert.ok( + originFrecency < threshold, + `Origin frecency should be below the threshold: ` + + `originFrecency=${originFrecency} threshold=${threshold}` + ); + + // The bookmark should be autofilled. + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: "A bookmark", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://not-" + url, + title: "test visit for http://not-" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + + await cleanup(); +}); + +// Bookmarked places should be autofilled when they *do* meet the threshold. +add_autofill_task(async function bookmarkAboveThreshold() { + // Bookmark a URL. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + // The frecencies of the place and origin should be >= the threshold. In + // fact they should be the same as the threshold since the place is the only + // place in the database. + let placeFrecency = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: "http://" + url } + ); + let originFrecency = await getOriginFrecency("http://", host); + let threshold = await getOriginAutofillThreshold(); + Assert.equal(placeFrecency, threshold); + Assert.equal(originFrecency, threshold); + + // The bookmark should be autofilled. + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: "A bookmark", + heuristic: true, + }), + ], + }); + + await cleanup(); +}); + +// Bookmark a page and then clear history. +// The bookmarked origin/URL should still be autofilled. +add_autofill_task(async function zeroThreshold() { + const pageUrl = "http://" + url; + await PlacesTestUtils.addBookmarkWithDetails({ + uri: pageUrl, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + await PlacesUtils.history.clear(); + await PlacesUtils.withConnectionWrapper("zeroThreshold", async db => { + await db.execute("UPDATE moz_places SET frecency = -1 WHERE url = :url", { + url: pageUrl, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + }); + + // Make sure the place's frecency is -1. + let placeFrecency = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: pageUrl } + ); + Assert.equal(placeFrecency, -1); + + // Make sure the origin's frecency is 0. + let originFrecency = await getOriginFrecency("http://", host); + Assert.equal(originFrecency, 0); + + // Make sure the autofill threshold is 0. + let threshold = await getOriginAutofillThreshold(); + Assert.equal(threshold, 0); + + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: "A bookmark", + heuristic: true, + }), + ], + }); + + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = false +// suggest.bookmark = true +// search for: visit +// prefix search: no +// prefix matches search: n/a +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestHistoryFalse_visit() { + await PlacesTestUtils.addVisits("http://" + url); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + context = createContext(search, { isPrivate: false }); + if (origins) { + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } else { + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://" + search, + fallbackTitle: "http://" + search, + iconUri: `page-icon:http://${host}/`, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = false +// suggest.bookmark = true +// search for: visit +// prefix search: yes +// prefix matches search: yes +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestHistoryFalse_visit_prefix() { + await PlacesTestUtils.addVisits("http://" + url); + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "http://" + url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + context = createContext(search, { isPrivate: false }); + if (origins) { + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } else { + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://" + search, + fallbackTitle: "http://" + search, + iconUri: `page-icon:http://${host}/`, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = false +// suggest.bookmark = true +// search for: bookmark +// prefix search: no +// prefix matches search: n/a +// origin matches search: yes +// +// Expected result: +// should autofill: yes +add_autofill_task(async function suggestHistoryFalse_bookmark_0() { + // Add the bookmark. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + // Make the bookmark fall below the autofill frecency threshold so we ensure + // the bookmark is always autofilled in this case, even if it doesn't meet + // the threshold. + await TestUtils.waitForCondition(async () => { + // Add a visit to another origin to boost the threshold. + await PlacesTestUtils.addVisits("http://foo-" + url); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + let originFrecency = await getOriginFrecency("http://", host); + let threshold = await getOriginAutofillThreshold(); + return threshold > originFrecency; + }, "Make the bookmark fall below the frecency threshold"); + + // At this point, the bookmark doesn't meet the threshold, but it should + // still be autofilled. + let originFrecency = await getOriginFrecency("http://", host); + let threshold = await getOriginAutofillThreshold(); + Assert.ok(originFrecency < threshold); + + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: "A bookmark", + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = false +// suggest.bookmark = true +// search for: bookmark +// prefix search: no +// prefix matches search: n/a +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestHistoryFalse_bookmark_1() { + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://non-matching-" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + let context = createContext(search, { isPrivate: false }); + let matches = [ + makeBookmarkResult(context, { + uri: "http://non-matching-" + url, + title: "A bookmark", + }), + ]; + if (origins) { + matches.unshift( + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }) + ); + } else { + matches.unshift( + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://" + search, + fallbackTitle: "http://" + search, + iconUri: `page-icon:http://${host}/`, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }) + ); + } + await check_results({ + context, + matches, + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = false +// suggest.bookmark = true +// search for: bookmark +// prefix search: yes +// prefix matches search: yes +// origin matches search: yes +// +// Expected result: +// should autofill: yes +add_autofill_task(async function suggestHistoryFalse_bookmark_prefix_0() { + // Add the bookmark. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + // Make the bookmark fall below the autofill frecency threshold so we ensure + // the bookmark is always autofilled in this case, even if it doesn't meet + // the threshold. + await TestUtils.waitForCondition(async () => { + // Add a visit to another origin to boost the threshold. + await PlacesTestUtils.addVisits("http://foo-" + url); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + let originFrecency = await getOriginFrecency("http://", host); + let threshold = await getOriginAutofillThreshold(); + return threshold > originFrecency; + }, "Make the bookmark fall below the frecency threshold"); + + // At this point, the bookmark doesn't meet the threshold, but it should + // still be autofilled. + let originFrecency = await getOriginFrecency("http://", host); + let threshold = await getOriginAutofillThreshold(); + Assert.ok(originFrecency < threshold); + + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "http://" + url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: "A bookmark", + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = false +// suggest.bookmark = true +// search for: bookmark +// prefix search: yes +// prefix matches search: no +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestHistoryFalse_bookmark_prefix_1() { + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "ftp://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeBookmarkResult(context, { + uri: "ftp://" + url, + title: "A bookmark", + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = false +// suggest.bookmark = true +// search for: bookmark +// prefix search: yes +// prefix matches search: yes +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestHistoryFalse_bookmark_prefix_2() { + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://non-matching-" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeBookmarkResult(context, { + uri: "http://non-matching-" + url, + title: "A bookmark", + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = false +// suggest.bookmark = true +// search for: bookmark +// prefix search: yes +// prefix matches search: no +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestHistoryFalse_bookmark_prefix_3() { + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "ftp://non-matching-" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeBookmarkResult(context, { + uri: "ftp://non-matching-" + url, + title: "A bookmark", + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visit +// prefix search: no +// prefix matches search: n/a +// origin matches search: yes +// +// Expected result: +// should autofill: yes +add_autofill_task(async function suggestBookmarkFalse_visit_0() { + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + await PlacesTestUtils.addVisits("http://" + url); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visit +// prefix search: no +// prefix matches search: n/a +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestBookmarkFalse_visit_1() { + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + await PlacesTestUtils.addVisits("http://non-matching-" + url); + let context = createContext(search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + let matches = [ + makeVisitResult(context, { + uri: "http://non-matching-" + url, + title: "test visit for http://non-matching-" + url, + providerName: PLACES_PROVIDERNAME, + }), + ]; + if (origins) { + matches.unshift( + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }) + ); + } else { + matches.unshift( + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }) + ); + } + await check_results({ + context, + matches, + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visit +// prefix search: yes +// prefix matches search: yes +// origin matches search: yes +// +// Expected result: +// should autofill: yes +add_autofill_task(async function suggestBookmarkFalse_visit_prefix_0() { + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + await PlacesTestUtils.addVisits("http://" + url); + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "http://" + url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visit +// prefix search: yes +// prefix matches search: no +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestBookmarkFalse_visit_prefix_1() { + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + await PlacesTestUtils.addVisits("ftp://" + url); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://" + url, + title: "test visit for ftp://" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visit +// prefix search: yes +// prefix matches search: yes +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestBookmarkFalse_visit_prefix_2() { + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + await PlacesTestUtils.addVisits("http://non-matching-" + url); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://non-matching-" + url, + title: "test visit for http://non-matching-" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visit +// prefix search: yes +// prefix matches search: no +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestBookmarkFalse_visit_prefix_3() { + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + await PlacesTestUtils.addVisits("ftp://non-matching-" + url); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://non-matching-" + url, + title: "test visit for ftp://non-matching-" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: unvisited bookmark +// prefix search: no +// prefix matches search: n/a +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestBookmarkFalse_unvisitedBookmark() { + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: "A bookmark", + heuristic: true, + }), + ], + }); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + context = createContext(search, { isPrivate: false }); + if (origins) { + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } else { + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://" + search, + fallbackTitle: "http://" + search, + iconUri: `page-icon:http://${host}/`, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: unvisited bookmark +// prefix search: yes +// prefix matches search: yes +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_unvisitedBookmark_prefix_0() { + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "http://" + url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: "A bookmark", + heuristic: true, + }), + ], + }); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + await cleanup(); + } +); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: unvisited bookmark +// prefix search: yes +// prefix matches search: no +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_unvisitedBookmark_prefix_1() { + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "ftp://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + await cleanup(); + } +); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: unvisited bookmark +// prefix search: yes +// prefix matches search: yes +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_unvisitedBookmark_prefix_2() { + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://non-matching-" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + await cleanup(); + } +); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: unvisited bookmark +// prefix search: yes +// prefix matches search: no +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_unvisitedBookmark_prefix_3() { + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "ftp://non-matching-" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + await cleanup(); + } +); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visited bookmark above autofill threshold +// prefix search: no +// prefix matches search: n/a +// origin matches search: yes +// +// Expected result: +// should autofill: yes +add_autofill_task(async function suggestBookmarkFalse_visitedBookmark_above() { + await PlacesTestUtils.addVisits("http://" + url); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visited bookmark above autofill threshold +// prefix search: yes +// prefix matches search: yes +// origin matches search: yes +// +// Expected result: +// should autofill: yes +add_autofill_task( + async function suggestBookmarkFalse_visitedBookmarkAbove_prefix_0() { + await PlacesTestUtils.addVisits("http://" + url); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "http://" + url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + await cleanup(); + } +); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visited bookmark above autofill threshold +// prefix search: yes +// prefix matches search: no +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_visitedBookmarkAbove_prefix_1() { + await PlacesTestUtils.addVisits("ftp://" + url); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "ftp://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeBookmarkResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "ftp://" + url, + title: "A bookmark", + }), + ], + }); + await cleanup(); + } +); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visited bookmark above autofill threshold +// prefix search: yes +// prefix matches search: yes +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_visitedBookmarkAbove_prefix_2() { + await PlacesTestUtils.addVisits("http://non-matching-" + url); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://non-matching-" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeBookmarkResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "http://non-matching-" + url, + title: "A bookmark", + }), + ], + }); + await cleanup(); + } +); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visited bookmark above autofill threshold +// prefix search: yes +// prefix matches search: no +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_visitedBookmarkAbove_prefix_3() { + await PlacesTestUtils.addVisits("ftp://non-matching-" + url); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "ftp://non-matching-" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeBookmarkResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "ftp://non-matching-" + url, + title: "A bookmark", + }), + ], + }); + await cleanup(); + } +); + +// The following suggestBookmarkFalse_visitedBookmarkBelow* tests are similar +// to the suggestBookmarkFalse_visitedBookmarkAbove* tests, but instead of +// checking visited bookmarks above the autofill threshold, they check visited +// bookmarks below the threshold. These tests don't make sense for URL +// queries (as opposed to origin queries) because URL queries don't use the +// same autofill threshold, so we skip them when !origins. + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visited bookmark below autofill threshold +// prefix search: no +// prefix matches search: n/a +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestBookmarkFalse_visitedBookmarkBelow() { + if (!origins) { + // See comment above suggestBookmarkFalse_visitedBookmarkBelow. + return; + } + // First, make sure that `url` is below the autofill threshold. + await PlacesTestUtils.addVisits("http://" + url); + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits("http://some-other-" + url); + } + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://some-other-" + url, + title: "test visit for http://some-other-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://" + url, + title: "test visit for http://" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + // Now bookmark it and set suggest.bookmark to false. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://some-other-" + url, + title: "test visit for http://some-other-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://" + url, + title: "A bookmark", + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visited bookmark below autofill threshold +// prefix search: yes +// prefix matches search: yes +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_visitedBookmarkBelow_prefix_0() { + if (!origins) { + // See comment above suggestBookmarkFalse_visitedBookmarkBelow. + return; + } + // First, make sure that `url` is below the autofill threshold. + await PlacesTestUtils.addVisits("http://" + url); + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits("http://some-other-" + url); + } + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${search}/`, + fallbackTitle: `http://${search}/`, + heuristic: true, + iconUri: "", + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://some-other-" + url, + title: "test visit for http://some-other-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://" + url, + title: "test visit for http://" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + // Now bookmark it and set suggest.bookmark to false. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${search}/`, + fallbackTitle: `http://${search}/`, + heuristic: true, + iconUri: "", + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://some-other-" + url, + title: "test visit for http://some-other-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://" + url, + title: "A bookmark", + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanup(); + } +); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visited bookmark below autofill threshold +// prefix search: yes +// prefix matches search: no +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_visitedBookmarkBelow_prefix_1() { + if (!origins) { + // See comment above suggestBookmarkFalse_visitedBookmarkBelow. + return; + } + // First, make sure that `url` is below the autofill threshold. + await PlacesTestUtils.addVisits("ftp://" + url); + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits("ftp://some-other-" + url); + } + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${search}/`, + fallbackTitle: `http://${search}/`, + heuristic: true, + iconUri: "", + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://some-other-" + url, + title: "test visit for ftp://some-other-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://" + url, + title: "test visit for ftp://" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + // Now bookmark it and set suggest.bookmark to false. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "ftp://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${search}/`, + fallbackTitle: `http://${search}/`, + heuristic: true, + iconUri: "", + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://some-other-" + url, + title: "test visit for ftp://some-other-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://" + url, + title: "A bookmark", + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanup(); + } +); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visited bookmark below autofill threshold +// prefix search: yes +// prefix matches search: yes +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_visitedBookmarkBelow_prefix_2() { + if (!origins) { + // See comment above suggestBookmarkFalse_visitedBookmarkBelow. + return; + } + // First, make sure that `url` is below the autofill threshold. + await PlacesTestUtils.addVisits("http://non-matching-" + url); + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits("http://some-other-" + url); + } + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${search}/`, + fallbackTitle: `http://${search}/`, + heuristic: true, + iconUri: "", + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://some-other-" + url, + title: "test visit for http://some-other-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://non-matching-" + url, + title: "test visit for http://non-matching-" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + // Now bookmark it and set suggest.bookmark to false. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://non-matching-" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${search}/`, + fallbackTitle: `http://${search}/`, + heuristic: true, + iconUri: "", + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://some-other-" + url, + title: "test visit for http://some-other-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://non-matching-" + url, + title: "A bookmark", + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanup(); + } +); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visited bookmark below autofill threshold +// prefix search: yes +// prefix matches search: no +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_visitedBookmarkBelow_prefix_3() { + if (!origins) { + // See comment above suggestBookmarkFalse_visitedBookmarkBelow. + return; + } + // First, make sure that `url` is below the autofill threshold. + await PlacesTestUtils.addVisits("ftp://non-matching-" + url); + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits("ftp://some-other-" + url); + } + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${search}/`, + fallbackTitle: `http://${search}/`, + heuristic: true, + iconUri: "", + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://some-other-" + url, + title: "test visit for ftp://some-other-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://non-matching-" + url, + title: "test visit for ftp://non-matching-" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + // Now bookmark it and set suggest.bookmark to false. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "ftp://non-matching-" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${search}/`, + fallbackTitle: `http://${search}/`, + heuristic: true, + iconUri: "", + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://some-other-" + url, + title: "test visit for ftp://some-other-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://non-matching-" + url, + title: "A bookmark", + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanup(); + } +); + +// When the heuristic is hidden, "ex" should autofill http://example.com/, and +// there should be an additional http://example.com/ non-autofill result. +add_autofill_task(async function hideHeuristic() { + UrlbarPrefs.set("experimental.hideHeuristic", true); + await PlacesTestUtils.addVisits("http://" + url); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://" + url, + title: "test visit for http://" + url, + }), + ], + }); + await cleanup(); + UrlbarPrefs.set("experimental.hideHeuristic", false); +}); diff --git a/browser/components/urlbar/tests/unit/test_autofill_origins_alt_frecency.js b/browser/components/urlbar/tests/unit/test_autofill_origins_alt_frecency.js new file mode 100644 index 0000000000..41ff69acf2 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_origins_alt_frecency.js @@ -0,0 +1,272 @@ +/* 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 is a basic autofill test to ensure enabling the alternative frecency +// algorithm doesn't break autofill or tab-to-search. A more comprehensive +// testing of the algorithm itself is not included since it's something that +// may change frequently according to experimentation results. +// Other existing autofill tests will, of course, need to be adapted once an +// algorithm is promoted to be the default. + +ChromeUtils.defineLazyGetter(this, "PlacesFrecencyRecalculator", () => { + return Cc["@mozilla.org/places/frecency-recalculator;1"].getService( + Ci.nsIObserver + ).wrappedJSObject; +}); + +testEngine_setup(); + +add_task(async function test_autofill() { + const origin = "example.com"; + let context = createContext(origin.substring(0, 2), { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + ], + }); + // Add many visits. + const url = `https://${origin}/`; + await PlacesTestUtils.addVisits(new Array(10).fill(url)); + Assert.equal( + await PlacesUtils.metadata.get("origin_alt_frecency_threshold", 0), + 0, + "Check there's no threshold initially" + ); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Assert.greater( + await PlacesUtils.metadata.get("origin_alt_frecency_threshold", 0), + 0, + "Check a threshold has been calculated" + ); + await check_results({ + context, + autofilled: `${origin}/`, + completed: url, + matches: [ + makeVisitResult(context, { + uri: url, + title: `test visit for ${url}`, + heuristic: true, + }), + ], + }); + await PlacesUtils.history.clear(); +}); + +add_task(async function test_autofill_www() { + const origin = "example.com"; + // Add many visits. + const url = `https://www.${origin}/`; + await PlacesTestUtils.addVisits(new Array(10).fill(url)); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + let context = createContext(origin.substring(0, 2), { isPrivate: false }); + await check_results({ + context, + autofilled: `${origin}/`, + completed: url, + matches: [ + makeVisitResult(context, { + uri: url, + title: `test visit for ${url}`, + heuristic: true, + }), + ], + }); + await PlacesUtils.history.clear(); +}); + +add_task( + { + pref_set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 0]], + }, + async function test_autofill_prefix_priority() { + const origin = "localhost"; + const url = `https://${origin}/`; + await PlacesTestUtils.addVisits([url, `http://${origin}/`]); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + let engine = Services.search.defaultEngine; + let context = createContext(origin.substring(0, 2), { isPrivate: false }); + await check_results({ + context, + autofilled: `${origin}/`, + completed: url, + matches: [ + makeVisitResult(context, { + uri: url, + title: `test visit for ${url}`, + heuristic: true, + }), + makeSearchResult(context, { + engineName: engine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost(engine.searchUrlDomain), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); + await PlacesUtils.history.clear(); + } +); + +add_task(async function test_autofill_threshold() { + await PlacesTestUtils.addVisits(new Array(10).fill("https://example.com/")); + // Add more visits to the same origins to differenciate the frecency scores. + await PlacesTestUtils.addVisits([ + "https://example.com/2", + "https://example.com/3", + ]); + await PlacesTestUtils.addVisits("https://somethingelse.org/"); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + let threshold = await PlacesUtils.metadata.get( + "origin_alt_frecency_threshold", + 0 + ); + Assert.greater( + threshold, + await PlacesTestUtils.getDatabaseValue("moz_origins", "alt_frecency", { + host: "somethingelse.org", + }), + "Check mozilla.org has a lower frecency than the threshold" + ); + Assert.equal( + threshold, + await PlacesTestUtils.getDatabaseValue("moz_origins", "avg(alt_frecency)"), + "Check the threshold has been calculared correctly" + ); + + let engine = Services.search.defaultEngine; + let context = createContext("so", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: "so", + engineName: engine.name, + }), + makeVisitResult(context, { + uri: "https://somethingelse.org/", + title: "test visit for https://somethingelse.org/", + }), + ], + }); + await PlacesUtils.history.clear(); +}); + +add_task(async function test_autofill_cutoff() { + // Add many visits older than the default 90 days cutoff. + const visitDate = new Date(Date.now() - 120 * 86400000); + await PlacesTestUtils.addVisits( + new Array(10).fill("https://example.com/").map(url => ({ url, visitDate })) + ); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + Assert.strictEqual( + await PlacesTestUtils.getDatabaseValue("moz_origins", "alt_frecency", { + host: "example.com", + }), + null, + "Check example.com has a NULL frecency" + ); + + let engine = Services.search.defaultEngine; + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: "ex", + engineName: engine.name, + }), + makeVisitResult(context, { + uri: "https://example.com/", + title: "test visit for https://example.com/", + }), + ], + }); + await PlacesUtils.history.clear(); +}); + +add_task(async function test_autofill_threshold_www() { + // Only one visit to the non-www origin, many to the www. version. We expect + // example.com to autofill even if its frecency is small, because the overall + // frecency for both origins should be considered. + await PlacesTestUtils.addVisits("https://example.com/"); + await PlacesTestUtils.addVisits( + new Array(10).fill("https://www.example.com/") + ); + await PlacesTestUtils.addVisits( + new Array(10).fill("https://www.somethingelse.org/") + ); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + let threshold = await PlacesUtils.metadata.get( + "origin_alt_frecency_threshold", + 0 + ); + let frecencyOfExampleCom = await PlacesTestUtils.getDatabaseValue( + "moz_origins", + "alt_frecency", + { + host: "example.com", + } + ); + let frecencyOfWwwExampleCom = await PlacesTestUtils.getDatabaseValue( + "moz_origins", + "alt_frecency", + { + host: "www.example.com", + } + ); + Assert.greater( + threshold, + frecencyOfExampleCom, + "example.com frecency is lower than the threshold" + ); + Assert.greater( + frecencyOfWwwExampleCom, + threshold, + "www.example.com frecency is higher than the threshold" + ); + + // We used to wrongly use the average between the 2 domains, so check also + // the average would not autofill. + Assert.greater( + threshold, + [frecencyOfExampleCom, frecencyOfWwwExampleCom].reduce( + (acc, v, i, arr) => acc + v / arr.length, + 0 + ), + "Check frecency average is lower than the threshold" + ); + + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/", + completed: "https://www.example.com/", + matches: [ + makeVisitResult(context, { + uri: "https://www.example.com/", + title: "test visit for https://www.example.com/", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://example.com/", + title: "test visit for https://example.com/", + }), + ], + }); + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/urlbar/tests/unit/test_autofill_prefix_fallback.js b/browser/components/urlbar/tests/unit/test_autofill_prefix_fallback.js new file mode 100644 index 0000000000..9ebee29cc2 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_prefix_fallback.js @@ -0,0 +1,76 @@ +/* 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 tests autofill prefix fallback in case multiple origins have the same +// exact frecency. +// We should prefer https, or in case of other prefixes just sort by descending +// id. + +add_task(async function () { + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + }); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + + let host = "example.com"; + let prefixes = ["https://", "https://www.", "http://", "http://www."]; + for (let prefix of prefixes) { + await PlacesUtils.bookmarks.insert({ + url: `${prefix}${host}`, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + } + await checkOriginsOrder(host, prefixes); + + // The https://www version should be filled because it's https and the www + // version has been added later so it has an higher id. + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + autofilled: `${host}/`, + completed: `https://www.${host}/`, + matches: [ + makeVisitResult(context, { + uri: `https://www.${host}/`, + fallbackTitle: UrlbarTestUtils.trimURL(`https://www.${host}`), + heuristic: true, + }), + makeBookmarkResult(context, { + uri: `https://${host}/`, + title: `${host}`, + }), + ], + }); + + // Remove and reinsert bookmarks in another order. + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + prefixes = ["https://www.", "http://", "https://", "http://www."]; + for (let prefix of prefixes) { + await PlacesUtils.bookmarks.insert({ + url: `${prefix}${host}`, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + } + await checkOriginsOrder(host, prefixes); + + await check_results({ + context, + autofilled: `${host}/`, + completed: `https://${host}/`, + matches: [ + makeVisitResult(context, { + uri: `https://${host}/`, + fallbackTitle: UrlbarTestUtils.trimURL(`https://${host}`), + heuristic: true, + }), + makeBookmarkResult(context, { + uri: `https://www.${host}/`, + title: `www.${host}`, + }), + ], + }); +}); diff --git a/browser/components/urlbar/tests/unit/test_autofill_search_engine_aliases.js b/browser/components/urlbar/tests/unit/test_autofill_search_engine_aliases.js new file mode 100644 index 0000000000..40df51ecf3 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_search_engine_aliases.js @@ -0,0 +1,85 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Tests autofilling search engine token ("@") aliases. + +"use strict"; + +const TEST_ENGINE_NAME = "test autofill aliases"; +const TEST_ENGINE_ALIAS = "@autofilltest"; + +add_setup(async () => { + // Add an engine with an "@" alias. + await SearchTestUtils.installSearchExtension({ + name: TEST_ENGINE_NAME, + keyword: TEST_ENGINE_ALIAS, + }); +}); + +// Searching for @autofi should autofill to @autofilltest. +add_task(async function basic() { + // Add a history visit that should normally match but for the fact that the + // search uses an @ alias. When an @ alias is autofilled, there should be no + // other matches except the autofill heuristic match. + await PlacesTestUtils.addVisits({ + uri: "http://example.com/", + title: TEST_ENGINE_ALIAS, + }); + + let search = TEST_ENGINE_ALIAS.substr( + 0, + Math.round(TEST_ENGINE_ALIAS.length / 2) + ); + let autofilledValue = TEST_ENGINE_ALIAS + " "; + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: autofilledValue, + matches: [ + makeSearchResult(context, { + engineName: TEST_ENGINE_NAME, + alias: TEST_ENGINE_ALIAS, + query: "", + providesSearchMode: true, + heuristic: false, + }), + ], + }); + await cleanupPlaces(); +}); + +// Searching for @AUTOFI should autofill to @AUTOFIlltest, preserving the case +// in the search string. +add_task(async function preserveCase() { + // Add a history visit that should normally match but for the fact that the + // search uses an @ alias. When an @ alias is autofilled, there should be no + // other matches except the autofill heuristic match. + await PlacesTestUtils.addVisits({ + uri: "http://example.com/", + title: TEST_ENGINE_ALIAS, + }); + + let search = TEST_ENGINE_ALIAS.toUpperCase().substr( + 0, + Math.round(TEST_ENGINE_ALIAS.length / 2) + ); + let alias = search + TEST_ENGINE_ALIAS.substr(search.length); + + let autofilledValue = alias + " "; + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: autofilledValue, + matches: [ + makeSearchResult(context, { + engineName: TEST_ENGINE_NAME, + alias, + query: "", + providesSearchMode: true, + heuristic: false, + }), + ], + }); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_autofill_urls.js b/browser/components/urlbar/tests/unit/test_autofill_urls.js new file mode 100644 index 0000000000..9805dc9ffc --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_urls.js @@ -0,0 +1,916 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const HEURISTIC_FALLBACK_PROVIDERNAME = "HeuristicFallback"; +const PLACES_PROVIDERNAME = "Places"; + +// "example.com/foo/" should match http://example.com/foo/. +testEngine_setup(); + +registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); +}); +Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + +add_task(async function multipleSlashes() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/foo/", + }, + ]); + let context = createContext("example.com/foo/", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/foo/", + completed: "http://example.com/foo/", + matches: [ + makeVisitResult(context, { + uri: "http://example.com/foo/", + title: "test visit for http://example.com/foo/", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +// "example.com:8888/f" should match http://example.com:8888/foo. +add_task(async function port() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com:8888/foo", + }, + ]); + let context = createContext("example.com:8888/f", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com:8888/foo", + completed: "http://example.com:8888/foo", + matches: [ + makeVisitResult(context, { + uri: "http://example.com:8888/foo", + title: "test visit for http://example.com:8888/foo", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +// "example.com:8999/f" should *not* autofill http://example.com:8888/foo. +add_task(async function portNoMatch() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com:8888/foo", + }, + ]); + let context = createContext("example.com:8999/f", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://example.com:8999/f", + fallbackTitle: "http://example.com:8999/f", + iconUri: "page-icon:http://example.com:8999/", + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +// autofill to the next slash +add_task(async function port() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com:8888/foo/bar/baz", + }, + ]); + let context = createContext("example.com:8888/foo/b", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com:8888/foo/bar/", + completed: "http://example.com:8888/foo/bar/", + matches: [ + makeVisitResult(context, { + uri: "http://example.com:8888/foo/bar/", + fallbackTitle: UrlbarTestUtils.trimURL( + "http://example.com:8888/foo/bar/" + ), + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://example.com:8888/foo/bar/baz", + title: "test visit for http://example.com:8888/foo/bar/baz", + tags: [], + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +// autofill to the next slash, end of url +add_task(async function port() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com:8888/foo/bar/baz", + }, + ]); + let context = createContext("example.com:8888/foo/bar/b", { + isPrivate: false, + }); + await check_results({ + context, + autofilled: "example.com:8888/foo/bar/baz", + completed: "http://example.com:8888/foo/bar/baz", + matches: [ + makeVisitResult(context, { + uri: "http://example.com:8888/foo/bar/baz", + title: "test visit for http://example.com:8888/foo/bar/baz", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +// autofill with case insensitive from history and bookmark. +add_task(async function caseInsensitiveFromHistoryAndBookmark() { + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", true); + + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/foo", + }, + ]); + + await testCaseInsensitive(); + + Services.prefs.clearUserPref("browser.urlbar.suggest.bookmark"); + Services.prefs.clearUserPref("browser.urlbar.suggest.history"); + await cleanupPlaces(); +}); + +// autofill with case insensitive from history. +add_task(async function caseInsensitiveFromHistory() { + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", true); + + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/foo", + }, + ]); + + await testCaseInsensitive(); + + Services.prefs.clearUserPref("browser.urlbar.suggest.bookmark"); + Services.prefs.clearUserPref("browser.urlbar.suggest.history"); + await cleanupPlaces(); +}); + +// autofill with case insensitive from bookmark. +add_task(async function caseInsensitiveFromBookmark() { + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://example.com/foo", + }); + + await testCaseInsensitive(true); + + Services.prefs.clearUserPref("browser.urlbar.suggest.bookmark"); + Services.prefs.clearUserPref("browser.urlbar.suggest.history"); + await cleanupPlaces(); +}); + +// should *not* autofill if the URI fragment does not match with case-sensitive. +add_task(async function uriFragmentCaseSensitiveNoMatch() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/#TEST", + }, + ]); + const context = createContext("http://example.com/#t", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://example.com/#t", + fallbackTitle: "http://example.com/#t", + heuristic: true, + }), + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "http://example.com/#TEST", + title: "test visit for http://example.com/#TEST", + tags: [], + }), + ], + }); + + await cleanupPlaces(); +}); + +// should autofill if the URI fragment matches with case-sensitive. +add_task(async function uriFragmentCaseSensitive() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/#TEST", + }, + ]); + const context = createContext("http://example.com/#T", { isPrivate: false }); + await check_results({ + context, + autofilled: "http://example.com/#TEST", + completed: "http://example.com/#TEST", + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "http://example.com/#TEST", + title: "test visit for http://example.com/#TEST", + heuristic: true, + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function uriCase() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/ABC/DEF", + }, + ]); + + const testData = [ + { + input: "example.COM", + expected: { + autofilled: "example.COM/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.com/"), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + { + input: "example.COM/", + expected: { + autofilled: "example.COM/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.com/", { + removeSingleTrailingSlash: false, + }), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + { + input: "example.COM/a", + expected: { + autofilled: "example.COM/aBC/", + completed: "http://example.com/ABC/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.com/ABC/"), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + { + input: "example.com/ab", + expected: { + autofilled: "example.com/abC/", + completed: "http://example.com/ABC/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.com/ABC/"), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + { + input: "example.com/abc", + expected: { + autofilled: "example.com/abc/", + completed: "http://example.com/ABC/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.com/ABC/"), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + { + input: "example.com/abc/", + expected: { + autofilled: "example.com/abc/", + completed: "http://example.com/abc/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/abc/", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.com/abc/"), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + { + input: "example.com/abc/d", + expected: { + autofilled: "example.com/abc/dEF", + completed: "http://example.com/ABC/DEF", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + heuristic: true, + }), + ], + }, + }, + { + input: "example.com/abc/de", + expected: { + autofilled: "example.com/abc/deF", + completed: "http://example.com/ABC/DEF", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + heuristic: true, + }), + ], + }, + }, + { + input: "example.com/abc/def", + expected: { + autofilled: "example.com/abc/def", + completed: "http://example.com/abc/def", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/abc/def", + fallbackTitle: UrlbarTestUtils.trimURL( + "http://example.com/abc/def" + ), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + { + input: "http://example.com/a", + expected: { + autofilled: "http://example.com/aBC/", + completed: "http://example.com/ABC/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.com/ABC/"), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + { + input: "http://example.com/abc/", + expected: { + autofilled: "http://example.com/abc/", + completed: "http://example.com/abc/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/abc/", + fallbackTitle: UrlbarTestUtils.trimURL("http://example.com/abc/"), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + { + input: "http://example.com/abc/d", + expected: { + autofilled: "http://example.com/abc/dEF", + completed: "http://example.com/ABC/DEF", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + heuristic: true, + }), + ], + }, + }, + { + input: "http://example.com/abc/def", + expected: { + autofilled: "http://example.com/abc/def", + completed: "http://example.com/abc/def", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/abc/def", + fallbackTitle: UrlbarTestUtils.trimURL( + "http://example.com/abc/def" + ), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + { + input: "http://eXAMple.com/ABC/DEF", + expected: { + autofilled: "http://eXAMple.com/ABC/DEF", + completed: "http://example.com/ABC/DEF", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + heuristic: true, + }), + ], + }, + }, + { + input: "http://eXAMple.com/abc/def", + expected: { + autofilled: "http://eXAMple.com/abc/def", + completed: "http://example.com/abc/def", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/abc/def", + fallbackTitle: UrlbarTestUtils.trimURL( + "http://example.com/abc/def" + ), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + ]; + + for (const { input, expected } of testData) { + const context = createContext(input, { + isPrivate: false, + }); + await check_results({ + context, + autofilled: expected.autofilled, + completed: expected.completed, + matches: expected.results.map(f => f(context)), + }); + } + + await cleanupPlaces(); +}); + +async function testCaseInsensitive(isBookmark = false) { + const testData = [ + { + input: "example.com/F", + expectedAutofill: "example.com/Foo", + }, + { + // Test with prefix. + input: "http://example.com/F", + expectedAutofill: "http://example.com/Foo", + }, + ]; + + for (const { input, expectedAutofill } of testData) { + const context = createContext(input, { + isPrivate: false, + }); + await check_results({ + context, + autofilled: expectedAutofill, + completed: "http://example.com/foo", + matches: [ + makeVisitResult(context, { + uri: "http://example.com/foo", + title: isBookmark + ? "A bookmark" + : "test visit for http://example.com/foo", + heuristic: true, + }), + ], + }); + } + + await cleanupPlaces(); +} + +// Checks a URL with an origin that looks like a prefix: a scheme with no dots + +// a port. +add_task(async function originLooksLikePrefix1() { + await PlacesTestUtils.addVisits([ + { + uri: "http://localhost:8888/foo", + }, + ]); + const context = createContext("localhost:8888/f", { isPrivate: false }); + await check_results({ + context, + autofilled: "localhost:8888/foo", + completed: "http://localhost:8888/foo", + matches: [ + makeVisitResult(context, { + uri: "http://localhost:8888/foo", + title: "test visit for http://localhost:8888/foo", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +// Same as previous (originLooksLikePrefix1) but uses a URL whose path has two +// slashes, not one. +add_task(async function originLooksLikePrefix2() { + await PlacesTestUtils.addVisits([ + { + uri: "http://localhost:8888/foo/bar", + }, + ]); + + let context = createContext("localhost:8888/f", { isPrivate: false }); + await check_results({ + context, + autofilled: "localhost:8888/foo/", + completed: "http://localhost:8888/foo/", + matches: [ + makeVisitResult(context, { + uri: "http://localhost:8888/foo/", + fallbackTitle: UrlbarTestUtils.trimURL("http://localhost:8888/foo/"), + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://localhost:8888/foo/bar", + title: "test visit for http://localhost:8888/foo/bar", + providerName: PLACES_PROVIDERNAME, + tags: [], + }), + ], + }); + + context = createContext("localhost:8888/foo/b", { isPrivate: false }); + await check_results({ + context, + autofilled: "localhost:8888/foo/bar", + completed: "http://localhost:8888/foo/bar", + matches: [ + makeVisitResult(context, { + uri: "http://localhost:8888/foo/bar", + title: "test visit for http://localhost:8888/foo/bar", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +// Checks view-source pages as a prefix +// Uses bookmark because addVisits does not allow non-http uri's +add_task(async function viewSourceAsPrefix() { + let address = "view-source:https://www.example.com/"; + let title = "A view source bookmark"; + await PlacesTestUtils.addBookmarkWithDetails({ + uri: address, + title, + }); + + let testData = [ + { + input: "view-source:h", + completed: "view-source:https:/", + autofilled: "view-source:https:/", + }, + { + input: "view-source:http", + completed: "view-source:https:/", + autofilled: "view-source:https:/", + }, + { + input: "VIEW-SOURCE:http", + completed: "view-source:https:/", + autofilled: "VIEW-SOURCE:https:/", + }, + ]; + + // Only autofills from view-source:h to view-source:https:/ + for (let { input, completed, autofilled } of testData) { + let context = createContext(input, { isPrivate: false }); + await check_results({ + context, + completed, + autofilled, + matches: [ + { + heuristic: true, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }, + makeBookmarkResult(context, { + uri: address, + iconUri: "chrome://global/skin/icons/defaultFavicon.svg", + title, + }), + ], + }); + } + + await cleanupPlaces(); +}); + +// Checks data url prefixes +// Uses bookmark because addVisits does not allow non-http uri's +add_task(async function dataAsPrefix() { + let address = "data:text/html,%3Ch1%3EHello%2C World!%3C%2Fh1%3E"; + let title = "A data url bookmark"; + await PlacesTestUtils.addBookmarkWithDetails({ + uri: address, + title, + }); + + let testData = [ + { + input: "data:t", + completed: "data:text/", + autofilled: "data:text/", + }, + { + input: "data:text", + completed: "data:text/", + autofilled: "data:text/", + }, + { + input: "DATA:text", + completed: "data:text/", + autofilled: "DATA:text/", + }, + ]; + + for (let { input, completed, autofilled } of testData) { + let context = createContext(input, { isPrivate: false }); + await check_results({ + context, + completed, + autofilled, + matches: [ + { + heuristic: true, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }, + makeBookmarkResult(context, { + uri: address, + iconUri: "chrome://global/skin/icons/defaultFavicon.svg", + title, + }), + ], + }); + } + + await cleanupPlaces(); +}); + +// Checks about prefixes +add_task(async function aboutAsPrefix() { + let testData = [ + { + input: "about:abou", + completed: "about:about", + autofilled: "about:about", + }, + { + input: "ABOUT:abou", + completed: "about:about", + autofilled: "ABOUT:about", + }, + ]; + + for (let { input, completed, autofilled } of testData) { + let context = createContext(input, { isPrivate: false }); + await check_results({ + context, + completed, + autofilled, + matches: [ + { + heuristic: true, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }, + ], + }); + } + + await cleanupPlaces(); +}); + +// Checks a URL that has www name in history. +add_task(async function wwwHistory() { + const testData = [ + { + input: "example.com/", + visitHistory: [{ uri: "http://www.example.com/", title: "Example" }], + expected: { + autofilled: "example.com/", + completed: "http://www.example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://www.example.com/", + title: "Example", + heuristic: true, + }), + ], + }, + }, + { + input: "https://example.com/", + visitHistory: [{ uri: "https://www.example.com/", title: "Example" }], + expected: { + autofilled: "https://example.com/", + completed: "https://www.example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "https://www.example.com/", + title: "Example", + heuristic: true, + }), + ], + }, + }, + { + input: "https://example.com/abc", + visitHistory: [{ uri: "https://www.example.com/abc", title: "Example" }], + expected: { + autofilled: "https://example.com/abc", + completed: "https://www.example.com/abc", + results: [ + context => + makeVisitResult(context, { + uri: "https://www.example.com/abc", + title: "Example", + heuristic: true, + }), + ], + }, + }, + { + input: "https://example.com/ABC", + visitHistory: [{ uri: "https://www.example.com/abc", title: "Example" }], + expected: { + autofilled: "https://example.com/ABC", + completed: "https://www.example.com/ABC", + results: [ + context => + makeVisitResult(context, { + uri: "https://www.example.com/ABC", + fallbackTitle: UrlbarTestUtils.trimURL( + "https://www.example.com/ABC" + ), + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "https://www.example.com/abc", + title: "Example", + }), + ], + }, + }, + ]; + + for (const { input, visitHistory, expected } of testData) { + await PlacesTestUtils.addVisits(visitHistory); + const context = createContext(input, { isPrivate: false }); + await check_results({ + context, + completed: expected.completed, + autofilled: expected.autofilled, + matches: expected.results.map(f => f(context)), + }); + await cleanupPlaces(); + } +}); + +add_task(async function formatPunycodeResultCorrectly() { + await PlacesTestUtils.addVisits([ + { + uri: `http://test.xn--e1afmkfd.com/`, + }, + ]); + let context = createContext("test", { isPrivate: false }); + await check_results({ + context, + autofilled: "test.xn--e1afmkfd.com/", + completed: "http://test.xn--e1afmkfd.com/", + matches: [ + makeVisitResult(context, { + uri: "http://test.xn--e1afmkfd.com/", + title: "test visit for http://test.xn--e1afmkfd.com/", + displayUrl: "http://test.пример.com", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_avoid_stripping_to_empty_tokens.js b/browser/components/urlbar/tests/unit/test_avoid_stripping_to_empty_tokens.js new file mode 100644 index 0000000000..b7c17d8cb3 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_avoid_stripping_to_empty_tokens.js @@ -0,0 +1,117 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +testEngine_setup(); + +add_task(async function test_protocol_trimming() { + for (let prot of ["http", "https"]) { + let visit = { + // Include the protocol in the query string to ensure we get matches (see bug 1059395) + uri: Services.io.newURI( + prot + + "://www.mozilla.org/test/?q=" + + prot + + encodeURIComponent("://") + + "www.foo" + ), + title: "Test title", + }; + await PlacesTestUtils.addVisits(visit); + + let input = prot + "://www."; + info("Searching for: " + input); + let context = createContext(input, { isPrivate: false }); + await check_results({ + context, + autofilled: prot + "://www.mozilla.org/", + completed: prot + "://www.mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: prot + "://www.mozilla.org/", + fallbackTitle: UrlbarTestUtils.trimURL(prot + "://www.mozilla.org"), + heuristic: true, + }), + makeVisitResult(context, { + uri: visit.uri.spec, + title: visit.title, + }), + ], + }); + + input = "www."; + info("Searching for: " + input); + context = createContext(input, { isPrivate: false }); + await check_results({ + context, + autofilled: "www.mozilla.org/", + completed: prot + "://www.mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: prot + "://www.mozilla.org/", + fallbackTitle: UrlbarTestUtils.trimURL(prot + "://www.mozilla.org"), + heuristic: true, + }), + makeVisitResult(context, { + uri: visit.uri.spec, + title: visit.title, + }), + ], + }); + + input = prot + "://www. "; + info("Searching for: " + input); + context = createContext(input, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `${input.trim()}/`, + fallbackTitle: `${input.trim()}/`, + iconUri: "", + heuristic: true, + providerName: "HeuristicFallback", + }), + makeVisitResult(context, { + uri: visit.uri.spec, + title: visit.title, + providerName: "Places", + }), + ], + }); + + let inputs = [ + prot + "://", + prot + ":// ", + prot + ":// mo", + prot + "://mo te", + prot + "://www. mo", + prot + "://www.mo te", + "www. ", + "www. mo", + "www.mo te", + ]; + for (input of inputs) { + info("Searching for: " + input); + context = createContext(input, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + query: input, + heuristic: true, + }), + makeVisitResult(context, { + uri: visit.uri.spec, + title: visit.title, + providerName: "Places", + }), + ], + }); + } + + await cleanupPlaces(); + } +}); diff --git a/browser/components/urlbar/tests/unit/test_calculator.js b/browser/components/urlbar/tests/unit/test_calculator.js new file mode 100644 index 0000000000..7fa899f320 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_calculator.js @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Calculator: "resource:///modules/UrlbarProviderCalculator.sys.mjs", +}); + +const FORMULAS = [ + ["1+1", 2], + ["3+4*2/(1-5)", 1], + ["39+4*2/(1-5)", 37], + ["(39+4)*2/(1-5)", -21.5], + ["4+-5", -1], + ["-5*6", -30], + ["-5.5*6", -33], + ["-5.5*-6.4", 35.2], + ["-6-6-6", -18], + ["6-6-6", -6], + [".001 /2", 0.0005], + ["(0-.001)/2", -0.0005], + ["-.001/(0-2)", 0.0005], + ["1000000000000000000000000+1", 1e24], + ["1000000000000000000000000-1", 1e24], + ["1e+30+10", 1e30], + ["1e+30*10", 1e31], + ["1e+30/100", 1e28], + ["10/1000000000000000000000000", 1e-23], + ["10/-1000000000000000000000000", -1e-23], + ["1,500.5+2.5", 1503], // Ignore commas when using decimal seperators + ["1,5+2,5", 4], // Support comma seperators + ["1.500,5+2,5", 1503], // Ignore periods when using comma decimal seperators +]; + +add_task(function test() { + for (let [formula, result] of FORMULAS) { + let postfix = Calculator.infix2postfix(formula); + Assert.equal( + Calculator.evaluatePostfix(postfix), + result, + `${formula} should equal ${result}` + ); + } +}); diff --git a/browser/components/urlbar/tests/unit/test_casing.js b/browser/components/urlbar/tests/unit/test_casing.js new file mode 100644 index 0000000000..0671b87a94 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_casing.js @@ -0,0 +1,370 @@ +/* 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 AUTOFILL_PROVIDERNAME = "Autofill"; +const PLACES_PROVIDERNAME = "Places"; + +testEngine_setup(); + +add_task(async function test_casing_1() { + info("Searching for cased entry 1"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/test/"), + }); + let context = createContext("MOZ", { isPrivate: false }); + await check_results({ + context, + autofilled: "MOZilla.org/", + completed: "http://mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/", + fallbackTitle: UrlbarTestUtils.trimURL("http://mozilla.org"), + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/test/", + title: "test visit for http://mozilla.org/test/", + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_casing_2() { + info("Searching for cased entry 2"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/test/"), + }); + let context = createContext("mozilla.org/T", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/Test/", + completed: "http://mozilla.org/test/", + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "http://mozilla.org/test/", + title: "test visit for http://mozilla.org/test/", + iconUri: "page-icon:http://mozilla.org/test/", + heuristic: true, + providerName: AUTOFILL_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_casing_3() { + info("Searching for cased entry 3"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/Test/"), + }); + let context = createContext("mozilla.org/T", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/Test/", + completed: "http://mozilla.org/Test/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/Test/", + title: "test visit for http://mozilla.org/Test/", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_casing_4() { + info("Searching for cased entry 4"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/Test/"), + }); + let context = createContext("mOzilla.org/t", { isPrivate: false }); + await check_results({ + context, + autofilled: "mOzilla.org/test/", + completed: "http://mozilla.org/Test/", + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "http://mozilla.org/Test/", + title: "test visit for http://mozilla.org/Test/", + iconUri: "page-icon:http://mozilla.org/Test/", + heuristic: true, + providerName: AUTOFILL_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_casing_5() { + info("Searching for cased entry 5"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/Test/"), + }); + let context = createContext("mOzilla.org/T", { isPrivate: false }); + await check_results({ + context, + autofilled: "mOzilla.org/Test/", + completed: "http://mozilla.org/Test/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/Test/", + title: "test visit for http://mozilla.org/Test/", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_casing() { + info("Searching for untrimmed cased entry"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/Test/"), + }); + let context = createContext("http://mOz", { isPrivate: false }); + await check_results({ + context, + autofilled: "http://mOzilla.org/", + completed: "http://mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/", + fallbackTitle: UrlbarTestUtils.trimURL("http://mozilla.org"), + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/Test/", + title: "test visit for http://mozilla.org/Test/", + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_www_casing() { + info("Searching for untrimmed cased entry with www"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://www.mozilla.org/Test/"), + }); + let context = createContext("http://www.mOz", { isPrivate: false }); + await check_results({ + context, + autofilled: "http://www.mOzilla.org/", + completed: "http://www.mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://www.mozilla.org/", + fallbackTitle: UrlbarTestUtils.trimURL("http://www.mozilla.org"), + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://www.mozilla.org/Test/", + title: "test visit for http://www.mozilla.org/Test/", + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_path_casing() { + info("Searching for untrimmed cased entry with path"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/Test/"), + }); + let context = createContext("http://mOzilla.org/t", { isPrivate: false }); + await check_results({ + context, + autofilled: "http://mOzilla.org/test/", + completed: "http://mozilla.org/Test/", + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "http://mozilla.org/Test/", + title: "test visit for http://mozilla.org/Test/", + iconUri: "page-icon:http://mozilla.org/Test/", + heuristic: true, + providerName: AUTOFILL_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_path_casing_2() { + info("Searching for untrimmed cased entry with path 2"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/Test/"), + }); + let context = createContext("http://mOzilla.org/T", { isPrivate: false }); + await check_results({ + context, + autofilled: "http://mOzilla.org/Test/", + completed: "http://mozilla.org/Test/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/Test/", + title: "test visit for http://mozilla.org/Test/", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_path_www_casing() { + info("Searching for untrimmed cased entry with www and path"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://www.mozilla.org/Test/"), + }); + let context = createContext("http://www.mOzilla.org/t", { isPrivate: false }); + await check_results({ + context, + autofilled: "http://www.mOzilla.org/test/", + completed: "http://www.mozilla.org/Test/", + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "http://www.mozilla.org/Test/", + title: "test visit for http://www.mozilla.org/Test/", + iconUri: "page-icon:http://www.mozilla.org/Test/", + heuristic: true, + providerName: AUTOFILL_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_path_www_casing_2() { + info("Searching for untrimmed cased entry with www and path 2"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://www.mozilla.org/Test/"), + }); + let context = createContext("http://www.mOzilla.org/T", { isPrivate: false }); + await check_results({ + context, + autofilled: "http://www.mOzilla.org/Test/", + completed: "http://www.mozilla.org/Test/", + matches: [ + makeVisitResult(context, { + uri: "http://www.mozilla.org/Test/", + title: "test visit for http://www.mozilla.org/Test/", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_searching() { + let uri1 = Services.io.newURI("http://dummy/1/"); + let uri2 = Services.io.newURI("http://dummy/2/"); + let uri3 = Services.io.newURI("http://dummy/3/"); + let uri4 = Services.io.newURI("http://dummy/4/"); + let uri5 = Services.io.newURI("http://dummy/5/"); + + await PlacesTestUtils.addVisits([ + { uri: uri1, title: "uppercase lambda \u039B" }, + { uri: uri2, title: "lowercase lambda \u03BB" }, + { uri: uri3, title: "symbol \u212A" }, // kelvin + { uri: uri4, title: "uppercase K" }, + { uri: uri5, title: "lowercase k" }, + ]); + + info("Search for lowercase lambda"); + let context = createContext("\u03BB", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri2.spec, + title: "lowercase lambda \u03BB", + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "uppercase lambda \u039B", + }), + ], + }); + + info("Search for uppercase lambda"); + context = createContext("\u039B", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri2.spec, + title: "lowercase lambda \u03BB", + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "uppercase lambda \u039B", + }), + ], + }); + + info("Search for kelvin sign"); + context = createContext("\u212A", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "lowercase k" }), + makeVisitResult(context, { uri: uri4.spec, title: "uppercase K" }), + makeVisitResult(context, { uri: uri3.spec, title: "symbol \u212A" }), + ], + }); + + info("Search for lowercase k"); + context = createContext("k", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "lowercase k" }), + makeVisitResult(context, { uri: uri4.spec, title: "uppercase K" }), + makeVisitResult(context, { uri: uri3.spec, title: "symbol \u212A" }), + ], + }); + + info("Search for uppercase k"); + + context = createContext("K", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "lowercase k" }), + makeVisitResult(context, { uri: uri4.spec, title: "uppercase K" }), + makeVisitResult(context, { uri: uri3.spec, title: "symbol \u212A" }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_dedupe_embedded_url_param.js b/browser/components/urlbar/tests/unit/test_dedupe_embedded_url_param.js new file mode 100644 index 0000000000..eaf42feb2d --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_dedupe_embedded_url_param.js @@ -0,0 +1,226 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +testEngine_setup(); + +add_task(async function test_embedded_url_show_up_as_places_result() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/?url=http://kitten.com/", + title: "kitten", + }, + ]); + + let context = createContext("kitten", { + isPrivate: false, + }); + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: "kitten", + engineName: Services.search.defaultEngine.name, + }), + makeVisitResult(context, { + uri: "http://example.com/?url=http://kitten.com/", + title: "kitten", + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_deduplication_of_embedded_url_autofill_result() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/?url=http://kitten.com/", + title: "kitten", + }, + { + uri: "http://kitten.com/", + title: "kitten", + }, + ]); + + let context = createContext("kitten", { + isPrivate: false, + }); + + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: "http://kitten.com/", + title: "kitten", + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + heuristic: true, + providerName: "Autofill", + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_deduplication_of_embedded_url_places_result() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/?url=http://kitten.com/", + title: "kitten", + }, + { + uri: "http://kitten.com/", + title: "kitten", + }, + ]); + + let context = createContext("kitten", { + isPrivate: false, + allowAutofill: false, + }); + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: "kitten", + engineName: Services.search.defaultEngine.name, + }), + makeVisitResult(context, { + uri: "http://kitten.com/", + title: "kitten", + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task( + async function test_deduplication_of_higher_frecency_embedded_url_places_result() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/?url=http://kitten.com/", + title: "kitten", + }, + { + uri: "http://example.com/?url=http://kitten.com/", + title: "kitten", + }, + { + uri: "http://kitten.com/", + title: "kitten", + }, + ]); + + let context = createContext("kitten", { + isPrivate: false, + allowAutofill: false, + }); + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: "kitten", + engineName: Services.search.defaultEngine.name, + }), + makeVisitResult(context, { + uri: "http://kitten.com/", + title: "kitten", + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }), + ], + }); + + await cleanupPlaces(); + } +); + +add_task( + async function test_deduplication_of_embedded_encoded_url_places_result() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/?url=http%3A%2F%2Fkitten.com%2F", + title: "kitten", + }, + { + uri: "http://kitten.com/", + title: "kitten", + }, + ]); + + let context = createContext("kitten", { + isPrivate: false, + allowAutofill: false, + }); + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: "kitten", + engineName: Services.search.defaultEngine.name, + }), + makeVisitResult(context, { + uri: "http://kitten.com/", + title: "kitten", + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }), + ], + }); + + await cleanupPlaces(); + } +); + +add_task(async function test_deduplication_of_embedded_url_switchTab_result() { + let uri = Services.io.newURI("http://kitten.com/"); + + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/?url=http://kitten.com/", + title: "kitten", + }, + { + uri, + title: "kitten", + }, + ]); + + await addOpenPages(uri, 1); + + let context = createContext("kitten", { + isPrivate: false, + allowAutofill: false, + }); + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: "kitten", + engineName: Services.search.defaultEngine.name, + }), + makeTabSwitchResult(context, { + source: UrlbarUtils.RESULT_SOURCE.TAB, + uri: "http://kitten.com/", + title: "kitten", + }), + ], + }); + + await removeOpenPages(uri, 1); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_dedupe_prefix.js b/browser/components/urlbar/tests/unit/test_dedupe_prefix.js new file mode 100644 index 0000000000..47a673d064 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_dedupe_prefix.js @@ -0,0 +1,277 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing that we dedupe results that have the same URL and title as another +// except for their prefix (e.g. http://www.). +add_task(async function dedupe_prefix() { + // We need to set the title or else we won't dedupe. We only dedupe when + // titles match up to mitigate deduping when the www. version of a site is + // completely different from it's www-less counterpart and thus presumably + // has a different title. + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/foo/", + title: "Example Page", + }, + { + uri: "http://www.example.com/foo/", + title: "Example Page", + }, + { + uri: "https://example.com/foo/", + title: "Example Page", + }, + // Note that we add https://www.example.com/foo/ twice here. + { + uri: "https://www.example.com/foo/", + title: "Example Page", + }, + { + uri: "https://www.example.com/foo/", + title: "Example Page", + }, + ]); + + // Expected results: + // + // Autofill result: + // https://www.example.com has the highest origin frecency since we added 2 + // visits to https://www.example.com/foo/ and only one visit to the other + // URLs. + // Other results: + // https://example.com/foo/ has the highest possible prefix rank, and it + // does not dupe the autofill result, so only it should be included. + let context = createContext("example.com/foo/", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/foo/", + completed: "https://www.example.com/foo/", + matches: [ + makeVisitResult(context, { + uri: "https://www.example.com/foo/", + title: "Example Page", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://example.com/foo/", + title: "Example Page", + }), + ], + }); + + // Add more visits to the lowest-priority prefix. It should be the heuristic + // result but we should still show our highest-priority result. https://www. + // should not appear at all. + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits([ + { + uri: "http://www.example.com/foo/", + title: "Example Page", + }, + ]); + } + + // Expected results: + // + // Autofill result: + // http://www.example.com now has the highest origin frecency since we added + // 4 visits to http://www.example.com/foo/ + // Other results: + // Same as before + context = createContext("example.com/foo/", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/foo/", + completed: "http://www.example.com/foo/", + matches: [ + makeVisitResult(context, { + uri: "http://www.example.com/foo/", + title: "Example Page", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://example.com/foo/", + title: "Example Page", + }), + ], + }); + + // Add enough https:// vists for it to have the highest frecency. It should + // be the heuristic result. We should still get the https://www. result + // because we still show results with the same key and protocol if they differ + // from the heuristic result in having www. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([ + { + uri: "https://example.com/foo/", + title: "Example Page", + }, + ]); + } + + // Expected results: + // + // Autofill result: + // https://example.com now has the highest origin frecency since we added + // 6 visits to https://example.com/foo/ + // Other results: + // https://example.com/foo/ has the highest possible prefix rank, but it + // dupes the heuristic so it should not be included. + // https://www.example.com/foo/ has the next highest prefix rank, and it + // does not dupe the heuristic, so only it should be included. + context = createContext("example.com/foo/", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/foo/", + completed: "https://example.com/foo/", + matches: [ + makeVisitResult(context, { + uri: "https://example.com/foo/", + title: "Example Page", + heuristic: true, + }), + ], + }); + + await cleanupPlaces(); +}); + +// This is the same as the previous task but with `experimental.hideHeuristic` +// enabled. +add_task(async function hideHeuristic() { + UrlbarPrefs.set("experimental.hideHeuristic", true); + + // We need to set the title or else we won't dedupe. We only dedupe when + // titles match up to mitigate deduping when the www. version of a site is + // completely different from it's www-less counterpart and thus presumably + // has a different title. + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/foo/", + title: "Example Page", + }, + { + uri: "http://www.example.com/foo/", + title: "Example Page", + }, + { + uri: "https://example.com/foo/", + title: "Example Page", + }, + // Note that we add https://www.example.com/foo/ twice here. + { + uri: "https://www.example.com/foo/", + title: "Example Page", + }, + { + uri: "https://www.example.com/foo/", + title: "Example Page", + }, + ]); + + // Expected results: + // + // Autofill result: + // https://www.example.com has the highest origin frecency since we added 2 + // visits to https://www.example.com/foo/ and only one visit to the other + // URLs. + // Other results: + // https://example.com/foo/ has the highest possible prefix rank, and it + // does not dupe the autofill result, so only it should be included. + let context = createContext("example.com/foo/", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/foo/", + completed: "https://www.example.com/foo/", + matches: [ + makeVisitResult(context, { + uri: "https://www.example.com/foo/", + title: "Example Page", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://example.com/foo/", + title: "Example Page", + }), + ], + }); + + // Add more visits to the lowest-priority prefix. It should be the heuristic + // result but we should still show our highest-priority result. https://www. + // should not appear at all. + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits([ + { + uri: "http://www.example.com/foo/", + title: "Example Page", + }, + ]); + } + + // Expected results: + // + // Autofill result: + // http://www.example.com now has the highest origin frecency since we added + // 4 visits to http://www.example.com/foo/ + // Other results: + // Same as before + context = createContext("example.com/foo/", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/foo/", + completed: "http://www.example.com/foo/", + matches: [ + makeVisitResult(context, { + uri: "http://www.example.com/foo/", + title: "Example Page", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://example.com/foo/", + title: "Example Page", + }), + ], + }); + + // Add enough https:// vists for it to have the highest frecency. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([ + { + uri: "https://example.com/foo/", + title: "Example Page", + }, + ]); + } + + // Expected results: + // + // Autofill result: + // https://example.com now has the highest origin frecency since we added + // 6 visits to https://example.com/foo/ + // Other results: + // https://example.com/foo/ has the highest possible prefix rank. It dupes + // the heuristic so ordinarily it should not be included, but because the + // heuristic is hidden, only it should appear. + context = createContext("example.com/foo/", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/foo/", + completed: "https://example.com/foo/", + matches: [ + makeVisitResult(context, { + uri: "https://example.com/foo/", + title: "Example Page", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://example.com/foo/", + title: "Example Page", + }), + ], + }); + + await cleanupPlaces(); + UrlbarPrefs.clear("experimental.hideHeuristic"); +}); diff --git a/browser/components/urlbar/tests/unit/test_dedupe_switchTab.js b/browser/components/urlbar/tests/unit/test_dedupe_switchTab.js new file mode 100644 index 0000000000..3b49866b1e --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_dedupe_switchTab.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +testEngine_setup(); + +add_task(async function test_deduplication_for_switch_tab() { + // Set up Places to think the tab is open locally. + let uri = Services.io.newURI("http://example.com/"); + + await PlacesTestUtils.addVisits({ uri, title: "An Example" }); + await addOpenPages(uri, 1); + await UrlbarUtils.addToInputHistory("http://example.com/", "An"); + + let query = "An"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "http://example.com/", + title: "An Example", + }), + ], + }); + + await removeOpenPages(uri, 1); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_dont_autofill_cases.js b/browser/components/urlbar/tests/unit/test_dont_autofill_cases.js new file mode 100644 index 0000000000..fefdd68452 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_dont_autofill_cases.js @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests some cases where autofill should not happen. + */ + +testEngine_setup(); + +add_task(async function test_prefix_space_noautofill() { + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://moz.org/test/"), + }); + + info("Should not try to autoFill if search string contains a space"); + let context = createContext(" mo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + query: " mo", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://moz.org/test/", + title: "test visit for http://moz.org/test/", + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_trailing_space_noautofill() { + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://moz.org/test/"), + }); + + info("Should not try to autoFill if search string contains a space"); + let context = createContext("mo ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + query: "mo ", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://moz.org/test/", + title: "test visit for http://moz.org/test/", + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_download_embed_bookmarks.js b/browser/components/urlbar/tests/unit/test_download_embed_bookmarks.js new file mode 100644 index 0000000000..29ce557748 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_download_embed_bookmarks.js @@ -0,0 +1,137 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim:set ts=2 sw=2 sts=2 et: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests bug 449406 to ensure that TRANSITION_DOWNLOAD, TRANSITION_EMBED and + * TRANSITION_FRAMED_LINK bookmarked uri's show up in the location bar. + */ + +testEngine_setup(); + +const TRANSITION_EMBED = PlacesUtils.history.TRANSITION_EMBED; +const TRANSITION_FRAMED_LINK = PlacesUtils.history.TRANSITION_FRAMED_LINK; +const TRANSITION_DOWNLOAD = PlacesUtils.history.TRANSITION_DOWNLOAD; + +add_task(async function test_download_embed_bookmarks() { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + let uri1 = Services.io.newURI("http://download/bookmarked"); + let uri2 = Services.io.newURI("http://embed/bookmarked"); + let uri3 = Services.io.newURI("http://framed/bookmarked"); + let uri4 = Services.io.newURI("http://download"); + let uri5 = Services.io.newURI("http://embed"); + let uri6 = Services.io.newURI("http://framed"); + await PlacesTestUtils.addVisits([ + { uri: uri1, title: "download-bookmark", transition: TRANSITION_DOWNLOAD }, + { uri: uri2, title: "embed-bookmark", transition: TRANSITION_EMBED }, + { uri: uri3, title: "framed-bookmark", transition: TRANSITION_FRAMED_LINK }, + { uri: uri4, title: "download2", transition: TRANSITION_DOWNLOAD }, + { uri: uri5, title: "embed2", transition: TRANSITION_EMBED }, + { uri: uri6, title: "framed2", transition: TRANSITION_FRAMED_LINK }, + ]); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri1, + title: "download-bookmark", + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri2, + title: "embed-bookmark", + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri3, + title: "framed-bookmark", + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("Searching for bookmarked download uri matches"); + let context = createContext("download-bookmark", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri1.spec, + title: "download-bookmark", + }), + ], + }); + + info("Searching for bookmarked embed uri matches"); + context = createContext("embed-bookmark", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri2.spec, + title: "embed-bookmark", + }), + ], + }); + + info("Searching for bookmarked framed uri matches"); + context = createContext("framed-bookmark", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri3.spec, + title: "framed-bookmark", + }), + ], + }); + + info("Searching for download uri does not match"); + context = createContext("download2", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + info("Searching for embed uri does not match"); + context = createContext("embed2", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + info("Searching for framed uri does not match"); + context = createContext("framed2", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_empty_search.js b/browser/components/urlbar/tests/unit/test_empty_search.js new file mode 100644 index 0000000000..2c6dffe8e6 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_empty_search.js @@ -0,0 +1,181 @@ +/* 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/. */ + +/** + * Test for bug 426864 that makes sure searching a space only shows typed pages + * from history. + */ + +testEngine_setup(); + +add_task(async function test_empty_search() { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + }); + + let uri1 = Services.io.newURI("http://t.foo/1"); + let uri2 = Services.io.newURI("http://t.foo/2"); + let uri3 = Services.io.newURI("http://t.foo/3"); + let uri4 = Services.io.newURI("http://t.foo/4"); + let uri5 = Services.io.newURI("http://t.foo/5"); + let uri6 = Services.io.newURI("http://t.foo/6"); + let uri7 = Services.io.newURI("http://t.foo/7"); + + await PlacesTestUtils.addVisits([ + { uri: uri7, title: "title" }, + { uri: uri6, title: "title" }, + { uri: uri4, title: "title" }, + { uri: uri3, title: "title" }, + { uri: uri2, title: "title" }, + { uri: uri1, title: "title" }, + ]); + + await PlacesTestUtils.addBookmarkWithDetails({ uri: uri6, title: "title" }); + await PlacesTestUtils.addBookmarkWithDetails({ uri: uri5, title: "title" }); + await PlacesTestUtils.addBookmarkWithDetails({ uri: uri4, title: "title" }); + await PlacesTestUtils.addBookmarkWithDetails({ uri: uri2, title: "title" }); + + await addOpenPages(uri7, 1); + + // Now remove page 6 from history, so it is an unvisited bookmark. + await PlacesUtils.history.remove(uri6); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + // With the changes above, the sites in descending order of frecency are: + // uri2 + // uri4 + // uri5 + // uri6 + // uri1 + // uri3 + // uri7 + + info("Match everything"); + let context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri2.spec, + title: "title", + }), + makeBookmarkResult(context, { + uri: uri4.spec, + title: "title", + }), + makeBookmarkResult(context, { + uri: uri5.spec, + title: "title", + }), + makeBookmarkResult(context, { + uri: uri6.spec, + title: "title", + }), + makeVisitResult(context, { uri: uri1.spec, title: "title" }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + makeTabSwitchResult(context, { uri: uri7.spec, title: "title" }), + ], + }); + + info("Match only history"); + context = createContext(`foo ${UrlbarTokenizer.RESTRICT.HISTORY}`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri2.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "title" }), + makeVisitResult(context, { uri: uri1.spec, title: "title" }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + makeVisitResult(context, { uri: uri7.spec, title: "title" }), + ], + }); + + info("Drop-down empty search matches history sorted by frecency desc"); + context = createContext(" ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + // query is made explict so makeSearchResult doesn't trim it. + query: " ", + heuristic: true, + }), + makeVisitResult(context, { uri: uri2.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "title" }), + makeVisitResult(context, { uri: uri1.spec, title: "title" }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + makeVisitResult(context, { uri: uri7.spec, title: "title" }), + ], + }); + + info("Empty search matches only bookmarks when history is disabled"); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true); + context = createContext(" ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + // query is made explict so makeSearchResult doesn't trim it. + query: " ", + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri2.spec, + title: "title", + }), + makeBookmarkResult(context, { + uri: uri4.spec, + title: "title", + }), + makeBookmarkResult(context, { + uri: uri5.spec, + title: "title", + }), + makeBookmarkResult(context, { + uri: uri6.spec, + title: "title", + }), + ], + }); + + info( + "Empty search matches only open tabs when bookmarks and history are disabled" + ); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + context = createContext(" ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + // query is made explict so makeSearchResult doesn't trim it. + query: " ", + heuristic: true, + }), + makeTabSwitchResult(context, { uri: uri7.spec, title: "title" }), + ], + }); + + Services.prefs.clearUserPref("browser.urlbar.suggest.history"); + Services.prefs.clearUserPref("browser.urlbar.suggest.bookmark"); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_encoded_urls.js b/browser/components/urlbar/tests/unit/test_encoded_urls.js new file mode 100644 index 0000000000..87a6015e86 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_encoded_urls.js @@ -0,0 +1,97 @@ +add_task(async function test_encoded() { + info("Searching for over encoded url should not break it"); + let url = "https://www.mozilla.com/search/top/?q=%25%32%35"; + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI(url), + title: url, + }); + let context = createContext(url, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: url, + matches: [ + makeVisitResult(context, { + uri: url, + title: url, + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_encoded_trimmed() { + info("Searching for over encoded url should not break it"); + let url = "https://www.mozilla.com/search/top/?q=%25%32%35"; + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI(url), + title: url, + }); + let context = createContext("mozilla.com/search/top/?q=%25%32%35", { + isPrivate: false, + }); + await check_results({ + context, + autofilled: "mozilla.com/search/top/?q=%25%32%35", + completed: url, + matches: [ + makeVisitResult(context, { + uri: url, + title: url, + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_encoded_partial() { + info("Searching for over encoded url should not break it"); + let url = "https://www.mozilla.com/search/top/?q=%25%32%35"; + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI(url), + title: url, + }); + let context = createContext("https://www.mozilla.com/search/top/?q=%25", { + isPrivate: false, + }); + await check_results({ + context, + autofilled: url, + completed: url, + matches: [ + makeVisitResult(context, { + uri: url, + title: url, + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_encoded_path() { + info("Searching for over encoded url should not break it"); + let url = "https://www.mozilla.com/%25%32%35/top/"; + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI(url), + title: url, + }); + let context = createContext("https://www.mozilla.com/%25%32%35/t", { + isPrivate: false, + }); + await check_results({ + context, + autofilled: url, + completed: url, + matches: [ + makeVisitResult(context, { + uri: url, + title: url, + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_escaping_badEscapedURI.js b/browser/components/urlbar/tests/unit/test_escaping_badEscapedURI.js new file mode 100644 index 0000000000..d330625bbb --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_escaping_badEscapedURI.js @@ -0,0 +1,37 @@ +/* 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/. */ + +/** + * Test bug 422277 to make sure bad escaped uris don't get escaped. This makes + * sure we don't hit an assertion for "not a UTF8 string". + */ + +testEngine_setup(); + +add_task(async function test() { + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + info("Bad escaped uri stays escaped"); + let uri1 = Services.io.newURI("http://site/%EAid"); + await PlacesTestUtils.addVisits([{ uri: uri1, title: "title" }]); + let context = createContext("site", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "title", + }), + ], + }); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_escaping_escapeSelf.js b/browser/components/urlbar/tests/unit/test_escaping_escapeSelf.js new file mode 100644 index 0000000000..470b93a2b2 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_escaping_escapeSelf.js @@ -0,0 +1,62 @@ +/* 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/. */ + +/** + * Test bug 422698 to make sure searches with urls from the location bar + * correctly match itself when it contains escaped characters. + */ + +testEngine_setup(); + +add_task(async function test_escape() { + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + let uri1 = Services.io.newURI("http://unescapeduri/"); + let uri2 = Services.io.newURI("http://escapeduri/%40/"); + await PlacesTestUtils.addVisits([ + { uri: uri1, title: "title" }, + { uri: uri2, title: "title" }, + ]); + + info("Unescaped location matches itself"); + let context = createContext("http://unescapeduri/", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: uri1.spec, + title: "title", + iconUri: `page-icon:${uri1.spec}`, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + heuristic: true, + }), + // Note that uri2 does not appear in results. + ], + }); + + info("Escaped location matches itself"); + context = createContext("http://escapeduri/%40", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: "http://escapeduri/%40", + fallbackTitle: "http://escapeduri/@", + iconUri: "page-icon:http://escapeduri/", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri2.spec, + title: "title", + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_exposure.js b/browser/components/urlbar/tests/unit/test_exposure.js new file mode 100644 index 0000000000..e3ce0b8479 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_exposure.js @@ -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/. */ + +ChromeUtils.defineESModuleGetters(this, { + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + UrlbarProviderQuickSuggest: + "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs", +}); + +// Tests that registering an exposureResults pref and triggering a match causes +// the exposure event to be recorded on the UrlbarResults. +const REMOTE_SETTINGS_RESULTS = [ + QuickSuggestTestUtils.ampRemoteSettings({ + keywords: ["test"], + }), + QuickSuggestTestUtils.wikipediaRemoteSettings({ + keywords: ["non_sponsored"], + }), +]; + +const EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT = makeAmpResult({ + keyword: "test", +}); + +const EXPECTED_NON_SPONSORED_REMOTE_SETTINGS_RESULT = makeWikipediaResult({ + keyword: "non_sponsored", +}); + +add_setup(async function test_setup() { + // FOG needs a profile directory to put its data in. + do_get_profile(); + + // FOG needs to be initialized in order for data to flow. + Services.fog.initializeFOG(); + + // Set up the remote settings client with the test data. + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + prefs: [ + ["suggest.quicksuggest.nonsponsored", true], + ["suggest.quicksuggest.sponsored", true], + ], + }); +}); + +add_task(async function testExposureCheck() { + UrlbarPrefs.set("exposureResults", suggestResultType("adm_sponsored")); + UrlbarPrefs.set("showExposureResults", true); + + let context = createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + Assert.equal( + context.results[0].exposureResultType, + suggestResultType("adm_sponsored") + ); + Assert.equal(context.results[0].exposureResultHidden, false); +}); + +add_task(async function testExposureCheckMultiple() { + UrlbarPrefs.set( + "exposureResults", + [ + suggestResultType("adm_sponsored"), + suggestResultType("adm_nonsponsored"), + ].join(",") + ); + UrlbarPrefs.set("showExposureResults", true); + + let context = createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + Assert.equal( + context.results[0].exposureResultType, + suggestResultType("adm_sponsored") + ); + Assert.equal(context.results[0].exposureResultHidden, false); + + context = createContext("non_sponsored", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + + await check_results({ + context, + matches: [EXPECTED_NON_SPONSORED_REMOTE_SETTINGS_RESULT], + }); + + Assert.equal( + context.results[0].exposureResultType, + suggestResultType("adm_nonsponsored") + ); + Assert.equal(context.results[0].exposureResultHidden, false); +}); + +add_task(async function exposureDisplayFiltering() { + UrlbarPrefs.set("exposureResults", suggestResultType("adm_sponsored")); + UrlbarPrefs.set("showExposureResults", false); + + let context = createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + Assert.equal( + context.results[0].exposureResultType, + suggestResultType("adm_sponsored") + ); + Assert.equal(context.results[0].exposureResultHidden, true); +}); + +function suggestResultType(typeWithoutSource) { + let source = UrlbarPrefs.get("quickSuggestRustEnabled") ? "rust" : "rs"; + return `${source}_${typeWithoutSource}`; +} + +// Copied from quicksuggest/unit/head.js +function makeAmpResult({ + source, + provider, + keyword = "amp", + title = "Amp Suggestion", + url = "http://example.com/amp", + originalUrl = "http://example.com/amp", + icon = null, + iconBlob = new Blob([new Uint8Array([])]), + impressionUrl = "http://example.com/amp-impression", + clickUrl = "http://example.com/amp-click", + blockId = 1, + advertiser = "Amp", + iabCategory = "22 - Shopping", + suggestedIndex = -1, + isSuggestedIndexRelativeToGroup = true, + requestId = undefined, +} = {}) { + let result = { + suggestedIndex, + isSuggestedIndexRelativeToGroup, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + title, + url, + originalUrl, + requestId, + displayUrl: url.replace(/^https:\/\//, ""), + isSponsored: true, + qsSuggestion: keyword, + sponsoredImpressionUrl: impressionUrl, + sponsoredClickUrl: clickUrl, + sponsoredBlockId: blockId, + sponsoredAdvertiser: advertiser, + sponsoredIabCategory: iabCategory, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + telemetryType: "adm_sponsored", + descriptionL10n: { id: "urlbar-result-action-sponsored" }, + }, + }; + + if (UrlbarPrefs.get("quickSuggestRustEnabled")) { + result.payload.source = source || "rust"; + result.payload.provider = provider || "Amp"; + if (result.payload.source == "rust") { + result.payload.iconBlob = iconBlob; + } else { + result.payload.icon = icon; + } + } else { + result.payload.source = source || "remote-settings"; + result.payload.provider = provider || "AdmWikipedia"; + result.payload.icon = icon; + } + + return result; +} + +// Copied from quicksuggest/unit/head.js +function makeWikipediaResult({ + source, + provider, + keyword = "wikipedia", + title = "Wikipedia Suggestion", + url = "http://example.com/wikipedia", + originalUrl = "http://example.com/wikipedia", + icon = null, + iconBlob = new Blob([new Uint8Array([])]), + impressionUrl = "http://example.com/wikipedia-impression", + clickUrl = "http://example.com/wikipedia-click", + blockId = 1, + advertiser = "Wikipedia", + iabCategory = "5 - Education", + suggestedIndex = -1, + isSuggestedIndexRelativeToGroup = true, +}) { + let result = { + suggestedIndex, + isSuggestedIndexRelativeToGroup, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + title, + url, + originalUrl, + displayUrl: url.replace(/^https:\/\//, ""), + isSponsored: false, + qsSuggestion: keyword, + sponsoredAdvertiser: "Wikipedia", + sponsoredIabCategory: "5 - Education", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + telemetryType: "adm_nonsponsored", + }, + }; + + if (UrlbarPrefs.get("quickSuggestRustEnabled")) { + result.payload.source = source || "rust"; + result.payload.provider = provider || "Wikipedia"; + result.payload.iconBlob = iconBlob; + } else { + result.payload.source = source || "remote-settings"; + result.payload.provider = provider || "AdmWikipedia"; + result.payload.icon = icon; + result.payload.sponsoredImpressionUrl = impressionUrl; + result.payload.sponsoredClickUrl = clickUrl; + result.payload.sponsoredBlockId = blockId; + result.payload.sponsoredAdvertiser = advertiser; + result.payload.sponsoredIabCategory = iabCategory; + } + + return result; +} diff --git a/browser/components/urlbar/tests/unit/test_frecency.js b/browser/components/urlbar/tests/unit/test_frecency.js new file mode 100644 index 0000000000..0d7a007e0d --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_frecency.js @@ -0,0 +1,403 @@ +/* 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/. */ + +/** + * Test for bug 406358 to make sure frecency works for empty input/search, but + * this also tests for non-empty inputs as well. Because the interactions among + * DIFFERENT* visit counts and visit dates is not well defined, this test + * holds one of the two values constant when modifying the other. + * + * Also test bug 419068 to make sure tagged pages don't necessarily have to be + * first in the results. + * + * Also test bug 426166 to make sure that the results of autocomplete searches + * are stable. Note that failures of this test will be intermittent by nature + * since we are testing to make sure that the unstable sort algorithm used + * by SQLite is not changing the order of the results on us. + */ + +testEngine_setup(); + +async function task_setCountDate(uri, count, date) { + // We need visits so that frecency can be computed over multiple visits + let visits = []; + for (let i = 0; i < count; i++) { + visits.push({ + uri, + visitDate: date, + transition: PlacesUtils.history.TRANSITION_TYPED, + }); + } + await PlacesTestUtils.addVisits(visits); +} + +async function setBookmark(uri) { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: uri, + title: "bleh", + }); +} + +async function tagURI(uri, tags) { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: uri, + title: "bleh", + }); + PlacesUtils.tagging.tagURI(uri, tags); +} + +var uri1 = Services.io.newURI("http://site.tld/1"); +var uri2 = Services.io.newURI("http://site.tld/2"); +var uri3 = Services.io.newURI("http://aaaaaaaaaa/1"); +var uri4 = Services.io.newURI("http://aaaaaaaaaa/2"); + +// d1 is younger (should show up higher) than d2 (PRTime is in usecs not msec) +// Make sure the dates fall into different frecency groups +var d1 = new Date(Date.now() - 1000 * 60 * 60) * 1000; +var d2 = new Date(Date.now() - 1000 * 60 * 60 * 24 * 10) * 1000; +// c1 is larger (should show up higher) than c2 +var c1 = 10; +var c2 = 1; + +var tests = [ + // test things without a search term + async function () { + info("Test 0: same count, different date"); + await task_setCountDate(uri1, c1, d1); + await task_setCountDate(uri2, c1, d2); + await tagURI(uri1, ["site"]); + let context = createContext(" ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + query: " ", + }), + // uri1 is a visit result despite being a tagged bookmark because we + // are searching for the empty string. By default, the empty string + // filters to history. uri1 will be displayed as a bookmark later in the + // test when we are searching with a non-empty string. + makeVisitResult(context, { + uri: uri1.spec, + title: "bleh", + }), + makeVisitResult(context, { + uri: uri2.spec, + title: `test visit for ${uri2.spec}`, + }), + ], + }); + }, + async function () { + info("Test 1: same count, different date"); + await task_setCountDate(uri1, c1, d2); + await task_setCountDate(uri2, c1, d1); + await tagURI(uri1, ["site"]); + let context = createContext(" ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + query: " ", + }), + makeVisitResult(context, { + uri: uri2.spec, + title: `test visit for ${uri2.spec}`, + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "bleh", + }), + ], + }); + }, + async function () { + info("Test 2: different count, same date"); + await task_setCountDate(uri1, c1, d1); + await task_setCountDate(uri2, c2, d1); + await tagURI(uri1, ["site"]); + let context = createContext(" ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + query: " ", + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "bleh", + }), + makeVisitResult(context, { + uri: uri2.spec, + title: `test visit for ${uri2.spec}`, + }), + ], + }); + }, + async function () { + info("Test 3: different count, same date"); + await task_setCountDate(uri1, c2, d1); + await task_setCountDate(uri2, c1, d1); + await tagURI(uri1, ["site"]); + let context = createContext(" ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + query: " ", + }), + makeVisitResult(context, { + uri: uri2.spec, + title: `test visit for ${uri2.spec}`, + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "bleh", + }), + ], + }); + }, + + // test things with a search term + async function () { + info("Test 4: same count, different date"); + await task_setCountDate(uri1, c1, d1); + await task_setCountDate(uri2, c1, d2); + await tagURI(uri1, ["site"]); + let context = createContext("site", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri1.spec, + title: "bleh", + tags: ["site"], + }), + makeVisitResult(context, { + uri: uri2.spec, + title: `test visit for ${uri2.spec}`, + }), + ], + }); + }, + async function () { + info("Test 5: same count, different date"); + await task_setCountDate(uri1, c1, d2); + await task_setCountDate(uri2, c1, d1); + await tagURI(uri1, ["site"]); + let context = createContext("site", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri2.spec, + title: `test visit for ${uri2.spec}`, + }), + makeBookmarkResult(context, { + uri: uri1.spec, + title: "bleh", + tags: ["site"], + }), + ], + }); + }, + async function () { + info("Test 6: different count, same date"); + await task_setCountDate(uri1, c1, d1); + await task_setCountDate(uri2, c2, d1); + await tagURI(uri1, ["site"]); + let context = createContext("site", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri1.spec, + title: "bleh", + tags: ["site"], + }), + makeVisitResult(context, { + uri: uri2.spec, + title: `test visit for ${uri2.spec}`, + }), + ], + }); + }, + async function () { + info("Test 7: different count, same date"); + await task_setCountDate(uri1, c2, d1); + await task_setCountDate(uri2, c1, d1); + await tagURI(uri1, ["site"]); + let context = createContext("site", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri2.spec, + title: `test visit for ${uri2.spec}`, + }), + makeBookmarkResult(context, { + uri: uri1.spec, + title: "bleh", + tags: ["site"], + }), + ], + }); + }, + // There are multiple tests for 8, hence the multiple functions + // Bug 426166 section + async function () { + info("Test 8.1a: same count, same date"); + await setBookmark(uri3); + await setBookmark(uri4); + let context = createContext("a", { isPrivate: false }); + let bookmarkResults = [ + makeBookmarkResult(context, { + uri: uri4.spec, + title: "bleh", + }), + makeBookmarkResult(context, { + uri: uri3.spec, + title: "bleh", + }), + ]; + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...bookmarkResults, + ], + }); + + context = createContext("aa", { isPrivate: false }); + await check_results({ + context, + matches: [ + // We need to continuously redefine the heuristic search result because it + // is the only one that changes with the search string. + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...bookmarkResults, + ], + }); + + context = createContext("aaa", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...bookmarkResults, + ], + }); + + context = createContext("aaaa", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...bookmarkResults, + ], + }); + + context = createContext("aaa", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...bookmarkResults, + ], + }); + + context = createContext("aa", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...bookmarkResults, + ], + }); + + context = createContext("a", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...bookmarkResults, + ], + }); + }, +]; + +add_task(async function test_frecency() { + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + // always search in history + bookmarks, no matter what the default is + Services.prefs.setBoolPref("browser.urlbar.suggest.history", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.openpage", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.engines", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + for (let test of tests) { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + + await test(); + } + for (let type of [ + "history", + "bookmark", + "openpage", + "searches", + "engines", + "quickactions", + ]) { + Services.prefs.clearUserPref("browser.urlbar.suggest." + type); + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + } +}); diff --git a/browser/components/urlbar/tests/unit/test_frecency_alternative_nimbus.js b/browser/components/urlbar/tests/unit/test_frecency_alternative_nimbus.js new file mode 100644 index 0000000000..d50d5314ad --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_frecency_alternative_nimbus.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function () { + const tests = [ + { + enableVariable: "originsAlternativeEnable", + enablePref: "places.frecency.origins.alternative.featureGate", + variables: { + originsDaysCutOff: "places.frecency.origins.alternative.daysCutOff", + }, + }, + { + enableVariable: "pagesAlternativeEnable", + enablePref: "places.frecency.pages.alternative.featureGate", + variables: { + pagesNumSampledVisits: + "places.frecency.pages.alternative.numSampledVisits", + pagesHalfLifeDays: "places.frecency.pages.alternative.halfLifeDays", + pagesHighWeight: "places.frecency.pages.alternative.highWeight", + pagesMediumWeight: "places.frecency.pages.alternative.mediumWeight", + pagesLowWeight: "places.frecency.pages.alternative.lowWeight", + }, + }, + ]; + for (let test of tests) { + await doTest(test.enableVariable, test.enablePref, test.variables); + } +}); + +async function doTest(enableVariable, enablePref, otherVariables) { + info(`Testing ${enableVariable}`); + let reset = await UrlbarTestUtils.initNimbusFeature( + { + // Empty for sanity check. + }, + "urlbar", + "config" + ); + Assert.ok(!Services.prefs.prefHasUserValue(enablePref)); + Assert.ok(!Services.prefs.getBoolPref(enablePref, false)); + for (let pref of Object.values(otherVariables)) { + Assert.ok(!Services.prefs.prefHasUserValue(pref)); + } + await reset(); + + reset = await UrlbarTestUtils.initNimbusFeature( + { + [enableVariable]: true, + }, + "urlbar", + "config" + ); + Assert.ok(Services.prefs.prefHasUserValue(enablePref)); + Assert.equal(Services.prefs.getBoolPref(enablePref), true); + for (let pref of Object.values(otherVariables)) { + Assert.ok(!Services.prefs.prefHasUserValue(pref)); + } + await reset(); + + const FAKE_VALUE = 777; + let config = { + [enableVariable]: true, + }; + for (let variable of Object.keys(otherVariables)) { + config[variable] = FAKE_VALUE; + } + reset = await UrlbarTestUtils.initNimbusFeature(config, "urlbar", "config"); + Assert.ok(Services.prefs.prefHasUserValue(enablePref)); + Assert.equal(Services.prefs.getBoolPref(enablePref), true); + for (let pref of Object.values(otherVariables)) { + Assert.ok(Services.prefs.prefHasUserValue(pref)); + Assert.equal(Services.prefs.getIntPref(pref, 90), FAKE_VALUE); + } + + await reset(); +} diff --git a/browser/components/urlbar/tests/unit/test_heuristic_cancel.js b/browser/components/urlbar/tests/unit/test_heuristic_cancel.js new file mode 100644 index 0000000000..6f6f2fbd8a --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_heuristic_cancel.js @@ -0,0 +1,238 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests that old results from UrlbarProviderAutofill do not overwrite results + * from UrlbarProviderHeuristicFallback after the autofillable query is + * cancelled. See bug 1653436. + */ + +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderAutofill: "resource:///modules/UrlbarProviderAutofill.sys.mjs", +}); + +/** + * A test provider that waits before returning results to simulate a slow DB + * lookup. + */ +class SlowHeuristicProvider extends TestProvider { + get type() { + return UrlbarUtils.PROVIDER_TYPE.HEURISTIC; + } + + async startQuery(context, add) { + this._context = context; + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 300)); + for (let result of this.results) { + add(this, result); + } + } +} + +/** + * A fast provider that alerts the test when it has added its results. + */ +class FastHeuristicProvider extends TestProvider { + get type() { + return UrlbarUtils.PROVIDER_TYPE.HEURISTIC; + } + + async startQuery(context, add) { + this._context = context; + for (let result of this.results) { + add(this, result); + } + Services.obs.notifyObservers(null, "results-added"); + } +} + +add_setup(async function () { + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); +}); + +/** + * Tests that UrlbarProvidersManager._heuristicProviderTimer is cancelled when + * a query is cancelled. + */ +add_task(async function timerIsCancelled() { + let context = createContext("m", { isPrivate: false }); + await PlacesTestUtils.promiseAsyncUpdates(); + info("Manually set up query and then overwrite it."); + // slowProvider is a stand-in for a slow UrlbarProviderPlaces returning a + // non-heuristic result. + let slowProvider = new SlowHeuristicProvider({ + results: [ + makeVisitResult(context, { + uri: `http://mozilla.org/`, + title: `mozilla.org/`, + }), + ], + }); + UrlbarProvidersManager.registerProvider(slowProvider); + + // fastProvider is a stand-in for a fast Autofill returning a heuristic + // result. + let fastProvider = new FastHeuristicProvider({ + results: [ + makeVisitResult(context, { + uri: `http://mozilla.com/`, + title: `mozilla.com/`, + heuristic: true, + }), + ], + }); + UrlbarProvidersManager.registerProvider(fastProvider); + let firstContext = createContext("m", { + providers: [slowProvider.name, fastProvider.name], + }); + let secondContext = createContext("ma", { + providers: [slowProvider.name, fastProvider.name], + }); + + let controller = UrlbarTestUtils.newMockController(); + let queryRecieved, queryCancelled; + const controllerListener = { + onQueryResults(queryContext) { + Assert.equal( + queryContext, + secondContext, + "Only the second query should finish." + ); + queryRecieved = true; + }, + onQueryCancelled(queryContext) { + Assert.equal( + queryContext, + firstContext, + "The first query should be cancelled." + ); + Assert.ok(!queryCancelled, "No more than one query should be cancelled."); + queryCancelled = true; + }, + }; + controller.addQueryListener(controllerListener); + + // Wait until FastProvider sends its results to the providers manager. + // Then they will be queued up in a _heuristicProvidersTimer, waiting for + // the results from SlowProvider. + let resultsAddedPromise = new Promise(resolve => { + let observe = async (subject, topic, data) => { + Services.obs.removeObserver(observe, "results-added"); + // Fire the second query to cancel the first. + await controller.startQuery(secondContext); + resolve(); + }; + + Services.obs.addObserver(observe, "results-added"); + }); + + controller.startQuery(firstContext); + await resultsAddedPromise; + + Assert.ok(queryCancelled, "At least one query was cancelled."); + Assert.ok(queryRecieved, "At least one query finished."); + controller.removeQueryListener(controllerListener); +}); + +/** + * Tests that old autofill results aren't displayed after a query is cancelled. + * See bug 1653436. + */ +add_task(async function autofillIsCleared() { + /** + * Steps: + * 1. Start query. + * 2. Allow UrlbarProviderAutofill to start _getAutofillResult. + * 3. Execute a new query with no autofill match, cancelling the first + * query. + * 4. Test that the old result from UrlbarProviderAutofill isn't displayed. + */ + await PlacesTestUtils.addVisits("http://example.com"); + + let firstContext = createContext("e", { + providers: ["Autofill", "HeuristicFallback"], + }); + let secondContext = createContext("em", { + providers: ["Autofill", "HeuristicFallback"], + }); + + info("Sanity check: The first query autofills and the second does not."); + await check_results({ + firstContext, + autofilled: "example.com", + completed: "http://example.com/", + matches: [ + makeVisitResult(firstContext, { + uri: "http://example.com/", + title: "example.com", + heuristic: true, + }), + ], + }); + + await check_results({ + secondContext, + matches: [ + makeSearchResult(secondContext, { + engineName: (await Services.search.getDefault()).name, + providerName: "HeuristicFallback", + heuristic: true, + }), + ], + }); + + // Refresh our queries + firstContext = createContext("e", { + providers: ["Autofill", "HeuristicFallback"], + }); + secondContext = createContext("em", { + providers: ["Autofill", "HeuristicFallback"], + }); + + // Set up controller to observe queries. + let controller = UrlbarTestUtils.newMockController(); + let queryRecieved, queryCancelled; + const controllerListener = { + onQueryResults(queryContext) { + Assert.equal( + queryContext, + secondContext, + "Only the second query should finish." + ); + queryRecieved = true; + }, + onQueryCancelled(queryContext) { + Assert.equal( + queryContext, + firstContext, + "The first query should be cancelled." + ); + Assert.ok( + !UrlbarProviderAutofill._autofillData, + "The first result should not have populated autofill data." + ); + Assert.ok(!queryCancelled, "No more than one query should be cancelled."); + queryCancelled = true; + }, + }; + controller.addQueryListener(controllerListener); + + // Intentionally do not await this first query. + controller.startQuery(firstContext); + await controller.startQuery(secondContext); + + Assert.ok(queryCancelled, "At least one query was cancelled."); + Assert.ok(queryRecieved, "At least one query finished."); + controller.removeQueryListener(controllerListener); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_hideSponsoredHistory.js b/browser/components/urlbar/tests/unit/test_hideSponsoredHistory.js new file mode 100644 index 0000000000..d49aaf2fb7 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_hideSponsoredHistory.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This tests the muxer functionality that hides URLs in history that were +// originally sponsored. + +"use strict"; + +add_task(async function test() { + // Disable search suggestions to avoid hitting the network. + UrlbarPrefs.set("suggest.searches", false); + + let engine = await Services.search.getDefault(); + let pref = "browser.newtabpage.activity-stream.hideTopSitesWithSearchParam"; + + // This maps URL search params to objects describing whether a URL with those + // params is expected to appear in the search results. Each inner object maps + // from a value of the pref to whether the URL is expected to appear given the + // pref value. + let tests = { + "": { + "": true, + test: true, + "test=": true, + "test=hide": true, + nomatch: true, + "nomatch=": true, + "nomatch=hide": true, + }, + test: { + "": true, + test: false, + "test=": false, + "test=hide": true, + nomatch: true, + "nomatch=": true, + "nomatch=hide": true, + }, + "test=hide": { + "": true, + test: false, + "test=": true, + "test=hide": false, + nomatch: true, + "nomatch=": true, + "nomatch=hide": true, + }, + "test=foo&test=hide": { + "": true, + test: false, + "test=": true, + "test=hide": false, + nomatch: true, + "nomatch=": true, + "nomatch=hide": true, + }, + }; + + for (let [urlParams, expected] of Object.entries(tests)) { + for (let [prefValue, shouldAppear] of Object.entries(expected)) { + info( + "Running test: " + + JSON.stringify({ urlParams, prefValue, shouldAppear }) + ); + + // Add a visit to a URL with search params `urlParams`. + let url = new URL("http://example.com/"); + url.search = urlParams; + await PlacesTestUtils.addVisits(url); + + // Set the pref to `prefValue`. + Services.prefs.setCharPref(pref, prefValue); + + // Set up the context and expected results. If `shouldAppear` is true, a + // visit result for the URL should appear. + let context = createContext("ample", { isPrivate: false }); + let expectedResults = [ + makeSearchResult(context, { + heuristic: true, + engineName: engine.name, + engineIconUri: engine.getIconURL(), + }), + ]; + if (shouldAppear) { + expectedResults.push( + makeVisitResult(context, { + uri: url.toString(), + title: "test visit for " + url, + }) + ); + } + + // Do a search and check the results. + await check_results({ + context, + matches: expectedResults, + }); + + await PlacesUtils.history.clear(); + } + } + + Services.prefs.clearUserPref(pref); +}); diff --git a/browser/components/urlbar/tests/unit/test_history_bookmark_results_on_search_service_failure.js b/browser/components/urlbar/tests/unit/test_history_bookmark_results_on_search_service_failure.js new file mode 100644 index 0000000000..32b3441f5e --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_history_bookmark_results_on_search_service_failure.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests history and bookmark results show up when search service + * initialization has failed. + */ + +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); + +const searchService = Services.search.wrappedJSObject; + +add_setup(async function setup() { + searchService.errorToThrowInTest = "Settings"; + + // When search service fails, we want the promise rejection to be uncaught + // so we can continue running the test. + PromiseTestUtils.expectUncaughtRejection( + /Fake Settings error during search service initialization./ + ); + + registerCleanupFunction(async () => { + searchService.errorToThrowInTest = null; + await cleanupPlaces(); + }); +}); + +add_task( + async function test_bookmark_results_are_shown_when_search_service_failed() { + Assert.equal( + searchService.isInitialized, + false, + "Search Service should not be initialized." + ); + + info("Add a bookmark"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://cat.com/", + title: "cat", + }); + + let context = createContext("cat", { + isPrivate: false, + allowAutofill: false, + }); + + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: "http://cat/", + heuristic: true, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + fallbackTitle: "http://cat/", + }), + makeBookmarkResult(context, { + title: "cat", + uri: "http://cat.com/", + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }), + ], + }); + + Assert.equal( + searchService.isInitialized, + true, + "Search Service should have finished its attempt to initialize." + ); + + Assert.equal( + searchService.hasSuccessfullyInitialized, + false, + "Search Service should have failed to initialize." + ); + await cleanupPlaces(); + } +); + +add_task( + async function test_history_results_are_shown_when_search_service_failed() { + Assert.equal( + searchService.isInitialized, + true, + "Search Service should have finished its attempt to initialize in the previous test." + ); + + Assert.equal( + searchService.hasSuccessfullyInitialized, + false, + "Search Service should have failed to initialize." + ); + + info("visit a url in history"); + await PlacesTestUtils.addVisits({ + uri: "http://example.com/", + title: "example", + }); + + let context = createContext("example", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + type: 3, + title: "example", + uri: "http://example.com/", + heuristic: true, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }), + ], + }); + } +); diff --git a/browser/components/urlbar/tests/unit/test_keywords.js b/browser/components/urlbar/tests/unit/test_keywords.js new file mode 100644 index 0000000000..1773768a5c --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_keywords.js @@ -0,0 +1,212 @@ +/* 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/. */ + +testEngine_setup(); + +add_task(async function test_non_keyword() { + info("Searching for non-keyworded entry should autoFill it"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/test/"), + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: Services.io.newURI("http://mozilla.org/test/"), + }); + let context = createContext("moz", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/", + completed: "http://mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/", + fallbackTitle: UrlbarTestUtils.trimURL("http://mozilla.org"), + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://mozilla.org/test/", + title: "A bookmark", + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_keyword() { + info("Searching for keyworded entry should not autoFill it"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/test/"), + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: Services.io.newURI("http://mozilla.org/test/"), + keyword: "moz", + }); + let context = createContext("moz", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://mozilla.org/test/", + title: "http://mozilla.org/test/", + keyword: "moz", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_more_than_keyword() { + info("Searching for more than keyworded entry should autoFill it"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/test/"), + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: Services.io.newURI("http://mozilla.org/test/"), + keyword: "moz", + }); + let context = createContext("mozi", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/", + completed: "http://mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/", + fallbackTitle: UrlbarTestUtils.trimURL("http://mozilla.org"), + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://mozilla.org/test/", + title: "A bookmark", + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_less_than_keyword() { + info("Searching for less than keyworded entry should autoFill it"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/test/"), + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: Services.io.newURI("http://mozilla.org/test/"), + keyword: "moz", + }); + let context = createContext("mo", { isPrivate: false }); + await check_results({ + context, + search: "mo", + autofilled: "mozilla.org/", + completed: "http://mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/", + fallbackTitle: UrlbarTestUtils.trimURL("http://mozilla.org"), + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://mozilla.org/test/", + title: "A bookmark", + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_keyword_casing() { + info("Searching for keyworded entry is case-insensitive"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/test/"), + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: Services.io.newURI("http://mozilla.org/test/"), + keyword: "moz", + }); + let context = createContext("MoZ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://mozilla.org/test/", + title: "http://mozilla.org/test/", + keyword: "MoZ", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_less_then_equal_than_keyword_bug_1124238() { + info("Searching for less than keyworded entry should autoFill it"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/test/"), + }); + await PlacesTestUtils.addVisits("http://mozilla.com/"); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: Services.io.newURI("http://mozilla.com/"), + keyword: "moz", + }); + + let context = createContext("mo", { isPrivate: false }); + await check_results({ + context, + search: "mo", + autofilled: "mozilla.com/", + completed: "http://mozilla.com/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.com/", + title: "A bookmark", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/test/", + title: "test visit for http://mozilla.org/test/", + }), + ], + }); + + // Search with an additional character. As the input matches a keyword, the + // completion should equal the keyword and not the URI as before. + context = createContext("moz", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://mozilla.com/", + title: "http://mozilla.com", + keyword: "moz", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/test/", + title: "test visit for http://mozilla.org/test/", + }), + ], + }); + + // Search with an additional character. The input doesn't match a keyword + // anymore, it should be autofilled. + context = createContext("mozi", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.com/", + completed: "http://mozilla.com/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.com/", + title: "A bookmark", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/test/", + title: "test visit for http://mozilla.org/test/", + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_l10nCache.js b/browser/components/urlbar/tests/unit/test_l10nCache.js new file mode 100644 index 0000000000..e92c75fa01 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_l10nCache.js @@ -0,0 +1,685 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests L10nCache in UrlbarUtils.jsm. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + L10nCache: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +add_task(async function comprehensive() { + // Set up a mock localization. + let l10n = initL10n({ + args0a: "Zero args value", + args0b: "Another zero args value", + args1a: "One arg value is { $arg1 }", + args1b: "Another one arg value is { $arg1 }", + args2a: "Two arg values are { $arg1 } and { $arg2 }", + args2b: "More two arg values are { $arg1 } and { $arg2 }", + args3a: "Three arg values are { $arg1 }, { $arg2 }, and { $arg3 }", + args3b: "More three arg values are { $arg1 }, { $arg2 }, and { $arg3 }", + attrs1: [".label = attrs1 label has zero args"], + attrs2: [ + ".label = attrs2 label has zero args", + ".tooltiptext = attrs2 tooltiptext arg value is { $arg1 }", + ], + attrs3: [ + ".label = attrs3 label has zero args", + ".tooltiptext = attrs3 tooltiptext arg value is { $arg1 }", + ".alt = attrs3 alt arg values are { $arg1 } and { $arg2 }", + ], + }); + + let tests = [ + // different strings with the same number of args and also the same strings + // with different args + { + obj: { + id: "args0a", + }, + expected: { + value: "Zero args value", + attributes: null, + }, + }, + { + obj: { + id: "args0b", + }, + expected: { + value: "Another zero args value", + attributes: null, + }, + }, + { + obj: { + id: "args1a", + args: { arg1: "foo1" }, + }, + expected: { + value: "One arg value is foo1", + attributes: null, + }, + }, + { + obj: { + id: "args1a", + args: { arg1: "foo2" }, + }, + expected: { + value: "One arg value is foo2", + attributes: null, + }, + }, + { + obj: { + id: "args1b", + args: { arg1: "foo1" }, + }, + expected: { + value: "Another one arg value is foo1", + attributes: null, + }, + }, + { + obj: { + id: "args1b", + args: { arg1: "foo2" }, + }, + expected: { + value: "Another one arg value is foo2", + attributes: null, + }, + }, + { + obj: { + id: "args2a", + args: { arg1: "foo1", arg2: "bar1" }, + }, + expected: { + value: "Two arg values are foo1 and bar1", + attributes: null, + }, + }, + { + obj: { + id: "args2a", + args: { arg1: "foo2", arg2: "bar2" }, + }, + expected: { + value: "Two arg values are foo2 and bar2", + attributes: null, + }, + }, + { + obj: { + id: "args2b", + args: { arg1: "foo1", arg2: "bar1" }, + }, + expected: { + value: "More two arg values are foo1 and bar1", + attributes: null, + }, + }, + { + obj: { + id: "args2b", + args: { arg1: "foo2", arg2: "bar2" }, + }, + expected: { + value: "More two arg values are foo2 and bar2", + attributes: null, + }, + }, + { + obj: { + id: "args3a", + args: { arg1: "foo1", arg2: "bar1", arg3: "baz1" }, + }, + expected: { + value: "Three arg values are foo1, bar1, and baz1", + attributes: null, + }, + }, + { + obj: { + id: "args3a", + args: { arg1: "foo2", arg2: "bar2", arg3: "baz2" }, + }, + expected: { + value: "Three arg values are foo2, bar2, and baz2", + attributes: null, + }, + }, + { + obj: { + id: "args3b", + args: { arg1: "foo1", arg2: "bar1", arg3: "baz1" }, + }, + expected: { + value: "More three arg values are foo1, bar1, and baz1", + attributes: null, + }, + }, + { + obj: { + id: "args3b", + args: { arg1: "foo2", arg2: "bar2", arg3: "baz2" }, + }, + expected: { + value: "More three arg values are foo2, bar2, and baz2", + attributes: null, + }, + }, + + // two instances of the same string with their args swapped + { + obj: { + id: "args2a", + args: { arg1: "arg A", arg2: "arg B" }, + }, + expected: { + value: "Two arg values are arg A and arg B", + attributes: null, + }, + }, + { + obj: { + id: "args2a", + args: { arg1: "arg B", arg2: "arg A" }, + }, + expected: { + value: "Two arg values are arg B and arg A", + attributes: null, + }, + }, + + // strings with attributes + { + obj: { + id: "attrs1", + }, + expected: { + value: null, + attributes: { + label: "attrs1 label has zero args", + }, + }, + }, + { + obj: { + id: "attrs2", + args: { + arg1: "arg A", + }, + }, + expected: { + value: null, + attributes: { + label: "attrs2 label has zero args", + tooltiptext: "attrs2 tooltiptext arg value is arg A", + }, + }, + }, + { + obj: { + id: "attrs3", + args: { + arg1: "arg A", + arg2: "arg B", + }, + }, + expected: { + value: null, + attributes: { + label: "attrs3 label has zero args", + tooltiptext: "attrs3 tooltiptext arg value is arg A", + alt: "attrs3 alt arg values are arg A and arg B", + }, + }, + }, + ]; + + let cache = new L10nCache(l10n); + + // Get some non-cached strings. + Assert.ok(!cache.get({ id: "uncached1" }), "Uncached string 1"); + Assert.ok(!cache.get({ id: "uncached2", args: "foo" }), "Uncached string 2"); + + // Add each test string and get it back. + for (let { obj, expected } of tests) { + await cache.add(obj); + let message = cache.get(obj); + Assert.deepEqual( + message, + expected, + "Expected message for obj: " + JSON.stringify(obj) + ); + } + + // Get each string again to make sure each add didn't somehow mess up the + // previously added strings. + for (let { obj, expected } of tests) { + Assert.deepEqual( + cache.get(obj), + expected, + "Expected message for obj: " + JSON.stringify(obj) + ); + } + + // Delete some of the strings. We'll delete every other one to mix it up. + for (let i = 0; i < tests.length; i++) { + if (i % 2 == 0) { + let { obj } = tests[i]; + cache.delete(obj); + Assert.ok(!cache.get(obj), "Deleted from cache: " + JSON.stringify(obj)); + } + } + + // Get each remaining string. + for (let i = 0; i < tests.length; i++) { + if (i % 2 != 0) { + let { obj, expected } = tests[i]; + Assert.deepEqual( + cache.get(obj), + expected, + "Expected message for obj: " + JSON.stringify(obj) + ); + } + } + + // Clear the cache. + cache.clear(); + for (let { obj } of tests) { + Assert.ok(!cache.get(obj), "After cache clear: " + JSON.stringify(obj)); + } + + // `ensure` each test string and get it back. + for (let { obj, expected } of tests) { + await cache.ensure(obj); + let message = cache.get(obj); + Assert.deepEqual( + message, + expected, + "Expected message for obj: " + JSON.stringify(obj) + ); + + // Call `ensure` again. This time, `add` should not be called. + let originalAdd = cache.add; + cache.add = () => Assert.ok(false, "add erroneously called"); + await cache.ensure(obj); + cache.add = originalAdd; + } + + // Clear the cache again. + cache.clear(); + for (let { obj } of tests) { + Assert.ok(!cache.get(obj), "After cache clear: " + JSON.stringify(obj)); + } + + // `ensureAll` the test strings and get them back. + let objects = tests.map(({ obj }) => obj); + await cache.ensureAll(objects); + for (let { obj, expected } of tests) { + let message = cache.get(obj); + Assert.deepEqual( + message, + expected, + "Expected message for obj: " + JSON.stringify(obj) + ); + } + + // Ensure the cache is cleared after the app locale changes + Assert.greater(cache.size(), 0, "The cache has messages in it."); + Services.obs.notifyObservers(null, "intl:app-locales-changed"); + await l10n.ready; + Assert.equal(cache.size(), 0, "The cache is empty on app locale change"); +}); + +// Tests the `excludeArgsFromCacheKey` option. +add_task(async function excludeArgsFromCacheKey() { + // Set up a mock localization. + let l10n = initL10n({ + args0: "Zero args value", + args1: "One arg value is { $arg1 }", + attrs0: [".label = attrs0 label has zero args"], + attrs1: [ + ".label = attrs1 label has zero args", + ".tooltiptext = attrs1 tooltiptext arg value is { $arg1 }", + ], + }); + + let cache = new L10nCache(l10n); + + // Test cases. For each test case, we cache a string using one or more + // methods, `cache.add({ excludeArgsFromCacheKey: true })` and/or + // `cache.ensure({ excludeArgsFromCacheKey: true })`. After calling each + // method, we call `cache.get()` to get the cached string. + // + // Test cases are cumulative, so when `cache.add()` is called for a string and + // then `cache.ensure()` is called for the same string but with different l10n + // argument values, the string should be re-cached with the new values. + // + // Each item in the tests array is: `{ methods, obj, gets }` + // + // {array} methods + // Array of cache method names, one or more of: "add", "ensure" + // Methods are called in the order they are listed. + // {object} obj + // An l10n object that will be passed to the cache methods: + // `{ id, args, excludeArgsFromCacheKey }` + // {array} gets + // An array of objects that describes a series of calls to `cache.get()` and + // the expected return values: `{ obj, expected }` + // + // {object} obj + // An l10n object that will be passed to `cache.get():` + // `{ id, args, excludeArgsFromCacheKey }` + // {object} expected + // The expected return value from `get()`. + let tests = [ + // args0: string with no args and no attributes + { + methods: ["add", "ensure"], + obj: { + id: "args0", + excludeArgsFromCacheKey: true, + }, + gets: [ + { + obj: { id: "args0" }, + expected: { + value: "Zero args value", + attributes: null, + }, + }, + { + obj: { id: "args0", excludeArgsFromCacheKey: true }, + expected: { + value: "Zero args value", + attributes: null, + }, + }, + ], + }, + + // args1: string with one arg and no attributes + { + methods: ["add"], + obj: { + id: "args1", + args: { arg1: "ADD" }, + excludeArgsFromCacheKey: true, + }, + gets: [ + { + obj: { id: "args1" }, + expected: { + value: "One arg value is ADD", + attributes: null, + }, + }, + { + obj: { id: "args1", excludeArgsFromCacheKey: true }, + expected: { + value: "One arg value is ADD", + attributes: null, + }, + }, + { + obj: { + id: "args1", + args: { arg1: "some other value" }, + excludeArgsFromCacheKey: true, + }, + expected: { + value: "One arg value is ADD", + attributes: null, + }, + }, + { + obj: { + id: "args1", + args: { arg1: "some other value" }, + }, + expected: undefined, + }, + ], + }, + { + methods: ["ensure"], + obj: { + id: "args1", + args: { arg1: "ENSURE" }, + excludeArgsFromCacheKey: true, + }, + gets: [ + { + obj: { id: "args1" }, + expected: { + value: "One arg value is ENSURE", + attributes: null, + }, + }, + { + obj: { id: "args1", excludeArgsFromCacheKey: true }, + expected: { + value: "One arg value is ENSURE", + attributes: null, + }, + }, + { + obj: { + id: "args1", + args: { arg1: "some other value" }, + excludeArgsFromCacheKey: true, + }, + expected: { + value: "One arg value is ENSURE", + attributes: null, + }, + }, + { + obj: { + id: "args1", + args: { arg1: "some other value" }, + }, + expected: undefined, + }, + ], + }, + + // attrs0: string with no args and one attribute + { + methods: ["add", "ensure"], + obj: { + id: "attrs0", + excludeArgsFromCacheKey: true, + }, + gets: [ + { + obj: { id: "attrs0" }, + expected: { + value: null, + attributes: { + label: "attrs0 label has zero args", + }, + }, + }, + { + obj: { id: "attrs0", excludeArgsFromCacheKey: true }, + expected: { + value: null, + attributes: { + label: "attrs0 label has zero args", + }, + }, + }, + ], + }, + + // attrs1: string with one arg and two attributes + { + methods: ["add"], + obj: { + id: "attrs1", + args: { arg1: "ADD" }, + excludeArgsFromCacheKey: true, + }, + gets: [ + { + obj: { id: "attrs1" }, + expected: { + value: null, + attributes: { + label: "attrs1 label has zero args", + tooltiptext: "attrs1 tooltiptext arg value is ADD", + }, + }, + }, + { + obj: { id: "attrs1", excludeArgsFromCacheKey: true }, + expected: { + value: null, + attributes: { + label: "attrs1 label has zero args", + tooltiptext: "attrs1 tooltiptext arg value is ADD", + }, + }, + }, + { + obj: { + id: "attrs1", + args: { arg1: "some other value" }, + excludeArgsFromCacheKey: true, + }, + expected: { + value: null, + attributes: { + label: "attrs1 label has zero args", + tooltiptext: "attrs1 tooltiptext arg value is ADD", + }, + }, + }, + { + obj: { + id: "attrs1", + args: { arg1: "some other value" }, + }, + expected: undefined, + }, + ], + }, + { + methods: ["ensure"], + obj: { + id: "attrs1", + args: { arg1: "ENSURE" }, + excludeArgsFromCacheKey: true, + }, + gets: [ + { + obj: { id: "attrs1" }, + expected: { + value: null, + attributes: { + label: "attrs1 label has zero args", + tooltiptext: "attrs1 tooltiptext arg value is ENSURE", + }, + }, + }, + { + obj: { id: "attrs1", excludeArgsFromCacheKey: true }, + expected: { + value: null, + attributes: { + label: "attrs1 label has zero args", + tooltiptext: "attrs1 tooltiptext arg value is ENSURE", + }, + }, + }, + { + obj: { + id: "attrs1", + args: { arg1: "some other value" }, + excludeArgsFromCacheKey: true, + }, + expected: { + value: null, + attributes: { + label: "attrs1 label has zero args", + tooltiptext: "attrs1 tooltiptext arg value is ENSURE", + }, + }, + }, + { + obj: { + id: "attrs1", + args: { arg1: "some other value" }, + }, + expected: undefined, + }, + ], + }, + ]; + + let sandbox = sinon.createSandbox(); + let spy = sandbox.spy(cache, "add"); + + for (let { methods, obj, gets } of tests) { + for (let method of methods) { + info(`Calling method '${method}' with l10n obj: ` + JSON.stringify(obj)); + await cache[method](obj); + + // `add()` should always be called: We either just called it directly, or + // `ensure({ excludeArgsFromCacheKey: true })` called it. + Assert.ok( + spy.calledOnce, + "add() should have been called once: " + JSON.stringify(obj) + ); + spy.resetHistory(); + + for (let { obj: getObj, expected } of gets) { + Assert.deepEqual( + cache.get(getObj), + expected, + "Expected message for get: " + JSON.stringify(getObj) + ); + } + } + } + + sandbox.restore(); +}); + +/** + * Sets up a mock localization. + * + * @param {object} pairs + * Fluent strings as key-value pairs. + * @returns {Localization} + * The mock Localization object. + */ +function initL10n(pairs) { + let source = Object.entries(pairs) + .map(([key, value]) => { + if (Array.isArray(value)) { + value = value.map(s => " \n" + s).join(""); + } + return `${key} = ${value}`; + }) + .join("\n"); + let registry = new L10nRegistry(); + registry.registerSources([ + L10nFileSource.createMock( + "test", + "app", + ["en-US"], + "/localization/{locale}", + [{ source, path: "/localization/en-US/test.ftl" }] + ), + ]); + return new Localization(["/test.ftl"], true, registry, ["en-US"]); +} diff --git a/browser/components/urlbar/tests/unit/test_local_suggest_prefs.js b/browser/components/urlbar/tests/unit/test_local_suggest_prefs.js new file mode 100644 index 0000000000..192265661a --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_local_suggest_prefs.js @@ -0,0 +1,126 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim:set ts=2 sw=2 sts=2 et: + * 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/. */ + +// Test for following preferences related to local suggest. +// * browser.urlbar.suggest.bookmark +// * browser.urlbar.suggest.history +// * browser.urlbar.suggest.openpage + +testEngine_setup(); + +add_setup(async () => { + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.engines", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + + const uri = Services.io.newURI("http://example.com/"); + + await PlacesTestUtils.addVisits([{ uri, title: "example" }]); + await PlacesUtils.bookmarks.insert({ + url: uri, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + await addOpenPages(uri); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + Services.prefs.clearUserPref("browser.urlbar.suggest.engines"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + + Services.prefs.clearUserPref("browser.urlbar.suggest.bookmark"); + Services.prefs.clearUserPref("browser.urlbar.suggest.history"); + Services.prefs.clearUserPref("browser.urlbar.suggest.openpage"); + await cleanupPlaces(); + }); +}); + +add_task(async function test_prefs() { + const testData = [ + { + bookmark: true, + history: true, + openpage: true, + }, + { + bookmark: false, + history: true, + openpage: true, + }, + { + bookmark: true, + history: false, + openpage: true, + }, + { + bookmark: true, + history: true, + openpage: false, + }, + { + bookmark: false, + history: false, + openpage: true, + }, + { + bookmark: false, + history: true, + openpage: false, + }, + { + bookmark: true, + history: false, + openpage: false, + }, + { + bookmark: false, + history: false, + openpage: false, + }, + ]; + + for (const { bookmark, history, openpage } of testData) { + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", bookmark); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", history); + Services.prefs.setBoolPref("browser.urlbar.suggest.openpage", openpage); + + info(`Test bookmark:${bookmark} history:${history} openpage:${openpage}`); + + const context = createContext("e", { isPrivate: false }); + const matches = []; + + matches.push( + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }) + ); + + if (openpage) { + matches.push( + makeTabSwitchResult(context, { + uri: "http://example.com/", + title: "example", + }) + ); + } else if (bookmark) { + matches.push( + makeBookmarkResult(context, { + uri: "http://example.com/", + title: "example", + }) + ); + } else if (history) { + matches.push( + makeVisitResult(context, { + uri: "http://example.com/", + title: "example", + }) + ); + } + + await check_results({ context, matches }); + } +}); diff --git a/browser/components/urlbar/tests/unit/test_match_javascript.js b/browser/components/urlbar/tests/unit/test_match_javascript.js new file mode 100644 index 0000000000..3d3eab19ba --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_match_javascript.js @@ -0,0 +1,153 @@ +/* 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/. */ + +/** + * Test for bug 417798 to make sure javascript: URIs don't show up unless the + * user searches for javascript: explicitly. + */ + +testEngine_setup(); + +add_task(async function test_javascript_match() { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.engines", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref("browser.urlbar.suggest.engines"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + }); + + let uri1 = Services.io.newURI("http://abc/def"); + let uri2 = Services.io.newURI("javascript:5"); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri2, + title: "Title with javascript:", + }); + await PlacesTestUtils.addVisits([ + { uri: uri1, title: "Title with javascript:" }, + ]); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("Match non-javascript: with plain search"); + let context = createContext("a", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "Title with javascript:", + }), + ], + }); + + info("Match non-javascript: with 'javascript'"); + context = createContext("javascript", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "Title with javascript:", + }), + ], + }); + + info("Match non-javascript with 'javascript:'"); + context = createContext("javascript:", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "Title with javascript:", + }), + ], + }); + + info("Match nothing with '5 javascript:'"); + context = createContext("5 javascript:", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + info("Match non-javascript: with 'a javascript:'"); + context = createContext("a javascript:", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "Title with javascript:", + }), + ], + }); + + info("Match non-javascript: and javascript: with 'javascript: a'"); + context = createContext("javascript: a", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: "javascript: a", + fallbackTitle: "javascript: a", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "Title with javascript:", + }), + makeBookmarkResult(context, { + uri: uri2.spec, + iconUri: "chrome://global/skin/icons/defaultFavicon.svg", + title: "Title with javascript:", + }), + ], + }); + + info("Match javascript: with 'javascript: 5'"); + context = createContext("javascript: 5", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: "javascript: 5", + fallbackTitle: "javascript: 5", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri2.spec, + iconUri: "chrome://global/skin/icons/defaultFavicon.svg", + title: "Title with javascript:", + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_multi_word_search.js b/browser/components/urlbar/tests/unit/test_multi_word_search.js new file mode 100644 index 0000000000..7054feb8aa --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_multi_word_search.js @@ -0,0 +1,126 @@ +/* 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/. */ + +/** + * Test for bug 401869 to allow multiple words separated by spaces to match in + * the page title, page url, or bookmark title to be considered a match. All + * terms must match but not all terms need to be in the title, etc. + * + * Test bug 424216 by making sure bookmark titles are always shown if one is + * available. Also bug 425056 makes sure matches aren't found partially in the + * page title and partially in the bookmark. + */ + +testEngine_setup(); + +add_task(async function test_match_beginning() { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + let uri1 = Services.io.newURI("http://a.b.c/d-e_f/h/t/p"); + let uri2 = Services.io.newURI("http://d.e.f/g-h_i/h/t/p"); + let uri3 = Services.io.newURI("http://g.h.i/j-k_l/h/t/p"); + let uri4 = Services.io.newURI("http://j.k.l/m-n_o/h/t/p"); + await PlacesTestUtils.addVisits([ + { uri: uri4, title: "f(o)o br" }, + { uri: uri3, title: "f(o)o br" }, + { uri: uri2, title: "b(a)r bz" }, + { uri: uri1, title: "f(o)o br" }, + ]); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri3, + title: "f(o)o br", + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri4, + title: "b(a)r bz", + }); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("Match 2 terms all in url"); + let context = createContext("c d", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri1.spec, title: "f(o)o br" }), + ], + }); + + info("Match 1 term in url and 1 term in title"); + context = createContext("b e", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri1.spec, title: "f(o)o br" }), + makeVisitResult(context, { uri: uri2.spec, title: "b(a)r bz" }), + ], + }); + + info("Match 3 terms all in title; display bookmark title if matched"); + context = createContext("b a z", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { uri: uri4.spec, title: "b(a)r bz" }), + makeVisitResult(context, { uri: uri2.spec, title: "b(a)r bz" }), + ], + }); + + info( + "Match 2 terms in url and 1 in title; make sure bookmark title is used for search" + ); + context = createContext("k f t", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { uri: uri3.spec, title: "f(o)o br" }), + ], + }); + + info("Match 3 terms in url and 1 in title"); + context = createContext("d i g z", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri2.spec, title: "b(a)r bz" }), + ], + }); + + info("Match nothing"); + context = createContext("m o z i", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_muxer.js b/browser/components/urlbar/tests/unit/test_muxer.js new file mode 100644 index 0000000000..8d4eef4ba2 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_muxer.js @@ -0,0 +1,731 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +let sandbox; + +add_setup(async function () { + sandbox = lazy.sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); +}); + +add_task(async function test_muxer() { + Assert.throws( + () => UrlbarProvidersManager.registerMuxer(), + /invalid muxer/, + "Should throw with no arguments" + ); + Assert.throws( + () => UrlbarProvidersManager.registerMuxer({}), + /invalid muxer/, + "Should throw with empty object" + ); + Assert.throws( + () => + UrlbarProvidersManager.registerMuxer({ + name: "", + }), + /invalid muxer/, + "Should throw with empty name" + ); + Assert.throws( + () => + UrlbarProvidersManager.registerMuxer({ + name: "test", + sort: "no", + }), + /invalid muxer/, + "Should throw with invalid sort" + ); + + let matches = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: "http://mozilla.org/tab/" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + { url: "http://mozilla.org/bookmark/" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/history/" } + ), + ]; + + let provider = registerBasicTestProvider(matches); + let context = createContext(undefined, { providers: [provider.name] }); + let controller = UrlbarTestUtils.newMockController(); + /** + * A test muxer. + */ + class TestMuxer extends UrlbarMuxer { + get name() { + return "TestMuxer"; + } + sort(queryContext, unsortedResults) { + queryContext.results = [...unsortedResults].sort((a, b) => { + if (b.source == UrlbarUtils.RESULT_SOURCE.TABS) { + return -1; + } + if (b.source == UrlbarUtils.RESULT_SOURCE.BOOKMARKS) { + return 1; + } + return a.source == UrlbarUtils.RESULT_SOURCE.BOOKMARKS ? -1 : 1; + }); + } + } + let muxer = new TestMuxer(); + + UrlbarProvidersManager.registerMuxer(muxer); + context.muxer = "TestMuxer"; + + info("Check results, the order should be: bookmark, history, tab"); + await UrlbarProvidersManager.startQuery(context, controller); + Assert.deepEqual(context.results, [matches[1], matches[2], matches[0]]); + + // Sanity check, should not throw. + UrlbarProvidersManager.unregisterMuxer(muxer); + UrlbarProvidersManager.unregisterMuxer("TestMuxer"); // no-op. +}); + +add_task(async function test_preselectedHeuristic_singleProvider() { + let matches = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/a" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/b" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/c" } + ), + ]; + matches[1].heuristic = true; + + let provider = registerBasicTestProvider(matches); + let context = createContext(undefined, { + providers: [provider.name], + }); + let controller = UrlbarTestUtils.newMockController(); + + info("Check results, the order should be: b (heuristic), a, c"); + await UrlbarProvidersManager.startQuery(context, controller); + Assert.deepEqual(context.results, [matches[1], matches[0], matches[2]]); +}); + +add_task(async function test_preselectedHeuristic_multiProviders() { + let matches1 = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/a" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/b" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/c" } + ), + ]; + + let matches2 = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/d" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/e" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/f" } + ), + ]; + matches2[1].heuristic = true; + + let provider1 = registerBasicTestProvider(matches1); + let provider2 = registerBasicTestProvider(matches2); + + let context = createContext(undefined, { + providers: [provider1.name, provider2.name], + }); + let controller = UrlbarTestUtils.newMockController(); + + info("Check results, the order should be: e (heuristic), a, b, c, d, f"); + await UrlbarProvidersManager.startQuery(context, controller); + Assert.deepEqual(context.results, [ + matches2[1], + ...matches1, + matches2[0], + matches2[2], + ]); +}); + +add_task(async function test_suggestions() { + Services.prefs.setIntPref("browser.urlbar.maxHistoricalSearchSuggestions", 1); + + let matches = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/a" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/b" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { + engine: "mozSearch", + query: "moz", + suggestion: "mozzarella", + lowerCaseSuggestion: "mozzarella", + } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + engine: "mozSearch", + query: "moz", + suggestion: "mozilla", + lowerCaseSuggestion: "mozilla", + } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + engine: "mozSearch", + query: "moz", + providesSearchMode: true, + keyword: "@moz", + } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/c" } + ), + ]; + + let provider = registerBasicTestProvider(matches); + + let context = createContext(undefined, { + providers: [provider.name], + }); + let controller = UrlbarTestUtils.newMockController(); + + info("Check results, the order should be: mozzarella, moz, a, b, @moz, c"); + await UrlbarProvidersManager.startQuery(context, controller); + Assert.deepEqual(context.results, [ + matches[2], + matches[3], + matches[0], + matches[1], + matches[4], + matches[5], + ]); + + Services.prefs.clearUserPref("browser.urlbar.maxHistoricalSearchSuggestions"); +}); + +add_task(async function test_deduplicate_for_unitConversion() { + const searchSuggestion = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + engine: "Google", + query: "10cm to m", + suggestion: "= 0.1 meters", + } + ); + const searchProvider = registerBasicTestProvider( + [searchSuggestion], + null, + UrlbarUtils.PROVIDER_TYPE.PROFILE + ); + + const unitConversionSuggestion = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.DYNAMIC, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + dynamicType: "unitConversion", + output: "0.1 m", + input: "10cm to m", + } + ); + unitConversionSuggestion.suggestedIndex = 1; + + const unitConversion = registerBasicTestProvider( + [unitConversionSuggestion], + null, + UrlbarUtils.PROVIDER_TYPE.PROFILE, + "UnitConversion" + ); + + const context = createContext(undefined, { + providers: [searchProvider.name, unitConversion.name], + }); + const controller = UrlbarTestUtils.newMockController(); + await UrlbarProvidersManager.startQuery(context, controller); + Assert.deepEqual(context.results, [unitConversionSuggestion]); +}); + +// These results are used in the badHeuristicGroups tests below. The order of +// the results in the array isn't important because they all get added at the +// same time. It's the resultGroups in each test that is important. +const BAD_HEURISTIC_RESULTS = [ + // heuristic + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/heuristic-0" } + ), + { heuristic: true } + ), + // heuristic + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/heuristic-1" } + ), + { heuristic: true } + ), + // non-heuristic + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/non-heuristic-0" } + ), + // non-heuristic + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/non-heuristic-1" } + ), +]; + +const BAD_HEURISTIC_RESULTS_FIRST_HEURISTIC = BAD_HEURISTIC_RESULTS[0]; +const BAD_HEURISTIC_RESULTS_GENERAL = [ + BAD_HEURISTIC_RESULTS[2], + BAD_HEURISTIC_RESULTS[3], +]; + +add_task(async function test_badHeuristicGroups_multiple_0() { + await doBadHeuristicGroupsTest( + [ + // 2 heuristics with child groups + { + maxResultCount: 2, + children: [{ group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }], + }, + // infinite general + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + [BAD_HEURISTIC_RESULTS_FIRST_HEURISTIC, ...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicGroups_multiple_1() { + await doBadHeuristicGroupsTest( + [ + // infinite heuristics with child groups + { + children: [{ group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }], + }, + // infinite general + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + [BAD_HEURISTIC_RESULTS_FIRST_HEURISTIC, ...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicGroups_multiple_2() { + await doBadHeuristicGroupsTest( + [ + // 2 heuristics + { + maxResultCount: 2, + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + }, + // infinite general + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + [BAD_HEURISTIC_RESULTS_FIRST_HEURISTIC, ...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicGroups_multiple_3() { + await doBadHeuristicGroupsTest( + [ + // infinite heuristics + { + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + }, + // infinite general + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + [BAD_HEURISTIC_RESULTS_FIRST_HEURISTIC, ...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicGroups_multiple_4() { + await doBadHeuristicGroupsTest( + [ + // 1 heuristic with child groups + { + maxResultCount: 1, + children: [{ group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }], + }, + // infinite general + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + // 1 heuristic with child groups + { + maxResultCount: 1, + children: [{ group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }], + }, + ], + [BAD_HEURISTIC_RESULTS_FIRST_HEURISTIC, ...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicGroups_multiple_5() { + await doBadHeuristicGroupsTest( + [ + // infinite heuristics with child groups + { + children: [{ group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }], + }, + // infinite general + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + // infinite heuristics with child groups + { + children: [{ group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }], + }, + ], + [BAD_HEURISTIC_RESULTS_FIRST_HEURISTIC, ...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicGroups_multiple_6() { + await doBadHeuristicGroupsTest( + [ + // 1 heuristic + { + maxResultCount: 1, + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + }, + // infinite general + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + // 1 heuristic + { + maxResultCount: 1, + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + }, + ], + [BAD_HEURISTIC_RESULTS_FIRST_HEURISTIC, ...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicGroups_multiple_7() { + await doBadHeuristicGroupsTest( + [ + // infinite heuristics + { + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + }, + // infinite general + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + // infinite heuristics + { + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + }, + ], + [BAD_HEURISTIC_RESULTS_FIRST_HEURISTIC, ...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicsGroups_notFirst_0() { + await doBadHeuristicGroupsTest( + [ + // infinite general first + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + // 1 heuristic with child groups second + { + maxResultCount: 1, + children: [{ group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }], + }, + ], + [...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicsGroups_notFirst_1() { + await doBadHeuristicGroupsTest( + [ + // infinite general first + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + // infinite heuristics with child groups second + { + children: [{ group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }], + }, + ], + [...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicsGroups_notFirst_2() { + await doBadHeuristicGroupsTest( + [ + // infinite general first + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + // 1 heuristic second + { + maxResultCount: 1, + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + }, + ], + [...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicsGroups_notFirst_3() { + await doBadHeuristicGroupsTest( + [ + // infinite general first + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + // infinite heuristics second + { + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + }, + ], + [...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicsGroups_notFirst_4() { + await doBadHeuristicGroupsTest( + [ + // 1 general first + { + maxResultCount: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + // infinite heuristics second + { + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + }, + // infinite general third + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + [...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +/** + * Sets the resultGroups pref, performs a search, and then checks the results. + * Regardless of the groups, the muxer should include at most one heuristic in + * its results and it should always be the first result. + * + * @param {Array} resultGroups + * The result groups. + * @param {Array} expectedResults + * The expected results. + */ +async function doBadHeuristicGroupsTest(resultGroups, expectedResults) { + sandbox.stub(UrlbarPrefs, "resultGroups").get(() => { + return { children: resultGroups }; + }); + + let provider = registerBasicTestProvider(BAD_HEURISTIC_RESULTS); + let context = createContext("foo", { providers: [provider.name] }); + let controller = UrlbarTestUtils.newMockController(); + await UrlbarProvidersManager.startQuery(context, controller); + Assert.deepEqual(context.results, expectedResults); + + sandbox.restore(); +} + +// When `maxRichResults` is positive and taken up by suggested-index result(s), +// both the heuristic and suggested-index results should be included because we +// (a) make room for the heuristic and (b) assume all suggested-index results +// should be included even if it means exceeding `maxRichResults`. The specified +// `maxRichResults` span will be exceeded in this case. +add_task(async function roomForHeuristic_suggestedIndex() { + let results = [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://example.com/heuristic" } + ), + { heuristic: true } + ), + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://example.com/suggestedIndex" } + ), + { suggestedIndex: 1 } + ), + ]; + + UrlbarPrefs.set("maxRichResults", 1); + + let provider = registerBasicTestProvider(results); + let context = createContext(undefined, { providers: [provider.name] }); + await check_results({ + context, + matches: results, + }); + + UrlbarPrefs.clear("maxRichResults"); +}); + +// When `maxRichResults` is positive but less than the heuristic's result span, +// the heuristic should be included because we make room for it even if it means +// exceeding `maxRichResults`. The specified `maxRichResults` span will be +// exceeded in this case. +add_task(async function roomForHeuristic_largeResultSpan() { + let results = [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://example.com/heuristic" } + ), + { heuristic: true, resultSpan: 2 } + ), + ]; + + UrlbarPrefs.set("maxRichResults", 1); + + let provider = registerBasicTestProvider(results); + let context = createContext(undefined, { providers: [provider.name] }); + await check_results({ + context, + matches: results, + }); + + UrlbarPrefs.clear("maxRichResults"); +}); + +// When `maxRichResults` is zero and there are no suggested-index results, the +// heuristic should not be included. +add_task(async function roomForHeuristic_maxRichResultsZero() { + let results = [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://example.com/heuristic" } + ), + { heuristic: true } + ), + ]; + + UrlbarPrefs.set("maxRichResults", 0); + + let provider = registerBasicTestProvider(results); + let context = createContext(undefined, { providers: [provider.name] }); + await check_results({ + context, + matches: [], + }); + + UrlbarPrefs.clear("maxRichResults"); +}); + +// When `maxRichResults` is zero and suggested-index results are present, +// neither the heuristic nor the suggested-index results should be included. +add_task(async function roomForHeuristic_maxRichResultsZero_suggestedIndex() { + let results = [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://example.com/heuristic" } + ), + { heuristic: true } + ), + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://example.com/suggestedIndex" } + ), + { suggestedIndex: 1 } + ), + ]; + + UrlbarPrefs.set("maxRichResults", 0); + + let provider = registerBasicTestProvider(results); + let context = createContext(undefined, { providers: [provider.name] }); + await check_results({ + context, + matches: [], + }); + + UrlbarPrefs.clear("maxRichResults"); +}); diff --git a/browser/components/urlbar/tests/unit/test_pages_alt_frecency.js b/browser/components/urlbar/tests/unit/test_pages_alt_frecency.js new file mode 100644 index 0000000000..41452587d4 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_pages_alt_frecency.js @@ -0,0 +1,85 @@ +/* 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 is a basic autocomplete test to ensure enabling the alternative frecency +// algorithm doesn't break results and sorts them appropriately. +// A more comprehensive testing of the algorithm itself is not included since it +// is something that may change frequently according to experimentation results. +// Other existing tests will, of course, need to be adapted once an algorithm +// is promoted to be the default. + +testEngine_setup(); + +add_task(async function test_autofill() { + const searchString = "match"; + const singleVisitUrl = "https://singlevisit-match.org/"; + const singleVisitBookmarkedUrl = "https://singlevisitbookmarked-match.org/"; + const adaptiveVisitUrl = "https://adaptivevisit-match.org/"; + const adaptiveManyVisitsUrl = "https://adaptivemanyvisit-match.org/"; + const manyVisitsUrl = "https://manyvisits-match.org/"; + const sampledVisitsUrl = "https://sampledvisits-match.org/"; + const bookmarkedUrl = "https://bookmarked-match.org/"; + + await PlacesUtils.bookmarks.insert({ + url: bookmarkedUrl, + title: "bookmark", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + await PlacesUtils.bookmarks.insert({ + url: singleVisitBookmarkedUrl, + title: "visited bookmark", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + await PlacesTestUtils.addVisits([ + singleVisitUrl, + singleVisitBookmarkedUrl, + adaptiveVisitUrl, + ...new Array(10).fill(adaptiveManyVisitsUrl), + ...new Array(100).fill(manyVisitsUrl), + ...new Array(10).fill(sampledVisitsUrl), + ]); + await UrlbarUtils.addToInputHistory(adaptiveVisitUrl, searchString); + await UrlbarUtils.addToInputHistory(adaptiveManyVisitsUrl, searchString); + + let context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + makeVisitResult(context, { + uri: adaptiveManyVisitsUrl, + title: `test visit for ${adaptiveManyVisitsUrl}`, + }), + makeVisitResult(context, { + uri: adaptiveVisitUrl, + title: `test visit for ${adaptiveVisitUrl}`, + }), + makeVisitResult(context, { + uri: manyVisitsUrl, + title: `test visit for ${manyVisitsUrl}`, + }), + makeVisitResult(context, { + uri: sampledVisitsUrl, + title: `test visit for ${sampledVisitsUrl}`, + }), + makeBookmarkResult(context, { + uri: singleVisitBookmarkedUrl, + title: "visited bookmark", + }), + makeBookmarkResult(context, { + uri: bookmarkedUrl, + title: "bookmark", + }), + makeVisitResult(context, { + uri: singleVisitUrl, + title: `test visit for ${singleVisitUrl}`, + }), + ], + }); + + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/urlbar/tests/unit/test_protocol_ignore.js b/browser/components/urlbar/tests/unit/test_protocol_ignore.js new file mode 100644 index 0000000000..2e5096cb46 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_protocol_ignore.js @@ -0,0 +1,42 @@ +/* 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/. */ + +/** + * Test bug 424509 to make sure searching for "h" doesn't match "http" of urls. + */ + +testEngine_setup(); + +add_task(async function test_escape() { + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + let uri1 = Services.io.newURI("http://site/"); + let uri2 = Services.io.newURI("http://happytimes/"); + await PlacesTestUtils.addVisits([ + { uri: uri1, title: "title" }, + { uri: uri2, title: "title" }, + ]); + + info("Searching for h matches site and not http://"); + let context = createContext("h", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri2.spec, + title: "title", + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_protocol_swap.js b/browser/components/urlbar/tests/unit/test_protocol_swap.js new file mode 100644 index 0000000000..4640b167f5 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_protocol_swap.js @@ -0,0 +1,302 @@ +/* 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/. */ + +/** + * Test bug 424717 to make sure searching with an existing location like + * http://site/ also matches https://site/ or ftp://site/. Same thing for + * ftp://site/ and https://site/. + * + * Test bug 461483 to make sure a search for "w" doesn't match the "www." from + * site subdomains. + */ + +testEngine_setup(); + +add_task(async function test_swap_protocol() { + let uri1 = Services.io.newURI("http://www.site/"); + let uri2 = Services.io.newURI("http://site/"); + let uri3 = Services.io.newURI("ftp://ftp.site/"); + let uri4 = Services.io.newURI("ftp://site/"); + let uri5 = Services.io.newURI("https://www.site/"); + let uri6 = Services.io.newURI("https://site/"); + let uri7 = Services.io.newURI("http://woohoo/"); + let uri8 = Services.io.newURI("http://wwwwwwacko/"); + await PlacesTestUtils.addVisits([ + { uri: uri8, title: "title" }, + { uri: uri7, title: "title" }, + { uri: uri6, title: "title" }, + { uri: uri5, title: "title" }, + { uri: uri4, title: "title" }, + { uri: uri3, title: "title" }, + { uri: uri2, title: "title" }, + { uri: uri1, title: "title" }, + ]); + + // Disable autoFill to avoid handling the first result. + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + info("http://www.site matches 'www.site' pages"); + let searchString = "http://www.site"; + let context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + title: "title", + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "title" }), + ], + }); + + info("http://site matches all sites"); + searchString = "http://site"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + title: "title", + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + heuristic: true, + }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "title" }), + makeVisitResult(context, { uri: uri6.spec, title: "title" }), + ], + }); + + info("ftp://ftp.site matches itself"); + searchString = "ftp://ftp.site"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + fallbackTitle: `${searchString}/`, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + ], + }); + + info("ftp://site matches all sites"); + searchString = "ftp://site"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + fallbackTitle: `${searchString}/`, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "title" }), + makeVisitResult(context, { uri: uri6.spec, title: "title" }), + ], + }); + + info("https://www.site matches all sites"); + searchString = "https://www.sit"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + fallbackTitle: `${searchString}/`, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "title" }), + ], + }); + + info("https://site matches all sites"); + searchString = "https://sit"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + fallbackTitle: `${searchString}/`, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "title" }), + makeVisitResult(context, { uri: uri6.spec, title: "title" }), + ], + }); + + info("www.site matches 'www.site' pages"); + searchString = "www.site"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `http://${searchString}/`, + title: "title", + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "title" }), + ], + }); + + info("w matches 'w' pages, including 'www'"); + context = createContext("w", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "title" }), + makeVisitResult(context, { uri: uri7.spec, title: "title" }), + makeVisitResult(context, { uri: uri8.spec, title: "title" }), + ], + }); + + info("http://w matches 'w' pages, including 'www'"); + searchString = "http://w"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + fallbackTitle: `${searchString}/`, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "title" }), + makeVisitResult(context, { uri: uri7.spec, title: "title" }), + makeVisitResult(context, { uri: uri8.spec, title: "title" }), + ], + }); + + info("http://www.w matches nothing"); + searchString = "http://www.w"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + fallbackTitle: `${searchString}/`, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + ], + }); + + info("ww matches no 'ww' pages, including 'www'"); + context = createContext("ww", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "title" }), + makeVisitResult(context, { uri: uri8.spec, title: "title" }), + ], + }); + + info("http://ww matches no 'ww' pages, including 'www'"); + searchString = "http://ww"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + fallbackTitle: `${searchString}/`, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "title" }), + makeVisitResult(context, { uri: uri8.spec, title: "title" }), + ], + }); + + info("http://www.ww matches nothing"); + searchString = "http://www.ww"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + fallbackTitle: `${searchString}/`, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + ], + }); + + info("www matches 'www' pages"); + context = createContext("www", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "title" }), + makeVisitResult(context, { uri: uri8.spec, title: "title" }), + ], + }); + + info("http://www matches 'www' pages"); + searchString = "http://www"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + fallbackTitle: `${searchString}/`, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "title" }), + makeVisitResult(context, { uri: uri8.spec, title: "title" }), + ], + }); + + info("http://www.www matches nothing"); + searchString = "http://www.www"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + fallbackTitle: `${searchString}/`, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerAliasEngines.js b/browser/components/urlbar/tests/unit/test_providerAliasEngines.js new file mode 100644 index 0000000000..bf2ce13e7e --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerAliasEngines.js @@ -0,0 +1,146 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests search engine aliases. See + * browser/components/urlbar/tests/browser/browser_tokenAlias.js for tests of + * the token alias list (i.e. showing all aliased engines on a "@" query). + */ + +testEngine_setup(); + +// Basic test that uses two engines, a GET engine and a POST engine, neither +// providing search suggestions. +add_task(async function basicGetAndPost() { + await SearchTestUtils.installSearchExtension({ + name: "AliasedGETMozSearch", + keyword: "get", + search_url: "https://s.example.com/search", + }); + await SearchTestUtils.installSearchExtension({ + name: "AliasedPOSTMozSearch", + keyword: "post", + search_url: "https://s.example.com/search", + search_url_post_params: "q={searchTerms}", + }); + + for (let alias of ["get", "post"]) { + let context = createContext(alias, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + providerName: "HeuristicFallback", + }), + ], + }); + + context = createContext(`${alias} `, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: `Aliased${alias.toUpperCase()}MozSearch`, + query: "", + alias, + heuristic: true, + providerName: "AliasEngines", + }), + ], + }); + + context = createContext(`${alias} fire`, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: `Aliased${alias.toUpperCase()}MozSearch`, + query: "fire", + alias, + heuristic: true, + providerName: "AliasEngines", + }), + ], + }); + + context = createContext(`${alias} mozilla`, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: `Aliased${alias.toUpperCase()}MozSearch`, + query: "mozilla", + alias, + heuristic: true, + providerName: "AliasEngines", + }), + ], + }); + + context = createContext(`${alias} MoZiLlA`, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: `Aliased${alias.toUpperCase()}MozSearch`, + query: "MoZiLlA", + alias, + heuristic: true, + providerName: "AliasEngines", + }), + ], + }); + + context = createContext(`${alias} mozzarella mozilla`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: `Aliased${alias.toUpperCase()}MozSearch`, + query: "mozzarella mozilla", + alias, + heuristic: true, + providerName: "AliasEngines", + }), + ], + }); + + context = createContext(`${alias} kitten?`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: `Aliased${alias.toUpperCase()}MozSearch`, + query: "kitten?", + alias, + heuristic: true, + providerName: "AliasEngines", + }), + ], + }); + + context = createContext(`${alias} kitten ?`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: `Aliased${alias.toUpperCase()}MozSearch`, + query: "kitten ?", + alias, + heuristic: true, + providerName: "AliasEngines", + }), + ], + }); + } + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerHeuristicFallback.js b/browser/components/urlbar/tests/unit/test_providerHeuristicFallback.js new file mode 100644 index 0000000000..7b331b346b --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerHeuristicFallback.js @@ -0,0 +1,775 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that visit-url and search engine heuristic results are returned by + * UrlbarProviderHeuristicFallback. + */ + +const QUICKACTIONS_PREF = "browser.urlbar.suggest.quickactions"; +const SUGGEST_ENABLED_PREF = "browser.search.suggest.enabled"; +const PRIVATE_SEARCH_PREF = "browser.search.separatePrivateDefault.ui.enabled"; + +// We make sure that restriction tokens and search terms are correctly +// recognized when they are separated by each of these different types of spaces +// and combinations of spaces. U+3000 is the ideographic space in CJK and is +// commonly used by CJK speakers. +const TEST_SPACES = [" ", "\u3000", " \u3000", "\u3000 "]; + +testEngine_setup(); + +add_setup(async function () { + registerCleanupFunction(async () => { + Services.prefs.clearUserPref(QUICKACTIONS_PREF); + Services.prefs.clearUserPref(SUGGEST_ENABLED_PREF); + Services.prefs.clearUserPref(PRIVATE_SEARCH_PREF); + Services.prefs.clearUserPref("keyword.enabled"); + }); + Services.prefs.setBoolPref(QUICKACTIONS_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, false); + Services.prefs.setBoolPref(PRIVATE_SEARCH_PREF, false); +}); + +add_task(async function () { + info("visit url, no protocol"); + let query = "mozilla.org"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}/`, + fallbackTitle: `http://${query}/`, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ], + }); + + info("visit url, no protocol but with 2 dots"); + query = "www.mozilla.org"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}/`, + fallbackTitle: `http://${query}/`, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ], + }); + + info("visit url, no protocol, e-mail like"); + query = "a@b.com"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}/`, + fallbackTitle: `http://${query}/`, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ], + }); + + info("visit url, with protocol but with 2 dots"); + query = "https://www.mozilla.org"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `${query}/`, + fallbackTitle: `${query}/`, + heuristic: true, + }), + ], + }); + + // info("visit url, with protocol but with 3 dots"); + query = "https://www.mozilla.org.tw"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `${query}/`, + fallbackTitle: `${query}/`, + heuristic: true, + }), + ], + }); + + info("visit url, with protocol"); + query = "https://mozilla.org"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `${query}/`, + fallbackTitle: `${query}/`, + heuristic: true, + }), + ], + }); + + info("visit url, about: protocol (no host)"); + query = "about:nonexistent"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: query, + fallbackTitle: query, + heuristic: true, + }), + ], + }); + + info("visit url, with non-standard whitespace"); + query = "https://mozilla.org"; + context = createContext(`${query}\u2028`, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `${query}/`, + fallbackTitle: `${query}/`, + heuristic: true, + }), + ], + }); + + // This is distinct because of how we predict being able to url autofill via + // host lookups. + info("visit url, host matching visited host but not visited url"); + await PlacesTestUtils.addVisits([ + { + uri: Services.io.newURI("http://mozilla.org/wine/"), + title: "Mozilla Wine", + transition: PlacesUtils.history.TRANSITION_TYPED, + }, + ]); + query = "mozilla.org/rum"; + context = createContext(`${query}\u2028`, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}`, + fallbackTitle: `http://${query}`, + iconUri: "page-icon:http://mozilla.org/", + heuristic: true, + }), + ], + }); + await PlacesUtils.history.clear(); + + // And hosts with no dot in them are special, due to requiring safelisting. + info("unknown host"); + query = "firefox"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + info("string with known host"); + query = "firefox/get"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + Services.prefs.setBoolPref("browser.fixup.domainwhitelist.firefox", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.fixup.domainwhitelist.firefox"); + }); + + info("known host"); + query = "firefox"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}/`, + fallbackTitle: `http://${query}/`, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ], + }); + + info("url with known host"); + query = "firefox/get"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}`, + fallbackTitle: `http://${query}`, + iconUri: "page-icon:http://firefox/", + heuristic: true, + }), + ], + }); + + info("visit url, host matching visited host but not visited url, known host"); + Services.prefs.setBoolPref("browser.fixup.domainwhitelist.mozilla", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.fixup.domainwhitelist.mozilla"); + }); + query = "mozilla/rum"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}`, + fallbackTitle: `http://${query}`, + iconUri: "page-icon:http://mozilla/", + heuristic: true, + }), + ], + }); + + // ipv4 and ipv6 literal addresses should offer to visit. + info("visit url, ipv4 literal"); + query = "127.0.0.1"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}/`, + fallbackTitle: `http://${query}/`, + heuristic: true, + }), + ], + }); + + info("visit url, ipv6 literal"); + query = "[2001:db8::1]"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}/`, + fallbackTitle: `http://${query}/`, + heuristic: true, + }), + ], + }); + + // Setting keyword.enabled to false should always try to visit. + let keywordEnabled = Services.prefs.getBoolPref("keyword.enabled"); + Services.prefs.setBoolPref("keyword.enabled", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("keyword.enabled"); + }); + info("visit url, keyword.enabled = false"); + query = "bacon"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}/`, + fallbackTitle: `http://${query}/`, + heuristic: true, + }), + ], + }); + + info("visit two word query, keyword.enabled = false"); + query = "bacon lovers"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: query, + fallbackTitle: query, + heuristic: true, + }), + ], + }); + + info("Forced search through a restriction token, keyword.enabled = false"); + query = "?bacon"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + query: "bacon", + }), + ], + }); + + Services.prefs.setBoolPref("keyword.enabled", true); + info("visit two word query, keyword.enabled = true"); + query = "bacon lovers"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + Services.prefs.setBoolPref("keyword.enabled", keywordEnabled); + + info("visit url, scheme+host"); + query = "http://example"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `${query}/`, + fallbackTitle: `${query}/`, + heuristic: true, + }), + ], + }); + + info("visit url, scheme+host"); + query = "ftp://example"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `${query}/`, + fallbackTitle: `${query}/`, + heuristic: true, + }), + ], + }); + + info("visit url, host+port"); + query = "example:8080"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}/`, + fallbackTitle: `http://${query}/`, + heuristic: true, + }), + ], + }); + + info("numerical operations that look like urls should search"); + query = "123/12"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + info("numerical operations that look like urls should search"); + query = "123.12/12.1"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + query = "resource:///modules"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: query, + fallbackTitle: query, + heuristic: true, + }), + ], + }); + + info("access resource://app/modules"); + query = "resource://app/modules"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: query, + fallbackTitle: query, + heuristic: true, + }), + ], + }); + + info("protocol with an extra slash"); + query = "http:///"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + info("change default engine"); + let originalTestEngine = Services.search.getEngineByName( + SUGGESTIONS_ENGINE_NAME + ); + await SearchTestUtils.installSearchExtension({ + name: "AliasEngine", + keyword: "alias", + }); + let engine2 = Services.search.getEngineByName("AliasEngine"); + Assert.notEqual( + Services.search.defaultEngine, + engine2, + "New engine shouldn't be the current engine yet" + ); + await Services.search.setDefault( + engine2, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + query = "toronto"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: "AliasEngine", + heuristic: true, + }), + ], + }); + await Services.search.setDefault( + originalTestEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + info( + "Leading search-mode restriction tokens are removed from the search result." + ); + for (let token of UrlbarTokenizer.SEARCH_MODE_RESTRICT) { + for (let spaces of TEST_SPACES) { + query = token + spaces + "query"; + info("Testing: " + JSON.stringify({ query, spaces: codePoints(spaces) })); + let expectedQuery = query.substring(1).trimStart(); + context = createContext(query, { isPrivate: false }); + info(`Searching for "${query}", expecting "${expectedQuery}"`); + let payload = { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + query: expectedQuery, + alias: token, + }; + if (token == UrlbarTokenizer.RESTRICT.SEARCH) { + payload.source = UrlbarUtils.RESULT_SOURCE.SEARCH; + payload.engineName = SUGGESTIONS_ENGINE_NAME; + } + await check_results({ + context, + matches: [makeSearchResult(context, payload)], + }); + } + } + + info( + "Leading search-mode restriction tokens are removed from the search result with keyword.enabled = false." + ); + Services.prefs.setBoolPref("keyword.enabled", false); + for (let token of UrlbarTokenizer.SEARCH_MODE_RESTRICT) { + for (let spaces of TEST_SPACES) { + query = token + spaces + "query"; + info("Testing: " + JSON.stringify({ query, spaces: codePoints(spaces) })); + let expectedQuery = query.substring(1).trimStart(); + context = createContext(query, { isPrivate: false }); + info(`Searching for "${query}", expecting "${expectedQuery}"`); + let payload = { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + query: expectedQuery, + alias: token, + }; + if (token == UrlbarTokenizer.RESTRICT.SEARCH) { + payload.source = UrlbarUtils.RESULT_SOURCE.SEARCH; + payload.engineName = SUGGESTIONS_ENGINE_NAME; + } + await check_results({ + context, + matches: [makeSearchResult(context, payload)], + }); + } + } + Services.prefs.clearUserPref("keyword.enabled"); + + info( + "Leading non-search-mode restriction tokens are not removed from the search result." + ); + for (let token of Object.values(UrlbarTokenizer.RESTRICT)) { + if (UrlbarTokenizer.SEARCH_MODE_RESTRICT.has(token)) { + continue; + } + for (let spaces of TEST_SPACES) { + query = token + spaces + "query"; + info("Testing: " + JSON.stringify({ query, spaces: codePoints(spaces) })); + let expectedQuery = query; + context = createContext(query, { isPrivate: false }); + info(`Searching for "${query}", expecting "${expectedQuery}"`); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: expectedQuery, + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ], + }); + } + } + + info( + "Test the format inputed is user@host, and the host is in domainwhitelist" + ); + Services.prefs.setBoolPref("browser.fixup.domainwhitelist.test-host", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.fixup.domainwhitelist.test-host"); + }); + + query = "any@test-host"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}/`, + fallbackTitle: `http://${query}/`, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ], + }); + + info( + "Test the format inputed is user@host, but the host is not in domainwhitelist" + ); + query = "any@not-host"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query, + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ], + }); + + info( + "Test if the format of user:pass@host is handled as visit even if the host is not in domainwhitelist" + ); + query = "user:pass@not-host"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://user:pass@not-host/", + fallbackTitle: "http://user:pass@not-host/", + heuristic: true, + }), + ], + }); + + info("Test if the format of user@ipaddress is handled as visit"); + query = "user@192.168.0.1"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://user@192.168.0.1/", + fallbackTitle: "http://user@192.168.0.1/", + heuristic: true, + }), + makeSearchResult(context, { + heuristic: false, + query, + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ], + }); + + await PlacesUtils.history.clear(); + // Check that punycode results are properly decoded before being displayed. + info("visit url, host matching visited host but not visited url"); + await PlacesTestUtils.addVisits([ + { + uri: Services.io.newURI("http://test.пример.com/"), + title: "test.пример.com", + transition: PlacesUtils.history.TRANSITION_TYPED, + }, + ]); + context = createContext("test", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: `http://test.xn--e1afmkfd.com/`, + displayUrl: `test.пример.com`, + heuristic: true, + iconUri: "page-icon:http://test.xn--e1afmkfd.com/", + }), + ], + }); + await PlacesUtils.history.clear(); +}); + +add_task(async function dont_fixup_urls_with_at_symbol() { + info("don't fixup search string if it contains no protocol and spaces."); + let query = "Lorem Ipsum @mozilla.org"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + query = "http://Lorem Ipsum @mozilla.org"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://Lorem%20Ipsum%20@mozilla.org/`, + fallbackTitle: `${query}/`, + heuristic: true, + }), + ], + }); + query = "https://Lorem Ipsum @mozilla.org"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `https://Lorem%20Ipsum%20@mozilla.org/`, + fallbackTitle: `${query}/`, + heuristic: true, + }), + ], + }); + + query = "LoremIpsum@mozilla.org"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}/`, + fallbackTitle: `http://${query}/`, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ], + }); +}); + +/** + * Returns an array of code points in the given string. Each code point is + * returned as a hexidecimal string. + * + * @param {string} str + * The code points of this string will be returned. + * @returns {Array} + * Array of code points in the string, where each is a hexidecimal string. + */ +function codePoints(str) { + return str.split("").map(s => s.charCodeAt(0).toString(16)); +} diff --git a/browser/components/urlbar/tests/unit/test_providerHistoryUrlHeuristic.js b/browser/components/urlbar/tests/unit/test_providerHistoryUrlHeuristic.js new file mode 100644 index 0000000000..7eb62fbeea --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerHistoryUrlHeuristic.js @@ -0,0 +1,197 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the behavior of UrlbarProviderHistoryUrlHeuristic. + +add_setup(async function () { + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + }); +}); + +add_task(async function test_basic() { + await PlacesTestUtils.addVisits([ + { uri: "https://example.com/", title: "Example COM" }, + ]); + + const testCases = [ + { + input: "https://example.com/", + expected: context => [ + makeVisitResult(context, { + uri: "https://example.com/", + title: "Example COM", + iconUri: "page-icon:https://example.com/", + heuristic: true, + providerName: "HistoryUrlHeuristic", + }), + ], + }, + { + input: "https://www.example.com/", + expected: context => [ + makeVisitResult(context, { + uri: "https://www.example.com/", + title: "Example COM", + iconUri: "page-icon:https://example.com/", + heuristic: true, + providerName: "HistoryUrlHeuristic", + }), + ], + }, + { + input: "http://example.com/", + expected: context => [ + makeVisitResult(context, { + uri: "http://example.com/", + title: "Example COM", + iconUri: "page-icon:https://example.com/", + heuristic: true, + providerName: "HistoryUrlHeuristic", + }), + makeVisitResult(context, { + uri: "https://example.com/", + title: "Example COM", + iconUri: "page-icon:https://example.com/", + providerName: "Places", + }), + ], + }, + { + input: "example.com", + expected: context => [ + makeVisitResult(context, { + uri: "http://example.com/", + title: "Example COM", + iconUri: "page-icon:https://example.com/", + heuristic: true, + providerName: "HistoryUrlHeuristic", + }), + makeVisitResult(context, { + uri: "https://example.com/", + title: "Example COM", + iconUri: "page-icon:https://example.com/", + providerName: "Places", + }), + ], + }, + { + input: "www.example.com", + expected: context => [ + makeVisitResult(context, { + uri: "http://www.example.com/", + title: "Example COM", + iconUri: "page-icon:https://example.com/", + heuristic: true, + providerName: "HistoryUrlHeuristic", + }), + ], + }, + { + input: "htp:example.com", + expected: context => [ + makeVisitResult(context, { + uri: "http://example.com/", + title: "Example COM", + iconUri: "page-icon:https://example.com/", + heuristic: true, + providerName: "HistoryUrlHeuristic", + }), + ], + }, + ]; + + for (const { input, expected } of testCases) { + info(`Test with "${input}"`); + const context = createContext(input, { isPrivate: false }); + await check_results({ + context, + matches: expected(context), + }); + } + + await PlacesUtils.history.clear(); +}); + +add_task(async function test_null_title() { + await PlacesTestUtils.addVisits([{ uri: "https://example.com/", title: "" }]); + + const context = createContext("https://example.com/", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "https://example.com/", + fallbackTitle: "https://example.com/", + iconUri: "page-icon:https://example.com/", + heuristic: true, + providerName: "HeuristicFallback", + }), + ], + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function test_over_max_length_text() { + let uri = "https://example.com/"; + for (; uri.length < UrlbarUtils.MAX_TEXT_LENGTH; ) { + uri += "0123456789"; + } + + await PlacesTestUtils.addVisits([{ uri, title: "Example MAX" }]); + + const context = createContext(uri, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri, + fallbackTitle: uri, + iconUri: "page-icon:https://example.com/", + heuristic: true, + providerName: "HeuristicFallback", + }), + ], + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function test_unsupported_protocol() { + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "about:robots", + title: "Robots!", + }); + + const context = createContext("about:robots", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "about:robots", + fallbackTitle: "about:robots", + heuristic: true, + providerName: "HeuristicFallback", + }), + makeBookmarkResult(context, { + uri: "about:robots", + title: "Robots!", + }), + makeVisitResult(context, { + uri: "about:robots", + title: "about:robots", + tags: null, + providerName: "AboutPages", + }), + ], + }); + + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerKeywords.js b/browser/components/urlbar/tests/unit/test_providerKeywords.js new file mode 100644 index 0000000000..e0958b8296 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerKeywords.js @@ -0,0 +1,407 @@ +/* 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/. */ + +/** + * Test for bug 392143 that puts keyword results into the autocomplete. Makes + * sure that multiple parameter queries get spaces converted to +, + converted + * to %2B, non-ascii become escaped, and pages in history that match the + * keyword uses the page's title. + * + * Also test for bug 249468 by making sure multiple keyword bookmarks with the + * same keyword appear in the list. + */ + +testEngine_setup(); + +add_task(async function test_keyword_search() { + let uri1 = "http://abc/?search=%s"; + let uri2 = "http://abc/?search=ThisPageIsInHistory"; + let uri3 = "http://abc/?search=%s&raw=%S"; + let uri4 = "http://abc/?search=%s&raw=%S&mozcharset=ISO-8859-1"; + let uri5 = "http://def/?search=%s"; + let uri6 = "http://ghi/?search=%s&raw=%S"; + let uri7 = "http://somedomain.example/key2"; + await PlacesTestUtils.addVisits([ + { uri: uri1 }, + { uri: uri2 }, + { uri: uri3 }, + { uri: uri6 }, + { uri: uri7 }, + ]); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri1, + title: "Keyword", + keyword: "key", + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri1, + title: "Post", + keyword: "post", + postData: "post_search=%s", + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri3, + title: "Encoded", + keyword: "encoded", + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri4, + title: "Charset", + keyword: "charset", + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri2, + title: "Noparam", + keyword: "noparam", + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri2, + title: "Noparam-Post", + keyword: "post_noparam", + postData: "noparam=1", + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri5, + title: "Keyword", + keyword: "key2", + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri6, + title: "Charset-history", + keyword: "charset_history", + }); + + await PlacesUtils.history.update({ + url: uri6, + annotations: new Map([[PlacesUtils.CHARSET_ANNO, "ISO-8859-1"]]), + }); + + info("Plain keyword query"); + let context = createContext("key term", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=term", + keyword: "key", + title: "abc: term", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + ], + }); + + info("Plain keyword UC"); + context = createContext("key TERM", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=TERM", + keyword: "key", + title: "abc: TERM", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + ], + }); + + info("Multi-word keyword query"); + context = createContext("key multi word", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=multi%20word", + keyword: "key", + title: "abc: multi word", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + ], + }); + + info("Keyword query with +"); + context = createContext("key blocking+", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=blocking%2B", + keyword: "key", + title: "abc: blocking+", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + ], + }); + + info("Keyword query with *"); + // We need a space before the asterisk to ensure it's considered a restriction + // token otherwise it will be a regular string character. + context = createContext("key blocking *", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=blocking%20*", + keyword: "key", + title: "abc: blocking *", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + ], + }); + + info("Keyword query with?"); + context = createContext("key blocking?", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=blocking%3F", + keyword: "key", + title: "abc: blocking?", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + ], + }); + + info("Keyword query with ?"); + context = createContext("key blocking ?", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=blocking%20%3F", + keyword: "key", + title: "abc: blocking ?", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + ], + }); + + info("Unescaped term in query"); + // ... but note that we call encodeURIComponent() on the query string when we + // build the URL, so the expected result will have the ユニコード substring + // encoded in the URL. + context = createContext("key ユニコード", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=" + encodeURIComponent("ユニコード"), + keyword: "key", + title: "abc: ユニコード", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + ], + }); + + info("Keyword that happens to match a page"); + context = createContext("key ThisPageIsInHistory", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=ThisPageIsInHistory", + keyword: "key", + title: "abc: ThisPageIsInHistory", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + ], + }); + + info("Keyword with partial page match"); + context = createContext("key ThisPage", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=ThisPage", + keyword: "key", + title: "abc: ThisPage", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + // Only the most recent bookmark for the URL: + makeBookmarkResult(context, { + uri: "http://abc/?search=ThisPageIsInHistory", + title: "Noparam-Post", + }), + ], + }); + + // For the keyword with no query terms (with or without space after), the + // domain is different from the other tests because otherwise all the other + // test bookmarks and history entries would be matches. + info("Keyword without query (without space)"); + context = createContext("key2", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://def/?search=", + fallbackTitle: "http://def/?search=", + keyword: "key2", + iconUri: "page-icon:http://def/?search=%s", + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri5, + title: "Keyword", + }), + ], + }); + + info("Keyword without query (with space)"); + context = createContext("key2 ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://def/?search=", + fallbackTitle: "http://def/?search=", + keyword: "key2", + iconUri: "page-icon:http://def/?search=%s", + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri5, + title: "Keyword", + }), + ], + }); + + info("POST Keyword"); + context = createContext("post foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=foo", + keyword: "post", + title: "abc: foo", + postData: "post_search=foo", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + ], + }); + + info("escaping with default UTF-8 charset"); + context = createContext("encoded foé", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=fo%C3%A9&raw=foé", + keyword: "encoded", + title: "abc: foé", + iconUri: "page-icon:http://abc/?search=%s&raw=%S", + heuristic: true, + }), + ], + }); + + info("escaping with forced ISO-8859-1 charset"); + context = createContext("charset foé", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=fo%E9&raw=foé", + keyword: "charset", + title: "abc: foé", + iconUri: "page-icon:http://abc/?search=%s&raw=%S&mozcharset=ISO-8859-1", + heuristic: true, + }), + ], + }); + + info("escaping with ISO-8859-1 charset annotated in history"); + context = createContext("charset_history foé", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://ghi/?search=fo%E9&raw=foé", + keyword: "charset_history", + title: "ghi: foé", + iconUri: "page-icon:http://ghi/?search=%s&raw=%S", + heuristic: true, + }), + ], + }); + + info("Bug 359809: escaping +, / and @ with default UTF-8 charset"); + context = createContext("encoded +/@", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=%2B%2F%40&raw=+/@", + keyword: "encoded", + title: "abc: +/@", + iconUri: "page-icon:http://abc/?search=%s&raw=%S", + heuristic: true, + }), + ], + }); + + info("Bug 359809: escaping +, / and @ with forced ISO-8859-1 charset"); + context = createContext("charset +/@", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=%2B%2F%40&raw=+/@", + keyword: "charset", + title: "abc: +/@", + iconUri: "page-icon:http://abc/?search=%s&raw=%S&mozcharset=ISO-8859-1", + heuristic: true, + }), + ], + }); + + info("Bug 1228111 - Keyword with a space in front"); + context = createContext(" key test", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=test", + keyword: "key", + title: "abc: test", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + ], + }); + + info("Bug 1481319 - Keyword with a prefix in front"); + context = createContext("http://key2", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://key2/", + fallbackTitle: "http://key2/", + heuristic: true, + providerName: "HeuristicFallback", + }), + makeVisitResult(context, { + uri: uri7, + title: "test visit for http://somedomain.example/key2", + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerOmnibox.js b/browser/components/urlbar/tests/unit/test_providerOmnibox.js new file mode 100644 index 0000000000..4e4ef02e0c --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerOmnibox.js @@ -0,0 +1,887 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim:set ts=2 sw=2 sts=2 et: + * 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 { ExtensionSearchHandler } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionSearchHandler.sys.mjs" +); + +let controller = Cc["@mozilla.org/autocomplete/controller;1"].getService( + Ci.nsIAutoCompleteController +); + +const SUGGEST_PREF = "browser.urlbar.suggest.searches"; +const SUGGEST_ENABLED_PREF = "browser.search.suggest.enabled"; + +async function cleanup() { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +} + +add_setup(function () { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, false); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref(SUGGEST_PREF); + Services.prefs.clearUserPref(SUGGEST_ENABLED_PREF); + }); +}); + +add_task(async function test_correct_errors_are_thrown() { + let keyword = "foo"; + let anotherKeyword = "bar"; + let unregisteredKeyword = "baz"; + + // Register a keyword. + ExtensionSearchHandler.registerKeyword(keyword, { emit: () => {} }); + + // Try registering the keyword again. + Assert.throws( + () => ExtensionSearchHandler.registerKeyword(keyword, { emit: () => {} }), + /The keyword provided is already registered/ + ); + + // Register a different keyword. + ExtensionSearchHandler.registerKeyword(anotherKeyword, { emit: () => {} }); + + // Try calling handleSearch for an unregistered keyword. + let searchData = { + keyword: unregisteredKeyword, + text: `${unregisteredKeyword} `, + }; + Assert.throws( + () => ExtensionSearchHandler.handleSearch(searchData, () => {}), + /The keyword provided is not registered/ + ); + + // Try calling handleSearch without a callback. + Assert.throws( + () => ExtensionSearchHandler.handleSearch(searchData), + /The keyword provided is not registered/ + ); + + // Try getting the description for a keyword which isn't registered. + Assert.throws( + () => ExtensionSearchHandler.getDescription(unregisteredKeyword), + /The keyword provided is not registered/ + ); + + // Try setting the default suggestion for a keyword which isn't registered. + Assert.throws( + () => + ExtensionSearchHandler.setDefaultSuggestion( + unregisteredKeyword, + "suggestion" + ), + /The keyword provided is not registered/ + ); + + // Try calling handleInputCancelled when there is no active input session. + Assert.throws( + () => ExtensionSearchHandler.handleInputCancelled(), + /There is no active input session/ + ); + + // Try calling handleInputEntered when there is no active input session. + Assert.throws( + () => + ExtensionSearchHandler.handleInputEntered( + anotherKeyword, + `${anotherKeyword} test`, + "tab" + ), + /There is no active input session/ + ); + + // Start a session by calling handleSearch with the registered keyword. + searchData = { + keyword, + text: `${keyword} test`, + }; + ExtensionSearchHandler.handleSearch(searchData, () => {}); + + // Try providing suggestions for an unregistered keyword. + Assert.throws( + () => ExtensionSearchHandler.addSuggestions(unregisteredKeyword, 0, []), + /The keyword provided is not registered/ + ); + + // Try providing suggestions for an inactive keyword. + Assert.throws( + () => ExtensionSearchHandler.addSuggestions(anotherKeyword, 0, []), + /The keyword provided is not apart of an active input session/ + ); + + // Try calling handleSearch for an inactive keyword. + searchData = { + keyword: anotherKeyword, + text: `${anotherKeyword} `, + }; + Assert.throws( + () => ExtensionSearchHandler.handleSearch(searchData, () => {}), + /A different input session is already ongoing/ + ); + + // Try calling addSuggestions with an old callback ID. + Assert.throws( + () => ExtensionSearchHandler.addSuggestions(keyword, 0, []), + /The callback is no longer active for the keyword provided/ + ); + + // Add suggestions with a valid callback ID. + ExtensionSearchHandler.addSuggestions(keyword, 1, []); + + // Add suggestions again with a valid callback ID. + ExtensionSearchHandler.addSuggestions(keyword, 1, []); + + // Try calling addSuggestions with a future callback ID. + Assert.throws( + () => ExtensionSearchHandler.addSuggestions(keyword, 2, []), + /The callback is no longer active for the keyword provided/ + ); + + // End the input session by calling handleInputCancelled. + ExtensionSearchHandler.handleInputCancelled(); + + // Try calling handleInputCancelled after the session has ended. + Assert.throws( + () => ExtensionSearchHandler.handleInputCancelled(), + /There is no active input sessio/ + ); + + // Try calling handleSearch that doesn't have a space after the keyword. + searchData = { + keyword: anotherKeyword, + text: `${anotherKeyword}`, + }; + Assert.throws( + () => ExtensionSearchHandler.handleSearch(searchData, () => {}), + /The text provided must start with/ + ); + + // Try calling handleSearch with text starting with the wrong keyword. + searchData = { + keyword: anotherKeyword, + text: `${keyword} test`, + }; + Assert.throws( + () => ExtensionSearchHandler.handleSearch(searchData, () => {}), + /The text provided must start with/ + ); + + // Start a new session by calling handleSearch with a different keyword + searchData = { + keyword: anotherKeyword, + text: `${anotherKeyword} test`, + }; + ExtensionSearchHandler.handleSearch(searchData, () => {}); + + // Try adding suggestions again with the same callback ID now that the input session has ended. + Assert.throws( + () => ExtensionSearchHandler.addSuggestions(keyword, 1, []), + /The keyword provided is not apart of an active input session/ + ); + + // Add suggestions with a valid callback ID. + ExtensionSearchHandler.addSuggestions(anotherKeyword, 2, []); + + // Try adding suggestions with a valid callback ID but a different keyword. + Assert.throws( + () => ExtensionSearchHandler.addSuggestions(keyword, 2, []), + /The keyword provided is not apart of an active input session/ + ); + + // Try adding suggestions with a valid callback ID but an unregistered keyword. + Assert.throws( + () => ExtensionSearchHandler.addSuggestions(unregisteredKeyword, 2, []), + /The keyword provided is not registered/ + ); + + // Set the default suggestion. + ExtensionSearchHandler.setDefaultSuggestion(anotherKeyword, { + description: "test result", + }); + + // Try ending the session using handleInputEntered with a different keyword. + Assert.throws( + () => + ExtensionSearchHandler.handleInputEntered( + keyword, + `${keyword} test`, + "tab" + ), + /A different input session is already ongoing/ + ); + + // Try calling handleInputEntered with invalid text. + Assert.throws( + () => + ExtensionSearchHandler.handleInputEntered(anotherKeyword, ` test`, "tab"), + /The text provided must start with/ + ); + + // Try calling handleInputEntered with an invalid disposition. + Assert.throws( + () => + ExtensionSearchHandler.handleInputEntered( + anotherKeyword, + `${anotherKeyword} test`, + "invalid" + ), + /Invalid "where" argument/ + ); + + // End the session by calling handleInputEntered. + ExtensionSearchHandler.handleInputEntered( + anotherKeyword, + `${anotherKeyword} test`, + "tab" + ); + + // Try calling handleInputEntered after the session has ended. + Assert.throws( + () => + ExtensionSearchHandler.handleInputEntered( + anotherKeyword, + `${anotherKeyword} test`, + "tab" + ), + /There is no active input session/ + ); + + // Unregister the keyword. + ExtensionSearchHandler.unregisterKeyword(keyword); + + // Try setting the default suggestion for the unregistered keyword. + Assert.throws( + () => + ExtensionSearchHandler.setDefaultSuggestion(keyword, { + description: "test", + }), + /The keyword provided is not registered/ + ); + + // Try handling a search with the unregistered keyword. + searchData = { + keyword, + text: `${keyword} test`, + }; + Assert.throws( + () => ExtensionSearchHandler.handleSearch(searchData, () => {}), + /The keyword provided is not registered/ + ); + + // Try unregistering the keyword again. + Assert.throws( + () => ExtensionSearchHandler.unregisterKeyword(keyword), + /The keyword provided is not registered/ + ); + + // Unregister the other keyword. + ExtensionSearchHandler.unregisterKeyword(anotherKeyword); + + // Try unregistering the word which was never registered. + Assert.throws( + () => ExtensionSearchHandler.unregisterKeyword(unregisteredKeyword), + /The keyword provided is not registered/ + ); + + // Try setting the default suggestion for a word that was never registered. + Assert.throws( + () => + ExtensionSearchHandler.setDefaultSuggestion(unregisteredKeyword, { + description: "test", + }), + /The keyword provided is not registered/ + ); + + await cleanup(); +}); + +add_task(async function test_extension_private_browsing() { + let events = []; + let mockExtension = { + emit: message => events.push(message), + privateBrowsingAllowed: false, + }; + + let keyword = "foo"; + + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + + let searchData = { + keyword, + text: `${keyword} test`, + inPrivateWindow: true, + }; + let result = await ExtensionSearchHandler.handleSearch(searchData); + Assert.equal(result, false, "unable to handle search for private window"); + + // Try calling handleInputEntered after the session has ended. + Assert.throws( + () => + ExtensionSearchHandler.handleInputEntered( + keyword, + `${keyword} test`, + "tab" + ), + /There is no active input session/ + ); + + ExtensionSearchHandler.unregisterKeyword(keyword); + await cleanup(); +}); + +add_task(async function test_extension_private_browsing_allowed() { + let extensionName = "Foo Bar"; + let mockExtension = { + name: extensionName, + emit: (message, text, id) => { + if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) { + ExtensionSearchHandler.addSuggestions(keyword, id, [ + { content: "foo", description: "first suggestion" }, + { content: "foobar", description: "second suggestion" }, + ]); + // The API doesn't have a way to notify when addition is complete. + do_timeout(1000, () => { + controller.stopSearch(); + }); + } + }, + privateBrowsingAllowed: true, + }; + + let keyword = "foo"; + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + + let query = `${keyword} foo`; + let context = createContext(query, { isPrivate: true }); + await check_results({ + context, + matches: [ + makeOmniboxResult(context, { + heuristic: true, + keyword, + description: extensionName, + content: query, + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} foobar`, + description: "second suggestion", + }), + ], + }); + + ExtensionSearchHandler.unregisterKeyword(keyword); + await cleanup(); +}); + +add_task(async function test_correct_events_are_emitted() { + let events = []; + function checkEvents(expectedEvents) { + Assert.equal( + events.length, + expectedEvents.length, + "The correct number of events fired" + ); + expectedEvents.forEach((e, i) => + Assert.equal(e, events[i], `Expected "${e}" event to fire`) + ); + events = []; + } + + let mockExtension = { emit: message => events.push(message) }; + + let keyword = "foo"; + let anotherKeyword = "bar"; + + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + ExtensionSearchHandler.registerKeyword(anotherKeyword, mockExtension); + + let searchData = { + keyword, + text: `${keyword} `, + }; + ExtensionSearchHandler.handleSearch(searchData, () => {}); + checkEvents([ExtensionSearchHandler.MSG_INPUT_STARTED]); + + searchData.text = `${keyword} f`; + ExtensionSearchHandler.handleSearch(searchData, () => {}); + checkEvents([ExtensionSearchHandler.MSG_INPUT_CHANGED]); + + ExtensionSearchHandler.handleInputEntered(keyword, searchData.text, "tab"); + checkEvents([ExtensionSearchHandler.MSG_INPUT_ENTERED]); + + ExtensionSearchHandler.handleSearch(searchData, () => {}); + checkEvents([ + ExtensionSearchHandler.MSG_INPUT_STARTED, + ExtensionSearchHandler.MSG_INPUT_CHANGED, + ]); + + ExtensionSearchHandler.handleInputCancelled(); + checkEvents([ExtensionSearchHandler.MSG_INPUT_CANCELLED]); + + ExtensionSearchHandler.handleSearch( + { + keyword: anotherKeyword, + text: `${anotherKeyword} baz`, + }, + () => {} + ); + checkEvents([ + ExtensionSearchHandler.MSG_INPUT_STARTED, + ExtensionSearchHandler.MSG_INPUT_CHANGED, + ]); + + ExtensionSearchHandler.handleInputEntered( + anotherKeyword, + `${anotherKeyword} baz`, + "tab" + ); + checkEvents([ExtensionSearchHandler.MSG_INPUT_ENTERED]); + + ExtensionSearchHandler.unregisterKeyword(keyword); +}); + +add_task(async function test_removes_suggestion_if_its_content_is_typed_in() { + let keyword = "test"; + let extensionName = "Foo Bar"; + + let mockExtension = { + name: extensionName, + emit(message, text, id) { + if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) { + ExtensionSearchHandler.addSuggestions(keyword, id, [ + { content: "foo", description: "first suggestion" }, + { content: "bar", description: "second suggestion" }, + { content: "baz", description: "third suggestion" }, + ]); + // The API doesn't have a way to notify when addition is complete. + do_timeout(1000, () => { + controller.stopSearch(); + }); + } + }, + }; + + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + + let query = `${keyword} unmatched`; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeOmniboxResult(context, { + heuristic: true, + keyword, + description: extensionName, + content: `${keyword} unmatched`, + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} foo`, + description: "first suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} bar`, + description: "second suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} baz`, + description: "third suggestion", + }), + ], + }); + + query = `${keyword} foo`; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeOmniboxResult(context, { + heuristic: true, + keyword, + description: extensionName, + content: `${keyword} foo`, + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} bar`, + description: "second suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} baz`, + description: "third suggestion", + }), + ], + }); + + query = `${keyword} bar`; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeOmniboxResult(context, { + heuristic: true, + keyword, + description: extensionName, + content: `${keyword} bar`, + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} foo`, + description: "first suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} baz`, + description: "third suggestion", + }), + ], + }); + + query = `${keyword} baz`; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeOmniboxResult(context, { + heuristic: true, + keyword, + description: extensionName, + content: `${keyword} baz`, + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} foo`, + description: "first suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} bar`, + description: "second suggestion", + }), + ], + }); + + ExtensionSearchHandler.unregisterKeyword(keyword); + await cleanup(); +}); + +add_task(async function test_extension_results_should_come_first() { + let keyword = "test"; + let extensionName = "Omnibox Example"; + + let uri = Services.io.newURI(`http://a.com/b`); + await PlacesTestUtils.addVisits([{ uri, title: `${keyword} -` }]); + + let mockExtension = { + name: extensionName, + emit(message, text, id) { + if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) { + ExtensionSearchHandler.addSuggestions(keyword, id, [ + { content: "foo", description: "first suggestion" }, + { content: "bar", description: "second suggestion" }, + { content: "baz", description: "third suggestion" }, + ]); + } + }, + }; + + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + + // Start an input session before testing MSG_INPUT_CHANGED. + ExtensionSearchHandler.handleSearch( + { keyword, text: `${keyword} ` }, + () => {} + ); + + let query = `${keyword} -`; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeOmniboxResult(context, { + heuristic: true, + keyword, + description: extensionName, + content: `${keyword} -`, + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} foo`, + description: "first suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} bar`, + description: "second suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} baz`, + description: "third suggestion", + }), + makeVisitResult(context, { + uri: `http://a.com/b`, + title: `${keyword} -`, + }), + ], + }); + + ExtensionSearchHandler.unregisterKeyword(keyword); + await cleanup(); +}); + +add_task(async function test_setting_the_default_suggestion() { + let keyword = "test"; + let extensionName = "Omnibox Example"; + + let mockExtension = { + name: extensionName, + emit(message, text, id) { + if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) { + ExtensionSearchHandler.addSuggestions(keyword, id, []); + // The API doesn't have a way to notify when addition is complete. + do_timeout(1000, () => { + controller.stopSearch(); + }); + } + }, + }; + + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + + ExtensionSearchHandler.setDefaultSuggestion(keyword, { + description: "hello world", + }); + + let query = `${keyword} search query`; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeOmniboxResult(context, { + heuristic: true, + keyword, + description: "hello world", + content: query, + }), + ], + }); + + ExtensionSearchHandler.setDefaultSuggestion(keyword, { + description: "foo bar", + }); + + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + searchParam: "enable-actions", + matches: [ + makeOmniboxResult(context, { + heuristic: true, + keyword, + description: "foo bar", + content: query, + }), + ], + }); + + ExtensionSearchHandler.unregisterKeyword(keyword); + await cleanup(); +}); + +add_task(async function test_maximum_number_of_suggestions_is_enforced() { + let keyword = "test"; + let extensionName = "Omnibox Example"; + + let mockExtension = { + name: extensionName, + emit(message, text, id) { + if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) { + ExtensionSearchHandler.addSuggestions(keyword, id, [ + { content: "a", description: "first suggestion" }, + { content: "b", description: "second suggestion" }, + { content: "c", description: "third suggestion" }, + { content: "d", description: "fourth suggestion" }, + { content: "e", description: "fifth suggestion" }, + { content: "f", description: "sixth suggestion" }, + { content: "g", description: "seventh suggestion" }, + { content: "h", description: "eigth suggestion" }, + { content: "i", description: "ninth suggestion" }, + { content: "j", description: "tenth suggestion" }, + { content: "k", description: "eleventh suggestion" }, + { content: "l", description: "twelfth suggestion" }, + ]); + // The API doesn't have a way to notify when addition is complete. + do_timeout(1000, () => { + controller.stopSearch(); + }); + } + }, + }; + + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + + // Start an input session before testing MSG_INPUT_CHANGED. + ExtensionSearchHandler.handleSearch( + { keyword, text: `${keyword} ` }, + () => {} + ); + + let query = `${keyword} #`; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeOmniboxResult(context, { + heuristic: true, + keyword, + description: extensionName, + content: `${keyword} #`, + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} a`, + description: "first suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} b`, + description: "second suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} c`, + description: "third suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} d`, + description: "fourth suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} e`, + description: "fifth suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} f`, + description: "sixth suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} g`, + description: "seventh suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} h`, + description: "eigth suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} i`, + description: "ninth suggestion", + }), + ], + }); + + ExtensionSearchHandler.unregisterKeyword(keyword); + await cleanup(); +}); + +add_task(async function conflicting_alias() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + + let engine = await addTestSuggestionsEngine(); + let keyword = "test"; + engine.alias = keyword; + let oldDefaultEngine = await Services.search.getDefault(); + Services.search.setDefault(engine, Ci.nsISearchService.CHANGE_REASON_UNKNOWN); + + let extensionName = "Omnibox Example"; + + let mockExtension = { + name: extensionName, + emit(message, text, id) { + if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) { + ExtensionSearchHandler.addSuggestions(keyword, id, [ + { content: "foo", description: "first suggestion" }, + { content: "bar", description: "second suggestion" }, + { content: "baz", description: "third suggestion" }, + ]); + // The API doesn't have a way to notify when addition is complete. + do_timeout(1000, () => { + controller.stopSearch(); + }); + } + }, + }; + + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + let query = `${keyword} unmatched`; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeOmniboxResult(context, { + heuristic: true, + keyword, + description: extensionName, + content: `${keyword} unmatched`, + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} foo`, + description: "first suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} bar`, + description: "second suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} baz`, + description: "third suggestion", + }), + makeSearchResult(context, { + query: "unmatched", + engineName: SUGGESTIONS_ENGINE_NAME, + alias: keyword, + suggestion: "unmatched", + }), + makeSearchResult(context, { + query: "unmatched", + engineName: SUGGESTIONS_ENGINE_NAME, + alias: keyword, + suggestion: "unmatched foo", + }), + makeSearchResult(context, { + query: "unmatched", + engineName: SUGGESTIONS_ENGINE_NAME, + alias: keyword, + suggestion: "unmatched bar", + }), + ], + }); + + Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, false); + await cleanup(); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerOpenTabs.js b/browser/components/urlbar/tests/unit/test_providerOpenTabs.js new file mode 100644 index 0000000000..f85f547ac3 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerOpenTabs.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_openTabs() { + const userContextId1 = 3; + const userContextId2 = 5; + const url = "http://foo.mozilla.org/"; + const url2 = "http://foo2.mozilla.org/"; + UrlbarProviderOpenTabs.registerOpenTab(url, userContextId1, false); + UrlbarProviderOpenTabs.registerOpenTab(url, userContextId1, false); + UrlbarProviderOpenTabs.registerOpenTab(url2, userContextId1, false); + UrlbarProviderOpenTabs.registerOpenTab(url, userContextId2, false); + Assert.deepEqual( + [url, url2], + UrlbarProviderOpenTabs.getOpenTabs(userContextId1), + "Found all the expected tabs" + ); + Assert.deepEqual( + [url], + UrlbarProviderOpenTabs.getOpenTabs(userContextId2), + "Found all the expected tabs" + ); + await PlacesUtils.promiseLargeCacheDBConnection(); + await UrlbarProviderOpenTabs.promiseDBPopulated; + Assert.deepEqual( + [ + { url, userContextId: userContextId1, count: 2 }, + { url: url2, userContextId: userContextId1, count: 1 }, + { url, userContextId: userContextId2, count: 1 }, + ], + await UrlbarProviderOpenTabs.getDatabaseRegisteredOpenTabsForTests(), + "Found all the expected tabs" + ); + + await UrlbarProviderOpenTabs.unregisterOpenTab(url2, userContextId1, false); + Assert.deepEqual( + [url], + UrlbarProviderOpenTabs.getOpenTabs(userContextId1), + "Found all the expected tabs" + ); + await UrlbarProviderOpenTabs.unregisterOpenTab(url, userContextId1, false); + Assert.deepEqual( + [url], + UrlbarProviderOpenTabs.getOpenTabs(userContextId1), + "Found all the expected tabs" + ); + Assert.deepEqual( + [ + { url, userContextId: userContextId1, count: 1 }, + { url, userContextId: userContextId2, count: 1 }, + ], + await UrlbarProviderOpenTabs.getDatabaseRegisteredOpenTabsForTests(), + "Found all the expected tabs" + ); + + let context = createContext(); + let matchCount = 0; + let callback = function (provider, match) { + matchCount++; + Assert.ok( + provider instanceof UrlbarProviderOpenTabs, + "Got the expected provider" + ); + Assert.equal( + match.type, + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + "Got the expected result type" + ); + Assert.equal(match.payload.url, url, "Got the expected url"); + Assert.equal(match.payload.title, undefined, "Got the expected title"); + }; + + let provider = new UrlbarProviderOpenTabs(); + await provider.startQuery(context, callback); + Assert.equal(matchCount, 2, "Found the expected number of matches"); + // Sanity check that this doesn't throw. + provider.cancelQuery(context); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerPlaces.js b/browser/components/urlbar/tests/unit/test_providerPlaces.js new file mode 100644 index 0000000000..c64f3345e1 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerPlaces.js @@ -0,0 +1,250 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// This is a simple test to check the Places provider works, it is not +// intended to check all the edge cases, because that component is already +// covered by a good amount of tests. + +const SUGGEST_PREF = "browser.urlbar.suggest.searches"; +const SUGGEST_ENABLED_PREF = "browser.search.suggest.enabled"; + +add_task(async function test_places() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + let engine = await addTestSuggestionsEngine(); + Services.search.defaultEngine = engine; + let oldCurrentEngine = Services.search.defaultEngine; + registerCleanupFunction(() => { + Services.prefs.clearUserPref(SUGGEST_PREF); + Services.prefs.clearUserPref(SUGGEST_ENABLED_PREF); + Services.search.defaultEngine = oldCurrentEngine; + }); + + let controller = UrlbarTestUtils.newMockController(); + // Also check case insensitivity. + let searchString = "MoZ oRg"; + let context = createContext(searchString, { isPrivate: false }); + + // Add entries from multiple sources. + await PlacesUtils.bookmarks.insert({ + url: "https://bookmark.mozilla.org/", + title: "Test bookmark", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + PlacesUtils.tagging.tagURI( + Services.io.newURI("https://bookmark.mozilla.org/"), + ["mozilla", "org", "ham", "moz", "bacon"] + ); + await PlacesTestUtils.addVisits([ + { uri: "https://history.mozilla.org/", title: "Test history" }, + { uri: "https://tab.mozilla.org/", title: "Test tab" }, + ]); + UrlbarProviderOpenTabs.registerOpenTab("https://tab.mozilla.org/", 0, false); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + await controller.startQuery(context); + + info( + "Results:\n" + + context.results.map(m => `${m.title} - ${m.payload.url}`).join("\n") + ); + Assert.equal( + context.results.length, + 6, + "Found the expected number of matches" + ); + + Assert.deepEqual( + [ + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_TYPE.URL, + ], + context.results.map(m => m.type), + "Check result types" + ); + + Assert.deepEqual( + [ + searchString, + searchString + " foo", + searchString + " bar", + "Test bookmark", + "Test tab", + "Test history", + ], + context.results.map(m => m.title), + "Check match titles" + ); + + Assert.deepEqual( + context.results[3].payload.tags, + ["moz", "mozilla", "org"], + "Check tags" + ); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + UrlbarProviderOpenTabs.unregisterOpenTab( + "https://tab.mozilla.org/", + 0, + false + ); +}); + +add_task(async function test_bookmarkBehaviorDisabled_tagged() { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, false); + + // Disable the bookmark behavior. + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + + let controller = UrlbarTestUtils.newMockController(); + // Also check case insensitivity. + let searchString = "MoZ oRg"; + let context = createContext(searchString, { isPrivate: false }); + + // Add a tagged bookmark that's also visited. + await PlacesUtils.bookmarks.insert({ + url: "https://bookmark.mozilla.org/", + title: "Test bookmark", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + PlacesUtils.tagging.tagURI( + Services.io.newURI("https://bookmark.mozilla.org/"), + ["mozilla", "org", "ham", "moz", "bacon"] + ); + await PlacesTestUtils.addVisits("https://bookmark.mozilla.org/"); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + await controller.startQuery(context); + + info( + "Results:\n" + + context.results.map(m => `${m.title} - ${m.payload.url}`).join("\n") + ); + Assert.equal( + context.results.length, + 2, + "Found the expected number of matches" + ); + + Assert.deepEqual( + [UrlbarUtils.RESULT_TYPE.SEARCH, UrlbarUtils.RESULT_TYPE.URL], + context.results.map(m => m.type), + "Check result types" + ); + + Assert.deepEqual( + [searchString, "Test bookmark"], + context.results.map(m => m.title), + "Check match titles" + ); + + Assert.deepEqual(context.results[1].payload.tags, [], "Check tags"); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_bookmarkBehaviorDisabled_untagged() { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, false); + + // Disable the bookmark behavior. + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + + let controller = UrlbarTestUtils.newMockController(); + // Also check case insensitivity. + let searchString = "MoZ oRg"; + let context = createContext(searchString, { isPrivate: false }); + + // Add an *untagged* bookmark that's also visited. + await PlacesUtils.bookmarks.insert({ + url: "https://bookmark.mozilla.org/", + title: "Test bookmark", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + await PlacesTestUtils.addVisits("https://bookmark.mozilla.org/"); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + await controller.startQuery(context); + + info( + "Results:\n" + + context.results.map(m => `${m.title} - ${m.payload.url}`).join("\n") + ); + Assert.equal( + context.results.length, + 2, + "Found the expected number of matches" + ); + + Assert.deepEqual( + [UrlbarUtils.RESULT_TYPE.SEARCH, UrlbarUtils.RESULT_TYPE.URL], + context.results.map(m => m.type), + "Check result types" + ); + + Assert.deepEqual( + [searchString, "Test bookmark"], + context.results.map(m => m.title), + "Check match titles" + ); + + Assert.deepEqual(context.results[1].payload.tags, [], "Check tags"); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_diacritics() { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, false); + + // Enable the bookmark behavior. + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true); + + let controller = UrlbarTestUtils.newMockController(); + let searchString = "agui"; + let context = createContext(searchString, { isPrivate: false }); + + await PlacesUtils.bookmarks.insert({ + url: "https://bookmark.mozilla.org/%C3%A3g%CC%83u%C4%A9", + title: "Test bookmark with accents in path", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + await controller.startQuery(context); + + info( + "Results:\n" + + context.results.map(m => `${m.title} - ${m.payload.url}`).join("\n") + ); + Assert.equal( + context.results.length, + 2, + "Found the expected number of matches" + ); + + Assert.deepEqual( + [UrlbarUtils.RESULT_TYPE.SEARCH, UrlbarUtils.RESULT_TYPE.URL], + context.results.map(m => m.type), + "Check result types" + ); + + Assert.deepEqual( + [searchString, "Test bookmark with accents in path"], + context.results.map(m => m.title), + "Check match titles" + ); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerPlaces_duplicate_entries.js b/browser/components/urlbar/tests/unit/test_providerPlaces_duplicate_entries.js new file mode 100644 index 0000000000..7533921fc6 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerPlaces_duplicate_entries.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_duplicates() { + const TEST_URL = "https://history.mozilla.org/"; + await PlacesTestUtils.addVisits([ + { uri: TEST_URL, title: "Test history" }, + { uri: TEST_URL + "?#", title: "Test history" }, + { uri: TEST_URL + "#", title: "Test history" }, + ]); + + let controller = UrlbarTestUtils.newMockController(); + let searchString = "^Hist"; + let context = createContext(searchString, { isPrivate: false }); + await controller.startQuery(context); + + // The first result will be a search heuristic, which we don't care about for + // this test. + info( + "Results:\n" + + context.results.map(m => `${m.title} - ${m.payload.url}`).join("\n") + ); + Assert.equal( + context.results.length, + 2, + "Found the expected number of matches" + ); + Assert.equal( + context.results[1].type, + UrlbarUtils.RESULT_TYPE.URL, + "Should have a history result" + ); + Assert.equal( + context.results[1].payload.url, + TEST_URL + "#", + "Check result URL" + ); + + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerPlaces_nonEnglish.js b/browser/components/urlbar/tests/unit/test_providerPlaces_nonEnglish.js new file mode 100644 index 0000000000..2cb5f5797a --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerPlaces_nonEnglish.js @@ -0,0 +1,43 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/* + +Test autocomplete for non-English URLs + +- add a visit for a page with a non-English URL +- search +- test number of matches (should be exactly one) + +*/ + +testEngine_setup(); + +add_task(async function test_autocomplete_non_english() { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + let searchTerm = "ユニコード"; + let unescaped = "http://www.foobar.com/" + searchTerm + "/"; + let uri = Services.io.newURI(unescaped); + await PlacesTestUtils.addVisits(uri); + let context = createContext(searchTerm, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri.spec, + title: `test visit for ${uri.spec}`, + }), + ], + }); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerRecentSearches.js b/browser/components/urlbar/tests/unit/test_providerRecentSearches.js new file mode 100644 index 0000000000..c7b542e317 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerRecentSearches.js @@ -0,0 +1,167 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + setTimeout: "resource://gre/modules/Timer.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", +}); + +let ENABLED_PREF = "recentsearches.featureGate"; +let EXPIRE_PREF = "recentsearches.expirationMs"; +let SUGGESTS_PREF = "suggest.recentsearches"; + +let TEST_SEARCHES = ["Bob Vylan", "Glasgow Weather", "Joy Formidable"]; +let defaultEngine; + +function makeRecentSearchResult(context, engine, suggestion) { + let result = makeFormHistoryResult(context, { + suggestion, + engineName: engine.name, + }); + delete result.payload.lowerCaseSuggestion; + return result; +} + +async function addSearches(searches = TEST_SEARCHES) { + // Add the searches sequentially so they get a new timestamp + // and we can order by the time added. + for (let search of searches) { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 10)); + await UrlbarTestUtils.formHistory.add([ + { value: search, source: defaultEngine.name }, + ]); + } +} + +add_setup(async () => { + defaultEngine = await addTestSuggestionsEngine(); + await Services.search.setDefault( + defaultEngine, + Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL + ); + + let oldCurrentEngine = Services.search.defaultEngine; + + registerCleanupFunction(() => { + Services.search.defaultEngine = oldCurrentEngine; + UrlbarPrefs.clear(ENABLED_PREF); + UrlbarPrefs.clear(SUGGESTS_PREF); + }); +}); + +add_task(async function test_enabled() { + UrlbarPrefs.set(ENABLED_PREF, true); + UrlbarPrefs.set(SUGGESTS_PREF, true); + await addSearches(); + let context = createContext("", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeRecentSearchResult(context, defaultEngine, "Joy Formidable"), + makeRecentSearchResult(context, defaultEngine, "Glasgow Weather"), + makeRecentSearchResult(context, defaultEngine, "Bob Vylan"), + ], + }); +}); + +add_task(async function test_disabled() { + UrlbarPrefs.set(ENABLED_PREF, false); + UrlbarPrefs.set(SUGGESTS_PREF, false); + await addSearches(); + await check_results({ + context: createContext("", { isPrivate: false }), + matches: [], + }); +}); + +add_task(async function test_most_recent_shown() { + UrlbarPrefs.set(ENABLED_PREF, true); + UrlbarPrefs.set(SUGGESTS_PREF, true); + + await addSearches(Array.from(Array(10).keys()).map(i => `Search ${i}`)); + let context = createContext("", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeRecentSearchResult(context, defaultEngine, "Search 9"), + makeRecentSearchResult(context, defaultEngine, "Search 8"), + makeRecentSearchResult(context, defaultEngine, "Search 7"), + makeRecentSearchResult(context, defaultEngine, "Search 6"), + makeRecentSearchResult(context, defaultEngine, "Search 5"), + ], + }); + await UrlbarTestUtils.formHistory.clear(); +}); + +add_task(async function test_per_engine() { + UrlbarPrefs.set(ENABLED_PREF, true); + UrlbarPrefs.set(SUGGESTS_PREF, true); + + let oldEngine = defaultEngine; + await addSearches(); + + defaultEngine = await addTestSuggestionsEngine(null, { + name: "NewTestEngine", + }); + await Services.search.setDefault( + defaultEngine, + Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL + ); + + await addSearches(); + + let context = createContext("", { + isPrivate: false, + formHistoryName: "test", + }); + await check_results({ + context, + matches: [ + makeRecentSearchResult(context, defaultEngine, "Joy Formidable"), + makeRecentSearchResult(context, defaultEngine, "Glasgow Weather"), + makeRecentSearchResult(context, defaultEngine, "Bob Vylan"), + ], + }); + + defaultEngine = oldEngine; + await Services.search.setDefault( + defaultEngine, + Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL + ); + + info("We only show searches made since last default engine change"); + context = createContext("", { isPrivate: false }); + await check_results({ + context, + matches: [], + }); + await UrlbarTestUtils.formHistory.clear(); +}); + +add_task(async function test_expiry() { + UrlbarPrefs.set(ENABLED_PREF, true); + UrlbarPrefs.set(SUGGESTS_PREF, true); + await addSearches(); + let context = createContext("", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeRecentSearchResult(context, defaultEngine, "Joy Formidable"), + makeRecentSearchResult(context, defaultEngine, "Glasgow Weather"), + makeRecentSearchResult(context, defaultEngine, "Bob Vylan"), + ], + }); + + let shortExpiration = 100; + UrlbarPrefs.set(EXPIRE_PREF, shortExpiration.toString()); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, shortExpiration * 2)); + + await check_results({ + context: createContext("", { isPrivate: false }), + matches: [], + }); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerTabToSearch.js b/browser/components/urlbar/tests/unit/test_providerTabToSearch.js new file mode 100644 index 0000000000..0a8bfbead5 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerTabToSearch.js @@ -0,0 +1,536 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests UrlbarProviderTabToSearch. See also + * browser/components/urlbar/tests/browser/browser_tabToSearch.js + */ + +"use strict"; + +let testEngine; + +add_setup(async () => { + // Disable search suggestions for a less verbose test. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + // Disable tab-to-search onboarding results. Those are covered in + // browser/components/urlbar/tests/browser/browser_tabToSearch.js. + Services.prefs.setIntPref( + "browser.urlbar.tabToSearch.onboard.interactionsLeft", + 0 + ); + await SearchTestUtils.installSearchExtension({ name: "Test" }); + testEngine = await Services.search.getEngineByName("Test"); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref( + "browser.urlbar.tabToSearch.onboard.interactionsLeft" + ); + Services.prefs.clearUserPref("browser.search.suggest.enabled"); + }); +}); + +// Tests that tab-to-search results appear when the engine's result domain is +// autofilled. +add_task(async function basic() { + await PlacesTestUtils.addVisits(["https://example.com/"]); + let context = createContext("examp", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/", + completed: "https://example.com/", + matches: [ + makeVisitResult(context, { + uri: "https://example.com/", + title: "test visit for https://example.com/", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: testEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost(testEngine.searchUrlDomain), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); + + info("Repeat the search but with tab-to-search disabled through pref."); + Services.prefs.setBoolPref("browser.urlbar.suggest.engines", false); + await check_results({ + context, + autofilled: "example.com/", + completed: "https://example.com/", + matches: [ + makeVisitResult(context, { + uri: "https://example.com/", + title: "test visit for https://example.com/", + heuristic: true, + providerName: "Autofill", + }), + ], + }); + Services.prefs.clearUserPref("browser.urlbar.suggest.engines"); + + await cleanupPlaces(); +}); + +// Tests that tab-to-search results are shown when the typed string matches an +// engine domain even when there is no autofill. +add_task(async function noAutofill() { + // Note we are not adding any history visits. + let context = createContext("examp", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: Services.search.defaultEngine.name, + engineIconUri: Services.search.defaultEngine.getIconURL(), + heuristic: true, + providerName: "HeuristicFallback", + }), + makeSearchResult(context, { + engineName: testEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost(testEngine.searchUrlDomain), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); +}); + +// Tests that tab-to-search results are not shown when the typed string matches +// an engine domain, but something else is being autofilled. +add_task(async function autofillDoesNotMatchEngine() { + await PlacesTestUtils.addVisits(["https://example.test.ca/"]); + let context = createContext("example", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.test.ca/", + completed: "https://example.test.ca/", + matches: [ + makeVisitResult(context, { + uri: "https://example.test.ca/", + title: "test visit for https://example.test.ca/", + heuristic: true, + providerName: "Autofill", + }), + ], + }); + + await cleanupPlaces(); +}); + +// Tests that www. is ignored for the purposes of matching autofill to +// tab-to-search. +add_task(async function ignoreWww() { + // The history result has www., the engine does not. + await PlacesTestUtils.addVisits(["https://www.example.com/"]); + let context = createContext("www.examp", { isPrivate: false }); + await check_results({ + context, + autofilled: "www.example.com/", + completed: "https://www.example.com/", + matches: [ + makeVisitResult(context, { + uri: "https://www.example.com/", + title: "test visit for https://www.example.com/", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: testEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost(testEngine.searchUrlDomain), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); + await cleanupPlaces(); + + // The engine has www., the history result does not. + await PlacesTestUtils.addVisits(["https://foo.bar/"]); + let extension = await SearchTestUtils.installSearchExtension( + { + name: "TestWww", + search_url: "https://www.foo.bar/", + }, + { skipUnload: true } + ); + let wwwTestEngine = Services.search.getEngineByName("TestWww"); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + autofilled: "foo.bar/", + completed: "https://foo.bar/", + matches: [ + makeVisitResult(context, { + uri: "https://foo.bar/", + title: "test visit for https://foo.bar/", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: wwwTestEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost( + wwwTestEngine.searchUrlDomain + ), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); + await cleanupPlaces(); + + // Both the engine and the history result have www. + await PlacesTestUtils.addVisits(["https://www.foo.bar/"]); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + autofilled: "foo.bar/", + completed: "https://www.foo.bar/", + matches: [ + makeVisitResult(context, { + uri: "https://www.foo.bar/", + title: "test visit for https://www.foo.bar/", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: wwwTestEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost( + wwwTestEngine.searchUrlDomain + ), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); + await cleanupPlaces(); + + await extension.unload(); +}); + +// Tests that when a user's query causes autofill to replace one engine's domain +// with another, the correct tab-to-search results are shown. +add_task(async function conflictingEngines() { + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits([ + "https://foobar.com/", + "https://foo.com/", + ]); + } + let extension1 = await SearchTestUtils.installSearchExtension( + { + name: "TestFooBar", + search_url: "https://foobar.com/", + }, + { skipUnload: true } + ); + let extension2 = await SearchTestUtils.installSearchExtension( + { + name: "TestFoo", + search_url: "https://foo.com/", + }, + { skipUnload: true } + ); + let fooBarTestEngine = Services.search.getEngineByName("TestFooBar"); + let fooTestEngine = Services.search.getEngineByName("TestFoo"); + + // Search for "foo", autofilling foo.com. Observe that the foo.com + // tab-to-search result is shown, even though the foobar.com engine was added + // first (and thus enginesForDomainPrefix puts it earlier in its returned + // array.) + let context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + autofilled: "foo.com/", + completed: "https://foo.com/", + matches: [ + makeVisitResult(context, { + uri: "https://foo.com/", + title: "test visit for https://foo.com/", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: fooTestEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost( + fooTestEngine.searchUrlDomain + ), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + makeVisitResult(context, { + uri: "https://foobar.com/", + title: "test visit for https://foobar.com/", + providerName: "Places", + }), + ], + }); + + // Search for "foob", autofilling foobar.com. Observe that the foo.com + // tab-to-search result is replaced with the foobar.com tab-to-search result. + context = createContext("foob", { isPrivate: false }); + await check_results({ + context, + autofilled: "foobar.com/", + completed: "https://foobar.com/", + matches: [ + makeVisitResult(context, { + uri: "https://foobar.com/", + title: "test visit for https://foobar.com/", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: fooBarTestEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost( + fooBarTestEngine.searchUrlDomain + ), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); + + await cleanupPlaces(); + await extension1.unload(); + await extension2.unload(); +}); + +add_task(async function multipleEnginesForHostname() { + info( + "In case of multiple engines only one tab-to-search result should be returned" + ); + let extension = await SearchTestUtils.installSearchExtension( + { + name: "TestMaps", + search_url: "https://example.com/maps/", + }, + { skipUnload: true } + ); + + let context = createContext("examp", { isPrivate: false }); + let maxResultCount = UrlbarPrefs.get("maxRichResults"); + + // Add enough visits to autofill example.com. + for (let i = 0; i < maxResultCount; i++) { + await PlacesTestUtils.addVisits("https://example.com/"); + } + + // Add enough visits to other URLs matching our query to fill up the list of + // results. + let otherVisitResults = []; + for (let i = 0; i < maxResultCount; i++) { + let url = "https://mochi.test:8888/example/" + i; + await PlacesTestUtils.addVisits(url); + otherVisitResults.unshift( + makeVisitResult(context, { + uri: url, + title: "test visit for " + url, + }) + ); + } + + await check_results({ + context, + autofilled: "example.com/", + completed: "https://example.com/", + matches: [ + makeVisitResult(context, { + uri: "https://example.com/", + title: "test visit for https://example.com/", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: testEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost(testEngine.searchUrlDomain), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + // There should be `maxResultCount` - 2 other visit results. If this fails + // because there are actually `maxResultCount` - 3 other results, then the + // muxer is improperly including both TabToSearch results in its + // calculation of the total available result span instead of only one, so + // one fewer visit result appears than expected. + ...otherVisitResults.slice(0, maxResultCount - 2), + ], + }); + await cleanupPlaces(); + await extension.unload(); +}); + +add_task(async function test_casing() { + info("Tab-to-search results appear also in case of different casing."); + await PlacesTestUtils.addVisits(["https://example.com/"]); + let context = createContext("eXAm", { isPrivate: false }); + await check_results({ + context, + autofilled: "eXAmple.com/", + completed: "https://example.com/", + matches: [ + makeVisitResult(context, { + uri: "https://example.com/", + title: "test visit for https://example.com/", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: testEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost(testEngine.searchUrlDomain), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_publicSuffix() { + info("Tab-to-search results appear also in case of partial host match."); + let extension = await SearchTestUtils.installSearchExtension( + { + name: "MyTest", + search_url: "https://test.mytest.it/", + }, + { skipUnload: true } + ); + let engine = Services.search.getEngineByName("MyTest"); + await PlacesTestUtils.addVisits(["https://test.mytest.it/"]); + let context = createContext("my", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: Services.search.defaultEngine.name, + engineIconUri: Services.search.defaultEngine.getIconURL(), + heuristic: true, + providerName: "HeuristicFallback", + }), + makeSearchResult(context, { + engineName: engine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost(engine.searchUrlDomain), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + satisfiesAutofillThreshold: true, + }), + makeVisitResult(context, { + uri: "https://test.mytest.it/", + title: "test visit for https://test.mytest.it/", + providerName: "Places", + }), + ], + }); + await cleanupPlaces(); + await extension.unload(); +}); + +add_task(async function test_publicSuffixIsHost() { + info("Tab-to-search results does not appear in case we autofill a suffix."); + let extension = await SearchTestUtils.installSearchExtension( + { + name: "SuffixTest", + search_url: "https://somesuffix.com.mx/", + }, + { skipUnload: true } + ); + + // The top level domain will be autofilled, not the full domain. + await PlacesTestUtils.addVisits(["https://com.mx/"]); + let context = createContext("co", { isPrivate: false }); + await check_results({ + context, + autofilled: "com.mx/", + completed: "https://com.mx/", + matches: [ + makeVisitResult(context, { + uri: "https://com.mx/", + title: "test visit for https://com.mx/", + heuristic: true, + providerName: "Autofill", + }), + ], + }); + await cleanupPlaces(); + await extension.unload(); +}); + +add_task(async function test_disabledEngine() { + info("Tab-to-search results does not appear for a Pref-disabled engine."); + let extension = await SearchTestUtils.installSearchExtension( + { + name: "Disabled", + search_url: "https://disabled.com/", + }, + { skipUnload: true } + ); + let engine = Services.search.getEngineByName("Disabled"); + await PlacesTestUtils.addVisits(["https://disabled.com/"]); + let context = createContext("dis", { isPrivate: false }); + + info("Sanity check that the engine would appear."); + await check_results({ + context, + autofilled: "disabled.com/", + completed: "https://disabled.com/", + matches: [ + makeVisitResult(context, { + uri: "https://disabled.com/", + title: "test visit for https://disabled.com/", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: engine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost(engine.searchUrlDomain), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); + + info("Now disable the engine."); + engine.hideOneOffButton = true; + + await check_results({ + context, + autofilled: "disabled.com/", + completed: "https://disabled.com/", + matches: [ + makeVisitResult(context, { + uri: "https://disabled.com/", + title: "test visit for https://disabled.com/", + heuristic: true, + providerName: "Autofill", + }), + ], + }); + engine.hideOneOffButton = false; + + await cleanupPlaces(); + await extension.unload(); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerTabToSearch_partialHost.js b/browser/components/urlbar/tests/unit/test_providerTabToSearch_partialHost.js new file mode 100644 index 0000000000..98c1081b84 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerTabToSearch_partialHost.js @@ -0,0 +1,214 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +// Search engine origins are autofilled normally when they get over the +// threshold, though certain origins redirect to localized subdomains, that +// the user is unlikely to type, for example wikipedia.org => en.wikipedia.org. +// We should get a tab to search result also for these cases, where a normal +// autofill wouldn't happen. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderAutofill: "resource:///modules/UrlbarProviderAutofill.sys.mjs", +}); + +add_setup(async function () { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + // Disable tab-to-search onboarding results. + Services.prefs.setIntPref( + "browser.urlbar.tabToSearch.onboard.interactionsLeft", + 0 + ); + Services.prefs.setBoolPref( + "browser.search.separatePrivateDefault.ui.enabled", + false + ); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + Services.prefs.clearUserPref( + "browser.search.separatePrivateDefault.ui.enabled" + ); + Services.prefs.clearUserPref( + "browser.urlbar.tabToSearch.onboard.interactionsLeft" + ); + }); +}); + +add_task(async function test() { + let url = "https://en.example.com/"; + await SearchTestUtils.installSearchExtension( + { + name: "TestEngine", + search_url: url, + }, + { setAsDefault: true } + ); + + // Make sure the engine domain would be autofilled. + await PlacesUtils.bookmarks.insert({ + url, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bookmark", + }); + + info("Test matching cases"); + + for (let searchStr of ["ex", "example.c"]) { + info("Searching for " + searchStr); + let context = createContext(searchStr, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: Services.search.defaultEngine.name, + providerName: "HeuristicFallback", + heuristic: true, + }), + makeSearchResult(context, { + engineName: "TestEngine", + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: "en.example.", + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + satisfiesAutofillThreshold: true, + }), + makeBookmarkResult(context, { + uri: url, + title: "bookmark", + }), + ], + }); + } + + info("Test a www engine"); + let url2 = "https://www.it.mochi.com/"; + await SearchTestUtils.installSearchExtension({ + name: "TestEngine2", + search_url: url2, + }); + + let engine2 = Services.search.getEngineByName("TestEngine2"); + // Make sure the engine domain would be autofilled. + await PlacesUtils.bookmarks.insert({ + url: url2, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bookmark", + }); + + for (let searchStr of ["mo", "mochi.c"]) { + info("Searching for " + searchStr); + let context = createContext(searchStr, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: Services.search.defaultEngine.name, + providerName: "HeuristicFallback", + heuristic: true, + }), + makeSearchResult(context, { + engineName: engine2.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: "www.it.mochi.", + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + satisfiesAutofillThreshold: true, + }), + makeBookmarkResult(context, { + uri: url2, + title: "bookmark", + }), + ], + }); + } + + info("Test non-matching cases"); + + for (let searchStr of ["www.en", "www.ex", "https://ex"]) { + info("Searching for " + searchStr); + let context = createContext(searchStr, { isPrivate: false }); + // We don't want to generate all the possible results here, just check + // the heuristic result is not autofill. + let controller = UrlbarTestUtils.newMockController(); + await UrlbarProvidersManager.startQuery(context, controller); + Assert.ok(context.results[0].heuristic, "Check heuristic result"); + Assert.notEqual(context.results[0].providerName, "Autofill"); + } + + info("Tab-to-search is not shown when an unrelated site is autofilled."); + let wikiUrl = "https://wikipedia.org/"; + await SearchTestUtils.installSearchExtension({ + name: "FakeWikipedia", + search_url: url, + }); + let wikiEngine = Services.search.getEngineByName("TestEngine"); + + // Make sure that wikiUrl will pass getTopHostOverThreshold. + await PlacesUtils.bookmarks.insert({ + url: wikiUrl, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "Wikipedia", + }); + + // Make sure an unrelated www site is autofilled. + let wwwUrl = "https://www.example.com"; + await PlacesUtils.bookmarks.insert({ + url: wwwUrl, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "Example", + }); + + let searchStr = "w"; + let context = createContext(searchStr, { + isPrivate: false, + sources: [UrlbarUtils.RESULT_SOURCE.BOOKMARKS], + }); + let host = await UrlbarProviderAutofill.getTopHostOverThreshold(context, [ + wikiEngine.searchUrlDomain, + ]); + Assert.equal( + host, + wikiEngine.searchUrlDomain, + "The search satisfies the autofill threshold requirement." + ); + await check_results({ + context, + autofilled: "www.example.com/", + completed: "https://www.example.com/", + matches: [ + makeVisitResult(context, { + uri: `${wwwUrl}/`, + title: "Example", + heuristic: true, + providerName: "Autofill", + }), + // Note that tab-to-search is not shown. + makeBookmarkResult(context, { + uri: wikiUrl, + title: "Wikipedia", + }), + makeBookmarkResult(context, { + uri: url2, + title: "bookmark", + }), + ], + }); + + info("Restricting to history should not autofill our bookmark"); + context = createContext("ex", { + isPrivate: false, + sources: [UrlbarUtils.RESULT_SOURCE.HISTORY], + }); + let controller = UrlbarTestUtils.newMockController(); + await UrlbarProvidersManager.startQuery(context, controller); + Assert.ok(context.results[0].heuristic, "Check heuristic result"); + Assert.notEqual(context.results[0].providerName, "Autofill"); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_providersManager.js b/browser/components/urlbar/tests/unit/test_providersManager.js new file mode 100644 index 0000000000..8446ed0675 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providersManager.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_providers() { + Assert.throws( + () => UrlbarProvidersManager.registerProvider(), + /invalid provider/, + "Should throw with no arguments" + ); + Assert.throws( + () => UrlbarProvidersManager.registerProvider({}), + /invalid provider/, + "Should throw with empty object" + ); + Assert.throws( + () => + UrlbarProvidersManager.registerProvider({ + name: "", + }), + /invalid provider/, + "Should throw with empty name" + ); + Assert.throws( + () => + UrlbarProvidersManager.registerProvider({ + name: "test", + startQuery: "no", + }), + /invalid provider/, + "Should throw with invalid startQuery" + ); + Assert.throws( + () => + UrlbarProvidersManager.registerProvider({ + name: "test", + startQuery: () => {}, + cancelQuery: "no", + }), + /invalid provider/, + "Should throw with invalid cancelQuery" + ); + + let match = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: "http://mozilla.org/foo/" } + ); + + let provider = registerBasicTestProvider([match]); + let context = createContext(undefined, { providers: [provider.name] }); + let controller = UrlbarTestUtils.newMockController(); + let resultsPromise = promiseControllerNotification( + controller, + "onQueryResults" + ); + + await UrlbarProvidersManager.startQuery(context, controller); + // Sanity check that this doesn't throw. It should be a no-op since we await + // for startQuery. + UrlbarProvidersManager.cancelQuery(context); + + let params = await resultsPromise; + Assert.deepEqual(params[0].results, [match]); +}); + +add_task(async function test_criticalSection() { + // Just a sanity check, this shouldn't throw. + await UrlbarProvidersManager.runInCriticalSection(async () => { + let db = await PlacesUtils.promiseLargeCacheDBConnection(); + await db.execute(`PRAGMA page_cache`); + }); +}); diff --git a/browser/components/urlbar/tests/unit/test_providersManager_filtering.js b/browser/components/urlbar/tests/unit/test_providersManager_filtering.js new file mode 100644 index 0000000000..094eb42437 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providersManager_filtering.js @@ -0,0 +1,405 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_filtering_disable_only_source() { + let match = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: "http://mozilla.org/foo/" } + ); + let provider = registerBasicTestProvider([match]); + let context = createContext(undefined, { providers: [provider.name] }); + let controller = UrlbarTestUtils.newMockController(); + + info("Disable the only available source, should get no matches"); + Services.prefs.setBoolPref("browser.urlbar.suggest.openpage", false); + let promise = Promise.race([ + promiseControllerNotification(controller, "onQueryResults", false), + promiseControllerNotification(controller, "onQueryFinished"), + ]); + await controller.startQuery(context); + await promise; + Services.prefs.clearUserPref("browser.urlbar.suggest.openpage"); + UrlbarProvidersManager.unregisterProvider({ name: provider.name }); +}); + +add_task(async function test_filtering_disable_one_source() { + let matches = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: "http://mozilla.org/foo/" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/foo/" } + ), + ]; + let provider = registerBasicTestProvider(matches); + let context = createContext(undefined, { providers: [provider.name] }); + let controller = UrlbarTestUtils.newMockController(); + + info("Disable one of the sources, should get a single match"); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + let promise = Promise.all([ + promiseControllerNotification(controller, "onQueryResults"), + promiseControllerNotification(controller, "onQueryFinished"), + ]); + await controller.startQuery(context, controller); + await promise; + Assert.deepEqual(context.results, matches.slice(0, 1)); + Services.prefs.clearUserPref("browser.urlbar.suggest.history"); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +add_task(async function test_filtering_restriction_token() { + let matches = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: "http://mozilla.org/foo/" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/foo/" } + ), + ]; + let provider = registerBasicTestProvider(matches); + let context = createContext(`foo ${UrlbarTokenizer.RESTRICT.OPENPAGE}`, { + providers: [provider.name], + }); + let controller = UrlbarTestUtils.newMockController(); + + info("Use a restriction character, should get a single match"); + let promise = Promise.all([ + promiseControllerNotification(controller, "onQueryResults"), + promiseControllerNotification(controller, "onQueryFinished"), + ]); + await controller.startQuery(context, controller); + await promise; + Assert.deepEqual(context.results, matches.slice(0, 1)); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +add_task(async function test_filter_javascript() { + let match = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: "http://mozilla.org/foo/" } + ); + let jsMatch = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "javascript:foo" } + ); + let provider = registerBasicTestProvider([match, jsMatch]); + let context = createContext(undefined, { providers: [provider.name] }); + let controller = UrlbarTestUtils.newMockController(); + + info("By default javascript should be filtered out"); + let promise = promiseControllerNotification(controller, "onQueryResults"); + await controller.startQuery(context, controller); + await promise; + Assert.deepEqual(context.results, [match]); + + info("Except when the user explicitly starts the search with javascript:"); + context = createContext(`javascript: ${UrlbarTokenizer.RESTRICT.HISTORY}`, { + providers: [provider.name], + }); + promise = promiseControllerNotification(controller, "onQueryResults"); + await controller.startQuery(context, controller); + await promise; + Assert.deepEqual(context.results, [jsMatch]); + + info("Disable javascript filtering"); + Services.prefs.setBoolPref("browser.urlbar.filter.javascript", false); + context = createContext(undefined, { providers: [provider.name] }); + promise = promiseControllerNotification(controller, "onQueryResults"); + await controller.startQuery(context, controller); + await promise; + Assert.deepEqual(context.results, [match, jsMatch]); + Services.prefs.clearUserPref("browser.urlbar.filter.javascript"); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +add_task(async function test_filter_isActive() { + let goodMatches = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: "http://mozilla.org/foo/" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/foo/" } + ), + ]; + let provider = registerBasicTestProvider(goodMatches); + + let badMatches = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + { url: "http://mozilla.org/foo/" } + ), + ]; + /** + * A test provider that should not be invoked. + */ + class NoInvokeProvider extends UrlbarProvider { + get name() { + return "BadProvider"; + } + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + isActive(context) { + info("Acceptable sources: " + context.sources); + return context.sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS); + } + async startQuery(context, add) { + Assert.ok(false, "Provider should no be invoked"); + for (const match of badMatches) { + add(this, match); + } + } + } + let badProvider = new NoInvokeProvider(); + UrlbarProvidersManager.registerProvider(badProvider); + + let context = createContext(undefined, { + sources: [UrlbarUtils.RESULT_SOURCE.TABS], + providers: [provider.name, "BadProvider"], + }); + let controller = UrlbarTestUtils.newMockController(); + + info("Only tabs should be returned"); + let promise = promiseControllerNotification(controller, "onQueryResults"); + await controller.startQuery(context, controller); + await promise; + Assert.deepEqual(context.results.length, 1, "Should find only one match"); + Assert.deepEqual( + context.results[0].source, + UrlbarUtils.RESULT_SOURCE.TABS, + "Should find only a tab match" + ); + UrlbarProvidersManager.unregisterProvider(provider); + UrlbarProvidersManager.unregisterProvider(badProvider); +}); + +add_task(async function test_filter_queryContext() { + let provider = registerBasicTestProvider(); + + /** + * A test provider that should not be invoked because of queryContext.providers. + */ + class NoInvokeProvider extends UrlbarProvider { + get name() { + return "BadProvider"; + } + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + isActive(context) { + return true; + } + async startQuery(context, add) { + Assert.ok(false, "Provider should no be invoked"); + } + } + let badProvider = new NoInvokeProvider(); + UrlbarProvidersManager.registerProvider(badProvider); + + let context = createContext(undefined, { + providers: [provider.name], + }); + let controller = UrlbarTestUtils.newMockController(); + + await controller.startQuery(context, controller); + UrlbarProvidersManager.unregisterProvider(provider); + UrlbarProvidersManager.unregisterProvider(badProvider); +}); + +add_task(async function test_nofilter_heuristic() { + // Checks that even if a provider returns a result that should be filtered out + // it will still be invoked if it's of type heuristic, and only the heuristic + // result is returned. + let matches = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: "http://mozilla.org/foo/" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: "http://mozilla.org/foo2/" } + ), + ]; + matches[0].heuristic = true; + let provider = registerBasicTestProvider( + matches, + undefined, + UrlbarUtils.PROVIDER_TYPE.HEURISTIC + ); + + let context = createContext(undefined, { + sources: [UrlbarUtils.RESULT_SOURCE.SEARCH], + providers: [provider.name], + }); + let controller = UrlbarTestUtils.newMockController(); + + // Disable search matches through prefs. + Services.prefs.setBoolPref("browser.urlbar.suggest.openpage", false); + info("Only 1 heuristic tab result should be returned"); + let promise = promiseControllerNotification(controller, "onQueryResults"); + await controller.startQuery(context, controller); + await promise; + Services.prefs.clearUserPref("browser.urlbar.suggest.openpage"); + Assert.deepEqual(context.results.length, 1, "Should find only one match"); + Assert.deepEqual( + context.results[0].source, + UrlbarUtils.RESULT_SOURCE.TABS, + "Should find only a tab match" + ); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +add_task(async function test_nofilter_restrict() { + // Checks that even if a pref is disabled, we still return results on a + // restriction token. + let matches = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: "http://mozilla.org/foo_tab/" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + { url: "http://mozilla.org/foo_bookmark/" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/foo_history/" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { engine: "noengine" } + ), + ]; + /** + * A test provider. + */ + class TestProvider extends UrlbarProvider { + get name() { + return "MyProvider"; + } + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + isActive(context) { + Assert.equal(context.sources.length, 1, "Check acceptable sources"); + return true; + } + async startQuery(context, add) { + Assert.ok(true, "expected provider was invoked"); + for (let match of matches) { + add(this, match); + } + } + } + let provider = new TestProvider(); + UrlbarProvidersManager.registerProvider(provider); + + let typeToPropertiesMap = new Map([ + ["HISTORY", { source: "HISTORY", pref: "history" }], + ["BOOKMARK", { source: "BOOKMARKS", pref: "bookmark" }], + ["OPENPAGE", { source: "TABS", pref: "openpage" }], + ["SEARCH", { source: "SEARCH", pref: "searches" }], + ]); + for (let [type, token] of Object.entries(UrlbarTokenizer.RESTRICT)) { + let properties = typeToPropertiesMap.get(type); + if (!properties) { + continue; + } + info("Restricting on " + type); + let context = createContext(token + " foo", { + providers: ["MyProvider"], + }); + let controller = UrlbarTestUtils.newMockController(); + // Disable the corresponding pref. + const pref = "browser.urlbar.suggest." + properties.pref; + info("Disabling " + pref); + Services.prefs.setBoolPref(pref, false); + await controller.startQuery(context, controller); + Assert.equal(context.results.length, 1, "Should find one result"); + Assert.equal( + context.results[0].source, + UrlbarUtils.RESULT_SOURCE[properties.source], + "Check result source" + ); + Services.prefs.clearUserPref(pref); + } + UrlbarProvidersManager.unregisterProvider(provider); +}); + +add_task(async function test_filter_priority() { + /** + * A test provider. + */ + class TestProvider extends UrlbarTestUtils.TestProvider { + constructor(priority, shouldBeInvoked, namePart = "") { + super({ priority, name: `${priority}` + namePart }); + this._shouldBeInvoked = shouldBeInvoked; + } + async startQuery(context, add) { + Assert.ok(this._shouldBeInvoked, `${this.name} was invoked`); + } + } + + // Test all possible orderings of the providers to make sure the logic that + // finds the highest priority providers is correct. + let providerPerms = permute([ + new TestProvider(0, false), + new TestProvider(1, false), + new TestProvider(2, true, "a"), + new TestProvider(2, true, "b"), + ]); + for (let providers of providerPerms) { + for (let provider of providers) { + UrlbarProvidersManager.registerProvider(provider); + } + let providerNames = providers.map(p => p.name); + let context = createContext(undefined, { providers: providerNames }); + let controller = UrlbarTestUtils.newMockController(); + await controller.startQuery(context, controller); + for (let name of providerNames) { + UrlbarProvidersManager.unregisterProvider({ name }); + } + } +}); + +function permute(objects) { + if (objects.length <= 1) { + return [objects]; + } + let perms = []; + for (let i = 0; i < objects.length; i++) { + let otherObjects = objects.slice(); + otherObjects.splice(i, 1); + let otherPerms = permute(otherObjects); + for (let perm of otherPerms) { + perm.unshift(objects[i]); + } + perms = perms.concat(otherPerms); + } + return perms; +} diff --git a/browser/components/urlbar/tests/unit/test_providersManager_maxResults.js b/browser/components/urlbar/tests/unit/test_providersManager_maxResults.js new file mode 100644 index 0000000000..b30b9352cd --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providersManager_maxResults.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_maxResults() { + const MATCHES_LENGTH = 20; + let matches = []; + for (let i = 0; i < MATCHES_LENGTH; i++) { + matches.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: `http://mozilla.org/foo/${i}` } + ) + ); + } + let provider = registerBasicTestProvider(matches); + let context = createContext(undefined, { providers: [provider.name] }); + let controller = UrlbarTestUtils.newMockController(); + + async function test_count(count) { + let promise = promiseControllerNotification(controller, "onQueryFinished"); + context.maxResults = count; + await controller.startQuery(context); + await promise; + Assert.equal( + context.results.length, + Math.min(MATCHES_LENGTH, count), + "Check count" + ); + Assert.deepEqual(context.results, matches.slice(0, count), "Check results"); + } + await test_count(10); + await test_count(1); + await test_count(30); +}); diff --git a/browser/components/urlbar/tests/unit/test_queryScorer.js b/browser/components/urlbar/tests/unit/test_queryScorer.js new file mode 100644 index 0000000000..1d6171eac4 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_queryScorer.js @@ -0,0 +1,405 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + QueryScorer: "resource:///modules/UrlbarProviderInterventions.sys.mjs", +}); + +const DISTANCE_THRESHOLD = 1; + +const DOCUMENTS = { + clear: [ + "cache firefox", + "clear cache firefox", + "clear cache in firefox", + "clear cookies firefox", + "clear firefox cache", + "clear history firefox", + "cookies firefox", + "delete cookies firefox", + "delete history firefox", + "firefox cache", + "firefox clear cache", + "firefox clear cookies", + "firefox clear history", + "firefox cookie", + "firefox cookies", + "firefox delete cookies", + "firefox delete history", + "firefox history", + "firefox not loading pages", + "history firefox", + "how to clear cache", + "how to clear history", + ], + refresh: [ + "firefox crashing", + "firefox keeps crashing", + "firefox not responding", + "firefox not working", + "firefox refresh", + "firefox slow", + "how to reset firefox", + "refresh firefox", + "reset firefox", + ], + update: [ + "download firefox", + "download mozilla", + "firefox browser", + "firefox download", + "firefox for mac", + "firefox for windows", + "firefox free download", + "firefox install", + "firefox installer", + "firefox latest version", + "firefox mac", + "firefox quantum", + "firefox update", + "firefox version", + "firefox windows", + "get firefox", + "how to update firefox", + "install firefox", + "mozilla download", + "mozilla firefox 2019", + "mozilla firefox 2020", + "mozilla firefox download", + "mozilla firefox for mac", + "mozilla firefox for windows", + "mozilla firefox free download", + "mozilla firefox mac", + "mozilla firefox update", + "mozilla firefox windows", + "mozilla update", + "update firefox", + "update mozilla", + "www.firefox.com", + ], +}; + +const VARIATIONS = new Map([["firefox", ["fire fox", "fox fire", "foxfire"]]]); + +let tests = [ + { + query: "firefox", + matches: [ + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "bogus", + matches: [ + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "no match", + matches: [ + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + + // clear + { + query: "firefox histo", + matches: [ + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefox histor", + matches: [ + { id: "clear", score: 1 }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefox history", + matches: [ + { id: "clear", score: 0 }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefox history we'll keep matching once we match", + matches: [ + { id: "clear", score: 0 }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + + { + query: "firef history", + matches: [ + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefo history", + matches: [ + { id: "clear", score: 1 }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefo histor", + matches: [ + { id: "clear", score: 2 }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefo histor we'll keep matching once we match", + matches: [ + { id: "clear", score: 2 }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + + { + query: "fire fox history", + matches: [ + { id: "clear", score: 0 }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "fox fire history", + matches: [ + { id: "clear", score: 0 }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "foxfire history", + matches: [ + { id: "clear", score: 0 }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + + // refresh + { + query: "firefox sl", + matches: [ + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefox slo", + matches: [ + { id: "refresh", score: 1 }, + { id: "clear", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefox slow", + matches: [ + { id: "refresh", score: 0 }, + { id: "clear", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefox slow we'll keep matching once we match", + matches: [ + { id: "refresh", score: 0 }, + { id: "clear", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + + { + query: "firef slow", + matches: [ + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefo slow", + matches: [ + { id: "refresh", score: 1 }, + { id: "clear", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefo slo", + matches: [ + { id: "refresh", score: 2 }, + { id: "clear", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefo slo we'll keep matching once we match", + matches: [ + { id: "refresh", score: 2 }, + { id: "clear", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + + { + query: "fire fox slow", + matches: [ + { id: "refresh", score: 0 }, + { id: "clear", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "fox fire slow", + matches: [ + { id: "refresh", score: 0 }, + { id: "clear", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "foxfire slow", + matches: [ + { id: "refresh", score: 0 }, + { id: "clear", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + + // update + { + query: "firefox upda", + matches: [ + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefox updat", + matches: [ + { id: "update", score: 1 }, + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + ], + }, + { + query: "firefox update", + matches: [ + { id: "update", score: 0 }, + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + ], + }, + { + query: "firefox update we'll keep matching once we match", + matches: [ + { id: "update", score: 0 }, + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + ], + }, + + { + query: "firef update", + matches: [ + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefo update", + matches: [ + { id: "update", score: 1 }, + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + ], + }, + { + query: "firefo updat", + matches: [ + { id: "update", score: 2 }, + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + ], + }, + { + query: "firefo updat we'll keep matching once we match", + matches: [ + { id: "update", score: 2 }, + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + ], + }, + + { + query: "fire fox update", + matches: [ + { id: "update", score: 0 }, + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + ], + }, + { + query: "fox fire update", + matches: [ + { id: "update", score: 0 }, + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + ], + }, + { + query: "foxfire update", + matches: [ + { id: "update", score: 0 }, + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + ], + }, +]; + +add_task(async function test() { + let qs = new QueryScorer({ + distanceThreshold: DISTANCE_THRESHOLD, + variations: VARIATIONS, + }); + + for (let [id, phrases] of Object.entries(DOCUMENTS)) { + qs.addDocument({ id, phrases }); + } + + for (let { query, matches } of tests) { + let actual = qs + .score(query) + .map(result => ({ id: result.document.id, score: result.score })); + Assert.deepEqual(actual, matches, `Query: "${query}"`); + } +}); diff --git a/browser/components/urlbar/tests/unit/test_query_url.js b/browser/components/urlbar/tests/unit/test_query_url.js new file mode 100644 index 0000000000..3b478c3cf3 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_query_url.js @@ -0,0 +1,123 @@ +/* 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 PLACES_PROVIDERNAME = "Places"; + +testEngine_setup(); + +add_task(async function test_no_slash() { + info("Searching for host match without slash should match host"); + await PlacesTestUtils.addVisits([ + { uri: "http://file.org/test/" }, + { uri: "file:///c:/test.html" }, + ]); + let context = createContext("file", { isPrivate: false }); + await check_results({ + context, + autofilled: "file.org/", + completed: "http://file.org/", + matches: [ + makeVisitResult(context, { + uri: "http://file.org/", + fallbackTitle: UrlbarTestUtils.trimURL("http://file.org/"), + heuristic: true, + }), + makeVisitResult(context, { + uri: "file:///c:/test.html", + title: "test visit for file:///c:/test.html", + iconUri: UrlbarUtils.ICON.DEFAULT, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://file.org/test/", + title: "test visit for http://file.org/test/", + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_w_slash() { + info("Searching match with slash at the end should match url"); + await PlacesTestUtils.addVisits( + { + uri: Services.io.newURI("http://file.org/test/"), + }, + { + uri: Services.io.newURI("file:///c:/test.html"), + } + ); + let context = createContext("file.org/", { isPrivate: false }); + await check_results({ + context, + autofilled: "file.org/", + completed: "http://file.org/", + matches: [ + makeVisitResult(context, { + uri: "http://file.org/", + fallbackTitle: UrlbarTestUtils.trimURL("http://file.org/", { + removeSingleTrailingSlash: false, + }), + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://file.org/test/", + title: "test visit for http://file.org/test/", + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_middle() { + info("Searching match with slash in the middle should match url"); + await PlacesTestUtils.addVisits( + { + uri: Services.io.newURI("http://file.org/test/"), + }, + { + uri: Services.io.newURI("file:///c:/test.html"), + } + ); + let context = createContext("file.org/t", { isPrivate: false }); + await check_results({ + context, + autofilled: "file.org/test/", + completed: "http://file.org/test/", + matches: [ + makeVisitResult(context, { + uri: "http://file.org/test/", + title: "test visit for http://file.org/test/", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_nonhost() { + info("Searching for non-host match without slash should not match url"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("file:///c:/test.html"), + }); + let context = createContext("file", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: "file:///c:/test.html", + title: "test visit for file:///c:/test.html", + iconUri: UrlbarUtils.ICON.DEFAULT, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_quickactions.js b/browser/components/urlbar/tests/unit/test_quickactions.js new file mode 100644 index 0000000000..00206c77b2 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_quickactions.js @@ -0,0 +1,127 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderQuickActions: + "resource:///modules/UrlbarProviderQuickActions.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", { + commands: ["newaction"], + }); + + registerCleanupFunction(async () => { + UrlbarPrefs.clear("quickactions.enabled"); + UrlbarPrefs.clear("suggest.quickactions"); + UrlbarProviderQuickActions.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: [], + }); +}); + +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)], + }); +}); + +add_task(async function duplicate_matches() { + UrlbarProviderQuickActions.addAction("testaction", { + commands: ["testaction", "test"], + }); + + let context = createContext("testaction", { + providers: [UrlbarProviderQuickActions.name], + isPrivate: false, + }); + + await check_results({ + context, + matches: [expectedMatch("testaction", 10)], + }); + + UrlbarProviderQuickActions.removeAction("testaction"); +}); + +add_task(async function remove_action() { + UrlbarProviderQuickActions.addAction("testaction", { + commands: ["testaction"], + }); + UrlbarProviderQuickActions.removeAction("testaction"); + + let context = createContext("test", { + providers: [UrlbarProviderQuickActions.name], + isPrivate: false, + }); + + await check_results({ + context, + matches: [], + }); +}); + +add_task(async function minimum_search_string() { + let searchString = "newa"; + 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 }); + } + } + UrlbarPrefs.clear("quickactions.minimumSearchString"); +}); diff --git a/browser/components/urlbar/tests/unit/test_remote_tabs.js b/browser/components/urlbar/tests/unit/test_remote_tabs.js new file mode 100644 index 0000000000..bb0e708162 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_remote_tabs.js @@ -0,0 +1,695 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim:set ts=2 sw=2 sts=2 et: + */ +"use strict"; + +const { Weave } = ChromeUtils.importESModule( + "resource://services-sync/main.sys.mjs" +); + +// A mock "Tabs" engine which autocomplete will use instead of the real +// engine. We pass a constructor that Sync creates. +function MockTabsEngine() { + this.clients = null; // We'll set this dynamically +} + +MockTabsEngine.prototype = { + name: "tabs", + + startTracking() {}, + getAllClients() { + return this.clients; + }, +}; + +// A clients engine that doesn't need to be a constructor. +let MockClientsEngine = { + getClientType(guid) { + Assert.ok(guid.endsWith("desktop") || guid.endsWith("mobile")); + return guid.endsWith("mobile") ? "phone" : "desktop"; + }, + remoteClientExists(id) { + return true; + }, + getClientName(id) { + return id.endsWith("mobile") ? "My Phone" : "My Desktop"; + }, +}; + +// Configure the singleton engine for a test. +function configureEngine(clients) { + // Configure the instance Sync created. + let engine = Weave.Service.engineManager.get("tabs"); + engine.clients = clients; + Weave.Service.clientsEngine = MockClientsEngine; + // Send an observer that pretends the engine just finished a sync. + Services.obs.notifyObservers(null, "weave:engine:sync:finish", "tabs"); +} + +testEngine_setup(); + +add_setup(async function () { + // Tell Sync about the mocks. + Weave.Service.engineManager.register(MockTabsEngine); + + // Tell the Sync XPCOM service it is initialized. + let weaveXPCService = Cc["@mozilla.org/weave/service;1"].getService( + Ci.nsISupports + ).wrappedJSObject; + weaveXPCService.ready = true; + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("services.sync.username"); + Services.prefs.clearUserPref("services.sync.registerEngines"); + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + await cleanupPlaces(); + }); + + Services.prefs.setCharPref("services.sync.username", "someone@somewhere.com"); + Services.prefs.setCharPref("services.sync.registerEngines", ""); + // Avoid hitting the network. + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); +}); + +add_task(async function test_minimal() { + // The minimal client and tabs info we can get away with. + configureEngine([ + { + id: "desktop", + tabs: [ + { + urlHistory: ["http://example.com/"], + }, + ], + }, + ]); + + let query = "ex"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeRemoteTabResult(context, { + uri: "http://example.com/", + device: "My Desktop", + }), + ], + }); +}); + +add_task(async function test_maximal() { + // Every field that could possibly exist on a remote record. + configureEngine([ + { + id: "mobile", + tabs: [ + { + urlHistory: ["http://example.com/"], + title: "An Example", + icon: "http://favicon", + }, + ], + }, + ]); + + let query = "ex"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeRemoteTabResult(context, { + uri: "http://example.com/", + device: "My Phone", + title: "An Example", + iconUri: "cached-favicon:http://favicon/", + }), + ], + }); +}); + +add_task(async function test_noShowIcons() { + Services.prefs.setBoolPref("services.sync.syncedTabs.showRemoteIcons", false); + configureEngine([ + { + id: "mobile", + tabs: [ + { + urlHistory: ["http://example.com/"], + title: "An Example", + icon: "http://favicon", + }, + ], + }, + ]); + + let query = "ex"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeRemoteTabResult(context, { + uri: "http://example.com/", + device: "My Phone", + title: "An Example", + // expecting the default favicon due to that pref. + iconUri: "", + }), + ], + }); + Services.prefs.clearUserPref("services.sync.syncedTabs.showRemoteIcons"); +}); + +add_task(async function test_dontMatchSyncedTabs() { + Services.prefs.setBoolPref("services.sync.syncedTabs.showRemoteTabs", false); + configureEngine([ + { + id: "mobile", + tabs: [ + { + urlHistory: ["http://example.com/"], + title: "An Example", + icon: "http://favicon", + }, + ], + }, + ]); + + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + Services.prefs.clearUserPref("services.sync.syncedTabs.showRemoteTabs"); +}); + +add_task(async function test_tabsDisabledInUrlbar() { + Services.prefs.setBoolPref("browser.urlbar.suggest.remotetab", false); + configureEngine([ + { + id: "mobile", + tabs: [ + { + urlHistory: ["http://example.com/"], + title: "An Example", + icon: "http://favicon", + }, + ], + }, + ]); + + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + Services.prefs.clearUserPref("browser.urlbar.suggest.remotetab"); +}); + +add_task(async function test_matches_title() { + // URL doesn't match search expression, should still match the title. + configureEngine([ + { + id: "mobile", + tabs: [ + { + urlHistory: ["http://foo.com/"], + title: "An Example", + }, + ], + }, + ]); + + let query = "ex"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeRemoteTabResult(context, { + uri: "http://foo.com/", + device: "My Phone", + title: "An Example", + }), + ], + }); +}); + +add_task(async function test_localtab_matches_override() { + // We have an open tab to the same page on a remote device, only "switch to + // tab" should appear as duplicate detection removed the remote one. + + // First set up Sync to have the page as a remote tab. + configureEngine([ + { + id: "mobile", + tabs: [ + { + urlHistory: ["http://foo.com/"], + title: "An Example", + }, + ], + }, + ]); + + // Set up Places to think the tab is open locally. + let uri = Services.io.newURI("http://foo.com/"); + await PlacesTestUtils.addVisits([{ uri, title: "An Example" }]); + await addOpenPages(uri, 1); + + let query = "ex"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "http://foo.com/", + title: "An Example", + }), + ], + }); + + await removeOpenPages(uri, 1); + await cleanupPlaces(); +}); + +add_task(async function test_remotetab_matches_override() { + // If we have an history result to the same page, we should only get the + // remote tab match. + let url = "http://foo.remote.com/"; + // First set up Sync to have the page as a remote tab. + configureEngine([ + { + id: "mobile", + tabs: [ + { + urlHistory: [url], + title: "An Example", + }, + ], + }, + ]); + + // Set up Places to think the tab is in history. + await PlacesTestUtils.addVisits(url); + + let query = "ex"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/", + device: "My Phone", + title: "An Example", + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_mixed_result_types() { + // In case we have many results, non-remote results should flex to the bottom. + let url = "http://foo.remote.com/"; + let tabs = Array(6) + .fill(0) + .map((e, i) => ({ + urlHistory: [`${url}${i}`], + title: "A title", + lastUsed: Math.floor(Date.now() / 1000) - i * 86400, // i days ago. + })); + // First set up Sync to have the page as a remote tab. + configureEngine([{ id: "mobile", tabs }]); + + // Register the page as an open tab. + let openTabUrl = url + "openpage/"; + let uri = Services.io.newURI(openTabUrl); + await PlacesTestUtils.addVisits([{ uri, title: "An Example" }]); + await addOpenPages(uri, 1); + + // Also add a local history result. + let historyUrl = url + "history/"; + await PlacesTestUtils.addVisits(historyUrl); + + let query = "rem"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/0", + device: "My Phone", + title: "A title", + lastUsed: tabs[0].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/1", + device: "My Phone", + title: "A title", + lastUsed: tabs[1].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/2", + device: "My Phone", + title: "A title", + lastUsed: tabs[2].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/3", + device: "My Phone", + title: "A title", + lastUsed: tabs[3].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/4", + device: "My Phone", + title: "A title", + lastUsed: tabs[4].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/5", + device: "My Phone", + title: "A title", + lastUsed: tabs[5].lastUsed, + }), + makeVisitResult(context, { + uri: historyUrl, + title: "test visit for " + historyUrl, + }), + makeTabSwitchResult(context, { + uri: openTabUrl, + title: "An Example", + }), + ], + }); + await removeOpenPages(uri, 1); + await cleanupPlaces(); +}); + +add_task(async function test_many_remotetab_results() { + let url = "http://foo.remote.com/"; + let tabs = Array(8) + .fill(0) + .map((e, i) => ({ + urlHistory: [`${url}${i}`], + title: "A title", + lastUsed: Math.floor(Date.now() / 1000) - i * 86400, // i days old. + })); + + // First set up Sync to have the page as a remote tab. + configureEngine([ + { + id: "mobile", + tabs, + }, + ]); + + let query = "rem"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/0", + device: "My Phone", + title: "A title", + lastUsed: tabs[0].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/1", + device: "My Phone", + title: "A title", + lastUsed: tabs[1].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/2", + device: "My Phone", + title: "A title", + lastUsed: tabs[2].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/3", + device: "My Phone", + title: "A title", + lastUsed: tabs[3].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/4", + device: "My Phone", + title: "A title", + lastUsed: tabs[4].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/5", + device: "My Phone", + title: "A title", + lastUsed: tabs[5].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/6", + device: "My Phone", + title: "A title", + lastUsed: tabs[6].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/7", + device: "My Phone", + title: "A title", + lastUsed: tabs[7].lastUsed, + }), + ], + }); +}); + +add_task(async function multiple_clients() { + let url = "http://foo.remote.com/"; + let mobileTabs = Array(2) + .fill(0) + .map((e, i) => ({ + urlHistory: [`${url}mobile/${i}`], + lastUsed: Date.now() / 1000 - 4 * 86400, // 4 days old (past threshold) + })); + + let desktopTabs = Array(3) + .fill(0) + .map((e, i) => ({ + urlHistory: [`${url}desktop/${i}`], + lastUsed: Date.now() / 1000 - 1, // Fresh tabs + })); + + // mobileTabs has the most recent tab, making it the most recent client. The + // rest of its tabs are stale. The tabs in desktopTabs are fresh, but not + // as fresh as the most recent tab in mobileTab. + mobileTabs.push({ + urlHistory: [`${url}mobile/fresh`], + lastUsed: Date.now() / 1000, + }); + + configureEngine([ + { + id: "mobile", + tabs: mobileTabs, + }, + { + id: "desktop", + tabs: desktopTabs, + }, + ]); + + // We expect that we will show the recent tab from mobileTabs, then all the + // tabs from desktopTabs, then the remaining tabs from mobileTabs. + let query = "rem"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/mobile/fresh", + device: "My Phone", + lastUsed: mobileTabs[2].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/desktop/0", + device: "My Desktop", + lastUsed: desktopTabs[0].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/desktop/1", + device: "My Desktop", + lastUsed: desktopTabs[1].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/desktop/2", + device: "My Desktop", + lastUsed: desktopTabs[2].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/mobile/0", + device: "My Phone", + lastUsed: mobileTabs[0].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/mobile/1", + device: "My Phone", + lastUsed: mobileTabs[1].lastUsed, + }), + ], + }); +}); + +add_task(async function test_restrictionCharacter() { + let url = "http://foo.remote.com/"; + let tabs = Array(5) + .fill(0) + .map((e, i) => ({ + urlHistory: [`${url}${i}`], + title: "A title", + lastUsed: Math.floor(Date.now() / 1000) - i, + })); + configureEngine([ + { + id: "mobile", + tabs, + }, + ]); + + // Also add an open page. + let openTabUrl = url + "openpage/"; + let uri = Services.io.newURI(openTabUrl); + await PlacesTestUtils.addVisits([{ uri, title: "An Example" }]); + await addOpenPages(uri, 1); + + // We expect the open tab to flex to the bottom. + let query = UrlbarTokenizer.RESTRICT.OPENPAGE; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/0", + device: "My Phone", + title: "A title", + lastUsed: tabs[0].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/1", + device: "My Phone", + title: "A title", + lastUsed: tabs[1].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/2", + device: "My Phone", + title: "A title", + lastUsed: tabs[2].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/3", + device: "My Phone", + title: "A title", + lastUsed: tabs[3].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/4", + device: "My Phone", + title: "A title", + lastUsed: tabs[4].lastUsed, + }), + makeTabSwitchResult(context, { + uri: openTabUrl, + title: "An Example", + }), + ], + }); + await removeOpenPages(uri, 1); + await cleanupPlaces(); +}); + +add_task(async function test_duplicate_remote_tabs() { + let url = "http://foo.remote.com/"; + let tabs = Array(3) + .fill(0) + .map((e, i) => ({ + urlHistory: [url], + title: "A title", + lastUsed: Math.floor(Date.now() / 1000), + })); + configureEngine([ + { + id: "mobile", + tabs, + }, + ]); + + // We expect the duplicate tabs to be deduped. + let query = UrlbarTokenizer.RESTRICT.OPENPAGE; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/", + device: "My Phone", + title: "A title", + lastUsed: tabs[0].lastUsed, + }), + ], + }); +}); diff --git a/browser/components/urlbar/tests/unit/test_resultGroups.js b/browser/components/urlbar/tests/unit/test_resultGroups.js new file mode 100644 index 0000000000..5d8cdd53d3 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_resultGroups.js @@ -0,0 +1,1576 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests the muxer's result groups composition logic: child groups, +// `availableSpan`, `maxResultCount`, flex, etc. The purpose of this test is to +// check the composition logic, not every possible result type or group. + +"use strict"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +// The possible limit-related properties in result groups. +const LIMIT_KEYS = ["availableSpan", "maxResultCount"]; + +// Most of this test adds tasks using `add_resultGroupsLimit_tasks`. It works +// like this. Instead of defining `maxResultCount` or `availableSpan` in their +// result groups, tasks define a `limit` property. The value of this property is +// a number just like any of the values for the limit-related properties. At +// runtime, `add_resultGroupsLimit_tasks` adds multiple tasks, one for each key +// in `LIMIT_KEYS`. In each of these tasks, the `limit` property is replaced +// with the actual limit key. This allows us to run checks against each of the +// limit keys using essentially the same task. + +const MAX_RICH_RESULTS_PREF = "browser.urlbar.maxRichResults"; + +// For simplicity, most of the flex tests below assume that this is 10, so +// you'll need to update them if you change this. +const MAX_RESULTS = 10; + +let sandbox; + +add_setup(async function () { + // Set a specific maxRichResults for sanity's sake. + Services.prefs.setIntPref(MAX_RICH_RESULTS_PREF, MAX_RESULTS); + + sandbox = lazy.sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); +}); + +add_resultGroupsLimit_tasks({ + testName: "empty root", + resultGroups: {}, + providerResults: [...makeHistoryResults(1)], + expectedResultIndexes: [], +}); + +add_resultGroupsLimit_tasks({ + testName: "root with empty children", + resultGroups: { + children: [], + }, + providerResults: [...makeHistoryResults(1)], + expectedResultIndexes: [], +}); + +add_resultGroupsLimit_tasks({ + testName: "root no match", + resultGroups: { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + providerResults: [...makeHistoryResults(1)], + expectedResultIndexes: [], +}); + +add_resultGroupsLimit_tasks({ + testName: "children no match", + resultGroups: { + children: [{ group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }], + }, + providerResults: [...makeHistoryResults(1)], + expectedResultIndexes: [], +}); + +add_resultGroupsLimit_tasks({ + // The actual max result count on the root is always context.maxResults and + // limit is ignored, so we expect the result in this case. + testName: "root limit: 0", + resultGroups: { + limit: 0, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + providerResults: [...makeHistoryResults(1)], + expectedResultIndexes: [0], +}); + +add_resultGroupsLimit_tasks({ + // The actual max result count on the root is always context.maxResults and + // limit is ignored, so we expect the result in this case. + testName: "root limit: 0 with children", + resultGroups: { + limit: 0, + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + providerResults: [...makeHistoryResults(1)], + expectedResultIndexes: [0], +}); + +add_resultGroupsLimit_tasks({ + testName: "child limit: 0", + resultGroups: { + children: [ + { + limit: 0, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + providerResults: [...makeHistoryResults(1)], + expectedResultIndexes: [], +}); + +add_resultGroupsLimit_tasks({ + testName: "root group", + resultGroups: { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + providerResults: [...makeHistoryResults(1)], + expectedResultIndexes: [...makeIndexRange(0, 1)], +}); + +add_resultGroupsLimit_tasks({ + testName: "root group multiple", + resultGroups: { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + providerResults: [...makeHistoryResults(2)], + expectedResultIndexes: [...makeIndexRange(0, 2)], +}); + +add_resultGroupsLimit_tasks({ + testName: "child group multiple", + resultGroups: { + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + providerResults: [...makeHistoryResults(2)], + expectedResultIndexes: [0, 1], +}); + +add_resultGroupsLimit_tasks({ + testName: "simple limit", + resultGroups: { + children: [ + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + providerResults: [...makeHistoryResults(2)], + expectedResultIndexes: [0], +}); + +add_resultGroupsLimit_tasks({ + testName: "limit siblings", + resultGroups: { + children: [ + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + ], + }, + providerResults: [...makeHistoryResults(2)], + expectedResultIndexes: [0, 1], +}); + +add_resultGroupsLimit_tasks({ + testName: "limit nested", + resultGroups: { + children: [ + { + limit: 1, + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + ], + }, + providerResults: [...makeHistoryResults(2)], + expectedResultIndexes: [0], +}); + +add_resultGroupsLimit_tasks({ + testName: "limit nested siblings", + resultGroups: { + children: [ + { + limit: 1, + children: [ + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + ], + }, + ], + }, + providerResults: [...makeHistoryResults(2)], + expectedResultIndexes: [0], +}); + +add_resultGroupsLimit_tasks({ + testName: "limit nested uncle", + resultGroups: { + children: [ + { + limit: 1, + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + ], + }, + providerResults: [...makeHistoryResults(2)], + expectedResultIndexes: [0, 1], +}); + +add_resultGroupsLimit_tasks({ + testName: "limit nested override bad", + resultGroups: { + children: [ + { + limit: 1, + children: [ + { + limit: 99, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + ], + }, + providerResults: [...makeHistoryResults(2)], + expectedResultIndexes: [0], +}); + +add_resultGroupsLimit_tasks({ + testName: "limit nested override good", + resultGroups: { + children: [ + { + limit: 99, + children: [ + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + ], + }, + providerResults: [...makeHistoryResults(2)], + expectedResultIndexes: [0], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups", + resultGroups: { + children: [ + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + { group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [...makeIndexRange(2, 2), ...makeIndexRange(0, 2)], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups limit 1", + resultGroups: { + children: [ + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [...makeIndexRange(2, 1), ...makeIndexRange(0, 2)], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups limit 2", + resultGroups: { + children: [ + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [...makeIndexRange(2, 2), ...makeIndexRange(0, 1)], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups limit 3", + resultGroups: { + children: [ + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }, + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [ + ...makeIndexRange(2, 1), + ...makeIndexRange(0, 2), + ...makeIndexRange(3, 1), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups limit 4", + resultGroups: { + children: [ + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [...makeIndexRange(2, 2), ...makeIndexRange(0, 1)], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups nested 1", + resultGroups: { + children: [ + { + children: [ + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + { group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }, + ], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [...makeIndexRange(2, 2), ...makeIndexRange(0, 2)], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups nested 2", + resultGroups: { + children: [ + { + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + { + children: [{ group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [...makeIndexRange(2, 2), ...makeIndexRange(0, 2)], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups nested limit 1", + resultGroups: { + children: [ + { + limit: 1, + children: [ + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + { group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }, + ], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [...makeIndexRange(2, 1)], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups nested limit 2", + resultGroups: { + children: [ + { + children: [ + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + { + children: [{ group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [...makeIndexRange(2, 1), ...makeIndexRange(0, 2)], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups nested limit 3", + resultGroups: { + children: [ + { + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + { + children: [ + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [...makeIndexRange(2, 2), ...makeIndexRange(0, 1)], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups nested limit 4", + resultGroups: { + children: [ + { + children: [ + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + { + children: [{ group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }], + }, + { + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [ + ...makeIndexRange(2, 1), + ...makeIndexRange(0, 2), + ...makeIndexRange(3, 1), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups nested limit 5", + resultGroups: { + children: [ + { + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + { + children: [ + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [...makeIndexRange(2, 2), ...makeIndexRange(0, 1)], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups nested limit 6", + resultGroups: { + children: [ + { + children: [ + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + { group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }, + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [ + ...makeIndexRange(2, 1), + ...makeIndexRange(0, 2), + ...makeIndexRange(3, 1), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex 1", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(10 * (1 / (1 + 1))) = 5 + ...makeIndexRange(MAX_RESULTS, 5), + // remote suggestions: round(10 * (1 / (1 + 1))) = 5 + ...makeIndexRange(0, 5), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex 2", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(10 * (2 / 3)) = 7 + ...makeIndexRange(MAX_RESULTS, 7), + // remote suggestions: round(10 * (1 / 3)) = 3 + ...makeIndexRange(0, 3), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex 3", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(10 * (1 / 3)) = 3 + ...makeIndexRange(MAX_RESULTS, 3), + // remote suggestions: round(10 * (2 / 3)) = 7 + ...makeIndexRange(0, 7), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex 4", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + ], + }, + providerResults: [ + ...makeFormHistoryResults(MAX_RESULTS), + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(10 * (1 / 3)) = 3, and then incremented to 4 so + // that the total result span is 10 instead of 9. This group is incremented + // because the fractional part of its unrounded ideal max result count is + // 0.33 (since 10 * (1 / 3) = 3.33), the same as the other two groups, and + // this group is first. + ...makeIndexRange(2 * MAX_RESULTS, 4), + // remote suggestions: round(10 * (1 / 3)) = 3 + ...makeIndexRange(MAX_RESULTS, 3), + // form history: round(10 * (1 / 3)) = 3 + // The first three form history results dupe the three remote suggestions, + // so they should not be included. + ...makeIndexRange(3, 3), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex 5", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + ], + }, + providerResults: [ + ...makeFormHistoryResults(MAX_RESULTS), + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(10 * (2 / 4)) = 5 + ...makeIndexRange(2 * MAX_RESULTS, 5), + // remote suggestions: round(10 * (1 / 4)) = 3 + ...makeIndexRange(MAX_RESULTS, 3), + // form history: round(10 * (1 / 4)) = 3, but context.maxResults is 10, so 2 + // The first three form history results dupe the three remote suggestions, + // so they should not be included. + ...makeIndexRange(3, 2), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex 6", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + ], + }, + providerResults: [ + ...makeFormHistoryResults(MAX_RESULTS), + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(10 * (1 / 4)) = 3 + ...makeIndexRange(2 * MAX_RESULTS, 3), + // remote suggestions: round(10 * (2 / 4)) = 5 + ...makeIndexRange(MAX_RESULTS, 5), + // form history: round(10 * (1 / 4)) = 3, but context.maxResults is 10, so 2 + // The first five form history results dupe the five remote suggestions, so + // they should not be included. + ...makeIndexRange(5, 2), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex 7", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + ], + }, + providerResults: [ + ...makeFormHistoryResults(MAX_RESULTS), + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(10 * (1 / 4)) = 3 + ...makeIndexRange(2 * MAX_RESULTS, 3), + // remote suggestions: round(10 * (1 / 4)) = 3, and then decremented to 2 so + // that the total result span is 10 instead of 11. This group is decremented + // because the fractional part of its unrounded ideal max result count is + // 0.5 (since 10 * (1 / 4) = 2.5), the same as the previous group, and the + // next group's fractional part is zero. + ...makeIndexRange(MAX_RESULTS, 2), + // form history: round(10 * (2 / 4)) = 5 + // The first 2 form history results dupe the three remote suggestions, so + // they should not be included. + ...makeIndexRange(2, 5), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex overfill 1", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + ], + }, + providerResults: [ + ...makeFormHistoryResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(10 * (2 / (2 + 0 + 1))) = 7 + ...makeIndexRange(MAX_RESULTS, 7), + // form history: round(10 * (1 / (2 + 0 + 1))) = 3 + ...makeIndexRange(0, 3), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex overfill 2", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + ], + }, + providerResults: [ + ...makeFormHistoryResults(MAX_RESULTS), + ...makeRemoteSuggestionResults(1), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(9 * (2 / (2 + 0 + 1))) = 6 + ...makeIndexRange(MAX_RESULTS + 1, 6), + // remote suggestions + ...makeIndexRange(MAX_RESULTS, 1), + // form history: round(9 * (1 / (2 + 0 + 1))) = 3 + // The first form history result dupes the remote suggestion, so it should + // not be included. + ...makeIndexRange(1, 3), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex nested limit 1", + resultGroups: { + children: [ + { + limit: 5, + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(5 * (2 / (2 + 1))) = 3 + ...makeIndexRange(MAX_RESULTS, 3), + // remote suggestions: round(5 * (1 / (2 + 1))) = 2 + ...makeIndexRange(0, 2), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex nested limit 2", + resultGroups: { + children: [ + { + limit: 7, + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general: round(7 * (1 / (2 + 1))) = 2 + ...makeIndexRange(MAX_RESULTS, 2), + // remote suggestions: round(7 * (2 / (2 + 1))) = 5 + ...makeIndexRange(0, 5), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex nested limit 3", + resultGroups: { + children: [ + { + limit: 7, + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + limit: 3, + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeFormHistoryResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general: round(7 * (1 / (2 + 1))) = 2 + ...makeIndexRange(2 * MAX_RESULTS, 2), + // remote suggestions: round(7 * (2 / (2 + 1))) = 5 + ...makeIndexRange(0, 5), + // form history: round(3 * (2 / (2 + 1))) = 2 + // The first five form history results dupe the five remote suggestions, so + // they should not be included. + ...makeIndexRange(MAX_RESULTS + 5, 2), + // general: round(3 * (1 / (2 + 1))) = 1 + ...makeIndexRange(2 * MAX_RESULTS + 2, 1), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex nested limit 4", + resultGroups: { + children: [ + { + limit: 7, + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + limit: 3, + children: [ + { group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY }, + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + ], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeFormHistoryResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general: round(7 * (1 / (2 + 1))) = 2 + ...makeIndexRange(2 * MAX_RESULTS, 2), + // remote suggestions: round(7 * (2 / (2 + 1))) = 5 + ...makeIndexRange(0, 5), + // form history: round(3 * (2 / (2 + 1))) = 2 + // The first five form history results dupe the five remote suggestions, so + // they should not be included. + ...makeIndexRange(MAX_RESULTS + 5, 3), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex nested limit 5", + resultGroups: { + children: [ + { + limit: 7, + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + limit: 3, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeFormHistoryResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general: round(7 * (1 / (2 + 1))) = 2 + ...makeIndexRange(2 * MAX_RESULTS, 2), + // remote suggestions: round(7 * (2 / (2 + 1))) = 5 + ...makeIndexRange(0, 5), + // form history: round(3 * (2 / (2 + 1))) = 2 + // The first five form history results dupe the five remote suggestions, so + // they should not be included. + ...makeIndexRange(MAX_RESULTS + 5, 3), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex nested", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 2, + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + flex: 1, + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeFormHistoryResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // outer 1: general & remote suggestions: round(10 * (2 / (2 + 1))) = 7 + // inner 1: general: round(7 * (1 / (2 + 1))) = 2 + ...makeIndexRange(2 * MAX_RESULTS, 2), + // inner 2: remote suggestions: round(7 * (2 / (2 + 1))) = 5 + ...makeIndexRange(0, 5), + + // outer 2: form history & general: round(10 * (1 / (2 + 1))) = 3 + // inner 1: form history: round(3 * (2 / (2 + 1))) = 2 + // The first five form history results dupe the five remote suggestions, so + // they should not be included. + ...makeIndexRange(MAX_RESULTS + 5, 2), + // inner 2: general: round(3 * (1 / (2 + 1))) = 1 + ...makeIndexRange(2 * MAX_RESULTS + 2, 1), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex nested overfill 1", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 2, + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + flex: 1, + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeFormHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // outer 1: general & remote suggestions: round(10 * (2 / (2 + 1))) = 7 + // inner 1: general: no results + // inner 2: remote suggestions: round(7 * (2 / (2 + 0))) = 7 + ...makeIndexRange(0, 7), + + // outer 2: form history & general: round(10 * (1 / (2 + 1))) = 3 + // inner 1: form history: round(3 * (2 / (2 + 0))) = 3 + // The first seven form history results dupe the seven remote suggestions, + // so they should not be included. + ...makeIndexRange(MAX_RESULTS + 7, 3), + // inner 2: general: no results + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex nested overfill 2", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 2, + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + flex: 1, + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + ], + }, + providerResults: [...makeFormHistoryResults(MAX_RESULTS)], + expectedResultIndexes: [ + // outer 1: general & remote suggestions: round(10 * (2 / (2 + 1))) = 7 + // inner 1: general: no results + // inner 2: remote suggestions: no results + + // outer 2: form history & general: round(10 * (1 / (0 + 1))) = 10 + // inner 1: form history: round(10 * (2 / (2 + 0))) = 10 + ...makeIndexRange(0, MAX_RESULTS), + // inner 2: general: no results + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex nested overfill 3", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 2, + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + flex: 1, + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + ], + }, + providerResults: [...makeRemoteSuggestionResults(MAX_RESULTS)], + expectedResultIndexes: [ + // outer 1: general & remote suggestions: round(10 * (2 / (2 + 0))) = 10 + // inner 1: general: no results + // inner 2: remote suggestions: round(10 * (2 / (2 + 0))) = 10 + ...makeIndexRange(0, MAX_RESULTS), + + // outer 2: form history & general: round(10 * (1 / (2 + 1))) = 3 + // inner 1: form history: no results + // inner 2: general: no results + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "limit ignored with flex", + resultGroups: { + flexChildren: true, + children: [ + { + limit: 1, + flex: 2, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(10 * (2 / (2 + 1))) = 7 -- limit ignored + ...makeIndexRange(MAX_RESULTS, 7), + // remote suggestions: round(10 * (1 / (2 + 1))) = 3 + ...makeIndexRange(0, 3), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "resultSpan = 3 followed by others", + resultGroups: { + children: [ + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + providerResults: [ + // max results remote suggestions + ...makeRemoteSuggestionResults(MAX_RESULTS), + // 1 history with resultSpan = 3 + Object.assign(makeHistoryResults(1)[0], { resultSpan: 3 }), + ], + expectedResultIndexes: [ + // general/history: 1 + ...makeIndexRange(MAX_RESULTS, 1), + // remote suggestions: maxResults - resultSpan of 3 = 10 - 3 = 7 + ...makeIndexRange(0, 7), + ], +}); + +add_resultGroups_task({ + testName: "maxResultCount: 1, availableSpan: 3", + resultGroups: { + children: [ + { + maxResultCount: 1, + availableSpan: 3, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + providerResults: [...makeHistoryResults(MAX_RESULTS)], + expectedResultIndexes: [0], +}); + +add_resultGroups_task({ + testName: "maxResultCount: 1, availableSpan: 3, resultSpan = 3", + resultGroups: { + children: [ + { + maxResultCount: 1, + availableSpan: 3, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + providerResults: [ + Object.assign(makeHistoryResults(1)[0], { resultSpan: 3 }), + Object.assign(makeHistoryResults(1)[0], { resultSpan: 3 }), + Object.assign(makeHistoryResults(1)[0], { resultSpan: 3 }), + ], + expectedResultIndexes: [0], +}); + +add_resultGroups_task({ + testName: "maxResultCount: 3, availableSpan: 1", + resultGroups: { + children: [ + { + maxResultCount: 3, + availableSpan: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + providerResults: [...makeHistoryResults(MAX_RESULTS)], + expectedResultIndexes: [0], +}); + +add_resultGroups_task({ + testName: "maxResultCount: 3, availableSpan: 1, resultSpan = 3", + resultGroups: { + children: [ + { + maxResultCount: 3, + availableSpan: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + providerResults: [Object.assign(makeHistoryResults(1)[0], { resultSpan: 3 })], + expectedResultIndexes: [], +}); + +add_resultGroups_task({ + testName: "availableSpan: 1", + resultGroups: { + children: [ + { + availableSpan: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + providerResults: [...makeHistoryResults(MAX_RESULTS)], + expectedResultIndexes: [0], +}); + +add_resultGroups_task({ + testName: "availableSpan: 1, resultSpan = 3", + resultGroups: { + children: [ + { + availableSpan: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + providerResults: [Object.assign(makeHistoryResults(1)[0], { resultSpan: 3 })], + expectedResultIndexes: [], +}); + +add_resultGroups_task({ + testName: "availableSpan: 3, resultSpan = 2 and resultSpan = 1", + resultGroups: { + children: [ + { + availableSpan: 3, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + providerResults: [ + makeHistoryResults(1)[0], + Object.assign(makeHistoryResults(1)[0], { resultSpan: 2 }), + makeHistoryResults(1)[0], + ], + expectedResultIndexes: [0, 1], +}); + +add_resultGroups_task({ + testName: "availableSpan: 3, resultSpan = 1 and resultSpan = 2", + resultGroups: { + children: [ + { + availableSpan: 3, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + providerResults: [ + Object.assign(makeHistoryResults(1)[0], { resultSpan: 2 }), + makeHistoryResults(1)[0], + makeHistoryResults(1)[0], + ], + expectedResultIndexes: [0, 1], +}); + +/** + * Adds a single test task. + * + * @param {object} options + * The options for the test + * @param {string} options.testName + * This name is logged with `info` as the task starts. + * @param {object} options.resultGroups + * browser.urlbar.resultGroups is set to this value as the task starts. + * @param {Array} options.providerResults + * Array of result objects that the test provider will add. + * @param {Array} options.expectedResultIndexes + * Array of indexes in `providerResults` of the expected final results. + */ +function add_resultGroups_task({ + testName, + resultGroups, + providerResults, + expectedResultIndexes, +}) { + let func = async () => { + info(`Running resultGroups test: ${testName}`); + info(`Setting result groups: ` + JSON.stringify(resultGroups)); + setResultGroups(resultGroups); + let provider = registerBasicTestProvider(providerResults); + let context = createContext("foo", { providers: [provider.name] }); + let controller = UrlbarTestUtils.newMockController(); + await UrlbarProvidersManager.startQuery(context, controller); + UrlbarProvidersManager.unregisterProvider(provider); + let expectedResults = expectedResultIndexes.map(i => providerResults[i]); + Assert.deepEqual(context.results, expectedResults); + setResultGroups(null); + }; + Object.defineProperty(func, "name", { value: testName }); + add_task(func); +} + +/** + * Adds test tasks for each of the keys in `LIMIT_KEYS`. + * + * @param {object} options + * The options for the test + * @param {string} options.testName + * The name of the test. + * @param {object} options.resultGroups + * The resultGroups object to set. + * @param {Array} options.providerResults + * The results to return from the test + * @param {Array} options.expectedResultIndexes + * Indexes of the expected results within {@link providerResults} + */ +function add_resultGroupsLimit_tasks({ + testName, + resultGroups, + providerResults, + expectedResultIndexes, +}) { + for (let key of LIMIT_KEYS) { + add_resultGroups_task({ + testName: `${testName} (limit: ${key})`, + resultGroups: replaceLimitWithKey(resultGroups, key), + providerResults, + expectedResultIndexes, + }); + } +} + +function replaceLimitWithKey(group, key) { + group = { ...group }; + if ("limit" in group) { + group[key] = group.limit; + delete group.limit; + } + for (let i = 0; i < group.children?.length; i++) { + group.children[i] = replaceLimitWithKey(group.children[i], key); + } + return group; +} + +function makeHistoryResults(count) { + let results = []; + for (let i = 0; i < count; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://example.com/" + i } + ) + ); + } + return results; +} + +function makeRemoteSuggestionResults(count) { + let results = []; + for (let i = 0; i < count; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + engine: "test", + query: "test", + suggestion: "test " + i, + lowerCaseSuggestion: "test " + i, + } + ) + ); + } + return results; +} + +function makeFormHistoryResults(count) { + let results = []; + for (let i = 0; i < count; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { + engine: "test", + suggestion: "test " + i, + lowerCaseSuggestion: "test " + i, + } + ) + ); + } + return results; +} + +function makeIndexRange(startIndex, count) { + let indexes = []; + for (let i = startIndex; i < startIndex + count; i++) { + indexes.push(i); + } + return indexes; +} + +function setResultGroups(resultGroups) { + sandbox.restore(); + if (resultGroups) { + sandbox.stub(UrlbarPrefs, "resultGroups").get(() => resultGroups); + } +} diff --git a/browser/components/urlbar/tests/unit/test_richsuggestions.js b/browser/components/urlbar/tests/unit/test_richsuggestions.js new file mode 100644 index 0000000000..b6ceaa6db5 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_richsuggestions.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that rich suggestion results results are shown without + * rich data if richSuggestions are disabled. + */ + +const SUGGEST_ENABLED_PREF = "browser.search.suggest.enabled"; +const RICH_SUGGESTIONS_PREF = "browser.urlbar.richSuggestions.featureGate"; +const QUICKACTIONS_URLBAR_PREF = "quickactions.enabled"; + +add_setup(async function () { + let engine = await addTestTailSuggestionsEngine(defaultRichSuggestionsFn); + // Install the test engine. + let oldDefaultEngine = await Services.search.getDefault(); + registerCleanupFunction(async () => { + Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + Services.prefs.clearUserPref(RICH_SUGGESTIONS_PREF); + Services.prefs.clearUserPref(SUGGEST_ENABLED_PREF); + UrlbarPrefs.clear(QUICKACTIONS_URLBAR_PREF); + }); + Services.search.setDefault(engine, Ci.nsISearchService.CHANGE_REASON_UNKNOWN); + Services.prefs.setBoolPref(RICH_SUGGESTIONS_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + UrlbarPrefs.set(QUICKACTIONS_URLBAR_PREF, false); +}); + +/** + * Test that suggestions with rich data are still shown + */ +add_task(async function test_richsuggestions_disabled() { + Services.prefs.setBoolPref(RICH_SUGGESTIONS_PREF, false); + + const query = "what time is it in t"; + let context = createContext(query, { isPrivate: false }); + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: query + "oronto", + }), + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: query + "unisia", + }), + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: query + "acoma", + }), + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: query + "aipei", + }), + ], + }); +}); diff --git a/browser/components/urlbar/tests/unit/test_richsuggestions_order.js b/browser/components/urlbar/tests/unit/test_richsuggestions_order.js new file mode 100644 index 0000000000..7e918b4e5e --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_richsuggestions_order.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that rich suggestion results are ordered in the + * same order they were returned from the API. + */ + +const SUGGEST_ENABLED_PREF = "browser.search.suggest.enabled"; +const RICH_SUGGESTIONS_PREF = "browser.urlbar.richSuggestions.featureGate"; + +const QUICKACTIONS_URLBAR_PREF = "quickactions.enabled"; + +add_setup(async function () { + let engine = await addTestTailSuggestionsEngine(defaultRichSuggestionsFn); + + // Install the test engine. + let oldDefaultEngine = await Services.search.getDefault(); + registerCleanupFunction(async () => { + Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + Services.prefs.clearUserPref(RICH_SUGGESTIONS_PREF); + Services.prefs.clearUserPref(SUGGEST_ENABLED_PREF); + UrlbarPrefs.clear(QUICKACTIONS_URLBAR_PREF); + }); + Services.search.setDefault(engine, Ci.nsISearchService.CHANGE_REASON_UNKNOWN); + Services.prefs.setBoolPref(RICH_SUGGESTIONS_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + UrlbarPrefs.set(QUICKACTIONS_URLBAR_PREF, false); +}); + +/** + * Tests that non-tail suggestion providers still return results correctly when + * the tailSuggestions pref is enabled. + */ +add_task(async function test_richsuggestions_order() { + const query = "what time is it in t"; + let context = createContext(query, { isPrivate: false }); + + let defaultRichResult = { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + isRichSuggestion: true, + }; + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult( + context, + Object.assign(defaultRichResult, { + suggestion: query + "oronto", + }) + ), + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: query + "unisia", + }), + makeSearchResult( + context, + Object.assign(defaultRichResult, { + suggestion: query + "acoma", + }) + ), + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: query + "aipei", + }), + ], + }); +}); diff --git a/browser/components/urlbar/tests/unit/test_search_engine_restyle.js b/browser/components/urlbar/tests/unit/test_search_engine_restyle.js new file mode 100644 index 0000000000..6c415c1283 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_search_engine_restyle.js @@ -0,0 +1,124 @@ +/* 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/. */ + +testEngine_setup(); + +const engineDomain = "s.example.com"; +add_setup(async function () { + Services.prefs.setBoolPref("browser.urlbar.restyleSearches", true); + await SearchTestUtils.installSearchExtension({ + name: "MozSearch", + search_url: `https://${engineDomain}/search`, + }); + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.restyleSearches"); + }); +}); + +add_task(async function test_searchEngine() { + let uri = Services.io.newURI(`https://${engineDomain}/search?q=Terms`); + await PlacesTestUtils.addVisits({ + uri, + title: "Terms - SearchEngine Search", + }); + + info("Past search terms should be styled."); + let context = createContext("term", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeFormHistoryResult(context, { + engineName: "MozSearch", + suggestion: "Terms", + }), + ], + }); + + info( + "Searching for a superset of the search string in history should not restyle." + ); + context = createContext("Terms Foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + info("Bookmarked past searches should not be restyled"); + await PlacesTestUtils.addBookmarkWithDetails({ + uri, + title: "Terms - SearchEngine Search", + }); + + context = createContext("term", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri.spec, + title: "Terms - SearchEngine Search", + }), + ], + }); + + await PlacesUtils.bookmarks.eraseEverything(); + + info("Past search terms should not be styled if restyling is disabled"); + Services.prefs.setBoolPref("browser.urlbar.restyleSearches", false); + context = createContext("term", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri.spec, + title: "Terms - SearchEngine Search", + }), + ], + }); + Services.prefs.setBoolPref("browser.urlbar.restyleSearches", true); + + await cleanupPlaces(); +}); + +add_task(async function test_extraneousParameters() { + info("SERPs in history with extraneous parameters should not be restyled."); + let uri = Services.io.newURI( + `https://${engineDomain}/search?q=Terms&p=2&type=img` + ); + await PlacesTestUtils.addVisits({ + uri, + title: "Terms - SearchEngine Search", + }); + + let context = createContext("term", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri.spec, + title: "Terms - SearchEngine Search", + }), + ], + }); +}); diff --git a/browser/components/urlbar/tests/unit/test_search_suggestions.js b/browser/components/urlbar/tests/unit/test_search_suggestions.js new file mode 100644 index 0000000000..dc7185149f --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_search_suggestions.js @@ -0,0 +1,2077 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that search engine suggestions are returned by + * UrlbarProviderSearchSuggestions. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +const SUGGEST_PREF = "browser.urlbar.suggest.searches"; +const SUGGEST_ENABLED_PREF = "browser.search.suggest.enabled"; +const PRIVATE_ENABLED_PREF = "browser.search.suggest.enabled.private"; +const PRIVATE_SEARCH_PREF = "browser.search.separatePrivateDefault.ui.enabled"; +const TAB_TO_SEARCH_PREF = "browser.urlbar.suggest.engines"; +const TRENDING_PREF = "browser.urlbar.trending.featureGate"; +const QUICKACTIONS_PREF = "browser.urlbar.suggest.quickactions"; +const MAX_RICH_RESULTS_PREF = "browser.urlbar.maxRichResults"; +const MAX_FORM_HISTORY_PREF = "browser.urlbar.maxHistoricalSearchSuggestions"; +const SHOW_SEARCH_SUGGESTIONS_FIRST_PREF = + "browser.urlbar.showSearchSuggestionsFirst"; +const SEARCH_STRING = "hello"; + +const MAX_RESULTS = Services.prefs.getIntPref(MAX_RICH_RESULTS_PREF, 10); + +var suggestionsFn; +var previousSuggestionsFn; +let port; +let sandbox; + +/** + * Set the current suggestion funciton. + * + * @param {Function} fn + * A function that that a search string and returns an array of strings that + * will be used as search suggestions. + * Note: `fn` should return > 0 suggestions in most cases. Otherwise, you may + * encounter unexpected behaviour with UrlbarProviderSuggestion's + * _lastLowResultsSearchSuggestion safeguard. + */ +function setSuggestionsFn(fn) { + previousSuggestionsFn = suggestionsFn; + suggestionsFn = fn; +} + +async function cleanup() { + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + Services.prefs.clearUserPref(SUGGEST_PREF); + Services.prefs.clearUserPref(SUGGEST_ENABLED_PREF); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + sandbox.restore(); +} + +async function cleanUpSuggestions() { + await cleanup(); + if (previousSuggestionsFn) { + suggestionsFn = previousSuggestionsFn; + previousSuggestionsFn = null; + } +} + +function makeFormHistoryResults(context, count) { + let results = []; + for (let i = 0; i < count; i++) { + results.push( + makeFormHistoryResult(context, { + suggestion: `${SEARCH_STRING} world Form History ${i}`, + engineName: SUGGESTIONS_ENGINE_NAME, + }) + ); + } + return results; +} + +function makeRemoteSuggestionResults( + context, + { suggestionPrefix = SEARCH_STRING, query = undefined } = {} +) { + // The suggestions function in `setup` returns: + // [searchString, searchString + "foo", searchString + "bar"] + // But when the heuristic is a search result, the muxer discards suggestion + // results that match the search string, and therefore we expect only two + // remote suggestion results, the "foo" and "bar" ones. + return [ + makeSearchResult(context, { + query, + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: suggestionPrefix + " foo", + }), + makeSearchResult(context, { + query, + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: suggestionPrefix + " bar", + }), + ]; +} + +function setResultGroups(groups) { + sandbox.restore(); + sandbox.stub(UrlbarPrefs, "resultGroups").get(() => { + return { + children: [ + // heuristic + { + maxResultCount: 1, + children: [ + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_EXTENSION }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_SEARCH_TIP }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_OMNIBOX }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_AUTOFILL }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TOKEN_ALIAS_ENGINE }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK }, + ], + }, + // extensions using the omnibox API + { + group: UrlbarUtils.RESULT_GROUP.OMNIBOX, + }, + ...groups, + ], + }; + }); +} + +add_setup(async function () { + sandbox = lazy.sinon.createSandbox(); + + let engine = await addTestSuggestionsEngine(searchStr => { + return suggestionsFn(searchStr); + }); + port = engine.getSubmission("").uri.port; + + setSuggestionsFn(searchStr => { + let suffixes = ["foo", "bar"]; + return [searchStr].concat(suffixes.map(s => searchStr + " " + s)); + }); + + // Install the test engine. + let oldDefaultEngine = await Services.search.getDefault(); + registerCleanupFunction(async () => { + Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + Services.prefs.clearUserPref(PRIVATE_SEARCH_PREF); + Services.prefs.clearUserPref(TRENDING_PREF); + Services.prefs.clearUserPref(QUICKACTIONS_PREF); + Services.prefs.clearUserPref(TAB_TO_SEARCH_PREF); + sandbox.restore(); + }); + Services.search.setDefault(engine, Ci.nsISearchService.CHANGE_REASON_UNKNOWN); + Services.prefs.setBoolPref(PRIVATE_SEARCH_PREF, false); + Services.prefs.setBoolPref(TRENDING_PREF, false); + Services.prefs.setBoolPref(QUICKACTIONS_PREF, false); + // Tab-to-search engines can introduce unexpected results, espescially because + // they depend on real en-US engines. + Services.prefs.setBoolPref(TAB_TO_SEARCH_PREF, false); + + // Add MAX_RESULTS form history. + let context = createContext(SEARCH_STRING, { isPrivate: false }); + let entries = makeFormHistoryResults(context, MAX_RESULTS).map(r => ({ + value: r.payload.suggestion, + source: SUGGESTIONS_ENGINE_NAME, + })); + await UrlbarTestUtils.formHistory.add(entries); +}); + +add_task(async function disabled_urlbarSuggestions() { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + let context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + await cleanUpSuggestions(); +}); + +add_task(async function disabled_allSuggestions() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, false); + let context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + await cleanUpSuggestions(); +}); + +add_task(async function disabled_privateWindow() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + Services.prefs.setBoolPref(PRIVATE_ENABLED_PREF, false); + let context = createContext(SEARCH_STRING, { isPrivate: true }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + await cleanUpSuggestions(); +}); + +add_task(async function disabled_urlbarSuggestions_withRestrictionToken() { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + let context = createContext( + `${UrlbarTokenizer.RESTRICT.SEARCH} ${SEARCH_STRING}`, + { isPrivate: false } + ); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + query: SEARCH_STRING, + alias: UrlbarTokenizer.RESTRICT.SEARCH, + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context, { + query: SEARCH_STRING, + }), + ], + }); + await cleanUpSuggestions(); +}); + +add_task( + async function disabled_urlbarSuggestions_withRestrictionToken_private() { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + Services.prefs.setBoolPref(PRIVATE_ENABLED_PREF, false); + let context = createContext( + `${UrlbarTokenizer.RESTRICT.SEARCH} ${SEARCH_STRING}`, + { isPrivate: true } + ); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + query: SEARCH_STRING, + alias: UrlbarTokenizer.RESTRICT.SEARCH, + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + await cleanUpSuggestions(); + } +); + +add_task( + async function disabled_urlbarSuggestions_withRestrictionToken_private_enabled() { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + Services.prefs.setBoolPref(PRIVATE_ENABLED_PREF, true); + let context = createContext( + `${UrlbarTokenizer.RESTRICT.SEARCH} ${SEARCH_STRING}`, + { isPrivate: true } + ); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + query: SEARCH_STRING, + alias: UrlbarTokenizer.RESTRICT.SEARCH, + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context, { + query: SEARCH_STRING, + }), + ], + }); + await cleanUpSuggestions(); + } +); + +add_task(async function enabled_by_pref_privateWindow() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + Services.prefs.setBoolPref(PRIVATE_ENABLED_PREF, true); + let context = createContext(SEARCH_STRING, { isPrivate: true }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context), + ], + }); + await cleanUpSuggestions(); + + Services.prefs.clearUserPref(PRIVATE_ENABLED_PREF); +}); + +add_task(async function singleWordQuery() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + let context = createContext(SEARCH_STRING, { isPrivate: false }); + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context), + ], + }); + + await cleanUpSuggestions(); +}); + +add_task(async function multiWordQuery() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + const query = `${SEARCH_STRING} world`; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: query, + }), + ], + }); + + await cleanUpSuggestions(); +}); + +add_task(async function suffixMatch() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + + setSuggestionsFn(searchStr => { + let prefixes = ["baz", "quux"]; + return prefixes.map(p => p + " " + searchStr); + }); + + let context = createContext(SEARCH_STRING, { isPrivate: false }); + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "baz " + SEARCH_STRING, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "quux " + SEARCH_STRING, + }), + ], + }); + + await cleanUpSuggestions(); +}); + +add_task(async function remoteSuggestionsDupeSearchString() { + Services.prefs.setIntPref(MAX_FORM_HISTORY_PREF, 0); + + // Return remote suggestions with the trimmed search string, the uppercased + // search string, and the search string with a trailing space, plus the usual + // "foo" and "bar" suggestions. + setSuggestionsFn(searchStr => { + let suffixes = ["foo", "bar"]; + return [searchStr.trim(), searchStr.toUpperCase(), searchStr + " "].concat( + suffixes.map(s => searchStr + " " + s) + ); + }); + + // Do a search with a trailing space. All the variations of the search string + // with regard to spaces and case should be discarded from the remote + // suggestions, leaving only the usual "foo" and "bar" suggestions. + let query = SEARCH_STRING + " "; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + query, + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeRemoteSuggestionResults(context), + ], + }); + + await cleanUpSuggestions(); + Services.prefs.clearUserPref(MAX_FORM_HISTORY_PREF); +}); + +add_task(async function queryIsNotASubstring() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + + setSuggestionsFn(searchStr => { + return ["aaa", "bbb"]; + }); + + let context = createContext(SEARCH_STRING, { isPrivate: false }); + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "aaa", + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "bbb", + }), + ], + }); + + await cleanUpSuggestions(); +}); + +add_task(async function restrictToken() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + + // Add a visit and a bookmark. Actually, make the bookmark visited too so + // that it's guaranteed, with its higher frecency, to appear above the search + // suggestions. + await PlacesTestUtils.addVisits([ + { + uri: Services.io.newURI(`http://example.com/${SEARCH_STRING}-visit`), + title: `${SEARCH_STRING} visit`, + }, + { + uri: Services.io.newURI(`http://example.com/${SEARCH_STRING}-bookmark`), + title: `${SEARCH_STRING} bookmark`, + }, + ]); + + await PlacesTestUtils.addBookmarkWithDetails({ + uri: Services.io.newURI(`http://example.com/${SEARCH_STRING}-bookmark`), + title: `${SEARCH_STRING} bookmark`, + }); + + let context = createContext(SEARCH_STRING, { isPrivate: false }); + + // Do an unrestricted search to make sure everything appears in it, including + // the visit and bookmark. + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 5), + ...makeRemoteSuggestionResults(context), + makeBookmarkResult(context, { + uri: `http://example.com/${SEARCH_STRING}-bookmark`, + title: `${SEARCH_STRING} bookmark`, + }), + makeVisitResult(context, { + uri: `http://example.com/${SEARCH_STRING}-visit`, + title: `${SEARCH_STRING} visit`, + }), + ], + }); + + // Now do a restricted search to make sure only suggestions appear. + context = createContext( + `${UrlbarTokenizer.RESTRICT.SEARCH} ${SEARCH_STRING}`, + { + isPrivate: false, + } + ); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias: UrlbarTokenizer.RESTRICT.SEARCH, + query: SEARCH_STRING, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: SEARCH_STRING, + query: SEARCH_STRING, + }), + ], + }); + + // Typing the search restriction char shows the Search Engine entry and local + // results. + context = createContext(UrlbarTokenizer.RESTRICT.SEARCH, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + query: "", + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 1), + ], + }); + + // Also if followed by multiple spaces. + context = createContext(`${UrlbarTokenizer.RESTRICT.SEARCH} `, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias: UrlbarTokenizer.RESTRICT.SEARCH, + query: "", + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 1), + ], + }); + + // If followed by any char we should fetch suggestions. + // Note this uses "h" to match form history. + context = createContext(`${UrlbarTokenizer.RESTRICT.SEARCH}h`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + query: "h", + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: "h", + query: "h", + }), + ], + }); + + // Also if followed by a space and single char. + context = createContext(`${UrlbarTokenizer.RESTRICT.SEARCH} h`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias: UrlbarTokenizer.RESTRICT.SEARCH, + query: "h", + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: "h", + query: "h", + }), + ], + }); + + // Leading search-mode restriction tokens are removed. + context = createContext( + `${UrlbarTokenizer.RESTRICT.BOOKMARK} ${SEARCH_STRING}`, + { isPrivate: false } + ); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + query: SEARCH_STRING, + alias: UrlbarTokenizer.RESTRICT.BOOKMARK, + }), + makeBookmarkResult(context, { + uri: `http://example.com/${SEARCH_STRING}-bookmark`, + title: `${SEARCH_STRING} bookmark`, + }), + ], + }); + + // Non-search-mode restriction tokens remain in the query and heuristic search + // result. + let token; + for (let t of Object.values(UrlbarTokenizer.RESTRICT)) { + if (!UrlbarTokenizer.SEARCH_MODE_RESTRICT.has(t)) { + token = t; + break; + } + } + Assert.ok( + token, + "Non-search-mode restrict token exists -- if not, you can probably remove me!" + ); + context = createContext(token, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + await cleanUpSuggestions(); +}); + +add_task(async function mixup_frecency() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + + // At most, we should have 22 results in this subtest. We set this to 30 to + // make we're not cutting off any results and we are actually getting 22. + Services.prefs.setIntPref(MAX_RICH_RESULTS_PREF, 30); + + // Add a visit and a bookmark. Actually, make the bookmark visited too so + // that it's guaranteed, with its higher frecency, to appear above the search + // suggestions. + await PlacesTestUtils.addVisits([ + { + uri: Services.io.newURI("http://example.com/lo0"), + title: `${SEARCH_STRING} low frecency 0`, + }, + { + uri: Services.io.newURI("http://example.com/lo1"), + title: `${SEARCH_STRING} low frecency 1`, + }, + { + uri: Services.io.newURI("http://example.com/lo2"), + title: `${SEARCH_STRING} low frecency 2`, + }, + { + uri: Services.io.newURI("http://example.com/lo3"), + title: `${SEARCH_STRING} low frecency 3`, + }, + { + uri: Services.io.newURI("http://example.com/lo4"), + title: `${SEARCH_STRING} low frecency 4`, + }, + ]); + + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([ + { + uri: Services.io.newURI("http://example.com/hi0"), + title: `${SEARCH_STRING} high frecency 0`, + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + { + uri: Services.io.newURI("http://example.com/hi1"), + title: `${SEARCH_STRING} high frecency 1`, + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + { + uri: Services.io.newURI("http://example.com/hi2"), + title: `${SEARCH_STRING} high frecency 2`, + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + { + uri: Services.io.newURI("http://example.com/hi3"), + title: `${SEARCH_STRING} high frecency 3`, + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + ]); + } + + for (let i = 0; i < 4; i++) { + let href = `http://example.com/hi${i}`; + await PlacesTestUtils.addBookmarkWithDetails({ + uri: href, + title: `${SEARCH_STRING} high frecency ${i}`, + }); + } + + // Do an unrestricted search to make sure everything appears in it, including + // the visit and bookmark. + let context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS), + ...makeRemoteSuggestionResults(context), + makeBookmarkResult(context, { + uri: "http://example.com/hi3", + title: `${SEARCH_STRING} high frecency 3`, + }), + makeBookmarkResult(context, { + uri: "http://example.com/hi2", + title: `${SEARCH_STRING} high frecency 2`, + }), + makeBookmarkResult(context, { + uri: "http://example.com/hi1", + title: `${SEARCH_STRING} high frecency 1`, + }), + makeBookmarkResult(context, { + uri: "http://example.com/hi0", + title: `${SEARCH_STRING} high frecency 0`, + }), + makeVisitResult(context, { + uri: "http://example.com/lo4", + title: `${SEARCH_STRING} low frecency 4`, + }), + makeVisitResult(context, { + uri: "http://example.com/lo3", + title: `${SEARCH_STRING} low frecency 3`, + }), + makeVisitResult(context, { + uri: "http://example.com/lo2", + title: `${SEARCH_STRING} low frecency 2`, + }), + makeVisitResult(context, { + uri: "http://example.com/lo1", + title: `${SEARCH_STRING} low frecency 1`, + }), + makeVisitResult(context, { + uri: "http://example.com/lo0", + title: `${SEARCH_STRING} low frecency 0`, + }), + ], + }); + + // Change the mixup. + setResultGroups([ + // 1 suggestion + { + maxResultCount: 1, + children: [ + { group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY }, + { group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }, + ], + }, + // 5 general + { + maxResultCount: 5, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + // 1 suggestion + { + maxResultCount: 1, + children: [ + { group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY }, + { group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }, + ], + }, + // remaining general + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + // remaining suggestions + { group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY }, + { group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }, + ]); + + // Do an unrestricted search to make sure everything appears in it, including + // the visits and bookmarks. + context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, 1), + makeBookmarkResult(context, { + uri: "http://example.com/hi3", + title: `${SEARCH_STRING} high frecency 3`, + }), + makeBookmarkResult(context, { + uri: "http://example.com/hi2", + title: `${SEARCH_STRING} high frecency 2`, + }), + makeBookmarkResult(context, { + uri: "http://example.com/hi1", + title: `${SEARCH_STRING} high frecency 1`, + }), + makeBookmarkResult(context, { + uri: "http://example.com/hi0", + title: `${SEARCH_STRING} high frecency 0`, + }), + makeVisitResult(context, { + uri: "http://example.com/lo4", + title: `${SEARCH_STRING} low frecency 4`, + }), + ...makeFormHistoryResults(context, 2).slice(1), + makeVisitResult(context, { + uri: "http://example.com/lo3", + title: `${SEARCH_STRING} low frecency 3`, + }), + makeVisitResult(context, { + uri: "http://example.com/lo2", + title: `${SEARCH_STRING} low frecency 2`, + }), + makeVisitResult(context, { + uri: "http://example.com/lo1", + title: `${SEARCH_STRING} low frecency 1`, + }), + makeVisitResult(context, { + uri: "http://example.com/lo0", + title: `${SEARCH_STRING} low frecency 0`, + }), + ...makeFormHistoryResults(context, MAX_RESULTS).slice(2), + ...makeRemoteSuggestionResults(context), + ], + }); + + Services.prefs.clearUserPref(MAX_RICH_RESULTS_PREF); + await cleanUpSuggestions(); +}); + +add_task(async function prohibit_suggestions() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref( + `browser.fixup.domainwhitelist.${SEARCH_STRING}`, + false + ); + + let context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context), + ], + }); + + Services.prefs.setBoolPref( + `browser.fixup.domainwhitelist.${SEARCH_STRING}`, + true + ); + registerCleanupFunction(() => { + Services.prefs.setBoolPref( + `browser.fixup.domainwhitelist.${SEARCH_STRING}`, + false + ); + }); + context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${SEARCH_STRING}/`, + fallbackTitle: `http://${SEARCH_STRING}/`, + iconUri: "", + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 2), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: false, + }), + ], + }); + + // When using multiple words, we should still get suggestions: + let query = `${SEARCH_STRING} world`; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context, { suggestionPrefix: query }), + ], + }); + + // Clear the whitelist for SEARCH_STRING and try preferring DNS for any single + // word instead: + Services.prefs.setBoolPref( + `browser.fixup.domainwhitelist.${SEARCH_STRING}`, + false + ); + Services.prefs.setBoolPref("browser.fixup.dns_first_for_single_words", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.fixup.dns_first_for_single_words"); + }); + + context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${SEARCH_STRING}/`, + fallbackTitle: `http://${SEARCH_STRING}/`, + iconUri: "", + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 2), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: false, + }), + ], + }); + + context = createContext("somethingelse", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://somethingelse/", + fallbackTitle: "http://somethingelse/", + iconUri: "", + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: false, + }), + ], + }); + + // When using multiple words, we should still get suggestions: + query = `${SEARCH_STRING} world`; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context, { suggestionPrefix: query }), + ], + }); + + Services.prefs.clearUserPref("browser.fixup.dns_first_for_single_words"); + + context = createContext("http://1.2.3.4/", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://1.2.3.4/", + fallbackTitle: "http://1.2.3.4/", + iconUri: "page-icon:http://1.2.3.4/", + heuristic: true, + }), + ], + }); + + context = createContext("[2001::1]:30", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://[2001::1]:30/", + fallbackTitle: "http://[2001::1]:30/", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("user:pass@test", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://user:pass@test/", + fallbackTitle: "http://user:pass@test/", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("data:text/plain,Content", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "data:text/plain,Content", + fallbackTitle: "data:text/plain,Content", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("a", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + await cleanUpSuggestions(); +}); + +add_task(async function uri_like_queries() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + + // We should not fetch any suggestions for an actual URL. + let query = "mozilla.org"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + fallbackTitle: `http://${query}/`, + uri: `http://${query}/`, + iconUri: "", + heuristic: true, + }), + makeSearchResult(context, { query, engineName: SUGGESTIONS_ENGINE_NAME }), + ], + }); + + // We should also not fetch suggestions for a partially-typed URL. + query = "mozilla.o"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + // Now trying queries that could be confused for URLs. They should return + // results. + const uriLikeQueries = [ + "mozilla.org is a great website", + "I like mozilla.org", + "a/b testing", + "he/him", + "Google vs.", + "5.8 cm", + ]; + for (query of uriLikeQueries) { + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: query, + }), + ], + }); + } + + await cleanUpSuggestions(); +}); + +add_task(async function avoid_remote_url_suggestions_1() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + setSuggestionsFn(searchStr => { + let suffixes = [".com", "/test", ":1]", "@test", ". com"]; + return suffixes.map(s => searchStr + s); + }); + + const query = "test"; + + await UrlbarTestUtils.formHistory.add([`${query}.com`]); + + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeFormHistoryResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: `${query}.com`, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: `${query}. com`, + }), + ], + }); + + await cleanUpSuggestions(); + await UrlbarTestUtils.formHistory.remove([`${query}.com`]); +}); + +add_task(async function avoid_remote_url_suggestions_2() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + + setSuggestionsFn(searchStr => { + let suffixes = ["ed", "eds"]; + return suffixes.map(s => searchStr + s); + }); + + let context = createContext("htt", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "htted", + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "htteds", + }), + ], + }); + + context = createContext("ftp", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "ftped", + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "ftpeds", + }), + ], + }); + + context = createContext("http", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "httped", + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "httpeds", + }), + ], + }); + + context = createContext("http:", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("https", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "httpsed", + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "httpseds", + }), + ], + }); + + context = createContext("https:", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("httpd", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "httpded", + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "httpdeds", + }), + ], + }); + + // Check FTP disabled + context = createContext("ftp:", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("ftp:/", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("ftp://", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("ftp://test", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "ftp://test/", + fallbackTitle: "ftp://test/", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("http:/", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("https:/", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("http://", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("https://", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("http://www", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://www/", + fallbackTitle: "http://www/", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("https://www", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "https://www/", + fallbackTitle: "https://www/", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("http://test", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://test/", + fallbackTitle: "http://test/", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("https://test", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "https://test/", + fallbackTitle: "https://test/", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("http://www.test", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://www.test/", + fallbackTitle: "http://www.test/", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("http://www.test.com", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://www.test.com/", + fallbackTitle: "http://www.test.com/", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("file", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "fileed", + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "fileeds", + }), + ], + }); + + context = createContext("file:", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("file:///Users", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "file:///Users", + fallbackTitle: "file:///Users", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("moz-test://", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("moz+test://", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("about", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "abouted", + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "abouteds", + }), + ], + }); + + await cleanUpSuggestions(); +}); + +add_task(async function restrict_remote_suggestions_after_no_results() { + // We don't fetch remote suggestions if a query with a length over + // maxCharsForSearchSuggestions returns 0 results. We set it to 4 here to + // avoid constructing a 100+ character string. + Services.prefs.setIntPref("browser.urlbar.maxCharsForSearchSuggestions", 4); + setSuggestionsFn(searchStr => { + return []; + }); + + const query = SEARCH_STRING.substring(0, SEARCH_STRING.length - 1); + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 1), + ], + }); + + context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 1), + // Because the previous search returned no suggestions, we will not fetch + // remote suggestions for this query that is just a longer version of the + // previous query. + ], + }); + + // Do one more search before resetting maxCharsForSearchSuggestions to reset + // the search suggestion provider's _lastLowResultsSearchSuggestion property. + // Otherwise it will be stuck at SEARCH_STRING, which interferes with + // subsequent tests. + context = createContext("not the search string", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + Services.prefs.clearUserPref("browser.urlbar.maxCharsForSearchSuggestions"); + + await cleanUpSuggestions(); +}); + +add_task(async function formHistory() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + + // `maxHistoricalSearchSuggestions` is no longer treated as a max count but as + // a boolean: If it's zero, then the user has opted out of form history so we + // shouldn't include any at all; if it's non-zero, then we include form + // history according to the limits specified in the muxer's result groups. + + // zero => no form history + Services.prefs.setIntPref(MAX_FORM_HISTORY_PREF, 0); + let context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeRemoteSuggestionResults(context), + ], + }); + + // non-zero => allow form history + Services.prefs.setIntPref(MAX_FORM_HISTORY_PREF, 1); + context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context), + ], + }); + + // non-zero => allow form history + Services.prefs.setIntPref(MAX_FORM_HISTORY_PREF, 2); + context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context), + ], + }); + + Services.prefs.clearUserPref(MAX_FORM_HISTORY_PREF); + + // Do a search for exactly the suggestion of the first form history result. + // The heuristic's query should be the suggestion; the first form history + // result should not be included since it dupes the heuristic; the other form + // history results should not be included since they don't match; and both + // remote suggestions should be included. + let firstSuggestion = makeFormHistoryResults(context, 1)[0].payload + .suggestion; + context = createContext(firstSuggestion, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: firstSuggestion, + }), + ], + }); + + // Do the same search but in uppercase with a trailing space. We should get + // the same results, i.e., the form history result dupes the trimmed search + // string so it shouldn't be included. + let query = firstSuggestion.toUpperCase() + " "; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + query, + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: firstSuggestion.toUpperCase(), + }), + ], + }); + + // Add a form history entry that dupes the first remote suggestion and do a + // search that triggers both. The form history should be included but the + // remote suggestion should not since it dupes the form history. + let suggestionPrefix = "dupe"; + let dupeSuggestion = makeRemoteSuggestionResults(context, { + suggestionPrefix, + })[0].payload.suggestion; + Assert.ok(dupeSuggestion, "Sanity check: dupeSuggestion is defined"); + await UrlbarTestUtils.formHistory.add([dupeSuggestion]); + + context = createContext(suggestionPrefix, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeFormHistoryResult(context, { + suggestion: dupeSuggestion, + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ...makeRemoteSuggestionResults(context, { suggestionPrefix }).slice(1), + ], + }); + + await UrlbarTestUtils.formHistory.remove([dupeSuggestion]); + + // Add these form history strings to use below. + let formHistoryStrings = ["foo", "FOO ", "foobar", "fooquux"]; + await UrlbarTestUtils.formHistory.add(formHistoryStrings); + + // Search for "foo". "foo" and "FOO " shouldn't be included since they dupe + // the heuristic. Both "foobar" and "fooquux" should be included even though + // the max form history count is only two and there are four matching form + // history results (including the discarded "foo" and "FOO "). + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeFormHistoryResult(context, { + suggestion: "foobar", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "fooquux", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: "foo", + }), + ], + }); + + // Add a visit that matches "foo" and will autofill so that the heuristic is + // not a search result. Now the "foo" and "foobar" form history should be + // included. The "foo" remote suggestion should not be included since it + // dupes the "foo" form history. + await PlacesTestUtils.addVisits("http://foo.example.com/"); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "http://foo.example.com/", + title: "test visit for http://foo.example.com/", + heuristic: true, + }), + makeFormHistoryResult(context, { + suggestion: "foo", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "foobar", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "fooquux", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: "foo", + }), + ], + }); + await PlacesUtils.history.clear(); + + // Add SERPs for "foobar", "fooBAR ", and "food", and search for "foo". The + // "foo" form history should be excluded since it dupes the heuristic; the + // "foobar" and "fooquux" form history should be included; the "food" SERP + // should be included since it doesn't dupe either form history result; and + // the "foobar" and "fooBAR " SERPs depend on the result groups, see below. + let engine = await Services.search.getDefault(); + let serpURLs = ["foobar", "fooBAR ", "food"].map( + term => UrlbarUtils.getSearchQueryUrl(engine, term)[0] + ); + await PlacesTestUtils.addVisits(serpURLs); + + // First set showSearchSuggestionsFirst = false so that general results appear + // before suggestions, which means that the muxer visits the "foobar" and + // "fooBAR " SERPs before visiting the "foobar" form history, and so it + // doesn't see that these two SERPs dupe the form history. They are therefore + // included. + Services.prefs.setBoolPref(SHOW_SEARCH_SUGGESTIONS_FIRST_PREF, false); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: `http://localhost:${port}/search?q=food`, + title: `test visit for http://localhost:${port}/search?q=food`, + }), + makeVisitResult(context, { + uri: `http://localhost:${port}/search?q=fooBAR+`, + title: `test visit for http://localhost:${port}/search?q=fooBAR+`, + }), + makeVisitResult(context, { + uri: `http://localhost:${port}/search?q=foobar`, + title: `test visit for http://localhost:${port}/search?q=foobar`, + }), + makeFormHistoryResult(context, { + suggestion: "foobar", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "fooquux", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: "foo", + }), + ], + }); + + // Now clear showSearchSuggestionsFirst so that suggestions appear before + // general results. Now the muxer will see that the "foobar" and "fooBAR " + // SERPs dupe the "foobar" form history, so it will exclude them. + Services.prefs.clearUserPref(SHOW_SEARCH_SUGGESTIONS_FIRST_PREF); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeFormHistoryResult(context, { + suggestion: "foobar", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "fooquux", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: "foo", + }), + makeVisitResult(context, { + uri: `http://localhost:${port}/search?q=food`, + title: `test visit for http://localhost:${port}/search?q=food`, + }), + ], + }); + + await UrlbarTestUtils.formHistory.remove(formHistoryStrings); + + await cleanUpSuggestions(); + await PlacesUtils.history.clear(); +}); + +// When the heuristic is hidden, search results that match the heuristic should +// be included and not deduped. +add_task(async function hideHeuristic() { + UrlbarPrefs.set("experimental.hideHeuristic", true); + UrlbarPrefs.set("browser.search.suggest.enabled", true); + UrlbarPrefs.set("suggest.searches", true); + let context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + makeSearchResult(context, { + query: SEARCH_STRING, + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: SEARCH_STRING, + }), + ...makeRemoteSuggestionResults(context), + ], + }); + await cleanUpSuggestions(); + UrlbarPrefs.clear("experimental.hideHeuristic"); +}); + +// When the heuristic is hidden, form history results that match the heuristic +// should be included and not deduped. +add_task(async function hideHeuristic_formHistory() { + UrlbarPrefs.set("experimental.hideHeuristic", true); + UrlbarPrefs.set("browser.search.suggest.enabled", true); + UrlbarPrefs.set("suggest.searches", true); + + // Search for exactly the suggestion of the first form history result. + // Expected results: + // + // * First form history should be included even though it dupes the heuristic + // * Other form history should not be included because they don't match the + // search string + // * The first remote suggestion that just echoes the search string should not + // be included because it dupes the first form history + // * The remaining remote suggestions should be included because they don't + // dupe anything + let context = createContext(SEARCH_STRING, { isPrivate: false }); + let firstFormHistory = makeFormHistoryResults(context, 1)[0]; + context = createContext(firstFormHistory.payload.suggestion, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + firstFormHistory, + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: firstFormHistory.payload.suggestion, + }), + ], + }); + + // Add these form history strings to use below. + let formHistoryStrings = ["foo", "FOO ", "foobar", "fooquux"]; + await UrlbarTestUtils.formHistory.add(formHistoryStrings); + + // Search for "foo". Expected results: + // + // * "foo" form history should be included even though it dupes the heuristic + // * "FOO " form history should not be included because it dupes the "foo" + // form history + // * "foobar" and "fooqux" form history should be included because they don't + // dupe anything + // * "foo" remote suggestion should not be included because it dupes the "foo" + // form history + // * "foo foo" and "foo bar" remote suggestions should be included because + // they don't dupe anything + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeFormHistoryResult(context, { + suggestion: "foo", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "foobar", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "fooquux", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: "foo", + }), + ], + }); + + // Add SERPs for "foo" and "food", and search for "foo". Expected results: + // + // * "foo" form history should be included even though it dupes the heuristic + // * "foobar" and "fooqux" form history should be included because they don't + // dupe anything + // * "foo" SERP depends on `showSearchSuggestionsFirst`, see below + // * "food" SERP should be include because it doesn't dupe anything + // * "foo" remote suggestion should not be included because it dupes the "foo" + // form history + // * "foo foo" and "foo bar" remote suggestions should be included because + // they don't dupe anything + let engine = await Services.search.getDefault(); + let serpURLs = ["foo", "food"].map( + term => UrlbarUtils.getSearchQueryUrl(engine, term)[0] + ); + await PlacesTestUtils.addVisits(serpURLs); + + // With `showSearchSuggestionsFirst = false` so that general results appear + // before suggestions, the muxer visits the "foo" (and "food") SERPs before + // visiting the "foo" form history, and so it doesn't see that the "foo" SERP + // dupes the form history. The SERP is therefore included. + UrlbarPrefs.set("showSearchSuggestionsFirst", false); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: `http://localhost:${port}/search?q=food`, + title: `test visit for http://localhost:${port}/search?q=food`, + }), + makeVisitResult(context, { + uri: `http://localhost:${port}/search?q=foo`, + title: `test visit for http://localhost:${port}/search?q=foo`, + }), + makeFormHistoryResult(context, { + suggestion: "foo", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "foobar", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "fooquux", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: "foo", + }), + ], + }); + + // Now clear `showSearchSuggestionsFirst` so that suggestions appear before + // general results. Now the muxer will see that the "foo" SERP dupes the "foo" + // form history, so it will exclude it. + UrlbarPrefs.clear("showSearchSuggestionsFirst"); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeFormHistoryResult(context, { + suggestion: "foo", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "foobar", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "fooquux", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: "foo", + }), + makeVisitResult(context, { + uri: `http://localhost:${port}/search?q=food`, + title: `test visit for http://localhost:${port}/search?q=food`, + }), + ], + }); + + await UrlbarTestUtils.formHistory.remove(formHistoryStrings); + + await cleanUpSuggestions(); + UrlbarPrefs.clear("experimental.hideHeuristic"); +}); diff --git a/browser/components/urlbar/tests/unit/test_search_suggestions_aliases.js b/browser/components/urlbar/tests/unit/test_search_suggestions_aliases.js new file mode 100644 index 0000000000..a21317428f --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_search_suggestions_aliases.js @@ -0,0 +1,364 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that an engine with suggestions works with our alias autocomplete + * behavior. + */ + +const DEFAULT_ENGINE_NAME = "TestDefaultEngine"; +const SUGGEST_PREF = "browser.urlbar.suggest.searches"; +const SUGGEST_ENABLED_PREF = "browser.search.suggest.enabled"; +const HISTORY_TITLE = "fire"; + +// We make sure that aliases and search terms are correctly recognized when they +// are separated by each of these different types of spaces and combinations of +// spaces. U+3000 is the ideographic space in CJK and is commonly used by CJK +// speakers. +const TEST_SPACES = [" ", "\u3000", " \u3000", "\u3000 "]; + +let engine; +let port; + +add_setup(async function () { + engine = await addTestSuggestionsEngine(); + port = engine.getSubmission("").uri.port; + + // Set a mock engine as the default so we don't hit the network below when we + // do searches that return the default engine heuristic result. + await SearchTestUtils.installSearchExtension( + { + name: DEFAULT_ENGINE_NAME, + search_url: "https://my.search.com/", + }, + { setAsDefault: true } + ); + + // History matches should not appear with @aliases, so this visit should not + // appear when searching with @aliases below. + await PlacesTestUtils.addVisits({ + uri: engine.searchForm, + title: HISTORY_TITLE, + }); +}); + +// A non-token alias without a trailing space shouldn't be recognized as a +// keyword. It should be treated as part of the search string. +add_task(async function nonTokenAlias_noTrailingSpace() { + Services.prefs.setBoolPref( + "browser.search.separatePrivateDefault.ui.enabled", + false + ); + + let alias = "moz"; + engine.alias = alias; + Assert.equal(engine.alias, alias); + let context = createContext(alias, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: DEFAULT_ENGINE_NAME, + query: alias, + heuristic: true, + }), + ], + }); + Services.prefs.clearUserPref( + "browser.search.separatePrivateDefault.ui.enabled" + ); +}); + +// A non-token alias with a trailing space should be recognized as a keyword, +// and the history result should be included. +add_task(async function nonTokenAlias_trailingSpace() { + let alias = "moz"; + engine.alias = alias; + Assert.equal(engine.alias, alias); + + for (let isPrivate of [false, true]) { + for (let spaces of TEST_SPACES) { + info( + "Testing: " + JSON.stringify({ isPrivate, spaces: codePoints(spaces) }) + ); + let context = createContext(alias + spaces, { isPrivate }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: "", + heuristic: true, + }), + makeVisitResult(context, { + uri: `http://localhost:${port}/search?q=`, + title: HISTORY_TITLE, + }), + ], + }); + } + } +}); + +// Search for "alias HISTORY_TITLE" with a non-token alias in a non-private +// context. The remote suggestions and history result should be shown. +add_task(async function nonTokenAlias_history_nonPrivate() { + let alias = "moz"; + engine.alias = alias; + Assert.equal(engine.alias, alias); + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + let context = createContext(alias + spaces + HISTORY_TITLE, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: HISTORY_TITLE, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: HISTORY_TITLE, + suggestion: `${HISTORY_TITLE} foo`, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: HISTORY_TITLE, + suggestion: `${HISTORY_TITLE} bar`, + }), + makeVisitResult(context, { + uri: `http://localhost:${port}/search?q=`, + title: HISTORY_TITLE, + }), + ], + }); + } +}); + +// Search for "alias HISTORY_TITLE" with a non-token alias in a private context. +// The history result should be shown, but not the remote suggestions. +add_task(async function nonTokenAlias_history_private() { + let alias = "moz"; + engine.alias = alias; + Assert.equal(engine.alias, alias); + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + let context = createContext(alias + spaces + HISTORY_TITLE, { + isPrivate: true, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: HISTORY_TITLE, + heuristic: true, + }), + makeVisitResult(context, { + uri: `http://localhost:${port}/search?q=`, + title: HISTORY_TITLE, + }), + ], + }); + } +}); + +// A token alias without a trailing space should be autofilled with a trailing +// space and recognized as a keyword with a keyword offer. +add_task(async function tokenAlias_noTrailingSpace() { + let alias = "@moz"; + engine.alias = alias; + Assert.equal(engine.alias, alias); + for (let isPrivate of [false, true]) { + let context = createContext(alias, { isPrivate }); + await check_results({ + context, + autofilled: alias + " ", + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + providesSearchMode: true, + query: "", + heuristic: false, + }), + ], + }); + } +}); + +// A token alias with a trailing space should be recognized as a keyword without +// a keyword offer. +add_task(async function tokenAlias_trailingSpace() { + let alias = "@moz"; + engine.alias = alias; + Assert.equal(engine.alias, alias); + for (let isPrivate of [false, true]) { + for (let spaces of TEST_SPACES) { + info( + "Testing: " + JSON.stringify({ isPrivate, spaces: codePoints(spaces) }) + ); + let context = createContext(alias + spaces, { isPrivate }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: "", + heuristic: true, + }), + ], + }); + } + } +}); + +// Search for "alias HISTORY_TITLE" with a token alias in a non-private context. +// The remote suggestions should be shown, but not the history result. +add_task(async function tokenAlias_history_nonPrivate() { + let alias = "@moz"; + engine.alias = alias; + Assert.equal(engine.alias, alias); + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + let context = createContext(alias + spaces + HISTORY_TITLE, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: HISTORY_TITLE, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: HISTORY_TITLE, + suggestion: `${HISTORY_TITLE} foo`, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: HISTORY_TITLE, + suggestion: `${HISTORY_TITLE} bar`, + }), + ], + }); + } +}); + +// Search for "alias HISTORY_TITLE" with a token alias in a private context. +// Neither the history result nor the remote suggestions should be shown. +add_task(async function tokenAlias_history_private() { + let alias = "@moz"; + engine.alias = alias; + Assert.equal(engine.alias, alias); + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + let context = createContext(alias + spaces + HISTORY_TITLE, { + isPrivate: true, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: HISTORY_TITLE, + heuristic: true, + }), + ], + }); + } +}); + +// Even when they're disabled, suggestions should still be returned when using a +// token alias in a non-private context. +add_task(async function suggestionsDisabled_nonPrivate() { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + let alias = "@moz"; + engine.alias = alias; + Assert.equal(engine.alias, alias); + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + let context = createContext(alias + spaces + "term", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: "term", + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: "term", + suggestion: "term foo", + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: "term", + suggestion: "term bar", + }), + ], + }); + } + Services.prefs.clearUserPref(SUGGEST_PREF); + Services.prefs.clearUserPref(SUGGEST_ENABLED_PREF); +}); + +// Suggestions should not be returned when using a token alias in a private +// context. +add_task(async function suggestionsDisabled_private() { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + let alias = "@moz"; + engine.alias = alias; + Assert.equal(engine.alias, alias); + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + let context = createContext(alias + spaces + "term", { isPrivate: true }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: "term", + heuristic: true, + }), + ], + }); + Services.prefs.clearUserPref(SUGGEST_PREF); + Services.prefs.clearUserPref(SUGGEST_ENABLED_PREF); + } +}); + +/** + * Returns an array of code points in the given string. Each code point is + * returned as a hexidecimal string. + * + * @param {string} str + * The code points of this string will be returned. + * @returns {Array} + * Array of code points in the string, where each is a hexidecimal string. + */ +function codePoints(str) { + return str.split("").map(s => s.charCodeAt(0).toString(16)); +} diff --git a/browser/components/urlbar/tests/unit/test_search_suggestions_tail.js b/browser/components/urlbar/tests/unit/test_search_suggestions_tail.js new file mode 100644 index 0000000000..c7e6905ff5 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_search_suggestions_tail.js @@ -0,0 +1,379 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that tailed search engine suggestions are returned by + * UrlbarProviderSearchSuggestions when available. + */ + +const SUGGEST_ENABLED_PREF = "browser.search.suggest.enabled"; +const PRIVATE_SEARCH_PREF = "browser.search.separatePrivateDefault.ui.enabled"; +const TAIL_SUGGESTIONS_PREF = "browser.urlbar.richSuggestions.tail"; + +var suggestionsFn; +var previousSuggestionsFn; + +/** + * Set the current suggestion funciton. + * + * @param {Function} fn + * A function that that a search string and returns an array of strings that + * will be used as search suggestions. + * Note: `fn` should return > 1 suggestion in most cases. Otherwise, you may + * encounter unexceptede behaviour with UrlbarProviderSuggestion's + * _lastLowResultsSearchSuggestion safeguard. + */ +function setSuggestionsFn(fn) { + previousSuggestionsFn = suggestionsFn; + suggestionsFn = fn; +} + +async function cleanup() { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +} + +async function cleanUpSuggestions() { + await cleanup(); + if (previousSuggestionsFn) { + suggestionsFn = previousSuggestionsFn; + previousSuggestionsFn = null; + } +} + +add_setup(async function () { + let engine = await addTestTailSuggestionsEngine(searchStr => { + return suggestionsFn(searchStr); + }); + setSuggestionsFn(searchStr => { + let suffixes = ["toronto", "tunisia"]; + return [ + "what time is it in t", + suffixes.map(s => searchStr + s.slice(1)), + [], + { + "google:irrelevantparameter": [], + "google:suggestdetail": suffixes.map(s => ({ + mp: "… ", + t: s, + })), + }, + ]; + }); + + // Install the test engine. + let oldDefaultEngine = await Services.search.getDefault(); + registerCleanupFunction(async () => { + Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + Services.prefs.clearUserPref(PRIVATE_SEARCH_PREF); + Services.prefs.clearUserPref(TAIL_SUGGESTIONS_PREF); + Services.prefs.clearUserPref(SUGGEST_ENABLED_PREF); + }); + Services.search.setDefault(engine, Ci.nsISearchService.CHANGE_REASON_UNKNOWN); + Services.prefs.setBoolPref(PRIVATE_SEARCH_PREF, false); + Services.prefs.setBoolPref(TAIL_SUGGESTIONS_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); +}); + +/** + * Tests that non-tail suggestion providers still return results correctly when + * the tailSuggestions pref is enabled. + */ +add_task(async function normal_suggestions_provider() { + let engine = await addTestSuggestionsEngine(); + let tailEngine = await Services.search.getDefault(); + Services.search.setDefault(engine, Ci.nsISearchService.CHANGE_REASON_UNKNOWN); + + const query = "hello world"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: query + " foo", + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: query + " bar", + }), + ], + }); + + Services.search.setDefault( + tailEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await cleanUpSuggestions(); +}); + +/** + * Tests a suggestions provider that returns only tail suggestions. + */ +add_task(async function basic_tail() { + const query = "what time is it in t"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: query + "oronto", + tail: "toronto", + }), + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: query + "unisia", + tail: "tunisia", + }), + ], + }); + await cleanUpSuggestions(); +}); + +/** + * Tests a suggestions provider that returns both normal and tail suggestions. + * Only normal results should be shown. + */ +add_task(async function mixed_suggestions() { + // When normal suggestions are mixed with tail suggestions, they appear at the + // correct position in the google:suggestdetail array as empty objects. + setSuggestionsFn(searchStr => { + let suffixes = ["toronto", "tunisia"]; + return [ + "what time is it in t", + ["what is the time today texas"].concat( + suffixes.map(s => searchStr + s.slice(1)) + ), + [], + { + "google:irrelevantparameter": [], + "google:suggestdetail": [{}].concat( + suffixes.map(s => ({ + mp: "… ", + t: s, + })) + ), + }, + ]; + }); + + const query = "what time is it in t"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: "what is the time today texas", + tail: undefined, + }), + ], + }); + await cleanUpSuggestions(); +}); + +/** + * Tests a suggestions provider that returns both normal and tail suggestions, + * with tail suggestions listed before normal suggestions. In the real world + * we don't expect that to happen, but we should handle it by showing only the + * normal suggestions. + */ +add_task(async function mixed_suggestions_tail_first() { + setSuggestionsFn(searchStr => { + let suffixes = ["toronto", "tunisia"]; + return [ + "what time is it in t", + suffixes + .map(s => searchStr + s.slice(1)) + .concat(["what is the time today texas"]), + [], + { + "google:irrelevantparameter": [], + "google:suggestdetail": suffixes + .map(s => ({ + mp: "… ", + t: s, + })) + .concat([{}]), + }, + ]; + }); + + const query = "what time is it in t"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: "what is the time today texas", + tail: undefined, + }), + ], + }); + await cleanUpSuggestions(); +}); + +/** + * Tests a search that returns history results, bookmark results and tail + * suggestions. Only the history and bookmark results should be shown. + */ +add_task(async function mixed_results() { + await PlacesTestUtils.addVisits([ + { + uri: Services.io.newURI("http://example.com/1"), + title: "what time is", + }, + ]); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/2", + title: "what time is", + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + // Tail suggestions should not be shown. + const query = "what time is"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://example.com/2", + title: "what time is", + }), + makeVisitResult(context, { + uri: "http://example.com/1", + title: "what time is", + }), + ], + }); + + // Once we make the query specific enough to exclude the history and bookmark + // results, we should show tail suggestions. + const tQuery = "what time is it in t"; + context = createContext(tQuery, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: tQuery + "oronto", + tail: "toronto", + }), + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: tQuery + "unisia", + tail: "tunisia", + }), + ], + }); + + await cleanUpSuggestions(); +}); + +/** + * Tests that tail suggestions are deduped if their full-text form is a dupe of + * a local search suggestion. Remaining tail suggestions should also not be + * shown since we do not mix tail and non-tail suggestions. + */ +add_task(async function dedupe_local() { + Services.prefs.setIntPref("browser.urlbar.maxHistoricalSearchSuggestions", 1); + await UrlbarTestUtils.formHistory.add(["what time is it in toronto"]); + + const query = "what time is it in t"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeFormHistoryResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: query + "oronto", + }), + ], + }); + + Services.prefs.clearUserPref("browser.urlbar.maxHistoricalSearchSuggestions"); + await cleanUpSuggestions(); +}); + +/** + * Tests that the correct number of suggestion results are displayed if + * maxResults is limited, even when tail suggestions are returned. + */ +add_task(async function limit_results() { + await UrlbarTestUtils.formHistory.clear(); + const query = "what time is it in t"; + let context = createContext(query, { isPrivate: false }); + context.maxResults = 2; + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: query + "oronto", + tail: "toronto", + }), + ], + }); + await cleanUpSuggestions(); +}); + +/** + * Tests that tail suggestions are hidden if the pref is disabled. + */ +add_task(async function disable_pref() { + let oldPrefValue = Services.prefs.getBoolPref(TAIL_SUGGESTIONS_PREF); + Services.prefs.setBoolPref(TAIL_SUGGESTIONS_PREF, false); + const query = "what time is it in t"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + Services.prefs.setBoolPref(TAIL_SUGGESTIONS_PREF, oldPrefValue); + await cleanUpSuggestions(); +}); diff --git a/browser/components/urlbar/tests/unit/test_special_search.js b/browser/components/urlbar/tests/unit/test_special_search.js new file mode 100644 index 0000000000..863196909a --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_special_search.js @@ -0,0 +1,543 @@ +/* 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/. */ + +/** + * Test for bug 395161 that allows special searches that restrict results to + * history/bookmark/tagged items and title/url matches. + * + * Test 485122 by making sure results don't have tags when restricting result + * to just history either by default behavior or dynamic query restrict. + */ + +testEngine_setup(); + +function setSuggestPrefsToFalse() { + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); +} + +const TRANSITION_TYPED = PlacesUtils.history.TRANSITION_TYPED; + +add_task(async function test_special_searches() { + let uri1 = Services.io.newURI("http://url/"); + let uri2 = Services.io.newURI("http://url/2"); + let uri3 = Services.io.newURI("http://foo.bar/"); + let uri4 = Services.io.newURI("http://foo.bar/2"); + let uri5 = Services.io.newURI("http://url/star"); + let uri6 = Services.io.newURI("http://url/star/2"); + let uri7 = Services.io.newURI("http://foo.bar/star"); + let uri8 = Services.io.newURI("http://foo.bar/star/2"); + let uri9 = Services.io.newURI("http://url/tag"); + let uri10 = Services.io.newURI("http://url/tag/2"); + let uri11 = Services.io.newURI("http://foo.bar/tag"); + let uri12 = Services.io.newURI("http://foo.bar/tag/2"); + await PlacesTestUtils.addVisits([ + { uri: uri11, title: "title", transition: TRANSITION_TYPED }, + { uri: uri6, title: "foo.bar" }, + { uri: uri4, title: "foo.bar", transition: TRANSITION_TYPED }, + { uri: uri3, title: "title" }, + { uri: uri2, title: "foo.bar" }, + { uri: uri1, title: "title", transition: TRANSITION_TYPED }, + ]); + + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri12, + title: "foo.bar", + tags: ["foo.bar"], + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri11, + title: "title", + tags: ["foo.bar"], + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri10, + title: "foo.bar", + tags: ["foo.bar"], + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri9, + title: "title", + tags: ["foo.bar"], + }); + await PlacesTestUtils.addBookmarkWithDetails({ uri: uri8, title: "foo.bar" }); + await PlacesTestUtils.addBookmarkWithDetails({ uri: uri7, title: "title" }); + await PlacesTestUtils.addBookmarkWithDetails({ uri: uri6, title: "foo.bar" }); + await PlacesTestUtils.addBookmarkWithDetails({ uri: uri5, title: "title" }); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + // Order of frecency when not restricting, descending: + // uri11 + // uri1 + // uri4 + // uri6 + // uri5 + // uri7 + // uri8 + // uri9 + // uri10 + // uri12 + // uri2 + // uri3 + + // Test restricting searches. + + info("History restrict"); + let context = createContext(UrlbarTokenizer.RESTRICT.HISTORY, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri11.spec, title: "title" }), + makeVisitResult(context, { uri: uri1.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri6.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri2.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + ], + }); + + info("Star restrict"); + context = createContext(UrlbarTokenizer.RESTRICT.BOOKMARK, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri11.spec, + title: "title", + }), + makeBookmarkResult(context, { uri: uri6.spec, title: "foo.bar" }), + makeBookmarkResult(context, { uri: uri5.spec, title: "title" }), + makeBookmarkResult(context, { uri: uri7.spec, title: "title" }), + makeBookmarkResult(context, { uri: uri8.spec, title: "foo.bar" }), + makeBookmarkResult(context, { + uri: uri9.spec, + title: "title", + }), + makeBookmarkResult(context, { + uri: uri10.spec, + title: "foo.bar", + }), + makeBookmarkResult(context, { + uri: uri12.spec, + title: "foo.bar", + }), + ], + }); + + info("Tag restrict"); + context = createContext(UrlbarTokenizer.RESTRICT.TAG, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri11.spec, + title: "title", + }), + makeBookmarkResult(context, { + uri: uri9.spec, + title: "title", + }), + makeBookmarkResult(context, { + uri: uri10.spec, + title: "foo.bar", + }), + makeBookmarkResult(context, { + uri: uri12.spec, + title: "foo.bar", + }), + ], + }); + + info("Special as first word"); + context = createContext(`${UrlbarTokenizer.RESTRICT.HISTORY} foo bar`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + query: "foo bar", + alias: UrlbarTokenizer.RESTRICT.HISTORY, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { uri: uri11.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri6.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri2.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + ], + }); + + info("Special as last word"); + context = createContext(`foo bar ${UrlbarTokenizer.RESTRICT.HISTORY}`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri11.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri6.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri2.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + ], + }); + + // Test restricting and matching searches with a term. + + info(`foo ${UrlbarTokenizer.RESTRICT.HISTORY} -> history`); + context = createContext(`foo ${UrlbarTokenizer.RESTRICT.HISTORY}`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri11.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri6.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri2.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + ], + }); + + info(`foo ${UrlbarTokenizer.RESTRICT.BOOKMARK} -> is star`); + context = createContext(`foo ${UrlbarTokenizer.RESTRICT.BOOKMARK}`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri11.spec, + title: "title", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { uri: uri6.spec, title: "foo.bar" }), + makeBookmarkResult(context, { uri: uri7.spec, title: "title" }), + makeBookmarkResult(context, { uri: uri8.spec, title: "foo.bar" }), + makeBookmarkResult(context, { + uri: uri9.spec, + title: "title", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { + uri: uri10.spec, + title: "foo.bar", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { + uri: uri12.spec, + title: "foo.bar", + tags: ["foo.bar"], + }), + ], + }); + + info(`foo ${UrlbarTokenizer.RESTRICT.TITLE} -> in title`); + context = createContext(`foo ${UrlbarTokenizer.RESTRICT.TITLE}`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri11.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "foo.bar" }), + makeBookmarkResult(context, { uri: uri6.spec, title: "foo.bar" }), + makeBookmarkResult(context, { uri: uri8.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri9.spec, title: "title" }), + makeVisitResult(context, { uri: uri10.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri12.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri2.spec, title: "foo.bar" }), + ], + }); + + info(`foo ${UrlbarTokenizer.RESTRICT.URL} -> in url`); + context = createContext(`foo ${UrlbarTokenizer.RESTRICT.URL}`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri11.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "foo.bar" }), + makeBookmarkResult(context, { uri: uri7.spec, title: "title" }), + makeBookmarkResult(context, { uri: uri8.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri12.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + ], + }); + + info(`foo ${UrlbarTokenizer.RESTRICT.TAG} -> is tag`); + context = createContext(`foo ${UrlbarTokenizer.RESTRICT.TAG}`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri11.spec, + title: "title", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { + uri: uri9.spec, + title: "title", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { + uri: uri10.spec, + title: "foo.bar", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { + uri: uri12.spec, + title: "foo.bar", + tags: ["foo.bar"], + }), + ], + }); + + // Test conflicting restrictions. + + info( + `conflict ${UrlbarTokenizer.RESTRICT.TITLE} ${UrlbarTokenizer.RESTRICT.URL} -> url wins` + ); + await PlacesTestUtils.addVisits([ + { + uri: `http://conflict.com/${UrlbarTokenizer.RESTRICT.TITLE}`, + title: "test", + }, + { + uri: "http://conflict.com/", + title: `test${UrlbarTokenizer.RESTRICT.TITLE}`, + }, + ]); + context = createContext( + `conflict ${UrlbarTokenizer.RESTRICT.TITLE} ${UrlbarTokenizer.RESTRICT.URL}`, + { isPrivate: false } + ); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: `http://conflict.com/${UrlbarTokenizer.RESTRICT.TITLE}`, + title: "test", + }), + ], + }); + + info( + `conflict ${UrlbarTokenizer.RESTRICT.HISTORY} ${UrlbarTokenizer.RESTRICT.BOOKMARK} -> bookmark wins` + ); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://bookmark.conflict.com/", + title: `conflict ${UrlbarTokenizer.RESTRICT.HISTORY}`, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + context = createContext( + `conflict ${UrlbarTokenizer.RESTRICT.HISTORY} ${UrlbarTokenizer.RESTRICT.BOOKMARK}`, + { isPrivate: false } + ); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://bookmark.conflict.com/", + title: `conflict ${UrlbarTokenizer.RESTRICT.HISTORY}`, + }), + ], + }); + + info( + `conflict ${UrlbarTokenizer.RESTRICT.BOOKMARK} ${UrlbarTokenizer.RESTRICT.TAG} -> tag wins` + ); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://tag.conflict.com/", + title: `conflict ${UrlbarTokenizer.RESTRICT.BOOKMARK}`, + tags: ["one"], + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://nontag.conflict.com/", + title: `conflict ${UrlbarTokenizer.RESTRICT.BOOKMARK}`, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + context = createContext( + `conflict ${UrlbarTokenizer.RESTRICT.BOOKMARK} ${UrlbarTokenizer.RESTRICT.TAG}`, + { isPrivate: false } + ); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://tag.conflict.com/", + title: `conflict ${UrlbarTokenizer.RESTRICT.BOOKMARK}`, + }), + ], + }); + + // Disable autoFill for the next tests, see test_autoFill_default_behavior.js + // for specific tests. + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + + // Test default usage by setting certain browser.urlbar.suggest.* prefs + info("foo -> default history"); + setSuggestPrefsToFalse(); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", true); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri11.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri6.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri2.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + ], + }); + + info("foo -> default history, is star"); + setSuggestPrefsToFalse(); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true); + // The purpose of this test is to verify what is being sent by ProviderPlaces. + // It will send 10 results, but the heuristic result pushes the last result + // out of the panel. We set maxRichResults to a high value to test the full + // output of ProviderPlaces. + Services.prefs.setIntPref("browser.urlbar.maxRichResults", 20); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri11.spec, + title: "title", + tags: ["foo.bar"], + }), + makeVisitResult(context, { uri: uri4.spec, title: "foo.bar" }), + makeBookmarkResult(context, { uri: uri6.spec, title: "foo.bar" }), + makeBookmarkResult(context, { uri: uri7.spec, title: "title" }), + makeBookmarkResult(context, { uri: uri8.spec, title: "foo.bar" }), + makeBookmarkResult(context, { + uri: uri9.spec, + title: "title", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { + uri: uri10.spec, + title: "foo.bar", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { + uri: uri12.spec, + title: "foo.bar", + tags: ["foo.bar"], + }), + makeVisitResult(context, { uri: uri2.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + ], + }); + Services.prefs.clearUserPref("browser.urlbar.maxRichResults"); + + info("foo -> is star"); + setSuggestPrefsToFalse(); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri11.spec, + title: "title", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { uri: uri6.spec, title: "foo.bar" }), + makeBookmarkResult(context, { uri: uri7.spec, title: "title" }), + makeBookmarkResult(context, { uri: uri8.spec, title: "foo.bar" }), + makeBookmarkResult(context, { + uri: uri9.spec, + title: "title", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { + uri: uri10.spec, + title: "foo.bar", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { + uri: uri12.spec, + title: "foo.bar", + tags: ["foo.bar"], + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_suggestedIndex.js b/browser/components/urlbar/tests/unit/test_suggestedIndex.js new file mode 100644 index 0000000000..7d9cc8fef0 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_suggestedIndex.js @@ -0,0 +1,599 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests results with suggestedIndex and resultSpan. + +"use strict"; + +const MAX_RESULTS = 10; + +add_task(async function suggestedIndex() { + let tests = [ + // no result spans > 1 + { + desc: "{ suggestedIndex: 0 }", + suggestedIndexes: [0], + expected: indexes([10, 1], [0, 9]), + }, + { + desc: "{ suggestedIndex: 1 }", + suggestedIndexes: [1], + expected: indexes([0, 1], [10, 1], [1, 8]), + }, + { + desc: "{ suggestedIndex: -1 }", + suggestedIndexes: [-1], + expected: indexes([0, 9], [10, 1]), + }, + { + desc: "{ suggestedIndex: -2 }", + suggestedIndexes: [-2], + expected: indexes([0, 8], [10, 1], [8, 1]), + }, + { + desc: "{ suggestedIndex: 0 }, { suggestedIndex: -1 }", + suggestedIndexes: [0, -1], + expected: indexes([10, 1], [0, 8], [11, 1]), + }, + { + desc: "{ suggestedIndex: 1 }, { suggestedIndex: -1 }", + suggestedIndexes: [1, -1], + expected: indexes([0, 1], [10, 1], [1, 7], [11, 1]), + }, + { + desc: "{ suggestedIndex: 1 }, { suggestedIndex: -2 }", + suggestedIndexes: [1, -2], + expected: indexes([0, 1], [10, 1], [1, 6], [11, 1], [7, 1]), + }, + { + desc: "{ suggestedIndex: 0 }, resultCount < max", + suggestedIndexes: [0], + resultCount: 5, + expected: indexes([5, 1], [0, 5]), + }, + { + desc: "{ suggestedIndex: 1 }, resultCount < max", + suggestedIndexes: [1], + resultCount: 5, + expected: indexes([0, 1], [5, 1], [1, 4]), + }, + { + desc: "{ suggestedIndex: -1 }, resultCount < max", + suggestedIndexes: [-1], + resultCount: 5, + expected: indexes([0, 5], [5, 1]), + }, + { + desc: "{ suggestedIndex: -2 }, resultCount < max", + suggestedIndexes: [-2], + resultCount: 5, + expected: indexes([0, 4], [5, 1], [4, 1]), + }, + { + desc: "{ suggestedIndex: 0 }, { suggestedIndex: -1 }, resultCount < max", + suggestedIndexes: [0, -1], + resultCount: 5, + expected: indexes([5, 1], [0, 5], [6, 1]), + }, + { + desc: "{ suggestedIndex: 1 }, { suggestedIndex: -1 }, resultCount < max", + suggestedIndexes: [1, -1], + resultCount: 5, + expected: indexes([0, 1], [5, 1], [1, 4], [6, 1]), + }, + { + desc: "{ suggestedIndex: 0 }, { suggestedIndex: -2 }, resultCount < max", + suggestedIndexes: [0, -2], + resultCount: 5, + expected: indexes([5, 1], [0, 4], [6, 1], [4, 1]), + }, + { + desc: "{ suggestedIndex: 1 }, { suggestedIndex: -2 }, resultCount < max", + suggestedIndexes: [1, -2], + resultCount: 5, + expected: indexes([0, 1], [5, 1], [1, 3], [6, 1], [4, 1]), + }, + + // one suggestedIndex with result span > 1 + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }", + suggestedIndexes: [0], + spansByIndex: { 10: 2 }, + expected: indexes([10, 1], [0, 8]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 3 }", + suggestedIndexes: [0], + spansByIndex: { 10: 3 }, + expected: indexes([10, 1], [0, 7]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 2 }", + suggestedIndexes: [1], + spansByIndex: { 10: 2 }, + expected: indexes([0, 1], [10, 1], [1, 7]), + }, + { + desc: "suggestedIndex: 1, resultSpan:: 3 }", + suggestedIndexes: [1], + spansByIndex: { 10: 3 }, + expected: indexes([0, 1], [10, 1], [1, 6]), + }, + { + desc: "{ suggestedIndex: -1, resultSpan 2 }", + suggestedIndexes: [-1], + spansByIndex: { 10: 2 }, + expected: indexes([0, 8], [10, 1]), + }, + { + desc: "{ suggestedIndex: -1, resultSpan: 3 }", + suggestedIndexes: [-1], + spansByIndex: { 10: 3 }, + expected: indexes([0, 7], [10, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, { suggestedIndex: -1 }", + suggestedIndexes: [0, -1], + spansByIndex: { 10: 2 }, + expected: indexes([10, 1], [0, 7], [11, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 3 }, { suggestedIndex: -1 }", + suggestedIndexes: [0, -1], + spansByIndex: { 10: 3 }, + expected: indexes([10, 1], [0, 6], [11, 1]), + }, + { + desc: "{ suggestedIndex: 0 }, { suggestedIndex: -1, resultSpan: 2 }", + suggestedIndexes: [0, -1], + spansByIndex: { 11: 2 }, + expected: indexes([10, 1], [0, 7], [11, 1]), + }, + { + desc: "{ suggestedIndex: 0 }, { suggestedIndex: -1, resultSpan: 3 }", + suggestedIndexes: [0, -1], + spansByIndex: { 11: 3 }, + expected: indexes([10, 1], [0, 6], [11, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 2 }, { suggestedIndex: -1 }", + suggestedIndexes: [1, -1], + spansByIndex: { 10: 2 }, + expected: indexes([0, 1], [10, 1], [1, 6], [11, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 3 }, { suggestedIndex: -1 }", + suggestedIndexes: [1, -1], + spansByIndex: { 10: 3 }, + expected: indexes([0, 1], [10, 1], [1, 5], [11, 1]), + }, + { + desc: "{ suggestedIndex: 1 }, { suggestedIndex: -1, resultSpan: 2 }", + suggestedIndexes: [1, -1], + spansByIndex: { 11: 2 }, + expected: indexes([0, 1], [10, 1], [1, 6], [11, 1]), + }, + { + desc: "{ suggestedIndex: 1 }, { suggestedIndex: -1, resultSpan: 3 }", + suggestedIndexes: [1, -1], + spansByIndex: { 11: 3 }, + expected: indexes([0, 1], [10, 1], [1, 5], [11, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, { suggestedIndex: -2 }", + suggestedIndexes: [0, -2], + spansByIndex: { 10: 2 }, + expected: indexes([10, 1], [0, 6], [11, 1], [6, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 3 }, { suggestedIndex: -2 }", + suggestedIndexes: [0, -2], + spansByIndex: { 10: 3 }, + expected: indexes([10, 1], [0, 5], [11, 1], [5, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 2 }, { suggestedIndex: -2 }", + suggestedIndexes: [1, -2], + spansByIndex: { 10: 2 }, + expected: indexes([0, 1], [10, 1], [1, 5], [11, 1], [6, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 3 }, { suggestedIndex: -2 }", + suggestedIndexes: [1, -2], + spansByIndex: { 10: 3 }, + expected: indexes([0, 1], [10, 1], [1, 4], [11, 1], [5, 1]), + }, + { + desc: "{ suggestedIndex: 0 }, { suggestedIndex: -2, resultSpan: 2 }", + suggestedIndexes: [0, -2], + spansByIndex: { 11: 2 }, + expected: indexes([10, 1], [0, 6], [11, 1], [6, 1]), + }, + { + desc: "{ suggestedIndex: 0 }, { suggestedIndex: -2, resultSpan: 3 }", + suggestedIndexes: [0, -2], + spansByIndex: { 11: 3 }, + expected: indexes([10, 1], [0, 5], [11, 1], [5, 1]), + }, + { + desc: "{ suggestedIndex: 1 }, { suggestedIndex: -2, resultSpan: 2 }", + suggestedIndexes: [1, -2], + spansByIndex: { 11: 2 }, + expected: indexes([0, 1], [10, 1], [1, 5], [11, 1], [6, 1]), + }, + { + desc: "{ suggestedIndex: 1 }, { suggestedIndex: -2, resultSpan: 3 }", + suggestedIndexes: [1, -2], + spansByIndex: { 11: 3 }, + expected: indexes([0, 1], [10, 1], [1, 4], [11, 1], [5, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, resultCount < max", + suggestedIndexes: [0], + spansByIndex: { 5: 2 }, + resultCount: 5, + expected: indexes([5, 1], [0, 5]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 2 }, resultCount < max", + suggestedIndexes: [1], + spansByIndex: { 5: 2 }, + resultCount: 5, + expected: indexes([0, 1], [5, 1], [1, 4]), + }, + { + desc: "{ suggestedIndex: -1, resultSpan: 2 }, resultCount < max", + suggestedIndexes: [-1], + spansByIndex: { 5: 2 }, + resultCount: 5, + expected: indexes([0, 5], [5, 1]), + }, + { + desc: "{ suggestedIndex: -2, resultSpan: 2 }, resultCount < max", + suggestedIndexes: [-2], + spansByIndex: { 5: 2 }, + resultCount: 5, + expected: indexes([0, 4], [5, 1], [4, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, { suggestedIndex: -1 }, resultCount < max", + suggestedIndexes: [0, -1], + spansByIndex: { 5: 2 }, + resultCount: 5, + expected: indexes([5, 1], [0, 5], [6, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 2 }, { suggestedIndex: -1 }, resultCount < max", + suggestedIndexes: [1, -1], + spansByIndex: { 5: 2 }, + resultCount: 5, + expected: indexes([0, 1], [5, 1], [1, 4], [6, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, { suggestedIndex: -2 }, resultCount < max", + suggestedIndexes: [0, -2], + spansByIndex: { 5: 2 }, + resultCount: 5, + expected: indexes([5, 1], [0, 4], [6, 1], [4, 1]), + }, + { + desc: "{ suggestedIndex: 0 }, { suggestedIndex: -1, resultSpan: 2 }, resultCount < max", + suggestedIndexes: [0, -1], + spansByIndex: { 6: 2 }, + resultCount: 5, + expected: indexes([5, 1], [0, 5], [6, 1]), + }, + { + desc: "{ suggestedIndex: 0 }, { suggestedIndex: -2, resultSpan: 2 }, resultCount < max", + suggestedIndexes: [0, -2], + spansByIndex: { 6: 2 }, + resultCount: 5, + expected: indexes([5, 1], [0, 4], [6, 1], [4, 1]), + }, + + // two suggestedIndexes with result span > 1 + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, { suggestedIndex: -1, resultSpan: 2 }", + suggestedIndexes: [0, -1], + spansByIndex: { 10: 2, 11: 2 }, + expected: indexes([10, 1], [0, 6], [11, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 3 }, { suggestedIndex: -1, resultSpan: 2 }", + suggestedIndexes: [0, -1], + spansByIndex: { 10: 3, 11: 2 }, + expected: indexes([10, 1], [0, 5], [11, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, { suggestedIndex: -1, resultSpan: 3 }", + suggestedIndexes: [0, -1], + spansByIndex: { 10: 2, 11: 3 }, + expected: indexes([10, 1], [0, 5], [11, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 2 }, { suggestedIndex: -1, resultSpan: 2 }", + suggestedIndexes: [1, -1], + spansByIndex: { 10: 2, 11: 2 }, + expected: indexes([0, 1], [10, 1], [1, 5], [11, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 3 }, { suggestedIndex: -1, resultSpan: 2 }", + suggestedIndexes: [1, -1], + spansByIndex: { 10: 3, 11: 2 }, + expected: indexes([0, 1], [10, 1], [1, 4], [11, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 2 }, { suggestedIndex: -1, resultSpan: 3 }", + suggestedIndexes: [1, -1], + spansByIndex: { 10: 2, 11: 3 }, + expected: indexes([0, 1], [10, 1], [1, 4], [11, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, { suggestedIndex: -2, resultSpan: 2 }", + suggestedIndexes: [0, -2], + spansByIndex: { 10: 2, 11: 2 }, + expected: indexes([10, 1], [0, 5], [11, 1], [5, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 3 }, { suggestedIndex: -2, resultSpan: 2 }", + suggestedIndexes: [0, -2], + spansByIndex: { 10: 3, 11: 2 }, + expected: indexes([10, 1], [0, 4], [11, 1], [4, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, { suggestedIndex: -2, resultSpan: 3 }", + suggestedIndexes: [0, -2], + spansByIndex: { 10: 2, 11: 3 }, + expected: indexes([10, 1], [0, 4], [11, 1], [4, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 2 }, { suggestedIndex: -2, resultSpan: 2 }", + suggestedIndexes: [1, -2], + spansByIndex: { 10: 2, 11: 2 }, + expected: indexes([0, 1], [10, 1], [1, 4], [11, 1], [5, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 3 }, { suggestedIndex: -2, resultSpan: 2 }", + suggestedIndexes: [1, -2], + spansByIndex: { 10: 3, 11: 2 }, + expected: indexes([0, 1], [10, 1], [1, 3], [11, 1], [4, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 2 }, { suggestedIndex: -2, resultSpan: 3 }", + suggestedIndexes: [1, -2], + spansByIndex: { 10: 2, 11: 3 }, + expected: indexes([0, 1], [10, 1], [1, 3], [11, 1], [4, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, { suggestedIndex: -1, resultSpan: 2 }, resultCount < max", + suggestedIndexes: [0, -1], + spansByIndex: { 5: 2, 6: 2 }, + resultCount: 5, + expected: indexes([5, 1], [0, 5], [6, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 2 }, { suggestedIndex: -1, resultSpan: 2 }, resultCount < max", + suggestedIndexes: [1, -1], + spansByIndex: { 5: 2, 6: 2 }, + resultCount: 5, + expected: indexes([0, 1], [5, 1], [1, 4], [6, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, { suggestedIndex: -2, resultSpan: 2 }, resultCount < max", + suggestedIndexes: [0, -2], + spansByIndex: { 5: 2, 6: 2 }, + resultCount: 5, + expected: indexes([5, 1], [0, 4], [6, 1], [4, 1]), + }, + + // one suggestedIndex plus other result with resultSpan > 1 + { + desc: "{ suggestedIndex: 0 }, { resultSpan: 2 } A", + suggestedIndexes: [0], + spansByIndex: { 0: 2 }, + expected: indexes([10, 1], [0, 8]), + }, + { + desc: "{ suggestedIndex: 0 }, { resultSpan: 2 } B", + suggestedIndexes: [0], + spansByIndex: { 8: 2 }, + expected: indexes([10, 1], [0, 8]), + }, + { + desc: "{ suggestedIndex: 0 }, { resultSpan: 2 } C", + suggestedIndexes: [0], + spansByIndex: { 9: 2 }, + expected: indexes([10, 1], [0, 9]), + }, + { + desc: "{ suggestedIndex: 1 }, { resultSpan: 2 } A", + suggestedIndexes: [1], + spansByIndex: { 0: 2 }, + expected: indexes([0, 1], [10, 1], [1, 7]), + }, + { + desc: "{ suggestedIndex: 1 }, { resultSpan: 2 } B", + suggestedIndexes: [1], + spansByIndex: { 8: 2 }, + expected: indexes([0, 1], [10, 1], [1, 7]), + }, + { + desc: "{ suggestedIndex: -1 }, { resultSpan: 2 }", + suggestedIndexes: [-1], + spansByIndex: { 0: 2 }, + expected: indexes([0, 8], [10, 1]), + }, + { + desc: "{ suggestedIndex: -2 }, { resultSpan: 2 }", + suggestedIndexes: [-2], + spansByIndex: { 0: 2 }, + expected: indexes([0, 7], [10, 1], [7, 1]), + }, + + // miscellaneous + { + desc: "no suggestedIndex, last result has resultSpan = 2", + suggestedIndexes: [], + spansByIndex: { 9: 2 }, + expected: indexes([0, 9]), + }, + { + desc: "{ suggestedIndex: -1 }, last result has resultSpan = 2", + suggestedIndexes: [-1], + spansByIndex: { 9: 2 }, + expected: indexes([0, 9], [10, 1]), + }, + { + desc: "no suggestedIndex, index 8 result has resultSpan = 2", + suggestedIndexes: [], + spansByIndex: { 8: 2 }, + expected: indexes([0, 9]), + }, + { + desc: "{ suggestedIndex: -1 }, index 8 result has resultSpan = 2", + suggestedIndexes: [-1], + spansByIndex: { 8: 2 }, + expected: indexes([0, 8], [10, 1]), + }, + { + desc: "{ suggestedIndex: 0, maxRichResults: 0 }", + maxRichResults: 0, + suggestedIndexes: [0], + expected: [], + }, + { + desc: "{ suggestedIndex: 1, maxRichResults: 0 }", + maxRichResults: 0, + suggestedIndexes: [1], + expected: [], + }, + { + desc: "{ suggestedIndex: -1, maxRichResults: 0 }", + maxRichResults: 0, + suggestedIndexes: [-1], + expected: [], + }, + { + desc: "{ suggestedIndex: 0, maxRichResults: 1 }", + maxRichResults: 1, + suggestedIndexes: [0], + expected: indexes([10, 1]), + }, + { + desc: "{ suggestedIndex: 1, maxRichResults: 1 }", + maxRichResults: 1, + suggestedIndexes: [1], + expected: indexes([10, 1]), + }, + { + desc: "{ suggestedIndex: -1, maxRichResults: 1 }", + maxRichResults: 1, + suggestedIndexes: [-1], + expected: indexes([10, 1]), + }, + ]; + + for (let test of tests) { + info("Running test: " + JSON.stringify(test)); + await doSuggestedIndexTest(test); + } +}); + +/** + * Sets up a provider with some results with suggested indexes and result spans, + * performs a search, and then checks the results. + * + * @param {object} options + * Options for the test. + * @param {Array} options.suggestedIndexes + * For each of the indexes in this array, a new result with the given + * suggestedIndex will be returned by the provider. + * @param {Array} options.expected + * The indexes of the expected results within the array of results returned by + * the provider. + * @param {object} [options.spansByIndex] + * Maps indexes within the array of results returned by the provider to result + * spans to set on those results. + * @param {number} [options.resultCount] + * Aside from the results with suggested indexes, this is the number of + * results that the provider will return. + * @param {number} [options.maxRichResults] + * The `maxRichResults` pref will be set to this value. + */ +async function doSuggestedIndexTest({ + suggestedIndexes, + expected, + spansByIndex = {}, + resultCount = MAX_RESULTS, + maxRichResults = MAX_RESULTS, +}) { + // Make resultCount history results. + let results = []; + for (let i = 0; i < resultCount; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { + url: "http://example.com/" + i, + } + ) + ); + } + + // Make the suggested-index results. + for (let suggestedIndex of suggestedIndexes) { + results.push( + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { + url: "http://example.com/si " + suggestedIndex, + } + ), + { suggestedIndex } + ) + ); + } + + // Set resultSpan on each result as indicated by spansByIndex. + for (let [index, span] of Object.entries(spansByIndex)) { + results[index].resultSpan = span; + } + + // Set up the provider, etc. + UrlbarPrefs.set("maxRichResults", maxRichResults); + let provider = registerBasicTestProvider(results); + let context = createContext(undefined, { providers: [provider.name] }); + let controller = UrlbarTestUtils.newMockController(); + + // Finally, search and check the results. + let expectedResults = expected.map(i => results[i]); + await UrlbarProvidersManager.startQuery(context, controller); + Assert.deepEqual(context.results, expectedResults); +} + +/** + * Helper that generates an array of indexes. Pass in [index, length] tuples. + * Each tuple will produce the indexes starting from `index` to `index + length` + * (not including the index at `index + length`). + * + * Examples: + * + * indexes([0, 5]) => [0, 1, 2, 3, 4] + * indexes([0, 1], [4, 3], [8, 2]) => [0, 4, 5, 6, 8, 9] + * + * @param {Array} pairs + * [index, length] tuples as described above. + * @returns {Array} + * An array of indexes. + */ +function indexes(...pairs) { + return pairs.reduce((indexesArray, [start, len]) => { + for (let i = start; i < start + len; i++) { + indexesArray.push(i); + } + return indexesArray; + }, []); +} diff --git a/browser/components/urlbar/tests/unit/test_suggestedIndexRelativeToGroup.js b/browser/components/urlbar/tests/unit/test_suggestedIndexRelativeToGroup.js new file mode 100644 index 0000000000..b69c17f50b --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_suggestedIndexRelativeToGroup.js @@ -0,0 +1,645 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests results with `suggestedIndex` and `isSuggestedIndexRelativeToGroup`. + +"use strict"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +const MAX_RESULTS = 10; + +// Default result groups used in the tests below. +const RESULT_GROUPS = { + children: [ + { + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + ], +}; + +let sandbox; +add_setup(async () => { + sandbox = lazy.sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); +}); + +add_task(async function test() { + // Create the default non-suggestedIndex results we'll use for tests that + // don't specify `otherResults`. + let basicResults = [ + ...makeHistoryResults(), + ...makeFormHistoryResults(), + ...makeRemoteSuggestionResults(), + ]; + + // Test cases follow. Each object in `tests` has the following properties: + // + // * {string} desc + // * {object} suggestedIndexResults + // Describes the suggestedIndex results the test provider should return. + // Properties: + // * {number} suggestedIndex + // * {UrlbarUtils.RESULT_GROUP} group + // This will force the result to have the given group. + // * {array} expected + // Describes the expected results the muxer should return, i.e., the search + // results. Each object in the array describes a slice of expected results. + // The size of the slice is defined by the `count` property. + // * {UrlbarUtils.RESULT_GROUP} group + // The expected group of the results in the slice. + // * {number} count + // The number of results in the slice. + // * {number} [offset] + // Can be used to offset the starting index of the slice in the results. + // * {array} [otherResults] + // An array of results besides the group-relative suggestedIndex results + // that the provider should return. If not specified `basicResults` is used. + // * {array} [resultGroups] + // The result groups to use. If not specified `RESULT_GROUPS` is used. + // * {number} [maxRichResults] + // The `maxRichResults` pref will be set to this value. If not specified + // `MAX_RESULTS` is used. + let tests = [ + { + desc: "First result in GENERAL", + suggestedIndexResults: [ + { + suggestedIndex: 0, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + count: 4, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + suggestedIndex: 0, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + count: 2, + }, + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + // The muxer will remove the first 4 remote suggestions because they + // dupe the earlier form history. + offset: 4, + count: 3, + }, + ], + }, + + { + desc: "Last result in GENERAL", + suggestedIndexResults: [ + { + suggestedIndex: -1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + count: 4, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + count: 2, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + suggestedIndex: -1, + }, + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + // The muxer will remove the first 4 remote suggestions because they + // dupe the earlier form history. + offset: 4, + count: 3, + }, + ], + }, + + { + desc: "First result in GENERAL_PARENT", + suggestedIndexResults: [ + { + suggestedIndex: 0, + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + suggestedIndex: 0, + }, + { + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + count: 3, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + count: 3, + }, + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + // The muxer will remove the first 3 remote suggestions because they + // dupe the earlier form history. + offset: 3, + count: 3, + }, + ], + }, + + { + desc: "Last result in GENERAL_PARENT", + suggestedIndexResults: [ + { + suggestedIndex: -1, + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + count: 3, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + count: 3, + }, + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + // The muxer will remove the first 3 remote suggestions because they + // dupe the earlier form history. + offset: 3, + count: 3, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + suggestedIndex: -1, + }, + ], + }, + + { + desc: "First and last results in GENERAL", + suggestedIndexResults: [ + { + suggestedIndex: 0, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + suggestedIndex: -1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + count: 4, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + suggestedIndex: 0, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + count: 1, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + suggestedIndex: -1, + }, + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + // The muxer will remove the first 4 remote suggestions because they + // dupe the earlier form history. + offset: 4, + count: 3, + }, + ], + }, + + { + desc: "First and last results in GENERAL_PARENT", + suggestedIndexResults: [ + { + suggestedIndex: 0, + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + }, + { + suggestedIndex: -1, + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + suggestedIndex: 0, + }, + { + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + count: 3, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + count: 3, + }, + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + // The muxer will remove the first 3 remote suggestions because they + // dupe the earlier form history. + offset: 3, + count: 2, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + suggestedIndex: -1, + }, + ], + }, + + { + desc: "First result in GENERAL_PARENT, first result in GENERAL", + suggestedIndexResults: [ + { + suggestedIndex: 0, + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + }, + { + suggestedIndex: 0, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + suggestedIndex: 0, + }, + { + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + count: 3, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + suggestedIndex: 0, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + count: 2, + }, + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + // The muxer will remove the first 3 remote suggestions because they + // dupe the earlier form history. + offset: 3, + count: 3, + }, + ], + }, + + { + desc: "Results in sibling group, no other results in same group", + otherResults: makeFormHistoryResults(), + suggestedIndexResults: [ + { + suggestedIndex: -1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + count: 9, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + suggestedIndex: -1, + }, + ], + }, + + { + desc: "Results in sibling group, no other results in same group, has child group", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + ], + }, + otherResults: makeRemoteSuggestionResults(), + suggestedIndexResults: [ + { + suggestedIndex: -1, + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + count: 9, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + suggestedIndex: -1, + }, + ], + }, + + { + desc: "Complex group nesting with global suggestedIndex with resultSpan", + resultGroups: { + children: [ + { + maxResultCount: 1, + children: [{ group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }], + }, + { + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + ], + }, + ], + }, + otherResults: [ + // heuristic + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + engine: "test", + suggestion: "foo", + lowerCaseSuggestion: "foo", + } + ), + { + heuristic: true, + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + } + ), + // global suggestedIndex with resultSpan = 2 + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + engine: "test", + } + ), + { + suggestedIndex: 1, + resultSpan: 2, + } + ), + // remote suggestions + ...makeRemoteSuggestionResults(), + ], + suggestedIndexResults: [ + { + suggestedIndex: -1, + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + count: 1, + }, + { + group: UrlbarUtils.RESULT_GROUP.SUGGESTED_INDEX, + suggestedIndex: 1, + resultSpan: 2, + count: 1, + }, + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + count: 6, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + suggestedIndex: -1, + }, + ], + }, + + { + desc: "Last result in REMOTE_SUGGESTION, maxRichResults too small to add any REMOTE_SUGGESTION", + maxRichResults: 2, + suggestedIndexResults: [ + { + suggestedIndex: -1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + count: 1, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + count: 1, + }, + // The suggestedIndex result should not be added. + ], + }, + + { + desc: "Last result in REMOTE_SUGGESTION, maxRichResults just big enough to show one REMOTE_SUGGESTION", + maxRichResults: 3, + suggestedIndexResults: [ + { + suggestedIndex: -1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + count: 1, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + count: 1, + }, + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + suggestedIndex: -1, + }, + ], + }, + ]; + + let controller = UrlbarTestUtils.newMockController(); + + for (let { + desc, + suggestedIndexResults, + expected, + resultGroups, + otherResults, + maxRichResults = MAX_RESULTS, + } of tests) { + info(`Running test: ${desc}`); + + setResultGroups(resultGroups || RESULT_GROUPS); + + UrlbarPrefs.set("maxRichResults", maxRichResults); + + // Make the array of all results and do a search. + let results = (otherResults || basicResults).concat( + makeSuggestedIndexResults(suggestedIndexResults) + ); + let provider = registerBasicTestProvider(results); + let context = createContext(undefined, { providers: [provider.name] }); + await UrlbarProvidersManager.startQuery(context, controller); + + // Make the list of expected results. + let expectedResults = []; + for (let { group, offset, count, suggestedIndex } of expected) { + // Find the index in `results` of the expected result. + let index = results.findIndex( + r => + UrlbarUtils.getResultGroup(r) == group && + r.suggestedIndex === suggestedIndex + ); + Assert.notEqual( + index, + -1, + "Sanity check: Expected result is in `results`" + ); + if (offset) { + index += offset; + } + + // Extract the expected number of results from `results` and append them + // to the expected results array. + count = count || 1; + expectedResults.push(...results.slice(index, index + count)); + } + + Assert.deepEqual(context.results, expectedResults); + + UrlbarProvidersManager.unregisterProvider(provider); + } +}); + +function makeHistoryResults(count = MAX_RESULTS) { + let results = []; + for (let i = 0; i < count; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://example.com/" + i } + ) + ); + } + return results; +} + +function makeRemoteSuggestionResults(count = MAX_RESULTS) { + let results = []; + for (let i = 0; i < count; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + engine: "test", + query: "test", + suggestion: "test " + i, + lowerCaseSuggestion: "test " + i, + } + ) + ); + } + return results; +} + +function makeFormHistoryResults(count = MAX_RESULTS) { + let results = []; + for (let i = 0; i < count; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { + engine: "test", + suggestion: "test " + i, + lowerCaseSuggestion: "test " + i, + } + ) + ); + } + return results; +} + +function makeSuggestedIndexResults(objects) { + return objects.map(({ suggestedIndex, group }) => + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + url: "http://example.com/si " + suggestedIndex, + } + ), + { + group, + suggestedIndex, + isSuggestedIndexRelativeToGroup: true, + } + ) + ); +} + +function setResultGroups(resultGroups) { + sandbox.restore(); + if (resultGroups) { + sandbox.stub(UrlbarPrefs, "resultGroups").get(() => resultGroups); + } +} diff --git a/browser/components/urlbar/tests/unit/test_tab_matches.js b/browser/components/urlbar/tests/unit/test_tab_matches.js new file mode 100644 index 0000000000..640a629911 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_tab_matches.js @@ -0,0 +1,366 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim:set ts=2 sw=2 sts=2 et: + * 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/. */ + +testEngine_setup(); + +add_task(async function test_tab_matches() { + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + }); + + let uri1 = Services.io.newURI("http://abc.com/"); + let uri2 = Services.io.newURI("http://xyz.net/"); + let uri3 = Services.io.newURI("about:mozilla"); + let uri4 = Services.io.newURI("data:text/html,test"); + let uri5 = Services.io.newURI("http://foobar.org"); + await PlacesTestUtils.addVisits([ + { + uri: uri5, + title: "foobar.org - much better than ABC, definitely better than XYZ", + }, + { uri: uri2, title: "xyz.net - we're better than ABC" }, + { uri: uri1, title: "ABC rocks" }, + ]); + await addOpenPages(uri1, 1); + // Pages that cannot be registered in history. + await addOpenPages(uri3, 1); + await addOpenPages(uri4, 1); + + info("basic tab match"); + let context = createContext("abc.com", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "http://abc.com/", + title: "ABC rocks", + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "http://abc.com/", + title: "ABC rocks", + }), + ], + }); + + info("three results, one tab match"); + context = createContext("abc", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "http://abc.com/", + title: "ABC rocks", + }), + makeVisitResult(context, { + uri: uri2.spec, + title: "xyz.net - we're better than ABC", + }), + makeVisitResult(context, { + uri: uri5.spec, + title: "foobar.org - much better than ABC, definitely better than XYZ", + }), + ], + }); + + info("three results, both normal results are tab matches"); + await addOpenPages(uri2, 1); + context = createContext("abc", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "http://abc.com/", + title: "ABC rocks", + }), + makeTabSwitchResult(context, { + uri: "http://xyz.net/", + title: "xyz.net - we're better than ABC", + }), + makeVisitResult(context, { + uri: uri5.spec, + title: "foobar.org - much better than ABC, definitely better than XYZ", + }), + ], + }); + + // This covers the following 3 tests. Container tests are in a dedicated + // test file anyway, so these are left to cover the disabled pref case. + UrlbarPrefs.set("switchTabs.searchAllContainers", false); + + info("a container tab is not visible in 'switch to tab'"); + await addOpenPages(uri5, 1, /* userContextId: */ 3); + context = createContext("abc", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "http://abc.com/", + title: "ABC rocks", + }), + makeTabSwitchResult(context, { + uri: "http://xyz.net/", + title: "xyz.net - we're better than ABC", + }), + makeVisitResult(context, { + uri: uri5.spec, + title: "foobar.org - much better than ABC, definitely better than XYZ", + }), + ], + }); + + info( + "a container tab should not see 'switch to tab' for other container tabs" + ); + context = createContext("abc", { isPrivate: false, userContextId: 3 }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "ABC rocks", + }), + makeVisitResult(context, { + uri: uri2.spec, + title: "xyz.net - we're better than ABC", + }), + makeTabSwitchResult(context, { + uri: "http://foobar.org/", + title: "foobar.org - much better than ABC, definitely better than XYZ", + userContextId: 3, + }), + ], + }); + + info("a different container tab should not see any 'switch to tab'"); + context = createContext("abc", { isPrivate: false, userContextId: 2 }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri1.spec, title: "ABC rocks" }), + makeVisitResult(context, { + uri: uri2.spec, + title: "xyz.net - we're better than ABC", + }), + makeVisitResult(context, { + uri: uri5.spec, + title: "foobar.org - much better than ABC, definitely better than XYZ", + }), + ], + }); + + UrlbarPrefs.clear("switchTabs.searchAllContainers"); + if (UrlbarPrefs.get("switchTabs.searchAllContainers")) { + // This would confuse the next tests, so remove it, containers are tested + // in a separate test file. + await removeOpenPages(uri5, 1, /* userContextId: */ 3); + } + + info( + "three results, both normal results are tab matches, one has multiple tabs" + ); + await addOpenPages(uri2, 5); + context = createContext("abc", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "http://abc.com/", + title: "ABC rocks", + }), + makeTabSwitchResult(context, { + uri: "http://xyz.net/", + title: "xyz.net - we're better than ABC", + }), + makeVisitResult(context, { + uri: uri5.spec, + title: "foobar.org - much better than ABC, definitely better than XYZ", + }), + ], + }); + + info("three results, no tab matches"); + await removeOpenPages(uri1, 1); + await removeOpenPages(uri2, 6); + context = createContext("abc", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "ABC rocks", + }), + makeVisitResult(context, { + uri: uri2.spec, + title: "xyz.net - we're better than ABC", + }), + makeVisitResult(context, { + uri: uri5.spec, + title: "foobar.org - much better than ABC, definitely better than XYZ", + }), + ], + }); + + info("tab match search with restriction character"); + await addOpenPages(uri1, 1); + context = createContext(UrlbarTokenizer.RESTRICT.OPENPAGE + " abc", { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + query: "abc", + alias: UrlbarTokenizer.RESTRICT.OPENPAGE, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "http://abc.com/", + title: "ABC rocks", + }), + ], + }); + + info("tab match with not-addable pages"); + context = createContext("mozilla", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "about:mozilla", + title: "about:mozilla", + }), + ], + }); + + info("tab match with not-addable pages, no boundary search"); + context = createContext("ut:mo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "about:mozilla", + title: "about:mozilla", + }), + ], + }); + + info("tab match with not-addable pages and restriction character"); + context = createContext(UrlbarTokenizer.RESTRICT.OPENPAGE + " mozilla", { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + query: "mozilla", + alias: UrlbarTokenizer.RESTRICT.OPENPAGE, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "about:mozilla", + title: "about:mozilla", + }), + ], + }); + + info("tab match with not-addable pages and only restriction character"); + context = createContext(UrlbarTokenizer.RESTRICT.OPENPAGE, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "data:text/html,test", + title: "data:text/html,test", + iconUri: UrlbarUtils.ICON.DEFAULT, + }), + makeTabSwitchResult(context, { + uri: "about:mozilla", + title: "about:mozilla", + }), + makeTabSwitchResult(context, { + uri: "http://abc.com/", + title: "ABC rocks", + }), + ], + }); + + info("tab match should not return tags as part of the title"); + // Bookmark one of the pages, and add tags to it, to check they don't appear + // in the title. + let bm = await PlacesUtils.bookmarks.insert({ + url: uri1, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + PlacesUtils.tagging.tagURI(uri1, ["test-tag"]); + context = createContext("abc.com", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "http://abc.com/", + title: "ABC rocks", + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "http://abc.com/", + title: "ABC rocks", + }), + ], + }); + await PlacesUtils.bookmarks.remove(bm); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_tags_caseInsensitivity.js b/browser/components/urlbar/tests/unit/test_tags_caseInsensitivity.js new file mode 100644 index 0000000000..f7994326ee --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_tags_caseInsensitivity.js @@ -0,0 +1,137 @@ +/* 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/. */ + +testEngine_setup(); + +/** + * Checks the results of a search for `searchTerm`. + * + * @param {Array} uris + * A 2-element array containing [{string} uri, {array} tags}], where `tags` + * is a comma-separated list of the tags expected to appear in the search. + * @param {string} searchTerm + * The term to search for + */ +async function ensure_tag_results(uris, searchTerm) { + print("Searching for '" + searchTerm + "'"); + let context = createContext(searchTerm, { isPrivate: false }); + let urlbarResults = []; + for (let [uri, tags] of uris) { + urlbarResults.push( + makeBookmarkResult(context, { + uri, + title: "A title", + tags, + }) + ); + } + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...urlbarResults, + ], + }); +} + +const uri1 = "http://site.tld/1"; +const uri2 = "http://site.tld/2"; +const uri3 = "http://site.tld/3"; +const uri4 = "http://site.tld/4"; +const uri5 = "http://site.tld/5"; +const uri6 = "http://site.tld/6"; + +/** + * Properly tags a uri adding it to bookmarks. + * + * @param {string} url + * The URI to tag. + * @param {Array} tags + * The tags to add. + */ +async function tagURI(url, tags) { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url, + title: "A title", + }); + PlacesUtils.tagging.tagURI(Services.io.newURI(url), tags); +} + +/** + * Test bug #408221 + */ +add_task(async function test_tags_search_case_insensitivity() { + // always search in history + bookmarks, no matter what the default is + Services.prefs.setBoolPref("browser.urlbar.suggest.history", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmarks", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.history"); + Services.prefs.clearUserPref("browser.urlbar.suggest.bookmarks"); + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + await tagURI(uri6, ["muD"]); + await tagURI(uri6, ["baR"]); + await tagURI(uri5, ["mud"]); + await tagURI(uri5, ["bar"]); + await tagURI(uri4, ["MUD"]); + await tagURI(uri4, ["BAR"]); + await tagURI(uri3, ["foO"]); + await tagURI(uri2, ["FOO"]); + await tagURI(uri1, ["Foo"]); + + await ensure_tag_results( + [ + [uri1, ["Foo"]], + [uri2, ["Foo"]], + [uri3, ["Foo"]], + ], + "foo" + ); + await ensure_tag_results( + [ + [uri1, ["Foo"]], + [uri2, ["Foo"]], + [uri3, ["Foo"]], + ], + "Foo" + ); + await ensure_tag_results( + [ + [uri1, ["Foo"]], + [uri2, ["Foo"]], + [uri3, ["Foo"]], + ], + "foO" + ); + await ensure_tag_results( + [ + [uri4, ["BAR", "MUD"]], + [uri5, ["BAR", "MUD"]], + [uri6, ["BAR", "MUD"]], + ], + "bar mud" + ); + await ensure_tag_results( + [ + [uri4, ["BAR", "MUD"]], + [uri5, ["BAR", "MUD"]], + [uri6, ["BAR", "MUD"]], + ], + "BAR MUD" + ); + await ensure_tag_results( + [ + [uri4, ["BAR", "MUD"]], + [uri5, ["BAR", "MUD"]], + [uri6, ["BAR", "MUD"]], + ], + "Bar Mud" + ); +}); diff --git a/browser/components/urlbar/tests/unit/test_tags_extendedUnicode.js b/browser/components/urlbar/tests/unit/test_tags_extendedUnicode.js new file mode 100644 index 0000000000..596b439be5 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_tags_extendedUnicode.js @@ -0,0 +1,66 @@ +/* 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/. */ + +/* + * Test autocomplete for non-English URLs that match the tag bug 416214. Also + * test bug 417441 by making sure escaped ascii characters like "+" remain + * escaped. + * + * - add a visit for a page with a non-English URL + * - add a tag for the page + * - search for the tag + * - test number of matches (should be exactly one) + * - make sure the url is decoded + */ + +testEngine_setup(); + +add_task(async function test_tag_match_url() { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + info( + "Make sure tag matches return the right url as well as '+' remain escaped" + ); + let uri1 = Services.io.newURI("http://escaped/ユニコード"); + let uri2 = Services.io.newURI("http://asciiescaped/blocking-firefox3%2B"); + await PlacesTestUtils.addVisits([ + { uri: uri1, title: "title" }, + { uri: uri2, title: "title" }, + ]); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri1, + title: "title", + tags: ["superTag"], + style: ["bookmark-tag"], + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri2, + title: "title", + tags: ["superTag"], + style: ["bookmark-tag"], + }); + let context = createContext("superTag", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri2.spec, + title: "title", + tags: ["superTag"], + }), + makeBookmarkResult(context, { + uri: uri1.spec, + title: "title", + tags: ["superTag"], + }), + ], + }); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_tags_general.js b/browser/components/urlbar/tests/unit/test_tags_general.js new file mode 100644 index 0000000000..c2c620c152 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_tags_general.js @@ -0,0 +1,207 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +testEngine_setup(); + +/** + * Checks the results of a search for `searchTerm`. + * + * @param {Array} uris + * A 2-element array containing [{string} uri, {array} tags}], where `tags` + * is a comma-separated list of the tags expected to appear in the search. + * @param {string} searchTerm + * The term to search for + */ +async function ensure_tag_results(uris, searchTerm) { + print("Searching for '" + searchTerm + "'"); + let context = createContext(searchTerm, { isPrivate: false }); + let urlbarResults = []; + for (let [uri, tags] of uris) { + urlbarResults.push( + makeBookmarkResult(context, { + uri, + title: "A title", + tags, + }) + ); + } + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...urlbarResults, + ], + }); +} + +var uri1 = "http://site.tld/1/aaa"; +var uri2 = "http://site.tld/2/bbb"; +var uri3 = "http://site.tld/3/aaa"; +var uri4 = "http://site.tld/4/bbb"; +var uri5 = "http://site.tld/5/aaa"; +var uri6 = "http://site.tld/6/bbb"; + +var tests = [ + () => + ensure_tag_results( + [ + [uri1, ["foo"]], + [uri4, ["foo bar"]], + [uri6, ["foo bar cheese"]], + ], + "foo" + ), + () => ensure_tag_results([[uri1, ["foo"]]], "foo aaa"), + () => + ensure_tag_results( + [ + [uri4, ["foo bar"]], + [uri6, ["foo bar cheese"]], + ], + "foo bbb" + ), + () => + ensure_tag_results( + [ + [uri2, ["bar"]], + [uri4, ["foo bar"]], + [uri5, ["bar cheese"]], + [uri6, ["foo bar cheese"]], + ], + "bar" + ), + () => ensure_tag_results([[uri5, ["bar cheese"]]], "bar aaa"), + () => + ensure_tag_results( + [ + [uri2, ["bar"]], + [uri4, ["foo bar"]], + [uri6, ["foo bar cheese"]], + ], + "bar bbb" + ), + () => + ensure_tag_results( + [ + [uri3, ["cheese"]], + [uri5, ["bar cheese"]], + [uri6, ["foo bar cheese"]], + ], + "cheese" + ), + () => + ensure_tag_results( + [ + [uri3, ["cheese"]], + [uri5, ["bar cheese"]], + ], + "chees aaa" + ), + () => ensure_tag_results([[uri6, ["foo bar cheese"]]], "chees bbb"), + () => + ensure_tag_results( + [ + [uri4, ["foo bar"]], + [uri6, ["foo bar cheese"]], + ], + "fo bar" + ), + () => ensure_tag_results([], "fo bar aaa"), + () => + ensure_tag_results( + [ + [uri4, ["foo bar"]], + [uri6, ["foo bar cheese"]], + ], + "fo bar bbb" + ), + () => + ensure_tag_results( + [ + [uri4, ["foo bar"]], + [uri6, ["foo bar cheese"]], + ], + "ba foo" + ), + () => ensure_tag_results([], "ba foo aaa"), + () => + ensure_tag_results( + [ + [uri4, ["foo bar"]], + [uri6, ["foo bar cheese"]], + ], + "ba foo bbb" + ), + () => + ensure_tag_results( + [ + [uri5, ["bar cheese"]], + [uri6, ["foo bar cheese"]], + ], + "ba chee" + ), + () => ensure_tag_results([[uri5, ["bar cheese"]]], "ba chee aaa"), + () => ensure_tag_results([[uri6, ["foo bar cheese"]]], "ba chee bbb"), + () => + ensure_tag_results( + [ + [uri5, ["bar cheese"]], + [uri6, ["foo bar cheese"]], + ], + "cheese bar" + ), + () => ensure_tag_results([[uri5, ["bar cheese"]]], "cheese bar aaa"), + () => ensure_tag_results([[uri6, ["foo bar cheese"]]], "chees bar bbb"), + () => ensure_tag_results([[uri6, ["foo bar cheese"]]], "cheese bar foo"), + () => ensure_tag_results([], "foo bar cheese aaa"), + () => ensure_tag_results([[uri6, ["foo bar cheese"]]], "foo bar cheese bbb"), +]; + +/** + * Properly tags a uri adding it to bookmarks. + * + * @param {string} url + * The URI to tag. + * @param {Array} tags + * The tags to add. + */ +async function tagURI(url, tags) { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url, + title: "A title", + }); + PlacesUtils.tagging.tagURI(Services.io.newURI(url), tags); +} + +/** + * Test history autocomplete + */ +add_task(async function test_history_autocomplete_tags() { + // always search in history + bookmarks, no matter what the default is + Services.prefs.setBoolPref("browser.urlbar.suggest.history", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmarks", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.history"); + Services.prefs.clearUserPref("browser.urlbar.suggest.bookmarks"); + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + await tagURI(uri6, ["foo bar cheese"]); + await tagURI(uri5, ["bar cheese"]); + await tagURI(uri4, ["foo bar"]); + await tagURI(uri3, ["cheese"]); + await tagURI(uri2, ["bar"]); + await tagURI(uri1, ["foo"]); + + for (let tagTest of tests) { + await tagTest(); + } +}); diff --git a/browser/components/urlbar/tests/unit/test_tags_matchBookmarkTitles.js b/browser/components/urlbar/tests/unit/test_tags_matchBookmarkTitles.js new file mode 100644 index 0000000000..98d12ebe32 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_tags_matchBookmarkTitles.js @@ -0,0 +1,42 @@ +/* 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/. */ + +/* + * Test bug 416211 to make sure results that match the tag show the bookmark + * title instead of the page title. + */ + +testEngine_setup(); + +add_task(async function test_tag_match_has_bookmark_title() { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + info("Make sure the tag match gives the bookmark title"); + let uri = Services.io.newURI("http://theuri/"); + await PlacesTestUtils.addVisits({ uri, title: "Page title" }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri, + title: "Bookmark title", + tags: ["superTag"], + }); + let context = createContext("superTag", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri.spec, + title: "Bookmark title", + tags: ["superTag"], + }), + ], + }); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_tags_returnedInSearches.js b/browser/components/urlbar/tests/unit/test_tags_returnedInSearches.js new file mode 100644 index 0000000000..d5f18278fd --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_tags_returnedInSearches.js @@ -0,0 +1,125 @@ +/* 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/. */ + +/** + * Test bug 418257 by making sure tags are returned with the title as part of + * the "comment" if there are tags even if we didn't match in the tags. They + * are separated from the title by a endash. + */ + +testEngine_setup(); + +add_task(async function test() { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + }); + + let uri1 = Services.io.newURI("http://page1"); + let uri2 = Services.io.newURI("http://page2"); + let uri3 = Services.io.newURI("http://page3"); + let uri4 = Services.io.newURI("http://page4"); + await PlacesTestUtils.addVisits([ + { uri: uri1, title: "tagged" }, + { uri: uri2, title: "tagged" }, + { uri: uri3, title: "tagged" }, + { uri: uri4, title: "tagged" }, + ]); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri1, + title: "tagged", + tags: ["tag1"], + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri2, + title: "tagged", + tags: ["tag1", "tag2"], + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri3, + title: "tagged", + tags: ["tag1", "tag3"], + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri4, + title: "tagged", + tags: ["tag1", "tag2", "tag3"], + }); + info("Make sure tags come back in the title when matching tags"); + let context = createContext("page1 tag", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri1.spec, + title: "tagged", + tags: ["tag1"], + }), + ], + }); + + info("Check tags in title for page2"); + context = createContext("page2 tag", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri2.spec, + title: "tagged", + tags: ["tag1", "tag2"], + }), + ], + }); + + info("Tags do not appear when not matching the tag"); + context = createContext("page3", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri3.spec, + title: "tagged", + tags: [], + }), + ], + }); + + info("Extra test just to make sure we match the title"); + context = createContext("tag2", { isPrivate: true }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri4.spec, + title: "tagged", + tags: ["tag2"], + }), + makeBookmarkResult(context, { + uri: uri2.spec, + title: "tagged", + tags: ["tag2"], + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_tokenizer.js b/browser/components/urlbar/tests/unit/test_tokenizer.js new file mode 100644 index 0000000000..835d1a5909 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_tokenizer.js @@ -0,0 +1,449 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_tokenizer() { + let testContexts = [ + { desc: "Empty string", searchString: "", expectedTokens: [] }, + { desc: "Spaces string", searchString: " ", expectedTokens: [] }, + { + desc: "Single word string", + searchString: "test", + expectedTokens: [{ value: "test", type: UrlbarTokenizer.TYPE.TEXT }], + }, + { + desc: "Multi word string with mixed whitespace types", + searchString: " test1 test2\u1680test3\u2004test4\u1680", + expectedTokens: [ + { value: "test1", type: UrlbarTokenizer.TYPE.TEXT }, + { value: "test2", type: UrlbarTokenizer.TYPE.TEXT }, + { value: "test3", type: UrlbarTokenizer.TYPE.TEXT }, + { value: "test4", type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "separate restriction char at beginning", + searchString: `${UrlbarTokenizer.RESTRICT.BOOKMARK} test`, + expectedTokens: [ + { + value: UrlbarTokenizer.RESTRICT.BOOKMARK, + type: UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK, + }, + { value: "test", type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "separate restriction char at end", + searchString: `test ${UrlbarTokenizer.RESTRICT.BOOKMARK}`, + expectedTokens: [ + { value: "test", type: UrlbarTokenizer.TYPE.TEXT }, + { + value: UrlbarTokenizer.RESTRICT.BOOKMARK, + type: UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK, + }, + ], + }, + { + desc: "boundary restriction char at end", + searchString: `test${UrlbarTokenizer.RESTRICT.BOOKMARK}`, + expectedTokens: [ + { + value: `test${UrlbarTokenizer.RESTRICT.BOOKMARK}`, + type: UrlbarTokenizer.TYPE.TEXT, + }, + ], + }, + { + desc: "boundary search restriction char at end", + searchString: `test${UrlbarTokenizer.RESTRICT.SEARCH}`, + expectedTokens: [ + { value: "test", type: UrlbarTokenizer.TYPE.TEXT }, + { + value: UrlbarTokenizer.RESTRICT.SEARCH, + type: UrlbarTokenizer.TYPE.RESTRICT_SEARCH, + }, + ], + }, + { + desc: "separate restriction char in the middle", + searchString: `test ${UrlbarTokenizer.RESTRICT.BOOKMARK} test`, + expectedTokens: [ + { value: "test", type: UrlbarTokenizer.TYPE.TEXT }, + { + value: UrlbarTokenizer.RESTRICT.BOOKMARK, + type: UrlbarTokenizer.TYPE.TEXT, + }, + { value: "test", type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "restriction char in the middle", + searchString: `test${UrlbarTokenizer.RESTRICT.BOOKMARK}test`, + expectedTokens: [ + { + value: `test${UrlbarTokenizer.RESTRICT.BOOKMARK}test`, + type: UrlbarTokenizer.TYPE.TEXT, + }, + ], + }, + { + desc: "restriction char in the middle 2", + searchString: `test${UrlbarTokenizer.RESTRICT.BOOKMARK} test`, + expectedTokens: [ + { + value: `test${UrlbarTokenizer.RESTRICT.BOOKMARK}`, + type: UrlbarTokenizer.TYPE.TEXT, + }, + { value: `test`, type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "double boundary restriction char", + searchString: `${UrlbarTokenizer.RESTRICT.BOOKMARK}test${UrlbarTokenizer.RESTRICT.TITLE}`, + expectedTokens: [ + { + value: UrlbarTokenizer.RESTRICT.BOOKMARK, + type: UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK, + }, + { + value: `test${UrlbarTokenizer.RESTRICT.TITLE}`, + type: UrlbarTokenizer.TYPE.TEXT, + }, + ], + }, + { + desc: "double non-combinable restriction char, single char string", + searchString: `t${UrlbarTokenizer.RESTRICT.BOOKMARK}${UrlbarTokenizer.RESTRICT.SEARCH}`, + expectedTokens: [ + { + value: `t${UrlbarTokenizer.RESTRICT.BOOKMARK}`, + type: UrlbarTokenizer.TYPE.TEXT, + }, + { + value: UrlbarTokenizer.RESTRICT.SEARCH, + type: UrlbarTokenizer.TYPE.RESTRICT_SEARCH, + }, + ], + }, + { + desc: "only boundary restriction chars", + searchString: `${UrlbarTokenizer.RESTRICT.BOOKMARK}${UrlbarTokenizer.RESTRICT.TITLE}`, + expectedTokens: [ + { + value: UrlbarTokenizer.RESTRICT.BOOKMARK, + type: UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK, + }, + { + value: UrlbarTokenizer.RESTRICT.TITLE, + type: UrlbarTokenizer.TYPE.RESTRICT_TITLE, + }, + ], + }, + { + desc: "only the boundary restriction char", + searchString: UrlbarTokenizer.RESTRICT.BOOKMARK, + expectedTokens: [ + { + value: UrlbarTokenizer.RESTRICT.BOOKMARK, + type: UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK, + }, + ], + }, + // Some restriction chars may be # or ?, that are also valid path parts. + // The next 2 tests will check we consider those as part of url paths. + { + desc: "boundary # char on path", + searchString: "test/#", + expectedTokens: [ + { value: "test/#", type: UrlbarTokenizer.TYPE.POSSIBLE_URL }, + ], + }, + { + desc: "boundary ? char on path", + searchString: "test/?", + expectedTokens: [ + { value: "test/?", type: UrlbarTokenizer.TYPE.POSSIBLE_URL }, + ], + }, + { + desc: "multiple boundary restriction chars suffix", + searchString: `test ${UrlbarTokenizer.RESTRICT.HISTORY} ${UrlbarTokenizer.RESTRICT.TAG}`, + expectedTokens: [ + { value: "test", type: UrlbarTokenizer.TYPE.TEXT }, + { + value: UrlbarTokenizer.RESTRICT.HISTORY, + type: UrlbarTokenizer.TYPE.TEXT, + }, + { + value: UrlbarTokenizer.RESTRICT.TAG, + type: UrlbarTokenizer.TYPE.RESTRICT_TAG, + }, + ], + }, + { + desc: "multiple boundary restriction chars prefix", + searchString: `${UrlbarTokenizer.RESTRICT.HISTORY} ${UrlbarTokenizer.RESTRICT.TAG} test`, + expectedTokens: [ + { + value: UrlbarTokenizer.RESTRICT.HISTORY, + type: UrlbarTokenizer.TYPE.RESTRICT_HISTORY, + }, + { + value: UrlbarTokenizer.RESTRICT.TAG, + type: UrlbarTokenizer.TYPE.TEXT, + }, + { value: "test", type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "Math with division", + searchString: "3.6/1.2", + expectedTokens: [{ value: "3.6/1.2", type: UrlbarTokenizer.TYPE.TEXT }], + }, + { + desc: "ipv4 in bookmarks", + searchString: `${UrlbarTokenizer.RESTRICT.BOOKMARK} 192.168.1.1:8`, + expectedTokens: [ + { + value: UrlbarTokenizer.RESTRICT.BOOKMARK, + type: UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK, + }, + { value: "192.168.1.1:8", type: UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN }, + ], + }, + { + desc: "email", + searchString: "test@mozilla.com", + expectedTokens: [ + { value: "test@mozilla.com", type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "email2", + searchString: "test.test@mozilla.co.uk", + expectedTokens: [ + { value: "test.test@mozilla.co.uk", type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "protocol", + searchString: "http://test", + expectedTokens: [ + { value: "http://test", type: UrlbarTokenizer.TYPE.POSSIBLE_URL }, + ], + }, + { + desc: "bogus protocol with host (we allow visits to http://///example.com)", + searchString: "http:///test", + expectedTokens: [ + { value: "http:///test", type: UrlbarTokenizer.TYPE.POSSIBLE_URL }, + ], + }, + { + desc: "file protocol with path", + searchString: "file:///home", + expectedTokens: [ + { value: "file:///home", type: UrlbarTokenizer.TYPE.POSSIBLE_URL }, + ], + }, + { + desc: "almost a protocol", + searchString: "http:", + expectedTokens: [ + { value: "http:", type: UrlbarTokenizer.TYPE.POSSIBLE_URL }, + ], + }, + { + desc: "almost a protocol 2", + searchString: "http:/", + expectedTokens: [ + { value: "http:/", type: UrlbarTokenizer.TYPE.POSSIBLE_URL }, + ], + }, + { + desc: "bogus protocol (we allow visits to http://///example.com)", + searchString: "http:///", + expectedTokens: [ + { value: "http:///", type: UrlbarTokenizer.TYPE.POSSIBLE_URL }, + ], + }, + { + desc: "file protocol", + searchString: "file:///", + expectedTokens: [ + { value: "file:///", type: UrlbarTokenizer.TYPE.POSSIBLE_URL }, + ], + }, + { + desc: "userinfo", + searchString: "user:pass@test", + expectedTokens: [ + { value: "user:pass@test", type: UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN }, + ], + }, + { + desc: "domain", + searchString: "www.mozilla.org", + expectedTokens: [ + { + value: "www.mozilla.org", + type: UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN, + }, + ], + }, + { + desc: "data uri", + searchString: "data:text/plain,Content", + expectedTokens: [ + { + value: "data:text/plain,Content", + type: UrlbarTokenizer.TYPE.POSSIBLE_URL, + }, + ], + }, + { + desc: "ipv6", + searchString: "[2001:db8::1]", + expectedTokens: [ + { value: "[2001:db8::1]", type: UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN }, + ], + }, + { + desc: "numeric domain", + searchString: "test1001.com", + expectedTokens: [ + { value: "test1001.com", type: UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN }, + ], + }, + { + desc: "invalid ip", + searchString: "192.2134.1.2", + expectedTokens: [ + { value: "192.2134.1.2", type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "ipv4", + searchString: "1.2.3.4", + expectedTokens: [ + { value: "1.2.3.4", type: UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN }, + ], + }, + { + desc: "host/path", + searchString: "test/test", + expectedTokens: [ + { value: "test/test", type: UrlbarTokenizer.TYPE.POSSIBLE_URL }, + ], + }, + { + desc: "percent encoded string", + searchString: "%E6%97%A5%E6%9C%AC", + expectedTokens: [ + { value: "%E6%97%A5%E6%9C%AC", type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "Uppercase", + searchString: "TEST", + expectedTokens: [{ value: "TEST", type: UrlbarTokenizer.TYPE.TEXT }], + }, + { + desc: "Mixed case 1", + searchString: "TeSt", + expectedTokens: [{ value: "TeSt", type: UrlbarTokenizer.TYPE.TEXT }], + }, + { + desc: "Mixed case 2", + searchString: "tEsT", + expectedTokens: [{ value: "tEsT", type: UrlbarTokenizer.TYPE.TEXT }], + }, + { + desc: "Uppercase with spaces", + searchString: "TEST EXAMPLE", + expectedTokens: [ + { value: "TEST", type: UrlbarTokenizer.TYPE.TEXT }, + { value: "EXAMPLE", type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "Mixed case with spaces", + searchString: "TeSt eXaMpLe", + expectedTokens: [ + { value: "TeSt", type: UrlbarTokenizer.TYPE.TEXT }, + { value: "eXaMpLe", type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "plain number", + searchString: "1001", + expectedTokens: [{ value: "1001", type: UrlbarTokenizer.TYPE.TEXT }], + }, + { + desc: "data uri with spaces", + searchString: "data:text/html,oh hi?", + expectedTokens: [ + { + value: "data:text/html,oh hi?", + type: UrlbarTokenizer.TYPE.POSSIBLE_URL, + }, + ], + }, + { + desc: "data uri with spaces ignored with other tokens", + searchString: "hi data:text/html,oh hi?", + expectedTokens: [ + { + value: "hi", + type: UrlbarTokenizer.TYPE.TEXT, + }, + { + value: "data:text/html,oh", + type: UrlbarTokenizer.TYPE.POSSIBLE_URL, + }, + { + value: "hi", + type: UrlbarTokenizer.TYPE.TEXT, + }, + { + value: UrlbarTokenizer.RESTRICT.SEARCH, + type: UrlbarTokenizer.TYPE.RESTRICT_SEARCH, + }, + ], + }, + { + desc: "whitelisted host", + searchString: "test whitelisted", + expectedTokens: [ + { + value: "test", + type: UrlbarTokenizer.TYPE.TEXT, + }, + { + value: "whitelisted", + type: UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN, + }, + ], + }, + ]; + + Services.prefs.setBoolPref("browser.fixup.domainwhitelist.whitelisted", true); + + for (let queryContext of testContexts) { + info(queryContext.desc); + queryContext.trimmedSearchString = queryContext.searchString.trim(); + for (let token of queryContext.expectedTokens) { + token.lowerCaseValue = token.value.toLocaleLowerCase(); + } + let newQueryContext = UrlbarTokenizer.tokenize(queryContext); + Assert.equal( + queryContext, + newQueryContext, + "The queryContext object is the same" + ); + Assert.deepEqual( + queryContext.tokens, + queryContext.expectedTokens, + "Check the expected tokens" + ); + } +}); diff --git a/browser/components/urlbar/tests/unit/test_trimming.js b/browser/components/urlbar/tests/unit/test_trimming.js new file mode 100644 index 0000000000..bf90f69d9f --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_trimming.js @@ -0,0 +1,171 @@ +/* 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/. */ + +add_setup(async function () { + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); +}); + +add_task(async function test_untrimmed_secure_www() { + info("Searching for untrimmed https://www entry"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("https://www.mozilla.org/test/"), + }); + let context = createContext("mo", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/", + completed: "https://www.mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "https://www.mozilla.org/", + fallbackTitle: UrlbarTestUtils.trimURL("https://www.mozilla.org"), + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://www.mozilla.org/test/", + title: "test visit for https://www.mozilla.org/test/", + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_secure_www_path() { + info("Searching for untrimmed https://www entry with path"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("https://www.mozilla.org/test/"), + }); + let context = createContext("mozilla.org/t", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/test/", + completed: "https://www.mozilla.org/test/", + matches: [ + makeVisitResult(context, { + uri: "https://www.mozilla.org/test/", + title: "test visit for https://www.mozilla.org/test/", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_secure() { + info("Searching for untrimmed https:// entry"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("https://mozilla.org/test/"), + }); + let context = createContext("mo", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/", + completed: "https://mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "https://mozilla.org/", + fallbackTitle: UrlbarTestUtils.trimURL("https://mozilla.org"), + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://mozilla.org/test/", + title: "test visit for https://mozilla.org/test/", + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_secure_path() { + info("Searching for untrimmed https:// entry with path"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("https://mozilla.org/test/"), + }); + let context = createContext("mozilla.org/t", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/test/", + completed: "https://mozilla.org/test/", + matches: [ + makeVisitResult(context, { + uri: "https://mozilla.org/test/", + title: "test visit for https://mozilla.org/test/", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_www() { + info("Searching for untrimmed http://www entry"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://www.mozilla.org/test/"), + }); + let context = createContext("mo", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/", + completed: "http://www.mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://www.mozilla.org/", + fallbackTitle: UrlbarTestUtils.trimURL("http://www.mozilla.org"), + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://www.mozilla.org/test/", + title: "test visit for http://www.mozilla.org/test/", + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_www_path() { + info("Searching for untrimmed http://www entry with path"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://www.mozilla.org/test/"), + }); + let context = createContext("mozilla.org/t", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/test/", + completed: "http://www.mozilla.org/test/", + matches: [ + makeVisitResult(context, { + uri: "http://www.mozilla.org/test/", + title: "test visit for http://www.mozilla.org/test/", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_escaped_chars() { + info("Searching for URL with characters that are normally escaped"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("https://www.mozilla.org/啊-test"), + }); + let context = createContext("https://www.mozilla.org/啊-test", { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "https://www.mozilla.org/%E5%95%8A-test", + title: "test visit for https://www.mozilla.org/%E5%95%8A-test", + iconUri: "page-icon:https://www.mozilla.org/%E5%95%8A-test", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_unitConversion.js b/browser/components/urlbar/tests/unit/test_unitConversion.js new file mode 100644 index 0000000000..ab9ea9bca4 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_unitConversion.js @@ -0,0 +1,503 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Unit test for unit conversion module. + */ + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderUnitConversion: + "resource:///modules/UrlbarProviderUnitConversion.sys.mjs", +}); + +const TEST_DATA = [ + { + category: "angle", + cases: [ + { queryString: "1 d to d", expected: "1 deg" }, + { queryString: "-1 d to d", expected: "-1 deg" }, + { queryString: "1 d in d", expected: "1 deg" }, + { queryString: "1 d = d", expected: "1 deg" }, + { queryString: "1 D=D", expected: "1 deg" }, + { queryString: "1 d to degree", expected: "1 deg" }, + { queryString: "2 d to degree", expected: "2 deg" }, + { + queryString: "1 d to radian", + expected: `${round(Math.PI / 180)} radian`, + }, + { + queryString: "2 d to radian", + expected: `${round((Math.PI / 180) * 2)} radian`, + }, + { queryString: "1 d to rad", expected: `${round(Math.PI / 180)} radian` }, + { queryString: "1 d to r", expected: `${round(Math.PI / 180)} radian` }, + { queryString: "1 d to gradian", expected: `${round(1 / 0.9)} gradian` }, + { queryString: "1 d to g", expected: `${round(1 / 0.9)} gradian` }, + { queryString: "1 d to minute", expected: "60 min" }, + { queryString: "1 d to min", expected: "60 min" }, + { queryString: "1 d to m", expected: "60 min" }, + { queryString: "1 d to second", expected: "3,600 sec" }, + { queryString: "1 d to sec", expected: "3,600 sec" }, + { queryString: "1 d to s", expected: "3,600 sec" }, + { queryString: "1 d to sign", expected: `${round(1 / 30)} sign` }, + { queryString: "1 d to mil", expected: `${round(1 / 0.05625)} mil` }, + { + queryString: "1 d to revolution", + expected: `${round(1 / 360)} revolution`, + }, + { queryString: "1 d to circle", expected: `${round(1 / 360)} circle` }, + { queryString: "1 d to turn", expected: `${round(1 / 360)} turn` }, + { queryString: "1 d to quadrant", expected: `${round(1 / 90)} quadrant` }, + { + queryString: "1 d to rightangle", + expected: `${round(1 / 90)} rightangle`, + }, + { queryString: "1 d to sextant", expected: `${round(1 / 60)} sextant` }, + { queryString: "1 degree to d", expected: "1 deg" }, + { queryString: "1 radian to d", expected: `${round(180 / Math.PI)} deg` }, + { + queryString: "1 r to g", + expected: `${round(180 / Math.PI / 0.9)} gradian`, + }, + ], + }, + { + category: "force", + cases: [ + { queryString: "1 n to n", expected: "1 newton" }, + { queryString: "-1 n to n", expected: "-1 newton" }, + { queryString: "1 n in n", expected: "1 newton" }, + { queryString: "1 n = n", expected: "1 newton" }, + { queryString: "1 N=N", expected: "1 newton" }, + { queryString: "1 n to newton", expected: "1 newton" }, + { queryString: "1 n to kilonewton", expected: "0.001 kilonewton" }, + { queryString: "1 n to kn", expected: "0.001 kilonewton" }, + { + queryString: "1 n to gram-force", + expected: `${round(101.9716213)} gram-force`, + }, + { + queryString: "1 n to gf", + expected: `${round(101.9716213)} gram-force`, + }, + { + queryString: "1 n to kilogram-force", + expected: `${round(0.1019716213)} kilogram-force`, + }, + { + queryString: "1 n to kgf", + expected: `${round(0.1019716213)} kilogram-force`, + }, + { + queryString: "1 n to ton-force", + expected: `${round(0.0001019716213)} ton-force`, + }, + { + queryString: "1 n to tf", + expected: `${round(0.0001019716213)} ton-force`, + }, + { + queryString: "1 n to exanewton", + expected: `${round(1.0e-18)} exanewton`, + }, + { queryString: "1 n to en", expected: `${round(1.0e-18)} exanewton` }, + { + queryString: "1 n to petanewton", + expected: `${round(1.0e-15)} petanewton`, + }, + { queryString: "1 n to PN", expected: `${round(1.0e-15)} petanewton` }, + { queryString: "1 n to Pn", expected: `${round(1.0e-15)} petanewton` }, + { + queryString: "1 n to teranewton", + expected: `${round(1.0e-12)} teranewton`, + }, + { queryString: "1 n to tn", expected: `${round(1.0e-12)} teranewton` }, + { + queryString: "1 n to giganewton", + expected: `${round(1.0e-9)} giganewton`, + }, + { queryString: "1 n to gn", expected: `${round(1.0e-9)} giganewton` }, + { queryString: "1 n to meganewton", expected: "0.000001 meganewton" }, + { queryString: "1 n to MN", expected: "0.000001 meganewton" }, + { queryString: "1 n to Mn", expected: "0.000001 meganewton" }, + { queryString: "1 n to hectonewton", expected: "0.01 hectonewton" }, + { queryString: "1 n to hn", expected: "0.01 hectonewton" }, + { queryString: "1 n to dekanewton", expected: "0.1 dekanewton" }, + { queryString: "1 n to dan", expected: "0.1 dekanewton" }, + { queryString: "1 n to decinewton", expected: "10 decinewton" }, + { queryString: "1 n to dn", expected: "10 decinewton" }, + { queryString: "1 n to centinewton", expected: "100 centinewton" }, + { queryString: "1 n to cn", expected: "100 centinewton" }, + { queryString: "1 n to millinewton", expected: "1000 millinewton" }, + { queryString: "1 n to mn", expected: "1000 millinewton" }, + { queryString: "1 n to micronewton", expected: "1000000 micronewton" }, + { queryString: "1 n to µn", expected: "1000000 micronewton" }, + { + queryString: "1 n to nanonewton", + expected: "1000000000 nanonewton", + }, + { queryString: "1 n to nn", expected: "1000000000 nanonewton" }, + { + queryString: "1 n to piconewton", + expected: "1000000000000 piconewton", + }, + { queryString: "1 n to pn", expected: "1000000000000 piconewton" }, + { + queryString: "1 n to femtonewton", + expected: "1000000000000000 femtonewton", + }, + { queryString: "1 n to fn", expected: "1000000000000000 femtonewton" }, + { + queryString: "1 n to attonewton", + expected: "1000000000000000000 attonewton", + }, + { queryString: "1 n to an", expected: "1000000000000000000 attonewton" }, + { queryString: "1 n to dyne", expected: "100000 dyne" }, + { queryString: "1 n to dyn", expected: "100000 dyne" }, + { queryString: "1 n to joule/meter", expected: "1 joule/meter" }, + { queryString: "1 n to j/m", expected: "1 joule/meter" }, + { + queryString: "1 n to joule/centimeter", + expected: "100 joule/centimeter", + }, + { queryString: "1 n to j/cm", expected: "100 joule/centimeter" }, + { + queryString: "1 n to ton-force-short", + expected: `${round(0.0001124045)} ton-force-short`, + }, + { + queryString: "1 n to short", + expected: `${round(0.0001124045)} ton-force-short`, + }, + { + queryString: "1 n to ton-force-long", + expected: `${round(0.0001003611)} ton-force-long`, + }, + { + queryString: "1 n to tonf", + expected: `${round(0.0001003611)} ton-force-long`, + }, + { + queryString: "1 n to kip-force", + expected: `${round(0.0002248089)} kip-force`, + }, + { + queryString: "1 n to kipf", + expected: `${round(0.0002248089)} kip-force`, + }, + { + queryString: "1 n to pound-force", + expected: `${round(0.2248089431)} pound-force`, + }, + { + queryString: "1 n to lbf", + expected: `${round(0.2248089431)} pound-force`, + }, + { + queryString: "1 n to ounce-force", + expected: `${round(3.5969430896)} ounce-force`, + }, + { + queryString: "1 n to ozf", + expected: `${round(3.5969430896)} ounce-force`, + }, + { + queryString: "1 n to poundal", + expected: `${round(7.2330138512)} poundal`, + }, + { queryString: "1 n to pdl", expected: `${round(7.2330138512)} poundal` }, + { queryString: "1 n to pond", expected: `${round(101.9716213)} pond` }, + { queryString: "1 n to p", expected: `${round(101.9716213)} pond` }, + { + queryString: "1 n to kilopond", + expected: `${round(0.1019716213)} kilopond`, + }, + { queryString: "1 n to kp", expected: `${round(0.1019716213)} kilopond` }, + { queryString: "1 kilonewton to n", expected: "1000 newton" }, + ], + }, + { + category: "length", + cases: [ + { queryString: "1 meter to meter", expected: "1 m" }, + { queryString: "-1 meter to meter", expected: "-1 m" }, + { queryString: "1 meter in meter", expected: "1 m" }, + { queryString: "1 meter = meter", expected: "1 m" }, + { queryString: "1 METER=METER", expected: "1 m" }, + { queryString: "1 m to meter", expected: "1 m" }, + { queryString: "1 m to nanometer", expected: "1000000000 nanometer" }, + { queryString: "1 m to micrometer", expected: "1000000 micrometer" }, + { queryString: "1 m to millimeter", expected: "1,000 mm" }, + { queryString: "1 m to mm", expected: "1,000 mm" }, + { queryString: "1 m to centimeter", expected: "100 cm" }, + { queryString: "1 m to cm", expected: "100 cm" }, + { queryString: "1 m to kilometer", expected: "0.001 km" }, + { queryString: "1 m to km", expected: "0.001 km" }, + { queryString: "1 m to mile", expected: `${round(0.0006213689)} mi` }, + { queryString: "1 m to mi", expected: `${round(0.0006213689)} mi` }, + { queryString: "1 m to yard", expected: `${round(1.0936132983)} yd` }, + { queryString: "1 m to yd", expected: `${round(1.0936132983)} yd` }, + { queryString: "1 m to foot", expected: `${round(3.280839895)} ft` }, + { queryString: "1 m to ft", expected: `${round(3.280839895)} ft` }, + { queryString: "1 m to inch", expected: `${round(39.37007874)} in` }, + { queryString: "1 inch to m", expected: `${round(1 / 39.37007874)} m` }, + ], + }, + { + category: "mass", + cases: [ + { queryString: "1 kg to kg", expected: "1 kg" }, + { queryString: "-1 kg to kg", expected: "-1 kg" }, + { queryString: "1 kg in kg", expected: "1 kg" }, + { queryString: "1 kg = kg", expected: "1 kg" }, + { queryString: "1 KG=KG", expected: "1 kg" }, + { queryString: "1 kg to kilogram", expected: "1 kg" }, + { queryString: "1 kg to gram", expected: "1,000 g" }, + { queryString: "1 kg to g", expected: "1,000 g" }, + { queryString: "1 kg to milligram", expected: "1000000 milligram" }, + { queryString: "1 kg to mg", expected: "1000000 milligram" }, + { queryString: "1 kg to ton", expected: "0.001 ton" }, + { queryString: "1 kg to t", expected: "0.001 ton" }, + { + queryString: "1 kg to longton", + expected: `${round(0.0009842073)} longton`, + }, + { + queryString: "1 kg to l.t.", + expected: `${round(0.0009842073)} longton`, + }, + { + queryString: "1 kg to l/t", + expected: `${round(0.0009842073)} longton`, + }, + { + queryString: "1 kg to shortton", + expected: `${round(0.0011023122)} shortton`, + }, + { + queryString: "1 kg to s.t.", + expected: `${round(0.0011023122)} shortton`, + }, + { + queryString: "1 kg to s/t", + expected: `${round(0.0011023122)} shortton`, + }, + { + queryString: "1 kg to pound", + expected: `${round(2.2046244202)} lb`, + }, + { queryString: "1 kg to lbs", expected: `${round(2.2046244202)} lb` }, + { + queryString: "1 kg to lb", + expected: `${round(2.2046244202)} lb`, + }, + { + queryString: "1 kg to ounce", + expected: `${round(35.273990723)} oz`, + }, + { queryString: "1 kg to oz", expected: `${round(35.273990723)} oz` }, + { queryString: "1 kg to carat", expected: "5000 carat" }, + { queryString: "1 kg to ffd", expected: "5000 ffd" }, + { queryString: "1 ffd to kg", expected: `${round(1 / 5000)} kg` }, + ], + }, + { + category: "temperature", + cases: [ + { queryString: "0 c to c", expected: "0°C" }, + { queryString: "0 c in c", expected: "0°C" }, + { queryString: "0 c = c", expected: "0°C" }, + { queryString: "0 C=C", expected: "0°C" }, + { queryString: "0 c to celsius", expected: "0°C" }, + { queryString: "0 c to kelvin", expected: "273.15 kelvin" }, + { queryString: "0 c to k", expected: "273.15 kelvin" }, + { queryString: "10 c to k", expected: "283.15 kelvin" }, + { queryString: "0 c to fahrenheit", expected: "32°F" }, + { queryString: "0 c to f", expected: "32°F" }, + { queryString: "10 c to f", expected: `${round(10 * 1.8 + 32)}°F` }, + { + queryString: "10 f to kelvin", + expected: `${round((10 - 32) / 1.8 + 273.15)} kelvin`, + }, + { queryString: "-10 c to f", expected: "14°F" }, + ], + }, + { + category: "timezone", + cases: [ + { queryString: "0 utc to utc", expected: "00:00 UTC" }, + { queryString: "0 utc in utc", expected: "00:00 UTC" }, + { queryString: "0 utc = utc", expected: "00:00 UTC" }, + { queryString: "0 UTC=UTC", expected: "00:00 UTC" }, + { queryString: "11 pm utc to utc", expected: "11:00 PM UTC" }, + { queryString: "11 am utc to utc", expected: "11:00 AM UTC" }, + { queryString: "11:30 utc to utc", expected: "11:30 UTC" }, + { queryString: "11:30 PM utc to utc", expected: "11:30 PM UTC" }, + { queryString: "1 utc to idlw", expected: "13:00 IDLW" }, + { queryString: "1 pm utc to idlw", expected: "1:00 AM IDLW" }, + { queryString: "1 am utc to idlw", expected: "1:00 PM IDLW" }, + { queryString: "1 utc to idlw", expected: "13:00 IDLW" }, + { queryString: "1 PM utc to idlw", expected: "1:00 AM IDLW" }, + { queryString: "0 utc to nt", expected: "13:00 NT" }, + { queryString: "0 utc to hst", expected: "14:00 HST" }, + { queryString: "0 utc to akst", expected: "15:00 AKST" }, + { queryString: "0 utc to pst", expected: "16:00 PST" }, + { queryString: "0 utc to akdt", expected: "16:00 AKDT" }, + { queryString: "0 utc to mst", expected: "17:00 MST" }, + { queryString: "0 utc to pdt", expected: "17:00 PDT" }, + { queryString: "0 utc to cst", expected: "18:00 CST" }, + { queryString: "0 utc to mdt", expected: "18:00 MDT" }, + { queryString: "0 utc to est", expected: "19:00 EST" }, + { queryString: "0 utc to cdt", expected: "19:00 CDT" }, + { queryString: "0 utc to edt", expected: "20:00 EDT" }, + { queryString: "0 utc to ast", expected: "20:00 AST" }, + { queryString: "0 utc to guy", expected: "21:00 GUY" }, + { queryString: "0 utc to adt", expected: "21:00 ADT" }, + { queryString: "0 utc to at", expected: "22:00 AT" }, + { queryString: "0 utc to gmt", expected: "00:00 GMT" }, + { queryString: "0 utc to z", expected: "00:00 Z" }, + { queryString: "0 utc to wet", expected: "00:00 WET" }, + { queryString: "0 utc to west", expected: "01:00 WEST" }, + { queryString: "0 utc to cet", expected: "01:00 CET" }, + { queryString: "0 utc to bst", expected: "01:00 BST" }, + { queryString: "0 utc to ist", expected: "01:00 IST" }, + { queryString: "0 utc to cest", expected: "02:00 CEST" }, + { queryString: "0 utc to eet", expected: "02:00 EET" }, + { queryString: "0 utc to eest", expected: "03:00 EEST" }, + { queryString: "0 utc to msk", expected: "03:00 MSK" }, + { queryString: "0 utc to msd", expected: "04:00 MSD" }, + { queryString: "0 utc to zp4", expected: "04:00 ZP4" }, + { queryString: "0 utc to zp5", expected: "05:00 ZP5" }, + { queryString: "0 utc to zp6", expected: "06:00 ZP6" }, + { queryString: "0 utc to wast", expected: "07:00 WAST" }, + { queryString: "0 utc to awst", expected: "08:00 AWST" }, + { queryString: "0 utc to wst", expected: "08:00 WST" }, + { queryString: "0 utc to jst", expected: "09:00 JST" }, + { queryString: "0 utc to acst", expected: "09:30 ACST" }, + { queryString: "0 utc to aest", expected: "10:00 AEST" }, + { queryString: "0 utc to acdt", expected: "10:30 ACDT" }, + { queryString: "0 utc to aedt", expected: "11:00 AEDT" }, + { queryString: "0 utc to nzst", expected: "12:00 NZST" }, + { queryString: "0 utc to idle", expected: "12:00 IDLE" }, + { queryString: "0 utc to nzd", expected: "13:00 NZD" }, + { queryString: "9:00 jst to utc", expected: "00:00 UTC" }, + { queryString: "8:00 jst to utc", expected: "23:00 UTC" }, + { queryString: "8:00 am jst to utc", expected: "11:00 PM UTC" }, + { queryString: "9:00 jst to pdt", expected: "17:00 PDT" }, + { queryString: "12 pm pst to cet", expected: "9:00 PM CET" }, + { queryString: "12 am pst to cet", expected: "9:00 AM CET" }, + { queryString: "12:30 pm pst to cet", expected: "9:30 PM CET" }, + { queryString: "12:30 am pst to cet", expected: "9:30 AM CET" }, + { queryString: "23 pm pst to cet", expected: "8:00 AM CET" }, + { queryString: "23:30 pm pst to cet", expected: "8:30 AM CET" }, + { + queryString: "10:00 JST to here", + timezone: "UTC", + expected: "01:00 UTC-000", + }, + { + queryString: "1:00 to JST", + timezone: "UTC", + expected: "10:00 JST", + }, + { + queryString: "1 am to JST", + timezone: "UTC", + expected: "10:00 AM JST", + }, + { + queryString: "now to JST", + timezone: "UTC", + assertResult: output => { + const outputRegexResult = /([0-9]+):([0-9]+)/.exec(output); + const outputMinutes = + parseInt(outputRegexResult[1]) * 60 + + parseInt(outputRegexResult[2]); + const nowDate = new Date(); + // Apply JST time difference. + nowDate.setHours(nowDate.getHours() + 9); + let nowMinutes = nowDate.getHours() * 60 + nowDate.getMinutes(); + // When we cross the day between the unit converter calculation and the + // assertion here. + nowMinutes = + outputMinutes > nowMinutes ? nowMinutes + 1440 : nowMinutes; + Assert.lessOrEqual(nowMinutes - outputMinutes, 1); + }, + }, + { + queryString: "now to here", + timezone: "UTC", + assertResult: output => { + const outputRegexResult = /([0-9]+):([0-9]+)/.exec(output); + const outputMinutes = + parseInt(outputRegexResult[1]) * 60 + + parseInt(outputRegexResult[2]); + const nowDate = new Date(); + let nowMinutes = nowDate.getHours() * 60 + nowDate.getMinutes(); + nowMinutes = + outputMinutes > nowMinutes ? nowMinutes + 1440 : nowMinutes; + Assert.lessOrEqual(nowMinutes - outputMinutes, 1); + }, + }, + ], + }, + { + category: "invalid", + cases: [ + { queryString: "1 to cm" }, + { queryString: "1cm to newton" }, + { queryString: "1cm to foo" }, + { queryString: "0:00:00 utc to jst" }, + ], + }, +]; + +add_task(function () { + // Enable unit conversion. + Services.prefs.setBoolPref("browser.urlbar.unitConversion.enabled", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.unitConversion.enabled"); + }); + + for (const { category, cases } of TEST_DATA) { + for (const { queryString, timezone, expected, assertResult } of cases) { + info(`Test "${queryString}" in ${category}`); + + if (timezone) { + info(`Set timezone ${timezone}`); + Cu.getJSTestingFunctions().setTimeZone(timezone); + } + + const context = createContext(queryString); + const isActive = UrlbarProviderUnitConversion.isActive(context); + Assert.equal(isActive, !!expected || !!assertResult); + + if (isActive) { + UrlbarProviderUnitConversion.startQuery(context, (module, result) => { + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.DYNAMIC); + Assert.equal(result.source, UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL); + Assert.equal(result.suggestedIndex, 1); + Assert.equal(result.payload.input, queryString); + + if (expected) { + Assert.equal(result.payload.output, expected); + } else { + assertResult(result.payload.output); + } + }); + } + + if (timezone) { + // Reset timezone to default + Cu.getJSTestingFunctions().setTimeZone(undefined); + } + } + } +}); + +function round(number) { + return parseFloat(number.toPrecision(10)); +} diff --git a/browser/components/urlbar/tests/unit/test_word_boundary_search.js b/browser/components/urlbar/tests/unit/test_word_boundary_search.js new file mode 100644 index 0000000000..7d94fd4379 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_word_boundary_search.js @@ -0,0 +1,401 @@ +/* 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/. */ + +/** + * Test to make sure matches against the url, title, tags are first made on word + * boundaries, instead of in the middle of words, and later are extended to the + * whole words. For this test it is critical to check sorting of the matches. + * + * Make sure we don't try matching one after a CamelCase because the upper-case + * isn't really a word boundary. (bug 429498) + */ + +testEngine_setup(); + +var katakana = ["\u30a8", "\u30c9"]; // E, Do +var ideograph = ["\u4efb", "\u5929", "\u5802"]; // Nin Ten Do + +add_task(async function test_escape() { + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + }); + + await PlacesTestUtils.addVisits([ + { uri: "http://matchme/", title: "title1" }, + { uri: "http://dontmatchme/", title: "title1" }, + { uri: "http://title/1", title: "matchme2" }, + { uri: "http://title/2", title: "dontmatchme3" }, + { uri: "http://tag/1", title: "title1" }, + { uri: "http://tag/2", title: "title1" }, + { uri: "http://crazytitle/", title: "!@#$%^&*()_+{}|:<>?word" }, + { uri: "http://katakana/", title: katakana.join("") }, + { uri: "http://ideograph/", title: ideograph.join("") }, + { uri: "http://camel/pleaseMatchMe/", title: "title1" }, + ]); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://tag/1", + title: "title1", + tags: ["matchme2"], + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://tag/2", + title: "title1", + tags: ["dontmatchme3"], + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("Match 'match' at the beginning or after / or on a CamelCase"); + let context = createContext("match", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://tag/1", + title: "title1", + tags: ["matchme2"], + }), + makeVisitResult(context, { + uri: "http://camel/pleaseMatchMe/", + title: "title1", + }), + makeVisitResult(context, { uri: "http://title/1", title: "matchme2" }), + makeVisitResult(context, { uri: "http://matchme/", title: "title1" }), + makeBookmarkResult(context, { + uri: "http://tag/2", + title: "title1", + tags: ["dontmatchme3"], + }), + makeVisitResult(context, { + uri: "http://title/2", + title: "dontmatchme3", + }), + makeVisitResult(context, { uri: "http://dontmatchme/", title: "title1" }), + ], + }); + + info("Match 'dont' at the beginning or after /"); + context = createContext("dont", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://tag/2", + title: "title1", + tags: ["dontmatchme3"], + }), + makeVisitResult(context, { + uri: "http://title/2", + title: "dontmatchme3", + }), + makeVisitResult(context, { uri: "http://dontmatchme/", title: "title1" }), + ], + }); + + info("Match 'match' at the beginning or after / or on a CamelCase"); + context = createContext("2", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://tag/2", + title: "title1", + }), + makeBookmarkResult(context, { + uri: "http://tag/1", + title: "title1", + tags: ["matchme2"], + }), + makeVisitResult(context, { + uri: "http://title/2", + title: "dontmatchme3", + }), + makeVisitResult(context, { uri: "http://title/1", title: "matchme2" }), + ], + }); + + info("Match 't' at the beginning or after /"); + context = createContext("t", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://tag/2", + title: "title1", + tags: ["dontmatchme3"], + }), + makeBookmarkResult(context, { + uri: "http://tag/1", + title: "title1", + tags: ["matchme2"], + }), + makeVisitResult(context, { + uri: "http://camel/pleaseMatchMe/", + title: "title1", + }), + makeVisitResult(context, { + uri: "http://title/2", + title: "dontmatchme3", + }), + makeVisitResult(context, { uri: "http://title/1", title: "matchme2" }), + makeVisitResult(context, { uri: "http://dontmatchme/", title: "title1" }), + makeVisitResult(context, { uri: "http://matchme/", title: "title1" }), + makeVisitResult(context, { + uri: "http://katakana/", + title: katakana.join(""), + }), + makeVisitResult(context, { + uri: "http://crazytitle/", + title: "!@#$%^&*()_+{}|:<>?word", + }), + ], + }); + + info("Match 'word' after many consecutive word boundaries"); + context = createContext("word", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://crazytitle/", + title: "!@#$%^&*()_+{}|:<>?word", + }), + ], + }); + + info("Match a word boundary '/' for everything"); + context = createContext("/", { isPrivate: false }); + // UNIX platforms can search for a file:// URL by typing a forward slash. + let heuristicSlashResult = + AppConstants.platform == "win" + ? makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }) + : makeVisitResult(context, { + uri: "file:///", + fallbackTitle: "file:///", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }); + await check_results({ + context, + matches: [ + heuristicSlashResult, + makeBookmarkResult(context, { + uri: "http://tag/2", + title: "title1", + }), + makeBookmarkResult(context, { + uri: "http://tag/1", + title: "title1", + }), + makeVisitResult(context, { + uri: "http://camel/pleaseMatchMe/", + title: "title1", + }), + makeVisitResult(context, { + uri: "http://ideograph/", + title: ideograph.join(""), + }), + makeVisitResult(context, { + uri: "http://katakana/", + title: katakana.join(""), + }), + makeVisitResult(context, { + uri: "http://crazytitle/", + title: "!@#$%^&*()_+{}|:<>?word", + }), + makeVisitResult(context, { + uri: "http://title/2", + title: "dontmatchme3", + }), + makeVisitResult(context, { uri: "http://title/1", title: "matchme2" }), + makeVisitResult(context, { uri: "http://dontmatchme/", title: "title1" }), + ], + }); + + info("Match word boundaries '()_' that are among word boundaries"); + context = createContext("()_", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://crazytitle/", + title: "!@#$%^&*()_+{}|:<>?word", + }), + ], + }); + + info("Katakana characters form a string, so match the beginning"); + context = createContext(katakana[0], { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://katakana/", + title: katakana.join(""), + }), + ], + }); + + /* + info("Middle of a katakana word shouldn't be matched"); + await check_autocomplete({ + search: katakana[1], + matches: [ ], + }); +*/ + + info("Ideographs are treated as words so 'nin' is one word"); + context = createContext(ideograph[0], { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://ideograph/", + title: ideograph.join(""), + }), + ], + }); + + info("Ideographs are treated as words so 'ten' is another word"); + context = createContext(ideograph[1], { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://ideograph/", + title: ideograph.join(""), + }), + ], + }); + + info("Ideographs are treated as words so 'do' is yet another word"); + context = createContext(ideograph[2], { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://ideograph/", + title: ideograph.join(""), + }), + ], + }); + + info("Match in the middle. Should just be sorted by frecency."); + context = createContext("ch", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://tag/2", + title: "title1", + tags: ["dontmatchme3"], + }), + makeBookmarkResult(context, { + uri: "http://tag/1", + title: "title1", + tags: ["matchme2"], + }), + makeVisitResult(context, { + uri: "http://camel/pleaseMatchMe/", + title: "title1", + }), + makeVisitResult(context, { + uri: "http://title/2", + title: "dontmatchme3", + }), + makeVisitResult(context, { uri: "http://title/1", title: "matchme2" }), + makeVisitResult(context, { uri: "http://dontmatchme/", title: "title1" }), + makeVisitResult(context, { uri: "http://matchme/", title: "title1" }), + ], + }); + + // Also this test should just be sorted by frecency. + info( + "Don't match one character after a camel-case word boundary (bug 429498). Should just be sorted by frecency." + ); + context = createContext("atch", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://tag/2", + title: "title1", + tags: ["dontmatchme3"], + }), + makeBookmarkResult(context, { + uri: "http://tag/1", + title: "title1", + tags: ["matchme2"], + }), + makeVisitResult(context, { + uri: "http://camel/pleaseMatchMe/", + title: "title1", + }), + makeVisitResult(context, { + uri: "http://title/2", + title: "dontmatchme3", + }), + makeVisitResult(context, { uri: "http://title/1", title: "matchme2" }), + makeVisitResult(context, { uri: "http://dontmatchme/", title: "title1" }), + makeVisitResult(context, { uri: "http://matchme/", title: "title1" }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/xpcshell.toml b/browser/components/urlbar/tests/unit/xpcshell.toml new file mode 100644 index 0000000000..188f4390c7 --- /dev/null +++ b/browser/components/urlbar/tests/unit/xpcshell.toml @@ -0,0 +1,201 @@ +[DEFAULT] +skip-if = ["os == 'android'"] # bug 1730213 +head = "head.js" +firefox-appdir = "browser" +support-files = ["data/engine.xml"] + +["test_000_frecency.js"] + +["test_UrlbarController_integration.js"] + +["test_UrlbarController_telemetry.js"] + +["test_UrlbarController_unit.js"] + +["test_UrlbarPrefs.js"] + +["test_UrlbarQueryContext.js"] + +["test_UrlbarQueryContext_restrictSource.js"] + +["test_UrlbarSearchUtils.js"] + +["test_UrlbarUtils_addToUrlbarHistory.js"] + +["test_UrlbarUtils_copySnakeKeysToCamel.js"] + +["test_UrlbarUtils_getShortcutOrURIAndPostData.js"] + +["test_UrlbarUtils_getTokenMatches.js"] + +["test_UrlbarUtils_skippableTimer.js"] + +["test_UrlbarUtils_unEscapeURIForUI.js"] + +["test_about_urls.js"] + +["test_autofill_adaptiveHistory.js"] + +["test_autofill_bookmarked.js"] + +["test_autofill_do_not_trim.js"] + +["test_autofill_functional.js"] + +["test_autofill_origins.js"] + +["test_autofill_originsAndQueries.js"] + +["test_autofill_origins_alt_frecency.js"] +prefs = [ + "places.frecency.origins.alternative.featureGate=true", + "browser.urlbar.suggest.quickactions=false", +] + +["test_autofill_prefix_fallback.js"] + +["test_autofill_search_engine_aliases.js"] + +["test_autofill_urls.js"] + +["test_avoid_stripping_to_empty_tokens.js"] + +["test_calculator.js"] + +["test_casing.js"] + +["test_dedupe_embedded_url_param.js"] + +["test_dedupe_prefix.js"] + +["test_dedupe_switchTab.js"] + +["test_dont_autofill_cases.js"] + +["test_download_embed_bookmarks.js"] + +["test_empty_search.js"] + +["test_encoded_urls.js"] + +["test_escaping_badEscapedURI.js"] + +["test_escaping_escapeSelf.js"] + +["test_exposure.js"] + +["test_frecency.js"] + +["test_frecency_alternative_nimbus.js"] + +["test_heuristic_cancel.js"] + +["test_hideSponsoredHistory.js"] + +["test_history_bookmark_results_on_search_service_failure.js"] + +["test_keywords.js"] +skip-if = ["os == 'linux'"] # bug 1474616 + +["test_l10nCache.js"] + +["test_local_suggest_prefs.js"] + +["test_match_javascript.js"] + +["test_multi_word_search.js"] + +["test_muxer.js"] + +["test_pages_alt_frecency.js"] +prefs = [ + "places.frecency.pages.alternative.featureGate=true", + "browser.urlbar.suggest.quickactions=false", +] + +["test_protocol_ignore.js"] + +["test_protocol_swap.js"] + +["test_providerAliasEngines.js"] + +["test_providerHeuristicFallback.js"] + +["test_providerHistoryUrlHeuristic.js"] + +["test_providerKeywords.js"] + +["test_providerOmnibox.js"] + +["test_providerOpenTabs.js"] +skip-if = [ + "os == 'mac' && debug", # Bug 1781972 + "os == 'win' && debug", # Bug 1781972 +] + +["test_providerPlaces.js"] + +["test_providerPlaces_duplicate_entries.js"] + +["test_providerPlaces_nonEnglish.js"] + +["test_providerRecentSearches.js"] + +["test_providerTabToSearch.js"] + +["test_providerTabToSearch_partialHost.js"] + +["test_providersManager.js"] + +["test_providersManager_filtering.js"] + +["test_providersManager_maxResults.js"] + +["test_queryScorer.js"] + +["test_query_url.js"] + +["test_quickactions.js"] + +["test_remote_tabs.js"] +skip-if = ["!sync"] + +["test_resultGroups.js"] + +["test_richsuggestions.js"] + +["test_richsuggestions_order.js"] + +["test_search_engine_restyle.js"] + +["test_search_suggestions.js"] + +["test_search_suggestions_aliases.js"] + +["test_search_suggestions_tail.js"] + +["test_special_search.js"] + +["test_suggestedIndex.js"] + +["test_suggestedIndexRelativeToGroup.js"] + +["test_tab_matches.js"] + +["test_tags_caseInsensitivity.js"] + +["test_tags_extendedUnicode.js"] + +["test_tags_general.js"] + +["test_tags_matchBookmarkTitles.js"] + +["test_tags_returnedInSearches.js"] + +["test_tokenizer.js"] + +["test_trimming.js"] + +["test_unitConversion.js"] + +["test_word_boundary_search.js"] diff --git a/browser/components/urlbar/unitconverters/UnitConverterSimple.sys.mjs b/browser/components/urlbar/unitconverters/UnitConverterSimple.sys.mjs new file mode 100644 index 0000000000..47ba0cc856 --- /dev/null +++ b/browser/components/urlbar/unitconverters/UnitConverterSimple.sys.mjs @@ -0,0 +1,243 @@ +/* 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/. */ + +// NOTE: This units table need to be localized upon supporting multi locales +// since it supports en-US only. +// e.g. Should support plugada or funty as well for pound. +const UNITS_GROUPS = [ + { + // Angle + degree: 1, + deg: "degree", + d: "degree", + radian: Math.PI / 180.0, + rad: "radian", + r: "radian", + gradian: 1 / 0.9, + grad: "gradian", + g: "gradian", + minute: 60, + min: "minute", + m: "minute", + second: 3600, + sec: "second", + s: "second", + sign: 1 / 30.0, + mil: 1 / 0.05625, + revolution: 1 / 360.0, + circle: 1 / 360.0, + turn: 1 / 360.0, + quadrant: 1 / 90.0, + rightangle: 1 / 90.0, + sextant: 1 / 60.0, + }, + { + // Force + newton: 1, + n: "newton", + kilonewton: 0.001, + kn: "kilonewton", + "gram-force": 101.9716213, + gf: "gram-force", + "kilogram-force": 0.1019716213, + kgf: "kilogram-force", + "ton-force": 0.0001019716213, + tf: "ton-force", + exanewton: 1.0e-18, + en: "exanewton", + petanewton: 1.0e-15, + PN: "petanewton", + Pn: "petanewton", + teranewton: 1.0e-12, + tn: "teranewton", + giganewton: 1.0e-9, + gn: "giganewton", + meganewton: 0.000001, + MN: "meganewton", + Mn: "meganewton", + hectonewton: 0.01, + hn: "hectonewton", + dekanewton: 0.1, + dan: "dekanewton", + decinewton: 10, + dn: "decinewton", + centinewton: 100, + cn: "centinewton", + millinewton: 1000, + mn: "millinewton", + micronewton: 1000000, + µn: "micronewton", + nanonewton: 1000000000, + nn: "nanonewton", + piconewton: 1000000000000, + pn: "piconewton", + femtonewton: 1000000000000000, + fn: "femtonewton", + attonewton: 1000000000000000000, + an: "attonewton", + dyne: 100000, + dyn: "dyne", + "joule/meter": 1, + "j/m": "joule/meter", + "joule/centimeter": 100, + "j/cm": "joule/centimeter", + "ton-force-short": 0.0001124045, + short: "ton-force-short", + "ton-force-long": 0.0001003611, + tonf: "ton-force-long", + "kip-force": 0.0002248089, + kipf: "kip-force", + "pound-force": 0.2248089431, + lbf: "pound-force", + "ounce-force": 3.5969430896, + ozf: "ounce-force", + poundal: 7.2330138512, + pdl: "poundal", + pond: 101.9716213, + p: "pond", + kilopond: 0.1019716213, + kp: "kilopond", + }, + { + // Length + meter: 1, + m: "meter", + nanometer: 1000000000, + micrometer: 1000000, + millimeter: 1000, + mm: "millimeter", + centimeter: 100, + cm: "centimeter", + kilometer: 0.001, + km: "kilometer", + mile: 0.0006213689, + mi: "mile", + yard: 1.0936132983, + yd: "yard", + foot: 3.280839895, + feet: "foot", + ft: "foot", + inch: 39.37007874, + inches: "inch", + in: "inch", + }, + { + // Mass + kilogram: 1, + kg: "kilogram", + gram: 1000, + g: "gram", + milligram: 1000000, + mg: "milligram", + ton: 0.001, + t: "ton", + longton: 0.0009842073, + "l.t.": "longton", + "l/t": "longton", + shortton: 0.0011023122, + "s.t.": "shortton", + "s/t": "shortton", + pound: 2.2046244202, + lbs: "pound", + lb: "pound", + ounce: 35.273990723, + oz: "ounce", + carat: 5000, + ffd: 5000, + }, +]; + +// There are some units that will be same in lower case in same unit group. +// e.g. Mn: meganewton and mn: millinewton on force group. +// Handle them as case-sensitive. +const CASE_SENSITIVE_UNITS = ["PN", "Pn", "MN", "Mn"]; + +const NUMBER_REGEX = "-?\\d+(?:\\.\\d+)?\\s*"; +const UNIT_REGEX = "[A-Za-zµ0-9_./-]+"; + +// NOTE: This regex need to be localized upon supporting multi locales +// since it supports en-US input format only. +const QUERY_REGEX = new RegExp( + `^(${NUMBER_REGEX})(${UNIT_REGEX})(?:\\s+in\\s+|\\s+to\\s+|\\s*=\\s*)(${UNIT_REGEX})`, + "i" +); + +const DECIMAL_PRECISION = 10; + +/** + * This module converts simple unit such as angle and length. + */ +export class UnitConverterSimple { + /** + * Convert the given search string. + * + * @param {string} searchString + * The string to be converted + * @returns {string} conversion result. + */ + convert(searchString) { + const regexResult = QUERY_REGEX.exec(searchString); + if (!regexResult) { + return null; + } + + const target = findUnitGroup(regexResult[2], regexResult[3]); + + if (!target) { + return null; + } + + const { group, inputUnit, outputUnit } = target; + const inputNumber = Number(regexResult[1]); + const outputNumber = parseFloat( + ((inputNumber / group[inputUnit]) * group[outputUnit]).toPrecision( + DECIMAL_PRECISION + ) + ); + + try { + return new Intl.NumberFormat("en-US", { + style: "unit", + unit: outputUnit, + maximumFractionDigits: DECIMAL_PRECISION, + }).format(outputNumber); + } catch (e) {} + + return `${outputNumber} ${outputUnit}`; + } +} + +/** + * Returns the suitable units for the given two values. + * If could not found suitable unit, returns null. + * + * @param {string} inputUnit + * A set of units to convert, mapped to the `inputUnit` value on the return + * @param {string} outputUnit + * A set of units to convert, mapped to the `outputUnit` value on the return + * @returns {{ inputUnit: string, outputUnit: string }} The suitable units. + */ +function findUnitGroup(inputUnit, outputUnit) { + inputUnit = toSuitableUnit(inputUnit); + outputUnit = toSuitableUnit(outputUnit); + + const group = UNITS_GROUPS.find(ug => ug[inputUnit] && ug[outputUnit]); + + if (!group) { + return null; + } + + const inputValue = group[inputUnit]; + const outputValue = group[outputUnit]; + + return { + group, + inputUnit: typeof inputValue === "string" ? inputValue : inputUnit, + outputUnit: typeof outputValue === "string" ? outputValue : outputUnit, + }; +} + +function toSuitableUnit(unit) { + return CASE_SENSITIVE_UNITS.includes(unit) ? unit : unit.toLowerCase(); +} diff --git a/browser/components/urlbar/unitconverters/UnitConverterTemperature.sys.mjs b/browser/components/urlbar/unitconverters/UnitConverterTemperature.sys.mjs new file mode 100644 index 0000000000..5a78d20577 --- /dev/null +++ b/browser/components/urlbar/unitconverters/UnitConverterTemperature.sys.mjs @@ -0,0 +1,124 @@ +/* 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 ABSOLUTE = ["celsius", "kelvin", "fahrenheit"]; +const ALIAS = ["c", "k", "f"]; +const UNITS = [...ABSOLUTE, ...ALIAS]; + +const NUMBER_REGEX = "-?\\d+(?:\\.\\d+)?\\s*"; +const UNIT_REGEX = "\\w+"; + +// NOTE: This regex need to be localized upon supporting multi locales +// since it supports en-US input format only. +const QUERY_REGEX = new RegExp( + `^(${NUMBER_REGEX})(${UNIT_REGEX})(?:\\s+in\\s+|\\s+to\\s+|\\s*=\\s*)(${UNIT_REGEX})`, + "i" +); + +const DECIMAL_PRECISION = 10; + +/** + * This module converts temperature unit. + */ +export class UnitConverterTemperature { + /** + * Convert the given search string. + * + * @param {string} searchString + * The string to be converted + * @returns {string} conversion result. + */ + convert(searchString) { + const regexResult = QUERY_REGEX.exec(searchString); + if (!regexResult) { + return null; + } + + const target = findUnits(regexResult[2], regexResult[3]); + + if (!target) { + return null; + } + + const { inputUnit, outputUnit } = target; + const inputNumber = Number(regexResult[1]); + const inputChar = inputUnit.charAt(0); + const outputChar = outputUnit.charAt(0); + + let outputNumber; + if (inputChar === outputChar) { + outputNumber = inputNumber; + } else { + outputNumber = this[`${inputChar}2${outputChar}`](inputNumber); + } + + outputNumber = parseFloat(outputNumber.toPrecision(DECIMAL_PRECISION)); + + try { + return new Intl.NumberFormat("en-US", { + style: "unit", + unit: outputUnit, + maximumFractionDigits: DECIMAL_PRECISION, + }).format(outputNumber); + } catch (e) {} + + return `${outputNumber} ${outputUnit}`; + } + + c2k(t) { + return t + 273.15; + } + + c2f(t) { + return t * 1.8 + 32; + } + + k2c(t) { + return t - 273.15; + } + + k2f(t) { + return this.c2f(this.k2c(t)); + } + + f2c(t) { + return (t - 32) / 1.8; + } + + f2k(t) { + return this.c2k(this.f2c(t)); + } +} + +/** + * Returns the suitable units for the given two values. + * If could not found suitable unit, returns null. + * + * @param {string} inputUnit + * A set of units to convert, mapped to the `inputUnit` value on the return + * @param {string} outputUnit + * A set of units to convert, mapped to the `outputUnit` value on the return + * @returns {{ inputUnit: string, outputUnit: string }} The suitable units. + */ +function findUnits(inputUnit, outputUnit) { + inputUnit = inputUnit.toLowerCase(); + outputUnit = outputUnit.toLowerCase(); + + if (!UNITS.includes(inputUnit) || !UNITS.includes(outputUnit)) { + return null; + } + + return { + inputUnit: toAbsoluteUnit(inputUnit), + outputUnit: toAbsoluteUnit(outputUnit), + }; +} + +function toAbsoluteUnit(unit) { + if (unit.length !== 1) { + return unit; + } + + return ABSOLUTE.find(a => a.startsWith(unit)); +} diff --git a/browser/components/urlbar/unitconverters/UnitConverterTimezone.sys.mjs b/browser/components/urlbar/unitconverters/UnitConverterTimezone.sys.mjs new file mode 100644 index 0000000000..50ba924bed --- /dev/null +++ b/browser/components/urlbar/unitconverters/UnitConverterTimezone.sys.mjs @@ -0,0 +1,148 @@ +/* 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 TIMEZONES = { + IDLW: -12, + NT: -11, + HST: -10, + AKST: -9, + PST: -8, + AKDT: -8, + MST: -7, + PDT: -7, + CST: -6, + MDT: -6, + EST: -5, + CDT: -5, + EDT: -4, + AST: -4, + GUY: -3, + ADT: -3, + AT: -2, + UTC: 0, + GMT: 0, + Z: 0, + WET: 0, + WEST: 1, + CET: 1, + BST: 1, + IST: 1, + CEST: 2, + EET: 2, + EEST: 3, + MSK: 3, + MSD: 4, + ZP4: 4, + ZP5: 5, + ZP6: 6, + WAST: 7, + AWST: 8, + WST: 8, + JST: 9, + ACST: 9.5, + ACDT: 10.5, + AEST: 10, + AEDT: 11, + NZST: 12, + IDLE: 12, + NZD: 13, +}; + +const TIME_REGEX = "([0-2]?[0-9])(:([0-5][0-9]))?\\s*([ap]m)?"; +const TIMEZONE_REGEX = "\\w+"; + +// NOTE: This regex need to be localized upon supporting multi locales +// since it supports en-US input format only. +const QUERY_REGEX = new RegExp( + `^(${TIME_REGEX}|now)\\s*(${TIMEZONE_REGEX})?(?:\\s+in\\s+|\\s+to\\s+|\\s*=\\s*)(${TIMEZONE_REGEX}|here)`, + "i" +); + +const KEYWORD_HERE = "HERE"; +const KEYWORD_NOW = "NOW"; + +/** + * This module converts timezone. + */ +export class UnitConverterTimezone { + /** + * Convert the given search string. + * + * @param {string} searchString + * The string to be converted + * @returns {string} conversion result. + */ + convert(searchString) { + const regexResult = QUERY_REGEX.exec(searchString); + if (!regexResult) { + return null; + } + + const inputTime = regexResult[1].toUpperCase(); + const inputTimezone = regexResult[6]?.toUpperCase(); + let outputTimezone = regexResult[7].toUpperCase(); + + if ( + (inputTimezone && + inputTimezone !== KEYWORD_NOW && + !(inputTimezone in TIMEZONES)) || + (outputTimezone !== KEYWORD_HERE && !(outputTimezone in TIMEZONES)) + ) { + return null; + } + + const inputDate = new Date(); + let isMeridiemNeeded = false; + if (inputTime === KEYWORD_NOW) { + inputDate.setUTCHours(inputDate.getHours()); + inputDate.setUTCMinutes(inputDate.getMinutes()); + } else { + // If the input was given as AM/PM, we need to convert it to 24h. + // 12AM is converted to 00, and for PM times we add 12 to the hour value except for 12PM. + // If the input is for example 23PM, we use 23 as the hour - we don't add 12 as this would result in a date increment. + const inputAMPM = regexResult[5]?.toLowerCase() || ""; + const inputHours = + regexResult[2] === "12" && inputAMPM === "am" + ? 0 + : Number(regexResult[2]); + const inputMinutes = regexResult[4] ? Number(regexResult[4]) : 0; + const inputMeridianHourShift = + inputAMPM === "pm" && inputHours < 12 ? 12 : 0; + inputDate.setUTCHours(inputHours + inputMeridianHourShift); + inputDate.setUTCMinutes(inputMinutes); + isMeridiemNeeded = !!inputAMPM; + } + + const inputOffset = inputTimezone + ? TIMEZONES[inputTimezone] * 60 + : -inputDate.getTimezoneOffset(); + let outputOffset; + if (outputTimezone === KEYWORD_HERE) { + outputOffset = -inputDate.getTimezoneOffset(); + const sign = -inputDate.getTimezoneOffset() > 0 ? "+" : "-"; + const hours = parseInt(Math.abs(outputOffset) / 60); + const minutes = formatMinutes((outputOffset % 60) * 60); + outputTimezone = `UTC${sign}${hours}${minutes}`; + } else { + outputOffset = TIMEZONES[outputTimezone] * 60; + } + + const outputDate = new Date(inputDate.getTime()); + outputDate.setUTCMinutes( + outputDate.getUTCMinutes() - inputOffset + outputOffset + ); + + const time = new Intl.DateTimeFormat("en-US", { + timeStyle: "short", + hour12: isMeridiemNeeded, + timeZone: "UTC", + }).format(outputDate); + + return `${time} ${outputTimezone}`; + } +} + +function formatMinutes(minutes) { + return minutes.toString().padStart(2, "0"); +} diff --git a/browser/components/urlbar/unitconverters/moz.build b/browser/components/urlbar/unitconverters/moz.build new file mode 100644 index 0000000000..649522658f --- /dev/null +++ b/browser/components/urlbar/unitconverters/moz.build @@ -0,0 +1,9 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +EXTRA_JS_MODULES += [ + "UnitConverterSimple.sys.mjs", + "UnitConverterTemperature.sys.mjs", + "UnitConverterTimezone.sys.mjs", +] -- cgit v1.2.3